@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,441 @@
1
+ import { FilePairedCredential, FileTokenCache, PeriscopeHost, defaultAgentHome, transcriptsRootUnder, claudeTranscriptResolver, credentialPaths, packageVersion, pairedCredentialPath, protocolFor, readMachineFacts, refresherFor, resolveEndpoints, tokenCachePath, } from '../host/index.js';
2
+ import { escalatingDecider } from '../gate/escalate.js';
3
+ import { PairedHostCredential, TokenCredential, identityPosture } from '../identity/index.js';
4
+ import { SessionRegistry } from '../sessions/registry.js';
5
+ import { readConfigFile } from '../host/config-file.js';
6
+ import { writeLinkState } from '../host/link-state-file.js';
7
+ import { describePosture, postureLine } from './posture.js';
8
+ import { isAbsolutePath } from '../core/paths.js';
9
+ import { systemClock, systemTicker } from '../core/time.js';
10
+ import { configValueProblem, overriddenByEnvironment, reconfigureHost } from './reconfigure.js';
11
+ import { hostConfigurationOf, workspaceCapabilitiesOf, workspacePostureProblem, workspacesFor, } from './workspaces.js';
12
+ /**
13
+ * How long the fatal-credential path holds the event loop open before exiting explicitly.
14
+ *
15
+ * Long enough for the stdout trace and the stderr remedy to flush through their pipes, short enough
16
+ * that a supervisor sees the exit promptly. It is not a grace period for work in flight; the host
17
+ * is already stopped by the time this runs.
18
+ */
19
+ export const FATAL_EXIT_FLUSH_MS = 50;
20
+ function readConfig(env) {
21
+ const controllerUrl = env['PERISCOPE_CONTROLLER_URL'];
22
+ if (controllerUrl === undefined || controllerUrl === '') {
23
+ return 'PERISCOPE_CONTROLLER_URL is not set';
24
+ }
25
+ // A host with nowhere to ask must not start, and neither alternative is survivable. Defaulting
26
+ // to allow makes this a code-execution service for whoever reaches the socket. Defaulting to deny
27
+ // makes a host that comes up healthy, accepts sessions, and blocks every tool call, which reads
28
+ // to an operator as the agent being broken rather than as this host being unconfigured. So it is
29
+ // named at start-up, once, in the one place that reads the environment.
30
+ const decisionUrl = env['PERISCOPE_DECISION_URL'];
31
+ if (decisionUrl === undefined || decisionUrl === '') {
32
+ return ('PERISCOPE_DECISION_URL is not set — this host has nowhere to send a permission decision, and a ' +
33
+ 'host that cannot ask is either an open door or a session where nothing runs. Set it to the ' +
34
+ 'endpoint your controller answers on.');
35
+ }
36
+ // The same screen the wire runs on `host_configure`: a controller URL that is not ws(s), a
37
+ // decision URL that is not http(s), refused at boot by name instead of dialled forever.
38
+ for (const [key, value] of [
39
+ ['PERISCOPE_CONTROLLER_URL', controllerUrl],
40
+ ['PERISCOPE_DECISION_URL', decisionUrl],
41
+ ]) {
42
+ const problem = configValueProblem(key, value);
43
+ if (problem !== null)
44
+ return problem;
45
+ }
46
+ const configuredHostId = env['PERISCOPE_HOST_ID'];
47
+ return {
48
+ controllerUrl,
49
+ // An empty value counts as unset, as every other key reads it: a shell that exports the name
50
+ // with nothing behind it has not chosen a host id.
51
+ hostId: configuredHostId === undefined || configuredHostId === ''
52
+ ? readMachineFacts().hostname
53
+ : configuredHostId,
54
+ decisionUrl,
55
+ workspaceRoot: env['PERISCOPE_WORKSPACE_ROOT'] ?? null,
56
+ repositoryRoot: env['PERISCOPE_REPOSITORY_ROOT'] ?? null,
57
+ branchScheme: env['PERISCOPE_BRANCH_SCHEME'] ?? null,
58
+ workspaceKey: env['PERISCOPE_WORKSPACE_KEY'] ?? null,
59
+ agentHome: env['PERISCOPE_AGENT_HOME'] ?? null,
60
+ };
61
+ }
62
+ /**
63
+ * Which credential this host presents, or null when it presents none.
64
+ *
65
+ * The paired credential is preferred over the OIDC cache, and the preference is the point of
66
+ * pairing: an OIDC refresh token can expire after a period of inactivity, the paired credential has
67
+ * no clock. A machine that has run `periscope pair` dials on the paired credential even when a
68
+ * token cache also exists. A missing paired file falls through to the OIDC postures; a corrupt one
69
+ * is fatal rather than a fallback, because silently degrading to a maybe-dead refresh token would
70
+ * put the host back in exactly the ambiguous posture pairing exists to end.
71
+ *
72
+ * The OIDC three-way decision itself lives in `identity/config.ts` and is tested there; this is the
73
+ * wiring. A misconfiguration returns a string, which `runServe` treats as fatal: a host that was
74
+ * told to use identity and cannot must not come up looking healthy while authenticating as nobody.
75
+ *
76
+ * The `absent` posture returns null rather than a refusing credential, which is the whole
77
+ * difference between "no identity" and "no tool calls". `UnconfiguredCredential` refuses by name on
78
+ * every call, and `escalatingDecider` treats a refusing credential as an outage and does not send
79
+ * the request, so handing one to it would turn every escalation in every session into
80
+ * `permission-decision-unavailable`. That is the exact outcome `readConfig` above refuses to ship
81
+ * for the missing-URL case, in the same file, for the same stated reason: a host that comes up
82
+ * healthy, accepts sessions, and blocks every tool call reads to an operator as the agent being
83
+ * broken. Null means the option is omitted, which `EscalationOptions.credential` documents as the
84
+ * supported no-identity mode; the placeholder stays the exported shape for an embedder that wants a
85
+ * credential that says no out loud.
86
+ */
87
+ export function readCredential(env, log) {
88
+ const pairedPath = pairedCredentialPath(env);
89
+ if (pairedPath !== null) {
90
+ const paired = new FilePairedCredential(pairedPath).read();
91
+ if (paired.ok) {
92
+ // The one posture line, matching the absent-case line in `runServe`: which credential this
93
+ // host is on is otherwise invisible until the first refusal.
94
+ log('credential', `paired as ${paired.value.hostId} - the paired credential is presented on every dial`, null);
95
+ return { credential: new PairedHostCredential(paired.value), pairedHostId: paired.value.hostId };
96
+ }
97
+ if (paired.refusal.reason !== 'token-unavailable') {
98
+ // Exists and is unreadable: fatal, loud, with the fix in the message (see the docblock).
99
+ return paired.refusal.detail;
100
+ }
101
+ // token-unavailable = this machine has never been paired: the normal fall-through.
102
+ }
103
+ const posture = identityPosture(env);
104
+ if (posture.kind === 'invalid')
105
+ return posture.detail;
106
+ if (posture.kind === 'absent')
107
+ return { credential: null, pairedHostId: null };
108
+ const cachePath = tokenCachePath(env);
109
+ if (cachePath === null) {
110
+ return 'identity is configured but there is nowhere to keep the token cache — set PERISCOPE_CONFIG_DIR';
111
+ }
112
+ const store = new FileTokenCache(cachePath);
113
+ const config = posture.config;
114
+ // The endpoints are resolved on first refresh, not at start-up. Discovery is a network call, and
115
+ // making it at construction would mean this host refuses to start when the provider is briefly
116
+ // unreachable, while holding a perfectly good cached token it could have presented. A host that
117
+ // cannot start is worse than one that refreshes late.
118
+ const refresh = async (cached) => {
119
+ const endpoints = await resolveEndpoints(config);
120
+ if (!endpoints.ok)
121
+ return endpoints;
122
+ return refresherFor(config, endpoints.value, store)(cached);
123
+ };
124
+ return {
125
+ credential: new TokenCredential({
126
+ store,
127
+ config,
128
+ // Loopback is the primary flow; the device-code fallback is a deliberate act, so a host running
129
+ // unattended is configured for the one it will actually use.
130
+ protocol: protocolFor(config),
131
+ nowMs: systemTicker,
132
+ refresh,
133
+ // The one line that tells an operator which of the three happened. Without it a cache hit, a
134
+ // silent refresh and "nobody ever signed in here" are the same silence, and the first difference
135
+ // is a 401 the user reads as a server fault. `[credential] refused` names the one thing they can
136
+ // act on, on the machine where they can act on it.
137
+ report: (outcome) => log('credential', outcome.kind === 'refused' ? `refused — ${outcome.reason}` : outcome.kind, outcome.kind === 'refused' ? outcome.detail : null),
138
+ }),
139
+ pairedHostId: null,
140
+ };
141
+ }
142
+ /**
143
+ * Running as root is refused by policy.
144
+ *
145
+ * An unattended agent running as root has the whole machine on every tool call, and a container
146
+ * built the obvious way runs as root. The gate decides which calls run; it does not shrink what a
147
+ * call can reach once it runs, and as uid 0 that is everything.
148
+ *
149
+ * Checked first, before configuration, so the answer arrives as one line at startup rather than as
150
+ * a session that can reach everything. A null `getuid` is win32, where the condition cannot arise.
151
+ */
152
+ function rootRefusal(getuid) {
153
+ if (getuid === null)
154
+ return null;
155
+ if (getuid() !== 0)
156
+ return null;
157
+ return ('refusing to start as root: an unattended agent as uid 0 has the whole machine on every tool call. ' +
158
+ 'Run as a non-root user (add a USER line to your container).');
159
+ }
160
+ /** Start the daemon. Returns the running host, or the refusal already written to stderr. */
161
+ export function runServe(views, deps) {
162
+ const refuse = (detail) => {
163
+ deps.stderr(`periscope: ${detail}`);
164
+ deps.setExitCode(1);
165
+ return { ok: false, detail };
166
+ };
167
+ const log = (channel, message, detail) => {
168
+ const line = detail === null ? message : `${message} — ${detail}`;
169
+ deps.log(`${systemClock()} [${channel}] ${line}`);
170
+ };
171
+ const asRoot = rootRefusal(deps.getuid === undefined ? processUid() : deps.getuid);
172
+ if (asRoot !== null)
173
+ return refuse(asRoot);
174
+ // The config file fills absences, and only absences; the merge is `main.ts`'s, shared by every
175
+ // configuration-consuming verb, and a file that cannot be used is fatal rather than empty.
176
+ const env = views.merged;
177
+ if (typeof env === 'string')
178
+ return refuse(env);
179
+ const config = readConfig(env);
180
+ if (typeof config === 'string')
181
+ return refuse(config);
182
+ // The workspace posture is screened at startup, by name. A default workspace key that fails
183
+ // the union screen, or a branch scheme with a typo'd placeholder, would refuse every session,
184
+ // and a machine that will refuse every session must say so when it boots, where the person who
185
+ // can fix it is looking, not when someone finally opens a session.
186
+ const workspaceMisconfiguration = workspacePostureProblem({
187
+ workspaceRoot: config.workspaceRoot,
188
+ repositoryRoot: config.repositoryRoot,
189
+ branchScheme: config.branchScheme,
190
+ workspaceKey: config.workspaceKey,
191
+ });
192
+ if (workspaceMisconfiguration !== null)
193
+ return refuse(workspaceMisconfiguration);
194
+ // The transcripts root is read on demand and jailed, but a relative one means "relative to a
195
+ // cwd nobody chose"; refused at start-up like every other posture problem.
196
+ if (config.agentHome !== null && config.agentHome !== '' && !isAbsolutePath(config.agentHome)) {
197
+ return refuse(`PERISCOPE_AGENT_HOME must be an absolute path — got '${config.agentHome}'`);
198
+ }
199
+ const raw = views.raw;
200
+ // The posture line leads the output and the credential lines follow it: the one-line summary
201
+ // first, then the detail. The credential is read before the summary can be composed, so its
202
+ // lines are held until the summary has printed.
203
+ const held = [];
204
+ const hold = (...line) => {
205
+ held.push(line);
206
+ };
207
+ const posture = readCredential(raw, hold);
208
+ if (typeof posture === 'string')
209
+ return refuse(posture);
210
+ const credential = posture.credential;
211
+ if (credential === null) {
212
+ // A legitimate posture, and a silent one until this line existed: a host with no identity
213
+ // configured dials with no header, and "misconfigured to nobody" and "deliberately anonymous"
214
+ // looked identical from the outside. Said once, at start-up, where the other two postures
215
+ // already speak.
216
+ hold('credential', 'absent - no identity is configured, so this host will dial without authentication', null);
217
+ }
218
+ // A paired credential names its host, and the name wins. The controller refuses a link_hello
219
+ // whose hostId differs from the credential's, so announcing anything else guarantees a closed
220
+ // socket. Said out loud when the environment disagrees, because a silently ignored variable is a
221
+ // misconfiguration nobody finds.
222
+ const hostId = posture.pairedHostId ?? config.hostId;
223
+ if (posture.pairedHostId !== null &&
224
+ config.hostId !== posture.pairedHostId &&
225
+ raw['PERISCOPE_HOST_ID'] !== undefined) {
226
+ hold('credential', `PERISCOPE_HOST_ID ('${config.hostId}') is overridden by the paired credential's host id ('${posture.pairedHostId}')`, null);
227
+ }
228
+ // The one line status prints whole, from the same description, so the two cannot disagree.
229
+ const reading = readConfigFile(raw);
230
+ const cachePath = tokenCachePath(raw);
231
+ const cached = cachePath === null ? null : new FileTokenCache(cachePath).read();
232
+ log('host', `periscope ${packageVersion()} · ` +
233
+ postureLine(describePosture({
234
+ raw,
235
+ merged: env,
236
+ fileValues: reading.problem === null ? reading.values : {},
237
+ credential: posture,
238
+ tokenExpiresAtMs: cached !== null && cached.ok ? cached.value.tokens.expiresAt : null,
239
+ nowMs: Date.now(),
240
+ link: null,
241
+ pidAlive: () => false,
242
+ hostname: readMachineFacts().hostname,
243
+ })), null);
244
+ for (const line of held)
245
+ log(...line);
246
+ const workspaces = workspacesFor(config);
247
+ const agentHome = config.agentHome === null || config.agentHome === '' ? defaultAgentHome(raw) : config.agentHome;
248
+ // The transcripts root derives from the agent home; it is reported in the hello and never set on
249
+ // its own.
250
+ const transcriptsRoot = agentHome === null ? null : transcriptsRootUnder(agentHome);
251
+ const homeDir = raw['USERPROFILE'] ?? raw['HOME'] ?? '';
252
+ // Declared before the host so the event callback can reach it: the callback runs only after
253
+ // `start()` below, so the binding is always assigned by the time anything can fire.
254
+ let host = null;
255
+ // Whether the fatal path has latched the exit code. A later signal must not reset it.
256
+ let fatal = false;
257
+ /**
258
+ * The one event that ends this process unsuccessfully without anyone asking it to.
259
+ *
260
+ * Everything else this host reports is an observation. `credential_rejected` is a conclusion about
261
+ * the host itself: the identity provider has refused its material and will keep refusing until a
262
+ * person signs in, so there is nothing left for the process to do and staying up implies
263
+ * otherwise. Without this, the link fails, no ref'd handle remains, node drains, and the process
264
+ * exits zero, so a supervisor records a clean run and a dashboard shows a finished host that
265
+ * never once connected.
266
+ *
267
+ * The exit code and the stderr line are both required, and they answer different readers. The
268
+ * code is what a supervisor branches on; the line is what the person at the machine acts on. A
269
+ * non-zero exit whose reason appears only in the stdout trace still leaves somebody reading logs.
270
+ */
271
+ let linkStateProblemSaid = false;
272
+ const onEvent = (event) => {
273
+ if (event.kind === 'link') {
274
+ // The record status reads. A disk that will not take it is said once and never stops the link.
275
+ const problem = writeLinkState(raw, {
276
+ state: event.transition.to,
277
+ cause: event.transition.cause,
278
+ at: event.transition.at,
279
+ detail: event.transition.detail,
280
+ negotiatedVersion: host?.link.negotiatedVersion ?? null,
281
+ pid: process.pid,
282
+ });
283
+ if (problem !== null && !linkStateProblemSaid) {
284
+ linkStateProblemSaid = true;
285
+ log('config', `the link state file is not being written; periscope status will read nothing — ${problem}`, null);
286
+ }
287
+ }
288
+ report(event, log);
289
+ if (event.kind === 'link' && event.transition.cause === 'credential_rejected') {
290
+ // The process exits now, live sessions included, and the count says what that cost. The
291
+ // alternative (serve the survivors and exit when the last one ends) is declined: an idle
292
+ // session never ends on its own, so that shape is a process that can stay up forever behind
293
+ // a link that will never carry another frame, which is the quiet-death posture this file
294
+ // exists to prevent. The transcript mirror keeps what the sessions produced; the one number
295
+ // on this line tells the operator whether work was cut mid-flight.
296
+ const liveSessions = host?.registry.list().length ?? 0;
297
+ // The remedy names the credential the host was actually on: a paired credential is revived by
298
+ // re-pairing, not by signing in. Telling a paired operator to `login` sends them to a flow
299
+ // whose token this host would not even present.
300
+ const remedy = posture.pairedHostId !== null
301
+ ? 'run: periscope pair <code> with a freshly minted code, then start this host again'
302
+ : 'run: periscope login, then start this host again';
303
+ deps.stderr('periscope: the controller or its identity provider refused this credential ' +
304
+ `(${liveSessions} live session(s) stopped) - ${remedy}`);
305
+ fatal = true;
306
+ deps.setExitCode(1);
307
+ host?.stop("this host's credential was refused");
308
+ // Exit deliberately: draining is what lets a signal launder this failure. Latching the
309
+ // code and letting the loop empty leaves a window in which node has released the loop but the
310
+ // process still exists with its default signal dispositions restored; a SIGTERM landing there
311
+ // terminates by signal, so the exit code arrives as null and a supervisor reads "killed"
312
+ // rather than "failed", the exact laundering this terminal class exists to prevent. The
313
+ // ref'd timer holds the loop open, which keeps the handlers below installed while the pipes
314
+ // flush, and the explicit exit is then the only way this path can end. Node 22 on Linux
315
+ // loses this race where node 24 wins it.
316
+ setTimeout(() => deps.exit(1), FATAL_EXIT_FLUSH_MS);
317
+ }
318
+ };
319
+ host = new PeriscopeHost({
320
+ controllerUrl: config.controllerUrl,
321
+ hostId,
322
+ // Omitted, not nulled, when there is no identity. Both this option and the decision
323
+ // transport's are optional by type, and omitting is the documented no-identity mode on each.
324
+ // Spelling it as one spread keeps the two transports impossible to configure differently by
325
+ // accident (one authenticated, one not).
326
+ ...(credential === null ? {} : { credential }),
327
+ // The same credential the link presents, on the decision POST. The surface that decides
328
+ // whether a tool runs must not ship unauthenticated while the surface that merely reports is
329
+ // authenticated.
330
+ //
331
+ // Per host, not per session, and the distinction is a design constraint rather than a detail:
332
+ // one host serves many sessions, and this is read once from the environment, so nothing here can
333
+ // authenticate as the particular session a decision is about. That is what
334
+ // `DecisionRequest.sessionKey` is for: the controller attributes; the credential authenticates.
335
+ //
336
+ // And it is omitted when there is none, not passed as a credential that refuses. Passing one
337
+ // that refuses does not make the request unauthenticated; it makes there be no request, on
338
+ // every tool call, in every session. See `readCredential` above.
339
+ decide: escalatingDecider({
340
+ url: config.decisionUrl,
341
+ transport: deps.transport ?? fetch,
342
+ ...(credential === null ? {} : { credential }),
343
+ }),
344
+ // Derived, never spelled out here: `credentialPaths` is the one source both this and the token
345
+ // cache's own location come from, so they cannot disagree about what is protected.
346
+ protectedPaths: credentialPaths(raw, { agentHome }),
347
+ // The discovery door: read-only, jailed. The root is derived here because reading the
348
+ // environment is the composition root's job; a machine with no resolvable home gets a host
349
+ // whose door answers with a named failure rather than a guessed root. The bulk resolver
350
+ // serves `claude-transcript:` locators through the same jail, reads only; the gate's
351
+ // protection of `~/.claude` against the agent is untouched by either.
352
+ ...(transcriptsRoot === null ? {} : { transcriptsRoot, bulk: claudeTranscriptResolver(transcriptsRoot) }),
353
+ ...(workspaces === null ? {} : { workspaces }),
354
+ // The workspace mode rides the hello as capability markers, derived from the same config the
355
+ // selector above consumed: the read half of what `periscope config` writes, and what lets a
356
+ // controller verify a host is set up rather than trusting its operator's memory.
357
+ linkCapabilities: workspaceCapabilitiesOf(config),
358
+ // The values behind the markers, from the same reading: which roots, which scheme, which
359
+ // transcripts root, which controller this process dialled.
360
+ configuration: hostConfigurationOf(config, {
361
+ transcriptsRoot,
362
+ controllerUrl: config.controllerUrl,
363
+ decisionUrl: config.decisionUrl,
364
+ agentHome,
365
+ }),
366
+ overriddenByEnvironment: overriddenByEnvironment(raw),
367
+ // The configure seam: validates, writes the config file, rebuilds the provider. It reads
368
+ // the raw environment because the file fills absences in it, and only this file may read that.
369
+ // The addresses this process dialled ride in so a written URL is reported as pending rather than
370
+ // applied: the live link is never re-pointed, the next start reads the file.
371
+ reconfigure: (entries, hostBusy) => reconfigureHost(raw, entries, hostBusy, {
372
+ controllerUrl: config.controllerUrl,
373
+ decisionUrl: config.decisionUrl,
374
+ }),
375
+ // The default an unkeyed session_new resolves to, screened above at startup, so a bad
376
+ // value died before this line. Omitted (not nulled) when unset: absence is the documented mode.
377
+ ...(config.workspaceKey === null || config.workspaceKey === ''
378
+ ? {}
379
+ : { defaultWorkspaceKey: config.workspaceKey }),
380
+ // The registry is the host's own unless a test supplies the process starter; then it is built
381
+ // here over the same base environment and home the host would have used, and the pair goes to
382
+ // the registry alone: the host refuses the two beside a registry, where nothing would read them.
383
+ ...(deps.startProcess === undefined
384
+ ? { baseEnv: raw, homeDir }
385
+ : { registry: new SessionRegistry({ baseEnv: raw, homeDir, startProcess: deps.startProcess }) }),
386
+ ...(deps.link === undefined ? {} : { link: deps.link }),
387
+ report: onEvent,
388
+ });
389
+ // A signal must leave the process in a state a supervisor can distinguish from a crash, so the
390
+ // sessions and the link are closed deliberately and the exit code says which path was taken.
391
+ const shutdown = (signal) => {
392
+ host?.stop(`received ${signal}`);
393
+ // A signal does not erase a failure already recorded. If the credential was refused first,
394
+ // that exit code stands: a supervisor stopping a host it has just been told is unusable must not
395
+ // read its own stop as evidence the run was fine.
396
+ if (!fatal)
397
+ deps.setExitCode(0);
398
+ };
399
+ deps.onSignal('SIGTERM', () => shutdown('SIGTERM'));
400
+ deps.onSignal('SIGINT', () => shutdown('SIGINT'));
401
+ host.start();
402
+ return { ok: true, host };
403
+ }
404
+ /** The process's effective-uid reader, or null where the platform has none. */
405
+ function processUid() {
406
+ return process.getuid?.bind(process) ?? null;
407
+ }
408
+ /** Every named thing the host reports, as one line each. The only output this process produces. */
409
+ export function report(event, log) {
410
+ switch (event.kind) {
411
+ case 'link':
412
+ return log('link', `${event.transition.from} -> ${event.transition.to} (${event.transition.cause})`, event.transition.detail);
413
+ case 'refusal':
414
+ return log('refused', `${event.sessionKey ?? 'link'}: ${event.refusal.reason}`, event.refusal.detail);
415
+ case 'gap':
416
+ return log('gap', `${event.sessionKey} expected ${event.expected}, received ${event.received}`, null);
417
+ case 'session-opened':
418
+ return log('session', `${event.sessionKey} opened in ${event.cwd}`, null);
419
+ case 'session-closed':
420
+ return log('session', `${event.sessionKey} closed`, null);
421
+ // The held-prompt queue's own trace. Without these the queue would work and nothing would say
422
+ // so, and the gap between this pair is the measurement that a provisioning window was survived.
423
+ case 'prompt-held':
424
+ return log('held', `${event.sessionKey} a turn waits for the session to open`, `${event.held} held`);
425
+ case 'prompt-delivered':
426
+ return log('held', `${event.sessionKey} held turns delivered`, `${event.delivered} delivered`);
427
+ case 'prompt-withdrawn':
428
+ return log('held', `${event.sessionKey} the controller cancelled before the session opened`, `${event.withdrawn} withdrawn`);
429
+ case 'transition':
430
+ return log('state', `${event.sessionKey} ${event.transition.from} -> ${event.transition.to} ` +
431
+ `(${event.transition.cause.kind}/${event.transition.cause.event})`, event.transition.cause.detail);
432
+ case 'degrade':
433
+ // The named conditions that change what is true of a session without ending it: an
434
+ // untrusted workspace, an id collision. The detail is the operator's instruction and it
435
+ // travels whole: the collision's detail is the only place "resume with fork" is ever said.
436
+ return log('degrade', `${event.sessionKey} ${event.degrade.kind}`, event.degrade.detail);
437
+ default:
438
+ return;
439
+ }
440
+ }
441
+ //# sourceMappingURL=serve.js.map
@@ -0,0 +1,11 @@
1
+ import type { ServeViews } from './serve.js';
2
+ export interface StatusOutcome {
3
+ readonly ok: boolean;
4
+ readonly lines: readonly string[];
5
+ }
6
+ export interface StatusDeps {
7
+ readonly nowMs: () => number;
8
+ readonly pidAlive: (pid: number) => boolean;
9
+ }
10
+ export declare function runStatus(views: ServeViews, deps?: StatusDeps): StatusOutcome;
11
+ //# sourceMappingURL=status.d.ts.map
@@ -0,0 +1,49 @@
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 { readCredential } from './serve.js';
13
+ function processPidAlive(pid) {
14
+ try {
15
+ process.kill(pid, 0);
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ const REAL = { nowMs: () => Date.now(), pidAlive: processPidAlive };
23
+ export function runStatus(views, deps = REAL) {
24
+ if (typeof views.merged === 'string')
25
+ return { ok: false, lines: [views.merged] };
26
+ const raw = views.raw;
27
+ const file = readConfigFile(raw);
28
+ const credential = readCredential(raw, () => undefined);
29
+ let tokenExpiresAtMs = null;
30
+ const cache = tokenCachePath(raw);
31
+ if (cache !== null) {
32
+ const cached = new FileTokenCache(cache).read();
33
+ if (cached.ok)
34
+ tokenExpiresAtMs = cached.value.tokens.expiresAt;
35
+ }
36
+ const posture = describePosture({
37
+ raw,
38
+ merged: views.merged,
39
+ fileValues: file.problem === null ? file.values : {},
40
+ credential,
41
+ tokenExpiresAtMs,
42
+ nowMs: deps.nowMs(),
43
+ link: readLinkState(raw),
44
+ pidAlive: deps.pidAlive,
45
+ hostname: readMachineFacts().hostname,
46
+ });
47
+ return { ok: true, lines: [`periscope ${packageVersion()}`, ...renderPosture(posture)] };
48
+ }
49
+ //# sourceMappingURL=status.js.map
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Which workspace provider a real process gets.
3
+ *
4
+ * Split out of the composition root (`main.ts` and `serve.ts`) rather than exported from it, for
5
+ * the reason that file's own header gives: everything below the composition root takes its
6
+ * configuration as arguments, which is what makes the rest of the package testable without a
7
+ * process. The selector is pure and lives where it can be called on its own.
8
+ */
9
+ import type { HostConfiguration } from '../control/frames.js';
10
+ import type { WorkspaceProvider } from '../workspace/provider.js';
11
+ /** The environment answers the selector reads. A subset of the binary's `Config`, by structure. */
12
+ export interface WorkspaceConfig {
13
+ /** When set, every session gets a directory beneath it instead of the one the controller named. */
14
+ readonly workspaceRoot: string | null;
15
+ /** When set alongside the root, sessions get a linked git worktree on their own branch. */
16
+ readonly repositoryRoot: string | null;
17
+ /**
18
+ * The branch template a worktree's branch is rendered from: `{key}` and `{repo}` are the
19
+ * two placeholders, e.g. `periscope/{repo}/{key}`. Null means the provider's own fallback
20
+ * (`periscope/{sessionId}`).
21
+ */
22
+ readonly branchScheme: string | null;
23
+ }
24
+ /** Render a branch scheme. Pure; exported so the rule is testable without a provider. */
25
+ export declare function renderBranch(scheme: string, key: string, repo: string): string;
26
+ /**
27
+ * The first way this rendered branch name is illegal, or null.
28
+ *
29
+ * A legal key can render an illegal refname: a `{repo}` carrying a dot-prefixed segment, a
30
+ * scheme with a trailing slash, a literal ending `.lock`. Validating the key and then building an
31
+ * unvalidated branch from it would let a branch die inside git on every open, so the result is
32
+ * screened: per slash-separated component, against the same union rule the key already passed
33
+ * (`refnameOrPathIllegality`, the single source of that class).
34
+ */
35
+ export declare function branchNameProblem(branch: string): string | null;
36
+ /**
37
+ * What is wrong with this workspace posture, at startup, or null when it is usable.
38
+ *
39
+ * Screened when the process boots, not at the first session. A machine that will refuse every
40
+ * session (an unusable default key, a scheme with a typo'd placeholder) must say so when it starts,
41
+ * where the one person who can fix it is looking, not days later when someone finally opens a
42
+ * session. Pure, and exported precisely so it is testable without starting a host.
43
+ *
44
+ * Three families of refusal, each by name:
45
+ * - a setting that depends on another that is absent (a key with no provider, a scheme with no
46
+ * repository); silently ignoring either is a misconfiguration nobody finds;
47
+ * - a default workspace key that fails the same union screen a wire-supplied key must pass;
48
+ * - a branch scheme with an unknown placeholder or an unmatched brace (never rendered literally;
49
+ * `{repoo}` or a trailing `{repo` in a branch name is a silent wrong answer), or whose literal
50
+ * text already renders illegally.
51
+ */
52
+ export declare function workspacePostureProblem(posture: {
53
+ readonly workspaceRoot: string | null;
54
+ readonly repositoryRoot: string | null;
55
+ readonly branchScheme: string | null;
56
+ readonly workspaceKey: string | null;
57
+ }): string | null;
58
+ /**
59
+ * The branch scheme a git-worktree host renders when none is configured: the repository's name,
60
+ * then the key. With keys named after the platform's own identities (`session-150`,
61
+ * `run-34`) this puts every session on `repo/session-150`, the name an operator reads in
62
+ * the session's own URL. Explicitly configured schemes override it; the `workspace:branch-scheme`
63
+ * marker means "explicitly configured", never "this default".
64
+ */
65
+ export declare const DEFAULT_BRANCH_SCHEME = "{repo}/{key}";
66
+ /**
67
+ * The `branchFor` a scheme produces, extracted so the render-and-screen rule is testable without
68
+ * a provider, a repository, or a real git (the same reason this whole file exists).
69
+ *
70
+ * A render the screen refuses throws, and the throw is the contract: the provider calls this
71
+ * inside its own try, so the message lands in a named `workspace-provision-failed` refusal rather
72
+ * than dying inside git. This is the runtime half of the screen, for the violations only a
73
+ * particular key or repo name produces (the startup screen already caught the scheme's literal
74
+ * text).
75
+ */
76
+ export declare function branchRenderer(scheme: string, repositoryRoot: string): (sessionId: string) => string;
77
+ export declare function workspacesFor(config: WorkspaceConfig): WorkspaceProvider | null;
78
+ /**
79
+ * The mode, as capability markers for the hello: the read half of what `periscope config` writes.
80
+ * A host that can be configured but cannot report how it is configured gives a controller nothing
81
+ * to verify.
82
+ *
83
+ * A pure twin of `workspacesFor`, kept beside it so the two cannot drift: the marker is derived
84
+ * from the same predicates that choose the provider, never from a second reading of the
85
+ * environment. Exactly one `workspace:*` mode marker is always present; absence of all three in a
86
+ * hello therefore means "this build does not report", which is what lets a controller render
87
+ * "not reported" instead of a default.
88
+ *
89
+ * Markers, not values. Which repository root, which scheme text: those would be payload members
90
+ * and a protocol-version change, deliberately not smuggled into marker strings.
91
+ */
92
+ export declare function workspaceCapabilitiesOf(config: WorkspaceConfig): readonly string[];
93
+ /** The values the hello reports that the workspace config does not hold. */
94
+ export interface HostConfigurationExtras {
95
+ readonly transcriptsRoot: string | null;
96
+ readonly controllerUrl: string | null;
97
+ readonly decisionUrl: string | null;
98
+ readonly agentHome: string | null;
99
+ }
100
+ /**
101
+ * The values behind the markers: what the hello reports as `configuration`.
102
+ *
103
+ * The other pure twin of `workspacesFor`. Read from the same `WorkspaceConfig` the selector and
104
+ * the markers consume, so a root the selector treats as unset (null or empty) is reported as
105
+ * null here, never as an empty string a controller would render as a path. A value present here
106
+ * and a `workspace:none` marker cannot both be true of one config, and the test pins that.
107
+ */
108
+ export declare function hostConfigurationOf(config: WorkspaceConfig, extras: HostConfigurationExtras): HostConfiguration;
109
+ //# sourceMappingURL=workspaces.d.ts.map