Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ 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 - Patch]

### Fixed

- `agent-relay up` / `node up` resolve the workspace through one documented precedence ladder: `--workspace-key` → `RELAY_WORKSPACE_KEY`/`AGENT_RELAY_WORKSPACE_KEY`/`RELAY_API_KEY` → the repository pin in `.agentworkforce/relay/workspace-key.json` → the machine-global active workspace in `~/.agentworkforce/relay/workspaces.json` → creating one. Startup prints the winning source (flag, variable, or file path — never key material).
- A Cloud enrollment no longer re-homes an enrolled node out of its repository's workspace. `RELAY_NODE_TOKEN` selects the node's identity, not its workspace, and no longer suppresses the repository pin; when a stored enrollment addresses a different workspace than the pin, `node up` stops and names both sources instead of silently choosing one.
- A first `up` in a fresh directory joins the machine's active workspace instead of silently creating a new one, and a start that does create a workspace says so instead of printing the same output as a join.

## [11.3.1] - 2026-07-31

Expand Down
36 changes: 36 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,42 @@ agent-relay node agent release <name>

For AI SDK native harnesses, attach renders structured activity, text, tools, approvals, files, usage, and lifecycle events. Add `--json` for NDJSON, `--reasoning` for reasoning events, or `--diagnostics` for sidecar diagnostics. Native harness `drive` is line-oriented and acknowledged; native harness `passthrough` is unsupported because no terminal stream exists. PTY attach behavior is unchanged.

### Which workspace a broker joins

`agent-relay up` and `agent-relay node up` resolve the workspace through one
precedence ladder. The first source that resolves wins:
Comment on lines +52 to +53

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the local up alias.

This section names agent-relay up and agent-relay node up, but not agent-relay local up. Add the alias so users can find the same workspace-resolution rules for every supported startup command.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/README.md` around lines 52 - 53, Update the workspace-resolution
documentation section to include agent-relay local up alongside agent-relay up
and agent-relay node up, making clear that all supported startup aliases follow
the same precedence ladder.


| # | Source | Where it comes from |
| --- | ------------------------------- | ----------------------------------------------------------------------------- |
| 1 | Command-line flag | `--workspace-key` / `--wk` |
| 2 | Environment | `RELAY_WORKSPACE_KEY`, then `AGENT_RELAY_WORKSPACE_KEY`, then `RELAY_API_KEY` |
| 3 | Repository pin | `<project>/.agentworkforce/relay/workspace-key.json` |
| 4 | Machine-global active workspace | the `active` entry in `~/.agentworkforce/relay/workspaces.json` |
| 5 | New workspace | created only when nothing above resolves |

Two rules follow from the order:

- **The repository pin always beats the machine-global active workspace.**
Switching your active workspace (`agent-relay workspace use <name>`) never
re-homes a checkout that already pinned one.
- **A new workspace is a last resort, not a default.** A fresh directory joins
the machine's active workspace when one is selected. When nothing resolves and
a workspace is created, startup says so explicitly.

Startup prints the winning source (a flag name, an environment variable, or a
file path — never key material):

```
Workspace source: repository pin (/repo/.agentworkforce/relay/workspace-key.json)
Workspace: joined rw_7ccfea89
```
Comment on lines +75 to +78

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a language identifier to this fenced block.

Line 75 starts an unlabeled fenced block. markdownlint reports MD040. Use text for this startup-output example.

Proposed fix
-```
+```text
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
Workspace source: repository pin (/repo/.agentworkforce/relay/workspace-key.json)
Workspace: joined rw_7ccfea89
```
🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 75-75: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/README.md` around lines 75 - 78, Label the fenced startup-output
example in the README with the text language identifier by changing its opening
fence to ```text, leaving the block contents unchanged.

Source: Linters/SAST tools


A Cloud enrollment (`RELAY_NODE_TOKEN`, or a record in the Fleet enrollment
store) selects the node's _identity_, not its workspace, so it never appears on
this ladder. If a stored enrollment addresses a different workspace than the
repository pin, `node up` refuses to start and names both source files rather
than silently choosing one.

## Remote fleet agents

