@naswerks/periscope 1.0.0 → 1.0.1

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 (187) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/contracts/wire-vectors/bulk_delivered.full.json +21 -0
  4. package/contracts/wire-vectors/bulk_delivered.minimal.json +21 -0
  5. package/contracts/wire-vectors/bulk_delivered.without-stat-pair.json +22 -0
  6. package/contracts/wire-vectors/bulk_failed.declared-reason.json +22 -0
  7. package/contracts/wire-vectors/bulk_failed.unknown-reason.json +23 -0
  8. package/contracts/wire-vectors/bulk_request.full.json +21 -0
  9. package/contracts/wire-vectors/host_configure.set-and-remove.json +28 -0
  10. package/contracts/wire-vectors/host_configure_result.applied.json +34 -0
  11. package/contracts/wire-vectors/host_configure_result.refused.json +33 -0
  12. package/contracts/wire-vectors/link_ack.cursors.json +25 -0
  13. package/contracts/wire-vectors/link_ack.empty.json +16 -0
  14. package/contracts/wire-vectors/link_bye.cause.json +16 -0
  15. package/contracts/wire-vectors/link_hello.empty.json +33 -0
  16. package/contracts/wire-vectors/link_hello.full.json +44 -0
  17. package/contracts/wire-vectors/link_ping.nonce.json +16 -0
  18. package/contracts/wire-vectors/link_ping.unknown-payload-key.json +18 -0
  19. package/contracts/wire-vectors/link_pong.nonce.json +16 -0
  20. package/contracts/wire-vectors/link_welcome.empty.json +18 -0
  21. package/contracts/wire-vectors/link_welcome.full.json +25 -0
  22. package/contracts/wire-vectors/refused.empty.json +10 -0
  23. package/contracts/wire-vectors/refused.encode.empty-at.json +18 -0
  24. package/contracts/wire-vectors/refused.encode.empty-session-id.json +18 -0
  25. package/contracts/wire-vectors/refused.encode.missing-member.json +22 -0
  26. package/contracts/wire-vectors/refused.encode.seq-zero.json +18 -0
  27. package/contracts/wire-vectors/refused.encode.undeclared-configure-refusal.json +34 -0
  28. package/contracts/wire-vectors/refused.encode.undeclared-wire-refusal.json +27 -0
  29. package/contracts/wire-vectors/refused.missing-member.json +10 -0
  30. package/contracts/wire-vectors/refused.not-json.json +10 -0
  31. package/contracts/wire-vectors/refused.over-limit.json +10 -0
  32. package/contracts/wire-vectors/refused.seq-zero.json +10 -0
  33. package/contracts/wire-vectors/refused.unknown-frame.json +10 -0
  34. package/contracts/wire-vectors/refused.unknown-kind.json +10 -0
  35. package/contracts/wire-vectors/repository_list.root.json +19 -0
  36. package/contracts/wire-vectors/repository_list.subdirectory.json +19 -0
  37. package/contracts/wire-vectors/repository_list_result.entries.json +34 -0
  38. package/contracts/wire-vectors/repository_list_result.refused.json +24 -0
  39. package/contracts/wire-vectors/repository_read.head.json +20 -0
  40. package/contracts/wire-vectors/repository_read_result.refused.json +25 -0
  41. package/contracts/wire-vectors/repository_read_result.truncated.json +22 -0
  42. package/contracts/wire-vectors/session_cancel.bare.json +17 -0
  43. package/contracts/wire-vectors/session_configure.full.json +22 -0
  44. package/contracts/wire-vectors/session_configure.minimal.json +20 -0
  45. package/contracts/wire-vectors/session_delta.agent-message.json +27 -0
  46. package/contracts/wire-vectors/session_list.request.json +18 -0
  47. package/contracts/wire-vectors/session_list_result.empty.json +21 -0
  48. package/contracts/wire-vectors/session_list_result.full.json +36 -0
  49. package/contracts/wire-vectors/session_new.full.json +72 -0
  50. package/contracts/wire-vectors/session_new.minimal.json +22 -0
  51. package/contracts/wire-vectors/session_new.request-all-null.json +41 -0
  52. package/contracts/wire-vectors/session_prompt.empty.json +18 -0
  53. package/contracts/wire-vectors/session_prompt.multibyte.json +18 -0
  54. package/contracts/wire-vectors/session_prompt.text.json +18 -0
  55. package/contracts/wire-vectors/session_prompt.unknown-frame-key.json +20 -0
  56. package/contracts/wire-vectors/session_prompt.unknown-payload-key.json +20 -0
  57. package/contracts/wire-vectors/session_update.agent-message.json +26 -0
  58. package/contracts/wire-vectors/session_update.state-transition-with-activity.json +44 -0
  59. package/contracts/wire-vectors/session_update.state-transition.json +41 -0
  60. package/contracts/wire-vectors/session_update.wire-refusal.json +26 -0
  61. package/contracts/wire-vectors/transcript_failed.declared-reason.json +22 -0
  62. package/contracts/wire-vectors/transcript_list.from-start.json +19 -0
  63. package/contracts/wire-vectors/transcript_list.paged.json +19 -0
  64. package/contracts/wire-vectors/transcript_list_result.entry-without-cwd.json +30 -0
  65. package/contracts/wire-vectors/transcript_list_result.full.json +29 -0
  66. package/contracts/wire-vectors/transcript_list_result.last-page.json +29 -0
  67. package/contracts/wire-vectors/transcript_tail.any-user-entry.json +22 -0
  68. package/contracts/wire-vectors/transcript_tail.with-needle.json +22 -0
  69. package/contracts/wire-vectors/transcript_tail_result.absent.json +23 -0
  70. package/contracts/wire-vectors/transcript_tail_result.found.json +23 -0
  71. package/contracts/wire-vectors/workspace_list.from-start.json +19 -0
  72. package/contracts/wire-vectors/workspace_list_result.page.json +48 -0
  73. package/contracts/wire-vectors/workspace_list_result.refused.json +26 -0
  74. package/contracts/wire-vectors/workspace_release.by-path-delete-branch.json +22 -0
  75. package/contracts/wire-vectors/workspace_release.request.json +22 -0
  76. package/contracts/wire-vectors/workspace_release_bulk.two-entries.json +32 -0
  77. package/contracts/wire-vectors/workspace_release_bulk_result.mixed.json +37 -0
  78. package/contracts/wire-vectors/workspace_release_result.partial.json +26 -0
  79. package/contracts/wire-vectors/workspace_release_result.refused.json +26 -0
  80. package/contracts/wire-vectors/workspace_release_result.released.json +23 -0
  81. package/package.json +6 -1
  82. package/src/bin/command.ts +198 -0
  83. package/src/bin/config.ts +104 -0
  84. package/src/bin/login.ts +149 -0
  85. package/src/bin/main.ts +204 -0
  86. package/src/bin/pair.ts +246 -0
  87. package/src/bin/periscope.ts +3 -0
  88. package/src/bin/posture.ts +232 -0
  89. package/src/bin/reconfigure.ts +266 -0
  90. package/src/bin/serve.ts +615 -0
  91. package/src/bin/status.ts +61 -0
  92. package/src/bin/workspaces.ts +280 -0
  93. package/src/control/backoff.ts +40 -0
  94. package/src/control/codec.ts +557 -0
  95. package/src/control/credential.ts +33 -0
  96. package/src/control/frames.ts +1586 -0
  97. package/src/control/link-state.ts +67 -0
  98. package/src/control/link.ts +894 -0
  99. package/src/control/queue.ts +248 -0
  100. package/src/control/seq.ts +76 -0
  101. package/src/control/stream-routing.ts +216 -0
  102. package/src/control/stream.ts +149 -0
  103. package/src/core/async-queue.ts +63 -0
  104. package/src/core/failure.ts +61 -0
  105. package/src/core/index.ts +22 -0
  106. package/src/core/keyed-turns.ts +47 -0
  107. package/src/core/paths.ts +84 -0
  108. package/src/core/refusal.ts +430 -0
  109. package/src/core/result.ts +27 -0
  110. package/src/core/time.ts +34 -0
  111. package/src/core/vocab.ts +44 -0
  112. package/src/core/workspace-id.ts +11 -0
  113. package/src/gate/command.ts +617 -0
  114. package/src/gate/decision.ts +173 -0
  115. package/src/gate/escalate.ts +164 -0
  116. package/src/gate/gate.ts +635 -0
  117. package/src/gate/index.ts +53 -0
  118. package/src/gate/jail.ts +210 -0
  119. package/src/gate/local.ts +115 -0
  120. package/src/gate/outcome.ts +164 -0
  121. package/src/gate/shell.ts +295 -0
  122. package/src/host/agent-process.ts +694 -0
  123. package/src/host/bulk-post.ts +166 -0
  124. package/src/host/claude-transcripts.ts +404 -0
  125. package/src/host/config-file.ts +246 -0
  126. package/src/host/git-facts.ts +119 -0
  127. package/src/host/hooks.ts +102 -0
  128. package/src/host/host.ts +1901 -0
  129. package/src/host/index.ts +92 -0
  130. package/src/host/link-state-file.ts +79 -0
  131. package/src/host/loopback.ts +177 -0
  132. package/src/host/machine.ts +27 -0
  133. package/src/host/mcp-server.ts +66 -0
  134. package/src/host/package-facts.ts +25 -0
  135. package/src/host/paired-credential-store.ts +140 -0
  136. package/src/host/paths.ts +175 -0
  137. package/src/host/repository-read.ts +245 -0
  138. package/src/host/session-store.ts +133 -0
  139. package/src/host/sign-in.ts +352 -0
  140. package/src/host/telemetry.ts +105 -0
  141. package/src/host/token-cache.ts +214 -0
  142. package/src/host/transcript-fs.ts +110 -0
  143. package/src/host/wire-request.ts +285 -0
  144. package/src/host/workspace-fs.ts +90 -0
  145. package/src/host/workspace-trust.ts +92 -0
  146. package/src/identity/authorize.ts +127 -0
  147. package/src/identity/config.ts +227 -0
  148. package/src/identity/credential.ts +176 -0
  149. package/src/identity/device-code.ts +217 -0
  150. package/src/identity/index.ts +84 -0
  151. package/src/identity/mode.ts +161 -0
  152. package/src/identity/paired-credential.ts +89 -0
  153. package/src/identity/pkce.ts +113 -0
  154. package/src/identity/store.ts +98 -0
  155. package/src/identity/token.ts +203 -0
  156. package/src/index.ts +140 -0
  157. package/src/mcp/descriptor.ts +75 -0
  158. package/src/mcp/index.ts +23 -0
  159. package/src/mcp/schema.ts +214 -0
  160. package/src/mcp/server.ts +180 -0
  161. package/src/persistence/entry.ts +142 -0
  162. package/src/persistence/index.ts +47 -0
  163. package/src/persistence/key.ts +77 -0
  164. package/src/persistence/mirror.ts +118 -0
  165. package/src/persistence/receipt.ts +182 -0
  166. package/src/persistence/retention.ts +118 -0
  167. package/src/persistence/store.ts +183 -0
  168. package/src/persistence/transition-log.ts +176 -0
  169. package/src/protocol.ts +215 -0
  170. package/src/sessions/index.ts +27 -0
  171. package/src/sessions/registry.ts +392 -0
  172. package/src/sessions/session.ts +440 -0
  173. package/src/sessions/spawn-env.ts +318 -0
  174. package/src/state/coverage.ts +360 -0
  175. package/src/state/index.ts +58 -0
  176. package/src/state/machine.ts +359 -0
  177. package/src/state/model.ts +417 -0
  178. package/src/state/observer.ts +499 -0
  179. package/src/state/reporter.ts +60 -0
  180. package/src/state/store.ts +157 -0
  181. package/src/telemetry/index.ts +10 -0
  182. package/src/telemetry/usage.ts +117 -0
  183. package/src/workspace/git-worktree.ts +551 -0
  184. package/src/workspace/index.ts +17 -0
  185. package/src/workspace/plain-dir.ts +132 -0
  186. package/src/workspace/provider.ts +159 -0
  187. package/src/workspace/worktree-porcelain.ts +118 -0
