@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,90 @@
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 type { Ticker } from '../core/time.js';
18
+ import type { IdentityConfig, IdentityEndpoints } from '../identity/config.js';
19
+ import type { AuthProtocol } from '../identity/device-code.js';
20
+ import type { CachedTokens, TokenStore } from '../identity/store.js';
21
+ import type { TokenRefresher } from '../identity/credential.js';
22
+ /** The injectable edges. Every one has a real default; the tests replace the transport. */
23
+ export interface SignInDeps {
24
+ readonly fetch?: typeof globalThis.fetch;
25
+ /** Told where to send the user. Defaults to printing the URL, which works on a headless box. */
26
+ readonly present?: (url: string) => void;
27
+ readonly timeoutMs?: number;
28
+ readonly nowMs?: Ticker;
29
+ /** Injected so a device-code poll loop is testable without real waiting. */
30
+ readonly sleep?: (ms: number) => Promise<void>;
31
+ }
32
+ /**
33
+ * The endpoints, from configuration or from the provider's discovery document.
34
+ *
35
+ * Stated endpoints win and skip the network entirely — an operator who pinned them gets no
36
+ * surprise round trip, and an air-gapped or proxied deployment can work without discovery at all.
37
+ */
38
+ export declare function resolveEndpoints(config: IdentityConfig, fetchImpl?: typeof globalThis.fetch): Promise<Result<IdentityEndpoints>>;
39
+ /**
40
+ * Sign a user in through the loopback redirect, and persist what comes back.
41
+ *
42
+ * The listener is opened before the authorization URL is built, because the redirect URI has to
43
+ * carry the port the OS actually gave. Building the URL first would mean either guessing a port
44
+ * or fixing one, and a fixed port collides with whatever else is running on a developer's machine.
45
+ *
46
+ * And the listener is closed on every path. It is a socket accepting callbacks; leaving it open
47
+ * after a failed exchange would leave the thing this flow exists to bound still bound.
48
+ */
49
+ export declare function signIn(config: IdentityConfig, store: TokenStore, deps?: SignInDeps): Promise<Result<CachedTokens>>;
50
+ /** What a device-code sign-in tells the operator to do, before it starts waiting. */
51
+ export interface DeviceCodeInstruction {
52
+ readonly verificationUri: string;
53
+ readonly userCode: string;
54
+ }
55
+ /**
56
+ * Sign in through the device-code flow, for a box with no browser on it.
57
+ *
58
+ * It refuses unless it was explicitly enabled, and that check is the first thing here. The
59
+ * provider calls this a high-risk method and recommends blocking it; a tenant refusing it outright
60
+ * is an expected outcome. It must be asked for by name, never arrived at by falling back — see
61
+ * `identity/device-code.ts` for the policy-blocked-flow trap it also carries.
62
+ *
63
+ * The poll interval is the provider's, not this package's, and `slow_down` widens it. Polling faster than
64
+ * asked is how a client gets rate-limited into failing a sign-in that would have worked.
65
+ */
66
+ export declare function signInWithDeviceCode(config: IdentityConfig, store: TokenStore, instruct: (instruction: DeviceCodeInstruction) => void, deps?: SignInDeps): Promise<Result<CachedTokens>>;
67
+ /**
68
+ * The refresher `TokenCredential` calls when its cached token has expired.
69
+ *
70
+ * An authentication-flows block discards the cache and names the flow to stop using. It does
71
+ * not say "sign in again".
72
+ *
73
+ * Why not: a blocked-flow refusal is the provider's policy refusing the flow (see
74
+ * `identity/device-code.ts`), so the trigger is a tenant admin enabling a policy, not a
75
+ * reconfiguration here. A refresher that cleared the cache and told the operator to sign in again
76
+ * would loop: `protocolFor` derives the flow from static config, so "again" would mean the same
77
+ * device-code flow, into the same policy. Unattended, that is refresh, blocked, clear, device code,
78
+ * blocked, repeat, never holding a usable token. A recovery path that returns to the failing state
79
+ * is not a recovery path.
80
+ *
81
+ * So it routes to the flow that is not blocked. The policy targets device code; loopback
82
+ * authorization-code plus PKCE is implemented here (`identity/authorize.ts`, `identity/pkce.ts`).
83
+ * When loopback is available the refusal says to use it; when it is not, the refusal refuses by
84
+ * name and tells the operator which setting to change, rather than inviting a retry that cannot
85
+ * succeed.
86
+ */
87
+ export declare function refresherFor(config: IdentityConfig, endpoints: IdentityEndpoints, store: TokenStore, deps?: SignInDeps): TokenRefresher;
88
+ /** Which flow a host configured this way will use. One place, so the cache is stamped consistently. */
89
+ export declare function protocolFor(config: IdentityConfig): AuthProtocol;
90
+ //# sourceMappingURL=sign-in.d.ts.map
@@ -0,0 +1,239 @@
1
+ import { ok, refuse } from '../core/result.js';
2
+ import { systemTicker } from '../core/time.js';
3
+ import { discoveryUrl, readDiscoveryDocument } from '../identity/config.js';
4
+ import { buildAuthorizationUrl, readAuthorizationCallback } from '../identity/authorize.js';
5
+ import { createPkce, createState } from '../identity/pkce.js';
6
+ import { deviceAuthorizationBody, devicePollBody, AUTH_FLOW_BLOCKED_ERROR, isAuthFlowBlocked, readDeviceAuthorization, readDevicePoll, requireDeviceCodeEnabled, } from '../identity/device-code.js';
7
+ import { codeExchangeBody, readTokenResponse, refreshBody } from '../identity/token.js';
8
+ import { DEFAULT_SIGN_IN_TIMEOUT_MS, openLoopbackListener } from './loopback.js';
9
+ const FORM = { 'content-type': 'application/x-www-form-urlencoded' };
10
+ /** A `fetch` that never throws: a transport error becomes a named refusal like any other outcome. */
11
+ async function post(fetchImpl, url, body) {
12
+ let response;
13
+ try {
14
+ response = await fetchImpl(url, { method: 'POST', headers: FORM, body });
15
+ }
16
+ catch (error) {
17
+ // A transport failure is an outage, not a refusal by the provider. Reading them as the same
18
+ // thing is how a network blip gets diagnosed as a bad credential.
19
+ return refuse('token-request-failed', `the token endpoint could not be reached: ${String(error)}`);
20
+ }
21
+ return ok({ status: response.status, body: await readJson(response) });
22
+ }
23
+ /** A body that is not JSON is `null`, and the reader above decides what that means. */
24
+ async function readJson(response) {
25
+ try {
26
+ return await response.json();
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /**
33
+ * The endpoints, from configuration or from the provider's discovery document.
34
+ *
35
+ * Stated endpoints win and skip the network entirely — an operator who pinned them gets no
36
+ * surprise round trip, and an air-gapped or proxied deployment can work without discovery at all.
37
+ */
38
+ export async function resolveEndpoints(config, fetchImpl = globalThis.fetch) {
39
+ if (config.endpoints !== null)
40
+ return ok(config.endpoints);
41
+ let response;
42
+ try {
43
+ response = await fetchImpl(discoveryUrl(config.authority));
44
+ }
45
+ catch (error) {
46
+ return refuse('identity-config-invalid', `the provider's discovery document could not be fetched: ${String(error)}`);
47
+ }
48
+ if (!response.ok) {
49
+ return refuse('identity-config-invalid', `the provider's discovery document returned HTTP ${response.status}`);
50
+ }
51
+ return readDiscoveryDocument(await readJson(response));
52
+ }
53
+ /**
54
+ * Sign a user in through the loopback redirect, and persist what comes back.
55
+ *
56
+ * The listener is opened before the authorization URL is built, because the redirect URI has to
57
+ * carry the port the OS actually gave. Building the URL first would mean either guessing a port
58
+ * or fixing one, and a fixed port collides with whatever else is running on a developer's machine.
59
+ *
60
+ * And the listener is closed on every path. It is a socket accepting callbacks; leaving it open
61
+ * after a failed exchange would leave the thing this flow exists to bound still bound.
62
+ */
63
+ export async function signIn(config, store, deps = {}) {
64
+ const fetchImpl = deps.fetch ?? globalThis.fetch;
65
+ const nowMs = deps.nowMs ?? systemTicker;
66
+ const present = deps.present ?? ((url) => process.stdout.write(`open this to sign in:\n${url}\n`));
67
+ const endpoints = await resolveEndpoints(config, fetchImpl);
68
+ if (!endpoints.ok)
69
+ return refuse(endpoints.refusal.reason, endpoints.refusal.detail);
70
+ const opened = await openLoopbackListener(config.redirectPort, deps.timeoutMs ?? DEFAULT_SIGN_IN_TIMEOUT_MS);
71
+ if (!opened.ok)
72
+ return refuse(opened.refusal.reason, opened.refusal.detail);
73
+ const listener = opened.value;
74
+ try {
75
+ const pkce = createPkce();
76
+ const state = createState();
77
+ const request = buildAuthorizationUrl(config, endpoints.value.authorizationEndpoint, pkce, state, listener.redirectUri);
78
+ present(request.url);
79
+ const arrived = await listener.callback;
80
+ if (!arrived.ok)
81
+ return refuse(arrived.refusal.reason, arrived.refusal.detail);
82
+ // The `state` check lives here, in the pure reader, and it runs before the code is touched.
83
+ const callback = readAuthorizationCallback(arrived.value, state);
84
+ if (!callback.ok)
85
+ return refuse(callback.refusal.reason, callback.refusal.detail);
86
+ const exchanged = await post(fetchImpl, endpoints.value.tokenEndpoint, codeExchangeBody(config, callback.value.code, pkce.verifier, listener.redirectUri));
87
+ if (!exchanged.ok)
88
+ return refuse(exchanged.refusal.reason, exchanged.refusal.detail);
89
+ const tokens = readTokenResponse(exchanged.value.status, exchanged.value.body, nowMs());
90
+ if (!tokens.ok)
91
+ return refuse(tokens.refusal.reason, tokens.refusal.detail);
92
+ const cached = {
93
+ tokens: tokens.value,
94
+ protocol: 'loopback',
95
+ authority: config.authority,
96
+ clientId: config.clientId,
97
+ };
98
+ // A cache that would not write is a failed sign-in, and that is the opposite of the rule in
99
+ // `credential.ts`. There, a refresh had already succeeded and the token was in hand. Here the
100
+ // whole point was to persist it: returning success would tell the operator they are signed in
101
+ // while the next start finds nothing.
102
+ const written = store.write(cached);
103
+ if (!written.ok)
104
+ return refuse(written.refusal.reason, written.refusal.detail);
105
+ return ok(cached);
106
+ }
107
+ finally {
108
+ listener.close();
109
+ }
110
+ }
111
+ /**
112
+ * Sign in through the device-code flow, for a box with no browser on it.
113
+ *
114
+ * It refuses unless it was explicitly enabled, and that check is the first thing here. The
115
+ * provider calls this a high-risk method and recommends blocking it; a tenant refusing it outright
116
+ * is an expected outcome. It must be asked for by name, never arrived at by falling back — see
117
+ * `identity/device-code.ts` for the policy-blocked-flow trap it also carries.
118
+ *
119
+ * The poll interval is the provider's, not this package's, and `slow_down` widens it. Polling faster than
120
+ * asked is how a client gets rate-limited into failing a sign-in that would have worked.
121
+ */
122
+ export async function signInWithDeviceCode(config, store, instruct, deps = {}) {
123
+ const enabled = requireDeviceCodeEnabled(config);
124
+ if (!enabled.ok)
125
+ return refuse(enabled.refusal.reason, enabled.refusal.detail);
126
+ const fetchImpl = deps.fetch ?? globalThis.fetch;
127
+ const nowMs = deps.nowMs ?? systemTicker;
128
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
129
+ const endpoints = await resolveEndpoints(config, fetchImpl);
130
+ if (!endpoints.ok)
131
+ return refuse(endpoints.refusal.reason, endpoints.refusal.detail);
132
+ const deviceEndpoint = endpoints.value.deviceAuthorizationEndpoint;
133
+ if (deviceEndpoint === null) {
134
+ return refuse('device-code-declined', 'this provider does not advertise a device authorization endpoint');
135
+ }
136
+ const started = await post(fetchImpl, deviceEndpoint, deviceAuthorizationBody(config));
137
+ if (!started.ok)
138
+ return refuse(started.refusal.reason, started.refusal.detail);
139
+ const authorization = readDeviceAuthorization(started.value.status, started.value.body, nowMs());
140
+ if (!authorization.ok)
141
+ return refuse(authorization.refusal.reason, authorization.refusal.detail);
142
+ instruct({ verificationUri: authorization.value.verificationUri, userCode: authorization.value.userCode });
143
+ let intervalMs = authorization.value.intervalMs;
144
+ const body = devicePollBody(config, authorization.value.deviceCode);
145
+ while (nowMs() < authorization.value.expiresAt) {
146
+ await sleep(intervalMs);
147
+ const polled = await post(fetchImpl, endpoints.value.tokenEndpoint, body);
148
+ if (!polled.ok)
149
+ return refuse(polled.refusal.reason, polled.refusal.detail);
150
+ const outcome = readDevicePoll(polled.value.status, polled.value.body, intervalMs);
151
+ if (outcome.kind === 'pending')
152
+ continue;
153
+ if (outcome.kind === 'slow-down') {
154
+ intervalMs = outcome.intervalMs;
155
+ continue;
156
+ }
157
+ if (outcome.kind === 'declined')
158
+ return refuse(outcome.refusal.reason, outcome.refusal.detail);
159
+ const tokens = readTokenResponse(polled.value.status, polled.value.body, nowMs());
160
+ if (!tokens.ok)
161
+ return refuse(tokens.refusal.reason, tokens.refusal.detail);
162
+ const cached = {
163
+ tokens: tokens.value,
164
+ // Stamped `device-code`, and that stamp is what makes the blocked-flow guard possible. A
165
+ // cache that does not record which flow minted it cannot be refreshed safely by either.
166
+ protocol: 'device-code',
167
+ authority: config.authority,
168
+ clientId: config.clientId,
169
+ };
170
+ const written = store.write(cached);
171
+ if (!written.ok)
172
+ return refuse(written.refusal.reason, written.refusal.detail);
173
+ return ok(cached);
174
+ }
175
+ return refuse('device-code-declined', 'the device code expired before the sign-in was completed');
176
+ }
177
+ /**
178
+ * The refresher `TokenCredential` calls when its cached token has expired.
179
+ *
180
+ * An authentication-flows block discards the cache and names the flow to stop using. It does
181
+ * not say "sign in again".
182
+ *
183
+ * Why not: a blocked-flow refusal is the provider's policy refusing the flow (see
184
+ * `identity/device-code.ts`), so the trigger is a tenant admin enabling a policy, not a
185
+ * reconfiguration here. A refresher that cleared the cache and told the operator to sign in again
186
+ * would loop: `protocolFor` derives the flow from static config, so "again" would mean the same
187
+ * device-code flow, into the same policy. Unattended, that is refresh, blocked, clear, device code,
188
+ * blocked, repeat, never holding a usable token. A recovery path that returns to the failing state
189
+ * is not a recovery path.
190
+ *
191
+ * So it routes to the flow that is not blocked. The policy targets device code; loopback
192
+ * authorization-code plus PKCE is implemented here (`identity/authorize.ts`, `identity/pkce.ts`).
193
+ * When loopback is available the refusal says to use it; when it is not, the refusal refuses by
194
+ * name and tells the operator which setting to change, rather than inviting a retry that cannot
195
+ * succeed.
196
+ */
197
+ export function refresherFor(config, endpoints, store, deps = {}) {
198
+ const fetchImpl = deps.fetch ?? globalThis.fetch;
199
+ const nowMs = deps.nowMs ?? systemTicker;
200
+ return async (cached) => {
201
+ const refreshToken = cached.tokens.refreshToken;
202
+ if (refreshToken === null) {
203
+ return refuse('token-unavailable', 'the cached token has no refresh token, so it cannot be renewed without signing in');
204
+ }
205
+ const response = await post(fetchImpl, endpoints.tokenEndpoint, refreshBody(config, refreshToken));
206
+ if (!response.ok)
207
+ return refuse(response.refusal.reason, response.refusal.detail);
208
+ const tokens = readTokenResponse(response.value.status, response.value.body, nowMs());
209
+ if (!tokens.ok) {
210
+ if (isAuthFlowBlocked(tokens.refusal.detail)) {
211
+ // Cleared either way: the provider's own guidance is that such material is unusable for good
212
+ // and should be deleted. What differs is what the operator is told to do next.
213
+ store.clear();
214
+ // The fallback is only a fallback if it is a different flow. Naming loopback while the
215
+ // host is configured for loopback would be the same loop with a new label.
216
+ const blockedFlow = protocolFor(config);
217
+ if (blockedFlow === 'device-code') {
218
+ return refuse('token-unavailable', `the identity provider's policy has blocked the DEVICE CODE flow this material was minted by ` +
219
+ `(provider code ${AUTH_FLOW_BLOCKED_ERROR}); the token can never be refreshed and the cache has been ` +
220
+ `discarded. Do not re-run the device code flow: the same policy will block it, every time. Set ` +
221
+ `deviceCodeEnabled: false (for the binary, unset PERISCOPE_IDENTITY_DEVICE_CODE) to use the loopback ` +
222
+ `authorization-code (PKCE) flow, which such a policy does not target, or have the provider's ` +
223
+ `administrator exempt this application.`);
224
+ }
225
+ return refuse('token-unavailable', `the identity provider's policy has blocked this material (provider code ${AUTH_FLOW_BLOCKED_ERROR}) ` +
226
+ `and the cache has been discarded. This host is ALREADY configured for the loopback flow, so there is ` +
227
+ `no unblocked flow left to fall back to: the policy must be changed by the provider's administrator. ` +
228
+ `Retrying will not help.`);
229
+ }
230
+ return refuse(tokens.refusal.reason, tokens.refusal.detail);
231
+ }
232
+ return tokens;
233
+ };
234
+ }
235
+ /** Which flow a host configured this way will use. One place, so the cache is stamped consistently. */
236
+ export function protocolFor(config) {
237
+ return config.deviceCodeEnabled ? 'device-code' : 'loopback';
238
+ }
239
+ //# sourceMappingURL=sign-in.js.map
@@ -0,0 +1,42 @@
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
+ import type { RateLimitStanding, TurnSpend } from '../telemetry/usage.js';
17
+ /**
18
+ * A turn's spend, or null for every message that is not a result.
19
+ *
20
+ * Every figure is copied, none computed. No token count is multiplied by anything here; the
21
+ * agent priced the turn and this carries what it said. That is what makes a mixed-model turn correct
22
+ * without this package holding a price table that would rot.
23
+ */
24
+ export declare function readTurnSpend(message: SDKMessage): TurnSpend | null;
25
+ /**
26
+ * Where a rate limit stands, or null for every other message.
27
+ *
28
+ * It arrives in-stream, which is the part worth noticing: no unofficial endpoint has to be polled
29
+ * to learn this. Nothing here polls anything.
30
+ */
31
+ export declare function readRateLimit(message: SDKMessage): RateLimitStanding | null;
32
+ /**
33
+ * A subagent's own usage, off the task notification that reports it.
34
+ *
35
+ * This is what makes a subagent's cost attributable: the agent reports per-task usage, and hooks
36
+ * carry an agent id, so spend has an owner without anything being inferred.
37
+ */
38
+ export declare function readTaskSpend(message: SDKMessage): {
39
+ agentId: string;
40
+ spend: TurnSpend;
41
+ } | null;
42
+ //# sourceMappingURL=telemetry.d.ts.map
@@ -0,0 +1,74 @@
1
+ /**
2
+ * A turn's spend, or null for every message that is not a result.
3
+ *
4
+ * Every figure is copied, none computed. No token count is multiplied by anything here; the
5
+ * agent priced the turn and this carries what it said. That is what makes a mixed-model turn correct
6
+ * without this package holding a price table that would rot.
7
+ */
8
+ export function readTurnSpend(message) {
9
+ if (message.type !== 'result')
10
+ return null;
11
+ const byModel = Object.entries(message.modelUsage ?? {}).map(([model, usage]) => ({
12
+ model,
13
+ costUsd: usage.costUSD,
14
+ inputTokens: usage.inputTokens,
15
+ outputTokens: usage.outputTokens,
16
+ cacheReadInputTokens: usage.cacheReadInputTokens,
17
+ cacheCreationInputTokens: usage.cacheCreationInputTokens,
18
+ // Absent is null rather than zero: a zero window reads as a real, tiny limit.
19
+ contextWindow: usage.contextWindow ?? null,
20
+ canonicalModel: usage.canonicalModel ?? null,
21
+ provider: usage.provider ?? null,
22
+ }));
23
+ return { totalCostUsd: message.total_cost_usd, byModel };
24
+ }
25
+ /**
26
+ * Where a rate limit stands, or null for every other message.
27
+ *
28
+ * It arrives in-stream, which is the part worth noticing: no unofficial endpoint has to be polled
29
+ * to learn this. Nothing here polls anything.
30
+ */
31
+ export function readRateLimit(message) {
32
+ if (message.type !== 'rate_limit_event')
33
+ return null;
34
+ const info = message.rate_limit_info;
35
+ return {
36
+ status: info.status,
37
+ resetsAt: info.resetsAt ?? null,
38
+ limitType: info.rateLimitType ?? null,
39
+ utilization: info.utilization ?? null,
40
+ };
41
+ }
42
+ /**
43
+ * A subagent's own usage, off the task notification that reports it.
44
+ *
45
+ * This is what makes a subagent's cost attributable: the agent reports per-task usage, and hooks
46
+ * carry an agent id, so spend has an owner without anything being inferred.
47
+ */
48
+ export function readTaskSpend(message) {
49
+ if (message.type !== 'system' || message.subtype !== 'task_notification')
50
+ return null;
51
+ const usage = message.model_usage;
52
+ const totalCost = message.total_cost_usd;
53
+ const agentId = message.agent_id;
54
+ if (usage === undefined || totalCost === undefined || agentId === undefined)
55
+ return null;
56
+ return {
57
+ agentId,
58
+ spend: {
59
+ totalCostUsd: totalCost,
60
+ byModel: Object.entries(usage).map(([model, one]) => ({
61
+ model,
62
+ costUsd: one.costUSD,
63
+ inputTokens: one.inputTokens,
64
+ outputTokens: one.outputTokens,
65
+ cacheReadInputTokens: one.cacheReadInputTokens,
66
+ cacheCreationInputTokens: one.cacheCreationInputTokens,
67
+ contextWindow: one.contextWindow ?? null,
68
+ canonicalModel: one.canonicalModel ?? null,
69
+ provider: one.provider ?? null,
70
+ })),
71
+ },
72
+ };
73
+ }
74
+ //# sourceMappingURL=telemetry.js.map
@@ -0,0 +1,62 @@
1
+ import type { Result } from '../core/result.js';
2
+ import type { CredentialModeOutcome, ModeEnforcement } from '../identity/mode.js';
3
+ import type { CachedTokens, TokenStore } from '../identity/store.js';
4
+ export interface TokenCacheWrite {
5
+ readonly path: string;
6
+ readonly mode: CredentialModeOutcome;
7
+ }
8
+ /**
9
+ * Measure what this filesystem does with a mode.
10
+ *
11
+ * Measured rather than listed by platform. A `process.platform === 'win32'` test is a guess that
12
+ * ages: WSL, a POSIX filesystem mounted under Windows, and a future runtime mapping modes onto ACLs
13
+ * would each make it wrong in the direction that matters. The probe writes a throwaway file BESIDE
14
+ * the cache — the same filesystem, which is the thing actually in question.
15
+ *
16
+ * It asserts its positive control before it reports the negative, and that ordering is the whole
17
+ * design. Three measurements, in this order:
18
+ *
19
+ * 1. Ask for `0600` and for `0666`. If they read back differently AND `0600` came back as `0600`,
20
+ * POSIX modes are honoured. Done.
21
+ * 2. They did not differ — so privacy is unconfirmable. **Before saying so, prove the instrument
22
+ * is alive**: clear the write bit with `0444`. Measured on win32, that DOES read back as
23
+ * `444`, because the write bit is the one real bit there. If it changes, this is a genuine
24
+ * finding about a filesystem that records only writability.
25
+ * 3. Even `0444` changed nothing. Then the probe cannot distinguish "this filesystem records
26
+ * nothing" from "this probe is broken", and it says exactly that.
27
+ *
28
+ * Without step 2, "unenforced" and "the probe never worked" are the same answer — an instrument
29
+ * with no inconclusive state, which is how a dead check gets read as evidence.
30
+ *
31
+ * `0444` is the control, never the target. The cache must stay owner-writable; refreshes are
32
+ * written to it. The probe restores a writable mode on its own throwaway file before removing it,
33
+ * because a read-only file is one `rmSync` can fail on.
34
+ */
35
+ export declare function probeModeEnforcement(directory: string): ModeEnforcement;
36
+ export declare class FileTokenCache implements TokenStore {
37
+ #private;
38
+ constructor(path: string);
39
+ get path(): string;
40
+ /** Memoised: the filesystem does not change its mind mid-process. */
41
+ modeEnforcement(): ModeEnforcement;
42
+ /**
43
+ * Read the cache.
44
+ *
45
+ * A missing file is `token-unavailable` — nobody has signed in — and that is deliberately NOT the
46
+ * same reason as a file that exists and cannot be parsed, which is a real problem with a real
47
+ * fix. Collapsing them would make a corrupted cache look like a fresh install forever.
48
+ */
49
+ read(): Result<CachedTokens>;
50
+ /**
51
+ * Write the cache, then verify what actually landed.
52
+ *
53
+ * A file that came out wider than asked is removed, not left and reported. Leaving a
54
+ * world-readable token on disk while returning a refusal would mean the caller sees a failure and
55
+ * the credential is exposed anyway — the worst of both. No cache at all is strictly safer than a
56
+ * readable one, and the sign-in can simply be repeated.
57
+ */
58
+ write(cached: CachedTokens): Result<TokenCacheWrite>;
59
+ /** Remove the cache. Idempotent — clearing a cache that is not there is success, not an error. */
60
+ clear(): void;
61
+ }
62
+ //# sourceMappingURL=token-cache.d.ts.map