aws-runtime-bridge 1.5.0 → 1.6.2

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 (62) hide show
  1. package/README.md +1 -1
  2. package/dist/adapter/AdapterRegistry.d.ts +1 -1
  3. package/dist/adapter/AdapterRegistry.d.ts.map +1 -1
  4. package/dist/adapter/AdapterRegistry.js +0 -2
  5. package/dist/adapter/ClaudeSdkAdapter.d.ts +4 -0
  6. package/dist/adapter/ClaudeSdkAdapter.d.ts.map +1 -1
  7. package/dist/adapter/ClaudeSdkAdapter.js +11 -2
  8. package/dist/adapter/CodexSdkAdapter.js +1 -1
  9. package/dist/adapter/OpencodeSdkAdapter.js +2 -2
  10. package/dist/adapter/types.d.ts +10 -0
  11. package/dist/adapter/types.d.ts.map +1 -1
  12. package/dist/index.js +14 -43
  13. package/dist/middleware/auth.d.ts +5 -0
  14. package/dist/middleware/auth.d.ts.map +1 -1
  15. package/dist/middleware/auth.js +9 -1
  16. package/dist/routes/file-browser.d.ts.map +1 -1
  17. package/dist/routes/file-browser.js +21 -1
  18. package/dist/routes/file-browser.test.js +9 -0
  19. package/dist/routes/instance.d.ts +10 -0
  20. package/dist/routes/instance.d.ts.map +1 -1
  21. package/dist/routes/instance.js +93 -2
  22. package/dist/routes/instance.test.js +50 -0
  23. package/dist/routes/pty.d.ts +107 -0
  24. package/dist/routes/pty.d.ts.map +1 -0
  25. package/dist/routes/pty.js +551 -0
  26. package/dist/routes/pty.test.d.ts +2 -0
  27. package/dist/routes/pty.test.d.ts.map +1 -0
  28. package/dist/routes/pty.test.js +82 -0
  29. package/dist/routes/sessions.d.ts +1 -1
  30. package/dist/routes/sessions.d.ts.map +1 -1
  31. package/dist/routes/sessions.js +32 -213
  32. package/dist/routes/terminal.d.ts +32 -3
  33. package/dist/routes/terminal.d.ts.map +1 -1
  34. package/dist/routes/terminal.js +411 -243
  35. package/dist/routes/terminal.test.js +105 -29
  36. package/dist/services/agent-process-manager.d.ts +2 -2
  37. package/dist/services/agent-process-manager.d.ts.map +1 -1
  38. package/dist/services/agent-process-manager.js +3 -3
  39. package/dist/services/process-detector.d.ts +2 -4
  40. package/dist/services/process-detector.d.ts.map +1 -1
  41. package/dist/services/process-detector.js +9 -16
  42. package/dist/services/process-registry.d.ts +2 -2
  43. package/dist/services/process-registry.d.ts.map +1 -1
  44. package/dist/services/process-registry.js +1 -1
  45. package/dist/services/session-output.d.ts +15 -5
  46. package/dist/services/session-output.d.ts.map +1 -1
  47. package/dist/services/session-output.js +33 -3
  48. package/dist/services/session-output.test.js +43 -29
  49. package/dist/services/terminal-persistence.d.ts +9 -0
  50. package/dist/services/terminal-persistence.d.ts.map +1 -1
  51. package/dist/services/terminal-persistence.js +20 -0
  52. package/dist/services/tool-installer.d.ts +10 -0
  53. package/dist/services/tool-installer.d.ts.map +1 -1
  54. package/dist/services/tool-installer.js +193 -28
  55. package/dist/services/tool-installer.test.js +46 -1
  56. package/dist/services/workspace-files.d.ts +14 -0
  57. package/dist/services/workspace-files.d.ts.map +1 -1
  58. package/dist/services/workspace-files.js +52 -0
  59. package/dist/services/workspace-files.test.js +85 -1
  60. package/dist/types.d.ts +8 -4
  61. package/dist/types.d.ts.map +1 -1
  62. package/package.json +2 -1
@@ -80,6 +80,26 @@ export async function upsertPersistedSession(session) {
80
80
  return runningSessions;
81
81
  });
82
82
  }
