surf-cli 2.9.0 → 2.11.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.
Files changed (44) hide show
  1. package/README.md +61 -4
  2. package/dist/content/accessibility-tree.js +11 -0
  3. package/dist/content/accessibility-tree.js.map +1 -0
  4. package/dist/content/visual-indicator.js +111 -0
  5. package/dist/content/visual-indicator.js.map +1 -0
  6. package/dist/manifest.json +11 -2
  7. package/dist/options/options.js +3 -3
  8. package/dist/options/options.js.map +1 -1
  9. package/dist/service-worker/index.js +61 -261
  10. package/dist/service-worker/index.js.map +1 -1
  11. package/native/activity-journal.cjs +55 -0
  12. package/native/chatgpt-client-response.cjs +336 -0
  13. package/native/chatgpt-client-selection.cjs +119 -0
  14. package/native/chatgpt-client-ui.cjs +481 -0
  15. package/native/chatgpt-client.cjs +254 -664
  16. package/native/cli.cjs +100 -273
  17. package/native/do-executor.cjs +52 -475
  18. package/native/do-parser.cjs +8 -249
  19. package/native/host-helpers.cjs +32 -15
  20. package/native/host-sessions.cjs +6 -1
  21. package/native/host.cjs +228 -6
  22. package/native/network-export.cjs +20 -17
  23. package/native/network-store.cjs +38 -58
  24. package/native/oracle-cli.cjs +434 -0
  25. package/native/oracle-context.cjs +311 -0
  26. package/native/oracle-host.cjs +301 -0
  27. package/native/oracle-jobs.cjs +253 -0
  28. package/native/playbook-authoring.cjs +44 -0
  29. package/native/playbook-cli.cjs +157 -0
  30. package/native/playbook-client.cjs +259 -0
  31. package/native/playbook-receipts.cjs +109 -0
  32. package/native/playbook-records.cjs +208 -0
  33. package/native/playbook-runtime.cjs +177 -0
  34. package/native/playbooks.cjs +235 -0
  35. package/native/private-state.cjs +156 -0
  36. package/native/redaction.cjs +104 -0
  37. package/native/workflow-definition.cjs +369 -0
  38. package/native/workflow-runtime.cjs +225 -0
  39. package/package.json +2 -1
  40. package/playbooks/page/ops/read.json +22 -0
  41. package/playbooks/page/playbook.json +7 -0
  42. package/skills/surf/SKILL.md +72 -1
  43. package/dist/content/index.js +0 -116
  44. package/dist/content/index.js.map +0 -1
