@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.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/SECURITY.md +315 -0
- package/dist/bin/command.d.ts +97 -0
- package/dist/bin/command.js +141 -0
- package/dist/bin/config.d.ts +7 -0
- package/dist/bin/config.js +90 -0
- package/dist/bin/login.d.ts +48 -0
- package/dist/bin/login.js +82 -0
- package/dist/bin/main.d.ts +33 -0
- package/dist/bin/main.js +173 -0
- package/dist/bin/pair.d.ts +28 -0
- package/dist/bin/pair.js +198 -0
- package/dist/bin/periscope.d.ts +3 -0
- package/dist/bin/periscope.js +4 -0
- package/dist/bin/posture.d.ts +64 -0
- package/dist/bin/posture.js +157 -0
- package/dist/bin/reconfigure.d.ts +64 -0
- package/dist/bin/reconfigure.js +195 -0
- package/dist/bin/serve.d.ts +103 -0
- package/dist/bin/serve.js +441 -0
- package/dist/bin/status.d.ts +11 -0
- package/dist/bin/status.js +49 -0
- package/dist/bin/workspaces.d.ts +109 -0
- package/dist/bin/workspaces.js +215 -0
- package/dist/control/backoff.d.ts +25 -0
- package/dist/control/backoff.js +20 -0
- package/dist/control/codec.d.ts +15 -0
- package/dist/control/codec.js +501 -0
- package/dist/control/credential.d.ts +23 -0
- package/dist/control/credential.js +8 -0
- package/dist/control/frames.d.ts +1180 -0
- package/dist/control/frames.js +441 -0
- package/dist/control/link-state.d.ts +32 -0
- package/dist/control/link-state.js +39 -0
- package/dist/control/link.d.ts +109 -0
- package/dist/control/link.js +736 -0
- package/dist/control/queue.d.ts +110 -0
- package/dist/control/queue.js +174 -0
- package/dist/control/seq.d.ts +49 -0
- package/dist/control/seq.js +52 -0
- package/dist/control/stream-routing.d.ts +200 -0
- package/dist/control/stream-routing.js +167 -0
- package/dist/control/stream.d.ts +43 -0
- package/dist/control/stream.js +64 -0
- package/dist/core/async-queue.d.ts +26 -0
- package/dist/core/async-queue.js +63 -0
- package/dist/core/failure.d.ts +22 -0
- package/dist/core/failure.js +53 -0
- package/dist/core/index.d.ts +18 -0
- package/dist/core/index.js +7 -0
- package/dist/core/keyed-turns.d.ts +28 -0
- package/dist/core/keyed-turns.js +44 -0
- package/dist/core/paths.d.ts +25 -0
- package/dist/core/paths.js +77 -0
- package/dist/core/refusal.d.ts +43 -0
- package/dist/core/refusal.js +399 -0
- package/dist/core/result.d.ts +22 -0
- package/dist/core/result.js +16 -0
- package/dist/core/time.d.ts +19 -0
- package/dist/core/time.js +25 -0
- package/dist/core/vocab.d.ts +23 -0
- package/dist/core/vocab.js +37 -0
- package/dist/core/workspace-id.d.ts +12 -0
- package/dist/core/workspace-id.js +12 -0
- package/dist/gate/command.d.ts +136 -0
- package/dist/gate/command.js +551 -0
- package/dist/gate/decision.d.ts +111 -0
- package/dist/gate/decision.js +97 -0
- package/dist/gate/escalate.d.ts +82 -0
- package/dist/gate/escalate.js +92 -0
- package/dist/gate/gate.d.ts +173 -0
- package/dist/gate/gate.js +417 -0
- package/dist/gate/index.d.ts +39 -0
- package/dist/gate/index.js +31 -0
- package/dist/gate/jail.d.ts +92 -0
- package/dist/gate/jail.js +131 -0
- package/dist/gate/local.d.ts +69 -0
- package/dist/gate/local.js +51 -0
- package/dist/gate/outcome.d.ts +72 -0
- package/dist/gate/outcome.js +91 -0
- package/dist/gate/shell.d.ts +36 -0
- package/dist/gate/shell.js +226 -0
- package/dist/host/agent-process.d.ts +452 -0
- package/dist/host/agent-process.js +382 -0
- package/dist/host/bulk-post.d.ts +44 -0
- package/dist/host/bulk-post.js +114 -0
- package/dist/host/claude-transcripts.d.ts +84 -0
- package/dist/host/claude-transcripts.js +339 -0
- package/dist/host/config-file.d.ts +60 -0
- package/dist/host/config-file.js +216 -0
- package/dist/host/git-facts.d.ts +7 -0
- package/dist/host/git-facts.js +106 -0
- package/dist/host/hooks.d.ts +56 -0
- package/dist/host/hooks.js +75 -0
- package/dist/host/host.d.ts +356 -0
- package/dist/host/host.js +1294 -0
- package/dist/host/index.d.ts +35 -0
- package/dist/host/index.js +22 -0
- package/dist/host/link-state-file.d.ts +18 -0
- package/dist/host/link-state-file.js +66 -0
- package/dist/host/loopback.d.ts +38 -0
- package/dist/host/loopback.js +122 -0
- package/dist/host/machine.d.ts +9 -0
- package/dist/host/machine.js +19 -0
- package/dist/host/mcp-server.d.ts +11 -0
- package/dist/host/mcp-server.js +48 -0
- package/dist/host/package-facts.d.ts +3 -0
- package/dist/host/package-facts.js +26 -0
- package/dist/host/paired-credential-store.d.ts +32 -0
- package/dist/host/paired-credential-store.js +112 -0
- package/dist/host/paths.d.ts +80 -0
- package/dist/host/paths.js +165 -0
- package/dist/host/repository-read.d.ts +33 -0
- package/dist/host/repository-read.js +201 -0
- package/dist/host/session-store.d.ts +53 -0
- package/dist/host/session-store.js +100 -0
- package/dist/host/sign-in.d.ts +90 -0
- package/dist/host/sign-in.js +239 -0
- package/dist/host/telemetry.d.ts +42 -0
- package/dist/host/telemetry.js +74 -0
- package/dist/host/token-cache.d.ts +62 -0
- package/dist/host/token-cache.js +185 -0
- package/dist/host/transcript-fs.d.ts +4 -0
- package/dist/host/transcript-fs.js +104 -0
- package/dist/host/wire-request.d.ts +80 -0
- package/dist/host/wire-request.js +196 -0
- package/dist/host/workspace-fs.d.ts +14 -0
- package/dist/host/workspace-fs.js +84 -0
- package/dist/host/workspace-trust.d.ts +23 -0
- package/dist/host/workspace-trust.js +80 -0
- package/dist/identity/authorize.d.ts +59 -0
- package/dist/identity/authorize.js +72 -0
- package/dist/identity/config.d.ts +98 -0
- package/dist/identity/config.js +130 -0
- package/dist/identity/credential.d.ts +91 -0
- package/dist/identity/credential.js +76 -0
- package/dist/identity/device-code.d.ts +115 -0
- package/dist/identity/device-code.js +134 -0
- package/dist/identity/index.d.ts +33 -0
- package/dist/identity/index.js +10 -0
- package/dist/identity/mode.d.ts +106 -0
- package/dist/identity/mode.js +69 -0
- package/dist/identity/paired-credential.d.ts +52 -0
- package/dist/identity/paired-credential.js +43 -0
- package/dist/identity/pkce.d.ts +50 -0
- package/dist/identity/pkce.js +94 -0
- package/dist/identity/store.d.ts +43 -0
- package/dist/identity/store.js +43 -0
- package/dist/identity/token.d.ts +58 -0
- package/dist/identity/token.js +149 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +53 -0
- package/dist/mcp/descriptor.d.ts +59 -0
- package/dist/mcp/descriptor.js +14 -0
- package/dist/mcp/index.d.ts +14 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/schema.d.ts +37 -0
- package/dist/mcp/schema.js +175 -0
- package/dist/mcp/server.d.ts +107 -0
- package/dist/mcp/server.js +66 -0
- package/dist/persistence/entry.d.ts +74 -0
- package/dist/persistence/entry.js +105 -0
- package/dist/persistence/index.d.ts +22 -0
- package/dist/persistence/index.js +8 -0
- package/dist/persistence/key.d.ts +46 -0
- package/dist/persistence/key.js +33 -0
- package/dist/persistence/mirror.d.ts +71 -0
- package/dist/persistence/mirror.js +57 -0
- package/dist/persistence/receipt.d.ts +111 -0
- package/dist/persistence/receipt.js +85 -0
- package/dist/persistence/retention.d.ts +68 -0
- package/dist/persistence/retention.js +68 -0
- package/dist/persistence/store.d.ts +106 -0
- package/dist/persistence/store.js +86 -0
- package/dist/persistence/transition-log.d.ts +73 -0
- package/dist/persistence/transition-log.js +133 -0
- package/dist/protocol.d.ts +27 -0
- package/dist/protocol.js +12 -0
- package/dist/sessions/index.d.ts +8 -0
- package/dist/sessions/index.js +4 -0
- package/dist/sessions/registry.d.ts +186 -0
- package/dist/sessions/registry.js +190 -0
- package/dist/sessions/session.d.ts +178 -0
- package/dist/sessions/session.js +288 -0
- package/dist/sessions/spawn-env.d.ts +88 -0
- package/dist/sessions/spawn-env.js +277 -0
- package/dist/state/coverage.d.ts +308 -0
- package/dist/state/coverage.js +315 -0
- package/dist/state/index.d.ts +12 -0
- package/dist/state/index.js +7 -0
- package/dist/state/machine.d.ts +112 -0
- package/dist/state/machine.js +237 -0
- package/dist/state/model.d.ts +233 -0
- package/dist/state/model.js +214 -0
- package/dist/state/observer.d.ts +55 -0
- package/dist/state/observer.js +413 -0
- package/dist/state/reporter.d.ts +45 -0
- package/dist/state/reporter.js +35 -0
- package/dist/state/store.d.ts +56 -0
- package/dist/state/store.js +120 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.js +2 -0
- package/dist/telemetry/usage.d.ts +78 -0
- package/dist/telemetry/usage.js +69 -0
- package/dist/workspace/git-worktree.d.ts +150 -0
- package/dist/workspace/git-worktree.js +417 -0
- package/dist/workspace/index.d.ts +9 -0
- package/dist/workspace/index.js +4 -0
- package/dist/workspace/plain-dir.d.ts +34 -0
- package/dist/workspace/plain-dir.js +90 -0
- package/dist/workspace/provider.d.ts +152 -0
- package/dist/workspace/provider.js +2 -0
- package/dist/workspace/worktree-porcelain.d.ts +29 -0
- package/dist/workspace/worktree-porcelain.js +100 -0
- package/docs/architecture.md +277 -0
- package/docs/configuration.md +187 -0
- package/docs/gate.md +219 -0
- package/docs/identity.md +107 -0
- package/docs/protocol.md +501 -0
- package/docs/state-machine.md +160 -0
- package/examples/README.md +141 -0
- package/examples/minimal-controller/controller.ts +38 -0
- package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
- package/examples/parallel-run-proof/run.ts +850 -0
- package/examples/test-controller/controller.ts +655 -0
- package/examples/test-controller/serve.ts +29 -0
- package/examples/tsconfig.json +30 -0
- package/package.json +98 -0
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parallel-run proof: a real agent, driven end-to-end by a composed Periscope host against the
|
|
3
|
+
* thin test controller.
|
|
4
|
+
*
|
|
5
|
+
* Run it: node examples/parallel-run-proof/run.ts
|
|
6
|
+
* It costs real money and needs the same ambient credentials the live tests use.
|
|
7
|
+
*
|
|
8
|
+
* Every assertion is observed or it is reported `not exercised` with its reason. Nothing here
|
|
9
|
+
* concludes a property from reading the code: three of these fail invisibly in production, which is
|
|
10
|
+
* exactly why a reasoned pass is worth less than an honest blank.
|
|
11
|
+
*
|
|
12
|
+
* It uses the published surface only: `@naswerks/periscope` and `@naswerks/periscope/protocol`, by package name. A
|
|
13
|
+
* private hook added to make the proof pass would invalidate the proof.
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
import type { FrameSink, HostEvent, SessionTransition, ToolCall } from '@naswerks/periscope';
|
|
21
|
+
import {
|
|
22
|
+
GitWorktreeProvider,
|
|
23
|
+
PeriscopeHost,
|
|
24
|
+
SessionRegistry,
|
|
25
|
+
composeSession,
|
|
26
|
+
ok,
|
|
27
|
+
createJsonlStore,
|
|
28
|
+
credentialPaths,
|
|
29
|
+
nodeCommandEffects,
|
|
30
|
+
nodeStoreEffects,
|
|
31
|
+
nodeWorkspaceEffects,
|
|
32
|
+
asSessionStore,
|
|
33
|
+
baselineAnchor,
|
|
34
|
+
resolveReceipt,
|
|
35
|
+
compactionCount,
|
|
36
|
+
sessionNew,
|
|
37
|
+
} from '@naswerks/periscope';
|
|
38
|
+
|
|
39
|
+
import { TestController } from '../test-controller/controller.ts';
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
interface Outcome {
|
|
44
|
+
readonly n: number;
|
|
45
|
+
readonly title: string;
|
|
46
|
+
status: 'OBSERVED' | 'NOT EXERCISED' | 'FAILED';
|
|
47
|
+
detail: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const outcomes: Outcome[] = [];
|
|
51
|
+
const lines: string[] = [];
|
|
52
|
+
|
|
53
|
+
/** What this proof actually cost, summed from the agents' own `result` messages. Never estimated. */
|
|
54
|
+
let spentUsd = 0;
|
|
55
|
+
|
|
56
|
+
const say = (line: string): void => {
|
|
57
|
+
lines.push(line);
|
|
58
|
+
process.stdout.write(`${line}\n`);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const record = (n: number, title: string, status: Outcome['status'], detail: string): void => {
|
|
62
|
+
outcomes.push({ n, title, status, detail });
|
|
63
|
+
say(`\n=== ASSERTION ${n} — ${status} — ${title}\n ${detail}\n`);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
67
|
+
|
|
68
|
+
/** Wait until a predicate holds, or give up. Returns whether it held. */
|
|
69
|
+
async function until(predicate: () => boolean, timeoutMs: number, stepMs = 250): Promise<boolean> {
|
|
70
|
+
const deadline = Date.now() + timeoutMs;
|
|
71
|
+
while (Date.now() < deadline) {
|
|
72
|
+
if (predicate()) return true;
|
|
73
|
+
await wait(stepMs);
|
|
74
|
+
}
|
|
75
|
+
return predicate();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Wait for the turn to end, rather than for the side effect a turn was supposed to have.
|
|
80
|
+
*
|
|
81
|
+
* Waiting on the side effect means a turn that ran and did nothing is indistinguishable from a turn
|
|
82
|
+
* still running, so a real result ("the agent tried and was refused") arrives as a timeout. The
|
|
83
|
+
* turn boundary is observable on the wire (`sdk-message/result`), so it is what this waits for; the
|
|
84
|
+
* side effect is then checked rather than waited for.
|
|
85
|
+
*/
|
|
86
|
+
async function turnEnds(controller: TestController, timeoutMs = 240_000): Promise<boolean> {
|
|
87
|
+
const before = controller.transitions().filter((one) => one.cause.event === 'result').length;
|
|
88
|
+
return until(
|
|
89
|
+
() => controller.transitions().filter((one) => one.cause.event === 'result').length > before,
|
|
90
|
+
timeoutMs,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Send a turn and wait for that turn to end.
|
|
96
|
+
*
|
|
97
|
+
* It waits for the session to go idle first, and that line is the whole function. Counting results
|
|
98
|
+
* and sending in the same breath has a race: the previous turn's `result` can still be in flight,
|
|
99
|
+
* arrive a moment later, and satisfy the wait for a turn that has not started, so a deny that did
|
|
100
|
+
* happen and is in the trace reads as "no denial was recorded" because the trace was read before
|
|
101
|
+
* the turn ran. A false negative from an instrument is the same class of defect as a false green.
|
|
102
|
+
*/
|
|
103
|
+
async function runTurn(
|
|
104
|
+
controller: TestController,
|
|
105
|
+
key: string,
|
|
106
|
+
text: string,
|
|
107
|
+
timeoutMs = 240_000,
|
|
108
|
+
): Promise<boolean> {
|
|
109
|
+
await until(() => (controller.transitions().at(-1)?.to ?? '') === 'idle', 90_000);
|
|
110
|
+
const before = controller.transitions().filter((one) => one.cause.event === 'result').length;
|
|
111
|
+
controller.send(key, { kind: 'session_prompt', text });
|
|
112
|
+
return until(
|
|
113
|
+
() => controller.transitions().filter((one) => one.cause.event === 'result').length > before,
|
|
114
|
+
timeoutMs,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A scratch repository OUTSIDE any checkout, so nothing this proof does can touch a real one. */
|
|
119
|
+
function scratchRepository(): string {
|
|
120
|
+
const root = mkdtempSync(join(tmpdir(), 'periscope-proof-repo-'));
|
|
121
|
+
const git = (...args: string[]): void => void execFileSync('git', args, { cwd: root, windowsHide: true });
|
|
122
|
+
git('init', '--initial-branch=main');
|
|
123
|
+
git('config', 'user.email', 'proof@example.invalid');
|
|
124
|
+
git('config', 'user.name', 'The Proof');
|
|
125
|
+
writeFileSync(join(root, 'README.md'), '# a scratch repository\n');
|
|
126
|
+
git('add', 'README.md');
|
|
127
|
+
git('commit', '-m', 'the first commit');
|
|
128
|
+
return root;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
const DENY_FILE = 'forbidden.txt';
|
|
134
|
+
const HOLD_TOOL = 'mcp__proof__slow';
|
|
135
|
+
|
|
136
|
+
async function main(): Promise<void> {
|
|
137
|
+
const startedAt = Date.now();
|
|
138
|
+
say(`PARALLEL-RUN PROOF — started ${new Date(startedAt).toISOString()}`);
|
|
139
|
+
say(`node ${process.version}`);
|
|
140
|
+
|
|
141
|
+
const repositoryRoot = scratchRepository();
|
|
142
|
+
const workspaceRoot = mkdtempSync(join(tmpdir(), 'periscope-proof-work-'));
|
|
143
|
+
const storeRoot = mkdtempSync(join(tmpdir(), 'periscope-proof-store-'));
|
|
144
|
+
say(`repository ${repositoryRoot}`);
|
|
145
|
+
say(`workspaces ${workspaceRoot}`);
|
|
146
|
+
|
|
147
|
+
await runTheTurns(repositoryRoot, workspaceRoot);
|
|
148
|
+
await runTheThrowingHandler(repositoryRoot, workspaceRoot);
|
|
149
|
+
await runOffline(repositoryRoot, workspaceRoot);
|
|
150
|
+
await runMirrorAndResume(workspaceRoot, storeRoot);
|
|
151
|
+
|
|
152
|
+
say(`\nfinished in ${Math.round((Date.now() - startedAt) / 1000)}s`);
|
|
153
|
+
say(`MEASURED SPEND: $${spentUsd.toFixed(4)} — summed from every turn's own result message`);
|
|
154
|
+
writeReport();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// The main run: assertions 1, 2, 3, 5, 7, 9, 10 and 12. Assertion 8 is `runMirrorAndResume`.
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
async function runTheTurns(repositoryRoot: string, workspaceRoot: string): Promise<void> {
|
|
162
|
+
say('\n--- THE MULTI-TURN RUN ------------------------------------------------');
|
|
163
|
+
|
|
164
|
+
let heldFor = 0;
|
|
165
|
+
const controller = new TestController({
|
|
166
|
+
render: (line) => say(line),
|
|
167
|
+
tools: {
|
|
168
|
+
note: (args) => `the note was recorded: ${typeof args['text'] === 'string' ? args['text'] : ''}`,
|
|
169
|
+
slow: () => 'the slow tool answered',
|
|
170
|
+
},
|
|
171
|
+
policy: async (ask) => {
|
|
172
|
+
// One deliberate DENY: a named file the agent is told to write and must not.
|
|
173
|
+
if (String((ask.toolInput as { file_path?: string } | null)?.file_path ?? '').includes(DENY_FILE)) {
|
|
174
|
+
return {
|
|
175
|
+
behavior: 'deny',
|
|
176
|
+
message: `${DENY_FILE} is off limits — the controller refuses this write`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// One deliberate HOLD, longer than a minute, then resolved: the hold, exercised.
|
|
180
|
+
if (ask.toolName === HOLD_TOOL) {
|
|
181
|
+
const began = Date.now();
|
|
182
|
+
await wait(65_000);
|
|
183
|
+
heldFor = Date.now() - began;
|
|
184
|
+
return { behavior: 'allow' };
|
|
185
|
+
}
|
|
186
|
+
return { behavior: 'allow' };
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
await controller.start();
|
|
190
|
+
|
|
191
|
+
const host = new PeriscopeHost({
|
|
192
|
+
controllerUrl: controller.controllerUrl,
|
|
193
|
+
hostId: 'proof-host',
|
|
194
|
+
decide: escalateTo(controller.decisionUrl),
|
|
195
|
+
protectedPaths: credentialPaths(process.env),
|
|
196
|
+
workspaces: new GitWorktreeProvider({
|
|
197
|
+
repositoryRoot,
|
|
198
|
+
workspaceRoot,
|
|
199
|
+
effects: nodeWorkspaceEffects,
|
|
200
|
+
commands: nodeCommandEffects(),
|
|
201
|
+
}),
|
|
202
|
+
tools: {
|
|
203
|
+
name: 'proof',
|
|
204
|
+
descriptors: [
|
|
205
|
+
{
|
|
206
|
+
name: 'note',
|
|
207
|
+
description: 'Records a short note with the controller and returns a confirmation.',
|
|
208
|
+
inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: 'slow',
|
|
212
|
+
description: 'A tool whose permission decision the controller deliberately takes its time over.',
|
|
213
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
invoke: invokerFor(controller.toolUrl),
|
|
217
|
+
},
|
|
218
|
+
// The host's deadline must outlast the deliberate 65-second hold, and the matcher's must
|
|
219
|
+
// outlast the host's — both stated rather than defaulted, because the default is 50 seconds.
|
|
220
|
+
gate: { decisionTimeoutMs: 150_000, holdAfterMs: 250, matcherTimeoutSeconds: 300 },
|
|
221
|
+
baseEnv: process.env,
|
|
222
|
+
homeDir: process.env['USERPROFILE'] ?? process.env['HOME'] ?? '',
|
|
223
|
+
report: (event) => reportOf(event),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
host.start();
|
|
227
|
+
await until(() => controller.connected, 15_000);
|
|
228
|
+
|
|
229
|
+
const key = 'proof-session-1';
|
|
230
|
+
controller.send(key, sessionNew(workspaceRoot));
|
|
231
|
+
const opened = await until(() => host.session(key).ok, 20_000);
|
|
232
|
+
if (!opened) {
|
|
233
|
+
record(1, 'the agent edits real files in a real worktree', 'FAILED', 'the session never opened');
|
|
234
|
+
await controller.stop();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const composed = host.session(key);
|
|
238
|
+
const cwd = composed.ok ? (composed.value.machine.where.cwd ?? '') : '';
|
|
239
|
+
say(`the session got the worktree ${cwd}`);
|
|
240
|
+
|
|
241
|
+
// -- turn 1: a real edit ---------------------------------------------------
|
|
242
|
+
controller.send(key, {
|
|
243
|
+
kind: 'session_prompt',
|
|
244
|
+
text:
|
|
245
|
+
'Use the Write tool once to create greeting.txt in the current directory containing exactly the ' +
|
|
246
|
+
'word pomegranate. Then stop.',
|
|
247
|
+
});
|
|
248
|
+
await turnEnds(controller);
|
|
249
|
+
|
|
250
|
+
const wrote = existsSync(join(cwd, 'greeting.txt'));
|
|
251
|
+
const allowed = controller.seen.some((one) => one.kind === 'answer' && one.text === 'Write -> allow');
|
|
252
|
+
record(
|
|
253
|
+
1,
|
|
254
|
+
'the agent edits real files in a real worktree',
|
|
255
|
+
wrote ? 'OBSERVED' : 'FAILED',
|
|
256
|
+
wrote
|
|
257
|
+
? `greeting.txt exists in the git worktree ${cwd} and reads ${JSON.stringify(readFileSync(join(cwd, 'greeting.txt'), 'utf8').trim())}`
|
|
258
|
+
: `nothing reached ${cwd}. The gate ${allowed ? 'ALLOWED the Write and the tool still did not run' : 'never allowed a Write'} — ` +
|
|
259
|
+
'see permission-mode-probe.ts: an allow that is not granted leaves the tool unrun, and the ' +
|
|
260
|
+
'options that could change the permission mode from a settings file or a rule list are pinned unreachable',
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
// -- turn 2: an MCP tool, and the deliberate hold ---------------------------
|
|
264
|
+
const beforeHold = Date.now();
|
|
265
|
+
controller.send(key, {
|
|
266
|
+
kind: 'session_prompt',
|
|
267
|
+
text:
|
|
268
|
+
'Do these two things and then stop. 1) Call the mcp__proof__note tool with text set to "hello". ' +
|
|
269
|
+
'2) Call the mcp__proof__slow tool with no arguments.',
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const sawHold = await until(
|
|
273
|
+
() => controller.transitions().some((one) => one.activity?.kind === 'permission'),
|
|
274
|
+
30_000,
|
|
275
|
+
);
|
|
276
|
+
say(
|
|
277
|
+
` the hold became visible ${Math.round((Date.now() - beforeHold) / 1000)}s in: ${sawHold ? 'a permission activity reached the controller' : 'NOTHING reached the controller'}`,
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const toolAnswered = await until(
|
|
281
|
+
() => controller.seen.some((one) => one.kind === 'tool' && one.text.startsWith('slow')),
|
|
282
|
+
180_000,
|
|
283
|
+
);
|
|
284
|
+
record(
|
|
285
|
+
2,
|
|
286
|
+
'every tool call reaches the gate, including MCP calls',
|
|
287
|
+
controller.seen.some((one) => one.kind === 'ask' && one.text.startsWith('mcp__proof__'))
|
|
288
|
+
? 'OBSERVED'
|
|
289
|
+
: 'FAILED',
|
|
290
|
+
`the controller was asked to decide: ${controller.seen
|
|
291
|
+
.filter((one) => one.kind === 'ask')
|
|
292
|
+
.map((one) => one.text.split(' ')[0])
|
|
293
|
+
.join(', ')}`,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
const held = heldFor > 60_000;
|
|
297
|
+
// The assertion is "survives and resolves", a property of the hold, not of what the tool did
|
|
298
|
+
// afterwards. Requiring the tool to have executed as well would fold an unrelated finding into
|
|
299
|
+
// this measurement. The resolution is observable in its own right: the entry opens, and the same
|
|
300
|
+
// entry closes with the decision that ended it.
|
|
301
|
+
const resolved = controller
|
|
302
|
+
.transitions()
|
|
303
|
+
.some((one) => one.cause.detail.includes(`the permission for ${HOLD_TOOL} resolved`));
|
|
304
|
+
record(
|
|
305
|
+
5,
|
|
306
|
+
'a hold survives more than 60 seconds and then resolves',
|
|
307
|
+
sawHold && held && resolved ? 'OBSERVED' : 'FAILED',
|
|
308
|
+
sawHold && held && resolved
|
|
309
|
+
? `the permission entry opened, stayed open while the controller withheld the decision for ` +
|
|
310
|
+
`${Math.round(heldFor / 1000)}s — past the 60s the SDK's own matcher default would have expired at — ` +
|
|
311
|
+
`and then closed with the decision that ended it, all of it visible on the wire. ` +
|
|
312
|
+
`(Whether the tool then EXECUTED is a separate finding: ${toolAnswered ? 'it did' : 'it did not - see assertion 1'}.)`
|
|
313
|
+
: `hold visible: ${sawHold}; withheld for ${heldFor}ms; resolution on the wire: ${resolved}`,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
// -- turn 3: the deny -------------------------------------------------------
|
|
317
|
+
await runTurn(
|
|
318
|
+
controller,
|
|
319
|
+
key,
|
|
320
|
+
`Use the Write tool once to create ${DENY_FILE} in the current directory containing the word denied. ` +
|
|
321
|
+
'If it is refused, do not retry and do not use any other tool — just say REFUSED and stop.',
|
|
322
|
+
);
|
|
323
|
+
const denied = controller.transitions().some((one) => one.cause.event === 'permission_denied');
|
|
324
|
+
const denialReachedDisk = existsSync(join(cwd, DENY_FILE));
|
|
325
|
+
const denial = controller.transitions().find((one) => one.cause.event === 'permission_denied');
|
|
326
|
+
record(
|
|
327
|
+
3,
|
|
328
|
+
'a deny blocks, and the reason reaches the model',
|
|
329
|
+
denied && !denialReachedDisk ? 'OBSERVED' : 'FAILED',
|
|
330
|
+
denied
|
|
331
|
+
? `${DENY_FILE} never reached disk; the controller's reason travelled to the wire verbatim: ${JSON.stringify(denial?.cause.detail ?? '')}`
|
|
332
|
+
: 'no denial was recorded',
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
// -- assertion 7: incremental rendering -------------------------------------
|
|
336
|
+
const deltas = controller.seen.filter((one) => one.kind === 'frame' && one.text.startsWith('delta'));
|
|
337
|
+
record(
|
|
338
|
+
7,
|
|
339
|
+
'frames render a turn live, text incrementally',
|
|
340
|
+
deltas.length > 0 ? 'OBSERVED' : 'FAILED',
|
|
341
|
+
`${deltas.length} delta frames arrived while turns were composing, ahead of the messages that settled them`,
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
// -- assertion 9: interrupt --------------------------------------------------
|
|
345
|
+
controller.send(key, {
|
|
346
|
+
kind: 'session_prompt',
|
|
347
|
+
text: 'Count slowly from 1 to 400, one number per line, using no tools.',
|
|
348
|
+
});
|
|
349
|
+
await wait(8_000);
|
|
350
|
+
controller.send(key, { kind: 'session_cancel' });
|
|
351
|
+
const interrupted = await until(
|
|
352
|
+
() => controller.transitions().some((one) => one.cause.event === 'interrupt_requested'),
|
|
353
|
+
30_000,
|
|
354
|
+
);
|
|
355
|
+
record(
|
|
356
|
+
9,
|
|
357
|
+
'interrupt stops a turn and returns a receipt',
|
|
358
|
+
interrupted ? 'OBSERVED' : 'FAILED',
|
|
359
|
+
interrupted
|
|
360
|
+
? 'the cancel was recorded as control/interrupt_requested and reached the controller as a transition'
|
|
361
|
+
: 'no interrupt reached the controller',
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
// -- assertion 10: reconnect --------------------------------------------------
|
|
365
|
+
// The drop happens mid-turn, with frames in flight, and that is the difference between a real
|
|
366
|
+
// observation and a vacuous one. Dropping the link while the session is idle leaves nothing
|
|
367
|
+
// pending and nothing unacked, so "lost nothing and duplicated nothing" would be true of an empty
|
|
368
|
+
// retention window and of a host with no replay at all.
|
|
369
|
+
say('\n--- dropping the link MID-TURN, then bringing it back on the same ports ---');
|
|
370
|
+
const seqBefore = controller.transitions().length;
|
|
371
|
+
controller.send(key, {
|
|
372
|
+
kind: 'session_prompt',
|
|
373
|
+
text: 'Count from 1 to 40, one number per line, using no tools. Then stop.',
|
|
374
|
+
});
|
|
375
|
+
await wait(3_000); // long enough that the turn is streaming
|
|
376
|
+
await controller.restart();
|
|
377
|
+
const back = await until(() => controller.connected, 60_000);
|
|
378
|
+
await turnEnds(controller, 120_000);
|
|
379
|
+
|
|
380
|
+
const grew = controller.transitions().length > seqBefore;
|
|
381
|
+
record(
|
|
382
|
+
10,
|
|
383
|
+
'reconnect after a link drop loses and duplicates nothing',
|
|
384
|
+
back && grew && controller.faults.gaps.length === 0 ? 'OBSERVED' : back ? 'FAILED' : 'NOT EXERCISED',
|
|
385
|
+
back
|
|
386
|
+
? `the link was dropped WHILE a turn was streaming and the host reconnected on its own. ` +
|
|
387
|
+
`The receiver's dense-seq arithmetic reports ${controller.faults.gaps.length} gaps and ` +
|
|
388
|
+
`${controller.faults.duplicates.length} duplicate frames dropped as already-held ` +
|
|
389
|
+
`(a replayed frame the controller already has is expected and is not a fault). ` +
|
|
390
|
+
`${controller.transitions().length - seqBefore} further transitions arrived after the drop, so the ` +
|
|
391
|
+
`window was not empty.`
|
|
392
|
+
: 'the host never reconnected',
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
// -- assertion 12: the trace alone --------------------------------------------
|
|
396
|
+
const trace = controller.transitions();
|
|
397
|
+
const reconstruction = reconstruct(trace);
|
|
398
|
+
say('\n--- ASSERTION 12: the run, reconstructed from transitions alone ---');
|
|
399
|
+
for (const line of reconstruction) say(` ${line}`);
|
|
400
|
+
const lanes = new Set(trace.map((one) => one.cause.kind));
|
|
401
|
+
record(
|
|
402
|
+
12,
|
|
403
|
+
'the trace alone reconstructs the whole run, without opening a transcript',
|
|
404
|
+
trace.length > 0 && lanes.has('control') && lanes.has('hook') ? 'OBSERVED' : 'FAILED',
|
|
405
|
+
`${trace.length} transitions carried where/what/why for every turn, across ${lanes.size} cause lanes ` +
|
|
406
|
+
`(${[...lanes].sort().join(', ')}); no transcript was opened to produce the reconstruction above. ` +
|
|
407
|
+
'The hook and control lanes are present because the gate lane now reaches the wire.',
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
// Assertion 8 is answered by `runMirrorAndResume`, which attaches a store to a real session;
|
|
411
|
+
// reading a store nothing was ever mirrored to would prove only that it was empty.
|
|
412
|
+
|
|
413
|
+
spentUsd += controller.spendUsd;
|
|
414
|
+
say(`\n this run cost $${controller.spendUsd.toFixed(4)}`);
|
|
415
|
+
host.stop('the run is over');
|
|
416
|
+
await controller.stop();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// Assertion 4: a throwing handler blocks. Its own session, because it blocks everything.
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
async function runTheThrowingHandler(repositoryRoot: string, workspaceRoot: string): Promise<void> {
|
|
424
|
+
say('\n--- THE THROWING HANDLER ---------------------------------------------');
|
|
425
|
+
const controller = new TestController({
|
|
426
|
+
render: (line) => say(line),
|
|
427
|
+
tools: { note: () => 'the note was recorded' },
|
|
428
|
+
});
|
|
429
|
+
await controller.start();
|
|
430
|
+
|
|
431
|
+
const host = new PeriscopeHost({
|
|
432
|
+
controllerUrl: controller.controllerUrl,
|
|
433
|
+
hostId: 'proof-host-throwing',
|
|
434
|
+
tools: {
|
|
435
|
+
name: 'proof',
|
|
436
|
+
descriptors: [
|
|
437
|
+
{
|
|
438
|
+
name: 'note',
|
|
439
|
+
description: 'Records a short note with the controller and returns a confirmation.',
|
|
440
|
+
inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
|
|
441
|
+
},
|
|
442
|
+
],
|
|
443
|
+
invoke: invokerFor(controller.toolUrl),
|
|
444
|
+
},
|
|
445
|
+
// A bug in the gate itself. The CLI treats a throwing hook as absent, so without the package's
|
|
446
|
+
// own try/catch this is an open door rather than a refusal.
|
|
447
|
+
decide: () => {
|
|
448
|
+
throw new Error('a deliberate bug in the gate itself');
|
|
449
|
+
},
|
|
450
|
+
protectedPaths: credentialPaths(process.env),
|
|
451
|
+
workspaces: new GitWorktreeProvider({
|
|
452
|
+
repositoryRoot,
|
|
453
|
+
workspaceRoot,
|
|
454
|
+
effects: nodeWorkspaceEffects,
|
|
455
|
+
commands: nodeCommandEffects(),
|
|
456
|
+
}),
|
|
457
|
+
gate: { decisionTimeoutMs: 20_000, holdAfterMs: 250 },
|
|
458
|
+
baseEnv: process.env,
|
|
459
|
+
homeDir: process.env['USERPROFILE'] ?? process.env['HOME'] ?? '',
|
|
460
|
+
report: (event) => reportOf(event),
|
|
461
|
+
});
|
|
462
|
+
host.start();
|
|
463
|
+
await until(() => controller.connected, 15_000);
|
|
464
|
+
|
|
465
|
+
const key = 'proof-session-throwing';
|
|
466
|
+
controller.send(key, sessionNew(workspaceRoot));
|
|
467
|
+
await until(() => host.session(key).ok, 20_000);
|
|
468
|
+
// The tool is an MCP tool, and the choice is this measurement's own control. A `Write` never
|
|
469
|
+
// reaches the decider here: the host's local gate is consulted first and refuses a path outside
|
|
470
|
+
// the workspace, which would measure the local gate and leave the throwing decider unproven. The
|
|
471
|
+
// local gate has no opinion about an `mcp__...` name, so an MCP call is guaranteed to reach the
|
|
472
|
+
// decider, which is what this assertion is about.
|
|
473
|
+
await runTurn(
|
|
474
|
+
controller,
|
|
475
|
+
key,
|
|
476
|
+
'Call the mcp__proof__note tool once with text set to "thrown". If it is refused, do not retry and ' +
|
|
477
|
+
'do not use any other tool — just say REFUSED and stop.',
|
|
478
|
+
);
|
|
479
|
+
const refused = controller
|
|
480
|
+
.transitions()
|
|
481
|
+
.some((one) => one.cause.kind === 'refusal' && one.cause.event === 'permission-decision-unavailable');
|
|
482
|
+
const ran = controller.seen.some((one) => one.kind === 'tool');
|
|
483
|
+
|
|
484
|
+
record(
|
|
485
|
+
4,
|
|
486
|
+
'a THROWING handler blocks',
|
|
487
|
+
refused && !ran ? 'OBSERVED' : 'FAILED',
|
|
488
|
+
refused && !ran
|
|
489
|
+
? 'the decider threw on every call. The tool never reached the controller, and the trace names it ' +
|
|
490
|
+
'refusal/permission-decision-unavailable - an outage, never a denial. Without the gate own ' +
|
|
491
|
+
'try/catch the CLI would have treated the throwing hook as ABSENT and the call would have run.'
|
|
492
|
+
: `the tool ${ran ? 'RAN - FAIL-OPEN' : 'did not run, but no refusal was recorded'}`,
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
spentUsd += controller.spendUsd;
|
|
496
|
+
say(`\n this run cost $${controller.spendUsd.toFixed(4)}`);
|
|
497
|
+
host.stop('the throwing run is over');
|
|
498
|
+
await controller.stop();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ---------------------------------------------------------------------------
|
|
502
|
+
// Assertion 6: the controller killed mid-run. And the MCP contrast.
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
async function runOffline(repositoryRoot: string, workspaceRoot: string): Promise<void> {
|
|
506
|
+
say('\n--- THE CONTROLLER, KILLED MID-RUN -----------------------------------');
|
|
507
|
+
const controller = new TestController({
|
|
508
|
+
render: (line) => say(line),
|
|
509
|
+
tools: { note: () => 'the note was recorded' },
|
|
510
|
+
});
|
|
511
|
+
await controller.start();
|
|
512
|
+
|
|
513
|
+
const events: HostEvent[] = [];
|
|
514
|
+
const host = new PeriscopeHost({
|
|
515
|
+
controllerUrl: controller.controllerUrl,
|
|
516
|
+
hostId: 'proof-host-offline',
|
|
517
|
+
decide: escalateTo(controller.decisionUrl),
|
|
518
|
+
protectedPaths: credentialPaths(process.env),
|
|
519
|
+
workspaces: new GitWorktreeProvider({
|
|
520
|
+
repositoryRoot,
|
|
521
|
+
workspaceRoot,
|
|
522
|
+
effects: nodeWorkspaceEffects,
|
|
523
|
+
commands: nodeCommandEffects(),
|
|
524
|
+
}),
|
|
525
|
+
tools: {
|
|
526
|
+
name: 'proof',
|
|
527
|
+
descriptors: [
|
|
528
|
+
{
|
|
529
|
+
name: 'note',
|
|
530
|
+
description: 'Records a short note with the controller and returns a confirmation.',
|
|
531
|
+
inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
|
|
532
|
+
},
|
|
533
|
+
],
|
|
534
|
+
invoke: invokerFor(controller.toolUrl),
|
|
535
|
+
},
|
|
536
|
+
// Short on purpose: the point of the local gate is that a boundary call never waits for this.
|
|
537
|
+
gate: { decisionTimeoutMs: 15_000, holdAfterMs: 250 },
|
|
538
|
+
baseEnv: process.env,
|
|
539
|
+
homeDir: process.env['USERPROFILE'] ?? process.env['HOME'] ?? '',
|
|
540
|
+
report: (event) => {
|
|
541
|
+
events.push(event);
|
|
542
|
+
reportOf(event);
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
host.start();
|
|
546
|
+
await until(() => controller.connected, 15_000);
|
|
547
|
+
|
|
548
|
+
const key = 'proof-session-offline';
|
|
549
|
+
controller.send(key, sessionNew(workspaceRoot));
|
|
550
|
+
await until(() => host.session(key).ok, 20_000);
|
|
551
|
+
|
|
552
|
+
say('killing the controller — both transports');
|
|
553
|
+
await controller.stop();
|
|
554
|
+
|
|
555
|
+
const composed = host.session(key);
|
|
556
|
+
if (!composed.ok) {
|
|
557
|
+
record(
|
|
558
|
+
6,
|
|
559
|
+
'with the controller killed, a boundary call is refused locally',
|
|
560
|
+
'FAILED',
|
|
561
|
+
'the session was gone',
|
|
562
|
+
);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// The refusal is read off the machine, not off the host's refusal channel, and the reason is the
|
|
567
|
+
// whole point of the scenario. The controller is dead, so nothing can reach it; a probe that
|
|
568
|
+
// watched the wire here would be measuring the link being down. A local refusal is recorded as a
|
|
569
|
+
// transition on the session's own machine, which is exactly where an offline host still knows
|
|
570
|
+
// things.
|
|
571
|
+
const local: string[] = [];
|
|
572
|
+
composed.value.machine.onTransition((transition) => {
|
|
573
|
+
if (transition.cause.kind === 'refusal') local.push(transition.cause.event);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// A call at the boundary, with nobody to ask: the agent's own credential store, which the local
|
|
577
|
+
// gate protects by name. No shell verb is involved, deliberately — the property is that a
|
|
578
|
+
// boundary-crossing call is refused locally, and a path is the cleaner way to state it.
|
|
579
|
+
const boundaryAt = Date.now();
|
|
580
|
+
composed.value.session.prompt(
|
|
581
|
+
'Use the Read tool once on the file .claude.json in your home directory. If it is refused, do not retry ' +
|
|
582
|
+
'and do not use any other tool — just say REFUSED and stop.',
|
|
583
|
+
);
|
|
584
|
+
const refusedLocally = await until(
|
|
585
|
+
() => local.some((reason) => reason.startsWith('credential-') || reason.startsWith('path-')),
|
|
586
|
+
120_000,
|
|
587
|
+
);
|
|
588
|
+
const localMs = Date.now() - boundaryAt;
|
|
589
|
+
|
|
590
|
+
record(
|
|
591
|
+
6,
|
|
592
|
+
'with the controller killed, a boundary call is refused LOCALLY',
|
|
593
|
+
refusedLocally ? 'OBSERVED' : 'FAILED',
|
|
594
|
+
refusedLocally
|
|
595
|
+
? `refused ${Math.round(localMs / 1000)}s after the turn was queued, BY NAME (${[...new Set(local)].join(', ')}), ` +
|
|
596
|
+
"with both of the controller's transports dead and the host never asking it. The gate's own " +
|
|
597
|
+
'deadline was 15s, so a refusal faster than that is the local gate answering rather than the ' +
|
|
598
|
+
'escalation timing out.'
|
|
599
|
+
: `no local refusal was recorded in ${Math.round(localMs / 1000)}s; the machine saw: ${local.join(', ') || '(nothing)'}`,
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
// The contrast, measured rather than reasoned: the same offline host, an MCP tool. The local gate
|
|
603
|
+
// matches on tool name and the shipped families name no `mcp__...` tool, so it has no opinion,
|
|
604
|
+
// the call is escalated to a controller that is not there, and the refusal arrives as an outage
|
|
605
|
+
// instead of by name.
|
|
606
|
+
const mcpAt = Date.now();
|
|
607
|
+
composed.value.session.prompt(
|
|
608
|
+
'Call the mcp__proof__note tool once with text set to "offline". If it is refused, do not retry — just stop.',
|
|
609
|
+
);
|
|
610
|
+
const outage = await until(() => local.includes('permission-decision-unavailable'), 120_000);
|
|
611
|
+
say(
|
|
612
|
+
`\n MCP CONTRAST: ${outage ? `refused as an OUTAGE after ${Math.round((Date.now() - mcpAt) / 1000)}s` : 'no outage observed'} — ` +
|
|
613
|
+
'the local gate had no opinion about an mcp__ tool name, so the same offline host refused a ' +
|
|
614
|
+
'built-in BY NAME and an MCP tool as an OUTAGE. THE COST IS THE KIND, NOT THE DELAY: a ' +
|
|
615
|
+
'refused connection fails fast, so neither waited out the deadline. The decision timeout is ' +
|
|
616
|
+
'only paid when a controller ACCEPTS the connection and does not answer — and an outage still ' +
|
|
617
|
+
'cannot be told apart from a platform failure, which is what the local gate exists to avoid.',
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
spentUsd += controller.spendUsd;
|
|
621
|
+
say(`\n this run cost $${controller.spendUsd.toFixed(4)}`);
|
|
622
|
+
host.stop('the offline run is over');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ---------------------------------------------------------------------------
|
|
626
|
+
// A real session mirrors to a store and resumes from it: the property that proves the store
|
|
627
|
+
// adapter works rather than merely type-checks.
|
|
628
|
+
// ---------------------------------------------------------------------------
|
|
629
|
+
|
|
630
|
+
async function runMirrorAndResume(workspaceRoot: string, storeRoot: string): Promise<void> {
|
|
631
|
+
say('\n--- THE MIRROR, AND A RESUME FROM IT ----------------------------------');
|
|
632
|
+
|
|
633
|
+
// This uses `composeSession` directly rather than the host's dispatcher, for exactly one reason:
|
|
634
|
+
// the store. `session_new` carries `resume`, `fork` and `settingSources`, so a controller can ask
|
|
635
|
+
// for a resume over the link; a `SessionStore` is an object with methods and has no JSON form, so
|
|
636
|
+
// it cannot cross a wire even in principle. That is the boundary rather than an omission: the
|
|
637
|
+
// store receives every message the agent saw, so a controller able to name one could name a
|
|
638
|
+
// destination for a transcript. This probe composes locally because it needs to pass a real
|
|
639
|
+
// store, which stays embedder-side by construction.
|
|
640
|
+
const registry = new SessionRegistry({
|
|
641
|
+
baseEnv: process.env,
|
|
642
|
+
homeDir: process.env['USERPROFILE'] ?? process.env['HOME'] ?? '',
|
|
643
|
+
startTimeoutMs: 120_000,
|
|
644
|
+
});
|
|
645
|
+
const store = createJsonlStore(nodeStoreEffects(storeRoot));
|
|
646
|
+
const sink: FrameSink = { send: () => ok(undefined) };
|
|
647
|
+
const cwd = mkdtempSync(join(workspaceRoot, 'mirror-'));
|
|
648
|
+
|
|
649
|
+
// The key is learned, not assumed. The SDK derives `projectKey` by sanitising the cwd (separators
|
|
650
|
+
// and colons become dashes), so a read keyed by the raw cwd finds nothing and reports an empty
|
|
651
|
+
// store for a mirror that worked. Wrapping the adapter and recording what it was called with
|
|
652
|
+
// needs no knowledge of that rule, and cannot drift when the rule changes.
|
|
653
|
+
const adapter = asSessionStore(store);
|
|
654
|
+
let writtenKey: { projectKey: string; sessionId: string } | null = null;
|
|
655
|
+
let appends = 0;
|
|
656
|
+
const recording: ReturnType<typeof asSessionStore> = {
|
|
657
|
+
...adapter,
|
|
658
|
+
append: async (key, entries) => {
|
|
659
|
+
writtenKey = { projectKey: key.projectKey, sessionId: key.sessionId };
|
|
660
|
+
appends += 1;
|
|
661
|
+
return adapter.append(key, entries);
|
|
662
|
+
},
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const first = composeSession({
|
|
666
|
+
registry,
|
|
667
|
+
sessionKey: 'mirror-1',
|
|
668
|
+
cwd,
|
|
669
|
+
sink,
|
|
670
|
+
decide: () => Promise.resolve({ behavior: 'allow' }),
|
|
671
|
+
gate: { grantOnAllow: true },
|
|
672
|
+
request: { sessionStore: recording, sessionStoreFlush: 'eager' },
|
|
673
|
+
});
|
|
674
|
+
if (!first.ok) {
|
|
675
|
+
record(
|
|
676
|
+
8,
|
|
677
|
+
'a receipt resolves across a compaction boundary',
|
|
678
|
+
'NOT EXERCISED',
|
|
679
|
+
`the session refused: ${first.refusal.detail}`,
|
|
680
|
+
);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const secret = 'periscope-mirror-elderflower';
|
|
685
|
+
first.value.session.prompt(`Remember this word and reply with it: ${secret}. Use no tools.`);
|
|
686
|
+
const live = await first.value.session.whenLive(120_000);
|
|
687
|
+
await until(() => first.value.machine.state === 'idle', 180_000);
|
|
688
|
+
const sessionId = live.ok ? live.value.id : null;
|
|
689
|
+
say(` the first session is ${sessionId ?? '(unidentified)'}`);
|
|
690
|
+
first.value.session.stop('the first half is done');
|
|
691
|
+
|
|
692
|
+
if (sessionId === null) {
|
|
693
|
+
record(
|
|
694
|
+
8,
|
|
695
|
+
'a receipt resolves across a compaction boundary',
|
|
696
|
+
'NOT EXERCISED',
|
|
697
|
+
'the agent never reported a session id',
|
|
698
|
+
);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const readKey: { projectKey: string; sessionId: string } = writtenKey ?? { projectKey: cwd, sessionId };
|
|
703
|
+
say(` the SDK wrote ${appends} batch(es) under projectKey ${readKey.projectKey}`);
|
|
704
|
+
const loaded = await store.load(readKey);
|
|
705
|
+
const entries = loaded.ok && loaded.value !== null ? loaded.value : [];
|
|
706
|
+
say(` the store holds ${entries.length} entries for that session`);
|
|
707
|
+
|
|
708
|
+
// The receipt read path, against entries a real agent actually produced.
|
|
709
|
+
const anchorUuid = entries.length > 0 ? baselineAnchor(entries.slice(0, 1)) : null;
|
|
710
|
+
const receipt = resolveReceipt(entries, { anchorUuid, expectText: secret });
|
|
711
|
+
const compactions = compactionCount(entries);
|
|
712
|
+
say(
|
|
713
|
+
` resolveReceipt: ${receipt.ok ? `delivered=${receipt.value.delivered} crossedCompaction=${receipt.value.crossedCompaction} scanned=${receipt.value.scannedAfterAnchor}` : `refused ${receipt.refusal.reason}`}`,
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
record(
|
|
717
|
+
8,
|
|
718
|
+
'a receipt resolves across a compaction boundary',
|
|
719
|
+
compactions > 0 && receipt.ok && receipt.value.crossedCompaction ? 'OBSERVED' : 'NOT EXERCISED',
|
|
720
|
+
compactions > 0
|
|
721
|
+
? `${compactions} compaction(s) in ${entries.length} entries`
|
|
722
|
+
: `the mirror was attached and holds ${entries.length} real entries, and the receipt read path ` +
|
|
723
|
+
`resolved against them (${receipt.ok ? `delivered=${receipt.value.delivered}` : receipt.refusal.reason}) — ` +
|
|
724
|
+
`but NO COMPACTION OCCURRED, so the boundary itself is still unexercised. There is no API that ` +
|
|
725
|
+
`forces one: a compaction happens when a context window fills, and a run long enough to fill one ` +
|
|
726
|
+
`is a different and much more expensive exercise than this proof. Predicted before the run, and ` +
|
|
727
|
+
`unchanged by it.`,
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
// --- the resume half ---------------------------------------------------------------------
|
|
731
|
+
const second = composeSession({
|
|
732
|
+
registry,
|
|
733
|
+
sessionKey: 'mirror-2',
|
|
734
|
+
cwd,
|
|
735
|
+
sink,
|
|
736
|
+
decide: () => Promise.resolve({ behavior: 'allow' }),
|
|
737
|
+
gate: { grantOnAllow: true },
|
|
738
|
+
request: { sessionStore: asSessionStore(store), resume: sessionId, fork: true },
|
|
739
|
+
});
|
|
740
|
+
if (!second.ok) {
|
|
741
|
+
say(` THE RESUME REFUSED: ${second.refusal.reason} ${second.refusal.detail}`);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
let answered = '';
|
|
746
|
+
second.value.session.onMessage((message: unknown) => {
|
|
747
|
+
const seen = message as { type: string; message?: { content?: unknown } };
|
|
748
|
+
if (seen.type === 'assistant') answered += JSON.stringify(seen.message?.content ?? '');
|
|
749
|
+
});
|
|
750
|
+
second.value.session.prompt(
|
|
751
|
+
'What was the word I asked you to remember? Reply with just that word, and use no tools.',
|
|
752
|
+
);
|
|
753
|
+
await second.value.session.whenLive(120_000);
|
|
754
|
+
await until(() => second.value.machine.state === 'idle' || answered.includes(secret), 180_000);
|
|
755
|
+
second.value.session.stop('the resume is done');
|
|
756
|
+
|
|
757
|
+
const carried = answered.includes(secret);
|
|
758
|
+
const mirrored = entries.length > 0;
|
|
759
|
+
// The two halves are reported separately because only one of them is about the store. A resume
|
|
760
|
+
// recalling the word proves the agent kept its context, which its own local transcript would
|
|
761
|
+
// deliver whether or not any mirror existed; claiming "the store adapter works" beside a measured
|
|
762
|
+
// `0 entries` would be a false statement generated by the probe itself.
|
|
763
|
+
say(
|
|
764
|
+
`\n MIRROR: ${mirrored ? `PROVEN — the SDK made ${appends} append call(s) through this package's adapter and the store holds ${entries.length} real entries, ${entries.filter((e) => JSON.stringify(e).includes(secret)).length} of them carrying the word` : `NOT PROVEN — the store is empty after ${appends} append call(s)`}.`,
|
|
765
|
+
);
|
|
766
|
+
say(
|
|
767
|
+
` RESUME: ${carried ? 'PROVEN' : 'NOT PROVEN'} — a second session started with resume ${carried ? 'recalled the word from the first' : `answered ${answered.slice(0, 160)}`}. ` +
|
|
768
|
+
`Note: this half is about the AGENT's context, not about the store: the CLI keeps its own local ` +
|
|
769
|
+
`transcript, so a recall does not by itself show the mirror was read.`,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ---------------------------------------------------------------------------
|
|
774
|
+
|
|
775
|
+
/** The trace, as prose. Nothing here opens a transcript; this is assertion 12's whole claim. */
|
|
776
|
+
function reconstruct(trace: readonly SessionTransition[]): string[] {
|
|
777
|
+
return trace.map((one) => {
|
|
778
|
+
const where = one.where.branch ?? one.where.worktree ?? one.where.cwd;
|
|
779
|
+
const what = one.activity === null ? one.to : `${one.to} [${one.activity.kind}:${one.activity.name}]`;
|
|
780
|
+
return `${one.seq.toString().padStart(3, ' ')} ${one.at} ${where} — ${what} because ${one.cause.kind}/${one.cause.event}: ${one.cause.detail}`;
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* The escalation decider, hand-built.
|
|
786
|
+
*
|
|
787
|
+
* `escalatingDecider` ships in the package and does exactly this, but taking it means importing
|
|
788
|
+
* from `@naswerks/periscope`, which is the entry point that reaches the privileged module. Written out here so
|
|
789
|
+
* the proof records what a controller-side implementer actually has to reproduce.
|
|
790
|
+
*/
|
|
791
|
+
function escalateTo(url: string): (request: unknown, signal: AbortSignal) => Promise<unknown> {
|
|
792
|
+
return async (request: unknown, signal: AbortSignal): Promise<unknown> => {
|
|
793
|
+
const response = await fetch(url, {
|
|
794
|
+
method: 'POST',
|
|
795
|
+
headers: { 'content-type': 'application/json' },
|
|
796
|
+
body: JSON.stringify(request),
|
|
797
|
+
signal,
|
|
798
|
+
});
|
|
799
|
+
if (!response.ok) throw new Error(`the controller answered ${response.status}`);
|
|
800
|
+
return await response.json();
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** How a validated tool call reaches the controller. There is no wire lane for this. */
|
|
805
|
+
function invokerFor(toolUrl: string): (call: ToolCall) => Promise<{ text: string; isError?: boolean }> {
|
|
806
|
+
return async (call: ToolCall) => {
|
|
807
|
+
const response = await fetch(`${toolUrl}/${call.toolName}`, {
|
|
808
|
+
method: 'POST',
|
|
809
|
+
headers: { 'content-type': 'application/json' },
|
|
810
|
+
body: JSON.stringify({ arguments: call.arguments, sessionId: call.identity.sessionId }),
|
|
811
|
+
});
|
|
812
|
+
return (await response.json()) as { text: string; isError?: boolean };
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function reportOf(event: HostEvent): void {
|
|
817
|
+
if (event.kind === 'refusal')
|
|
818
|
+
say(` HOST REFUSAL ${event.sessionKey ?? '-'} ${event.refusal.reason} — ${event.refusal.detail}`);
|
|
819
|
+
if (event.kind === 'session-opened') say(` HOST session ${event.sessionKey} opened in ${event.cwd}`);
|
|
820
|
+
if (event.kind === 'link') say(` HOST link ${event.transition.from} -> ${event.transition.to}`);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function writeReport(): void {
|
|
824
|
+
const table = [
|
|
825
|
+
'',
|
|
826
|
+
'=== THE TWELVE ASSERTIONS ===',
|
|
827
|
+
'',
|
|
828
|
+
...outcomes
|
|
829
|
+
.sort((a, b) => a.n - b.n)
|
|
830
|
+
.map(
|
|
831
|
+
(one) =>
|
|
832
|
+
`${String(one.n).padStart(2, ' ')}. ${one.status.padEnd(13, ' ')} ${one.title}\n ${one.detail}`,
|
|
833
|
+
),
|
|
834
|
+
'',
|
|
835
|
+
];
|
|
836
|
+
for (const line of table) say(line);
|
|
837
|
+
|
|
838
|
+
const out = process.env['PERISCOPE_PROOF_OUT'];
|
|
839
|
+
if (out !== undefined && out !== '') {
|
|
840
|
+
writeFileSync(out, `${lines.join('\n')}\n`, 'utf8');
|
|
841
|
+
process.stdout.write(`\nwrote ${out}\n`);
|
|
842
|
+
}
|
|
843
|
+
if (outcomes.some((one) => one.status === 'FAILED')) process.exitCode = 1;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
void main().catch((error: unknown) => {
|
|
847
|
+
say(`\nTHE PROOF ITSELF FAILED: ${error instanceof Error ? error.stack : String(error)}`);
|
|
848
|
+
writeReport();
|
|
849
|
+
process.exitCode = 1;
|
|
850
|
+
});
|