@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,92 @@
1
+ /**
2
+ * The privileged module: the only place in this package that imports `node:fs`,
3
+ * `node:fs/promises`, `node:child_process` or `node:os`.
4
+ *
5
+ * That is the package's central claim: a reviewer answers "what can this touch on my machine?" by
6
+ * reading one directory. It is enforced twice — an ESLint rule for editor-time feedback, and a
7
+ * tree-walking pin test that survives the lint config being changed or deleted.
8
+ */
9
+ export type { MachineFacts } from './machine.js';
10
+ export { readMachineFacts } from './machine.js';
11
+
12
+ // The composer: the per-session assembly, and the host that owns a link and a registry. It reaches
13
+ // every other directory in the package, which is exactly what makes it the one place a consumer
14
+ // does not have to reproduce by hand.
15
+ export type {
16
+ BulkResolver,
17
+ ComposeSessionOptions,
18
+ ComposedSession,
19
+ GateTimings,
20
+ HostEvent,
21
+ PeriscopeHostOptions,
22
+ } from './host.js';
23
+ export { PeriscopeHost, composeSession } from './host.js';
24
+
25
+ export type { BulkPostReceipt, BulkPostRequest } from './bulk-post.js';
26
+ export { postBulk } from './bulk-post.js';
27
+
28
+ // The discovery door: read-only, jailed enumeration of the agent CLI's own transcripts. The root
29
+ // derivation ships beside the reader so the composition root derives and the module only reads.
30
+ export type { DiscoveredTranscript, TranscriptPage, TranscriptTailAnswer } from './claude-transcripts.js';
31
+ export { TRANSCRIPT_PAGE_SIZE, TRANSCRIPT_WHAT_PREFIX } from '../control/frames.js';
32
+ export { packageVersion } from './package-facts.js';
33
+ export {
34
+ claudeProjectsRoot,
35
+ defaultAgentHome,
36
+ transcriptsRootUnder,
37
+ claudeTranscriptResolver,
38
+ isMatchingUserEntry,
39
+ listTranscripts,
40
+ resolveTranscriptPath,
41
+ tailTranscript,
42
+ } from './claude-transcripts.js';
43
+
44
+ // The repository read: the same read-only, jailed posture over the repository root.
45
+ export type { RepositoryListing, RepositoryText } from './repository-read.js';
46
+ export {
47
+ BINARY_PROBE_BYTES,
48
+ listRepositoryDirectory,
49
+ readRepositoryFile,
50
+ resolveRepositoryPath,
51
+ } from './repository-read.js';
52
+
53
+ // The two impure inputs the host's own gate takes at construction, plus the derived location of
54
+ // this host's own credential material — `credentialPaths` covers it BECAUSE both come from here.
55
+ export {
56
+ credentialPaths,
57
+ nodePathResolver,
58
+ pairedCredentialPath,
59
+ periscopeCredentialDir,
60
+ tokenCachePath,
61
+ } from './paths.js';
62
+ export type { ConfigFileReading, ConfigKey } from './config-file.js';
63
+ export {
64
+ CONFIG_KEYS,
65
+ configFilePath,
66
+ isConfigKey,
67
+ readConfigFile,
68
+ withConfigFallback,
69
+ writeConfigEntry,
70
+ } from './config-file.js';
71
+
72
+ // The impure halves of identity: the file the token lives in, and the port the callback arrives on.
73
+ export type { TokenCacheWrite } from './token-cache.js';
74
+ export { FileTokenCache, probeModeEnforcement } from './token-cache.js';
75
+
76
+ // The paired credential's file half — same directory, same modes, same verify-after-write.
77
+ export type { PairedCredentialWrite } from './paired-credential-store.js';
78
+ export { FilePairedCredential } from './paired-credential-store.js';
79
+
80
+ export type { DeviceCodeInstruction, SignInDeps } from './sign-in.js';
81
+ export { protocolFor, refresherFor, resolveEndpoints, signIn, signInWithDeviceCode } from './sign-in.js';
82
+
83
+ export type { LoopbackListener } from './loopback.js';
84
+ export { DEFAULT_SIGN_IN_TIMEOUT_MS, LOOPBACK_HOST, openLoopbackListener } from './loopback.js';
85
+
86
+ // The local JSONL mirror's effects, and the bridge that presents a transcript store to the SDK.
87
+ // Both are here for the two reasons this directory exists: one names `node:fs`, the other the SDK.
88
+ export { nodeStoreEffects } from './transcript-fs.js';
89
+ export { asSessionStore, readMirrorDrop, toSessionKey, toTranscriptKey } from './session-store.js';
90
+
91
+ // Cost and rate-limit facts, lifted off the agent's own stream. Nothing here polls anything.
92
+ export { readRateLimit, readTaskSpend, readTurnSpend } from './telemetry.js';
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The link's last transition, kept on disk by `serve` so `periscope status` can answer "is this host
3
+ * linked, and since when" without a socket: one JSON file beside the credentials, replaced whole on
4
+ * every transition (written to a sibling, then renamed, so a reader never sees half a record). The
5
+ * process id rides in it, so a stale record from a host that died can be told from a live one.
6
+ */
7
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+
10
+ import type { LinkCause, LinkState } from '../control/link-state.js';
11
+ import { tokenCachePath } from './paths.js';
12
+
13
+ export interface LinkStateRecord {
14
+ readonly state: LinkState;
15
+ readonly cause: LinkCause;
16
+ readonly at: string;
17
+ readonly detail: string | null;
18
+ /** The version the controller chose at the last accepted handshake; null before one. */
19
+ readonly negotiatedVersion: number | null;
20
+ /** The serve process that wrote the record. */
21
+ readonly pid: number;
22
+ }
23
+
24
+ /** Beside the token cache and the paired credential; null where there is no home and no config dir. */
25
+ export function linkStatePath(env: NodeJS.ProcessEnv): string | null {
26
+ const cache = tokenCachePath(env);
27
+ return cache === null ? null : join(dirname(cache), 'link-state.json');
28
+ }
29
+
30
+ /** Writes the record whole. Returns the problem, or null. Never throws: the link must not depend on the disk. */
31
+ export function writeLinkState(env: NodeJS.ProcessEnv, record: LinkStateRecord): string | null {
32
+ const path = linkStatePath(env);
33
+ if (path === null)
34
+ return 'there is nowhere to keep the link state (no home directory and no PERISCOPE_CONFIG_DIR)';
35
+ try {
36
+ mkdirSync(dirname(path), { recursive: true });
37
+ const sibling = `${path}.${record.pid}.tmp`;
38
+ writeFileSync(sibling, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
39
+ renameSync(sibling, path);
40
+ return null;
41
+ } catch (error) {
42
+ return `could not write ${path}: ${error instanceof Error ? error.message : String(error)}`;
43
+ }
44
+ }
45
+
46
+ /** The record, or null when there is none or it is unreadable: an absent file is a host that never ran. */
47
+ export function readLinkState(env: NodeJS.ProcessEnv): LinkStateRecord | null {
48
+ const path = linkStatePath(env);
49
+ if (path === null) return null;
50
+ let text: string;
51
+ try {
52
+ text = readFileSync(path, 'utf8');
53
+ } catch {
54
+ return null;
55
+ }
56
+ try {
57
+ const parsed = JSON.parse(text) as Partial<LinkStateRecord> | null;
58
+ if (
59
+ parsed === null ||
60
+ typeof parsed !== 'object' ||
61
+ typeof parsed.state !== 'string' ||
62
+ typeof parsed.cause !== 'string' ||
63
+ typeof parsed.at !== 'string' ||
64
+ typeof parsed.pid !== 'number'
65
+ ) {
66
+ return null;
67
+ }
68
+ return {
69
+ state: parsed.state,
70
+ cause: parsed.cause,
71
+ at: parsed.at,
72
+ detail: typeof parsed.detail === 'string' ? parsed.detail : null,
73
+ negotiatedVersion: typeof parsed.negotiatedVersion === 'number' ? parsed.negotiatedVersion : null,
74
+ pid: parsed.pid,
75
+ };
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The loopback redirect listener: a socket open for one callback, on one interface, for a bounded
3
+ * time.
4
+ *
5
+ * Every constraint here is a consequence of one fact: the agent shares this host's OS user. That
6
+ * is the premise the whole identity model rests on; it is why an 0600 token file is not a boundary
7
+ * and why the gate carries a credential-path denial. It also means any local process can connect
8
+ * to this listener, so the listener is a surface facing the exact actor the identity model
9
+ * defends against:
10
+ *
11
+ * - **`127.0.0.1` explicitly, never `0.0.0.0`.** The default binds every interface, which would
12
+ * put an authorization callback endpoint on the network. Nothing off this machine has any
13
+ * business reaching it, and the difference is one argument nobody notices missing.
14
+ * - **Exactly one callback, then closed.** A listener that stays open after answering is a
15
+ * listener something else can still reach, and there is no second callback to wait for.
16
+ * - **It times out.** An abandoned sign-in must not leave a port open indefinitely on a host that
17
+ * runs unattended for weeks.
18
+ * - **The `state` check is the caller's and it is not optional** — see `identity/authorize.ts`.
19
+ * This module returns the query string verbatim; it deliberately does not decide anything about
20
+ * whether the callback belongs to this sign-in.
21
+ */
22
+ import { createServer } from 'node:http';
23
+ import type { IncomingMessage, Server, ServerResponse } from 'node:http';
24
+ import type { AddressInfo } from 'node:net';
25
+
26
+ import type { Result } from '../core/result.js';
27
+ import { ok, refuse } from '../core/result.js';
28
+ import { CALLBACK_PAGE } from '../identity/authorize.js';
29
+ import { redirectUriFor } from '../identity/authorize.js';
30
+
31
+ /** The one interface this listener will ever bind. */
32
+ export const LOOPBACK_HOST = '127.0.0.1';
33
+
34
+ /** Long enough for a real sign-in including a second factor; short enough to not be forever. */
35
+ export const DEFAULT_SIGN_IN_TIMEOUT_MS = 300_000;
36
+
37
+ export interface LoopbackListener {
38
+ readonly port: number;
39
+ readonly redirectUri: string;
40
+ /** Resolves with the callback's raw query string, or a refusal. Settles exactly once. */
41
+ readonly callback: Promise<Result<string>>;
42
+ close(): void;
43
+ }
44
+
45
+ /**
46
+ * Open the listener.
47
+ *
48
+ * `port` 0 asks the OS for a free one, which is the ordinary case — the redirect URI is then built
49
+ * from what it gave back, which is why the provider registration must allow a loopback redirect with
50
+ * any port. (Providers treat `http://127.0.0.1` loopback redirects specially for exactly this
51
+ * reason; a fixed port would collide with whatever else is running.)
52
+ */
53
+ export function openLoopbackListener(
54
+ port = 0,
55
+ timeoutMs = DEFAULT_SIGN_IN_TIMEOUT_MS,
56
+ ): Promise<Result<LoopbackListener>> {
57
+ return openLoopbackListenerWith(createServer, port, timeoutMs);
58
+ }
59
+
60
+ /**
61
+ * The one server shape this module asks for — narrower than `createServer`'s overloads on purpose,
62
+ * so an injected constructor is writable without reproducing them.
63
+ */
64
+ export type LoopbackServerConstructor = (
65
+ handler: (request: IncomingMessage, response: ServerResponse) => void,
66
+ ) => Server;
67
+
68
+ /**
69
+ * The listener over an injected server constructor.
70
+ *
71
+ * Injected for the same reason `jail.ts` takes its resolver injected: the server's failure paths
72
+ * cannot be reached from outside this module — a post-listen `'error'` has no external trigger —
73
+ * and the only way to exercise them at all is a caller that holds the server it built. This is
74
+ * package-internal: it is exported from this file for the suite and deliberately NOT re-exported
75
+ * from either barrel, and the package-shape suite asserts that stays true.
76
+ */
77
+ export function openLoopbackListenerWith(
78
+ createServerImpl: LoopbackServerConstructor,
79
+ port: number,
80
+ timeoutMs: number,
81
+ ): Promise<Result<LoopbackListener>> {
82
+ return new Promise((resolveListener) => {
83
+ let settle: (result: Result<string>) => void = () => {};
84
+ const callback = new Promise<Result<string>>((resolve) => {
85
+ settle = resolve;
86
+ });
87
+
88
+ let settled = false;
89
+ const finish = (result: Result<string>): void => {
90
+ if (settled) return;
91
+ settled = true;
92
+ settle(result);
93
+ close();
94
+ };
95
+
96
+ // Closing settles the wait. A caller that gives up (a shutdown signal, an outer timeout) must
97
+ // not leave whoever is awaiting the callback pending forever. A promise nobody will ever
98
+ // resolve is the shape that turns a cancelled sign-in into a hung host.
99
+ const abandon = (): void =>
100
+ finish(refuse('auth-callback-refused', 'the sign-in was closed before a callback arrived'));
101
+
102
+ const server = createServerImpl((request, response) => {
103
+ const url = new URL(request.url ?? '/', `http://${LOOPBACK_HOST}`);
104
+
105
+ // Anything that is not the redirect path gets a flat 404 and does NOT settle the wait. A
106
+ // stray probe — a browser prefetch, another process scanning ports — must not be able to end
107
+ // a sign-in that is still in progress.
108
+ if (url.pathname !== '/callback') {
109
+ response.writeHead(404, { 'content-type': 'text/plain' });
110
+ response.end('not found');
111
+ return;
112
+ }
113
+
114
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
115
+ response.end(CALLBACK_PAGE);
116
+ finish(ok(url.search.startsWith('?') ? url.search.slice(1) : url.search));
117
+ });
118
+
119
+ const timer = setTimeout(() => {
120
+ finish(
121
+ refuse(
122
+ 'auth-callback-refused',
123
+ `no callback arrived within ${timeoutMs}ms; the sign-in was not completed`,
124
+ ),
125
+ );
126
+ }, timeoutMs);
127
+ // The host must be able to exit while a sign-in is pending rather than being held open by it.
128
+ timer.unref?.();
129
+
130
+ let closed = false;
131
+ const close = (): void => {
132
+ if (closed) return;
133
+ closed = true;
134
+ clearTimeout(timer);
135
+ server.close();
136
+ // Sockets kept alive by a browser would hold the server open past `close()`.
137
+ server.closeAllConnections?.();
138
+ };
139
+
140
+ server.once('error', (error) => {
141
+ // Before `listen` succeeds this settles the OPEN with a refusal. After it, the outer promise
142
+ // is already resolved and that call is a no-op — the wait that must settle then is the
143
+ // CALLBACK's, and `finish` is the one route that settles it, closes the server and clears
144
+ // the deadline in one motion. Skipping `finish` here is what once left `signIn` hanging
145
+ // forever on a callback that could no longer arrive, with the timeout already disarmed.
146
+ resolveListener(
147
+ refuse(
148
+ 'identity-config-invalid',
149
+ `the loopback listener could not bind ${LOOPBACK_HOST}:${port}: ${String(error)}`,
150
+ ),
151
+ );
152
+ finish(
153
+ refuse(
154
+ 'auth-callback-refused',
155
+ `the loopback listener failed (${String(error)}), so no callback can arrive; the sign-in was not completed`,
156
+ ),
157
+ );
158
+ });
159
+
160
+ server.listen(port, LOOPBACK_HOST, () => {
161
+ const address = server.address() as AddressInfo | null;
162
+ if (address === null) {
163
+ close();
164
+ resolveListener(refuse('identity-config-invalid', 'the loopback listener bound no address'));
165
+ return;
166
+ }
167
+ resolveListener(
168
+ ok({
169
+ port: address.port,
170
+ redirectUri: redirectUriFor(address.port),
171
+ callback,
172
+ close: abandon,
173
+ }),
174
+ );
175
+ });
176
+ });
177
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Facts about the machine this host runs on.
3
+ *
4
+ * Inside `src/host/` because it reaches `node:os`. Everything outward takes these as plain values,
5
+ * which is what lets the rest of the package run in a test, a browser, or a controller unchanged.
6
+ */
7
+ import { homedir, hostname, platform, release, tmpdir } from 'node:os';
8
+
9
+ export interface MachineFacts {
10
+ readonly hostname: string;
11
+ readonly platform: NodeJS.Platform;
12
+ readonly release: string;
13
+ readonly homeDir: string;
14
+ readonly tempDir: string;
15
+ }
16
+
17
+ export function readMachineFacts(): MachineFacts {
18
+ return {
19
+ hostname: hostname(),
20
+ platform: platform(),
21
+ release: release(),
22
+ // On Windows this resolves the USERPROFILE family, not HOME. Code keyed on HOME alone finds
23
+ // nothing there and fails without saying so.
24
+ homeDir: homedir(),
25
+ tempDir: tmpdir(),
26
+ };
27
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The one place a tool plan becomes an SDK server.
3
+ *
4
+ * It lives here for the import, not for the danger. `createSdkMcpServer` and `tool()` build
5
+ * in-process objects and start nothing, but the boundary rule is that one directory names
6
+ * `@anthropic-ai/claude-agent-sdk`, so a reader answering "what can start a process here?" reads
7
+ * `src/host/` and nowhere else. Carving out an exception for the safe calls in that package is
8
+ * how a boundary stops meaning anything, so the exception is not taken. All the decisions are in
9
+ * `mcp/server.ts`; this file adds the SDK call and nothing else.
10
+ *
11
+ * In-process removes machinery rather than replacing it: no `.mcp.json` written into the
12
+ * workspace, no stdio child to spawn, no attach receipt to watch for, no port, no handshake.
13
+ */
14
+ import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
15
+ import type { McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent-sdk';
16
+
17
+ import type { Result } from '../core/result.js';
18
+ import { ok, refuse } from '../core/result.js';
19
+ import type { McpServerOptions, ToolPlan } from '../mcp/server.js';
20
+ import { planTools } from '../mcp/server.js';
21
+
22
+ /**
23
+ * One tool definition, named through the SDK's own signature rather than by hand.
24
+ *
25
+ * `CreateSdkMcpServerOptions` is a `declare type` and not exported, so a consumer cannot name the
26
+ * argument `createSdkMcpServer` takes. Deriving it from the function's parameter is how this file
27
+ * stays exactly as wide as the SDK is: if the accepted shape changes, this breaks at build time
28
+ * rather than after a cast quietly absorbed the difference.
29
+ */
30
+ type ToolDefinition = NonNullable<Parameters<typeof createSdkMcpServer>[0]['tools']>[number];
31
+
32
+ /**
33
+ * Build the in-process server from a controller's descriptors.
34
+ *
35
+ * Refuses rather than throwing — registration happens before any session exists, and a controller
36
+ * that sent an unusable descriptor needs to be told which one and why.
37
+ */
38
+ export function createToolServer(options: McpServerOptions): Result<McpSdkServerConfigWithInstance> {
39
+ const planned = planTools(options);
40
+ if (!planned.ok)
41
+ return refuse<McpSdkServerConfigWithInstance>(planned.refusal.reason, planned.refusal.detail);
42
+
43
+ return ok(
44
+ createSdkMcpServer({
45
+ name: options.name,
46
+ version: options.version ?? '0.0.0',
47
+ tools: planned.value.map(toDefinition),
48
+ }),
49
+ );
50
+ }
51
+
52
+ /**
53
+ * A plan, as the SDK's own tool definition. The whole of this module's added value.
54
+ *
55
+ * The result is re-built rather than passed through; that is the adaptation this seam exists for.
56
+ * The SDK's result type carries an index signature (`[x: string]: unknown`), which a declared
57
+ * interface cannot satisfy — so `mcp/server.ts` states the shape it means in its own terms and the
58
+ * translation happens here, in the one file allowed to know both. A cast would have compiled and
59
+ * would have hidden any later divergence between the two shapes.
60
+ */
61
+ function toDefinition(plan: ToolPlan): ToolDefinition {
62
+ return tool(plan.name, plan.description, plan.shape, async (args: unknown) => {
63
+ const result = await plan.handler(args);
64
+ return { content: result.content, ...(result.isError === true ? { isError: true } : {}) };
65
+ });
66
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The package's own version, read from the manifest that ships beside `dist/`.
3
+ *
4
+ * Read here rather than baked in at build: `package.json` is the one place the version is stated,
5
+ * and a constant copied into source would be a second place that can drift. The read is lazy and
6
+ * memoised, and a manifest that cannot be read reports `unknown` rather than failing a verb that
7
+ * only wanted to print a line.
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ let cached: string | null = null;
13
+
14
+ /** `<name>@<version>` of the running package, or `unknown` when the manifest cannot be read. */
15
+ export function packageVersion(): string {
16
+ if (cached !== null) return cached;
17
+ try {
18
+ const manifestPath = fileURLToPath(new URL('../../package.json', import.meta.url));
19
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf8')) as { version?: unknown };
20
+ cached = typeof parsed.version === 'string' && parsed.version !== '' ? parsed.version : 'unknown';
21
+ } catch {
22
+ cached = 'unknown';
23
+ }
24
+ return cached;
25
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The file-backed paired-credential store: `token-cache.ts`'s discipline, applied to the second
3
+ * credential this package persists.
4
+ *
5
+ * Same directory, same modes, same verify-after-write, and none of it restated by hand. The file
6
+ * lives beside the token cache under `periscopeCredentialDir`, which is what puts it inside the
7
+ * gate's protected set for free: `credentialPaths` names the directory, precisely so anything the
8
+ * host later keeps beside the token cache is protected by having been put there.
9
+ *
10
+ * What 0600 is and is not: see `token-cache.ts`'s header. The agent runs as the same OS user;
11
+ * what keeps the AGENT away from this file is the gate's credential-path denial, and the
12
+ * composition test in `identity/paired-credential.test.ts` proves the containment.
13
+ */
14
+ import { chmodSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
15
+
16
+ import type { Result } from '../core/result.js';
17
+ import { ok, refuse } from '../core/result.js';
18
+ import type { CredentialModeOutcome, ModeEnforcement } from '../identity/mode.js';
19
+ import { CREDENTIAL_MODE, classifyCredentialMode } from '../identity/mode.js';
20
+ import type { PairedCredentialFile, PairedCredentialStore } from '../identity/paired-credential.js';
21
+ import { readPairedCredentialFile } from '../identity/paired-credential.js';
22
+ import { probeModeEnforcement } from './token-cache.js';
23
+
24
+ /** Directories holding credential material are owner-only too — `token-cache.ts`'s constant. */
25
+ const CREDENTIAL_DIR_MODE = 0o700;
26
+
27
+ export interface PairedCredentialWrite {
28
+ readonly path: string;
29
+ readonly mode: CredentialModeOutcome;
30
+ }
31
+
32
+ export class FilePairedCredential implements PairedCredentialStore {
33
+ readonly #path: string;
34
+ readonly #directory: string;
35
+ #enforcement: ModeEnforcement | null = null;
36
+
37
+ constructor(path: string) {
38
+ this.#path = path;
39
+ const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
40
+ this.#directory = cut > 0 ? path.slice(0, cut) : path;
41
+ }
42
+
43
+ get path(): string {
44
+ return this.#path;
45
+ }
46
+
47
+ /** Memoised: the filesystem does not change its mind mid-process. */
48
+ modeEnforcement(): ModeEnforcement {
49
+ if (this.#enforcement === null) {
50
+ try {
51
+ mkdirSync(this.#directory, { recursive: true, mode: CREDENTIAL_DIR_MODE });
52
+ } catch {
53
+ // The write path reports the real failure; a probe must not be the thing that raises it.
54
+ }
55
+ this.#enforcement = probeModeEnforcement(this.#directory);
56
+ }
57
+ return this.#enforcement;
58
+ }
59
+
60
+ /**
61
+ * Read the paired credential.
62
+ *
63
+ * A missing file is `token-unavailable` — this machine has never been paired, the NORMAL state
64
+ * every unpaired host is in — and that is deliberately not the same reason as a file that exists
65
+ * and cannot be parsed. The composition root falls through to the OIDC posture on the first and
66
+ * refuses to start on the second: a corrupt paired credential silently degrading to a maybe-dead
67
+ * refresh token would put the host in exactly the ambiguous posture pairing exists to end.
68
+ */
69
+ read(): Result<PairedCredentialFile> {
70
+ let raw: string;
71
+ try {
72
+ raw = readFileSync(this.#path, 'utf8');
73
+ } catch {
74
+ return refuse('token-unavailable', 'this machine holds no paired credential');
75
+ }
76
+
77
+ let parsed: unknown;
78
+ try {
79
+ parsed = JSON.parse(raw);
80
+ } catch {
81
+ return refuse(
82
+ 'credential-cache-unreadable',
83
+ `the paired credential at ${this.#path} exists and is not valid JSON; remove it or run: periscope pair <code>`,
84
+ );
85
+ }
86
+
87
+ return readPairedCredentialFile(parsed);
88
+ }
89
+
90
+ /**
91
+ * Write the credential, then verify what actually landed — a file that came out wider than asked
92
+ * is removed, not left and reported (`token-cache.ts`'s rule, same words, same reason).
93
+ */
94
+ write(file: PairedCredentialFile): Result<PairedCredentialWrite> {
95
+ const enforcement = this.modeEnforcement();
96
+
97
+ try {
98
+ mkdirSync(this.#directory, { recursive: true, mode: CREDENTIAL_DIR_MODE });
99
+ writeFileSync(this.#path, `${JSON.stringify(file, null, 2)}\n`, { mode: CREDENTIAL_MODE });
100
+ // `writeFileSync`'s mode applies only when it CREATES the file — re-stating it is what makes
101
+ // an overwrite (a re-pair) as private as a first write.
102
+ chmodSync(this.#path, CREDENTIAL_MODE);
103
+ } catch (error) {
104
+ return refuse(
105
+ 'credential-cache-write-failed',
106
+ `the paired credential could not be written to ${this.#path}: ${String(error)}`,
107
+ );
108
+ }
109
+
110
+ let observed: number;
111
+ try {
112
+ observed = statSync(this.#path).mode;
113
+ } catch (error) {
114
+ return refuse(
115
+ 'credential-cache-write-failed',
116
+ `the paired credential was written and could not be read back from ${this.#path}: ${String(error)}`,
117
+ );
118
+ }
119
+
120
+ const mode = classifyCredentialMode(observed, enforcement);
121
+ if (mode.kind === 'too-wide') {
122
+ this.clear();
123
+ return refuse(
124
+ mode.refusal.reason,
125
+ `${mode.refusal.detail}; the file has been removed rather than left readable`,
126
+ );
127
+ }
128
+
129
+ return ok({ path: this.#path, mode });
130
+ }
131
+
132
+ /** Remove the credential. Idempotent — clearing what is not there is success, not an error. */
133
+ clear(): void {
134
+ try {
135
+ rmSync(this.#path, { force: true });
136
+ } catch {
137
+ // Nothing here can act on the failure.
138
+ }
139
+ }
140
+ }