@junghanacs/entwurf 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +240 -0
- package/BASELINE.md +227 -0
- package/CHANGELOG.md +1210 -0
- package/CONTRIBUTING.md +63 -0
- package/DELIVERY.md +209 -0
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/VERIFY.md +260 -0
- package/demo/README.md +188 -0
- package/demo/demo-baseline.sh +156 -0
- package/demo/demo.sh +183 -0
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/assets/entwurf-doomemacs.gif +0 -0
- package/docs/assets/entwurf-entwurf.gif +0 -0
- package/docs/assets/entwurf-hero.jpg +0 -0
- package/docs/setup-clean-host.md +305 -0
- package/mcp/entwurf-bridge/src/index.ts +513 -0
- package/mcp/entwurf-bridge/start.sh +25 -0
- package/mcp/entwurf-bridge/test.sh +54 -0
- package/mcp/tsconfig.json +29 -0
- package/package.json +130 -0
- package/pi/entwurf-capabilities.json +9 -0
- package/pi/entwurf-targets.json +20 -0
- package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
- package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
- package/pi/settings.reference.json +43 -0
- package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
- package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
- package/pi-extensions/acp-provider.ts +63 -0
- package/pi-extensions/entwurf-control.ts +1692 -0
- package/pi-extensions/lib/acp/acp-client.ts +90 -0
- package/pi-extensions/lib/acp/augment.ts +238 -0
- package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
- package/pi-extensions/lib/acp/backend.ts +873 -0
- package/pi-extensions/lib/acp/config.ts +552 -0
- package/pi-extensions/lib/acp/context.ts +177 -0
- package/pi-extensions/lib/acp/engraving.ts +123 -0
- package/pi-extensions/lib/acp/event-mapper.ts +339 -0
- package/pi-extensions/lib/acp/models.ts +102 -0
- package/pi-extensions/lib/acp/overlay.ts +220 -0
- package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
- package/pi-extensions/lib/acp/session-store.ts +418 -0
- package/pi-extensions/lib/acp/tool-surface.ts +184 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
- package/pi-extensions/lib/entwurf-core.ts +2033 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
- package/pi-extensions/lib/entwurf-facts.ts +251 -0
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
- package/pi-extensions/lib/entwurf-preflight.ts +247 -0
- package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
- package/pi-extensions/lib/entwurf-self-address.ts +117 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
- package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
- package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
- package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
- package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
- package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
- package/pi-extensions/lib/meta-session.ts +1951 -0
- package/pi-extensions/lib/project-trust-handler.ts +154 -0
- package/pi-extensions/lib/session-id.js +57 -0
- package/pi-extensions/lib/socket-discovery.ts +346 -0
- package/pi-extensions/lib/socket-probe.ts +92 -0
- package/pi-extensions/meta-bridge-hook.ts +250 -0
- package/pi-extensions/model-lock.ts +235 -0
- package/prompts/engraving.md +27 -0
- package/protocol.js +31 -0
- package/run.sh +2832 -0
- package/scripts/check-acp-backend-preflight.ts +130 -0
- package/scripts/check-acp-carrier-augment.ts +297 -0
- package/scripts/check-acp-config.ts +322 -0
- package/scripts/check-acp-event-mapper.ts +253 -0
- package/scripts/check-acp-overlay.ts +154 -0
- package/scripts/check-acp-prompt-builder.ts +191 -0
- package/scripts/check-acp-provider-surface.ts +159 -0
- package/scripts/check-acp-sdk-surface.ts +211 -0
- package/scripts/check-acp-session-reuse.ts +756 -0
- package/scripts/check-acp-session-store.ts +387 -0
- package/scripts/check-acp-tool-surface.ts +159 -0
- package/scripts/check-entwurf-bridge-boot.ts +199 -0
- package/scripts/check-entwurf-capabilities.ts +123 -0
- package/scripts/check-entwurf-control-rpc.ts +187 -0
- package/scripts/check-entwurf-deliverability.ts +158 -0
- package/scripts/check-entwurf-fact-provider.ts +316 -0
- package/scripts/check-entwurf-facts.ts +358 -0
- package/scripts/check-entwurf-mailbox-guard.ts +264 -0
- package/scripts/check-entwurf-peers-surface.ts +235 -0
- package/scripts/check-entwurf-resume-args.ts +149 -0
- package/scripts/check-entwurf-self-address.ts +209 -0
- package/scripts/check-entwurf-session-identity.ts +703 -0
- package/scripts/check-entwurf-v2-contract.ts +536 -0
- package/scripts/check-entwurf-v2-decider.ts +795 -0
- package/scripts/check-entwurf-v2-lock.ts +368 -0
- package/scripts/check-entwurf-v2-mailbox.ts +228 -0
- package/scripts/check-entwurf-v2-matrix.ts +437 -0
- package/scripts/check-entwurf-v2-production.ts +406 -0
- package/scripts/check-entwurf-v2-release.ts +245 -0
- package/scripts/check-entwurf-v2-runner.ts +452 -0
- package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
- package/scripts/check-entwurf-v2-send.ts +454 -0
- package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
- package/scripts/check-entwurf-v2-spawn.ts +398 -0
- package/scripts/check-entwurf-v2-surface.ts +352 -0
- package/scripts/check-keyset-overlap.py +120 -0
- package/scripts/check-mailbox-receipt-state.ts +170 -0
- package/scripts/check-meta-capability-source.ts +112 -0
- package/scripts/check-meta-dual-consumers.ts +154 -0
- package/scripts/check-meta-dual-read.ts +158 -0
- package/scripts/check-meta-listing.ts +138 -0
- package/scripts/check-meta-mailbox-state-write.ts +135 -0
- package/scripts/check-meta-migration.ts +212 -0
- package/scripts/check-meta-receiver-marker.ts +185 -0
- package/scripts/check-meta-record-v2.ts +191 -0
- package/scripts/check-meta-session.ts +673 -0
- package/scripts/check-model-lock.ts +408 -0
- package/scripts/check-package-source-routing.ts +253 -0
- package/scripts/check-pi-preflight.ts +304 -0
- package/scripts/check-project-trust-handler.ts +265 -0
- package/scripts/check-shell-quote.ts +121 -0
- package/scripts/check-socket-discovery.ts +428 -0
- package/scripts/check-socket-probe.ts +106 -0
- package/scripts/fixtures/probe-mcp-server.ts +33 -0
- package/scripts/gnew-rpc-drive.ts +211 -0
- package/scripts/lib/acp-child-cleanup.ts +116 -0
- package/scripts/meta-bridge-doctor.sh +315 -0
- package/scripts/meta-bridge-hook-log.sh +26 -0
- package/scripts/meta-bridge-install.sh +135 -0
- package/scripts/meta-bridge-prune.ts +199 -0
- package/scripts/meta-bridge-state.py +549 -0
- package/scripts/meta-bridge-statusline.sh +192 -0
- package/scripts/meta-bridge-store-doctor.ts +64 -0
- package/scripts/meta-bridge-uninstall.sh +39 -0
- package/scripts/new-session-id.ts +25 -0
- package/scripts/postinstall-chmod.cjs +58 -0
- package/scripts/raw-async-delivery/README.md +258 -0
- package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
- package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
- package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
- package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
- package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
- package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
- package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
- package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
- package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
- package/scripts/resolve-acp-bridge.ts +25 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
- package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
- package/scripts/smoke-acp-mcp-live.ts +129 -0
- package/scripts/smoke-acp-memory-containment-live.ts +389 -0
- package/scripts/smoke-acp-overlay-live.ts +314 -0
- package/scripts/smoke-acp-provider-live.ts +162 -0
- package/scripts/smoke-acp-raw-turn-live.ts +261 -0
- package/scripts/smoke-acp-session-reuse-live.ts +172 -0
- package/scripts/smoke-acp-skill-live.ts +144 -0
- package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
- package/scripts/smoke-claude-native-resume-live.sh +198 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
- package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
- package/scripts/smoke-meta-async-drift.sh +171 -0
- package/scripts/smoke-meta-honesty.sh +147 -0
- package/scripts/smoke-meta-install-state.sh +403 -0
- package/scripts/smoke-meta-keyset-guard.sh +111 -0
- package/scripts/smoke-meta-prune.sh +174 -0
- package/scripts/smoke-resident-garden-guard.sh +433 -0
- package/scripts/smoke-session-id-name.ts +187 -0
- package/scripts/tsconfig.json +34 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-shell-quote — deterministic POSIX-safety gate for remote SSH arg quoting.
|
|
3
|
+
*
|
|
4
|
+
* Background: the 2026-05-18 remote entwurf incident traced back to building
|
|
5
|
+
* SSH command strings with `JSON.stringify`, which is NOT a shell-safe escape.
|
|
6
|
+
* Backticks and `$(...)` inside a user prompt got executed by the remote shell
|
|
7
|
+
* before pi ever saw them. The fix introduced `shellQuote()` (POSIX `'...'`
|
|
8
|
+
* with `'\''` escape) in two places:
|
|
9
|
+
* - pi-extensions/lib/entwurf-core.ts (legacy sync spawn/resume helper while v2 extraction is in progress)
|
|
10
|
+
*
|
|
11
|
+
* This script enforces two invariants:
|
|
12
|
+
* 1. `shellQuote()` source matches the reference implementation byte-for-byte.
|
|
13
|
+
* 2. The reference implementation produces POSIX-safe output for the exact
|
|
14
|
+
* payload classes that caused the original incident — backtick command
|
|
15
|
+
* substitution, `$(...)` command substitution, `$VAR` expansion, embedded
|
|
16
|
+
* single quotes, whitespace, empty string, non-ASCII text.
|
|
17
|
+
*
|
|
18
|
+
* No process spawn, no SSH, no API. Pure-string verification — safe in `pnpm
|
|
19
|
+
* check` chain.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import assert from "node:assert/strict";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
28
|
+
|
|
29
|
+
// Reference implementation — must match the literal function body in both
|
|
30
|
+
// production sites. If you need to change the algorithm, change the reference
|
|
31
|
+
// FIRST, then sync both sites, then update SOURCE_SITES.
|
|
32
|
+
function shellQuote(value: string): string {
|
|
33
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const REFERENCE_BODY = `function shellQuote(value: string): string {
|
|
37
|
+
\treturn \`'\${value.replace(/'/g, \`'\\\\''\`)}\`;
|
|
38
|
+
}`;
|
|
39
|
+
|
|
40
|
+
const SOURCE_SITES = ["pi-extensions/lib/entwurf-core.ts"] as const;
|
|
41
|
+
|
|
42
|
+
// Match the function block from `function shellQuote` up to the closing brace.
|
|
43
|
+
// Tab indentation is required (matches the rest of the repo).
|
|
44
|
+
const SHELL_QUOTE_BLOCK =
|
|
45
|
+
/function shellQuote\(value: string\): string \{\n\treturn `'\$\{value\.replace\(\/'\/g, `'\\\\''`\)\}'`;\n\}/;
|
|
46
|
+
|
|
47
|
+
let assertions = 0;
|
|
48
|
+
|
|
49
|
+
function check(label: string, fn: () => void): void {
|
|
50
|
+
fn();
|
|
51
|
+
assertions += 1;
|
|
52
|
+
process.stdout.write(`[check-shell-quote] ${label}: ok\n`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ------------------------------------------------------------------
|
|
56
|
+
// Invariant 1 — source parity across the two duplication sites
|
|
57
|
+
// ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
for (const rel of SOURCE_SITES) {
|
|
60
|
+
check(`source parity — ${rel}`, () => {
|
|
61
|
+
const abs = path.join(REPO_ROOT, rel);
|
|
62
|
+
const text = fs.readFileSync(abs, "utf8");
|
|
63
|
+
const match = text.match(SHELL_QUOTE_BLOCK);
|
|
64
|
+
assert.ok(
|
|
65
|
+
match,
|
|
66
|
+
`shellQuote() block in ${rel} did not match the reference shape. ` +
|
|
67
|
+
`If you intentionally changed the algorithm, update both sites AND the ` +
|
|
68
|
+
`reference block in scripts/check-shell-quote.ts.`,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
check("reference body byte-pattern is canonical", () => {
|
|
74
|
+
// Sanity: the reference declared above is itself a valid shellQuote
|
|
75
|
+
// definition. The string match is informational; the real check is the
|
|
76
|
+
// behavioral suite below.
|
|
77
|
+
assert.match(REFERENCE_BODY, /function shellQuote/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ------------------------------------------------------------------
|
|
81
|
+
// Invariant 2 — POSIX-safety behavior on the payload classes that
|
|
82
|
+
// caused the 2026-05-18 incident, plus general escape cases.
|
|
83
|
+
// ------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
type Case = { label: string; input: string; expected: string };
|
|
86
|
+
|
|
87
|
+
const CASES: Case[] = [
|
|
88
|
+
// The four incident payloads.
|
|
89
|
+
{ label: "backtick command substitution", input: "`date`", expected: "'`date`'" },
|
|
90
|
+
{ label: "$() command substitution", input: "$(rm -rf /)", expected: "'$(rm -rf /)'" },
|
|
91
|
+
{ label: "$VAR expansion", input: "$HOME/.pi/agent", expected: "'$HOME/.pi/agent'" },
|
|
92
|
+
{
|
|
93
|
+
label: "korean token broken by SSH (denotecli read 어제)",
|
|
94
|
+
input: "denotecli read 어제",
|
|
95
|
+
expected: "'denotecli read 어제'",
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
// General escape correctness.
|
|
99
|
+
{ label: "plain alphanumeric", input: "hello", expected: "'hello'" },
|
|
100
|
+
{ label: "whitespace preserved", input: "hello world", expected: "'hello world'" },
|
|
101
|
+
{ label: "embedded single quote", input: "it's fine", expected: `'it'\\''s fine'` },
|
|
102
|
+
{ label: "multiple single quotes", input: "''", expected: `''\\'''\\'''` },
|
|
103
|
+
{ label: "empty string", input: "", expected: "''" },
|
|
104
|
+
{ label: "double quote literal", input: 'she said "hi"', expected: `'she said "hi"'` },
|
|
105
|
+
{ label: "backslash literal", input: "a\\b", expected: "'a\\b'" },
|
|
106
|
+
{ label: "newline literal", input: "a\nb", expected: "'a\nb'" },
|
|
107
|
+
{ label: "ampersand + semicolon", input: "a && b ; c", expected: "'a && b ; c'" },
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
for (const c of CASES) {
|
|
111
|
+
check(`behavior — ${c.label}`, () => {
|
|
112
|
+
const actual = shellQuote(c.input);
|
|
113
|
+
assert.equal(actual, c.expected, `input=${JSON.stringify(c.input)}`);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ------------------------------------------------------------------
|
|
118
|
+
// Done
|
|
119
|
+
// ------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
process.stdout.write(`[check-shell-quote] ${assertions} assertions ok\n`);
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-socket-discovery — deterministic gate for the fact-provider's SOCKET
|
|
3
|
+
* axis (0.11 Stage 0 step 4, slice 3). Drives `scanSocketProbes` with injected
|
|
4
|
+
* readdir/probe (no IO) and proves the slice-2 contract is fed correctly:
|
|
5
|
+
*
|
|
6
|
+
* - union: every dir-present socket AND every in-domain citizen is probed,
|
|
7
|
+
* - dormant trap: a pi citizen with NO socket file reads `dead` (ENOENT), so
|
|
8
|
+
* downstream it routes dormant→resumable (never an unprobed gap),
|
|
9
|
+
* - F3 preserve: a stalled socket reads `indeterminate`, never folded to dead,
|
|
10
|
+
* - dir hygiene: non-`.sock` names ignored; malformed (non-garden-id) names
|
|
11
|
+
* are VISIBLY dropped (`malformedNames`), not silently (P3),
|
|
12
|
+
* - symlink guard (P1): a gid-shaped `*.sock` symlink is never probed — a
|
|
13
|
+
* citizen owning one is forced `dead`, a record-less one dropped — and is
|
|
14
|
+
* surfaced in `symlinkedGardenIds`,
|
|
15
|
+
* - dir-read error (P2e②): ENOENT → `dirError=null` (normal empty); any other
|
|
16
|
+
* failure → `dirError` set (socket axis loss surfaced, not swallowed),
|
|
17
|
+
* - dedup: a gid present in BOTH the dir and the citizen list is probed once,
|
|
18
|
+
* - missing dir: citizens are still probed (→ dead),
|
|
19
|
+
* - determinism: output sorted by gardenId,
|
|
20
|
+
* - enrich: default fake scans stay null; live sockets can carry get_info cwd/model/idle,
|
|
21
|
+
* - end-to-end: scanSocketProbes → resolveFactList yields the dormant citizen
|
|
22
|
+
* as a resumable `dead` PeerFact (no throw — all in-domain citizens probed).
|
|
23
|
+
*
|
|
24
|
+
* No IO, no backend, no API — readdir/probe are injected fakes.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import assert from "node:assert/strict";
|
|
28
|
+
import * as path from "node:path";
|
|
29
|
+
import { resolveFactList } from "../pi-extensions/lib/entwurf-facts.ts";
|
|
30
|
+
import type { MetaBackendV2, MetaIdentity } from "../pi-extensions/lib/meta-session.ts";
|
|
31
|
+
import {
|
|
32
|
+
controlSocketPath,
|
|
33
|
+
inspectControlSocketPath,
|
|
34
|
+
inspectTargetControlSocket,
|
|
35
|
+
type LstatLike,
|
|
36
|
+
mapInspectionToLiveness,
|
|
37
|
+
SOCKET_SUFFIX,
|
|
38
|
+
type SocketDirEntry,
|
|
39
|
+
scanSocketProbes,
|
|
40
|
+
} from "../pi-extensions/lib/socket-discovery.ts";
|
|
41
|
+
import type { SocketLiveness } from "../pi-extensions/lib/socket-probe.ts";
|
|
42
|
+
|
|
43
|
+
let passed = 0;
|
|
44
|
+
function ok(label: string, cond: boolean): void {
|
|
45
|
+
assert.ok(cond, label);
|
|
46
|
+
console.log(` ok ${label}`);
|
|
47
|
+
passed++;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DIR = "/fake/entwurf-control";
|
|
51
|
+
const GID_LIVE = "20260611T115213-3aa371"; // socket present → alive
|
|
52
|
+
const GID_STALL = "20260611T135517-5f0d25"; // socket present → indeterminate (F3)
|
|
53
|
+
const GID_DORMANT = "20260611T093858-14984d"; // pi citizen, no socket file → dead
|
|
54
|
+
const GID_SOCKET_ONLY = "20260611T222222-bbbbbb"; // socket present, no citizen
|
|
55
|
+
|
|
56
|
+
const PROBE_MAP: Record<string, SocketLiveness> = {
|
|
57
|
+
[GID_LIVE]: "alive",
|
|
58
|
+
[GID_STALL]: "indeterminate",
|
|
59
|
+
[GID_SOCKET_ONLY]: "alive",
|
|
60
|
+
// GID_DORMANT intentionally absent → fakeProbe returns "dead" (ENOENT)
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Plain regular-file entries (the common case). A name may be passed as a
|
|
64
|
+
// `{name, isSymbolicLink}` tuple to mark it a symlink.
|
|
65
|
+
function fakeReaddir(names: Array<string | SocketDirEntry>): (dir: string) => Promise<SocketDirEntry[]> {
|
|
66
|
+
return async () => names.map((n) => (typeof n === "string" ? { name: n, isSymbolicLink: false } : n));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Probe keyed by the gardenId embedded in the canonical socket path. An absent
|
|
70
|
+
// gid → "dead", mirroring an ENOENT connect on a missing socket file.
|
|
71
|
+
function fakeProbe(byGid: Record<string, SocketLiveness>): (socketPath: string) => Promise<SocketLiveness> {
|
|
72
|
+
return async (socketPath: string) => {
|
|
73
|
+
const base = path.basename(socketPath, SOCKET_SUFFIX);
|
|
74
|
+
return byGid[base] ?? "dead";
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const NAMES = [`${GID_LIVE}.sock`, `${GID_STALL}.sock`, `${GID_SOCKET_ONLY}.sock`, "README.txt", "not-a-gid.sock"];
|
|
79
|
+
|
|
80
|
+
async function main(): Promise<void> {
|
|
81
|
+
// ── union: dir sockets ∪ pi citizens, each probed ──────────────────────────
|
|
82
|
+
{
|
|
83
|
+
const { probes } = await scanSocketProbes([GID_DORMANT, GID_LIVE], {
|
|
84
|
+
dir: DIR,
|
|
85
|
+
readdir: fakeReaddir(NAMES),
|
|
86
|
+
probe: fakeProbe(PROBE_MAP),
|
|
87
|
+
});
|
|
88
|
+
const byGid = Object.fromEntries(probes.map((p) => [p.gardenId, p]));
|
|
89
|
+
ok("union: dir sockets + pi citizens all probed (LIVE/STALL/SOCKET_ONLY/DORMANT)", probes.length === 4);
|
|
90
|
+
ok("alive socket → alive", byGid[GID_LIVE]?.liveness === "alive");
|
|
91
|
+
ok("F3: stalled socket → indeterminate (never folded to dead)", byGid[GID_STALL]?.liveness === "indeterminate");
|
|
92
|
+
ok("dormant pi citizen (no socket file) → dead (ENOENT)", byGid[GID_DORMANT]?.liveness === "dead");
|
|
93
|
+
ok("record-less socket → alive", byGid[GID_SOCKET_ONLY]?.liveness === "alive");
|
|
94
|
+
ok(
|
|
95
|
+
"enrich default null under injected fake scan (honest)",
|
|
96
|
+
byGid[GID_LIVE]?.cwd === null && byGid[GID_LIVE]?.model === null,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── live get_info enrich: only alive sockets carry cwd/model/idle ───────────
|
|
101
|
+
{
|
|
102
|
+
const calls: string[] = [];
|
|
103
|
+
const { probes } = await scanSocketProbes([GID_DORMANT], {
|
|
104
|
+
dir: DIR,
|
|
105
|
+
readdir: fakeReaddir([`${GID_LIVE}.sock`, `${GID_STALL}.sock`]),
|
|
106
|
+
probe: fakeProbe(PROBE_MAP),
|
|
107
|
+
getInfo: async (socketPath) => {
|
|
108
|
+
calls.push(path.basename(socketPath, SOCKET_SUFFIX));
|
|
109
|
+
return { cwd: "/work/cos", model: "gpt-5.4", idle: false };
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
const byGid = Object.fromEntries(probes.map((p) => [p.gardenId, p]));
|
|
113
|
+
ok("get_info called only for alive sockets", calls.length === 1 && calls[0] === GID_LIVE);
|
|
114
|
+
ok("alive socket enrich carries cwd", byGid[GID_LIVE]?.cwd === "/work/cos");
|
|
115
|
+
ok("alive socket enrich carries model", byGid[GID_LIVE]?.model === "gpt-5.4");
|
|
116
|
+
ok("alive socket enrich carries idle", byGid[GID_LIVE]?.idle === false);
|
|
117
|
+
ok(
|
|
118
|
+
"indeterminate socket does not get_info",
|
|
119
|
+
byGid[GID_STALL]?.cwd === null && byGid[GID_STALL]?.infoError === null,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── get_info failure is surfaced but liveness remains alive ─────────────────
|
|
124
|
+
{
|
|
125
|
+
const { probes } = await scanSocketProbes([], {
|
|
126
|
+
dir: DIR,
|
|
127
|
+
readdir: fakeReaddir([`${GID_LIVE}.sock`]),
|
|
128
|
+
probe: fakeProbe(PROBE_MAP),
|
|
129
|
+
getInfo: async () => {
|
|
130
|
+
throw new Error("boom get_info");
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
ok("get_info failure keeps alive liveness", probes[0]?.liveness === "alive");
|
|
134
|
+
ok("get_info failure surfaces infoError", probes[0]?.infoError?.includes("boom get_info") === true);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── dir hygiene: non-.sock ignored; malformed names VISIBLY dropped (P3) ────
|
|
138
|
+
{
|
|
139
|
+
const { probes, malformedNames } = await scanSocketProbes([], {
|
|
140
|
+
dir: DIR,
|
|
141
|
+
readdir: fakeReaddir(NAMES),
|
|
142
|
+
probe: fakeProbe(PROBE_MAP),
|
|
143
|
+
});
|
|
144
|
+
const gids = probes.map((p) => p.gardenId);
|
|
145
|
+
ok("non-.sock entry ignored (README.txt)", !gids.includes("README"));
|
|
146
|
+
ok("malformed socket name (not a garden id) not probed", !gids.some((g) => g.includes("not-a-gid")));
|
|
147
|
+
ok(
|
|
148
|
+
"only well-formed garden ids surface",
|
|
149
|
+
gids.every((g) => /^\d{8}T\d{6}-[0-9a-f]{6}$/.test(g)),
|
|
150
|
+
);
|
|
151
|
+
ok("P3: malformed .sock name surfaced (not silently dropped)", malformedNames.includes("not-a-gid.sock"));
|
|
152
|
+
ok("P3: non-.sock entry is NOT a malformed-name diagnostic", !malformedNames.includes("README.txt"));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── symlink guard (P1): gid-shaped .sock symlink never probed, surfaced ─────
|
|
156
|
+
{
|
|
157
|
+
// GID_LIVE is a symlink (forgery vector) AND a pi citizen; GID_SOCKET_ONLY is
|
|
158
|
+
// a symlink with no citizen (record-less). GID_DORMANT is a clean citizen.
|
|
159
|
+
const { probes, symlinkedGardenIds } = await scanSocketProbes([GID_LIVE, GID_DORMANT], {
|
|
160
|
+
dir: DIR,
|
|
161
|
+
readdir: fakeReaddir([
|
|
162
|
+
{ name: `${GID_LIVE}.sock`, isSymbolicLink: true },
|
|
163
|
+
{ name: `${GID_SOCKET_ONLY}.sock`, isSymbolicLink: true },
|
|
164
|
+
`${GID_DORMANT}.sock`,
|
|
165
|
+
]),
|
|
166
|
+
// probe would return "alive" for both — proving we did NOT probe the symlinks.
|
|
167
|
+
probe: fakeProbe({ [GID_LIVE]: "alive", [GID_SOCKET_ONLY]: "alive", [GID_DORMANT]: "alive" }),
|
|
168
|
+
});
|
|
169
|
+
const byGid = Object.fromEntries(probes.map((p) => [p.gardenId, p]));
|
|
170
|
+
ok("P1: symlinked sockets surfaced in symlinkedGardenIds", symlinkedGardenIds.length === 2);
|
|
171
|
+
ok("P1: symlinked citizen forced dead (NOT probed alive)", byGid[GID_LIVE]?.liveness === "dead");
|
|
172
|
+
ok("P1: record-less symlink dropped from probes entirely", byGid[GID_SOCKET_ONLY] === undefined);
|
|
173
|
+
ok("P1: clean citizen still probed normally", byGid[GID_DORMANT]?.liveness === "alive");
|
|
174
|
+
ok(
|
|
175
|
+
"P1: symlinkedGardenIds sorted",
|
|
176
|
+
symlinkedGardenIds[0] !== undefined && symlinkedGardenIds[0] < (symlinkedGardenIds[1] ?? "~"),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── dedup: gid in BOTH dir and citizen list → probed once ──────────────────
|
|
181
|
+
{
|
|
182
|
+
const { probes } = await scanSocketProbes([GID_LIVE], {
|
|
183
|
+
dir: DIR,
|
|
184
|
+
readdir: fakeReaddir([`${GID_LIVE}.sock`]),
|
|
185
|
+
probe: fakeProbe(PROBE_MAP),
|
|
186
|
+
});
|
|
187
|
+
ok("dedup: gid in dir AND citizen list → one probe", probes.filter((p) => p.gardenId === GID_LIVE).length === 1);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── dir-read error (P2e②): ENOENT empty vs other-error surfaced ────────────
|
|
191
|
+
{
|
|
192
|
+
const enoent = await scanSocketProbes([GID_DORMANT], {
|
|
193
|
+
dir: DIR,
|
|
194
|
+
readdir: async () => {
|
|
195
|
+
const e = new Error("ENOENT: no such directory") as NodeJS.ErrnoException;
|
|
196
|
+
e.code = "ENOENT";
|
|
197
|
+
throw e;
|
|
198
|
+
},
|
|
199
|
+
probe: fakeProbe(PROBE_MAP),
|
|
200
|
+
});
|
|
201
|
+
ok("missing dir (ENOENT) → empty listing, citizen still probed", enoent.probes.length === 1);
|
|
202
|
+
ok("missing dir → dormant citizen reads dead", enoent.probes[0]?.liveness === "dead");
|
|
203
|
+
ok("P2e②: ENOENT is the normal empty → dirError null (not surfaced)", enoent.dirError === null);
|
|
204
|
+
|
|
205
|
+
// probe returns "alive" — proving the citizen is NOT probed when the dir is
|
|
206
|
+
// untrusted (a non-ENOENT readdir failure): connect() would follow a symlink,
|
|
207
|
+
// so we hold the citizen at indeterminate instead (GPi Q2/P1).
|
|
208
|
+
let probeCalled = false;
|
|
209
|
+
const eacces = await scanSocketProbes([GID_DORMANT], {
|
|
210
|
+
dir: DIR,
|
|
211
|
+
readdir: async () => {
|
|
212
|
+
const e = new Error("EACCES: permission denied, scandir") as NodeJS.ErrnoException;
|
|
213
|
+
e.code = "EACCES";
|
|
214
|
+
throw e;
|
|
215
|
+
},
|
|
216
|
+
probe: async () => {
|
|
217
|
+
probeCalled = true;
|
|
218
|
+
return "alive";
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
ok("P2e②: non-ENOENT readdir failure → dirError surfaced", typeof eacces.dirError === "string");
|
|
222
|
+
ok("P2e②: EACCES message preserved in dirError", eacces.dirError?.includes("EACCES") === true);
|
|
223
|
+
ok("P2e②: citizen still reported (socket axis lost, not blinded)", eacces.probes.length === 1);
|
|
224
|
+
ok("P2e②/P1: untrusted dir → citizen NOT probed (no connect through unverified path)", !probeCalled);
|
|
225
|
+
ok(
|
|
226
|
+
"P2e②/P1: untrusted dir → citizen held at indeterminate (not alive, not stranded)",
|
|
227
|
+
eacces.probes[0]?.liveness === "indeterminate",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── determinism: sorted by gardenId ────────────────────────────────────────
|
|
232
|
+
{
|
|
233
|
+
const { probes } = await scanSocketProbes(["20260611T333333-cccccc", "20260611T111111-aaaaaa"], {
|
|
234
|
+
dir: DIR,
|
|
235
|
+
readdir: async () => [],
|
|
236
|
+
probe: async () => "dead",
|
|
237
|
+
});
|
|
238
|
+
ok("determinism: probes sorted by gardenId", probes[0]?.gardenId === "20260611T111111-aaaaaa");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── canonical socket path shape ────────────────────────────────────────────
|
|
242
|
+
ok("controlSocketPath = <dir>/<gid>.sock", controlSocketPath(GID_LIVE, DIR) === `${DIR}/${GID_LIVE}.sock`);
|
|
243
|
+
|
|
244
|
+
// ── end-to-end: scanSocketProbes → resolveFactList (dormant resumable) ──────
|
|
245
|
+
{
|
|
246
|
+
const identity = (gid: string, backend: MetaBackendV2): MetaIdentity => ({
|
|
247
|
+
schemaVersion: 2,
|
|
248
|
+
gardenId: gid,
|
|
249
|
+
backend,
|
|
250
|
+
nativeSessionId: "n",
|
|
251
|
+
cwd: "/x",
|
|
252
|
+
model: null,
|
|
253
|
+
transcriptPath: null,
|
|
254
|
+
parentGardenId: null,
|
|
255
|
+
isEntwurf: false,
|
|
256
|
+
createdAt: "2026-06-11T00:00:00.000Z",
|
|
257
|
+
recordUpdatedAt: "2026-06-11T00:00:00.000Z",
|
|
258
|
+
});
|
|
259
|
+
const citizens = [identity(GID_LIVE, "pi"), identity(GID_DORMANT, "pi")];
|
|
260
|
+
const { probes } = await scanSocketProbes([GID_LIVE, GID_DORMANT], {
|
|
261
|
+
dir: DIR,
|
|
262
|
+
readdir: fakeReaddir([`${GID_LIVE}.sock`]),
|
|
263
|
+
probe: fakeProbe(PROBE_MAP),
|
|
264
|
+
});
|
|
265
|
+
const out = resolveFactList(citizens, probes);
|
|
266
|
+
ok("e2e: 2 pi citizens → 2 PeerFacts (no throw — all probed)", out.peers.length === 2);
|
|
267
|
+
ok("e2e: live citizen alive", out.peers.find((p) => p.gardenId === GID_LIVE)?.liveness === "alive");
|
|
268
|
+
ok(
|
|
269
|
+
"e2e: dormant citizen dead (resumable, not stranded)",
|
|
270
|
+
out.peers.find((p) => p.gardenId === GID_DORMANT)?.liveness === "dead",
|
|
271
|
+
);
|
|
272
|
+
ok("e2e: dir held only LIVE socket → no record-less SocketOnlyFact", out.socketOnly.length === 0);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── inspectTargetControlSocket (?2 — lstat-then-connect, v2 decider helper) ──
|
|
276
|
+
// The single-target, lock-time inspection. lstat is injected so every branch is
|
|
277
|
+
// driven without a real fs; every variant carries the canonical socketPath.
|
|
278
|
+
{
|
|
279
|
+
const DIR2 = "/fake/ctl";
|
|
280
|
+
const expectedPath = controlSocketPath(GID_LIVE, DIR2);
|
|
281
|
+
const stat = (over: Partial<Record<"sym" | "sock", boolean>>): LstatLike => ({
|
|
282
|
+
isSymbolicLink: () => over.sym === true,
|
|
283
|
+
isSocket: () => over.sock === true,
|
|
284
|
+
});
|
|
285
|
+
const lstatThrowing = (code: string) => async (): Promise<LstatLike> => {
|
|
286
|
+
const e = new Error(`${code}: lstat`) as NodeJS.ErrnoException;
|
|
287
|
+
e.code = code;
|
|
288
|
+
throw e;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// ENOENT → absent (the citizen is dormant; this is the path a resume creates).
|
|
292
|
+
const absent = await inspectTargetControlSocket(GID_LIVE, DIR2, lstatThrowing("ENOENT"));
|
|
293
|
+
ok("inspect: ENOENT → absent", absent.kind === "absent");
|
|
294
|
+
ok("inspect: absent carries socketPath", absent.socketPath === expectedPath);
|
|
295
|
+
|
|
296
|
+
// symlink → address-conflict (NEVER connected — P1), reason symlink.
|
|
297
|
+
const sym = await inspectTargetControlSocket(GID_LIVE, DIR2, async () => stat({ sym: true }));
|
|
298
|
+
ok("inspect: symlink → address-conflict", sym.kind === "address-conflict");
|
|
299
|
+
ok("inspect: symlink reason", sym.kind === "address-conflict" && sym.reason === "symlink");
|
|
300
|
+
|
|
301
|
+
// a real socket file → socket-file (safe to connect now).
|
|
302
|
+
const sock = await inspectTargetControlSocket(GID_LIVE, DIR2, async () => stat({ sock: true }));
|
|
303
|
+
ok("inspect: socket file → socket-file", sock.kind === "socket-file");
|
|
304
|
+
ok("inspect: socket-file carries socketPath", sock.socketPath === expectedPath);
|
|
305
|
+
|
|
306
|
+
// a non-socket regular file/dir at the canonical path → address-conflict.
|
|
307
|
+
const notSock = await inspectTargetControlSocket(GID_LIVE, DIR2, async () => stat({}));
|
|
308
|
+
ok("inspect: non-socket → address-conflict", notSock.kind === "address-conflict");
|
|
309
|
+
ok("inspect: non-socket reason", notSock.kind === "address-conflict" && notSock.reason === "not-socket");
|
|
310
|
+
|
|
311
|
+
// EACCES / unknown lstat error → indeterminate (never connect, never spawn).
|
|
312
|
+
const indet = await inspectTargetControlSocket(GID_LIVE, DIR2, lstatThrowing("EACCES"));
|
|
313
|
+
ok("inspect: EACCES → indeterminate", indet.kind === "indeterminate");
|
|
314
|
+
ok("inspect: indeterminate carries error code", indet.kind === "indeterminate" && indet.error === "EACCES");
|
|
315
|
+
// symlink is decided BEFORE socket — a symlink that also reports isSocket is still a conflict.
|
|
316
|
+
const symSock = await inspectTargetControlSocket(GID_LIVE, DIR2, async () => stat({ sym: true, sock: true }));
|
|
317
|
+
ok("inspect: symlink wins over isSocket (never connect a symlink)", symSock.kind === "address-conflict");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── inspectControlSocketPath (5c-3c R1 — the path-addressed SSOT) ────────────────
|
|
321
|
+
// The 5c-3 spawn-bg watcher observes plan.expectedSocketPath and MUST inspect THAT exact
|
|
322
|
+
// path (no gid re-derivation). Proves the path-taking core classifies every branch
|
|
323
|
+
// identically to the gid wrapper, and that the wrapper just feeds it controlSocketPath(gid).
|
|
324
|
+
{
|
|
325
|
+
const EXACT = "/fake/ctl/some-exact.sock";
|
|
326
|
+
const WRAP_DIR = "/fake/ctl";
|
|
327
|
+
const stat = (over: Partial<Record<"sym" | "sock", boolean>>): LstatLike => ({
|
|
328
|
+
isSymbolicLink: () => over.sym === true,
|
|
329
|
+
isSocket: () => over.sock === true,
|
|
330
|
+
});
|
|
331
|
+
const lstatThrowing = (code: string) => async (): Promise<LstatLike> => {
|
|
332
|
+
const e = new Error(`${code}: lstat`) as NodeJS.ErrnoException;
|
|
333
|
+
e.code = code;
|
|
334
|
+
throw e;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
// Inspects the EXACT path handed in (carried verbatim on every variant).
|
|
338
|
+
const absent = await inspectControlSocketPath(EXACT, lstatThrowing("ENOENT"));
|
|
339
|
+
ok("path-inspect: ENOENT → absent", absent.kind === "absent");
|
|
340
|
+
ok("path-inspect: absent carries the EXACT path (no re-derivation)", absent.socketPath === EXACT);
|
|
341
|
+
|
|
342
|
+
const sym = await inspectControlSocketPath(EXACT, async () => stat({ sym: true }));
|
|
343
|
+
ok("path-inspect: symlink → address-conflict (never connected, P1)", sym.kind === "address-conflict");
|
|
344
|
+
ok("path-inspect: symlink reason", sym.kind === "address-conflict" && sym.reason === "symlink");
|
|
345
|
+
|
|
346
|
+
const sock = await inspectControlSocketPath(EXACT, async () => stat({ sock: true }));
|
|
347
|
+
ok("path-inspect: socket file → socket-file", sock.kind === "socket-file");
|
|
348
|
+
ok("path-inspect: socket-file carries the EXACT path", sock.socketPath === EXACT);
|
|
349
|
+
|
|
350
|
+
const notSock = await inspectControlSocketPath(EXACT, async () => stat({}));
|
|
351
|
+
ok("path-inspect: non-socket → address-conflict", notSock.kind === "address-conflict");
|
|
352
|
+
ok("path-inspect: non-socket reason", notSock.kind === "address-conflict" && notSock.reason === "not-socket");
|
|
353
|
+
|
|
354
|
+
const indet = await inspectControlSocketPath(EXACT, lstatThrowing("EACCES"));
|
|
355
|
+
ok("path-inspect: EACCES → indeterminate", indet.kind === "indeterminate");
|
|
356
|
+
ok("path-inspect: indeterminate carries error code", indet.kind === "indeterminate" && indet.error === "EACCES");
|
|
357
|
+
|
|
358
|
+
// The gid wrapper feeds the path-core controlSocketPath(gid, dir) verbatim — same
|
|
359
|
+
// classification, just a derived path. Proves the thin-wrapper equivalence.
|
|
360
|
+
const wrapped = await inspectTargetControlSocket(GID_LIVE, WRAP_DIR, async () => stat({ sock: true }));
|
|
361
|
+
const direct = await inspectControlSocketPath(controlSocketPath(GID_LIVE, WRAP_DIR), async () =>
|
|
362
|
+
stat({ sock: true }),
|
|
363
|
+
);
|
|
364
|
+
ok(
|
|
365
|
+
"path-inspect: gid wrapper === path-core over controlSocketPath(gid)",
|
|
366
|
+
wrapped.socketPath === direct.socketPath && wrapped.kind === direct.kind,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// mapInspectionToLiveness — the shared inspection→liveness mapper (SSOT for the 5b
|
|
371
|
+
// decider AND the 5c-2b dead-control-send resolver). Proves the 4-way mapping that
|
|
372
|
+
// both callers depend on, so a drift between them is impossible: absent→dead,
|
|
373
|
+
// socket-file→(probe result), indeterminate→indeterminate, address-conflict→conflict.
|
|
374
|
+
{
|
|
375
|
+
const SP = "/fake/ctl/m.sock";
|
|
376
|
+
const absent = await mapInspectionToLiveness({ kind: "absent", socketPath: SP }, async () => "alive");
|
|
377
|
+
ok(
|
|
378
|
+
"map: absent → dead (ENOENT = honest dormant), carries socketPath",
|
|
379
|
+
!("addressConflict" in absent) && absent.liveness === "dead" && absent.socketPath === SP,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
let probed = "";
|
|
383
|
+
const live = await mapInspectionToLiveness({ kind: "socket-file", socketPath: SP }, async (p) => {
|
|
384
|
+
probed = p;
|
|
385
|
+
return "alive";
|
|
386
|
+
});
|
|
387
|
+
ok(
|
|
388
|
+
"map: socket-file → probe result (the ONLY case that connects)",
|
|
389
|
+
!("addressConflict" in live) && live.liveness === "alive" && probed === SP,
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
const stall = await mapInspectionToLiveness({ kind: "socket-file", socketPath: SP }, async () => "indeterminate");
|
|
393
|
+
ok(
|
|
394
|
+
"map: socket-file + probe indeterminate → indeterminate (never folded to dead)",
|
|
395
|
+
!("addressConflict" in stall) && stall.liveness === "indeterminate",
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
let connectedOnIndet = false;
|
|
399
|
+
const indet = await mapInspectionToLiveness(
|
|
400
|
+
{ kind: "indeterminate", socketPath: SP, error: "EACCES" },
|
|
401
|
+
async () => {
|
|
402
|
+
connectedOnIndet = true;
|
|
403
|
+
return "alive";
|
|
404
|
+
},
|
|
405
|
+
);
|
|
406
|
+
ok(
|
|
407
|
+
"map: inspect indeterminate → indeterminate, NEVER connects",
|
|
408
|
+
!("addressConflict" in indet) && indet.liveness === "indeterminate" && connectedOnIndet === false,
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
let connectedOnConflict = false;
|
|
412
|
+
const conflict = await mapInspectionToLiveness(
|
|
413
|
+
{ kind: "address-conflict", socketPath: SP, reason: "symlink" },
|
|
414
|
+
async () => {
|
|
415
|
+
connectedOnConflict = true;
|
|
416
|
+
return "alive";
|
|
417
|
+
},
|
|
418
|
+
);
|
|
419
|
+
ok(
|
|
420
|
+
"map: address-conflict → {addressConflict:true}, NEVER connects",
|
|
421
|
+
"addressConflict" in conflict && connectedOnConflict === false,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
console.log(`\n[check-socket-discovery] ${passed} assertions ok`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
await main();
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-socket-probe — deterministic gate for 0.11 Stage 0 (F3 fix): the
|
|
3
|
+
* three-valued control-socket liveness probe and its GC/listing policies.
|
|
4
|
+
*
|
|
5
|
+
* Proves:
|
|
6
|
+
* - classifyConnectError is a pure boundary: ECONNREFUSED/ENOENT → dead;
|
|
7
|
+
* timeout/EACCES/unknown/undefined → indeterminate (default = don't destroy),
|
|
8
|
+
* - shouldUnlinkOnGc reclaims ONLY dead (the F3 invariant: indeterminate and
|
|
9
|
+
* alive both survive the sweep),
|
|
10
|
+
* - shouldListAsLive lists ONLY alive (indeterminate hidden but not unlinked),
|
|
11
|
+
* - probeSocketLiveness end-to-end on the two REPRODUCIBLE cases:
|
|
12
|
+
* (a) a real listening socket → "alive" → survives GC,
|
|
13
|
+
* (b) a nonexistent path → "dead" → eligible for GC.
|
|
14
|
+
*
|
|
15
|
+
* Why no timeout fixture: a connect that neither connects nor errors cannot be
|
|
16
|
+
* forged deterministically at the wire level (a dead unix socket gives
|
|
17
|
+
* ECONNREFUSED; Node's listener auto-accepts). The timeout→indeterminate edge
|
|
18
|
+
* is held by the pure classifier (ETIMEDOUT) + the probe's timer branch, not by
|
|
19
|
+
* a flaky wire test. (Per GLG: don't burn time on a wire timeout fixture.)
|
|
20
|
+
*
|
|
21
|
+
* Pure + two-socket integration; no backend, no API.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import assert from "node:assert/strict";
|
|
25
|
+
import { promises as fs } from "node:fs";
|
|
26
|
+
import * as net from "node:net";
|
|
27
|
+
import * as os from "node:os";
|
|
28
|
+
import * as path from "node:path";
|
|
29
|
+
import {
|
|
30
|
+
classifyConnectError,
|
|
31
|
+
probeSocketLiveness,
|
|
32
|
+
type SocketLiveness,
|
|
33
|
+
shouldListAsLive,
|
|
34
|
+
shouldUnlinkOnGc,
|
|
35
|
+
} from "../pi-extensions/lib/socket-probe.ts";
|
|
36
|
+
|
|
37
|
+
let passed = 0;
|
|
38
|
+
function ok(label: string, cond: boolean): void {
|
|
39
|
+
assert.ok(cond, label);
|
|
40
|
+
console.log(` ok ${label}`);
|
|
41
|
+
passed++;
|
|
42
|
+
}
|
|
43
|
+
function eq(label: string, actual: unknown, expected: unknown): void {
|
|
44
|
+
assert.strictEqual(actual, expected, label);
|
|
45
|
+
console.log(` ok ${label}`);
|
|
46
|
+
passed++;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Pure classifier: only the two positive-absence codes are dead ──────────
|
|
50
|
+
eq("classify ECONNREFUSED → dead", classifyConnectError("ECONNREFUSED"), "dead");
|
|
51
|
+
eq("classify ENOENT → dead", classifyConnectError("ENOENT"), "dead");
|
|
52
|
+
eq("classify ETIMEDOUT → indeterminate", classifyConnectError("ETIMEDOUT"), "indeterminate");
|
|
53
|
+
eq("classify EACCES → indeterminate (unknown ≠ dead)", classifyConnectError("EACCES"), "indeterminate");
|
|
54
|
+
eq("classify EPIPE → indeterminate", classifyConnectError("EPIPE"), "indeterminate");
|
|
55
|
+
eq("classify undefined code → indeterminate", classifyConnectError(undefined), "indeterminate");
|
|
56
|
+
|
|
57
|
+
// ── GC policy: reclaim ONLY dead — the F3 invariant ────────────────────────
|
|
58
|
+
eq("GC unlinks dead", shouldUnlinkOnGc("dead"), true);
|
|
59
|
+
eq("GC keeps indeterminate (F3: load-stalled live socket survives)", shouldUnlinkOnGc("indeterminate"), false);
|
|
60
|
+
eq("GC keeps alive", shouldUnlinkOnGc("alive"), false);
|
|
61
|
+
|
|
62
|
+
// ── Listing policy: list ONLY alive; GC scope ⊂ listing-hidden scope ───────
|
|
63
|
+
eq("list alive", shouldListAsLive("alive"), true);
|
|
64
|
+
eq("list hides dead", shouldListAsLive("dead"), false);
|
|
65
|
+
eq("list hides indeterminate", shouldListAsLive("indeterminate"), false);
|
|
66
|
+
// Structural invariant: nothing that GC reclaims is ever listed as live, and
|
|
67
|
+
// the set GC reclaims (dead) is strictly narrower than the set hidden from the
|
|
68
|
+
// listing (dead + indeterminate) — so a hidden socket is never auto-destroyed.
|
|
69
|
+
for (const liveness of ["alive", "dead", "indeterminate"] as SocketLiveness[]) {
|
|
70
|
+
if (shouldUnlinkOnGc(liveness)) {
|
|
71
|
+
ok(`invariant: GC-reclaimed (${liveness}) is not listed live`, !shouldListAsLive(liveness));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Integration: two reproducible cases on real paths ──────────────────────
|
|
76
|
+
async function integration(): Promise<void> {
|
|
77
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "socket-probe-"));
|
|
78
|
+
const liveSock = path.join(dir, "live.sock");
|
|
79
|
+
const goneSock = path.join(dir, "absent.sock");
|
|
80
|
+
|
|
81
|
+
// (a) real listener → alive → survives GC
|
|
82
|
+
const server = net.createServer();
|
|
83
|
+
await new Promise<void>((resolve, reject) => {
|
|
84
|
+
server.once("error", reject);
|
|
85
|
+
server.listen(liveSock, resolve);
|
|
86
|
+
});
|
|
87
|
+
try {
|
|
88
|
+
const live = await probeSocketLiveness(liveSock);
|
|
89
|
+
eq("integration: listening socket → alive", live, "alive");
|
|
90
|
+
eq("integration: alive socket survives GC", shouldUnlinkOnGc(live), false);
|
|
91
|
+
ok("integration: alive socket is listed", shouldListAsLive(live));
|
|
92
|
+
} finally {
|
|
93
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// (b) nonexistent path → dead → eligible for GC
|
|
97
|
+
const dead = await probeSocketLiveness(goneSock);
|
|
98
|
+
eq("integration: nonexistent path → dead", dead, "dead");
|
|
99
|
+
eq("integration: dead path is GC-eligible", shouldUnlinkOnGc(dead), true);
|
|
100
|
+
|
|
101
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await integration();
|
|
105
|
+
|
|
106
|
+
console.log(`\ncheck-socket-probe: ${passed} assertions passed`);
|