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
@@ -4,12 +4,20 @@ import { resolve } from "node:path";
4
4
  import type { Command } from "commander";
5
5
  import type { EmitContext, HarneryProgramContext } from "../commander.ts";
6
6
 
7
+ import { resolveCoordRoot } from "../core/agents/coord-client.ts";
7
8
  import { resolveBinName } from "../core/config.ts";
9
+ import {
10
+ tryRegisterServer,
11
+ tunnelServerId,
12
+ tunnelServerInput,
13
+ unregisterServer,
14
+ } from "../core/servers/index.ts";
8
15
  import {
9
16
  processLogDestination,
10
17
  runRotatingProcessSync,
11
18
  spawnRotatingProcess,
12
19
  } from "../core/storage/process-log.ts";
20
+ import { ALLOW_PATHS_ENV, normalizeAllowPaths } from "../lib/tunnel/path-scope.ts";
13
21
  import {
14
22
  clearState,
15
23
  DEFAULT_INSTANCE,
@@ -35,6 +43,8 @@ import {
35
43
  * upstream. Cloudflare quick tunnels add an IP allowlist at the gate via the
36
44
  * Cloudflare-set `CF-Connecting-IP` header; Tailscale Serve/Funnel exposes the
37
45
  * same gate through tailscaled, with Tailscale owning the access boundary.
46
+ * Every provider also passes through a path scope: `up` requires at least one
47
+ * `--allow-path <prefix>`, and the gate refuses any request outside it.
38
48
  *
39
49
  * State + config persisted under `.cache/tunnel/`. cloudflared auto-installs
40
50
  * to ~/.local/bin/ on first run (Linux only; macOS users `brew install`);
@@ -55,6 +65,7 @@ interface UpOpts {
55
65
  visibility?: string;
56
66
  path?: string;
57
67
  httpsPort?: string;
68
+ allowPath?: string[];
58
69
  }
59
70
 
60
71
  interface DownOpts {
@@ -165,6 +176,46 @@ function resolveHttpsPort(raw: string | undefined): number {
165
176
  return port;
166
177
  }
167
178
 
179
+ /**
180
+ * The path scope for `up`. At least one prefix is required: a tunnel forwards
181
+ * to a whole local app, and publishing all of it has to be a stated choice
182
+ * (`--allow-path /`), never the result of leaving a flag off.
183
+ */
184
+ function resolveAllowPaths(raw: string[] | undefined): string[] {
185
+ let paths: string[];
186
+ try {
187
+ paths = normalizeAllowPaths(raw ?? []);
188
+ } catch (error) {
189
+ emit.error({
190
+ code: "tunnel_path_scope_invalid",
191
+ message: error instanceof Error ? error.message : String(error),
192
+ });
193
+ process.exit(1);
194
+ }
195
+ if (paths.length === 0) {
196
+ emit.error({
197
+ code: "tunnel_path_scope_empty",
198
+ message:
199
+ "No path scope; refusing to start. Name what the tunnel shares with --allow-path <prefix> " +
200
+ "(repeatable), or pass --allow-path / to publish the whole upstream.",
201
+ });
202
+ process.exit(1);
203
+ }
204
+ return paths;
205
+ }
206
+
207
+ function samePaths(a: readonly string[], b: readonly string[]): boolean {
208
+ return a.length === b.length && [...a].sort().join("\n") === [...b].sort().join("\n");
209
+ }
210
+
211
+ function formatPaths(paths: readonly string[]): string {
212
+ return paths.length === 0 ? "(none: every path refused)" : paths.join(", ");
213
+ }
214
+
215
+ function collectPath(value: string, previous: string[]): string[] {
216
+ return [...previous, value];
217
+ }
218
+
168
219
  function gateScriptPath(): string {
169
220
  return resolve(import.meta.dirname, "..", "lib", "tunnel", "gate.ts");
170
221
  }
@@ -444,6 +495,7 @@ interface GateSpawnOpts {
444
495
  vhost: string;
445
496
  provider: TunnelProvider;
446
497
  allowedIps: string[];
498
+ allowPaths: string[];
447
499
  gateLogPath: string;
448
500
  }
449
501
 
@@ -470,6 +522,7 @@ function spawnGate(o: GateSpawnOpts): ChildProcess {
470
522
  HARNERY_TUNNEL_TARGET: o.target,
471
523
  HARNERY_TUNNEL_VHOST: o.vhost,
472
524
  HARNERY_TUNNEL_PORT: String(o.gatePort),
525
+ [ALLOW_PATHS_ENV]: o.allowPaths.join(","),
473
526
  },
474
527
  });
475
528
  gateProc.unref();
@@ -498,6 +551,7 @@ async function up(opts: UpOpts): Promise<void> {
498
551
  provider === "tailscale" ? resolveTailscalePath(opts.path, name) : undefined;
499
552
  const tailscaleHttpsPort =
500
553
  provider === "tailscale" ? resolveHttpsPort(opts.httpsPort) : undefined;
554
+ const allowPaths = resolveAllowPaths(opts.allowPath);
501
555
 
502
556
  const existing = readState(name);
503
557
  if (existing && tunnelIsAlive(existing)) {
@@ -508,9 +562,19 @@ async function up(opts: UpOpts): Promise<void> {
508
562
  });
509
563
  process.exit(1);
510
564
  }
565
+ if (!samePaths(existing.allow_paths, allowPaths)) {
566
+ emit.error({
567
+ code: "tunnel_instance_in_use",
568
+ message:
569
+ `Tunnel [${name}] is already up sharing ${formatPaths(existing.allow_paths)}. ` +
570
+ `Stop it before starting it with ${formatPaths(allowPaths)}.`,
571
+ });
572
+ process.exit(1);
573
+ }
511
574
  emit.text(`Already up [${name}]: ${existing.url}\n`);
512
575
  emit.text(` Provider: ${existing.provider}\n`);
513
576
  emit.text(` Forwarding: ${existing.target} (Host: ${existing.vhost})\n`);
577
+ emit.text(` Paths: ${formatPaths(existing.allow_paths)}\n`);
514
578
  return;
515
579
  }
