From 1578779211ee5cbbf45e949b6f37c6aa7f9bbe8a Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 20:42:31 +0200 Subject: [PATCH 1/2] fix(cli): orchestrator + list/destroy read active.json + cloud-auth.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy orchestrator was still using the legacy `envWorkspaceAuth()` default, which only consults `WORKFORCE_WORKSPACE_TOKEN` env + a long-dead keychain. A user who freshly ran `agentworkforce login` (which writes the shared @agent-relay/cloud accessToken + an active.json pointer) would hit `no workspace resolved` because that flow is invisible to the env-only resolver. PR #113 fixed the cloud launcher and list/destroy commands but missed this orchestrator entry point. The list and destroy CLI commands also defaulted to `https://agentrelay.com` (missing the `/cloud` basePath), so every API call landed on the marketing site's Next.js 404 page — and the full HTML response body was dumped verbatim into the CLI error message. Comprehensive fix: * Add `resolveCloudUrl()` as the single source of truth for cloud URL resolution (flag → env → active.json → canonical default). All three CLI commands and the orchestrator now route through it. The canonical default is now applied via `canonicalizeCloudUrl`, which also remaps the bare apex `agentrelay.com` → `agentrelay.com/cloud` to prevent the marketing-site fallthrough from ever happening again. * Add `formatHttpErrorBody()` — detects HTML response bodies and replaces them with a one-line hint, truncates long non-HTML bodies. list and destroy both use it. * Swap the orchestrator's auth default from `envWorkspaceAuth()` to `resolveWorkspaceToken()`, which respects the same env vars (Tier 1 for CI) but additionally falls through to the shared cloud-auth + active.json pointer. * Add `WORKFORCE_DISABLE_SHARED_AUTH` opt-out for hermetic tests and power users who want strictly env-only operation. Tests: 17 new (cloud-url, error-format, deploy, destroy) covering URL resolution precedence, apex canonicalization, HTML body sanitization, the deploy env-Tier 1 path, deploy noPrompt error message, destroy active.json fallback, and the test isolation hook. Smoke verified against the local build in proactive-agents: `agentworkforce deploy ... --no-prompt` now resolves the workspace from active.json, finds both notion and github already connected, and stages the bundle (failing later on harness creds, which is M3 scope). `agentworkforce deployments list` returns clean results instead of a 404 HTML wall. Co-Authored-By: Claude Opus 4.7 --- packages/cli/src/destroy-command.test.ts | 156 ++++++++++++++++++++++- packages/cli/src/destroy-command.ts | 56 ++++---- packages/cli/src/list-command.ts | 29 ++--- packages/deploy/src/cloud-url.test.ts | 110 +++++++++++++++- packages/deploy/src/cloud-url.ts | 61 +++++++++ packages/deploy/src/deploy.test.ts | 64 ++++++++++ packages/deploy/src/deploy.ts | 46 +++++-- packages/deploy/src/error-format.test.ts | 57 +++++++++ packages/deploy/src/error-format.ts | 34 +++++ packages/deploy/src/index.ts | 3 +- packages/deploy/src/login.ts | 15 +++ 11 files changed, 573 insertions(+), 58 deletions(-) create mode 100644 packages/deploy/src/error-format.test.ts create mode 100644 packages/deploy/src/error-format.ts diff --git a/packages/cli/src/destroy-command.test.ts b/packages/cli/src/destroy-command.test.ts index 9dcda18..021d5f3 100644 --- a/packages/cli/src/destroy-command.test.ts +++ b/packages/cli/src/destroy-command.test.ts @@ -73,6 +73,7 @@ function trapFetch(handler: (call: FetchCall) => Response | Promise): } function withTokenEnv(token: string, workspace: string): () => void { + const restoreIsolate = isolateAuthFiles(); const prevToken = process.env.WORKFORCE_WORKSPACE_TOKEN; const prevWs = process.env.WORKFORCE_WORKSPACE_ID; const prevCloudA = process.env.WORKFORCE_DEPLOY_CLOUD_URL; @@ -88,6 +89,31 @@ function withTokenEnv(token: string, workspace: string): () => void { else process.env.WORKFORCE_WORKSPACE_ID = prevWs; if (prevCloudA !== undefined) process.env.WORKFORCE_DEPLOY_CLOUD_URL = prevCloudA; if (prevCloudB !== undefined) process.env.WORKFORCE_CLOUD_URL = prevCloudB; + restoreIsolate(); + }; +} + +/** + * Pin every filesystem-backed auth source to definitely-missing/disabled + * paths so the destroy CLI tests don't accidentally pick up the host + * developer's `~/.agentworkforce/active.json` or `~/.agent-relay/cloud-auth.json`. + * Tests that intentionally exercise the active.json fallback override + * `WORKFORCE_ACTIVE_WORKSPACE_FILE` after this runs. + */ +function isolateAuthFiles(): () => void { + const prevActive = process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE; + const prevLogin = process.env.WORKFORCE_LOGIN_FILE; + const prevDisable = process.env.WORKFORCE_DISABLE_SHARED_AUTH; + process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE = path.join(os.tmpdir(), 'wf-destroy-test-active-MISSING.json'); + process.env.WORKFORCE_LOGIN_FILE = path.join(os.tmpdir(), 'wf-destroy-test-login-MISSING.json'); + process.env.WORKFORCE_DISABLE_SHARED_AUTH = '1'; + return () => { + if (prevActive === undefined) delete process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE; + else process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE = prevActive; + if (prevLogin === undefined) delete process.env.WORKFORCE_LOGIN_FILE; + else process.env.WORKFORCE_LOGIN_FILE = prevLogin; + if (prevDisable === undefined) delete process.env.WORKFORCE_DISABLE_SHARED_AUTH; + else process.env.WORKFORCE_DISABLE_SHARED_AUTH = prevDisable; }; } @@ -227,7 +253,13 @@ test('runDestroy: 401 maps to exit 1 with a login hint', async () => { }); test('runDestroy: missing workspace exits 1', async () => { - // No WORKFORCE_WORKSPACE_ID, no --workspace. + // No WORKFORCE_WORKSPACE_ID, no --workspace, and no on-disk auth state + // — destroy should fail fast with an actionable error and never reach + // the network. We isolate the filesystem sources because the new code + // path also consults `~/.agentworkforce/active.json` and the shared + // cloud-auth file, which would otherwise leak from the host machine + // running the test. + const restoreIsolate = isolateAuthFiles(); const prevToken = process.env.WORKFORCE_WORKSPACE_TOKEN; const prevWs = process.env.WORKFORCE_WORKSPACE_ID; process.env.WORKFORCE_WORKSPACE_TOKEN = 'tok-1'; @@ -237,9 +269,12 @@ test('runDestroy: missing workspace exits 1', async () => { }); const trap = trapIO(); try { - await assert.rejects(runDestroy([AGENT_UUID]), /__exit_trap__:1/); + await assert.rejects(runDestroy([AGENT_UUID, '--no-prompt']), /__exit_trap__:1/); assert.deepEqual(trap.exits, [1]); - assert.match(trap.stderr, /no workspace resolved/); + // Accept either the orchestrator-level message ("no workspace resolved") + // or the auth-resolver message ("no workspace credentials resolved") + // — both are valid pre-network failures. + assert.match(trap.stderr, /no workspace (credentials )?resolved/); assert.equal(fetchTrap.calls.length, 0); } finally { trap.restore(); @@ -247,6 +282,7 @@ test('runDestroy: missing workspace exits 1', async () => { if (prevToken === undefined) delete process.env.WORKFORCE_WORKSPACE_TOKEN; else process.env.WORKFORCE_WORKSPACE_TOKEN = prevToken; if (prevWs !== undefined) process.env.WORKFORCE_WORKSPACE_ID = prevWs; + restoreIsolate(); } }); @@ -389,3 +425,117 @@ test('runDestroy: 5xx server error exits 1 and surfaces the status', async () => restoreEnv(); } }); + +test('runDestroy: HTML 404 body is replaced with a hint, not dumped verbatim', async () => { + // Regression guard for the apex-without-/cloud bug: when the CLI hits + // `agentrelay.com/api/...` instead of `agentrelay.com/cloud/api/...`, + // cloud's marketing site returns a full Next.js 404 page. The error + // formatter must summarize that, not dump it into stderr. + const restoreEnv = withTokenEnv('tok-1', WORKSPACE); + const htmlPage = '' + + '404' + + ''.repeat(20) + + ''; + const fetchTrap = trapFetch( + async () => new Response(htmlPage, { + status: 404, + headers: { 'content-type': 'text/html; charset=utf-8' } + }) + ); + const trap = trapIO(); + try { + // 404 on a DELETE is the documented "not found / already destroyed" + // path (exit 2). That branch produces a clean message that doesn't + // surface the body — so this guard is really about the + // !res.ok fallthrough. We use 500 here to exercise the generic + // formatter instead. + fetchTrap.restore(); + const fetchTrap2 = trapFetch( + async () => new Response(htmlPage, { + status: 500, + headers: { 'content-type': 'text/html; charset=utf-8' } + }) + ); + try { + await assert.rejects( + runDestroy([AGENT_UUID, '--cloud-url', CLOUD]), + /__exit_trap__:1/ + ); + assert.deepEqual(trap.exits, [1]); + assert.match(trap.stderr, /500/); + assert.match(trap.stderr, /HTML|wrong API root/); + // The raw '.repeat(50) + + ''; + const formatted = formatHttpErrorBody(html); + // Must not include the raw HTML. + assert.equal(formatted.includes(' { + assert.match(formatHttpErrorBody('...'), /HTML/); + assert.match(formatHttpErrorBody('...'), /HTML/); + assert.match(formatHttpErrorBody('x'), /HTML/); +}); + +test('formatHttpErrorBody: includes the offending URL when provided', () => { + const html = '404'; + const formatted = formatHttpErrorBody(html, { + url: 'https://agentrelay.com/api/v1/workspaces/abc/deployments' + }); + assert.match(formatted, /https:\/\/agentrelay\.com\/api\/v1\/workspaces\/abc\/deployments/); +}); + +test('formatHttpErrorBody: a stray

