@evoclock/pi-agentic-driver 1.1.2 → 2.0.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/.agentic-driver/loop-until-done.defaults.json +5 -0
- package/README.md +302 -41
- package/config/merge-policy.v1.example.json +4 -0
- package/config/relay-authority.v1.example.json +7 -0
- package/config/treehouse.v1.json +4 -0
- package/docs/durable-driver-api.md +23 -0
- package/docs/durable-persistence.md +32 -0
- package/docs/merge-policy.md +45 -0
- package/docs/relay-authority.md +9 -0
- package/extensions/aidr.ts +3 -0
- package/extensions/harvest-lessons.ts +8 -0
- package/extensions/herdr-dispatch.ts +32 -3
- package/extensions/loop-until-done.ts +7 -0
- package/extensions/recovery-evidence.ts +3 -3
- package/extensions/treehouse-recycle.ts +47 -0
- package/janus/README.md +2 -4
- package/janus/janus/config.ts +2 -2
- package/janus/janus/providers.ts +2 -4
- package/janus/tests/providers.test.ts +2 -2
- package/{extensions → lib}/task-store-adapter.ts +1 -5
- package/package.json +158 -4
- package/scripts/enforcement/approval_ledger_pi.js +135 -0
- package/scripts/enforcement/attended_authority_guard.js +113 -0
- package/scripts/enforcement/board_claim_bridge_pi.js +10 -10
- package/scripts/enforcement/durable_assignment_pi.js +488 -0
- package/scripts/enforcement/durable_driver_pi.d.ts +51 -0
- package/scripts/enforcement/durable_driver_pi.js +254 -0
- package/scripts/enforcement/herdr_async_dispatch_pi.js +86 -20
- package/scripts/enforcement/herdr_async_seam_pi.js +90 -3
- package/scripts/enforcement/herdr_communication_pi.js +126 -12
- package/scripts/enforcement/herdr_lifecycle_pi.js +730 -81
- package/scripts/enforcement/linux_microvm_cutover_pi.js +3 -3
- package/scripts/enforcement/linux_microvm_remote_fixture.sh +0 -6
- package/scripts/enforcement/loop_until_done_guide.md +13 -0
- package/scripts/enforcement/loop_until_done_pi.js +214 -0
- package/scripts/enforcement/merge_policy.js +181 -0
- package/scripts/enforcement/outward_effect_pi.js +156 -0
- package/scripts/enforcement/recovery_evidence_review_pi.js +31 -3
- package/scripts/enforcement/storage_owner_lock_pi.js +66 -0
- package/scripts/enforcement/task_board_core_pi.js +13 -100
- package/scripts/enforcement/task_board_hash_pi.js +86 -0
- package/scripts/enforcement/treehouse_recycle_pi.js +145 -0
- package/scripts/harvest_lessons.js +219 -0
- package/scripts/relay/record_approval.mjs +10 -0
- package/scripts/relay/send_request.mjs +26 -0
- package/skills/architect-pi/SKILL.md +44 -0
- package/skills/arena-pi/SKILL.md +46 -0
- package/skills/automate-me-pi/SKILL.md +37 -0
- package/skills/benchmark-checklist-pi/SKILL.md +35 -0
- package/skills/blast-radius-pi/SKILL.md +24 -0
- package/skills/bro-pi/SKILL.md +17 -0
- package/skills/correct-pi/SKILL.md +36 -0
- package/skills/create-verification-skill-pi/LICENSE +21 -0
- package/skills/create-verification-skill-pi/SKILL.md +24 -0
- package/skills/decision-to-spec/SKILL.md +5 -5
- package/skills/figure-it-out-pi/SKILL.md +37 -0
- package/skills/harvest-lessons/SKILL.md +56 -0
- package/skills/harvest-lessons/harvest.example.json +8 -0
- package/skills/how/LICENSE +24 -0
- package/skills/how/WORKFLOW_BOUNDARIES.md +19 -0
- package/skills/how-pi/SKILL.md +33 -0
- package/skills/in-your-own-words-pi/SKILL.md +15 -0
- package/skills/interrogate-pi/SKILL.md +35 -0
- package/skills/ketchup-pi/SKILL.md +21 -0
- package/skills/maintain-verification-skill-pi/SKILL.md +37 -0
- package/skills/make-bot-ui-pi/SKILL.md +33 -0
- package/skills/no-comments-pi/SKILL.md +33 -0
- package/skills/poteto-help-pi/SKILL.md +50 -0
- package/skills/poteto-mode-pi/LICENSE +21 -0
- package/skills/poteto-mode-pi/SKILL.md +41 -0
- package/skills/poteto-mode-pi/playbooks.md +95 -0
- package/skills/principle-attack-the-premise-pi/SKILL.md +17 -0
- package/skills/principle-boundary-discipline-pi/SKILL.md +19 -0
- package/skills/principle-build-the-lever-pi/SKILL.md +19 -0
- package/skills/principle-encode-lessons-in-structure-pi/SKILL.md +17 -0
- package/skills/principle-exhaust-the-design-space-pi/SKILL.md +17 -0
- package/skills/principle-experience-first-pi/SKILL.md +17 -0
- package/skills/principle-explain-the-number-pi/SKILL.md +21 -0
- package/skills/principle-fix-root-causes-pi/SKILL.md +21 -0
- package/skills/principle-foundational-thinking-pi/SKILL.md +21 -0
- package/skills/principle-guard-the-context-window-pi/SKILL.md +19 -0
- package/skills/principle-laziness-protocol-pi/SKILL.md +19 -0
- package/skills/principle-make-operations-idempotent-pi/SKILL.md +21 -0
- package/skills/principle-migrate-callers-then-delete-legacy-apis-pi/SKILL.md +19 -0
- package/skills/principle-minimize-reader-load-pi/SKILL.md +19 -0
- package/skills/principle-model-the-domain-pi/SKILL.md +19 -0
- package/skills/principle-never-block-on-the-human-pi/SKILL.md +19 -0
- package/skills/principle-outcome-oriented-execution-pi/SKILL.md +19 -0
- package/skills/principle-prove-it-works-pi/SKILL.md +19 -0
- package/skills/principle-redesign-from-first-principles-pi/SKILL.md +19 -0
- package/skills/principle-separate-before-serializing-shared-state-pi/SKILL.md +19 -0
- package/skills/principle-sequence-verifiable-units-pi/SKILL.md +19 -0
- package/skills/principle-subtract-before-you-add-pi/SKILL.md +19 -0
- package/skills/principle-test-behavior-not-implementation-pi/SKILL.md +21 -0
- package/skills/principle-type-system-discipline-pi/SKILL.md +19 -0
- package/skills/public-pr-wording/SKILL.md +69 -0
- package/skills/public-pr-wording/pr_wording_config.example.json +22 -0
- package/skills/public-pr-wording/pr_wording_lint.py +249 -0
- package/skills/public-pr-wording/pr_wording_rules.json +60 -0
- package/skills/pulse/SKILL.md +6 -0
- package/skills/recall-pi/SKILL.md +29 -0
- package/skills/reflect-pi/SKILL.md +27 -0
- package/skills/setup-pstack-pi/SKILL.md +23 -0
- package/skills/setup-pstack-pi/assets/pstack-model-roles.v1.example.json +7 -0
- package/skills/sharpen-pi/SKILL.md +19 -0
- package/skills/show-me-your-work-pi/SKILL.md +27 -0
- package/skills/swarm-pi/SKILL.md +27 -0
- package/skills/tasks/SKILL.md +8 -2
- package/skills/tdd-pi/SKILL.md +21 -0
- package/skills/teach-pi/SKILL.md +21 -0
- package/skills/technical-writing-pi/SKILL.md +27 -0
- package/skills/typescript-best-practices-pi/SKILL.md +21 -0
- package/skills/unslop-pi/SKILL.md +27 -0
- package/skills/why-pi/SKILL.md +37 -0
|
@@ -8,16 +8,18 @@
|
|
|
8
8
|
// allowlist constant. Pane placement uses pane split directly; tab placement
|
|
9
9
|
// uses tab create. Both capture the returned pane identity and start once.
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
|
-
import { accessSync, constants as fsConstants, readFileSync, realpathSync, statSync } from "node:fs";
|
|
11
|
+
import { accessSync, constants as fsConstants, readFileSync, realpathSync, statSync, lstatSync, writeFileSync, copyFileSync } from "node:fs";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
|
-
import { join, resolve } from "node:path";
|
|
14
|
-
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { basename, join, resolve } from "node:path";
|
|
14
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
15
15
|
import {
|
|
16
16
|
HERDR_ROLE_PATTERN,
|
|
17
17
|
HERDR_ROLE_POLICY,
|
|
18
18
|
resolveTrustedHerdrExecutable,
|
|
19
19
|
} from "./herdr_communication_pi.js";
|
|
20
20
|
import { isAttendedElectronContext, isNativeTuiContext } from "./native_tui_context.js";
|
|
21
|
+
import { reserveApprovalUse, recordApprovalUse, authorizeApproval, checkRelayRequests } from "./approval_ledger_pi.js";
|
|
22
|
+
import { needsOwner } from "./attended_authority_guard.js";
|
|
21
23
|
|
|
22
24
|
export const HERDR_SPAWN_WORKER_TOOL = "agentic_herdr_spawn_worker";
|
|
23
25
|
export const HERDR_LIFECYCLE_SCHEMA = "agentic-driver.herdr-lifecycle.v1";
|
|
@@ -40,7 +42,7 @@ const AGENT_READY_TIMEOUT_MS = 120_000;
|
|
|
40
42
|
// The active Pi profile's model roll is user configuration, read read-only.
|
|
41
43
|
// It resolves from PI_CODING_AGENT_DIR (the active profile directory); only
|
|
42
44
|
// when that env var is unset does it fall back to the default per-user path
|
|
43
|
-
//
|
|
45
|
+
// the installed agent model catalog (recorded in the package design record).
|
|
44
46
|
function resolvePiModelsPath() {
|
|
45
47
|
const profileDir = process.env.PI_CODING_AGENT_DIR;
|
|
46
48
|
if (typeof profileDir === "string" && profileDir.trim()) {
|
|
@@ -51,7 +53,7 @@ function resolvePiModelsPath() {
|
|
|
51
53
|
// A model selection is either a unique local model id or a provider followed
|
|
52
54
|
// by one or more bounded model-id segments. Pi model IDs may use colons, but
|
|
53
55
|
// provider names may not. validateSpawnParams separately bounds total length.
|
|
54
|
-
const MODEL_ID_REGEXP = /^[
|
|
56
|
+
const MODEL_ID_REGEXP = /^(?:[A-Za-z0-9][A-Za-z0-9._:-]{0,127}|[a-z0-9][a-z0-9._-]{0,63}(?:\/[a-z0-9][a-z0-9._:-]{0,127})+|[A-Za-z0-9]+(?: [A-Za-z0-9]+){1,8})$/;
|
|
55
57
|
// Closed response keys. Shapes beyond `.result.move_result.pane.pane_id` are
|
|
56
58
|
// provisional from tagged-source evidence (Tranche 07) and fail closed on
|
|
57
59
|
// anything extra.
|
|
@@ -73,6 +75,11 @@ export const HERDR_SPAWN_WORKER_PARAMETERS = Object.freeze({
|
|
|
73
75
|
role: { type: "string", pattern: HERDR_ROLE_PATTERN, maxLength: 64 },
|
|
74
76
|
model: { type: "string", pattern: MODEL_ID_REGEXP.source, maxLength: 192 },
|
|
75
77
|
repository: { type: "string", pattern: SAFE_NAME_REGEXP.source, maxLength: 64 },
|
|
78
|
+
// Optional bounded worktree target. It is a request, never authority: it
|
|
79
|
+
// must resolve to the canonical Git worktree root of the registered
|
|
80
|
+
// repository and carry an active Treehouse lease observed read-only.
|
|
81
|
+
worktreePath: { type: "string", minLength: 1, maxLength: 1024 },
|
|
82
|
+
authority: { type: "object", additionalProperties: false, properties: { ledgerId: { type: "string" } }, required: ["ledgerId"] },
|
|
76
83
|
},
|
|
77
84
|
required: ["placement", "role", "model", "repository"],
|
|
78
85
|
});
|
|
@@ -166,11 +173,27 @@ export function validateSpawnParams(params) {
|
|
|
166
173
|
if (!isPlainObject(params)) {
|
|
167
174
|
throw lifecycleError("invalid_parameters", "spawn parameters must be an object", "denied");
|
|
168
175
|
}
|
|
176
|
+
const requiredKeys = ["placement", "role", "model", "repository"];
|
|
177
|
+
const optionalKeys = ["worktreePath", "authority"];
|
|
169
178
|
const keys = Object.keys(params);
|
|
170
|
-
if (keys.length
|
|
171
|
-
|
|
179
|
+
if (keys.length < requiredKeys.length || keys.length > requiredKeys.length + optionalKeys.length
|
|
180
|
+
|| requiredKeys.some((k) => !keys.includes(k))
|
|
181
|
+
|| keys.some((k) => !requiredKeys.includes(k) && !optionalKeys.includes(k))
|
|
182
|
+
|| (params.authority !== undefined && (!isPlainObject(params.authority) || Object.keys(params.authority).length !== 1 || typeof params.authority.ledgerId !== "string"))) {
|
|
183
|
+
throw lifecycleError("closed_parameters", "spawn_worker accepts placement, role, model, repository, and optional worktreePath or authority ledgerId", "denied");
|
|
184
|
+
}
|
|
185
|
+
const { placement, role, model, repository, worktreePath } = params;
|
|
186
|
+
if (worktreePath !== undefined) {
|
|
187
|
+
if (typeof worktreePath !== "string" || worktreePath.length > 1024 || !worktreePath.trim()
|
|
188
|
+
|| worktreePath.includes("\0")) {
|
|
189
|
+
throw lifecycleError("worktree_target_invalid", "worktreePath must be a bounded absolute path", "denied");
|
|
190
|
+
}
|
|
191
|
+
// A relative target is refused here; canonical/realpath identity is
|
|
192
|
+
// decided later against real Git and Treehouse evidence.
|
|
193
|
+
if (!worktreePath.startsWith("/")) {
|
|
194
|
+
throw lifecycleError("worktree_target_invalid", "worktreePath must be an absolute path", "denied");
|
|
195
|
+
}
|
|
172
196
|
}
|
|
173
|
-
const { placement, role, model, repository } = params;
|
|
174
197
|
if (!SPAWN_PLACEMENTS.includes(placement)) {
|
|
175
198
|
throw lifecycleError("unsupported_placement", "placement must be tab, right, or below", "denied");
|
|
176
199
|
}
|
|
@@ -184,7 +207,11 @@ export function validateSpawnParams(params) {
|
|
|
184
207
|
if (typeof model !== "string" || model.length > 192 || !MODEL_ID_REGEXP.test(model)) {
|
|
185
208
|
throw lifecycleError("model_denied", "the model must be an installed Pi provider/id or unique model id", "denied");
|
|
186
209
|
}
|
|
187
|
-
return {
|
|
210
|
+
return {
|
|
211
|
+
placement, role, model, repository,
|
|
212
|
+
...(worktreePath === undefined ? {} : { worktreePath }),
|
|
213
|
+
...(params.authority ? { authority: params.authority } : {}),
|
|
214
|
+
};
|
|
188
215
|
}
|
|
189
216
|
|
|
190
217
|
function parseJsonEnvelope(raw, label, { requireResult = false } = {}) {
|
|
@@ -408,12 +435,39 @@ function validateRegistry(registryPath) {
|
|
|
408
435
|
return registry.repositories;
|
|
409
436
|
}
|
|
410
437
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
438
|
+
const GIT_PROBE_TIMEOUT_MS = 10_000;
|
|
439
|
+
|
|
440
|
+
// Repository-selection environment is process input, never tool input, but an
|
|
441
|
+
// inherited GIT_DIR/GIT_COMMON_DIR/GIT_WORK_TREE would silently redirect an
|
|
442
|
+
// authoritative Git probe to another repository. Every lifecycle Git probe
|
|
443
|
+
// therefore runs with all GIT_* variables removed, bounded by timeout and
|
|
444
|
+
// fixed argv with shell:false. This is host-level defense in depth: it does not
|
|
445
|
+
// claim containment of a deliberately hostile parent process tree.
|
|
446
|
+
function controlledEnv() {
|
|
447
|
+
const env = {};
|
|
448
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
449
|
+
if (/^GIT_/i.test(key)) continue;
|
|
450
|
+
env[key] = value;
|
|
451
|
+
}
|
|
452
|
+
return env;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function gitProbe(path, args, code = "repository_not_git_root") {
|
|
456
|
+
const value = spawnSync("git", ["-C", path, ...args], {
|
|
457
|
+
encoding: "utf8",
|
|
458
|
+
shell: false,
|
|
459
|
+
env: controlledEnv(),
|
|
460
|
+
timeout: GIT_PROBE_TIMEOUT_MS,
|
|
461
|
+
maxBuffer: MAX_PROCESS_OUTPUT_BYTES,
|
|
462
|
+
});
|
|
463
|
+
if (value.error || value.status !== 0 || typeof value.stdout !== "string" || !value.stdout.trim()) {
|
|
464
|
+
throw lifecycleError(code, "an authoritative Git probe could not be completed");
|
|
415
465
|
}
|
|
416
|
-
return
|
|
466
|
+
return value.stdout.trim();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function gitRoot(path) {
|
|
470
|
+
return resolve(gitProbe(path, ["rev-parse", "--show-toplevel"]));
|
|
417
471
|
}
|
|
418
472
|
|
|
419
473
|
// Resolves the requested registry name BEFORE any mutation and re-checks it
|
|
@@ -422,7 +476,7 @@ function gitRoot(path) {
|
|
|
422
476
|
// Registry lookup order (coordinator decision): the session repository's
|
|
423
477
|
// own config/herdr-worker-repositories.v1.json first; when missing (ENOENT
|
|
424
478
|
// only) the active Pi profile's shared config under
|
|
425
|
-
// {PI_CODING_AGENT_DIR}/config/ (fallback
|
|
479
|
+
// {PI_CODING_AGENT_DIR}/config/ (fallback to the installed agent config directory). A malformed
|
|
426
480
|
// registry at either level is a stop, never a silent fallthrough; only a
|
|
427
481
|
// missing one falls back.
|
|
428
482
|
function resolveProfileConfigPath(relative) {
|
|
@@ -485,13 +539,371 @@ export function resolveTrustedSpawnRepository(coordinatorCwd, requestedName) {
|
|
|
485
539
|
if (candidateReal !== resolve(coordinatorReal, "..", requestedName)) {
|
|
486
540
|
throw lifecycleError("repository_untrusted", "the requested repository entry is not a canonical sibling", "denied");
|
|
487
541
|
}
|
|
542
|
+
let standaloneRoot = false;
|
|
543
|
+
try {
|
|
544
|
+
standaloneRoot = statSync(candidateReal).isDirectory()
|
|
545
|
+
&& !lstatSync(candidate).isSymbolicLink()
|
|
546
|
+
&& lstatSync(resolve(candidateReal, ".git")).isDirectory();
|
|
547
|
+
} catch { /* missing or non-directory .git is not a standalone repository */ }
|
|
548
|
+
if (basename(candidate) !== basename(candidate).toLowerCase() || !standaloneRoot) {
|
|
549
|
+
throw lifecycleError("repository_not_git_root", "the requested repository must be a lowercase, canonical Git repository root (not a worktree or symlink)", "denied");
|
|
550
|
+
}
|
|
488
551
|
const root = gitRoot(candidateReal);
|
|
489
552
|
if (root !== candidateReal) {
|
|
490
553
|
throw lifecycleError("repository_not_git_root", "the requested repository resolved outside its Git root", "denied");
|
|
491
554
|
}
|
|
555
|
+
try {
|
|
556
|
+
const dotGit = join(candidateReal, ".git");
|
|
557
|
+
const common = gitProbe(candidateReal, ["rev-parse", "--git-common-dir"]);
|
|
558
|
+
if (!statSync(dotGit).isDirectory() || realpathSync(dotGit) !== dotGit
|
|
559
|
+
|| resolve(candidateReal, common) !== dotGit) throw new Error("non-primary Git directory");
|
|
560
|
+
} catch {
|
|
561
|
+
throw lifecycleError("repository_not_git_root", "the requested repository is not a primary clone", "denied");
|
|
562
|
+
}
|
|
492
563
|
return candidateReal;
|
|
493
564
|
}
|
|
494
565
|
|
|
566
|
+
// --- Optional worktree target (real Git identity + Treehouse lease) ---------
|
|
567
|
+
// The requested worktreePath is a request, never authority. It is trusted only
|
|
568
|
+
// when ALL of the following hold, each observed read-only:
|
|
569
|
+
// 1. it is absolute, lexically canonical, and resolves (realpath) to itself,
|
|
570
|
+
// so a symlink, a `..` segment, or a copied directory never qualifies;
|
|
571
|
+
// 2. it is a genuine registered linked worktree of the registry-authorized
|
|
572
|
+
// canonical repository: same Git common directory, a per-worktree
|
|
573
|
+
// administrative directory under <common>/worktrees/<name>, and an exact
|
|
574
|
+
// `gitdir` backpointer from that administrative directory to the target's
|
|
575
|
+
// own `.git` file. Root/common-dir string equality alone is NOT identity;
|
|
576
|
+
// 3. the stable physical identity of the target directory, canonical
|
|
577
|
+
// repository root, common directory, and administrative directory
|
|
578
|
+
// (device + inode) is bound, so a rename-and-recreate at identical paths
|
|
579
|
+
// is a different target. HEAD and dirty/clean state are deliberately NOT
|
|
580
|
+
// identity: an ordinary legitimately dirty worktree stays valid;
|
|
581
|
+
// 4. Treehouse reports exactly one active lease on that path and its lease id
|
|
582
|
+
// is bound for the rest of the operation.
|
|
583
|
+
// The bound identity is re-observed after confirmation, after coordinator pane
|
|
584
|
+
// observation and immediately before the split, between mutations, after agent
|
|
585
|
+
// start, and after the final read-back; any change fails closed with truthful
|
|
586
|
+
// partial state.
|
|
587
|
+
const MAX_WORKTREE_PATH_CHARS = 1024;
|
|
588
|
+
const LEASE_ID_REGEXP = /^[a-f0-9]{32}$/i;
|
|
589
|
+
const TREEHOUSE_STATUS_TIMEOUT_MS = 10_000;
|
|
590
|
+
|
|
591
|
+
function treehouseStatusJson(options, targetPath) {
|
|
592
|
+
const injected = options?.treehouseStatus;
|
|
593
|
+
if (typeof injected === "function") {
|
|
594
|
+
const value = injected(targetPath);
|
|
595
|
+
if (value && typeof value.then === "function") {
|
|
596
|
+
throw lifecycleError("worktree_lease_untrusted", "asynchronous Treehouse evidence is not supported");
|
|
597
|
+
}
|
|
598
|
+
return value;
|
|
599
|
+
}
|
|
600
|
+
if (typeof injected === "string") return injected;
|
|
601
|
+
// Production path: the read-only status query only. No lease acquire, no
|
|
602
|
+
// return, no prune, no mutation of any kind. Repository-selection variables
|
|
603
|
+
// are stripped for the same reason as the Git probes.
|
|
604
|
+
const result = spawnSync("treehouse", ["status", "--json"], {
|
|
605
|
+
encoding: "utf8",
|
|
606
|
+
shell: false,
|
|
607
|
+
cwd: targetPath,
|
|
608
|
+
env: controlledEnv(),
|
|
609
|
+
timeout: TREEHOUSE_STATUS_TIMEOUT_MS,
|
|
610
|
+
maxBuffer: MAX_PROCESS_OUTPUT_BYTES,
|
|
611
|
+
});
|
|
612
|
+
if (result.error || result.status !== 0 || typeof result.stdout !== "string") {
|
|
613
|
+
throw lifecycleError("worktree_lease_untrusted", "the Treehouse lease state could not be read", "denied");
|
|
614
|
+
}
|
|
615
|
+
return result.stdout;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function gitCommonDir(path) {
|
|
619
|
+
try {
|
|
620
|
+
return realpathSync(gitProbe(path, ["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
621
|
+
"worktree_target_invalid"));
|
|
622
|
+
} catch {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function gitAbsoluteDir(path) {
|
|
628
|
+
try {
|
|
629
|
+
return realpathSync(gitProbe(path, ["rev-parse", "--absolute-git-dir"], "worktree_target_invalid"));
|
|
630
|
+
} catch {
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function statIdentity(path, code, label) {
|
|
636
|
+
let stats;
|
|
637
|
+
try {
|
|
638
|
+
stats = statSync(path);
|
|
639
|
+
} catch {
|
|
640
|
+
throw lifecycleError(code, `${label} could not be observed`);
|
|
641
|
+
}
|
|
642
|
+
if (!stats.isDirectory()) {
|
|
643
|
+
throw lifecycleError(code, `${label} is not a directory`);
|
|
644
|
+
}
|
|
645
|
+
return `${stats.dev}:${stats.ino}`;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function fileIdentity(path, code, label) {
|
|
649
|
+
let stats;
|
|
650
|
+
try {
|
|
651
|
+
stats = lstatSync(path);
|
|
652
|
+
} catch {
|
|
653
|
+
throw lifecycleError(code, `${label} could not be observed`);
|
|
654
|
+
}
|
|
655
|
+
if (!stats.isFile()) {
|
|
656
|
+
throw lifecycleError(code, `${label} is not a regular file`);
|
|
657
|
+
}
|
|
658
|
+
return `${stats.dev}:${stats.ino}`;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function containedWorktreeAdminDir(commonDir, adminDir) {
|
|
662
|
+
// A linked worktree's administrative directory is one direct child of
|
|
663
|
+
// <common>/worktrees. Anything else is not a registered linked worktree.
|
|
664
|
+
const root = join(commonDir, "worktrees");
|
|
665
|
+
let rootReal;
|
|
666
|
+
try {
|
|
667
|
+
rootReal = realpathSync(root);
|
|
668
|
+
} catch {
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
if (rootReal !== root) return null;
|
|
672
|
+
const prefix = `${root}/`;
|
|
673
|
+
if (!adminDir.startsWith(prefix)) return null;
|
|
674
|
+
const name = adminDir.slice(prefix.length);
|
|
675
|
+
if (!name || name.includes("/") || name === "." || name === "..") return null;
|
|
676
|
+
return adminDir;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function assertCanonicalWorktreePath(worktreePath) {
|
|
680
|
+
if (typeof worktreePath !== "string" || worktreePath.length > MAX_WORKTREE_PATH_CHARS
|
|
681
|
+
|| !worktreePath.startsWith("/") || worktreePath.trim() !== worktreePath) {
|
|
682
|
+
throw lifecycleError("worktree_target_invalid", "worktreePath must be a bounded absolute path", "denied");
|
|
683
|
+
}
|
|
684
|
+
// Lexical canonicality: reject `..`, doubled separators, and a trailing
|
|
685
|
+
// separator even when the filesystem would resolve them.
|
|
686
|
+
if (resolve(worktreePath) !== worktreePath) {
|
|
687
|
+
throw lifecycleError("worktree_target_invalid", "worktreePath is not lexically canonical", "denied");
|
|
688
|
+
}
|
|
689
|
+
let observedReal;
|
|
690
|
+
try {
|
|
691
|
+
observedReal = realpathSync(worktreePath);
|
|
692
|
+
} catch {
|
|
693
|
+
throw lifecycleError("worktree_target_unavailable", "worktreePath does not exist", "denied");
|
|
694
|
+
}
|
|
695
|
+
if (observedReal !== worktreePath) {
|
|
696
|
+
throw lifecycleError("worktree_target_invalid", "worktreePath resolves through a link or is not canonical", "denied");
|
|
697
|
+
}
|
|
698
|
+
return observedReal;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function worktreeAdministrativeIdentity(targetReal, canonicalRoot) {
|
|
702
|
+
if (targetReal === canonicalRoot) {
|
|
703
|
+
throw lifecycleError("worktree_target_untrusted", "worktreePath is the canonical repository root, not a worktree", "denied");
|
|
704
|
+
}
|
|
705
|
+
let targetRoot;
|
|
706
|
+
try {
|
|
707
|
+
targetRoot = gitRoot(targetReal);
|
|
708
|
+
} catch {
|
|
709
|
+
throw lifecycleError("worktree_target_invalid", "worktreePath is not a Git work tree root", "denied");
|
|
710
|
+
}
|
|
711
|
+
if (realpathSync(targetRoot) !== targetReal) {
|
|
712
|
+
throw lifecycleError("worktree_target_invalid", "worktreePath is not the Git work tree root", "denied");
|
|
713
|
+
}
|
|
714
|
+
const registeredCommon = gitCommonDir(canonicalRoot);
|
|
715
|
+
const targetCommon = gitCommonDir(targetReal);
|
|
716
|
+
if (!registeredCommon || !targetCommon || registeredCommon !== targetCommon) {
|
|
717
|
+
throw lifecycleError("worktree_target_untrusted", "worktreePath is not a worktree of the registered repository", "denied");
|
|
718
|
+
}
|
|
719
|
+
const adminDir = gitAbsoluteDir(targetReal);
|
|
720
|
+
const registeredAdminDir = containedWorktreeAdminDir(registeredCommon, adminDir ?? "");
|
|
721
|
+
if (!adminDir || !registeredAdminDir) {
|
|
722
|
+
throw lifecycleError("worktree_target_untrusted", "worktreePath has no registered per-worktree Git administration", "denied");
|
|
723
|
+
}
|
|
724
|
+
// Genuine backpointer: the administrative directory must point back at THIS
|
|
725
|
+
// worktree's own .git file. A copied .git pointer that borrows another
|
|
726
|
+
// slot's administration fails here even when root/common-dir match.
|
|
727
|
+
const dotGit = join(targetReal, ".git");
|
|
728
|
+
let dotGitStats;
|
|
729
|
+
try {
|
|
730
|
+
dotGitStats = lstatSync(dotGit);
|
|
731
|
+
} catch {
|
|
732
|
+
throw lifecycleError("worktree_target_untrusted", "worktreePath has no Git worktree pointer", "denied");
|
|
733
|
+
}
|
|
734
|
+
if (!dotGitStats.isFile() || dotGitStats.isSymbolicLink()) {
|
|
735
|
+
throw lifecycleError("worktree_target_untrusted", "worktreePath does not use a linked-worktree Git pointer", "denied");
|
|
736
|
+
}
|
|
737
|
+
let pointerText;
|
|
738
|
+
try {
|
|
739
|
+
pointerText = readFileSync(dotGit, "utf8").trim();
|
|
740
|
+
} catch {
|
|
741
|
+
throw lifecycleError("worktree_target_untrusted", "worktreePath Git pointer is unreadable", "denied");
|
|
742
|
+
}
|
|
743
|
+
if (!pointerText.startsWith("gitdir: ")) {
|
|
744
|
+
throw lifecycleError("worktree_target_untrusted", "worktreePath Git pointer is malformed", "denied");
|
|
745
|
+
}
|
|
746
|
+
let pointerAdmin;
|
|
747
|
+
try {
|
|
748
|
+
pointerAdmin = realpathSync(resolve(targetReal, pointerText.slice("gitdir: ".length).trim()));
|
|
749
|
+
} catch {
|
|
750
|
+
throw lifecycleError("worktree_target_untrusted", "worktreePath Git pointer does not resolve", "denied");
|
|
751
|
+
}
|
|
752
|
+
if (pointerAdmin !== adminDir) {
|
|
753
|
+
throw lifecycleError("worktree_target_untrusted", "worktreePath Git pointer names a different administrative directory", "denied");
|
|
754
|
+
}
|
|
755
|
+
let backpointerText;
|
|
756
|
+
try {
|
|
757
|
+
backpointerText = readFileSync(join(adminDir, "gitdir"), "utf8").trim();
|
|
758
|
+
} catch {
|
|
759
|
+
throw lifecycleError("worktree_target_untrusted", "worktreePath Git administrative record is unreadable", "denied");
|
|
760
|
+
}
|
|
761
|
+
let backpointer;
|
|
762
|
+
try {
|
|
763
|
+
backpointer = realpathSync(resolve(adminDir, backpointerText));
|
|
764
|
+
} catch {
|
|
765
|
+
throw lifecycleError("worktree_target_untrusted", "worktreePath Git administrative backpointer does not resolve", "denied");
|
|
766
|
+
}
|
|
767
|
+
let dotGitReal;
|
|
768
|
+
try {
|
|
769
|
+
dotGitReal = realpathSync(dotGit);
|
|
770
|
+
} catch {
|
|
771
|
+
throw lifecycleError("worktree_target_untrusted", "worktreePath Git pointer cannot be canonicalized", "denied");
|
|
772
|
+
}
|
|
773
|
+
if (backpointer !== dotGitReal) {
|
|
774
|
+
throw lifecycleError("worktree_target_untrusted", "worktreePath Git administrative backpointer names a different worktree", "denied");
|
|
775
|
+
}
|
|
776
|
+
return Object.freeze({
|
|
777
|
+
repositoryPath: canonicalRoot,
|
|
778
|
+
target: targetReal,
|
|
779
|
+
commonDir: registeredCommon,
|
|
780
|
+
adminDir,
|
|
781
|
+
dotGit: dotGitReal,
|
|
782
|
+
targetIdentity: statIdentity(targetReal, "worktree_target_untrusted", "the worktree target"),
|
|
783
|
+
repositoryIdentity: statIdentity(canonicalRoot, "worktree_target_untrusted", "the registered repository"),
|
|
784
|
+
commonIdentity: statIdentity(registeredCommon, "worktree_target_untrusted", "the Git common directory"),
|
|
785
|
+
adminIdentity: statIdentity(adminDir, "worktree_target_untrusted", "the worktree administrative directory"),
|
|
786
|
+
dotGitIdentity: fileIdentity(dotGitReal, "worktree_target_untrusted", "the worktree Git pointer"),
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function observedLeaseIdentity(targetReal, options) {
|
|
791
|
+
const raw = treehouseStatusJson(options, targetReal);
|
|
792
|
+
if (typeof raw !== "string" || !raw.trim()
|
|
793
|
+
|| Buffer.byteLength(raw, "utf8") > MAX_PROCESS_OUTPUT_BYTES) {
|
|
794
|
+
throw lifecycleError("worktree_lease_untrusted", "the Treehouse lease evidence is unusable", "denied");
|
|
795
|
+
}
|
|
796
|
+
let entries;
|
|
797
|
+
try {
|
|
798
|
+
entries = JSON.parse(raw);
|
|
799
|
+
} catch {
|
|
800
|
+
throw lifecycleError("worktree_lease_untrusted", "the Treehouse lease evidence is malformed", "denied");
|
|
801
|
+
}
|
|
802
|
+
if (!Array.isArray(entries) || entries.length > 256) {
|
|
803
|
+
throw lifecycleError("worktree_lease_untrusted", "the Treehouse lease evidence is malformed", "denied");
|
|
804
|
+
}
|
|
805
|
+
const matches = entries.filter((entry) => {
|
|
806
|
+
if (!isPlainObject(entry) || typeof entry.path !== "string" || !entry.path.startsWith("/")) return false;
|
|
807
|
+
try {
|
|
808
|
+
return realpathSync(entry.path) === targetReal;
|
|
809
|
+
} catch {
|
|
810
|
+
return false;
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
if (matches.length !== 1) {
|
|
814
|
+
throw lifecycleError("worktree_lease_untrusted", "the worktree target is not a managed pool worktree", "denied");
|
|
815
|
+
}
|
|
816
|
+
const entry = matches[0];
|
|
817
|
+
if (entry.status !== "leased") {
|
|
818
|
+
throw lifecycleError("worktree_lease_untrusted", "the worktree target is not actively leased", "denied");
|
|
819
|
+
}
|
|
820
|
+
if (typeof entry.lease_id !== "string" || !LEASE_ID_REGEXP.test(entry.lease_id)) {
|
|
821
|
+
throw lifecycleError("worktree_lease_untrusted", "the worktree lease identity is not trustworthy", "denied");
|
|
822
|
+
}
|
|
823
|
+
const holder = typeof entry.lease_holder === "string" && entry.lease_holder.length <= 128
|
|
824
|
+
? entry.lease_holder
|
|
825
|
+
: null;
|
|
826
|
+
return Object.freeze({ leaseId: entry.lease_id.toLowerCase(), holder });
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function resolveWorktreeIdentity(canonicalRoot, worktreePath, options) {
|
|
830
|
+
const targetReal = assertCanonicalWorktreePath(worktreePath);
|
|
831
|
+
const admin = worktreeAdministrativeIdentity(targetReal, canonicalRoot);
|
|
832
|
+
const lease = observedLeaseIdentity(targetReal, options);
|
|
833
|
+
return Object.freeze({
|
|
834
|
+
worktreePath: targetReal,
|
|
835
|
+
leaseId: lease.leaseId,
|
|
836
|
+
leaseHolder: lease.holder,
|
|
837
|
+
repositoryPath: admin.repositoryPath,
|
|
838
|
+
commonDir: admin.commonDir,
|
|
839
|
+
adminDir: admin.adminDir,
|
|
840
|
+
dotGit: admin.dotGit,
|
|
841
|
+
identity: Object.freeze({
|
|
842
|
+
target: admin.targetIdentity,
|
|
843
|
+
repository: admin.repositoryIdentity,
|
|
844
|
+
common: admin.commonIdentity,
|
|
845
|
+
admin: admin.adminIdentity,
|
|
846
|
+
dotGit: admin.dotGitIdentity,
|
|
847
|
+
}),
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export function resolveWorktreeSpawnTarget(canonicalRoot, worktreePath, options = {}) {
|
|
852
|
+
return resolveWorktreeIdentity(canonicalRoot, worktreePath, options);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
export function worktreeTargetUnchanged(left, right) {
|
|
856
|
+
if (!left || !right) return false;
|
|
857
|
+
if (left.worktreePath !== right.worktreePath || left.leaseId !== right.leaseId) return false;
|
|
858
|
+
if (left.repositoryPath !== right.repositoryPath
|
|
859
|
+
|| left.commonDir !== right.commonDir
|
|
860
|
+
|| left.adminDir !== right.adminDir
|
|
861
|
+
|| left.dotGit !== right.dotGit) {
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
864
|
+
for (const key of ["target", "repository", "common", "admin", "dotGit"]) {
|
|
865
|
+
if (left.identity?.[key] !== right.identity?.[key]) return false;
|
|
866
|
+
}
|
|
867
|
+
return true;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function revalidateWorktreeTarget(canonicalRoot, request, bound, options) {
|
|
871
|
+
if (!request.worktreePath) return null;
|
|
872
|
+
let observed;
|
|
873
|
+
try {
|
|
874
|
+
observed = resolveWorktreeIdentity(canonicalRoot, request.worktreePath, options);
|
|
875
|
+
} catch (error) {
|
|
876
|
+
throw lifecycleError(
|
|
877
|
+
"worktree_target_changed",
|
|
878
|
+
`the confirmed worktree target is no longer valid: ${error?.message || "revalidation failed"}`,
|
|
879
|
+
"blocked",
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
if (!worktreeTargetUnchanged(observed, bound)) {
|
|
883
|
+
throw lifecycleError(
|
|
884
|
+
"worktree_target_changed",
|
|
885
|
+
"the worktree physical identity, Git administrative association, or lease identity changed during the spawn",
|
|
886
|
+
"blocked",
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
return observed;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Registry membership is authority for the whole spawn, not only for the
|
|
893
|
+
// pre-mutation resolve. Every boundary that already re-observes the bound
|
|
894
|
+
// target and lease also re-resolves the CURRENT registry, so a membership
|
|
895
|
+
// revocation during an asynchronous observation fails closed at that boundary
|
|
896
|
+
// instead of at a later one (or after a success report). This composes the
|
|
897
|
+
// existing resolvers; it is not a second validation owner.
|
|
898
|
+
function revalidateSpawnAuthority(coordinatorCwd, requestedName, canonicalRoot, request, bound, options, boundary) {
|
|
899
|
+
const currentRoot = resolveTrustedSpawnRepository(coordinatorCwd, requestedName);
|
|
900
|
+
if (currentRoot !== canonicalRoot) {
|
|
901
|
+
throw lifecycleError("repository_mismatch", `the trusted repository changed ${boundary}`);
|
|
902
|
+
}
|
|
903
|
+
if (request.worktreePath) revalidateWorktreeTarget(currentRoot, request, bound, options);
|
|
904
|
+
return currentRoot;
|
|
905
|
+
}
|
|
906
|
+
|
|
495
907
|
// --- Installed Pi model roll (read-only; no allowlist constant) -------------
|
|
496
908
|
// The authoritative installed roll is the merged Pi catalog: built-in provider
|
|
497
909
|
// models plus the profile's custom models.json entries. It resolves read-only
|
|
@@ -579,18 +991,99 @@ export function resolveInstalledModel(requested, options = {}) {
|
|
|
579
991
|
? options.modelsPath
|
|
580
992
|
: resolvePiModelsPath();
|
|
581
993
|
for (const pair of readCustomModelPairs(modelsPath)) pairs.add(pair);
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
994
|
+
if (typeof requested !== "string" || !MODEL_ID_REGEXP.test(requested)) {
|
|
995
|
+
throw lifecycleError("model_denied", "the requested model is invalid", "denied");
|
|
996
|
+
}
|
|
997
|
+
// The local policy owns names, provider classes, forbidden pairs and budgets.
|
|
998
|
+
// A missing or malformed policy never authorizes an inferred gateway route.
|
|
999
|
+
let policy = options.routePolicy;
|
|
1000
|
+
if (policy === undefined) {
|
|
1001
|
+
const path = options.policyPath ?? resolveProfileConfigPath("config/model-route-policy.v1.local.json").path;
|
|
1002
|
+
try {
|
|
1003
|
+
const info = lstatSync(path);
|
|
1004
|
+
// A symlink at the file or ANY parent of the path fails closed.
|
|
1005
|
+
if (info.isSymbolicLink() || realpathSync(path) !== path) {
|
|
1006
|
+
throw lifecycleError("model_policy_symlink", `model routing policy path is a symlink: ${path}`, "denied");
|
|
1007
|
+
}
|
|
1008
|
+
if (!info.isFile() || info.size > 16_384) {
|
|
1009
|
+
throw lifecycleError("model_policy_invalid", `model routing policy file is invalid: ${path}`, "denied");
|
|
1010
|
+
}
|
|
1011
|
+
policy = JSON.parse(readFileSync(path, "utf8"));
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
if (error?.name === "HerdrLifecycleError") throw error;
|
|
1014
|
+
if (error?.code === "ENOENT") {
|
|
1015
|
+
throw lifecycleError("model_policy_missing", `model routing policy is unavailable: ${path}; fill it in locally before spawning`, "denied");
|
|
1016
|
+
}
|
|
1017
|
+
throw lifecycleError("model_policy_invalid", `model routing policy could not be read: ${path}`, "denied");
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
if (policy?.schema !== "agentic-driver.model-route-policy.v1" || !Array.isArray(policy.routes)
|
|
1021
|
+
|| !Array.isArray(policy.bannedPairs) || !isPlainObject(policy.budgets)
|
|
1022
|
+
|| policy.routes.length > 128 || policy.bannedPairs.length > 128
|
|
1023
|
+
|| policy.routes.some((route) => !isPlainObject(route) || typeof route.provider !== "string"
|
|
1024
|
+
|| typeof route.model !== "string" || !PROVIDER_TOKEN_REGEXP.test(route.provider)
|
|
1025
|
+
|| !MODEL_TOKEN_REGEXP.test(route.model) || typeof route.family !== "string"
|
|
1026
|
+
|| !/^[a-z0-9-]{1,64}$/.test(route.family) || typeof route.subscription !== "boolean"
|
|
1027
|
+
|| typeof route.gateway !== "boolean" || route.subscription === route.gateway
|
|
1028
|
+
|| !Array.isArray(route.aliases) || route.aliases.length > 16
|
|
1029
|
+
|| route.aliases.some((alias) => typeof alias !== "string" || !/^[a-zA-Z0-9 ._-]{1,128}$/.test(alias)))
|
|
1030
|
+
|| policy.bannedPairs.some((pair) => !isPlainObject(pair)
|
|
1031
|
+
|| typeof pair.family !== "string" || typeof pair.provider !== "string")
|
|
1032
|
+
|| new Set(policy.routes.map((route) => `${route.provider}/${route.model}`)).size !== policy.routes.length) {
|
|
1033
|
+
throw lifecycleError("model_policy_invalid", "the local model routing policy is invalid", "denied");
|
|
1034
|
+
}
|
|
1035
|
+
const normalize = (value) => value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
1036
|
+
const explicit = requested.includes("/");
|
|
1037
|
+
const matching = policy.routes.filter((route) => {
|
|
1038
|
+
const pair = `${route.provider}/${route.model}`;
|
|
1039
|
+
return pairs.has(pair) && (explicit ? pair === requested
|
|
1040
|
+
: [route.model, ...route.aliases].some((alias) => normalize(alias) === normalize(requested)));
|
|
1041
|
+
});
|
|
1042
|
+
if (!matching.length) throw lifecycleError("model_unknown", "requested model has no installed policy route", "denied");
|
|
1043
|
+
const family = matching[0].family;
|
|
1044
|
+
if (matching.some((route) => route.family !== family)) {
|
|
1045
|
+
throw lifecycleError("model_ambiguous", "the name matches multiple model families; specify a provider", "denied");
|
|
1046
|
+
}
|
|
1047
|
+
const routes = policy.routes.filter((route) => route.family === family && pairs.has(`${route.provider}/${route.model}`));
|
|
1048
|
+
const subscriptions = routes.filter((route) => route.subscription);
|
|
1049
|
+
// Production currently treats Pi roll membership as availability. The probe
|
|
1050
|
+
// seam is test-only until a live non-credential availability signal is wired.
|
|
1051
|
+
const available = subscriptions.filter((route) => options.isRouteAvailable?.(`${route.provider}/${route.model}`) !== false);
|
|
1052
|
+
const suggestion = subscriptions.length
|
|
1053
|
+
? subscriptions.map((route) => `${route.provider}/${route.model}`).join(", ")
|
|
1054
|
+
: "no installed subscription route";
|
|
1055
|
+
const banned = (route) => policy.bannedPairs.some((pair) => pair.family === route.family && pair.provider === route.provider);
|
|
1056
|
+
if (explicit && banned(matching[0])) {
|
|
1057
|
+
throw lifecycleError("model_gateway_denied", `route forbidden; use subscription route ${suggestion}`, "denied");
|
|
1058
|
+
}
|
|
1059
|
+
if (!explicit && available.length > 1) {
|
|
1060
|
+
throw lifecycleError("model_ambiguous", `multiple subscription routes match; specify one: ${suggestion}`, "denied");
|
|
1061
|
+
}
|
|
1062
|
+
if (!explicit && available.length === 1) {
|
|
1063
|
+
if (banned(available[0])) {
|
|
1064
|
+
throw lifecycleError("model_gateway_denied", `route forbidden; use another subscription route (available: ${suggestion})`, "denied");
|
|
1065
|
+
}
|
|
1066
|
+
return Object.freeze(["--model", `${available[0].provider}/${available[0].model}`]);
|
|
1067
|
+
}
|
|
1068
|
+
if (explicit && matching[0].subscription) {
|
|
1069
|
+
// An exact request cannot silently switch to a gateway; only a loose name
|
|
1070
|
+
// can use a separately budgeted fallback when its subscription is unavailable.
|
|
1071
|
+
if (!available.includes(matching[0])) throw lifecycleError("model_unavailable", `subscription route ${requested} is unavailable`, "denied");
|
|
1072
|
+
return Object.freeze(["--model", requested]);
|
|
1073
|
+
}
|
|
1074
|
+
if (explicit && available.length) {
|
|
1075
|
+
throw lifecycleError("model_subscription_preferred", `use subscription route ${suggestion}; explicit provider will not be silently switched`, "denied");
|
|
588
1076
|
}
|
|
589
|
-
|
|
590
|
-
|
|
1077
|
+
const eligible = (explicit ? matching : routes).filter((route) => route.gateway && !banned(route));
|
|
1078
|
+
if (eligible.length !== 1) {
|
|
1079
|
+
throw lifecycleError("model_ambiguous", `requested model has ${eligible.length} eligible gateway routes; specify a provider (subscription: ${suggestion})`, "denied");
|
|
591
1080
|
}
|
|
592
|
-
|
|
593
|
-
|
|
1081
|
+
const chosen = eligible[0];
|
|
1082
|
+
const budget = policy.budgets[chosen.provider]?.[chosen.model];
|
|
1083
|
+
if (budget !== "no-limit" && !(typeof budget === "number" && Number.isFinite(budget) && budget > 0)) {
|
|
1084
|
+
throw lifecycleError("model_budget_required", `gateway route ${chosen.provider}/${chosen.model} needs an explicit per-provider, per-model budget or no-limit; subscription route: ${suggestion}`, "denied");
|
|
1085
|
+
}
|
|
1086
|
+
return Object.freeze(["--model", `${chosen.provider}/${chosen.model}`]);
|
|
594
1087
|
}
|
|
595
1088
|
|
|
596
1089
|
// --- Fixed argv builders ----------------------------------------------------
|
|
@@ -660,7 +1153,8 @@ async function invokeHerdr(argv, canonicalRoot, options, signal) {
|
|
|
660
1153
|
// Production path: the shared bounded spawner in the communication adapter
|
|
661
1154
|
// is process-level; spawn here is reserved to the fixed argv builders via
|
|
662
1155
|
// the same trusted executable pin, so the native spawn seam stays closed.
|
|
663
|
-
|
|
1156
|
+
// Import statically: dispatch must not yield after the caller's final
|
|
1157
|
+
// authority observation and before the actual subprocess is created.
|
|
664
1158
|
raw = await new Promise((resolveResult) => {
|
|
665
1159
|
const child = spawn(executable, spec.argv, {
|
|
666
1160
|
...spec.spawnOptions,
|
|
@@ -732,6 +1226,13 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
|
|
|
732
1226
|
// Resolve BEFORE any mutation: registry + realpath + Git root.
|
|
733
1227
|
let canonicalRoot = resolveTrustedSpawnRepository(context?.cwd, repository);
|
|
734
1228
|
const modelArgv = resolveInstalledModel(model, options);
|
|
1229
|
+
// An optional worktree target is validated read-only here (canonical path,
|
|
1230
|
+
// real Git repository identity, active Treehouse lease) and its observed
|
|
1231
|
+
// lease identity is bound for the rest of the operation.
|
|
1232
|
+
let worktreeTarget = request.worktreePath
|
|
1233
|
+
? resolveWorktreeSpawnTarget(canonicalRoot, request.worktreePath, options)
|
|
1234
|
+
: null;
|
|
1235
|
+
let spawnCwd = worktreeTarget ? worktreeTarget.worktreePath : canonicalRoot;
|
|
735
1236
|
|
|
736
1237
|
// The calling pane must be a Herdr session pane; `pane current --current`
|
|
737
1238
|
// resolves the coordinator pane identity for the final move step.
|
|
@@ -742,7 +1243,7 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
|
|
|
742
1243
|
|
|
743
1244
|
// Native confirmation is required before any mutation; the cheap local
|
|
744
1245
|
// gate comes before any Herdr observation call.
|
|
745
|
-
if (!isNativeTuiContext(context)) {
|
|
1246
|
+
if (!request.authority && !isNativeTuiContext(context)) {
|
|
746
1247
|
throw lifecycleError("native_confirmation_required", "spawn_worker requires the interactive Pi TUI", "blocked");
|
|
747
1248
|
}
|
|
748
1249
|
|
|
@@ -751,7 +1252,20 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
|
|
|
751
1252
|
if (duplicateRoleExists(listed, role)) {
|
|
752
1253
|
throw lifecycleError("duplicate_worker_name", "a live agent already holds this name", "denied");
|
|
753
1254
|
}
|
|
754
|
-
|
|
1255
|
+
let confirmed;
|
|
1256
|
+
if (request.authority) {
|
|
1257
|
+
try {
|
|
1258
|
+
// A ledger grant is bound to the exact target when a worktree is
|
|
1259
|
+
// requested: a repository-only grant never covers an unrecorded path.
|
|
1260
|
+
await reserveApprovalUse(resolveProfileConfigPath(".").base, request.authority.ledgerId, {
|
|
1261
|
+
tier: 1, repository, action: "spawn", model, role,
|
|
1262
|
+
...(worktreeTarget ? { path: worktreeTarget.worktreePath } : {}),
|
|
1263
|
+
});
|
|
1264
|
+
confirmed = true;
|
|
1265
|
+
} catch (error) {
|
|
1266
|
+
return errorResult(lifecycleError("approval_missing", error.message, "needs-owner"), needsOwner(1, ["spawn approval covering repository, model and role"], repository));
|
|
1267
|
+
}
|
|
1268
|
+
} else confirmed = await context.ui.confirm(
|
|
755
1269
|
"Spawn Herdr worker",
|
|
756
1270
|
[
|
|
757
1271
|
"One guarded composite spawn: create the requested layout, then start and verify one Pi agent.",
|
|
@@ -761,6 +1275,12 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
|
|
|
761
1275
|
: `Placement: ${placement} (Herdr split: ${HERDR_DIRECTIONS[placement]})`,
|
|
762
1276
|
`Model: ${modelArgv[1]} (argv: ${modelArgv.join(" ")})`,
|
|
763
1277
|
`Repository: ${canonicalRoot}`,
|
|
1278
|
+
...(worktreeTarget
|
|
1279
|
+
? [
|
|
1280
|
+
`Worktree target: ${worktreeTarget.worktreePath}`,
|
|
1281
|
+
`Treehouse lease: ${worktreeTarget.leaseId}${worktreeTarget.leaseHolder ? ` (holder: ${worktreeTarget.leaseHolder})` : ""}`,
|
|
1282
|
+
]
|
|
1283
|
+
: []),
|
|
764
1284
|
"No rollback is automatic; a failed step leaves the created state for human cleanup.",
|
|
765
1285
|
].join("\n"),
|
|
766
1286
|
);
|
|
@@ -769,18 +1289,23 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
|
|
|
769
1289
|
}
|
|
770
1290
|
|
|
771
1291
|
// Revalidation point after confirmation, before the first mutation.
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
canonicalRoot = reconfirmedRoot;
|
|
1292
|
+
canonicalRoot = revalidateSpawnAuthority(
|
|
1293
|
+
context?.cwd, repository, canonicalRoot, request, worktreeTarget, options, "after confirmation",
|
|
1294
|
+
);
|
|
1295
|
+
if (request.worktreePath) spawnCwd = worktreeTarget.worktreePath;
|
|
777
1296
|
|
|
778
1297
|
// Herdr owns new-shell readiness. Follow its documented topology:
|
|
779
1298
|
// split directly for pane placement, or create a tab for tab placement.
|
|
780
1299
|
let coordinator = null;
|
|
781
1300
|
if (placement !== "tab") {
|
|
782
|
-
const currentRaw = await invokeHerdr(currentPaneArgv(),
|
|
1301
|
+
const currentRaw = await invokeHerdr(currentPaneArgv(), spawnCwd, options, signal);
|
|
783
1302
|
coordinator = parseCurrentPane(currentRaw);
|
|
1303
|
+
// The coordinator observation is asynchronous; a registry, lease, or
|
|
1304
|
+
// association change during it must fail closed before the split.
|
|
1305
|
+
canonicalRoot = revalidateSpawnAuthority(
|
|
1306
|
+
context?.cwd, repository, canonicalRoot, request, worktreeTarget, options,
|
|
1307
|
+
"during the coordinator pane observation",
|
|
1308
|
+
);
|
|
784
1309
|
}
|
|
785
1310
|
|
|
786
1311
|
const layoutPartial = placement === "tab" ? "tab_created" : "pane_created";
|
|
@@ -789,12 +1314,12 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
|
|
|
789
1314
|
let tabId;
|
|
790
1315
|
try {
|
|
791
1316
|
if (placement === "tab") {
|
|
792
|
-
layoutRaw = await invokeHerdr(tabCreateArgv(
|
|
1317
|
+
layoutRaw = await invokeHerdr(tabCreateArgv(spawnCwd, role), spawnCwd, options, signal);
|
|
793
1318
|
({ rootPaneId, tabId } = parseTabCreate(layoutRaw));
|
|
794
1319
|
} else {
|
|
795
1320
|
layoutRaw = await invokeHerdr(
|
|
796
|
-
splitArgv(coordinator.paneId,
|
|
797
|
-
|
|
1321
|
+
splitArgv(coordinator.paneId, spawnCwd, placement),
|
|
1322
|
+
spawnCwd, options, signal,
|
|
798
1323
|
);
|
|
799
1324
|
const split = parsePaneSplit(layoutRaw, coordinator.tabId);
|
|
800
1325
|
rootPaneId = split.paneId;
|
|
@@ -809,17 +1334,16 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
|
|
|
809
1334
|
|
|
810
1335
|
let agentStartSucceeded = false;
|
|
811
1336
|
try {
|
|
812
|
-
// Re-check the
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
}
|
|
1337
|
+
// Re-check registry authorization and the bound target between mutations.
|
|
1338
|
+
canonicalRoot = revalidateSpawnAuthority(
|
|
1339
|
+
context?.cwd, repository, canonicalRoot, request, worktreeTarget, options, "between mutations",
|
|
1340
|
+
);
|
|
817
1341
|
|
|
818
1342
|
// Start exactly once in the shell pane returned by the layout command.
|
|
819
1343
|
const startCall = startArgv(role, rootPaneId, modelArgv);
|
|
820
1344
|
let startRaw;
|
|
821
1345
|
try {
|
|
822
|
-
startRaw = await invokeHerdr(startCall,
|
|
1346
|
+
startRaw = await invokeHerdr(startCall, spawnCwd, options, signal);
|
|
823
1347
|
agentStartSucceeded = true;
|
|
824
1348
|
} catch (error) {
|
|
825
1349
|
throw lifecycleError("start_failed", error?.message || "Herdr agent start failed");
|
|
@@ -827,23 +1351,37 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
|
|
|
827
1351
|
|
|
828
1352
|
// Agent start is a mutation, so authorization must be resolved again
|
|
829
1353
|
// before accepting any start/read-back facts.
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
}
|
|
834
|
-
canonicalRoot = postStartRoot;
|
|
1354
|
+
canonicalRoot = revalidateSpawnAuthority(
|
|
1355
|
+
context?.cwd, repository, canonicalRoot, request, worktreeTarget, options, "after agent start",
|
|
1356
|
+
);
|
|
835
1357
|
|
|
836
|
-
const startedPaneId = readBackStart(startRaw, role,
|
|
1358
|
+
const startedPaneId = readBackStart(startRaw, role, spawnCwd, [...modelArgv]);
|
|
837
1359
|
if (startedPaneId !== rootPaneId) {
|
|
838
1360
|
throw lifecycleError("pane_mismatch", "agent start returned a different pane identity");
|
|
839
1361
|
}
|
|
840
|
-
const agentRaw = await invokeHerdr(["agent", "get", role],
|
|
841
|
-
const agentReadBack = readBackAgent(agentRaw, role,
|
|
1362
|
+
const agentRaw = await invokeHerdr(["agent", "get", role], spawnCwd, options, signal);
|
|
1363
|
+
const agentReadBack = readBackAgent(agentRaw, role, spawnCwd, [...modelArgv]);
|
|
842
1364
|
if (agentReadBack.paneId !== rootPaneId) {
|
|
843
1365
|
throw lifecycleError("pane_mismatch", "agent get returned a different pane identity");
|
|
844
1366
|
}
|
|
845
|
-
const paneRaw = await invokeHerdr(["pane", "get", rootPaneId],
|
|
846
|
-
readBackPane(paneRaw, rootPaneId,
|
|
1367
|
+
const paneRaw = await invokeHerdr(["pane", "get", rootPaneId], spawnCwd, options, signal);
|
|
1368
|
+
readBackPane(paneRaw, rootPaneId, spawnCwd);
|
|
1369
|
+
// The read-backs are asynchronous too. A registry, lease, or
|
|
1370
|
+
// association change during them must not be reported as a successful
|
|
1371
|
+
// spawn.
|
|
1372
|
+
revalidateSpawnAuthority(
|
|
1373
|
+
context?.cwd, repository, canonicalRoot, request, worktreeTarget, options,
|
|
1374
|
+
"during the final read-backs",
|
|
1375
|
+
);
|
|
1376
|
+
if (request.authority) {
|
|
1377
|
+
await recordApprovalUse(resolveProfileConfigPath(".").base, request.authority.ledgerId, "spawn", repository, "spawned");
|
|
1378
|
+
// The audit write can await its writer lock. Recheck authority after
|
|
1379
|
+
// that wait, before reporting success for the already-started agent.
|
|
1380
|
+
revalidateSpawnAuthority(
|
|
1381
|
+
context?.cwd, repository, canonicalRoot, request, worktreeTarget, options,
|
|
1382
|
+
"during the final approval-use audit write",
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
847
1385
|
|
|
848
1386
|
return {
|
|
849
1387
|
schema: HERDR_LIFECYCLE_SCHEMA,
|
|
@@ -858,6 +1396,9 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
|
|
|
858
1396
|
tabId,
|
|
859
1397
|
modelArgv: [...modelArgv],
|
|
860
1398
|
repository: canonicalRoot,
|
|
1399
|
+
...(worktreeTarget
|
|
1400
|
+
? { worktreePath: worktreeTarget.worktreePath, leaseId: worktreeTarget.leaseId }
|
|
1401
|
+
: {}),
|
|
861
1402
|
hashes: Object.freeze({
|
|
862
1403
|
request: sha256(JSON.stringify(request)),
|
|
863
1404
|
layoutCreateResponse: sha256(layoutRaw),
|
|
@@ -937,7 +1478,12 @@ function attendedElectronParams(params) {
|
|
|
937
1478
|
function resolveAttendedElectronPrerequisites(request, context, options) {
|
|
938
1479
|
const canonicalRoot = resolveTrustedSpawnRepository(context.cwd, request.repository);
|
|
939
1480
|
const modelArgv = resolveInstalledModel(request.model, options);
|
|
940
|
-
|
|
1481
|
+
// The optional worktree target follows exactly the same rules on this
|
|
1482
|
+
// boundary: canonical path, real Git repository identity, active lease.
|
|
1483
|
+
const worktreeTarget = request.worktreePath
|
|
1484
|
+
? resolveWorktreeSpawnTarget(canonicalRoot, request.worktreePath, options)
|
|
1485
|
+
: null;
|
|
1486
|
+
return { canonicalRoot, modelArgv, worktreeTarget };
|
|
941
1487
|
}
|
|
942
1488
|
|
|
943
1489
|
function assertHerdrExecutableAvailable(options) {
|
|
@@ -975,8 +1521,12 @@ export async function preflightAttendedElectronSpawn(params, context, options =
|
|
|
975
1521
|
attendedElectronContextError(context);
|
|
976
1522
|
const request = attendedElectronParams(params);
|
|
977
1523
|
assertHerdrExecutableAvailable(options);
|
|
978
|
-
const { canonicalRoot, modelArgv } = resolveAttendedElectronPrerequisites(request, context, options);
|
|
979
|
-
await observeDuplicateRole(canonicalRoot, request.role, options, null);
|
|
1524
|
+
const { canonicalRoot, modelArgv, worktreeTarget } = resolveAttendedElectronPrerequisites(request, context, options);
|
|
1525
|
+
await observeDuplicateRole(worktreeTarget ? worktreeTarget.worktreePath : canonicalRoot, request.role, options, null);
|
|
1526
|
+
revalidateSpawnAuthority(
|
|
1527
|
+
context.cwd, request.repository, canonicalRoot, request, worktreeTarget, options,
|
|
1528
|
+
"during the preflight agent-list observation",
|
|
1529
|
+
);
|
|
980
1530
|
return {
|
|
981
1531
|
schema: HERDR_LIFECYCLE_SCHEMA,
|
|
982
1532
|
ok: true,
|
|
@@ -985,6 +1535,9 @@ export async function preflightAttendedElectronSpawn(params, context, options =
|
|
|
985
1535
|
placement: "tab",
|
|
986
1536
|
role: request.role,
|
|
987
1537
|
repository: canonicalRoot,
|
|
1538
|
+
...(worktreeTarget
|
|
1539
|
+
? { worktreePath: worktreeTarget.worktreePath, leaseId: worktreeTarget.leaseId }
|
|
1540
|
+
: {}),
|
|
988
1541
|
modelArgv: [...modelArgv],
|
|
989
1542
|
nonAuthorizing: true,
|
|
990
1543
|
authorityCreated: false,
|
|
@@ -994,13 +1547,19 @@ export async function preflightAttendedElectronSpawn(params, context, options =
|
|
|
994
1547
|
}
|
|
995
1548
|
}
|
|
996
1549
|
|
|
997
|
-
function attendedElectronConfirmBody(role, modelArgv, canonicalRoot) {
|
|
1550
|
+
function attendedElectronConfirmBody(role, modelArgv, canonicalRoot, worktreeTarget = null) {
|
|
998
1551
|
return [
|
|
999
1552
|
"One guarded attended-Electron composite spawn: create one tab, then start and verify one Pi agent.",
|
|
1000
1553
|
`Role: ${role}`,
|
|
1001
1554
|
"Placement: individual tab (the Electron host has no Herdr session pane)",
|
|
1002
1555
|
`Model: ${modelArgv[1]} (argv: ${modelArgv.join(" ")})`,
|
|
1003
1556
|
`Repository: ${canonicalRoot}`,
|
|
1557
|
+
...(worktreeTarget
|
|
1558
|
+
? [
|
|
1559
|
+
`Worktree target: ${worktreeTarget.worktreePath}`,
|
|
1560
|
+
`Treehouse lease: ${worktreeTarget.leaseId}${worktreeTarget.leaseHolder ? ` (holder: ${worktreeTarget.leaseHolder})` : ""}`,
|
|
1561
|
+
]
|
|
1562
|
+
: []),
|
|
1004
1563
|
"No rollback is automatic; a failed step leaves the created state for human cleanup.",
|
|
1005
1564
|
].join("\n");
|
|
1006
1565
|
}
|
|
@@ -1016,7 +1575,11 @@ export async function executeHerdrSpawnWorkerAttendedElectron(params, context, o
|
|
|
1016
1575
|
// installed model roll. Duplicate live names are denied at spawn.
|
|
1017
1576
|
let canonicalRoot = resolveTrustedSpawnRepository(context.cwd, repository);
|
|
1018
1577
|
const modelArgv = resolveInstalledModel(model, options);
|
|
1019
|
-
|
|
1578
|
+
let worktreeTarget = request.worktreePath
|
|
1579
|
+
? resolveWorktreeSpawnTarget(canonicalRoot, request.worktreePath, options)
|
|
1580
|
+
: null;
|
|
1581
|
+
let spawnCwd = worktreeTarget ? worktreeTarget.worktreePath : canonicalRoot;
|
|
1582
|
+
await observeDuplicateRole(spawnCwd, role, options, signal);
|
|
1020
1583
|
|
|
1021
1584
|
// ONE native owner confirmation from the attended host. A denial is a
|
|
1022
1585
|
// stop; a throw is a structured refusal. Never re-asked, never retried.
|
|
@@ -1024,7 +1587,7 @@ export async function executeHerdrSpawnWorkerAttendedElectron(params, context, o
|
|
|
1024
1587
|
try {
|
|
1025
1588
|
confirmed = await context.ui.confirm(
|
|
1026
1589
|
"Spawn Herdr worker (attended Electron)",
|
|
1027
|
-
attendedElectronConfirmBody(role, modelArgv, canonicalRoot),
|
|
1590
|
+
attendedElectronConfirmBody(role, modelArgv, canonicalRoot, worktreeTarget),
|
|
1028
1591
|
);
|
|
1029
1592
|
} catch (error) {
|
|
1030
1593
|
throw lifecycleError(
|
|
@@ -1040,11 +1603,10 @@ export async function executeHerdrSpawnWorkerAttendedElectron(params, context, o
|
|
|
1040
1603
|
// Revalidation point after confirmation, before the first mutation. A
|
|
1041
1604
|
// change here is a post-confirmation race: the caller reports it as a
|
|
1042
1605
|
// refusal; nothing has been mutated yet.
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
canonicalRoot = reconfirmedRoot;
|
|
1606
|
+
canonicalRoot = revalidateSpawnAuthority(
|
|
1607
|
+
context.cwd, repository, canonicalRoot, request, worktreeTarget, options, "after confirmation",
|
|
1608
|
+
);
|
|
1609
|
+
if (request.worktreePath) spawnCwd = worktreeTarget.worktreePath;
|
|
1048
1610
|
|
|
1049
1611
|
// Tab topology only: no coordinator pane identity is resolved, used, or
|
|
1050
1612
|
// manufactured anywhere on this path.
|
|
@@ -1052,7 +1614,7 @@ export async function executeHerdrSpawnWorkerAttendedElectron(params, context, o
|
|
|
1052
1614
|
let rootPaneId;
|
|
1053
1615
|
let layoutRaw;
|
|
1054
1616
|
try {
|
|
1055
|
-
layoutRaw = await invokeHerdr(tabCreateArgv(
|
|
1617
|
+
layoutRaw = await invokeHerdr(tabCreateArgv(spawnCwd, role), spawnCwd, options, signal);
|
|
1056
1618
|
({ rootPaneId, tabId } = parseTabCreate(layoutRaw));
|
|
1057
1619
|
} catch (error) {
|
|
1058
1620
|
return partialResult(error, {
|
|
@@ -1063,39 +1625,40 @@ export async function executeHerdrSpawnWorkerAttendedElectron(params, context, o
|
|
|
1063
1625
|
|
|
1064
1626
|
let agentStartSucceeded = false;
|
|
1065
1627
|
try {
|
|
1066
|
-
// Re-check the
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
}
|
|
1628
|
+
// Re-check registry authorization and the bound target between mutations.
|
|
1629
|
+
canonicalRoot = revalidateSpawnAuthority(
|
|
1630
|
+
context.cwd, repository, canonicalRoot, request, worktreeTarget, options, "between mutations",
|
|
1631
|
+
);
|
|
1071
1632
|
|
|
1072
1633
|
// Start exactly once in the tab's root pane.
|
|
1073
1634
|
const startCall = startArgv(role, rootPaneId, modelArgv);
|
|
1074
1635
|
let startRaw;
|
|
1075
1636
|
try {
|
|
1076
|
-
startRaw = await invokeHerdr(startCall,
|
|
1637
|
+
startRaw = await invokeHerdr(startCall, spawnCwd, options, signal);
|
|
1077
1638
|
agentStartSucceeded = true;
|
|
1078
1639
|
} catch (error) {
|
|
1079
1640
|
throw lifecycleError("start_failed", error?.message || "Herdr agent start failed");
|
|
1080
1641
|
}
|
|
1081
1642
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
}
|
|
1086
|
-
canonicalRoot = postStartRoot;
|
|
1643
|
+
canonicalRoot = revalidateSpawnAuthority(
|
|
1644
|
+
context.cwd, repository, canonicalRoot, request, worktreeTarget, options, "after agent start",
|
|
1645
|
+
);
|
|
1087
1646
|
|
|
1088
|
-
const startedPaneId = readBackStart(startRaw, role,
|
|
1647
|
+
const startedPaneId = readBackStart(startRaw, role, spawnCwd, [...modelArgv]);
|
|
1089
1648
|
if (startedPaneId !== rootPaneId) {
|
|
1090
1649
|
throw lifecycleError("pane_mismatch", "agent start returned a different pane identity");
|
|
1091
1650
|
}
|
|
1092
|
-
const agentRaw = await invokeHerdr(["agent", "get", role],
|
|
1093
|
-
const agentReadBack = readBackAgent(agentRaw, role,
|
|
1651
|
+
const agentRaw = await invokeHerdr(["agent", "get", role], spawnCwd, options, signal);
|
|
1652
|
+
const agentReadBack = readBackAgent(agentRaw, role, spawnCwd, [...modelArgv]);
|
|
1094
1653
|
if (agentReadBack.paneId !== rootPaneId) {
|
|
1095
1654
|
throw lifecycleError("pane_mismatch", "agent get returned a different pane identity");
|
|
1096
1655
|
}
|
|
1097
|
-
const paneRaw = await invokeHerdr(["pane", "get", rootPaneId],
|
|
1098
|
-
readBackPane(paneRaw, rootPaneId,
|
|
1656
|
+
const paneRaw = await invokeHerdr(["pane", "get", rootPaneId], spawnCwd, options, signal);
|
|
1657
|
+
readBackPane(paneRaw, rootPaneId, spawnCwd);
|
|
1658
|
+
revalidateSpawnAuthority(
|
|
1659
|
+
context.cwd, repository, canonicalRoot, request, worktreeTarget, options,
|
|
1660
|
+
"during the final read-backs",
|
|
1661
|
+
);
|
|
1099
1662
|
|
|
1100
1663
|
return {
|
|
1101
1664
|
schema: HERDR_LIFECYCLE_SCHEMA,
|
|
@@ -1111,6 +1674,9 @@ export async function executeHerdrSpawnWorkerAttendedElectron(params, context, o
|
|
|
1111
1674
|
tabId,
|
|
1112
1675
|
modelArgv: [...modelArgv],
|
|
1113
1676
|
repository: canonicalRoot,
|
|
1677
|
+
...(worktreeTarget
|
|
1678
|
+
? { worktreePath: worktreeTarget.worktreePath, leaseId: worktreeTarget.leaseId }
|
|
1679
|
+
: {}),
|
|
1114
1680
|
hashes: Object.freeze({
|
|
1115
1681
|
request: sha256(JSON.stringify(request)),
|
|
1116
1682
|
layoutCreateResponse: sha256(layoutRaw),
|
|
@@ -1132,9 +1698,92 @@ export async function executeHerdrSpawnWorkerAttendedElectron(params, context, o
|
|
|
1132
1698
|
}
|
|
1133
1699
|
}
|
|
1134
1700
|
|
|
1701
|
+
export async function executeHerdrRegisterRepository({ name, ledgerId }, context, options = {}) {
|
|
1702
|
+
const refusal = (reason, tier = 2) => ({ schema: HERDR_LIFECYCLE_SCHEMA, ok: false, code: "registration_refused", reason, ...needsOwner(tier, [reason], name), nonAuthorizing: true });
|
|
1703
|
+
if (typeof name !== "string" || !SAFE_NAME_REGEXP.test(name) || name.length > 64 || !ledgerId || typeof ledgerId !== "string") return refusal("valid repository name and ledger id");
|
|
1704
|
+
const profile = resolveProfileConfigPath(".").base;
|
|
1705
|
+
let config;
|
|
1706
|
+
try { config = JSON.parse(readFileSync(resolve(profile, "config/relay-authority.v1.json"), "utf8")); }
|
|
1707
|
+
catch { return refusal("trusted relayer configuration"); }
|
|
1708
|
+
const owner = config.githubOwner;
|
|
1709
|
+
if (typeof owner !== "string" || !/^[a-z][a-z0-9-]{0,38}$/.test(owner)) return refusal("configured GitHub owner");
|
|
1710
|
+
if (config.denyRepositories?.some((pattern) => pattern === name || (pattern.endsWith("*") && name.startsWith(pattern.slice(0, -1))))) return refusal("repository excluded by policy", 3);
|
|
1711
|
+
let root;
|
|
1712
|
+
let registryWritten = false;
|
|
1713
|
+
try {
|
|
1714
|
+
const coordinator = realpathSync(context.cwd);
|
|
1715
|
+
root = resolve(coordinator, "..", name);
|
|
1716
|
+
if (lstatSync(root).isSymbolicLink() || !lstatSync(root).isDirectory() || realpathSync(root) !== root || gitRoot(root) !== root) return refusal("canonical sibling Git clone");
|
|
1717
|
+
const dotGit = join(root, ".git");
|
|
1718
|
+
if (!lstatSync(dotGit).isDirectory() || realpathSync(dotGit) !== dotGit) return refusal("verified origin and non-worktree clone");
|
|
1719
|
+
const primaryIdentity = statIdentity(root, "registration_refused", "registration clone");
|
|
1720
|
+
const gitIdentity = statIdentity(dotGit, "registration_refused", "registration Git directory");
|
|
1721
|
+
let common;
|
|
1722
|
+
let origin;
|
|
1723
|
+
try {
|
|
1724
|
+
common = gitProbe(root, ["rev-parse", "--git-common-dir"]);
|
|
1725
|
+
origin = gitProbe(root, ["remote", "get-url", "origin"]);
|
|
1726
|
+
} catch { return refusal("verified origin and non-worktree clone"); }
|
|
1727
|
+
if (common !== ".git" || ![`git@github.com:${owner}/${name}`, `https://github.com/${owner}/${name}`].includes(origin.replace(/\.git$/, ""))) return refusal("verified origin and non-worktree clone");
|
|
1728
|
+
const gh = (options.runGh ?? spawnSync)("gh", ["repo", "view", `${owner}/${name}`, "--json", "nameWithOwner,visibility"], { encoding: "utf8", timeout: 15000 });
|
|
1729
|
+
const repo = JSON.parse(gh.stdout);
|
|
1730
|
+
if (gh.status !== 0 || repo.nameWithOwner !== `${owner}/${name}` || repo.visibility !== "PRIVATE") return refusal("verified private repository", 3);
|
|
1731
|
+
const registryPath = resolve(profile, WORKER_REPOSITORY_REGISTRY);
|
|
1732
|
+
const repositories = validateRegistry(registryPath);
|
|
1733
|
+
authorizeApproval(profile, ledgerId, { tier: 2, repository: name, action: "register" });
|
|
1734
|
+
if (repositories.includes(name)) return { schema: HERDR_LIFECYCLE_SCHEMA, ok: true, status: "already-registered", name };
|
|
1735
|
+
if (repositories.length >= 32) return refusal("registry capacity");
|
|
1736
|
+
const result = await reserveApprovalUse(profile, ledgerId, { tier: 2, repository: name, action: "register" }, () => {
|
|
1737
|
+
const current = validateRegistry(registryPath);
|
|
1738
|
+
if (current.includes(name)) return { schema: HERDR_LIFECYCLE_SCHEMA, ok: true, status: "already-registered", name };
|
|
1739
|
+
if (current.length >= 32) throw lifecycleError("registry_capacity", "registry capacity reached");
|
|
1740
|
+
// Reservation can wait on the ledger lock. Reobserve the same physical
|
|
1741
|
+
// primary clone and current trust here, with no await before the write.
|
|
1742
|
+
const currentConfig = JSON.parse(readFileSync(resolve(profile, "config/relay-authority.v1.json"), "utf8"));
|
|
1743
|
+
if (currentConfig.githubOwner !== owner) return refusal("configured GitHub owner changed");
|
|
1744
|
+
if (currentConfig.denyRepositories?.some((pattern) => pattern === name || (pattern.endsWith("*") && name.startsWith(pattern.slice(0, -1))))) return refusal("repository excluded by policy", 3);
|
|
1745
|
+
const currentGh = (options.runGh ?? spawnSync)("gh", ["repo", "view", `${owner}/${name}`, "--json", "nameWithOwner,visibility"], { encoding: "utf8", timeout: 15000 });
|
|
1746
|
+
const currentRepo = JSON.parse(currentGh.stdout);
|
|
1747
|
+
if (currentGh.status !== 0 || currentRepo.nameWithOwner !== `${owner}/${name}` || currentRepo.visibility !== "PRIVATE") return refusal("verified private repository", 3);
|
|
1748
|
+
if (lstatSync(root).isSymbolicLink() || realpathSync(root) !== root
|
|
1749
|
+
|| statIdentity(root, "registration_refused", "registration clone") !== primaryIdentity
|
|
1750
|
+
|| !lstatSync(dotGit).isDirectory() || realpathSync(dotGit) !== dotGit
|
|
1751
|
+
|| statIdentity(dotGit, "registration_refused", "registration Git directory") !== gitIdentity
|
|
1752
|
+
|| gitRoot(root) !== root) return refusal("canonical sibling Git clone changed");
|
|
1753
|
+
if (gitProbe(root, ["rev-parse", "--git-common-dir"]) !== common
|
|
1754
|
+
|| gitProbe(root, ["remote", "get-url", "origin"]) !== origin) return refusal("verified origin and non-worktree clone changed");
|
|
1755
|
+
copyFileSync(registryPath, `${registryPath}.${new Date().toISOString().replaceAll(":", "-")}.bak`, fsConstants.COPYFILE_EXCL);
|
|
1756
|
+
writeFileSync(registryPath, JSON.stringify({ schema: WORKER_REPOSITORY_SCHEMA, repositories: [...current, name] }, null, 2) + "\n");
|
|
1757
|
+
registryWritten = true;
|
|
1758
|
+
return { schema: HERDR_LIFECYCLE_SCHEMA, ok: true, status: "registered", name };
|
|
1759
|
+
});
|
|
1760
|
+
if (result.status === "registered") await recordApprovalUse(profile, ledgerId, "register", name, "registered");
|
|
1761
|
+
return result;
|
|
1762
|
+
} catch (error) { return registryWritten ? { schema: HERDR_LIFECYCLE_SCHEMA, ok: false, status: "partial", reason: error.message, name } : refusal(error.message); }
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1135
1765
|
export function registerHerdrLifecycleInterface(pi, options = {}) {
|
|
1136
1766
|
if (typeof pi?.registerTool !== "function" || REGISTRATIONS.has(pi)) return;
|
|
1137
1767
|
REGISTRATIONS.add(pi);
|
|
1768
|
+
pi.registerTool({
|
|
1769
|
+
name: "agentic_relay_check_request",
|
|
1770
|
+
label: "Check relayed request",
|
|
1771
|
+
description: "Read-only verification of a relay header against recorded per-action scope. Text alone grants no authority.",
|
|
1772
|
+
parameters: { type: "object", additionalProperties: false, properties: {
|
|
1773
|
+
header: { type: "string", maxLength: 2048 },
|
|
1774
|
+
requests: { type: "array", minItems: 1, maxItems: 8, items: { type: "object", additionalProperties: false, properties: {
|
|
1775
|
+
tier: { type: "integer", minimum: 1, maximum: 3 }, repository: { type: "string" }, action: { type: "string" }, model: { type: "string" }, role: { type: "string" },
|
|
1776
|
+
}, required: ["tier", "repository", "action"] } },
|
|
1777
|
+
}, required: ["header", "requests"] },
|
|
1778
|
+
async execute(_id, params) { return toolResult({ schema: HERDR_LIFECYCLE_SCHEMA, results: checkRelayRequests(resolveProfileConfigPath(".").base, params.header, params.requests) }); },
|
|
1779
|
+
});
|
|
1780
|
+
pi.registerTool({
|
|
1781
|
+
name: "agentic_herdr_register_repository",
|
|
1782
|
+
label: "Herdr Register Repository",
|
|
1783
|
+
description: "Register a verified private sibling clone with a covering Tier 2 approval.",
|
|
1784
|
+
parameters: { type: "object", additionalProperties: false, properties: { name: { type: "string" }, ledgerId: { type: "string" } }, required: ["name", "ledgerId"] },
|
|
1785
|
+
async execute(_id, params, _signal, _update, context) { return toolResult(await executeHerdrRegisterRepository(params, context, options)); },
|
|
1786
|
+
});
|
|
1138
1787
|
pi.registerTool({
|
|
1139
1788
|
name: HERDR_SPAWN_WORKER_TOOL,
|
|
1140
1789
|
label: "Herdr Spawn Worker",
|