516
580
  if (existing) downOne(name, new Set());
@@ -552,6 +616,7 @@ async function up(opts: UpOpts): Promise<void> {
552
616
  vhost,
553
617
  provider,
554
618
  allowedIps: cfg.allowed_ips,
619
+ allowPaths,
555
620
  gateLogPath,
556
621
  });
557
622
 
@@ -630,11 +695,13 @@ async function up(opts: UpOpts): Promise<void> {
630
695
  target,
631
696
  vhost,
632
697
  gate_port: gatePort,
698
+ allow_paths: allowPaths,
633
699
  tailscale_mode: tailscaleMode,
634
700
  tailscale_path: tailscalePath,
635
701
  tailscale_https_port: tailscaleHttpsPort,
636
702
  };
637
703
  writeState(state);
704
+ recordTunnelServer(state);
638
705
 
639
706
  const stopHint =
640
707
  name === DEFAULT_INSTANCE ? "harn tunnel down" : `harn tunnel down --name ${name}`;
@@ -642,6 +709,7 @@ async function up(opts: UpOpts): Promise<void> {
642
709
  emit.text(` Provider: ${provider}${tailscaleMode ? ` (${tailscaleMode})` : ""}\n`);
643
710
  emit.text(` URL: ${url}\n\n`);
644
711
  emit.text(` Forwarding: ${target} (Host: ${vhost})\n`);
712
+ emit.text(` Paths: ${formatPaths(allowPaths)}\n`);
645
713
  emit.text(` Gate port: ${gatePort}\n`);
646
714
  if (provider === "cloudflare") {
647
715
  emit.text(` Allowed IPs: ${cfg.allowed_ips.join(", ")}\n\n`);
@@ -662,6 +730,16 @@ async function up(opts: UpOpts): Promise<void> {
662
730
  emit.text(" Status: harn tunnel status\n");
663
731
  }
664
732
 
733
+ /** The server registry lists tunnels beside every other local server. */
734
+ function registryRoot(): string {
735
+ return resolveCoordRoot() ?? process.cwd();
736
+ }
737
+
738
+ function recordTunnelServer(state: TunnelState): void {
739
+ const coordRoot = registryRoot();
740
+ tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot });
741
+ }
742
+
665
743
  /** Tear down a single instance by name. Returns the number of processes killed. */
