From e181e1c87a69030c5055b364416d852781ce4caa Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 27 Oct 2026 21:33:12 -0700 Subject: [PATCH 1/7] fix(cli): relay init writes portable MCP commands, tells the truth about Codex trust, and keeps the workspace key out of git - MCP command: never write npx's temporary cache copy (npx puts its cache bin first on PATH, which is how the _npx path got in). Project scope writes `agent-relay mcp` by name when a lasting agent-relay is on PATH, else `npx -y agent-relay@ mcp`; global scope keeps absolute launcher/PATH paths and uses the pinned npx command from an npx run. npx launches are warmed into npm's cache during init. - Codex: 0.154 loads /.codex/config.toml only when the exact project root (git root) is trusted in $CODEX_HOME/config.toml. A trusted parent does not count and `-c projects...` overrides are ignored. init now detects the trust state, offers to record it in the guided flow, and prints the exact line to add with --yes. - The workspace-key writer adds `/.agentworkforce/relay/` to the repo's info/exclude (worktree-aware, never .gitignore), so every writer is covered, not just init. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 + docs/quickstart.md | 22 +- packages/cli/src/cli/bootstrap.ts | 2 +- packages/cli/src/cli/commands/init.test.ts | 99 ++++++- packages/cli/src/cli/commands/init.ts | 70 ++++- .../cli/src/cli/lib/agent-tools-mcp.test.ts | 175 +++++++++++- packages/cli/src/cli/lib/agent-tools-mcp.ts | 256 +++++++++++++++--- .../cloud/src/project-workspace-key.test.ts | 20 ++ packages/cloud/src/project-workspace-key.ts | 15 +- packages/config/src/project-namespace.test.ts | 50 +++- packages/config/src/project-namespace.ts | 124 +++++---- 11 files changed, 719 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53bf7b925d..26fa98e969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `install.sh` no longer reports an unwritable `TMPDIR` as a failed verification or falls back to npm when it cannot create a temp file. - `install.sh` now also installs the short `relay` command next to `agent-relay` (same CLI), so `relay init` works right after a curl install. An existing `relay` from another tool is left alone with a warning, and `agent-relay uninstall` removes `relay` only when the installer wrote it. - `relay init` no longer switches the machine-wide active workspace away from one already in use: it saves the new workspace for the current project, keeps the existing active one, and prints `relay workspace switch ` to make it the default. +- `relay init` no longer writes npx's temporary cache path into agent tool configs. Project configs (`.mcp.json` and the like) run `agent-relay mcp` by name, or `npx -y agent-relay@ mcp` when Relay is not installed, so they work on teammates' machines. Re-running `relay init` updates entries it wrote earlier. +- `relay init --scope project` now tells the truth about Codex: Codex loads `.codex/config.toml` only when that exact project is trusted in `~/.codex/config.toml` (a trusted parent folder does not count, and `codex exec` never asks). In a terminal, `relay init` offers to record that trust; with `--yes` it prints the line to add. +- The project workspace key file (`.agentworkforce/relay/workspace-key.json`) is no longer picked up by `git add -A`: any command that saves it adds `.agentworkforce/relay/` to `.git/info/exclude` (worktrees included), never to `.gitignore`. - `relay node up` now reports the actual workspace-key source when startup fails, including the repository's `.agentworkforce/relay/workspace-key.json` pin instead of mislabeling every key as `AGENT_RELAY_WORKSPACE_KEY`. ### Security diff --git a/docs/quickstart.md b/docs/quickstart.md index 0f516541da..30bd480b9e 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -48,8 +48,9 @@ In a terminal it asks four questions, the same way `npx skills add` does: │ Claude Code .mcp.json (claude mcp add) │ │ Codex .codex/config.toml (config file) │ │ │ -│ Runs: /usr/local/bin/agent-relay mcp │ -│ the agent-relay on your PATH, so upgrading Relay updates it too │ +│ Runs: agent-relay mcp │ +│ the agent-relay on PATH, by name, so the project file works on │ +│ any machine with Relay installed │ ├─────────────────────────────────────────────────────────────────────────╯ │ ◇ Proceed with installation? @@ -82,9 +83,22 @@ In a terminal it asks four questions, the same way `npx skills add` does: | Pi | `pi mcp add` | `.pi/mcp.json` | `~/.pi/agent/mcp.json` | | Devin | `devin mcp add` | `.devin/mcp_config.json` | `~/.config/devin/mcp_config.json` | +Project files name the server portably, since teammates share them: +`agent-relay mcp` when `agent-relay` is on your PATH, otherwise +`npx -y agent-relay@ mcp`. Global configs use the installed launcher's +path. A temporary `npx` copy of the CLI is never written. + Claude Code asks you to approve a project `.mcp.json` server the first time it -opens the folder. Codex and Pi read project config only once you trust the -project, which they ask about on first run. +opens the folder. Pi reads `.pi/mcp.json` only once you trust the project, which +it asks about on first run. Codex reads `.codex/config.toml` only when that +exact project (its git root) is trusted in `~/.codex/config.toml`; a trusted +parent folder does not count, and `codex exec` never asks. In a terminal +`relay init` offers to record that trust for you; with `--yes` it prints the +line to add instead. + +In a git repository, `relay init` keeps `.agentworkforce/relay/` (which holds +the project's workspace key) out of `git status` through `.git/info/exclude`; +your `.gitignore` is not changed. No key or token is written into these files: `agent-relay mcp` finds the workspace `relay init` saved, and sends its key only to the server it belongs diff --git a/packages/cli/src/cli/bootstrap.ts b/packages/cli/src/cli/bootstrap.ts index dd003e8816..da6832b98c 100644 --- a/packages/cli/src/cli/bootstrap.ts +++ b/packages/cli/src/cli/bootstrap.ts @@ -419,7 +419,7 @@ export function createProgram(options: { name?: string } = {}): Command { registerStatusCommand(program); registerSetupCommands(program); registerCloudCommands(program); - registerInitCommand(program); + registerInitCommand(program, { cliVersion: VERSION }); registerWorkspaceCommands(program); registerObserverCommands(program); registerAgentCommands(program); diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index 8f8e684f60..c6ec6e5b34 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; @@ -87,6 +88,7 @@ function setup(overrides: Partial = {}) { args: ['mcp'], reason: 'the test launcher', }), + warmLaunch: vi.fn(), // Offline by default: no test reaches GitHub, and the npm path is the fallback. downloadEngine: vi.fn(async () => { throw new Error('offline'); @@ -435,7 +437,77 @@ describe('relay init agent tools (non-interactive)', () => { const folder = path.basename(project).toLowerCase(); expect(output).toContain(`"Send a message to codex-${folder} saying hello"`); expect(output).toContain('relay message dm send bob "hello" --as alice'); - expect(output).toContain('trust this project'); + // Codex reads the project file only once the project itself is trusted; + // --yes never trusts it on the person's behalf. + expect(output).toContain('Codex reads .codex/config.toml only in projects it trusts'); + expect(output).toContain( + `[projects.${JSON.stringify(fs.realpathSync(project))}] trust_level = "trusted"` + ); + expect(fs.existsSync(path.join(toolHome, '.codex', 'config.toml'))).toBe(false); + }); + + it('says nothing about Codex trust when Codex already trusts the project', async () => { + fs.mkdirSync(path.join(toolHome, '.codex')); + fs.writeFileSync( + path.join(toolHome, '.codex', 'config.toml'), + `[projects.${JSON.stringify(fs.realpathSync(project))}]\ntrust_level = "trusted"\n` + ); + const { run, logs } = setup(); + await run('--tools', 'codex'); + expect(logs.join('\n')).not.toContain('only in projects it trusts'); + }); + + it('resolves the launch for the chosen scope and warms an npx launch before writing it', async () => { + const resolveLaunch = vi.fn((_ctx: unknown, scope: string) => ({ + command: 'npx', + args: ['-y', `agent-relay@15.0.0`, 'mcp'], + reason: scope, + })); + const warmLaunch = vi.fn(); + const { run } = setup({ resolveLaunch, warmLaunch }); + await run('--tools', 'claude', '--scope', 'project'); + expect(resolveLaunch).toHaveBeenCalledWith(expect.anything(), 'project'); + expect(warmLaunch).toHaveBeenCalledWith(expect.objectContaining({ command: 'npx' })); + expect(mcpJson().mcpServers['agent-relay']).toMatchObject({ + command: 'npx', + args: ['-y', 'agent-relay@15.0.0', 'mcp'], + }); + }); + + it('replaces an entry it wrote with an npx cache path on re-run', async () => { + const npxCache = '/home/me/.npm/_npx/88f578dfe12e5edc/node_modules/.bin/agent-relay'; + const first = setup({ resolveLaunch: () => ({ command: npxCache, args: ['mcp'], reason: 'old' }) }); + await first.run('--tools', 'claude,codex'); + const { run, logs } = setup({ + resolveLaunch: () => ({ command: 'agent-relay', args: ['mcp'], reason: 'portable' }), + }); + await run('--tools', 'claude,codex'); + expect(logs.join('\n')).toMatch(/Claude Code\s+updated/); + expect(mcpJson().mcpServers['agent-relay'].command).toBe('agent-relay'); + const codex = fs.readFileSync(path.join(project, '.codex', 'config.toml'), 'utf8'); + expect(codex).toContain('command = "agent-relay"'); + expect(codex).not.toContain('_npx'); + }); + + it('keeps the workspace key out of git status in a git project', async () => { + try { + execFileSync('git', ['init', '-q', project]); + } catch { + return; // git is not installed here. + } + const { run } = setup(); + await run('--tools', 'claude'); + expect(fs.existsSync(path.join(project, '.agentworkforce', 'relay', 'workspace-key.json'))).toBe(true); + expect(fs.readFileSync(path.join(project, '.git', 'info', 'exclude'), 'utf8')).toContain( + '/.agentworkforce/relay/' + ); + const status = execFileSync('git', ['-C', project, 'status', '--porcelain', '--untracked-files=all'], { + encoding: 'utf8', + }); + expect(status).not.toContain('.agentworkforce'); + expect(status).toContain('.mcp.json'); + // The project's own .gitignore is never touched. + expect(fs.existsSync(path.join(project, '.gitignore'))).toBe(false); }); it('says so when no tool is installed, and still prints the CLI fallback', async () => { @@ -549,6 +621,7 @@ describe('relay init guided flow', () => { ['claude', 'codex'], 'project', true, + true, ]); const { run, createWorkspace } = setup({ isInteractive: () => true, prompter: () => script.prompter }); await run(); @@ -560,7 +633,15 @@ describe('relay init guided flow', () => { 'Which agents should get Relay?', 'Installation scope', 'Proceed with installation?', + expect.stringMatching( + /^Codex reads \.codex\/config\.toml only in projects it trusts\. Trust .* in Codex now\?/ + ), ]); + // Codex now trusts exactly this project, so it loads .codex/config.toml. + expect(fs.readFileSync(path.join(toolHome, '.codex', 'config.toml'), 'utf8')).toBe( + `[projects.${JSON.stringify(fs.realpathSync(project))}]\ntrust_level = "trusted"\n` + ); + expect(script.shown.join('\n')).not.toContain('not trusted yet'); // Detected tools are pre-checked; every supported tool is offered. expect(script.shown).toContain('options Claude Code*,Codex,OpenCode,Grok CLI,Pi*,Devin (local CLI)'); expect(createWorkspace).not.toHaveBeenCalled(); @@ -573,6 +654,22 @@ describe('relay init guided flow', () => { expect(shown).toContain('outro Relay is ready.'); }); + it('leaves Codex trust alone when the person declines, and says what is left to do', async () => { + const script = scriptedPrompter([true, false]); + const { run } = setup({ isInteractive: () => true, prompter: () => script.prompter }); + await run('--hosted', '--tools', 'codex', '--scope', 'project'); + expect(script.asked).toHaveLength(2); + expect(fs.existsSync(path.join(toolHome, '.codex', 'config.toml'))).toBe(false); + expect(script.shown.join('\n')).toContain('not trusted yet'); + }); + + it('does not ask about Codex trust at global scope', async () => { + const script = scriptedPrompter([true]); + const { run } = setup({ isInteractive: () => true, prompter: () => script.prompter }); + await run('--hosted', '--tools', 'codex', '--scope', 'global'); + expect(script.asked).toEqual(['Proceed with installation?']); + }); + it('only asks what the flags did not answer', async () => { const script = scriptedPrompter([true]); const { run } = setup({ isInteractive: () => true, prompter: () => script.prompter }); diff --git a/packages/cli/src/cli/commands/init.ts b/packages/cli/src/cli/commands/init.ts index 964562773d..f664266b77 100644 --- a/packages/cli/src/cli/commands/init.ts +++ b/packages/cli/src/cli/commands/init.ts @@ -1,4 +1,4 @@ -import { execFileSync, spawn } from 'node:child_process'; +import { execFileSync, spawn, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -15,6 +15,9 @@ import { import { TOOL_IDS, TOOL_TARGETS, + codexProjectRoot, + codexProjectTrust, + codexUserConfigPath, defaultRunTool, defaultToolContext, detectTools, @@ -23,6 +26,7 @@ import { installTool, parseToolsFlag, resolveMcpLaunch, + trustCodexProject, uninstallTool, type InstallScope, type McpLaunch, @@ -71,8 +75,15 @@ export interface InitCommandDependencies extends SdkCommandDeps { toolContext: () => ToolContext; /** Runs an agent tool's own `mcp add`. */ runTool: RunTool; - /** The command agent tools run for `relay mcp`. */ - resolveLaunch: (ctx: ToolContext) => McpLaunch; + /** This CLI's version, pinned when agent tools start Relay through npx. */ + cliVersion?: string; + /** The command agent tools run for `relay mcp`, for an install scope. */ + resolveLaunch: (ctx: ToolContext, scope: InstallScope) => McpLaunch; + /** + * Fetch an `npx` launch's package into npm's cache now, so an agent tool's + * first start of the server does not hit its MCP startup timeout. + */ + warmLaunch: (launch: McpLaunch) => void; /** Downloads one file of the standalone server release (binary or `.sha256`). */ downloadEngine: DownloadFile; /** The version an installed `relaycast-engine` reports for `--version`. */ @@ -194,6 +205,17 @@ function defaultStartEngine({ dataDir, port, logFile }: { dataDir: string; port: } } +function defaultWarmLaunch(launch: McpLaunch): void { + if (!/^npx(?:\.cmd)?$/.test(launch.command)) return; + // `--version` runs no Relay code beyond printing; failures are left for the + // agent tool to surface, since the entry itself is still right. + spawnSync(launch.command, [...launch.args.slice(0, -1), '--version'], { + stdio: 'ignore', + timeout: 180_000, + env: process.env, + }); +} + function defaultIsAlive(pid: number): boolean { try { process.kill(pid, 0); @@ -230,7 +252,8 @@ export function withInitDefaults(overrides: Partial = { prompter: clackPrompter, toolContext: () => defaultToolContext(projectRoot()), runTool: defaultRunTool, - resolveLaunch: (ctx) => resolveMcpLaunch({ ctx }), + resolveLaunch: (ctx, scope) => resolveMcpLaunch({ ctx, scope, version: overrides.cliVersion }), + warmLaunch: defaultWarmLaunch, downloadEngine: fetchDownload, engineVersion: readEngineVersion, enginePlatform: currentEnginePlatform, @@ -605,9 +628,9 @@ function nextStep(installed: ToolId[], scope: InstallScope, ctx: ToolContext): s return ['No agent tool was set up. Run relay init again to choose some.']; } -function notesFor(installed: ToolId[], scope: InstallScope): string[] { +function notesFor(installed: ToolId[], scope: InstallScope, ctx: ToolContext): string[] { return installed - .map((id) => findToolTarget(id)!.note?.(scope)) + .map((id) => findToolTarget(id)!.note?.(scope, ctx)) .filter((note): note is string => Boolean(note)); } @@ -694,7 +717,8 @@ async function runPlain(deps: InitCommandDependencies, o: InitOptions): Promise< const scope = o.scope ?? 'project'; const installed: ToolId[] = []; if (tools.length > 0) { - const launch = deps.resolveLaunch(ctx); + const launch = deps.resolveLaunch(ctx, scope); + deps.warmLaunch(launch); deps.log(''); deps.log( `Adding the Relay MCP server (${[launch.command, ...launch.args].join(' ')}) to your agent tools:` @@ -704,7 +728,7 @@ async function runPlain(deps: InitCommandDependencies, o: InitOptions): Promise< if (['installed', 'updated', 'unchanged'].includes(result.status)) installed.push(result.id); } if (launch.warning) deps.log(` ! ${launch.warning}`); - for (const note of notesFor(installed, scope)) deps.log(` ${note}`); + for (const note of notesFor(installed, scope, ctx)) deps.log(` ${note}`); const scopeNote = globalScopeNote(setup, scope, installed); if (scopeNote) deps.log(` ! ${scopeNote}`); } else if (o.tools === undefined) { @@ -717,6 +741,30 @@ async function runPlain(deps: InitCommandDependencies, o: InitOptions): Promise< for (const line of CLI_FALLBACK) deps.log(` ${line}`); } +/** + * Codex reads a project's .codex/config.toml only once that project is + * trusted, which `codex exec` never asks about. Offer to record the trust + * Codex's own first-run prompt would, since it also relaxes Codex's approval + * defaults in the project. `--yes` never trusts on the person's behalf. + */ +async function offerCodexTrust(p: InitPrompter, ctx: ToolContext): Promise { + if (codexProjectTrust(ctx) !== 'unset') return; + const root = codexProjectRoot(ctx); + const trust = await p.confirm({ + message: + `Codex reads .codex/config.toml only in projects it trusts. Trust ${root} in Codex now? ` + + '(Codex asks the same on its first run here; trusted projects get its trusted approval defaults.)', + initialValue: true, + }); + if (trust !== true) return; + try { + trustCodexProject(ctx); + p.info(`Codex now trusts ${root} (${displayPath(codexUserConfigPath(ctx), ctx)}).`); + } catch (error) { + p.warn((error as Error).message); + } +} + /** `relay init` in a terminal: ask what flags did not say. */ async function runGuided(deps: InitCommandDependencies, o: InitOptions): Promise { const p = deps.prompter(); @@ -811,7 +859,7 @@ async function runGuided(deps: InitCommandDependencies, o: InitOptions): Promise } // e. Install. - const launch = deps.resolveLaunch(ctx); + const launch = deps.resolveLaunch(ctx, scope); const summary = [ ...tools.map((id) => { const target = findToolTarget(id)!; @@ -831,12 +879,14 @@ async function runGuided(deps: InitCommandDependencies, o: InitOptions): Promise const proceed = await p.confirm({ message: 'Proceed with installation?', initialValue: true }); if (proceed === CANCELLED || !proceed) return cancelled(); } + deps.warmLaunch(launch); const results = installAll(deps, { tools, scope }, ctx, launch); const installed = results .filter((result) => ['installed', 'updated', 'unchanged'].includes(result.status)) .map((result) => result.id); p.note(results.map((result) => describeResult(result, ctx)).join('\n'), 'Relay MCP server'); - for (const note of notesFor(installed, scope)) p.info(note); + if (scope === 'project' && installed.includes('codex')) await offerCodexTrust(p, ctx); + for (const note of notesFor(installed, scope, ctx)) p.info(note); const scopeNote = globalScopeNote(setup, scope, installed); if (scopeNote) p.warn(scopeNote); diff --git a/packages/cli/src/cli/lib/agent-tools-mcp.test.ts b/packages/cli/src/cli/lib/agent-tools-mcp.test.ts index afd0fdc028..bfd69c7713 100644 --- a/packages/cli/src/cli/lib/agent-tools-mcp.test.ts +++ b/packages/cli/src/cli/lib/agent-tools-mcp.test.ts @@ -10,11 +10,15 @@ import { MANAGED_ENV_VALUE, TOOL_IDS, TOOL_TARGETS, + codexProjectRoot, + codexProjectTrust, + codexTrustNote, detectTools, findToolTarget, installTool, parseToolsFlag, resolveMcpLaunch, + trustCodexProject, uninstallTool, type InstallScope, type McpLaunch, @@ -413,24 +417,110 @@ describe('resolveMcpLaunch', () => { expect(result.warning).toBeUndefined(); }); - it('uses the installed launcher instead of a temporary npx copy', () => { - const launcher = executable(path.join(ctx.home, '.agentworkforce', 'relay', 'bin', 'agent-relay')); - const npx = executable( - path.join(ctx.home, '.npm', '_npx', 'abc', 'node_modules', 'agent-relay', 'index.js') + /** An npx run: the CLI unpacked in npm's cache, with that cache's bin first on PATH, as npx sets it up. */ + function npxRun() { + const pkg = executable( + path.join( + ctx.home, + '.npm', + '_npx', + '88f578dfe12e5edc', + 'node_modules', + 'agent-relay', + 'dist', + 'index.js' + ) ); - expect(resolveMcpLaunch({ ctx, execPath: '/usr/bin/node', script: npx })).toMatchObject({ - command: launcher, + const bin = path.join(ctx.home, '.npm', '_npx', '88f578dfe12e5edc', 'node_modules', '.bin'); + fs.mkdirSync(bin, { recursive: true }); + fs.symlinkSync(pkg, path.join(bin, 'agent-relay')); + return { script: path.join(bin, 'agent-relay'), bin }; + } + + it.each(['project', 'global'] as const)( + 'never writes the npx cache copy at %s scope, even though npx puts it first on PATH', + (scope) => { + const { script, bin } = npxRun(); + ctx.env = { PATH: bin }; + const result = resolveMcpLaunch({ + ctx, + scope, + version: '15.0.0-next.2', + execPath: '/usr/bin/node', + script, + }); + expect(result).toMatchObject({ command: 'npx', args: ['-y', 'agent-relay@15.0.0-next.2', 'mcp'] }); + expect(JSON.stringify(result)).not.toContain('_npx'); + expect(result.warning).toBeUndefined(); + } + ); + + it('does not fall back to an installed launcher that is not on PATH from npx', () => { + // It may be an old, unrelated install; npx pins the version that ran init. + executable(path.join(ctx.home, '.agentworkforce', 'relay', 'bin', 'agent-relay')); + const { script, bin } = npxRun(); + ctx.env = { PATH: bin }; + expect(resolveMcpLaunch({ ctx, version: '15.0.0', execPath: '/usr/bin/node', script })).toMatchObject({ + command: 'npx', + args: ['-y', 'agent-relay@15.0.0', 'mcp'], + }); + }); + + it('leaves the npx version unpinned when this build has no published version', () => { + const { script } = npxRun(); + expect( + resolveMcpLaunch({ ctx, scope: 'project', version: 'unknown', execPath: '/usr/bin/node', script }) + ).toMatchObject({ command: 'npx', args: ['-y', 'agent-relay', 'mcp'] }); + }); + + it('from npx, uses a lasting agent-relay on PATH: by name for a project, by path globally', () => { + const installed = executable(path.join(root, 'usr', 'bin', 'agent-relay')); + const { script, bin } = npxRun(); + ctx.env = { PATH: [bin, path.dirname(installed)].join(path.delimiter) }; + const inputs = { ctx, version: '15.0.0', execPath: '/usr/bin/node', script }; + expect(resolveMcpLaunch({ ...inputs, scope: 'project' })).toMatchObject({ + command: 'agent-relay', + args: ['mcp'], + }); + expect(resolveMcpLaunch({ ...inputs, scope: 'global' })).toMatchObject({ + command: installed, args: ['mcp'], }); }); - it('warns when only a temporary npx copy exists', () => { - const npx = executable( - path.join(ctx.home, '.npm', '_npx', 'abc', 'node_modules', 'agent-relay', 'index.js') - ); - const result = resolveMcpLaunch({ ctx, execPath: '/usr/bin/node', script: npx }); - expect(result).toMatchObject({ command: '/usr/bin/node', args: [npx, 'mcp'] }); - expect(result.warning).toMatch(/npx cache/); + it('writes the install.sh launcher by name for a project and by path globally', () => { + const launcher = executable(path.join(ctx.home, '.agentworkforce', 'relay', 'bin', 'agent-relay')); + const onPath = path.join(ctx.home, '.local', 'bin'); + fs.mkdirSync(onPath, { recursive: true }); + fs.symlinkSync(launcher, path.join(onPath, 'agent-relay')); + ctx.env = { PATH: onPath }; + const inputs = { ctx, execPath: '/usr/bin/node', script: path.join(onPath, 'agent-relay') }; + expect(resolveMcpLaunch({ ...inputs, scope: 'project' })).toMatchObject({ command: 'agent-relay' }); + expect(resolveMcpLaunch({ ...inputs, scope: 'global' })).toMatchObject({ command: launcher }); + }); + + it('uses npx for a project when the running launcher is not on PATH', () => { + const launcher = executable(path.join(ctx.home, '.agentworkforce', 'relay', 'bin', 'agent-relay')); + expect( + resolveMcpLaunch({ + ctx, + scope: 'project', + version: '15.0.0', + execPath: '/usr/bin/node', + script: launcher, + }) + ).toMatchObject({ command: 'npx', args: ['-y', 'agent-relay@15.0.0', 'mcp'] }); + }); + + it('warns before writing a checkout path into project files', () => { + const result = resolveMcpLaunch({ + ctx, + scope: 'project', + execPath: '/usr/bin/node', + script: '/src/relay/dist/cli/index.js', + }); + expect(result).toMatchObject({ command: '/usr/bin/node', args: ['/src/relay/dist/cli/index.js', 'mcp'] }); + expect(result.warning).toMatch(/absolute path/); }); it('re-enters a Bun standalone binary directly', () => { @@ -446,6 +536,65 @@ describe('resolveMcpLaunch', () => { }); }); +describe('Codex project trust', () => { + const codexConfig = () => path.join(ctx.home, '.codex', 'config.toml'); + const writeCodexConfig = (text: string) => { + fs.mkdirSync(path.dirname(codexConfig()), { recursive: true }); + fs.writeFileSync(codexConfig(), text); + }; + const realProject = () => fs.realpathSync(ctx.projectRoot); + + it('trusts the git root, which Codex keys trust by', () => { + fs.mkdirSync(path.join(ctx.projectRoot, '.git')); + const sub = path.join(ctx.projectRoot, 'packages', 'app'); + fs.mkdirSync(sub, { recursive: true }); + expect(codexProjectRoot({ ...ctx, projectRoot: sub })).toBe(realProject()); + }); + + it('does not count a trusted parent folder, as Codex does not', () => { + writeCodexConfig(`[projects.${JSON.stringify(path.dirname(realProject()))}]\ntrust_level = "trusted"\n`); + expect(codexProjectTrust(ctx)).toBe('unset'); + expect(codexTrustNote(ctx)).toMatch( + /not trusted yet.*trusted parent folder does not count.*codex exec never asks/s + ); + }); + + it('reads trusted and untrusted entries for the project itself', () => { + writeCodexConfig( + `model = "o3"\n\n[projects.${JSON.stringify(realProject())}]\ntrust_level = "trusted"\n` + ); + expect(codexProjectTrust(ctx)).toBe('trusted'); + expect(codexTrustNote(ctx)).toBeUndefined(); + writeCodexConfig(`[projects.'${realProject()}']\ntrust_level = "untrusted"\n`); + expect(codexProjectTrust(ctx)).toBe('untrusted'); + expect(codexTrustNote(ctx)).toMatch(/marked untrusted/); + }); + + it('records trust the way Codex does, keeping the rest of the config', () => { + const existing = '# mine\nmodel = "o3"\n\n[mcp_servers.github]\ncommand = "gh-mcp"\n'; + writeCodexConfig(existing); + fs.chmodSync(codexConfig(), 0o600); + trustCodexProject(ctx); + const text = read(codexConfig()); + expect(text.startsWith(existing.trimEnd())).toBe(true); + expect(text).toContain(`[projects.${JSON.stringify(realProject())}]\ntrust_level = "trusted"\n`); + expect(fs.statSync(codexConfig()).mode & 0o777).toBe(0o600); + expect(codexProjectTrust(ctx)).toBe('trusted'); + trustCodexProject(ctx); + expect(read(codexConfig())).toBe(text); + }); + + it('creates the Codex config when there is none', () => { + trustCodexProject(ctx); + expect(codexProjectTrust(ctx)).toBe('trusted'); + }); + + it('refuses a projects table it would have to restructure', () => { + writeCodexConfig('[projects]\n"/somewhere" = { trust_level = "trusted" }\n'); + expect(() => trustCodexProject(ctx)).toThrow(/does not edit/); + }); +}); + it('offers exactly the supported tools', () => { expect(TOOL_TARGETS.map((t) => t.id)).toEqual(['claude', 'codex', 'opencode', 'grok', 'pi', 'devin']); }); diff --git a/packages/cli/src/cli/lib/agent-tools-mcp.ts b/packages/cli/src/cli/lib/agent-tools-mcp.ts index 71accccde8..0ac94d7919 100644 --- a/packages/cli/src/cli/lib/agent-tools-mcp.ts +++ b/packages/cli/src/cli/lib/agent-tools-mcp.ts @@ -86,7 +86,7 @@ export interface ToolTarget { /** Arguments for the tool's own `mcp add`, when it has one for this scope. */ cliAdd?(scope: InstallScope, launch: McpLaunch, env: Record): string[] | undefined; /** Something the person has to do before the tool loads the entry. */ - note?(scope: InstallScope): string | undefined; + note?(scope: InstallScope, ctx: ToolContext): string | undefined; } function xdgConfigHome(ctx: ToolContext): string { @@ -157,7 +157,8 @@ export const TOOL_TARGETS: readonly ToolTarget[] = [ binary: 'codex', format: { kind: 'toml' }, // `codex mcp add` always writes $CODEX_HOME/config.toml. Codex also reads - // .codex/config.toml in a project once that project is trusted. + // .codex/config.toml in a project, but only once that exact project is + // trusted in $CODEX_HOME/config.toml (see codexProjectTrust). configPath: (scope, ctx) => scope === 'project' ? path.join(ctx.projectRoot, '.codex', 'config.toml') @@ -168,10 +169,7 @@ export const TOOL_TARGETS: readonly ToolTarget[] = [ scope === 'global' ? ['mcp', 'add', ...envFlags('--env', env), MCP_SERVER_NAME, '--', launch.command, ...launch.args] : undefined, - note: (scope) => - scope === 'project' - ? 'Codex loads .codex/config.toml only after you trust this project (it asks on first run).' - : undefined, + note: (scope, ctx) => (scope === 'project' ? codexTrustNote(ctx) : undefined), }, { id: 'opencode', @@ -266,6 +264,121 @@ export const TOOL_TARGETS: readonly ToolTarget[] = [ }, ]; +// --------------------------------------------------------------------------- +// Codex project trust +// +// Codex 0.154 reads /.codex/config.toml (MCP servers included) only +// when that project has its own `[projects.""] trust_level = "trusted"` +// table in $CODEX_HOME/config.toml. The root is the nearest directory with a +// `.git` (Codex's default project_root_markers), else the working directory. +// A trusted parent directory does not count, `-c projects...` overrides do not +// count, and `codex exec` never asks: only an interactive `codex` offers to +// trust the folder on first run. + +/** The directory Codex treats as this project's root, which is what it trusts. */ +export function codexProjectRoot(ctx: ToolContext): string { + const start = realpath(ctx.projectRoot) ?? path.resolve(ctx.projectRoot); + let current = start; + for (;;) { + if (fs.existsSync(path.join(current, '.git'))) return current; + const parent = path.dirname(current); + if (parent === current) return start; + current = parent; + } +} + +/** Codex's user config, where project trust is recorded. */ +export function codexUserConfigPath(ctx: ToolContext): string { + return path.join(codexHome(ctx), 'config.toml'); +} + +const CODEX_PROJECT_HEADER = /^\s*\[\s*projects\s*\.\s*("(?:[^"\\]|\\.)*"|'[^']*')\s*\]\s*(?:#.*)?$/; + +function tomlQuotedKey(raw: string): string | undefined { + if (raw.startsWith("'")) return raw.slice(1, -1); + try { + return JSON.parse(raw) as string; + } catch { + return undefined; + } +} + +/** Codex's recorded trust for this project. */ +export type CodexTrust = 'trusted' | 'untrusted' | 'unset'; + +/** Whether Codex will read this project's .codex/config.toml. */ +export function codexProjectTrust(ctx: ToolContext): CodexTrust { + const root = codexProjectRoot(ctx); + const text = readText(codexUserConfigPath(ctx)); + if (text === undefined) return 'unset'; + const lines = text.split('\n'); + let inRoot = false; + let trust: CodexTrust = 'unset'; + for (const line of lines) { + if (TOML_HEADER.test(line)) { + const match = CODEX_PROJECT_HEADER.exec(line); + inRoot = Boolean(match && tomlQuotedKey(match[1]) === root); + continue; + } + if (!inRoot) continue; + const value = /^\s*trust_level\s*=\s*["']([^"']*)["']/.exec(line)?.[1]; + if (value) trust = value === 'trusted' ? 'trusted' : 'untrusted'; + } + return trust; +} + +/** + * Record this project as trusted in Codex's user config, as Codex's own + * first-run prompt does. Trust also relaxes Codex's approval defaults in the + * project, so callers ask first. Refuses (throws) rather than risk a config + * Codex cannot parse. + */ +export function trustCodexProject(ctx: ToolContext): void { + const root = codexProjectRoot(ctx); + const file = codexUserConfigPath(ctx); + const text = readText(file) ?? ''; + const lines = text.split('\n'); + if (lines.some((line) => /^\s*projects\s*[.=]/.test(line) || /^\s*\[\s*projects\s*\]/.test(line))) { + throw new Error( + `${file} declares projects in a form relay init does not edit; trust ${root} in Codex instead.` + ); + } + const header = `[projects.${JSON.stringify(root)}]`; + const index = lines.findIndex((line) => { + const match = CODEX_PROJECT_HEADER.exec(line); + return Boolean(match && tomlQuotedKey(match[1]) === root); + }); + let next: string; + if (index === -1) { + const base = text.replace(/\n*$/, ''); + next = `${base}${base ? '\n\n' : ''}${header}\ntrust_level = "trusted"\n`; + } else { + let end = index + 1; + while (end < lines.length && !TOML_HEADER.test(lines[end])) end += 1; + const body = lines.slice(index + 1, end).filter((line) => !/^\s*trust_level\s*=/.test(line)); + next = [...lines.slice(0, index + 1), 'trust_level = "trusted"', ...body, ...lines.slice(end)].join('\n'); + } + writeKeepingMode(file, next, 0o600); +} + +/** The step left before Codex loads the project entry, if any. */ +export function codexTrustNote(ctx: ToolContext): string | undefined { + const trust = codexProjectTrust(ctx); + if (trust === 'trusted') return undefined; + const root = codexProjectRoot(ctx); + if (trust === 'untrusted') { + return ( + `Codex ignores .codex/config.toml here: ${root} is marked untrusted in ${codexUserConfigPath(ctx)}. ` + + 'Mark it trusted there, or run relay init --scope global for Codex.' + ); + } + return ( + `Codex reads .codex/config.toml only in projects it trusts, and ${root} is not trusted yet ` + + '(a trusted parent folder does not count, and codex exec never asks). Open codex here once and trust ' + + `the folder, or add [projects.${JSON.stringify(root)}] trust_level = "trusted" to ${codexUserConfigPath(ctx)}.` + ); +} + /** Look up a supported tool. */ export function findToolTarget(id: string): ToolTarget | undefined { return TOOL_TARGETS.find((target) => target.id === id); @@ -359,12 +472,23 @@ export function detectTools(ctx: ToolContext): DetectedTool[] { /** Inputs to {@link resolveMcpLaunch}; `process` values by default. */ export interface LaunchInputs { ctx: ToolContext; + /** + * Where the entry goes. Project files are shared with everyone who clones + * the project, so they get a command without machine-specific paths. + * Defaults to `global`. + */ + scope?: InstallScope; + /** This CLI's version, pinned in an `npx` command. Defaults to AGENT_RELAY_CLI_VERSION. */ + version?: string; execPath?: string; /** The running CLI script (process.argv[1]). */ script?: string; isBun?: boolean; } +/** The npm package that provides `agent-relay mcp`. */ +const NPM_PACKAGE = 'agent-relay'; + function realpath(file: string): string | undefined { try { return fs.realpathSync(file); @@ -373,29 +497,70 @@ function realpath(file: string): string | undefined { } } -function isEphemeralPath(file: string): boolean { +/** A path npm or Bun may delete at any time: an npx/bunx cache copy. */ +export function isEphemeralPath(file: string): boolean { const normalized = file.replace(/\\/g, '/'); return ( normalized.includes('/_npx/') || normalized.includes('/.npm/_cacache/') || normalized.includes('/bunx-') ); } +/** `agent-relay` on PATH, skipping the temporary bin directory npx puts first on PATH. */ +function stableOnPath(env: NodeJS.ProcessEnv): string | undefined { + const dirs = (env.PATH ?? '').split(path.delimiter).filter((dir) => dir && !isEphemeralPath(`${dir}/`)); + return findOnPath('agent-relay', { ...env, PATH: dirs.join(path.delimiter) }); +} + +/** A usable npm version string, or undefined for unknown/dev builds. */ +function publishedVersion(version: string | undefined): string | undefined { + const trimmed = version?.trim(); + return trimmed && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(trimmed) && trimmed !== '0.0.0' + ? trimmed + : undefined; +} + +/** `npx -y agent-relay@ mcp`: works on any machine with Node, and never goes stale. */ +function npxLaunch(version: string | undefined, reason: string): McpLaunch { + const pinned = publishedVersion(version); + return { + command: process.platform === 'win32' ? 'npx.cmd' : 'npx', + args: ['-y', pinned ? `${NPM_PACKAGE}@${pinned}` : NPM_PACKAGE, 'mcp'], + reason, + }; +} + /** - * Choose the command agent tools run for `relay mcp`, most durable first: + * Choose the command agent tools run for `relay mcp`. + * + * `AGENT_RELAY_MCP_COMMAND`, the override the broker honours, always wins. + * Otherwise a copy of the CLI that npx unpacked into its cache is never + * written: npm deletes that cache at will, and its path is specific to this + * machine (npx also puts its cache `bin` first on PATH, so a PATH lookup alone + * would find it). + * + * Project scope writes files teammates share (.mcp.json and friends), so it + * gets a portable command with no home-directory path: + * + * 1. `agent-relay mcp` when a lasting `agent-relay` is on PATH and this run is + * that install or a temporary npx copy. + * 2. `npx -y agent-relay@ mcp` when Relay is not installed. + * 3. The running CLI by absolute path, for a source checkout. + * + * Global scope writes this machine's own config, so absolute paths are fine: * - * 1. `AGENT_RELAY_MCP_COMMAND`, the same override the broker honours. - * 2. The running CLI, when it is the standalone launcher install.sh places in - * ~/.agentworkforce/relay/bin, or the `agent-relay` on PATH that leads to - * the running CLI (an npm global install). Either survives upgrades. - * 3. The standalone launcher, then `agent-relay` on PATH, when the running - * CLI is a temporary copy (npx), which npm may delete at any time. - * 4. The running CLI by absolute path (a checkout or an unusual install). + * 1. The running CLI's launcher (install.sh's bin directory) or the + * `agent-relay` on PATH that leads to it. Both survive upgrades. + * 2. For a temporary npx copy: a lasting `agent-relay` on PATH, else + * `npx -y agent-relay@ mcp`. + * 3. The running CLI by absolute path. */ export function resolveMcpLaunch(inputs: LaunchInputs): McpLaunch { const { ctx } = inputs; + const scope = inputs.scope ?? 'global'; const execPath = inputs.execPath ?? process.execPath; const script = inputs.script ?? process.argv[1] ?? ''; const isBun = inputs.isBun ?? Boolean(process.versions.bun); + const version = inputs.version ?? ctx.env.AGENT_RELAY_CLI_VERSION; const override = ctx.env.AGENT_RELAY_MCP_COMMAND?.trim(); if (override) { @@ -409,50 +574,63 @@ export function resolveMcpLaunch(inputs: LaunchInputs): McpLaunch { path.join(ctx.home, '.agentworkforce', 'relay', 'bin'), ]; const launcher = launcherDirs.map((dir) => path.join(dir, binary)).find(isExecutable); - const onPath = findOnPath('agent-relay', ctx.env); + const onPath = stableOnPath(ctx.env); const standalone = isBun && isBundledBunEntrypointPath(script); const running = standalone ? execPath : script; const runningReal = running ? realpath(running) : undefined; + const ephemeral = Boolean( + running && (isEphemeralPath(running) || (runningReal && isEphemeralPath(runningReal))) + ); + const isRunning = (file: string | undefined) => + Boolean(file && runningReal && realpath(file) === runningReal); - if (launcher && runningReal && realpath(launcher) === runningReal) { - return { - command: launcher, - args: ['mcp'], - reason: 'the Relay launcher install.sh keeps up to date', - }; - } - if (onPath && runningReal && realpath(onPath) === runningReal) { - return { - command: onPath, - args: ['mcp'], - reason: 'the agent-relay on your PATH, so upgrading Relay updates it too', - }; - } - if (running && isEphemeralPath(running)) { - if (launcher) { + if (scope === 'project') { + if (onPath && (ephemeral || isRunning(onPath) || isRunning(launcher))) { return { - command: launcher, + command: 'agent-relay', args: ['mcp'], - reason: 'the installed Relay launcher (npx copies are temporary)', + reason: + 'the agent-relay on PATH, by name, so the project file works on any machine with Relay installed', }; } - if (onPath) { + if (ephemeral || isRunning(launcher)) { + return npxLaunch( + version, + 'npx, because agent-relay is not on your PATH; the project file then works on any machine with Node.js' + ); + } + } else { + if (launcher && isRunning(launcher)) { + return { command: launcher, args: ['mcp'], reason: 'the Relay launcher install.sh keeps up to date' }; + } + if (onPath && isRunning(onPath)) { return { command: onPath, args: ['mcp'], - reason: 'the agent-relay on your PATH (npx copies are temporary)', + reason: 'the agent-relay on your PATH, so upgrading Relay updates it too', }; } + if (ephemeral) { + if (onPath) { + return { + command: onPath, + args: ['mcp'], + reason: 'the agent-relay on your PATH (npx copies are temporary)', + }; + } + return npxLaunch(version, 'npx, because npx copies are temporary and agent-relay is not on your PATH'); + } } return { command: execPath, args: standalone ? ['mcp'] : [script, 'mcp'], reason: 'this copy of the Relay CLI, by absolute path', warning: - running && isEphemeralPath(running) - ? 'This CLI runs from the npx cache, which npm can delete. Install Relay ' + - '(npm install -g agent-relay) and run relay init again so the agents keep working.' + scope === 'project' + ? "The project files name this machine's copy of the Relay CLI by absolute path. Set " + + 'AGENT_RELAY_MCP_COMMAND, or install Relay so agent-relay is on your PATH, and run relay init again ' + + 'before sharing them.' : undefined, }; } diff --git a/packages/cloud/src/project-workspace-key.test.ts b/packages/cloud/src/project-workspace-key.test.ts index 3977240d1a..28c7a8db5d 100644 --- a/packages/cloud/src/project-workspace-key.test.ts +++ b/packages/cloud/src/project-workspace-key.test.ts @@ -480,3 +480,23 @@ describe('workspace precedence ladder diagnostics', () => { }); }); }); + +describe('workspace key and git', () => { + it('keeps the project data directory holding the key out of git status', () => { + fs.mkdirSync(path.join(root, '.git')); + writeProjectWorkspaceKey(dataDir, 'rk_live_project'); + expect(fs.readFileSync(path.join(root, '.git', 'info', 'exclude'), 'utf8')).toBe( + '/.agentworkforce/relay/\n' + ); + writeProjectWorkspaceKeyPreservingSession(dataDir, 'rk_live_project'); + expect(fs.readFileSync(path.join(root, '.git', 'info', 'exclude'), 'utf8')).toBe( + '/.agentworkforce/relay/\n' + ); + }); + + it('leaves a caller-chosen data directory alone', () => { + fs.mkdirSync(path.join(root, '.git')); + writeProjectWorkspaceKey(path.join(root, 'state'), 'rk_live_project'); + expect(fs.existsSync(path.join(root, '.git', 'info', 'exclude'))).toBe(false); + }); +}); diff --git a/packages/cloud/src/project-workspace-key.ts b/packages/cloud/src/project-workspace-key.ts index b2d4567fad..ef6912098d 100644 --- a/packages/cloud/src/project-workspace-key.ts +++ b/packages/cloud/src/project-workspace-key.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import { getProjectPaths } from '@agent-relay/config'; +import { ensureGitExclude, getProjectPaths } from '@agent-relay/config'; import { readRelaycastCredential, @@ -177,6 +177,18 @@ export function writeProjectWorkspaceKey( withProjectWorkspaceKeyLock(dataDir, () => writeProjectWorkspaceKeyUnlocked(dataDir, key, metadata, env)); } +/** + * The workspace key is a credential: keep the project data directory that + * holds it out of `git status` (and so out of `git add -A`) through the + * repository's `info/exclude`. Only the standard `/.agentworkforce/relay` + * directory is excluded; a caller-chosen data directory elsewhere is left to + * its owner. + */ +function excludeProjectDataDirFromGit(dataDir: string): void { + const parts = path.resolve(dataDir).split(path.sep); + if (parts.at(-1) === 'relay' && parts.at(-2) === '.agentworkforce') ensureGitExclude(dataDir); +} + function writeProjectWorkspaceKeyUnlocked( dataDir: string, workspaceKey: string, @@ -206,6 +218,7 @@ function writeProjectWorkspaceKeyUnlocked( ); } fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 }); + excludeProjectDataDirFromGit(dataDir); const file = projectWorkspaceKeyPath(dataDir); // Worker threads share a PID, so include a per-write nonce as well as the PID. let tmp = `${file}.tmp.${process.pid}.${randomUUID()}`; diff --git a/packages/config/src/project-namespace.test.ts b/packages/config/src/project-namespace.test.ts index eb4b573c43..e9d16b7d0b 100644 --- a/packages/config/src/project-namespace.test.ts +++ b/packages/config/src/project-namespace.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { findProjectRoot } from './project-namespace.js'; +import { ensureGitExclude, findProjectRoot } from './project-namespace.js'; let root: string; beforeEach(() => { @@ -57,3 +57,51 @@ describe('shared repository workspace boundary', () => { expect(() => findProjectRoot(nested)).toThrow('Cannot resolve the repository workspace'); }); }); + +describe('ensureGitExclude', () => { + const dataDir = () => path.join(root, '.agentworkforce', 'relay'); + const exclude = (gitDir = path.join(root, '.git')) => + fs.readFileSync(path.join(gitDir, 'info', 'exclude'), 'utf8'); + + it('adds the data directory, anchored, to info/exclude once', () => { + fs.mkdirSync(path.join(root, '.git', 'info'), { recursive: true }); + fs.writeFileSync( + path.join(root, '.git', 'info', 'exclude'), + '# git ls-files --others --exclude-from=.git/info/exclude' + ); + expect(ensureGitExclude(dataDir())).toEqual({ modified: true }); + expect(ensureGitExclude(dataDir())).toEqual({ modified: false }); + expect(exclude()).toBe( + '# git ls-files --others --exclude-from=.git/info/exclude\n/.agentworkforce/relay/\n' + ); + }); + + it('creates info/exclude and anchors a project below the repository root', () => { + fs.mkdirSync(path.join(root, '.git')); + const nested = path.join(root, 'apps', 'web', '.agentworkforce', 'relay'); + expect(ensureGitExclude(nested)).toEqual({ modified: true }); + expect(exclude()).toBe('/apps/web/.agentworkforce/relay/\n'); + }); + + it('accepts the unanchored entry older versions wrote', () => { + fs.mkdirSync(path.join(root, '.git', 'info'), { recursive: true }); + fs.writeFileSync(path.join(root, '.git', 'info', 'exclude'), '.agentworkforce/relay/\n'); + expect(ensureGitExclude(dataDir())).toEqual({ modified: false }); + }); + + it('writes the shared exclude of a linked worktree', () => { + const common = path.join(root, 'main', '.git'); + const worktreeGitDir = path.join(common, 'worktrees', 'feature'); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + fs.writeFileSync(path.join(worktreeGitDir, 'commondir'), '../..\n'); + const worktree = path.join(root, 'feature'); + fs.mkdirSync(worktree); + fs.writeFileSync(path.join(worktree, '.git'), `gitdir: ${worktreeGitDir}\n`); + expect(ensureGitExclude(path.join(worktree, '.agentworkforce', 'relay'))).toEqual({ modified: true }); + expect(exclude(common)).toBe('/.agentworkforce/relay/\n'); + }); + + it('does nothing outside a git repository', () => { + expect(ensureGitExclude(dataDir())).toEqual({ modified: false }); + }); +}); diff --git a/packages/config/src/project-namespace.ts b/packages/config/src/project-namespace.ts index 3d261d2235..5cfa550ce8 100644 --- a/packages/config/src/project-namespace.ts +++ b/packages/config/src/project-namespace.ts @@ -113,62 +113,92 @@ export function getProjectPaths(projectRoot?: string): ProjectPaths { }; } +/** The `.git` directory or `gitdir:` file nearest `dir`, walking up. */ +function findDotGit(dir: string): string | undefined { + let current = path.resolve(dir); + for (;;) { + const candidate = path.join(current, '.git'); + if (fs.existsSync(candidate)) return candidate; + const parent = path.dirname(current); + if (parent === current) return undefined; + current = parent; + } +} + /** - * Add .agentworkforce/relay/ to .git/info/exclude (local per-repo gitignore). - * - * This file is: - * - Per-repository (not shared across clones) - * - Not committed (local to each developer) - * - Standard git feature with no permission issues - * - No branch/merge complications - * - * Returns true if the exclude file was modified + * The `info/exclude` git reads for the work tree whose `.git` is `dotGit`. + * A linked worktree or submodule has a `.git` file pointing at its git + * directory; a worktree shares `info/exclude` with the main repository + * through `commondir`, as `git rev-parse --git-path info/exclude` reports. */ -function ensureGitExclude(projectRoot: string): { modified: boolean } { - // Find the .git directory (could be in projectRoot or a parent for worktrees) - const gitDir = path.join(projectRoot, '.git'); - const excludePath = path.join(gitDir, 'info', 'exclude'); - +function gitExcludePath(dotGit: string): string | undefined { + let gitDir = dotGit; + if (fs.statSync(dotGit).isFile()) { + const match = /^gitdir:\s*(.+?)\s*$/m.exec(fs.readFileSync(dotGit, 'utf-8')); + if (!match) return undefined; + gitDir = path.resolve(path.dirname(dotGit), match[1]); + } + let commonDir = gitDir; try { - // Check if this is a git repository - if (!fs.existsSync(gitDir)) { - return { modified: false }; - } + const pointer = fs.readFileSync(path.join(gitDir, 'commondir'), 'utf-8').trim(); + if (pointer) commonDir = path.resolve(gitDir, pointer); + } catch { + // Not a linked worktree. + } + return path.join(commonDir, 'info', 'exclude'); +} - // Ensure .git/info directory exists - const infoDir = path.join(gitDir, 'info'); - if (!fs.existsSync(infoDir)) { - fs.mkdirSync(infoDir, { recursive: true }); - } +/** Escape a work-tree-relative path into an anchored gitignore pattern. */ +function anchoredPattern(relative: string): string { + return `/${relative + .split(path.sep) + .join('/') + .replace(/[\\*?[\]!#]/g, '\\$&')}/`; +} +/** + * Keep the project's `.agentworkforce/relay/` directory (workspace key, + * sockets, local state) out of `git status` by adding it to the repository's + * `info/exclude`, the per-clone ignore file that is never committed. The + * project's own `.gitignore` is never modified. + * + * Linked worktrees and submodules are handled (the exclude file lives in the + * repository's common git directory). Outside a git work tree, or on any + * error, nothing is changed. + * + * @param dataDir - The project data directory, `/.agentworkforce/relay`. + * @returns Whether the exclude file was changed. + */ +export function ensureGitExclude(dataDir: string): { modified: boolean } { + try { + const resolved = path.resolve(dataDir); + const dotGit = findDotGit(resolved); + if (!dotGit) return { modified: false }; + const relative = path.relative(path.dirname(dotGit), resolved); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return { modified: false }; + const excludePath = gitExcludePath(dotGit); + if (!excludePath) return { modified: false }; + + const pattern = anchoredPattern(relative); + const bare = pattern.slice(1, -1); let content = ''; - if (fs.existsSync(excludePath)) { + try { content = fs.readFileSync(excludePath, 'utf-8'); - // Check if .agentworkforce/relay is already in exclude - const lines = content.split('\n'); - const hasEntry = lines.some((line) => { - const trimmed = line.trim(); - return ( - trimmed === '.agentworkforce/relay' || - trimmed === '.agentworkforce/relay/' || - trimmed === '/.agentworkforce/relay' || - trimmed === '/.agentworkforce/relay/' - ); - }); - if (hasEntry) { - return { modified: false }; // Already present - } + } catch { + // No exclude file yet. } - - // Add .agentworkforce/relay/ to exclude - const newEntry = '.agentworkforce/relay/'; - const newContent = - content.endsWith('\n') || content === '' ? `${content}${newEntry}\n` : `${content}\n${newEntry}\n`; - - fs.writeFileSync(excludePath, newContent, 'utf-8'); + const present = content + .split('\n') + .map((line) => line.trim()) + .some((line) => [bare, `${bare}/`, `/${bare}`, pattern].includes(line)); + if (present) return { modified: false }; + + fs.mkdirSync(path.dirname(excludePath), { recursive: true }); + const separator = content === '' || content.endsWith('\n') ? '' : '\n'; + fs.appendFileSync(excludePath, `${separator}${pattern}\n`, 'utf-8'); return { modified: true }; } catch { - // Silently ignore errors (not a git repo, no write permission, etc.) + // Not a git repository we can write to; never block the caller. return { modified: false }; } } @@ -186,7 +216,7 @@ export function ensureProjectDir(projectRoot?: string): ProjectPaths { fs.mkdirSync(paths.dataDir, { recursive: true }); // Add to .git/info/exclude (local per-repo gitignore, not committed) - const excludeResult = ensureGitExclude(paths.projectRoot); + const excludeResult = ensureGitExclude(paths.dataDir); if (excludeResult.modified) { console.log('[agent-relay] Added .agentworkforce/relay/ to .git/info/exclude'); } From 328c518fc58ec84dced042f71d96f44821b92043 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 27 Oct 2026 21:36:40 -0700 Subject: [PATCH 2/7] fix(cli): read quoted TOML table headers that contain ] Codex project paths such as /work/client[old] were missed, so relay init re-offered trust and appended a duplicate table. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/cli/src/cli/lib/agent-tools-mcp.test.ts | 16 ++++++++++++++++ packages/cli/src/cli/lib/agent-tools-mcp.ts | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli/lib/agent-tools-mcp.test.ts b/packages/cli/src/cli/lib/agent-tools-mcp.test.ts index bfd69c7713..0f70c0ea92 100644 --- a/packages/cli/src/cli/lib/agent-tools-mcp.test.ts +++ b/packages/cli/src/cli/lib/agent-tools-mcp.test.ts @@ -584,6 +584,22 @@ describe('Codex project trust', () => { expect(read(codexConfig())).toBe(text); }); + it('finds an existing entry for a path with brackets instead of adding a duplicate table', () => { + const bracketed = path.join(root, 'client[old]'); + fs.mkdirSync(bracketed); + const bctx = { ...ctx, projectRoot: bracketed }; + const header = `[projects.${JSON.stringify(fs.realpathSync(bracketed))}]`; + writeCodexConfig(`${header}\ntrust_level = "trusted"\n\n[mcp_servers.x]\ncommand = "x"\n`); + expect(codexProjectTrust(bctx)).toBe('trusted'); + trustCodexProject(bctx); + expect(read(codexConfig()).split(header)).toHaveLength(2); + writeCodexConfig(`${header}\nmodel = "o3"\n`); + expect(codexProjectTrust(bctx)).toBe('unset'); + trustCodexProject(bctx); + expect(read(codexConfig())).toBe(`${header}\ntrust_level = "trusted"\nmodel = "o3"\n`); + expect(codexProjectTrust(bctx)).toBe('trusted'); + }); + it('creates the Codex config when there is none', () => { trustCodexProject(ctx); expect(codexProjectTrust(ctx)).toBe('trusted'); diff --git a/packages/cli/src/cli/lib/agent-tools-mcp.ts b/packages/cli/src/cli/lib/agent-tools-mcp.ts index 0ac94d7919..cc4fb35ae7 100644 --- a/packages/cli/src/cli/lib/agent-tools-mcp.ts +++ b/packages/cli/src/cli/lib/agent-tools-mcp.ts @@ -761,7 +761,8 @@ function jsonIndent(text: string): string | number { // Only the `[mcp_servers.agent-relay]` table and its sub-tables are touched; // every other line, comment included, is kept byte for byte. -const TOML_HEADER = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$/; +// Quote-aware: a quoted key may contain `]`, as in [projects."/work/client[old]"]. +const TOML_HEADER = /^\s*\[\[?\s*((?:"(?:[^"\\]|\\.)*"|'[^']*'|[^\]"'])+?)\s*\]\]?\s*(?:#.*)?$/; const TOML_TABLE = `mcp_servers.${MCP_SERVER_NAME}`; function tomlKey(raw: string): string { From 118539cd3030b9f8e30d60319f611a0c4b94565a Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 27 Oct 2026 21:43:39 -0700 Subject: [PATCH 3/7] fix(cli): parse quoted Codex project keys, print a valid two-line trust snippet - Recognise ["projects"."/repo"] and other quotings of the project table, and refuse [projects], inline, dotted and array-of-tables forms instead of appending a duplicate table. - The --yes note now prints the trust snippet as two TOML lines. - JSDoc @param/@returns on the new exports. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 +- docs/quickstart.md | 2 +- packages/cli/src/cli/commands/init.test.ts | 2 +- .../cli/src/cli/lib/agent-tools-mcp.test.ts | 26 +++- packages/cli/src/cli/lib/agent-tools-mcp.ts | 128 ++++++++++++++---- 5 files changed, 130 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26fa98e969..fcaf3a6013 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `install.sh` now also installs the short `relay` command next to `agent-relay` (same CLI), so `relay init` works right after a curl install. An existing `relay` from another tool is left alone with a warning, and `agent-relay uninstall` removes `relay` only when the installer wrote it. - `relay init` no longer switches the machine-wide active workspace away from one already in use: it saves the new workspace for the current project, keeps the existing active one, and prints `relay workspace switch ` to make it the default. - `relay init` no longer writes npx's temporary cache path into agent tool configs. Project configs (`.mcp.json` and the like) run `agent-relay mcp` by name, or `npx -y agent-relay@ mcp` when Relay is not installed, so they work on teammates' machines. Re-running `relay init` updates entries it wrote earlier. -- `relay init --scope project` now tells the truth about Codex: Codex loads `.codex/config.toml` only when that exact project is trusted in `~/.codex/config.toml` (a trusted parent folder does not count, and `codex exec` never asks). In a terminal, `relay init` offers to record that trust; with `--yes` it prints the line to add. +- `relay init --scope project` now tells the truth about Codex: Codex loads `.codex/config.toml` only when that exact project is trusted in `~/.codex/config.toml` (a trusted parent folder does not count, and `codex exec` never asks). In a terminal, `relay init` offers to record that trust; with `--yes` it prints the lines to add. - The project workspace key file (`.agentworkforce/relay/workspace-key.json`) is no longer picked up by `git add -A`: any command that saves it adds `.agentworkforce/relay/` to `.git/info/exclude` (worktrees included), never to `.gitignore`. - `relay node up` now reports the actual workspace-key source when startup fails, including the repository's `.agentworkforce/relay/workspace-key.json` pin instead of mislabeling every key as `AGENT_RELAY_WORKSPACE_KEY`. diff --git a/docs/quickstart.md b/docs/quickstart.md index 30bd480b9e..798e4f6863 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -94,7 +94,7 @@ it asks about on first run. Codex reads `.codex/config.toml` only when that exact project (its git root) is trusted in `~/.codex/config.toml`; a trusted parent folder does not count, and `codex exec` never asks. In a terminal `relay init` offers to record that trust for you; with `--yes` it prints the -line to add instead. +lines to add instead. In a git repository, `relay init` keeps `.agentworkforce/relay/` (which holds the project's workspace key) out of `git status` through `.git/info/exclude`; diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index c6ec6e5b34..af25dde8c0 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -441,7 +441,7 @@ describe('relay init agent tools (non-interactive)', () => { // --yes never trusts it on the person's behalf. expect(output).toContain('Codex reads .codex/config.toml only in projects it trusts'); expect(output).toContain( - `[projects.${JSON.stringify(fs.realpathSync(project))}] trust_level = "trusted"` + `[projects.${JSON.stringify(fs.realpathSync(project))}]\n trust_level = "trusted"` ); expect(fs.existsSync(path.join(toolHome, '.codex', 'config.toml'))).toBe(false); }); diff --git a/packages/cli/src/cli/lib/agent-tools-mcp.test.ts b/packages/cli/src/cli/lib/agent-tools-mcp.test.ts index 0f70c0ea92..d8f274803c 100644 --- a/packages/cli/src/cli/lib/agent-tools-mcp.test.ts +++ b/packages/cli/src/cli/lib/agent-tools-mcp.test.ts @@ -605,9 +605,31 @@ describe('Codex project trust', () => { expect(codexProjectTrust(ctx)).toBe('trusted'); }); - it('refuses a projects table it would have to restructure', () => { - writeCodexConfig('[projects]\n"/somewhere" = { trust_level = "trusted" }\n'); + it('recognises the project table however its key is quoted', () => { + writeCodexConfig(`["projects".${JSON.stringify(realProject())}]\ntrust_level = "trusted"\n`); + expect(codexProjectTrust(ctx)).toBe('trusted'); + writeCodexConfig(`[ "projects" . '${realProject()}' ]\nmodel = "o3"\n`); + trustCodexProject(ctx); + expect(read(codexConfig())).toBe( + `[ "projects" . '${realProject()}' ]\ntrust_level = "trusted"\nmodel = "o3"\n` + ); + }); + + it('prints a trust snippet that is valid TOML on two lines', () => { + expect(codexTrustNote(ctx)).toContain( + `[projects.${JSON.stringify(realProject())}]\n trust_level = "trusted"` + ); + }); + + it.each([ + ['a [projects] table', '[projects]\n"/somewhere" = { trust_level = "trusted" }\n'], + ['an inline table', 'projects = { "/somewhere" = { trust_level = "trusted" } }\n'], + ['dotted keys', '"projects"."/somewhere".trust_level = "trusted"\n'], + ['an array of tables', '[[projects]]\npath = "/somewhere"\n'], + ])('refuses %s it would have to restructure', (_name, text) => { + writeCodexConfig(text); expect(() => trustCodexProject(ctx)).toThrow(/does not edit/); + expect(read(codexConfig())).toBe(text); }); }); diff --git a/packages/cli/src/cli/lib/agent-tools-mcp.ts b/packages/cli/src/cli/lib/agent-tools-mcp.ts index cc4fb35ae7..1241230c05 100644 --- a/packages/cli/src/cli/lib/agent-tools-mcp.ts +++ b/packages/cli/src/cli/lib/agent-tools-mcp.ts @@ -275,7 +275,12 @@ export const TOOL_TARGETS: readonly ToolTarget[] = [ // count, and `codex exec` never asks: only an interactive `codex` offers to // trust the folder on first run. -/** The directory Codex treats as this project's root, which is what it trusts. */ +/** + * The directory Codex treats as this project's root, which is what it trusts. + * + * @param ctx - Project root, home and environment to resolve against. + * @returns The real path of the nearest directory with a `.git`, else the project root. + */ export function codexProjectRoot(ctx: ToolContext): string { const start = realpath(ctx.projectRoot) ?? path.resolve(ctx.projectRoot); let current = start; @@ -287,37 +292,74 @@ export function codexProjectRoot(ctx: ToolContext): string { } } -/** Codex's user config, where project trust is recorded. */ +/** + * Codex's user config, where project trust is recorded. + * + * @param ctx - Home and environment (`CODEX_HOME`) to resolve against. + * @returns `$CODEX_HOME/config.toml`, by default `~/.codex/config.toml`. + */ export function codexUserConfigPath(ctx: ToolContext): string { return path.join(codexHome(ctx), 'config.toml'); } -const CODEX_PROJECT_HEADER = /^\s*\[\s*projects\s*\.\s*("(?:[^"\\]|\\.)*"|'[^']*')\s*\]\s*(?:#.*)?$/; - -function tomlQuotedKey(raw: string): string | undefined { - if (raw.startsWith("'")) return raw.slice(1, -1); - try { - return JSON.parse(raw) as string; - } catch { - return undefined; +/** + * Split a TOML key (`projects."/repo"`, `"projects".'/repo'`) into its + * decoded parts, or undefined when it is not a key this parser understands. + */ +function tomlKeyParts(raw: string): string[] | undefined { + const parts: string[] = []; + const segment = /\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|([A-Za-z0-9_-]+))\s*(\.|$)/y; + let index = 0; + while (index < raw.length) { + segment.lastIndex = index; + const match = segment.exec(raw); + if (!match) return undefined; + if (match[1] !== undefined) { + try { + parts.push(JSON.parse(`"${match[1]}"`) as string); + } catch { + return undefined; + } + } else { + parts.push(match[2] ?? match[3]); + } + index = segment.lastIndex; + if (match[4] === '' && index < raw.length) return undefined; + if (match[4] === '.' && index >= raw.length) return undefined; } + return parts.length > 0 ? parts : undefined; +} + +/** The key of a `[table]` header line, or undefined for any other line. */ +function tomlTableKey(line: string): string[] | undefined { + const match = TOML_HEADER.exec(line); + return match && !line.trim().startsWith('[[') ? tomlKeyParts(match[1]) : undefined; +} + +/** Whether a header line opens `[projects.""]`, however the key is quoted. */ +function isProjectTable(line: string, root: string): boolean { + const key = tomlTableKey(line); + return Boolean(key && key.length === 2 && key[0] === 'projects' && key[1] === root); } /** Codex's recorded trust for this project. */ export type CodexTrust = 'trusted' | 'untrusted' | 'unset'; -/** Whether Codex will read this project's .codex/config.toml. */ +/** + * Whether Codex will read this project's `.codex/config.toml`. + * + * @param ctx - Project root, home and environment to resolve against. + * @returns The trust level recorded for exactly this project's root. + */ export function codexProjectTrust(ctx: ToolContext): CodexTrust { const root = codexProjectRoot(ctx); const text = readText(codexUserConfigPath(ctx)); if (text === undefined) return 'unset'; - const lines = text.split('\n'); let inRoot = false; let trust: CodexTrust = 'unset'; - for (const line of lines) { + for (const line of text.split('\n')) { if (TOML_HEADER.test(line)) { - const match = CODEX_PROJECT_HEADER.exec(line); - inRoot = Boolean(match && tomlQuotedKey(match[1]) === root); + inRoot = isProjectTable(line, root); continue; } if (!inRoot) continue; @@ -327,27 +369,49 @@ export function codexProjectTrust(ctx: ToolContext): CodexTrust { return trust; } +/** + * Whether `projects` is declared anywhere other than `[projects.]` + * tables: a `[projects]` table, an inline table, dotted keys, or a header + * this parser cannot read. Appending a table then could duplicate a key. + */ +function hasUneditableProjects(lines: string[]): boolean { + let table: string[] | undefined = []; + for (const line of lines) { + if (TOML_HEADER.test(line)) { + table = tomlTableKey(line); + if (table === undefined && /projects/.test(line)) return true; + if (table?.[0] === 'projects' && table.length !== 2) return true; + continue; + } + const assignment = /^\s*([^=#]+?)\s*=/.exec(line); + if (!assignment || table === undefined) continue; + const key = [...table, ...(tomlKeyParts(assignment[1]) ?? [assignment[1]])]; + if (key[0] === 'projects' && table[0] !== 'projects') return true; + } + return false; +} + /** * Record this project as trusted in Codex's user config, as Codex's own * first-run prompt does. Trust also relaxes Codex's approval defaults in the - * project, so callers ask first. Refuses (throws) rather than risk a config - * Codex cannot parse. + * project, so callers ask first. + * + * @param ctx - Project root, home and environment to resolve against. + * @throws When the config declares projects in a form this would have to + * restructure, rather than risk a config Codex cannot parse. */ export function trustCodexProject(ctx: ToolContext): void { const root = codexProjectRoot(ctx); const file = codexUserConfigPath(ctx); const text = readText(file) ?? ''; const lines = text.split('\n'); - if (lines.some((line) => /^\s*projects\s*[.=]/.test(line) || /^\s*\[\s*projects\s*\]/.test(line))) { + if (hasUneditableProjects(lines)) { throw new Error( `${file} declares projects in a form relay init does not edit; trust ${root} in Codex instead.` ); } const header = `[projects.${JSON.stringify(root)}]`; - const index = lines.findIndex((line) => { - const match = CODEX_PROJECT_HEADER.exec(line); - return Boolean(match && tomlQuotedKey(match[1]) === root); - }); + const index = lines.findIndex((line) => isProjectTable(line, root)); let next: string; if (index === -1) { const base = text.replace(/\n*$/, ''); @@ -361,7 +425,12 @@ export function trustCodexProject(ctx: ToolContext): void { writeKeepingMode(file, next, 0o600); } -/** The step left before Codex loads the project entry, if any. */ +/** + * The step left before Codex loads the project entry, if any. + * + * @param ctx - Project root, home and environment to resolve against. + * @returns A note for the person, or undefined when Codex already trusts the project. + */ export function codexTrustNote(ctx: ToolContext): string | undefined { const trust = codexProjectTrust(ctx); if (trust === 'trusted') return undefined; @@ -375,7 +444,8 @@ export function codexTrustNote(ctx: ToolContext): string | undefined { return ( `Codex reads .codex/config.toml only in projects it trusts, and ${root} is not trusted yet ` + '(a trusted parent folder does not count, and codex exec never asks). Open codex here once and trust ' + - `the folder, or add [projects.${JSON.stringify(root)}] trust_level = "trusted" to ${codexUserConfigPath(ctx)}.` + `the folder, or add these two lines to ${codexUserConfigPath(ctx)}:\n` + + ` [projects.${JSON.stringify(root)}]\n trust_level = "trusted"` ); } @@ -497,7 +567,12 @@ function realpath(file: string): string | undefined { } } -/** A path npm or Bun may delete at any time: an npx/bunx cache copy. */ +/** + * Whether a path is one npm or Bun may delete at any time: an npx/bunx cache copy. + * + * @param file - Path to check. + * @returns True for paths under `_npx`, npm's `_cacache`, or a `bunx-` directory. + */ export function isEphemeralPath(file: string): boolean { const normalized = file.replace(/\\/g, '/'); return ( @@ -553,6 +628,9 @@ function npxLaunch(version: string | undefined, reason: string): McpLaunch { * 2. For a temporary npx copy: a lasting `agent-relay` on PATH, else * `npx -y agent-relay@ mcp`. * 3. The running CLI by absolute path. + * + * @param inputs - Tool context, scope, version, and (in tests) the running process. + * @returns The command and arguments to write, why, and any warning to show. */ export function resolveMcpLaunch(inputs: LaunchInputs): McpLaunch { const { ctx } = inputs; From 200453e591bf6b31af35b3133d00d67622a4bb5c Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 27 Oct 2026 21:47:14 -0700 Subject: [PATCH 4/7] fix(cli): run npx through cmd on Windows; scrub git env in test; cover relative gitdir - Windows: write `cmd /c npx -y agent-relay@ mcp` and warm the cache through a shell, since npx.cmd cannot be spawned directly. - init.test: strip GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE/GIT_COMMON_DIR. - config: test a submodule .git file with a relative gitdir. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/cli/src/cli/commands/init.test.ts | 6 ++++- packages/cli/src/cli/commands/init.ts | 15 ++++++----- .../cli/src/cli/lib/agent-tools-mcp.test.ts | 11 ++++++++ packages/cli/src/cli/lib/agent-tools-mcp.ts | 27 +++++++++++++++---- packages/config/src/project-namespace.test.ts | 10 +++++++ 5 files changed, 56 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index af25dde8c0..5d3b8599dc 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -490,8 +490,11 @@ describe('relay init agent tools (non-interactive)', () => { }); it('keeps the workspace key out of git status in a git project', async () => { + // A git hook or shell may point git elsewhere; aim it at the test project only. + const gitEnv = { ...process.env }; + for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR']) delete gitEnv[key]; try { - execFileSync('git', ['init', '-q', project]); + execFileSync('git', ['init', '-q', project], { env: gitEnv }); } catch { return; // git is not installed here. } @@ -502,6 +505,7 @@ describe('relay init agent tools (non-interactive)', () => { '/.agentworkforce/relay/' ); const status = execFileSync('git', ['-C', project, 'status', '--porcelain', '--untracked-files=all'], { + env: gitEnv, encoding: 'utf8', }); expect(status).not.toContain('.agentworkforce'); diff --git a/packages/cli/src/cli/commands/init.ts b/packages/cli/src/cli/commands/init.ts index f664266b77..561d01d8be 100644 --- a/packages/cli/src/cli/commands/init.ts +++ b/packages/cli/src/cli/commands/init.ts @@ -24,6 +24,7 @@ import { findOnPath, findToolTarget, installTool, + npxPackageSpec, parseToolsFlag, resolveMcpLaunch, trustCodexProject, @@ -206,14 +207,14 @@ function defaultStartEngine({ dataDir, port, logFile }: { dataDir: string; port: } function defaultWarmLaunch(launch: McpLaunch): void { - if (!/^npx(?:\.cmd)?$/.test(launch.command)) return; + const spec = npxPackageSpec(launch); + if (!spec) return; // `--version` runs no Relay code beyond printing; failures are left for the - // agent tool to surface, since the entry itself is still right. - spawnSync(launch.command, [...launch.args.slice(0, -1), '--version'], { - stdio: 'ignore', - timeout: 180_000, - env: process.env, - }); + // agent tool to surface, since the entry itself is still right. Windows + // starts npx (a .cmd) only through a shell; the spec is a plain package name. + const options = { stdio: 'ignore' as const, timeout: 180_000, env: process.env }; + if (process.platform === 'win32') spawnSync(`npx -y ${spec} --version`, { ...options, shell: true }); + else spawnSync('npx', ['-y', spec, '--version'], options); } function defaultIsAlive(pid: number): boolean { diff --git a/packages/cli/src/cli/lib/agent-tools-mcp.test.ts b/packages/cli/src/cli/lib/agent-tools-mcp.test.ts index d8f274803c..18d0a98383 100644 --- a/packages/cli/src/cli/lib/agent-tools-mcp.test.ts +++ b/packages/cli/src/cli/lib/agent-tools-mcp.test.ts @@ -15,6 +15,7 @@ import { codexTrustNote, detectTools, findToolTarget, + npxPackageSpec, installTool, parseToolsFlag, resolveMcpLaunch, @@ -466,6 +467,16 @@ describe('resolveMcpLaunch', () => { }); }); + it('names the npx package spec to warm, and nothing for other launches', () => { + expect(npxPackageSpec({ command: 'npx', args: ['-y', 'agent-relay@15.0.0', 'mcp'], reason: '' })).toBe( + 'agent-relay@15.0.0' + ); + expect( + npxPackageSpec({ command: 'cmd', args: ['/c', 'npx', '-y', 'agent-relay@15.0.0', 'mcp'], reason: '' }) + ).toBe('agent-relay@15.0.0'); + expect(npxPackageSpec(launch)).toBeUndefined(); + }); + it('leaves the npx version unpinned when this build has no published version', () => { const { script } = npxRun(); expect( diff --git a/packages/cli/src/cli/lib/agent-tools-mcp.ts b/packages/cli/src/cli/lib/agent-tools-mcp.ts index 1241230c05..7d3943b011 100644 --- a/packages/cli/src/cli/lib/agent-tools-mcp.ts +++ b/packages/cli/src/cli/lib/agent-tools-mcp.ts @@ -597,11 +597,28 @@ function publishedVersion(version: string | undefined): string | undefined { /** `npx -y agent-relay@ mcp`: works on any machine with Node, and never goes stale. */ function npxLaunch(version: string | undefined, reason: string): McpLaunch { const pinned = publishedVersion(version); - return { - command: process.platform === 'win32' ? 'npx.cmd' : 'npx', - args: ['-y', pinned ? `${NPM_PACKAGE}@${pinned}` : NPM_PACKAGE, 'mcp'], - reason, - }; + const npxArgs = ['-y', pinned ? `${NPM_PACKAGE}@${pinned}` : NPM_PACKAGE, 'mcp']; + // Windows cannot start npx.cmd without a shell; MCP clients document `cmd /c npx`. + return process.platform === 'win32' + ? { command: 'cmd', args: ['/c', 'npx', ...npxArgs], reason } + : { command: 'npx', args: npxArgs, reason }; +} + +/** + * The package spec an `npx` launch from {@link resolveMcpLaunch} runs, so it + * can be fetched ahead of time. + * + * @param launch - A resolved launch. + * @returns `agent-relay@` for an npx launch, else undefined. + */ +export function npxPackageSpec(launch: McpLaunch): string | undefined { + const args = + launch.command === 'npx' + ? launch.args + : launch.command === 'cmd' && launch.args[0] === '/c' && launch.args[1] === 'npx' + ? launch.args.slice(2) + : undefined; + return args?.[0] === '-y' && args[1]?.startsWith(NPM_PACKAGE) ? args[1] : undefined; } /** diff --git a/packages/config/src/project-namespace.test.ts b/packages/config/src/project-namespace.test.ts index e9d16b7d0b..4b4d3d5ed0 100644 --- a/packages/config/src/project-namespace.test.ts +++ b/packages/config/src/project-namespace.test.ts @@ -101,6 +101,16 @@ describe('ensureGitExclude', () => { expect(exclude(common)).toBe('/.agentworkforce/relay/\n'); }); + it('writes the exclude of a submodule whose .git file has a relative gitdir', () => { + const gitDir = path.join(root, 'super', '.git', 'modules', 'sub'); + fs.mkdirSync(gitDir, { recursive: true }); + const sub = path.join(root, 'super', 'sub'); + fs.mkdirSync(sub, { recursive: true }); + fs.writeFileSync(path.join(sub, '.git'), 'gitdir: ../.git/modules/sub\n'); + expect(ensureGitExclude(path.join(sub, '.agentworkforce', 'relay'))).toEqual({ modified: true }); + expect(exclude(gitDir)).toBe('/.agentworkforce/relay/\n'); + }); + it('does nothing outside a git repository', () => { expect(ensureGitExclude(dataDir())).toEqual({ modified: false }); }); From 43eb7d185b3a2d6e6d92e4ae5aa23f3cae8ead3d Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 27 Oct 2026 21:57:46 -0700 Subject: [PATCH 5/7] fix(config): read .git directly instead of stat-then-read (CodeQL race) Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/config/src/project-namespace.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/config/src/project-namespace.ts b/packages/config/src/project-namespace.ts index 5cfa550ce8..0ccc31d43f 100644 --- a/packages/config/src/project-namespace.ts +++ b/packages/config/src/project-namespace.ts @@ -133,8 +133,15 @@ function findDotGit(dir: string): string | undefined { */ function gitExcludePath(dotGit: string): string | undefined { let gitDir = dotGit; - if (fs.statSync(dotGit).isFile()) { - const match = /^gitdir:\s*(.+?)\s*$/m.exec(fs.readFileSync(dotGit, 'utf-8')); + // Read rather than stat-then-read: a directory fails with EISDIR. + let pointerFile: string | undefined; + try { + pointerFile = fs.readFileSync(dotGit, 'utf-8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EISDIR') return undefined; + } + if (pointerFile !== undefined) { + const match = /^gitdir:\s*(.+?)\s*$/m.exec(pointerFile); if (!match) return undefined; gitDir = path.resolve(path.dirname(dotGit), match[1]); } From 48a6834aa57dc0d525afee752631ecc16c8e0165 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 27 Oct 2026 22:04:00 -0700 Subject: [PATCH 6/7] fix(config): detect a .git directory with fstat on the open descriptor FreeBSD reads a directory instead of failing with EISDIR. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/config/src/project-namespace.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/config/src/project-namespace.ts b/packages/config/src/project-namespace.ts index 0ccc31d43f..a1fbe74ad1 100644 --- a/packages/config/src/project-namespace.ts +++ b/packages/config/src/project-namespace.ts @@ -133,12 +133,18 @@ function findDotGit(dir: string): string | undefined { */ function gitExcludePath(dotGit: string): string | undefined { let gitDir = dotGit; - // Read rather than stat-then-read: a directory fails with EISDIR. + // Check and read through one descriptor, so the answer cannot change + // between them. Some platforms refuse to open a directory (EISDIR); others + // open it, and FreeBSD would even "read" it, hence fstat. let pointerFile: string | undefined; + let fd: number | undefined; try { - pointerFile = fs.readFileSync(dotGit, 'utf-8'); + fd = fs.openSync(dotGit, 'r'); + if (!fs.fstatSync(fd).isDirectory()) pointerFile = fs.readFileSync(fd, 'utf-8'); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EISDIR') return undefined; + } finally { + if (fd !== undefined) fs.closeSync(fd); } if (pointerFile !== undefined) { const match = /^gitdir:\s*(.+?)\s*$/m.exec(pointerFile); From 363aa3f02736f4ababedfff4c73e6309d792be77 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 27 Oct 2026 22:13:54 -0700 Subject: [PATCH 7/7] fix(config): skip a .git file whose gitdir no longer exists Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/config/src/project-namespace.test.ts | 7 +++++++ packages/config/src/project-namespace.ts | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/packages/config/src/project-namespace.test.ts b/packages/config/src/project-namespace.test.ts index 4b4d3d5ed0..5351455ce0 100644 --- a/packages/config/src/project-namespace.test.ts +++ b/packages/config/src/project-namespace.test.ts @@ -111,6 +111,13 @@ describe('ensureGitExclude', () => { expect(exclude(gitDir)).toBe('/.agentworkforce/relay/\n'); }); + it('writes nothing for a .git file whose gitdir no longer exists', () => { + const missing = path.join(root, 'gone', '.git', 'worktrees', 'feature'); + fs.writeFileSync(path.join(root, '.git'), `gitdir: ${missing}\n`); + expect(ensureGitExclude(dataDir())).toEqual({ modified: false }); + expect(fs.existsSync(path.join(root, 'gone'))).toBe(false); + }); + it('does nothing outside a git repository', () => { expect(ensureGitExclude(dataDir())).toEqual({ modified: false }); }); diff --git a/packages/config/src/project-namespace.ts b/packages/config/src/project-namespace.ts index a1fbe74ad1..68edffce40 100644 --- a/packages/config/src/project-namespace.ts +++ b/packages/config/src/project-namespace.ts @@ -150,6 +150,12 @@ function gitExcludePath(dotGit: string): string | undefined { const match = /^gitdir:\s*(.+?)\s*$/m.exec(pointerFile); if (!match) return undefined; gitDir = path.resolve(path.dirname(dotGit), match[1]); + // A stale pointer (moved or pruned worktree) is not a repository to write into. + try { + if (!fs.statSync(gitDir).isDirectory()) return undefined; + } catch { + return undefined; + } } let commonDir = gitDir; try {