surf-cli 2.8.0 → 2.10.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/README.md +146 -8
- package/native/abort.cjs +65 -0
- package/native/activity-journal.cjs +55 -0
- package/native/ai-queue.cjs +64 -0
- package/native/aistudio-build.cjs +21 -13
- package/native/aistudio-client.cjs +40 -20
- package/native/browser-lock.cjs +2 -2
- package/native/chatgpt-client.cjs +49 -31
- package/native/cli.cjs +352 -482
- package/native/client-transport.cjs +168 -0
- package/native/do-executor.cjs +68 -510
- package/native/do-parser.cjs +8 -249
- package/native/doctor.cjs +55 -5
- package/native/endpoint.cjs +174 -0
- package/native/file-transfer.cjs +734 -0
- package/native/gemini-client.cjs +156 -71
- package/native/grok-client.cjs +98 -89
- package/native/host-helpers.cjs +43 -26
- package/native/host-sessions.cjs +287 -0
- package/native/host.cjs +998 -620
- package/native/listener.cjs +20 -0
- package/native/mcp-server.cjs +60 -65
- package/native/network-export.cjs +116 -0
- package/native/network-store.cjs +38 -58
- package/native/perplexity-client.cjs +46 -17
- package/native/playbook-authoring.cjs +44 -0
- package/native/playbook-cli.cjs +157 -0
- package/native/playbook-client.cjs +259 -0
- package/native/playbook-receipts.cjs +109 -0
- package/native/playbook-records.cjs +208 -0
- package/native/playbook-runtime.cjs +177 -0
- package/native/playbooks.cjs +235 -0
- package/native/private-state.cjs +156 -0
- package/native/redaction.cjs +104 -0
- package/native/remote-auth.cjs +279 -0
- package/native/remote-transport.cjs +337 -0
- package/native/request-pending.cjs +148 -0
- package/native/socket-path.cjs +1 -1
- package/native/workflow-definition.cjs +368 -0
- package/native/workflow-runtime.cjs +225 -0
- package/package.json +9 -6
- package/playbooks/page/ops/read.json +22 -0
- package/playbooks/page/playbook.json +7 -0
- package/scripts/install-native-host.cjs +36 -5
- package/skills/README.md +11 -5
- package/skills/deep-x-research/SKILL.md +106 -0
- package/skills/surf/SKILL.md +72 -5
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
* Similar approach to the ChatGPT client.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
const { abortableDelay, raceAbort, throwIfAborted } = require("./abort.cjs");
|
|
9
|
+
|
|
8
10
|
const PERPLEXITY_URL = "https://www.perplexity.ai/";
|
|
9
11
|
|
|
10
12
|
// ============================================================================
|
|
11
13
|
// Helpers
|
|
12
14
|
// ============================================================================
|
|
13
15
|
|
|
14
|
-
function delay(ms) {
|
|
15
|
-
return
|
|
16
|
+
function delay(ms, signal) {
|
|
17
|
+
return abortableDelay(ms, signal);
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
function buildClickDispatcher() {
|
|
@@ -371,7 +373,27 @@ async function submitPrompt(cdp, inputCdp) {
|
|
|
371
373
|
// Response Handling
|
|
372
374
|
// ============================================================================
|
|
373
375
|
|
|
374
|
-
|
|
376
|
+
function extractPerplexityResponseText() {
|
|
377
|
+
const selectors = [
|
|
378
|
+
'[id^="markdown-content"]',
|
|
379
|
+
'[data-testid="answer"]',
|
|
380
|
+
'article',
|
|
381
|
+
'.prose',
|
|
382
|
+
];
|
|
383
|
+
|
|
384
|
+
for (const selector of selectors) {
|
|
385
|
+
const elements = Array.from(document.querySelectorAll(selector));
|
|
386
|
+
for (let i = elements.length - 1; i >= 0; i--) {
|
|
387
|
+
const text = elements[i].innerText?.trim() || '';
|
|
388
|
+
if (text) return text;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return '';
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function waitForResponse(cdp, timeoutMs = 120000, signal) {
|
|
396
|
+
throwIfAborted(signal);
|
|
375
397
|
const deadline = Date.now() + timeoutMs;
|
|
376
398
|
let previousText = '';
|
|
377
399
|
let stableCycles = 0;
|
|
@@ -386,17 +408,16 @@ async function waitForResponse(cdp, timeoutMs = 120000) {
|
|
|
386
408
|
if (url && url.includes('/search/')) {
|
|
387
409
|
break;
|
|
388
410
|
}
|
|
389
|
-
await delay(200);
|
|
411
|
+
await delay(200, signal);
|
|
390
412
|
}
|
|
391
413
|
|
|
392
414
|
// Wait a bit for the response area to render
|
|
393
|
-
await delay(1000);
|
|
415
|
+
await delay(1000, signal);
|
|
394
416
|
|
|
395
417
|
// Now poll for response completion
|
|
396
418
|
while (Date.now() < deadline) {
|
|
397
419
|
const snapshot = await evaluate(cdp, `(function() {
|
|
398
|
-
const
|
|
399
|
-
const text = prose ? prose.innerText : '';
|
|
420
|
+
const text = (${extractPerplexityResponseText.toString()})();
|
|
400
421
|
const hasStop = !!document.querySelector('button[aria-label*=stop], button[aria-label*=Stop]');
|
|
401
422
|
const hasCopy = !!document.querySelector('button[aria-label*=copy], button[aria-label*=Copy]');
|
|
402
423
|
const hasRelated = document.body.innerText.indexOf('Related') > -1;
|
|
@@ -412,7 +433,7 @@ async function waitForResponse(cdp, timeoutMs = 120000) {
|
|
|
412
433
|
})()`);
|
|
413
434
|
|
|
414
435
|
if (!snapshot) {
|
|
415
|
-
await delay(300);
|
|
436
|
+
await delay(300, signal);
|
|
416
437
|
continue;
|
|
417
438
|
}
|
|
418
439
|
|
|
@@ -437,7 +458,7 @@ async function waitForResponse(cdp, timeoutMs = 120000) {
|
|
|
437
458
|
const hasCompletionIndicators = snapshot.hasActions || snapshot.hasRelated || snapshot.hasFollowUp;
|
|
438
459
|
const isDone = !snapshot.generating && (hasCompletionIndicators || isStable);
|
|
439
460
|
|
|
440
|
-
if (isDone && currentText.length >
|
|
461
|
+
if (isDone && currentText.trim().length > 0) {
|
|
441
462
|
// Clean up the response text
|
|
442
463
|
let cleanText = currentText;
|
|
443
464
|
|
|
@@ -454,11 +475,11 @@ async function waitForResponse(cdp, timeoutMs = 120000) {
|
|
|
454
475
|
};
|
|
455
476
|
}
|
|
456
477
|
|
|
457
|
-
await delay(300);
|
|
478
|
+
await delay(300, signal);
|
|
458
479
|
}
|
|
459
480
|
|
|
460
481
|
// Timeout - return whatever we have
|
|
461
|
-
if (previousText.length >
|
|
482
|
+
if (previousText.trim().length > 0) {
|
|
462
483
|
return {
|
|
463
484
|
text: previousText,
|
|
464
485
|
sources: 0,
|
|
@@ -485,13 +506,15 @@ async function query(options) {
|
|
|
485
506
|
cdpEvaluate,
|
|
486
507
|
cdpCommand,
|
|
487
508
|
log = () => {},
|
|
509
|
+
signal,
|
|
488
510
|
} = options;
|
|
511
|
+
throwIfAborted(signal);
|
|
489
512
|
|
|
490
513
|
const startTime = Date.now();
|
|
491
514
|
log("Starting Perplexity query");
|
|
492
515
|
|
|
493
516
|
// Create tab
|
|
494
|
-
const tabInfo = await createTab
|
|
517
|
+
const tabInfo = await raceAbort(createTab, signal);
|
|
495
518
|
log(`createTab returned: ${JSON.stringify(tabInfo)}`);
|
|
496
519
|
const { tabId } = tabInfo || {};
|
|
497
520
|
|
|
@@ -500,8 +523,8 @@ async function query(options) {
|
|
|
500
523
|
}
|
|
501
524
|
log(`Created tab ${tabId}`);
|
|
502
525
|
|
|
503
|
-
const cdp = (expr) => cdpEvaluate(tabId, expr);
|
|
504
|
-
const inputCdp = (method, params) => cdpCommand(tabId, method, params);
|
|
526
|
+
const cdp = (expr) => raceAbort(() => cdpEvaluate(tabId, expr), signal);
|
|
527
|
+
const inputCdp = (method, params) => raceAbort(() => cdpCommand(tabId, method, params), signal);
|
|
505
528
|
|
|
506
529
|
try {
|
|
507
530
|
// Wait for page load
|
|
@@ -522,6 +545,7 @@ async function query(options) {
|
|
|
522
545
|
const selectedMode = await selectMode(cdp, mode);
|
|
523
546
|
log(`Mode: ${selectedMode}`);
|
|
524
547
|
} catch (e) {
|
|
548
|
+
if (signal?.aborted) throw e;
|
|
525
549
|
log(`Mode selection failed: ${e.message}`);
|
|
526
550
|
}
|
|
527
551
|
}
|
|
@@ -532,6 +556,7 @@ async function query(options) {
|
|
|
532
556
|
const selectedModel = await selectModel(cdp, model);
|
|
533
557
|
log(`Model: ${selectedModel}`);
|
|
534
558
|
} catch (e) {
|
|
559
|
+
if (signal?.aborted) throw e;
|
|
535
560
|
log(`Model selection failed: ${e.message}`);
|
|
536
561
|
}
|
|
537
562
|
}
|
|
@@ -545,7 +570,7 @@ async function query(options) {
|
|
|
545
570
|
log("Submitted, waiting for response...");
|
|
546
571
|
|
|
547
572
|
// Wait for response
|
|
548
|
-
const response = await waitForResponse(cdp, timeout);
|
|
573
|
+
const response = await waitForResponse(cdp, timeout, signal);
|
|
549
574
|
log(`Response: ${response.text.length} chars, ${response.sources} sources${response.partial ? ' (partial)' : ''}`);
|
|
550
575
|
|
|
551
576
|
return {
|
|
@@ -558,8 +583,12 @@ async function query(options) {
|
|
|
558
583
|
tookMs: Date.now() - startTime,
|
|
559
584
|
};
|
|
560
585
|
} finally {
|
|
561
|
-
|
|
586
|
+
try {
|
|
587
|
+
await closeTab(tabId);
|
|
588
|
+
} catch (error) {
|
|
589
|
+
log(`Failed to close Perplexity tab ${tabId}: ${error?.message || error}`);
|
|
590
|
+
}
|
|
562
591
|
}
|
|
563
592
|
}
|
|
564
593
|
|
|
565
|
-
module.exports = { query, PERPLEXITY_URL };
|
|
594
|
+
module.exports = { query, PERPLEXITY_URL, waitForResponse, extractPerplexityResponseText };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { readRecent } = require("./activity-journal.cjs");
|
|
4
|
+
const { writeNetworkExport } = require("./network-export.cjs");
|
|
5
|
+
const { getPrivateStateRoot, readPrivateJson } = require("./private-state.cjs");
|
|
6
|
+
const { draftFromRecord, readRecord, recordsRoot } = require("./playbook-records.cjs");
|
|
7
|
+
const { savePlaybook, validateOp } = require("./playbooks.cjs");
|
|
8
|
+
const { commandMetadata, promoteRedactedStepArgs } = require("./workflow-definition.cjs");
|
|
9
|
+
|
|
10
|
+
function suggestions({ since = "1h", root } = {}) {
|
|
11
|
+
const events = readRecent({ since, root });
|
|
12
|
+
const counts = new Map();
|
|
13
|
+
for (const event of events.filter((entry) => entry.type === "tool.issued")) counts.set(event.command, (counts.get(event.command) || 0) + 1);
|
|
14
|
+
return [...counts.entries()].map(([command, count]) => ({ command, count })).sort((a, b) => b.count - a.count || a.command.localeCompare(b.command));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function saveFromRecent({ site, op: opId, since = "1h", scope = "user", cwd, home, root }) {
|
|
18
|
+
const events = readRecent({ since, root }).filter((event) => event.type === "tool.issued");
|
|
19
|
+
if (events.length === 0) throw new Error("no recent Surf activity to save");
|
|
20
|
+
if (events.some((event) => ["page-write", "unknown"].includes(commandMetadata(event.command).effect))) {
|
|
21
|
+
throw new Error("recent activity includes write-capable commands; use an explicit record and review its draft before saving");
|
|
22
|
+
}
|
|
23
|
+
const promoted = promoteRedactedStepArgs(events.map((event) => ({ tool: event.command, args: event.argsRedacted || {} })));
|
|
24
|
+
const op = { id: opId, description: "Drafted from recent Surf activity", effect: "read", args: promoted.args, run: [{ using: "workflow", steps: promoted.steps }], provenance: { recentSince: since } };
|
|
25
|
+
validateOp(op, { origins: [] });
|
|
26
|
+
return savePlaybook({ manifest: { id: site, name: site, version: "1.0.0", origins: [] }, op, scope, cwd, home });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function saveFromRecord({ recordId, scope = "user", cwd, home, root = getPrivateStateRoot() }) {
|
|
30
|
+
const record = readRecord(recordId, root);
|
|
31
|
+
if (!record) throw new Error(`record not found: ${recordId}`);
|
|
32
|
+
const draftPath = path.join(recordsRoot(root), recordId, "draft", "op.json");
|
|
33
|
+
const op = fs.existsSync(draftPath) ? readPrivateJson(draftPath, null, { root }) : draftFromRecord(recordId, root);
|
|
34
|
+
validateOp(op, { origins: record.origin ? [record.origin] : [] });
|
|
35
|
+
return savePlaybook({ manifest: { id: record.site, name: record.site, version: "1.0.0", origins: record.origin ? [record.origin] : [] }, op, scope, cwd, home });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function exportRecordHar(recordId, output, root = getPrivateStateRoot()) {
|
|
39
|
+
const trace = readPrivateJson(path.join(recordsRoot(root), recordId, "network", "trace.json"), null, { root });
|
|
40
|
+
if (!trace) throw new Error(`record ${recordId} has no network trace`);
|
|
41
|
+
return writeNetworkExport(path.resolve(output), trace.entries, "har");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { exportRecordHar, saveFromRecent, saveFromRecord, suggestions };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
const { openClientTransport } = require("./client-transport.cjs");
|
|
3
|
+
const { resolveRequestDeadlineMs } = require("./host-sessions.cjs");
|
|
4
|
+
const { exportRecordHar, saveFromRecent, saveFromRecord, suggestions } = require("./playbook-authoring.cjs");
|
|
5
|
+
const { deriveClient, exportClient, verifyClient } = require("./playbook-client.cjs");
|
|
6
|
+
const { exportPlaybookDirectory, importPlaybookDirectory, listPlaybooks, resolvePlaybook } = require("./playbooks.cjs");
|
|
7
|
+
|
|
8
|
+
function parseCommandArgs(argv) {
|
|
9
|
+
const positional = [];
|
|
10
|
+
const options = {};
|
|
11
|
+
for (let index = 0; index < argv.length; index++) {
|
|
12
|
+
const value = argv[index];
|
|
13
|
+
if (!value.startsWith("--")) {
|
|
14
|
+
positional.push(value);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const name = value.slice(2);
|
|
18
|
+
const next = argv[index + 1];
|
|
19
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
20
|
+
options[name] = /^-?\d+(?:\.\d+)?$/.test(next) ? Number(next) : next;
|
|
21
|
+
index++;
|
|
22
|
+
} else options[name] = true;
|
|
23
|
+
}
|
|
24
|
+
return { positional, options };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function unwrapResponse(response) {
|
|
28
|
+
if (response.error) throw new Error(response.error.content?.[0]?.text || JSON.stringify(response.error));
|
|
29
|
+
const text = response.result?.content?.[0]?.text;
|
|
30
|
+
if (text === undefined) return response.result;
|
|
31
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function requestHost(endpoint, tool, args, options = {}) {
|
|
35
|
+
const transport = await openClientTransport(endpoint, { requestTimeoutMs: options.timeoutMs || 11 * 60 * 1000 });
|
|
36
|
+
try {
|
|
37
|
+
const request = { type: "tool_request", method: "execute_tool", params: { tool, args }, id: `playbook-${Date.now()}-${Math.random()}` };
|
|
38
|
+
if (options.tabId) request.tabId = options.tabId;
|
|
39
|
+
return unwrapResponse(await transport.request(request, options.timeoutMs || 11 * 60 * 1000));
|
|
40
|
+
} finally {
|
|
41
|
+
await transport.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function runSpec(argv) {
|
|
46
|
+
const direct = argv[0] === "use";
|
|
47
|
+
const offset = direct ? 1 : 2;
|
|
48
|
+
const parsed = parseCommandArgs(argv.slice(offset));
|
|
49
|
+
const [playbook, op] = parsed.positional;
|
|
50
|
+
if (!playbook || !op) throw new Error(direct ? "Usage: surf use <playbook> <op> [--arg value]" : "Usage: surf pb run <playbook> <op> [--arg value]");
|
|
51
|
+
const reserved = new Set(["json", "no-lock", "tab-id", "write", "repeat", "retry-attempt", "override-in-doubt", "pin-built-in"]);
|
|
52
|
+
const args = Object.fromEntries(Object.entries(parsed.options).filter(([name]) => !reserved.has(name)));
|
|
53
|
+
return { playbook, op, args, options: parsed.options };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveRunTimeout(spec, cwd) {
|
|
57
|
+
const explicit = Number(spec.args.timeout);
|
|
58
|
+
if (Number.isFinite(explicit) && explicit > 0) return explicit;
|
|
59
|
+
try {
|
|
60
|
+
const playbook = resolvePlaybook(spec.playbook, { cwd, pinBuiltIn: spec.options["pin-built-in"] === true });
|
|
61
|
+
const op = playbook.ops.get(spec.op);
|
|
62
|
+
const value = Number(op?.args?.timeout?.default);
|
|
63
|
+
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function playbookCommandNeedsBrowser(argv) {
|
|
70
|
+
if (argv[0] === "use") return true;
|
|
71
|
+
const subcommand = argv[1];
|
|
72
|
+
if (subcommand === "run") return true;
|
|
73
|
+
return subcommand === "record" && ["start", "stop", "discard"].includes(argv[2]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function handlePlaybookCli(argv, { endpoint, cwd = process.cwd() }) {
|
|
77
|
+
if (!["playbook", "pb", "use"].includes(argv[0])) return { handled: false };
|
|
78
|
+
if (argv[0] === "use" || argv[1] === "run") {
|
|
79
|
+
const spec = runSpec(argv);
|
|
80
|
+
const timeout = resolveRunTimeout(spec, cwd);
|
|
81
|
+
const args = {
|
|
82
|
+
playbook: spec.playbook,
|
|
83
|
+
op: spec.op,
|
|
84
|
+
args: spec.args,
|
|
85
|
+
projectDir: cwd,
|
|
86
|
+
...(timeout ? { timeout } : {}),
|
|
87
|
+
write: spec.options.write === true,
|
|
88
|
+
repeat: spec.options.repeat === true,
|
|
89
|
+
retryAttempt: spec.options["retry-attempt"],
|
|
90
|
+
overrideInDoubt: spec.options["override-in-doubt"] === true,
|
|
91
|
+
pinBuiltIn: spec.options["pin-built-in"] === true,
|
|
92
|
+
};
|
|
93
|
+
const value = await requestHost(endpoint, "playbook.run", args, {
|
|
94
|
+
tabId: spec.options["tab-id"],
|
|
95
|
+
timeoutMs: resolveRequestDeadlineMs("playbook.run", args),
|
|
96
|
+
});
|
|
97
|
+
return { handled: true, value, json: spec.options.json === true };
|
|
98
|
+
}
|
|
99
|
+
const command = argv[1];
|
|
100
|
+
const parsed = parseCommandArgs(argv.slice(2));
|
|
101
|
+
if (!command || command === "help") return { handled: true, value: "Usage: surf playbook|pb <list|show|ops|run|record|suggest|save|client|trace|export|import>" };
|
|
102
|
+
if (endpoint?.kind === "remote" && ["list", "show", "ops"].includes(command)) throw new Error(`playbook ${command} is local-only with --remote because runs resolve on the browser host`);
|
|
103
|
+
if (command === "list") return { handled: true, value: listPlaybooks({ cwd }), json: parsed.options.json === true };
|
|
104
|
+
if (command === "show") {
|
|
105
|
+
const playbook = resolvePlaybook(parsed.positional[0], { cwd });
|
|
106
|
+
return { handled: true, value: { id: playbook.id, name: playbook.name, version: playbook.version, description: playbook.description, origins: playbook.origins, provenance: playbook.provenance, ops: [...playbook.ops.keys()] }, json: parsed.options.json === true };
|
|
107
|
+
}
|
|
108
|
+
if (command === "ops") {
|
|
109
|
+
const playbook = resolvePlaybook(parsed.positional[0], { cwd });
|
|
110
|
+
return { handled: true, value: [...playbook.ops.values()].map((op) => ({ id: op.id, description: op.description || "", effect: op.effect, strategies: op.run.map((strategy) => strategy.using) })), json: parsed.options.json === true };
|
|
111
|
+
}
|
|
112
|
+
if (command === "record") {
|
|
113
|
+
const action = parsed.positional[0];
|
|
114
|
+
const tool = `playbook.record.${action}`;
|
|
115
|
+
let args = {};
|
|
116
|
+
if (action === "start") args = { site: parsed.positional[1], op: parsed.options.op, watch: parsed.options.watch === true, network: parsed.options.network === true, includeInputValues: parsed.options["include-input-values"] === true };
|
|
117
|
+
else if (action === "mark") args = { label: parsed.positional.slice(1).join(" ") };
|
|
118
|
+
else if (action === "stop") args = { draft: parsed.options.draft === true };
|
|
119
|
+
else if (!["status", "pause", "resume", "discard"].includes(action)) throw new Error("Unknown record command");
|
|
120
|
+
const value = await requestHost(endpoint, tool, args, { tabId: parsed.options["tab-id"] });
|
|
121
|
+
return { handled: true, value, json: parsed.options.json === true };
|
|
122
|
+
}
|
|
123
|
+
if (command === "suggest") return { handled: true, value: suggestions({ since: parsed.options.since || "1h" }), json: parsed.options.json === true };
|
|
124
|
+
if (command === "save") {
|
|
125
|
+
let value;
|
|
126
|
+
if (parsed.options["from-record"]) value = saveFromRecord({ recordId: parsed.options["from-record"], scope: parsed.options.project ? "project" : "user", cwd });
|
|
127
|
+
else if (parsed.options["from-recent"] || parsed.positional[0]) value = saveFromRecent({ site: parsed.positional[0], op: parsed.options.op, since: parsed.options["from-recent"] === true ? "1h" : parsed.options["from-recent"] || "1h", scope: parsed.options.project ? "project" : "user", cwd });
|
|
128
|
+
else throw new Error("save requires --from-record <id> or <site> --op <name> --from-recent");
|
|
129
|
+
return { handled: true, value, json: parsed.options.json === true };
|
|
130
|
+
}
|
|
131
|
+
if (command === "client") {
|
|
132
|
+
const action = parsed.positional[0];
|
|
133
|
+
if (action === "derive") return { handled: true, value: deriveClient(parsed.positional[1], parsed.options.op, parsed.options.out, { recordId: parsed.options["from-record"], requestId: parsed.options["request-id"] }), json: parsed.options.json === true };
|
|
134
|
+
if (action === "export") {
|
|
135
|
+
const playbook = parsed.positional[1];
|
|
136
|
+
const resolved = resolvePlaybook(playbook, { cwd });
|
|
137
|
+
const op = parsed.options.op || [...resolved.ops.keys()][0];
|
|
138
|
+
return { handled: true, value: exportClient(playbook, op, parsed.options.out, { cwd }), json: parsed.options.json === true };
|
|
139
|
+
}
|
|
140
|
+
if (action === "verify") return { handled: true, value: await verifyClient(parsed.positional[1], { live: parsed.options.live === true ? true : undefined }), json: parsed.options.json === true };
|
|
141
|
+
throw new Error("Unknown client command");
|
|
142
|
+
}
|
|
143
|
+
if (command === "trace" && parsed.positional[0] === "export") {
|
|
144
|
+
if (!parsed.options["from-record"] || !parsed.options.har) throw new Error("trace export requires --from-record <id> --har <path>");
|
|
145
|
+
return { handled: true, value: exportRecordHar(parsed.options["from-record"], path.resolve(parsed.options.har)), json: parsed.options.json === true };
|
|
146
|
+
}
|
|
147
|
+
if (command === "export") return { handled: true, value: exportPlaybookDirectory(parsed.positional[0], { out: parsed.options.out, cwd }), json: parsed.options.json === true };
|
|
148
|
+
if (command === "import") return { handled: true, value: importPlaybookDirectory(parsed.positional[0], { scope: parsed.options.project ? "project" : "user", cwd }), json: parsed.options.json === true };
|
|
149
|
+
throw new Error(`Unknown playbook command: ${command}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function formatPlaybookOutput(value, json = false) {
|
|
153
|
+
if (json || typeof value !== "string") return JSON.stringify(value, null, 2);
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { formatPlaybookOutput, handlePlaybookCli, playbookCommandNeedsBrowser };
|
|
@@ -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 };
|