Skip to content

Commit a0610cc

Browse files
khaliqgantRicky Schema Cascadeclaude
authored
fix(cli): canonicalize cloud URL and rewrite agent-relay error hints (#117)
The customer hit two stacked CLI bugs in one deploy: - ensureAuthenticated occasionally returns auth.apiUrl pointing at the SST edge-bypass hostname (origin.agentrelay.cloud). Persisting that into active.json sends every subsequent API call cross-subdomain, where session cookies and Bearer tokens don't validate, so every call 401s. - The 401 hint then told `agentworkforce` users to run `agent-relay cloud whoami` / `agent-relay cloud login`, which they don't have. Add canonicalizeCloudUrl() and apply it at every CLI write/consume site for the cloud URL (login --cloud-url, ensureAuthenticated result, writeActiveWorkspace, resolveWorkspaceToken). Known-bypass hostnames (origin.*, *.agentrelay.cloud) remap to https://agentrelay.com/cloud; localhost and unrelated tenants pass through untouched. Rewrite the 401 / 403 error strings in relayfileIntegrationResolver so they point at `agentworkforce login` instead of `agent-relay cloud whoami`, and drop the hardcoded origin.agentrelay.cloud URL from the hint text. Same sweep across help / usage strings. Tests cover the canonicalization table, the login-time canonicalization end-to-end (origin.* apiUrl -> public canonical written), and a relayfileIntegrationResolver 401 surfacing the new agentworkforce-native hint (regex match so future copy-edits don't break it). Co-authored-by: Ricky Schema Cascade <ricky@agent-relay.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 8d98804 commit a0610cc

8 files changed

Lines changed: 213 additions & 27 deletions

File tree

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,3 +335,43 @@ test('parseDeployArgs: malformed --input exits with clean error', () => {
335335
trap.restore();
336336
}
337337
});
338+
339+
test('runLogin canonicalizes origin.agentrelay.cloud apiUrl before persisting active.json', async () => {
340+
// ensureAuthenticated occasionally returns auth.apiUrl pointing at the
341+
// SST origin-bypass hostname. If we persist that, every subsequent API
342+
// call 401s because session cookies don't cross subdomains. The CLI
343+
// must canonicalize before writing.
344+
const writes: Array<{ cloudUrl?: string }> = [];
345+
const restoreDeps = configureDeployCommandForTest({
346+
createTerminalIO: () => createBufferedIO(),
347+
ensureAuthenticated: async () => ({
348+
apiUrl: 'https://origin.agentrelay.cloud',
349+
accessToken: 'access',
350+
refreshToken: 'refresh',
351+
accessTokenExpiresAt: '2999-01-01T00:00:00.000Z'
352+
}),
353+
createCloudApiClient() {
354+
return {
355+
async fetch(_pathname: string) {
356+
return new Response(JSON.stringify({ workspaces: [{ id: 'ws-1', slug: 'acme' }] }), {
357+
status: 200,
358+
headers: { 'content-type': 'application/json' }
359+
});
360+
}
361+
};
362+
},
363+
writeActiveWorkspace: async (pointer: { cloudUrl?: string }) => {
364+
writes.push(pointer);
365+
}
366+
});
367+
const trap = trapExit(false);
368+
try {
369+
await runLogin(['--cloud-url', 'https://agentrelay.com/cloud']);
370+
assert.deepEqual(trap.exits, [0]);
371+
assert.equal(writes.length, 1);
372+
assert.equal(writes[0].cloudUrl, 'https://agentrelay.com/cloud');
373+
} finally {
374+
trap.restore();
375+
restoreDeps();
376+
}
377+
});

