@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,352 @@
1
+ /**
2
+ * The assembly: the one place the identity parts become a working sign-in.
3
+ *
4
+ * Why this file exists at all: a set of individually correct parts with nothing composing them is a
5
+ * failure mode (a gate, an observer and a session each proven, with nothing wiring them together,
6
+ * so a default session runs ungated). Identity has exactly the same shape: a config reader, a PKCE
7
+ * generator, a listener, a token parser and a cache are five green modules and zero working
8
+ * sign-ins. This is the sixth thing, and without it the other five are a claim rather than a
9
+ * capability.
10
+ *
11
+ * The transport is injected. `fetch` and the browser-opener are parameters, so the whole flow
12
+ * (discovery, redemption, refresh, and every failure in them) is exercised against a real local HTTP
13
+ * server in the tests rather than against a mock of one. What is not exercised is a real identity
14
+ * provider; that needs an app registration, which is a human act and deliberately not this code's.
15
+ */
16
+ import type { Result } from '../core/result.js';
17
+ import { ok, refuse } from '../core/result.js';
18
+ import type { Ticker } from '../core/time.js';
19
+ import { systemTicker } from '../core/time.js';
20
+ import type { IdentityConfig, IdentityEndpoints } from '../identity/config.js';
21
+ import { discoveryUrl, readDiscoveryDocument } from '../identity/config.js';
22
+ import { buildAuthorizationUrl, readAuthorizationCallback } from '../identity/authorize.js';
23
+ import { createPkce, createState } from '../identity/pkce.js';
24
+ import type { AuthProtocol } from '../identity/device-code.js';
25
+ import {
26
+ deviceAuthorizationBody,
27
+ devicePollBody,
28
+ AUTH_FLOW_BLOCKED_ERROR,
29
+ isAuthFlowBlocked,
30
+ readDeviceAuthorization,
31
+ readDevicePoll,
32
+ requireDeviceCodeEnabled,
33
+ } from '../identity/device-code.js';
34
+ import type { CachedTokens, TokenStore } from '../identity/store.js';
35
+ import type { TokenRefresher } from '../identity/credential.js';
36
+ import type { TokenSet } from '../identity/token.js';
37
+ import { codeExchangeBody, readTokenResponse, refreshBody } from '../identity/token.js';
38
+ import { DEFAULT_SIGN_IN_TIMEOUT_MS, openLoopbackListener } from './loopback.js';
39
+
40
+ /** The injectable edges. Every one has a real default; the tests replace the transport. */
41
+ export interface SignInDeps {
42
+ readonly fetch?: typeof globalThis.fetch;
43
+ /** Told where to send the user. Defaults to printing the URL, which works on a headless box. */
44
+ readonly present?: (url: string) => void;
45
+ readonly timeoutMs?: number;
46
+ readonly nowMs?: Ticker;
47
+ /** Injected so a device-code poll loop is testable without real waiting. */
48
+ readonly sleep?: (ms: number) => Promise<void>;
49
+ }
50
+
51
+ const FORM = { 'content-type': 'application/x-www-form-urlencoded' };
52
+
53
+ /** A `fetch` that never throws: a transport error becomes a named refusal like any other outcome. */
54
+ async function post(
55
+ fetchImpl: typeof globalThis.fetch,
56
+ url: string,
57
+ body: string,
58
+ ): Promise<Result<{ status: number; body: unknown }>> {
59
+ let response: Response;
60
+ try {
61
+ response = await fetchImpl(url, { method: 'POST', headers: FORM, body });
62
+ } catch (error) {
63
+ // A transport failure is an outage, not a refusal by the provider. Reading them as the same
64
+ // thing is how a network blip gets diagnosed as a bad credential.
65
+ return refuse('token-request-failed', `the token endpoint could not be reached: ${String(error)}`);
66
+ }
67
+ return ok({ status: response.status, body: await readJson(response) });
68
+ }
69
+
70
+ /** A body that is not JSON is `null`, and the reader above decides what that means. */
71
+ async function readJson(response: Response): Promise<unknown> {
72
+ try {
73
+ return await response.json();
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * The endpoints, from configuration or from the provider's discovery document.
81
+ *
82
+ * Stated endpoints win and skip the network entirely — an operator who pinned them gets no
83
+ * surprise round trip, and an air-gapped or proxied deployment can work without discovery at all.
84
+ */
85
+ export async function resolveEndpoints(
86
+ config: IdentityConfig,
87
+ fetchImpl: typeof globalThis.fetch = globalThis.fetch,
88
+ ): Promise<Result<IdentityEndpoints>> {
89
+ if (config.endpoints !== null) return ok(config.endpoints);
90
+
91
+ let response: Response;
92
+ try {
93
+ response = await fetchImpl(discoveryUrl(config.authority));
94
+ } catch (error) {
95
+ return refuse(
96
+ 'identity-config-invalid',
97
+ `the provider's discovery document could not be fetched: ${String(error)}`,
98
+ );
99
+ }
100
+ if (!response.ok) {
101
+ return refuse(
102
+ 'identity-config-invalid',
103
+ `the provider's discovery document returned HTTP ${response.status}`,
104
+ );
105
+ }
106
+ return readDiscoveryDocument(await readJson(response));
107
+ }
108
+
109
+ /**
110
+ * Sign a user in through the loopback redirect, and persist what comes back.
111
+ *
112
+ * The listener is opened before the authorization URL is built, because the redirect URI has to
113
+ * carry the port the OS actually gave. Building the URL first would mean either guessing a port
114
+ * or fixing one, and a fixed port collides with whatever else is running on a developer's machine.
115
+ *
116
+ * And the listener is closed on every path. It is a socket accepting callbacks; leaving it open
117
+ * after a failed exchange would leave the thing this flow exists to bound still bound.
118
+ */
119
+ export async function signIn(
120
+ config: IdentityConfig,
121
+ store: TokenStore,
122
+ deps: SignInDeps = {},
123
+ ): Promise<Result<CachedTokens>> {
124
+ const fetchImpl = deps.fetch ?? globalThis.fetch;
125
+ const nowMs = deps.nowMs ?? systemTicker;
126
+ const present = deps.present ?? ((url: string) => process.stdout.write(`open this to sign in:\n${url}\n`));
127
+
128
+ const endpoints = await resolveEndpoints(config, fetchImpl);
129
+ if (!endpoints.ok) return refuse(endpoints.refusal.reason, endpoints.refusal.detail);
130
+
131
+ const opened = await openLoopbackListener(
132
+ config.redirectPort,
133
+ deps.timeoutMs ?? DEFAULT_SIGN_IN_TIMEOUT_MS,
134
+ );
135
+ if (!opened.ok) return refuse(opened.refusal.reason, opened.refusal.detail);
136
+ const listener = opened.value;
137
+
138
+ try {
139
+ const pkce = createPkce();
140
+ const state = createState();
141
+ const request = buildAuthorizationUrl(
142
+ config,
143
+ endpoints.value.authorizationEndpoint,
144
+ pkce,
145
+ state,
146
+ listener.redirectUri,
147
+ );
148
+
149
+ present(request.url);
150
+
151
+ const arrived = await listener.callback;
152
+ if (!arrived.ok) return refuse(arrived.refusal.reason, arrived.refusal.detail);
153
+
154
+ // The `state` check lives here, in the pure reader, and it runs before the code is touched.
155
+ const callback = readAuthorizationCallback(arrived.value, state);
156
+ if (!callback.ok) return refuse(callback.refusal.reason, callback.refusal.detail);
157
+
158
+ const exchanged = await post(
159
+ fetchImpl,
160
+ endpoints.value.tokenEndpoint,
161
+ codeExchangeBody(config, callback.value.code, pkce.verifier, listener.redirectUri),
162
+ );
163
+ if (!exchanged.ok) return refuse(exchanged.refusal.reason, exchanged.refusal.detail);
164
+
165
+ const tokens = readTokenResponse(exchanged.value.status, exchanged.value.body, nowMs());
166
+ if (!tokens.ok) return refuse(tokens.refusal.reason, tokens.refusal.detail);
167
+
168
+ const cached: CachedTokens = {
169
+ tokens: tokens.value,
170
+ protocol: 'loopback',
171
+ authority: config.authority,
172
+ clientId: config.clientId,
173
+ };
174
+
175
+ // A cache that would not write is a failed sign-in, and that is the opposite of the rule in
176
+ // `credential.ts`. There, a refresh had already succeeded and the token was in hand. Here the
177
+ // whole point was to persist it: returning success would tell the operator they are signed in
178
+ // while the next start finds nothing.
179
+ const written = store.write(cached);
180
+ if (!written.ok) return refuse(written.refusal.reason, written.refusal.detail);
181
+
182
+ return ok(cached);
183
+ } finally {
184
+ listener.close();
185
+ }
186
+ }
187
+
188
+ /** What a device-code sign-in tells the operator to do, before it starts waiting. */
189
+ export interface DeviceCodeInstruction {
190
+ readonly verificationUri: string;
191
+ readonly userCode: string;
192
+ }
193
+
194
+ /**
195
+ * Sign in through the device-code flow, for a box with no browser on it.
196
+ *
197
+ * It refuses unless it was explicitly enabled, and that check is the first thing here. The
198
+ * provider calls this a high-risk method and recommends blocking it; a tenant refusing it outright
199
+ * is an expected outcome. It must be asked for by name, never arrived at by falling back — see
200
+ * `identity/device-code.ts` for the policy-blocked-flow trap it also carries.
201
+ *
202
+ * The poll interval is the provider's, not this package's, and `slow_down` widens it. Polling faster than
203
+ * asked is how a client gets rate-limited into failing a sign-in that would have worked.
204
+ */
205
+ export async function signInWithDeviceCode(
206
+ config: IdentityConfig,
207
+ store: TokenStore,
208
+ instruct: (instruction: DeviceCodeInstruction) => void,
209
+ deps: SignInDeps = {},
210
+ ): Promise<Result<CachedTokens>> {
211
+ const enabled = requireDeviceCodeEnabled(config);
212
+ if (!enabled.ok) return refuse(enabled.refusal.reason, enabled.refusal.detail);
213
+
214
+ const fetchImpl = deps.fetch ?? globalThis.fetch;
215
+ const nowMs = deps.nowMs ?? systemTicker;
216
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
217
+
218
+ const endpoints = await resolveEndpoints(config, fetchImpl);
219
+ if (!endpoints.ok) return refuse(endpoints.refusal.reason, endpoints.refusal.detail);
220
+
221
+ const deviceEndpoint = endpoints.value.deviceAuthorizationEndpoint;
222
+ if (deviceEndpoint === null) {
223
+ return refuse('device-code-declined', 'this provider does not advertise a device authorization endpoint');
224
+ }
225
+
226
+ const started = await post(fetchImpl, deviceEndpoint, deviceAuthorizationBody(config));
227
+ if (!started.ok) return refuse(started.refusal.reason, started.refusal.detail);
228
+
229
+ const authorization = readDeviceAuthorization(started.value.status, started.value.body, nowMs());
230
+ if (!authorization.ok) return refuse(authorization.refusal.reason, authorization.refusal.detail);
231
+
232
+ instruct({ verificationUri: authorization.value.verificationUri, userCode: authorization.value.userCode });
233
+
234
+ let intervalMs = authorization.value.intervalMs;
235
+ const body = devicePollBody(config, authorization.value.deviceCode);
236
+
237
+ while (nowMs() < authorization.value.expiresAt) {
238
+ await sleep(intervalMs);
239
+
240
+ const polled = await post(fetchImpl, endpoints.value.tokenEndpoint, body);
241
+ if (!polled.ok) return refuse(polled.refusal.reason, polled.refusal.detail);
242
+
243
+ const outcome = readDevicePoll(polled.value.status, polled.value.body, intervalMs);
244
+ if (outcome.kind === 'pending') continue;
245
+ if (outcome.kind === 'slow-down') {
246
+ intervalMs = outcome.intervalMs;
247
+ continue;
248
+ }
249
+ if (outcome.kind === 'declined') return refuse(outcome.refusal.reason, outcome.refusal.detail);
250
+
251
+ const tokens = readTokenResponse(polled.value.status, polled.value.body, nowMs());
252
+ if (!tokens.ok) return refuse(tokens.refusal.reason, tokens.refusal.detail);
253
+
254
+ const cached: CachedTokens = {
255
+ tokens: tokens.value,
256
+ // Stamped `device-code`, and that stamp is what makes the blocked-flow guard possible. A
257
+ // cache that does not record which flow minted it cannot be refreshed safely by either.
258
+ protocol: 'device-code',
259
+ authority: config.authority,
260
+ clientId: config.clientId,
261
+ };
262
+
263
+ const written = store.write(cached);
264
+ if (!written.ok) return refuse(written.refusal.reason, written.refusal.detail);
265
+ return ok(cached);
266
+ }
267
+
268
+ return refuse('device-code-declined', 'the device code expired before the sign-in was completed');
269
+ }
270
+
271
+ /**
272
+ * The refresher `TokenCredential` calls when its cached token has expired.
273
+ *
274
+ * An authentication-flows block discards the cache and names the flow to stop using. It does
275
+ * not say "sign in again".
276
+ *
277
+ * Why not: a blocked-flow refusal is the provider's policy refusing the flow (see
278
+ * `identity/device-code.ts`), so the trigger is a tenant admin enabling a policy, not a
279
+ * reconfiguration here. A refresher that cleared the cache and told the operator to sign in again
280
+ * would loop: `protocolFor` derives the flow from static config, so "again" would mean the same
281
+ * device-code flow, into the same policy. Unattended, that is refresh, blocked, clear, device code,
282
+ * blocked, repeat, never holding a usable token. A recovery path that returns to the failing state
283
+ * is not a recovery path.
284
+ *
285
+ * So it routes to the flow that is not blocked. The policy targets device code; loopback
286
+ * authorization-code plus PKCE is implemented here (`identity/authorize.ts`, `identity/pkce.ts`).
287
+ * When loopback is available the refusal says to use it; when it is not, the refusal refuses by
288
+ * name and tells the operator which setting to change, rather than inviting a retry that cannot
289
+ * succeed.
290
+ */
291
+ export function refresherFor(
292
+ config: IdentityConfig,
293
+ endpoints: IdentityEndpoints,
294
+ store: TokenStore,
295
+ deps: SignInDeps = {},
296
+ ): TokenRefresher {
297
+ const fetchImpl = deps.fetch ?? globalThis.fetch;
298
+ const nowMs = deps.nowMs ?? systemTicker;
299
+
300
+ return async (cached: CachedTokens): Promise<Result<TokenSet>> => {
301
+ const refreshToken = cached.tokens.refreshToken;
302
+ if (refreshToken === null) {
303
+ return refuse(
304
+ 'token-unavailable',
305
+ 'the cached token has no refresh token, so it cannot be renewed without signing in',
306
+ );
307
+ }
308
+
309
+ const response = await post(fetchImpl, endpoints.tokenEndpoint, refreshBody(config, refreshToken));
310
+ if (!response.ok) return refuse(response.refusal.reason, response.refusal.detail);
311
+
312
+ const tokens = readTokenResponse(response.value.status, response.value.body, nowMs());
313
+ if (!tokens.ok) {
314
+ if (isAuthFlowBlocked(tokens.refusal.detail)) {
315
+ // Cleared either way: the provider's own guidance is that such material is unusable for good
316
+ // and should be deleted. What differs is what the operator is told to do next.
317
+ store.clear();
318
+
319
+ // The fallback is only a fallback if it is a different flow. Naming loopback while the
320
+ // host is configured for loopback would be the same loop with a new label.
321
+ const blockedFlow = protocolFor(config);
322
+ if (blockedFlow === 'device-code') {
323
+ return refuse(
324
+ 'token-unavailable',
325
+ `the identity provider's policy has blocked the DEVICE CODE flow this material was minted by ` +
326
+ `(provider code ${AUTH_FLOW_BLOCKED_ERROR}); the token can never be refreshed and the cache has been ` +
327
+ `discarded. Do not re-run the device code flow: the same policy will block it, every time. Set ` +
328
+ `deviceCodeEnabled: false (for the binary, unset PERISCOPE_IDENTITY_DEVICE_CODE) to use the loopback ` +
329
+ `authorization-code (PKCE) flow, which such a policy does not target, or have the provider's ` +
330
+ `administrator exempt this application.`,
331
+ );
332
+ }
333
+
334
+ return refuse(
335
+ 'token-unavailable',
336
+ `the identity provider's policy has blocked this material (provider code ${AUTH_FLOW_BLOCKED_ERROR}) ` +
337
+ `and the cache has been discarded. This host is ALREADY configured for the loopback flow, so there is ` +
338
+ `no unblocked flow left to fall back to: the policy must be changed by the provider's administrator. ` +
339
+ `Retrying will not help.`,
340
+ );
341
+ }
342
+ return refuse(tokens.refusal.reason, tokens.refusal.detail);
343
+ }
344
+
345
+ return tokens;
346
+ };
347
+ }
348
+
349
+ /** Which flow a host configured this way will use. One place, so the cache is stamped consistently. */
350
+ export function protocolFor(config: IdentityConfig): AuthProtocol {
351
+ return config.deviceCodeEnabled ? 'device-code' : 'loopback';
352
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Lifting cost and rate-limit facts off the agent's own stream.
3
+ *
4
+ * In `src/host/` because it names the SDK's message union, the same reason `readInitFacts` is there.
5
+ * The layer above branches on plain records and never learns the SDK's shapes.
6
+ *
7
+ * What is deliberately absent, and it is not an oversight: two facts a dashboard wants,
8
+ * `getContextUsage()` for the context ring and `accountInfo()` for the account's own limits, are
9
+ * methods on the query object, and this package does not hand that object out: it is the narrowing
10
+ * that keeps four mid-session permission mutators unreachable, and it is pinned. Reaching them means
11
+ * either widening that narrowing or adding a named method beside `prompt` and `interrupt`, and both
12
+ * are decisions above this file. Everything below arrives on the message stream, which this host
13
+ * already has, so none of it costs that trade.
14
+ */
15
+ import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
16
+
17
+ import type { ModelSpend, RateLimitStanding, TurnSpend } from '../telemetry/usage.js';
18
+
19
+ /**
20
+ * A turn's spend, or null for every message that is not a result.
21
+ *
22
+ * Every figure is copied, none computed. No token count is multiplied by anything here; the
23
+ * agent priced the turn and this carries what it said. That is what makes a mixed-model turn correct
24
+ * without this package holding a price table that would rot.
25
+ */
26
+ export function readTurnSpend(message: SDKMessage): TurnSpend | null {
27
+ if (message.type !== 'result') return null;
28
+
29
+ const byModel: ModelSpend[] = Object.entries(message.modelUsage ?? {}).map(([model, usage]) => ({
30
+ model,
31
+ costUsd: usage.costUSD,
32
+ inputTokens: usage.inputTokens,
33
+ outputTokens: usage.outputTokens,
34
+ cacheReadInputTokens: usage.cacheReadInputTokens,
35
+ cacheCreationInputTokens: usage.cacheCreationInputTokens,
36
+ // Absent is null rather than zero: a zero window reads as a real, tiny limit.
37
+ contextWindow: usage.contextWindow ?? null,
38
+ canonicalModel: usage.canonicalModel ?? null,
39
+ provider: usage.provider ?? null,
40
+ }));
41
+
42
+ return { totalCostUsd: message.total_cost_usd, byModel };
43
+ }
44
+
45
+ /**
46
+ * Where a rate limit stands, or null for every other message.
47
+ *
48
+ * It arrives in-stream, which is the part worth noticing: no unofficial endpoint has to be polled
49
+ * to learn this. Nothing here polls anything.
50
+ */
51
+ export function readRateLimit(message: SDKMessage): RateLimitStanding | null {
52
+ if (message.type !== 'rate_limit_event') return null;
53
+ const info = message.rate_limit_info;
54
+ return {
55
+ status: info.status,
56
+ resetsAt: info.resetsAt ?? null,
57
+ limitType: info.rateLimitType ?? null,
58
+ utilization: info.utilization ?? null,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * A subagent's own usage, off the task notification that reports it.
64
+ *
65
+ * This is what makes a subagent's cost attributable: the agent reports per-task usage, and hooks
66
+ * carry an agent id, so spend has an owner without anything being inferred.
67
+ */
68
+ export function readTaskSpend(message: SDKMessage): { agentId: string; spend: TurnSpend } | null {
69
+ if (message.type !== 'system' || message.subtype !== 'task_notification') return null;
70
+
71
+ const usage = (message as unknown as { model_usage?: Record<string, ModelUsageShape> }).model_usage;
72
+ const totalCost = (message as unknown as { total_cost_usd?: number }).total_cost_usd;
73
+ const agentId = (message as unknown as { agent_id?: string }).agent_id;
74
+ if (usage === undefined || totalCost === undefined || agentId === undefined) return null;
75
+
76
+ return {
77
+ agentId,
78
+ spend: {
79
+ totalCostUsd: totalCost,
80
+ byModel: Object.entries(usage).map(([model, one]) => ({
81
+ model,
82
+ costUsd: one.costUSD,
83
+ inputTokens: one.inputTokens,
84
+ outputTokens: one.outputTokens,
85
+ cacheReadInputTokens: one.cacheReadInputTokens,
86
+ cacheCreationInputTokens: one.cacheCreationInputTokens,
87
+ contextWindow: one.contextWindow ?? null,
88
+ canonicalModel: one.canonicalModel ?? null,
89
+ provider: one.provider ?? null,
90
+ })),
91
+ },
92
+ };
93
+ }
94
+
95
+ /** The per-model shape a task notification carries, named so the read above stays readable. */
96
+ interface ModelUsageShape {
97
+ readonly inputTokens: number;
98
+ readonly outputTokens: number;
99
+ readonly cacheReadInputTokens: number;
100
+ readonly cacheCreationInputTokens: number;
101
+ readonly costUSD: number;
102
+ readonly contextWindow?: number;
103
+ readonly canonicalModel?: string;
104
+ readonly provider?: string;
105
+ }
@@ -0,0 +1,214 @@
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
+
23
+ import type { Result } from '../core/result.js';
24
+ import { ok, refuse } from '../core/result.js';
25
+ import type { CredentialModeOutcome, ModeEnforcement } from '../identity/mode.js';
26
+ import { CREDENTIAL_MODE, classifyCredentialMode, classifyProbeReadings } from '../identity/mode.js';
27
+ import type { CachedTokens, TokenStore } from '../identity/store.js';
28
+ import { readCachedTokens } from '../identity/store.js';
29
+
30
+ /** Directories holding credential material are owner-only too. */
31
+ const CREDENTIAL_DIR_MODE = 0o700;
32
+
33
+ export interface TokenCacheWrite {
34
+ readonly path: string;
35
+ readonly mode: CredentialModeOutcome;
36
+ }
37
+
38
+ /**
39
+ * Measure what this filesystem does with a mode.
40
+ *
41
+ * Measured rather than listed by platform. A `process.platform === 'win32'` test is a guess that
42
+ * ages: WSL, a POSIX filesystem mounted under Windows, and a future runtime mapping modes onto ACLs
43
+ * would each make it wrong in the direction that matters. The probe writes a throwaway file BESIDE
44
+ * the cache — the same filesystem, which is the thing actually in question.
45
+ *
46
+ * It asserts its positive control before it reports the negative, and that ordering is the whole
47
+ * design. Three measurements, in this order:
48
+ *
49
+ * 1. Ask for `0600` and for `0666`. If they read back differently AND `0600` came back as `0600`,
50
+ * POSIX modes are honoured. Done.
51
+ * 2. They did not differ — so privacy is unconfirmable. **Before saying so, prove the instrument
52
+ * is alive**: clear the write bit with `0444`. Measured on win32, that DOES read back as
53
+ * `444`, because the write bit is the one real bit there. If it changes, this is a genuine
54
+ * finding about a filesystem that records only writability.
55
+ * 3. Even `0444` changed nothing. Then the probe cannot distinguish "this filesystem records
56
+ * nothing" from "this probe is broken", and it says exactly that.
57
+ *
58
+ * Without step 2, "unenforced" and "the probe never worked" are the same answer — an instrument
59
+ * with no inconclusive state, which is how a dead check gets read as evidence.
60
+ *
61
+ * `0444` is the control, never the target. The cache must stay owner-writable; refreshes are
62
+ * written to it. The probe restores a writable mode on its own throwaway file before removing it,
63
+ * because a read-only file is one `rmSync` can fail on.
64
+ */
65
+ export function probeModeEnforcement(directory: string): ModeEnforcement {
66
+ const probe = `${directory}/.mode-probe`;
67
+ const modeOf = (): number => statSync(probe).mode & 0o777;
68
+
69
+ try {
70
+ writeFileSync(probe, '', { mode: CREDENTIAL_MODE });
71
+ chmodSync(probe, CREDENTIAL_MODE);
72
+ const narrow = modeOf();
73
+
74
+ writeFileSync(probe, '', { mode: 0o666 });
75
+ chmodSync(probe, 0o666);
76
+ const wide = modeOf();
77
+
78
+ // The positive control. Taken before anything is concluded, not after.
79
+ chmodSync(probe, 0o444);
80
+ const readOnly = modeOf();
81
+ chmodSync(probe, 0o666);
82
+
83
+ // Taking the readings is this function's job; what they MEAN is `identity/mode.ts`'s, so the
84
+ // filesystem shapes this machine cannot produce on demand are still drivable in a test.
85
+ return classifyProbeReadings(narrow, wide, readOnly);
86
+ } catch {
87
+ return 'unobservable';
88
+ } finally {
89
+ try {
90
+ // Restore writability first: a read-only file is one removal can fail on, and a probe that
91
+ // leaves litter behind on the credential directory is its own small defect.
92
+ try {
93
+ chmodSync(probe, 0o666);
94
+ } catch {
95
+ // Already gone, or never created.
96
+ }
97
+ rmSync(probe, { force: true });
98
+ } catch {
99
+ // A probe file that could not be removed is untidy, never a reason to fail a sign-in.
100
+ }
101
+ }
102
+ }
103
+
104
+ export class FileTokenCache implements TokenStore {
105
+ readonly #path: string;
106
+ readonly #directory: string;
107
+ #enforcement: ModeEnforcement | null = null;
108
+
109
+ constructor(path: string) {
110
+ this.#path = path;
111
+ const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
112
+ this.#directory = cut > 0 ? path.slice(0, cut) : path;
113
+ }
114
+
115
+ get path(): string {
116
+ return this.#path;
117
+ }
118
+
119
+ /** Memoised: the filesystem does not change its mind mid-process. */
120
+ modeEnforcement(): ModeEnforcement {
121
+ if (this.#enforcement === null) {
122
+ try {
123
+ mkdirSync(this.#directory, { recursive: true, mode: CREDENTIAL_DIR_MODE });
124
+ } catch {
125
+ // The write path reports the real failure; a probe must not be the thing that raises it.
126
+ }
127
+ this.#enforcement = probeModeEnforcement(this.#directory);
128
+ }
129
+ return this.#enforcement;
130
+ }
131
+
132
+ /**
133
+ * Read the cache.
134
+ *
135
+ * A missing file is `token-unavailable` — nobody has signed in — and that is deliberately NOT the
136
+ * same reason as a file that exists and cannot be parsed, which is a real problem with a real
137
+ * fix. Collapsing them would make a corrupted cache look like a fresh install forever.
138
+ */
139
+ read(): Result<CachedTokens> {
140
+ let raw: string;
141
+ try {
142
+ raw = readFileSync(this.#path, 'utf8');
143
+ } catch {
144
+ return refuse('token-unavailable', 'no token cache exists on this host yet — sign in first');
145
+ }
146
+
147
+ let parsed: unknown;
148
+ try {
149
+ parsed = JSON.parse(raw);
150
+ } catch {
151
+ return refuse(
152
+ 'credential-cache-unreadable',
153
+ `the token cache at ${this.#path} exists and is not valid JSON; remove it and sign in again`,
154
+ );
155
+ }
156
+
157
+ return readCachedTokens(parsed);
158
+ }
159
+
160
+ /**
161
+ * Write the cache, then verify what actually landed.
162
+ *
163
+ * A file that came out wider than asked is removed, not left and reported. Leaving a
164
+ * world-readable token on disk while returning a refusal would mean the caller sees a failure and
165
+ * the credential is exposed anyway — the worst of both. No cache at all is strictly safer than a
166
+ * readable one, and the sign-in can simply be repeated.
167
+ */
168
+ write(cached: CachedTokens): Result<TokenCacheWrite> {
169
+ const enforcement = this.modeEnforcement();
170
+
171
+ try {
172
+ mkdirSync(this.#directory, { recursive: true, mode: CREDENTIAL_DIR_MODE });
173
+ writeFileSync(this.#path, `${JSON.stringify(cached, null, 2)}\n`, { mode: CREDENTIAL_MODE });
174
+ // `writeFileSync`'s mode applies only when it CREATES the file — an existing file keeps the
175
+ // mode it already had. Re-stating it is what makes an overwrite as private as a first write.
176
+ chmodSync(this.#path, CREDENTIAL_MODE);
177
+ } catch (error) {
178
+ return refuse(
179
+ 'credential-cache-write-failed',
180
+ `the token cache could not be written to ${this.#path}: ${String(error)}`,
181
+ );
182
+ }
183
+
184
+ let observed: number;
185
+ try {
186
+ observed = statSync(this.#path).mode;
187
+ } catch (error) {
188
+ return refuse(
189
+ 'credential-cache-write-failed',
190
+ `the token cache was written and could not be read back from ${this.#path}: ${String(error)}`,
191
+ );
192
+ }
193
+
194
+ const mode = classifyCredentialMode(observed, enforcement);
195
+ if (mode.kind === 'too-wide') {
196
+ this.clear();
197
+ return refuse(
198
+ mode.refusal.reason,
199
+ `${mode.refusal.detail}; the file has been removed rather than left readable`,
200
+ );
201
+ }
202
+
203
+ return ok({ path: this.#path, mode });
204
+ }
205
+
206
+ /** Remove the cache. Idempotent — clearing a cache that is not there is success, not an error. */
207
+ clear(): void {
208
+ try {
209
+ rmSync(this.#path, { force: true });
210
+ } catch {
211
+ // Nothing here can act on the failure, and throwing would turn "sign out" into an error path.
212
+ }
213
+ }
214
+ }