@junghanacs/entwurf 0.12.6 → 0.12.7
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 +31 -17
- package/BASELINE.md +42 -8
- package/CHANGELOG.md +22 -0
- package/DELIVERY.md +71 -14
- package/README.md +77 -45
- package/VERIFY.md +18 -13
- package/docs/setup-clean-host.md +80 -22
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
- package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
- package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
- package/mcp/entwurf-bridge/src/index.ts +101 -67
- package/mcp/entwurf-bridge/test.sh +1 -1
- package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
- package/package.json +10 -5
- package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
- package/pi-extensions/lib/entwurf-self-address.ts +58 -15
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
- package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
- package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
- package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
- package/pi-extensions/lib/meta-sender-identity.ts +154 -0
- package/pi-extensions/lib/native-push/adapter.ts +255 -0
- package/pi-extensions/lib/native-push/register.ts +99 -0
- package/run.sh +756 -214
- package/scripts/agy-bridge-config.py +446 -0
- package/scripts/agy-bridge.sh +359 -0
- package/scripts/agy-hooks-bridge.sh +193 -0
- package/scripts/agy-hooks-config.py +257 -0
- package/scripts/agy-imprint.sh +28 -0
- package/scripts/agy-imprint.ts +193 -0
- package/scripts/agy-statusline-bridge.sh +176 -0
- package/scripts/agy-statusline-config.py +213 -0
- package/scripts/agy-statusline.sh +256 -0
- package/scripts/build-bridge.sh +20 -0
- package/scripts/check-agy-sender-identity.ts +364 -0
- package/scripts/check-entwurf-bridge-boot.ts +8 -2
- package/scripts/check-entwurf-deliverability.ts +34 -0
- package/scripts/check-entwurf-self-address.ts +78 -11
- package/scripts/check-entwurf-v2-contract.ts +136 -1
- package/scripts/check-entwurf-v2-decider.ts +95 -1
- package/scripts/check-entwurf-v2-matrix.ts +14 -3
- package/scripts/check-entwurf-v2-native-push.ts +193 -0
- package/scripts/check-entwurf-v2-production.ts +68 -1
- package/scripts/check-entwurf-v2-runner.ts +58 -0
- package/scripts/check-entwurf-v2-surface.ts +35 -0
- package/scripts/check-install-surface.ts +357 -0
- package/scripts/check-native-push-adapter.ts +319 -0
- package/scripts/check-native-push-register.ts +130 -0
- package/scripts/dev-bin.sh +195 -0
- package/scripts/doctor-pi-provider.ts +140 -0
- package/scripts/meta-bridge-doctor.sh +36 -2
- package/scripts/register-pi-package.py +37 -3
- package/scripts/register-pi-provider.py +287 -0
- package/scripts/smoke-agy-hooks-state.sh +172 -0
- package/scripts/smoke-agy-install-state.sh +660 -0
- package/scripts/smoke-agy-native-push-live.ts +243 -0
- package/scripts/smoke-agy-statusline-state.sh +300 -0
- package/scripts/smoke-meta-async-drift.sh +9 -2
- package/scripts/smoke-meta-install-state.sh +20 -0
- package/scripts/smoke-pi-provider-state.sh +182 -0
- package/scripts/smoke-user-scope-citizen.sh +62 -0
- package/scripts/with-dist-lock.sh +81 -0
- package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
- package/scripts/__pycache__/meta-bridge-state.cpython-313.pyc +0 -0
- package/scripts/__pycache__/register-pi-package.cpython-313.pyc +0 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-agy-sender-identity — deterministic gate for the #46 sender-identity lane: the agy
|
|
3
|
+
* hook WRITES the marker, and the bridge's resolver READS it into exactly one identity or
|
|
4
|
+
* refuses.
|
|
5
|
+
*
|
|
6
|
+
* The bug this closes: a birthed agy conversation called entwurf_v2 for real (MCP tool call,
|
|
7
|
+
* not shell-out) and its message still landed as `external-mcp/unknown-host`, non-replyable.
|
|
8
|
+
* The bridge held the owner pid but had no marker to look up: the hook wrote only the
|
|
9
|
+
* meta-record, and the resolver searched the `claude-code` marker directory alone.
|
|
10
|
+
*
|
|
11
|
+
* BEHAVIORAL, not source-regex: the resolver runs against isolated marker/record stores, and
|
|
12
|
+
* the hook runs as a real child process (so the marker's ownerPid is THIS gate's pid — the
|
|
13
|
+
* same parent-pid join the bridge performs in production).
|
|
14
|
+
*
|
|
15
|
+
* Rows:
|
|
16
|
+
* WRITER (real subprocess)
|
|
17
|
+
* - hook writes an antigravity marker keyed by its PARENT pid, matching the record it minted
|
|
18
|
+
* - upsert failure writes NO marker (record authority first — never a garden-id with no record)
|
|
19
|
+
* RESOLVER (real store)
|
|
20
|
+
* - 0 markers → null (anonymous; the caller decides whether that is fatal)
|
|
21
|
+
* - 1 trusted → that identity, on EITHER backend (the claude-code-only lookup was the bug)
|
|
22
|
+
* - marker with no backing record → null (a hint is not an identity)
|
|
23
|
+
* - marker whose nativeSessionId drifted from its record → null
|
|
24
|
+
* - 2 distinct live identities on one owner pid → THROW (never guess, never downgrade)
|
|
25
|
+
* - 2 markers naming the SAME identity → NOT a conflict (an older release wrote parent AND
|
|
26
|
+
* grandparent markers; both may still sit on disk)
|
|
27
|
+
* DOMAIN (the rail the bridge picks the replyable fact from)
|
|
28
|
+
* - antigravity is native-push, claude-code is not — so an agy sender's replyable comes from
|
|
29
|
+
* the adapter probe, never from a mailbox watch it can never arm (보정①)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import assert from "node:assert/strict";
|
|
33
|
+
import { execFileSync } from "node:child_process";
|
|
34
|
+
import * as fs from "node:fs";
|
|
35
|
+
import * as os from "node:os";
|
|
36
|
+
import * as path from "node:path";
|
|
37
|
+
import { fileURLToPath } from "node:url";
|
|
38
|
+
import { nativePushSupported } from "../pi-extensions/lib/entwurf-v2-contract.ts";
|
|
39
|
+
import {
|
|
40
|
+
EntwurfSenderIdentityAmbiguityError,
|
|
41
|
+
META_SENDER_BACKENDS,
|
|
42
|
+
probeNativeSenderAlive,
|
|
43
|
+
resolveTrustedMetaSenderIdentity,
|
|
44
|
+
} from "../pi-extensions/lib/meta-sender-identity.ts";
|
|
45
|
+
import { type MetaIdentity, upsertMetaSession, writeMetaSenderMarker } from "../pi-extensions/lib/meta-session.ts";
|
|
46
|
+
import type { NativePushAdapter } from "../pi-extensions/lib/native-push/adapter.ts";
|
|
47
|
+
|
|
48
|
+
let passed = 0;
|
|
49
|
+
function ok(label: string, cond: boolean): void {
|
|
50
|
+
assert.ok(cond, label);
|
|
51
|
+
console.log(` ok ${label}`);
|
|
52
|
+
passed++;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const REPO_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
56
|
+
const HOOK = path.join(REPO_DIR, "scripts", "agy-imprint.ts");
|
|
57
|
+
|
|
58
|
+
// Isolated stores — this gate never touches the operator's real garden. XDG_STATE_HOME is
|
|
59
|
+
// isolated too: the hook's own log lives under it, and a gate that appends fabricated
|
|
60
|
+
// "upsert-failed" lines to the operator's real agy-imprint.log would be planting false
|
|
61
|
+
// evidence in the exact file a live agy debugging session reads.
|
|
62
|
+
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-sender-identity-"));
|
|
63
|
+
const SESSIONS_DIR = path.join(ROOT, "meta-sessions");
|
|
64
|
+
const SENDERS_DIR = path.join(ROOT, "meta-senders");
|
|
65
|
+
const STATE_DIR = path.join(ROOT, "state");
|
|
66
|
+
process.env.ENTWURF_META_SESSIONS_DIR = SESSIONS_DIR;
|
|
67
|
+
process.env.ENTWURF_META_SENDERS_DIR = SENDERS_DIR;
|
|
68
|
+
process.env.XDG_STATE_HOME = STATE_DIR;
|
|
69
|
+
|
|
70
|
+
// The owner pid the markers are keyed to. process.pid is alive by construction, so
|
|
71
|
+
// writeMetaSenderMarker's start-key is a real one and the read-side pid-reuse guard passes —
|
|
72
|
+
// exactly as an agy host pid does in production.
|
|
73
|
+
const OWNER = process.pid;
|
|
74
|
+
const resolve = () => resolveTrustedMetaSenderIdentity({ ownerPids: [OWNER], sendersDir: SENDERS_DIR });
|
|
75
|
+
|
|
76
|
+
function clearMarkers(): void {
|
|
77
|
+
fs.rmSync(SENDERS_DIR, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
// ── WRITER: the real hook, as a real child process ───────────────────────
|
|
82
|
+
{
|
|
83
|
+
// A synthetic id on purpose: a REAL conversation id here would write plausible-looking
|
|
84
|
+
// imprint lines that a later reader could mistake for live evidence.
|
|
85
|
+
const conversationId = "00000000-gate-0000-0000-agyimprint01";
|
|
86
|
+
const payload = JSON.stringify({
|
|
87
|
+
conversationId,
|
|
88
|
+
workspacePaths: [REPO_DIR],
|
|
89
|
+
modelName: "gemini-3.1-pro-low",
|
|
90
|
+
});
|
|
91
|
+
const stdout = execFileSync(
|
|
92
|
+
process.execPath,
|
|
93
|
+
["--experimental-strip-types", "--disable-warning=ExperimentalWarning", HOOK],
|
|
94
|
+
{ input: payload, encoding: "utf8", env: process.env },
|
|
95
|
+
);
|
|
96
|
+
ok(
|
|
97
|
+
"hook still prints the PreInvocation neutral response (the agy loop must never break)",
|
|
98
|
+
stdout.trim() === '{"injectSteps":[]}',
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// The hook's parent IS this gate — the same shape as production, where the hook's parent
|
|
102
|
+
// is the agy host that also parents the bridge. So the marker must land under OUR pid.
|
|
103
|
+
const markerPath = path.join(SENDERS_DIR, "antigravity", `${OWNER}.json`);
|
|
104
|
+
ok("hook writes an antigravity sender marker keyed by its PARENT pid", fs.existsSync(markerPath));
|
|
105
|
+
|
|
106
|
+
const marker = JSON.parse(fs.readFileSync(markerPath, "utf8"));
|
|
107
|
+
ok("marker carries backend=antigravity", marker.backend === "antigravity");
|
|
108
|
+
ok("marker's nativeSessionId is the agy conversationId", marker.nativeSessionId === conversationId);
|
|
109
|
+
ok(
|
|
110
|
+
"marker is start-key guarded (pid alone is reused; pid+start is boot-unique)",
|
|
111
|
+
typeof marker.ownerStartKey === "string" && marker.ownerStartKey.length > 0,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// End-to-end: the bridge's resolver, over what the hook actually wrote.
|
|
115
|
+
const trusted = resolve();
|
|
116
|
+
ok("resolver turns the hook's marker into ONE trusted identity", trusted !== null);
|
|
117
|
+
ok("resolved identity is the agy citizen the hook minted", trusted?.identity.gardenId === marker.gardenId);
|
|
118
|
+
ok(
|
|
119
|
+
"resolved identity carries backend=antigravity (this is what stops external-mcp/unknown-host)",
|
|
120
|
+
trusted?.identity.backend === "antigravity",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── TRACE CONTRACT: the owner-topology trace is OPT-IN, and stays that way ────
|
|
125
|
+
// PreInvocation runs before EVERY model turn. A trace left on by default would walk /proc six
|
|
126
|
+
// times per turn and append to the imprint log forever — an unbounded diagnostic in the file
|
|
127
|
+
// whose job is to hold birth evidence. It earned its place while proving the pid join and must
|
|
128
|
+
// now cost nothing until someone asks for it, so the DEFAULT is what this gate owns.
|
|
129
|
+
{
|
|
130
|
+
const logFile = path.join(STATE_DIR, "entwurf", "agy-imprint.log");
|
|
131
|
+
const runHook = (trace: boolean) =>
|
|
132
|
+
execFileSync(process.execPath, ["--experimental-strip-types", "--disable-warning=ExperimentalWarning", HOOK], {
|
|
133
|
+
input: JSON.stringify({ conversationId: "00000000-gate-0000-0000-tracecontract", workspacePaths: [REPO_DIR] }),
|
|
134
|
+
encoding: "utf8",
|
|
135
|
+
env: trace ? { ...process.env, ENTWURF_AGY_TRACE_OWNER: "1" } : process.env,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
fs.rmSync(logFile, { force: true });
|
|
139
|
+
runHook(false);
|
|
140
|
+
ok(
|
|
141
|
+
"trace: default env writes NO ancestry line (no per-turn /proc walk, no unbounded log)",
|
|
142
|
+
!fs.readFileSync(logFile, "utf8").includes("pids conversationId="),
|
|
143
|
+
);
|
|
144
|
+
ok(
|
|
145
|
+
"trace: the useful evidence still lands (the marker write is what a healthy host needs)",
|
|
146
|
+
fs.readFileSync(logFile, "utf8").includes("sender marker "),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
fs.rmSync(logFile, { force: true });
|
|
150
|
+
runHook(true);
|
|
151
|
+
ok(
|
|
152
|
+
"trace: ENTWURF_AGY_TRACE_OWNER=1 writes the ancestry line (the only way to say WHY a sender resolves anonymous)",
|
|
153
|
+
/pids conversationId=.* ppid=\d+ chain=/.test(fs.readFileSync(logFile, "utf8")),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── WRITER negative: no record → no marker ───────────────────────────────
|
|
158
|
+
{
|
|
159
|
+
clearMarkers();
|
|
160
|
+
// A file where the record dir must be: upsertMetaSession cannot write, so it throws.
|
|
161
|
+
const brokenRoot = path.join(ROOT, "broken-sessions");
|
|
162
|
+
fs.writeFileSync(brokenRoot, "not a directory\n");
|
|
163
|
+
execFileSync(process.execPath, ["--experimental-strip-types", "--disable-warning=ExperimentalWarning", HOOK], {
|
|
164
|
+
input: JSON.stringify({ conversationId: "dead-beef-0000", workspacePaths: [REPO_DIR] }),
|
|
165
|
+
encoding: "utf8",
|
|
166
|
+
env: { ...process.env, ENTWURF_META_SESSIONS_DIR: brokenRoot },
|
|
167
|
+
});
|
|
168
|
+
ok(
|
|
169
|
+
"upsert failure writes NO sender marker (record authority first — no garden-id without a record)",
|
|
170
|
+
!fs.existsSync(path.join(SENDERS_DIR, "antigravity")),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// The gate must be invisible to the operator's garden: the hook's log follows
|
|
174
|
+
// XDG_STATE_HOME, so it has to land in the isolated root, never in ~/.local/state.
|
|
175
|
+
ok(
|
|
176
|
+
"hook's imprint log stays inside the isolated XDG_STATE_HOME (no writes to the operator's real log)",
|
|
177
|
+
fs.existsSync(path.join(STATE_DIR, "entwurf", "agy-imprint.log")),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
clearMarkers();
|
|
181
|
+
ok("no marker → null (anonymous; the caller decides whether that is fatal)", resolve() === null);
|
|
182
|
+
|
|
183
|
+
// A trusted identity on EITHER backend. The claude-code-only lookup is the bug — an agy
|
|
184
|
+
// citizen was invisible to the very bridge its own conversation spawned.
|
|
185
|
+
for (const backend of META_SENDER_BACKENDS) {
|
|
186
|
+
clearMarkers();
|
|
187
|
+
const nativeSessionId = `native-${backend}`;
|
|
188
|
+
const record = upsertMetaSession({ input: { backend, nativeSessionId, cwd: REPO_DIR } });
|
|
189
|
+
writeMetaSenderMarker({
|
|
190
|
+
backend,
|
|
191
|
+
gardenId: record.record.gardenId,
|
|
192
|
+
nativeSessionId,
|
|
193
|
+
cwd: REPO_DIR,
|
|
194
|
+
ownerPid: OWNER,
|
|
195
|
+
sendersDir: SENDERS_DIR,
|
|
196
|
+
});
|
|
197
|
+
const trusted = resolve();
|
|
198
|
+
ok(
|
|
199
|
+
`backend ${backend}: live record-backed marker → trusted identity`,
|
|
200
|
+
trusted?.identity.gardenId === record.record.gardenId,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
clearMarkers();
|
|
204
|
+
writeMetaSenderMarker({
|
|
205
|
+
backend: "antigravity",
|
|
206
|
+
gardenId: "20260713T161524-0db936",
|
|
207
|
+
nativeSessionId: "no-such-conversation",
|
|
208
|
+
cwd: REPO_DIR,
|
|
209
|
+
ownerPid: OWNER,
|
|
210
|
+
sendersDir: SENDERS_DIR,
|
|
211
|
+
});
|
|
212
|
+
ok("marker with NO backing record → null (a stale marker names nobody)", resolve() === null);
|
|
213
|
+
|
|
214
|
+
{
|
|
215
|
+
clearMarkers();
|
|
216
|
+
const record = upsertMetaSession({
|
|
217
|
+
input: { backend: "antigravity", nativeSessionId: "conversation-A", cwd: REPO_DIR },
|
|
218
|
+
});
|
|
219
|
+
writeMetaSenderMarker({
|
|
220
|
+
backend: "antigravity",
|
|
221
|
+
gardenId: record.record.gardenId,
|
|
222
|
+
nativeSessionId: "conversation-B", // drifted from the record
|
|
223
|
+
cwd: REPO_DIR,
|
|
224
|
+
ownerPid: OWNER,
|
|
225
|
+
sendersDir: SENDERS_DIR,
|
|
226
|
+
});
|
|
227
|
+
ok("marker whose nativeSessionId drifted from its record → null", resolve() === null);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// THE REFUSAL. One native host driving two live sessions breaks the pid→conversation
|
|
231
|
+
// binding the marker rests on. We can see both identities and cannot name the caller, so
|
|
232
|
+
// we send under neither: a wrong attribution is worse than no send, and a silent fallback
|
|
233
|
+
// to anonymous would hide an identity we already hold.
|
|
234
|
+
{
|
|
235
|
+
clearMarkers();
|
|
236
|
+
const a = upsertMetaSession({ input: { backend: "antigravity", nativeSessionId: "conv-A", cwd: REPO_DIR } });
|
|
237
|
+
const b = upsertMetaSession({ input: { backend: "claude-code", nativeSessionId: "sess-B", cwd: REPO_DIR } });
|
|
238
|
+
writeMetaSenderMarker({
|
|
239
|
+
backend: "antigravity",
|
|
240
|
+
gardenId: a.record.gardenId,
|
|
241
|
+
nativeSessionId: "conv-A",
|
|
242
|
+
cwd: REPO_DIR,
|
|
243
|
+
ownerPid: OWNER,
|
|
244
|
+
sendersDir: SENDERS_DIR,
|
|
245
|
+
});
|
|
246
|
+
writeMetaSenderMarker({
|
|
247
|
+
backend: "claude-code",
|
|
248
|
+
gardenId: b.record.gardenId,
|
|
249
|
+
nativeSessionId: "sess-B",
|
|
250
|
+
cwd: REPO_DIR,
|
|
251
|
+
ownerPid: OWNER,
|
|
252
|
+
sendersDir: SENDERS_DIR,
|
|
253
|
+
});
|
|
254
|
+
let threw: unknown = null;
|
|
255
|
+
try {
|
|
256
|
+
resolve();
|
|
257
|
+
} catch (err) {
|
|
258
|
+
threw = err;
|
|
259
|
+
}
|
|
260
|
+
ok(
|
|
261
|
+
"two distinct live identities on one owner pid → THROW (never guess, never downgrade to anonymous)",
|
|
262
|
+
threw instanceof EntwurfSenderIdentityAmbiguityError,
|
|
263
|
+
);
|
|
264
|
+
ok(
|
|
265
|
+
"the refusal names both citizens so the wiring bug is debuggable",
|
|
266
|
+
threw instanceof EntwurfSenderIdentityAmbiguityError && threw.gardenIds.length === 2,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Same identity reached twice is NOT ambiguity: an older release wrote a marker for the
|
|
271
|
+
// parent AND the grandparent. Throwing there would refuse a send that has one honest answer.
|
|
272
|
+
{
|
|
273
|
+
clearMarkers();
|
|
274
|
+
const record = upsertMetaSession({
|
|
275
|
+
input: { backend: "antigravity", nativeSessionId: "conv-solo", cwd: REPO_DIR },
|
|
276
|
+
});
|
|
277
|
+
for (const pid of [OWNER, process.ppid]) {
|
|
278
|
+
writeMetaSenderMarker({
|
|
279
|
+
backend: "antigravity",
|
|
280
|
+
gardenId: record.record.gardenId,
|
|
281
|
+
nativeSessionId: "conv-solo",
|
|
282
|
+
cwd: REPO_DIR,
|
|
283
|
+
ownerPid: pid,
|
|
284
|
+
sendersDir: SENDERS_DIR,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
const trusted = resolveTrustedMetaSenderIdentity({
|
|
288
|
+
ownerPids: [OWNER, process.ppid],
|
|
289
|
+
sendersDir: SENDERS_DIR,
|
|
290
|
+
});
|
|
291
|
+
ok(
|
|
292
|
+
"two markers naming the SAME identity → not a conflict, one identity",
|
|
293
|
+
trusted?.identity.gardenId === record.record.gardenId,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
ok(
|
|
297
|
+
"antigravity is a native-push backend → its replyable comes from the adapter probe",
|
|
298
|
+
nativePushSupported("antigravity"),
|
|
299
|
+
);
|
|
300
|
+
ok(
|
|
301
|
+
"claude-code is NOT native-push → its replyable stays on the mailbox receiver axis (보정①: the rails never borrow each other's facts)",
|
|
302
|
+
!nativePushSupported("claude-code"),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// ── PROBE ERROR POLICY: an adapter outcome is a FACT, a throw is a DEFECT ────
|
|
306
|
+
// The adapter reports "not reachable" as a value (dead / indeterminate). So a throw out of the
|
|
307
|
+
// probe can only be a broken registry or a probe that could not run at all. Folding that into
|
|
308
|
+
// replyable:false would tell the receiver a lie about this sender AND bury the defect — the
|
|
309
|
+
// exact Crash-Don't-Warn shape this lane exists to remove. It must propagate.
|
|
310
|
+
{
|
|
311
|
+
const identity: Pick<MetaIdentity, "backend" | "nativeSessionId"> = {
|
|
312
|
+
backend: "antigravity",
|
|
313
|
+
nativeSessionId: "conv-x",
|
|
314
|
+
};
|
|
315
|
+
const adapterWith = (probe: NativePushAdapter["probe"]) => ({ resolveAdapter: () => ({ probe }) });
|
|
316
|
+
|
|
317
|
+
ok(
|
|
318
|
+
"probe alive → replyable true",
|
|
319
|
+
(await probeNativeSenderAlive(
|
|
320
|
+
identity,
|
|
321
|
+
adapterWith(async () => ({ status: "alive", route: { lsAddress: "127.0.0.1:1" } })),
|
|
322
|
+
)) === true,
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
for (const status of ["dead", "indeterminate"] as const) {
|
|
326
|
+
ok(
|
|
327
|
+
`probe ${status} → replyable false (an adapter's OWN outcome is the fact)`,
|
|
328
|
+
(await probeNativeSenderAlive(
|
|
329
|
+
identity,
|
|
330
|
+
adapterWith(async () => ({ status, reason: "gate" })),
|
|
331
|
+
)) === false,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let registryThrew = false;
|
|
336
|
+
try {
|
|
337
|
+
await probeNativeSenderAlive(identity, {
|
|
338
|
+
resolveAdapter: () => {
|
|
339
|
+
throw new Error("no native-push adapter owns backend id");
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
} catch {
|
|
343
|
+
registryThrew = true;
|
|
344
|
+
}
|
|
345
|
+
ok("unresolvable adapter → THROWS (a registry bug is not a fact about the citizen)", registryThrew);
|
|
346
|
+
|
|
347
|
+
let probeThrew = false;
|
|
348
|
+
try {
|
|
349
|
+
await probeNativeSenderAlive(
|
|
350
|
+
identity,
|
|
351
|
+
adapterWith(async () => {
|
|
352
|
+
throw new Error("pgrep: command not found");
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
} catch {
|
|
356
|
+
probeThrew = true;
|
|
357
|
+
}
|
|
358
|
+
ok("probe runner blows up → THROWS, never a silent replyable:false (Crash-Don't-Warn)", probeThrew);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
console.log(`\ncheck-agy-sender-identity: ${passed} checks passed`);
|
|
362
|
+
} finally {
|
|
363
|
+
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
364
|
+
}
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
* - G1b: `entwurf_v2` is registered on the runtime tools/list surface with the
|
|
14
14
|
* expected input schema (props ⊇ target/intent/message/mode/wants_reply;
|
|
15
15
|
* required ⊇ target/intent/message; intent/mode enums).
|
|
16
|
+
* - G1c/G1d: the shipped manual native-registration fallback is present and
|
|
17
|
+
* retired v1 verbs remain absent.
|
|
16
18
|
*
|
|
17
|
-
* Scope boundary (D1=A안): this gate owns boot +
|
|
19
|
+
* Scope boundary (D1=A안): this gate owns boot + current tool registration/schema.
|
|
18
20
|
* The broad protocol/negative suite stays in check-bridge/test.sh. Only a
|
|
19
21
|
* `tools/list` is sent — no `tools/call` — so there is NO lock/filesystem side
|
|
20
22
|
* effect and no auth/model is needed; it lives in `pnpm check`.
|
|
@@ -189,7 +191,11 @@ async function main(): Promise<void> {
|
|
|
189
191
|
);
|
|
190
192
|
|
|
191
193
|
ok(
|
|
192
|
-
"G1c:
|
|
194
|
+
"G1c: native registration fallback is present",
|
|
195
|
+
tools.some((t) => t?.name === "entwurf_register_native"),
|
|
196
|
+
);
|
|
197
|
+
ok(
|
|
198
|
+
"G1d: legacy v1 MCP tools are absent",
|
|
193
199
|
!tools.some((t) => ["entwurf", "entwurf_resume", "entwurf_send"].includes(String(t?.name))),
|
|
194
200
|
);
|
|
195
201
|
|
|
@@ -24,6 +24,8 @@ import { fileURLToPath } from "node:url";
|
|
|
24
24
|
import {
|
|
25
25
|
computeMetaReceiverActive,
|
|
26
26
|
mailboxConversationalDeliverable,
|
|
27
|
+
type NativePushDeliverabilityFacts,
|
|
28
|
+
nativePushDeliverable,
|
|
27
29
|
receiverMarkerMatchesIdentity,
|
|
28
30
|
} from "../pi-extensions/lib/entwurf-deliverability.ts";
|
|
29
31
|
|
|
@@ -144,6 +146,38 @@ ok(
|
|
|
144
146
|
ok("null marker → false (fail-closed)", !receiverMarkerMatchesIdentity(null, ID));
|
|
145
147
|
ok("undefined marker → false (fail-closed)", !receiverMarkerMatchesIdentity(undefined, ID));
|
|
146
148
|
|
|
149
|
+
// ── nativePushDeliverable: the SEPARATE native-push axis (봉인 6) ────────────
|
|
150
|
+
// deliverable ⟺ recordBacked ∧ probeAlive. Distinct axis from the mailbox gate: it
|
|
151
|
+
// must NOT depend on watchArmed (the mailbox-only atom), so a native-push target's
|
|
152
|
+
// deliverability is unaffected by any watch/ownerAlive signal.
|
|
153
|
+
const NP = (f: Parameters<typeof nativePushDeliverable>[0]) => nativePushDeliverable(f).deliverable;
|
|
154
|
+
|
|
155
|
+
ok("native-push: record backed + probe alive → deliverable", NP({ recordBacked: true, probeAlive: true }) === true);
|
|
156
|
+
ok(
|
|
157
|
+
"native-push: no record → NOT deliverable (record-less native id)",
|
|
158
|
+
NP({ recordBacked: false, probeAlive: true }) === false,
|
|
159
|
+
);
|
|
160
|
+
ok(
|
|
161
|
+
"native-push: probe not alive → NOT deliverable (no live conversation)",
|
|
162
|
+
NP({ recordBacked: true, probeAlive: false }) === false,
|
|
163
|
+
);
|
|
164
|
+
ok("native-push: undefined axes → fail-closed inactive", NP({ recordBacked: true }) === false);
|
|
165
|
+
ok("native-push: both undefined → fail-closed inactive", NP({}) === false);
|
|
166
|
+
// axis separation (보정①): the native-push predicate has NO watchArmed/ownerAlive
|
|
167
|
+
// concept — its shape carries only recordBacked/probeAlive, so a mailbox liveness
|
|
168
|
+
// fact cannot be smuggled into it.
|
|
169
|
+
ok(
|
|
170
|
+
"native-push: reason distinguishes record-less from dead-probe",
|
|
171
|
+
nativePushDeliverable({ recordBacked: false, probeAlive: true }).reason !==
|
|
172
|
+
nativePushDeliverable({ recordBacked: true, probeAlive: false }).reason,
|
|
173
|
+
);
|
|
174
|
+
// Type-level guard: the fact keyset is EXACTLY recordBacked|probeAlive — if a future
|
|
175
|
+
// edit added `watchArmed`/`ownerAlive` (the mailbox atom fields), this stops compiling,
|
|
176
|
+
// so the mailbox atom can never be smuggled into the native-push axis (보정①).
|
|
177
|
+
type NativePushFactKeys = keyof NativePushDeliverabilityFacts;
|
|
178
|
+
const _nativePushKeysGuard: NativePushFactKeys extends "recordBacked" | "probeAlive" ? true : never = true;
|
|
179
|
+
void _nativePushKeysGuard;
|
|
180
|
+
|
|
147
181
|
// ── WIRING: self-addressability shares the SAME atom ────────────────────────
|
|
148
182
|
const selfSrc = readFileSync(path.join(REPO_DIR, "pi-extensions", "lib", "entwurf-self-address.ts"), "utf8");
|
|
149
183
|
ok(
|
|
@@ -77,26 +77,93 @@ function row(facts: SelfAddressabilityFacts): { replyable: boolean; socketState:
|
|
|
77
77
|
ok("pi + no session id → socketState none", none.socketState === "none");
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
// meta-session
|
|
80
|
+
// meta-session / SELF-FETCH rail (claude-code): 3-conjunct deliverability
|
|
81
|
+
// (recordBacked ∧ ownerAlive ∧ watchArmed).
|
|
81
82
|
{
|
|
82
|
-
const
|
|
83
|
-
|
|
83
|
+
const meta = (f: Partial<SelfAddressabilityFacts>): SelfAddressabilityFacts => ({
|
|
84
|
+
origin: "meta-session",
|
|
85
|
+
metaDeliveryDomain: "self-fetch",
|
|
86
|
+
...f,
|
|
87
|
+
});
|
|
88
|
+
const full = row(meta({ recordBacked: true, ownerAlive: true, watchArmed: true }));
|
|
89
|
+
ok("meta/self-fetch + record + owner-alive + watch-armed → replyable", full.replyable === true);
|
|
84
90
|
|
|
85
91
|
// (b) REGRESSION-PROOF row: record PRESENT but owner dead.
|
|
86
|
-
const ownerDead = row({
|
|
87
|
-
ok(
|
|
92
|
+
const ownerDead = row(meta({ recordBacked: true, ownerAlive: false, watchArmed: true }));
|
|
93
|
+
ok(
|
|
94
|
+
"meta/self-fetch + record present + owner-dead (start-key mismatch) → NOT replyable",
|
|
95
|
+
ownerDead.replyable === false,
|
|
96
|
+
);
|
|
88
97
|
|
|
89
98
|
// (c) REGRESSION-PROOF row: record + owner present but watch never armed.
|
|
90
|
-
const watchUnarmed = row({
|
|
91
|
-
ok("meta + record
|
|
99
|
+
const watchUnarmed = row(meta({ recordBacked: true, ownerAlive: true, watchArmed: false }));
|
|
100
|
+
ok("meta/self-fetch + record + owner-alive + watch-unarmed → NOT replyable", watchUnarmed.replyable === false);
|
|
92
101
|
|
|
93
102
|
// not backed by a record at all → false (the all-absent baseline; weakest row).
|
|
94
|
-
const unbacked = row({
|
|
95
|
-
ok("meta + no backing record → NOT replyable", unbacked.replyable === false);
|
|
103
|
+
const unbacked = row(meta({ recordBacked: false, ownerAlive: false, watchArmed: false }));
|
|
104
|
+
ok("meta/self-fetch + no backing record → NOT replyable", unbacked.replyable === false);
|
|
96
105
|
|
|
97
106
|
// fail-closed on missing axes (undefined treated as false, never optimistic).
|
|
98
|
-
const partial = row({
|
|
99
|
-
ok("meta + record but undefined owner/watch → fail-closed NOT replyable", partial.replyable === false);
|
|
107
|
+
const partial = row(meta({ recordBacked: true }));
|
|
108
|
+
ok("meta/self-fetch + record but undefined owner/watch → fail-closed NOT replyable", partial.replyable === false);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// meta-session / NATIVE-PUSH rail (antigravity): a SEPARATE axis — recordBacked ∧ probeAlive.
|
|
112
|
+
// The two rails must not be able to borrow each other's facts (보정①): a mailbox signal
|
|
113
|
+
// deciding an agy reply, or a probe deciding a Claude reply, is a category error.
|
|
114
|
+
{
|
|
115
|
+
const push = (f: Partial<SelfAddressabilityFacts>): SelfAddressabilityFacts => ({
|
|
116
|
+
origin: "meta-session",
|
|
117
|
+
metaDeliveryDomain: "native-push",
|
|
118
|
+
...f,
|
|
119
|
+
});
|
|
120
|
+
const reachable = row(push({ recordBacked: true, probeAlive: true }));
|
|
121
|
+
ok("meta/native-push + record + probe-alive → replyable", reachable.replyable === true);
|
|
122
|
+
|
|
123
|
+
// THE ROW THIS RAIL EXISTS FOR: an agy citizen never arms a mailbox watch. Under the
|
|
124
|
+
// self-fetch atom it would be un-replyable forever; on its own rail it is reachable.
|
|
125
|
+
ok(
|
|
126
|
+
"meta/native-push + record + probe-alive + watch NEVER armed → STILL replyable (no mailbox axis leak)",
|
|
127
|
+
row(push({ recordBacked: true, probeAlive: true, watchArmed: false })).replyable === true,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Dead conversation: the host is gone, so an injected reply has nowhere to land.
|
|
131
|
+
ok(
|
|
132
|
+
"meta/native-push + record + probe-dead → NOT replyable",
|
|
133
|
+
row(push({ recordBacked: true, probeAlive: false })).replyable === false,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// No record → not an identity, whatever the probe says.
|
|
137
|
+
ok(
|
|
138
|
+
"meta/native-push + no record + probe-alive → NOT replyable",
|
|
139
|
+
row(push({ recordBacked: false, probeAlive: true })).replyable === false,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// fail-closed: an unsupplied probe fact is false, never optimistic.
|
|
143
|
+
ok(
|
|
144
|
+
"meta/native-push + record but undefined probe → fail-closed NOT replyable",
|
|
145
|
+
row(push({ recordBacked: true })).replyable === false,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// A native-push citizen must NOT be able to buy replyability with mailbox facts.
|
|
149
|
+
ok(
|
|
150
|
+
"meta/native-push + owner-alive + watch-armed but probe-dead → NOT replyable (mailbox facts cannot rescue it)",
|
|
151
|
+
row(push({ recordBacked: true, ownerAlive: true, watchArmed: true, probeAlive: false })).replyable === false,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// meta-session with NO rail declared: we cannot say how a reply would travel, so we do not
|
|
156
|
+
// claim it would arrive. The domain is derived from nativePushSupported(backend) — a caller
|
|
157
|
+
// that forgets it gets a refusal, not an optimistic guess.
|
|
158
|
+
{
|
|
159
|
+
const noDomain = row({
|
|
160
|
+
origin: "meta-session",
|
|
161
|
+
recordBacked: true,
|
|
162
|
+
ownerAlive: true,
|
|
163
|
+
watchArmed: true,
|
|
164
|
+
probeAlive: true,
|
|
165
|
+
});
|
|
166
|
+
ok("meta + NO delivery domain (all facts true) → fail-closed NOT replyable", noDomain.replyable === false);
|
|
100
167
|
}
|
|
101
168
|
|
|
102
169
|
// external-mcp: never replyable.
|