packages/cli/src/deploy-command.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
type StoredAuth
88
} from '@agent-relay/cloud';
99
import {
10+
canonicalizeCloudUrl,
1011
clearActiveWorkspace,
1112
clearStoredWorkspaceToken,
1213
createTerminalIO,
@@ -127,13 +128,18 @@ export async function runLogin(args: readonly string[]): Promise<void> {
127128

128129
const opts = parseLoginArgs(args);
129130
const io = deployCommandDeps.createTerminalIO();
130-
const cloudUrl = normalizeCloudUrl(
131+
const cloudUrl = canonicalizeCloudUrl(normalizeCloudUrl(
131132
opts.cloudUrl ?? process.env.WORKFORCE_DEPLOY_CLOUD_URL ?? process.env.WORKFORCE_CLOUD_URL ?? defaultApiUrl()
132-
);
133+
));
133134

134135
try {
135136
const auth = await deployCommandDeps.ensureAuthenticated(cloudUrl);
136-
const apiUrl = normalizeCloudUrl(auth.apiUrl || cloudUrl);
137+
// Canonicalize what ensureAuthenticated handed back — when the auth
138+
// request happens to route through cloud's edge-bypass hostname,
139+
// auth.apiUrl can be `https://origin.agentrelay.cloud` even though
140+
// the user's session cookies are scoped to `agentrelay.com`. Storing
141+
// that URL is what causes every subsequent API call to 401.
142+
const apiUrl = canonicalizeCloudUrl(normalizeCloudUrl(auth.apiUrl || cloudUrl));
137143
let workspaces: LoginWorkspace[] = [];
138144
let chosen: string;
139145
if (opts.workspace) {
@@ -142,7 +148,7 @@ export async function runLogin(args: readonly string[]): Promise<void> {
142148
workspaces = await listWorkspacesForLogin(auth, apiUrl);
143149
if (workspaces.length === 0) {
144150
throw new Error(
145-
'no workspaces are accessible from this account. Create one at https://agentrelay.cloud, '
151+
'no workspaces are accessible from this account. Create one at https://agentrelay.com/cloud, '
146152
+ 'or pass --workspace <id-or-slug> if you already know the workspace identifier.'
147153
);
148154
}
@@ -218,12 +224,9 @@ Flags:
218224

219225
const LOGIN_USAGE = `usage: agentworkforce login [flags]
220226
221-
Connect this machine to a workforce workspace. Reuses the shared
222-
Agent Relay Cloud login (\`~/.agent-relay/cloud-auth.json\`) for the bearer
223-
credential and stores a small pointer at \`~/.agentworkforce/active.json\`
224-
recording which workspace this machine targets. No separate workspace-scoped
225-
token is minted; cloud accepts the shared accessToken as Authorization: Bearer
226-
for deploy endpoints.
227+
Connect this machine to a workforce workspace. Opens the browser to sign in
228+
to the workforce cloud and stores a small pointer at
229+
\`~/.agentworkforce/active.json\` recording which workspace this machine targets.
227230
228231
Flags:
229232
--workspace <name> Workforce workspace; defaults to WORKFORCE_WORKSPACE_ID or prompt
@@ -235,12 +238,12 @@ Flags:
235238

236239
const LOGOUT_USAGE = `usage: agentworkforce logout [flags]
237240
238-
Clear the stored workforce workspace token. Agent Relay Cloud browser auth is
239-
shared with agent-relay and is preserved unless --cloud-auth is passed.
241+
Clear the stored workforce workspace pointer. The shared cloud browser auth
242+
is preserved unless --cloud-auth is passed.
240243
241244
Flags:
242245
--workspace <name> Optional workspace token entry to clear
243-
--cloud-auth Also clear the shared Agent Relay Cloud login
246+
--cloud-auth Also clear the shared cloud login
244247
--all Alias for --cloud-auth
245248
-h, --help Print this message
246249
`;
@@ -451,7 +454,7 @@ async function listWorkspacesForLogin(auth: StoredAuth, apiUrl: string): Promise
451454
if (res.status === 403) {
452455
throw new Error(
453456
'workspace list returned 403 Forbidden. Pass --workspace <id-or-slug> to skip listing, '
454-
+ 'or check that your account has access to a workspace at https://agentrelay.cloud.'
457+
+ 'or check that your account has access to a workspace at https://agentrelay.com/cloud.'
455458
);
456459
}
457460
if (res.status !== 404 && res.status !== 405) {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { canonicalizeCloudUrl } from './cloud-url.js';
4+
5+
test('canonicalizeCloudUrl: origin.agentrelay.cloud bare host → public canonical', () => {
6+
assert.equal(
7+
canonicalizeCloudUrl('https://origin.agentrelay.cloud'),
8+
'https://agentrelay.com/cloud'
9+
);
10+
});
11+
12+
test('canonicalizeCloudUrl: origin.agentrelay.cloud/cloud → public canonical', () => {
13+
assert.equal(
14+
canonicalizeCloudUrl('https://origin.agentrelay.cloud/cloud'),
15+
'https://agentrelay.com/cloud'
16+
);
17+
});
18+
19+
test('canonicalizeCloudUrl: staging.agentrelay.cloud → public canonical', () => {
20+
assert.equal(
21+
canonicalizeCloudUrl('https://staging.agentrelay.cloud'),
22+
'https://agentrelay.com/cloud'
23+
);
24+
});
25+
26+
test('canonicalizeCloudUrl: bare agentrelay.cloud/cloud → public canonical', () => {
27+
assert.equal(
28+
canonicalizeCloudUrl('https://agentrelay.cloud/cloud'),
29+
'https://agentrelay.com/cloud'
30+
);
31+
});
32+
33+
test('canonicalizeCloudUrl: public canonical is idempotent', () => {
34+
assert.equal(
35+
canonicalizeCloudUrl('https://agentrelay.com/cloud'),
36+
'https://agentrelay.com/cloud'
37+
);
38+
});
39+
40+
test('canonicalizeCloudUrl: trailing slash is stripped on canonical input', () => {
41+
assert.equal(
42+
canonicalizeCloudUrl('https://agentrelay.com/cloud/'),
43+
'https://agentrelay.com/cloud'
44+
);
45+
});
46+
47+
test('canonicalizeCloudUrl: localhost dev URLs are left untouched', () => {
48+
assert.equal(
49+
canonicalizeCloudUrl('http://localhost:3000'),
50+
'http://localhost:3000'
51+
);
52+
});
53+
54+
test('canonicalizeCloudUrl: unrelated tenant URLs are left untouched', () => {
55+
assert.equal(
56+
canonicalizeCloudUrl('https://some-other-tenant.example.com'),
57+
'https://some-other-tenant.example.com'
58+
);
59+
});
60+
61+
test('canonicalizeCloudUrl: empty input returns empty string', () => {
62+
assert.equal(canonicalizeCloudUrl(''), '');
63+
});
64+
65+
test('canonicalizeCloudUrl: unparseable input is returned untouched (trimmed)', () => {
66+
assert.equal(canonicalizeCloudUrl(' not-a-url '), 'not-a-url');
67+
});

packages/deploy/src/cloud-url.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Canonicalize the workforce cloud URL to the public host the user logged
3+
* into, regardless of which edge / origin-bypass URL the auth response
4+
* happened to come from.
5+
*
6+
* Why this exists: cloud's auth-result handler currently echoes
7+
* `request.url` back as `apiUrl`, so when the auth request happens to
8+
* route through the SST/Cloudflare origin-bypass (`origin.agentrelay.cloud`)
9+
* the CLI ends up persisting that hostname and sending every subsequent
10+
* API call to it. Session cookies and Bearer tokens don't validate
11+
* cross-subdomain, so every call 401s.
12+
*
13+
* This is a CLI-side mitigation; the proper structural fix is cloud-side
14+
* (the handler should emit a configured public URL, never `request.url`).
15+
*
16+
* Rules:
17+
* - Map known-bypass hostnames (`origin.agentrelay.cloud`,
18+
* `*.agentrelay.cloud`) → canonical `https://agentrelay.com/cloud`.
19+
* - Leave other hostnames untouched (dev `localhost:*`, custom tenants,
20+
* etc.) — only the cloud-bypass family is remapped.
21+
* - Strip a trailing slash so equality comparisons in the rest of the
22+
* deploy code stay stable.
23+
*/
24+
export function canonicalizeCloudUrl(input: string): string {
25+
const trimmed = input.trim();
26+
if (!trimmed) return trimmed;
27+
let url: URL;
28+
try {
29+
url = new URL(trimmed);
30+
} catch {
31+
// If it doesn't parse as a URL we don't know how to remap it; return
32+
// the original (trimmed) string so the caller can choose to error
33+
// downstream.
34+
return trimmed;
35+
}
36+
const host = url.hostname.toLowerCase();
37+
if (host === 'agentrelay.cloud' || host.endsWith('.agentrelay.cloud')) {
38+
return 'https://agentrelay.com/cloud';
39+
}
40+
return stripTrailingSlash(url.toString());
41+
}
42+
43+
function stripTrailingSlash(value: string): string {
44+
return value.replace(/\/+$/, '');
45+
}

packages/deploy/src/connect.test.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ test('connectIntegrations fails fast on auth errors without prompting to connect
124124
integrations: {
125125
async isConnected() {
126126
throw new Error(
127-
'cloud integration request failed: unauthorized. Open https://origin.agentrelay.cloud/cloud to verify your cloud session, then run `agent-relay cloud whoami` and `agentworkforce login` to refresh the active workspace.'
127+
'cloud integration request failed: unauthorized. Your active workspace session is invalid or expired. Run `agentworkforce login --workspace <id-or-slug>` to refresh, then retry.'
128128
);
129129
},
130130
async connect() {
@@ -136,18 +136,39 @@ test('connectIntegrations fails fast on auth errors without prompting to connect
136136

137137
assert.equal(confirmCalled, false);
138138
assert.equal(connectCalled, false);
139-
assert.deepEqual(result.outcomes, [
140-
{
141-
provider: 'notion',
142-
status: 'failed',
143-
message:
144-
'cloud integration request failed: unauthorized. Open https://origin.agentrelay.cloud/cloud to verify your cloud session, then run `agent-relay cloud whoami` and `agentworkforce login` to refresh the active workspace.'
145-
}
146-
]);
139+
assert.equal(result.outcomes.length, 1);
140+
const [outcome] = result.outcomes;
141+
assert.equal(outcome.provider, 'notion');
142+
assert.equal(outcome.status, 'failed');
143+
// Future-proofed against copy-edits: the message must point users at the
144+
// workforce CLI's own login and must NOT instruct them to reach for the
145+
// upstream `agent-relay` binary.
146+
assert.match(outcome.message ?? '', /agentworkforce login/i);
147+
assert.doesNotMatch(outcome.message ?? '', /agent-relay cloud/);
147148
assert.ok(io.messages.some((message) => message.level === 'warn' && message.message.includes('failed to check connection status for notion')));
148149
assert.ok(io.messages.some((message) => message.level === 'error' && message.message.includes('auth failed')));
149150
});
150151

152+
test('relayfileIntegrationResolver surfaces the agentworkforce-native error on 401', async () => {
153+
const resolver = relayfileIntegrationResolver({
154+
apiUrl: 'https://cloud.example.test',
155+
workspaceId: 'ws-1',
156+
workspaceToken: 'tok',
157+
fetch: async () => new Response('Unauthorized', { status: 401 })
158+
});
159+
await assert.rejects(
160+
resolver.isConnected({ workspace: 'ws-1', provider: 'notion' }),
161+
(err: unknown) => {
162+
const message = err instanceof Error ? err.message : String(err);
163+
assert.match(message, /unauthorized/i);
164+
assert.match(message, /agentworkforce login/i);
165+
assert.doesNotMatch(message, /agent-relay cloud/);
166+
assert.doesNotMatch(message, /origin\.agentrelay\.cloud/);
167+
return true;
168+
}
169+
);
170+
});
171+
151172
test('connectIntegrations honors --no-prompt for subscription provider setup', async () => {
152173
const io = createBufferedIO();
153174
let confirmCalled = false;

packages/deploy/src/connect.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,12 +318,12 @@ async function requestJson(
318318
});
319319
if (res.status === 401) {
320320
throw new Error(
321-
'cloud integration request failed: unauthorized. Open https://origin.agentrelay.cloud/cloud to verify your cloud session, then run `agent-relay cloud whoami` and `agentworkforce login` to refresh the active workspace.'
321+
'cloud integration request failed: unauthorized. Your active workspace session is invalid or expired. Run `agentworkforce login --workspace <id-or-slug>` to refresh, then retry.'
322322
);
323323
}
324324
if (res.status === 403) {
325325
throw new Error(
326-
'cloud integration request failed: forbidden. The active account is not authorized for this workspace; open https://origin.agentrelay.cloud/cloud to verify account/workspace access, then run `agent-relay cloud whoami` and `agentworkforce login` to refresh the active workspace.'
326+
'cloud integration request failed: forbidden. The active account is not authorized for this workspace. Run `agentworkforce login --workspace <id-or-slug>` against an account with access, then retry.'
327327
);
328328
}
329329
if (!res.ok) {

packages/deploy/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export {
4242
type WorkspaceAuth,
4343
type WorkspaceAuthToken
4444
} from './login.js';
45+
export { canonicalizeCloudUrl } from './cloud-url.js';
4546
export { createTerminalIO, createBufferedIO, type BufferedIO } from './io.js';
4647
export { bundleStager } from './bundle.js';
4748
export { devLauncher } from './modes/dev.js';

packages/deploy/src/login.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
writeStoredAuth,
88
type StoredAuth
99
} from '@agent-relay/cloud';
10+
import { canonicalizeCloudUrl } from './cloud-url.js';
1011
import type { DeployIO } from './types.js';
1112

1213
/**
@@ -116,11 +117,15 @@ export async function writeActiveWorkspace(
116117
): Promise<void> {
117118
const file = activeWorkspaceFile();
118119
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
120+
// Canonicalize at write time so we never persist an edge / origin-bypass
121+
// hostname (e.g. origin.agentrelay.cloud) into active.json. Downstream
122+
// readers can trust the stored value and skip canonicalization.
123+
const cloudUrl = input.cloudUrl ? canonicalizeCloudUrl(input.cloudUrl) : undefined;
119124
const payload: ActiveWorkspacePointer = {
120125
workspace: input.workspace,
121126
...(input.workspaceSlug ? { workspaceSlug: input.workspaceSlug } : {}),
122127
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
123-
...(input.cloudUrl ? { cloudUrl: input.cloudUrl } : {}),
128+
...(cloudUrl ? { cloudUrl } : {}),
124129
setAt: new Date().toISOString()
125130
};
126131
await writeFile(file, `${JSON.stringify(payload, null, 2)}\n`, {
@@ -194,6 +199,10 @@ export async function resolveWorkspaceToken(args: {
194199
io: DeployIO;
195200
noPrompt?: boolean;
196201
}): Promise<WorkspaceAuthToken & { workspace?: string }> {
202+
// Defensively canonicalize the incoming cloud URL so any per-call
203+
// matching (e.g. cloudUrlMatches in loadWorkspaceToken) compares against
204+
// the public canonical host rather than an origin-bypass hostname.
205+
const cloudUrl = canonicalizeCloudUrl(args.cloudUrl);
197206
const envWorkspace = (process.env.WORKFORCE_WORKSPACE_ID ?? '').trim();
198207
const fromEnv = (process.env.WORKFORCE_WORKSPACE_TOKEN ?? '').trim();
199208
const requestedWorkspace = (args.workspace ?? '').trim();
@@ -228,7 +237,7 @@ export async function resolveWorkspaceToken(args: {
228237
// Tier 3: legacy keychain / file-stored workspace token. Kept for users
229238
// mid-upgrade who already have a minted workspace token from the old
230239
// login flow.
231-
const stored = await loadWorkspaceToken(requestedWorkspace || undefined, args.cloudUrl);
240+
const stored = await loadWorkspaceToken(requestedWorkspace || undefined, cloudUrl);
232241
if (stored) {
233242
return {
234243
token: stored.token,

0 commit comments

Comments
 (0)