diff --git a/.agentworkforce/trajectories/completed/2026-07/traj_f8bgcpr32y71/summary.md b/.agentworkforce/trajectories/completed/2026-07/traj_f8bgcpr32y71/summary.md new file mode 100644 index 000000000..399ed2a40 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-07/traj_f8bgcpr32y71/summary.md @@ -0,0 +1,33 @@ +# Trajectory: Add --wk shorthand for --workspace-key on SDK commands + +> **Status:** ✅ Completed +> **Confidence:** 90% +> **Started:** July 15, 2026 at 06:57 PM +> **Completed:** July 15, 2026 at 06:57 PM + +--- + +## Summary + +Added --wk alias for --workspace-key via preAction hook in addSdkOptions + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Fold --wk into workspaceKey via a Commander preAction hook in addSdkOptions + +- **Chose:** Fold --wk into workspaceKey via a Commander preAction hook in addSdkOptions +- **Reasoning:** Single choke point normalizes the alias for every reader; Commander v12 can't alias two long flags to one attribute + +--- + +## Chapters + +### 1. Work + +_Agent: default_ + +- Fold --wk into workspaceKey via a Commander preAction hook in addSdkOptions: Fold --wk into workspaceKey via a Commander preAction hook in addSdkOptions diff --git a/.agentworkforce/trajectories/completed/2026-07/traj_f8bgcpr32y71/trajectory.json b/.agentworkforce/trajectories/completed/2026-07/traj_f8bgcpr32y71/trajectory.json new file mode 100644 index 000000000..248ccc383 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-07/traj_f8bgcpr32y71/trajectory.json @@ -0,0 +1,53 @@ +{ + "id": "traj_f8bgcpr32y71", + "version": 1, + "task": { + "title": "Add --wk shorthand for --workspace-key on SDK commands" + }, + "status": "completed", + "startedAt": "2026-07-15T18:57:06.368Z", + "completedAt": "2026-07-15T18:57:13.746Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-15T18:57:07.613Z" + } + ], + "chapters": [ + { + "id": "chap_pz1ubxwwsf70", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-15T18:57:07.613Z", + "endedAt": "2026-07-15T18:57:13.746Z", + "events": [ + { + "ts": 1784141827614, + "type": "decision", + "content": "Fold --wk into workspaceKey via a Commander preAction hook in addSdkOptions: Fold --wk into workspaceKey via a Commander preAction hook in addSdkOptions", + "raw": { + "question": "Fold --wk into workspaceKey via a Commander preAction hook in addSdkOptions", + "chosen": "Fold --wk into workspaceKey via a Commander preAction hook in addSdkOptions", + "alternatives": [], + "reasoning": "Single choke point normalizes the alias for every reader; Commander v12 can't alias two long flags to one attribute" + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Added --wk alias for --workspace-key via preAction hook in addSdkOptions", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relay", + "tags": [], + "_trace": { + "startRef": "e93e5c7b8f94b35d718057baec5e4242d198dfb7", + "endRef": "e93e5c7b8f94b35d718057baec5e4242d198dfb7" + } +} diff --git a/.agentworkforce/trajectories/completed/2026-07/traj_sgdizcyacm51/summary.md b/.agentworkforce/trajectories/completed/2026-07/traj_sgdizcyacm51/summary.md new file mode 100644 index 000000000..07a5c9d68 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-07/traj_sgdizcyacm51/summary.md @@ -0,0 +1,24 @@ +# Trajectory: Persist up workspace key per-project; resolve it in CWD; add --wk to up + +> **Status:** ✅ Completed +> **Confidence:** 85% +> **Started:** July 15, 2026 at 07:28 PM +> **Completed:** July 15, 2026 at 07:28 PM + +--- + +## Summary + +up persists project workspace key; CWD resolution; --wk on up + +**Approach:** Standard approach + +--- + +## Chapters + +### 1. Work + +_Agent: default_ + +- Persist workspace key per-project on up; resolve below flag/env above global store: Persist workspace key per-project on up; resolve below flag/env above global store diff --git a/.agentworkforce/trajectories/completed/2026-07/traj_sgdizcyacm51/trajectory.json b/.agentworkforce/trajectories/completed/2026-07/traj_sgdizcyacm51/trajectory.json new file mode 100644 index 000000000..aa607600c --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-07/traj_sgdizcyacm51/trajectory.json @@ -0,0 +1,53 @@ +{ + "id": "traj_sgdizcyacm51", + "version": 1, + "task": { + "title": "Persist up workspace key per-project; resolve it in CWD; add --wk to up" + }, + "status": "completed", + "startedAt": "2026-07-15T19:28:16.051Z", + "completedAt": "2026-07-15T19:28:23.602Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-15T19:28:17.148Z" + } + ], + "chapters": [ + { + "id": "chap_a1d3oh6mby93", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-15T19:28:17.148Z", + "endedAt": "2026-07-15T19:28:23.602Z", + "events": [ + { + "ts": 1784143697150, + "type": "decision", + "content": "Persist workspace key per-project on up; resolve below flag/env above global store: Persist workspace key per-project on up; resolve below flag/env above global store", + "raw": { + "question": "Persist workspace key per-project on up; resolve below flag/env above global store", + "chosen": "Persist workspace key per-project on up; resolve below flag/env above global store", + "alternatives": [], + "reasoning": "" + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "up persists project workspace key; CWD resolution; --wk on up", + "approach": "Standard approach", + "confidence": 0.85 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relay", + "tags": [], + "_trace": { + "startRef": "ff4b2773aabaa2c2ec3cbf2d9856b8bce77022e4", + "endRef": "ff4b2773aabaa2c2ec3cbf2d9856b8bce77022e4" + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7950931ac..bfec0328b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,12 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - Minor] + +### Added + +- `--wk ` is a shorthand for `--workspace-key` on every SDK-backed `agent-relay` command (`fleet nodes`, `workspace`, `integration`, `webhook`, …) and on `up`/`node up`; an explicit `--workspace-key` still wins when both are passed. +- `agent-relay up` records the workspace it joins (passed via `--workspace-key`/`--wk` or auto-minted) in the project data dir, and SDK-backed commands run in that directory now resolve that workspace key ahead of the machine-global active workspace, so `fleet nodes`/`node …` in a project reflect the broker's actual workspace. An explicit `--workspace-key`/`--wk` or `RELAY_WORKSPACE_KEY`/`RELAY_API_KEY` still overrides it. `fleet nodes` prints a stderr note when the key was inferred from that project record, so a stale broker workspace is visible rather than silent. ## [10.3.0] - 2026-07-15 diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index 56bf6fa40..7152a56f4 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -1,7 +1,11 @@ import { Command } from 'commander'; +import nodeFs from 'node:fs'; import os from 'node:os'; +import nodePath from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { readProjectWorkspaceKey } from '../lib/project-workspace-key.js'; + const sdkStatusClient = { getStatus: vi.fn(async () => ({ agent_count: 0, pending_delivery_count: 0 })), getSession: vi.fn(async () => ({ workspace_key: '' }) as { workspace_key?: string }), @@ -500,6 +504,36 @@ describe('registerCoreCommands', () => { expect(deps.log).toHaveBeenCalledWith('Broker PID: 5151'); }); + it('up --state-dir records the workspace key in the default project dir, not the state dir', async () => { + // SDK commands (fleet nodes, …) read the key from the default project data + // dir and never accept --state-dir, so a redirected broker state dir must + // NOT be where the key lands — otherwise those commands miss it. + const stateDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'relay-statedir-')); + const relay = createRelayMock({ workspaceKey: 'rk_statedir_regression' }); + const { program } = createHarness({ relay }); + + try { + const exitCode = await runCommand(program, [ + 'up', + '--state-dir', + stateDir, + '--workspace-key', + 'rk_statedir_regression', + ]); + + expect(exitCode).toBeUndefined(); + // Persisted at the default project data dir (the harness's mocked path)… + expect(readProjectWorkspaceKey('/tmp/project/.agentworkforce/relay')).toBe('rk_statedir_regression'); + // …and NOT in the redirected state dir. + expect(readProjectWorkspaceKey(stateDir)).toBeUndefined(); + } finally { + nodeFs.rmSync(stateDir, { recursive: true, force: true }); + // The key is written to the real default project dir; clean it up so it + // doesn't leak into other tests running in this process. + nodeFs.rmSync('/tmp/project/.agentworkforce/relay/workspace-key.json', { force: true }); + } + }); + it('up --background re-execs a Bun standalone binary without adding its virtual entrypoint', async () => { const spawnedProcess = createSpawnedProcessMock(); let now = 0; @@ -1175,6 +1209,21 @@ describe('registerCoreCommands', () => { expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_custom'); }); + it('up --wk is an alias for --workspace-key', async () => { + const env: NodeJS.ProcessEnv = {}; + const relay = createRelayMock({ workspaceKey: 'rk_live_alias' }); + const { program, deps } = createHarness({ relay, env }); + + const exitCode = await runCommand(program, ['up', '--wk', 'rk_live_alias']); + + expect(exitCode).toBeUndefined(); + // The alias is folded into workspaceKey, so the broker sees the same env the + // explicit flag would have set. + expect(env.RELAY_WORKSPACE_KEY).toBe('rk_live_alias'); + expect(env.RELAY_API_KEY).toBe('rk_live_alias'); + expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_alias'); + }); + it('up without --workspace-key does not set workspace key env vars', async () => { const env: NodeJS.ProcessEnv = {}; const relay = createRelayMock(); diff --git a/packages/cli/src/cli/commands/core.ts b/packages/cli/src/cli/commands/core.ts index e610358bc..19aab00f3 100644 --- a/packages/cli/src/cli/commands/core.ts +++ b/packages/cli/src/cli/commands/core.ts @@ -280,27 +280,38 @@ export interface UpCommandOptions { * on top of these; `local up` uses them as-is. */ export function addUpCommandOptions(command: Command): Command { - return command - .option('--spawn', 'Force spawn all agents from teams.json') - .option('--no-spawn', 'Do not auto-spawn agents (just start broker)') - .option('--background', 'Run broker in the background (detached)') - .option('--verbose', 'Enable verbose logging') - .option('--workspace-key ', 'Use a pre-established Relaycast workspace key') - .option( - '--state-dir ', - 'Directory for broker state and connection files (default: .agentworkforce/relay/)' - ) - .option('--broker-name ', 'Override the broker name (defaults to project directory basename)') - .option( - '--log-file ', - 'Write structured node logs (capabilities registered, actions invoked/completed) to a file' - ) - .option( - '--log-level ', - 'Node log verbosity: debug | info | warn | error (default: info)', - parseLogLevel - ) - .option('--log-json', 'Emit node logs as JSON lines instead of text'); + return ( + command + .option('--spawn', 'Force spawn all agents from teams.json') + .option('--no-spawn', 'Do not auto-spawn agents (just start broker)') + .option('--background', 'Run broker in the background (detached)') + .option('--verbose', 'Enable verbose logging') + .option('--workspace-key ', 'Use a pre-established Relaycast workspace key') + .option('--wk ', 'Alias for --workspace-key') + .option( + '--state-dir ', + 'Directory for broker state and connection files (default: .agentworkforce/relay/)' + ) + .option('--broker-name ', 'Override the broker name (defaults to project directory basename)') + .option( + '--log-file ', + 'Write structured node logs (capabilities registered, actions invoked/completed) to a file' + ) + .option( + '--log-level ', + 'Node log verbosity: debug | info | warn | error (default: info)', + parseLogLevel + ) + .option('--log-json', 'Emit node logs as JSON lines instead of text') + // Fold the `--wk` alias into `workspaceKey` before the action runs, matching + // the SDK commands' `addSdkOptions`. An explicit `--workspace-key` wins. + .hook('preAction', (thisCommand) => { + const opts = thisCommand.opts(); + if (typeof opts.wk === 'string' && opts.wk.trim() && !opts.workspaceKey) { + thisCommand.setOptionValue('workspaceKey', opts.wk); + } + }) + ); } const LOG_LEVELS = ['debug', 'info', 'warn', 'error'] as const; diff --git a/packages/cli/src/cli/commands/fleet.test.ts b/packages/cli/src/cli/commands/fleet.test.ts index 9adf92d97..bdb0437ec 100644 --- a/packages/cli/src/cli/commands/fleet.test.ts +++ b/packages/cli/src/cli/commands/fleet.test.ts @@ -1,3 +1,7 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + import { Command } from 'commander'; import { describe, expect, it, vi } from 'vitest'; @@ -26,6 +30,7 @@ vi.mock('@agent-relay/harness-driver', () => ({ })); import { registerFleetCommands } from './fleet.js'; +import { writeProjectWorkspaceKey } from '../lib/project-workspace-key.js'; describe('fleet command support', () => { it.each([ @@ -80,6 +85,158 @@ describe('fleet command support', () => { }); }); + it('fleet nodes accepts --wk as an alias for --workspace-key', async () => { + const nodes = { list: vi.fn(async () => []) }; + const createWorkspaceRelay = vi.fn(() => ({ nodes })); + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + sdk: { + createAgentRelay: vi.fn() as never, + createWorkspaceRelay: createWorkspaceRelay as never, + createWorkspace: vi.fn() as never, + log: vi.fn() as never, + error: vi.fn(), + exit: vi.fn() as never, + }, + log: () => undefined, + warn: () => undefined, + error: () => undefined, + }); + + await program.parseAsync(['fleet', 'nodes', '--wk', 'rk_live_alias'], { from: 'user' }); + + // The alias is folded into workspaceKey before the action resolves the client. + expect(createWorkspaceRelay).toHaveBeenCalledWith({ + workspaceKey: 'rk_live_alias', + token: undefined, + baseUrl: undefined, + }); + expect(nodes.list).toHaveBeenCalledTimes(1); + }); + + it('fleet nodes prefers an explicit --workspace-key over --wk', async () => { + const nodes = { list: vi.fn(async () => []) }; + const createWorkspaceRelay = vi.fn(() => ({ nodes })); + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + sdk: { + createAgentRelay: vi.fn() as never, + createWorkspaceRelay: createWorkspaceRelay as never, + createWorkspace: vi.fn() as never, + log: vi.fn() as never, + error: vi.fn(), + exit: vi.fn() as never, + }, + log: () => undefined, + warn: () => undefined, + error: () => undefined, + }); + + await program.parseAsync( + ['fleet', 'nodes', '--workspace-key', 'rk_live_explicit', '--wk', 'rk_live_alias'], + { from: 'user' } + ); + + expect(createWorkspaceRelay).toHaveBeenCalledWith({ + workspaceKey: 'rk_live_explicit', + token: undefined, + baseUrl: undefined, + }); + }); + + it('fleet nodes warns when the workspace key is inferred from the project broker', async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-fleet-proj-')); + const saved = { + project: process.env.AGENT_RELAY_PROJECT, + ws: process.env.RELAY_WORKSPACE_KEY, + api: process.env.RELAY_API_KEY, + }; + process.env.AGENT_RELAY_PROJECT = projectRoot; + delete process.env.RELAY_WORKSPACE_KEY; + delete process.env.RELAY_API_KEY; + writeProjectWorkspaceKey(path.join(projectRoot, '.agentworkforce/relay'), 'rk_project_broker'); + + const warnings: string[] = []; + const nodes = { list: vi.fn(async () => []) }; + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + sdk: { + createAgentRelay: vi.fn() as never, + createWorkspaceRelay: vi.fn(() => ({ nodes })) as never, + createWorkspace: vi.fn() as never, + log: vi.fn() as never, + error: vi.fn(), + exit: vi.fn() as never, + }, + log: () => undefined, + warn: (...args: unknown[]) => warnings.push(args.join(' ')), + error: () => undefined, + }); + + try { + await program.parseAsync(['fleet', 'nodes'], { from: 'user' }); + } finally { + if (saved.project === undefined) delete process.env.AGENT_RELAY_PROJECT; + else process.env.AGENT_RELAY_PROJECT = saved.project; + if (saved.ws !== undefined) process.env.RELAY_WORKSPACE_KEY = saved.ws; + if (saved.api !== undefined) process.env.RELAY_API_KEY = saved.api; + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + + // The warning is advisory only — the roster is still fetched and printed. + expect(warnings.join('\n')).toMatch(/recorded in this directory/); + expect(nodes.list).toHaveBeenCalledTimes(1); + }); + + it('fleet nodes does not warn when an explicit key overrides a recorded project key', async () => { + // A recorded project key IS present (the same context that makes the sibling + // test warn); the explicit --wk must take precedence and suppress the advisory. + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-fleet-proj-')); + const saved = { + project: process.env.AGENT_RELAY_PROJECT, + ws: process.env.RELAY_WORKSPACE_KEY, + api: process.env.RELAY_API_KEY, + }; + process.env.AGENT_RELAY_PROJECT = projectRoot; + delete process.env.RELAY_WORKSPACE_KEY; + delete process.env.RELAY_API_KEY; + writeProjectWorkspaceKey(path.join(projectRoot, '.agentworkforce/relay'), 'rk_project_broker'); + + const warnings: string[] = []; + const nodes = { list: vi.fn(async () => []) }; + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + sdk: { + createAgentRelay: vi.fn() as never, + createWorkspaceRelay: vi.fn(() => ({ nodes })) as never, + createWorkspace: vi.fn() as never, + log: vi.fn() as never, + error: vi.fn(), + exit: vi.fn() as never, + }, + log: () => undefined, + warn: (...args: unknown[]) => warnings.push(args.join(' ')), + error: () => undefined, + }); + + try { + await program.parseAsync(['fleet', 'nodes', '--wk', 'rk_live_alias'], { from: 'user' }); + } finally { + if (saved.project === undefined) delete process.env.AGENT_RELAY_PROJECT; + else process.env.AGENT_RELAY_PROJECT = saved.project; + if (saved.ws !== undefined) process.env.RELAY_WORKSPACE_KEY = saved.ws; + if (saved.api !== undefined) process.env.RELAY_API_KEY = saved.api; + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + + expect(warnings).toEqual([]); + expect(nodes.list).toHaveBeenCalledTimes(1); + }); + it('fleet status output redacts the node token and workspace key from the session', async () => { const logs: string[] = []; const nodes = { list: vi.fn(async () => [{ name: 'live-node', status: 'online', capabilities: [] }]) }; diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index 8be129243..ef196ea30 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -4,6 +4,7 @@ import { HarnessDriverClient } from '@agent-relay/harness-driver'; import { withDefaults, type CoreDependencies } from './core.js'; import { readBrokerConnection } from '../lib/broker-lifecycle.js'; import { redactSecrets } from '../lib/redact.js'; +import { resolveWorkspaceKeyWithSource } from '../lib/sdk-client.js'; import { addSdkOptions, printJson, @@ -68,6 +69,7 @@ export function registerFleetCommands( .option('--name ', 'Filter by node name') ).action(async (options: Record) => { await runSdk(deps.sdk, async () => { + warnIfInferredFromProjectBroker(options, deps.warn); const relay = deps.sdk.createWorkspaceRelay(sdkOptionsFromOpts(options)); printJson(deps.sdk, { nodes: await relay.nodes.list({ @@ -126,6 +128,33 @@ export function registerFleetCommands( }); } +/** + * Warn (on stderr, so it never pollutes the JSON on stdout) when the workspace + * key was inferred from the local broker's project record rather than named + * explicitly. That key is whatever `agent-relay up` last joined in this + * directory, which can be stale — surfacing it lets the operator override with + * `--workspace-key`/`--wk` or `RELAY_WORKSPACE_KEY` if the roster looks wrong. + * Resolution errors are swallowed: the SDK call below reports the real failure. + */ +function warnIfInferredFromProjectBroker( + options: Record, + warn: (...args: unknown[]) => void +): void { + let source: string; + try { + source = resolveWorkspaceKeyWithSource(sdkOptionsFromOpts(options)).source; + } catch { + return; + } + if (source === 'project') { + warn( + 'Note: using the workspace key `agent-relay up` recorded in this directory. ' + + 'If the local broker has since joined a different workspace, pass --workspace-key/--wk ' + + 'or set RELAY_WORKSPACE_KEY to override.' + ); + } +} + async function runFleetStatus( deps: FleetCommandDependencies, options: Record diff --git a/packages/cli/src/cli/lib/broker-lifecycle.ts b/packages/cli/src/cli/lib/broker-lifecycle.ts index 9e6d31649..147bffcca 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.ts @@ -15,6 +15,7 @@ import { loadNodeDefinition, } from './node-definition-loader.js'; import { startReflexCapture, type RunningReflexCapture } from './reflex-capture.js'; +import { writeProjectWorkspaceKey } from './project-workspace-key.js'; type UpOptions = { spawn?: boolean; @@ -1077,6 +1078,12 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): ensureBundledAgentRelayMcpCommand(deps); const paths = deps.getProjectPaths(); + // The stable, default project data dir (`.agentworkforce/relay/`) captured + // BEFORE any --state-dir override below. SDK-backed commands resolve the + // recorded workspace key from this default location (they don't accept + // --state-dir), so the key must be persisted here even when broker state is + // redirected elsewhere. + const projectWorkspaceKeyDataDir = paths.dataDir; // --state-dir overrides where the broker writes state / connection files if (options.stateDir) { const resolved = path.resolve(options.stateDir); @@ -1253,6 +1260,18 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): deps.log(`Workspace Key: ${relay.workspaceKey ?? 'unknown'}`); deps.log('Broker started.'); + // Record the workspace this broker joined (explicitly passed or auto-minted) + // in the DEFAULT project data dir (not any --state-dir override), so later + // SDK commands in this CWD resolve it instead of the machine-global active + // workspace. Persistence must never abort startup, so a write failure is + // swallowed. + try { + writeProjectWorkspaceKey(projectWorkspaceKeyDataDir, relay.workspaceKey ?? undefined); + } catch { + // best-effort: a broker that came up should stay up even if the key file + // can't be written (read-only dir, etc.). + } + vlog(deps, options.verbose, 'Starting node capability providers (if any)...'); nodeProviders = await startNodeCapabilityProviders(paths, relay, options, deps, nodeDefinition); // When Reflex is enabled, periodically sync + push local session history to diff --git a/packages/cli/src/cli/lib/project-workspace-key.test.ts b/packages/cli/src/cli/lib/project-workspace-key.test.ts new file mode 100644 index 000000000..a6288345c --- /dev/null +++ b/packages/cli/src/cli/lib/project-workspace-key.test.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + projectWorkspaceKeyPath, + readProjectWorkspaceKey, + writeProjectWorkspaceKey, +} from './project-workspace-key.js'; + +let dataDir: string; + +beforeEach(() => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-proj-wk-')); +}); + +afterEach(() => { + fs.rmSync(dataDir, { recursive: true, force: true }); +}); + +describe('project workspace key store', () => { + it('round-trips a written key and trims it', () => { + writeProjectWorkspaceKey(dataDir, ' rk_project '); + expect(readProjectWorkspaceKey(dataDir)).toBe('rk_project'); + // Owner-only permissions, matching connection.json. + const mode = fs.statSync(projectWorkspaceKeyPath(dataDir)).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it('reads undefined for a missing, malformed, or blank-key file', () => { + expect(readProjectWorkspaceKey(dataDir)).toBeUndefined(); + + fs.writeFileSync(projectWorkspaceKeyPath(dataDir), 'not json'); + expect(readProjectWorkspaceKey(dataDir)).toBeUndefined(); + + fs.writeFileSync(projectWorkspaceKeyPath(dataDir), JSON.stringify({ workspaceKey: ' ' })); + expect(readProjectWorkspaceKey(dataDir)).toBeUndefined(); + }); + + it('ignores a blank key so a resolved key is not clobbered by an empty one', () => { + writeProjectWorkspaceKey(dataDir, 'rk_keep'); + writeProjectWorkspaceKey(dataDir, ' '); + writeProjectWorkspaceKey(dataDir, undefined); + expect(readProjectWorkspaceKey(dataDir)).toBe('rk_keep'); + }); +}); diff --git a/packages/cli/src/cli/lib/project-workspace-key.ts b/packages/cli/src/cli/lib/project-workspace-key.ts new file mode 100644 index 000000000..c36dca72f --- /dev/null +++ b/packages/cli/src/cli/lib/project-workspace-key.ts @@ -0,0 +1,86 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Project-local record of the workspace key the broker in this directory was + * started with. `agent-relay up` writes it into the project data dir + * (`.agentworkforce/relay/`, the same git-excluded directory that holds + * `connection.json`) so that later SDK-backed commands run in the same CWD + * (`fleet nodes`, `node …`, etc.) resolve the workspace the local broker + * actually joined — rather than falling through to the machine-global active + * workspace, which may point at a different workspace. + */ +const PROJECT_WORKSPACE_KEY_FILENAME = 'workspace-key.json'; + +interface ProjectWorkspaceKeyFile { + workspaceKey: string; +} + +/** Absolute path to the project-local workspace-key file within `dataDir`. */ +export function projectWorkspaceKeyPath(dataDir: string): string { + return path.join(dataDir, PROJECT_WORKSPACE_KEY_FILENAME); +} + +/** + * Read the workspace key recorded for this project's data dir. A missing or + * malformed file (or a blank key) reads as `undefined` — the caller falls + * through to the next resolution source rather than failing. + */ +export function readProjectWorkspaceKey(dataDir: string): string | undefined { + try { + const raw = fs.readFileSync(projectWorkspaceKeyPath(dataDir), 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + const key = typeof parsed.workspaceKey === 'string' ? parsed.workspaceKey.trim() : ''; + return key || undefined; + } catch { + return undefined; + } +} + +/** + * Persist the workspace key for this project's data dir with owner-only + * permissions (matching `connection.json`). A blank key is ignored so a broker + * that never resolved a key does not clobber a previously recorded one. + * + * The write is atomic and symlink-safe: the payload is written to a fresh, + * exclusively-created temp file (so a pre-planted symlink at the temp path + * cannot redirect it) and then `rename`d over the destination. `rename` never + * follows a symlink at the destination and is atomic on POSIX, so a concurrent + * reader sees either the old or the new complete file — never a truncated one, + * and never an attacker-chosen target. + */ +export function writeProjectWorkspaceKey(dataDir: string, workspaceKey: string | undefined): void { + const key = workspaceKey?.trim(); + if (!key) return; + fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 }); + const file = projectWorkspaceKeyPath(dataDir); + const tmp = `${file}.tmp.${process.pid}`; + const payload: ProjectWorkspaceKeyFile = { workspaceKey: key }; + const data = `${JSON.stringify(payload, null, 2)}\n`; + + // 'wx' == O_CREAT | O_EXCL: create a brand-new regular file, failing (rather + // than following a symlink or truncating an existing file) if anything is + // already at the temp path. A stale temp from a crashed run is removed first. + let fd: number; + try { + fd = fs.openSync(tmp, 'wx', 0o600); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + fs.rmSync(tmp, { force: true }); + fd = fs.openSync(tmp, 'wx', 0o600); + } + try { + try { + fs.writeSync(fd, data); + } finally { + fs.closeSync(fd); + } + // openSync's mode is masked by umask; enforce owner-only before publishing. + fs.chmodSync(tmp, 0o600); + fs.renameSync(tmp, file); + } catch (err) { + // Never leave a partial temp file behind, whichever step failed. + fs.rmSync(tmp, { force: true }); + throw err; + } +} diff --git a/packages/cli/src/cli/lib/sdk-client.test.ts b/packages/cli/src/cli/lib/sdk-client.test.ts index b7c814609..439bcd301 100644 --- a/packages/cli/src/cli/lib/sdk-client.test.ts +++ b/packages/cli/src/cli/lib/sdk-client.test.ts @@ -4,23 +4,43 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { resolveAgentToken, resolveBaseUrl, resolveWorkspaceKey } from './sdk-client.js'; +import { + resolveAgentToken, + resolveBaseUrl, + resolveWorkspaceKey, + resolveWorkspaceKeyWithSource, +} from './sdk-client.js'; import { setWorkspaceKey } from './workspace-store.js'; +import { writeProjectWorkspaceKey } from './project-workspace-key.js'; let dir: string; +let projectRoot: string; const original = process.env.AGENT_RELAY_HOME; +const originalProject = process.env.AGENT_RELAY_PROJECT; beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-sdk-client-')); process.env.AGENT_RELAY_HOME = dir; + // Isolate the project root (and thus the project data dir that + // `resolveWorkspaceKey` reads the CWD broker key from) to a temp dir. + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-sdk-project-')); + process.env.AGENT_RELAY_PROJECT = projectRoot; }); afterEach(() => { if (original === undefined) delete process.env.AGENT_RELAY_HOME; else process.env.AGENT_RELAY_HOME = original; + if (originalProject === undefined) delete process.env.AGENT_RELAY_PROJECT; + else process.env.AGENT_RELAY_PROJECT = originalProject; fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(projectRoot, { recursive: true, force: true }); }); +/** The project data dir `getProjectPaths()` resolves for the isolated root. */ +function projectDataDir(): string { + return path.join(projectRoot, '.agentworkforce/relay'); +} + describe('sdk client option resolution', () => { it('falls through blank workspace-key candidates and trims the chosen key', () => { setWorkspaceKey('ops', ' rk_store '); @@ -33,6 +53,53 @@ describe('sdk client option resolution', () => { ).toBe('rk_store'); }); + it('uses the CWD broker workspace key over the machine-global active workspace', () => { + setWorkspaceKey('ops', 'rk_global'); + writeProjectWorkspaceKey(projectDataDir(), 'rk_project_broker'); + + // No explicit flag/env key → the project broker's key wins over the global store. + expect(resolveWorkspaceKey({ env: { AGENT_RELAY_HOME: dir } })).toBe('rk_project_broker'); + }); + + it('lets an explicit flag and env override the CWD broker workspace key', () => { + writeProjectWorkspaceKey(projectDataDir(), 'rk_project_broker'); + + expect(resolveWorkspaceKey({ workspaceKey: 'rk_flag', env: { AGENT_RELAY_HOME: dir } })).toBe('rk_flag'); + expect(resolveWorkspaceKey({ env: { RELAY_WORKSPACE_KEY: 'rk_env', AGENT_RELAY_HOME: dir } })).toBe( + 'rk_env' + ); + }); + + it('falls back to the global active workspace when no CWD broker key is recorded', () => { + setWorkspaceKey('ops', 'rk_global'); + expect(resolveWorkspaceKey({ env: { AGENT_RELAY_HOME: dir } })).toBe('rk_global'); + }); + + it('reports the source each workspace key was resolved from', () => { + setWorkspaceKey('ops', 'rk_global'); + writeProjectWorkspaceKey(projectDataDir(), 'rk_project_broker'); + + expect( + resolveWorkspaceKeyWithSource({ workspaceKey: 'rk_flag', env: { AGENT_RELAY_HOME: dir } }) + ).toEqual({ key: 'rk_flag', source: 'flag' }); + expect( + resolveWorkspaceKeyWithSource({ env: { RELAY_WORKSPACE_KEY: 'rk_env', AGENT_RELAY_HOME: dir } }) + ).toEqual({ key: 'rk_env', source: 'env' }); + // No flag/env → the CWD broker key, reported as 'project'. + expect(resolveWorkspaceKeyWithSource({ env: { AGENT_RELAY_HOME: dir } })).toEqual({ + key: 'rk_project_broker', + source: 'project', + }); + }); + + it('reports the global store as the source when no CWD broker key exists', () => { + setWorkspaceKey('ops', 'rk_global'); + expect(resolveWorkspaceKeyWithSource({ env: { AGENT_RELAY_HOME: dir } })).toEqual({ + key: 'rk_global', + source: 'store', + }); + }); + it('trims optional base URL and agent token values', () => { expect(resolveBaseUrl({ baseUrl: ' https://relay.example ' })).toBe('https://relay.example'); expect(resolveAgentToken({ token: ' at_123 ' })).toBe('at_123'); diff --git a/packages/cli/src/cli/lib/sdk-client.ts b/packages/cli/src/cli/lib/sdk-client.ts index 2d16b7255..6b87e7ea7 100644 --- a/packages/cli/src/cli/lib/sdk-client.ts +++ b/packages/cli/src/cli/lib/sdk-client.ts @@ -1,6 +1,8 @@ import { AgentRelay, type AgentRelayAgent } from '@agent-relay/sdk'; +import { getProjectPaths } from '@agent-relay/config'; import { activeWorkspaceKey } from './workspace-store.js'; +import { readProjectWorkspaceKey } from './project-workspace-key.js'; /** Options shared by the SDK-backed (Relaycast) CLI command groups. */ export interface SdkClientOptions { @@ -19,19 +21,49 @@ function trimOrUndefined(value: string | undefined): string | undefined { return trimmed ? trimmed : undefined; } -export function resolveWorkspaceKey(options: SdkClientOptions = {}): string { +/** Where a resolved workspace key came from, in precedence order. */ +export type WorkspaceKeySource = 'flag' | 'env' | 'project' | 'store'; + +/** + * Resolve the workspace key and report which source it came from. Precedence: + * explicit flag → `RELAY_WORKSPACE_KEY`/`RELAY_API_KEY` env → the key the local + * broker in this CWD was started with (`relay up`) → the machine-global active + * workspace. Callers use the source to warn when the key was inferred from the + * project broker rather than named explicitly. + */ +export function resolveWorkspaceKeyWithSource(options: SdkClientOptions = {}): { + key: string; + source: WorkspaceKeySource; +} { const e = env(options); - const key = - trimOrUndefined(options.workspaceKey) ?? - trimOrUndefined(e.RELAY_WORKSPACE_KEY) ?? - trimOrUndefined(e.RELAY_API_KEY) ?? - trimOrUndefined(activeWorkspaceKey(e)); - if (!key) { - throw new Error( - 'No workspace key found. Pass --workspace-key, set RELAY_WORKSPACE_KEY, or run `relay workspace set_key `.' - ); + const flag = trimOrUndefined(options.workspaceKey); + if (flag) return { key: flag, source: 'flag' }; + const envKey = trimOrUndefined(e.RELAY_WORKSPACE_KEY) ?? trimOrUndefined(e.RELAY_API_KEY); + if (envKey) return { key: envKey, source: 'env' }; + const project = trimOrUndefined(projectWorkspaceKey()); + if (project) return { key: project, source: 'project' }; + const store = trimOrUndefined(activeWorkspaceKey(e)); + if (store) return { key: store, source: 'store' }; + throw new Error( + 'No workspace key found. Pass --workspace-key, set RELAY_WORKSPACE_KEY, or run `relay workspace set_key `.' + ); +} + +export function resolveWorkspaceKey(options: SdkClientOptions = {}): string { + return resolveWorkspaceKeyWithSource(options).key; +} + +/** + * Read the workspace key recorded by `relay up` for the current project + * directory, or `undefined` when there is none / the project root cannot be + * resolved. Never throws — a resolution failure just falls through. + */ +function projectWorkspaceKey(): string | undefined { + try { + return readProjectWorkspaceKey(getProjectPaths().dataDir); + } catch { + return undefined; } - return key; } export function resolveBaseUrl(options: SdkClientOptions = {}): string | undefined { diff --git a/packages/cli/src/cli/lib/sdk-command.ts b/packages/cli/src/cli/lib/sdk-command.ts index f0ad22732..da1d137c1 100644 --- a/packages/cli/src/cli/lib/sdk-command.ts +++ b/packages/cli/src/cli/lib/sdk-command.ts @@ -31,13 +31,26 @@ export function withSdkDefaults(overrides: Partial = {}): SdkCom /** Add the common workspace/token/base-url options to a command. */ export function addSdkOptions(command: Command): Command { - return command - .option( - '--workspace-key ', - 'Workspace key (defaults to RELAY_WORKSPACE_KEY or the active workspace)' - ) - .option('--token ', 'Agent token (defaults to RELAY_AGENT_TOKEN)') - .option('--base-url ', 'Override the API base URL (defaults to RELAY_BASE_URL)'); + return ( + command + .option( + '--workspace-key ', + 'Workspace key (defaults to RELAY_WORKSPACE_KEY or the active workspace)' + ) + .option('--wk ', 'Alias for --workspace-key') + .option('--token ', 'Agent token (defaults to RELAY_AGENT_TOKEN)') + .option('--base-url ', 'Override the API base URL (defaults to RELAY_BASE_URL)') + // Fold the `--wk` alias into `workspaceKey` before the action runs, so every + // downstream reader (`sdkOptionsFromOpts`, integration's `explicitWorkspaceKey`, + // etc.) sees a single canonical option regardless of which spelling was used. + // An explicit `--workspace-key` always wins over `--wk`. + .hook('preAction', (thisCommand) => { + const opts = thisCommand.opts(); + if (typeof opts.wk === 'string' && opts.wk.trim() && !opts.workspaceKey) { + thisCommand.setOptionValue('workspaceKey', opts.wk); + } + }) + ); } export function sdkOptionsFromOpts(opts: Record): SdkClientOptions {