666
744
  function downOne(name: string, killed: Set<number>): number {
667
745
  const before = killed.size;
@@ -684,6 +762,7 @@ function downOne(name: string, killed: Set<number>): number {
684
762
  // lost; they'd otherwise squat on the port and break the next `up`.
685
763
  sweepStrays(state?.gate_port ?? DEFAULT_GATE_PORT, killed);
686
764
  clearState(name);
765
+ unregisterServer(tunnelServerId(name), { coordRoot: registryRoot() });
687
766
  return killed.size - before;
688
767
  }
689
768
 
@@ -771,6 +850,15 @@ export async function reloadOne(state: TunnelState): Promise<{ ok: boolean; mess
771
850
  if (!bunAvailable()) {
772
851
  return { ok: false, message: `[${name}] bun is not on PATH, so the gate can't be respawned.` };
773
852
  }
853
+ if (state.allow_paths.length === 0) {
854
+ return {
855
+ ok: false,
856
+ message:
857
+ `[${name}] has no recorded path scope, so a reloaded gate would refuse every request. ` +
858
+ `Restart it with \`${bin} tunnel down --name ${name}\` and ` +
859
+ `\`${bin} tunnel up --name ${name} --allow-path <prefix>\`.`,
860
+ };
861
+ }
774
862
 
775
863
  // Kill ONLY the gate. sweepStrays() is deliberately avoided here: it also
776
864
  // matches `--url http://localhost:<port>`, which is the provider process we
@@ -803,6 +891,7 @@ export async function reloadOne(state: TunnelState): Promise<{ ok: boolean; mess
803
891
  vhost: state.vhost,
804
892
  provider: state.provider,
805
893
  allowedIps: cfg.allowed_ips,
894
+ allowPaths: state.allow_paths,
806
895
  gateLogPath: tunnelLogDestinations(name, state.provider).gate,
807
896
  });
808
897
 
@@ -822,6 +911,7 @@ export async function reloadOne(state: TunnelState): Promise<{ ok: boolean; mess
822
911
  }
823
912
 
824
913
  writeState({ ...state, gate_pid: pid });
914
+ recordTunnelServer({ ...state, gate_pid: pid });
825
915
  const allowNote =
826
916
  state.provider === "cloudflare" ? ` Allowlist: ${cfg.allowed_ips.length} IP(s).` : "";
827
917
  return { ok: true, message: `[${name}] gate reloaded, URL unchanged: ${state.url}.${allowNote}` };
@@ -898,6 +988,7 @@ function statusDetail(state: TunnelState): void {
898
988
  );
899
989
  emit.text(` URL: ${state.url}\n`);
900
990
  emit.text(` Forwarding: ${state.target} (Host: ${state.vhost})\n`);
991
+ emit.text(` Paths: ${formatPaths(state.allow_paths)}\n`);
901
992
  emit.text(` Gate port: ${state.gate_port}\n`);
902
993
  if (state.provider === "cloudflare") {
903
994
  emit.text(` Allowed IPs: ${cfg.allowed_ips.join(", ")}\n`);
@@ -941,6 +1032,7 @@ function status(opts: StatusOpts): void {
941
1032
  state: instanceState(s),
942
1033
  url: s.url,
943
1034
  fwd: `${s.target} (${s.vhost})`,
1035
+ paths: formatPaths(s.allow_paths),
944
1036
  port: String(s.gate_port),
945
1037
  up: fmtUptime(s.started_at),
946
1038
  }));
@@ -950,15 +1042,16 @@ function status(opts: StatusOpts): void {
950
1042
  state: 5,
951
1043
  url: Math.max(3, ...rows.map((r) => r.url.length)),
952
1044
  fwd: Math.max(10, ...rows.map((r) => r.fwd.length)),
1045
+ paths: Math.max(5, ...rows.map((r) => r.paths.length)),
953
1046
  port: 4,
954
1047
  };
955
1048
  const pad = (s: string, n: number) => s.padEnd(n);
956
1049
  emit.text(
957
- `${pad("NAME", w.name)} ${pad("PROVIDER", w.provider)} ${pad("STATE", w.state)} ${pad("URL", w.url)} ${pad("FORWARDING", w.fwd)} ${pad("PORT", w.port)} UPTIME\n`,
1050
+ `${pad("NAME", w.name)} ${pad("PROVIDER", w.provider)} ${pad("STATE", w.state)} ${pad("URL", w.url)} ${pad("FORWARDING", w.fwd)} ${pad("PATHS", w.paths)} ${pad("PORT", w.port)} UPTIME\n`,
958
1051
  );
