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
40 changes: 40 additions & 0 deletions packages/cli/src/deploy-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
31 changes: 17 additions & 14 deletions packages/cli/src/deploy-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type StoredAuth
} from '@agent-relay/cloud';
import {
canonicalizeCloudUrl,
clearActiveWorkspace,
clearStoredWorkspaceToken,
createTerminalIO,
Expand Down Expand Up @@ -127,13 +128,18 @@ export async function runLogin(args: readonly string[]): Promise<void> {

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) {
Expand All @@ -142,7 +148,7 @@ export async function runLogin(args: readonly string[]): Promise<void> {
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 <id-or-slug> if you already know the workspace identifier.'
);
}
Expand Down Expand Up @@ -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 <name> Workforce workspace; defaults to WORKFORCE_WORKSPACE_ID or prompt
Expand All @@ -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 <name> 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
`;
Expand Down Expand Up @@ -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 <id-or-slug> 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) {
Expand Down
67 changes: 67 additions & 0 deletions packages/deploy/src/cloud-url.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
45 changes: 45 additions & 0 deletions packages/deploy/src/cloud-url.ts
Original file line number Diff line number Diff line change
@@ -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(/\/+$/, '');
}
39 changes: 30 additions & 9 deletions packages/deploy/src/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id-or-slug>` to refresh, then retry.'
);
},
async connect() {
Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions packages/deploy/src/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id-or-slug>` 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 <id-or-slug>` against an account with access, then retry.'
);
}
if (!res.ok) {
Expand Down
1 change: 1 addition & 0 deletions packages/deploy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
13 changes: 11 additions & 2 deletions packages/deploy/src/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
writeStoredAuth,
type StoredAuth
} from '@agent-relay/cloud';
import { canonicalizeCloudUrl } from './cloud-url.js';
import type { DeployIO } from './types.js';

/**
Expand Down Expand Up @@ -116,11 +117,15 @@ export async function writeActiveWorkspace(
): Promise<void> {
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`, {
Expand Down Expand Up @@ -194,6 +199,10 @@ export async function resolveWorkspaceToken(args: {
io: DeployIO;
noPrompt?: boolean;
}): Promise<WorkspaceAuthToken & { workspace?: string }> {
// 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();
Expand Down Expand Up @@ -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,
Expand Down
Loading