From 53f90061d10a813761d6a15fa46f559a26e8a4e9 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 24 Apr 2026 11:26:49 +0200 Subject: [PATCH 1/3] migration: mint provisioner tokens with RS256 --- .../src/__tests__/provisioner-mount.test.ts | 4 +- .../src/provisioner/__tests__/audit.test.ts | 4 +- .../__tests__/token-factory.test.ts | 48 +++++--- .../src/provisioner/__tests__/token.test.ts | 12 +- packages/sdk/src/provisioner/index.ts | 7 +- packages/sdk/src/provisioner/local-jwks.ts | 104 ++++++++++++++++++ packages/sdk/src/provisioner/token.ts | 18 +-- packages/sdk/src/provisioner/types.ts | 5 +- packages/sdk/src/workflows/runner.ts | 28 ++++- 9 files changed, 192 insertions(+), 38 deletions(-) create mode 100644 packages/sdk/src/provisioner/local-jwks.ts diff --git a/packages/sdk/src/__tests__/provisioner-mount.test.ts b/packages/sdk/src/__tests__/provisioner-mount.test.ts index cf4dcb1ce..92d1ccebd 100644 --- a/packages/sdk/src/__tests__/provisioner-mount.test.ts +++ b/packages/sdk/src/__tests__/provisioner-mount.test.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { ensureRelayfileMount } from '../provisioner/mount.js'; -import { provisionWorkflowAgents } from '../provisioner/index.js'; +import { createLocalJwksKeyPair, provisionWorkflowAgents } from '../provisioner/index.js'; const tempDirs: string[] = []; @@ -103,7 +103,7 @@ describe('provisionWorkflowAgents mount integration', () => { await writeFile(path.join(projectDir, 'src', 'index.ts'), 'export const value = 1;\n'); const result = await provisionWorkflowAgents({ - secret: 'test-secret', + tokenSigningKey: createLocalJwksKeyPair(), workspace: 'rw_workspace', projectDir, relayfileBaseUrl: 'http://127.0.0.1:8080', diff --git a/packages/sdk/src/provisioner/__tests__/audit.test.ts b/packages/sdk/src/provisioner/__tests__/audit.test.ts index 3e7f53d97..7ca616158 100644 --- a/packages/sdk/src/provisioner/__tests__/audit.test.ts +++ b/packages/sdk/src/provisioner/__tests__/audit.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import { provisionWorkflowAgents } from '../index.js'; +import { createLocalJwksKeyPair, provisionWorkflowAgents } from '../index.js'; async function createWorkspace(): Promise<{ dir: string; cleanup: () => Promise }> { const dir = await mkdtemp(path.join(tmpdir(), 'relay-provisioner-audit-')); @@ -22,7 +22,7 @@ test('provisionWorkflowAgents writes a permission audit without token values', a try { const result = await provisionWorkflowAgents({ - secret: 'test-secret', + tokenSigningKey: createLocalJwksKeyPair(), workspace: 'audit-workspace', projectDir: workspace.dir, relayfileBaseUrl: 'http://127.0.0.1:8080', diff --git a/packages/sdk/src/provisioner/__tests__/token-factory.test.ts b/packages/sdk/src/provisioner/__tests__/token-factory.test.ts index 18ca69066..082f9d47c 100644 --- a/packages/sdk/src/provisioner/__tests__/token-factory.test.ts +++ b/packages/sdk/src/provisioner/__tests__/token-factory.test.ts @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; -import { createHmac } from 'node:crypto'; +import { createPublicKey, createVerify } from 'node:crypto'; import test from 'node:test'; +import { createLocalJwksKeyPair } from '../local-jwks.js'; import { DEFAULT_ADMIN_AGENT_NAME, DEFAULT_ADMIN_SCOPES, @@ -14,6 +15,7 @@ import { interface JwtHeader { alg: string; typ: string; + kid: string; } function decodeJwtPart(value: string): T { @@ -33,9 +35,15 @@ function decodeJwt(token: string): { header: JwtHeader; payload: TokenClaims; si }; } +function testSigningKey() { + const { privateKey, kid } = createLocalJwksKeyPair(); + return { privateKey, kid }; +} + test('mintAgentToken returns a valid JWT', () => { + const signingKey = testSigningKey(); const token = mintAgentToken({ - secret: 'test-secret', + ...signingKey, agentName: 'worker', workspace: 'workspace-123', scopes: ['relayfile:fs:read:/src/index.ts'], @@ -46,14 +54,15 @@ test('mintAgentToken returns a valid JWT', () => { assert.equal(parts.length, 3); assert.ok(parts.every((part) => /^[A-Za-z0-9_-]+$/u.test(part))); - assert.deepEqual(decoded.header, { alg: 'HS256', typ: 'JWT' }); + assert.deepEqual(decoded.header, { alg: 'RS256', typ: 'JWT', kid: signingKey.kid }); assert.equal(decoded.payload.sub, 'agent_worker'); }); test('mintAgentToken payload contains agent_name, workspace, and scopes', () => { + const signingKey = testSigningKey(); const scopes = ['relayfile:fs:read:/src/index.ts', 'relayfile:fs:write:/src/index.ts']; const token = mintAgentToken({ - secret: 'test-secret', + ...signingKey, agentName: 'compiler', workspace: 'workspace-abc', scopes, @@ -69,7 +78,7 @@ test('mintAgentToken payload contains agent_name, workspace, and scopes', () => test('mintAgentToken defaults expiry to 2 hours', () => { const token = mintAgentToken({ - secret: 'test-secret', + ...testSigningKey(), agentName: 'worker', workspace: 'workspace-123', scopes: [], @@ -83,7 +92,7 @@ test('mintAgentToken defaults expiry to 2 hours', () => { test('mintAgentToken applies a custom TTL', () => { const token = mintAgentToken({ - secret: 'test-secret', + ...testSigningKey(), agentName: 'worker', workspace: 'workspace-123', scopes: [], @@ -96,7 +105,8 @@ test('mintAgentToken applies a custom TTL', () => { }); test('WorkflowTokenFactory mintAdmin uses the default admin identity and scopes', () => { - const factory = new WorkflowTokenFactory('test-secret', 'workspace-admin'); + const signingKey = testSigningKey(); + const factory = new WorkflowTokenFactory(signingKey.privateKey, signingKey.kid, 'workspace-admin'); const token = factory.mintAdmin(); const { payload } = decodeJwt(token); @@ -106,14 +116,16 @@ test('WorkflowTokenFactory mintAdmin uses the default admin identity and scopes' }); test('WorkflowTokenFactory getToken returns the token minted for an agent', () => { - const factory = new WorkflowTokenFactory('test-secret', 'workspace-123'); + const signingKey = testSigningKey(); + const factory = new WorkflowTokenFactory(signingKey.privateKey, signingKey.kid, 'workspace-123'); const token = factory.mintForAgent('builder', ['relayfile:fs:read:/src/index.ts']); assert.equal(factory.getToken('builder'), token); }); test('WorkflowTokenFactory uses its configured TTL when minting agent tokens', () => { - const factory = new WorkflowTokenFactory('test-secret', 'workspace-123', 45); + const signingKey = testSigningKey(); + const factory = new WorkflowTokenFactory(signingKey.privateKey, signingKey.kid, 'workspace-123', 45); const token = factory.mintForAgent('builder', []); const { payload } = decodeJwt(token); @@ -123,7 +135,7 @@ test('WorkflowTokenFactory uses its configured TTL when minting agent tokens', ( test('mintAgentToken generates a unique JTI per token', () => { const first = decodeJwt( mintAgentToken({ - secret: 'test-secret', + ...testSigningKey(), agentName: 'worker', workspace: 'workspace-123', scopes: [], @@ -131,7 +143,7 @@ test('mintAgentToken generates a unique JTI per token', () => { ).payload; const second = decodeJwt( mintAgentToken({ - secret: 'test-secret', + ...testSigningKey(), agentName: 'worker', workspace: 'workspace-123', scopes: [], @@ -145,7 +157,7 @@ test('mintAgentToken generates a unique JTI per token', () => { test('mintAgentToken includes the expected audience claims', () => { const token = mintAgentToken({ - secret: 'test-secret', + ...testSigningKey(), agentName: 'worker', workspace: 'workspace-123', scopes: [], @@ -156,17 +168,19 @@ test('mintAgentToken includes the expected audience claims', () => { assert.deepEqual(payload.aud, ['relayauth', 'relayfile']); }); -test('mintAgentToken signs tokens with HMAC-SHA256', () => { - const secret = 'test-secret'; +test('mintAgentToken signs tokens with RS256', () => { + const signingKey = testSigningKey(); const token = mintAgentToken({ - secret, + ...signingKey, agentName: 'worker', workspace: 'workspace-123', scopes: ['relayfile:fs:read:/src/index.ts'], }); const [header, payload, signature] = token.split('.'); - const expectedSignature = createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url'); + const verifier = createVerify('RSA-SHA256'); + verifier.update(`${header}.${payload}`); + verifier.end(); - assert.equal(signature, expectedSignature); + assert.equal(verifier.verify(createPublicKey(signingKey.privateKey), signature, 'base64url'), true); }); diff --git a/packages/sdk/src/provisioner/__tests__/token.test.ts b/packages/sdk/src/provisioner/__tests__/token.test.ts index 88b9a1451..f79028303 100644 --- a/packages/sdk/src/provisioner/__tests__/token.test.ts +++ b/packages/sdk/src/provisioner/__tests__/token.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { createLocalJwksKeyPair } from '../local-jwks.js'; import { DEFAULT_WORKFLOW_TOKEN_TTL_SECONDS, mintAgentToken, type TokenClaims } from '../token.js'; function decodeJwtPayload(token: string): TokenClaims { @@ -8,9 +9,14 @@ function decodeJwtPayload(token: string): TokenClaims { return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as TokenClaims; } +function testSigningKey() { + const { privateKey, kid } = createLocalJwksKeyPair(); + return { privateKey, kid }; +} + test('mintAgentToken returns a valid JWT', () => { const token = mintAgentToken({ - secret: 'test-secret', + ...testSigningKey(), agentName: 'worker', workspace: 'workspace-123', scopes: ['relayfile:fs:read:/src/index.ts'], @@ -24,7 +30,7 @@ test('mintAgentToken returns a valid JWT', () => { test('mintAgentToken payload contains agent_name, workspace, and scopes', () => { const scopes = ['relayfile:fs:read:/src/index.ts', 'relayfile:fs:write:/src/index.ts']; const token = mintAgentToken({ - secret: 'test-secret', + ...testSigningKey(), agentName: 'compiler', workspace: 'workspace-abc', scopes, @@ -40,7 +46,7 @@ test('mintAgentToken payload contains agent_name, workspace, and scopes', () => test('mintAgentToken defaults expiry to 2 hours', () => { const token = mintAgentToken({ - secret: 'test-secret', + ...testSigningKey(), agentName: 'worker', workspace: 'workspace-123', scopes: [], diff --git a/packages/sdk/src/provisioner/index.ts b/packages/sdk/src/provisioner/index.ts index d3079bebc..c6d8c88b2 100644 --- a/packages/sdk/src/provisioner/index.ts +++ b/packages/sdk/src/provisioner/index.ts @@ -17,6 +17,7 @@ import type { } from './types.js'; export * from './compiler.js'; +export * from './local-jwks.js'; export * from './mount.js'; export * from './seeder.js'; export * from './token.js'; @@ -147,7 +148,8 @@ export async function provisionWorkflowAgents(config: WorkflowProvisionConfig): permissions: agent.permissions, }); const token = mintAgentToken({ - secret: config.secret, + privateKey: config.tokenSigningKey.privateKey, + kid: config.tokenSigningKey.kid, agentName: agent.name, workspace: config.workspace, scopes: compiled.scopes, @@ -174,7 +176,8 @@ export async function provisionWorkflowAgents(config: WorkflowProvisionConfig): const adminScopes = [...(config.adminScopes ?? DEFAULT_ADMIN_SCOPES)]; const adminToken = mintAgentToken({ - secret: config.secret, + privateKey: config.tokenSigningKey.privateKey, + kid: config.tokenSigningKey.kid, agentName: DEFAULT_ADMIN_AGENT_NAME, workspace: config.workspace, scopes: adminScopes, diff --git a/packages/sdk/src/provisioner/local-jwks.ts b/packages/sdk/src/provisioner/local-jwks.ts new file mode 100644 index 000000000..2aa2eb422 --- /dev/null +++ b/packages/sdk/src/provisioner/local-jwks.ts @@ -0,0 +1,104 @@ +import { + createHash, + createPrivateKey, + generateKeyPairSync, + type KeyObject, +} from 'node:crypto'; +import { createServer as createHttpServer, type Server } from 'node:http'; + +export const RELAYAUTH_JWKS_URL_ENV = 'RELAYAUTH_JWKS_URL'; +export const RELAYAUTH_JWT_PRIVATE_KEY_PEM_ENV = 'RELAYAUTH_JWT_PRIVATE_KEY_PEM'; +export const RELAYAUTH_JWT_KID_ENV = 'RELAYAUTH_JWT_KID'; + +export interface RsaPublicJwk { + kty: string; + n: string; + e: string; +} + +export interface LocalJwksSigningKey { + privateKey: KeyObject; + kid: string; +} + +export interface LocalJwksKeyPair extends LocalJwksSigningKey { + publicJwk: RsaPublicJwk; +} + +export interface LocalJwks extends LocalJwksKeyPair { + jwksUrl: string; + shutdown: () => Promise; +} + +export function createLocalJwksKeyPair(): LocalJwksKeyPair { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const publicJwk = publicKey.export({ format: 'jwk' }) as RsaPublicJwk; + const kid = createHash('sha256') + .update(JSON.stringify({ e: publicJwk.e, kty: 'RSA', n: publicJwk.n })) + .digest('base64url'); + + return { privateKey, publicJwk, kid }; +} + +export function exportPrivateKeyPem(privateKey: KeyObject): string { + return privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(); +} + +export function importPrivateKeyPem(privateKeyPem: string): KeyObject { + return createPrivateKey(privateKeyPem); +} + +export async function createLocalJwks(): Promise { + const keyPair = createLocalJwksKeyPair(); + const jwk = { + ...keyPair.publicJwk, + kty: 'RSA', + alg: 'RS256', + use: 'sig', + kid: keyPair.kid, + }; + const server = createHttpServer((_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ keys: [jwk] })); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + await closeServer(server); + throw new Error('local JWKS server did not bind to a TCP port'); + } + + server.unref(); + let closed = false; + return { + ...keyPair, + jwksUrl: `http://127.0.0.1:${address.port}/.well-known/jwks.json`, + shutdown: async () => { + if (closed) { + return; + } + closed = true; + await closeServer(server); + }, + }; +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} diff --git a/packages/sdk/src/provisioner/token.ts b/packages/sdk/src/provisioner/token.ts index 5c77618d8..38c0c9533 100644 --- a/packages/sdk/src/provisioner/token.ts +++ b/packages/sdk/src/provisioner/token.ts @@ -1,4 +1,4 @@ -import { createHmac, randomUUID } from 'node:crypto'; +import { randomUUID, sign as cryptoSign, type KeyObject } from 'node:crypto'; export const DEFAULT_WORKFLOW_TOKEN_TTL_SECONDS = 2 * 60 * 60; export const DEFAULT_ADMIN_AGENT_NAME = 'relay-admin'; @@ -13,8 +13,6 @@ export const DEFAULT_ADMIN_SCOPES = [ 'admin:read', ]; -const JWT_HEADER = { alg: 'HS256', typ: 'JWT' } as const; - export interface TokenClaims { sub: string; org: string; @@ -33,7 +31,8 @@ export interface TokenClaims { } export interface MintAgentTokenOptions { - secret: string; + privateKey: KeyObject; + kid: string; agentName: string; workspace: string; scopes: string[]; @@ -54,6 +53,7 @@ function normalizeTtlSeconds(ttlSeconds?: number): number { export function mintAgentToken(opts: MintAgentTokenOptions): string { const now = Math.floor(Date.now() / 1000); + const header = { alg: 'RS256', typ: 'JWT', kid: opts.kid } as const; const payload: TokenClaims = { sub: `agent_${opts.agentName}`, org: 'org_relay', @@ -71,8 +71,8 @@ export function mintAgentToken(opts: MintAgentTokenOptions): string { jti: `tok-${now}-${randomUUID()}`, }; - const unsigned = `${base64urlEncode(JWT_HEADER)}.${base64urlEncode(payload)}`; - const signature = createHmac('sha256', opts.secret).update(unsigned).digest('base64url'); + const unsigned = `${base64urlEncode(header)}.${base64urlEncode(payload)}`; + const signature = cryptoSign('RSA-SHA256', Buffer.from(unsigned), opts.privateKey).toString('base64url'); return `${unsigned}.${signature}`; } @@ -82,7 +82,8 @@ export class WorkflowTokenFactory { private readonly ttlSeconds: number; constructor( - private readonly secret: string, + private readonly privateKey: KeyObject, + private readonly kid: string, private readonly workspace: string, ttlSeconds = DEFAULT_WORKFLOW_TOKEN_TTL_SECONDS ) { @@ -91,7 +92,8 @@ export class WorkflowTokenFactory { mintForAgent(agentName: string, scopes: string[], ttlSeconds = this.ttlSeconds): string { const token = mintAgentToken({ - secret: this.secret, + privateKey: this.privateKey, + kid: this.kid, workspace: this.workspace, agentName, scopes, diff --git a/packages/sdk/src/provisioner/types.ts b/packages/sdk/src/provisioner/types.ts index e827e8ab9..19fa282cb 100644 --- a/packages/sdk/src/provisioner/types.ts +++ b/packages/sdk/src/provisioner/types.ts @@ -5,14 +5,15 @@ import type { FilePermissions, PermissionSource, } from '../workflows/types.js'; +import type { LocalJwksSigningKey } from './local-jwks.js'; import type { MountHandle } from './mount.js'; // ── Input Configuration ──────────────────────────────────────────────────── /** Configuration for provisioning workflow agents. */ export interface WorkflowProvisionConfig { - /** HMAC secret used to sign JWT tokens. */ - secret: string; + /** RS256 signing key used to mint JWT tokens. */ + tokenSigningKey: LocalJwksSigningKey; /** Workspace identifier (e.g. 'my-project'). */ workspace: string; diff --git a/packages/sdk/src/workflows/runner.ts b/packages/sdk/src/workflows/runner.ts index 833f6c170..38df677cb 100644 --- a/packages/sdk/src/workflows/runner.ts +++ b/packages/sdk/src/workflows/runner.ts @@ -46,6 +46,13 @@ import { CustomStepResolutionError, } from './custom-steps.js'; import { provisionWorkflowAgents, resolveAgentPermissions } from '../provisioner/index.js'; +import { + createLocalJwksKeyPair, + importPrivateKeyPem, + RELAYAUTH_JWT_KID_ENV, + RELAYAUTH_JWT_PRIVATE_KEY_PEM_ENV, + type LocalJwksSigningKey, +} from '../provisioner/local-jwks.js'; import type { MountHandle } from '../provisioner/mount.js'; import { collectCliSession, type CliSessionReport } from './cli-session-collector.js'; import { executeApiStep } from './api-executor.js'; @@ -445,6 +452,23 @@ function getWorkflowSdkSpawner(relay: AgentRelay, cli: AgentCli): AgentSpawner | } } +function resolveWorkflowTokenSigningKey(env: NodeJS.ProcessEnv): LocalJwksSigningKey { + const privateKeyPem = env[RELAYAUTH_JWT_PRIVATE_KEY_PEM_ENV]; + const kid = env[RELAYAUTH_JWT_KID_ENV]; + if (privateKeyPem && kid) { + return { + privateKey: importPrivateKeyPem(privateKeyPem), + kid, + }; + } + + const generated = createLocalJwksKeyPair(); + return { + privateKey: generated.privateKey, + kid: generated.kid, + }; +} + // ── WorkflowRunner ────────────────────────────────────────────────────────── export class WorkflowRunner { @@ -1910,9 +1934,9 @@ export class WorkflowRunner { ...process.env, ...(this.getRelayEnv() ?? {}), }; + const tokenSigningKey = resolveWorkflowTokenSigningKey(relayEnv); const result = await provisionWorkflowAgents({ - secret: - this.envSecrets?.RELAY_AUTH_SECRET ?? relayEnv.RELAY_AUTH_SECRET ?? randomBytes(32).toString('hex'), + tokenSigningKey, workspace: this.workspaceId, projectDir: this.cwd, relayfileBaseUrl: relayEnv.RELAYFILE_BASE_URL ?? 'http://127.0.0.1:8080', From 10c9bd2ad627c6b601ea6d98b4c0ee0fae117c7c Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 24 Apr 2026 11:26:53 +0200 Subject: [PATCH 2/3] migration: wire relay on through local JWKS --- src/cli/commands/on/provision.ts | 12 +++-- src/cli/commands/on/services.test.ts | 5 +- src/cli/commands/on/services.ts | 13 ++++- src/cli/commands/on/start.test.ts | 14 ++++-- src/cli/commands/on/start.ts | 72 ++++++++++++++++++---------- src/cli/commands/on/token.ts | 9 ++-- 6 files changed, 86 insertions(+), 39 deletions(-) diff --git a/src/cli/commands/on/provision.ts b/src/cli/commands/on/provision.ts index 1d4d5d955..ea02fcedc 100644 --- a/src/cli/commands/on/provision.ts +++ b/src/cli/commands/on/provision.ts @@ -8,11 +8,12 @@ import { hasDotfiles as hasDotfilesFromCore, } from './dotfiles.js'; import { mintAgentToken as mintToken } from '../../../../packages/sdk/src/provisioner/token.js'; +import type { LocalJwksSigningKey } from '../../../../packages/sdk/src/provisioner/local-jwks.js'; interface ProvisionConfig { relayauthRoot: string; relayfileRoot: string; - secret: string; + tokenSigningKey: LocalJwksSigningKey; workspace: string; projectDir: string; portAuth: number; @@ -97,14 +98,15 @@ function mergeAcl(target: Record, source: Record { .mockReturnValueOnce({ pid: 2222 }); spawnSyncMock.mockReturnValue({ status: 1, stdout: '' }); - const started = await startServices({ relayauthRoot: relayauth, relayfileRoot: relayfile, logDir, secret: 'secret' }); + const jwksUrl = 'http://127.0.0.1:49152/.well-known/jwks.json'; + const started = await startServices({ relayauthRoot: relayauth, relayfileRoot: relayfile, logDir, secret: 'secret', jwksUrl }); expect(started).toEqual({ authPid: 1111, filePid: 2222 }); expect(spawnMock).toHaveBeenCalledTimes(2); + expect(spawnMock.mock.calls[0]?.[2]?.env).toMatchObject({ RELAYAUTH_JWKS_URL: jwksUrl }); + expect(spawnMock.mock.calls[1]?.[2]?.env).toMatchObject({ RELAYAUTH_JWKS_URL: jwksUrl }); const pidFile = JSON.parse(readFileSync(pidFilePath, 'utf8')); expect(pidFile).toEqual({ relayauthPid: 1111, relayfilePid: 2222 }); diff --git a/src/cli/commands/on/services.ts b/src/cli/commands/on/services.ts index 0bad3c09e..5356b2097 100644 --- a/src/cli/commands/on/services.ts +++ b/src/cli/commands/on/services.ts @@ -20,6 +20,7 @@ interface ServiceConfigCache { portAuth?: string | number; portFile?: string | number; secret?: string; + jwksUrl?: string; } interface PidFile { @@ -30,7 +31,8 @@ interface PidFile { export interface ServiceConfig { relayauthRoot: string; // path to relayauth repo relayfileRoot: string; // path to relayfile repo - secret: string; // shared signing secret + secret: string; // legacy shared signing secret for older local services + jwksUrl?: string; // local JWKS endpoint for RS256 token verification portAuth: number; // default 8787 portFile: number; // default 8080 logDir: string; // .relay/logs/ @@ -101,6 +103,7 @@ function getCachedConfig(): ServiceConfigCache { : typeof data.signingSecret === 'string' ? data.signingSecret : undefined, + jwksUrl: getStringValue('RELAYAUTH_JWKS_URL', 'jwksUrl'), }; } catch { return {}; @@ -173,6 +176,11 @@ export function resolveServiceConfig(overrides: Partial = {}): Se process.env.SIGNING_KEY, cache.secret, ]), + jwksUrl: pickFirstString([ + overrides.jwksUrl, + process.env.RELAYAUTH_JWKS_URL, + cache.jwksUrl, + ]) || undefined, portAuth: parsePositiveInt( pickFirst([ process.env.RELAY_AUTH_PORT, @@ -321,6 +329,7 @@ function spawnRelayauth(config: ServiceConfig, relayauthLogPath: string): ChildP { ...process.env, SIGNING_KEY: config.secret, + ...(config.jwksUrl ? { RELAYAUTH_JWKS_URL: config.jwksUrl } : {}), }, relayauthLogPath ); @@ -336,6 +345,7 @@ function spawnRelayfile(config: ServiceConfig, relayfileLogPath: string): ChildP { ...process.env, RELAYFILE_JWT_SECRET: config.secret, + ...(config.jwksUrl ? { RELAYAUTH_JWKS_URL: config.jwksUrl } : {}), RELAYFILE_BACKEND_PROFILE: 'durable-local', }, relayfileLogPath @@ -349,6 +359,7 @@ function spawnRelayfile(config: ServiceConfig, relayfileLogPath: string): ChildP { ...process.env, RELAYFILE_JWT_SECRET: config.secret, + ...(config.jwksUrl ? { RELAYAUTH_JWKS_URL: config.jwksUrl } : {}), RELAYFILE_BACKEND_PROFILE: 'durable-local', }, relayfileLogPath diff --git a/src/cli/commands/on/start.test.ts b/src/cli/commands/on/start.test.ts index 0cce29777..99c10286c 100644 --- a/src/cli/commands/on/start.test.ts +++ b/src/cli/commands/on/start.test.ts @@ -14,6 +14,7 @@ vi.mock('./dotfiles.js', () => ({ })); import { requestWorkspaceSession } from './start.js'; +import { createLocalJwksKeyPair } from '../../../../packages/sdk/src/provisioner/local-jwks.js'; function jsonResponse(payload: unknown, status = 200): Response { return new Response(JSON.stringify(payload), { @@ -27,6 +28,11 @@ function decodeJwtPayload(token: string): Record { return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Record; } +function testSigningKey() { + const { privateKey, kid } = createLocalJwksKeyPair(); + return { privateKey, kid }; +} + describe('requestWorkspaceSession', () => { it('joins an existing workspace without creating a new one', async () => { const fetchFn = vi.fn(async () => @@ -135,7 +141,7 @@ describe('requestWorkspaceSession', () => { workspaceName: 'my-project', agentName: 'codex', scopes: ['fs:read', 'fs:write'], - signingSecret: 'dev-secret', + tokenSigningKey: testSigningKey(), relayDir, fetchFn: fetchFn as unknown as typeof fetch, }); @@ -183,7 +189,7 @@ describe('requestWorkspaceSession', () => { workspaceName: 'my-project', agentName: 'claude', scopes: ['fs:read', 'fs:write'], - signingSecret: 'dev-secret', + tokenSigningKey: testSigningKey(), relayDir, preferLocalSession: true, fetchFn: fetchFn as unknown as typeof fetch, @@ -224,7 +230,7 @@ describe('requestWorkspaceSession', () => { workspaceName: 'my-project', agentName: 'claude', scopes: ['fs:read'], - signingSecret: 'dev-secret', + tokenSigningKey: testSigningKey(), relayDir, preferLocalSession: true, fetchFn: fetchFn as unknown as typeof fetch, @@ -266,7 +272,7 @@ describe('requestWorkspaceSession', () => { requestedWorkspaceId: 'rw_a7f3x9k2', agentName: 'claude', scopes: ['fs:read'], - signingSecret: 'dev-secret', + tokenSigningKey: testSigningKey(), relayDir, fetchFn: fetchFn as unknown as typeof fetch, }); diff --git a/src/cli/commands/on/start.ts b/src/cli/commands/on/start.ts index 839ba82eb..a22f813f7 100644 --- a/src/cli/commands/on/start.ts +++ b/src/cli/commands/on/start.ts @@ -22,6 +22,14 @@ import { launchOnMount } from '@relayfile/local-mount'; import { mintToken } from './token.js'; import { seedAclRules } from './workspace.js'; import { seedWorkspace } from '../../../../packages/sdk/src/provisioner/seeder.js'; +import { + createLocalJwks, + exportPrivateKeyPem, + RELAYAUTH_JWKS_URL_ENV, + RELAYAUTH_JWT_KID_ENV, + RELAYAUTH_JWT_PRIVATE_KEY_PEM_ENV, + type LocalJwksSigningKey, +} from '../../../../packages/sdk/src/provisioner/local-jwks.js'; import { ensureAuthenticated, readStoredAuth } from '@agent-relay/cloud'; interface OnOptions { @@ -86,7 +94,7 @@ interface WorkspaceSessionRequest { workspaceName?: string; agentName: string; scopes: string[]; - signingSecret?: string; + tokenSigningKey?: LocalJwksSigningKey; relayDir?: string; relaycastBaseUrl?: string; fetchFn?: FetchFn; @@ -473,14 +481,14 @@ async function createRelaycastWorkspace( async function requestLocalWorkspaceSession(options: WorkspaceSessionRequest): Promise { const fetchFn = options.fetchFn ?? fetch; const relayDir = options.relayDir; - const signingSecret = toString(options.signingSecret); + const tokenSigningKey = options.tokenSigningKey; const requestedWorkspaceId = normalizeWorkspaceId(options.requestedWorkspaceId); if (!relayDir) { throw new Error('relayDir is required for local workspace sessions'); } - if (!signingSecret) { - throw new Error('signingSecret is required for local workspace sessions'); + if (!tokenSigningKey) { + throw new Error('tokenSigningKey is required for local workspace sessions'); } const workspaceId = requestedWorkspaceId ?? generateWorkspaceId(); @@ -517,7 +525,8 @@ async function requestLocalWorkspaceSession(options: WorkspaceSessionRequest): P created: !requestedWorkspaceId, workspaceId, token: mintToken({ - secret: signingSecret, + privateKey: tokenSigningKey.privateKey, + kid: tokenSigningKey.kid, agentName: options.agentName, workspace: workspaceId, scopes: options.scopes, @@ -535,7 +544,7 @@ export async function requestWorkspaceSession(options: WorkspaceSessionRequest): if ( (isLocalBaseUrl(options.authBase) || options.preferLocalSession) && options.relayDir && - options.signingSecret + options.tokenSigningKey ) { return requestLocalWorkspaceSession({ ...options, fetchFn, requestedWorkspaceId }); } @@ -658,12 +667,6 @@ function loadConfigFromFile(configPath: string, projectDir: string): RelayConfig payload.signing_secret, toString(root.signing_secret, process.env.SIGNING_KEY ?? '') ); - if (!signing_secret) { - throw new Error( - `relay config at ${configPath} is missing signing_secret and SIGNING_KEY env var is not set. ` + - 'Set signing_secret in your config or export SIGNING_KEY.' - ); - } const agents = normalizeAgents(payload.agents ?? root.agents); return { workspace, signing_secret, agents }; @@ -1015,12 +1018,14 @@ function pickDeniedCount(syncOutput: string): number { function generateTokenFromScript( config: RelayConfig, agent: RelayConfigAgent, + tokenSigningKey: LocalJwksSigningKey, log: LogFn, error: LogFn ): string | null { try { return mintToken({ - secret: config.signing_secret, + privateKey: tokenSigningKey.privateKey, + kid: tokenSigningKey.kid, agentName: agent.name, workspace: config.workspace, scopes: agent.scopes, @@ -1067,14 +1072,19 @@ interface GoOnRelayDeps { error?: LogFn; exit?: (code: number) => never | void; fetch?: FetchFn; - provision?: (config: RelayConfig, agent: RelayConfigAgent) => Promise; + provision?: ( + config: RelayConfig, + agent: RelayConfigAgent, + tokenSigningKey: LocalJwksSigningKey + ) => Promise; provisionAgentToken?: (opts: { config: RelayConfig; agent: RelayConfigAgent; tokenPath: string; + tokenSigningKey: LocalJwksSigningKey; }) => Promise; - ensureServicesRunning?: (authBase: string, fileBase: string) => Promise; - startServices?: (opts: { authBase: string; fileBase: string }) => Promise; + ensureServicesRunning?: (authBase: string, fileBase: string, jwksUrl: string) => Promise; + startServices?: (opts: { authBase: string; fileBase: string; jwksUrl: string }) => Promise; } function getSandboxFlags(cli: string): string[] { @@ -1096,6 +1106,7 @@ function getSandboxFlags(cli: string): string[] { async function ensureProvisioned( config: RelayConfig, agent: RelayConfigAgent, + tokenSigningKey: LocalJwksSigningKey, relayfileRoot: string, projectDir: string, tokenPath: string, @@ -1110,7 +1121,7 @@ async function ensureProvisioned( } if (typeof deps?.provision === 'function') { - await deps.provision(config, { ...agent }); + await deps.provision(config, { ...agent }, tokenSigningKey); try { return readFileSync(tokenPath, 'utf8').trim(); } catch { @@ -1119,7 +1130,7 @@ async function ensureProvisioned( } if (typeof deps?.provisionAgentToken === 'function') { - const generated = await deps.provisionAgentToken({ config, agent, tokenPath }); + const generated = await deps.provisionAgentToken({ config, agent, tokenPath, tokenSigningKey }); if (typeof generated === 'string' && generated.trim()) { const generatedToken = generated.trim(); writeFileSync(tokenPath, `${generatedToken}\n`, { encoding: 'utf8', mode: 0o600 }); @@ -1127,7 +1138,7 @@ async function ensureProvisioned( } } - const generatedToken = generateTokenFromScript(config, agent, log, error); + const generatedToken = generateTokenFromScript(config, agent, tokenSigningKey, log, error); if (generatedToken) { ensureDirectory(path.dirname(tokenPath)); writeFileSync(tokenPath, `${generatedToken}\n`, { encoding: 'utf8', mode: 0o600 }); @@ -1140,6 +1151,7 @@ async function ensureProvisioned( async function ensureServices( authBase: string, fileBase: string, + jwksUrl: string, deps: GoOnRelayDeps, log: LogFn, error: LogFn @@ -1155,14 +1167,14 @@ async function ensureServices( if (authHealthy && fileHealthy) return; if (typeof deps?.ensureServicesRunning === 'function') { - await deps.ensureServicesRunning(authBase, fileBase); + await deps.ensureServicesRunning(authBase, fileBase, jwksUrl); const postAuthHealthy = !needsLocalAuth || (await waitForHttpHealthy(authBase)); const postFileHealthy = !needsLocalFile || (await waitForHttpHealthy(fileBase)); if (postAuthHealthy && postFileHealthy) return; } if (typeof deps?.startServices === 'function') { - await deps.startServices({ authBase, fileBase }); + await deps.startServices({ authBase, fileBase, jwksUrl }); const postAuthHealthy = !needsLocalAuth || (await waitForHttpHealthy(authBase)); const postFileHealthy = !needsLocalFile || (await waitForHttpHealthy(fileBase)); if (postAuthHealthy && postFileHealthy) return; @@ -1221,6 +1233,8 @@ export async function goOnTheRelay( const projectDir = process.cwd(); const relayDir = path.join(projectDir, '.relay'); + const localJwks = await createLocalJwks(); + const privateKeyPem = exportPrivateKeyPem(localJwks.privateKey); if (!isCommandAvailable('node') || !isCommandAvailable('npx')) { throw new Error('node and npx must be available in PATH to run relay.'); @@ -1235,7 +1249,7 @@ export async function goOnTheRelay( // Default: solo local (symlink mount). --shared or --cloud: use relayfile. const useSymlinkMount = !options.shared && !options.cloud; - await ensureServices(authBase, fileBase, deps, log, error); + await ensureServices(authBase, fileBase, localJwks.jwksUrl, deps, log, error); const workspaceSession = await requestWorkspaceSession({ authBase, @@ -1244,7 +1258,7 @@ export async function goOnTheRelay( workspaceName: config.workspace, agentName: agent.name, scopes: agent.scopes, - signingSecret: config.signing_secret, + tokenSigningKey: localJwks, relayDir, relaycastBaseUrl: process.env.RELAYCAST_BASE_URL, fetchFn: deps.fetch, @@ -1291,6 +1305,9 @@ export async function goOnTheRelay( RELAYFILE_TOKEN: workspaceSession.token, RELAYFILE_BASE_URL: workspaceSession.relayfileUrl, RELAYFILE_WORKSPACE: workspaceSession.workspaceId, + [RELAYAUTH_JWKS_URL_ENV]: localJwks.jwksUrl, + [RELAYAUTH_JWT_PRIVATE_KEY_PEM_ENV]: privateKeyPem, + [RELAYAUTH_JWT_KID_ENV]: localJwks.kid, RELAY_WORKSPACE_ID: workspaceSession.workspaceId, RELAY_DEFAULT_WORKSPACE: workspaceSession.workspaceId, RELAY_WORKSPACE: mountDir, @@ -1352,6 +1369,7 @@ export async function goOnTheRelay( }); log('Off the relay.'); + await localJwks.shutdown(); exit(launchResult.exitCode); return; } @@ -1412,7 +1430,11 @@ export async function goOnTheRelay( mountDir, ]; const onceArgs = [...mountBaseArgs, '--once']; - const mountEnv: NodeJS.ProcessEnv = { ...process.env, RELAYFILE_TOKEN: workspaceSession.token }; + const mountEnv: NodeJS.ProcessEnv = { + ...process.env, + RELAYFILE_TOKEN: workspaceSession.token, + [RELAYAUTH_JWKS_URL_ENV]: localJwks.jwksUrl, + }; log(`Mounting workspace at ${mountDir}...`); let initialSyncOutput = ''; @@ -1563,8 +1585,10 @@ export async function goOnTheRelay( await finalizeCleanup(); log('Off the relay.'); + await localJwks.shutdown(); exit(agentExitCode); } finally { await finalizeCleanup(); + await localJwks.shutdown(); } } diff --git a/src/cli/commands/on/token.ts b/src/cli/commands/on/token.ts index 53f19ed24..f70523d74 100644 --- a/src/cli/commands/on/token.ts +++ b/src/cli/commands/on/token.ts @@ -1,4 +1,4 @@ -import { createHmac, randomUUID } from 'node:crypto'; +import { randomUUID, sign as cryptoSign, type KeyObject } from 'node:crypto'; interface TokenClaims { sub: string; @@ -18,14 +18,15 @@ interface TokenClaims { } export function mintToken(opts: { - secret: string; + privateKey: KeyObject; + kid: string; agentName: string; workspace: string; scopes: string[]; ttlSeconds?: number; }): string { const now = Math.floor(Date.now() / 1000); - const header = { alg: 'HS256', typ: 'JWT' }; + const header = { alg: 'RS256', typ: 'JWT', kid: opts.kid }; const payload: TokenClaims = { sub: 'agent_' + opts.agentName, org: 'org_relay', @@ -46,7 +47,7 @@ export function mintToken(opts: { const base64url = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url'); const unsigned = base64url(header) + '.' + base64url(payload); - const signature = createHmac('sha256', opts.secret).update(unsigned).digest('base64url'); + const signature = cryptoSign('RSA-SHA256', Buffer.from(unsigned), opts.privateKey).toString('base64url'); return `${unsigned}.${signature}`; } From acf3f9f8a6abb6df0a3bed765f9abc39b38fec49 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 24 Apr 2026 12:06:25 +0200 Subject: [PATCH 3/3] fix(on): shut down local JWKS server on all error paths Wrap goOnTheRelay body after createLocalJwks() in a top-level try/finally so the JWKS HTTP server is shut down even when ensureServices, requestWorkspaceSession, launchOnMount, or the FUSE setup throws. Removes the per-branch shutdown calls now handled by the outer finally. Addresses Devin review finding on #779. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/commands/on/start.ts | 632 ++++++++++++++++++----------------- 1 file changed, 317 insertions(+), 315 deletions(-) diff --git a/src/cli/commands/on/start.ts b/src/cli/commands/on/start.ts index a22f813f7..83d3e6e2f 100644 --- a/src/cli/commands/on/start.ts +++ b/src/cli/commands/on/start.ts @@ -1233,362 +1233,364 @@ export async function goOnTheRelay( const projectDir = process.cwd(); const relayDir = path.join(projectDir, '.relay'); - const localJwks = await createLocalJwks(); - const privateKeyPem = exportPrivateKeyPem(localJwks.privateKey); if (!isCommandAvailable('node') || !isCommandAvailable('npx')) { throw new Error('node and npx must be available in PATH to run relay.'); } - ensureStateDirs(relayDir); - const defaultAgentName = toString(options.agent, path.basename(cli)); - const config = resolveConfig(projectDir, relayDir, defaultAgentName); - const agent = findAgentConfig(config, defaultAgentName); - const authBase = normalizeBaseUrl(options.cloud && options.url ? options.url : options.portAuth); - const fileBase = normalizeBaseUrl(options.portFile); - // Default: solo local (symlink mount). --shared or --cloud: use relayfile. - const useSymlinkMount = !options.shared && !options.cloud; - - await ensureServices(authBase, fileBase, localJwks.jwksUrl, deps, log, error); - - const workspaceSession = await requestWorkspaceSession({ - authBase, - fallbackRelayfileUrl: fileBase, - requestedWorkspaceId: options.workspace, - workspaceName: config.workspace, - agentName: agent.name, - scopes: agent.scopes, - tokenSigningKey: localJwks, - relayDir, - relaycastBaseUrl: process.env.RELAYCAST_BASE_URL, - fetchFn: deps.fetch, - preferLocalSession: useSymlinkMount, - }); + const localJwks = await createLocalJwks(); + try { + const privateKeyPem = exportPrivateKeyPem(localJwks.privateKey); + + ensureStateDirs(relayDir); + const defaultAgentName = toString(options.agent, path.basename(cli)); + const config = resolveConfig(projectDir, relayDir, defaultAgentName); + const agent = findAgentConfig(config, defaultAgentName); + const authBase = normalizeBaseUrl(options.cloud && options.url ? options.url : options.portAuth); + const fileBase = normalizeBaseUrl(options.portFile); + // Default: solo local (symlink mount). --shared or --cloud: use relayfile. + const useSymlinkMount = !options.shared && !options.cloud; + + await ensureServices(authBase, fileBase, localJwks.jwksUrl, deps, log, error); + + const workspaceSession = await requestWorkspaceSession({ + authBase, + fallbackRelayfileUrl: fileBase, + requestedWorkspaceId: options.workspace, + workspaceName: config.workspace, + agentName: agent.name, + scopes: agent.scopes, + tokenSigningKey: localJwks, + relayDir, + relaycastBaseUrl: process.env.RELAYCAST_BASE_URL, + fetchFn: deps.fetch, + preferLocalSession: useSymlinkMount, + }); - // Compile dotfile permissions for this agent - const hasDots = hasDotfiles(projectDir); - const dotfileAcl = hasDots ? compileDotfiles(projectDir, agent.name, workspaceSession.workspaceId) : null; - const compiledPath = path.join(relayDir, 'compiled-acl.json'); - const compiled = extractPermissionPatternsFromCompiled(compiledPath, agent.name); - const fallback = collectPermissionPatternsFromDotfiles(projectDir); - const readonlyPatterns = - compiled.readonlyPatterns.length > 0 ? compiled.readonlyPatterns : fallback.readonlyPatterns; - const ignoredPatterns = - compiled.ignoredPatterns.length > 0 ? compiled.ignoredPatterns : fallback.ignoredPatterns; - const permsDoc = buildPermissionDoc(agent.name, readonlyPatterns, ignoredPatterns); - - const seedExcludes = [...DEFAULT_SEED_EXCLUDES]; - if (dotfileAcl) { - for (const [dir, rules] of Object.entries(dotfileAcl.acl)) { - if (rules.some((r) => r.startsWith('deny:agent:'))) { - seedExcludes.push(dir.replace(/^\//, '')); + // Compile dotfile permissions for this agent + const hasDots = hasDotfiles(projectDir); + const dotfileAcl = hasDots ? compileDotfiles(projectDir, agent.name, workspaceSession.workspaceId) : null; + const compiledPath = path.join(relayDir, 'compiled-acl.json'); + const compiled = extractPermissionPatternsFromCompiled(compiledPath, agent.name); + const fallback = collectPermissionPatternsFromDotfiles(projectDir); + const readonlyPatterns = + compiled.readonlyPatterns.length > 0 ? compiled.readonlyPatterns : fallback.readonlyPatterns; + const ignoredPatterns = + compiled.ignoredPatterns.length > 0 ? compiled.ignoredPatterns : fallback.ignoredPatterns; + const permsDoc = buildPermissionDoc(agent.name, readonlyPatterns, ignoredPatterns); + + const seedExcludes = [...DEFAULT_SEED_EXCLUDES]; + if (dotfileAcl) { + for (const [dir, rules] of Object.entries(dotfileAcl.acl)) { + if (rules.some((r) => r.startsWith('deny:agent:'))) { + seedExcludes.push(dir.replace(/^\//, '')); + } } } - } - const mountDirName = `workspace-${sanitizePathComponent(workspaceSession.workspaceId)}-${sanitizePathComponent(agent.name)}`; - // Symlink mounts live under ~/.agent-relay/mounts/ (outside the project tree). - // @relayfile/local-mount refuses any mountDir that overlaps projectDir as a - // safety check against destroying the project on cleanup, and putting mounts - // in $HOME keeps them durable across reboots (unlike $TMPDIR) and scoped to - // the user, consistent with ~/.agent-workforce/. The FUSE path keeps the - // historical in-project location under .relay/ — it's managed by the - // relayfile-mount Go binary, not @relayfile/local-mount, so the overlap - // guard doesn't apply. - const mountDir = useSymlinkMount - ? path.join(os.homedir(), '.agent-relay', 'mounts', mountDirName) - : path.join(relayDir, mountDirName); - const sandboxFlags = getSandboxFlags(cli); - - const buildAgentEnv = (): NodeJS.ProcessEnv => ({ - RELAY_AGENT_TOKEN: workspaceSession.token, - RELAYFILE_TOKEN: workspaceSession.token, - RELAYFILE_BASE_URL: workspaceSession.relayfileUrl, - RELAYFILE_WORKSPACE: workspaceSession.workspaceId, - [RELAYAUTH_JWKS_URL_ENV]: localJwks.jwksUrl, - [RELAYAUTH_JWT_PRIVATE_KEY_PEM_ENV]: privateKeyPem, - [RELAYAUTH_JWT_KID_ENV]: localJwks.kid, - RELAY_WORKSPACE_ID: workspaceSession.workspaceId, - RELAY_DEFAULT_WORKSPACE: workspaceSession.workspaceId, - RELAY_WORKSPACE: mountDir, - RELAY_AGENT_NAME: agent.name, - ...(workspaceSession.relaycastApiKey - ? { - RELAY_API_KEY: workspaceSession.relaycastApiKey, - RELAY_WORKSPACES_JSON: JSON.stringify([ - { - workspace_id: workspaceSession.workspaceId, - api_key: workspaceSession.relaycastApiKey, - }, - ]), - } - : {}), - }); - - if (useSymlinkMount) { - log(`Preparing local workspace at ${mountDir}...`); - const agentArgs = [...sandboxFlags, ...extraArgs]; - // Extend ignoredPatterns with `_PERMISSIONS.md` so @relayfile/local-mount's - // syncBack() does not copy the permissions doc we write in onBeforeLaunch - // into the user's project directory (the library only hides its own - // _MOUNT_README.md / .relayfile-local-mount marker from sync-back). - const launchIgnoredPatterns = [...ignoredPatterns, '_PERMISSIONS.md']; - // Ensure `.relay` is excluded from the mount — @relayfile/local-mount no - // longer has it in the default excludeDirs list, and seedExcludes already - // includes it for symlink + cloud paths. - const launchResult = await launchOnMount({ - cli, - projectDir, - mountDir, - args: agentArgs, - ignoredPatterns: launchIgnoredPatterns, - readonlyPatterns, - excludeDirs: seedExcludes, - env: { ...process.env, ...buildAgentEnv() }, - agentName: agent.name, - onBeforeLaunch: (realMountDir) => { - // Write the richer agent-relay permissions doc. This coexists with the - // generic _MOUNT_README.md that @relayfile/local-mount writes itself. - writeFileSync(path.join(realMountDir, '_PERMISSIONS.md'), permsDoc, 'utf8'); - - const mountedFiles = countFilesForSync(realMountDir); - log(`On the relay as ${agent.name}`); - log(` Workspace: ${workspaceSession.workspaceId}`); - log(` Join: ${workspaceSession.joinCommand}`); - log(` Mounted files: ${mountedFiles}`); - log(` Permissions denied (initial sync): 0`); - if (sandboxFlags.length > 0) { - log(` Sandbox: relay-enforced (${sandboxFlags.join(' ')})`); - log(` ⚠ Agent CLI sandbox bypassed — relay file permissions are the only safety layer`); - } - }, - onAfterSync: (synced) => { - log(` ✓ ${synced} file(s) synced back`); - log(`Cleaned relay mount for ${agent.name}`); - }, + const mountDirName = `workspace-${sanitizePathComponent(workspaceSession.workspaceId)}-${sanitizePathComponent(agent.name)}`; + // Symlink mounts live under ~/.agent-relay/mounts/ (outside the project tree). + // @relayfile/local-mount refuses any mountDir that overlaps projectDir as a + // safety check against destroying the project on cleanup, and putting mounts + // in $HOME keeps them durable across reboots (unlike $TMPDIR) and scoped to + // the user, consistent with ~/.agent-workforce/. The FUSE path keeps the + // historical in-project location under .relay/ — it's managed by the + // relayfile-mount Go binary, not @relayfile/local-mount, so the overlap + // guard doesn't apply. + const mountDir = useSymlinkMount + ? path.join(os.homedir(), '.agent-relay', 'mounts', mountDirName) + : path.join(relayDir, mountDirName); + const sandboxFlags = getSandboxFlags(cli); + + const buildAgentEnv = (): NodeJS.ProcessEnv => ({ + RELAY_AGENT_TOKEN: workspaceSession.token, + RELAYFILE_TOKEN: workspaceSession.token, + RELAYFILE_BASE_URL: workspaceSession.relayfileUrl, + RELAYFILE_WORKSPACE: workspaceSession.workspaceId, + [RELAYAUTH_JWKS_URL_ENV]: localJwks.jwksUrl, + [RELAYAUTH_JWT_PRIVATE_KEY_PEM_ENV]: privateKeyPem, + [RELAYAUTH_JWT_KID_ENV]: localJwks.kid, + RELAY_WORKSPACE_ID: workspaceSession.workspaceId, + RELAY_DEFAULT_WORKSPACE: workspaceSession.workspaceId, + RELAY_WORKSPACE: mountDir, + RELAY_AGENT_NAME: agent.name, + ...(workspaceSession.relaycastApiKey + ? { + RELAY_API_KEY: workspaceSession.relaycastApiKey, + RELAY_WORKSPACES_JSON: JSON.stringify([ + { + workspace_id: workspaceSession.workspaceId, + api_key: workspaceSession.relaycastApiKey, + }, + ]), + } + : {}), }); - log('Off the relay.'); - await localJwks.shutdown(); - exit(launchResult.exitCode); - return; - } + if (useSymlinkMount) { + log(`Preparing local workspace at ${mountDir}...`); + const agentArgs = [...sandboxFlags, ...extraArgs]; + // Extend ignoredPatterns with `_PERMISSIONS.md` so @relayfile/local-mount's + // syncBack() does not copy the permissions doc we write in onBeforeLaunch + // into the user's project directory (the library only hides its own + // _MOUNT_README.md / .relayfile-local-mount marker from sync-back). + const launchIgnoredPatterns = [...ignoredPatterns, '_PERMISSIONS.md']; + // Ensure `.relay` is excluded from the mount — @relayfile/local-mount no + // longer has it in the default excludeDirs list, and seedExcludes already + // includes it for symlink + cloud paths. + const launchResult = await launchOnMount({ + cli, + projectDir, + mountDir, + args: agentArgs, + ignoredPatterns: launchIgnoredPatterns, + readonlyPatterns, + excludeDirs: seedExcludes, + env: { ...process.env, ...buildAgentEnv() }, + agentName: agent.name, + onBeforeLaunch: (realMountDir) => { + // Write the richer agent-relay permissions doc. This coexists with the + // generic _MOUNT_README.md that @relayfile/local-mount writes itself. + writeFileSync(path.join(realMountDir, '_PERMISSIONS.md'), permsDoc, 'utf8'); + + const mountedFiles = countFilesForSync(realMountDir); + log(`On the relay as ${agent.name}`); + log(` Workspace: ${workspaceSession.workspaceId}`); + log(` Join: ${workspaceSession.joinCommand}`); + log(` Mounted files: ${mountedFiles}`); + log(` Permissions denied (initial sync): 0`); + if (sandboxFlags.length > 0) { + log(` Sandbox: relay-enforced (${sandboxFlags.join(' ')})`); + log(` ⚠ Agent CLI sandbox bypassed — relay file permissions are the only safety layer`); + } + }, + onAfterSync: (synced) => { + log(` ✓ ${synced} file(s) synced back`); + log(`Cleaned relay mount for ${agent.name}`); + }, + }); - // Cloud / --shared path: use the relayfile-mount FUSE binary. - const mountBin = process.env.RELAYFILE_ROOT - ? path.join(process.env.RELAYFILE_ROOT, 'bin', 'relayfile-mount') - : await ensureRelayfileMountBinary(); + log('Off the relay.'); + exit(launchResult.exitCode); + return; + } - if (!existsSync(mountBin)) { - throw new Error(`missing relayfile mount binary: ${mountBin}`); - } + // Cloud / --shared path: use the relayfile-mount FUSE binary. + const mountBin = process.env.RELAYFILE_ROOT + ? path.join(process.env.RELAYFILE_ROOT, 'bin', 'relayfile-mount') + : await ensureRelayfileMountBinary(); - if (workspaceSession.created) { - await seedWorkspace( - workspaceSession.relayfileUrl, - workspaceSession.token, - workspaceSession.workspaceId, - projectDir, - seedExcludes - ); + if (!existsSync(mountBin)) { + throw new Error(`missing relayfile mount binary: ${mountBin}`); + } - if (dotfileAcl && Object.keys(dotfileAcl.acl).length > 0) { - await seedAclRules( + if (workspaceSession.created) { + await seedWorkspace( workspaceSession.relayfileUrl, workspaceSession.token, workspaceSession.workspaceId, - dotfileAcl.acl + projectDir, + seedExcludes ); - writeFileSync( - compiledPath, - JSON.stringify( - { - workspace: workspaceSession.workspaceId, - acl: dotfileAcl.acl, - summary: dotfileAcl.summary, - agents: [{ name: agent.name, summary: dotfileAcl.summary }], - }, - null, - 2 - ) + '\n', - { encoding: 'utf8' } - ); + if (dotfileAcl && Object.keys(dotfileAcl.acl).length > 0) { + await seedAclRules( + workspaceSession.relayfileUrl, + workspaceSession.token, + workspaceSession.workspaceId, + dotfileAcl.acl + ); + + writeFileSync( + compiledPath, + JSON.stringify( + { + workspace: workspaceSession.workspaceId, + acl: dotfileAcl.acl, + summary: dotfileAcl.summary, + agents: [{ name: agent.name, summary: dotfileAcl.summary }], + }, + null, + 2 + ) + '\n', + { encoding: 'utf8' } + ); + } } - } - mkdirSync(mountDir, { recursive: true }); - const mountLogPath = path.join(relayDir, 'logs', `${agent.name}-mount.log`); - writeFileSync(mountLogPath, '', 'utf8'); - - const mountBaseArgs = [ - '--base-url', - workspaceSession.relayfileUrl, - '--workspace', - workspaceSession.workspaceId, - '--local-dir', - mountDir, - ]; - const onceArgs = [...mountBaseArgs, '--once']; - const mountEnv: NodeJS.ProcessEnv = { - ...process.env, - RELAYFILE_TOKEN: workspaceSession.token, - [RELAYAUTH_JWKS_URL_ENV]: localJwks.jwksUrl, - }; + mkdirSync(mountDir, { recursive: true }); + const mountLogPath = path.join(relayDir, 'logs', `${agent.name}-mount.log`); + writeFileSync(mountLogPath, '', 'utf8'); - log(`Mounting workspace at ${mountDir}...`); - let initialSyncOutput = ''; - try { - initialSyncOutput = await runCommandCapture(mountBin, onceArgs, mountEnv); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - throw new Error(`initial workspace sync failed for ${agent.name}: ${message}`); - } + const mountBaseArgs = [ + '--base-url', + workspaceSession.relayfileUrl, + '--workspace', + workspaceSession.workspaceId, + '--local-dir', + mountDir, + ]; + const onceArgs = [...mountBaseArgs, '--once']; + const mountEnv: NodeJS.ProcessEnv = { + ...process.env, + RELAYFILE_TOKEN: workspaceSession.token, + [RELAYAUTH_JWKS_URL_ENV]: localJwks.jwksUrl, + }; - const deniedCount = pickDeniedCount(initialSyncOutput); - writeFileSync(path.join(mountDir, '_PERMISSIONS.md'), permsDoc, 'utf8'); + log(`Mounting workspace at ${mountDir}...`); + let initialSyncOutput = ''; + try { + initialSyncOutput = await runCommandCapture(mountBin, onceArgs, mountEnv); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`initial workspace sync failed for ${agent.name}: ${message}`); + } - const projectDeny = path.join(projectDir, '.agentdeny'); - if (existsSync(projectDeny)) { - cpSync(projectDeny, path.join(mountDir, '.agentdeny'), { force: true }); - } + const deniedCount = pickDeniedCount(initialSyncOutput); + writeFileSync(path.join(mountDir, '_PERMISSIONS.md'), permsDoc, 'utf8'); - const mountedFiles = countFilesForSync(mountDir); - log(`On the relay as ${agent.name}`); - log(` Workspace: ${workspaceSession.workspaceId}`); - log(` Join: ${workspaceSession.joinCommand}`); - log(` Mounted files: ${mountedFiles}`); - log(` Permissions denied (initial sync): ${deniedCount}`); - if (sandboxFlags.length > 0) { - log(` Sandbox: relay-enforced (${sandboxFlags.join(' ')})`); - log(` ⚠ Agent CLI sandbox bypassed — relay file permissions are the only safety layer`); - } + const projectDeny = path.join(projectDir, '.agentdeny'); + if (existsSync(projectDeny)) { + cpSync(projectDeny, path.join(mountDir, '.agentdeny'), { force: true }); + } - const cleanupState: CleanupState = { - mountDir, - mountLogPath, - projectDir, - relayDir, - workspace: workspaceSession.workspaceId, - readonlyPatterns, - ignoredPatterns, - }; + const mountedFiles = countFilesForSync(mountDir); + log(`On the relay as ${agent.name}`); + log(` Workspace: ${workspaceSession.workspaceId}`); + log(` Join: ${workspaceSession.joinCommand}`); + log(` Mounted files: ${mountedFiles}`); + log(` Permissions denied (initial sync): ${deniedCount}`); + if (sandboxFlags.length > 0) { + log(` Sandbox: relay-enforced (${sandboxFlags.join(' ')})`); + log(` ⚠ Agent CLI sandbox bypassed — relay file permissions are the only safety layer`); + } - let mountProc: ChildProcessWithoutNullStreams | undefined; - let agentProc: ReturnType | undefined; - let cleanupDone = false; + const cleanupState: CleanupState = { + mountDir, + mountLogPath, + projectDir, + relayDir, + workspace: workspaceSession.workspaceId, + readonlyPatterns, + ignoredPatterns, + }; - const finalizeCleanup = async (): Promise => { - if (cleanupDone) return; - cleanupDone = true; - cleanupState.mountProc = mountProc; - await cleanupRun(cleanupState, agent.name, log); - }; + let mountProc: ChildProcessWithoutNullStreams | undefined; + let agentProc: ReturnType | undefined; + let cleanupDone = false; - try { - const mountedProc: ChildProcessWithoutNullStreams = spawn(mountBin, mountBaseArgs, { - stdio: ['pipe', 'pipe', 'pipe'], - env: mountEnv, - }); - mountProc = mountedProc; + const finalizeCleanup = async (): Promise => { + if (cleanupDone) return; + cleanupDone = true; + cleanupState.mountProc = mountProc; + await cleanupRun(cleanupState, agent.name, log); + }; - mountedProc.stdout.on('data', (chunk: Buffer) => { - appendFileSync(mountLogPath, chunk); - }); - mountedProc.stderr.on('data', (chunk: Buffer) => { - appendFileSync(mountLogPath, chunk); - }); + try { + const mountedProc: ChildProcessWithoutNullStreams = spawn(mountBin, mountBaseArgs, { + stdio: ['pipe', 'pipe', 'pipe'], + env: mountEnv, + }); + mountProc = mountedProc; - await new Promise((resolve, reject) => { - const timer = setTimeout(() => resolve(), 600); - mountedProc.on('error', (spawnError) => { - clearTimeout(timer); - reject(spawnError); + mountedProc.stdout.on('data', (chunk: Buffer) => { + appendFileSync(mountLogPath, chunk); }); - mountedProc.on('spawn', () => { - clearTimeout(timer); - resolve(); + mountedProc.stderr.on('data', (chunk: Buffer) => { + appendFileSync(mountLogPath, chunk); }); - }); - if (!ensureProcessRunning(mountedProc)) { - throw new Error(`mount process for ${agent.name} exited before continuing`); - } - - cleanupState.mountProc = mountProc; - - let agentExitCode = 0; - await new Promise((resolve, reject) => { - const envVars = { - ...process.env, - ...buildAgentEnv(), - }; - - agentProc = spawn(cli, [...sandboxFlags, ...extraArgs], { - cwd: mountDir, - stdio: 'inherit', - env: envVars, + await new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve(), 600); + mountedProc.on('error', (spawnError) => { + clearTimeout(timer); + reject(spawnError); + }); + mountedProc.on('spawn', () => { + clearTimeout(timer); + resolve(); + }); }); - let cleanupInProgress: Promise | undefined; - const cleanupHook = () => { - if (agentProc && !agentProc.killed) { - agentProc.kill('SIGTERM'); - } - // Wait for the agent process to exit so agentExitCode is set by the close handler, - // then ensure cleanup completes before resolving — avoids data loss from premature exit - cleanupInProgress = new Promise((r) => { - if (!agentProc || agentProc.exitCode !== null) { - r(); - return; - } - const t = setTimeout(r, 2000); - agentProc.once('close', () => { - clearTimeout(t); - r(); - }); - }) - .then(() => finalizeCleanup()) - .then(() => resolve()); - }; - - process.once('SIGINT', cleanupHook); - process.once('SIGTERM', cleanupHook); - - agentProc.on('error', (err) => { - process.removeListener('SIGINT', cleanupHook); - process.removeListener('SIGTERM', cleanupHook); - reject(err); - }); + if (!ensureProcessRunning(mountedProc)) { + throw new Error(`mount process for ${agent.name} exited before continuing`); + } - agentProc.on('close', (code, signal) => { - process.removeListener('SIGINT', cleanupHook); - process.removeListener('SIGTERM', cleanupHook); - if (typeof code === 'number') { - agentExitCode = code; - } else if (signal === 'SIGINT') { - agentExitCode = 130; - } else if (signal === 'SIGTERM') { - agentExitCode = 143; - } else { - agentExitCode = 1; - } - // If cleanup was triggered by a signal, wait for it to finish - if (cleanupInProgress) { - cleanupInProgress.then(() => resolve()); - } else { - resolve(); - } + cleanupState.mountProc = mountProc; + + let agentExitCode = 0; + await new Promise((resolve, reject) => { + const envVars = { + ...process.env, + ...buildAgentEnv(), + }; + + agentProc = spawn(cli, [...sandboxFlags, ...extraArgs], { + cwd: mountDir, + stdio: 'inherit', + env: envVars, + }); + + let cleanupInProgress: Promise | undefined; + const cleanupHook = () => { + if (agentProc && !agentProc.killed) { + agentProc.kill('SIGTERM'); + } + // Wait for the agent process to exit so agentExitCode is set by the close handler, + // then ensure cleanup completes before resolving — avoids data loss from premature exit + cleanupInProgress = new Promise((r) => { + if (!agentProc || agentProc.exitCode !== null) { + r(); + return; + } + const t = setTimeout(r, 2000); + agentProc.once('close', () => { + clearTimeout(t); + r(); + }); + }) + .then(() => finalizeCleanup()) + .then(() => resolve()); + }; + + process.once('SIGINT', cleanupHook); + process.once('SIGTERM', cleanupHook); + + agentProc.on('error', (err) => { + process.removeListener('SIGINT', cleanupHook); + process.removeListener('SIGTERM', cleanupHook); + reject(err); + }); + + agentProc.on('close', (code, signal) => { + process.removeListener('SIGINT', cleanupHook); + process.removeListener('SIGTERM', cleanupHook); + if (typeof code === 'number') { + agentExitCode = code; + } else if (signal === 'SIGINT') { + agentExitCode = 130; + } else if (signal === 'SIGTERM') { + agentExitCode = 143; + } else { + agentExitCode = 1; + } + // If cleanup was triggered by a signal, wait for it to finish + if (cleanupInProgress) { + cleanupInProgress.then(() => resolve()); + } else { + resolve(); + } + }); + // Finalization happens in outer finally. }); - // Finalization happens in outer finally. - }); - await finalizeCleanup(); - log('Off the relay.'); - await localJwks.shutdown(); - exit(agentExitCode); + await finalizeCleanup(); + log('Off the relay.'); + exit(agentExitCode); + } finally { + await finalizeCleanup(); + } } finally { - await finalizeCleanup(); await localJwks.shutdown(); } }