From 82b47e89fae28ff8ae163514b05dc2927578891b Mon Sep 17 00:00:00 2001 From: mm Date: Mon, 21 Sep 2026 14:49:10 -0400 Subject: [PATCH 1/5] feat: add explicit staging account connection to Rust and Bun CLI --- cli/README.md | 2 + cli/SPEC.md | 57 + cli/bun/src/adapters/environment.ts | 2 +- cli/bun/src/cli.ts | 7 + cli/bun/src/core/args.ts | 10 + cli/bun/src/core/service-account.ts | 220 ++++ cli/bun/src/core/types.ts | 10 +- cli/bun/src/supervision/environment.ts | 1 + cli/bun/test/service-account.test.ts | 114 ++ cli/ci/run_local.py | 20 + .../cases/fixtures/runner-help.txt | 7 + .../runner/staging-service-corpus.json | 1113 +++++++++++++++++ cli/conformance/runner/staging_service.py | 99 ++ cli/protocol/OWNERSHIP.md | 10 + cli/rust/crates/prose-cli/src/main.rs | 10 + .../crates/prose-runner-core/src/error.rs | 31 + .../src/installed_adapters.rs | 22 +- .../prose-runner-core/src/invocation.rs | 30 +- cli/rust/crates/prose-runner-core/src/lib.rs | 2 + .../crates/prose-runner-core/src/runner.rs | 2 +- .../prose-runner-core/src/service_account.rs | 515 ++++++++ cli/shared/errors/taxonomy.v1.json | 48 + .../schemas/organization-list.schema.json | 63 + cli/shared/schemas/runner-error.schema.json | 363 +++++- .../schemas/service-account.schema.json | 49 + cli/shared/tests/test_staging_contract.py | 29 + docs/staging-account.md | 38 + docs/validation/imp-034/README.md | 38 + 28 files changed, 2871 insertions(+), 41 deletions(-) create mode 100644 cli/bun/src/core/service-account.ts create mode 100644 cli/bun/test/service-account.test.ts create mode 100644 cli/conformance/runner/staging-service-corpus.json create mode 100644 cli/conformance/runner/staging_service.py create mode 100644 cli/rust/crates/prose-runner-core/src/service_account.rs create mode 100644 cli/shared/schemas/organization-list.schema.json create mode 100644 cli/shared/schemas/service-account.schema.json create mode 100644 cli/shared/tests/test_staging_contract.py create mode 100644 docs/staging-account.md create mode 100644 docs/validation/imp-034/README.md diff --git a/cli/README.md b/cli/README.md index 00d0c0c..66fd769 100644 --- a/cli/README.md +++ b/cli/README.md @@ -29,6 +29,8 @@ Release operators use the [functional-alpha readiness contract](release/ALPHA_READINESS.md) to distinguish candidate, promotion, and post-publication authority. +For the candidate service-account connection, see [Connect the CLI to staging](../docs/staging-account.md). This does not enable registry publishing or hosted execution. + ## First five minutes 1. Read [Install the functional alpha](#install-the-functional-alpha) and verify diff --git a/cli/SPEC.md b/cli/SPEC.md index 3ba883b..8d09f17 100644 --- a/cli/SPEC.md +++ b/cli/SPEC.md @@ -1778,3 +1778,60 @@ shared test and benchmark authorities; Rust/Bun shared-surface and baseline parity with explicitly implementation-specific research variants; one isolated repository subtree; local functionality before public automation; and evidence-backed portability claims only. + +## IMP-034: explicit staging account commands + +The opt-in global `--service-environment staging` admits only `cli auth login`, +`cli auth status`, `cli auth logout`, and `cli org list`. Other values or uses +are invocation errors. Without this option existing unavailable account behavior +and all harness routing remain unchanged. This does not enable hosted execution. +The only network origin is `https://run-prose-staging.openprose.workers.dev`; +redirects and user-configurable token destinations are forbidden. + +The shared black-box corpus is `conformance/runner/staging-service-corpus.json`. +Staging account and organization results use the closed `service-account` and +`organization-list` schemas. Service errors are contained in `problem`, with +exit code 10; cancellation uses existing `CANCELLED` and exit 24. No raw remote +errors, device codes, API keys, or unknown response fields enter output. +Organization projection contains only id, slug, name, and optional role. + +`OPENPROSE_STAGING_API_KEY` overrides only the staging local credential. It is +never forwarded to a harness. Login/logout reject this variable when nonempty, +because they cannot replace or clear the parent environment. Local credentials +use OS credential storage, service `org.openprose.cli.staging`, account +`api-key`; unavailable native storage fails closed with no plaintext fallback. +Status without a token succeeds as signed out. Status with a token verifies it +using GET `/organizations`; this endpoint can lazily create the account's default +organization. Logout removes only the local credential; it does not revoke a +server key. No provider credential is consulted or modified. + +Device login POSTs `/auth/device` without authorization, prints the returned +user code and exact `https://github.com/login/device` verification URI on stderr, +and POSTs `{device_code}` to `/auth/device/poll`. It does not open a browser. +The start response requires expiry 1..900 seconds and polling interval 1..30 +seconds. Pending waits the current interval; slow_down adds five seconds capped +at 30. The operation stops at expiry or 180 polls, on cancellation, on remote +error, or on complete. Complete requires a nonempty API key, stored only in the +native credential store. Expired remote tokens and local deadlines normalize to +DEVICE_AUTH_EXPIRED; other device errors to DEVICE_AUTH_FAILED. HTTP 401/403 +normalize to SERVICE_AUTH_REQUIRED except device error responses; HTTP transport +or 5xx failures to SERVICE_UNAVAILABLE; malformed successful responses to +SERVICE_PROTOCOL_INVALID. Requests have bounded time and response sizes and +never retry by changing origins or credentials. + +Only compiled test-seam builds may honor `PROSE_TEST_SERVICE_FIXTURE`. The file +contains `credential`, `storeAvailable`, ordered `exchanges` with exact `method`, +`path`, HTTP `status`, and JSON `body`, plus optional `cancelBeforePoll`. This +transport consumes the transcript without network, uses a virtual monotonic +clock, and replaces the credential store in memory. It is not an endpoint +override. Requests must match transcript methods and paths and use a bearer +credential only for organization requests. Release builds ignore this seam. + +The staging credential predicate is exactly `rr_test_[0-9a-f]{32}`; invalid +credentials normalize to SERVICE_PROTOCOL_INVALID. Device user codes are +1..32 characters from `[A-Z0-9-]`, and must not contain the private device code. +Projected organization strings are nonempty, at most 4096 Unicode scalar values, +contain no ASCII control characters or DEL, and cannot contain the bearer key. +Extra organization fields, including nested private fields, are discarded. +Malformed transcript roots fail SERVICE_PROTOCOL_INVALID: credential must be +null or a string, storeAvailable a boolean, exchanges an array of at most 182. diff --git a/cli/bun/src/adapters/environment.ts b/cli/bun/src/adapters/environment.ts index ce98612..38310d1 100644 --- a/cli/bun/src/adapters/environment.ts +++ b/cli/bun/src/adapters/environment.ts @@ -34,7 +34,7 @@ export function buildInstalledAdapterEnvironment(input: AdapterEnvironmentInput) authProfile: input.credentialGroup, }); } - const alwaysStrip = new Set(adapterAlwaysStrip.map(normalize)); + const alwaysStrip = new Set([...adapterAlwaysStrip, "OPENPROSE_STAGING_API_KEY"].map(normalize)); const selected = new Set([ ...adapterBaseEnvironmentAllowlist, ...credentialNames, diff --git a/cli/bun/src/cli.ts b/cli/bun/src/cli.ts index 313be71..b56327c 100644 --- a/cli/bun/src/cli.ts +++ b/cli/bun/src/cli.ts @@ -1,3 +1,4 @@ +import { runServiceAccount } from "./core/service-account"; import { runWeaveHost, writeHostBytes, stopHostOutput } from "./core/weave-host"; import { PUBLISHED_KERNEL_STARTUP } from "./core/build"; import { publishedKernel } from "./core/kernel-startup"; @@ -88,6 +89,12 @@ export async function runCli(args: readonly string[], dependencies: CliDependenc const invocationId = dependencies.ids.invocationId(); try { const parsed = parseEntrypoint(args); + if (parsed.global.serviceEnvironment !== undefined) { + mode = parsed.kind === "operation" && parsed.json ? "json" : parsed.global.output ?? "human"; + if (parsed.kind !== "operation" || !["auth-status", "auth-login", "auth-logout", "org-list"].includes(parsed.operation) || Object.keys(parsed.global).some((key) => !["serviceEnvironment", "output", "color", "verbose"].includes(key))) throw failure("INVOCATION_INVALID"); + return await runServiceAccount(parsed.operation, mode, dependencies); + } + if (parsed.kind === "operation" && parsed.operation === "org-list") throw failure("INVOCATION_INVALID"); if (parsed.kind === "weave") return await runWeaveHost(parsed.argv, parsed.global, dependencies); if (parsed.kind === "help") { dependencies.writeStdout(runnerHelp); diff --git a/cli/bun/src/core/args.ts b/cli/bun/src/core/args.ts index c04c96e..05c7313 100644 --- a/cli/bun/src/core/args.ts +++ b/cli/bun/src/core/args.ts @@ -2,6 +2,7 @@ import { invocationFailure } from "./errors"; import type { GlobalFlags, OutputMode, ParsedEntrypoint } from "./types"; const valueOptions: Record = { + "--service-environment": "serviceEnvironment", "--harness": "harness", "--transport": "transport", "--cwd": "cwd", @@ -26,6 +27,12 @@ function invalid(message: string): never { } function setValue(global: GlobalFlags, key: keyof GlobalFlags, value: string, option: string): void { + if (key === "serviceEnvironment") { + if (value !== "staging") invalid("Service environment must be staging."); + if (global.serviceEnvironment !== undefined) invalid("Service environment was specified more than once."); + global.serviceEnvironment = value; + return; + } if (value.length === 0) invalid(`${option} requires a non-empty value.`); if (key === "nativeAddDirs" || key === "nativeAllowTools") { (global[key] ??= []).push(value); return; } if (key === "nativeMaxTurns" || key === "nativeTimeout" || key === "nativeToolTimeout" || key === "nativeOutputBytes") {global[key]=value;return;} @@ -127,6 +134,7 @@ function parseOperation(global: GlobalFlags, args: readonly string[]): ParsedEnt if (key === "doctor") return { kind: "operation", global, operation: "doctor", json }; if (key === "harness list") return { kind: "operation", global, operation: "harness-list", json }; if (key === "config explain") return { kind: "operation", global, operation: "config-explain", json }; + if (key === "org list") return { kind: "operation", global, operation: "org-list", json }; if (key === "auth status") return { kind: "operation", global, operation: "auth-status", json }; if (key === "auth login") return { kind: "operation", global, operation: "auth-login", json }; if (key === "auth logout") return { kind: "operation", global, operation: "auth-logout", json }; @@ -178,6 +186,8 @@ function knownRunnerHelpPath(args: readonly string[]): boolean { "cleanup prime --help", "config --help", "config explain --help", + "org --help", + "org list --help", "auth --help", "auth status --help", "auth login --help", diff --git a/cli/bun/src/core/service-account.ts b/cli/bun/src/core/service-account.ts new file mode 100644 index 0000000..a728f27 --- /dev/null +++ b/cli/bun/src/core/service-account.ts @@ -0,0 +1,220 @@ +import { readFile } from "node:fs/promises"; +import { TEST_SEAMS_ENABLED } from "./build"; +import { failure } from "./errors"; +import { humanSafeScalar, jsonLine } from "./output"; +import { RunnerFailure, type OutputMode, type RunnerOperation } from "./types"; + +// This target is deliberately independent of hosted language execution. +const BASE = "https://run-prose-staging.openprose.workers.dev"; +const STORE = { service: "org.openprose.cli.staging", name: "api-key" }; +const MAX_BYTES = 65_536; +type RecordValue = Record; +interface Fixture { credential: string | null; storeAvailable: boolean; exchanges: Array<{method: string; path: string; status: number; body: unknown}>; cancelBeforePoll?: boolean } +interface Dependencies { + env: Readonly>; + cancellationSignal?: AbortSignal; + writeStdout(text: string): void; + writeStderr(text: string): void; +} +function object(value: unknown): RecordValue { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw failure("SERVICE_PROTOCOL_INVALID"); + return value as RecordValue; +} +function text(value: unknown, max = 4096): string { + if (typeof value !== "string" || value.length === 0 || Array.from(value).length > max || /[\u0000-\u001f\u007f\ud800-\udfff]/u.test(value)) throw failure("SERVICE_PROTOCOL_INVALID"); + return value; +} +function token(value: unknown): string { + const result = text(value); + if (!/^rr_test_[0-9a-f]{32}$/u.test(result)) throw failure("SERVICE_PROTOCOL_INVALID"); + return result; +} +function integer(value: unknown, low: number, high: number): number { + if (typeof value !== "number" || !Number.isInteger(value) || value < low || value > high) throw failure("SERVICE_PROTOCOL_INVALID"); + return value; +} +async function fixtureFor(deps: Dependencies): Promise { + const path = TEST_SEAMS_ENABLED ? deps.env.PROSE_TEST_SERVICE_FIXTURE : undefined; + if (path === undefined) return undefined; + try { + const bytes = await readFile(path); + if (bytes.length > 1_048_576) throw new Error(); + const value = object(JSON.parse(bytes.toString("utf8"))); + if (!(value.credential === null || typeof value.credential === "string") || typeof value.storeAvailable !== "boolean" || !Array.isArray(value.exchanges) || value.exchanges.length > 182) throw new Error(); + return value as unknown as Fixture; + } catch { throw failure("SERVICE_PROTOCOL_INVALID"); } +} +class Service { + elapsed = 0; + constructor(readonly deps: Dependencies, readonly fixture?: Fixture) {} + checkCancel(): void { if (this.deps.cancellationSignal?.aborted) throw failure("CANCELLED"); } + async store(action: "get" | "set" | "delete", value?: string): Promise { + this.checkCancel(); + try { + if (this.fixture !== undefined) { + if (!this.fixture.storeAvailable) throw new Error(); + if (action === "set") this.fixture.credential = value!; + if (action === "delete") this.fixture.credential = null; + return this.fixture.credential; + } + if (typeof Bun.secrets?.get !== "function") throw new Error(); + // Native APIs cannot cancel an in-flight keychain mutation. Bound waiting, + // but report store failure (not cancellation) when its outcome is unknown. + let timer: ReturnType | undefined; + try { + const operation = action === "get" ? Bun.secrets.get(STORE) + : action === "set" ? Bun.secrets.set({ ...STORE, value: value! }).then(() => null) + : Bun.secrets.delete(STORE).then(() => null); + return await Promise.race([operation, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Credential store timeout")), 10_000); + })]); + } finally { if (timer !== undefined) clearTimeout(timer); } + } catch { throw failure("CREDENTIAL_STORE_UNAVAILABLE"); } + } + async sleep(seconds: number): Promise { + this.checkCancel(); + if (this.fixture?.cancelBeforePoll) throw failure("CANCELLED"); + this.elapsed += seconds; + if (this.fixture !== undefined) return; + await new Promise((resolve, reject) => { + const signal = this.deps.cancellationSignal; + const abort = () => { clearTimeout(timer); signal?.removeEventListener("abort", abort); reject(failure("CANCELLED")); }; + const timer = setTimeout(() => { signal?.removeEventListener("abort", abort); resolve(); }, seconds * 1000); + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) abort(); + }); + } + async request(method: string, path: string, credential?: string, body?: unknown, timeoutMs = 10_000): Promise<{status:number; body:RecordValue}> { + this.checkCancel(); + try { + if (this.fixture !== undefined) { + const expectedCredential = this.deps.env.OPENPROSE_STAGING_API_KEY || this.fixture.credential; + if (path === "/organizations" ? credential === undefined || credential !== expectedCredential : credential !== undefined) throw failure("SERVICE_PROTOCOL_INVALID"); + const exchange = this.fixture.exchanges.shift(); + if (exchange === undefined || exchange.method !== method || exchange.path !== path || JSON.stringify(exchange.body).length > MAX_BYTES) throw failure("SERVICE_PROTOCOL_INVALID"); + return { status: integer(exchange.status, 100, 599), body: object(exchange.body) }; + } + const signal = this.deps.cancellationSignal === undefined ? AbortSignal.timeout(timeoutMs) : AbortSignal.any([this.deps.cancellationSignal, AbortSignal.timeout(timeoutMs)]); + const response = await fetch(`${BASE}${path}`, { + method, redirect: "error", signal, + headers: { Accept: "application/json", ...(credential === undefined ? {} : { Authorization: `Bearer ${credential}` }), ...(body === undefined ? {} : { "Content-Type": "application/json" }) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + // HTTP authentication/service failures do not depend on an error body's format. + if (response.status === 401 || response.status === 403) { await response.body?.cancel(); throw failure("SERVICE_AUTH_REQUIRED"); } + if (response.status >= 500 || response.status === 429) { await response.body?.cancel(); throw failure("SERVICE_UNAVAILABLE"); } + if (response.body === null) throw failure("SERVICE_PROTOCOL_INVALID"); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const item = await reader.read(); + if (item.done) break; + length += item.value.length; + if (length > MAX_BYTES) throw failure("SERVICE_PROTOCOL_INVALID"); + chunks.push(item.value); + } + } finally { await reader.cancel().catch(() => {}); } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; } + let parsed: unknown; + try { parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); } + catch { throw failure("SERVICE_PROTOCOL_INVALID"); } + return { status: response.status, body: object(parsed) }; + } catch (error) { + this.checkCancel(); + if (error instanceof RunnerFailure) throw error; + throw failure("SERVICE_UNAVAILABLE"); + } + } +} +function checkStatus(status: number): void { + if (status === 401 || status === 403) throw failure("SERVICE_AUTH_REQUIRED"); + if (status < 200 || status >= 300) throw failure("SERVICE_UNAVAILABLE"); +} +function organizations(body: RecordValue, credential: string): RecordValue[] { + if (!Array.isArray(body.organizations) || body.organizations.length > 1000) throw failure("SERVICE_PROTOCOL_INVALID"); + return body.organizations.map((raw) => { + const row = object(raw); + const result: RecordValue = { id: text(row.id), slug: text(row.slug), name: text(row.name) }; + if (row.role !== undefined) { + if (!["admin", "developer", "reader"].includes(String(row.role))) throw failure("SERVICE_PROTOCOL_INVALID"); + result.role = text(row.role); + } + if (Object.values(result).some((value) => String(value).includes(credential))) throw failure("SERVICE_PROTOCOL_INVALID"); + return result; + }); +} +async function login(service: Service): Promise { + // Fail closed before obtaining a credential if its durable store is unavailable. + await service.store("get"); + const start = await service.request("POST", "/auth/device"); + checkStatus(start.status); + const deviceCode = text(start.body.device_code); + const userCode = text(start.body.user_code, 32); + if (!/^[A-Z0-9-]+$/u.test(userCode) || start.body.verification_uri !== "https://github.com/login/device" || userCode.includes(deviceCode)) throw failure("SERVICE_PROTOCOL_INVALID"); + const expiry = integer(start.body.expires_in, 1, 900); + let interval = integer(start.body.interval, 1, 30); + const deadline = performance.now() + expiry * 1000; + service.deps.writeStderr(`Go to https://github.com/login/device and enter code: ${userCode}\n`); + for (let count = 0; count < 180; count += 1) { + if (service.elapsed + interval >= expiry || (service.fixture === undefined && performance.now() + interval * 1000 >= deadline)) throw failure("DEVICE_AUTH_EXPIRED"); + await service.sleep(interval); + const remaining = service.fixture === undefined ? deadline - performance.now() : (expiry - service.elapsed) * 1000; + if (remaining <= 0) throw failure("DEVICE_AUTH_EXPIRED"); + const poll = await service.request("POST", "/auth/device/poll", undefined, { device_code: deviceCode }, Math.max(1, Math.min(10_000, Math.floor(remaining)))); + if (poll.body.status === "error") throw failure(poll.body.error === "expired_token" ? "DEVICE_AUTH_EXPIRED" : "DEVICE_AUTH_FAILED"); + checkStatus(poll.status); + if (poll.body.status === "pending") continue; + if (poll.body.status === "slow_down") { interval = Math.min(30, interval + 5); continue; } + if (poll.body.status !== "complete") throw failure("SERVICE_PROTOCOL_INVALID"); + if (service.fixture === undefined && performance.now() >= deadline) throw failure("DEVICE_AUTH_EXPIRED"); + await service.store("set", token(poll.body.api_key)); + return; + } + throw failure("DEVICE_AUTH_EXPIRED"); +} +export async function runServiceAccount(operation: RunnerOperation, mode: OutputMode, deps: Dependencies): Promise { + const isOrg = operation === "org-list"; + const report: RecordValue = isOrg + ? { schema: "openprose.organization-list/1", environment: "staging", organizations: [], problem: null } + : { schema: "openprose.service-account/1", environment: "staging", operation: operation.slice(5), authenticated: false, credentialSource: "none", problem: null }; + let exitCode = 0; + try { + const service = new Service(deps, await fixtureFor(deps)); + const environmentToken = deps.env.OPENPROSE_STAGING_API_KEY; + if ((operation === "auth-login" || operation === "auth-logout") && environmentToken !== undefined && environmentToken !== "") throw failure("INVOCATION_INVALID"); + if (operation === "auth-login") { + await login(service); + report.authenticated = true; + report.credentialSource = "os-credential-store"; + } else if (operation === "auth-logout") { + await service.store("delete"); + } else { + const fromEnvironment = environmentToken !== undefined && environmentToken !== ""; + const credential = fromEnvironment ? token(environmentToken) : await service.store("get"); + if (credential === null) { + if (isOrg) throw failure("SERVICE_AUTH_REQUIRED"); + } else { + const response = await service.request("GET", "/organizations", token(credential)); + checkStatus(response.status); + const rows = organizations(response.body, credential); + if (isOrg) report.organizations = rows; + else { report.authenticated = true; report.credentialSource = fromEnvironment ? "environment" : "os-credential-store"; } + } + } + } catch (caught) { + const error = caught instanceof RunnerFailure ? caught : failure("SERVICE_UNAVAILABLE"); + report.problem = error.toJSON(); + exitCode = error.exitCode; + } + if (mode !== "human") deps.writeStdout(jsonLine(report)); + else { + if (isOrg) for (const row of report.organizations as RecordValue[]) deps.writeStdout(`${humanSafeScalar(String(row.slug))}\t${humanSafeScalar(String(row.name))}\n`); + else deps.writeStdout(`OpenProse staging account: ${report.authenticated ? "authenticated" : "signed out"}\n`); + if (report.problem !== null) { const problem = report.problem as RecordValue; deps.writeStderr(`${problem.code}: ${problem.message}\n${problem.action}\n`); } + } + return exitCode; +} diff --git a/cli/bun/src/core/types.ts b/cli/bun/src/core/types.ts index 2d89a03..63a5357 100644 --- a/cli/bun/src/core/types.ts +++ b/cli/bun/src/core/types.ts @@ -9,6 +9,7 @@ export interface ValueSource { } export interface GlobalFlags { + serviceEnvironment?: "staging"; harness?: string; transport?: string; cwd?: string; @@ -69,7 +70,8 @@ export type RunnerOperation = | "config-explain" | "auth-status" | "auth-login" - | "auth-logout"; + | "auth-logout" + | "org-list"; export type ParsedEntrypoint = | { kind: "weave"; global: GlobalFlags; argv: string[] } @@ -125,6 +127,12 @@ export type RunnerErrorCode = | "HOSTED_UNAVAILABLE" | "HOSTED_AUTH_REQUIRED" | "HOSTED_QUOTA_EXCEEDED" + | "SERVICE_UNAVAILABLE" + | "SERVICE_AUTH_REQUIRED" + | "SERVICE_PROTOCOL_INVALID" + | "CREDENTIAL_STORE_UNAVAILABLE" + | "DEVICE_AUTH_FAILED" + | "DEVICE_AUTH_EXPIRED" | "INTERNAL_ERROR"; export type RunnerBoundary = diff --git a/cli/bun/src/supervision/environment.ts b/cli/bun/src/supervision/environment.ts index 7946753..dd2a433 100644 --- a/cli/bun/src/supervision/environment.ts +++ b/cli/bun/src/supervision/environment.ts @@ -28,6 +28,7 @@ export function buildChildEnvironment( const permitted = new Set([...operatingSystemNames, ...additionalNames]); const result: Record = {}; for (const name of permitted) { + if (name.toUpperCase() === "OPENPROSE_STAGING_API_KEY") continue; const value = ambient[name]; if (value !== undefined) result[name] = value; } diff --git a/cli/bun/test/service-account.test.ts b/cli/bun/test/service-account.test.ts new file mode 100644 index 0000000..345a049 --- /dev/null +++ b/cli/bun/test/service-account.test.ts @@ -0,0 +1,114 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runCli } from "../src/cli"; +import { runServiceAccount } from "../src/core/service-account"; +import { buildChildEnvironment } from "../src/supervision/environment"; +const credential = `rr_test_${"1".repeat(32)}`; +const start = { device_code: "private-device-value", user_code: "ABCD-EFGH", verification_uri: "https://github.com/login/device", expires_in: 60, interval: 1 }; +async function invoke(fixture: unknown, operation = "status", env: Record = {}, args?: string[]) { + const root = await mkdtemp(join(tmpdir(), "prose-service-test-")); + const path = join(root, "fixture.json"); + const original = JSON.stringify(fixture); + await writeFile(path, original); + let stdout = "", stderr = ""; + try { + const code = await runCli(args ?? ["--service-environment", "staging", "cli", ...(operation === "org" ? ["org", "list"] : ["auth", operation]), "--json"], { + env: { PROSE_TEST_SERVICE_FIXTURE: path, ...env }, processCwd: root, + clock: { now: () => "2026-01-01T00:00:00Z", monotonicMs: () => 0 }, ids: { invocationId: () => "test-invocation" }, + writeStdout: (value) => { stdout += value; }, writeStderr: (value) => { stderr += value; }, + }); + expect(await readFile(path, "utf8")).toBe(original); + expect(stdout + stderr).not.toContain(credential); + expect(stdout + stderr).not.toContain(start.device_code); + return { code, report: JSON.parse(stdout), stdout, stderr }; + } finally { await rm(root, { recursive: true, force: true }); } +} +function loginFixture(overrides: Record, polls: unknown[] = []) { + return { credential: null, storeAvailable: true, exchanges: [ + { method: "POST", path: "/auth/device", status: 200, body: { ...start, ...overrides } }, + ...polls.map((body) => ({ method: "POST", path: "/auth/device/poll", status: 200, body })), + ] }; +} +test("device flow stops before a sleep crosses expiry", async () => { + expect((await invoke(loginFixture({ expires_in: 1 }), "login")).report.problem.code).toBe("DEVICE_AUTH_EXPIRED"); +}); +test("slow_down changes interval and respects deadline", async () => { + expect((await invoke(loginFixture({ expires_in: 7 }, [{ status: "slow_down" }]), "login")).report.problem.code).toBe("DEVICE_AUTH_EXPIRED"); +}); +test("device flow rejects malicious user code before output", async () => { + const result = await invoke(loginFixture({ user_code: "bad\nhttps://evil.invalid" }), "login"); + expect(result.report.problem.code).toBe("SERVICE_PROTOCOL_INVALID"); + expect(result.stderr).toBe(""); +}); +test("org projection rejects unknown roles and credential reflection", async () => { + for (const row of [{ id: "org", slug: "org", name: "Org", role: "root" }, { id: "org", slug: "org", name: credential }]) { + const result = await invoke({ credential, storeAvailable: true, exchanges: [{ method: "GET", path: "/organizations", status: 200, body: { organizations: [row] } }] }, "org"); + expect(result.report.problem.code).toBe("SERVICE_PROTOCOL_INVALID"); + } +}); +test("login rejects production or malformed credential without reflection", async () => { + const result = await invoke(loginFixture({}, [{ status: "complete", api_key: "rr_live_secret-must-not-escape" }]), "login"); + expect(result.report.problem.code).toBe("SERVICE_PROTOCOL_INVALID"); + expect(result.stdout + result.stderr).not.toContain("rr_live_secret"); +}); +test("environment login/logout cannot mutate credentials", async () => { + for (const operation of ["login", "logout"]) { + const result = await invoke({ credential: null, storeAvailable: false, exchanges: [] }, operation, { OPENPROSE_STAGING_API_KEY: credential }); + expect(result.report.problem.code).toBe("INVOCATION_INVALID"); + } +}); +test("staging selection cannot launch language or unrelated operations", async () => { + for (const args of [["run", "program.prose"], ["cli", "doctor"], ["--", "cli", "auth", "status"]]) { + const result = await invoke({}, "status", {}, ["--output", "json", "--service-environment", "staging", ...args]); + expect(result.report.code).toBe("INVOCATION_INVALID"); + } +}); +test("staging token cannot enter child environment via explicit allowlist", () => { + expect(buildChildEnvironment({ OPENPROSE_STAGING_API_KEY: credential }, { invocationId: "id", recursionToken: "recursion", runNonce: "nonce" }, ["OPENPROSE_STAGING_API_KEY"]).OPENPROSE_STAGING_API_KEY).toBeUndefined(); +}); +test("transport pins origin and bearer and strips extra remote fields", async () => { + const originalFetch = globalThis.fetch; + let stdout = ""; + try { + globalThis.fetch = (async (input: unknown, options?: RequestInit) => { + expect(input).toBe("https://run-prose-staging.openprose.workers.dev/organizations"); + expect(options?.redirect).toBe("error"); + expect((options?.headers as Record).Authorization).toBe(`Bearer ${credential}`); + expect(options?.signal).toBeInstanceOf(AbortSignal); + return Response.json({ organizations: [{ id: "org", slug: "org", name: "Org", api_key: credential }] }); + }) as unknown as typeof fetch; + const code = await runServiceAccount("org-list", "json", { env: { OPENPROSE_STAGING_API_KEY: credential }, writeStdout: (value) => { stdout += value; }, writeStderr: () => {} }); + expect(code).toBe(0); + expect(JSON.parse(stdout).organizations).toEqual([{ id: "org", slug: "org", name: "Org" }]); + expect(stdout).not.toContain(credential); + } finally { globalThis.fetch = originalFetch; } +}); +test("HTTP failures remain typed with empty or HTML bodies; successful bodies remain bounded", async () => { + const originalFetch = globalThis.fetch; + try { + for (const [status, body, expected] of [ + [401, "", "SERVICE_AUTH_REQUIRED"], + [503, "unavailable", "SERVICE_UNAVAILABLE"], + [200, "x".repeat(65_537), "SERVICE_PROTOCOL_INVALID"], + ] as const) { + let stdout = ""; + globalThis.fetch = (async () => new Response(body, { status })) as unknown as typeof fetch; + await runServiceAccount("auth-status", "json", { env: { OPENPROSE_STAGING_API_KEY: credential }, writeStdout: (value) => { stdout += value; }, writeStderr: () => {} }); + expect(JSON.parse(stdout).problem.code).toBe(expected); + expect(stdout).not.toContain(credential); + } + } finally { globalThis.fetch = originalFetch; } +}); +test("organization help stays in runner namespace", async () => { + for (const args of [["cli", "org", "--help"], ["cli", "org", "list", "--help"]]) { + let stdout = ""; + const code = await runCli(args, { + env: {}, processCwd: "/absent", clock: { now: () => "2026-01-01T00:00:00Z", monotonicMs: () => 0 }, ids: { invocationId: () => "test" }, + writeStdout: (value) => { stdout += value; }, writeStderr: () => {}, + }); + expect(code).toBe(0); + expect(stdout).toContain("OpenProse outer runner"); + } +}); diff --git a/cli/ci/run_local.py b/cli/ci/run_local.py index 2e3ce52..6965210 100644 --- a/cli/ci/run_local.py +++ b/cli/ci/run_local.py @@ -393,6 +393,26 @@ def gates() -> tuple[Gate, ...]: REPOSITORY_ROOT, (python, "cli/conformance/runner/run.py", "--build", "--phase", "7"), ), + Gate( + "staging-service-corpus", + REPOSITORY_ROOT, + (python, "cli/conformance/runner/staging_service.py", "--validate"), + ), + Gate("staging-service-rust-build", CLI_ROOT / "rust", + ("cargo", "build", "--locked", "--features", "test-seams", "--bin", "prose")), + Gate( + "staging-service-rust", + REPOSITORY_ROOT, + (python, "cli/conformance/runner/staging_service.py", "--", + str(CLI_ROOT / "rust" / "target" / "debug" / "prose")), + ), + Gate("staging-service-bun-build", CLI_ROOT / "bun", ("bun", "run", "build:test")), + Gate( + "staging-service-bun", + REPOSITORY_ROOT, + (python, "cli/conformance/runner/staging_service.py", "--", + str(CLI_ROOT / "bun" / "dist" / "prose-test")), + ), Gate( "conformance-host", REPOSITORY_ROOT, diff --git a/cli/conformance/cases/fixtures/runner-help.txt b/cli/conformance/cases/fixtures/runner-help.txt index 5e040ec..263f79f 100644 --- a/cli/conformance/cases/fixtures/runner-help.txt +++ b/cli/conformance/cases/fixtures/runner-help.txt @@ -45,3 +45,10 @@ login defaults when those selection options are omitted. The first language-command token ends runner-option parsing. `prose help` is forwarded to OpenProse; use `prose --help` for this runner help. Use `prose -- cli ...` to forward a language command named `cli`. + +Staging account commands (no hosted execution): + prose --service-environment staging cli auth login|status|logout [--json] + prose --service-environment staging cli org list [--json] + +Login stores credentials in the OS credential store. For CI, set +OPENPROSE_STAGING_API_KEY; logout removes only the local credential. diff --git a/cli/conformance/runner/staging-service-corpus.json b/cli/conformance/runner/staging-service-corpus.json new file mode 100644 index 0000000..bd6af5c --- /dev/null +++ b/cli/conformance/runner/staging-service-corpus.json @@ -0,0 +1,1113 @@ +{ + "schema": "openprose.staging-service-corpus/1", + "cases": [ + { + "id": "signed-out-status", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "status" + ], + "fixture": { + "credential": null, + "storeAvailable": true, + "exchanges": [] + }, + "exitCode": 0, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "status", + "authenticated": false, + "credentialSource": "none", + "problem": null + } + }, + { + "id": "stored-status", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "status" + ], + "fixture": { + "credential": "rr_test_11111111111111111111111111111111", + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/organizations", + "status": 200, + "body": { + "organizations": [ + { + "id": "org-fixture", + "slug": "fixture", + "name": "Fixture", + "role": "admin" + } + ] + } + } + ] + }, + "exitCode": 0, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "status", + "authenticated": true, + "credentialSource": "os-credential-store", + "problem": null + } + }, + { + "id": "org-list", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "org", + "list" + ], + "fixture": { + "credential": "rr_test_11111111111111111111111111111111", + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/organizations", + "status": 200, + "body": { + "organizations": [ + { + "id": "org-fixture", + "slug": "fixture", + "name": "Fixture", + "role": "admin" + } + ] + } + } + ] + }, + "exitCode": 0, + "resultMatches": { + "schema": "openprose.organization-list/1", + "environment": "staging", + "organizations": [ + { + "id": "org-fixture", + "slug": "fixture", + "name": "Fixture", + "role": "admin" + } + ], + "problem": null + } + }, + { + "id": "org-auth-required", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "org", + "list" + ], + "fixture": { + "credential": null, + "storeAvailable": true, + "exchanges": [] + }, + "exitCode": 10, + "resultMatches": { + "schema": "openprose.organization-list/1", + "environment": "staging", + "organizations": [], + "problem": { + "code": "SERVICE_AUTH_REQUIRED" + } + } + }, + { + "id": "invalid-token", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "status" + ], + "fixture": { + "credential": "rr_test_11111111111111111111111111111111", + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/organizations", + "status": 401, + "body": { + "error": "fixture-secret-must-not-escape" + } + } + ] + }, + "exitCode": 10, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "status", + "authenticated": false, + "credentialSource": "none", + "problem": { + "code": "SERVICE_AUTH_REQUIRED" + } + } + }, + { + "id": "service-unavailable", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "status" + ], + "fixture": { + "credential": "rr_test_11111111111111111111111111111111", + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/organizations", + "status": 503, + "body": { + "error": "fixture-secret-must-not-escape" + } + } + ] + }, + "exitCode": 10, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "status", + "authenticated": false, + "credentialSource": "none", + "problem": { + "code": "SERVICE_UNAVAILABLE" + } + } + }, + { + "id": "malformed-organizations", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "org", + "list" + ], + "fixture": { + "credential": "rr_test_11111111111111111111111111111111", + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/organizations", + "status": 200, + "body": { + "organizations": "wrong" + } + } + ] + }, + "exitCode": 10, + "resultMatches": { + "schema": "openprose.organization-list/1", + "environment": "staging", + "organizations": [], + "problem": { + "code": "SERVICE_PROTOCOL_INVALID" + } + } + }, + { + "id": "login-pending-slowdown", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "login" + ], + "fixture": { + "credential": null, + "storeAvailable": true, + "exchanges": [ + { + "method": "POST", + "path": "/auth/device", + "status": 200, + "body": { + "device_code": "fixture-device-secret", + "user_code": "ABCD-EFGH", + "verification_uri": "https://github.com/login/device", + "expires_in": 60, + "interval": 1 + } + }, + { + "method": "POST", + "path": "/auth/device/poll", + "status": 200, + "body": { + "status": "pending" + } + }, + { + "method": "POST", + "path": "/auth/device/poll", + "status": 200, + "body": { + "status": "slow_down" + } + }, + { + "method": "POST", + "path": "/auth/device/poll", + "status": 200, + "body": { + "status": "complete", + "api_key": "rr_test_22222222222222222222222222222222", + "customer_id": "customer-fixture", + "github_login": "fixture" + } + } + ] + }, + "exitCode": 0, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "login", + "authenticated": true, + "credentialSource": "os-credential-store", + "problem": null + } + }, + { + "id": "login-denied", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "login" + ], + "fixture": { + "credential": null, + "storeAvailable": true, + "exchanges": [ + { + "method": "POST", + "path": "/auth/device", + "status": 200, + "body": { + "device_code": "fixture-device-secret", + "user_code": "ABCD-EFGH", + "verification_uri": "https://github.com/login/device", + "expires_in": 60, + "interval": 1 + } + }, + { + "method": "POST", + "path": "/auth/device/poll", + "status": 400, + "body": { + "status": "error", + "error": "access_denied" + } + } + ] + }, + "exitCode": 10, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "login", + "authenticated": false, + "credentialSource": "none", + "problem": { + "code": "DEVICE_AUTH_FAILED" + } + } + }, + { + "id": "login-expired", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "login" + ], + "fixture": { + "credential": null, + "storeAvailable": true, + "exchanges": [ + { + "method": "POST", + "path": "/auth/device", + "status": 200, + "body": { + "device_code": "fixture-device-secret", + "user_code": "ABCD-EFGH", + "verification_uri": "https://github.com/login/device", + "expires_in": 60, + "interval": 1 + } + }, + { + "method": "POST", + "path": "/auth/device/poll", + "status": 400, + "body": { + "status": "error", + "error": "expired_token" + } + } + ] + }, + "exitCode": 10, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "login", + "authenticated": false, + "credentialSource": "none", + "problem": { + "code": "DEVICE_AUTH_EXPIRED" + } + } + }, + { + "id": "login-cancelled", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "login" + ], + "fixture": { + "credential": null, + "storeAvailable": true, + "exchanges": [ + { + "method": "POST", + "path": "/auth/device", + "status": 200, + "body": { + "device_code": "fixture-device-secret", + "user_code": "ABCD-EFGH", + "verification_uri": "https://github.com/login/device", + "expires_in": 60, + "interval": 1 + } + } + ], + "cancelBeforePoll": true + }, + "exitCode": 24, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "login", + "authenticated": false, + "credentialSource": "none", + "problem": { + "code": "CANCELLED" + } + } + }, + { + "id": "login-store-unavailable", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "login" + ], + "fixture": { + "credential": null, + "storeAvailable": false, + "exchanges": [] + }, + "exitCode": 10, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "login", + "authenticated": false, + "credentialSource": "none", + "problem": { + "code": "CREDENTIAL_STORE_UNAVAILABLE" + } + } + }, + { + "id": "logout", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "logout" + ], + "fixture": { + "credential": "rr_test_11111111111111111111111111111111", + "storeAvailable": true, + "exchanges": [] + }, + "exitCode": 0, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "logout", + "authenticated": false, + "credentialSource": "none", + "problem": null + } + }, + { + "id": "logout-idempotent", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "logout" + ], + "fixture": { + "credential": null, + "storeAvailable": true, + "exchanges": [] + }, + "exitCode": 0, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "logout", + "authenticated": false, + "credentialSource": "none", + "problem": null + } + }, + { + "id": "environment-status", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "status" + ], + "fixture": { + "credential": null, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/organizations", + "status": 200, + "body": { + "organizations": [ + { + "id": "org-fixture", + "slug": "fixture", + "name": "Fixture", + "role": "admin" + } + ] + } + } + ] + }, + "exitCode": 0, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "status", + "authenticated": true, + "credentialSource": "environment", + "problem": null + }, + "environment": { + "OPENPROSE_STAGING_API_KEY": "rr_test_33333333333333333333333333333333" + } + }, + { + "id": "login-invalid-verification-uri", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "login" + ], + "fixture": { + "credential": null, + "storeAvailable": true, + "exchanges": [ + { + "method": "POST", + "path": "/auth/device", + "status": 200, + "body": { + "device_code": "fixture-device-secret", + "user_code": "ABCD-EFGH", + "verification_uri": "https://attacker.invalid/device", + "expires_in": 60, + "interval": 1 + } + } + ] + }, + "exitCode": 10, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "login", + "authenticated": false, + "credentialSource": "none", + "problem": { + "code": "SERVICE_PROTOCOL_INVALID" + } + } + }, + { + "id": "exhausted-transcript", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "status" + ], + "fixture": { + "credential": "rr_test_11111111111111111111111111111111", + "storeAvailable": true, + "exchanges": [] + }, + "exitCode": 10, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "status", + "authenticated": false, + "credentialSource": "none", + "problem": { + "code": "SERVICE_PROTOCOL_INVALID" + } + } + }, + { + "id": "organization-echoed-secret", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "org", + "list" + ], + "fixture": { + "credential": "rr_test_11111111111111111111111111111111", + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/organizations", + "status": 200, + "body": { + "organizations": [ + { + "id": "org-fixture", + "slug": "fixture", + "name": "rr_test_11111111111111111111111111111111", + "role": "admin" + } + ] + } + } + ] + }, + "exitCode": 10, + "resultMatches": { + "schema": "openprose.organization-list/1", + "environment": "staging", + "organizations": [], + "problem": { + "code": "SERVICE_PROTOCOL_INVALID" + } + } + }, + { + "id": "organization-extra-secret-fields-stripped", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "org", + "list" + ], + "fixture": { + "credential": "rr_test_11111111111111111111111111111111", + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/organizations", + "status": 200, + "body": { + "organizations": [ + { + "id": "org-fixture", + "slug": "fixture", + "name": "Fixture", + "role": "admin", + "private": { + "api_key": "rr_test_11111111111111111111111111111111" + } + } + ] + } + } + ] + }, + "exitCode": 0, + "resultMatches": { + "schema": "openprose.organization-list/1", + "environment": "staging", + "organizations": [ + { + "id": "org-fixture", + "slug": "fixture", + "name": "Fixture", + "role": "admin" + } + ], + "problem": null + } + }, + { + "id": "login-user-code-equals-device-code", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "login" + ], + "fixture": { + "credential": null, + "storeAvailable": true, + "exchanges": [ + { + "method": "POST", + "path": "/auth/device", + "status": 200, + "body": { + "device_code": "ABCD-EFGH", + "user_code": "ABCD-EFGH", + "verification_uri": "https://github.com/login/device", + "expires_in": 60, + "interval": 1 + } + }, + { + "method": "POST", + "path": "/auth/device/poll", + "status": 200, + "body": { + "status": "pending" + } + }, + { + "method": "POST", + "path": "/auth/device/poll", + "status": 200, + "body": { + "status": "slow_down" + } + }, + { + "method": "POST", + "path": "/auth/device/poll", + "status": 200, + "body": { + "status": "complete", + "api_key": "rr_test_22222222222222222222222222222222", + "customer_id": "customer-fixture", + "github_login": "fixture" + } + } + ] + }, + "exitCode": 10, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "login", + "authenticated": false, + "credentialSource": "none", + "problem": { + "code": "SERVICE_PROTOCOL_INVALID" + } + } + }, + { + "id": "login-lowercase-user-code", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "login" + ], + "fixture": { + "credential": null, + "storeAvailable": true, + "exchanges": [ + { + "method": "POST", + "path": "/auth/device", + "status": 200, + "body": { + "device_code": "fixture-device-secret", + "user_code": "abcd-efgh", + "verification_uri": "https://github.com/login/device", + "expires_in": 60, + "interval": 1 + } + }, + { + "method": "POST", + "path": "/auth/device/poll", + "status": 200, + "body": { + "status": "pending" + } + }, + { + "method": "POST", + "path": "/auth/device/poll", + "status": 200, + "body": { + "status": "slow_down" + } + }, + { + "method": "POST", + "path": "/auth/device/poll", + "status": 200, + "body": { + "status": "complete", + "api_key": "rr_test_22222222222222222222222222222222", + "customer_id": "customer-fixture", + "github_login": "fixture" + } + } + ] + }, + "exitCode": 10, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "login", + "authenticated": false, + "credentialSource": "none", + "problem": { + "code": "SERVICE_PROTOCOL_INVALID" + } + } + }, + { + "id": "login-long-user-code", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "login" + ], + "fixture": { + "credential": null, + "storeAvailable": true, + "exchanges": [ + { + "method": "POST", + "path": "/auth/device", + "status": 200, + "body": { + "device_code": "fixture-device-secret", + "user_code": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "verification_uri": "https://github.com/login/device", + "expires_in": 60, + "interval": 1 + } + }, + { + "method": "POST", + "path": "/auth/device/poll", + "status": 200, + "body": { + "status": "pending" + } + }, + { + "method": "POST", + "path": "/auth/device/poll", + "status": 200, + "body": { + "status": "slow_down" + } + }, + { + "method": "POST", + "path": "/auth/device/poll", + "status": 200, + "body": { + "status": "complete", + "api_key": "rr_test_22222222222222222222222222222222", + "customer_id": "customer-fixture", + "github_login": "fixture" + } + } + ] + }, + "exitCode": 10, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "login", + "authenticated": false, + "credentialSource": "none", + "problem": { + "code": "SERVICE_PROTOCOL_INVALID" + } + } + }, + { + "id": "malformed-fixture", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "status" + ], + "fixture": {}, + "exitCode": 10, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "status", + "authenticated": false, + "credentialSource": "none", + "problem": { + "code": "SERVICE_PROTOCOL_INVALID" + } + } + }, + { + "id": "uppercase-token-rejected", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "status" + ], + "fixture": { + "credential": null, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/organizations", + "status": 200, + "body": { + "organizations": [ + { + "id": "org-fixture", + "slug": "fixture", + "name": "Fixture", + "role": "admin" + } + ] + } + } + ] + }, + "exitCode": 10, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "status", + "authenticated": false, + "credentialSource": "none", + "problem": { + "code": "SERVICE_PROTOCOL_INVALID" + } + }, + "environment": { + "OPENPROSE_STAGING_API_KEY": "rr_test_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + }, + { + "id": "invalid-token-rejected", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "auth", + "status" + ], + "fixture": { + "credential": null, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/organizations", + "status": 200, + "body": { + "organizations": [ + { + "id": "org-fixture", + "slug": "fixture", + "name": "Fixture", + "role": "admin" + } + ] + } + } + ] + }, + "exitCode": 10, + "resultMatches": { + "schema": "openprose.service-account/1", + "environment": "staging", + "operation": "status", + "authenticated": false, + "credentialSource": "none", + "problem": { + "code": "SERVICE_PROTOCOL_INVALID" + } + }, + "environment": { + "OPENPROSE_STAGING_API_KEY": "wrong-token" + } + }, + { + "id": "organization-field-over-limit", + "argv": [ + "--service-environment", + "staging", + "--output", + "json", + "cli", + "org", + "list" + ], + "fixture": { + "credential": "rr_test_11111111111111111111111111111111", + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/organizations", + "status": 200, + "body": { + "organizations": [ + { + "id": "org-fixture", + "slug": "fixture", + "name": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "role": "admin" + } + ] + } + } + ] + }, + "exitCode": 10, + "resultMatches": { + "schema": "openprose.organization-list/1", + "environment": "staging", + "organizations": [], + "problem": { + "code": "SERVICE_PROTOCOL_INVALID" + } + } + } + ] +} diff --git a/cli/conformance/runner/staging_service.py b/cli/conformance/runner/staging_service.py new file mode 100644 index 0000000..4c34d90 --- /dev/null +++ b/cli/conformance/runner/staging_service.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Provider-free process oracle for explicit staging account commands. + +Usage: python3 staging_service.py -- /absolute/path/to/prose + python3 staging_service.py -- bun /absolute/path/to/src/cli.ts +Each invocation receives fresh HOME, a closed HTTP transcript, and no credentials. +""" +from __future__ import annotations +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +CORPUS = Path(__file__).with_name('staging-service-corpus.json') + + +def matches(actual, expected, path='$'): + if isinstance(expected, dict): + if not isinstance(actual, dict): + raise AssertionError(f'{path}: expected object') + for key, value in expected.items(): + if key not in actual: + raise AssertionError(f'{path}.{key}: missing') + matches(actual[key], value, f'{path}.{key}') + elif actual != expected: + raise AssertionError(f'{path}: differs from corpus') + + +def validate_corpus(): + corpus = json.loads(CORPUS.read_text()) + cli = CORPUS.parents[2] + taxonomy = {e['code']: e for e in json.loads((cli/'shared/errors/taxonomy.v1.json').read_text())['errors']} + ids = set() + for case in corpus['cases']: + assert case['id'] not in ids + ids.add(case['id']) + assert case['argv'][:2] == ['--service-environment', 'staging'] + result = case['resultMatches'] + assert result['environment'] == 'staging' + if result['problem']: + assert taxonomy[result['problem']['code']]['exitCode'] == case['exitCode'] + else: + assert case['exitCode'] == 0 + for exchange in case['fixture'].get('exchanges', []): + assert (exchange['method'], exchange['path']) in { + ('GET', '/organizations'), ('POST', '/auth/device'), ('POST', '/auth/device/poll')} + assert isinstance(exchange['status'], int) + print(f'Validated {len(ids)} staging corpus cases (standard-library structural checks).') + return 0 + + +def run(command): + failures = [] + for case in json.loads(CORPUS.read_text())['cases']: + with tempfile.TemporaryDirectory(prefix='prose-staging-oracle-') as directory: + root = Path(directory) + fixture = root / 'service.json' + fixture.write_text(json.dumps(case['fixture'])) + environment = {'PATH': os.environ.get('PATH', '/usr/bin:/bin'), + 'HOME': directory, 'XDG_CONFIG_HOME': str(root/'config'), + 'XDG_CACHE_HOME': str(root/'cache'), 'TMPDIR': directory, + 'PROSE_TEST_SERVICE_FIXTURE': str(fixture), + 'HTTP_PROXY': 'http://127.0.0.1:9', + 'HTTPS_PROXY': 'http://127.0.0.1:9', + 'ALL_PROXY': 'http://127.0.0.1:9', 'NO_PROXY': ''} + environment.update(case.get("environment", {})) + try: + observed = subprocess.run([*command, *case['argv']], cwd=root, + env=environment, capture_output=True, timeout=10) + if observed.returncode != case['exitCode']: + raise AssertionError(f'exit {observed.returncode}, expected {case["exitCode"]}') + result = json.loads(observed.stdout) + matches(result, case['resultMatches']) + for secret in (b'rr_test_11111111111111111111111111111111', b'rr_test_22222222222222222222222222222222', + b'rr_test_33333333333333333333333333333333', b'fixture-device-secret', b'fixture-secret-must-not-escape'): + if secret in observed.stdout + observed.stderr: + raise AssertionError('secret appeared in process output') + if case['id'] == 'login-user-code-equals-device-code' and b'ABCD-EFGH' in observed.stdout + observed.stderr: + raise AssertionError('device secret appeared in output') + if result.get('problem') is None and set(result) != set(case['resultMatches']): + raise AssertionError('unexpected result fields') + print(f'PASS {case["id"]}') + except (AssertionError, ValueError, subprocess.TimeoutExpired) as error: + failures.append(case['id']) + print(f'FAIL {case["id"]}: {error}') + return 1 if failures else 0 + + +if __name__ == '__main__': + command = sys.argv[1:] + if command[:1] == ['--']: + command = command[1:] + if command == ['--validate']: + raise SystemExit(validate_corpus()) + if not command: + raise SystemExit('Provide a test-seam product command after --') + raise SystemExit(run(command)) diff --git a/cli/protocol/OWNERSHIP.md b/cli/protocol/OWNERSHIP.md index 7b053c6..7a2374e 100644 --- a/cli/protocol/OWNERSHIP.md +++ b/cli/protocol/OWNERSHIP.md @@ -480,3 +480,13 @@ Root retains CLI integration and Git. weave_onboarding has a documentation-only At19:20Eastern root assigns weave_parity the narrow public-core identity correction: `experiments/weave-seed/SPEC.md`, new `fixtures/core-identity.json`, `bun/index.mjs`, `bun/conformance.mjs`, `rust/lib.rs`, and a narrowly scoped Rust identity regression test if required (exact path reported before editing). Shared cases/spec precede implementation. Align with the existing persisted-host whitespace predicate; no API rename or broad refactor. Root owns Git and final qualification. The additional approved regression path is `experiments/weave-seed/rust/identity_tests.rs`; no other Rust test paths are assigned. + +## IMP-034 public CLI staging integration + +Isolated checkout: branch `codex/imp-034-staging-cli`. Root owns Git, integration, `cli/protocol/OWNERSHIP.md`, documentation and shared manifest changes. No release or deployment authorized by these leases. + +- `staging_contract`: `cli/SPEC.md`, `cli/shared/schemas/`, `cli/shared/errors/`, `cli/shared/tests/`, `cli/conformance/cases/operations/`, `cli/conformance/runner/` for shared staging authentication and organization behavior and hermetic service fixtures. Record concrete observable cases before implementation. +- `staging_bun`: `cli/bun/src/`, `cli/bun/test/` for staged service account behavior only; wait for shared contract before editing. No manifest edits without root coordination. +- `staging_rust`: `cli/rust/crates/prose-runner-core/src/`, `cli/rust/crates/prose-runner-core/tests/`, `cli/rust/crates/prose-cli/src/` for equivalent Rust behavior; wait for shared contract before editing. No manifest edits without root coordination. + +Root integration lease also covers `cli/ci/run_local.py`, `cli/README.md`, and `cli/conformance/cases/fixtures/runner-help.txt` for test wiring and synchronized help. diff --git a/cli/rust/crates/prose-cli/src/main.rs b/cli/rust/crates/prose-cli/src/main.rs index 9a1a2e0..cd24484 100644 --- a/cli/rust/crates/prose-cli/src/main.rs +++ b/cli/rust/crates/prose-cli/src/main.rs @@ -279,6 +279,16 @@ fn prepare( _ => {} } + if parsed.globals.service_environment.is_some() { + if let Action::Runner { ref command, json } = parsed.action { + if matches!(command, RunnerCommand::AuthLogin | RunnerCommand::AuthStatus | RunnerCommand::AuthLogout | RunnerCommand::OrgList) { + let mode = if json { OutputMode::Json } else { parsed.globals.output.unwrap_or_default() }; + return prose_runner_core::service_account::execute(command, mode, cancellation); + } + } + return error_outcome(RunnerError::invocation("service environment requires an account or organization command"), error_mode, &clock, &ids); + } + let system = match SystemContext::capture() { Ok(system) => system, Err(error) => { diff --git a/cli/rust/crates/prose-runner-core/src/error.rs b/cli/rust/crates/prose-runner-core/src/error.rs index aa54423..ff35c16 100644 --- a/cli/rust/crates/prose-runner-core/src/error.rs +++ b/cli/rust/crates/prose-runner-core/src/error.rs @@ -129,6 +129,12 @@ pub enum ErrorCode { SemanticStatusUnknown, Cancelled, ProcessCleanupFailed, + ServiceUnavailable, + ServiceAuthRequired, + ServiceProtocolInvalid, + CredentialStoreUnavailable, + DeviceAuthFailed, + DeviceAuthExpired, HostedUnavailable, HostedAuthRequired, HostedQuotaExceeded, @@ -157,6 +163,12 @@ impl ErrorCode { Self::SemanticStatusUnknown => "SEMANTIC_STATUS_UNKNOWN", Self::Cancelled => "CANCELLED", Self::ProcessCleanupFailed => "PROCESS_CLEANUP_FAILED", + Self::ServiceUnavailable => "SERVICE_UNAVAILABLE", + Self::ServiceAuthRequired => "SERVICE_AUTH_REQUIRED", + Self::ServiceProtocolInvalid => "SERVICE_PROTOCOL_INVALID", + Self::CredentialStoreUnavailable => "CREDENTIAL_STORE_UNAVAILABLE", + Self::DeviceAuthFailed => "DEVICE_AUTH_FAILED", + Self::DeviceAuthExpired => "DEVICE_AUTH_EXPIRED", Self::HostedUnavailable => "HOSTED_UNAVAILABLE", Self::HostedAuthRequired => "HOSTED_AUTH_REQUIRED", Self::HostedQuotaExceeded => "HOSTED_QUOTA_EXCEEDED", @@ -171,6 +183,12 @@ impl ErrorCode { Self::HarnessUnavailable | Self::HarnessIncompatible | Self::HarnessNeedsAuth + | Self::ServiceUnavailable + | Self::ServiceAuthRequired + | Self::ServiceProtocolInvalid + | Self::CredentialStoreUnavailable + | Self::DeviceAuthFailed + | Self::DeviceAuthExpired | Self::HostedUnavailable | Self::HostedAuthRequired | Self::HostedQuotaExceeded => 10, @@ -241,6 +259,13 @@ impl RunnerError { #[must_use] pub fn catalog(code: ErrorCode) -> Self { let (boundary, message, action, retryable) = match code { + ErrorCode::ServiceUnavailable => ("hosted-service","The staging account service is unavailable.","Retry the staging account command later.",true), + ErrorCode::ServiceAuthRequired => ("authentication","Staging account authentication is required.","Run prose --service-environment staging cli auth login, then retry.",false), + ErrorCode::ServiceProtocolInvalid => ("protocol","The staging account service returned an invalid response.","Retry later and report the sanitized error code if it persists.",false), + ErrorCode::CredentialStoreUnavailable => ("authentication","The operating system credential store is unavailable.","Unlock or configure the operating system credential store, then retry.",false), + ErrorCode::DeviceAuthFailed => ("authentication","Device authorization failed.","Run the staging login command again and authorize the displayed code.",false), + ErrorCode::DeviceAuthExpired => ("authentication","Device authorization expired.","Run the staging login command again to obtain a new code.",true), + ErrorCode::ConfigInvalid => ( "configuration", "Runner configuration is invalid.", @@ -739,6 +764,12 @@ mod tests { ErrorCode::HostedUnavailable, ErrorCode::HostedAuthRequired, ErrorCode::HostedQuotaExceeded, + ErrorCode::ServiceUnavailable, + ErrorCode::ServiceAuthRequired, + ErrorCode::ServiceProtocolInvalid, + ErrorCode::CredentialStoreUnavailable, + ErrorCode::DeviceAuthFailed, + ErrorCode::DeviceAuthExpired, ErrorCode::InternalRunnerFault, ]; let expected = taxonomy["errors"].as_array().unwrap(); diff --git a/cli/rust/crates/prose-runner-core/src/installed_adapters.rs b/cli/rust/crates/prose-runner-core/src/installed_adapters.rs index 0356d1e..b008f15 100644 --- a/cli/rust/crates/prose-runner-core/src/installed_adapters.rs +++ b/cli/rust/crates/prose-runner-core/src/installed_adapters.rs @@ -2921,7 +2921,11 @@ pub fn environment_policy( "auth profile does not select exactly one adapter credential group", ) })?; - let ambient = ambient.into_iter().collect::>(); + // Remove service-only credentials from ambient storage as well as the + // closed allowlist, so later allow_inherited calls cannot recover them. + let ambient = ambient.into_iter() + .filter(|(name, _)| !name.to_string_lossy().eq_ignore_ascii_case("OPENPROSE_STAGING_API_KEY")) + .collect::>(); auth_readiness(adapter, auth_group, &ambient)?; let mut policy = EnvironmentPolicy::from_pairs(ambient); for name in BASE_ENVIRONMENT { @@ -2941,6 +2945,8 @@ pub(crate) fn version_probe_environment( _adapter: InstalledAdapter, ambient: impl IntoIterator, ) -> EnvironmentPolicy { + let ambient = ambient.into_iter() + .filter(|(name, _)| !name.to_string_lossy().eq_ignore_ascii_case("OPENPROSE_STAGING_API_KEY")); let mut policy = EnvironmentPolicy::from_pairs(ambient); for name in VERSION_PROBE_ENVIRONMENT { policy = policy.allow_inherited(*name, Sensitivity::Public); @@ -3573,6 +3579,20 @@ mod tests { .unwrap() } + #[test] + fn staging_service_token_cannot_be_reallowed_into_harness_or_probe() { + let secret = "rr_test_11111111111111111111111111111111"; + let ambient = || vec![(OsString::from("OPENPROSE_STAGING_API_KEY"), OsString::from(secret))]; + let adapter = InstalledAdapter::CodexExecJson; + let harness = environment_policy(adapter, adapter.default_probe_auth_group(), ambient()).unwrap(); + let probe = version_probe_environment(adapter, ambient()); + for policy in [harness, probe] { + let policy = policy.allow_inherited("OPENPROSE_STAGING_API_KEY", Sensitivity::Secret); + assert!(!policy.secret_strings().iter().any(|value| value == secret)); + assert!(!policy.output_protected_strings().iter().any(|value| value == secret)); + } + } + fn empty_environment(adapter: InstalledAdapter) -> EnvironmentPolicy { let ambient = match adapter { InstalledAdapter::AgentsSdkJsonl => vec![("OPENAI_API_KEY".into(),"fixture-secret".into())], diff --git a/cli/rust/crates/prose-runner-core/src/invocation.rs b/cli/rust/crates/prose-runner-core/src/invocation.rs index 56074b0..884d345 100644 --- a/cli/rust/crates/prose-runner-core/src/invocation.rs +++ b/cli/rust/crates/prose-runner-core/src/invocation.rs @@ -31,6 +31,7 @@ impl OutputMode { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct GlobalFlags { + pub service_environment: Option, pub harness: Option, pub transport: Option, pub cwd: Option, @@ -63,6 +64,7 @@ pub enum RunnerCommand { AuthStatus, AuthLogin, AuthLogout, + OrgList, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -97,6 +99,23 @@ pub struct ParsedInvocation { pub fn parse_invocation( args: impl IntoIterator, ) -> Result { + let parsed = parse_invocation_inner(args)?; + if parsed.globals.service_environment.is_some() { + let mut other = parsed.globals.clone(); + other.service_environment = None; other.output = None; other.no_color = false; other.verbose = false; + if other != GlobalFlags::default() || !matches!(parsed.action, + Action::Runner { command: RunnerCommand::AuthStatus | RunnerCommand::AuthLogin | RunnerCommand::AuthLogout | RunnerCommand::OrgList, .. }) { + return Err(RunnerError::invocation("service environment requires an account or organization command")); + } + } + if parsed.globals.service_environment.is_none() + && matches!(parsed.action, Action::Runner { command: RunnerCommand::OrgList, .. }) { + return Err(RunnerError::invocation("organization commands require the staging service environment")); + } + Ok(parsed) +} + +fn parse_invocation_inner(args: impl IntoIterator) -> Result { let args: Vec = args.into_iter().collect(); let mut globals = GlobalFlags::default(); let mut index = 0; @@ -211,7 +230,8 @@ fn forward(mut opaque: Vec) -> Action { fn is_value_option(value: &str) -> bool { matches!( value, - "--harness" + "--service-environment" + | "--harness" | "--transport" | "--cwd" | "--model" @@ -243,6 +263,10 @@ fn set_value_option(globals: &mut GlobalFlags, name: &str, value: &str) -> Resul ))); } match name { + "--service-environment" => { + if value != "staging" { return Err(RunnerError::invocation("service environment must be staging")); } + set_once(&mut globals.service_environment, name, value)?; + } "--harness" => globals.harness = Some(value.to_owned()), "--transport" => globals.transport = Some(value.to_owned()), "--cwd" => globals.cwd = Some(PathBuf::from(value)), @@ -312,6 +336,7 @@ fn parse_runner_command(args: &[String], globals: &mut GlobalFlags) -> Result { (RunnerCommand::ConfigExplain, tail) } + [org, list, tail @ ..] if org == "org" && list == "list" => (RunnerCommand::OrgList, tail), [auth, status, tail @ ..] if auth == "auth" && status == "status" => { (RunnerCommand::AuthStatus, tail) } @@ -397,7 +422,7 @@ fn known_runner_help_path(args: &[String]) -> bool { values.as_slice(), ["--help"] | [ - "doctor" | "harness" | "cleanup" | "config" | "auth", + "doctor" | "harness" | "cleanup" | "config" | "auth" | "org", "--help" ] | ["harness", "list" | "use", "--help"] @@ -406,6 +431,7 @@ fn known_runner_help_path(args: &[String]) -> bool { | ["cleanup", "prime", _, "--help"] | ["config", "explain", "--help"] | ["auth", "status" | "login" | "logout", "--help"] + | ["org", "list", "--help"] ) } diff --git a/cli/rust/crates/prose-runner-core/src/lib.rs b/cli/rust/crates/prose-runner-core/src/lib.rs index c77f451..91ed3aa 100644 --- a/cli/rust/crates/prose-runner-core/src/lib.rs +++ b/cli/rust/crates/prose-runner-core/src/lib.rs @@ -27,3 +27,5 @@ pub use prose_process_supervisor::{CancellationToken, SignalCancellationGuard}; pub use runtime::{Clock, IdSource, SystemClock, SystemIdSource}; pub mod kernel_startup; + +pub mod service_account; diff --git a/cli/rust/crates/prose-runner-core/src/runner.rs b/cli/rust/crates/prose-runner-core/src/runner.rs index ec68849..bca4cda 100644 --- a/cli/rust/crates/prose-runner-core/src/runner.rs +++ b/cli/rust/crates/prose-runner-core/src/runner.rs @@ -620,7 +620,7 @@ fn execute_runner_command( CommandOutcome::json(report, problem.exit_code) } } - RunnerCommand::AuthLogin | RunnerCommand::AuthLogout => { + RunnerCommand::AuthLogin | RunnerCommand::AuthLogout | RunnerCommand::OrgList => { error_outcome(hosted_unavailable(), mode, clock, ids) } } diff --git a/cli/rust/crates/prose-runner-core/src/service_account.rs b/cli/rust/crates/prose-runner-core/src/service_account.rs new file mode 100644 index 0000000..aaab589 --- /dev/null +++ b/cli/rust/crates/prose-runner-core/src/service_account.rs @@ -0,0 +1,515 @@ +//! Explicit staging account operations. Credentials never enter harness configuration. +use crate::{CancellationToken, OutputMode, RunnerCommand, RunnerError}; +use crate::error::ErrorCode; +use crate::output::CommandOutcome; +use serde_json::{json, Value}; +use std::io::{Read, Write}; +use std::time::{Duration, Instant}; + +const BASE: &str = "https://run-prose-staging.openprose.workers.dev"; +const LIMIT: u64 = 65_536; + +fn problem(code: ErrorCode) -> RunnerError { + RunnerError::catalog(code) +} + +fn valid_token(token: &str) -> bool { + token.strip_prefix("rr_test_").is_some_and(|v| v.len() == 32 && v.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))) +} + +struct Session { + fixture: Option, + next: usize, + cancellation: CancellationToken, + deadline: Option, +} + +impl Session { + + fn new(cancellation: &CancellationToken) -> Result { + #[allow(unused_mut)] + let mut fixture: Option = None; + #[cfg(feature="test-seams")] + if let Some(path) = std::env::var_os("PROSE_TEST_SERVICE_FIXTURE") { + let mut bytes = Vec::new(); + std::fs::File::open(path).map_err(|_| problem(ErrorCode::ServiceProtocolInvalid))?.take(LIMIT + 1).read_to_end(&mut bytes).map_err(|_| problem(ErrorCode::ServiceProtocolInvalid))?; + if bytes.len() as u64 > LIMIT { + return Err(problem(ErrorCode::ServiceProtocolInvalid)); + } + fixture = Some(serde_json::from_slice(&bytes).map_err(|_| problem(ErrorCode::ServiceProtocolInvalid))?); + } + if let Some(value) = &fixture { + if !value.is_object() + || !value["storeAvailable"].is_boolean() + || !value["exchanges"].as_array().is_some_and(|v| v.len() <= 182) + || !value.get("credential").is_some_and(|v| v.is_null() || v.is_string()) + { + return Err(problem(ErrorCode::ServiceProtocolInvalid)); + } + } + Ok(Self { + fixture, + next: 0, + deadline: None, + cancellation: cancellation.clone(), + }) + } + + fn check(&self) -> Result<(), RunnerError> { + if self.cancellation.is_cancelled() { + Err(problem(ErrorCode::Cancelled)) + } else if self.deadline.is_some_and(|deadline| Instant::now() >= deadline) { + Err(problem(ErrorCode::DeviceAuthExpired)) + } else { + Ok(()) + } + } + + fn transport_failure(&self) -> RunnerError { + self.check().err().unwrap_or_else(|| problem(ErrorCode::ServiceUnavailable)) + } + + fn store(&mut self, operation: &str, token: Option<&str>) -> Result, RunnerError> { + self.check()?; + if let Some(fixture) = self.fixture.as_mut() { + if fixture["storeAvailable"] == false { + return Err(problem(ErrorCode::CredentialStoreUnavailable)); + } + let previous = fixture["credential"].as_str().map(str::to_owned); + if operation == "set" { + fixture["credential"] = json!(token); + } + if operation == "delete" { + fixture["credential"] = Value::Null; + } + return Ok(previous); + } + native_store(operation, token, &self.cancellation) + } + + fn request( + &mut self, + method: &str, + path: &str, + token: Option<&str>, + body: Value, + ) -> Result { + self.check()?; + let (status, value) = if let Some(fixture) = self.fixture.as_ref() { + if (path == "/organizations") != token.is_some() || (path != "/organizations" && token.is_some()) { + return Err(problem(ErrorCode::ServiceProtocolInvalid)); + } + if let Some(token) = token { + let expected = std::env::var("OPENPROSE_STAGING_API_KEY").ok().or_else(|| fixture["credential"].as_str().map(str::to_owned)); + if expected.as_deref() != Some(token) { + return Err(problem(ErrorCode::ServiceProtocolInvalid)); + } + } + let exchange = fixture["exchanges"].get(self.next).ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; + self.next += 1; + if exchange["method"] != method || exchange["path"] != path { + return Err(problem(ErrorCode::ServiceProtocolInvalid)); + } + (exchange["status"].as_u64().unwrap_or(0), exchange["body"].clone()) + } else { + let timeout = self.deadline.map_or(Duration::from_secs(10), |deadline| deadline.saturating_duration_since(Instant::now()).min(Duration::from_secs(10))); + if timeout.is_zero() { + return Err(problem(ErrorCode::DeviceAuthExpired)); + } + let agent = ureq::AgentBuilder::new().timeout(timeout).redirects(0).build(); + let mut request = agent.request(method, &format!("{BASE}{path}")).set("Accept", "application/json"); + if let Some(token) = token { + request = request.set("Authorization", &format!("Bearer {token}")); + } + let result = if method == "POST" { + request.set("Content-Type", "application/json").send_string(&body.to_string()) + } else { + request.call() + }; + let response = match result { + Ok(r) | Err(ureq::Error::Status(_,r)) => r, Err(_) => return Err(self.transport_failure()) + }; + self.check()?; + let status = u64::from(response.status()); + if matches!(status,401|403) { + return Err(problem(ErrorCode::ServiceAuthRequired)); + } + if !(200..=299).contains(&status) && !(status == 400 && path.ends_with("/poll")) { + return Err(problem(ErrorCode::ServiceUnavailable)); + } + let mut bytes = Vec::new(); + response.into_reader().take(LIMIT+1).read_to_end(&mut bytes).map_err(|_| self.transport_failure())?; + self.check()?; + if bytes.len() as u64 > LIMIT { + return Err(problem(ErrorCode::ServiceProtocolInvalid)); + } + (status, serde_json::from_slice(&bytes).map_err(|_| problem(ErrorCode::ServiceProtocolInvalid))?) + }; + self.check()?; + match status { + 200..=299 => Ok(value), 401 | 403 => Err(problem(ErrorCode::ServiceAuthRequired)), 400 if path.ends_with("/poll") => Err(problem(if value["error"] == "expired_token" { + ErrorCode::DeviceAuthExpired + } else { + ErrorCode::DeviceAuthFailed + })), _ => Err(problem(ErrorCode::ServiceUnavailable)) + } + } + + fn pause(&self, seconds: u64) -> Result<(), RunnerError> { + if let Some(fixture) = &self.fixture { + if fixture["cancelBeforePoll"] == true { + return Err(problem(ErrorCode::Cancelled)); + } + return self.check(); + } + let deadline = Instant::now() + Duration::from_secs(seconds); + while Instant::now() < deadline { + self.check()?; + std::thread::sleep(Duration::from_millis(50)); + } + self.check() + } +} + +fn organizations(value: Value) -> Result { + let entries = value["organizations"].as_array().ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; + if entries.len()>1000 { + return Err(problem(ErrorCode::ServiceProtocolInvalid)); + } + let mut output = Vec::new(); + for entry in entries { + let mut row = json!({ + }); + for field in ["id", "slug", "name"] { + let text = entry[field].as_str().filter(|s| !s.is_empty() && s.chars().count()<=4096 && !s.chars().any(|c| c <= '\u{1f}' || c == '\u{7f}')).ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; + row[field] = json!(text); + } + if let Some(role) = entry.get("role") { + if !matches!(role.as_str(),Some("admin"|"developer"|"reader")) { + return Err(problem(ErrorCode::ServiceProtocolInvalid)); + } + row["role"] = role.clone(); + } + output.push(row); + } + Ok(json!(output)) +} +/// Executes an account operation with a bounded staging transport. +#[must_use] +pub fn execute( + command: &RunnerCommand, + mode: OutputMode, + cancellation: &CancellationToken, +) -> CommandOutcome { + let operation = match command { + RunnerCommand::AuthLogin => "login", RunnerCommand::AuthLogout => "logout", RunnerCommand::OrgList => "list", _ => "status" + }; + let mut source = "none"; + let mut authenticated = false; + let mut rows = json!([]); + let result = (|| -> Result<(),RunnerError> { + let mut session = Session::new(cancellation)?; + let environment = std::env::var("OPENPROSE_STAGING_API_KEY").ok().filter(|s| !s.is_empty()); + if environment.is_some() && matches!(operation,"login"|"logout") { + return Err(RunnerError::invocation("Environment credentials cannot be changed by login or logout.")); + } + if operation == "logout" { + session.store("delete",None)?; + return Ok(()); + } + if operation == "login" { + session.store("get",None)?; + let start = session.request("POST","/auth/device",None,json!({ + }))?; + let code = start["device_code"].as_str().filter(|s| !s.is_empty() && s.len()<=1024).ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?.to_owned(); + let user = start["user_code"].as_str().filter(|s| !s.is_empty() && s.len()<=32 && s.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'-') && !s.contains(&code)).ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; + if start["verification_uri"] != "https://github.com/login/device" { + return Err(problem(ErrorCode::ServiceProtocolInvalid)); + } + let expiry = start["expires_in"].as_u64().filter(|v| (1..=900).contains(v)).ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; + let mut interval = start["interval"].as_u64().filter(|v| (1..=30).contains(v)).ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; + eprintln!("Go to https://github.com/login/device and enter code: {user}"); + let began = Instant::now(); + session.deadline = Some(began + Duration::from_secs(expiry)); + let mut elapsed = 0; + for _ in 0..180 { + elapsed += interval; + if elapsed >= expiry || began.elapsed().as_secs() >= expiry { + return Err(problem(ErrorCode::DeviceAuthExpired)); + } + session.pause(interval)?; + if began.elapsed().as_secs() >= expiry { + return Err(problem(ErrorCode::DeviceAuthExpired)); + } + let poll = session.request("POST","/auth/device/poll",None,json!({ + "device_code":code + }))?; + if began.elapsed().as_secs() >= expiry { + return Err(problem(ErrorCode::DeviceAuthExpired)); + } + match poll["status"].as_str() { + Some("pending") => { + }, + Some("slow_down") => interval = (interval+5).min(30), + Some("complete") => { + let token = poll["api_key"].as_str().filter(|s| valid_token(s)).ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; + session.store("set",Some(token))?; + if session.store("get",None)?.as_deref() != Some(token) { + return Err(problem(ErrorCode::CredentialStoreUnavailable)); + } + source = "os-credential-store"; + authenticated = true; + return Ok(()); + }, + Some("error") if poll["error"] == "expired_token" => return Err(problem(ErrorCode::DeviceAuthExpired)), + Some("error") => return Err(problem(ErrorCode::DeviceAuthFailed)), + _ => return Err(problem(ErrorCode::ServiceProtocolInvalid)), + } + } + return Err(problem(ErrorCode::DeviceAuthExpired)); + } + let token = if let Some(token) = environment { + source = "environment"; + Some(token) + } else { + let token = session.store("get",None)?; + if token.is_some() { + source = "os-credential-store"; + } + token + }; + if let Some(token) = token { + if !valid_token(&token) { + return Err(problem(ErrorCode::ServiceProtocolInvalid)); + } + rows = organizations(session.request("GET","/organizations",Some(&token),Value::Null)?)?; + if rows.as_array().is_some_and(|entries| entries.iter().any(|entry| ["id","slug","name"].iter().any(|key| entry[*key].as_str().is_some_and(|value| value.contains(&token))))) { + rows = json!([]); + return Err(problem(ErrorCode::ServiceProtocolInvalid)); + } + authenticated = true; + } else if operation == "list" { + return Err(problem(ErrorCode::ServiceAuthRequired)); + } + Ok(()) + })(); + let error = result.err(); + if error.is_some() { + source = "none"; + } + let exit = error.as_ref().map_or(0,|e| e.exit_code); + let report = if operation == "list" { + json!({ + "schema":"openprose.organization-list/1","environment":"staging","organizations":rows,"problem":error + }) + } else { + json!({ + "schema":"openprose.service-account/1","environment":"staging","operation":operation,"authenticated":authenticated,"credentialSource":source,"problem":error + }) + }; + if mode == OutputMode::Human { + if let Some(error) = error { + CommandOutcome::human("",format!("{}: {}\n",error.code,error.message),exit) + } + else if operation == "list" { + CommandOutcome::human(format!("{}\n",rows),"",0) + } + else { + CommandOutcome::human(format!("Staging account {operation}: {}\n", if authenticated { + "authenticated" + } else { + "signed out" + }),"",0) + } + } else { + CommandOutcome::json(report,exit) + } +} +#[cfg(not(target_os = "macos"))] + +fn native_store(_: &str, _: Option<&str>, _: &CancellationToken) -> Result, RunnerError> { + Err(problem(ErrorCode::CredentialStoreUnavailable)) +} +#[cfg(target_os = "macos")] + +fn native_store( + operation: &str, + token: Option<&str>, + cancellation: &CancellationToken, +) -> Result, RunnerError> { + use std::process::{ + Command, Stdio + }; + // No shell or token argv. Interactive security command parsing receives only + // fixed commands and a closed ASCII token alphabet over an anonymous pipe. + let arguments = "-s org.openprose.cli.staging -a api-key"; + let script = match operation { + "get" => format!("find-generic-password {arguments} -w\n"), + "delete" => format!("delete-generic-password {arguments}\n"), + "set" => { + let token = token.filter(|s| valid_token(s)).ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; + format!("add-generic-password -U {arguments} -w {token}\n") + }, + _ => return Err(problem(ErrorCode::CredentialStoreUnavailable)), + }; + let mut child = Command::new("/usr/bin/security").arg("-i").env_clear() + .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn() + .map_err(|_| problem(ErrorCode::CredentialStoreUnavailable))?; + let stdout = child.stdout.take().expect("piped stdout"); + let stderr = child.stderr.take().expect("piped stderr"); + let read = |pipe: Box| std::thread::spawn(move || { + let mut bytes = Vec::new(); + let result = pipe.take(8193).read_to_end(&mut bytes); + (result.is_ok() && bytes.len() <= 8192, bytes) + }); + let out_reader = read(Box::new(stdout)); + let err_reader = read(Box::new(stderr)); + let write_ok = child.stdin.take().is_some_and(|mut stdin| stdin.write_all(script.as_bytes()).is_ok()); + let deadline = Instant::now()+Duration::from_secs(10); + let mut failed = !write_ok; + loop { + if cancellation.is_cancelled() || Instant::now() >= deadline || failed { + failed = true; + let _ = child.kill(); + let _ = child.wait(); + break; + } + match child.try_wait() { + Ok(Some(status)) => { + failed = !status.success(); + break; + }, + Ok(None) => std::thread::sleep(Duration::from_millis(25)), + Err(_) => { + failed = true; + let _=child.kill(); + let _=child.wait(); + break; + }, + } + } + let (out_ok, stdout) = out_reader.join().unwrap_or_default(); + let (err_ok, stderr) = err_reader.join().unwrap_or_default(); + if cancellation.is_cancelled() { + return Err(problem(ErrorCode::Cancelled)); + } + if !out_ok || !err_ok { + return Err(problem(ErrorCode::CredentialStoreUnavailable)); + } + // security -i may exit zero after a failed subcommand. Inspect only fixed + // error markers; never propagate its output or captured command echo. + let diagnostic = String::from_utf8_lossy(&stderr); + if diagnostic.contains("could not be found") && matches!(operation,"get"|"delete") { + return Ok(None); + } + if failed || diagnostic.contains("SecKeychain") || diagnostic.contains("SecItem") || diagnostic.contains("error:") { + return Err(problem(ErrorCode::CredentialStoreUnavailable)); + } + if operation == "get" { + let output = String::from_utf8(stdout).map_err(|_| problem(ErrorCode::CredentialStoreUnavailable))?; + let token = output.trim(); + if !valid_token(token) { + return Err(problem(ErrorCode::CredentialStoreUnavailable)); + } + Ok(Some(token.to_owned())) + } else { + Ok(None) + } +} +#[cfg(test)] +mod tests { + #[test] + fn transport_failures_prioritize_cancellation_then_expiry() { + let cancel = CancellationToken::default(); + let mut session = Session { fixture: None, next: 0, deadline: None, cancellation: cancel.clone() }; + assert_eq!(session.transport_failure().code, ErrorCode::ServiceUnavailable); + session.deadline = Some(Instant::now()); + assert_eq!(session.transport_failure().code, ErrorCode::DeviceAuthExpired); + cancel.cancel(); + assert_eq!(session.transport_failure().code, ErrorCode::Cancelled); + } + + use super::*; + #[test] + + fn token_alphabet_cannot_inject_native_commands() { + assert!(valid_token("rr_test_11111111111111111111111111111111")); + for token in ["", "fixture", "rr_live_11111111111111111111111111111111", "rr_test_11111111111111111111111111111111\nquit", "rr_test_1111111111111111111111111111111\""] { + assert!(!valid_token(token)); + } + } + #[test] + + fn fixture_transport_fails_closed_on_missing_or_wrong_requests() { + let mut session = Session { + fixture: Some(json!({ + "credential":null,"storeAvailable":true,"exchanges":[] + })), next:0, deadline:None, cancellation:CancellationToken::default() + }; + assert_eq!(session.request("POST","/auth/device",None,json!({ + })).unwrap_err().code,ErrorCode::ServiceProtocolInvalid); + assert_eq!(session.request("GET","/organizations",None,Value::Null).unwrap_err().code,ErrorCode::ServiceProtocolInvalid); + session.fixture = Some(json!({ + "credential":"expected","exchanges":[{ + "method":"GET","path":"/organizations","status":200,"body":{ + "organizations":[] + } + }] + })); + assert_eq!(session.request("GET","/organizations",Some("wrong"),Value::Null).unwrap_err().code,ErrorCode::ServiceProtocolInvalid); + } + #[test] + + fn unknown_organization_roles_and_controls_fail_closed() { + for role in ["owner", "root"] { + assert!(organizations(json!({ + "organizations":[{ + "id":"id","slug":"slug","name":"name","role":role + }] + })).is_err()); + } + assert!(organizations(json!({ + "organizations":[{ + "id":"id","slug":"slug","name":"\u{1b}" + }] + })).is_err()); + } + #[test] + + fn cancellation_precedes_fixture_store_and_transport() { + let cancel = CancellationToken::default(); + cancel.cancel(); + let mut session = Session { + fixture: Some(json!({ + "credential":null,"storeAvailable":true,"exchanges":[] + })), next:0, deadline:None, cancellation:cancel + }; + assert_eq!(session.store("get",None).unwrap_err().code,ErrorCode::Cancelled); + assert_eq!(session.request("POST","/auth/device",None,json!({ + })).unwrap_err().code,ErrorCode::Cancelled); + } + #[test] + + fn fixture_store_is_in_memory_and_logout_is_idempotent() { + let mut session = Session { + fixture: Some(json!({ + "credential":null,"storeAvailable":true,"exchanges":[] + })), next:0, deadline:None, cancellation:CancellationToken::default() + }; + session.store("set",Some("test-only")).unwrap(); + assert_eq!(session.store("get",None).unwrap().as_deref(),Some("test-only")); + session.store("delete",None).unwrap(); + session.store("delete",None).unwrap(); + assert!(session.store("get",None).unwrap().is_none()); + } + #[test] + + fn staging_flag_preserves_language_boundary_and_rejects_other_operations() { + let parse = |args: &[&str]| crate::parse_invocation(args.iter().map(|s| s.to_string())); + assert!(parse(&["--service-environment","staging","cli","org","list"]).is_ok()); + assert!(parse(&["--service-environment","staging","cli","doctor"]).is_err()); + assert!(parse(&["--service-environment","production","cli","auth","status"]).is_err()); + let parsed = parse(&["run","--service-environment","staging"]).unwrap(); + assert!(parsed.globals.service_environment.is_none()); + } +} diff --git a/cli/shared/errors/taxonomy.v1.json b/cli/shared/errors/taxonomy.v1.json index 0256e00..b66f8fd 100644 --- a/cli/shared/errors/taxonomy.v1.json +++ b/cli/shared/errors/taxonomy.v1.json @@ -168,6 +168,54 @@ "retryable": false, "message": "The runner encountered an internal fault.", "action": "Retry with `--verbose` and report the sanitized diagnostic identifier." + }, + { + "code": "SERVICE_UNAVAILABLE", + "boundary": "hosted-service", + "exitCode": 10, + "retryable": true, + "message": "The staging account service is unavailable.", + "action": "Retry the staging account command later." + }, + { + "code": "SERVICE_AUTH_REQUIRED", + "boundary": "authentication", + "exitCode": 10, + "retryable": false, + "message": "Staging account authentication is required.", + "action": "Run prose --service-environment staging cli auth login, then retry." + }, + { + "code": "SERVICE_PROTOCOL_INVALID", + "boundary": "protocol", + "exitCode": 10, + "retryable": false, + "message": "The staging account service returned an invalid response.", + "action": "Retry later and report the sanitized error code if it persists." + }, + { + "code": "CREDENTIAL_STORE_UNAVAILABLE", + "boundary": "authentication", + "exitCode": 10, + "retryable": false, + "message": "The operating system credential store is unavailable.", + "action": "Unlock or configure the operating system credential store, then retry." + }, + { + "code": "DEVICE_AUTH_FAILED", + "boundary": "authentication", + "exitCode": 10, + "retryable": false, + "message": "Device authorization failed.", + "action": "Run the staging login command again and authorize the displayed code." + }, + { + "code": "DEVICE_AUTH_EXPIRED", + "boundary": "authentication", + "exitCode": 10, + "retryable": true, + "message": "Device authorization expired.", + "action": "Run the staging login command again to obtain a new code." } ] } diff --git a/cli/shared/schemas/organization-list.schema.json b/cli/shared/schemas/organization-list.schema.json new file mode 100644 index 0000000..35f62ac --- /dev/null +++ b/cli/shared/schemas/organization-list.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.openprose.org/cli/v1/organization-list.schema.json", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "environment", + "organizations", + "problem" + ], + "properties": { + "schema": { + "const": "openprose.organization-list/1" + }, + "environment": { + "const": "staging" + }, + "organizations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "slug", + "name" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "slug": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "role": { + "enum": [ + "admin", + "developer", + "reader" + ] + } + } + } + }, + "problem": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "runner-error.schema.json" + } + ] + } + } +} diff --git a/cli/shared/schemas/runner-error.schema.json b/cli/shared/schemas/runner-error.schema.json index d186b75..b9a10aa 100644 --- a/cli/shared/schemas/runner-error.schema.json +++ b/cli/shared/schemas/runner-error.schema.json @@ -4,36 +4,103 @@ "title": "OpenProse normalized runner error v1", "type": "object", "additionalProperties": false, - "required": ["schema", "code", "boundary", "message", "action", "exitCode", "retryable"], + "required": [ + "schema", + "code", + "boundary", + "message", + "action", + "exitCode", + "retryable" + ], "properties": { - "schema": { "const": "openprose.runner-error/1" }, + "schema": { + "const": "openprose.runner-error/1" + }, "code": { "type": "string", "enum": [ - "CONFIG_INVALID", "INVOCATION_INVALID", "HARNESS_UNAVAILABLE", "HARNESS_INCOMPATIBLE", - "HARNESS_NEEDS_AUTH", "TRANSPORT_UNSUPPORTED", - "PROMPT_CHANNEL_UNSUPPORTED", "IMAGE_INVALID", "IMAGE_TOO_LARGE", - "RECURSIVE_INVOCATION", "STARTUP_TIMEOUT", "PROTOCOL_MALFORMED", - "PROTOCOL_TRUNCATED", "HARNESS_FAILED", "SEMANTIC_STATUS_UNKNOWN", - "CANCELLED", "PROCESS_CLEANUP_FAILED", "HOSTED_UNAVAILABLE", - "HOSTED_AUTH_REQUIRED", "HOSTED_QUOTA_EXCEEDED", "INTERNAL_ERROR" + "CONFIG_INVALID", + "INVOCATION_INVALID", + "HARNESS_UNAVAILABLE", + "HARNESS_INCOMPATIBLE", + "HARNESS_NEEDS_AUTH", + "TRANSPORT_UNSUPPORTED", + "PROMPT_CHANNEL_UNSUPPORTED", + "IMAGE_INVALID", + "IMAGE_TOO_LARGE", + "RECURSIVE_INVOCATION", + "STARTUP_TIMEOUT", + "PROTOCOL_MALFORMED", + "PROTOCOL_TRUNCATED", + "HARNESS_FAILED", + "SEMANTIC_STATUS_UNKNOWN", + "CANCELLED", + "PROCESS_CLEANUP_FAILED", + "HOSTED_UNAVAILABLE", + "HOSTED_AUTH_REQUIRED", + "HOSTED_QUOTA_EXCEEDED", + "INTERNAL_ERROR", + "SERVICE_UNAVAILABLE", + "SERVICE_AUTH_REQUIRED", + "SERVICE_PROTOCOL_INVALID", + "CREDENTIAL_STORE_UNAVAILABLE", + "DEVICE_AUTH_FAILED", + "DEVICE_AUTH_EXPIRED" ] }, "boundary": { "type": "string", - "enum": ["invocation", "configuration", "image", "adapter", "authentication", "hosted-service", "process", "protocol", "semantic-terminal", "cleanup", "runner"] + "enum": [ + "invocation", + "configuration", + "image", + "adapter", + "authentication", + "hosted-service", + "process", + "protocol", + "semantic-terminal", + "cleanup", + "runner" + ] + }, + "message": { + "$ref": "common.schema.json#/$defs/nonEmptyString" + }, + "action": { + "$ref": "common.schema.json#/$defs/nonEmptyString" + }, + "exitCode": { + "type": "integer", + "enum": [ + 2, + 10, + 20, + 21, + 22, + 23, + 24, + 25, + 70 + ] + }, + "retryable": { + "type": "boolean" }, - "message": { "$ref": "common.schema.json#/$defs/nonEmptyString" }, - "action": { "$ref": "common.schema.json#/$defs/nonEmptyString" }, - "exitCode": { "type": "integer", "enum": [2, 10, 20, 21, 22, 23, 24, 25, 70] }, - "retryable": { "type": "boolean" }, "details": { "description": "Sanitized mechanical details only; secrets and raw image/task content are forbidden by policy.", "type": "object", "properties": { - "adapterDiagnostic": { "$ref": "adapter-diagnostic.schema.json" }, - "nativeFailure": { "$ref": "native-failure.schema.json" }, - "transportDiagnostic": { "$ref": "transport-diagnostic.schema.json" }, + "adapterDiagnostic": { + "$ref": "adapter-diagnostic.schema.json" + }, + "nativeFailure": { + "$ref": "native-failure.schema.json" + }, + "transportDiagnostic": { + "$ref": "transport-diagnostic.schema.json" + }, "cleanupHandle": { "type": "string", "minLength": 1, @@ -43,9 +110,15 @@ "cleanupArgv": { "type": "array", "prefixItems": [ - { "const": "cli" }, - { "const": "cleanup" }, - { "const": "prime" }, + { + "const": "cli" + }, + { + "const": "cleanup" + }, + { + "const": "prime" + }, { "type": "string", "minLength": 1, @@ -56,38 +129,258 @@ "minItems": 4, "maxItems": 4 }, - "sensitiveFilesRemoved": { "const": true }, - "runtimePrerequisite": { "$ref": "common.schema.json#/$defs/runtimePrerequisiteObservation" } + "sensitiveFilesRemoved": { + "const": true + }, + "runtimePrerequisite": { + "$ref": "common.schema.json#/$defs/runtimePrerequisiteObservation" + } } } }, "allOf": [ { "if": { - "properties": { "code": { "const": "CONFIG_INVALID" } }, - "required": ["code"] + "properties": { + "code": { + "const": "CONFIG_INVALID" + } + }, + "required": [ + "code" + ] }, "then": { "properties": { - "boundary": { "const": "configuration" }, - "message": { "const": "Runner configuration is invalid." }, - "action": { "const": "Correct or remove the reported configuration source or setting, then invoke the `cli config explain` runner operation to verify the repair." }, - "exitCode": { "const": 2 }, - "retryable": { "const": false } + "boundary": { + "const": "configuration" + }, + "message": { + "const": "Runner configuration is invalid." + }, + "action": { + "const": "Correct or remove the reported configuration source or setting, then invoke the `cli config explain` runner operation to verify the repair." + }, + "exitCode": { + "const": 2 + }, + "retryable": { + "const": false + } } } }, { "if": { - "properties": { "code": { "const": "INVOCATION_INVALID" } }, - "required": ["code"] + "properties": { + "code": { + "const": "INVOCATION_INVALID" + } + }, + "required": [ + "code" + ] + }, + "then": { + "properties": { + "boundary": { + "const": "invocation" + }, + "message": { + "const": "Runner invocation is invalid." + }, + "exitCode": { + "const": 2 + }, + "retryable": { + "const": false + } + } + } + }, + { + "if": { + "properties": { + "code": { + "const": "SERVICE_UNAVAILABLE" + } + }, + "required": [ + "code" + ] + }, + "then": { + "properties": { + "boundary": { + "const": "hosted-service" + }, + "exitCode": { + "const": 10 + }, + "retryable": { + "const": true + }, + "message": { + "const": "The staging account service is unavailable." + }, + "action": { + "const": "Retry the staging account command later." + } + } + } + }, + { + "if": { + "properties": { + "code": { + "const": "SERVICE_AUTH_REQUIRED" + } + }, + "required": [ + "code" + ] + }, + "then": { + "properties": { + "boundary": { + "const": "authentication" + }, + "exitCode": { + "const": 10 + }, + "retryable": { + "const": false + }, + "message": { + "const": "Staging account authentication is required." + }, + "action": { + "const": "Run prose --service-environment staging cli auth login, then retry." + } + } + } + }, + { + "if": { + "properties": { + "code": { + "const": "SERVICE_PROTOCOL_INVALID" + } + }, + "required": [ + "code" + ] + }, + "then": { + "properties": { + "boundary": { + "const": "protocol" + }, + "exitCode": { + "const": 10 + }, + "retryable": { + "const": false + }, + "message": { + "const": "The staging account service returned an invalid response." + }, + "action": { + "const": "Retry later and report the sanitized error code if it persists." + } + } + } + }, + { + "if": { + "properties": { + "code": { + "const": "CREDENTIAL_STORE_UNAVAILABLE" + } + }, + "required": [ + "code" + ] + }, + "then": { + "properties": { + "boundary": { + "const": "authentication" + }, + "exitCode": { + "const": 10 + }, + "retryable": { + "const": false + }, + "message": { + "const": "The operating system credential store is unavailable." + }, + "action": { + "const": "Unlock or configure the operating system credential store, then retry." + } + } + } + }, + { + "if": { + "properties": { + "code": { + "const": "DEVICE_AUTH_FAILED" + } + }, + "required": [ + "code" + ] + }, + "then": { + "properties": { + "boundary": { + "const": "authentication" + }, + "exitCode": { + "const": 10 + }, + "retryable": { + "const": false + }, + "message": { + "const": "Device authorization failed." + }, + "action": { + "const": "Run the staging login command again and authorize the displayed code." + } + } + } + }, + { + "if": { + "properties": { + "code": { + "const": "DEVICE_AUTH_EXPIRED" + } + }, + "required": [ + "code" + ] }, "then": { "properties": { - "boundary": { "const": "invocation" }, - "message": { "const": "Runner invocation is invalid." }, - "exitCode": { "const": 2 }, - "retryable": { "const": false } + "boundary": { + "const": "authentication" + }, + "exitCode": { + "const": 10 + }, + "retryable": { + "const": true + }, + "message": { + "const": "Device authorization expired." + }, + "action": { + "const": "Run the staging login command again to obtain a new code." + } } } } diff --git a/cli/shared/schemas/service-account.schema.json b/cli/shared/schemas/service-account.schema.json new file mode 100644 index 0000000..73774e9 --- /dev/null +++ b/cli/shared/schemas/service-account.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.openprose.org/cli/v1/service-account.schema.json", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "environment", + "operation", + "authenticated", + "credentialSource", + "problem" + ], + "properties": { + "schema": { + "const": "openprose.service-account/1" + }, + "environment": { + "const": "staging" + }, + "operation": { + "enum": [ + "login", + "status", + "logout" + ] + }, + "authenticated": { + "type": "boolean" + }, + "credentialSource": { + "enum": [ + "os-credential-store", + "environment", + "none" + ] + }, + "problem": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "runner-error.schema.json" + } + ] + } + } +} diff --git a/cli/shared/tests/test_staging_contract.py b/cli/shared/tests/test_staging_contract.py new file mode 100644 index 0000000..bb43a60 --- /dev/null +++ b/cli/shared/tests/test_staging_contract.py @@ -0,0 +1,29 @@ +"""Validate shared staging examples independently of either product.""" +import json +from pathlib import Path +import unittest +import test_contracts + +CLI = Path(__file__).resolve().parents[2] + + +class StagingContractTest(unittest.TestCase): + setUpClass = classmethod(test_contracts.ContractsTest.setUpClass.__func__) + validator = test_contracts.ContractsTest.validator + assert_valid = test_contracts.ContractsTest.assert_valid + def test_staging_corpus(self): + corpus = json.loads((CLI/'conformance/runner/staging-service-corpus.json').read_text()) + taxonomy = {e['code']: e for e in json.loads((CLI/'shared/errors/taxonomy.v1.json').read_text())['errors']} + for case in corpus['cases']: + result = case['resultMatches'].copy() + if result['problem']: + result['problem'] = {'schema': 'openprose.runner-error/1', **taxonomy[result['problem']['code']]} + schema = 'organization-list' if result['schema'] == 'openprose.organization-list/1' else 'service-account' + self.assert_valid(schema+'.schema.json', result) + result['api_key'] = 'must be rejected' + self.assertTrue(list(self.validator(schema+'.schema.json').iter_errors(result))) + self.assertEqual(len(corpus['cases']), len({c['id'] for c in corpus['cases']})) + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/staging-account.md b/docs/staging-account.md new file mode 100644 index 0000000..dd91388 --- /dev/null +++ b/docs/staging-account.md @@ -0,0 +1,38 @@ +# Connect the CLI to staging + +This feature is being developed under IMP-034. It is not included in an existing published release merely because this document exists. Use a candidate executable built from the same reviewed revision. + +Staging is an explicit service environment. Local harness execution and model-provider credentials are separate from the OpenProse service account. + +## Account connection + +The intended command sequence is: + +```sh +prose --service-environment staging cli auth login +prose --service-environment staging cli auth status --json +prose --service-environment staging cli org list --json +prose --service-environment staging cli auth logout +``` + +Login starts the existing service's GitHub device flow. Follow the verification URL and enter the displayed code in your browser. The CLI waits within the authorization expiry and reports cancellation or failure. Staging admission remains controlled by the backend's account allowlist. + +Local credentials belong in operating-system credential storage, identified separately from production credentials and from credentials used by installed agent harnesses. If a supported credential store is unavailable, login must fail explicitly rather than save the key in plaintext. Logging out removes the selected local credential; it does not revoke every account session. + +For automation, the staging-specific `OPENPROSE_STAGING_API_KEY` supplies a previously issued service credential. Supply it through your CI secret facility. It takes precedence over the local credential store. Do not put the value in shell history, source files, command arguments, or issue reports. Removing a local credential cannot unset a credential supplied by the parent environment. + +## Scope + +This connection supports account authentication and organization discovery. It does not enable hosted execution or contract publication. Registry upload and exact-version retrieval are separate IMP-034 delivery steps. + +Status verifies an available credential through the organization API. That endpoint may create the account's default organization on first use. A missing credential is reported as signed out. A failed request must not silently select another environment, use a model-provider key, or fall back to local execution. + +## Verification + +Rust and Bun use the same shared conformance cases with a fake service. Those tests do not call GitHub, Cloudflare, or model providers. Live staging checks are explicit and use a separately provisioned account; browser authorization still requires the account owner's participation. Test credentials must not appear in retained output. + +## Current platform limits + +The candidate Rust implementation uses macOS Keychain through a bounded native helper. Its local credential-store operation is unavailable on other platforms; use the scoped staging environment credential there. Bun uses its native credential-store API where the operating system supports it. Cross-platform native-store parity has not yet been qualified. Neither implementation falls back to a plaintext file. + +Native credential-store operations may request operating-system approval. If a store operation times out, inspect account status before retrying: an underlying operation that cannot be cancelled may complete after the CLI reports the timeout. Device login still requires browser approval; a hermetic login test does not prove that live approval path. diff --git a/docs/validation/imp-034/README.md b/docs/validation/imp-034/README.md new file mode 100644 index 0000000..c753830 --- /dev/null +++ b/docs/validation/imp-034/README.md @@ -0,0 +1,38 @@ +# IMP-034 staging account candidate validation + +This record covers explicit public CLI staging authentication and organization listing. It does not establish registry publication, hosted execution, production readiness, or public release availability. + +## Passing checks + +- Shared process corpus: 26 cases passed independently against the Rust test-seam executable and the compiled Bun test-seam executable. +- Shared result schemas: Ajv validated all 26 expected result shapes and rejected 26 additional secret-field properties. +- Bun 1.3.5: typecheck passed; 35 focused parser, service and help tests passed. +- Rust: seven staging unit tests, fifteen parser tests, taxonomy parity, and the service-token harness/probe isolation regression passed. Default and test-seam builds passed using cached dependencies. +- Ordinary Rust and Bun binaries ignored the test fixture variable in a no-network invocation. No test fixture may select an alternate service in an ordinary build. +- Direct `cli/ci/check_architecture.py` and Git whitespace validation passed. +- Independent Astra review identified and prompted fixes for credential reflection, inconsistent response predicates, malformed fixtures, HTTP error classification, cancellation precedence, and device expiry handling. + +## Reproduction + +Use the pinned tools and dependency setup in `cli/CONTRIBUTING.md`. The normal local admission runner includes `staging-service-corpus`, `staging-service-rust-build`, `staging-service-rust`, `staging-service-bun-build`, and `staging-service-bun` gates. Run the build gates before their corresponding process gates when selecting individual gates. + +The standalone corpus runner accepts a compiled test executable: + +```sh +python3 cli/conformance/runner/staging_service.py --validate +python3 cli/conformance/runner/staging_service.py -- /absolute/path/to/test-seam/prose +``` + +Every case uses an isolated home directory and a closed fake service transcript without live credentials. Fixture handling is compiled out of ordinary builds. + +## Limits and outstanding qualification + +No live account login, OS credential-store mutation, or authenticated staging request was performed for this CLI candidate. The browser approval flow and cross-implementation native credential-store interoperability still need explicit live qualification. + +Rust local storage currently supports macOS; other platforms fail closed and may use the scoped staging environment credential. Bun delegates to its operating-system credential API. Native storage operations can request OS approval. A timed-out operation that cannot be cancelled may finish later; status should be checked before retrying. + +The full Bun suite reported 556 passes, two skips and 25 failures in this execution environment: socket permission failures, a FIFO-related subprocess warning, and temporary-path output assertions. An initial run with incomplete PATH had additional failures; the counts above are from the corrected PATH run. An exact baseline comparison remains outstanding. The broader Rust suite also had socket permission and temporary-path failures. These results are not a claim that the full suites passed or that every failure is unrelated. + +The broad architecture unit gate also failed; the direct architecture checker passed. Python JSON Schema tests were unavailable because the required Python dependency could not be downloaded; the Ajv checks are recorded separately. Rustfmt and Clippy were not installed. Full normal admission and supported-platform CI remain required before merge/release. + +No model-provider calls, paid runs, backend edits, deployment or publication were performed. From bc76a3d29aa859202c90afb16629227b1907cac5 Mon Sep 17 00:00:00 2001 From: mm Date: Mon, 21 Sep 2026 15:07:08 -0400 Subject: [PATCH 2/5] fix: repair qualification tests and CLI repository metadata --- .../openprose-cli-benchmark-profile.yml | 154 ++++++ .github/ISSUE_TEMPLATE/openprose-cli-bug.yml | 110 +++++ .../openprose-cli-harness-model.yml | 132 +++++ cli/CONTRIBUTING.md | 13 +- cli/SUPPORT.md | 8 +- cli/bun/test/output.test.ts | 8 +- cli/ci/check_alpha_public_docs.py | 6 +- cli/ci/create_draft_release.py | 4 +- cli/ci/test_check_alpha_public_docs.py | 50 +- cli/ci/test_check_contributor_docs.py | 10 +- cli/protocol/OWNERSHIP.md | 12 + cli/rust/crates/prose-cli/src/main.rs | 23 +- .../crates/prose-runner-core/src/error.rs | 50 +- .../prose-runner-core/src/service_account.rs | 457 +++++++++++++----- cli/shared/tests/test_contracts.py | 2 + docs/validation/imp-034/README.md | 13 +- 16 files changed, 873 insertions(+), 179 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/openprose-cli-benchmark-profile.yml create mode 100644 .github/ISSUE_TEMPLATE/openprose-cli-bug.yml create mode 100644 .github/ISSUE_TEMPLATE/openprose-cli-harness-model.yml diff --git a/.github/ISSUE_TEMPLATE/openprose-cli-benchmark-profile.yml b/.github/ISSUE_TEMPLATE/openprose-cli-benchmark-profile.yml new file mode 100644 index 0000000..6712c12 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/openprose-cli-benchmark-profile.yml @@ -0,0 +1,154 @@ +{ + "name": "OpenProse CLI benchmark profile or cell proposal", + "description": "Propose a frozen benchmark profile or one exact cell for separate review", + "title": "[CLI benchmark proposal]: ", + "body": [ + { + "type": "markdown", + "attributes": { + "value": "Use this form only for a rigorous benchmark profile or cell proposal. A benchmark proposal does not authorize live collection. Do not include credentials, tokens, account identifiers, private paths, or raw provider transcripts. Do not report a suspected security vulnerability here; use the private vulnerability report at https://github.com/openprose/prose-cli/security/advisories/new." + } + }, + { + "type": "dropdown", + "id": "proposal_type", + "attributes": { + "label": "Proposal type", + "options": [ + "New benchmark profile", + "New cell in a frozen profile", + "Profile or cell amendment" + ] + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "research_question", + "attributes": { + "label": "Research question", + "description": "State what comparison the proposal would support and its limits." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "profile_identity", + "attributes": { + "label": "Frozen profile", + "description": "Identify the frozen benchmark profile and its version or digest; describe proposed amendments explicitly." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "cell_identity", + "attributes": { + "label": "Exact cell", + "description": "Specify the exact admitted adapter, harness version, model ID, authentication route, and exact target artifact digest." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "semantic_contract", + "attributes": { + "label": "Semantic contract", + "description": "Identify the authoritative task corpus, acceptance criteria and scoring rules." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "execution_controls", + "attributes": { + "label": "Execution controls", + "description": "Specify the fixed seed, environment, repetition count, ordering and isolation controls." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "stops_retries_cost", + "attributes": { + "label": "Stops, retries and accounting", + "description": "Specify stop conditions, retry rules, accounting including failed attempts, and the cost observation channel." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "evidence_authority", + "attributes": { + "label": "Evidence and authority", + "description": "Identify retained evidence, provenance and independent review; benchmark inclusion does not establish semantic conformance, portability, or public ranking." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "reproduction", + "attributes": { + "label": "Reproduction", + "description": "Provide synthetic reproducible steps and public fixture references; use REDACTED for private values." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "upstream_evidence", + "attributes": { + "label": "Upstream evidence", + "description": "Link public documentation that supports the proposed controls and measurements." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "affiliation", + "attributes": { + "label": "Affiliation", + "description": "Disclose any relevant affiliation or financial interest, or write none." + }, + "validations": { + "required": true + } + }, + { + "type": "checkboxes", + "id": "disclosure", + "attributes": { + "label": "Disclosure", + "options": [ + { + "label": "I removed credentials, account identifiers, private paths, and raw provider output.", + "required": true + } + ] + }, + "validations": { + "required": true + } + } + ] +} diff --git a/.github/ISSUE_TEMPLATE/openprose-cli-bug.yml b/.github/ISSUE_TEMPLATE/openprose-cli-bug.yml new file mode 100644 index 0000000..9147eaf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/openprose-cli-bug.yml @@ -0,0 +1,110 @@ +{ + "name": "OpenProse CLI bug or install problem", + "description": "Report a reproducible CLI installation or execution problem using synthetic evidence.", + "title": "[CLI bug]: ", + "body": [ + { + "type": "markdown", + "attributes": { + "value": "Do not include credentials, tokens, account identifiers, private paths, or raw provider output. Use the literal word REDACTED for removed values. Do not attach raw provider transcripts. For a suspected security vulnerability, use the private vulnerability report at https://github.com/openprose/prose-cli/security/advisories/new." + } + }, + { + "type": "textarea", + "id": "problem_area", + "attributes": { + "label": "Problem area", + "description": "Describe whether this affects installation, startup, a harness adapter or output." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "environment", + "attributes": { + "label": "Environment", + "description": "Provide operating system, architecture and installation method; omit private paths and account identifiers." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "cli_version", + "attributes": { + "label": "Exact CLI version", + "description": "Provide the exact CLI version and Bun or Rust implementation; include public harness versions if relevant." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "reproduction", + "attributes": { + "label": "Minimal reproduction", + "description": "Provide synthetic inputs and argument arrays that reproduce the problem." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "expected", + "attributes": { + "label": "Expected behavior", + "description": "Describe what should happen." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "actual", + "attributes": { + "label": "Actual behavior", + "description": "Describe observed behavior and exit status without raw provider output." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "diagnostics", + "attributes": { + "label": "Sanitized diagnostics", + "description": "Provide only relevant sanitized error codes and messages, or write none. Use the literal word REDACTED for private values." + }, + "validations": { + "required": true + } + }, + { + "type": "checkboxes", + "id": "disclosure", + "attributes": { + "label": "Disclosure", + "options": [ + { + "label": "I removed credentials, tokens, account identifiers, private paths, and raw provider output.", + "required": true + }, + { + "label": "This is not a suspected security vulnerability.", + "required": true + } + ] + }, + "validations": { + "required": true + } + } + ] +} diff --git a/.github/ISSUE_TEMPLATE/openprose-cli-harness-model.yml b/.github/ISSUE_TEMPLATE/openprose-cli-harness-model.yml new file mode 100644 index 0000000..55cefff --- /dev/null +++ b/.github/ISSUE_TEMPLATE/openprose-cli-harness-model.yml @@ -0,0 +1,132 @@ +{ + "name": "OpenProse CLI harness or model request", + "description": "Request a new harness adapter, admitted harness version, or model or authentication route", + "title": "[CLI harness/model request]: ", + "body": [ + { + "type": "markdown", + "attributes": { + "value": "Use this form for adapter admission, an exact admitted harness version, or a model or authentication route. Do not use this form for a benchmark profile or cell. Do not include credentials, tokens, account identifiers, private paths, or raw provider transcripts. Do not report a suspected security vulnerability here; use the private vulnerability report at https://github.com/openprose/prose-cli/security/advisories/new. Remember: adapter admission, benchmark inclusion, semantic conformance, portability, and public ranking are separate decisions." + } + }, + { + "type": "dropdown", + "id": "request_type", + "attributes": { + "label": "Request type", + "options": [ + "New harness adapter", + "New admitted harness version", + "Model or authentication route" + ] + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "requested_identity", + "attributes": { + "label": "Requested harness or model", + "description": "Provide the exact harness version, exact model ID, and version command and expected output. Use public identifiers only." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "user_journey", + "attributes": { + "label": "User journey", + "description": "Describe the task and why existing admitted routes do not meet it." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "distribution_platform", + "attributes": { + "label": "Distribution and platform", + "description": "Name the public installation source, operating system and architecture." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "protocol_isolation", + "attributes": { + "label": "Protocol and isolation", + "description": "Describe structured output, noninteractive operation, cancellation and process isolation." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "authentication_billing", + "attributes": { + "label": "Authentication and billing", + "description": "Describe the authentication route and who pays; never include credentials or account identifiers." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "reproduction", + "attributes": { + "label": "Minimal reproduction", + "description": "Provide synthetic inputs, argument arrays and expected behavior. Use the literal word REDACTED for private values." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "upstream_evidence", + "attributes": { + "label": "Upstream evidence", + "description": "Link public protocol, version and model documentation supporting the request." + }, + "validations": { + "required": true + } + }, + { + "type": "textarea", + "id": "affiliation", + "attributes": { + "label": "Affiliation", + "description": "Disclose any relevant affiliation or financial interest, or write none." + }, + "validations": { + "required": true + } + }, + { + "type": "checkboxes", + "id": "disclosure", + "attributes": { + "label": "Disclosure", + "options": [ + { + "label": "I removed credentials, account identifiers, private paths, and raw provider output.", + "required": true + } + ] + }, + "validations": { + "required": true + } + } + ] +} diff --git a/cli/CONTRIBUTING.md b/cli/CONTRIBUTING.md index 930062c..517e146 100644 --- a/cli/CONTRIBUTING.md +++ b/cli/CONTRIBUTING.md @@ -12,10 +12,13 @@ adapter should remain thin. ## Start here -Required development tools are Python 3.10 or newer, Rust 1.87.0 with Clippy +Required development tools are Python 3.10, Rust 1.87.0 with Clippy and rustfmt, Bun 1.3.5, Node.js 22.22.3 or newer, and npm 10 or newer. CI uses Python 3.10.18 and Node.js 24.20.0. Use those exact versions when you need to -reproduce CI or release behavior. +reproduce CI or release behavior. The hash-locked test dependencies include +Python 3.10 native wheels; use a Python 3.10 virtual environment for the install +and test commands below. Newer Python versions may select wheels whose hashes +are not in this lock file. Do not bypass hash verification. The local admission commands below currently require macOS or Linux. Native Windows admission fails before it starts a child process because the required @@ -81,7 +84,7 @@ absent. Do not file a public issue or harness/model request, or a benchmark proposal, for a suspected security vulnerability. Use the repository's -[private vulnerability report](https://github.com/openprose/prose/security/advisories/new) +[private vulnerability report](https://github.com/openprose/prose-cli/security/advisories/new) and include only the information needed to reproduce and assess the issue. Do not include live credentials, account identifiers, or unrelated private data. @@ -105,7 +108,7 @@ can establish and which facts remain unknown. ## Propose a harness, model, or admitted version -Use the [OpenProse CLI harness or model request](https://github.com/openprose/prose/issues/new?template=openprose-cli-harness-model.yml) +Use the [OpenProse CLI harness or model request](https://github.com/openprose/prose-cli/issues/new?template=openprose-cli-harness-model.yml) issue form for one of these requests: - a new external-process harness adapter; @@ -127,7 +130,7 @@ in the request. ## Propose a benchmark profile or cell -Use the [OpenProse CLI benchmark profile or cell proposal](https://github.com/openprose/prose/issues/new?template=openprose-cli-benchmark-profile.yml) +Use the [OpenProse CLI benchmark profile or cell proposal](https://github.com/openprose/prose-cli/issues/new?template=openprose-cli-benchmark-profile.yml) issue form for a new frozen benchmark profile, one exact cell in a frozen profile, or a reviewed amendment. Do not use this form to request adapter admission or a new admitted version. diff --git a/cli/SUPPORT.md b/cli/SUPPORT.md index d69281d..d028e52 100644 --- a/cli/SUPPORT.md +++ b/cli/SUPPORT.md @@ -3,16 +3,16 @@ Use the route that matches the request: - Report a reproducible installation, packaging, command, or existing-adapter - problem with the [OpenProse CLI bug or install problem](https://github.com/openprose/prose/issues/new?template=openprose-cli-bug.yml) + problem with the [OpenProse CLI bug or install problem](https://github.com/openprose/prose-cli/issues/new?template=openprose-cli-bug.yml) form. - Propose a new adapter, admitted harness version, or model route with the - [OpenProse CLI harness or model request](https://github.com/openprose/prose/issues/new?template=openprose-cli-harness-model.yml) + [OpenProse CLI harness or model request](https://github.com/openprose/prose-cli/issues/new?template=openprose-cli-harness-model.yml) form. - Propose a benchmark profile or cell with the [OpenProse CLI benchmark profile - or cell proposal](https://github.com/openprose/prose/issues/new?template=openprose-cli-benchmark-profile.yml) + or cell proposal](https://github.com/openprose/prose-cli/issues/new?template=openprose-cli-benchmark-profile.yml) form. - Report a suspected vulnerability only through [private vulnerability - reporting](https://github.com/openprose/prose/security/advisories/new). + reporting](https://github.com/openprose/prose-cli/security/advisories/new). Do not include credentials, tokens, account identifiers, private paths, or raw provider output in a public issue. Reduce diagnostics to the minimum sanitized diff --git a/cli/bun/test/output.test.ts b/cli/bun/test/output.test.ts index 821d945..58a913d 100644 --- a/cli/bun/test/output.test.ts +++ b/cli/bun/test/output.test.ts @@ -86,7 +86,9 @@ describe("human error output", () => { expect(rendered).not.toContain("$PROSE"); expect(rendered).not.toContain("Recovery: prose cli cleanup"); expect(rendered).not.toContain("`prose cli"); - expect(rendered).not.toContain("/tmp/"); + // The exact executable/entrypoint may legitimately be installed under /tmp. + // Only that known invocation is exempt; private paths elsewhere still fail. + expect(rendered.replaceAll(humanRunnerInvocation(), "")).not.toContain("/tmp/"); }); test.skipIf(process.platform === "win32")("renders shell-parseable harness-selection commands", () => { @@ -125,7 +127,9 @@ describe("human error output", () => { expect(rendered).toContain("Detected runtime version: 1.3.13"); expect(rendered).toContain("Required runtime version: >=1.3.14"); expect(rendered).toContain("Repair: npm install --global bun@1.3.14 @oh-my-pi/pi-coding-agent@18.0.9"); - expect(rendered).not.toContain("/tmp/"); + // The exact executable/entrypoint may legitimately be installed under /tmp. + // Only that known invocation is exempt; private paths elsewhere still fail. + expect(rendered.replaceAll(humanRunnerInvocation(), "")).not.toContain("/tmp/"); expect(rendered).not.toContain("rawOutput"); }); diff --git a/cli/ci/check_alpha_public_docs.py b/cli/ci/check_alpha_public_docs.py index 2ea5133..21520f8 100644 --- a/cli/ci/check_alpha_public_docs.py +++ b/cli/ci/check_alpha_public_docs.py @@ -98,14 +98,14 @@ _RELEASE_LINK = re.compile(r"(? None: @@ -87,13 +86,13 @@ def valid_documents(root: Path, version: str = VERSION) -> None: root / "cli" / "SUPPORT.md", """# OpenProse CLI support -- Use the [OpenProse CLI harness or model request](https://github.com/openprose/prose/issues/new?template=openprose-cli-harness-model.yml) +- Use the [OpenProse CLI harness or model request](https://github.com/openprose/prose-cli/issues/new?template=openprose-cli-harness-model.yml) form for a new adapter, admitted harness version, model route, or authentication route. -- Use the [OpenProse CLI benchmark profile or cell proposal](https://github.com/openprose/prose/issues/new?template=openprose-cli-benchmark-profile.yml) +- Use the [OpenProse CLI benchmark profile or cell proposal](https://github.com/openprose/prose-cli/issues/new?template=openprose-cli-benchmark-profile.yml) form for a benchmark profile or cell. - Report a suspected vulnerability only through [private vulnerability - reporting](https://github.com/openprose/prose/security/advisories/new). + reporting](https://github.com/openprose/prose-cli/security/advisories/new). Do not include credentials, tokens, account identifiers, private paths, or raw provider output in a public issue. @@ -123,7 +122,7 @@ def valid_documents(root: Path, version: str = VERSION) -> None: "paths, or raw provider transcripts. Do not report a " "suspected security vulnerability here; use the " "private " - "vulnerability report at https://github.com/openprose/prose/" + "vulnerability report at https://github.com/openprose/prose-cli/" "security/advisories/new." ) }, @@ -187,7 +186,7 @@ def valid_documents(root: Path, version: str = VERSION) -> None: "transcripts. Do not report a suspected security " "vulnerability here; use the private vulnerability " "report " - "at https://github.com/openprose/prose/security/advisories/" + "at https://github.com/openprose/prose-cli/security/advisories/" "new." ) }, @@ -504,11 +503,11 @@ def test_license_requires_the_mit_identity_and_material_terms(self) -> None: def test_support_requires_distinct_intake_and_private_security_routes(self) -> None: support = """# Support -- Use the [harness request](https://github.com/openprose/prose/issues/new?template=openprose-cli-harness-model.yml) +- Use the [harness request](https://github.com/openprose/prose-cli/issues/new?template=openprose-cli-harness-model.yml) for a harness, model, or authentication route. -- Use the [benchmark proposal](https://github.com/openprose/prose/issues/new?template=openprose-cli-benchmark-profile.yml) +- Use the [benchmark proposal](https://github.com/openprose/prose-cli/issues/new?template=openprose-cli-benchmark-profile.yml) for a benchmark profile or cell. -- Report a suspected vulnerability through [private reporting](https://github.com/openprose/prose/security/advisories/new). +- Report a suspected vulnerability through [private reporting](https://github.com/openprose/prose-cli/security/advisories/new). Do not include credentials, tokens, account identifiers, private paths, or raw provider output in a public issue. @@ -525,16 +524,16 @@ def test_support_requires_distinct_intake_and_private_security_routes(self) -> N mutations = { "missing-benchmark-route": support.replace( - "https://github.com/openprose/prose/issues/new?template=openprose-cli-benchmark-profile.yml", - "https://github.com/openprose/prose/issues/new", + "https://github.com/openprose/prose-cli/issues/new?template=openprose-cli-benchmark-profile.yml", + "https://github.com/openprose/prose-cli/issues/new", ), "benchmark-routed-to-harness": support.replace( - "https://github.com/openprose/prose/issues/new?template=openprose-cli-benchmark-profile.yml", - "https://github.com/openprose/prose/issues/new?template=openprose-cli-harness-model.yml", + "https://github.com/openprose/prose-cli/issues/new?template=openprose-cli-benchmark-profile.yml", + "https://github.com/openprose/prose-cli/issues/new?template=openprose-cli-harness-model.yml", ), "missing-private-security": support.replace( - "https://github.com/openprose/prose/security/advisories/new", - "https://github.com/openprose/prose/issues/new", + "https://github.com/openprose/prose-cli/security/advisories/new", + "https://github.com/openprose/prose-cli/issues/new", ), "missing-public-redaction": support.replace( "credentials, tokens, account identifiers, private paths, or raw\n" @@ -577,8 +576,8 @@ def test_issue_forms_keep_separate_purposes_and_private_reporting(self) -> None: ), "harness-security-public": ( "openprose-cli-harness-model.yml", - "https://github.com/openprose/prose/security/advisories/new", - "https://github.com/openprose/prose/issues/new", + "https://github.com/openprose/prose-cli/security/advisories/new", + "https://github.com/openprose/prose-cli/issues/new", ( "ISSUE_FORM_PRIVACY_INVALID", "cli-harness-model-issue-form", @@ -814,8 +813,21 @@ def test_cli_rejects_duplicate_input_flags_as_ambiguous_json(self) -> None: self.assertIsNone(report["version"]) self.assertNotIn("private", output[0]) - def test_current_repository_fails_for_the_recorded_owner_blockers(self) -> None: - report = docs.assess(version=VERSION, repository_root=REPOSITORY_ROOT) + def test_incomplete_release_documents_report_all_owner_blockers(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + valid_documents(root) + incomplete = { + "README.md": "# OpenProse\n\nThere is no separate binary.\n", + "RELEASE.md": "# Release process\n", + "CONTRIBUTING.md": "# Contributing\n", + "PRIVACY.md": "# Privacy\n", + "TERMS.md": "# Terms\n", + "cli/CHANGELOG.md": "# Changelog\n\n## [Unreleased]\n", + } + for relative, value in incomplete.items(): + write(root / relative, value) + report = docs.assess(version=VERSION, repository_root=root) observed_checks = {item[2] for item in failures(report)} self.assertEqual(report["status"], "fail") self.assertEqual( diff --git a/cli/ci/test_check_contributor_docs.py b/cli/ci/test_check_contributor_docs.py index 368346e..54bf2dc 100644 --- a/cli/ci/test_check_contributor_docs.py +++ b/cli/ci/test_check_contributor_docs.py @@ -404,7 +404,7 @@ def test_guide_preserves_language_and_provider_safety_boundaries(self) -> None: "If you work in your own\n fork or branch, do not edit `OWNERSHIP.md`", "Provider-free tests make no network or model calls.", "No secret, private path, or raw provider response", - "https://github.com/openprose/prose/security/advisories/new", + "https://github.com/openprose/prose-cli/security/advisories/new", "Do not file a public issue or harness/model request", "Acceptance into the adapter inventory is not acceptance into a benchmark.", "## Implement a harness adapter", @@ -459,7 +459,7 @@ def test_harness_request_template_is_lightweight_and_exact(self) -> None: "exact model ID", "version command and expected output", "suspected security vulnerability", - "https://github.com/openprose/prose/security/advisories/new", + "https://github.com/openprose/prose-cli/security/advisories/new", "Do not use this form for a benchmark profile or cell", "adapter admission, benchmark inclusion, semantic conformance, " "portability, and public ranking are separate decisions", @@ -528,7 +528,7 @@ def test_benchmark_request_template_freezes_profile_and_cell_controls( "cost observation channel", "does not authorize live collection", "suspected security vulnerability", - "https://github.com/openprose/prose/security/advisories/new", + "https://github.com/openprose/prose-cli/security/advisories/new", "benchmark inclusion does not establish semantic conformance, " "portability, or public ranking", ): @@ -609,7 +609,7 @@ def test_general_cli_bug_template_is_distinct_and_privacy_safe(self) -> None: "Do not include credentials, tokens, account identifiers, private paths, " "or raw provider output.", "Use the literal word REDACTED", - "https://github.com/openprose/prose/security/advisories/new", + "https://github.com/openprose/prose-cli/security/advisories/new", "not a suspected security vulnerability", ): with self.subTest(marker=marker): @@ -631,7 +631,7 @@ def test_support_policy_states_alpha_compatibility_and_triage_boundaries( "openprose-cli-harness-model.yml", "OpenProse CLI benchmark profile or cell proposal", "openprose-cli-benchmark-profile.yml", - "https://github.com/openprose/prose/security/advisories/new", + "https://github.com/openprose/prose-cli/security/advisories/new", "Do not include credentials, tokens, account identifiers, private paths, " "or raw provider output", "No response or resolution service-level agreement is promised", diff --git a/cli/protocol/OWNERSHIP.md b/cli/protocol/OWNERSHIP.md index 7a2374e..8a2242e 100644 --- a/cli/protocol/OWNERSHIP.md +++ b/cli/protocol/OWNERSHIP.md @@ -490,3 +490,15 @@ Isolated checkout: branch `codex/imp-034-staging-cli`. Root owns Git, integratio - `staging_rust`: `cli/rust/crates/prose-runner-core/src/`, `cli/rust/crates/prose-runner-core/tests/`, `cli/rust/crates/prose-cli/src/` for equivalent Rust behavior; wait for shared contract before editing. No manifest edits without root coordination. Root integration lease also covers `cli/ci/run_local.py`, `cli/README.md`, and `cli/conformance/cases/fixtures/runner-help.txt` for test wiring and synchronized help. + +Root qualification lease: `cli/bun/test/output.test.ts` for baseline-reproduced temporary-installation privacy assertions. No production output changes. + +Root also owns `cli/rust/crates/prose-runner-core/src/error.rs` for the equivalent temporary-installation assertion repair. `qualification` owns `cli/shared/tests/test_contracts.py` to align the explicit taxonomy expectation with the six new staging errors. + +Root owns `cli/CONTRIBUTING.md` for clarifying the Python version required by the unchanged hash-locked native test wheels. + +`repository_gate_diagnosis` owns `cli/ci/create_draft_release.py` and `cli/ci/test_check_draft_release.py` for the stale repository identity correction only. Legacy release workflows must not be restored implicitly. + +`repository_gate_diagnosis` additionally owns `.github/ISSUE_TEMPLATE/openprose-cli-{bug,harness-model,benchmark-profile}.yml`, `cli/SUPPORT.md`, `cli/CONTRIBUTING.md` (preserve Python clarification), and `cli/ci/test_check_alpha_public_docs.py` to restore documented contributor forms and replace a mutable historical snapshot with a controlled fixture. No release workflows or gate bypass. + +Root additionally authorizes `repository_gate_diagnosis` repository URL substitutions only in `cli/ci/check_alpha_public_docs.py` and `cli/ci/test_check_contributor_docs.py`; preserve substantive privacy and routing checks. diff --git a/cli/rust/crates/prose-cli/src/main.rs b/cli/rust/crates/prose-cli/src/main.rs index cd24484..8edeb96 100644 --- a/cli/rust/crates/prose-cli/src/main.rs +++ b/cli/rust/crates/prose-cli/src/main.rs @@ -281,12 +281,29 @@ fn prepare( if parsed.globals.service_environment.is_some() { if let Action::Runner { ref command, json } = parsed.action { - if matches!(command, RunnerCommand::AuthLogin | RunnerCommand::AuthStatus | RunnerCommand::AuthLogout | RunnerCommand::OrgList) { - let mode = if json { OutputMode::Json } else { parsed.globals.output.unwrap_or_default() }; + if matches!( + command, + RunnerCommand::AuthLogin + | RunnerCommand::AuthStatus + | RunnerCommand::AuthLogout + | RunnerCommand::OrgList + ) { + let mode = if json { + OutputMode::Json + } else { + parsed.globals.output.unwrap_or_default() + }; return prose_runner_core::service_account::execute(command, mode, cancellation); } } - return error_outcome(RunnerError::invocation("service environment requires an account or organization command"), error_mode, &clock, &ids); + return error_outcome( + RunnerError::invocation( + "service environment requires an account or organization command", + ), + error_mode, + &clock, + &ids, + ); } let system = match SystemContext::capture() { diff --git a/cli/rust/crates/prose-runner-core/src/error.rs b/cli/rust/crates/prose-runner-core/src/error.rs index ff35c16..cd095bd 100644 --- a/cli/rust/crates/prose-runner-core/src/error.rs +++ b/cli/rust/crates/prose-runner-core/src/error.rs @@ -259,12 +259,42 @@ impl RunnerError { #[must_use] pub fn catalog(code: ErrorCode) -> Self { let (boundary, message, action, retryable) = match code { - ErrorCode::ServiceUnavailable => ("hosted-service","The staging account service is unavailable.","Retry the staging account command later.",true), - ErrorCode::ServiceAuthRequired => ("authentication","Staging account authentication is required.","Run prose --service-environment staging cli auth login, then retry.",false), - ErrorCode::ServiceProtocolInvalid => ("protocol","The staging account service returned an invalid response.","Retry later and report the sanitized error code if it persists.",false), - ErrorCode::CredentialStoreUnavailable => ("authentication","The operating system credential store is unavailable.","Unlock or configure the operating system credential store, then retry.",false), - ErrorCode::DeviceAuthFailed => ("authentication","Device authorization failed.","Run the staging login command again and authorize the displayed code.",false), - ErrorCode::DeviceAuthExpired => ("authentication","Device authorization expired.","Run the staging login command again to obtain a new code.",true), + ErrorCode::ServiceUnavailable => ( + "hosted-service", + "The staging account service is unavailable.", + "Retry the staging account command later.", + true, + ), + ErrorCode::ServiceAuthRequired => ( + "authentication", + "Staging account authentication is required.", + "Run prose --service-environment staging cli auth login, then retry.", + false, + ), + ErrorCode::ServiceProtocolInvalid => ( + "protocol", + "The staging account service returned an invalid response.", + "Retry later and report the sanitized error code if it persists.", + false, + ), + ErrorCode::CredentialStoreUnavailable => ( + "authentication", + "The operating system credential store is unavailable.", + "Unlock or configure the operating system credential store, then retry.", + false, + ), + ErrorCode::DeviceAuthFailed => ( + "authentication", + "Device authorization failed.", + "Run the staging login command again and authorize the displayed code.", + false, + ), + ErrorCode::DeviceAuthExpired => ( + "authentication", + "Device authorization expired.", + "Run the staging login command again to obtain a new code.", + true, + ), ErrorCode::ConfigInvalid => ( "configuration", @@ -735,7 +765,13 @@ mod tests { assert!(!rendered.contains("$PROSE")); assert!(!rendered.contains("Recovery: prose cli cleanup")); assert!(!rendered.contains("`prose cli")); - assert!(!rendered.contains("/tmp/")); + // The executable can be installed under /tmp; private paths outside + // that exact, intentionally disclosed invocation must remain absent. + assert!( + !rendered + .replace(&human_runner_executable(), "") + .contains("/tmp/") + ); } #[test] diff --git a/cli/rust/crates/prose-runner-core/src/service_account.rs b/cli/rust/crates/prose-runner-core/src/service_account.rs index aaab589..6c3fa55 100644 --- a/cli/rust/crates/prose-runner-core/src/service_account.rs +++ b/cli/rust/crates/prose-runner-core/src/service_account.rs @@ -1,8 +1,8 @@ //! Explicit staging account operations. Credentials never enter harness configuration. -use crate::{CancellationToken, OutputMode, RunnerCommand, RunnerError}; use crate::error::ErrorCode; use crate::output::CommandOutcome; -use serde_json::{json, Value}; +use crate::{CancellationToken, OutputMode, RunnerCommand, RunnerError}; +use serde_json::{Value, json}; use std::io::{Read, Write}; use std::time::{Duration, Instant}; @@ -14,7 +14,11 @@ fn problem(code: ErrorCode) -> RunnerError { } fn valid_token(token: &str) -> bool { - token.strip_prefix("rr_test_").is_some_and(|v| v.len() == 32 && v.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))) + token.strip_prefix("rr_test_").is_some_and(|v| { + v.len() == 32 + && v.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + }) } struct Session { @@ -25,24 +29,34 @@ struct Session { } impl Session { - fn new(cancellation: &CancellationToken) -> Result { #[allow(unused_mut)] let mut fixture: Option = None; - #[cfg(feature="test-seams")] + #[cfg(feature = "test-seams")] if let Some(path) = std::env::var_os("PROSE_TEST_SERVICE_FIXTURE") { let mut bytes = Vec::new(); - std::fs::File::open(path).map_err(|_| problem(ErrorCode::ServiceProtocolInvalid))?.take(LIMIT + 1).read_to_end(&mut bytes).map_err(|_| problem(ErrorCode::ServiceProtocolInvalid))?; + std::fs::File::open(path) + .map_err(|_| problem(ErrorCode::ServiceProtocolInvalid))? + .take(LIMIT + 1) + .read_to_end(&mut bytes) + .map_err(|_| problem(ErrorCode::ServiceProtocolInvalid))?; if bytes.len() as u64 > LIMIT { return Err(problem(ErrorCode::ServiceProtocolInvalid)); } - fixture = Some(serde_json::from_slice(&bytes).map_err(|_| problem(ErrorCode::ServiceProtocolInvalid))?); + fixture = Some( + serde_json::from_slice(&bytes) + .map_err(|_| problem(ErrorCode::ServiceProtocolInvalid))?, + ); } if let Some(value) = &fixture { if !value.is_object() || !value["storeAvailable"].is_boolean() - || !value["exchanges"].as_array().is_some_and(|v| v.len() <= 182) - || !value.get("credential").is_some_and(|v| v.is_null() || v.is_string()) + || !value["exchanges"] + .as_array() + .is_some_and(|v| v.len() <= 182) + || !value + .get("credential") + .is_some_and(|v| v.is_null() || v.is_string()) { return Err(problem(ErrorCode::ServiceProtocolInvalid)); } @@ -58,7 +72,10 @@ impl Session { fn check(&self) -> Result<(), RunnerError> { if self.cancellation.is_cancelled() { Err(problem(ErrorCode::Cancelled)) - } else if self.deadline.is_some_and(|deadline| Instant::now() >= deadline) { + } else if self + .deadline + .is_some_and(|deadline| Instant::now() >= deadline) + { Err(problem(ErrorCode::DeviceAuthExpired)) } else { Ok(()) @@ -66,10 +83,16 @@ impl Session { } fn transport_failure(&self) -> RunnerError { - self.check().err().unwrap_or_else(|| problem(ErrorCode::ServiceUnavailable)) + self.check() + .err() + .unwrap_or_else(|| problem(ErrorCode::ServiceUnavailable)) } - fn store(&mut self, operation: &str, token: Option<&str>) -> Result, RunnerError> { + fn store( + &mut self, + operation: &str, + token: Option<&str>, + ) -> Result, RunnerError> { self.check()?; if let Some(fixture) = self.fixture.as_mut() { if fixture["storeAvailable"] == false { @@ -96,62 +119,94 @@ impl Session { ) -> Result { self.check()?; let (status, value) = if let Some(fixture) = self.fixture.as_ref() { - if (path == "/organizations") != token.is_some() || (path != "/organizations" && token.is_some()) { + if (path == "/organizations") != token.is_some() + || (path != "/organizations" && token.is_some()) + { return Err(problem(ErrorCode::ServiceProtocolInvalid)); } if let Some(token) = token { - let expected = std::env::var("OPENPROSE_STAGING_API_KEY").ok().or_else(|| fixture["credential"].as_str().map(str::to_owned)); + let expected = std::env::var("OPENPROSE_STAGING_API_KEY") + .ok() + .or_else(|| fixture["credential"].as_str().map(str::to_owned)); if expected.as_deref() != Some(token) { return Err(problem(ErrorCode::ServiceProtocolInvalid)); } } - let exchange = fixture["exchanges"].get(self.next).ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; + let exchange = fixture["exchanges"] + .get(self.next) + .ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; self.next += 1; if exchange["method"] != method || exchange["path"] != path { return Err(problem(ErrorCode::ServiceProtocolInvalid)); } - (exchange["status"].as_u64().unwrap_or(0), exchange["body"].clone()) + ( + exchange["status"].as_u64().unwrap_or(0), + exchange["body"].clone(), + ) } else { - let timeout = self.deadline.map_or(Duration::from_secs(10), |deadline| deadline.saturating_duration_since(Instant::now()).min(Duration::from_secs(10))); + let timeout = self.deadline.map_or(Duration::from_secs(10), |deadline| { + deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_secs(10)) + }); if timeout.is_zero() { return Err(problem(ErrorCode::DeviceAuthExpired)); } - let agent = ureq::AgentBuilder::new().timeout(timeout).redirects(0).build(); - let mut request = agent.request(method, &format!("{BASE}{path}")).set("Accept", "application/json"); + let agent = ureq::AgentBuilder::new() + .timeout(timeout) + .redirects(0) + .build(); + let mut request = agent + .request(method, &format!("{BASE}{path}")) + .set("Accept", "application/json"); if let Some(token) = token { request = request.set("Authorization", &format!("Bearer {token}")); } let result = if method == "POST" { - request.set("Content-Type", "application/json").send_string(&body.to_string()) + request + .set("Content-Type", "application/json") + .send_string(&body.to_string()) } else { request.call() }; let response = match result { - Ok(r) | Err(ureq::Error::Status(_,r)) => r, Err(_) => return Err(self.transport_failure()) + Ok(r) | Err(ureq::Error::Status(_, r)) => r, + Err(_) => return Err(self.transport_failure()), }; self.check()?; let status = u64::from(response.status()); - if matches!(status,401|403) { + if matches!(status, 401 | 403) { return Err(problem(ErrorCode::ServiceAuthRequired)); } if !(200..=299).contains(&status) && !(status == 400 && path.ends_with("/poll")) { return Err(problem(ErrorCode::ServiceUnavailable)); } let mut bytes = Vec::new(); - response.into_reader().take(LIMIT+1).read_to_end(&mut bytes).map_err(|_| self.transport_failure())?; + response + .into_reader() + .take(LIMIT + 1) + .read_to_end(&mut bytes) + .map_err(|_| self.transport_failure())?; self.check()?; if bytes.len() as u64 > LIMIT { return Err(problem(ErrorCode::ServiceProtocolInvalid)); } - (status, serde_json::from_slice(&bytes).map_err(|_| problem(ErrorCode::ServiceProtocolInvalid))?) + ( + status, + serde_json::from_slice(&bytes) + .map_err(|_| problem(ErrorCode::ServiceProtocolInvalid))?, + ) }; self.check()?; match status { - 200..=299 => Ok(value), 401 | 403 => Err(problem(ErrorCode::ServiceAuthRequired)), 400 if path.ends_with("/poll") => Err(problem(if value["error"] == "expired_token" { + 200..=299 => Ok(value), + 401 | 403 => Err(problem(ErrorCode::ServiceAuthRequired)), + 400 if path.ends_with("/poll") => Err(problem(if value["error"] == "expired_token" { ErrorCode::DeviceAuthExpired } else { ErrorCode::DeviceAuthFailed - })), _ => Err(problem(ErrorCode::ServiceUnavailable)) + })), + _ => Err(problem(ErrorCode::ServiceUnavailable)), } } @@ -172,20 +227,28 @@ impl Session { } fn organizations(value: Value) -> Result { - let entries = value["organizations"].as_array().ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; - if entries.len()>1000 { + let entries = value["organizations"] + .as_array() + .ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; + if entries.len() > 1000 { return Err(problem(ErrorCode::ServiceProtocolInvalid)); } let mut output = Vec::new(); for entry in entries { - let mut row = json!({ - }); + let mut row = json!({}); for field in ["id", "slug", "name"] { - let text = entry[field].as_str().filter(|s| !s.is_empty() && s.chars().count()<=4096 && !s.chars().any(|c| c <= '\u{1f}' || c == '\u{7f}')).ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; + let text = entry[field] + .as_str() + .filter(|s| { + !s.is_empty() + && s.chars().count() <= 4096 + && !s.chars().any(|c| c <= '\u{1f}' || c == '\u{7f}') + }) + .ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; row[field] = json!(text); } if let Some(role) = entry.get("role") { - if !matches!(role.as_str(),Some("admin"|"developer"|"reader")) { + if !matches!(role.as_str(), Some("admin" | "developer" | "reader")) { return Err(problem(ErrorCode::ServiceProtocolInvalid)); } row["role"] = role.clone(); @@ -202,32 +265,57 @@ pub fn execute( cancellation: &CancellationToken, ) -> CommandOutcome { let operation = match command { - RunnerCommand::AuthLogin => "login", RunnerCommand::AuthLogout => "logout", RunnerCommand::OrgList => "list", _ => "status" + RunnerCommand::AuthLogin => "login", + RunnerCommand::AuthLogout => "logout", + RunnerCommand::OrgList => "list", + _ => "status", }; let mut source = "none"; let mut authenticated = false; let mut rows = json!([]); - let result = (|| -> Result<(),RunnerError> { + let result = (|| -> Result<(), RunnerError> { let mut session = Session::new(cancellation)?; - let environment = std::env::var("OPENPROSE_STAGING_API_KEY").ok().filter(|s| !s.is_empty()); - if environment.is_some() && matches!(operation,"login"|"logout") { - return Err(RunnerError::invocation("Environment credentials cannot be changed by login or logout.")); + let environment = std::env::var("OPENPROSE_STAGING_API_KEY") + .ok() + .filter(|s| !s.is_empty()); + if environment.is_some() && matches!(operation, "login" | "logout") { + return Err(RunnerError::invocation( + "Environment credentials cannot be changed by login or logout.", + )); } if operation == "logout" { - session.store("delete",None)?; + session.store("delete", None)?; return Ok(()); } if operation == "login" { - session.store("get",None)?; - let start = session.request("POST","/auth/device",None,json!({ - }))?; - let code = start["device_code"].as_str().filter(|s| !s.is_empty() && s.len()<=1024).ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?.to_owned(); - let user = start["user_code"].as_str().filter(|s| !s.is_empty() && s.len()<=32 && s.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'-') && !s.contains(&code)).ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; + session.store("get", None)?; + let start = session.request("POST", "/auth/device", None, json!({}))?; + let code = start["device_code"] + .as_str() + .filter(|s| !s.is_empty() && s.len() <= 1024) + .ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))? + .to_owned(); + let user = start["user_code"] + .as_str() + .filter(|s| { + !s.is_empty() + && s.len() <= 32 + && s.bytes() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'-') + && !s.contains(&code) + }) + .ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; if start["verification_uri"] != "https://github.com/login/device" { return Err(problem(ErrorCode::ServiceProtocolInvalid)); } - let expiry = start["expires_in"].as_u64().filter(|v| (1..=900).contains(v)).ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; - let mut interval = start["interval"].as_u64().filter(|v| (1..=30).contains(v)).ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; + let expiry = start["expires_in"] + .as_u64() + .filter(|v| (1..=900).contains(v)) + .ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; + let mut interval = start["interval"] + .as_u64() + .filter(|v| (1..=30).contains(v)) + .ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; eprintln!("Go to https://github.com/login/device and enter code: {user}"); let began = Instant::now(); session.deadline = Some(began + Duration::from_secs(expiry)); @@ -241,27 +329,36 @@ pub fn execute( if began.elapsed().as_secs() >= expiry { return Err(problem(ErrorCode::DeviceAuthExpired)); } - let poll = session.request("POST","/auth/device/poll",None,json!({ - "device_code":code - }))?; + let poll = session.request( + "POST", + "/auth/device/poll", + None, + json!({ + "device_code":code + }), + )?; if began.elapsed().as_secs() >= expiry { return Err(problem(ErrorCode::DeviceAuthExpired)); } match poll["status"].as_str() { - Some("pending") => { - }, - Some("slow_down") => interval = (interval+5).min(30), + Some("pending") => {} + Some("slow_down") => interval = (interval + 5).min(30), Some("complete") => { - let token = poll["api_key"].as_str().filter(|s| valid_token(s)).ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; - session.store("set",Some(token))?; - if session.store("get",None)?.as_deref() != Some(token) { + let token = poll["api_key"] + .as_str() + .filter(|s| valid_token(s)) + .ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; + session.store("set", Some(token))?; + if session.store("get", None)?.as_deref() != Some(token) { return Err(problem(ErrorCode::CredentialStoreUnavailable)); } source = "os-credential-store"; authenticated = true; return Ok(()); - }, - Some("error") if poll["error"] == "expired_token" => return Err(problem(ErrorCode::DeviceAuthExpired)), + } + Some("error") if poll["error"] == "expired_token" => { + return Err(problem(ErrorCode::DeviceAuthExpired)); + } Some("error") => return Err(problem(ErrorCode::DeviceAuthFailed)), _ => return Err(problem(ErrorCode::ServiceProtocolInvalid)), } @@ -272,7 +369,7 @@ pub fn execute( source = "environment"; Some(token) } else { - let token = session.store("get",None)?; + let token = session.store("get", None)?; if token.is_some() { source = "os-credential-store"; } @@ -282,8 +379,21 @@ pub fn execute( if !valid_token(&token) { return Err(problem(ErrorCode::ServiceProtocolInvalid)); } - rows = organizations(session.request("GET","/organizations",Some(&token),Value::Null)?)?; - if rows.as_array().is_some_and(|entries| entries.iter().any(|entry| ["id","slug","name"].iter().any(|key| entry[*key].as_str().is_some_and(|value| value.contains(&token))))) { + rows = organizations(session.request( + "GET", + "/organizations", + Some(&token), + Value::Null, + )?)?; + if rows.as_array().is_some_and(|entries| { + entries.iter().any(|entry| { + ["id", "slug", "name"].iter().any(|key| { + entry[*key] + .as_str() + .is_some_and(|value| value.contains(&token)) + }) + }) + }) { rows = json!([]); return Err(problem(ErrorCode::ServiceProtocolInvalid)); } @@ -297,7 +407,7 @@ pub fn execute( if error.is_some() { source = "none"; } - let exit = error.as_ref().map_or(0,|e| e.exit_code); + let exit = error.as_ref().map_or(0, |e| e.exit_code); let report = if operation == "list" { json!({ "schema":"openprose.organization-list/1","environment":"staging","organizations":rows,"problem":error @@ -309,25 +419,34 @@ pub fn execute( }; if mode == OutputMode::Human { if let Some(error) = error { - CommandOutcome::human("",format!("{}: {}\n",error.code,error.message),exit) - } - else if operation == "list" { - CommandOutcome::human(format!("{}\n",rows),"",0) - } - else { - CommandOutcome::human(format!("Staging account {operation}: {}\n", if authenticated { - "authenticated" - } else { - "signed out" - }),"",0) + CommandOutcome::human("", format!("{}: {}\n", error.code, error.message), exit) + } else if operation == "list" { + CommandOutcome::human(format!("{}\n", rows), "", 0) + } else { + CommandOutcome::human( + format!( + "Staging account {operation}: {}\n", + if authenticated { + "authenticated" + } else { + "signed out" + } + ), + "", + 0, + ) } } else { - CommandOutcome::json(report,exit) + CommandOutcome::json(report, exit) } } #[cfg(not(target_os = "macos"))] -fn native_store(_: &str, _: Option<&str>, _: &CancellationToken) -> Result, RunnerError> { +fn native_store( + _: &str, + _: Option<&str>, + _: &CancellationToken, +) -> Result, RunnerError> { Err(problem(ErrorCode::CredentialStoreUnavailable)) } #[cfg(target_os = "macos")] @@ -337,9 +456,7 @@ fn native_store( token: Option<&str>, cancellation: &CancellationToken, ) -> Result, RunnerError> { - use std::process::{ - Command, Stdio - }; + use std::process::{Command, Stdio}; // No shell or token argv. Interactive security command parsing receives only // fixed commands and a closed ASCII token alphabet over an anonymous pipe. let arguments = "-s org.openprose.cli.staging -a api-key"; @@ -347,25 +464,37 @@ fn native_store( "get" => format!("find-generic-password {arguments} -w\n"), "delete" => format!("delete-generic-password {arguments}\n"), "set" => { - let token = token.filter(|s| valid_token(s)).ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; + let token = token + .filter(|s| valid_token(s)) + .ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; format!("add-generic-password -U {arguments} -w {token}\n") - }, + } _ => return Err(problem(ErrorCode::CredentialStoreUnavailable)), }; - let mut child = Command::new("/usr/bin/security").arg("-i").env_clear() - .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn() - .map_err(|_| problem(ErrorCode::CredentialStoreUnavailable))?; + let mut child = Command::new("/usr/bin/security") + .arg("-i") + .env_clear() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|_| problem(ErrorCode::CredentialStoreUnavailable))?; let stdout = child.stdout.take().expect("piped stdout"); let stderr = child.stderr.take().expect("piped stderr"); - let read = |pipe: Box| std::thread::spawn(move || { - let mut bytes = Vec::new(); - let result = pipe.take(8193).read_to_end(&mut bytes); - (result.is_ok() && bytes.len() <= 8192, bytes) - }); + let read = |pipe: Box| { + std::thread::spawn(move || { + let mut bytes = Vec::new(); + let result = pipe.take(8193).read_to_end(&mut bytes); + (result.is_ok() && bytes.len() <= 8192, bytes) + }) + }; let out_reader = read(Box::new(stdout)); let err_reader = read(Box::new(stderr)); - let write_ok = child.stdin.take().is_some_and(|mut stdin| stdin.write_all(script.as_bytes()).is_ok()); - let deadline = Instant::now()+Duration::from_secs(10); + let write_ok = child + .stdin + .take() + .is_some_and(|mut stdin| stdin.write_all(script.as_bytes()).is_ok()); + let deadline = Instant::now() + Duration::from_secs(10); let mut failed = !write_ok; loop { if cancellation.is_cancelled() || Instant::now() >= deadline || failed { @@ -378,14 +507,14 @@ fn native_store( Ok(Some(status)) => { failed = !status.success(); break; - }, + } Ok(None) => std::thread::sleep(Duration::from_millis(25)), Err(_) => { failed = true; - let _=child.kill(); - let _=child.wait(); + let _ = child.kill(); + let _ = child.wait(); break; - }, + } } } let (out_ok, stdout) = out_reader.join().unwrap_or_default(); @@ -399,14 +528,19 @@ fn native_store( // security -i may exit zero after a failed subcommand. Inspect only fixed // error markers; never propagate its output or captured command echo. let diagnostic = String::from_utf8_lossy(&stderr); - if diagnostic.contains("could not be found") && matches!(operation,"get"|"delete") { + if diagnostic.contains("could not be found") && matches!(operation, "get" | "delete") { return Ok(None); } - if failed || diagnostic.contains("SecKeychain") || diagnostic.contains("SecItem") || diagnostic.contains("error:") { + if failed + || diagnostic.contains("SecKeychain") + || diagnostic.contains("SecItem") + || diagnostic.contains("error:") + { return Err(problem(ErrorCode::CredentialStoreUnavailable)); } if operation == "get" { - let output = String::from_utf8(stdout).map_err(|_| problem(ErrorCode::CredentialStoreUnavailable))?; + let output = String::from_utf8(stdout) + .map_err(|_| problem(ErrorCode::CredentialStoreUnavailable))?; let token = output.trim(); if !valid_token(token) { return Err(problem(ErrorCode::CredentialStoreUnavailable)); @@ -421,10 +555,21 @@ mod tests { #[test] fn transport_failures_prioritize_cancellation_then_expiry() { let cancel = CancellationToken::default(); - let mut session = Session { fixture: None, next: 0, deadline: None, cancellation: cancel.clone() }; - assert_eq!(session.transport_failure().code, ErrorCode::ServiceUnavailable); + let mut session = Session { + fixture: None, + next: 0, + deadline: None, + cancellation: cancel.clone(), + }; + assert_eq!( + session.transport_failure().code, + ErrorCode::ServiceUnavailable + ); session.deadline = Some(Instant::now()); - assert_eq!(session.transport_failure().code, ErrorCode::DeviceAuthExpired); + assert_eq!( + session.transport_failure().code, + ErrorCode::DeviceAuthExpired + ); cancel.cancel(); assert_eq!(session.transport_failure().code, ErrorCode::Cancelled); } @@ -434,7 +579,13 @@ mod tests { fn token_alphabet_cannot_inject_native_commands() { assert!(valid_token("rr_test_11111111111111111111111111111111")); - for token in ["", "fixture", "rr_live_11111111111111111111111111111111", "rr_test_11111111111111111111111111111111\nquit", "rr_test_1111111111111111111111111111111\""] { + for token in [ + "", + "fixture", + "rr_live_11111111111111111111111111111111", + "rr_test_11111111111111111111111111111111\nquit", + "rr_test_1111111111111111111111111111111\"", + ] { assert!(!valid_token(token)); } } @@ -444,11 +595,25 @@ mod tests { let mut session = Session { fixture: Some(json!({ "credential":null,"storeAvailable":true,"exchanges":[] - })), next:0, deadline:None, cancellation:CancellationToken::default() + })), + next: 0, + deadline: None, + cancellation: CancellationToken::default(), }; - assert_eq!(session.request("POST","/auth/device",None,json!({ - })).unwrap_err().code,ErrorCode::ServiceProtocolInvalid); - assert_eq!(session.request("GET","/organizations",None,Value::Null).unwrap_err().code,ErrorCode::ServiceProtocolInvalid); + assert_eq!( + session + .request("POST", "/auth/device", None, json!({})) + .unwrap_err() + .code, + ErrorCode::ServiceProtocolInvalid + ); + assert_eq!( + session + .request("GET", "/organizations", None, Value::Null) + .unwrap_err() + .code, + ErrorCode::ServiceProtocolInvalid + ); session.fixture = Some(json!({ "credential":"expected","exchanges":[{ "method":"GET","path":"/organizations","status":200,"body":{ @@ -456,23 +621,35 @@ mod tests { } }] })); - assert_eq!(session.request("GET","/organizations",Some("wrong"),Value::Null).unwrap_err().code,ErrorCode::ServiceProtocolInvalid); + assert_eq!( + session + .request("GET", "/organizations", Some("wrong"), Value::Null) + .unwrap_err() + .code, + ErrorCode::ServiceProtocolInvalid + ); } #[test] fn unknown_organization_roles_and_controls_fail_closed() { for role in ["owner", "root"] { - assert!(organizations(json!({ + assert!( + organizations(json!({ + "organizations":[{ + "id":"id","slug":"slug","name":"name","role":role + }] + })) + .is_err() + ); + } + assert!( + organizations(json!({ "organizations":[{ - "id":"id","slug":"slug","name":"name","role":role + "id":"id","slug":"slug","name":"\u{1b}" }] - })).is_err()); - } - assert!(organizations(json!({ - "organizations":[{ - "id":"id","slug":"slug","name":"\u{1b}" - }] - })).is_err()); + })) + .is_err() + ); } #[test] @@ -482,11 +659,22 @@ mod tests { let mut session = Session { fixture: Some(json!({ "credential":null,"storeAvailable":true,"exchanges":[] - })), next:0, deadline:None, cancellation:cancel + })), + next: 0, + deadline: None, + cancellation: cancel, }; - assert_eq!(session.store("get",None).unwrap_err().code,ErrorCode::Cancelled); - assert_eq!(session.request("POST","/auth/device",None,json!({ - })).unwrap_err().code,ErrorCode::Cancelled); + assert_eq!( + session.store("get", None).unwrap_err().code, + ErrorCode::Cancelled + ); + assert_eq!( + session + .request("POST", "/auth/device", None, json!({})) + .unwrap_err() + .code, + ErrorCode::Cancelled + ); } #[test] @@ -494,22 +682,37 @@ mod tests { let mut session = Session { fixture: Some(json!({ "credential":null,"storeAvailable":true,"exchanges":[] - })), next:0, deadline:None, cancellation:CancellationToken::default() + })), + next: 0, + deadline: None, + cancellation: CancellationToken::default(), }; - session.store("set",Some("test-only")).unwrap(); - assert_eq!(session.store("get",None).unwrap().as_deref(),Some("test-only")); - session.store("delete",None).unwrap(); - session.store("delete",None).unwrap(); - assert!(session.store("get",None).unwrap().is_none()); + session.store("set", Some("test-only")).unwrap(); + assert_eq!( + session.store("get", None).unwrap().as_deref(), + Some("test-only") + ); + session.store("delete", None).unwrap(); + session.store("delete", None).unwrap(); + assert!(session.store("get", None).unwrap().is_none()); } #[test] fn staging_flag_preserves_language_boundary_and_rejects_other_operations() { let parse = |args: &[&str]| crate::parse_invocation(args.iter().map(|s| s.to_string())); - assert!(parse(&["--service-environment","staging","cli","org","list"]).is_ok()); - assert!(parse(&["--service-environment","staging","cli","doctor"]).is_err()); - assert!(parse(&["--service-environment","production","cli","auth","status"]).is_err()); - let parsed = parse(&["run","--service-environment","staging"]).unwrap(); + assert!(parse(&["--service-environment", "staging", "cli", "org", "list"]).is_ok()); + assert!(parse(&["--service-environment", "staging", "cli", "doctor"]).is_err()); + assert!( + parse(&[ + "--service-environment", + "production", + "cli", + "auth", + "status" + ]) + .is_err() + ); + let parsed = parse(&["run", "--service-environment", "staging"]).unwrap(); assert!(parsed.globals.service_environment.is_none()); } } diff --git a/cli/shared/tests/test_contracts.py b/cli/shared/tests/test_contracts.py index bd1ed94..d589c3c 100755 --- a/cli/shared/tests/test_contracts.py +++ b/cli/shared/tests/test_contracts.py @@ -774,6 +774,8 @@ def test_error_taxonomy_is_complete_unique_and_schema_valid(self) -> None: "PROTOCOL_TRUNCATED", "HARNESS_FAILED", "SEMANTIC_STATUS_UNKNOWN", "CANCELLED", "PROCESS_CLEANUP_FAILED", "HOSTED_UNAVAILABLE", "HOSTED_AUTH_REQUIRED", "HOSTED_QUOTA_EXCEEDED", "INTERNAL_ERROR", + "SERVICE_UNAVAILABLE", "SERVICE_AUTH_REQUIRED", "SERVICE_PROTOCOL_INVALID", + "CREDENTIAL_STORE_UNAVAILABLE", "DEVICE_AUTH_FAILED", "DEVICE_AUTH_EXPIRED", } records = taxonomy["errors"] self.assertEqual({record["code"] for record in records}, expected_codes) diff --git a/docs/validation/imp-034/README.md b/docs/validation/imp-034/README.md index c753830..f474f4b 100644 --- a/docs/validation/imp-034/README.md +++ b/docs/validation/imp-034/README.md @@ -31,8 +31,17 @@ No live account login, OS credential-store mutation, or authenticated staging re Rust local storage currently supports macOS; other platforms fail closed and may use the scoped staging environment credential. Bun delegates to its operating-system credential API. Native storage operations can request OS approval. A timed-out operation that cannot be cancelled may finish later; status should be checked before retrying. -The full Bun suite reported 556 passes, two skips and 25 failures in this execution environment: socket permission failures, a FIFO-related subprocess warning, and temporary-path output assertions. An initial run with incomplete PATH had additional failures; the counts above are from the corrected PATH run. An exact baseline comparison remains outstanding. The broader Rust suite also had socket permission and temporary-path failures. These results are not a claim that the full suites passed or that every failure is unrelated. +After unrestricted access was restored, the full Bun 1.3.5 suite passed: 582 passed, two skipped, zero failed. The full Rust 1.87.0 workspace/all-targets suite with test seams passed: 331 passed, two ignored, zero failed. Shared Python contract tests passed 27/27, architecture boundary unit tests passed 34/34, and the portable Windows host tests passed 17/17 on macOS (this is not native Windows qualification). -The broad architecture unit gate also failed; the direct architecture checker passed. Python JSON Schema tests were unavailable because the required Python dependency could not be downloaded; the Ajv checks are recorded separately. Rustfmt and Clippy were not installed. Full normal admission and supported-platform CI remain required before merge/release. +Diagnosis distinguished four causes: + +- Two Bun output assertions and the equivalent Rust assertion rejected any `/tmp/` path, including the deliberately displayed runner executable. The Bun failures reproduced on unchanged baseline `eb40bc4`; the Rust assertion was also unchanged. The correction exempts only the exact runner invocation and retains rejection of other temporary paths. +- The new staging taxonomy was missing from a shared test's explicit expected-code set. This candidate regression was corrected; the test still checks the exact set. +- The locked Python wheels target Python 3.10. Installation under Python 3.11 failed hash verification; Python 3.10.20 installed the unchanged lock successfully. Contributor instructions now specify that requirement. +- The protected package manifest still named the old `prose.git` repository, unlike the current packager. Correcting it to `prose-cli.git` restored all 14 draft-release tests. + +The broader historical architecture/release suite failed identically on the unchanged baseline: 156 tests, two failures and 48 errors. Its missing legacy release workflows are explicitly documented in `docs/cli-distribution.md`; restoring their release authority is a separate migration, not an authentication fix. Three documented contributor issue forms are restored, and support links now target this repository. A historical blocker snapshot now uses a controlled fixture rather than asserting stale facts about the current checkout. The affected suites pass: 22 public-documentation tests, 19 contributor-documentation tests, and 14 draft-release tests. Full admission must not be described as passing while those historical release checks remain unresolved. + +Rust formatting of the candidate's three newly affected files is corrected. Fifteen pre-existing files still fail the full formatting check, and five initial Clippy diagnostics in unchanged supervisor/framing code remain. These are separate baseline qualification debt; supported-platform CI and live credential-store qualification remain outstanding. No model-provider calls, paid runs, backend edits, deployment or publication were performed. From 3dfb375b916e4a84a1f1c9d044de1526c38ffe35 Mon Sep 17 00:00:00 2001 From: mm Date: Mon, 21 Sep 2026 16:41:20 -0400 Subject: [PATCH 3/5] feat: persist production and staging service environments --- cli/SPEC.md | 13 + cli/bun/src/adapters/environment.ts | 2 +- cli/bun/src/cli.ts | 45 ++-- cli/bun/src/core/args.ts | 11 +- cli/bun/src/core/config.ts | 54 ++-- cli/bun/src/core/service-account.ts | 49 ++-- cli/bun/src/core/types.ts | 5 +- cli/bun/src/supervision/environment.ts | 2 +- cli/bun/test/cli.test.ts | 28 +- cli/bun/test/service-account.test.ts | 6 +- cli/bun/test/service-environment.test.ts | 74 ++++++ cli/ci/run_local.py | 6 + .../cases/fixtures/runner-help.txt | 17 +- .../operations/auth-status-unavailable.json | 44 +++- .../runner/service-environment-corpus.json | 175 +++++++++++++ cli/conformance/runner/service_environment.py | 48 ++++ cli/protocol/OWNERSHIP.md | 6 + cli/rust/crates/prose-cli/src/main.rs | 34 +-- cli/rust/crates/prose-cli/tests/cli.rs | 74 +++--- .../crates/prose-runner-core/src/config.rs | 226 ++++++++++++++++- .../src/installed_adapters.rs | 8 +- .../prose-runner-core/src/invocation.rs | 16 +- .../crates/prose-runner-core/src/runner.rs | 42 +-- .../prose-runner-core/src/service_account.rs | 240 ++++++++++++++++-- .../schemas/organization-list.schema.json | 5 +- .../schemas/service-account.schema.json | 5 +- .../schemas/service-environment.schema.json | 39 +++ docs/staging-account.md | 45 ++-- docs/validation/imp-034/README.md | 4 + 29 files changed, 1064 insertions(+), 259 deletions(-) create mode 100644 cli/bun/test/service-environment.test.ts create mode 100644 cli/conformance/runner/service-environment-corpus.json create mode 100644 cli/conformance/runner/service_environment.py create mode 100644 cli/shared/schemas/service-environment.schema.json diff --git a/cli/SPEC.md b/cli/SPEC.md index 8d09f17..84fe9cb 100644 --- a/cli/SPEC.md +++ b/cli/SPEC.md @@ -1835,3 +1835,16 @@ contain no ASCII control characters or DEL, and cannot contain the bearer key. Extra organization fields, including nested private fields, are discarded. Malformed transcript roots fail SERVICE_PROTOCOL_INVALID: credential must be null or a string, storeAvailable a boolean, exchanges an array of at most 182. + +## IMP-034 persistent service environments (supersedes staging-only admission) + +Production-default service environment contract, supersedes staging-only paragraph: +- Grammar: prose cli environment show [--json]; prose cli environment use staging|production [--json]; prose cli environment reset [--json]. Global --output json supported. Reset removes stored selection =>production. Use production stores explicit production. Commands require no auth/network/keychain. +- Persistent flat service_environment="staging"|"production" in existing USER cli.toml only. Use existing safe atomic writer preserving other values/comments and validate same protections. No project setting may redirect service. Service resolver reads user only, ignores project; normal project config with service_environment rejects CONFIG_INVALID. Keep service setting out of harness EffectiveValues/config-explain schema, expose via environment show. +- Unset=>production. All auth login/status/logout and org list work by default, no flag needed. Retain --service-environment production|staging as ephemeral account/org override only; reject with environment management or unrelated ops. No generic endpoint env override. +- Production origin https://run-prose-production.openprose.workers.dev; staging https://run-prose-staging.openprose.workers.dev. All OpenProse account/service requests route through selected fixed origin. Model/provider/kernel artifact endpoints unchanged. +- Credentials: OPENPROSE_API_KEY production; OPENPROSE_STAGING_API_KEY staging. Only selected variable consulted. Both filtered from ALL harness/probe env even explicit reallow. Both namespaces independent org.openprose.cli.production and org.openprose.cli.staging account api-key. Backend actually issues rr_test_32hex on BOTH deployments, do not use rr_live_. No fallback across env. Login/logout reject only selected envtoken. Switching never reads/writes/deletes creds. +- Human account/service and environment command output for staging clearly includes 'OpenProse staging'. JSON stays single parseable object with environment production|staging (no banner); device verification stderr remains necessary but contains no credential. Other local/harness human outputs need not change because they make no OpenProse service request. +- Environment command closed output: schema openprose.service-environment/1, environment production|staging, source default|user-config, problem null|runner-error. Success show/use/reset returns selected env+source. Failure invalid config normal existing runner-error envelope allowed; never default silently if malformed user config. service-account/organization-list schemas environment enum both. +- Test seam may optionally contain environment:'production'|'staging', credentials:{production: token|null,staging:token|null}; old credential applies only expected environment when provided. Assert selected env equals fixture.environment if supplied and exchange optional origin equals selected origin. Never reach network when fixture present or credential real stores. +- Shared sequences will test persisted use/show/status/reset, fresh process, precedence, wrong env tokens, fixed origins, malformed/project config, JSON/human indicator. Update existing no-flag account tests to hermetic fixture behavior BEFORE full tests to avoid actual network/keychain. diff --git a/cli/bun/src/adapters/environment.ts b/cli/bun/src/adapters/environment.ts index 38310d1..497ef01 100644 --- a/cli/bun/src/adapters/environment.ts +++ b/cli/bun/src/adapters/environment.ts @@ -34,7 +34,7 @@ export function buildInstalledAdapterEnvironment(input: AdapterEnvironmentInput) authProfile: input.credentialGroup, }); } - const alwaysStrip = new Set([...adapterAlwaysStrip, "OPENPROSE_STAGING_API_KEY"].map(normalize)); + const alwaysStrip = new Set([...adapterAlwaysStrip, "OPENPROSE_STAGING_API_KEY", "OPENPROSE_API_KEY"].map(normalize)); const selected = new Set([ ...adapterBaseEnvironmentAllowlist, ...credentialNames, diff --git a/cli/bun/src/cli.ts b/cli/bun/src/cli.ts index b56327c..be6211a 100644 --- a/cli/bun/src/cli.ts +++ b/cli/bun/src/cli.ts @@ -10,7 +10,7 @@ import { embeddedRuntimeImage } from "./assets/sentinel"; import runnerHelp from "../../conformance/cases/fixtures/runner-help.txt" with { type: "text" }; import deterministicMockDescriptor from "../../shared/fixtures/transport/deterministic-mock-adapter.json" with { type: "json" }; import fakeProcessDescriptor from "../../shared/fixtures/transport/mock-adapter.json" with { type: "json" }; -import { resolveConfiguration, writeUserHarnessSelection } from "./core/config"; +import { resolveConfiguration, resolveServiceEnvironment, writeUserServiceEnvironment, writeUserHarnessSelection } from "./core/config"; import { failure } from "./core/errors"; import { harnessById, harnesses, type HarnessDescriptor } from "./core/harnesses"; import { canonicalJson, sha256, verifyRuntimeImage } from "./core/image"; @@ -92,9 +92,22 @@ export async function runCli(args: readonly string[], dependencies: CliDependenc if (parsed.global.serviceEnvironment !== undefined) { mode = parsed.kind === "operation" && parsed.json ? "json" : parsed.global.output ?? "human"; if (parsed.kind !== "operation" || !["auth-status", "auth-login", "auth-logout", "org-list"].includes(parsed.operation) || Object.keys(parsed.global).some((key) => !["serviceEnvironment", "output", "color", "verbose"].includes(key))) throw failure("INVOCATION_INVALID"); - return await runServiceAccount(parsed.operation, mode, dependencies); } - if (parsed.kind === "operation" && parsed.operation === "org-list") throw failure("INVOCATION_INVALID"); + if (parsed.kind === "operation" && (["auth-status", "auth-login", "auth-logout", "org-list"].includes(parsed.operation) || parsed.operation.startsWith("environment-"))) { + mode = parsed.json ? "json" : parsed.global.output ?? "human"; + if (Object.keys(parsed.global).some((key) => !["serviceEnvironment", "output", "color", "verbose"].includes(key))) throw failure("INVOCATION_INVALID"); + let selected = await resolveServiceEnvironment(dependencies); + if (parsed.operation.startsWith("environment-")) { + if (parsed.operation !== "environment-show") { + await writeUserServiceEnvironment(selected.path, parsed.operation === "environment-reset" ? null : parsed.value as "production" | "staging"); + selected = await resolveServiceEnvironment(dependencies); + } + const report = { schema: "openprose.service-environment/1", environment: selected.environment, source: selected.source, problem: null }; + dependencies.writeStdout(mode === "human" ? `OpenProse ${selected.environment} environment (${selected.source})\n` : jsonLine(report)); + return 0; + } + return await runServiceAccount(parsed.operation, mode, dependencies, parsed.global.serviceEnvironment ?? selected.environment); + } if (parsed.kind === "weave") return await runWeaveHost(parsed.argv, parsed.global, dependencies); if (parsed.kind === "help") { dependencies.writeStdout(runnerHelp); @@ -202,32 +215,6 @@ async function runOperation( } else dependencies.writeStdout(jsonLine(report)); return 0; } - if (operation === "auth-status") { - const problem = failure("HOSTED_UNAVAILABLE", { billingOwner: "openprose", fallbackSelected: false }); - const report = { - schema: "openprose.account-status/1", - availability: "unavailable", - authenticated: null, - authCategory: "openprose-account", - billingOwner: "openprose", - credentialStorage: "unavailable", - problem: problem.toJSON(), - }; - if (mode === "human") { - dependencies.writeStdout([ - "OpenProse account: unavailable", - "Authentication: unknown", - "Credential storage: unavailable", - `Problem: ${problem.code} — ${problem.message}`, - `Action: ${humanAction(problem)}`, - "", - ].join("\n")); - } else dependencies.writeStdout(jsonLine(report)); - return problem.exitCode; - } - if (operation === "auth-login" || operation === "auth-logout") { - throw failure("HOSTED_UNAVAILABLE", { billingOwner: "openprose", fallbackSelected: false }); - } if (operation === "config-explain") { if (mode === "human") dependencies.writeStdout(humanConfiguration(config)); else dependencies.writeStdout(jsonLine(configurationExplanation(config))); diff --git a/cli/bun/src/core/args.ts b/cli/bun/src/core/args.ts index 05c7313..2ea5908 100644 --- a/cli/bun/src/core/args.ts +++ b/cli/bun/src/core/args.ts @@ -28,7 +28,7 @@ function invalid(message: string): never { function setValue(global: GlobalFlags, key: keyof GlobalFlags, value: string, option: string): void { if (key === "serviceEnvironment") { - if (value !== "staging") invalid("Service environment must be staging."); + if (value !== "staging" && value !== "production") invalid("Service environment must be production or staging."); if (global.serviceEnvironment !== undefined) invalid("Service environment was specified more than once."); global.serviceEnvironment = value; return; @@ -131,6 +131,9 @@ function parseOperation(global: GlobalFlags, args: readonly string[]): ParsedEnt if (json) invalid("Prime cleanup uses the global `--output json` option before `cli`."); return { kind: "operation", global, operation: "prime-cleanup", json, value: withoutJson[2]! }; } + if (key === "environment show") return { kind: "operation", global, operation: "environment-show", json }; + if (key === "environment reset") return { kind: "operation", global, operation: "environment-reset", json }; + if (withoutJson.length === 3 && withoutJson[0] === "environment" && withoutJson[1] === "use" && ["production", "staging"].includes(withoutJson[2]!)) return { kind: "operation", global, operation: "environment-use", json, value: withoutJson[2]! }; if (key === "doctor") return { kind: "operation", global, operation: "doctor", json }; if (key === "harness list") return { kind: "operation", global, operation: "harness-list", json }; if (key === "config explain") return { kind: "operation", global, operation: "config-explain", json }; @@ -186,6 +189,12 @@ function knownRunnerHelpPath(args: readonly string[]): boolean { "cleanup prime --help", "config --help", "config explain --help", + "environment --help", + "environment show --help", + "environment use --help", + "environment reset --help", + "environment use staging --help", + "environment use production --help", "org --help", "org list --help", "auth --help", diff --git a/cli/bun/src/core/config.ts b/cli/bun/src/core/config.ts index 42ec340..7db002a 100644 --- a/cli/bun/src/core/config.ts +++ b/cli/bun/src/core/config.ts @@ -108,7 +108,7 @@ export async function resolveConfiguration( (Object.keys(defaults) as ConfigKey[]).map((key) => [key, { kind: "default", location: "built-in" }]), ) as { [K in ConfigKey]: ValueSource }; - const user = await readConfigIfPresent(userConfigPath); + const user = await readConfigIfPresent(userConfigPath, true); apply(values, sources, user.values, "user-config", user.locations); if (projectConfigPath !== null) { const project = await readConfigIfPresent(projectConfigPath); @@ -177,7 +177,10 @@ async function exists(path: string): Promise { } } +export type ServiceEnvironment = "production" | "staging"; + interface ParsedValues { + serviceEnvironment?: ServiceEnvironment; values: PartialValues; locations: Partial>; } @@ -350,7 +353,8 @@ function assignFileValue( assignValidated(values, key, value, location); } -function parseFlatToml(source: string, path: string): ParsedValues { +function parseFlatToml(source: string, path: string, allowService = true): ParsedValues { + let serviceEnvironment: ServiceEnvironment | undefined; const values: PartialValues = {}; const locations: Partial> = {}; const seen = new Set(); @@ -368,7 +372,7 @@ function parseFlatToml(source: string, path: string): ParsedValues { } const rawKey = match[1]!; const key = Object.hasOwn(fileKeyMap, rawKey) ? fileKeyMap[rawKey] : undefined; - if (key === undefined) configLineFailure(path, lineNumber, "Configuration contains an unknown key."); + if (key === undefined && rawKey !== "service_environment") configLineFailure(path, lineNumber, "Configuration contains an unknown key."); if (seen.has(rawKey)) configLineFailure(path, lineNumber, `Duplicate configuration key: ${rawKey}.`); seen.add(rawKey); const rawValue = line.slice(match[0].length); @@ -396,13 +400,19 @@ function parseFlatToml(source: string, path: string): ParsedValues { configLineFailure(path, lineNumber, "Unexpected content after configuration value."); } const location = `${path}:${lineNumber}`; - assignFileValue(values, key, rawKey, parsed, location); - locations[key] = location; + if (rawKey === "service_environment") { + if (!allowService) configLineFailure(path, lineNumber, "Service environment is only allowed in user configuration."); + if (parsed !== "production" && parsed !== "staging") configLineFailure(path, lineNumber, "Service environment must be production or staging."); + serviceEnvironment = parsed; + } else { + assignFileValue(values, key!, rawKey, parsed, location); + locations[key!] = location; + } } - return { values, locations }; + return { values, locations, ...(serviceEnvironment === undefined ? {} : { serviceEnvironment }) }; } -async function readConfigIfPresent(path: string): Promise { +async function readConfigIfPresent(path: string, allowService = false): Promise { if (!(await exists(path))) return { values: {}, locations: {} }; let text: string; try { @@ -411,7 +421,7 @@ async function readConfigIfPresent(path: string): Promise { if (caught instanceof RunnerFailure) throw caught; fail(`Cannot read configuration file: ${path}.`, path); } - return parseFlatToml(text, path); + return parseFlatToml(text, path, allowService); } function parseEnvironment(env: Readonly>): ParsedValues { @@ -555,6 +565,26 @@ export async function writeUserHarnessSelection( } if (selection.model !== null && selection.model.length === 0) fail("Saved model must not be empty.", path); if (selection.authProfile !== null && selection.authProfile.length === 0) fail("Saved auth profile must not be empty.", path); + return writeUserSelection(path, new Set(["harness", "model", "auth_profile"]), [ + ...(selection.authProfile === null ? [] : [`auth_profile = ${JSON.stringify(selection.authProfile)}`]), + `harness = ${JSON.stringify(harness)}`, + ...(selection.model === null ? [] : [`model = ${JSON.stringify(selection.model)}`]), + ]); +} + +export async function resolveServiceEnvironment(dependencies: ConfigDependencies): Promise<{ environment: ServiceEnvironment; source: "default" | "user-config"; path: string }> { + const path = dependencies.userConfigPath ?? defaultUserConfigPath(dependencies); + const pathApi = (dependencies.platform ?? process.platform) === "win32" ? win32 : posix; + if (!pathApi.isAbsolute(path)) fail("OpenProse user configuration path must be absolute."); + const parsed = await readConfigIfPresent(path, true); + return { environment: parsed.serviceEnvironment ?? "production", source: parsed.serviceEnvironment === undefined ? "default" : "user-config", path }; +} + +export async function writeUserServiceEnvironment(path: string, environment: ServiceEnvironment | null): Promise { + return writeUserSelection(path, new Set(["service_environment"]), environment === null ? [] : [`service_environment = ${JSON.stringify(environment)}`]); +} + +async function writeUserSelection(path: string, targetKeys: Set, bundle: string[]): Promise { const parent = dirname(path); await secureUserConfigParent(parent); @@ -574,7 +604,6 @@ export async function writeUserHarnessSelection( const lines = original.length === 0 ? [] : original.replace(/\n$/u, "").split("\n"); if (original.length > 0) parseFlatToml(original, path); - const targetKeys = new Set(["harness", "model", "auth_profile"]); const seen = new Set(); let insertionIndex: number | null = null; const updated: string[] = []; @@ -589,13 +618,8 @@ export async function writeUserHarnessSelection( seen.add(key); if (insertionIndex === null) insertionIndex = updated.length; } - const bundle = [ - ...(selection.authProfile === null ? [] : [`auth_profile = ${JSON.stringify(selection.authProfile)}`]), - `harness = ${JSON.stringify(harness)}`, - ...(selection.model === null ? [] : [`model = ${JSON.stringify(selection.model)}`]), - ]; updated.splice(insertionIndex ?? updated.length, 0, ...bundle); - const next = `${updated.join("\n")}\n`; + const next = updated.length === 0 ? "" : `${updated.join("\n")}\n`; if (next === original) return false; const temporary = join(parent, `.cli.toml.openprose-${process.pid}-${randomUUID()}.tmp`); diff --git a/cli/bun/src/core/service-account.ts b/cli/bun/src/core/service-account.ts index a728f27..af829ec 100644 --- a/cli/bun/src/core/service-account.ts +++ b/cli/bun/src/core/service-account.ts @@ -5,11 +5,10 @@ import { humanSafeScalar, jsonLine } from "./output"; import { RunnerFailure, type OutputMode, type RunnerOperation } from "./types"; // This target is deliberately independent of hosted language execution. -const BASE = "https://run-prose-staging.openprose.workers.dev"; -const STORE = { service: "org.openprose.cli.staging", name: "api-key" }; +type ServiceEnvironment = "production" | "staging"; const MAX_BYTES = 65_536; type RecordValue = Record; -interface Fixture { credential: string | null; storeAvailable: boolean; exchanges: Array<{method: string; path: string; status: number; body: unknown}>; cancelBeforePoll?: boolean } +interface Fixture { environment?: ServiceEnvironment; credentials?: Partial>; credential: string | null; storeAvailable: boolean; exchanges: Array<{method: string; path: string; status: number; body: unknown; origin?: string}>; cancelBeforePoll?: boolean } interface Dependencies { env: Readonly>; cancellationSignal?: AbortSignal; @@ -40,31 +39,40 @@ async function fixtureFor(deps: Dependencies): Promise { const bytes = await readFile(path); if (bytes.length > 1_048_576) throw new Error(); const value = object(JSON.parse(bytes.toString("utf8"))); - if (!(value.credential === null || typeof value.credential === "string") || typeof value.storeAvailable !== "boolean" || !Array.isArray(value.exchanges) || value.exchanges.length > 182) throw new Error(); + if (!(value.credentials !== undefined || value.credential === null || typeof value.credential === "string") || typeof value.storeAvailable !== "boolean" || !Array.isArray(value.exchanges) || value.exchanges.length > 182) throw new Error(); return value as unknown as Fixture; } catch { throw failure("SERVICE_PROTOCOL_INVALID"); } } class Service { elapsed = 0; - constructor(readonly deps: Dependencies, readonly fixture?: Fixture) {} + constructor(readonly deps: Dependencies, readonly environment: ServiceEnvironment, readonly fixture?: Fixture) { + if (fixture?.environment !== undefined && fixture.environment !== environment) throw failure("SERVICE_PROTOCOL_INVALID"); + } + get origin(): string { return `https://run-prose-${this.environment}.openprose.workers.dev`; } + get storeIdentity(): { service: string; name: string } { return { service: `org.openprose.cli.${this.environment}`, name: "api-key" }; } + get environmentToken(): string | undefined { return this.deps.env[this.environment === "production" ? "OPENPROSE_API_KEY" : "OPENPROSE_STAGING_API_KEY"]; } + get fixtureCredential(): string | null { return this.fixture?.credentials === undefined ? this.fixture?.credential ?? null : this.fixture.credentials[this.environment] ?? null; } checkCancel(): void { if (this.deps.cancellationSignal?.aborted) throw failure("CANCELLED"); } async store(action: "get" | "set" | "delete", value?: string): Promise { this.checkCancel(); try { if (this.fixture !== undefined) { if (!this.fixture.storeAvailable) throw new Error(); - if (action === "set") this.fixture.credential = value!; - if (action === "delete") this.fixture.credential = null; - return this.fixture.credential; + if (action !== "get") { + const next = action === "set" ? value! : null; + if (this.fixture.credentials !== undefined) this.fixture.credentials[this.environment] = next; + else this.fixture.credential = next; + } + return this.fixtureCredential; } if (typeof Bun.secrets?.get !== "function") throw new Error(); // Native APIs cannot cancel an in-flight keychain mutation. Bound waiting, // but report store failure (not cancellation) when its outcome is unknown. let timer: ReturnType | undefined; try { - const operation = action === "get" ? Bun.secrets.get(STORE) - : action === "set" ? Bun.secrets.set({ ...STORE, value: value! }).then(() => null) - : Bun.secrets.delete(STORE).then(() => null); + const operation = action === "get" ? Bun.secrets.get(this.storeIdentity) + : action === "set" ? Bun.secrets.set({ ...this.storeIdentity, value: value! }).then(() => null) + : Bun.secrets.delete(this.storeIdentity).then(() => null); return await Promise.race([operation, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Credential store timeout")), 10_000); })]); @@ -88,14 +96,14 @@ class Service { this.checkCancel(); try { if (this.fixture !== undefined) { - const expectedCredential = this.deps.env.OPENPROSE_STAGING_API_KEY || this.fixture.credential; + const expectedCredential = this.environmentToken || this.fixtureCredential; if (path === "/organizations" ? credential === undefined || credential !== expectedCredential : credential !== undefined) throw failure("SERVICE_PROTOCOL_INVALID"); const exchange = this.fixture.exchanges.shift(); - if (exchange === undefined || exchange.method !== method || exchange.path !== path || JSON.stringify(exchange.body).length > MAX_BYTES) throw failure("SERVICE_PROTOCOL_INVALID"); + if (exchange === undefined || exchange.method !== method || exchange.path !== path || (exchange.origin !== undefined && exchange.origin !== this.origin) || JSON.stringify(exchange.body).length > MAX_BYTES) throw failure("SERVICE_PROTOCOL_INVALID"); return { status: integer(exchange.status, 100, 599), body: object(exchange.body) }; } const signal = this.deps.cancellationSignal === undefined ? AbortSignal.timeout(timeoutMs) : AbortSignal.any([this.deps.cancellationSignal, AbortSignal.timeout(timeoutMs)]); - const response = await fetch(`${BASE}${path}`, { + const response = await fetch(`${this.origin}${path}`, { method, redirect: "error", signal, headers: { Accept: "application/json", ...(credential === undefined ? {} : { Authorization: `Bearer ${credential}` }), ...(body === undefined ? {} : { "Content-Type": "application/json" }) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), @@ -176,15 +184,16 @@ async function login(service: Service): Promise { } throw failure("DEVICE_AUTH_EXPIRED"); } -export async function runServiceAccount(operation: RunnerOperation, mode: OutputMode, deps: Dependencies): Promise { +export async function runServiceAccount(operation: RunnerOperation, mode: OutputMode, deps: Dependencies, environment: ServiceEnvironment = "production"): Promise { const isOrg = operation === "org-list"; const report: RecordValue = isOrg - ? { schema: "openprose.organization-list/1", environment: "staging", organizations: [], problem: null } - : { schema: "openprose.service-account/1", environment: "staging", operation: operation.slice(5), authenticated: false, credentialSource: "none", problem: null }; + ? { schema: "openprose.organization-list/1", environment, organizations: [], problem: null } + : { schema: "openprose.service-account/1", environment, operation: operation.slice(5), authenticated: false, credentialSource: "none", problem: null }; + if (mode === "human" && environment === "staging") deps.writeStderr("OpenProse staging environment\n"); let exitCode = 0; try { - const service = new Service(deps, await fixtureFor(deps)); - const environmentToken = deps.env.OPENPROSE_STAGING_API_KEY; + const service = new Service(deps, environment, await fixtureFor(deps)); + const environmentToken = service.environmentToken; if ((operation === "auth-login" || operation === "auth-logout") && environmentToken !== undefined && environmentToken !== "") throw failure("INVOCATION_INVALID"); if (operation === "auth-login") { await login(service); @@ -213,7 +222,7 @@ export async function runServiceAccount(operation: RunnerOperation, mode: Output if (mode !== "human") deps.writeStdout(jsonLine(report)); else { if (isOrg) for (const row of report.organizations as RecordValue[]) deps.writeStdout(`${humanSafeScalar(String(row.slug))}\t${humanSafeScalar(String(row.name))}\n`); - else deps.writeStdout(`OpenProse staging account: ${report.authenticated ? "authenticated" : "signed out"}\n`); + else deps.writeStdout(`OpenProse ${environment} account: ${report.authenticated ? "authenticated" : "signed out"}\n`); if (report.problem !== null) { const problem = report.problem as RecordValue; deps.writeStderr(`${problem.code}: ${problem.message}\n${problem.action}\n`); } } return exitCode; diff --git a/cli/bun/src/core/types.ts b/cli/bun/src/core/types.ts index 63a5357..1a9dd65 100644 --- a/cli/bun/src/core/types.ts +++ b/cli/bun/src/core/types.ts @@ -9,7 +9,7 @@ export interface ValueSource { } export interface GlobalFlags { - serviceEnvironment?: "staging"; + serviceEnvironment?: "production" | "staging"; harness?: string; transport?: string; cwd?: string; @@ -63,6 +63,9 @@ export interface EffectiveConfiguration { } export type RunnerOperation = + | "environment-show" + | "environment-use" + | "environment-reset" | "doctor" | "harness-list" | "harness-use" diff --git a/cli/bun/src/supervision/environment.ts b/cli/bun/src/supervision/environment.ts index dd2a433..6187f05 100644 --- a/cli/bun/src/supervision/environment.ts +++ b/cli/bun/src/supervision/environment.ts @@ -28,7 +28,7 @@ export function buildChildEnvironment( const permitted = new Set([...operatingSystemNames, ...additionalNames]); const result: Record = {}; for (const name of permitted) { - if (name.toUpperCase() === "OPENPROSE_STAGING_API_KEY") continue; + if (["OPENPROSE_STAGING_API_KEY", "OPENPROSE_API_KEY"].includes(name.toUpperCase())) continue; const value = ambient[name]; if (value !== undefined) result[name] = value; } diff --git a/cli/bun/test/cli.test.ts b/cli/bun/test/cli.test.ts index 6d28702..1404286 100644 --- a/cli/bun/test/cli.test.ts +++ b/cli/bun/test/cli.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"; import { chmod, lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import accountStatusFixture from "../../shared/fixtures/operations/account-status-unavailable.json" with { type: "json" }; import configurationFixture from "../../shared/fixtures/operations/configuration-explanation.json" with { type: "json" }; import { humanHarnessList, runCli, type CliDependencies } from "../src/cli"; import { harnesses } from "../src/core/harnesses"; @@ -1242,20 +1241,17 @@ describe("CLI behavior", () => { expect(io.invocations).toHaveLength(0); }); - test("auth status emits the fail-closed account report without reading or printing credentials", async () => { - const io = operationFixture(); - expect(await runCli(["--output", "json", "cli", "auth", "status"], io.deps)).toBe(10); - expect(JSON.parse(io.stdout())).toEqual(accountStatusFixture); - expect(io.stderr()).toBe(""); - expect(io.invocations).toHaveLength(0); - expect(io.stdout()).not.toContain("must-not-appear"); - }); - - test.each(["login", "logout"])("auth %s reaches the hosted boundary with the frozen taxonomy action", async (command) => { - const io = operationFixture(); - expect(await runCli(["--output", "json", "cli", "auth", command], io.deps)).toBe(10); - expect(JSON.parse(io.stdout())).toEqual(accountStatusFixture.problem); - expect(io.stderr()).toBe(""); - expect(io.invocations).toHaveLength(0); + test.each(["status", "login", "logout"])("auth %s defaults to production with hermetic credential store failure", async (command) => { + const root = await mkdtemp(join(tmpdir(), "prose-account-")); + try { + const path = join(root, "service.json"); + await writeFile(path, JSON.stringify({ environment: "production", credential: null, storeAvailable: false, exchanges: [] })); + const io = operationFixture(); + io.deps.env = { PROSE_TEST_SERVICE_FIXTURE: path }; + expect(await runCli(["--output", "json", "cli", "auth", command], io.deps)).toBe(10); + expect(JSON.parse(io.stdout())).toMatchObject({ environment: "production", problem: { code: "CREDENTIAL_STORE_UNAVAILABLE" } }); + expect(io.stderr()).toBe(""); + expect(io.invocations).toHaveLength(0); + } finally { await rm(root, { recursive: true, force: true }); } }); }); diff --git a/cli/bun/test/service-account.test.ts b/cli/bun/test/service-account.test.ts index 345a049..9698860 100644 --- a/cli/bun/test/service-account.test.ts +++ b/cli/bun/test/service-account.test.ts @@ -15,7 +15,7 @@ async function invoke(fixture: unknown, operation = "status", env: Record "2026-01-01T00:00:00Z", monotonicMs: () => 0 }, ids: { invocationId: () => "test-invocation" }, writeStdout: (value) => { stdout += value; }, writeStderr: (value) => { stderr += value; }, }); @@ -79,7 +79,7 @@ test("transport pins origin and bearer and strips extra remote fields", async () expect(options?.signal).toBeInstanceOf(AbortSignal); return Response.json({ organizations: [{ id: "org", slug: "org", name: "Org", api_key: credential }] }); }) as unknown as typeof fetch; - const code = await runServiceAccount("org-list", "json", { env: { OPENPROSE_STAGING_API_KEY: credential }, writeStdout: (value) => { stdout += value; }, writeStderr: () => {} }); + const code = await runServiceAccount("org-list", "json", { env: { OPENPROSE_STAGING_API_KEY: credential }, writeStdout: (value) => { stdout += value; }, writeStderr: () => {} }, "staging"); expect(code).toBe(0); expect(JSON.parse(stdout).organizations).toEqual([{ id: "org", slug: "org", name: "Org" }]); expect(stdout).not.toContain(credential); @@ -95,7 +95,7 @@ test("HTTP failures remain typed with empty or HTML bodies; successful bodies re ] as const) { let stdout = ""; globalThis.fetch = (async () => new Response(body, { status })) as unknown as typeof fetch; - await runServiceAccount("auth-status", "json", { env: { OPENPROSE_STAGING_API_KEY: credential }, writeStdout: (value) => { stdout += value; }, writeStderr: () => {} }); + await runServiceAccount("auth-status", "json", { env: { OPENPROSE_STAGING_API_KEY: credential }, writeStdout: (value) => { stdout += value; }, writeStderr: () => {} }, "staging"); expect(JSON.parse(stdout).problem.code).toBe(expected); expect(stdout).not.toContain(credential); } diff --git a/cli/bun/test/service-environment.test.ts b/cli/bun/test/service-environment.test.ts new file mode 100644 index 0000000..465954e --- /dev/null +++ b/cli/bun/test/service-environment.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runCli } from "../src/cli"; +import { runServiceAccount } from "../src/core/service-account"; +import { buildChildEnvironment } from "../src/supervision/environment"; +const token = `rr_test_${"1".repeat(32)}`; +async function workspace(fn: (root: string, invoke: (args: string[], env?: Record) => Promise<{ code: number; stdout: string; stderr: string }>) => Promise) { + const root = await mkdtemp(join(tmpdir(), "prose-environment-")); + await mkdir(join(root, "user")); + await writeFile(join(root, "fixture.json"), JSON.stringify({ credentials: { production: null, staging: null }, storeAvailable: true, exchanges: [] })); + const invoke = async (args: string[], env: Record = {}) => { + let stdout = "", stderr = ""; + const code = await runCli(args, { processCwd: root, userConfigPath: join(root, "user", "cli.toml"), env: { PROSE_TEST_SERVICE_FIXTURE: join(root, "fixture.json"), ...env }, clock: { now: () => "2026-01-01T00:00:00Z", monotonicMs: () => 0 }, ids: { invocationId: () => "test" }, writeStdout: value => { stdout += value; }, writeStderr: value => { stderr += value; } }); + return { code, stdout, stderr }; + }; + try { await fn(root, invoke); } finally { await rm(root, { recursive: true, force: true }); } +} +test("persistent environment sequences consume shared corpus", async () => { + const corpus = await Bun.file(new URL("../../conformance/runner/service-environment-corpus.json", import.meta.url)).json(); + for (const item of corpus.cases) await workspace(async (root, invoke) => { + if (item.userConfig !== undefined) await writeFile(join(root, "user", "cli.toml"), item.userConfig); + for (const step of item.steps) { + const result = await invoke(step.argv); + expect(result.code).toBe(step.exitCode); + expect(JSON.parse(result.stdout)).toMatchObject(step.resultMatches); + expect(result.stderr).toBe(""); + } + if (item.preserve !== undefined) expect(await readFile(join(root, "user", "cli.toml"), "utf8")).toBe(item.preserve); + }); +}); +test("user selection, override and reset isolate credential routes; project selection cannot redirect", async () => workspace(async (root, invoke) => { + await mkdir(join(root, ".prose")); + await writeFile(join(root, ".prose", "cli.toml"), 'service_environment = "staging"\n'); + expect(JSON.parse((await invoke(["cli", "auth", "status", "--json"], { OPENPROSE_STAGING_API_KEY: "malformed" })).stdout)).toMatchObject({ environment: "production", authenticated: false, problem: null }); + expect(JSON.parse((await invoke(["cli", "config", "explain", "--json"])).stdout).code).toBe("CONFIG_INVALID"); + await invoke(["cli", "environment", "use", "staging", "--json"]); + expect(JSON.parse((await invoke(["cli", "auth", "status", "--json"], { OPENPROSE_API_KEY: "malformed" })).stdout)).toMatchObject({ environment: "staging", authenticated: false, problem: null }); + expect(JSON.parse((await invoke(["--service-environment", "production", "cli", "auth", "status", "--json"])).stdout).environment).toBe("production"); + expect(JSON.parse((await invoke(["cli", "environment", "show", "--json"])).stdout).environment).toBe("staging"); + const human = await invoke(["cli", "org", "list"]); + expect(human.stderr).toContain("OpenProse staging"); + expect(JSON.parse((await invoke(["--service-environment", "production", "cli", "environment", "reset", "--json"])).stdout).code).toBe("INVOCATION_INVALID"); +})); +test("malformed user selection fails before service and unsafe config writes preserve targets", async () => workspace(async (root, invoke) => { + const path = join(root, "user", "cli.toml"); + await writeFile(path, 'service_environment = "unknown"\n'); + expect(JSON.parse((await invoke(["cli", "auth", "status", "--json"])).stdout).code).toBe("CONFIG_INVALID"); + await rm(path); + const target = join(root, "target.toml"); + await writeFile(target, '# untouched\n'); + await symlink(target, path); + expect(JSON.parse((await invoke(["cli", "environment", "use", "staging", "--json"])).stdout).code).toBe("CONFIG_INVALID"); + expect(await readFile(target, "utf8")).toBe('# untouched\n'); +})); +test("production requests pin origin and use only production bearer", async () => { + const original = globalThis.fetch; + try { + globalThis.fetch = (async (input: unknown, options?: RequestInit) => { + expect(input).toBe("https://run-prose-production.openprose.workers.dev/organizations"); + expect((options?.headers as Record).Authorization).toBe(`Bearer ${token}`); + return Response.json({ organizations: [] }); + }) as typeof fetch; + let stdout = ""; + expect(await runServiceAccount("org-list", "json", { env: { OPENPROSE_API_KEY: token, OPENPROSE_STAGING_API_KEY: "malformed" }, writeStdout: value => { stdout += value; }, writeStderr: () => {} })).toBe(0); + expect(JSON.parse(stdout)).toMatchObject({ environment: "production", problem: null }); + } finally { globalThis.fetch = original; } +}); +test("both service credentials resist explicit child reallow", () => { + const result = buildChildEnvironment({ OPENPROSE_API_KEY: token, OPENPROSE_STAGING_API_KEY: token }, { invocationId: "id", recursionToken: "r", runNonce: "n" }, ["OPENPROSE_API_KEY", "OPENPROSE_STAGING_API_KEY"]); + expect(result.OPENPROSE_API_KEY).toBeUndefined(); + expect(result.OPENPROSE_STAGING_API_KEY).toBeUndefined(); +}); diff --git a/cli/ci/run_local.py b/cli/ci/run_local.py index 6965210..dbbc6fe 100644 --- a/cli/ci/run_local.py +++ b/cli/ci/run_local.py @@ -413,6 +413,12 @@ def gates() -> tuple[Gate, ...]: (python, "cli/conformance/runner/staging_service.py", "--", str(CLI_ROOT / "bun" / "dist" / "prose-test")), ), + Gate("service-environment-rust", REPOSITORY_ROOT, + (python, "cli/conformance/runner/service_environment.py", "--", + str(CLI_ROOT / "rust" / "target" / "debug" / "prose"))), + Gate("service-environment-bun", REPOSITORY_ROOT, + (python, "cli/conformance/runner/service_environment.py", "--", + str(CLI_ROOT / "bun" / "dist" / "prose-test"))), Gate( "conformance-host", REPOSITORY_ROOT, diff --git a/cli/conformance/cases/fixtures/runner-help.txt b/cli/conformance/cases/fixtures/runner-help.txt index 263f79f..b57e472 100644 --- a/cli/conformance/cases/fixtures/runner-help.txt +++ b/cli/conformance/cases/fixtures/runner-help.txt @@ -46,9 +46,16 @@ The first language-command token ends runner-option parsing. `prose help` is forwarded to OpenProse; use `prose --help` for this runner help. Use `prose -- cli ...` to forward a language command named `cli`. -Staging account commands (no hosted execution): - prose --service-environment staging cli auth login|status|logout [--json] - prose --service-environment staging cli org list [--json] +OpenProse service commands (production by default; no hosted execution): + prose cli auth login|status|logout [--json] + prose cli org list [--json] + prose cli environment show [--json] + prose cli environment use production|staging [--json] + prose cli environment reset [--json] -Login stores credentials in the OS credential store. For CI, set -OPENPROSE_STAGING_API_KEY; logout removes only the local credential. +Environment selection persists in user configuration. Reset returns to production. +Account commands also accept an ephemeral --service-environment override before cli. +Staging service output is labeled; JSON reports include the selected environment. +Login stores credentials separately per environment in the OS credential store. +For CI, use OPENPROSE_API_KEY or OPENPROSE_STAGING_API_KEY for the selected service. +Logout removes only the selected local credential. diff --git a/cli/conformance/cases/operations/auth-status-unavailable.json b/cli/conformance/cases/operations/auth-status-unavailable.json index 9e4ce43..f10bba5 100644 --- a/cli/conformance/cases/operations/auth-status-unavailable.json +++ b/cli/conformance/cases/operations/auth-status-unavailable.json @@ -1,9 +1,15 @@ { "schema": "openprose.runner-case/1", "id": "operations.auth-status-unavailable", - "summary": "The advertised OpenProse account status route fails at the hosted boundary rather than as unknown syntax.", + "summary": "Production account status rejects an invalid explicit credential without network or native store access.", "phase": 6, - "tags": ["cli", "auth", "agent-dx", "differential", "no-secret"], + "tags": [ + "cli", + "auth", + "agent-dx", + "differential", + "no-secret" + ], "controls": { "fixedInvocationId": "fixture-invocation-0001", "fixedTimestamp": "2025-01-01T00:00:00Z", @@ -11,25 +17,39 @@ "network": "denied" }, "invocation": { - "argv": ["--output", "json", "cli", "auth", "status"], + "argv": [ + "--output", + "json", + "cli", + "auth", + "status" + ], "cwd": "{{WORKSPACE}}", "environment": { "HOME": "{{WORKSPACE}}/home", - "XDG_CONFIG_HOME": "{{WORKSPACE}}/config" + "XDG_CONFIG_HOME": "{{WORKSPACE}}/config", + "OPENPROSE_API_KEY": "invalid-fixture-credential" } }, "expected": { "exitCode": 10, - "stdout": { "kind": "json", "schema": "openprose.account-status/1" }, - "stderr": { "kind": "empty" }, + "stdout": { + "kind": "json", + "schema": "openprose.service-account/1" + }, + "stderr": { + "kind": "empty" + }, "startedHarness": false, "resultMatches": { - "schema": "openprose.account-status/1", - "availability": "unavailable", - "authenticated": null, - "authCategory": "openprose-account", - "billingOwner": "openprose", - "credentialStorage": "unavailable" + "schema": "openprose.service-account/1", + "environment": "production", + "operation": "status", + "authenticated": false, + "credentialSource": "none", + "problem": { + "code": "SERVICE_PROTOCOL_INVALID" + } } } } diff --git a/cli/conformance/runner/service-environment-corpus.json b/cli/conformance/runner/service-environment-corpus.json new file mode 100644 index 0000000..4318f12 --- /dev/null +++ b/cli/conformance/runner/service-environment-corpus.json @@ -0,0 +1,175 @@ +{ + "schema": "openprose.service-environment-corpus/1", + "cases": [ + { + "id": "persist-and-reset", + "steps": [ + { + "argv": [ + "cli", + "environment", + "show", + "--json" + ], + "exitCode": 0, + "resultMatches": { + "schema": "openprose.service-environment/1", + "environment": "production", + "source": "default", + "problem": null + } + }, + { + "argv": [ + "cli", + "environment", + "use", + "staging", + "--json" + ], + "exitCode": 0, + "resultMatches": { + "schema": "openprose.service-environment/1", + "environment": "staging", + "source": "user-config", + "problem": null + } + }, + { + "argv": [ + "cli", + "environment", + "show", + "--json" + ], + "exitCode": 0, + "resultMatches": { + "schema": "openprose.service-environment/1", + "environment": "staging", + "source": "user-config", + "problem": null + } + }, + { + "argv": [ + "cli", + "environment", + "reset", + "--json" + ], + "exitCode": 0, + "resultMatches": { + "schema": "openprose.service-environment/1", + "environment": "production", + "source": "default", + "problem": null + } + }, + { + "argv": [ + "cli", + "environment", + "show", + "--json" + ], + "exitCode": 0, + "resultMatches": { + "schema": "openprose.service-environment/1", + "environment": "production", + "source": "default", + "problem": null + } + } + ] + }, + { + "id": "explicit-production", + "steps": [ + { + "argv": [ + "cli", + "environment", + "use", + "production", + "--json" + ], + "exitCode": 0, + "resultMatches": { + "schema": "openprose.service-environment/1", + "environment": "production", + "source": "user-config", + "problem": null + } + }, + { + "argv": [ + "cli", + "environment", + "show", + "--json" + ], + "exitCode": 0, + "resultMatches": { + "schema": "openprose.service-environment/1", + "environment": "production", + "source": "user-config", + "problem": null + } + }, + { + "argv": [ + "cli", + "environment", + "reset", + "--json" + ], + "exitCode": 0, + "resultMatches": { + "schema": "openprose.service-environment/1", + "environment": "production", + "source": "default", + "problem": null + } + } + ] + }, + { + "id": "preserve-user-settings", + "userConfig": "# keep this comment\nharness = \"codex\"\n", + "preserve": "# keep this comment\nharness = \"codex\"\n", + "steps": [ + { + "argv": [ + "cli", + "environment", + "use", + "staging", + "--json" + ], + "exitCode": 0, + "resultMatches": { + "schema": "openprose.service-environment/1", + "environment": "staging", + "source": "user-config", + "problem": null + } + }, + { + "argv": [ + "cli", + "environment", + "reset", + "--json" + ], + "exitCode": 0, + "resultMatches": { + "schema": "openprose.service-environment/1", + "environment": "production", + "source": "default", + "problem": null + } + } + ] + } + ] +} diff --git a/cli/conformance/runner/service_environment.py b/cli/conformance/runner/service_environment.py new file mode 100644 index 0000000..079761f --- /dev/null +++ b/cli/conformance/runner/service_environment.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Hermetic process checks for persistent service selection across fresh invocations.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +from staging_service import matches + +CORPUS = Path(__file__).with_name('service-environment-corpus.json') + + +def run(command): + for case in json.loads(CORPUS.read_text())['cases']: + with tempfile.TemporaryDirectory(prefix='prose-environment-oracle-') as directory: + root = Path(directory) + config = root / 'config' / 'openprose' / 'cli.toml' + config.parent.mkdir(parents=True) + if 'userConfig' in case: + config.write_text(case['userConfig']) + fixture = root / 'service.json' + fixture.write_text(json.dumps({'credentials': {'production': None, 'staging': None}, 'storeAvailable': True, 'exchanges': []})) + env = {'PATH': os.environ.get('PATH', '/usr/bin:/bin'), 'HOME': directory, + 'XDG_CONFIG_HOME': str(root/'config'), 'TMPDIR': directory, + 'PROSE_TEST_SERVICE_FIXTURE': str(fixture), + 'HTTP_PROXY': 'http://127.0.0.1:9', 'HTTPS_PROXY': 'http://127.0.0.1:9', + 'ALL_PROXY': 'http://127.0.0.1:9', 'NO_PROXY': ''} + for step in case['steps']: + result = subprocess.run([*command, *step['argv']], cwd=root, env=env, capture_output=True, timeout=10) + assert result.returncode == step['exitCode'], (case['id'], result.returncode, result.stderr) + actual = json.loads(result.stdout) + matches(actual, step['resultMatches']) + assert set(actual) == set(step['resultMatches']) + assert not result.stderr, (case['id'], result.stderr) + if 'preserve' in case: + assert config.read_text() == case['preserve'] + print('PASS', case['id']) + return 0 + + +if __name__ == '__main__': + args = sys.argv[1:] + if args[:1] == ['--']: + args = args[1:] + if not args: + raise SystemExit('Supply a test-seam executable after --') + raise SystemExit(run(args)) diff --git a/cli/protocol/OWNERSHIP.md b/cli/protocol/OWNERSHIP.md index 8a2242e..f6a9fc3 100644 --- a/cli/protocol/OWNERSHIP.md +++ b/cli/protocol/OWNERSHIP.md @@ -502,3 +502,9 @@ Root owns `cli/CONTRIBUTING.md` for clarifying the Python version required by th `repository_gate_diagnosis` additionally owns `.github/ISSUE_TEMPLATE/openprose-cli-{bug,harness-model,benchmark-profile}.yml`, `cli/SUPPORT.md`, `cli/CONTRIBUTING.md` (preserve Python clarification), and `cli/ci/test_check_alpha_public_docs.py` to restore documented contributor forms and replace a mutable historical snapshot with a controlled fixture. No release workflows or gate bypass. Root additionally authorizes `repository_gate_diagnosis` repository URL substitutions only in `cli/ci/check_alpha_public_docs.py` and `cli/ci/test_check_contributor_docs.py`; preserve substantive privacy and routing checks. + +## IMP-034 persistent service environments + +User authorized production-default account operations with a persistent user-only staging selection in PR #8. Root owns shared spec, schemas, corpus, help fixtures, integration and Git. `environment_bun` owns `cli/bun/src/` and `cli/bun/test/`; `environment_rust` owns `cli/rust/crates/prose-runner-core/src/` and `cli/rust/crates/prose-cli/src/` plus their tests. Both wait for shared contract/cases before implementation. `environment_review` is read-only architecture/security/UX review. No backend deployment, real credential mutation or production requests. Existing leases in these paths are superseded for this phase. + +Root integration also owns `docs/staging-account.md`, `cli/conformance/runner/service_environment.py`, `cli/conformance/cases/operations/auth-status-unavailable.json`, and `cli/ci/run_local.py` for environment qualification and hermetic no-flag auth admission. diff --git a/cli/rust/crates/prose-cli/src/main.rs b/cli/rust/crates/prose-cli/src/main.rs index 8edeb96..db216f3 100644 --- a/cli/rust/crates/prose-cli/src/main.rs +++ b/cli/rust/crates/prose-cli/src/main.rs @@ -279,33 +279,6 @@ fn prepare( _ => {} } - if parsed.globals.service_environment.is_some() { - if let Action::Runner { ref command, json } = parsed.action { - if matches!( - command, - RunnerCommand::AuthLogin - | RunnerCommand::AuthStatus - | RunnerCommand::AuthLogout - | RunnerCommand::OrgList - ) { - let mode = if json { - OutputMode::Json - } else { - parsed.globals.output.unwrap_or_default() - }; - return prose_runner_core::service_account::execute(command, mode, cancellation); - } - } - return error_outcome( - RunnerError::invocation( - "service environment requires an account or organization command", - ), - error_mode, - &clock, - &ids, - ); - } - let system = match SystemContext::capture() { Ok(system) => system, Err(error) => { @@ -317,6 +290,13 @@ fn prepare( ); } }; + if let Action::Runner { ref command, json } = parsed.action { + if prose_runner_core::service_account::is_service_command(command) { + let mode = if json { OutputMode::Json } else { parsed.globals.output.unwrap_or_default() }; + return prose_runner_core::service_account::execute_user_command(command, &parsed.globals, &system, mode, cancellation) + .unwrap_or_else(|error| error_outcome(error, mode, &clock, &ids)); + } + } let config = match resolve_config(&parsed.globals, &system) { Ok(config) => config, Err(error) => { diff --git a/cli/rust/crates/prose-cli/tests/cli.rs b/cli/rust/crates/prose-cli/tests/cli.rs index 401a39c..f8242e7 100644 --- a/cli/rust/crates/prose-cli/tests/cli.rs +++ b/cli/rust/crates/prose-cli/tests/cli.rs @@ -4982,40 +4982,56 @@ fn doctor_reports_installed_adapter_configuration_blockers_without_fallback() { )); } +#[cfg(feature = "test-seams")] #[test] -fn account_status_and_mutations_fail_closed_at_the_hosted_boundary() { +fn account_status_and_mutations_use_only_hermetic_service_store() { let temp = TempDir::new().unwrap(); - let home = temp.path().join("home"); - let xdg = home.join("xdg"); - let observation = temp.path().join("must-not-exist.json"); - fs::create_dir_all(&xdg).unwrap(); - let status = Command::new(env!("CARGO_BIN_EXE_prose")) - .args(["--output", "json", "cli", "auth", "status"]) - .current_dir(temp.path()) - .env_clear() - .env("HOME", &home) - .env("XDG_CONFIG_HOME", &xdg) - .env("OPENAI_API_KEY", "must-not-appear-openai") - .env("ANTHROPIC_API_KEY", "must-not-appear-anthropic") - .env("OPENPROSE_TOKEN", "must-not-appear-openprose") - .env("OPENPROSE_CONFORMANCE_FAKE_HARNESS", fake_harness()) - .env("OPENPROSE_CONFORMANCE_FAKE_SCENARIO", "success") - .env("OPENPROSE_CONFORMANCE_FAKE_OBSERVATION", &observation) - .output() - .unwrap(); - assert_eq!(status.status.code(), Some(10)); - let account = operation_fixture("account"); - assert_eq!(json_stdout(&status), account); - assert!(!observation.exists()); - assert!(!String::from_utf8_lossy(&status.stdout).contains("must-not-appear")); - - for command in ["login", "logout"] { - let output = prose(temp.path(), &["--output", "json", "cli", "auth", command]); - assert_eq!(output.status.code(), Some(10), "{command}"); - assert_eq!(json_stdout(&output), account["problem"], "{command}"); + let fixture = temp.path().join("service.json"); + fs::write(&fixture, json!({"environment":"production","credential":null,"storeAvailable":false,"exchanges":[]}).to_string()).unwrap(); + for command in ["status", "login", "logout"] { + let output = Command::new(env!("CARGO_BIN_EXE_prose")) + .args(["cli", "auth", command, "--json"]) + .current_dir(temp.path()).env_clear() + .env("HOME", temp.path().join("home")) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("PROSE_TEST_SERVICE_FIXTURE", &fixture) + .output().unwrap(); + let result = json_stdout(&output); + assert_eq!(result["schema"], "openprose.service-account/1"); + assert_eq!(result["environment"], "production"); + assert_eq!(result["problem"]["code"], "CREDENTIAL_STORE_UNAVAILABLE"); } } +#[cfg(feature = "test-seams")] +#[test] +fn persistent_service_selection_and_credentials_are_isolated() { + let temp = TempDir::new().unwrap(); + let xdg = temp.path().join("xdg"); + fs::create_dir_all(xdg.join("openprose")).unwrap(); + let config = xdg.join("openprose/cli.toml"); + fs::write(&config, "# retain comment\nharness = \"codex\"\n").unwrap(); + let fixture = temp.path().join("service.json"); + fs::write(&fixture, json!({"environment":"staging","credentials":{"production":"rr_test_11111111111111111111111111111111","staging":null},"storeAvailable":true,"exchanges":[]}).to_string()).unwrap(); + let run = |args: &[&str]| Command::new(env!("CARGO_BIN_EXE_prose")) + .args(args).current_dir(temp.path()).env_clear() + .env("HOME", temp.path().join("home")).env("XDG_CONFIG_HOME", &xdg) + .env("PROSE_TEST_SERVICE_FIXTURE", &fixture) + .env("OPENPROSE_API_KEY", "rr_test_11111111111111111111111111111111") + .output().unwrap(); + assert_eq!(json_stdout(&run(&["cli", "environment", "show", "--json"]))["environment"], "production"); + assert_eq!(json_stdout(&run(&["cli", "environment", "use", "staging", "--json"]))["source"], "user-config"); + assert_eq!(json_stdout(&run(&["cli", "environment", "show", "--json"]))["environment"], "staging"); + let status = json_stdout(&run(&["cli", "auth", "status", "--json"])); + assert_eq!(status["environment"], "staging"); + assert_eq!(status["authenticated"], false); + assert_eq!(status["credentialSource"], "none"); + let human = run(&["cli", "org", "list"]); + assert!(String::from_utf8_lossy(&human.stderr).contains("OpenProse staging")); + assert_eq!(json_stdout(&run(&["cli", "environment", "reset", "--json"]))["source"], "default"); + assert_eq!(fs::read_to_string(config).unwrap(), "# retain comment\nharness = \"codex\"\n"); +} + #[test] fn human_failures_keep_result_stdout_clean() { let temp = TempDir::new().unwrap(); diff --git a/cli/rust/crates/prose-runner-core/src/config.rs b/cli/rust/crates/prose-runner-core/src/config.rs index f7f34b5..ab87932 100644 --- a/cli/rust/crates/prose-runner-core/src/config.rs +++ b/cli/rust/crates/prose-runner-core/src/config.rs @@ -294,6 +294,12 @@ pub fn write_user_harness( RunnerError::config(format!("cannot serialize user configuration: {error}")) })? .into_bytes(); + atomic_user_config_write(&path, &bytes)?; + Ok(UserHarnessSelection { path, changed: true }) +} + +fn atomic_user_config_write(path: &Path, bytes: &[u8]) -> Result<(), RunnerError> { + let parent = path.parent().ok_or_else(|| RunnerError::config("user configuration has no parent directory"))?; let temporary = parent.join(format!(".cli.toml.{}.tmp", uuid::Uuid::now_v7())); let write_result = (|| -> std::io::Result<()> { let mut options = OpenOptions::new(); @@ -304,15 +310,15 @@ pub fn write_user_harness( options.mode(0o600); } let mut file = options.open(&temporary)?; - file.write_all(&bytes)?; + file.write_all(bytes)?; file.sync_all()?; // Reauthenticate both names at the last practical boundary before the // atomic replacement. This preserves the same fail-closed behavior as // the Bun implementation if a local actor substitutes a direct parent // or destination symlink while the new bytes are being prepared. harden_config_parent_io(parent)?; - refuse_symlinked_config_destination_io(&path)?; - fs::rename(&temporary, &path)?; + refuse_symlinked_config_destination_io(path)?; + fs::rename(&temporary, path)?; Ok(()) })(); if let Err(error) = write_result { @@ -322,10 +328,7 @@ pub fn write_user_harness( path.display() ))); } - Ok(UserHarnessSelection { - path, - changed: true, - }) + Ok(()) } fn prepare_private_config_parent(parent: &Path) -> Result<(), RunnerError> { @@ -476,6 +479,7 @@ impl EffectiveConfig { #[derive(Debug, Clone, Default, Deserialize)] #[serde(deny_unknown_fields)] struct FileConfig { + service_environment: Option, harness: Option, transport: Option, model: Option, @@ -502,6 +506,7 @@ struct LoadedFileConfig { } const FILE_CONFIG_KEYS: &[&str] = &[ + "service_environment", "harness", "transport", "model", @@ -890,6 +895,11 @@ fn apply_file( values: source_values, lines, } = loaded; + if let Some(value) = source_values.service_environment { + if source.kind != ConfigSourceKind::UserFile || !matches!(value.as_str(), "production" | "staging") { + return Err(file_value_error(&source, &lines, "service_environment", "Service environment must be production or staging and may only be set in user configuration.")); + } + } if let Some(value) = source_values.harness { target.harness.replace( validate_harness("harness", value).map_err(|_| { @@ -1843,3 +1853,205 @@ pub(crate) fn native_output_bytes(config: &EffectiveConfig) -> usize { pub(crate) fn native_output_limits(config: &EffectiveConfig) -> Option { (config.output_contract.value == "native").then(|| serde_json::json!({"maxAggregateStdoutBytes":native_output_bytes(config),"maxNativeCaptureBytes":native_output_bytes(config),"captureEnabled":config.native_log.value.is_some()})) } + +/// User-only service selection, separate from harness configuration. +#[derive(Debug, Clone)] +pub struct ServiceSelection { + pub environment: String, + pub source: &'static str, +} + +fn read_service_config( + system: &SystemContext, +) -> Result<(PathBuf, String, FileConfig), RunnerError> { + let path = system.user_config_path()?; + let bytes = match fs::read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(_) => return Err(RunnerError::config("Cannot read user configuration.")), + }; + let text = decode_configuration(&bytes, &path)?.to_owned(); + let lines = validate_flat_toml(&text, &path)?; + let values: FileConfig = toml::from_str(&text).map_err(|_| { + config_line_error( + &path, + 1, + "Configuration does not match the supported flat TOML subset.", + ) + })?; + let mut validated = + EffectiveConfig::defaults(system.current_dir.clone(), None, Some(path.clone())); + apply_file( + &mut validated, + LoadedFileConfig { + values: values.clone(), + lines, + }, + ConfigSource::file(ConfigSourceKind::UserFile, &path), + )?; + Ok((path, text, values)) +} + +/// Reads only the user's service selection; workspace files cannot redirect it. +/// +/// # Errors +/// Returns `CONFIG_INVALID` if the user path or configuration is invalid. +pub fn resolve_service_selection(system: &SystemContext) -> Result { + let (_, _, values) = read_service_config(system)?; + Ok(ServiceSelection { + source: if values.service_environment.is_some() { + "user-config" + } else { + "default" + }, + environment: values + .service_environment + .unwrap_or_else(|| "production".into()), + }) +} + +/// Saves or removes only the service selection, preserving unrelated text. +/// +/// # Errors +/// Returns `CONFIG_INVALID` for invalid values, unsafe paths, invalid existing +/// configuration, or an atomic filesystem update failure. +pub fn write_service_selection( + system: &SystemContext, + environment: Option<&str>, +) -> Result { + if environment.is_some_and(|value| !matches!(value, "production" | "staging")) { + return Err(RunnerError::config( + "Service environment must be production or staging.", + )); + } + let path = system.user_config_path()?; + let parent = path + .parent() + .ok_or_else(|| RunnerError::config("user configuration has no parent directory"))?; + prepare_private_config_parent(parent)?; + refuse_symlinked_config_destination(&path)?; + let (_, existing, values) = read_service_config(system)?; + if values.service_environment.as_deref() != environment { + let mut updated = String::new(); + for line in existing.split_inclusive('\n') { + if line + .split_once('=') + .is_some_and(|(key, _)| key.trim() == "service_environment") + { + if let Some((_, comment)) = line.split_once('#') { + updated.push('#'); + updated.push_str(comment); + } + } else { + updated.push_str(line); + } + } + if let Some(environment) = environment { + if !updated.is_empty() && !updated.ends_with('\n') { + updated.push('\n'); + } + updated.push_str(&format!("service_environment = \"{environment}\"\n")); + } + atomic_user_config_write(&path, updated.as_bytes())?; + } + Ok(ServiceSelection { + environment: environment.unwrap_or("production").into(), + source: if environment.is_some() { + "user-config" + } else { + "default" + }, + }) +} + +#[cfg(test)] +mod service_selection_tests { + use super::*; + fn system(root: &Path) -> SystemContext { + SystemContext { + current_dir: root.to_owned(), + home_dir: Some(root.join("home")), + xdg_config_home: Some(root.join("xdg")), + appdata: None, + environment: BTreeMap::new(), + platform: Platform::Unix, + } + } + #[test] + fn service_selection_is_user_only_and_bad_config_never_defaults() { + let root = tempfile::tempdir().unwrap(); + let system = system(root.path()); + assert_eq!( + resolve_service_selection(&system).unwrap().environment, + "production" + ); + fs::create_dir_all(root.path().join(".prose")).unwrap(); + fs::write( + root.path().join(".prose/cli.toml"), + "service_environment = \"staging\"\n", + ) + .unwrap(); + assert_eq!( + resolve_service_selection(&system).unwrap().environment, + "production" + ); + assert!(resolve_config(&GlobalFlags::default(), &system).is_err()); + let path = system.user_config_path().unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "service_environment = \"other\"\n").unwrap(); + assert!(resolve_service_selection(&system).is_err()); + assert!(write_service_selection(&system, Some("production")).is_err()); + assert_eq!( + fs::read_to_string(path).unwrap(), + "service_environment = \"other\"\n" + ); + } + #[test] + fn service_selection_preserves_comments_and_harness_writer_preserves_selection() { + let root = tempfile::tempdir().unwrap(); + let system = system(root.path()); + let path = system.user_config_path().unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + "# user note\nharness = \"codex\"\nservice_environment = \"staging\" # service note\n", + ) + .unwrap(); + write_service_selection(&system, None).unwrap(); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "# user note\nharness = \"codex\"\n# service note\n" + ); + write_service_selection(&system, Some("staging")).unwrap(); + let config = resolve_config(&GlobalFlags::default(), &system).unwrap(); + write_user_harness(&config, "claude", None, None).unwrap(); + assert_eq!( + resolve_service_selection(&system).unwrap().environment, + "staging" + ); + } + #[cfg(unix)] + #[test] + fn service_selection_refuses_destination_and_parent_symlinks() { + use std::os::unix::fs::symlink; + let root = tempfile::tempdir().unwrap(); + let system = system(root.path()); + let path = system.user_config_path().unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let target = root.path().join("target"); + fs::write(&target, "harness = \"codex\"\n").unwrap(); + symlink(&target, &path).unwrap(); + assert!(write_service_selection(&system, Some("staging")).is_err()); + assert_eq!( + fs::read_to_string(&target).unwrap(), + "harness = \"codex\"\n" + ); + fs::remove_file(&path).unwrap(); + fs::remove_dir(path.parent().unwrap()).unwrap(); + let redirected = root.path().join("redirected"); + fs::create_dir(&redirected).unwrap(); + symlink(&redirected, path.parent().unwrap()).unwrap(); + assert!(write_service_selection(&system, Some("staging")).is_err()); + assert!(!redirected.join("cli.toml").exists()); + } +} diff --git a/cli/rust/crates/prose-runner-core/src/installed_adapters.rs b/cli/rust/crates/prose-runner-core/src/installed_adapters.rs index b008f15..d77e5d4 100644 --- a/cli/rust/crates/prose-runner-core/src/installed_adapters.rs +++ b/cli/rust/crates/prose-runner-core/src/installed_adapters.rs @@ -2924,7 +2924,7 @@ pub fn environment_policy( // Remove service-only credentials from ambient storage as well as the // closed allowlist, so later allow_inherited calls cannot recover them. let ambient = ambient.into_iter() - .filter(|(name, _)| !name.to_string_lossy().eq_ignore_ascii_case("OPENPROSE_STAGING_API_KEY")) + .filter(|(name, _)| !name.to_string_lossy().eq_ignore_ascii_case("OPENPROSE_STAGING_API_KEY") && !name.to_string_lossy().eq_ignore_ascii_case("OPENPROSE_API_KEY")) .collect::>(); auth_readiness(adapter, auth_group, &ambient)?; let mut policy = EnvironmentPolicy::from_pairs(ambient); @@ -2946,7 +2946,7 @@ pub(crate) fn version_probe_environment( ambient: impl IntoIterator, ) -> EnvironmentPolicy { let ambient = ambient.into_iter() - .filter(|(name, _)| !name.to_string_lossy().eq_ignore_ascii_case("OPENPROSE_STAGING_API_KEY")); + .filter(|(name, _)| !name.to_string_lossy().eq_ignore_ascii_case("OPENPROSE_STAGING_API_KEY") && !name.to_string_lossy().eq_ignore_ascii_case("OPENPROSE_API_KEY")); let mut policy = EnvironmentPolicy::from_pairs(ambient); for name in VERSION_PROBE_ENVIRONMENT { policy = policy.allow_inherited(*name, Sensitivity::Public); @@ -3582,12 +3582,12 @@ mod tests { #[test] fn staging_service_token_cannot_be_reallowed_into_harness_or_probe() { let secret = "rr_test_11111111111111111111111111111111"; - let ambient = || vec![(OsString::from("OPENPROSE_STAGING_API_KEY"), OsString::from(secret))]; + let ambient = || vec![(OsString::from("OPENPROSE_STAGING_API_KEY"), OsString::from(secret)), (OsString::from("OPENPROSE_API_KEY"), OsString::from(secret))]; let adapter = InstalledAdapter::CodexExecJson; let harness = environment_policy(adapter, adapter.default_probe_auth_group(), ambient()).unwrap(); let probe = version_probe_environment(adapter, ambient()); for policy in [harness, probe] { - let policy = policy.allow_inherited("OPENPROSE_STAGING_API_KEY", Sensitivity::Secret); + let policy = policy.allow_inherited("OPENPROSE_STAGING_API_KEY", Sensitivity::Secret).allow_inherited("OPENPROSE_API_KEY", Sensitivity::Secret); assert!(!policy.secret_strings().iter().any(|value| value == secret)); assert!(!policy.output_protected_strings().iter().any(|value| value == secret)); } diff --git a/cli/rust/crates/prose-runner-core/src/invocation.rs b/cli/rust/crates/prose-runner-core/src/invocation.rs index 884d345..e19f992 100644 --- a/cli/rust/crates/prose-runner-core/src/invocation.rs +++ b/cli/rust/crates/prose-runner-core/src/invocation.rs @@ -65,6 +65,9 @@ pub enum RunnerCommand { AuthLogin, AuthLogout, OrgList, + EnvironmentShow, + EnvironmentUse(String), + EnvironmentReset, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -108,10 +111,6 @@ pub fn parse_invocation( return Err(RunnerError::invocation("service environment requires an account or organization command")); } } - if parsed.globals.service_environment.is_none() - && matches!(parsed.action, Action::Runner { command: RunnerCommand::OrgList, .. }) { - return Err(RunnerError::invocation("organization commands require the staging service environment")); - } Ok(parsed) } @@ -264,7 +263,7 @@ fn set_value_option(globals: &mut GlobalFlags, name: &str, value: &str) -> Resul } match name { "--service-environment" => { - if value != "staging" { return Err(RunnerError::invocation("service environment must be staging")); } + if !matches!(value, "staging" | "production") { return Err(RunnerError::invocation("service environment must be production or staging")); } set_once(&mut globals.service_environment, name, value)?; } "--harness" => globals.harness = Some(value.to_owned()), @@ -336,6 +335,9 @@ fn parse_runner_command(args: &[String], globals: &mut GlobalFlags) -> Result { (RunnerCommand::ConfigExplain, tail) } + [environment, show, tail @ ..] if environment == "environment" && show == "show" => (RunnerCommand::EnvironmentShow, tail), + [environment, reset, tail @ ..] if environment == "environment" && reset == "reset" => (RunnerCommand::EnvironmentReset, tail), + [environment, use_command, value, tail @ ..] if environment == "environment" && use_command == "use" && matches!(value.as_str(), "production" | "staging") => (RunnerCommand::EnvironmentUse(value.clone()), tail), [org, list, tail @ ..] if org == "org" && list == "list" => (RunnerCommand::OrgList, tail), [auth, status, tail @ ..] if auth == "auth" && status == "status" => { (RunnerCommand::AuthStatus, tail) @@ -422,7 +424,7 @@ fn known_runner_help_path(args: &[String]) -> bool { values.as_slice(), ["--help"] | [ - "doctor" | "harness" | "cleanup" | "config" | "auth" | "org", + "doctor" | "harness" | "cleanup" | "config" | "auth" | "org" | "environment", "--help" ] | ["harness", "list" | "use", "--help"] @@ -432,6 +434,8 @@ fn known_runner_help_path(args: &[String]) -> bool { | ["config", "explain", "--help"] | ["auth", "status" | "login" | "logout", "--help"] | ["org", "list", "--help"] + | ["environment", "show" | "reset" | "use", "--help"] + | ["environment", "use", _, "--help"] ) } diff --git a/cli/rust/crates/prose-runner-core/src/runner.rs b/cli/rust/crates/prose-runner-core/src/runner.rs index bca4cda..4c5b9eb 100644 --- a/cli/rust/crates/prose-runner-core/src/runner.rs +++ b/cli/rust/crates/prose-runner-core/src/runner.rs @@ -193,6 +193,12 @@ fn execute_inner( human_stream: Option<&mut dyn IoWrite>, ) -> CommandOutcome { let mode = action_output_mode(parsed, config); + if let Action::Runner { command, .. } = &parsed.action { + if crate::service_account::is_service_command(command) { + return crate::SystemContext::capture().and_then(|system| crate::service_account::execute_user_command(command, &parsed.globals, &system, mode, cancellation)) + .unwrap_or_else(|error| error_outcome(error, mode, clock, ids)); + } + } let mut outcome = match &parsed.action { Action::Help => CommandOutcome::human(HELP, "", 0), Action::Version => CommandOutcome::human(format!("prose {RUNNER_VERSION} (rust)\n"), "", 0), @@ -594,34 +600,8 @@ fn execute_runner_command( RunnerCommand::CleanupPrime(handle) => { execute_prime_cleanup(&handle, mode, clock, ids, &std::env::temp_dir()) } - RunnerCommand::AuthStatus => { - let problem = hosted_unavailable(); - let report = json!({ - "schema": "openprose.account-status/1", - "availability": "unavailable", - "authenticated": null, - "authCategory": "openprose-account", - "billingOwner": "openprose", - "credentialStorage": "unavailable", - "problem": problem - }); - if mode == OutputMode::Human { - CommandOutcome::human( - format!( - "OpenProse account: unavailable\nAuthentication: unknown\nCredential storage: unavailable\nProblem: {} — {}\nAction: {}\n", - problem.code, - problem.message, - problem.human_action() - ), - "", - problem.exit_code, - ) - } else { - CommandOutcome::json(report, problem.exit_code) - } - } - RunnerCommand::AuthLogin | RunnerCommand::AuthLogout | RunnerCommand::OrgList => { - error_outcome(hosted_unavailable(), mode, clock, ids) + RunnerCommand::AuthStatus | RunnerCommand::AuthLogin | RunnerCommand::AuthLogout | RunnerCommand::OrgList | RunnerCommand::EnvironmentShow | RunnerCommand::EnvironmentUse(_) | RunnerCommand::EnvironmentReset => { + unreachable!("service commands are dispatched before harness operations") } } } @@ -737,12 +717,6 @@ fn render_harness_status_human( } } -fn hosted_unavailable() -> RunnerError { - RunnerError::catalog(ErrorCode::HostedUnavailable) - .with_detail("billingOwner", "openprose") - .with_detail("fallbackSelected", false) -} - fn supported_transports(harness: &str) -> Option<&'static [&'static str]> { match harness { "openprose" => Some(&["hosted"]), diff --git a/cli/rust/crates/prose-runner-core/src/service_account.rs b/cli/rust/crates/prose-runner-core/src/service_account.rs index 6c3fa55..e856582 100644 --- a/cli/rust/crates/prose-runner-core/src/service_account.rs +++ b/cli/rust/crates/prose-runner-core/src/service_account.rs @@ -1,4 +1,4 @@ -//! Explicit staging account operations. Credentials never enter harness configuration. +//! Environment-isolated account operations. Credentials never enter harness configuration. use crate::error::ErrorCode; use crate::output::CommandOutcome; use crate::{CancellationToken, OutputMode, RunnerCommand, RunnerError}; @@ -6,7 +6,38 @@ use serde_json::{Value, json}; use std::io::{Read, Write}; use std::time::{Duration, Instant}; -const BASE: &str = "https://run-prose-staging.openprose.workers.dev"; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ServiceEnvironment { + Production, + Staging, +} +impl ServiceEnvironment { + fn from_selection(value: &str) -> Self { + if value == "staging" { + Self::Staging + } else { + Self::Production + } + } + fn name(self) -> &'static str { + match self { + Self::Production => "production", + Self::Staging => "staging", + } + } + fn origin(self) -> &'static str { + match self { + Self::Production => "https://run-prose-production.openprose.workers.dev", + Self::Staging => "https://run-prose-staging.openprose.workers.dev", + } + } + fn variable(self) -> &'static str { + match self { + Self::Production => "OPENPROSE_API_KEY", + Self::Staging => "OPENPROSE_STAGING_API_KEY", + } + } +} const LIMIT: u64 = 65_536; fn problem(code: ErrorCode) -> RunnerError { @@ -22,6 +53,7 @@ fn valid_token(token: &str) -> bool { } struct Session { + environment: ServiceEnvironment, fixture: Option, next: usize, cancellation: CancellationToken, @@ -29,7 +61,10 @@ struct Session { } impl Session { - fn new(cancellation: &CancellationToken) -> Result { + fn new( + cancellation: &CancellationToken, + environment: ServiceEnvironment, + ) -> Result { #[allow(unused_mut)] let mut fixture: Option = None; #[cfg(feature = "test-seams")] @@ -54,14 +89,25 @@ impl Session { || !value["exchanges"] .as_array() .is_some_and(|v| v.len() <= 182) - || !value + || !(value .get("credential") .is_some_and(|v| v.is_null() || v.is_string()) + || value.get("credentials").is_some_and(Value::is_object)) + || value + .get("environment") + .is_some_and(|v| v != environment.name()) + || value.get("credentials").is_some_and(|v| { + !v.is_object() + || ["production", "staging"] + .iter() + .any(|key| !v.get(*key).is_some_and(|v| v.is_null() || v.is_string())) + }) { return Err(problem(ErrorCode::ServiceProtocolInvalid)); } } Ok(Self { + environment, fixture, next: 0, deadline: None, @@ -98,16 +144,21 @@ impl Session { if fixture["storeAvailable"] == false { return Err(problem(ErrorCode::CredentialStoreUnavailable)); } - let previous = fixture["credential"].as_str().map(str::to_owned); + let slot = if fixture.get("credentials").is_some() { + &mut fixture["credentials"][self.environment.name()] + } else { + &mut fixture["credential"] + }; + let previous = slot.as_str().map(str::to_owned); if operation == "set" { - fixture["credential"] = json!(token); + *slot = json!(token); } if operation == "delete" { - fixture["credential"] = Value::Null; + *slot = Value::Null; } return Ok(previous); } - native_store(operation, token, &self.cancellation) + native_store(operation, token, &self.cancellation, self.environment) } fn request( @@ -125,9 +176,17 @@ impl Session { return Err(problem(ErrorCode::ServiceProtocolInvalid)); } if let Some(token) = token { - let expected = std::env::var("OPENPROSE_STAGING_API_KEY") + let expected = std::env::var(self.environment.variable()) .ok() - .or_else(|| fixture["credential"].as_str().map(str::to_owned)); + .filter(|value| !value.is_empty()) + .or_else(|| { + let value = if fixture.get("credentials").is_some() { + &fixture["credentials"][self.environment.name()] + } else { + &fixture["credential"] + }; + value.as_str().map(str::to_owned) + }); if expected.as_deref() != Some(token) { return Err(problem(ErrorCode::ServiceProtocolInvalid)); } @@ -136,7 +195,12 @@ impl Session { .get(self.next) .ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; self.next += 1; - if exchange["method"] != method || exchange["path"] != path { + if exchange["method"] != method + || exchange["path"] != path + || exchange + .get("origin") + .is_some_and(|v| v != self.environment.origin()) + { return Err(problem(ErrorCode::ServiceProtocolInvalid)); } ( @@ -157,7 +221,7 @@ impl Session { .redirects(0) .build(); let mut request = agent - .request(method, &format!("{BASE}{path}")) + .request(method, &format!("{}{path}", self.environment.origin())) .set("Accept", "application/json"); if let Some(token) = token { request = request.set("Authorization", &format!("Bearer {token}")); @@ -257,10 +321,11 @@ fn organizations(value: Value) -> Result { } Ok(json!(output)) } -/// Executes an account operation with a bounded staging transport. +/// Executes an account operation with a bounded environment-isolated transport. #[must_use] pub fn execute( command: &RunnerCommand, + selected: ServiceEnvironment, mode: OutputMode, cancellation: &CancellationToken, ) -> CommandOutcome { @@ -274,8 +339,8 @@ pub fn execute( let mut authenticated = false; let mut rows = json!([]); let result = (|| -> Result<(), RunnerError> { - let mut session = Session::new(cancellation)?; - let environment = std::env::var("OPENPROSE_STAGING_API_KEY") + let mut session = Session::new(cancellation, selected)?; + let environment = std::env::var(selected.variable()) .ok() .filter(|s| !s.is_empty()); if environment.is_some() && matches!(operation, "login" | "logout") { @@ -410,22 +475,36 @@ pub fn execute( let exit = error.as_ref().map_or(0, |e| e.exit_code); let report = if operation == "list" { json!({ - "schema":"openprose.organization-list/1","environment":"staging","organizations":rows,"problem":error + "schema":"openprose.organization-list/1","environment":selected.name(),"organizations":rows,"problem":error }) } else { json!({ - "schema":"openprose.service-account/1","environment":"staging","operation":operation,"authenticated":authenticated,"credentialSource":source,"problem":error + "schema":"openprose.service-account/1","environment":selected.name(),"operation":operation,"authenticated":authenticated,"credentialSource":source,"problem":error }) }; if mode == OutputMode::Human { if let Some(error) = error { - CommandOutcome::human("", format!("{}: {}\n", error.code, error.message), exit) + CommandOutcome::human( + "", + format!( + "OpenProse {}: {}: {}\n", + selected.name(), + error.code, + error.message + ), + exit, + ) } else if operation == "list" { - CommandOutcome::human(format!("{}\n", rows), "", 0) + CommandOutcome::human( + format!("OpenProse {} organizations:\n{}\n", selected.name(), rows), + "", + 0, + ) } else { CommandOutcome::human( format!( - "Staging account {operation}: {}\n", + "OpenProse {} account {operation}: {}\n", + selected.name(), if authenticated { "authenticated" } else { @@ -446,6 +525,7 @@ fn native_store( _: &str, _: Option<&str>, _: &CancellationToken, + _: ServiceEnvironment, ) -> Result, RunnerError> { Err(problem(ErrorCode::CredentialStoreUnavailable)) } @@ -455,11 +535,12 @@ fn native_store( operation: &str, token: Option<&str>, cancellation: &CancellationToken, + environment: ServiceEnvironment, ) -> Result, RunnerError> { use std::process::{Command, Stdio}; // No shell or token argv. Interactive security command parsing receives only // fixed commands and a closed ASCII token alphabet over an anonymous pipe. - let arguments = "-s org.openprose.cli.staging -a api-key"; + let arguments = format!("-s org.openprose.cli.{} -a api-key", environment.name()); let script = match operation { "get" => format!("find-generic-password {arguments} -w\n"), "delete" => format!("delete-generic-password {arguments}\n"), @@ -550,12 +631,124 @@ fn native_store( Ok(None) } } +#[must_use] +pub fn is_service_command(command: &RunnerCommand) -> bool { + matches!( + command, + RunnerCommand::AuthLogin + | RunnerCommand::AuthStatus + | RunnerCommand::AuthLogout + | RunnerCommand::OrgList + | RunnerCommand::EnvironmentShow + | RunnerCommand::EnvironmentUse(_) + | RunnerCommand::EnvironmentReset + ) +} + +/// Resolves and executes service commands without reading workspace configuration. +/// +/// # Errors +/// Returns an invocation or configuration error before any credential access +/// when the command or user configuration is invalid. +pub fn execute_user_command( + command: &RunnerCommand, + flags: &crate::GlobalFlags, + system: &crate::SystemContext, + mode: OutputMode, + cancellation: &CancellationToken, +) -> Result { + if !is_service_command(command) { + return Err(RunnerError::invocation( + "Expected a service or environment command.", + )); + } + let selection = match command { + RunnerCommand::EnvironmentUse(value) => { + crate::config::write_service_selection(system, Some(value))? + } + RunnerCommand::EnvironmentReset => crate::config::write_service_selection(system, None)?, + _ => crate::config::resolve_service_selection(system)?, + }; + if matches!( + command, + RunnerCommand::EnvironmentShow + | RunnerCommand::EnvironmentUse(_) + | RunnerCommand::EnvironmentReset + ) { + return Ok(if mode == OutputMode::Human { + CommandOutcome::human( + format!( + "OpenProse {} environment ({})\n", + selection.environment, selection.source + ), + "", + 0, + ) + } else { + CommandOutcome::json( + json!({"schema":"openprose.service-environment/1","environment":selection.environment,"source":selection.source,"problem":null}), + 0, + ) + }); + } + let selected = ServiceEnvironment::from_selection( + flags + .service_environment + .as_deref() + .unwrap_or(&selection.environment), + ); + Ok(execute(command, selected, mode, cancellation)) +} + #[cfg(test)] mod tests { + #[test] + fn fixture_credentials_and_origins_are_environment_isolated() { + for environment in [ServiceEnvironment::Production, ServiceEnvironment::Staging] { + let mut session = Session { + environment, + fixture: Some( + json!({"storeAvailable":true,"credentials":{"production":"production-only","staging":"staging-only"},"exchanges":[{"method":"POST","path":"/auth/device","origin":environment.origin(),"status":200,"body":{}}]}), + ), + next: 0, + deadline: None, + cancellation: CancellationToken::default(), + }; + assert_eq!( + session.store("get", None).unwrap().unwrap(), + format!("{}-only", environment.name()) + ); + session.store("delete", None).unwrap(); + let other = if environment == ServiceEnvironment::Production { + "staging" + } else { + "production" + }; + assert_eq!( + session.fixture.as_ref().unwrap()["credentials"][other], + format!("{other}-only") + ); + assert!( + session + .request("POST", "/auth/device", None, json!({})) + .is_ok() + ); + session.next = 0; + session.fixture.as_mut().unwrap()["exchanges"][0]["origin"] = + json!("https://untrusted.invalid"); + assert!( + session + .request("POST", "/auth/device", None, json!({})) + .is_err() + ); + } + } + #[test] fn transport_failures_prioritize_cancellation_then_expiry() { let cancel = CancellationToken::default(); let mut session = Session { + environment: ServiceEnvironment::Staging, fixture: None, next: 0, deadline: None, @@ -593,6 +786,7 @@ mod tests { fn fixture_transport_fails_closed_on_missing_or_wrong_requests() { let mut session = Session { + environment: ServiceEnvironment::Staging, fixture: Some(json!({ "credential":null,"storeAvailable":true,"exchanges":[] })), @@ -657,6 +851,7 @@ mod tests { let cancel = CancellationToken::default(); cancel.cancel(); let mut session = Session { + environment: ServiceEnvironment::Staging, fixture: Some(json!({ "credential":null,"storeAvailable":true,"exchanges":[] })), @@ -680,6 +875,7 @@ mod tests { fn fixture_store_is_in_memory_and_logout_is_idempotent() { let mut session = Session { + environment: ServiceEnvironment::Staging, fixture: Some(json!({ "credential":null,"storeAvailable":true,"exchanges":[] })), @@ -710,7 +906,7 @@ mod tests { "auth", "status" ]) - .is_err() + .is_ok() ); let parsed = parse(&["run", "--service-environment", "staging"]).unwrap(); assert!(parsed.globals.service_environment.is_none()); diff --git a/cli/shared/schemas/organization-list.schema.json b/cli/shared/schemas/organization-list.schema.json index 35f62ac..e3bb301 100644 --- a/cli/shared/schemas/organization-list.schema.json +++ b/cli/shared/schemas/organization-list.schema.json @@ -14,7 +14,10 @@ "const": "openprose.organization-list/1" }, "environment": { - "const": "staging" + "enum": [ + "production", + "staging" + ] }, "organizations": { "type": "array", diff --git a/cli/shared/schemas/service-account.schema.json b/cli/shared/schemas/service-account.schema.json index 73774e9..73096d7 100644 --- a/cli/shared/schemas/service-account.schema.json +++ b/cli/shared/schemas/service-account.schema.json @@ -16,7 +16,10 @@ "const": "openprose.service-account/1" }, "environment": { - "const": "staging" + "enum": [ + "production", + "staging" + ] }, "operation": { "enum": [ diff --git a/cli/shared/schemas/service-environment.schema.json b/cli/shared/schemas/service-environment.schema.json new file mode 100644 index 0000000..038bd74 --- /dev/null +++ b/cli/shared/schemas/service-environment.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.openprose.org/cli/v1/service-environment.schema.json", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "environment", + "source", + "problem" + ], + "properties": { + "schema": { + "const": "openprose.service-environment/1" + }, + "environment": { + "enum": [ + "production", + "staging" + ] + }, + "source": { + "enum": [ + "default", + "user-config" + ] + }, + "problem": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "runner-error.schema.json" + } + ] + } + } +} diff --git a/docs/staging-account.md b/docs/staging-account.md index dd91388..45fd9a5 100644 --- a/docs/staging-account.md +++ b/docs/staging-account.md @@ -1,38 +1,35 @@ -# Connect the CLI to staging +# Service accounts and environments -This feature is being developed under IMP-034. It is not included in an existing published release merely because this document exists. Use a candidate executable built from the same reviewed revision. - -Staging is an explicit service environment. Local harness execution and model-provider credentials are separate from the OpenProse service account. - -## Account connection - -The intended command sequence is: +The CLI connects to the production OpenProse account service by default. Local model providers and local execution retain their own configuration; selecting a service environment does not enable hosted execution. ```sh -prose --service-environment staging cli auth login -prose --service-environment staging cli auth status --json -prose --service-environment staging cli org list --json -prose --service-environment staging cli auth logout +prose cli auth login +prose cli auth status --json +prose cli org list --json +prose cli auth logout ``` -Login starts the existing service's GitHub device flow. Follow the verification URL and enter the displayed code in your browser. The CLI waits within the authorization expiry and reports cancellation or failure. Staging admission remains controlled by the backend's account allowlist. +Login displays a GitHub verification URL and code. Approve the request in your browser; the CLI waits for completion and saves the credential in the operating system credential store. Status verifies a stored credential against the service. Logout removes the local credential; it does not revoke the server credential. -Local credentials belong in operating-system credential storage, identified separately from production credentials and from credentials used by installed agent harnesses. If a supported credential store is unavailable, login must fail explicitly rather than save the key in plaintext. Logging out removes the selected local credential; it does not revoke every account session. +## Use staging -For automation, the staging-specific `OPENPROSE_STAGING_API_KEY` supplies a previously issued service credential. Supply it through your CI secret facility. It takes precedence over the local credential store. Do not put the value in shell history, source files, command arguments, or issue reports. Removing a local credential cannot unset a credential supplied by the parent environment. - -## Scope +```sh +prose cli environment use staging +prose cli environment show --json +prose cli auth login +prose cli environment reset +``` -This connection supports account authentication and organization discovery. It does not enable hosted execution or contract publication. Registry upload and exact-version retrieval are separate IMP-034 delivery steps. +Selection persists in the existing user `cli.toml`. Reset removes the selection and restores production. `use production` saves an explicit production selection. These commands do not contact a service or change credentials. Account commands accept an ephemeral `--service-environment production|staging` before `cli`; this does not change the saved selection. Projects cannot select a service environment. -Status verifies an available credential through the organization API. That endpoint may create the account's default organization on first use. A missing credential is reported as signed out. A failed request must not silently select another environment, use a model-provider key, or fall back to local execution. +Staging service output is labeled `OpenProse staging`; JSON reports identify the environment without a banner. Each environment has an independent credential-store namespace. A request never falls back to another service or credential. Endpoints are fixed; there is no arbitrary destination override. -## Verification +## Automation -Rust and Bun use the same shared conformance cases with a fake service. Those tests do not call GitHub, Cloudflare, or model providers. Live staging checks are explicit and use a separately provisioned account; browser authorization still requires the account owner's participation. Test credentials must not appear in retained output. +Use `OPENPROSE_API_KEY` for production or `OPENPROSE_STAGING_API_KEY` for staging. Only the selected variable is consulted, and it takes precedence over the selected local credential. Login and logout refuse to modify credentials while that variable is active. Neither service key is forwarded to model harnesses, including through explicit inheritance settings. -## Current platform limits +Rust native credential storage currently supports macOS; other platforms can use the scoped environment variable. Bun uses its native credential-store API. No plaintext storage fallback is provided. OS approval may be required; a native operation that cannot be cancelled may finish after timeout. -The candidate Rust implementation uses macOS Keychain through a bounded native helper. Its local credential-store operation is unavailable on other platforms; use the scoped staging environment credential there. Bun uses its native credential-store API where the operating system supports it. Cross-platform native-store parity has not yet been qualified. Neither implementation falls back to a plaintext file. +## Qualification -Native credential-store operations may request operating-system approval. If a store operation times out, inspect account status before retrying: an underlying operation that cannot be cancelled may complete after the CLI reports the timeout. Device login still requires browser approval; a hermetic login test does not prove that live approval path. +Both implementations have hermetic service and persistence tests. Live browser approval and native-store interoperability remain unverified for this candidate. Current source changes are not a released CLI or a production deployment. See [candidate validation](validation/imp-034/README.md). diff --git a/docs/validation/imp-034/README.md b/docs/validation/imp-034/README.md index f474f4b..316d01f 100644 --- a/docs/validation/imp-034/README.md +++ b/docs/validation/imp-034/README.md @@ -45,3 +45,7 @@ The broader historical architecture/release suite failed identically on the unch Rust formatting of the candidate's three newly affected files is corrected. Fifteen pre-existing files still fail the full formatting check, and five initial Clippy diagnostics in unchanged supervisor/framing code remain. These are separate baseline qualification debt; supported-platform CI and live credential-store qualification remain outstanding. No model-provider calls, paid runs, backend edits, deployment or publication were performed. + +## Persistent environment extension + +Production-default account routing and user-only persistent staging selection are implemented in both ports. Shared process checks pass three multi-invocation persistence sequences and all 26 staging cases against both test binaries. Pinned Bun 1.3.5 passes 40 focused environment/account/parser tests and typecheck. Rust focused service/config/process tests pass. Current restricted execution again prevents socket-based full-suite qualification; earlier full-suite results above apply to the preceding candidate, not this extension. A broad agent run used non-pinned Bun 1.4.2 and reported socket failures, a Python warning assertion and a build digest mismatch; it is not pinned-tool qualification evidence. Live login remains unverified. From d0075a89bc0af53c2c7dbebacc7a1d8df0316408 Mon Sep 17 00:00:00 2001 From: mm Date: Mon, 21 Sep 2026 17:03:44 -0400 Subject: [PATCH 4/5] Use selected service environment in account error guidance --- cli/rust/crates/prose-runner-core/src/error.rs | 14 +++++++------- cli/shared/errors/taxonomy.v1.json | 14 +++++++------- cli/shared/schemas/runner-error.schema.json | 14 +++++++------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/cli/rust/crates/prose-runner-core/src/error.rs b/cli/rust/crates/prose-runner-core/src/error.rs index cd095bd..fc59901 100644 --- a/cli/rust/crates/prose-runner-core/src/error.rs +++ b/cli/rust/crates/prose-runner-core/src/error.rs @@ -261,19 +261,19 @@ impl RunnerError { let (boundary, message, action, retryable) = match code { ErrorCode::ServiceUnavailable => ( "hosted-service", - "The staging account service is unavailable.", - "Retry the staging account command later.", + "The OpenProse service is unavailable.", + "Check the selected service environment and retry later.", true, ), ErrorCode::ServiceAuthRequired => ( "authentication", - "Staging account authentication is required.", - "Run prose --service-environment staging cli auth login, then retry.", + "OpenProse service authentication is required.", + "Run cli auth login for the selected service environment, then retry.", false, ), ErrorCode::ServiceProtocolInvalid => ( "protocol", - "The staging account service returned an invalid response.", + "The OpenProse service returned an invalid response.", "Retry later and report the sanitized error code if it persists.", false, ), @@ -286,13 +286,13 @@ impl RunnerError { ErrorCode::DeviceAuthFailed => ( "authentication", "Device authorization failed.", - "Run the staging login command again and authorize the displayed code.", + "Run login again for the selected service environment and authorize the displayed code.", false, ), ErrorCode::DeviceAuthExpired => ( "authentication", "Device authorization expired.", - "Run the staging login command again to obtain a new code.", + "Run login again for the selected service environment to obtain a new code.", true, ), diff --git a/cli/shared/errors/taxonomy.v1.json b/cli/shared/errors/taxonomy.v1.json index b66f8fd..6d98b1d 100644 --- a/cli/shared/errors/taxonomy.v1.json +++ b/cli/shared/errors/taxonomy.v1.json @@ -174,23 +174,23 @@ "boundary": "hosted-service", "exitCode": 10, "retryable": true, - "message": "The staging account service is unavailable.", - "action": "Retry the staging account command later." + "message": "The OpenProse service is unavailable.", + "action": "Check the selected service environment and retry later." }, { "code": "SERVICE_AUTH_REQUIRED", "boundary": "authentication", "exitCode": 10, "retryable": false, - "message": "Staging account authentication is required.", - "action": "Run prose --service-environment staging cli auth login, then retry." + "message": "OpenProse service authentication is required.", + "action": "Run cli auth login for the selected service environment, then retry." }, { "code": "SERVICE_PROTOCOL_INVALID", "boundary": "protocol", "exitCode": 10, "retryable": false, - "message": "The staging account service returned an invalid response.", + "message": "The OpenProse service returned an invalid response.", "action": "Retry later and report the sanitized error code if it persists." }, { @@ -207,7 +207,7 @@ "exitCode": 10, "retryable": false, "message": "Device authorization failed.", - "action": "Run the staging login command again and authorize the displayed code." + "action": "Run login again for the selected service environment and authorize the displayed code." }, { "code": "DEVICE_AUTH_EXPIRED", @@ -215,7 +215,7 @@ "exitCode": 10, "retryable": true, "message": "Device authorization expired.", - "action": "Run the staging login command again to obtain a new code." + "action": "Run login again for the selected service environment to obtain a new code." } ] } diff --git a/cli/shared/schemas/runner-error.schema.json b/cli/shared/schemas/runner-error.schema.json index b9a10aa..9cd9385 100644 --- a/cli/shared/schemas/runner-error.schema.json +++ b/cli/shared/schemas/runner-error.schema.json @@ -221,10 +221,10 @@ "const": true }, "message": { - "const": "The staging account service is unavailable." + "const": "The OpenProse service is unavailable." }, "action": { - "const": "Retry the staging account command later." + "const": "Check the selected service environment and retry later." } } } @@ -252,10 +252,10 @@ "const": false }, "message": { - "const": "Staging account authentication is required." + "const": "OpenProse service authentication is required." }, "action": { - "const": "Run prose --service-environment staging cli auth login, then retry." + "const": "Run cli auth login for the selected service environment, then retry." } } } @@ -283,7 +283,7 @@ "const": false }, "message": { - "const": "The staging account service returned an invalid response." + "const": "The OpenProse service returned an invalid response." }, "action": { "const": "Retry later and report the sanitized error code if it persists." @@ -348,7 +348,7 @@ "const": "Device authorization failed." }, "action": { - "const": "Run the staging login command again and authorize the displayed code." + "const": "Run login again for the selected service environment and authorize the displayed code." } } } @@ -379,7 +379,7 @@ "const": "Device authorization expired." }, "action": { - "const": "Run the staging login command again to obtain a new code." + "const": "Run login again for the selected service environment to obtain a new code." } } } From e1279b78ca8e5c02217d2fdec7e5ef3bf79f3e92 Mon Sep 17 00:00:00 2001 From: mm Date: Mon, 21 Sep 2026 18:47:56 -0400 Subject: [PATCH 5/5] fix: discover output contracts for installed CLI qualification --- cli/conformance/runner/run.py | 26 +++++++++++--------- cli/conformance/runner/test_runner.py | 35 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/cli/conformance/runner/run.py b/cli/conformance/runner/run.py index 9802703..119e8e9 100755 --- a/cli/conformance/runner/run.py +++ b/cli/conformance/runner/run.py @@ -1269,17 +1269,17 @@ def __init__(self) -> None: path.name: json.loads(path.read_text("utf-8")) for path in sorted(SCHEMAS.glob("*.schema.json")) } - self.by_contract = { - "openprose.runner-error/1": "runner-error.schema.json", - "openprose.runner-result/1": "runner-result.schema.json", - "openprose.normalized-event/1": "normalized-event.schema.json", - "openprose.runner-dry-run-report/1": "runner-dry-run-report.schema.json", - "openprose.doctor-report/1": "doctor-report.schema.json", - "openprose.harness-list/1": "harness-list.schema.json", - "openprose.configuration-explanation/1": "configuration-explanation.schema.json", - "openprose.account-status/1": "account-status.schema.json", - "openprose.harness-selection/1": "harness-selection.schema.json", - } + self.by_contract: dict[str, str] = {} + for name, schema in self.schemas.items(): + contract = schema.get("properties", {}).get("schema", {}).get("const") + # Helper schemas have no output-envelope discriminator. + if contract is None: + continue + if not isinstance(contract, str) or not contract: + raise ValueError(f"Invalid contract discriminator in {name}") + if contract in self.by_contract: + raise ValueError(f"Duplicate contract discriminator: {contract}") + self.by_contract[contract] = name registry = Registry() for schema in self.schemas.values(): registry = registry.with_resource( @@ -1288,7 +1288,9 @@ def __init__(self) -> None: self.registry = registry def errors(self, contract: str, instance: Any) -> list[str]: - schema_name = self.by_contract[contract] + schema_name = self.by_contract.get(contract) + if schema_name is None: + return [f"Unknown output contract: {contract}"] validator = jsonschema.Draft202012Validator( self.schemas[schema_name], registry=self.registry, diff --git a/cli/conformance/runner/test_runner.py b/cli/conformance/runner/test_runner.py index 51bde90..a77bd15 100755 --- a/cli/conformance/runner/test_runner.py +++ b/cli/conformance/runner/test_runner.py @@ -15,6 +15,7 @@ import threading import time import unittest +from unittest.mock import patch MODULE_PATH = Path(__file__).with_name("run.py") @@ -27,6 +28,40 @@ SPEC.loader.exec_module(runner) +class ContractRegistryTest(unittest.TestCase): + def test_all_corpus_output_contracts_are_registered(self): + contracts = runner.ContractRegistry() + for path in runner.CASES.rglob("*.json"): + case = json.loads(path.read_text("utf-8")) + for stream in ("stdout", "stderr"): + rule = case.get("expected", {}).get(stream, {}) + if "schema" in rule: + self.assertIn(rule["schema"], contracts.by_contract, str(path)) + # The regression must validate the actual schema, not merely recognize it. + valid = {"schema": "openprose.service-account/1", "environment": "staging", + "operation": "status", "authenticated": False, + "credentialSource": "none", "problem": None} + self.assertEqual([], contracts.errors(valid["schema"], valid)) + self.assertTrue(contracts.errors(valid["schema"], {**valid, "authenticated": "false"})) + + def test_discovers_new_contracts_and_fails_closed_on_unknown_or_duplicate(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + schema = {"$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.test/future.schema.json", "type": "object", + "required": ["schema"], + "properties": {"schema": {"const": "openprose.future-output/1"}}} + (root / "future.schema.json").write_text(json.dumps(schema)) + with patch.object(runner, "SCHEMAS", root): + contracts = runner.ContractRegistry() + self.assertEqual([], contracts.errors("openprose.future-output/1", {"schema": "openprose.future-output/1"})) + self.assertTrue(contracts.errors("openprose.unknown/1", {})) + duplicate = {**schema, "$id": "https://example.test/duplicate.schema.json"} + (root / "duplicate.schema.json").write_text(json.dumps(duplicate)) + with self.assertRaisesRegex(ValueError, "Duplicate contract discriminator"): + runner.ContractRegistry() + + class RunnerUnitTest(unittest.TestCase): def test_host_oracle_keeps_admitted_expectations_and_requires_rejection(self): for path in runner.case_paths(7, set()):