specrails-desktop 2.11.7 → 2.11.8

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 (73) hide show
  1. package/cli/dist/specrails-desktop.js +41 -10
  2. package/client/dist/assets/{ActivityFeedPage-CjaTeSEy.js → ActivityFeedPage-D12zOZGQ.js} +1 -1
  3. package/client/dist/assets/AgentsPage-CNyxiSCU.js +86 -0
  4. package/client/dist/assets/AnalyticsPage-DxuypQg1.js +1 -0
  5. package/client/dist/assets/{BarChart-26Ov3eDx.js → BarChart-CWDs7Ffl.js} +2 -2
  6. package/client/dist/assets/CodePage-AN1T4w99.js +2 -0
  7. package/client/dist/assets/{DesktopAnalyticsPage-BLioEDw-.js → DesktopAnalyticsPage-CPdWVhzT.js} +1 -1
  8. package/client/dist/assets/{DocsDialog-w7AelI_u.js → DocsDialog-C1MnXtzC.js} +2 -2
  9. package/client/dist/assets/DocsPage-BSqJ-G6V.js +11 -0
  10. package/client/dist/assets/{ExportDropdown-DlchZhDG.js → ExportDropdown-DpI605_7.js} +1 -1
  11. package/client/dist/assets/IntegrationsPage-KaZLo4KU.js +3 -0
  12. package/client/dist/assets/JobDetailPage-QN9NOcrX.js +16 -0
  13. package/client/dist/assets/JobsPage-C-fLmFs9.js +1 -0
  14. package/client/dist/assets/{dist-js-tJGlm77v.js → dist-js-EZlD2frS.js} +1 -1
  15. package/client/dist/assets/{dist-js-DaFKJPgY.js → dist-js-jOe3QgTk.js} +1 -1
  16. package/client/dist/assets/{index-Clyz7rFj.js → index-wK8zASrE.js} +46 -46
  17. package/client/dist/assets/{lib-B7C231WR.js → lib-CiLTp9yY.js} +1 -1
  18. package/client/dist/index.html +1 -1
  19. package/package.json +1 -1
  20. package/server/dist/agent-refine-manager.js +54 -11
  21. package/server/dist/artifact-registry.js +83 -6
  22. package/server/dist/attachment-manager.js +16 -0
  23. package/server/dist/browser-capture-manager.js +33 -4
  24. package/server/dist/browser-network.js +35 -1
  25. package/server/dist/browser-playwright.js +112 -5
  26. package/server/dist/chat-manager.js +21 -0
  27. package/server/dist/code-explorer-router.js +22 -1
  28. package/server/dist/contract-refine-runner.js +87 -5
  29. package/server/dist/desktop-db.js +9 -1
  30. package/server/dist/desktop-router.js +111 -6
  31. package/server/dist/explore-stdin-session.js +43 -3
  32. package/server/dist/file-summary-manager.js +92 -7
  33. package/server/dist/framework-manager.js +56 -12
  34. package/server/dist/framework-migration.js +14 -2
  35. package/server/dist/index.js +28 -4
  36. package/server/dist/interactive-job-session.js +74 -18
  37. package/server/dist/jira/jira-adf.js +13 -2
  38. package/server/dist/jira/jira-backlog-config.js +4 -1
  39. package/server/dist/jira/jira-client.js +75 -9
  40. package/server/dist/jira/jira-sync-manager.js +34 -6
  41. package/server/dist/mobile/mobile-devices.js +78 -0
  42. package/server/dist/mobile/mobile-gateway.js +8 -1
  43. package/server/dist/mobile/mobile-redact.js +32 -2
  44. package/server/dist/mobile/mobile-router.js +25 -0
  45. package/server/dist/mobile/mobile-signal-reconnect.js +13 -3
  46. package/server/dist/mobile/mobile-webrtc-peer.js +28 -7
  47. package/server/dist/mobile/mobile-webrtc.js +13 -1
  48. package/server/dist/mobile/mobile-ws.js +18 -4
  49. package/server/dist/path-resolver.js +19 -2
  50. package/server/dist/plugin-manager.js +92 -31
  51. package/server/dist/proposal-manager.js +59 -8
  52. package/server/dist/queue-manager.js +179 -30
  53. package/server/dist/schemas/file-summary.v1.json +62 -0
  54. package/server/dist/smash-runner.js +49 -5
  55. package/server/dist/spec-launcher-manager.js +65 -8
  56. package/server/dist/ssrf-guard.js +142 -0
  57. package/server/dist/telemetry-receiver.js +28 -17
  58. package/server/dist/terminal-manager.js +4 -1
  59. package/server/dist/ticket-store.js +79 -10
  60. package/server/dist/user-mcp-config.js +5 -0
  61. package/server/dist/util/cli-prompt.js +21 -6
  62. package/server/dist/util/win-spawn.js +73 -10
  63. package/server/dist/webhook-manager.js +11 -0
  64. package/server/dist/workspace-manager.js +7 -3
  65. package/client/dist/assets/AgentsPage-BWzkbBYn.js +0 -86
  66. package/client/dist/assets/AnalyticsPage-CAyfZ8Yi.js +0 -1
  67. package/client/dist/assets/CodePage-V82YTNl3.js +0 -2
  68. package/client/dist/assets/DocsPage-vZcAHvLq.js +0 -11
  69. package/client/dist/assets/IntegrationsPage-CCyZ11D7.js +0 -3
  70. package/client/dist/assets/JobDetailPage-CaT9c75p.js +0 -16
  71. package/client/dist/assets/JobsPage-CoqWNWN1.js +0 -1
  72. package/client/dist/assets/useProjectCache-BJDa0Nwo.js +0 -1
  73. /package/client/dist/assets/{format-command-2VNoNnMv.js → format-command-CZsy-vHq.js} +0 -0
