cowork-harness 0.1.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/.env.example +16 -0
- package/CHANGELOG.md +190 -0
- package/LICENSE +21 -0
- package/README.md +470 -0
- package/baselines/desktop-1.11847.5.json +78 -0
- package/baselines/desktop-1.12603.1.json +140 -0
- package/baselines/prompts/desktop-1.12603.1/host-loop-append.md +8 -0
- package/baselines/prompts/desktop-1.12603.1/subagent-append-vm.md +3 -0
- package/baselines/prompts/desktop-1.12603.1/system-prompt-append.md +18 -0
- package/dist/agent/session.js +465 -0
- package/dist/assert.js +159 -0
- package/dist/baseline.js +87 -0
- package/dist/boundary.js +114 -0
- package/dist/canary/grants.js +37 -0
- package/dist/cli.js +1107 -0
- package/dist/decide/decider.js +521 -0
- package/dist/decide/external-channel.js +262 -0
- package/dist/decide/llm-transport.js +52 -0
- package/dist/dotenv.js +52 -0
- package/dist/egress/proxy.js +138 -0
- package/dist/egress/sidecar.js +125 -0
- package/dist/hostloop/provenance.js +110 -0
- package/dist/hostloop/workspace-handler.js +226 -0
- package/dist/loop-decision.js +62 -0
- package/dist/prompt.js +43 -0
- package/dist/run/cassette.js +420 -0
- package/dist/run/chat.js +194 -0
- package/dist/run/envelope.js +31 -0
- package/dist/run/execute.js +533 -0
- package/dist/run/renderer.js +179 -0
- package/dist/run/run.js +347 -0
- package/dist/run/trace-view.js +227 -0
- package/dist/runtime/argv.js +126 -0
- package/dist/runtime/container.js +76 -0
- package/dist/runtime/host-env.js +28 -0
- package/dist/runtime/hostloop.js +129 -0
- package/dist/runtime/lima.js +177 -0
- package/dist/runtime/microvm.js +151 -0
- package/dist/runtime/protocol.js +79 -0
- package/dist/runtime/stage.js +52 -0
- package/dist/secrets.js +42 -0
- package/dist/session.js +315 -0
- package/dist/sync/cowork-sync.js +215 -0
- package/dist/types.js +127 -0
- package/docker/Dockerfile.agent +31 -0
- package/docker/Dockerfile.proxy +12 -0
- package/docker/compose.yml +31 -0
- package/fixtures/subagent-grants.json +5 -0
- package/package.json +70 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #30 — web_fetch URL provenance. Faithful port of Cowork's per-session `webFetchAllowedUrls` set
|
|
3
|
+
* + URL extractors (binary-verified, app.asar 1.12603.1):
|
|
4
|
+
* - normalize: `zG(A){ new URL(A); http/https only; hash=""; strip one trailing slash; → .href }`
|
|
5
|
+
* - extract: `Ien` over gen=/https?:\/\/…/ , len=/www\.…/ , uen=/bare domain.tld/, each ZHA-trimmed
|
|
6
|
+
* - membership: `set.has(zG(url))` (exact, normalized)
|
|
7
|
+
*
|
|
8
|
+
* The harness has no WebSearch tool, so the WebSearch-result seed path (`Een`) is N/A; we seed from
|
|
9
|
+
* user-turn text and tool-result text — the faithful subset for this harness (see the plan).
|
|
10
|
+
*/
|
|
11
|
+
/** Port of `zG`: parse + http/https-only + drop fragment + strip one trailing slash → normalized href. */
|
|
12
|
+
export function normalizeUrl(raw) {
|
|
13
|
+
let u;
|
|
14
|
+
try {
|
|
15
|
+
u = new URL(raw);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (u.protocol !== "http:" && u.protocol !== "https:")
|
|
21
|
+
return null;
|
|
22
|
+
u.hash = "";
|
|
23
|
+
if (u.pathname !== "/" && u.pathname.endsWith("/"))
|
|
24
|
+
u.pathname = u.pathname.slice(0, -1);
|
|
25
|
+
return u.href;
|
|
26
|
+
}
|
|
27
|
+
const GEN = /https?:\/\/[^\s<>"'`]+/g; // full URLs
|
|
28
|
+
const LEN = /www\.[^\s<>"'`]+/g; // www.-prefixed bare hosts
|
|
29
|
+
// bare `domain.tld(/path)?` at a token boundary (conservative port of `uen`)
|
|
30
|
+
const UEN = /(?<![\w@.])(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(?:\/[^\s<>"'`]*)?/g;
|
|
31
|
+
const TRAIL = new Set([".", ",", ";", ":", "!", "?", "'", '"', "`"]);
|
|
32
|
+
const CLOSERS = { ")": "(", "]": "[", "}": "{" };
|
|
33
|
+
/** Port of `ZHA`: strip trailing punctuation; drop an unbalanced trailing closing bracket. */
|
|
34
|
+
function trimMatch(m) {
|
|
35
|
+
let e = m.length;
|
|
36
|
+
while (e > 0) {
|
|
37
|
+
const ch = m[e - 1];
|
|
38
|
+
if (TRAIL.has(ch)) {
|
|
39
|
+
e--;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const open = CLOSERS[ch];
|
|
43
|
+
if (open) {
|
|
44
|
+
const seg = m.slice(0, e);
|
|
45
|
+
const closes = seg.split(ch).length - 1;
|
|
46
|
+
const opens = seg.split(open).length - 1;
|
|
47
|
+
if (closes > opens) {
|
|
48
|
+
e--;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
return m.slice(0, e);
|
|
55
|
+
}
|
|
56
|
+
/** Port of `Ien`: extract+normalize all URL-shaped tokens from free text. */
|
|
57
|
+
export function extractUrls(text) {
|
|
58
|
+
const out = [];
|
|
59
|
+
const push = (s) => {
|
|
60
|
+
if (s)
|
|
61
|
+
out.push(s);
|
|
62
|
+
};
|
|
63
|
+
for (const m of text.matchAll(GEN))
|
|
64
|
+
push(normalizeUrl(trimMatch(m[0])));
|
|
65
|
+
for (const m of text.matchAll(LEN))
|
|
66
|
+
push(normalizeUrl(`https://${trimMatch(m[0])}`));
|
|
67
|
+
for (const m of text.matchAll(UEN))
|
|
68
|
+
push(normalizeUrl(`https://${trimMatch(m[0])}`));
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
/** Per-session provenance set (mirrors Cowork's `session.webFetchAllowedUrls`). */
|
|
72
|
+
export class ProvenanceTracker {
|
|
73
|
+
seen = new Set();
|
|
74
|
+
/** Membership on the normalized URL (the `set.has(zG(url))` check). */
|
|
75
|
+
has(url) {
|
|
76
|
+
const n = normalizeUrl(url);
|
|
77
|
+
return n !== null && this.seen.has(n);
|
|
78
|
+
}
|
|
79
|
+
/** Add the normalized URL (after approval, or when seeded). */
|
|
80
|
+
add(url) {
|
|
81
|
+
const n = normalizeUrl(url);
|
|
82
|
+
if (n)
|
|
83
|
+
this.seen.add(n);
|
|
84
|
+
}
|
|
85
|
+
/** Seed from user-turn / tool-result text; returns how many NEW URLs were added. */
|
|
86
|
+
seedFromText(text) {
|
|
87
|
+
let added = 0;
|
|
88
|
+
for (const u of extractUrls(text)) {
|
|
89
|
+
if (!this.seen.has(u)) {
|
|
90
|
+
this.seen.add(u);
|
|
91
|
+
added++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return added;
|
|
95
|
+
}
|
|
96
|
+
/** Tool results are seeded the same way (the harness has no structured WebSearch results). */
|
|
97
|
+
seedFromToolResult(text) {
|
|
98
|
+
return this.seedFromText(text);
|
|
99
|
+
}
|
|
100
|
+
/** Serialize for session persistence (mirrors Cowork's save/load), if ever needed. */
|
|
101
|
+
snapshot() {
|
|
102
|
+
return [...this.seen];
|
|
103
|
+
}
|
|
104
|
+
static restore(urls) {
|
|
105
|
+
const t = new ProvenanceTracker();
|
|
106
|
+
for (const u of urls)
|
|
107
|
+
t.add(u);
|
|
108
|
+
return t;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { compile } from "../egress/proxy.js";
|
|
4
|
+
const pexec = promisify(execFile);
|
|
5
|
+
const MAX_REDIRECTS = 5; // Cowork's RZe redirect cap (Path B re-checks U1t per hop)
|
|
6
|
+
/** Port of Cowork's `XwA`: is the host a local / private / link-local address (SSRF backstop)? */
|
|
7
|
+
export function isLocalOrPrivate(host) {
|
|
8
|
+
const h = host.toLowerCase().replace(/^\[|\]$/g, "");
|
|
9
|
+
if (h === "localhost" || h.endsWith(".localhost") || h.endsWith(".local"))
|
|
10
|
+
return true;
|
|
11
|
+
if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd"))
|
|
12
|
+
return true; // IPv6 loopback/ULA/link-local
|
|
13
|
+
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
14
|
+
if (m) {
|
|
15
|
+
const a = Number(m[1]);
|
|
16
|
+
const b = Number(m[2]);
|
|
17
|
+
if (a === 0 || a === 127 || a === 10)
|
|
18
|
+
return true; // this-host / loopback / private
|
|
19
|
+
if (a === 192 && b === 168)
|
|
20
|
+
return true; // private
|
|
21
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
22
|
+
return true; // private
|
|
23
|
+
if (a === 169 && b === 254)
|
|
24
|
+
return true; // link-local (incl. cloud metadata 169.254.169.254)
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
/** Port of Cowork's `U1t` (Path B domain gate): scheme + private-address + the egress domain allowlist
|
|
29
|
+
* (the SAME `wen()`/`compile()` matcher the container egress uses). Returns a deny reason, or null = allow. */
|
|
30
|
+
export function u1t(u, allow, matcher) {
|
|
31
|
+
if (u.protocol !== "http:" && u.protocol !== "https:")
|
|
32
|
+
return `URL scheme "${u.protocol}" is not allowed. Use http or https.`;
|
|
33
|
+
if (isLocalOrPrivate(u.hostname))
|
|
34
|
+
return `Host "${u.hostname}" is a local or private address.`;
|
|
35
|
+
if (!allow.length)
|
|
36
|
+
return "No network allowlist is configured for this session. The web_fetch tool is disabled.";
|
|
37
|
+
if (!matcher(u.hostname))
|
|
38
|
+
return `Web fetch was not allowed: ${u.hostname} is not in the session web-fetch allowlist. Ask to add this domain.`;
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const defaultRawFetch = async (url) => {
|
|
42
|
+
const r = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(30000) });
|
|
43
|
+
return { status: r.status, location: r.headers.get("location") ?? undefined, text: () => r.text() };
|
|
44
|
+
};
|
|
45
|
+
const BASH_DESC = "Run a shell command in the session's isolated Linux workspace. Your connected folders are mounted under {{mnt}}/ — the Shell access section of your system prompt lists the exact path for each folder. Each bash call is independent (no cwd/env carryover). Use absolute paths.";
|
|
46
|
+
const FETCH_DESC = "Fetch a URL from the session network (subject to the egress allowlist). web_fetch can only retrieve URLs that appeared in a user message or a prior result.";
|
|
47
|
+
export function makeWorkspaceHandler(containerName, vmMnt, runner = "docker", webFetchAllow = ["*"], onEgress, provenanceRef, // #30: Run fills this before the stream starts
|
|
48
|
+
rawFetch = defaultRawFetch) {
|
|
49
|
+
// Per-handler (per-spawn) latch for the provenance-unenforced warning — was module-level, which
|
|
50
|
+
// silenced the gap after the first run in a long-lived process. Each fresh handler warns once.
|
|
51
|
+
const provWarned = { value: false };
|
|
52
|
+
const tools = [
|
|
53
|
+
{
|
|
54
|
+
name: "bash",
|
|
55
|
+
description: BASH_DESC.replace("{{mnt}}", vmMnt),
|
|
56
|
+
inputSchema: { type: "object", properties: { command: { type: "string" }, timeout_ms: { type: "number" } }, required: ["command"] },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "web_fetch",
|
|
60
|
+
description: FETCH_DESC,
|
|
61
|
+
inputSchema: { type: "object", properties: { url: { type: "string" } }, required: ["url"] },
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
return async (server, jr) => {
|
|
65
|
+
const method = jr.method;
|
|
66
|
+
if (method === "initialize")
|
|
67
|
+
return {
|
|
68
|
+
result: {
|
|
69
|
+
protocolVersion: (jr.params && jr.params.protocolVersion) || "2025-06-18",
|
|
70
|
+
capabilities: { tools: {} },
|
|
71
|
+
serverInfo: { name: "workspace", version: "1.0.0" },
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
if (method === "tools/list")
|
|
75
|
+
return { result: { tools } };
|
|
76
|
+
if (method === "tools/call") {
|
|
77
|
+
const name = jr.params?.name;
|
|
78
|
+
const a = jr.params?.arguments ?? {};
|
|
79
|
+
if (name === "bash")
|
|
80
|
+
return { result: await execInContainer(runner, containerName, vmMnt, String(a.command ?? ""), clampTimeout(a.timeout_ms)) };
|
|
81
|
+
if (name === "web_fetch")
|
|
82
|
+
return {
|
|
83
|
+
result: await fetchViaHost(String(a.url ?? ""), webFetchAllow, onEgress, provenanceRef?.current, provWarned, rawFetch),
|
|
84
|
+
};
|
|
85
|
+
return { error: { code: -32602, message: `unknown tool: ${name}` } };
|
|
86
|
+
}
|
|
87
|
+
return { result: {} }; // ping / notifications
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function textResult(text, isError = false) {
|
|
91
|
+
const r = { content: [{ type: "text", text }] };
|
|
92
|
+
if (isError)
|
|
93
|
+
r.isError = true;
|
|
94
|
+
return r;
|
|
95
|
+
}
|
|
96
|
+
// #29: clamp a model-requested bash timeout into a sane range. Guards NaN/negative/missing → the
|
|
97
|
+
// 120s default; floors at 1s and caps at 10min. This is INFERRED-parity for the bash tool — Cowork's
|
|
98
|
+
// binary-verified timeout_ms honoring is for web_fetch, not bash — but the bash inputSchema advertises
|
|
99
|
+
// timeout_ms, so we honor it rather than silently ignoring the requested value.
|
|
100
|
+
export function clampTimeout(ms) {
|
|
101
|
+
return Math.min(Math.max(Number(ms) || 120000, 1000), 600000);
|
|
102
|
+
}
|
|
103
|
+
async function execInContainer(runner, container, cwd, command, timeoutMs = 120000) {
|
|
104
|
+
if (!command)
|
|
105
|
+
return textResult("error: missing 'command'", true);
|
|
106
|
+
// Async (execFile, not spawnSync) so the awaited MCP handler yields the event loop while the subprocess
|
|
107
|
+
// runs — a slow `docker exec` no longer blocks all protocol I/O. Each call independent (fresh sh).
|
|
108
|
+
try {
|
|
109
|
+
const { stdout, stderr } = await pexec(runner, ["exec", "-w", cwd, container, "sh", "-c", command], {
|
|
110
|
+
encoding: "utf8",
|
|
111
|
+
timeout: timeoutMs, // #29: honor the model-requested timeout_ms (clamped at the call site)
|
|
112
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
113
|
+
});
|
|
114
|
+
const out = (stdout ?? "") + (stderr ?? "");
|
|
115
|
+
return textResult(out.length ? out : "(no output)");
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
const out = (e.stdout ?? "") + (e.stderr ?? "");
|
|
119
|
+
return textResult(`[exit ${e.code ?? 1}]\n${out}`, true);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Cowork routes web_fetch through the HOST API (gate 1978029737 `coworkWebFetchViaApi:true` →
|
|
123
|
+
// `POST /api/organizations/<org>/cowork/web_fetch`), NOT the container egress path that `bash` uses
|
|
124
|
+
// (binary-verified 2026-06-13, app.asar 1.12603.1). It is gated by a SEPARATE web-fetch hostname
|
|
125
|
+
// allowlist (`getWebFetchAllowedUrls`, `*` = unrestricted) plus a URL-provenance rule (#30). We mirror
|
|
126
|
+
// that: fetch host-side (so a reachable URL is not falsely egress-denied), gated by both.
|
|
127
|
+
/** Path A per-hop gate: scheme + private-address only — NO hostname allowlist (Path A is decoupled
|
|
128
|
+
* from the egress domain list per SPEC §6; the provenance set gates the initial URL). The SSRF
|
|
129
|
+
* backstop still applies on every hop so an approved/redirected URL can't reach `file://` or a
|
|
130
|
+
* private/metadata host (#43/#44). */
|
|
131
|
+
function schemePrivateGate(u) {
|
|
132
|
+
if (u.protocol !== "http:" && u.protocol !== "https:")
|
|
133
|
+
return `URL scheme "${u.protocol}" is not allowed. Use http or https.`;
|
|
134
|
+
if (isLocalOrPrivate(u.hostname))
|
|
135
|
+
return `Host "${u.hostname}" is a local or private address.`;
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Follow redirects manually (cap MAX_REDIRECTS), re-running `gate` on EVERY hop. A redirect to a
|
|
140
|
+
* gate-blocked host (private address, bad scheme, or off-allowlist on Path B) is refused — the SSRF
|
|
141
|
+
* protection that `curl -L` lacked. Emits one egress allow/deny on the terminal decision. Shared by
|
|
142
|
+
* both web_fetch paths; the gate is the only difference (Path A: scheme+private; Path B: full u1t).
|
|
143
|
+
*/
|
|
144
|
+
async function followWithRedirects(startUrl, rawFetch, gate, onEgress) {
|
|
145
|
+
let cur;
|
|
146
|
+
try {
|
|
147
|
+
cur = new URL(startUrl);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return textResult("web_fetch failed: invalid URL", true);
|
|
151
|
+
}
|
|
152
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
153
|
+
const blocked = gate(cur);
|
|
154
|
+
if (blocked) {
|
|
155
|
+
onEgress?.({ host: cur.hostname, decision: "deny" });
|
|
156
|
+
return textResult(hop === 0 ? blocked : `Redirect to ${cur.href} blocked: ${blocked}`, true);
|
|
157
|
+
}
|
|
158
|
+
let resp;
|
|
159
|
+
try {
|
|
160
|
+
resp = await rawFetch(cur.href);
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
return textResult(`Fetch failed: ${e?.message ?? String(e)}`, true);
|
|
164
|
+
}
|
|
165
|
+
if (resp.status >= 300 && resp.status < 400) {
|
|
166
|
+
if (!resp.location)
|
|
167
|
+
return textResult(`Redirect ${resp.status} from ${cur.href} had no Location header.`, true);
|
|
168
|
+
try {
|
|
169
|
+
cur = new URL(resp.location, cur);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return textResult(`Redirect to an invalid URL: ${resp.location}`, true);
|
|
173
|
+
}
|
|
174
|
+
continue; // re-check the gate on the new host
|
|
175
|
+
}
|
|
176
|
+
onEgress?.({ host: cur.hostname, decision: "allow" });
|
|
177
|
+
return textResult((await resp.text()).slice(0, 200000));
|
|
178
|
+
}
|
|
179
|
+
return textResult(`web_fetch failed: too many redirects (> ${MAX_REDIRECTS}).`, true);
|
|
180
|
+
}
|
|
181
|
+
async function fetchViaHost(url, allow, onEgress, prov, warned, rawFetch = defaultRawFetch) {
|
|
182
|
+
if (!url)
|
|
183
|
+
return textResult("error: missing 'url'", true);
|
|
184
|
+
let host;
|
|
185
|
+
try {
|
|
186
|
+
host = new URL(url).hostname;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return textResult("web_fetch failed: invalid URL", true);
|
|
190
|
+
}
|
|
191
|
+
// Two paths (binary-verified G1t/U1t, app.asar 1.12603.1), selected by whether provenance is engaged:
|
|
192
|
+
// • PATH A (prov present ⇒ coworkWebFetchViaApi on): the exact-URL provenance SET is the ONLY gate —
|
|
193
|
+
// NO hostname allowlist. A miss → the per-domain approval (coworkWebFetchPrompt) via the Decider.
|
|
194
|
+
// • PATH B (prov absent ⇒ gate off): the fall-through hostname allowlist (the egress domain list).
|
|
195
|
+
if (prov) {
|
|
196
|
+
if (prov.permissiveMode)
|
|
197
|
+
prov.markAllowed(url); // `cre` bypass: pre-add, skip the check
|
|
198
|
+
if (!prov.isAllowed(url)) {
|
|
199
|
+
if (prov.requestApproval && prov.promptGateOn) {
|
|
200
|
+
if (await prov.requestApproval(host, url))
|
|
201
|
+
prov.markAllowed(url);
|
|
202
|
+
else {
|
|
203
|
+
onEgress?.({ host, decision: "deny" });
|
|
204
|
+
return textResult("Web fetch was not allowed.", true);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
onEgress?.({ host, decision: "deny" });
|
|
209
|
+
return textResult("URL not in provenance set. web_fetch can only retrieve URLs that appeared in a user message " +
|
|
210
|
+
"or a prior web_fetch result. Ask the user to include the URL in a message first.", true);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Provenance satisfied. Cowork fetches server-side (host API); the hostname allowlist does NOT apply
|
|
214
|
+
// here (decoupled from egress — the #30 conflation). But scheme + private-address ARE enforced per
|
|
215
|
+
// hop (#43/#44): follow redirects manually instead of `curl -L`, blocking file:// / SSRF targets.
|
|
216
|
+
return followWithRedirects(url, rawFetch, schemePrivateGate, onEgress);
|
|
217
|
+
}
|
|
218
|
+
// PATH B (provenance not enforced — coworkWebFetchViaApi off). Faithful port of U1t re-checked on EVERY
|
|
219
|
+
// redirect hop (a redirect to a denied or private host is blocked — the SSRF false-green `curl -L` had).
|
|
220
|
+
if (warned && !warned.value) {
|
|
221
|
+
warned.value = true;
|
|
222
|
+
process.stderr.write("::warning:: web_fetch provenance is NOT enforced (fidelity gap vs Cowork)\n");
|
|
223
|
+
}
|
|
224
|
+
const matcher = compile(allow);
|
|
225
|
+
return followWithRedirects(url, rawFetch, (u) => u1t(u, allow, matcher), onEgress);
|
|
226
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #30 — read a GrowthBook gate sub-flag (e.g. `coworkWebFetchPrompt`) from the baseline's
|
|
3
|
+
* provenance.gates. Handles BOTH shapes: the committed prose string ("on(force) coworkWebFetchPrompt=true …")
|
|
4
|
+
* and a #39-decoded structured entry ({on, source, value:{coworkWebFetchPrompt:true}}). CRITICAL: the
|
|
5
|
+
* baseline key is prefixed ("coworkRuntimeConfig:1978029737") — try the prefixed key, then bare id
|
|
6
|
+
* (mirrors decideLoopFromBaseline's `gates["hostLoop:…"] ?? gates["…"]`). A missing gate ⇒ false.
|
|
7
|
+
*/
|
|
8
|
+
export function readGateFlag(baseline, id, flag) {
|
|
9
|
+
const gates = baseline.provenance?.gates ?? {};
|
|
10
|
+
let entry = gates[id];
|
|
11
|
+
if (entry === undefined) {
|
|
12
|
+
for (const k of Object.keys(gates)) {
|
|
13
|
+
if (k.endsWith(":" + id)) {
|
|
14
|
+
entry = gates[k];
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (entry == null)
|
|
20
|
+
return false;
|
|
21
|
+
if (typeof entry === "string")
|
|
22
|
+
return new RegExp(`\\b${flag}=true\\b`).test(entry);
|
|
23
|
+
if (typeof entry === "object") {
|
|
24
|
+
const v = entry.value;
|
|
25
|
+
if (v && typeof v === "object")
|
|
26
|
+
return v[flag] === true;
|
|
27
|
+
return entry[flag] === true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
export function decideLoop(inputs) {
|
|
32
|
+
if (inputs.requireFullVmSandbox === true)
|
|
33
|
+
return "vm"; // HeA()
|
|
34
|
+
if (inputs.forceDisableHostLoop === true)
|
|
35
|
+
return "vm"; // iX()
|
|
36
|
+
if (inputs.devForceHostLoop === true)
|
|
37
|
+
return "host"; // dev override
|
|
38
|
+
return inputs.gateHostLoopOn ? "host" : "vm"; // cPt()
|
|
39
|
+
}
|
|
40
|
+
/** Derive loop inputs from the baseline's synced gate state + env, then decide. */
|
|
41
|
+
export function decideLoopFromBaseline(baseline, over = {}) {
|
|
42
|
+
const p = baseline;
|
|
43
|
+
const gates = p.provenance?.gates ?? {};
|
|
44
|
+
const gateRaw = gates["hostLoop:1143815894"] ?? gates["1143815894"];
|
|
45
|
+
// Gate value may be a synced structured entry ({on,source,value}, post-#39), an authored prose
|
|
46
|
+
// string ("on(force) …"), or absent. Read `.on` for objects (a bare `!!obj` would be true even for
|
|
47
|
+
// an OFF gate); the on/true/force test for strings.
|
|
48
|
+
const gateHostLoopOn = gateRaw && typeof gateRaw === "object"
|
|
49
|
+
? !!gateRaw.on
|
|
50
|
+
: typeof gateRaw === "string"
|
|
51
|
+
? /on|true|force/i.test(gateRaw)
|
|
52
|
+
: !!gateRaw;
|
|
53
|
+
return decideLoop({
|
|
54
|
+
// BUG FIX: a locked-down-org baseline (requireFullVmSandbox:true) must force VM-loop — this was
|
|
55
|
+
// previously ignored, so such a baseline would wrongly run host-loop.
|
|
56
|
+
requireFullVmSandbox: p.requireFullVmSandbox === true,
|
|
57
|
+
forceDisableHostLoop: p.forceDisableHostLoop === true,
|
|
58
|
+
gateHostLoopOn,
|
|
59
|
+
devForceHostLoop: process.env.CLAUDE_FORCE_HOST_LOOP === "1",
|
|
60
|
+
...over,
|
|
61
|
+
});
|
|
62
|
+
}
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
const BASELINES_DIR = join(fileURLToPath(new URL("..", import.meta.url)), "baselines");
|
|
5
|
+
export function renderPrompts(baseline, session, sessionId) {
|
|
6
|
+
const spawn = baseline.spawn;
|
|
7
|
+
if (!spawn)
|
|
8
|
+
return {};
|
|
9
|
+
const sessionRoot = `/sessions/${sessionId}`;
|
|
10
|
+
const mntRoot = `${sessionRoot}/mnt`;
|
|
11
|
+
const firstFolder = session.folders[0]?.to ?? (session.folders[0]?.from ? basenameish(session.folders[0].from) : undefined);
|
|
12
|
+
const workspaceFolder = firstFolder ? `${mntRoot}/.projects/${firstFolder}` : `${mntRoot}/outputs`;
|
|
13
|
+
const tokens = {
|
|
14
|
+
"{{cwd}}": sessionRoot,
|
|
15
|
+
"{{skillsDir}}": `${mntRoot}/.claude`,
|
|
16
|
+
"{{workspaceFolder}}": workspaceFolder,
|
|
17
|
+
"{{folderSelected}}": firstFolder ? "true" : "false",
|
|
18
|
+
"{{modelName}}": session.model ?? "Claude",
|
|
19
|
+
};
|
|
20
|
+
const subst = (s) => Object.entries(tokens).reduce((acc, [k, v]) => acc.split(k).join(v), s);
|
|
21
|
+
const read = (rel) => {
|
|
22
|
+
if (!rel)
|
|
23
|
+
return undefined; // no asset configured — not a drift, just absent
|
|
24
|
+
const p = join(BASELINES_DIR, rel);
|
|
25
|
+
if (!existsSync(p)) {
|
|
26
|
+
// #35: a baseline that REFERENCES a prompt asset which is absent must not silently degrade — the
|
|
27
|
+
// run would proceed without key Cowork framing. Warn loudly, consistent with the host-loop path (#34).
|
|
28
|
+
process.stderr.write(`::warning:: [prompt] referenced asset not found: ${p} — running WITHOUT this prompt section (fidelity gap)\n`);
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
return subst(stripComments(readFileSync(p, "utf8"))).trim();
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
systemPromptAppend: read(spawn.promptTemplate),
|
|
35
|
+
subagentAppend: read(spawn.subagentAppend),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function stripComments(s) {
|
|
39
|
+
return s.replace(/<!--[\s\S]*?-->/g, "");
|
|
40
|
+
}
|
|
41
|
+
function basenameish(p) {
|
|
42
|
+
return p.replace(/\/+$/, "").split("/").pop() ?? p;
|
|
43
|
+
}
|