@junghanacs/entwurf 0.13.1 → 0.14.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/AGENTS.md +48 -15
- package/BASELINE.md +3 -3
- package/CHANGELOG.md +45 -0
- package/CONTRIBUTING.md +13 -9
- package/DELIVERY.md +7 -6
- package/README.md +27 -27
- package/VERIFY.md +22 -14
- package/demo/README.md +1 -1
- package/demo/demo-baseline.sh +1 -3
- package/demo/demo.sh +2 -5
- package/docs/acp-backend-rail.md +9 -4
- package/docs/external-mcp-host.md +4 -5
- package/docs/setup-clean-host.md +8 -7
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +148 -28
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +8 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
- package/mcp/entwurf-bridge/src/index.ts +164 -28
- package/mcp/entwurf-bridge/start.sh +2 -2
- package/mcp/entwurf-bridge/test.sh +23 -9
- package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
- package/package.json +22 -11
- package/pi-extensions/entwurf-control.ts +218 -40
- package/pi-extensions/lib/acp/backend.ts +71 -12
- package/pi-extensions/lib/acp/overlay.ts +8 -6
- package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
- package/pi-extensions/lib/entwurf-core.ts +15 -15
- package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
- package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
- package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
- package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
- package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
- package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
- package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
- package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
- package/pi-extensions/lib/meta-session.ts +93 -5
- package/pi-extensions/lib/mux-fresh-call.ts +328 -0
- package/pi-extensions/lib/mux-launch.ts +267 -0
- package/pi-extensions/lib/mux-placement.ts +387 -0
- package/pi-extensions/lib/mux-resume-call.ts +221 -0
- package/pi-extensions/lib/resume-launch-identity.ts +162 -0
- package/pi-extensions/lib/session-id.js +8 -5
- package/pi-extensions/lib/socket-discovery.ts +3 -3
- package/run.sh +471 -201
- package/scripts/agy-bridge-config.py +5 -1
- package/scripts/check-acp-backend-preflight.ts +1 -1
- package/scripts/check-acp-overlay.ts +13 -3
- package/scripts/check-acp-stream-hooks.ts +504 -0
- package/scripts/check-elapsed.sh +25 -0
- package/scripts/check-entwurf-bridge-boot.ts +51 -4
- package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
- package/scripts/check-entwurf-control-rpc.ts +4 -3
- package/scripts/check-entwurf-resume-args.ts +72 -70
- package/scripts/check-entwurf-session-identity.ts +14 -10
- package/scripts/check-entwurf-v2-contract.ts +34 -59
- package/scripts/check-entwurf-v2-decider.ts +17 -177
- package/scripts/check-entwurf-v2-lock.ts +5 -2
- package/scripts/check-entwurf-v2-matrix.ts +3 -53
- package/scripts/check-entwurf-v2-production.ts +2 -91
- package/scripts/check-entwurf-v2-release.ts +10 -105
- package/scripts/check-entwurf-v2-runner.ts +4 -85
- package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
- package/scripts/check-entwurf-v2-send.ts +0 -28
- package/scripts/check-entwurf-v2-surface.ts +157 -128
- package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
- package/scripts/check-fresh-cut-gate.sh +1 -1
- package/scripts/check-gate-qualification.ts +98 -7
- package/scripts/check-install-container.sh +10 -2
- package/scripts/check-install-surface.ts +1 -1
- package/scripts/check-keyset-overlap.py +1 -1
- package/scripts/check-meta-facts.ts +249 -0
- package/scripts/check-meta-identity-consumers.ts +1 -1
- package/scripts/check-meta-session.ts +169 -0
- package/scripts/check-mux-launch-tmux.ts +316 -0
- package/scripts/check-mux-launch.ts +288 -0
- package/scripts/check-mux-launcher-fence.ts +264 -0
- package/scripts/check-mux-parent-artifact.ts +195 -0
- package/scripts/check-mux-placement-tmux.ts +322 -0
- package/scripts/check-mux-placement.ts +323 -0
- package/scripts/check-mux-resume-call.ts +282 -0
- package/scripts/check-probe-cli-shim.ts +25 -22
- package/scripts/check-probe-ordering.ts +84 -76
- package/scripts/check-release-gate-outcomes.ts +127 -7
- package/scripts/check-resume-launch-identity.ts +244 -0
- package/scripts/check-socket-discovery.ts +1 -1
- package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
- package/scripts/inventory-verification-surface.ts +349 -0
- package/scripts/lib/claude-launcher-fence.ts +322 -0
- package/scripts/lib/mutation-qualify.ts +109 -3
- package/scripts/meta-bridge-doctor.sh +6 -8
- package/scripts/meta-facts.ts +60 -0
- package/scripts/mutants/acp-overlay.json +17 -0
- package/scripts/mutants/acp-stream-hooks.json +158 -0
- package/scripts/mutants/bridge-boot-resume.json +45 -0
- package/scripts/mutants/meta-facts.json +50 -0
- package/scripts/mutants/meta-identity.json +36 -0
- package/scripts/mutants/mux-boundary.json +196 -0
- package/scripts/mutants/mux-fresh-call.json +185 -0
- package/scripts/mutants/mux-launcher-fence.json +123 -0
- package/scripts/mutants/mux-parent-artifact.json +39 -0
- package/scripts/mutants/mux-resume-call.json +148 -0
- package/scripts/mutants/probe-ordering.json +0 -1037
- package/scripts/mutants/release-gate.json +35 -0
- package/scripts/mutants/resume-args.json +76 -0
- package/scripts/mutants/resume-launch-identity.json +96 -0
- package/scripts/mutants/v2-surface.json +58 -18
- package/scripts/mutants/v2-visible-resume.json +215 -0
- package/scripts/new-session-id.ts +9 -4
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-agy-native-push-live.ts +6 -17
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-meta-honesty.sh +1 -1
- package/scripts/smoke-mux-fresh-call-live.ts +365 -0
- package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
- package/scripts/smoke-pi-attach.ts +1 -1
- package/scripts/smoke-user-scope-citizen.sh +1 -1
- package/scripts/tsconfig.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
- package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
- package/scripts/check-acp-sdk-surface.ts +0 -275
- package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
- package/scripts/check-entwurf-v2-spawn.ts +0 -399
- package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -467
|
@@ -98,13 +98,17 @@ PERMISSION_STATE_SCHEMA_VERSION = 2
|
|
|
98
98
|
# EVERY call. The bridge was registered and two thirds of it was unusable — the same failure the
|
|
99
99
|
# doctor already names for entwurf_v2, just never checked for the other two.
|
|
100
100
|
#
|
|
101
|
-
# The
|
|
101
|
+
# The four tools deliberately NOT here, because a grant we do not need is a grant we should not take:
|
|
102
102
|
# entwurf_inbox_read — native-push has no inbox to drain. An agy citizen has no meta-mailbox
|
|
103
103
|
# directory at all (measured), so granting it would pre-approve a rail this
|
|
104
104
|
# backend does not have.
|
|
105
105
|
# entwurf_register_native — explicit/manual fallback, not the normal birth path (DELIVERY.md):
|
|
106
106
|
# agy births automatically from PreInvocation. Auto-approving a registration
|
|
107
107
|
# verb the normal path never calls is exactly the excess this list avoids.
|
|
108
|
+
# entwurf_fresh_call / entwurf_resume_call — both launch into the CALLER's own tmux session and
|
|
109
|
+
# refuse without a pane anchor. An agy conversation is not a tmux client,
|
|
110
|
+
# so these are not on its normal path either; the count moved from two to
|
|
111
|
+
# four when 0.14 shipped the mux verbs, and the granted set did not.
|
|
108
112
|
ALLOW_RULES = tuple(f"mcp({SERVER_KEY}/{tool})" for tool in ("entwurf_v2", "entwurf_peers", "entwurf_self"))
|
|
109
113
|
# Rules that MATCH one of our tools: its exact grant, the server-wide rule, and the action wildcard.
|
|
110
114
|
# Membership here is a statement about agy's matcher, not about which list the rule sits in — the
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// exclude a built-in the Claude child still exposes (`read`); the turn must fail
|
|
7
7
|
// fast into the returned stream as an `error` event — never reach a spawn, never
|
|
8
8
|
// emit `done`. No live backend is launched (the preflight throws first), so this
|
|
9
|
-
// stays deterministic and IN pnpm check.
|
|
9
|
+
// stays deterministic and IN pnpm run check:full.
|
|
10
10
|
//
|
|
11
11
|
// backend.ts imports its siblings with `.js` suffixes (the root/jiti runtime
|
|
12
12
|
// convention), which `node --experimental-strip-types` cannot resolve directly.
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// Drives ensureClaudeConfigOverlay against INJECTED temp realDir/overlayDir
|
|
4
4
|
// (no operator ~/.claude touched), then asserts the overlay shape the ACP
|
|
5
5
|
// child must see:
|
|
6
|
-
// - settings.json: permissions.defaultMode "
|
|
7
|
-
// false, hooks === {}
|
|
6
|
+
// - settings.json: permissions.defaultMode "bypassPermissions" (unattended
|
|
7
|
+
// ACP turns never pause on a prompt), autoMemoryEnabled false, hooks === {}
|
|
8
|
+
// (configured-but-empty → mailbox absence by design);
|
|
8
9
|
// - whitelisted operator entries symlinked to their real path;
|
|
9
10
|
// - projects/sessions are overlay-PRIVATE real dirs, NOT symlinks;
|
|
10
11
|
// - non-whitelist operator entries (CLAUDE.md, settings.local.json, plugins,
|
|
@@ -73,7 +74,16 @@ try {
|
|
|
73
74
|
|
|
74
75
|
// settings.json
|
|
75
76
|
const settings = JSON.parse(readFileSync(join(overlayDir, "settings.json"), "utf8"));
|
|
76
|
-
assert.equal(
|
|
77
|
+
assert.equal(
|
|
78
|
+
settings.permissions?.defaultMode,
|
|
79
|
+
"bypassPermissions",
|
|
80
|
+
"[QK:ACP-OVERLAY-UNATTENDED-BYPASS] the overlay we author must pin permissions.defaultMode to " +
|
|
81
|
+
"'bypassPermissions'. The isolated overlay is the ONLY authority here — CLAUDE_CONFIG_DIR redirects the " +
|
|
82
|
+
"SDK away from the operator's native settings, so an unattended entwurf ACP turn inherits nothing and a " +
|
|
83
|
+
"regression to 'default' suspends that turn on an interactive permission prompt with no operator at the " +
|
|
84
|
+
"keyboard. The callable surface stays bounded by explicit tools/disallowedTools and backend auth, not by " +
|
|
85
|
+
"this mode.",
|
|
86
|
+
);
|
|
77
87
|
assert.equal(settings.autoMemoryEnabled, false, "settings.autoMemoryEnabled must be false");
|
|
78
88
|
assert.ok(
|
|
79
89
|
settings.hooks && typeof settings.hooks === "object" && Object.keys(settings.hooks).length === 0,
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
// Deterministic gate for the pi 0.84 streamSimple HOOK contract on the ACP rail
|
|
2
|
+
// (#63; upstream pi-mono #7372 → doc-only PR #7576).
|
|
3
|
+
//
|
|
4
|
+
// What the contract is, measured, not assumed:
|
|
5
|
+
//
|
|
6
|
+
// onPayload maps TRUTHFULLY: the provider request on this rail is the ACP
|
|
7
|
+
// `session/prompt` params, so the hook receives the EXACT
|
|
8
|
+
// `{sessionId, prompt}` object after the wire content is fully
|
|
9
|
+
// built (augment / reuse delta included) and immediately before
|
|
10
|
+
// `connection.prompt`; a returned replacement becomes the wire
|
|
11
|
+
// params. Integrity is fail-closed: a replacement must be a
|
|
12
|
+
// non-null, non-array object keeping the bootstrapped sessionId
|
|
13
|
+
// and carrying a non-empty prompt array — prompt rewriting is
|
|
14
|
+
// upstream-granted power, session identity and this rail's own
|
|
15
|
+
// non-empty-prompt invariant are not.
|
|
16
|
+
// onResponse is an EXPLICIT LOCAL NON-HTTP EXEMPTION: pi hard-types it as
|
|
17
|
+
// HTTP `{status, headers}` and ACP's terminal result arrives only
|
|
18
|
+
// after the session-update body was consumed — any invocation
|
|
19
|
+
// would fabricate HTTP evidence. This gate pins the ABSENCE
|
|
20
|
+
// behaviorally across success, error, and abort turns.
|
|
21
|
+
//
|
|
22
|
+
// Every cell drives `streamAcpTurn` against a fake ACP child/connection with
|
|
23
|
+
// handlers registered for BOTH hooks, and judges what actually reached the wire.
|
|
24
|
+
//
|
|
25
|
+
// backend.ts imports its siblings with `.js` suffixes (the root/jiti runtime
|
|
26
|
+
// convention), so — like check-acp-prompt-lifecycle — we tsc-emit the project
|
|
27
|
+
// and import the COMPILED backend.js whose `.js` imports resolve to real siblings.
|
|
28
|
+
|
|
29
|
+
import { strict as assert } from "node:assert";
|
|
30
|
+
import { execFileSync } from "node:child_process";
|
|
31
|
+
import { copyFileSync, mkdirSync, mkdtempSync, rmdirSync, rmSync } from "node:fs";
|
|
32
|
+
import { tmpdir } from "node:os";
|
|
33
|
+
import { resolve } from "node:path";
|
|
34
|
+
import { pathToFileURL } from "node:url";
|
|
35
|
+
import type { Api, AssistantMessageEvent, Context, Message, Model } from "@earendil-works/pi-ai";
|
|
36
|
+
|
|
37
|
+
const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
|
|
38
|
+
|
|
39
|
+
type Stream = AsyncIterable<AssistantMessageEvent>;
|
|
40
|
+
|
|
41
|
+
interface Deferred<T> {
|
|
42
|
+
promise: Promise<T>;
|
|
43
|
+
resolve: (value: T) => void;
|
|
44
|
+
reject: (err: unknown) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function deferred<T>(): Deferred<T> {
|
|
48
|
+
let res!: (value: T) => void;
|
|
49
|
+
let rej!: (err: unknown) => void;
|
|
50
|
+
const promise = new Promise<T>((a, b) => {
|
|
51
|
+
res = a;
|
|
52
|
+
rej = b;
|
|
53
|
+
});
|
|
54
|
+
return { promise, resolve: res, reject: rej };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function makeFakeChild() {
|
|
58
|
+
const pipe = () => ({ destroy() {}, unref() {} });
|
|
59
|
+
return {
|
|
60
|
+
pid: undefined as number | undefined,
|
|
61
|
+
exitCode: null as number | null,
|
|
62
|
+
signalCode: null as NodeJS.Signals | null,
|
|
63
|
+
stdin: pipe(),
|
|
64
|
+
stdout: pipe(),
|
|
65
|
+
stderr: { on() {}, destroy() {}, unref() {} },
|
|
66
|
+
kill() {
|
|
67
|
+
return true;
|
|
68
|
+
},
|
|
69
|
+
unref() {},
|
|
70
|
+
once() {},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const EMPTY_MCP_HASH = "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945";
|
|
75
|
+
const DEFAULT_RESOLVED_CONFIG: any = {
|
|
76
|
+
settingSources: [],
|
|
77
|
+
strictMcpConfig: true,
|
|
78
|
+
showToolNotifications: true,
|
|
79
|
+
mcpServers: [],
|
|
80
|
+
mcpServersHash: EMPTY_MCP_HASH,
|
|
81
|
+
tools: ["Read"],
|
|
82
|
+
skillPlugins: [],
|
|
83
|
+
permissionAllow: ["Read(*)"],
|
|
84
|
+
disallowedTools: [],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** One backend world whose fake connection CAPTURES the raw wire prompt params. */
|
|
88
|
+
function makeHarness(recordDir: string) {
|
|
89
|
+
const children: ReturnType<typeof makeFakeChild>[] = [];
|
|
90
|
+
const promptCalls: Array<{ sessionId: string; prompt: Array<{ type: string; text: string }> }> = [];
|
|
91
|
+
|
|
92
|
+
const makeConnection = (_handlers: any) => ({
|
|
93
|
+
initialize: async () => ({ agentCapabilities: {} }),
|
|
94
|
+
newSession: async () => ({ sessionId: "ACP-1" }),
|
|
95
|
+
setSessionConfigOption: async () => ({}),
|
|
96
|
+
prompt: async (params: any) => {
|
|
97
|
+
// Deep-cloned: the capture must be the bytes that crossed the seam, not a
|
|
98
|
+
// live reference a later mutation could rewrite.
|
|
99
|
+
promptCalls.push(structuredClone(params));
|
|
100
|
+
return { stopReason: "end_turn" };
|
|
101
|
+
},
|
|
102
|
+
cancel: async () => {},
|
|
103
|
+
close: () => {},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
children,
|
|
108
|
+
promptCalls,
|
|
109
|
+
deps: {
|
|
110
|
+
resolveLaunch: () => ({ command: "node", args: ["fake"] }),
|
|
111
|
+
ensureOverlay: () => {},
|
|
112
|
+
spawnChild: () => {
|
|
113
|
+
const c = makeFakeChild();
|
|
114
|
+
children.push(c);
|
|
115
|
+
return c;
|
|
116
|
+
},
|
|
117
|
+
createConnection: (_child: any, handlers: any) => makeConnection(handlers),
|
|
118
|
+
lifecyclePolicy: () => "process-scoped",
|
|
119
|
+
loadConfig: () => DEFAULT_RESOLVED_CONFIG,
|
|
120
|
+
now: () => "2026-08-07T00:00:00Z",
|
|
121
|
+
sessionDir: recordDir,
|
|
122
|
+
abortGraceMs: 40,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function collect(stream: Stream): Promise<AssistantMessageEvent[]> {
|
|
128
|
+
const events: AssistantMessageEvent[] = [];
|
|
129
|
+
for await (const ev of stream) events.push(ev);
|
|
130
|
+
return events;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const sealed = (events: AssistantMessageEvent[]) =>
|
|
134
|
+
events.filter((e) => e.type === "done" || e.type === "error") as any[];
|
|
135
|
+
|
|
136
|
+
const userCtx = (text: string): Context => ({ messages: [{ role: "user", content: text, timestamp: 0 }] }) as Context;
|
|
137
|
+
|
|
138
|
+
function reuseCtx(prior: string, latest: string): Context {
|
|
139
|
+
return {
|
|
140
|
+
messages: [
|
|
141
|
+
{ role: "user", content: prior, timestamp: 0 },
|
|
142
|
+
{
|
|
143
|
+
role: "assistant",
|
|
144
|
+
content: [{ type: "text", text: "ok" }],
|
|
145
|
+
api: "x",
|
|
146
|
+
provider: "x",
|
|
147
|
+
model: "x",
|
|
148
|
+
usage: {
|
|
149
|
+
input: 0,
|
|
150
|
+
output: 0,
|
|
151
|
+
cacheRead: 0,
|
|
152
|
+
cacheWrite: 0,
|
|
153
|
+
totalTokens: 0,
|
|
154
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
155
|
+
},
|
|
156
|
+
stopReason: "stop",
|
|
157
|
+
timestamp: 0,
|
|
158
|
+
} as unknown as Message,
|
|
159
|
+
{ role: "user", content: latest, timestamp: 0 },
|
|
160
|
+
],
|
|
161
|
+
} as Context;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// EVERY cell registers onResponse; the exemption claim at the end judges the sum.
|
|
165
|
+
let onResponseCalls = 0;
|
|
166
|
+
const onResponse = () => {
|
|
167
|
+
onResponseCalls++;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const TMP_EMIT = ".tmp-verify/acp-stream-hooks";
|
|
171
|
+
rmSync(TMP_EMIT, { recursive: true, force: true });
|
|
172
|
+
const recordDir = mkdtempSync(resolve(tmpdir(), "acp-stream-hooks-"));
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
execFileSync("node_modules/.bin/tsc", ["--outDir", TMP_EMIT, "--rootDir", ".", "--noEmit", "false"], {
|
|
176
|
+
stdio: "pipe",
|
|
177
|
+
});
|
|
178
|
+
const promptsOut = resolve(TMP_EMIT, "pi-extensions/lib/acp/prompts");
|
|
179
|
+
mkdirSync(promptsOut, { recursive: true });
|
|
180
|
+
copyFileSync("pi-extensions/lib/acp/prompts/engraving.md", resolve(promptsOut, "engraving.md"));
|
|
181
|
+
const backend = (await import(pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/backend.js")).href)) as any;
|
|
182
|
+
|
|
183
|
+
// ----------------------------------------------------------------------
|
|
184
|
+
// CELL 1 — NEW turn: the hook sees the EXACT wire params; undefined keeps them.
|
|
185
|
+
// ----------------------------------------------------------------------
|
|
186
|
+
{
|
|
187
|
+
const h = makeHarness(recordDir);
|
|
188
|
+
const seen: any[] = [];
|
|
189
|
+
const events = await collect(
|
|
190
|
+
backend.streamAcpTurn(
|
|
191
|
+
sonnet,
|
|
192
|
+
userCtx("NONCE-FIRST-TURN"),
|
|
193
|
+
{
|
|
194
|
+
sessionId: "hooks-new",
|
|
195
|
+
onPayload: (payload: unknown) => {
|
|
196
|
+
seen.push(structuredClone(payload));
|
|
197
|
+
return undefined;
|
|
198
|
+
},
|
|
199
|
+
onResponse,
|
|
200
|
+
},
|
|
201
|
+
h.deps,
|
|
202
|
+
) as Stream,
|
|
203
|
+
);
|
|
204
|
+
assert.equal(sealed(events)[0]?.type, "done", "the pass-through turn completes normally");
|
|
205
|
+
assert.ok(
|
|
206
|
+
seen.length === 1 && JSON.stringify(seen[0]) === JSON.stringify(h.promptCalls[0]),
|
|
207
|
+
"[QK:ACPHOOK-PAYLOAD-EXACT-WIRE] before_provider_request must run exactly once and receive the EXACT ACP " +
|
|
208
|
+
"session/prompt params that then cross the wire — same bootstrapped sessionId, same fully-built prompt " +
|
|
209
|
+
"(augment included) — and an undefined return must leave those params untouched; a skipped hook or a " +
|
|
210
|
+
"divergent payload means extensions observe a fiction",
|
|
211
|
+
);
|
|
212
|
+
assert.ok(
|
|
213
|
+
JSON.stringify(seen[0].prompt).includes("NONCE-FIRST-TURN"),
|
|
214
|
+
"the observed payload really carries this turn's user content",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ----------------------------------------------------------------------
|
|
219
|
+
// CELL 2 — NEW turn: a returned replacement becomes the wire params.
|
|
220
|
+
// ----------------------------------------------------------------------
|
|
221
|
+
{
|
|
222
|
+
const h = makeHarness(recordDir);
|
|
223
|
+
const events = await collect(
|
|
224
|
+
backend.streamAcpTurn(
|
|
225
|
+
sonnet,
|
|
226
|
+
userCtx("NONCE-ORIGINAL"),
|
|
227
|
+
{
|
|
228
|
+
sessionId: "hooks-replace",
|
|
229
|
+
onPayload: (payload: any) => ({
|
|
230
|
+
sessionId: payload.sessionId,
|
|
231
|
+
prompt: [{ type: "text", text: "NONCE-REPLACED-WIRE" }],
|
|
232
|
+
}),
|
|
233
|
+
onResponse,
|
|
234
|
+
},
|
|
235
|
+
h.deps,
|
|
236
|
+
) as Stream,
|
|
237
|
+
);
|
|
238
|
+
assert.equal(sealed(events)[0]?.type, "done", "the replaced turn completes normally");
|
|
239
|
+
assert.equal(h.promptCalls.length, 1, "exactly one prompt crossed the wire");
|
|
240
|
+
const wire = JSON.stringify(h.promptCalls[0]);
|
|
241
|
+
assert.ok(
|
|
242
|
+
wire.includes("NONCE-REPLACED-WIRE") && !wire.includes("NONCE-ORIGINAL"),
|
|
243
|
+
"[QK:ACPHOOK-REPLACEMENT-ON-WIRE] a replacement returned by before_provider_request must BE the wire params — " +
|
|
244
|
+
"the upstream contract says 'use any returned replacement payload', and ignoring it is exactly the " +
|
|
245
|
+
"pi-vertex defect (#7372) that silently breaks payload-hooking extensions",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ----------------------------------------------------------------------
|
|
250
|
+
// CELL 3 — REUSE turn parity: same boundary, delta params, replacement honored.
|
|
251
|
+
// ----------------------------------------------------------------------
|
|
252
|
+
{
|
|
253
|
+
const h = makeHarness(recordDir);
|
|
254
|
+
const seen: any[] = [];
|
|
255
|
+
let replaceReuse = false;
|
|
256
|
+
const options = {
|
|
257
|
+
sessionId: "hooks-reuse",
|
|
258
|
+
onPayload: (payload: any) => {
|
|
259
|
+
seen.push(structuredClone(payload));
|
|
260
|
+
if (!replaceReuse) return undefined;
|
|
261
|
+
return { sessionId: payload.sessionId, prompt: [{ type: "text", text: "NONCE-REUSE-REPLACED" }] };
|
|
262
|
+
},
|
|
263
|
+
onResponse,
|
|
264
|
+
};
|
|
265
|
+
const first = await collect(backend.streamAcpTurn(sonnet, userCtx("NONCE-TURN-ONE"), options, h.deps) as Stream);
|
|
266
|
+
assert.equal(sealed(first)[0]?.type, "done", "turn 1 (new) completes");
|
|
267
|
+
replaceReuse = true;
|
|
268
|
+
const second = await collect(
|
|
269
|
+
backend.streamAcpTurn(sonnet, reuseCtx("NONCE-TURN-ONE", "NONCE-TURN-TWO"), options, h.deps) as Stream,
|
|
270
|
+
);
|
|
271
|
+
assert.equal(sealed(second)[0]?.type, "done", "turn 2 (reuse) completes");
|
|
272
|
+
assert.equal(h.children.length, 1, "turn 2 reused the live session — no second child");
|
|
273
|
+
const reuseSeen = JSON.stringify(seen[1] ?? null);
|
|
274
|
+
const reuseWire = JSON.stringify(h.promptCalls[1] ?? null);
|
|
275
|
+
assert.ok(
|
|
276
|
+
seen.length === 2 &&
|
|
277
|
+
reuseSeen.includes("NONCE-TURN-TWO") &&
|
|
278
|
+
!reuseSeen.includes("NONCE-TURN-ONE") &&
|
|
279
|
+
reuseWire.includes("NONCE-REUSE-REPLACED") &&
|
|
280
|
+
!reuseWire.includes("NONCE-TURN-TWO"),
|
|
281
|
+
"[QK:ACPHOOK-REUSE-PARITY] the reuse turn owes the hook the SAME truthful boundary as a new turn — the payload " +
|
|
282
|
+
"it sees is the actual delta-only wire params for the live session, and its replacement crosses the wire; " +
|
|
283
|
+
"a hook that only fires on cold turns would hide every resident-session request from extensions",
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ----------------------------------------------------------------------
|
|
288
|
+
// CELL 4 — a rejecting hook fails the turn LOUD, nothing is sent.
|
|
289
|
+
// (Ordered before the refusal cells: a silent-fallback defect at the call
|
|
290
|
+
// site must die here, at its own claim, before an integrity cell can trip.)
|
|
291
|
+
// ----------------------------------------------------------------------
|
|
292
|
+
{
|
|
293
|
+
const h = makeHarness(recordDir);
|
|
294
|
+
const events = await collect(
|
|
295
|
+
backend.streamAcpTurn(
|
|
296
|
+
sonnet,
|
|
297
|
+
userCtx("hook rejection"),
|
|
298
|
+
{
|
|
299
|
+
sessionId: "hooks-reject",
|
|
300
|
+
onPayload: () => Promise.reject(new Error("NONCE-HOOK-EXPLODED")),
|
|
301
|
+
onResponse,
|
|
302
|
+
},
|
|
303
|
+
h.deps,
|
|
304
|
+
) as Stream,
|
|
305
|
+
);
|
|
306
|
+
const seal = sealed(events)[0];
|
|
307
|
+
assert.ok(
|
|
308
|
+
seal?.type === "error" &&
|
|
309
|
+
String(seal.error?.errorMessage ?? "").includes("NONCE-HOOK-EXPLODED") &&
|
|
310
|
+
h.promptCalls.length === 0,
|
|
311
|
+
"[QK:ACPHOOK-REJECTION-FAILS-LOUD] pi's own runner already swallows extension handler errors, so a rejection " +
|
|
312
|
+
"reaching this seam is a real failure — it must seal the turn loudly with the original message and send " +
|
|
313
|
+
"nothing, never be silently caught into a fallback send",
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ----------------------------------------------------------------------
|
|
318
|
+
// CELL 5 — a non-object replacement is refused LOUD, nothing is sent.
|
|
319
|
+
// ----------------------------------------------------------------------
|
|
320
|
+
{
|
|
321
|
+
const h = makeHarness(recordDir);
|
|
322
|
+
const events = await collect(
|
|
323
|
+
backend.streamAcpTurn(
|
|
324
|
+
sonnet,
|
|
325
|
+
userCtx("invalid replacement"),
|
|
326
|
+
{ sessionId: "hooks-invalid", onPayload: () => 42, onResponse },
|
|
327
|
+
h.deps,
|
|
328
|
+
) as Stream,
|
|
329
|
+
);
|
|
330
|
+
const seal = sealed(events)[0];
|
|
331
|
+
assert.ok(
|
|
332
|
+
seal?.type === "error" &&
|
|
333
|
+
String(seal.error?.errorMessage ?? "").includes("non-object replacement") &&
|
|
334
|
+
h.promptCalls.length === 0,
|
|
335
|
+
"[QK:ACPHOOK-INVALID-REPLACEMENT-REFUSED] a replacement that is not a non-null, non-array object must seal " +
|
|
336
|
+
"the turn as a loud error BEFORE anything reaches the wire — silently falling back to the original params " +
|
|
337
|
+
"would hide a broken extension exactly where it thinks it is rewriting requests",
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ----------------------------------------------------------------------
|
|
342
|
+
// CELL 6 — a sessionId-tampering replacement is refused LOUD, nothing is sent.
|
|
343
|
+
// ----------------------------------------------------------------------
|
|
344
|
+
{
|
|
345
|
+
const h = makeHarness(recordDir);
|
|
346
|
+
const events = await collect(
|
|
347
|
+
backend.streamAcpTurn(
|
|
348
|
+
sonnet,
|
|
349
|
+
userCtx("tampered sessionId"),
|
|
350
|
+
{
|
|
351
|
+
sessionId: "hooks-tamper",
|
|
352
|
+
onPayload: (payload: any) => ({ sessionId: "SOMEONE-ELSE", prompt: payload.prompt }),
|
|
353
|
+
onResponse,
|
|
354
|
+
},
|
|
355
|
+
h.deps,
|
|
356
|
+
) as Stream,
|
|
357
|
+
);
|
|
358
|
+
const seal = sealed(events)[0];
|
|
359
|
+
assert.ok(
|
|
360
|
+
seal?.type === "error" &&
|
|
361
|
+
String(seal.error?.errorMessage ?? "").includes("changed the ACP sessionId") &&
|
|
362
|
+
h.promptCalls.length === 0,
|
|
363
|
+
"[QK:ACPHOOK-SESSIONID-TAMPER-REFUSED] a replacement that changes the bootstrapped sessionId must refuse the " +
|
|
364
|
+
"turn before the wire — entwurf cannot truthfully deliver to an ACP session it did not open, and silently " +
|
|
365
|
+
"sending there would corrupt session identity while reporting success",
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ----------------------------------------------------------------------
|
|
370
|
+
// CELL 7 — a replacement that empties the prompt is refused LOUD, nothing sent.
|
|
371
|
+
// ----------------------------------------------------------------------
|
|
372
|
+
{
|
|
373
|
+
const h = makeHarness(recordDir);
|
|
374
|
+
const events = await collect(
|
|
375
|
+
backend.streamAcpTurn(
|
|
376
|
+
sonnet,
|
|
377
|
+
userCtx("emptied prompt"),
|
|
378
|
+
{
|
|
379
|
+
sessionId: "hooks-empty",
|
|
380
|
+
onPayload: (payload: any) => ({ sessionId: payload.sessionId, prompt: [] }),
|
|
381
|
+
onResponse,
|
|
382
|
+
},
|
|
383
|
+
h.deps,
|
|
384
|
+
) as Stream,
|
|
385
|
+
);
|
|
386
|
+
const seal = sealed(events)[0];
|
|
387
|
+
assert.ok(
|
|
388
|
+
seal?.type === "error" &&
|
|
389
|
+
String(seal.error?.errorMessage ?? "").includes("non-empty prompt array") &&
|
|
390
|
+
h.promptCalls.length === 0,
|
|
391
|
+
"[QK:ACPHOOK-EMPTY-PROMPT-REFUSED] a replacement whose prompt is missing, non-array, or empty must refuse " +
|
|
392
|
+
"the turn by name before the wire — this rail already owns a non-empty-prompt invariant on the built " +
|
|
393
|
+
"params, and a replacement must not be able to undo it into an empty ACP send",
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ----------------------------------------------------------------------
|
|
398
|
+
// CELL 8 — abort raised WHILE the hook is awaited wins before the wire (NEW turn).
|
|
399
|
+
// ----------------------------------------------------------------------
|
|
400
|
+
{
|
|
401
|
+
const h = makeHarness(recordDir);
|
|
402
|
+
const gate = deferred<undefined>();
|
|
403
|
+
const controller = new AbortController();
|
|
404
|
+
const turn = collect(
|
|
405
|
+
backend.streamAcpTurn(
|
|
406
|
+
sonnet,
|
|
407
|
+
userCtx("abort during hook"),
|
|
408
|
+
{
|
|
409
|
+
sessionId: "hooks-abort",
|
|
410
|
+
signal: controller.signal,
|
|
411
|
+
onPayload: () => gate.promise,
|
|
412
|
+
onResponse,
|
|
413
|
+
},
|
|
414
|
+
h.deps,
|
|
415
|
+
) as Stream,
|
|
416
|
+
);
|
|
417
|
+
// Let the turn reach the awaited hook, abort, THEN let the hook resolve.
|
|
418
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
419
|
+
controller.abort();
|
|
420
|
+
gate.resolve(undefined);
|
|
421
|
+
const events = await turn;
|
|
422
|
+
const seal = sealed(events)[0];
|
|
423
|
+
assert.ok(
|
|
424
|
+
seal?.type === "error" && seal.reason === "aborted" && h.promptCalls.length === 0,
|
|
425
|
+
"[QK:ACPHOOK-ABORT-DURING-HOOK-WINS] an abort raised while onPayload is awaited must win before the wire " +
|
|
426
|
+
"write — the turn seals aborted and NO prompt is sent, because a slow handler must never turn a cancelled " +
|
|
427
|
+
"turn into a delivered one",
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ----------------------------------------------------------------------
|
|
432
|
+
// CELL 9 — abort during the awaited hook wins on the REUSE path too: the
|
|
433
|
+
// resident session's bootstrap prompt is the control, and no SECOND prompt
|
|
434
|
+
// may cross the wire.
|
|
435
|
+
// ----------------------------------------------------------------------
|
|
436
|
+
{
|
|
437
|
+
const h = makeHarness(recordDir);
|
|
438
|
+
const gate = deferred<undefined>();
|
|
439
|
+
const controller = new AbortController();
|
|
440
|
+
let deferReuseHook = false;
|
|
441
|
+
const options: Record<string, unknown> = {
|
|
442
|
+
sessionId: "hooks-reuse-abort",
|
|
443
|
+
signal: controller.signal,
|
|
444
|
+
onPayload: () => (deferReuseHook ? gate.promise : undefined),
|
|
445
|
+
onResponse,
|
|
446
|
+
};
|
|
447
|
+
const first = await collect(
|
|
448
|
+
backend.streamAcpTurn(sonnet, userCtx("NONCE-REUSE-ABORT-ONE"), options, h.deps) as Stream,
|
|
449
|
+
);
|
|
450
|
+
assert.equal(sealed(first)[0]?.type, "done", "turn 1 (new) establishes the resident session");
|
|
451
|
+
assert.equal(h.promptCalls.length, 1, "the bootstrap prompt is the control send");
|
|
452
|
+
deferReuseHook = true;
|
|
453
|
+
const turn = collect(
|
|
454
|
+
backend.streamAcpTurn(
|
|
455
|
+
sonnet,
|
|
456
|
+
reuseCtx("NONCE-REUSE-ABORT-ONE", "NONCE-REUSE-ABORT-TWO"),
|
|
457
|
+
options,
|
|
458
|
+
h.deps,
|
|
459
|
+
) as Stream,
|
|
460
|
+
);
|
|
461
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
462
|
+
controller.abort();
|
|
463
|
+
gate.resolve(undefined);
|
|
464
|
+
const events = await turn;
|
|
465
|
+
const seal = sealed(events)[0];
|
|
466
|
+
assert.ok(
|
|
467
|
+
seal?.type === "error" && seal.reason === "aborted" && h.promptCalls.length === 1 && h.children.length === 1,
|
|
468
|
+
"[QK:ACPHOOK-REUSE-ABORT-WINS] an abort raised while the REUSE turn's onPayload is awaited must win before " +
|
|
469
|
+
"the wire write — the turn seals aborted and the resident session's bootstrap prompt stays the ONLY send; " +
|
|
470
|
+
"the reuse path owes the same abort-over-slow-handler guarantee as a cold turn",
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ----------------------------------------------------------------------
|
|
475
|
+
// CELL 10 — the onResponse exemption, judged across every cell above.
|
|
476
|
+
// ----------------------------------------------------------------------
|
|
477
|
+
assert.equal(
|
|
478
|
+
onResponseCalls,
|
|
479
|
+
0,
|
|
480
|
+
"[QK:ACPHOOK-ONRESPONSE-NEVER-CALLED] onResponse was registered on every turn above — success, replacement, " +
|
|
481
|
+
"reuse, refusals, rejection, and both abort paths — and must have been invoked ZERO times: ACP has no " +
|
|
482
|
+
"truthful HTTP {status, headers} and its terminal result arrives after the body was consumed, so any " +
|
|
483
|
+
"invocation is fabricated evidence; the exemption is deliberate and this is its behavioral pin",
|
|
484
|
+
);
|
|
485
|
+
} finally {
|
|
486
|
+
rmSync(TMP_EMIT, { recursive: true, force: true });
|
|
487
|
+
try {
|
|
488
|
+
// A leftover EMPTY parent dir reads as IMPURE tree drift in the
|
|
489
|
+
// qualification harness; a concurrent sibling gate's emit keeps it alive
|
|
490
|
+
// and this rmdir simply fails.
|
|
491
|
+
rmdirSync(".tmp-verify");
|
|
492
|
+
} catch {
|
|
493
|
+
// non-empty or already gone — fine either way
|
|
494
|
+
}
|
|
495
|
+
rmSync(recordDir, { recursive: true, force: true });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
console.log(
|
|
499
|
+
"[check-acp-stream-hooks] ok — before_provider_request receives the exact ACP session/prompt wire params on new " +
|
|
500
|
+
"AND reuse turns and its replacement is honored fail-closed (non-null non-array object, sessionId invariant, " +
|
|
501
|
+
"non-empty prompt array, loud refusal with zero sends otherwise, rejection loud, abort-during-hook wins on " +
|
|
502
|
+
"BOTH paths), and after_provider_response is a deliberate non-HTTP exemption invoked zero times across " +
|
|
503
|
+
"success/error/abort",
|
|
504
|
+
);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# check-elapsed — total wall-time reporter for the tiered check entrypoints (#70).
|
|
3
|
+
#
|
|
4
|
+
# Usage: bash scripts/check-elapsed.sh <label> <pnpm-script>...
|
|
5
|
+
#
|
|
6
|
+
# Runs each named pnpm script IN ORDER (fail-fast), then prints exactly one
|
|
7
|
+
# summary line — total elapsed wall seconds plus the exit code — on success AND
|
|
8
|
+
# on failure, and exits with the first failing script's code. Tier MEMBERSHIP
|
|
9
|
+
# lives in package.json (the executable SSOT); this wrapper owns only the
|
|
10
|
+
# wall-clock line. It is deliberately not a profiler: no per-step timing, no
|
|
11
|
+
# cache, no history, no thresholds. The core tier's ≤60s acceptance is an
|
|
12
|
+
# operator measurement on the reference host (oracle), never a hard fail here —
|
|
13
|
+
# an arbitrary CI host's variance must not turn wall time into a flaky gate.
|
|
14
|
+
set -u
|
|
15
|
+
|
|
16
|
+
label="$1"
|
|
17
|
+
shift
|
|
18
|
+
start=$(date +%s)
|
|
19
|
+
code=0
|
|
20
|
+
for script in "$@"; do
|
|
21
|
+
pnpm run "$script" || { code=$?; break; }
|
|
22
|
+
done
|
|
23
|
+
elapsed=$(($(date +%s) - start))
|
|
24
|
+
echo "[${label}] total elapsed ${elapsed}s exit ${code}"
|
|
25
|
+
exit "$code"
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* runtime contract that the source-shape gate `check-entwurf-v2-surface` cannot:
|
|
6
6
|
*
|
|
7
7
|
* - G1a: the server actually BOOTS. start.sh static-imports the whole v2 fence
|
|
8
|
-
* graph (entwurf-v2-surface → runner → production → decider/lock/send/
|
|
8
|
+
* graph (entwurf-v2-surface → runner → production → decider/lock/send/
|
|
9
9
|
* mailbox/control-rpc) at top level under strip-types; a strip-types-hostile
|
|
10
10
|
* construct (enum / namespace / parameter property / `import =`) anywhere in
|
|
11
11
|
* that graph would crash the server at boot. A parseable tools/list (id:2)
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* Scope boundary (D1=A안): this gate owns boot + current tool registration/schema.
|
|
20
20
|
* The broad protocol/negative suite stays in check-bridge/test.sh. Only a
|
|
21
21
|
* `tools/list` is sent — no `tools/call` — so there is NO lock/filesystem side
|
|
22
|
-
* effect and no auth/model is needed; it lives in `pnpm check`.
|
|
22
|
+
* effect and no auth/model is needed; it lives in `pnpm run check:full`.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { spawn } from "node:child_process";
|
|
@@ -175,8 +175,8 @@ async function main(): Promise<void> {
|
|
|
175
175
|
ok(`G1b: entwurf_v2 schema requires '${r}'`, required.includes(r), `--- inputSchema ---\n${rawSchema}`);
|
|
176
176
|
}
|
|
177
177
|
ok(
|
|
178
|
-
"G1b: entwurf_v2 intent enum == {fire-and-forget
|
|
179
|
-
setEq(props.intent?.enum, ["fire-and-forget"
|
|
178
|
+
"G1b: entwurf_v2 intent enum == {fire-and-forget} (owned-outcome withdrawn: visible-first cut)",
|
|
179
|
+
setEq(props.intent?.enum, ["fire-and-forget"]),
|
|
180
180
|
`--- inputSchema ---\n${rawSchema}`,
|
|
181
181
|
);
|
|
182
182
|
ok(
|
|
@@ -190,6 +190,30 @@ async function main(): Promise<void> {
|
|
|
190
190
|
`--- inputSchema ---\n${rawSchema}`,
|
|
191
191
|
);
|
|
192
192
|
|
|
193
|
+
// G1e — the S1 resume verb, judged on the RUNTIME surface rather than on source wiring. A
|
|
194
|
+
// source-level check would still pass if the registration were deleted and only the import
|
|
195
|
+
// remained; this is the oracle that says the tool is actually registered and its schema has
|
|
196
|
+
// the shape claimed.
|
|
197
|
+
//
|
|
198
|
+
// What it does NOT prove: that a host will ACCEPT that schema. This gate reads the emitted
|
|
199
|
+
// JSON, it does not run a provider's validator — which is exactly why 7-M-fix's unescaped `[`
|
|
200
|
+
// got past every gate here while breaking real sessions. Host/provider acceptance is an
|
|
201
|
+
// uncovered axis, tracked as issue #61, and nothing below should be read as standing in for it.
|
|
202
|
+
const resume = tools.find((t) => t?.name === "entwurf_resume_call");
|
|
203
|
+
ok(
|
|
204
|
+
"[QK:BRIDGEBOOT-RESUME-CALL-REGISTERED] G1e: entwurf_resume_call is registered on the runtime tools/list surface — not merely imported",
|
|
205
|
+
!!resume,
|
|
206
|
+
);
|
|
207
|
+
const resumeSchema = resume?.inputSchema ?? {};
|
|
208
|
+
const rawResume = JSON.stringify(resumeSchema);
|
|
209
|
+
const resumeProps = resumeSchema.properties ?? {};
|
|
210
|
+
const resumeRequired = Array.isArray(resumeSchema.required) ? resumeSchema.required.map(String) : [];
|
|
211
|
+
ok(
|
|
212
|
+
"[QK:BRIDGEBOOT-RESUME-CALL-TARGET-ONLY] G1e: entwurf_resume_call takes EXACTLY one required input, target — a model, task or prompt knob here would be a second way to decide what a resumed citizen is, when the record already decided",
|
|
213
|
+
setEq(Object.keys(resumeProps), ["target"]) && setEq(resumeRequired, ["target"]),
|
|
214
|
+
`--- inputSchema ---\n${rawResume}`,
|
|
215
|
+
);
|
|
216
|
+
|
|
193
217
|
ok(
|
|
194
218
|
"G1c: native registration fallback is present",
|
|
195
219
|
tools.some((t) => t?.name === "entwurf_register_native"),
|
|
@@ -199,6 +223,29 @@ async function main(): Promise<void> {
|
|
|
199
223
|
!tools.some((t) => ["entwurf", "entwurf_resume", "entwurf_send"].includes(String(t?.name))),
|
|
200
224
|
);
|
|
201
225
|
|
|
226
|
+
// G1f — the public surface is an EXACT set, judged last so the named assertions above
|
|
227
|
+
// keep their own diagnosis. Every check before this one is existential: each names one
|
|
228
|
+
// verb it cares about, so a verb nobody named could be dropped (entwurf_fresh_call was
|
|
229
|
+
// exactly that) or an undecided one added, with the gate still green. "A narrow tool
|
|
230
|
+
// surface is discipline" (AGENTS North Star) is a statement about the WHOLE set, and
|
|
231
|
+
// only equality can carry it. The expected list is written here rather than derived
|
|
232
|
+
// from the server: an oracle computed from the subject proves nothing.
|
|
233
|
+
const publicSurface = tools.map((t) => String(t?.name)).sort();
|
|
234
|
+
const expectedSurface = [
|
|
235
|
+
"entwurf_fresh_call",
|
|
236
|
+
"entwurf_inbox_read",
|
|
237
|
+
"entwurf_peers",
|
|
238
|
+
"entwurf_register_native",
|
|
239
|
+
"entwurf_resume_call",
|
|
240
|
+
"entwurf_self",
|
|
241
|
+
"entwurf_v2",
|
|
242
|
+
];
|
|
243
|
+
ok(
|
|
244
|
+
"[QK:BRIDGEBOOT-PUBLIC-SURFACE-EXACT-SET] G1f: the runtime tools/list surface is EXACTLY the seven shipped garden verbs — no missing verb, no undecided extra, no duplicate",
|
|
245
|
+
publicSurface.length === expectedSurface.length && expectedSurface.every((n, i) => publicSurface[i] === n),
|
|
246
|
+
`--- want ---\n${expectedSurface.join(",")}\n--- got ---\n${publicSurface.join(",")}`,
|
|
247
|
+
);
|
|
248
|
+
|
|
202
249
|
console.log(`\ncheck-entwurf-bridge-boot: ${passed} checks passed`);
|
|
203
250
|
}
|
|
204
251
|
|