harnery 0.41.0 → 0.42.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 (212) hide show
  1. package/dist/commander.js +3 -0
  2. package/dist/commands/agents.d.ts.map +1 -1
  3. package/dist/commands/agents.js +9 -1
  4. package/dist/commands/artifacts.d.ts.map +1 -1
  5. package/dist/commands/artifacts.js +113 -12
  6. package/dist/commands/browse.d.ts +164 -0
  7. package/dist/commands/browse.d.ts.map +1 -1
  8. package/dist/commands/browse.js +263 -51
  9. package/dist/commands/claude-desktop.d.ts.map +1 -1
  10. package/dist/commands/claude-desktop.js +52 -0
  11. package/dist/commands/disk.d.ts +6 -0
  12. package/dist/commands/disk.d.ts.map +1 -0
  13. package/dist/commands/disk.js +80 -0
  14. package/dist/commands/doctor.d.ts +8 -0
  15. package/dist/commands/doctor.d.ts.map +1 -1
  16. package/dist/commands/doctor.js +48 -1
  17. package/dist/commands/files.d.ts +16 -0
  18. package/dist/commands/files.d.ts.map +1 -1
  19. package/dist/commands/files.js +54 -2
  20. package/dist/commands/init.d.ts +2 -2
  21. package/dist/commands/init.d.ts.map +1 -1
  22. package/dist/commands/init.js +43 -9
  23. package/dist/commands/rm.d.ts +4 -0
  24. package/dist/commands/rm.d.ts.map +1 -0
  25. package/dist/commands/rm.js +87 -0
  26. package/dist/commands/servers.d.ts +7 -0
  27. package/dist/commands/servers.d.ts.map +1 -0
  28. package/dist/commands/servers.js +394 -0
  29. package/dist/commands/tunnel.d.ts.map +1 -1
  30. package/dist/commands/tunnel.js +82 -2
  31. package/dist/core/adapters/profiles.d.ts +9 -0
  32. package/dist/core/adapters/profiles.d.ts.map +1 -1
  33. package/dist/core/adapters/profiles.js +22 -1
  34. package/dist/core/adapters/types.d.ts +9 -0
  35. package/dist/core/adapters/types.d.ts.map +1 -1
  36. package/dist/core/agents/cli.js +13 -6
  37. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  38. package/dist/core/agents/render/session-context.js +2 -0
  39. package/dist/core/agents/state/live-coordination-view.d.ts +20 -0
  40. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  41. package/dist/core/agents/state/live-coordination-view.js +60 -0
  42. package/dist/core/artifacts/delivery-card.js +9 -2
  43. package/dist/core/artifacts/index.d.ts +140 -4
  44. package/dist/core/artifacts/index.d.ts.map +1 -1
  45. package/dist/core/artifacts/index.js +522 -49
  46. package/dist/core/config.d.ts +68 -11
  47. package/dist/core/config.d.ts.map +1 -1
  48. package/dist/core/config.js +128 -3
  49. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  50. package/dist/core/diagnostics/bundle.js +2 -2
  51. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  52. package/dist/core/events/v3/producers/recorder.js +16 -0
  53. package/dist/core/events/v3/projection.js +9 -1
  54. package/dist/core/hooks/adapter/events.d.ts +17 -0
  55. package/dist/core/hooks/adapter/events.d.ts.map +1 -1
  56. package/dist/core/hooks/adapter/events.js +5 -0
  57. package/dist/core/hooks/adapter/runtime-telemetry.js +3 -1
  58. package/dist/core/hooks/adapter/wiring.d.ts +37 -7
  59. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  60. package/dist/core/hooks/adapter/wiring.js +62 -4
  61. package/dist/core/hooks/cli.js +45 -6
  62. package/dist/core/hooks/run-markers.d.ts +37 -0
  63. package/dist/core/hooks/run-markers.d.ts.map +1 -0
  64. package/dist/core/hooks/run-markers.js +109 -0
  65. package/dist/core/servers/index.d.ts +211 -0
  66. package/dist/core/servers/index.d.ts.map +1 -0
  67. package/dist/core/servers/index.js +560 -0
  68. package/dist/core/servers/net.d.ts +58 -0
  69. package/dist/core/servers/net.d.ts.map +1 -0
  70. package/dist/core/servers/net.js +270 -0
  71. package/dist/core/servers/tunnels.d.ts +14 -0
  72. package/dist/core/servers/tunnels.d.ts.map +1 -0
  73. package/dist/core/servers/tunnels.js +33 -0
  74. package/dist/core/storage/builtins.js +1 -0
  75. package/dist/core/workflow/engine.d.ts.map +1 -1
  76. package/dist/core/workflow/engine.js +30 -1
  77. package/dist/core/workflow/sandbox-projection.d.ts +13 -2
  78. package/dist/core/workflow/sandbox-projection.d.ts.map +1 -1
  79. package/dist/core/workflow/sandbox-projection.js +23 -0
  80. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  81. package/dist/core/workflow/spawn-claude.js +6 -1
  82. package/dist/core/workflow/spawn-codex.d.ts +2 -0
  83. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  84. package/dist/core/workflow/spawn-codex.js +20 -1
  85. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  86. package/dist/core/workflow/spawn-cursor.js +6 -1
  87. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  88. package/dist/core/workflow/spawn-opencode.js +4 -1
  89. package/dist/core/workflow/types.d.ts +47 -2
  90. package/dist/core/workflow/types.d.ts.map +1 -1
  91. package/dist/core/workflow/worker-access.d.ts +92 -0
  92. package/dist/core/workflow/worker-access.d.ts.map +1 -0
  93. package/dist/core/workflow/worker-access.js +450 -0
  94. package/dist/lib/browser/batch-steps.d.ts +18 -0
  95. package/dist/lib/browser/batch-steps.d.ts.map +1 -0
  96. package/dist/lib/browser/batch-steps.js +82 -0
  97. package/dist/lib/browser/collect/collector.d.ts +99 -0
  98. package/dist/lib/browser/collect/collector.d.ts.map +1 -0
  99. package/dist/lib/browser/collect/collector.js +202 -0
  100. package/dist/lib/browser/collect/fields.d.ts +31 -0
  101. package/dist/lib/browser/collect/fields.d.ts.map +1 -0
  102. package/dist/lib/browser/collect/fields.js +75 -0
  103. package/dist/lib/browser/collect/index.d.ts +6 -0
  104. package/dist/lib/browser/collect/index.d.ts.map +1 -0
  105. package/dist/lib/browser/collect/index.js +5 -0
  106. package/dist/lib/browser/collect/page-driver.d.ts +14 -0
  107. package/dist/lib/browser/collect/page-driver.d.ts.map +1 -0
  108. package/dist/lib/browser/collect/page-driver.js +306 -0
  109. package/dist/lib/browser/collect/presets.d.ts +52 -0
  110. package/dist/lib/browser/collect/presets.d.ts.map +1 -0
  111. package/dist/lib/browser/collect/presets.js +176 -0
  112. package/dist/lib/browser/collect/scroll-plan.d.ts +85 -0
  113. package/dist/lib/browser/collect/scroll-plan.d.ts.map +1 -0
  114. package/dist/lib/browser/collect/scroll-plan.js +118 -0
  115. package/dist/lib/browser/index.d.ts +1 -1
  116. package/dist/lib/browser/index.d.ts.map +1 -1
  117. package/dist/lib/browser/page-review-judge.d.ts +2 -0
  118. package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
  119. package/dist/lib/browser/page-review-judge.js +13 -0
  120. package/dist/lib/browser/qa-run-contracts.d.ts +10 -0
  121. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  122. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  123. package/dist/lib/browser/qa-run.js +1 -0
  124. package/dist/lib/browser/windows-chrome.d.ts +59 -0
  125. package/dist/lib/browser/windows-chrome.d.ts.map +1 -0
  126. package/dist/lib/browser/windows-chrome.js +179 -0
  127. package/dist/lib/claude-desktop-share.d.ts +136 -0
  128. package/dist/lib/claude-desktop-share.d.ts.map +1 -0
  129. package/dist/lib/claude-desktop-share.js +394 -0
  130. package/dist/lib/disk-usage.d.ts +50 -0
  131. package/dist/lib/disk-usage.d.ts.map +1 -0
  132. package/dist/lib/disk-usage.js +303 -0
  133. package/dist/lib/docs-lint.d.ts +6 -0
  134. package/dist/lib/docs-lint.d.ts.map +1 -1
  135. package/dist/lib/docs-lint.js +8 -3
  136. package/dist/lib/guarded-remove.d.ts +34 -0
  137. package/dist/lib/guarded-remove.d.ts.map +1 -0
  138. package/dist/lib/guarded-remove.js +310 -0
  139. package/dist/lib/instructions/templates.js +8 -8
  140. package/dist/lib/tunnel/error-page.d.ts +1 -1
  141. package/dist/lib/tunnel/error-page.d.ts.map +1 -1
  142. package/dist/lib/tunnel/error-page.js +19 -8
  143. package/dist/lib/tunnel/gate.js +20 -1
  144. package/dist/lib/tunnel/path-scope.d.ts +22 -0
  145. package/dist/lib/tunnel/path-scope.d.ts.map +1 -0
  146. package/dist/lib/tunnel/path-scope.js +62 -0
  147. package/dist/lib/tunnel/state.d.ts +5 -0
  148. package/dist/lib/tunnel/state.d.ts.map +1 -1
  149. package/dist/lib/tunnel/state.js +17 -3
  150. package/package.json +6 -1
  151. package/schemas/config.schema.json +91 -0
  152. package/src/commander.ts +19 -0
  153. package/src/commands/agents.ts +14 -0
  154. package/src/commands/artifacts.ts +171 -19
  155. package/src/commands/browse.ts +400 -57
  156. package/src/commands/claude-desktop.ts +61 -0
  157. package/src/commands/disk.ts +114 -0
  158. package/src/commands/doctor.ts +52 -1
  159. package/src/commands/files.ts +93 -2
  160. package/src/commands/init.ts +55 -5
  161. package/src/commands/rm.ts +115 -0
  162. package/src/commands/servers.ts +506 -0
  163. package/src/commands/tunnel.ts +102 -2
  164. package/src/core/adapters/profiles.ts +22 -1
  165. package/src/core/adapters/types.ts +10 -0
  166. package/src/core/agents/cli.ts +14 -6
  167. package/src/core/agents/render/session-context.ts +1 -0
  168. package/src/core/agents/state/live-coordination-view.ts +73 -0
  169. package/src/core/artifacts/delivery-card.ts +10 -2
  170. package/src/core/artifacts/index.ts +697 -44
  171. package/src/core/config.ts +183 -4
  172. package/src/core/diagnostics/bundle.ts +2 -1
  173. package/src/core/events/v3/producers/recorder.ts +16 -0
  174. package/src/core/events/v3/projection.ts +11 -1
  175. package/src/core/hooks/adapter/events.ts +19 -0
  176. package/src/core/hooks/adapter/runtime-telemetry.ts +3 -1
  177. package/src/core/hooks/adapter/wiring.ts +116 -8
  178. package/src/core/hooks/cli.ts +47 -6
  179. package/src/core/hooks/run-markers.ts +132 -0
  180. package/src/core/servers/index.ts +712 -0
  181. package/src/core/servers/net.ts +283 -0
  182. package/src/core/servers/tunnels.ts +39 -0
  183. package/src/core/storage/builtins.ts +1 -0
  184. package/src/core/workflow/engine.ts +38 -1
  185. package/src/core/workflow/sandbox-projection.ts +38 -2
  186. package/src/core/workflow/spawn-claude.ts +5 -1
  187. package/src/core/workflow/spawn-codex.ts +18 -1
  188. package/src/core/workflow/spawn-cursor.ts +5 -1
  189. package/src/core/workflow/spawn-opencode.ts +4 -1
  190. package/src/core/workflow/types.ts +62 -2
  191. package/src/core/workflow/worker-access.ts +525 -0
  192. package/src/lib/browser/batch-steps.ts +79 -0
  193. package/src/lib/browser/collect/collector.ts +306 -0
  194. package/src/lib/browser/collect/fields.ts +87 -0
  195. package/src/lib/browser/collect/index.ts +47 -0
  196. package/src/lib/browser/collect/page-driver.ts +327 -0
  197. package/src/lib/browser/collect/presets.ts +237 -0
  198. package/src/lib/browser/collect/scroll-plan.ts +179 -0
  199. package/src/lib/browser/index.ts +1 -0
  200. package/src/lib/browser/page-review-judge.ts +16 -0
  201. package/src/lib/browser/qa-run-contracts.ts +11 -0
  202. package/src/lib/browser/qa-run.ts +1 -0
  203. package/src/lib/browser/windows-chrome.ts +227 -0
  204. package/src/lib/claude-desktop-share.ts +511 -0
  205. package/src/lib/disk-usage.ts +347 -0
  206. package/src/lib/docs-lint.ts +8 -3
  207. package/src/lib/guarded-remove.ts +376 -0
  208. package/src/lib/instructions/templates.ts +8 -8
  209. package/src/lib/tunnel/error-page.ts +25 -9
  210. package/src/lib/tunnel/gate.ts +22 -1
  211. package/src/lib/tunnel/path-scope.ts +73 -0
  212. package/src/lib/tunnel/state.ts +20 -3
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Local socket and process observation for the server registry.
3
+ *
4
+ * Linux reads procfs directly (no `ss`/`lsof` dependency, works under WSL).
5
+ * macOS shells out to `lsof` and `ps`. Other platforms report `unsupported`
6
+ * so callers can say the scan did not run rather than implying it found
7
+ * nothing.
8
+ */
9
+
10
+ import { spawnSync } from "node:child_process";
11
+ import { readdirSync, readFileSync, readlinkSync } from "node:fs";
12
+
13
+ export type ScanSupport = "procfs" | "lsof" | "unsupported";
14
+
15
+ export interface ListeningSocket {
16
+ pid: number;
17
+ port: number;
18
+ address: string;
19
+ }
20
+
21
+ export interface ProcessInfo {
22
+ pid: number;
23
+ ppid: number | null;
24
+ command: string;
25
+ cwd: string | null;
26
+ }
27
+
28
+ export function scanSupport(): ScanSupport {
29
+ if (process.platform === "linux") return "procfs";
30
+ if (process.platform === "darwin") return "lsof";
31
+ return "unsupported";
32
+ }
33
+
34
+ interface ProcSocketRow {
35
+ localAddress: string;
36
+ localPort: number;
37
+ state: string;
38
+ inode: string;
39
+ }
40
+
41
+ /** Parse `/proc/net/tcp` or `/proc/net/tcp6` content. */
42
+ export function parseProcNetTcp(content: string): ProcSocketRow[] {
43
+ const rows: ProcSocketRow[] = [];
44
+ for (const line of content.split("\n").slice(1)) {
45
+ const fields = line.trim().split(/\s+/);
46
+ if (fields.length < 10) continue;
47
+ const [address, portHex] = (fields[1] ?? "").split(":");
48
+ if (!address || !portHex) continue;
49
+ rows.push({
50
+ localAddress: decodeProcAddress(address),
51
+ localPort: Number.parseInt(portHex, 16),
52
+ state: fields[3] ?? "",
53
+ inode: fields[9] ?? "",
54
+ });
55
+ }
56
+ return rows;
57
+ }
58
+
59
+ function decodeProcAddress(hex: string): string {
60
+ if (hex.length === 8) {
61
+ const bytes = hex.match(/../g)?.map((pair) => Number.parseInt(pair, 16)) ?? [];
62
+ return bytes.reverse().join(".");
63
+ }
64
+ if (/^0+$/.test(hex)) return "::";
65
+ if (hex === "00000000000000000000000001000000") return "::1";
66
+ if (hex.startsWith("0000000000000000FFFF0000")) return decodeProcAddress(hex.slice(24));
67
+ return `ipv6:${hex.toLowerCase()}`;
68
+ }
69
+
70
+ function readProcSockets(): ProcSocketRow[] {
71
+ const rows: ProcSocketRow[] = [];
72
+ for (const file of ["/proc/net/tcp", "/proc/net/tcp6"]) {
73
+ try {
74
+ rows.push(...parseProcNetTcp(readFileSync(file, "utf8")));
75
+ } catch {
76
+ // tcp6 is absent on IPv6-disabled kernels.
77
+ }
78
+ }
79
+ return rows;
80
+ }
81
+
82
+ /** Map socket inodes to the pids holding them (only processes we may read). */
83
+ function socketOwners(inodes: Set<string>): Map<string, number> {
84
+ const owners = new Map<string, number>();
85
+ if (inodes.size === 0) return owners;
86
+ let entries: string[];
87
+ try {
88
+ entries = readdirSync("/proc");
89
+ } catch {
90
+ return owners;
91
+ }
92
+ for (const entry of entries) {
93
+ if (!/^\d+$/.test(entry)) continue;
94
+ let fds: string[];
95
+ try {
96
+ fds = readdirSync(`/proc/${entry}/fd`);
97
+ } catch {
98
+ continue;
99
+ }
100
+ for (const fd of fds) {
101
+ let target: string;
102
+ try {
103
+ target = readlinkSync(`/proc/${entry}/fd/${fd}`);
104
+ } catch {
105
+ continue;
106
+ }
107
+ const match = /^socket:\[(\d+)\]$/.exec(target);
108
+ if (match?.[1] && inodes.has(match[1]) && !owners.has(match[1])) {
109
+ owners.set(match[1], Number(entry));
110
+ }
111
+ }
112
+ if (owners.size === inodes.size) break;
113
+ }
114
+ return owners;
115
+ }
116
+
117
+ /** Listening TCP sockets whose owning process this user can see. */
118
+ export function listListeningSockets(): ListeningSocket[] {
119
+ const support = scanSupport();
120
+ if (support === "procfs") {
121
+ const listening = readProcSockets().filter((row) => row.state === "0A");
122
+ const owners = socketOwners(new Set(listening.map((row) => row.inode)));
123
+ const seen = new Set<string>();
124
+ const sockets: ListeningSocket[] = [];
125
+ for (const row of listening) {
126
+ const pid = owners.get(row.inode);
127
+ if (!pid) continue;
128
+ const key = `${pid}:${row.localPort}`;
129
+ if (seen.has(key)) continue;
130
+ seen.add(key);
131
+ sockets.push({ pid, port: row.localPort, address: row.localAddress });
132
+ }
133
+ return sockets.sort((a, b) => a.port - b.port);
134
+ }
135
+ if (support === "lsof") return lsofListeningSockets();
136
+ return [];
137
+ }
138
+
139
+ /**
140
+ * Every listening TCP port on this machine, including ports whose owner this
141
+ * user cannot see (containers, other users). Used to avoid port collisions.
142
+ */
143
+ export function listListeningPorts(): Set<number> {
144
+ const support = scanSupport();
145
+ if (support === "procfs")
146
+ return new Set(
147
+ readProcSockets()
148
+ .filter((row) => row.state === "0A")
149
+ .map((row) => row.localPort),
150
+ );
151
+ if (support === "lsof") return new Set(listListeningSockets().map((socket) => socket.port));
152
+ return new Set();
153
+ }
154
+
155
+ /** Parse `lsof -Fpn` output for listening sockets. */
156
+ export function parseLsofListen(output: string): ListeningSocket[] {
157
+ const sockets: ListeningSocket[] = [];
158
+ const seen = new Set<string>();
159
+ let pid = 0;
160
+ for (const line of output.split("\n")) {
161
+ if (line.startsWith("p")) pid = Number(line.slice(1));
162
+ else if (line.startsWith("n") && pid > 0) {
163
+ const match = /^(.*):(\d+)$/.exec(line.slice(1));
164
+ if (!match?.[2]) continue;
165
+ const port = Number(match[2]);
166
+ const key = `${pid}:${port}`;
167
+ if (seen.has(key)) continue;
168
+ seen.add(key);
169
+ sockets.push({ pid, port, address: (match[1] ?? "").replace(/^\[|\]$/g, "") });
170
+ }
171
+ }
172
+ return sockets.sort((a, b) => a.port - b.port);
173
+ }
174
+
175
+ /**
176
+ * Count established server-side connections per local port. A browser tab
177
+ * holding a live-reload socket or keep-alive connection counts as activity.
178
+ */
179
+ export function establishedConnectionCounts(ports: number[]): Map<number, number> {
180
+ const counts = new Map<number, number>();
181
+ const wanted = new Set(ports.filter((port) => Number.isInteger(port) && port > 0));
182
+ if (wanted.size === 0) return counts;
183
+ const support = scanSupport();
184
+ if (support === "procfs") {
185
+ for (const row of readProcSockets()) {
186
+ // 01 ESTABLISHED; 03 SYN_RECV, where a deferred-accept server parks a
187
+ // connected client until its first bytes arrive.
188
+ if ((row.state !== "01" && row.state !== "03") || !wanted.has(row.localPort)) continue;
189
+ counts.set(row.localPort, (counts.get(row.localPort) ?? 0) + 1);
190
+ }
191
+ return counts;
192
+ }
193
+ if (support === "lsof") return lsofConnectionCounts(wanted);
194
+ return counts;
195
+ }
196
+
197
+ /** Listening sockets via `lsof` (the macOS path; also runs wherever lsof exists). */
198
+ export function lsofListeningSockets(): ListeningSocket[] {
199
+ return parseLsofListen(run("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-Fpn"]));
200
+ }
201
+
202
+ /** Server-side established connections per wanted local port, via `lsof`. */
203
+ export function lsofConnectionCounts(wanted: Set<number>): Map<number, number> {
204
+ const counts = new Map<number, number>();
205
+ const output = run("lsof", ["-nP", "-iTCP", "-sTCP:ESTABLISHED", "-Fn"]);
206
+ for (const line of output.split("\n")) {
207
+ const match = /^n.*?:(\d+)->/.exec(line);
208
+ const port = match?.[1] ? Number(match[1]) : 0;
209
+ if (wanted.has(port)) counts.set(port, (counts.get(port) ?? 0) + 1);
210
+ }
211
+ return counts;
212
+ }
213
+
214
+ /** Parent, command line, and working directory via `ps` and `lsof` (the macOS path). */
215
+ export function psProcessInfo(pid: number): ProcessInfo | null {
216
+ const ps = run("ps", ["-o", "ppid=,command=", "-p", String(pid)]).trim();
217
+ if (!ps) return null;
218
+ const match = /^(\d+)\s+(.*)$/.exec(ps);
219
+ const cwdLine = run("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"])
220
+ .split("\n")
221
+ .find((line) => line.startsWith("n"));
222
+ return {
223
+ pid,
224
+ ppid: match?.[1] ? Number(match[1]) : null,
225
+ command: match?.[2] ?? ps,
226
+ cwd: cwdLine ? cwdLine.slice(1) : null,
227
+ };
228
+ }
229
+
230
+ export function readProcessInfo(pid: number): ProcessInfo | null {
231
+ if (process.platform === "linux") {
232
+ try {
233
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
234
+ const afterComm = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
235
+ const ppid = Number(afterComm[1]);
236
+ const command = readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").join(" ").trim();
237
+ let cwd: string | null = null;
238
+ try {
239
+ cwd = readlinkSync(`/proc/${pid}/cwd`);
240
+ } catch {
241
+ cwd = null;
242
+ }
243
+ return { pid, ppid: Number.isInteger(ppid) ? ppid : null, command, cwd };
244
+ } catch {
245
+ return null;
246
+ }
247
+ }
248
+ if (process.platform === "darwin") return psProcessInfo(pid);
249
+ return null;
250
+ }
251
+
252
+ /** Parse `ps -o etime=` output (`[[dd-]hh:]mm:ss`) into seconds. */
253
+ export function parseElapsed(value: string): number | null {
254
+ const match = /^\s*(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)\s*$/.exec(value);
255
+ if (!match) return null;
256
+ const [, days, hours, minutes, seconds] = match;
257
+ return (
258
+ Number(days ?? 0) * 86_400 + Number(hours ?? 0) * 3_600 + Number(minutes) * 60 + Number(seconds)
259
+ );
260
+ }
261
+
262
+ /** When a process started, from its elapsed run time; null when unavailable. */
263
+ export function processStartedAt(pid: number, now = Date.now()): string | null {
264
+ if (process.platform !== "linux" && process.platform !== "darwin") return null;
265
+ const elapsed = parseElapsed(run("ps", ["-o", "etime=", "-p", String(pid)]));
266
+ return elapsed === null ? null : new Date(now - elapsed * 1_000).toISOString();
267
+ }
268
+
269
+ /** The pid followed by its ancestors, nearest first (bounded). */
270
+ export function ancestry(pid: number, maxHops = 32): number[] {
271
+ const chain: number[] = [];
272
+ let current: number | null = pid;
273
+ while (current && current > 1 && chain.length < maxHops && !chain.includes(current)) {
274
+ chain.push(current);
275
+ current = readProcessInfo(current)?.ppid ?? null;
276
+ }
277
+ return chain;
278
+ }
279
+
280
+ function run(command: string, args: string[]): string {
281
+ const result = spawnSync(command, args, { encoding: "utf8", timeout: 10_000 });
282
+ return result.status === 0 || result.stdout ? (result.stdout ?? "") : "";
283
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Tunnels as registry services.
3
+ *
4
+ * `tunnel up` and `tunnel reload` register the instance; `tunnel down`
5
+ * removes it. Tunnels started before the registry existed are adopted the next
6
+ * time the registry is read, so a long-lived tunnel never shows as an
7
+ * unregistered listener.
8
+ */
9
+
10
+ import { resolve } from "node:path";
11
+ import { gateLogFile, type TunnelState } from "../../lib/tunnel/state.ts";
12
+ import { resolveBinName } from "../config.ts";
13
+ import type { RegisterServerInput } from "./index.ts";
14
+
15
+ export function tunnelServerId(name: string): string {
16
+ return `tunnel-${name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-")}`;
17
+ }
18
+
19
+ /** The registry input for one tunnel instance. */
20
+ export function tunnelServerInput(state: TunnelState, coordRoot: string): RegisterServerInput {
21
+ const helpers = [state.cloudflared_pid, state.provider_pid].filter(
22
+ (pid): pid is number => typeof pid === "number" && pid > 0,
23
+ );
24
+ return {
25
+ id: tunnelServerId(state.name),
26
+ kind: "service",
27
+ type: "tunnel",
28
+ label: `Tunnel ${state.name} → ${state.target}${state.vhost ? ` (${state.vhost})` : ""}`,
29
+ url: state.url,
30
+ port: state.gate_port,
31
+ pid: state.gate_pid,
32
+ ...(helpers.length ? { pids: helpers } : {}),
33
+ log: resolve(coordRoot, ".cache", "tunnel", gateLogFile(state.name)),
34
+ cwd: coordRoot,
35
+ stop_argv: [resolveBinName(coordRoot), "tunnel", "down", "--name", state.name],
36
+ started_at: state.started_at,
37
+ owner: null,
38
+ };
39
+ }
@@ -476,6 +476,7 @@ function cacheFamilies(): HarneryStorageFamily[] {
476
476
  ["pid-map-cache", ".harnery/pid-map", "active native process observations"],
477
477
  ["remote-presence-cache", ".harnery/presence/remote", "presence relay observations"],
478
478
  ["event-v3-live-display", ".harnery/live/v3", "Event Ledger V3 canonical events"],
479
+ ["local-server-registry", ".harnery/servers", "running local server processes"],
479
480
  ] as const;
480
481
  return [
481
482
  ...definitions.map(([id, root, source]) =>
@@ -21,6 +21,7 @@ import { createHash, randomBytes } from "node:crypto";
21
21
  import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
22
22
  import { isAbsolute, join, resolve } from "node:path";
23
23
  import { pathToFileURL } from "node:url";
24
+ import { workflowWorkerFullAccessConfig } from "../config.ts";
24
25
  import { snapshotRepo } from "../context/index.ts";
25
26
  import type {
26
27
  ExternalMutationRequest,
@@ -69,6 +70,7 @@ import type {
69
70
  BlockedInput,
70
71
  EngineOpts,
71
72
  GitAdministrativeGrant,
73
+ ParentAccessEvidence,
72
74
  RunReport,
73
75
  SpawnFilesystemPolicy,
74
76
  SpawnResult,
@@ -88,6 +90,11 @@ import type {
88
90
  import { WORKFLOW_DIAGNOSTIC_ADMISSION_SCHEMA_VERSION } from "./types.ts";
89
91
  import { parseStageOutput, validateAgainstSchema } from "./validate.ts";
90
92
  import { freezeWorkflowWorkContext } from "./work-context.ts";
93
+ import {
94
+ decideWorkerAccess,
95
+ detectParentAccess,
96
+ parseWorkerFullAccessPolicy,
97
+ } from "./worker-access.ts";
91
98
  import {
92
99
  attestTerminal,
93
100
  attestWorkspaceFailure,
@@ -464,6 +471,13 @@ async function executeWorkflow(
464
471
  writableRoots: [...(requestedPolicy.writableRoots ?? []), ...gitGrantRoots],
465
472
  }
466
473
  : undefined;
474
+ // Worker full access (ADR 0192) is decided per launch. The host policy is
475
+ // read once per run; the parent probe runs at most once, and only when a
476
+ // launch has already cleared every cheaper condition.
477
+ const workerFullAccess = parseWorkerFullAccessPolicy(
478
+ workflowWorkerFullAccessConfig(opts.coordRoot),
479
+ );
480
+ let parentAccess: ParentAccessEvidence | undefined;
467
481
  const executionRepoBefore = resumeState?.manifest.repository_before ?? snapshotRepo(executionCwd);
468
482
  if (!resumeState) {
469
483
  writeWorkflowRunManifest({
@@ -992,6 +1006,22 @@ async function executeWorkflow(
992
1006
 
993
1007
  if (!spawnCountClaimed) agentsSpawned++;
994
1008
 
1009
+ const access = decideWorkerAccess({
1010
+ adapter,
1011
+ model: agentOpts.model,
1012
+ effort: agentOpts.effort,
1013
+ policy: workerFullAccess,
1014
+ filesystemPolicy,
1015
+ parent: () => {
1016
+ if (!parentAccess) {
1017
+ parentAccess = (opts.detectParentAccess ?? detectParentAccess)();
1018
+ transcript("worker_access.parent", { ...parentAccess });
1019
+ }
1020
+ return parentAccess;
1021
+ },
1022
+ });
1023
+ agentProof.access = access;
1024
+
995
1025
  await acquire();
996
1026
  try {
997
1027
  await ensureDiagnosticAdmissionObserved();
@@ -1003,7 +1033,11 @@ async function executeWorkflow(
1003
1033
  specialist: agentOpts.specialist ?? null,
1004
1034
  model: agentOpts.model ?? null,
1005
1035
  effort: agentOpts.effort ?? null,
1036
+ access,
1006
1037
  });
1038
+ if (access.mode === "full-access") {
1039
+ log(`[access] ${id} ${adapter}: full access (${access.detail})`);
1040
+ }
1007
1041
  log(`[${name}] ${currentStage || "(no stage)"} → ${id} [${adapter}] ${label}`);
1008
1042
 
1009
1043
  // Headless children do not reliably fire adapter hooks. The engine owns
@@ -1033,7 +1067,10 @@ async function executeWorkflow(
1033
1067
  runId,
1034
1068
  agentId: id,
1035
1069
  subscriptionOnly,
1036
- filesystemPolicy,
1070
+ // A full-access launch has no sandbox to project a policy into.
1071
+ ...(access.mode === "full-access"
1072
+ ? { access: "full-access" as const }
1073
+ : { filesystemPolicy }),
1037
1074
  });
1038
1075
  agentProof.attempts = attempt;
1039
1076
  agentProof.duration_ms += last.durationMs;
@@ -14,8 +14,9 @@
14
14
  * not, which is worse than refusing and worse than never offering the feature.
15
15
  */
16
16
 
17
+ import { builtinAdapterProfile } from "../adapters/profiles.ts";
17
18
  import type { AdapterSandboxProjection } from "../adapters/types.ts";
18
- import type { GitAdministrativeGrant, SpawnFilesystemPolicy } from "./types.ts";
19
+ import type { GitAdministrativeGrant, SpawnFilesystemPolicy, SpawnRequest } from "./types.ts";
19
20
  import type { WorkspaceBinding } from "./workspaces/types.ts";
20
21
 
21
22
  export class SandboxProjectionError extends Error {
@@ -25,7 +26,9 @@ export class SandboxProjectionError extends Error {
25
26
  | "writable_roots_unrepresentable"
26
27
  | "no_projection"
27
28
  | "writable_root_escapes_workspace"
28
- | "git_grant_unavailable";
29
+ | "git_grant_unavailable"
30
+ | "full_access_unrepresentable"
31
+ | "full_access_conflicts_with_policy";
29
32
 
30
33
  constructor(adapter: string, reason: SandboxProjectionError["reason"], message: string) {
31
34
  super(message);
@@ -148,3 +151,36 @@ export function resolveGitGrantRoots(
148
151
  // repeated writable root is noise in the rendered argv and in proof.
149
152
  return [...new Set([repository.gitdir.realpath, repository.common_dir.realpath])];
150
153
  }
154
+
155
+ /**
156
+ * The vendor arguments for an unsandboxed launch, or undefined for an ordinary
157
+ * sandboxed one (ADR 0192).
158
+ *
159
+ * The engine decides whether a worker qualifies; this only renders the result,
160
+ * and refuses rather than degrades. An adapter with no declared full-access
161
+ * rendering cannot honor the request, and a request that also carries a
162
+ * filesystem policy asks for two contradictory things, so both fail before
163
+ * launch instead of silently picking one.
164
+ */
165
+ export function resolveFullAccessArgv(
166
+ adapter: string,
167
+ req: Pick<SpawnRequest, "access" | "filesystemPolicy">,
168
+ ): readonly string[] | undefined {
169
+ if (req.access !== "full-access") return undefined;
170
+ if (req.filesystemPolicy) {
171
+ throw new SandboxProjectionError(
172
+ adapter,
173
+ "full_access_conflicts_with_policy",
174
+ `${adapter} was asked for full access and a "${req.filesystemPolicy.mode}" filesystem policy at once; a launch gets one or the other`,
175
+ );
176
+ }
177
+ const rendering = builtinAdapterProfile(adapter)?.fullAccess;
178
+ if (!rendering) {
179
+ throw new SandboxProjectionError(
180
+ adapter,
181
+ "full_access_unrepresentable",
182
+ `${adapter} declares no full-access mode, so a worker on it cannot run without its sandbox`,
183
+ );
184
+ }
185
+ return rendering.argv;
186
+ }
@@ -23,7 +23,7 @@ import { builtinAdapterProfile, validateAdapterEffort } from "../adapters/profil
23
23
  import type { AdapterInvocation, AdapterRawResult } from "../adapters/types.ts";
24
24
  import { notFoundError } from "./adapters.ts";
25
25
  import { buildChildEnv } from "./child-env.ts";
26
- import { resolveSandboxProjection } from "./sandbox-projection.ts";
26
+ import { resolveFullAccessArgv, resolveSandboxProjection } from "./sandbox-projection.ts";
27
27
  import { isUpstreamFailureText, vendorFailureText } from "./spawn-failure.ts";
28
28
  import type { Spawner, SpawnRequest, SpawnResult } from "./types.ts";
29
29
 
@@ -56,6 +56,10 @@ export function buildClaudeInvocation(req: SpawnRequest): AdapterInvocation {
56
56
  "--max-turns",
57
57
  String(req.maxTurns),
58
58
  ];
59
+ // A qualified launch removes the permission prompt and the Bash sandbox
60
+ // (ADR 0192); an ordinary one leaves Claude Code's own settings in charge.
61
+ const fullAccess = resolveFullAccessArgv("claude-code", req);
62
+ if (fullAccess) argv.push(...fullAccess);
59
63
  if (req.model) argv.push("--model", req.model);
60
64
  if (req.effort) argv.push("--effort", req.effort);
61
65
  return { argv };
@@ -10,6 +10,8 @@
10
10
  * experimental `--json` JSONL event stream.
11
11
  * - `--skip-git-repo-check` keeps non-repo cwds working; `--sandbox
12
12
  * workspace-write` matches workflow-stage expectations (children may edit).
13
+ * A launch the engine qualified for full access renders `--sandbox
14
+ * danger-full-access` instead (ADR 0192).
13
15
  * - No per-run cost or session-id surface in this mode → both left undefined.
14
16
  * - No max-turns equivalent → `maxTurns` is accepted and ignored (documented
15
17
  * in the CLI docs page).
@@ -24,13 +26,28 @@ import { builtinAdapterProfile, validateAdapterEffort } from "../adapters/profil
24
26
  import type { AdapterInvocation, AdapterRawResult } from "../adapters/types.ts";
25
27
  import { notFoundError } from "./adapters.ts";
26
28
  import { buildChildEnv } from "./child-env.ts";
27
- import { resolveSandboxProjection } from "./sandbox-projection.ts";
29
+ import { resolveFullAccessArgv, resolveSandboxProjection } from "./sandbox-projection.ts";
28
30
  import { isUpstreamFailureText, vendorFailureText } from "./spawn-failure.ts";
29
31
  import type { Spawner, SpawnRequest, SpawnResult } from "./types.ts";
30
32
 
31
33
  export function buildCodexInvocation(req: SpawnRequest, resultFile?: string): AdapterInvocation {
32
34
  validateAdapterEffort("codex", req.effort);
33
35
  if (!resultFile) throw new Error("codex adapter requires a final-message result file");
36
+ const fullAccess = resolveFullAccessArgv("codex", req);
37
+ if (fullAccess) {
38
+ const argv = [
39
+ "codex",
40
+ "exec",
41
+ req.prompt,
42
+ "--output-last-message",
43
+ resultFile,
44
+ "--skip-git-repo-check",
45
+ ...fullAccess,
46
+ ];
47
+ if (req.model) argv.push("--model", req.model);
48
+ if (req.effort) argv.push("-c", `model_reasoning_effort=${JSON.stringify(req.effort)}`);
49
+ return { argv, resultFile };
50
+ }
34
51
  // Default stays workspace-write so an unprojected request is unchanged.
35
52
  const projection = req.filesystemPolicy
36
53
  ? resolveSandboxProjection(
@@ -24,7 +24,7 @@ import { builtinAdapterProfile, validateAdapterEffort } from "../adapters/profil
24
24
  import type { AdapterInvocation, AdapterRawResult } from "../adapters/types.ts";
25
25
  import { notFoundError } from "./adapters.ts";
26
26
  import { buildChildEnv } from "./child-env.ts";
27
- import { resolveSandboxProjection } from "./sandbox-projection.ts";
27
+ import { resolveFullAccessArgv, resolveSandboxProjection } from "./sandbox-projection.ts";
28
28
  import { isUpstreamFailureText, vendorFailureText } from "./spawn-failure.ts";
29
29
  import type { Spawner, SpawnRequest, SpawnResult } from "./types.ts";
30
30
 
@@ -69,6 +69,10 @@ export function buildCursorInvocation(req: SpawnRequest): AdapterInvocation {
69
69
  // commands. Neither flag maps host policy into child tools; that capability
70
70
  // remains explicitly unsupported.
71
71
  const argv = ["cursor-agent", "-p", req.prompt, "--output-format", "json", "--trust", "--force"];
72
+ // A qualified launch also turns the Cursor sandbox off (ADR 0192); an
73
+ // ordinary one leaves the configured sandbox setting untouched.
74
+ const fullAccess = resolveFullAccessArgv("cursor", req);
75
+ if (fullAccess) argv.push(...fullAccess);
72
76
  if (req.model) argv.push("--model", req.model);
73
77
  return { argv };
74
78
  }
@@ -22,7 +22,7 @@ import { builtinAdapterProfile, validateAdapterEffort } from "../adapters/profil
22
22
  import type { AdapterInvocation, AdapterRawResult } from "../adapters/types.ts";
23
23
  import { notFoundError } from "./adapters.ts";
24
24
  import { buildChildEnv } from "./child-env.ts";
25
- import { resolveSandboxProjection } from "./sandbox-projection.ts";
25
+ import { resolveFullAccessArgv, resolveSandboxProjection } from "./sandbox-projection.ts";
26
26
  import { isUpstreamFailureText, vendorFailureText } from "./spawn-failure.ts";
27
27
  import type { Spawner, SpawnRequest, SpawnResult } from "./types.ts";
28
28
 
@@ -75,6 +75,9 @@ export function buildOpenCodeInvocation(req: SpawnRequest): AdapterInvocation {
75
75
  req.filesystemPolicy,
76
76
  );
77
77
  }
78
+ // OpenCode declares no full-access rendering, so this refuses a full-access
79
+ // request instead of launching it as an ordinary one (ADR 0192).
80
+ resolveFullAccessArgv("opencode", req);
78
81
  // `--auto` auto-approves permissions that are not explicitly denied, which a
79
82
  // headless workflow child needs because it has no interactive approval
80
83
  // channel; it does not map host policy into child tools (unsupported).
@@ -142,6 +142,9 @@ export interface WorkflowAgentProof {
142
142
  * Absent ⇒ a work failure. Recorded even when a script's parallel() swallows
143
143
  * the rejection, so the run-level class can still be derived from proof. */
144
144
  class?: SpawnFailureClass;
145
+ /** Which vendor sandbox mode this agent launched with and why (ADR 0192).
146
+ * Absent on a cached agent, which launched nothing. */
147
+ access?: WorkerAccessDecision;
145
148
  }
146
149
 
147
150
  export interface WorkflowRepoSnapshot {
@@ -423,6 +426,57 @@ export interface SpawnRequest {
423
426
  * is what shared-checkout runs use. An adapter that cannot represent the
424
427
  * requested projection refuses before launch rather than downgrading. */
425
428
  filesystemPolicy?: SpawnFilesystemPolicy;
429
+ /** Launch without the vendor sandbox (ADR 0192). Absent means sandboxed,
430
+ * which is the adapter's ordinary invocation. The engine sets this only after
431
+ * the host policy, the parent's own access, and the model floor all agree; a
432
+ * spawner refuses it when its adapter declares no full-access rendering, and
433
+ * refuses it alongside a filesystem policy, which it would contradict. */
434
+ access?: WorkerAccessMode;
435
+ }
436
+
437
+ /**
438
+ * Whether a workflow worker runs inside its vendor sandbox (ADR 0192).
439
+ *
440
+ * `sandboxed` is today's projection: read-only or workspace-write, with any
441
+ * declared writable roots and Git grant. `full-access` removes the vendor
442
+ * sandbox entirely and is only ever granted per launch, never as a run default.
443
+ */
444
+ export type WorkerAccessMode = "sandboxed" | "full-access";
445
+
446
+ /** Why a worker got the access mode it did. `qualified` is the only reason that
447
+ * accompanies `full-access`; every other reason is a sandboxed launch. */
448
+ export type WorkerAccessReason =
449
+ | "qualified"
450
+ | "disabled"
451
+ | "config_invalid"
452
+ | "adapter_unrepresentable"
453
+ | "policy_read_only"
454
+ | "parent_sandboxed"
455
+ | "parent_unknown"
456
+ | "model_unset"
457
+ | "model_not_listed"
458
+ | "effort_unset"
459
+ | "effort_below_floor";
460
+
461
+ /**
462
+ * What Harnery could prove about the access of the session that launched this
463
+ * run (ADR 0192). Only positive evidence yields `full-access`; a missing,
464
+ * unreadable, or contradictory signal is `unknown`, which is treated exactly
465
+ * like `sandboxed`.
466
+ */
467
+ export interface ParentAccessEvidence {
468
+ state: "full-access" | "sandboxed" | "unknown";
469
+ /** Adapter session markers found in the environment, in the order checked. */
470
+ adapters: string[];
471
+ detail: string;
472
+ }
473
+
474
+ /** One launch's recorded access decision (ADR 0192). */
475
+ export interface WorkerAccessDecision {
476
+ mode: WorkerAccessMode;
477
+ reason: WorkerAccessReason;
478
+ /** Plain-language explanation naming the evidence the decision rests on. */
479
+ detail: string;
426
480
  }
427
481
 
428
482
  /**
@@ -437,8 +491,10 @@ export interface SpawnRequest {
437
491
  */
438
492
  export type GitAdministrativeGrant = "none" | "shared-repository";
439
493
 
440
- /** What the host has decided the child may write. `full-access` is deliberately
441
- * not a mode: Harnery does not project a no-sandbox state into a vendor CLI. */
494
+ /** What the host has decided a sandboxed child may write. There is no
495
+ * `full-access` mode here: removing the sandbox is a separate, per-launch
496
+ * decision carried by `SpawnRequest.access` (ADR 0192), because it depends on
497
+ * the parent's own access and the worker's model rather than on the run. */
442
498
  export interface SpawnFilesystemPolicy {
443
499
  mode: "read-only" | "workspace-write";
444
500
  /** Explicit absolute paths the child may write, declared rather than derived
@@ -608,6 +664,10 @@ export interface EngineOpts {
608
664
  allowApiBilling?: boolean;
609
665
  /** Billing-probe override for tests (default: the real probeBilling). */
610
666
  probeBilling?: BillingProber;
667
+ /** Parent-access probe override for tests (default: the real
668
+ * detectParentAccess). Consulted only when the host enables worker full
669
+ * access, and at most once per run (ADR 0192). */
670
+ detectParentAccess?: () => ParentAccessEvidence;
611
671
  /** Capability claims used to state whether adapter-native tool evidence was
612
672
  * available. Missing claims remain unknown. */
613
673
  adapterEvidence?: Readonly<Record<AdapterName, AdapterEvidenceCapability | undefined>>;