@@ -0,0 +1,110 @@
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
+
17
+ import type { StoreEffects } from '../persistence/store.js';
18
+ import { isContainedBy, normalizePath, requireAbsolute } from '../core/paths.js';
19
+
20
+ /** File-backed effects rooted at one directory. The root must be absolute. */
21
+ export function nodeStoreEffects(root: string): StoreEffects {
22
+ const absolute = requireAbsolute(root);
23
+ if (!absolute.ok) {
24
+ throw new Error(`the transcript root must be absolute: ${absolute.refusal.detail}`);
25
+ }
26
+ const rootPath = normalizePath(absolute.value);
27
+
28
+ const pathFor = (token: string): string => {
29
+ const candidate = normalizePath(resolve(rootPath, `${token}.jsonl`));
30
+ if (!isContainedBy(candidate, rootPath)) {
31
+ // Unreachable through `transcriptToken`, which escapes every segment. Kept because the day it
32
+ // becomes reachable is the day somebody writes a second token function.
33
+ throw new Error(`refusing a transcript path outside the store root: ${token}`);
34
+ }
35
+ return candidate;
36
+ };
37
+
38
+ return {
39
+ async appendTo(token: string, text: string): Promise<void> {
40
+ const file = pathFor(token);
41
+ await mkdir(dirname(file), { recursive: true });
42
+ // A real append, not a read-modify-write: a transcript reaches megabytes and batches arrive
43
+ // throughout a turn, so rewriting per batch would cost the square of the session's length.
44
+ await appendFile(file, text, 'utf8');
45
+ },
46
+
47
+ async readAll(token: string): Promise<string | null> {
48
+ try {
49
+ return await readFile(pathFor(token), 'utf8');
50
+ } catch (error) {
51
+ // A transcript that was never written is `null`, and that is distinct from a read that
52
+ // failed — the store contract turns the first into "nothing was ever stored" and must not
53
+ // be handed it for a permission error.
54
+ if (isMissing(error)) return null;
55
+ throw error;
56
+ }
57
+ },
58
+
59
+ async list(projectKey: string) {
60
+ const scope = pathFor(encodeURIComponent(projectKey)).replace(/\.jsonl$/, '');
61
+ let names: string[];
62
+ try {
63
+ names = await readdir(scope);
64
+ } catch (error) {
65
+ if (isMissing(error)) return [];
66
+ throw error;
67
+ }
68
+
69
+ const found: { token: string; sessionId: string; mtime: number }[] = [];
70
+ for (const name of names) {
71
+ if (!name.endsWith('.jsonl')) continue;
72
+ const encodedId = name.slice(0, -'.jsonl'.length);
73
+ const stats = await stat(join(scope, name));
74
+ found.push({
75
+ token: `${encodeURIComponent(projectKey)}/${encodedId}`,
76
+ sessionId: decodeURIComponent(encodedId),
77
+ // Floored: the contract asks for integer epoch milliseconds and a fractional source here
78
+ // would not compare equal to a summary's own stamp.
79
+ mtime: Math.floor(stats.mtimeMs),
80
+ });
81
+ }
82
+ return found;
83
+ },
84
+
85
+ async remove(token: string): Promise<void> {
86
+ await rm(pathFor(token), { force: true });
87
+ },
88
+
89
+ async subkeys(projectKey: string, sessionId: string): Promise<readonly string[]> {
90
+ // A session's subagent transcripts live under a directory named for the session, beside its
91
+ // own file. Absent means it never spawned one, which is not an error.
92
+ const scope = pathFor(`${encodeURIComponent(projectKey)}/${encodeURIComponent(sessionId)}`).replace(
93
+ /\.jsonl$/,
94
+ '',
95
+ );
96
+ try {
97
+ return (await readdir(scope))
98
+ .filter((name) => name.endsWith('.jsonl'))
99
+ .map((name) => decodeURIComponent(name.slice(0, -'.jsonl'.length)));
100
+ } catch (error) {
101
+ if (isMissing(error)) return [];
102
+ throw error;
103
+ }
104
+ },
105
+ };
106
+ }
107
+
108
+ function isMissing(error: unknown): boolean {
109
+ return typeof error === 'object' && error !== null && (error as { code?: string }).code === 'ENOENT';
110
+ }
@@ -0,0 +1,285 @@
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 { JsonObject, SessionNewRequest } from '../control/frames.js';
24
+ import type { Result } from '../core/result.js';
25
+ import { ok, refuse } from '../core/result.js';
26
+ import type { SessionRequest } from '../sessions/registry.js';
27
+ import type { SpawnEnvPolicy } from '../sessions/spawn-env.js';
28
+ import type {
29
+ McpServerConfig,
30
+ PermissionMode,
31
+ SdkPluginConfig,
32
+ SettingSource,
33
+ ThinkingConfig,
34
+ } from './agent-process.js';
35
+ import type { SessionConfigure } from '../control/frames.js';
36
+
37
+ /** What a controller may ask for, minus the two the composer supplies itself. */
38
+ export type ComposableRequest = Omit<SessionRequest, 'cwd' | 'hooks'>;
39
+
40
+ /**
41
+ * The three tiers the SDK understands, as data.
42
+ *
43
+ * Restated here rather than derived because the SDK ships them as a bare string union with no
44
+ * runtime value to read — so this is the one place a copy is unavoidable. `wire-request.test.ts`
45
+ * asserts each member against the shipped `sdk.d.ts`, the same way the permission pin does, so the
46
+ * copy cannot quietly drift from what it copies.
47
+ */
48
+ export const SETTING_SOURCES: readonly SettingSource[] = ['user', 'project', 'local'];
49
+
50
+ /** The thinking shapes the SDK declares, by discriminator. Same argument as above. */
51
+ export const THINKING_TYPES: readonly string[] = ['adaptive', 'enabled', 'disabled'];
52
+
53
+ /** The SDK's effort levels, by name. An unknown level is refused, never dropped. */
54
+ export const EFFORT_LEVELS: readonly string[] = ['low', 'medium', 'high', 'xhigh', 'max'];
55
+
56
+ /** The SDK's permission modes, by name (protocol v6, CLI parity). */
57
+ export const PERMISSION_MODES: readonly string[] = [
58
+ 'default',
59
+ 'acceptEdits',
60
+ 'bypassPermissions',
61
+ 'plan',
62
+ 'dontAsk',
63
+ 'auto',
64
+ ];
65
+
66
+ /**
67
+ * The one mode under which a granting gate and loaded operator settings are NOT two authorities: the
68
+ * mode already allows everything the grant would, and a hook deny survives every mode. Named here, in
69
+ * the one module that holds the vocabulary, so nothing else has to spell the mode.
70
+ */
71
+ export function isBypassMode(mode: string | null | undefined): boolean {
72
+ return mode === PERMISSION_MODES[2];
73
+ }
74
+
75
+ /** What a `session_configure` frame asked for, narrowed to the SDK's own types. */
76
+ export interface SessionConfigureChange {
77
+ readonly model?: string | null;
78
+ readonly permissionMode?: PermissionMode;
79
+ readonly thinking?: ThinkingConfig;
80
+ }
81
+
82
+ /**
83
+ * Narrow a `session_configure` payload. Same discipline as `readSessionRequest`: a value the SDK does
84
+ * not declare is REFUSED by name — a mode nobody recognises must never become "the default" silently.
85
+ */
86
+ export function readSessionConfigure(payload: SessionConfigure): Result<SessionConfigureChange> {
87
+ const change: { model?: string | null; permissionMode?: PermissionMode; thinking?: ThinkingConfig } = {};
88
+ if (payload.model !== null) change.model = payload.model;
89
+ if (payload.permissionMode !== null) {
90
+ if (!PERMISSION_MODES.includes(payload.permissionMode)) {
91
+ return refuse<SessionConfigureChange>(
92
+ 'frame-malformed',
93
+ `permissionMode is ${JSON.stringify(payload.permissionMode)}; the modes this SDK declares are ` +
94
+ `${PERMISSION_MODES.join(', ')}`,
95
+ );
96
+ }
97
+ change.permissionMode = payload.permissionMode as PermissionMode;
98
+ }
99
+ if (payload.thinking !== null) {
100
+ const type = payload.thinking['type'];
101
+ if (typeof type !== 'string' || !THINKING_TYPES.includes(type)) {
102
+ return refuse<SessionConfigureChange>(
103
+ 'frame-malformed',
104
+ `thinking.type is ${JSON.stringify(type)}; the shapes this SDK declares are ${THINKING_TYPES.join(', ')}`,
105
+ );
106
+ }
107
+ change.thinking = payload.thinking as unknown as ThinkingConfig;
108
+ }
109
+ return ok(change);
110
+ }
111
+
112
+ /**
113
+ * Turn a controller's request into a local one, or refuse it by name.
114
+ *
115
+ * `null` in means "every default", which is byte-for-byte the behaviour before this payload grew —
116
+ * so a controller that sends nothing new is unaffected by any of this.
117
+ */
118
+ export function readSessionRequest(request: SessionNewRequest | null): Result<ComposableRequest> {
119
+ if (request === null) return ok({});
120
+
121
+ const composed: Record<string, unknown> = {};
122
+
123
+ if (request.resume !== null) composed['resume'] = request.resume;
124
+ if (request.fork !== null) composed['fork'] = request.fork;
125
+ if (request.includePartialMessages !== null) {
126
+ composed['includePartialMessages'] = request.includePartialMessages;
127
+ }
128
+ if (request.forwardSubagentText !== null) composed['forwardSubagentText'] = request.forwardSubagentText;
129
+ if (request.strictMcpConfig !== null) composed['strictMcpConfig'] = request.strictMcpConfig;
130
+ if (request.model !== null) composed['model'] = request.model;
131
+ // Handed on as the SDK's own option type. A string, a list of strings and a preset object are all
132
+ // legal, so there is no closed set to check and inventing one would refuse valid requests.
133
+ if (request.systemPrompt !== null) composed['systemPrompt'] = request.systemPrompt;
134
+
135
+ if (request.settingSources !== null) {
136
+ // A tier nobody declared is refused, not dropped. Dropping it would start a session the
137
+ // controller believes loads its project settings, silently without them — and the permission
138
+ // rules it expected to be in force would simply not be. Nothing would say so.
139
+ const unknown = request.settingSources.filter(
140
+ (source) => !(SETTING_SOURCES as readonly string[]).includes(source),
141
+ );
142
+ if (unknown.length > 0) {
143
+ return refuse<ComposableRequest>(
144
+ 'frame-malformed',
145
+ `settingSources names ${unknown.map((source) => `"${source}"`).join(', ')}, which this host ` +
146
+ `does not know — the tiers are ${SETTING_SOURCES.join(', ')}. Refused rather than ignored: a ` +
147
+ `session started without a tier its controller asked for runs with permission rules nobody ` +
148
+ `told it were absent`,
149
+ );
150
+ }
151
+ composed['settingSources'] = [...request.settingSources] as SettingSource[];
152
+ }
153
+
154
+ if (request.plugins !== null) {
155
+ const plugins: SdkPluginConfig[] = [];
156
+ for (const plugin of request.plugins) {
157
+ // Same argument as the tiers: the SDK supports exactly one plugin type today, and a value it
158
+ // does not recognise loads nothing while looking like it asked for something.
159
+ if (plugin.type !== 'local') {
160
+ return refuse<ComposableRequest>(
161
+ 'frame-malformed',
162
+ `a plugin declares type "${plugin.type}"; this host passes only "local" plugins to the agent`,
163
+ );
164
+ }
165
+ plugins.push({
166
+ type: 'local',
167
+ path: plugin.path,
168
+ ...(plugin.skipMcpDiscovery === null ? {} : { skipMcpDiscovery: plugin.skipMcpDiscovery }),
169
+ });
170
+ }
171
+ composed['plugins'] = plugins;
172
+ }
173
+
174
+ if (request.thinking !== null) {
175
+ const type = request.thinking['type'];
176
+ if (typeof type !== 'string' || !THINKING_TYPES.includes(type)) {
177
+ return refuse<ComposableRequest>(
178
+ 'frame-malformed',
179
+ `thinking.type is ${JSON.stringify(type)}; the shapes this SDK declares are ` +
180
+ `${THINKING_TYPES.join(', ')}`,
181
+ );
182
+ }
183
+ composed['thinking'] = request.thinking;
184
+ }
185
+
186
+ if (request.effort !== null) {
187
+ if (!EFFORT_LEVELS.includes(request.effort)) {
188
+ return refuse<ComposableRequest>(
189
+ 'frame-malformed',
190
+ `effort is ${JSON.stringify(request.effort)}; the levels this SDK declares are ${EFFORT_LEVELS.join(', ')}`,
191
+ );
192
+ }
193
+ composed['effort'] = request.effort;
194
+ }
195
+
196
+ if (request.permissionMode !== null) {
197
+ // The lane that was closed, opened by name (protocol v6). Narrowed like every other member: an
198
+ // unknown mode is refused, never dropped and never defaulted.
199
+ if (!PERMISSION_MODES.includes(request.permissionMode)) {
200
+ return refuse<ComposableRequest>(
201
+ 'frame-malformed',
202
+ `permissionMode is ${JSON.stringify(request.permissionMode)}; the modes this SDK declares are ` +
203
+ `${PERMISSION_MODES.join(', ')}`,
204
+ );
205
+ }
206
+ composed['permissionMode'] = request.permissionMode;
207
+ }
208
+
209
+ if (request.env !== null) {
210
+ const policy: SpawnEnvPolicy = {
211
+ ...(request.env.extraAllowedKeys === null
212
+ ? {}
213
+ : { extraAllowedKeys: [...request.env.extraAllowedKeys] }),
214
+ ...(request.env.extraDeniedKeys === null ? {} : { extraDeniedKeys: [...request.env.extraDeniedKeys] }),
215
+ ...(request.env.extraEnv === null ? {} : { extraEnv: { ...request.env.extraEnv } }),
216
+ };
217
+ composed['env'] = policy;
218
+ }
219
+
220
+ if (request.mcpServers !== null) {
221
+ const servers = readMcpServers(request.mcpServers);
222
+ if (!servers.ok) return refuse<ComposableRequest>(servers.refusal.reason, servers.refusal.detail);
223
+ composed['mcpServers'] = servers.value;
224
+ }
225
+
226
+ return ok(composed as ComposableRequest);
227
+ }
228
+
229
+ /**
230
+ * Shape-check the server map. Each value must be an object; beyond that the SDK is the validator.
231
+ *
232
+ * See this file's header for why the config union is not re-stated here.
233
+ */
234
+ function readMcpServers(servers: JsonObject): Result<Record<string, McpServerConfig>> {
235
+ const read: Record<string, McpServerConfig> = {};
236
+ for (const [name, config] of Object.entries(servers)) {
237
+ if (name === '') {
238
+ return refuse<Record<string, McpServerConfig>>(
239
+ 'mcp-descriptor-invalid',
240
+ 'an MCP server was declared under an empty name; a tool reaches the model as ' +
241
+ '`mcp__{server}__{tool}` and an unnamed server has no reachable tools',
242
+ );
243
+ }
244
+ if (typeof config !== 'object' || config === null || Array.isArray(config)) {
245
+ return refuse<Record<string, McpServerConfig>>(
246
+ 'mcp-descriptor-invalid',
247
+ `MCP server "${name}" is declared as ${config === null ? 'null' : typeof config}, not an object`,
248
+ );
249
+ }
250
+ read[name] = config as unknown as McpServerConfig;
251
+ }
252
+ return ok(read);
253
+ }
254
+
255
+ /**
256
+ * Merge the controller's servers with the ones this host registers itself.
257
+ *
258
+ * A colliding name is refused rather than resolved, and either precedence would have been wrong.
259
+ * Letting the controller win lets a remote peer replace the host's OWN tool server — the one that
260
+ * carries the host's identity into every call — with something it supplies. Letting the host win
261
+ * silently discards a registration the controller believes it made, and its tools then simply do not
262
+ * exist, with nothing anywhere saying why. So the pair is refused, before any process exists, naming
263
+ * the server both sides claimed.
264
+ */
265
+ export function mergeMcpServers(
266
+ fromController: Readonly<Record<string, McpServerConfig>> | undefined,
267
+ fromHost: Readonly<Record<string, McpServerConfig>> | null,
268
+ ): Result<Readonly<Record<string, McpServerConfig>> | null> {
269
+ if (fromHost === null) return ok(fromController ?? null);
270
+ if (fromController === undefined) return ok(fromHost);
271
+
272
+ const collisions = Object.keys(fromHost).filter((name) =>
273
+ Object.prototype.hasOwnProperty.call(fromController, name),
274
+ );
275
+ if (collisions.length > 0) {
276
+ return refuse<Readonly<Record<string, McpServerConfig>> | null>(
277
+ 'mcp-descriptor-invalid',
278
+ `MCP server ${collisions.map((name) => `"${name}"`).join(', ')} is declared by both this host ` +
279
+ `and the controller. Refused rather than resolved: letting the controller win replaces this ` +
280
+ `host's own tool server, and letting the host win discards a registration the controller ` +
281
+ `believes it made. Rename one`,
282
+ );
283
+ }
284
+ return ok({ ...fromHost, ...fromController });
285
+ }
@@ -0,0 +1,90 @@
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
+
18
+ import type { CommandEffects, WorkspaceEffects } from '../workspace/provider.js';
19
+
20
+ /** The filesystem half, on `node:fs/promises`. */
21
+ export const nodeWorkspaceEffects: WorkspaceEffects = {
22
+ async makeDirectory(path: string): Promise<void> {
23
+ await mkdir(path, { recursive: true });
24
+ },
25
+ async exists(path: string): Promise<boolean> {
26
+ try {
27
+ await stat(path);
28
+ return true;
29
+ } catch {
30
+ // Any failure to stat is treated as absent. The caller's next act either creates it — which
31
+ // reports its own error — or attaches to it, so a stat that failed for a different reason
32
+ // surfaces as a named provisioning refusal rather than being swallowed here.
33
+ return false;
34
+ }
35
+ },
36
+ async removeDirectory(path: string): Promise<void> {
37
+ await rm(path, { recursive: true, force: true });
38
+ },
39
+ async listDirectories(path: string): Promise<string[]> {
40
+ try {
41
+ const entries = await readdir(path, { withFileTypes: true });
42
+ return entries
43
+ .filter((entry) => entry.isDirectory())
44
+ .map((entry) => entry.name)
45
+ .sort();
46
+ } catch (error) {
47
+ // A root that does not exist yet has no workspaces in it; anything else is the caller's refusal.
48
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
49
+ throw error;
50
+ }
51
+ },
52
+ };
53
+
54
+ /**
55
+ * 60 seconds. Long enough for a cold worktree creation on a loaded machine; short enough that a git
56
+ * call which will never return does not hold a session's provisioning open indefinitely.
57
+ */
58
+ const DEFAULT_COMMAND_TIMEOUT_MS = 60_000;
59
+
60
+ /**
61
+ * Running a program with an argv array.
62
+ *
63
+ * It rejects on a non-zero exit, and that is load-bearing rather than conventional. The worktree
64
+ * provider probes for a branch by running `rev-parse --verify` and reading the FAILURE as "this
65
+ * branch does not exist" — so an implementation that resolved with an empty string on a non-zero
66
+ * exit would report every branch as existing, and the provider would attach where it should create.
67
+ * The safe direction, but silently wrong, and it would look like it worked.
68
+ */
69
+ export function nodeCommandEffects(timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS): CommandEffects {
70
+ return {
71
+ run(program: string, args: readonly string[], cwd: string): Promise<string> {
72
+ return new Promise<string>((resolve, reject) => {
73
+ execFile(
74
+ program,
75
+ [...args],
76
+ { cwd, timeout: timeoutMs, windowsHide: true },
77
+ (error, stdout, stderr) => {
78
+ if (error !== null) {
79
+ // stderr carries git's own message, which is the only text that says WHY. Dropping it
80
+ // leaves a refusal reading "command failed" with nothing to act on.
81
+ reject(new Error(`${program} ${args.join(' ')} failed: ${stderr.trim() || error.message}`));
82
+ return;
83
+ }
84
+ resolve(stdout.trim());
85
+ },
86
+ );
87
+ });
88
+ },
89
+ };
90
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Whether a working directory is one the agent CLI considers trusted. Read, never written.
3
+ *
4
+ * The trap this exists for: in an untrusted workspace the CLI silently voids the permission rules
5
+ * in `.claude/settings.json` — *"Ignoring 1 permissions.allow entry … this workspace has not been
6
+ * trusted"* — and that sentence is a STDERR LINE, not an error. Nothing in the SDK's type surface
7
+ * models trust at all. A host that provisions working directories programmatically will therefore
8
+ * create untrusted ones by default and never be told.
9
+ *
10
+ * Why this module reads and does not write: granting trust
11
+ * means writing `hasTrustDialogAccepted: true` into the user's own `~/.claude.json`, a file every
12
+ * running CLI rewrites, so a host that grants is racing them for user-global state it does not own.
13
+ * It would also remove the SYMPTOM rather than the SILENCE, and silence is the actual failure: a
14
+ * host that reports the condition works on a machine where granting is impossible — a read-only
15
+ * home, a container, another user's account — and one that grants does not.
16
+ *
17
+ * What replaces it: this package depends on no settings file (`settingSources: []` reads none, so
18
+ * there are no rules for an untrusted workspace to void), and where a caller opts back in, the
19
+ * condition is REPORTED. The permission authority is the in-process hook, which the evidence shows
20
+ * fires regardless of allow rules — depending on the rules instead would mean depending on the one
21
+ * mechanism proven to be silently voidable.
22
+ */
23
+ import { readFileSync } from 'node:fs';
24
+
25
+ import { normalizePath } from '../core/paths.js';
26
+
27
+ export type WorkspaceTrust =
28
+ /** The config records this directory as trusted. */
29
+ | 'trusted'
30
+ /** The config was read and this directory is either absent from it or recorded as untrusted. */
31
+ | 'untrusted'
32
+ /** No config, or one that could not be read. NOT the same as untrusted — see below. */
33
+ | 'unknown';
34
+
35
+ /**
36
+ * The stderr line the CLI emits when it drops rules for this reason, as a matcher.
37
+ *
38
+ * Deliberately loose: it matches the stable part of a message whose wording the CLI owns, so a
39
+ * reworded prefix or a different rule count still trips it. A matcher that is too precise here fails
40
+ * open, which is the direction that reproduces the original silence.
41
+ */
42
+ const UNTRUSTED_STDERR = /has not been trusted/i;
43
+
44
+ export function isUntrustedWorkspaceWarning(line: string): boolean {
45
+ return UNTRUSTED_STDERR.test(line);
46
+ }
47
+
48
+ /** Where the CLI keeps per-directory trust. Separate from the settings files. */
49
+ export function trustConfigPath(homeDir: string): string {
50
+ return `${normalizePath(homeDir)}/.claude.json`;
51
+ }
52
+
53
+ /**
54
+ * Read the recorded trust for `cwd`.
55
+ *
56
+ * Comparison is normalized and case-insensitive because the recorded keys are whatever absolute
57
+ * path the CLI was started with — `C:\x` and `c:/x` are the same directory on Windows and would
58
+ * otherwise read as two.
59
+ *
60
+ * `unknown` is a third answer and not a synonym for untrusted. A config that cannot be read
61
+ * supports no claim, and reporting "untrusted" for it would state a fact about the user's machine
62
+ * that was never observed.
63
+ */
64
+ export function readWorkspaceTrust(configPath: string, cwd: string): WorkspaceTrust {
65
+ let raw: string;
66
+ try {
67
+ raw = readFileSync(configPath, 'utf8');
68
+ } catch {
69
+ return 'unknown';
70
+ }
71
+
72
+ let parsed: unknown;
73
+ try {
74
+ parsed = JSON.parse(raw);
75
+ } catch {
76
+ return 'unknown';
77
+ }
78
+
79
+ const projects = (parsed as { projects?: unknown }).projects;
80
+ if (typeof projects !== 'object' || projects === null) return 'unknown';
81
+
82
+ const wanted = normalizePath(cwd).toLowerCase();
83
+ for (const [recordedPath, entry] of Object.entries(projects as Record<string, unknown>)) {
84
+ if (normalizePath(recordedPath).toLowerCase() !== wanted) continue;
85
+ const accepted = (entry as { hasTrustDialogAccepted?: unknown }).hasTrustDialogAccepted;
86
+ return accepted === true ? 'trusted' : 'untrusted';
87
+ }
88
+
89
+ // The file exists and lists projects; this one is simply not among them. That IS an observation:
90
+ // a directory the CLI has never been trusted in.
91
+ return 'untrusted';
92
+ }