The `fleet` command group lists and controls agents across all live nodes in
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/cli/commands/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export interface CoreRelay {
shutdown: () => Promise<unknown>;
/** Agent Relay workspace key, available after the hello handshake. */
workspaceKey?: string;
/** Relay workspace id the broker joined, available after the hello handshake. */
workspaceId?: string;
/** PID of the underlying broker process, when available. */
brokerPid?: number;
/** Actual HTTP API port bound by the broker, including OS-assigned ports. */
Expand Down Expand Up @@ -187,6 +189,9 @@ async function createDefaultRelay(
get workspaceKey() {
return client.workspaceKey;
},
get workspaceId() {
return client.workspaceId;
},
get brokerPid() {
return client.brokerPid;
},
Expand Down
66 changes: 62 additions & 4 deletions packages/cli/src/cli/commands/node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,14 @@ function createNodeHarness(opts?: {
const error = vi.fn();
const warn = vi.fn();

const core = { env, exit, log, error, warn } as unknown as CoreDependencies;
const core = {
env,
exit,
log,
error,
warn,
getProjectPaths: () => ({ projectRoot: '/repo', dataDir: '/repo/.agentworkforce/relay' }),
} as unknown as CoreDependencies;
const resolveEnrollment =
opts?.resolveEnrollment ??
(vi.fn(() => undefined) as unknown as NodeCommandDependencies['resolveEnrollment']);
Expand Down Expand Up @@ -236,7 +243,7 @@ describe('registerNodeCommands', () => {
expect(env.RELAY_NODE_TOKEN).toBeUndefined();
});

it('resumes a project-pinned workspace instead of replacing it with an enrollment', async () => {
it('never adopts an enrollment for a project that pinned its own workspace', async () => {
const resolveEnrollment = vi.fn(
() => enrollmentRecord
) as unknown as NodeCommandDependencies['resolveEnrollment'];
Expand All @@ -251,13 +258,64 @@ describe('registerNodeCommands', () => {

await program.parseAsync(['node', 'up'], { from: 'user' });

// A pin without an enrolled node id never reaches for the machine-global
// enrollment store, and no node token is applied — so `runUpCommand`'s
// precedence ladder resolves the repository pin unopposed.
expect(resolveEnrollment).not.toHaveBeenCalled();
expect(env.RELAY_WORKSPACE_KEY).toBe('rk_project_session');
expect(env.RELAY_API_KEY).toBe('rk_project_session');
expect(env.RELAY_NODE_TOKEN).toBeUndefined();
expect(brokerMocks.runUpCommand).toHaveBeenCalledTimes(1);
});

it('refuses to start when the enrollment and the repository pin disagree (#1406)', async () => {
const resolveEnrollment = vi.fn(
() => enrollmentRecord
) as unknown as NodeCommandDependencies['resolveEnrollment'];
const { program, env, error, exit } = createNodeHarness({
env: { AGENT_RELAY_HOME: '/tmp/relay-home-fixture' },
resolveEnrollment,
// A previous start recorded rw_stale; the enrollment points at rw_123.
resolveProjectWorkspaceSession: vi.fn(() => ({
workspaceKey: 'rk_project_session',
enrolledNodeId: 'node_abc',
workspaceId: 'rw_stale',
})),
});

await expect(program.parseAsync(['node', 'up'], { from: 'user' })).rejects.toBeInstanceOf(ExitSignal);

expect(exit).toHaveBeenCalledWith(1);
const message = error.mock.calls.flat().join('\n');
expect(message).toContain('select different workspaces');
expect(message).toContain('rw_stale');
expect(message).toContain('rw_123');
expect(message).toContain('workspace-key.json');
// Diagnostics name sources, never credentials.
expect(message).not.toContain('rk_project_session');
expect(message).not.toContain('nt_secret');
expect(env.RELAY_NODE_TOKEN).toBeUndefined();
expect(brokerMocks.runUpCommand).not.toHaveBeenCalled();
});

it('starts normally when the enrollment matches the pinned workspace', async () => {
const resolveEnrollment = vi.fn(
() => enrollmentRecord
) as unknown as NodeCommandDependencies['resolveEnrollment'];
const { program, env } = createNodeHarness({
env: {},
resolveEnrollment,
resolveProjectWorkspaceSession: vi.fn(() => ({
workspaceKey: 'rk_project_session',
enrolledNodeId: 'node_abc',
workspaceId: 'rw_123',
})),
});

await program.parseAsync(['node', 'up'], { from: 'user' });

expect(env.RELAY_NODE_TOKEN).toBe('nt_secret');
expect(brokerMocks.runUpCommand).toHaveBeenCalledTimes(1);
});

it('preserves an enrolled identity across a consecutive project-session restart', async () => {
const firstResolveEnrollment = vi.fn(
() => enrollmentRecord
Expand Down
66 changes: 54 additions & 12 deletions packages/cli/src/cli/commands/node.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Command } from 'commander';
import { resolveActiveFleetNodeEnrollment } from '@agent-relay/cloud';
import { fleetNodeEnrollmentStorePath, resolveActiveFleetNodeEnrollment } from '@agent-relay/cloud';

import {
addUpCommandOptions,
Expand All @@ -9,7 +9,11 @@ import {
type UpCommandOptions,
} from './core.js';
import { runUpCommand } from '../lib/broker-lifecycle.js';
import { readProjectWorkspaceSession, type ProjectWorkspaceSession } from '../lib/project-workspace-key.js';
import {
projectWorkspaceKeyPath,
readProjectWorkspaceSession,
type ProjectWorkspaceSession,
} from '../lib/project-workspace-key.js';
import { promoteWorkspaceKeyEnvAlias } from '../lib/workspace-env.js';
import { registerLocalAgentCommands } from './local-agent.js';
import { registerLocalWorkflowCommands } from './local-workflow.js';
Expand Down Expand Up @@ -81,10 +85,41 @@ function prepareExplicitWorkspaceForNodeUp(
return Boolean(options.workspaceKey?.trim() || envWorkspaceKey);
}

/** Apply a project-pinned workspace without changing the persisted enrolled-node association. */
function resumeProjectWorkspace(session: ProjectWorkspaceSession, deps: NodeCommandDependencies): void {
deps.core.env.RELAY_WORKSPACE_KEY = session.workspaceKey;
deps.core.env.RELAY_API_KEY = session.workspaceKey;
/**
* Refuse to start when the stored enrollment addresses a different workspace
* than the repository pin.
*
* The enrollment store is machine-global; the pin is per-repository. When they
* disagree, silently preferring either one re-homes the node — so name both
* sources and stop. Only possible once a previous start recorded the pin's
* workspace id; before that the two are simply passed through together (the
* pin wins for workspace selection, the enrollment for node identity) and a
* mismatched node token fails loudly at registration instead.
*/
function reportWorkspaceSourceConflict(
record: NonNullable<ReturnType<typeof resolveActiveFleetNodeEnrollment>>,
session: ProjectWorkspaceSession | undefined,
deps: NodeCommandDependencies
): boolean {
const pinnedWorkspaceId = session?.workspaceId?.trim();
const enrolledWorkspaceId = record.relayWorkspaceId?.trim();
if (!pinnedWorkspaceId || !enrolledWorkspaceId || pinnedWorkspaceId === enrolledWorkspaceId) {
return false;
}

const pinPath = projectWorkspaceKeyPath(deps.core.getProjectPaths().dataDir);
deps.error(
'Refusing to start: this repository and the stored Fleet enrollment select different workspaces.'
);
deps.error(` repository pin ${pinPath} -> workspace ${pinnedWorkspaceId}`);
deps.error(
` fleet enrollment ${fleetNodeEnrollmentStorePath(deps.core.env)} -> workspace ${enrolledWorkspaceId} (node ${record.nodeId})`
);
deps.error(
'Pass --workspace-key to choose explicitly, re-enroll this node in the pinned workspace, ' +
'or delete the repository pin to adopt the enrollment.'
);
return true;
}

/** Apply stored enrollment credentials and return the enrolled node name, when present. */
Expand Down Expand Up @@ -130,7 +165,14 @@ function resolveEnrollmentForProject(
});
}

/** Apply an enrollment or safely resume a project workspace when its enrollment is unavailable. */
/**
* Apply the node identity for this start.
*
* Workspace selection is NOT decided here — `runUpCommand` walks the shared
* precedence ladder (flag → env → repository pin → machine-global active) after
* this returns. This function only settles which node identity the broker runs
* as, so an enrollment can no longer suppress the repository's workspace.
*/
function applyResolvedNodeSession(
record: ReturnType<typeof resolveActiveFleetNodeEnrollment> | undefined,
projectSession: ProjectWorkspaceSession | undefined,
Expand All @@ -139,16 +181,12 @@ function applyResolvedNodeSession(
if (record) {
return applyEnrollment(record, deps);
}
if (!projectSession) {
return undefined;
}
if (projectSession.enrolledNodeId) {
if (projectSession?.enrolledNodeId) {
deps.core.env.AGENT_RELAY_ENROLLED_NODE_ID = projectSession.enrolledNodeId;
deps.warn(
`Persisted enrollment for node "${projectSession.enrolledNodeId}" was not found; resuming the pinned workspace without that node identity.`
);
}
resumeProjectWorkspace(projectSession, deps);
return undefined;
}

Expand Down Expand Up @@ -184,6 +222,10 @@ async function runNodeUp(options: UpCommandOptions, deps: NodeCommandDependencie
deps.exit(1);
return;
}
if (record && reportWorkspaceSourceConflict(record, projectSession, deps)) {
deps.exit(1);
return;
}
// Serve under the enrolled name (mirrors the old `fleet serve
// --enrollment-token` behavior where --name beat the enrollment name).
enrolledNodeName = applyResolvedNodeSession(record, projectSession, deps);
Expand Down
Loading
Loading