959
1052
  for (const r of rows) {
960
1053
  emit.text(
961
- `${pad(r.name, w.name)} ${pad(r.provider, w.provider)} ${pad(r.state, w.state)} ${pad(r.url, w.url)} ${pad(r.fwd, w.fwd)} ${pad(r.port, w.port)} ${r.up}\n`,
1054
+ `${pad(r.name, w.name)} ${pad(r.provider, w.provider)} ${pad(r.state, w.state)} ${pad(r.url, w.url)} ${pad(r.fwd, w.fwd)} ${pad(r.paths, w.paths)} ${pad(r.port, w.port)} ${r.up}\n`,
962
1055
  );
963
1056
  }
964
1057
  }
@@ -1071,6 +1164,13 @@ export function registerTunnelCommand(
1071
1164
  "Tailscale only: URL path mount (default: / for default, /<name> for named instances)",
1072
1165
  )
1073
1166
  .option("--https-port <port>", "Tailscale only: HTTPS listen port (default: 443)", "443")
1167
+ .option(
1168
+ "--allow-path <prefix>",
1169
+ "URL path prefix the tunnel shares (repeatable, required). Every other path is refused; " +
1170
+ "pass / to publish the whole upstream.",
1171
+ collectPath,
1172
+ [],
1173
+ )
1074
1174
  .action(up);
1075
1175
 
1076
1176
  cmd
