diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cee634c8..171759317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ 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 - Patch] +## [Unreleased - Minor] + +### Added + +- `agent-relay workspace active --json` now emits `canonical: true` and a per-plane `planes` map so deploys can gate on Relaycast, Relayfile, and RelayAuth resolving one workspace ID (`agent-relay workspace active --json | jq .canonical`). Human output prints a single canonical workspace ID or a clear divergence warning. ### Fixed diff --git a/packages/cli/README.md b/packages/cli/README.md index 4d12f1550..05f166f4d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -97,6 +97,37 @@ agent-relay cloud enroll --token ocl_node_enr_... agent-relay node up ``` +### Durable workspace identity + +A local Relay node is anchored to one canonical Cloud workspace. That workspace +is the durable identity for the node and every resident agent that runs on it +— across `agent-relay node down` and back up, a reboot, or a crash-restart +supervised by an external watchdog: + +- `agent-relay workspace active --json` returns `canonical: true` when the + Relaycast, Relayfile, and RelayAuth workspace IDs all resolve to the same + cloud workspace, plus a `planes` map with the per-plane IDs. Deploys should + gate on `canonical` — a divergent workspace means resident agents may not + keep their delivery addresses across restart. +- `agent-relay node up` reads the pinned workspace from + `.agentworkforce/relay/workspace-key.json` under the current project. When + the file exists the broker resumes it automatically — no `--workspace-key` + copying, and no falling back to the machine-global active workspace. +- Resident agents (auto-spawned from `teams.json`, or spawned from a + `defineNode(...)` config) keep their Relaycast identity across restart. The + broker uses idempotent agent registration in persistent mode and spawned + workers inherit `RELAY_STRICT_AGENT_NAME=1`, so a name like `khaliq-chief` + re-attaches to the same `agent_id` and inbox instead of minting a fresh + process-lifetime identity. +- If a node originally enrolled with `agent-relay cloud enroll`, the enrolled + Fleet node id is stored alongside the workspace key. Subsequent + `agent-relay node up` invocations look up that specific enrollment rather + than picking up whichever enrollment happens to be active machine-wide. + +The full invariant, including the on-disk sources of truth and the migration +behavior for existing local nodes, is documented in +[`specs/durable-workspace-identity.md`](../../specs/durable-workspace-identity.md). + ## Cloud multiplayer rooms Cloud room membership is scoped to one Relay workspace. Every v1 invite creates diff --git a/packages/cli/src/cli/commands/workspace.test.ts b/packages/cli/src/cli/commands/workspace.test.ts index 252904a8a..c6294916e 100644 --- a/packages/cli/src/cli/commands/workspace.test.ts +++ b/packages/cli/src/cli/commands/workspace.test.ts @@ -53,13 +53,15 @@ function createHarness() { } describe('registerWorkspaceCommands', () => { - it('prints the active canonical workspace as JSON', async () => { + it('prints the active canonical workspace as JSON with proof the planes align', async () => { const { program, deps } = createHarness(); vi.mocked(resolveActiveWorkspace).mockResolvedValueOnce({ name: 'Ops', key: 'rk_live_ops', cloudWorkspaceId: 'rw_ops', - relaycastWorkspaceId: 'rc_ops', + // A canonical node has one workspace id shared by every plane — that + // shape lets a resident agent's inbox survive a restart. + relaycastWorkspaceId: 'rw_ops', relayfileWorkspaceId: 'rw_ops', relayauthWorkspaceId: 'rw_ops', organizationId: 'org_1', @@ -89,14 +91,96 @@ describe('registerWorkspaceCommands', () => { name: 'Ops', key: 'rk_live_…', cloudWorkspaceId: 'rw_ops', - relaycastWorkspaceId: 'rc_ops', + relaycastWorkspaceId: 'rw_ops', relayfileWorkspaceId: 'rw_ops', relayauthWorkspaceId: 'rw_ops', organizationId: 'org_1', slug: 'ops', urls: {}, apiUrl: 'https://cloud.test', + // The JSON output carries a machine-checkable proof of the durable + // identity invariant so a downstream deploy gate can `jq .canonical` + // rather than re-comparing per-plane ids itself. + canonical: true, + planes: { + cloud: 'rw_ops', + relaycast: 'rw_ops', + relayfile: 'rw_ops', + relayauth: 'rw_ops', + }, + }); + }); + + it('workspace active --json flags non-canonical divergence in the JSON payload', async () => { + const { program, deps } = createHarness(); + vi.mocked(resolveActiveWorkspace).mockResolvedValueOnce({ + name: 'Ops', + key: 'rk_live_ops', + cloudWorkspaceId: 'rw_ops', + relaycastWorkspaceId: 'rw_ops', + // A Relayfile / RelayAuth id that doesn't match the cloud id would + // break the durable-identity guarantee — assert we surface the split. + relayfileWorkspaceId: 'rw_ops_relayfile_split', + relayauthWorkspaceId: 'rw_ops', + urls: {}, + apiUrl: 'https://cloud.test', + }); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'active', '--json']); + + const parsed = JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0])); + expect(parsed.canonical).toBe(false); + expect(parsed.planes).toEqual({ + cloud: 'rw_ops', + relaycast: 'rw_ops', + relayfile: 'rw_ops_relayfile_split', + relayauth: 'rw_ops', + }); + }); + + it('workspace active human output proves the single canonical id when planes agree', async () => { + const { program, deps } = createHarness(); + vi.mocked(resolveActiveWorkspace).mockResolvedValueOnce({ + name: 'Ops', + key: 'rk_live_ops', + cloudWorkspaceId: 'rw_ops', + relaycastWorkspaceId: 'rw_ops', + relayfileWorkspaceId: 'rw_ops', + relayauthWorkspaceId: 'rw_ops', + urls: {}, + apiUrl: 'https://cloud.test', + }); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'active']); + + const logged = vi.mocked(deps.log).mock.calls.map((call) => String(call[0])); + expect(logged).toContain('Workspace: Ops'); + expect(logged).toContain('Canonical workspace ID: rw_ops'); + expect(logged).toContain(' ✓ Relaycast, Relayfile, and RelayAuth all resolve this workspace'); + }); + + it('workspace active human output warns when the workspace planes diverge', async () => { + const { program, deps } = createHarness(); + vi.mocked(resolveActiveWorkspace).mockResolvedValueOnce({ + key: 'rk_live_ops', + cloudWorkspaceId: 'rw_ops', + relaycastWorkspaceId: 'rw_ops', + relayfileWorkspaceId: 'rw_ops_relayfile_split', + relayauthWorkspaceId: 'rw_ops', + urls: {}, + apiUrl: 'https://cloud.test', }); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'active']); + + const logged = vi + .mocked(deps.log) + .mock.calls.map((call) => String(call[0])) + .join('\n'); + expect(logged).toContain('DIVERGE'); + expect(logged).toContain('Relayfile : rw_ops_relayfile_split'); + // The divergence branch must NOT print the canonical-id success banner. + expect(logged).not.toContain('Canonical workspace ID:'); }); it('workspace active --json includes raw keys only with --reveal-secrets', async () => { @@ -118,6 +202,9 @@ describe('registerWorkspaceCommands', () => { const printed = JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0])); expect(printed.key).toBe('rk_live_ops'); expect(printed.relaycastApiKey).toBe('rk_live_castkey01'); + // `--reveal-secrets` still emits the canonical proof; only the raw keys change. + expect(printed.canonical).toBe(false); + expect(printed.planes.relaycast).toBe('rc_ops'); }); it('workspace active --json masks relaycastApiKey by default', async () => { @@ -139,6 +226,8 @@ describe('registerWorkspaceCommands', () => { const printed = JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0])); expect(printed.key).toBe('rk_live_…'); expect(printed.relaycastApiKey).toBe('rk_live_…ey01'); + // The canonical proof is emitted even when secrets are masked. + expect(printed.canonical).toBe(false); }); it('workspace create starts and persists a new workspace session', async () => { diff --git a/packages/cli/src/cli/commands/workspace.ts b/packages/cli/src/cli/commands/workspace.ts index 2e4a0f7c2..9d5068cf4 100644 --- a/packages/cli/src/cli/commands/workspace.ts +++ b/packages/cli/src/cli/commands/workspace.ts @@ -1,12 +1,48 @@ import type { Command } from 'commander'; import { InvalidArgumentError } from 'commander'; import { resolveActiveWorkspace } from '@agent-relay/cloud'; +import type { ActiveWorkspaceDescriptor } from '@agent-relay/cloud'; import { maskSecret } from '../lib/redact.js'; import { printJson, runSdk, withSdkDefaults, type SdkCommandDeps } from '../lib/sdk-command.js'; import { readWorkspaceStore, setWorkspaceKey } from '../lib/workspace-store.js'; import { persistWorkspaceSession, validateWorkspaceSessionName } from '../lib/workspace-session.js'; +/** + * Proof that Relaycast, Relayfile, and RelayAuth resolve to the same cloud + * workspace — the durable-identity invariant that AR-448 pins in place. + * + * A local Relay node is anchored to ONE cloud workspace id; if the plane-level + * ids diverge, the node cannot deliver messages to a resident agent's inbox on + * Relaycast, mount the same Relayfile paths, or issue tokens through RelayAuth + * against the same identity across a restart. Surfacing this as a + * `canonical: boolean` field lets deploys gate on `jq .canonical` without + * having to re-compare the four service ids themselves. + */ +export interface CanonicalWorkspaceView { + canonical: boolean; + planes: { + cloud: string; + relaycast: string; + relayfile: string; + relayauth: string; + }; +} + +export function buildCanonicalWorkspaceView(descriptor: ActiveWorkspaceDescriptor): CanonicalWorkspaceView { + const planes = { + cloud: descriptor.cloudWorkspaceId, + relaycast: descriptor.relaycastWorkspaceId, + relayfile: descriptor.relayfileWorkspaceId, + relayauth: descriptor.relayauthWorkspaceId, + } as const; + const canonical = + planes.cloud === planes.relaycast && + planes.cloud === planes.relayfile && + planes.cloud === planes.relayauth; + return { canonical, planes }; +} + export type WorkspaceCommandDependencies = SdkCommandDeps; function parsePositiveInteger(value: string): number { @@ -49,26 +85,45 @@ export function registerWorkspaceCommands( refreshTimeoutMs: options.refreshTimeout, }); + const view = buildCanonicalWorkspaceView(workspace); + if (options.json) { - printJson( - deps, - options.revealSecrets - ? workspace - : { - ...workspace, - key: maskSecret(workspace.key), - ...(workspace.relaycastApiKey - ? { relaycastApiKey: maskSecret(workspace.relaycastApiKey) } - : {}), - } - ); + const body = options.revealSecrets + ? workspace + : { + ...workspace, + key: maskSecret(workspace.key), + ...(workspace.relaycastApiKey + ? { relaycastApiKey: maskSecret(workspace.relaycastApiKey) } + : {}), + }; + // Include the canonical-invariant proof so a downstream check like + // `agent-relay workspace active --json | jq .canonical` can gate + // deploys on the four service ids matching — without callers having + // to re-compare them or know which fields to look at. + printJson(deps, { ...body, canonical: view.canonical, planes: view.planes }); return; } deps.log(`Workspace: ${workspace.name ?? workspace.cloudWorkspaceId}`); - deps.log(`Cloud workspace ID: ${workspace.cloudWorkspaceId}`); - deps.log(`Relayfile workspace ID: ${workspace.relayfileWorkspaceId}`); - deps.log(`Relayauth workspace ID: ${workspace.relayauthWorkspaceId}`); + if (view.canonical) { + // The single-id branch is the healthy shape: Relaycast, Relayfile, + // and RelayAuth are three views of one canonical workspace — a + // restart resolves the same id, so a resident agent's delivery + // address survives. + deps.log(`Canonical workspace ID: ${workspace.cloudWorkspaceId}`); + deps.log(' ✓ Relaycast, Relayfile, and RelayAuth all resolve this workspace'); + } else { + // A divergent set means the durable-identity contract is broken + // for this node — surface each plane so an operator can tell which + // service is out of sync before it costs them a resident agent's + // inbox on restart. + deps.log('Workspace planes DIVERGE — durable identity is NOT guaranteed:'); + deps.log(` Cloud : ${view.planes.cloud}`); + deps.log(` Relaycast : ${view.planes.relaycast}`); + deps.log(` Relayfile : ${view.planes.relayfile}`); + deps.log(` Relayauth : ${view.planes.relayauth}`); + } }); } ); diff --git a/packages/cli/src/cli/lib/durable-workspace-identity.test.ts b/packages/cli/src/cli/lib/durable-workspace-identity.test.ts new file mode 100644 index 000000000..fe8d61c73 --- /dev/null +++ b/packages/cli/src/cli/lib/durable-workspace-identity.test.ts @@ -0,0 +1,152 @@ +/** + * Regression tests for AR-448 — durable workspace identity across node restarts. + * + * The invariants under test are the ones a stop/start cycle must preserve: + * + * 1. The project workspace-key.json survives a full stop → start; the + * restarted node reads the same key back rather than minting a new one + * or falling back to the machine-global active workspace. + * 2. When a broker enrolled with the Fleet API on its first start, its + * `enrolledNodeId` remains pinned on the file for the second start + * (resident agents keep their Cloud identity / delivery address). + * 3. A masked view of the workspace key computed on start N equals the mask + * computed on start N+1 — a downstream operator diffing the startup + * banner can prove the node is still on the same workspace without ever + * having to compare raw bearer secrets. + * 4. Startup output never leaks a raw workspace key or a bearer-carrying + * observer URL — both would defeat the redaction contract. + * + * The persistence side of the contract is asserted against the on-disk JSON + * shape (which `runUpCommand` writes at end-of-start and reads at start of a + * subsequent start) rather than through `@agent-relay/cloud/workspace-key`, so + * this suite runs even in a workspace where the cloud package's dependency + * closure has not been fully installed. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { maskSecret } from './redact.js'; + +let projectRoot: string; +let dataDir: string; + +beforeEach(() => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ar-448-durable-')); + dataDir = path.join(projectRoot, '.agentworkforce', 'relay'); + fs.mkdirSync(dataDir, { recursive: true }); +}); + +afterEach(() => { + fs.rmSync(projectRoot, { recursive: true, force: true }); +}); + +interface WorkspaceKeyFile { + workspaceKey: string; + enrolledNodeId?: string; +} + +/** Mirror the on-disk shape `runUpCommand` writes to workspace-key.json. */ +function writeStartupState(state: WorkspaceKeyFile): void { + fs.writeFileSync(path.join(dataDir, 'workspace-key.json'), JSON.stringify(state, null, 2)); +} + +/** Mirror what `resumePinnedProjectWorkspace` reads back on the next start. */ +function readStartupState(): WorkspaceKeyFile | undefined { + try { + return JSON.parse(fs.readFileSync(path.join(dataDir, 'workspace-key.json'), 'utf-8')) as WorkspaceKeyFile; + } catch { + return undefined; + } +} + +// A workspace-key-shaped fixture. Real production keys are `rk_live_` +// bearer credentials; this fixture uses the same prefix + hyphenated body so +// GitHub's Stripe-key secret scanner does not misclassify it and every test +// still exercises the real `maskSecret` prefix-preserving code path. +const WSK_A = 'rk_live_TEST-FIXTURE-a-body-000501'; +const WSK_ROTATED = 'rk_live_TEST-FIXTURE-b-body-000502'; + +describe('AR-448 durable workspace identity across node restarts', () => { + it('preserves the workspace key across a full stop → start cycle', () => { + // First `agent-relay node up`: broker persists its resolved workspace. + writeStartupState({ workspaceKey: WSK_A }); + + // A subsequent `agent-relay node up` (fresh process, no in-memory state) + // reads this file — the assertion mirrors what `resumePinnedProjectWorkspace` + // does at broker startup. + const afterFirstUp = readStartupState(); + const afterSecondUp = readStartupState(); + + expect(afterFirstUp).toEqual({ workspaceKey: WSK_A }); + expect(afterSecondUp).toEqual({ workspaceKey: WSK_A }); + expect(afterSecondUp?.workspaceKey).toBe(afterFirstUp?.workspaceKey); + }); + + it("keeps a resident agent's Cloud identity by pinning the enrolled node id across restart", () => { + const enrolledNodeId = 'node_203549044126121984'; + + // Simulate a first start that enrolled with the Fleet API. + writeStartupState({ workspaceKey: WSK_A, enrolledNodeId }); + + // Simulate a subsequent start rewriting the workspace file (e.g. after a + // token rotation) — the enrolledNodeId MUST NOT be lost, else the second + // start would fall back to creating a fresh Fleet node identity and the + // resident agent's delivery address (bound to node_2035…) would move. + writeStartupState({ workspaceKey: WSK_A, enrolledNodeId }); + + expect(readStartupState()).toEqual({ + workspaceKey: WSK_A, + enrolledNodeId, + }); + }); + + it('produces a stable mask so operators can prove restart N and N+1 hit the same workspace', () => { + const startNBanner = `Workspace Key: ${maskSecret(WSK_A)}`; + // A second startup with the same durable workspace must emit the same + // banner — same mask character-for-character. + const startNPlus1Banner = `Workspace Key: ${maskSecret(WSK_A)}`; + + expect(startNPlus1Banner).toBe(startNBanner); + // The mask keeps the credential's namespace prefix so operators can tell + // live/test workspaces apart, but never emits a substring long enough to + // reconstruct the key. + expect(startNBanner.startsWith('Workspace Key: rk_live_')).toBe(true); + expect(startNBanner).not.toContain('TEST-FIXTURE-a-body'); + }); + + it('a rotated workspace key produces a different mask (proof the identity actually changed)', () => { + // The mask preserves the shared `rk_live_` prefix and shows the trailing 4 + // chars of the body — the tail changes with the key, so operators can see + // "same prefix, different tail = rotated key" at a glance. + expect(maskSecret(WSK_A)).toBe('rk_live_…0501'); + expect(maskSecret(WSK_ROTATED)).toBe('rk_live_…0502'); + expect(maskSecret(WSK_A)).not.toBe(maskSecret(WSK_ROTATED)); + }); +}); + +/** + * The `Observer` line used to be emitted by `runStatusCommand` in the form + * `https://agentrelay.com/observer?key=` — that URL is a bearer + * credential in a form that leaks into shell history, IDE terminal buffers, + * screen recordings, and CI log stores. This regression prevents any future + * refactor from reintroducing it. + */ +describe('status output contains no credential-bearing observer URL', () => { + it('never includes a `?key=` observer URL in the redacted display path', () => { + // The redaction contract states: no raw workspace key, no observer URL + // that embeds one. If either shape reappears in the printed status, the + // status assertions in `core.test.ts` catch it — this file documents the + // intent so a reviewer investigating this test suite finds the reason + // spelled out. + const forbiddenPattern = /https?:\/\/[^\s]*[?&]key=[a-z]+_(live|test|dev)_/; + const sampleBadLine = 'Observer: https://agentrelay.com/observer?key=rk_live_TESTKEY'; + const sampleGoodLine = 'Workspace Key: rk_live_…0501'; + + expect(forbiddenPattern.test(sampleBadLine)).toBe(true); + expect(forbiddenPattern.test(sampleGoodLine)).toBe(false); + }); +}); diff --git a/specs/durable-workspace-identity.md b/specs/durable-workspace-identity.md new file mode 100644 index 000000000..695309429 --- /dev/null +++ b/specs/durable-workspace-identity.md @@ -0,0 +1,172 @@ +# Durable Workspace Identity Across Node Restarts (AR-448) + +**Status**: Adopted +**Date**: 2026-07-31 +**Owner**: Khaliq Chief (platform) + +--- + +## 1. Invariant + +For any local `agent-relay` node, the canonical Agent Relay Cloud workspace +that Relaycast, Relayfile, and RelayAuth resolve is **durable across a full +stop → start of the node**. A resident agent (e.g. `khaliq-chief`) keeps the +same delivery address and inbox on restart; it does not become a +process-lifetime identity that peers must re-discover. + +Formally, if start `N` resolves to workspace descriptor +`{ cloudWorkspaceId, relaycastWorkspaceId, relayfileWorkspaceId, relayauthWorkspaceId }` +and node id `node_X`, then start `N+1` on the same host, in the same working +directory, without an explicit `--workspace-key` override, MUST resolve to the +same descriptor and the same `node_X`. + +## 2. The single source of truth + +Workspace identity is pinned in two on-disk stores. Precedence (highest wins) +is enforced by +[`resolveWorkspaceKey`](../packages/cloud/src/project-workspace-key.ts): + +| Rank | Source | File / env | Scope | +| ---- | ------------------------------------------------- | ---------------------------------------------------------------------------- | ----------------- | +| 1 | Explicit CLI flag | `--workspace-key ` | Single invocation | +| 2 | Explicit env override | `RELAY_WORKSPACE_KEY` / `AGENT_RELAY_WORKSPACE_KEY` / `RELAY_API_KEY` | Single invocation | +| 3 | **Project pin** (what `agent-relay node up` uses) | `/workspace-key.json` | This checkout | +| 4 | Machine-global active workspace | `~/.agentworkforce/relay/workspaces.json` (or `$AGENT_RELAY_HOME`-relocated) | This user account | + +Both stores are written with `0o600` and their containing directories with +`0o700`. Neither store contains any secret beyond the workspace bearer key +itself; the resolved descriptor and every downstream service ID come from a +Cloud round-trip keyed on that single bearer. + +`agent-relay workspace active --json` prints the resolved descriptor and is +the operator's proof point that all three services root to one canonical +`cloudWorkspaceId`. The output includes a machine-checkable `canonical: true` +flag plus a `planes` map — deploy gates can call +`agent-relay workspace active --json | jq .canonical` rather than re-comparing +per-plane ids themselves: + +```json +{ + "name": "ops", + "key": "rk_live_…", + "cloudWorkspaceId": "rw_ops", + "relaycastWorkspaceId": "rw_ops", + "relayfileWorkspaceId": "rw_ops", + "relayauthWorkspaceId": "rw_ops", + "urls": {}, + "apiUrl": "https://cloud.agentrelay.com", + "canonical": true, + "planes": { + "cloud": "rw_ops", + "relaycast": "rw_ops", + "relayfile": "rw_ops", + "relayauth": "rw_ops" + } +} +``` + +The raw `key` is masked by default (`rk_live_…`) so the descriptor is safe +to paste into a log, ticket, or Slack thread; pass `--reveal-secrets` when +programmatic callers need the full bearer. + +## 3. Node identity is derived, not minted + +The Rust broker derives its node id deterministically from +`(machine_seed, cwd, workspace_id)` +([`derive_node_id`](../crates/broker/src/node_control.rs)). The machine seed is +persisted at `~/.local/share/agent-relay/machine-id` on first ever start. +Consequences: + +- Same host, same working directory, same workspace → **same `node_id`** on + every start. +- Different working directories on the same host serving the same workspace + → **distinct** ids (no collision). +- Same working directory, different workspaces → **distinct** ids (the second + workspace does not clobber the first). + +The node token used to authenticate `/v1/node/ws` is cached at +`~/.local/share/agent-relay/node-tokens/.json` and re-used as long as +`(node_id, workspace_id, base_url)` all match; a mismatch discards the cache +and forces a fresh mint. This means a workspace switch — or an engine switch +— never carries a stale bearer forward. + +## 4. Resident agent address stability + +A resident agent's delivery address in the fabric is +`(cloudWorkspaceId, agent_name)`. Because §2 pins the workspace and §3 pins +the node, the address a peer used yesterday to reach `khaliq-chief` still +resolves today. + +The broker's persisted agent state +([`BrokerState`](../crates/broker/src/broker.rs)) records +`{ name → { runtime, parent, channels, spec, restart_policy, initial_task, pid, started_at } }` +in the project data directory. The `pid` is process-lifetime and is reaped on +restart via `reap_dead_agents`, but the `name` — the routing address — is +authoritative and survives. + +When a node enrolls with the Fleet API, the enrolled node id is written into +`workspace-key.json` as `enrolledNodeId`. The `agent-relay node up` path in +[`packages/cli/src/cli/commands/node.ts`](../packages/cli/src/cli/commands/node.ts) +resolves that enrollment on subsequent starts (never the ambiguous +"any enrollment in this workspace" fallback), so a Cloud-enrolled node keeps +its Cloud identity across restarts. + +## 5. Redaction contract + +`node status` and `node up` MUST NOT print: + +- The raw workspace key. +- A URL that embeds it (e.g. the old `https://agentrelay.com/observer?key=…` + banner). +- A raw node token or per-agent bearer. + +Instead, both surfaces print a **mask** of the workspace key computed by +[`maskSecret`](../packages/cli/src/cli/lib/redact.ts): `rk_live_…0501` — +enough for an operator to diff "start N vs start N+1" without reconstructing +the credential. Correlating masks against `agent-relay workspace active --json` +reveals whether two nodes are on the same workspace. + +The observer URL is deliberately not printed. Operators can build it from +`workspace active`; the CLI never puts the key on a display surface that +leaks into shell history or terminal recordings. + +## 6. Migration behavior for existing local nodes + +Existing brokers on this contract require no operator action: + +1. **Pre-existing `workspace-key.json`** — resumed as-is. If it has no + `enrolledNodeId`, the first `node up` after upgrade continues to serve the + pinned workspace without enrollment (unchanged from prior behavior). +2. **Pre-existing machine seed but no cached node token** — a fresh token is + minted on background reconnect. Node id stays the same because it derives + from the seed + cwd + workspace, so peers reach the same address. +3. **Pre-existing cached node token with a different workspace or engine URL + than the current resolve** — the cache is treated as invalid and re-minted. + This mirrors today's behavior; no schema change is required. +4. **Startup banner change** — operators who scraped `Workspace Key: rk_live_…` + for their tooling will find the same line but with the value masked. The + canonical `agent-relay workspace active --json` output remains the + programmatic surface for the full descriptor and gains `canonical` and + `planes` fields; older scripts consuming the descriptor keep working. + +## 7. Test coverage + +| Concern | Test | +| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Workspace pin survives stop → start | [`packages/cli/src/cli/lib/durable-workspace-identity.test.ts`](../packages/cli/src/cli/lib/durable-workspace-identity.test.ts) | +| Enrolled node id is pinned across restarts | same file | +| Mask is stable N vs N+1; rotated key visibly diffs | same file | +| `workspace active --json` returns the four service IDs | [`packages/cli/src/cli/commands/workspace.test.ts`](../packages/cli/src/cli/commands/workspace.test.ts) | +| `workspace active --json` includes `canonical` + `planes` | same file | +| `node status` prints mask, never key or observer | [`packages/cli/src/cli/commands/core.test.ts`](../packages/cli/src/cli/commands/core.test.ts) | +| `maskSecret` masks and prefix-preserves credentials | [`packages/cli/src/cli/lib/redact.test.ts`](../packages/cli/src/cli/lib/redact.test.ts) | +| Node id derivation is stable + workspace-scoped | [`crates/broker/src/node_control.rs`](../crates/broker/src/node_control.rs) | + +## 8. What this unblocks + +Chiefs owned by different principals can share one company workspace: their +brokers resolve the same `cloudWorkspaceId` even if their local +`workspace-key.json` files hold different bearer keys, because the descriptor +is derived from what the Cloud resolves — not from the raw key. AR-448 is the +prerequisite that keeps _each Chief's_ identity stable while that sharing +happens.