@@ -8,16 +8,110 @@
8
8
  // Screencast uses CDP (`Page.startScreencast`) since Playwright exposes no
9
9
  // screencast API; input + screenshot + DOM use Playwright's high-level page API.
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.isNavigableUrl = isNavigableUrl;
12
+ exports.normalizeUrl = normalizeUrl;
13
+ exports.chromiumLaunchArgs = chromiumLaunchArgs;
11
14
  exports.createPlaywrightLauncher = createPlaywrightLauncher;
12
15
  const chromium_resolver_1 = require("./chromium-resolver");
13
16
  const browser_network_1 = require("./browser-network");
17
+ /**
18
+ * Centralized SSRF / scheme guard for EVERY navigation path (WS `navigate`, REST
19
+ * `navigate`, REST create `initialUrl`). Returns true only for an `http`/`https`
20
+ * URL whose host is NOT a private / loopback / link-local address. `about:blank`
21
+ * is always allowed (the harmless empty page). Everything else — `file://`,
22
+ * `data:`, `chrome://`, link-local `169.254.0.0/16`, loopback `127.0.0.0/8` /
23
+ * `::1`, RFC1918 `10/8` / `172.16/12` / `192.168/16` — is rejected.
24
+ *
25
+ * This is intentionally conservative: a host that fails to parse, or a literal
26
+ * IP in a blocked range, is rejected. DNS-name hosts that *resolve* to a private
27
+ * IP are out of scope here (we don't resolve at navigate time); the loopback
28
+ * server bind + token-gated upgrade are the primary control — this is the
29
+ * defense-in-depth scheme + literal-IP block called out in BUG-BROWSER-01.
30
+ */
31
+ function isNavigableUrl(raw) {
32
+ const trimmed = (raw ?? '').trim();
33
+ if (!trimmed)
34
+ return false;
35
+ if (trimmed === 'about:blank')
36
+ return true;
37
+ let u;
38
+ try {
39
+ u = new URL(trimmed);
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ if (u.protocol !== 'http:' && u.protocol !== 'https:')
45
+ return false;
46
+ const host = u.hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
47
+ if (isBlockedHost(host))
48
+ return false;
49
+ return true;
50
+ }
51
+ /** True when a literal host string is a loopback / private / link-local address. */
52
+ function isBlockedHost(host) {
53
+ if (!host)
54
+ return true;
55
+ if (host === 'localhost' || host.endsWith('.localhost'))
56
+ return true;
57
+ // IPv6 loopback + IPv4-mapped/embedded forms.
58
+ if (host === '::1' || host === '::' || host === '0:0:0:0:0:0:0:1')
59
+ return true;
60
+ // IPv6 unique-local (fc00::/7) and link-local (fe80::/10).
61
+ if (/^f[cd][0-9a-f]{2}:/i.test(host))
62
+ return true;
63
+ if (/^fe[89ab][0-9a-f]:/i.test(host))
64
+ return true;
65
+ // IPv4-mapped IPv6 (::ffff:a.b.c.d) — fall through to the IPv4 check on the tail.
66
+ // The WHATWG URL parser also normalises the dotted tail into hex
67
+ // (`::ffff:7f00:1`), so decode that hextet form back to dotted quads too.
68
+ const mappedDotted = host.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
69
+ const mappedHex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
70
+ let ipv4 = host;
71
+ if (mappedDotted) {
72
+ ipv4 = mappedDotted[1];
73
+ }
74
+ else if (mappedHex) {
75
+ const hi = parseInt(mappedHex[1], 16);
76
+ const lo = parseInt(mappedHex[2], 16);
77
+ ipv4 = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
78
+ }
79
+ const m = ipv4.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
80
+ if (m) {
81
+ const [a, b] = [Number(m[1]), Number(m[2])];
82
+ if (a === 127)
83
+ return true; // 127.0.0.0/8 loopback
84
+ if (a === 10)
85
+ return true; // 10.0.0.0/8
86
+ if (a === 169 && b === 254)
87
+ return true; // 169.254.0.0/16 link-local
88
+ if (a === 172 && b >= 16 && b <= 31)
89
+ return true; // 172.16.0.0/12
90
+ if (a === 192 && b === 168)
91
+ return true; // 192.168.0.0/16
92
+ if (a === 0)
93
+ return true; // 0.0.0.0/8 "this host"
94
+ }
95
+ return false;
96
+ }
97
+ /**
98
+ * Normalize a user-supplied address into a safe, navigable URL. A bare host
99
+ * (`example.com`) is upgraded to `https://`. The result is then validated by
100
+ * `isNavigableUrl`; anything that fails the SSRF/scheme guard collapses to
101
+ * `about:blank` so a blocked navigation never reaches Chromium.
102
+ */
14
103
  function normalizeUrl(raw) {
15
- const trimmed = raw.trim();
104
+ const trimmed = (raw ?? '').trim();
16
105
  if (!trimmed)
17
106
  return 'about:blank';
18
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) || trimmed.startsWith('about:'))
19
- return trimmed;
20
- return `https://${trimmed}`;
107
+ let candidate;
108
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) || trimmed.startsWith('about:')) {
109
+ candidate = trimmed;
110
+ }
111
+ else {
112
+ candidate = `https://${trimmed}`;
113
+ }
114
+ return isNavigableUrl(candidate) ? candidate : 'about:blank';
21
115
  }
