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

## [Unreleased]

### Fixed

- `deploy`, `deployments list`, `destroy`, `runs`, `trigger` and `local-surface`
now address cloud APIs with the cloud workspace id instead of the relaycast
workspace id, which cloud rejected with `403 Forbidden` after
`agentworkforce login`. When the cloud returns no cloud workspace id for a
workspace, the CLI now stops with an actionable error instead of silently
falling back to the relaycast id.
- `WORKFORCE_WORKSPACE_TOKEN` set to a relaycast workspace key (`rk_…` from
`workspaces.json`) is rejected up front with guidance instead of failing
later with `401`.

### Added

- `agentworkforce login` prints the cloud workspace id, and warns when the
chosen workspace has none.

## [4.1.51] - 2026-08-31

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/customers/proactive-agents-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Then sign in once:
agentworkforce login
```

The login command opens a browser, completes PKCE auth, lets you choose a workspace, and stores the workspace-scoped deploy token in keychain-backed storage.
The login command opens a browser, signs you in to Agent Relay cloud, lets you choose a workspace, and prints its cloud workspace id. Later `deploy`, `deployments list` and `destroy` commands reuse that session automatically, so you do not need to set `WORKFORCE_WORKSPACE_ID` or `WORKFORCE_WORKSPACE_TOKEN`. If you do set them (for CI), the id must be the cloud workspace id (UUID), not the `rw_…` relaycast id, and the token must be a cloud access token, not the `rk_live_…` key from `workspaces.json` — see the CLI README's "Login and workspace ids" section.

## Configure The Persona

Expand Down
9 changes: 6 additions & 3 deletions examples/weekly-digest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@ export WEEKLY_DIGEST_TOPICS="agentworkforce,relayfile,proactive-agents"
export WEEKLY_DIGEST_REPO="YourOrg/weekly-digest"
export BRAVE_API_KEY="brave_..."

# Workspace (only needed when actually launching, not for --dry-run):
export WORKFORCE_WORKSPACE_ID="ws_demo"
export WORKFORCE_WORKSPACE_TOKEN="ws_token_..."
# Workspace auth (only needed when actually launching, not for --dry-run).
# Locally, just run `agentworkforce login`. For CI, set both:
# WORKFORCE_WORKSPACE_ID = cloud workspace id (UUID printed by login), not the rw_… id
# WORKFORCE_WORKSPACE_TOKEN = cloud access token, not the rk_live_… workspaces.json key
# export WORKFORCE_WORKSPACE_ID="<cloud-workspace-uuid>"
# export WORKFORCE_WORKSPACE_TOKEN="<cloud-access-token>"

# Relayfile mount root the handler writes into. The workforce runtime
# sets this automatically when it spawns the handler. Only set it
Expand Down
41 changes: 41 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,47 @@ corepack pnpm -r build
corepack pnpm --filter agentworkforce link --global
```

## Login and workspace ids

```sh
agentworkforce login
```

`login` signs you in to Agent Relay cloud, lets you pick a workspace, and
prints its **cloud workspace id** (a UUID). After that, `deploy`,
`deployments list`, `destroy`, `runs`, `trigger`, `env` and `integrations` use
the login session automatically — no environment variables are needed.

A workspace has two different ids, and they are not interchangeable:

| Id | Looks like | Used for |
|----|------------|----------|
| Cloud workspace id | `0b6c2d4e-…` (UUID) | Workforce cloud APIs (`/api/v1/workspaces/<id>/…`): deployments, integrations, env, runtime credentials |
| Relaycast workspace id | `rw_…` | Relaycast messaging and fleet nodes only |

Using the relaycast id against cloud APIs returns `403 Forbidden`. If the cloud
cannot resolve a cloud workspace id for your workspace, the CLI stops with an
error naming the relaycast id instead of guessing — re-run
`agentworkforce login`, or pass `--workspace <cloud-workspace-id>`.

### Non-interactive / CI override

`WORKFORCE_WORKSPACE_ID` + `WORKFORCE_WORKSPACE_TOKEN` override the login
session when **both** are set (`--workspace` may stand in for the id):

- `WORKFORCE_WORKSPACE_ID` — the **cloud workspace id** printed by
`agentworkforce login` (not the `rw_…` relaycast id).
- `WORKFORCE_WORKSPACE_TOKEN` — a **cloud API bearer token**, e.g. the
`accessToken` from `~/.agentworkforce/relay/cloud-auth.json` after
`agentworkforce login`. This access token expires; re-run
`agentworkforce login` to refresh it.

Do **not** use the workspace `key` (`rk_live_…`) from
`~/.agentworkforce/relay/workspaces.json` as `WORKFORCE_WORKSPACE_TOKEN`: that
is a relaycast key, cloud APIs reject it with `401`, and the CLI refuses it up
front. On your own machine, prefer unsetting both variables and relying on
`agentworkforce login`.

## Discover integrations and triggers

