@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,127 @@
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 { ok, refuse } from '../core/result.js';
17
+ import type { IdentityConfig } from './config.js';
18
+ import type { Pkce } from './pkce.js';
19
+ import { stateMatches } from './pkce.js';
20
+
21
+ /** Everything one sign-in attempt must remember between the request and the callback. */
22
+ export interface AuthorizationRequest {
23
+ readonly url: string;
24
+ readonly state: string;
25
+ readonly pkce: Pkce;
26
+ readonly redirectUri: string;
27
+ }
28
+
29
+ /** The loopback redirect URI for a port the OS has already assigned. */
30
+ export function redirectUriFor(port: number): string {
31
+ // 127.0.0.1, never `localhost`. `localhost` can resolve to ::1 or be redefined in a hosts
32
+ // file, and the provider matches the redirect URI as a literal string — so the registered value
33
+ // and the listener must agree on the exact spelling, not on a name that resolves.
34
+ return `http://127.0.0.1:${port}/callback`;
35
+ }
36
+
37
+ /**
38
+ * Build the URL the user's browser is sent to.
39
+ *
40
+ * `redirectUri` is passed rather than derived because the port is not known until the listener is
41
+ * bound — asking the OS for a free port and then telling the provider about it is the ordinary
42
+ * loopback shape.
43
+ */
44
+ export function buildAuthorizationUrl(
45
+ config: IdentityConfig,
46
+ authorizationEndpoint: string,
47
+ pkce: Pkce,
48
+ state: string,
49
+ redirectUri: string,
50
+ ): AuthorizationRequest {
51
+ const url = new URL(authorizationEndpoint);
52
+ url.searchParams.set('client_id', config.clientId);
53
+ url.searchParams.set('response_type', 'code');
54
+ url.searchParams.set('redirect_uri', redirectUri);
55
+ url.searchParams.set('scope', config.scopes.join(' '));
56
+ url.searchParams.set('state', state);
57
+ url.searchParams.set('code_challenge', pkce.challenge);
58
+ url.searchParams.set('code_challenge_method', pkce.method);
59
+ return { url: url.toString(), state, pkce, redirectUri };
60
+ }
61
+
62
+ /** What the provider sent back, once it has been proven to answer this host's request. */
63
+ export interface AuthorizationCallback {
64
+ readonly code: string;
65
+ }
66
+
67
+ /**
68
+ * Read a callback and decide whether it is the answer to this host's request.
69
+ *
70
+ * The `state` check runs before the code is even looked at, and that ordering is the point. A
71
+ * callback carrying a perfectly good authorization code from somebody else's authorization request
72
+ * is exactly the attack: any local process can reach this host's loopback listener, because the
73
+ * agent shares this host's OS user. Reading the code first and validating afterwards would work
74
+ * identically in every test and leave the window open.
75
+ *
76
+ * `rawQuery` is the callback request's query string, taken from the listener verbatim.
77
+ */
78
+ export function readAuthorizationCallback(
79
+ rawQuery: string,
80
+ expectedState: string,
81
+ ): Result<AuthorizationCallback> {
82
+ const params = new URLSearchParams(rawQuery);
83
+
84
+ const receivedState = params.get('state');
85
+ if (receivedState === null || !stateMatches(expectedState, receivedState)) {
86
+ return refuse(
87
+ 'auth-state-mismatch',
88
+ receivedState === null
89
+ ? "the callback carried no state value, so it cannot be shown to answer this host's request"
90
+ : "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",
91
+ );
92
+ }
93
+
94
+ // Only now is anything else in the callback worth reading.
95
+ const error = params.get('error');
96
+ if (error !== null) {
97
+ const description = params.get('error_description');
98
+ return refuse(
99
+ 'auth-callback-refused',
100
+ `the provider returned ${error}${description === null ? '' : `: ${description}`}`,
101
+ );
102
+ }
103
+
104
+ const code = params.get('code');
105
+ if (code === null || code === '') {
106
+ return refuse(
107
+ 'auth-callback-refused',
108
+ 'the callback carried neither an authorization code nor an error, so there is nothing to redeem',
109
+ );
110
+ }
111
+
112
+ return ok({ code });
113
+ }
114
+
115
+ /**
116
+ * The page the browser lands on after the callback.
117
+ *
118
+ * Deliberately static and self-contained — no script, no external asset, and nothing derived from
119
+ * the query string. Reflecting any part of the callback into this HTML would be a cross-site
120
+ * scripting hole in a page that renders in the user's real, signed-in browser.
121
+ */
122
+ export const CALLBACK_PAGE = [
123
+ '<!doctype html><meta charset="utf-8"><title>Signed in</title>',
124
+ '<body style="font-family:system-ui;padding:2rem">',
125
+ '<h1>Signed in</h1><p>You can close this tab and return to the terminal.</p>',
126
+ '</body>',
127
+ ].join('');
@@ -0,0 +1,227 @@
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
+ import { ok, refuse } from '../core/result.js';
18
+
19
+ /**
20
+ * The endpoints an OAuth 2.0 authorization-code exchange needs.
21
+ *
22
+ * Separate from the config because there are two honest ways to get them — the operator states
23
+ * them, or they are read from the provider's discovery document — and the flow should not care
24
+ * which happened.
25
+ */
26
+ export interface IdentityEndpoints {
27
+ readonly authorizationEndpoint: string;
28
+ readonly tokenEndpoint: string;
29
+ /** Null when the provider does not advertise the device-code grant. */
30
+ readonly deviceAuthorizationEndpoint: string | null;
31
+ }
32
+
33
+ export interface IdentityConfig {
34
+ /**
35
+ * The issuer this host authenticates against.
36
+ *
37
+ * A consumer-identity (CIAM) authority is typically single-tenant — so a workplace's employees
38
+ * cannot sign in to it. The answer to that is a registration in the workplace's own tenant
39
+ * pointed at by this value, which costs no code precisely because this is a setting. See the
40
+ * README's deployment note.
41
+ */
42
+ readonly authority: string;
43
+ readonly clientId: string;
44
+ readonly scopes: readonly string[];
45
+ /** Explicitly stated endpoints; null means "read the discovery document". */
46
+ readonly endpoints: IdentityEndpoints | null;
47
+ /** Loopback redirect port. 0 asks the OS for a free one, which is the ordinary case. */
48
+ readonly redirectPort: number;
49
+ /**
50
+ * Off unless the operator turns it on. The device-code grant is never reached by falling back
51
+ * to it — see `device-code.ts` for why the provider itself recommends blocking it.
52
+ */
53
+ readonly deviceCodeEnabled: boolean;
54
+ }
55
+
56
+ /**
57
+ * `offline_access` is in the default set deliberately: without a refresh token every access token
58
+ * expiring means an interactive sign-in, and this host is meant to run unattended.
59
+ */
60
+ export const DEFAULT_SCOPES: readonly string[] = ['openid', 'profile', 'offline_access'];
61
+
62
+ const REQUIRED = 'PERISCOPE_IDENTITY_AUTHORITY and PERISCOPE_IDENTITY_CLIENT_ID';
63
+
64
+ function trimmed(env: NodeJS.ProcessEnv, key: string): string | null {
65
+ const value = env[key];
66
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
67
+ }
68
+
69
+ /**
70
+ * Is this a usable issuer URL?
71
+ *
72
+ * HTTPS is required and `localhost` is not exempted. A plaintext authority would put the
73
+ * authorization code and then the token itself on the wire in clear, and "it is only the test
74
+ * environment" is how that reaches production. A provider being tested locally is reached through
75
+ * its real https endpoint like any other.
76
+ */
77
+ function invalidAuthority(authority: string): string | null {
78
+ let parsed: URL;
79
+ try {
80
+ parsed = new URL(authority);
81
+ } catch {
82
+ return `PERISCOPE_IDENTITY_AUTHORITY is not a URL: ${authority}`;
83
+ }
84
+ if (parsed.protocol !== 'https:') {
85
+ return `PERISCOPE_IDENTITY_AUTHORITY must be https, got ${parsed.protocol}//`;
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /** Endpoints are all-or-nothing: a half-stated set is a config error, never a partial default. */
91
+ function readEndpoints(env: NodeJS.ProcessEnv): Result<IdentityEndpoints | null> {
92
+ const authorizationEndpoint = trimmed(env, 'PERISCOPE_IDENTITY_AUTHORIZE_URL');
93
+ const tokenEndpoint = trimmed(env, 'PERISCOPE_IDENTITY_TOKEN_URL');
94
+ const deviceAuthorizationEndpoint = trimmed(env, 'PERISCOPE_IDENTITY_DEVICE_CODE_URL');
95
+
96
+ if (authorizationEndpoint === null && tokenEndpoint === null) {
97
+ // Neither stated: discovery will supply both. The device endpoint alone is not enough to act on.
98
+ return ok(null);
99
+ }
100
+ if (authorizationEndpoint === null || tokenEndpoint === null) {
101
+ return refuse(
102
+ 'identity-config-invalid',
103
+ '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',
104
+ );
105
+ }
106
+ return ok({ authorizationEndpoint, tokenEndpoint, deviceAuthorizationEndpoint });
107
+ }
108
+
109
+ function readPort(env: NodeJS.ProcessEnv): Result<number> {
110
+ const raw = trimmed(env, 'PERISCOPE_IDENTITY_REDIRECT_PORT');
111
+ if (raw === null) return ok(0);
112
+ const port = Number(raw);
113
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
114
+ return refuse(
115
+ 'identity-config-invalid',
116
+ `PERISCOPE_IDENTITY_REDIRECT_PORT must be an integer 0-65535, got ${raw}`,
117
+ );
118
+ }
119
+ return ok(port);
120
+ }
121
+
122
+ /**
123
+ * Read the identity configuration, or say precisely what is missing.
124
+ *
125
+ * The environment is passed in rather than read, matching the rest of the package: the composition
126
+ * root supplies the environment, helpers only default to `process.env` when called bare, and every
127
+ * module below the root is testable without a process.
128
+ */
129
+ export function readIdentityConfig(env: NodeJS.ProcessEnv): Result<IdentityConfig> {
130
+ const authority = trimmed(env, 'PERISCOPE_IDENTITY_AUTHORITY');
131
+ const clientId = trimmed(env, 'PERISCOPE_IDENTITY_CLIENT_ID');
132
+
133
+ if (authority === null || clientId === null) {
134
+ return refuse(
135
+ 'identity-not-configured',
136
+ `${REQUIRED} must both be set; this host does not authenticate without them and has no default issuer`,
137
+ );
138
+ }
139
+
140
+ const badAuthority = invalidAuthority(authority);
141
+ if (badAuthority !== null) return refuse('identity-config-invalid', badAuthority);
142
+
143
+ const endpoints = readEndpoints(env);
144
+ if (!endpoints.ok) return refuse(endpoints.refusal.reason, endpoints.refusal.detail);
145
+
146
+ const port = readPort(env);
147
+ if (!port.ok) return refuse(port.refusal.reason, port.refusal.detail);
148
+
149
+ const rawScopes = trimmed(env, 'PERISCOPE_IDENTITY_SCOPES');
150
+ const scopes =
151
+ rawScopes === null ? DEFAULT_SCOPES : rawScopes.split(/[\s,]+/).filter((scope) => scope !== '');
152
+
153
+ if (scopes.length === 0) {
154
+ return refuse('identity-config-invalid', 'PERISCOPE_IDENTITY_SCOPES was set but lists no scope');
155
+ }
156
+
157
+ return ok({
158
+ authority,
159
+ clientId,
160
+ scopes,
161
+ endpoints: endpoints.value,
162
+ redirectPort: port.value,
163
+ deviceCodeEnabled: trimmed(env, 'PERISCOPE_IDENTITY_DEVICE_CODE') === '1',
164
+ });
165
+ }
166
+
167
+ /**
168
+ * What the composition root should do about identity, decided here so `bin/` stays a wiring file.
169
+ *
170
+ * The three-way split is the point, and the middle one is the easy mistake. "Configured wrong"
171
+ * must not degrade to "not configured": an operator who set an authority and mistyped it has stated
172
+ * an intention, and silently starting without identity would honour the typo instead of the
173
+ * intention — the host would come up looking healthy and authenticate as nobody.
174
+ *
175
+ * configured — build the real credential.
176
+ * absent — nothing was set. Start without identity; the existing credential refuses by
177
+ * name, which is exactly what this host did before identity existed.
178
+ * invalid — something was set and cannot be used. Refuse to start.
179
+ */
180
+ export type IdentityPosture =
181
+ | { readonly kind: 'configured'; readonly config: IdentityConfig }
182
+ | { readonly kind: 'absent' }
183
+ | { readonly kind: 'invalid'; readonly detail: string };
184
+
185
+ export function identityPosture(env: NodeJS.ProcessEnv): IdentityPosture {
186
+ const result = readIdentityConfig(env);
187
+ if (result.ok) return { kind: 'configured', config: result.value };
188
+ if (result.refusal.reason === 'identity-not-configured') return { kind: 'absent' };
189
+ return { kind: 'invalid', detail: result.refusal.detail };
190
+ }
191
+
192
+ /**
193
+ * The provider's discovery document URL.
194
+ *
195
+ * OpenID Connect Discovery, so this works for any conforming provider rather than one vendor's URL
196
+ * shape. The trailing-slash handling matters: `new URL('.well-known/…', 'https://x/tenant')` would
197
+ * drop `tenant`.
198
+ */
199
+ export function discoveryUrl(authority: string): string {
200
+ const base = authority.endsWith('/') ? authority : `${authority}/`;
201
+ return new URL('.well-known/openid-configuration', base).toString();
202
+ }
203
+
204
+ /** Parse a discovery document into the endpoints, refusing anything that is not usable. */
205
+ export function readDiscoveryDocument(body: unknown): Result<IdentityEndpoints> {
206
+ if (typeof body !== 'object' || body === null) {
207
+ return refuse('identity-config-invalid', 'the discovery document is not a JSON object');
208
+ }
209
+ const record = body as Record<string, unknown>;
210
+ const authorizationEndpoint = record['authorization_endpoint'];
211
+ const tokenEndpoint = record['token_endpoint'];
212
+ const deviceAuthorizationEndpoint = record['device_authorization_endpoint'];
213
+
214
+ if (typeof authorizationEndpoint !== 'string' || typeof tokenEndpoint !== 'string') {
215
+ return refuse(
216
+ 'identity-config-invalid',
217
+ 'the discovery document does not advertise both authorization_endpoint and token_endpoint',
218
+ );
219
+ }
220
+
221
+ return ok({
222
+ authorizationEndpoint,
223
+ tokenEndpoint,
224
+ deviceAuthorizationEndpoint:
225
+ typeof deviceAuthorizationEndpoint === 'string' ? deviceAuthorizationEndpoint : null,
226
+ });
227
+ }
@@ -0,0 +1,176 @@
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 { ok, refuse } from '../core/result.js';
27
+ import type { IdentityConfig } from './config.js';
28
+ import type { AuthProtocol } from './device-code.js';
29
+ import { discardOnProtocolMismatch } from './device-code.js';
30
+ import type { CachedTokens, TokenStore } from './store.js';
31
+ import type { TokenSet } from './token.js';
32
+ import { authorizationValue, isFresh } from './token.js';
33
+
34
+ /**
35
+ * Exchanges a refresh token for a fresh set.
36
+ *
37
+ * Injected so the credential's whole decision surface is testable without a network, and so the
38
+ * transport stays in one place.
39
+ */
40
+ export type TokenRefresher = (cached: CachedTokens) => Promise<Result<TokenSet>>;
41
+
42
+ /**
43
+ * Which of the three things `authorize()` did — because from the outside they are identical.
44
+ *
45
+ * Nothing under `identity/` emits anything, and `control/link.ts` swallows a credential refusal
46
+ * and connects with no header rather than pretending to hold a scheme — which is deliberate.
47
+ * Without a report, a cache hit, a silent refresh and "nobody has ever signed in on this machine"
48
+ * produce byte-identical output, and the first thing that distinguishes them is a 401 arriving
49
+ * from the controller minutes later. On a binary running on the user's own laptop that reads as a
50
+ * server problem, and the user is never told the one thing they can act on.
51
+ *
52
+ * It carries no token material, and that is a contract rather than an oversight — an outcome name,
53
+ * a refusal reason and the refusal's own already-human detail. Pinned by this package's own suite.
54
+ */
55
+ export type CredentialOutcome =
56
+ | { readonly kind: 'cache-hit' }
57
+ | { readonly kind: 'refreshed' }
58
+ | { readonly kind: 'refused'; readonly reason: RefusalReason; readonly detail: string };
59
+
60
+ /**
61
+ * Where an outcome is reported.
62
+ *
63
+ * Data, not a log line. `identity/` returns `Result`s and formats nothing, on the same split every
64
+ * other seam in this package uses: the embedder decides what a line looks like and where it goes.
65
+ */
66
+ export type CredentialReporter = (outcome: CredentialOutcome) => void;
67
+
68
+ export interface TokenCredentialOptions {
69
+ readonly store: TokenStore;
70
+ readonly config: IdentityConfig;
71
+ /** Which flow this host is configured to use — the protocol-mismatch guard compares against it. */
72
+ readonly protocol: AuthProtocol;
73
+ /** The package's own millisecond ticker, injected like every other clock here. */
74
+ readonly nowMs: Ticker;
75
+ /** Omit to refuse rather than refresh — useful for a host that only ever reads. */
76
+ readonly refresh?: TokenRefresher;
77
+ /** Omit for a credential that decides in silence, which is what this was before it could say. */
78
+ readonly report?: CredentialReporter;
79
+ }
80
+
81
+ /** The header the controller link puts on its connect request. */
82
+ export const AUTHORIZATION_HEADER = 'Authorization';
83
+
84
+ export class TokenCredential implements ControllerCredential {
85
+ readonly #options: TokenCredentialOptions;
86
+
87
+ constructor(options: TokenCredentialOptions) {
88
+ this.#options = options;
89
+ }
90
+
91
+ /**
92
+ * One report, at one place, derived from the decision rather than repeated alongside it.
93
+ *
94
+ * `#decide` holds every branch and this holds the only call to `report`, so a path added later
95
+ * cannot forget to say what it did — which is precisely how a silent path arises.
96
+ */
97
+ async authorize(): Promise<Result<Authorization>> {
98
+ const decided = await this.#decide();
99
+ const report = this.#options.report;
100
+
101
+ if (report !== undefined) {
102
+ report(
103
+ decided.ok
104
+ ? { kind: decided.value.via }
105
+ : { kind: 'refused', reason: decided.refusal.reason, detail: decided.refusal.detail },
106
+ );
107
+ }
108
+
109
+ if (!decided.ok) return refuse(decided.refusal.reason, decided.refusal.detail);
110
+ return ok(decided.value.authorization);
111
+ }
112
+
113
+ async #decide(): Promise<Result<{ authorization: Authorization; via: 'cache-hit' | 'refreshed' }>> {
114
+ const { store, config, protocol, nowMs, refresh } = this.#options;
115
+
116
+ const cached = store.read();
117
+ if (!cached.ok) return refuse(cached.refusal.reason, cached.refusal.detail);
118
+
119
+ // A cache minted for a different provider or client is not a token this host may present. It is
120
+ // discarded rather than kept, because leaving it means every later read re-derives the same
121
+ // refusal from material that will never become valid.
122
+ if (cached.value.authority !== config.authority || cached.value.clientId !== config.clientId) {
123
+ store.clear();
124
+ return refuse(
125
+ 'token-unavailable',
126
+ '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',
127
+ );
128
+ }
129
+
130
+ // The protocol-mismatch guard. Material minted by one flow is discarded when the host is now
131
+ // configured for the other: a refresh across flows fails for a reason nobody would connect to a
132
+ // sign-in weeks earlier. Discarding now turns an unexplainable future failure into one sign-in
133
+ // today. This is not the blocked-flow policy case — see `device-code.ts` for that one.
134
+ if (discardOnProtocolMismatch(cached.value.protocol, protocol)) {
135
+ store.clear();
136
+ return refuse(
137
+ 'token-unavailable',
138
+ `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`,
139
+ );
140
+ }
141
+
142
+ if (isFresh(cached.value.tokens, nowMs())) {
143
+ return ok({ authorization: present(cached.value.tokens), via: 'cache-hit' });
144
+ }
145
+
146
+ if (cached.value.tokens.refreshToken === null || refresh === undefined) {
147
+ return refuse(
148
+ 'token-unavailable',
149
+ 'the cached token has expired and there is no refresh token to renew it with — sign in again',
150
+ );
151
+ }
152
+
153
+ const refreshed = await refresh(cached.value);
154
+ if (!refreshed.ok) return refuse(refreshed.refusal.reason, refreshed.refusal.detail);
155
+
156
+ // A provider that issues no new refresh token on a refresh means the old one keeps working;
157
+ // dropping it would turn the next expiry into an interactive sign-in for no reason.
158
+ const tokens: TokenSet = {
159
+ ...refreshed.value,
160
+ refreshToken: refreshed.value.refreshToken ?? cached.value.tokens.refreshToken,
161
+ };
162
+
163
+ const written = store.write({ ...cached.value, tokens });
164
+ // A cache that could not be written is not a reason to refuse a token already held. The
165
+ // refresh succeeded; failing here would take a working host down over a disk problem, and the
166
+ // only cost of continuing is signing in again after a restart. The refusal is still surfaced by
167
+ // the write path's own return value for anyone who wants it.
168
+ void written;
169
+
170
+ return ok({ authorization: present(tokens), via: 'refreshed' });
171
+ }
172
+ }
173
+
174
+ function present(tokens: TokenSet): Authorization {
175
+ return { header: AUTHORIZATION_HEADER, value: authorizationValue(tokens) };
176
+ }