Skip to content

Commit 6a22f13

Browse files
khaliqgantRicky Schema Cascadeclaude
authored
fix(cli): reuse @agent-relay/cloud auth as Bearer; drop workspace-token mint (#113)
* fix(cli): short-circuit workspace-list when --workspace is set; clearer 403 hint When agentworkforce login is invoked with --workspace, listWorkspacesForLogin should not be called — but the previous flow always listed first and only short-circuited the picker. That meant users hitting 403 on /api/v1/workspaces could not log in at all, even with the workspace id in hand. This PR: - Short-circuits listWorkspacesForLogin when opts.workspace is provided - Surfaces a clearer error when the list 403s, pointing at --workspace - Surfaces a clearer error when the list is empty (no workspaces yet) - Adds tests covering all three paths Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): reuse @agent-relay/cloud auth as Bearer; drop workspace-token mint agentworkforce login was hitting POST /api/v1/workspaces/{id}/tokens/workspace (and two fallback paths) to mint a workspace-scoped token. Cloud does not implement any of those routes -- all three 404, blocking login for every user who already has a valid agent-relay cloud login. Cloud's resolveRequestAuth already accepts the user's accessToken from ~/.agent-relay/cloud-auth.json as Authorization: Bearer. The mint step exists for CI/service-account use, not interactive CLI use, but currently blocks BOTH because cloud has not shipped the route. This PR: - Drops issueWorkspaceToken from runLogin. - Persists a lightweight ~/.agentworkforce/active.json pointer (workspaceId + workspaceSlug + cloudUrl) at login time. - resolveWorkspaceToken reads active.json + @agent-relay/cloud's shared auth and returns auth.accessToken as the Bearer. Refreshes the accessToken via refreshStoredAuth when expired. - Preserves the WORKFORCE_WORKSPACE_TOKEN env fallback (CI) and the legacy keychain-stored workspace token path (back-compat for users mid-upgrade). - runLogout always clears active.json; --cloud-auth/--all still clears the shared agent-relay auth as before. Layered on top of workforce#112 (--workspace short-circuit) so login works end-to-end with just a known workspace id. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Ricky Schema Cascade <ricky@agent-relay.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 25945b7 commit 6a22f13

5 files changed

Lines changed: 614 additions & 85 deletions

File tree

packages/cli/src/deploy-command.test.ts

Lines changed: 141 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ function trapExit(throwOnExit = true): ExitTrap {
5454
return trap;
5555
}
5656

