@naswerks/periscope 1.0.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 (230) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +146 -0
  4. package/SECURITY.md +315 -0
  5. package/dist/bin/command.d.ts +97 -0
  6. package/dist/bin/command.js +141 -0
  7. package/dist/bin/config.d.ts +7 -0
  8. package/dist/bin/config.js +90 -0
  9. package/dist/bin/login.d.ts +48 -0
  10. package/dist/bin/login.js +82 -0
  11. package/dist/bin/main.d.ts +33 -0
  12. package/dist/bin/main.js +173 -0
  13. package/dist/bin/pair.d.ts +28 -0
  14. package/dist/bin/pair.js +198 -0
  15. package/dist/bin/periscope.d.ts +3 -0
  16. package/dist/bin/periscope.js +4 -0
  17. package/dist/bin/posture.d.ts +64 -0
  18. package/dist/bin/posture.js +157 -0
  19. package/dist/bin/reconfigure.d.ts +64 -0
  20. package/dist/bin/reconfigure.js +195 -0
  21. package/dist/bin/serve.d.ts +103 -0
  22. package/dist/bin/serve.js +441 -0
  23. package/dist/bin/status.d.ts +11 -0
  24. package/dist/bin/status.js +49 -0
  25. package/dist/bin/workspaces.d.ts +109 -0
  26. package/dist/bin/workspaces.js +215 -0
  27. package/dist/control/backoff.d.ts +25 -0
  28. package/dist/control/backoff.js +20 -0
  29. package/dist/control/codec.d.ts +15 -0
  30. package/dist/control/codec.js +501 -0
  31. package/dist/control/credential.d.ts +23 -0
  32. package/dist/control/credential.js +8 -0
  33. package/dist/control/frames.d.ts +1180 -0
  34. package/dist/control/frames.js +441 -0
  35. package/dist/control/link-state.d.ts +32 -0
  36. package/dist/control/link-state.js +39 -0
  37. package/dist/control/link.d.ts +109 -0
  38. package/dist/control/link.js +736 -0
  39. package/dist/control/queue.d.ts +110 -0
  40. package/dist/control/queue.js +174 -0
  41. package/dist/control/seq.d.ts +49 -0
  42. package/dist/control/seq.js +52 -0
  43. package/dist/control/stream-routing.d.ts +200 -0
  44. package/dist/control/stream-routing.js +167 -0
  45. package/dist/control/stream.d.ts +43 -0
  46. package/dist/control/stream.js +64 -0
  47. package/dist/core/async-queue.d.ts +26 -0
  48. package/dist/core/async-queue.js +63 -0
  49. package/dist/core/failure.d.ts +22 -0
  50. package/dist/core/failure.js +53 -0
  51. package/dist/core/index.d.ts +18 -0
  52. package/dist/core/index.js +7 -0
  53. package/dist/core/keyed-turns.d.ts +28 -0
  54. package/dist/core/keyed-turns.js +44 -0
  55. package/dist/core/paths.d.ts +25 -0
  56. package/dist/core/paths.js +77 -0
  57. package/dist/core/refusal.d.ts +43 -0
  58. package/dist/core/refusal.js +399 -0
  59. package/dist/core/result.d.ts +22 -0
  60. package/dist/core/result.js +16 -0
  61. package/dist/core/time.d.ts +19 -0
  62. package/dist/core/time.js +25 -0
  63. package/dist/core/vocab.d.ts +23 -0
  64. package/dist/core/vocab.js +37 -0
  65. package/dist/core/workspace-id.d.ts +12 -0
  66. package/dist/core/workspace-id.js +12 -0
  67. package/dist/gate/command.d.ts +136 -0
  68. package/dist/gate/command.js +551 -0
  69. package/dist/gate/decision.d.ts +111 -0
  70. package/dist/gate/decision.js +97 -0
  71. package/dist/gate/escalate.d.ts +82 -0
  72. package/dist/gate/escalate.js +92 -0
  73. package/dist/gate/gate.d.ts +173 -0
  74. package/dist/gate/gate.js +417 -0
  75. package/dist/gate/index.d.ts +39 -0
  76. package/dist/gate/index.js +31 -0
  77. package/dist/gate/jail.d.ts +92 -0
  78. package/dist/gate/jail.js +131 -0
  79. package/dist/gate/local.d.ts +69 -0
  80. package/dist/gate/local.js +51 -0
  81. package/dist/gate/outcome.d.ts +72 -0
  82. package/dist/gate/outcome.js +91 -0
  83. package/dist/gate/shell.d.ts +36 -0
  84. package/dist/gate/shell.js +226 -0
  85. package/dist/host/agent-process.d.ts +452 -0
  86. package/dist/host/agent-process.js +382 -0
  87. package/dist/host/bulk-post.d.ts +44 -0
  88. package/dist/host/bulk-post.js +114 -0
  89. package/dist/host/claude-transcripts.d.ts +84 -0
  90. package/dist/host/claude-transcripts.js +339 -0
  91. package/dist/host/config-file.d.ts +60 -0
  92. package/dist/host/config-file.js +216 -0
  93. package/dist/host/git-facts.d.ts +7 -0
  94. package/dist/host/git-facts.js +106 -0
  95. package/dist/host/hooks.d.ts +56 -0
  96. package/dist/host/hooks.js +75 -0
  97. package/dist/host/host.d.ts +356 -0
  98. package/dist/host/host.js +1294 -0
  99. package/dist/host/index.d.ts +35 -0
  100. package/dist/host/index.js +22 -0
  101. package/dist/host/link-state-file.d.ts +18 -0
  102. package/dist/host/link-state-file.js +66 -0
  103. package/dist/host/loopback.d.ts +38 -0
  104. package/dist/host/loopback.js +122 -0
  105. package/dist/host/machine.d.ts +9 -0
  106. package/dist/host/machine.js +19 -0
  107. package/dist/host/mcp-server.d.ts +11 -0
  108. package/dist/host/mcp-server.js +48 -0
  109. package/dist/host/package-facts.d.ts +3 -0
  110. package/dist/host/package-facts.js +26 -0
  111. package/dist/host/paired-credential-store.d.ts +32 -0
  112. package/dist/host/paired-credential-store.js +112 -0
  113. package/dist/host/paths.d.ts +80 -0
  114. package/dist/host/paths.js +165 -0
  115. package/dist/host/repository-read.d.ts +33 -0
  116. package/dist/host/repository-read.js +201 -0
  117. package/dist/host/session-store.d.ts +53 -0
  118. package/dist/host/session-store.js +100 -0
  119. package/dist/host/sign-in.d.ts +90 -0
  120. package/dist/host/sign-in.js +239 -0
  121. package/dist/host/telemetry.d.ts +42 -0
  122. package/dist/host/telemetry.js +74 -0
  123. package/dist/host/token-cache.d.ts +62 -0
  124. package/dist/host/token-cache.js +185 -0
  125. package/dist/host/transcript-fs.d.ts +4 -0
  126. package/dist/host/transcript-fs.js +104 -0
  127. package/dist/host/wire-request.d.ts +80 -0
  128. package/dist/host/wire-request.js +196 -0
  129. package/dist/host/workspace-fs.d.ts +14 -0
  130. package/dist/host/workspace-fs.js +84 -0
  131. package/dist/host/workspace-trust.d.ts +23 -0
  132. package/dist/host/workspace-trust.js +80 -0
  133. package/dist/identity/authorize.d.ts +59 -0
  134. package/dist/identity/authorize.js +72 -0
  135. package/dist/identity/config.d.ts +98 -0
  136. package/dist/identity/config.js +130 -0
  137. package/dist/identity/credential.d.ts +91 -0
  138. package/dist/identity/credential.js +76 -0
  139. package/dist/identity/device-code.d.ts +115 -0
  140. package/dist/identity/device-code.js +134 -0
  141. package/dist/identity/index.d.ts +33 -0
  142. package/dist/identity/index.js +10 -0
  143. package/dist/identity/mode.d.ts +106 -0
  144. package/dist/identity/mode.js +69 -0
  145. package/dist/identity/paired-credential.d.ts +52 -0
  146. package/dist/identity/paired-credential.js +43 -0
  147. package/dist/identity/pkce.d.ts +50 -0
  148. package/dist/identity/pkce.js +94 -0
  149. package/dist/identity/store.d.ts +43 -0
  150. package/dist/identity/store.js +43 -0
  151. package/dist/identity/token.d.ts +58 -0
  152. package/dist/identity/token.js +149 -0
  153. package/dist/index.d.ts +45 -0
  154. package/dist/index.js +53 -0
  155. package/dist/mcp/descriptor.d.ts +59 -0
  156. package/dist/mcp/descriptor.js +14 -0
  157. package/dist/mcp/index.d.ts +14 -0
  158. package/dist/mcp/index.js +4 -0
  159. package/dist/mcp/schema.d.ts +37 -0
  160. package/dist/mcp/schema.js +175 -0
  161. package/dist/mcp/server.d.ts +107 -0
  162. package/dist/mcp/server.js +66 -0
  163. package/dist/persistence/entry.d.ts +74 -0
  164. package/dist/persistence/entry.js +105 -0
  165. package/dist/persistence/index.d.ts +22 -0
  166. package/dist/persistence/index.js +8 -0
  167. package/dist/persistence/key.d.ts +46 -0
  168. package/dist/persistence/key.js +33 -0
  169. package/dist/persistence/mirror.d.ts +71 -0
  170. package/dist/persistence/mirror.js +57 -0
  171. package/dist/persistence/receipt.d.ts +111 -0
  172. package/dist/persistence/receipt.js +85 -0
  173. package/dist/persistence/retention.d.ts +68 -0
  174. package/dist/persistence/retention.js +68 -0
  175. package/dist/persistence/store.d.ts +106 -0
  176. package/dist/persistence/store.js +86 -0
  177. package/dist/persistence/transition-log.d.ts +73 -0
  178. package/dist/persistence/transition-log.js +133 -0
  179. package/dist/protocol.d.ts +27 -0
  180. package/dist/protocol.js +12 -0
  181. package/dist/sessions/index.d.ts +8 -0
  182. package/dist/sessions/index.js +4 -0
  183. package/dist/sessions/registry.d.ts +186 -0
  184. package/dist/sessions/registry.js +190 -0
  185. package/dist/sessions/session.d.ts +178 -0
  186. package/dist/sessions/session.js +288 -0
  187. package/dist/sessions/spawn-env.d.ts +88 -0
  188. package/dist/sessions/spawn-env.js +277 -0
  189. package/dist/state/coverage.d.ts +308 -0
  190. package/dist/state/coverage.js +315 -0
  191. package/dist/state/index.d.ts +12 -0
  192. package/dist/state/index.js +7 -0
  193. package/dist/state/machine.d.ts +112 -0
  194. package/dist/state/machine.js +237 -0
  195. package/dist/state/model.d.ts +233 -0
  196. package/dist/state/model.js +214 -0
  197. package/dist/state/observer.d.ts +55 -0
  198. package/dist/state/observer.js +413 -0
  199. package/dist/state/reporter.d.ts +45 -0
  200. package/dist/state/reporter.js +35 -0
  201. package/dist/state/store.d.ts +56 -0
  202. package/dist/state/store.js +120 -0
  203. package/dist/telemetry/index.d.ts +11 -0
  204. package/dist/telemetry/index.js +2 -0
  205. package/dist/telemetry/usage.d.ts +78 -0
  206. package/dist/telemetry/usage.js +69 -0
  207. package/dist/workspace/git-worktree.d.ts +150 -0
  208. package/dist/workspace/git-worktree.js +417 -0
  209. package/dist/workspace/index.d.ts +9 -0
  210. package/dist/workspace/index.js +4 -0
  211. package/dist/workspace/plain-dir.d.ts +34 -0
  212. package/dist/workspace/plain-dir.js +90 -0
  213. package/dist/workspace/provider.d.ts +152 -0
  214. package/dist/workspace/provider.js +2 -0
  215. package/dist/workspace/worktree-porcelain.d.ts +29 -0
  216. package/dist/workspace/worktree-porcelain.js +100 -0
  217. package/docs/architecture.md +277 -0
  218. package/docs/configuration.md +187 -0
  219. package/docs/gate.md +219 -0
  220. package/docs/identity.md +107 -0
  221. package/docs/protocol.md +501 -0
  222. package/docs/state-machine.md +160 -0
  223. package/examples/README.md +141 -0
  224. package/examples/minimal-controller/controller.ts +38 -0
  225. package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
  226. package/examples/parallel-run-proof/run.ts +850 -0
  227. package/examples/test-controller/controller.ts +655 -0
  228. package/examples/test-controller/serve.ts +29 -0
  229. package/examples/tsconfig.json +30 -0
  230. package/package.json +98 -0
