diff --git a/packages/cli/src/deploy-command.test.ts b/packages/cli/src/deploy-command.test.ts index 55941e0..b67781b 100644 --- a/packages/cli/src/deploy-command.test.ts +++ b/packages/cli/src/deploy-command.test.ts @@ -335,3 +335,43 @@ test('parseDeployArgs: malformed --input exits with clean error', () => { trap.restore(); } }); + +test('runLogin canonicalizes origin.agentrelay.cloud apiUrl before persisting active.json', async () => { + // ensureAuthenticated occasionally returns auth.apiUrl pointing at the + // SST origin-bypass hostname. If we persist that, every subsequent API + // call 401s because session cookies don't cross subdomains. The CLI + // must canonicalize before writing. + const writes: Array<{ cloudUrl?: string }> = []; + const restoreDeps = configureDeployCommandForTest({ + createTerminalIO: () => createBufferedIO(), + ensureAuthenticated: async () => ({ + apiUrl: 'https://origin.agentrelay.cloud', + accessToken: 'access', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }), + createCloudApiClient() { + return { + async fetch(_pathname: string) { + return new Response(JSON.stringify({ workspaces: [{ id: 'ws-1', slug: 'acme' }] }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } + }; + }, + writeActiveWorkspace: async (pointer: { cloudUrl?: string }) => { + writes.push(pointer); + } + }); + const trap = trapExit(false); + try { + await runLogin(['--cloud-url', 'https://agentrelay.com/cloud']); + assert.deepEqual(trap.exits, [0]); + assert.equal(writes.length, 1); + assert.equal(writes[0].cloudUrl, 'https://agentrelay.com/cloud'); + } finally { + trap.restore(); + restoreDeps(); + } +}); diff --git a/packages/cli/src/deploy-command.ts b/packages/cli/src/deploy-command.ts index d815bb2..fb4aa21 100644 --- a/packages/cli/src/deploy-command.ts +++ b/packages/cli/src/deploy-command.ts @@ -7,6 +7,7 @@ import { type StoredAuth } from '@agent-relay/cloud'; import { + canonicalizeCloudUrl, clearActiveWorkspace, clearStoredWorkspaceToken, createTerminalIO, @@ -127,13 +128,18 @@ export async function runLogin(args: readonly string[]): Promise { const opts = parseLoginArgs(args); const io = deployCommandDeps.createTerminalIO(); - const cloudUrl = normalizeCloudUrl( + const cloudUrl = canonicalizeCloudUrl(normalizeCloudUrl( opts.cloudUrl ?? process.env.WORKFORCE_DEPLOY_CLOUD_URL ?? process.env.WORKFORCE_CLOUD_URL ?? defaultApiUrl() - ); + )); try { const auth = await deployCommandDeps.ensureAuthenticated(cloudUrl); - const apiUrl = normalizeCloudUrl(auth.apiUrl || cloudUrl); + // Canonicalize what ensureAuthenticated handed back — when the auth + // request happens to route through cloud's edge-bypass hostname, + // auth.apiUrl can be `https://origin.agentrelay.cloud` even though + // the user's session cookies are scoped to `agentrelay.com`. Storing + // that URL is what causes every subsequent API call to 401. + const apiUrl = canonicalizeCloudUrl(normalizeCloudUrl(auth.apiUrl || cloudUrl)); let workspaces: LoginWorkspace[] = []; let chosen: string; if (opts.workspace) { @@ -142,7 +148,7 @@ export async function runLogin(args: readonly string[]): Promise { workspaces = await listWorkspacesForLogin(auth, apiUrl); if (workspaces.length === 0) { throw new Error( - 'no workspaces are accessible from this account. Create one at https://agentrelay.cloud, ' + 'no workspaces are accessible from this account. Create one at https://agentrelay.com/cloud, ' + 'or pass --workspace if you already know the workspace identifier.' ); } @@ -218,12 +224,9 @@ Flags: const LOGIN_USAGE = `usage: agentworkforce login [flags] -Connect this machine to a workforce workspace. Reuses the shared -Agent Relay Cloud login (\`~/.agent-relay/cloud-auth.json\`) for the bearer -credential and stores a small pointer at \`~/.agentworkforce/active.json\` -recording which workspace this machine targets. No separate workspace-scoped -token is minted; cloud accepts the shared accessToken as Authorization: Bearer -for deploy endpoints. +Connect this machine to a workforce workspace. Opens the browser to sign in +to the workforce cloud and stores a small pointer at +\`~/.agentworkforce/active.json\` recording which workspace this machine targets. Flags: --workspace Workforce workspace; defaults to WORKFORCE_WORKSPACE_ID or prompt @@ -235,12 +238,12 @@ Flags: const LOGOUT_USAGE = `usage: agentworkforce logout [flags] -Clear the stored workforce workspace token. Agent Relay Cloud browser auth is -shared with agent-relay and is preserved unless --cloud-auth is passed. +Clear the stored workforce workspace pointer. The shared cloud browser auth +is preserved unless --cloud-auth is passed. Flags: --workspace Optional workspace token entry to clear - --cloud-auth Also clear the shared Agent Relay Cloud login + --cloud-auth Also clear the shared cloud login --all Alias for --cloud-auth -h, --help Print this message `; @@ -451,7 +454,7 @@ async function listWorkspacesForLogin(auth: StoredAuth, apiUrl: string): Promise if (res.status === 403) { throw new Error( 'workspace list returned 403 Forbidden. Pass --workspace to skip listing, ' - + 'or check that your account has access to a workspace at https://agentrelay.cloud.' + + 'or check that your account has access to a workspace at https://agentrelay.com/cloud.' ); } if (res.status !== 404 && res.status !== 405) { diff --git a/packages/deploy/src/cloud-url.test.ts b/packages/deploy/src/cloud-url.test.ts new file mode 100644 index 0000000..b5ba152 --- /dev/null +++ b/packages/deploy/src/cloud-url.test.ts @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { canonicalizeCloudUrl } from './cloud-url.js'; + +test('canonicalizeCloudUrl: origin.agentrelay.cloud bare host → public canonical', () => { + assert.equal( + canonicalizeCloudUrl('https://origin.agentrelay.cloud'), + 'https://agentrelay.com/cloud' + ); +}); + +test('canonicalizeCloudUrl: origin.agentrelay.cloud/cloud → public canonical', () => { + assert.equal( + canonicalizeCloudUrl('https://origin.agentrelay.cloud/cloud'), + 'https://agentrelay.com/cloud' + ); +}); + +test('canonicalizeCloudUrl: staging.agentrelay.cloud → public canonical', () => { + assert.equal( + canonicalizeCloudUrl('https://staging.agentrelay.cloud'), + 'https://agentrelay.com/cloud' + ); +}); + +test('canonicalizeCloudUrl: bare agentrelay.cloud/cloud → public canonical', () => { + assert.equal( + canonicalizeCloudUrl('https://agentrelay.cloud/cloud'), + 'https://agentrelay.com/cloud' + ); +}); + +test('canonicalizeCloudUrl: public canonical is idempotent', () => { + assert.equal( + canonicalizeCloudUrl('https://agentrelay.com/cloud'), + 'https://agentrelay.com/cloud' + ); +}); + +test('canonicalizeCloudUrl: trailing slash is stripped on canonical input', () => { + assert.equal( + canonicalizeCloudUrl('https://agentrelay.com/cloud/'), + 'https://agentrelay.com/cloud' + ); +}); + +test('canonicalizeCloudUrl: localhost dev URLs are left untouched', () => { + assert.equal( + canonicalizeCloudUrl('http://localhost:3000'), + 'http://localhost:3000' + ); +}); + +test('canonicalizeCloudUrl: unrelated tenant URLs are left untouched', () => { + assert.equal( + canonicalizeCloudUrl('https://some-other-tenant.example.com'), + 'https://some-other-tenant.example.com' + ); +}); + +test('canonicalizeCloudUrl: empty input returns empty string', () => { + assert.equal(canonicalizeCloudUrl(''), ''); +}); + +test('canonicalizeCloudUrl: unparseable input is returned untouched (trimmed)', () => { + assert.equal(canonicalizeCloudUrl(' not-a-url '), 'not-a-url'); +}); diff --git a/packages/deploy/src/cloud-url.ts b/packages/deploy/src/cloud-url.ts new file mode 100644 index 0000000..868a0c6 --- /dev/null +++ b/packages/deploy/src/cloud-url.ts @@ -0,0 +1,45 @@ +/** + * Canonicalize the workforce cloud URL to the public host the user logged + * into, regardless of which edge / origin-bypass URL the auth response + * happened to come from. + * + * Why this exists: cloud's auth-result handler currently echoes + * `request.url` back as `apiUrl`, so when the auth request happens to + * route through the SST/Cloudflare origin-bypass (`origin.agentrelay.cloud`) + * the CLI ends up persisting that hostname and sending every subsequent + * API call to it. Session cookies and Bearer tokens don't validate + * cross-subdomain, so every call 401s. + * + * This is a CLI-side mitigation; the proper structural fix is cloud-side + * (the handler should emit a configured public URL, never `request.url`). + * + * Rules: + * - Map known-bypass hostnames (`origin.agentrelay.cloud`, + * `*.agentrelay.cloud`) → canonical `https://agentrelay.com/cloud`. + * - Leave other hostnames untouched (dev `localhost:*`, custom tenants, + * etc.) — only the cloud-bypass family is remapped. + * - Strip a trailing slash so equality comparisons in the rest of the + * deploy code stay stable. + */ +export function canonicalizeCloudUrl(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return trimmed; + let url: URL; + try { + url = new URL(trimmed); + } catch { + // If it doesn't parse as a URL we don't know how to remap it; return + // the original (trimmed) string so the caller can choose to error + // downstream. + return trimmed; + } + const host = url.hostname.toLowerCase(); + if (host === 'agentrelay.cloud' || host.endsWith('.agentrelay.cloud')) { + return 'https://agentrelay.com/cloud'; + } + return stripTrailingSlash(url.toString()); +} + +function stripTrailingSlash(value: string): string { + return value.replace(/\/+$/, ''); +} diff --git a/packages/deploy/src/connect.test.ts b/packages/deploy/src/connect.test.ts index c942491..08ff22f 100644 --- a/packages/deploy/src/connect.test.ts +++ b/packages/deploy/src/connect.test.ts @@ -124,7 +124,7 @@ test('connectIntegrations fails fast on auth errors without prompting to connect integrations: { async isConnected() { throw new Error( - '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.' + 'cloud integration request failed: unauthorized. Your active workspace session is invalid or expired. Run `agentworkforce login --workspace ` to refresh, then retry.' ); }, async connect() { @@ -136,18 +136,39 @@ test('connectIntegrations fails fast on auth errors without prompting to connect assert.equal(confirmCalled, false); assert.equal(connectCalled, false); - assert.deepEqual(result.outcomes, [ - { - provider: 'notion', - status: 'failed', - message: - '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.' - } - ]); + assert.equal(result.outcomes.length, 1); + const [outcome] = result.outcomes; + assert.equal(outcome.provider, 'notion'); + assert.equal(outcome.status, 'failed'); + // Future-proofed against copy-edits: the message must point users at the + // workforce CLI's own login and must NOT instruct them to reach for the + // upstream `agent-relay` binary. + assert.match(outcome.message ?? '', /agentworkforce login/i); + assert.doesNotMatch(outcome.message ?? '', /agent-relay cloud/); assert.ok(io.messages.some((message) => message.level === 'warn' && message.message.includes('failed to check connection status for notion'))); assert.ok(io.messages.some((message) => message.level === 'error' && message.message.includes('auth failed'))); }); +test('relayfileIntegrationResolver surfaces the agentworkforce-native error on 401', async () => { + const resolver = relayfileIntegrationResolver({ + apiUrl: 'https://cloud.example.test', + workspaceId: 'ws-1', + workspaceToken: 'tok', + fetch: async () => new Response('Unauthorized', { status: 401 }) + }); + await assert.rejects( + resolver.isConnected({ workspace: 'ws-1', provider: 'notion' }), + (err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + assert.match(message, /unauthorized/i); + assert.match(message, /agentworkforce login/i); + assert.doesNotMatch(message, /agent-relay cloud/); + assert.doesNotMatch(message, /origin\.agentrelay\.cloud/); + return true; + } + ); +}); + test('connectIntegrations honors --no-prompt for subscription provider setup', async () => { const io = createBufferedIO(); let confirmCalled = false; diff --git a/packages/deploy/src/connect.ts b/packages/deploy/src/connect.ts index 5110fce..4319483 100644 --- a/packages/deploy/src/connect.ts +++ b/packages/deploy/src/connect.ts @@ -318,12 +318,12 @@ async function requestJson( }); if (res.status === 401) { throw new Error( - '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.' + 'cloud integration request failed: unauthorized. Your active workspace session is invalid or expired. Run `agentworkforce login --workspace ` to refresh, then retry.' ); } if (res.status === 403) { throw new Error( - '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.' + 'cloud integration request failed: forbidden. The active account is not authorized for this workspace. Run `agentworkforce login --workspace ` against an account with access, then retry.' ); } if (!res.ok) { diff --git a/packages/deploy/src/index.ts b/packages/deploy/src/index.ts index 273442f..8706f8b 100644 --- a/packages/deploy/src/index.ts +++ b/packages/deploy/src/index.ts @@ -42,6 +42,7 @@ export { type WorkspaceAuth, type WorkspaceAuthToken } from './login.js'; +export { canonicalizeCloudUrl } from './cloud-url.js'; export { createTerminalIO, createBufferedIO, type BufferedIO } from './io.js'; export { bundleStager } from './bundle.js'; export { devLauncher } from './modes/dev.js'; diff --git a/packages/deploy/src/login.ts b/packages/deploy/src/login.ts index d91b035..3a3a934 100644 --- a/packages/deploy/src/login.ts +++ b/packages/deploy/src/login.ts @@ -7,6 +7,7 @@ import { writeStoredAuth, type StoredAuth } from '@agent-relay/cloud'; +import { canonicalizeCloudUrl } from './cloud-url.js'; import type { DeployIO } from './types.js'; /** @@ -116,11 +117,15 @@ export async function writeActiveWorkspace( ): Promise { const file = activeWorkspaceFile(); await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); + // Canonicalize at write time so we never persist an edge / origin-bypass + // hostname (e.g. origin.agentrelay.cloud) into active.json. Downstream + // readers can trust the stored value and skip canonicalization. + const cloudUrl = input.cloudUrl ? canonicalizeCloudUrl(input.cloudUrl) : undefined; const payload: ActiveWorkspacePointer = { workspace: input.workspace, ...(input.workspaceSlug ? { workspaceSlug: input.workspaceSlug } : {}), ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), - ...(input.cloudUrl ? { cloudUrl: input.cloudUrl } : {}), + ...(cloudUrl ? { cloudUrl } : {}), setAt: new Date().toISOString() }; await writeFile(file, `${JSON.stringify(payload, null, 2)}\n`, { @@ -194,6 +199,10 @@ export async function resolveWorkspaceToken(args: { io: DeployIO; noPrompt?: boolean; }): Promise { + // Defensively canonicalize the incoming cloud URL so any per-call + // matching (e.g. cloudUrlMatches in loadWorkspaceToken) compares against + // the public canonical host rather than an origin-bypass hostname. + const cloudUrl = canonicalizeCloudUrl(args.cloudUrl); const envWorkspace = (process.env.WORKFORCE_WORKSPACE_ID ?? '').trim(); const fromEnv = (process.env.WORKFORCE_WORKSPACE_TOKEN ?? '').trim(); const requestedWorkspace = (args.workspace ?? '').trim(); @@ -228,7 +237,7 @@ export async function resolveWorkspaceToken(args: { // Tier 3: legacy keychain / file-stored workspace token. Kept for users // mid-upgrade who already have a minted workspace token from the old // login flow. - const stored = await loadWorkspaceToken(requestedWorkspace || undefined, args.cloudUrl); + const stored = await loadWorkspaceToken(requestedWorkspace || undefined, cloudUrl); if (stored) { return { token: stored.token,