83
+ /**
84
+ * 更新指定 Agent 的持久化自动命令配置。
85
+ *
86
+ * 主流程:加载 running 会话 -> 命中 agentId 时只替换自动命令字段,保留原 command/workspace/pid。
87
+ */
88
+ export async function updatePersistedSessionAutoCommands(agentId, commands) {
89
+ let updated = false;
90
+ await updatePersistedSessions((sessions) => sessions.map((session) => {
91
+ if (session.agentId !== agentId) {
92
+ return session;
93
+ }
94
+ updated = true;
95
+ return {
96
+ ...session,
97
+ idleInputAutoCommand: commands.idleInputAutoCommand,
98
+ nonInputAutoCommand: commands.nonInputAutoCommand,
99
+ };
100
+ }));
101
+ return updated;
102
+ }
83
103
  /**
84
104
  * 移除持久化会话(从完整文件中删除,包括 stopped 状态的)
85
105
  *
@@ -1,5 +1,11 @@
1
1
  import type { ToolInstallStatus } from "../types.js";
2
2
  export declare const SUPPORTED_INSTALLABLE_TOOLS: readonly string[];
3
+ export declare const SUPPORTED_UNINSTALLABLE_TOOLS: readonly string[];
4
+ /**
5
+ * 返回工具对应的全局卸载命令副本,用于诊断与测试卸载覆盖范围。
6
+ */
7
+ export declare function getToolUninstallCommands(tool: string): string[];
8
+ export declare function isVoltaShimPath(commandPath: string): boolean;
3
9
  /**
4
10
  * 检查单个工具的 CLI 可执行状态,供实例状态展示与初始化前判断使用。
5
11
  */
@@ -12,4 +18,8 @@ export declare function detectToolStatuses(tools: string[]): Promise<Record<stri
12
18
  * 根据勾选工具自动安装缺失 CLI,安装后重新检测状态并返回。
13
19
  */
14
20
  export declare function ensureToolsInstalled(tools: string[]): Promise<Record<string, ToolInstallStatus>>;
21
+ /**
22
+ * 按工具定义执行全局卸载命令,随后重新检测并返回最新安装状态。
23
+ */
24
+ export declare function uninstallTools(tools: string[]): Promise<Record<string, ToolInstallStatus>>;
15
25
  //# sourceMappingURL=tool-installer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tool-installer.d.ts","sourceRoot":"","sources":["../../src/services/tool-installer.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAoErD,eAAO,MAAM,2BAA2B,mBAEvC,CAAC;AAmLF;;GAEG;AACH,wBAAsB,uBAAuB,CAC3C,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,iBAAiB,CAAC,CAyB5B;AAED;;GAEG;AACH,wBAAsB,kBAAkB,CACtC,KAAK,EAAE,MAAM,EAAE,GACd,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC,CAkB5C;AAED;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,MAAM,EAAE,GACd,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC,CA+C5C"}
