harnery 0.31.6 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +2 -0
- package/dist/commands/agents.d.ts +0 -9
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +70 -95
- package/dist/commands/browse-session.d.ts +21 -0
- package/dist/commands/browse-session.d.ts.map +1 -0
- package/dist/commands/browse-session.js +157 -0
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +205 -37
- package/dist/commands/checkpoint.js +1 -1
- package/dist/commands/deinit.d.ts.map +1 -1
- package/dist/commands/deinit.js +4 -0
- package/dist/commands/docs.d.ts.map +1 -1
- package/dist/commands/docs.js +40 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +100 -17
- package/dist/commands/init.d.ts +1 -2
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +16 -5
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +2 -3
- package/dist/core/agents/canonical-emit.d.ts +1 -2
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +1 -2
- package/dist/core/agents/cli.js +123 -88
- package/dist/core/agents/coord-client.d.ts +7 -3
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +5 -4
- package/dist/core/agents/finalization.d.ts +68 -0
- package/dist/core/agents/finalization.d.ts.map +1 -0
- package/dist/core/agents/finalization.js +443 -0
- package/dist/core/agents/git-hook.d.ts +51 -0
- package/dist/core/agents/git-hook.d.ts.map +1 -0
- package/dist/core/agents/git-hook.js +118 -0
- package/dist/core/agents/render/prompt-context.d.ts +6 -5
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +26 -13
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +15 -2
- package/dist/core/agents/rules/claim-conflict.js +3 -3
- package/dist/core/agents/rules/commit-conflict.d.ts +15 -6
- package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/commit-conflict.js +21 -5
- package/dist/core/agents/rules/stop-hook.d.ts +3 -0
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
- package/dist/core/agents/rules/stop-hook.js +59 -23
- package/dist/core/agents/session-events.d.ts +8 -16
- package/dist/core/agents/session-events.d.ts.map +1 -1
- package/dist/core/agents/session-events.js +12 -26
- package/dist/core/agents/state/heartbeat-projector.d.ts +3 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +44 -1
- package/dist/core/agents/state/heartbeat-writer.d.ts +32 -5
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +54 -17
- package/dist/core/agents/state/names.d.ts +22 -1
- package/dist/core/agents/state/names.d.ts.map +1 -1
- package/dist/core/agents/state/names.js +40 -2
- package/dist/core/config.d.ts +30 -2
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +74 -11
- package/dist/core/governor/planning.d.ts.map +1 -1
- package/dist/core/governor/planning.js +12 -13
- package/dist/core/hooks/adapter/detect.d.ts +2 -4
- package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
- package/dist/core/hooks/adapter/detect.js +4 -14
- package/dist/core/hooks/adapter/output.d.ts +1 -1
- package/dist/core/hooks/adapter/output.d.ts.map +1 -1
- package/dist/core/hooks/adapter/output.js +5 -4
- package/dist/core/hooks/adapter/wiring.d.ts +23 -0
- package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
- package/dist/core/hooks/adapter/wiring.js +32 -0
- package/dist/core/hooks/cli.d.ts +1 -2
- package/dist/core/hooks/cli.d.ts.map +1 -1
- package/dist/core/hooks/cli.js +169 -27
- package/dist/core/hooks/codex-wsl-bridge.d.ts +46 -0
- package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -0
- package/dist/core/hooks/codex-wsl-bridge.js +137 -0
- package/dist/core/hooks/effects/index.d.ts +1 -1
- package/dist/core/hooks/effects/index.js +3 -3
- package/dist/core/hooks/events/schema.d.ts +27 -0
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/hooks/resolve/owner.d.ts +7 -1
- package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
- package/dist/core/hooks/resolve/owner.js +15 -0
- package/dist/core/hooks/resolve/transcript.d.ts +44 -0
- package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
- package/dist/core/hooks/resolve/transcript.js +176 -1
- package/dist/core/hooks/session-name-presence.d.ts +30 -0
- package/dist/core/hooks/session-name-presence.d.ts.map +1 -0
- package/dist/core/hooks/session-name-presence.js +43 -0
- package/dist/core/hooks/unsafe-cross-shell.d.ts +13 -0
- package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -0
- package/dist/core/hooks/unsafe-cross-shell.js +151 -0
- package/dist/core/work/state.d.ts +4 -5
- package/dist/core/work/state.d.ts.map +1 -1
- package/dist/core/work/state.js +5 -8
- package/dist/core/workflow/index.d.ts +1 -1
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/proof.d.ts +2 -2
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +2 -2
- package/dist/core/workflow/run-state.d.ts +2 -2
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +2 -2
- package/dist/core/workflow/types.d.ts +3 -3
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/execution.d.ts +2 -2
- package/dist/core/workflow/workspaces/execution.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/execution.js +5 -0
- package/dist/core/workflow/workspaces/index.d.ts +1 -1
- package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.js +1 -2
- package/dist/core/workflow/workspaces/paths.d.ts +2 -2
- package/dist/core/workflow/workspaces/types.d.ts +0 -6
- package/dist/core/workflow/workspaces/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/validate.d.ts +0 -2
- package/dist/core/workflow/workspaces/validate.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/validate.js +0 -2
- package/dist/lib/browser/client.d.ts +118 -1
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +435 -6
- package/dist/lib/browser/geometry.d.ts.map +1 -1
- package/dist/lib/browser/geometry.js +180 -37
- package/dist/lib/browser/index.d.ts +5 -1
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +5 -1
- package/dist/lib/browser/netscape-cookies.d.ts +6 -0
- package/dist/lib/browser/netscape-cookies.d.ts.map +1 -0
- package/dist/lib/browser/netscape-cookies.js +31 -0
- package/dist/lib/browser/proxy.d.ts +24 -0
- package/dist/lib/browser/proxy.d.ts.map +1 -0
- package/dist/lib/browser/proxy.js +84 -0
- package/dist/lib/browser/runts.d.ts +6 -0
- package/dist/lib/browser/runts.d.ts.map +1 -1
- package/dist/lib/browser/runts.js +21 -4
- package/dist/lib/browser/session-control.d.ts +112 -0
- package/dist/lib/browser/session-control.d.ts.map +1 -0
- package/dist/lib/browser/session-control.js +670 -0
- package/dist/lib/docs-links.d.ts +108 -0
- package/dist/lib/docs-links.d.ts.map +1 -0
- package/dist/lib/docs-links.js +555 -0
- package/dist/lib/exec.d.ts +1 -1
- package/dist/lib/exec.js +1 -1
- package/dist/lib/identities/assume.d.ts +4 -2
- package/dist/lib/identities/assume.d.ts.map +1 -1
- package/dist/lib/identities/assume.js +15 -2
- package/dist/lib/instructions/git-hooks.d.ts +75 -0
- package/dist/lib/instructions/git-hooks.d.ts.map +1 -0
- package/dist/lib/instructions/git-hooks.js +238 -0
- package/dist/lib/instructions/splice.d.ts +10 -4
- package/dist/lib/instructions/splice.d.ts.map +1 -1
- package/dist/lib/instructions/splice.js +19 -11
- package/package.json +1 -1
- package/schemas/config.schema.json +32 -0
- package/src/commander.ts +2 -0
- package/src/commands/agents.ts +96 -115
- package/src/commands/browse-session.ts +245 -0
- package/src/commands/browse.ts +287 -44
- package/src/commands/checkpoint.ts +1 -1
- package/src/commands/deinit.ts +7 -1
- package/src/commands/docs.ts +56 -0
- package/src/commands/doctor.ts +100 -18
- package/src/commands/init.ts +17 -9
- package/src/commands/tunnel.ts +2 -5
- package/src/core/agents/canonical-emit.ts +1 -2
- package/src/core/agents/cli.ts +137 -88
- package/src/core/agents/coord-client.ts +10 -5
- package/src/core/agents/finalization.ts +595 -0
- package/src/core/agents/git-hook.ts +126 -0
- package/src/core/agents/render/prompt-context.ts +28 -13
- package/src/core/agents/render/session-context.ts +15 -2
- package/src/core/agents/rules/claim-conflict.ts +3 -3
- package/src/core/agents/rules/commit-conflict.ts +35 -8
- package/src/core/agents/rules/stop-hook.ts +71 -23
- package/src/core/agents/session-events.ts +14 -41
- package/src/core/agents/state/heartbeat-projector.ts +45 -1
- package/src/core/agents/state/heartbeat-writer.ts +78 -16
- package/src/core/agents/state/names.ts +54 -2
- package/src/core/config.ts +92 -12
- package/src/core/governor/planning.ts +17 -12
- package/src/core/hooks/adapter/detect.ts +4 -13
- package/src/core/hooks/adapter/output.ts +9 -4
- package/src/core/hooks/adapter/wiring.ts +43 -0
- package/src/core/hooks/cli.ts +201 -23
- package/src/core/hooks/codex-wsl-bridge.ts +188 -0
- package/src/core/hooks/effects/index.ts +5 -5
- package/src/core/hooks/events/schema.ts +27 -0
- package/src/core/hooks/resolve/owner.ts +22 -1
- package/src/core/hooks/resolve/transcript.ts +164 -1
- package/src/core/hooks/session-name-presence.ts +52 -0
- package/src/core/hooks/unsafe-cross-shell.ts +160 -0
- package/src/core/work/state.ts +8 -12
- package/src/core/workflow/engine.ts +2 -2
- package/src/core/workflow/index.ts +0 -1
- package/src/core/workflow/proof.ts +4 -4
- package/src/core/workflow/run-state.ts +5 -5
- package/src/core/workflow/types.ts +3 -3
- package/src/core/workflow/workspaces/execution.ts +11 -4
- package/src/core/workflow/workspaces/index.ts +0 -3
- package/src/core/workflow/workspaces/local-git.ts +1 -2
- package/src/core/workflow/workspaces/paths.ts +2 -2
- package/src/core/workflow/workspaces/types.ts +0 -9
- package/src/core/workflow/workspaces/validate.ts +1 -5
- package/src/lib/browser/client.ts +528 -6
- package/src/lib/browser/geometry.ts +197 -37
- package/src/lib/browser/index.ts +39 -0
- package/src/lib/browser/netscape-cookies.ts +39 -0
- package/src/lib/browser/proxy.ts +105 -0
- package/src/lib/browser/runts.ts +27 -3
- package/src/lib/browser/session-control.ts +892 -0
- package/src/lib/docs-links.ts +674 -0
- package/src/lib/exec.ts +1 -1
- package/src/lib/identities/assume.ts +22 -1
- package/src/lib/instructions/git-hooks.ts +259 -0
- package/src/lib/instructions/splice.ts +41 -11
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
import {
|
|
4
|
+
chmodSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
linkSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
statSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { createConnection, createServer, type Server, type Socket } from "node:net";
|
|
14
|
+
import { dirname, resolve } from "node:path";
|
|
15
|
+
import type {
|
|
16
|
+
BrowserSessionInspection,
|
|
17
|
+
BrowserSessionLocator,
|
|
18
|
+
BrowserSessionScreenshot,
|
|
19
|
+
BrowserSessionStatus,
|
|
20
|
+
BrowserSessionTab,
|
|
21
|
+
} from "./client.ts";
|
|
22
|
+
|
|
23
|
+
export const BROWSER_SESSION_PROTOCOL_VERSION = 1 as const;
|
|
24
|
+
export const BROWSER_SESSION_MAX_FRAME_BYTES = 1024 * 1024;
|
|
25
|
+
export const BROWSER_SESSION_MAX_FILL_BYTES = 64 * 1024;
|
|
26
|
+
export const BROWSER_SESSION_MAX_INSPECTION_TEXT_BYTES = 256 * 1024;
|
|
27
|
+
|
|
28
|
+
const DEFAULT_ACTION_TIMEOUT_MS = 30_000;
|
|
29
|
+
const SHORT_ACTION_TIMEOUT_MS = 5_000;
|
|
30
|
+
const UNIX_SOCKET_MAX_BYTES = 100;
|
|
31
|
+
const ARIA_ROLES = new Set([
|
|
32
|
+
"alert",
|
|
33
|
+
"alertdialog",
|
|
34
|
+
"application",
|
|
35
|
+
"article",
|
|
36
|
+
"banner",
|
|
37
|
+
"blockquote",
|
|
38
|
+
"button",
|
|
39
|
+
"caption",
|
|
40
|
+
"cell",
|
|
41
|
+
"checkbox",
|
|
42
|
+
"code",
|
|
43
|
+
"columnheader",
|
|
44
|
+
"combobox",
|
|
45
|
+
"complementary",
|
|
46
|
+
"contentinfo",
|
|
47
|
+
"definition",
|
|
48
|
+
"deletion",
|
|
49
|
+
"dialog",
|
|
50
|
+
"directory",
|
|
51
|
+
"document",
|
|
52
|
+
"emphasis",
|
|
53
|
+
"feed",
|
|
54
|
+
"figure",
|
|
55
|
+
"form",
|
|
56
|
+
"generic",
|
|
57
|
+
"grid",
|
|
58
|
+
"gridcell",
|
|
59
|
+
"group",
|
|
60
|
+
"heading",
|
|
61
|
+
"img",
|
|
62
|
+
"insertion",
|
|
63
|
+
"link",
|
|
64
|
+
"list",
|
|
65
|
+
"listbox",
|
|
66
|
+
"listitem",
|
|
67
|
+
"log",
|
|
68
|
+
"main",
|
|
69
|
+
"marquee",
|
|
70
|
+
"math",
|
|
71
|
+
"meter",
|
|
72
|
+
"menu",
|
|
73
|
+
"menubar",
|
|
74
|
+
"menuitem",
|
|
75
|
+
"menuitemcheckbox",
|
|
76
|
+
"menuitemradio",
|
|
77
|
+
"navigation",
|
|
78
|
+
"none",
|
|
79
|
+
"note",
|
|
80
|
+
"option",
|
|
81
|
+
"paragraph",
|
|
82
|
+
"presentation",
|
|
83
|
+
"progressbar",
|
|
84
|
+
"radio",
|
|
85
|
+
"radiogroup",
|
|
86
|
+
"region",
|
|
87
|
+
"row",
|
|
88
|
+
"rowgroup",
|
|
89
|
+
"rowheader",
|
|
90
|
+
"scrollbar",
|
|
91
|
+
"search",
|
|
92
|
+
"searchbox",
|
|
93
|
+
"separator",
|
|
94
|
+
"slider",
|
|
95
|
+
"spinbutton",
|
|
96
|
+
"status",
|
|
97
|
+
"strong",
|
|
98
|
+
"subscript",
|
|
99
|
+
"superscript",
|
|
100
|
+
"switch",
|
|
101
|
+
"tab",
|
|
102
|
+
"table",
|
|
103
|
+
"tablist",
|
|
104
|
+
"tabpanel",
|
|
105
|
+
"term",
|
|
106
|
+
"textbox",
|
|
107
|
+
"time",
|
|
108
|
+
"timer",
|
|
109
|
+
"toolbar",
|
|
110
|
+
"tooltip",
|
|
111
|
+
"tree",
|
|
112
|
+
"treegrid",
|
|
113
|
+
"treeitem",
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
export interface BrowserSessionDescriptor {
|
|
117
|
+
version: typeof BROWSER_SESSION_PROTOCOL_VERSION;
|
|
118
|
+
pid: number;
|
|
119
|
+
created_at: string;
|
|
120
|
+
transport: { kind: "unix"; address: string } | { kind: "pipe"; address: string };
|
|
121
|
+
token: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type BrowserSessionRequest =
|
|
125
|
+
| BaseRequest<"status", Record<string, never>>
|
|
126
|
+
| BaseRequest<"inspect", { locator?: BrowserSessionLocator }>
|
|
127
|
+
| BaseRequest<"screenshot", { out: string }>
|
|
128
|
+
| BaseRequest<"tabs", Record<string, never>>
|
|
129
|
+
| BaseRequest<"select_tab", { index: number }>
|
|
130
|
+
| BaseRequest<"open_tab", { url: string }>
|
|
131
|
+
| BaseRequest<"close_tab", { index: number }>
|
|
132
|
+
| BaseRequest<"goto", { url: string }>
|
|
133
|
+
| BaseRequest<"reload", Record<string, never>>
|
|
134
|
+
| BaseRequest<"click", { locator: BrowserSessionLocator }>
|
|
135
|
+
| BaseRequest<"fill", { locator: BrowserSessionLocator; value: string }>
|
|
136
|
+
| BaseRequest<"press", { key: string }>
|
|
137
|
+
| BaseRequest<"wait", { locator: BrowserSessionLocator }>
|
|
138
|
+
| BaseRequest<"close", Record<string, never>>;
|
|
139
|
+
|
|
140
|
+
interface BaseRequest<Action extends string, Args> {
|
|
141
|
+
version: typeof BROWSER_SESSION_PROTOCOL_VERSION;
|
|
142
|
+
id: string;
|
|
143
|
+
token: string;
|
|
144
|
+
action: Action;
|
|
145
|
+
args: Args;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type BrowserSessionResult =
|
|
149
|
+
| BrowserSessionStatus
|
|
150
|
+
| BrowserSessionInspection
|
|
151
|
+
| BrowserSessionScreenshot
|
|
152
|
+
| BrowserSessionTab[]
|
|
153
|
+
| BrowserSessionTab
|
|
154
|
+
| { closing: true }
|
|
155
|
+
| { revision: number };
|
|
156
|
+
|
|
157
|
+
export type BrowserSessionResponse =
|
|
158
|
+
| {
|
|
159
|
+
version: typeof BROWSER_SESSION_PROTOCOL_VERSION;
|
|
160
|
+
id: string;
|
|
161
|
+
ok: true;
|
|
162
|
+
result: BrowserSessionResult;
|
|
163
|
+
}
|
|
164
|
+
| {
|
|
165
|
+
version: typeof BROWSER_SESSION_PROTOCOL_VERSION;
|
|
166
|
+
id: string;
|
|
167
|
+
ok: false;
|
|
168
|
+
error: { code: string; message: string };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export interface BrowserSessionTarget {
|
|
172
|
+
sessionStatus(): Promise<BrowserSessionStatus>;
|
|
173
|
+
sessionInspect(locator?: BrowserSessionLocator): Promise<BrowserSessionInspection>;
|
|
174
|
+
sessionScreenshot(out: string): Promise<BrowserSessionScreenshot>;
|
|
175
|
+
sessionTabs(): Promise<BrowserSessionTab[]>;
|
|
176
|
+
sessionSelectTab(index: number): Promise<BrowserSessionTab>;
|
|
177
|
+
sessionOpenTab(url: string): Promise<BrowserSessionTab>;
|
|
178
|
+
sessionCloseTab(index: number): Promise<BrowserSessionTab>;
|
|
179
|
+
sessionGoto(url: string): Promise<BrowserSessionTab>;
|
|
180
|
+
sessionReload(): Promise<BrowserSessionTab>;
|
|
181
|
+
sessionClick(locator: BrowserSessionLocator): Promise<{ revision: number }>;
|
|
182
|
+
sessionFill(locator: BrowserSessionLocator, value: string): Promise<{ revision: number }>;
|
|
183
|
+
sessionPress(key: string): Promise<{ revision: number }>;
|
|
184
|
+
sessionWait(locator: BrowserSessionLocator): Promise<{ revision: number }>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export class BrowserSessionError extends Error {
|
|
188
|
+
constructor(
|
|
189
|
+
readonly code: string,
|
|
190
|
+
message: string,
|
|
191
|
+
) {
|
|
192
|
+
super(message);
|
|
193
|
+
this.name = "BrowserSessionError";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface BrowserSessionServer {
|
|
198
|
+
descriptorPath: string;
|
|
199
|
+
closeRequested: Promise<void>;
|
|
200
|
+
stopAccepting(): Promise<void>;
|
|
201
|
+
cleanup(): Promise<void>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface StartOptions {
|
|
205
|
+
platform?: NodeJS.Platform;
|
|
206
|
+
actionTimeoutMs?: number;
|
|
207
|
+
shortActionTimeoutMs?: number;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface Deferred {
|
|
211
|
+
promise: Promise<void>;
|
|
212
|
+
resolve(): void;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function deferred(): Deferred {
|
|
216
|
+
let resolvePromise!: () => void;
|
|
217
|
+
const promise = new Promise<void>((resolve) => {
|
|
218
|
+
resolvePromise = resolve;
|
|
219
|
+
});
|
|
220
|
+
return { promise, resolve: resolvePromise };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function browserSessionTransport(
|
|
224
|
+
controlFile: string,
|
|
225
|
+
platform: NodeJS.Platform = process.platform,
|
|
226
|
+
): BrowserSessionDescriptor["transport"] {
|
|
227
|
+
if (platform === "win32") {
|
|
228
|
+
return {
|
|
229
|
+
kind: "pipe",
|
|
230
|
+
address: `\\\\.\\pipe\\harnery-browser-${randomUUID()}`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const address = `${controlFile}.${randomBytes(8).toString("hex")}.sock`;
|
|
234
|
+
if (Buffer.byteLength(address) > UNIX_SOCKET_MAX_BYTES) {
|
|
235
|
+
throw new BrowserSessionError(
|
|
236
|
+
"socket_path_too_long",
|
|
237
|
+
`Control file path is too long for a Unix socket; keep it under ${UNIX_SOCKET_MAX_BYTES - 23} bytes.`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
return { kind: "unix", address };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function startBrowserSessionServer(
|
|
244
|
+
controlFileInput: string,
|
|
245
|
+
target: BrowserSessionTarget,
|
|
246
|
+
options: StartOptions = {},
|
|
247
|
+
): Promise<BrowserSessionServer> {
|
|
248
|
+
const controlFile = resolve(controlFileInput);
|
|
249
|
+
const platform = options.platform ?? process.platform;
|
|
250
|
+
assertPrivateParent(controlFile, platform);
|
|
251
|
+
if (existsSync(controlFile)) {
|
|
252
|
+
throw new BrowserSessionError(
|
|
253
|
+
"descriptor_exists",
|
|
254
|
+
`Control descriptor already exists at ${controlFile}. Inspect or remove the stale file first.`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const transport = browserSessionTransport(controlFile, platform);
|
|
259
|
+
if (transport.kind === "unix" && existsSync(transport.address)) {
|
|
260
|
+
throw new BrowserSessionError(
|
|
261
|
+
"transport_exists",
|
|
262
|
+
"Control transport already exists. Remove the stale endpoint first.",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const token = randomBytes(32).toString("base64url");
|
|
267
|
+
const descriptor: BrowserSessionDescriptor = {
|
|
268
|
+
version: BROWSER_SESSION_PROTOCOL_VERSION,
|
|
269
|
+
pid: process.pid,
|
|
270
|
+
created_at: new Date().toISOString(),
|
|
271
|
+
transport,
|
|
272
|
+
token,
|
|
273
|
+
};
|
|
274
|
+
const closeSignal = deferred();
|
|
275
|
+
const sockets = new Set<Socket>();
|
|
276
|
+
const actionSockets = new Set<Socket>();
|
|
277
|
+
let accepting = true;
|
|
278
|
+
let cleaned = false;
|
|
279
|
+
let published = false;
|
|
280
|
+
let actionTail: Promise<void> = Promise.resolve();
|
|
281
|
+
|
|
282
|
+
const server = createServer({ allowHalfOpen: true }, (socket) => {
|
|
283
|
+
sockets.add(socket);
|
|
284
|
+
socket.once("close", () => {
|
|
285
|
+
sockets.delete(socket);
|
|
286
|
+
actionSockets.delete(socket);
|
|
287
|
+
});
|
|
288
|
+
readRequestFrame(socket)
|
|
289
|
+
.then(async (raw) => {
|
|
290
|
+
const parsed = parseBrowserSessionRequest(raw);
|
|
291
|
+
if (!tokenMatches(token, parsed.token)) {
|
|
292
|
+
return errorResponse(parsed.id, "auth_failed", "Session authentication failed.");
|
|
293
|
+
}
|
|
294
|
+
if (!accepting) {
|
|
295
|
+
return errorResponse(parsed.id, "session_closing", "Browser session is closing.");
|
|
296
|
+
}
|
|
297
|
+
actionSockets.add(socket);
|
|
298
|
+
|
|
299
|
+
let actionPromise!: Promise<BrowserSessionResult>;
|
|
300
|
+
actionPromise = actionTail.then(() => dispatchAction(parsed, target, closeSignal));
|
|
301
|
+
actionTail = actionPromise.then(
|
|
302
|
+
() => undefined,
|
|
303
|
+
() => undefined,
|
|
304
|
+
);
|
|
305
|
+
const timeoutMs = isShortAction(parsed.action)
|
|
306
|
+
? (options.shortActionTimeoutMs ?? SHORT_ACTION_TIMEOUT_MS)
|
|
307
|
+
: (options.actionTimeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS);
|
|
308
|
+
try {
|
|
309
|
+
const result = await responseWithTimeout(actionPromise, timeoutMs);
|
|
310
|
+
return successResponse(parsed.id, result);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
return safeActionError(parsed.id, error);
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
.catch((error) => {
|
|
316
|
+
const safe =
|
|
317
|
+
error instanceof BrowserSessionError
|
|
318
|
+
? errorResponse("unknown", error.code, error.message)
|
|
319
|
+
: errorResponse("unknown", "invalid_request", "Invalid browser-session request.");
|
|
320
|
+
return safe;
|
|
321
|
+
})
|
|
322
|
+
.then((response) => {
|
|
323
|
+
if (!socket.destroyed) socket.end(`${JSON.stringify(response)}\n`);
|
|
324
|
+
actionSockets.delete(socket);
|
|
325
|
+
})
|
|
326
|
+
.catch(() => socket.destroy());
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
server.listen(transport.address);
|
|
331
|
+
await once(server, "listening");
|
|
332
|
+
if (transport.kind === "unix") chmodSync(transport.address, 0o600);
|
|
333
|
+
publishDescriptor(controlFile, descriptor);
|
|
334
|
+
published = true;
|
|
335
|
+
} catch (error) {
|
|
336
|
+
await closeServer(server);
|
|
337
|
+
if (transport.kind === "unix") unlinkIfExists(transport.address);
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function stopAccepting(): Promise<void> {
|
|
342
|
+
if (!accepting) return;
|
|
343
|
+
accepting = false;
|
|
344
|
+
const serverClosed = closeServer(server);
|
|
345
|
+
for (const socket of sockets) {
|
|
346
|
+
if (!actionSockets.has(socket)) socket.destroy();
|
|
347
|
+
}
|
|
348
|
+
const settled = await settlesWithin(
|
|
349
|
+
actionTail,
|
|
350
|
+
options.actionTimeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS,
|
|
351
|
+
);
|
|
352
|
+
if (!settled) {
|
|
353
|
+
for (const socket of sockets) socket.destroy();
|
|
354
|
+
}
|
|
355
|
+
await serverClosed;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function cleanup(): Promise<void> {
|
|
359
|
+
if (cleaned) return;
|
|
360
|
+
cleaned = true;
|
|
361
|
+
await stopAccepting();
|
|
362
|
+
for (const socket of sockets) socket.destroy();
|
|
363
|
+
if (published) unlinkIfExists(controlFile);
|
|
364
|
+
if (transport.kind === "unix") unlinkIfExists(transport.address);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
descriptorPath: controlFile,
|
|
369
|
+
closeRequested: closeSignal.promise,
|
|
370
|
+
stopAccepting,
|
|
371
|
+
cleanup,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function readBrowserSessionDescriptor(
|
|
376
|
+
controlFileInput: string,
|
|
377
|
+
platform: NodeJS.Platform = process.platform,
|
|
378
|
+
): BrowserSessionDescriptor {
|
|
379
|
+
const controlFile = resolve(controlFileInput);
|
|
380
|
+
assertPrivateParent(controlFile, platform);
|
|
381
|
+
let stat: ReturnType<typeof statSync>;
|
|
382
|
+
try {
|
|
383
|
+
stat = statSync(controlFile);
|
|
384
|
+
} catch {
|
|
385
|
+
throw new BrowserSessionError(
|
|
386
|
+
"descriptor_missing",
|
|
387
|
+
"Browser session descriptor was not found.",
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
if (!stat.isFile()) {
|
|
391
|
+
throw new BrowserSessionError(
|
|
392
|
+
"descriptor_invalid",
|
|
393
|
+
"Browser session descriptor is not a file.",
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
if (platform !== "win32") {
|
|
397
|
+
const uid = process.getuid?.();
|
|
398
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
399
|
+
throw new BrowserSessionError(
|
|
400
|
+
"descriptor_owner",
|
|
401
|
+
"Browser session descriptor is not owned by the current user.",
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
if ((stat.mode & 0o777) !== 0o600) {
|
|
405
|
+
throw new BrowserSessionError(
|
|
406
|
+
"descriptor_mode",
|
|
407
|
+
"Browser session descriptor must have mode 0600.",
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let parsed: unknown;
|
|
413
|
+
try {
|
|
414
|
+
parsed = JSON.parse(readFileSync(controlFile, "utf8"));
|
|
415
|
+
} catch {
|
|
416
|
+
throw new BrowserSessionError("descriptor_invalid", "Browser session descriptor is invalid.");
|
|
417
|
+
}
|
|
418
|
+
return validateDescriptor(parsed);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export async function sendBrowserSessionRequest(
|
|
422
|
+
controlFile: string,
|
|
423
|
+
action: BrowserSessionRequest["action"],
|
|
424
|
+
args: Record<string, unknown>,
|
|
425
|
+
): Promise<BrowserSessionResult> {
|
|
426
|
+
const descriptor = readBrowserSessionDescriptor(controlFile);
|
|
427
|
+
const request = parseBrowserSessionRequest({
|
|
428
|
+
version: BROWSER_SESSION_PROTOCOL_VERSION,
|
|
429
|
+
id: randomUUID(),
|
|
430
|
+
token: descriptor.token,
|
|
431
|
+
action,
|
|
432
|
+
args,
|
|
433
|
+
});
|
|
434
|
+
const response = await sendRequestToDescriptor(descriptor, request);
|
|
435
|
+
if (!response.ok) throw new BrowserSessionError(response.error.code, response.error.message);
|
|
436
|
+
return response.result;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export async function sendRequestToDescriptor(
|
|
440
|
+
descriptor: BrowserSessionDescriptor,
|
|
441
|
+
request: BrowserSessionRequest,
|
|
442
|
+
): Promise<BrowserSessionResponse> {
|
|
443
|
+
const socket = createConnection(descriptor.transport.address);
|
|
444
|
+
try {
|
|
445
|
+
await once(socket, "connect");
|
|
446
|
+
socket.write(`${JSON.stringify(request)}\n`);
|
|
447
|
+
const raw = await readResponseFrame(socket);
|
|
448
|
+
return parseBrowserSessionResponse(raw, request.id);
|
|
449
|
+
} catch (error) {
|
|
450
|
+
socket.destroy();
|
|
451
|
+
if (error instanceof BrowserSessionError) throw error;
|
|
452
|
+
throw new BrowserSessionError("transport_failed", "Could not connect to the browser session.");
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function parseBrowserSessionRequest(value: unknown): BrowserSessionRequest {
|
|
457
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["version", "id", "token", "action", "args"])) {
|
|
458
|
+
throw new BrowserSessionError("invalid_request", "Invalid browser-session request.");
|
|
459
|
+
}
|
|
460
|
+
if (value.version !== BROWSER_SESSION_PROTOCOL_VERSION) {
|
|
461
|
+
throw new BrowserSessionError("unsupported_version", "Unsupported browser-session protocol.");
|
|
462
|
+
}
|
|
463
|
+
const id = requiredString(value.id, "id", 128);
|
|
464
|
+
const token = requiredString(value.token, "token", 128);
|
|
465
|
+
const action = requiredString(value.action, "action", 32);
|
|
466
|
+
if (!isRecord(value.args)) {
|
|
467
|
+
throw new BrowserSessionError("invalid_request", "Request args must be an object.");
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const base = { version: BROWSER_SESSION_PROTOCOL_VERSION, id, token };
|
|
471
|
+
switch (action) {
|
|
472
|
+
case "status":
|
|
473
|
+
case "tabs":
|
|
474
|
+
case "reload":
|
|
475
|
+
case "close":
|
|
476
|
+
requireKeys(value.args, []);
|
|
477
|
+
return { ...base, action, args: {} };
|
|
478
|
+
case "inspect": {
|
|
479
|
+
requireKeys(value.args, ["locator"], true);
|
|
480
|
+
return {
|
|
481
|
+
...base,
|
|
482
|
+
action,
|
|
483
|
+
args: value.args.locator === undefined ? {} : { locator: parseLocator(value.args.locator) },
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
case "screenshot":
|
|
487
|
+
requireKeys(value.args, ["out"]);
|
|
488
|
+
return { ...base, action, args: { out: requiredString(value.args.out, "out", 4096) } };
|
|
489
|
+
case "select_tab":
|
|
490
|
+
case "close_tab":
|
|
491
|
+
requireKeys(value.args, ["index"]);
|
|
492
|
+
return { ...base, action, args: { index: requiredIndex(value.args.index) } };
|
|
493
|
+
case "open_tab":
|
|
494
|
+
case "goto":
|
|
495
|
+
requireKeys(value.args, ["url"]);
|
|
496
|
+
return { ...base, action, args: { url: requiredString(value.args.url, "url", 8192) } };
|
|
497
|
+
case "click":
|
|
498
|
+
case "wait":
|
|
499
|
+
requireKeys(value.args, ["locator"]);
|
|
500
|
+
return { ...base, action, args: { locator: parseLocator(value.args.locator) } };
|
|
501
|
+
case "fill": {
|
|
502
|
+
requireKeys(value.args, ["locator", "value"]);
|
|
503
|
+
const fillValue = requiredString(value.args.value, "value", BROWSER_SESSION_MAX_FILL_BYTES);
|
|
504
|
+
if (Buffer.byteLength(fillValue) > BROWSER_SESSION_MAX_FILL_BYTES) {
|
|
505
|
+
throw new BrowserSessionError("fill_too_large", "Fill input exceeds the 64 KiB limit.");
|
|
506
|
+
}
|
|
507
|
+
return {
|
|
508
|
+
...base,
|
|
509
|
+
action,
|
|
510
|
+
args: { locator: parseLocator(value.args.locator), value: fillValue },
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
case "press":
|
|
514
|
+
requireKeys(value.args, ["key"]);
|
|
515
|
+
return { ...base, action, args: { key: requiredString(value.args.key, "key", 128) } };
|
|
516
|
+
default:
|
|
517
|
+
throw new BrowserSessionError("unknown_action", "Unknown browser-session action.");
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function parseLocator(value: unknown): BrowserSessionLocator {
|
|
522
|
+
if (!isRecord(value)) {
|
|
523
|
+
throw new BrowserSessionError("invalid_locator", "A locator object is required.");
|
|
524
|
+
}
|
|
525
|
+
const kind = requiredString(value.kind, "locator kind", 16);
|
|
526
|
+
const partial = value.partial === undefined ? false : requiredBoolean(value.partial);
|
|
527
|
+
if (kind === "selector") {
|
|
528
|
+
requireKeys(value, ["kind", "value", "partial"] as const, true);
|
|
529
|
+
return { kind, value: requiredString(value.value, "selector", 4096), partial };
|
|
530
|
+
}
|
|
531
|
+
if (kind === "role") {
|
|
532
|
+
requireKeys(value, ["kind", "value", "name", "partial"] as const, true);
|
|
533
|
+
const role = requiredString(value.value, "role", 64);
|
|
534
|
+
if (!ARIA_ROLES.has(role)) {
|
|
535
|
+
throw new BrowserSessionError("invalid_locator", "Unsupported accessible role.");
|
|
536
|
+
}
|
|
537
|
+
return {
|
|
538
|
+
kind,
|
|
539
|
+
value: role,
|
|
540
|
+
...(value.name === undefined ? {} : { name: requiredString(value.name, "name", 4096) }),
|
|
541
|
+
partial,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
if (kind === "label" || kind === "text") {
|
|
545
|
+
requireKeys(value, ["kind", "value", "partial"] as const, true);
|
|
546
|
+
return { kind, value: requiredString(value.value, kind, 4096), partial };
|
|
547
|
+
}
|
|
548
|
+
throw new BrowserSessionError("invalid_locator", "Unsupported locator kind.");
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function dispatchAction(
|
|
552
|
+
request: BrowserSessionRequest,
|
|
553
|
+
target: BrowserSessionTarget,
|
|
554
|
+
closeSignal: Deferred,
|
|
555
|
+
): Promise<BrowserSessionResult> {
|
|
556
|
+
switch (request.action) {
|
|
557
|
+
case "status":
|
|
558
|
+
return target.sessionStatus();
|
|
559
|
+
case "inspect":
|
|
560
|
+
return target.sessionInspect(request.args.locator);
|
|
561
|
+
case "screenshot":
|
|
562
|
+
return target.sessionScreenshot(request.args.out);
|
|
563
|
+
case "tabs":
|
|
564
|
+
return target.sessionTabs();
|
|
565
|
+
case "select_tab":
|
|
566
|
+
return target.sessionSelectTab(request.args.index);
|
|
567
|
+
case "open_tab":
|
|
568
|
+
return target.sessionOpenTab(request.args.url);
|
|
569
|
+
case "close_tab":
|
|
570
|
+
return target.sessionCloseTab(request.args.index);
|
|
571
|
+
case "goto":
|
|
572
|
+
return target.sessionGoto(request.args.url);
|
|
573
|
+
case "reload":
|
|
574
|
+
return target.sessionReload();
|
|
575
|
+
case "click":
|
|
576
|
+
return target.sessionClick(request.args.locator);
|
|
577
|
+
case "fill":
|
|
578
|
+
return target.sessionFill(request.args.locator, request.args.value);
|
|
579
|
+
case "press":
|
|
580
|
+
return target.sessionPress(request.args.key);
|
|
581
|
+
case "wait":
|
|
582
|
+
return target.sessionWait(request.args.locator);
|
|
583
|
+
case "close":
|
|
584
|
+
closeSignal.resolve();
|
|
585
|
+
return { closing: true };
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function publishDescriptor(path: string, descriptor: BrowserSessionDescriptor): void {
|
|
590
|
+
const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
591
|
+
try {
|
|
592
|
+
writeFileSync(temporary, `${JSON.stringify(descriptor)}\n`, {
|
|
593
|
+
encoding: "utf8",
|
|
594
|
+
flag: "wx",
|
|
595
|
+
mode: 0o600,
|
|
596
|
+
});
|
|
597
|
+
chmodSync(temporary, 0o600);
|
|
598
|
+
linkSync(temporary, path);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
if (existsSync(path)) {
|
|
601
|
+
throw new BrowserSessionError(
|
|
602
|
+
"descriptor_exists",
|
|
603
|
+
`Control descriptor already exists at ${path}. Inspect or remove the stale file first.`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
throw error;
|
|
607
|
+
} finally {
|
|
608
|
+
unlinkIfExists(temporary);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function assertPrivateParent(path: string, platform: NodeJS.Platform): void {
|
|
613
|
+
const parent = dirname(path);
|
|
614
|
+
let stat: ReturnType<typeof lstatSync>;
|
|
615
|
+
try {
|
|
616
|
+
stat = lstatSync(parent);
|
|
617
|
+
} catch {
|
|
618
|
+
throw new BrowserSessionError(
|
|
619
|
+
"parent_missing",
|
|
620
|
+
"Control descriptor parent directory must already exist.",
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
if (!stat.isDirectory()) {
|
|
624
|
+
throw new BrowserSessionError(
|
|
625
|
+
"parent_invalid",
|
|
626
|
+
"Control descriptor parent is not a directory.",
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
if (platform === "win32") return;
|
|
630
|
+
const uid = process.getuid?.();
|
|
631
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
632
|
+
throw new BrowserSessionError(
|
|
633
|
+
"parent_owner",
|
|
634
|
+
"Control descriptor directory is not owned by the current user.",
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
638
|
+
throw new BrowserSessionError(
|
|
639
|
+
"parent_mode",
|
|
640
|
+
"Control descriptor directory must not be accessible by group or other users.",
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function readRequestFrame(socket: Socket): Promise<unknown> {
|
|
646
|
+
const text = await readFrame(socket, BROWSER_SESSION_MAX_FRAME_BYTES, "request");
|
|
647
|
+
try {
|
|
648
|
+
return JSON.parse(text);
|
|
649
|
+
} catch {
|
|
650
|
+
throw new BrowserSessionError("malformed_json", "Request frame is not valid JSON.");
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function readResponseFrame(socket: Socket): Promise<unknown> {
|
|
655
|
+
const text = await readFrame(socket, BROWSER_SESSION_MAX_FRAME_BYTES, "response");
|
|
656
|
+
try {
|
|
657
|
+
return JSON.parse(text);
|
|
658
|
+
} catch {
|
|
659
|
+
throw new BrowserSessionError("invalid_response", "Browser session returned invalid JSON.");
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function readFrame(
|
|
664
|
+
socket: Socket,
|
|
665
|
+
limit: number,
|
|
666
|
+
kind: "request" | "response",
|
|
667
|
+
): Promise<string> {
|
|
668
|
+
return await new Promise<string>((resolveFrame, reject) => {
|
|
669
|
+
let buffer = Buffer.alloc(0);
|
|
670
|
+
let settled = false;
|
|
671
|
+
const fail = (error: Error) => {
|
|
672
|
+
if (settled) return;
|
|
673
|
+
settled = true;
|
|
674
|
+
reject(error);
|
|
675
|
+
};
|
|
676
|
+
socket.on("data", (chunk: Buffer) => {
|
|
677
|
+
if (settled) return;
|
|
678
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
679
|
+
if (buffer.length > limit) {
|
|
680
|
+
fail(
|
|
681
|
+
new BrowserSessionError("frame_too_large", `${capitalize(kind)} frame exceeds 1 MiB.`),
|
|
682
|
+
);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const newline = buffer.indexOf(0x0a);
|
|
686
|
+
if (newline < 0) return;
|
|
687
|
+
const trailing = buffer
|
|
688
|
+
.subarray(newline + 1)
|
|
689
|
+
.toString("utf8")
|
|
690
|
+
.trim();
|
|
691
|
+
if (trailing.length > 0) {
|
|
692
|
+
fail(
|
|
693
|
+
new BrowserSessionError("multiple_frames", "Only one frame is allowed per connection."),
|
|
694
|
+
);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
settled = true;
|
|
698
|
+
resolveFrame(buffer.subarray(0, newline).toString("utf8"));
|
|
699
|
+
});
|
|
700
|
+
socket.once("end", () => {
|
|
701
|
+
if (!settled) {
|
|
702
|
+
fail(
|
|
703
|
+
new BrowserSessionError(
|
|
704
|
+
"unterminated_frame",
|
|
705
|
+
`${capitalize(kind)} frame is unterminated.`,
|
|
706
|
+
),
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
socket.once("error", (error) => fail(error));
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function parseBrowserSessionResponse(value: unknown, requestId: string): BrowserSessionResponse {
|
|
715
|
+
if (
|
|
716
|
+
!isRecord(value) ||
|
|
717
|
+
value.version !== BROWSER_SESSION_PROTOCOL_VERSION ||
|
|
718
|
+
value.id !== requestId
|
|
719
|
+
) {
|
|
720
|
+
throw new BrowserSessionError(
|
|
721
|
+
"invalid_response",
|
|
722
|
+
"Browser session returned an invalid response.",
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
if (value.ok === true && "result" in value) {
|
|
726
|
+
return value as BrowserSessionResponse;
|
|
727
|
+
}
|
|
728
|
+
if (
|
|
729
|
+
value.ok === false &&
|
|
730
|
+
isRecord(value.error) &&
|
|
731
|
+
typeof value.error.code === "string" &&
|
|
732
|
+
typeof value.error.message === "string"
|
|
733
|
+
) {
|
|
734
|
+
return value as BrowserSessionResponse;
|
|
735
|
+
}
|
|
736
|
+
throw new BrowserSessionError(
|
|
737
|
+
"invalid_response",
|
|
738
|
+
"Browser session returned an invalid response.",
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function validateDescriptor(value: unknown): BrowserSessionDescriptor {
|
|
743
|
+
if (
|
|
744
|
+
!isRecord(value) ||
|
|
745
|
+
!hasOnlyKeys(value, ["version", "pid", "created_at", "transport", "token"])
|
|
746
|
+
) {
|
|
747
|
+
throw new BrowserSessionError("descriptor_invalid", "Browser session descriptor is invalid.");
|
|
748
|
+
}
|
|
749
|
+
if (
|
|
750
|
+
value.version !== BROWSER_SESSION_PROTOCOL_VERSION ||
|
|
751
|
+
!Number.isSafeInteger(value.pid) ||
|
|
752
|
+
(value.pid as number) <= 0 ||
|
|
753
|
+
typeof value.created_at !== "string" ||
|
|
754
|
+
Number.isNaN(Date.parse(value.created_at)) ||
|
|
755
|
+
typeof value.token !== "string" ||
|
|
756
|
+
value.token.length < 40 ||
|
|
757
|
+
!isRecord(value.transport) ||
|
|
758
|
+
!hasOnlyKeys(value.transport, ["kind", "address"]) ||
|
|
759
|
+
(value.transport.kind !== "unix" && value.transport.kind !== "pipe") ||
|
|
760
|
+
typeof value.transport.address !== "string" ||
|
|
761
|
+
value.transport.address.length === 0
|
|
762
|
+
) {
|
|
763
|
+
throw new BrowserSessionError("descriptor_invalid", "Browser session descriptor is invalid.");
|
|
764
|
+
}
|
|
765
|
+
return value as unknown as BrowserSessionDescriptor;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function tokenMatches(expected: string, actual: string): boolean {
|
|
769
|
+
const expectedHash = createHash("sha256").update(expected).digest();
|
|
770
|
+
const actualHash = createHash("sha256").update(actual).digest();
|
|
771
|
+
return timingSafeEqual(expectedHash, actualHash);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function successResponse(id: string, result: BrowserSessionResult): BrowserSessionResponse {
|
|
775
|
+
return { version: BROWSER_SESSION_PROTOCOL_VERSION, id, ok: true, result };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function errorResponse(id: string, code: string, message: string): BrowserSessionResponse {
|
|
779
|
+
return { version: BROWSER_SESSION_PROTOCOL_VERSION, id, ok: false, error: { code, message } };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function safeActionError(id: string, error: unknown): BrowserSessionResponse {
|
|
783
|
+
if (error instanceof BrowserSessionError) return errorResponse(id, error.code, error.message);
|
|
784
|
+
if (
|
|
785
|
+
isRecord(error) &&
|
|
786
|
+
error.name === "BrowserSessionActionError" &&
|
|
787
|
+
typeof error.code === "string" &&
|
|
788
|
+
typeof error.message === "string"
|
|
789
|
+
) {
|
|
790
|
+
return errorResponse(id, error.code, error.message);
|
|
791
|
+
}
|
|
792
|
+
return errorResponse(id, "action_failed", "Browser action failed.");
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function responseWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
796
|
+
return new Promise<T>((resolvePromise, reject) => {
|
|
797
|
+
const timer = setTimeout(
|
|
798
|
+
() => reject(new BrowserSessionError("action_timeout", "Browser action timed out.")),
|
|
799
|
+
timeoutMs,
|
|
800
|
+
);
|
|
801
|
+
timer.unref?.();
|
|
802
|
+
promise.then(
|
|
803
|
+
(value) => {
|
|
804
|
+
clearTimeout(timer);
|
|
805
|
+
resolvePromise(value);
|
|
806
|
+
},
|
|
807
|
+
(error) => {
|
|
808
|
+
clearTimeout(timer);
|
|
809
|
+
reject(error);
|
|
810
|
+
},
|
|
811
|
+
);
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function settlesWithin(promise: Promise<void>, timeoutMs: number): Promise<boolean> {
|
|
816
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
817
|
+
try {
|
|
818
|
+
return await Promise.race([
|
|
819
|
+
promise.then(() => true),
|
|
820
|
+
new Promise<boolean>((resolveTimeout) => {
|
|
821
|
+
timer = setTimeout(() => resolveTimeout(false), timeoutMs);
|
|
822
|
+
timer.unref?.();
|
|
823
|
+
}),
|
|
824
|
+
]);
|
|
825
|
+
} finally {
|
|
826
|
+
if (timer) clearTimeout(timer);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function isShortAction(action: BrowserSessionRequest["action"]): boolean {
|
|
831
|
+
return action === "status" || action === "tabs" || action === "close";
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async function closeServer(server: Server): Promise<void> {
|
|
835
|
+
if (!server.listening) return;
|
|
836
|
+
await new Promise<void>((resolveClose) => server.close(() => resolveClose()));
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function unlinkIfExists(path: string): void {
|
|
840
|
+
try {
|
|
841
|
+
unlinkSync(path);
|
|
842
|
+
} catch (error) {
|
|
843
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function requiredString(value: unknown, field: string, maxLength: number): string {
|
|
848
|
+
if (typeof value !== "string" || value.length === 0 || value.length > maxLength) {
|
|
849
|
+
throw new BrowserSessionError("invalid_request", `Invalid ${field}.`);
|
|
850
|
+
}
|
|
851
|
+
return value;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function requiredIndex(value: unknown): number {
|
|
855
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
856
|
+
throw new BrowserSessionError("invalid_request", "Tab index must be a non-negative integer.");
|
|
857
|
+
}
|
|
858
|
+
return value as number;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function requiredBoolean(value: unknown): boolean {
|
|
862
|
+
if (typeof value !== "boolean") {
|
|
863
|
+
throw new BrowserSessionError("invalid_request", "Locator partial flag must be boolean.");
|
|
864
|
+
}
|
|
865
|
+
return value;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function requireKeys(
|
|
869
|
+
value: Record<string, unknown>,
|
|
870
|
+
keys: readonly string[],
|
|
871
|
+
optional = false,
|
|
872
|
+
): void {
|
|
873
|
+
if (!hasOnlyKeys(value, keys)) {
|
|
874
|
+
throw new BrowserSessionError("invalid_request", "Request args contain unsupported fields.");
|
|
875
|
+
}
|
|
876
|
+
if (!optional && keys.some((key) => !(key in value))) {
|
|
877
|
+
throw new BrowserSessionError("invalid_request", "Request args are incomplete.");
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function hasOnlyKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
882
|
+
const allowed = new Set(keys);
|
|
883
|
+
return Object.keys(value).every((key) => allowed.has(key));
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
887
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function capitalize(value: string): string {
|
|
891
|
+
return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
|
|
892
|
+
}
|