@@ -45,6 +45,18 @@ export const BUILTIN_ADAPTER_PROFILES = {
45
45
  modelFamily: "claude",
46
46
  effortValues: ["low", "medium", "high", "xhigh", "max"],
47
47
  verified: { date: "2026-08-18", version: "2.1.233 (Claude Code)" },
48
+ // Checked against 2.1.283 (ADR 0192). Claude Code has no sandbox mode flag;
49
+ // its permission mode and its optional Bash sandbox are separate, so full
50
+ // access needs both: bypass permissions, and a settings layer that turns
51
+ // the Bash sandbox off in case a user or project settings file enabled it.
52
+ fullAccess: {
53
+ argv: [
54
+ "--permission-mode",
55
+ "bypassPermissions",
56
+ "--settings",
57
+ '{"sandbox":{"enabled":false}}',
58
+ ],
59
+ },
48
60
  capabilities: capabilities({
49
61
  effortSelection: supported("Mapped to `--effort <level>`."),
50
62
  maxTurns: supported("Mapped to `--max-turns <n>`."),
@@ -78,6 +90,9 @@ export const BUILTIN_ADAPTER_PROFILES = {
78
90
  modes: { "read-only": "read-only", "workspace-write": "workspace-write" },
79
91
  writableRoots: true,
80
92
  },
93
+ // Checked against codex-cli 0.155.1 (ADR 0192): `--sandbox` accepts
94
+ // danger-full-access, and `codex exec` never prompts for approval.
95
+ fullAccess: { argv: ["--sandbox", "danger-full-access"] },
81
96
  capabilities: capabilities({
82
97
  effortSelection: supported('Mapped to `-c model_reasoning_effort="<level>"`.'),
83
98
  filesystemPolicyProjection: supported(
@@ -107,6 +122,10 @@ export const BUILTIN_ADAPTER_PROFILES = {
107
122
  modelFamily: "multi",
108
123
  effortValues: [],
109
124
  verified: { date: "2026-09-20", version: "2026.09.18-9a7762b" },
125
+ // Checked against 2026.09.26-dd393fe (ADR 0192): `--sandbox disabled`
126
+ // overrides the configured sandbox. Cursor carries effort inside the model
127
+ // id, so a full-access floor for it names exact model ids.
128
+ fullAccess: { argv: ["--sandbox", "disabled"] },
110
129
  capabilities: capabilities({
111
130
  effortSelection: unsupported(
112
131
  "Cursor embeds effort in some parameterized model ids; Harnery does not rewrite model ids.",
@@ -136,7 +155,9 @@ export const BUILTIN_ADAPTER_PROFILES = {
136
155
  authModel: "own-auth",
137
156
  modelFamily: "multi",
138
157
  // Model reasoning is selected through the `provider/model#variant` id;
139
- // OpenCode exposes no separate effort dial for Harnery to map.
158
+ // OpenCode exposes no separate effort dial for Harnery to map. It also has
159
+ // no vendor sandbox to remove, so it declares no full-access rendering and
160
+ // a worker on it is never granted one (ADR 0192).
140
161
  effortValues: [],
141
162
  verified: { date: "2026-09-20", version: "opencode v2.0.11" },
142
163
  capabilities: capabilities({
@@ -67,6 +67,16 @@ export interface AdapterProfile {
67
67
  /** How this adapter projects host filesystem policy into the vendor's own
68
68
  * sandbox (ADR 0039). Absent means it cannot project any of it. */
69
69
  sandboxProjection?: AdapterSandboxProjection;
70
+ /** Vendor-native arguments that launch a worker with no vendor sandbox
71
+ * (ADR 0192). Absent means the adapter has no distinguishable full-access
72
+ * mode, so a worker on it never qualifies for one. */
73
+ fullAccess?: AdapterFullAccessRendering;
74
+ }
75
+
76
+ /** How an adapter renders an unsandboxed launch (ADR 0192). The spawner emits
77
+ * exactly these arguments, so the declaration and the argv cannot drift. */
78
+ export interface AdapterFullAccessRendering {
79
+ argv: readonly string[];
70
80
  }
71
81
 
72
82
  /** Fully planned child invocation. `resultFile` is used by adapters such as
@@ -309,12 +309,20 @@ async function handleStateAction(root: string, action: string, rest: string[]):
309
309
  }
310
310
  } catch (error) {
311
311
  const detail = error instanceof Error ? error.message : String(error);
312
- // A missing heartbeat here means the session's generation ended
313
- // while its process lived on. Name the reopen command (ADR 0088);
314
- // set-task is usually the first thing such a session runs.
315
- const hint = detail.includes("heartbeat_missing:")
316
- ? `; run \`${resolveBinName(root)} agents lifecycle active\` to open a fresh generation for this continuing session`
317
- : "";
312
+ // A missing heartbeat usually means the session's generation ended
313
+ // while its process lived on, so name the reopen command (ADR 0088).
314
+ // A still-live generation whose authority the ledger withholds looks
315
+ // the same here; name its diagnostics instead, since reopen cannot apply.
316
+ const { describeLiveAuthorityBlockV3, liveAuthorityBlockHintV3, liveAuthorityBlockV3 } =
317
+ await import("./state/live-coordination-view.ts");
318
+ const block = detail.includes("heartbeat_missing:")
319
+ ? liveAuthorityBlockV3(root, owner)
320
+ : null;
321
+ const hint = block
322
+ ? `; ${describeLiveAuthorityBlockV3(block)}; ${liveAuthorityBlockHintV3(resolveBinName(root))}`
323
+ : detail.includes("heartbeat_missing:")
324
+ ? `; run \`${resolveBinName(root)} agents lifecycle active\` to open a fresh generation for this continuing session`
325
+ : "";
318
326
  process.stderr.write(`agent-coord set-task: V3 authority refused (${detail})${hint}\n`);
319
327
  return 1;
320
328
  }
@@ -126,6 +126,7 @@ export function renderSessionContext(opts: RenderOpts): string {
126
126
  if (d.missing.length > 0)
127
127
  bits.push(`missing: ${d.missing.map((m) => m.subcommand).join(", ")}`);
128
128
  if (d.orphans.length > 0) bits.push(`orphaned: ${d.orphans.join(", ")}`);
129
+ if (d.stale.length > 0) bits.push(`outdated: ${d.stale.map((m) => m.subcommand).join(", ")}`);
129
130
  return ` - ${d.settingsFile} — ${bits.join("; ")}`;
130
131
  });
131
132
  messages.push(
@@ -226,3 +226,76 @@ function observedModel(generation: CoordinationGenerationViewV3): string | undef
226
226
  function isLifecycleState(value: string | undefined): value is "active" | "blocked" | "done" {
227
227
  return value === "active" || value === "blocked" || value === "done";
228
228
  }
229
+
230
+ /** Why a still-live V3 generation has lost coordination authority. */
231
+ export interface LiveAuthorityBlockV3 {
232
+ generation_id: string;
233
+ scope: "generation" | "ledger";
234
+ diagnostics: { code: string; event_id?: string }[];
235
+ }
236
+
237
+ /**
238
+ * Explain an authority refusal for a generation that is still live. Callers
239
+ * reach this after a lookup came back empty; without it the caller sees only
240
+ * "missing", which reads like an ended session and points at a reopen that
241
+ * cannot apply. Returns null when no live generation exists or nothing blocks.
242
+ */
243
+ export function liveAuthorityBlockV3(
244
+ coordRoot: string,
245
+ nativeInstanceId: string,
246
+ ): LiveAuthorityBlockV3 | null {
247
+ let view: CoordinationViewV3;
248
+ try {
249
+ if (observeLiveEventLedgerRouteV3(coordRoot).state === "blocked") return null;
250
+ view = readCoordinationViewV3(coordRoot);
251
+ } catch {
252
+ return null;
253
+ }
254
+ const generation = view.instances[liveInstanceIdV3(nativeInstanceId)];
255
+ if (generation?.phase !== "live" || generation.authority_eligible) return null;
256
+ const own = (view.diagnostics_by_generation[generation.generation_id] ?? []).filter(
257
+ (diagnostic) => diagnostic.authority_blocking,
258
+ );
259
+ const blocking = own.length
260
+ ? own
261
+ : view.global_diagnostics.filter((diagnostic) => diagnostic.authority_blocking);
262
+ if (!blocking.length) return null;
263
+ return {
264
+ generation_id: generation.generation_id,
265
+ scope: own.length ? "generation" : "ledger",
266
+ diagnostics: blocking.map((diagnostic) => ({
267
+ code: diagnostic.source_code
268
+ ? `${diagnostic.code}:${diagnostic.source_code}`
269
+ : diagnostic.code,
270
+ ...(diagnostic.event_id ? { event_id: diagnostic.event_id } : {}),
271
+ })),
272
+ };
273
+ }
274
+
275
+ /** One-line description of a live authority block, naming codes and events. */
276
+ export function describeLiveAuthorityBlockV3(block: LiveAuthorityBlockV3): string {
277
+ const byCode = new Map<string, string[]>();
278
+ for (const { code, event_id } of block.diagnostics) {
279
+ const events = byCode.get(code) ?? [];
280
+ if (event_id) events.push(event_id);
281
+ byCode.set(code, events);
282
+ }
283
+ const parts = [...byCode].map(([code, events]) => {
284
+ const shown = events.slice(0, 2).join(", ");
285
+ const more = events.length > 2 ? `, +${events.length - 2} more` : "";
286
+ return events.length ? `${code} ×${events.length} (${shown}${more})` : code;
287
+ });
288
+ const where =
289
+ block.scope === "generation"
290
+ ? `the event ledger holds ${block.diagnostics.length} authority-blocking diagnostic(s) for it`
291
+ : "the event ledger holds authority-blocking diagnostics that block every generation";
292
+ return `generation ${block.generation_id} is still live, but ${where}: ${parts.join("; ")}`;
293
+ }
294
+
295
+ /** Recovery pointer for a live authority block, in place of the reopen hint. */
296
+ export function liveAuthorityBlockHintV3(binName: string): string {
297
+ return (
298
+ `\`${binName} agents lifecycle active\` cannot help because it only reopens an ended ` +
299
+ `generation; \`${binName} agents health\` lists the diagnostics`
300
+ );
301
+ }
@@ -12,7 +12,7 @@ import {
12
12
  } from "node:fs";
13
13
  import { isAbsolute, relative, resolve, sep } from "node:path";
14
14
  import { encodeLinkSafeComponent } from "../../lib/local-file-url.ts";
15
- import { findLiveTunnelForOrigin } from "../../lib/tunnel/state.ts";
15
+ import { findLiveTunnelForOrigin, tunnelServesPaths } from "../../lib/tunnel/state.ts";
16
16
  import { resolveWebPort } from "../config.ts";
17
17
  import { stateFileMode } from "../storage/modes.ts";
18
18
  import { ARTIFACT_MANIFEST } from "./constants.ts";
@@ -326,9 +326,17 @@ function deliveryLinkBase(repoRoot: string, environment: DisplayEnvironment): st
326
326
  // dashboard's own origin will answer them. A tunnel sharing this upstream
327
327
  // port under another Host serves a different site and would 400 on /browse
328
328
  // and /files, so fall back to the local URL rather than publish that host.
329
+ // The same holds for a dashboard tunnel whose path scope leaves out either
330
+ // route: its gate would refuse every link on the card.
331
+ const tunnel =
332
+ environment.tunnelUrl === undefined
333
+ ? findLiveTunnelForOrigin(webPort, `localhost:${webPort}`, repoRoot)
334
+ : null;
329
335
  const tunnelUrl =
330
336
  environment.tunnelUrl === undefined
331
- ? findLiveTunnelForOrigin(webPort, `localhost:${webPort}`, repoRoot)?.url
337
+ ? tunnel && tunnelServesPaths(tunnel, ["/browse", "/files"])
338
+ ? tunnel.url
339
+ : undefined
332
340
  : environment.tunnelUrl;
333
341
  return (tunnelUrl ?? `http://localhost:${webPort}`).replace(/\/+$/, "");
334
342
  }