wtagent 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +83 -0
- package/docs/technical-design.md +838 -0
- package/package.json +55 -0
- package/src/browser/cdp-browser.js +170 -0
- package/src/browser/chatgpt-web-adapter.js +717 -0
- package/src/browser/fake-web-model-adapter.js +86 -0
- package/src/browser/mode-selection.js +179 -0
- package/src/browser/native-login.js +49 -0
- package/src/cli/at-files.js +93 -0
- package/src/cli/main.js +629 -0
- package/src/cli/render-events.js +367 -0
- package/src/platform/chrome-discovery.js +94 -0
- package/src/platform/paths.js +73 -0
- package/src/policy/path-guard.js +114 -0
- package/src/policy/policy-engine.js +277 -0
- package/src/protocol/markers.js +64 -0
- package/src/protocol/prompt-builder.js +152 -0
- package/src/protocol/xml-protocol.js +333 -0
- package/src/runtime/agent-runtime.js +636 -0
- package/src/session/agent-session.js +569 -0
- package/src/session/canonical-transcript.js +145 -0
- package/src/session/session-export.js +202 -0
- package/src/session/task-session.js +5 -0
- package/src/shared/errors.js +52 -0
- package/src/shared/limits.js +31 -0
- package/src/tools/default-tools.js +555 -0
- package/src/tools/process-manager.js +116 -0
- package/src/tools/process-utils.js +31 -0
- package/src/tools/registry.js +129 -0
- package/src/tools/safe-env.js +67 -0
- package/src/tools/terminal-exec.js +118 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// Terminal renderer for the agent event stream.
|
|
2
|
+
//
|
|
3
|
+
// The runtime emits a flat sequence of events (see AgentRuntime.emit). This
|
|
4
|
+
// module turns that stream into a live, chat-style CLI: timestamped milestone
|
|
5
|
+
// lines, a spinner while the request is being processed or a tool is running, compact
|
|
6
|
+
// tool-call / tool-result lines, and a prominent final answer block.
|
|
7
|
+
//
|
|
8
|
+
// A single Renderer instance is shared across every turn of a conversation so
|
|
9
|
+
// spinner state and per-tool timing survive between the model's replies.
|
|
10
|
+
|
|
11
|
+
const DIM = "\x1b[2m";
|
|
12
|
+
const BOLD = "\x1b[1m";
|
|
13
|
+
const CYAN = "\x1b[36m";
|
|
14
|
+
const GREEN = "\x1b[32m";
|
|
15
|
+
const YELLOW = "\x1b[33m";
|
|
16
|
+
const RED = "\x1b[31m";
|
|
17
|
+
const RESET = "\x1b[0m";
|
|
18
|
+
|
|
19
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
20
|
+
|
|
21
|
+
function hms() {
|
|
22
|
+
const now = new Date();
|
|
23
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
24
|
+
return `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function truncate(value, max = 120) {
|
|
28
|
+
const text = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
29
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function summarizeArgs(name, args = {}) {
|
|
33
|
+
if (!args || typeof args !== "object") {
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
if (name === "terminal.exec" || name === "process.start") {
|
|
37
|
+
const program = args.program ?? "";
|
|
38
|
+
const argv = Array.isArray(args.argv) ? args.argv.join(" ") : (args.argv ?? "");
|
|
39
|
+
return truncate(`${program} ${argv}`.trim(), 80);
|
|
40
|
+
}
|
|
41
|
+
if (typeof name === "string" && name.startsWith("fs.")) {
|
|
42
|
+
if (args.path) return truncate(String(args.path), 80);
|
|
43
|
+
if (args.query) return truncate(String(args.query), 80);
|
|
44
|
+
if (args.pattern) return truncate(String(args.pattern), 80);
|
|
45
|
+
}
|
|
46
|
+
if (typeof name === "string" && name.startsWith("process.")) {
|
|
47
|
+
if (args.processId) return truncate(String(args.processId), 80);
|
|
48
|
+
if (args.program) return truncate(String(args.program), 80);
|
|
49
|
+
}
|
|
50
|
+
for (const key of ["path", "query", "pattern", "program", "processId", "command", "cmd"]) {
|
|
51
|
+
if (key in args) return truncate(`${key}=${args[key]}`, 80);
|
|
52
|
+
}
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class Renderer {
|
|
57
|
+
constructor({ stream = process.stdout } = {}) {
|
|
58
|
+
this.stream = stream;
|
|
59
|
+
this.isTTY = Boolean(stream.isTTY);
|
|
60
|
+
this.spinner = null;
|
|
61
|
+
this.timer = null;
|
|
62
|
+
this.frame = 0;
|
|
63
|
+
// Tools resolve exactly once; track which callIds already produced a result
|
|
64
|
+
// line so the several completion-shaped events never double-print.
|
|
65
|
+
this.handled = new Set();
|
|
66
|
+
this.toolStart = new Map();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---- spinner -----------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
#ensureTimer() {
|
|
72
|
+
if (!this.isTTY) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (this.timer) {
|
|
76
|
+
this.#render();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.timer = setInterval(() => {
|
|
80
|
+
this.frame += 1;
|
|
81
|
+
this.#render();
|
|
82
|
+
}, 120);
|
|
83
|
+
this.timer.unref?.();
|
|
84
|
+
this.#render();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#render() {
|
|
88
|
+
if (!this.isTTY || !this.spinner) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const frame = SPINNER[this.frame % SPINNER.length];
|
|
92
|
+
const elapsed = ((Date.now() - this.spinner.start) / 1000).toFixed(1);
|
|
93
|
+
let line = `${DIM}${frame} ${this.spinner.label} ${elapsed}s`;
|
|
94
|
+
if (this.spinner.bytes) {
|
|
95
|
+
line += ` · ${this.spinner.bytes} chars`;
|
|
96
|
+
}
|
|
97
|
+
line += RESET;
|
|
98
|
+
this.stream.write(`\r\x1b[2K${line}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
beginProcessing() {
|
|
102
|
+
if (this.spinner?.kind === "processing") {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.spinner = {
|
|
106
|
+
kind: "processing",
|
|
107
|
+
label: "processing request",
|
|
108
|
+
start: Date.now(),
|
|
109
|
+
bytes: 0,
|
|
110
|
+
};
|
|
111
|
+
this.#ensureTimer();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
beginRunning(name) {
|
|
115
|
+
this.spinner = {
|
|
116
|
+
kind: "running",
|
|
117
|
+
label: `running ${name}`,
|
|
118
|
+
start: Date.now(),
|
|
119
|
+
bytes: 0,
|
|
120
|
+
};
|
|
121
|
+
this.#ensureTimer();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
addBytes(count) {
|
|
125
|
+
if (this.spinner && count > 0) {
|
|
126
|
+
this.spinner.bytes += count;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
stopSpinner() {
|
|
131
|
+
if (this.timer) {
|
|
132
|
+
clearInterval(this.timer);
|
|
133
|
+
this.timer = null;
|
|
134
|
+
}
|
|
135
|
+
if (this.isTTY && this.spinner) {
|
|
136
|
+
this.stream.write("\r\x1b[2K");
|
|
137
|
+
}
|
|
138
|
+
this.spinner = null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---- output primitives -------------------------------------------------
|
|
142
|
+
|
|
143
|
+
println(line = "") {
|
|
144
|
+
if (this.isTTY && this.timer) {
|
|
145
|
+
this.stream.write("\r\x1b[2K");
|
|
146
|
+
}
|
|
147
|
+
this.stream.write(`${line}\n`);
|
|
148
|
+
if (this.isTTY && this.timer && this.spinner) {
|
|
149
|
+
this.#render();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#hdr() {
|
|
154
|
+
return `${DIM}[${hms()}]${RESET}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
status(message) {
|
|
158
|
+
this.println(`${DIM}[${hms()}] ${message}${RESET}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
note(message) {
|
|
162
|
+
this.stopSpinner();
|
|
163
|
+
this.println(`${this.#hdr()} ${YELLOW}${message}${RESET}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
error(message) {
|
|
167
|
+
this.stopSpinner();
|
|
168
|
+
this.println(`${this.#hdr()} ${RED}${truncate(message, 300)}${RESET}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
hint(message) {
|
|
172
|
+
this.println(`${DIM}${message}${RESET}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
banner(lines) {
|
|
176
|
+
this.println("");
|
|
177
|
+
for (const line of lines) {
|
|
178
|
+
this.println(`${CYAN}${line}${RESET}`);
|
|
179
|
+
}
|
|
180
|
+
this.println("");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---- semantic renderers ------------------------------------------------
|
|
184
|
+
|
|
185
|
+
#progress(message) {
|
|
186
|
+
this.println(`${this.#hdr()} ${DIM}·${RESET} ${truncate(message, 300)}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#toolCall(name, args) {
|
|
190
|
+
const summary = summarizeArgs(name, args);
|
|
191
|
+
const tail = summary ? ` ${DIM}${summary}${RESET}` : "";
|
|
192
|
+
this.println(`${this.#hdr()} ${CYAN}→ ${name}${RESET}${tail}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
#elapsedFor(callId) {
|
|
196
|
+
if (callId && this.toolStart.has(callId)) {
|
|
197
|
+
const started = this.toolStart.get(callId);
|
|
198
|
+
this.toolStart.delete(callId);
|
|
199
|
+
return ((Date.now() - started) / 1000).toFixed(1);
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#resultLine(name, ok, message, elapsed, { unknown = false } = {}) {
|
|
205
|
+
const tail = elapsed != null ? ` ${DIM}(${elapsed}s)${RESET}` : "";
|
|
206
|
+
if (ok) {
|
|
207
|
+
this.println(`${this.#hdr()} ${GREEN}✓${RESET} ${name}${tail}`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const mark = unknown ? YELLOW : RED;
|
|
211
|
+
const detail = message ? `: ${truncate(message, 140)}` : "";
|
|
212
|
+
this.println(`${this.#hdr()} ${mark}✗${RESET} ${name}${detail}${tail}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#result(result = {}, { unknown = false } = {}) {
|
|
216
|
+
this.stopSpinner();
|
|
217
|
+
const callId = result.callId;
|
|
218
|
+
if (callId && this.handled.has(callId)) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (callId) {
|
|
222
|
+
this.handled.add(callId);
|
|
223
|
+
}
|
|
224
|
+
const ok = Boolean(result.ok) && !unknown;
|
|
225
|
+
this.#resultLine(result.name, ok, result.message, this.#elapsedFor(callId), {
|
|
226
|
+
unknown,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
answer(message) {
|
|
231
|
+
this.stopSpinner();
|
|
232
|
+
this.println("");
|
|
233
|
+
this.println(`${GREEN}${BOLD}⏺ assistant${RESET}`);
|
|
234
|
+
for (const line of String(message ?? "").split("\n")) {
|
|
235
|
+
this.println(line);
|
|
236
|
+
}
|
|
237
|
+
this.println("");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
finish() {
|
|
241
|
+
this.stopSpinner();
|
|
242
|
+
if (this.isTTY) {
|
|
243
|
+
// Reset styling and make sure the cursor is visible on exit.
|
|
244
|
+
this.stream.write(`${RESET}\x1b[?25h`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---- event dispatch ----------------------------------------------------
|
|
249
|
+
|
|
250
|
+
handle(event) {
|
|
251
|
+
const { type, payload = {} } = event ?? {};
|
|
252
|
+
switch (type) {
|
|
253
|
+
case "browser.started":
|
|
254
|
+
this.status("Chrome started.");
|
|
255
|
+
break;
|
|
256
|
+
case "browser.auth_required":
|
|
257
|
+
this.stopSpinner();
|
|
258
|
+
this.println(`${YELLOW}Log in to ChatGPT in the opened Chrome window…${RESET}`);
|
|
259
|
+
break;
|
|
260
|
+
case "browser.authenticated":
|
|
261
|
+
this.println(`${GREEN}ChatGPT login detected.${RESET}`);
|
|
262
|
+
break;
|
|
263
|
+
case "conversation.mode_selected": {
|
|
264
|
+
const { requested, status, selectedLabel } = payload;
|
|
265
|
+
if (status === "select" || status === "already") {
|
|
266
|
+
this.status(`Mode: ${selectedLabel ?? requested}.`);
|
|
267
|
+
} else if (status === "fallback") {
|
|
268
|
+
this.note(`Mode: ${requested} unavailable; using ${selectedLabel}.`);
|
|
269
|
+
} else {
|
|
270
|
+
this.note(`Mode: could not select ${requested}; continuing on current mode.`);
|
|
271
|
+
}
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
case "conversation.started":
|
|
275
|
+
if (payload.mode) {
|
|
276
|
+
this.status(`Conversation ready (${payload.mode}).`);
|
|
277
|
+
} else {
|
|
278
|
+
this.status("Conversation ready (current mode).");
|
|
279
|
+
}
|
|
280
|
+
break;
|
|
281
|
+
case "model.message_sent":
|
|
282
|
+
this.beginProcessing();
|
|
283
|
+
break;
|
|
284
|
+
case "model.streaming":
|
|
285
|
+
this.beginProcessing();
|
|
286
|
+
this.addBytes((payload.delta ?? "").length);
|
|
287
|
+
break;
|
|
288
|
+
case "model.message_complete":
|
|
289
|
+
this.stopSpinner();
|
|
290
|
+
break;
|
|
291
|
+
case "model.progress":
|
|
292
|
+
this.stopSpinner();
|
|
293
|
+
if (payload.message) {
|
|
294
|
+
this.#progress(payload.message);
|
|
295
|
+
}
|
|
296
|
+
break;
|
|
297
|
+
case "protocol.invalid":
|
|
298
|
+
this.note(`format retry${payload.count ? ` (${payload.count})` : ""}: ${truncate(payload.message, 100)}`);
|
|
299
|
+
break;
|
|
300
|
+
case "tool.proposed":
|
|
301
|
+
this.stopSpinner();
|
|
302
|
+
this.#toolCall(payload.name, payload.args);
|
|
303
|
+
if (payload.id) {
|
|
304
|
+
this.toolStart.set(payload.id, Date.now());
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
307
|
+
case "tool.started":
|
|
308
|
+
this.beginRunning(payload.name);
|
|
309
|
+
break;
|
|
310
|
+
case "tool.output":
|
|
311
|
+
this.addBytes((payload.chunk ?? "").length);
|
|
312
|
+
break;
|
|
313
|
+
case "tool.completed":
|
|
314
|
+
this.#result(payload.result);
|
|
315
|
+
break;
|
|
316
|
+
case "tool.completion_unknown":
|
|
317
|
+
this.#result(payload.result, { unknown: true });
|
|
318
|
+
break;
|
|
319
|
+
case "tool.invalid":
|
|
320
|
+
this.stopSpinner();
|
|
321
|
+
if (payload.id) {
|
|
322
|
+
this.handled.add(payload.id);
|
|
323
|
+
}
|
|
324
|
+
this.#resultLine(payload.name, false, payload.message, null);
|
|
325
|
+
break;
|
|
326
|
+
case "tool.reused":
|
|
327
|
+
this.stopSpinner();
|
|
328
|
+
if (!payload.id || !this.handled.has(payload.id)) {
|
|
329
|
+
if (payload.id) this.handled.add(payload.id);
|
|
330
|
+
this.println(`${this.#hdr()} ${DIM}↺ reused prior result for ${payload.name}${RESET}`);
|
|
331
|
+
}
|
|
332
|
+
break;
|
|
333
|
+
case "tool.reused_unknown":
|
|
334
|
+
this.stopSpinner();
|
|
335
|
+
if (!payload.id || !this.handled.has(payload.id)) {
|
|
336
|
+
if (payload.id) this.handled.add(payload.id);
|
|
337
|
+
this.println(`${this.#hdr()} ${YELLOW}↺ reused (completion unknown) for ${payload.name}${RESET}`);
|
|
338
|
+
}
|
|
339
|
+
break;
|
|
340
|
+
case "tool.conflict":
|
|
341
|
+
this.stopSpinner();
|
|
342
|
+
if (!payload.id || !this.handled.has(payload.id)) {
|
|
343
|
+
if (payload.id) this.handled.add(payload.id);
|
|
344
|
+
this.note(`conflicting reuse for ${payload.name}`);
|
|
345
|
+
}
|
|
346
|
+
break;
|
|
347
|
+
case "run.completed":
|
|
348
|
+
this.answer(payload.message);
|
|
349
|
+
break;
|
|
350
|
+
case "run.interrupted":
|
|
351
|
+
this.stopSpinner();
|
|
352
|
+
this.println(`${YELLOW}Run interrupted: ${truncate(payload.message, 160)}${RESET}`);
|
|
353
|
+
break;
|
|
354
|
+
case "tool.result_sent":
|
|
355
|
+
// A tool result was just sent to the model; we are waiting on the next
|
|
356
|
+
// reply, so bring the processing spinner back.
|
|
357
|
+
this.beginProcessing();
|
|
358
|
+
break;
|
|
359
|
+
default:
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function createRenderer(options) {
|
|
366
|
+
return new Renderer(options);
|
|
367
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
function existingFile(candidate) {
|
|
6
|
+
return candidate && fs.existsSync(candidate) ? candidate : null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function findOnPath(names, platform) {
|
|
10
|
+
const finder = platform === "win32" ? "where" : "which";
|
|
11
|
+
for (const name of names) {
|
|
12
|
+
const result = spawnSync(finder, [name], {
|
|
13
|
+
encoding: "utf8",
|
|
14
|
+
windowsHide: true,
|
|
15
|
+
});
|
|
16
|
+
if (result.status === 0) {
|
|
17
|
+
const first = result.stdout
|
|
18
|
+
.split(/\r?\n/)
|
|
19
|
+
.map((value) => value.trim())
|
|
20
|
+
.find(Boolean);
|
|
21
|
+
if (first && fs.existsSync(first)) {
|
|
22
|
+
return first;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function discoverChromeExecutable(explicitPath, {
|
|
30
|
+
env = process.env,
|
|
31
|
+
platform = process.platform,
|
|
32
|
+
} = {}) {
|
|
33
|
+
const configured = explicitPath
|
|
34
|
+
?? env.WTAGENT_CHROME_PATH
|
|
35
|
+
?? env.WEBAGENT_CHROME_PATH;
|
|
36
|
+
if (configured) {
|
|
37
|
+
const resolved = path.resolve(configured);
|
|
38
|
+
if (!fs.existsSync(resolved)) {
|
|
39
|
+
throw new Error(`Configured Chrome executable does not exist: ${resolved}`);
|
|
40
|
+
}
|
|
41
|
+
return resolved;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const candidates = [];
|
|
45
|
+
if (platform === "darwin") {
|
|
46
|
+
candidates.push(
|
|
47
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
48
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
49
|
+
);
|
|
50
|
+
} else if (platform === "win32") {
|
|
51
|
+
const programFiles = [
|
|
52
|
+
env.PROGRAMFILES,
|
|
53
|
+
env["PROGRAMFILES(X86)"],
|
|
54
|
+
env.LOCALAPPDATA,
|
|
55
|
+
].filter(Boolean);
|
|
56
|
+
for (const base of programFiles) {
|
|
57
|
+
candidates.push(
|
|
58
|
+
path.join(base, "Google", "Chrome", "Application", "chrome.exe"),
|
|
59
|
+
path.join(base, "Chromium", "Application", "chrome.exe"),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
candidates.push(
|
|
64
|
+
"/usr/bin/google-chrome",
|
|
65
|
+
"/usr/bin/google-chrome-stable",
|
|
66
|
+
"/usr/bin/chromium",
|
|
67
|
+
"/usr/bin/chromium-browser",
|
|
68
|
+
"/snap/bin/chromium",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const candidate of candidates) {
|
|
73
|
+
const found = existingFile(candidate);
|
|
74
|
+
if (found) {
|
|
75
|
+
return found;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const fromPath = findOnPath([
|
|
80
|
+
"google-chrome",
|
|
81
|
+
"google-chrome-stable",
|
|
82
|
+
"chromium",
|
|
83
|
+
"chromium-browser",
|
|
84
|
+
"chrome",
|
|
85
|
+
], platform);
|
|
86
|
+
|
|
87
|
+
if (fromPath) {
|
|
88
|
+
return fromPath;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
throw new Error(
|
|
92
|
+
"Chrome/Chromium was not found. Install Chrome or set WTAGENT_CHROME_PATH.",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const APP_NAME = "wtagent";
|
|
7
|
+
const LEGACY_APP_NAME = "webagent";
|
|
8
|
+
|
|
9
|
+
function platformAppDataDir(appName, {
|
|
10
|
+
env,
|
|
11
|
+
platform,
|
|
12
|
+
homeDir,
|
|
13
|
+
}) {
|
|
14
|
+
if (platform === "darwin") {
|
|
15
|
+
return path.join(homeDir, "Library", "Application Support", appName);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (platform === "win32") {
|
|
19
|
+
const base = env.APPDATA
|
|
20
|
+
?? path.join(homeDir, "AppData", "Roaming");
|
|
21
|
+
return path.join(base, appName);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const base = env.XDG_DATA_HOME
|
|
25
|
+
?? path.join(homeDir, ".local", "share");
|
|
26
|
+
return path.join(base, appName);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function looksLikeLegacyDataDir(directory, exists) {
|
|
30
|
+
return ["chrome-profile", "sessions", "tasks"]
|
|
31
|
+
.some((entry) => exists(path.join(directory, entry)));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getAppDataDir(appName = APP_NAME, {
|
|
35
|
+
env = process.env,
|
|
36
|
+
platform = process.platform,
|
|
37
|
+
homeDir = os.homedir(),
|
|
38
|
+
exists = existsSync,
|
|
39
|
+
} = {}) {
|
|
40
|
+
const configured = env.WTAGENT_HOME ?? env.WEBAGENT_HOME;
|
|
41
|
+
if (configured) {
|
|
42
|
+
return path.resolve(configured);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const current = platformAppDataDir(appName, { env, platform, homeDir });
|
|
46
|
+
if (appName !== APP_NAME || exists(current)) {
|
|
47
|
+
return current;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const legacy = platformAppDataDir(LEGACY_APP_NAME, {
|
|
51
|
+
env,
|
|
52
|
+
platform,
|
|
53
|
+
homeDir,
|
|
54
|
+
});
|
|
55
|
+
return looksLikeLegacyDataDir(legacy, exists) ? legacy : current;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getChromeProfileDir(appDataDir = getAppDataDir()) {
|
|
59
|
+
return path.join(appDataDir, "chrome-profile");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getTasksDir(appDataDir = getAppDataDir()) {
|
|
63
|
+
return path.join(appDataDir, "tasks");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getSessionsDir(appDataDir = getAppDataDir()) {
|
|
67
|
+
return path.join(appDataDir, "sessions");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function ensureDirectory(directoryPath) {
|
|
71
|
+
await fs.mkdir(directoryPath, { recursive: true });
|
|
72
|
+
return directoryPath;
|
|
73
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Windows device / reserved names that cannot be used as file or directory
|
|
5
|
+
// names, even with an extension (e.g. CON.txt). See
|
|
6
|
+
// https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
|
|
7
|
+
const WINDOWS_RESERVED_NAMES = new Set([
|
|
8
|
+
"CON", "PRN", "AUX", "NUL",
|
|
9
|
+
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
10
|
+
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
// Characters that are never legal in a Windows file name.
|
|
14
|
+
const WINDOWS_ILLEGAL_CHARS = /[<>:"/\\|?*\x00-\x1f]/;
|
|
15
|
+
|
|
16
|
+
// Returns the trailing path component (the file or directory name that the
|
|
17
|
+
// path refers to), stripping any trailing separator.
|
|
18
|
+
function leafName(candidatePath) {
|
|
19
|
+
const normalized = String(candidatePath ?? "").replace(/[\\/]+$/, "");
|
|
20
|
+
const lastSep = Math.max(
|
|
21
|
+
normalized.lastIndexOf("/"),
|
|
22
|
+
normalized.lastIndexOf("\\"),
|
|
23
|
+
);
|
|
24
|
+
return lastSep >= 0 ? normalized.slice(lastSep + 1) : normalized;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Validates that a path's leaf name is legal on Windows. On non-Windows
|
|
28
|
+
// platforms this is a no-op so POSIX-only names remain usable.
|
|
29
|
+
export function assertWindowsSafeName(candidatePath) {
|
|
30
|
+
if (process.platform !== "win32") {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const leaf = leafName(candidatePath);
|
|
34
|
+
if (!leaf) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
// Strip the extension before checking reserved names: CON, CON.txt,
|
|
38
|
+
// CON.anything are all reserved.
|
|
39
|
+
const dot = leaf.indexOf(".");
|
|
40
|
+
const base = dot >= 0 ? leaf.slice(0, dot) : leaf;
|
|
41
|
+
if (WINDOWS_RESERVED_NAMES.has(base.toUpperCase())) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Path uses a reserved Windows device name: ${leaf}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
if (WINDOWS_ILLEGAL_CHARS.test(leaf)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Path contains characters that are illegal on Windows: ${leaf}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
// Trailing space or period is not allowed in Windows file names.
|
|
52
|
+
if (/[ .]$/.test(leaf)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Path ends with a space or period, which is not allowed on Windows: ${leaf}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isPathInside(rootPath, candidatePath) {
|
|
60
|
+
const relative = path.relative(rootPath, candidatePath);
|
|
61
|
+
return relative === ""
|
|
62
|
+
|| (!relative.startsWith(`..${path.sep}`)
|
|
63
|
+
&& relative !== ".."
|
|
64
|
+
&& !path.isAbsolute(relative));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function nearestExistingAncestor(candidatePath) {
|
|
68
|
+
let current = candidatePath;
|
|
69
|
+
const suffix = [];
|
|
70
|
+
|
|
71
|
+
while (true) {
|
|
72
|
+
try {
|
|
73
|
+
await fs.lstat(current);
|
|
74
|
+
return { ancestor: current, suffix: suffix.reverse() };
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error.code !== "ENOENT") {
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const parent = path.dirname(current);
|
|
82
|
+
if (parent === current) {
|
|
83
|
+
throw new Error(`No existing ancestor found for path: ${candidatePath}`);
|
|
84
|
+
}
|
|
85
|
+
suffix.push(path.basename(current));
|
|
86
|
+
current = parent;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function resolveToolPath(projectRoot, rawPath) {
|
|
91
|
+
const root = await fs.realpath(path.resolve(projectRoot));
|
|
92
|
+
const requested = String(rawPath ?? "").trim();
|
|
93
|
+
if (!requested) {
|
|
94
|
+
throw new Error("Path cannot be empty.");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
assertWindowsSafeName(requested);
|
|
98
|
+
|
|
99
|
+
const lexical = path.isAbsolute(requested)
|
|
100
|
+
? path.resolve(requested)
|
|
101
|
+
: path.resolve(root, requested);
|
|
102
|
+
|
|
103
|
+
const { ancestor, suffix } = await nearestExistingAncestor(lexical);
|
|
104
|
+
const realAncestor = await fs.realpath(ancestor);
|
|
105
|
+
const resolved = path.join(realAncestor, ...suffix);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
root,
|
|
109
|
+
requested,
|
|
110
|
+
path: resolved,
|
|
111
|
+
inside: isPathInside(root, resolved),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|