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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `.agentworkforce/features/critical-paths.md` — documents the 4 product-critical sequences that must work end-to-end (broker+registration, channel messaging, local agent lifecycle, MCP server).
- `@agent-relay/sdk` `./workflows` subpath export, enabling local workflow files to import the workflow builder without a build step.

### Fixed

- `agent-relay node up --background` now preserves persisted Cloud enrollment credentials and node identity through detached startup, and fails instead of reporting healthy when the enrolled node cannot connect.

## [10.6.1] - 2026-07-16

### Fixed
Expand Down
242 changes: 233 additions & 9 deletions packages/cli/src/cli/commands/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ 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 }),
getSession: vi.fn(
async () =>
({ workspace_key: '' }) as {
workspace_key?: string;
node_id?: string;
node_name?: string;
}
),
disconnect: vi.fn(() => undefined),
};

Expand Down Expand Up @@ -534,7 +541,7 @@ describe('registerCoreCommands', () => {
}
});

it('up --background re-execs a Bun standalone binary without adding its virtual entrypoint', async () => {
it('up --background preserves an enrolled identity through a Bun standalone re-exec', async () => {
const spawnedProcess = createSpawnedProcessMock();
let now = 0;
const fs = createFsMock();
Expand All @@ -546,25 +553,237 @@ describe('registerCoreCommands', () => {
if ((pid === 9001 || pid === 4242) && signal === 0) return;
throw new Error('unexpected kill check');
});
sdkStatusClient.getStatus.mockResolvedValue({
agent_count: 0,
pending_delivery_count: 0,
node_connected: true,
node_delivery: { token_present: true, connected: true },
});
sdkStatusClient.getSession.mockResolvedValue({
workspace_key: 'rk_enrolled',
node_id: 'node_enrolled',
node_name: 'sf-mini',
});
const { program, deps } = createHarness({
fs,
env: {
RELAY_NODE_ID: 'node_enrolled',
RELAY_NODE_TOKEN: 'nt_enrolled',
},
spawnedProcess,
killImpl,
nowImpl: vi.fn(() => now),
sleepImpl,
execPath: '/tmp/agent-relay-darwin-arm64',
cliScript: '/$bunfs/root/agent-relay-darwin-arm64',
argv: ['bun', '/$bunfs/root/agent-relay-darwin-arm64', 'up', '--background'],
argv: [
'bun',
'/$bunfs/root/agent-relay-darwin-arm64',
'node',
'up',
'--background',
'--config',
'agent-relay.mjs',
],
});

const exitCode = await runCommand(program, ['up', '--background']);
const exitCode = await runCommand(program, ['up', '--background', '--broker-name', 'sf-mini']);

expect(exitCode).toBe(0);
expect(deps.spawnProcess).toHaveBeenCalledWith('/tmp/agent-relay-darwin-arm64', ['up'], {
detached: true,
stdio: 'ignore',
env: deps.env,
expect(deps.spawnProcess).toHaveBeenCalledWith(
'/tmp/agent-relay-darwin-arm64',
['node', 'up', '--config', 'agent-relay.mjs', '--broker-name', 'sf-mini'],
{
detached: true,
stdio: 'ignore',
env: expect.objectContaining({
RELAY_NODE_ID: 'node_enrolled',
RELAY_NODE_TOKEN: 'nt_enrolled',
}),
}
);
});

it('up --background fails loudly when the broker reports the wrong enrolled identity', async () => {
const spawnedProcess = createSpawnedProcessMock();
let now = 0;
const fs = createFsMock();
const sleepImpl = vi.fn(async (ms: number) => {
now += ms;
fs.writeFileSync('/tmp/project/.agentworkforce/relay/connection.json', connectionFile(4242));
});
const stopped = new Set<number>();
const killImpl = vi.fn((pid: number, signal?: NodeJS.Signals | number) => {
if ((pid === 9001 || pid === 4242) && signal === 0 && !stopped.has(pid)) return;
if ((pid === 9001 || pid === 4242) && (signal === 'SIGTERM' || signal === 'SIGKILL')) {
stopped.add(pid);
return;
}
throw new Error('unexpected kill check');
});
sdkStatusClient.getStatus.mockResolvedValue({
node_connected: true,
node_delivery: { token_present: true, connected: true },
});
sdkStatusClient.getSession.mockResolvedValue({
workspace_key: 'rk_enrolled',
node_id: 'node_enrolled',
node_name: 'project',
});
const { program, deps } = createHarness({
fs,
env: {
RELAY_NODE_ID: 'node_enrolled',
RELAY_NODE_TOKEN: 'nt_enrolled',
},
spawnedProcess,
killImpl,
nowImpl: vi.fn(() => now),
sleepImpl,
});

const exitCode = await runCommand(program, ['up', '--background', '--broker-name', 'sf-mini']);

expect(exitCode).toBe(1);
expect(deps.error).toHaveBeenCalledWith(
'Cloud enrollment identity mismatch: expected node name "sf-mini", got "project".'
);
expect(killImpl).toHaveBeenCalledWith(4242, 'SIGTERM');
expect(deps.log).not.toHaveBeenCalledWith('Broker started.');
});

it('up --background rejects an enrolled node token without a node id', async () => {
const spawnedProcess = createSpawnedProcessMock();
let now = 0;
const fs = createFsMock();
const sleepImpl = vi.fn(async (ms: number) => {
now += ms;
fs.writeFileSync('/tmp/project/.agentworkforce/relay/connection.json', connectionFile(4242));
});
const stopped = new Set<number>();
const killImpl = vi.fn((pid: number, signal?: NodeJS.Signals | number) => {
if ((pid === 9001 || pid === 4242) && signal === 0 && !stopped.has(pid)) return;
if ((pid === 9001 || pid === 4242) && (signal === 'SIGTERM' || signal === 'SIGKILL')) {
stopped.add(pid);
return;
}
throw new Error('unexpected kill check');
});
const { program, deps } = createHarness({
fs,
env: { RELAY_NODE_ID: ' ', RELAY_NODE_TOKEN: 'nt_incomplete' },
spawnedProcess,
killImpl,
nowImpl: vi.fn(() => now),
sleepImpl,
});

const exitCode = await runCommand(program, ['up', '--background', '--broker-name', 'sf-mini']);

expect(exitCode).toBe(1);
expect(deps.error).toHaveBeenCalledWith(
'Cloud enrollment credentials are incomplete: RELAY_NODE_ID is required when RELAY_NODE_TOKEN is set.'
);
expect(killImpl).toHaveBeenCalledWith(4242, 'SIGTERM');
expect(deps.log).not.toHaveBeenCalledWith('Broker started.');
});

it('up --background retains broker state when failed enrollment cleanup cannot stop the broker', async () => {
const spawnedProcess = createSpawnedProcessMock();
let now = 0;
const fs = createFsMock();
const connectionPath = '/tmp/project/.agentworkforce/relay/connection.json';
const sleepImpl = vi.fn(async (ms: number) => {
now += ms;
fs.writeFileSync(connectionPath, connectionFile(4242));
});
const runningPids = new Set([9001, 4242]);
const killImpl = vi.fn((pid: number, signal?: NodeJS.Signals | number) => {
if (signal === 0) {
if (runningPids.has(pid)) return;
throw new Error('not running');
}
if (pid === 9001 && (signal === 'SIGTERM' || signal === 'SIGKILL')) {
runningPids.delete(pid);
}
});
sdkStatusClient.getStatus.mockResolvedValue({
node_connected: true,
node_delivery: { token_present: true, connected: true },
});
sdkStatusClient.getSession.mockResolvedValue({
workspace_key: 'rk_enrolled',
node_id: 'node_enrolled',
node_name: 'project',
});
const { program, deps } = createHarness({
fs,
env: {
RELAY_NODE_ID: 'node_enrolled',
RELAY_NODE_TOKEN: 'nt_enrolled',
},
spawnedProcess,
killImpl,
nowImpl: vi.fn(() => now),
sleepImpl,
});

const exitCode = await runCommand(program, ['up', '--background', '--broker-name', 'sf-mini']);

expect(exitCode).toBe(1);
expect(deps.error).toHaveBeenCalledWith(
'Failed to stop broker process after Cloud enrollment startup failed (pid: 4242). ' +
'Run `agent-relay down --force` to retry cleanup.'
);
expect(fs.existsSync(connectionPath)).toBe(true);
expect(deps.log).not.toHaveBeenCalledWith('Broker started.');
});

it('up --background fails when enrolled node delivery never connects', async () => {
const spawnedProcess = createSpawnedProcessMock();
let now = 0;
const fs = createFsMock();
const sleepImpl = vi.fn(async (ms: number) => {
now += ms;
fs.writeFileSync('/tmp/project/.agentworkforce/relay/connection.json', connectionFile(4242));
});
const stopped = new Set<number>();
const killImpl = vi.fn((pid: number, signal?: NodeJS.Signals | number) => {
if ((pid === 9001 || pid === 4242) && signal === 0 && !stopped.has(pid)) return;
if ((pid === 9001 || pid === 4242) && (signal === 'SIGTERM' || signal === 'SIGKILL')) {
stopped.add(pid);
return;
}
throw new Error('unexpected kill check');
});
sdkStatusClient.getStatus.mockResolvedValue({
node_connected: false,
node_delivery: { token_present: true, connected: false },
});
sdkStatusClient.getSession.mockResolvedValue({
workspace_key: 'rk_enrolled',
node_id: 'node_enrolled',
node_name: 'sf-mini',
});
const { program, deps } = createHarness({
fs,
env: {
RELAY_NODE_ID: 'node_enrolled',
RELAY_NODE_TOKEN: 'nt_expired',
},
spawnedProcess,
killImpl,
nowImpl: vi.fn(() => now),
sleepImpl,
});

const exitCode = await runCommand(program, ['up', '--background', '--broker-name', 'sf-mini']);

expect(exitCode).toBe(1);
expect(deps.error).toHaveBeenCalledWith(
'Cloud enrollment for node "sf-mini" did not become ready. Node delivery: DOWN (node websocket disconnected)'
);
expect(deps.log).not.toHaveBeenCalledWith('Broker started.');
});

it('up --background exits non-zero when the detached broker never becomes ready', async () => {
Expand Down Expand Up @@ -896,7 +1115,11 @@ describe('registerCoreCommands', () => {
const connectionPath = '/tmp/project/.agentworkforce/relay/connection.json';
const fs = createFsMock({ [connectionPath]: connectionFile(4242) });
sdkStatusClient.getStatus.mockResolvedValueOnce({ agent_count: 4, pending_delivery_count: 2 });
sdkStatusClient.getSession.mockResolvedValueOnce({ workspace_key: 'rk_live_test123' });
sdkStatusClient.getSession.mockResolvedValueOnce({
workspace_key: 'rk_live_test123',
node_id: 'node_enrolled',
node_name: 'sf-mini',
});

const { program, deps } = createHarness({ fs });

Expand All @@ -906,6 +1129,7 @@ describe('registerCoreCommands', () => {
expect(deps.log).toHaveBeenCalledWith('Status: RUNNING');
expect(deps.log).toHaveBeenCalledWith('Agents: 4');
expect(deps.log).toHaveBeenCalledWith('Pending deliveries: 2');
expect(deps.log).toHaveBeenCalledWith('Node: sf-mini (node_enrolled)');
expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_test123');
expect(deps.log).toHaveBeenCalledWith('Observer: https://agentrelay.com/observer?key=rk_live_test123');
expect(sdkStatusClient.disconnect).toHaveBeenCalled();
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/src/cli/commands/node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,28 @@ describe('registerNodeCommands', () => {
expect(log.mock.calls.flat().join('\n')).toContain('rw_123');
});

it('preserves the enrolled identity when background startup re-execs the CLI', async () => {
const resolveEnrollment = vi.fn(
() => enrollmentRecord
) as unknown as NodeCommandDependencies['resolveEnrollment'];
const { program, env } = createNodeHarness({ env: {}, resolveEnrollment });

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

expect(env).toMatchObject({
RELAY_NODE_ID: 'node_abc',
RELAY_NODE_TOKEN: 'nt_secret',
});
expect(brokerMocks.runUpCommand).toHaveBeenCalledWith(
expect.objectContaining({
background: true,
brokerName: 'kjglaptop',
nodeName: 'kjglaptop',
}),
expect.anything()
);
});

it('lets --broker-name beat the enrolled node name', async () => {
const resolveEnrollment = vi.fn(
() => enrollmentRecord
Expand Down Expand Up @@ -166,6 +188,24 @@ describe('registerNodeCommands', () => {
expect(brokerMocks.runUpCommand).toHaveBeenCalledTimes(1);
});

it('reuses the forwarded enrolled name when the detached child already has credentials', async () => {
const resolveEnrollment = vi.fn(
() => enrollmentRecord
) as unknown as NodeCommandDependencies['resolveEnrollment'];
const { program } = createNodeHarness({
env: { RELAY_NODE_ID: 'node_abc', RELAY_NODE_TOKEN: 'nt_secret' },
resolveEnrollment,
});

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

expect(resolveEnrollment).not.toHaveBeenCalled();
expect(brokerMocks.runUpCommand).toHaveBeenCalledWith(
expect.objectContaining({ brokerName: 'kjglaptop', nodeName: 'kjglaptop' }),
expect.anything()
);
});

it('keeps an existing RELAY_BASE_URL when applying enrollment creds', async () => {
const resolveEnrollment = vi.fn(
() => enrollmentRecord
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/src/cli/commands/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,15 @@ async function runNodeUp(options: UpCommandOptions, deps: NodeCommandDependencie
}
}

const nodeName = options.brokerName ?? enrolledNodeName;
await runUpCommand(
{
...options,
discoverConfig: true,
...((options.brokerName ?? enrolledNodeName)
? { nodeName: options.brokerName ?? enrolledNodeName }
: {}),
// The broker name is also its registered fleet-node name. Keeping both
// fields aligned makes foreground startup use the enrolled identity and
// lets detached startup preserve it via the existing --broker-name arg.
...(nodeName ? { brokerName: nodeName, nodeName } : {}),
},
deps.core
);
Expand Down
Loading
Loading