wtagent 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +163 -9
  2. package/package.json +9 -8
  3. package/src/artifacts/artifact-store.js +154 -0
  4. package/src/audio/music-artifact.js +66 -0
  5. package/src/audio/native-music-receiver.js +91 -0
  6. package/src/browser/base-web-adapter.js +2418 -201
  7. package/src/browser/cdp-browser.js +223 -25
  8. package/src/browser/cdp-state.js +8 -5
  9. package/src/browser/chatgpt-dom.js +179 -0
  10. package/src/browser/chatgpt-web-adapter.js +831 -69
  11. package/src/browser/claude-web-adapter.js +13 -0
  12. package/src/browser/fake-web-model-adapter.js +152 -3
  13. package/src/browser/gemini-web-adapter.js +71 -20
  14. package/src/browser/glm-web-adapter.js +2 -2
  15. package/src/browser/grok-web-adapter.js +35 -3
  16. package/src/browser/rendered-text.js +11 -0
  17. package/src/cli/i18n.js +50 -20
  18. package/src/cli/main.js +124 -40
  19. package/src/cli/prompt-input.js +68 -0
  20. package/src/image/browser-image-driver.js +238 -0
  21. package/src/image/browser-image-session-pool.js +96 -0
  22. package/src/image/image-generation-service.js +101 -0
  23. package/src/image/image-provider-driver.js +22 -0
  24. package/src/image/native-image-receiver.js +66 -0
  25. package/src/image/provider-registry.js +23 -0
  26. package/src/image/providers/chatgpt-image-driver.js +211 -0
  27. package/src/image/providers/gemini-image-driver.js +100 -0
  28. package/src/image/providers/grok-image-driver.js +193 -0
  29. package/src/platform/command-launcher.js +1 -1
  30. package/src/platform/paths.js +19 -0
  31. package/src/platform/windows-diagnostics.js +9 -16
  32. package/src/policy/path-guard.js +5 -1
  33. package/src/policy/policy-engine.js +10 -5
  34. package/src/protocol/envelope.js +74 -0
  35. package/src/protocol/markers.js +16 -0
  36. package/src/protocol/prompt-builder.js +12 -9
  37. package/src/protocol/xml-protocol.js +36 -18
  38. package/src/runtime/agent-runtime.js +1383 -238
  39. package/src/session/agent-session.js +1047 -121
  40. package/src/tools/default-tools.js +1 -1
  41. package/src/tools/image-tools.js +92 -0
  42. package/src/tools/registry.js +13 -1
  43. package/docs/technical-design.md +0 -866
