diff --git a/cli/README.md b/cli/README.md index 5dfb0220..1cfa7b4d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -6,7 +6,9 @@ streams the selected harness output back to the terminal. The CLI does not replace the OpenProse VM or execute programs by itself. The selected harness still runs the prompt, loads the OpenProse skill/specs, spawns -agents when available, and writes run state. +agents when available, and writes run state. Prompts sent through the CLI tell +the harness not to invoke the `prose` shell command again, which prevents +recursive wrapper calls. ## Requirements @@ -66,10 +68,15 @@ Maintainers can find the release process in [RELEASE.md](RELEASE.md). Select a harness with `--harness ` or `PROSE_HARNESS`. -For externally sandboxed CI environments, Codex harnesses also honor -`PROSE_CODEX_SANDBOX_MODE` (`read-only`, `workspace-write`, or -`danger-full-access`) and `PROSE_CODEX_APPROVAL_POLICY` (`never`, `on-request`, -`on-failure`, or `untrusted`) and forward those values to Codex. +Codex harnesses default to `workspace-write`, skip Codex's Git-repository +startup check, allow network access inside the workspace-write sandbox, and add +the user's Codex home as a writable directory so OpenProse sub-sessions can be +created. They also ask Codex shells to inherit the user's PATH so normal local +tools are available. + +Override those defaults with `PROSE_CODEX_SANDBOX_MODE` (`read-only`, +`workspace-write`, or `danger-full-access`) and `PROSE_CODEX_APPROVAL_POLICY` +(`never`, `on-request`, `on-failure`, or `untrusted`). ## Skill Setup diff --git a/cli/install.sh b/cli/install.sh index 0a6a8949..91aa8604 100644 --- a/cli/install.sh +++ b/cli/install.sh @@ -2,7 +2,7 @@ set -eu # Installs the Node-based Prose CLI from a verified release tarball. -DEFAULT_VERSION="0.1.1" +DEFAULT_VERSION="0.1.2" DEFAULT_REPO_URL="https://github.com/openprose/prose" log() { diff --git a/cli/package-lock.json b/cli/package-lock.json index 21452b8f..9875d921 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openprose/prose-cli", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openprose/prose-cli", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.90", diff --git a/cli/package.json b/cli/package.json index d319b0cd..3ed5aa0b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openprose/prose-cli", - "version": "0.1.1", + "version": "0.1.2", "description": "Run OpenProse commands through Codex, Claude, or SDK-backed agent harnesses.", "type": "module", "packageManager": "npm@10.9.2", diff --git a/cli/src/commands/base.ts b/cli/src/commands/base.ts index f615cfb6..8c6a1e2d 100644 --- a/cli/src/commands/base.ts +++ b/cli/src/commands/base.ts @@ -1,4 +1,6 @@ import { Command } from "@oclif/core"; +import { existsSync, statSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import type { CommandName } from "../prose/index.js"; import { canonicalPrompt, CommandModelError, usageFor } from "../prose/index.js"; import { createHarness, type HarnessName } from "../harnesses/index.js"; @@ -86,14 +88,16 @@ function isOclifExit(error: unknown): boolean { export async function runForwardedProseCommand(options: ForwardRunOptions): Promise { const { harness, args } = splitHarnessArgs(options.argv, options.env, options.command); - const prompt = canonicalPrompt(options.command, args); - if (shouldRunSkillPreflight(options)) { - await runSkillPreflight(harness, options); + const prompt = harnessPrompt(canonicalPrompt(options.command, args)); + const cwd = resolveHarnessCwd(options.cwd, options.command, args, options.env); + const forwardedOptions = { ...options, cwd }; + if (shouldRunSkillPreflight(forwardedOptions)) { + await runSkillPreflight(harness, forwardedOptions); } const selectedHarness = (options.harnessFactory ?? createHarness)(harness); return selectedHarness.run(prompt, { - cwd: options.cwd, + cwd, env: { ...options.env }, stdout: options.stdout, stderr: options.stderr, @@ -101,6 +105,16 @@ export async function runForwardedProseCommand(options: ForwardRunOptions): Prom }); } +function harnessPrompt(commandPrompt: string): string { + return [ + "You are running inside the Prose CLI harness.", + "Interpret the following OpenProse command in-session through the open-prose skill.", + "Do not invoke the `prose`, `npx prose`, or `@openprose/prose-cli` shell command; that would recursively call this wrapper.", + "", + commandPrompt, + ].join("\n"); +} + function shouldRunSkillPreflight(options: ForwardRunOptions): boolean { if (options.skillPreflight === false) { return false; @@ -174,6 +188,70 @@ export function splitHarnessArgs( return { harness, args }; } +function resolveHarnessCwd( + cwd: string, + command: CommandName, + args: readonly string[], + env: Readonly>, +): string { + const target = localPathTarget(command, args, cwd, env); + if (target === undefined) { + return cwd; + } + + const targetDirectory = statSync(target).isDirectory() ? target : dirname(target); + return nearestGitRoot(targetDirectory) ?? targetDirectory; +} + +function localPathTarget( + command: CommandName, + args: readonly string[], + cwd: string, + env: Readonly>, +): string | undefined { + if (!["lint", "migrate", "preflight", "run", "test"].includes(command)) { + return undefined; + } + + const target = args[0]; + if (target === undefined || target.startsWith("-") || target.includes("://")) { + return undefined; + } + + const expanded = expandHome(target, env); + const absolute = isAbsolute(expanded) ? expanded : resolve(cwd, expanded); + if (!existsSync(absolute)) { + return undefined; + } + + return absolute; +} + +function expandHome(path: string, env: Readonly>): string { + if (path === "~") { + return env.HOME ?? process.env.HOME ?? path; + } + if (path.startsWith("~/")) { + const home = env.HOME ?? process.env.HOME; + return home === undefined ? path : join(home, path.slice(2)); + } + return path; +} + +function nearestGitRoot(directory: string): string | undefined { + let current = resolve(directory); + for (;;) { + if (existsSync(join(current, ".git"))) { + return current; + } + const parent = dirname(current); + if (parent === current) { + return undefined; + } + current = parent; + } +} + export function normalizeEntrypointArgv( argv: readonly string[], ): string[] { diff --git a/cli/src/harnesses/codex-options.ts b/cli/src/harnesses/codex-options.ts index 8de2bf15..16f6618e 100644 --- a/cli/src/harnesses/codex-options.ts +++ b/cli/src/harnesses/codex-options.ts @@ -2,34 +2,86 @@ import type { CodexThreadOptions } from "./types.js"; const CODEX_SANDBOX_MODES = ["read-only", "workspace-write", "danger-full-access"] as const; const CODEX_APPROVAL_POLICIES = ["never", "on-request", "on-failure", "untrusted"] as const; +const DEFAULT_CODEX_APPROVAL_POLICY = "never"; +const DEFAULT_CODEX_SANDBOX_MODE = "workspace-write"; export function codexCliRuntimeArgs(env: Record | undefined): string[] { const args: string[] = []; - const sandboxMode = codexEnvOption("PROSE_CODEX_SANDBOX_MODE", CODEX_SANDBOX_MODES, env); - const approvalPolicy = codexEnvOption("PROSE_CODEX_APPROVAL_POLICY", CODEX_APPROVAL_POLICIES, env); + const sandboxMode = codexSandboxMode(env); + const approvalPolicy = codexApprovalPolicy(env); + args.push("--skip-git-repo-check"); if (sandboxMode !== undefined) { args.push("--sandbox", sandboxMode); } if (approvalPolicy !== undefined) { args.push("--config", `approval_policy="${approvalPolicy}"`); } + for (const directory of additionalWritableDirectories(sandboxMode, env)) { + args.push("--add-dir", directory); + } + if (sandboxMode === "workspace-write") { + args.push("--config", "sandbox_workspace_write.network_access=true"); + } + args.push("--config", "shell_environment_policy.inherit=all"); return args; } export function codexThreadRuntimeOptions( env: Record | undefined, -): Pick { - const sandboxMode = codexEnvOption("PROSE_CODEX_SANDBOX_MODE", CODEX_SANDBOX_MODES, env); - const approvalPolicy = codexEnvOption("PROSE_CODEX_APPROVAL_POLICY", CODEX_APPROVAL_POLICIES, env); +): Pick< + CodexThreadOptions, + "additionalDirectories" | "approvalPolicy" | "networkAccessEnabled" | "sandboxMode" | "skipGitRepoCheck" +> { + const sandboxMode = codexSandboxMode(env); + const approvalPolicy = codexApprovalPolicy(env); + const additionalDirectories = additionalWritableDirectories(sandboxMode, env); return { + skipGitRepoCheck: true, ...(sandboxMode === undefined ? {} : { sandboxMode }), ...(approvalPolicy === undefined ? {} : { approvalPolicy }), + ...(additionalDirectories.length === 0 ? {} : { additionalDirectories }), + ...(sandboxMode === "workspace-write" ? { networkAccessEnabled: true } : {}), }; } +export function codexClientConfig() { + return { + shell_environment_policy: { + inherit: "all", + }, + }; +} + +function codexSandboxMode(env: Record | undefined) { + return codexEnvOption("PROSE_CODEX_SANDBOX_MODE", CODEX_SANDBOX_MODES, env) ?? DEFAULT_CODEX_SANDBOX_MODE; +} + +function codexApprovalPolicy(env: Record | undefined) { + return codexEnvOption("PROSE_CODEX_APPROVAL_POLICY", CODEX_APPROVAL_POLICIES, env) ?? DEFAULT_CODEX_APPROVAL_POLICY; +} + +function additionalWritableDirectories( + sandboxMode: string | undefined, + env: Record | undefined, +): string[] { + if (sandboxMode !== "workspace-write") { + return []; + } + + const codexHome = env?.CODEX_HOME ?? process.env.CODEX_HOME; + const home = env?.HOME ?? process.env.HOME; + if (codexHome !== undefined && codexHome !== "") { + return [codexHome]; + } + if (home !== undefined && home !== "") { + return [`${home}/.codex`]; + } + return []; +} + function codexEnvOption( name: string, allowedValues: T, diff --git a/cli/src/harnesses/codex-sdk.ts b/cli/src/harnesses/codex-sdk.ts index 06eb84fa..47f9f1e4 100644 --- a/cli/src/harnesses/codex-sdk.ts +++ b/cli/src/harnesses/codex-sdk.ts @@ -1,4 +1,4 @@ -import { codexThreadRuntimeOptions } from "./codex-options.js"; +import { codexClientConfig, codexThreadRuntimeOptions } from "./codex-options.js"; import { writeLine } from "./streams.js"; import type { CodexSdkClientOptions, CodexSdkFactory, CodexThreadEvent, CodexThreadItem, Harness } from "./types.js"; @@ -60,6 +60,7 @@ function codexClientOptions(env: Record | undefined) { return { ...(apiKey === undefined ? {} : { apiKey }), + config: codexClientConfig(), ...(env === undefined ? {} : { env }), }; } diff --git a/cli/src/harnesses/types.ts b/cli/src/harnesses/types.ts index 1f64924a..774b3749 100644 --- a/cli/src/harnesses/types.ts +++ b/cli/src/harnesses/types.ts @@ -45,7 +45,7 @@ export type ProcessRunner = ( ) => Promise; export type CodexThreadOptions = ThreadOptions; -export type CodexSdkClientOptions = Pick; +export type CodexSdkClientOptions = Pick; export interface CodexThread { runStreamed(prompt: CodexInput, options?: TurnOptions): Promise; diff --git a/cli/tests/cli/cli.test.ts b/cli/tests/cli/cli.test.ts index 232eb132..c69eaffc 100644 --- a/cli/tests/cli/cli.test.ts +++ b/cli/tests/cli/cli.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -115,11 +115,56 @@ describe("runForwardedProseCommand", () => { }); expect(exitCode).toBe(7); - expect(seen).toEqual(["prose run './flows/needs review.md' --topic 'two words'", "/repo", "secret"]); + expect(seen).toEqual([ + [ + "You are running inside the Prose CLI harness.", + "Interpret the following OpenProse command in-session through the open-prose skill.", + "Do not invoke the `prose`, `npx prose`, or `@openprose/prose-cli` shell command; that would recursively call this wrapper.", + "", + "prose run './flows/needs review.md' --topic 'two words'", + ].join("\n"), + "/repo", + "secret", + ]); expect(io.stdout).toBe("out"); expect(io.stderr).toBe("err"); }); + it("runs local file targets from their nearest project root", async () => { + const temp = mkdtempSync(join(tmpdir(), "prose-target-cwd-")); + const io = memoryStreams(); + const seen: string[] = []; + + try { + const project = join(temp, "project"); + const programDir = join(project, "flows"); + const program = join(programDir, "flow.md"); + mkdirSync(join(project, ".git"), { recursive: true }); + mkdirSync(programDir, { recursive: true }); + writeFileSync(program, "---\nkind: program\n---\n"); + + await runForwardedProseCommand({ + command: "run", + argv: [program, "--harness", "mock"], + cwd: temp, + env: {}, + stdout: io.streams.stdout, + stderr: io.streams.stderr, + harnessFactory: () => ({ + name: "mock", + async run(_prompt, options) { + seen.push(options.cwd ?? ""); + return 0; + }, + }), + }); + + expect(seen).toEqual([project]); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + it("passes abort signals to harnesses", async () => { const io = memoryStreams(); const signal = new AbortController().signal; diff --git a/cli/tests/harnesses/harnesses.test.ts b/cli/tests/harnesses/harnesses.test.ts index bd8556b5..f45c7a22 100644 --- a/cli/tests/harnesses/harnesses.test.ts +++ b/cli/tests/harnesses/harnesses.test.ts @@ -65,9 +65,28 @@ describe("process harnesses", () => { const prompt = "prose run inspector.md --flag='two words'"; const harness = createHarness("codex", { runner: recordingRunner(calls) }); - await harness.run(prompt, { ...io.options }); + await harness.run(prompt, { ...io.options, env: { HOME: "/home/prose" } }); - expect(calls).toEqual([{ command: "codex", args: ["exec", prompt] }]); + expect(calls).toEqual([ + { + command: "codex", + args: [ + "exec", + "--skip-git-repo-check", + "--sandbox", + "workspace-write", + "--config", + 'approval_policy="never"', + "--add-dir", + "/home/prose/.codex", + "--config", + "sandbox_workspace_write.network_access=true", + "--config", + "shell_environment_policy.inherit=all", + prompt, + ], + }, + ]); }); test("codex CLI maps OPENAI_API_KEY to CODEX_API_KEY", async () => { @@ -104,7 +123,17 @@ describe("process harnesses", () => { expect(calls).toEqual([ { command: "codex", - args: ["exec", "--sandbox", "danger-full-access", "--config", 'approval_policy="never"', prompt], + args: [ + "exec", + "--skip-git-repo-check", + "--sandbox", + "danger-full-access", + "--config", + 'approval_policy="never"', + "--config", + "shell_environment_policy.inherit=all", + prompt, + ], }, ]); }); @@ -184,14 +213,29 @@ describe("codex-sdk harness", () => { const exitCode = await createCodexSdkHarness({ factory }).run("prose run inspector.md", { ...io.options, cwd: "/repo", - env: { OPENAI_API_KEY: "test", EMPTY: undefined }, + env: { OPENAI_API_KEY: "test", HOME: "/home/prose", EMPTY: undefined }, signal, }); expect(exitCode).toBe(0); expect(io.stdout).toBe("sdk output\n"); - expect(starts).toEqual([{ workingDirectory: "/repo" }]); - expect(factoryOptions).toEqual([{ apiKey: "test", env: { OPENAI_API_KEY: "test" } }]); + expect(starts).toEqual([ + { + additionalDirectories: ["/home/prose/.codex"], + approvalPolicy: "never", + networkAccessEnabled: true, + sandboxMode: "workspace-write", + skipGitRepoCheck: true, + workingDirectory: "/repo", + }, + ]); + expect(factoryOptions).toEqual([ + { + apiKey: "test", + config: { shell_environment_policy: { inherit: "all" } }, + env: { OPENAI_API_KEY: "test", HOME: "/home/prose" }, + }, + ]); }); test("forwards requested sandbox and approval settings to Codex SDK threads", async () => { @@ -219,7 +263,13 @@ describe("codex-sdk harness", () => { }); expect(exitCode).toBe(0); - expect(starts).toEqual([{ approvalPolicy: "never", sandboxMode: "danger-full-access" }]); + expect(starts).toEqual([ + { + approvalPolicy: "never", + sandboxMode: "danger-full-access", + skipGitRepoCheck: true, + }, + ]); }); test("maps failed turns to stderr and nonzero exit", async () => { diff --git a/skills/open-prose/SKILL.md b/skills/open-prose/SKILL.md index 10211063..7a3b834f 100644 --- a/skills/open-prose/SKILL.md +++ b/skills/open-prose/SKILL.md @@ -4,9 +4,9 @@ description: | Activate when the user types `prose ...`, opens a `.md` file with `kind:` frontmatter, opens a `.prose` file, or asks for reusable multi-agent orchestration. Treat `prose run ...` as an in-session instruction: embody - the OpenProse VM yourself; do not shell out to a `prose` binary unless the - host explicitly provides one. On activation read the Markdown contract, wire - services, execute with host primitives, and persist `.prose/runs/`. + the OpenProse VM yourself; do not shell out to a `prose` binary. On + activation read the Markdown contract, wire services, execute with host + primitives, and persist `.prose/runs/`. Decline for one-shot questions — a plain prompt is often the right answer. --- @@ -96,11 +96,10 @@ Activate this skill when the user: `prose ...` commands are first an agent-session command language. When the user types `prose run foo.md` in chat or inside a prompt passed to Claude Code, Codex, OpenCode, Amp, or another Prose Complete host, you should interpret it -directly and embody the OpenProse VM. Do not assume there is a `prose` shell -binary on PATH. If a host does provide a native Prose CLI, the same command -strings may be passed to that CLI; otherwise the shell executable is the agent -runner, e.g. `claude -p "prose run foo.md"` or -`codex exec "prose run foo.md"`. +directly and embody the OpenProse VM. Do not run a `prose` shell binary or +`npx prose`; in wrapper hosts this recursively calls the wrapper instead of +executing the program. The shell executable is the agent runner, e.g. +`claude -p "prose run foo.md"` or `codex exec "prose run foo.md"`. | Command | Action | |---------|--------|