@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,198 @@
1
+ /**
2
+ * `periscope pair <code> [--controller <origin>] [--label <name>]` — trade a short-lived pair code
3
+ * for this machine's durable credential, and learn where the controller is from its answer.
4
+ *
5
+ * Why this is a command rather than something the daemon does: the same reason `login` is. The
6
+ * code is single-use and expires in minutes (it is a deliberate act a person performs once) and
7
+ * the daemon must never block on anything interactive. After this succeeds, the daemon reads the
8
+ * written file forever; after the controller revokes it, the daemon's next dial dies loudly and
9
+ * the fix is to run this again with a fresh code.
10
+ *
11
+ * It writes the same protected directory the daemon reads, derived from the same call:
12
+ * `pairedCredentialPath(env)`, beside the token cache, inside the gate's protected set. Nothing
13
+ * here computes a path of its own.
14
+ *
15
+ * Where the redemption door is: `--controller`'s origin when given; else `PERISCOPE_PAIR_URL` when
16
+ * set; otherwise derived from `PERISCOPE_DECISION_URL`'s origin (the one controller URL every
17
+ * configured host already has) plus the controller's published redemption route. The derivation is
18
+ * stated here once so a controller that moves the route has one line to argue with.
19
+ *
20
+ * What the controller answers with, beyond the credential: the URLs this host should dial
21
+ * (`controllerUrl`, `decisionUrl`). The controller names its own routes; this command hardcodes only
22
+ * the pair route above. When both arrive they are written to the config file beside the credential,
23
+ * so a machine paired with one command needs nothing else before `serve`. The environment still
24
+ * wins per key, and the write says so when it is shadowed.
25
+ */
26
+ import { certificateRemedy, describeFailure, isCertificateRefusal } from '../core/failure.js';
27
+ import { writeConfigEntries } from '../host/config-file.js';
28
+ import { readMachineFacts } from '../host/machine.js';
29
+ import { pairedCredentialPath } from '../host/paths.js';
30
+ import { FilePairedCredential } from '../host/paired-credential-store.js';
31
+ const REDEMPTION_ROUTE = '/api/periscope/pair';
32
+ /** Run the pair command. Returns rather than exits, so the caller owns the process. */
33
+ export async function runPair(code, env, deps = {}, asked = {}) {
34
+ const write = deps.write ?? ((line) => process.stdout.write(`${line}\n`));
35
+ if (code === null) {
36
+ return {
37
+ ok: false,
38
+ detail: 'no code given - usage: periscope pair <code> [--controller <origin>] [--label <name>]',
39
+ };
40
+ }
41
+ const url = redemptionUrl(env, asked.controller ?? null);
42
+ if (url === null) {
43
+ return {
44
+ ok: false,
45
+ detail: asked.controller === undefined || asked.controller === null
46
+ ? 'nowhere to redeem the code - pass --controller <origin>, or set PERISCOPE_PAIR_URL or PERISCOPE_DECISION_URL (whose origin names the controller)'
47
+ : `--controller '${asked.controller}' is not an http(s) URL`,
48
+ };
49
+ }
50
+ const path = pairedCredentialPath(env);
51
+ if (path === null) {
52
+ return {
53
+ ok: false,
54
+ detail: 'nowhere to keep the credential - set PERISCOPE_CONFIG_DIR or run as a user with a home directory',
55
+ };
56
+ }
57
+ // The label the operator will recognize this machine by in every listing. Overridable because a
58
+ // hostname like `DESKTOP-4F7Q2` recognizes nobody.
59
+ const machineLabel = asked.label ?? env['PERISCOPE_MACHINE_LABEL'] ?? readMachineFacts().hostname;
60
+ const transport = deps.transport ?? fetch;
61
+ let response;
62
+ try {
63
+ response = await transport(url, {
64
+ method: 'POST',
65
+ headers: { 'content-type': 'application/json' },
66
+ body: JSON.stringify({ code, machineLabel }),
67
+ });
68
+ }
69
+ catch (error) {
70
+ // The cause, not the wrapper: `fetch failed` over a refused certificate is not "unreachable", and
71
+ // a reader told the controller is down will not look at the certificate.
72
+ const why = describeFailure(error);
73
+ return {
74
+ ok: false,
75
+ detail: isCertificateRefusal(error)
76
+ ? `the controller at ${url} presented a certificate Node refused (${why}). ${certificateRemedy('the controller')}`
77
+ : `the controller at ${url} could not be reached - ${why}`,
78
+ };
79
+ }
80
+ if (response.status === 404 || response.status === 405) {
81
+ // Nothing at this route is a wrong door, not a refused code: the origin is not a controller,
82
+ // or its pair route is elsewhere. Saying "mint a fresh code" here sends the reader to the
83
+ // wrong fix.
84
+ return {
85
+ ok: false,
86
+ detail: `nothing answers the pair route at ${url} (HTTP ${response.status}). Check --controller (an ` +
87
+ 'http(s) origin) or PERISCOPE_PAIR_URL; a controller redeems codes at its own pair route.',
88
+ };
89
+ }
90
+ if (!response.ok) {
91
+ // The door's one refusal is deliberate (unknown, expired and consumed answer identically);
92
+ // the honest instruction is therefore always the same: mint a fresh code.
93
+ const body = await response.text().catch(() => '');
94
+ return {
95
+ ok: false,
96
+ detail: `the controller refused this code (HTTP ${response.status}${body === '' ? '' : ` - ${body}`}). ` +
97
+ 'Codes are single-use and expire in minutes; mint a fresh one and try again.',
98
+ };
99
+ }
100
+ let parsed;
101
+ try {
102
+ parsed = await response.json();
103
+ }
104
+ catch {
105
+ return {
106
+ ok: false,
107
+ detail: 'the controller answered success with an unreadable body - nothing was written',
108
+ };
109
+ }
110
+ const record = (typeof parsed === 'object' && parsed !== null ? parsed : {});
111
+ const hostId = record['hostId'];
112
+ const credential = record['hostCredential'];
113
+ if (typeof hostId !== 'string' || hostId === '' || typeof credential !== 'string' || credential === '') {
114
+ return {
115
+ ok: false,
116
+ detail: 'the controller answered success without a credential - nothing was written',
117
+ };
118
+ }
119
+ // The shape is the contract: `serve` reads the host id out of the credential and refuses a file
120
+ // whose two copies disagree, so a credential that would be refused at the next start is refused
121
+ // here, where the fix (the controller's answer) is nameable.
122
+ if (!credential.startsWith(`p1.${hostId}.`) || credential.length <= `p1.${hostId}.`.length) {
123
+ return {
124
+ ok: false,
125
+ detail: `the controller answered a credential that does not have the shape p1.<hostId>.<secret> for ` +
126
+ `host ${hostId} - nothing was written. A paired credential embeds the host id it speaks for.`,
127
+ };
128
+ }
129
+ const store = (deps.store ?? ((p) => new FilePairedCredential(p)))(path);
130
+ const written = store.write({ hostId, credential });
131
+ if (!written.ok) {
132
+ return { ok: false, detail: `${written.refusal.reason} - ${written.refusal.detail}` };
133
+ }
134
+ write(`paired as ${hostId}; credential written to ${path}`);
135
+ write('this host now dials with the paired credential - the sign-in token expiry no longer applies to it');
136
+ // The controller names where this host should dial. Written after the credential, never instead
137
+ // of it: the credential is the scarce thing, and a config write that fails is reported, not a
138
+ // reason to lose what was just minted.
139
+ const controllerUrl = record['controllerUrl'];
140
+ const decisionUrl = record['decisionUrl'];
141
+ if (typeof controllerUrl === 'string' &&
142
+ controllerUrl !== '' &&
143
+ typeof decisionUrl === 'string' &&
144
+ decisionUrl !== '') {
145
+ const problem = writeConfigEntries(env, [
146
+ { key: 'PERISCOPE_CONTROLLER_URL', value: controllerUrl },
147
+ { key: 'PERISCOPE_DECISION_URL', value: decisionUrl },
148
+ ]);
149
+ if (problem !== null) {
150
+ write(`the controller's addresses were not written to the config file - ${problem}`);
151
+ }
152
+ else {
153
+ write(`PERISCOPE_CONTROLLER_URL and PERISCOPE_DECISION_URL written to the config file - serve needs nothing else`);
154
+ for (const key of ['PERISCOPE_CONTROLLER_URL', 'PERISCOPE_DECISION_URL']) {
155
+ const fromEnv = env[key];
156
+ if (fromEnv !== undefined && fromEnv !== '') {
157
+ write(`note: ${key} is set in the environment, and the environment wins - the file value is a fallback`);
158
+ }
159
+ }
160
+ }
161
+ }
162
+ else {
163
+ write('the controller did not name its link and decision URLs - set PERISCOPE_CONTROLLER_URL and PERISCOPE_DECISION_URL before serve');
164
+ }
165
+ return { ok: true, hostId, path };
166
+ }
167
+ /**
168
+ * The redemption door's address. `PERISCOPE_PAIR_URL` verbatim when set; otherwise the decision
169
+ * URL's origin + the controller's published route. The decision URL is required configuration for
170
+ * every serving host, so a machine being paired on the box it will serve from has nothing extra to
171
+ * set.
172
+ */
173
+ function redemptionUrl(env, controller) {
174
+ if (controller !== null) {
175
+ try {
176
+ const parsed = new URL(controller);
177
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
178
+ return null;
179
+ return `${parsed.origin}${REDEMPTION_ROUTE}`;
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ }
185
+ const explicit = env['PERISCOPE_PAIR_URL'];
186
+ if (explicit !== undefined && explicit !== '')
187
+ return explicit;
188
+ const decision = env['PERISCOPE_DECISION_URL'];
189
+ if (decision === undefined || decision === '')
190
+ return null;
191
+ try {
192
+ return `${new URL(decision).origin}${REDEMPTION_ROUTE}`;
193
+ }
194
+ catch {
195
+ return null;
196
+ }
197
+ }
198
+ //# sourceMappingURL=pair.js.map
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=periscope.d.ts.map
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main, processIo } from './main.js';
3
+ void main(process.argv.slice(2), process.env, processIo());
4
+ //# sourceMappingURL=periscope.js.map
@@ -0,0 +1,64 @@
1
+ import type { LinkStateRecord } from '../host/link-state-file.js';
2
+ import type { CredentialPosture } from './serve.js';
3
+ export interface PostureInputs {
4
+ /** The process environment as it was. */
5
+ readonly raw: NodeJS.ProcessEnv;
6
+ /** The environment with the config file filling its absences. */
7
+ readonly merged: NodeJS.ProcessEnv;
8
+ /** The values the config file holds, so a key can say which source it came from. */
9
+ readonly fileValues: Readonly<Record<string, string>>;
10
+ /** What `readCredential` decided, or its refusal. */
11
+ readonly credential: CredentialPosture | string;
12
+ /** The cached token's expiry in epoch milliseconds, when a token cache is readable. */
13
+ readonly tokenExpiresAtMs: number | null;
14
+ readonly nowMs: number;
15
+ readonly link: LinkStateRecord | null;
16
+ /** Whether the process that wrote the link record is still alive. */
17
+ readonly pidAlive: (pid: number) => boolean;
18
+ /** The machine's hostname, the host id's fallback. */
19
+ readonly hostname: string;
20
+ }
21
+ export type CredentialKind = 'paired' | 'token' | 'absent' | 'unreadable';
22
+ export type WorkspaceMode = 'git-worktree' | 'plain' | 'none';
23
+ export type Source = 'environment' | 'config-file' | 'default' | 'unset';
24
+ export interface Setting {
25
+ readonly key: string;
26
+ readonly value: string | null;
27
+ readonly source: Source;
28
+ /** Set in both places, and the environment won. */
29
+ readonly shadowsFile: boolean;
30
+ }
31
+ export interface Posture {
32
+ readonly hostId: {
33
+ readonly configured: string;
34
+ readonly paired: string | null;
35
+ readonly effective: string;
36
+ };
37
+ readonly credential: {
38
+ readonly kind: CredentialKind;
39
+ readonly expiresAt: string | null;
40
+ readonly expired: boolean;
41
+ readonly problem: string | null;
42
+ };
43
+ readonly workspace: {
44
+ readonly mode: WorkspaceMode;
45
+ readonly branchScheme: string | null;
46
+ };
47
+ readonly sources: readonly Setting[];
48
+ readonly configFile: string | null;
49
+ readonly link: {
50
+ readonly state: string;
51
+ readonly cause: string | null;
52
+ readonly at: string | null;
53
+ readonly detail: string | null;
54
+ readonly negotiatedVersion: number | null;
55
+ readonly pid: number | null;
56
+ readonly alive: boolean;
57
+ };
58
+ }
59
+ export declare function describePosture(inputs: PostureInputs): Posture;
60
+ /** The one line `serve` prints at start-up: the host, the credential, the workspace, the config file. */
61
+ export declare function postureLine(posture: Posture): string;
62
+ /** The whole posture, one fact per line, for `periscope status`. */
63
+ export declare function renderPosture(posture: Posture): string[];
64
+ //# sourceMappingURL=posture.d.ts.map
@@ -0,0 +1,157 @@
1
+ /**
2
+ * What this host is, said once: its ids, its credential, its workspace, its link and where each
3
+ * setting came from. `serve` prints the one-line form at start-up and `status` prints the whole,
4
+ * from one description, so the two can never disagree about the host they describe. Pure over its
5
+ * inputs; the reads that fill them live with the verbs.
6
+ */
7
+ import { CONFIG_KEYS, WIRE_CONFIGURABLE_KEYS, configFilePath } from '../host/config-file.js';
8
+ import { workspaceCapabilitiesOf } from './workspaces.js';
9
+ function settingOf(key, inputs) {
10
+ const fromEnv = inputs.raw[key];
11
+ const fromFile = inputs.fileValues[key];
12
+ const inEnv = fromEnv !== undefined && fromEnv !== '';
13
+ const inFile = fromFile !== undefined && fromFile !== '';
14
+ if (inEnv)
15
+ return { key, value: fromEnv, source: 'environment', shadowsFile: inFile };
16
+ if (inFile)
17
+ return { key, value: fromFile, source: 'config-file', shadowsFile: false };
18
+ const merged = inputs.merged[key];
19
+ if (merged !== undefined && merged !== '')
20
+ return { key, value: merged, source: 'default', shadowsFile: false };
21
+ return { key, value: null, source: 'unset', shadowsFile: false };
22
+ }
23
+ export function describePosture(inputs) {
24
+ const keys = [
25
+ ...new Set([
26
+ ...CONFIG_KEYS,
27
+ ...WIRE_CONFIGURABLE_KEYS,
28
+ 'PERISCOPE_HOST_ID',
29
+ 'PERISCOPE_CONFIG_DIR',
30
+ ]),
31
+ ];
32
+ const sources = keys.map((key) => settingOf(key, inputs));
33
+ const value = (key) => sources.find((one) => one.key === key)?.value ?? null;
34
+ const configured = value('PERISCOPE_HOST_ID') ?? inputs.hostname;
35
+ const paired = typeof inputs.credential === 'string' ? null : inputs.credential.pairedHostId;
36
+ let kind;
37
+ let problem = null;
38
+ if (typeof inputs.credential === 'string') {
39
+ kind = 'unreadable';
40
+ problem = inputs.credential;
41
+ }
42
+ else if (inputs.credential.pairedHostId !== null)
43
+ kind = 'paired';
44
+ else if (inputs.credential.credential !== null)
45
+ kind = 'token';
46
+ else
47
+ kind = 'absent';
48
+ const expiresAt = kind === 'token' && inputs.tokenExpiresAtMs !== null
49
+ ? new Date(inputs.tokenExpiresAtMs).toISOString()
50
+ : null;
51
+ const expired = kind === 'token' && inputs.tokenExpiresAtMs !== null && inputs.tokenExpiresAtMs <= inputs.nowMs;
52
+ const capabilities = workspaceCapabilitiesOf({
53
+ workspaceRoot: value('PERISCOPE_WORKSPACE_ROOT'),
54
+ repositoryRoot: value('PERISCOPE_REPOSITORY_ROOT'),
55
+ branchScheme: value('PERISCOPE_BRANCH_SCHEME'),
56
+ });
57
+ const mode = capabilities.includes('workspace:git-worktree')
58
+ ? 'git-worktree'
59
+ : capabilities.includes('workspace:plain')
60
+ ? 'plain'
61
+ : 'none';
62
+ const link = inputs.link;
63
+ return {
64
+ hostId: { configured, paired, effective: paired ?? configured },
65
+ credential: { kind, expiresAt, expired, problem },
66
+ workspace: { mode, branchScheme: value('PERISCOPE_BRANCH_SCHEME') },
67
+ sources,
68
+ configFile: configFilePath(inputs.raw),
69
+ link: link === null
70
+ ? {
71
+ state: 'unknown',
72
+ cause: null,
73
+ at: null,
74
+ detail: null,
75
+ negotiatedVersion: null,
76
+ pid: null,
77
+ alive: false,
78
+ }
79
+ : {
80
+ state: link.state,
81
+ cause: link.cause,
82
+ at: link.at,
83
+ detail: link.detail,
84
+ negotiatedVersion: link.negotiatedVersion,
85
+ pid: link.pid,
86
+ alive: inputs.pidAlive(link.pid),
87
+ },
88
+ };
89
+ }
90
+ /** The one line `serve` prints at start-up: the host, the credential, the workspace, the config file. */
91
+ export function postureLine(posture) {
92
+ // A token posture names the cache it will present: configured for a token with none cached is
93
+ // the state every refusal at the first dial comes from, and it is visible here first.
94
+ const credential = posture.credential.kind === 'token'
95
+ ? posture.credential.expiresAt !== null
96
+ ? `token (expires ${posture.credential.expiresAt})`
97
+ : 'token (no cache yet - sign in first)'
98
+ : posture.credential.kind;
99
+ return (`host ${posture.hostId.effective}` +
100
+ (posture.hostId.paired !== null && posture.hostId.paired !== posture.hostId.configured
101
+ ? ` (paired; configured ${posture.hostId.configured})`
102
+ : '') +
103
+ ` · credential ${credential} · workspace ${posture.workspace.mode}` +
104
+ ` · config file ${posture.configFile ?? '(none)'}`);
105
+ }
106
+ /** The whole posture, one fact per line, for `periscope status`. */
107
+ export function renderPosture(posture) {
108
+ const lines = [];
109
+ const link = posture.link;
110
+ if (link.state === 'unknown') {
111
+ lines.push('link: unknown — no host has started with this config directory yet; run periscope to start one');
112
+ }
113
+ else {
114
+ const liveness = link.alive
115
+ ? `serve pid ${link.pid} is running`
116
+ : `serve pid ${link.pid} is gone, so this is the last thing it said`;
117
+ lines.push(`link: ${link.state} since ${link.at} (${link.cause}${link.detail === null ? '' : `: ${link.detail}`}); ${liveness}`);
118
+ if (link.negotiatedVersion !== null)
119
+ lines.push(`protocol: v${link.negotiatedVersion} negotiated at the last accepted handshake`);
120
+ }
121
+ const credential = posture.credential;
122
+ switch (credential.kind) {
123
+ case 'paired':
124
+ lines.push(`credential: paired as ${posture.hostId.paired} (no expiry; rotation is re-pairing)`);
125
+ break;
126
+ case 'token':
127
+ lines.push(`credential: signed-in token${credential.expiresAt === null ? ' (no cache yet - run periscope login)' : `, ${credential.expired ? 'EXPIRED at' : 'expires'} ${credential.expiresAt}`}`);
128
+ break;
129
+ case 'absent':
130
+ lines.push('credential: absent — this host dials without authentication');
131
+ break;
132
+ default:
133
+ lines.push(`credential: unreadable — ${credential.problem ?? ''}`);
134
+ }
135
+ lines.push(`host id: ${posture.hostId.effective}` +
136
+ (posture.hostId.paired !== null
137
+ ? posture.hostId.paired === posture.hostId.configured
138
+ ? ' (the paired credential agrees)'
139
+ : ` (the paired credential's, overriding the configured ${posture.hostId.configured})`
140
+ : ''));
141
+ lines.push(`workspace: ${posture.workspace.mode}${posture.workspace.branchScheme === null ? '' : `, branch scheme ${posture.workspace.branchScheme}`}`);
142
+ lines.push(`config file: ${posture.configFile ?? '(none: no home directory and no PERISCOPE_CONFIG_DIR)'}`);
143
+ for (const setting of posture.sources) {
144
+ // The config directory is never a config-file key (a file cannot move itself), so it is listed
145
+ // only when the environment set it.
146
+ if (setting.key === 'PERISCOPE_CONFIG_DIR' && setting.source === 'unset')
147
+ continue;
148
+ const where = setting.source === 'environment'
149
+ ? setting.shadowsFile
150
+ ? 'environment, shadowing the config file'
151
+ : 'environment'
152
+ : setting.source;
153
+ lines.push(` ${setting.key} = ${setting.value ?? '(unset)'} [${where}]`);
154
+ }
155
+ return lines;
156
+ }
157
+ //# sourceMappingURL=posture.js.map
@@ -0,0 +1,64 @@
1
+ /**
2
+ * A configuration change asked over the wire (`host_configure`, protocol v7): validate, write,
3
+ * rebuild.
4
+ *
5
+ * Split out of the composition root for the reason `workspaces.ts` gives: everything below the
6
+ * composition root takes its configuration as arguments, and this is the one function that
7
+ * reads the config file, screens a candidate posture and builds the pieces the host swaps in. It
8
+ * is called with the RAW environment because the file fills absences in it (`withConfigFallback`),
9
+ * and the same rule that governs `serve` governs a change made while serving: the environment
10
+ * wins per key, so a value this writes that the environment shadows is reported as overridden,
11
+ * never silently inert.
12
+ *
13
+ * Nothing is written until the whole candidate posture has passed the same screen start-up runs,
14
+ * so a half-applied set never lands on disk; and a change to a workspace root is refused while the
15
+ * host is busy, because a session releases through the provider that provisioned it.
16
+ */
17
+ import type { HostConfigureEntry } from '../control/frames.js';
18
+ import type { Result } from '../core/result.js';
19
+ import type { HostReconfigured } from '../host/host.js';
20
+ /** The effective posture a merged environment describes, with absence as null. */
21
+ interface EffectivePosture {
22
+ readonly workspaceRoot: string | null;
23
+ readonly repositoryRoot: string | null;
24
+ readonly branchScheme: string | null;
25
+ readonly workspaceKey: string | null;
26
+ readonly agentHome: string | null;
27
+ readonly controllerUrl: string | null;
28
+ readonly decisionUrl: string | null;
29
+ }
30
+ /**
31
+ * The control-plane addresses the running host actually uses: what it dialled at start. A file
32
+ * value that differs is written and reported as pending, never applied to the live link.
33
+ */
34
+ export interface LiveAddresses {
35
+ readonly controllerUrl: string | null;
36
+ readonly decisionUrl: string | null;
37
+ }
38
+ /**
39
+ * Why a control-plane address cannot be written, or null. The link is dialled as a WebSocket and
40
+ * the decision endpoint is POSTed to, so each key takes the schemes its act can use and nothing
41
+ * else: a value that fits the other key would fail at the first act after the restart it waits for.
42
+ */
43
+ /**
44
+ * The first thing wrong with ONE value for ONE key, or null: the per-key half of `candidateProblem`,
45
+ * shared with `periscope config` and the daemon's start-up read so a value the wire would refuse is
46
+ * refused wherever it is typed. A key this screen does not know passes; the closed key set is
47
+ * checked by the writer.
48
+ */
49
+ export declare function configValueProblem(key: string, value: string): string | null;
50
+ /** The wire-settable keys the environment sets: their file values are unreachable. */
51
+ export declare function overriddenByEnvironment(raw: NodeJS.ProcessEnv): readonly string[];
52
+ /**
53
+ * The first thing wrong with a candidate posture, or null. The same screen start-up runs
54
+ * (`workspacePostureProblem`) plus the rules the wire needs that start-up gets from the shell: a
55
+ * root must be absolute, and a scheme must render distinct branches (contain `{key}`).
56
+ */
57
+ export declare function candidateProblem(posture: EffectivePosture): string | null;
58
+ /**
59
+ * Apply the entries: screen every key, screen the candidate posture as a whole, refuse a roots
60
+ * change while busy, write the file once, and rebuild what the host swaps in.
61
+ */
62
+ export declare function reconfigureHost(raw: NodeJS.ProcessEnv, entries: readonly HostConfigureEntry[], hostBusy: boolean, live?: LiveAddresses): Result<HostReconfigured>;
63
+ export {};
64
+ //# sourceMappingURL=reconfigure.d.ts.map