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,310 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { lstatSync, readdirSync, readlinkSync, realpathSync, rmSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, isAbsolute, parse, relative, resolve, sep } from "node:path";
5
+ const protectedNames = new Set([".git", ".harnery", ".credentials", ".ssh", ".gnupg"]);
6
+ const MAX_ENTRIES = 10_000;
7
+ /** Permanent removal is deliberately limited to inspected, untracked local outputs. */
8
+ export function guardedRemove(paths, options, checkClaims = () => { }) {
9
+ const cwd = realpathSync(process.cwd());
10
+ const root = checkedPath(options.root, new Set(), true);
11
+ const allowed = allowedProtected(options.allowProtected ?? [], root, options.liveRoots ?? []);
12
+ const rootStat = lstatSync(root);
13
+ const rootFingerprint = fingerprint(rootStat);
14
+ if (!rootStat.isDirectory())
15
+ throw new Error("--root must be a directory");
16
+ if (root === parse(root).root || root === realpathSync(homedir()))
17
+ throw new Error("--root cannot be a filesystem root or home directory");
18
+ if (paths.length === 0 || paths.length > 100)
19
+ throw new Error("Supply 1-100 explicit targets");
20
+ const targets = paths.map((path) => checkedPath(path, allowed));
21
+ const snapshots = new Map();
22
+ const removedProtected = new Set();
23
+ const links = [];
24
+ const gitDirs = new Set();
25
+ let bytes = 0;
26
+ const devices = new Set();
27
+ for (const target of targets) {
28
+ const home = realpathSync(homedir());
29
+ if (target === home || strictlyInside(target, home))
30
+ throw new Error(`Cannot remove the home directory or its ancestor: ${target}`);
31
+ const systemRoots = process.platform === "win32"
32
+ ? [
33
+ process.env.SystemRoot,
34
+ process.env.ProgramFiles,
35
+ process.env["ProgramFiles(x86)"],
36
+ ].filter((value) => Boolean(value))
37
+ : [
38
+ "/etc",
39
+ "/usr",
40
+ "/boot",
41
+ "/dev",
42
+ "/proc",
43
+ "/sys",
44
+ "/bin",
45
+ "/sbin",
46
+ "/lib",
47
+ "/lib64",
48
+ "/run",
49
+ ];
50
+ if (systemRoots.some((system) => target === resolve(system) ||
51
+ strictlyInside(resolve(system), target) ||
52
+ strictlyInside(target, resolve(system))))
53
+ throw new Error(`System files are protected: ${target}`);
54
+ if (!strictlyInside(root, target))
55
+ throw new Error(`Target must be strictly inside --root: ${target}`);
56
+ if (target === cwd || strictlyInside(target, cwd))
57
+ throw new Error(`Cannot remove the working directory or its ancestor: ${target}`);
58
+ if (targets.some((other) => other !== target && strictlyInside(other, target)) ||
59
+ targets.filter((other) => other === target).length > 1)
60
+ throw new Error("Targets must not duplicate or contain one another");
61
+ const stat = lstatSync(target);
62
+ if (stat.dev !== rootStat.dev)
63
+ throw new Error(`Target crosses a mount boundary: ${target}`);
64
+ if (stat.isDirectory() && !options.recursive)
65
+ throw new Error(`Directory removal requires --recursive: ${target}`);
66
+ const device = stat.dev;
67
+ devices.add(device);
68
+ const pending = [target];
69
+ while (pending.length) {
70
+ const path = pending.pop();
71
+ const info = lstatSync(path);
72
+ if (basename(path) === ARTIFACT_RECORD && insideArtifactWorkspace(path, null))
73
+ throw new Error(`An artifact's record is managed by the artifact commands: ${path}`);
74
+ if (protectedNames.has(basename(path).toLowerCase())) {
75
+ if (!allowed.has(path))
76
+ throw new Error(protectedMessage(path));
77
+ removedProtected.add(path);
78
+ }
79
+ if (info.isSymbolicLink()) {
80
+ // A link inside the tree is removed as a link; its target is never
81
+ // read, followed, or counted. (The target itself and its ancestors
82
+ // were already refused if they were links, in checkedPath.)
83
+ if (path === target)
84
+ throw new Error(`Path contains a symlink: ${path}`);
85
+ if (snapshots.size >= MAX_ENTRIES)
86
+ throw new Error(`Removal exceeds the ${MAX_ENTRIES}-entry limit; use the owning cleanup command`);
87
+ snapshots.set(path, fingerprint(info));
88
+ links.push({ path, target: readlinkSync(path) });
89
+ continue;
90
+ }
91
+ if (info.dev !== device || (!info.isDirectory() && !info.isFile()))
92
+ throw new Error(`Mount boundary or special file cannot be removed: ${path}`);
93
+ if (snapshots.size >= MAX_ENTRIES)
94
+ throw new Error(`Removal exceeds the ${MAX_ENTRIES}-entry limit; use the owning cleanup command`);
95
+ snapshots.set(path, fingerprint(info));
96
+ if (info.isFile())
97
+ bytes += info.size;
98
+ else {
99
+ const names = readdirSync(path);
100
+ if (names.includes("HEAD") &&
101
+ names.includes("objects") &&
102
+ names.includes("config") &&
103
+ !insideAllowed(path, allowed))
104
+ throw new Error(`Repository metadata cannot be removed: ${path}. To remove it on purpose, name it with --allow-protected ${path}`);
105
+ if (allowed.has(path))
106
+ removedProtected.add(path);
107
+ if (isRepositoryMetadata(path))
108
+ gitDirs.add(path);
109
+ for (const name of names)
110
+ pending.push(resolve(path, name));
111
+ }
112
+ }
113
+ assertUntracked(target);
114
+ }
115
+ if (devices.size > 1)
116
+ throw new Error("Targets must be on one filesystem");
117
+ for (const path of allowed)
118
+ if (!removedProtected.has(path))
119
+ throw new Error(`--allow-protected names a path no target removes: ${path}`);
120
+ for (const dir of gitDirs)
121
+ assertNoUnpushedHistory(dir);
122
+ checkClaims(targets);
123
+ const report = {
124
+ schema: "harnery.removal/v1",
125
+ applied: false,
126
+ root,
127
+ targets,
128
+ entries: snapshots.size,
129
+ bytes,
130
+ protected: [...removedProtected].sort(),
131
+ links,
132
+ };
133
+ if (!options.yes || options.dryRun)
134
+ return report;
135
+ // Recheck the entire batch before its first mutation. Filesystem changes after
136
+ // these checks remain possible; this is a guard, not a transactional filesystem.
137
+ if (checkedPath(options.root, new Set(), true) !== root ||
138
+ fingerprint(lstatSync(root)) !== rootFingerprint)
139
+ throw new Error("Allowed root changed during inspection");
140
+ for (const target of targets) {
141
+ checkedPath(target, allowed);
142
+ assertUntracked(target);
143
+ }
144
+ for (const dir of gitDirs)
145
+ assertNoUnpushedHistory(dir);
146
+ checkClaims(targets);
147
+ for (const [path, previous] of snapshots) {
148
+ if (fingerprint(lstatSync(path)) !== previous)
149
+ throw new Error(`Target changed during inspection: ${path}`);
150
+ }
151
+ for (const target of targets)
152
+ rmSync(target, { recursive: Boolean(options.recursive), force: false });
153
+ return { ...report, applied: true };
154
+ }
155
+ /** The metadata file the artifact commands keep in each artifact workspace. */
156
+ const ARTIFACT_RECORD = ".harnery-artifact.json";
157
+ /**
158
+ * True when `path` lies strictly inside one artifact workspace,
159
+ * <dir>/.harnery/artifacts/<id>/..., under the `.harnery` component `state`
160
+ * (any `.harnery` when null). Artifact workspaces hold local outputs, so their
161
+ * contents may be removed; the workspace itself, its record file, and the rest
162
+ * of Harnery state stay protected and follow the artifact commands. `orSelf`
163
+ * accepts the workspace directory itself (used for --root, which is never removed).
164
+ */
165
+ function insideArtifactWorkspace(path, state, orSelf = false) {
166
+ const parts = path.split(sep);
167
+ for (let i = 0; i < parts.length; i++) {
168
+ if (parts[i].toLowerCase() !== ".harnery")
169
+ continue;
170
+ if (state !== null && parts.slice(0, i + 1).join(sep) !== state)
171
+ continue;
172
+ // .harnery / artifacts / <id> / <something>
173
+ return (parts[i + 1] === "artifacts" && Boolean(parts[i + 2]) && parts.length >= i + (orSelf ? 3 : 4));
174
+ }
175
+ return false;
176
+ }
177
+ function protectedMessage(path) {
178
+ return `Protected state cannot be removed: ${path}. To remove it on purpose, name it with --allow-protected ${path}`;
179
+ }
180
+ function insideAllowed(path, allowed) {
181
+ for (const entry of allowed)
182
+ if (path === entry || strictlyInside(entry, path))
183
+ return true;
184
+ return false;
185
+ }
186
+ /**
187
+ * Resolves the caller's --allow-protected paths. Each must be one exact
188
+ * protected path strictly inside the root, reached without symlinks, and
189
+ * never the Git metadata or Harnery state of a live workspace root.
190
+ */
191
+ function allowedProtected(paths, root, liveRoots) {
192
+ const allowed = new Set();
193
+ const live = liveRoots.map((dir) => resolve(dir));
194
+ for (const input of paths) {
195
+ if (!input?.trim() || /[\0\r\n*?[\]$]/.test(input))
196
+ throw new Error("Use a nonempty explicit --allow-protected path without wildcards");
197
+ const path = resolve(input);
198
+ let cursor = dirname(path);
199
+ while (true) {
200
+ if (lstatSync(cursor).isSymbolicLink())
201
+ throw new Error(`--allow-protected path contains a symlink: ${cursor}`);
202
+ const parent = dirname(cursor);
203
+ if (parent === cursor)
204
+ break;
205
+ cursor = parent;
206
+ }
207
+ const info = lstatSync(path);
208
+ if (info.isSymbolicLink())
209
+ throw new Error(`--allow-protected path is a symlink: ${path}`);
210
+ if (!strictlyInside(root, path))
211
+ throw new Error(`--allow-protected path must be strictly inside --root: ${path}`);
212
+ const named = protectedNames.has(basename(path).toLowerCase());
213
+ if (!named && !(info.isDirectory() && isRepositoryMetadata(path)))
214
+ throw new Error(`--allow-protected must name a protected path itself: ${path}`);
215
+ for (const dir of live) {
216
+ const own = [".git", ".harnery"].map((name) => resolve(dir, name));
217
+ if (path === dir ||
218
+ strictlyInside(path, dir) ||
219
+ own.some((state) => path === state || strictlyInside(state, path)))
220
+ throw new Error(`The live workspace's own state cannot be allowed: ${path}`);
221
+ }
222
+ allowed.add(realpathSync(path));
223
+ }
224
+ return allowed;
225
+ }
226
+ function isRepositoryMetadata(dir) {
227
+ const names = readdirSync(dir);
228
+ return names.includes("HEAD") && names.includes("objects") && names.includes("config");
229
+ }
230
+ /**
231
+ * Removing a repository's metadata loses any commit that exists nowhere
232
+ * else. An allowed Git directory must have no branch or tag commit missing
233
+ * from its remotes; anything that cannot be checked is refused.
234
+ */
235
+ function assertNoUnpushedHistory(gitDir) {
236
+ const result = spawnSync("git", [
237
+ "--git-dir",
238
+ gitDir,
239
+ "rev-list",
240
+ "--max-count=1",
241
+ "--branches",
242
+ "--tags",
243
+ "--not",
244
+ "--remotes",
245
+ ], {
246
+ encoding: "utf8",
247
+ timeout: 10_000,
248
+ env: { ...process.env, LC_ALL: "C", GIT_DIR: undefined, GIT_WORK_TREE: undefined },
249
+ });
250
+ if (result.error || result.status !== 0)
251
+ throw new Error(`Cannot confirm ${gitDir} holds no unpushed history`);
252
+ if (result.stdout.trim())
253
+ throw new Error(`Repository has commits not on any remote: ${gitDir}`);
254
+ }
255
+ function strictlyInside(parent, child) {
256
+ const rel = relative(parent, child);
257
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
258
+ }
259
+ function checkedPath(input, allowed = new Set(), isRoot = false) {
260
+ if (!input?.trim() || /[\0\r\n*?[\]$]/.test(input))
261
+ throw new Error("Use a nonempty explicit path without wildcards or unresolved variables");
262
+ const path = resolve(input);
263
+ let cursor = path;
264
+ while (true) {
265
+ if (protectedNames.has(basename(cursor).toLowerCase()) &&
266
+ !allowed.has(cursor) &&
267
+ !insideArtifactWorkspace(path, cursor, isRoot))
268
+ throw new Error(cursor === path ? protectedMessage(path) : `Protected state contains this path: ${cursor}`);
269
+ const info = lstatSync(cursor);
270
+ if (info.isSymbolicLink())
271
+ throw new Error(`Path contains a symlink: ${cursor}`);
272
+ const parent = dirname(cursor);
273
+ if (parent === cursor)
274
+ break;
275
+ cursor = parent;
276
+ }
277
+ return realpathSync(path);
278
+ }
279
+ function fingerprint(stat) {
280
+ return [stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeMs, stat.ctimeMs].join(":");
281
+ }
282
+ function assertUntracked(target) {
283
+ const git = (args) => spawnSync("git", ["-C", dirname(target), ...args], {
284
+ encoding: "utf8",
285
+ timeout: 10_000,
286
+ maxBuffer: 4 * 1024 * 1024,
287
+ env: {
288
+ ...process.env,
289
+ LC_ALL: "C",
290
+ GIT_LITERAL_PATHSPECS: "1",
291
+ GIT_DIR: undefined,
292
+ GIT_WORK_TREE: undefined,
293
+ GIT_INDEX_FILE: undefined,
294
+ },
295
+ });
296
+ const detected = git(["rev-parse", "--show-toplevel"]);
297
+ if (detected.error)
298
+ throw new Error(`Cannot check Git ownership: ${detected.error.message}`);
299
+ if (detected.status !== 0) {
300
+ if (detected.status === 128 && detected.stderr.startsWith("fatal: not a git repository"))
301
+ return;
302
+ throw new Error(`Cannot check Git ownership: ${detected.stderr.trim()}`);
303
+ }
304
+ const repo = detected.stdout.trim();
305
+ const tracked = git(["ls-files", "--cached", "-z", "--full-name", "--", target]);
306
+ if (tracked.error || tracked.status !== 0)
307
+ throw new Error(`Cannot inspect Git index in ${repo}`);
308
+ if (tracked.stdout.length)
309
+ throw new Error(`Tracked files are protected; use git rm for intentional source removal: ${target}`);
310
+ }
@@ -67,8 +67,7 @@ export function renderInstructionsBlock(binName, skills = { decide: true, counci
67
67
  return `## harnery coordination
68
68
 
69
69
  This project runs [harnery](https://harnery.com) for multi-agent coordination.
70
- You share this checkout with other agents; the surfaces below keep you oriented
71
- and out of each other's way, and let you dispatch a team of your own when a job
70
+ You share this checkout with other agents; the surfaces below keep you oriented and out of each other's way, and let you dispatch a team of your own when a job
72
71
  is bigger than one session. Run \`${b} <command> --help\` for any command's full
73
72
  surface. ${deeper}
74
73
 
@@ -81,8 +80,7 @@ widely-shared files.
81
80
  **Task lifecycle.** Beside the activity peers already see, declare whether your
82
81
  objective is still open: \`${b} agents lifecycle blocked --reason "<why>"\` when
83
82
  it cannot proceed, \`${b} agents lifecycle done\` when it is complete, and
84
- \`${b} agents lifecycle active\` to reopen. \`done\` requires a current task and
85
- a passing Git finalization check (dirty or unpushed work refuses, and nothing is
83
+ \`${b} agents lifecycle active\` to reopen. \`done\` requires a current task and a passing Git finalization check (dirty or unpushed work refuses, and nothing is
86
84
  written). Ordinary \`set-task\` calls never change lifecycle, and a transition
87
85
  that re-mints the session title tells you the new name to copy. ${endPointer}
88
86
 
@@ -107,8 +105,7 @@ It reclaims an abandoned namesake (no live process) and refuses only when anothe
107
105
  live process still holds the name; never hand-edit Harnery's history, heartbeat,
108
106
  or derived identity cache.
109
107
 
110
- **Declare intent on shell commands.** Every command you run is captured to the
111
- coordination ledger (\`.harnery/ledgers/v3/\`). Lead a shell command with a
108
+ **Declare intent on shell commands.** Every command you run is captured to the coordination ledger (\`.harnery/ledgers/v3/\`). Lead a shell command with a
112
109
  \`# intent: <why>\` comment (or set the tool's description) so the recorded event
113
110
  carries a reason instead of \`(no intent)\`; the [tool-intent
114
111
  guide](https://harnery.com/guides/tool-intent/) owns the details.
@@ -132,6 +129,10 @@ After review, use \`${b} artifacts discard <id> --reason "<why no longer needed>
132
129
  Do not create a repo-root temp directory; \`${b} artifacts clean\` previews
133
130
  expired cleanup and requires \`--yes\` to delete anything.
134
131
 
132
+ **Removing local outputs.** Use \`${b} rm --root <directory> <paths...>\` instead of shell \`rm\`, which skips resolved-path, Git ownership, and peer-claim checks. Preview
133
+ is the default; \`--yes\` deletes permanently and \`--recursive\` permits directories.
134
+ Use \`git rm\` for tracked source and the artifact commands for managed evidence.
135
+
135
136
  **Local file links.** When the operator should open a local repo file, mint the
136
137
  URL with \`${b} files url <repo-relative-path>\` instead of guessing it. HTML opens
137
138
  as a real page with working scripts and relative assets; other files open in the
@@ -142,8 +143,7 @@ can't resolve from the repo, file it instead. \`${b} decision file "<question>"\
142
143
  records it and lets you proceed on a stated default; \`${b} decision search "<terms>"\`
143
144
  surfaces prior decisions, so check for precedent before re-deciding. ${decidePointer}
144
145
 
145
- **Councils.** For a hard or contested decision, convene a council of agents.
146
- \`${b} council create "<objective>"\` runs structured rounds toward a decision. ${councilPointer}`;
146
+ **Councils.** For a hard or contested decision, convene a council of agents. \`${b} council create "<objective>"\` runs structured rounds toward a decision. ${councilPointer}`;
147
147
  }
148
148
  function decideBody(b) {
149
149
  return `The decision docket is a persistent queue for decisions you would otherwise
@@ -1,5 +1,5 @@
1
1
  export interface TunnelErrorPageOptions {
2
- kind: "access-denied" | "upstream-unavailable";
2
+ kind: "access-denied" | "path-denied" | "upstream-unavailable";
3
3
  incidentId: string;
4
4
  timestamp: string;
5
5
  tunnelName: string;
@@ -1 +1 @@
1
- {"version":3,"file":"error-page.d.ts","sourceRoot":"","sources":["../../../src/lib/tunnel/error-page.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,eAAe,GAAG,sBAAsB,CAAC;IAC/C,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AA6BD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,MAAM,CAwE7E"}
1
+ {"version":3,"file":"error-page.d.ts","sourceRoot":"","sources":["../../../src/lib/tunnel/error-page.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,eAAe,GAAG,aAAa,GAAG,sBAAsB,CAAC;IAC/D,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AA6BD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,MAAM,CAwF7E"}
@@ -25,14 +25,25 @@ function diagnosticText(options) {
25
25
  return lines.join("\n");
26
26
  }
27
27
  export function renderTunnelErrorPage(options) {
28
- const denied = options.kind === "access-denied";
29
- const title = denied ? "This device is not allowed yet" : "The preview is temporarily offline";
30
- const summary = denied
31
- ? "The tunnel is running, but this device's public IP is not on its access list."
32
- : "The public tunnel and access check are working, but the local preview server is not responding.";
33
- const nextStep = denied
34
- ? "Copy the diagnostic below and send it to the person running the tunnel. It includes the public IP that needs to be allowed."
35
- : "Copy the diagnostic below and send it to the person running the tunnel. The incident ID can be matched to the server log.";
28
+ const copy = {
29
+ "access-denied": {
30
+ title: "This device is not allowed yet",
31
+ summary: "The tunnel is running, but this device's public IP is not on its access list.",
32
+ nextStep: "Copy the diagnostic below and send it to the person running the tunnel. It includes the public IP that needs to be allowed.",
33
+ },
34
+ "path-denied": {
35
+ title: "This page is not shared",
36
+ summary: "The tunnel is running, but it shares only part of the site, and this address is outside it.",
37
+ nextStep: "If you expected to see this page, copy the diagnostic below and send it to the person running the tunnel.",
38
+ },
39
+ "upstream-unavailable": {
40
+ title: "The preview is temporarily offline",
41
+ summary: "The public tunnel and access check are working, but the local preview server is not responding.",
42
+ nextStep: "Copy the diagnostic below and send it to the person running the tunnel. The incident ID can be matched to the server log.",
43
+ },
44
+ }[options.kind];
45
+ const { title, summary, nextStep } = copy;
46
+ const denied = options.kind !== "upstream-unavailable";
36
47
  const diagnostic = diagnosticText(options);
37
48
  const safeDiagnosticForScript = JSON.stringify(diagnostic).replaceAll("<", "\\u003c");
38
49
  return `<!doctype html>
@@ -4,13 +4,16 @@
4
4
  // stripped (Bun's fetch auto-decompresses the body but retains the encoding
5
5
  // header, which breaks browser decoding downstream). Cloudflare provider mode
6
6
  // checks CF-Connecting-IP against an allowlist; trusted local proxy mode lets
7
- // the local exposer own access.
7
+ // the local exposer own access. In every mode the gate then refuses any path
8
+ // outside the tunnel's path scope (see path-scope.ts); an empty scope refuses
9
+ // everything.
8
10
  //
9
11
  // Runs detached, outside the CLI command framework; no command context is
10
12
  // available; stdout/stderr is captured into .cache/tunnel/gate.log by the
11
13
  // spawner.
12
14
  import { renderTunnelErrorPage } from "./error-page";
13
15
  import { applyUpstreamHeaders } from "./forward-headers";
16
+ import { ALLOW_PATHS_ENV, isPathAllowed, parseAllowPathsEnv } from "./path-scope.js";
14
17
  // `--port`/`--name` are also passed on argv (not just env) so the gate's port
15
18
  // and instance name show up in its process command line. That's what lets
16
19
  // `tunnel down` scope its stray-process sweep to a single instance via
@@ -29,6 +32,7 @@ const TARGET = process.env.HARNERY_TUNNEL_TARGET ?? "127.0.0.1:8001";
29
32
  const VHOST = process.env.HARNERY_TUNNEL_VHOST ?? "localhost";
30
33
  const PORT = Number(process.env.HARNERY_TUNNEL_PORT ?? argvFlag("--port") ?? "9001");
31
34
  const ACCESS = process.env.HARNERY_TUNNEL_ACCESS ?? "cloudflare-allowlist";
35
+ const ALLOW_PATHS = parseAllowPathsEnv(process.env[ALLOW_PATHS_ENV]);
32
36
  const NAME = argvFlag("--name") ?? "default";
33
37
  const UPSTREAM_HTTP = `http://${TARGET}`;
34
38
  const UPSTREAM_WS = `ws://${TARGET}`;
@@ -79,6 +83,20 @@ const server = Bun.serve({
79
83
  });
80
84
  }
81
85
  }
