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.
- package/cli/dist/specrails-desktop.js +41 -10
- package/client/dist/assets/{ActivityFeedPage-CjaTeSEy.js → ActivityFeedPage-D12zOZGQ.js} +1 -1
- package/client/dist/assets/AgentsPage-CNyxiSCU.js +86 -0
- package/client/dist/assets/AnalyticsPage-DxuypQg1.js +1 -0
- package/client/dist/assets/{BarChart-26Ov3eDx.js → BarChart-CWDs7Ffl.js} +2 -2
- package/client/dist/assets/CodePage-AN1T4w99.js +2 -0
- package/client/dist/assets/{DesktopAnalyticsPage-BLioEDw-.js → DesktopAnalyticsPage-CPdWVhzT.js} +1 -1
- package/client/dist/assets/{DocsDialog-w7AelI_u.js → DocsDialog-C1MnXtzC.js} +2 -2
- package/client/dist/assets/DocsPage-BSqJ-G6V.js +11 -0
- package/client/dist/assets/{ExportDropdown-DlchZhDG.js → ExportDropdown-DpI605_7.js} +1 -1
- package/client/dist/assets/IntegrationsPage-KaZLo4KU.js +3 -0
- package/client/dist/assets/JobDetailPage-QN9NOcrX.js +16 -0
- package/client/dist/assets/JobsPage-C-fLmFs9.js +1 -0
- package/client/dist/assets/{dist-js-tJGlm77v.js → dist-js-EZlD2frS.js} +1 -1
- package/client/dist/assets/{dist-js-DaFKJPgY.js → dist-js-jOe3QgTk.js} +1 -1
- package/client/dist/assets/{index-Clyz7rFj.js → index-wK8zASrE.js} +46 -46
- package/client/dist/assets/{lib-B7C231WR.js → lib-CiLTp9yY.js} +1 -1
- package/client/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/dist/agent-refine-manager.js +54 -11
- package/server/dist/artifact-registry.js +83 -6
- package/server/dist/attachment-manager.js +16 -0
- package/server/dist/browser-capture-manager.js +33 -4
- package/server/dist/browser-network.js +35 -1
- package/server/dist/browser-playwright.js +112 -5
- package/server/dist/chat-manager.js +21 -0
- package/server/dist/code-explorer-router.js +22 -1
- package/server/dist/contract-refine-runner.js +87 -5
- package/server/dist/desktop-db.js +9 -1
- package/server/dist/desktop-router.js +111 -6
- package/server/dist/explore-stdin-session.js +43 -3
- package/server/dist/file-summary-manager.js +92 -7
- package/server/dist/framework-manager.js +56 -12
- package/server/dist/framework-migration.js +14 -2
- package/server/dist/index.js +28 -4
- package/server/dist/interactive-job-session.js +74 -18
- package/server/dist/jira/jira-adf.js +13 -2
- package/server/dist/jira/jira-backlog-config.js +4 -1
- package/server/dist/jira/jira-client.js +75 -9
- package/server/dist/jira/jira-sync-manager.js +34 -6
- package/server/dist/mobile/mobile-devices.js +78 -0
- package/server/dist/mobile/mobile-gateway.js +8 -1
- package/server/dist/mobile/mobile-redact.js +32 -2
- package/server/dist/mobile/mobile-router.js +25 -0
- package/server/dist/mobile/mobile-signal-reconnect.js +13 -3
- package/server/dist/mobile/mobile-webrtc-peer.js +28 -7
- package/server/dist/mobile/mobile-webrtc.js +13 -1
- package/server/dist/mobile/mobile-ws.js +18 -4
- package/server/dist/path-resolver.js +19 -2
- package/server/dist/plugin-manager.js +92 -31
- package/server/dist/proposal-manager.js +59 -8
- package/server/dist/queue-manager.js +179 -30
- package/server/dist/schemas/file-summary.v1.json +62 -0
- package/server/dist/smash-runner.js +49 -5
- package/server/dist/spec-launcher-manager.js +65 -8
- package/server/dist/ssrf-guard.js +142 -0
- package/server/dist/telemetry-receiver.js +28 -17
- package/server/dist/terminal-manager.js +4 -1
- package/server/dist/ticket-store.js +79 -10
- package/server/dist/user-mcp-config.js +5 -0
- package/server/dist/util/cli-prompt.js +21 -6
- package/server/dist/util/win-spawn.js +73 -10
- package/server/dist/webhook-manager.js +11 -0
- package/server/dist/workspace-manager.js +7 -3
- package/client/dist/assets/AgentsPage-BWzkbBYn.js +0 -86
- package/client/dist/assets/AnalyticsPage-CAyfZ8Yi.js +0 -1
- package/client/dist/assets/CodePage-V82YTNl3.js +0 -2
- package/client/dist/assets/DocsPage-vZcAHvLq.js +0 -11
- package/client/dist/assets/IntegrationsPage-CCyZ11D7.js +0 -3
- package/client/dist/assets/JobDetailPage-CaT9c75p.js +0 -16
- package/client/dist/assets/JobsPage-CoqWNWN1.js +0 -1
- package/client/dist/assets/useProjectCache-BJDa0Nwo.js +0 -1
- /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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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, {
|