Skip to content
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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@<version> 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 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`.

### Security
Expand Down
22 changes: 18 additions & 4 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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@<version> 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
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`;
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
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cli/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
103 changes: 102 additions & 1 deletion packages/cli/src/cli/commands/init.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -87,6 +88,7 @@ function setup(overrides: Partial<InitCommandDependencies> = {}) {
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');
Expand Down Expand Up @@ -435,7 +437,81 @@ 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))}]\n 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 () => {
// 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], { env: gitEnv });
} 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'], {
env: gitEnv,
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 () => {
Expand Down Expand Up @@ -549,6 +625,7 @@ describe('relay init guided flow', () => {
['claude', 'codex'],
'project',
true,
true,
]);
const { run, createWorkspace } = setup({ isInteractive: () => true, prompter: () => script.prompter });
await run();
Expand All @@ -560,7 +637,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();
Expand All @@ -573,6 +658,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 });
Expand Down
71 changes: 61 additions & 10 deletions packages/cli/src/cli/commands/init.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,14 +15,19 @@ import {
import {
TOOL_IDS,
TOOL_TARGETS,
codexProjectRoot,
codexProjectTrust,
codexUserConfigPath,
defaultRunTool,
defaultToolContext,
detectTools,
findOnPath,
findToolTarget,
installTool,
npxPackageSpec,
parseToolsFlag,
resolveMcpLaunch,
trustCodexProject,
uninstallTool,
type InstallScope,
type McpLaunch,
Expand Down Expand Up @@ -71,8 +76,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`. */
Expand Down Expand Up @@ -194,6 +206,17 @@ function defaultStartEngine({ dataDir, port, logFile }: { dataDir: string; port:
}
}

function defaultWarmLaunch(launch: McpLaunch): void {
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. 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);
}

Comment thread
khaliqgant marked this conversation as resolved.
Resolved
function defaultIsAlive(pid: number): boolean {
try {
process.kill(pid, 0);
Expand Down Expand Up @@ -230,7 +253,8 @@ export function withInitDefaults(overrides: Partial<InitCommandDependencies> = {
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,
Expand Down Expand Up @@ -605,9 +629,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));
}

Expand Down Expand Up @@ -694,7 +718,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:`
Expand All @@ -704,7 +729,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) {
Expand All @@ -717,6 +742,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<void> {
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<void> {
const p = deps.prompter();
Expand Down Expand Up @@ -811,7 +860,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)!;
Expand All @@ -831,12 +880,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);

Expand Down
Loading
Loading