22
116
  class PlaywrightPageHandle {
23
117
  page;
@@ -863,6 +957,19 @@ class PlaywrightContextHandle {
863
957
  catch { /* ignore */ }
864
958
  }
865
959
  }
960
+ /**
961
+ * Chromium launch args. The renderer SANDBOX is kept ENABLED on macOS/Windows
962
+ * (it works there and is the primary mitigation for navigating untrusted pages —
963
+ * BUG-BROWSER-04). `--no-sandbox` is only added on Linux, where the namespace
964
+ * sandbox frequently fails to initialise inside a packaged/headless app and would
965
+ * otherwise refuse to launch. We never disable the sandbox where it functions.
966
+ */
967
+ function chromiumLaunchArgs() {
968
+ const args = ['--disable-dev-shm-usage', '--disable-gpu'];
969
+ if (process.platform === 'linux')
970
+ args.unshift('--no-sandbox');
971
+ return args;
972
+ }
866
973
  /**
867
974
  * Build the real Playwright-backed launcher. Lazy-imports playwright so the
868
975
  * dependency is only loaded when the feature is actually used.
@@ -881,7 +988,7 @@ function createPlaywrightLauncher() {
881
988
  headless: true,
882
989
  executablePath,
883
990
  viewport: opts.viewport,
884
- args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
991
+ args: chromiumLaunchArgs(),
885
992
  });
886
993
  return new PlaywrightContextHandle(context);
887
994
  };
@@ -65,6 +65,11 @@ class ChatManager {
65
65
  /** Persistent-stdin Explore transport (big bet #3, flag-gated default OFF).
66
66
  * Holds long-lived claude children that survive between turns. */
67
67
  _stdinSessions = new explore_stdin_session_1.ExploreStdinSessions();
68
+ /** Fire-and-forget auxiliary CLI children (e.g. `_autoTitle`) that are NOT
69
+ * keyed by conversation in `_activeProcesses`. Tracked so `shutdown()` can
70
+ * tree-kill any in-flight one instead of orphaning it (BUG-CHAT-02). Each
71
+ * entry self-removes on its own `'close'`. */
72
+ _auxProcesses = new Set();
68
73
  _cwd;
69
74
  _projectName;
70
75
  _adapter;
@@ -1243,6 +1248,18 @@ class ChatManager {
1243
1248
  }
1244
1249
  // Persistent-stdin children outlive individual turns — tear them down too.
1245
1250
  this._stdinSessions.killAll();
1251
+ // Fire-and-forget auxiliary children (auto-title) are not keyed by
1252
+ // conversation; tree-kill any in-flight one so it isn't orphaned on
1253
+ // shutdown / project removal (BUG-CHAT-02).
1254
+ for (const child of this._auxProcesses) {
1255
+ if (child?.pid) {
1256
+ try {
1257
+ (0, tree_kill_1.default)(child.pid, 'SIGTERM');
1258
+ }
1259
+ catch { /* best-effort */ }
1260
+ }
1261
+ }
1262
+ this._auxProcesses.clear();
1246
1263
  for (const id of this._exploreLifecycle.keys()) {
1247
1264
  this._clearIdleTimer(id);
1248
1265
  }
