@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,23 @@
1
+ export type WorkspaceTrust =
2
+ /** The config records this directory as trusted. */
3
+ 'trusted'
4
+ /** The config was read and this directory is either absent from it or recorded as untrusted. */
5
+ | 'untrusted'
6
+ /** No config, or one that could not be read. NOT the same as untrusted — see below. */
7
+ | 'unknown';
8
+ export declare function isUntrustedWorkspaceWarning(line: string): boolean;
9
+ /** Where the CLI keeps per-directory trust. Separate from the settings files. */
10
+ export declare function trustConfigPath(homeDir: string): string;
11
+ /**
12
+ * Read the recorded trust for `cwd`.
13
+ *
14
+ * Comparison is normalized and case-insensitive because the recorded keys are whatever absolute
15
+ * path the CLI was started with — `C:\x` and `c:/x` are the same directory on Windows and would
16
+ * otherwise read as two.
17
+ *
18
+ * `unknown` is a third answer and not a synonym for untrusted. A config that cannot be read
19
+ * supports no claim, and reporting "untrusted" for it would state a fact about the user's machine
20
+ * that was never observed.
21
+ */
22
+ export declare function readWorkspaceTrust(configPath: string, cwd: string): WorkspaceTrust;
23
+ //# sourceMappingURL=workspace-trust.d.ts.map
@@ -0,0 +1,80 @@
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
+ import { normalizePath } from '../core/paths.js';
25
+ /**
26
+ * The stderr line the CLI emits when it drops rules for this reason, as a matcher.
27
+ *
28
+ * Deliberately loose: it matches the stable part of a message whose wording the CLI owns, so a
29
+ * reworded prefix or a different rule count still trips it. A matcher that is too precise here fails
30
+ * open, which is the direction that reproduces the original silence.
31
+ */
32
+ const UNTRUSTED_STDERR = /has not been trusted/i;
33
+ export function isUntrustedWorkspaceWarning(line) {
34
+ return UNTRUSTED_STDERR.test(line);
35
+ }
36
+ /** Where the CLI keeps per-directory trust. Separate from the settings files. */
37
+ export function trustConfigPath(homeDir) {
38
+ return `${normalizePath(homeDir)}/.claude.json`;
39
+ }
40
+ /**
41
+ * Read the recorded trust for `cwd`.
42
+ *
43
+ * Comparison is normalized and case-insensitive because the recorded keys are whatever absolute
44
+ * path the CLI was started with — `C:\x` and `c:/x` are the same directory on Windows and would
45
+ * otherwise read as two.
46
+ *
47
+ * `unknown` is a third answer and not a synonym for untrusted. A config that cannot be read
48
+ * supports no claim, and reporting "untrusted" for it would state a fact about the user's machine
49
+ * that was never observed.
50
+ */
51
+ export function readWorkspaceTrust(configPath, cwd) {
52
+ let raw;
53
+ try {
54
+ raw = readFileSync(configPath, 'utf8');
55
+ }
56
+ catch {
57
+ return 'unknown';
58
+ }
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(raw);
62
+ }
63
+ catch {
64
+ return 'unknown';
65
+ }
66
+ const projects = parsed.projects;
67
+ if (typeof projects !== 'object' || projects === null)
68
+ return 'unknown';
69
+ const wanted = normalizePath(cwd).toLowerCase();
70
+ for (const [recordedPath, entry] of Object.entries(projects)) {
71
+ if (normalizePath(recordedPath).toLowerCase() !== wanted)
72
+ continue;
73
+ const accepted = entry.hasTrustDialogAccepted;
74
+ return accepted === true ? 'trusted' : 'untrusted';
75
+ }
76
+ // The file exists and lists projects; this one is simply not among them. That IS an observation:
77
+ // a directory the CLI has never been trusted in.
78
+ return 'untrusted';
79
+ }
80
+ //# sourceMappingURL=workspace-trust.js.map
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The authorization request, and the callback that answers it — pure, so the whole decision surface
3
+ * can be exercised without opening a port or a browser.
4
+ *
5
+ * The loopback redirect is what `az`, `gh` and `kubectl` do, and it is the primary flow here for
6
+ * the reason those tools chose it: the authorization code comes back to a listener only this
7
+ * machine can reach, and the browser doing the sign-in is the user's real one, with their real
8
+ * session, their real conditional-access evaluation and their real second factor.
9
+ *
10
+ * Nothing in this module puts an authorization code into a refusal. A code is redeemable
11
+ * credential material for as long as it lives, and a refusal `detail` is written to logs by
12
+ * definition — that is the exact path by which a credential ends up in a diagnostic. Refusals here
13
+ * say which field was wrong, never what it contained.
14
+ */
15
+ import type { Result } from '../core/result.js';
16
+ import type { IdentityConfig } from './config.js';
17
+ import type { Pkce } from './pkce.js';
18
+ /** Everything one sign-in attempt must remember between the request and the callback. */
19
+ export interface AuthorizationRequest {
20
+ readonly url: string;
21
+ readonly state: string;
22
+ readonly pkce: Pkce;
23
+ readonly redirectUri: string;
24
+ }
25
+ /** The loopback redirect URI for a port the OS has already assigned. */
26
+ export declare function redirectUriFor(port: number): string;
27
+ /**
28
+ * Build the URL the user's browser is sent to.
29
+ *
30
+ * `redirectUri` is passed rather than derived because the port is not known until the listener is
31
+ * bound — asking the OS for a free port and then telling the provider about it is the ordinary
32
+ * loopback shape.
33
+ */
34
+ export declare function buildAuthorizationUrl(config: IdentityConfig, authorizationEndpoint: string, pkce: Pkce, state: string, redirectUri: string): AuthorizationRequest;
35
+ /** What the provider sent back, once it has been proven to answer this host's request. */
36
+ export interface AuthorizationCallback {
37
+ readonly code: string;
38
+ }
39
+ /**
40
+ * Read a callback and decide whether it is the answer to this host's request.
41
+ *
42
+ * The `state` check runs before the code is even looked at, and that ordering is the point. A
43
+ * callback carrying a perfectly good authorization code from somebody else's authorization request
44
+ * is exactly the attack: any local process can reach this host's loopback listener, because the
45
+ * agent shares this host's OS user. Reading the code first and validating afterwards would work
46
+ * identically in every test and leave the window open.
47
+ *
48
+ * `rawQuery` is the callback request's query string, taken from the listener verbatim.
49
+ */
50
+ export declare function readAuthorizationCallback(rawQuery: string, expectedState: string): Result<AuthorizationCallback>;
51
+ /**
52
+ * The page the browser lands on after the callback.
53
+ *
54
+ * Deliberately static and self-contained — no script, no external asset, and nothing derived from
55
+ * the query string. Reflecting any part of the callback into this HTML would be a cross-site
56
+ * scripting hole in a page that renders in the user's real, signed-in browser.
57
+ */
58
+ export declare const CALLBACK_PAGE: string;
59
+ //# sourceMappingURL=authorize.d.ts.map
@@ -0,0 +1,72 @@
1
+ import { ok, refuse } from '../core/result.js';
2
+ import { stateMatches } from './pkce.js';
3
+ /** The loopback redirect URI for a port the OS has already assigned. */
4
+ export function redirectUriFor(port) {
5
+ // 127.0.0.1, never `localhost`. `localhost` can resolve to ::1 or be redefined in a hosts
6
+ // file, and the provider matches the redirect URI as a literal string — so the registered value
7
+ // and the listener must agree on the exact spelling, not on a name that resolves.
8
+ return `http://127.0.0.1:${port}/callback`;
9
+ }
10
+ /**
11
+ * Build the URL the user's browser is sent to.
12
+ *
13
+ * `redirectUri` is passed rather than derived because the port is not known until the listener is
14
+ * bound — asking the OS for a free port and then telling the provider about it is the ordinary
15
+ * loopback shape.
16
+ */
17
+ export function buildAuthorizationUrl(config, authorizationEndpoint, pkce, state, redirectUri) {
18
+ const url = new URL(authorizationEndpoint);
19
+ url.searchParams.set('client_id', config.clientId);
20
+ url.searchParams.set('response_type', 'code');
21
+ url.searchParams.set('redirect_uri', redirectUri);
22
+ url.searchParams.set('scope', config.scopes.join(' '));
23
+ url.searchParams.set('state', state);
24
+ url.searchParams.set('code_challenge', pkce.challenge);
25
+ url.searchParams.set('code_challenge_method', pkce.method);
26
+ return { url: url.toString(), state, pkce, redirectUri };
27
+ }
28
+ /**
29
+ * Read a callback and decide whether it is the answer to this host's request.
30
+ *
31
+ * The `state` check runs before the code is even looked at, and that ordering is the point. A
32
+ * callback carrying a perfectly good authorization code from somebody else's authorization request
33
+ * is exactly the attack: any local process can reach this host's loopback listener, because the
34
+ * agent shares this host's OS user. Reading the code first and validating afterwards would work
35
+ * identically in every test and leave the window open.
36
+ *
37
+ * `rawQuery` is the callback request's query string, taken from the listener verbatim.
38
+ */
39
+ export function readAuthorizationCallback(rawQuery, expectedState) {
40
+ const params = new URLSearchParams(rawQuery);
41
+ const receivedState = params.get('state');
42
+ if (receivedState === null || !stateMatches(expectedState, receivedState)) {
43
+ return refuse('auth-state-mismatch', receivedState === null
44
+ ? "the callback carried no state value, so it cannot be shown to answer this host's request"
45
+ : "the callback's state is not the one this host minted — this callback answers somebody else's authorization request and its code is not redeemed");
46
+ }
47
+ // Only now is anything else in the callback worth reading.
48
+ const error = params.get('error');
49
+ if (error !== null) {
50
+ const description = params.get('error_description');
51
+ return refuse('auth-callback-refused', `the provider returned ${error}${description === null ? '' : `: ${description}`}`);
52
+ }
53
+ const code = params.get('code');
54
+ if (code === null || code === '') {
55
+ return refuse('auth-callback-refused', 'the callback carried neither an authorization code nor an error, so there is nothing to redeem');
56
+ }
57
+ return ok({ code });
58
+ }
59
+ /**
60
+ * The page the browser lands on after the callback.
61
+ *
62
+ * Deliberately static and self-contained — no script, no external asset, and nothing derived from
63
+ * the query string. Reflecting any part of the callback into this HTML would be a cross-site
64
+ * scripting hole in a page that renders in the user's real, signed-in browser.
65
+ */
66
+ export const CALLBACK_PAGE = [
67
+ '<!doctype html><meta charset="utf-8"><title>Signed in</title>',
68
+ '<body style="font-family:system-ui;padding:2rem">',
69
+ '<h1>Signed in</h1><p>You can close this tab and return to the terminal.</p>',
70
+ '</body>',
71
+ ].join('');
72
+ //# sourceMappingURL=authorize.js.map
@@ -0,0 +1,98 @@
1
+ /**
2
+ * What this host needs to know before it can sign anyone in — and the named refusal when it does
3
+ * not know it.
4
+ *
5
+ * The authority is config, never a constant. That is the one thing this module exists to
6
+ * guarantee. A hardcoded issuer would make the package work for exactly one tenant and look like it
7
+ * worked for everyone, and it is the difference between something a stranger can point at their own
8
+ * provider and something only its authors can run. `pins/identity-generic.test.ts` scans the source
9
+ * for an embedded authority host, so this cannot regress quietly.
10
+ *
11
+ * Absence is a refusal with a name, not a default. There is no fallback issuer, no implicit
12
+ * tenant, and no "unauthenticated mode" — a host that cannot prove who it is does not connect. The
13
+ * two unset/invalid reasons are kept apart because they are different people's problems: nothing
14
+ * configured is a setup step nobody has done, and a bad value is a setup step done wrong.
15
+ */
16
+ import type { Result } from '../core/result.js';
17
+ /**
18
+ * The endpoints an OAuth 2.0 authorization-code exchange needs.
19
+ *
20
+ * Separate from the config because there are two honest ways to get them — the operator states
21
+ * them, or they are read from the provider's discovery document — and the flow should not care
22
+ * which happened.
23
+ */
24
+ export interface IdentityEndpoints {
25
+ readonly authorizationEndpoint: string;
26
+ readonly tokenEndpoint: string;
27
+ /** Null when the provider does not advertise the device-code grant. */
28
+ readonly deviceAuthorizationEndpoint: string | null;
29
+ }
30
+ export interface IdentityConfig {
31
+ /**
32
+ * The issuer this host authenticates against.
33
+ *
34
+ * A consumer-identity (CIAM) authority is typically single-tenant — so a workplace's employees
35
+ * cannot sign in to it. The answer to that is a registration in the workplace's own tenant
36
+ * pointed at by this value, which costs no code precisely because this is a setting. See the
37
+ * README's deployment note.
38
+ */
39
+ readonly authority: string;
40
+ readonly clientId: string;
41
+ readonly scopes: readonly string[];
42
+ /** Explicitly stated endpoints; null means "read the discovery document". */
43
+ readonly endpoints: IdentityEndpoints | null;
44
+ /** Loopback redirect port. 0 asks the OS for a free one, which is the ordinary case. */
45
+ readonly redirectPort: number;
46
+ /**
47
+ * Off unless the operator turns it on. The device-code grant is never reached by falling back
48
+ * to it — see `device-code.ts` for why the provider itself recommends blocking it.
49
+ */
50
+ readonly deviceCodeEnabled: boolean;
51
+ }
52
+ /**
53
+ * `offline_access` is in the default set deliberately: without a refresh token every access token
54
+ * expiring means an interactive sign-in, and this host is meant to run unattended.
55
+ */
56
+ export declare const DEFAULT_SCOPES: readonly string[];
57
+ /**
58
+ * Read the identity configuration, or say precisely what is missing.
59
+ *
60
+ * The environment is passed in rather than read, matching the rest of the package: the composition
61
+ * root supplies the environment, helpers only default to `process.env` when called bare, and every
62
+ * module below the root is testable without a process.
63
+ */
64
+ export declare function readIdentityConfig(env: NodeJS.ProcessEnv): Result<IdentityConfig>;
65
+ /**
66
+ * What the composition root should do about identity, decided here so `bin/` stays a wiring file.
67
+ *
68
+ * The three-way split is the point, and the middle one is the easy mistake. "Configured wrong"
69
+ * must not degrade to "not configured": an operator who set an authority and mistyped it has stated
70
+ * an intention, and silently starting without identity would honour the typo instead of the
71
+ * intention — the host would come up looking healthy and authenticate as nobody.
72
+ *
73
+ * configured — build the real credential.
74
+ * absent — nothing was set. Start without identity; the existing credential refuses by
75
+ * name, which is exactly what this host did before identity existed.
76
+ * invalid — something was set and cannot be used. Refuse to start.
77
+ */
78
+ export type IdentityPosture = {
79
+ readonly kind: 'configured';
80
+ readonly config: IdentityConfig;
81
+ } | {
82
+ readonly kind: 'absent';
83
+ } | {
84
+ readonly kind: 'invalid';
85
+ readonly detail: string;
86
+ };
87
+ export declare function identityPosture(env: NodeJS.ProcessEnv): IdentityPosture;
88
+ /**
89
+ * The provider's discovery document URL.
90
+ *
91
+ * OpenID Connect Discovery, so this works for any conforming provider rather than one vendor's URL
92
+ * shape. The trailing-slash handling matters: `new URL('.well-known/…', 'https://x/tenant')` would
93
+ * drop `tenant`.
94
+ */
95
+ export declare function discoveryUrl(authority: string): string;
96
+ /** Parse a discovery document into the endpoints, refusing anything that is not usable. */
97
+ export declare function readDiscoveryDocument(body: unknown): Result<IdentityEndpoints>;
98
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1,130 @@
1
+ import { ok, refuse } from '../core/result.js';
2
+ /**
3
+ * `offline_access` is in the default set deliberately: without a refresh token every access token
4
+ * expiring means an interactive sign-in, and this host is meant to run unattended.
5
+ */
6
+ export const DEFAULT_SCOPES = ['openid', 'profile', 'offline_access'];
7
+ const REQUIRED = 'PERISCOPE_IDENTITY_AUTHORITY and PERISCOPE_IDENTITY_CLIENT_ID';
8
+ function trimmed(env, key) {
9
+ const value = env[key];
10
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
11
+ }
12
+ /**
13
+ * Is this a usable issuer URL?
14
+ *
15
+ * HTTPS is required and `localhost` is not exempted. A plaintext authority would put the
16
+ * authorization code and then the token itself on the wire in clear, and "it is only the test
17
+ * environment" is how that reaches production. A provider being tested locally is reached through
18
+ * its real https endpoint like any other.
19
+ */
20
+ function invalidAuthority(authority) {
21
+ let parsed;
22
+ try {
23
+ parsed = new URL(authority);
24
+ }
25
+ catch {
26
+ return `PERISCOPE_IDENTITY_AUTHORITY is not a URL: ${authority}`;
27
+ }
28
+ if (parsed.protocol !== 'https:') {
29
+ return `PERISCOPE_IDENTITY_AUTHORITY must be https, got ${parsed.protocol}//`;
30
+ }
31
+ return null;
32
+ }
33
+ /** Endpoints are all-or-nothing: a half-stated set is a config error, never a partial default. */
34
+ function readEndpoints(env) {
35
+ const authorizationEndpoint = trimmed(env, 'PERISCOPE_IDENTITY_AUTHORIZE_URL');
36
+ const tokenEndpoint = trimmed(env, 'PERISCOPE_IDENTITY_TOKEN_URL');
37
+ const deviceAuthorizationEndpoint = trimmed(env, 'PERISCOPE_IDENTITY_DEVICE_CODE_URL');
38
+ if (authorizationEndpoint === null && tokenEndpoint === null) {
39
+ // Neither stated: discovery will supply both. The device endpoint alone is not enough to act on.
40
+ return ok(null);
41
+ }
42
+ if (authorizationEndpoint === null || tokenEndpoint === null) {
43
+ return refuse('identity-config-invalid', 'PERISCOPE_IDENTITY_AUTHORIZE_URL and PERISCOPE_IDENTITY_TOKEN_URL must be set together or not at all — a half-stated endpoint set would silently discover the other half and mix two providers');
44
+ }
45
+ return ok({ authorizationEndpoint, tokenEndpoint, deviceAuthorizationEndpoint });
46
+ }
47
+ function readPort(env) {
48
+ const raw = trimmed(env, 'PERISCOPE_IDENTITY_REDIRECT_PORT');
49
+ if (raw === null)
50
+ return ok(0);
51
+ const port = Number(raw);
52
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
53
+ return refuse('identity-config-invalid', `PERISCOPE_IDENTITY_REDIRECT_PORT must be an integer 0-65535, got ${raw}`);
54
+ }
55
+ return ok(port);
56
+ }
57
+ /**
58
+ * Read the identity configuration, or say precisely what is missing.
59
+ *
60
+ * The environment is passed in rather than read, matching the rest of the package: the composition
61
+ * root supplies the environment, helpers only default to `process.env` when called bare, and every
62
+ * module below the root is testable without a process.
63
+ */
64
+ export function readIdentityConfig(env) {
65
+ const authority = trimmed(env, 'PERISCOPE_IDENTITY_AUTHORITY');
66
+ const clientId = trimmed(env, 'PERISCOPE_IDENTITY_CLIENT_ID');
67
+ if (authority === null || clientId === null) {
68
+ return refuse('identity-not-configured', `${REQUIRED} must both be set; this host does not authenticate without them and has no default issuer`);
69
+ }
70
+ const badAuthority = invalidAuthority(authority);
71
+ if (badAuthority !== null)
72
+ return refuse('identity-config-invalid', badAuthority);
73
+ const endpoints = readEndpoints(env);
74
+ if (!endpoints.ok)
75
+ return refuse(endpoints.refusal.reason, endpoints.refusal.detail);
76
+ const port = readPort(env);
77
+ if (!port.ok)
78
+ return refuse(port.refusal.reason, port.refusal.detail);
79
+ const rawScopes = trimmed(env, 'PERISCOPE_IDENTITY_SCOPES');
80
+ const scopes = rawScopes === null ? DEFAULT_SCOPES : rawScopes.split(/[\s,]+/).filter((scope) => scope !== '');
81
+ if (scopes.length === 0) {
82
+ return refuse('identity-config-invalid', 'PERISCOPE_IDENTITY_SCOPES was set but lists no scope');
83
+ }
84
+ return ok({
85
+ authority,
86
+ clientId,
87
+ scopes,
88
+ endpoints: endpoints.value,
89
+ redirectPort: port.value,
90
+ deviceCodeEnabled: trimmed(env, 'PERISCOPE_IDENTITY_DEVICE_CODE') === '1',
91
+ });
92
+ }
93
+ export function identityPosture(env) {
94
+ const result = readIdentityConfig(env);
95
+ if (result.ok)
96
+ return { kind: 'configured', config: result.value };
97
+ if (result.refusal.reason === 'identity-not-configured')
98
+ return { kind: 'absent' };
99
+ return { kind: 'invalid', detail: result.refusal.detail };
100
+ }
101
+ /**
102
+ * The provider's discovery document URL.
103
+ *
104
+ * OpenID Connect Discovery, so this works for any conforming provider rather than one vendor's URL
105
+ * shape. The trailing-slash handling matters: `new URL('.well-known/…', 'https://x/tenant')` would
106
+ * drop `tenant`.
107
+ */
108
+ export function discoveryUrl(authority) {
109
+ const base = authority.endsWith('/') ? authority : `${authority}/`;
110
+ return new URL('.well-known/openid-configuration', base).toString();
111
+ }
112
+ /** Parse a discovery document into the endpoints, refusing anything that is not usable. */
113
+ export function readDiscoveryDocument(body) {
114
+ if (typeof body !== 'object' || body === null) {
115
+ return refuse('identity-config-invalid', 'the discovery document is not a JSON object');
116
+ }
117
+ const record = body;
118
+ const authorizationEndpoint = record['authorization_endpoint'];
119
+ const tokenEndpoint = record['token_endpoint'];
120
+ const deviceAuthorizationEndpoint = record['device_authorization_endpoint'];
121
+ if (typeof authorizationEndpoint !== 'string' || typeof tokenEndpoint !== 'string') {
122
+ return refuse('identity-config-invalid', 'the discovery document does not advertise both authorization_endpoint and token_endpoint');
123
+ }
124
+ return ok({
125
+ authorizationEndpoint,
126
+ tokenEndpoint,
127
+ deviceAuthorizationEndpoint: typeof deviceAuthorizationEndpoint === 'string' ? deviceAuthorizationEndpoint : null,
128
+ });
129
+ }
130
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Presenting the user's own token to the controller.
3
+ *
4
+ * What this replaces: a host reaching its controller with a shared secret that authenticates it as
5
+ * any user, which every agent on the box can read. What it presents instead is one user's own
6
+ * access token: scoped to them, expiring by itself, and revocable from the identity provider
7
+ * without anyone touching this machine. That is a different order of exposure, not a smaller
8
+ * amount of the same one.
9
+ *
10
+ * `authorize()` never starts an interactive sign-in. It reads what is cached, refreshes silently
11
+ * when it can, and otherwise refuses by name. Opening a browser from inside a reconnect would mean
12
+ * an unattended host at 3am trying to render a login page nobody is looking at, retrying forever —
13
+ * and on a headless box it would simply hang. Signing in is a deliberate act somebody performs;
14
+ * this is the part that runs on its own.
15
+ *
16
+ * And it never reads `apiKeySource` to decide anything. That field names which key source an
17
+ * agent session used, and it reads `none` on a session that is fully authenticated and billing real
18
+ * money — ambient and subscription auth are not keys. It looks exactly like an "is this
19
+ * authenticated" answer and is not one. Authentication state here comes from the cache and the
20
+ * token's own expiry; for an agent session, a completed turn is the proof.
21
+ */
22
+ import type { Authorization, ControllerCredential } from '../control/credential.js';
23
+ import type { Ticker } from '../core/time.js';
24
+ import type { RefusalReason } from '../core/refusal.js';
25
+ import type { Result } from '../core/result.js';
26
+ import type { IdentityConfig } from './config.js';
27
+ import type { AuthProtocol } from './device-code.js';
28
+ import type { CachedTokens, TokenStore } from './store.js';
29
+ import type { TokenSet } from './token.js';
30
+ /**
31
+ * Exchanges a refresh token for a fresh set.
32
+ *
33
+ * Injected so the credential's whole decision surface is testable without a network, and so the
34
+ * transport stays in one place.
35
+ */
36
+ export type TokenRefresher = (cached: CachedTokens) => Promise<Result<TokenSet>>;
37
+ /**
38
+ * Which of the three things `authorize()` did — because from the outside they are identical.
39
+ *
40
+ * Nothing under `identity/` emits anything, and `control/link.ts` swallows a credential refusal
41
+ * and connects with no header rather than pretending to hold a scheme — which is deliberate.
42
+ * Without a report, a cache hit, a silent refresh and "nobody has ever signed in on this machine"
43
+ * produce byte-identical output, and the first thing that distinguishes them is a 401 arriving
44
+ * from the controller minutes later. On a binary running on the user's own laptop that reads as a
45
+ * server problem, and the user is never told the one thing they can act on.
46
+ *
47
+ * It carries no token material, and that is a contract rather than an oversight — an outcome name,
48
+ * a refusal reason and the refusal's own already-human detail. Pinned by this package's own suite.
49
+ */
50
+ export type CredentialOutcome = {
51
+ readonly kind: 'cache-hit';
52
+ } | {
53
+ readonly kind: 'refreshed';
54
+ } | {
55
+ readonly kind: 'refused';
56
+ readonly reason: RefusalReason;
57
+ readonly detail: string;
58
+ };
59
+ /**
60
+ * Where an outcome is reported.
61
+ *
62
+ * Data, not a log line. `identity/` returns `Result`s and formats nothing, on the same split every
63
+ * other seam in this package uses: the embedder decides what a line looks like and where it goes.
64
+ */
65
+ export type CredentialReporter = (outcome: CredentialOutcome) => void;
66
+ export interface TokenCredentialOptions {
67
+ readonly store: TokenStore;
68
+ readonly config: IdentityConfig;
69
+ /** Which flow this host is configured to use — the protocol-mismatch guard compares against it. */
70
+ readonly protocol: AuthProtocol;
71
+ /** The package's own millisecond ticker, injected like every other clock here. */
72
+ readonly nowMs: Ticker;
73
+ /** Omit to refuse rather than refresh — useful for a host that only ever reads. */
74
+ readonly refresh?: TokenRefresher;
75
+ /** Omit for a credential that decides in silence, which is what this was before it could say. */
76
+ readonly report?: CredentialReporter;
77
+ }
78
+ /** The header the controller link puts on its connect request. */
79
+ export declare const AUTHORIZATION_HEADER = "Authorization";
80
+ export declare class TokenCredential implements ControllerCredential {
81
+ #private;
82
+ constructor(options: TokenCredentialOptions);
83
+ /**
84
+ * One report, at one place, derived from the decision rather than repeated alongside it.
85
+ *
86
+ * `#decide` holds every branch and this holds the only call to `report`, so a path added later
87
+ * cannot forget to say what it did — which is precisely how a silent path arises.
88
+ */
89
+ authorize(): Promise<Result<Authorization>>;
90
+ }
91
+ //# sourceMappingURL=credential.d.ts.map
@@ -0,0 +1,76 @@
1
+ import { ok, refuse } from '../core/result.js';
2
+ import { discardOnProtocolMismatch } from './device-code.js';
3
+ import { authorizationValue, isFresh } from './token.js';
4
+ /** The header the controller link puts on its connect request. */
5
+ export const AUTHORIZATION_HEADER = 'Authorization';
6
+ export class TokenCredential {
7
+ #options;
8
+ constructor(options) {
9
+ this.#options = options;
10
+ }
11
+ /**
12
+ * One report, at one place, derived from the decision rather than repeated alongside it.
13
+ *
14
+ * `#decide` holds every branch and this holds the only call to `report`, so a path added later
15
+ * cannot forget to say what it did — which is precisely how a silent path arises.
16
+ */
17
+ async authorize() {
18
+ const decided = await this.#decide();
19
+ const report = this.#options.report;
20
+ if (report !== undefined) {
21
+ report(decided.ok
22
+ ? { kind: decided.value.via }
23
+ : { kind: 'refused', reason: decided.refusal.reason, detail: decided.refusal.detail });
24
+ }
25
+ if (!decided.ok)
26
+ return refuse(decided.refusal.reason, decided.refusal.detail);
27
+ return ok(decided.value.authorization);
28
+ }
29
+ async #decide() {
30
+ const { store, config, protocol, nowMs, refresh } = this.#options;
31
+ const cached = store.read();
32
+ if (!cached.ok)
33
+ return refuse(cached.refusal.reason, cached.refusal.detail);
34
+ // A cache minted for a different provider or client is not a token this host may present. It is
35
+ // discarded rather than kept, because leaving it means every later read re-derives the same
36
+ // refusal from material that will never become valid.
37
+ if (cached.value.authority !== config.authority || cached.value.clientId !== config.clientId) {
38
+ store.clear();
39
+ return refuse('token-unavailable', 'the cached token was minted for a different authority or client than this host is now configured for; it has been discarded — sign in again');
40
+ }
41
+ // The protocol-mismatch guard. Material minted by one flow is discarded when the host is now
42
+ // configured for the other: a refresh across flows fails for a reason nobody would connect to a
43
+ // sign-in weeks earlier. Discarding now turns an unexplainable future failure into one sign-in
44
+ // today. This is not the blocked-flow policy case — see `device-code.ts` for that one.
45
+ if (discardOnProtocolMismatch(cached.value.protocol, protocol)) {
46
+ store.clear();
47
+ return refuse('token-unavailable', `the cached token was obtained by the ${cached.value.protocol} flow and this host is configured for ${protocol}; it cannot be refreshed across flows and has been discarded — sign in again`);
48
+ }
49
+ if (isFresh(cached.value.tokens, nowMs())) {
50
+ return ok({ authorization: present(cached.value.tokens), via: 'cache-hit' });
51
+ }
52
+ if (cached.value.tokens.refreshToken === null || refresh === undefined) {
53
+ return refuse('token-unavailable', 'the cached token has expired and there is no refresh token to renew it with — sign in again');
54
+ }
55
+ const refreshed = await refresh(cached.value);
56
+ if (!refreshed.ok)
57
+ return refuse(refreshed.refusal.reason, refreshed.refusal.detail);
58
+ // A provider that issues no new refresh token on a refresh means the old one keeps working;
59
+ // dropping it would turn the next expiry into an interactive sign-in for no reason.
60
+ const tokens = {
61
+ ...refreshed.value,
62
+ refreshToken: refreshed.value.refreshToken ?? cached.value.tokens.refreshToken,
63
+ };
64
+ const written = store.write({ ...cached.value, tokens });
65
+ // A cache that could not be written is not a reason to refuse a token already held. The
66
+ // refresh succeeded; failing here would take a working host down over a disk problem, and the
67
+ // only cost of continuing is signing in again after a restart. The refusal is still surfaced by
68
+ // the write path's own return value for anyone who wants it.
69
+ void written;
70
+ return ok({ authorization: present(tokens), via: 'refreshed' });
71
+ }
72
+ }
73
+ function present(tokens) {
74
+ return { header: AUTHORIZATION_HEADER, value: authorizationValue(tokens) };
75
+ }
76
+ //# sourceMappingURL=credential.js.map