86
+ if (!isPathAllowed(url.pathname, ALLOW_PATHS)) {
87
+ const details = requestDetails(req, url);
88
+ console.log(
89
+ // lint-ok-emission: detached worker, see file note above
90
+ `path-deny: incident=${details.incidentId} ip=${details.clientIp || "(missing-cf-connecting-ip)"} ray=${details.cloudflareRay || "(missing)"} ${req.method} ${details.path}`);
91
+ return new Response(renderTunnelErrorPage({ ...details, kind: "path-denied" }), {
92
+ status: 403,
93
+ headers: {
94
+ "cache-control": "no-store",
95
+ "content-type": "text/html; charset=utf-8",
96
+ "x-harnery-tunnel-incident": details.incidentId,
97
+ },
98
+ });
99
+ }
82
100
  if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
83
101
  if (server.upgrade(req, {
84
102
  data: { path: url.pathname + url.search, originalHost: req.headers.get("host") ?? "" },
@@ -168,4 +186,5 @@ const server = Bun.serve({
168
186
  // captured into .cache/tunnel/gate.log by the spawning command.
169
187
  console.log(`harn-tunnel-gate :${server.port} -> ${UPSTREAM_HTTP} (Host: ${VHOST})`); // lint-ok-emission: detached worker, see file note above
170
188
  console.log(`access: ${ACCESS}`); // lint-ok-emission: detached worker, see file note above
189
+ console.log(`paths: ${ALLOW_PATHS.join(", ") || "(empty, every path refused)"}`); // lint-ok-emission: detached worker, see file note above
171
190
  console.log(`allow: ${[...ALLOW].join(", ") || "(empty, not used outside allowlist mode)"}`); // lint-ok-emission: detached worker, see file note above
@@ -0,0 +1,22 @@
1
+ /** Env var that carries the scope from `tunnel up` to the detached gate. */
2
+ export declare const ALLOW_PATHS_ENV = "HARNERY_TUNNEL_ALLOW_PATHS";
3
+ /**
4
+ * Normalize one `--allow-path` value to a canonical prefix: a leading slash,
5
+ * no trailing slash (except `/` itself), no query or fragment, and no dot,
6
+ * empty, or percent-encoded segment. Throws on anything the gate could not
7
+ * match unambiguously, so a typo fails at `tunnel up` instead of publishing
8
+ * more or less than intended.
9
+ */
10
+ export declare function normalizeAllowPath(raw: string): string;
11
+ /** Normalize and de-duplicate a list of `--allow-path` values. */
12
+ export declare function normalizeAllowPaths(raw: readonly string[]): string[];
13
+ /** Parse the gate's env value (comma-separated, already normalized by `up`). */
14
+ export declare function parseAllowPathsEnv(value: string | undefined): string[];
15
+ /**
16
+ * True when `pathname` (a WHATWG-parsed URL pathname, so `.` and `..` segments
17
+ * are already resolved) falls inside one of the allowed prefixes. A prefix
18
+ * matches itself and anything below it on a segment boundary: `/share` allows
19
+ * `/share` and `/share/x`, never `/shared`.
20
+ */
21
+ export declare function isPathAllowed(pathname: string, allowPaths: readonly string[]): boolean;
22
+ //# sourceMappingURL=path-scope.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"path-scope.d.ts","sourceRoot":"","sources":["../../../src/lib/tunnel/path-scope.ts"],"names":[],"mappings":"AAQA,4EAA4E;AAC5E,eAAO,MAAM,eAAe,+BAA+B,CAAC;AAE5D;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAmBtD;AAED,kEAAkE;AAClE,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAEpE;AAED,gFAAgF;AAChF,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAOtE;AAQD;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAMtF"}
@@ -0,0 +1,62 @@
1
+ // Path scope for the tunnel gate: the URL path prefixes a tunnel publishes.
2
+ //
3
+ // A tunnel forwards to a whole local app, but the person starting it usually
4
+ // means to share one surface of that app. The gate therefore refuses every
5
+ // request whose path is outside the scope the operator named at `tunnel up`,
6
+ // and an empty scope refuses everything. `/` is the explicit way to publish
7
+ // the whole upstream.
8
+ /** Env var that carries the scope from `tunnel up` to the detached gate. */
9
+ export const ALLOW_PATHS_ENV = "HARNERY_TUNNEL_ALLOW_PATHS";
10
+ /**
11
+ * Normalize one `--allow-path` value to a canonical prefix: a leading slash,
12
+ * no trailing slash (except `/` itself), no query or fragment, and no dot,
13
+ * empty, or percent-encoded segment. Throws on anything the gate could not
14
+ * match unambiguously, so a typo fails at `tunnel up` instead of publishing
15
+ * more or less than intended.
16
+ */
17
+ export function normalizeAllowPath(raw) {
18
+ const value = raw.trim();
19
+ if (!value.startsWith("/")) {
20
+ throw new Error(`allow-path must start with "/": ${JSON.stringify(raw)}`);
21
+ }
22
+ if (/[?#%\\]/.test(value)) {
23
+ throw new Error(`allow-path must be a plain path prefix without ?, #, %, or \\: ${JSON.stringify(raw)}`);
24
+ }
25
+ if (value === "/")
26
+ return "/";
27
+ const trimmed = value.replace(/\/+$/, "");
28
+ const segments = trimmed.slice(1).split("/");
29
+ if (segments.some((s) => s === "" || s === "." || s === "..")) {
30
+ throw new Error(`allow-path must not contain empty, "." or ".." segments: ${JSON.stringify(raw)}`);
31
+ }
32
+ return trimmed;
33
+ }
34
+ /** Normalize and de-duplicate a list of `--allow-path` values. */
35
+ export function normalizeAllowPaths(raw) {
36
+ return [...new Set(raw.map(normalizeAllowPath))];
37
+ }
38
+ /** Parse the gate's env value (comma-separated, already normalized by `up`). */
39
+ export function parseAllowPathsEnv(value) {
40
+ return normalizeAllowPaths((value ?? "")
41
+ .split(",")
42
+ .map((s) => s.trim())
43
+ .filter(Boolean));
44
+ }
45
+ // Encoded slash, backslash, dot, or percent. A request path carrying one of
46
+ // these can be decoded differently by the gate and the upstream router (for
47
+ // example `/share%2F..%2Fprivate`), so the gate refuses it outright rather than
48
+ // guess which reading the upstream will use.
49
+ const AMBIGUOUS_PATH = /%(2f|5c|2e|25)|\\/i;
50
+ /**
51
+ * True when `pathname` (a WHATWG-parsed URL pathname, so `.` and `..` segments
52
+ * are already resolved) falls inside one of the allowed prefixes. A prefix
53
+ * matches itself and anything below it on a segment boundary: `/share` allows
54
+ * `/share` and `/share/x`, never `/shared`.
55
+ */
56
+ export function isPathAllowed(pathname, allowPaths) {
57
+ if (allowPaths.length === 0)
58
+ return false;
59
+ if (!pathname.startsWith("/") || AMBIGUOUS_PATH.test(pathname))
60
+ return false;
61
+ return allowPaths.some((prefix) => prefix === "/" || pathname === prefix || pathname.startsWith(`${prefix}/`));
62
+ }
@@ -21,6 +21,8 @@ export interface TunnelState {
21
21
  target: string;
22
22
  vhost: string;
23
23
  gate_port: number;
24
+ /** URL path prefixes the gate forwards; every other path is refused. */
25
+ allow_paths: string[];
24
26
  tailscale_mode?: TailscaleMode;
25
27
  tailscale_path?: string;
26
28
  tailscale_https_port?: number;
@@ -39,6 +41,7 @@ export declare function isProcessAlive(pid: number): boolean;
39
41
  export type ProcessAliveCheck = (pid: number) => boolean;
40
42
  /** True when both the local gate and its provider-side process are live. */
41
43
  export declare function isTunnelStateLive(state: TunnelState, processAlive?: ProcessAliveCheck): boolean;
44
+ export declare function tunnelTargetPort(state: TunnelState): number | null;
42
45
  /**
43
46
  * Newest live tunnel that serves `vhost` on the requested local web port.
44
47
  *
@@ -51,6 +54,8 @@ export declare function isTunnelStateLive(state: TunnelState, processAlive?: Pro
51
54
  * actually serves the caller's origin.
52
55
  */
53
56
  export declare function findLiveTunnelForOrigin(port: number, vhost: string, root?: string, processAlive?: ProcessAliveCheck): TunnelState | null;
57
+ /** True when the tunnel's path scope forwards every one of `paths`. */
58
+ export declare function tunnelServesPaths(state: TunnelState, paths: readonly string[]): boolean;
54
59
  /**
55
60
  * Ensure cloudflared is on PATH or installed at ~/.local/bin/cloudflared.
56
61
  * Auto-downloads on Linux; throws on other platforms with brew hint.
@@ -1 +1 @@
1
- {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../../src/lib/tunnel/state.ts"],"names":[],"mappings":"AAwBA,0DAA0D;AAC1D,eAAO,MAAM,gBAAgB,YAAY,CAAC;AAW1C,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE/C;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,cAAc,GAAG,MAAM,CAG9E;AAgBD,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,MAAM,MAAM,cAAc,GAAG,YAAY,GAAG,WAAW,CAAC;AACxD,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,QAAQ,CAAC;AAE/C,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,cAAc,CAAC;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,+EAA+E;IAC/E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,+DAA+D;IAC/D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,aAAa,CAAC;IAC/B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAOD,wBAAgB,UAAU,IAAI,YAAY,CAWzC;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI,CAEnD;AAED,wBAAgB,SAAS,CACvB,IAAI,GAAE,MAAyB,EAC/B,IAAI,GAAE,MAAsB,GAC3B,WAAW,GAAG,IAAI,CAQpB;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,GAAE,MAAsB,GAAG,IAAI,CAEjF;AAED,wBAAgB,UAAU,CAAC,IAAI,GAAE,MAAyB,EAAE,IAAI,GAAE,MAAsB,GAAG,IAAI,CAG9F;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,IAAI,GAAE,MAAsB,GAAG,WAAW,EAAE,CAWtE;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAOnD;AAED,MAAM,MAAM,iBAAiB,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;AAEzD,4EAA4E;AAC5E,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,WAAW,EAClB,YAAY,GAAE,iBAAkC,GAC/C,OAAO,CAKT;AAeD;;;;;;;;;;GAUG;AACH,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,IAAI,GAAE,MAAsB,EAC5B,YAAY,GAAE,iBAAkC,GAC/C,WAAW,GAAG,IAAI,CASpB;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAuB1C"}
1
+ {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../../src/lib/tunnel/state.ts"],"names":[],"mappings":"AAyBA,0DAA0D;AAC1D,eAAO,MAAM,gBAAgB,YAAY,CAAC;AAW1C,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE/C;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,cAAc,GAAG,MAAM,CAG9E;AAgBD,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,MAAM,MAAM,cAAc,GAAG,YAAY,GAAG,WAAW,CAAC;AACxD,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,QAAQ,CAAC;AAE/C,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,cAAc,CAAC;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,+EAA+E;IAC/E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,+DAA+D;IAC/D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,wEAAwE;IACxE,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,cAAc,CAAC,EAAE,aAAa,CAAC;IAC/B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAgBD,wBAAgB,UAAU,IAAI,YAAY,CAWzC;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI,CAEnD;AAED,wBAAgB,SAAS,CACvB,IAAI,GAAE,MAAyB,EAC/B,IAAI,GAAE,MAAsB,GAC3B,WAAW,GAAG,IAAI,CAQpB;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,GAAE,MAAsB,GAAG,IAAI,CAEjF;AAED,wBAAgB,UAAU,CAAC,IAAI,GAAE,MAAyB,EAAE,IAAI,GAAE,MAAsB,GAAG,IAAI,CAG9F;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,IAAI,GAAE,MAAsB,GAAG,WAAW,EAAE,CAWtE;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAOnD;AAED,MAAM,MAAM,iBAAiB,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;AAEzD,4EAA4E;AAC5E,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,WAAW,EAClB,YAAY,GAAE,iBAAkC,GAC/C,OAAO,CAKT;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,CAWlE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,IAAI,GAAE,MAAsB,EAC5B,YAAY,GAAE,iBAAkC,GAC/C,WAAW,GAAG,IAAI,CASpB;AAED,uEAAuE;AACvE,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAEvF;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAuB1C"}
@@ -3,6 +3,7 @@
3
3
  import { execSync } from "node:child_process";
4
4
  import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
5
5
  import { resolve } from "node:path";
6
+ import { isPathAllowed } from "./path-scope.js";
6
7
  // Tunnel state lives under <root>/.cache/tunnel/. Root defaults to cwd for the
7
8
  // command surface and can be supplied by callers that already resolved a repo.
8
9
  function cachePath(tool, filename, root = process.cwd()) {
@@ -46,9 +47,18 @@ function nameFromStateFile(file) {
46
47
  const DEFAULT_CONFIG = {
47
48
  allowed_ips: [],
48
49
  };
49
- /** Normalize a parsed state blob; supply `name` for pre-multi-instance files. */
50
+ /**
51
+ * Normalize a parsed state blob; supply `name` for pre-multi-instance files.
52
+ * A state written before path scopes existed reads as an empty scope, which
53
+ * the gate treats as "refuse every path".
54
+ */
50
55
  function normalizeState(raw, fallbackName) {
51
- return { ...raw, name: raw.name ?? fallbackName, provider: raw.provider ?? "cloudflare" };
56
+ return {
57
+ ...raw,
58
+ name: raw.name ?? fallbackName,
59
+ provider: raw.provider ?? "cloudflare",
60
+ allow_paths: Array.isArray(raw.allow_paths) ? raw.allow_paths : [],
61
+ };
52
62
  }
53
63
  export function readConfig() {
54
64
  const p = cachePath("tunnel", CONFIG_FILE);
@@ -122,7 +132,7 @@ export function isTunnelStateLive(state, processAlive = isProcessAlive) {
122
132
  const providerPid = state.cloudflared_pid ?? state.provider_pid;
123
133
  return typeof providerPid === "number" && processAlive(providerPid);
124
134
  }
125
- function tunnelTargetPort(state) {
135
+ export function tunnelTargetPort(state) {
126
136
  const value = /^[a-z][a-z\d+.-]*:\/\//i.test(state.target)
127
137
  ? state.target
128
138
  : `http://${state.target}`;
@@ -152,6 +162,10 @@ export function findLiveTunnelForOrigin(port, vhost, root = process.cwd(), proce
152
162
  state.vhost === vhost &&
153
163
  isTunnelStateLive(state, processAlive)) ?? null);
154
164
  }
165
+ /** True when the tunnel's path scope forwards every one of `paths`. */
166
+ export function tunnelServesPaths(state, paths) {
167
+ return paths.every((path) => isPathAllowed(path, state.allow_paths));
168
+ }
155
169
  /**
156
170
  * Ensure cloudflared is on PATH or installed at ~/.local/bin/cloudflared.
157
171
  * Auto-downloads on Linux; throws on other platforms with brew hint.