@@ -0,0 +1,259 @@
1
+ const fs = require("fs");
2
+ const http = require("http");
3
+ const path = require("path");
4
+ const { execFile } = require("child_process");
5
+ const { atomicWriteFile, atomicWriteJson, ensurePrivateDir, getPrivateStateRoot, readPrivateJson } = require("./private-state.cjs");
6
+ const { resolveOp } = require("./playbooks.cjs");
7
+ const { readRecord, recordsRoot } = require("./playbook-records.cjs");
8
+ const { assertNoEmbeddedSecrets, assertUrlHasNoEmbeddedSecrets, safeHeaders } = require("./redaction.cjs");
9
+ const { version: PACKAGE_VERSION } = require("../package.json");
10
+
11
+ function absoluteEndpointUrl(url, origins = []) {
12
+ if (typeof url !== "string" || !url) throw new Error("client projection requires an endpoint URL");
13
+ try {
14
+ const parsed = new URL(url);
15
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("client projection endpoint must use HTTP(S)");
16
+ return parsed.toString();
17
+ } catch (error) {
18
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) throw error;
19
+ }
20
+ if (!Array.isArray(origins) || origins.length !== 1) {
21
+ throw new Error("client projection requires an absolute endpoint URL or exactly one declared origin");
22
+ }
23
+ const resolved = new URL(url, origins[0]);
24
+ if (resolved.protocol !== "http:" && resolved.protocol !== "https:") throw new Error("client projection endpoint must use HTTP(S)");
25
+ return resolved.toString();
26
+ }
27
+
28
+ function networkStrategy(op) {
29
+ const strategy = op.run.find((candidate) => candidate.using === "network");
30
+ if (!strategy) throw new Error(`op ${op.id} has no validated network strategy`);
31
+ return strategy;
32
+ }
33
+
34
+ function clientSource() {
35
+ return `#!/usr/bin/env node
36
+ import fs from "node:fs";
37
+ import path from "node:path";
38
+ import { fileURLToPath } from "node:url";
39
+ const directory = path.dirname(fileURLToPath(import.meta.url));
40
+ const manifest = JSON.parse(fs.readFileSync(path.join(directory, "surf-client.json"), "utf8"));
41
+ const args = {};
42
+ for (let i = 2; i < process.argv.length; i++) {
43
+ if (!process.argv[i].startsWith("--")) continue;
44
+ const name = process.argv[i].slice(2);
45
+ const next = process.argv[i + 1];
46
+ args[name] = next && !next.startsWith("--") ? (i++, next) : true;
47
+ }
48
+ const template = (value) => typeof value === "string"
49
+ ? value.replace(/\\{\\{([a-zA-Z0-9._-]+)\\}\\}/g, (_, name) => {
50
+ if (args[name] === undefined) throw new Error(\`missing argument --\${name}\`);
51
+ return String(args[name]);
52
+ })
53
+ : Array.isArray(value) ? value.map(template)
54
+ : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, template(item)]))
55
+ : value;
56
+ const endpoint = template(manifest.endpoint);
57
+ const url = new URL(process.env.SURF_CLIENT_ENDPOINT_URL || endpoint.url);
58
+ for (const [name, value] of Object.entries(endpoint.query || {})) url.searchParams.set(name, String(value));
59
+ const headers = { ...(endpoint.headers || {}) };
60
+ for (const input of manifest.authInputs || []) {
61
+ const value = process.env[input.env];
62
+ if (input.required && !value) throw new Error(\`missing auth environment variable \${input.env}\`);
63
+ if (value && input.header) headers[input.header] = value;
64
+ }
65
+ const response = await fetch(url, {
66
+ method: endpoint.method,
67
+ headers,
68
+ ...(endpoint.body === undefined ? {} : { body: typeof endpoint.body === "string" ? endpoint.body : JSON.stringify(endpoint.body) }),
69
+ });
70
+ const text = await response.text();
71
+ if (!response.ok) throw new Error(\`HTTP \${response.status}: \${text.slice(0, 500)}\`);
72
+ let bodyJson;
73
+ try { bodyJson = JSON.parse(text); } catch {}
74
+ let output = manifest.extract ? {
75
+ status: response.status,
76
+ ok: response.ok,
77
+ url: response.url,
78
+ headers: Object.fromEntries(response.headers.entries()),
79
+ body: text,
80
+ bodyJson,
81
+ } : bodyJson ?? text;
82
+ if (manifest.extract?.jsonPath) {
83
+ output = bodyJson ?? output;
84
+ for (const part of manifest.extract.jsonPath.replace(/^\\$\\.?/, "").split(".").filter(Boolean)) output = output?.[part];
85
+ }
86
+ if (manifest.extract?.field) output = output?.[manifest.extract.field];
87
+ process.stdout.write(output === undefined ? "" : typeof output === "string" ? output : JSON.stringify(output, null, 2));
88
+ process.stdout.write("\\n");
89
+ `;
90
+ }
91
+
92
+ function generateClient({ playbookId, op, strategy, provenance, out, allowWrite = false, origins = [] }) {
93
+ if (op.effect === "write" && !allowWrite) throw new Error("write-capable client projection requires explicit review");
94
+ if (typeof out !== "string" || !out) throw new Error("client projection requires --out <directory>");
95
+ const directory = path.resolve(out);
96
+ const request = strategy.request;
97
+ const endpoint = {
98
+ method: request.method || "GET",
99
+ url: absoluteEndpointUrl(request.url, origins),
100
+ query: request.query || {},
101
+ headers: safeHeaders(request.headers),
102
+ ...(request.body !== undefined ? { body: request.body } : {}),
103
+ };
104
+ assertNoEmbeddedSecrets(endpoint);
105
+ assertUrlHasNoEmbeddedSecrets(endpoint.url);
106
+ if (fs.existsSync(directory) && fs.lstatSync(directory).isSymbolicLink()) throw new Error(`refusing symbolic link: ${directory}`);
107
+ ensurePrivateDir(directory, directory);
108
+ const manifest = {
109
+ version: 1,
110
+ generator: { name: "surf-cli", version: PACKAGE_VERSION },
111
+ source: provenance,
112
+ playbook: playbookId,
113
+ op: op.id,
114
+ effect: op.effect,
115
+ endpoint,
116
+ extract: strategy.extract || null,
117
+ authInputs: Array.isArray(request.authInputs) ? request.authInputs.map((input) => ({ env: input.env, header: input.header, required: input.required !== false })) : [],
118
+ verification: strategy.verify || null,
119
+ verificationCommand: "surf pb client verify .",
120
+ noEmbeddedSecrets: true,
121
+ };
122
+ atomicWriteJson(path.join(directory, "surf-client.json"), manifest, { root: directory });
123
+ atomicWriteFile(path.join(directory, "client.mjs"), clientSource(), { root: directory, encoding: "utf8" });
124
+ atomicWriteJson(path.join(directory, "package.json"), { private: true, type: "module", scripts: { start: "node client.mjs" } }, { root: directory });
125
+ return { directory, manifest };
126
+ }
127
+
128
+ function exportClient(playbookId, opId, out, options = {}) {
129
+ const { playbook, op } = resolveOp(playbookId, opId, options);
130
+ return generateClient({ playbookId, op, strategy: networkStrategy(op), provenance: { type: "playbook", id: playbook.id, op: op.id, ...playbook.provenance }, out, allowWrite: options.allowWrite, origins: op.origins || playbook.origins });
131
+ }
132
+
133
+ function findRecord(site, op, root = getPrivateStateRoot()) {
134
+ const base = recordsRoot(root);
135
+ if (!fs.existsSync(base)) return null;
136
+ return fs.readdirSync(base).filter((name) => name.startsWith("rec-")).map((name) => readRecord(name, root)).filter((record) => record?.site === site && record?.op === op).sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)))[0] || null;
137
+ }
138
+
139
+ function deriveClient(site, opId, out, options = {}) {
140
+ const root = options.root || getPrivateStateRoot();
141
+ const record = options.recordId ? readRecord(options.recordId, root) : findRecord(site, opId, root);
142
+ if (!record) throw new Error(`no record found for ${site} ${opId}`);
143
+ const trace = readPrivateJson(path.join(recordsRoot(root), record.id, "network", "trace.json"), null, { root });
144
+ const candidates = (trace?.entries || []).filter((candidate) => ["GET", "HEAD", "OPTIONS", "POST"].includes(candidate.method) && candidate.status >= 200 && candidate.status < 400);
145
+ const entry = options.requestId
146
+ ? candidates.find((candidate) => candidate.id === options.requestId || candidate._requestId === options.requestId)
147
+ : candidates.length === 1 ? candidates[0] : null;
148
+ if (!options.requestId && candidates.length > 1) throw new Error(`record ${record.id} has multiple read endpoints; pass --request-id`);
149
+ if (!entry) throw new Error(`record ${record.id} has no validated read endpoint`);
150
+ const op = { id: opId, effect: "read", run: [] };
151
+ const url = new URL(entry.url);
152
+ const query = Object.fromEntries(url.searchParams.entries());
153
+ url.search = "";
154
+ const strategy = { using: "network", request: { method: entry.method, url: url.toString(), query, headers: safeHeaders(entry.requestHeaders), ...(entry.requestBody !== undefined ? { body: entry.requestBody } : {}) } };
155
+ return generateClient({ playbookId: site, op, strategy, provenance: { type: "record", recordId: record.id }, out });
156
+ }
157
+
158
+ function collectTemplateArgs(value, names = new Set()) {
159
+ if (typeof value === "string") {
160
+ for (const match of value.matchAll(/\{\{([a-zA-Z0-9._-]+)\}\}/g)) names.add(match[1]);
161
+ } else if (Array.isArray(value)) {
162
+ for (const item of value) collectTemplateArgs(item, names);
163
+ } else if (value && typeof value === "object") {
164
+ for (const item of Object.values(value)) collectTemplateArgs(item, names);
165
+ }
166
+ return names;
167
+ }
168
+
169
+ function clientArgs(manifest) {
170
+ return [...collectTemplateArgs(manifest.endpoint)].flatMap((name) => [`--${name}`, "verify"]);
171
+ }
172
+
173
+ function authEnv(manifest, live) {
174
+ const env = {};
175
+ for (const input of manifest.authInputs || []) {
176
+ if (!input.env) continue;
177
+ if (live) {
178
+ if (process.env[input.env] !== undefined) env[input.env] = process.env[input.env];
179
+ } else env[input.env] = "verify-token";
180
+ }
181
+ return env;
182
+ }
183
+
184
+ function verificationBody(manifest) {
185
+ if (manifest.extract?.field === "body") return "verified";
186
+ return JSON.stringify({ ok: true, body: "verified", data: "verified", items: ["verified"] });
187
+ }
188
+
189
+ function templateForVerify(value) {
190
+ if (typeof value === "string") return value.replace(/\{\{[a-zA-Z0-9._-]+\}\}/g, "verify");
191
+ if (Array.isArray(value)) return value.map(templateForVerify);
192
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, templateForVerify(item)]));
193
+ return value;
194
+ }
195
+
196
+ function runClient(resolved, manifest, { env = {}, live = false } = {}) {
197
+ return new Promise((resolve, reject) => {
198
+ execFile(process.execPath, [path.join(resolved, "client.mjs"), ...clientArgs(manifest)], {
199
+ cwd: resolved,
200
+ env: { ...process.env, ...authEnv(manifest, live), ...env },
201
+ encoding: "utf8",
202
+ timeout: 30000,
203
+ }, (error, stdout, stderr) => {
204
+ if (error) {
205
+ error.message = stderr || error.message;
206
+ reject(error);
207
+ } else resolve(stdout);
208
+ });
209
+ });
210
+ }
211
+
212
+ async function verifyWithLocalServer(resolved, manifest) {
213
+ const requests = [];
214
+ const endpoint = templateForVerify(manifest.endpoint);
215
+ const expectedUrl = new URL(endpoint.url);
216
+ for (const [name, value] of Object.entries(endpoint.query || {})) expectedUrl.searchParams.set(name, String(value));
217
+ const expectedPath = `${expectedUrl.pathname}${expectedUrl.search}`;
218
+ const expectedBody = endpoint.body === undefined ? undefined : typeof endpoint.body === "string" ? endpoint.body : JSON.stringify(endpoint.body);
219
+ const server = http.createServer((request, response) => {
220
+ let requestBody = "";
221
+ request.setEncoding("utf8");
222
+ request.on("data", (chunk) => { requestBody += chunk; });
223
+ request.on("end", () => {
224
+ requests.push({ method: request.method, url: request.url, body: requestBody });
225
+ const body = verificationBody(manifest);
226
+ response.writeHead(manifest.verification?.status || 200, { "content-type": body.startsWith("{") ? "application/json" : "text/plain" });
227
+ response.end(body);
228
+ });
229
+ });
230
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
231
+ try {
232
+ const address = server.address();
233
+ const stdout = await runClient(resolved, manifest, {
234
+ env: { SURF_CLIENT_ENDPOINT_URL: `http://127.0.0.1:${address.port}${expectedPath}` },
235
+ });
236
+ if (requests.length === 0) throw new Error("generated client did not call its verification endpoint");
237
+ const request = requests[0];
238
+ if (request.method !== endpoint.method) throw new Error(`generated client used ${request.method} instead of ${endpoint.method}`);
239
+ if (request.url !== expectedPath) throw new Error(`generated client requested ${request.url} instead of ${expectedPath}`);
240
+ if (expectedBody !== undefined && request.body !== expectedBody) throw new Error("generated client request body did not match the projected endpoint");
241
+ return { requests: requests.length, stdout: stdout.trim() };
242
+ } finally {
243
+ await new Promise((resolve) => server.close(resolve));
244
+ }
245
+ }
246
+
247
+ async function verifyClient(directory, { live } = {}) {
248
+ const resolved = path.resolve(directory);
249
+ const manifest = JSON.parse(fs.readFileSync(path.join(resolved, "surf-client.json"), "utf8"));
250
+ const source = fs.readFileSync(path.join(resolved, "client.mjs"), "utf8");
251
+ const serialized = `${JSON.stringify(manifest)}\n${source}`.toLowerCase();
252
+ if (!manifest.noEmbeddedSecrets || /bearer [a-z0-9._-]+|cookie:\s*[^<]|authorization\s*[:=]\s*["'][^<]/i.test(serialized)) throw new Error("generated client contains embedded credentials");
253
+ if (!manifest.endpoint?.method || !manifest.endpoint?.url) throw new Error("generated client endpoint is incomplete");
254
+ absoluteEndpointUrl(manifest.endpoint.url);
255
+ const execution = live ? { stdout: (await runClient(resolved, manifest, { live: true })).trim() } : await verifyWithLocalServer(resolved, manifest);
256
+ return { valid: true, playbook: manifest.playbook, op: manifest.op, endpoint: manifest.endpoint, execution };
257
+ }
258
+
259
+ module.exports = { absoluteEndpointUrl, deriveClient, exportClient, generateClient, verifyClient };
@@ -0,0 +1,109 @@
1
+ const crypto = require("crypto");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const {
5
+ atomicWriteJson,
6
+ ensurePrivateDir,
7
+ getPrivateStateRoot,
8
+ readPrivateFile,
9
+ readPrivateJson,
10
+ writePrivateFileExclusive,
11
+ } = require("./private-state.cjs");
12
+
13
+ function receiptRoot(root = getPrivateStateRoot()) {
14
+ return path.join(root, "playbook-receipts");
15
+ }
16
+
17
+ function loadSalt(root) {
18
+ const directory = receiptRoot(root);
19
+ ensurePrivateDir(directory, root);
20
+ const saltPath = path.join(directory, ".salt");
21
+ const existing = readPrivateFile(saltPath, { root, allowMissing: true, fallback: null, encoding: "utf8" });
22
+ if (existing) return Buffer.from(existing.trim(), "hex");
23
+ const salt = crypto.randomBytes(32);
24
+ try {
25
+ writePrivateFileExclusive(saltPath, `${salt.toString("hex")}\n`, { root, encoding: "utf8" });
26
+ } catch (error) {
27
+ if (error?.code !== "EEXIST") throw error;
28
+ }
29
+ return Buffer.from(readPrivateFile(saltPath, { root, encoding: "utf8" }).trim(), "hex");
30
+ }
31
+
32
+ function semanticClaimKey({ playbookId, op, args, root = getPrivateStateRoot() }) {
33
+ const semantic = {};
34
+ for (const name of op.safety.key) {
35
+ if (args[name] === undefined) throw new Error(`semantic safety argument is missing: ${name}`);
36
+ semantic[name] = args[name];
37
+ }
38
+ const message = JSON.stringify({ playbook: playbookId, op: op.id, semantic });
39
+ return crypto.createHmac("sha256", loadSalt(root)).update(message).digest("hex");
40
+ }
41
+
42
+ function attemptFiles(claimDir) {
43
+ if (!fs.existsSync(claimDir)) return [];
44
+ return fs.readdirSync(claimDir)
45
+ .filter((name) => name.endsWith(".json"))
46
+ .map((name) => readPrivateJson(path.join(claimDir, name), null, { root: path.dirname(claimDir) }))
47
+ .filter(Boolean)
48
+ .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
49
+ }
50
+
51
+ function reserveReceipt({ playbookId, op, args, repeat = false, retryAttempt, overrideInDoubt = false, attemptId = crypto.randomUUID(), now = Date.now(), root = getPrivateStateRoot() }) {
52
+ if (op.effect !== "write") return null;
53
+ const base = receiptRoot(root);
54
+ ensurePrivateDir(base, root);
55
+ const claimKey = semanticClaimKey({ playbookId, op, args, root });
56
+ const claimDir = path.join(base, claimKey);
57
+ let created = false;
58
+ try {
59
+ fs.mkdirSync(claimDir, { mode: 0o700 });
60
+ created = true;
61
+ } catch (error) {
62
+ if (error?.code !== "EEXIST") throw error;
63
+ }
64
+ if (!created) {
65
+ const existing = attemptFiles(claimDir);
66
+ const latest = existing.at(-1);
67
+ if (!latest) {
68
+ throw new Error("write blocked by an unresolved semantic claim created before an attempt receipt was durable");
69
+ }
70
+ if (retryAttempt) {
71
+ const retry = existing.find((attempt) => attempt.attemptId === retryAttempt);
72
+ if (!retry) throw new Error(`receipt attempt not found: ${retryAttempt}`);
73
+ if (retry.status === "verified") throw new Error(`receipt attempt is already verified: ${retryAttempt}`);
74
+ if (retry.status !== "reserved" && retry.status !== "not_dispatched" && !op.safety.serverIdempotency) {
75
+ throw new Error("--retry-attempt after dispatch requires declared server idempotency");
76
+ }
77
+ return { claimKey, claimDir, attemptId: retryAttempt, path: path.join(claimDir, `${retryAttempt}.json`), receipt: retry };
78
+ }
79
+ if (latest) {
80
+ const ageMs = Math.max(0, now - Date.parse(latest.updatedAt || latest.createdAt));
81
+ const windowMs = op.safety.windowMs ?? 30000;
82
+ const windowElapsed = op.safety.duplicate === "repeatable-window" && latest.status === "verified" && ageMs >= windowMs;
83
+ const deliberateOverride = overrideInDoubt && latest.status === "indeterminate";
84
+ const deliberateRepeat = repeat && latest.status === "verified";
85
+ if (!windowElapsed && !deliberateOverride && !deliberateRepeat) {
86
+ throw new Error(`write blocked by ${latest.status} receipt ${latest.attemptId}; use the policy-specific retry or override flag`);
87
+ }
88
+ }
89
+ }
90
+ const createdAt = new Date(now).toISOString();
91
+ const receipt = { version: 1, playbook: playbookId, op: op.id, claimKey, attemptId, status: "reserved", duplicate: op.safety.duplicate, createdAt, updatedAt: createdAt };
92
+ const receiptPath = path.join(claimDir, `${attemptId}.json`);
93
+ atomicWriteJson(path.join(claimDir, "claim"), { version: 1, playbook: playbookId, op: op.id, claimKey }, { root });
94
+ atomicWriteJson(receiptPath, receipt, { root });
95
+ return { claimKey, claimDir, attemptId, path: receiptPath, receipt };
96
+ }
97
+
98
+ function updateReceipt(handle, status, details = {}, root = getPrivateStateRoot()) {
99
+ if (!handle) return null;
100
+ if (!["reserved", "not_dispatched", "dispatched", "indeterminate", "verified"].includes(status)) throw new Error(`invalid receipt status: ${status}`);
101
+ const current = readPrivateJson(handle.path, null, { root });
102
+ if (!current) throw new Error(`receipt is missing: ${handle.attemptId}`);
103
+ const receipt = { ...current, status, updatedAt: new Date().toISOString(), ...(details.error ? { errorCode: crypto.createHash("sha256").update(String(details.error)).digest("hex").slice(0, 16) } : {}) };
104
+ atomicWriteJson(handle.path, receipt, { root });
105
+ handle.receipt = receipt;
106
+ return receipt;
107
+ }
108
+
109
+ module.exports = { receiptRoot, reserveReceipt, semanticClaimKey, updateReceipt };
@@ -0,0 +1,208 @@
1
+ const crypto = require("crypto");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const {
5
+ appendPrivateJsonLine,
6
+ assertWithin,
7
+ atomicWriteJson,
8
+ ensurePrivateDir,
9
+ getPrivateStateRoot,
10
+ readPrivateFile,
11
+ readPrivateJson,
12
+ } = require("./private-state.cjs");
13
+ const { redactSensitiveFields, redactUrlSecrets, safeHeaders } = require("./redaction.cjs");
14
+ const { commandMetadata, promoteRedactedStepArgs } = require("./workflow-definition.cjs");
15
+ const { version: PACKAGE_VERSION } = require("../package.json");
16
+
17
+ function recordsRoot(root = getPrivateStateRoot()) {
18
+ return path.join(root, "records");
19
+ }
20
+
21
+ function activePath(root = getPrivateStateRoot()) {
22
+ return path.join(recordsRoot(root), "active.json");
23
+ }
24
+
25
+ function recordDirectory(recordId, root = getPrivateStateRoot()) {
26
+ if (typeof recordId !== "string" || !/^rec-[a-zA-Z0-9-]+$/.test(recordId)) throw new Error("record ID is invalid");
27
+ return path.join(recordsRoot(root), recordId);
28
+ }
29
+
30
+ function readRecord(recordId, root = getPrivateStateRoot()) {
31
+ return readPrivateJson(path.join(recordDirectory(recordId, root), "record.json"), null, { root });
32
+ }
33
+
34
+ function activeRecord(root = getPrivateStateRoot()) {
35
+ const active = readPrivateJson(activePath(root), null, { root });
36
+ return active ? readRecord(active.recordId, root) : null;
37
+ }
38
+
39
+ function writeRecord(record, root) {
40
+ atomicWriteJson(path.join(recordDirectory(record.id, root), "record.json"), record, { root });
41
+ }
42
+
43
+ function startRecord({ site, op, watch = false, network = false, includeInputValues = false, tabId, origin, root = getPrivateStateRoot() }) {
44
+ if (!site || !op) throw new Error("record start requires site and op");
45
+ if (activeRecord(root)) throw new Error("a playbook record is already active");
46
+ const id = `rec-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
47
+ const directory = recordDirectory(id, root);
48
+ ensurePrivateDir(directory, root);
49
+ const record = {
50
+ version: 1,
51
+ id,
52
+ site,
53
+ op,
54
+ status: "recording",
55
+ capture: { watch, network },
56
+ redaction: { includeInputValues },
57
+ provenance: { generator: "surf-cli", version: PACKAGE_VERSION },
58
+ ...(tabId ? { tabId } : {}),
59
+ ...(origin ? { origin } : {}),
60
+ startedAt: new Date().toISOString(),
61
+ eventCount: 0,
62
+ };
63
+ writeRecord(record, root);
64
+ atomicWriteJson(activePath(root), { recordId: id }, { root });
65
+ return record;
66
+ }
67
+
68
+ function appendRecordEvent(event, { root = getPrivateStateRoot(), allowPaused = false } = {}) {
69
+ const record = activeRecord(root);
70
+ if (!record || (record.status !== "recording" && !allowPaused)) return false;
71
+ appendPrivateJsonLine(path.join(recordDirectory(record.id, root), "events.jsonl"), { version: 1, recordId: record.id, ...event }, { root });
72
+ record.eventCount++;
73
+ writeRecord(record, root);
74
+ return true;
75
+ }
76
+
77
+ function updateActiveStatus(status, root = getPrivateStateRoot()) {
78
+ const record = activeRecord(root);
79
+ if (!record) throw new Error("no active playbook record");
80
+ const allowed = { recording: ["paused", "stopping"], paused: ["recording", "stopping"] };
81
+ if (!allowed[record.status]?.includes(status)) throw new Error(`cannot change record from ${record.status} to ${status}`);
82
+ record.status = status;
83
+ writeRecord(record, root);
84
+ return record;
85
+ }
86
+
87
+ function updateRecordContext({ tabId, origin }, root = getPrivateStateRoot()) {
88
+ const record = activeRecord(root);
89
+ if (!record) throw new Error("no active playbook record");
90
+ if (tabId) record.tabId = tabId;
91
+ if (origin) record.origin = origin;
92
+ writeRecord(record, root);
93
+ return record;
94
+ }
95
+
96
+ function markRecord(label, root = getPrivateStateRoot()) {
97
+ if (typeof label !== "string" || !label.trim()) throw new Error("record mark requires text");
98
+ const record = activeRecord(root);
99
+ if (!record) throw new Error("no active playbook record");
100
+ appendRecordEvent({ type: "mark", label: label.trim(), timestamp: new Date().toISOString() }, { root, allowPaused: true });
101
+ return activeRecord(root);
102
+ }
103
+
104
+ function readEvents(recordId, root = getPrivateStateRoot()) {
105
+ const filePath = path.join(recordDirectory(recordId, root), "events.jsonl");
106
+ const content = readPrivateFile(filePath, { root, allowMissing: true, fallback: "", encoding: "utf8" });
107
+ return content.split("\n").filter(Boolean).map((line) => JSON.parse(line));
108
+ }
109
+
110
+ function sanitizeTraceEntry(entry, includeInputValues = false) {
111
+ return {
112
+ ...redactSensitiveFields(entry),
113
+ url: redactUrlSecrets(entry.url),
114
+ requestHeaders: safeHeaders(entry.requestHeaders),
115
+ responseHeaders: safeHeaders(entry.responseHeaders),
116
+ responseBody: entry.responseBody === undefined ? undefined : "<response-body>",
117
+ ...(includeInputValues ? {} : { requestBody: entry.requestBody === undefined ? undefined : "<request-body>" }),
118
+ };
119
+ }
120
+
121
+ function attachNetworkTrace(recordId, entries, root = getPrivateStateRoot()) {
122
+ const record = readRecord(recordId, root);
123
+ if (!record) throw new Error(`record not found: ${recordId}`);
124
+ const sanitized = entries.map((entry) => sanitizeTraceEntry(entry, record.redaction.includeInputValues));
125
+ const directory = path.join(recordDirectory(recordId, root), "network");
126
+ ensurePrivateDir(directory, root);
127
+ const tracePath = path.join(directory, "trace.json");
128
+ atomicWriteJson(tracePath, { version: 1, recordId, capturedAt: new Date().toISOString(), entries: sanitized }, { root });
129
+ record.trace = { path: "network/trace.json", count: sanitized.length };
130
+ writeRecord(record, root);
131
+ return { path: tracePath, count: sanitized.length };
132
+ }
133
+
134
+ function draftFromRecord(recordId, root = getPrivateStateRoot()) {
135
+ const record = readRecord(recordId, root);
136
+ if (!record) throw new Error(`record not found: ${recordId}`);
137
+ const events = readEvents(recordId, root).filter((event) => event.type === "tool.completed");
138
+ let effect = "read";
139
+ const steps = [];
140
+ for (const event of events) {
141
+ const metadata = commandMetadata(event.command);
142
+ if (!metadata.recordable) continue;
143
+ if (["page-write", "unknown"].includes(metadata.effect)) effect = "write";
144
+ steps.push({ tool: event.command, args: event.argsRedacted || {} });
145
+ }
146
+ const tracePath = path.join(recordDirectory(recordId, root), "network", "trace.json");
147
+ const trace = readPrivateJson(tracePath, null, { root });
148
+ const strategies = [];
149
+ const observed = trace?.entries?.findLast((entry) => ["GET", "HEAD", "OPTIONS", "POST"].includes(entry.method) && entry.status >= 200 && entry.status < 400);
150
+ if (observed) {
151
+ const url = new URL(observed.url);
152
+ const query = Object.fromEntries(url.searchParams.entries());
153
+ url.search = "";
154
+ strategies.push({ using: "network", request: { method: observed.method, url: url.toString(), query, headers: safeHeaders(observed.requestHeaders), ...(observed.requestBody !== undefined ? { body: observed.requestBody } : {}) } });
155
+ }
156
+ let args = {};
157
+ if (steps.length > 0) {
158
+ const promoted = promoteRedactedStepArgs(steps);
159
+ args = promoted.args;
160
+ strategies.push({ using: "workflow", steps: promoted.steps });
161
+ }
162
+ if (strategies.length === 0) throw new Error("record has no executable evidence");
163
+ const safety = effect === "write" ? { authorization: "explicit", duplicate: "transactional", key: Object.keys(args).length ? Object.keys(args) : ["review_key"] } : undefined;
164
+ if (effect === "write" && !args.review_key && safety.key.includes("review_key")) args.review_key = { required: true, desc: "Semantic key for reviewed write replay" };
165
+ const op = { id: record.op, description: `Drafted from ${record.id}`, effect, args, ...(safety ? { safety } : {}), run: strategies, provenance: { recordId: record.id } };
166
+ const draftDir = path.join(recordDirectory(recordId, root), "draft");
167
+ ensurePrivateDir(draftDir, root);
168
+ atomicWriteJson(path.join(draftDir, "op.json"), op, { root });
169
+ return op;
170
+ }
171
+
172
+ function stopRecord({ draft = false, root = getPrivateStateRoot() } = {}) {
173
+ let record = updateActiveStatus("stopping", root);
174
+ let op;
175
+ if (draft) op = draftFromRecord(record.id, root);
176
+ record = readRecord(record.id, root);
177
+ record.status = draft ? "draft_created" : "stopped";
178
+ record.stoppedAt = new Date().toISOString();
179
+ writeRecord(record, root);
180
+ fs.unlinkSync(activePath(root));
181
+ return { record, ...(op ? { draft: op } : {}) };
182
+ }
183
+
184
+ function discardRecord(root = getPrivateStateRoot()) {
185
+ const record = activeRecord(root);
186
+ if (!record) throw new Error("no active playbook record");
187
+ const directory = assertWithin(recordsRoot(root), recordDirectory(record.id, root));
188
+ fs.rmSync(directory, { recursive: true, force: true });
189
+ try { fs.unlinkSync(activePath(root)); } catch {}
190
+ return { discarded: record.id };
191
+ }
192
+
193
+ module.exports = {
194
+ activeRecord,
195
+ appendRecordEvent,
196
+ attachNetworkTrace,
197
+ discardRecord,
198
+ draftFromRecord,
199
+ markRecord,
200
+ pauseRecord: (root) => updateActiveStatus("paused", root),
201
+ readEvents,
202
+ readRecord,
203
+ recordsRoot,
204
+ resumeRecord: (root) => updateActiveStatus("recording", root),
205
+ startRecord,
206
+ stopRecord,
207
+ updateRecordContext,
208
+ };