@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,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a decision about a tool call is, and how an unrecognised one is read.
|
|
3
|
+
*
|
|
4
|
+
* The shape is the SDK's `PermissionResult`, field for field. Not a shape of this package's own
|
|
5
|
+
* that happens to mean the same thing: the host's own local gate returns the same type, and a
|
|
6
|
+
* `canUseTool` implementation would return it directly, so a second spelling would need a
|
|
7
|
+
* translation in the one place a translation buys nothing. The SDK's word wins, exactly.
|
|
8
|
+
*
|
|
9
|
+
* The SDK documentation page names `PermissionResultDeny`, which the shipped `sdk.d.ts` does not
|
|
10
|
+
* declare (`grep -c PermissionResultDeny` returns 0). The docs page additionally states
|
|
11
|
+
* `{allow: false, reason}`, which does not exist either. Anything written that way fails to
|
|
12
|
+
* compile or, worse, type-widens.
|
|
13
|
+
*
|
|
14
|
+
* A decider returns `unknown`, deliberately. A decision arriving from a controller was never seen
|
|
15
|
+
* by this build's compiler — it is a value off a wire, from a peer that may be newer than this host.
|
|
16
|
+
* Typing the decider as returning a `Decision` would make the compiler assert a fact only the
|
|
17
|
+
* runtime can establish, and the unknown-decision rule below exists precisely because that fact is
|
|
18
|
+
* sometimes false. So the type says `unknown` and `readDecision` is the total function that narrows
|
|
19
|
+
* it — the honesty is in the signature, not in a comment.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What a decision about one tool call says.
|
|
24
|
+
*
|
|
25
|
+
* `allow` may rewrite the call's arguments through `updatedInput`. The model is never told that
|
|
26
|
+
* happened — observed on a real session — so a rewrite is invisible from the transcript's side.
|
|
27
|
+
*/
|
|
28
|
+
export type Decision =
|
|
29
|
+
| { readonly behavior: 'allow'; readonly updatedInput?: Record<string, unknown> }
|
|
30
|
+
| { readonly behavior: 'deny'; readonly message: string; readonly interrupt?: boolean };
|
|
31
|
+
|
|
32
|
+
/** What a decider is asked about. Everything the hook knows, in this package's own terms. */
|
|
33
|
+
export interface DecisionRequest {
|
|
34
|
+
readonly toolName: string;
|
|
35
|
+
readonly toolUseId: string;
|
|
36
|
+
readonly toolInput: unknown;
|
|
37
|
+
/**
|
|
38
|
+
* The agent's own session id, read off the hook input. Not the controller's handle.
|
|
39
|
+
*
|
|
40
|
+
* Read `sessionKey` below before using this to correlate anything. This value is minted by the
|
|
41
|
+
* agent at `system/init`; every frame on the link is keyed by the controller's handle instead. So
|
|
42
|
+
* a decision request identified only by this arrives naming an id the controller may never have
|
|
43
|
+
* seen — a session that dies during start-up never mints one at all, and the first transitions of
|
|
44
|
+
* every session carry `null` here. Correlating on it is a correlation that silently fails exactly
|
|
45
|
+
* when the session is in trouble, which is when a decision matters most.
|
|
46
|
+
*/
|
|
47
|
+
readonly sessionId: string;
|
|
48
|
+
/**
|
|
49
|
+
* The controller's handle — the same string every wire frame for this session is keyed by.
|
|
50
|
+
*
|
|
51
|
+
* This is the field to correlate on, and it exists because nothing else here could. The gate is
|
|
52
|
+
* the second transport: it is an ordinary HTTP POST to a URL nothing on the wire announces, so the
|
|
53
|
+
* body is the only place the two transports can be tied together. Without it a controller has to
|
|
54
|
+
* build an agent-id index and answer non-2xx for any id it has not seen yet.
|
|
55
|
+
*
|
|
56
|
+
* The host supplies it; it is never read from the agent's input, because the agent has no idea
|
|
57
|
+
* what its controller calls it.
|
|
58
|
+
*/
|
|
59
|
+
readonly sessionKey: string;
|
|
60
|
+
readonly cwd: string;
|
|
61
|
+
/**
|
|
62
|
+
* The subagent this call came from, or null on the main thread.
|
|
63
|
+
*
|
|
64
|
+
* `agent_id` is the field that distinguishes a subagent call — `agent_type` is also present on the
|
|
65
|
+
* main thread of a session started with `--agent`, so branching on the type alone would read a
|
|
66
|
+
* main-thread call as a subagent one.
|
|
67
|
+
*/
|
|
68
|
+
readonly agentId: string | null;
|
|
69
|
+
readonly agentType: string | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Answers one tool call. Returns `unknown` — see this file's header.
|
|
74
|
+
*
|
|
75
|
+
* The `signal` aborts when the surrounding turn is cancelled. A decider that reaches a controller
|
|
76
|
+
* passes it through so a cancelled turn does not leave a request in flight.
|
|
77
|
+
*/
|
|
78
|
+
export type Decider = (request: DecisionRequest, signal: AbortSignal) => Promise<unknown>;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A decision read from an untrusted value: either one this build understands, or the raw payload.
|
|
82
|
+
*
|
|
83
|
+
* The raw payload is preserved rather than discarded. A host that silently drops what it did not
|
|
84
|
+
* understand makes a controller-side bug invisible on the only side that could have seen it — the
|
|
85
|
+
* controller believes it answered, the tool is blocked, and nothing anywhere says why.
|
|
86
|
+
*/
|
|
87
|
+
export type DecisionReading =
|
|
88
|
+
| { readonly recognised: true; readonly decision: Decision }
|
|
89
|
+
| { readonly recognised: false; readonly raw: string };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* How much of an unrecognised payload is carried into the trace.
|
|
93
|
+
*
|
|
94
|
+
* Bounded because the value goes into a transition that is retained and may cross the wire, and an
|
|
95
|
+
* unbounded field there is a way for a peer to fill this host's memory. Truncation is marked, so a
|
|
96
|
+
* reader can tell a short payload from a clipped one.
|
|
97
|
+
*/
|
|
98
|
+
const RAW_PAYLOAD_LIMIT = 512;
|
|
99
|
+
|
|
100
|
+
/** The raw value as one line, bounded and marked. Never throws — a cyclic value is still evidence. */
|
|
101
|
+
export function describeRaw(raw: unknown): string {
|
|
102
|
+
let text: string;
|
|
103
|
+
try {
|
|
104
|
+
text = raw === undefined ? 'undefined' : JSON.stringify(raw);
|
|
105
|
+
if (text === undefined) text = String(raw);
|
|
106
|
+
} catch {
|
|
107
|
+
// A cyclic or otherwise unserialisable payload still tells the reader its type, which is more
|
|
108
|
+
// than nothing and is the whole point of preserving it.
|
|
109
|
+
text = `[unserialisable ${typeof raw}]`;
|
|
110
|
+
}
|
|
111
|
+
return text.length > RAW_PAYLOAD_LIMIT ? `${text.slice(0, RAW_PAYLOAD_LIMIT)}…[truncated]` : text;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Read an untrusted value as a decision. Total: every input produces a reading.
|
|
116
|
+
*
|
|
117
|
+
* A value this build has never seen is never an allow. A controller running ahead of a host, a
|
|
118
|
+
* decision tier added later, a rolled-back deploy — each produces a `behavior` this code does not
|
|
119
|
+
* know, and the only safe reading of "I do not understand your answer" is that no answer was given.
|
|
120
|
+
* Borrowed from the Agent Client Protocol's `RequestPermissionOutcome::Other`, whose rule is that an
|
|
121
|
+
* agent which does not understand an outcome MUST NOT treat it as approval.
|
|
122
|
+
*
|
|
123
|
+
* It is refused here, by the gate, rather than at the transport. A strict codec could reject the
|
|
124
|
+
* frame instead — but then the gate never runs, and the tool's fate depends on a parse error rather
|
|
125
|
+
* than on a policy act with a receipt.
|
|
126
|
+
*/
|
|
127
|
+
export function readDecision(raw: unknown): DecisionReading {
|
|
128
|
+
if (typeof raw !== 'object' || raw === null) return { recognised: false, raw: describeRaw(raw) };
|
|
129
|
+
|
|
130
|
+
const candidate = raw as {
|
|
131
|
+
behavior?: unknown;
|
|
132
|
+
message?: unknown;
|
|
133
|
+
updatedInput?: unknown;
|
|
134
|
+
interrupt?: unknown;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (candidate.behavior === 'allow') {
|
|
138
|
+
const updatedInput = candidate.updatedInput;
|
|
139
|
+
// An `allow` whose updatedInput is present but not an object is not an allow with the field
|
|
140
|
+
// dropped: it is a decision this build cannot carry out, and carrying out the half it
|
|
141
|
+
// understood would run the tool with arguments nobody approved.
|
|
142
|
+
if (updatedInput === undefined) return { recognised: true, decision: { behavior: 'allow' } };
|
|
143
|
+
if (typeof updatedInput !== 'object' || updatedInput === null || Array.isArray(updatedInput)) {
|
|
144
|
+
return { recognised: false, raw: describeRaw(raw) };
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
recognised: true,
|
|
148
|
+
decision: { behavior: 'allow', updatedInput: updatedInput as Record<string, unknown> },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (candidate.behavior === 'deny') {
|
|
153
|
+
// `message` is required on a deny by the SDK's own type. A deny without one would reach the
|
|
154
|
+
// model as a blocked call with no stated reason, which is the silent refusal this package
|
|
155
|
+
// forbids everywhere else — so it is unrecognised rather than quietly given a default.
|
|
156
|
+
if (typeof candidate.message !== 'string' || candidate.message.length === 0) {
|
|
157
|
+
return { recognised: false, raw: describeRaw(raw) };
|
|
158
|
+
}
|
|
159
|
+
const interrupt = candidate.interrupt;
|
|
160
|
+
if (interrupt !== undefined && typeof interrupt !== 'boolean')
|
|
161
|
+
return { recognised: false, raw: describeRaw(raw) };
|
|
162
|
+
return {
|
|
163
|
+
recognised: true,
|
|
164
|
+
decision: {
|
|
165
|
+
behavior: 'deny',
|
|
166
|
+
message: candidate.message,
|
|
167
|
+
...(interrupt === undefined ? {} : { interrupt }),
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { recognised: false, raw: describeRaw(raw) };
|
|
173
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asking a controller. The one place this package makes an outbound decision request.
|
|
3
|
+
*
|
|
4
|
+
* The status is discriminated before the body is read, and that order is the whole file. A client
|
|
5
|
+
* that parses first and branches second turns a 500 carrying a perfectly valid problem-details body
|
|
6
|
+
* into an object with no `allow` field, whose falsy value renders as a refusal somebody made. That
|
|
7
|
+
* is a controller outage impersonating a human decision — nobody had said no, and the trace said
|
|
8
|
+
* somebody had. Reading `status` first makes that shape unwritable.
|
|
9
|
+
*
|
|
10
|
+
* The transport is injected and structural. A minimal `{status, text()}` shape rather than the DOM
|
|
11
|
+
* `Response` type: the global `fetch` satisfies it, a test double satisfies it without a server, and
|
|
12
|
+
* nothing here acquires a dependency on a browser lib for a package that runs on a server.
|
|
13
|
+
*/
|
|
14
|
+
import { certificateRemedy, describeFailure, isCertificateRefusal } from '../core/failure.js';
|
|
15
|
+
import type { ControllerCredential } from '../control/credential.js';
|
|
16
|
+
import type { Decider, DecisionRequest } from './decision.js';
|
|
17
|
+
|
|
18
|
+
/** Just enough of a response to decide on it. `fetch`'s own `Response` satisfies this. */
|
|
19
|
+
export interface EscalationResponse {
|
|
20
|
+
readonly status: number;
|
|
21
|
+
text(): Promise<string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type EscalationTransport = (
|
|
25
|
+
url: string,
|
|
26
|
+
init: {
|
|
27
|
+
readonly method: string;
|
|
28
|
+
readonly headers: Record<string, string>;
|
|
29
|
+
readonly body: string;
|
|
30
|
+
readonly signal: AbortSignal;
|
|
31
|
+
},
|
|
32
|
+
) => Promise<EscalationResponse>;
|
|
33
|
+
|
|
34
|
+
export interface EscalationOptions {
|
|
35
|
+
/** Where the controller answers. Supplied by the embedder; never derived here. */
|
|
36
|
+
readonly url: string;
|
|
37
|
+
readonly transport: EscalationTransport;
|
|
38
|
+
/** Sent verbatim, on every request. For a scheme whose value does not change. */
|
|
39
|
+
readonly headers?: Record<string, string>;
|
|
40
|
+
/**
|
|
41
|
+
* The credential this host presents on each decision request. Resolved per request.
|
|
42
|
+
*
|
|
43
|
+
* Why it is a credential and not a header. The static `headers` above cannot carry a bearer
|
|
44
|
+
* token: a token is refreshed on a schedule this module does not know, so a value captured once
|
|
45
|
+
* would be presented for the life of the host and start failing silently at the first expiry —
|
|
46
|
+
* as an outage, on every tool call, which is the failure shape this whole file is written against.
|
|
47
|
+
* The same seam the link uses is asked again here, and it hands back a live value.
|
|
48
|
+
*
|
|
49
|
+
* Its failure posture is the opposite of the link's, deliberately. The link connects with no
|
|
50
|
+
* headers when its credential refuses — a stated decision, on the ground that a version without
|
|
51
|
+
* identity is better than one pretending to have a scheme. That reasoning does not transfer here.
|
|
52
|
+
* The link carries observations; this endpoint decides whether a tool runs. An unauthenticated
|
|
53
|
+
* request to it is an unauthenticated permission decision, and anyone who can reach the URL can
|
|
54
|
+
* answer for the agent. So a credential that refuses is an outage and the tool does not run — the
|
|
55
|
+
* gate's own invariant, applied to the gate's own transport. Two transports, two postures, and the
|
|
56
|
+
* difference is what each one can be used to do.
|
|
57
|
+
*
|
|
58
|
+
* Absent means no credential is presented, which is the previous behaviour and stays available for
|
|
59
|
+
* an embedder whose endpoint is reachable only from inside its own network.
|
|
60
|
+
*
|
|
61
|
+
* The precondition: a credential that refuses is not a quieter credential — it is an outage on
|
|
62
|
+
* every tool call, so the option must be omitted rather than filled with a placeholder when there
|
|
63
|
+
* is no identity. `bin/periscope.ts` does exactly that, and the composition is the contract.
|
|
64
|
+
*
|
|
65
|
+
* What this package can present is narrower than what a controller may require. `identity/`
|
|
66
|
+
* implements the authorization-code, refresh-token and device-code grants — every one of which
|
|
67
|
+
* mints a delegated user token. There is no client-credentials grant here, so this host cannot
|
|
68
|
+
* obtain an app-only token at all, and `Authorization` carries one header, so it cannot present a
|
|
69
|
+
* second scheme selector alongside the bearer. A controller that admits only a machine app role
|
|
70
|
+
* therefore refuses both postures — a configured host with a user token and an unconfigured host
|
|
71
|
+
* with none — and it refuses them at the door, fail-closed. Nothing in this file grants this host
|
|
72
|
+
* a machine identity, and a reader should not infer from the presence of this option that one
|
|
73
|
+
* exists.
|
|
74
|
+
*/
|
|
75
|
+
readonly credential?: ControllerCredential;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Thrown for every way the ask failed, as distinct from a decision that said no.
|
|
80
|
+
*
|
|
81
|
+
* It is an error rather than a returned value because the gate's contract is that the decider
|
|
82
|
+
* returns whatever the controller said; "the controller said nothing" is not something it said.
|
|
83
|
+
* The gate turns this into `permission-decision-unavailable` — an outage, never a denial.
|
|
84
|
+
*/
|
|
85
|
+
export class EscalationUnavailable extends Error {
|
|
86
|
+
constructor(detail: string) {
|
|
87
|
+
super(detail);
|
|
88
|
+
this.name = 'EscalationUnavailable';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A decider that asks a controller over HTTP.
|
|
94
|
+
*
|
|
95
|
+
* Returns the parsed body as `unknown`: reading it as a decision is `readDecision`'s job, and a
|
|
96
|
+
* well-formed answer this build does not recognise must reach the gate to be refused by the gate.
|
|
97
|
+
*
|
|
98
|
+
* The two failure kinds are split on purpose, and the split is not obvious.
|
|
99
|
+
* - A non-2xx, a transport error, or a body that is not JSON is an outage. The controller did not
|
|
100
|
+
* answer, or answered something that is not an answer. A controller-side fix.
|
|
101
|
+
* - Valid JSON that is not a decision this build knows is not handled here. It is returned intact
|
|
102
|
+
* and the gate refuses it as unrecognised. A controller-version fix.
|
|
103
|
+
* Malformed JSON is an outage rather than an unrecognised decision because version skew produces a
|
|
104
|
+
* *different* decision, never a broken one.
|
|
105
|
+
*/
|
|
106
|
+
export function escalatingDecider(options: EscalationOptions): Decider {
|
|
107
|
+
return async (request: DecisionRequest, signal: AbortSignal): Promise<unknown> => {
|
|
108
|
+
// ---- The credential, before the request exists. A refusal here never becomes a request: an
|
|
109
|
+
// unauthenticated ask is not a quieter version of an authenticated one, it is a different act.
|
|
110
|
+
const authorization: Record<string, string> = {};
|
|
111
|
+
if (options.credential !== undefined) {
|
|
112
|
+
const authorized = await options.credential.authorize();
|
|
113
|
+
if (!authorized.ok) {
|
|
114
|
+
throw new EscalationUnavailable(
|
|
115
|
+
`this host has no credential to present on a decision request for ${request.toolName} ` +
|
|
116
|
+
`(${authorized.refusal.reason}: ${authorized.refusal.detail}). The request was NOT sent: ` +
|
|
117
|
+
`an unauthenticated permission decision is one anybody who can reach the endpoint could ` +
|
|
118
|
+
`answer, so the gate treats this as an outage and the tool does not run`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
authorization[authorized.value.header] = authorized.value.value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let response: EscalationResponse;
|
|
125
|
+
try {
|
|
126
|
+
response = await options.transport(options.url, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'content-type': 'application/json', ...(options.headers ?? {}), ...authorization },
|
|
129
|
+
body: JSON.stringify(request),
|
|
130
|
+
signal,
|
|
131
|
+
});
|
|
132
|
+
} catch (error) {
|
|
133
|
+
const why = describeFailure(error);
|
|
134
|
+
throw new EscalationUnavailable(
|
|
135
|
+
isCertificateRefusal(error)
|
|
136
|
+
? `the decision request to ${options.url} was refused at TLS (${why}). ${certificateRemedy('the decision endpoint')}`
|
|
137
|
+
: `the decision request to ${options.url} did not complete: ${why}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---- Status first. Nothing below this line has read the body. ----
|
|
142
|
+
if (response.status < 200 || response.status >= 300) {
|
|
143
|
+
throw new EscalationUnavailable(
|
|
144
|
+
`the controller answered ${response.status} to a decision request for ${request.toolName}; ` +
|
|
145
|
+
'the body was not read, because a body that parses is not an answer that was given',
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let body: string;
|
|
150
|
+
try {
|
|
151
|
+
body = await response.text();
|
|
152
|
+
} catch (error) {
|
|
153
|
+
throw new EscalationUnavailable(`the decision response body could not be read: ${String(error)}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
return JSON.parse(body) as unknown;
|
|
158
|
+
} catch {
|
|
159
|
+
throw new EscalationUnavailable(
|
|
160
|
+
`the controller answered ${response.status} with a body that is not JSON (${body.length} bytes)`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|