Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -66,10 +68,15 @@ Maintainers can find the release process in [RELEASE.md](RELEASE.md).

Select a harness with `--harness <name>` 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

Expand Down
2 changes: 1 addition & 1 deletion cli/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
4 changes: 2 additions & 2 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
86 changes: 82 additions & 4 deletions cli/src/commands/base.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -86,21 +88,33 @@ function isOclifExit(error: unknown): boolean {

export async function runForwardedProseCommand(options: ForwardRunOptions): Promise<number> {
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,
...(options.signal === undefined ? {} : { signal: options.signal }),
});
}

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;
Expand Down Expand Up @@ -174,6 +188,70 @@ export function splitHarnessArgs(
return { harness, args };
}

function resolveHarnessCwd(
cwd: string,
command: CommandName,
args: readonly string[],
env: Readonly<Record<string, string | undefined>>,
): 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<Record<string, string | undefined>>,
): 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<Record<string, string | undefined>>): 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[] {
Expand Down
62 changes: 57 additions & 5 deletions cli/src/harnesses/codex-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> | 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<string, string | undefined> | undefined,
): Pick<CodexThreadOptions, "approvalPolicy" | "sandboxMode"> {
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<string, string | undefined> | undefined) {
return codexEnvOption("PROSE_CODEX_SANDBOX_MODE", CODEX_SANDBOX_MODES, env) ?? DEFAULT_CODEX_SANDBOX_MODE;
}

function codexApprovalPolicy(env: Record<string, string | undefined> | undefined) {
return codexEnvOption("PROSE_CODEX_APPROVAL_POLICY", CODEX_APPROVAL_POLICIES, env) ?? DEFAULT_CODEX_APPROVAL_POLICY;
}

function additionalWritableDirectories(
sandboxMode: string | undefined,
env: Record<string, string | undefined> | 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<const T extends readonly string[]>(
name: string,
allowedValues: T,
Expand Down
3 changes: 2 additions & 1 deletion cli/src/harnesses/codex-sdk.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -60,6 +60,7 @@ function codexClientOptions(env: Record<string, string> | undefined) {

return {
...(apiKey === undefined ? {} : { apiKey }),
config: codexClientConfig(),
...(env === undefined ? {} : { env }),
};
}
Expand Down
2 changes: 1 addition & 1 deletion cli/src/harnesses/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export type ProcessRunner = (
) => Promise<ProcessRunResult>;

export type CodexThreadOptions = ThreadOptions;
export type CodexSdkClientOptions = Pick<CodexOptions, "apiKey" | "env">;
export type CodexSdkClientOptions = Pick<CodexOptions, "apiKey" | "config" | "env">;

export interface CodexThread {
runStreamed(prompt: CodexInput, options?: TurnOptions): Promise<RunStreamedResult>;
Expand Down
49 changes: 47 additions & 2 deletions cli/tests/cli/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading