@mrpatronz/nexusflow 2.4.0 → 2.5.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 (168) hide show
  1. package/dist/commands/isolate.d.ts +12 -0
  2. package/dist/commands/isolate.d.ts.map +1 -0
  3. package/dist/commands/isolate.js +78 -0
  4. package/dist/commands/isolate.js.map +1 -0
  5. package/dist/commands/isolate.test.d.ts +2 -0
  6. package/dist/commands/isolate.test.d.ts.map +1 -0
  7. package/dist/commands/isolate.test.js +59 -0
  8. package/dist/commands/isolate.test.js.map +1 -0
  9. package/dist/commands/open.d.ts.map +1 -1
  10. package/dist/commands/open.js +66 -21
  11. package/dist/commands/open.js.map +1 -1
  12. package/dist/commands/ui.d.ts +1 -1
  13. package/dist/commands/ui.d.ts.map +1 -1
  14. package/dist/commands/ui.js +26 -6
  15. package/dist/commands/ui.js.map +1 -1
  16. package/dist/core/config-schema.d.ts +17 -0
  17. package/dist/core/config-schema.d.ts.map +1 -0
  18. package/dist/core/config-schema.js +24 -0
  19. package/dist/core/config-schema.js.map +1 -0
  20. package/dist/core/doctor.d.ts.map +1 -1
  21. package/dist/core/doctor.js +19 -5
  22. package/dist/core/doctor.js.map +1 -1
  23. package/dist/core/isolate.d.ts +31 -0
  24. package/dist/core/isolate.d.ts.map +1 -0
  25. package/dist/core/isolate.js +175 -0
  26. package/dist/core/isolate.js.map +1 -0
  27. package/dist/core/isolate.test.d.ts +2 -0
  28. package/dist/core/isolate.test.d.ts.map +1 -0
  29. package/dist/core/isolate.test.js +290 -0
  30. package/dist/core/isolate.test.js.map +1 -0
  31. package/dist/core/refresh.d.ts.map +1 -1
  32. package/dist/core/refresh.js +35 -1
  33. package/dist/core/refresh.js.map +1 -1
  34. package/dist/core/sync.d.ts.map +1 -1
  35. package/dist/core/sync.js +21 -7
  36. package/dist/core/sync.js.map +1 -1
  37. package/dist/core/workspace.d.ts +6 -0
  38. package/dist/core/workspace.d.ts.map +1 -1
  39. package/dist/core/workspace.js +61 -17
  40. package/dist/core/workspace.js.map +1 -1
  41. package/dist/core/worktree.d.ts.map +1 -1
  42. package/dist/core/worktree.js +7 -0
  43. package/dist/core/worktree.js.map +1 -1
  44. package/dist/desktop-server.js +2 -1
  45. package/dist/desktop-server.js.map +1 -1
  46. package/dist/generators/base.d.ts.map +1 -1
  47. package/dist/generators/base.js +13 -5
  48. package/dist/generators/base.js.map +1 -1
  49. package/dist/generators/base.test.js +1 -1
  50. package/dist/generators/base.test.js.map +1 -1
  51. package/dist/gui/antigravity-logo.png +0 -0
  52. package/dist/gui/assets/AgentsPage-BNHR9qux.js +3 -0
  53. package/dist/gui/assets/CSPContext-BG7no5S_.js +1 -0
  54. package/dist/gui/assets/DashboardPage-BScH3Cxp.js +1 -0
  55. package/dist/gui/assets/{GettingStartedPage-DQ5kCj0q.js → GettingStartedPage-AWlI-176.js} +1 -1
  56. package/dist/gui/assets/ProjectsPage-Bojj7gxI.js +1 -0
  57. package/dist/gui/assets/ScaffoldRepoInline-DJMbeHVM.js +1 -0
  58. package/dist/gui/assets/SettingsPage-BAdXO8X0.js +1 -0
  59. package/dist/gui/assets/SkillsPage-CF6Db1FA.js +6 -0
  60. package/dist/gui/assets/StartWorkPage-BQid3uIw.js +1 -0
  61. package/dist/gui/assets/{StrategiesPage-CkpJI4Q3.js → StrategiesPage-nGbR9TAc.js} +2 -2
  62. package/dist/gui/assets/WorkspacesPage-7XRtVJNF.js +2 -0
  63. package/dist/gui/assets/{alert-dialog-Cjwogh-W.js → alert-dialog-67uzXJi4.js} +1 -1
  64. package/dist/gui/assets/antigravity-logo-DRKQga2U.png +0 -0
  65. package/dist/gui/assets/{badge-BnkhqVEF.js → badge-CAohJGj6.js} +1 -1
  66. package/dist/gui/assets/{bot-wH0FKlic.js → bot-C2HWrAft.js} +1 -1
  67. package/dist/gui/assets/{button-cPldH5S4.js → button-BDDZnIHx.js} +1 -1
  68. package/dist/gui/assets/card-BKXzd_5L.js +1 -0
  69. package/dist/gui/assets/chevron-down-qTL8HPug.js +1 -0
  70. package/dist/gui/assets/{circle-plus-C0PNc7h4.js → circle-plus-lmU1yNJd.js} +1 -1
  71. package/dist/gui/assets/code-xml-jFAa3-TT.js +1 -0
  72. package/dist/gui/assets/{cpu-CPUNnHRM.js → cpu-Cdwd84Jz.js} +1 -1
  73. package/dist/gui/assets/{empty-DWyDwDYB.js → empty-BdlgbifD.js} +1 -1
  74. package/dist/gui/assets/{git-branch-Dw55pQWa.js → git-branch-Cfj9D8yW.js} +1 -1
  75. package/dist/gui/assets/index-CZoZQkeC.css +2 -0
  76. package/dist/gui/assets/index-V1dDX1O_.js +50 -0
  77. package/dist/gui/assets/isElementDisabled-D_fu2zfc.js +1 -0
  78. package/dist/gui/assets/{label-JFgMNsTn.js → label-CG5exjag.js} +1 -1
  79. package/dist/gui/assets/{terminal-D1awFRPr.js → square-DlIYnlSq.js} +1 -1
  80. package/dist/gui/assets/store-D7ixdVck.js +3 -0
  81. package/dist/gui/assets/styles-CVwNuFCx.js +1 -0
  82. package/dist/gui/assets/textarea-BNRuIXNs.js +1 -0
  83. package/dist/gui/assets/{trash-2-Da8F2gKd.js → trash-2-CtR35_eF.js} +1 -1
  84. package/dist/gui/assets/useAriaLabelledBy-DfRdwH8a.js +1 -0
  85. package/dist/gui/assets/useValueChanged-CStSGSsD.js +1 -0
  86. package/dist/gui/assets/vsc-Di05BTNr.js +1 -0
  87. package/dist/gui/assets/zap-CWG_Lvki.js +1 -0
  88. package/dist/gui/index.html +9 -7
  89. package/dist/index.js +22 -0
  90. package/dist/index.js.map +1 -1
  91. package/dist/mcp/tools.d.ts.map +1 -1
  92. package/dist/mcp/tools.js +47 -1
  93. package/dist/mcp/tools.js.map +1 -1
  94. package/dist/mcp/tools.test.d.ts +2 -0
  95. package/dist/mcp/tools.test.d.ts.map +1 -0
  96. package/dist/mcp/tools.test.js +70 -0
  97. package/dist/mcp/tools.test.js.map +1 -0
  98. package/dist/orchestration/orchestrator.js +2 -2
  99. package/dist/orchestration/orchestrator.js.map +1 -1
  100. package/dist/orchestration/orchestrator.test.js +1 -1
  101. package/dist/orchestration/orchestrator.test.js.map +1 -1
  102. package/dist/orchestration/runner.d.ts +5 -1
  103. package/dist/orchestration/runner.d.ts.map +1 -1
  104. package/dist/orchestration/runner.js +14 -4
  105. package/dist/orchestration/runner.js.map +1 -1
  106. package/dist/orchestration/runner.test.js +22 -16
  107. package/dist/orchestration/runner.test.js.map +1 -1
  108. package/dist/server.d.ts.map +1 -1
  109. package/dist/server.js +189 -27
  110. package/dist/server.js.map +1 -1
  111. package/dist/server.test.js +220 -7
  112. package/dist/server.test.js.map +1 -1
  113. package/dist/types.d.ts +17 -1
  114. package/dist/types.d.ts.map +1 -1
  115. package/dist/utils/detect-editors.d.ts +1 -7
  116. package/dist/utils/detect-editors.d.ts.map +1 -1
  117. package/dist/utils/detect-editors.js +12 -10
  118. package/dist/utils/detect-editors.js.map +1 -1
  119. package/dist/utils/detect-editors.test.js +58 -25
  120. package/dist/utils/detect-editors.test.js.map +1 -1
  121. package/dist/utils/feature.d.ts +10 -1
  122. package/dist/utils/feature.d.ts.map +1 -1
  123. package/dist/utils/feature.js +36 -4
  124. package/dist/utils/feature.js.map +1 -1
  125. package/dist/utils/open-editor.d.ts +1 -1
  126. package/dist/utils/open-editor.d.ts.map +1 -1
  127. package/dist/utils/open-editor.js +11 -2
  128. package/dist/utils/open-editor.js.map +1 -1
  129. package/dist/utils/session-finder.d.ts +11 -0
  130. package/dist/utils/session-finder.d.ts.map +1 -1
  131. package/dist/utils/session-finder.js +121 -48
  132. package/dist/utils/session-finder.js.map +1 -1
  133. package/dist/utils/session-finder.test.js +105 -1
  134. package/dist/utils/session-finder.test.js.map +1 -1
  135. package/dist/utils/terminal-launch.d.ts +39 -0
  136. package/dist/utils/terminal-launch.d.ts.map +1 -0
  137. package/dist/utils/terminal-launch.js +205 -0
  138. package/dist/utils/terminal-launch.js.map +1 -0
  139. package/dist/utils/terminal-launch.test.d.ts +2 -0
  140. package/dist/utils/terminal-launch.test.d.ts.map +1 -0
  141. package/dist/utils/terminal-launch.test.js +117 -0
  142. package/dist/utils/terminal-launch.test.js.map +1 -0
  143. package/dist/utils/workspace-launch.d.ts.map +1 -1
  144. package/dist/utils/workspace-launch.js +3 -1
  145. package/dist/utils/workspace-launch.js.map +1 -1
  146. package/package.json +5 -3
  147. package/dist/gui/assets/AgentsPage-B_3Vqn2y.js +0 -3
  148. package/dist/gui/assets/DashboardPage-W7KE2gn1.js +0 -1
  149. package/dist/gui/assets/ProjectsPage-CrZhKj85.js +0 -1
  150. package/dist/gui/assets/ScaffoldRepoInline-BSpQbVe7.js +0 -1
  151. package/dist/gui/assets/Separator-Chx6O62Y.js +0 -1
  152. package/dist/gui/assets/SettingsPage-Zvq-P6Hj.js +0 -1
  153. package/dist/gui/assets/SkillsPage-DR-Y5ZM1.js +0 -6
  154. package/dist/gui/assets/StartWorkPage-DO6t_zFb.js +0 -1
  155. package/dist/gui/assets/WorkspaceLauncher-CDtnv1IK.js +0 -1
  156. package/dist/gui/assets/WorkspacesPage-B9QOLnq_.js +0 -6
  157. package/dist/gui/assets/card-82INhRRE.js +0 -1
  158. package/dist/gui/assets/chevron-down-DDmhvRLr.js +0 -1
  159. package/dist/gui/assets/index-ofoYKpCG.js +0 -52
  160. package/dist/gui/assets/index-w-iQhEQv.css +0 -2
  161. package/dist/gui/assets/search-ChVovkOL.js +0 -1
  162. package/dist/gui/assets/skeleton-CCPbfn1L.js +0 -1
  163. package/dist/gui/assets/spinner-BCEaGbib.js +0 -1
  164. package/dist/gui/assets/textarea-D3-elOAa.js +0 -1
  165. package/dist/gui/assets/useAriaLabelledBy-DmwmZIqb.js +0 -1
  166. package/dist/gui/assets/useCompositeListItem-CZnlZ-eF.js +0 -1
  167. package/dist/gui/assets/useValueChanged-BfI2s7NX.js +0 -1
  168. package/dist/gui/assets/zap-DhYfYRlq.js +0 -1