```sh
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/deploy-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ test('runLogin uses cloud SDK auth, picks a workspace, and pins the canonical re
return new Response(JSON.stringify({
key: 'rk_live_acme',
workspaceId: 'rw_1234abcd',
cloudWorkspaceId: '0b6c2d4e-1f3a-4b5c-8d7e-9f0a1b2c3d4e',
relaycastWorkspaceId: 'rw_1234abcd',
relayfileWorkspaceId: 'rf_acme',
relayauthWorkspaceId: 'ra_acme',
Expand Down Expand Up @@ -150,6 +151,8 @@ test('runLogin uses cloud SDK auth, picks a workspace, and pins the canonical re
]);
assert.deepEqual(pinned, [{ name: 'Acme', key: 'rk_live_acme' }]);
assert.match(trap.stdout, /logged in: Acme/);
assert.match(trap.stdout, /cloud workspace id: 0b6c2d4e-1f3a-4b5c-8d7e-9f0a1b2c3d4e/);
assert.doesNotMatch(trap.stderr, /no linked cloud workspace/);
} finally {
trap.restore();
restoreDeps();
Expand Down Expand Up @@ -215,6 +218,9 @@ test('runLogin with --workspace skips the workspaces list and pins the resolved
key: 'rk_live_direct'
}]);
assert.match(trap.stdout, /logged in: 50587328-441d-4acb-b8f3-dbe1b3c5de99/);
// The resolve payload above has no cloudWorkspaceId: warn instead of
// letting later cloud calls fail with an opaque 403.
assert.match(trap.stderr, /workspace rw_5678abcd has no linked cloud workspace/);
} finally {
trap.restore();
restoreDeps();
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/deploy-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,14 @@ export async function runLogin(args: readonly string[]): Promise<void> {
const workspaceName = descriptor.name ?? descriptor.slug ?? match?.slug ?? match?.name ?? chosen;
await deployCommandDeps.setWorkspaceKey(workspaceName, descriptor.key);
process.stdout.write(`\nlogged in: ${workspaceName}\n`);
if (descriptor.cloudWorkspaceId) {
process.stdout.write(`cloud workspace id: ${descriptor.cloudWorkspaceId}\n`);
} else {
process.stderr.write(
`warn: workspace ${descriptor.relaycastWorkspaceId} has no linked cloud workspace; ` +
'deploy, deployments list and destroy will fail until it is linked. Contact support with this id.\n'
);
}
process.exit(0);
} catch (err) {
process.stderr.write(
Expand Down Expand Up @@ -546,6 +554,7 @@ type LoginWorkspace = {
type LoginWorkspaceDescriptor = {
key: string;
relaycastWorkspaceId: string;
cloudWorkspaceId?: string;
name?: string;
slug?: string;
};
Expand Down Expand Up @@ -593,9 +602,11 @@ async function resolveWorkspaceForLogin(
if (!key || !relaycastWorkspaceId) {
throw new Error('workspace resolve returned an incomplete descriptor');
}
const cloudWorkspaceId = readString(record, 'cloudWorkspaceId');
return {
key,
relaycastWorkspaceId,
...(cloudWorkspaceId ? { cloudWorkspaceId } : {}),
...(readString(record, 'name') ? { name: readString(record, 'name') } : {}),
...(readString(record, 'slug') ? { slug: readString(record, 'slug') } : {})
};
Expand Down
93 changes: 93 additions & 0 deletions packages/cli/src/local-surface-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,99 @@ test('runLocalSurface resolves the deployed persona UUID, reuses a persisted enr
}
});

test('runLocalSurface uses the cloud workspace id for cloud APIs and the relaycast id for fleet enrollment', async () => {
const enrollmentLookups: unknown[] = [];
const deploymentWorkspaces: string[] = [];
let capturedLocalSurfaceBody: unknown;
const { writes, restore } = withMockedDeps({
resolveWorkspaceToken: (async () => ({
token: 'tok_workspace',
workspace: '0b6c2d4e-1f3a-4b5c-8d7e-9f0a1b2c3d4e',
relaycastWorkspaceId: 'rw_1234abcd'
})) as never,
fetchDeployments: (async (args: { workspace: string }) => {
deploymentWorkspaces.push(args.workspace);
return [deployedAgent()];
}) as never,
resolveActiveFleetNodeEnrollment: ((input: unknown) => {
enrollmentLookups.push(input);
return {
nodeId: 'node_1',
nodeName: 'my-laptop',
nodeToken: 'nt_live_abc',
relayWorkspaceId: 'rw_1234abcd',
relaycastUrl: 'https://relaycast.example.com',
websocketUrl: 'wss://relaycast.example.com/v1/node/ws',
enrolledAt: '2026-07-01T00:00:00.000Z'
};
}) as never,
fetch: (async (_url: string, init: { body?: string }) => {
capturedLocalSurfaceBody = JSON.parse(init.body ?? '{}');
return fakeResponse({ ok: true, json: { channel: 'local-surface-demo-persona' } });
}) as never
});

try {
process.exitCode = undefined;
await runLocalSurface(['/personas/demo.json']);
assert.equal(process.exitCode, 0);
assert.deepEqual(deploymentWorkspaces, ['0b6c2d4e-1f3a-4b5c-8d7e-9f0a1b2c3d4e']);
assert.deepEqual(enrollmentLookups, [{ workspaceId: 'rw_1234abcd' }]);
assert.deepEqual(capturedLocalSurfaceBody, {
workspaceId: '0b6c2d4e-1f3a-4b5c-8d7e-9f0a1b2c3d4e',
personaId: 'persona-uuid-1'
});
assert.ok(writes[0]!.contents.includes('"0b6c2d4e-1f3a-4b5c-8d7e-9f0a1b2c3d4e"'));
} finally {
restore();
process.exitCode = undefined;
}
});

test('runLocalSurface resolves the relaycast id for env-override auth before the fleet enrollment lookup', async () => {
const enrollmentLookups: unknown[] = [];
const requests: Array<{ url: string; method?: string }> = [];
const { restore } = withMockedDeps({
// WORKFORCE_WORKSPACE_ID + WORKFORCE_WORKSPACE_TOKEN: no relaycast id.
resolveWorkspaceToken: (async () => ({
token: 'tok_workspace',
workspace: '0b6c2d4e-1f3a-4b5c-8d7e-9f0a1b2c3d4e',
authSource: 'env'
})) as never,
resolveActiveFleetNodeEnrollment: ((input: unknown) => {
enrollmentLookups.push(input);
return {
nodeId: 'node_1',
nodeName: 'my-laptop',
nodeToken: 'nt_live_abc',
relayWorkspaceId: 'rw_1234abcd',
relaycastUrl: 'https://relaycast.example.com',
websocketUrl: 'wss://relaycast.example.com/v1/node/ws',
enrolledAt: '2026-07-01T00:00:00.000Z'
};
}) as never,
fetch: (async (url: string, init: { method?: string }) => {
requests.push({ url, method: init.method });
if (url.endsWith('/resolve')) {
return fakeResponse({ ok: true, json: { relaycastWorkspaceId: 'rw_1234abcd' } });
}
return fakeResponse({ ok: true, json: { channel: 'local-surface-demo-persona' } });
}) as never
});

try {
process.exitCode = undefined;
await runLocalSurface(['/personas/demo.json']);
assert.equal(process.exitCode, 0);
assert.ok(requests.some((r) =>
r.url.endsWith('/api/v1/workspaces/0b6c2d4e-1f3a-4b5c-8d7e-9f0a1b2c3d4e/resolve') && r.method === 'GET'));
assert.deepEqual(enrollmentLookups, [{ workspaceId: 'rw_1234abcd' }]);
} finally {
restore();
process.exitCode = undefined;
}
});

test('runLocalSurface fails loud (does not proceed) when the persona has no active cloud deployment', async () => {
const { errors, logs, restore } = withMockedDeps({
fetchDeployments: (async () => []) as never
Expand Down
41 changes: 40 additions & 1 deletion packages/cli/src/local-surface-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,15 @@ async function runLocalSurfaceWithOptions(opts: LocalSurfaceOptions): Promise<vo
personaSlug: preflight.persona.id
});

const enrollment = await resolveOrRedeemEnrollment({ workspace, opts, cloudUrl });
// Fleet node enrollments are keyed by the relaycast workspace id; `workspace`
// is the cloud workspace id used for cloud API paths.
const relaycastWorkspaceId = auth.relaycastWorkspaceId?.trim()
|| await resolveRelaycastWorkspaceId({ cloudUrl, token, workspace });
const enrollment = await resolveOrRedeemEnrollment({
workspace: relaycastWorkspaceId,
opts,
cloudUrl
});
Comment thread
khaliqgant marked this conversation as resolved.
deps.log(`local-surface: fleet node "${enrollment.nodeName}" (${enrollment.relaycastUrl})`);

const localSurface = await callLocalSurfaceApi({ cloudUrl, token, workspace, personaId: personaUuid });
Expand Down Expand Up @@ -399,6 +407,37 @@ async function resolveOrRedeemEnrollment(input: {
return record;
}

/**
* Env-override auth (WORKFORCE_WORKSPACE_ID + WORKFORCE_WORKSPACE_TOKEN) only
* carries the cloud workspace id, so ask the cloud for the matching relaycast
* id. Falls back to `workspace` when it cannot be resolved (e.g. the env id
* already is the relaycast id, or an older cloud).
*/
async function resolveRelaycastWorkspaceId(input: {
cloudUrl: string;
token: string;
workspace: string;
}): Promise<string> {
const url = `${input.cloudUrl.replace(/\/+$/, '')}/api/v1/workspaces/${encodeURIComponent(input.workspace)}/resolve`;
try {
const response = await deps.fetch(url, {
method: 'GET',
headers: {
authorization: `Bearer ${input.token}`,
'user-agent': 'workforce-local-surface'
}
});
if (!response.ok) return input.workspace;
const body = (await response.json().catch(() => null)) as { relaycastWorkspaceId?: unknown } | null;
const relaycastWorkspaceId = body?.relaycastWorkspaceId;
return typeof relaycastWorkspaceId === 'string' && relaycastWorkspaceId.trim()
? relaycastWorkspaceId.trim()
: input.workspace;
} catch {
return input.workspace;
}
}

async function callLocalSurfaceApi(input: {
cloudUrl: string;
token: string;
Expand Down
Loading
Loading