@@ -0,0 +1,211 @@
1
+ import fs from "node:fs/promises";
2
+ import { BrowserImageDriver } from "../browser-image-driver.js";
3
+
4
+ const DOWNLOAD_NAME_PATTERN = /download(?: image)?|下载(?:图片)?|下載(?:圖片)?/i;
5
+ const MIN_IMAGE_EDGE = 256;
6
+
7
+ export function preferredImageUrl(image) {
8
+ const candidates = [image?.source, image?.href]
9
+ .filter((value) => typeof value === "string" && value.length > 0);
10
+ if (candidates.length === 0) {
11
+ return null;
12
+ }
13
+ const durableHttps = candidates.find((value) => {
14
+ try {
15
+ const parsed = new URL(value);
16
+ return parsed.protocol === "https:"
17
+ && !parsed.hostname.endsWith("chatgpt.com");
18
+ } catch {
19
+ return false;
20
+ }
21
+ });
22
+ return durableHttps
23
+ ?? candidates.find((value) => value.startsWith("https://"))
24
+ ?? candidates[0];
25
+ }
26
+
27
+ export class ChatGPTImageDriver extends BrowserImageDriver {
28
+ capabilities() {
29
+ return Object.freeze({
30
+ generate: true,
31
+ referenceImages: true,
32
+ aspectRatios: ["auto", "1:1", "3:2", "2:3", "16:9", "9:16"],
33
+ imageCount: 1,
34
+ });
35
+ }
36
+
37
+ async generate(request, execution = {}) {
38
+ await this.ensureChatPage();
39
+ const baseline = new Set(
40
+ (await this.chatImages().catch(() => [])).map((image) => preferredImageUrl(image)),
41
+ );
42
+ return await this.performTurn(request, execution, async () => {
43
+ const image = await this.waitForChatImage(request.timeoutMs, baseline);
44
+ if (image.type === "text") return image;
45
+ const downloaded = await this.downloadChatImage(
46
+ image,
47
+ Math.min(request.timeoutMs, 60_000),
48
+ );
49
+ return {
50
+ sourcePath: downloaded.temporaryPath,
51
+ cleanup: async () => fs.rm(downloaded.temporaryDir, { recursive: true, force: true }),
52
+ provenance: {
53
+ provider: this.provider,
54
+ providerFileId: this.remoteFileId(image.source)
55
+ ?? this.remoteFileId(downloaded.downloadUrl),
56
+ suggestedFilename: downloaded.suggestedFilename,
57
+ },
58
+ };
59
+ });
60
+ }
61
+
62
+ async imagesFromLocator(locator) {
63
+ if (await locator.count().catch(() => 0) === 0) return [];
64
+ return await locator.evaluate((root, minEdge) => {
65
+ const toAbsolute = (value) => {
66
+ if (!value) return "";
67
+ try {
68
+ return new URL(value, location.href).href;
69
+ } catch {
70
+ return value;
71
+ }
72
+ };
73
+ const fromSrcset = (srcset) => {
74
+ let best = "";
75
+ let bestWidth = 0;
76
+ for (const part of String(srcset ?? "").split(",")) {
77
+ const [url, descriptor] = part.trim().split(/\s+/, 2);
78
+ const width = descriptor?.endsWith("w")
79
+ ? Number.parseInt(descriptor, 10)
80
+ : 0;
81
+ if (url && width >= bestWidth) {
82
+ best = url;
83
+ bestWidth = width;
84
+ }
85
+ }
86
+ return best;
87
+ };
88
+ const images = [...root.querySelectorAll("img")].map((image) => {
89
+ const source = toAbsolute(
90
+ image.currentSrc
91
+ || fromSrcset(image.srcset)
92
+ || image.src
93
+ || image.getAttribute("data-src")
94
+ || "",
95
+ );
96
+ const href = toAbsolute(image.closest("a")?.href || "");
97
+ return {
98
+ source,
99
+ href,
100
+ width: image.naturalWidth || 0,
101
+ height: image.naturalHeight || 0,
102
+ };
103
+ }).filter((image) => (
104
+ (image.source || image.href)
105
+ && image.width >= minEdge
106
+ && image.height >= minEdge
107
+ ));
108
+
109
+ for (const link of root.querySelectorAll("a[href]")) {
110
+ const href = toAbsolute(link.href);
111
+ if (
112
+ !href
113
+ || images.some((image) => image.href === href || image.source === href)
114
+ ) {
115
+ continue;
116
+ }
117
+ if (
118
+ /oaiusercontent/i.test(href)
119
+ || /\.(png|jpe?g|webp)(?:\?|$)/i.test(href)
120
+ ) {
121
+ images.push({
122
+ source: href,
123
+ href,
124
+ width: minEdge,
125
+ height: minEdge,
126
+ });
127
+ }
128
+ }
129
+ return images;
130
+ }, MIN_IMAGE_EDGE).catch(() => []);
131
+ }
132
+
133
+ async currentAssistantImages() {
134
+ return await this.imagesFromLocator(this.adapter.assistantMessages().last());
135
+ }
136
+
137
+ async chatImages() {
138
+ return await this.imagesFromLocator(
139
+ this.adapter.page.locator("main").first(),
140
+ );
141
+ }
142
+
143
+ async waitForChatImage(timeoutMs, baseline = new Set()) {
144
+ const deadline = Date.now() + timeoutMs;
145
+ let candidate = null;
146
+ let stableSince = 0;
147
+ const textState = {};
148
+ while (Date.now() < deadline) {
149
+ const images = (await this.currentAssistantImages().catch(() => []))
150
+ .filter((image) => !baseline.has(preferredImageUrl(image)));
151
+ if (images.length > 1) {
152
+ throw new Error(
153
+ "ChatGPT produced multiple uncorrelated chat images; refusing to guess which artifact belongs to this request.",
154
+ );
155
+ }
156
+ if (images.length === 1) {
157
+ const next = preferredImageUrl(images[0]);
158
+ if (candidate !== next) {
159
+ candidate = next;
160
+ stableSince = Date.now();
161
+ } else if (Date.now() - stableSince >= 1_500) {
162
+ return { ...images[0], source: next };
163
+ }
164
+ } else {
165
+ candidate = null;
166
+ stableSince = 0;
167
+ }
168
+ if (images.length === 0) {
169
+ const text = await this.completedTextResponse(textState);
170
+ if (text) return text;
171
+ } else {
172
+ textState.candidate = null;
173
+ }
174
+ await this.adapter.page.waitForTimeout(1_000);
175
+ }
176
+ throw new Error(`ChatGPT did not publish a new image within ${Math.round(timeoutMs / 1000)} seconds.`);
177
+ }
178
+
179
+ async downloadChatImage(image, timeoutMs) {
180
+ await this.adapter.page.bringToFront().catch(() => null);
181
+ const url = preferredImageUrl(image);
182
+ if (url) {
183
+ try {
184
+ return await this.saveFromUrl(this.adapter.page, url, timeoutMs);
185
+ } catch {
186
+ // The in-chat URL can be a preview or expire; try a download control next.
187
+ }
188
+ }
189
+
190
+ const response = this.adapter.assistantMessages().last();
191
+ const buttonCandidates = [
192
+ response.getByRole("button", { name: DOWNLOAD_NAME_PATTERN }),
193
+ response.locator('button[aria-label*="download" i]'),
194
+ ];
195
+ for (const locator of buttonCandidates) {
196
+ if (await locator.first().isVisible().catch(() => false)) {
197
+ try {
198
+ return await this.saveDownload(
199
+ this.adapter.page,
200
+ locator.first(),
201
+ Math.min(timeoutMs, 30_000),
202
+ );
203
+ } catch {
204
+ break;
205
+ }
206
+ }
207
+ }
208
+ throw new Error("ChatGPT generated an image but no download control or image URL was found.");
209
+ }
210
+
211
+ }
@@ -0,0 +1,100 @@
1
+ import fs from "node:fs/promises";
2
+ import { BrowserImageDriver } from "../browser-image-driver.js";
3
+
4
+ export class GeminiImageDriver extends BrowserImageDriver {
5
+ capabilities() {
6
+ return Object.freeze({
7
+ generate: true,
8
+ referenceImages: true,
9
+ aspectRatios: ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"],
10
+ imageCount: 1,
11
+ });
12
+ }
13
+
14
+ async generatedImages() {
15
+ const response = this.adapter.assistantMessages().last();
16
+ if (await response.count().catch(() => 0) === 0) return [];
17
+ return await response.locator("img").evaluateAll((images) => images.map((image) => ({
18
+ source: image.currentSrc || image.src || "",
19
+ alt: image.alt || "",
20
+ width: image.naturalWidth || 0,
21
+ height: image.naturalHeight || 0,
22
+ })).filter((image) => image.source && image.width >= 256 && image.height >= 256));
23
+ }
24
+
25
+ async waitForGeneratedImage(timeoutMs, baseline = new Set()) {
26
+ const deadline = Date.now() + timeoutMs;
27
+ let stableSource = null;
28
+ let stableSince = 0;
29
+ const textState = {};
30
+ while (Date.now() < deadline) {
31
+ const images = (await this.generatedImages())
32
+ .filter((image) => !baseline.has(image.source));
33
+ if (images.length === 1) {
34
+ if (stableSource !== images[0].source) {
35
+ stableSource = images[0].source;
36
+ stableSince = Date.now();
37
+ } else if (Date.now() - stableSince >= 2_000) {
38
+ return images[0];
39
+ }
40
+ } else if (images.length > 1) {
41
+ throw new Error(
42
+ "Gemini returned multiple images for a single-output request; refusing to choose one implicitly.",
43
+ );
44
+ }
45
+ if (images.length === 0) {
46
+ const text = await this.completedTextResponse(textState);
47
+ if (text) return text;
48
+ } else {
49
+ textState.candidate = null;
50
+ }
51
+ await this.adapter.page.waitForTimeout(1_000);
52
+ }
53
+ throw new Error(`Gemini did not finish an image within ${Math.round(timeoutMs / 1000)} seconds.`);
54
+ }
55
+
56
+ async downloadGeneratedImage(timeoutMs, generatedImage = null) {
57
+ const response = this.adapter.assistantMessages().last();
58
+ const scope = response;
59
+ const buttonCandidates = [
60
+ scope.getByRole("button", { name: /download(?: image)?|下载(?:图片)?|下載(?:圖片)?/i }),
61
+ scope.locator('button[aria-label*="download" i]'),
62
+ scope.locator('button[data-test-id*="download" i]'),
63
+ ];
64
+ for (const locator of buttonCandidates) {
65
+ if (await locator.first().isVisible().catch(() => false)) {
66
+ try {
67
+ return await this.saveDownload(this.adapter.page, locator.first(), timeoutMs);
68
+ } catch {
69
+ break;
70
+ }
71
+ }
72
+ }
73
+ if (generatedImage?.source) {
74
+ return await this.saveFromUrl(this.adapter.page, generatedImage.source, timeoutMs);
75
+ }
76
+ throw new Error("Gemini generated an image but no image download control was found.");
77
+ }
78
+
79
+ async generate(request, execution = {}) {
80
+ await this.ensureChatPage();
81
+ const before = new Set((await this.generatedImages()).map((item) => item.source));
82
+ return await this.performTurn(request, execution, async () => {
83
+ const generatedImage = await this.waitForGeneratedImage(request.timeoutMs, before);
84
+ if (generatedImage.type === "text") return generatedImage;
85
+ const downloaded = await this.downloadGeneratedImage(
86
+ Math.min(request.timeoutMs, 60_000),
87
+ generatedImage,
88
+ );
89
+ return {
90
+ sourcePath: downloaded.temporaryPath,
91
+ cleanup: async () => fs.rm(downloaded.temporaryDir, { recursive: true, force: true }),
92
+ provenance: {
93
+ provider: this.provider,
94
+ providerFileId: this.remoteFileId(downloaded.downloadUrl),
95
+ suggestedFilename: downloaded.suggestedFilename,
96
+ },
97
+ };
98
+ });
99
+ }
100
+ }
@@ -0,0 +1,193 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { BrowserImageDriver } from "../browser-image-driver.js";
5
+
6
+ const GROK_ASSET_HOST = "assets.grok.com";
7
+ const GROK_GENERATED_IMAGE_PATH = /^\/users\/[^/]+\/generated\/([0-9a-f-]{16,})\/image\.(?:jpe?g|png|webp)$/i;
8
+ const IMAGE_CONTENT_TYPE = /^image\/(?:jpeg|jpg|png|webp)(?:;|$)/i;
9
+
10
+ export function parseGrokGeneratedImageUrl(value) {
11
+ try {
12
+ const url = new URL(value);
13
+ if (url.protocol !== "https:" || url.hostname !== GROK_ASSET_HOST) return null;
14
+ const match = url.pathname.match(GROK_GENERATED_IMAGE_PATH);
15
+ if (!match) return null;
16
+ return {
17
+ id: match[1],
18
+ key: `${url.origin}${url.pathname}`,
19
+ url: url.href,
20
+ };
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ function responseUrl(response) {
27
+ return typeof response?.url === "function" ? response.url() : response?.url;
28
+ }
29
+
30
+ function responseHeaders(response) {
31
+ return typeof response?.headers === "function" ? response.headers() : response?.headers;
32
+ }
33
+
34
+ export class GrokImageDriver extends BrowserImageDriver {
35
+ constructor(options) {
36
+ super(options);
37
+ this.responseStabilityMs = options.responseStabilityMs ?? 2_000;
38
+ }
39
+
40
+ capabilities() {
41
+ return Object.freeze({
42
+ generate: true,
43
+ referenceImages: false,
44
+ aspectRatios: ["auto", "1:1", "3:2", "2:3", "16:9", "9:16"],
45
+ imageCount: 1,
46
+ });
47
+ }
48
+
49
+ trackGeneratedImageResponses() {
50
+ const page = this.adapter.page;
51
+ const images = [];
52
+ const keys = new Set();
53
+ let armed = false;
54
+ let lastAddedAt = 0;
55
+ const listener = (response) => {
56
+ if (!armed) return;
57
+ const parsed = parseGrokGeneratedImageUrl(responseUrl(response));
58
+ if (!parsed || keys.has(parsed.key)) return;
59
+ const ok = typeof response?.ok === "function" ? response.ok() : response?.ok;
60
+ const contentType = String(responseHeaders(response)?.["content-type"] ?? "");
61
+ if (!ok || !IMAGE_CONTENT_TYPE.test(contentType)) return;
62
+ keys.add(parsed.key);
63
+ images.push(parsed);
64
+ lastAddedAt = Date.now();
65
+ };
66
+ page.on("response", listener);
67
+ return {
68
+ arm: () => { armed = true; },
69
+ images: () => images.slice(),
70
+ lastAddedAt: () => lastAddedAt,
71
+ stop: () => page.off("response", listener),
72
+ };
73
+ }
74
+
75
+ async currentAssistantImages() {
76
+ const response = this.adapter.assistantMessages().last();
77
+ if (await response.count().catch(() => 0) === 0) return [];
78
+ return await response.locator("img").evaluateAll((images) => images.map((image) => ({
79
+ source: image.currentSrc || image.src || "",
80
+ alt: image.alt || "",
81
+ width: image.naturalWidth || 0,
82
+ height: image.naturalHeight || 0,
83
+ })).filter((image) => image.source && image.width >= 256 && image.height >= 256));
84
+ }
85
+
86
+ async waitForGeneratedImage(timeoutMs, tracker, baseline = new Set()) {
87
+ const deadline = Date.now() + timeoutMs;
88
+ let candidateKey = null;
89
+ let candidateSince = 0;
90
+ const textState = {};
91
+ while (Date.now() < deadline) {
92
+ const responses = tracker.images();
93
+ const responseByKey = new Map(responses.map((image) => [image.key, image]));
94
+ const rendered = (await this.currentAssistantImages().catch(() => []))
95
+ .map((image) => ({ ...image, parsed: parseGrokGeneratedImageUrl(image.source) }))
96
+ .filter((image) => (
97
+ image.parsed
98
+ && !baseline.has(image.parsed.key)
99
+ && responseByKey.has(image.parsed.key)
100
+ ));
101
+ const keys = [...new Set(rendered.map((image) => image.parsed.key))];
102
+ if (
103
+ keys.length > 0
104
+ && Date.now() - tracker.lastAddedAt() >= this.responseStabilityMs
105
+ ) {
106
+ const signature = keys.join("|");
107
+ if (candidateKey !== signature) {
108
+ candidateKey = signature;
109
+ candidateSince = Date.now();
110
+ } else if (Date.now() - candidateSince >= this.responseStabilityMs) {
111
+ const parsed = responseByKey.get(keys[0]);
112
+ return {
113
+ source: parsed.url,
114
+ providerFileId: parsed.id,
115
+ generatedImageCount: keys.length,
116
+ selectedImageIndex: 1,
117
+ };
118
+ }
119
+ }
120
+ if (keys.length === 0) {
121
+ const text = await this.completedTextResponse(textState);
122
+ if (text) return text;
123
+ } else {
124
+ textState.candidate = null;
125
+ }
126
+ await this.adapter.page.waitForTimeout(500);
127
+ }
128
+ throw new Error(`Grok did not finish an image within ${Math.round(timeoutMs / 1000)} seconds.`);
129
+ }
130
+
131
+ async downloadGeneratedImage(image) {
132
+ const temporaryDir = await fs.mkdtemp(path.join(os.tmpdir(), "wtagent-image-"));
133
+ const sourceUrl = new URL(image.source);
134
+ const extension = path.extname(sourceUrl.pathname).toLowerCase() || ".jpg";
135
+ const temporaryPath = path.join(temporaryDir, `generated-image${extension}`);
136
+ try {
137
+ const response = await this.adapter.page.request.get(image.source, {
138
+ timeout: 60_000,
139
+ });
140
+ if (!response.ok()) {
141
+ throw new Error(`Grok image download failed with HTTP ${response.status()}.`);
142
+ }
143
+ await fs.writeFile(temporaryPath, await response.body());
144
+ return {
145
+ temporaryDir,
146
+ temporaryPath,
147
+ suggestedFilename: path.basename(sourceUrl.pathname) || `generated-image${extension}`,
148
+ downloadUrl: image.source,
149
+ };
150
+ } catch (error) {
151
+ await fs.rm(temporaryDir, { recursive: true, force: true }).catch(() => null);
152
+ throw error;
153
+ }
154
+ }
155
+
156
+ async generate(request, execution = {}) {
157
+ if (request.referenceImages.length > 0) {
158
+ throw new Error("The current Grok image driver does not support reference images.");
159
+ }
160
+ await this.ensureChatPage();
161
+ const baseline = new Set(
162
+ (await this.currentAssistantImages().catch(() => []))
163
+ .map((image) => parseGrokGeneratedImageUrl(image.source)?.key)
164
+ .filter(Boolean),
165
+ );
166
+ const tracker = this.trackGeneratedImageResponses();
167
+ try {
168
+ tracker.arm();
169
+ return await this.performTurn(request, execution, async () => {
170
+ const image = await this.waitForGeneratedImage(
171
+ request.timeoutMs,
172
+ tracker,
173
+ baseline,
174
+ );
175
+ if (image.type === "text") return image;
176
+ const downloaded = await this.downloadGeneratedImage(image);
177
+ return {
178
+ sourcePath: downloaded.temporaryPath,
179
+ cleanup: async () => fs.rm(downloaded.temporaryDir, { recursive: true, force: true }),
180
+ provenance: {
181
+ provider: this.provider,
182
+ providerFileId: image.providerFileId,
183
+ suggestedFilename: downloaded.suggestedFilename,
184
+ generatedImageCount: image.generatedImageCount,
185
+ selectedImageIndex: image.selectedImageIndex,
186
+ },
187
+ };
188
+ });
189
+ } finally {
190
+ tracker.stop();
191
+ }
192
+ }
193
+ }
@@ -178,7 +178,7 @@ function buildBatchCommand(scriptPath, argv, {
178
178
  export function resolveLaunchPlan({
179
179
  program,
180
180
  argv = [],
181
- cwd,
181
+ cwd = process.cwd(),
182
182
  env = process.env,
183
183
  platform = process.platform,
184
184
  existsSync = fs.existsSync,
@@ -7,6 +7,25 @@ import { getEnvCaseInsensitive } from "./command-launcher.js";
7
7
  const APP_NAME = "wtagent";
8
8
  const LEGACY_APP_NAME = "webagent";
9
9
 
10
+ export function formatHomePath(filePath, {
11
+ platform = process.platform,
12
+ homeDir = os.homedir(),
13
+ } = {}) {
14
+ const pathApi = platform === "win32" ? path.win32 : path.posix;
15
+ if (!pathApi.isAbsolute(filePath)) {
16
+ return filePath;
17
+ }
18
+ const relative = pathApi.relative(homeDir, filePath);
19
+ if (relative === "") {
20
+ return "~";
21
+ }
22
+ if (relative === ".." || relative.startsWith(`..${pathApi.sep}`)
23
+ || pathApi.isAbsolute(relative)) {
24
+ return filePath;
25
+ }
26
+ return `~${pathApi.sep}${relative}`;
27
+ }
28
+
10
29
  function platformAppDataDir(appName, {
11
30
  env,
12
31
  platform,
@@ -8,11 +8,12 @@ import { promisify } from "node:util";
8
8
  import { discoverChromeExecutable } from "./chrome-discovery.js";
9
9
  import { resolveLaunchPlan } from "./command-launcher.js";
10
10
  import { inspectCdpProfileState } from "../browser/cdp-state.js";
11
+ import { getPackageInfo } from "../shared/package-info.js";
11
12
 
12
13
  const execFileAsync = promisify(execFile);
13
14
 
14
15
  function parseMajorMinorPatch(version) {
15
- const match = String(version ?? "").replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
16
+ const match = String(version ?? "").replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)$/);
16
17
  if (!match) {
17
18
  return null;
18
19
  }
@@ -24,20 +25,12 @@ export function isSupportedNodeVersion(version = process.version) {
24
25
  if (!parsed) {
25
26
  return false;
26
27
  }
27
- const [major, minor, patch] = parsed;
28
- if (major > 20) {
29
- return true;
30
- }
31
- if (major < 20) {
32
- return false;
33
- }
34
- if (minor > 17) {
35
- return true;
36
- }
37
- if (minor < 17) {
38
- return false;
39
- }
40
- return patch >= 0;
28
+ const [major, minor] = parsed;
29
+ // Keep aligned with package.json and Inquirer's supported Node releases.
30
+ return (major === 20 && minor >= 17)
31
+ || (major === 22 && minor >= 13)
32
+ || (major === 23 && minor >= 5)
33
+ || major >= 24;
41
34
  }
42
35
 
43
36
  export function detectWsl({
@@ -126,7 +119,7 @@ export function assertNativeRuntimeSupported(context = {}) {
126
119
  const version = context.version ?? process.version;
127
120
  if (!isSupportedNodeVersion(version)) {
128
121
  throw new Error(
129
- `WTAgent requires Node.js 20.17.0 or newer; current runtime is ${version}.`,
122
+ `WTAgent requires Node.js ${getPackageInfo().engines.node}; current runtime is ${version}.`,
130
123
  );
131
124
  }
132
125
 
@@ -73,7 +73,11 @@ export function isPathInside(rootPath, candidatePath, options = {}) {
73
73
  };
74
74
  const root = normalize(rootPath);
75
75
  const candidate = normalize(candidatePath);
76
- return candidate === root || candidate.startsWith(`${root}\\`);
76
+ const relative = path.win32.relative(root, candidate);
77
+ return relative === ""
78
+ || (relative !== ".."
79
+ && !relative.startsWith("..\\")
80
+ && !path.win32.isAbsolute(relative));
77
81
  }
78
82
  const relative = path.relative(rootPath, candidatePath);
79
83
  return relative === ""
@@ -9,6 +9,7 @@ const PATH_FIELDS = Object.freeze({
9
9
  "fs.search": ["path"],
10
10
  "terminal.exec": ["cwd"],
11
11
  "process.start": ["cwd"],
12
+ "image.generate": ["output_path", "reference_images"],
12
13
  });
13
14
 
14
15
  const PRIVILEGED_PROGRAMS = new Set(["sudo", "su", "doas", "runas"]);
@@ -268,11 +269,15 @@ export class PolicyEngine {
268
269
  let allowOutside = false;
269
270
 
270
271
  for (const field of PATH_FIELDS[toolCall.name] ?? []) {
271
- const rawPath = toolCall.args[field] || ".";
272
- const resolved = await resolveToolPath(context.projectRoot, rawPath);
273
- if (!resolved.inside) {
274
- reasons.push(`${field} is outside the selected project: ${resolved.path}`);
275
- allowOutside = true;
272
+ const values = Array.isArray(toolCall.args[field])
273
+ ? toolCall.args[field]
274
+ : [toolCall.args[field] || "."];
275
+ for (const rawPath of values) {
276
+ const resolved = await resolveToolPath(context.projectRoot, rawPath);
277
+ if (!resolved.inside) {
278
+ reasons.push(`${field} is outside the selected project: ${resolved.path}`);
279
+ allowOutside = true;
280
+ }
276
281
  }
277
282
  }
278
283