package/dist/server.js CHANGED
@@ -17,19 +17,22 @@ import * as os from 'node:os';
17
17
  import { pipeline } from 'node:stream/promises';
18
18
  import { spawn } from 'node:child_process';
19
19
  import { loadConfig, saveConfig, getConfigDir } from './core/config.js';
20
+ import { configPatchSchema } from './core/config-schema.js';
20
21
  import { listStorageProviders } from './core/adapters/registry.js';
21
22
  import { scanForRepos } from './core/scanner.js';
22
- import { createNewRepo } from './core/new-repo.js';
23
+ import { createNewRepo, isValidProjectName } from './core/new-repo.js';
23
24
  import { loadProjects, createProject, updateProject, removeProject, slugifyProjectName } from './core/projects.js';
24
25
  import { getSessionCwd, isInPlace, resolveFeatureRepoPath } from './utils/feature.js';
25
26
  import { listBranches } from './utils/git.js';
26
- import { createWorkspace, listWorkspaces, loadFeatureConfig, loadWorkspaceManifest, deleteWorkspace, addRepoToWorkspace } from './core/workspace.js';
27
+ import { createWorkspace, listWorkspaces, loadFeatureConfig, loadWorkspaceManifest, deleteWorkspace, addRepoToWorkspace, isolateWorkspaceRepo } from './core/workspace.js';
27
28
  import { loadWorkspaceState } from './core/workspace-state.js';
