@junghanacs/entwurf 0.35.0 → 0.36.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 +2 -2
- package/CHANGELOG.md +338 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +15 -0
- package/FAQ.md +3 -3
- package/README.md +30 -16
- package/VERIFY.md +14 -11
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +29 -3
- package/docs/setup-clean-host.md +31 -23
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +46 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/model-ids.js +67 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +9 -20
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +4 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +11 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +27 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +2 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +99 -18
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +3 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +13 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +178 -25
- package/mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js +44 -0
- package/mcp/entwurf-bridge/tsconfig.build.json +5 -0
- package/package.json +20 -15
- package/pi/pi-durable/bootstrap.mjs +12 -8
- package/pi/pi-durable/carrier/runtime.js +52 -2
- package/pi/pi-durable/carrier/subagent.js +6 -1
- package/pi/pi-durable/carrier/tui.js +31 -4
- package/pi/pi-durable/carrier-relocation.mjs +2 -2
- package/pi/pi-durable/overlay/upstream-pin.json +79 -36
- package/pi-extensions/lib/acp/backend-adapter.ts +52 -2
- package/pi-extensions/lib/acp/model-ids.js +77 -0
- package/pi-extensions/lib/acp/models.ts +15 -22
- package/pi-extensions/lib/codex-fresh-preflight.ts +4 -3
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +12 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +25 -1
- package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
- package/pi-extensions/lib/mux-fresh-call.ts +3 -0
- package/pi-extensions/lib/native-push/codex-ws-client.ts +129 -18
- package/pi-extensions/lib/pi-durable-runtime.ts +4 -4
- package/pi-extensions/lib/resume-launch-identity.ts +12 -4
- package/pi-extensions/meta-bridge-pi-durable.ts +230 -34
- package/run.sh +193 -58
- package/scripts/check-acp-backend-preflight.ts +10 -2
- package/scripts/check-acp-cortex.ts +7 -6
- package/scripts/check-acp-prompt-lifecycle.ts +2 -2
- package/scripts/check-acp-provider-surface.ts +1 -1
- package/scripts/check-acp-session-reuse.ts +5 -5
- package/scripts/check-acp-stop-reason.ts +2 -2
- package/scripts/check-acp-stream-hooks.ts +2 -2
- package/scripts/check-acp-usage-accounting.ts +2 -2
- package/scripts/check-codex-app-server-launch.ts +55 -12
- package/scripts/check-codex-native-push.ts +231 -12
- package/scripts/check-fresh-call-dispatch.ts +47 -4
- package/scripts/check-gate-qualification.ts +8 -6
- package/scripts/check-probe-ordering.ts +13 -6
- package/scripts/check-resume-launch-identity.ts +38 -0
- package/scripts/codex-app-server-launch.sh +54 -29
- package/scripts/codex-socket-classify.ts +46 -0
- package/scripts/emit-pi-durable-carrier.ts +159 -168
- package/scripts/mutants/acp-cortex.json +17 -8
- package/scripts/mutants/acp-set-model-readback.json +28 -0
- package/scripts/mutants/codex-app-server-launch.json +39 -12
- package/scripts/mutants/codex-native.json +15 -0
- package/scripts/mutants/control-send-receipt.json +1 -1
- package/scripts/mutants/fresh-call-dispatch.json +11 -0
- package/scripts/mutants/herdr-dev-checkout.json +70 -0
- package/scripts/mutants/pack-install.json +3 -3
- package/scripts/mutants/pi-durable.json +282 -16
- package/scripts/mutants/pi-floor.json +5 -5
- package/scripts/mutants/resume-launch-identity.json +15 -0
- package/scripts/mutants/v2-visible-resume.json +11 -0
- package/scripts/pi-durable-contact-host.mjs +1 -1
- package/scripts/resolve-acp-bridge.ts +1 -1
- package/scripts/smoke-acp-bundled-mcp-live.ts +1 -1
- package/scripts/smoke-acp-carrier-augment-live.ts +1 -1
- package/scripts/smoke-acp-long-turn-live.ts +1 -1
- package/scripts/smoke-acp-mcp-live.ts +1 -1
- package/scripts/smoke-acp-memory-containment-live.ts +1 -1
- package/scripts/smoke-acp-ordering-probe-live.ts +1 -1
- package/scripts/smoke-acp-overlay-live.ts +1 -1
- package/scripts/smoke-acp-provider-live.ts +1 -1
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-session-reuse-live.ts +1 -1
- package/scripts/smoke-acp-skill-live.ts +1 -1
- package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
- package/scripts/smoke-acp-v2-send-live.ts +1 -1
- package/scripts/smoke-entwurf-chain-live.ts +1 -1
- package/scripts/smoke-mux-lifecycle-live.ts +1 -1
- package/pi/pi-durable/overlay/runtime-contacts.patch +0 -283
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// ACP plugin — the curated model IDs, and nothing else (#128).
|
|
2
|
+
//
|
|
3
|
+
// This leaf imports NOTHING. `models.ts` builds the registry rows from the pi-ai
|
|
4
|
+
// catalog; the launch seams (`fresh-call-dispatch.ts`, `resume-launch-identity.ts`)
|
|
5
|
+
// only need to know which IDs exist, and they ride the pi-free bridge closure, so
|
|
6
|
+
// they read the IDs here rather than load a vendor catalog to learn them.
|
|
7
|
+
//
|
|
8
|
+
// WHY `.js`: the same reason as `protocol.js`. Both the emit-capable root program
|
|
9
|
+
// (allowJs) and Node --experimental-strip-types resolve an explicit `.js` import
|
|
10
|
+
// literally to this file, so `models.ts` (root fence, `.js` specifiers) and the
|
|
11
|
+
// strip-types gates that load `models.ts` directly reach one SSOT. A `.ts` leaf
|
|
12
|
+
// would need a `.ts` specifier the root config cannot allow.
|
|
13
|
+
//
|
|
14
|
+
// WHY THE LAUNCH SEAMS ASK AT ALL. Pi's `--model` resolver falls back to a
|
|
15
|
+
// case-insensitive substring match among the provider's rows and prefers the
|
|
16
|
+
// lexically later id. Under one `entwurf` provider a retired `claude-sonnet-5`
|
|
17
|
+
// is a substring of `cortex-claude-sonnet-5`, so a launch Entwurf builds would
|
|
18
|
+
// silently open on the Snowflake Cortex rail instead of the Claude subscription.
|
|
19
|
+
// An exact match against this list is the only admission; nothing is aliased,
|
|
20
|
+
// substituted or rewritten.
|
|
21
|
+
|
|
22
|
+
/** The single ACP provider id under which every curated row is registered. */
|
|
23
|
+
export const PROVIDER_ID = "entwurf";
|
|
24
|
+
|
|
25
|
+
// The curated Claude ids. Adding one here is a commitment to verify it across
|
|
26
|
+
// both axes (protocol smoke + agent interview) — do not extend casually.
|
|
27
|
+
export const SUPPORTED_ANTHROPIC_MODEL_IDS = /** @type {const} */ ([
|
|
28
|
+
"claude-sonnet-5-5",
|
|
29
|
+
"claude-opus-5-5",
|
|
30
|
+
"claude-fable-5-1",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/** The anchor model whose absence is a hard registry regression, not a soft skip. */
|
|
34
|
+
export const CURATED_ANCHOR_MODEL_ID = "claude-opus-5-5";
|
|
35
|
+
|
|
36
|
+
// Cortex ids carry the reserved `cortex-` prefix; the rest of each id is the
|
|
37
|
+
// Snowflake-native model name, which is why Cortex still says `claude-opus-5`.
|
|
38
|
+
export const CORTEX_MODEL_PREFIX = "cortex-";
|
|
39
|
+
export const SUPPORTED_CORTEX_MODEL_IDS = /** @type {const} */ ([
|
|
40
|
+
"cortex-auto",
|
|
41
|
+
"cortex-claude-opus-5",
|
|
42
|
+
"cortex-claude-sonnet-5",
|
|
43
|
+
"cortex-openai-gpt-5.4",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** @type {ReadonlySet<string>} */
|
|
47
|
+
const CURATED_ACP_MODEL_IDS = new Set([...SUPPORTED_ANTHROPIC_MODEL_IDS, ...SUPPORTED_CORTEX_MODEL_IDS]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Exact membership only — no trim, no case folding, no prefix.
|
|
51
|
+
* @param {string} model
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
export function isCuratedAcpModelId(model) {
|
|
55
|
+
return CURATED_ACP_MODEL_IDS.has(model);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The named refusal both launch seams answer with. */
|
|
59
|
+
export const ACP_MODEL_NOT_CURATED = /** @type {const} */ ("acp-model-not-curated");
|
|
60
|
+
|
|
61
|
+
export const ACP_MODEL_NOT_CURATED_HINT =
|
|
62
|
+
`an ${PROVIDER_ID} model must be one exact curated ACP id (${[...CURATED_ACP_MODEL_IDS].join(", ")}); ` +
|
|
63
|
+
"Pi would otherwise pick another row by substring — a retired claude-sonnet-5 lands on cortex-claude-sonnet-5 — " +
|
|
64
|
+
"so nothing was opened, and nothing is aliased or rewritten";
|
|
65
|
+
|
|
66
|
+
/** Carries `reason` so callers match a FIELD, never the wording. */
|
|
67
|
+
export class AcpModelNotCuratedError extends Error {
|
|
68
|
+
/** @param {string} model */
|
|
69
|
+
constructor(model) {
|
|
70
|
+
super(`${ACP_MODEL_NOT_CURATED}: ${JSON.stringify(model)} is not an exact curated ${PROVIDER_ID} model id`);
|
|
71
|
+
this.name = "AcpModelNotCuratedError";
|
|
72
|
+
/** @readonly */
|
|
73
|
+
this.reason = ACP_MODEL_NOT_CURATED;
|
|
74
|
+
/** @readonly */
|
|
75
|
+
this.model = model;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -33,9 +33,18 @@
|
|
|
33
33
|
// re-export of `getBuiltinModels`. When pi removes compat we migrate to whatever
|
|
34
34
|
// the loader then exposes.
|
|
35
35
|
import { getModels } from "@earendil-works/pi-ai/compat";
|
|
36
|
+
import { CURATED_ANCHOR_MODEL_ID, SUPPORTED_ANTHROPIC_MODEL_IDS } from "./model-ids.js";
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
// The provider id and the curated ID lists live in the dependency-free
|
|
39
|
+
// `model-ids.js` leaf, shared with the launch seams (#128); this file is the only
|
|
40
|
+
// one that loads the upstream catalog to turn them into registry rows.
|
|
41
|
+
export {
|
|
42
|
+
CORTEX_MODEL_PREFIX,
|
|
43
|
+
CURATED_ANCHOR_MODEL_ID,
|
|
44
|
+
PROVIDER_ID,
|
|
45
|
+
SUPPORTED_ANTHROPIC_MODEL_IDS,
|
|
46
|
+
SUPPORTED_CORTEX_MODEL_IDS,
|
|
47
|
+
} from "./model-ids.js";
|
|
39
48
|
|
|
40
49
|
// #26 auth-boundary sentinel. `pi.registerProvider` requires an apiKey when a
|
|
41
50
|
// provider defines custom models, but the ACP plugin consumes NO key: backend
|
|
@@ -49,17 +58,8 @@ export const PROVIDER_ID = "entwurf";
|
|
|
49
58
|
// check-auth-boundary gate pins this.
|
|
50
59
|
export const ENTWURF_ACP_NO_AUTH_SENTINEL = "entwurf-no-auth";
|
|
51
60
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
// Exported so the claude backend adapter (backend-adapter.ts) can answer
|
|
55
|
-
// `routeModel` without re-deriving the set from curatedClaudeModels().
|
|
56
|
-
export const SUPPORTED_ANTHROPIC_MODEL_IDS = ["claude-sonnet-5", "claude-opus-5", "claude-fable-5-1"] as const;
|
|
57
|
-
|
|
58
|
-
/** The anchor model whose absence is a hard registry regression, not a soft skip. */
|
|
59
|
-
export const CURATED_ANCHOR_MODEL_ID = "claude-opus-5";
|
|
60
|
-
|
|
61
|
-
// Anthropic's registry reports 1M for Sonnet 5, Opus 5 and Fable 5.1, and the
|
|
62
|
-
// entwurf surface exposes the full 1M for all three. Sonnet 5's 1M window is the
|
|
61
|
+
// Anthropic's registry reports 1M for Sonnet 5.5, Opus 5.5 and Fable 5.1, and the
|
|
62
|
+
// entwurf surface exposes the full 1M for all three. Sonnet 5's 1M window was the
|
|
63
63
|
// whole point of the 0.12.3 bump — it is the compact-free long-context floor the
|
|
64
64
|
// earlier 200K Sonnet cap could not provide. We still clamp to a 1M ceiling so a
|
|
65
65
|
// future registry value can't silently inflate the surface past what we verify.
|
|
@@ -92,7 +92,7 @@ function claudeContextWindow(model: { id: string; contextWindow: number }): numb
|
|
|
92
92
|
// carries the reserved `cortex-` prefix: the prefix is the SINGLE routing
|
|
93
93
|
// authority (backend-adapter.ts `routeModel`) and keeps the ids off the Claude
|
|
94
94
|
// native ids Cortex routes to (`cortex-claude-sonnet-5` vs the unprefixed
|
|
95
|
-
// `claude-sonnet-5` the claude adapter owns). `routeModel` strips the prefix to
|
|
95
|
+
// `claude-sonnet-5-5` the claude adapter owns). `routeModel` strips the prefix to
|
|
96
96
|
// recover the native id, which is enforced per-turn via
|
|
97
97
|
// session/set_config_option — never a launch `-m` pin (CP0-M).
|
|
98
98
|
//
|
|
@@ -101,14 +101,7 @@ function claudeContextWindow(model: { id: string; contextWindow: number }): numb
|
|
|
101
101
|
// the running cortex no longer serves fails LOUD at set-model, before the
|
|
102
102
|
// prompt (measured: `Unsupported model: …` names the live set). Adding an id
|
|
103
103
|
// here is the same verify-both-axes commitment as the Claude set — do not
|
|
104
|
-
// extend casually.
|
|
105
|
-
export const CORTEX_MODEL_PREFIX = "cortex-";
|
|
106
|
-
export const SUPPORTED_CORTEX_MODEL_IDS = [
|
|
107
|
-
"cortex-auto",
|
|
108
|
-
"cortex-claude-opus-5",
|
|
109
|
-
"cortex-claude-sonnet-5",
|
|
110
|
-
"cortex-openai-gpt-5.4",
|
|
111
|
-
] as const;
|
|
104
|
+
// extend casually. The prefix and the four ids are declared in model-ids.js.
|
|
112
105
|
|
|
113
106
|
// Hand-set conservative context window. Cortex reports the LIVE window via its
|
|
114
107
|
// own ACP session config, not this curated metadata — this is a floor for the
|
|
@@ -624,11 +624,12 @@ export async function codexFreshPreflight(
|
|
|
624
624
|
if (config === null || trustReceiptMissing(config, key)) return "codex-birth-trust-missing";
|
|
625
625
|
if (mcpMissing(config)) return "codex-mcp-hand-missing";
|
|
626
626
|
if (visibleIdentityMissing(config)) return "codex-visible-identity-missing";
|
|
627
|
-
const
|
|
628
|
-
const socket = (deps.checkSocket ?? checkCodexSocketFile)(socketPath);
|
|
627
|
+
const socket = (deps.checkSocket ?? checkCodexSocketFile)(resolveCodexDefaultSocketPath(env));
|
|
629
628
|
if (!socket.ok) return "codex-app-server-unavailable";
|
|
629
|
+
// The protocol exchange goes to the endpoint the check verified, never the advertised path
|
|
630
|
+
// again (on Codex 0.162.1 that path is an alias; see `checkCodexSocketFile`).
|
|
630
631
|
const live = await appServerIsLive(
|
|
631
|
-
|
|
632
|
+
socket.connectPath,
|
|
632
633
|
deps.openProtocol ?? realCodexProtocolOpener,
|
|
633
634
|
deps.appServerTimeoutMs ?? DEFAULT_APP_SERVER_TIMEOUT_MS,
|
|
634
635
|
);
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
* outcome for this module to claim.
|
|
48
48
|
*/
|
|
49
49
|
|
|
50
|
+
import { ACP_MODEL_NOT_CURATED_HINT, AcpModelNotCuratedError } from "./acp/model-ids.js";
|
|
50
51
|
import { buildResumePiArgs } from "./entwurf-resume-args.ts";
|
|
51
52
|
import {
|
|
52
53
|
type AcquireLockResult,
|
|
@@ -93,6 +94,7 @@ export const RESUME_OBSERVE_INTERVAL_MS = 500;
|
|
|
93
94
|
export type VisibleResumeRejectReason =
|
|
94
95
|
| "target-invalid"
|
|
95
96
|
| "target-not-pi"
|
|
97
|
+
| "acp-model-not-curated"
|
|
96
98
|
| "target-locked"
|
|
97
99
|
| "target-live"
|
|
98
100
|
| "target-indeterminate"
|
|
@@ -227,6 +229,15 @@ export async function visibleResume(targetGardenId: string, deps: VisibleResumeD
|
|
|
227
229
|
try {
|
|
228
230
|
identity = deps.resolveIdentity(targetGardenId);
|
|
229
231
|
} catch (err) {
|
|
232
|
+
// #128: the recorded model is not an exact curated ACP id, so Pi would pick another row
|
|
233
|
+
// by substring. Same shape as `target-not-pi`: a named refusal matched on its FIELD.
|
|
234
|
+
if (err instanceof AcpModelNotCuratedError) {
|
|
235
|
+
return {
|
|
236
|
+
ok: false,
|
|
237
|
+
reason: err.reason,
|
|
238
|
+
detail: `${targetGardenId} would launch model ${JSON.stringify(err.model)} under the entwurf provider (the resolved launch pair, which may differ from the recorded provider)`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
230
241
|
if (err instanceof ResumeBackendUnsupportedError) {
|
|
231
242
|
return {
|
|
232
243
|
ok: false,
|
|
@@ -321,6 +332,7 @@ export async function observeSameGidSocket(
|
|
|
321
332
|
const REJECT_HINT: Record<VisibleResumeRejectReason, string> = {
|
|
322
333
|
"target-invalid": "no citizen has that id; discover targets with entwurf_peers",
|
|
323
334
|
"target-not-pi": "that citizen's backend has no same-id resume — reach it with entwurf_v2, or open a fresh sibling",
|
|
335
|
+
"acp-model-not-curated": ACP_MODEL_NOT_CURATED_HINT,
|
|
324
336
|
"target-locked": "another resume or dispatch holds this citizen's lock right now",
|
|
325
337
|
"target-live": "that citizen is already running — this verb only reopens dormant ones",
|
|
326
338
|
"target-indeterminate": "the socket probe could not prove the citizen is dormant, so nothing was started",
|
|
@@ -25,8 +25,9 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { spawn as spawnChildProcess } from "node:child_process";
|
|
28
|
+
import { ACP_MODEL_NOT_CURATED, isCuratedAcpModelId, PROVIDER_ID } from "./acp/model-ids.js";
|
|
28
29
|
import { codexCallerFreshPreflight, codexFreshPreflight } from "./codex-fresh-preflight.ts";
|
|
29
|
-
import { mintNonce } from "./fresh-call-composition.ts";
|
|
30
|
+
import { isSafeFreshCallModel, mintNonce } from "./fresh-call-composition.ts";
|
|
30
31
|
import {
|
|
31
32
|
createHerdrRunner,
|
|
32
33
|
type HerdrFreshCallResult,
|
|
@@ -67,6 +68,23 @@ export type DispatchedFreshCall =
|
|
|
67
68
|
| { readonly rail: "tmux"; readonly result: FreshCallResult }
|
|
68
69
|
| { readonly rail: "herdr"; readonly result: HerdrFreshCallResult };
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* #128: a pi sibling asked for under the `entwurf` provider must name an EXACT curated ACP id. Pi
|
|
73
|
+
* resolves `--model` by substring among that provider's rows, so a retired `entwurf/claude-sonnet-5`
|
|
74
|
+
* would open on `cortex-claude-sonnet-5` — another vendor and billing rail — with a receipt that
|
|
75
|
+
* still names the requested id. Asked here, at the one root both rails share, so neither rail can
|
|
76
|
+
* forget it. It reads the model the way the rails will (trimmed), and only a model that already
|
|
77
|
+
* passes their grammar: a malformed one keeps its own `model-invalid` answer from the rail.
|
|
78
|
+
*/
|
|
79
|
+
export function isUncuratedEntwurfPiModel(backend: string, model: string): boolean {
|
|
80
|
+
if (backend !== "pi") return false;
|
|
81
|
+
const trimmed = model.trim();
|
|
82
|
+
if (!isSafeFreshCallModel(trimmed)) return false;
|
|
83
|
+
const slash = trimmed.indexOf("/");
|
|
84
|
+
if (slash < 0 || trimmed.slice(0, slash) !== PROVIDER_ID) return false;
|
|
85
|
+
return !isCuratedAcpModelId(trimmed.slice(slash + 1));
|
|
86
|
+
}
|
|
87
|
+
|
|
70
88
|
/**
|
|
71
89
|
* Open a sibling on whichever rail this process is actually standing in.
|
|
72
90
|
*
|
|
@@ -88,6 +106,12 @@ export async function dispatchFreshCall(
|
|
|
88
106
|
spawn?: SpawnFn,
|
|
89
107
|
nonce: string = mintNonce(),
|
|
90
108
|
): Promise<DispatchedFreshCall> {
|
|
109
|
+
// Before either rail is asked anything: both would open a pane on the wrong row.
|
|
110
|
+
if (isUncuratedEntwurfPiModel(request.backend, request.model)) {
|
|
111
|
+
return selectFreshCallRail(env) === "herdr"
|
|
112
|
+
? { rail: "herdr", result: { ok: false, reason: ACP_MODEL_NOT_CURATED } }
|
|
113
|
+
: { rail: "tmux", result: { ok: false, reason: ACP_MODEL_NOT_CURATED } };
|
|
114
|
+
}
|
|
91
115
|
if (selectFreshCallRail(env) === "herdr") {
|
|
92
116
|
const context = resolveHerdrContext(env);
|
|
93
117
|
if (!context.ok) {
|
|
@@ -119,6 +119,7 @@ export type HerdrFreshCallRejectReason =
|
|
|
119
119
|
// The caller-facing input contract, shared with the tmux rail so a mistyped model or an
|
|
120
120
|
// oversized task is answered with the SAME word on either placement owner.
|
|
121
121
|
| FreshCallInputRejectReason
|
|
122
|
+
| "acp-model-not-curated"
|
|
122
123
|
| "herdr-context-missing"
|
|
123
124
|
| "herdr-caller-pane-missing"
|
|
124
125
|
/** herdr would not tell us where the CALLER is sitting. Three separate names because the
|
|
@@ -1102,6 +1103,10 @@ const HERDR_REJECT_HINT: Record<HerdrFreshCallRejectReason | HerdrLaunchFailureR
|
|
|
1102
1103
|
"this surface has no record-backed identity, so a sibling would have nowhere to call home",
|
|
1103
1104
|
"model-empty": "pass the model you want the sibling opened with — it is never inherited from this process",
|
|
1104
1105
|
"model-invalid": "the model is not in the accepted grammar (no whitespace, no leading dash)",
|
|
1106
|
+
// #128: decided at the dispatch root (`fresh-call-dispatch.ts`), worded here because this rail's
|
|
1107
|
+
// import fence admits no ACP module. The curated list itself lives in `acp/model-ids.js`.
|
|
1108
|
+
"acp-model-not-curated":
|
|
1109
|
+
"an entwurf model must be one exact curated ACP id — Pi would otherwise pick another row by substring (a retired claude-sonnet-5 lands on cortex-claude-sonnet-5), so nothing was opened and nothing was aliased",
|
|
1105
1110
|
"task-empty": "a sibling opened with nothing to do is a window nobody asked for",
|
|
1106
1111
|
"task-too-long": `the task is over the ${TASK_MAX_CHARS}-character interface bound this verb shares with entwurf_v2`,
|
|
1107
1112
|
"herdr-context-missing":
|
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
* there is no "session created" field because nothing here creates one.
|
|
87
87
|
*/
|
|
88
88
|
|
|
89
|
+
import { ACP_MODEL_NOT_CURATED_HINT } from "./acp/model-ids.js";
|
|
89
90
|
import { callbackEnvAssignments } from "./callback-env.ts";
|
|
90
91
|
import { classifyTmuxCwd, type TmuxCwdRejectReason } from "./classify-tmux-cwd.ts";
|
|
91
92
|
import {
|
|
@@ -265,6 +266,7 @@ export type FreshCallRejectReason =
|
|
|
265
266
|
| CodexPreflightRejectReason
|
|
266
267
|
| CodexCallerPreflightRejectReason
|
|
267
268
|
| CodexCallerSeatRejectReason
|
|
269
|
+
| "acp-model-not-curated"
|
|
268
270
|
| "caller-identity-unavailable"
|
|
269
271
|
| "model-empty"
|
|
270
272
|
| "model-invalid"
|
|
@@ -678,6 +680,7 @@ const REJECT_HINT: Record<FreshCallRejectReason, string> = {
|
|
|
678
680
|
"no session with that exact name answers on this agent's tmux server (or that server stopped answering) — nothing was created, so open the session yourself and call again",
|
|
679
681
|
"model-empty": "model is empty after trimming; fresh calls require an explicit model",
|
|
680
682
|
"model-invalid": `model must be one ${MODEL_MAX_CHARS}-character argv-safe id/alias without whitespace or tmux syntax`,
|
|
683
|
+
"acp-model-not-curated": ACP_MODEL_NOT_CURATED_HINT,
|
|
681
684
|
"task-empty": "task is empty after trimming",
|
|
682
685
|
"task-too-long": `task exceeds ${TASK_MAX_CHARS} characters`,
|
|
683
686
|
"runtime-unresolved": "the backend's runtime is not installed on PATH",
|
|
@@ -23,30 +23,135 @@ export function resolveCodexDefaultSocketPath(env: CodexSocketEnv): string {
|
|
|
23
23
|
return path.join(resolveCodexHome(env), "app-server-control", "app-server-control.sock");
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* What the advertised control-socket path is, and the one endpoint a caller may connect to.
|
|
28
|
+
*
|
|
29
|
+
* `connectPath` is a transport endpoint for one exchange: the socket this check verified. It is
|
|
30
|
+
* not an address, a record field or a replacement for `resolveCodexDefaultSocketPath`. `dead`
|
|
31
|
+
* names which absence it is, because the managed launcher treats them differently: an absent
|
|
32
|
+
* path is a first launch, and a dangling rendezvous alias is the leftover of a crashed server
|
|
33
|
+
* that the vendor removes on its next start.
|
|
34
|
+
*/
|
|
35
|
+
export type CodexSocketFileCheck =
|
|
36
|
+
| { ok: true; connectPath: string }
|
|
37
|
+
| { ok: false; status: "dead"; layout: "absent" | "dangling-alias"; reason: string }
|
|
38
|
+
| { ok: false; status: "indeterminate"; reason: string };
|
|
39
|
+
|
|
40
|
+
/** Test seam only: the gate points these at a sandbox. Production callers pass nothing. */
|
|
41
|
+
export interface CodexSocketFileDeps {
|
|
42
|
+
readonly daemonRoot?: string;
|
|
43
|
+
readonly euid?: number;
|
|
44
|
+
}
|
|
27
45
|
|
|
28
|
-
|
|
46
|
+
/**
|
|
47
|
+
* The advertised path is exactly one of two layouts, and anything else is refused.
|
|
48
|
+
*
|
|
49
|
+
* (A) A plain socket owned by the effective uid: Codex up to 0.153.4 binds the advertised
|
|
50
|
+
* path itself. `connectPath` is that path.
|
|
51
|
+
* (B) Codex's rendezvous alias. From 0.162.1 the advertised path "is only an alias": the
|
|
52
|
+
* server binds a 0600 socket under `realpath(/tmp)/codex-daemon-<euid>/` named by the
|
|
53
|
+
* sha256 of the canonical advertised path, then symlinks the advertised path to it
|
|
54
|
+
* (`[source]` rust-v0.162.1 `app-server-transport/src/transport/unix_socket.rs:43-131,
|
|
55
|
+
* 290-304`; `uds/src/daemon_directory.rs:1-38`). Upstream's reason is a privileged
|
|
56
|
+
* rendezvous root that sandboxes hide; the alias also keeps an over-long advertised path
|
|
57
|
+
* usable (`uds/src/lib.rs:167-174`). The alias is accepted only when every hop is the
|
|
58
|
+
* vendor's own: the link is owned by the effective uid; its target is BYTE-EQUAL to the
|
|
59
|
+
* path Codex derives (a relative, foreign, re-hashed or chained target is refused); the
|
|
60
|
+
* daemon directory is a real directory owned by the effective uid with `mode & 0o777`
|
|
61
|
+
* exactly 0700, the vendor's own rule; and the target is a socket owned by the effective
|
|
62
|
+
* uid that no group or other user can open. `connectPath` is the verified target, so the
|
|
63
|
+
* alias is never re-resolved between this check and the connect. A same-euid process can
|
|
64
|
+
* still replace the verified socket file itself; that residual is named, not locked.
|
|
65
|
+
*
|
|
66
|
+
* Only `lstat` and one `readlink` touch the alias; it is never followed or `realpath`ed.
|
|
67
|
+
* The effective uid is the vendor's (`libc::geteuid`), not the real uid.
|
|
68
|
+
*/
|
|
69
|
+
export function checkCodexSocketFile(socketPath: string, deps: CodexSocketFileDeps = {}): CodexSocketFileCheck {
|
|
70
|
+
const euid = deps.euid ?? process.geteuid?.();
|
|
71
|
+
let stat: fs.Stats;
|
|
29
72
|
try {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return { ok: false, status: "indeterminate", reason: `app-server path is not a socket: ${socketPath}` };
|
|
73
|
+
stat = fs.lstatSync(socketPath);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
76
|
+
if (code === "ENOENT") {
|
|
77
|
+
return { ok: false, status: "dead", layout: "absent", reason: `app-server socket is absent: ${socketPath}` };
|
|
36
78
|
}
|
|
37
|
-
|
|
79
|
+
return { ok: false, status: "indeterminate", reason: error instanceof Error ? error.message : String(error) };
|
|
80
|
+
}
|
|
81
|
+
if (stat.isSymbolicLink()) return checkCodexRendezvousAlias(socketPath, stat, euid, deps);
|
|
82
|
+
if (!stat.isSocket()) {
|
|
83
|
+
return { ok: false, status: "indeterminate", reason: `app-server path is not a socket: ${socketPath}` };
|
|
84
|
+
}
|
|
85
|
+
if (euid !== undefined && stat.uid !== euid) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
status: "indeterminate",
|
|
89
|
+
reason: `app-server socket is owned by uid ${stat.uid}, not the effective uid ${euid}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return { ok: true, connectPath: socketPath };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function checkCodexRendezvousAlias(
|
|
96
|
+
socketPath: string,
|
|
97
|
+
link: fs.Stats,
|
|
98
|
+
euid: number | undefined,
|
|
99
|
+
deps: CodexSocketFileDeps,
|
|
100
|
+
): CodexSocketFileCheck {
|
|
101
|
+
const refuse = (reason: string): CodexSocketFileCheck => ({ ok: false, status: "indeterminate", reason });
|
|
102
|
+
if (euid === undefined) {
|
|
103
|
+
return refuse(`app-server socket is a symlink and this platform has no effective uid to verify it: ${socketPath}`);
|
|
104
|
+
}
|
|
105
|
+
if (link.uid !== euid) {
|
|
106
|
+
return refuse(`app-server socket symlink is owned by uid ${link.uid}, not the effective uid ${euid}`);
|
|
107
|
+
}
|
|
108
|
+
let daemonRoot: string;
|
|
109
|
+
let expected: string;
|
|
110
|
+
let target: string;
|
|
111
|
+
try {
|
|
112
|
+
daemonRoot = deps.daemonRoot ?? path.join(fs.realpathSync("/tmp"), `codex-daemon-${euid}`);
|
|
113
|
+
const canonical = path.join(fs.realpathSync(path.dirname(socketPath)), path.basename(socketPath));
|
|
114
|
+
expected = path.join(daemonRoot, crypto.createHash("sha256").update(canonical).digest("hex"));
|
|
115
|
+
target = fs.readlinkSync(socketPath);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return refuse(
|
|
118
|
+
`could not verify the app-server socket symlink ${socketPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (target !== expected) {
|
|
122
|
+
return refuse(`app-server socket is a symlink that is not Codex's rendezvous alias: ${socketPath} -> ${target}`);
|
|
123
|
+
}
|
|
124
|
+
let directory: fs.Stats;
|
|
125
|
+
try {
|
|
126
|
+
directory = fs.lstatSync(daemonRoot);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return refuse(`Codex's socket directory cannot be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
129
|
+
}
|
|
130
|
+
if (!directory.isDirectory() || directory.uid !== euid || (directory.mode & 0o777) !== 0o700) {
|
|
131
|
+
return refuse(
|
|
132
|
+
`Codex's socket directory ${daemonRoot} must be a real directory owned by uid ${euid} with mode 0700`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
let physical: fs.Stats;
|
|
136
|
+
try {
|
|
137
|
+
physical = fs.lstatSync(expected);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
38
140
|
return {
|
|
39
141
|
ok: false,
|
|
40
|
-
status: "
|
|
41
|
-
|
|
142
|
+
status: "dead",
|
|
143
|
+
layout: "dangling-alias",
|
|
144
|
+
reason: `app-server socket is a dangling rendezvous alias: ${socketPath} -> ${expected}`,
|
|
42
145
|
};
|
|
43
146
|
}
|
|
44
|
-
return
|
|
45
|
-
} catch (error) {
|
|
46
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
47
|
-
if (code === "ENOENT") return { ok: false, status: "dead", reason: `app-server socket is absent: ${socketPath}` };
|
|
48
|
-
return { ok: false, status: "indeterminate", reason: error instanceof Error ? error.message : String(error) };
|
|
147
|
+
return refuse(error instanceof Error ? error.message : String(error));
|
|
49
148
|
}
|
|
149
|
+
if (!physical.isSocket() || physical.uid !== euid || (physical.mode & 0o077) !== 0) {
|
|
150
|
+
return refuse(
|
|
151
|
+
`Codex's physical socket ${expected} must be a socket owned by uid ${euid} that no group or other user can open`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return { ok: true, connectPath: expected };
|
|
50
155
|
}
|
|
51
156
|
|
|
52
157
|
export interface CodexRpcProtocol {
|
|
@@ -315,6 +420,7 @@ async function initialize(protocol: CodexRpcProtocol): Promise<void> {
|
|
|
315
420
|
|
|
316
421
|
export interface CodexRoute {
|
|
317
422
|
readonly backend: "codex";
|
|
423
|
+
/** The endpoint `checkCodexSocketFile` verified for this probe, which the send also uses. Not an address. */
|
|
318
424
|
readonly socketPath: string;
|
|
319
425
|
}
|
|
320
426
|
|
|
@@ -359,9 +465,14 @@ export async function probeCodexThread(
|
|
|
359
465
|
nativeSessionId: string,
|
|
360
466
|
deps: CodexAdapterDeps = {},
|
|
361
467
|
): Promise<CodexProbeResult> {
|
|
362
|
-
const
|
|
363
|
-
|
|
468
|
+
const socketCheck = (deps.checkSocket ?? checkCodexSocketFile)(
|
|
469
|
+
resolveCodexDefaultSocketPath(deps.env ?? process.env),
|
|
470
|
+
);
|
|
364
471
|
if (!socketCheck.ok) return { status: socketCheck.status, reason: socketCheck.reason };
|
|
472
|
+
// The probe, and the queue send its route carries, both use the endpoint just verified. The
|
|
473
|
+
// advertised path is not resolved again: on 0.162.1 it is an alias that could be retargeted
|
|
474
|
+
// between the check and a second transport.
|
|
475
|
+
const socketPath = socketCheck.connectPath;
|
|
365
476
|
let protocol: CodexRpcProtocol;
|
|
366
477
|
try {
|
|
367
478
|
protocol = await (deps.openProtocol ?? realCodexProtocolOpener).open(socketPath);
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* under (#129 W, #130).
|
|
4
4
|
*
|
|
5
5
|
* The experimental durable app is source-only upstream: published `@earendil-works/pi-coding-agent`
|
|
6
|
-
* ships no `dist/experimental`. Entwurf therefore ships the app itself as a CARRIER —
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* ships no `dist/experimental`. Entwurf therefore ships the app itself as a CARRIER — its owned
|
|
7
|
+
* source (pi/pi-durable/src, #141), type-stripped and relocated (pi/pi-durable/carrier/, emitted only
|
|
8
|
+
* by scripts/emit-pi-durable-carrier.ts) — and declares the published SDK set it runs
|
|
9
9
|
* against as exact production dependencies. Nothing is provisioned beside the package: there is no
|
|
10
10
|
* runtime directory, no source checkout and no model data to copy (it rides the published pi-ai).
|
|
11
11
|
*
|
|
@@ -47,7 +47,7 @@ export type PiDurableRuntimeRejectReason =
|
|
|
47
47
|
export interface PiDurablePackageLayout {
|
|
48
48
|
readonly root: string;
|
|
49
49
|
readonly overlayDir: string;
|
|
50
|
-
/** The emitted
|
|
50
|
+
/** The app files emitted from the owned source, and their license. */
|
|
51
51
|
readonly carrierDir: string;
|
|
52
52
|
/** The `--import` resolver the launcher runs the bootstrap under. */
|
|
53
53
|
readonly resolver: string;
|
|
@@ -20,12 +20,13 @@
|
|
|
20
20
|
|
|
21
21
|
import { existsSync } from "node:fs";
|
|
22
22
|
import path from "node:path";
|
|
23
|
+
import { AcpModelNotCuratedError, isCuratedAcpModelId, PROVIDER_ID } from "./acp/model-ids.js";
|
|
23
24
|
import { getEntwurfExplicitExtensions, readSessionIdentity } from "./entwurf-core.ts";
|
|
24
25
|
import { readAddressableMetaIdentity } from "./meta-session.ts";
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
|
-
*
|
|
28
|
-
* same-id resume at all.
|
|
28
|
+
* One of the TWO expected refusals on this leaf: the target is a citizen of a backend that has no
|
|
29
|
+
* same-id resume at all. The other is `AcpModelNotCuratedError` (#128, `acp/model-ids.js`).
|
|
29
30
|
*
|
|
30
31
|
* Everything else this function throws is a defect in the record, the transcript or the
|
|
31
32
|
* environment — a stale path, a foreign session file, an unrecorded model, an unresolvable
|
|
@@ -152,11 +153,18 @@ export function resolveResumeLaunchIdentity(gardenId: string): LaunchIdentity {
|
|
|
152
153
|
if (!identity.cwd) {
|
|
153
154
|
throw new Error(`resume-launch-identity: ${gardenId} header has no cwd (the cold-resume authority, #9).`);
|
|
154
155
|
}
|
|
156
|
+
// #128: the pair checked is the one the launch will EMIT, not the recorded provider alone —
|
|
157
|
+
// `getEntwurfExplicitExtensions` also routes a bare Claude model id to `entwurf`. Under that
|
|
158
|
+
// provider Pi resolves `--model` by substring, so a retired `claude-sonnet-5` would reopen on
|
|
159
|
+
// `cortex-claude-sonnet-5`. The record and transcript stay as they are; the resume is refused.
|
|
160
|
+
const provider = explicitExtensions.provider ?? identity.provider;
|
|
161
|
+
const model = explicitExtensions.modelOverride ?? resumeModel;
|
|
162
|
+
if (provider === PROVIDER_ID && !isCuratedAcpModelId(model)) throw new AcpModelNotCuratedError(model);
|
|
155
163
|
return {
|
|
156
164
|
sessionFile,
|
|
157
165
|
cwd: identity.cwd,
|
|
158
166
|
explicitExtensionArgs: explicitExtensions.args,
|
|
159
|
-
provider
|
|
160
|
-
model
|
|
167
|
+
provider,
|
|
168
|
+
model,
|
|
161
169
|
};
|
|
162
170
|
}
|