@junghanacs/entwurf 0.12.0 → 0.12.2
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 +40 -0
- package/README.md +88 -28
- package/docs/setup-clean-host.md +117 -219
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +454 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +111 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +1683 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +121 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +155 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +119 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +160 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +63 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +81 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +290 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +254 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +365 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +64 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +218 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +108 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-resume-marker.js +33 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +116 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +184 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +237 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +216 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +164 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +66 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +1502 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +50 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +259 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-probe.js +81 -0
- package/mcp/entwurf-bridge/dist/protocol.js +29 -0
- package/mcp/entwurf-bridge/start.sh +49 -7
- package/mcp/entwurf-bridge/test.sh +12 -3
- package/mcp/entwurf-bridge/tsconfig.build.json +42 -0
- package/package.json +30 -9
- package/pi/meta-bridge/.claude-plugin/marketplace.json +0 -1
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +101 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +10 -78
- package/pi-extensions/lib/entwurf-v2-decider.ts +6 -2
- package/pi-extensions/lib/entwurf-v2-production.ts +26 -4
- package/run.sh +150 -15
- package/scripts/check-entwurf-bridge-pi-free.ts +146 -0
- package/scripts/check-entwurf-v2-contract.ts +6 -4
- package/scripts/check-meta-manifest-schema.py +145 -0
- package/scripts/meta-bridge-install.sh +17 -3
- package/scripts/meta-bridge-state.py +37 -10
- package/scripts/smoke-acp-bundled-mcp-live.ts +13 -2
- package/scripts/smoke-acp-carrier-augment-live.ts +35 -19
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// check-entwurf-bridge-pi-free (0.12.1 A-gate, static half)
|
|
2
|
+
//
|
|
3
|
+
// INVARIANT: the entwurf-bridge MCP server must boot WITHOUT any pi package
|
|
4
|
+
// (`@earendil-works/pi-*`). entwurf is a harness-neutral npm package; pi is one
|
|
5
|
+
// optional adapter lane, not a boot dependency. A plain `npm install
|
|
6
|
+
// @junghanacs/entwurf` (no pi peers) must still stand `entwurf-bridge` up and
|
|
7
|
+
// answer MCP `tools/list`.
|
|
8
|
+
//
|
|
9
|
+
// This gate is the STATIC half of that contract: it walks the EAGER static
|
|
10
|
+
// value-import closure of the bridge entry (`mcp/entwurf-bridge/src/index.ts`)
|
|
11
|
+
// and fails if any reachable module statically value-imports `@earendil-works/*`.
|
|
12
|
+
//
|
|
13
|
+
// "Eager static value-import" is the precise boundary (GPT-agreed):
|
|
14
|
+
// - `import type …` → erased by strip-types → NOT followed
|
|
15
|
+
// - `import { type A, type B }` → all-type, erased → NOT followed
|
|
16
|
+
// - `import { type A, b }` → has a value binding, kept → followed
|
|
17
|
+
// - `import x` / `import * as x` → value → followed
|
|
18
|
+
// - `import "x"` (side-effect) → value → followed
|
|
19
|
+
// - `export { a } from "x"` → value re-export → followed
|
|
20
|
+
// - `export type { a } from "x"` → type re-export, erased → NOT followed
|
|
21
|
+
// - `await import("x")` (dynamic) → INTENDED lazy boundary → NOT followed
|
|
22
|
+
//
|
|
23
|
+
// The dynamic-import exemption is deliberate: B-2 makes the pi-coding-agent
|
|
24
|
+
// `preflight` a lazy `await import("./entwurf-preflight.ts")` reached ONLY on an
|
|
25
|
+
// owned-outcome spawn-bg resume, so it is allowed to pull pi at runtime in that
|
|
26
|
+
// one branch. The runtime boot smoke (separate half) is the final authority that
|
|
27
|
+
// peers/self/list/mailbox-deliver come up pi-free.
|
|
28
|
+
|
|
29
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
30
|
+
import { dirname, relative, resolve } from "node:path";
|
|
31
|
+
|
|
32
|
+
const REPO = resolve(import.meta.dirname, "..");
|
|
33
|
+
const ENTRY = resolve(REPO, "mcp/entwurf-bridge/src/index.ts");
|
|
34
|
+
const PI_SPECIFIER = /^@earendil-works\/pi-(ai|coding-agent|tui)(\/|$)/;
|
|
35
|
+
|
|
36
|
+
/** Strip line + block comments so a commented-out import never registers. */
|
|
37
|
+
function stripComments(src: string): string {
|
|
38
|
+
return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/[^\n]*/g, "$1");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Is every named binding in a `{ … }` clause a `type` binding? (→ import erased) */
|
|
42
|
+
function allNamedAreType(clause: string): boolean {
|
|
43
|
+
const parts = clause
|
|
44
|
+
.split(",")
|
|
45
|
+
.map((p) => p.trim())
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
if (parts.length === 0) return false; // `import {} from` is odd; treat as value.
|
|
48
|
+
return parts.every((p) => /^type\s/.test(p));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Return the runtime-loaded (non-type, non-dynamic) module specifiers of `src`.
|
|
53
|
+
* Only static `import …`/`export … from` whose effect survives strip-types.
|
|
54
|
+
*/
|
|
55
|
+
function runtimeSpecifiers(src: string): string[] {
|
|
56
|
+
const clean = stripComments(src);
|
|
57
|
+
const specs: string[] = [];
|
|
58
|
+
// import … from "spec" | import "spec"
|
|
59
|
+
const importRe = /\bimport\b([^"'`;]*?)\bfrom\b\s*["'`]([^"'`]+)["'`]|\bimport\s*["'`]([^"'`]+)["'`]/g;
|
|
60
|
+
for (let m = importRe.exec(clean); m !== null; m = importRe.exec(clean)) {
|
|
61
|
+
const sideEffect = m[3];
|
|
62
|
+
if (sideEffect) {
|
|
63
|
+
specs.push(sideEffect);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const clause = (m[1] ?? "").trim();
|
|
67
|
+
const spec = m[2];
|
|
68
|
+
if (/^type\b/.test(clause)) continue; // `import type …`
|
|
69
|
+
const braced = clause.match(/\{([^}]*)\}/);
|
|
70
|
+
// A pure `{ … }` clause (no default/namespace) that is all-type is erased.
|
|
71
|
+
if (braced && !/^[A-Za-z0-9_$]/.test(clause) && allNamedAreType(braced[1])) continue;
|
|
72
|
+
specs.push(spec);
|
|
73
|
+
}
|
|
74
|
+
// export … from "spec" (value re-export); export type … from → skip
|
|
75
|
+
const reexportRe = /\bexport\b([^"'`;]*?)\bfrom\b\s*["'`]([^"'`]+)["'`]/g;
|
|
76
|
+
for (let m = reexportRe.exec(clean); m !== null; m = reexportRe.exec(clean)) {
|
|
77
|
+
const clause = (m[1] ?? "").trim();
|
|
78
|
+
const spec = m[2];
|
|
79
|
+
if (/^type\b/.test(clause)) continue; // `export type … from`
|
|
80
|
+
const braced = clause.match(/\{([^}]*)\}/);
|
|
81
|
+
if (braced && allNamedAreType(braced[1])) continue;
|
|
82
|
+
specs.push(spec);
|
|
83
|
+
}
|
|
84
|
+
return specs;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Resolve a relative specifier to an on-disk source file, or null if external. */
|
|
88
|
+
function resolveLocal(fromFile: string, spec: string): string | null {
|
|
89
|
+
if (!spec.startsWith(".")) return null; // bare/external — handled by caller
|
|
90
|
+
const base = resolve(dirname(fromFile), spec);
|
|
91
|
+
const candidates = [
|
|
92
|
+
base,
|
|
93
|
+
`${base}.ts`,
|
|
94
|
+
`${base}.js`,
|
|
95
|
+
`${base}.mjs`,
|
|
96
|
+
resolve(base, "index.ts"),
|
|
97
|
+
resolve(base, "index.js"),
|
|
98
|
+
];
|
|
99
|
+
for (const c of candidates) if (existsSync(c)) return c;
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// BFS over the eager static value-import closure.
|
|
104
|
+
const visited = new Set<string>();
|
|
105
|
+
const violations: Array<{ chain: string[]; specifier: string }> = [];
|
|
106
|
+
const queue: Array<{ file: string; chain: string[] }> = [{ file: ENTRY, chain: [relative(REPO, ENTRY)] }];
|
|
107
|
+
|
|
108
|
+
while (queue.length > 0) {
|
|
109
|
+
const { file, chain } = queue.shift()!;
|
|
110
|
+
if (visited.has(file)) continue;
|
|
111
|
+
visited.add(file);
|
|
112
|
+
let src: string;
|
|
113
|
+
try {
|
|
114
|
+
src = readFileSync(file, "utf8");
|
|
115
|
+
} catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
for (const spec of runtimeSpecifiers(src)) {
|
|
119
|
+
if (PI_SPECIFIER.test(spec)) {
|
|
120
|
+
violations.push({ chain, specifier: spec });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const local = resolveLocal(file, spec);
|
|
124
|
+
if (local && !visited.has(local)) {
|
|
125
|
+
queue.push({ file: local, chain: [...chain, relative(REPO, local)] });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (violations.length > 0) {
|
|
131
|
+
console.error("[check-entwurf-bridge-pi-free] FAIL: bridge boot closure statically value-imports pi:");
|
|
132
|
+
for (const v of violations) {
|
|
133
|
+
console.error(` - ${v.specifier}`);
|
|
134
|
+
console.error(` via ${v.chain.join(" -> ")}`);
|
|
135
|
+
}
|
|
136
|
+
console.error(
|
|
137
|
+
"\n The entwurf-bridge MCP server must boot pi-free. Move the pi value-import behind a\n" +
|
|
138
|
+
" type-only import, a pi-side module the bridge does not reach, or a lazy `await import()`\n" +
|
|
139
|
+
" on the owned-outcome resume branch only.",
|
|
140
|
+
);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
console.log(
|
|
145
|
+
`[check-entwurf-bridge-pi-free] ok — bridge boot closure is pi-free (${visited.size} modules walked, no static @earendil-works/pi-* value-import)`,
|
|
146
|
+
);
|
|
@@ -41,10 +41,6 @@ import {
|
|
|
41
41
|
ENTWURF_V2_OWNERSHIPS,
|
|
42
42
|
ENTWURF_V2_REJECT_REASONS,
|
|
43
43
|
ENTWURF_V2_TRANSPORTS,
|
|
44
|
-
EntwurfV2InputSchema,
|
|
45
|
-
EntwurfV2ReceiptRejectSchema,
|
|
46
|
-
EntwurfV2ReceiptSchema,
|
|
47
|
-
EntwurfV2ReceiptSuccessSchema,
|
|
48
44
|
FACT_LIVENESSES,
|
|
49
45
|
type FactLiveness,
|
|
50
46
|
factLivenessOf,
|
|
@@ -57,6 +53,12 @@ import {
|
|
|
57
53
|
resolveDispatch,
|
|
58
54
|
UNSUPPORTED_DISPATCH_TABLE,
|
|
59
55
|
} from "../pi-extensions/lib/entwurf-v2-contract.ts";
|
|
56
|
+
import {
|
|
57
|
+
EntwurfV2InputSchema,
|
|
58
|
+
EntwurfV2ReceiptRejectSchema,
|
|
59
|
+
EntwurfV2ReceiptSchema,
|
|
60
|
+
EntwurfV2ReceiptSuccessSchema,
|
|
61
|
+
} from "../pi-extensions/lib/entwurf-v2-contract-schema.ts";
|
|
60
62
|
import { SESSION_ID_RE } from "../pi-extensions/lib/session-id.js";
|
|
61
63
|
import type { SocketLiveness } from "../pi-extensions/lib/socket-probe.ts";
|
|
62
64
|
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# check-meta-manifest-schema — deterministic, CLI-version-INDEPENDENT guard for the
|
|
3
|
+
# meta-bridge plugin manifests + the installed-vs-clone MCP wiring decision.
|
|
4
|
+
#
|
|
5
|
+
# Why this exists (0.12.2): `claude plugin validate` is a CLOSED schema — it REJECTS
|
|
6
|
+
# unrecognized keys, and the allowed keyset differs by Claude Code version. 0.12.1
|
|
7
|
+
# shipped a marketplace.json carrying a root `description`; Claude 2.1.195 (the dev
|
|
8
|
+
# box) accepted it, Claude 2.1.97 (the install floor) rejected it with
|
|
9
|
+
# `Unrecognized key: "description"`, so `entwurf install-meta-bridge` died on the
|
|
10
|
+
# floor host while the release looked green. The lesson: a "nice to have" decorative
|
|
11
|
+
# key in a closed-schema manifest is a future regression surface. This guard pins the
|
|
12
|
+
# committed manifests to the MINIMAL keyset confirmed to validate on the lowest
|
|
13
|
+
# supported Claude, independent of whatever CLI version happens to run here.
|
|
14
|
+
#
|
|
15
|
+
# It also asserts meta-bridge-state.py::desired_mcp() picks the stable `entwurf-bridge`
|
|
16
|
+
# bin for an installed package and the clone's start.sh for a dev clone (the other
|
|
17
|
+
# 0.12.1 install-surface fragility: baking the pnpm store path goes stale on peer bumps).
|
|
18
|
+
#
|
|
19
|
+
# Offline / hermetic. Deps: python3 only.
|
|
20
|
+
import json
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
HERE = Path(__file__).resolve().parent
|
|
26
|
+
REPO = HERE.parent
|
|
27
|
+
MB = REPO / "pi" / "meta-bridge"
|
|
28
|
+
STATE = HERE / "meta-bridge-state.py"
|
|
29
|
+
|
|
30
|
+
fail = 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ok(msg: str) -> None:
|
|
34
|
+
print(f" ok {msg}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def bad(msg: str) -> None:
|
|
38
|
+
global fail
|
|
39
|
+
print(f" FAIL {msg}")
|
|
40
|
+
fail = 1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load(path: Path):
|
|
44
|
+
try:
|
|
45
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
46
|
+
except Exception as exc: # noqa: BLE001
|
|
47
|
+
bad(f"cannot parse {path}: {exc}")
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def subset(label: str, got, allowed: set[str]) -> None:
|
|
52
|
+
if not isinstance(got, dict):
|
|
53
|
+
bad(f"{label}: expected an object, got {type(got).__name__}")
|
|
54
|
+
return
|
|
55
|
+
extra = set(got) - allowed
|
|
56
|
+
if extra:
|
|
57
|
+
bad(f"{label}: keys {sorted(extra)} outside minimal allowed set {sorted(allowed)} "
|
|
58
|
+
f"(closed-schema risk: an older Claude may reject them)")
|
|
59
|
+
else:
|
|
60
|
+
ok(f"{label}: keys ⊆ {sorted(allowed)}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- marketplace.json --------------------------------------------------------
|
|
64
|
+
mkt = load(MB / ".claude-plugin" / "marketplace.json")
|
|
65
|
+
if mkt is not None:
|
|
66
|
+
subset("marketplace root", mkt, {"name", "owner", "plugins"})
|
|
67
|
+
subset("marketplace.owner", mkt.get("owner", {}), {"name"})
|
|
68
|
+
plugins = mkt.get("plugins")
|
|
69
|
+
if not isinstance(plugins, list) or not plugins:
|
|
70
|
+
bad("marketplace.plugins must be a non-empty array")
|
|
71
|
+
else:
|
|
72
|
+
for i, p in enumerate(plugins):
|
|
73
|
+
subset(f"marketplace.plugins[{i}]", p, {"name", "source", "description"})
|
|
74
|
+
|
|
75
|
+
# --- plugin.json -------------------------------------------------------------
|
|
76
|
+
plug = load(MB / "entwurf-meta-receive" / ".claude-plugin" / "plugin.json")
|
|
77
|
+
if plug is not None:
|
|
78
|
+
subset("plugin.json", plug, {"name", "version", "description"})
|
|
79
|
+
|
|
80
|
+
# --- hooks.json --------------------------------------------------------------
|
|
81
|
+
hooks = load(MB / "entwurf-meta-receive" / "hooks" / "hooks.json")
|
|
82
|
+
if hooks is not None:
|
|
83
|
+
subset("hooks.json root", hooks, {"hooks"})
|
|
84
|
+
# Pin the hook EVENT names too — an unrecognized event key is the same closed-schema
|
|
85
|
+
# risk as an unrecognized field. These four are the meta-bridge's load-bearing events.
|
|
86
|
+
subset("hooks.json events", hooks.get("hooks") or {},
|
|
87
|
+
{"SessionStart", "CwdChanged", "UserPromptSubmit", "FileChanged"})
|
|
88
|
+
for event, entries in (hooks.get("hooks") or {}).items():
|
|
89
|
+
if not isinstance(entries, list):
|
|
90
|
+
bad(f"hooks.{event} must be an array")
|
|
91
|
+
continue
|
|
92
|
+
for j, entry in enumerate(entries):
|
|
93
|
+
subset(f"hooks.{event}[{j}]", entry, {"matcher", "hooks"})
|
|
94
|
+
for k, h in enumerate(entry.get("hooks", []) if isinstance(entry, dict) else []):
|
|
95
|
+
# asyncRewake/timeout are load-bearing on the FileChanged doorbell —
|
|
96
|
+
# allowed, NOT decorative. Anything beyond this set must be reviewed.
|
|
97
|
+
subset(f"hooks.{event}[{j}].hooks[{k}]", h, {"type", "command", "asyncRewake", "timeout"})
|
|
98
|
+
|
|
99
|
+
# --- desired_mcp() installed-vs-clone dual-mode ------------------------------
|
|
100
|
+
def desired_mcp(repo: str):
|
|
101
|
+
out = subprocess.run(
|
|
102
|
+
[sys.executable, str(STATE), "desired-mcp", "--repo", repo],
|
|
103
|
+
capture_output=True, text=True,
|
|
104
|
+
)
|
|
105
|
+
if out.returncode != 0:
|
|
106
|
+
bad(f"desired-mcp --repo {repo} exited {out.returncode}: {out.stderr.strip()}")
|
|
107
|
+
return None
|
|
108
|
+
return json.loads(out.stdout)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Both probes use SYNTHETIC paths that need not exist — never REPO. If this guard ran
|
|
112
|
+
# from an installed package (e.g. `entwurf check-meta-manifest-schema`), REPO would
|
|
113
|
+
# itself end in node_modules/@junghanacs/entwurf and a REPO-based clone probe would
|
|
114
|
+
# wrongly resolve to the installed shape and self-fail. Fixed, installed-vs-clone-shaped
|
|
115
|
+
# literals make the assertion location-independent.
|
|
116
|
+
installed = desired_mcp("/opt/x/node_modules/@junghanacs/entwurf")
|
|
117
|
+
if installed is not None:
|
|
118
|
+
if installed.get("command") == "entwurf-bridge" and installed.get("args") == []:
|
|
119
|
+
ok("desired_mcp(installed-shaped) → stable `entwurf-bridge` bin (no store path baked)")
|
|
120
|
+
else:
|
|
121
|
+
bad(f"desired_mcp(installed-shaped) should wire the `entwurf-bridge` bin, got {installed.get('command')} {installed.get('args')}")
|
|
122
|
+
|
|
123
|
+
clone = desired_mcp("/opt/entwurf-dev-clone")
|
|
124
|
+
if clone is not None:
|
|
125
|
+
args = clone.get("args") or [""]
|
|
126
|
+
if clone.get("command") == "bash" and str(args[0]).endswith("mcp/entwurf-bridge/start.sh"):
|
|
127
|
+
ok("desired_mcp(clone-shaped) → that clone's start.sh (not the global bin)")
|
|
128
|
+
else:
|
|
129
|
+
bad(f"desired_mcp(clone-shaped) should wire bash start.sh, got {clone.get('command')} {args}")
|
|
130
|
+
|
|
131
|
+
# both modes must keep the canonical sender env
|
|
132
|
+
for label, entry in (("installed", installed), ("clone", clone)):
|
|
133
|
+
if entry is None:
|
|
134
|
+
continue
|
|
135
|
+
env = entry.get("env", {})
|
|
136
|
+
if env.get("ENTWURF_BRIDGE_REQUIRE_META_SENDER") == "1" and \
|
|
137
|
+
env.get("ENTWURF_BRIDGE_EXTERNAL_AGENT_ID") == "external-mcp/claude-code":
|
|
138
|
+
ok(f"desired_mcp({label}) carries canonical sender env")
|
|
139
|
+
else:
|
|
140
|
+
bad(f"desired_mcp({label}) dropped canonical sender env: {env}")
|
|
141
|
+
|
|
142
|
+
if fail:
|
|
143
|
+
print("check-meta-manifest-schema FAIL")
|
|
144
|
+
sys.exit(1)
|
|
145
|
+
print("check-meta-manifest-schema PASS")
|
|
@@ -115,9 +115,23 @@ claude mcp remove entwurf-bridge -s user >/dev/null 2>&1 || true
|
|
|
115
115
|
# 0.11 S2 cutover: drop any stale USER-scope pi-tools-bridge entry written by a
|
|
116
116
|
# prior version (one-shot rename cleanup, not a runtime alias).
|
|
117
117
|
claude mcp remove pi-tools-bridge -s user >/dev/null 2>&1 || true
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
# 0.12.2 installed-vs-clone dual-mode — MUST mirror meta-bridge-state.py::desired_mcp(),
|
|
119
|
+
# which the trailing `apply` re-asserts as the SSOT. An installed package ($REPO ends
|
|
120
|
+
# in node_modules/@junghanacs/entwurf) wires the STABLE `entwurf-bridge` bin shim; baking
|
|
121
|
+
# the pnpm store path here would go stale on any peer/version bump. A dev clone pins to
|
|
122
|
+
# this clone's start.sh. Both branches carry the same two env vars desired_mcp() writes.
|
|
123
|
+
case "$REPO" in
|
|
124
|
+
*/node_modules/@junghanacs/entwurf)
|
|
125
|
+
claude mcp add -s user entwurf-bridge \
|
|
126
|
+
-e ENTWURF_BRIDGE_EXTERNAL_AGENT_ID=external-mcp/claude-code \
|
|
127
|
+
-e ENTWURF_BRIDGE_REQUIRE_META_SENDER=1 \
|
|
128
|
+
-- entwurf-bridge >/dev/null ;;
|
|
129
|
+
*)
|
|
130
|
+
claude mcp add -s user entwurf-bridge \
|
|
131
|
+
-e ENTWURF_BRIDGE_EXTERNAL_AGENT_ID=external-mcp/claude-code \
|
|
132
|
+
-e ENTWURF_BRIDGE_REQUIRE_META_SENDER=1 \
|
|
133
|
+
-- bash "$REPO/mcp/entwurf-bridge/start.sh" >/dev/null ;;
|
|
134
|
+
esac
|
|
121
135
|
(cd /tmp && claude mcp get entwurf-bridge 2>/dev/null | grep -q "Scope: User config") || \
|
|
122
136
|
die "post-install: entwurf-bridge is not reachable as USER-scope MCP from /tmp"
|
|
123
137
|
echo "[meta-bridge-install] installed entwurf-bridge MCP (scope: user = global receiver tools)"
|
|
@@ -264,20 +264,42 @@ def desired_marketplace(asm: Path) -> dict[str, Any]:
|
|
|
264
264
|
return {"source": {"source": "directory", "path": str(asm.resolve())}}
|
|
265
265
|
|
|
266
266
|
|
|
267
|
+
def is_installed_package(repo: Path) -> bool:
|
|
268
|
+
# An installed (npm / pnpm) layout always ends in node_modules/@junghanacs/entwurf
|
|
269
|
+
# (pnpm global nests it under .pnpm/<hash>/node_modules/@junghanacs/entwurf). A dev
|
|
270
|
+
# clone never lives under node_modules/@junghanacs/entwurf, so the trailing three
|
|
271
|
+
# path components cleanly distinguish the two WITHOUT a PATH lookup (`command -v
|
|
272
|
+
# entwurf-bridge` could resolve a stale GLOBAL bin from a clone host and is wrong here).
|
|
273
|
+
parts = repo.resolve().parts
|
|
274
|
+
return parts[-3:] == ("node_modules", "@junghanacs", "entwurf")
|
|
275
|
+
|
|
276
|
+
|
|
267
277
|
def desired_mcp(repo: Path) -> dict[str, Any]:
|
|
278
|
+
env = {
|
|
279
|
+
"ENTWURF_BRIDGE_EXTERNAL_AGENT_ID": "external-mcp/claude-code",
|
|
280
|
+
# Anonymous sends are forbidden on the Claude Code install path: a send
|
|
281
|
+
# with no pi-session identity AND no meta-sender marker is refused, not
|
|
282
|
+
# delivered as an unidentified external. The SessionStart hook writes
|
|
283
|
+
# the marker (parent-pid keyed), so a normally-opened session always has
|
|
284
|
+
# an authoritative garden-id sender.
|
|
285
|
+
"ENTWURF_BRIDGE_REQUIRE_META_SENDER": "1",
|
|
286
|
+
}
|
|
287
|
+
if is_installed_package(repo):
|
|
288
|
+
# Installed package: wire the STABLE `entwurf-bridge` bin shim that npm/pnpm
|
|
289
|
+
# place on PATH. Baking repo/mcp/entwurf-bridge/start.sh would embed the pnpm
|
|
290
|
+
# store path (.pnpm/@junghanacs+entwurf@<ver>_<peer-hash>/...), which moves
|
|
291
|
+
# on any peer/version change and leaves a dead MCP command. The bare bin name
|
|
292
|
+
# tracks whatever version is currently installed — the right behaviour for a
|
|
293
|
+
# managed install host. start.sh's own position-based dual-mode then runs the
|
|
294
|
+
# dist boot under node_modules. (0.12.2 fix; 0.12.1 shipped only the clone path.)
|
|
295
|
+
return {"type": "stdio", "command": "entwurf-bridge", "args": [], "env": env}
|
|
296
|
+
# Dev clone: pin to THIS clone's launcher (an absolute resolved path), never the
|
|
297
|
+
# global bin, so a clone host stays bound to its own source tree.
|
|
268
298
|
return {
|
|
269
299
|
"type": "stdio",
|
|
270
300
|
"command": "bash",
|
|
271
301
|
"args": [str((repo / "mcp" / "entwurf-bridge" / "start.sh").resolve())],
|
|
272
|
-
"env":
|
|
273
|
-
"ENTWURF_BRIDGE_EXTERNAL_AGENT_ID": "external-mcp/claude-code",
|
|
274
|
-
# Anonymous sends are forbidden on the Claude Code install path: a send
|
|
275
|
-
# with no pi-session identity AND no meta-sender marker is refused, not
|
|
276
|
-
# delivered as an unidentified external. The SessionStart hook writes
|
|
277
|
-
# the marker (parent-pid keyed), so a normally-opened session always has
|
|
278
|
-
# an authoritative garden-id sender.
|
|
279
|
-
"ENTWURF_BRIDGE_REQUIRE_META_SENDER": "1",
|
|
280
|
-
},
|
|
302
|
+
"env": env,
|
|
281
303
|
}
|
|
282
304
|
|
|
283
305
|
|
|
@@ -519,7 +541,7 @@ def main() -> int:
|
|
|
519
541
|
parser = argparse.ArgumentParser(description="entwurf meta-bridge state manager")
|
|
520
542
|
parser.add_argument(
|
|
521
543
|
"command",
|
|
522
|
-
choices=["prepare", "apply", "preflight-uninstall", "uninstall", "check", "managed-keys"],
|
|
544
|
+
choices=["prepare", "apply", "preflight-uninstall", "uninstall", "check", "managed-keys", "desired-mcp"],
|
|
523
545
|
)
|
|
524
546
|
parser.add_argument("--repo", default=Path(__file__).resolve().parents[1], type=Path)
|
|
525
547
|
parser.add_argument("--asm", default=None, type=Path)
|
|
@@ -539,6 +561,11 @@ def main() -> int:
|
|
|
539
561
|
check(repo, asm)
|
|
540
562
|
elif args.command == "managed-keys":
|
|
541
563
|
print(json.dumps(managed_keys(), indent=2))
|
|
564
|
+
elif args.command == "desired-mcp":
|
|
565
|
+
# Debug/guard surface: print the user-scope MCP entry desired_mcp() would
|
|
566
|
+
# write for --repo. Lets a deterministic guard assert the installed-vs-clone
|
|
567
|
+
# dual-mode without spinning up a real `claude` CLI. --repo need not exist.
|
|
568
|
+
print(json.dumps(desired_mcp(repo), indent=2))
|
|
542
569
|
except StateError as exc:
|
|
543
570
|
print(f"meta-bridge-state: {exc}", file=sys.stderr)
|
|
544
571
|
return 1
|
|
@@ -207,9 +207,20 @@ async function main(): Promise<void> {
|
|
|
207
207
|
);
|
|
208
208
|
|
|
209
209
|
// Drive ONE model turn over the stdin RPC: call the BUNDLED bridge's entwurf_self.
|
|
210
|
+
// Ask for LABELED lines ("socketState: <value>"), not bare values. The old
|
|
211
|
+
// "reply with the values ... one per line, and nothing else" prompt contradicted
|
|
212
|
+
// the assertions below, which match on the FIELD NAME (e.g. /socketState…alive/):
|
|
213
|
+
// a model that obeyed "values only" replied with a bare "alive" line that the
|
|
214
|
+
// envelope filter (it keys on field-name tokens) dropped, and the [tool:done]
|
|
215
|
+
// notice truncates the envelope before socketState — so the gate flaked on the
|
|
216
|
+
// model's formatting choice. Labeled lines make the three identity fields
|
|
217
|
+
// deterministically observable in the reply regardless of notice truncation.
|
|
210
218
|
const prompt =
|
|
211
|
-
"Call the mcp__entwurf-bridge__entwurf_self tool now. Then reply with exactly
|
|
212
|
-
"
|
|
219
|
+
"Call the mcp__entwurf-bridge__entwurf_self tool now. Then reply with exactly these " +
|
|
220
|
+
"three lines, copying the tool result verbatim and nothing else:\n" +
|
|
221
|
+
"sessionId: <value>\n" +
|
|
222
|
+
"agentId: <value>\n" +
|
|
223
|
+
"socketState: <value>\n" +
|
|
213
224
|
"Do not paraphrase or invent values — copy them verbatim from the tool result.";
|
|
214
225
|
// The prompt must NOT leak the gid — the gid in the envelope is the proof the bridge
|
|
215
226
|
// answered, so a gid in the prompt would make that proof circular (the model could echo
|
|
@@ -7,10 +7,17 @@
|
|
|
7
7
|
// This is the S2e-1 live half: it drives the REAL pi provider path and proves the
|
|
8
8
|
// two 핀1-critical behaviors the gate cannot observe on a real model turn:
|
|
9
9
|
//
|
|
10
|
-
// 1. the augment reaches the model — a unique
|
|
11
|
-
// scratch cwd's AGENTS.md (NEVER into the user prompt) comes
|
|
12
|
-
// reply, so buildPiContextAugment's "## <cwd>/AGENTS.md" section
|
|
13
|
-
// the wire to the live model via streamShellAcp.
|
|
10
|
+
// 1. the augment reaches the model — a unique BENIGN factual marker written
|
|
11
|
+
// ONLY into the scratch cwd's AGENTS.md (NEVER into the user prompt) comes
|
|
12
|
+
// back in the reply, so buildPiContextAugment's "## <cwd>/AGENTS.md" section
|
|
13
|
+
// actually rode the wire to the live model via streamShellAcp. The marker is
|
|
14
|
+
// a plain project fact ("internal build codename: <nonce>") asked back with a
|
|
15
|
+
// normal "answer from project context" question — NOT a "SECRET ... reply
|
|
16
|
+
// with the value" directive. The old secret-echo phrasing read as a prompt
|
|
17
|
+
// injection embedded in a /tmp AGENTS.md, and current Claude correctly
|
|
18
|
+
// REFUSES it ("typical injection pattern"), which made this MUST gate fail
|
|
19
|
+
// even though the augment DID ride the wire. Proving delivery must not depend
|
|
20
|
+
// on the model agreeing to echo an exfil-shaped secret.
|
|
14
21
|
// 2. the default (EMPTY) carrier does not trip subscription billing — the turn
|
|
15
22
|
// exits 0 with no HTTP-400 / "extra usage" billing error. This is the 핀1
|
|
16
23
|
// live check: a carrier-absent run must bill like a normal subscription call.
|
|
@@ -18,11 +25,13 @@
|
|
|
18
25
|
// Read-tool caveat (deliberate — GPT c32a6c8 Q1): Claude ACP exposes Read, so a
|
|
19
26
|
// model COULD read AGENTS.md directly instead of answering from the augment. We do
|
|
20
27
|
// not forbid that at the wire — the deterministic gate already locks the augment
|
|
21
|
-
// SHAPE; this smoke asks the model to answer WITHOUT tools and treats the
|
|
28
|
+
// SHAPE; this smoke asks the model to answer WITHOUT tools and treats the marker
|
|
22
29
|
// in the reply as evidence that the augment+provider path is live AND billing-
|
|
23
30
|
// clean. The honest claim is "the augment rode the live provider path and the
|
|
24
31
|
// empty-carrier turn billed fine", NOT "the model was physically unable to read
|
|
25
|
-
// the file". A wire-dump would over-build for this cut's purpose
|
|
32
|
+
// the file". A live wire-dump would over-build for this cut's purpose (the
|
|
33
|
+
// production ACP path has no prompt-capture seam; only the deterministic gate's
|
|
34
|
+
// fake child captures payloads).
|
|
26
35
|
//
|
|
27
36
|
// Optional carrier-present path (SMOKE_ACP_CARRIER_PRESENT=1, non-blocking — GPT
|
|
28
37
|
// c32a6c8 Q2): a second turn with a TINY engraving via ENTWURF_ACP_ENGRAVING_PATH
|
|
@@ -122,45 +131,52 @@ function assertCleanTurn(label: string, turn: { status: number | null; combined:
|
|
|
122
131
|
const scratch = mkdtempSync(join(tmpdir(), "entwurf-s2e1-"));
|
|
123
132
|
try {
|
|
124
133
|
// A nonce unique to this run; lives ONLY in the cwd AGENTS.md, never the prompt.
|
|
134
|
+
// The marker is a BENIGN project FACT (an internal build codename) with no
|
|
135
|
+
// directive attached — not a "SECRET ... reply with the value" instruction.
|
|
136
|
+
// A factual line cannot read as an embedded user-turn injection, so asking for
|
|
137
|
+
// it back is a normal "answer from project context" task the model performs
|
|
138
|
+
// instead of refusing (see header). Its uniqueness still proves delivery: the
|
|
139
|
+
// codename appears nowhere but the cwd AGENTS.md the augment carries.
|
|
125
140
|
const nonce = `${process.pid.toString(36)}${Date.now().toString(36)}`;
|
|
126
|
-
const
|
|
141
|
+
const marker = `nimbus${nonce}`;
|
|
127
142
|
const agentsPath = join(scratch, "AGENTS.md");
|
|
128
143
|
writeFileSync(
|
|
129
144
|
agentsPath,
|
|
130
145
|
[
|
|
131
|
-
"# Scratch project
|
|
146
|
+
"# Scratch project: entwurf augment live check",
|
|
132
147
|
"",
|
|
133
|
-
|
|
148
|
+
"## Project facts",
|
|
134
149
|
"",
|
|
135
|
-
|
|
150
|
+
`- Internal build codename: ${marker}`,
|
|
136
151
|
"",
|
|
137
152
|
].join("\n"),
|
|
138
153
|
);
|
|
139
154
|
|
|
140
|
-
// The prompt never names the
|
|
155
|
+
// The prompt never names the marker; only the cwd AGENTS.md (carried by the
|
|
141
156
|
// augment) holds it. "Without using any tool" pushes the model to answer from
|
|
142
|
-
// the provided
|
|
157
|
+
// the provided project context rather than reading the file (Read-tool caveat).
|
|
143
158
|
const prompt =
|
|
144
|
-
"
|
|
145
|
-
"
|
|
159
|
+
"Based on the project context you were given, what is this project's " +
|
|
160
|
+
"internal build codename? Reply with just the codename and nothing else, " +
|
|
161
|
+
"and answer from the provided context without using any tool or reading any file.";
|
|
146
162
|
|
|
147
163
|
console.error(`[smoke-acp-carrier-augment-live] repo: ${REPO_ROOT}`);
|
|
148
164
|
console.error(`[smoke-acp-carrier-augment-live] cwd: ${scratch}`);
|
|
149
165
|
console.error(`[smoke-acp-carrier-augment-live] model: ${PROVIDER}/${MODEL}`);
|
|
150
|
-
console.error(`[smoke-acp-carrier-augment-live]
|
|
166
|
+
console.error(`[smoke-acp-carrier-augment-live] marker: ${marker} (only in cwd AGENTS.md)`);
|
|
151
167
|
|
|
152
168
|
// --- MUST: empty (default) carrier + augment behavior ---------------------
|
|
153
169
|
const turn1 = runTurn(scratch, prompt, {});
|
|
154
170
|
assertCleanTurn("empty-carrier turn", turn1);
|
|
155
171
|
assert.ok(
|
|
156
|
-
turn1.stdout.includes(
|
|
157
|
-
`empty-carrier turn: reply did not carry the cwd-AGENTS
|
|
172
|
+
turn1.stdout.includes(marker),
|
|
173
|
+
`empty-carrier turn: reply did not carry the cwd-AGENTS marker ${marker} ` +
|
|
158
174
|
`(the augment did not reach the model). stdout tail: ${JSON.stringify(turn1.stdout.slice(-300))}`,
|
|
159
175
|
);
|
|
160
|
-
console.log("[smoke-acp-carrier-augment-live] PASS (MUST) — augment delivered the cwd AGENTS.md
|
|
176
|
+
console.log("[smoke-acp-carrier-augment-live] PASS (MUST) — augment delivered the cwd AGENTS.md marker on a");
|
|
161
177
|
console.log(" live provider turn; the empty default carrier billed clean (exit 0, no 400 canary).");
|
|
162
178
|
console.log(` model: ${PROVIDER}/${MODEL}`);
|
|
163
|
-
console.log(`
|
|
179
|
+
console.log(` marker: ${marker} present in assistant reply`);
|
|
164
180
|
|
|
165
181
|
// --- OPTIONAL (non-blocking): tiny carrier-present billing -----------------
|
|
166
182
|
if (process.env.SMOKE_ACP_CARRIER_PRESENT === "1") {
|