@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,94 @@
1
+ /**
2
+ * PKCE (RFC 7636) and the callback's `state` — the two random values that make an authorization-code
3
+ * exchange safe to run on a machine somebody else is also running code on.
4
+ *
5
+ * They defend different things and neither substitutes for the other. This is the sentence worth
6
+ * reading twice, because "it does PKCE" is routinely taken to mean the whole callback is covered:
7
+ *
8
+ * - The code verifier proves that whoever redeems the authorization code is the same party
9
+ * that requested it. It protects the code exchange.
10
+ * - The state proves that the callback this host received is the answer to the request this
11
+ * host made. It protects the callback itself.
12
+ *
13
+ * Why that matters acutely here, rather than as boilerplate. This host's whole premise is that
14
+ * the agent runs as the same OS user as the host — it is why an 0600 token file is not a boundary
15
+ * and why the gate has a credential-path denial. That same fact means any local process can
16
+ * connect to the loopback listener this flow opens. Without `state`, such a process could hand this
17
+ * host an authorization code of its own obtaining and have the host redeem it and cache the
18
+ * resulting token. `state` is what makes that a named refusal instead of a silent substitution.
19
+ */
20
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
21
+ import { ok, refuse } from '../core/result.js';
22
+ /**
23
+ * The only challenge method this host will use.
24
+ *
25
+ * RFC 7636 also defines `plain`, where the challenge is the verifier. This host refuses it rather
26
+ * than merely not offering it — an unused branch is one a later reader restores on a compatibility
27
+ * report, and `plain` gives away the whole property to anyone who can observe the authorization
28
+ * request.
29
+ */
30
+ export const CODE_CHALLENGE_METHOD = 'S256';
31
+ /** 32 bytes, which is 43 base64url characters — the RFC's floor is 43 and its ceiling is 128. */
32
+ const ENTROPY_BYTES = 32;
33
+ /**
34
+ * base64url, per RFC 4648 §5 — no padding, URL-safe alphabet.
35
+ *
36
+ * Hand-rolled from base64 rather than reached for by name: `Buffer`'s `base64url` encoding is
37
+ * equivalent, but the transform is one line and stating it keeps this readable next to the RFC.
38
+ */
39
+ function base64Url(bytes) {
40
+ return bytes.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
41
+ }
42
+ /**
43
+ * A cryptographically random, URL-safe string.
44
+ *
45
+ * `randomBytes`, never `Math.random`. `Math.random` is seeded, predictable and explicitly not for
46
+ * this; a predictable verifier or state defeats the property entirely while looking identical in
47
+ * every test.
48
+ */
49
+ export function randomUrlSafe(bytes = ENTROPY_BYTES) {
50
+ return base64Url(randomBytes(bytes));
51
+ }
52
+ /** A fresh verifier and its S256 challenge. */
53
+ export function createPkce() {
54
+ const verifier = randomUrlSafe();
55
+ return { verifier, challenge: challengeFor(verifier), method: CODE_CHALLENGE_METHOD };
56
+ }
57
+ /** The S256 challenge for a verifier: base64url(SHA-256(ascii(verifier))). */
58
+ export function challengeFor(verifier) {
59
+ return base64Url(createHash('sha256').update(verifier, 'ascii').digest());
60
+ }
61
+ /**
62
+ * The `state` value for one authorization request.
63
+ *
64
+ * Same entropy source as the verifier and deliberately a separate value — reusing the verifier as
65
+ * state would put it in a URL the browser and the provider both log, and the verifier is the one
66
+ * that must never leave this process until the token exchange.
67
+ */
68
+ export function createState() {
69
+ return randomUrlSafe();
70
+ }
71
+ /**
72
+ * Refuse a challenge method this host will not use.
73
+ *
74
+ * Exported so the refusal is reachable and testable, rather than being an `if` nobody can drive.
75
+ */
76
+ export function requireS256(method) {
77
+ if (method !== CODE_CHALLENGE_METHOD) {
78
+ return refuse('pkce-method-unsupported', `code challenge method ${method} is refused; this host uses ${CODE_CHALLENGE_METHOD} only, and "plain" would put the verifier itself in the authorization request`);
79
+ }
80
+ return ok(CODE_CHALLENGE_METHOD);
81
+ }
82
+ /**
83
+ * Compare two `state` values without leaking their contents through timing.
84
+ *
85
+ * The length check is separate and deliberate: `timingSafeEqual` throws on unequal lengths, so
86
+ * comparing lengths first is required rather than an optimisation. It leaks the length and nothing
87
+ * else, which is the standard accepted residual.
88
+ */
89
+ export function stateMatches(expected, received) {
90
+ const a = Buffer.from(expected, 'utf8');
91
+ const b = Buffer.from(received, 'utf8');
92
+ return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
93
+ }
94
+ //# sourceMappingURL=pkce.js.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * What is persisted, and the port that persists it.
3
+ *
4
+ * The shape and its validation live here, not with the filesystem, and the dependency direction
5
+ * is the reason: `host/` may import this, and this may never import `host/`. Reading a cache is two
6
+ * jobs — getting bytes off a disk, and deciding whether what they say is usable — and only the
7
+ * first needs a machine. Keeping the second here means every malformed-cache branch is drivable
8
+ * without writing a file.
9
+ */
10
+ import type { Result } from '../core/result.js';
11
+ import type { AuthProtocol } from './device-code.js';
12
+ import type { TokenSet } from './token.js';
13
+ /**
14
+ * The persisted record.
15
+ *
16
+ * `authority` and `clientId` ride along so a cache minted against one provider is never presented
17
+ * to another — a re-pointed host discards rather than replays, which matters because the token
18
+ * would otherwise be sent to an audience it was not issued for. `protocol` exists for the
19
+ * protocol-mismatch guard: material minted by one flow is discarded rather than refreshed when the
20
+ * host is configured for the other.
21
+ */
22
+ export interface CachedTokens {
23
+ readonly tokens: TokenSet;
24
+ readonly protocol: AuthProtocol;
25
+ readonly authority: string;
26
+ readonly clientId: string;
27
+ }
28
+ /**
29
+ * The port the credential holds.
30
+ *
31
+ * An interface rather than the concrete file cache so the credential's whole decision surface —
32
+ * expired, re-pointed, wrong protocol, corrupt — is testable with an in-memory double, and so an
33
+ * embedder with its own secret storage can supply one without this package growing an opinion
34
+ * about keychains.
35
+ */
36
+ export interface TokenStore {
37
+ read(): Result<CachedTokens>;
38
+ write(cached: CachedTokens): Result<unknown>;
39
+ clear(): void;
40
+ }
41
+ /** Validate a parsed cache into the shape the rest of the host relies on. */
42
+ export declare function readCachedTokens(parsed: unknown): Result<CachedTokens>;
43
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1,43 @@
1
+ import { ok, refuse } from '../core/result.js';
2
+ /** Validate a parsed cache into the shape the rest of the host relies on. */
3
+ export function readCachedTokens(parsed) {
4
+ if (typeof parsed !== 'object' || parsed === null) {
5
+ return refuse('credential-cache-unreadable', 'the token cache is not a JSON object');
6
+ }
7
+ const record = parsed;
8
+ const tokens = record['tokens'];
9
+ const protocol = record['protocol'];
10
+ const authority = record['authority'];
11
+ const clientId = record['clientId'];
12
+ if (typeof tokens !== 'object' || tokens === null) {
13
+ return refuse('credential-cache-unreadable', 'the token cache carries no tokens object');
14
+ }
15
+ const tokenRecord = tokens;
16
+ const accessToken = tokenRecord['accessToken'];
17
+ const expiresAt = tokenRecord['expiresAt'];
18
+ if (typeof accessToken !== 'string' || accessToken === '' || typeof expiresAt !== 'number') {
19
+ return refuse('credential-cache-unreadable', 'the cached token has no accessToken or no numeric expiresAt');
20
+ }
21
+ if (protocol !== 'loopback' && protocol !== 'device-code') {
22
+ return refuse('credential-cache-unreadable', 'the token cache does not say which flow minted it, so it cannot be safely refreshed');
23
+ }
24
+ if (typeof authority !== 'string' || authority === '' || typeof clientId !== 'string' || clientId === '') {
25
+ return refuse('credential-cache-unreadable', 'the token cache does not record the authority and client it was minted for');
26
+ }
27
+ const refreshToken = tokenRecord['refreshToken'];
28
+ const tokenType = tokenRecord['tokenType'];
29
+ const scope = tokenRecord['scope'];
30
+ return ok({
31
+ tokens: {
32
+ accessToken,
33
+ refreshToken: typeof refreshToken === 'string' && refreshToken !== '' ? refreshToken : null,
34
+ expiresAt,
35
+ tokenType: typeof tokenType === 'string' && tokenType !== '' ? tokenType : 'Bearer',
36
+ scope: typeof scope === 'string' ? scope : null,
37
+ },
38
+ protocol,
39
+ authority,
40
+ clientId,
41
+ });
42
+ }
43
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The token exchange — building the requests, reading the responses, and deciding when what the
3
+ * host holds is still usable. Pure: no network here, so every branch including the ugly ones is
4
+ * drivable.
5
+ *
6
+ * The redaction in this file is load-bearing, not hygiene. Mapping a provider's error response
7
+ * into a readable message is the single most likely place a token ends up in a log, because the
8
+ * natural implementation — echo the body so the operator can see what happened — is also the one
9
+ * that writes credential material to disk. Everything a provider says passes through
10
+ * `redactSecrets` before it becomes a refusal `detail`, and `token.test.ts` drives a failure path
11
+ * carrying a token and asserts the token appears nowhere in the output.
12
+ */
13
+ import type { Result } from '../core/result.js';
14
+ import type { IdentityConfig } from './config.js';
15
+ /** What this host holds after a successful exchange. */
16
+ export interface TokenSet {
17
+ readonly accessToken: string;
18
+ /** Null when the provider issued none — then expiry means an interactive sign-in. */
19
+ readonly refreshToken: string | null;
20
+ /** Epoch milliseconds. */
21
+ readonly expiresAt: number;
22
+ readonly tokenType: string;
23
+ readonly scope: string | null;
24
+ }
25
+ /**
26
+ * How long before expiry a token is treated as already expired.
27
+ *
28
+ * A token that expires in four seconds is not usable for a request that takes five. The skew also
29
+ * covers clock drift between this host and the provider, which is why it is generous.
30
+ */
31
+ export declare const EXPIRY_SKEW_MS = 60000;
32
+ /**
33
+ * Collect every credential-shaped value in a parsed body.
34
+ *
35
+ * Recursive because providers nest error payloads, and a token one level down is exactly as
36
+ * damaging as one at the top.
37
+ */
38
+ export declare function secretsIn(body: unknown): string[];
39
+ /** Replace every known secret with a marker. Longest first, so a prefix cannot leave a tail behind. */
40
+ export declare function redactSecrets(text: string, secrets: readonly string[]): string;
41
+ /** The authorization-code redemption body. */
42
+ export declare function codeExchangeBody(config: IdentityConfig, code: string, codeVerifier: string, redirectUri: string): string;
43
+ /** The refresh body. */
44
+ export declare function refreshBody(config: IdentityConfig, refreshToken: string): string;
45
+ /**
46
+ * Turn a token-endpoint response into a `TokenSet`, or into the right kind of refusal.
47
+ *
48
+ * Two failure reasons, and they must not be collapsed — the same discipline the permission gate
49
+ * uses. A non-2xx is the provider refusing or an outage: the fix is a credential, a tenant policy,
50
+ * or a retry. A 2xx whose body cannot be used is version skew or a misconfigured endpoint: the fix
51
+ * is code or configuration. One is the provider's problem and one is this host's.
52
+ */
53
+ export declare function readTokenResponse(status: number, body: unknown, nowMs: number): Result<TokenSet>;
54
+ /** Is this token still usable, allowing for the skew? */
55
+ export declare function isFresh(tokens: TokenSet, nowMs: number): boolean;
56
+ /** The header a fresh token is presented as. */
57
+ export declare function authorizationValue(tokens: TokenSet): string;
58
+ //# sourceMappingURL=token.d.ts.map
@@ -0,0 +1,149 @@
1
+ import { ok, refuse } from '../core/result.js';
2
+ /**
3
+ * How long before expiry a token is treated as already expired.
4
+ *
5
+ * A token that expires in four seconds is not usable for a request that takes five. The skew also
6
+ * covers clock drift between this host and the provider, which is why it is generous.
7
+ */
8
+ export const EXPIRY_SKEW_MS = 60_000;
9
+ /** The field names whose values must never reach a log, at any nesting depth. */
10
+ const SECRET_FIELDS = ['access_token', 'refresh_token', 'id_token', 'code', 'code_verifier', 'client_secret'];
11
+ /**
12
+ * Collect every credential-shaped value in a parsed body.
13
+ *
14
+ * Recursive because providers nest error payloads, and a token one level down is exactly as
15
+ * damaging as one at the top.
16
+ */
17
+ export function secretsIn(body) {
18
+ const found = [];
19
+ const visit = (node, depth) => {
20
+ if (depth > 8 || typeof node !== 'object' || node === null)
21
+ return;
22
+ if (Array.isArray(node)) {
23
+ for (const item of node)
24
+ visit(item, depth + 1);
25
+ return;
26
+ }
27
+ for (const [key, value] of Object.entries(node)) {
28
+ if (SECRET_FIELDS.includes(key) && typeof value === 'string' && value !== '')
29
+ found.push(value);
30
+ else
31
+ visit(value, depth + 1);
32
+ }
33
+ };
34
+ visit(body, 0);
35
+ return found;
36
+ }
37
+ /** Replace every known secret with a marker. Longest first, so a prefix cannot leave a tail behind. */
38
+ export function redactSecrets(text, secrets) {
39
+ let out = text;
40
+ for (const secret of [...secrets].sort((a, b) => b.length - a.length)) {
41
+ if (secret === '')
42
+ continue;
43
+ out = out.split(secret).join('[redacted]');
44
+ }
45
+ return out;
46
+ }
47
+ /** Bound a provider-supplied string before it becomes a message. */
48
+ function bounded(value, max = 200) {
49
+ return value.length <= max ? value : `${value.slice(0, max)}… (${value.length} chars)`;
50
+ }
51
+ function form(pairs) {
52
+ const body = new URLSearchParams();
53
+ for (const [key, value] of Object.entries(pairs))
54
+ body.set(key, value);
55
+ return body.toString();
56
+ }
57
+ /** The authorization-code redemption body. */
58
+ export function codeExchangeBody(config, code, codeVerifier, redirectUri) {
59
+ return form({
60
+ grant_type: 'authorization_code',
61
+ client_id: config.clientId,
62
+ code,
63
+ redirect_uri: redirectUri,
64
+ code_verifier: codeVerifier,
65
+ scope: config.scopes.join(' '),
66
+ });
67
+ }
68
+ /** The refresh body. */
69
+ export function refreshBody(config, refreshToken) {
70
+ return form({
71
+ grant_type: 'refresh_token',
72
+ client_id: config.clientId,
73
+ refresh_token: refreshToken,
74
+ scope: config.scopes.join(' '),
75
+ });
76
+ }
77
+ /**
78
+ * Turn a token-endpoint response into a `TokenSet`, or into the right kind of refusal.
79
+ *
80
+ * Two failure reasons, and they must not be collapsed — the same discipline the permission gate
81
+ * uses. A non-2xx is the provider refusing or an outage: the fix is a credential, a tenant policy,
82
+ * or a retry. A 2xx whose body cannot be used is version skew or a misconfigured endpoint: the fix
83
+ * is code or configuration. One is the provider's problem and one is this host's.
84
+ */
85
+ export function readTokenResponse(status, body, nowMs) {
86
+ const secrets = secretsIn(body);
87
+ if (status < 200 || status >= 300) {
88
+ const record = typeof body === 'object' && body !== null ? body : {};
89
+ const error = typeof record['error'] === 'string' ? record['error'] : `HTTP ${status}`;
90
+ const description = typeof record['error_description'] === 'string' ? record['error_description'] : null;
91
+ // Only the two named fields are quoted — the body is never dumped. That is the first of two
92
+ // defences here, and it is the one that is easy to lose: "echo what the provider said so the
93
+ // operator can see it" is the natural implementation and it writes tokens to a log.
94
+ const message = description === null ? error : `${error}: ${description}`;
95
+ // `invalid_grant` is a different answer from every other non-2xx, and conflating them hides a
96
+ // dead grant behind retries. RFC 6749 section 5.2 defines it as the grant being expired, revoked,
97
+ // or issued to another client - none of which the next attempt improves. Everything else here is
98
+ // something a retry may well survive: a gateway hiccup, a throttle, a provider blip.
99
+ //
100
+ // This reader serves both grant exchanges, and the conclusion holds for each. On a refresh the
101
+ // refresh token has lapsed; on the initial code exchange the authorization code has expired or
102
+ // been replayed. Either way the material presented is spent and the next attempt with the same
103
+ // material fails identically - what differs is only which sign-in a person has to redo.
104
+ //
105
+ // The name is the RFC's, not a provider's. Providers append their own diagnostic codes to
106
+ // `error_description` and no one provider's is canonical; branching on the standard name is
107
+ // what keeps the rule true for a host pointed somewhere else.
108
+ if (error === 'invalid_grant') {
109
+ return refuse('token-grant-rejected', redactSecrets(bounded(`${message} - this grant is no longer valid; a new sign-in is required`), secrets));
110
+ }
111
+ return refuse('token-request-failed', redactSecrets(bounded(message), secrets));
112
+ }
113
+ if (typeof body !== 'object' || body === null) {
114
+ return refuse('token-response-invalid', 'the token endpoint returned 2xx with a body that is not a JSON object');
115
+ }
116
+ const record = body;
117
+ const accessToken = record['access_token'];
118
+ if (typeof accessToken !== 'string' || accessToken === '') {
119
+ return refuse('token-response-invalid', 'the token endpoint returned 2xx with no access_token');
120
+ }
121
+ // `expires_in` is only recommended by RFC 6749, and this host refuses without it anyway. An
122
+ // unattended host that does not know when its token dies cannot decide when to refresh — it would
123
+ // either refresh on every single call or discover expiry as a 401 in the middle of doing
124
+ // something. Neither is acceptable, and guessing a lifetime would be inventing a fact.
125
+ const expiresIn = record['expires_in'];
126
+ const seconds = typeof expiresIn === 'number' ? expiresIn : Number(expiresIn);
127
+ if (!Number.isFinite(seconds) || seconds <= 0) {
128
+ return refuse('token-response-invalid', 'the token endpoint returned 2xx with no usable expires_in; an unattended host cannot decide when to refresh a token whose lifetime it does not know');
129
+ }
130
+ const refreshToken = record['refresh_token'];
131
+ const scope = record['scope'];
132
+ const tokenType = record['token_type'];
133
+ return ok({
134
+ accessToken,
135
+ refreshToken: typeof refreshToken === 'string' && refreshToken !== '' ? refreshToken : null,
136
+ expiresAt: nowMs + seconds * 1000,
137
+ tokenType: typeof tokenType === 'string' && tokenType !== '' ? tokenType : 'Bearer',
138
+ scope: typeof scope === 'string' ? scope : null,
139
+ });
140
+ }
141
+ /** Is this token still usable, allowing for the skew? */
142
+ export function isFresh(tokens, nowMs) {
143
+ return tokens.expiresAt - EXPIRY_SKEW_MS > nowMs;
144
+ }
145
+ /** The header a fresh token is presented as. */
146
+ export function authorizationValue(tokens) {
147
+ return `${tokens.tokenType} ${tokens.accessToken}`;
148
+ }
149
+ //# sourceMappingURL=token.js.map
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The public surface of the package.
3
+ *
4
+ * Everything a consumer may depend on is named here or on the `./protocol` subpath. A module not
5
+ * re-exported from one of the two is internal, whatever its file path suggests.
6
+ */
7
+ export * from './protocol.js';
8
+ export type { LinkHandlers, LinkOptions } from './control/link.js';
9
+ export { ControllerLink } from './control/link.js';
10
+ export type { ForwardSessionOptions, FrameSink } from './control/stream.js';
11
+ export { forwardSession } from './control/stream.js';
12
+ export type { RoutingRow, StreamLane } from './control/stream-routing.js';
13
+ export { MESSAGE_ROUTING, discriminatorsOn, laneFor } from './control/stream-routing.js';
14
+ export type { LinkCause, LinkState, LinkTransition } from './control/link-state.js';
15
+ export { LINK_CAUSES, LinkStateMachine } from './control/link-state.js';
16
+ export type { BackoffOptions } from './control/backoff.js';
17
+ export { DEFAULT_BACKOFF, nextDelayMs } from './control/backoff.js';
18
+ export type { QueueStats } from './control/queue.js';
19
+ export { BoundedFrameQueue } from './control/queue.js';
20
+ export type { Authorization, ControllerCredential } from './control/credential.js';
21
+ export { UnconfiguredCredential } from './control/credential.js';
22
+ export { HOST_NOUNS, SDK_NOUNS, isAbsolutePath, isContainedBy, isDeclaredNoun, isOk, nounOf, normalizePath, ok, refuse, refusal, requireAbsolute, systemClock, systemTicker, valueOr, } from './core/index.js';
23
+ export type { Clock, HostNoun, SdkNoun, Ticker } from './core/index.js';
24
+ export * from './state/index.js';
25
+ export * from './sessions/index.js';
26
+ export type { BulkPostReceipt, BulkPostRequest, MachineFacts } from './host/index.js';
27
+ export { credentialPaths, nodePathResolver, periscopeCredentialDir, postBulk, readMachineFacts, tokenCachePath, } from './host/index.js';
28
+ export type { BulkResolver, ComposeSessionOptions, ComposedSession, GateTimings, HostEvent, PeriscopeHostOptions, } from './host/index.js';
29
+ export { PeriscopeHost, composeSession } from './host/index.js';
30
+ export * from './identity/index.js';
31
+ export type { DeviceCodeInstruction, LoopbackListener, SignInDeps, TokenCacheWrite } from './host/index.js';
32
+ export { DEFAULT_SIGN_IN_TIMEOUT_MS, FileTokenCache, LOOPBACK_HOST, openLoopbackListener, probeModeEnforcement, protocolFor, refresherFor, resolveEndpoints, signIn, signInWithDeviceCode, } from './host/index.js';
33
+ export type { HookFailureListener, ObservationHookOptions } from './host/hooks.js';
34
+ export { mergeHooks, observationHooks, wiredHookEvents } from './host/hooks.js';
35
+ export { readWhere } from './host/git-facts.js';
36
+ export * from './gate/index.js';
37
+ export * from './workspace/index.js';
38
+ export { nodeCommandEffects, nodeWorkspaceEffects } from './host/workspace-fs.js';
39
+ export * from './mcp/index.js';
40
+ export { createToolServer } from './host/mcp-server.js';
41
+ export * from './persistence/index.js';
42
+ export { asSessionStore, nodeStoreEffects, readMirrorDrop, toSessionKey, toTranscriptKey, } from './host/index.js';
43
+ export * from './telemetry/index.js';
44
+ export { readRateLimit, readTaskSpend, readTurnSpend } from './host/index.js';
45
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The public surface of the package.
3
+ *
4
+ * Everything a consumer may depend on is named here or on the `./protocol` subpath. A module not
5
+ * re-exported from one of the two is internal, whatever its file path suggests.
6
+ */
7
+ // The wire contract, re-exported so `@naswerks/periscope` alone is enough for a host-side consumer. A
8
+ // controller that wants only the wire should import `@naswerks/periscope/protocol` instead — that path
9
+ // cannot reach the privileged module.
10
+ export * from './protocol.js';
11
+ export { ControllerLink } from './control/link.js';
12
+ export { forwardSession } from './control/stream.js';
13
+ export { MESSAGE_ROUTING, discriminatorsOn, laneFor } from './control/stream-routing.js';
14
+ export { LINK_CAUSES, LinkStateMachine } from './control/link-state.js';
15
+ export { DEFAULT_BACKOFF, nextDelayMs } from './control/backoff.js';
16
+ export { BoundedFrameQueue } from './control/queue.js';
17
+ export { UnconfiguredCredential } from './control/credential.js';
18
+ // The pure core.
19
+ export { HOST_NOUNS, SDK_NOUNS, isAbsolutePath, isContainedBy, isDeclaredNoun, isOk, nounOf, normalizePath, ok, refuse, refusal, requireAbsolute, systemClock, systemTicker, valueOr, } from './core/index.js';
20
+ // The declared state model, its machine, its store and the coverage table.
21
+ export * from './state/index.js';
22
+ // The agent session lifecycle and the registry of live sessions.
23
+ export * from './sessions/index.js';
24
+ export { credentialPaths, nodePathResolver, periscopeCredentialDir, postBulk, readMachineFacts, tokenCachePath, } from './host/index.js';
25
+ export { PeriscopeHost, composeSession } from './host/index.js';
26
+ // Identity — the pure flows, plus the two impure halves that live in `host/` by the same rule as
27
+ // everything else that touches the machine.
28
+ export * from './identity/index.js';
29
+ export { DEFAULT_SIGN_IN_TIMEOUT_MS, FileTokenCache, LOOPBACK_HOST, openLoopbackListener, probeModeEnforcement, protocolFor, refresherFor, resolveEndpoints, signIn, signInWithDeviceCode, } from './host/index.js';
30
+ export { mergeHooks, observationHooks, wiredHookEvents } from './host/hooks.js';
31
+ export { readWhere } from './host/git-facts.js';
32
+ // The permission gate. Registered after observationHooks — see permissionHooks' own note on why the
33
+ // order is load-bearing.
34
+ export * from './gate/index.js';
35
+ // Workspace provisioning — the directory a session runs in. The two providers ship; the interesting
36
+ // policies (a shared directory per task, a branch naming rule, a cleanup schedule) are the
37
+ // embedder's and are written against `WorkspaceProvider` rather than added here.
38
+ export * from './workspace/index.js';
39
+ export { nodeCommandEffects, nodeWorkspaceEffects } from './host/workspace-fs.js';
40
+ // The generic tool mechanism: descriptors in, an in-process MCP server out. The host validates a
41
+ // call against the schema it was handed, attaches session identity, forwards, and returns — it never
42
+ // learns what any tool means. `createToolServer` sits in `host/` because it names the SDK.
43
+ export * from './mcp/index.js';
44
+ export { createToolServer } from './host/mcp-server.js';
45
+ // Durability. The pure half — the store contract, the receipt read path, the transition log's
46
+ // encoding and the retention policy — plus the two impure halves in `host/`: the local JSONL
47
+ // effects, and the bridge that presents a store to the SDK.
48
+ export * from './persistence/index.js';
49
+ export { asSessionStore, nodeStoreEffects, readMirrorDrop, toSessionKey, toTranscriptKey, } from './host/index.js';
50
+ // Cost, usage and rate limits — read from the agent's own result, never computed from a price table.
51
+ export * from './telemetry/index.js';
52
+ export { readRateLimit, readTaskSpend, readTurnSpend } from './host/index.js';
53
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The descriptor contract — what an embedder supplies so this host can register an in-process tool.
3
+ *
4
+ * The descriptor is deliberately language-neutral. An embedder composing this host from another
5
+ * language, or a controller whose descriptors an embedder forwards,
6
+ * cannot express a TypeScript schema library's types, and asking it to would put a TypeScript
7
+ * dependency on the wire — which is precisely the leak this package is built to avoid. So a
8
+ * descriptor is JSON: a name, a description, and a JSON-Schema object. The host converts it
9
+ * (schema.ts) into what the SDK needs.
10
+ *
11
+ * The host never learns what a tool means. It validates the descriptor's shape, converts the
12
+ * schema it was handed, checks a call's arguments against that schema, attaches session identity,
13
+ * forwards, and returns the answer. There is no branch anywhere on a tool's name — pinned by
14
+ * pins/mcp-generic.test.ts, which registers the same descriptors under permuted names and requires
15
+ * the results to be identical.
16
+ *
17
+ * The subset is declared, not inferred. Only the constructs below are convertible. A descriptor
18
+ * carrying anything else is refused at registration rather than converted to something permissive —
19
+ * see schema.ts, where that rule is the most consequential line in this directory.
20
+ */
21
+ import type { JsonValue } from '../control/frames.js';
22
+ /** The JSON-Schema types this host converts. Anything else is refused by name. */
23
+ export declare const SUPPORTED_SCHEMA_TYPES: readonly ["string", "number", "integer", "boolean", "array", "object"];
24
+ export type SupportedSchemaType = (typeof SUPPORTED_SCHEMA_TYPES)[number];
25
+ /**
26
+ * One node of a tool's input schema.
27
+ *
28
+ * Everything is optional because this is what ARRIVES — an untrusted object off the wire, not a
29
+ * shape this package constructs. Validation happens in schema.ts and reports what was wrong.
30
+ */
31
+ export interface JsonSchemaNode {
32
+ readonly type?: string;
33
+ readonly description?: string;
34
+ /** A closed set of literal values. Takes precedence over `type`. */
35
+ readonly enum?: readonly JsonValue[];
36
+ /** For `array`. */
37
+ readonly items?: JsonSchemaNode;
38
+ /** For `object`. */
39
+ readonly properties?: Readonly<Record<string, JsonSchemaNode>>;
40
+ readonly required?: readonly string[];
41
+ /** JSON-Schema draft-4 style, and the spelling most emitters produce. */
42
+ readonly nullable?: boolean;
43
+ }
44
+ /** A tool's input schema. Always an object at the top level — `tool()` takes a shape, not a type. */
45
+ export interface JsonSchemaObject extends JsonSchemaNode {
46
+ readonly type?: 'object';
47
+ readonly properties?: Readonly<Record<string, JsonSchemaNode>>;
48
+ readonly required?: readonly string[];
49
+ }
50
+ /** One tool, as the embedder describes it. */
51
+ export interface ToolDescriptor {
52
+ /** What the agent calls. Reaches the model as `mcp__{server}__{name}`. */
53
+ readonly name: string;
54
+ /** What the tool does, in the model's terms. The only thing that makes it usable. */
55
+ readonly description: string;
56
+ readonly inputSchema: JsonSchemaObject;
57
+ }
58
+ export declare function isUsableToolName(name: string): boolean;
59
+ //# sourceMappingURL=descriptor.d.ts.map
@@ -0,0 +1,14 @@
1
+ /** The JSON-Schema types this host converts. Anything else is refused by name. */
2
+ export const SUPPORTED_SCHEMA_TYPES = ['string', 'number', 'integer', 'boolean', 'array', 'object'];
3
+ /**
4
+ * A tool name both the MCP protocol and the agent CLI accept.
5
+ *
6
+ * Conservative on purpose: the name is concatenated into `mcp__{server}__{name}`, so a name carrying
7
+ * the separator would produce a tool the agent addresses ambiguously — and the failure would look
8
+ * like the wrong tool being called rather than like a bad name.
9
+ */
10
+ const NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
11
+ export function isUsableToolName(name) {
12
+ return NAME_PATTERN.test(name) && !name.includes('__');
13
+ }
14
+ //# sourceMappingURL=descriptor.js.map
@@ -0,0 +1,14 @@
1
+ /**
2
+ * In-process MCP servers and the tool descriptors that build them.
3
+ *
4
+ * `createToolServer` is not here. It calls the SDK, so it lives in `host/mcp-server.ts` under the
5
+ * rule that one directory names the SDK — see that file. Everything in this directory is the
6
+ * decisions: the descriptor contract, the schema conversion, and the plan a server is built from.
7
+ */
8
+ export type { JsonSchemaNode, JsonSchemaObject, SupportedSchemaType, ToolDescriptor } from './descriptor.js';
9
+ export { SUPPORTED_SCHEMA_TYPES, isUsableToolName } from './descriptor.js';
10
+ export type { ZodRawShape } from './schema.js';
11
+ export { toRawShape, toZod } from './schema.js';
12
+ export type { McpServerOptions, ToolCall, ToolCallIdentity, ToolInvoker, ToolPlan, ToolResponse, ToolResult, } from './server.js';
13
+ export { planTools } from './server.js';
14
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,4 @@
1
+ export { SUPPORTED_SCHEMA_TYPES, isUsableToolName } from './descriptor.js';
2
+ export { toRawShape, toZod } from './schema.js';
3
+ export { planTools } from './server.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,37 @@
1
+ /**
2
+ * JSON-Schema to Zod raw shape, because that is the shape the SDK's `tool()` actually takes.
3
+ *
4
+ * Why a conversion exists at all. `SdkMcpToolDefinition<Schema extends AnyZodRawShape>` is generic
5
+ * over a Zod raw shape — a plain record of Zod types, not a schema object and not JSON Schema. A
6
+ * design in which descriptors carry `inputSchema: JsonSchema` and the host does
7
+ * `validate(input, d.inputSchema)` does not compile against the shipped types. A controller in
8
+ * another language cannot express Zod shapes, so the conversion happens here and the descriptor
9
+ * contract stays JSON.
10
+ *
11
+ * An unconvertible construct is refused. Never widened, never `z.any()`, never skipped.
12
+ * This is the most consequential line in the directory. A converter that met something it did not
13
+ * recognise and fell back to a permissive schema would register the tool successfully and validate
14
+ * nothing — so "a malformed call is rejected before it reaches the controller" would be silently
15
+ * false for exactly the tools nobody looked at, while every test stayed green and the tool appeared
16
+ * to work. That is a false green in the highest-consequence direction: the failure is invisible
17
+ * precisely where the checking was supposed to be. Refusing happens at registration, before any
18
+ * session exists, and names the construct that could not be converted.
19
+ *
20
+ * The subset is deliberately small — what a controller emits for a tool's arguments. Growing it is
21
+ * a normal change; growing it by accident is what the refusal prevents.
22
+ */
23
+ import { z } from 'zod';
24
+ import type { Result } from '../core/result.js';
25
+ import type { JsonSchemaNode, JsonSchemaObject } from './descriptor.js';
26
+ /** What `tool()` takes: a record of Zod types, one per top-level property. */
27
+ export type ZodRawShape = Record<string, z.ZodType>;
28
+ /**
29
+ * Convert a tool's input schema into a raw shape.
30
+ *
31
+ * An object with no properties converts to an empty shape rather than being refused — a tool that
32
+ * takes no arguments is ordinary, and refusing it would force every caller to invent a parameter.
33
+ */
34
+ export declare function toRawShape(schema: JsonSchemaObject, at?: string): Result<ZodRawShape>;
35
+ /** Convert one node. Recursive for `array` and `object`. */
36
+ export declare function toZod(node: JsonSchemaNode, at: string): Result<z.ZodType>;
37
+ //# sourceMappingURL=schema.d.ts.map