57-
test('runLogin uses cloud SDK auth, mints a workspace token, and stores it', async () => {
57+
test('runLogin uses cloud SDK auth, picks a workspace, and writes the active pointer (no token mint)', async () => {
5858
const calls: string[] = [];
5959
const writes: unknown[] = [];
6060
const restoreDeps = configureDeployCommandForTest({
@@ -79,12 +79,8 @@ test('runLogin uses cloud SDK auth, mints a workspace token, and stores it', asy
7979
}
8080
};
8181
},
82-
issueWorkspaceToken: async (workspace: string, options: { apiUrl?: string; name?: string } = {}) => {
83-
calls.push(`issue:${workspace}:${options.apiUrl}:${options.name}`);
84-
return { key: 'tok-ws', workspaceToken: { workspaceId: 'ws-1', kind: 'workspace_token' } };
85-
},
86-
writeStoredWorkspaceToken: async (login: unknown) => {
87-
writes.push(login);
82+
writeActiveWorkspace: async (pointer: unknown) => {
83+
writes.push(pointer);
8884
}
8985
});
9086
const trap = trapExit(false);
@@ -93,14 +89,12 @@ test('runLogin uses cloud SDK auth, mints a workspace token, and stores it', asy
9389
assert.deepEqual(trap.exits, [0]);
9490
assert.deepEqual(calls, [
9591
'ensure:https://cloud.example.test',
96-
'fetch:/api/v1/workspaces',
97-
'issue:acme:https://cloud.example.test:agentworkforce-cli'
92+
'fetch:/api/v1/workspaces'
9893
]);
9994
assert.deepEqual(writes, [{
10095
workspace: 'acme',
10196
workspaceSlug: 'acme',
10297
workspaceId: 'ws-1',
103-
token: 'tok-ws',
10498
cloudUrl: 'https://cloud.example.test'
10599
}]);
106100
assert.match(trap.stdout, /logged in: acme/);
@@ -110,12 +104,137 @@ test('runLogin uses cloud SDK auth, mints a workspace token, and stores it', asy
110104
}
111105
});
112106

113-
test('runLogout preserves shared cloud auth and clears only the workspace token by default', async () => {
107+
test('runLogin with --workspace skips the workspaces list, skips token mint, writes active pointer', async () => {
108+
const calls: string[] = [];
109+
const writes: unknown[] = [];
110+
const restoreDeps = configureDeployCommandForTest({
111+
createTerminalIO: () => createBufferedIO(),
112+
ensureAuthenticated: async (apiUrl: string) => {
113+
calls.push(`ensure:${apiUrl}`);
114+
return {
115+
apiUrl,
116+
accessToken: 'access',
117+
refreshToken: 'refresh',
118+
accessTokenExpiresAt: '2999-01-01T00:00:00.000Z'
119+
};
120+
},
121+
createCloudApiClient() {
122+
calls.push('createCloudApiClient');
123+
return {
124+
async fetch(pathname: string) {
125+
calls.push(`fetch:${pathname}`);
126+
return new Response('should not be called', { status: 500 });
127+
}
128+
};
129+
},
130+
writeActiveWorkspace: async (pointer: unknown) => {
131+
writes.push(pointer);
132+
}
133+
});
134+
const trap = trapExit(false);
135+
try {
136+
await runLogin([
137+
'--cloud-url',
138+
'https://cloud.example.test/',
139+
'--workspace',
140+
'50587328-441d-4acb-b8f3-dbe1b3c5de99'
141+
]);
142+
assert.deepEqual(trap.exits, [0]);
143+
assert.ok(
144+
!calls.some((c) => c === 'createCloudApiClient' || c.startsWith('fetch:')),
145+
`expected workspace-list to be skipped, got calls: ${JSON.stringify(calls)}`
146+
);
147+
assert.deepEqual(calls, ['ensure:https://cloud.example.test']);
148+
assert.deepEqual(writes, [{
149+
workspace: '50587328-441d-4acb-b8f3-dbe1b3c5de99',
150+
cloudUrl: 'https://cloud.example.test'
151+
}]);
152+
assert.match(trap.stdout, /logged in: 50587328-441d-4acb-b8f3-dbe1b3c5de99/);
153+
} finally {
154+
trap.restore();
155+
restoreDeps();
156+
}
157+
});
158+
159+
test('runLogin without --workspace surfaces a --workspace hint when the workspaces list returns 403', async () => {
160+
const restoreDeps = configureDeployCommandForTest({
161+
createTerminalIO: () => createBufferedIO(),
162+
ensureAuthenticated: async (apiUrl: string) => ({
163+
apiUrl,
164+
accessToken: 'access',
165+
refreshToken: 'refresh',
166+
accessTokenExpiresAt: '2999-01-01T00:00:00.000Z'
167+
}),
168+
createCloudApiClient() {
169+
return {
170+
async fetch(_pathname: string) {
171+
return new Response(JSON.stringify({ error: 'Forbidden' }), {
172+
status: 403,
173+
headers: { 'content-type': 'application/json' }
174+
});
175+
}
176+
};
177+
},
178+
writeActiveWorkspace: async () => {
179+
throw new Error('writeActiveWorkspace should not be called when listing fails');
180+
}
181+
});
182+
const trap = trapExit(false);
183+
try {
184+
await runLogin(['--cloud-url', 'https://cloud.example.test/']);
185+
assert.deepEqual(trap.exits, [1]);
186+
assert.match(trap.stderr, /workspace list returned 403 Forbidden/);
187+
assert.match(trap.stderr, /Pass --workspace <id-or-slug> to skip listing/);
188+
} finally {
189+
trap.restore();
190+
restoreDeps();
191+
}
192+
});
193+
194+
test('runLogin without --workspace surfaces a no-workspaces message when the list comes back empty', async () => {
195+
const restoreDeps = configureDeployCommandForTest({
196+
createTerminalIO: () => createBufferedIO(),
197+
ensureAuthenticated: async (apiUrl: string) => ({
198+
apiUrl,
199+
accessToken: 'access',
200+
refreshToken: 'refresh',
201+
accessTokenExpiresAt: '2999-01-01T00:00:00.000Z'
202+
}),
203+
createCloudApiClient() {
204+
return {
205+
async fetch(_pathname: string) {
206+
return new Response(JSON.stringify({ workspaces: [] }), {
207+
status: 200,
208+
headers: { 'content-type': 'application/json' }
209+
});
210+
}
211+
};
212+
},
213+
writeActiveWorkspace: async () => {
214+
throw new Error('writeActiveWorkspace should not be called when no workspaces');
215+
}
216+
});
217+
const trap = trapExit(false);
218+
try {
219+
await runLogin(['--cloud-url', 'https://cloud.example.test/']);
220+
assert.deepEqual(trap.exits, [1]);
221+
assert.match(trap.stderr, /no workspaces are accessible from this account/);
222+
assert.match(trap.stderr, /pass --workspace <id-or-slug>/);
223+
} finally {
224+
trap.restore();
225+
restoreDeps();
226+
}
227+
});
228+
229+
test('runLogout preserves shared cloud auth and clears the active pointer + legacy keychain token by default', async () => {
114230
const calls: string[] = [];
115231
const restoreDeps = configureDeployCommandForTest({
116232
clearStoredAuth: async () => {
117233
calls.push('clear-auth');
118234
},
235+
clearActiveWorkspace: async () => {
236+
calls.push('clear-active');
237+
},
119238
clearStoredWorkspaceToken: async (workspace?: string) => {
120239
calls.push(`clear-workspace:${workspace ?? ''}`);
121240
}
@@ -124,20 +243,23 @@ test('runLogout preserves shared cloud auth and clears only the workspace token
124243
try {
125244
await runLogout(['--workspace', 'acme']);
126245
assert.deepEqual(trap.exits, [0]);
127-
assert.deepEqual(calls, ['clear-workspace:acme']);
246+
assert.deepEqual(calls, ['clear-active', 'clear-workspace:acme']);
128247
assert.match(trap.stdout, /workspace login cleared/);
129248
} finally {
130249
trap.restore();
131250
restoreDeps();
132251
}
133252
});
134253

135-
test('runLogout clears shared cloud auth when explicitly requested', async () => {
254+
test('runLogout clears shared cloud auth + active pointer when --cloud-auth is passed', async () => {
136255
const calls: string[] = [];
137256
const restoreDeps = configureDeployCommandForTest({
138257
clearStoredAuth: async () => {
139258
calls.push('clear-auth');
140259
},
260+
clearActiveWorkspace: async () => {
261+
calls.push('clear-active');
262+
},
141263
clearStoredWorkspaceToken: async (workspace?: string) => {
142264
calls.push(`clear-workspace:${workspace ?? ''}`);
143265
}
@@ -146,20 +268,23 @@ test('runLogout clears shared cloud auth when explicitly requested', async () =>
146268
try {
147269
await runLogout(['--workspace', 'acme', '--cloud-auth']);
148270
assert.deepEqual(trap.exits, [0]);
149-
assert.deepEqual(calls, ['clear-auth', 'clear-workspace:acme']);
271+
assert.deepEqual(calls, ['clear-auth', 'clear-active', 'clear-workspace:acme']);
150272
assert.match(trap.stdout, /logged out/);
151273
} finally {
152274
trap.restore();
153275
restoreDeps();
154276
}
155277
});
156278

157-
test('runLogout treats --all as an alias for clearing shared cloud auth', async () => {
279+
test('runLogout treats --all as an alias for clearing shared cloud auth + active pointer', async () => {
158280
const calls: string[] = [];
159281
const restoreDeps = configureDeployCommandForTest({
160282
clearStoredAuth: async () => {
161283
calls.push('clear-auth');
162284
},
285+
clearActiveWorkspace: async () => {
286+
calls.push('clear-active');
287+
},
163288
clearStoredWorkspaceToken: async (workspace?: string) => {
164289
calls.push(`clear-workspace:${workspace ?? ''}`);
165290
}
@@ -168,7 +293,7 @@ test('runLogout treats --all as an alias for clearing shared cloud auth', async
168293
try {
169294
await runLogout(['--all']);
170295
assert.deepEqual(trap.exits, [0]);
171-
assert.deepEqual(calls, ['clear-auth', 'clear-workspace:']);
296+
assert.deepEqual(calls, ['clear-auth', 'clear-active', 'clear-workspace:']);
172297
assert.match(trap.stdout, /logged out/);
173298
} finally {
174299
trap.restore();

0 commit comments

Comments
 (0)