@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.
- package/CHANGELOG.md +6 -0
- package/README.md +2 -2
- package/contracts/wire-vectors/bulk_delivered.full.json +21 -0
- package/contracts/wire-vectors/bulk_delivered.minimal.json +21 -0
- package/contracts/wire-vectors/bulk_delivered.without-stat-pair.json +22 -0
- package/contracts/wire-vectors/bulk_failed.declared-reason.json +22 -0
- package/contracts/wire-vectors/bulk_failed.unknown-reason.json +23 -0
- package/contracts/wire-vectors/bulk_request.full.json +21 -0
- package/contracts/wire-vectors/host_configure.set-and-remove.json +28 -0
- package/contracts/wire-vectors/host_configure_result.applied.json +34 -0
- package/contracts/wire-vectors/host_configure_result.refused.json +33 -0
- package/contracts/wire-vectors/link_ack.cursors.json +25 -0
- package/contracts/wire-vectors/link_ack.empty.json +16 -0
- package/contracts/wire-vectors/link_bye.cause.json +16 -0
- package/contracts/wire-vectors/link_hello.empty.json +33 -0
- package/contracts/wire-vectors/link_hello.full.json +44 -0
- package/contracts/wire-vectors/link_ping.nonce.json +16 -0
- package/contracts/wire-vectors/link_ping.unknown-payload-key.json +18 -0
- package/contracts/wire-vectors/link_pong.nonce.json +16 -0
- package/contracts/wire-vectors/link_welcome.empty.json +18 -0
- package/contracts/wire-vectors/link_welcome.full.json +25 -0
- package/contracts/wire-vectors/refused.empty.json +10 -0
- package/contracts/wire-vectors/refused.encode.empty-at.json +18 -0
- package/contracts/wire-vectors/refused.encode.empty-session-id.json +18 -0
- package/contracts/wire-vectors/refused.encode.missing-member.json +22 -0
- package/contracts/wire-vectors/refused.encode.seq-zero.json +18 -0
- package/contracts/wire-vectors/refused.encode.undeclared-configure-refusal.json +34 -0
- package/contracts/wire-vectors/refused.encode.undeclared-wire-refusal.json +27 -0
- package/contracts/wire-vectors/refused.missing-member.json +10 -0
- package/contracts/wire-vectors/refused.not-json.json +10 -0
- package/contracts/wire-vectors/refused.over-limit.json +10 -0
- package/contracts/wire-vectors/refused.seq-zero.json +10 -0
- package/contracts/wire-vectors/refused.unknown-frame.json +10 -0
- package/contracts/wire-vectors/refused.unknown-kind.json +10 -0
- package/contracts/wire-vectors/repository_list.root.json +19 -0
- package/contracts/wire-vectors/repository_list.subdirectory.json +19 -0
- package/contracts/wire-vectors/repository_list_result.entries.json +34 -0
- package/contracts/wire-vectors/repository_list_result.refused.json +24 -0
- package/contracts/wire-vectors/repository_read.head.json +20 -0
- package/contracts/wire-vectors/repository_read_result.refused.json +25 -0
- package/contracts/wire-vectors/repository_read_result.truncated.json +22 -0
- package/contracts/wire-vectors/session_cancel.bare.json +17 -0
- package/contracts/wire-vectors/session_configure.full.json +22 -0
- package/contracts/wire-vectors/session_configure.minimal.json +20 -0
- package/contracts/wire-vectors/session_delta.agent-message.json +27 -0
- package/contracts/wire-vectors/session_list.request.json +18 -0
- package/contracts/wire-vectors/session_list_result.empty.json +21 -0
- package/contracts/wire-vectors/session_list_result.full.json +36 -0
- package/contracts/wire-vectors/session_new.full.json +72 -0
- package/contracts/wire-vectors/session_new.minimal.json +22 -0
- package/contracts/wire-vectors/session_new.request-all-null.json +41 -0
- package/contracts/wire-vectors/session_prompt.empty.json +18 -0
- package/contracts/wire-vectors/session_prompt.multibyte.json +18 -0
- package/contracts/wire-vectors/session_prompt.text.json +18 -0
- package/contracts/wire-vectors/session_prompt.unknown-frame-key.json +20 -0
- package/contracts/wire-vectors/session_prompt.unknown-payload-key.json +20 -0
- package/contracts/wire-vectors/session_update.agent-message.json +26 -0
- package/contracts/wire-vectors/session_update.state-transition-with-activity.json +44 -0
- package/contracts/wire-vectors/session_update.state-transition.json +41 -0
- package/contracts/wire-vectors/session_update.wire-refusal.json +26 -0
- package/contracts/wire-vectors/transcript_failed.declared-reason.json +22 -0
- package/contracts/wire-vectors/transcript_list.from-start.json +19 -0
- package/contracts/wire-vectors/transcript_list.paged.json +19 -0
- package/contracts/wire-vectors/transcript_list_result.entry-without-cwd.json +30 -0
- package/contracts/wire-vectors/transcript_list_result.full.json +29 -0
- package/contracts/wire-vectors/transcript_list_result.last-page.json +29 -0
- package/contracts/wire-vectors/transcript_tail.any-user-entry.json +22 -0
- package/contracts/wire-vectors/transcript_tail.with-needle.json +22 -0
- package/contracts/wire-vectors/transcript_tail_result.absent.json +23 -0
- package/contracts/wire-vectors/transcript_tail_result.found.json +23 -0
- package/contracts/wire-vectors/workspace_list.from-start.json +19 -0
- package/contracts/wire-vectors/workspace_list_result.page.json +48 -0
- package/contracts/wire-vectors/workspace_list_result.refused.json +26 -0
- package/contracts/wire-vectors/workspace_release.by-path-delete-branch.json +22 -0
- package/contracts/wire-vectors/workspace_release.request.json +22 -0
- package/contracts/wire-vectors/workspace_release_bulk.two-entries.json +32 -0
- package/contracts/wire-vectors/workspace_release_bulk_result.mixed.json +37 -0
- package/contracts/wire-vectors/workspace_release_result.partial.json +26 -0
- package/contracts/wire-vectors/workspace_release_result.refused.json +26 -0
- package/contracts/wire-vectors/workspace_release_result.released.json +23 -0
- package/package.json +6 -1
- package/src/bin/command.ts +198 -0
- package/src/bin/config.ts +104 -0
- package/src/bin/login.ts +149 -0
- package/src/bin/main.ts +204 -0
- package/src/bin/pair.ts +246 -0
- package/src/bin/periscope.ts +3 -0
- package/src/bin/posture.ts +232 -0
- package/src/bin/reconfigure.ts +266 -0
- package/src/bin/serve.ts +615 -0
- package/src/bin/status.ts +61 -0
- package/src/bin/workspaces.ts +280 -0
- package/src/control/backoff.ts +40 -0
- package/src/control/codec.ts +557 -0
- package/src/control/credential.ts +33 -0
- package/src/control/frames.ts +1586 -0
- package/src/control/link-state.ts +67 -0
- package/src/control/link.ts +894 -0
- package/src/control/queue.ts +248 -0
- package/src/control/seq.ts +76 -0
- package/src/control/stream-routing.ts +216 -0
- package/src/control/stream.ts +149 -0
- package/src/core/async-queue.ts +63 -0
- package/src/core/failure.ts +61 -0
- package/src/core/index.ts +22 -0
- package/src/core/keyed-turns.ts +47 -0
- package/src/core/paths.ts +84 -0
- package/src/core/refusal.ts +430 -0
- package/src/core/result.ts +27 -0
- package/src/core/time.ts +34 -0
- package/src/core/vocab.ts +44 -0
- package/src/core/workspace-id.ts +11 -0
- package/src/gate/command.ts +617 -0
- package/src/gate/decision.ts +173 -0
- package/src/gate/escalate.ts +164 -0
- package/src/gate/gate.ts +635 -0
- package/src/gate/index.ts +53 -0
- package/src/gate/jail.ts +210 -0
- package/src/gate/local.ts +115 -0
- package/src/gate/outcome.ts +164 -0
- package/src/gate/shell.ts +295 -0
- package/src/host/agent-process.ts +694 -0
- package/src/host/bulk-post.ts +166 -0
- package/src/host/claude-transcripts.ts +404 -0
- package/src/host/config-file.ts +246 -0
- package/src/host/git-facts.ts +119 -0
- package/src/host/hooks.ts +102 -0
- package/src/host/host.ts +1901 -0
- package/src/host/index.ts +92 -0
- package/src/host/link-state-file.ts +79 -0
- package/src/host/loopback.ts +177 -0
- package/src/host/machine.ts +27 -0
- package/src/host/mcp-server.ts +66 -0
- package/src/host/package-facts.ts +25 -0
- package/src/host/paired-credential-store.ts +140 -0
- package/src/host/paths.ts +175 -0
- package/src/host/repository-read.ts +245 -0
- package/src/host/session-store.ts +133 -0
- package/src/host/sign-in.ts +352 -0
- package/src/host/telemetry.ts +105 -0
- package/src/host/token-cache.ts +214 -0
- package/src/host/transcript-fs.ts +110 -0
- package/src/host/wire-request.ts +285 -0
- package/src/host/workspace-fs.ts +90 -0
- package/src/host/workspace-trust.ts +92 -0
- package/src/identity/authorize.ts +127 -0
- package/src/identity/config.ts +227 -0
- package/src/identity/credential.ts +176 -0
- package/src/identity/device-code.ts +217 -0
- package/src/identity/index.ts +84 -0
- package/src/identity/mode.ts +161 -0
- package/src/identity/paired-credential.ts +89 -0
- package/src/identity/pkce.ts +113 -0
- package/src/identity/store.ts +98 -0
- package/src/identity/token.ts +203 -0
- package/src/index.ts +140 -0
- package/src/mcp/descriptor.ts +75 -0
- package/src/mcp/index.ts +23 -0
- package/src/mcp/schema.ts +214 -0
- package/src/mcp/server.ts +180 -0
- package/src/persistence/entry.ts +142 -0
- package/src/persistence/index.ts +47 -0
- package/src/persistence/key.ts +77 -0
- package/src/persistence/mirror.ts +118 -0
- package/src/persistence/receipt.ts +182 -0
- package/src/persistence/retention.ts +118 -0
- package/src/persistence/store.ts +183 -0
- package/src/persistence/transition-log.ts +176 -0
- package/src/protocol.ts +215 -0
- package/src/sessions/index.ts +27 -0
- package/src/sessions/registry.ts +392 -0
- package/src/sessions/session.ts +440 -0
- package/src/sessions/spawn-env.ts +318 -0
- package/src/state/coverage.ts +360 -0
- package/src/state/index.ts +58 -0
- package/src/state/machine.ts +359 -0
- package/src/state/model.ts +417 -0
- package/src/state/observer.ts +499 -0
- package/src/state/reporter.ts +60 -0
- package/src/state/store.ts +157 -0
- package/src/telemetry/index.ts +10 -0
- package/src/telemetry/usage.ts +117 -0
- package/src/workspace/git-worktree.ts +551 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/plain-dir.ts +132 -0
- package/src/workspace/provider.ts +159 -0
- package/src/workspace/worktree-porcelain.ts +118 -0
|
@@ -0,0 +1,98 @@
|
|
|
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 { ok, refuse } from '../core/result.js';
|
|
12
|
+
import type { AuthProtocol } from './device-code.js';
|
|
13
|
+
import type { TokenSet } from './token.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The persisted record.
|
|
17
|
+
*
|
|
18
|
+
* `authority` and `clientId` ride along so a cache minted against one provider is never presented
|
|
19
|
+
* to another — a re-pointed host discards rather than replays, which matters because the token
|
|
20
|
+
* would otherwise be sent to an audience it was not issued for. `protocol` exists for the
|
|
21
|
+
* protocol-mismatch guard: material minted by one flow is discarded rather than refreshed when the
|
|
22
|
+
* host is configured for the other.
|
|
23
|
+
*/
|
|
24
|
+
export interface CachedTokens {
|
|
25
|
+
readonly tokens: TokenSet;
|
|
26
|
+
readonly protocol: AuthProtocol;
|
|
27
|
+
readonly authority: string;
|
|
28
|
+
readonly clientId: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The port the credential holds.
|
|
33
|
+
*
|
|
34
|
+
* An interface rather than the concrete file cache so the credential's whole decision surface —
|
|
35
|
+
* expired, re-pointed, wrong protocol, corrupt — is testable with an in-memory double, and so an
|
|
36
|
+
* embedder with its own secret storage can supply one without this package growing an opinion
|
|
37
|
+
* about keychains.
|
|
38
|
+
*/
|
|
39
|
+
export interface TokenStore {
|
|
40
|
+
read(): Result<CachedTokens>;
|
|
41
|
+
write(cached: CachedTokens): Result<unknown>;
|
|
42
|
+
clear(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Validate a parsed cache into the shape the rest of the host relies on. */
|
|
46
|
+
export function readCachedTokens(parsed: unknown): Result<CachedTokens> {
|
|
47
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
48
|
+
return refuse('credential-cache-unreadable', 'the token cache is not a JSON object');
|
|
49
|
+
}
|
|
50
|
+
const record = parsed as Record<string, unknown>;
|
|
51
|
+
const tokens = record['tokens'];
|
|
52
|
+
const protocol = record['protocol'];
|
|
53
|
+
const authority = record['authority'];
|
|
54
|
+
const clientId = record['clientId'];
|
|
55
|
+
|
|
56
|
+
if (typeof tokens !== 'object' || tokens === null) {
|
|
57
|
+
return refuse('credential-cache-unreadable', 'the token cache carries no tokens object');
|
|
58
|
+
}
|
|
59
|
+
const tokenRecord = tokens as Record<string, unknown>;
|
|
60
|
+
const accessToken = tokenRecord['accessToken'];
|
|
61
|
+
const expiresAt = tokenRecord['expiresAt'];
|
|
62
|
+
|
|
63
|
+
if (typeof accessToken !== 'string' || accessToken === '' || typeof expiresAt !== 'number') {
|
|
64
|
+
return refuse(
|
|
65
|
+
'credential-cache-unreadable',
|
|
66
|
+
'the cached token has no accessToken or no numeric expiresAt',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (protocol !== 'loopback' && protocol !== 'device-code') {
|
|
70
|
+
return refuse(
|
|
71
|
+
'credential-cache-unreadable',
|
|
72
|
+
'the token cache does not say which flow minted it, so it cannot be safely refreshed',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (typeof authority !== 'string' || authority === '' || typeof clientId !== 'string' || clientId === '') {
|
|
76
|
+
return refuse(
|
|
77
|
+
'credential-cache-unreadable',
|
|
78
|
+
'the token cache does not record the authority and client it was minted for',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const refreshToken = tokenRecord['refreshToken'];
|
|
83
|
+
const tokenType = tokenRecord['tokenType'];
|
|
84
|
+
const scope = tokenRecord['scope'];
|
|
85
|
+
|
|
86
|
+
return ok({
|
|
87
|
+
tokens: {
|
|
88
|
+
accessToken,
|
|
89
|
+
refreshToken: typeof refreshToken === 'string' && refreshToken !== '' ? refreshToken : null,
|
|
90
|
+
expiresAt,
|
|
91
|
+
tokenType: typeof tokenType === 'string' && tokenType !== '' ? tokenType : 'Bearer',
|
|
92
|
+
scope: typeof scope === 'string' ? scope : null,
|
|
93
|
+
},
|
|
94
|
+
protocol,
|
|
95
|
+
authority,
|
|
96
|
+
clientId,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
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 { ok, refuse } from '../core/result.js';
|
|
15
|
+
import type { IdentityConfig } from './config.js';
|
|
16
|
+
|
|
17
|
+
/** What this host holds after a successful exchange. */
|
|
18
|
+
export interface TokenSet {
|
|
19
|
+
readonly accessToken: string;
|
|
20
|
+
/** Null when the provider issued none — then expiry means an interactive sign-in. */
|
|
21
|
+
readonly refreshToken: string | null;
|
|
22
|
+
/** Epoch milliseconds. */
|
|
23
|
+
readonly expiresAt: number;
|
|
24
|
+
readonly tokenType: string;
|
|
25
|
+
readonly scope: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* How long before expiry a token is treated as already expired.
|
|
30
|
+
*
|
|
31
|
+
* A token that expires in four seconds is not usable for a request that takes five. The skew also
|
|
32
|
+
* covers clock drift between this host and the provider, which is why it is generous.
|
|
33
|
+
*/
|
|
34
|
+
export const EXPIRY_SKEW_MS = 60_000;
|
|
35
|
+
|
|
36
|
+
/** The field names whose values must never reach a log, at any nesting depth. */
|
|
37
|
+
const SECRET_FIELDS = ['access_token', 'refresh_token', 'id_token', 'code', 'code_verifier', 'client_secret'];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Collect every credential-shaped value in a parsed body.
|
|
41
|
+
*
|
|
42
|
+
* Recursive because providers nest error payloads, and a token one level down is exactly as
|
|
43
|
+
* damaging as one at the top.
|
|
44
|
+
*/
|
|
45
|
+
export function secretsIn(body: unknown): string[] {
|
|
46
|
+
const found: string[] = [];
|
|
47
|
+
const visit = (node: unknown, depth: number): void => {
|
|
48
|
+
if (depth > 8 || typeof node !== 'object' || node === null) return;
|
|
49
|
+
if (Array.isArray(node)) {
|
|
50
|
+
for (const item of node) visit(item, depth + 1);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
|
54
|
+
if (SECRET_FIELDS.includes(key) && typeof value === 'string' && value !== '') found.push(value);
|
|
55
|
+
else visit(value, depth + 1);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
visit(body, 0);
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Replace every known secret with a marker. Longest first, so a prefix cannot leave a tail behind. */
|
|
63
|
+
export function redactSecrets(text: string, secrets: readonly string[]): string {
|
|
64
|
+
let out = text;
|
|
65
|
+
for (const secret of [...secrets].sort((a, b) => b.length - a.length)) {
|
|
66
|
+
if (secret === '') continue;
|
|
67
|
+
out = out.split(secret).join('[redacted]');
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Bound a provider-supplied string before it becomes a message. */
|
|
73
|
+
function bounded(value: string, max = 200): string {
|
|
74
|
+
return value.length <= max ? value : `${value.slice(0, max)}… (${value.length} chars)`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function form(pairs: Record<string, string>): string {
|
|
78
|
+
const body = new URLSearchParams();
|
|
79
|
+
for (const [key, value] of Object.entries(pairs)) body.set(key, value);
|
|
80
|
+
return body.toString();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The authorization-code redemption body. */
|
|
84
|
+
export function codeExchangeBody(
|
|
85
|
+
config: IdentityConfig,
|
|
86
|
+
code: string,
|
|
87
|
+
codeVerifier: string,
|
|
88
|
+
redirectUri: string,
|
|
89
|
+
): string {
|
|
90
|
+
return form({
|
|
91
|
+
grant_type: 'authorization_code',
|
|
92
|
+
client_id: config.clientId,
|
|
93
|
+
code,
|
|
94
|
+
redirect_uri: redirectUri,
|
|
95
|
+
code_verifier: codeVerifier,
|
|
96
|
+
scope: config.scopes.join(' '),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The refresh body. */
|
|
101
|
+
export function refreshBody(config: IdentityConfig, refreshToken: string): string {
|
|
102
|
+
return form({
|
|
103
|
+
grant_type: 'refresh_token',
|
|
104
|
+
client_id: config.clientId,
|
|
105
|
+
refresh_token: refreshToken,
|
|
106
|
+
scope: config.scopes.join(' '),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Turn a token-endpoint response into a `TokenSet`, or into the right kind of refusal.
|
|
112
|
+
*
|
|
113
|
+
* Two failure reasons, and they must not be collapsed — the same discipline the permission gate
|
|
114
|
+
* uses. A non-2xx is the provider refusing or an outage: the fix is a credential, a tenant policy,
|
|
115
|
+
* or a retry. A 2xx whose body cannot be used is version skew or a misconfigured endpoint: the fix
|
|
116
|
+
* is code or configuration. One is the provider's problem and one is this host's.
|
|
117
|
+
*/
|
|
118
|
+
export function readTokenResponse(status: number, body: unknown, nowMs: number): Result<TokenSet> {
|
|
119
|
+
const secrets = secretsIn(body);
|
|
120
|
+
|
|
121
|
+
if (status < 200 || status >= 300) {
|
|
122
|
+
const record = typeof body === 'object' && body !== null ? (body as Record<string, unknown>) : {};
|
|
123
|
+
const error = typeof record['error'] === 'string' ? record['error'] : `HTTP ${status}`;
|
|
124
|
+
const description = typeof record['error_description'] === 'string' ? record['error_description'] : null;
|
|
125
|
+
// Only the two named fields are quoted — the body is never dumped. That is the first of two
|
|
126
|
+
// defences here, and it is the one that is easy to lose: "echo what the provider said so the
|
|
127
|
+
// operator can see it" is the natural implementation and it writes tokens to a log.
|
|
128
|
+
const message = description === null ? error : `${error}: ${description}`;
|
|
129
|
+
|
|
130
|
+
// `invalid_grant` is a different answer from every other non-2xx, and conflating them hides a
|
|
131
|
+
// dead grant behind retries. RFC 6749 section 5.2 defines it as the grant being expired, revoked,
|
|
132
|
+
// or issued to another client - none of which the next attempt improves. Everything else here is
|
|
133
|
+
// something a retry may well survive: a gateway hiccup, a throttle, a provider blip.
|
|
134
|
+
//
|
|
135
|
+
// This reader serves both grant exchanges, and the conclusion holds for each. On a refresh the
|
|
136
|
+
// refresh token has lapsed; on the initial code exchange the authorization code has expired or
|
|
137
|
+
// been replayed. Either way the material presented is spent and the next attempt with the same
|
|
138
|
+
// material fails identically - what differs is only which sign-in a person has to redo.
|
|
139
|
+
//
|
|
140
|
+
// The name is the RFC's, not a provider's. Providers append their own diagnostic codes to
|
|
141
|
+
// `error_description` and no one provider's is canonical; branching on the standard name is
|
|
142
|
+
// what keeps the rule true for a host pointed somewhere else.
|
|
143
|
+
if (error === 'invalid_grant') {
|
|
144
|
+
return refuse(
|
|
145
|
+
'token-grant-rejected',
|
|
146
|
+
redactSecrets(
|
|
147
|
+
bounded(`${message} - this grant is no longer valid; a new sign-in is required`),
|
|
148
|
+
secrets,
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return refuse('token-request-failed', redactSecrets(bounded(message), secrets));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (typeof body !== 'object' || body === null) {
|
|
157
|
+
return refuse(
|
|
158
|
+
'token-response-invalid',
|
|
159
|
+
'the token endpoint returned 2xx with a body that is not a JSON object',
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const record = body as Record<string, unknown>;
|
|
163
|
+
|
|
164
|
+
const accessToken = record['access_token'];
|
|
165
|
+
if (typeof accessToken !== 'string' || accessToken === '') {
|
|
166
|
+
return refuse('token-response-invalid', 'the token endpoint returned 2xx with no access_token');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// `expires_in` is only recommended by RFC 6749, and this host refuses without it anyway. An
|
|
170
|
+
// unattended host that does not know when its token dies cannot decide when to refresh — it would
|
|
171
|
+
// either refresh on every single call or discover expiry as a 401 in the middle of doing
|
|
172
|
+
// something. Neither is acceptable, and guessing a lifetime would be inventing a fact.
|
|
173
|
+
const expiresIn = record['expires_in'];
|
|
174
|
+
const seconds = typeof expiresIn === 'number' ? expiresIn : Number(expiresIn);
|
|
175
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
176
|
+
return refuse(
|
|
177
|
+
'token-response-invalid',
|
|
178
|
+
'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',
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const refreshToken = record['refresh_token'];
|
|
183
|
+
const scope = record['scope'];
|
|
184
|
+
const tokenType = record['token_type'];
|
|
185
|
+
|
|
186
|
+
return ok({
|
|
187
|
+
accessToken,
|
|
188
|
+
refreshToken: typeof refreshToken === 'string' && refreshToken !== '' ? refreshToken : null,
|
|
189
|
+
expiresAt: nowMs + seconds * 1000,
|
|
190
|
+
tokenType: typeof tokenType === 'string' && tokenType !== '' ? tokenType : 'Bearer',
|
|
191
|
+
scope: typeof scope === 'string' ? scope : null,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Is this token still usable, allowing for the skew? */
|
|
196
|
+
export function isFresh(tokens: TokenSet, nowMs: number): boolean {
|
|
197
|
+
return tokens.expiresAt - EXPIRY_SKEW_MS > nowMs;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The header a fresh token is presented as. */
|
|
201
|
+
export function authorizationValue(tokens: TokenSet): string {
|
|
202
|
+
return `${tokens.tokenType} ${tokens.accessToken}`;
|
|
203
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
|
|
8
|
+
// The wire contract, re-exported so `@naswerks/periscope` alone is enough for a host-side consumer. A
|
|
9
|
+
// controller that wants only the wire should import `@naswerks/periscope/protocol` instead — that path
|
|
10
|
+
// cannot reach the privileged module.
|
|
11
|
+
export * from './protocol.js';
|
|
12
|
+
|
|
13
|
+
// The outbound link.
|
|
14
|
+
export type { LinkHandlers, LinkOptions } from './control/link.js';
|
|
15
|
+
export { ControllerLink } from './control/link.js';
|
|
16
|
+
|
|
17
|
+
// Forwarding a session's live output onto the link, and the table that declares which lane each
|
|
18
|
+
// message rides. Not on `./protocol` — the table is keyed off the SDK's own message union, so it
|
|
19
|
+
// reaches the privileged module by construction.
|
|
20
|
+
export type { ForwardSessionOptions, FrameSink } from './control/stream.js';
|
|
21
|
+
export { forwardSession } from './control/stream.js';
|
|
22
|
+
export type { RoutingRow, StreamLane } from './control/stream-routing.js';
|
|
23
|
+
export { MESSAGE_ROUTING, discriminatorsOn, laneFor } from './control/stream-routing.js';
|
|
24
|
+
|
|
25
|
+
export type { LinkCause, LinkState, LinkTransition } from './control/link-state.js';
|
|
26
|
+
export { LINK_CAUSES, LinkStateMachine } from './control/link-state.js';
|
|
27
|
+
|
|
28
|
+
export type { BackoffOptions } from './control/backoff.js';
|
|
29
|
+
export { DEFAULT_BACKOFF, nextDelayMs } from './control/backoff.js';
|
|
30
|
+
|
|
31
|
+
export type { QueueStats } from './control/queue.js';
|
|
32
|
+
export { BoundedFrameQueue } from './control/queue.js';
|
|
33
|
+
|
|
34
|
+
export type { Authorization, ControllerCredential } from './control/credential.js';
|
|
35
|
+
export { UnconfiguredCredential } from './control/credential.js';
|
|
36
|
+
|
|
37
|
+
// The pure core.
|
|
38
|
+
export {
|
|
39
|
+
HOST_NOUNS,
|
|
40
|
+
SDK_NOUNS,
|
|
41
|
+
isAbsolutePath,
|
|
42
|
+
isContainedBy,
|
|
43
|
+
isDeclaredNoun,
|
|
44
|
+
isOk,
|
|
45
|
+
nounOf,
|
|
46
|
+
normalizePath,
|
|
47
|
+
ok,
|
|
48
|
+
refuse,
|
|
49
|
+
refusal,
|
|
50
|
+
requireAbsolute,
|
|
51
|
+
systemClock,
|
|
52
|
+
systemTicker,
|
|
53
|
+
valueOr,
|
|
54
|
+
} from './core/index.js';
|
|
55
|
+
export type { Clock, HostNoun, SdkNoun, Ticker } from './core/index.js';
|
|
56
|
+
|
|
57
|
+
// The declared state model, its machine, its store and the coverage table.
|
|
58
|
+
export * from './state/index.js';
|
|
59
|
+
|
|
60
|
+
// The agent session lifecycle and the registry of live sessions.
|
|
61
|
+
export * from './sessions/index.js';
|
|
62
|
+
|
|
63
|
+
// The privileged module, exported deliberately: a host-side embedder needs it, and naming it here
|
|
64
|
+
// keeps the boundary visible rather than hiding it behind a deep import.
|
|
65
|
+
export type { BulkPostReceipt, BulkPostRequest, MachineFacts } from './host/index.js';
|
|
66
|
+
export {
|
|
67
|
+
credentialPaths,
|
|
68
|
+
nodePathResolver,
|
|
69
|
+
periscopeCredentialDir,
|
|
70
|
+
postBulk,
|
|
71
|
+
readMachineFacts,
|
|
72
|
+
tokenCachePath,
|
|
73
|
+
} from './host/index.js';
|
|
74
|
+
|
|
75
|
+
// The composer. Everything above this line is a part; this is the one export that assembles them
|
|
76
|
+
// into a session that is gated, observed, persisted and on the wire. An embedder writing
|
|
77
|
+
// `registry.create({cwd})` by hand gets a correct-looking session with no gate and no observation,
|
|
78
|
+
// which is why the assembly ships rather than living in a document.
|
|
79
|
+
export type {
|
|
80
|
+
BulkResolver,
|
|
81
|
+
ComposeSessionOptions,
|
|
82
|
+
ComposedSession,
|
|
83
|
+
GateTimings,
|
|
84
|
+
HostEvent,
|
|
85
|
+
PeriscopeHostOptions,
|
|
86
|
+
} from './host/index.js';
|
|
87
|
+
export { PeriscopeHost, composeSession } from './host/index.js';
|
|
88
|
+
|
|
89
|
+
// Identity — the pure flows, plus the two impure halves that live in `host/` by the same rule as
|
|
90
|
+
// everything else that touches the machine.
|
|
91
|
+
export * from './identity/index.js';
|
|
92
|
+
export type { DeviceCodeInstruction, LoopbackListener, SignInDeps, TokenCacheWrite } from './host/index.js';
|
|
93
|
+
export {
|
|
94
|
+
DEFAULT_SIGN_IN_TIMEOUT_MS,
|
|
95
|
+
FileTokenCache,
|
|
96
|
+
LOOPBACK_HOST,
|
|
97
|
+
openLoopbackListener,
|
|
98
|
+
probeModeEnforcement,
|
|
99
|
+
protocolFor,
|
|
100
|
+
refresherFor,
|
|
101
|
+
resolveEndpoints,
|
|
102
|
+
signIn,
|
|
103
|
+
signInWithDeviceCode,
|
|
104
|
+
} from './host/index.js';
|
|
105
|
+
|
|
106
|
+
export type { HookFailureListener, ObservationHookOptions } from './host/hooks.js';
|
|
107
|
+
export { mergeHooks, observationHooks, wiredHookEvents } from './host/hooks.js';
|
|
108
|
+
export { readWhere } from './host/git-facts.js';
|
|
109
|
+
|
|
110
|
+
// The permission gate. Registered after observationHooks — see permissionHooks' own note on why the
|
|
111
|
+
// order is load-bearing.
|
|
112
|
+
export * from './gate/index.js';
|
|
113
|
+
|
|
114
|
+
// Workspace provisioning — the directory a session runs in. The two providers ship; the interesting
|
|
115
|
+
// policies (a shared directory per task, a branch naming rule, a cleanup schedule) are the
|
|
116
|
+
// embedder's and are written against `WorkspaceProvider` rather than added here.
|
|
117
|
+
export * from './workspace/index.js';
|
|
118
|
+
export { nodeCommandEffects, nodeWorkspaceEffects } from './host/workspace-fs.js';
|
|
119
|
+
|
|
120
|
+
// The generic tool mechanism: descriptors in, an in-process MCP server out. The host validates a
|
|
121
|
+
// call against the schema it was handed, attaches session identity, forwards, and returns — it never
|
|
122
|
+
// learns what any tool means. `createToolServer` sits in `host/` because it names the SDK.
|
|
123
|
+
export * from './mcp/index.js';
|
|
124
|
+
export { createToolServer } from './host/mcp-server.js';
|
|
125
|
+
|
|
126
|
+
// Durability. The pure half — the store contract, the receipt read path, the transition log's
|
|
127
|
+
// encoding and the retention policy — plus the two impure halves in `host/`: the local JSONL
|
|
128
|
+
// effects, and the bridge that presents a store to the SDK.
|
|
129
|
+
export * from './persistence/index.js';
|
|
130
|
+
export {
|
|
131
|
+
asSessionStore,
|
|
132
|
+
nodeStoreEffects,
|
|
133
|
+
readMirrorDrop,
|
|
134
|
+
toSessionKey,
|
|
135
|
+
toTranscriptKey,
|
|
136
|
+
} from './host/index.js';
|
|
137
|
+
|
|
138
|
+
// Cost, usage and rate limits — read from the agent's own result, never computed from a price table.
|
|
139
|
+
export * from './telemetry/index.js';
|
|
140
|
+
export { readRateLimit, readTaskSpend, readTurnSpend } from './host/index.js';
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
|
|
23
|
+
/** The JSON-Schema types this host converts. Anything else is refused by name. */
|
|
24
|
+
export const SUPPORTED_SCHEMA_TYPES = ['string', 'number', 'integer', 'boolean', 'array', 'object'] as const;
|
|
25
|
+
|
|
26
|
+
export type SupportedSchemaType = (typeof SUPPORTED_SCHEMA_TYPES)[number];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One node of a tool's input schema.
|
|
30
|
+
*
|
|
31
|
+
* Everything is optional because this is what ARRIVES — an untrusted object off the wire, not a
|
|
32
|
+
* shape this package constructs. Validation happens in schema.ts and reports what was wrong.
|
|
33
|
+
*/
|
|
34
|
+
export interface JsonSchemaNode {
|
|
35
|
+
readonly type?: string;
|
|
36
|
+
readonly description?: string;
|
|
37
|
+
/** A closed set of literal values. Takes precedence over `type`. */
|
|
38
|
+
readonly enum?: readonly JsonValue[];
|
|
39
|
+
/** For `array`. */
|
|
40
|
+
readonly items?: JsonSchemaNode;
|
|
41
|
+
/** For `object`. */
|
|
42
|
+
readonly properties?: Readonly<Record<string, JsonSchemaNode>>;
|
|
43
|
+
readonly required?: readonly string[];
|
|
44
|
+
/** JSON-Schema draft-4 style, and the spelling most emitters produce. */
|
|
45
|
+
readonly nullable?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A tool's input schema. Always an object at the top level — `tool()` takes a shape, not a type. */
|
|
49
|
+
export interface JsonSchemaObject extends JsonSchemaNode {
|
|
50
|
+
readonly type?: 'object';
|
|
51
|
+
readonly properties?: Readonly<Record<string, JsonSchemaNode>>;
|
|
52
|
+
readonly required?: readonly string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One tool, as the embedder describes it. */
|
|
56
|
+
export interface ToolDescriptor {
|
|
57
|
+
/** What the agent calls. Reaches the model as `mcp__{server}__{name}`. */
|
|
58
|
+
readonly name: string;
|
|
59
|
+
/** What the tool does, in the model's terms. The only thing that makes it usable. */
|
|
60
|
+
readonly description: string;
|
|
61
|
+
readonly inputSchema: JsonSchemaObject;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A tool name both the MCP protocol and the agent CLI accept.
|
|
66
|
+
*
|
|
67
|
+
* Conservative on purpose: the name is concatenated into `mcp__{server}__{name}`, so a name carrying
|
|
68
|
+
* the separator would produce a tool the agent addresses ambiguously — and the failure would look
|
|
69
|
+
* like the wrong tool being called rather than like a bad name.
|
|
70
|
+
*/
|
|
71
|
+
const NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
72
|
+
|
|
73
|
+
export function isUsableToolName(name: string): boolean {
|
|
74
|
+
return NAME_PATTERN.test(name) && !name.includes('__');
|
|
75
|
+
}
|
package/src/mcp/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
|
|
11
|
+
export type { ZodRawShape } from './schema.js';
|
|
12
|
+
export { toRawShape, toZod } from './schema.js';
|
|
13
|
+
|
|
14
|
+
export type {
|
|
15
|
+
McpServerOptions,
|
|
16
|
+
ToolCall,
|
|
17
|
+
ToolCallIdentity,
|
|
18
|
+
ToolInvoker,
|
|
19
|
+
ToolPlan,
|
|
20
|
+
ToolResponse,
|
|
21
|
+
ToolResult,
|
|
22
|
+
} from './server.js';
|
|
23
|
+
export { planTools } from './server.js';
|