dsh-browser-playwright-codex 0.8.2 → 0.9.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/CHANGELOG.md +80 -0
- package/README.md +16 -4
- package/lib/browser-lifecycle.js +71 -4
- package/lib/client.js +97 -0
- package/lib/config.d.ts +11 -0
- package/lib/config.js +12 -1
- package/lib/contract.d.ts +6 -2
- package/lib/contract.js +8 -0
- package/lib/gates-patch.d.ts +29 -0
- package/lib/gates-patch.js +149 -0
- package/lib/provider.d.ts +6 -0
- package/lib/provider.js +10 -0
- package/lib/proxy.d.ts +56 -0
- package/lib/proxy.js +131 -0
- package/lib/script-runner.d.ts +65 -0
- package/lib/script-runner.js +280 -0
- package/lib/script-steps.d.ts +95 -0
- package/lib/script-steps.js +234 -0
- package/lib/script-store.d.ts +32 -0
- package/lib/script-store.js +104 -0
- package/lib/settings-card.js +62 -1
- package/lib/tool.js +143 -0
- package/package.json +1 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where saved scripts live: one JSON file per script under `~/.dsh/browser-scripts`.
|
|
3
|
+
*
|
|
4
|
+
* Kept deliberately dumb — read, write, list — because the interesting rules
|
|
5
|
+
* (what a step may contain, what may run) live in `script-steps.ts` and
|
|
6
|
+
* `script-runner.ts`. A store that also validated would be the second place to
|
|
7
|
+
* change when the step vocabulary grows.
|
|
8
|
+
*
|
|
9
|
+
* Names are restricted to a flat, boring charset and are never joined onto a
|
|
10
|
+
* path unvalidated: a script name reaches `path.join`, so `../` in a name would
|
|
11
|
+
* otherwise write anywhere the process can reach.
|
|
12
|
+
* @module dsh-browser-playwright-codex/script-store
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { ScriptStepError, parseScriptSteps } from "./script-steps.js";
|
|
18
|
+
/** A script name: letters, digits, dash and underscore, starting with a letter or digit. */
|
|
19
|
+
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
20
|
+
/** Directory holding the saved scripts. `DSH_BROWSER_SCRIPTS_DIR` overrides it (tests). */
|
|
21
|
+
export function scriptsDir() {
|
|
22
|
+
const override = process.env.DSH_BROWSER_SCRIPTS_DIR;
|
|
23
|
+
if (override !== undefined && override !== '')
|
|
24
|
+
return override;
|
|
25
|
+
return path.join(os.homedir(), '.dsh', 'browser-scripts');
|
|
26
|
+
}
|
|
27
|
+
/** Validate a script name, returning the trimmed value. */
|
|
28
|
+
export function validName(raw) {
|
|
29
|
+
const name = typeof raw === 'string' ? raw.trim() : '';
|
|
30
|
+
if (!NAME_RE.test(name)) {
|
|
31
|
+
throw new ScriptStepError(1, 'script name must be 1-64 characters of letters, digits, dash or underscore — got ' +
|
|
32
|
+
(name === '' ? '(empty)' : '"' + name + '"'));
|
|
33
|
+
}
|
|
34
|
+
return name;
|
|
35
|
+
}
|
|
36
|
+
/** Path of one script file. */
|
|
37
|
+
function scriptPath(name) {
|
|
38
|
+
return path.join(scriptsDir(), name + '.json');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Write a script, replacing any file of the same name.
|
|
42
|
+
*
|
|
43
|
+
* @param name - Validated script name.
|
|
44
|
+
* @param steps - Steps already validated by `parseScriptSteps`.
|
|
45
|
+
* @returns The file path and how many steps were stored.
|
|
46
|
+
*/
|
|
47
|
+
export function saveScript(name, steps) {
|
|
48
|
+
const file = scriptPath(name);
|
|
49
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
50
|
+
const body = JSON.stringify({ name, savedAt: new Date().toISOString(), steps }, null, 2);
|
|
51
|
+
// Write next to the target, then rename: a half-written file left by a crash
|
|
52
|
+
// would otherwise load as a script that silently lost its last steps.
|
|
53
|
+
const tmp = file + '.tmp';
|
|
54
|
+
fs.writeFileSync(tmp, body, 'utf8');
|
|
55
|
+
fs.renameSync(tmp, file);
|
|
56
|
+
return { path: file, steps: steps.length };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Read one script.
|
|
60
|
+
*
|
|
61
|
+
* @param name - Validated script name.
|
|
62
|
+
* @param maxSteps - Cap applied to the file's steps, so an edited file cannot
|
|
63
|
+
* smuggle in a longer list than the tool accepts.
|
|
64
|
+
*/
|
|
65
|
+
export function loadScript(name, maxSteps) {
|
|
66
|
+
// Validated here as well as at the tool boundary: this is exported, and a
|
|
67
|
+
// name that reaches `path.join` unvalidated is the one way out of the folder.
|
|
68
|
+
const safe = validName(name);
|
|
69
|
+
const file = scriptPath(safe);
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new ScriptStepError(1, 'no saved script named "' + safe + '"; save one with the save tool, or pass steps inline');
|
|
76
|
+
}
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(raw);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
throw new ScriptStepError(1, 'the saved script "' + safe + '" is not valid JSON; save it again');
|
|
83
|
+
}
|
|
84
|
+
const record = parsed;
|
|
85
|
+
const steps = parseScriptSteps(record.steps, maxSteps);
|
|
86
|
+
return {
|
|
87
|
+
name: safe,
|
|
88
|
+
savedAt: typeof record.savedAt === 'string' ? record.savedAt : '',
|
|
89
|
+
steps,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** Names of the saved scripts, sorted. Missing directory means none yet. */
|
|
93
|
+
export function listScripts() {
|
|
94
|
+
try {
|
|
95
|
+
return fs
|
|
96
|
+
.readdirSync(scriptsDir())
|
|
97
|
+
.filter((entry) => entry.endsWith('.json'))
|
|
98
|
+
.map((entry) => entry.slice(0, -'.json'.length))
|
|
99
|
+
.sort();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
package/lib/settings-card.js
CHANGED
|
@@ -62,7 +62,8 @@
|
|
|
62
62
|
import * as runtimeState from "./runtime-state.js";
|
|
63
63
|
// The patch writer lives in its own dependency-free module so it can be tested
|
|
64
64
|
// without a DSH host (a half-written patch layer would break every plugin load).
|
|
65
|
-
import { homePatchPath, parseGatesBlock, writeGates } from "./gates-patch.js";
|
|
65
|
+
import { homePatchPath, parseBrowserBlockProxy, parseGatesBlock, writeGates, writeProxy } from "./gates-patch.js";
|
|
66
|
+
import { normalizeProxy } from "./proxy.js";
|
|
66
67
|
import { onPersistPlayedChanged, resetPlayed } from "./swarm-intro.js";
|
|
67
68
|
import { readFileSync } from 'node:fs';
|
|
68
69
|
import { clean, isTargetId, resolvePaths, scan } from "./cache-clean.js";
|
|
@@ -90,6 +91,14 @@ function readGates() {
|
|
|
90
91
|
* shows the *effective* gates from readGates(); this is the file-level view used
|
|
91
92
|
* for diagnostics when the two disagree (i.e. the reload has not landed yet).
|
|
92
93
|
*/
|
|
94
|
+
function managedBlockProxy() {
|
|
95
|
+
try {
|
|
96
|
+
return parseBrowserBlockProxy(readFileSync(homePatchPath(), 'utf8'));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return '';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
93
102
|
function managedBlockGates() {
|
|
94
103
|
try {
|
|
95
104
|
const text = readFileSync(homePatchPath(), 'utf8');
|
|
@@ -391,6 +400,8 @@ export function apply(ctx) {
|
|
|
391
400
|
// What the user's own layer says right now. Differs from the above
|
|
392
401
|
// between a write and the live reload that applies it.
|
|
393
402
|
patchGates: managedBlockGates() ?? null,
|
|
403
|
+
// launch.proxy(设置页的「代理」输入框)。空 = 直连。
|
|
404
|
+
proxy: managedBlockProxy(),
|
|
394
405
|
});
|
|
395
406
|
},
|
|
396
407
|
}));
|
|
@@ -523,5 +534,55 @@ export function apply(ctx) {
|
|
|
523
534
|
sendJson(res, 200, { logins });
|
|
524
535
|
},
|
|
525
536
|
}));
|
|
537
|
+
// ---- launch.proxy:设置页里的「代理」输入框 ----
|
|
538
|
+
// 只改托管块里 `playwright.launch.proxy` 这一行(与三个闸门同一个模式),
|
|
539
|
+
// 其它键逐字节不动;空字符串 = 删掉那一行 = 直连。
|
|
540
|
+
ctx.effect(() => ctx.webServer.register({
|
|
541
|
+
kind: 'exact',
|
|
542
|
+
path: `${PREFIX}/proxy`,
|
|
543
|
+
handler: async (req, res) => {
|
|
544
|
+
if (req.method !== 'POST')
|
|
545
|
+
return sendJson(res, 405, { error: { code: 'METHOD', message: 'POST only' } });
|
|
546
|
+
if (req.headers[HEADER] !== 'browser-toggle')
|
|
547
|
+
return sendJson(res, 403, { error: { code: 'CSRF', message: 'missing plugin header' } });
|
|
548
|
+
const body = await readBody(req);
|
|
549
|
+
if (!body.ok)
|
|
550
|
+
return sendJson(res, 400, { error: { code: 'BODY_READ', message: body.error } });
|
|
551
|
+
let payload;
|
|
552
|
+
try {
|
|
553
|
+
payload = JSON.parse(body.text || '{}');
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
return sendJson(res, 400, { error: { code: 'INVALID_JSON', message: 'body must be JSON' } });
|
|
557
|
+
}
|
|
558
|
+
if (typeof payload.proxy !== 'string')
|
|
559
|
+
return sendJson(res, 400, { error: { code: 'INVALID_BODY', message: '`proxy` must be a string' } });
|
|
560
|
+
// 校验直接用 proxy.ts 的同一份实现:非法地址当场报错,文件一个字节不动。
|
|
561
|
+
let server = '';
|
|
562
|
+
try {
|
|
563
|
+
const normalized = normalizeProxy(payload.proxy);
|
|
564
|
+
server = normalized === undefined ? '' : normalized.server;
|
|
565
|
+
}
|
|
566
|
+
catch (error) {
|
|
567
|
+
return sendJson(res, 400, {
|
|
568
|
+
error: { code: 'INVALID_PROXY', message: String(error?.message ?? error) },
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
try {
|
|
572
|
+
const written = await writeProxy(server);
|
|
573
|
+
sendJson(res, 200, {
|
|
574
|
+
proxy: written.proxy,
|
|
575
|
+
patch: written.patch,
|
|
576
|
+
// 代理是浏览器启动参数:插件在加载时读配置,所以下次启动 DSH 才生效。
|
|
577
|
+
appliedOn: 'restart',
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
catch (error) {
|
|
581
|
+
sendJson(res, 500, {
|
|
582
|
+
error: { code: 'PATCH_WRITE', message: String(error?.message ?? error) },
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
},
|
|
586
|
+
}));
|
|
526
587
|
}
|
|
527
588
|
export default { name, inject, apply };
|
package/lib/tool.js
CHANGED
|
@@ -10,6 +10,9 @@ import { SCREENSHOT_SCHEMA, SNAPSHOT_SCHEMA, TABS_SCHEMA } from "./tool-schemas.
|
|
|
10
10
|
import { parseJsonText, extractionPrompt } from "./extract.js";
|
|
11
11
|
import { detectChallenge } from "./challenge.js";
|
|
12
12
|
import { describeWait, normalizeWaitCondition } from "./wait-conditions.js";
|
|
13
|
+
import { MAX_SCRIPT_STEPS, ScriptStepError, parseScriptSteps } from "./script-steps.js";
|
|
14
|
+
import { runScript } from "./script-runner.js";
|
|
15
|
+
import { listScripts, loadScript, saveScript, validName } from "./script-store.js";
|
|
13
16
|
import { renderDiagnostics, renderScreenshotValue, renderSnapshotValue, renderTabsValue, snapshotValue, } from "./tool-render.js";
|
|
14
17
|
export { detectChallenge };
|
|
15
18
|
/** Cordis plugin name used by loader diagnostics. */
|
|
@@ -692,6 +695,146 @@ export function apply(ctx, config) {
|
|
|
692
695
|
},
|
|
693
696
|
}));
|
|
694
697
|
}
|
|
698
|
+
ctx.tools.register(defineTool({
|
|
699
|
+
name: p('run_script'),
|
|
700
|
+
description: 'Run several browser steps in ONE call — the deterministic repeat of a flow the model already worked out. ' +
|
|
701
|
+
'Pass either a saved script by name or the steps inline. Steps: navigate, snapshot, click, fill, press, ' +
|
|
702
|
+
'hover, scroll, back, forward, wait. ' +
|
|
703
|
+
'A step names its element by `ref` (from the snapshot the previous step returned) or by a visible `text` label ' +
|
|
704
|
+
'(resolved against the freshest snapshot right before the action, so a label survives a re-render that would stale a ref). ' +
|
|
705
|
+
'IRON RULE: the run stops at the FIRST failing step and returns its index, its action, the failure text and the last good snapshot — ' +
|
|
706
|
+
'take over from there with the single-step tools; do NOT replay the whole script blind. ' +
|
|
707
|
+
'Use it for flows whose shape is known (login → open page → fill form → read result), not to explore: ' +
|
|
708
|
+
'explore with the single tools first, then save what worked. ' +
|
|
709
|
+
'Every step still passes the same URL policy, ref validation and credential refusal as its own tool.',
|
|
710
|
+
parameters: {
|
|
711
|
+
name: {
|
|
712
|
+
type: 'string',
|
|
713
|
+
description: 'Saved script to run (see ' + p('save_script') + '). Omit when passing `steps` inline.',
|
|
714
|
+
},
|
|
715
|
+
steps: {
|
|
716
|
+
type: 'array',
|
|
717
|
+
items: { type: 'object', additionalProperties: true },
|
|
718
|
+
description: 'Inline steps, used when `name` is omitted. Each: {action:"navigate",url} | {action:"snapshot"} | ' +
|
|
719
|
+
'{action:"click",ref?|text?} | {action:"fill",ref?|text?,value} | {action:"press",ref?|text?,key} | ' +
|
|
720
|
+
'{action:"hover",ref?|text?} | {action:"scroll",direction,amount?,ref?|text?} | {action:"back"} | ' +
|
|
721
|
+
'{action:"forward"} | {action:"wait",ms?|text?|selector?|networkIdle?|urlContains?}. ' +
|
|
722
|
+
'A step naming its element by `text` is retried once against a fresh snapshot before the run fails; ' +
|
|
723
|
+
'a step naming it by `ref` is not (a ref belongs to the document that produced it). ' +
|
|
724
|
+
'Max ' +
|
|
725
|
+
String(MAX_SCRIPT_STEPS) +
|
|
726
|
+
' steps.',
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
output: {
|
|
730
|
+
schema: { type: 'json' },
|
|
731
|
+
render: (_args, value) => {
|
|
732
|
+
const outcome = value;
|
|
733
|
+
const lines = [];
|
|
734
|
+
lines.push(String(outcome.ran) + '/' + String(outcome.total) + ' steps ran');
|
|
735
|
+
for (const entry of outcome.outcomes) {
|
|
736
|
+
lines.push((entry.ok ? '✔ ' : '✖ ') +
|
|
737
|
+
String(entry.index) +
|
|
738
|
+
'. ' +
|
|
739
|
+
entry.step +
|
|
740
|
+
(entry.note === undefined ? '' : ' — ' + entry.note));
|
|
741
|
+
}
|
|
742
|
+
if (outcome.failedAt !== undefined) {
|
|
743
|
+
lines.push('Stopped at step ' +
|
|
744
|
+
String(outcome.failedAt) +
|
|
745
|
+
' (' +
|
|
746
|
+
String(outcome.failedAction) +
|
|
747
|
+
'): ' +
|
|
748
|
+
String(outcome.failure) +
|
|
749
|
+
(outcome.failureCode === undefined ? '' : ' [' + outcome.failureCode + ']'));
|
|
750
|
+
lines.push('Fix that step and run again, or take over with the single-step tools from the snapshot below.');
|
|
751
|
+
}
|
|
752
|
+
if (outcome.snapshot !== undefined) {
|
|
753
|
+
lines.push('Page: ' + outcome.snapshot.title + ' — ' + outcome.snapshot.url);
|
|
754
|
+
}
|
|
755
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
756
|
+
},
|
|
757
|
+
},
|
|
758
|
+
async execute(args, exec) {
|
|
759
|
+
let steps;
|
|
760
|
+
try {
|
|
761
|
+
if (args.steps !== undefined && String(args.name ?? '').trim() !== '') {
|
|
762
|
+
throw new ScriptStepError(1, 'pass steps, or a saved name, not both');
|
|
763
|
+
}
|
|
764
|
+
steps =
|
|
765
|
+
args.steps !== undefined
|
|
766
|
+
? parseScriptSteps(args.steps, MAX_SCRIPT_STEPS)
|
|
767
|
+
: loadScript(validName(args.name), MAX_SCRIPT_STEPS).steps;
|
|
768
|
+
}
|
|
769
|
+
catch (error) {
|
|
770
|
+
const message = String(error.message ?? error);
|
|
771
|
+
throw new BrowserError('SCRIPT_INVALID', message);
|
|
772
|
+
}
|
|
773
|
+
const session = await acquire(exec);
|
|
774
|
+
const outcome = await runScript(session, steps, { signal: exec.signal });
|
|
775
|
+
return outcome;
|
|
776
|
+
},
|
|
777
|
+
}));
|
|
778
|
+
ctx.tools.register(defineTool({
|
|
779
|
+
name: p('save_script'),
|
|
780
|
+
description: 'Save a working step list under a name, so a later run costs one call instead of one call per action. ' +
|
|
781
|
+
'Save what actually worked in this session — the flow you just did with the single tools — not what you hope works. ' +
|
|
782
|
+
'Prefer `text` labels over refs in a saved script: refs belong to one snapshot and go stale on the next page load, ' +
|
|
783
|
+
'while a label is resolved fresh on every run. ' +
|
|
784
|
+
'Refuses to overwrite an existing name unless `overwrite` is true. ' +
|
|
785
|
+
'Scripts live in ~/.dsh/browser-scripts (one JSON file each) and never leave this machine.',
|
|
786
|
+
parameters: {
|
|
787
|
+
name: {
|
|
788
|
+
type: 'string',
|
|
789
|
+
required: true,
|
|
790
|
+
description: 'Script name: 1-64 letters, digits, dash or underscore (e.g. "login-and-read-orders").',
|
|
791
|
+
},
|
|
792
|
+
steps: {
|
|
793
|
+
type: 'array',
|
|
794
|
+
items: { type: 'object', additionalProperties: true },
|
|
795
|
+
required: true,
|
|
796
|
+
description: 'Steps to save, same shape as ' + p('run_script') + ' accepts. Max ' + String(MAX_SCRIPT_STEPS) + '.',
|
|
797
|
+
},
|
|
798
|
+
overwrite: { type: 'boolean', description: 'Replace an existing script of the same name. Defaults to false.' },
|
|
799
|
+
},
|
|
800
|
+
output: {
|
|
801
|
+
schema: { type: 'json' },
|
|
802
|
+
render: (_args, value) => {
|
|
803
|
+
const saved = value;
|
|
804
|
+
return [
|
|
805
|
+
{
|
|
806
|
+
type: 'text',
|
|
807
|
+
text: 'Saved "' +
|
|
808
|
+
saved.name +
|
|
809
|
+
'" (' +
|
|
810
|
+
String(saved.steps) +
|
|
811
|
+
' steps) → ' +
|
|
812
|
+
saved.path +
|
|
813
|
+
'. Run it with ' +
|
|
814
|
+
p('run_script') +
|
|
815
|
+
'({ name: "' +
|
|
816
|
+
saved.name +
|
|
817
|
+
'" }).',
|
|
818
|
+
},
|
|
819
|
+
];
|
|
820
|
+
},
|
|
821
|
+
},
|
|
822
|
+
async execute(args) {
|
|
823
|
+
try {
|
|
824
|
+
const name = validName(args.name);
|
|
825
|
+
if (args.overwrite !== true && listScripts().includes(name)) {
|
|
826
|
+
throw new ScriptStepError(1, 'a script named "' + name + '" already exists; pass overwrite:true to replace it');
|
|
827
|
+
}
|
|
828
|
+
const steps = parseScriptSteps(args.steps, MAX_SCRIPT_STEPS);
|
|
829
|
+
const written = saveScript(name, steps);
|
|
830
|
+
return { name, path: written.path, steps: written.steps };
|
|
831
|
+
}
|
|
832
|
+
catch (error) {
|
|
833
|
+
const message = String(error.message ?? error);
|
|
834
|
+
throw new BrowserError('SCRIPT_INVALID', message);
|
|
835
|
+
}
|
|
836
|
+
},
|
|
837
|
+
}));
|
|
695
838
|
ctx.tools.register(defineTool({
|
|
696
839
|
name: p('close'),
|
|
697
840
|
description: "Release this session's browser window. Persistent mode shares one window, so it closes only when the last session releases it; the profile keeps login state either way, so the next browser call reopens it still signed in (reset the profile to clear cookies).",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-browser-playwright-codex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Browser automation for DeepSeek Harness, one plugin: fill in forms, scrape lists and work across tabs in real pages, using the logins you already have. Runs in a visible window that stays quiet when minimised; native dialogs are parked instead of confirmed, CAPTCHA pages are flagged with a note that says stop and hand the page back, password / one-time-code / card fields are refused by browser_fill, and the Settings card (enable/disable, page-reading mode, capability gates) now lives inside this package.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|