@staix/agent-hub 0.20.0 → 0.22.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 (183) hide show
  1. package/CHANGELOG.md +66 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +3 -3
  8. package/docs/agent-notes/daemon.md +17 -8
  9. package/docs/agent-notes/local-worker.md +4 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +17 -9
  12. package/docs/agent-notes/tests.md +9 -2
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +39 -4
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +610 -62
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +18 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +54 -10
  23. package/docs/security.md +396 -34
  24. package/docs/smoke.md +133 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1101 -22
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  29. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  30. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  31. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  32. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  33. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  34. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  35. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  36. package/docs/verification/2026-10-30-0.22.0.md +49 -0
  37. package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
  38. package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
  39. package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
  40. package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
  41. package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
  42. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  43. package/docs/verification/claude-permission-transport-native.py +71 -14
  44. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  45. package/docs/verified.json +15 -15
  46. package/package.json +2 -2
  47. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  48. package/plugins/agent-hub/server.js +1016 -280
  49. package/src/adapters/acp.ts +79 -3
  50. package/src/adapters/channel-peer.ts +4 -0
  51. package/src/adapters/claude-channel.ts +13 -6
  52. package/src/adapters/claude-implementer.ts +60 -7
  53. package/src/adapters/codex-appserver.ts +77 -4
  54. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  55. package/src/adapters/conformance/kit.ts +3 -9
  56. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  57. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  58. package/src/adapters/definitions/native-config-policy.ts +3 -2
  59. package/src/adapters/definitions/native-context.ts +12 -0
  60. package/src/adapters/external-metadata.ts +48 -0
  61. package/src/adapters/external-modules.ts +63 -3
  62. package/src/adapters/jcode.ts +6 -2
  63. package/src/adapters/local-worker.ts +40 -25
  64. package/src/adapters/native-proof-ports.ts +10 -0
  65. package/src/adapters/registry-types.ts +4 -0
  66. package/src/broker/protocol.ts +203 -0
  67. package/src/broker/server.ts +314 -0
  68. package/src/broker/store.ts +548 -0
  69. package/src/cli/bench.ts +3 -2
  70. package/src/cli/broker.ts +58 -0
  71. package/src/cli/conductor-recovery.ts +142 -0
  72. package/src/cli/configured-acp-entry.ts +26 -6
  73. package/src/cli/console-state.ts +59 -17
  74. package/src/cli/console.ts +17 -2
  75. package/src/cli/facts-hook.ts +3 -1
  76. package/src/cli/help.ts +29 -16
  77. package/src/cli/identity.ts +25 -10
  78. package/src/cli/init.ts +34 -7
  79. package/src/cli/intake-output.ts +33 -0
  80. package/src/cli/learn-output.ts +38 -0
  81. package/src/cli/main.ts +334 -73
  82. package/src/cli/output.ts +41 -1
  83. package/src/cli/recovery-package.ts +18 -1
  84. package/src/cli/recovery-waits.ts +26 -0
  85. package/src/cli/role-holders.ts +35 -0
  86. package/src/cli/room.ts +304 -0
  87. package/src/cli/say-input.ts +16 -0
  88. package/src/cli/schedule-output.ts +49 -0
  89. package/src/cli/sdlc-output.ts +44 -6
  90. package/src/cli/smoke-pty-bridge.ts +82 -0
  91. package/src/cli/smoke-pty.ts +153 -0
  92. package/src/cli/smoke-run.ts +139 -0
  93. package/src/cli/smoke-target.ts +130 -0
  94. package/src/cli/source-output.ts +29 -0
  95. package/src/cli/task-output.ts +3 -2
  96. package/src/cli/upgrade-runtime.ts +112 -28
  97. package/src/cli/upgrade.ts +68 -8
  98. package/src/hub/ask.ts +76 -12
  99. package/src/hub/board.ts +132 -22
  100. package/src/hub/bus.ts +416 -65
  101. package/src/hub/child-process.ts +2 -0
  102. package/src/hub/ci-watch.ts +42 -20
  103. package/src/hub/claude-failure-stage.ts +85 -0
  104. package/src/hub/claude-shell-wrapper.ts +11 -5
  105. package/src/hub/conductor.ts +17 -4
  106. package/src/hub/config-apply.ts +34 -6
  107. package/src/hub/config-reload.ts +7 -5
  108. package/src/hub/config-trust.ts +7 -1
  109. package/src/hub/context-window.ts +33 -3
  110. package/src/hub/control-client.ts +14 -2
  111. package/src/hub/corpus-config.ts +34 -0
  112. package/src/hub/corpus-evidence.ts +184 -0
  113. package/src/hub/corpus.ts +373 -0
  114. package/src/hub/cost-governance.ts +26 -0
  115. package/src/hub/daemon.ts +1209 -183
  116. package/src/hub/deliverable-files.ts +8 -2
  117. package/src/hub/delivery-journal.ts +200 -73
  118. package/src/hub/dogfood-profile.ts +32 -5
  119. package/src/hub/dogfood-read.ts +6 -2
  120. package/src/hub/envelope.ts +11 -0
  121. package/src/hub/eval-gate.ts +131 -0
  122. package/src/hub/events.ts +1 -1
  123. package/src/hub/external-instance.ts +69 -0
  124. package/src/hub/facts.ts +15 -5
  125. package/src/hub/folder-trigger.ts +223 -0
  126. package/src/hub/hub-tools.ts +2 -1
  127. package/src/hub/inference.ts +20 -5
  128. package/src/hub/instance-config.ts +69 -3
  129. package/src/hub/instance-execution.ts +6 -2
  130. package/src/hub/instances.ts +32 -4
  131. package/src/hub/intake.ts +125 -0
  132. package/src/hub/learning.ts +117 -0
  133. package/src/hub/lifecycle.ts +7 -3
  134. package/src/hub/limits.ts +12 -5
  135. package/src/hub/native-permission-request.ts +77 -0
  136. package/src/hub/operational-authority.ts +63 -5
  137. package/src/hub/operational-settings.ts +154 -0
  138. package/src/hub/operational-view.ts +1 -1
  139. package/src/hub/packs.ts +132 -0
  140. package/src/hub/path-policy.ts +7 -0
  141. package/src/hub/peer-doing.ts +130 -0
  142. package/src/hub/peers.ts +12 -0
  143. package/src/hub/queue-backpressure.ts +58 -0
  144. package/src/hub/read-tools.ts +2 -0
  145. package/src/hub/recovery-authority.ts +151 -0
  146. package/src/hub/recovery-policy.ts +220 -0
  147. package/src/hub/research.ts +24 -14
  148. package/src/hub/restart.ts +7 -0
  149. package/src/hub/routing.ts +54 -11
  150. package/src/hub/schedules.ts +266 -0
  151. package/src/hub/sdlc-merge.ts +38 -14
  152. package/src/hub/sdlc-metrics.ts +32 -10
  153. package/src/hub/sdlc-queue.ts +7 -1
  154. package/src/hub/sdlc-release.ts +1 -1
  155. package/src/hub/sdlc.ts +37 -7
  156. package/src/hub/security-baseline.ts +38 -0
  157. package/src/hub/settings.ts +27 -2
  158. package/src/hub/shipped-packs.ts +8 -0
  159. package/src/hub/snapshots.ts +72 -6
  160. package/src/hub/source-read.ts +160 -0
  161. package/src/hub/sources.ts +311 -0
  162. package/src/hub/tasks.ts +987 -110
  163. package/src/hub/workflow.ts +1 -1
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/tools.ts +37 -3
  167. package/src/local/web-fetch-config.ts +43 -0
  168. package/src/local/web-fetch.ts +183 -0
  169. package/src/memory/brief.ts +85 -13
  170. package/src/memory/capture.ts +1 -1
  171. package/src/rooms/application.ts +114 -0
  172. package/src/rooms/boundary.ts +103 -0
  173. package/src/rooms/commands.ts +294 -0
  174. package/src/rooms/crypto.ts +371 -0
  175. package/src/rooms/link.ts +584 -0
  176. package/src/rooms/policy.ts +82 -0
  177. package/src/rooms/remote-peer.ts +80 -0
  178. package/src/rooms/service.ts +426 -0
  179. package/src/rooms/status.ts +146 -0
  180. package/src/rooms/store.ts +1210 -0
  181. package/src/rooms/wire.ts +465 -0
  182. package/src/ui/index.html +91 -4
  183. package/templates/packs/example.pack.json +13 -0
