Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- `agent-relay node up` resolves its installed broker through canonical package-manager links and Relay's user install directories, so mise-managed and minimal-`PATH` launches no longer fail when the broker binary is already installed.
- `agent-relay node up` warns instead of silently ignoring stored Cloud fleet enrollments when the project workspace pin has no enrolled node id. That combination started the broker in the pinned workspace while the node never heartbeat, leaving the Cloud dashboard and `agent-relay fleet nodes` showing different rosters with no error from either.
- `agent-relay cloud enroll` records the enrolled node on the project workspace pin, so `node up` in that repo serves the node it just enrolled. A pin that already names a different node is reported and left untouched rather than repointed.
- `agent-relay workspace switch|join` keeps the project's enrolled fleet node id instead of dropping it, which previously produced the pin state that made the next `node up` ignore the enrollment store.

## [11.4.1] - 2026-08-03

Expand Down
25 changes: 23 additions & 2 deletions packages/cli/src/cli/agent-relay-mcp.startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ async function loadAgentRelayMcpModule(options: LoadOptions = {}) {
const telemetryTrack = vi.fn();
const telemetryInit = vi.fn();
const telemetryShutdown = vi.fn(async () => undefined);
const persistWorkspaceSession = vi.fn();
// Returns a result object describing what the write changed beyond the key.
const persistWorkspaceSession = vi.fn(() => ({}));
const resolveWorkspaceSessionKey = vi.fn(() => options.persistedWorkspaceKey);
const validateWorkspaceSessionName = vi.fn((name: string) => {
const trimmed = name.trim();
Expand Down Expand Up @@ -258,8 +259,12 @@ async function loadAgentRelayMcpModule(options: LoadOptions = {}) {
shutdown: telemetryShutdown,
track: telemetryTrack,
}));
vi.doMock('./lib/workspace-session.js', () => ({
vi.doMock('./lib/workspace-session.js', async (importOriginal) => ({
persistWorkspaceSession,
// The real formatter, not a copy, so the warning these tools return cannot
// drift away from what the CLI prints for the same event.
describeClearedEnrollment: (await importOriginal<typeof import('./lib/workspace-session.js')>())
.describeClearedEnrollment,
resolveWorkspaceSessionKey,
validateWorkspaceSessionName,
}));
Expand Down Expand Up @@ -574,6 +579,22 @@ describe('createAgentRelayMcpServer', () => {
expect(mocks.relayInstances.some((instance) => instance.config.apiKey === 'rk_live_selected')).toBe(true);
});

it('reports an enrolled fleet node dropped by joining another workspace', async () => {
const { mod, mocks } = await loadAgentRelayMcpModule();
mocks.persistWorkspaceSession.mockReturnValueOnce({ clearedEnrolledNodeId: 'node_abc' });

mod.createAgentRelayMcpServer({ baseUrl: 'https://relay.example.com/' });
const server = mocks.serverInstances[0];
const result = await server.tools
.get('set_workspace_key')
?.handler({ workspace_key: 'rk_live_selected' });

// Silence here is what left the fleet node shadowed until some later
// `node up` mentioned it.
expect(result.structuredContent.message).toContain('node_abc');
expect(result.structuredContent.message).toContain('relay cloud enroll');
});

it('registers submit_result when a spawned-agent result callback is configured', async () => {
vi.stubEnv('AGENT_RELAY_RESULT_URL', 'http://127.0.0.1:3889/api/agent-result');
vi.stubEnv('AGENT_RELAY_RESULT_TOKEN', 'arr_test');
Expand Down
16 changes: 12 additions & 4 deletions packages/cli/src/cli/agent-relay-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import { registerMessagingTools } from './mcp/messaging-tools.js';
import { identityOverrideInputShape, messageResult } from './mcp/tool-shapes.js';
import {
describeClearedEnrollment,
persistWorkspaceSession,
resolveWorkspaceSessionKey,
validateWorkspaceSessionName,
Expand Down Expand Up @@ -275,7 +276,7 @@
});
} catch (err) {
if ((err as { name?: string }).name === 'AbortError') {
throw new Error(`Agent Relay result submission timed out after ${timeoutMs}ms`);

Check warning on line 279 in packages/cli/src/cli/agent-relay-mcp.ts

View workflow job for this annotation

GitHub Actions / lint

There is no `cause` attached to the symptom error being thrown
}
throw err;
} finally {
Expand Down Expand Up @@ -328,7 +329,7 @@
return { agentName, agentToken };
}

export async function registerAgentWithRebind({

Check warning on line 332 in packages/cli/src/cli/agent-relay-mcp.ts

View workflow job for this annotation

GitHub Actions / lint

Async function 'registerAgentWithRebind' has a complexity of 23. Maximum allowed is 15
session,
setSession,
getRelay,
Expand Down Expand Up @@ -409,7 +410,7 @@
title: 'Create Workspace',
description:
'Explicitly start a new Agent Relay workspace session and persist it for this project. ' +
'Returns the new workspace key and its resolved name. A `warning` field is present only when the workspace was created but its session could not be saved to disk, meaning the key must be kept and re-supplied to reconnect.',
"Returns the new workspace key and its resolved name. A `warning` field appears in two cases, and its text says which: the workspace was created but its session could not be saved to disk, meaning the key must be kept and re-supplied to reconnect; or the session was saved and doing so dropped this project's enrolled Cloud fleet node, because the new workspace is not the one that node belongs to.",
inputSchema: {
name: z.string().describe('Human-readable workspace name'),
},
Expand Down Expand Up @@ -438,7 +439,12 @@
});
let persistenceWarning: string | undefined;
try {
persistWorkspaceSession({ name: workspaceName, workspaceKey });
// A new workspace key never matches an existing pin, so this can drop
// the project's enrolled fleet node. Report it rather than letting the
// next `node up` be the first thing that mentions it.
persistenceWarning = describeClearedEnrollment(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
persistWorkspaceSession({ name: workspaceName, workspaceKey })
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
persistenceWarning =
Expand All @@ -459,7 +465,7 @@
title: 'Set Workspace Key',
description:
'Join this MCP session to an existing Agent Relay workspace using a shared workspace key. ' +
'Returns a confirmation message stating whether the key was persisted for this project, and whether "register_agent" must be called to claim an identity in the newly joined workspace.',
'Returns a confirmation message stating whether the key was persisted for this project, and whether "register_agent" must be called to claim an identity in the newly joined workspace. The message also reports when joining dropped this project\'s enrolled Cloud fleet node, which happens when the key names a workspace that node does not belong to.',
inputSchema: {
workspace_key: z.string().optional().describe('Workspace key starting with "rk_live_"'),
api_key: z.string().optional().describe('Deprecated alias for workspace_key'),
Expand Down Expand Up @@ -495,7 +501,9 @@
}
let persistenceWarning: string | undefined;
try {
persistWorkspaceSession({ workspaceKey: key });
// Joining a different workspace drops this project's enrolled fleet
// node; surface that here instead of at the next `node up`.
persistenceWarning = describeClearedEnrollment(persistWorkspaceSession({ workspaceKey: key }));
Comment on lines +504 to +506

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep cleared enrollment separate from persistence failure.

A cleared enrollment means persistWorkspaceSession succeeded. The current persistenceWarning branch selects activeMessage, so set_workspace_key does not state that the key persisted despite the contract documented at Line 468.

  • packages/cli/src/cli/agent-relay-mcp.ts#L504-L506: Track persistence failure separately. Use persistedMessage plus the cleared-enrollment warning after a successful write.
  • packages/cli/src/cli/agent-relay-mcp.startup.test.ts#L582-L596: Assert that the cleared-enrollment response also says the key persisted.
Proposed fix
+      let persistenceFailed = false;
       let persistenceWarning: string | undefined;
       try {
         persistenceWarning = describeClearedEnrollment(persistWorkspaceSession({ workspaceKey: key }));
       } catch (error) {
+        persistenceFailed = true;
         // existing error message assignment
       }

-      const message = persistenceWarning ? `${activeMessage} ${persistenceWarning}` : persistedMessage;
+      const message = persistenceWarning
+        ? `${persistenceFailed ? activeMessage : persistedMessage} ${persistenceWarning}`
+        : persistedMessage;

Based on PR objectives: MCP tools must report cleared enrollment while preserving their output contract.

📍 Affects 2 files
  • packages/cli/src/cli/agent-relay-mcp.ts#L504-L506 (this comment)
  • packages/cli/src/cli/agent-relay-mcp.startup.test.ts#L582-L596
🤖 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/src/cli/agent-relay-mcp.ts` around lines 504 - 506, Track
persistence failure independently in the set_workspace_key flow around
persistWorkspaceSession and describeClearedEnrollment: use persistedMessage
after a successful write, appending the cleared-enrollment warning, while
retaining the failure message only when persistence fails. Update
packages/cli/src/cli/agent-relay-mcp.startup.test.ts lines 582-596 to assert the
cleared-enrollment response states that the key persisted.

} catch (error) {
const persistenceError = error instanceof Error ? error.message : String(error);
persistenceWarning =
Expand Down
120 changes: 120 additions & 0 deletions packages/cli/src/cli/commands/cloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ function createHarness(overrides?: Partial<CloudDependencies>) {

const deps: CloudDependencies = {
log: vi.fn(() => undefined),
warn: vi.fn(() => undefined),
error: vi.fn(() => undefined),
exit,
ensureCloudSession: vi.mocked(ensureCloudSession),
Expand All @@ -94,6 +95,10 @@ function createHarness(overrides?: Partial<CloudDependencies>) {
upsertFleetNodeEnrollment:
cloudMocks.upsertFleetNodeEnrollment as unknown as CloudDependencies['upsertFleetNodeEnrollment'],
writeEnrollmentRecoveryFile: vi.fn(() => '/tmp/cloud-enrollment-recovery.json'),
// Stubbed by default so no test can reach the real checkout's workspace pin.
linkEnrolledNodeToProjectPin: vi.fn(() => ({
status: 'no-pin',
})) as unknown as CloudDependencies['linkEnrolledNodeToProjectPin'],
...overrides,
};

Expand Down Expand Up @@ -1770,6 +1775,121 @@ describe('registerCloudCommands', () => {
expect(cloudMocks.upsertFleetNodeEnrollment).not.toHaveBeenCalled();
});

it('cloud enroll links the enrolled node to this project workspace pin', async () => {
cloudMocks.enrollFleetNode.mockResolvedValueOnce({
nodeId: 'node_abc',
nodeName: 'kjglaptop',
nodeToken: 'nt_secret',
relayWorkspaceId: 'rw_123',
relaycastUrl: 'https://relaycast.example.com',
websocketUrl: 'https://relaycast.example.com/v1/node/ws',
});
cloudMocks.upsertFleetNodeEnrollment.mockReturnValueOnce({ version: 1, active: {}, nodes: {} });
const linkEnrolledNodeToProjectPin = vi.fn(() => ({
status: 'linked',
nodeId: 'node_abc',
pinPath: '/repo/.agentworkforce/relay/workspace-key.json',
})) as unknown as CloudDependencies['linkEnrolledNodeToProjectPin'];
const log = vi.fn();
const { program } = createHarness({ log, linkEnrolledNodeToProjectPin });

await program.parseAsync(['node', 'agent-relay', 'cloud', 'enroll', '--token', 'ocl_node_enr_x']);

expect(linkEnrolledNodeToProjectPin).toHaveBeenCalledWith({ nodeId: 'node_abc' });
const output = log.mock.calls.flat().join('\n');
expect(output).toContain('/repo/.agentworkforce/relay/workspace-key.json');
expect(output).toContain('node_abc');
// The pinned key holds a workspace *key* and the enrollment holds a
// workspace *id*, so the link cannot be verified locally. Say which
// workspace will actually be served and do not claim more than that.
expect(output).toContain('rw_123');
expect(output).toContain('was not verified');
});

it('cloud enroll warns instead of repointing a pin that names another node', async () => {
cloudMocks.enrollFleetNode.mockResolvedValueOnce({
nodeId: 'node_new',
nodeName: 'kjglaptop',
nodeToken: 'nt_secret',
relayWorkspaceId: 'rw_123',
relaycastUrl: 'https://relaycast.example.com',
websocketUrl: 'https://relaycast.example.com/v1/node/ws',
});
cloudMocks.upsertFleetNodeEnrollment.mockReturnValueOnce({ version: 1, active: {}, nodes: {} });
const linkEnrolledNodeToProjectPin = vi.fn(() => ({
status: 'conflict',
nodeId: 'node_new',
pinnedNodeId: 'node_existing',
pinPath: '/repo/.agentworkforce/relay/workspace-key.json',
})) as unknown as CloudDependencies['linkEnrolledNodeToProjectPin'];
const warn = vi.fn();
const { program } = createHarness({ warn, linkEnrolledNodeToProjectPin });

await program.parseAsync(['node', 'agent-relay', 'cloud', 'enroll', '--token', 'ocl_node_enr_x']);

const warned = warn.mock.calls.flat().join('\n');
expect(warned).toContain('already linked to node node_existing');
expect(warned).toContain('node_new');
});

it('cloud enroll survives a pin write failure without failing the redeemed enrollment', async () => {
cloudMocks.enrollFleetNode.mockResolvedValueOnce({
nodeId: 'node_abc',
nodeName: 'kjglaptop',
nodeToken: 'nt_secret',
relayWorkspaceId: 'rw_123',
relaycastUrl: 'https://relaycast.example.com',
websocketUrl: 'https://relaycast.example.com/v1/node/ws',
});
cloudMocks.upsertFleetNodeEnrollment.mockReturnValueOnce({ version: 1, active: {}, nodes: {} });
const linkEnrolledNodeToProjectPin = vi.fn(() => {
throw new Error('EACCES: permission denied');
}) as unknown as CloudDependencies['linkEnrolledNodeToProjectPin'];
const log = vi.fn();
const warn = vi.fn();
const { program, deps } = createHarness({ log, warn, linkEnrolledNodeToProjectPin });

await program.parseAsync(['node', 'agent-relay', 'cloud', 'enroll', '--token', 'ocl_node_enr_x']);

expect(deps.exit).not.toHaveBeenCalled();
expect(log.mock.calls.flat().join('\n')).toContain('Enrolled node "kjglaptop"');
expect(warn.mock.calls.flat().join('\n')).toContain('EACCES');
});

it('cloud enroll --json keeps pin reporting off stdout', async () => {
cloudMocks.enrollFleetNode.mockResolvedValueOnce({
nodeId: 'node_abc',
nodeName: 'kjglaptop',
nodeToken: 'nt_secret',
relayWorkspaceId: 'rw_123',
relaycastUrl: 'https://relaycast.example.com',
websocketUrl: 'https://relaycast.example.com/v1/node/ws',
});
cloudMocks.upsertFleetNodeEnrollment.mockReturnValueOnce({ version: 1, active: {}, nodes: {} });
const linkEnrolledNodeToProjectPin = vi.fn(() => ({
status: 'linked',
nodeId: 'node_abc',
pinPath: '/repo/.agentworkforce/relay/workspace-key.json',
})) as unknown as CloudDependencies['linkEnrolledNodeToProjectPin'];
const log = vi.fn();
const { program } = createHarness({ log, linkEnrolledNodeToProjectPin });

await program.parseAsync([
'node',
'agent-relay',
'cloud',
'enroll',
'--token',
'ocl_node_enr_x',
'--json',
]);

// The pin is still reconciled, but stdout stays parseable JSON.
expect(linkEnrolledNodeToProjectPin).toHaveBeenCalledTimes(1);
expect(log).toHaveBeenCalledTimes(1);
expect(() => JSON.parse(String(log.mock.calls[0][0]))).not.toThrow();
});

it('cloud enroll rejects a non-positive --max-agents', async () => {
const { program } = createHarness();

Expand Down
48 changes: 48 additions & 0 deletions packages/cli/src/cli/commands/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
type WorkflowSchedule,
} from '@agent-relay/cloud';

import { linkEnrolledNodeToProjectPin, type EnrolledNodePinResult } from '../lib/enrollment-pin.js';
import { defaultExit } from '../lib/exit.js';
import { sanitizeForTerminalLine } from '../lib/formatting.js';
import { maskSecret } from '../lib/redact.js';
Expand Down Expand Up @@ -62,6 +63,7 @@

export interface CloudDependencies {
log: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
exit: ExitFn;
ensureCloudSession: typeof ensureCloudSession;
Expand All @@ -74,13 +76,16 @@
* "recovery write also failed" last resort.
*/
writeEnrollmentRecoveryFile: (record: unknown) => string;
/** Reconcile a freshly enrolled node against this project's workspace pin. */
linkEnrolledNodeToProjectPin: typeof linkEnrolledNodeToProjectPin;
}

// ── Helpers ──────────────────────────────────────────────────────────────────

function withDefaults(overrides: Partial<CloudDependencies> = {}): CloudDependencies {
return {
log: (...args: unknown[]) => console.log(...args),
warn: (...args: unknown[]) => console.warn(...args),
error: (...args: unknown[]) => console.error(...args),
exit: defaultExit,
ensureCloudSession,
Expand All @@ -98,6 +103,7 @@
fs.chmodSync(file, 0o600);
return file;
},
linkEnrolledNodeToProjectPin,
...overrides,
};
}
Expand Down Expand Up @@ -441,12 +447,41 @@
};
} catch (error) {
if (isCloudLoginError(error)) {
throw new Error('Cloud login required. Run `agent-relay cloud login` and retry.');

Check warning on line 450 in packages/cli/src/cli/commands/cloud.ts

View workflow job for this annotation

GitHub Actions / lint

There is no `cause` attached to the symptom error being thrown
}
throw error;
}
}

/**
* Link a redeemed enrollment to this project's workspace pin and report anything
* the operator must act on. Runs after the one-time token is already consumed,
* so every failure here is reported and swallowed — never fatal.
*/
function reconcileEnrollmentPin(
nodeId: string,
deps: Pick<CloudDependencies, 'warn' | 'linkEnrolledNodeToProjectPin'>
): EnrolledNodePinResult | undefined {
try {
const result = deps.linkEnrolledNodeToProjectPin({ nodeId });
if (result.status === 'conflict') {
deps.warn(
`This project's workspace pin (${result.pinPath}) is already linked to node ${result.pinnedNodeId}, ` +
`so it was left unchanged. 'relay node up' here will keep serving ${result.pinnedNodeId}, not the node ` +
`just enrolled (${result.nodeId}). Update or remove the pin to serve the new node.`
);
}
return result;
} catch (err) {
deps.warn(
`Enrollment succeeded but this project's workspace pin could not be updated: ${
err instanceof Error ? err.message : String(err)
}. 'relay node up' in this project may ignore the new enrollment.`
);
return undefined;
}
}

async function resolveFleetNodeEnrollmentInput(
options: {
token?: string;
Expand Down Expand Up @@ -968,6 +1003,11 @@
return;
}

// A repo pinned to a workspace ignores the enrollment store entirely
// on `node up`, so link the two now while we know the node id. Never
// let this fail the command: the one-time token is already redeemed.
const pin = reconcileEnrollmentPin(record.nodeId, deps);

if (options.json) {
// Never print the node token, even in JSON mode.
const { nodeToken: _nodeToken, ...safe } = record;
Expand All @@ -981,6 +1021,14 @@
`Enrolled node "${record.nodeName}"${nodeIdSuffix} in workspace ${record.relayWorkspaceId}. ` +
"Run 'relay node up' to serve it."
);
if (pin?.status === 'linked') {
deps.log(
`Linked this project's workspace pin (${pin.pinPath}) to node ${pin.nodeId}, so ` +
`'relay node up' here serves this enrollment in workspace ${record.relayWorkspaceId}. ` +
'The pinned workspace key was not verified against that workspace — if they differ, ' +
'agent commands in this project keep using the pinned one.'
);
}
} catch (err) {
deps.error(err instanceof Error ? err.message : String(err));
deps.exit(1);
Expand Down
Loading
Loading