in plain text is NOT treated as HTML', () => { + // Bare angle brackets in JSON error messages shouldn't trigger the + // suppress path. The detector looks for the opening doctype / + trio. + const body = '{"error":"got <h1> tag in input"}'; + assert.equal(formatHttpErrorBody(body), body); +}); diff --git a/packages/deploy/src/error-format.ts b/packages/deploy/src/error-format.ts new file mode 100644 index 0000000..6deed3c --- /dev/null +++ b/packages/deploy/src/error-format.ts @@ -0,0 +1,34 @@ +/** + * Format an HTTP response body for inclusion in a CLI error message. + * + * Common failure mode this guards against: the CLI hits the wrong URL + * (missing `/cloud` basePath, marketing-site fallthrough, etc.) and the + * server returns a full Next.js 404 page. Dumping the HTML verbatim + * produces an unreadable wall of `<script>` tags. This helper detects + * HTML and replaces it with a one-line hint that points at the cause. + * + * Non-HTML bodies are truncated to a reasonable length so a stray + * stack trace doesn't drown the actual error message. + */ +export function formatHttpErrorBody( + body: string | undefined | null, + opts: { maxLength?: number; url?: string } = {} +): string { + const trimmed = (body ?? '').trim(); + if (!trimmed) return ''; + if (looksLikeHtml(trimmed)) { + const where = opts.url ? ` from ${opts.url}` : ''; + return `server returned HTML${where} — likely wrong API root (basePath missing, or fell through to a marketing/landing page). Body suppressed (${trimmed.length} bytes).`; + } + const max = opts.maxLength ?? 300; + if (trimmed.length <= max) return trimmed; + return `${trimmed.slice(0, max)}... (${trimmed.length - max} more bytes truncated)`; +} + +function looksLikeHtml(body: string): boolean { + const head = body.slice(0, 256).toLowerCase(); + if (head.startsWith('<!doctype html')) return true; + if (head.startsWith('<html')) return true; + if (/<head[\s>]/.test(head) && /<title[\s>]/.test(head)) return true; + return false; +} diff --git a/packages/deploy/src/index.ts b/packages/deploy/src/index.ts index d6236ab..5f68d48 100644 --- a/packages/deploy/src/index.ts +++ b/packages/deploy/src/index.ts @@ -44,7 +44,8 @@ export { type WorkspaceAuth, type WorkspaceAuthToken } from './login.js'; -export { canonicalizeCloudUrl } from './cloud-url.js'; +export { canonicalizeCloudUrl, resolveCloudUrl, type CloudUrlContext } from './cloud-url.js'; +export { formatHttpErrorBody } from './error-format.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 3a3a934..dd7f7e8 100644 --- a/packages/deploy/src/login.ts +++ b/packages/deploy/src/login.ts @@ -261,8 +261,16 @@ export async function resolveWorkspaceToken(args: { * Read the shared @agent-relay/cloud auth, refreshing if the accessToken * is expired and a refreshToken is available. Returns `null` on any * failure — callers fall through to the next resolution tier. + * + * Set `WORKFORCE_DISABLE_SHARED_AUTH=1` (or any truthy value) to skip + * the shared-auth read entirely. Primary use cases: + * - Hermetic tests that must not pick up the host machine's + * `~/.agent-relay/cloud-auth.json`. + * - Users who want the CLI to behave as if they had never run + * `agent-relay cloud login` (e.g. to force env-only operation in CI). */ async function readSharedAuthForBearer(): Promise<StoredAuth | null> { + if (isTruthyEnv(process.env.WORKFORCE_DISABLE_SHARED_AUTH)) return null; const auth = await readStoredAuth().catch(() => null); if (!auth || !auth.accessToken) return null; if (!isExpired(auth.accessTokenExpiresAt)) return auth; @@ -274,6 +282,12 @@ async function readSharedAuthForBearer(): Promise<StoredAuth | null> { } } +function isTruthyEnv(value: string | undefined): boolean { + if (!value) return false; + const v = value.trim().toLowerCase(); + return v === '1' || v === 'true' || v === 'yes' || v === 'on'; +} + export async function loadWorkspaceToken( workspace?: string, cloudUrl?: string @@ -346,6 +360,7 @@ async function readWorkspaceTokenFromCloudAuth( cloudUrl?: string ): Promise<StoredWorkspaceLogin | null> { if (usesWorkspaceLoginFileOverride()) return null; + if (isTruthyEnv(process.env.WORKFORCE_DISABLE_SHARED_AUTH)) return null; let auth = await readStoredAuth().catch(() => null); if (!auth) return null; From 6c583490b9215c35387129f677469702a5d57f92 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade <ricky@agent-relay.com> Date: Wed, 13 May 2026 20:55:38 +0200 Subject: [PATCH 2/2] fix(cli): address pr review comments --- packages/cli/src/destroy-command.test.ts | 6 ++++-- packages/deploy/src/deploy.test.ts | 18 ++++++++++++++++-- packages/deploy/src/deploy.ts | 2 +- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/destroy-command.test.ts b/packages/cli/src/destroy-command.test.ts index 021d5f3..baf45ef 100644 --- a/packages/cli/src/destroy-command.test.ts +++ b/packages/cli/src/destroy-command.test.ts @@ -87,8 +87,10 @@ function withTokenEnv(token: string, workspace: string): () => void { else process.env.WORKFORCE_WORKSPACE_TOKEN = prevToken; if (prevWs === undefined) delete process.env.WORKFORCE_WORKSPACE_ID; else process.env.WORKFORCE_WORKSPACE_ID = prevWs; - if (prevCloudA !== undefined) process.env.WORKFORCE_DEPLOY_CLOUD_URL = prevCloudA; - if (prevCloudB !== undefined) process.env.WORKFORCE_CLOUD_URL = prevCloudB; + if (prevCloudA === undefined) delete process.env.WORKFORCE_DEPLOY_CLOUD_URL; + else process.env.WORKFORCE_DEPLOY_CLOUD_URL = prevCloudA; + if (prevCloudB === undefined) delete process.env.WORKFORCE_CLOUD_URL; + else process.env.WORKFORCE_CLOUD_URL = prevCloudB; restoreIsolate(); }; } diff --git a/packages/deploy/src/deploy.test.ts b/packages/deploy/src/deploy.test.ts index 2d72cf7..0854800 100644 --- a/packages/deploy/src/deploy.test.ts +++ b/packages/deploy/src/deploy.test.ts @@ -749,10 +749,14 @@ test('deploy: clear error when nothing resolves and noPrompt is set', async () = ); await withWorkspaceEnv({ workspace: undefined, token: undefined }, async () => { - // Point active-workspace file at a definitely-missing path so the test - // doesn't accidentally pick up the host user's `~/.agentworkforce/active.json`. + // Point filesystem-backed auth at definitely-missing/disabled paths so the + // test doesn't accidentally pick up host credentials. const previousActiveFile = process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE; + const previousLoginFile = process.env.WORKFORCE_LOGIN_FILE; + const previousDisableShared = process.env.WORKFORCE_DISABLE_SHARED_AUTH; process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE = path.join(os.tmpdir(), 'wf-deploy-test-missing-active.json'); + process.env.WORKFORCE_LOGIN_FILE = path.join(os.tmpdir(), 'wf-deploy-test-missing-login.json'); + process.env.WORKFORCE_DISABLE_SHARED_AUTH = '1'; try { await assert.rejects( deploy( @@ -767,6 +771,16 @@ test('deploy: clear error when nothing resolves and noPrompt is set', async () = } else { process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE = previousActiveFile; } + if (previousLoginFile === undefined) { + delete process.env.WORKFORCE_LOGIN_FILE; + } else { + process.env.WORKFORCE_LOGIN_FILE = previousLoginFile; + } + if (previousDisableShared === undefined) { + delete process.env.WORKFORCE_DISABLE_SHARED_AUTH; + } else { + process.env.WORKFORCE_DISABLE_SHARED_AUTH = previousDisableShared; + } } }); diff --git a/packages/deploy/src/deploy.ts b/packages/deploy/src/deploy.ts index ff906bc..9c82084 100644 --- a/packages/deploy/src/deploy.ts +++ b/packages/deploy/src/deploy.ts @@ -231,7 +231,7 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { ...(activeToken ? { workspaceToken: activeToken } : {}), ...(opts.detach ? { detach: true } : {}), ...(opts.byoSandbox ? { byoSandbox: true } : {}), - ...(opts.cloudUrl ? { cloudUrl: opts.cloudUrl } : {}), + ...(cloudUrl ? { cloudUrl } : {}), ...(opts.noPrompt ? { noPrompt: true } : {}), ...(opts.harnessSource ? { harnessSource: opts.harnessSource } : {}), ...(opts.byokKey ? { byokKey: opts.byokKey } : {}),