28
29
  import { analyzeAllRepos } from './analyzers/index.js';
29
30
  import { generateContextFiles } from './generators/index.js';
30
31
  import { detectAIAssistants } from './utils/detect-ai.js';
31
32
  import { detectEditors } from './utils/detect-editors.js';
32
33
  import { buildWorkspaceLaunchPrompt, detectWorkspaceLaunchTargets, launchTargetIdForEditorCommand, launchWorkspaceTarget, } from './utils/workspace-launch.js';
34
+ import { isBinaryOnPath, launchWorkspaceTerminal, SUPPORTED_ASSISTANTS } from './utils/terminal-launch.js';
35
+ import { openInEditor } from './utils/open-editor.js';
33
36
  import { canOpenCodexSessionInWorkspace, canTransferClaudeSessionInWorkspace, findSessions, getSessionTranscript, } from './utils/session-finder.js';
34
37
  import { ProviderRegistry } from './agent/adapters.js';
35
38
  import { isValidSessionUuid } from './agent/session.js';
@@ -291,7 +294,15 @@ const DESKTOP_HANDOFF_SCAN_LIMIT = 20;
291
294
  * Claude's documented `/desktop` transfer is narrower than its URI handler:
292
295
  * it requires macOS or x64 Windows, a subscription login, and a usable CLI.
