diff --git a/cli/bun/src/cli.ts b/cli/bun/src/cli.ts index 513b2e2d..6f0c1ebd 100644 --- a/cli/bun/src/cli.ts +++ b/cli/bun/src/cli.ts @@ -12,7 +12,7 @@ import runnerHelp from "../../conformance/cases/fixtures/runner-help.txt" with { import dryRunTemplate from "../../shared/fixtures/human/dry-run.v1.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, writeUserHarnessSelection, mutateUserConfiguration, prepareUserConfigurationMutation } from "./core/config"; import { serviceEnvironment } from "./core/service/endpoint"; import { failure } from "./core/errors"; import { argvErrorOutcome, runService } from "./core/service/index"; @@ -148,7 +148,33 @@ export async function runCli(args: readonly string[], dependencies: CliDependenc return await runPrimeCleanup(parsed.value, mode, dependencies); } - const config = await resolveConfiguration(parsed.global, dependencies); + // Set the operation rendering mode before resolution so partial errors retain JSON. + if(parsed.kind === "operation" && parsed.json) mode="json"; + let config: EffectiveConfiguration; + if(parsed.kind === "operation" && ["config-migrate","config-unset"].includes(parsed.operation)) { + const preflight=await prepareUserConfigurationMutation(dependencies); + let mutation: NonNullable; + try {mutation=await mutateUserConfiguration(preflight,parsed.operation === "config-migrate" ? "migrate" : "unset",parsed.configKeys ?? []);} + catch(caught) { + if(caught instanceof RunnerFailure && caught.code === "CONFIG_INVALID") { + let explanation=configurationExplanation(preflight); + try {explanation=configurationExplanation(await resolveConfiguration(parsed.global,dependencies));} + catch(contextError) { + if(contextError instanceof RunnerFailure && contextError.details?.configurationExplanation!==undefined)explanation=contextError.details.configurationExplanation as Record; + } + const diagnostics=Array.isArray(explanation.diagnostics)?explanation.diagnostics:[]; + explanation.diagnostics=[...diagnostics,{code:"CONFIG_INVALID",severity:"error",source:String(caught.details?.source ?? preflight.userConfigPath),reason:String(caught.details?.reason ?? "Runner configuration is invalid.")}]; + explanation.runtime={transport:null,permissionMode:null,authProfile:null,billingOwner:null,nativeLimits:null,nativeOutputLimits:null}; + throw failure("CONFIG_INVALID",{...(caught.details ?? {}),configurationExplanation:explanation}); + } + throw caught; + } + try {config=await resolveConfiguration(parsed.global,dependencies);} + catch(caught) {if(caught instanceof RunnerFailure && caught.code === "CONFIG_INVALID")throw failure("CONFIG_INVALID",{...(caught.details ?? {}),mutation});throw caught;} + config.mutation=mutation; + } else { + config=await resolveConfiguration(parsed.global,{...dependencies,...(parsed.kind === "operation"&&parsed.targetArgv!==undefined ? {targetArgv:parsed.targetArgv}:{})}); + } // The default hosted harness runs no language command, so a language // command word that also names a service command is that rejection. if (hostedRejection !== undefined && config.values.harness === "openprose" && parsed.global.dryRun !== true) { @@ -235,11 +261,15 @@ async function runOperation( ? "Route: cached-chatgpt-login (Codex default; not saved)" : operationValue === "claude" ? "Route: claude-subscription (Claude default; not saved)" + : operationValue === "agents-sdk" + ? "Route: openai-api-key (Agents SDK default; not saved)" : "Route: OpenProse account (external route cleared)"; const savedModel = selection.model !== null ? `Model: ${humanSafeScalar(selection.model)} (saved)` : operationValue === "codex" || operationValue === "claude" ? "Model: harness default (not saved)" + : operationValue === "agents-sdk" + ? "Model: gpt-6.1-sol (Agents SDK default; not saved)" : "Model: OpenProse default (external model cleared)"; dependencies.writeStdout([ `Default harness: ${humanSafeScalar(operationValue)} (${changed ? "updated" : "already selected"})`, @@ -252,7 +282,7 @@ async function runOperation( } else dependencies.writeStdout(jsonLine(report)); return 0; } - if (operation === "config-explain") { + if (["config-explain","config-migrate","config-unset"].includes(operation)) { if (mode === "human") dependencies.writeStdout(humanConfiguration(config)); else dependencies.writeStdout(jsonLine(configurationExplanation(config))); return 0; @@ -1639,7 +1669,7 @@ function configurationProvenance(config: EffectiveConfiguration): Array token !== "--json") || prefix.length > 1 || Object.keys(global).length > 0) invalid("Exact configuration explanation requires options inside its target vector."); + const target = parseEntrypoint(tokens.slice(separator + 1)); + if (target.kind !== "language" || target.argv.length < 2 || (target.redirect !== undefined && target.redirect.language !== true)) invalid("Configuration explanation requires a local language command."); + return { kind: "operation", global: target.global, operation: "config-explain", json: prefix.includes("--json"), targetArgv: target.argv }; + } + if (tokens[0] === "config" && ["migrate", "unset"].includes(tokens[1] ?? "")) { + if(tokens.length === 3 && ["--help","-h"].includes(tokens[2]!)) return {kind:"help",global}; + const rest = tokens.slice(2); const json = rest.at(-1) === "--json"; + if (json) rest.pop(); + if (Object.keys(global).some(key => !["output", "color", "verbose"].includes(key))) invalid("Configuration mutation does not accept runner execution flags."); + if (tokens[1] === "migrate" && rest.length !== 0) invalid("Configuration migrate does not accept operands."); + if (tokens[1] === "unset" && (rest.length === 0 || rest.some(key => !CONFIGURATION_KEYS.includes(key)))) invalid("Configuration unset requires known configuration keys."); + return { kind: "operation", global, operation: tokens[1] === "migrate" ? "config-migrate" : "config-unset", json, configKeys: [...new Set(rest)] }; + } // Help is text in every mode, so `--json` beside a help request is // dropped. const unjson = helpWithoutJson(tokens) ?? tokens; @@ -226,8 +244,8 @@ function withInvocationAction(error: RunnerFailure, action: string): RunnerFailu function parseHarnessUse(global: GlobalFlags, args: readonly string[]): ParsedEntrypoint { const harness = args[0]; - if (harness === undefined || !["openprose", "prime", "omp", "codex", "claude"].includes(harness)) { - invalid("Harness selection must be one of openprose, prime, omp, codex, or claude."); + if (harness === undefined || !["openprose", "agents-sdk", "prime", "omp", "codex", "claude"].includes(harness)) { + invalid("Harness selection must be one of openprose, agents-sdk, prime, omp, codex, or claude."); } let json = false; @@ -280,6 +298,7 @@ function knownRunnerHelpPath(args: readonly string[]): boolean { } export function inferOutputMode(args: readonly string[]): OutputMode { + if (args[0] === "cli" && args[1] === "config" && args.slice(2, args.indexOf("--") < 0 ? args.length : args.indexOf("--")).includes("--json")) return "json"; let index = 0; let mode: OutputMode = "human"; while (index < args.length) { diff --git a/cli/bun/src/core/config.ts b/cli/bun/src/core/config.ts index 8b8e6bbf..cefaee16 100644 --- a/cli/bun/src/core/config.ts +++ b/cli/bun/src/core/config.ts @@ -1,12 +1,15 @@ +import { harnessById } from "./harnesses"; +import { installedAdapterDefinition } from "../adapters/recipes"; +import type { InstalledAdapterId } from "../adapters/types"; import { PUBLISHED_KERNEL_STARTUP } from "./build"; import {nativeOutputLimits,validateNativeOutputBytes} from "../adapters/output-budget"; import {nativeLimits} from "../adapters/sdk-limits"; -import { access, chmod, lstat, mkdir, readFile, realpath, rename, stat, unlink, writeFile } from "node:fs/promises"; +import { access, chmod, lstat, mkdir, readFile, realpath, rename, stat, unlink, writeFile, link } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { dirname, join, parse, posix, resolve, win32 } from "node:path"; import type { GlobalFlags, EffectiveConfiguration, EffectiveValues, SourceKind, ValueSource } from "./types"; import { failure } from "./errors"; -import { quote } from "./output"; +import { quote, configurationExplanation } from "./output"; import { RunnerFailure } from "./types"; import { nativeProfileArgv, validateNativeConfiguration } from "../adapters/native-profile"; @@ -16,6 +19,7 @@ export interface ConfigDependencies { userConfigPath?: string; platform?: NodeJS.Platform; homeDir?: string; + targetArgv?: string[]; } type ConfigKey = keyof EffectiveValues; @@ -43,6 +47,8 @@ const fileKeyMap: Record = { codex_compatibility: "codexCompatibility", }; +export const CONFIGURATION_KEYS = Object.keys(fileKeyMap); + /** * The configuration keys in their one validation order (the order of `values` * in shared/schemas/configuration-explanation.schema.json, with nativeLog @@ -112,6 +118,28 @@ export async function resolveConfiguration( flags: GlobalFlags, dependencies: ConfigDependencies, ): Promise { + try {return await resolveConfigured(flags,dependencies);} + catch(caught) {throw earlyConfigurationFailure(caught,dependencies);} +} + +function earlyConfigurationFailure(caught:unknown,dependencies:ConfigDependencies):unknown { + if(!(caught instanceof RunnerFailure) || caught.code!=="CONFIG_INVALID" || caught.details?.configurationExplanation!==undefined)throw caught; + const source=String(caught.details?.source ?? "configuration"); + const reason=String(caught.details?.reason ?? "Runner configuration is invalid."); + const values=Object.fromEntries(SETTINGS.map(([key])=>[key,{value:defaults[key] ?? (key==="nativeProfile"?"default":["nativeAddDirs","nativeAllowTools"].includes(key)?[]:null),source:{kind:"default",location:"built-in"}}])); + const candidates=Object.fromEntries(Object.entries(values).map(([key,entry])=>[key,[{...entry,selected:true}]])); + return failure("CONFIG_INVALID",{...caught.details,configurationExplanation:{ + schema:"openprose.configuration-explanation/1", + cwd:{value:dependencies.processCwd,source:{kind:"default",location:"process cwd"}}, + projectConfigPath:null,userConfigPath:null,values, + target:dependencies.targetArgv===undefined?null:{argv:dependencies.targetArgv}, + locations:[],candidates,diagnostics:[{code:"CONFIG_INVALID",severity:"error",source,reason}], + runtime:{transport:null,permissionMode:null,authProfile:null,billingOwner:null,nativeLimits:null,nativeOutputLimits:null}, + }}); +} + +async function resolveConfigured(flags: GlobalFlags,dependencies: ConfigDependencies):Promise { + if((dependencies.platform ?? process.platform) === "win32") dependencies={...dependencies,env:Object.fromEntries(Object.entries(dependencies.env).map(([key,value])=>[key.toUpperCase(),value]))}; const requestedCwd = flags.cwd === undefined ? dependencies.processCwd : resolve(dependencies.processCwd, flags.cwd); const cwdLocation = flags.cwd === undefined ? "process cwd" : "--cwd"; let cwd: string; @@ -127,30 +155,88 @@ export async function resolveConfiguration( const userConfigPath = dependencies.userConfigPath ?? defaultUserConfigPath(dependencies); const pathApi = (dependencies.platform ?? process.platform) === "win32" ? win32 : posix; if (userConfigPath.length === 0 || !pathApi.isAbsolute(userConfigPath)) { - fail("OpenProse user configuration path must be absolute."); - } - const projectConfigPath = await discoverProjectConfig(cwd); + fail("OpenProse user configuration path must be absolute.","user configuration path"); + } + const locations: NonNullable = []; + const diagnostics: NonNullable = []; + let projectConfigPath: string | null = null; + const legacyConfigPath = dependencies.userConfigPath === undefined && dependencies.env.PROSE_CONFIG_DIR === undefined + ? legacyUserConfigPath(dependencies) : null; + if(dependencies.userConfigPath === undefined && dependencies.env.PROSE_CONFIG_DIR === undefined && dependencies.env.XDG_CONFIG_HOME !== undefined && legacyConfigPath === null) diagnostics.push({code:"LEGACY_CONFIG_ROOT_INVALID",severity:"warning",source:"XDG_CONFIG_HOME",reason:"Legacy configuration root is not a non-empty absolute path and is ignored."}); const values: EffectiveValues = { ...defaults }; const sources = Object.fromEntries( - (Object.keys(defaults) as ConfigKey[]).map((key) => [key, { kind: "default", location: "built-in" }]), + SETTINGS.map(([key]) => [key, { kind: "default", location: "built-in" }]), ) as { [K in ConfigKey]: ValueSource }; - - // The same physical file is loaded once; in both roles the nearest - // project role is authoritative. - const userConfig = await regularFile(userConfigPath); - if (userConfig !== null && userConfig !== projectConfigPath) { - const user = await readConfig(userConfig, true); - apply(values, sources, user.values, "user-config", user.locations); - } - if (projectConfigPath !== null) { - const project = await readConfig(projectConfigPath); - apply(values, sources, project.values, "project-config", project.locations); - } - - const environment = parseEnvironment(dependencies.env); - apply(values, sources, environment.values, "environment", environment.locations); - const invocation = parseFlags(flags); - apply(values, sources, invocation.values, "flag", invocation.locations); + const candidates: NonNullable = Object.fromEntries(SETTINGS.map(([key]) => [key, [{value: values[key] ?? (key === "nativeProfile" ? "default" : ["nativeAddDirs","nativeAllowTools"].includes(key) ? [] : null), source: sources[key], selected: true}]])); + const config: EffectiveConfiguration = { cwd, cwdSource: flags.cwd === undefined ? {kind:"default",location:"process cwd"} : {kind:"flag",location:"--cwd"}, values, sources, projectConfigPath, userConfigPath, legacyConfigPath, activeUserConfigPath:null, target:dependencies.targetArgv === undefined ? null : {argv:dependencies.targetArgv}, locations, candidates, diagnostics }; + const owners: Partial> = {}; + const candidateHarnesses = new WeakMap(); + const ranks: Partial> = {}; + let currentHarness = values.harness, harnessRank = 0; + const overlay = (parsed: ParsedValues, kind: SourceKind, rank: number) => { + if (parsed.values.harness !== undefined) {currentHarness=parsed.values.harness;harnessRank=rank;} + for (const key of Object.keys(parsed.values) as ConfigKey[]) { + const source = {kind,location:parsed.locations[key] ?? kind}; + for (const candidate of candidates[key]!) candidate.selected=false; + const candidate={value:parsed.values[key],source,selected:true}; + candidateHarnesses.set(candidate,currentHarness); + candidates[key]!.push(candidate); + owners[key]=currentHarness; ranks[key]=rank; + } + apply(values,sources,parsed.values,kind,parsed.locations); + }; + try { + projectConfigPath=await discoverProjectConfig(cwd,locations);config.projectConfigPath=projectConfigPath; + locations.unshift({role:"user",path:userConfigPath,present:false,selected:false}); + const userConfig = await strictConfigFile(userConfigPath); + const legacyConfig = legacyConfigPath === null || legacyConfigPath === userConfigPath ? null : await regularFile(legacyConfigPath); + locations[0]={role:"user",path:userConfigPath,present:userConfig!==null,selected:userConfig!==null}; + if (legacyConfigPath !== null && legacyConfigPath !== userConfigPath) locations.splice(1,0,{role:"legacy-user",path:legacyConfigPath,present:legacyConfig!==null,selected:userConfig===null&&legacyConfig!==null}); + config.activeUserConfigPath = userConfig !== null ? userConfigPath : legacyConfig !== null ? legacyConfigPath : null; + if (config.activeUserConfigPath !== null && (userConfig ?? legacyConfig) !== projectConfigPath) overlay(await readConfig(config.activeUserConfigPath,true),"user-config",1); + if (legacyConfig !== null && userConfig === null) diagnostics.push({code:"LEGACY_CONFIG_ACTIVE",severity:"warning",source:legacyConfigPath!,reason:"Legacy user configuration is active; run prose cli config migrate to copy explicit settings."}); + if (legacyConfig !== null && userConfig !== null && legacyConfig !== userConfig) { + let reason="Canonical user configuration is authoritative; legacy values are ignored."; + try { + const old=await readConfig(legacyConfig,true); const now=await readConfig(userConfig,true); + const differing=SETTINGS.filter(([key]) => old.values[key]!==undefined&&JSON.stringify(old.values[key])!==JSON.stringify(now.values[key])).map(([,key])=>key); + if(differing.length) reason+=` Differing explicit keys: ${differing.join(", ")}.`; + } catch {reason+=" Ignored legacy configuration is invalid.";} + diagnostics.push({code:"LEGACY_CONFIG_IGNORED",severity:"warning",source:legacyConfigPath!,reason}); + } + if (projectConfigPath !== null) overlay(await readConfig(projectConfigPath),"project-config",2); + overlay(parseEnvironment(dependencies.env),"environment",3); + overlay(parseFlags(flags),"flag",4); + candidates.authProfile=candidates.authProfile!.filter(candidate=>{ + if(candidate.selected || candidate.value===null)return true; + const owner=candidateHarnesses.get(candidate); + const descriptor=owner===undefined ? undefined : harnessById(owner); + if(descriptor?.runtime!=="installed-process")return true; + const definition=installedAdapterDefinition(`${owner}/${descriptor.transports[0]}` as InstalledAdapterId); + if(typeof candidate.value==="string" && definition.credentialGroups[candidate.value]!==undefined)return true; + diagnostics.push({code:"CONFIG_CANDIDATE_INVALID",severity:"warning",source:candidate.source.location,reason:"Incompatible overridden authentication profile is omitted."}); + return false; + }); + for (const key of ["model","authProfile"] as const) { + if (sources[key].kind === "default") { + const value = key === "model" ? (values.harness === "agents-sdk" ? "gpt-6.1-sol" : null) + : ({"agents-sdk":"openai-api-key",codex:"cached-chatgpt-login",claude:"claude-subscription"} as Record)[values.harness] ?? null; + values[key]=value; + if (value!==null) sources[key]={kind:"default",location:`built-in:${values.harness}`}; + candidates[key]![0]={value,source:sources[key],selected:true}; + } else if (owners[key] !== values.harness && (ranks[key] ?? 0)segment.length>0&&!/[\s\p{Cc}]/u.test(segment))) throw failure("CONFIG_INVALID",{adapterId:`${values.harness}/${descriptor.transports[0]}`,source:sources.model.location,reason:"Prime and OMP models must be a fully qualified provider/model with no empty, whitespace, or control-character segments."}); + if(["prime","omp"].includes(values.harness) && values.model!==null && !values.model.includes("/")) throw failure("CONFIG_INVALID",{adapterId:`${values.harness}/${descriptor.transports[0]}`,source:sources.model.location,reason:"Prime and OMP models must be a fully qualified provider/model with no empty, whitespace, or control-character segments."}); + if(values.authProfile !== null && definition.credentialGroups[values.authProfile] === undefined) throw failure("CONFIG_INVALID",{adapterId:`${values.harness}/${descriptor.transports[0]}`,reason:"Authentication profile is incompatible with the selected harness.",source:sources.authProfile.location,supportedAuthProfiles:Object.keys(definition.credentialGroups)}); + } + if(values.harness==="agents-sdk" && values.permissionMode!==null)throw failure("CONFIG_INVALID",{adapterId:"agents-sdk/jsonl",source:sources.permissionMode.location,reason:"Unsupported explicit permission mode for this harness."}); if (values.codexCompatibility !== "qualified" && values.harness !== "codex") fail("Codex compatibility probe requires the codex harness.", sources.codexCompatibility?.location); @@ -173,35 +259,61 @@ export async function resolveConfiguration( try { await validateNativeConfiguration(values, cwd); } catch (caught) { at(["nativeAddDirs"], () => { throw caught; }); } } - return { - cwd, - cwdSource: flags.cwd === undefined - ? { kind: "default", location: "process cwd" } - : { kind: "flag", location: "--cwd" }, - values, - sources, - projectConfigPath, - userConfigPath, - }; + config.runtime = { + transport: values.transport === "auto" ? descriptor?.transports[0] ?? null : values.transport, + permissionMode: values.permissionMode ?? null, + authProfile: values.authProfile, + billingOwner: descriptor?.billingOwner ?? null, + nativeLimits: nativeLimits(values) ?? null, + nativeOutputLimits: nativeOutputLimits(values) ?? null, + }; + return config; + } catch (caught) { + if (caught instanceof RunnerFailure && caught.code === "CONFIG_INVALID") { + const rejectedSource=String(caught.details?.source ?? "configuration"); + for(const [key] of SETTINGS) if(sources[key].location===rejectedSource && sources[key].kind!=="default") { + const safe=(candidates[key] ?? []).filter(candidate=>candidate.source.location!==rejectedSource); + const previous=safe.at(-1); + if(previous) { + safe.forEach((candidate,index)=>{candidate.selected=index===safe.length-1;}); + sources[key]=previous.source; + if(previous.value===null && !["model","authProfile","permissionMode"].includes(key))delete values[key]; + else Object.assign(values,{[key]:previous.value}); + } + candidates[key]=safe; + } + diagnostics.push({code:"CONFIG_INVALID",severity:"error",source:rejectedSource,reason:String(caught.details?.reason ?? "Runner configuration is invalid.")}); + throw failure("CONFIG_INVALID",{...(caught.details ?? {}),configurationExplanation:configurationExplanation(config)}); + } + throw caught; + } } function defaultUserConfigPath(dependencies: ConfigDependencies): string { - const platform = dependencies.platform ?? process.platform; - const pathApi = platform === "win32" ? win32 : posix; - const requireRoot = (value: string | undefined, name: string): string => { - if (value === undefined || value.length === 0 || !pathApi.isAbsolute(value)) { - fail(`${name} must be a non-empty absolute path to locate OpenProse user configuration.`); - } - return value; - }; - const xdg = dependencies.env.XDG_CONFIG_HOME; - if (xdg !== undefined) return pathApi.join(requireRoot(xdg, "XDG_CONFIG_HOME"), "openprose", "cli.toml"); - if (platform === "win32") { - return pathApi.join(requireRoot(dependencies.env.APPDATA, "APPDATA"), "OpenProse", "cli.toml"); - } - const home = requireRoot(dependencies.homeDir ?? dependencies.env.HOME, "HOME"); - if (platform === "darwin") return pathApi.join(home, "Library", "Application Support", "OpenProse", "cli.toml"); - return pathApi.join(home, ".config", "openprose", "cli.toml"); + const platform=dependencies.platform ?? process.platform; + const pathApi=platform === "win32" ? win32 : posix; + const override=dependencies.env.PROSE_CONFIG_DIR; + if(override!==undefined) { + if(!override || !pathApi.isAbsolute(override)) fail("PROSE_CONFIG_DIR must be a non-empty absolute path to locate OpenProse user configuration.","PROSE_CONFIG_DIR"); + return pathApi.join(override,"cli.toml"); + } + const home=dependencies.homeDir ?? (platform === "win32" ? dependencies.env.USERPROFILE ?? dependencies.env.HOME : dependencies.env.HOME); + if(!home || !pathApi.isAbsolute(home)) fail(`${platform === "win32" ? "USERPROFILE" : "HOME"} must be a non-empty absolute path to locate OpenProse user configuration.`,platform === "win32" ? "USERPROFILE" : "HOME"); + return pathApi.join(home,".prose","cli.toml"); +} +function legacyUserConfigPath(dependencies: ConfigDependencies): string | null { + const platform=dependencies.platform ?? process.platform; + const pathApi=platform === "win32" ? win32 : posix; + const xdg=dependencies.env.XDG_CONFIG_HOME; + if(xdg!==undefined) return xdg && pathApi.isAbsolute(xdg) ? pathApi.join(xdg,"openprose","cli.toml") : null; + if(platform === "win32") return dependencies.env.APPDATA && pathApi.isAbsolute(dependencies.env.APPDATA) ? pathApi.join(dependencies.env.APPDATA,"OpenProse","cli.toml") : null; + const home=dependencies.homeDir ?? dependencies.env.HOME; + if(!home || !pathApi.isAbsolute(home)) return null; + return platform === "darwin" ? pathApi.join(home,"Library","Application Support","OpenProse","cli.toml") : pathApi.join(home,".config","openprose","cli.toml"); +} +async function strictConfigFile(path: string): Promise { + try { const info=await stat(path); if(!info.isFile()) return null; return await realpath(path); } + catch(caught) {if(caught instanceof RunnerFailure)throw caught;if((caught as NodeJS.ErrnoException).code === "ENOENT")return null;fail("Cannot read configuration file.",path);} } /** The canonical path of `path` when it is a regular file (symlinks followed), else null. */ @@ -213,10 +325,12 @@ async function regularFile(path: string): Promise { catch { fail(`Cannot read configuration file: ${path}.`, path); } } -async function discoverProjectConfig(cwd: string): Promise { +async function discoverProjectConfig(cwd: string, locations: NonNullable): Promise { let directory = cwd; while (true) { - const candidate = await regularFile(join(directory, ".prose", "cli.toml")); + const considered = join(directory, ".prose", "cli.toml"); + const candidate = await strictConfigFile(considered); + locations.push({role:"project",path:considered,present:candidate!==null,selected:candidate!==null}); if (candidate !== null) return candidate; if (await exists(join(directory, ".git"))) return null; const parent = dirname(directory); @@ -409,7 +523,7 @@ function validateFileValues(raw: Partial> = {}; const locations: Partial> = {}; const seen = new Set(); @@ -458,12 +572,12 @@ function parseFlatToml(source: string, path: string, allowService = true): Parse if (rawKey === RETIRED_USER_KEY) { if (!allowService) configLineFailure(path, lineNumber, "Configuration contains an unknown key."); } else { - checkFileType(key!, rawKey, parsed, location); + if(!syntaxOnly) checkFileType(key!, rawKey, parsed, location); rawValues[key!] = parsed; locations[key!] = location; } } - return { values: validateFileValues(rawValues, locations), locations }; + return { values: syntaxOnly ? rawValues as PartialValues : validateFileValues(rawValues, locations), locations }; } async function readConfig(path: string, allowService = false): Promise { @@ -685,6 +799,7 @@ async function writeUserSelection(path: string, targetKeys: Set, bundle: if (!current.isFile() || current.isSymbolicLink()) { fail("OpenProse user configuration changed to an unsafe file before replacement.", path); } + if(decodeConfiguration(await readFile(path),path)!==original) fail("OpenProse user configuration changed before replacement.",path); } catch (caught) { if (caught instanceof RunnerFailure) throw caught; if ((caught as NodeJS.ErrnoException).code !== "ENOENT") throw caught; @@ -713,3 +828,99 @@ function parseStringArray(raw:string,path:string,line:number):{value:string[];en cursor++; } } + +/** Only explicit user settings are copied or removed; inherited values are never saved. */ +export async function mutateUserConfiguration(config: EffectiveConfiguration, operation: "migrate" | "unset", keys: string[]): Promise> { + const destination=config.userConfigPath; + if(operation === "unset") { + try {const info=await lstat(destination);if(!info.isFile() || info.isSymbolicLink())fail("OpenProse user configuration must be a regular non-symlink file.",destination);} + catch(caught) {if(caught instanceof RunnerFailure)throw caught;if((caught as NodeJS.ErrnoException).code!=="ENOENT")fail("OpenProse user configuration cannot be read safely.",destination);} + } + const legacy=config.activeUserConfigPath !== destination ? config.activeUserConfigPath ?? null : null; + if(operation === "migrate") { + try {await lstat(destination);fail("Canonical user configuration already exists; migration never overwrites it.",destination);} + catch(caught) {if(caught instanceof RunnerFailure)throw caught;if((caught as NodeJS.ErrnoException).code!=="ENOENT")fail("Canonical user configuration cannot be read safely.",destination);} + if(legacy===null) fail("No legacy user configuration exists to migrate.",destination); + const bytes=await safeUserBytes(legacy); + validateUserSettingsBundle(bytes,legacy); + await createUserConfiguration(destination,bytes); + return {operation,changed:true,path:destination,sourcePath:legacy,keys:[]}; + } + if(config.activeUserConfigPath===null || config.activeUserConfigPath===undefined) return {operation,changed:false,path:destination,sourcePath:null,keys}; + const active=config.activeUserConfigPath; + const bytes=await safeUserBytes(active,operation !== "unset"); + const original=decodeConfiguration(bytes,active); + const retained=original.split(/(?<=\n)/u).filter(line=>{ + const match=/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/u.exec(line); return match?.[1]===undefined || !keys.includes(match[1]); + }).join(""); + validateUserSettingsBundle(new TextEncoder().encode(retained),destination); + const changed=retained!==original || legacy!==null; + if(changed) { + if(legacy!==null) await createUserConfiguration(destination,new TextEncoder().encode(retained)); + else await replaceUserBytes(destination,new TextEncoder().encode(retained),bytes); + } + return {operation,changed,path:destination,sourcePath:legacy,keys}; +} +async function safeUserBytes(path: string, validateValues=true): Promise { + const info=await lstat(path); + if(!info.isFile() || info.isSymbolicLink()) fail("OpenProse user configuration must be a regular non-symlink file.",path); + const bytes=await readFile(path);parseFlatToml(decodeConfiguration(bytes,path),path,true,!validateValues);return bytes; +} +async function createUserConfiguration(path: string, bytes: Uint8Array): Promise { + const parent=dirname(path);await secureUserConfigParent(parent); + const temporary=join(parent,`.cli.toml.openprose-${process.pid}-${randomUUID()}.tmp`); + try { + await writeFile(temporary,bytes,{mode:0o600,flag:"wx"}); + await secureUserConfigParent(parent); + // Atomic no-replace creation. A concurrently created destination is never overwritten. + await link(temporary,path); + } catch(caught) { + fail((caught as NodeJS.ErrnoException).code === "EEXIST" ? "Canonical user configuration already exists; migration never overwrites it." : "OpenProse user configuration could not be created atomically.",path); + } finally {try {await unlink(temporary);}catch{/* no temporary file */}} +} +async function replaceUserBytes(path: string, bytes: Uint8Array, original: Uint8Array): Promise { + const parent=dirname(path);await secureUserConfigParent(parent); + const temporary=join(parent,`.cli.toml.openprose-${process.pid}-${randomUUID()}.tmp`); + try { + await writeFile(temporary,bytes,{mode:0o600,flag:"wx"}); + const current=await safeUserBytes(path,false); + if(!Buffer.from(current).equals(Buffer.from(original))) fail("OpenProse user configuration changed before replacement.",path); + await secureUserConfigParent(parent);await rename(temporary,path); + } catch(caught) {if(caught instanceof RunnerFailure)throw caught;fail("OpenProse user configuration could not be written atomically.",path);} + finally {try {await unlink(temporary);}catch{/* already renamed */}} +} + +/** Mutation preflight examines only the user locations, never project or execution overrides. */ +export async function prepareUserConfigurationMutation(dependencies: ConfigDependencies): Promise { + try {return await prepareUserMutation(dependencies);} + catch(caught) {throw earlyConfigurationFailure(caught,dependencies);} +} +async function prepareUserMutation(dependencies:ConfigDependencies):Promise { + let cwd:string; + try { + if(!(await stat(dependencies.processCwd)).isDirectory())fail(`Working directory is not a directory: ${dependencies.processCwd}.`,"process cwd"); + cwd=await realpath(dependencies.processCwd); + }catch(caught){if(caught instanceof RunnerFailure)throw caught;fail(`Working directory does not exist or cannot be read: ${dependencies.processCwd}.`,"process cwd");} + if((dependencies.platform ?? process.platform) === "win32")dependencies={...dependencies,env:Object.fromEntries(Object.entries(dependencies.env).map(([key,value])=>[key.toUpperCase(),value]))}; + const userConfigPath=dependencies.userConfigPath ?? defaultUserConfigPath(dependencies); + const pathApi=(dependencies.platform ?? process.platform) === "win32" ? win32:posix; + if(!pathApi.isAbsolute(userConfigPath))fail("OpenProse user configuration path must be absolute.","user configuration path"); + const legacyConfigPath=dependencies.userConfigPath===undefined&&dependencies.env.PROSE_CONFIG_DIR===undefined ? legacyUserConfigPath(dependencies):null; + const user=await strictConfigFile(userConfigPath); + const legacy=legacyConfigPath===null ? null:await regularFile(legacyConfigPath); + const values={...defaults}; + const sources=Object.fromEntries(SETTINGS.map(([key])=>[key,{kind:"default",location:"built-in"}])) as EffectiveConfiguration["sources"]; + return {cwd,cwdSource:{kind:"default",location:"process cwd"},values,sources,userConfigPath,legacyConfigPath,activeUserConfigPath:user!==null ? userConfigPath:legacy!==null ? legacyConfigPath:null,projectConfigPath:null,target:null,diagnostics:[],locations:[{role:"user",path:userConfigPath,present:user!==null,selected:user!==null},...(legacyConfigPath===null ? []:[{role:"legacy-user" as const,path:legacyConfigPath,present:legacy!==null,selected:user===null&&legacy!==null}])],candidates:{}}; +} +function validateUserSettingsBundle(bytes: Uint8Array, path: string): void { + const parsed=parseFlatToml(decodeConfiguration(bytes,path),path,true); + const harness=parsed.values.harness; + if(harness===undefined)return; + const descriptor=harnessById(harness); + if(descriptor?.runtime!=="installed-process")return; + const adapterId=`${harness}/${descriptor.transports[0]}` as InstalledAdapterId; + const definition=installedAdapterDefinition(adapterId); + if(parsed.values.authProfile!==undefined && parsed.values.authProfile!==null && definition.credentialGroups[parsed.values.authProfile]===undefined) fail("Authentication profile is incompatible with the selected harness.",parsed.locations.authProfile); + const model=parsed.values.model; + if(["prime","omp"].includes(harness)&&model!==undefined&&model!==null&&(!model.includes("/")||model.split("/").some(segment=>!segment || /[\s\p{Cc}]/u.test(segment))))fail("Prime and OMP models must be a fully qualified provider/model with no empty, whitespace, or control-character segments.",parsed.locations.model); +} diff --git a/cli/bun/src/core/output.ts b/cli/bun/src/core/output.ts index c78231f5..bd0d29c8 100644 --- a/cli/bun/src/core/output.ts +++ b/cli/bun/src/core/output.ts @@ -405,9 +405,9 @@ export function reportedConfigurationKeys(config: EffectiveConfiguration): Array export function configurationExplanation(config: EffectiveConfiguration): Record { const values = Object.fromEntries( - reportedConfigurationKeys(config).map((key) => [key, { - value: config.values[key], - source: config.sources[key], + (Object.keys(config.sources) as Array).map((key) => [key, { + value: config.values[key] ?? (key === "nativeProfile" ? "default" : ["nativeAddDirs","nativeAllowTools"].includes(key) ? [] : null), + source: config.sources[key] ?? {kind:"default",location:"built-in"}, }]), ); return { @@ -416,17 +416,25 @@ export function configurationExplanation(config: EffectiveConfiguration): Record projectConfigPath: config.projectConfigPath, userConfigPath: config.userConfigPath, values, + target: config.target ?? null, + locations: config.locations ?? [], + candidates: config.candidates ?? {}, + diagnostics: config.diagnostics ?? [], + runtime: config.runtime ?? {transport:null,permissionMode:null,authProfile:null,billingOwner:null,nativeLimits:null,nativeOutputLimits:null}, + ...(config.mutation === undefined ? {} : {mutation:config.mutation}), }; } export function humanConfiguration(config: EffectiveConfiguration): string { const lines = [`cwd = ${humanSafeScalar(config.cwd)} (${humanSafeScalar(config.cwdSource.kind)}: ${humanSafeScalar(config.cwdSource.location)})`]; - for (const key of reportedConfigurationKeys(config)) { - const raw = config.values[key]; - const value = raw === null ? "unset" : humanSafeScalar(String(raw)); + for (const key of Object.keys(config.sources) as Array) { + const raw = key === "nativeProfile" ? config.values[key] ?? "default" : config.values[key]; + const value = raw === null || raw === undefined ? "unset" : humanSafeScalar(Array.isArray(raw) ? JSON.stringify(raw) : String(raw)); const source = config.sources[key] ?? {kind:"default",location:"built-in"}; lines.push(`${key} = ${value} (${humanSafeScalar(source.kind)}: ${humanSafeScalar(source.location)})`); } + for (const diagnostic of config.diagnostics ?? []) lines.push(`${humanSafeScalar(diagnostic.severity)}: ${humanSafeScalar(diagnostic.reason)} (${humanSafeScalar(diagnostic.source)})`); + if(config.mutation) lines.push(`${config.mutation.operation}: ${config.mutation.changed ? "changed" : "unchanged"} ${humanSafeScalar(config.mutation.path)}`); return `${lines.join("\n")}\n`; } diff --git a/cli/bun/src/core/types.ts b/cli/bun/src/core/types.ts index 508e2ee1..072cb62e 100644 --- a/cli/bun/src/core/types.ts +++ b/cli/bun/src/core/types.ts @@ -61,6 +61,14 @@ export interface EffectiveConfiguration { sources: { [K in keyof EffectiveValues]: ValueSource }; projectConfigPath: string | null; userConfigPath: string; + legacyConfigPath?: string | null; + activeUserConfigPath?: string | null; + target?: { argv: string[] } | null; + locations?: Array<{ role: "user" | "legacy-user" | "project"; path: string; present: boolean; selected: boolean }>; + candidates?: Record>; + diagnostics?: Array<{ code: string; severity: "warning" | "error"; source: string; reason: string }>; + runtime?: { transport: string | null; permissionMode: string | null; authProfile: string | null; billingOwner: string | null; nativeLimits: Record | null; nativeOutputLimits: Record | null }; + mutation?: { operation: "migrate" | "unset"; changed: boolean; path: string; sourcePath: string | null; keys: string[] }; } export type RunnerOperation = @@ -70,6 +78,8 @@ export type RunnerOperation = | "harness-use" | "prime-cleanup" | "config-explain" + | "config-migrate" + | "config-unset" | "auth-status" | "auth-login" | "auth-logout" @@ -79,7 +89,7 @@ export type ParsedEntrypoint = | { kind: "weave"; global: GlobalFlags; argv: string[] } | { kind: "help"; global: GlobalFlags } | { kind: "version"; global: GlobalFlags } - | { kind: "operation"; global: GlobalFlags; operation: RunnerOperation; json: boolean; value?: string; packageCommand?: import("./package-args").PackageCommand } + | { kind: "operation"; global: GlobalFlags; operation: RunnerOperation; json: boolean; value?: string; targetArgv?: string[]; configKeys?: string[]; packageCommand?: import("./package-args").PackageCommand } | { kind: "service"; global: GlobalFlags; command: import("./service/manifest").ServiceCommand } /** `redirect`: the words also name a service command; rejected unless an operand exists on disk, else a HOSTED_UNAVAILABLE hint. */ | { kind: "language"; global: GlobalFlags; argv: string[]; redirect?: import("./service/manifest").CliRedirect }; diff --git a/cli/bun/test/adapters-installed.test.ts b/cli/bun/test/adapters-installed.test.ts index 6bd56f63..3fd33b90 100644 --- a/cli/bun/test/adapters-installed.test.ts +++ b/cli/bun/test/adapters-installed.test.ts @@ -1504,13 +1504,13 @@ describe("installed executable discovery and version probes", () => { imageBundle: sentinelImage, })).toBe(2); expect(JSON.parse(stdout)).toMatchObject({ - error: { - code: "CONFIG_INVALID", - details: { + code: "CONFIG_INVALID", + details: { adapterId, - reason: `Unknown auth_profile for ${adapterId}: unsupported-profile.`, + reason: "Authentication profile is incompatible with the selected harness.", + source: "--auth-profile", + configurationExplanation:{diagnostics:[{code:"CONFIG_INVALID",severity:"error"}]}, supportedAuthProfiles: Object.keys(installedAdapterDefinition(adapterId).credentialGroups), - }, }, }); }); @@ -1537,12 +1537,11 @@ describe("installed executable discovery and version probes", () => { imageBundle: sentinelImage, })).toBe(2); expect(JSON.parse(stdout)).toMatchObject({ - error: { - code: "CONFIG_INVALID", - details: { + code: "CONFIG_INVALID", + details: { adapterId: `${harness}/rpc`, reason: "Prime and OMP models must be a fully qualified provider/model with no empty, whitespace, or control-character segments.", - }, + source:"--model", }, }); }); @@ -2110,8 +2109,8 @@ describe("installed executable discovery and version probes", () => { imageBundle: sentinelImage, })).toBe(2); expect(JSON.parse(stdout)).toMatchObject({ - ready: false, - problems: [{ code: "CONFIG_INVALID" }], + code: "CONFIG_INVALID", + details:{source:"--model",configurationExplanation:{diagnostics:[{code:"CONFIG_INVALID",severity:"error"}]}}, }); expect(stdout).toContain("provider/model"); } diff --git a/cli/bun/test/args.test.ts b/cli/bun/test/args.test.ts index dc196c16..79dc6293 100644 --- a/cli/bun/test/args.test.ts +++ b/cli/bun/test/args.test.ts @@ -58,7 +58,7 @@ describe("runner-global parsing", () => { for (const [args, reason] of [ [["--output=machine", "cli", "doctor"], 'invalid output mode "machine"; expected human, json, or jsonl'], [["--model", "one", "--model", "two", "cli", "doctor"], "runner option --model was specified more than once"], - [["cli", "harness", "use", "nope"], "Harness selection must be one of openprose, prime, omp, codex, or claude."], + [["cli", "harness", "use", "nope"], "Harness selection must be one of openprose, agents-sdk, prime, omp, codex, or claude."], ] as const) { expectInvocationFailure(args, reason); } @@ -405,3 +405,15 @@ test("Codex compatibility is explicit and cannot be duplicated", () => { expect(parseEntrypoint(["--harness","codex","--codex-compatibility=probe","run"]).global).toMatchObject({harness:"codex",codexCompatibility:"probe"}); expect(() => parseEntrypoint(["--codex-compatibility","probe","--codex-compatibility","qualified","run"])).toThrow(); }); + +test("configuration target explanation reuses global parsing and preserves opaque argv",()=>{ + expect(parseEntrypoint(["cli","config","explain","--json","--","--cwd","work space","--harness","agents-sdk","run","--model","opaque"])).toEqual({kind:"operation",operation:"config-explain",json:true,global:{cwd:"work space",harness:"agents-sdk"},targetArgv:["prose","run","--model","opaque"]}); + expect(inferOutputMode(["cli","config","explain","--json","--","--harness","agents-sdk","run","x"])).toBe("json"); +}); +test("configuration target explanation rejects absent targets and nested operations",()=>{ + for(const argv of [["cli","config","explain","--json","--"],["cli","config","explain","--","cli","doctor"],["cli","config","explain","--","--version"],["--cwd","x","cli","config","explain","--","run","x"]]) expect(()=>parseEntrypoint(argv)).toThrow(); +}); +test("configuration mutation accepts only known key operands",()=>{ + expect(parseEntrypoint(["cli","config","unset","model","auth_profile","--json"])).toMatchObject({operation:"config-unset",json:true,configKeys:["model","auth_profile"]}); + for(const argv of [["cli","config","unset"],["cli","config","unset","api_key"],["cli","config","migrate","extra"]])expect(()=>parseEntrypoint(argv)).toThrow(); +}); diff --git a/cli/bun/test/build-identity.test.ts b/cli/bun/test/build-identity.test.ts index 38128f01..703f43e7 100644 --- a/cli/bun/test/build-identity.test.ts +++ b/cli/bun/test/build-identity.test.ts @@ -180,7 +180,7 @@ describe("standalone build identity and seam exclusion", () => { ...(model === undefined ? {} : { PROSE_MODEL: model }), }); expect(attempted.exitCode).toBe(2); - expect(await Bun.file(join(configRoot, "openprose", "cli.toml")).exists()).toBeFalse(); + expect(await Bun.file(join(root, `${harness}-${label}-home`, ".prose", "cli.toml")).exists()).toBeFalse(); expect(await Bun.file(join(root, `${harness}-injection`)).exists()).toBeFalse(); } @@ -191,7 +191,7 @@ describe("standalone build identity and seam exclusion", () => { PROSE_MODEL: "openai/gpt-5.4", }); expect(selected.exitCode, selected.stderr).toBe(0); - expect(await readFile(join(validConfigRoot, "openprose", "cli.toml"), "utf8")).toBe([ + expect(await readFile(join(root, `${harness}-valid-home`, ".prose", "cli.toml"), "utf8")).toBe([ `auth_profile = ${JSON.stringify(authProfile)}`, `harness = ${JSON.stringify(harness)}`, 'model = "openai/gpt-5.4"', diff --git a/cli/bun/test/cli.test.ts b/cli/bun/test/cli.test.ts index 1ba873ab..50dba710 100644 --- a/cli/bun/test/cli.test.ts +++ b/cli/bun/test/cli.test.ts @@ -67,7 +67,7 @@ function fixture(overrides: Partial = {}) { } function operationFixture() { - const cwd = process.cwd(); + const cwd = resolve(import.meta.dir, ".."); const userConfigPath = join(cwd, "config", "openprose", "cli.toml"); const io = fixture({ env: { @@ -84,11 +84,24 @@ function operationFixture() { } function expectedConfiguration(cwd: string, userConfigPath: string) { - return { - ...configurationFixture, - cwd: { ...configurationFixture.cwd, value: cwd }, - userConfigPath, + const replace = (value: unknown): unknown => { + if(Array.isArray(value))return value.map(replace); + if(value!==null && typeof value === "object")return Object.fromEntries(Object.entries(value).map(([key,item])=>[key,replace(item)])); + if(typeof value === "string")return value.replaceAll("/workspace/config/openprose/cli.toml",userConfigPath).replaceAll("/workspace",cwd); + return value; }; + const report = replace(configurationFixture) as JsonRecord; + // This fixture starts in cli/bun; its known Git boundary is the repository + // root, so discovery must report all three considered project locations. + const repositoryRoot = resolve(import.meta.dir, "../../.."); + expect(cwd).toBe(join(repositoryRoot, "cli", "bun")); + report.locations = [ + ...(report.locations as JsonRecord[]).filter((location) => location.role !== "project"), + ...[cwd, join(repositoryRoot, "cli"), repositoryRoot].map((directory) => ({ + role: "project", path: join(directory, ".prose", "cli.toml"), present: false, selected: false, + })), + ]; + return report; } describe("CLI behavior", () => { @@ -826,7 +839,7 @@ describe("CLI behavior", () => { ["omp", "rpc"], ])("fails closed when the selected %s/%s executable is unavailable", async (harness, transport) => { const io = fixture({ - env: harness === "prime" || harness === "omp" ? { PROSE_AUTH_PROFILE: "openrouter" } : {}, + env: harness === "prime" || harness === "omp" ? { PROSE_HARNESS: harness, PROSE_AUTH_PROFILE: "openrouter" } : {}, }); const configured = harness === "prime" || harness === "omp" ? ["--model", "fixture/model"] @@ -1158,16 +1171,16 @@ describe("CLI behavior", () => { test("human doctor confines a hostile diagnostic to one physical detail line", async () => { const hostileProfile = "unknown\nAction: forged\t\u001b[31m\u2028next\u2029paragraph"; - const expectedReason = `Unknown auth_profile for prime/rpc: ${hostileProfile}.`; + const expectedReason = "Authentication profile is incompatible with the selected harness."; const human = operationFixture(); expect(await runCli([ "--harness", "prime", "--transport", "rpc", "--model", "fixture/model", "--auth-profile", hostileProfile, "cli", "doctor", ], human.deps)).toBe(2); - expect(human.stdout()).toContain(`detail: ${humanSafeScalar(expectedReason)}\n`); - expect(human.stdout().match(/^detail:/gmu)).toHaveLength(1); + expect(human.stderr()).toContain(expectedReason); + expect(human.stderr().match(/Authentication profile is incompatible/gmu)).toHaveLength(1); for (const unsafe of ["\nAction: forged", "\t", "\u001b", "\u2028", "\u2029"]) { - expect(human.stdout()).not.toContain(unsafe); + expect(human.stdout()+human.stderr()).not.toContain(unsafe); } const machine = operationFixture(); @@ -1175,7 +1188,7 @@ describe("CLI behavior", () => { "--harness", "prime", "--transport", "rpc", "--model", "fixture/model", "--auth-profile", hostileProfile, "--output", "json", "cli", "doctor", ], machine.deps)).toBe(2); - expect(JSON.parse(machine.stdout()).problems[0].details.reason).toBe(expectedReason); + expect(JSON.parse(machine.stdout())).toMatchObject({code:"CONFIG_INVALID",details:{reason:expectedReason,source:"--auth-profile"}}); }); test.skipIf(process.platform === "win32")("human dry-run escapes a hostile cwd while machine output preserves it", async () => { @@ -1189,7 +1202,7 @@ describe("CLI behavior", () => { expect(human.stdout()).toContain(`Working directory: ${humanSafeScalar(canonicalCwd)}\n`); expect(human.stdout().match(/^Working directory:/gmu)).toHaveLength(1); for (const unsafe of ["\nAction: forged", "\t", "\u001b", "\u2028", "\u2029"]) { - expect(human.stdout()).not.toContain(unsafe); + expect(human.stdout()+human.stderr()).not.toContain(unsafe); } const machine = fixture({ processCwd: hostileCwd }); diff --git a/cli/bun/test/config-production.test.ts b/cli/bun/test/config-production.test.ts new file mode 100644 index 00000000..0ef83f2f --- /dev/null +++ b/cli/bun/test/config-production.test.ts @@ -0,0 +1,178 @@ +import Ajv2020 from "ajv/dist/2020"; +import commonSchema from "../../shared/schemas/common.schema.json" with { type: "json" }; +import explanationSchema from "../../shared/schemas/configuration-explanation.schema.json" with { type: "json" }; +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import corpus from "../../shared/fixtures/config/production-v2.json" with { type: "json" }; +import { runCli } from "../src/cli"; +const ajv=new Ajv2020({allErrors:true,strict:true,strictTypes:false}); +ajv.addSchema(commonSchema);const validateExplanation=ajv.compile(explanationSchema); +const roots: string[]=[]; +async function treeFiles(root: string, prefix=""): Promise { + const result:string[]=[]; + for(const entry of await readdir(join(root,prefix),{withFileTypes:true})) { + const path=prefix ? `${prefix}/${entry.name}` : entry.name; + if(entry.isDirectory())result.push(...await treeFiles(root,path));else result.push(path); + } + return result.sort(); +} +afterEach(async()=>{for(const root of roots.splice(0))await rm(root,{recursive:true,force:true});}); +for(const fixture of corpus.cases) test(`production config black box: ${fixture.id}`,async()=>{ + const root=await realpath(await mkdtemp(join(tmpdir(),"prose-config-production-")));roots.push(root); + for(const [path,bytes] of Object.entries(fixture.setup.files)) { + const target=join(root,path);await mkdir(join(target,".."),{recursive:true});await writeFile(target,bytes); + } + for(const directory of fixture.setup.directories)await mkdir(join(root,directory),{recursive:true}); + const number=fixture.id.split("-").at(-1)!; + const manifest=JSON.parse(await readFile(join(import.meta.dir,`../../conformance/cases/operations/config-production-${number}.json`),"utf8")); + const expand=(value:string)=>value.replaceAll("{{WORKSPACE}}",root); + const expandExpected=(value:unknown):unknown=>typeof value==="string"?expand(value):Array.isArray(value)?value.map(expandExpected):value!==null&&typeof value==="object"?Object.fromEntries(Object.entries(value).map(([key,item])=>[key,expandExpected(item)])):value; + let stdout="",stderr="",started=false; + const exit=await runCli(manifest.invocation.argv,{processCwd:root,env:Object.fromEntries(Object.entries(manifest.invocation.environment).map(([key,value])=>[key,expand(value as string)])),clock:{now:()=>"2025-01-01T00:00:00Z",monotonicMs:()=>0},ids:{invocationId:()=>"fixture-invocation-0001"},writeStdout:text=>{stdout+=text;},writeStderr:text=>{stderr+=text;},observeMockInvocation:()=>{started=true;}}); + expect(exit).toBe(manifest.expected.exitCode);expect(stderr).toBe("");expect(started).toBe(false); + const report=JSON.parse(stdout); + expect(report).toMatchObject(expandExpected(manifest.expected.resultMatches) as Record); + const explanation=manifest.expected.exitCode===0 ? report:report.details?.configurationExplanation; + expect(explanation).toBeDefined();expect(validateExplanation(explanation),JSON.stringify(validateExplanation.errors)).toBe(true); + const checks=fixture.setup.checks as {unchangedFiles?:true|string[];absent?:string[];files?:Record;outputAbsent?:string[]}; + const preserved=checks.unchangedFiles === true ? Object.keys(fixture.setup.files) : checks.unchangedFiles ?? []; + for(const path of preserved)expect(await readFile(join(root,path),"utf8")).toBe((fixture.setup.files as Record)[path]!); + for(const [path,bytes] of Object.entries(checks.files ?? {}))expect(await readFile(join(root,path),"utf8")).toBe(bytes); + expect(await treeFiles(root)).toEqual([...new Set([...Object.keys(fixture.setup.files),...Object.keys(checks.files ?? {})])].sort()); + for(const path of checks.absent ?? [])expect(await stat(join(root,path)).then(()=>true,()=>false)).toBe(false); + for(const sentinel of checks.outputAbsent ?? [])expect(stdout+stderr).not.toContain(sentinel); +}); + +import { resolveConfiguration, mutateUserConfiguration } from "../src/core/config"; +import { parseEntrypoint } from "../src/core/args"; +import { nativeLimits } from "../src/adapters/sdk-limits"; +import { nativeOutputLimits } from "../src/adapters/output-budget"; +import { symlink } from "node:fs/promises"; +async function freshConfigurationRoot(): Promise { + const root=await realpath(await mkdtemp(join(tmpdir(),"prose-config-boundaries-")));roots.push(root);return root; +} +test("Windows home and configuration-root discovery are static and environment-case independent",async()=>{ + const root=await freshConfigurationRoot(); + const configured=await resolveConfiguration({}, {processCwd:root,platform:"win32",env:{userprofile:"C:\\Users\\Fixture"}}); + expect(configured.userConfigPath).toBe("C:\\Users\\Fixture\\.prose\\cli.toml"); + const overridden=await resolveConfiguration({}, {processCwd:root,platform:"win32",env:{prose_config_dir:"D:\\Config Space"}}); + expect(overridden.userConfigPath).toBe("D:\\Config Space\\cli.toml"); + await expect(resolveConfiguration({}, {processCwd:root,platform:"win32",env:{USERPROFILE:"relative"}})).rejects.toMatchObject({code:"CONFIG_INVALID"}); +}); +test("unset absent settings creates neither settings nor parent and repeated unset is unchanged",async()=>{ + const root=await freshConfigurationRoot();const dependencies={processCwd:root,env:{HOME:join(root,"home")}}; + const absent=await resolveConfiguration({},dependencies); + expect(await mutateUserConfiguration(absent,"unset",["model"])).toMatchObject({changed:false}); + expect(await stat(join(root,"home")).then(()=>true,()=>false)).toBe(false); + await mkdir(join(root,"home/.prose"),{recursive:true});await writeFile(absent.userConfigPath,'# retained\nverbose = true\n'); + expect(await mutateUserConfiguration(await resolveConfiguration({},dependencies),"unset",["model"])).toMatchObject({changed:false}); + expect(await readFile(absent.userConfigPath,"utf8")).toBe('# retained\nverbose = true\n'); +}); +test("legacy-backed multiple unset preserves CRLF and unrelated explicit bytes",async()=>{ + const root=await freshConfigurationRoot();const dependencies={processCwd:root,env:{HOME:join(root,"home"),XDG_CONFIG_HOME:join(root,"legacy")}}; + const original='# original comment\r\nharness = "codex"\r\nmodel = "gpt-6.1-sol"\r\nauth_profile = "openai-api-key"\r\nverbose = true'; + const legacy=join(root,"legacy/openprose/cli.toml");await mkdir(join(legacy,".."),{recursive:true});await writeFile(legacy,original); + const config=await resolveConfiguration({},dependencies);const receipt=await mutateUserConfiguration(config,"unset",["model","auth_profile"]); + expect(receipt).toMatchObject({changed:true,sourcePath:legacy});expect(await readFile(legacy,"utf8")).toBe(original); + expect(await readFile(config.userConfigPath,"utf8")).toBe('# original comment\r\nharness = "codex"\r\nverbose = true'); + const updated=await resolveConfiguration({},dependencies);expect(updated.values.authProfile).toBe("cached-chatgpt-login");expect(updated.values.model).toBe(null); +}); +test("explicit unset refuses file and direct-parent symlinks and nonregular destinations",async()=>{ + const root=await freshConfigurationRoot();const dependencies={processCwd:root,env:{HOME:join(root,"home")}}; + const destination=join(root,"home/.prose/cli.toml");await mkdir(join(destination,".."),{recursive:true}); + const target=join(root,"original.toml");await writeFile(target,'verbose = true\n');await symlink(target,destination); + await expect(mutateUserConfiguration(await resolveConfiguration({},dependencies),"unset",["verbose"])).rejects.toMatchObject({code:"CONFIG_INVALID"}); + expect(await readFile(target,"utf8")).toBe('verbose = true\n');await rm(destination);await mkdir(destination); + await expect(mutateUserConfiguration(await resolveConfiguration({},dependencies),"unset",["verbose"])).rejects.toMatchObject({code:"CONFIG_INVALID"}); +}); +test("same-layer replacements establish a coherent SDK route and execution-derived budgets",async()=>{ + const root=await freshConfigurationRoot();const user=join(root,"home/.prose/cli.toml");await mkdir(join(user,".."),{recursive:true});await writeFile(user,'harness = "codex"\nmodel = "native-choice"\nauth_profile = "cached-chatgpt-login"\n'); + const config=await resolveConfiguration({harness:"agents-sdk",model:"gpt-6.1-sol",authProfile:"openai-api-key",nativeTimeout:"5m",nativeToolTimeout:"10s",nativeMaxTurns:"8",outputContract:"native",nativeOutputBytes:"1048576"},{processCwd:root,env:{HOME:join(root,"home")}}); + expect(config.runtime?.nativeLimits).toEqual(nativeLimits(config.values));expect(config.runtime?.nativeOutputLimits).toEqual(nativeOutputLimits(config.values)); + expect(config.values.harness).toBe("agents-sdk");expect(config.candidates?.authProfile?.at(-1)?.selected).toBe(true); + expect(parseEntrypoint(["cli","config","unset","harness","model","auth_profile","--json"])).toMatchObject({configKeys:["harness","model","auth_profile"]}); +}); + +test("unset repairs a removed invalid value before effective resolution",async()=>{ + const root=await freshConfigurationRoot();const user=join(root,"home/.prose/cli.toml");await mkdir(join(user,".."),{recursive:true});await writeFile(user,'# preserve\ntimeout = "invalid-duration"\nverbose = true\n'); + let stdout=""; + expect(await runCli(["cli","config","unset","timeout","--json"],{processCwd:root,env:{HOME:join(root,"home")},clock:{now:()=>"2025-01-01T00:00:00Z",monotonicMs:()=>0},ids:{invocationId:()=>"fixture-invocation-0001"},writeStdout:text=>{stdout+=text;},writeStderr:()=>{}})).toBe(0); + expect(JSON.parse(stdout)).toMatchObject({mutation:{changed:true,operation:"unset"},values:{timeout:{value:"10m"},verbose:{value:true}}}); + expect(await readFile(user,"utf8")).toBe('# preserve\nverbose = true\n'); +}); +test("post-migration execution-override errors retain the successful mutation receipt",async()=>{ + const root=await freshConfigurationRoot();const legacy=join(root,"legacy/openprose/cli.toml");await mkdir(join(legacy,".."),{recursive:true});await writeFile(legacy,'# explicit preference\ntimeout = "2m"\n'); + let stdout=""; + expect(await runCli(["cli","config","migrate","--json"],{processCwd:root,env:{HOME:join(root,"home"),XDG_CONFIG_HOME:join(root,"legacy"),PROSE_OUTPUT:"invalid"},clock:{now:()=>"2025-01-01T00:00:00Z",monotonicMs:()=>0},ids:{invocationId:()=>"fixture-invocation-0001"},writeStdout:text=>{stdout+=text;},writeStderr:()=>{}})).toBe(2); + expect(JSON.parse(stdout)).toMatchObject({code:"CONFIG_INVALID",details:{source:"PROSE_OUTPUT",mutation:{operation:"migrate",changed:true}}}); + expect(await readFile(join(root,"home/.prose/cli.toml"),"utf8")).toBe(await readFile(legacy,"utf8")); +}); +test("a semantically rejected credential-profile value never appears in partial diagnostics",async()=>{ + const root=await freshConfigurationRoot();let stdout="",stderr=""; + expect(await runCli(["cli","config","explain","--json","--","--harness","agents-sdk","--auth-profile","fixture-secret-profile-value","run","x"],{processCwd:root,env:{HOME:join(root,"home")},clock:{now:()=>"2025-01-01T00:00:00Z",monotonicMs:()=>0},ids:{invocationId:()=>"fixture-invocation-0001"},writeStdout:text=>{stdout+=text;},writeStderr:text=>{stderr+=text;}})).toBe(2); + expect(stdout+stderr).not.toContain("fixture-secret-profile-value");expect(JSON.parse(stdout)).toMatchObject({details:{source:"--auth-profile",configurationExplanation:{diagnostics:[{code:"CONFIG_INVALID",severity:"error"}]}}}); +}); +test("an overridden invalid credential-profile candidate is omitted with a safe source diagnostic",async()=>{ + const root=await freshConfigurationRoot();const user=join(root,"home/.prose/cli.toml");await mkdir(join(user,".."),{recursive:true});await writeFile(user,'harness = "agents-sdk"\nauth_profile = "rejected-profile-sentinel"\n'); + let stdout="",stderr=""; + expect(await runCli(["cli","config","explain","--json","--","--auth-profile","openai-api-key","run","x"],{processCwd:root,env:{HOME:join(root,"home")},clock:{now:()=>"2025-01-01T00:00:00Z",monotonicMs:()=>0},ids:{invocationId:()=>"fixture-invocation-0001"},writeStdout:text=>{stdout+=text;},writeStderr:text=>{stderr+=text;}})).toBe(0); + expect(stdout+stderr).not.toContain("rejected-profile-sentinel");const report=JSON.parse(stdout); + expect(report).toMatchObject({values:{authProfile:{value:"openai-api-key"}},diagnostics:[{code:"CONFIG_CANDIDATE_INVALID",severity:"warning",source:`${user}:2`,reason:"Incompatible overridden authentication profile is omitted."}]}); + expect(report.candidates.authProfile).toHaveLength(2);expect(validateExplanation(report),JSON.stringify(validateExplanation.errors)).toBe(true); +}); +for(const permission of ["default","acceptEdits","workspace-write","read-only"])test(`SDK explanation rejects unsupported explicit permission ${permission} before runtime inference`,async()=>{ + const root=await freshConfigurationRoot();let stdout="",stderr=""; + expect(await runCli(["cli","config","explain","--json","--","--harness","agents-sdk","--permission-mode",permission,"run","x"],{processCwd:root,env:{HOME:join(root,"home")},clock:{now:()=>"2025-01-01T00:00:00Z",monotonicMs:()=>0},ids:{invocationId:()=>"fixture-invocation-0001"},writeStdout:text=>{stdout+=text;},writeStderr:text=>{stderr+=text;}})).toBe(2); + const report=JSON.parse(stdout);expect(report).toMatchObject({code:"CONFIG_INVALID",details:{adapterId:"agents-sdk/jsonl",source:"--permission-mode",reason:"Unsupported explicit permission mode for this harness.",configurationExplanation:{values:{permissionMode:{value:null}},runtime:{transport:null,permissionMode:null,authProfile:null,billingOwner:null,nativeLimits:null,nativeOutputLimits:null}}}}); + expect(validateExplanation(report.details.configurationExplanation),JSON.stringify(validateExplanation.errors)).toBe(true); +}); +test("SDK saved harness selection reports contextual defaults without persisting them",async()=>{ + const root=await freshConfigurationRoot();let stdout=""; + expect(await runCli(["cli","harness","use","agents-sdk"],{processCwd:root,env:{HOME:join(root,"home")},clock:{now:()=>"2025-01-01T00:00:00Z",monotonicMs:()=>0},ids:{invocationId:()=>"fixture-invocation-0001"},writeStdout:text=>{stdout+=text;},writeStderr:()=>{}})).toBe(0); + expect(stdout).toContain("Route: openai-api-key (Agents SDK default; not saved)");expect(stdout).toContain("Model: gpt-6.1-sol (Agents SDK default; not saved)"); + expect(await readFile(join(root,"home/.prose/cli.toml"),"utf8")).toBe('harness = "agents-sdk"\n'); +}); +for(const [label,env] of [["absent",{}],["empty",{HOME:""}],["relative",{HOME:"relative"}],["invalid override",{HOME:"/unused",PROSE_CONFIG_DIR:"relative"}]] as const)test(`early ${label} configuration root failure retains safe complete defaults`,async()=>{ + const root=await freshConfigurationRoot();let stdout=""; + expect(await runCli(["cli","config","explain","--json"],{processCwd:root,env,clock:{now:()=>"2025-01-01T00:00:00Z",monotonicMs:()=>0},ids:{invocationId:()=>"fixture-invocation-0001"},writeStdout:text=>{stdout+=text;},writeStderr:()=>{}})).toBe(2); + const report=JSON.parse(stdout);const partial=report.details.configurationExplanation; + expect(partial).toMatchObject({cwd:{value:root,source:{kind:"default",location:"process cwd"}},userConfigPath:null,projectConfigPath:null,target:null,locations:[],runtime:{transport:null,permissionMode:null,authProfile:null,billingOwner:null,nativeLimits:null,nativeOutputLimits:null}}); + expect(Object.keys(partial.values)).toHaveLength(19);expect(Object.keys(partial.candidates)).toHaveLength(19);expect(partial.diagnostics).toEqual([{code:"CONFIG_INVALID",severity:"error",source:report.details.source,reason:report.details.reason}]); + expect(validateExplanation(partial),JSON.stringify(validateExplanation.errors)).toBe(true);expect(await treeFiles(root)).toEqual([]); +}); +test("early cwd failure preserves the exact target and makes no runtime claims",async()=>{ + const root=await freshConfigurationRoot();let stdout=""; + expect(await runCli(["cli","config","explain","--json","--","--cwd","absent","--harness","agents-sdk","run","x"],{processCwd:root,env:{HOME:join(root,"home")},clock:{now:()=>"2025-01-01T00:00:00Z",monotonicMs:()=>0},ids:{invocationId:()=>"fixture-invocation-0001"},writeStdout:text=>{stdout+=text;},writeStderr:()=>{}})).toBe(2); + const partial=JSON.parse(stdout).details.configurationExplanation; + expect(partial).toMatchObject({cwd:{value:root,source:{kind:"default",location:"process cwd"}},target:{argv:["prose","run","x"]},values:{harness:{value:"openprose"}},locations:[],runtime:{transport:null}}); + expect(validateExplanation(partial),JSON.stringify(validateExplanation.errors)).toBe(true);expect(await treeFiles(root)).toEqual([]); +}); +for(const args of [["cli","config","migrate","--json"],["cli","config","unset","model","--json"]])test(`mutation preflight ${args[2]} root failure has safe partial defaults`,async()=>{ + const root=await freshConfigurationRoot();let stdout=""; + expect(await runCli(args,{processCwd:root,env:{},clock:{now:()=>"2025-01-01T00:00:00Z",monotonicMs:()=>0},ids:{invocationId:()=>"fixture-invocation-0001"},writeStdout:text=>{stdout+=text;},writeStderr:()=>{}})).toBe(2); + const report=JSON.parse(stdout);expect(report).toMatchObject({code:"CONFIG_INVALID",details:{source:"HOME",configurationExplanation:{userConfigPath:null,locations:[],runtime:{transport:null}}}}); + expect(validateExplanation(report.details.configurationExplanation),JSON.stringify(validateExplanation.errors)).toBe(true);expect(await treeFiles(root)).toEqual([]); +}); +test("migration conflict retains resolved candidates and legacy warnings without file effects",async()=>{ + const root=await freshConfigurationRoot();const canonical=join(root,"home/.prose/cli.toml"),legacy=join(root,"legacy/openprose/cli.toml"); + await mkdir(join(root,".git")); + for(const [path,bytes] of [[canonical,'# canonical\ntimeout = "2m"\n'],[legacy,'# legacy\ntimeout = "3m"\n']]){await mkdir(join(path!,".."),{recursive:true});await writeFile(path!,bytes!);} + let stdout=""; + expect(await runCli(["cli","config","migrate","--json"],{processCwd:root,env:{HOME:join(root,"home"),XDG_CONFIG_HOME:join(root,"legacy")},clock:{now:()=>"2025-01-01T00:00:00Z",monotonicMs:()=>0},ids:{invocationId:()=>"fixture-invocation-0001"},writeStdout:text=>{stdout+=text;},writeStderr:()=>{}})).toBe(2); + const report=JSON.parse(stdout),partial=report.details.configurationExplanation; + expect(partial).toMatchObject({values:{timeout:{value:"2m"}},runtime:{transport:null},diagnostics:[{code:"LEGACY_CONFIG_IGNORED",severity:"warning",source:legacy},{code:"CONFIG_INVALID",severity:"error",source:canonical}]}); + expect(Object.keys(partial.candidates)).toHaveLength(19);expect(partial.candidates.timeout).toHaveLength(2);expect(partial.locations).toHaveLength(3); + expect(validateExplanation(partial),JSON.stringify(validateExplanation.errors)).toBe(true); + expect(await readFile(canonical,"utf8")).toBe('# canonical\ntimeout = "2m"\n');expect(await readFile(legacy,"utf8")).toBe('# legacy\ntimeout = "3m"\n');expect(await treeFiles(root)).toEqual(["home/.prose/cli.toml","legacy/openprose/cli.toml"]); +}); +test("migration conflict preserves a failed resolver's safe context without exposing invalid overrides",async()=>{ + const root=await freshConfigurationRoot(),canonical=join(root,"home/.prose/cli.toml");await mkdir(join(root,".git"));await mkdir(join(canonical,".."),{recursive:true});await writeFile(canonical,'timeout = "2m"\n'); + let stdout=""; + expect(await runCli(["cli","config","migrate","--json"],{processCwd:root,env:{HOME:join(root,"home"),PROSE_OUTPUT:"invalid-output-sentinel"},clock:{now:()=>"2025-01-01T00:00:00Z",monotonicMs:()=>0},ids:{invocationId:()=>"fixture-invocation-0001"},writeStdout:text=>{stdout+=text;},writeStderr:()=>{}})).toBe(2); + const report=JSON.parse(stdout),partial=report.details.configurationExplanation; + expect(stdout).not.toContain("invalid-output-sentinel");expect(report.details.source).toBe(canonical);expect(partial.diagnostics).toMatchObject([{code:"CONFIG_INVALID",source:"PROSE_OUTPUT"},{code:"CONFIG_INVALID",source:canonical}]); + expect(Object.keys(partial.candidates)).toHaveLength(19);expect(partial.values.timeout.value).toBe("2m");expect(validateExplanation(partial),JSON.stringify(validateExplanation.errors)).toBe(true); + expect(await readFile(canonical,"utf8")).toBe('timeout = "2m"\n');expect(await treeFiles(root)).toEqual(["home/.prose/cli.toml"]); +}); diff --git a/cli/bun/test/config.test.ts b/cli/bun/test/config.test.ts index b7528fd9..1ea7bf36 100644 --- a/cli/bun/test/config.test.ts +++ b/cli/bun/test/config.test.ts @@ -116,7 +116,7 @@ describe("configuration", () => { { cwd: nested, harness: "mock", authProfile: "openrouter" }, { processCwd: workspace, - env: { PROSE_MODEL: "environment-model", PROSE_AUTH_PROFILE: "openai" }, + env: { PROSE_HARNESS: "mock", PROSE_MODEL: "environment-model", PROSE_AUTH_PROFILE: "openai" }, userConfigPath: userConfig, }, ); @@ -210,8 +210,8 @@ describe("configuration", () => { { processCwd: workspace, env: {}, platform: "linux" as const }, { processCwd: workspace, env: { HOME: "" }, platform: "linux" as const }, { processCwd: workspace, env: { HOME: "relative" }, platform: "linux" as const }, - { processCwd: workspace, env: { HOME: absoluteHome, XDG_CONFIG_HOME: "" }, platform: "linux" as const }, - { processCwd: workspace, env: { HOME: absoluteHome, XDG_CONFIG_HOME: "relative" }, platform: "linux" as const }, + { processCwd: workspace, env: { HOME: absoluteHome, PROSE_CONFIG_DIR: "" }, platform: "linux" as const }, + { processCwd: workspace, env: { HOME: absoluteHome, PROSE_CONFIG_DIR: "relative" }, platform: "linux" as const }, { processCwd: workspace, env: {}, platform: "darwin" as const }, { processCwd: workspace, env: {}, platform: "win32" as const }, { processCwd: workspace, env: { APPDATA: "relative" }, platform: "win32" as const }, diff --git a/cli/bun/test/reporting-parity.test.ts b/cli/bun/test/reporting-parity.test.ts index 474982ec..2ce67656 100644 --- a/cli/bun/test/reporting-parity.test.ts +++ b/cli/bun/test/reporting-parity.test.ts @@ -5,11 +5,11 @@ import {tmpdir} from "node:os"; import f from "../../shared/fixtures/config/optional-reporting.json"; import {resolveConfiguration} from "../src/core/config"; import {configurationExplanation,reportedConfigurationKeys} from "../src/core/output"; -test("optional report fields preserve defaults and expose explicit default selections",async()=>{ +test("explanation reports inherited defaults while concise execution summaries omit optional defaults",async()=>{ const dir=await mkdtemp(join(tmpdir(),"reporting-"));try{ const deps={processCwd:dir,userConfigPath:join(dir,"absent"),env:{}}; const base=await resolveConfiguration({},deps);expect(base.values.outputContract).toBe("image-envelope");expect(base.values.permissionMode).toBeNull(); - const values=(configurationExplanation(base).values as Record);for(const key of f.defaultsOmitted){expect(values).not.toHaveProperty(key);expect(reportedConfigurationKeys(base)).not.toContain(key);} + const values=(configurationExplanation(base).values as Record);for(const [key,value] of Object.entries(f.explanationDefaults))expect(values[key]).toMatchObject({value,source:{kind:"default",location:"built-in"}});for(const key of f.defaultsOmitted){expect(values[key]).toMatchObject({source:{kind:"default",location:"built-in"}});expect(reportedConfigurationKeys(base)).not.toContain(key);} for(const c of f.cases){const config=await resolveConfiguration(c,deps),report=configurationExplanation(config).values as any;for(const key of f.defaultsOmitted)expect(report[key]).toMatchObject({value:c[key as 'outputContract'|'permissionMode'],source:{kind:"flag"}});expect(report.outputContract.source.location).toBe("--output-contract");expect(report.permissionMode.source.location).toBe("--permission-mode");expect(config.values.outputContract).toBe(c.outputContract);expect(config.values.permissionMode).toBe(c.permissionMode);} }finally{await rm(dir,{recursive:true,force:true});} }); diff --git a/cli/bun/test/shared-contract.test.ts b/cli/bun/test/shared-contract.test.ts index bc200331..002e5875 100644 --- a/cli/bun/test/shared-contract.test.ts +++ b/cli/bun/test/shared-contract.test.ts @@ -22,6 +22,7 @@ for (const name of [ "adapter-diagnostic", "transport-diagnostic", "native-configuration", + "configuration-explanation", "native-limits", "native-output-limits", "native-failure", diff --git a/cli/ci/check_architecture.py b/cli/ci/check_architecture.py index 509a704d..0ef2a425 100644 --- a/cli/ci/check_architecture.py +++ b/cli/ci/check_architecture.py @@ -1221,6 +1221,10 @@ def _tainted_names(code: str, *, rust: bool) -> set[str]: while changed: changed = False for name, expression in assignments: + # Rust's `_` is a discard pattern, never a bound local. Treating + # it as an alias also taints unrelated closure parameters `|_|`. + if rust and name == "_": + continue if name not in tainted and ( seed in expression or ".argv" in expression diff --git a/cli/ci/rehearse_release.py b/cli/ci/rehearse_release.py index 7283fb31..983a8161 100644 --- a/cli/ci/rehearse_release.py +++ b/cli/ci/rehearse_release.py @@ -44,7 +44,7 @@ SAFE_BASENAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") MAX_EVIDENCE_BYTES = 16 * 1024 * 1024 CONFORMANCE_PHASE = 7 -CONFORMANCE_CASES = 64 +CONFORMANCE_CASES = 76 CONFORMANCE_CANDIDATE_VALIDATIONS = CONFORMANCE_CASES * 3 CONFORMANCE_DIFFERENTIAL_VALIDATIONS = CONFORMANCE_CASES * 2 CONFORMANCE_TOTAL_VALIDATIONS = ( diff --git a/cli/ci/test_check_architecture.py b/cli/ci/test_check_architecture.py index 7c6c78a8..a7ad9c7a 100644 --- a/cli/ci/test_check_architecture.py +++ b/cli/ci/test_check_architecture.py @@ -718,6 +718,26 @@ def test_node_net_remains_rejected_for_every_other_bun_source_path(self) -> None [violation.rule for violation in violations], ) + def test_rust_discard_does_not_taint_unrelated_paths(self) -> None: + temporary, root = self.fixture() + self.addCleanup(temporary.cleanup) + source = root / "cli/rust/crates/runner/src/lib.rs" + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text( + 'fn read_config(keys: &[String]) {\n' + ' let _ = keys.len();\n' + ' let source = config_path().map_err(|_| "unavailable");\n' + ' let _ = std::fs::read(source);\n' + '}\n', 'utf-8') + self.assertNotIn("opaque-program-boundary", {v.rule for v in check_repository(root)}) + source.write_text( + 'fn forbidden(argv: &[String]) {\n' + ' let _ = argv.len();\n' + ' let source = &argv[0];\n' + ' let _ = std::fs::read(source);\n' + '}\n', 'utf-8') + self.assertIn("opaque-program-boundary", {v.rule for v in check_repository(root)}) + @unittest.skipUnless(hasattr(Path, "symlink_to"), "requires symlink support") def test_symlinked_stable_source_is_never_followed_as_product_code(self) -> None: temporary, root = self.fixture() diff --git a/cli/ci/test_rehearse_release.py b/cli/ci/test_rehearse_release.py index 29a670fa..4e7929b0 100644 --- a/cli/ci/test_rehearse_release.py +++ b/cli/ci/test_rehearse_release.py @@ -592,10 +592,10 @@ def builder(**kwargs): self.assertIsInstance(benchmark.calls[0][4], float) mechanical = summary["bindings"]["mechanicalConformance"] self.assertEqual(7, mechanical["phase"]) - self.assertEqual(64, len(mechanical["caseIds"])) - self.assertEqual(192, mechanical["candidateCaseValidations"]) - self.assertEqual(128, mechanical["differentialValidations"]) - self.assertEqual(320, mechanical["totalValidations"]) + self.assertEqual(76, len(mechanical["caseIds"])) + self.assertEqual(228, mechanical["candidateCaseValidations"]) + self.assertEqual(152, mechanical["differentialValidations"]) + self.assertEqual(380, mechanical["totalValidations"]) self.assertEqual( rehearse_release.CONFORMANCE_CASES, len(mechanical["caseIds"]) ) diff --git a/cli/conformance/adversarial/adapter-products/test_adapter_products.py b/cli/conformance/adversarial/adapter-products/test_adapter_products.py index ff99e671..25f4d486 100644 --- a/cli/conformance/adversarial/adapter-products/test_adapter_products.py +++ b/cli/conformance/adversarial/adapter-products/test_adapter_products.py @@ -631,7 +631,6 @@ def test_doctor_reports_selected_missing_adapter(self) -> None: environment, canaries = self.make_environment( temporary, traps, - {"PROSE_AUTH_PROFILE": self.auth_profile(adapter_id)}, ) harness, transport = adapter_id.split("/", 1) globals_ = ( @@ -644,6 +643,8 @@ def test_doctor_reports_selected_missing_adapter(self) -> None: [ "--harness", harness, + "--auth-profile", + self.auth_profile(adapter_id), "--transport", transport, *globals_, @@ -699,7 +700,6 @@ def test_dry_run_reports_selection_billing_and_missing_readiness(self) -> None: environment, canaries = self.make_environment( temporary, traps, - {"PROSE_AUTH_PROFILE": self.auth_profile(adapter_id)}, ) harness, transport = adapter_id.split("/", 1) globals_ = ( @@ -712,6 +712,8 @@ def test_dry_run_reports_selection_billing_and_missing_readiness(self) -> None: [ "--harness", harness, + "--auth-profile", + self.auth_profile(adapter_id), "--transport", transport, *globals_, diff --git a/cli/conformance/cases/case-manifest.schema.json b/cli/conformance/cases/case-manifest.schema.json index f418c038..613eb249 100644 --- a/cli/conformance/cases/case-manifest.schema.json +++ b/cli/conformance/cases/case-manifest.schema.json @@ -20,6 +20,9 @@ "fixedTimestamp": { "type": "string", "format": "date-time" }, "fixedMonotonicMs": { "type": "integer", "minimum": 0 }, "network": { "const": "denied" }, + "configurationFixture": { + "enum": ["operations.config-production-01", "operations.config-production-02", "operations.config-production-03", "operations.config-production-04", "operations.config-production-05", "operations.config-production-06", "operations.config-production-07", "operations.config-production-08", "operations.config-production-09", "operations.config-production-10", "operations.config-production-11", "operations.config-production-12"] + }, "omitEnvironment": { "type": "array", "uniqueItems": true, diff --git a/cli/conformance/cases/operations/config-production-01.json b/cli/conformance/cases/operations/config-production-01.json new file mode 100644 index 00000000..d06d8530 --- /dev/null +++ b/cli/conformance/cases/operations/config-production-01.json @@ -0,0 +1,67 @@ +{ + "schema": "openprose.runner-case/1", + "id": "operations.config-production-01", + "summary": "Missing settings expose inherited defaults without creating a file.", + "phase": 6, + "tags": [ + "cli", + "configuration", + "production", + "differential" + ], + "controls": { + "fixedInvocationId": "fixture-invocation-0001", + "fixedTimestamp": "2025-01-01T00:00:00Z", + "fixedMonotonicMs": 0, + "network": "denied", + "configurationFixture": "operations.config-production-01" + }, + "invocation": { + "argv": [ + "cli", + "config", + "explain", + "--json" + ], + "cwd": "{{WORKSPACE}}", + "environment": { + "HOME": "{{WORKSPACE}}/home", + "XDG_CONFIG_HOME": "{{WORKSPACE}}/config" + } + }, + "expected": { + "exitCode": 0, + "stdout": { + "kind": "json", + "schema": "openprose.configuration-explanation/1" + }, + "stderr": { + "kind": "empty" + }, + "startedHarness": false, + "resultMatches": { + "target": null, + "diagnostics": [], + "values": { + "harness": { + "value": "openprose" + }, + "model": { + "value": null + }, + "authProfile": { + "value": null + }, + "nativeMaxTurns": { + "value": null + }, + "nativeLog": { + "value": null + } + }, + "runtime": { + "nativeLimits": null + } + } + } +} diff --git a/cli/conformance/cases/operations/config-production-02.json b/cli/conformance/cases/operations/config-production-02.json new file mode 100644 index 00000000..ca7b4a67 --- /dev/null +++ b/cli/conformance/cases/operations/config-production-02.json @@ -0,0 +1,87 @@ +{ + "schema": "openprose.runner-case/1", + "id": "operations.config-production-02", + "summary": "Explicit SDK selection resolves its model, auth and inner limits without probing.", + "phase": 6, + "tags": [ + "cli", + "configuration", + "production", + "differential" + ], + "controls": { + "fixedInvocationId": "fixture-invocation-0001", + "fixedTimestamp": "2025-01-01T00:00:00Z", + "fixedMonotonicMs": 0, + "network": "denied", + "configurationFixture": "operations.config-production-02" + }, + "invocation": { + "argv": [ + "cli", + "config", + "explain", + "--json", + "--", + "--harness", + "agents-sdk", + "run", + "input.prose.md" + ], + "cwd": "{{WORKSPACE}}", + "environment": { + "HOME": "{{WORKSPACE}}/home", + "XDG_CONFIG_HOME": "{{WORKSPACE}}/config" + } + }, + "expected": { + "exitCode": 0, + "stdout": { + "kind": "json", + "schema": "openprose.configuration-explanation/1" + }, + "stderr": { + "kind": "empty" + }, + "startedHarness": false, + "resultMatches": { + "target": { + "argv": [ + "prose", + "run", + "input.prose.md" + ] + }, + "values": { + "harness": { + "value": "agents-sdk" + }, + "model": { + "value": "gpt-6.1-sol", + "source": { + "kind": "default", + "location": "built-in:agents-sdk" + } + }, + "authProfile": { + "value": "openai-api-key", + "source": { + "kind": "default", + "location": "built-in:agents-sdk" + } + } + }, + "runtime": { + "transport": "jsonl", + "authProfile": "openai-api-key", + "billingOwner": "user-provider", + "nativeLimits": { + "maxTurns": 20, + "timeoutSeconds": 180, + "toolTimeoutSeconds": 30, + "maxOutputTokens": 12000 + } + } + } + } +} diff --git a/cli/conformance/cases/operations/config-production-03.json b/cli/conformance/cases/operations/config-production-03.json new file mode 100644 index 00000000..ce56fb12 --- /dev/null +++ b/cli/conformance/cases/operations/config-production-03.json @@ -0,0 +1,113 @@ +{ + "schema": "openprose.runner-case/1", + "id": "operations.config-production-03", + "summary": "Target flags win over environment, project and user values while preserving candidates.", + "phase": 6, + "tags": [ + "cli", + "configuration", + "production", + "differential" + ], + "controls": { + "fixedInvocationId": "fixture-invocation-0001", + "fixedTimestamp": "2025-01-01T00:00:00Z", + "fixedMonotonicMs": 0, + "network": "denied", + "configurationFixture": "operations.config-production-03" + }, + "invocation": { + "argv": [ + "cli", + "config", + "explain", + "--json", + "--", + "--timeout", + "4m", + "run", + "--model", + "opaque-model" + ], + "cwd": "{{WORKSPACE}}", + "environment": { + "HOME": "{{WORKSPACE}}/home", + "XDG_CONFIG_HOME": "{{WORKSPACE}}/config", + "PROSE_TIMEOUT": "3m" + } + }, + "expected": { + "exitCode": 0, + "stdout": { + "kind": "json", + "schema": "openprose.configuration-explanation/1" + }, + "stderr": { + "kind": "empty" + }, + "startedHarness": false, + "resultMatches": { + "target": { + "argv": [ + "prose", + "run", + "--model", + "opaque-model" + ] + }, + "values": { + "timeout": { + "value": "4m", + "source": { + "kind": "flag", + "location": "--timeout" + } + } + }, + "candidates": { + "timeout": [ + { + "value": "10m", + "selected": false, + "source": { + "kind": "default", + "location": "built-in" + } + }, + { + "value": "1m", + "selected": false, + "source": { + "kind": "user-config", + "location": "{{WORKSPACE}}/home/.prose/cli.toml:1" + } + }, + { + "value": "2m", + "selected": false, + "source": { + "kind": "project-config", + "location": "{{WORKSPACE}}/.prose/cli.toml:1" + } + }, + { + "value": "3m", + "selected": false, + "source": { + "kind": "environment", + "location": "PROSE_TIMEOUT" + } + }, + { + "value": "4m", + "selected": true, + "source": { + "kind": "flag", + "location": "--timeout" + } + } + ] + } + } + } +} diff --git a/cli/conformance/cases/operations/config-production-04.json b/cli/conformance/cases/operations/config-production-04.json new file mode 100644 index 00000000..c6321f92 --- /dev/null +++ b/cli/conformance/cases/operations/config-production-04.json @@ -0,0 +1,64 @@ +{ + "schema": "openprose.runner-case/1", + "id": "operations.config-production-04", + "summary": "Legacy-only settings remain effective through visible read-only fallback.", + "phase": 6, + "tags": [ + "cli", + "configuration", + "production", + "differential" + ], + "controls": { + "fixedInvocationId": "fixture-invocation-0001", + "fixedTimestamp": "2025-01-01T00:00:00Z", + "fixedMonotonicMs": 0, + "network": "denied", + "configurationFixture": "operations.config-production-04" + }, + "invocation": { + "argv": [ + "cli", + "config", + "explain", + "--json" + ], + "cwd": "{{WORKSPACE}}", + "environment": { + "HOME": "{{WORKSPACE}}/home", + "XDG_CONFIG_HOME": "{{WORKSPACE}}/config" + } + }, + "expected": { + "exitCode": 0, + "stdout": { + "kind": "json", + "schema": "openprose.configuration-explanation/1" + }, + "stderr": { + "kind": "empty" + }, + "startedHarness": false, + "resultMatches": { + "values": { + "harness": { + "value": "codex" + }, + "verbose": { + "value": true + }, + "authProfile": { + "value": "cached-chatgpt-login" + } + }, + "diagnostics": [ + { + "code": "LEGACY_CONFIG_ACTIVE", + "severity": "warning", + "source": "{{WORKSPACE}}/config/openprose/cli.toml", + "reason": "Legacy user configuration is active; run prose cli config migrate to copy explicit settings." + } + ] + } + } +} diff --git a/cli/conformance/cases/operations/config-production-05.json b/cli/conformance/cases/operations/config-production-05.json new file mode 100644 index 00000000..88cb2538 --- /dev/null +++ b/cli/conformance/cases/operations/config-production-05.json @@ -0,0 +1,61 @@ +{ + "schema": "openprose.runner-case/1", + "id": "operations.config-production-05", + "summary": "Canonical settings win as a complete file and conflicting legacy keys are visible.", + "phase": 6, + "tags": [ + "cli", + "configuration", + "production", + "differential" + ], + "controls": { + "fixedInvocationId": "fixture-invocation-0001", + "fixedTimestamp": "2025-01-01T00:00:00Z", + "fixedMonotonicMs": 0, + "network": "denied", + "configurationFixture": "operations.config-production-05" + }, + "invocation": { + "argv": [ + "cli", + "config", + "explain", + "--json" + ], + "cwd": "{{WORKSPACE}}", + "environment": { + "HOME": "{{WORKSPACE}}/home", + "XDG_CONFIG_HOME": "{{WORKSPACE}}/config" + } + }, + "expected": { + "exitCode": 0, + "stdout": { + "kind": "json", + "schema": "openprose.configuration-explanation/1" + }, + "stderr": { + "kind": "empty" + }, + "startedHarness": false, + "resultMatches": { + "values": { + "harness": { + "value": "agents-sdk" + }, + "verbose": { + "value": false + } + }, + "diagnostics": [ + { + "code": "LEGACY_CONFIG_IGNORED", + "severity": "warning", + "source": "{{WORKSPACE}}/config/openprose/cli.toml", + "reason": "Canonical user configuration is authoritative; legacy values are ignored. Differing explicit keys: harness, model, verbose." + } + ] + } + } +} diff --git a/cli/conformance/cases/operations/config-production-06.json b/cli/conformance/cases/operations/config-production-06.json new file mode 100644 index 00000000..ccb90c16 --- /dev/null +++ b/cli/conformance/cases/operations/config-production-06.json @@ -0,0 +1,58 @@ +{ + "schema": "openprose.runner-case/1", + "id": "operations.config-production-06", + "summary": "Explicit migration copies comments and only explicit values without deleting legacy.", + "phase": 6, + "tags": [ + "cli", + "configuration", + "production", + "differential" + ], + "controls": { + "fixedInvocationId": "fixture-invocation-0001", + "fixedTimestamp": "2025-01-01T00:00:00Z", + "fixedMonotonicMs": 0, + "network": "denied", + "configurationFixture": "operations.config-production-06" + }, + "invocation": { + "argv": [ + "cli", + "config", + "migrate", + "--json" + ], + "cwd": "{{WORKSPACE}}", + "environment": { + "HOME": "{{WORKSPACE}}/home", + "XDG_CONFIG_HOME": "{{WORKSPACE}}/config" + } + }, + "expected": { + "exitCode": 0, + "stdout": { + "kind": "json", + "schema": "openprose.configuration-explanation/1" + }, + "stderr": { + "kind": "empty" + }, + "startedHarness": false, + "resultMatches": { + "mutation": { + "operation": "migrate", + "changed": true, + "keys": [] + }, + "values": { + "harness": { + "value": "codex" + }, + "verbose": { + "value": true + } + } + } + } +} diff --git a/cli/conformance/cases/operations/config-production-07.json b/cli/conformance/cases/operations/config-production-07.json new file mode 100644 index 00000000..3bcd7880 --- /dev/null +++ b/cli/conformance/cases/operations/config-production-07.json @@ -0,0 +1,49 @@ +{ + "schema": "openprose.runner-case/1", + "id": "operations.config-production-07", + "summary": "Migration refuses an existing destination without modifying either file.", + "phase": 6, + "tags": [ + "cli", + "configuration", + "production", + "differential" + ], + "controls": { + "fixedInvocationId": "fixture-invocation-0001", + "fixedTimestamp": "2025-01-01T00:00:00Z", + "fixedMonotonicMs": 0, + "network": "denied", + "configurationFixture": "operations.config-production-07" + }, + "invocation": { + "argv": [ + "cli", + "config", + "migrate", + "--json" + ], + "cwd": "{{WORKSPACE}}", + "environment": { + "HOME": "{{WORKSPACE}}/home", + "XDG_CONFIG_HOME": "{{WORKSPACE}}/config" + } + }, + "expected": { + "exitCode": 2, + "stdout": { + "kind": "json", + "schema": "openprose.runner-error/1" + }, + "stderr": { + "kind": "empty" + }, + "startedHarness": false, + "resultMatches": { + "code": "CONFIG_INVALID", + "details": { + "reason": "Canonical user configuration already exists; migration never overwrites it." + } + } + } +} diff --git a/cli/conformance/cases/operations/config-production-08.json b/cli/conformance/cases/operations/config-production-08.json new file mode 100644 index 00000000..fbabf681 --- /dev/null +++ b/cli/conformance/cases/operations/config-production-08.json @@ -0,0 +1,61 @@ +{ + "schema": "openprose.runner-case/1", + "id": "operations.config-production-08", + "summary": "Unset removes only a named override, preserving unrelated values and comments.", + "phase": 6, + "tags": [ + "cli", + "configuration", + "production", + "differential" + ], + "controls": { + "fixedInvocationId": "fixture-invocation-0001", + "fixedTimestamp": "2025-01-01T00:00:00Z", + "fixedMonotonicMs": 0, + "network": "denied", + "configurationFixture": "operations.config-production-08" + }, + "invocation": { + "argv": [ + "cli", + "config", + "unset", + "timeout", + "--json" + ], + "cwd": "{{WORKSPACE}}", + "environment": { + "HOME": "{{WORKSPACE}}/home", + "XDG_CONFIG_HOME": "{{WORKSPACE}}/config" + } + }, + "expected": { + "exitCode": 0, + "stdout": { + "kind": "json", + "schema": "openprose.configuration-explanation/1" + }, + "stderr": { + "kind": "empty" + }, + "startedHarness": false, + "resultMatches": { + "mutation": { + "operation": "unset", + "changed": true, + "keys": [ + "timeout" + ] + }, + "values": { + "timeout": { + "value": "10m" + }, + "verbose": { + "value": true + } + } + } + } +} diff --git a/cli/conformance/cases/operations/config-production-09.json b/cli/conformance/cases/operations/config-production-09.json new file mode 100644 index 00000000..9cc8cc69 --- /dev/null +++ b/cli/conformance/cases/operations/config-production-09.json @@ -0,0 +1,58 @@ +{ + "schema": "openprose.runner-case/1", + "id": "operations.config-production-09", + "summary": "Malformed active settings report a partial explanation without revealing rejected text.", + "phase": 6, + "tags": [ + "cli", + "configuration", + "production", + "differential" + ], + "controls": { + "fixedInvocationId": "fixture-invocation-0001", + "fixedTimestamp": "2025-01-01T00:00:00Z", + "fixedMonotonicMs": 0, + "network": "denied", + "configurationFixture": "operations.config-production-09" + }, + "invocation": { + "argv": [ + "cli", + "config", + "explain", + "--json" + ], + "cwd": "{{WORKSPACE}}", + "environment": { + "HOME": "{{WORKSPACE}}/home", + "XDG_CONFIG_HOME": "{{WORKSPACE}}/config" + } + }, + "expected": { + "exitCode": 2, + "stdout": { + "kind": "json", + "schema": "openprose.runner-error/1" + }, + "stderr": { + "kind": "empty" + }, + "startedHarness": false, + "resultMatches": { + "code": "CONFIG_INVALID", + "details": { + "configurationExplanation": { + "diagnostics": [ + { + "code": "CONFIG_INVALID", + "severity": "error", + "source": "{{WORKSPACE}}/home/.prose/cli.toml:2", + "reason": "Configuration contains an unknown key." + } + ] + } + } + } + } +} diff --git a/cli/conformance/cases/operations/config-production-10.json b/cli/conformance/cases/operations/config-production-10.json new file mode 100644 index 00000000..a44ffc40 --- /dev/null +++ b/cli/conformance/cases/operations/config-production-10.json @@ -0,0 +1,68 @@ +{ + "schema": "openprose.runner-case/1", + "id": "operations.config-production-10", + "summary": "Selecting another harness rejects a stale lower-layer credential bundle before readiness.", + "phase": 6, + "tags": [ + "cli", + "configuration", + "production", + "differential" + ], + "controls": { + "fixedInvocationId": "fixture-invocation-0001", + "fixedTimestamp": "2025-01-01T00:00:00Z", + "fixedMonotonicMs": 0, + "network": "denied", + "configurationFixture": "operations.config-production-10" + }, + "invocation": { + "argv": [ + "cli", + "config", + "explain", + "--json", + "--", + "--harness", + "agents-sdk", + "run", + "input.prose.md" + ], + "cwd": "{{WORKSPACE}}", + "environment": { + "HOME": "{{WORKSPACE}}/home", + "XDG_CONFIG_HOME": "{{WORKSPACE}}/config" + } + }, + "expected": { + "exitCode": 2, + "stdout": { + "kind": "json", + "schema": "openprose.runner-error/1" + }, + "stderr": { + "kind": "empty" + }, + "startedHarness": false, + "resultMatches": { + "code": "CONFIG_INVALID", + "details": { + "configurationExplanation": { + "values": { + "harness": { + "value": "agents-sdk" + } + }, + "diagnostics": [ + { + "code": "CONFIG_INVALID", + "severity": "error", + "source": "{{WORKSPACE}}/home/.prose/cli.toml:3", + "reason": "Inherited model belongs to a different harness; replace it at the harness-selecting layer." + } + ] + } + } + } + } +} diff --git a/cli/conformance/cases/operations/config-production-11.json b/cli/conformance/cases/operations/config-production-11.json new file mode 100644 index 00000000..168fd4e3 --- /dev/null +++ b/cli/conformance/cases/operations/config-production-11.json @@ -0,0 +1,52 @@ +{ + "schema": "openprose.runner-case/1", + "id": "operations.config-production-11", + "summary": "An absolute configuration-root override replaces canonical and legacy discovery.", + "phase": 6, + "tags": [ + "cli", + "configuration", + "production", + "differential" + ], + "controls": { + "fixedInvocationId": "fixture-invocation-0001", + "fixedTimestamp": "2025-01-01T00:00:00Z", + "fixedMonotonicMs": 0, + "network": "denied", + "configurationFixture": "operations.config-production-11" + }, + "invocation": { + "argv": [ + "cli", + "config", + "explain", + "--json" + ], + "cwd": "{{WORKSPACE}}", + "environment": { + "HOME": "{{WORKSPACE}}/home", + "XDG_CONFIG_HOME": "{{WORKSPACE}}/config", + "PROSE_CONFIG_DIR": "{{WORKSPACE}}/custom root" + } + }, + "expected": { + "exitCode": 0, + "stdout": { + "kind": "json", + "schema": "openprose.configuration-explanation/1" + }, + "stderr": { + "kind": "empty" + }, + "startedHarness": false, + "resultMatches": { + "values": { + "timeout": { + "value": "7m" + } + }, + "diagnostics": [] + } + } +} diff --git a/cli/conformance/cases/operations/config-production-12.json b/cli/conformance/cases/operations/config-production-12.json new file mode 100644 index 00000000..cb733d1f --- /dev/null +++ b/cli/conformance/cases/operations/config-production-12.json @@ -0,0 +1,70 @@ +{ + "schema": "openprose.runner-case/1", + "id": "operations.config-production-12", + "summary": "Target cwd selects its own project settings and preserves every opaque token.", + "phase": 6, + "tags": [ + "cli", + "configuration", + "production", + "differential" + ], + "controls": { + "fixedInvocationId": "fixture-invocation-0001", + "fixedTimestamp": "2025-01-01T00:00:00Z", + "fixedMonotonicMs": 0, + "network": "denied", + "configurationFixture": "operations.config-production-12" + }, + "invocation": { + "argv": [ + "cli", + "config", + "explain", + "--json", + "--", + "--cwd", + "work space", + "--harness", + "agents-sdk", + "--", + "run", + "--harness", + "literal" + ], + "cwd": "{{WORKSPACE}}", + "environment": { + "HOME": "{{WORKSPACE}}/home", + "XDG_CONFIG_HOME": "{{WORKSPACE}}/config" + } + }, + "expected": { + "exitCode": 0, + "stdout": { + "kind": "json", + "schema": "openprose.configuration-explanation/1" + }, + "stderr": { + "kind": "empty" + }, + "startedHarness": false, + "resultMatches": { + "target": { + "argv": [ + "prose", + "run", + "--harness", + "literal" + ] + }, + "values": { + "timeout": { + "value": "6m" + }, + "harness": { + "value": "agents-sdk" + } + } + } + } +} diff --git a/cli/conformance/cases/operations/harness-use-omp-bundle.json b/cli/conformance/cases/operations/harness-use-omp-bundle.json index ef09f92e..e82103fe 100644 --- a/cli/conformance/cases/operations/harness-use-omp-bundle.json +++ b/cli/conformance/cases/operations/harness-use-omp-bundle.json @@ -38,12 +38,12 @@ "schema": "openprose.harness-selection/1", "harness": "omp", "scope": "user", - "path": "{{WORKSPACE}}/config/openprose/cli.toml", + "path": "{{WORKSPACE}}/home/.prose/cli.toml", "changed": true }, "fileEffects": [ { - "path": "{{WORKSPACE}}/config/openprose/cli.toml", + "path": "{{WORKSPACE}}/home/.prose/cli.toml", "utf8": "auth_profile = \"omp-harness-login\"\nharness = \"omp\"\nmodel = \"fixture/model\"\n" } ] diff --git a/cli/conformance/cases/operations/harness-use-prime-bundle.json b/cli/conformance/cases/operations/harness-use-prime-bundle.json index 37345b7d..8328c232 100644 --- a/cli/conformance/cases/operations/harness-use-prime-bundle.json +++ b/cli/conformance/cases/operations/harness-use-prime-bundle.json @@ -38,12 +38,12 @@ "schema": "openprose.harness-selection/1", "harness": "prime", "scope": "user", - "path": "{{WORKSPACE}}/config/openprose/cli.toml", + "path": "{{WORKSPACE}}/home/.prose/cli.toml", "changed": true }, "fileEffects": [ { - "path": "{{WORKSPACE}}/config/openprose/cli.toml", + "path": "{{WORKSPACE}}/home/.prose/cli.toml", "utf8": "auth_profile = \"prime-harness-login\"\nharness = \"prime\"\nmodel = \"fixture/model\"\n" } ] diff --git a/cli/conformance/cases/operations/harness-use-user.json b/cli/conformance/cases/operations/harness-use-user.json index d8683b72..fa57b1ae 100644 --- a/cli/conformance/cases/operations/harness-use-user.json +++ b/cli/conformance/cases/operations/harness-use-user.json @@ -27,12 +27,12 @@ "schema": "openprose.harness-selection/1", "harness": "claude", "scope": "user", - "path": "{{WORKSPACE}}/config/openprose/cli.toml", + "path": "{{WORKSPACE}}/home/.prose/cli.toml", "changed": true }, "fileEffects": [ { - "path": "{{WORKSPACE}}/config/openprose/cli.toml", + "path": "{{WORKSPACE}}/home/.prose/cli.toml", "utf8": "harness = \"claude\"\n" } ] diff --git a/cli/conformance/runner/run.py b/cli/conformance/runner/run.py index 409ea925..a4f8a13e 100755 --- a/cli/conformance/runner/run.py +++ b/cli/conformance/runner/run.py @@ -154,6 +154,7 @@ class Observation: process_settled: bool = True stdout_truncated: bool = False stderr_truncated: bool = False + configuration_before: dict[str, tuple[str, str]] | None = None @dataclass(frozen=True) @@ -1469,6 +1470,157 @@ def hermetic_environment(root: Path, additions: dict[str, str]) -> dict[str, str return environment +CONFIGURATION_FIXTURE = CLI / "shared/fixtures/config/production-v2.json" +CONFIGURATION_CASE_IDS = [f"operations.config-production-{n:02}" for n in range(1, 13)] + + +def configuration_path(workspace: Path, relative: str) -> Path: + """Refuse traversal and symlinks, even if a link currently stays in the root.""" + if not isinstance(relative, str) or not relative or "\\" in relative: + raise ValueError("configuration fixture path must be a safe relative path") + parts = relative.split("/") + if any(part in {"", ".", ".."} or ":" in part for part in parts): + raise ValueError("configuration fixture path leaves product workspace") + root = workspace.resolve() + path = root + for part in parts: + path = path / part + if path.is_symlink(): + raise ValueError("configuration fixture path contains a symlink") + return path + + +def load_configuration_setup(case: dict[str, Any]) -> dict[str, Any] | None: + reference = case.get("controls", {}).get("configurationFixture") + if reference is None: + return None + if reference != case.get("id") or reference not in CONFIGURATION_CASE_IDS: + raise ValueError("configurationFixture must reference its matching closed case") + if any(key in case["controls"] for key in ("fakeHarness", "installedAdapter")): + raise ValueError("configurationFixture cannot start a fake or installed harness") + corpus = json.loads(CONFIGURATION_FIXTURE.read_text("utf-8")) + if set(corpus) != {"schema", "summary", "cases"} or corpus["schema"] != "openprose.configuration-production-corpus/2": + raise ValueError("invalid closed configuration fixture corpus") + if [record.get("id") for record in corpus["cases"]] != CONFIGURATION_CASE_IDS: + raise ValueError("configuration fixture corpus must contain exactly twelve ordered cases") + for record in corpus["cases"]: + if set(record) != {"id", "setup"}: + raise ValueError("invalid configuration fixture record") + setup = record["setup"] + if set(setup) != {"files", "directories", "checks"}: + raise ValueError("invalid configuration fixture setup") + checks = setup["checks"] + if not isinstance(checks, dict) or set(checks) - {"unchangedFiles", "files", "absent", "outputAbsent"}: + raise ValueError("invalid configuration fixture checks") + for files in (setup["files"], checks.get("files", {})): + if not isinstance(files, dict) or any(not isinstance(value, str) for value in files.values()): + raise ValueError("configuration fixture files must contain UTF-8 strings") + unchanged = checks.get("unchangedFiles", False) + if type(unchanged) is not bool and not isinstance(unchanged, list): + raise ValueError("unchangedFiles must be a boolean or selected path list") + for paths in (setup["directories"], checks.get("absent", []), checks.get("outputAbsent", []), unchanged if isinstance(unchanged, list) else []): + if not isinstance(paths, list) or any(not isinstance(value, str) or not value for value in paths) or len(paths) != len(set(paths)): + raise ValueError("configuration fixture lists must contain unique nonempty strings") + if isinstance(unchanged, list) and any(path not in setup["files"] for path in unchanged): + raise ValueError("unchangedFiles must select existing setup files") + return next(record["setup"] for record in corpus["cases"] if record["id"] == reference) + + +def configuration_snapshot(workspace: Path) -> dict[str, tuple[str, str]]: + """Bounded byte/type oracle; no symlink traversal or provider involvement.""" + snapshot: dict[str, tuple[str, str]] = {} + total_bytes = 0 + pending = [workspace.resolve()] + while pending: + directory = pending.pop() + for path in directory.iterdir(): + relative = path.relative_to(workspace.resolve()).as_posix() + mode = path.lstat().st_mode + if stat.S_ISLNK(mode): + snapshot[relative] = ("symlink", os.readlink(path)) + elif stat.S_ISDIR(mode): + snapshot[relative] = ("directory", "") + pending.append(path) + elif stat.S_ISREG(mode): + size = path.stat().st_size + total_bytes += size + if total_bytes > MAX_CAPTURE_BYTES: + raise ValueError("configuration effects exceed bounded snapshot bytes") + with path.open("rb") as source: + content = source.read(size + 1) + if len(content) != size: + raise ValueError("configuration file changed during bounded snapshot") + snapshot[relative] = ("file", sha256(content)) + else: + snapshot[relative] = ("nonregular", "") + if len(snapshot) > 1024: + raise ValueError("configuration effects exceed bounded snapshot entries") + return snapshot + + +def prepare_configuration(workspace: Path, setup: dict[str, Any]) -> dict[str, tuple[str, str]]: + # Validate every path before creating anything. + checks = setup["checks"] + unchanged = checks.get("unchangedFiles", False) + paths = [*setup["directories"], *setup["files"], *checks.get("files", {}), *checks.get("absent", []), *(unchanged if isinstance(unchanged, list) else [])] + for relative in paths: + configuration_path(workspace, relative) + for relative in setup["directories"]: + configuration_path(workspace, relative).mkdir(parents=True, exist_ok=True) + for relative, value in setup["files"].items(): + path = configuration_path(workspace, relative) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("xb") as output: + output.write(value.encode("utf-8")) + return configuration_snapshot(workspace) + + +def validate_configuration_effects(observation: Observation) -> list[str]: + setup = load_configuration_setup(observation.case) + if setup is None: + return [] + if observation.workspace is None or observation.configuration_before is None: + return ["configuration effects lack an isolated pre-execution snapshot"] + workspace = observation.workspace + checks = setup["checks"] + failures: list[str] = [] + try: + after = configuration_snapshot(workspace) + unchanged = checks.get("unchangedFiles", False) + if unchanged is True and after != observation.configuration_before: + failures.append("configuration effects changed the protected workspace tree") + elif isinstance(unchanged, list): + for relative in unchanged: + if after.get(relative) != observation.configuration_before.get(relative): + failures.append(f"configuration effects changed protected file: {relative}") + for relative, value in checks.get("files", {}).items(): + path = configuration_path(workspace, relative) + if not path.is_file() or path.read_bytes() != value.encode("utf-8"): + failures.append(f"configuration effects expected exact file bytes: {relative}") + for relative in checks.get("absent", []): + path = configuration_path(workspace, relative) + if path.exists(): + failures.append(f"configuration effects expected absent path: {relative}") + except (OSError, ValueError) as error: + failures.append(f"configuration effects cannot be checked safely: {error}") + for sentinel in checks.get("outputAbsent", []): + if any(sentinel.encode("utf-8") in stream for stream in (observation.stdout, observation.stderr)): + failures.append("configuration output exposed a forbidden fixture sentinel") + return failures + + +def isolate_workspace(workspace: Path) -> None: + """Make project discovery stop at the product-owned root, retaining nested ancestry.""" + workspace.mkdir(parents=True, exist_ok=True) + boundary = workspace / ".git" + if boundary.is_symlink(): + raise ValueError("mechanical workspace Git boundary cannot be a symlink") + if not boundary.exists(): + boundary.mkdir() + elif not boundary.is_dir() and not boundary.is_file(): + raise ValueError("mechanical workspace Git boundary must be a directory or file") + + def execute( product: Product, case: dict[str, Any], @@ -1476,10 +1628,17 @@ def execute( workspace: Path, ) -> Observation: canonical_workspace = workspace.resolve() + configuration_setup = load_configuration_setup(case) cwd_text = case["invocation"]["cwd"].replace( "{{WORKSPACE}}", str(canonical_workspace) ) cwd = Path(cwd_text) + if configuration_setup is not None: + try: + cwd.resolve().relative_to(canonical_workspace) + except ValueError as error: + raise ValueError("configuration fixture cwd leaves product workspace") from error + isolate_workspace(canonical_workspace) cwd.mkdir(parents=True, exist_ok=True) additions = { key: value.replace("{{WORKSPACE}}", str(canonical_workspace)) @@ -1532,6 +1691,8 @@ def execute( if adapter_id in {"prime/rpc", "omp/rpc"}: additions["OPENROUTER_API_KEY"] = "fixture-provider-free-openrouter-key" environment = hermetic_environment(environment_root, additions) + configuration_before = (prepare_configuration(canonical_workspace, configuration_setup) + if configuration_setup is not None else None) for name in case["controls"].get("omitEnvironment", []): environment.pop(name, None) completed = run_owned_process( @@ -1551,6 +1712,7 @@ def execute( process_settled=completed.settled, stdout_truncated=completed.stdout_truncated, stderr_truncated=completed.stderr_truncated, + configuration_before=configuration_before, ) @@ -1914,6 +2076,7 @@ def validate_output(observation: Observation, contracts: ContractRegistry) -> li f"wanted {sha256(effect['utf8'].encode('utf-8'))}, " f"got {sha256(actual.encode('utf-8'))}" ) + failures.extend(validate_configuration_effects(observation)) return failures diff --git a/cli/conformance/runner/test_runner.py b/cli/conformance/runner/test_runner.py index 6c8372a9..db5c59a2 100755 --- a/cli/conformance/runner/test_runner.py +++ b/cli/conformance/runner/test_runner.py @@ -109,7 +109,187 @@ def test_host_oracle_rejects_unsupported_host_without_skipping_case(self): wanted = runner.expected_for_host(case, "linux", "aarch64") self.assertEqual("arm64", wanted["resultMatches"]["error"]["details"]["hostArchitecture"]) self.assertTrue(runner.deep_subset({"terminal": {"classification": "success"}}, wanted["resultMatches"])) - self.assertEqual(64, len(list(runner.case_paths(7, set())))) + self.assertEqual(76, len(list(runner.case_paths(7, set())))) + + def test_configuration_corpus_prepares_each_product_independently_and_checks_effects(self): + for identifier in runner.CONFIGURATION_CASE_IDS: + case = json.loads((runner.CASES / "operations" / f"{identifier.split('.')[-1]}.json").read_text()) + setup = runner.load_configuration_setup(case) + with self.subTest(case=identifier), tempfile.TemporaryDirectory() as temporary: + case_root = Path(temporary) + workspaces = [] + for name in ("rust", "bun"): + environment_root, workspace = runner.product_roots(case_root, name) + def product_call(argv, *, cwd, environment, timeout_seconds): + self.assertEqual(15, timeout_seconds) + self.assertTrue((workspace / ".git").is_dir()) + self.assertEqual(str(workspace.resolve() / "home"), environment["HOME"]) + for relative, value in setup["files"].items(): + self.assertEqual(value.encode(), (workspace / relative).read_bytes()) + for relative, value in setup["checks"].get("files", {}).items(): + destination = workspace / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(value.encode()) + return runner.OwnedProcessResult(0, b"{}", b"", False, True, False, False) + with patch.object(runner, "run_owned_process", side_effect=product_call): + observation = runner.execute(runner.Product(name, Path(sys.executable)), case, environment_root, workspace) + self.assertEqual([], runner.validate_configuration_effects(observation)) + self.assertEqual(observation.configuration_before[".git"], ("directory", "")) + workspaces.append(workspace) + self.assertNotEqual(workspaces[0], workspaces[1]) + (workspaces[0] / "unexpected-state").write_bytes(b"product-specific") + self.assertFalse((workspaces[1] / "unexpected-state").exists()) + + def configuration_observation(self, number, workspace): + case = json.loads((runner.CASES / "operations" / f"config-production-{number:02}.json").read_text()) + setup = runner.load_configuration_setup(case) + before = runner.prepare_configuration(workspace, setup) + return runner.Observation(runner.Product("fixture", Path(sys.executable)), case, 0, b"", b"", workspace=workspace, configuration_before=before) + + def test_configuration_tree_oracle_detects_additions_deletion_types_and_bytes(self): + for alteration in ("addition", "directory", "deletion", "bytes", "symlink"): + with self.subTest(alteration=alteration), tempfile.TemporaryDirectory() as temporary: + workspace = Path(temporary) + observation = self.configuration_observation(3, workspace) + protected = workspace / "home/.prose/cli.toml" + if alteration == "addition": + (workspace / "unexpected").write_bytes(b"unexpected") + elif alteration == "directory": + (workspace / "unexpected").mkdir() + elif alteration == "deletion": + protected.unlink() + elif alteration == "bytes": + protected.write_bytes(b"changed") + else: + protected.unlink() + protected.symlink_to(workspace / ".prose/cli.toml") + self.assertTrue(runner.validate_configuration_effects(observation)) + + def test_configuration_selected_source_and_exact_destination_are_separate_oracles(self): + with tempfile.TemporaryDirectory() as temporary: + workspace = Path(temporary) + observation = self.configuration_observation(6, workspace) + self.assertTrue(runner.validate_configuration_effects(observation)) + source = workspace / "config/openprose/cli.toml" + destination = workspace / "home/.prose/cli.toml" + destination.parent.mkdir(parents=True) + destination.write_bytes(source.read_bytes()) + self.assertEqual([], runner.validate_configuration_effects(observation)) + destination.write_bytes(source.read_bytes().replace(b"\n", b"\r\n")) + self.assertTrue(runner.validate_configuration_effects(observation)) + destination.write_bytes(source.read_bytes()) + source.write_bytes(b"changed source") + self.assertTrue(any("protected file" in failure for failure in runner.validate_configuration_effects(observation))) + + def test_configuration_absence_and_redaction_check_both_streams(self): + with tempfile.TemporaryDirectory() as temporary: + workspace = Path(temporary) + observation = self.configuration_observation(1, workspace) + destination = workspace / "home/.prose/cli.toml" + destination.parent.mkdir(parents=True) + destination.write_bytes(b"") + self.assertTrue(any("absent path" in failure for failure in runner.validate_configuration_effects(observation))) + for stream in ("stdout", "stderr"): + with self.subTest(stream=stream), tempfile.TemporaryDirectory() as temporary: + observation = self.configuration_observation(9, Path(temporary)) + setattr(observation, stream, b"fixture-secret-do-not-print") + failures = runner.validate_configuration_effects(observation) + self.assertEqual(["configuration output exposed a forbidden fixture sentinel"], failures) + self.assertNotIn("fixture-secret-do-not-print", str(failures)) + + def test_configuration_setup_refuses_escape_links_and_existing_files_before_writing(self): + for relative in ("../escape", "/escape", "home/../escape", "C:/escape", "home\\escape", "home//escape", "home/./escape"): + with self.subTest(path=relative), tempfile.TemporaryDirectory() as temporary: + workspace = Path(temporary) + setup = {"files": {"would-write": "safe", relative: "bad"}, "directories": [], "checks": {}} + with self.assertRaises(ValueError): + runner.prepare_configuration(workspace, setup) + self.assertFalse((workspace / "would-write").exists()) + with tempfile.TemporaryDirectory() as temporary: + workspace = Path(temporary) + (workspace / "linked").symlink_to(workspace, target_is_directory=True) + with self.assertRaisesRegex(ValueError, "symlink"): + runner.prepare_configuration(workspace, {"files": {"linked/file": "bad"}, "directories": [], "checks": {}}) + (workspace / "existing").write_bytes(b"original") + with self.assertRaises(FileExistsError): + runner.prepare_configuration(workspace, {"files": {"existing": "bad"}, "directories": [], "checks": {}}) + self.assertEqual(b"original", (workspace / "existing").read_bytes()) + + def test_configuration_effects_participate_in_full_output_validation(self): + with tempfile.TemporaryDirectory() as temporary: + observation = self.configuration_observation(1, Path(temporary)) + observation.case["expected"] = {"exitCode": 0, "stdout": {"kind": "empty"}, "stderr": {"kind": "empty"}, "startedHarness": False} + self.assertEqual([], runner.validate_output(observation, runner.ContractRegistry())) + (observation.workspace / "unexpected").write_bytes(b"unexpected") + self.assertIn("configuration effects changed the protected workspace tree", runner.validate_output(observation, runner.ContractRegistry())) + + def test_workspace_boundary_preserves_nested_project_discovery_inputs(self): + with tempfile.TemporaryDirectory() as temporary: + workspace = Path(temporary) / "workspace" + nested = workspace / "work space" + nested.mkdir(parents=True) + (nested / ".prose").mkdir() + (nested / ".prose/cli.toml").write_bytes(b'timeout = "6m"\n') + (workspace / ".prose").mkdir() + (workspace / ".prose/cli.toml").write_bytes(b'timeout = "5m"\n') + runner.isolate_workspace(workspace) + self.assertTrue((workspace / ".git").is_dir()) + self.assertFalse((nested / ".git").exists()) + self.assertEqual(b'timeout = "6m"\n', (nested / ".prose/cli.toml").read_bytes()) + self.assertEqual(b'timeout = "5m"\n', (workspace / ".prose/cli.toml").read_bytes()) + # A supplied nested Git boundary and worktree-file boundary stay explicit. + (nested / ".git").mkdir() + (workspace / ".git").rmdir() + (workspace / ".git").write_bytes(b'gitdir: fixture-only\n') + runner.isolate_workspace(workspace) + self.assertEqual(b'gitdir: fixture-only\n', (workspace / ".git").read_bytes()) + self.assertTrue((nested / ".git").is_dir()) + + def test_configuration_snapshot_bounds_entries_and_bytes(self): + with tempfile.TemporaryDirectory() as temporary: + workspace = Path(temporary) + large = workspace / "large" + with large.open("wb") as output: + output.truncate(runner.MAX_CAPTURE_BYTES + 1) + with self.assertRaisesRegex(ValueError, "bounded snapshot bytes"): + runner.configuration_snapshot(workspace) + large.unlink() + for number in range(1025): + (workspace / str(number)).touch() + with self.assertRaisesRegex(ValueError, "bounded snapshot entries"): + runner.configuration_snapshot(workspace) + + def test_configuration_cwd_cannot_escape_product_workspace(self): + with tempfile.TemporaryDirectory() as temporary: + case_root = Path(temporary) + case = json.loads((runner.CASES / "operations/config-production-01.json").read_text()) + case["invocation"]["cwd"] = str(case_root / "escape") + with patch.object(runner, "run_owned_process") as start: + with self.assertRaisesRegex(ValueError, "cwd leaves product workspace"): + runner.execute(runner.Product("fixture", Path(sys.executable)), case, case_root / "environment", case_root / "workspace") + start.assert_not_called() + self.assertFalse((case_root / "escape").exists()) + + def test_configuration_references_and_corpus_fail_closed(self): + case = json.loads((runner.CASES / "operations/config-production-01.json").read_text()) + for reference in ("unknown", "operations.config-production-02"): + with self.subTest(reference=reference): + case["controls"]["configurationFixture"] = reference + with self.assertRaisesRegex(ValueError, "matching closed case"): + runner.load_configuration_setup(case) + case["controls"]["configurationFixture"] = case["id"] + with tempfile.TemporaryDirectory() as temporary: + fixture = Path(temporary) / "corpus.json" + corpus = json.loads(runner.CONFIGURATION_FIXTURE.read_text()) + for mutation in (lambda value: value.update(extra=True), lambda value: value["cases"].pop(), lambda value: value["cases"][0]["setup"]["checks"].update(extra=True)): + value = json.loads(json.dumps(corpus)) + mutation(value) + fixture.write_text(json.dumps(value)) + with patch.object(runner, "CONFIGURATION_FIXTURE", fixture), self.assertRaises(ValueError): + runner.load_configuration_setup(case) + case["controls"]["fakeHarness"] = {"scenario": "success"} + with self.assertRaisesRegex(ValueError, "cannot start"): + runner.load_configuration_setup(case) def test_hosted_transport_and_missing_selection_cases_freeze_dx_precedence( self, @@ -472,7 +652,7 @@ def test_exact_json_fixture_freezes_order_redaction_and_workspace(self) -> None: self.assertEqual( [], runner.validate_output(observation, runner.ContractRegistry()) ) - actual["configuration"][-1]["redacted"] = False + actual["configuration"][-1]["redacted"] = True observation.stdout = json.dumps(actual).encode() failures = runner.validate_output(observation, runner.ContractRegistry()) self.assertTrue( diff --git a/cli/protocol/OWNERSHIP.md b/cli/protocol/OWNERSHIP.md index db19d387..22b028b9 100644 --- a/cli/protocol/OWNERSHIP.md +++ b/cli/protocol/OWNERSHIP.md @@ -908,3 +908,49 @@ PR47 Linux admission failure. Publish the fixture's observed descendantPID before unrelated logging and validate a nonzero numericPID before procfs. Preserve300ms/3s bounds and killed-or-zombie assertion. Production logic, cleanup deadlines and frozen qualified RC3 binaries remain unchanged. + +## IMP-097 configuration contract and independent implementations — October 6, 2026 + +Lead Codex `/root`, session `01a1133b-e47b-7b32-af8e-2655afa0259c`, branch `codex/imp-097-user-configuration`, base `e780725`. Root owns Git/index, design and integration. + +`/root/configuration_contract` exact paths: `docs/user-configuration.md` (new), `cli/shared/schemas/configuration-explanation.schema.json`, `cli/shared/fixtures/config/production-v2.json` (new), and twelve new case files `cli/conformance/cases/operations/config-production-01.json` through `config-production-12.json`. Scope: frozen observable design and shared black-box acceptance before product implementation. No product code/runner edits/dependency changes/Git or inference. Root must lease runner plumbing if required. + +Root exact paths for Rust implementation: `cli/rust/crates/prose-runner-core/src/config.rs`, `cli/rust/crates/prose-runner-core/src/invocation.rs`, `cli/rust/crates/prose-cli/src/main.rs`, `cli/rust/crates/prose-cli/tests/cli.rs`; shared runner plumbing and schemas require separate recorded extension. Bun implementation reserved for later independent agent; no agent edits both product trees. + +Root integration lease extension: `cli/rust/crates/prose-runner-core/src/runner.rs`, `cli/conformance/runner/run.py`, `cli/conformance/runner/test_runner.py`, `cli/conformance/cases/case-manifest.schema.json`, `cli/shared/tests/test_contracts.py`, `cli/bun/test/shared-contract.test.ts` solely for new config corpus plumbing/schema registration. Existing counts and safety must match actual corpus, no relaxed assertions. + +`/root/configuration_contract` Bun implementation wave exact paths: `cli/bun/src/core/config.ts`, `cli/bun/src/core/args.ts`, `cli/bun/src/core/types.ts`, `cli/bun/src/core/output.ts`, `cli/bun/src/cli.ts`, `cli/bun/test/config.test.ts`, `cli/bun/test/args.test.ts`. Implements frozen `docs/user-configuration.md` independently from Rust, using shared production-v2 corpus/schema. May request focused new testpath; no Rust/shared/runner edits/Git/index/provider calls. Root integrates and runs full admission. + +IMP-097 Bun lease extension: `/root/configuration_contract` exact new test path `cli/bun/test/config-production.test.ts` for real runCli execution of independent shared setup cases. + +`/root/conformance_plumbing` replaces root's reserved plumbing lease on exact paths `cli/conformance/runner/run.py`, `cli/conformance/runner/test_runner.py`, `cli/conformance/cases/case-manifest.schema.json`, `cli/shared/tests/test_contracts.py`. Scope: provider-free configurationFixture setup and independently asserted unchanged/absent/expected-file effects, safe isolated roots, case/schema validation and actual counts. No product/schema-explanation edits/Git/deps/model calls. Root owns remaining shared/Bun schema registration and integration. + +IMP-097 Bun fixture reconciliation lease: `/root/configuration_contract` exact paths `cli/bun/test/discovery.test.ts`, `cli/bun/test/reporting-parity.test.ts`, `cli/bun/test/cli.test.ts`, `cli/bun/test/dx-parity.test.ts`, `cli/bun/test/adapters-installed.test.ts` solely to reconcile intended new explanation/contextual defaults/safe validation behavior. Preserve process lifecycle/transport/budget assertions, require focused failure evidence before edits, and report shared fixture changes for root. No passing by suppressing or skipping failures. + +IMP-097 root reclaims frozen explanation schema for nullable default permissionMode correction only; closed explicit permission values remain unchanged. + +IMP-097 fixture reconciliation extension: `/root/configuration_contract` owns `cli/shared/fixtures/operations/configuration-explanation.json`, `cli/shared/fixtures/config/optional-reporting.json`, `cli/shared/fixtures/dx/dry-run-default-hosted.json`, `cli/shared/fixtures/dx/dry-run-mock.json`, `cli/shared/fixtures/dx/dry-run-prime.json` for measured complete explanation/default-profile and public auth-profile reporting. Preserve concise summary defaultsOmitted and other fields. Root retains schema and Rust. + +IMP-097 independent Rust black-box test wave: `/root/sdk_final_review` owns new `cli/rust/crates/prose-cli/tests/config_production.rs` only, provider-free isolated migration/unset/exact explanation/redaction evidence against frozen design. No existing source/Git/dependency edits. + +IMP-097 Bun measured compiled-identity fixture extension: `/root/configuration_contract` owns `cli/bun/test/build-identity.test.ts` for canonical HOME/.prose saved-path assertions only. Preserve build digest/signature/reproducibility assertions. + +IMP-097 root fixture consistency extension: `cli/shared/fixtures/operations/doctor-report.json` configuration subobject only, to match the same resolver explanation oracle required by existing shared contract. + +IMP-097 Rust measured fixture reconciliation: `/root/sdk_final_review` owns `cli/rust/crates/prose-cli/tests/cli.rs` in place of root, limited to observed existing expectation changes for canonical paths, full explanation and earlier safe semantic validation. Preserve independent safety/lifecycle checks and report actual source failures. Root retains all production Rust. + +IMP-097 actual differential repair wave: `/root/conformance_plumbing` owns `cli/conformance/cases/operations/harness-use-user.json`, `harness-use-prime-bundle.json`, `harness-use-omp-bundle.json`, `config-production-03.json`, `config-production-04.json`, `config-production-05.json`, `config-production-09.json`, `config-production-10.json` in that same directory, plus `cli/conformance/runner/run.py` and `test_runner.py`. Scope canonical path controls, complete frozen expected source/diagnostic array objects and truthful isolated Git discovery boundary before filesystem baselines. Do not relax normalization/deep comparisons; preserve within-workspace ancestor discovery. + +IMP-097 root full-admission repair lease: `cli/ci/check_architecture.py`, `cli/ci/test_check_architecture.py` only to stop propagating argv taint through Rust's nonbinding discard pattern `_`. Preserve real aliases and opaque-program file-read prohibitions; regression must show actual argv-derived reads still fail. + +IMP-097 root formatting-only integration: completed independent test lanes release `cli/rust/crates/prose-cli/tests/cli.rs` and `cli/rust/crates/prose-cli/tests/config_production.rs` to root for Rust1.87 rustfmt corrections required by full admission. No test semantics change. + +IMP-097 root Clippy admission cleanup extends exact lease to `cli/rust/crates/prose-cli/tests/service_programs.rs` only for removing an already-unused serde_json::json import exposed by all-target admission. Root keeps main.rs configuration preparation refactor and config.rs documentation/style corrections; no altered runtime behavior or lint suppression. + +IMP-097 root compiled test-seam reconciliation: `cli/rust/crates/prose-cli/tests/cli.rs` three saved-bundle byte expectations only, in `harness_use_persists_an_explicit_prime_bundle_from_suffix_or_prefix_without_spawning`, `codex_claude_and_openprose_switches_clear_stale_bundle_values`, and `assert_harness_guidance_executes_directly`. Full-admission failures show the frozen alphabetic auth_profile/harness/model publication order; preserve exact comparisons and no-spawn/unchanged checks. + +IMP-097 measured full-Bun admission repair: `configuration_contract` owns `cli/bun/test/cli.test.ts` expectedConfiguration location adaptation only. Shared complete report originates in a one-directory fixture; the real worktree fixture visits cwd, cli and repository root before its Git boundary. Retain independent complete comparisons and explicit expected filesystem context; no production/shared-schema changes or received-report copying. Root owns integration. + +IMP-097 measured adapter adversary admission: `config_assessment` owns `cli/conformance/adversarial/adapter-products/test_adapter_products.py` to diagnose the 16 configuration-before-readiness failures in full admission 13. Preserve credential canaries, strict schema/exit/readiness checks and no-spawn assertions. Correct valid fixture inputs only after measuring actual stdout. No product changes, Git or heavy builds. + +IMP-097 measured release rehearsal admission: `conformance_plumbing` owns `cli/ci/rehearse_release.py` and `cli/ci/test_rehearse_release.py` narrowly to update the frozen current-corpus count from 64 to the reviewed 76 configuration cases and exact 3N/2N/5N validation totals. Preserve historical snapshot replay, mutations, secretion/redaction, custody and wrong-count rejection. Existing full14 passed packaging and failed only this stale gate. No dynamic relaxation, Git or heavy real rehearsal builds. Root integrates SDK branch-specific 100-case count after merge. diff --git a/cli/rust/crates/prose-cli/src/main.rs b/cli/rust/crates/prose-cli/src/main.rs index eef06be7..a5cce419 100644 --- a/cli/rust/crates/prose-cli/src/main.rs +++ b/cli/rust/crates/prose-cli/src/main.rs @@ -459,6 +459,96 @@ fn account_command_outcome( })) } +fn prepare_configuration( + parsed: &ParsedInvocation, + system: &SystemContext, +) -> Result { + let clock = SystemClock; + let ids = SystemIdSource; + let mutation = if let Action::Runner { command, json } = &parsed.action { + let result = match command { + RunnerCommand::ConfigMigrate => Some( + prose_runner_core::config::migrate_user_configuration(system), + ), + RunnerCommand::ConfigUnset(keys) => Some( + prose_runner_core::config::unset_user_configuration(system, keys), + ), + _ => None, + }; + match result { + Some(Ok(receipt)) => Some(receipt), + Some(Err(error)) => { + let error = prose_runner_core::config::mutation_error_context( + error, + &parsed.globals, + system, + ); + return Err(error_outcome( + error, + if *json { + OutputMode::Json + } else { + parsed.globals.output.unwrap_or_default() + }, + &clock, + &ids, + )); + } + None => None, + } + } else { + None + }; + let mut resolution_flags = parsed.globals.clone(); + if matches!( + &parsed.action, + Action::Runner { + command: RunnerCommand::HarnessUse(_), + .. + } + ) { + // Selection arguments describe the new persisted bundle, not the old active harness. + resolution_flags.model = None; + resolution_flags.auth_profile = None; + } + let mut config = match resolve_config(&resolution_flags, system) { + Ok(config) => config, + Err(error) => { + let mut error = error; + if let Action::Runner { + command: RunnerCommand::ConfigExplainTarget(argv), + .. + } = &parsed.action + { + if let Some(details) = error.details.as_mut() { + if let Some(report) = details.get_mut("configurationExplanation") { + report["target"] = serde_json::json!({"argv":argv}); + } + } + } + let mode = match parsed.action { + Action::Runner { json: true, .. } => OutputMode::Json, + _ => parsed.globals.output.unwrap_or_default(), + }; + let error = if let Some(receipt) = &mutation { + error.with_detail("mutation", receipt.clone()) + } else { + error + }; + return Err(error_outcome(error, mode, &clock, &ids)); + } + }; + config.mutation = mutation; + if let Action::Runner { + command: RunnerCommand::ConfigExplainTarget(argv), + .. + } = &parsed.action + { + config.explanation_target = Some(argv.clone()); + } + Ok(config) +} + fn prepare( args: &[String], cancellation: &CancellationToken, @@ -504,15 +594,9 @@ fn prepare( if let Some(outcome) = account_command_outcome(&parsed, args, &system, cancellation) { return outcome; } - let config = match resolve_config(&parsed.globals, &system) { + let config = match prepare_configuration(&parsed, &system) { Ok(config) => config, - Err(error) => { - let mode = match parsed.action { - Action::Runner { json: true, .. } => OutputMode::Json, - _ => parsed.globals.output.unwrap_or_default(), - }; - return error_outcome(error, mode, &clock, &ids); - } + Err(outcome) => return outcome, }; // The default hosted harness runs no language command, so a language // command word that also names a service command is that rejection. @@ -534,6 +618,18 @@ fn prepare( } } let mode = prose_runner_core::runner::action_output_mode(&parsed, &config); + if matches!( + &parsed.action, + Action::Runner { + command: RunnerCommand::ConfigExplain + | RunnerCommand::ConfigExplainTarget(_) + | RunnerCommand::ConfigMigrate + | RunnerCommand::ConfigUnset(_), + .. + } + ) { + return prose_runner_core::runner::configuration_outcome(&config, mode); + } let image = match execution_image(&parsed, &config, cancellation) { Ok(image) => image, Err(error) => return error_outcome(error, mode, &clock, &ids), diff --git a/cli/rust/crates/prose-cli/tests/cli.rs b/cli/rust/crates/prose-cli/tests/cli.rs index 937273e5..6fe9befd 100644 --- a/cli/rust/crates/prose-cli/tests/cli.rs +++ b/cli/rust/crates/prose-cli/tests/cli.rs @@ -428,6 +428,7 @@ fn assert_release_mock_rpc_is_rejected(binary: &Path, root: &Path) { } } +#[cfg(feature = "test-seams")] fn fake_harness() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../../conformance/fake-harness/fake_harness.py") @@ -1157,11 +1158,20 @@ fn expected_configuration(root: &Path) -> Value { )) .unwrap(); expected["cwd"]["value"] = Value::String(fs::canonicalize(root).unwrap().display().to_string()); - expected["userConfigPath"] = Value::String( - root.join("home/xdg/openprose/cli.toml") - .display() - .to_string(), - ); + expected["userConfigPath"] = + Value::String(root.join("home/.prose/cli.toml").display().to_string()); + let mut locations = vec![ + json!({"role":"user", "path":root.join("home/.prose/cli.toml"), "present":false,"selected":false}), + json!({"role":"legacy-user", "path":root.join("home/xdg/openprose/cli.toml"), "present":false,"selected":false}), + ]; + let canonical = fs::canonicalize(root).unwrap(); + for directory in canonical.ancestors() { + locations.push(json!({"role":"project", "path":directory.join(".prose/cli.toml"), "present":false,"selected":false})); + if directory.join(".git").exists() { + break; + } + } + expected["locations"] = json!(locations); expected } @@ -2883,7 +2893,8 @@ fn prime_and_omp_harness_login_routes_require_explicit_profiles_and_qualified_mo "{harness} accepted {invalid:?}" ); let report = json_stdout(&output); - assert_eq!(report["problems"][0]["code"], "CONFIG_INVALID"); + assert_eq!(report["code"], "CONFIG_INVALID"); + assert_eq!(report["boundary"], "configuration"); assert!( serde_json::to_string(&report) .unwrap() @@ -3678,12 +3689,12 @@ fn config_explain_reports_closed_precedence_sources() { fs::create_dir_all(home.join("xdg/openprose")).unwrap(); fs::write( home.join("xdg/openprose/cli.toml"), - "model = \"user-model\"\ntimeout = \"2m\"\n", + "harness = \"codex\"\nmodel = \"user-model\"\ntimeout = \"2m\"\n", ) .unwrap(); fs::write( project.join(".prose/cli.toml"), - "transport = \"project-transport\"\n", + "transport = \"exec-json\"\n", ) .unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_prose")) @@ -3691,9 +3702,9 @@ fn config_explain_reports_closed_precedence_sources() { "--cwd", child.to_str().unwrap(), "--harness", - "mock", + "codex", "--auth-profile", - "flag-profile", + "cached-chatgpt-login", "cli", "config", "explain", @@ -3715,7 +3726,10 @@ fn config_explain_reports_closed_precedence_sources() { ); assert_eq!(report["values"]["model"]["source"]["kind"], "user-config"); assert_eq!(report["values"]["timeout"]["source"]["kind"], "environment"); - assert_eq!(report["values"]["authProfile"]["value"], "flag-profile"); + assert_eq!( + report["values"]["authProfile"]["value"], + "cached-chatgpt-login" + ); assert_eq!( report["values"]["authProfile"]["source"], json!({"kind":"flag","location":"--auth-profile"}) @@ -3737,7 +3751,7 @@ fn harness_use_persists_the_user_default_without_starting_a_harness() { assert!(output.status.success()); assert!(output.stderr.is_empty()); let report = json_stdout(&output); - let path = temp.path().join("home/xdg/openprose/cli.toml"); + let path = temp.path().join("home/.prose/cli.toml"); assert_eq!( report, json!({ @@ -3789,7 +3803,7 @@ fn harness_use_enforces_private_config_permissions_under_a_permissive_umask() { String::from_utf8_lossy(&output.stderr) ); assert_eq!(json_stdout(&output)["changed"], true); - let parent = xdg.join("openprose"); + let parent = home.join(".prose"); let path = parent.join("cli.toml"); assert_eq!( fs::metadata(&parent).unwrap().permissions().mode() & 0o777, @@ -3913,7 +3927,7 @@ fn harness_use_persists_an_explicit_prime_bundle_from_suffix_or_prefix_without_s .unwrap(); assert!(output.status.success()); assert_eq!(json_stdout(&output)["harness"], "prime"); - let path = xdg.join("openprose/cli.toml"); + let path = home.join(".prose/cli.toml"); assert_eq!( fs::read_to_string(&path).unwrap(), "auth_profile = \"prime-harness-login\"\nharness = \"prime\"\nmodel = \"openai/gpt-5.4\"\n" @@ -3958,7 +3972,7 @@ fn prime_and_omp_selection_require_both_explicit_cli_values_and_never_inherit_th let xdg = home.join("xdg"); let path = xdg.join("openprose/cli.toml"); fs::create_dir_all(path.parent().unwrap()).unwrap(); - let original = "harness = \"claude\"\nmodel = \"stale/model\"\nauth_profile = \"claude-subscription\"\ntimeout = \"30s\"\n"; + let original = "harness = \"prime\"\nmodel = \"stale/model\"\nauth_profile = \"prime-harness-login\"\ntimeout = \"30s\"\n"; fs::write(&path, original).unwrap(); let mut args = vec!["cli", "harness", "use", harness]; args.extend(explicit); @@ -4079,7 +4093,7 @@ fn harness_selection_validates_route_and_model_before_writing() { #[test] fn codex_claude_and_openprose_switches_clear_stale_bundle_values() { let temp = TempDir::new().unwrap(); - let path = temp.path().join("home/xdg/openprose/cli.toml"); + let path = temp.path().join("home/.prose/cli.toml"); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write( &path, @@ -4091,7 +4105,7 @@ fn codex_claude_and_openprose_switches_clear_stale_bundle_values() { assert!(claude.status.success()); assert_eq!( fs::read_to_string(&path).unwrap(), - "harness = \"claude\"\ntimeout = \"30s\"\n" + "timeout = \"30s\"\nharness = \"claude\"\n" ); let codex = prose( @@ -4111,7 +4125,7 @@ fn codex_claude_and_openprose_switches_clear_stale_bundle_values() { assert!(codex.status.success()); assert_eq!( fs::read_to_string(&path).unwrap(), - "auth_profile = \"cached-chatgpt-login\"\nharness = \"codex\"\nmodel = \"gpt-5.4\"\ntimeout = \"30s\"\n" + "timeout = \"30s\"\nauth_profile = \"cached-chatgpt-login\"\nharness = \"codex\"\nmodel = \"gpt-5.4\"\n" ); let openprose = prose( @@ -4121,7 +4135,7 @@ fn codex_claude_and_openprose_switches_clear_stale_bundle_values() { assert!(openprose.status.success()); assert_eq!( fs::read_to_string(&path).unwrap(), - "harness = \"openprose\"\ntimeout = \"30s\"\n" + "timeout = \"30s\"\nharness = \"openprose\"\n" ); } @@ -4155,7 +4169,7 @@ fn human_harness_use_confirms_the_default_and_gives_the_verification_command() { let output = prose(temp.path(), &["cli", "harness", "use", "claude"]); assert!(output.status.success()); assert!(output.stderr.is_empty()); - let path = temp.path().join("home/xdg/openprose/cli.toml"); + let path = temp.path().join("home/.prose/cli.toml"); assert_eq!( String::from_utf8(output.stdout).unwrap(), format!( @@ -4177,6 +4191,26 @@ fn human_harness_use_confirms_the_default_and_gives_the_verification_command() { assert!(!stdout.contains("$PROSE")); } +#[test] +fn sdk_harness_selection_describes_inherited_api_route_without_saving_defaults() { + let temp = TempDir::new().unwrap(); + let output = prose(temp.path(), &["cli", "harness", "use", "agents-sdk"]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stderr.is_empty()); + let rendered = String::from_utf8(output.stdout).unwrap(); + assert!(rendered.contains("openai-api-key")); + assert!(rendered.contains("not saved")); + assert!(!rendered.contains("OpenProse account")); + assert_eq!( + fs::read(temp.path().join("home/.prose/cli.toml")).unwrap(), + b"harness = \"agents-sdk\"\n" + ); +} + #[test] fn mock_is_rejected_when_selected_ambiently() { let temp = TempDir::new().unwrap(); @@ -4253,7 +4287,8 @@ fn dry_run_is_machine_readable_and_starts_no_run() { assert_eq!(report["configuration"][0]["source"], "default"); assert_eq!(report["configuration"][0]["location"], "process cwd"); assert_eq!(report["configuration"][0]["redacted"], false); - assert_eq!(report["configuration"][8]["redacted"], true); + assert_eq!(report["configuration"][8]["redacted"], false); + assert_eq!(report["configuration"][8]["value"], Value::Null); assert!( report["configuration"] .as_array() @@ -4303,7 +4338,8 @@ fn default_dry_run_identifies_the_hosted_adapter_without_fallback() { "authProfile", ] ); - assert_eq!(report["configuration"][8]["redacted"], true); + assert_eq!(report["configuration"][8]["redacted"], false); + assert_eq!(report["configuration"][8]["value"], Value::Null); } // docs/kernel-startup.md, "Fixed inputs and tests", distinguishes ordinary @@ -4975,7 +5011,7 @@ fn assert_guarded_model_choice( .unwrap(); assert!(selected.status.success()); assert_eq!( - fs::read_to_string(valid_config.join("openprose/cli.toml")).unwrap(), + fs::read_to_string(valid_root.join("home/.prose/cli.toml")).unwrap(), format!( "auth_profile = \"{auth_profile}\"\nharness = \"{harness}\"\nmodel = \"openai/gpt-5.4\"\n" ) @@ -5518,7 +5554,7 @@ fn malformed_local_command_honors_json_stdout_discipline() { fn human_doctor_confines_a_hostile_diagnostic_to_one_physical_detail_line() { let temp = TempDir::new().unwrap(); let hostile_profile = "unknown\nAction: forged\t\u{001B}[31m\u{2028}next\u{2029}paragraph"; - let expected_reason = format!("Unknown auth_profile for omp/rpc: {hostile_profile}."); + let expected_reason = "Authentication profile is incompatible with the selected harness."; let human = prose( temp.path(), &[ @@ -5535,10 +5571,11 @@ fn human_doctor_confines_a_hostile_diagnostic_to_one_physical_detail_line() { ], ); assert_eq!(human.status.code(), Some(2)); - let rendered = String::from_utf8(human.stdout).unwrap(); + assert!(human.stdout.is_empty()); + let rendered = String::from_utf8(human.stderr).unwrap(); assert!(rendered.contains(&format!( "Detail: {}\n", - expected_human_safe_scalar(&expected_reason) + expected_human_safe_scalar(expected_reason) ))); assert_eq!( rendered @@ -5569,10 +5606,8 @@ fn human_doctor_confines_a_hostile_diagnostic_to_one_physical_detail_line() { ], ); assert_eq!(machine.status.code(), Some(2)); - assert_eq!( - json_stdout(&machine)["problems"][0]["details"]["reason"], - expected_reason - ); + assert_eq!(json_stdout(&machine)["details"]["reason"], expected_reason); + assert!(!String::from_utf8_lossy(&machine.stdout).contains(hostile_profile)); } #[cfg(all(feature = "test-seams", unix))] diff --git a/cli/rust/crates/prose-cli/tests/config_production.rs b/cli/rust/crates/prose-cli/tests/config_production.rs new file mode 100644 index 00000000..c1cfce56 --- /dev/null +++ b/cli/rust/crates/prose-cli/tests/config_production.rs @@ -0,0 +1,530 @@ +//! Production configuration commands through the compiled CLI. No provider, +//! credential store, executable discovery, or source-language execution is needed. +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use tempfile::TempDir; + +struct Fixture { + root: TempDir, +} + +impl Fixture { + fn new() -> Self { + let root = TempDir::new().unwrap(); + fs::create_dir(root.path().join("home")).unwrap(); + Self { root } + } + + fn path(&self, relative: &str) -> PathBuf { + self.root.path().join(relative) + } + + fn write(&self, relative: &str, bytes: &[u8]) { + let path = self.path(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, bytes).unwrap(); + } + + fn invoke(&self, args: &[&str], config_root: Option<&Path>) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_prose")); + command + .args(args) + .current_dir(self.root.path()) + .env_clear() + .env("HOME", self.path("home")) + .env("USERPROFILE", self.path("home")) + .env("XDG_CONFIG_HOME", self.path("legacy")) + .env("PATH", self.path("absent-bin")); + if let Some(root) = config_root { + command.env("PROSE_CONFIG_DIR", root); + } + command.output().unwrap() + } +} + +fn report(output: &Output, exit: i32) -> Value { + assert_eq!( + output.status.code(), + Some(exit), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + output.stderr.is_empty(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).unwrap() +} + +#[test] +fn migration_preserves_exact_bytes_and_never_overwrites() { + let fixture = Fixture::new(); + let original = b"# retained comment\r\nharness = \"codex\"\r\nmodel = \"explicit-model\"\r\nverbose = true\r\n"; + fixture.write("legacy/openprose/cli.toml", original); + let active = report( + &fixture.invoke(&["cli", "config", "explain", "--json"], None), + 0, + ); + assert_eq!(active["diagnostics"][0]["code"], "LEGACY_CONFIG_ACTIVE"); + assert!(!fixture.path("home/.prose").exists()); + let migrated = report( + &fixture.invoke(&["cli", "config", "migrate", "--json"], None), + 0, + ); + assert_eq!(migrated["mutation"]["operation"], "migrate"); + assert_eq!(migrated["mutation"]["changed"], true); + assert_eq!( + fs::read(fixture.path("home/.prose/cli.toml")).unwrap(), + original + ); + assert_eq!( + fs::read(fixture.path("legacy/openprose/cli.toml")).unwrap(), + original + ); + let rejected = report( + &fixture.invoke(&["cli", "config", "migrate", "--json"], None), + 2, + ); + assert_eq!(rejected["code"], "CONFIG_INVALID"); + assert_eq!( + fs::read(fixture.path("home/.prose/cli.toml")).unwrap(), + original + ); + assert_eq!( + fs::read(fixture.path("legacy/openprose/cli.toml")).unwrap(), + original + ); +} + +#[test] +fn invalid_migration_and_unknown_unset_leave_every_file_unchanged() { + for original in [ + "harness = \"agents-sdk\"\nauth_profile = \"cached-chatgpt-login\"\n", + "harness = \"codex\"\nunknown_preference = \"reject-me\"\n", + ] { + let fixture = Fixture::new(); + fixture.write("legacy/openprose/cli.toml", original.as_bytes()); + let failed = report( + &fixture.invoke(&["cli", "config", "migrate", "--json"], None), + 2, + ); + assert_eq!(failed["code"], "CONFIG_INVALID"); + assert!(!fixture.path("home/.prose/cli.toml").exists()); + assert_eq!( + fs::read(fixture.path("legacy/openprose/cli.toml")).unwrap(), + original.as_bytes() + ); + } + let fixture = Fixture::new(); + let original = b"# preserved\ntimeout = \"2m\"\n"; + fixture.write("home/.prose/cli.toml", original); + let failed = report( + &fixture.invoke( + &["cli", "config", "unset", "unknown_preference", "--json"], + None, + ), + 2, + ); + assert_eq!(failed["code"], "INVOCATION_INVALID"); + assert_eq!( + fs::read(fixture.path("home/.prose/cli.toml")).unwrap(), + original + ); +} + +#[test] +fn unset_preserves_crlf_comments_and_repairs_bundle_without_materializing_defaults() { + let fixture = Fixture::new(); + let original = b"# chosen harness\r\nharness = \"codex\"\r\n# keep comment\r\nmodel = \"old-model\"\r\nauth_profile = \"openrouter\"\r\nverbose = true\r\n"; + fixture.write("home/.prose/cli.toml", original); + let invalid = report( + &fixture.invoke(&["cli", "config", "explain", "--json"], None), + 2, + ); + assert_eq!(invalid["code"], "CONFIG_INVALID"); + let output = report( + &fixture.invoke( + &["cli", "config", "unset", "model", "auth_profile", "--json"], + None, + ), + 0, + ); + assert_eq!( + output["mutation"]["keys"], + serde_json::json!(["model", "auth_profile"]) + ); + assert_eq!( + fs::read(fixture.path("home/.prose/cli.toml")).unwrap(), + b"# chosen harness\r\nharness = \"codex\"\r\n# keep comment\r\nverbose = true\r\n" + ); + assert!(output["values"]["model"]["value"].is_null()); + assert_eq!( + output["values"]["authProfile"]["value"], + "cached-chatgpt-login" + ); + assert_eq!( + output["values"]["authProfile"]["source"]["location"], + "built-in:codex" + ); + let repeated = report( + &fixture.invoke( + &["cli", "config", "unset", "model", "auth_profile", "--json"], + None, + ), + 0, + ); + assert_eq!(repeated["mutation"]["changed"], false); +} + +#[test] +fn absent_unset_creates_neither_default_home_nor_override_directory() { + for override_root in [false, true] { + let fixture = Fixture::new(); + let custom = fixture.path("custom root"); + let output = report( + &fixture.invoke( + &["cli", "config", "unset", "timeout", "--json"], + override_root.then_some(custom.as_path()), + ), + 0, + ); + assert_eq!(output["mutation"]["changed"], false); + assert!(!fixture.path("home/.prose").exists()); + assert!(!custom.exists()); + } +} + +#[test] +fn legacy_unset_creates_only_filtered_canonical_copy() { + let fixture = Fixture::new(); + let original = b"# retained\r\ntimeout = \"2m\"\r\nverbose = true\r\n"; + fixture.write("legacy/openprose/cli.toml", original); + let output = report( + &fixture.invoke(&["cli", "config", "unset", "timeout", "--json"], None), + 0, + ); + assert_eq!( + output["mutation"]["sourcePath"], + fixture.path("legacy/openprose/cli.toml").to_str().unwrap() + ); + assert_eq!( + fs::read(fixture.path("legacy/openprose/cli.toml")).unwrap(), + original + ); + assert_eq!( + fs::read(fixture.path("home/.prose/cli.toml")).unwrap(), + b"# retained\r\nverbose = true\r\n" + ); +} + +#[test] +fn rejected_profile_never_appears_anywhere_in_error_output() { + let fixture = Fixture::new(); + let sentinel = "private-profile-sentinel-84919"; + let original = format!("harness = \"agents-sdk\"\nauth_profile = \"{sentinel}\"\n"); + fixture.write("home/.prose/cli.toml", original.as_bytes()); + let output = fixture.invoke(&["cli", "config", "explain", "--json"], None); + let failed = report(&output, 2); + assert_eq!(failed["code"], "CONFIG_INVALID"); + assert!(failed["details"]["configurationExplanation"]["diagnostics"].is_array()); + assert!(!String::from_utf8_lossy(&output.stdout).contains(sentinel)); + assert!(!String::from_utf8_lossy(&output.stderr).contains(sentinel)); + assert_eq!( + fs::read(fixture.path("home/.prose/cli.toml")).unwrap(), + original.as_bytes() + ); +} + +#[test] +fn exact_target_uses_its_cwd_and_keeps_opaque_tokens_and_contextual_defaults() { + let fixture = Fixture::new(); + fixture.write(".prose/cli.toml", b"timeout = \"5m\"\n"); + fixture.write("work space/.prose/cli.toml", b"timeout = \"6m\"\n"); + let output = report( + &fixture.invoke( + &[ + "cli", + "config", + "explain", + "--json", + "--", + "--cwd", + "work space", + "--harness", + "agents-sdk", + "--", + "run", + "--model", + "literal-model", + ], + None, + ), + 0, + ); + assert_eq!( + output["target"]["argv"], + serde_json::json!(["prose", "run", "--model", "literal-model"]) + ); + assert_eq!( + output["cwd"]["value"], + fs::canonicalize(fixture.path("work space")) + .unwrap() + .to_str() + .unwrap() + ); + assert_eq!(output["values"]["timeout"]["value"], "6m"); + assert_eq!(output["values"]["model"]["value"], "gpt-6.1-sol"); + assert_eq!(output["values"]["authProfile"]["value"], "openai-api-key"); + assert_eq!(output["runtime"]["transport"], "jsonl"); + assert_eq!(output["runtime"]["billingOwner"], "user-provider"); + assert_eq!( + output["values"]["model"]["source"]["location"], + "built-in:agents-sdk" + ); + assert_eq!(output["values"].as_object().unwrap().len(), 19); + assert!(!fixture.path("home/.prose").exists()); + assert_eq!( + fs::read(fixture.path("work space/.prose/cli.toml")).unwrap(), + b"timeout = \"6m\"\n" + ); +} + +#[test] +fn overridden_invalid_profile_is_not_disclosed_as_a_candidate() { + let fixture = Fixture::new(); + let sentinel = "rejected-profile-sentinel-765"; + fixture.write( + "home/.prose/cli.toml", + format!("harness = \"agents-sdk\"\nauth_profile = \"{sentinel}\"\n").as_bytes(), + ); + let raw = fixture.invoke( + &[ + "cli", + "config", + "explain", + "--json", + "--", + "--auth-profile", + "openai-api-key", + "run", + "subject", + ], + None, + ); + let output = report(&raw, 0); + assert_eq!(output["values"]["authProfile"]["value"], "openai-api-key"); + assert!(!String::from_utf8_lossy(&raw.stdout).contains(sentinel)); + assert!(!output["diagnostics"].as_array().unwrap().is_empty()); +} + +#[test] +fn sdk_unsupported_permissions_fail_during_pure_explanation() { + let fixture = Fixture::new(); + let output = report( + &fixture.invoke( + &[ + "cli", + "config", + "explain", + "--json", + "--", + "--harness", + "agents-sdk", + "--permission-mode", + "read-only", + "run", + "subject", + ], + None, + ), + 2, + ); + assert_eq!(output["code"], "CONFIG_INVALID"); + assert_eq!(output["boundary"], "configuration"); + assert!(output["details"]["configurationExplanation"]["runtime"]["permissionMode"].is_null()); + assert!(!fixture.path("home/.prose").exists()); +} + +#[test] +fn ignored_legacy_classification_and_conflicts_are_visible_without_values() { + let fixture = Fixture::new(); + fixture.write("home/.prose/cli.toml", b"timeout = \"2m\"\n"); + fixture.write("legacy/openprose/cli.toml", b"timeout = \"9m\"\n"); + let output = report( + &fixture.invoke(&["cli", "config", "explain", "--json"], None), + 0, + ); + let diagnostics = serde_json::to_string(&output["diagnostics"]).unwrap(); + assert!(diagnostics.contains("LEGACY_CONFIG_IGNORED")); + assert!(diagnostics.contains("timeout")); + fixture.write( + "legacy/openprose/cli.toml", + b"api_key = \"rejected-legacy-secret\"\n", + ); + let raw = fixture.invoke(&["cli", "config", "explain", "--json"], None); + let output = report(&raw, 0); + let diagnostics = serde_json::to_string(&output["diagnostics"]) + .unwrap() + .to_lowercase(); + assert!(diagnostics.contains("invalid") || diagnostics.contains("unknown")); + assert!(!String::from_utf8_lossy(&raw.stdout).contains("rejected-legacy-secret")); +} + +#[test] +fn selected_file_candidates_identify_the_assignment_line() { + let fixture = Fixture::new(); + fixture.write( + "home/.prose/cli.toml", + b"# original preference\ntimeout = \"2m\"\n", + ); + let output = report( + &fixture.invoke(&["cli", "config", "explain", "--json"], None), + 0, + ); + let chosen = output["candidates"]["timeout"] + .as_array() + .unwrap() + .iter() + .find(|candidate| candidate["selected"] == true) + .unwrap(); + assert!( + chosen["source"]["location"] + .as_str() + .unwrap() + .ends_with("cli.toml:2") + ); + assert_eq!(chosen["value"], "2m"); +} + +#[test] +fn project_locations_show_only_visited_candidates_and_stop_at_boundary() { + let fixture = Fixture::new(); + fs::create_dir(fixture.path(".git")).unwrap(); + fs::create_dir_all(fixture.path("child/deep")).unwrap(); + fixture.write(".prose/cli.toml", b"timeout = \"3m\"\n"); + let args = [ + "cli", + "config", + "explain", + "--json", + "--", + "--cwd", + "child/deep", + "run", + "subject", + ]; + for present in [true, false] { + if !present { + fs::remove_file(fixture.path(".prose/cli.toml")).unwrap(); + } + let output = report(&fixture.invoke(&args, None), 0); + let project_locations: Vec<&Value> = output["locations"] + .as_array() + .unwrap() + .iter() + .filter(|location| location["role"] == "project") + .collect(); + assert_eq!(project_locations.len(), 3); + let canonical = fs::canonicalize(fixture.root.path()).unwrap(); + for (index, relative) in [ + "child/deep/.prose/cli.toml", + "child/.prose/cli.toml", + ".prose/cli.toml", + ] + .iter() + .enumerate() + { + assert_eq!( + project_locations[index]["path"], + canonical.join(relative).to_str().unwrap() + ); + assert_eq!(project_locations[index]["present"], present && index == 2); + assert_eq!(project_locations[index]["selected"], present && index == 2); + } + if present { + assert_eq!(output["values"]["timeout"]["value"], "3m"); + } else { + assert!(output["projectConfigPath"].is_null()); + } + } +} + +#[test] +fn invalid_ignored_legacy_root_has_safe_diagnostic_without_blocking_canonical() { + let fixture = Fixture::new(); + fixture.write("home/.prose/cli.toml", b"timeout = \"2m\"\n"); + let raw = Command::new(env!("CARGO_BIN_EXE_prose")) + .args(["cli", "config", "explain", "--json"]) + .current_dir(fixture.root.path()) + .env_clear() + .env("HOME", fixture.path("home")) + .env("XDG_CONFIG_HOME", "relative-legacy-root-sentinel") + .env("PATH", fixture.path("absent-bin")) + .output() + .unwrap(); + let output = report(&raw, 0); + assert_eq!(output["values"]["timeout"]["value"], "2m"); + assert!( + output["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| diagnostic["source"] == "XDG_CONFIG_HOME") + ); + assert!(!String::from_utf8_lossy(&raw.stdout).contains("relative-legacy-root-sentinel")); +} + +#[cfg(unix)] +#[test] +fn readonly_physical_alias_loads_once_but_mutations_refuse_symlink() { + let fixture = Fixture::new(); + let original = b"# one physical preference\ntimeout = \"2m\"\n"; + fixture.write("legacy/openprose/cli.toml", original); + fs::create_dir_all(fixture.path("home/.prose")).unwrap(); + std::os::unix::fs::symlink( + fixture.path("legacy/openprose/cli.toml"), + fixture.path("home/.prose/cli.toml"), + ) + .unwrap(); + let output = report( + &fixture.invoke(&["cli", "config", "explain", "--json"], None), + 0, + ); + assert_eq!(output["values"]["timeout"]["value"], "2m"); + assert_eq!( + output["candidates"]["timeout"] + .as_array() + .unwrap() + .iter() + .filter(|candidate| candidate["value"] == "2m") + .count(), + 1 + ); + assert!( + !output["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| diagnostic["code"] == "LEGACY_CONFIG_IGNORED") + ); + for args in [ + vec!["cli", "config", "migrate", "--json"], + vec!["cli", "config", "unset", "timeout", "--json"], + ] { + let failed = report(&fixture.invoke(&args, None), 2); + assert_eq!(failed["code"], "CONFIG_INVALID"); + assert!( + fs::symlink_metadata(fixture.path("home/.prose/cli.toml")) + .unwrap() + .file_type() + .is_symlink() + ); + assert_eq!( + fs::read(fixture.path("legacy/openprose/cli.toml")).unwrap(), + original + ); + } +} diff --git a/cli/rust/crates/prose-cli/tests/service_programs.rs b/cli/rust/crates/prose-cli/tests/service_programs.rs index 855aa13a..0f7a59a5 100644 --- a/cli/rust/crates/prose-cli/tests/service_programs.rs +++ b/cli/rust/crates/prose-cli/tests/service_programs.rs @@ -2,7 +2,9 @@ //! express compactly. Behavior is pinned by //! `cli/conformance/cases/service/programs/`; the Bun twin is //! `cli/bun/test/service-programs.test.ts`. -use serde_json::{Value, json}; +use serde_json::Value; +#[cfg(feature = "test-seams")] +use serde_json::json; use std::process::Command; use tempfile::TempDir; diff --git a/cli/rust/crates/prose-runner-core/src/config.rs b/cli/rust/crates/prose-runner-core/src/config.rs index 8ab543de..0307a12e 100644 --- a/cli/rust/crates/prose-runner-core/src/config.rs +++ b/cli/rust/crates/prose-runner-core/src/config.rs @@ -1,8 +1,10 @@ use crate::error::RunnerError; use crate::invocation::{GlobalFlags, OutputMode}; use serde::Serialize; +use serde_json::{Value, json}; use std::collections::BTreeMap; use std::env; +use std::fmt::Write as _; use std::fs; use std::fs::OpenOptions; use std::io::Write as _; @@ -91,6 +93,37 @@ impl SystemContext { /// Returns `CONFIG_INVALID` rather than allowing an absent, empty, or /// relative ambient root to resolve inside the candidate workspace. pub fn user_config_path(&self) -> Result { + if let Some(root) = self.environment.get("PROSE_CONFIG_DIR") { + return checked_config_root(Path::new(root), "PROSE_CONFIG_DIR") + .map(|root| root.join("cli.toml")); + } + let (root, name) = if self.platform == Platform::Windows { + ( + self.environment.get("USERPROFILE").map(PathBuf::from), + "USERPROFILE", + ) + } else { + (self.home_dir.clone(), "HOME") + }; + let root = root.ok_or_else(|| { + RunnerError::config(format!( + "{name} must be a non-empty absolute path to locate OpenProse user configuration." + )) + }).map_err(|error| error.with_detail("source", name))?; + checked_config_root(&root, name).map(|root| root.join(".prose").join("cli.toml")) + } + + /// Historical discovery is retained only as a visible migration candidate. + /// + /// # Errors + /// Returns `CONFIG_INVALID` for an explicit canonical root or an invalid + /// or absent historical configuration root. + pub fn legacy_user_config_path(&self) -> Result { + if self.environment.contains_key("PROSE_CONFIG_DIR") { + return Err(RunnerError::config( + "Explicit configuration root has no legacy migration source.", + )); + } if let Some(root) = &self.xdg_config_home { return checked_config_root(root, "XDG_CONFIG_HOME") .map(|root| root.join("openprose").join("cli.toml")); @@ -121,7 +154,8 @@ fn checked_config_root<'a>(root: &'a Path, name: &str) -> Result<&'a Path, Runne if root.as_os_str().is_empty() || !root.is_absolute() { return Err(RunnerError::config(format!( "{name} must be a non-empty absolute path to locate OpenProse user configuration." - ))); + )) + .with_detail("source", name)); } Ok(root) } @@ -185,6 +219,18 @@ impl Sourced { #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct EffectiveConfig { + #[serde(skip)] + pub locations: Vec, + #[serde(skip)] + pub candidates: BTreeMap>, + #[serde(skip)] + pub diagnostics: Vec, + #[serde(skip)] + pub legacy_user_config: Option, + #[serde(skip)] + pub explanation_target: Option>, + #[serde(skip)] + pub mutation: Option, pub cwd: PathBuf, pub cwd_source: ConfigSource, pub project_config: Option, @@ -248,7 +294,17 @@ pub fn write_user_harness( .ok_or_else(|| RunnerError::config("user configuration has no parent directory"))?; prepare_private_config_parent(parent)?; refuse_symlinked_config_destination(&path)?; - let existing_bytes = match fs::read(&path) { + let source_path = if path.exists() { + &path + } else { + config + .legacy_user_config + .as_ref() + .filter(|legacy| legacy.is_file()) + .unwrap_or(&path) + }; + refuse_symlinked_config_destination(source_path)?; + let existing_bytes = match fs::read(source_path) { Ok(bytes) => bytes, Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), Err(_) => { @@ -258,16 +314,16 @@ pub fn write_user_harness( )); } }; - let existing = decode_configuration(&existing_bytes, &path)?.to_owned(); + let existing = decode_configuration(&existing_bytes, source_path)?.to_owned(); let mut table = if existing.trim().is_empty() { toml::map::Map::new() } else { - let loaded = parse_file(&existing, &path)?; - let mut validated = config.clone(); + let loaded = parse_file(&existing, source_path)?; + let mut validated = EffectiveConfig::defaults(config.cwd.clone(), None, Some(path.clone())); apply_file( &mut validated, loaded, - &ConfigSource::file(ConfigSourceKind::UserFile, &path), + &ConfigSource::file(ConfigSourceKind::UserFile, source_path), )?; toml::from_str::(&existing).map_err(|_| { config_line_error( @@ -280,7 +336,7 @@ pub fn write_user_harness( let already_selected = table.get("harness").and_then(toml::Value::as_str) == Some(harness) && table.get("model").and_then(toml::Value::as_str) == model && table.get("auth_profile").and_then(toml::Value::as_str) == auth_profile; - if already_selected { + if already_selected && source_path == &path { return Ok(UserHarnessSelection { path, changed: false, @@ -309,22 +365,42 @@ pub fn write_user_harness( table.remove("auth_profile"); } } - let bytes = toml::to_string(&table) - .map_err(|_| { - setting_error( - "OpenProse user configuration could not be written atomically.", - &path.display().to_string(), - ) - })? - .into_bytes(); - atomic_user_config_write(&path, &bytes)?; + // Keep every unrelated line, including comments and original line endings. + let mut retained = existing + .split_inclusive('\n') + .filter(|line| { + !line + .trim_start() + .split_once('=') + .is_some_and(|(key, _)| matches!(key.trim(), "harness" | "model" | "auth_profile")) + }) + .collect::(); + if !retained.is_empty() && !retained.ends_with('\n') { + retained.push('\n'); + } + for key in ["auth_profile", "harness", "model"] { + if let Some(value) = table.get(key) { + writeln!(retained, "{key} = {value}").expect("writing to String cannot fail"); + } + } + let loaded = parse_file(&retained, &path)?; + let mut validated = EffectiveConfig::defaults(config.cwd.clone(), None, Some(path.clone())); + validated.candidates.clear(); + apply_file( + &mut validated, + loaded, + &ConfigSource::file(ConfigSourceKind::UserFile, &path), + )?; + contextual_defaults(&mut validated)?; + native_checks(&mut validated)?; + atomic_user_config_install(&path, retained.as_bytes(), source_path == &path)?; Ok(UserHarnessSelection { path, changed: true, }) } -fn atomic_user_config_write(path: &Path, bytes: &[u8]) -> Result<(), RunnerError> { +fn atomic_user_config_install(path: &Path, bytes: &[u8], replace: bool) -> Result<(), RunnerError> { let parent = path .parent() .ok_or_else(|| RunnerError::config("user configuration has no parent directory"))?; @@ -346,7 +422,14 @@ fn atomic_user_config_write(path: &Path, bytes: &[u8]) -> Result<(), RunnerError // 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)?; + if replace { + fs::rename(&temporary, path)?; + } else { + // An exclusive link publishes the fully flushed bytes atomically and + // fails if a concurrent writer created the destination. + fs::hard_link(&temporary, path)?; + fs::remove_file(&temporary)?; + } Ok(()) })(); if write_result.is_err() { @@ -359,6 +442,122 @@ fn atomic_user_config_write(path: &Path, bytes: &[u8]) -> Result<(), RunnerError Ok(()) } +fn validate_user_configuration( + loaded: LoadedFileConfig, + path: &Path, + system: &SystemContext, +) -> Result<(), RunnerError> { + let mut config = + EffectiveConfig::defaults(system.current_dir.clone(), None, Some(path.to_owned())); + record_candidates(&mut config); + apply_file( + &mut config, + loaded, + &ConfigSource::file(ConfigSourceKind::UserFile, path), + )?; + record_candidates(&mut config); + contextual_defaults(&mut config)?; + native_checks(&mut config) +} + +/// Explicitly copies validated legacy settings without overwriting or deleting either file. +/// +/// # Errors +/// Returns `CONFIG_INVALID` for an existing destination, missing legacy file, +/// unsafe path, invalid settings or a filesystem failure. +pub fn migrate_user_configuration(system: &SystemContext) -> Result { + let path = system.user_config_path()?; + if fs::symlink_metadata(&path).is_ok() { + return Err(setting_error( + "Canonical user configuration already exists; migration never overwrites it.", + &path.display().to_string(), + )); + } + let source = system.legacy_user_config_path()?; + refuse_symlinked_config_destination(&source)?; + let bytes = fs::read(&source).map_err(|_| { + setting_error( + "Legacy user configuration is unavailable for migration.", + &source.display().to_string(), + ) + })?; + validate_user_configuration(load_file(&source)?, &source, system)?; + prepare_private_config_parent( + path.parent() + .ok_or_else(|| RunnerError::config("User configuration has no parent directory."))?, + )?; + atomic_user_config_install(&path, &bytes, false)?; + Ok(json!({"operation":"migrate","changed":true,"path":path,"sourcePath":source,"keys":[]})) +} + +/// Removes only named explicit settings and preserves unrelated bytes/comments. +/// +/// # Errors +/// Returns `INVOCATION_INVALID` for an unknown key; `CONFIG_INVALID` for +/// malformed or unsafe settings and filesystem failures. An absent file is not created. +pub fn unset_user_configuration( + system: &SystemContext, + keys: &[String], +) -> Result { + for key in keys { + if !FILE_CONFIG_KEYS.contains(&key.as_str()) || key == "service_environment" { + return Err(RunnerError::invocation( + "Config unset requires known configuration file keys.", + )); + } + } + let path = system.user_config_path()?; + let source = if path + .try_exists() + .map_err(|_| RunnerError::config("User configuration cannot be inspected."))? + { + path.clone() + } else { + system + .legacy_user_config_path() + .ok() + .filter(|legacy| legacy.is_file()) + .unwrap_or_else(|| path.clone()) + }; + refuse_symlinked_config_destination(&source)?; + let bytes = match fs::read(&source) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok( + json!({"operation":"unset","changed":false,"path":path,"sourcePath":Value::Null,"keys":keys}), + ); + } + Err(_) => { + return Err(setting_error( + "User configuration cannot be read safely.", + &source.display().to_string(), + )); + } + }; + let text = decode_configuration(&bytes, &source)?; + let _ = parse_file(text, &source)?; + let retained = text + .split_inclusive('\n') + .filter(|line| { + let assignment = line.trim_start().split_once('='); + !assignment.is_some_and(|(key, _)| keys.iter().any(|wanted| wanted == key.trim())) + }) + .collect::(); + validate_user_configuration(parse_file(&retained, &source)?, &source, system)?; + let changed = retained.as_bytes() != bytes; + if changed { + prepare_private_config_parent( + path.parent().ok_or_else(|| { + RunnerError::config("User configuration has no parent directory.") + })?, + )?; + atomic_user_config_install(&path, retained.as_bytes(), source == path)?; + } + Ok( + json!({"operation":"unset","changed":changed,"path":path,"sourcePath":if source == path {None} else {Some(source)},"keys":keys}), + ) +} + /// Creates and secures the user configuration's parent, with the fixed /// reasons both ports use (no operating-system error text). fn prepare_private_config_parent(parent: &Path) -> Result<(), RunnerError> { @@ -463,6 +662,12 @@ impl EffectiveConfig { user_config: Option, ) -> Self { Self { + locations: Vec::new(), + candidates: BTreeMap::new(), + diagnostics: Vec::new(), + legacy_user_config: None, + explanation_target: None, + mutation: None, cwd, cwd_source: ConfigSource::new( ConfigSourceKind::Default, @@ -833,6 +1038,29 @@ fn validate_flat_toml(source: &str, path: &Path) -> Result Result { + resolve_config_inner(flags, system).map_err(|error| { + if error + .details + .as_deref() + .is_some_and(|details| details.contains_key("configurationExplanation")) + { + error + } else { + let mut partial = EffectiveConfig::defaults( + system.current_dir.clone(), + None, + system.user_config_path().ok(), + ); + record_candidates(&mut partial); + explanation_error(error, &partial) + } + }) +} + +fn resolve_config_inner( + flags: &GlobalFlags, + system: &SystemContext, ) -> Result { let requested_cwd = flags.cwd.as_ref().map_or_else( || system.current_dir.clone(), @@ -867,61 +1095,406 @@ pub fn resolve_config( } let cwd = fs::canonicalize(&requested_cwd).map_err(|_| unreadable())?; - let project_config = discover_project_config(&cwd)?; + let (project_config, project_locations) = discover_project_config(&cwd)?; let user_config_candidate = system.user_config_path()?; - let user_config = user_config_candidate - .is_file() - .then(|| fs::canonicalize(&user_config_candidate).unwrap_or(user_config_candidate.clone())); - - let mut config = - EffectiveConfig::defaults(cwd, project_config.clone(), Some(user_config_candidate)); + let legacy_result = system.legacy_user_config_path(); + let legacy = legacy_result + .as_ref() + .ok() + .cloned() + .filter(|path| path != &user_config_candidate); + let canonical_exists = user_config_candidate.try_exists().map_err(|_| { + setting_error( + "OpenProse user configuration cannot be read safely.", + &user_config_candidate.display().to_string(), + ) + })?; + let canonical_present = canonical_exists && !user_config_candidate.is_dir(); + let active_user = if canonical_present { + Some(user_config_candidate.clone()) + } else { + legacy + .clone() + .filter(|path| path.exists() && !path.is_dir()) + }; + let user_config = active_user + .as_ref() + .filter(|path| path.is_file()) + .map(|path| fs::canonicalize(path).unwrap_or_else(|_| path.clone())); + + let mut config = EffectiveConfig::defaults( + cwd, + project_config.clone(), + Some(user_config_candidate.clone()), + ); + config.legacy_user_config.clone_from(&legacy); + if legacy_result.is_err() + && !system.environment.contains_key("PROSE_CONFIG_DIR") + && system.xdg_config_home.is_some() + { + config.diagnostics.push(json!({"code":"LEGACY_CONFIG_ROOT_INVALID", "severity":"warning", "source":"XDG_CONFIG_HOME", "reason":"Legacy configuration root is not a non-empty absolute path and is ignored."})); + } + config + .locations + .push(json!({"role":"user", "path":user_config_candidate, + "present":canonical_present,"selected":canonical_present})); + if let Some(path) = &legacy { + let present = path.exists() && !path.is_dir(); + config + .locations + .push(json!({"role":"legacy-user", "path":path, + "present":present,"selected":present && !canonical_present})); + let alias = canonical_present + && fs::canonicalize(path).ok() == fs::canonicalize(&user_config_candidate).ok(); + if present && !alias { + let mut reason = if canonical_present { + "Canonical user configuration is authoritative; legacy values are ignored." + .to_owned() + } else { + "Legacy user configuration is active; run prose cli config migrate to copy explicit settings.".to_owned() + }; + if canonical_present { + match load_file(path) + .and_then(|old| load_file(&user_config_candidate).map(|now| (old, now))) + { + Ok((old, now)) => { + let differing: Vec<&str> = SETTINGS + .iter() + .filter_map(|(_, key, _, _)| { + old.values + .get(*key) + .filter(|value| now.values.get(*key) != Some(value)) + .map(|_| *key) + }) + .collect(); + if !differing.is_empty() { + write!( + reason, + " Differing explicit keys: {}.", + differing.join(", ") + ) + .expect("writing to String cannot fail"); + } + } + Err(_) => reason.push_str(" Ignored legacy configuration is invalid."), + } + } + config.diagnostics.push(json!({"code":if canonical_present {"LEGACY_CONFIG_IGNORED"} else {"LEGACY_CONFIG_ACTIVE"}, "severity":"warning", "source":path, "reason":reason})); + } + } + config.locations.extend(project_locations); if flags.cwd.is_some() { config.cwd_source = ConfigSource::flag("--cwd"); } + record_candidates(&mut config); // Same physical file is loaded once. If it appears in both roles, the // nearest-project role is authoritative. if user_config.as_ref() != project_config.as_ref() { if let Some(path) = &user_config { - let file = load_file(path)?; + let file = load_file(path).map_err(|error| explanation_error(error, &config))?; apply_file( &mut config, file, &ConfigSource::file(ConfigSourceKind::UserFile, path), - )?; + ) + .map_err(|error| explanation_error(error, &config))?; + record_candidates(&mut config); } } if let Some(path) = &project_config { - let file = load_file(path)?; + let file = load_file(path).map_err(|error| explanation_error(error, &config))?; apply_file( &mut config, file, &ConfigSource::file(ConfigSourceKind::ProjectFile, path), - )?; - } - apply_environment(&mut config, &system.environment)?; - apply_flags(&mut config, flags)?; - native_checks(&mut config)?; + ) + .map_err(|error| explanation_error(error, &config))?; + record_candidates(&mut config); + } + apply_environment(&mut config, &system.environment) + .map_err(|error| explanation_error(error, &config))?; + record_candidates(&mut config); + apply_flags(&mut config, flags).map_err(|error| explanation_error(error, &config))?; + record_candidates(&mut config); + contextual_defaults(&mut config).map_err(|error| explanation_error(error, &config))?; + native_checks(&mut config).map_err(|error| explanation_error(error, &config))?; + sanitize_overridden_profiles(&mut config); + finalize_candidates(&mut config); Ok(config) } -fn discover_project_config(cwd: &Path) -> Result, RunnerError> { +fn config_values(config: &EffectiveConfig) -> Value { + let serialized = serde_json::to_value(config).expect("configuration serialization"); + let mut values = serde_json::Map::new(); + for (key, _, _, _) in SETTINGS { + values.insert(key.to_owned(), serialized[key].clone()); + } + Value::Object(values) +} + +fn record_candidates(config: &mut EffectiveConfig) { + let values = config_values(config); + for (key, value) in values.as_object().expect("configuration object") { + let candidate = json!({"value":value["value"],"source":value["source"],"selected":false}); + let history = config.candidates.entry(key.clone()).or_default(); + if history.last() != Some(&candidate) { + history.push(candidate); + } + } +} + +fn sanitize_overridden_profiles(config: &mut EffectiveConfig) { + let rank = |kind: &Value| match kind.as_str() { + Some("user-config") => 1, + Some("project-config") => 2, + Some("environment") => 3, + Some("flag") => 4, + _ => 0, + }; + let harnesses = config + .candidates + .get("harness") + .cloned() + .unwrap_or_default(); + if let Some(history) = config.candidates.get_mut("authProfile") { + history.retain(|candidate| { + let Some(profile) = candidate["value"].as_str() else { return true; }; + let owner = harnesses.iter().rev().find(|harness| rank(&harness["source"]["kind"]) <= rank(&candidate["source"]["kind"])); + let valid = owner.and_then(|owner| owner["value"].as_str()).and_then(crate::installed_adapters::for_harness).is_none_or(|adapter| adapter.auth_profiles().contains(&profile)); + if !valid { config.diagnostics.push(json!({"code":"CONFIG_CANDIDATE_INVALID", "severity":"warning", "source":candidate["source"]["location"], "reason":"Incompatible overridden authentication profile is omitted."})); } + valid + }); + } +} + +fn finalize_candidates(config: &mut EffectiveConfig) { + let values = config_values(config); + for (key, history) in &mut config.candidates { + if values[key]["source"]["kind"] == "default" { + history[0] = json!({"value":values[key]["value"],"source":values[key]["source"],"selected":false}); + } + for candidate in history { + candidate["selected"] = json!( + candidate["value"] == values[key]["value"] + && candidate["source"] == values[key]["source"] + ); + } + } +} + +/// Pure configuration explanation. No credential access, executable probe or image acquisition. +#[must_use] +pub fn configuration_explanation(config: &EffectiveConfig) -> Value { + let mut report = json!({"schema":"openprose.configuration-explanation/1", "cwd":{"value":config.cwd,"source":config.cwd_source}, + "projectConfigPath":config.project_config,"userConfigPath":config.user_config, + "values":config_values(config),"target":config.explanation_target.as_ref().map(|argv| json!({"argv":argv})), + "locations":config.locations,"candidates":config.candidates,"diagnostics":config.diagnostics, + "runtime":resolved_runtime(config)}); + if let Some(mutation) = &config.mutation { + report["mutation"] = mutation.clone(); + } + report +} + +/// Attaches pure diagnostic context to an explicit mutation failure without writing or probing. +#[must_use] +pub fn mutation_error_context( + error: RunnerError, + flags: &GlobalFlags, + system: &SystemContext, +) -> RunnerError { + if error.code != crate::ErrorCode::ConfigInvalid { + return error; + } + match resolve_config(flags, system) { + Ok(config) => explanation_error(error, &config), + Err(context) => match context + .details + .as_deref() + .and_then(|details| details.get("configurationExplanation")) + { + Some(report) => { + let mut report = report.clone(); + let details = error.details.as_deref(); + let diagnostic = json!({"code":"CONFIG_INVALID","severity":"error","source":details.and_then(|details| details.get("source")).cloned().unwrap_or_else(|| json!("configuration")),"reason":details.and_then(|details| details.get("reason")).cloned().unwrap_or_else(|| json!("Runner configuration is invalid."))}); + if let Some(diagnostics) = report["diagnostics"].as_array_mut() { + if diagnostics.last() != Some(&diagnostic) { + diagnostics.push(diagnostic); + } + } + error.with_detail("configurationExplanation", report) + } + None => error, + }, + } +} + +fn explanation_error(error: RunnerError, config: &EffectiveConfig) -> RunnerError { + let mut snapshot = config.clone(); + finalize_candidates(&mut snapshot); + let mut report = configuration_explanation(&snapshot); + report["runtime"] = json!({"transport":null,"permissionMode":null,"authProfile":null,"billingOwner":null,"nativeLimits":null,"nativeOutputLimits":null}); + let detail = error.details.as_deref(); + if let Some(source) = detail.and_then(|details| details.get("source")) { + let keys: Vec = report["values"] + .as_object() + .expect("settings") + .iter() + .filter(|(_, setting)| { + setting["source"]["location"] == *source && setting["source"]["kind"] != "default" + }) + .map(|(key, _)| key.clone()) + .collect(); + for key in keys { + let history = report["candidates"][&key] + .as_array_mut() + .expect("candidate history"); + history.retain(|candidate| candidate["source"]["location"] != *source); + for candidate in history.iter_mut() { + candidate["selected"] = json!(false); + } + if let Some(candidate) = history.last_mut() { + candidate["selected"] = json!(true); + let restored = json!({"value":candidate["value"],"source":candidate["source"]}); + report["values"][&key] = restored; + } + } + } + report["diagnostics"].as_array_mut().expect("diagnostic list").push(json!({ + "code":"CONFIG_INVALID", "severity":"error", "source":detail.and_then(|d| d.get("source")).cloned().unwrap_or_else(|| json!("configuration")), + "reason":detail.and_then(|d| d.get("reason")).cloned().unwrap_or_else(|| json!("Invalid configuration.")) + })); + error.with_detail("configurationExplanation", report) +} + +fn contextual_defaults(config: &mut EffectiveConfig) -> Result<(), RunnerError> { + let rank = |kind: &Value| match kind.as_str() { + Some("user-config") => 1, + Some("project-config") => 2, + Some("environment") => 3, + Some("flag") => 4, + _ => 0, + }; + let harness_rank = rank(&serde_json::to_value(&config.harness.source.kind).expect("source")); + for (_key, setting, file_key) in [ + ("model", &config.model, "model"), + ("authProfile", &config.auth_profile, "auth_profile"), + ] { + let selection_rank = rank(&serde_json::to_value(&setting.source.kind).expect("source")); + if setting.value.is_some() && selection_rank > 0 && selection_rank < harness_rank { + let owner = config.candidates.get("harness").and_then(|history| { + history + .iter() + .rev() + .find(|candidate| rank(&candidate["source"]["kind"]) <= selection_rank) + }); + if owner.is_some_and(|candidate| candidate["value"] != config.harness.value) { + return Err(setting_error( + format!( + "Inherited {file_key} belongs to a different harness; replace it at the harness-selecting layer." + ), + setting + .source + .location + .as_deref() + .unwrap_or("configuration"), + )); + } + } + } + let (model, auth) = match config.harness.value.as_str() { + "agents-sdk" => (Some("gpt-6.1-sol"), Some("openai-api-key")), + "codex" => (None, Some("cached-chatgpt-login")), + "claude" => (None, Some("claude-subscription")), + _ => (None, None), + }; + if config.model.source.kind == ConfigSourceKind::Default { + config.model.value = model.map(str::to_owned); + if model.is_some() { + config.model.source.location = Some(format!("built-in:{}", config.harness.value)); + } + } + if config.auth_profile.source.kind == ConfigSourceKind::Default { + config.auth_profile.value = auth.map(str::to_owned); + if auth.is_some() { + config.auth_profile.source.location = + Some(format!("built-in:{}", config.harness.value)); + } + } + if matches!(config.harness.value.as_str(), "prime" | "omp") + && config + .model + .value + .as_deref() + .is_some_and(|model| !crate::runner::is_fully_qualified_provider_model(model)) + { + let adapter = crate::installed_adapters::for_harness(&config.harness.value) + .expect("installed harness"); + return Err(setting_error("Prime and OMP models must be a fully qualified provider/model with no empty, whitespace, or control-character segments.", config.model.source.location.as_deref().unwrap_or("configuration")).with_detail("adapterId", adapter.id())); + } + if let Some(profile) = &config.auth_profile.value { + if let Some(adapter) = crate::installed_adapters::for_harness(&config.harness.value) { + if !adapter.auth_profiles().contains(&profile.as_str()) { + return Err(setting_error( + "Authentication profile is incompatible with the selected harness.", + config + .auth_profile + .source + .location + .as_deref() + .unwrap_or("built-in"), + ) + .with_detail("adapterId", adapter.id()) + .with_detail("supportedAuthProfiles", adapter.auth_profiles())); + } + } + } + Ok(()) +} + +fn resolved_runtime(config: &EffectiveConfig) -> Value { + let transport = if config.transport.value == "auto" { + crate::installed_adapters::for_harness(&config.harness.value).map_or_else( + || { + if config.harness.value == "openprose" { + "hosted" + } else { + "deterministic" + } + }, + crate::installed_adapters::InstalledAdapter::transport, + ) + } else { + config.transport.value.as_str() + }; + json!({"transport":transport,"permissionMode":config.permission_mode.value, + "authProfile":config.auth_profile.value,"billingOwner":match config.harness.value.as_str() {"openprose" => "openprose", "mock" => "test-fixture", _ => "user-provider"}, + "nativeLimits":native_limits(config), "nativeOutputLimits":native_output_limits(config)}) +} + +fn discover_project_config(cwd: &Path) -> Result<(Option, Vec), RunnerError> { + let mut locations = Vec::new(); let mut cursor = Some(cwd); while let Some(directory) = cursor { let candidate = directory.join(".prose").join("cli.toml"); - if candidate.is_file() { + let present = candidate.is_file(); + locations.push( + json!({"role":"project", "path":candidate, "present":present,"selected":present}), + ); + if present { let shown = candidate.display().to_string(); let canonical = fs::canonicalize(&candidate).map_err(|_| { setting_error(format!("Cannot read configuration file: {shown}."), &shown) })?; - return Ok(Some(canonical)); + return Ok((Some(canonical), locations)); } if directory.join(".git").exists() { break; } cursor = directory.parent(); } - Ok(None) + Ok((None, locations)) } /// `path` with `.` and `..` components removed lexically, as the Bun build @@ -959,6 +1532,23 @@ fn native_checks(config: &mut EffectiveConfig) -> Result<(), RunnerError> { config, )); } + if let Some(permission) = &config.permission_mode.value { + if !((config.harness.value == "claude" + && matches!(permission.as_str(), "default" | "acceptEdits")) + || (config.harness.value == "codex" + && matches!(permission.as_str(), "workspace-write" | "read-only"))) + { + let mut error = fail( + "Unsupported explicit permission mode for this harness.", + &["permissionMode"], + config, + ); + if let Some(adapter) = crate::installed_adapters::for_harness(&config.harness.value) { + error = error.with_detail("adapterId", adapter.id()); + } + return Err(error); + } + } let budgets = ["nativeMaxTurns", "nativeTimeout", "nativeToolTimeout"]; if config.harness.value != "agents-sdk" && first_source(config, &budgets).is_some() { return Err(fail( @@ -1163,7 +1753,7 @@ const SETTINGS: [(&str, &str, Option<&str>, &str); 19] = [ pub(crate) const MAX_TIMEOUT_MS: u64 = 86_400_000; /// One raw configuration value, before validation. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] enum RawSetting { Text(String), Boolean(bool), @@ -1385,6 +1975,7 @@ const SUPPORTED_HARNESSES: [&str; 7] = [ fn first_source(config: &EffectiveConfig, keys: &[&str]) -> Option { keys.iter().find_map(|key| { let source = match *key { + "permissionMode" => &config.permission_mode.source, "codexCompatibility" => &config.codex_compatibility.source, "nativeMaxTurns" => &config.native_max_turns.source, "nativeTimeout" => &config.native_timeout.source, @@ -1427,7 +2018,7 @@ fn apply_file( key, file_key, raw.clone(), - source, + &ConfigSource::new(source.kind.clone(), Some(location(file_key))), &location(file_key), true, )?; @@ -1759,10 +2350,10 @@ mod tests { fs::create_dir_all(project.join(".git")).unwrap(); fs::create_dir_all(project.join(".prose")).unwrap(); fs::create_dir_all(&child).unwrap(); - fs::create_dir_all(home.join("xdg/openprose")).unwrap(); + fs::create_dir_all(home.join(".prose")).unwrap(); fs::write( - home.join("xdg/openprose/cli.toml"), - "harness = \"claude\"\ntransport = \"user-transport\"\nmodel = \"user-model\"\n", + home.join(".prose/cli.toml"), + "harness = \"mock\"\ntransport = \"user-transport\"\nmodel = \"user-model\"\n", ) .unwrap(); fs::write( @@ -1848,7 +2439,7 @@ mod tests { home_dir: Some(absolute_home.clone()), xdg_config_home: Some(PathBuf::new()), appdata: None, - environment: BTreeMap::new(), + environment: BTreeMap::from([("PROSE_CONFIG_DIR".into(), String::new())]), platform: Platform::Unix, }, SystemContext { @@ -1856,7 +2447,7 @@ mod tests { home_dir: Some(absolute_home), xdg_config_home: Some(PathBuf::from("relative")), appdata: None, - environment: BTreeMap::new(), + environment: BTreeMap::from([("PROSE_CONFIG_DIR".into(), "relative".into())]), platform: Platform::Unix, }, SystemContext { @@ -1919,7 +2510,7 @@ mod tests { for accepted in corpus["accepted"].as_array().unwrap() { let temp = TempDir::new().unwrap(); let home = temp.path().join("home"); - let config_path = home.join("xdg/openprose/cli.toml"); + let config_path = home.join(".prose/cli.toml"); fs::create_dir_all(config_path.parent().unwrap()).unwrap(); fs::write(&config_path, accepted["source"].as_str().unwrap()).unwrap(); let config = resolve_config(&GlobalFlags::default(), &context(temp.path(), &home)) @@ -1951,7 +2542,7 @@ mod tests { for rejected in corpus["rejected"].as_array().unwrap() { let temp = TempDir::new().unwrap(); let home = temp.path().join("home"); - let config_path = home.join("xdg/openprose/cli.toml"); + let config_path = home.join(".prose/cli.toml"); fs::create_dir_all(config_path.parent().unwrap()).unwrap(); fs::write(&config_path, rejected["source"].as_str().unwrap()).unwrap(); let error = @@ -2002,7 +2593,7 @@ mod tests { for rejected in corpus["binaryRejected"].as_array().unwrap() { let temp = TempDir::new().unwrap(); let home = temp.path().join("home"); - let config_path = home.join("xdg/openprose/cli.toml"); + let config_path = home.join(".prose/cli.toml"); fs::create_dir_all(config_path.parent().unwrap()).unwrap(); let source = rejected["sourceHex"] .as_str() @@ -2044,7 +2635,7 @@ mod tests { fn unsupported_harness_values_fail_at_the_configuration_source() { let temp = TempDir::new().unwrap(); let home = temp.path().join("home"); - let config_path = home.join("xdg/openprose/cli.toml"); + let config_path = home.join(".prose/cli.toml"); fs::create_dir_all(config_path.parent().unwrap()).unwrap(); fs::write( &config_path, @@ -2095,7 +2686,7 @@ mod tests { let home = temp.path().join("home"); let system = context(temp.path(), &home); let config = resolve_config(&GlobalFlags::default(), &system).unwrap(); - let path = home.join("xdg/openprose/cli.toml"); + let path = home.join(".prose/cli.toml"); let first = write_user_harness(&config, "claude", None, None).unwrap(); assert!(first.changed); @@ -2114,7 +2705,7 @@ mod tests { ); assert_eq!( fs::read_to_string(&path).unwrap(), - "harness = \"claude\"\ntimeout = \"30s\"\n" + "timeout = \"30s\"\nharness = \"claude\"\n" ); assert!( @@ -2174,7 +2765,7 @@ mod tests { let temp = TempDir::new().unwrap(); let home = temp.path().join("home"); let config = resolve_config(&GlobalFlags::default(), &context(temp.path(), &home)).unwrap(); - let path = home.join("xdg/openprose/cli.toml"); + let path = home.join(".prose/cli.toml"); fs::create_dir_all(path.parent().unwrap()).unwrap(); let target = temp.path().join("target.toml"); fs::write(&target, "harness = \"openprose\"\n").unwrap(); @@ -2201,7 +2792,7 @@ mod tests { fs::create_dir_all(&xdg).unwrap(); fs::create_dir_all(&redirected).unwrap(); fs::set_permissions(&redirected, fs::Permissions::from_mode(0o755)).unwrap(); - symlink(&redirected, xdg.join("openprose")).unwrap(); + symlink(&redirected, home.join(".prose")).unwrap(); let config = resolve_config(&GlobalFlags::default(), &context(temp.path(), &home)).unwrap(); let error = write_user_harness(&config, "claude", None, None).unwrap_err(); @@ -2220,7 +2811,7 @@ mod tests { let temp = TempDir::new().unwrap(); let home = temp.path().join("home"); - let parent = home.join("xdg/openprose"); + let parent = home.join(".prose"); fs::create_dir_all(&parent).unwrap(); fs::set_permissions(&parent, fs::Permissions::from_mode(0o777)).unwrap(); let config = resolve_config(&GlobalFlags::default(), &context(temp.path(), &home)).unwrap(); @@ -2258,10 +2849,14 @@ mod tests { let temp = TempDir::new().unwrap(); let home = temp.path().join("home"); let xdg = home.join("xdg"); - let parent = xdg.join("openprose"); + let parent = home.join(".prose"); fs::create_dir_all(&xdg).unwrap(); fs::write(&parent, "not a directory\n").unwrap(); - let config = resolve_config(&GlobalFlags::default(), &context(temp.path(), &home)).unwrap(); + let config = EffectiveConfig::defaults( + temp.path().to_owned(), + None, + Some(home.join(".prose/cli.toml")), + ); let error = write_user_harness(&config, "claude", None, None).unwrap_err(); @@ -2281,7 +2876,7 @@ mod tests { fs::create_dir_all(&xdg).unwrap(); fs::create_dir_all(&redirected).unwrap(); fs::write(redirected.join("cli.toml"), "harness = \"claude\"\n").unwrap(); - symlink(&redirected, xdg.join("openprose")).unwrap(); + symlink(&redirected, home.join(".prose")).unwrap(); let config = resolve_config(&GlobalFlags::default(), &context(temp.path(), &home)).unwrap(); let error = write_user_harness(&config, "claude", None, None).unwrap_err(); @@ -2303,7 +2898,7 @@ mod tests { .code, crate::ErrorCode::ConfigInvalid ); - let path = home.join("xdg/openprose/cli.toml"); + let path = home.join(".prose/cli.toml"); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(&path, "unknown = true\n").unwrap(); assert_eq!( diff --git a/cli/rust/crates/prose-runner-core/src/invocation.rs b/cli/rust/crates/prose-runner-core/src/invocation.rs index 6ff0aed1..2e5ceb03 100644 --- a/cli/rust/crates/prose-runner-core/src/invocation.rs +++ b/cli/rust/crates/prose-runner-core/src/invocation.rs @@ -62,6 +62,9 @@ pub enum RunnerCommand { HarnessUse(String), CleanupPrime(String), ConfigExplain, + ConfigExplainTarget(Vec), + ConfigMigrate, + ConfigUnset(Vec), AuthStatus, AuthLogin, AuthLogout, @@ -487,10 +490,10 @@ fn parse_runner_command( { if !matches!( harness_id.as_str(), - "openprose" | "prime" | "omp" | "codex" | "claude" + "openprose" | "agents-sdk" | "prime" | "omp" | "codex" | "claude" ) { return Err(RunnerError::invocation( - "Harness selection must be one of openprose, prime, omp, codex, or claude.", + "Harness selection must be one of openprose, agents-sdk, prime, omp, codex, or claude.", )); } (RunnerCommand::HarnessUse(harness_id.clone()), tail) @@ -499,8 +502,51 @@ fn parse_runner_command( (RunnerCommand::CleanupPrime(handle.clone()), tail) } [config, explain, tail @ ..] if config == "config" && explain == "explain" => { + if let Some(separator) = tail.iter().position(|word| word == "--") { + let json = match &tail[..separator] { + [] => false, + [flag] if flag == "--json" => true, + _ => { + return Err(RunnerError::invocation( + "Config explain accepts only --json before the target separator.", + )); + } + }; + if *globals != GlobalFlags::default() { + return Err(RunnerError::invocation( + "Pass target runner options after the config explain separator.", + )); + } + let target = parse_invocation(tail[separator + 1..].iter().cloned())?; + let Action::Forward { argv, .. } = target.action else { + return Err(RunnerError::invocation( + "Config explain target must be a language invocation, not a runner operation.", + )); + }; + *globals = target.globals; + return Ok(Action::Runner { + command: RunnerCommand::ConfigExplainTarget(argv), + json, + }); + } (RunnerCommand::ConfigExplain, tail) } + [config, migrate, tail @ ..] if config == "config" && migrate == "migrate" => { + (RunnerCommand::ConfigMigrate, tail) + } + [config, unset, tail @ ..] if config == "config" && unset == "unset" => { + let json = tail.last().is_some_and(|flag| flag == "--json"); + let keys = if json { &tail[..tail.len() - 1] } else { tail }; + if keys.is_empty() || keys.iter().any(|key| key.starts_with('-')) { + return Err(RunnerError::invocation( + "Config unset requires one or more configuration file keys.", + )); + } + return Ok(Action::Runner { + command: RunnerCommand::ConfigUnset(keys.to_vec()), + json, + }); + } [org, list, tail @ ..] if org == "org" && list == "list" => (RunnerCommand::OrgList, tail), [auth, status, tail @ ..] if auth == "auth" && status == "status" => { (RunnerCommand::AuthStatus, tail) @@ -720,7 +766,7 @@ mod tests { ), ( vec!["cli", "harness", "use", "nope"], - "Harness selection must be one of openprose, prime, omp, codex, or claude.", + "Harness selection must be one of openprose, agents-sdk, prime, omp, codex, or claude.", ), ] { assert_invocation_error(&args, reason); diff --git a/cli/rust/crates/prose-runner-core/src/runner.rs b/cli/rust/crates/prose-runner-core/src/runner.rs index 854a09c9..2428e312 100644 --- a/cli/rust/crates/prose-runner-core/src/runner.rs +++ b/cli/rust/crates/prose-runner-core/src/runner.rs @@ -77,49 +77,6 @@ const CONFORMANCE_ADAPTER_CLEANUP_FAILURE: &str = "OPENPROSE_CONFORMANCE_ADAPTER pub const HELP: &str = include_str!("../../../../conformance/cases/fixtures/runner-help.txt"); -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct ConfigReport<'a> { - schema: &'static str, - cwd: crate::config::Sourced, - project_config_path: Option, - user_config_path: String, - values: ConfigValuesReport<'a>, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct ConfigValuesReport<'a> { - harness: &'a crate::config::Sourced, - transport: &'a crate::config::Sourced, - model: &'a crate::config::Sourced>, - timeout: &'a crate::config::Sourced, - output: &'a crate::config::Sourced, - color: &'a crate::config::Sourced, - verbose: &'a crate::config::Sourced, - auth_profile: &'a crate::config::Sourced>, - #[serde(skip_serializing_if = "Option::is_none")] - output_contract: Option<&'a crate::config::Sourced>, - #[serde(skip_serializing_if = "Option::is_none")] - permission_mode: Option<&'a crate::config::Sourced>>, - #[serde(skip_serializing_if = "Option::is_none")] - codex_compatibility: Option<&'a crate::config::Sourced>, - #[serde(skip_serializing_if = "Option::is_none")] - native_profile: Option<&'a crate::config::Sourced>, - #[serde(skip_serializing_if = "Option::is_none")] - native_max_turns: Option<&'a crate::config::Sourced>>, - #[serde(skip_serializing_if = "Option::is_none")] - native_timeout: Option<&'a crate::config::Sourced>>, - #[serde(skip_serializing_if = "Option::is_none")] - native_tool_timeout: Option<&'a crate::config::Sourced>>, - #[serde(skip_serializing_if = "Option::is_none")] - native_output_bytes: Option<&'a crate::config::Sourced>>, - #[serde(skip_serializing_if = "Option::is_none")] - native_add_dirs: Option<&'a crate::config::Sourced>>, - #[serde(skip_serializing_if = "Option::is_none")] - native_allow_tools: Option<&'a crate::config::Sourced>>, -} - #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct HarnessStatus { @@ -385,7 +342,7 @@ fn validate_harness_selection(harness: &str, flags: &GlobalFlags) -> Result<(), .with_detail("adapterId", adapter.id()) .with_detail( "reason", - format!("Unknown auth_profile for {}: {profile}.", adapter.id()), + "Authentication profile is incompatible with the selected harness.", ) .with_detail("supportedAuthProfiles", adapter.auth_profiles())); } @@ -464,14 +421,10 @@ fn execute_runner_command( ids: &dyn IdSource, ) -> CommandOutcome { match command { - RunnerCommand::ConfigExplain => { - let report = config_report(config); - if mode == OutputMode::Human { - CommandOutcome::human(render_config_human(config), "", 0) - } else { - CommandOutcome::json(report, 0) - } - } + RunnerCommand::ConfigExplain + | RunnerCommand::ConfigExplainTarget(_) + | RunnerCommand::ConfigMigrate + | RunnerCommand::ConfigUnset(_) => configuration_outcome(config, mode), RunnerCommand::Doctor => { if let Err(error) = validate_selected_transport(config) { return error_outcome(error, mode, clock, ids); @@ -633,6 +586,9 @@ fn execute_runner_command( Some(profile) => { format!("Route: {} (saved)\n", human_safe_scalar(profile)) } + None if harness == "agents-sdk" => { + "Route: openai-api-key (Agents SDK default; not saved)\n".to_owned() + } None if harness == "codex" => { "Route: cached-chatgpt-login (Codex default; not saved)\n".to_owned() } @@ -643,6 +599,9 @@ fn execute_runner_command( }; let saved_model = match flags.model.as_deref() { Some(model) => format!("Model: {} (saved)\n", human_safe_scalar(model)), + None if harness == "agents-sdk" => { + "Model: gpt-6.1-sol (Agents SDK default; not saved)\n".to_owned() + } None if matches!(harness.as_str(), "codex" | "claude") => { "Model: harness default (not saved)\n".to_owned() } @@ -1691,7 +1650,7 @@ fn selected_auth_group( .with_detail("adapterId", adapter.id()) .with_detail( "reason", - format!("Unknown auth_profile for {}: {profile}.", adapter.id()), + "Authentication profile is incompatible with the selected harness.", ) .with_detail("supportedAuthProfiles", adapter.auth_profiles())); } @@ -1956,7 +1915,7 @@ fn inspect_installed_adapter( } } -fn is_fully_qualified_provider_model(model: &str) -> bool { +pub(crate) fn is_fully_qualified_provider_model(model: &str) -> bool { let mut segments = model.split('/'); let Some(provider) = segments.next() else { return false; @@ -4011,7 +3970,7 @@ fn config_source_entries(config: &EffectiveConfig) -> Vec { config_source_entry("output", &config.output.source, false), config_source_entry("color", &config.color.source, false), config_source_entry("verbose", &config.verbose.source, false), - config_source_entry("authProfile", &config.auth_profile.source, true), + config_source_entry("authProfile", &config.auth_profile.source, false), ]; for (key, source) in [ ("outputContract", &config.output_contract.source), @@ -4424,66 +4383,17 @@ fn event( }) } -fn config_report(config: &EffectiveConfig) -> ConfigReport<'_> { - ConfigReport { - schema: "openprose.configuration-explanation/1", - cwd: crate::config::Sourced { - value: config.cwd.display().to_string(), - source: config.cwd_source.clone(), - }, - project_config_path: config - .project_config - .as_ref() - .map(|path| path.display().to_string()), - user_config_path: config - .user_config - .as_ref() - .map(|path| path.display().to_string()) - .expect("resolved configuration always has a user config candidate"), - values: ConfigValuesReport { - harness: &config.harness, - transport: &config.transport, - model: &config.model, - timeout: &config.timeout, - output: &config.output, - color: &config.color, - verbose: &config.verbose, - auth_profile: &config.auth_profile, - codex_compatibility: (config.codex_compatibility.source.kind - != ConfigSourceKind::Default) - .then_some(&config.codex_compatibility), - output_contract: (config.output_contract.source.kind != ConfigSourceKind::Default) - .then_some(&config.output_contract), - permission_mode: (config.permission_mode.source.kind != ConfigSourceKind::Default) - .then_some(&config.permission_mode), - native_max_turns: config - .native_max_turns - .value - .as_ref() - .map(|_| &config.native_max_turns), - native_timeout: config - .native_timeout - .value - .as_ref() - .map(|_| &config.native_timeout), - native_tool_timeout: config - .native_tool_timeout - .value - .as_ref() - .map(|_| &config.native_tool_timeout), - native_output_bytes: config - .native_output_bytes - .value - .as_ref() - .map(|_| &config.native_output_bytes), - native_profile: (config.native_profile.source.kind != ConfigSourceKind::Default) - .then_some(&config.native_profile), - native_add_dirs: (config.native_add_dirs.source.kind != ConfigSourceKind::Default) - .then_some(&config.native_add_dirs), - native_allow_tools: (config.native_allow_tools.source.kind - != ConfigSourceKind::Default) - .then_some(&config.native_allow_tools), - }, +fn config_report(config: &EffectiveConfig) -> Value { + crate::config::configuration_explanation(config) +} + +/// Renders pure configuration diagnostics without acquiring or probing an image. +#[must_use] +pub fn configuration_outcome(config: &EffectiveConfig, mode: OutputMode) -> CommandOutcome { + if mode == OutputMode::Human { + CommandOutcome::human(render_config_human(config), "", 0) + } else { + CommandOutcome::json(config_report(config), 0) } } @@ -4508,32 +4418,49 @@ fn render_config_human(config: &EffectiveConfig) -> String { human_safe_scalar(config.auth_profile.value.as_deref().unwrap_or("unset")), source_label(&config.auth_profile.source), ); - for (name, value, source) in [ - ( - "codexCompatibility", - config.codex_compatibility.value.as_str(), - &config.codex_compatibility.source, - ), - ( - "outputContract", - config.output_contract.value.as_str(), - &config.output_contract.source, - ), - ( - "permissionMode", - config.permission_mode.value.as_deref().unwrap_or("unset"), - &config.permission_mode.source, - ), - ] { - if source.kind != ConfigSourceKind::Default { - let _ = writeln!( - output, - "{} = {} ({})", - name, - human_safe_scalar(value), - source_label(source) - ); + let report = config_report(config); + for (name, setting) in report["values"] + .as_object() + .expect("configuration settings") + { + if matches!( + name.as_str(), + "harness" + | "transport" + | "model" + | "timeout" + | "output" + | "color" + | "verbose" + | "authProfile" + ) { + continue; } + let _ = writeln!( + output, + "{} = {} ({})", + name, + human_safe_scalar(&setting["value"].to_string()), + human_safe_scalar(setting["source"]["location"].as_str().unwrap_or("built-in")) + ); + } + for location in &config.locations { + let _ = writeln!( + output, + "{}: {} (present: {}, selected: {})", + location["role"].as_str().unwrap_or("configuration"), + human_safe_scalar(location["path"].as_str().unwrap_or("unavailable")), + location["present"], + location["selected"] + ); + } + for diagnostic in &config.diagnostics { + let _ = writeln!( + output, + "{}: {}", + diagnostic["code"].as_str().unwrap_or("CONFIGURATION"), + human_safe_scalar(diagnostic["reason"].as_str().unwrap_or("")) + ); } output } @@ -4733,14 +4660,27 @@ mod tests { model: Option<&str>, auth_profile: &str, ) -> EffectiveConfig { + // Transport inspection tests deliberately exercise invalid bundles. Resolve + // a coherent baseline, then inject the inspected values at that boundary. + let adapter = installed_adapters::for_harness(harness).unwrap(); + let safe_model = model.filter(|model| { + !matches!(harness, "prime" | "omp") || is_fully_qualified_provider_model(model) + }); let flags = GlobalFlags { harness: Some(harness.to_owned()), transport: Some(transport.to_owned()), - model: model.map(str::to_owned), - auth_profile: Some(auth_profile.to_owned()), + model: safe_model.map(str::to_owned), + auth_profile: Some( + if adapter.auth_profiles().contains(&auth_profile) { + auth_profile + } else { + adapter.default_probe_auth_group() + } + .to_owned(), + ), ..GlobalFlags::default() }; - crate::config::resolve_config( + let mut config = crate::config::resolve_config( &flags, &crate::config::SystemContext { current_dir: root.to_owned(), @@ -4751,7 +4691,10 @@ mod tests { platform: crate::config::Platform::current(), }, ) - .unwrap() + .unwrap(); + config.model.value = model.map(str::to_owned); + config.auth_profile.value = Some(auth_profile.to_owned()); + config } #[test] @@ -4828,7 +4771,12 @@ mod tests { assert_eq!(config.output_contract.value, "image-envelope"); assert!(config.permission_mode.value.is_none()); for k in fixture["defaultsOmitted"].as_array().unwrap() { - assert!(base["values"].get(k.as_str().unwrap()).is_none()); + assert!(base["values"].get(k.as_str().unwrap()).is_some()); + assert!( + !config_source_entries(&config) + .iter() + .any(|entry| entry["key"] == *k) + ); } for case in fixture["cases"].as_array().unwrap() { config.output_contract.value = case["outputContract"].as_str().unwrap().into(); @@ -5028,10 +4976,9 @@ mod tests { ); assert_eq!( problem.details.unwrap().get("reason"), - Some(&Value::String(format!( - "Unknown auth_profile for {}: unsupported-profile.", - adapter.id() - ))), + Some(&Value::String( + "Authentication profile is incompatible with the selected harness.".to_owned() + )), "{}", adapter.id() ); diff --git a/cli/shared/fixtures/config/optional-reporting.json b/cli/shared/fixtures/config/optional-reporting.json index c5ed64b7..ed1c7e69 100644 --- a/cli/shared/fixtures/config/optional-reporting.json +++ b/cli/shared/fixtures/config/optional-reporting.json @@ -32,5 +32,11 @@ "outputContract": "native", "permissionMode": "acceptEdits" } + }, + "explanationDefaults": { + "outputContract": "image-envelope", + "permissionMode": null, + "nativeProfile": "default", + "codexCompatibility": "qualified" } } diff --git a/cli/shared/fixtures/config/production-v2.json b/cli/shared/fixtures/config/production-v2.json new file mode 100644 index 00000000..2d45fb85 --- /dev/null +++ b/cli/shared/fixtures/config/production-v2.json @@ -0,0 +1,183 @@ +{ + "schema": "openprose.configuration-production-corpus/2", + "summary": "Deterministic provider-free setup and side-effect checks for the twelve matching black-box cases. Paths are relative to the isolated product workspace. No setup invokes a product or provider. Root runner must compare unchanged bytes, explicit absent paths and outputAbsent strings after execution.", + "cases": [ + { + "id": "operations.config-production-01", + "setup": { + "files": {}, + "directories": [], + "checks": { + "unchangedFiles": true, + "absent": [ + "home/.prose/cli.toml" + ] + } + } + }, + { + "id": "operations.config-production-02", + "setup": { + "files": {}, + "directories": [], + "checks": { + "unchangedFiles": true, + "absent": [ + "home/.prose/cli.toml" + ] + } + } + }, + { + "id": "operations.config-production-03", + "setup": { + "files": { + "home/.prose/cli.toml": "timeout = \"1m\"\n", + ".prose/cli.toml": "timeout = \"2m\"\n" + }, + "directories": [], + "checks": { + "unchangedFiles": true, + "absent": [] + } + } + }, + { + "id": "operations.config-production-04", + "setup": { + "files": { + "config/openprose/cli.toml": "# Keep this preference\nharness = \"codex\"\nmodel = \"gpt-6.1-sol\"\nverbose = true\n" + }, + "directories": [], + "checks": { + "unchangedFiles": true, + "absent": [ + "home/.prose/cli.toml" + ] + } + } + }, + { + "id": "operations.config-production-05", + "setup": { + "files": { + "home/.prose/cli.toml": "harness = \"agents-sdk\"\n", + "config/openprose/cli.toml": "# Keep this preference\nharness = \"codex\"\nmodel = \"gpt-6.1-sol\"\nverbose = true\n" + }, + "directories": [], + "checks": { + "unchangedFiles": true, + "absent": [] + } + } + }, + { + "id": "operations.config-production-06", + "setup": { + "files": { + "config/openprose/cli.toml": "# Keep this preference\nharness = \"codex\"\nmodel = \"gpt-6.1-sol\"\nverbose = true\n" + }, + "directories": [], + "checks": { + "unchangedFiles": [ + "config/openprose/cli.toml" + ], + "files": { + "home/.prose/cli.toml": "# Keep this preference\nharness = \"codex\"\nmodel = \"gpt-6.1-sol\"\nverbose = true\n" + }, + "absent": [] + } + } + }, + { + "id": "operations.config-production-07", + "setup": { + "files": { + "config/openprose/cli.toml": "# Keep this preference\nharness = \"codex\"\nmodel = \"gpt-6.1-sol\"\nverbose = true\n", + "home/.prose/cli.toml": "verbose = false\n" + }, + "directories": [], + "checks": { + "unchangedFiles": true, + "absent": [] + } + } + }, + { + "id": "operations.config-production-08", + "setup": { + "files": { + "home/.prose/cli.toml": "# Keep this comment\ntimeout = \"2m\"\nverbose = true\n" + }, + "directories": [], + "checks": { + "files": { + "home/.prose/cli.toml": "# Keep this comment\nverbose = true\n" + }, + "absent": [] + } + } + }, + { + "id": "operations.config-production-09", + "setup": { + "files": { + "home/.prose/cli.toml": "model = \"safe-model\"\napi_key = \"fixture-secret-do-not-print\"\n" + }, + "directories": [], + "checks": { + "unchangedFiles": true, + "absent": [], + "outputAbsent": [ + "fixture-secret-do-not-print", + "api_key =" + ] + } + } + }, + { + "id": "operations.config-production-10", + "setup": { + "files": { + "home/.prose/cli.toml": "harness = \"codex\"\nauth_profile = \"cached-chatgpt-login\"\nmodel = \"gpt-6.1-sol\"\n" + }, + "directories": [], + "checks": { + "unchangedFiles": true, + "absent": [] + } + } + }, + { + "id": "operations.config-production-11", + "setup": { + "files": { + "custom root/cli.toml": "timeout = \"7m\"\n", + "home/.prose/cli.toml": "timeout = \"8m\"\n", + "config/openprose/cli.toml": "timeout = \"9m\"\n" + }, + "directories": [], + "checks": { + "unchangedFiles": true, + "absent": [] + } + } + }, + { + "id": "operations.config-production-12", + "setup": { + "files": { + "work space/.prose/cli.toml": "timeout = \"6m\"\n", + ".prose/cli.toml": "timeout = \"5m\"\n" + }, + "directories": [], + "checks": { + "unchangedFiles": true, + "absent": [ + "home/.prose/cli.toml" + ] + } + } + } + ] +} diff --git a/cli/shared/fixtures/dx/dry-run-default-hosted.json b/cli/shared/fixtures/dx/dry-run-default-hosted.json index b3a0a4ff..598c074a 100644 --- a/cli/shared/fixtures/dx/dry-run-default-hosted.json +++ b/cli/shared/fixtures/dx/dry-run-default-hosted.json @@ -9,9 +9,15 @@ "runtimeVersion": null, "model": null }, - "prompt": { "placement": null, "strictness": "unsupported" }, + "prompt": { + "placement": null, + "strictness": "unsupported" + }, "isolation": "unsupported", - "auth": { "category": "openprose-account", "readiness": "unknown" }, + "auth": { + "category": "openprose-account", + "readiness": "unknown" + }, "billingOwner": "openprose", "languageImage": { "formatVersion": "openprose.skill-runtime-image/1", @@ -20,15 +26,60 @@ "releaseEligible": false }, "configuration": [ - { "key": "cwd", "source": "default", "location": "process cwd", "redacted": false }, - { "key": "harness", "source": "default", "location": "built-in", "redacted": false }, - { "key": "transport", "source": "default", "location": "built-in", "redacted": false }, - { "key": "model", "source": "default", "location": "built-in", "redacted": false }, - { "key": "timeout", "source": "default", "location": "built-in", "redacted": false }, - { "key": "output", "source": "flag", "location": "--output", "redacted": false }, - { "key": "color", "source": "default", "location": "built-in", "redacted": false }, - { "key": "verbose", "source": "default", "location": "built-in", "redacted": false }, - { "key": "authProfile", "source": "default", "location": "built-in", "redacted": true } + { + "key": "cwd", + "source": "default", + "location": "process cwd", + "redacted": false + }, + { + "key": "harness", + "source": "default", + "location": "built-in", + "redacted": false + }, + { + "key": "transport", + "source": "default", + "location": "built-in", + "redacted": false + }, + { + "key": "model", + "source": "default", + "location": "built-in", + "redacted": false + }, + { + "key": "timeout", + "source": "default", + "location": "built-in", + "redacted": false + }, + { + "key": "output", + "source": "flag", + "location": "--output", + "redacted": false + }, + { + "key": "color", + "source": "default", + "location": "built-in", + "redacted": false + }, + { + "key": "verbose", + "source": "default", + "location": "built-in", + "redacted": false + }, + { + "key": "authProfile", + "source": "default", + "location": "built-in", + "redacted": false + } ], "readiness": "blocked", "blockingError": { @@ -39,6 +90,9 @@ "retryable": false, "message": "Programs run here only with a local harness; hosted runs use `prose cli run submit`.", "action": "To use the hosted service, run `cli run submit FILE --preview`; running programs on this machine needs a local harness (`cli harness list`).", - "details": { "billingOwner": "openprose", "fallbackSelected": false } + "details": { + "billingOwner": "openprose", + "fallbackSelected": false + } } } diff --git a/cli/shared/fixtures/dx/dry-run-mock.json b/cli/shared/fixtures/dx/dry-run-mock.json index c41f5f4b..c5a166f5 100644 --- a/cli/shared/fixtures/dx/dry-run-mock.json +++ b/cli/shared/fixtures/dx/dry-run-mock.json @@ -9,9 +9,15 @@ "runtimeVersion": "1.0.0", "model": null }, - "prompt": { "placement": "developer", "strictness": "strict" }, + "prompt": { + "placement": "developer", + "strictness": "strict" + }, "isolation": "test-fixture", - "auth": { "category": "none-test-only", "readiness": "not-applicable" }, + "auth": { + "category": "none-test-only", + "readiness": "not-applicable" + }, "billingOwner": "test-fixture", "languageImage": { "formatVersion": "openprose.skill-runtime-image/1", @@ -20,15 +26,60 @@ "releaseEligible": false }, "configuration": [ - { "key": "cwd", "source": "default", "location": "process cwd", "redacted": false }, - { "key": "harness", "source": "flag", "location": "--harness", "redacted": false }, - { "key": "transport", "source": "default", "location": "built-in", "redacted": false }, - { "key": "model", "source": "default", "location": "built-in", "redacted": false }, - { "key": "timeout", "source": "default", "location": "built-in", "redacted": false }, - { "key": "output", "source": "flag", "location": "--output", "redacted": false }, - { "key": "color", "source": "default", "location": "built-in", "redacted": false }, - { "key": "verbose", "source": "default", "location": "built-in", "redacted": false }, - { "key": "authProfile", "source": "default", "location": "built-in", "redacted": true } + { + "key": "cwd", + "source": "default", + "location": "process cwd", + "redacted": false + }, + { + "key": "harness", + "source": "flag", + "location": "--harness", + "redacted": false + }, + { + "key": "transport", + "source": "default", + "location": "built-in", + "redacted": false + }, + { + "key": "model", + "source": "default", + "location": "built-in", + "redacted": false + }, + { + "key": "timeout", + "source": "default", + "location": "built-in", + "redacted": false + }, + { + "key": "output", + "source": "flag", + "location": "--output", + "redacted": false + }, + { + "key": "color", + "source": "default", + "location": "built-in", + "redacted": false + }, + { + "key": "verbose", + "source": "default", + "location": "built-in", + "redacted": false + }, + { + "key": "authProfile", + "source": "default", + "location": "built-in", + "redacted": false + } ], "readiness": "ready", "blockingError": null diff --git a/cli/shared/fixtures/dx/dry-run-prime.json b/cli/shared/fixtures/dx/dry-run-prime.json index 15fc87c9..937ece17 100644 --- a/cli/shared/fixtures/dx/dry-run-prime.json +++ b/cli/shared/fixtures/dx/dry-run-prime.json @@ -9,9 +9,15 @@ "runtimeVersion": "prime-agent 0.7.0", "model": "fixture/model:alpha" }, - "prompt": { "placement": "system-append", "strictness": "strict" }, + "prompt": { + "placement": "system-append", + "strictness": "strict" + }, "isolation": "partial", - "auth": { "category": "harness-managed", "readiness": "unknown" }, + "auth": { + "category": "harness-managed", + "readiness": "unknown" + }, "billingOwner": "user-provider", "languageImage": { "formatVersion": "openprose.skill-runtime-image/1", @@ -20,15 +26,60 @@ "releaseEligible": false }, "configuration": [ - { "key": "cwd", "source": "default", "location": "process cwd", "redacted": false }, - { "key": "harness", "source": "flag", "location": "--harness", "redacted": false }, - { "key": "transport", "source": "flag", "location": "--transport", "redacted": false }, - { "key": "model", "source": "flag", "location": "--model", "redacted": false }, - { "key": "timeout", "source": "default", "location": "built-in", "redacted": false }, - { "key": "output", "source": "flag", "location": "--output", "redacted": false }, - { "key": "color", "source": "default", "location": "built-in", "redacted": false }, - { "key": "verbose", "source": "default", "location": "built-in", "redacted": false }, - { "key": "authProfile", "source": "flag", "location": "--auth-profile", "redacted": true } + { + "key": "cwd", + "source": "default", + "location": "process cwd", + "redacted": false + }, + { + "key": "harness", + "source": "flag", + "location": "--harness", + "redacted": false + }, + { + "key": "transport", + "source": "flag", + "location": "--transport", + "redacted": false + }, + { + "key": "model", + "source": "flag", + "location": "--model", + "redacted": false + }, + { + "key": "timeout", + "source": "default", + "location": "built-in", + "redacted": false + }, + { + "key": "output", + "source": "flag", + "location": "--output", + "redacted": false + }, + { + "key": "color", + "source": "default", + "location": "built-in", + "redacted": false + }, + { + "key": "verbose", + "source": "default", + "location": "built-in", + "redacted": false + }, + { + "key": "authProfile", + "source": "flag", + "location": "--auth-profile", + "redacted": false + } ], "readiness": "ready", "blockingError": null diff --git a/cli/shared/fixtures/operations/configuration-explanation.json b/cli/shared/fixtures/operations/configuration-explanation.json index 9ff887a6..7647d413 100644 --- a/cli/shared/fixtures/operations/configuration-explanation.json +++ b/cli/shared/fixtures/operations/configuration-explanation.json @@ -2,18 +2,370 @@ "schema": "openprose.configuration-explanation/1", "cwd": { "value": "/workspace", - "source": { "kind": "default", "location": "process cwd" } + "source": { + "kind": "default", + "location": "process cwd" + } }, "projectConfigPath": null, "userConfigPath": "/workspace/config/openprose/cli.toml", "values": { - "harness": { "value": "openprose", "source": { "kind": "default", "location": "built-in" } }, - "transport": { "value": "auto", "source": { "kind": "default", "location": "built-in" } }, - "model": { "value": null, "source": { "kind": "default", "location": "built-in" } }, - "timeout": { "value": "10m", "source": { "kind": "default", "location": "built-in" } }, - "output": { "value": "json", "source": { "kind": "flag", "location": "--output" } }, - "color": { "value": false, "source": { "kind": "default", "location": "built-in" } }, - "verbose": { "value": false, "source": { "kind": "default", "location": "built-in" } }, - "authProfile": { "value": null, "source": { "kind": "default", "location": "built-in" } } + "harness": { + "value": "openprose", + "source": { + "kind": "default", + "location": "built-in" + } + }, + "transport": { + "value": "auto", + "source": { + "kind": "default", + "location": "built-in" + } + }, + "model": { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + } + }, + "timeout": { + "value": "10m", + "source": { + "kind": "default", + "location": "built-in" + } + }, + "output": { + "value": "json", + "source": { + "kind": "flag", + "location": "--output" + } + }, + "color": { + "value": false, + "source": { + "kind": "default", + "location": "built-in" + } + }, + "verbose": { + "value": false, + "source": { + "kind": "default", + "location": "built-in" + } + }, + "outputContract": { + "value": "image-envelope", + "source": { + "kind": "default", + "location": "built-in" + } + }, + "codexCompatibility": { + "value": "qualified", + "source": { + "kind": "default", + "location": "built-in" + } + }, + "permissionMode": { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + } + }, + "nativeMaxTurns": { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + } + }, + "nativeTimeout": { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + } + }, + "nativeToolTimeout": { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + } + }, + "nativeOutputBytes": { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + } + }, + "nativeProfile": { + "value": "default", + "source": { + "kind": "default", + "location": "built-in" + } + }, + "nativeAddDirs": { + "value": [], + "source": { + "kind": "default", + "location": "built-in" + } + }, + "nativeAllowTools": { + "value": [], + "source": { + "kind": "default", + "location": "built-in" + } + }, + "nativeLog": { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + } + }, + "authProfile": { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + } + } + }, + "target": null, + "locations": [ + { + "role": "user", + "path": "/workspace/config/openprose/cli.toml", + "present": false, + "selected": false + }, + { + "role": "project", + "path": "/workspace/.prose/cli.toml", + "present": false, + "selected": false + } + ], + "candidates": { + "harness": [ + { + "value": "openprose", + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "transport": [ + { + "value": "auto", + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "model": [ + { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "timeout": [ + { + "value": "10m", + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "output": [ + { + "value": "human", + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": false + }, + { + "value": "json", + "source": { + "kind": "flag", + "location": "--output" + }, + "selected": true + } + ], + "color": [ + { + "value": false, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "verbose": [ + { + "value": false, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "outputContract": [ + { + "value": "image-envelope", + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "codexCompatibility": [ + { + "value": "qualified", + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "permissionMode": [ + { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "nativeMaxTurns": [ + { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "nativeTimeout": [ + { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "nativeToolTimeout": [ + { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "nativeOutputBytes": [ + { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "nativeProfile": [ + { + "value": "default", + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "nativeAddDirs": [ + { + "value": [], + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "nativeAllowTools": [ + { + "value": [], + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "nativeLog": [ + { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "authProfile": [ + { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ] + }, + "diagnostics": [], + "runtime": { + "transport": "hosted", + "permissionMode": null, + "authProfile": null, + "billingOwner": "openprose", + "nativeLimits": null, + "nativeOutputLimits": null } } diff --git a/cli/shared/fixtures/operations/doctor-report.json b/cli/shared/fixtures/operations/doctor-report.json index ee0a7c47..3074b7b3 100644 --- a/cli/shared/fixtures/operations/doctor-report.json +++ b/cli/shared/fixtures/operations/doctor-report.json @@ -28,18 +28,373 @@ }, "configuration": { "schema": "openprose.configuration-explanation/1", - "cwd": { "value": "/workspace", "source": { "kind": "default", "location": "process cwd" } }, + "cwd": { + "value": "/workspace", + "source": { + "kind": "default", + "location": "process cwd" + } + }, "projectConfigPath": null, "userConfigPath": "/workspace/config/openprose/cli.toml", "values": { - "harness": { "value": "openprose", "source": { "kind": "default", "location": "built-in" } }, - "transport": { "value": "auto", "source": { "kind": "default", "location": "built-in" } }, - "model": { "value": null, "source": { "kind": "default", "location": "built-in" } }, - "timeout": { "value": "10m", "source": { "kind": "default", "location": "built-in" } }, - "output": { "value": "json", "source": { "kind": "flag", "location": "--output" } }, - "color": { "value": false, "source": { "kind": "default", "location": "built-in" } }, - "verbose": { "value": false, "source": { "kind": "default", "location": "built-in" } }, - "authProfile": { "value": null, "source": { "kind": "default", "location": "built-in" } } + "harness": { + "value": "openprose", + "source": { + "kind": "default", + "location": "built-in" + } + }, + "transport": { + "value": "auto", + "source": { + "kind": "default", + "location": "built-in" + } + }, + "model": { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + } + }, + "timeout": { + "value": "10m", + "source": { + "kind": "default", + "location": "built-in" + } + }, + "output": { + "value": "json", + "source": { + "kind": "flag", + "location": "--output" + } + }, + "color": { + "value": false, + "source": { + "kind": "default", + "location": "built-in" + } + }, + "verbose": { + "value": false, + "source": { + "kind": "default", + "location": "built-in" + } + }, + "outputContract": { + "value": "image-envelope", + "source": { + "kind": "default", + "location": "built-in" + } + }, + "codexCompatibility": { + "value": "qualified", + "source": { + "kind": "default", + "location": "built-in" + } + }, + "permissionMode": { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + } + }, + "nativeMaxTurns": { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + } + }, + "nativeTimeout": { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + } + }, + "nativeToolTimeout": { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + } + }, + "nativeOutputBytes": { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + } + }, + "nativeProfile": { + "value": "default", + "source": { + "kind": "default", + "location": "built-in" + } + }, + "nativeAddDirs": { + "value": [], + "source": { + "kind": "default", + "location": "built-in" + } + }, + "nativeAllowTools": { + "value": [], + "source": { + "kind": "default", + "location": "built-in" + } + }, + "nativeLog": { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + } + }, + "authProfile": { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + } + } + }, + "target": null, + "locations": [ + { + "role": "user", + "path": "/workspace/config/openprose/cli.toml", + "present": false, + "selected": false + }, + { + "role": "project", + "path": "/workspace/.prose/cli.toml", + "present": false, + "selected": false + } + ], + "candidates": { + "harness": [ + { + "value": "openprose", + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "transport": [ + { + "value": "auto", + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "model": [ + { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "timeout": [ + { + "value": "10m", + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "output": [ + { + "value": "human", + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": false + }, + { + "value": "json", + "source": { + "kind": "flag", + "location": "--output" + }, + "selected": true + } + ], + "color": [ + { + "value": false, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "verbose": [ + { + "value": false, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "outputContract": [ + { + "value": "image-envelope", + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "codexCompatibility": [ + { + "value": "qualified", + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "permissionMode": [ + { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "nativeMaxTurns": [ + { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "nativeTimeout": [ + { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "nativeToolTimeout": [ + { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "nativeOutputBytes": [ + { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "nativeProfile": [ + { + "value": "default", + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "nativeAddDirs": [ + { + "value": [], + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "nativeAllowTools": [ + { + "value": [], + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "nativeLog": [ + { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ], + "authProfile": [ + { + "value": null, + "source": { + "kind": "default", + "location": "built-in" + }, + "selected": true + } + ] + }, + "diagnostics": [], + "runtime": { + "transport": "hosted", + "permissionMode": null, + "authProfile": null, + "billingOwner": "openprose", + "nativeLimits": null, + "nativeOutputLimits": null } }, "harnesses": [ @@ -48,7 +403,9 @@ "runtime": "hosted-runtime", "availability": "not-implemented", "detectedVersion": null, - "transports": ["hosted"], + "transports": [ + "hosted" + ], "authCategory": "openprose-account", "billingOwner": "openprose", "strictWrapperConformant": false, @@ -59,7 +416,9 @@ "runtime": "installed-process", "availability": "not-implemented", "detectedVersion": null, - "transports": ["rpc"], + "transports": [ + "rpc" + ], "authCategory": "harness-managed", "billingOwner": "user-provider", "strictWrapperConformant": false, @@ -71,7 +430,9 @@ "runtime": "installed-process", "availability": "not-implemented", "detectedVersion": null, - "transports": ["rpc"], + "transports": [ + "rpc" + ], "authCategory": "harness-managed", "billingOwner": "user-provider", "strictWrapperConformant": false, @@ -92,7 +453,9 @@ "runtime": "installed-process", "availability": "not-implemented", "detectedVersion": null, - "transports": ["exec-json"], + "transports": [ + "exec-json" + ], "authCategory": "harness-managed", "billingOwner": "user-provider", "strictWrapperConformant": false, @@ -104,7 +467,9 @@ "runtime": "installed-process", "availability": "not-implemented", "detectedVersion": null, - "transports": ["print-stream-json"], + "transports": [ + "print-stream-json" + ], "authCategory": "harness-managed", "billingOwner": "user-provider", "strictWrapperConformant": false, @@ -116,7 +481,10 @@ "runtime": "deterministic-mock", "availability": "available", "detectedVersion": "1.0.0", - "transports": ["deterministic", "fake-process"], + "transports": [ + "deterministic", + "fake-process" + ], "authCategory": "none-test-only", "billingOwner": "test-fixture", "strictWrapperConformant": true, @@ -132,7 +500,10 @@ "action": "To use the hosted service, run `cli run submit FILE --preview`; running programs on this machine needs a local harness (`cli harness list`).", "exitCode": 10, "retryable": false, - "details": { "billingOwner": "openprose", "fallbackSelected": false } + "details": { + "billingOwner": "openprose", + "fallbackSelected": false + } } ] } diff --git a/cli/shared/schemas/configuration-explanation.schema.json b/cli/shared/schemas/configuration-explanation.schema.json index b3acde96..6de79032 100644 --- a/cli/shared/schemas/configuration-explanation.schema.json +++ b/cli/shared/schemas/configuration-explanation.schema.json @@ -4,40 +4,398 @@ "title": "OpenProse effective runner configuration v1", "type": "object", "additionalProperties": false, - "required": ["schema", "cwd", "projectConfigPath", "userConfigPath", "values"], + "required": [ + "schema", + "cwd", + "projectConfigPath", + "userConfigPath", + "values" + ], "properties": { - "schema": { "const": "openprose.configuration-explanation/1" }, - "cwd": { "$ref": "#/$defs/sourcedPath" }, - "projectConfigPath": { "type": ["string", "null"] }, - "userConfigPath": { "type": "string", "minLength": 1 }, + "schema": { + "const": "openprose.configuration-explanation/1" + }, + "cwd": { + "$ref": "#/$defs/sourcedPath" + }, + "projectConfigPath": { + "type": [ + "string", + "null" + ] + }, + "userConfigPath": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, "values": { "type": "object", "additionalProperties": false, - "required": ["harness", "transport", "model", "timeout", "output", "color", "verbose", "authProfile"], + "required": [ + "harness", + "transport", + "model", + "timeout", + "output", + "color", + "verbose", + "authProfile" + ], "properties": { - "harness": { "$ref": "#/$defs/sourcedString" }, - "transport": { "$ref": "#/$defs/sourcedString" }, - "model": { "$ref": "#/$defs/sourcedNullableString" }, - "timeout": { "$ref": "#/$defs/sourcedString" }, + "harness": { + "$ref": "#/$defs/sourcedString" + }, + "transport": { + "$ref": "#/$defs/sourcedString" + }, + "model": { + "$ref": "#/$defs/sourcedNullableString" + }, + "timeout": { + "$ref": "#/$defs/sourcedString" + }, "output": { "allOf": [ - { "$ref": "#/$defs/sourced" }, - { "properties": { "value": { "enum": ["human", "json", "jsonl"] } } } + { + "$ref": "#/$defs/sourced" + }, + { + "properties": { + "value": { + "enum": [ + "human", + "json", + "jsonl" + ] + } + } + } + ] + }, + "color": { + "$ref": "#/$defs/sourcedBoolean" + }, + "verbose": { + "$ref": "#/$defs/sourcedBoolean" + }, + "outputContract": { + "allOf": [ + { + "$ref": "#/$defs/sourcedString" + }, + { + "properties": { + "value": { + "enum": [ + "image-envelope", + "native" + ] + } + } + } + ] + }, + "codexCompatibility": { + "allOf": [ + { + "$ref": "#/$defs/sourcedString" + }, + { + "properties": { + "value": { + "enum": [ + "qualified", + "probe" + ] + } + } + } + ] + }, + "permissionMode": { + "allOf": [ + { + "$ref": "#/$defs/sourcedNullableString" + }, + { + "properties": { + "value": { + "enum": [ + "default", + "acceptEdits", + "workspace-write", + "read-only", + null + ] + } + } + } + ] + }, + "nativeMaxTurns": { + "$ref": "#/$defs/sourcedNullableString" + }, + "nativeTimeout": { + "$ref": "#/$defs/sourcedNullableString" + }, + "nativeToolTimeout": { + "$ref": "#/$defs/sourcedNullableString" + }, + "nativeOutputBytes": { + "$ref": "#/$defs/sourcedNullableString" + }, + "nativeProfile": { + "$ref": "#/$defs/sourcedNullableString" + }, + "nativeAddDirs": { + "allOf": [ + { + "$ref": "#/$defs/sourced" + }, + { + "properties": { + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + }, + "nativeAllowTools": { + "allOf": [ + { + "$ref": "#/$defs/sourced" + }, + { + "properties": { + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + }, + "authProfile": { + "$ref": "#/$defs/sourcedNullableString" + }, + "nativeLog": { + "$ref": "#/$defs/sourcedNullableString" + } + } + }, + "target": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "argv" + ], + "properties": { + "argv": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + } + ] + }, + "locations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "role", + "path", + "present", + "selected" + ], + "properties": { + "role": { + "enum": [ + "user", + "legacy-user", + "project" + ] + }, + "path": { + "type": "string" + }, + "present": { + "type": "boolean" + }, + "selected": { + "type": "boolean" + } + } + } + }, + "candidates": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "value", + "source", + "selected" + ], + "properties": { + "value": { + "$ref": "common.schema.json#/$defs/jsonValue" + }, + "source": { + "$ref": "#/$defs/source" + }, + "selected": { + "type": "boolean" + } + } + } + } + }, + "diagnostics": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "severity", + "source", + "reason" + ], + "properties": { + "code": { + "type": "string" + }, + "severity": { + "enum": [ + "warning", + "error" + ] + }, + "source": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + } + }, + "runtime": { + "type": "object", + "additionalProperties": false, + "required": [ + "transport", + "permissionMode", + "authProfile", + "billingOwner", + "nativeLimits", + "nativeOutputLimits" + ], + "properties": { + "transport": { + "type": [ + "string", + "null" ] }, - "color": { "$ref": "#/$defs/sourcedBoolean" }, - "verbose": { "$ref": "#/$defs/sourcedBoolean" }, - "outputContract": { "allOf": [{ "$ref": "#/$defs/sourcedString" }, { "properties": { "value": { "enum": ["image-envelope", "native"] } } }] }, - "codexCompatibility": { "allOf": [{ "$ref": "#/$defs/sourcedString" }, { "properties": { "value": { "enum": ["qualified", "probe"] } } }] }, - "permissionMode": { "allOf": [{ "$ref": "#/$defs/sourcedString" }, { "properties": { "value": { "enum": ["default", "acceptEdits", "workspace-write", "read-only"] } } }] }, - "nativeMaxTurns": { "$ref": "#/$defs/sourcedString" }, - "nativeTimeout": { "$ref": "#/$defs/sourcedString" }, - "nativeToolTimeout": { "$ref": "#/$defs/sourcedString" }, - "nativeOutputBytes": { "$ref": "#/$defs/sourcedString" }, - "nativeProfile": { "$ref": "#/$defs/sourcedString" }, - "nativeAddDirs": { "allOf": [{ "$ref": "#/$defs/sourced" }, { "properties": { "value": { "type": "array", "items": { "type": "string" } } } }] }, - "nativeAllowTools": { "allOf": [{ "$ref": "#/$defs/sourced" }, { "properties": { "value": { "type": "array", "items": { "type": "string" } } } }] }, - "authProfile": { "$ref": "#/$defs/sourcedNullableString" } + "permissionMode": { + "type": [ + "string", + "null" + ] + }, + "authProfile": { + "type": [ + "string", + "null" + ] + }, + "billingOwner": { + "type": [ + "string", + "null" + ] + }, + "nativeLimits": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "number" + } + }, + "nativeOutputLimits": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": [ + "number", + "boolean" + ] + } + } + } + }, + "mutation": { + "type": "object", + "additionalProperties": false, + "required": [ + "operation", + "changed", + "path", + "sourcePath", + "keys" + ], + "properties": { + "operation": { + "enum": [ + "migrate", + "unset" + ] + }, + "changed": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "sourcePath": { + "type": [ + "string", + "null" + ] + }, + "keys": { + "type": "array", + "items": { + "type": "string" + } + } } } }, @@ -45,43 +403,101 @@ "source": { "type": "object", "additionalProperties": false, - "required": ["kind", "location"], + "required": [ + "kind", + "location" + ], "properties": { - "kind": { "enum": ["default", "user-config", "project-config", "environment", "flag"] }, - "location": { "type": "string", "minLength": 1 } + "kind": { + "enum": [ + "default", + "user-config", + "project-config", + "environment", + "flag" + ] + }, + "location": { + "type": "string", + "minLength": 1 + } } }, "sourced": { "type": "object", "additionalProperties": false, - "required": ["value", "source"], + "required": [ + "value", + "source" + ], "properties": { - "value": { "$ref": "common.schema.json#/$defs/jsonValue" }, - "source": { "$ref": "#/$defs/source" } + "value": { + "$ref": "common.schema.json#/$defs/jsonValue" + }, + "source": { + "$ref": "#/$defs/source" + } } }, "sourcedPath": { "allOf": [ - { "$ref": "#/$defs/sourced" }, - { "properties": { "value": { "type": "string", "minLength": 1 } } } + { + "$ref": "#/$defs/sourced" + }, + { + "properties": { + "value": { + "type": "string", + "minLength": 1 + } + } + } ] }, "sourcedString": { "allOf": [ - { "$ref": "#/$defs/sourced" }, - { "properties": { "value": { "type": "string", "minLength": 1 } } } + { + "$ref": "#/$defs/sourced" + }, + { + "properties": { + "value": { + "type": "string", + "minLength": 1 + } + } + } ] }, "sourcedNullableString": { "allOf": [ - { "$ref": "#/$defs/sourced" }, - { "properties": { "value": { "type": ["string", "null"] } } } + { + "$ref": "#/$defs/sourced" + }, + { + "properties": { + "value": { + "type": [ + "string", + "null" + ] + } + } + } ] }, "sourcedBoolean": { "allOf": [ - { "$ref": "#/$defs/sourced" }, - { "properties": { "value": { "type": "boolean" } } } + { + "$ref": "#/$defs/sourced" + }, + { + "properties": { + "value": { + "type": "boolean" + } + } + } ] } } diff --git a/cli/shared/tests/test_contracts.py b/cli/shared/tests/test_contracts.py index 184ad372..0c816127 100755 --- a/cli/shared/tests/test_contracts.py +++ b/cli/shared/tests/test_contracts.py @@ -903,6 +903,50 @@ def test_all_case_manifests_validate_and_error_actions_are_frozen(self) -> None: self.assertEqual(len(ids), len(set(ids))) self.assertTrue({"core.initial-help", "core.opaque-argv", "core.mock-success", "core.openprose-hosted-unavailable"}.issubset(ids)) + def test_configuration_production_corpus_is_closed_and_cases_reference_exact_setup(self): + corpus = load_json(FIXTURES / "config/production-v2.json") + self.assertEqual({"schema", "summary", "cases"}, set(corpus)) + self.assertEqual("openprose.configuration-production-corpus/2", corpus["schema"]) + identifiers = [f"operations.config-production-{number:02}" for number in range(1, 13)] + self.assertEqual(identifiers, [record["id"] for record in corpus["cases"]]) + case_schema = load_json(CASES / "case-manifest.schema.json") + self.assertEqual(identifiers, case_schema["properties"]["controls"]["properties"]["configurationFixture"]["enum"]) + for record in corpus["cases"]: + self.assertEqual({"id", "setup"}, set(record)) + setup = record["setup"] + self.assertEqual({"files", "directories", "checks"}, set(setup)) + checks = setup["checks"] + self.assertFalse(set(checks) - {"unchangedFiles", "files", "absent", "outputAbsent"}) + self.assertIn("absent", checks) + self.assertTrue("unchangedFiles" in checks or "files" in checks) + unchanged = checks.get("unchangedFiles", False) + self.assertTrue(type(unchanged) is bool or isinstance(unchanged, list)) + if isinstance(unchanged, list): + self.assertTrue(set(unchanged).issubset(setup["files"])) + for files in (setup["files"], checks.get("files", {})): + self.assertIsInstance(files, dict) + self.assertTrue(all(isinstance(value, str) for value in files.values())) + for paths in (setup["directories"], checks["absent"], checks.get("outputAbsent", [])): + self.assertIsInstance(paths, list) + self.assertEqual(len(paths), len(set(paths))) + self.assertTrue(all(isinstance(value, str) and value for value in paths)) + paths = [*setup["files"], *setup["directories"], *checks.get("files", {}), *checks["absent"]] + for relative in paths: + self.assertFalse(PurePosixPath(relative).is_absolute()) + self.assertTrue(relative) + self.assertNotIn("\\", relative) + self.assertTrue(all(part not in {"", ".", ".."} and ":" not in part for part in relative.split("/"))) + case = load_json(CASES / "operations" / f"{record['id'].split('.')[-1]}.json") + self.assertEqual(record["id"], case["id"]) + self.assertEqual(record["id"], case["controls"]["configurationFixture"]) + self.assertFalse(case["expected"]["startedHarness"]) + self.assertNotIn("fakeHarness", case["controls"]) + self.assertNotIn("installedAdapter", case["controls"]) + self.assertEqual("denied", case["controls"]["network"]) + manifests = [load_json(path) for path in CASES.rglob("*.json")] + references = [case["controls"]["configurationFixture"] for case in manifests if "configurationFixture" in case.get("controls", {})] + self.assertEqual(sorted(identifiers), sorted(references)) + def test_adversarial_scenario_catalog_covers_fake_harness_contract(self) -> None: catalog = load_json(CLI / "conformance" / "adversarial" / "transport" / "fake-scenario-expectations.json") expected = { diff --git a/docs/user-configuration.md b/docs/user-configuration.md new file mode 100644 index 00000000..c9fdc36e --- /dev/null +++ b/docs/user-configuration.md @@ -0,0 +1,214 @@ +# User configuration and exact-command explanation + +IMP-097 design, frozen October 6, 2026. This document specifies the candidate +behavior; implementation and qualification are recorded separately in the +workspace task. IMP-097 retains the `openprose` default harness. IMP-098 changes +that built-in to the packaged `agents-sdk` harness after qualification. + +## Peer comparison and selected convention + +The official [Codex configuration guide](https://learn.chatgpt.com/docs/config-file/config-basic) +uses `~/.codex/config.toml`, project files and omitted values inheriting defaults. +The official [Claude Code settings guide](https://code.claude.com/docs/en/settings) +uses `~/.claude/settings.json`, project settings, per-session overrides and an +explicit configuration-directory override. The official +[OpenCode configuration guide](https://opencode.ai/docs/config/) uses XDG user +settings, project discovery up to the nearest Git directory, and explicit custom +file/directory overrides. Sources were inspected October 6, 2026. The referenced +Prime settings page was unavailable; it supplies no evidence for this decision. + +These peers agree on settings independent of installation, separate user and +project layers, and inherited defaults. Home versus XDG locations and environment +precedence vary. The user explicitly chose `~/.prose/cli.toml`. Preserve Prose's +existing flags > environment > project > user > built-ins precedence. Select the +established explicit-directory-override approach as `PROSE_CONFIG_DIR`, governing +ordinary settings only. No extra managed-policy, profile or interactive layer is +introduced by this change. Credential and cache locations are separate concerns. + +## Discovery and migration + +Normal discovery uses `$HOME/.prose/cli.toml` on macOS/Linux and +`%USERPROFILE%\.prose\cli.toml` on Windows. Existing internal dependency injection +remains available to hermetic tests. `PROSE_CONFIG_DIR`, when supplied, must be a +nonempty absolute directory and selects `/cli.toml` as the sole user +location. Empty/relative roots fail with their environment source. The override +does not relocate credentials or caches, and disables legacy discovery. + +Without that override, consider one legacy location using the former resolver: +`$XDG_CONFIG_HOME/openprose/cli.toml` when the variable is set; otherwise +`~/Library/Application Support/OpenProse/cli.toml` on macOS, +`~/.config/openprose/cli.toml` on Linux or `%APPDATA%\OpenProse\cli.toml` on Windows. +A missing optional legacy root is not an error when canonical discovery works. +An invalid legacy root is reported safely in diagnostics; it must not defeat an +existing canonical file. A canonical/legacy alias of one physical file is loaded +once. Unreadable or malformed active settings fail closed. Preserve existing discovery: +a directory named `cli.toml` is absent configuration, not an active file. Explicit +mutations reject nonregular destinations and symlinks. + +If only legacy settings exist, load them read-only and emit +`LEGACY_CONFIG_ACTIVE`. Do not copy them during explanation or execution. If both +files exist, canonical settings win as an entire user layer: do not fill omitted +canonical keys from legacy settings. Emit `LEGACY_CONFIG_IGNORED`, explaining +that authority and any differing explicit keys. Invalid ignored legacy content +is a warning containing classification/source only, never its rejected values. +Missing settings use built-ins and do not create directories or files. + +`prose cli config migrate [--json]` validates legacy settings and copies their +bytes to canonical settings, including comments, without adding defaults. Secure +atomic creation must refuse existing canonical destinations and unsafe symlinks; +never delete or replace the old file. An existing destination gives +`CONFIG_INVALID` with reason `Canonical user configuration already exists; +migration never overwrites it.` A missing legacy source gives an actionable +configuration error. Root override has no migration source. + +`prose cli config unset KEY... [--json]` accepts known TOML keys and removes only +those assignments, preserving other lines and comments. Unknown keys and an +empty key list are invocation errors. With no user file, succeed unchanged and +create nothing. When a legacy file is active, copy its valid explicit settings +into the canonical destination with the requested keys removed, leaving the old +file intact. Parse active files for supported syntax before changing any bytes. Unset may +repair a semantically invalid value by removing it; validate all retained values +and the retained explicit harness bundle before atomic publication. Migrate +validates all original values and the explicit bundle. Mutation preflight ignores +project settings and execution environment overrides, but retains home and root +selection. Resolve the full effective configuration after publication. If that +resolution fails, the configuration error includes `details.mutation` so the +user can see the completed change. Reuse the existing owner-only, atomic, nonsymlink writer. Removing an override restores +inheritance from the remaining precedence layers and current built-ins. + +`prose cli harness use` remains the atomic persistence route for a selected +harness/model/authentication bundle. It saves explicitly supplied choices only. +Other keys can be edited directly in the accepted TOML format. A commented example +is optional; no command materializes inherited defaults automatically. + +## Contextual defaults and compatibility + +Resolve the harness first, then its built-in model/authentication defaults. + +| Harness | Model when omitted | Authentication profile when omitted | +| --- | --- | --- | +| agents-sdk | `gpt-6.1-sol` | `openai-api-key` | +| codex | null: native harness selection | `cached-chatgpt-login` | +| claude | null: native harness selection | `claude-subscription` | +| prime / omp | explicit selection required | explicit selection required | +| openprose / mock | null | null | + +Contextual defaults have source `{kind:"default", location:"built-in:HARNESS"}`; +general built-ins retain location `built-in`. Incompatible explicit profiles +fail `CONFIG_INVALID` before executable discovery, credential acquisition or +inference. No account/model/harness fallback is permitted. Explicit lower-layer +model/auth settings belong to the closest harness selection at that layer or +below. If a higher layer selects a different harness, it must replace affected +explicit model/auth settings at that layer or above. Reject stale inherited +bundles rather than silently carrying them over or discarding them. General +built-ins are selected contextually and are not stale explicit overrides. + +Keep existing native Codex/Claude model choices and login routes. SDK built-ins +must never become their inherited credential or model choice. Persisting an +alternative and later upgrading the executable preserves the user's settings. + +## Pure explanation + +Existing syntax remains supported: + +```text +prose [runner-global-options...] cli config explain [--json] +``` + +To inspect the exact planned language invocation: + +```text +prose cli config explain [--json] -- [runner-global-options...] COMMAND [ARGS...] +prose cli config explain --json -- --cwd "work space" --harness agents-sdk run snapshot.prose.md +``` + +Use the execution entrypoint parser and configuration resolver. The target +`--cwd` controls canonical cwd and project discovery; its flags participate in +normal precedence. After the first opaque token or target `--`, preserve every +argument literally, including spellings such as `--model`. The reported target +argv includes the `prose` introducer. Require a target command and reject nested +runner operations, target help/version and target service commands; this surface +explains local language execution. Outer `--json` renders the explanation and +must not become a target output override. Reject runner globals before `cli` +when a separate target vector is supplied, avoiding two contradictory commands. + +Explanation performs configuration reads and cwd canonicalization only. It +starts no subprocess, model or agent; inspects no credential stores; and acquires +no published kernel. `cli doctor` and `--dry-run` own readiness. Public profile +names and billing owners are useful diagnostics. Secret values are never printed. + +## Frozen machine interface + +Retain `openprose.configuration-explanation/1`. New fields are additive for old +fixture compatibility, but every new production report emits all of them. + +- `cwd`, `userConfigPath`, `projectConfigPath`, `values`: retain the current + structure. Emit all nineteen current setting keys. Missing/inapplicable scalar + values are null; additional-directory/tool lists are empty arrays. The implicit + native profile is `default`, reflecting the execution helper. Each value + has its winning `source`. Do not inject these report-only nulls or native + defaults into validation inputs for other harnesses. +- `target`: null for untargeted operations, otherwise `{argv:["prose",...]}`. +- `locations`: deterministic discovery-order array of + `{role:"user"|"legacy-user"|"project",path,present,selected}`. Include absent + considered files and ignored files. List project candidates from target cwd + upwards through the stopping boundary; no unvisited ancestors are claimed. +- `candidates`: object keyed by setting, with arrays of + `{value,source:{kind,location},selected}` from lowest to highest precedence. + File locations contain exact path and line; the owning map key identifies the + setting. Validate candidate values before displaying them. There is exactly + one selected candidate for a successfully resolved value. Null/inapplicable + defaults remain visible. Arrays of candidates are not a second resolver. +- `diagnostics`: deterministic array of `{code,severity:"warning"|"error", + source,reason}`. Reasons contain safe classifications and known key names, + never rejected input lines or values. Normal no-config success has `[]`. +- `runtime`: `{transport,permissionMode,authProfile,billingOwner,nativeLimits, + nativeOutputLimits}`. Unresolved/inapplicable members are null. Resolve + automatic transport from the existing static capability facts, without probing. + Report the actual implicit permissions selected by the execution planner; + unavailable/unenforced permissions must not be represented as containment. + Installed third-party billing is `user-provider`; native model selection stays + null when the runner does not select it. + +SDK `runtime.nativeLimits` begins with the currently implemented defaults: +`{maxTurns:20,timeoutSeconds:180,toolTimeoutSeconds:30,maxOutputTokens:12000}`. +Native output budgets reuse the execution helper, including fixed record size, +aggregate stdout, capture bound and capture-enabled state. The new harness +packaging work may add bounds through a separately revised shared contract. + +A failed explain retains the existing error envelope and nonzero exit. For +configuration errors, `details.configurationExplanation` contains the safely +resolved partial report and the first blocking error in `diagnostics`. Unknown +or unvalidated values must not appear in its candidates/values. No partial +report authorizes execution. Human output displays winning values/sources and +concise migration/error summaries; JSON carries the complete candidate detail. +If cwd or the user configuration root cannot be resolved, the partial report +contains all built-in values and candidates, the process cwd with its source, +null configuration paths, no discovered locations, and the exact target when +already parsed. Every runtime field is null because resolution did not complete. + +Migration/unset successful JSON is the same post-operation explanation with +`mutation:{operation:"migrate"|"unset",changed,path,sourcePath,keys}`. `sourcePath` +is the legacy path for migration and legacy-backed unset, otherwise null; +`keys` is the requested TOML key list for unset, otherwise `[]`. Human output +states changed/unchanged, destination and retained legacy source when applicable. + +## Acceptance and authority + +The shared twelve `operations.config-production-NN` cases reference +`cli/shared/fixtures/config/production-v2.json` for deterministic isolated setup +and byte/absence assertions. The runner prepares files without invoking either +product, executes the actual command, then checks unchanged source bytes, +expected destination bytes, absent paths and redaction sentinels. Both products +are independently compared to these controls. A fake/provider harness start is +always forbidden for these cases, including malformed and stale-bundle cases. + +These cases establish basic shared behavior, not the entire release gate. +Independent product tests must additionally cover all setting grammars, malformed +and inaccessible roots, supported Windows home discovery, duplicate keys, +symlink refusal, atomic write failures, repeated/absent unset, multiple-key unset, +legacy-backed unset, unknown keys, invalid/empty target vectors, native-budget +parity and updates to built-ins with explicit overrides retained. Installed-route +and upgrade evidence must verify identical resolution for npm, npx, standalone +and Homebrew. Live SDK fulfillment and packaged-runtime admission belong to +IMP-098 and do not follow from an explanation passing.