@wrangled/cli 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.js +192 -0
- package/dist/brand.js +9 -0
- package/dist/files.js +66 -0
- package/dist/guide.js +221 -0
- package/dist/main.js +2151 -0
- package/dist/playbooks.js +512 -0
- package/dist/pull.js +209 -0
- package/dist/runner/claim.js +42 -0
- package/dist/runner/claude.js +87 -0
- package/dist/runner/codex.js +167 -0
- package/dist/runner/harness.js +65 -0
- package/dist/runner/harnesses.js +10 -0
- package/dist/runner/redact.js +56 -0
- package/dist/runner/run.js +204 -0
- package/dist/skill-zip.js +189 -0
- package/dist/skills.js +131 -0
- package/package.json +34 -0
package/dist/pull.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { mkdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { api, apiDownload, CliError, exitCodes } from "./api.js";
|
|
4
|
+
import { brand } from "./brand.js";
|
|
5
|
+
import { inRunFolder, writeFileAtomic } from "./files.js";
|
|
6
|
+
/** Kept in the pull folder: whose content it holds, which file each item went to, and where the next pull starts. */
|
|
7
|
+
export const PULL_STATE_FILE = `.${brand.slug}-content.json`;
|
|
8
|
+
export const PULL_MEDIA_DIR = "media";
|
|
9
|
+
const PAGE_SIZE = 200;
|
|
10
|
+
const MAX_STEM = 100;
|
|
11
|
+
// Windows refuses these as a file's name before its first dot, in any case.
|
|
12
|
+
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³])$/i;
|
|
13
|
+
/** A file name safe on macOS, Linux and Windows: no reserved characters or names, no trailing dot or space, at most `max` characters. */
|
|
14
|
+
export function safeFileName(text, max = MAX_STEM) {
|
|
15
|
+
const cleaned = text
|
|
16
|
+
.normalize("NFC")
|
|
17
|
+
.replace(/[<>:"/\\|?*\u0000-\u001f\u007f\s]+/g, "-")
|
|
18
|
+
.replace(/-{2,}/g, "-");
|
|
19
|
+
const trimmed = Array.from(cleaned.replace(/^[-. ]+/, ""))
|
|
20
|
+
.slice(0, max)
|
|
21
|
+
.join("")
|
|
22
|
+
.replace(/[-. ]+$/, "");
|
|
23
|
+
const name = trimmed || "item";
|
|
24
|
+
return WINDOWS_RESERVED.test(name.split(".")[0]) ? `_${name}` : name;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Gives each item its file, relative to the pull folder, recording it in `files`: <kind>/<SKU or natural key>.md. Once
|
|
28
|
+
* given, an item keeps its path. A name another item already has (compared without case, as macOS and Windows do)
|
|
29
|
+
* gets the item's id added.
|
|
30
|
+
*/
|
|
31
|
+
export function pathAssigner(files) {
|
|
32
|
+
const taken = new Set(Object.values(files).map((path) => path.toLowerCase()));
|
|
33
|
+
return (item) => {
|
|
34
|
+
const known = files[item.id];
|
|
35
|
+
if (known)
|
|
36
|
+
return known;
|
|
37
|
+
const dir = safeFileName(item.kind);
|
|
38
|
+
const plain = `${dir}/${safeFileName(item.sku ?? item.naturalKey)}.md`;
|
|
39
|
+
const path = taken.has(plain.toLowerCase()) ? `${dir}/${safeFileName(item.sku ?? item.naturalKey, MAX_STEM - 37)}-${item.id}.md` : plain;
|
|
40
|
+
files[item.id] = path;
|
|
41
|
+
taken.add(path.toLowerCase());
|
|
42
|
+
return path;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** JSON with every object's keys sorted, so the same value always prints the same. */
|
|
46
|
+
function sortedJson(value) {
|
|
47
|
+
const sort = (v) => Array.isArray(v)
|
|
48
|
+
? v.map(sort)
|
|
49
|
+
: v !== null && typeof v === "object"
|
|
50
|
+
? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sort(v[k])]))
|
|
51
|
+
: v;
|
|
52
|
+
return JSON.stringify(sort(value), null, 2);
|
|
53
|
+
}
|
|
54
|
+
function yamlLines(key, value, indent) {
|
|
55
|
+
const scalar = (v) => (typeof v === "string" ? JSON.stringify(v) : String(v));
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
if (value.length === 0)
|
|
58
|
+
return [`${indent}${key}: []`];
|
|
59
|
+
return [
|
|
60
|
+
`${indent}${key}:`,
|
|
61
|
+
...value.flatMap((entry) => {
|
|
62
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry))
|
|
63
|
+
return [`${indent} - ${scalar(entry)}`];
|
|
64
|
+
const lines = Object.entries(entry).flatMap(([k, v]) => yamlLines(k, v, `${indent} `));
|
|
65
|
+
return [`${indent} - ${lines[0].trimStart()}`, ...lines.slice(1)];
|
|
66
|
+
}),
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
if (value !== null && typeof value === "object") {
|
|
70
|
+
const entries = Object.entries(value);
|
|
71
|
+
if (entries.length === 0)
|
|
72
|
+
return [`${indent}${key}: {}`];
|
|
73
|
+
return [`${indent}${key}:`, ...entries.flatMap(([k, v]) => yamlLines(k, v, `${indent} `))];
|
|
74
|
+
}
|
|
75
|
+
return [`${indent}${key}: ${scalar(value)}`];
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* An item as a Markdown file: YAML front matter with what it is, its numbers and where its files are, then its words.
|
|
79
|
+
* Only what the item holds goes in, so the file changes only when the item does.
|
|
80
|
+
*/
|
|
81
|
+
export function renderItem(item) {
|
|
82
|
+
const front = {
|
|
83
|
+
id: item.id,
|
|
84
|
+
sku: item.sku,
|
|
85
|
+
natural_key: item.naturalKey,
|
|
86
|
+
kind: item.kind,
|
|
87
|
+
platform: item.platform,
|
|
88
|
+
origin: item.origin,
|
|
89
|
+
name: item.name,
|
|
90
|
+
status: item.status,
|
|
91
|
+
platform_status: item.platformStatus,
|
|
92
|
+
version: item.version,
|
|
93
|
+
captured_at: item.capturedAt,
|
|
94
|
+
removed_at: item.removedAt,
|
|
95
|
+
links: item.links.map((l) => ({ platform: l.platform, object_type: l.objectType, external_id: l.externalId })),
|
|
96
|
+
html: item.htmlPath,
|
|
97
|
+
media: item.media.map((m) => ({ path: m.path, role: m.role, content_type: m.contentType, bytes: m.bytes, sha256: m.sha256 })),
|
|
98
|
+
scorecards: [...item.scorecards]
|
|
99
|
+
.sort((a, b) => byText(a.currency ?? "", b.currency ?? ""))
|
|
100
|
+
.map((s) => ({ currency: s.currency, numbers: Object.fromEntries(Object.entries(s.numbers).sort(([a], [b]) => byText(a, b))) })),
|
|
101
|
+
};
|
|
102
|
+
const sections = [
|
|
103
|
+
["Subject", item.subject],
|
|
104
|
+
["Preview text", item.previewText],
|
|
105
|
+
["Headline", item.headline],
|
|
106
|
+
["Body", item.body || null],
|
|
107
|
+
["Fields", Object.keys(item.fields).length === 0 ? null : `\`\`\`json\n${sortedJson(item.fields)}\n\`\`\``],
|
|
108
|
+
...item.transcripts.map((t) => [`Transcript (file ${t.mediaPosition})`, t.text]),
|
|
109
|
+
];
|
|
110
|
+
const body = sections.flatMap(([title, text]) => (text === null ? [] : [`## ${title}\n\n${text.trimEnd()}\n`]));
|
|
111
|
+
return ["---", ...Object.entries(front).flatMap(([k, v]) => yamlLines(k, v, "")), "---", "", ...body.join("\n").split("\n")].join("\n").trimEnd() + "\n";
|
|
112
|
+
}
|
|
113
|
+
const byText = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
114
|
+
async function readState(path) {
|
|
115
|
+
const text = await readText(path);
|
|
116
|
+
if (text === null)
|
|
117
|
+
return null;
|
|
118
|
+
try {
|
|
119
|
+
const state = JSON.parse(text);
|
|
120
|
+
return typeof state.apiOrigin === "string" && typeof state.workspaceId === "string" && typeof state.files === "object" && state.files !== null
|
|
121
|
+
? { ...state, last: state.last ?? null }
|
|
122
|
+
: null;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function readText(path) {
|
|
129
|
+
return readFile(path, "utf8").catch((error) => {
|
|
130
|
+
if (error.code === "ENOENT")
|
|
131
|
+
return null;
|
|
132
|
+
throw error;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const exists = (path) => stat(path).then(() => true, (error) => {
|
|
136
|
+
if (error.code === "ENOENT")
|
|
137
|
+
return false;
|
|
138
|
+
throw error;
|
|
139
|
+
});
|
|
140
|
+
const sameFilters = (a, b) => a.kind === b.kind && a.platform === b.platform && a.origin === b.origin;
|
|
141
|
+
/**
|
|
142
|
+
* Copies the content library into `dir`, one Markdown file per item, fetching only what changed since the last pull
|
|
143
|
+
* into the same folder with the same filters (or everything, with `full`, or when `media` is asked for and the last
|
|
144
|
+
* pull saved none). A file is written only when its text changes. With `media`, each item's files and HTML are saved
|
|
145
|
+
* under media/ by their storage name, once. A folder holding another workspace's content is refused.
|
|
146
|
+
*/
|
|
147
|
+
export async function pullContent(connection, dir, filters, options) {
|
|
148
|
+
await inRunFolder(dir);
|
|
149
|
+
await mkdir(dir, { recursive: true });
|
|
150
|
+
const statePath = join(dir, PULL_STATE_FILE);
|
|
151
|
+
const state = await readState(statePath);
|
|
152
|
+
const apiOrigin = new URL(connection.apiUrl).origin;
|
|
153
|
+
const { workspace } = await api(connection, "/api/v1/me");
|
|
154
|
+
if (state && (state.apiOrigin !== apiOrigin || state.workspaceId !== workspace.id)) {
|
|
155
|
+
throw new CliError(`${dir} holds content pulled from another workspace (${state.workspaceId} at ${state.apiOrigin}), not ${workspace.slug} (${workspace.id} at ${apiOrigin}). Pull into another folder.`, exitCodes.error);
|
|
156
|
+
}
|
|
157
|
+
const last = options.full ? null : state?.last;
|
|
158
|
+
const changedSince = last && sameFilters(last.filters, filters) && (last.media || !options.media) ? last.watermark : undefined;
|
|
159
|
+
const files = { ...state?.files };
|
|
160
|
+
const assignPath = pathAssigner(files);
|
|
161
|
+
const saveState = (next) => writeFileAtomic(statePath, `${JSON.stringify({ apiOrigin, workspaceId: workspace.id, files, last: next }, null, 2)}\n`, 0o644);
|
|
162
|
+
const report = { dir, items: 0, written: [], unchanged: 0, removed: 0, media: 0, watermark: "" };
|
|
163
|
+
let cursor = null;
|
|
164
|
+
do {
|
|
165
|
+
const query = new URLSearchParams(Object.entries({ ...filters, changedSince, cursor: cursor ?? undefined, limit: String(PAGE_SIZE) }).filter((e) => e[1] !== undefined));
|
|
166
|
+
const page = await api(connection, `/api/v1/content/all?${query}`);
|
|
167
|
+
for (const item of page.items) {
|
|
168
|
+
const relative = assignPath(item);
|
|
169
|
+
const path = join(dir, relative);
|
|
170
|
+
const text = renderItem(item);
|
|
171
|
+
report.items++;
|
|
172
|
+
if (item.removedAt)
|
|
173
|
+
report.removed++;
|
|
174
|
+
if ((await readText(path)) === text)
|
|
175
|
+
report.unchanged++;
|
|
176
|
+
else {
|
|
177
|
+
await mkdir(join(dir, relative, ".."), { recursive: true });
|
|
178
|
+
await writeFileAtomic(path, text, 0o644);
|
|
179
|
+
report.written.push(relative);
|
|
180
|
+
}
|
|
181
|
+
if (options.media)
|
|
182
|
+
report.media += await saveMedia(connection, dir, item);
|
|
183
|
+
}
|
|
184
|
+
report.watermark = page.watermark;
|
|
185
|
+
cursor = page.nextCursor;
|
|
186
|
+
if (cursor)
|
|
187
|
+
await saveState(state?.last ?? null);
|
|
188
|
+
} while (cursor);
|
|
189
|
+
await saveState({ watermark: report.watermark, filters, media: options.media });
|
|
190
|
+
return report;
|
|
191
|
+
}
|
|
192
|
+
/** Where --media saves a stored file, relative to the pull folder: media/ and its storage path after the workspace id. */
|
|
193
|
+
export function mediaPath(assetPath) {
|
|
194
|
+
return [PULL_MEDIA_DIR, ...assetPath.split("/").slice(1).map((segment) => safeFileName(segment, 200))].join("/");
|
|
195
|
+
}
|
|
196
|
+
async function saveMedia(connection, dir, item) {
|
|
197
|
+
let saved = 0;
|
|
198
|
+
for (const assetPath of [...(item.htmlPath ? [item.htmlPath] : []), ...item.media.map((m) => m.path)]) {
|
|
199
|
+
const segments = assetPath.split("/");
|
|
200
|
+
const target = join(dir, mediaPath(assetPath));
|
|
201
|
+
if (await exists(target))
|
|
202
|
+
continue;
|
|
203
|
+
const { bytes } = await apiDownload(connection, `/api/v1/assets/${segments.map(encodeURIComponent).join("/")}`);
|
|
204
|
+
await mkdir(join(target, ".."), { recursive: true });
|
|
205
|
+
await writeFileAtomic(target, bytes, 0o644);
|
|
206
|
+
saved++;
|
|
207
|
+
}
|
|
208
|
+
return saved;
|
|
209
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { CliError, exitCodes } from "../api.js";
|
|
2
|
+
import { SKILL_NAME_PATTERN } from "../skill-zip.js";
|
|
3
|
+
export const harnessNames = ["claude", "codex"];
|
|
4
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
/** The claim response as the runner uses it. Never echoes the body: it holds the run key. */
|
|
6
|
+
export function parseClaim(body) {
|
|
7
|
+
const bad = (field) => new CliError(`The server's claim has no valid ${field}.`, exitCodes.error);
|
|
8
|
+
if (!isObject(body))
|
|
9
|
+
throw bad("body");
|
|
10
|
+
const { run, skill, caps } = body;
|
|
11
|
+
if (!isObject(run) || typeof run.id !== "string")
|
|
12
|
+
throw bad("run.id");
|
|
13
|
+
if (typeof body.runKey !== "string" || body.runKey.length === 0)
|
|
14
|
+
throw bad("runKey");
|
|
15
|
+
if (!isObject(skill) || typeof skill.name !== "string" || !SKILL_NAME_PATTERN.test(skill.name) || typeof skill.version !== "number" || typeof skill.sha256 !== "string" || typeof skill.url !== "string") {
|
|
16
|
+
throw bad("skill");
|
|
17
|
+
}
|
|
18
|
+
if (!harnessNames.includes(body.harness))
|
|
19
|
+
throw bad("harness");
|
|
20
|
+
if (typeof body.model !== "string" || body.model.length === 0)
|
|
21
|
+
throw bad("model");
|
|
22
|
+
if (typeof body.prompt !== "string")
|
|
23
|
+
throw bad("prompt");
|
|
24
|
+
if (!isObject(caps) || typeof caps.aiCents !== "number" || caps.aiCents < 0)
|
|
25
|
+
throw bad("caps.aiCents");
|
|
26
|
+
const deadline = typeof body.deadline === "string" ? new Date(body.deadline) : new Date(Number.NaN);
|
|
27
|
+
if (Number.isNaN(deadline.getTime()))
|
|
28
|
+
throw bad("deadline");
|
|
29
|
+
if (body.openRouterKey !== undefined && (typeof body.openRouterKey !== "string" || body.openRouterKey.length === 0))
|
|
30
|
+
throw bad("openRouterKey");
|
|
31
|
+
return {
|
|
32
|
+
runId: run.id,
|
|
33
|
+
runKey: body.runKey,
|
|
34
|
+
skill: { name: skill.name, version: skill.version, sha256: skill.sha256, url: skill.url },
|
|
35
|
+
harness: body.harness,
|
|
36
|
+
model: body.model,
|
|
37
|
+
prompt: body.prompt,
|
|
38
|
+
aiCapUsd: caps.aiCents / 100,
|
|
39
|
+
deadline,
|
|
40
|
+
billing: typeof body.openRouterKey === "string" ? { kind: "openrouter", key: body.openRouterKey } : { kind: "subscription" },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { brand } from "../brand.js";
|
|
2
|
+
import { harnessModel } from "./harness.js";
|
|
3
|
+
const OPENROUTER_ANTHROPIC_URL = "https://openrouter.ai/api";
|
|
4
|
+
/** File tools work inside the run folder only; the home folder (logins, keys) is off limits even if a path escapes. */
|
|
5
|
+
const FILE_TOOLS = ["Read(./**)", "Edit(./**)", "Write(./**)", "Glob", "Grep"];
|
|
6
|
+
const DENIED_FILES = ["Read(~/**)", "Edit(~/**)", "Write(~/**)"];
|
|
7
|
+
/**
|
|
8
|
+
* The Agent SDK options for a run. Only the run folder's settings load, only the run's skill is on, and the only shell
|
|
9
|
+
* commands allowed are the CLI's. When OpenRouter pays, every model variable points at the worker's model, so no
|
|
10
|
+
* background task runs on a cheaper one.
|
|
11
|
+
*/
|
|
12
|
+
export function claudeOptions(ctx) {
|
|
13
|
+
const model = harnessModel("claude", ctx.model, ctx.billing);
|
|
14
|
+
const gateway = ctx.billing.kind === "openrouter"
|
|
15
|
+
? {
|
|
16
|
+
ANTHROPIC_BASE_URL: OPENROUTER_ANTHROPIC_URL,
|
|
17
|
+
ANTHROPIC_AUTH_TOKEN: ctx.billing.key,
|
|
18
|
+
ANTHROPIC_API_KEY: "",
|
|
19
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: model,
|
|
20
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: model,
|
|
21
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
|
|
22
|
+
ANTHROPIC_SMALL_FAST_MODEL: model,
|
|
23
|
+
CLAUDE_CODE_SUBAGENT_MODEL: model,
|
|
24
|
+
}
|
|
25
|
+
: {};
|
|
26
|
+
return {
|
|
27
|
+
cwd: ctx.dir,
|
|
28
|
+
settingSources: ["project"],
|
|
29
|
+
skills: [ctx.skill],
|
|
30
|
+
permissionMode: "dontAsk",
|
|
31
|
+
allowedTools: [...FILE_TOOLS, `Bash(${brand.slug} *)`],
|
|
32
|
+
disallowedTools: DENIED_FILES,
|
|
33
|
+
maxTurns: ctx.maxTurns,
|
|
34
|
+
maxBudgetUsd: ctx.aiCapUsd,
|
|
35
|
+
model,
|
|
36
|
+
persistSession: false,
|
|
37
|
+
env: { ...ctx.env, CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1", ...gateway },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Token counts across every model the run used, and the SDK's cost estimate. */
|
|
41
|
+
export function claudeTokens(result) {
|
|
42
|
+
const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: result.total_cost_usd };
|
|
43
|
+
for (const usage of Object.values(result.modelUsage)) {
|
|
44
|
+
tokens.input += usage.inputTokens;
|
|
45
|
+
tokens.output += usage.outputTokens;
|
|
46
|
+
tokens.cacheRead += usage.cacheReadInputTokens;
|
|
47
|
+
tokens.cacheWrite += usage.cacheCreationInputTokens;
|
|
48
|
+
}
|
|
49
|
+
return tokens;
|
|
50
|
+
}
|
|
51
|
+
export const claudeHarness = async (ctx) => {
|
|
52
|
+
// Loaded only here, so starting the CLI never loads the SDK.
|
|
53
|
+
const { query } = await import("@anthropic-ai/claude-agent-sdk");
|
|
54
|
+
const abortController = new AbortController();
|
|
55
|
+
const abort = () => abortController.abort();
|
|
56
|
+
if (ctx.signal.aborted)
|
|
57
|
+
abort();
|
|
58
|
+
ctx.signal.addEventListener("abort", abort, { once: true });
|
|
59
|
+
let result;
|
|
60
|
+
try {
|
|
61
|
+
const messages = query({ prompt: ctx.prompt, options: { ...claudeOptions(ctx), abortController, stderr: ctx.log } });
|
|
62
|
+
for await (const message of messages) {
|
|
63
|
+
logMessage(message, ctx.log);
|
|
64
|
+
if (message.type === "result")
|
|
65
|
+
result = message;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
ctx.signal.removeEventListener("abort", abort);
|
|
70
|
+
}
|
|
71
|
+
if (!result)
|
|
72
|
+
return { exitCode: 1 };
|
|
73
|
+
return { exitCode: result.subtype === "success" && !result.is_error ? 0 : 1, tokens: claudeTokens(result) };
|
|
74
|
+
};
|
|
75
|
+
function logMessage(message, log) {
|
|
76
|
+
if (message.type === "assistant") {
|
|
77
|
+
for (const block of message.message.content) {
|
|
78
|
+
if (block.type === "text")
|
|
79
|
+
log(`${block.text}\n`);
|
|
80
|
+
else if (block.type === "tool_use")
|
|
81
|
+
log(`[${block.name}] ${JSON.stringify(block.input)}\n`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (message.type === "result") {
|
|
85
|
+
log(`[result ${message.subtype}] ${message.subtype === "success" ? message.result : message.errors.join("; ")}\n`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { copyFile, lstat, mkdir, rename, symlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { CliError, exitCodes } from "../api.js";
|
|
9
|
+
import { harnessModel, runProcess } from "./harness.js";
|
|
10
|
+
const OPENROUTER_URL = "https://openrouter.ai/api/v1";
|
|
11
|
+
export const OPENROUTER_KEY_ENV = "OPENROUTER_API_KEY";
|
|
12
|
+
export const PERMISSION_PROFILE = "run";
|
|
13
|
+
/**
|
|
14
|
+
* The oldest Codex the run config was checked against: its permission profile, `--strict-config` and the feature
|
|
15
|
+
* names below. An older Codex may read the config differently, so the runner refuses it.
|
|
16
|
+
*/
|
|
17
|
+
export const MIN_CODEX_VERSION = "0.155.0";
|
|
18
|
+
/** Codex tools a run never needs; with these off, `codex exec` offers only its shell and an ask-the-user tool. */
|
|
19
|
+
const FEATURES_OFF = [
|
|
20
|
+
"view_image",
|
|
21
|
+
"multi_agent",
|
|
22
|
+
"goals",
|
|
23
|
+
"apps",
|
|
24
|
+
"plugins",
|
|
25
|
+
"remote_plugin",
|
|
26
|
+
"browser_use",
|
|
27
|
+
"browser_use_external",
|
|
28
|
+
"computer_use",
|
|
29
|
+
"image_generation",
|
|
30
|
+
"memories",
|
|
31
|
+
"in_app_browser",
|
|
32
|
+
"skill_mcp_dependency_install",
|
|
33
|
+
"tool_suggest",
|
|
34
|
+
"hooks",
|
|
35
|
+
];
|
|
36
|
+
const tomlString = (value) => JSON.stringify(value);
|
|
37
|
+
/**
|
|
38
|
+
* The run's own config.toml. Commands Codex runs may write only in the run folder and read only that, the system's
|
|
39
|
+
* basics and `readableRoots`, and never `unreadable`, even inside the run folder; the codex process itself still reads
|
|
40
|
+
* its login. The network stays on, which the CLI's
|
|
41
|
+
* calls need. Nothing ever waits for an approval. No `sandbox_mode`: it would override the permission profile.
|
|
42
|
+
*/
|
|
43
|
+
export function codexConfig({ model, billing, readableRoots, unreadable, }) {
|
|
44
|
+
const lines = [
|
|
45
|
+
`model = ${tomlString(harnessModel("codex", model, billing))}`,
|
|
46
|
+
`approval_policy = "never"`,
|
|
47
|
+
`web_search = "disabled"`,
|
|
48
|
+
`default_permissions = ${tomlString(PERMISSION_PROFILE)}`,
|
|
49
|
+
];
|
|
50
|
+
if (billing.kind === "openrouter")
|
|
51
|
+
lines.push(`model_provider = "openrouter"`);
|
|
52
|
+
lines.push("", `[permissions.${PERMISSION_PROFILE}.filesystem]`, `":minimal" = "read"`, `":workspace_roots" = "write"`, ...readableRoots.map((root) => `${tomlString(root)} = "read"`), ...unreadable.map((path) => `${tomlString(path)} = "deny"`), "", `[permissions.${PERMISSION_PROFILE}.network]`, "enabled = true", "", "[features]", ...FEATURES_OFF.map((feature) => `${feature} = false`), "", "[shell_environment_policy]", `exclude = [${tomlString(OPENROUTER_KEY_ENV)}]`);
|
|
53
|
+
if (billing.kind === "openrouter") {
|
|
54
|
+
lines.push("", "[model_providers.openrouter]", `name = "OpenRouter"`, `base_url = ${tomlString(OPENROUTER_URL)}`, `env_key = ${tomlString(OPENROUTER_KEY_ENV)}`, `wire_api = "responses"`);
|
|
55
|
+
}
|
|
56
|
+
return `${lines.join("\n")}\n`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* What a run's commands may read besides the run folder and the system's basics: this Node's install, this CLI's
|
|
60
|
+
* package and every `node_modules` above it that Node would load its dependencies from (the run's `wr` needs all of
|
|
61
|
+
* them), and on macOS the certificates Node loads. Only those that exist.
|
|
62
|
+
*/
|
|
63
|
+
export function readableRoots() {
|
|
64
|
+
const cliRoot = fileURLToPath(new URL("../../", import.meta.url)).replace(/\/$/, "");
|
|
65
|
+
const roots = [dirname(dirname(process.execPath)), cliRoot];
|
|
66
|
+
for (let dir = dirname(cliRoot); dir !== dirname(dir); dir = dirname(dir))
|
|
67
|
+
roots.push(join(dir, "node_modules"));
|
|
68
|
+
if (process.platform === "darwin")
|
|
69
|
+
roots.push("/System/Library/OpenSSL");
|
|
70
|
+
return [...new Set(roots)].filter((root) => existsSync(root));
|
|
71
|
+
}
|
|
72
|
+
/** Why `codex --version` output is not good enough to run, or undefined when it is. */
|
|
73
|
+
export function codexVersionProblem(versionOutput) {
|
|
74
|
+
const found = /codex-cli (\d+)\.(\d+)\.(\d+)/.exec(versionOutput);
|
|
75
|
+
const minimum = MIN_CODEX_VERSION.split(".").map(Number);
|
|
76
|
+
const update = "Update it with `codex update` or `npm i -g @openai/codex`.";
|
|
77
|
+
if (!found)
|
|
78
|
+
return `Could not read the Codex version from \`codex --version\` (${versionOutput.trim()}); the runner needs ${MIN_CODEX_VERSION} or later. ${update}`;
|
|
79
|
+
const version = found.slice(1).map(Number);
|
|
80
|
+
const older = version.findIndex((part, i) => part !== minimum[i]);
|
|
81
|
+
if (older === -1 || version[older] > minimum[older])
|
|
82
|
+
return undefined;
|
|
83
|
+
return `Codex ${version.join(".")} is older than ${MIN_CODEX_VERSION}, the oldest the runner's sandbox rules were checked against. ${update}`;
|
|
84
|
+
}
|
|
85
|
+
/** Sums the token counts of every `usage` object in `codex exec --json` output lines. */
|
|
86
|
+
export function codexTokens(lines) {
|
|
87
|
+
const names = {
|
|
88
|
+
input_tokens: "input",
|
|
89
|
+
cached_input_tokens: "cachedInput",
|
|
90
|
+
output_tokens: "output",
|
|
91
|
+
reasoning_output_tokens: "reasoningOutput",
|
|
92
|
+
};
|
|
93
|
+
const tokens = {};
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
let event;
|
|
96
|
+
try {
|
|
97
|
+
event = JSON.parse(line);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
for (const [field, value] of Object.entries(event?.usage ?? {})) {
|
|
103
|
+
const name = names[field];
|
|
104
|
+
if (name && typeof value === "number" && value >= 0)
|
|
105
|
+
tokens[name] = (tokens[name] ?? 0) + value;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return Object.keys(tokens).length > 0 ? tokens : undefined;
|
|
109
|
+
}
|
|
110
|
+
/** The runner's own Codex home, where its ChatGPT login lives. */
|
|
111
|
+
const ownCodexHome = () => process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
112
|
+
export const codexHarness = async (ctx) => {
|
|
113
|
+
const { stdout } = await promisify(execFile)("codex", ["--version"], { env: ctx.env }).catch((error) => {
|
|
114
|
+
if (error.code === "ENOENT")
|
|
115
|
+
throw new CliError("Codex is not installed on this machine: no `codex` on PATH. Install it with `npm i -g @openai/codex`.", exitCodes.error);
|
|
116
|
+
throw error;
|
|
117
|
+
});
|
|
118
|
+
const versionProblem = codexVersionProblem(stdout);
|
|
119
|
+
if (versionProblem)
|
|
120
|
+
throw new CliError(versionProblem, exitCodes.error);
|
|
121
|
+
const codexHome = join(ctx.dir, ".codex");
|
|
122
|
+
await mkdir(codexHome, { recursive: true });
|
|
123
|
+
const runAuth = join(codexHome, "auth.json");
|
|
124
|
+
// Denied by path, so a refreshed login Codex writes there as a real file is as unreadable as the link.
|
|
125
|
+
await writeFile(join(codexHome, "config.toml"), codexConfig({ ...ctx, readableRoots: readableRoots(), unreadable: [runAuth] }), { mode: 0o600 });
|
|
126
|
+
// The user's own temp folder is outside what commands may read, so the run gets one inside its folder.
|
|
127
|
+
const tmp = join(ctx.dir, ".tmp");
|
|
128
|
+
await mkdir(tmp);
|
|
129
|
+
const env = { ...ctx.env, CODEX_HOME: codexHome, TMPDIR: tmp };
|
|
130
|
+
const ownAuth = join(ownCodexHome(), "auth.json");
|
|
131
|
+
if (ctx.billing.kind === "openrouter")
|
|
132
|
+
env[OPENROUTER_KEY_ENV] = ctx.billing.key;
|
|
133
|
+
else {
|
|
134
|
+
// A link, so the login and any refresh of it stay in the runner's own Codex home.
|
|
135
|
+
await lstat(ownAuth).catch(() => {
|
|
136
|
+
throw new CliError(`Codex is not logged in on this machine: no ${ownAuth}. Run \`codex login\`.`, exitCodes.auth);
|
|
137
|
+
});
|
|
138
|
+
await symlink(ownAuth, runAuth);
|
|
139
|
+
}
|
|
140
|
+
const lines = [];
|
|
141
|
+
let partial = "";
|
|
142
|
+
const exitCode = await runProcess("codex", ["exec", "--json", "--strict-config", "--skip-git-repo-check", "-C", ctx.dir, "-"], {
|
|
143
|
+
cwd: ctx.dir,
|
|
144
|
+
env,
|
|
145
|
+
stdin: ctx.prompt,
|
|
146
|
+
signal: ctx.signal,
|
|
147
|
+
log: ctx.log,
|
|
148
|
+
onStdout(text) {
|
|
149
|
+
ctx.log(text);
|
|
150
|
+
const split = (partial + text).split("\n");
|
|
151
|
+
partial = split.pop();
|
|
152
|
+
lines.push(...split);
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
if (ctx.billing.kind === "subscription")
|
|
156
|
+
await keepRefreshedLogin(runAuth, ownAuth);
|
|
157
|
+
return { exitCode, tokens: codexTokens([...lines, partial]) };
|
|
158
|
+
};
|
|
159
|
+
/** If Codex replaced the link with a refreshed login, the refresh goes back to the runner's own home, not away with the run folder. */
|
|
160
|
+
async function keepRefreshedLogin(runAuth, ownAuth) {
|
|
161
|
+
const info = await lstat(runAuth).catch(() => null);
|
|
162
|
+
if (!info || info.isSymbolicLink())
|
|
163
|
+
return;
|
|
164
|
+
const staged = `${ownAuth}.${process.pid}.tmp`;
|
|
165
|
+
await copyFile(runAuth, staged);
|
|
166
|
+
await rename(staged, ownAuth);
|
|
167
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { constants } from "node:os";
|
|
3
|
+
export const MAX_TURNS = 150;
|
|
4
|
+
const KILL_GRACE_MS = 5_000;
|
|
5
|
+
/**
|
|
6
|
+
* The id the harness's own login knows the model by. The Claude subscription takes `claude-sonnet-5-5` for
|
|
7
|
+
* `anthropic/claude-sonnet-5.5`; Codex takes `gpt-5.5` for `openai/gpt-5.5`.
|
|
8
|
+
*/
|
|
9
|
+
export function nativeModel(harness, model) {
|
|
10
|
+
if (harness === "claude")
|
|
11
|
+
return model.replace(/^anthropic\//, "").replaceAll(".", "-");
|
|
12
|
+
return model.replace(/^openai\//, "");
|
|
13
|
+
}
|
|
14
|
+
/** The model id the harness is started with: OpenRouter's own id when OpenRouter pays, else the native one. */
|
|
15
|
+
export function harnessModel(harness, model, billing) {
|
|
16
|
+
return billing.kind === "openrouter" ? model : nativeModel(harness, model);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Runs a program in its own process group with `stdin` as its input and stdout and stderr into `log`. On abort the
|
|
20
|
+
* whole group gets SIGTERM, then SIGKILL; once the program exits, anything it left running in its group is killed too.
|
|
21
|
+
*/
|
|
22
|
+
export function runProcess(command, args, options) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const child = spawn(command, args, { cwd: options.cwd, env: options.env, detached: true });
|
|
25
|
+
const killGroup = (signal) => {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(-child.pid, signal);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// The group is already gone.
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
let killTimer;
|
|
34
|
+
const onAbort = () => {
|
|
35
|
+
killGroup("SIGTERM");
|
|
36
|
+
killTimer = setTimeout(() => killGroup("SIGKILL"), KILL_GRACE_MS);
|
|
37
|
+
};
|
|
38
|
+
let exitCode = 1;
|
|
39
|
+
child.on("error", (error) => {
|
|
40
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
41
|
+
reject(error);
|
|
42
|
+
});
|
|
43
|
+
child.on("spawn", () => {
|
|
44
|
+
if (options.signal.aborted)
|
|
45
|
+
onAbort();
|
|
46
|
+
else
|
|
47
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
48
|
+
});
|
|
49
|
+
child.stdout.setEncoding("utf8").on("data", (text) => (options.onStdout ?? options.log)(text));
|
|
50
|
+
child.stderr.setEncoding("utf8").on("data", options.log);
|
|
51
|
+
child.stdin.on("error", () => {
|
|
52
|
+
// A program that never reads its input closes the pipe; that is not a failure of the run.
|
|
53
|
+
});
|
|
54
|
+
child.stdin.end(options.stdin);
|
|
55
|
+
child.on("exit", (code, signal) => {
|
|
56
|
+
exitCode = code ?? 128 + (signal ? constants.signals[signal] : 0);
|
|
57
|
+
killGroup("SIGKILL");
|
|
58
|
+
});
|
|
59
|
+
child.on("close", () => {
|
|
60
|
+
clearTimeout(killTimer);
|
|
61
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
62
|
+
resolve(exitCode);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { claudeHarness } from "./claude.js";
|
|
2
|
+
import { codexHarness } from "./codex.js";
|
|
3
|
+
import { runProcess } from "./harness.js";
|
|
4
|
+
export const harnesses = { claude: claudeHarness, codex: codexHarness };
|
|
5
|
+
/** Runs `command` in place of the coding agent: the prompt on stdin, its output into the log. Tests use it as a fake. */
|
|
6
|
+
export function commandHarness(command) {
|
|
7
|
+
return async (ctx) => ({
|
|
8
|
+
exitCode: await runProcess(command, [], { cwd: ctx.dir, env: ctx.env, stdin: ctx.prompt, signal: ctx.signal, log: ctx.log }),
|
|
9
|
+
});
|
|
10
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { brand } from "../brand.js";
|
|
2
|
+
// Mirrors the server's redactRunOutput (src/lib/workers/runner-protocol.ts), so nothing secret leaves the runner even
|
|
3
|
+
// before the server redacts again. src/lib/cli-consistency.test.ts checks the two agree.
|
|
4
|
+
export const REDACTED = "[redacted]";
|
|
5
|
+
export const LOG_TAIL_MAX_BYTES = 20 * 1024;
|
|
6
|
+
export const RUN_ERROR_MAX_BYTES = 4 * 1024;
|
|
7
|
+
const MIN_SECRET_LENGTH = 8;
|
|
8
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9
|
+
const KEY_LIKE = new RegExp(`(?<![A-Za-z0-9])(?:${[brand.apiKeyPrefix, ...brand.legacyApiKeyPrefixes, brand.runnerTokenPrefix].map(escapeRegExp).join("|")})[A-Za-z0-9_-]{16,}`, "g");
|
|
10
|
+
const SECRET_PARAM = /\b(access_token|token|api_key|apikey|key|secret|signature|sig|password)=[^&\s"']+/gi;
|
|
11
|
+
const BEARER = /\b(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
|
|
12
|
+
const PROVIDER_KEY = /(?<![A-Za-z0-9])sk-[A-Za-z0-9_-]{16,}/g;
|
|
13
|
+
const CREDENTIAL_ASSIGNMENT = /(["']?)([A-Za-z0-9_.-]*(?:key|token|secret|password|passwd|authorization|credential)[A-Za-z0-9_.-]*)\1(\s*[:=]\s*)(["']?)([^\s"'&,;}]{8,})/gi;
|
|
14
|
+
/** Known secrets, credential parameters, key-shaped strings, bearer tokens and credential-named values replaced. */
|
|
15
|
+
export function redact(text, secrets) {
|
|
16
|
+
let out = text;
|
|
17
|
+
for (const secret of secrets) {
|
|
18
|
+
if (secret.length >= MIN_SECRET_LENGTH)
|
|
19
|
+
out = out.replaceAll(secret, REDACTED);
|
|
20
|
+
}
|
|
21
|
+
return out
|
|
22
|
+
.replace(SECRET_PARAM, `$1=${REDACTED}`)
|
|
23
|
+
.replace(KEY_LIKE, REDACTED)
|
|
24
|
+
.replace(PROVIDER_KEY, REDACTED)
|
|
25
|
+
.replace(BEARER, `$1${REDACTED}`)
|
|
26
|
+
.replace(CREDENTIAL_ASSIGNMENT, (match, quote, name, separator, valueQuote, value) => /^\d+$/.test(value) ? match : `${quote}${name}${quote}${separator}${valueQuote}${REDACTED}`);
|
|
27
|
+
}
|
|
28
|
+
/** The end of `text` that fits in `maxBytes` of UTF-8, never starting mid-character. */
|
|
29
|
+
export function tailBytes(text, maxBytes) {
|
|
30
|
+
const bytes = Buffer.from(text, "utf8");
|
|
31
|
+
if (bytes.length <= maxBytes)
|
|
32
|
+
return text;
|
|
33
|
+
let start = bytes.length - maxBytes;
|
|
34
|
+
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80)
|
|
35
|
+
start++;
|
|
36
|
+
return bytes.subarray(start).toString("utf8");
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The last output of a run. It keeps more than it reports, so a secret cut in half at the start of what it keeps is
|
|
40
|
+
* outside the reported tail, which is redacted as a whole.
|
|
41
|
+
*/
|
|
42
|
+
export class LogTail {
|
|
43
|
+
maxBytes;
|
|
44
|
+
#text = "";
|
|
45
|
+
constructor(maxBytes = LOG_TAIL_MAX_BYTES) {
|
|
46
|
+
this.maxBytes = maxBytes;
|
|
47
|
+
}
|
|
48
|
+
append(chunk) {
|
|
49
|
+
this.#text += chunk;
|
|
50
|
+
if (this.#text.length > 4 * this.maxBytes)
|
|
51
|
+
this.#text = tailBytes(this.#text, 2 * this.maxBytes);
|
|
52
|
+
}
|
|
53
|
+
report(secrets) {
|
|
54
|
+
return tailBytes(redact(this.#text, secrets), this.maxBytes);
|
|
55
|
+
}
|
|
56
|
+
}
|