293
296
  */
294
- export function canOfferClaudeDesktopTransfer(platform = process.platform, architecture = process.arch, env = process.env, isCliConfigured = () => ProviderRegistry.getProvider('claude-cli')?.isConfigured() === true) {
297
+ export function canOfferClaudeDesktopTransfer(platform = process.platform, architecture = process.arch, env = process.env, isCliConfigured = () => {
298
+ try {
299
+ const provider = ProviderRegistry.getProvider('claude-cli');
300
+ if (provider?.isConfigured())
301
+ return true;
302
+ }
303
+ catch { }
304
+ return isBinaryOnPath('claude');
305
+ }) {
295
306
  const supportedPlatform = platform === 'darwin' || (platform === 'win32' && architecture === 'x64');
296
307
  const usesUnsupportedAuth = Boolean(env.ANTHROPIC_API_KEY
297
308
  || env.ANTHROPIC_AUTH_TOKEN
@@ -425,6 +436,33 @@ app.use('/api/*', cors({
425
436
  }
426
437
  },
427
438
  }));
439
+ // --- host guard: DNS rebinding protection ---
440
+ function isAllowedHost(hostHeader) {
441
+ if (!hostHeader)
442
+ return true; // Internal Hono programmatic invocations / test requests
443
+ const hostname = hostHeader.startsWith('[')
444
+ ? hostHeader.slice(1, hostHeader.indexOf(']'))
445
+ : hostHeader.split(':')[0]?.toLowerCase() ?? '';
446
+ return ['localhost', '127.0.0.1', '::1'].includes(hostname);
447
+ }
448
+ app.use('*', async (c, next) => {
449
+ const host = c.req.header('host');
450
+ if (!isAllowedHost(host)) {
451
+ return c.text('Forbidden: invalid Host header', 403);
452
+ }
453
+ await next();
454
+ });
455
+ // Enforce trusted local origin on all mutating HTTP methods across /api/* to defend against
456
+ // cross-site request forgery and browser form posts from untrusted web pages.
457
+ app.use('/api/*', async (c, next) => {
458
+ if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(c.req.method)) {
459
+ const origin = c.req.header('origin');
460
+ if (origin && !hasTrustedLocalOrigin(origin)) {
461
+ return c.json({ error: 'Forbidden cross-origin request.' }, 403);
462
+ }
463
+ }
464
+ await next();
465
+ });
428
466
  // ─── API Endpoints ────────────────────────────────────────────────────────
