@junghanacs/entwurf 0.16.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +125 -0
- package/README.md +8 -11
- package/demo/README.md +1 -1
- package/docs/setup-clean-host.md +24 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +19 -10
- package/package.json +8 -8
- package/pi-extensions/lib/acp/backend-adapter.ts +19 -9
- package/pi-extensions/lib/acp/backend.ts +125 -7
- package/pi-extensions/lib/acp/claude-acp-launch.js +100 -0
- package/run.sh +108 -32
- package/scripts/check-acp-launch-namespace.ts +127 -0
- package/scripts/check-acp-prompt-lifecycle.ts +145 -2
- package/scripts/check-copilot-birth-hook.ts +28 -1
- package/scripts/check-gate-qualification.ts +3 -2
- package/scripts/check-omp-fresh-preflight.ts +27 -0
- package/scripts/check-setup-qualification.sh +40 -2
- package/scripts/copilot-bridge-oracle.sh +14 -6
- package/scripts/fake-copilot-vendor.sh +4 -2
- package/scripts/mutants/acp-launch-namespace.json +34 -0
- package/scripts/mutants/acp-prompt-lifecycle.json +67 -2
- package/scripts/mutants/copilot-birth.json +3 -5
- package/scripts/mutants/pack-install.json +2 -2
- package/scripts/mutants/setup-verdict.json +35 -0
- package/scripts/omp-config-xdev.py +310 -0
- package/scripts/omp-config-xdev.sh +76 -0
- package/scripts/omp-tool-surface.py +61 -10
- package/scripts/raw-acp-child-exit-measure/README.md +285 -0
- package/scripts/raw-acp-child-exit-measure/acp-turn-population.py +89 -0
- package/scripts/raw-acp-child-exit-measure/reaper-correlation.py +47 -0
- package/scripts/smoke-setup-verdict.sh +48 -3
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Deterministic gate — the Claude ACP child launches under a name entwurf OWNS.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS IS A GATE AND NOT A COMMENT (#72). The vendor bin is called
|
|
4
|
+
// `claude-agent-acp`, and that name belongs to the package, not to us: any
|
|
5
|
+
// harness on the host spawning the same package produces a process with the
|
|
6
|
+
// same name. On GLG's oracle host a janitor installed for a DIFFERENT harness
|
|
7
|
+
// selects `claude-agent-acp` by argv SUBSTRING and SIGTERMs anything older than
|
|
8
|
+
// 900s. Because entwurf RETAINS its child across turns, its age is the age of
|
|
9
|
+
// the session — 12 of 12 anomalous terminations across two boots were that
|
|
10
|
+
// janitor (receipts: scripts/raw-acp-child-exit-measure/README.md §ANSWERED).
|
|
11
|
+
//
|
|
12
|
+
// The defense is a launcher whose own name carries no vendor substring. That is
|
|
13
|
+
// a property of a STRING, which is exactly the kind of thing that rots silently
|
|
14
|
+
// under a rename or a "harmless" revert — hence a gate that fails loudly.
|
|
15
|
+
//
|
|
16
|
+
// The second half matters as much: the launcher must remain TRANSPARENT. The
|
|
17
|
+
// vendor reads `--cli` / `--version` from `process.argv` and builds its own
|
|
18
|
+
// re-invocation command from `process.argv.slice(1)`. A launcher that consumed
|
|
19
|
+
// a flag of its own, or failed to start the vendor at all, would trade one
|
|
20
|
+
// silent breakage for another — so we RUN it and require the vendor to answer.
|
|
21
|
+
|
|
22
|
+
import { strict as assert } from "node:assert";
|
|
23
|
+
import { execFileSync } from "node:child_process";
|
|
24
|
+
import { rmdirSync, rmSync } from "node:fs";
|
|
25
|
+
import { resolve } from "node:path";
|
|
26
|
+
import { pathToFileURL } from "node:url";
|
|
27
|
+
|
|
28
|
+
/** The substring the janitor matches on — the thing our launch argv must not contain. */
|
|
29
|
+
const VENDOR_PROCESS_MATCHER = "claude-agent-acp";
|
|
30
|
+
|
|
31
|
+
const TMP_EMIT = ".tmp-verify/acp-launch-namespace";
|
|
32
|
+
rmSync(TMP_EMIT, { recursive: true, force: true });
|
|
33
|
+
try {
|
|
34
|
+
execFileSync("node_modules/.bin/tsc", ["--outDir", TMP_EMIT, "--rootDir", ".", "--noEmit", "false"], {
|
|
35
|
+
stdio: "pipe",
|
|
36
|
+
});
|
|
37
|
+
const adapterUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/backend-adapter.js")).href;
|
|
38
|
+
const mod = (await import(adapterUrl)) as {
|
|
39
|
+
claudeAdapter: {
|
|
40
|
+
resolveLaunch: (p: { cwd: string; modelId: string; nativeModelId: string; config: unknown }) => {
|
|
41
|
+
command: string;
|
|
42
|
+
args: string[];
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const launch = mod.claudeAdapter.resolveLaunch({
|
|
48
|
+
cwd: process.cwd(),
|
|
49
|
+
modelId: "claude-sonnet-5",
|
|
50
|
+
nativeModelId: "claude-sonnet-5",
|
|
51
|
+
config: {},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ----------------------------------------------------------------------
|
|
55
|
+
// The claim: nothing a name-matching janitor scans contains the vendor name.
|
|
56
|
+
// We check the WHOLE argv the way `ps` presents it, because that is what the
|
|
57
|
+
// janitor's awk actually reads — not just the basename.
|
|
58
|
+
// ----------------------------------------------------------------------
|
|
59
|
+
const psLine = [launch.command, ...launch.args].join(" ");
|
|
60
|
+
assert.ok(
|
|
61
|
+
!psLine.includes(VENDOR_PROCESS_MATCHER),
|
|
62
|
+
`[QK:CLAUDE-LAUNCH-IS-NAMESPACED] the default Claude ACP launch must not put "${VENDOR_PROCESS_MATCHER}" anywhere ` +
|
|
63
|
+
"in its argv: a janitor installed for another harness selects that substring by age and SIGTERMs it, which is " +
|
|
64
|
+
`the whole of #72. Got: ${JSON.stringify(psLine)}`,
|
|
65
|
+
);
|
|
66
|
+
assert.ok(
|
|
67
|
+
launch.args.length === 1 && launch.args[0].endsWith("claude-acp-launch.js"),
|
|
68
|
+
"the default launch is the entwurf-owned launcher and NOTHING else — an extra argv entry would be a flag of our " +
|
|
69
|
+
`own, which the vendor's argv.slice(1) self-reinvocation cannot survive. Got: ${JSON.stringify(launch.args)}`,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// ----------------------------------------------------------------------
|
|
73
|
+
// The launcher must still BE the vendor. A name split that stopped starting
|
|
74
|
+
// the agent would pass every string assertion above and ship a dead backend,
|
|
75
|
+
// so run it and make the vendor answer through it.
|
|
76
|
+
// ----------------------------------------------------------------------
|
|
77
|
+
const version = execFileSync(process.execPath, [...launch.args, "--version"], {
|
|
78
|
+
encoding: "utf8",
|
|
79
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
80
|
+
timeout: 60_000,
|
|
81
|
+
}).trim();
|
|
82
|
+
assert.match(
|
|
83
|
+
version,
|
|
84
|
+
/^\d+\.\d+\.\d+/,
|
|
85
|
+
"[QK:CLAUDE-LAUNCH-IS-TRANSPARENT] the launcher must pass argv through untouched and start the real vendor — " +
|
|
86
|
+
`\`--version\` has to reach it and answer. Got: ${JSON.stringify(version)}`,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// ----------------------------------------------------------------------
|
|
90
|
+
// The debug override is an EXPLICIT operator choice and must stay literal:
|
|
91
|
+
// an operator who names their own command owns the result, including losing
|
|
92
|
+
// the name split. Routing it through the launcher would silently overrule them.
|
|
93
|
+
// ----------------------------------------------------------------------
|
|
94
|
+
process.env.CLAUDE_AGENT_ACP_COMMAND = "echo overridden";
|
|
95
|
+
try {
|
|
96
|
+
const overridden = mod.claudeAdapter.resolveLaunch({
|
|
97
|
+
cwd: process.cwd(),
|
|
98
|
+
modelId: "claude-sonnet-5",
|
|
99
|
+
nativeModelId: "claude-sonnet-5",
|
|
100
|
+
config: {},
|
|
101
|
+
});
|
|
102
|
+
assert.deepEqual(
|
|
103
|
+
[overridden.command, ...overridden.args],
|
|
104
|
+
["bash", "-lc", "echo overridden"],
|
|
105
|
+
"CLAUDE_AGENT_ACP_COMMAND must stay verbatim — the launcher is the DEFAULT, never an override of the operator",
|
|
106
|
+
);
|
|
107
|
+
} finally {
|
|
108
|
+
delete process.env.CLAUDE_AGENT_ACP_COMMAND;
|
|
109
|
+
}
|
|
110
|
+
} finally {
|
|
111
|
+
rmSync(TMP_EMIT, { recursive: true, force: true });
|
|
112
|
+
try {
|
|
113
|
+
// A leftover EMPTY parent dir reads as IMPURE tree drift in the
|
|
114
|
+
// qualification harness; a concurrent sibling gate's emit keeps it alive
|
|
115
|
+
// and this rmdir simply fails.
|
|
116
|
+
rmdirSync(".tmp-verify");
|
|
117
|
+
} catch {
|
|
118
|
+
// non-empty or already gone — fine either way
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log(
|
|
123
|
+
"[check-acp-launch-namespace] ok — the default Claude ACP launch carries no vendor process name in its argv (so a " +
|
|
124
|
+
"name-matching janitor installed for another harness cannot select it), is exactly the entwurf-owned launcher " +
|
|
125
|
+
"with no flag of its own, still starts the real vendor through argv passed untouched, and leaves an explicit " +
|
|
126
|
+
"CLAUDE_AGENT_ACP_COMMAND override verbatim",
|
|
127
|
+
);
|
|
@@ -77,6 +77,7 @@ function deferred<T>(): Deferred<T> {
|
|
|
77
77
|
function makeFakeChild() {
|
|
78
78
|
const exitListeners: Array<(...args: unknown[]) => void> = [];
|
|
79
79
|
const stderrListeners: Array<(chunk: Buffer) => void> = [];
|
|
80
|
+
const stderrCloseListeners: Array<() => void> = [];
|
|
80
81
|
const kills: Array<NodeJS.Signals | number | undefined> = [];
|
|
81
82
|
const pipe = () => ({ destroy() {}, unref() {} });
|
|
82
83
|
const child = {
|
|
@@ -90,6 +91,9 @@ function makeFakeChild() {
|
|
|
90
91
|
on(_event: "data", listener: (chunk: Buffer) => void) {
|
|
91
92
|
stderrListeners.push(listener);
|
|
92
93
|
},
|
|
94
|
+
once(_event: "close", listener: () => void) {
|
|
95
|
+
stderrCloseListeners.push(listener);
|
|
96
|
+
},
|
|
93
97
|
destroy() {},
|
|
94
98
|
unref() {},
|
|
95
99
|
},
|
|
@@ -105,10 +109,11 @@ function makeFakeChild() {
|
|
|
105
109
|
writeStderr(text: string) {
|
|
106
110
|
for (const listener of [...stderrListeners]) listener(Buffer.from(text));
|
|
107
111
|
},
|
|
108
|
-
/** driver: the backend process ends. */
|
|
112
|
+
/** driver: the backend process ends. Its stderr pipe closes with it. */
|
|
109
113
|
die(code: number | null, signal: NodeJS.Signals | null = null) {
|
|
110
114
|
child.exitCode = code;
|
|
111
115
|
child.signalCode = signal;
|
|
116
|
+
for (const listener of stderrCloseListeners.splice(0)) listener();
|
|
112
117
|
for (const listener of exitListeners.splice(0)) listener(code, signal);
|
|
113
118
|
},
|
|
114
119
|
};
|
|
@@ -656,6 +661,141 @@ try {
|
|
|
656
661
|
);
|
|
657
662
|
}
|
|
658
663
|
|
|
664
|
+
// ----------------------------------------------------------------------
|
|
665
|
+
// CELL 12 — a signal the LAUNCHER caught survives the vendor erasing it.
|
|
666
|
+
//
|
|
667
|
+
// #72's whole difficulty: the vendor turns SIGTERM into `dispose(); exit(0)`,
|
|
668
|
+
// so an external kill and a clean vendor shutdown reach us as the SAME facts
|
|
669
|
+
// (code 0, signal null). CELL 10 can only separate a signal that was NOT
|
|
670
|
+
// caught. `claude-acp-launch.js` records the catch before the vendor erases
|
|
671
|
+
// it; this cell holds that the record reaches the operator, on its own line,
|
|
672
|
+
// and that the tail stays vendor-only.
|
|
673
|
+
// ----------------------------------------------------------------------
|
|
674
|
+
{
|
|
675
|
+
const h = makeHarness(recordDir);
|
|
676
|
+
const turn = startTurn(backend, userCtx("reaped from outside"), { sessionId: "life-eof-launchsig" }, h.deps);
|
|
677
|
+
await delay(30);
|
|
678
|
+
h.children[0].writeStderr("VENDOR-TAIL-MARK\n");
|
|
679
|
+
h.children[0].writeStderr("ENTWURF_ACP_LAUNCH_SIGNAL=SIGTERM\n");
|
|
680
|
+
h.transportClosed();
|
|
681
|
+
await delay(1);
|
|
682
|
+
h.children[0].die(0, null);
|
|
683
|
+
await turn.done;
|
|
684
|
+
|
|
685
|
+
const message = String(sealed(turn.events)[0].error.errorMessage);
|
|
686
|
+
assert.ok(
|
|
687
|
+
message.includes("launch observed SIGTERM before child exit"),
|
|
688
|
+
"[QK:LAUNCH-SIGNAL-EVIDENCE-STRUCTURED] a caught SIGTERM must reach the operator even though the vendor " +
|
|
689
|
+
`normalized it to exit 0 — otherwise an external kill is indistinguishable from a clean close. Got: ${JSON.stringify(message)}`,
|
|
690
|
+
);
|
|
691
|
+
assert.ok(
|
|
692
|
+
message.includes("sender not attributed"),
|
|
693
|
+
"the observation must NOT claim who sent the signal — attribution needs the host journal, which this " +
|
|
694
|
+
`process cannot read. Got: ${JSON.stringify(message)}`,
|
|
695
|
+
);
|
|
696
|
+
assert.ok(
|
|
697
|
+
message.includes("exit code 0") && message.includes("VENDOR-TAIL-MARK"),
|
|
698
|
+
`the exit fact and the vendor tail both survive alongside the observation. Got: ${JSON.stringify(message)}`,
|
|
699
|
+
);
|
|
700
|
+
assert.ok(
|
|
701
|
+
!message.includes("ENTWURF_ACP_LAUNCH_SIGNAL="),
|
|
702
|
+
"[QK:LAUNCH-FRAME-NOT-IN-TAIL] the raw control frame must be CONSUMED, not echoed into the vendor tail — " +
|
|
703
|
+
`the tail is vendor evidence and the observation is ours, and #72 was made of confusing the two. Got: ${JSON.stringify(message)}`,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ----------------------------------------------------------------------
|
|
708
|
+
// CELL 13 — NEGATIVE SIBLING: vendor prose can never manufacture the fact.
|
|
709
|
+
//
|
|
710
|
+
// The oracle for CELL 12's exactness. Vendor stderr is free text and it does
|
|
711
|
+
// mention signals; if a loose test (`includes("SIGTERM")`, or a prefix match
|
|
712
|
+
// without the enum) fed the observation, entwurf would report an entwurf-owned
|
|
713
|
+
// fact it never observed — a worse failure than reporting nothing, because it
|
|
714
|
+
// would be trusted. Both a near-miss frame and prose must produce NOTHING.
|
|
715
|
+
// ----------------------------------------------------------------------
|
|
716
|
+
{
|
|
717
|
+
const h = makeHarness(recordDir);
|
|
718
|
+
const turn = startTurn(backend, userCtx("vendor mentions a signal"), { sessionId: "life-eof-nosig" }, h.deps);
|
|
719
|
+
await delay(30);
|
|
720
|
+
h.children[0].writeStderr("shutting down after SIGTERM; ENTWURF_ACP_LAUNCH_SIGNAL=SIGQUIT\n");
|
|
721
|
+
h.children[0].writeStderr(" ENTWURF_ACP_LAUNCH_SIGNAL=SIGTERM (quoted in prose, not a frame)\n");
|
|
722
|
+
h.transportClosed();
|
|
723
|
+
await delay(1);
|
|
724
|
+
h.children[0].die(0, null);
|
|
725
|
+
await turn.done;
|
|
726
|
+
|
|
727
|
+
const message = String(sealed(turn.events)[0].error.errorMessage);
|
|
728
|
+
assert.ok(
|
|
729
|
+
!message.includes("launch observed"),
|
|
730
|
+
"[QK:LAUNCH-SIGNAL-EXACT-FRAME-ONLY] neither vendor prose containing 'SIGTERM', an unknown signal value, nor " +
|
|
731
|
+
"an indented near-miss may produce a launch observation — the frame is an exact full line with a fixed " +
|
|
732
|
+
`enum, and anything looser lets vendor text forge our own evidence. Got: ${JSON.stringify(message)}`,
|
|
733
|
+
);
|
|
734
|
+
assert.ok(
|
|
735
|
+
message.includes("SIGQUIT") && message.includes("quoted in prose"),
|
|
736
|
+
`text that is not our frame stays in the vendor tail verbatim. Got: ${JSON.stringify(message)}`,
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ----------------------------------------------------------------------
|
|
741
|
+
// CELL 14 — the child's LAST words survive having no trailing newline.
|
|
742
|
+
//
|
|
743
|
+
// Filtering our control frame out of the tail means reading stderr by LINE,
|
|
744
|
+
// and a process dying mid-write does not finish its line. That fragment is
|
|
745
|
+
// exactly the dying words the tail exists for, so holding it in a line buffer
|
|
746
|
+
// forever would trade #72's diagnosis for a worse blindness. The stream's
|
|
747
|
+
// close must flush it VERBATIM.
|
|
748
|
+
// ----------------------------------------------------------------------
|
|
749
|
+
{
|
|
750
|
+
const h = makeHarness(recordDir);
|
|
751
|
+
const turn = startTurn(backend, userCtx("dies mid-write"), { sessionId: "life-eof-nonl" }, h.deps);
|
|
752
|
+
await delay(30);
|
|
753
|
+
h.children[0].writeStderr("FATAL dying words with no newline");
|
|
754
|
+
h.transportClosed();
|
|
755
|
+
await delay(1);
|
|
756
|
+
h.children[0].die(0, null);
|
|
757
|
+
await turn.done;
|
|
758
|
+
|
|
759
|
+
const message = String(sealed(turn.events)[0].error.errorMessage);
|
|
760
|
+
assert.ok(
|
|
761
|
+
message.includes("FATAL dying words with no newline"),
|
|
762
|
+
"[QK:STDERR-TAIL-FLUSHES-PARTIAL-LINE] a child that dies mid-write leaves its last line unterminated, and " +
|
|
763
|
+
"that fragment IS the dying words the tail exists for — line-buffering to strip our own frame must not " +
|
|
764
|
+
`swallow it. Got: ${JSON.stringify(message)}`,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// ----------------------------------------------------------------------
|
|
769
|
+
// CELL 15 — a frame split across two reads is still recognised exactly.
|
|
770
|
+
//
|
|
771
|
+
// Pipe chunk boundaries fall wherever the kernel put them, so the frame can
|
|
772
|
+
// arrive in pieces. This is the reason the filter buffers by line at all;
|
|
773
|
+
// without a cell for it, an implementation that matched per-CHUNK would pass
|
|
774
|
+
// every other test here and then miss the real signal in the field.
|
|
775
|
+
// ----------------------------------------------------------------------
|
|
776
|
+
{
|
|
777
|
+
const h = makeHarness(recordDir);
|
|
778
|
+
const turn = startTurn(backend, userCtx("frame arrives split"), { sessionId: "life-eof-split" }, h.deps);
|
|
779
|
+
await delay(30);
|
|
780
|
+
h.children[0].writeStderr("ENTWURF_ACP_LAUNCH_SI");
|
|
781
|
+
h.children[0].writeStderr("GNAL=SIGTERM\n");
|
|
782
|
+
h.transportClosed();
|
|
783
|
+
await delay(1);
|
|
784
|
+
h.children[0].die(0, null);
|
|
785
|
+
await turn.done;
|
|
786
|
+
|
|
787
|
+
const message = String(sealed(turn.events)[0].error.errorMessage);
|
|
788
|
+
assert.ok(
|
|
789
|
+
message.includes("launch observed SIGTERM before child exit"),
|
|
790
|
+
"[QK:LAUNCH-FRAME-SPANS-CHUNKS] the frame must be recognised across a chunk boundary — the kernel, not the " +
|
|
791
|
+
`writer, decides where a read ends. Got: ${JSON.stringify(message)}`,
|
|
792
|
+
);
|
|
793
|
+
assert.ok(
|
|
794
|
+
!message.includes("ENTWURF_ACP_LAUNCH_SI"),
|
|
795
|
+
`neither half of a split frame may leak into the vendor tail. Got: ${JSON.stringify(message)}`,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
659
799
|
// ----------------------------------------------------------------------
|
|
660
800
|
// CELL 11 — a child that never reports an end says SO, bounded.
|
|
661
801
|
//
|
|
@@ -771,7 +911,10 @@ console.log(
|
|
|
771
911
|
"reported with its exit status AND stderr tail on BOTH the new and the reuse path; a death BETWEEN turns is " +
|
|
772
912
|
"announced once by the next turn while a teardown WE performed stays silent; the child's end survives the " +
|
|
773
913
|
"temporal order the field showed too (transport EOF first, exit one tick later, alongside the opposite order), " +
|
|
774
|
-
"telling a clean exit apart from a signal and reporting silence AS silence within a bounded window;
|
|
914
|
+
"telling a clean exit apart from a signal and reporting silence AS silence within a bounded window; a signal the " +
|
|
915
|
+
"LAUNCHER caught survives the vendor normalizing it to exit 0 and is reported on its own line without claiming " +
|
|
916
|
+
"who sent it, while its raw control frame is consumed out of the vendor tail and vendor prose mentioning a " +
|
|
917
|
+
"signal can never forge that observation; and pi's " +
|
|
775
918
|
"own isRetryableAssistantError refuses to classify any of those failures as transient while still matching " +
|
|
776
919
|
"the retired 600s text",
|
|
777
920
|
);
|
|
@@ -495,7 +495,7 @@ function makeFakeHost(label: string, opts: FakeOpts): FakeHost {
|
|
|
495
495
|
? ' "plugin list") echo "not authenticated" >&2; exit 1 ;;'
|
|
496
496
|
: opts.pluginListRaw !== undefined
|
|
497
497
|
? ` "plugin list") echo "Installed plugins:"; cat ${JSON.stringify(rawList)}; exit 0 ;;`
|
|
498
|
-
: ' "plugin list") echo "
|
|
498
|
+
: ' "plugin list") echo "Live Plugins (loaded from a local marketplace directory, never copied):"; sed "s/^/ • /;s/$/ (v$VER) (enabled)/" "$STATE"; exit 0 ;;',
|
|
499
499
|
' "plugin uninstall")',
|
|
500
500
|
opts.uninstallFails
|
|
501
501
|
? ' echo "boom" >&2; exit 1 ;;'
|
|
@@ -1065,6 +1065,33 @@ function writeBoundState(host: FakeHost): void {
|
|
|
1065
1065
|
inst.status !== 0 && (inst.stderr ?? "").includes("malformed") && !existsSync(instHost.stateFile),
|
|
1066
1066
|
);
|
|
1067
1067
|
}
|
|
1068
|
+
{
|
|
1069
|
+
// Copilot CLI 1.0.81 (measured 2026-08-31) appends its own state token after the
|
|
1070
|
+
// version and an indented `from <path>` continuation line. The grammar admits that
|
|
1071
|
+
// ONE optional `(enabled)`/`(disabled)` token — reading it as part of the version
|
|
1072
|
+
// made every surface refuse a perfectly healthy host. Anything else in the tail
|
|
1073
|
+
// stays malformed, so the admission cannot widen into "ignore whatever follows".
|
|
1074
|
+
const liveRaw = `Live Plugins (loaded from a local marketplace directory, never copied):\n • ${OURS} (v${SHIPPED_VERSION}) (enabled)\n from /home/nobody/.assembled`;
|
|
1075
|
+
const docHost = makeFakeHost("doctor-state-token-row", { installed: [OURS], pluginListRaw: liveRaw });
|
|
1076
|
+
writeBoundState(docHost);
|
|
1077
|
+
writeFileSync(docHost.mktState, `${MKT}\t${docHost.asm}\n`);
|
|
1078
|
+
mkdirSync(path.join(docHost.asm, PLUGIN), { recursive: true });
|
|
1079
|
+
const doc = runVerb(docHost, "doctor-copilot-bridge");
|
|
1080
|
+
ok(
|
|
1081
|
+
"[QK:COPILOT-ROW-STATE-TOKEN-ADMITTED] a `(vX) (enabled)` row plus its `from` continuation line parses as the installed version — no malformed refusal on a healthy 1.0.81 host",
|
|
1082
|
+
doc.stdout.includes(`${OURS} (v${SHIPPED_VERSION}) is registered in Copilot`) &&
|
|
1083
|
+
!doc.stdout.includes("malformed") &&
|
|
1084
|
+
!(doc.stderr ?? "").includes("malformed"),
|
|
1085
|
+
);
|
|
1086
|
+
const foreignHost = makeFakeHost("install-foreign-tail-row", {
|
|
1087
|
+
pluginListRaw: ` • ${OURS} (v${SHIPPED_VERSION}) (whatever)`,
|
|
1088
|
+
});
|
|
1089
|
+
const foreign = runVerb(foreignHost, "install-copilot-bridge");
|
|
1090
|
+
ok(
|
|
1091
|
+
"an UNKNOWN trailing token is still malformed — the admission is exactly the two measured state words",
|
|
1092
|
+
foreign.status !== 0 && (foreign.stderr ?? "").includes("malformed") && !existsSync(foreignHost.stateFile),
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1068
1095
|
{
|
|
1069
1096
|
// B defect 4: a pluginVersion carrying whitespace would be truncated by the
|
|
1070
1097
|
// space-separated fact transport (`cut -d' ' -f3`) into a FABRICATED version and a
|
|
@@ -799,8 +799,9 @@ console.log(`\n[gate-qualification] self-test: ${passed} checks passed`);
|
|
|
799
799
|
const EXPECTED_LANE_MUTANTS: Record<string, number> = {
|
|
800
800
|
"acp-augment": 10,
|
|
801
801
|
"acp-cortex": 12,
|
|
802
|
+
"acp-launch-namespace": 2,
|
|
802
803
|
"acp-overlay": 1,
|
|
803
|
-
"acp-prompt-lifecycle":
|
|
804
|
+
"acp-prompt-lifecycle": 15,
|
|
804
805
|
"acp-stop-reason": 6,
|
|
805
806
|
"acp-stream-hooks": 10,
|
|
806
807
|
"agy-permission": 6,
|
|
@@ -829,7 +830,7 @@ console.log(`\n[gate-qualification] self-test: ${passed} checks passed`);
|
|
|
829
830
|
"resume-args": 6,
|
|
830
831
|
"resume-launch-identity": 6,
|
|
831
832
|
"self-address": 5,
|
|
832
|
-
"setup-verdict":
|
|
833
|
+
"setup-verdict": 13,
|
|
833
834
|
"source-install": 2,
|
|
834
835
|
"v2-surface": 7,
|
|
835
836
|
"v2-visible-resume": 17,
|
|
@@ -155,6 +155,16 @@ function pythonXdev(agentDir: string): "false" | "true" | "unreadable" {
|
|
|
155
155
|
["a top-level xdev outside tools", "xdev: false\ntools:\n approvalMode: yolo\n"],
|
|
156
156
|
["tab indentation (not YAML for the vendor)", "tools:\n\txdev: false\n"],
|
|
157
157
|
["broken flow scalar", "tools: [oops\n"],
|
|
158
|
+
// The shape the VENDOR's own settings writer produces: `key:` on one line with an
|
|
159
|
+
// indented flow collection under it. The python leaf's block-only reader returned
|
|
160
|
+
// None for the WHOLE file here, so an untouched operator config classified as
|
|
161
|
+
// `unreadable` and doctor-omp-mcp went RED for a reason unrelated to tools.xdev
|
|
162
|
+
// (measured on a real host, omp 18.0.0). Agreement alone could never catch it —
|
|
163
|
+
// both halves collapse `unreadable` and `true` into "not false" — so the direct
|
|
164
|
+
// assertion below is the one that holds the reader to the vendor's own output.
|
|
165
|
+
["the vendor's own writer output (empty flow map sibling)", "modelRoles: \n {}\ntools: \n xdev: false\n"],
|
|
166
|
+
["a populated flow map sibling", "modelRoles: \n {default: xai/grok}\ntools: \n xdev: false\n"],
|
|
167
|
+
["a flow sequence sibling", "disabledProviders: \n [openrouter, google]\ntools: \n xdev: false\n"],
|
|
158
168
|
];
|
|
159
169
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-omp-xdev-"));
|
|
160
170
|
try {
|
|
@@ -173,6 +183,23 @@ function pythonXdev(agentDir: string): "false" | "true" | "unreadable" {
|
|
|
173
183
|
pythonSaysFalse === (ts === false),
|
|
174
184
|
);
|
|
175
185
|
}
|
|
186
|
+
// Agreement is not enough on the vendor's own shapes: two readers that BOTH fail
|
|
187
|
+
// closed agree perfectly and still leave the doctor red on a healthy host. These
|
|
188
|
+
// name the answer instead of comparing the halves.
|
|
189
|
+
for (const [label, yaml] of CONFIGS) {
|
|
190
|
+
if (
|
|
191
|
+
!label.startsWith("the vendor's own writer output") &&
|
|
192
|
+
!label.startsWith("a populated flow map") &&
|
|
193
|
+
!label.startsWith("a flow sequence")
|
|
194
|
+
)
|
|
195
|
+
continue;
|
|
196
|
+
const dir = fs.mkdtempSync(path.join(root, "vendor-"));
|
|
197
|
+
fs.writeFileSync(path.join(dir, "config.yml"), yaml as string);
|
|
198
|
+
ok(
|
|
199
|
+
`[QK:OMP-XDEV-VENDOR-SHAPE-READABLE] the python leaf READS tools.xdev on ${label} — a flow collection elsewhere in the file is not an unreadable config`,
|
|
200
|
+
pythonXdev(dir) === "false",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
176
203
|
} finally {
|
|
177
204
|
fs.rmSync(root, { recursive: true, force: true });
|
|
178
205
|
}
|
|
@@ -42,11 +42,23 @@ for _f in copilot-bridge-install.sh copilot-bridge-oracle.sh copilot-mcp-bridge.
|
|
|
42
42
|
copilot-receive-bridge.sh copilot-statusline-bridge.sh copilot-statusline-config.py; do
|
|
43
43
|
cp "$REPO_DIR/scripts/$_f" "$PKG/scripts/$_f"
|
|
44
44
|
done
|
|
45
|
+
# OMP composition surface (Cells E/F): same discipline — every omp unit script is a real
|
|
46
|
+
# tracked file here, because the claims are about what the composition actually reaches.
|
|
47
|
+
for _f in omp-bridge-install.sh omp-bridge-oracle.sh omp-mcp-bridge.sh omp-mcp-config.py \
|
|
48
|
+
omp-tool-surface.py omp-config-xdev.sh omp-config-xdev.py omp-receive-install.sh; do
|
|
49
|
+
cp "$REPO_DIR/scripts/$_f" "$PKG/scripts/$_f"
|
|
50
|
+
done
|
|
45
51
|
cp -r "$REPO_DIR/pi" "$PKG/pi"
|
|
46
52
|
cp "$REPO_DIR/pi-extensions/lib/session-id.js" "$PKG/pi-extensions/lib/session-id.js"
|
|
47
53
|
printf '%s\n' '// dist stub: copied+digested by the receive installer, never executed here' \
|
|
48
54
|
> "$PKG/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js"
|
|
49
55
|
cp "$PKG/pi-extensions/lib/session-id.js" "$PKG/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js"
|
|
56
|
+
# The omp units select the same compiled closure in installed mode. Same stub discipline:
|
|
57
|
+
# the installers copy and digest these bytes, they never execute them here.
|
|
58
|
+
for _e in meta-bridge-omp.js meta-bridge-receive-omp.js; do
|
|
59
|
+
printf '%s\n' '// dist stub: copied+digested by the omp installers, never executed here' \
|
|
60
|
+
> "$PKG/mcp/entwurf-bridge/dist/pi-extensions/$_e"
|
|
61
|
+
done
|
|
50
62
|
|
|
51
63
|
# Fake pnpm: resolvable (so an unconditional require_cmd would pass), but any
|
|
52
64
|
# INVOCATION writes a marker and exits uniquely — the bootstrap tripwire.
|
|
@@ -56,12 +68,12 @@ printf '#!/usr/bin/env bash\necho invoked > "%s"\nexit 97\n' "$MARKER" > "$SB/bi
|
|
|
56
68
|
chmod +x "$SB/bin/pnpm"
|
|
57
69
|
|
|
58
70
|
ABSENT="$SB/definitely-absent"
|
|
59
|
-
run_setup() { # $1=HOME-root $2=project $3=PATH $4=PI_BIN $5=COPILOT_BIN(
|
|
71
|
+
run_setup() { # $1=HOME-root $2=project $3=PATH $4=PI_BIN $5=COPILOT_BIN(opt) $6=OMP_BIN(opt) → OUT/RC
|
|
60
72
|
mkdir -p "$1/.pi/agent" "$2"
|
|
61
73
|
set +e
|
|
62
74
|
# ONE physical line by contract: check-install-surface S5c is a line-scoped static tripwire,
|
|
63
75
|
# so the sandbox env assignments must ride the same line as the run.sh drive they guard.
|
|
64
|
-
OUT="$(HOME="$1" XDG_DATA_HOME="$1/.local/share" XDG_STATE_HOME="$1/.local/state" XDG_CACHE_HOME="$1/.cache" XDG_CONFIG_HOME="$1/.config" PI_CODING_AGENT_DIR="$1/.pi/agent" PATH="$3" PI_BIN="$4" CLAUDE_BIN="$ABSENT" AGY_BIN="$ABSENT" COPILOT_BIN="${5:-$ABSENT}" bash "$PKG/run.sh" setup "$2" 2>&1)"
|
|
76
|
+
OUT="$(HOME="$1" XDG_DATA_HOME="$1/.local/share" XDG_STATE_HOME="$1/.local/state" XDG_CACHE_HOME="$1/.cache" XDG_CONFIG_HOME="$1/.config" PI_CODING_AGENT_DIR="$1/.pi/agent" PATH="$3" PI_BIN="$4" CLAUDE_BIN="$ABSENT" AGY_BIN="$ABSENT" COPILOT_BIN="${5:-$ABSENT}" OMP_BIN="${6:-$ABSENT}" ENTWURF_OMP_AGENT_DIR="$1/.omp/agent" bash "$PKG/run.sh" setup "$2" 2>&1)"
|
|
65
77
|
RC=$?
|
|
66
78
|
set -e
|
|
67
79
|
}
|
|
@@ -79,6 +91,8 @@ want "A: no auth.json.bak and credential bytes identical [QK:SETUP-CREDENTIAL-FR
|
|
|
79
91
|
"[ ! -e '$HOME_A/.pi/agent/auth.json.bak' ] && [ \"\$(sha256sum '$HOME_A/.pi/agent/auth.json' | cut -d' ' -f1)\" = '$AUTH_SHA' ]"
|
|
80
92
|
want "A: an absent copilot is one zero-state SKIP — no unit composed, no .copilot written [QK:SETUP-COPILOT-ABSENT-SKIP]" \
|
|
81
93
|
"printf '%s' \"\$OUT\" | grep -q 'copilot: SKIP' && [ ! -e '$HOME_A/.copilot' ]"
|
|
94
|
+
want "A: an absent omp is one zero-state SKIP — no unit composed, no .omp written [QK:SETUP-OMP-ABSENT-SKIP]" \
|
|
95
|
+
"printf '%s' \"\$OUT\" | grep -q 'omp: SKIP' && [ ! -e '$HOME_A/.omp' ]"
|
|
82
96
|
want "A control: mode named first, pi/claude/agy SKIP, bins PASS, core FAIL, NON-GREEN summary" \
|
|
83
97
|
"printf '%s' \"\$OUT\" | head -n 1 | grep -q 'mode: installed package' && printf '%s' \"\$OUT\" | grep -q 'pi: SKIP' && printf '%s' \"\$OUT\" | grep -q 'claude: SKIP' && printf '%s' \"\$OUT\" | grep -q 'agy: SKIP' && printf '%s' \"\$OUT\" | grep -q 'bins: PASS' && printf '%s' \"\$OUT\" | grep -q 'core: FAIL' && printf '%s' \"\$OUT\" | grep -q 'NON-GREEN'"
|
|
84
98
|
|
|
@@ -122,5 +136,29 @@ want "D: the failing-vendor birth is a named FAIL, never a cosmetic PASS [QK:SET
|
|
|
122
136
|
want "D control: detected copilot never reads SKIP, and the summary names copilot-birth NON-GREEN" \
|
|
123
137
|
"! printf '%s' \"\$OUT\" | grep -q 'copilot: SKIP' && printf '%s' \"\$OUT\" | grep -q 'NON-GREEN' && printf '%s' \"\$OUT\" | grep -q 'copilot-birth'"
|
|
124
138
|
|
|
139
|
+
# ── Cell E: omp PRESENT — the four units compose, and the SETTING is a real writer ──
|
|
140
|
+
# The omp unit scripts never spawn the vendor (they probe `omp` on PATH and write into the
|
|
141
|
+
# agent dir), so a stub binary is a faithful presence pin. Two claims live here: the
|
|
142
|
+
# composition reaches omp at all, and the tools.xdev writer refuses an EXPLICIT operator
|
|
143
|
+
# `true` by name rather than overwriting a decision it disagrees with.
|
|
144
|
+
STUB_OMP="$SB/stub-omp"; mkdir -p "$STUB_OMP"
|
|
145
|
+
printf '#!/usr/bin/env bash\necho "omp/18.0.0"\n' > "$STUB_OMP/omp"
|
|
146
|
+
chmod +x "$STUB_OMP/omp"
|
|
147
|
+
run_setup "$SB/home-e" "$SB/proj-e" "$STUB_OMP:$SB/bin:$PATH" "$ABSENT" "" "$STUB_OMP/omp"
|
|
148
|
+
# One assertion on purpose: the four PASS rows AND the artifacts behind them. Split in two,
|
|
149
|
+
# the row half alone passes a composition that reports PASS without running the unit.
|
|
150
|
+
want "E: a detected omp composes all four units — own rows, each backed by its ARTIFACT rather than an exit code [QK:SETUP-OMP-INDEPENDENT]" \
|
|
151
|
+
"printf '%s' \"\$OUT\" | grep -q 'omp-birth: PASS' && printf '%s' \"\$OUT\" | grep -q 'omp-mcp: PASS' && printf '%s' \"\$OUT\" | grep -q 'omp-config: PASS' && printf '%s' \"\$OUT\" | grep -q 'omp-receive: PASS' && ! printf '%s' \"\$OUT\" | grep -q 'omp: SKIP' && [ -d '$SB/home-e/.omp/agent/extensions/entwurf-meta-omp' ] && [ -d '$SB/home-e/.omp/agent/extensions/entwurf-receive-omp' ] && [ -f '$SB/home-e/.omp/agent/mcp.json' ]"
|
|
152
|
+
want "E control: the setting reached the config the vendor reads (effective xdev-off)" \
|
|
153
|
+
"[ \"\$(python3 '$PKG/scripts/omp-tool-surface.py' '$SB/home-e/.omp/agent' | awk '/^verdict /{print \$2}')\" = 'xdev-off' ]"
|
|
154
|
+
# Now the disagreement branch: an operator who wrote xdev: true explicitly owns that value.
|
|
155
|
+
mkdir -p "$SB/home-f/.omp/agent"
|
|
156
|
+
printf 'tools: \n xdev: true\n' > "$SB/home-f/.omp/agent/config.yml"
|
|
157
|
+
run_setup "$SB/home-f" "$SB/proj-f" "$STUB_OMP:$SB/bin:$PATH" "$ABSENT" "" "$STUB_OMP/omp"
|
|
158
|
+
want "F: an EXPLICIT operator tools.xdev:true is refused by name, never overwritten [QK:SETUP-OMP-CONFIG-NO-OVERWRITE]" \
|
|
159
|
+
"printf '%s' \"\$OUT\" | grep -q 'omp-config: FAIL' && grep -q 'xdev: true' '$SB/home-f/.omp/agent/config.yml' && ! grep -q 'xdev: false' '$SB/home-f/.omp/agent/config.yml'"
|
|
160
|
+
want "F control: the disagreement is a component FAIL that leaves the other omp units composed" \
|
|
161
|
+
"printf '%s' \"\$OUT\" | grep -q 'omp-birth: PASS' && printf '%s' \"\$OUT\" | grep -q 'omp-receive: PASS' && printf '%s' \"\$OUT\" | grep -q 'NON-GREEN'"
|
|
162
|
+
|
|
125
163
|
echo ""
|
|
126
164
|
echo "check-setup-qualification: $PASS checks passed (mutation-attribution oracle only — behavior evidence lives in smoke-setup-verdict and check-pack-install)"
|
|
@@ -77,13 +77,20 @@ PY
|
|
|
77
77
|
# QUALIFIED is absent, present exactly once with a parsed nonempty version, or in a
|
|
78
78
|
# shape nobody may act on. rc 0 prints `absent` or `one <version>`; rc 1 with the
|
|
79
79
|
# reason on stderr for a MALFORMED exact row (claims our qualified id but does not
|
|
80
|
-
# parse as `<qualified> (v<nonempty>)
|
|
81
|
-
#
|
|
80
|
+
# parse as `<qualified> (v<nonempty>)`, optionally followed by the vendor's own
|
|
81
|
+
# `(enabled)`/`(disabled)` state token — measured on Copilot CLI 1.0.81) or MULTIPLE
|
|
82
|
+
# exact rows. Longer tokens that merely contain the qualified id remain foreign and
|
|
83
|
+
# are ignored, same as above.
|
|
82
84
|
copilot_exact_row_version() {
|
|
83
85
|
local list_text="$1" qualified="$2"
|
|
84
86
|
LIST_TEXT_ENV="$list_text" QUALIFIED_ENV="$qualified" python3 - <<'PY'
|
|
85
|
-
import os, sys
|
|
87
|
+
import os, re, sys
|
|
86
88
|
qualified = os.environ["QUALIFIED_ENV"]
|
|
89
|
+
# The measured row tail. Copilot CLI 1.0.81 appends its own state token after the
|
|
90
|
+
# version (`... (v0.1.0) (enabled)`), so the grammar carries that OPTIONAL trailing
|
|
91
|
+
# `(enabled)`/`(disabled)` explicitly instead of reading it as a garbled version.
|
|
92
|
+
# Anything else in the tail is still malformed.
|
|
93
|
+
ROW_TAIL = re.compile(r"\(v(?P<version>[^()]*)\)(?:[ \t]+\((?:enabled|disabled)\))?\Z")
|
|
87
94
|
versions = []
|
|
88
95
|
for raw in os.environ["LIST_TEXT_ENV"].splitlines():
|
|
89
96
|
row = raw.strip()
|
|
@@ -100,10 +107,11 @@ for raw in os.environ["LIST_TEXT_ENV"].splitlines():
|
|
|
100
107
|
if not parts or parts[0] != qualified:
|
|
101
108
|
continue # foreign row, including longer ids that merely contain ours
|
|
102
109
|
rest = parts[1] if len(parts) == 2 else ""
|
|
103
|
-
|
|
104
|
-
|
|
110
|
+
match = ROW_TAIL.fullmatch(rest)
|
|
111
|
+
if match is None:
|
|
112
|
+
print(f"malformed exact row for {qualified}: {row!r} does not parse as '<qualified> (v<version>)' with an optional '(enabled)'/'(disabled)' state token", file=sys.stderr)
|
|
105
113
|
sys.exit(1)
|
|
106
|
-
version =
|
|
114
|
+
version = match.group("version")
|
|
107
115
|
if not version:
|
|
108
116
|
print(f"malformed exact row for {qualified}: empty version in {row!r}", file=sys.stderr)
|
|
109
117
|
sys.exit(1)
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
# cannot drift: smoke-setup-verdict's copilot-present cell and check-pack-install's
|
|
5
5
|
# installed copilot-present consumer row. The answer shapes are the MEASURED CLI's
|
|
6
6
|
# (copilot 1.0.80, 2026-08-27) — the same shapes check-copilot-birth-hook.ts bakes
|
|
7
|
-
# into its own TS fake: `plugin list` prints qualified ids with a `(vX)` suffix
|
|
7
|
+
# into its own TS fake: `plugin list` prints qualified ids with a `(vX)` suffix
|
|
8
|
+
# followed by the vendor's `(enabled)` state token and an indented `from <path>`
|
|
9
|
+
# continuation line (measured on copilot 1.0.81, 2026-08-31),
|
|
8
10
|
# `plugin marketplace list` prints `<name> (Local: <abs path>)`, and `--force`
|
|
9
11
|
# anywhere is refused loudly (the real `marketplace remove --force` uninstalls that
|
|
10
12
|
# marketplace's plugins as a side effect; no entwurf surface may reach for it).
|
|
@@ -51,7 +53,7 @@ case "\$1 \$2 \$3" in
|
|
|
51
53
|
"plugin marketplace remove") awk -F"\t" -v n="\$4" '\$1 != n' "\$MKTS" > "\$MKTS.tmp"; mv "\$MKTS.tmp" "\$MKTS"; exit 0 ;;
|
|
52
54
|
esac
|
|
53
55
|
case "\$1 \$2" in
|
|
54
|
-
"plugin list") echo "
|
|
56
|
+
"plugin list") echo "Live Plugins (loaded from a local marketplace directory, never copied):"; while read -r id; do [ -n "\$id" ] && { echo " • \$id (v\$VER) (enabled)"; echo " from $dir"; }; done < "\$STATE"; exit 0 ;;
|
|
55
57
|
"plugin uninstall") grep -Fvx "\$3" "\$STATE" > "\$STATE.tmp"; mv "\$STATE.tmp" "\$STATE"; exit 0 ;;
|
|
56
58
|
"plugin install") echo "\$3" >> "\$STATE"; exit 0 ;;
|
|
57
59
|
esac
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"lane": "acp-launch-namespace",
|
|
4
|
+
"mutants": [
|
|
5
|
+
{
|
|
6
|
+
"claim": "CLAUDE-LAUNCH-IS-NAMESPACED",
|
|
7
|
+
"title": "the default launch names the vendor bin directly again — the child re-enters the process-name space a janitor for another harness scans by substring, which is the whole of #72",
|
|
8
|
+
"subject": "pi-extensions/lib/acp/backend-adapter.ts",
|
|
9
|
+
"find": ["\tconst launcher = fileURLToPath(new URL(\"./claude-acp-launch.js\", import.meta.url));"],
|
|
10
|
+
"replace": ["\tconst launcher = require.resolve(\"@agentclientprotocol/claude-agent-acp/dist/index.js\");"],
|
|
11
|
+
"gate": ["bash", "run.sh", "check-acp-launch-namespace"],
|
|
12
|
+
"timeoutSeconds": 240,
|
|
13
|
+
"signature": "[QK:CLAUDE-LAUNCH-IS-NAMESPACED]",
|
|
14
|
+
"signatureSource": "scripts/check-acp-launch-namespace.ts"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"claim": "CLAUDE-LAUNCH-IS-TRANSPARENT",
|
|
18
|
+
"title": "the launcher consumes an argv slot of its own — the vendor's argv.slice(1) self-reinvocation and its own flags stop working behind a name split that still looks correct",
|
|
19
|
+
"subject": "pi-extensions/lib/acp/claude-acp-launch.js",
|
|
20
|
+
"find": ["\tawait import(pathToFileURL(join(dirname(pkgJsonPath), binPath)).href);"],
|
|
21
|
+
"replace": [
|
|
22
|
+
"\tif (process.argv.includes(\"--version\")) {",
|
|
23
|
+
"\t\tconsole.log(\"entwurf launcher\");",
|
|
24
|
+
"\t\tprocess.exit(0);",
|
|
25
|
+
"\t}",
|
|
26
|
+
"\tawait import(pathToFileURL(join(dirname(pkgJsonPath), binPath)).href);"
|
|
27
|
+
],
|
|
28
|
+
"gate": ["bash", "run.sh", "check-acp-launch-namespace"],
|
|
29
|
+
"timeoutSeconds": 240,
|
|
30
|
+
"signature": "[QK:CLAUDE-LAUNCH-IS-TRANSPARENT]",
|
|
31
|
+
"signatureSource": "scripts/check-acp-launch-namespace.ts"
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|