From d4a4e9e3e3a6a19ea17328008f2ead28718b34a9 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Thu, 3 Sep 2026 15:01:59 +0200 Subject: [PATCH 1/3] =?UTF-8?q?wip(cli):=20env=20command=20=E2=80=94=20che?= =?UTF-8?q?ckpoint=20of=20workforce-env-cli=20agent's=20uncommitted=20work?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Committed by the supervising session, not by the authoring agent. That agent is at 6% context budget with ~90 minutes of work sitting untracked in the working tree, including two new files (env-command.ts, env-command.test.ts) that git would lose entirely if it stopped. This is a checkpoint for recoverability, NOT a claim that the work is complete or correct. It has not been reviewed, the gates have not been run, and the agent may still be mid-edit. Refs AgentWorkforce/workforce#332 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01U1KdhQH9QodhnsxSMo6ax7 --- packages/agentworkforce/CHANGELOG.md | 5 + packages/agentworkforce/README.md | 8 + packages/cli/CHANGELOG.md | 7 + packages/cli/README.md | 21 ++ packages/cli/src/cli-impl.ts | 22 ++ packages/cli/src/env-command.test.ts | 347 ++++++++++++++++++++ packages/cli/src/env-command.ts | 453 +++++++++++++++++++++++++++ 7 files changed, 863 insertions(+) create mode 100644 packages/cli/src/env-command.test.ts create mode 100644 packages/cli/src/env-command.ts diff --git a/packages/agentworkforce/CHANGELOG.md b/packages/agentworkforce/CHANGELOG.md index 34dfb350..6bbdd417 100644 --- a/packages/agentworkforce/CHANGELOG.md +++ b/packages/agentworkforce/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Expose the CLI's workspace-scoped `env set`, `env list`, and `env unset` + commands through the top-level `agentworkforce` binary. + ## [4.1.51] - 2026-08-31 ### Added diff --git a/packages/agentworkforce/README.md b/packages/agentworkforce/README.md index 7b0d661b..8001f3fb 100644 --- a/packages/agentworkforce/README.md +++ b/packages/agentworkforce/README.md @@ -14,10 +14,18 @@ agentworkforce agent [--install-in-repo] [--no-launch-metadata] [@ agentworkforce sources +agentworkforce env [key] [--workspace ] [--json] agentworkforce harness check agentworkforce --version ``` +Workspace secrets are set from stdin so their values never enter argv: + +```sh +printf '%s' "$RTH_TOKEN" | agentworkforce env set RTH_TOKEN +agentworkforce env list +``` + This package is a thin wrapper around [`@agentworkforce/cli`](https://www.npmjs.com/package/@agentworkforce/cli). It exists so the global install command and the binary name match the project name. diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 8893c01f..c488cb68 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add workspace-scoped `env set`, `env list`, and `env unset` commands. Secret + values are accepted only from stdin, output is metadata-only, and cloud + deployments receive the variables through their runtime environment rather + than persona prompt inputs. + ## [4.1.51] - 2026-08-31 ### Added diff --git a/packages/cli/README.md b/packages/cli/README.md index 87ed4e0e..1170ef8d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14,6 +14,7 @@ agentworkforce persona compile agentworkforce install [flags] agentworkforce deploy [flags] agentworkforce integrations [provider] [--all] [--json] +agentworkforce env [key] [--workspace ] [--json] agentworkforce trigger [--workspace ] [--cloud-url ] [--json] [--no-prompt] agentworkforce sources agentworkforce harness check @@ -37,6 +38,8 @@ agentworkforce --version an authored source module such as `persona.ts` or `persona.js`. - `integrations` — discover available integrations, known trigger events, and connection status for the active workspace. +- `env` — manage runtime environment variables for the active workspace without + putting their values in persona inputs, argv, logs, or command output. - `trigger` — manually fire an active deployed persona for testing. The selector accepts agent id, compact agent id, deployed name, persona slug, or persona id, and posts to the same cloud trigger endpoint used by the dashboard. @@ -84,6 +87,24 @@ offline trigger catalog, rendering connection state as unknown. A provider argument prints the full trigger list, connection details, and a persona/agent snippet using the cloud provider id. +## Workspace environment variables + +Use workspace environment variables for runtime-only values such as service +tokens that must be available through `process.env` but must not be substituted +into a persona's system prompt. + +```sh +printf '%s' "$RTH_TOKEN" | agentworkforce env set RTH_TOKEN +agentworkforce env list +agentworkforce env unset RTH_TOKEN +``` + +`env set` accepts only the key on the command line and reads the value from +non-interactive stdin. It reports whether the key was created or overwritten. +`env list` returns only keys, last-set timestamps, and setter identities — never +values or masked fragments. All three commands use the active workspace unless +`--workspace ` is supplied; ambiguous workspace selection fails closed. + ## Selectors ``` diff --git a/packages/cli/src/cli-impl.ts b/packages/cli/src/cli-impl.ts index 5d8b5f09..e90f8ae2 100644 --- a/packages/cli/src/cli-impl.ts +++ b/packages/cli/src/cli-impl.ts @@ -272,6 +272,16 @@ Commands: Discover workspace integrations, connection status, and known trigger events. JSON output includes registration health when the cloud status API provides it. + env set Set or overwrite a workspace environment variable from + stdin. Values are never accepted on argv. Flags: + --workspace override the active workspace + --cloud-url override the workforce cloud URL + --json emit metadata only (never values) + --no-prompt fail instead of prompting for login + env list List workspace environment variable names, last-set + times, and setters. Values are never returned. + env unset Remove a workspace environment variable; fails if the + key is not set. Accepts the same flags as env set. trigger [payload-json] [flags] Manually fire an active deployed persona for testing. The selector accepts agent id, compact agent id, @@ -401,6 +411,8 @@ Examples: agentworkforce sources list agentworkforce sources add ../my-personas --position 1 agentworkforce integrations --all + printf '%s' "$RTH_TOKEN" | agentworkforce env set RTH_TOKEN + agentworkforce env list agentworkforce harness check agentworkforce pick "review this PR for security issues" agentworkforce agent "$(agentworkforce pick "fix the flaky test in foo.test.ts")" @@ -5170,6 +5182,16 @@ export async function main(): Promise { return; } + if (subcommand === 'env') { + try { + const { runEnv } = await import('./env-command.js'); + await runEnv(rest); + return; + } catch (err) { + die(`agentworkforce env failed: ${err instanceof Error ? err.message : String(err)}`, false); + } + } + if (subcommand === 'trigger') { await runTrigger(rest); return; diff --git a/packages/cli/src/env-command.test.ts b/packages/cli/src/env-command.test.ts new file mode 100644 index 00000000..e41c7377 --- /dev/null +++ b/packages/cli/src/env-command.test.ts @@ -0,0 +1,347 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Readable } from 'node:stream'; +import { createBufferedIO, type BufferedIO } from '@agentworkforce/deploy'; +import { + configureEnvCommandForTest, + parseWorkspaceEnvArgs, + readWorkspaceEnvValue, + runEnv +} from './env-command.js'; + +const CLOUD = 'https://cloud.example.test'; +const SECRET = 'rth_live_SENTINEL-do-not-print-51N7INEL'; + +type FetchCall = { url: string; init?: RequestInit }; + +function response(body: unknown, status = 200): Response { + return new Response(body === null ? null : JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }); +} + +function installDeps(input: { + workspace?: string; + cloudWorkspaceId?: string; + stdin?: Readable; + fetchImpl: (url: string, init?: RequestInit) => Promise; +}): { io: BufferedIO; restore: () => void } { + const io = createBufferedIO(); + const restore = configureEnvCommandForTest({ + createTerminalIO: () => io, + resolveCloudUrl: () => CLOUD, + resolveWorkspaceToken: async ({ workspace }) => ({ + token: 'workspace-bearer', + ...(input.workspace ?? workspace ? { workspace: input.workspace ?? workspace } : {}), + ...(input.cloudWorkspaceId + ? { workspaceDescriptor: { cloudWorkspaceId: input.cloudWorkspaceId } as never } + : {}) + }), + stdin: input.stdin ?? Readable.from([]), + fetchImpl: input.fetchImpl as typeof fetch, + now: () => '2026-09-03T12:00:00.000Z' + }); + return { io, restore }; +} + +test('env set accepts a key only and never echoes a positional secret', () => { + assert.throws( + () => parseWorkspaceEnvArgs(['set', 'RTH_TOKEN', SECRET]), + (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + assert.match(message, /pass the value on stdin/); + assert.doesNotMatch(message, new RegExp(SECRET)); + return true; + } + ); + assert.throws( + () => parseWorkspaceEnvArgs(['set', 'RTH_TOKEN', `--value=${SECRET}`]), + (error: unknown) => { + assert.doesNotMatch(error instanceof Error ? error.message : String(error), new RegExp(SECRET)); + return true; + } + ); +}); + +test('env keys use a strict portable environment-variable shape', () => { + assert.equal(parseWorkspaceEnvArgs(['set', 'RTH_TOKEN']).key, 'RTH_TOKEN'); + for (const key of ['9TOKEN', 'BAD-KEY', 'BAD.KEY', '', 'A'.repeat(129)]) { + assert.throws(() => parseWorkspaceEnvArgs(['set', key]), /environment variable KEY|missing KEY/); + } +}); + +test('stdin value reader strips one pipe newline and rejects unsafe input', async () => { + assert.equal(await readWorkspaceEnvValue(Readable.from([`${SECRET}\r\n`])), SECRET); + await assert.rejects(readWorkspaceEnvValue(Readable.from([])), /stdin is empty/); + await assert.rejects(readWorkspaceEnvValue(Readable.from(['has\0nul'])), /NUL byte/); + await assert.rejects( + readWorkspaceEnvValue(Readable.from(['x'.repeat(64 * 1024 + 1)])), + /exceeds 65536 bytes/ + ); + const tty = Readable.from([SECRET]) as Readable & { isTTY?: boolean }; + tty.isTTY = true; + await assert.rejects(readWorkspaceEnvValue(tty), /non-interactive stdin/); +}); + +test('env set sends the secret only in the request body and reports creation without exposing it', async () => { + const calls: FetchCall[] = []; + const { io, restore } = installDeps({ + workspace: 'ws alpha', + stdin: Readable.from([SECRET]), + async fetchImpl(url, init) { + calls.push({ url, init }); + if (init?.method === 'GET') return response({ error: 'not found' }, 404); + return response({ + name: 'RTH_TOKEN', + envVar: 'RTH_TOKEN', + kind: 'environment', + maskedValue: SECRET, + updatedAt: '2026-09-03T11:59:00.000Z', + setBy: 'user-1' + }, 201); + } + }); + try { + await runEnv(['set', 'RTH_TOKEN']); + assert.equal(calls.length, 2); + assert.equal(calls[0]?.url, `${CLOUD}/api/v1/workspaces/ws%20alpha/secrets/RTH_TOKEN`); + assert.equal(calls[0]?.init?.method, 'GET'); + assert.equal(calls[1]?.url, `${CLOUD}/api/v1/workspaces/ws%20alpha/secrets`); + const body = JSON.parse(String(calls[1]?.init?.body)) as Record; + assert.deepEqual(body, { + name: 'RTH_TOKEN', + envVar: 'RTH_TOKEN', + kind: 'environment', + value: SECRET + }); + const output = io.messages.map((item) => item.message).join('\n'); + assert.match(output, /Set RTH_TOKEN in workspace ws alpha/); + assert.doesNotMatch(output, new RegExp(SECRET)); + } finally { + restore(); + } +}); + +test('env set reports overwrite and JSON output contains metadata only', async () => { + const { io, restore } = installDeps({ + workspace: 'ws-a', + stdin: Readable.from([SECRET]), + async fetchImpl(_url, init) { + if (init?.method === 'GET') return response({ name: 'RTH_TOKEN' }); + return response({ + name: 'RTH_TOKEN', + envVar: 'RTH_TOKEN', + kind: 'environment', + maskedValue: SECRET, + updatedAt: '2026-09-03T11:59:00.000Z', + setBy: 'user-2' + }); + } + }); + try { + await runEnv(['set', 'RTH_TOKEN', '--json']); + const output = io.messages.map((item) => item.message).join('\n'); + const parsed = JSON.parse(output) as Record; + assert.deepEqual(parsed, { + workspace: 'ws-a', + key: 'RTH_TOKEN', + updatedAt: '2026-09-03T11:59:00.000Z', + setBy: 'user-2', + overwritten: true + }); + assert.doesNotMatch(output, new RegExp(SECRET)); + } finally { + restore(); + } +}); + +test('env list emits names and audit metadata but never values or masks', async () => { + const { io, restore } = installDeps({ + workspace: 'ws-a', + async fetchImpl() { + return response({ + ok: true, + data: { + items: [ + { + name: 'RTH_TOKEN', + envVar: 'RTH_TOKEN', + kind: 'environment', + value: SECRET, + maskedValue: 'rt********************************EL', + updatedAt: '2026-09-03T11:59:00.000Z', + setBy: 'user-1' + }, + { + name: 'openai-production', + envVar: 'OPENAI_API_KEY', + maskedValue: 'sk**********42', + updatedAt: '2026-09-02T10:00:00.000Z' + }, + { + name: 'LEGACY_TOKEN', + envVar: 'LEGACY_TOKEN', + value: SECRET, + updatedAt: '2026-09-01T09:00:00.000Z' + } + ] + } + }); + } + }); + try { + await runEnv(['list', '--json']); + const output = io.messages.map((item) => item.message).join('\n'); + assert.deepEqual(JSON.parse(output), { + workspace: 'ws-a', + variables: [ + { + key: 'RTH_TOKEN', + updatedAt: '2026-09-03T11:59:00.000Z', + setBy: 'user-1' + } + ] + }); + assert.doesNotMatch(output, new RegExp(SECRET)); + assert.doesNotMatch(output, /rt\*+EL|sk\*+42|maskedValue|value/); + } finally { + restore(); + } +}); + +test('workspace override scopes every request and cannot cross-read another workspace', async () => { + const calls: string[] = []; + const { restore } = installDeps({ + async fetchImpl(url) { + calls.push(url); + return response({ ok: true, data: { items: [] } }); + } + }); + try { + await runEnv(['list', '--workspace', 'workspace-a']); + await runEnv(['list', '--workspace', 'workspace-b']); + assert.deepEqual(calls, [ + `${CLOUD}/api/v1/workspaces/workspace-a/secrets`, + `${CLOUD}/api/v1/workspaces/workspace-b/secrets` + ]); + } finally { + restore(); + } +}); + +test('cloud workspace identity wins over a relaycast provider id for storage scope', async () => { + const calls: string[] = []; + const { restore } = installDeps({ + workspace: '987654321', + cloudWorkspaceId: '11111111-1111-4111-8111-111111111111', + async fetchImpl(url) { + calls.push(url); + return response({ ok: true, data: { items: [] } }); + } + }); + try { + await runEnv(['list']); + assert.deepEqual(calls, [ + `${CLOUD}/api/v1/workspaces/11111111-1111-4111-8111-111111111111/secrets` + ]); + } finally { + restore(); + } +}); + +test('ambiguous workspace fails before any environment request', async () => { + let fetched = false; + const { restore } = installDeps({ + async fetchImpl() { + fetched = true; + return response({ ok: true, data: { items: [] } }); + } + }); + try { + await assert.rejects(runEnv(['list']), /workspace is ambiguous/); + assert.equal(fetched, false); + } finally { + restore(); + } +}); + +test('env unset refuses to report success for a missing key', async () => { + const { io, restore } = installDeps({ + workspace: 'ws-a', + async fetchImpl() { + return response({ error: 'not found' }, 404); + } + }); + try { + await assert.rejects(runEnv(['unset', 'RTH_TOKEN']), /RTH_TOKEN is not set/); + assert.equal(io.messages.length, 0); + } finally { + restore(); + } +}); + +test('env unset cannot delete a same-named non-environment secret', async () => { + const methods: Array = []; + const { io, restore } = installDeps({ + workspace: 'ws-a', + async fetchImpl(_url, init) { + methods.push(init?.method); + return response({ + name: 'RTH_TOKEN', + envVar: 'RTH_TOKEN', + maskedValue: 'rt********EL', + updatedAt: '2026-09-03T11:59:00.000Z' + }); + } + }); + try { + await assert.rejects(runEnv(['unset', 'RTH_TOKEN']), /RTH_TOKEN is not set/); + assert.deepEqual(methods, ['GET']); + assert.equal(io.messages.length, 0); + } finally { + restore(); + } +}); + +test('cloud error bodies are never reflected into an env error', async () => { + const { restore } = installDeps({ + workspace: 'ws-a', + stdin: Readable.from([SECRET]), + async fetchImpl(_url, init) { + if (init?.method === 'GET') return response({ error: 'not found' }, 404); + return response({ error: `upstream rejected ${SECRET}` }, 500); + } + }); + try { + await assert.rejects(runEnv(['set', 'RTH_TOKEN']), (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + assert.match(message, /HTTP 500/); + assert.doesNotMatch(message, new RegExp(SECRET)); + return true; + }); + } finally { + restore(); + } +}); + +test('top-level CLI dispatch reaches the workspace env command', async () => { + let fetched = false; + const { restore } = installDeps({ + workspace: 'ws-dispatch', + async fetchImpl() { + fetched = true; + return response({ ok: true, data: { items: [] } }); + } + }); + const previousArgv = process.argv; + process.argv = [process.execPath, 'agentworkforce', 'env', 'list']; + try { + const { main } = await import('./cli-impl.js'); + await main(); + assert.equal(fetched, true); + } finally { + process.argv = previousArgv; + restore(); + } +}); diff --git a/packages/cli/src/env-command.ts b/packages/cli/src/env-command.ts new file mode 100644 index 00000000..0122db08 --- /dev/null +++ b/packages/cli/src/env-command.ts @@ -0,0 +1,453 @@ +import { + createTerminalIO, + resolveCloudUrl, + resolveWorkspaceToken, + type DeployIO +} from '@agentworkforce/deploy'; + +const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; +const MAX_ENV_VALUE_BYTES = 64 * 1024; + +export const ENV_USAGE = `usage: agentworkforce env [key] [flags] + +Manage environment variables for the active workspace. Values are never +accepted as arguments: \`env set\` reads the value only from stdin. + +Commands: + env set Set or overwrite KEY from stdin + env list List names and audit metadata (never values) + env unset Remove KEY; fails when KEY is not set + +Flags: + --workspace Workforce workspace; defaults to the active workspace + --cloud-url Override the workforce cloud URL + --json Emit metadata as JSON (never values) + --no-prompt Fail instead of prompting for login + +Examples: + printf '%s' "$RTH_TOKEN" | agentworkforce env set RTH_TOKEN + agentworkforce env list --workspace my-workspace + agentworkforce env unset RTH_TOKEN +`; + +type EnvAction = 'set' | 'list' | 'unset'; + +export type WorkspaceEnvOptions = { + action: EnvAction; + key?: string; + workspace?: string; + cloudUrl?: string; + json: boolean; + noPrompt: boolean; +}; + +export type WorkspaceEnvMetadata = { + key: string; + updatedAt: string; + setBy: string; +}; + +type ReadableInput = AsyncIterable & { isTTY?: boolean }; + +type WorkspaceEnvDeps = { + fetchImpl: typeof fetch; + resolveWorkspaceToken: typeof resolveWorkspaceToken; + resolveCloudUrl: typeof resolveCloudUrl; + createTerminalIO: typeof createTerminalIO; + stdin: ReadableInput; + now: () => string; +}; + +const defaultDeps: WorkspaceEnvDeps = { + fetchImpl: fetch, + resolveWorkspaceToken, + resolveCloudUrl, + createTerminalIO, + stdin: process.stdin, + now: () => new Date().toISOString() +}; + +let envCommandDeps = defaultDeps; + +export function configureEnvCommandForTest( + overrides: Partial +): () => void { + const previous = envCommandDeps; + envCommandDeps = { ...envCommandDeps, ...overrides }; + return () => { + envCommandDeps = previous; + }; +} + +export function parseWorkspaceEnvArgs(args: readonly string[]): WorkspaceEnvOptions { + const [rawAction, ...rest] = args; + if (rawAction !== 'set' && rawAction !== 'list' && rawAction !== 'unset') { + throw new Error('env: expected one of: set, list, unset'); + } + + let workspace: string | undefined; + let cloudUrl: string | undefined; + let json = false; + let noPrompt = false; + const positional: string[] = []; + + for (let index = 0; index < rest.length; index += 1) { + const arg = rest[index]!; + if (arg === '--workspace') { + workspace = expectFlagValue('--workspace', rest[++index]); + } else if (arg.startsWith('--workspace=')) { + workspace = expectInlineFlagValue('--workspace', arg.slice('--workspace='.length)); + } else if (arg === '--cloud-url') { + cloudUrl = expectFlagValue('--cloud-url', rest[++index]); + } else if (arg.startsWith('--cloud-url=')) { + cloudUrl = expectInlineFlagValue('--cloud-url', arg.slice('--cloud-url='.length)); + } else if (arg === '--json') { + json = true; + } else if (arg === '--no-prompt') { + noPrompt = true; + } else if (!arg.startsWith('-')) { + positional.push(arg); + } else { + throw new Error(`env ${rawAction}: unsupported option; secret values must be passed only on stdin`); + } + } + + if (rawAction === 'list') { + if (positional.length > 0) { + throw new Error('env list: this command accepts no positional arguments'); + } + } else if (positional.length === 0) { + throw new Error(`env ${rawAction}: missing KEY`); + } else if (positional.length > 1) { + throw new Error( + rawAction === 'set' + ? 'env set: accepts only KEY; pass the value on stdin' + : 'env unset: accepts only KEY' + ); + } + + const key = positional[0]; + if (key !== undefined) validateWorkspaceEnvKey(key); + + return { + action: rawAction, + ...(key ? { key } : {}), + ...(workspace ? { workspace } : {}), + ...(cloudUrl ? { cloudUrl } : {}), + json, + noPrompt + }; +} + +export function validateWorkspaceEnvKey(key: string): void { + if (!ENV_KEY_PATTERN.test(key)) { + throw new Error( + 'environment variable KEY must start with a letter or underscore, contain only letters, digits, and underscores, and be at most 128 characters' + ); + } +} + +export async function readWorkspaceEnvValue(input: ReadableInput): Promise { + if (input.isTTY) { + throw new Error( + 'env set reads the value from non-interactive stdin; pipe it with printf or redirect a file' + ); + } + + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of input) { + const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk); + total += buffer.byteLength; + if (total > MAX_ENV_VALUE_BYTES) { + throw new Error(`environment variable value exceeds ${MAX_ENV_VALUE_BYTES} bytes`); + } + chunks.push(buffer); + } + + let value = Buffer.concat(chunks).toString('utf8'); + if (value.endsWith('\n')) { + value = value.slice(0, -1); + if (value.endsWith('\r')) value = value.slice(0, -1); + } + if (!value) throw new Error('environment variable value from stdin is empty'); + if (value.includes('\0')) throw new Error('environment variable value from stdin contains a NUL byte'); + return value; +} + +export async function runEnv(args: readonly string[]): Promise { + if (args.length === 0 || args[0] === '-h' || args[0] === '--help') { + process.stdout.write(ENV_USAGE); + return; + } + + const options = parseWorkspaceEnvArgs(args); + const io = envCommandDeps.createTerminalIO(); + const cloudUrl = envCommandDeps.resolveCloudUrl({ + ...(options.cloudUrl ? { flag: options.cloudUrl } : {}) + }); + const auth = await envCommandDeps.resolveWorkspaceToken({ + ...(options.workspace ? { workspace: options.workspace } : {}), + cloudUrl, + io, + ...(options.noPrompt ? { noPrompt: true } : {}) + }); + // Cloud APIs are scoped by the canonical cloud workspace id. The relaycast + // provider id exposed as `auth.workspace` may be numeric and is not the id + // used by Relayfile-backed runtime storage. + const workspace = auth.workspaceDescriptor?.cloudWorkspaceId?.trim() + || auth.workspace?.trim() + || options.workspace?.trim(); + if (!workspace) { + throw new Error( + 'env: workspace is ambiguous; pass --workspace, set WORKFORCE_WORKSPACE_ID, or select an active workspace' + ); + } + + if (options.action === 'set') { + const value = await readWorkspaceEnvValue(envCommandDeps.stdin); + const result = await setWorkspaceEnv({ + cloudUrl, + workspace, + token: auth.token, + key: options.key!, + value, + fetchImpl: envCommandDeps.fetchImpl, + now: envCommandDeps.now + }); + writeSetOutput(io, result, options.json); + return; + } + + if (options.action === 'unset') { + await unsetWorkspaceEnv({ + cloudUrl, + workspace, + token: auth.token, + key: options.key!, + fetchImpl: envCommandDeps.fetchImpl + }); + writeUnsetOutput(io, { workspace, key: options.key! }, options.json); + return; + } + + const variables = await listWorkspaceEnv({ + cloudUrl, + workspace, + token: auth.token, + fetchImpl: envCommandDeps.fetchImpl + }); + writeListOutput(io, { workspace, variables }, options.json); +} + +async function setWorkspaceEnv(input: { + cloudUrl: string; + workspace: string; + token: string; + key: string; + value: string; + fetchImpl: typeof fetch; + now: () => string; +}): Promise { + const detailUrl = workspaceEnvDetailUrl(input.cloudUrl, input.workspace, input.key); + const existing = await input.fetchImpl(detailUrl, { + method: 'GET', + headers: authHeaders(input.token) + }); + if (existing.status !== 404 && !existing.ok) { + throw requestError('check', existing.status, input.workspace); + } + + const response = await input.fetchImpl(workspaceEnvCollectionUrl(input.cloudUrl, input.workspace), { + method: 'POST', + headers: jsonAuthHeaders(input.token), + body: JSON.stringify({ + name: input.key, + envVar: input.key, + kind: 'environment', + value: input.value + }) + }); + if (!response.ok) throw requestError('set', response.status, input.workspace); + + const record = await readJsonRecord(response); + return { + workspace: input.workspace, + key: input.key, + updatedAt: readString(record, 'updatedAt') ?? input.now(), + setBy: readString(record, 'setBy') ?? 'unknown', + overwritten: existing.ok + }; +} + +async function listWorkspaceEnv(input: { + cloudUrl: string; + workspace: string; + token: string; + fetchImpl: typeof fetch; +}): Promise { + const response = await input.fetchImpl(workspaceEnvCollectionUrl(input.cloudUrl, input.workspace), { + method: 'GET', + headers: authHeaders(input.token) + }); + if (!response.ok) throw requestError('list', response.status, input.workspace); + + const payload = await readJsonRecord(response); + const data = isRecord(payload.data) ? payload.data : {}; + const items = Array.isArray(data.items) ? data.items : []; + return items + .map(toWorkspaceEnvMetadata) + .filter((item): item is WorkspaceEnvMetadata => item !== null) + .sort((left, right) => left.key.localeCompare(right.key)); +} + +async function unsetWorkspaceEnv(input: { + cloudUrl: string; + workspace: string; + token: string; + key: string; + fetchImpl: typeof fetch; +}): Promise { + const detailUrl = workspaceEnvDetailUrl(input.cloudUrl, input.workspace, input.key); + const existing = await input.fetchImpl(detailUrl, { + method: 'GET', + headers: authHeaders(input.token) + }); + if (existing.status === 404) throw missingWorkspaceEnvError(input.key, input.workspace); + if (!existing.ok) throw requestError('check', existing.status, input.workspace); + + const existingRecord = await readJsonRecord(existing); + const existingMetadata = toWorkspaceEnvMetadata(existingRecord); + if (!existingMetadata || existingMetadata.key !== input.key) { + throw missingWorkspaceEnvError(input.key, input.workspace); + } + + const response = await input.fetchImpl(detailUrl, { + method: 'DELETE', + headers: authHeaders(input.token) + }); + if (response.status === 404) throw missingWorkspaceEnvError(input.key, input.workspace); + if (!response.ok) throw requestError('unset', response.status, input.workspace); +} + +function toWorkspaceEnvMetadata(value: unknown): WorkspaceEnvMetadata | null { + if (!isRecord(value)) return null; + const name = readString(value, 'name'); + const envVar = readString(value, 'envVar'); + const kind = readString(value, 'kind'); + if (!envVar || kind !== 'environment' || name !== envVar) return null; + if (!ENV_KEY_PATTERN.test(envVar)) return null; + return { + key: envVar, + updatedAt: readString(value, 'updatedAt') ?? 'unknown', + setBy: readString(value, 'setBy') ?? 'unknown' + }; +} + +function writeSetOutput( + io: DeployIO, + result: WorkspaceEnvMetadata & { workspace: string; overwritten: boolean }, + json: boolean +): void { + if (json) { + io.info(JSON.stringify(result, null, 2)); + return; + } + io.info( + `${result.overwritten ? 'Overwrote' : 'Set'} ${result.key} in workspace ${result.workspace}.` + ); +} + +function writeUnsetOutput( + io: DeployIO, + result: { workspace: string; key: string }, + json: boolean +): void { + if (json) { + io.info(JSON.stringify({ ...result, unset: true }, null, 2)); + return; + } + io.info(`Unset ${result.key} in workspace ${result.workspace}.`); +} + +function writeListOutput( + io: DeployIO, + result: { workspace: string; variables: WorkspaceEnvMetadata[] }, + json: boolean +): void { + if (json) { + io.info(JSON.stringify(result, null, 2)); + return; + } + if (result.variables.length === 0) { + io.info(`No environment variables are set in workspace ${result.workspace}.`); + return; + } + const widths = { + key: Math.max('KEY'.length, ...result.variables.map((item) => item.key.length)), + updatedAt: Math.max('LAST SET'.length, ...result.variables.map((item) => item.updatedAt.length)) + }; + const lines = [ + `${'KEY'.padEnd(widths.key)} ${'LAST SET'.padEnd(widths.updatedAt)} SET BY`, + ...result.variables.map( + (item) => `${item.key.padEnd(widths.key)} ${item.updatedAt.padEnd(widths.updatedAt)} ${item.setBy}` + ) + ]; + io.info(lines.join('\n')); +} + +function workspaceEnvCollectionUrl(cloudUrl: string, workspace: string): string { + return `${cloudUrl.replace(/\/+$/, '')}/api/v1/workspaces/${encodeURIComponent(workspace)}/secrets`; +} + +function workspaceEnvDetailUrl(cloudUrl: string, workspace: string, key: string): string { + return `${workspaceEnvCollectionUrl(cloudUrl, workspace)}/${encodeURIComponent(key)}`; +} + +function authHeaders(token: string): Record { + return { + authorization: `Bearer ${token}`, + 'user-agent': 'agentworkforce-cli' + }; +} + +function jsonAuthHeaders(token: string): Record { + return { ...authHeaders(token), 'content-type': 'application/json' }; +} + +function requestError(action: string, status: number, workspace: string): Error { + if (status === 401) return new Error('env: unauthorized; run `agentworkforce login` and retry'); + if (status === 403) { + return new Error(`env: active account cannot ${action} environment variables in workspace ${workspace}`); + } + return new Error(`env ${action} failed for workspace ${workspace} (HTTP ${status})`); +} + +function missingWorkspaceEnvError(key: string, workspace: string): Error { + return new Error(`env unset: ${key} is not set in workspace ${workspace}`); +} + +async function readJsonRecord(response: Response): Promise> { + const value = await response.json().catch(() => null); + if (!isRecord(value)) throw new Error('env: cloud returned an invalid response'); + return value; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); +} + +function readString(value: Record, key: string): string | undefined { + const raw = value[key]; + return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined; +} + +function expectFlagValue(flag: string, value: string | undefined): string { + if (!value || value.startsWith('-')) throw new Error(`env: ${flag} requires a value`); + return value.trim(); +} + +function expectInlineFlagValue(flag: string, value: string): string { + if (!value.trim()) throw new Error(`env: ${flag} requires a value`); + return value.trim(); +} From ccb6255fc1692f362ebba90ae9ca580e43b08387 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Thu, 3 Sep 2026 15:56:09 +0200 Subject: [PATCH 2/3] fix(cli): harden workspace environment requests Session-Id: 01a06706-3c5c-7233-b21e-1c9b3f39e2f5 --- packages/agentworkforce/README.md | 4 +- packages/cli/README.md | 4 +- packages/cli/src/env-command.test.ts | 152 +++++++++++++++++++++++++-- packages/cli/src/env-command.ts | 150 ++++++++++++++++++++------ 4 files changed, 267 insertions(+), 43 deletions(-) diff --git a/packages/agentworkforce/README.md b/packages/agentworkforce/README.md index 8001f3fb..d0afa55f 100644 --- a/packages/agentworkforce/README.md +++ b/packages/agentworkforce/README.md @@ -14,7 +14,9 @@ agentworkforce agent [--install-in-repo] [--no-launch-metadata] [@ agentworkforce sources -agentworkforce env [key] [--workspace ] [--json] +agentworkforce env set [--workspace ] [--cloud-url ] [--json] [--no-prompt] +agentworkforce env list [--workspace ] [--cloud-url ] [--json] [--no-prompt] +agentworkforce env unset [--workspace ] [--cloud-url ] [--json] [--no-prompt] agentworkforce harness check agentworkforce --version ``` diff --git a/packages/cli/README.md b/packages/cli/README.md index 1170ef8d..b92e688d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14,7 +14,9 @@ agentworkforce persona compile agentworkforce install [flags] agentworkforce deploy [flags] agentworkforce integrations [provider] [--all] [--json] -agentworkforce env [key] [--workspace ] [--json] +agentworkforce env set [--workspace ] [--cloud-url ] [--json] [--no-prompt] +agentworkforce env list [--workspace ] [--cloud-url ] [--json] [--no-prompt] +agentworkforce env unset [--workspace ] [--cloud-url ] [--json] [--no-prompt] agentworkforce trigger [--workspace ] [--cloud-url ] [--json] [--no-prompt] agentworkforce sources agentworkforce harness check diff --git a/packages/cli/src/env-command.test.ts b/packages/cli/src/env-command.test.ts index e41c7377..23c2d02f 100644 --- a/packages/cli/src/env-command.test.ts +++ b/packages/cli/src/env-command.test.ts @@ -24,25 +24,33 @@ function response(body: unknown, status = 200): Response { function installDeps(input: { workspace?: string; cloudWorkspaceId?: string; + includeWorkspaceDescriptor?: boolean; + cloudUrl?: string; stdin?: Readable; fetchImpl: (url: string, init?: RequestInit) => Promise; -}): { io: BufferedIO; restore: () => void } { +}): { io: BufferedIO; restore: () => void; authCalls: string[] } { const io = createBufferedIO(); + const authCalls: string[] = []; const restore = configureEnvCommandForTest({ createTerminalIO: () => io, - resolveCloudUrl: () => CLOUD, - resolveWorkspaceToken: async ({ workspace }) => ({ - token: 'workspace-bearer', - ...(input.workspace ?? workspace ? { workspace: input.workspace ?? workspace } : {}), - ...(input.cloudWorkspaceId - ? { workspaceDescriptor: { cloudWorkspaceId: input.cloudWorkspaceId } as never } - : {}) - }), + resolveCloudUrl: () => input.cloudUrl ?? CLOUD, + resolveWorkspaceToken: async ({ workspace, cloudUrl }) => { + authCalls.push(cloudUrl); + const resolvedWorkspace = input.workspace ?? workspace; + const cloudWorkspaceId = input.cloudWorkspaceId ?? resolvedWorkspace; + return { + token: 'workspace-bearer', + ...(resolvedWorkspace ? { workspace: resolvedWorkspace } : {}), + ...(input.includeWorkspaceDescriptor !== false && cloudWorkspaceId + ? { workspaceDescriptor: { cloudWorkspaceId } as never } + : {}) + }; + }, stdin: input.stdin ?? Readable.from([]), fetchImpl: input.fetchImpl as typeof fetch, now: () => '2026-09-03T12:00:00.000Z' }); - return { io, restore }; + return { io, restore, authCalls }; } test('env set accepts a key only and never echoes a positional secret', () => { @@ -73,12 +81,18 @@ test('env keys use a strict portable environment-variable shape', () => { test('stdin value reader strips one pipe newline and rejects unsafe input', async () => { assert.equal(await readWorkspaceEnvValue(Readable.from([`${SECRET}\r\n`])), SECRET); + const maximumValue = 'x'.repeat(64 * 1024); + assert.equal(await readWorkspaceEnvValue(Readable.from([`${maximumValue}\n`])), maximumValue); await assert.rejects(readWorkspaceEnvValue(Readable.from([])), /stdin is empty/); await assert.rejects(readWorkspaceEnvValue(Readable.from(['has\0nul'])), /NUL byte/); await assert.rejects( readWorkspaceEnvValue(Readable.from(['x'.repeat(64 * 1024 + 1)])), /exceeds 65536 bytes/ ); + await assert.rejects( + readWorkspaceEnvValue(Readable.from([Buffer.from([0x80])])), + /valid UTF-8/ + ); const tty = Readable.from([SECRET]) as Readable & { isTTY?: boolean }; tty.isTTY = true; await assert.rejects(readWorkspaceEnvValue(tty), /non-interactive stdin/); @@ -117,7 +131,10 @@ test('env set sends the secret only in the request body and reports creation wit }); const output = io.messages.map((item) => item.message).join('\n'); assert.match(output, /Set RTH_TOKEN in workspace ws alpha/); + assert.match(output, /Last set: 2026-09-03T11:59:00.000Z/); + assert.match(output, /Set by: user-1/); assert.doesNotMatch(output, new RegExp(SECRET)); + assert.ok(calls.every((call) => call.init?.redirect === 'error')); } finally { restore(); } @@ -128,7 +145,13 @@ test('env set reports overwrite and JSON output contains metadata only', async ( workspace: 'ws-a', stdin: Readable.from([SECRET]), async fetchImpl(_url, init) { - if (init?.method === 'GET') return response({ name: 'RTH_TOKEN' }); + if (init?.method === 'GET') { + return response({ + name: 'RTH_TOKEN', + envVar: 'RTH_TOKEN', + kind: 'environment' + }); + } return response({ name: 'RTH_TOKEN', envVar: 'RTH_TOKEN', @@ -156,6 +179,29 @@ test('env set reports overwrite and JSON output contains metadata only', async ( } }); +test('env set refuses to overwrite a same-named non-environment secret', async () => { + const methods: Array = []; + const { io, restore } = installDeps({ + workspace: 'ws-a', + stdin: Readable.from([SECRET]), + async fetchImpl(_url, init) { + methods.push(init?.method); + return response({ + name: 'RTH_TOKEN', + envVar: 'RTH_TOKEN', + maskedValue: 'rt********EL' + }); + } + }); + try { + await assert.rejects(runEnv(['set', 'RTH_TOKEN']), /already exists as a non-environment secret/); + assert.deepEqual(methods, ['GET']); + assert.equal(io.messages.length, 0); + } finally { + restore(); + } +}); + test('env list emits names and audit metadata but never values or masks', async () => { const { io, restore } = installDeps({ workspace: 'ws-a', @@ -230,6 +276,13 @@ test('workspace override scopes every request and cannot cross-read another work } }); +test('workspace flags containing only whitespace fail instead of using the active workspace', () => { + assert.throws( + () => parseWorkspaceEnvArgs(['list', '--workspace', ' ']), + /--workspace requires a value/ + ); +}); + test('cloud workspace identity wins over a relaycast provider id for storage scope', async () => { const calls: string[] = []; const { restore } = installDeps({ @@ -250,6 +303,45 @@ test('cloud workspace identity wins over a relaycast provider id for storage sco } }); +test('token auth resolves a raw workspace alias to its canonical cloud id', async () => { + const calls: FetchCall[] = []; + const canonical = '11111111-1111-4111-8111-111111111111'; + const { restore } = installDeps({ + workspace: 'rw_raw_provider_id', + includeWorkspaceDescriptor: false, + async fetchImpl(url, init) { + calls.push({ url, init }); + if (url.endsWith('/resolve')) return response({ cloudWorkspaceId: canonical }); + return response({ ok: true, data: { items: [] } }); + } + }); + try { + await runEnv(['list']); + assert.deepEqual(calls.map((call) => call.url), [ + `${CLOUD}/api/v1/workspaces/rw_raw_provider_id/resolve`, + `${CLOUD}/api/v1/workspaces/${canonical}/secrets` + ]); + assert.ok(calls.every((call) => call.init?.redirect === 'error')); + } finally { + restore(); + } +}); + +test('workspace resolution fails closed when cloud returns no canonical id', async () => { + const { restore } = installDeps({ + workspace: 'rw_unbound', + includeWorkspaceDescriptor: false, + async fetchImpl() { + return response({ cloudWorkspaceId: null }); + } + }); + try { + await assert.rejects(runEnv(['list']), /did not resolve to a canonical cloud workspace/); + } finally { + restore(); + } +}); + test('ambiguous workspace fails before any environment request', async () => { let fetched = false; const { restore } = installDeps({ @@ -266,6 +358,44 @@ test('ambiguous workspace fails before any environment request', async () => { } }); +test('credentialed env requests reject insecure cloud URLs before authentication', async () => { + let fetched = false; + const { restore, authCalls } = installDeps({ + workspace: 'ws-a', + cloudUrl: 'http://cloud.example.test', + async fetchImpl() { + fetched = true; + return response({ ok: true, data: { items: [] } }); + } + }); + try { + await assert.rejects(runEnv(['list']), /cloud URL must use HTTPS/); + assert.deepEqual(authCalls, []); + assert.equal(fetched, false); + } finally { + restore(); + } +}); + +test('loopback HTTP remains available for local development', async () => { + const localCloud = 'http://127.0.0.1:8788'; + const calls: string[] = []; + const { restore } = installDeps({ + workspace: 'ws-local', + cloudUrl: localCloud, + async fetchImpl(url) { + calls.push(url); + return response({ ok: true, data: { items: [] } }); + } + }); + try { + await runEnv(['list']); + assert.deepEqual(calls, [`${localCloud}/api/v1/workspaces/ws-local/secrets`]); + } finally { + restore(); + } +}); + test('env unset refuses to report success for a missing key', async () => { const { io, restore } = installDeps({ workspace: 'ws-a', diff --git a/packages/cli/src/env-command.ts b/packages/cli/src/env-command.ts index 0122db08..2509cca2 100644 --- a/packages/cli/src/env-command.ts +++ b/packages/cli/src/env-command.ts @@ -8,7 +8,10 @@ import { const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; const MAX_ENV_VALUE_BYTES = 64 * 1024; -export const ENV_USAGE = `usage: agentworkforce env [key] [flags] +export const ENV_USAGE = `usage: + agentworkforce env set [flags] + agentworkforce env list [flags] + agentworkforce env unset [flags] Manage environment variables for the active workspace. Values are never accepted as arguments: \`env set\` reads the value only from stdin. @@ -159,17 +162,26 @@ export async function readWorkspaceEnvValue(input: ReadableInput): Promise MAX_ENV_VALUE_BYTES) { + // Permit one LF/CRLF framing suffix while still bounding streamed input. + if (total > MAX_ENV_VALUE_BYTES + 2) { throw new Error(`environment variable value exceeds ${MAX_ENV_VALUE_BYTES} bytes`); } chunks.push(buffer); } - let value = Buffer.concat(chunks).toString('utf8'); + let value: string; + try { + value = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)); + } catch { + throw new Error('environment variable value from stdin must be valid UTF-8'); + } if (value.endsWith('\n')) { value = value.slice(0, -1); if (value.endsWith('\r')) value = value.slice(0, -1); } + if (Buffer.byteLength(value, 'utf8') > MAX_ENV_VALUE_BYTES) { + throw new Error(`environment variable value exceeds ${MAX_ENV_VALUE_BYTES} bytes`); + } if (!value) throw new Error('environment variable value from stdin is empty'); if (value.includes('\0')) throw new Error('environment variable value from stdin contains a NUL byte'); return value; @@ -186,6 +198,7 @@ export async function runEnv(args: readonly string[]): Promise { const cloudUrl = envCommandDeps.resolveCloudUrl({ ...(options.cloudUrl ? { flag: options.cloudUrl } : {}) }); + assertSecureCloudUrl(cloudUrl); const auth = await envCommandDeps.resolveWorkspaceToken({ ...(options.workspace ? { workspace: options.workspace } : {}), cloudUrl, @@ -195,14 +208,13 @@ export async function runEnv(args: readonly string[]): Promise { // Cloud APIs are scoped by the canonical cloud workspace id. The relaycast // provider id exposed as `auth.workspace` may be numeric and is not the id // used by Relayfile-backed runtime storage. - const workspace = auth.workspaceDescriptor?.cloudWorkspaceId?.trim() - || auth.workspace?.trim() - || options.workspace?.trim(); - if (!workspace) { - throw new Error( - 'env: workspace is ambiguous; pass --workspace, set WORKFORCE_WORKSPACE_ID, or select an active workspace' - ); - } + const workspace = await resolveCanonicalWorkspaceId({ + cloudUrl, + token: auth.token, + descriptorWorkspaceId: auth.workspaceDescriptor?.cloudWorkspaceId, + requestedWorkspace: auth.workspace ?? options.workspace, + fetchImpl: envCommandDeps.fetchImpl + }); if (options.action === 'set') { const value = await readWorkspaceEnvValue(envCommandDeps.stdin); @@ -250,24 +262,36 @@ async function setWorkspaceEnv(input: { now: () => string; }): Promise { const detailUrl = workspaceEnvDetailUrl(input.cloudUrl, input.workspace, input.key); - const existing = await input.fetchImpl(detailUrl, { + const existing = await fetchWorkspaceApi(input.fetchImpl, detailUrl, { method: 'GET', headers: authHeaders(input.token) }); if (existing.status !== 404 && !existing.ok) { throw requestError('check', existing.status, input.workspace); } + if (existing.ok) { + const existingMetadata = toWorkspaceEnvMetadata(await readJsonRecord(existing)); + if (!existingMetadata || existingMetadata.key !== input.key) { + throw new Error( + `env set: ${input.key} already exists as a non-environment secret in workspace ${input.workspace}` + ); + } + } - const response = await input.fetchImpl(workspaceEnvCollectionUrl(input.cloudUrl, input.workspace), { - method: 'POST', - headers: jsonAuthHeaders(input.token), - body: JSON.stringify({ - name: input.key, - envVar: input.key, - kind: 'environment', - value: input.value - }) - }); + const response = await fetchWorkspaceApi( + input.fetchImpl, + workspaceEnvCollectionUrl(input.cloudUrl, input.workspace), + { + method: 'POST', + headers: jsonAuthHeaders(input.token), + body: JSON.stringify({ + name: input.key, + envVar: input.key, + kind: 'environment', + value: input.value + }) + } + ); if (!response.ok) throw requestError('set', response.status, input.workspace); const record = await readJsonRecord(response); @@ -286,10 +310,14 @@ async function listWorkspaceEnv(input: { token: string; fetchImpl: typeof fetch; }): Promise { - const response = await input.fetchImpl(workspaceEnvCollectionUrl(input.cloudUrl, input.workspace), { - method: 'GET', - headers: authHeaders(input.token) - }); + const response = await fetchWorkspaceApi( + input.fetchImpl, + workspaceEnvCollectionUrl(input.cloudUrl, input.workspace), + { + method: 'GET', + headers: authHeaders(input.token) + } + ); if (!response.ok) throw requestError('list', response.status, input.workspace); const payload = await readJsonRecord(response); @@ -309,7 +337,7 @@ async function unsetWorkspaceEnv(input: { fetchImpl: typeof fetch; }): Promise { const detailUrl = workspaceEnvDetailUrl(input.cloudUrl, input.workspace, input.key); - const existing = await input.fetchImpl(detailUrl, { + const existing = await fetchWorkspaceApi(input.fetchImpl, detailUrl, { method: 'GET', headers: authHeaders(input.token) }); @@ -322,7 +350,7 @@ async function unsetWorkspaceEnv(input: { throw missingWorkspaceEnvError(input.key, input.workspace); } - const response = await input.fetchImpl(detailUrl, { + const response = await fetchWorkspaceApi(input.fetchImpl, detailUrl, { method: 'DELETE', headers: authHeaders(input.token) }); @@ -354,7 +382,8 @@ function writeSetOutput( return; } io.info( - `${result.overwritten ? 'Overwrote' : 'Set'} ${result.key} in workspace ${result.workspace}.` + `${result.overwritten ? 'Overwrote' : 'Set'} ${result.key} in workspace ${result.workspace}. ` + + `Last set: ${result.updatedAt}. Set by: ${result.setBy}.` ); } @@ -404,6 +433,65 @@ function workspaceEnvDetailUrl(cloudUrl: string, workspace: string, key: string) return `${workspaceEnvCollectionUrl(cloudUrl, workspace)}/${encodeURIComponent(key)}`; } +async function resolveCanonicalWorkspaceId(input: { + cloudUrl: string; + token: string; + descriptorWorkspaceId?: string; + requestedWorkspace?: string; + fetchImpl: typeof fetch; +}): Promise { + const descriptorWorkspaceId = input.descriptorWorkspaceId?.trim(); + if (descriptorWorkspaceId) return descriptorWorkspaceId; + + const requestedWorkspace = input.requestedWorkspace?.trim(); + if (!requestedWorkspace) { + throw new Error( + 'env: workspace is ambiguous; pass --workspace, set WORKFORCE_WORKSPACE_ID, or select an active workspace' + ); + } + const response = await fetchWorkspaceApi( + input.fetchImpl, + `${input.cloudUrl.replace(/\/+$/, '')}/api/v1/workspaces/${encodeURIComponent(requestedWorkspace)}/resolve`, + { + method: 'GET', + headers: authHeaders(input.token) + } + ); + if (!response.ok) throw requestError('resolve', response.status, requestedWorkspace); + const record = await readJsonRecord(response); + const workspaceId = readString(record, 'cloudWorkspaceId'); + if (!workspaceId) { + throw new Error( + `env: workspace ${requestedWorkspace} did not resolve to a canonical cloud workspace` + ); + } + return workspaceId; +} + +function fetchWorkspaceApi( + fetchImpl: typeof fetch, + url: string, + init: RequestInit +): Promise { + return fetchImpl(url, { ...init, redirect: 'error' }); +} + +function assertSecureCloudUrl(cloudUrl: string): void { + let url: URL; + try { + url = new URL(cloudUrl); + } catch { + throw new Error('env: cloud URL must be a valid HTTPS URL'); + } + const hostname = url.hostname.toLowerCase(); + const loopbackHttp = url.protocol === 'http:' && ( + hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' + ); + if (url.protocol !== 'https:' && !loopbackHttp) { + throw new Error('env: cloud URL must use HTTPS (HTTP is allowed only for localhost loopback development)'); + } +} + function authHeaders(token: string): Record { return { authorization: `Bearer ${token}`, @@ -443,7 +531,9 @@ function readString(value: Record, key: string): string | undef } function expectFlagValue(flag: string, value: string | undefined): string { - if (!value || value.startsWith('-')) throw new Error(`env: ${flag} requires a value`); + if (!value || value.startsWith('-') || !value.trim()) { + throw new Error(`env: ${flag} requires a value`); + } return value.trim(); } From e38d41b6988203a5aa569c842ad1f6527cf873d1 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Thu, 3 Sep 2026 16:06:11 +0200 Subject: [PATCH 3/3] fix(cli): resolve fallback workspace descriptors Session-Id: 01a06706-3c5c-7233-b21e-1c9b3f39e2f5 --- packages/cli/src/env-command.test.ts | 36 +++++++++++++++++++++++++++- packages/cli/src/env-command.ts | 11 +++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/env-command.test.ts b/packages/cli/src/env-command.test.ts index 23c2d02f..eb9b1844 100644 --- a/packages/cli/src/env-command.test.ts +++ b/packages/cli/src/env-command.test.ts @@ -24,6 +24,7 @@ function response(body: unknown, status = 200): Response { function installDeps(input: { workspace?: string; cloudWorkspaceId?: string; + relaycastWorkspaceId?: string; includeWorkspaceDescriptor?: boolean; cloudUrl?: string; stdin?: Readable; @@ -42,7 +43,14 @@ function installDeps(input: { token: 'workspace-bearer', ...(resolvedWorkspace ? { workspace: resolvedWorkspace } : {}), ...(input.includeWorkspaceDescriptor !== false && cloudWorkspaceId - ? { workspaceDescriptor: { cloudWorkspaceId } as never } + ? { + workspaceDescriptor: { + cloudWorkspaceId, + ...(input.relaycastWorkspaceId + ? { relaycastWorkspaceId: input.relaycastWorkspaceId } + : {}) + } as never + } : {}) }; }, @@ -288,6 +296,7 @@ test('cloud workspace identity wins over a relaycast provider id for storage sco const { restore } = installDeps({ workspace: '987654321', cloudWorkspaceId: '11111111-1111-4111-8111-111111111111', + relaycastWorkspaceId: '987654321', async fetchImpl(url) { calls.push(url); return response({ ok: true, data: { items: [] } }); @@ -303,6 +312,31 @@ test('cloud workspace identity wins over a relaycast provider id for storage sco } }); +test('descriptor compatibility fallback is resolved instead of trusted as a cloud id', async () => { + const calls: string[] = []; + const providerId = '987654321'; + const canonical = '11111111-1111-4111-8111-111111111111'; + const { restore } = installDeps({ + workspace: providerId, + cloudWorkspaceId: providerId, + relaycastWorkspaceId: providerId, + async fetchImpl(url) { + calls.push(url); + if (url.endsWith('/resolve')) return response({ cloudWorkspaceId: canonical }); + return response({ ok: true, data: { items: [] } }); + } + }); + try { + await runEnv(['list']); + assert.deepEqual(calls, [ + `${CLOUD}/api/v1/workspaces/${providerId}/resolve`, + `${CLOUD}/api/v1/workspaces/${canonical}/secrets` + ]); + } finally { + restore(); + } +}); + test('token auth resolves a raw workspace alias to its canonical cloud id', async () => { const calls: FetchCall[] = []; const canonical = '11111111-1111-4111-8111-111111111111'; diff --git a/packages/cli/src/env-command.ts b/packages/cli/src/env-command.ts index 2509cca2..1c04a608 100644 --- a/packages/cli/src/env-command.ts +++ b/packages/cli/src/env-command.ts @@ -207,11 +207,18 @@ export async function runEnv(args: readonly string[]): Promise { }); // Cloud APIs are scoped by the canonical cloud workspace id. The relaycast // provider id exposed as `auth.workspace` may be numeric and is not the id - // used by Relayfile-backed runtime storage. + // used by Relayfile-backed runtime storage. Older workspace descriptors + // copy that provider id into cloudWorkspaceId when the cloud id is absent, + // so equal ids are a compatibility fallback rather than canonical proof. + const descriptorCloudWorkspaceId = auth.workspaceDescriptor?.cloudWorkspaceId?.trim(); + const descriptorRelaycastWorkspaceId = auth.workspaceDescriptor?.relaycastWorkspaceId?.trim(); const workspace = await resolveCanonicalWorkspaceId({ cloudUrl, token: auth.token, - descriptorWorkspaceId: auth.workspaceDescriptor?.cloudWorkspaceId, + ...(descriptorCloudWorkspaceId + && descriptorCloudWorkspaceId !== descriptorRelaycastWorkspaceId + ? { descriptorWorkspaceId: descriptorCloudWorkspaceId } + : {}), requestedWorkspace: auth.workspace ?? options.workspace, fetchImpl: envCommandDeps.fetchImpl });