@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,149 @@
1
+ /**
2
+ * `periscope login` — acquire a token interactively and write it to the cache the daemon reads.
3
+ *
4
+ * Why this is a command rather than something the daemon does: `bin/serve.ts` presents a
5
+ * token that is already there; there is no interactive flow in the shipped daemon, deliberately,
6
+ * because a host meant to run unattended must not block on a browser at start-up. So the
7
+ * interactive half is a separate act, run once by a person, and the daemon is unchanged by this
8
+ * file existing.
9
+ *
10
+ * It writes the same cache the daemon reads, derived from the same call. `tokenCachePath(env)` is
11
+ * the one source for the location, and `credentialPaths(env)`, which the gate uses to build its
12
+ * protected set, derives from it too. Nothing here may compute a path of its own, or the thing the
13
+ * gate refuses to let an agent read and the thing this writes could drift apart.
14
+ *
15
+ * The flow is derived from config, never chosen here. `protocolFor(config)` picks loopback or
16
+ * device-code, and the daemon's credential calls the same function to decide what it expects, so a
17
+ * cache written under one protocol can never be read under the other. (`TokenCredential` discards a
18
+ * cache whose protocol does not match, so getting this wrong would look like "signing in did
19
+ * nothing".) Setting `PERISCOPE_IDENTITY_DEVICE_CODE=1` moves both ends together, which is the
20
+ * property that makes it safe to expose as one environment variable.
21
+ */
22
+ import type { Result } from '../core/result.js';
23
+ import { ok, refuse } from '../core/result.js';
24
+ import type { CachedTokens, TokenStore } from '../identity/index.js';
25
+ import { isAuthFlowBlocked, readIdentityConfig } from '../identity/index.js';
26
+ import type { DeviceCodeInstruction } from '../host/index.js';
27
+ import {
28
+ FilePairedCredential,
29
+ FileTokenCache,
30
+ pairedCredentialPath,
31
+ protocolFor,
32
+ signIn,
33
+ signInWithDeviceCode,
34
+ tokenCachePath,
35
+ } from '../host/index.js';
36
+
37
+ /** The edges, injected so the whole command is testable without a browser, a socket or a clock. */
38
+ export interface LoginDeps {
39
+ /** Where operator-facing instructions go. Defaults to stdout. */
40
+ readonly write?: (line: string) => void;
41
+ /** Replaced in tests; the real ones talk to the provider. */
42
+ readonly signIn?: typeof signIn;
43
+ readonly signInWithDeviceCode?: typeof signInWithDeviceCode;
44
+ /** Replaced in tests so nothing touches the real cache path. Defaults to the file cache at `path`. */
45
+ readonly store?: (path: string) => TokenStore;
46
+ /**
47
+ * Whether a paired credential is present at `path`. Replaced in tests. The daemon prefers a
48
+ * paired credential over the token this verb writes, so a signed-in operator on a paired box is
49
+ * told the token will not be the one presented.
50
+ */
51
+ readonly pairedCredentialPresent?: (path: string) => boolean;
52
+ }
53
+
54
+ /**
55
+ * Run the login command.
56
+ *
57
+ * Returns a `Result` rather than exiting, so the caller owns the process and this stays callable
58
+ * from a test. Every failure is a named refusal, the same posture the rest of the package holds.
59
+ */
60
+ export async function runLogin(env: NodeJS.ProcessEnv, deps: LoginDeps = {}): Promise<Result<CachedTokens>> {
61
+ const write = deps.write ?? ((line: string) => process.stdout.write(`${line}\n`));
62
+ // Said once, on success: the daemon prefers a paired credential over the token this verb writes,
63
+ // so an operator signing in on a paired box is told the token will not be the one presented.
64
+ const pairedPath = pairedCredentialPath(env);
65
+ const pairedPresent =
66
+ deps.pairedCredentialPresent ?? ((path: string) => new FilePairedCredential(path).read().ok);
67
+ const pairedNotice =
68
+ pairedPath !== null && pairedPresent(pairedPath)
69
+ ? `note: this machine holds a paired credential at ${pairedPath}, and the daemon presents that on ` +
70
+ 'every dial in preference to this token'
71
+ : null;
72
+
73
+ // The config module's own read and its own refusal, restated nowhere.
74
+ //
75
+ // Two reasons, and the second is enforced. (1) "Configured" must mean the identical thing here and
76
+ // in the daemon; an operator who can sign in but whose host then refuses to start is the worst
77
+ // outcome available. (2) `pins/identity-generic.test.ts` asserts that exactly one shipped file
78
+ // names the authority environment variable, because a second mention is a second answer to "which
79
+ // provider is this host talking to". Naming those variables here, even in a comment or a message
80
+ // string, since the pin reads source text, trips it, and it is right to: the fix is to carry the
81
+ // one refusal that already exists rather than write a second copy that can drift.
82
+ //
83
+ // Not a default, a refusal. There is no fallback issuer and no implicit tenant: signing in
84
+ // "somewhere" would mint a token this controller cannot validate, and the failure would surface
85
+ // much later as a 401 on the upgrade with nothing naming the cause.
86
+ const read = readIdentityConfig(env);
87
+ if (!read.ok) return refuse(read.refusal.reason, read.refusal.detail);
88
+
89
+ const config = read.value;
90
+
91
+ const cachePath = tokenCachePath(env);
92
+ if (cachePath === null) {
93
+ return refuse(
94
+ 'identity-config-invalid',
95
+ 'identity is configured but there is nowhere to keep the token cache — set PERISCOPE_CONFIG_DIR',
96
+ );
97
+ }
98
+
99
+ const store = (deps.store ?? ((path: string) => new FileTokenCache(path)))(cachePath);
100
+ const protocol = protocolFor(config);
101
+
102
+ if (protocol === 'device-code') {
103
+ // The headless flow, and it must stay reachable without a redirect port. This is the flow for
104
+ // a box with no browser, such as a server or a container. It takes no listener and binds
105
+ // nothing.
106
+ const run = deps.signInWithDeviceCode ?? signInWithDeviceCode;
107
+ const result = await run(config, store, (instruction: DeviceCodeInstruction) => {
108
+ write(`open ${instruction.verificationUri} and enter the code: ${instruction.userCode}`);
109
+ });
110
+ return finish(result, cachePath, write, pairedNotice);
111
+ }
112
+
113
+ // Loopback + PKCE: the primary flow. `signIn` binds an ephemeral port and closes it on every path;
114
+ // its default `present` prints the URL, which is what works over SSH.
115
+ const run = deps.signIn ?? signIn;
116
+ const result = await run(config, store, {
117
+ present: (url: string) => write(`open this to sign in:\n${url}`),
118
+ });
119
+ return finish(result, cachePath, write, pairedNotice);
120
+ }
121
+
122
+ /**
123
+ * Report the outcome once, in the operator's words.
124
+ *
125
+ * A policy-blocked flow is surfaced with its do-not-retry rule attached. A provider policy blocking
126
+ * the device-code flow refuses identically every time, so an operator who reads a bare failure will
127
+ * re-run it, and the second attempt costs the same and fails the same way.
128
+ */
129
+ function finish(
130
+ result: Result<CachedTokens>,
131
+ cachePath: string,
132
+ write: (line: string) => void,
133
+ pairedNotice: string | null,
134
+ ): Result<CachedTokens> {
135
+ if (!result.ok) {
136
+ write(`sign-in failed: ${result.refusal.reason} — ${result.refusal.detail}`);
137
+ if (isAuthFlowBlocked(result.refusal.detail)) {
138
+ write(
139
+ 'do not re-run the device code flow: the same policy will block it, every time. ' +
140
+ 'Use the loopback flow (unset PERISCOPE_IDENTITY_DEVICE_CODE) or have the policy changed.',
141
+ );
142
+ }
143
+ return result;
144
+ }
145
+
146
+ write(`signed in; token cache written to ${cachePath}`);
147
+ if (pairedNotice !== null) write(pairedNotice);
148
+ return ok(result.value);
149
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * The composition root, and the only place this package reads the environment.
3
+ *
4
+ * Everything below takes its configuration as arguments. That is what makes the rest of the
5
+ * package testable without a process, and it is why "where does this value come from?" has one
6
+ * answer instead of one per module. `main` itself takes the environment and the process edges as
7
+ * arguments for the same reason; `bin/periscope.ts` is the one line that hands it the real ones.
8
+ */
9
+ import { readConfigFile, withConfigFallback } from '../host/config-file.js';
10
+ import { packageVersion } from '../host/package-facts.js';
11
+ import { USAGE, readCommand } from './command.js';
12
+ import { runConfig } from './config.js';
13
+ import { runLogin } from './login.js';
14
+ import { runPair } from './pair.js';
15
+ import type { ServeOutcome, ServeViews } from './serve.js';
16
+ import { runServe } from './serve.js';
17
+ import { runStatus } from './status.js';
18
+
19
+ /** The process edges. `processIo()` is the real set; a test supplies recording ones. */
20
+ export interface Io {
21
+ /** One stdout line, without its newline. */
22
+ readonly stdout: (line: string) => void;
23
+ /** One stderr line, without its newline. */
24
+ readonly stderr: (line: string) => void;
25
+ readonly setExitCode: (code: number) => void;
26
+ /** The verbs, replaceable so the dispatch is testable without a provider, a controller or a host. */
27
+ readonly runLogin?: typeof runLogin;
28
+ readonly runPair?: typeof runPair;
29
+ readonly runConfig?: typeof runConfig;
30
+ readonly serve?: (views: ServeViews) => ServeOutcome;
31
+ readonly runStatus?: typeof runStatus;
32
+ /** The package version, replaceable so the dispatch test does not read the manifest. */
33
+ readonly version?: () => string;
34
+ }
35
+
36
+ /** The real process edges: stdout, stderr and the exit code. The verbs take their defaults. */
37
+ export function processIo(): Io {
38
+ return {
39
+ stdout: (line) => {
40
+ process.stdout.write(`${line}\n`);
41
+ },
42
+ stderr: (line) => {
43
+ process.stderr.write(`${line}\n`);
44
+ },
45
+ setExitCode: (code) => {
46
+ process.exitCode = code;
47
+ },
48
+ };
49
+ }
50
+
51
+ /**
52
+ * The config file fills absences, and only absences, for every verb that consumes configuration
53
+ * (`serve`, `login`, `pair`), which is what makes USAGE's unconditional env-first-then-file
54
+ * sentence true. The merged view is consulted for the closed key set only, so a deployment that
55
+ * sets env vars is configured by them alone, and nothing outside that set (identity configuration,
56
+ * the config dir itself) can arrive from a file. A file that exists and cannot be used is fatal
57
+ * rather than silently empty: a corrupt file must not impersonate a deliberate absence (the paired
58
+ * credential takes the same posture, for the same reason).
59
+ *
60
+ * `pair` belongs on that list: `PERISCOPE_DECISION_URL` is a legal config key and is exactly what
61
+ * `redemptionUrl` derives the redemption door from, so a config-file-only machine handed raw env
62
+ * would answer "nowhere to redeem the code" at the first step of the very flow the config verb was
63
+ * built for. `login` reads no config key today; it rides the merged view so the help text's claim
64
+ * is true by wiring rather than by coincidence.
65
+ */
66
+ function environmentWithConfigFile(raw: NodeJS.ProcessEnv): NodeJS.ProcessEnv | string {
67
+ const fileConfig = readConfigFile(raw);
68
+ if (fileConfig.problem !== null) return fileConfig.problem;
69
+ return withConfigFallback(raw, fileConfig.values);
70
+ }
71
+
72
+ /** `error` as one line, for a verb that threw instead of returning. */
73
+ function describe(error: unknown): string {
74
+ return error instanceof Error ? error.message : String(error);
75
+ }
76
+
77
+ /**
78
+ * The command dispatch.
79
+ *
80
+ * `serve` runs synchronously, which is the path every supervisor takes. `login` and `pair` are the
81
+ * asynchronous verbs; each returns its own promise so a caller can await the outcome, and nothing
82
+ * about the daemon's start-up ordering depends on them. `raw` is the process environment as
83
+ * received; the merged view, where the config file fills absences, is derived per verb.
84
+ */
85
+ export function main(argv: readonly string[], raw: NodeJS.ProcessEnv, io: Io): Promise<void> | void {
86
+ const command = readCommand(argv);
87
+
88
+ if (command.kind === 'help') {
89
+ io.stdout(USAGE);
90
+ return;
91
+ }
92
+
93
+ if (command.kind === 'version') {
94
+ io.stdout((io.version ?? packageVersion)());
95
+ return;
96
+ }
97
+
98
+ if (command.kind === 'unknown') {
99
+ // Named rather than defaulted to `serve` (see `readCommand`): a typo that silently starts a host
100
+ // is discovered much later, as an empty token cache.
101
+ io.stderr(`periscope: unknown command '${command.name}'\n\n${USAGE}`);
102
+ io.setExitCode(1);
103
+ return;
104
+ }
105
+
106
+ if (command.kind === 'login') {
107
+ const env = environmentWithConfigFile(raw);
108
+ if (typeof env === 'string') {
109
+ io.stderr(`periscope: ${env}`);
110
+ io.setExitCode(1);
111
+ return;
112
+ }
113
+ // The interactive half is a separate act by design; the daemon never signs anyone in.
114
+ return (io.runLogin ?? runLogin)(env).then(
115
+ (result) => {
116
+ if (!result.ok) {
117
+ io.stderr(`periscope: login failed - ${result.refusal.reason} - ${result.refusal.detail}`);
118
+ io.setExitCode(1);
119
+ }
120
+ },
121
+ // `runLogin` returns refusals rather than throwing, so this branch is for the failure it did
122
+ // not model. Without it a throw here would be an unhandled rejection followed by exit 0: a
123
+ // sign-in that crashed reporting success to whoever scripted it.
124
+ (error: unknown) => {
125
+ io.stderr(`periscope: login failed - ${describe(error)}`);
126
+ io.setExitCode(1);
127
+ },
128
+ );
129
+ }
130
+
131
+ if (command.kind === 'status') {
132
+ const outcome = (io.runStatus ?? runStatus)({ raw, merged: environmentWithConfigFile(raw) });
133
+ const sink = outcome.ok ? io.stdout : io.stderr;
134
+ for (const line of outcome.lines) sink(outcome.ok ? line : `periscope: ${line}`);
135
+ if (!outcome.ok) io.setExitCode(1);
136
+ return;
137
+ }
138
+ if (command.kind === 'config') {
139
+ // Synchronous by nature: one file read or one file write, no network, no process.
140
+ //
141
+ // Raw env, deliberately. This is the one verb that must never see the merged view, twice over:
142
+ // its "currently overridden by the environment" marker exists to distinguish env from file,
143
+ // and a merged env would mark every file value as overridden by itself; and this verb is the
144
+ // escape hatch that makes fatal-on-corrupt safe everywhere else. A corrupt file must still
145
+ // leave `periscope config` able to say which file is broken and why, rather than refusing
146
+ // before the verb runs.
147
+ const outcome = (io.runConfig ?? runConfig)(command.key, command.value, raw, command.unset);
148
+ const sink = outcome.ok ? io.stdout : io.stderr;
149
+ for (const line of outcome.lines) sink(line);
150
+ if (!outcome.ok) io.setExitCode(1);
151
+ return;
152
+ }
153
+
154
+ if (command.kind === 'pair') {
155
+ if (command.problem !== null) {
156
+ io.stderr(
157
+ `periscope: ${command.problem} - usage: periscope pair <code> [--controller <origin>] [--label <name>]`,
158
+ );
159
+ io.setExitCode(1);
160
+ return;
161
+ }
162
+ const env = environmentWithConfigFile(raw);
163
+ if (typeof env === 'string') {
164
+ io.stderr(`periscope: ${env}`);
165
+ io.setExitCode(1);
166
+ return;
167
+ }
168
+ // The same shape as `login`, for the same reasons, including the rejection branch, because a
169
+ // pairing that crashed must not report success to whoever scripted it.
170
+ return (io.runPair ?? runPair)(command.code, env, undefined, {
171
+ controller: command.controller,
172
+ label: command.label,
173
+ }).then(
174
+ (result) => {
175
+ if (!result.ok) {
176
+ io.stderr(`periscope: pair failed - ${result.detail}`);
177
+ io.setExitCode(1);
178
+ }
179
+ },
180
+ (error: unknown) => {
181
+ io.stderr(`periscope: pair failed - ${describe(error)}`);
182
+ io.setExitCode(1);
183
+ },
184
+ );
185
+ }
186
+
187
+ // The daemon reads two views: configuration from the merged one, credentials and the session
188
+ // environment from the raw one. `runServe` says which reads which, and reports its own refusals.
189
+ const serve = io.serve ?? ((views: ServeViews) => runServe(views, processServeDeps(io)));
190
+ serve({ raw, merged: environmentWithConfigFile(raw) });
191
+ }
192
+
193
+ /** The daemon's edges over a caller-supplied `io`, with the process itself behind exit and signals. */
194
+ function processServeDeps(io: Io): Parameters<typeof runServe>[1] {
195
+ return {
196
+ log: io.stdout,
197
+ stderr: io.stderr,
198
+ setExitCode: io.setExitCode,
199
+ exit: (code) => process.exit(code),
200
+ onSignal: (signal, handler) => {
201
+ process.on(signal, handler);
202
+ },
203
+ };
204
+ }
@@ -0,0 +1,246 @@
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
+ import type { PairedCredentialStore } from '../identity/paired-credential.js';
32
+
33
+ const REDEMPTION_ROUTE = '/api/periscope/pair';
34
+
35
+ /** The two flags: null means "not given", and the environment path applies. */
36
+ export interface PairAsk {
37
+ readonly controller?: string | null;
38
+ readonly label?: string | null;
39
+ }
40
+
41
+ /** The verb's outcome: plain, CLI-local, and deliberately not the wire's refusal vocabulary.
42
+ * Nothing here crosses the link, so nothing here may widen it. */
43
+ export type PairOutcome =
44
+ | { readonly ok: true; readonly hostId: string; readonly path: string }
45
+ | { readonly ok: false; readonly detail: string };
46
+
47
+ /** The edges, injected so the whole verb is testable without a controller or a disk. */
48
+ export interface PairDeps {
49
+ /** Where operator-facing lines go. Defaults to stdout. */
50
+ readonly write?: (line: string) => void;
51
+ /** The HTTP edge. Defaults to global fetch. */
52
+ readonly transport?: typeof fetch;
53
+ /** Replaced in tests so nothing touches the real credential path. */
54
+ readonly store?: (path: string) => PairedCredentialStore;
55
+ }
56
+
57
+ /** Run the pair command. Returns rather than exits, so the caller owns the process. */
58
+ export async function runPair(
59
+ code: string | null,
60
+ env: NodeJS.ProcessEnv,
61
+ deps: PairDeps = {},
62
+ asked: PairAsk = {},
63
+ ): Promise<PairOutcome> {
64
+ const write = deps.write ?? ((line: string) => process.stdout.write(`${line}\n`));
65
+
66
+ if (code === null) {
67
+ return {
68
+ ok: false,
69
+ detail: 'no code given - usage: periscope pair <code> [--controller <origin>] [--label <name>]',
70
+ };
71
+ }
72
+
73
+ const url = redemptionUrl(env, asked.controller ?? null);
74
+ if (url === null) {
75
+ return {
76
+ ok: false,
77
+ detail:
78
+ asked.controller === undefined || asked.controller === null
79
+ ? 'nowhere to redeem the code - pass --controller <origin>, or set PERISCOPE_PAIR_URL or PERISCOPE_DECISION_URL (whose origin names the controller)'
80
+ : `--controller '${asked.controller}' is not an http(s) URL`,
81
+ };
82
+ }
83
+
84
+ const path = pairedCredentialPath(env);
85
+ if (path === null) {
86
+ return {
87
+ ok: false,
88
+ detail:
89
+ 'nowhere to keep the credential - set PERISCOPE_CONFIG_DIR or run as a user with a home directory',
90
+ };
91
+ }
92
+
93
+ // The label the operator will recognize this machine by in every listing. Overridable because a
94
+ // hostname like `DESKTOP-4F7Q2` recognizes nobody.
95
+ const machineLabel = asked.label ?? env['PERISCOPE_MACHINE_LABEL'] ?? readMachineFacts().hostname;
96
+
97
+ const transport = deps.transport ?? fetch;
98
+ let response: Response;
99
+ try {
100
+ response = await transport(url, {
101
+ method: 'POST',
102
+ headers: { 'content-type': 'application/json' },
103
+ body: JSON.stringify({ code, machineLabel }),
104
+ });
105
+ } catch (error) {
106
+ // The cause, not the wrapper: `fetch failed` over a refused certificate is not "unreachable", and
107
+ // a reader told the controller is down will not look at the certificate.
108
+ const why = describeFailure(error);
109
+ return {
110
+ ok: false,
111
+ detail: isCertificateRefusal(error)
112
+ ? `the controller at ${url} presented a certificate Node refused (${why}). ${certificateRemedy('the controller')}`
113
+ : `the controller at ${url} could not be reached - ${why}`,
114
+ };
115
+ }
116
+
117
+ if (response.status === 404 || response.status === 405) {
118
+ // Nothing at this route is a wrong door, not a refused code: the origin is not a controller,
119
+ // or its pair route is elsewhere. Saying "mint a fresh code" here sends the reader to the
120
+ // wrong fix.
121
+ return {
122
+ ok: false,
123
+ detail:
124
+ `nothing answers the pair route at ${url} (HTTP ${response.status}). Check --controller (an ` +
125
+ 'http(s) origin) or PERISCOPE_PAIR_URL; a controller redeems codes at its own pair route.',
126
+ };
127
+ }
128
+ if (!response.ok) {
129
+ // The door's one refusal is deliberate (unknown, expired and consumed answer identically);
130
+ // the honest instruction is therefore always the same: mint a fresh code.
131
+ const body = await response.text().catch(() => '');
132
+ return {
133
+ ok: false,
134
+ detail:
135
+ `the controller refused this code (HTTP ${response.status}${body === '' ? '' : ` - ${body}`}). ` +
136
+ 'Codes are single-use and expire in minutes; mint a fresh one and try again.',
137
+ };
138
+ }
139
+
140
+ let parsed: unknown;
141
+ try {
142
+ parsed = await response.json();
143
+ } catch {
144
+ return {
145
+ ok: false,
146
+ detail: 'the controller answered success with an unreadable body - nothing was written',
147
+ };
148
+ }
149
+
150
+ const record = (typeof parsed === 'object' && parsed !== null ? parsed : {}) as Record<string, unknown>;
151
+ const hostId = record['hostId'];
152
+ const credential = record['hostCredential'];
153
+ if (typeof hostId !== 'string' || hostId === '' || typeof credential !== 'string' || credential === '') {
154
+ return {
155
+ ok: false,
156
+ detail: 'the controller answered success without a credential - nothing was written',
157
+ };
158
+ }
159
+ // The shape is the contract: `serve` reads the host id out of the credential and refuses a file
160
+ // whose two copies disagree, so a credential that would be refused at the next start is refused
161
+ // here, where the fix (the controller's answer) is nameable.
162
+ if (!credential.startsWith(`p1.${hostId}.`) || credential.length <= `p1.${hostId}.`.length) {
163
+ return {
164
+ ok: false,
165
+ detail:
166
+ `the controller answered a credential that does not have the shape p1.<hostId>.<secret> for ` +
167
+ `host ${hostId} - nothing was written. A paired credential embeds the host id it speaks for.`,
168
+ };
169
+ }
170
+
171
+ const store = (deps.store ?? ((p: string) => new FilePairedCredential(p)))(path);
172
+ const written = store.write({ hostId, credential });
173
+ if (!written.ok) {
174
+ return { ok: false, detail: `${written.refusal.reason} - ${written.refusal.detail}` };
175
+ }
176
+
177
+ write(`paired as ${hostId}; credential written to ${path}`);
178
+ write('this host now dials with the paired credential - the sign-in token expiry no longer applies to it');
179
+
180
+ // The controller names where this host should dial. Written after the credential, never instead
181
+ // of it: the credential is the scarce thing, and a config write that fails is reported, not a
182
+ // reason to lose what was just minted.
183
+ const controllerUrl = record['controllerUrl'];
184
+ const decisionUrl = record['decisionUrl'];
185
+ if (
186
+ typeof controllerUrl === 'string' &&
187
+ controllerUrl !== '' &&
188
+ typeof decisionUrl === 'string' &&
189
+ decisionUrl !== ''
190
+ ) {
191
+ const problem = writeConfigEntries(env, [
192
+ { key: 'PERISCOPE_CONTROLLER_URL', value: controllerUrl },
193
+ { key: 'PERISCOPE_DECISION_URL', value: decisionUrl },
194
+ ]);
195
+ if (problem !== null) {
196
+ write(`the controller's addresses were not written to the config file - ${problem}`);
197
+ } else {
198
+ write(
199
+ `PERISCOPE_CONTROLLER_URL and PERISCOPE_DECISION_URL written to the config file - serve needs nothing else`,
200
+ );
201
+ for (const key of ['PERISCOPE_CONTROLLER_URL', 'PERISCOPE_DECISION_URL'] as const) {
202
+ const fromEnv = env[key];
203
+ if (fromEnv !== undefined && fromEnv !== '') {
204
+ write(
205
+ `note: ${key} is set in the environment, and the environment wins - the file value is a fallback`,
206
+ );
207
+ }
208
+ }
209
+ }
210
+ } else {
211
+ write(
212
+ 'the controller did not name its link and decision URLs - set PERISCOPE_CONTROLLER_URL and PERISCOPE_DECISION_URL before serve',
213
+ );
214
+ }
215
+ return { ok: true, hostId, path };
216
+ }
217
+
218
+ /**
219
+ * The redemption door's address. `PERISCOPE_PAIR_URL` verbatim when set; otherwise the decision
220
+ * URL's origin + the controller's published route. The decision URL is required configuration for
221
+ * every serving host, so a machine being paired on the box it will serve from has nothing extra to
222
+ * set.
223
+ */
224
+ function redemptionUrl(env: NodeJS.ProcessEnv, controller: string | null): string | null {
225
+ if (controller !== null) {
226
+ try {
227
+ const parsed = new URL(controller);
228
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
229
+ return `${parsed.origin}${REDEMPTION_ROUTE}`;
230
+ } catch {
231
+ return null;
232
+ }
233
+ }
234
+
235
+ const explicit = env['PERISCOPE_PAIR_URL'];
236
+ if (explicit !== undefined && explicit !== '') return explicit;
237
+
238
+ const decision = env['PERISCOPE_DECISION_URL'];
239
+ if (decision === undefined || decision === '') return null;
240
+
241
+ try {
242
+ return `${new URL(decision).origin}${REDEMPTION_ROUTE}`;
243
+ } catch {
244
+ return null;
245
+ }
246
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { main, processIo } from './main.js';
3
+ void main(process.argv.slice(2), process.env, processIo());