@@ -5,7 +5,7 @@
5
5
  import { duration, plural, relative, table, terminalText, wrap, type Span, type Tone } from "./console-state.ts";
6
6
 
7
7
  type Line = Span[];
8
- const s = (value: unknown, tone?: Tone): Span => ({ text: terminalText(value).replace(/[\n\t]/g, " "), ...(tone ? { tone } : {}) });
8
+ export const s = (value: unknown, tone?: Tone): Span => ({ text: terminalText(value).replace(/[\n\t]/g, " "), ...(tone ? { tone } : {}) });
9
9
  const room = (columns?: number) => columns ?? Infinity;
10
10
  const list = (value: unknown): any[] => Array.isArray(value) ? value : [];
11
11
  const yes = (value: unknown) => value === true ? "yes" : value === false ? "no" : "unknown";
@@ -29,7 +29,7 @@ function para(text: string, columns: number | undefined, tone?: Tone, indent = "
29
29
  * the value does not fit in the room left of the label column, the value moves under the label instead, so a path or id
30
30
  * is broken only when it is wider than the whole terminal.
31
31
  */
32
- function pair(label: string, value: unknown, columns: number | undefined, labelWidth: number, tone?: Tone, indent = ""): Line[] {
32
+ export function pair(label: string, value: unknown, columns: number | undefined, labelWidth: number, tone?: Tone, indent = ""): Line[] {
33
33
  const text = terminalText(value).replace(/[\n\t]/g, " ");
34
34
  const head = indent + label.padEnd(labelWidth) + " ";
35
35
  const longest = Math.max(0, ...text.split(/\s+/).map(textWidth));
@@ -52,7 +52,7 @@ const textWidth = (text: string) => Bun.stringWidth(text);
52
52
  * widest cells wrap inside their column (a PR linked to thirty tasks wraps its TASKS cell, it does not push the
53
53
  * table to 134 columns); and when even each column's longest unbreakable word cannot fit, one labelled block per row.
54
54
  */
55
- function grid(head: string[], data: Span[][], columns: number | undefined): Line[] {
55
+ export function grid(head: string[], data: Span[][], columns: number | undefined): Line[] {
56
56
  const natural = head.map((name, i) => Math.max(textWidth(name), ...data.map(row => textWidth(row[i]?.text ?? ""))));
57
57
  const gaps = GAP * (head.length - 1);
58
58
  if (columns === undefined || natural.reduce((sum, n) => sum + n, 0) + gaps <= columns) return table(head, data, room(columns), natural);
@@ -79,8 +79,8 @@ function blocks(head: string[], data: Span[][], columns: number): Line[] {
79
79
  ...head.slice(1).flatMap((name, i) => pair(name.toLowerCase(), row[i + 1]?.text ?? "", columns, label, row[i + 1]?.tone, " "))]);
80
80
  }
81
81
  /** A heading wraps to the width like any paragraph; a long repository name never pushes it past the terminal. */
82
- const heading = (text: string, columns?: number): Line[] => wrap(text, room(columns), 2).map(line => [s(line, "strong")]);
83
- const blank: Line = [s("")];
82
+ export const heading = (text: string, columns?: number): Line[] => wrap(text, room(columns), 2).map(line => [s(line, "strong")]);
83
+ export const blank: Line = [s("")];
84
84
  const readyTone = (ready: unknown): Tone => ready === true ? "success" : "attention";
85
85
 
86
86
  // ---- sdlc status / explain ----------------------------------------------------------------------------------------
@@ -115,6 +115,12 @@ function taskBlock(row: any, columns: number | undefined, detail: boolean): Line
115
115
  out.push(...pair("tests", `${t.applicability === "bug" ? "bug fix" : t.applicability === "not_bug" ? "not a bug fix" : "bug status unknown"}, red-first ${String(t.redFirst).replace("_", " ")}, files ${t.fileState}`, columns, 11, undefined, " "));
116
116
  out.push(...bullets("modified tests", list(t.modifiedTests), columns, undefined, " "), ...bullets("test blockers", list(t.blockers), columns, "attention", " "));
117
117
  }
118
+ const e = row.evalGate;
119
+ if (e) {
120
+ const shown = e.state === "not_configured" ? "eval check not configured (suite choice pending the person)" : e.state === "coverage_unknown" ? "PR file coverage unknown" : `${e.check} ${String(e.state)}${e.passRate !== undefined ? `, pass rate ${e.passRate}` : ""}${e.suite !== undefined ? `, suite ${e.suite}` : ""}`;
121
+ out.push(...pair("eval gate", `configuration PR, ${shown}`, columns, 11, e.state === "passed" && !list(e.gaps).length ? undefined : "attention", " "));
122
+ out.push(...bullets("eval gaps", [...list(e.gaps), ...list(e.notes)], columns, "attention", " "));
123
+ }
118
124
  const c = row.criteria;
119
125
  if (c) {
120
126
  out.push(...pair("criteria", `${c.ready ? "all met" : "not all met"}${c.id ? `, checklist ${c.id}` : ""}${c.note ? `, ${c.note}` : ""}`, columns, 11, readyTone(c.ready), " "));
@@ -175,6 +181,12 @@ export function renderSdlcRelease(reply: unknown, columns?: number, now = Date.n
175
181
  if (a) out.push(blank, ...pair("authorization", `${a.state}: version ${a.receipt?.version ?? "unknown"} at ${short(a.receipt?.commit)}, approved ${moment(a.receipt?.at, now)}`, columns, w, a.state === "matched" ? "success" : "attention"));
176
182
  else out.push(blank, ...pair("authorization", "none recorded: run ahub sdlc authorize-release <version>", columns, w, "muted"));
177
183
  for (const t of list(r.trains)) out.push(...pair(`train #${t.release?.id ?? "?"}`, `${t.readiness?.trigger ?? "unknown"}${list(t.readiness?.blockers).length ? `, ${list(t.readiness.blockers).join("; ")}` : ""}`, columns, w, list(t.readiness?.blockers).length ? "attention" : undefined));
184
+ const rm = r.releaseMetrics;
185
+ if (rm) {
186
+ const mean = (values: unknown[]) => { const known = values.filter((v): v is number => typeof v === "number" && Number.isFinite(v)); return known.length ? span(known.reduce((a, b) => a + b, 0) / known.length) : "unknown"; };
187
+ out.push(blank, ...pair("releases", `${list(rm.intervals).length ? `interval ${mean(list(rm.intervals).map((i: any) => i.ms))} mean over ${plural(list(rm.intervals).length, "gap")}` : "interval unknown (needs two shipped releases with history)"}; lead time ${mean(list(rm.approvalToReleaseMs).map((x: any) => x.ms))} mean approval to release; hotfix rate ${percent(rm.hotfixRate)}${rm.hotfixCount === null || rm.hotfixCount === undefined ? "" : ` (${rm.hotfixCount})`}`, columns, w));
188
+ if (list(rm.unknown).length) out.push(...pair("unknown", `${plural(list(rm.unknown).length, "release")} without complete history: ${list(rm.unknown).map((id: number) => `#${id}`).join(", ")}`, columns, w, "attention", " "));
189
+ }
178
190
  out.push(...bullets("blockers", list(r.blockers), columns, "attention"));
179
191
  return out;
180
192
  }
@@ -184,6 +196,7 @@ export function renderSdlcRelease(reply: unknown, columns?: number, now = Date.n
184
196
  const SUMMARY_ROWS: [string, string, boolean][] = [
185
197
  ["acceptance_to_plan_ms", "acceptance to plan", true], ["acceptance_to_first_review_ms", "acceptance to first review", true], ["acceptance_to_merge_ms", "acceptance to merge", true],
186
198
  ["rework_rounds", "rework rounds", false], ["same_head_ci_reruns", "same-head CI reruns", false], ["stale_head_re_reviews", "stale-head re-reviews", false],
199
+ ["review_rounds_at_merge", "review rounds at merge", false], ["ci_runs_at_merge", "CI runs at merge", false], ["github_reviews_at_merge", "GitHub reviews at merge", false],
187
200
  ];
188
201
  const BAND_TONE: Record<string, Tone> = { ok: "success", log: "muted", diagnose: "attention", propose: "failure" };
189
202
  /** `ahub sdlc metrics [--since ...]`: times and counts from board history, the two approval rates, stalls and band tiers. */
@@ -206,7 +219,7 @@ export function renderSdlcMetrics(result: unknown, columns?: number, now = Date.
206
219
  if (st) out.push(blank, ...pair("stalls", `${st.open ?? 0} open${Object.keys(st.byKind ?? {}).length ? ` (${Object.entries(st.byKind).map(([k, n]) => `${k} ${n}`).join(", ")})` : ""}, ${st.doneWithoutWrites ?? 0} done without writes`, columns, 10, st.open ? "attention" : undefined));
207
220
  if (m?.bands === "off" || m?.bands === undefined) out.push(...pair("bands", "off", columns, 10, "muted"));
208
221
  else {
209
- out.push(blank, ...grid(["BAND", "TIER", "BASELINE", "LAST", "Z", "TASK"], list(m.bands).map((b: any) => [s(String(b.metric).replaceAll("_", " ")), s(b.tier, BAND_TONE[b.tier] ?? undefined), s(count(b.baseline)), s(count(b.last)), s(count(b.z)), s(b.lastTask ? `#${b.lastTask}` : "-", "taskRef")]), columns));
222
+ out.push(blank, ...grid(["BAND", "TIER", "BASELINE", "LAST", "Z", "SUBJECT"], list(m.bands).map((b: any) => [s(String(b.metric).replaceAll("_", " ")), s(b.tier, BAND_TONE[b.tier] ?? undefined), s(count(b.baseline)), s(count(b.last)), s(count(b.z)), s(b.lastTask ? `${b.subject === "release" ? "release " : b.subject === "scan" ? "scan " : ""}#${b.lastTask}` : "-", "taskRef")]), columns));
210
223
  for (const b of list(m.bands)) if (b.reason && b.tier !== "ok") out.push(...para(`${String(b.metric).replaceAll("_", " ")}: ${b.reason}`, columns, "muted", " ", 2));
211
224
  }
212
225
  return out;
@@ -240,3 +253,28 @@ export function renderAuthorization(authorization: unknown, columns?: number, no
240
253
  ...pair("id", a?.id ?? "unknown", columns, w, "muted"), blank,
241
254
  ...para("A daemon restart revokes this authorization.", columns, "muted")];
242
255
  }
256
+
257
+ // ---- sdlc merge ---------------------------------------------------------------------------------------------------
258
+
259
+ const MERGE_STATE: Record<string, [string, Tone]> = { merged: ["merged", "success"], not_merged: ["not merged", "failure"], unknown: ["merge state unknown", "attention"] };
260
+ /** `ahub sdlc merge <task> [--dry-run]` (#899): the hub's decision, every gate that refused it, the `gh` command, and GitHub's readback after a run. */
261
+ export function renderSdlcMerge(view: unknown, columns?: number, _now = Date.now()): Span[][] {
262
+ const v = view as any;
263
+ const w = 8, refused = list(v?.refused), argv = list(v?.argv);
264
+ const out: Line[] = [...heading(`Guarded merge of task #${v?.task ?? "unknown"}`, columns)];
265
+ out.push(...pair("mode", v?.mode ?? "unknown", columns, w, v?.mode === "off" ? "attention" : undefined));
266
+ if (v?.decision === "merge") out.push(...pair("decision", "merge: every gate passed", columns, w, "success"));
267
+ else if (v?.decision === "refused") out.push(...pair("decision", `refused by ${plural(refused.length, "gate")}`, columns, w, "attention"));
268
+ else out.push(...pair("decision", "unknown", columns, w, "attention"));
269
+ out.push(...bullets("refused", refused, columns, "attention", " "));
270
+ if (argv.length) out.push(...pair("command", `gh ${argv.join(" ")}`, columns, w));
271
+ const ran = v?.ran === true ? "yes" : v?.ran === false ? (v?.mode === "dry-run" && argv.length ? "no (dry run: the command above was not run)" : "no") : "unknown";
272
+ out.push(...pair("ran", ran, columns, w, v?.ran === true ? undefined : "muted"));
273
+ const r = v?.result;
274
+ if (r && typeof r === "object") {
275
+ const [state, tone] = MERGE_STATE[String(r.state)] ?? ["merge state unknown", "attention" as Tone];
276
+ out.push(blank, ...pair("result", `${state}, gh exit ${count(r.exit)}${r.mergeCommit ? `, merge commit ${short(r.mergeCommit)}` : ""}`, columns, w, tone));
277
+ if (r.cleanup) out.push(...pair("cleanup", `branch cleanup ${String(r.cleanup).replaceAll("_", " ")}${r.reason ? ` (${String(r.reason).replaceAll("_", " ")})` : ""}`, columns, w, r.cleanup === "deleted" ? undefined : "muted"));
278
+ }
279
+ return out;
280
+ }
@@ -0,0 +1,82 @@
1
+ // macOS script(1) requires a terminal on its own stdin, including when its child gets a PTY.
2
+ // Keep a real PTY in this owned bridge instead; the outer Node child remains tracked by stopOwnedProcess.
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ /**
8
+ * #560: Bun up to 1.4.3 can hang for good inside `Bun.spawn({ terminal })` on macOS when the child writes and starts exiting before the call returns (oven-sh/bun#45300, fixed in 1.4.4): the
9
+ * event loop thread blocks in wait4 while the exiting session leader waits for that same thread to drain its pty. Measured here with a bare Bun script and no agent-hub code: 26 of 96 runs hung
10
+ * at 12 in parallel on 1.4.3 and 0 of 180 on 1.4.4. The child therefore starts behind a gate: a shell that waits for a file the bridge writes right after `Bun.spawn` has returned, then `exec`s the
11
+ * real command (same pid, same session, same terminal). The wait has no bound of its own: a bridge that dies before it opens the gate closes the pty, which hangs the shell up (the trap below), and a bridge
12
+ * that is alive but never opens it is a hung bridge, which `runPty` stops with its group.
13
+ * ponytail: ceiling and upgrade path. The gate exists only because of oven-sh/bun#45300 on Bun up to 1.4.3 (this repo pins 1.4.2 in `.bun-version`); the upgrade path is to delete GATE_SCRIPT, `gate` and `gateDir` once the pinned Bun is 1.4.4 or newer, which is its own task because it
14
+ * touches CI and every OS leg. The parent-death stop below stays whatever the Bun version: an orphaned bridge is this repository's bug, not Bun's. The gate costs about one shell start (a few ms).
15
+ */
16
+ // The shell removes its own gate (file, then directory) once released and when it is hung up or terminated (a bridge killed with SIGKILL runs no cleanup, and the pty closing
17
+ // sends the shell SIGHUP), so no path leaves a directory behind. `exec` resets the traps for the real command.
18
+ const GATE_SCRIPT = 'd=${0%/*}; trap "rm -f \\"$0\\"; rmdir \\"$d\\" 2>/dev/null; exit 125" HUP TERM INT; while [ ! -e "$0" ]; do sleep 0.01; done; rm -f "$0"; rmdir "$d" 2>/dev/null; exec "$@"';
19
+ /** A bridge whose caller died is stopped instead of running on: its child holds a terminal nobody reads and, under launchd, nothing else would ever stop it. */
20
+ const PARENT_POLL_MS = 250;
21
+ const PARENT_TERM_GRACE_MS = 1_000;
22
+ const PARENT_HARD_EXIT_MS = 3_000;
23
+
24
+ // The caller is whoever started this bridge, read before anything that can take time: a caller that dies while `Bun.spawn` runs (the window #45300 widens) has already reparented this process by the
25
+ // time the call returns, and a parent read after it would be launchd and never change.
26
+ const parent = process.ppid;
27
+ const callerGone = (): boolean => process.ppid !== parent;
28
+ const argv = process.argv.slice(2);
29
+ if (!argv.length) {
30
+ process.exitCode = 125;
31
+ } else {
32
+ let streamClosed!: () => void;
33
+ const eof = new Promise<void>(resolve => { streamClosed = resolve; });
34
+ const gateDir = process.platform === "darwin" ? mkdtempSync(join(tmpdir(), "ahub-pty-gate-")) : undefined;
35
+ const gate = gateDir ? join(gateDir, "open") : undefined;
36
+ let watch: ReturnType<typeof setInterval> | undefined;
37
+ try {
38
+ const child = Bun.spawn(gate ? ["/bin/sh", "-c", GATE_SCRIPT, gate, ...argv] : argv, {
39
+ cwd: process.cwd(), env: process.env,
40
+ terminal: {
41
+ cols: 120, rows: 40, name: "xterm-256color",
42
+ data: (_terminal, data) => { process.stdout.write(data); },
43
+ exit: () => streamClosed(),
44
+ },
45
+ });
46
+ const stop = () => { try { child.kill("SIGTERM"); } catch { /* exit readback decides */ } };
47
+ process.on("SIGINT", stop); process.on("SIGTERM", stop);
48
+ /** The caller is gone: stop the child now, kill it after a grace period, and exit even if its exit is never reported. */
49
+ const abandon = () => {
50
+ clearInterval(watch);
51
+ stop();
52
+ setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* already gone */ } }, PARENT_TERM_GRACE_MS).unref();
53
+ setTimeout(() => process.exit(129), PARENT_HARD_EXIT_MS).unref();
54
+ };
55
+ // A caller that died while Bun.spawn ran never gets its command run: the gate stays shut and the gate shell takes the SIGTERM (its trap removes the gate and exits).
56
+ // Abandoning once is enough: the interval below exists only while the caller is still there, and clears itself the first time it is not.
57
+ if (callerGone()) abandon();
58
+ else {
59
+ if (gate) writeFileSync(gate, "");
60
+ watch = setInterval(() => { if (callerGone()) abandon(); }, PARENT_POLL_MS);
61
+ }
62
+ try {
63
+ const code = await child.exited;
64
+ // Drain the PTY before closing it; a descendant holding a slave cannot make the bridge wait forever.
65
+ let timer: ReturnType<typeof setTimeout> | undefined;
66
+ await Promise.race([eof, new Promise<void>(resolve => { timer = setTimeout(resolve, 250); })]);
67
+ if (timer) clearTimeout(timer);
68
+ process.exitCode = code;
69
+ } catch {
70
+ process.exitCode = 125;
71
+ } finally {
72
+ child.terminal?.close();
73
+ process.removeListener("SIGINT", stop); process.removeListener("SIGTERM", stop);
74
+ }
75
+ } catch {
76
+ process.exitCode = 125;
77
+ } finally {
78
+ if (watch) clearInterval(watch);
79
+ if (gateDir) rmSync(gateDir, { recursive: true, force: true });
80
+ }
81
+ }
82
+ export {};
@@ -0,0 +1,153 @@
1
+ import { join } from "node:path";
2
+ import { spawn } from "node:child_process";
3
+ import { shellQuote } from "./shell-quote.ts";
4
+ import type { ChildProcess } from "node:child_process";
5
+ import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
6
+
7
+ export const MAX_PTY_OUTPUT_BYTES = 1024 * 1024;
8
+ const MIN_TIMEOUT_MS = 100;
9
+ const MAX_TIMEOUT_MS = 120_000;
10
+ const SCRIPT = "/usr/bin/script";
11
+ const PTY_COMMAND = '/bin/stty rows 40 cols 120 || exit 125; exec "$@"';
12
+
13
+ export interface PtyOptions {
14
+ cwd: string;
15
+ env: NodeJS.ProcessEnv;
16
+ timeoutMs: number;
17
+ onOutput?: (chunk: Buffer) => void;
18
+ signal?: AbortSignal;
19
+ ready?: () => Promise<void>;
20
+ }
21
+
22
+ export interface PtyResult {
23
+ outcome: "exited" | "completed" | "timeout" | "aborted" | "failed";
24
+ exitCode: number | null;
25
+ outputBytes: number;
26
+ elapsedMs: number;
27
+ cleaned: boolean;
28
+ }
29
+
30
+ type Outcome = PtyResult["outcome"];
31
+ type Winner = { outcome: Outcome };
32
+
33
+
34
+ function commandFor(argv: readonly string[]): string[] {
35
+ const command = process.platform === "darwin"
36
+ ? [process.execPath, join(import.meta.dir, "smoke-pty-bridge.ts"), ...argv]
37
+ : [SCRIPT, "-q", "-e", "-f", "-c", ["/bin/sh", "-c", PTY_COMMAND, "ahub-pty", ...argv].map(shellQuote).join(" "), "/dev/null"];
38
+ return command;
39
+ }
40
+
41
+ function validate(argv: readonly string[], options: PtyOptions): void {
42
+ if (!Array.isArray(argv) || argv.length === 0 || argv.some(arg => typeof arg !== "string" || arg.includes("\0")) || !argv[0]) throw new Error("invalid PTY command");
43
+ if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < MIN_TIMEOUT_MS || options.timeoutMs > MAX_TIMEOUT_MS) throw new Error("invalid PTY timeout");
44
+ if (typeof options.cwd !== "string" || !options.cwd || !options.env || typeof options.env !== "object") throw new Error("invalid PTY environment");
45
+ if (process.platform !== "darwin" && process.platform !== "linux") throw new Error("PTY smoke runner is unsupported on this platform");
46
+ }
47
+
48
+ function waitForExit(proc: ChildProcess): Promise<{ code: number | null }> {
49
+ return new Promise(resolve => {
50
+ if (proc.exitCode !== null || proc.signalCode !== null) { resolve({ code: proc.exitCode }); return; }
51
+ proc.once("exit", code => resolve({ code }));
52
+ proc.once("error", () => resolve({ code: null }));
53
+ });
54
+ }
55
+
56
+ /** Run an argv vector inside a fixed-size PTY, always stopping its owned process group before returning. */
57
+ export function runPty(argv: readonly string[], options: PtyOptions): Promise<PtyResult> {
58
+ return runPtyWith(commandFor, argv, options);
59
+ }
60
+
61
+ /**
62
+ * `runPty` with the launcher named by the caller. Only code can reach it (a test names a bridge that ignores SIGTERM or never exits); no config key, request or environment variable does, so the
63
+ * bridge a hub runs is always `commandFor`'s.
64
+ */
65
+ export async function runPtyWith(launcher: (argv: readonly string[]) => string[], argv: readonly string[], options: PtyOptions): Promise<PtyResult> {
66
+ validate(argv, options);
67
+ const started = Date.now();
68
+ if (options.signal?.aborted) return { outcome: "aborted", exitCode: null, outputBytes: 0, elapsedMs: Date.now() - started, cleaned: true };
69
+
70
+ let proc: ChildProcess;
71
+ try {
72
+ const command = launcher(argv);
73
+ proc = spawn(command[0]!, command.slice(1), {
74
+ cwd: options.cwd,
75
+ env: { ...options.env, TERM: "xterm-256color" },
76
+ detached: true,
77
+ stdio: ["pipe", "pipe", "pipe"],
78
+ });
79
+ } catch {
80
+ throw new Error("PTY process could not be started");
81
+ }
82
+ trackGroup(proc);
83
+ const closed = new Promise<void>(resolve => proc.once("close", () => resolve()));
84
+
85
+ let outputBytes = 0, outputFailure: Error | undefined;
86
+ const drain = (chunk: Buffer) => {
87
+ outputBytes += chunk.length;
88
+ const room = Math.max(0, MAX_PTY_OUTPUT_BYTES - (outputBytes - chunk.length));
89
+ if (room > 0 && options.onOutput) {
90
+ try { options.onOutput(chunk.subarray(0, Math.min(room, chunk.length))); }
91
+ catch { outputFailure = new Error("PTY output handler failed"); }
92
+ }
93
+ if (outputBytes > MAX_PTY_OUTPUT_BYTES) outputFailure = new Error("PTY output exceeded 1 MiB");
94
+ };
95
+ proc.stdout?.on("data", drain);
96
+ proc.stderr?.on("data", drain);
97
+
98
+ let exitCode: number | null = null;
99
+ const exited = waitForExit(proc).then(value => { exitCode = value.code; return { outcome: "exited" as const }; });
100
+ let timer: ReturnType<typeof setTimeout> | undefined;
101
+ let abortListener: (() => void) | undefined;
102
+ const interrupted = new Promise<Winner>(resolve => {
103
+ timer = setTimeout(() => resolve({ outcome: "timeout" }), options.timeoutMs);
104
+ options.signal?.addEventListener("abort", abortListener = () => resolve({ outcome: "aborted" }), { once: true });
105
+ if (options.signal?.aborted) resolve({ outcome: "aborted" });
106
+ });
107
+ const spawned = new Promise<void>((resolve, reject) => {
108
+ if (proc.pid !== undefined) { resolve(); return; }
109
+ proc.once("spawn", () => resolve());
110
+ proc.once("error", () => reject());
111
+ });
112
+
113
+ let winner: Winner;
114
+ let cleaned = false;
115
+ try {
116
+ const beforeStart = await Promise.race([spawned.then(() => ({ outcome: "started" as const })), interrupted]);
117
+ if (beforeStart.outcome !== "started") winner = beforeStart;
118
+ else if (proc.exitCode !== null || proc.signalCode !== null) winner = { outcome: "exited" };
119
+ else {
120
+ const ready = options.ready ? Promise.resolve().then(async (): Promise<Winner> => {
121
+ if (proc.exitCode !== null || proc.signalCode !== null) return { outcome: "exited" as const };
122
+ await options.ready!();
123
+ return { outcome: "completed" };
124
+ }).catch(() => ({ outcome: "failed" as const })) : undefined;
125
+ const failures = new Promise<Winner>(resolve => {
126
+ const check = () => { if (outputFailure) resolve({ outcome: "failed" }); };
127
+ proc.stdout?.on("data", check);
128
+ proc.stderr?.on("data", check);
129
+ check();
130
+ });
131
+ winner = await Promise.race([exited, interrupted, failures, ...(ready ? [ready] : [])]);
132
+ }
133
+ } catch {
134
+ winner = { outcome: "failed" };
135
+ }
136
+
137
+ if (timer) clearTimeout(timer);
138
+ if (abortListener) options.signal?.removeEventListener("abort", abortListener);
139
+ if (proc.pid === undefined) await spawned.catch(() => {});
140
+ try {
141
+ await stopOwnedProcess(proc, { group: true });
142
+ cleaned = true;
143
+ } catch {
144
+ cleaned = false;
145
+ }
146
+ let closeTimer: ReturnType<typeof setTimeout> | undefined;
147
+ await Promise.race([closed, new Promise<void>(resolve => { closeTimer = setTimeout(resolve, 250); })]);
148
+ if (closeTimer) clearTimeout(closeTimer);
149
+ // The owned-group stop drops pipes, but the close event is only a bounded best-effort drain barrier.
150
+ if (outputFailure) winner = { outcome: "failed" };
151
+ if (!cleaned && winner.outcome !== "timeout" && winner.outcome !== "aborted") winner = { outcome: "failed" };
152
+ return { outcome: winner.outcome, exitCode: exitCode ?? proc.exitCode, outputBytes, elapsedMs: Date.now() - started, cleaned };
153
+ }
@@ -0,0 +1,139 @@
1
+ import { closeSync, constants, existsSync, fstatSync, mkdirSync, mkdtempSync, openSync, readSync, rmSync, writeFileSync, writeSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { assessVersion } from "../adapters/compat.ts";
4
+ import { hubGitSync } from "../hub/git.ts";
5
+ import { realPath } from "../hub/project.ts";
6
+ import { runPty } from "./smoke-pty.ts";
7
+ import type { SmokeTarget } from "./smoke-target.ts";
8
+
9
+ export interface SmokeReceipt {
10
+ schemaVersion: 1;
11
+ leg: "qwen-dual-output";
12
+ outcome: "passed" | "failed" | "timeout";
13
+ reason: "complete" | "protocol" | "timeout" | "cleanup" | "aborted";
14
+ checks: { disposable: boolean; protocol: boolean; queued: boolean; cleaned: boolean };
15
+ counts: { submits: number; users: number; assistants: number; stops: number; events: number; terminalBytes: number };
16
+ timings: { totalMs: number; protocolMs: number | null };
17
+ }
18
+ export interface SmokeOptions { leg: "qwen-dual-output"; receipt: string; timeoutMs: number; signal?: AbortSignal }
19
+ /** Host-only test ports, never loaded from project configuration or accepted as CLI arguments. */
20
+ export interface SmokePorts { command?: readonly string[]; env?: NodeJS.ProcessEnv }
21
+ const FIRST = "AHUB_SMOKE_FIRST", SECOND = "AHUB_SMOKE_SECOND";
22
+ const FIRST_PROMPT = `Print ${FIRST} once, then list 64 short numbered lines. Do not use any tools.`;
23
+ const SECOND_PROMPT = `Reply with exactly ${SECOND}. Do not use any tools.`;
24
+ const protocolError = () => new Error("smoke protocol refused");
25
+ const inside = (root: string, value: string) => { const name = relative(root, value); return !!name && !isAbsolute(name) && name !== ".." && !name.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`); };
26
+
27
+ /** No model-selected commands, prompts or persisted output: this is one fixed Q2 protocol leg. */
28
+ export async function runSmoke(target: SmokeTarget, options: SmokeOptions, ports: SmokePorts = {}): Promise<SmokeReceipt> {
29
+ target.check();
30
+ if (options.leg !== "qwen-dual-output" || !Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 100 || options.timeoutMs > 120_000) throw new Error("smoke arguments refused");
31
+ const receiptPath = resolve(target.cwd, options.receipt), parent = dirname(receiptPath);
32
+ if (!inside(target.cwd, receiptPath) || parent !== target.cwd || realPath(parent) !== parent || existsSync(receiptPath)) throw new Error("smoke receipt refused");
33
+ const source = ports.env ?? process.env;
34
+ if (!source.OPENAI_API_KEY?.trim() || !source.OPENAI_BASE_URL?.trim() || !source.OPENAI_MODEL?.trim()) throw new Error("smoke prerequisites unavailable");
35
+ let endpoint: URL;
36
+ try { endpoint = new URL(source.OPENAI_BASE_URL); } catch { throw new Error("smoke prerequisites unavailable"); }
37
+ if (endpoint.username || endpoint.password || !["https:", "http:"].includes(endpoint.protocol)) throw new Error("smoke prerequisites unavailable");
38
+ const command = ports.command ?? [Bun.which("qwen", { PATH: source.PATH }) ?? ""];
39
+ if (!command.length || !command[0]) throw new Error("smoke prerequisites unavailable");
40
+ const started = performance.now();
41
+ const receipt: SmokeReceipt = { schemaVersion: 1, leg: "qwen-dual-output", outcome: "failed", reason: "protocol", checks: { disposable: true, protocol: false, queued: false, cleaned: true }, counts: { submits: 0, users: 0, assistants: 0, stops: 0, events: 0, terminalBytes: 0 }, timings: { totalMs: 0, protocolMs: null } };
42
+ target.check();
43
+ const run = mkdtempSync(join(target.cwd, ".smoke-run-"));
44
+ const home = join(run, "home"), cwd = join(run, "project"), temporary = join(run, "tmp");
45
+ for (const dir of [home, cwd, temporary, join(home, ".qwen")]) mkdirSync(dir, { mode: 0o700 });
46
+ let cleaned = true, protocolMs: number | null = null;
47
+ try {
48
+ const git = hubGitSync(["-c", "init.templateDir=", "init", "-q", cwd], { encoding: "utf8" });
49
+ if (git.status !== 0) throw protocolError();
50
+ writeFileSync(join(home, ".qwen", "settings.json"), JSON.stringify({ security: { auth: { selectedType: "openai" } }, general: { disableAutoUpdate: true, disableUpdateNag: true }, privacy: { usageStatisticsEnabled: false }, telemetry: { enabled: false }, mcpServers: {} }), { mode: 0o600, flag: "wx" });
51
+ const input = join(run, "input.jsonl"), events = join(run, "events.jsonl"), mcp = join(run, "mcp.json");
52
+ for (const file of [input, events]) writeFileSync(file, "", { mode: 0o600, flag: "wx" });
53
+ writeFileSync(mcp, JSON.stringify({ mcpServers: {} }), { mode: 0o600, flag: "wx" });
54
+ const env: NodeJS.ProcessEnv = { HOME: home, XDG_CONFIG_HOME: join(home, ".config"), XDG_CACHE_HOME: join(home, ".cache"), XDG_DATA_HOME: join(home, ".local", "share"), PATH: source.PATH ?? "", TERM: "xterm-256color", LANG: "en_US.UTF-8", TMPDIR: temporary, OPENAI_API_KEY: source.OPENAI_API_KEY, OPENAI_BASE_URL: source.OPENAI_BASE_URL, OPENAI_MODEL: source.OPENAI_MODEL, QWEN_USAGE_STATISTICS_ENABLED: "false", NO_COLOR: "1" };
55
+ const fd = openSync(events, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
56
+ const inputFd = openSync(input, constants.O_WRONLY | constants.O_APPEND | constants.O_NOFOLLOW);
57
+ const decoder = new TextDecoder();
58
+ let position = 0, partial = "", announced = false, assistant = false, queued = false, queueCandidate = false;
59
+ const protocolStop = new AbortController();
60
+ const protocolStarted = performance.now();
61
+ const deadline = protocolStarted + options.timeoutMs;
62
+ const submit = (text: string, window?: () => boolean) => { target.check(); if (window && !window()) return false; if (!fstatSync(inputFd).isFile() || fstatSync(inputFd).nlink !== 1) throw protocolError(); writeSync(inputFd, JSON.stringify({ type: "submit", text }) + "\n"); receipt.counts.submits++; return true; };
63
+ try {
64
+ target.check(); cleaned = false; receipt.checks.cleaned = false;
65
+ const result = await runPty([...command, "--input-file", input, "--json-file", events, "--mcp-config", mcp, "--approval-mode", "default", "--auth-type", "openai", "--model", source.OPENAI_MODEL], { cwd, env, timeoutMs: options.timeoutMs, signal: options.signal, ready: async () => {
66
+ try {
67
+ while (performance.now() < deadline && !options.signal?.aborted && !protocolStop.signal.aborted) {
68
+ const info = fstatSync(fd);
69
+ if (!info.isFile() || info.nlink !== 1 || info.size < position || info.size > 8 * 1024 * 1024) throw protocolError();
70
+ if (position < info.size) {
71
+ const chunk = Buffer.alloc(Math.min(64 * 1024, info.size - position));
72
+ const got = readSync(fd, chunk, 0, chunk.length, position); position += got;
73
+ partial += decoder.decode(chunk.subarray(0, got), { stream: true });
74
+ if (Buffer.byteLength(partial) > 128 * 1024) throw protocolError();
75
+ let newline: number;
76
+ while ((newline = partial.indexOf("\n")) >= 0) {
77
+ const line = partial.slice(0, newline); partial = partial.slice(newline + 1);
78
+ if (!line.trim()) continue;
79
+ if (Buffer.byteLength(line) > 64 * 1024) throw protocolError();
80
+ let event: any; try { event = JSON.parse(line); } catch { throw protocolError(); }
81
+ receipt.counts.events++;
82
+ if (!event || typeof event !== "object" || Array.isArray(event)) throw protocolError();
83
+ if (event.type === "control_request") throw protocolError(); // this read-only leg never answers approvals
84
+ if (event.type === "system" && event.subtype === "session_start") {
85
+ if (announced || event.data?.protocol_version !== 2 || !assessVersion("qwen-dual", event.data?.version).ok || !Array.isArray(event.data?.supported_events) || !["user", "assistant", "stream_event"].every(name => event.data.supported_events.includes(name))) throw protocolError();
86
+ announced = true; submit(FIRST_PROMPT);
87
+ } else if (event.type === "user") {
88
+ if (!announced || receipt.counts.users >= receipt.counts.submits || receipt.counts.users === 1 && receipt.counts.stops !== 1) throw protocolError();
89
+ const content = event.message?.content;
90
+ const text = typeof content === "string" ? content : Array.isArray(content) ? content.filter((b: any) => b?.type === "text" && typeof b.text === "string").map((b: any) => b.text).join("") : "";
91
+ if (text.trim() !== (receipt.counts.users === 0 ? FIRST_PROMPT : SECOND_PROMPT)) throw protocolError();
92
+ receipt.counts.users++;
93
+ } else if (event.type === "assistant") {
94
+ const blocks = event.message?.content;
95
+ const text = Array.isArray(blocks) ? blocks.filter((b: any) => b?.type === "text" && typeof b.text === "string").map((b: any) => b.text).join("") : "";
96
+ if (event.message?.stop_reason === "tool_use" || !text.includes(receipt.counts.stops === 0 ? FIRST : SECOND) || receipt.counts.users !== receipt.counts.stops + 1 || assistant) throw protocolError();
97
+ assistant = true; receipt.counts.assistants++;
98
+ } else if (event.type === "stream_event") {
99
+ if (!event.event || typeof event.event.type !== "string") throw protocolError();
100
+ if (event.event?.type === "message_stop") {
101
+ if (!assistant || receipt.counts.stops === 0 && !queued) throw protocolError();
102
+ assistant = false; receipt.counts.stops++;
103
+ if (receipt.counts.stops === 2) { receipt.checks.protocol = true; receipt.checks.queued = queued; return; }
104
+ } else if (receipt.counts.users === 1 && !queued && ["message_start", "content_block_start", "content_block_delta"].includes(event.event.type)) {
105
+ queueCandidate = true;
106
+ }
107
+ }
108
+ }
109
+ // Consume everything already published before calling a still-running turn queued.
110
+ if (position < info.size) continue;
111
+ if (queueCandidate && !queued && partial.length === 0) {
112
+ if (submit(SECOND_PROMPT, () => fstatSync(fd).size === position)) { queued = true; queueCandidate = false; }
113
+ }
114
+ }
115
+ await Bun.sleep(20);
116
+ }
117
+ throw protocolError();
118
+ } finally { protocolMs = Math.round(performance.now() - protocolStarted); }
119
+ } });
120
+ receipt.counts.terminalBytes = result.outputBytes;
121
+ receipt.checks.cleaned = result.cleaned;
122
+ cleaned = result.cleaned;
123
+ if (result.outcome === "completed" && receipt.checks.protocol && receipt.checks.queued && result.cleaned) { receipt.outcome = "passed"; receipt.reason = "complete"; }
124
+ else if (result.outcome === "timeout" || !receipt.checks.protocol && performance.now() >= deadline) { receipt.outcome = "timeout"; receipt.reason = "timeout"; }
125
+ else if (result.outcome === "aborted") receipt.reason = "aborted";
126
+ } finally { protocolStop.abort(); closeSync(fd); closeSync(inputFd); }
127
+ } catch { receipt.reason = "protocol"; }
128
+ finally {
129
+ // Remove only the directory this invocation made, after all owned processes were confirmed stopped.
130
+ if (cleaned) rmSync(run, { recursive: true, force: true });
131
+ }
132
+ receipt.timings.totalMs = Math.round(performance.now() - started);
133
+ receipt.timings.protocolMs = protocolMs;
134
+ if (!receipt.checks.cleaned) { receipt.outcome = "failed"; receipt.reason = "cleanup"; }
135
+ target.check();
136
+ try { writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + "\n", { mode: 0o600, flag: "wx" }); }
137
+ catch { throw new Error("smoke receipt refused"); }
138
+ return receipt;
139
+ }
@@ -0,0 +1,130 @@
1
+ import { lstatSync, type Stats } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
4
+ import type { Project } from "../hub/registry.ts";
5
+ import { hubGitSync } from "../hub/git.ts";
6
+ import { realPath } from "../hub/project.ts";
7
+
8
+ const REFUSAL = "smoke target refused";
9
+
10
+ export interface SmokeTarget {
11
+ cwd: string;
12
+ hubHome: string;
13
+ stateDir: string;
14
+ projectId: string;
15
+ check(): void;
16
+ }
17
+
18
+ type Identity = { dev: number; ino: number };
19
+ type Snapshot = { cwd: string; hubHome: string; stateDir: string; projectId: string; identities: { cwd: Identity; git: Identity; home: Identity; state: Identity } };
20
+
21
+ function refuse(): never { throw new Error(REFUSAL); }
22
+
23
+ function realDirectory(path: string): { path: string; stat: Stats } {
24
+ if (!isAbsolute(path)) refuse();
25
+ assertNoSymlink(path);
26
+ const canonical = realPath(path);
27
+ const stat = lstatSync(canonical);
28
+ if (stat.isSymbolicLink() || !stat.isDirectory()) refuse();
29
+ return { path: canonical, stat };
30
+ }
31
+
32
+ /** Check the caller's spelling too: resolving a symlink before the check would hide a redirected path. */
33
+ function assertNoSymlink(path: string): void {
34
+ if (path.split(sep).includes("..")) refuse();
35
+ const absolute = resolve(path);
36
+ const root = parse(absolute).root;
37
+ let current = root;
38
+ for (const segment of absolute.slice(root.length).split(sep).filter(Boolean)) {
39
+ current = join(current, segment);
40
+ const stat = lstatSync(current);
41
+ if (stat.isSymbolicLink()) refuse();
42
+ }
43
+ if (lstatSync(root).isSymbolicLink()) refuse();
44
+ }
45
+
46
+ function inside(root: string, child: string): boolean {
47
+ const rel = relative(root, child);
48
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
49
+ }
50
+
51
+ function identity(stat: Stats): Identity { return { dev: stat.dev, ino: stat.ino }; }
52
+ function sameIdentity(a: Identity, b: Identity): boolean { return a.dev === b.dev && a.ino === b.ino; }
53
+
54
+ function gitPath(cwd: string, args: string[]): string {
55
+ const result = hubGitSync(["-C", cwd, ...args], { encoding: "utf8", timeout: 2000, maxBuffer: 64 * 1024 });
56
+ if (result.status !== 0) refuse();
57
+ const path = result.stdout.trim();
58
+ if (!path) refuse();
59
+ return realPath(path);
60
+ }
61
+
62
+ function inspect(input: {
63
+ cwd: string;
64
+ hubHome: string;
65
+ project: Pick<Project, "id" | "root" | "stateDir">;
66
+ expectedProjectId: string;
67
+ liveTest: boolean;
68
+ }): Snapshot {
69
+ if (input.liveTest !== true || !input.expectedProjectId || input.project.id !== input.expectedProjectId) refuse();
70
+
71
+ const root = realDirectory(input.cwd);
72
+ const registeredRoot = realDirectory(input.project.root);
73
+ if (root.path !== registeredRoot.path) refuse();
74
+
75
+ const tempRoot = realPath(tmpdir());
76
+ const temp = realDirectory(tempRoot);
77
+ if (dirname(root.path) !== temp.path || !basename(root.path).startsWith("ahub-smoke-") || root.path === temp.path) refuse();
78
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
79
+ if (uid !== undefined && root.stat.uid !== uid) refuse();
80
+
81
+ const gitDirPath = join(root.path, ".git");
82
+ const gitDir = lstatSync(gitDirPath);
83
+ if (gitDir.isSymbolicLink() || !gitDir.isDirectory()) refuse();
84
+ if (gitPath(root.path, ["rev-parse", "--show-toplevel"]) !== root.path) refuse();
85
+ if (gitPath(root.path, ["rev-parse", "--path-format=absolute", "--git-common-dir"]) !== gitDirPath) refuse();
86
+
87
+ // ponytail: first slice accepts an uncommitted, untracked scratch index only; a future explicit disposable manifest can admit fixture histories.
88
+ for (const args of [["for-each-ref", "--count=1", "--format=%(refname)"], ["ls-files"], ["remote"]]) {
89
+ const result = hubGitSync(["-C", root.path, ...args], { encoding: "utf8", timeout: 2000, maxBuffer: 64 * 1024 });
90
+ if (result.status !== 0 || result.stdout.trim()) refuse();
91
+ }
92
+
93
+ const home = realDirectory(input.hubHome);
94
+ const state = realDirectory(input.project.stateDir);
95
+ if (!inside(root.path, home.path) || !inside(root.path, state.path)) refuse();
96
+
97
+ return {
98
+ cwd: root.path,
99
+ hubHome: home.path,
100
+ stateDir: state.path,
101
+ projectId: input.project.id,
102
+ identities: { cwd: identity(root.stat), git: identity(gitDir), home: identity(home.stat), state: identity(state.stat) },
103
+ };
104
+ }
105
+
106
+ export function validateSmokeTarget(input: {
107
+ cwd: string;
108
+ hubHome: string;
109
+ project: Pick<Project, "id" | "root" | "stateDir">;
110
+ expectedProjectId: string;
111
+ liveTest: boolean;
112
+ }): SmokeTarget {
113
+ let original: Snapshot;
114
+ try { original = inspect(input); } catch { return refuse(); }
115
+ return {
116
+ cwd: original.cwd,
117
+ hubHome: original.hubHome,
118
+ stateDir: original.stateDir,
119
+ projectId: original.projectId,
120
+ check() {
121
+ try {
122
+ const current = inspect(input);
123
+ if (current.cwd !== original.cwd || current.hubHome !== original.hubHome || current.stateDir !== original.stateDir
124
+ || current.projectId !== original.projectId || !sameIdentity(current.identities.cwd, original.identities.cwd)
125
+ || !sameIdentity(current.identities.git, original.identities.git) || !sameIdentity(current.identities.home, original.identities.home)
126
+ || !sameIdentity(current.identities.state, original.identities.state)) refuse();
127
+ } catch { refuse(); }
128
+ },
129
+ };
130
+ }