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..d0afa55f 100644 --- a/packages/agentworkforce/README.md +++ b/packages/agentworkforce/README.md @@ -14,10 +14,20 @@ agentworkforce agent [--install-in-repo] [--no-launch-metadata] [@ agentworkforce sources +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 ``` +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..b92e688d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14,6 +14,9 @@ agentworkforce persona compile agentworkforce install [flags] agentworkforce deploy [flags] agentworkforce integrations [provider] [--all] [--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 @@ -37,6 +40,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 +89,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..eb9b1844 --- /dev/null +++ b/packages/cli/src/env-command.test.ts @@ -0,0 +1,511 @@ +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; + relaycastWorkspaceId?: string; + includeWorkspaceDescriptor?: boolean; + cloudUrl?: string; + stdin?: Readable; + fetchImpl: (url: string, init?: RequestInit) => Promise; +}): { io: BufferedIO; restore: () => void; authCalls: string[] } { + const io = createBufferedIO(); + const authCalls: string[] = []; + const restore = configureEnvCommandForTest({ + createTerminalIO: () => io, + 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, + ...(input.relaycastWorkspaceId + ? { relaycastWorkspaceId: input.relaycastWorkspaceId } + : {}) + } as never + } + : {}) + }; + }, + stdin: input.stdin ?? Readable.from([]), + fetchImpl: input.fetchImpl as typeof fetch, + now: () => '2026-09-03T12:00:00.000Z' + }); + return { io, restore, authCalls }; +} + +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); + 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/); +}); + +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.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(); + } +}); + +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', + envVar: 'RTH_TOKEN', + kind: 'environment' + }); + } + 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 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', + 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('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({ + workspace: '987654321', + cloudWorkspaceId: '11111111-1111-4111-8111-111111111111', + relaycastWorkspaceId: '987654321', + 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('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'; + 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({ + 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('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', + 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..1c04a608 --- /dev/null +++ b/packages/cli/src/env-command.ts @@ -0,0 +1,550 @@ +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 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. + +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; + // 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: 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; +} + +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 } : {}) + }); + assertSecureCloudUrl(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. 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, + ...(descriptorCloudWorkspaceId + && descriptorCloudWorkspaceId !== descriptorRelaycastWorkspaceId + ? { descriptorWorkspaceId: descriptorCloudWorkspaceId } + : {}), + requestedWorkspace: auth.workspace ?? options.workspace, + fetchImpl: envCommandDeps.fetchImpl + }); + + 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 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 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); + 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 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); + 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 fetchWorkspaceApi(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 fetchWorkspaceApi(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}. ` + + `Last set: ${result.updatedAt}. Set by: ${result.setBy}.` + ); +} + +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)}`; +} + +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}`, + '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('-') || !value.trim()) { + 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(); +}