@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,615 @@
1
+ /**
2
+ * `periscope serve`: dial the controller and serve sessions. The daemon every supervisor starts.
3
+ *
4
+ * Two views of the environment arrive, and the split is deliberate. Configuration (`readConfig`)
5
+ * comes from the merged view, where the config file fills absences. Credential material, the
6
+ * environment sessions are filtered from, and the home directory come from the raw view: nothing
7
+ * outside the closed config key set may arrive from a file, and the credential files are found by
8
+ * the same `PERISCOPE_CONFIG_DIR` that says where the config file itself is.
9
+ *
10
+ * Every process-level edge is a dependency: where lines go, how the exit code is latched, how the
11
+ * process is exited, how signals are subscribed. The link and the agent process are injectable for
12
+ * the same reason, so the whole start-up sequence runs in a test with no socket and no agent.
13
+ */
14
+ import type { ControllerCredential } from '../control/credential.js';
15
+ import type { LinkHandlers } from '../control/link.js';
16
+ import type { HostEvent } from '../host/index.js';
17
+ import type { HostLink } from '../host/host.js';
18
+ import {
19
+ FilePairedCredential,
20
+ FileTokenCache,
21
+ PeriscopeHost,
22
+ defaultAgentHome,
23
+ transcriptsRootUnder,
24
+ claudeTranscriptResolver,
25
+ credentialPaths,
26
+ packageVersion,
27
+ pairedCredentialPath,
28
+ protocolFor,
29
+ readMachineFacts,
30
+ refresherFor,
31
+ resolveEndpoints,
32
+ tokenCachePath,
33
+ } from '../host/index.js';
34
+ import type { AgentProcess, AgentProcessRequest } from '../host/agent-process.js';
35
+ import type { EscalationTransport } from '../gate/escalate.js';
36
+ import { escalatingDecider } from '../gate/escalate.js';
37
+ import type { TokenRefresher } from '../identity/index.js';
38
+ import { PairedHostCredential, TokenCredential, identityPosture } from '../identity/index.js';
39
+ import { SessionRegistry } from '../sessions/registry.js';
40
+ import { readConfigFile } from '../host/config-file.js';
41
+ import { writeLinkState } from '../host/link-state-file.js';
42
+ import { describePosture, postureLine } from './posture.js';
43
+ import { isAbsolutePath } from '../core/paths.js';
44
+ import { systemClock, systemTicker } from '../core/time.js';
45
+ import { configValueProblem, overriddenByEnvironment, reconfigureHost } from './reconfigure.js';
46
+ import {
47
+ hostConfigurationOf,
48
+ workspaceCapabilitiesOf,
49
+ workspacePostureProblem,
50
+ workspacesFor,
51
+ } from './workspaces.js';
52
+
53
+ /**
54
+ * How long the fatal-credential path holds the event loop open before exiting explicitly.
55
+ *
56
+ * Long enough for the stdout trace and the stderr remedy to flush through their pipes, short enough
57
+ * that a supervisor sees the exit promptly. It is not a grace period for work in flight; the host
58
+ * is already stopped by the time this runs.
59
+ */
60
+ export const FATAL_EXIT_FLUSH_MS = 50;
61
+
62
+ /** The two environments the daemon reads. See the module header for which reads which. */
63
+ export interface ServeViews {
64
+ /** The process environment as received. Credentials, the session base env and the home read it. */
65
+ readonly raw: NodeJS.ProcessEnv;
66
+ /**
67
+ * The environment with the config file filling absences, or the problem that made the file
68
+ * unusable. Checked after the root refusal, so a host that cannot run at all is told that first.
69
+ */
70
+ readonly merged: NodeJS.ProcessEnv | string;
71
+ }
72
+
73
+ /** The edges. Every one has a production default in `main.ts`; a test replaces them all. */
74
+ export interface ServeDeps {
75
+ /** One stdout line, without its newline: the trace. */
76
+ readonly log: (line: string) => void;
77
+ /** One stderr line, without its newline: refusals and the fatal remedy. */
78
+ readonly stderr: (line: string) => void;
79
+ readonly setExitCode: (code: number) => void;
80
+ /** Ends the process now. Called only on the fatal-credential path, after the flush delay. */
81
+ readonly exit: (code: number) => void;
82
+ readonly onSignal: (signal: 'SIGTERM' | 'SIGINT', handler: () => void) => void;
83
+ /** The decision POST's transport. Defaults to global fetch. */
84
+ readonly transport?: EscalationTransport;
85
+ /** How the link is built. Defaults to the real `ControllerLink`. */
86
+ readonly link?: (handlers: LinkHandlers) => HostLink;
87
+ /** The effective uid, or null where the platform has none (win32). Defaults to the process's. */
88
+ readonly getuid?: (() => number) | null;
89
+ /** How an agent process is started. Defaults to the real one. */
90
+ readonly startProcess?: (request: AgentProcessRequest) => AgentProcess;
91
+ }
92
+
93
+ export type ServeOutcome =
94
+ { readonly ok: true; readonly host: PeriscopeHost } | { readonly ok: false; readonly detail: string };
95
+
96
+ interface Config {
97
+ readonly controllerUrl: string;
98
+ readonly hostId: string;
99
+ /** Where the controller answers a permission escalation. See `readConfig` on why it is required. */
100
+ readonly decisionUrl: string;
101
+ /** When set, every session gets a directory beneath it instead of the one the controller named. */
102
+ readonly workspaceRoot: string | null;
103
+ /**
104
+ * The repository a session's worktree is linked to. When set (alongside `workspaceRoot`), sessions
105
+ * get a linked git worktree on their own branch instead of a plain directory; see `workspacesFor`.
106
+ */
107
+ readonly repositoryRoot: string | null;
108
+ /** The branch template worktree branches render from. Null = the provider's fallback. */
109
+ readonly branchScheme: string | null;
110
+ /** The key an unkeyed session provisions at. Null = the session key. */
111
+ readonly workspaceKey: string | null;
112
+ /**
113
+ * The agent's home: the folder the agent CLI keeps its state in; transcripts are read under
114
+ * it. Null = the CLI's own default under the home directory (`defaultAgentHome`).
115
+ */
116
+ readonly agentHome: string | null;
117
+ }
118
+
119
+ function readConfig(env: NodeJS.ProcessEnv): Config | string {
120
+ const controllerUrl = env['PERISCOPE_CONTROLLER_URL'];
121
+ if (controllerUrl === undefined || controllerUrl === '') {
122
+ return 'PERISCOPE_CONTROLLER_URL is not set';
123
+ }
124
+
125
+ // A host with nowhere to ask must not start, and neither alternative is survivable. Defaulting
126
+ // to allow makes this a code-execution service for whoever reaches the socket. Defaulting to deny
127
+ // makes a host that comes up healthy, accepts sessions, and blocks every tool call, which reads
128
+ // to an operator as the agent being broken rather than as this host being unconfigured. So it is
129
+ // named at start-up, once, in the one place that reads the environment.
130
+ const decisionUrl = env['PERISCOPE_DECISION_URL'];
131
+ if (decisionUrl === undefined || decisionUrl === '') {
132
+ return (
133
+ 'PERISCOPE_DECISION_URL is not set — this host has nowhere to send a permission decision, and a ' +
134
+ 'host that cannot ask is either an open door or a session where nothing runs. Set it to the ' +
135
+ 'endpoint your controller answers on.'
136
+ );
137
+ }
138
+
139
+ // The same screen the wire runs on `host_configure`: a controller URL that is not ws(s), a
140
+ // decision URL that is not http(s), refused at boot by name instead of dialled forever.
141
+ for (const [key, value] of [
142
+ ['PERISCOPE_CONTROLLER_URL', controllerUrl],
143
+ ['PERISCOPE_DECISION_URL', decisionUrl],
144
+ ] as const) {
145
+ const problem = configValueProblem(key, value);
146
+ if (problem !== null) return problem;
147
+ }
148
+
149
+ const configuredHostId = env['PERISCOPE_HOST_ID'];
150
+ return {
151
+ controllerUrl,
152
+ // An empty value counts as unset, as every other key reads it: a shell that exports the name
153
+ // with nothing behind it has not chosen a host id.
154
+ hostId:
155
+ configuredHostId === undefined || configuredHostId === ''
156
+ ? readMachineFacts().hostname
157
+ : configuredHostId,
158
+ decisionUrl,
159
+ workspaceRoot: env['PERISCOPE_WORKSPACE_ROOT'] ?? null,
160
+ repositoryRoot: env['PERISCOPE_REPOSITORY_ROOT'] ?? null,
161
+ branchScheme: env['PERISCOPE_BRANCH_SCHEME'] ?? null,
162
+ workspaceKey: env['PERISCOPE_WORKSPACE_KEY'] ?? null,
163
+ agentHome: env['PERISCOPE_AGENT_HOME'] ?? null,
164
+ };
165
+ }
166
+
167
+ /** One trace line: `<clock> [channel] message — detail`. */
168
+ export type Logger = (channel: string, message: string, detail: string | null) => void;
169
+
170
+ /** What `readCredential` decided: the credential (or none), and, when the paired branch chose, the
171
+ * host id the credential is bound to, which overrides the configured one at `link_hello`. */
172
+ export interface CredentialPosture {
173
+ readonly credential: ControllerCredential | null;
174
+ readonly pairedHostId: string | null;
175
+ }
176
+
177
+ /**
178
+ * Which credential this host presents, or null when it presents none.
179
+ *
180
+ * The paired credential is preferred over the OIDC cache, and the preference is the point of
181
+ * pairing: an OIDC refresh token can expire after a period of inactivity, the paired credential has
182
+ * no clock. A machine that has run `periscope pair` dials on the paired credential even when a
183
+ * token cache also exists. A missing paired file falls through to the OIDC postures; a corrupt one
184
+ * is fatal rather than a fallback, because silently degrading to a maybe-dead refresh token would
185
+ * put the host back in exactly the ambiguous posture pairing exists to end.
186
+ *
187
+ * The OIDC three-way decision itself lives in `identity/config.ts` and is tested there; this is the
188
+ * wiring. A misconfiguration returns a string, which `runServe` treats as fatal: a host that was
189
+ * told to use identity and cannot must not come up looking healthy while authenticating as nobody.
190
+ *
191
+ * The `absent` posture returns null rather than a refusing credential, which is the whole
192
+ * difference between "no identity" and "no tool calls". `UnconfiguredCredential` refuses by name on
193
+ * every call, and `escalatingDecider` treats a refusing credential as an outage and does not send
194
+ * the request, so handing one to it would turn every escalation in every session into
195
+ * `permission-decision-unavailable`. That is the exact outcome `readConfig` above refuses to ship
196
+ * for the missing-URL case, in the same file, for the same stated reason: a host that comes up
197
+ * healthy, accepts sessions, and blocks every tool call reads to an operator as the agent being
198
+ * broken. Null means the option is omitted, which `EscalationOptions.credential` documents as the
199
+ * supported no-identity mode; the placeholder stays the exported shape for an embedder that wants a
200
+ * credential that says no out loud.
201
+ */
202
+ export function readCredential(env: NodeJS.ProcessEnv, log: Logger): CredentialPosture | string {
203
+ const pairedPath = pairedCredentialPath(env);
204
+ if (pairedPath !== null) {
205
+ const paired = new FilePairedCredential(pairedPath).read();
206
+ if (paired.ok) {
207
+ // The one posture line, matching the absent-case line in `runServe`: which credential this
208
+ // host is on is otherwise invisible until the first refusal.
209
+ log(
210
+ 'credential',
211
+ `paired as ${paired.value.hostId} - the paired credential is presented on every dial`,
212
+ null,
213
+ );
214
+ return { credential: new PairedHostCredential(paired.value), pairedHostId: paired.value.hostId };
215
+ }
216
+ if (paired.refusal.reason !== 'token-unavailable') {
217
+ // Exists and is unreadable: fatal, loud, with the fix in the message (see the docblock).
218
+ return paired.refusal.detail;
219
+ }
220
+ // token-unavailable = this machine has never been paired: the normal fall-through.
221
+ }
222
+
223
+ const posture = identityPosture(env);
224
+
225
+ if (posture.kind === 'invalid') return posture.detail;
226
+ if (posture.kind === 'absent') return { credential: null, pairedHostId: null };
227
+
228
+ const cachePath = tokenCachePath(env);
229
+ if (cachePath === null) {
230
+ return 'identity is configured but there is nowhere to keep the token cache — set PERISCOPE_CONFIG_DIR';
231
+ }
232
+
233
+ const store = new FileTokenCache(cachePath);
234
+ const config = posture.config;
235
+
236
+ // The endpoints are resolved on first refresh, not at start-up. Discovery is a network call, and
237
+ // making it at construction would mean this host refuses to start when the provider is briefly
238
+ // unreachable, while holding a perfectly good cached token it could have presented. A host that
239
+ // cannot start is worse than one that refreshes late.
240
+ const refresh: TokenRefresher = async (cached) => {
241
+ const endpoints = await resolveEndpoints(config);
242
+ if (!endpoints.ok) return endpoints;
243
+ return refresherFor(config, endpoints.value, store)(cached);
244
+ };
245
+
246
+ return {
247
+ credential: new TokenCredential({
248
+ store,
249
+ config,
250
+ // Loopback is the primary flow; the device-code fallback is a deliberate act, so a host running
251
+ // unattended is configured for the one it will actually use.
252
+ protocol: protocolFor(config),
253
+ nowMs: systemTicker,
254
+ refresh,
255
+ // The one line that tells an operator which of the three happened. Without it a cache hit, a
256
+ // silent refresh and "nobody ever signed in here" are the same silence, and the first difference
257
+ // is a 401 the user reads as a server fault. `[credential] refused` names the one thing they can
258
+ // act on, on the machine where they can act on it.
259
+ report: (outcome) =>
260
+ log(
261
+ 'credential',
262
+ outcome.kind === 'refused' ? `refused — ${outcome.reason}` : outcome.kind,
263
+ outcome.kind === 'refused' ? outcome.detail : null,
264
+ ),
265
+ }),
266
+ pairedHostId: null,
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Running as root is refused by policy.
272
+ *
273
+ * An unattended agent running as root has the whole machine on every tool call, and a container
274
+ * built the obvious way runs as root. The gate decides which calls run; it does not shrink what a
275
+ * call can reach once it runs, and as uid 0 that is everything.
276
+ *
277
+ * Checked first, before configuration, so the answer arrives as one line at startup rather than as
278
+ * a session that can reach everything. A null `getuid` is win32, where the condition cannot arise.
279
+ */
280
+ function rootRefusal(getuid: (() => number) | null): string | null {
281
+ if (getuid === null) return null;
282
+ if (getuid() !== 0) return null;
283
+ return (
284
+ 'refusing to start as root: an unattended agent as uid 0 has the whole machine on every tool call. ' +
285
+ 'Run as a non-root user (add a USER line to your container).'
286
+ );
287
+ }
288
+
289
+ /** Start the daemon. Returns the running host, or the refusal already written to stderr. */
290
+ export function runServe(views: ServeViews, deps: ServeDeps): ServeOutcome {
291
+ const refuse = (detail: string): ServeOutcome => {
292
+ deps.stderr(`periscope: ${detail}`);
293
+ deps.setExitCode(1);
294
+ return { ok: false, detail };
295
+ };
296
+ const log: Logger = (channel, message, detail) => {
297
+ const line = detail === null ? message : `${message} — ${detail}`;
298
+ deps.log(`${systemClock()} [${channel}] ${line}`);
299
+ };
300
+
301
+ const asRoot = rootRefusal(deps.getuid === undefined ? processUid() : deps.getuid);
302
+ if (asRoot !== null) return refuse(asRoot);
303
+
304
+ // The config file fills absences, and only absences; the merge is `main.ts`'s, shared by every
305
+ // configuration-consuming verb, and a file that cannot be used is fatal rather than empty.
306
+ const env = views.merged;
307
+ if (typeof env === 'string') return refuse(env);
308
+
309
+ const config = readConfig(env);
310
+ if (typeof config === 'string') return refuse(config);
311
+
312
+ // The workspace posture is screened at startup, by name. A default workspace key that fails
313
+ // the union screen, or a branch scheme with a typo'd placeholder, would refuse every session,
314
+ // and a machine that will refuse every session must say so when it boots, where the person who
315
+ // can fix it is looking, not when someone finally opens a session.
316
+ const workspaceMisconfiguration = workspacePostureProblem({
317
+ workspaceRoot: config.workspaceRoot,
318
+ repositoryRoot: config.repositoryRoot,
319
+ branchScheme: config.branchScheme,
320
+ workspaceKey: config.workspaceKey,
321
+ });
322
+ if (workspaceMisconfiguration !== null) return refuse(workspaceMisconfiguration);
323
+ // The transcripts root is read on demand and jailed, but a relative one means "relative to a
324
+ // cwd nobody chose"; refused at start-up like every other posture problem.
325
+ if (config.agentHome !== null && config.agentHome !== '' && !isAbsolutePath(config.agentHome)) {
326
+ return refuse(`PERISCOPE_AGENT_HOME must be an absolute path — got '${config.agentHome}'`);
327
+ }
328
+
329
+ const raw = views.raw;
330
+ // The posture line leads the output and the credential lines follow it: the one-line summary
331
+ // first, then the detail. The credential is read before the summary can be composed, so its
332
+ // lines are held until the summary has printed.
333
+ const held: Array<Parameters<Logger>> = [];
334
+ const hold: Logger = (...line) => {
335
+ held.push(line);
336
+ };
337
+ const posture = readCredential(raw, hold);
338
+ if (typeof posture === 'string') return refuse(posture);
339
+ const credential = posture.credential;
340
+ if (credential === null) {
341
+ // A legitimate posture, and a silent one until this line existed: a host with no identity
342
+ // configured dials with no header, and "misconfigured to nobody" and "deliberately anonymous"
343
+ // looked identical from the outside. Said once, at start-up, where the other two postures
344
+ // already speak.
345
+ hold(
346
+ 'credential',
347
+ 'absent - no identity is configured, so this host will dial without authentication',
348
+ null,
349
+ );
350
+ }
351
+
352
+ // A paired credential names its host, and the name wins. The controller refuses a link_hello
353
+ // whose hostId differs from the credential's, so announcing anything else guarantees a closed
354
+ // socket. Said out loud when the environment disagrees, because a silently ignored variable is a
355
+ // misconfiguration nobody finds.
356
+ const hostId = posture.pairedHostId ?? config.hostId;
357
+ if (
358
+ posture.pairedHostId !== null &&
359
+ config.hostId !== posture.pairedHostId &&
360
+ raw['PERISCOPE_HOST_ID'] !== undefined
361
+ ) {
362
+ hold(
363
+ 'credential',
364
+ `PERISCOPE_HOST_ID ('${config.hostId}') is overridden by the paired credential's host id ('${posture.pairedHostId}')`,
365
+ null,
366
+ );
367
+ }
368
+
369
+ // The one line status prints whole, from the same description, so the two cannot disagree.
370
+ const reading = readConfigFile(raw);
371
+ const cachePath = tokenCachePath(raw);
372
+ const cached = cachePath === null ? null : new FileTokenCache(cachePath).read();
373
+ log(
374
+ 'host',
375
+ `periscope ${packageVersion()} · ` +
376
+ postureLine(
377
+ describePosture({
378
+ raw,
379
+ merged: env,
380
+ fileValues: reading.problem === null ? reading.values : {},
381
+ credential: posture,
382
+ tokenExpiresAtMs: cached !== null && cached.ok ? cached.value.tokens.expiresAt : null,
383
+ nowMs: Date.now(),
384
+ link: null,
385
+ pidAlive: () => false,
386
+ hostname: readMachineFacts().hostname,
387
+ }),
388
+ ),
389
+ null,
390
+ );
391
+ for (const line of held) log(...line);
392
+
393
+ const workspaces = workspacesFor(config);
394
+ const agentHome =
395
+ config.agentHome === null || config.agentHome === '' ? defaultAgentHome(raw) : config.agentHome;
396
+ // The transcripts root derives from the agent home; it is reported in the hello and never set on
397
+ // its own.
398
+ const transcriptsRoot = agentHome === null ? null : transcriptsRootUnder(agentHome);
399
+ const homeDir = raw['USERPROFILE'] ?? raw['HOME'] ?? '';
400
+
401
+ // Declared before the host so the event callback can reach it: the callback runs only after
402
+ // `start()` below, so the binding is always assigned by the time anything can fire.
403
+ let host: PeriscopeHost | null = null;
404
+ // Whether the fatal path has latched the exit code. A later signal must not reset it.
405
+ let fatal = false;
406
+
407
+ /**
408
+ * The one event that ends this process unsuccessfully without anyone asking it to.
409
+ *
410
+ * Everything else this host reports is an observation. `credential_rejected` is a conclusion about
411
+ * the host itself: the identity provider has refused its material and will keep refusing until a
412
+ * person signs in, so there is nothing left for the process to do and staying up implies
413
+ * otherwise. Without this, the link fails, no ref'd handle remains, node drains, and the process
414
+ * exits zero, so a supervisor records a clean run and a dashboard shows a finished host that
415
+ * never once connected.
416
+ *
417
+ * The exit code and the stderr line are both required, and they answer different readers. The
418
+ * code is what a supervisor branches on; the line is what the person at the machine acts on. A
419
+ * non-zero exit whose reason appears only in the stdout trace still leaves somebody reading logs.
420
+ */
421
+ let linkStateProblemSaid = false;
422
+ const onEvent = (event: HostEvent): void => {
423
+ if (event.kind === 'link') {
424
+ // The record status reads. A disk that will not take it is said once and never stops the link.
425
+ const problem = writeLinkState(raw, {
426
+ state: event.transition.to,
427
+ cause: event.transition.cause,
428
+ at: event.transition.at,
429
+ detail: event.transition.detail,
430
+ negotiatedVersion: host?.link.negotiatedVersion ?? null,
431
+ pid: process.pid,
432
+ });
433
+ if (problem !== null && !linkStateProblemSaid) {
434
+ linkStateProblemSaid = true;
435
+ log(
436
+ 'config',
437
+ `the link state file is not being written; periscope status will read nothing — ${problem}`,
438
+ null,
439
+ );
440
+ }
441
+ }
442
+ report(event, log);
443
+
444
+ if (event.kind === 'link' && event.transition.cause === 'credential_rejected') {
445
+ // The process exits now, live sessions included, and the count says what that cost. The
446
+ // alternative (serve the survivors and exit when the last one ends) is declined: an idle
447
+ // session never ends on its own, so that shape is a process that can stay up forever behind
448
+ // a link that will never carry another frame, which is the quiet-death posture this file
449
+ // exists to prevent. The transcript mirror keeps what the sessions produced; the one number
450
+ // on this line tells the operator whether work was cut mid-flight.
451
+ const liveSessions = host?.registry.list().length ?? 0;
452
+ // The remedy names the credential the host was actually on: a paired credential is revived by
453
+ // re-pairing, not by signing in. Telling a paired operator to `login` sends them to a flow
454
+ // whose token this host would not even present.
455
+ const remedy =
456
+ posture.pairedHostId !== null
457
+ ? 'run: periscope pair <code> with a freshly minted code, then start this host again'
458
+ : 'run: periscope login, then start this host again';
459
+ deps.stderr(
460
+ 'periscope: the controller or its identity provider refused this credential ' +
461
+ `(${liveSessions} live session(s) stopped) - ${remedy}`,
462
+ );
463
+ fatal = true;
464
+ deps.setExitCode(1);
465
+ host?.stop("this host's credential was refused");
466
+ // Exit deliberately: draining is what lets a signal launder this failure. Latching the
467
+ // code and letting the loop empty leaves a window in which node has released the loop but the
468
+ // process still exists with its default signal dispositions restored; a SIGTERM landing there
469
+ // terminates by signal, so the exit code arrives as null and a supervisor reads "killed"
470
+ // rather than "failed", the exact laundering this terminal class exists to prevent. The
471
+ // ref'd timer holds the loop open, which keeps the handlers below installed while the pipes
472
+ // flush, and the explicit exit is then the only way this path can end. Node 22 on Linux
473
+ // loses this race where node 24 wins it.
474
+ setTimeout(() => deps.exit(1), FATAL_EXIT_FLUSH_MS);
475
+ }
476
+ };
477
+
478
+ host = new PeriscopeHost({
479
+ controllerUrl: config.controllerUrl,
480
+ hostId,
481
+ // Omitted, not nulled, when there is no identity. Both this option and the decision
482
+ // transport's are optional by type, and omitting is the documented no-identity mode on each.
483
+ // Spelling it as one spread keeps the two transports impossible to configure differently by
484
+ // accident (one authenticated, one not).
485
+ ...(credential === null ? {} : { credential }),
486
+ // The same credential the link presents, on the decision POST. The surface that decides
487
+ // whether a tool runs must not ship unauthenticated while the surface that merely reports is
488
+ // authenticated.
489
+ //
490
+ // Per host, not per session, and the distinction is a design constraint rather than a detail:
491
+ // one host serves many sessions, and this is read once from the environment, so nothing here can
492
+ // authenticate as the particular session a decision is about. That is what
493
+ // `DecisionRequest.sessionKey` is for: the controller attributes; the credential authenticates.
494
+ //
495
+ // And it is omitted when there is none, not passed as a credential that refuses. Passing one
496
+ // that refuses does not make the request unauthenticated; it makes there be no request, on
497
+ // every tool call, in every session. See `readCredential` above.
498
+ decide: escalatingDecider({
499
+ url: config.decisionUrl,
500
+ transport: deps.transport ?? fetch,
501
+ ...(credential === null ? {} : { credential }),
502
+ }),
503
+ // Derived, never spelled out here: `credentialPaths` is the one source both this and the token
504
+ // cache's own location come from, so they cannot disagree about what is protected.
505
+ protectedPaths: credentialPaths(raw, { agentHome }),
506
+ // The discovery door: read-only, jailed. The root is derived here because reading the
507
+ // environment is the composition root's job; a machine with no resolvable home gets a host
508
+ // whose door answers with a named failure rather than a guessed root. The bulk resolver
509
+ // serves `claude-transcript:` locators through the same jail, reads only; the gate's
510
+ // protection of `~/.claude` against the agent is untouched by either.
511
+ ...(transcriptsRoot === null ? {} : { transcriptsRoot, bulk: claudeTranscriptResolver(transcriptsRoot) }),
512
+ ...(workspaces === null ? {} : { workspaces }),
513
+ // The workspace mode rides the hello as capability markers, derived from the same config the
514
+ // selector above consumed: the read half of what `periscope config` writes, and what lets a
515
+ // controller verify a host is set up rather than trusting its operator's memory.
516
+ linkCapabilities: workspaceCapabilitiesOf(config),
517
+ // The values behind the markers, from the same reading: which roots, which scheme, which
518
+ // transcripts root, which controller this process dialled.
519
+ configuration: hostConfigurationOf(config, {
520
+ transcriptsRoot,
521
+ controllerUrl: config.controllerUrl,
522
+ decisionUrl: config.decisionUrl,
523
+ agentHome,
524
+ }),
525
+ overriddenByEnvironment: overriddenByEnvironment(raw),
526
+ // The configure seam: validates, writes the config file, rebuilds the provider. It reads
527
+ // the raw environment because the file fills absences in it, and only this file may read that.
528
+ // The addresses this process dialled ride in so a written URL is reported as pending rather than
529
+ // applied: the live link is never re-pointed, the next start reads the file.
530
+ reconfigure: (entries, hostBusy) =>
531
+ reconfigureHost(raw, entries, hostBusy, {
532
+ controllerUrl: config.controllerUrl,
533
+ decisionUrl: config.decisionUrl,
534
+ }),
535
+ // The default an unkeyed session_new resolves to, screened above at startup, so a bad
536
+ // value died before this line. Omitted (not nulled) when unset: absence is the documented mode.
537
+ ...(config.workspaceKey === null || config.workspaceKey === ''
538
+ ? {}
539
+ : { defaultWorkspaceKey: config.workspaceKey }),
540
+ // The registry is the host's own unless a test supplies the process starter; then it is built
541
+ // here over the same base environment and home the host would have used, and the pair goes to
542
+ // the registry alone: the host refuses the two beside a registry, where nothing would read them.
543
+ ...(deps.startProcess === undefined
544
+ ? { baseEnv: raw, homeDir }
545
+ : { registry: new SessionRegistry({ baseEnv: raw, homeDir, startProcess: deps.startProcess }) }),
546
+ ...(deps.link === undefined ? {} : { link: deps.link }),
547
+ report: onEvent,
548
+ });
549
+
550
+ // A signal must leave the process in a state a supervisor can distinguish from a crash, so the
551
+ // sessions and the link are closed deliberately and the exit code says which path was taken.
552
+ const shutdown = (signal: string): void => {
553
+ host?.stop(`received ${signal}`);
554
+ // A signal does not erase a failure already recorded. If the credential was refused first,
555
+ // that exit code stands: a supervisor stopping a host it has just been told is unusable must not
556
+ // read its own stop as evidence the run was fine.
557
+ if (!fatal) deps.setExitCode(0);
558
+ };
559
+ deps.onSignal('SIGTERM', () => shutdown('SIGTERM'));
560
+ deps.onSignal('SIGINT', () => shutdown('SIGINT'));
561
+
562
+ host.start();
563
+ return { ok: true, host };
564
+ }
565
+
566
+ /** The process's effective-uid reader, or null where the platform has none. */
567
+ function processUid(): (() => number) | null {
568
+ return process.getuid?.bind(process) ?? null;
569
+ }
570
+
571
+ /** Every named thing the host reports, as one line each. The only output this process produces. */
572
+ export function report(event: HostEvent, log: Logger): void {
573
+ switch (event.kind) {
574
+ case 'link':
575
+ return log(
576
+ 'link',
577
+ `${event.transition.from} -> ${event.transition.to} (${event.transition.cause})`,
578
+ event.transition.detail,
579
+ );
580
+ case 'refusal':
581
+ return log('refused', `${event.sessionKey ?? 'link'}: ${event.refusal.reason}`, event.refusal.detail);
582
+ case 'gap':
583
+ return log('gap', `${event.sessionKey} expected ${event.expected}, received ${event.received}`, null);
584
+ case 'session-opened':
585
+ return log('session', `${event.sessionKey} opened in ${event.cwd}`, null);
586
+ case 'session-closed':
587
+ return log('session', `${event.sessionKey} closed`, null);
588
+ // The held-prompt queue's own trace. Without these the queue would work and nothing would say
589
+ // so, and the gap between this pair is the measurement that a provisioning window was survived.
590
+ case 'prompt-held':
591
+ return log('held', `${event.sessionKey} a turn waits for the session to open`, `${event.held} held`);
592
+ case 'prompt-delivered':
593
+ return log('held', `${event.sessionKey} held turns delivered`, `${event.delivered} delivered`);
594
+ case 'prompt-withdrawn':
595
+ return log(
596
+ 'held',
597
+ `${event.sessionKey} the controller cancelled before the session opened`,
598
+ `${event.withdrawn} withdrawn`,
599
+ );
600
+ case 'transition':
601
+ return log(
602
+ 'state',
603
+ `${event.sessionKey} ${event.transition.from} -> ${event.transition.to} ` +
604
+ `(${event.transition.cause.kind}/${event.transition.cause.event})`,
605
+ event.transition.cause.detail,
606
+ );
607
+ case 'degrade':
608
+ // The named conditions that change what is true of a session without ending it: an
609
+ // untrusted workspace, an id collision. The detail is the operator's instruction and it
610
+ // travels whole: the collision's detail is the only place "resume with fork" is ever said.
611
+ return log('degrade', `${event.sessionKey} ${event.degrade.kind}`, event.degrade.detail);
612
+ default:
613
+ return;
614
+ }
615
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `periscope status`: what this host is, read from what `serve` reads and from the link record
3
+ * `serve` keeps, never from a socket. A host that cannot dial is exactly when the question is asked.
4
+ */
5
+ import { readConfigFile } from '../host/config-file.js';
6
+ import { readLinkState } from '../host/link-state-file.js';
7
+ import { tokenCachePath } from '../host/paths.js';
8
+ import { FileTokenCache } from '../host/token-cache.js';
9
+ import { readMachineFacts } from '../host/machine.js';
10
+ import { describePosture, renderPosture } from './posture.js';
11
+ import { packageVersion } from '../host/package-facts.js';
12
+ import type { ServeViews } from './serve.js';
13
+ import { readCredential } from './serve.js';
14
+
15
+ export interface StatusOutcome {
16
+ readonly ok: boolean;
17
+ readonly lines: readonly string[];
18
+ }
19
+
20
+ export interface StatusDeps {
21
+ readonly nowMs: () => number;
22
+ readonly pidAlive: (pid: number) => boolean;
23
+ }
24
+
25
+ function processPidAlive(pid: number): boolean {
26
+ try {
27
+ process.kill(pid, 0);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ const REAL: StatusDeps = { nowMs: () => Date.now(), pidAlive: processPidAlive };
35
+
36
+ export function runStatus(views: ServeViews, deps: StatusDeps = REAL): StatusOutcome {
37
+ if (typeof views.merged === 'string') return { ok: false, lines: [views.merged] };
38
+ const raw = views.raw;
39
+ const file = readConfigFile(raw);
40
+ const credential = readCredential(raw, () => undefined);
41
+
42
+ let tokenExpiresAtMs: number | null = null;
43
+ const cache = tokenCachePath(raw);
44
+ if (cache !== null) {
45
+ const cached = new FileTokenCache(cache).read();
46
+ if (cached.ok) tokenExpiresAtMs = cached.value.tokens.expiresAt;
47
+ }
48
+
49
+ const posture = describePosture({
50
+ raw,
51
+ merged: views.merged,
52
+ fileValues: file.problem === null ? file.values : {},
53
+ credential,
54
+ tokenExpiresAtMs,
55
+ nowMs: deps.nowMs(),
56
+ link: readLinkState(raw),
57
+ pidAlive: deps.pidAlive,
58
+ hostname: readMachineFacts().hostname,
59
+ });
60
+ return { ok: true, lines: [`periscope ${packageVersion()}`, ...renderPosture(posture)] };
61
+ }