1
+ {"version":3,"file":"tool-installer.d.ts","sourceRoot":"","sources":["../../src/services/tool-installer.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AA0GrD,eAAO,MAAM,2BAA2B,mBAEvC,CAAC;AAEF,eAAO,MAAM,6BAA6B,mBAIzC,CAAC;AAEF;;GAEG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAK/D;AAoDD,wBAAgB,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAM5D;AAgND;;GAEG;AACH,wBAAsB,uBAAuB,CAC3C,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,iBAAiB,CAAC,CAqC5B;AAED;;GAEG;AACH,wBAAsB,kBAAkB,CACtC,KAAK,EAAE,MAAM,EAAE,GACd,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC,CAkB5C;AAED;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,MAAM,EAAE,GACd,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC,CA+C5C;AAED;;GAEG;AACH,wBAAsB,cAAc,CAClC,KAAK,EAAE,MAAM,EAAE,GACd,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC,CAiD5C"}
@@ -1,44 +1,70 @@
1
+ import { execFile } from "node:child_process";
2
+ import { access, readFile } from "node:fs/promises";
1
3
  import os from "node:os";
2
4
  import path from "node:path";
3
- import { access } from "node:fs/promises";
4
- import { execFile } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
5
6
  import { promisify } from "node:util";
6
7
  const execFileAsync = promisify(execFile);
8
+ const bridgePackageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
7
9
  const isWindows = process.platform === "win32";
10
+ function quoteCommandArg(value) {
11
+ if (isWindows) {
12
+ return `"${value.replaceAll('"', '\\"')}"`;
13
+ }
14
+ return `'${value.replaceAll("'", "'\\''")}'`;
15
+ }
16
+ function npmInstallIntoBridgeCommand(packages) {
17
+ return `npm install --prefix ${quoteCommandArg(bridgePackageRoot)} ${packages.join(" ")}`;
18
+ }
19
+ function npmUninstallFromBridgeCommand(packages) {
20
+ return `npm uninstall --prefix ${quoteCommandArg(bridgePackageRoot)} ${packages.join(" ")}`;
21
+ }
8
22
  const TOOL_DEFINITIONS = {
9
23
  claude: {
10
24
  key: "claude",
11
- aliases: isWindows ? ["claude.cmd", "claude.exe", "claude"] : ["claude"],
12
- versionArgs: ["--version"],
13
- installCommands: isWindows
14
- ? ["npm install -g @anthropic-ai/claude-code@latest"]
15
- : [
16
- "curl -fsSL https://claude.ai/install.sh | bash",
17
- "npm install -g @anthropic-ai/claude-code@latest",
18
- ],
25
+ packageName: "@anthropic-ai/claude-agent-sdk",
26
+ sdkPackageName: "@anthropic-ai/claude-agent-sdk",
27
+ aliases: [],
28
+ versionArgs: [],
29
+ installCommands: [
30
+ npmInstallIntoBridgeCommand(["@anthropic-ai/claude-agent-sdk@latest"]),
31
+ ],
32
+ uninstallCommands: [
33
+ npmUninstallFromBridgeCommand(["@anthropic-ai/claude-agent-sdk"]),
34
+ ],
19
35
  },
20
36
  claudecode: {
21
37
  key: "claudecode",
22
- aliases: isWindows ? ["claude.cmd", "claude.exe", "claude"] : ["claude"],
23
- versionArgs: ["--version"],
24
- installCommands: isWindows
25
- ? ["npm install -g @anthropic-ai/claude-code@latest"]
26
- : [
27
- "curl -fsSL https://claude.ai/install.sh | bash",
28
- "npm install -g @anthropic-ai/claude-code@latest",
29
- ],
38
+ packageName: "@anthropic-ai/claude-agent-sdk",
39
+ sdkPackageName: "@anthropic-ai/claude-agent-sdk",
40
+ aliases: [],
41
+ versionArgs: [],
42
+ installCommands: [
43
+ npmInstallIntoBridgeCommand(["@anthropic-ai/claude-agent-sdk@latest"]),
44
+ ],
45
+ uninstallCommands: [
46
+ npmUninstallFromBridgeCommand(["@anthropic-ai/claude-agent-sdk"]),
47
+ ],
30
48
  },
31
49
  opencode: {
32
50
  key: "opencode",
51
+ packageName: "@opencode-ai/sdk",
52
+ sdkPackageName: "@opencode-ai/sdk",
33
53
  aliases: isWindows
34
54
  ? ["opencode.cmd", "opencode.exe", "opencode"]
35
55
  : ["opencode"],
36
56
  versionArgs: ["--version"],
37
- installCommands: isWindows
38
- ? ["npm install -g opencode-ai@latest"]
57
+ installCommands: [
58
+ npmInstallIntoBridgeCommand(["@opencode-ai/sdk@latest", "opencode-ai@latest"]),
59
+ ],
60
+ uninstallCommands: isWindows
61
+ ? [
62
+ npmUninstallFromBridgeCommand(["@opencode-ai/sdk", "opencode-ai"]),
63
+ ]
39
64
  : [
40
- "curl -fsSL https://opencode.ai/install | bash",
41
- "npm install -g opencode-ai@latest",
65
+ npmUninstallFromBridgeCommand(["@opencode-ai/sdk", "opencode-ai"]),
66
+ "opencode uninstall --force",
67
+ "rm -f ~/.opencode/bin/opencode",
42
68
  ],
43
69
  extraSearchPaths: () => {
44
70
  const home = os.homedir();
@@ -53,12 +79,25 @@ const TOOL_DEFINITIONS = {
53
79
  },
54
80
  codex: {
55
81
  key: "codex",
56
- aliases: isWindows ? ["codex.cmd", "codex.exe", "codex"] : ["codex"],
57
- versionArgs: ["--version"],
58
- installCommands: ["npm install -g @openai/codex@latest"],
82
+ packageName: "@openai/codex-sdk",
83
+ sdkPackageName: "@openai/codex-sdk",
84
+ aliases: [],
85
+ versionArgs: [],
86
+ installCommands: [npmInstallIntoBridgeCommand(["@openai/codex-sdk@latest"])],
87
+ uninstallCommands: [npmUninstallFromBridgeCommand(["@openai/codex-sdk"])],
59
88
  },
60
89
  };
61
90
  export const SUPPORTED_INSTALLABLE_TOOLS = Object.freeze(Object.keys(TOOL_DEFINITIONS));
91
+ export const SUPPORTED_UNINSTALLABLE_TOOLS = Object.freeze(Object.keys(TOOL_DEFINITIONS).filter((tool) => TOOL_DEFINITIONS[tool].uninstallCommands.length > 0));
92
+ /**
93
+ * 返回工具对应的全局卸载命令副本,用于诊断与测试卸载覆盖范围。
94
+ */
95
+ export function getToolUninstallCommands(tool) {
96
+ const normalizedTool = String(tool || "")
97
+ .trim()
98
+ .toLowerCase();
99
+ return [...(TOOL_DEFINITIONS[normalizedTool]?.uninstallCommands || [])];
100
+ }
62
101
  function parseVersion(output) {
63
102
  const normalized = String(output || "").trim();
64
103
  if (!normalized) {
@@ -103,6 +142,26 @@ function describeExecError(error) {
103
142
  }
104
143
  return message || "command execution failed";
105
144
  }
145
+ export function isVoltaShimPath(commandPath) {
146
+ const normalizedPath = path
147
+ .normalize(commandPath)
148
+ .replaceAll("\\", "/")
149
+ .toLowerCase();
150
+ return /(?:^|\/)\.?volta\/bin\//.test(normalizedPath);
151
+ }
152
+ async function isVoltaPackageInstalled(packageName) {
153
+ try {
154
+ const { stdout, stderr } = await execFileAsync("volta", ["list", "--format", "plain"], {
155
+ timeout: 20_000,
156
+ });
157
+ const output = `${stdout || ""}\n${stderr || ""}`;
158
+ const escapedPackageName = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
159
+ return new RegExp(`^package\\s+${escapedPackageName}(?:@|\\s)`, "im").test(output);
160
+ }
161
+ catch {
162
+ return false;
163
+ }
164
+ }
106
165
  /**
107
166
  * 在 Windows 上执行命令(处理 .cmd/.bat 文件)
108
167
  * 在 Unix 系统上直接执行
@@ -151,7 +210,7 @@ async function resolveExecutableCandidate(definition) {
151
210
  }
152
211
  catch (error) {
153
212
  executionFailureMessage = describeExecError(error);
154
- if (executionFailureMessage !== "command not found in PATH") {
213
+ if (!isWindows && executionFailureMessage !== "command not found in PATH") {
155
214
  return {
156
215
  executable: alias,
157
216
  version: null,
@@ -164,6 +223,11 @@ async function resolveExecutableCandidate(definition) {
164
223
  try {
165
224
  await access(absolutePath);
166
225
  foundCommandPath = absolutePath;
226
+ if (isVoltaShimPath(absolutePath) &&
227
+ !(await isVoltaPackageInstalled(definition.packageName))) {
228
+ foundCommandPath = null;
229
+ continue;
230
+ }
167
231
  const { stdout, stderr } = await executeVersionCommand(absolutePath, definition.versionArgs);
168
232
  const version = parseVersion(`${stdout || ""}\n${stderr || ""}`);
169
233
  if (!version) {
@@ -196,7 +260,51 @@ async function resolveExecutableCandidate(definition) {
196
260
  }
197
261
  return { executable: null, version: null, error: "command not installed" };
198
262
  }
199
- async function runInstallCommand(command) {
263
+ async function readPackageVersion(packageJsonPath) {
264
+ try {
265
+ const rawPackageJson = await readFile(packageJsonPath, "utf8");
266
+ const parsedPackageJson = JSON.parse(rawPackageJson);
267
+ return typeof parsedPackageJson.version === "string"
268
+ ? parsedPackageJson.version
269
+ : null;
270
+ }
271
+ catch {
272
+ return null;
273
+ }
274
+ }
275
+ async function readInstalledPackageVersion(packageName) {
276
+ const packageJsonPath = path.join(bridgePackageRoot, "node_modules", ...packageName.split("/"), "package.json");
277
+ return readPackageVersion(packageJsonPath);
278
+ }
279
+ /**
280
+ * 检测 SDK provider 包是否能被当前 bridge 进程解析。
281
+ * 主流程:使用与运行时代码相同的模块解析上下文,避免把全局 CLI 安装误报为 SDK 可用。
282
+ */
283
+ async function resolveSdkPackageCandidate(definition) {
284
+ const sdkPackageName = definition.sdkPackageName;
285
+ if (!sdkPackageName) {
286
+ return resolveExecutableCandidate(definition);
287
+ }
288
+ try {
289
+ await import(sdkPackageName);
290
+ return {
291
+ executable: sdkPackageName,
292
+ version: await readInstalledPackageVersion(sdkPackageName),
293
+ error: null,
294
+ };
295
+ }
296
+ catch (error) {
297
+ const message = error instanceof Error ? error.message : String(error || "");
298
+ return {
299
+ executable: null,
300
+ version: null,
301
+ error: message.includes(sdkPackageName)
302
+ ? `SDK package ${sdkPackageName} is not installed in aws-runtime-bridge`
303
+ : message || `SDK package ${sdkPackageName} is not installed in aws-runtime-bridge`,
304
+ };
305
+ }
306
+ }
307
+ async function runToolCommand(command) {
200
308
  if (isWindows) {
201
309
  await execFileAsync("cmd.exe", ["/d", "/s", "/c", command], {
202
310
  timeout: 10 * 60 * 1000,
@@ -223,6 +331,17 @@ export async function detectToolInstallStatus(tool) {
223
331
  error: "unsupported tool",
224
332
  };
225
333
  }
334
+ const sdkResult = await resolveSdkPackageCandidate(definition);
335
+ if (definition.sdkPackageName) {
336
+ return {
337
+ tool: normalizedTool,
338
+ installed: Boolean(sdkResult.executable),
339
+ executable: sdkResult.executable,
340
+ version: sdkResult.version,
341
+ installing: false,
342
+ error: sdkResult.error,
343
+ };
344
+ }
226
345
  const result = await resolveExecutableCandidate(definition);
227
346
  return {
228
347
  tool: normalizedTool,
@@ -267,7 +386,7 @@ export async function ensureToolsInstalled(tools) {
267
386
  let lastError = current.error;
268
387
  for (const command of definition.installCommands) {
269
388
  try {
270
- await runInstallCommand(command);
389
+ await runToolCommand(command);
271
390
  const detected = await detectToolInstallStatus(tool);
272
391
  nextStatuses[tool] = detected;
273
392
  if (detected.installed) {
@@ -295,3 +414,49 @@ export async function ensureToolsInstalled(tools) {
295
414
  }
296
415
  return nextStatuses;
297
416
  }
417
+ /**
418
+ * 按工具定义执行全局卸载命令,随后重新检测并返回最新安装状态。
419
+ */
420
+ export async function uninstallTools(tools) {
421
+ const normalizedTools = [
422
+ ...new Set((Array.isArray(tools) ? tools : [])
423
+ .map((item) => String(item || "")
424
+ .trim()
425
+ .toLowerCase())
426
+ .filter(Boolean)),
427
+ ];
428
+ const nextStatuses = {};
429
+ for (const tool of normalizedTools) {
430
+ const definition = TOOL_DEFINITIONS[tool];
431
+ if (!definition) {
432
+ nextStatuses[tool] = await detectToolInstallStatus(tool);
433
+ continue;
434
+ }
435
+ let lastError = null;
436
+ for (const command of definition.uninstallCommands) {
437
+ try {
438
+ await runToolCommand(command);
439
+ lastError = null;
440
+ const detectedAfterCommand = await detectToolInstallStatus(tool);
441
+ if (!detectedAfterCommand.installed) {
442
+ nextStatuses[tool] = detectedAfterCommand;
443
+ break;
444
+ }
445
+ }
446
+ catch (error) {
447
+ lastError =
448
+ error instanceof Error
449
+ ? error.message
450
+ : String(error || "uninstall failed");
451
+ }
452
+ }
453
+ const detected = await detectToolInstallStatus(tool);
454
+ nextStatuses[tool] = {
455
+ ...detected,
456
+ error: detected.installed
457
+ ? lastError || detected.error || "uninstall completed but command is still available"
458
+ : detected.error,
459
+ };
460
+ }
461
+ return nextStatuses;
462
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { detectToolStatuses, detectToolInstallStatus, SUPPORTED_INSTALLABLE_TOOLS } from './tool-installer.js';
2
+ import { detectToolInstallStatus, SUPPORTED_INSTALLABLE_TOOLS, SUPPORTED_UNINSTALLABLE_TOOLS, detectToolStatuses, getToolUninstallCommands, isVoltaShimPath } from './tool-installer.js';
3
3
  describe('tool installer service', () => {
4
4
  it('returns structured status for supported tools', async () => {
5
5
  const statuses = await detectToolStatuses(['claude', 'opencode', 'codex']);
@@ -76,6 +76,51 @@ describe('tool installer service', () => {
76
76
  expect(typeof status.installed).toBe('boolean');
77
77
  expect(status.error).not.toBe('unsupported tool');
78
78
  });
79
+ it('detects OpenCode by SDK package instead of CLI availability', async () => {
80
+ const status = await detectToolInstallStatus('opencode');
81
+ expect(status.tool).toBe('opencode');
82
+ expect(status.executable === null || status.executable === '@opencode-ai/sdk').toBe(true);
83
+ if (!status.installed) {
84
+ expect(status.error).toContain('@opencode-ai/sdk');
85
+ }
86
+ });
87
+ it('detects Codex by SDK package instead of CLI availability', async () => {
88
+ const status = await detectToolInstallStatus('codex');
89
+ expect(status.tool).toBe('codex');
90
+ expect(status.executable === null || status.executable === '@openai/codex-sdk').toBe(true);
91
+ if (!status.installed) {
92
+ expect(status.error).toContain('@openai/codex-sdk');
93
+ }
94
+ });
95
+ it('supports all panel tools as uninstallable tools', () => {
96
+ expect(SUPPORTED_UNINSTALLABLE_TOOLS).toEqual(expect.arrayContaining(['claude', 'opencode', 'codex']));
97
+ });
98
+ it('covers npm and native installer uninstall paths for OpenCode', () => {
99
+ const commands = getToolUninstallCommands('opencode');
100
+ expect(commands.some(command => command.includes('npm uninstall --prefix'))).toBe(true);
101
+ expect(commands.some(command => command.includes('@opencode-ai/sdk'))).toBe(true);
102
+ expect(commands.some(command => command.includes('opencode-ai'))).toBe(true);
103
+ if (process.platform !== 'win32') {
104
+ expect(commands).toContain('opencode uninstall --force');
105
+ expect(commands).toContain('rm -f ~/.opencode/bin/opencode');
106
+ }
107
+ });
108
+ it('covers bridge-local SDK uninstall path for Claude Code', () => {
109
+ const commands = getToolUninstallCommands('claude');
110
+ expect(commands.some(command => command.includes('npm uninstall --prefix'))).toBe(true);
111
+ expect(commands.some(command => command.includes('@anthropic-ai/claude-agent-sdk'))).toBe(true);
112
+ });
113
+ it('covers bridge-local SDK uninstall path for Codex', () => {
114
+ const commands = getToolUninstallCommands('codex');
115
+ expect(commands.some(command => command.includes('npm uninstall --prefix'))).toBe(true);
116
+ expect(commands.some(command => command.includes('@openai/codex-sdk'))).toBe(true);
117
+ });
118
+ it('recognizes Volta shim paths for package-manager-aware detection', () => {
119
+ expect(isVoltaShimPath('C:\\Users\\tester\\AppData\\Local\\Volta\\bin\\codex.cmd')).toBe(true);
120
+ expect(isVoltaShimPath('/home/tester/.volta/bin/codex')).toBe(true);
121
+ expect(isVoltaShimPath('/Users/tester/.volta/bin/claude')).toBe(true);
122
+ expect(isVoltaShimPath('/usr/local/bin/codex')).toBe(false);
123
+ });
79
124
  it('detectToolStatuses returns status for all requested tools', async () => {
80
125
  const tools = ['claude', 'opencode', 'unknown-tool'];
81
126
  const statuses = await detectToolStatuses(tools);
@@ -67,6 +67,10 @@ interface RenameWorkspaceEntryParams extends WorkspacePathParams {
67
67
  targetPath: string;
68
68
  newName: string;
69
69
  }
70
+ interface MoveWorkspaceEntryParams extends WorkspacePathParams {
71
+ targetPath: string;
72
+ destinationPath: string;
73
+ }
70
74
  interface DeleteWorkspaceEntryParams extends WorkspacePathParams {
71
75
  targetPath: string;
72
76
  }
@@ -119,6 +123,16 @@ export declare function renameWorkspaceEntry(params: RenameWorkspaceEntryParams)
119
123
  sourcePath: string;
120
124
  targetPath: string;
121
125
  }>;
126
+ /**
127
+ * 将工作区内文件或目录移动到另一个工作区目录,保持原文件名不变。
128
+ */
129
+ export declare function moveWorkspaceEntry(params: MoveWorkspaceEntryParams): Promise<{
130
+ ok: true;
131
+ workspacePath: string;
132
+ sourcePath: string;
133
+ targetPath: string;
134
+ destinationPath: string;
135
+ }>;
122
136
  /**
123
137
  * 删除工作区内的文件或目录,并阻止误删工作区根目录。
124
138
  */
@@ -1 +1 @@
1
- {"version":3,"file":"workspace-files.d.ts","sourceRoot":"","sources":["../../src/services/workspace-files.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"workspace-files.d.ts","sourceRoot":"","sources":["../../src/services/workspace-files.ts"],"names":[],"mappings":"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"}
@@ -487,6 +487,55 @@ export async function renameWorkspaceEntry(params) {
487
487
  targetPath: toDisplayPath(renamedPath)
488
488
  };
489
489
  }
490
+ /**
491
+ * 将工作区内文件或目录移动到另一个工作区目录,保持原文件名不变。
492
+ */
493
+ export async function moveWorkspaceEntry(params) {
494
+ const { workspaceRoot, resolvedTargetPath } = await resolveEntryTarget(params);
495
+ const destination = await resolveDirectoryTarget({
496
+ workspacePath: params.workspacePath,
497
+ targetPath: params.destinationPath,
498
+ });
499
+ const destinationStat = await fs.stat(destination.resolvedTargetPath);
500
+ if (!destinationStat.isDirectory()) {
501
+ throw new Error(`Destination is not a directory: ${destination.resolvedTargetPath}`);
502
+ }
503
+ const targetStat = await fs.stat(resolvedTargetPath);
504
+ if (targetStat.isDirectory()) {
505
+ const relativeDestination = path.relative(resolvedTargetPath, destination.resolvedTargetPath);
506
+ if (relativeDestination === '' || (!relativeDestination.startsWith('..') && !path.isAbsolute(relativeDestination))) {
507
+ throw new Error('Cannot move a directory into itself or its descendant');
508
+ }
509
+ }
510
+ const movedPath = ensureInsideWorkspace(workspaceRoot, path.join(destination.resolvedTargetPath, path.basename(resolvedTargetPath)));
511
+ if (movedPath === resolvedTargetPath) {
512
+ return {
513
+ ok: true,
514
+ workspacePath: toDisplayPath(workspaceRoot),
515
+ sourcePath: toDisplayPath(resolvedTargetPath),
516
+ targetPath: toDisplayPath(movedPath),
517
+ destinationPath: toDisplayPath(destination.resolvedTargetPath),
518
+ };
519
+ }
520
+ try {
521
+ await fs.access(movedPath);
522
+ throw new Error(`Path already exists: ${movedPath}`);
523
+ }
524
+ catch (error) {
525
+ const errorCode = typeof error === 'object' && error !== null ? Reflect.get(error, 'code') : undefined;
526
+ if (errorCode !== 'ENOENT') {
527
+ throw error;
528
+ }
529
+ }
530
+ await fs.rename(resolvedTargetPath, movedPath);
531
+ return {
532
+ ok: true,
533
+ workspacePath: toDisplayPath(workspaceRoot),
534
+ sourcePath: toDisplayPath(resolvedTargetPath),
535
+ targetPath: toDisplayPath(movedPath),
536
+ destinationPath: toDisplayPath(destination.resolvedTargetPath),
537
+ };
538
+ }
490
539
  /**
491
540
  * 删除工作区内的文件或目录,并阻止误删工作区根目录。
492
541
  */
@@ -639,6 +688,9 @@ async function extractTarArchive(archivePath, outputRoot, gzip, meter) {
639
688
  if (entryType === 'symboliclink' || entryType === 'link') {
640
689
  throw new Error(`Archive links are not supported: ${entryPath}`);
641
690
  }
691
+ if (entryType !== 'file' && entryType !== 'oldfile' && entryType !== 'contiguousfile' && entryType !== 'directory') {
692
+ throw new Error(`Unsupported archive entry type: ${entryPath}`);
693
+ }
642
694
  if (typeof entry.size === 'number') {
643
695
  trackArchiveBytes(meter, entry.size);
644
696
  }
@@ -3,7 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import * as tar from 'tar';
5
5
  import { afterAll, beforeAll, describe, expect, it } from 'vitest';
6
- import { createWorkspaceEntry, deleteWorkspaceEntry, extractWorkspaceArchive, listWorkspaceDirectory, previewWorkspaceDocument, readWorkspaceFile, renameWorkspaceEntry, resolveWorkspaceDownloadTarget, streamWorkspaceDirectoryZip, uploadWorkspaceFiles, writeWorkspaceFile, } from './workspace-files.js';
6
+ import { createWorkspaceEntry, deleteWorkspaceEntry, extractWorkspaceArchive, listWorkspaceDirectory, moveWorkspaceEntry, previewWorkspaceDocument, readWorkspaceFile, renameWorkspaceEntry, resolveWorkspaceDownloadTarget, streamWorkspaceDirectoryZip, uploadWorkspaceFiles, writeWorkspaceFile, } from './workspace-files.js';
7
7
  const { ZipArchive } = await import('archiver');
8
8
  function crc32(input) {
9
9
  let crc = 0xffffffff;
@@ -70,6 +70,27 @@ async function writeStoredZipWithNameBytes(filePath, nameBytes, content) {
70
70
  endRecord
71
71
  ]));
72
72
  }
73
+ async function writeTarHeaderOnlyEntry(filePath, entryName, typeFlag) {
74
+ const header = Buffer.alloc(512, 0);
75
+ header.write(entryName, 0, Math.min(Buffer.byteLength(entryName), 100), 'utf-8');
76
+ header.write('0000644\0', 100, 'ascii');
77
+ header.write('0000000\0', 108, 'ascii');
78
+ header.write('0000000\0', 116, 'ascii');
79
+ header.write('00000000000\0', 124, 'ascii');
80
+ header.write('00000000000\0', 136, 'ascii');
81
+ header.fill(0x20, 148, 156);
82
+ header.write(typeFlag, 156, 'ascii');
83
+ header.write('ustar\0', 257, 'ascii');
84
+ header.write('00', 263, 'ascii');
85
+ let checksum = 0;
86
+ for (const byte of header) {
87
+ checksum += byte;
88
+ }
89
+ header.write(checksum.toString(8).padStart(6, '0'), 148, 'ascii');
90
+ header[154] = 0;
91
+ header[155] = 0x20;
92
+ await fs.writeFile(filePath, Buffer.concat([header, Buffer.alloc(1024, 0)]));
93
+ }
73
94
  describe('workspace file service', () => {
74
95
  const tempRoot = path.join(os.tmpdir(), `aws-workspace-files-${Date.now()}`);
75
96
  const workspacePath = path.join(tempRoot, 'workspace');
@@ -201,6 +222,60 @@ describe('workspace file service', () => {
201
222
  targetPath: workspacePath
202
223
  })).rejects.toThrow(/Workspace root cannot be modified/);
203
224
  });
225
+ it('moves files and rejects moving directories into themselves', async () => {
226
+ const moveSourceDir = path.join(workspacePath, 'move-source');
227
+ const moveDestinationDir = path.join(workspacePath, 'move-destination');
228
+ const fileToMove = path.join(moveSourceDir, 'move-me.txt');
229
+ await fs.mkdir(moveSourceDir, { recursive: true });
230
+ await fs.mkdir(moveDestinationDir, { recursive: true });
231
+ await fs.writeFile(fileToMove, 'move me', 'utf-8');
232
+ const result = await moveWorkspaceEntry({
233
+ workspacePath,
234
+ targetPath: fileToMove,
235
+ destinationPath: moveDestinationDir,
236
+ });
237
+ const movedPath = path.join(moveDestinationDir, 'move-me.txt');
238
+ expect(result.sourcePath).toBe(fileToMove.replace(/\\/g, '/'));
239
+ expect(result.targetPath).toBe((await fs.realpath(movedPath)).replace(/\\/g, '/'));
240
+ await expect(fs.access(fileToMove)).rejects.toThrow();
241
+ await expect(fs.readFile(movedPath, 'utf-8')).resolves.toBe('move me');
242
+ const childDestination = path.join(moveSourceDir, 'child');
243
+ await fs.mkdir(childDestination, { recursive: true });
244
+ await expect(moveWorkspaceEntry({
245
+ workspacePath,
246
+ targetPath: moveSourceDir,
247
+ destinationPath: childDestination,
248
+ })).rejects.toThrow(/itself or its descendant/);
249
+ });
250
+ it('rejects unsafe workspace move targets', async () => {
251
+ const moveSafetyDir = path.join(workspacePath, 'move-safety');
252
+ const moveSafetyFile = path.join(moveSafetyDir, 'safe.txt');
253
+ const moveSafetyDestination = path.join(workspacePath, 'move-safety-dest');
254
+ await fs.mkdir(moveSafetyDir, { recursive: true });
255
+ await fs.mkdir(moveSafetyDestination, { recursive: true });
256
+ await fs.writeFile(moveSafetyFile, 'safe', 'utf-8');
257
+ await fs.writeFile(path.join(moveSafetyDestination, 'safe.txt'), 'exists', 'utf-8');
258
+ await expect(moveWorkspaceEntry({
259
+ workspacePath,
260
+ targetPath: workspacePath,
261
+ destinationPath: moveSafetyDestination,
262
+ })).rejects.toThrow(/Workspace root cannot be modified/);
263
+ await expect(moveWorkspaceEntry({
264
+ workspacePath,
265
+ targetPath: path.join(tempRoot, 'outside.txt'),
266
+ destinationPath: moveSafetyDestination,
267
+ })).rejects.toThrow(/outside workspace/);
268
+ await expect(moveWorkspaceEntry({
269
+ workspacePath,
270
+ targetPath: moveSafetyFile,
271
+ destinationPath: tempRoot,
272
+ })).rejects.toThrow(/outside workspace/);
273
+ await expect(moveWorkspaceEntry({
274
+ workspacePath,
275
+ targetPath: moveSafetyFile,
276
+ destinationPath: moveSafetyDestination,
277
+ })).rejects.toThrow(/Path already exists/);
278
+ });
204
279
  it('uploads files to a selected workspace directory', async () => {
205
280
  const uploadSource = path.join(tempRoot, 'upload-source.txt');
206
281
  const uploadTargetDir = path.join(workspacePath, 'uploads');
@@ -490,4 +565,13 @@ describe('workspace file service', () => {
490
565
  outputPath: workspacePath
491
566
  })).rejects.toThrow(/links are not supported/);
492
567
  });
568
+ it('rejects tar archives with unsupported special entries', async () => {
569
+ const archivePath = path.join(workspacePath, 'special-entry.tar');
570
+ await writeTarHeaderOnlyEntry(archivePath, 'special-device', '3');
571
+ await expect(extractWorkspaceArchive({
572
+ workspacePath,
573
+ archivePath,
574
+ outputPath: workspacePath
575
+ })).rejects.toThrow(/Unsupported archive entry type/);
576
+ });
493
577
  });