@@ -1273,6 +1290,9 @@ class ChatManager {
1273
1290
  stdio: ['ignore', 'pipe', 'pipe'],
1274
1291
  cwd: this._cwd,
1275
1292
  });
1293
+ // Track this fire-and-forget child so shutdown() can tree-kill it
1294
+ // instead of orphaning it (BUG-CHAT-02). Self-removed on 'close'.
1295
+ this._auxProcesses.add(child);
1276
1296
  let titleText = '';
1277
1297
  const reader = (0, readline_1.createInterface)({ input: child.stdout, crlfDelay: Infinity });
1278
1298
  reader.on('line', (line) => {
@@ -1286,6 +1306,7 @@ class ChatManager {
1286
1306
  }
1287
1307
  });
1288
1308
  child.on('close', (code) => {
1309
+ this._auxProcesses.delete(child);
1289
1310
  if (code === 0 && titleText) {
1290
1311
  (0, db_1.updateConversation)(this._db, conversationId, { title: titleText });
1291
1312
  this._broadcast({
@@ -276,11 +276,19 @@ function listAllEntries(projectPath) {
276
276
  function listTouchedEntries(projectPath, rowsByPath) {
277
277
  const seen = new Set();
278
278
  const out = [];
279
+ // Drop gitignored files too — mirroring listAllEntries — so a gitignored
280
+ // AI-touched file (e.g. config.local.json, a custom build dir) whose name is
281
+ // not deny-listed never surfaces its path / ticket-attribution / mtime. One
282
+ // batched git check-ignore over the not-already-denied touched files.
283
+ const candidateFiles = [...rowsByPath.keys()].filter((p) => !isDeniedRelPath(p));
284
+ const ignored = gitIgnoredSet(projectPath, candidateFiles);
279
285
  for (const filePath of rowsByPath.keys()) {
280
286
  // Keep touched-by-ai consistent with the `all` tree (and never surface
281
287
  // secrets like .env that an AI job happened to touch).
282
288
  if (isDeniedRelPath(filePath))
283
289
  continue;
290
+ if (ignored.has(filePath))
291
+ continue;
284
292
  const parts = filePath.split('/').filter(Boolean);
285
293
  for (let i = 1; i < parts.length; i += 1) {
286
294
  const dirRel = parts.slice(0, i).join('/');
@@ -796,7 +804,20 @@ function createCodeExplorerRouter(deps) {
796
804
  res.status(400).json({ error: 'path traversal not allowed' });
797
805
  return;
798
806
  }
799
- const diff = (0, file_provenance_1.getProvenanceDiff)(deps.db, deps.projectId, jobId, normalizeRel(relPath));
807
+ // Mirror every sibling content endpoint (/file, /summary, /provenance):
808
+ // a stored patch for an added file contains the FULL file contents, so a
809
+ // denied/gitignored secret (.env, *.pem, id_rsa, gitignored creds) that an
810
+ // AI job happened to touch must never be served verbatim through /diff.
811
+ if (isDeniedRelPath(relPath)) {
812
+ res.status(403).json({ error: 'path is excluded by the code-explorer deny-list' });
813
+ return;
814
+ }
815
+ const rel = normalizeRel(relPath);
816
+ if (isGitIgnored(deps.projectPath, rel)) {
817
+ res.status(403).json({ error: 'path is gitignored' });
818
+ return;
819
+ }
820
+ const diff = (0, file_provenance_1.getProvenanceDiff)(deps.db, deps.projectId, jobId, rel);
800
821
  if (!diff) {
801
822
  res.status(404).json({ error: 'diff not available' });
802
823
  return;
@@ -10,6 +10,9 @@
10
10
  *
11
11
  * See openspec/changes/explore-spec-contract-refine.
12
12
  */
13
+ var __importDefault = (this && this.__importDefault) || function (mod) {
14
+ return (mod && mod.__esModule) ? mod : { "default": mod };
15
+ };
13
16
  Object.defineProperty(exports, "__esModule", { value: true });
14
17
  exports.prepareContractRefineSpawn = prepareContractRefineSpawn;
15
18
  exports.readRefineChildOutput = readRefineChildOutput;
@@ -18,6 +21,7 @@ exports.runContractRefine = runContractRefine;
18
21
  exports.runContractRefineForQuick = runContractRefineForQuick;
19
22
  const node_crypto_1 = require("node:crypto");
20
23
  const node_readline_1 = require("node:readline");
24
+ const tree_kill_1 = __importDefault(require("tree-kill"));
21
25
  const cli_prompt_1 = require("./util/cli-prompt");
22
26
  const spawn_lifecycle_1 = require("./spawn-lifecycle");
23
27
  const registry_1 = require("./providers/registry");
@@ -39,6 +43,24 @@ function resolveContractTicketsPath(projectPath) {
39
43
  const exec = (0, workspace_resolution_1.resolveProjectExecution)({ path: projectPath });
40
44
  return exec.relocated ? exec.ticketsPath : (0, ticket_store_1.resolveTicketStoragePath)(projectPath);
41
45
  }
46
+ /**
47
+ * BUG-PARSER-04: a result event can carry `is_error: true` (with an exit code
48
+ * of 0) when the turn was truncated — most commonly `subtype: 'error_max_turns'`
49
+ * where the model never got to emit the contract block. Previously this fell
50
+ * through to the parser and surfaced as `malformed` (a misleading reason that
51
+ * implies the model produced bad output, when really the run was cut short).
52
+ * Detect the truncation/error markers and short-circuit to `model_error`.
53
+ */
54
+ function isResultErrorEvent(resultEvent) {
55
+ if (!resultEvent)
56
+ return false;
57
+ if (resultEvent.is_error === true)
58
+ return true;
59
+ const subtype = typeof resultEvent.subtype === 'string' ? resultEvent.subtype : '';
60
+ // `error_max_turns` is the canonical truncation subtype; treat any
61
+ // `error_*` subtype as a model-side error rather than malformed output.
62
+ return subtype === 'error_max_turns' || subtype.startsWith('error_');
63
+ }
42
64
  function normalizeClaudeCodeModel(model) {
43
65
  if (!model || typeof model !== 'string')
44
66
  return 'sonnet';
@@ -96,12 +118,32 @@ function prepareContractRefineSpawn(deps, conversation) {
96
118
  const args = buildRefineArgs(conversation.model ?? 'sonnet', systemPrompt, conversation.session_id ?? '');
97
119
  return { args, cwd, systemPrompt, env };
98
120
  }
121
+ /** Grace before SIGKILL-escalating a child that swallowed SIGTERM. */
122
+ const REFINE_SIGKILL_GRACE_MS = 2_000;
123
+ function escalateKill(pid, kill) {
124
+ if (typeof pid !== 'number')
125
+ return null;
126
+ try {
127
+ kill(pid, 'SIGTERM', () => { });
128
+ }
129
+ catch { /* already gone */ }
130
+ const escalation = setTimeout(() => {
131
+ try {
132
+ kill(pid, 'SIGKILL', () => { });
133
+ }
134
+ catch { /* already gone */ }
135
+ }, REFINE_SIGKILL_GRACE_MS);
136
+ // Don't let the escalation timer keep the event loop alive.
137
+ if (typeof escalation.unref === 'function')
138
+ escalation.unref();
139
+ return escalation;
140
+ }
99
141
  /**
100
142
  * Test-friendly inner runner: takes a child-like object and returns the parsed
101
143
  * outcome (text + result event + close code). Does NOT touch the DB or the
102
144
  * file system — the caller wires those.
103
145
  */
104
- function readRefineChildOutput(child, timeoutMs) {
146
+ function readRefineChildOutput(child, timeoutMs, kill = tree_kill_1.default) {
105
147
  return new Promise((resolve) => {
106
148
  let fullText = '';
107
149
  let resultEvent = null;
@@ -120,6 +162,7 @@ function readRefineChildOutput(child, timeoutMs) {
120
162
  stderrBuf = stderrBuf.slice(-8192);
121
163
  });
122
164
  }
165
+ let killEscalation = null;
123
166
  const reader = (0, node_readline_1.createInterface)({ input: child.stdout, crlfDelay: Infinity });
124
167
  reader.on('line', (line) => {
125
168
  let parsed = null;
@@ -146,10 +189,9 @@ function readRefineChildOutput(child, timeoutMs) {
146
189
  });
147
190
  const timer = setTimeout(() => {
148
191
  timedOut = true;
149
- try {
150
- child.kill('SIGTERM');
151
- }
152
- catch { /* best effort */ }
192
+ // BUG-PARSER-01: treeKill the whole subtree (SIGTERM) and SIGKILL-escalate
193
+ // after a grace window so a signal-swallowing CLI tree is force-killed.
194
+ killEscalation = escalateKill(child.pid, kill);
153
195
  if (!settled) {
154
196
  settled = true;
155
197
  resolve({ fullText, resultEvent, code: null, timedOut: true });
@@ -157,6 +199,12 @@ function readRefineChildOutput(child, timeoutMs) {
157
199
  }, timeoutMs);
158
200
  child.on('close', (code) => {
159
201
  clearTimeout(timer);
202
+ // The child exited (possibly because SIGTERM was honoured) — cancel the
203
+ // pending SIGKILL escalation.
204
+ if (killEscalation) {
205
+ clearTimeout(killEscalation);
206
+ killEscalation = null;
207
+ }
160
208
  if (settled)
161
209
  return;
162
210
  settled = true;
@@ -167,6 +215,10 @@ function readRefineChildOutput(child, timeoutMs) {
167
215
  });
168
216
  child.on('error', (err) => {
169
217
  clearTimeout(timer);
218
+ if (killEscalation) {
219
+ clearTimeout(killEscalation);
220
+ killEscalation = null;
221
+ }
170
222
  console.log(`[contract-refine-runner] child error: ${err.message}; stderr=${JSON.stringify(stderrBuf.slice(-2000))}`);
171
223
  if (settled)
172
224
  return;
@@ -344,6 +396,21 @@ async function runContractRefine(deps, conversationId, ticketId) {
344
396
  });
345
397
  return { ok: false, reason: result.resultEvent ? 'model_error' : 'crashed', ticketId, conversationId };
346
398
  }
399
+ // BUG-PARSER-04: exit-0 but the result event flags an error / max-turns
400
+ // truncation — classify as model_error, not malformed.
401
+ if (isResultErrorEvent(result.resultEvent)) {
402
+ const r = result.resultEvent;
403
+ console.log(`[contract-refine-runner] result error event subtype=${r.subtype ?? '-'} is_error=${r.is_error ?? '-'}`);
404
+ recordSafely(deps, conversationId, ticketId, conversation.model, startedAt, finishedAt, 'failed', result.resultEvent);
405
+ deps.broadcast({
406
+ type: 'explore.contract_refine_failed',
407
+ projectId: deps.projectId,
408
+ ticketId,
409
+ reason: 'model_error',
410
+ timestamp: finishedAt,
411
+ });
412
+ return { ok: false, reason: 'model_error', ticketId, conversationId };
413
+ }
347
414
  const parse = (0, explore_contract_refine_1.parseContractLayerBlock)(result.fullText);
348
415
  console.log(`[contract-refine-runner] parse ok=${parse.ok} reason=${!parse.ok ? parse.reason : '-'} firstChars=${JSON.stringify(result.fullText.slice(0, 200))}`);
349
416
  if (!parse.ok) {
@@ -487,6 +554,21 @@ async function runContractRefineForQuick(deps, ticketId, generatedTitle, generat
487
554
  });
488
555
  return { ok: false, reason: result.resultEvent ? 'model_error' : 'crashed', ticketId, conversationId: '' };
489
556
  }
557
+ // BUG-PARSER-04: exit-0 but the result event flags an error / max-turns
558
+ // truncation — classify as model_error, not malformed.
559
+ if (isResultErrorEvent(result.resultEvent)) {
560
+ const r = result.resultEvent;
561
+ console.log(`[contract-refine-runner] quick result error event subtype=${r.subtype ?? '-'} is_error=${r.is_error ?? '-'}`);
562
+ recordSafelyQuick(deps, ticketId, model, startedAt, finishedAt, 'failed', result.resultEvent);
563
+ deps.broadcast({
564
+ type: 'explore.contract_refine_failed',
565
+ projectId: deps.projectId,
566
+ ticketId,
567
+ reason: 'model_error',
568
+ timestamp: finishedAt,
569
+ });
570
+ return { ok: false, reason: 'model_error', ticketId, conversationId: '' };
571
+ }
490
572
  const parse = (0, explore_contract_refine_1.parseContractLayerBlock)(result.fullText);
491
573
  if (!parse.ok) {
492
574
  const reason = parse.reason === 'parser-error' ? 'parser_error' : 'malformed';
@@ -74,12 +74,20 @@ function migrateLegacyDbFile(dbPath) {
74
74
  const legacyPath = path_1.default.join(path_1.default.dirname(dbPath), 'hub.sqlite'); // legacy (pre-rebrand) filename
75
75
  try {
76
76
  if (fs_1.default.existsSync(legacyPath) && !fs_1.default.existsSync(dbPath)) {
77
- fs_1.default.renameSync(legacyPath, dbPath);
77
+ // BUG-SQLITE-05: rename the -wal/-shm sidecars BEFORE the main DB file.
78
+ // SQLite matches a WAL to its database by base filename; if the main file
79
+ // is renamed first, a crash in the (sub-ms) window between renames leaves
80
+ // `desktop.sqlite` with no adjacent WAL -> un-checkpointed commits in the
81
+ // orphaned `hub.sqlite-wal` are silently discarded on next open. Moving the
82
+ // sidecars first means an interrupted migration leaves the WAL/SHM still
83
+ // matched to the legacy main file (the new file does not exist yet, so the
84
+ // guard above re-runs the whole migration on the next launch).
78
85
  for (const suffix of ['-wal', '-shm']) {
79
86
  if (fs_1.default.existsSync(legacyPath + suffix)) {
80
87
  fs_1.default.renameSync(legacyPath + suffix, dbPath + suffix);
81
88
  }
82
89
  }
90
+ fs_1.default.renameSync(legacyPath, dbPath);
83
91
  }
84
92
  }
85
93
  catch (err) {
@@ -9,6 +9,7 @@ const crypto_1 = require("crypto");
9
9
  const path_1 = __importDefault(require("path"));
10
10
  const fs_1 = __importDefault(require("fs"));
11
11
  const net_1 = __importDefault(require("net"));
12
+ const dns_1 = __importDefault(require("dns"));
12
13
  const desktop_db_1 = require("./desktop-db");
13
14
  const webhook_manager_1 = require("./webhook-manager");
14
15
  const core_update_manager_1 = require("./core-update-manager");
@@ -129,14 +130,74 @@ function isPrivateIpv4(addr) {
129
130
  (a === 192 && b === 168) ||
130
131
  (a === 100 && b >= 64 && b <= 127); // 100.64.0.0/10 CGNAT
131
132
  }
133
+ // BUG-WEBHOOK-01: alternate IPv4 encodings (decimal `2130706433`, octal
134
+ // `0177.0.0.1`, hex `0x7f.0.0.1` or a single `0x7f000001`) all resolve to the
135
+ // same address as `127.0.0.1` but evade the dotted-quad-only `isPrivateIpv4`.
136
+ // Canonicalize any whole-number / non-dotted-decimal form to a dotted quad so
137
+ // the private-range check sees the real address. Returns null when the input is
138
+ // not an unambiguous IPv4 literal (e.g. a real DNS name) so callers fall back to
139
+ // DNS resolution.
140
+ function canonicalizeIpv4Literal(host) {
141
+ const u32ToDotted = (n) => `${(n >>> 24) & 0xff}.${(n >>> 16) & 0xff}.${(n >>> 8) & 0xff}.${n & 0xff}`;
142
+ // Single integer form (decimal / 0x-hex / 0-octal): https://2130706433/
143
+ if (/^(0x[0-9a-f]+|0[0-7]*|[1-9][0-9]*)$/.test(host)) {
144
+ let n;
145
+ if (host.startsWith('0x'))
146
+ n = Number.parseInt(host.slice(2), 16);
147
+ else if (host.startsWith('0') && host !== '0')
148
+ n = Number.parseInt(host, 8);
149
+ else
150
+ n = Number.parseInt(host, 10);
151
+ if (!Number.isFinite(n) || n < 0 || n > 0xffffffff)
152
+ return null;
153
+ return u32ToDotted(n >>> 0);
154
+ }
155
+ // Dotted form where one or more octets are octal/hex: 0177.0.0.1 / 0x7f.0.0.1
156
+ if (/^[0-9a-fx.]+$/.test(host) && host.includes('.')) {
157
+ const segs = host.split('.');
158
+ if (segs.length !== 4)
159
+ return null;
160
+ const nums = [];
161
+ for (const seg of segs) {
162
+ if (seg === '')
163
+ return null;
164
+ let v;
165
+ if (/^0x[0-9a-f]+$/.test(seg))
166
+ v = Number.parseInt(seg.slice(2), 16);
167
+ else if (/^0[0-7]+$/.test(seg))
168
+ v = Number.parseInt(seg, 8);
169
+ else if (/^[0-9]+$/.test(seg))
170
+ v = Number.parseInt(seg, 10);
171
+ else
172
+ return null;
173
+ if (!Number.isFinite(v) || v < 0 || v > 255)
174
+ return null;
175
+ nums.push(v);
176
+ }
177
+ return nums.join('.');
178
+ }
179
+ return null;
180
+ }
132
181
  function isPrivateIp(hostname) {
133
182
  // Strip IPv6 brackets that URL.hostname can include (else net.isIP returns 0
134
183
  // and an IPv6 literal would slip through as "not an IP").
135
184
  let host = hostname.toLowerCase().replace(/^\[|\]$/g, '');
136
- // IPv4-mapped IPv6 (`::ffff:169.254.169.254`) → check the embedded v4.
137
- const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(host);
138
- if (mapped)
139
- return isPrivateIpv4(mapped[1]);
185
+ // IPv4-mapped IPv6 — both the dotted-decimal embedding (`::ffff:127.0.0.1`)
186
+ // and the hex embedding (`::ffff:7f00:1`) must be unwrapped to their v4 form.
187
+ const mappedDotted = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(host);
188
+ if (mappedDotted)
189
+ return isPrivateIpv4(mappedDotted[1]);
190
+ const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host);
191
+ if (mappedHex) {
192
+ const hi = Number.parseInt(mappedHex[1], 16);
193
+ const lo = Number.parseInt(mappedHex[2], 16);
194
+ const v4 = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
195
+ return isPrivateIpv4(v4);
196
+ }
197
+ // Alternate IPv4 encodings (decimal / octal / hex) → canonicalize then check.
198
+ const canon = canonicalizeIpv4Literal(host);
199
+ if (canon)
200
+ return isPrivateIpv4(canon);
140
201
  const ipVersion = net_1.default.isIP(host);
141
202
  if (ipVersion === 0)
142
203
  return false;
@@ -146,6 +207,26 @@ function isPrivateIp(hostname) {
146
207
  }
147
208
  return isPrivateIpv4(host);
148
209
  }
210
+ // BUG-WEBHOOK-01 (DNS-rebinding SSRF): `isPrivateIp` only inspects IP literals,
211
+ // so a public DNS name that resolves to a loopback/private/link-local address
212
+ // slips through. Resolve ALL addresses for the host and reject if ANY of them
213
+ // is private. Returns true ⇒ the host is safe to target; false ⇒ blocked.
214
+ // Tolerant of resolution failure: an unresolvable host can't be a confirmed
215
+ // SSRF target, so it is allowed through (delivery will simply fail).
216
+ async function resolvesToPublicOnly(hostname) {
217
+ const host = hostname.replace(/^\[|\]$/g, '');
218
+ // Literal IPs are already handled synchronously by isPrivateIp; skip lookup.
219
+ if (net_1.default.isIP(host) !== 0 || canonicalizeIpv4Literal(host.toLowerCase()))
220
+ return true;
221
+ let addrs;
222
+ try {
223
+ addrs = await dns_1.default.promises.lookup(host, { all: true, verbatim: true });
224
+ }
225
+ catch {
226
+ return true;
227
+ }
228
+ return !addrs.some((a) => isPrivateIp(a.address));
229
+ }
149
230
  function validateHttpUrl(raw, opts) {
150
231
  let parsed;
151
232
  try {
@@ -607,8 +688,24 @@ function createDesktopRouter(registry, broadcast) {
607
688
  router.get('/webhooks', (_req, res) => {
608
689
  res.json({ webhooks: (0, desktop_db_1.listWebhooks)(registry.desktopDb).map(publicWebhook) });
609
690
  });
691
+ // BUG-WEBHOOK-01: webhook destinations must not resolve to internal hosts.
692
+ // `validateHttpUrl` blocks IP literals; this adds the DNS-resolution gate so a
693
+ // public hostname that resolves to a loopback/private/link-local address is
694
+ // rejected too. Skipped when loopback is explicitly allowed (dev opt-in).
695
+ async function webhookHostAllowed(normalizedUrl) {
696
+ if (process.env.SPECRAILS_ALLOW_LOCAL_WEBHOOKS === '1')
697
+ return true;
698
+ let hostname;
699
+ try {
700
+ hostname = new URL(normalizedUrl).hostname;
701
+ }
702
+ catch {
703
+ return false;
704
+ }
705
+ return resolvesToPublicOnly(hostname);
706
+ }
610
707
  // POST /api/webhooks — create a webhook
611
- router.post('/webhooks', (req, res) => {
708
+ router.post('/webhooks', async (req, res) => {
612
709
  const { url, secret, events, projectId } = req.body ?? {};
613
710
  if (!url || typeof url !== 'string') {
614
711
  res.status(400).json({ error: 'url is required' });
@@ -637,6 +734,10 @@ function createDesktopRouter(registry, broadcast) {
637
734
  res.status(400).json({ error: 'webhook url must be https and must not target localhost/private IPs' });
638
735
  return;
639
736
  }
737
+ if (!(await webhookHostAllowed(normalizedUrl))) {
738
+ res.status(400).json({ error: 'webhook url must be https and must not target localhost/private IPs' });
739
+ return;
740
+ }
640
741
  const webhook = (0, desktop_db_1.addWebhook)(registry.desktopDb, {
641
742
  id: (0, crypto_1.randomUUID)(),
642
743
  projectId: projectId ?? null,
@@ -647,7 +748,7 @@ function createDesktopRouter(registry, broadcast) {
647
748
  res.status(201).json({ webhook: publicWebhook(webhook) });
648
749
  });
649
750
  // PATCH /api/webhooks/:id — update a webhook
650
- router.patch('/webhooks/:id', (req, res) => {
751
+ router.patch('/webhooks/:id', async (req, res) => {
651
752
  const existing = (0, desktop_db_1.getWebhook)(registry.desktopDb, req.params.id);
652
753
  if (!existing) {
653
754
  res.status(404).json({ error: 'Webhook not found' });
@@ -668,6 +769,10 @@ function createDesktopRouter(registry, broadcast) {
668
769
  res.status(400).json({ error: 'webhook url must be https and must not target localhost/private IPs' });
669
770
  return;
670
771
  }
772
+ if (!(await webhookHostAllowed(candidate))) {
773
+ res.status(400).json({ error: 'webhook url must be https and must not target localhost/private IPs' });
774
+ return;
775
+ }
671
776
  normalizedUrl = candidate;
672
777
  }
673
778
  const updated = (0, desktop_db_1.updateWebhook)(registry.desktopDb, req.params.id, {