@@ -0,0 +1,185 @@
1
+ /**
2
+ * The file-backed token cache: one of the two places this package writes credential material to
3
+ * disk (the other is the paired-credential store beside it).
4
+ *
5
+ * What 0600 is and is not, because the honest version is load-bearing here. The agent runs as the
6
+ * same OS user as this host. File permissions are not a boundary against a process running as the
7
+ * same user: an 0600 token file is readable by the agent exactly as it is by the host. What 0600
8
+ * buys is keeping other OS users out. What keeps the agent out is the gate's credential-path
9
+ * denial, and that denial covers this file because `credentialPaths` and `tokenCachePath` are
10
+ * derived from one function in `paths.ts`, not kept equal by hand.
11
+ *
12
+ * The exposure is a different kind, not a smaller amount of the same one. Compared with a shared
13
+ * secret that grants access as every user, what is stored here is one user's own token, expiring
14
+ * on its own and revocable from the identity provider without touching this machine.
15
+ *
16
+ * Enforcement is probed, not assumed. `node` on win32 accepts a mode and ignores it, and reports
17
+ * the same `0o666` for a deliberately world-readable file as for one written `0o600`, so a
18
+ * verify-after-write there is either always-refusing or always-vacuous. This measures which world
19
+ * it is in once, and `identity/mode.ts` turns the answer into a named outcome.
20
+ */
21
+ import { chmodSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
22
+ import { ok, refuse } from '../core/result.js';
23
+ import { CREDENTIAL_MODE, classifyCredentialMode, classifyProbeReadings } from '../identity/mode.js';
24
+ import { readCachedTokens } from '../identity/store.js';
25
+ /** Directories holding credential material are owner-only too. */
26
+ const CREDENTIAL_DIR_MODE = 0o700;
27
+ /**
28
+ * Measure what this filesystem does with a mode.
29
+ *
30
+ * Measured rather than listed by platform. A `process.platform === 'win32'` test is a guess that
31
+ * ages: WSL, a POSIX filesystem mounted under Windows, and a future runtime mapping modes onto ACLs
32
+ * would each make it wrong in the direction that matters. The probe writes a throwaway file BESIDE
33
+ * the cache — the same filesystem, which is the thing actually in question.
34
+ *
35
+ * It asserts its positive control before it reports the negative, and that ordering is the whole
36
+ * design. Three measurements, in this order:
37
+ *
38
+ * 1. Ask for `0600` and for `0666`. If they read back differently AND `0600` came back as `0600`,
39
+ * POSIX modes are honoured. Done.
40
+ * 2. They did not differ — so privacy is unconfirmable. **Before saying so, prove the instrument
41
+ * is alive**: clear the write bit with `0444`. Measured on win32, that DOES read back as
42
+ * `444`, because the write bit is the one real bit there. If it changes, this is a genuine
43
+ * finding about a filesystem that records only writability.
44
+ * 3. Even `0444` changed nothing. Then the probe cannot distinguish "this filesystem records
45
+ * nothing" from "this probe is broken", and it says exactly that.
46
+ *
47
+ * Without step 2, "unenforced" and "the probe never worked" are the same answer — an instrument
48
+ * with no inconclusive state, which is how a dead check gets read as evidence.
49
+ *
50
+ * `0444` is the control, never the target. The cache must stay owner-writable; refreshes are
51
+ * written to it. The probe restores a writable mode on its own throwaway file before removing it,
52
+ * because a read-only file is one `rmSync` can fail on.
53
+ */
54
+ export function probeModeEnforcement(directory) {
55
+ const probe = `${directory}/.mode-probe`;
56
+ const modeOf = () => statSync(probe).mode & 0o777;
57
+ try {
58
+ writeFileSync(probe, '', { mode: CREDENTIAL_MODE });
59
+ chmodSync(probe, CREDENTIAL_MODE);
60
+ const narrow = modeOf();
61
+ writeFileSync(probe, '', { mode: 0o666 });
62
+ chmodSync(probe, 0o666);
63
+ const wide = modeOf();
64
+ // The positive control. Taken before anything is concluded, not after.
65
+ chmodSync(probe, 0o444);
66
+ const readOnly = modeOf();
67
+ chmodSync(probe, 0o666);
68
+ // Taking the readings is this function's job; what they MEAN is `identity/mode.ts`'s, so the
69
+ // filesystem shapes this machine cannot produce on demand are still drivable in a test.
70
+ return classifyProbeReadings(narrow, wide, readOnly);
71
+ }
72
+ catch {
73
+ return 'unobservable';
74
+ }
75
+ finally {
76
+ try {
77
+ // Restore writability first: a read-only file is one removal can fail on, and a probe that
78
+ // leaves litter behind on the credential directory is its own small defect.
79
+ try {
80
+ chmodSync(probe, 0o666);
81
+ }
82
+ catch {
83
+ // Already gone, or never created.
84
+ }
85
+ rmSync(probe, { force: true });
86
+ }
87
+ catch {
88
+ // A probe file that could not be removed is untidy, never a reason to fail a sign-in.
89
+ }
90
+ }
91
+ }
92
+ export class FileTokenCache {
93
+ #path;
94
+ #directory;
95
+ #enforcement = null;
96
+ constructor(path) {
97
+ this.#path = path;
98
+ const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
99
+ this.#directory = cut > 0 ? path.slice(0, cut) : path;
100
+ }
101
+ get path() {
102
+ return this.#path;
103
+ }
104
+ /** Memoised: the filesystem does not change its mind mid-process. */
105
+ modeEnforcement() {
106
+ if (this.#enforcement === null) {
107
+ try {
108
+ mkdirSync(this.#directory, { recursive: true, mode: CREDENTIAL_DIR_MODE });
109
+ }
110
+ catch {
111
+ // The write path reports the real failure; a probe must not be the thing that raises it.
112
+ }
113
+ this.#enforcement = probeModeEnforcement(this.#directory);
114
+ }
115
+ return this.#enforcement;
116
+ }
117
+ /**
118
+ * Read the cache.
119
+ *
120
+ * A missing file is `token-unavailable` — nobody has signed in — and that is deliberately NOT the
121
+ * same reason as a file that exists and cannot be parsed, which is a real problem with a real
122
+ * fix. Collapsing them would make a corrupted cache look like a fresh install forever.
123
+ */
124
+ read() {
125
+ let raw;
126
+ try {
127
+ raw = readFileSync(this.#path, 'utf8');
128
+ }
129
+ catch {
130
+ return refuse('token-unavailable', 'no token cache exists on this host yet — sign in first');
131
+ }
132
+ let parsed;
133
+ try {
134
+ parsed = JSON.parse(raw);
135
+ }
136
+ catch {
137
+ return refuse('credential-cache-unreadable', `the token cache at ${this.#path} exists and is not valid JSON; remove it and sign in again`);
138
+ }
139
+ return readCachedTokens(parsed);
140
+ }
141
+ /**
142
+ * Write the cache, then verify what actually landed.
143
+ *
144
+ * A file that came out wider than asked is removed, not left and reported. Leaving a
145
+ * world-readable token on disk while returning a refusal would mean the caller sees a failure and
146
+ * the credential is exposed anyway — the worst of both. No cache at all is strictly safer than a
147
+ * readable one, and the sign-in can simply be repeated.
148
+ */
149
+ write(cached) {
150
+ const enforcement = this.modeEnforcement();
151
+ try {
152
+ mkdirSync(this.#directory, { recursive: true, mode: CREDENTIAL_DIR_MODE });
153
+ writeFileSync(this.#path, `${JSON.stringify(cached, null, 2)}\n`, { mode: CREDENTIAL_MODE });
154
+ // `writeFileSync`'s mode applies only when it CREATES the file — an existing file keeps the
155
+ // mode it already had. Re-stating it is what makes an overwrite as private as a first write.
156
+ chmodSync(this.#path, CREDENTIAL_MODE);
157
+ }
158
+ catch (error) {
159
+ return refuse('credential-cache-write-failed', `the token cache could not be written to ${this.#path}: ${String(error)}`);
160
+ }
161
+ let observed;
162
+ try {
163
+ observed = statSync(this.#path).mode;
164
+ }
165
+ catch (error) {
166
+ return refuse('credential-cache-write-failed', `the token cache was written and could not be read back from ${this.#path}: ${String(error)}`);
167
+ }
168
+ const mode = classifyCredentialMode(observed, enforcement);
169
+ if (mode.kind === 'too-wide') {
170
+ this.clear();
171
+ return refuse(mode.refusal.reason, `${mode.refusal.detail}; the file has been removed rather than left readable`);
172
+ }
173
+ return ok({ path: this.#path, mode });
174
+ }
175
+ /** Remove the cache. Idempotent — clearing a cache that is not there is success, not an error. */
176
+ clear() {
177
+ try {
178
+ rmSync(this.#path, { force: true });
179
+ }
180
+ catch {
181
+ // Nothing here can act on the failure, and throwing would turn "sign out" into an error path.
182
+ }
183
+ }
184
+ }
185
+ //# sourceMappingURL=token-cache.js.map
@@ -0,0 +1,4 @@
1
+ import type { StoreEffects } from '../persistence/store.js';
2
+ /** File-backed effects rooted at one directory. The root must be absolute. */
3
+ export declare function nodeStoreEffects(root: string): StoreEffects;
4
+ //# sourceMappingURL=transcript-fs.d.ts.map
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The local JSONL mirror: the store's effects, on a real disk.
3
+ *
4
+ * It lives here for the one reason everything else in this directory does: it names `node:fs`.
5
+ * The rules worth testing (which key becomes which record, what a missing transcript returns, what
6
+ * a corrupt line does) are in `persistence/store.ts` and are proven without a disk. This file is
7
+ * the part that cannot be, so it is small and it is the only place a path is built.
8
+ *
9
+ * The root is a jail, and the token is escaped before it gets here. A project key is
10
+ * caller-supplied and may contain anything, separators included; `transcriptToken` percent-encodes
11
+ * each segment so a key cannot introduce one. This checks containment anyway, because a jail that
12
+ * relies on its caller having escaped correctly is a jail with one lock on the outside.
13
+ */
14
+ import { mkdir, readFile, readdir, rm, stat, appendFile } from 'node:fs/promises';
15
+ import { dirname, join, resolve } from 'node:path';
16
+ import { isContainedBy, normalizePath, requireAbsolute } from '../core/paths.js';
17
+ /** File-backed effects rooted at one directory. The root must be absolute. */
18
+ export function nodeStoreEffects(root) {
19
+ const absolute = requireAbsolute(root);
20
+ if (!absolute.ok) {
21
+ throw new Error(`the transcript root must be absolute: ${absolute.refusal.detail}`);
22
+ }
23
+ const rootPath = normalizePath(absolute.value);
24
+ const pathFor = (token) => {
25
+ const candidate = normalizePath(resolve(rootPath, `${token}.jsonl`));
26
+ if (!isContainedBy(candidate, rootPath)) {
27
+ // Unreachable through `transcriptToken`, which escapes every segment. Kept because the day it
28
+ // becomes reachable is the day somebody writes a second token function.
29
+ throw new Error(`refusing a transcript path outside the store root: ${token}`);
30
+ }
31
+ return candidate;
32
+ };
33
+ return {
34
+ async appendTo(token, text) {
35
+ const file = pathFor(token);
36
+ await mkdir(dirname(file), { recursive: true });
37
+ // A real append, not a read-modify-write: a transcript reaches megabytes and batches arrive
38
+ // throughout a turn, so rewriting per batch would cost the square of the session's length.
39
+ await appendFile(file, text, 'utf8');
40
+ },
41
+ async readAll(token) {
42
+ try {
43
+ return await readFile(pathFor(token), 'utf8');
44
+ }
45
+ catch (error) {
46
+ // A transcript that was never written is `null`, and that is distinct from a read that
47
+ // failed — the store contract turns the first into "nothing was ever stored" and must not
48
+ // be handed it for a permission error.
49
+ if (isMissing(error))
50
+ return null;
51
+ throw error;
52
+ }
53
+ },
54
+ async list(projectKey) {
55
+ const scope = pathFor(encodeURIComponent(projectKey)).replace(/\.jsonl$/, '');
56
+ let names;
57
+ try {
58
+ names = await readdir(scope);
59
+ }
60
+ catch (error) {
61
+ if (isMissing(error))
62
+ return [];
63
+ throw error;
64
+ }
65
+ const found = [];
66
+ for (const name of names) {
67
+ if (!name.endsWith('.jsonl'))
68
+ continue;
69
+ const encodedId = name.slice(0, -'.jsonl'.length);
70
+ const stats = await stat(join(scope, name));
71
+ found.push({
72
+ token: `${encodeURIComponent(projectKey)}/${encodedId}`,
73
+ sessionId: decodeURIComponent(encodedId),
74
+ // Floored: the contract asks for integer epoch milliseconds and a fractional source here
75
+ // would not compare equal to a summary's own stamp.
76
+ mtime: Math.floor(stats.mtimeMs),
77
+ });
78
+ }
79
+ return found;
80
+ },
81
+ async remove(token) {
82
+ await rm(pathFor(token), { force: true });
83
+ },
84
+ async subkeys(projectKey, sessionId) {
85
+ // A session's subagent transcripts live under a directory named for the session, beside its
86
+ // own file. Absent means it never spawned one, which is not an error.
87
+ const scope = pathFor(`${encodeURIComponent(projectKey)}/${encodeURIComponent(sessionId)}`).replace(/\.jsonl$/, '');
88
+ try {
89
+ return (await readdir(scope))
90
+ .filter((name) => name.endsWith('.jsonl'))
91
+ .map((name) => decodeURIComponent(name.slice(0, -'.jsonl'.length)));
92
+ }
93
+ catch (error) {
94
+ if (isMissing(error))
95
+ return [];
96
+ throw error;
97
+ }
98
+ },
99
+ };
100
+ }
101
+ function isMissing(error) {
102
+ return typeof error === 'object' && error !== null && error.code === 'ENOENT';
103
+ }
104
+ //# sourceMappingURL=transcript-fs.js.map
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The one place a controller's `session_new.request` becomes a local `SessionRequest`.
3
+ *
4
+ * Why it is a file and not four lines in the dispatcher: everything crossing here arrived from
5
+ * off-box and was validated only as JSON. The codec's job is to keep malformed bytes out, and it
6
+ * says so; an empty `cwd`, an empty `text` and a non-ISO `at` all pass it. So a value that is
7
+ * well-formed JSON and meaningless to the SDK reaches this line, and there are exactly two things to
8
+ * do with one: refuse it by name before a process exists, or hand it to `query()` and let the
9
+ * failure arrive later wearing a spawn error's clothes. This file is the first of those.
10
+ *
11
+ * What it narrows and what it deliberately does not, because the split is a judgement worth
12
+ * stating rather than a gap. It narrows every value with a closed set this package can see (the
13
+ * three setting sources, the plugin type, the thinking discriminator) because an unrecognised value
14
+ * there silently does nothing, which is the failure mode with no symptom. It does not re-validate
15
+ * the MCP server configs: that union is large, versioned with the SDK, and re-stating it here would
16
+ * be a second copy that can disagree with the first. Those are shape-checked and handed on, and the
17
+ * SDK is their validator.
18
+ *
19
+ * Nothing here can produce `sessionStore`, `sessionStoreFlush`, `spawn`, `onStderr` or `hooks`.
20
+ * They have no JSON form, so the wire type has no member for them and this file has nothing to read.
21
+ * That is the boundary, and it is structural rather than a check somebody remembered to write.
22
+ */
23
+ import type { SessionNewRequest } from '../control/frames.js';
24
+ import type { Result } from '../core/result.js';
25
+ import type { SessionRequest } from '../sessions/registry.js';
26
+ import type { McpServerConfig, PermissionMode, SettingSource, ThinkingConfig } from './agent-process.js';
27
+ import type { SessionConfigure } from '../control/frames.js';
28
+ /** What a controller may ask for, minus the two the composer supplies itself. */
29
+ export type ComposableRequest = Omit<SessionRequest, 'cwd' | 'hooks'>;
30
+ /**
31
+ * The three tiers the SDK understands, as data.
32
+ *
33
+ * Restated here rather than derived because the SDK ships them as a bare string union with no
34
+ * runtime value to read — so this is the one place a copy is unavoidable. `wire-request.test.ts`
35
+ * asserts each member against the shipped `sdk.d.ts`, the same way the permission pin does, so the
36
+ * copy cannot quietly drift from what it copies.
37
+ */
38
+ export declare const SETTING_SOURCES: readonly SettingSource[];
39
+ /** The thinking shapes the SDK declares, by discriminator. Same argument as above. */
40
+ export declare const THINKING_TYPES: readonly string[];
41
+ /** The SDK's effort levels, by name. An unknown level is refused, never dropped. */
42
+ export declare const EFFORT_LEVELS: readonly string[];
43
+ /** The SDK's permission modes, by name (protocol v6, CLI parity). */
44
+ export declare const PERMISSION_MODES: readonly string[];
45
+ /**
46
+ * The one mode under which a granting gate and loaded operator settings are NOT two authorities: the
47
+ * mode already allows everything the grant would, and a hook deny survives every mode. Named here, in
48
+ * the one module that holds the vocabulary, so nothing else has to spell the mode.
49
+ */
50
+ export declare function isBypassMode(mode: string | null | undefined): boolean;
51
+ /** What a `session_configure` frame asked for, narrowed to the SDK's own types. */
52
+ export interface SessionConfigureChange {
53
+ readonly model?: string | null;
54
+ readonly permissionMode?: PermissionMode;
55
+ readonly thinking?: ThinkingConfig;
56
+ }
57
+ /**
58
+ * Narrow a `session_configure` payload. Same discipline as `readSessionRequest`: a value the SDK does
59
+ * not declare is REFUSED by name — a mode nobody recognises must never become "the default" silently.
60
+ */
61
+ export declare function readSessionConfigure(payload: SessionConfigure): Result<SessionConfigureChange>;
62
+ /**
63
+ * Turn a controller's request into a local one, or refuse it by name.
64
+ *
65
+ * `null` in means "every default", which is byte-for-byte the behaviour before this payload grew —
66
+ * so a controller that sends nothing new is unaffected by any of this.
67
+ */
68
+ export declare function readSessionRequest(request: SessionNewRequest | null): Result<ComposableRequest>;
69
+ /**
70
+ * Merge the controller's servers with the ones this host registers itself.
71
+ *
72
+ * A colliding name is refused rather than resolved, and either precedence would have been wrong.
73
+ * Letting the controller win lets a remote peer replace the host's OWN tool server — the one that
74
+ * carries the host's identity into every call — with something it supplies. Letting the host win
75
+ * silently discards a registration the controller believes it made, and its tools then simply do not
76
+ * exist, with nothing anywhere saying why. So the pair is refused, before any process exists, naming
77
+ * the server both sides claimed.
78
+ */
79
+ export declare function mergeMcpServers(fromController: Readonly<Record<string, McpServerConfig>> | undefined, fromHost: Readonly<Record<string, McpServerConfig>> | null): Result<Readonly<Record<string, McpServerConfig>> | null>;
80
+ //# sourceMappingURL=wire-request.d.ts.map
@@ -0,0 +1,196 @@
1
+ import { ok, refuse } from '../core/result.js';
2
+ /**
3
+ * The three tiers the SDK understands, as data.
4
+ *
5
+ * Restated here rather than derived because the SDK ships them as a bare string union with no
6
+ * runtime value to read — so this is the one place a copy is unavoidable. `wire-request.test.ts`
7
+ * asserts each member against the shipped `sdk.d.ts`, the same way the permission pin does, so the
8
+ * copy cannot quietly drift from what it copies.
9
+ */
10
+ export const SETTING_SOURCES = ['user', 'project', 'local'];
11
+ /** The thinking shapes the SDK declares, by discriminator. Same argument as above. */
12
+ export const THINKING_TYPES = ['adaptive', 'enabled', 'disabled'];
13
+ /** The SDK's effort levels, by name. An unknown level is refused, never dropped. */
14
+ export const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
15
+ /** The SDK's permission modes, by name (protocol v6, CLI parity). */
16
+ export const PERMISSION_MODES = [
17
+ 'default',
18
+ 'acceptEdits',
19
+ 'bypassPermissions',
20
+ 'plan',
21
+ 'dontAsk',
22
+ 'auto',
23
+ ];
24
+ /**
25
+ * The one mode under which a granting gate and loaded operator settings are NOT two authorities: the
26
+ * mode already allows everything the grant would, and a hook deny survives every mode. Named here, in
27
+ * the one module that holds the vocabulary, so nothing else has to spell the mode.
28
+ */
29
+ export function isBypassMode(mode) {
30
+ return mode === PERMISSION_MODES[2];
31
+ }
32
+ /**
33
+ * Narrow a `session_configure` payload. Same discipline as `readSessionRequest`: a value the SDK does
34
+ * not declare is REFUSED by name — a mode nobody recognises must never become "the default" silently.
35
+ */
36
+ export function readSessionConfigure(payload) {
37
+ const change = {};
38
+ if (payload.model !== null)
39
+ change.model = payload.model;
40
+ if (payload.permissionMode !== null) {
41
+ if (!PERMISSION_MODES.includes(payload.permissionMode)) {
42
+ return refuse('frame-malformed', `permissionMode is ${JSON.stringify(payload.permissionMode)}; the modes this SDK declares are ` +
43
+ `${PERMISSION_MODES.join(', ')}`);
44
+ }
45
+ change.permissionMode = payload.permissionMode;
46
+ }
47
+ if (payload.thinking !== null) {
48
+ const type = payload.thinking['type'];
49
+ if (typeof type !== 'string' || !THINKING_TYPES.includes(type)) {
50
+ return refuse('frame-malformed', `thinking.type is ${JSON.stringify(type)}; the shapes this SDK declares are ${THINKING_TYPES.join(', ')}`);
51
+ }
52
+ change.thinking = payload.thinking;
53
+ }
54
+ return ok(change);
55
+ }
56
+ /**
57
+ * Turn a controller's request into a local one, or refuse it by name.
58
+ *
59
+ * `null` in means "every default", which is byte-for-byte the behaviour before this payload grew —
60
+ * so a controller that sends nothing new is unaffected by any of this.
61
+ */
62
+ export function readSessionRequest(request) {
63
+ if (request === null)
64
+ return ok({});
65
+ const composed = {};
66
+ if (request.resume !== null)
67
+ composed['resume'] = request.resume;
68
+ if (request.fork !== null)
69
+ composed['fork'] = request.fork;
70
+ if (request.includePartialMessages !== null) {
71
+ composed['includePartialMessages'] = request.includePartialMessages;
72
+ }
73
+ if (request.forwardSubagentText !== null)
74
+ composed['forwardSubagentText'] = request.forwardSubagentText;
75
+ if (request.strictMcpConfig !== null)
76
+ composed['strictMcpConfig'] = request.strictMcpConfig;
77
+ if (request.model !== null)
78
+ composed['model'] = request.model;
79
+ // Handed on as the SDK's own option type. A string, a list of strings and a preset object are all
80
+ // legal, so there is no closed set to check and inventing one would refuse valid requests.
81
+ if (request.systemPrompt !== null)
82
+ composed['systemPrompt'] = request.systemPrompt;
83
+ if (request.settingSources !== null) {
84
+ // A tier nobody declared is refused, not dropped. Dropping it would start a session the
85
+ // controller believes loads its project settings, silently without them — and the permission
86
+ // rules it expected to be in force would simply not be. Nothing would say so.
87
+ const unknown = request.settingSources.filter((source) => !SETTING_SOURCES.includes(source));
88
+ if (unknown.length > 0) {
89
+ return refuse('frame-malformed', `settingSources names ${unknown.map((source) => `"${source}"`).join(', ')}, which this host ` +
90
+ `does not know — the tiers are ${SETTING_SOURCES.join(', ')}. Refused rather than ignored: a ` +
91
+ `session started without a tier its controller asked for runs with permission rules nobody ` +
92
+ `told it were absent`);
93
+ }
94
+ composed['settingSources'] = [...request.settingSources];
95
+ }
96
+ if (request.plugins !== null) {
97
+ const plugins = [];
98
+ for (const plugin of request.plugins) {
99
+ // Same argument as the tiers: the SDK supports exactly one plugin type today, and a value it
100
+ // does not recognise loads nothing while looking like it asked for something.
101
+ if (plugin.type !== 'local') {
102
+ return refuse('frame-malformed', `a plugin declares type "${plugin.type}"; this host passes only "local" plugins to the agent`);
103
+ }
104
+ plugins.push({
105
+ type: 'local',
106
+ path: plugin.path,
107
+ ...(plugin.skipMcpDiscovery === null ? {} : { skipMcpDiscovery: plugin.skipMcpDiscovery }),
108
+ });
109
+ }
110
+ composed['plugins'] = plugins;
111
+ }
112
+ if (request.thinking !== null) {
113
+ const type = request.thinking['type'];
114
+ if (typeof type !== 'string' || !THINKING_TYPES.includes(type)) {
115
+ return refuse('frame-malformed', `thinking.type is ${JSON.stringify(type)}; the shapes this SDK declares are ` +
116
+ `${THINKING_TYPES.join(', ')}`);
117
+ }
118
+ composed['thinking'] = request.thinking;
119
+ }
120
+ if (request.effort !== null) {
121
+ if (!EFFORT_LEVELS.includes(request.effort)) {
122
+ return refuse('frame-malformed', `effort is ${JSON.stringify(request.effort)}; the levels this SDK declares are ${EFFORT_LEVELS.join(', ')}`);
123
+ }
124
+ composed['effort'] = request.effort;
125
+ }
126
+ if (request.permissionMode !== null) {
127
+ // The lane that was closed, opened by name (protocol v6). Narrowed like every other member: an
128
+ // unknown mode is refused, never dropped and never defaulted.
129
+ if (!PERMISSION_MODES.includes(request.permissionMode)) {
130
+ return refuse('frame-malformed', `permissionMode is ${JSON.stringify(request.permissionMode)}; the modes this SDK declares are ` +
131
+ `${PERMISSION_MODES.join(', ')}`);
132
+ }
133
+ composed['permissionMode'] = request.permissionMode;
134
+ }
135
+ if (request.env !== null) {
136
+ const policy = {
137
+ ...(request.env.extraAllowedKeys === null
138
+ ? {}
139
+ : { extraAllowedKeys: [...request.env.extraAllowedKeys] }),
140
+ ...(request.env.extraDeniedKeys === null ? {} : { extraDeniedKeys: [...request.env.extraDeniedKeys] }),
141
+ ...(request.env.extraEnv === null ? {} : { extraEnv: { ...request.env.extraEnv } }),
142
+ };
143
+ composed['env'] = policy;
144
+ }
145
+ if (request.mcpServers !== null) {
146
+ const servers = readMcpServers(request.mcpServers);
147
+ if (!servers.ok)
148
+ return refuse(servers.refusal.reason, servers.refusal.detail);
149
+ composed['mcpServers'] = servers.value;
150
+ }
151
+ return ok(composed);
152
+ }
153
+ /**
154
+ * Shape-check the server map. Each value must be an object; beyond that the SDK is the validator.
155
+ *
156
+ * See this file's header for why the config union is not re-stated here.
157
+ */
158
+ function readMcpServers(servers) {
159
+ const read = {};
160
+ for (const [name, config] of Object.entries(servers)) {
161
+ if (name === '') {
162
+ return refuse('mcp-descriptor-invalid', 'an MCP server was declared under an empty name; a tool reaches the model as ' +
163
+ '`mcp__{server}__{tool}` and an unnamed server has no reachable tools');
164
+ }
165
+ if (typeof config !== 'object' || config === null || Array.isArray(config)) {
166
+ return refuse('mcp-descriptor-invalid', `MCP server "${name}" is declared as ${config === null ? 'null' : typeof config}, not an object`);
167
+ }
168
+ read[name] = config;
169
+ }
170
+ return ok(read);
171
+ }
172
+ /**
173
+ * Merge the controller's servers with the ones this host registers itself.
174
+ *
175
+ * A colliding name is refused rather than resolved, and either precedence would have been wrong.
176
+ * Letting the controller win lets a remote peer replace the host's OWN tool server — the one that
177
+ * carries the host's identity into every call — with something it supplies. Letting the host win
178
+ * silently discards a registration the controller believes it made, and its tools then simply do not
179
+ * exist, with nothing anywhere saying why. So the pair is refused, before any process exists, naming
180
+ * the server both sides claimed.
181
+ */
182
+ export function mergeMcpServers(fromController, fromHost) {
183
+ if (fromHost === null)
184
+ return ok(fromController ?? null);
185
+ if (fromController === undefined)
186
+ return ok(fromHost);
187
+ const collisions = Object.keys(fromHost).filter((name) => Object.prototype.hasOwnProperty.call(fromController, name));
188
+ if (collisions.length > 0) {
189
+ return refuse('mcp-descriptor-invalid', `MCP server ${collisions.map((name) => `"${name}"`).join(', ')} is declared by both this host ` +
190
+ `and the controller. Refused rather than resolved: letting the controller win replaces this ` +
191
+ `host's own tool server, and letting the host win discards a registration the controller ` +
192
+ `believes it made. Rename one`);
193
+ }
194
+ return ok({ ...fromHost, ...fromController });
195
+ }
196
+ //# sourceMappingURL=wire-request.js.map
@@ -0,0 +1,14 @@
1
+ import type { CommandEffects, WorkspaceEffects } from '../workspace/provider.js';
2
+ /** The filesystem half, on `node:fs/promises`. */
3
+ export declare const nodeWorkspaceEffects: WorkspaceEffects;
4
+ /**
5
+ * Running a program with an argv array.
6
+ *
7
+ * It rejects on a non-zero exit, and that is load-bearing rather than conventional. The worktree
8
+ * provider probes for a branch by running `rev-parse --verify` and reading the FAILURE as "this
9
+ * branch does not exist" — so an implementation that resolved with an empty string on a non-zero
10
+ * exit would report every branch as existing, and the provider would attach where it should create.
11
+ * The safe direction, but silently wrong, and it would look like it worked.
12
+ */
13
+ export declare function nodeCommandEffects(timeoutMs?: number): CommandEffects;
14
+ //# sourceMappingURL=workspace-fs.d.ts.map
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The real effects the workspace providers run on: the filesystem, and `git`.
3
+ *
4
+ * Why they live here and the providers do not: `src/host/` is the only directory allowed to touch
5
+ * the machine, pinned by pins/host-boundary.test.ts, and a provider is mostly DECISIONS: which argv
6
+ * a git call gets, whether a directory is reused or made, what a release does. Splitting them puts
7
+ * the decisions where they can be tested with no disk and no repository — which is what turns "never
8
+ * hard-reset an existing branch" into a unit test instead of a rule only a lost branch could prove.
9
+ *
10
+ * `execFile`, never `exec`. `exec` runs its argument through a shell, so every branch name and
11
+ * path in it becomes an injection site — and branch names arrive from a caller this package does not
12
+ * control. `execFile` takes an argv array and spawns the program directly, with no shell to quote
13
+ * for and nothing to escape.
14
+ */
15
+ import { execFile } from 'node:child_process';
16
+ import { mkdir, readdir, rm, stat } from 'node:fs/promises';
17
+ /** The filesystem half, on `node:fs/promises`. */
18
+ export const nodeWorkspaceEffects = {
19
+ async makeDirectory(path) {
20
+ await mkdir(path, { recursive: true });
21
+ },
22
+ async exists(path) {
23
+ try {
24
+ await stat(path);
25
+ return true;
26
+ }
27
+ catch {
28
+ // Any failure to stat is treated as absent. The caller's next act either creates it — which
29
+ // reports its own error — or attaches to it, so a stat that failed for a different reason
30
+ // surfaces as a named provisioning refusal rather than being swallowed here.
31
+ return false;
32
+ }
33
+ },
34
+ async removeDirectory(path) {
35
+ await rm(path, { recursive: true, force: true });
36
+ },
37
+ async listDirectories(path) {
38
+ try {
39
+ const entries = await readdir(path, { withFileTypes: true });
40
+ return entries
41
+ .filter((entry) => entry.isDirectory())
42
+ .map((entry) => entry.name)
43
+ .sort();
44
+ }
45
+ catch (error) {
46
+ // A root that does not exist yet has no workspaces in it; anything else is the caller's refusal.
47
+ if (error.code === 'ENOENT')
48
+ return [];
49
+ throw error;
50
+ }
51
+ },
52
+ };
53
+ /**
54
+ * 60 seconds. Long enough for a cold worktree creation on a loaded machine; short enough that a git
55
+ * call which will never return does not hold a session's provisioning open indefinitely.
56
+ */
57
+ const DEFAULT_COMMAND_TIMEOUT_MS = 60_000;
58
+ /**
59
+ * Running a program with an argv array.
60
+ *
61
+ * It rejects on a non-zero exit, and that is load-bearing rather than conventional. The worktree
62
+ * provider probes for a branch by running `rev-parse --verify` and reading the FAILURE as "this
63
+ * branch does not exist" — so an implementation that resolved with an empty string on a non-zero
64
+ * exit would report every branch as existing, and the provider would attach where it should create.
65
+ * The safe direction, but silently wrong, and it would look like it worked.
66
+ */
67
+ export function nodeCommandEffects(timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS) {
68
+ return {
69
+ run(program, args, cwd) {
70
+ return new Promise((resolve, reject) => {
71
+ execFile(program, [...args], { cwd, timeout: timeoutMs, windowsHide: true }, (error, stdout, stderr) => {
72
+ if (error !== null) {
73
+ // stderr carries git's own message, which is the only text that says WHY. Dropping it
74
+ // leaves a refusal reading "command failed" with nothing to act on.
75
+ reject(new Error(`${program} ${args.join(' ')} failed: ${stderr.trim() || error.message}`));
76
+ return;
77
+ }
78
+ resolve(stdout.trim());
79
+ });
80
+ });
81
+ },
82
+ };
83
+ }
84
+ //# sourceMappingURL=workspace-fs.js.map