feat: work on the relay — dotfile permissions, relay CLI, JWT mapping#8
feat: work on the relay — dotfile permissions, relay CLI, JWT mapping#8khaliqgant merged 35 commits intomainfrom
Conversation
…ng, and workflows Foundation for combining relayauth + relayfile into a local agent sandbox where every file operation is permission-checked via relay primitives. Core changes: - JWT claim mapping: generate-dev-token.sh emits workspace_id + agent_name for relayfile compatibility - Token types: added workspace_id and agent_name optional fields - Test helpers: updated to include new claims and relayfile audience Relay CLI (scripts/relay/): - relay.sh: up, down, provision, shell, scan, run, doctor, mount, status - dotfile-parser.ts: .agentignore/.agentreadonly with .gitignore syntax - dotfile-compiler.ts: compiles dotfiles to relayfile ACL rules - seed-acl.ts: seeds .relayfile.acl markers via relayfile API - parse-config.ts: relay.yaml parser with scope validation - Zero-config mode: no YAML needed, agent names from dot filenames Specs & RFC: - specs/work-on-the-relay.md: full architecture spec - specs/rfc-dotfile-permissions.md: RFC for Will on dotfile model Workflows: - 111: JWT claim mapping + relay CLI (completed) - 112: dotfile permissions + prereqs (completed) - 113: "relay run" command with FUSE mount integration Trajectories: 6 decisions + 1 reflection recorded via trail Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Zero-dependency core package containing token verification, scope parsing/matching, ACL evaluation, and config helpers. Imported by both the OSS relayauth server and the cloud repo (replacing the duplicated source files in cloud/packages/relayauth). Exports: verifyToken, ScopeChecker, parseScope, matchScope, filePermissionAllows, resolveFilePermissions, parsePermissionRule. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- ci.yml: build, typecheck, test on PR and push to main - publish.yml: manual workflow dispatch to publish @relayauth/types, @relayauth/core, @relayauth/sdk to npm with provenance. Supports version bump (patch/minor/major/pre*), dry run, dist-tags. Auto-creates git tag on publish to match relay repo pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Matches relay repo pattern: update npm before publish (required for provenance attestation) and --ignore-scripts for safety. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Relayfile auth.go expects short scopes like "fs:read" while relayauth uses the full format "relayfile:fs:read:/path". Include both in the token so it works with both systems. Also fix admin token scopes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
e2e-dotfiles.sh: fix isReadonly argument order (path, perms) e2e-dotfiles.sh: rewrite compile probe to CJS pattern (async IIFE + require) e2e-dotfiles.sh: fix compileDotfiles args (workspace string, not perms object) e2e-dotfiles.sh: fix property access (acl, ignoredPaths) publish.yml: fix matrix expression to produce proper JSON array install.sh: replace hardcoded path with dynamic resolution Co-Authored-By: My Senior Dev <dev@myseniordev.com>
| assert_array_contains "$compile_json" "data.scopes" "relayfile:fs:read:*" \ | ||
| "Compiler scopes include relayfile:fs:read:*" |
There was a problem hiding this comment.
🟡 E2E test asserts wildcard scope that the compiler never produces
The verify_compiler_contract function asserts that data.scopes contains "relayfile:fs:read:*" (line 364). However, the compiler (dotfile-compiler.ts:38-40) produces path-specific scopes like "relayfile:fs:read:/README.md" — it never emits wildcard scopes. This assertion will always fail, causing the e2e test to report a false failure.
Prompt for agents
In scripts/relay/e2e-dotfiles.sh, line 364-365, replace the wildcard scope assertion with an assertion that checks for a specific path-based scope that the compiler actually produces. For instance, check that data.scopes contains a scope matching the pattern 'relayfile:fs:read:/README.md' (since README.md is a readable file in the test fixture created at line 274). The compiler at dotfile-compiler.ts:38-40 generates scopes in the format relayfile:fs:{action}:/{relativePath}, so the assertion should check for one of those specific scopes rather than a wildcard.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if JSON_INPUT="$compile_json" node -e ' | ||
| const data = JSON.parse(process.env.JSON_INPUT); | ||
| const acl = data.aclRules ?? {}; | ||
| const rules = acl["/"] ?? []; | ||
| if (!Array.isArray(rules)) process.exit(1); | ||
| if (!rules.includes("deny:agent:test-agent")) process.exit(1); | ||
| if (!rules.includes("allow:scope:relayfile:fs:read:/*")) process.exit(1); | ||
| '; then | ||
| print_pass "Compiler restricts README.md writes via root ACL while preserving read" | ||
| else | ||
| print_fail "Compiler did not encode readonly root ACL as expected" | ||
| fi |
There was a problem hiding this comment.
🟡 E2E test asserts allow:scope:* ACL rule that the compiler never produces
The verify_compiler_contract function at lines 367-373 asserts that the root ACL (/) contains both "deny:agent:test-agent" and "allow:scope:relayfile:fs:read:/*". However, the compiler (dotfile-compiler.ts:76-89) only ever generates deny:agent:* rules for ignored files — it never produces allow:scope:* rules. For readonly files, it simply adds a read scope to the token and omits the write scope, rather than creating ACL allow rules. This assertion will always fail.
Prompt for agents
In scripts/relay/e2e-dotfiles.sh lines 367-378, the test expects the root ACL to contain 'allow:scope:relayfile:fs:read:/*' but the compiler in dotfile-compiler.ts never emits allow:scope rules. The compiler handles readonly files by adding a read scope to the token but NOT adding a write scope (lines 82-85), and only producing deny:agent rules for ignored files (line 78). Update this assertion to match the actual compiler behavior: either check that README.md appears in the scopes list as a read scope but NOT as a write scope, or check that the root ACL does not contain deny rules (since README.md is readonly, not ignored, the root ACL may not have any deny rules related to it).
Was this helpful? React with 👍 or 👎 to provide feedback.
| if JSON_INPUT="$parser_json" node -e ' | ||
| const data = JSON.parse(process.env.JSON_INPUT); | ||
| if (data.semantics?.ignoresSecretsDir) process.exit(1); | ||
| if (data.semantics?.ignoresDotEnv) process.exit(1); | ||
| if (data.semantics?.readonlyReadme) process.exit(1); | ||
| '; then | ||
| print_pass "Per-agent empty dot files clear global ignore/readonly rules for admin-agent" | ||
| else | ||
| print_fail "admin-agent still inherits global dot-file restrictions" | ||
| fi |
There was a problem hiding this comment.
🟡 Admin-agent override test wrongly assumes empty per-agent dotfiles clear global restrictions
The verify_admin_override_contract function (lines 381-409) creates empty .admin-agent.agentignore and .admin-agent.agentreadonly files, then asserts that admin-agent has NO ignore/readonly restrictions (lines 388-393). But parseDotfiles (dotfile-parser.ts:51-73) loads global .agentignore patterns first, then layers per-agent patterns on top additively via the ignore library. Empty per-agent files add nothing and do NOT clear the global patterns. So admin-agent will still inherit secrets/ and .env from the global .agentignore and README.md from .agentreadonly. The test will always report a failure at line 396: "admin-agent still inherits global dot-file restrictions".
Prompt for agents
In scripts/relay/e2e-dotfiles.sh lines 381-409, the admin-agent override test is fundamentally incorrect. The parseDotfiles function (dotfile-parser.ts:51-73) adds global patterns first, then per-agent patterns additively. Empty per-agent files do NOT clear global patterns. Either:
1. Change the test to expect that admin-agent STILL inherits global restrictions (the correct behavior given the parser implementation), OR
2. If admin-agent override is a desired feature, update the parser in dotfile-parser.ts to support a convention like a '!' negation line or a special '# clear' directive in per-agent files that clears inherited global patterns, then adjust the test to use that convention.
Was this helpful? React with 👍 or 👎 to provide feedback.
- seedAclEntries now uses POST /fs/bulk instead of PUT /fs/file (bulk write doesn't require If-Match for new files) - Always seed compiled ACLs in dotfile mode (was only seeding for relay.yaml) - ACL markers (.relayfile.acl) now created in relayfile workspace Note: relayfile Go server doesn't yet enforce .relayfile.acl markers — ACL eval logic exists in @relayfile/core (TypeScript) but isn't wired into Go HTTP handlers. Separate PR needed in relayfile repo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| globalThis.fetch = (async (input: URL | RequestInfo, init?: RequestInit) => { | ||
| calls.push({ input: new URL(String(input)), init }); | ||
| return new Response(null, { status: 200 }); | ||
| }) as typeof fetch; | ||
|
|
||
| try { | ||
| await seedAclEntries( | ||
| "workspace-a", | ||
| { | ||
| "/": ["agent:root:read"], | ||
| "/src": ["agent:dev:write"], | ||
| }, | ||
| "http://127.0.0.1:8080/", | ||
| "token-123", | ||
| ); | ||
| } finally { | ||
| globalThis.fetch = originalFetch; | ||
| } | ||
|
|
||
| assert.equal(calls.length, 2); | ||
| assert.equal(calls[0]?.input.pathname, "/v1/workspaces/workspace-a/fs/file"); | ||
| assert.equal(calls[0]?.input.searchParams.get("path"), "/.relayfile.acl"); | ||
| assert.equal(calls[1]?.input.searchParams.get("path"), "/src/.relayfile.acl"); | ||
| assert.equal((calls[0]?.init?.headers as Record<string, string>)?.authorization, "Bearer token-123"); | ||
|
|
||
| const firstBody = JSON.parse(String(calls[0]?.init?.body)); | ||
| assert.deepEqual(firstBody, { | ||
| content: JSON.stringify({ semantics: { permissions: ["agent:root:read"] } }), | ||
| encoding: "utf-8", | ||
| }); |
There was a problem hiding this comment.
🔴 ACL unit tests completely mismatched with bulk endpoint implementation
The seedAclEntries implementation (packages/core/src/acl.ts:20) sends a single POST to /v1/workspaces/.../fs/bulk with all files, but the test at packages/core/src/__tests__/acl.test.ts was written for a per-entry API that no longer exists. The test asserts calls.length === 2 (line 29), expects pathname /fs/file (line 30), and checks per-entry body structure (line 35-39) — none of which match the bulk implementation. Additionally, the mock returns new Response(null, { status: 200 }) (line 12), but the implementation calls response.json() (packages/core/src/acl.ts:36) on the response, which will throw a JSON parse error on null body, causing the first test to error rather than even reach the assertions. The second test (line 50) expects the regex /failed to seed ACL for \/src: HTTP 500 boom/ but the implementation throws "failed to seed ACLs: HTTP 500 boom" (plural, no path).
Prompt for agents
Rewrite the tests in packages/core/src/__tests__/acl.test.ts to match the bulk endpoint implementation in packages/core/src/acl.ts. The implementation now makes a single POST to /v1/workspaces/{ws}/fs/bulk with body {files: [{path, content, encoding}, ...]}. The mock fetch should return new Response(JSON.stringify({errorCount: 0, errors: []}), {status: 200}) instead of new Response(null, {status: 200}). The first test should assert calls.length === 1, check the pathname is /v1/workspaces/workspace-a/fs/bulk, and verify the body contains a files array with both ACL entries. The second test's regex should match the actual error message 'failed to seed ACLs: HTTP 500 boom' (plural, no per-path info).
Was this helpful? React with 👍 or 👎 to provide feedback.
Previously, any ignored file added deny:agent:X to its parent dir, which blocked all files in that dir (including allowed ones). Now deny rules only apply to directories where every file is ignored (e.g. /secrets/, /config/). Mixed directories rely on token scopes for per-file enforcement. Also fixed ACL seeder to use bulk write endpoint (no If-Match needed). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
scripts/relay/e2e-dotfiles.sh
Outdated
| assert_array_contains "$compile_json" "data.scopes" "relayfile:fs:write:*" \ | ||
| "admin-agent retains write scope" |
There was a problem hiding this comment.
🟡 E2E admin-agent compile test asserts wildcard relayfile:fs:write:* scope that compiler never produces
At e2e-dotfiles.sh:405, the test asserts admin-agent scopes contain relayfile:fs:write:*. The compiler (dotfile-compiler.ts:91-92) only adds per-file write scopes like relayfile:fs:write:/src/app.ts, never wildcard relayfile:fs:write:*. This assertion will always fail.
| assert_array_contains "$compile_json" "data.scopes" "relayfile:fs:write:*" \ | |
| "admin-agent retains write scope" | |
| assert_array_contains "$compile_json" "data.scopes" "relayfile:fs:write:/src/app.ts" \ | |
| "admin-agent retains write scope" |
Was this helpful? React with 👍 or 👎 to provide feedback.
…y from seed Agent tokens now only contain per-file relayauth scopes (relayfile:fs:read:/path). The relayfile server and mount client both understand this format. Blanket short scopes were overriding per-file permissions. Also exclude .relay/, .git/, node_modules/ from workspace seeding to prevent internal files from appearing in the agent's workspace. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Since the relay IS the sandbox (permission-enforced workspace), agents should run with full autonomy inside it. relay run now auto-applies: - claude: --dangerously-skip-permissions - codex: --dangerously-bypass-approvals-and-sandbox - gemini: --yolo - aider: --yes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BASH_SOURCE[0] is empty in zsh when sourced. Use ZSH_EVAL_CONTEXT
and ${(%):-%x} as fallbacks for script path resolution and
sourced-vs-executed detection.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…cape code pollution zsh prompts with terminal title setting inject escape codes into pwd output, breaking cd-based path resolution. Use dirname string manipulation instead. Fall back to known paths if dirname fails. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ZSH_EVAL_CONTEXT contains "file" (not "*:file:*") when sourced in zsh. Check ZSH_VERSION first since in zsh BASH_SOURCE is always empty, causing the bash check to fall through to the wrong branch. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ctive terminal) Background subshell with wait causes "suspended (tty output)" for interactive agents. Use foreground exec instead — agent gets the terminal directly. Cleanup runs after agent exits. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…entity "relay run codex" should use "default-agent" (the provisioned identity), not "codex" as the agent name. The CLI name is which binary to run, the agent name is which identity/token to use. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The config parser and dotfile scripts import from @relayauth/sdk which needs dist/ built. check_prereqs now runs turbo build if dist is missing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| assert_array_contains "$compile_json" "data.scopes" "relayfile:fs:read:*" \ | ||
| "Compiler scopes include relayfile:fs:read:*" |
There was a problem hiding this comment.
🟡 E2E test asserts wildcard scope relayfile:fs:read:* but compiler only produces per-file scopes
The verify_compiler_contract function asserts that data.scopes contains relayfile:fs:read:*, but the compileDotfiles function in scripts/relay/dotfile-compiler.ts:38-41 generates per-file scopes (e.g., relayfile:fs:read:/README.md) via the addScope helper — it never produces wildcard scopes. This causes the assertion to always fail, reporting a false test failure. The same incorrect assertion is repeated at line 405 for admin-agent in verify_admin_override_contract.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if JSON_INPUT="$compile_json" node -e ' | ||
| const data = JSON.parse(process.env.JSON_INPUT); | ||
| const acl = data.aclRules ?? {}; | ||
| const rules = acl["/"] ?? []; | ||
| if (!Array.isArray(rules)) process.exit(1); | ||
| if (!rules.includes("deny:agent:test-agent")) process.exit(1); | ||
| if (!rules.includes("allow:scope:relayfile:fs:read:/*")) process.exit(1); | ||
| '; then | ||
| print_pass "Compiler restricts README.md writes via root ACL while preserving read" | ||
| else | ||
| print_fail "Compiler did not encode readonly root ACL as expected" | ||
| fi |
There was a problem hiding this comment.
🔴 e2e-dotfiles.sh asserts root ACL contains allow:scope rule but compiler never generates allow rules
At line 367-378, the verify_compiler_contract() function checks that root ACL (/) contains both deny:agent:test-agent and allow:scope:relayfile:fs:read:/*. But the compiler at scripts/relay/dotfile-compiler.ts:99-106 only generates deny:agent:X rules for directories where ALL files are ignored. For the root directory /, .env is ignored but other files (like README.md, src/app.ts) are not, so it's a "mixed" directory and gets no ACL rules at all. The compiler never generates allow:scope:* rules anywhere. This assertion will always fail.
Was this helpful? React with 👍 or 👎 to provide feedback.
…eedback After initial sync, relay run generates a _PERMISSIONS.md (and CLAUDE.md) listing exactly which files are read-only and which are hidden, so agents understand the permission model without checking logs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Subshell PIDs in .relay/pids may not match the actual service process. Now also kills any process on :8787 and :8080 to handle orphans. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…om previous run) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| echo "list=types,core,sdk,cli" >> "$GITHUB_OUTPUT" | ||
| echo 'matrix=["types","core","sdk","cli"]' >> "$GITHUB_OUTPUT" |
There was a problem hiding this comment.
🔴 Publish workflow publishes @relayauth/core before its dependency @relayauth/sdk
The publish matrix order is ["types","core","sdk","cli"] with max-parallel: 1, so packages publish sequentially as types → core → sdk → cli. However, @relayauth/core declares @relayauth/sdk as a hard dependency (packages/core/package.json:19). When core is published before sdk, any consumer installing @relayauth/core will fail to resolve @relayauth/sdk because it doesn't exist on npm yet. On the very first publish this is guaranteed to fail since no prior version of @relayauth/sdk exists. The correct order should be types,sdk,core,cli.
| echo "list=types,core,sdk,cli" >> "$GITHUB_OUTPUT" | |
| echo 'matrix=["types","core","sdk","cli"]' >> "$GITHUB_OUTPUT" | |
| echo "list=types,sdk,core,cli" >> "$GITHUB_OUTPUT" | |
| echo 'matrix=["types","sdk","core","cli"]' >> "$GITHUB_OUTPUT" |
Was this helpful? React with 👍 or 👎 to provide feedback.
token-verify.ts: configurable clockSkewLeewaySeconds, consistent #private members auth.ts: documented intentional JWT duplication with TODO config.ts: preserve bare "*" wildcard for scope-matcher fast-path consistency file-acl.ts: console.warn on default-open ACL fallthrough paths relay.sh: fix shell injection in config_agent_json, warn on default secrets errors.ts: explicit error name strings for minification safety config.test.ts: 7 new tests (empty agents, missing fields, path traversal, etc.) package.json: pin @relayauth/sdk to ^0.1.0 instead of wildcard generate-dev-token.sh: remove duplicate workspace_id/agent_name claims types/token.ts: @deprecated tags on workspace_id and agent_name index.ts: remove process provenance comment Co-Authored-By: My Senior Dev <dev@myseniordev.com>
| audience_json="${RELAYAUTH_AUDIENCE_JSON:-[\"relayauth\",\"relayfile\"]}" | ||
| token_type="${RELAYAUTH_TOKEN_TYPE:-access}" | ||
| secret="${SIGNING_KEY:-dev-secret}" | ||
| payload="{\"sub\":\"${subject}\",\"org\":\"${org}\",\"wks\":\"${workspace}\",\"scopes\":${scopes_json},\"sponsorId\":\"${sponsor}\",\"sponsorChain\":[\"${sponsor}\"],\"token_type\":\"${token_type}\",\"iss\":\"${issuer}\",\"aud\":${audience_json},\"iat\":${now},\"exp\":${exp},\"jti\":\"${jti}\"}" |
There was a problem hiding this comment.
🔴 generate-dev-token.sh missing workspace_id and agent_name JWT claims despite relay.sh setting RELAYAUTH_AGENT_NAME
The relay.sh provisioning flow sets RELAYAUTH_AGENT_NAME at line 662 when calling generate-dev-token.sh, but the token script never reads this env var and never includes workspace_id or agent_name in the JWT payload (line 17). The design doc (docs/auth-changes-plan.md) explicitly specifies these fields should be added, and test-helpers.ts:127-128 correctly includes them. As a result, tokens generated by the relay CLI for actual agent provisioning are missing these claims, which relayfile's auth.go expects for agent identification. This breaks the core cross-service compatibility that this PR is designed to establish.
Prompt for agents
In scripts/generate-dev-token.sh, add an agent_name variable after the workspace variable (around line 10), reading from RELAYAUTH_AGENT_NAME env var with a default of the subject:
agent_name="${RELAYAUTH_AGENT_NAME:-${subject}}"
Then on line 17, update the payload JSON string to include workspace_id and agent_name claims. Insert after the wks field:
"workspace_id":"${workspace}","agent_name":"${agent_name}",
The final payload line should look like:
payload="{\"sub\":\"${subject}\",\"org\":\"${org}\",\"wks\":\"${workspace}\",\"workspace_id\":\"${workspace}\",\"agent_name\":\"${agent_name}\",\"scopes\":${scopes_json},\"sponsorId\":\"${sponsor}\",\"sponsorChain\":[\"${sponsor}\"],\"token_type\":\"${token_type}\",\"iss\":\"${issuer}\",\"aud\":${audience_json},\"iat\":${now},\"exp\":${exp},\"jti\":\"${jti}\"}"
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
Foundation for combining relayauth + relayfile into a local agent sandbox where agents operate under granular, permission-checked file access using relay primitives.
generate-dev-token.shnow emitsworkspace_id+agent_nameso relayfile accepts relayauth-issued tokens.agentignore/.agentreadonlywith.gitignoresyntax — zero config agent sandboxingrelay up,down,provision,shell,scan,run,doctor,mount,statusrelay.yamlneeded — agent names discovered from dot filenamesrelay runKey architecture decisions (from trail)
@relayauth/corebridging bothRelated PRs
Test plan
relay upstarts both services locallyrelay provisionmints scoped tokens via generate-dev-token.shrelay scanshows dotfile permissionsrelay run claudein a project with.agentignore🤖 Generated with Claude Code