429
467
  // Status checks may execute a trusted local CLI from PATH. Keep that work
430
468
  // behind a non-simple, same-machine POST so a remote page cannot trigger it
@@ -479,9 +517,15 @@ app.get('/api/adapters', async (c) => {
479
517
  // 2. Save configuration
480
518
  app.post('/api/config', async (c) => {
481
519
  try {
482
- const newConfig = await c.req.json();
483
- await saveConfig(newConfig);
484
- return c.json({ success: true, config: newConfig });
520
+ const body = await c.req.json().catch(() => null);
521
+ const parsed = configPatchSchema.safeParse(body);
522
+ if (!parsed.success) {
523
+ return c.json({ error: 'Invalid config payload', issues: parsed.error.flatten() }, 400);
524
+ }
525
+ const current = await loadConfig();
526
+ const next = { ...current, ...parsed.data };
527
+ await saveConfig(next);
528
+ return c.json({ success: true, config: next });
485
529
  }
486
530
  catch (error) {
487
531
  return errorResponse(c, error);
@@ -596,11 +640,16 @@ app.delete('/api/projects/:id', async (c) => {
596
640
  return errorResponse(c, error);
597
641
  }
598
642
  });
599
- // 4. List existing workspaces
643
+ // 4. List existing workspaces (ordered by newest creation date first)
600
644
  app.get('/api/workspaces', async (c) => {
601
645
  try {
602
646
  const config = await loadConfig();
603
647
  const workspaces = await listWorkspaces(config.workspacesDir);
648
+ workspaces.sort((a, b) => {
649
+ const aTime = new Date(a.createdAt || 0).getTime();
650
+ const bTime = new Date(b.createdAt || 0).getTime();
651
+ return bTime - aTime;
652
+ });
604
653
  return c.json(workspaces);
605
654
  }
606
655
  catch (error) {
@@ -857,6 +906,19 @@ app.post('/api/workspace', async (c) => {
857
906
  return c.json({ error: 'Missing "branchName" in request body' }, 400);
858
907
  }
859
908
  const config = await loadConfig();
909
+ // Validate repo names and paths synchronously before starting the job.
910
+ for (const r of body.repos || []) {
911
+ if (inPlace) {
912
+ if (config.devDir && r.path) {
913
+ assertWithin(config.devDir, path.resolve(r.path));
914
+ }
915
+ }
916
+ else {
917
+ if (r.name && !isValidProjectName(r.name)) {
918
+ return c.json({ error: `Invalid repository name: ${JSON.stringify(r.name)}` }, 400);
919
+ }
920
+ }
921
+ }
860
922
  // The job id doubles as the workspace directory name.
861
923
  const jobId = inPlace ? slugifyProjectName(body.name) : body.branchName;
862
924
  if (!jobId) {
@@ -984,6 +1046,9 @@ app.post('/api/workspace/:id/repo', async (c) => {
984
1046
  const id = decodeURIComponent(c.req.param('id'));
985
1047
  const { repoPath } = await c.req.json();
986
1048
  const config = await loadConfig();
1049
+ if (config.devDir) {
1050
+ assertWithin(config.devDir, path.resolve(repoPath));
1051
+ }
987
1052
  const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
988
1053
  await addRepoToWorkspace(workspacePath, repoPath);
989
1054
  return c.json({ success: true });
@@ -992,6 +1057,29 @@ app.post('/api/workspace/:id/repo', async (c) => {
992
1057
  return errorResponse(c, error);
993
1058
  }
994
1059
  });
1060
+ // 7.7b. Isolate repo in in-place workspace
1061
+ app.post('/api/workspace/:id/isolate', async (c) => {
1062
+ try {
1063
+ const id = decodeURIComponent(c.req.param('id'));
1064
+ const body = await c.req.json().catch(() => ({}));
1065
+ const repo = typeof body.repo === 'string' ? body.repo.trim() : '';
1066
+ if (!repo) {
1067
+ return c.json({ error: 'Missing "repo" parameter in request body' }, 400);
1068
+ }
1069
+ const branchName = typeof body.branchName === 'string' && body.branchName.trim() ? body.branchName.trim() : undefined;
1070
+ const baseBranch = typeof body.baseBranch === 'string' && body.baseBranch.trim() ? body.baseBranch.trim() : undefined;
1071
+ const config = await loadConfig();
1072
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
1073
+ const result = await isolateWorkspaceRepo(workspacePath, repo, {
1074
+ branchName,
1075
+ baseBranch,
1076
+ });
1077
+ return c.json({ success: true, ...result });
1078
+ }
1079
+ catch (error) {
1080
+ return errorResponse(c, error);
1081
+ }
1082
+ });
995
1083
  // 7.8. Suggest workflow strategy based on feature description & selected repos
996
1084
  app.post('/api/workspace/suggest-workflow', async (c) => {
997
1085
  try {
@@ -1018,6 +1106,10 @@ app.get('/api/workspace-launch-targets', async (c) => {
1018
1106
  });
1019
1107
  // 8a. Open a real NexusFlow workspace in a selected desktop app or editor.
1020
1108
  app.post('/api/workspace/:id/launch', async (c) => {
1109
+ const origin = c.req.header('origin');
1110
+ if (origin && !hasTrustedLocalOrigin(origin)) {
1111
+ return c.json({ error: 'Forbidden cross-origin request.' }, 403);
1112
+ }
1021
1113
  try {
1022
1114
  const id = decodeURIComponent(c.req.param('id'));
1023
1115
  const { targetId, action = 'new', sessionId } = await c.req.json().catch(() => ({}));
@@ -1079,12 +1171,74 @@ app.post('/api/workspace/:id/launch', async (c) => {
1079
1171
  return errorResponse(c, error);
1080
1172
  }
1081
1173
  });
1082
- // 8b. Legacy editor route retained for older GUI clients using recognized
1174
+ // 8b. Launch an external interactive terminal for a workspace or AI session.
1175
+ app.post('/api/workspace/:id/terminal', async (c) => {
1176
+ const origin = c.req.header('origin');
1177
+ if (origin && !hasTrustedLocalOrigin(origin)) {
1178
+ return c.json({ error: 'Forbidden cross-origin request.' }, 403);
1179
+ }
1180
+ try {
1181
+ const id = decodeURIComponent(c.req.param('id'));
1182
+ const { command, assistant, sessionId, title } = await c.req.json().catch(() => ({}));
1183
+ if (assistant !== undefined) {
1184
+ if (typeof assistant !== 'string' || !SUPPORTED_ASSISTANTS.has(assistant.trim().toLowerCase())) {
1185
+ return c.json({ error: 'Invalid or unsupported assistant parameter.' }, 400);
1186
+ }
1187
+ }
1188
+ if (sessionId !== undefined) {
1189
+ if (typeof sessionId !== 'string' || !isValidSessionUuid(sessionId)) {
1190
+ return c.json({ error: 'Invalid session UUID format.' }, 400);
1191
+ }
1192
+ }
1193
+ if (command !== undefined && (typeof command !== 'string' || !command.trim())) {
1194
+ return c.json({ error: 'Command must be a non-empty string.' }, 400);
1195
+ }
1196
+ if (title !== undefined && typeof title !== 'string') {
1197
+ return c.json({ error: 'Title must be a string.' }, 400);
1198
+ }
1199
+ const config = await loadConfig();
1200
+ const requestedWorkspacePath = resolveWorkspacePath(config.workspacesDir, id);
1201
+ let workspacePath;
1202
+ try {
1203
+ workspacePath = await resolveExactLaunchWorkspace(config.workspacesDir, requestedWorkspacePath);
1204
+ }
1205
+ catch (error) {
1206
+ if (error instanceof PathAccessError)
1207
+ throw error;
1208
+ return c.json({ error: 'Workspace configuration not found.' }, 404);
1209
+ }
1210
+ if (!workspacePath) {
1211
+ return c.json({ error: 'Workspace configuration not found.' }, 404);
1212
+ }
1213
+ const res = await launchWorkspaceTerminal(workspacePath, {
1214
+ command: typeof command === 'string' ? command : undefined,
1215
+ assistant: typeof assistant === 'string' ? assistant : undefined,
1216
+ sessionId: typeof sessionId === 'string' ? sessionId : undefined,
1217
+ title: typeof title === 'string' ? title : undefined,
1218
+ });
1219
+ return c.json({ success: true, command: res.command });
1220
+ }
1221
+ catch (error) {
1222
+ return errorResponse(c, error);
1223
+ }
1224
+ });
1225
+ // 8c. Legacy editor route retained for older GUI clients using recognized
1083
1226
  // graphical editors. Interactive terminal editors are intentionally rejected:
1084
1227
  // a detached HTTP request cannot safely provide their required TTY.
1085
1228
  app.post('/api/open-editor', async (c) => {
1229
+ const origin = c.req.header('origin');
1230
+ if (origin && !hasTrustedLocalOrigin(origin)) {
1231
+ return c.json({ error: 'Forbidden cross-origin request.' }, 403);
1232
+ }
1086
1233
  try {
1087
- const { workspacePath, command } = await c.req.json();
1234
+ const body = await c.req.json().catch(() => ({}));
1235
+ const { workspacePath, command } = body;
1236
+ if (typeof workspacePath !== 'string' || !workspacePath.trim()) {
1237
+ return c.json({ error: 'Workspace path does not exist' }, 400);
1238
+ }
1239
+ if (typeof command !== 'string' || !command.trim()) {
1240
+ return c.json({ error: 'Forbidden editor command' }, 400);
1241
+ }
1088
1242
  const targetId = launchTargetIdForEditorCommand(command);
1089
1243
  if (!ALLOWED_EDITORS.has(command) || !targetId) {
1090
1244
  return c.json({ error: 'Forbidden editor command' }, 400);
@@ -1329,10 +1483,10 @@ app.get('/api/workspace/:id/changes', async (c) => {
1329
1483
  });
1330
1484
  const numstatLines = numstatRaw.split('\n').filter(Boolean);
1331
1485
  for (const numLine of numstatLines) {
1332
- const parts = numLine.trim().split(/\s+/);
1333
- if (parts.length >= 3) {
1334
- const [add, del, file] = parts;
1335
- numstatMap.set(file, {
1486
+ const match = numLine.trim().match(/^(\d+|-)\s+(\d+|-)\s+(.*)$/);
1487
+ if (match) {
1488
+ const [, add, del, file] = match;
1489
+ numstatMap.set(file.trim(), {
1336
1490
  additions: add === '-' ? 0 : parseInt(add, 10) || 0,
1337
1491
  deletions: del === '-' ? 0 : parseInt(del, 10) || 0,
1338
1492
  });
@@ -1567,6 +1721,10 @@ app.post('/api/workspace/:id/commit', async (c) => {
1567
1721
  });
1568
1722
  // 14. Resume session in workspace (copies CLI resume command and opens editor)
1569
1723
  app.post('/api/workspace/:id/resume', async (c) => {
1724
+ const origin = c.req.header('origin');
1725
+ if (origin && !hasTrustedLocalOrigin(origin)) {
1726
+ return c.json({ error: 'Forbidden cross-origin request.' }, 403);
1727
+ }
1570
1728
  try {
1571
1729
  const id = decodeURIComponent(c.req.param('id'));
1572
1730
  const config = await loadConfig();
@@ -1577,8 +1735,14 @@ app.post('/api/workspace/:id/resume', async (c) => {
1577
1735
  return c.json({ error: 'Workspace configuration not found.' }, 404);
1578
1736
  }
1579
1737
  const body = await c.req.json().catch(() => ({}));
1580
- const targetSessionId = body.sessionId;
1581
- const targetAssistant = body.assistant;
1738
+ const targetSessionId = typeof body.sessionId === 'string' && isValidSessionUuid(body.sessionId) ? body.sessionId : undefined;
1739
+ if (body.sessionId !== undefined && targetSessionId === undefined) {
1740
+ return c.json({ error: 'Invalid session UUID format.' }, 400);
1741
+ }
1742
+ if (body.command !== undefined && (typeof body.command !== 'string' || !ALLOWED_EDITORS.has(body.command))) {
1743
+ return c.json({ error: 'Forbidden editor command' }, 400);
1744
+ }
1745
+ const targetAssistant = typeof body.assistant === 'string' ? body.assistant : undefined;
1582
1746
  // Find the session to resume
1583
1747
  let resumeCommand = '';
1584
1748
  let selectedAssistant = targetAssistant || feature.assistants[0] || 'antigravity';
@@ -1628,17 +1792,12 @@ app.post('/api/workspace/:id/resume', async (c) => {
1628
1792
  if (!ALLOWED_EDITORS.has(body.command)) {
1629
1793
  return c.json({ error: 'Forbidden editor command' }, 400);
1630
1794
  }
1631
- const isWin = process.platform === 'win32';
1632
- const child = execa(body.command, [workspacePath], {
1633
- detached: true,
1634
- stdio: 'ignore',
1635
- shell: isWin,
1636
- cleanup: false,
1637
- });
1638
- child.unref();
1639
- child.catch((err) => {
1795
+ try {
1796
+ await openInEditor(body.command, workspacePath);
1797
+ }
1798
+ catch (err) {
1640
1799
  console.error(`Failed to launch editor ${body.command} for path ${workspacePath}:`, err);
1641
- });
1800
+ }
1642
1801
  }
1643
1802
  // Where the resume command should be run: the workspace dir, or the repo
1644
1803
  // root for single-repo in-place features.
@@ -1726,8 +1885,11 @@ app.get('/api/session/:assistant/:sessionId/transcript', async (c) => {
1726
1885
  const assistant = c.req.param('assistant');
1727
1886
  const sessionId = c.req.param('sessionId');
1728
1887
  try {
1729
- if (assistant === 'codex' && !isValidSessionUuid(sessionId)) {
1730
- return c.json({ error: 'Invalid Codex session id.' }, 400);
1888
+ if (!SUPPORTED_ASSISTANTS.has(assistant.trim().toLowerCase())) {
1889
+ return c.json({ error: `Unsupported assistant: "${assistant}".` }, 400);
1890
+ }
1891
+ if (!isValidSessionUuid(sessionId)) {
1892
+ return c.json({ error: 'Invalid session UUID format.' }, 400);
1731
1893
  }
1732
1894
  const messages = await getSessionTranscript(assistant, sessionId);
1733
1895
  return c.json({ messages });