@staix/agent-hub 0.18.0 → 0.19.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/CHANGELOG.md +19 -0
- package/README.md +4 -4
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +10 -8
- package/docs/agent-notes/daemon.md +5 -2
- package/docs/agent-notes/local-worker.md +2 -1
- package/docs/agent-notes/tasks.md +4 -1
- package/docs/agent-notes/tests.md +6 -3
- package/docs/dogfood.md +9 -3
- package/docs/memory.md +1 -1
- package/docs/operations.md +66 -28
- package/docs/quickstart.md +9 -5
- package/docs/security.md +22 -13
- package/docs/smoke.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +16 -5
- package/docs/verification/2026-10-25-remote-docker-transport.md +10 -0
- package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
- package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
- package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
- package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
- package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
- package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
- package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
- package/docs/verification/2026-10-27-0.19.0.md +86 -0
- package/docs/verification/claude-permission-transport-native.py +187 -0
- package/docs/verification/claude-shell-wrapper-host.ts +20 -0
- package/docs/verification/claude-shell-wrapper-native.py +145 -0
- package/docs/verification/pi-140-model-free.ts +57 -0
- package/docs/verification/pi-140-native.mjs +21 -0
- package/docs/verified.json +14 -14
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +273 -68
- package/src/adapters/agy/entry.ts +1 -1
- package/src/adapters/claude-channel.ts +7 -3
- package/src/adapters/claude-implementer.ts +5 -3
- package/src/adapters/compat.ts +32 -18
- package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
- package/src/adapters/definitions/builtin-metadata.ts +2 -2
- package/src/adapters/definitions/native-factories.ts +7 -2
- package/src/adapters/definitions/reviewer-variants.ts +2 -2
- package/src/adapters/pi.ts +39 -3
- package/src/adapters/qwen-dual.ts +25 -0
- package/src/adapters/registry-types.ts +6 -2
- package/src/adapters/registry.ts +1 -0
- package/src/cli/agents.ts +69 -7
- package/src/cli/claude-shell-wrapper.ts +54 -0
- package/src/cli/main.ts +30 -24
- package/src/cli/output.ts +4 -0
- package/src/hub/agent-drift.ts +61 -0
- package/src/hub/board.ts +14 -2
- package/src/hub/check-runner.ts +148 -18
- package/src/hub/claude-shell-wrapper.ts +349 -0
- package/src/hub/config-edit.ts +2 -2
- package/src/hub/config-tier.ts +2 -2
- package/src/hub/daemon.ts +197 -35
- package/src/hub/host-executable.ts +20 -1
- package/src/hub/instances.ts +18 -2
- package/src/hub/lifecycle.ts +1 -1
- package/src/hub/operational-authority.ts +7 -0
- package/src/hub/operational-view.ts +6 -10
- package/src/hub/read-only-shell-proof.ts +7 -6
- package/src/hub/review-scope.ts +13 -0
- package/src/hub/sdlc-metrics.ts +6 -4
- package/src/hub/task-shell-command.ts +85 -0
- package/src/hub/task-shell-default.ts +78 -0
- package/src/hub/task-shell-proof.ts +171 -0
- package/src/hub/task-shell-sandbox.ts +61 -0
- package/src/hub/tasks.ts +139 -13
- package/src/local/deny.ts +91 -11
- package/src/local/sandbox.ts +67 -28
- package/src/local/shell-environment.ts +10 -0
- package/src/ui/index.html +1 -0
package/src/hub/daemon.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { operationalRosterSetting, operationalSetting, operationalRefusal, operationalRequestLabel } from "./operational-authority.ts";
|
|
1
|
+
import { OperationalAuthorityError, OPERATIONAL_PERSON_GUIDANCE, operationalRosterSetting, operationalSetting, operationalRefusal, operationalRequestLabel } from "./operational-authority.ts";
|
|
2
2
|
import { operationalView, redactBusEvent } from "./operational-view.ts";
|
|
3
3
|
import { inspectSelectedOrphans, stopSelectedOrphan, orphanProcessPorts } from "./orphan-process.ts";
|
|
4
4
|
import { dogfoodLines } from "./dogfood-read.ts";
|
|
@@ -22,7 +22,8 @@ import { NativeHookLaunches } from "./native-hook-launch.ts";
|
|
|
22
22
|
import { readReleaseHostAdmission, type ReleaseHostAdmission } from "./release-admission.ts";
|
|
23
23
|
import { ContainerRunner, RemoteRunner, remoteRunnerConfig, type RemoteRunnerConfig, type RunnerOptions, type RunReceipt } from "./check-runner.ts";
|
|
24
24
|
import { fetchSpec, specAvailable, specRefs } from "./spec-read.ts";
|
|
25
|
-
import { isDenied } from "../local/deny.ts";
|
|
25
|
+
import { isDenied, sbplString } from "../local/deny.ts";
|
|
26
|
+
import { hostShellPath } from "./host-executable.ts";
|
|
26
27
|
import { supersededDeliveryReason, deliveryTaskContext } from "./delivery-superseded.ts";
|
|
27
28
|
import { renderHeldHint } from "./delivery-hints.ts";
|
|
28
29
|
import { correctionInput, MemoryTelemetry } from "./memory-candidates.ts";
|
|
@@ -42,7 +43,9 @@ import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, typ
|
|
|
42
43
|
import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
|
|
43
44
|
import { ActivityRing } from "./activity.ts";
|
|
44
45
|
import { JcodeOptionMismatch, isJcodePeer, jcodeVersionAsync, type JcodePeer } from "../adapters/jcode.ts";
|
|
45
|
-
import { VERSION_REFUSAL, describe, isCompatId } from "../adapters/compat.ts";
|
|
46
|
+
import { VERSION_REFUSAL, configuredCommands, describe, isCompatId } from "../adapters/compat.ts";
|
|
47
|
+
import { agentVersionsAsync, type AgentVersionRow } from "../cli/agents.ts";
|
|
48
|
+
import { driftedAgents, noticeDrift, type DriftedAgent } from "./agent-drift.ts";
|
|
46
49
|
import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
|
|
47
50
|
import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
|
|
48
51
|
import { claudePeer as defaultChannelPeer, claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
|
|
@@ -52,6 +55,7 @@ import { RecoveryDeferredHubError } from "./deferred-hub.ts";
|
|
|
52
55
|
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
53
56
|
import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf, createProjectAdapterCatalog, isFixedBuiltinDefinition } from "../adapters/registry.ts";
|
|
54
57
|
import { isSupportedPeer as registrySupportedPeer, definitionOf as builtinDefinitionOf, KNOWN_PEERS as BASE_KNOWN_PEERS, SUPPORTED_PEERS as BASE_SUPPORTED_PEERS, STARTABLE_PEERS as BASE_STARTABLE_PEERS } from "../adapters/registry.ts";
|
|
58
|
+
import type { ReviewScope } from "./review-scope.ts";
|
|
55
59
|
import { checkJcodeInstanceHomes, instanceDefinitionOf, effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
|
|
56
60
|
import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
|
|
57
61
|
import { instanceBudgetHooks, projectBudgetHeadroom } from "./instance-budget.ts";
|
|
@@ -82,7 +86,7 @@ import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscr
|
|
|
82
86
|
import type { ServerWebSocket } from "bun";
|
|
83
87
|
import { isAcpPeer, type AcpPeer, QwenIdentityError, ACTION_BUDGET_OPTION, type PermissionRequest, type ActionGateVerdict } from "../adapters/acp.ts";
|
|
84
88
|
import { isCodexPeer, type CodexPeer } from "../adapters/codex-appserver.ts";
|
|
85
|
-
import { isPiPeer, type PiPeer } from "../adapters/pi.ts";
|
|
89
|
+
import { isPiPeer, PiVersionRefusal, type PiPeer } from "../adapters/pi.ts";
|
|
86
90
|
import { isQwenDualPeer, qwenDualVersionAsync, type QwenDualPeer } from "../adapters/qwen-dual.ts";
|
|
87
91
|
import { startModelRelay, type ModelRelay } from "../models/relay.ts";
|
|
88
92
|
import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
|
|
@@ -90,10 +94,15 @@ import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
|
|
|
90
94
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
91
95
|
import { processSignature } from "../pi/process-signature.ts";
|
|
92
96
|
import { PiSessionBookmarkMismatch, PiSessionUnresumable, recordPiReplacement, validatePiSession } from "../pi/session.ts";
|
|
93
|
-
import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
97
|
+
import { profile, proxyEnv, sandboxAvailable, type SandboxNetwork } from "../local/sandbox.ts";
|
|
94
98
|
import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
|
|
95
99
|
import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext, type ShellPermitPolicy } from "../local/tools.ts";
|
|
96
100
|
import { proveReadOnlyShell, ShellExecutionProofs } from "./read-only-shell-proof.ts";
|
|
101
|
+
import { createHostShellWrapper, type HostShellWrapper } from "./claude-shell-wrapper.ts";
|
|
102
|
+
import { readTaskShellDefaultBranch, readTaskShellBinding, proveTaskShell } from "./task-shell-proof.ts";
|
|
103
|
+
import { taskShellSandboxRestrictions } from "./task-shell-sandbox.ts";
|
|
104
|
+
import { hostShellEnvironment } from "../local/shell-environment.ts";
|
|
105
|
+
import { hubGitArgv, hubGitEnv } from "./git.ts";
|
|
97
106
|
import { isLocalPeer, type LocalPeer } from "../adapters/local-worker.ts";
|
|
98
107
|
import { isReviewerPrintPeer, reviewerNoVerdictLine, reviewerUnavailableReason, ReviewerUnavailableError, REVIEWER_FALLBACK_UNCONFIRMED, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
|
|
99
108
|
import { createReviewerMcpSession, type ReviewerToolName } from "../adapters/reviewer-mcp.ts";
|
|
@@ -523,6 +532,8 @@ function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
|
|
|
523
532
|
export interface DaemonOptions {
|
|
524
533
|
/** Host-only test/embedding port; never read from project configuration or a tool. */
|
|
525
534
|
memoryClient?: MemoryClient;
|
|
535
|
+
/** Host-only remote-default evidence seam; never read from config or control messages. */
|
|
536
|
+
shellDefaultBranchProbe?: typeof readTaskShellDefaultBranch;
|
|
526
537
|
/** Host-only snapshot/probe seams; never read from project config or tool arguments. */
|
|
527
538
|
snapshot?: typeof snapshot;
|
|
528
539
|
snapshotBurstMs?: number;
|
|
@@ -558,6 +569,13 @@ export interface DaemonOptions {
|
|
|
558
569
|
taskSweepNow?: () => number;
|
|
559
570
|
/** Test seam: the period of the SDLC band evaluation, default five minutes. */
|
|
560
571
|
bandsEveryMs?: number;
|
|
572
|
+
/**
|
|
573
|
+
* #843 AC3: read each gated agent CLI's installed version at start and daily, and tell the planner about drift.
|
|
574
|
+
* `true` uses the same probe as `ahub agents` (the product sets it); a function replaces the probe (tests). Off by default.
|
|
575
|
+
*/
|
|
576
|
+
agentDrift?: boolean | (() => Promise<readonly AgentVersionRow[]>);
|
|
577
|
+
/** Test seam: the period of the agent drift sweep, default one day. */
|
|
578
|
+
agentDriftEveryMs?: number;
|
|
561
579
|
/** Test seam: an extra hold on the SDLC band evaluation, like a storage error. */
|
|
562
580
|
bandsHeld?: () => boolean;
|
|
563
581
|
/** How long a research record waits after an approval for the task's open turns to end (#247). Tests shrink this. */
|
|
@@ -720,12 +738,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
720
738
|
mkdirSync(opts.stateDir, { recursive: true });
|
|
721
739
|
const operationalLogSubscribers=new Map<Sock,()=>void>();
|
|
722
740
|
const operationalContext=new AsyncLocalStorage<{actor:string;current:()=>void}|undefined>();
|
|
723
|
-
const checkOperational=(authority:SettingAuthority)=>{if(authority==="conductor"){const scope=operationalContext.getStore();if(!scope)throw new
|
|
741
|
+
const checkOperational=(authority:SettingAuthority)=>{if(authority==="conductor"){const scope=operationalContext.getStore();if(!scope)throw new OperationalAuthorityError("operational conductor authority unavailable");scope.current();}};
|
|
724
742
|
const logFile = join(opts.stateDir, "hub.log");
|
|
725
743
|
// The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
|
|
726
744
|
const log = (line: string) => { const text=`${new Date().toISOString()} ${line}`;try { appendFileSync(logFile, `${text}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ }
|
|
727
|
-
let publicLine:string|undefined;
|
|
728
|
-
for(const [sock,current] of operationalLogSubscribers)try {current();if(sock.getBufferedAmount()>64*1024)throw new Error("slow log consumer");
|
|
745
|
+
let publicLine:string|undefined,view:ReturnType<typeof operationalView>|undefined;
|
|
746
|
+
for(const [sock,current] of operationalLogSubscribers)try {current();if(sock.getBufferedAmount()>64*1024)throw new Error("slow log consumer");view??=publicOperationalView();publicLine??=view.line(text);sock.send(JSON.stringify({t:"operational_log",line:publicLine,complete:view.complete,...(!view.complete?{incompleteReason:view.incompleteReason}:{})}));}catch{operationalLogSubscribers.delete(sock);try{sock.close(4403,"operational log authority ended");}catch{/* The subscription is already gone. */}}
|
|
729
747
|
};
|
|
730
748
|
let runningRouting = currentRouting(opts.cwd, log);
|
|
731
749
|
let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd, externalAdapters);
|
|
@@ -1021,13 +1039,24 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1021
1039
|
if (refused) log(`limits: ${env.from}: ${refused}`);
|
|
1022
1040
|
return refused;
|
|
1023
1041
|
};
|
|
1042
|
+
/**
|
|
1043
|
+
* #852: what a reviewer reads. A fresh-reviewer instance's own `review_scope` (config.local.json only, tier 3) wins, else its definition's
|
|
1044
|
+
* scope (the sandboxed codex and kimi reviewers are `code`), else `full`. Only a review-only instance has one; any other peer is `full`.
|
|
1045
|
+
*/
|
|
1046
|
+
const reviewScopeOf = (peer: string): ReviewScope => {
|
|
1047
|
+
const declared = Object.hasOwn(declarations, peer) ? declarations[peer]!.review_scope : undefined;
|
|
1048
|
+
if (declared) return declared;
|
|
1049
|
+
return reviewOnlyPeer(peer) && definitionOf(peer)?.reviewer?.scope === "code" ? "code" : "full";
|
|
1050
|
+
};
|
|
1024
1051
|
/**
|
|
1025
1052
|
* #685: the provider, model and profile names an extra Jcode instance was declared with, for status, doctor and the dashboard,
|
|
1026
1053
|
* exactly as the person wrote them (they authenticate inside Jcode). Never a URL, a token or the native home path.
|
|
1027
1054
|
*/
|
|
1028
1055
|
const jcodeModelSource = (id: string): { modelSource?: string } => {
|
|
1029
1056
|
const instance = declarations[id];
|
|
1030
|
-
if (!instance
|
|
1057
|
+
if (!instance) return {};
|
|
1058
|
+
// #844: a model exists on an instance only where validation admitted it (a headless implementer whose definition declares modelSelect).
|
|
1059
|
+
if (definitionOf(id)?.instances?.nativeHome !== true) return instance.model ? { modelSource: instance.model } : {};
|
|
1031
1060
|
return { modelSource: [instance.provider, instance.model, instance.provider_profile && `profile ${instance.provider_profile}`].filter(Boolean).join(" ") || "native default" };
|
|
1032
1061
|
};
|
|
1033
1062
|
/**
|
|
@@ -1591,6 +1620,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1591
1620
|
instances: () => declarations,
|
|
1592
1621
|
adapterCatalog,
|
|
1593
1622
|
reviewOnly: reviewOnlyPeer,
|
|
1623
|
+
reviewScope: reviewScopeOf,
|
|
1594
1624
|
// #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
|
|
1595
1625
|
reviewAuthority: runningReviewAuthority,
|
|
1596
1626
|
requestAuthority: by => {
|
|
@@ -2273,6 +2303,86 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2273
2303
|
const binding = c.toolsOwner, launch = currentImplementerTools(c);
|
|
2274
2304
|
return () => c.toolsOwner === binding && !!launch && currentImplementerTools(c) === launch;
|
|
2275
2305
|
}
|
|
2306
|
+
const shellProjectRepository = githubOriginRepository(opts.cwd);
|
|
2307
|
+
const shellNeedsDefault = Object.keys(declarations).some(peer => instanceDefinitionOf(peer, declarations, adapterCatalog)?.start.daemonPort === "claude-implementer");
|
|
2308
|
+
const shellDefaultBranch = shellNeedsDefault && shellProjectRepository
|
|
2309
|
+
? await (opts.shellDefaultBranchProbe ?? readTaskShellDefaultBranch)(opts.cwd, hostShellEnvironment({ PATH: hostShellPath(opts.cwd, opts.cwd) ?? "" }), () => true, opts.cwd, shellProjectRepository)
|
|
2310
|
+
: undefined;
|
|
2311
|
+
let implementerShellStart: Promise<HostShellWrapper> | undefined;
|
|
2312
|
+
const implementerShellProfiles = new WeakMap<object, { key: string; sandbox: { profile: string; readOnlyData: boolean } }>();
|
|
2313
|
+
async function automaticImplementerShell(c: Client, tool: string, original: Record<string, unknown>, current: () => boolean): Promise<{ command: string; timeout: number; description: string } | undefined> {
|
|
2314
|
+
const peer = c.peer, launch = currentImplementerTools(c), root = peer && ownerWriteRoot(peer), policy = config;
|
|
2315
|
+
if (!peer || !launch || !root || launch.task === undefined || tool !== "Bash" || permissionMode(peer) !== "ask-when-needed") return;
|
|
2316
|
+
if (Object.keys(original).some(key => !["command", "description", "timeout"].includes(key)) || typeof original.command !== "string"
|
|
2317
|
+
|| original.description !== undefined && (typeof original.description !== "string" || original.description.length > 2000)
|
|
2318
|
+
|| original.timeout !== undefined && (!Number.isSafeInteger(original.timeout) || Number(original.timeout) < 1 || Number(original.timeout) > 600_000)) return;
|
|
2319
|
+
const input = Object.freeze({ ...original }), serialized = JSON.stringify(input);
|
|
2320
|
+
if (serialized.length > 8000 || !tasks.publicMetadataText(serialized)) return;
|
|
2321
|
+
const path = hostShellPath(opts.cwd, root);
|
|
2322
|
+
if (!path) return;
|
|
2323
|
+
const taskId = launch.task, extra = Object.freeze({ ...proxyEnv(sandboxNetwork), AGENTHUB_PEER_ID: peer, PATH: path });
|
|
2324
|
+
const env = hostShellEnvironment(extra), environment = JSON.stringify(env);
|
|
2325
|
+
const policyState = () => JSON.stringify([config.local.deny, config.local.read_allow, config.data?.readonly, config.protect_instruction_files]);
|
|
2326
|
+
const policyFingerprint = policyState();
|
|
2327
|
+
const authority = () => {
|
|
2328
|
+
const task = board.get(taskId);
|
|
2329
|
+
return current() && config === policy && policyState() === policyFingerprint && permissionMode(peer) === "ask-when-needed" && !contextPii(peer)
|
|
2330
|
+
&& !!task && task.owner === peer && ["in_progress", "changes_requested"].includes(task.state) && !tasks.isPii(task)
|
|
2331
|
+
&& claudeActionClasses(actionBudgets, tool, input, root).length === 0 && hostShellPath(opts.cwd, root) === path && JSON.stringify(hostShellEnvironment(extra)) === environment;
|
|
2332
|
+
};
|
|
2333
|
+
if (!authority()) return;
|
|
2334
|
+
const binding = readTaskShellBinding(root, env, authority, opts.cwd, shellDefaultBranch);
|
|
2335
|
+
if (!binding) return;
|
|
2336
|
+
const shellOptions = { root, projectRoot: opts.cwd, deny: config.local.deny, readOnly: config.data?.readonly, env: extra };
|
|
2337
|
+
const taskOptions = { ...shellOptions, env, binding, protectInstructions: config.protect_instruction_files, repository: shellProjectRepository, publicText: (text: string) => tasks.publicMetadataText(text) };
|
|
2338
|
+
const readProof = proveReadOnlyShell(input, shellOptions, authority), taskProof = readProof ? undefined : proveTaskShell(input.command, taskOptions, authority);
|
|
2339
|
+
const confined = !!readProof || taskProof?.requiresSandbox === true;
|
|
2340
|
+
if (confined && !sandboxAvailable()) return;
|
|
2341
|
+
// The planner's fallback binds only on a task-gate request; read-only requests use an already recorded task branch.
|
|
2342
|
+
if (board.get(taskId)?.refs.branch === undefined && !taskProof) return;
|
|
2343
|
+
if (!readProof && !taskProof || !tasks.bindShellBranch(peer, taskId, { ...binding, worktree: root }, authority)) return;
|
|
2344
|
+
const fingerprint = (readProof ?? taskProof)!.fingerprint;
|
|
2345
|
+
const currentSandbox = () => {
|
|
2346
|
+
if (!confined) return undefined;
|
|
2347
|
+
const state = realPath(opts.stateDir);
|
|
2348
|
+
const owner = c.toolsOwner!.owner, key = JSON.stringify([root, state, policyFingerprint]), cached = implementerShellProfiles.get(owner);
|
|
2349
|
+
if (cached?.key === key) return cached.sandbox;
|
|
2350
|
+
// Cache only this synchronous proof burst: newly initialized submodules change concrete Git-directory protections.
|
|
2351
|
+
// ponytail: shared profile discovery is synchronous; move it to an asynchronous, deadline-bound inventory proof.
|
|
2352
|
+
// read_allow may include the canonical checkout. Its live control credentials remain denied independently of the worktree root.
|
|
2353
|
+
const restrictions = taskShellSandboxRestrictions(root, binding.branch, { projectRoot: opts.cwd, env });
|
|
2354
|
+
if (!restrictions) return undefined;
|
|
2355
|
+
const sandbox = { profile: `${profile(root, sandboxNetwork, config.local.read_allow, config.local.deny, config.data?.readonly)}\n${restrictions}\n(deny file-read* file-write* (subpath ${sbplString(state)}))`, readOnlyData: !!config.data?.readonly?.length };
|
|
2356
|
+
const entry = { key, sandbox };
|
|
2357
|
+
implementerShellProfiles.set(owner, entry);
|
|
2358
|
+
queueMicrotask(() => { if (implementerShellProfiles.get(owner) === entry) implementerShellProfiles.delete(owner); });
|
|
2359
|
+
return sandbox;
|
|
2360
|
+
};
|
|
2361
|
+
let sandbox: { profile: string; readOnlyData: boolean } | undefined;
|
|
2362
|
+
try { sandbox = currentSandbox(); } catch { return; }
|
|
2363
|
+
if (confined && !sandbox) return;
|
|
2364
|
+
const recheck = () => {
|
|
2365
|
+
if (!authority() || !tasks.bindShellBranch(peer, taskId, { ...binding, worktree: root }, authority)) return false;
|
|
2366
|
+
const observed = readTaskShellBinding(root, env, authority, opts.cwd, shellDefaultBranch);
|
|
2367
|
+
if (!observed || observed.branch !== binding.branch || observed.defaultBranch !== binding.defaultBranch) return false;
|
|
2368
|
+
const proof = readProof ? proveReadOnlyShell(input, shellOptions, authority) : proveTaskShell(input.command, taskOptions, authority);
|
|
2369
|
+
let observedSandbox: typeof sandbox;
|
|
2370
|
+
try { observedSandbox = currentSandbox(); } catch { return false; }
|
|
2371
|
+
return proof?.fingerprint === fingerprint && observedSandbox?.profile === sandbox?.profile && observedSandbox?.readOnlyData === sandbox?.readOnlyData && authority();
|
|
2372
|
+
};
|
|
2373
|
+
// A fixed capability on the channel request prevents an older plugin from ignoring updatedInput and running the original command.
|
|
2374
|
+
let wrapper: HostShellWrapper;
|
|
2375
|
+
try { wrapper = await (implementerShellStart ??= createHostShellWrapper().catch(error => { implementerShellStart = undefined; throw error; })); }
|
|
2376
|
+
catch { return; } // An unavailable host capability supplies no allow; the ordinary person path remains available.
|
|
2377
|
+
if (!recheck()) return;
|
|
2378
|
+
const proof = (readProof ?? taskProof)!, git = proof.program === "git";
|
|
2379
|
+
const argv = readProof?.program === "git" ? hubGitArgv([...proof.argv]).slice(1) : proof.argv;
|
|
2380
|
+
const command = wrapper.issue({ peer, task: taskId, generation: launch.generation, root, branch: binding.branch, executable: proof.executable, argv,
|
|
2381
|
+
env: taskProof?.env ?? (git ? Object.fromEntries(Object.entries(hubGitEnv(env, { repositoryIsolation: true })).filter((entry): entry is [string, string] => entry[1] !== undefined)) : env),
|
|
2382
|
+
signal: launch.signal, timeoutMs: Number(input.timeout ?? 120_000), current: recheck, activity: () => c.toolsOwner?.owner.keepApprovalAlive?.(), ...(sandbox ? { sandbox } : {}) });
|
|
2383
|
+
if (command) log(`permission auto-allowed for ${peer}: bash (host wrapper ${readProof ? "read-only shell" : "task command"})`);
|
|
2384
|
+
return command && { command: command.command, timeout: Math.min(600_000, Number(input.timeout ?? 120_000) + 2000), description: "Hub-owned task command" };
|
|
2385
|
+
}
|
|
2276
2386
|
function canonicalWriteScope(repo: Repo, budget?: SnapshotBudget): boolean | undefined {
|
|
2277
2387
|
// ponytail: D1 is inert for builtin owners while .worktrees exists (a deliberate false negative).
|
|
2278
2388
|
// Bind each owner to its real execution worktree before narrowing this unknown scope.
|
|
@@ -2376,10 +2486,36 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2376
2486
|
} catch { /* The audit event is retained even if its advisory notice cannot be delivered. */ }
|
|
2377
2487
|
}
|
|
2378
2488
|
}
|
|
2489
|
+
// #843 AC3: local agent CLI drift. One probe path (`agentVersionsAsync`, the one `ahub agents` shares), no network, no install.
|
|
2490
|
+
let agentDrift: DriftedAgent[] = [];
|
|
2491
|
+
let agentDriftRunning = false;
|
|
2492
|
+
// The first probe is owed until one has run: a daemon that starts in recovery skips it, and the tick runs it once the hold releases.
|
|
2493
|
+
let agentDriftOwed = true;
|
|
2494
|
+
// A planner that is not attached yet gets it from the tick as soon as it is; a recorded marker is never sent twice.
|
|
2495
|
+
const offerAgentDrift = () => noticeDrift(agentDrift, {
|
|
2496
|
+
record: marker => board.recordAgentDriftNotice(marker), forget: marker => board.forgetAgentDriftNotice(marker),
|
|
2497
|
+
planners: plannerPeers, isRostered: peer => bus.isRostered(peer), publish: env => bus.publish(env),
|
|
2498
|
+
});
|
|
2499
|
+
const sweepAgentDrift = async () => {
|
|
2500
|
+
const probe = opts.agentDrift === true ? () => agentVersionsAsync(configuredCommands(config)) : opts.agentDrift || undefined;
|
|
2501
|
+
if (!probe || agentDriftRunning || stopping || recoveryActive()) return;
|
|
2502
|
+
agentDriftRunning = true;
|
|
2503
|
+
agentDriftOwed = false;
|
|
2504
|
+
try {
|
|
2505
|
+
const drift = driftedAgents(await probe());
|
|
2506
|
+
if (stopping) return;
|
|
2507
|
+
agentDrift = drift;
|
|
2508
|
+
offerAgentDrift();
|
|
2509
|
+
writeStatus();
|
|
2510
|
+
} catch { log("agent CLI drift sweep failed; run ahub agents"); } finally { agentDriftRunning = false; }
|
|
2511
|
+
};
|
|
2379
2512
|
const intervals = [
|
|
2513
|
+
setInterval(() => { void sweepAgentDrift(); }, opts.agentDriftEveryMs ?? 86_400_000),
|
|
2380
2514
|
setInterval(() => {
|
|
2381
2515
|
void refreshRunner().catch(() => {});
|
|
2382
2516
|
try { tasks.flushPlannerNotices(); } catch { /* the board remains authoritative if notice delivery fails */ }
|
|
2517
|
+
if (agentDriftOwed && !stopping && !recoveryActive()) void sweepAgentDrift();
|
|
2518
|
+
if (agentDrift.length && !stopping && !recoveryActive()) try { offerAgentDrift(); } catch { /* the next tick tries again */ }
|
|
2383
2519
|
for (const pending of checkpointWaits.values()) if (!pending.valid()) pending.done(undefined);
|
|
2384
2520
|
for (const [id, request] of permissions) if (request.expiresAt > Date.now()) supervision.approvalWaiting({ id, peer: request.peer, tool: request.tool ?? "unknown", createdAt: request.createdAt });
|
|
2385
2521
|
for (const row of bus.queueList(undefined, true)) if (row.state === "needs_review") supervision.needsReview(row.peer, row.id);
|
|
@@ -3239,6 +3375,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3239
3375
|
pluginBundle: SERVER_JS,
|
|
3240
3376
|
pendingRestart: allPendingRestart(),
|
|
3241
3377
|
retainedConfig,
|
|
3378
|
+
...(agentDrift.length ? { agentDrift } : {}),
|
|
3242
3379
|
...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
|
|
3243
3380
|
startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
|
|
3244
3381
|
memoryProfile: memoryProfileStatus(includeAttentionItems),
|
|
@@ -3266,7 +3403,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3266
3403
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
3267
3404
|
codexProxyPort: opts.codexProxyPort,
|
|
3268
3405
|
peers: Object.fromEntries(
|
|
3269
|
-
peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isFixedBuiltinDefinition(definitionOf(id)) && (isCodexPeer(p) || isQwenDualPeer(p)) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(declarations[id] && config.roles[id]?.includes("implementer") && definitionOf(id)?.transport === "claude-channel" ? { ownerIsolation: !!ownerWriteRoot(id) && ownerWriteRoot(id) !== opts.cwd, ...(!ownerWriteRoot(id) ? { ownerIsolationNote: "terminal execution root not verified; owner admission refused" } : {}) } : {}), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(id) } : {}), ...jcodeModelSource(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
|
|
3406
|
+
peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isFixedBuiltinDefinition(definitionOf(id)) && (isCodexPeer(p) || isQwenDualPeer(p)) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(declarations[id] && config.roles[id]?.includes("implementer") && definitionOf(id)?.transport === "claude-channel" ? { ownerIsolation: !!ownerWriteRoot(id) && ownerWriteRoot(id) !== opts.cwd, ...(!ownerWriteRoot(id) ? { ownerIsolationNote: "terminal execution root not verified; owner admission refused" } : {}) } : {}), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(id) } : {}), ...jcodeModelSource(id), ...(reviewOnlyPeer(id) ? { reviewScope: reviewScopeOf(id) } : {}), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
|
|
3270
3407
|
),
|
|
3271
3408
|
taskWip: tasks.wipBreakdown(peerIds),
|
|
3272
3409
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
@@ -4645,7 +4782,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4645
4782
|
executionOwners.set(peer, { owner: pi, cwd: executionCwd });
|
|
4646
4783
|
bus.add(pi);
|
|
4647
4784
|
// The start's own error is the one reported; a stop that fails too is logged beside it (#115).
|
|
4648
|
-
try { await pi.start(); } catch (error) {
|
|
4785
|
+
try { await pi.start(); } catch (error) {
|
|
4786
|
+
await pi.stop().catch((stop: Error) => log(`pi stop after a failed start: ${stop.message}`));
|
|
4787
|
+
if (error instanceof PiVersionRefusal) return { ok: false, error: sanitize(error.message).slice(0, 1000) };
|
|
4788
|
+
throw error;
|
|
4789
|
+
}
|
|
4649
4790
|
return { ok: true, ...(mode === "tui" ? { launch: pi.tuiLaunch } : {}) };
|
|
4650
4791
|
}
|
|
4651
4792
|
if (adapter?.start.daemonPort === "local-worker") {
|
|
@@ -4754,7 +4895,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4754
4895
|
if (selected !== "ask" && selected !== "ask-when-needed") return { ok: false, error: "Claude implementer refuses never-ask" };
|
|
4755
4896
|
const implementer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: selected, mcpServers: [], daemon: DAEMON_PORTS["claude-implementer"]({
|
|
4756
4897
|
cwd: executionCwd, stateDir: opts.stateDir, projectRoot: opts.cwd, instanceId, server: SERVER_JS,
|
|
4757
|
-
factsHook: join(import.meta.dir, "..", "cli", "facts-hook.ts"), systemPrompt: sessionRoleContract(peer), mode: selected, watchdogMs: config.watchdog_ms,
|
|
4898
|
+
factsHook: join(import.meta.dir, "..", "cli", "facts-hook.ts"), systemPrompt: sessionRoleContract(peer), mode: selected, watchdogMs: config.watchdog_ms, ...(declarations[peer]?.model ? { model: declarations[peer].model } : {}),
|
|
4758
4899
|
admit: env => {
|
|
4759
4900
|
try {
|
|
4760
4901
|
if (env.private || !currentRoot() || bus.peers.get(peer) !== implementer) return false;
|
|
@@ -4976,8 +5117,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4976
5117
|
};
|
|
4977
5118
|
}
|
|
4978
5119
|
|
|
5120
|
+
let operationalViewCache: { board: number; config: number; routing: ReturnType<typeof activeRouting>; privacy: string; view: ReturnType<typeof operationalView> } | undefined;
|
|
4979
5121
|
function publicOperationalView() {
|
|
4980
|
-
|
|
5122
|
+
const revision=board.statusRevision(),routing=activeRouting(),privacy=JSON.stringify([config.pii?.paths??[],config.local.deny]);
|
|
5123
|
+
if(operationalViewCache && operationalViewCache.board===revision && operationalViewCache.config===configRevision && operationalViewCache.routing===routing && operationalViewCache.privacy===privacy)return operationalViewCache.view;
|
|
5124
|
+
const privatePaths=[...(config.pii?.paths??[]),...config.local.deny];
|
|
5125
|
+
const currentPrivate=(task:Task)=>tasks.isPii(task) || [...(task.refs.paths??[]),...(task.plan?.paths??[])].some(path=>matchesPolicy(opts.cwd,path,privatePaths,true));
|
|
5126
|
+
const view=operationalView({tasks:board.list(),isPii:currentPrivate,publicView:task=>tasks.publicView(task),publicMetadataText:text=>tasks.publicMetadataText(text) && boundedSafe(text),redact:record=>redact(record as BusEvent)});
|
|
5127
|
+
operationalViewCache={board:revision,config:configRevision,routing,privacy,view};return view;
|
|
5128
|
+
}
|
|
5129
|
+
function operationalFailure(error: unknown, fallback?: string): Record<string, unknown> {
|
|
5130
|
+
return {ok:false,error:error instanceof OperationalAuthorityError?error.message:fallback??(error instanceof Error?error.message:"operational request refused"),...(error instanceof OperationalAuthorityError?{authorityRefused:true,authorityScope:error.scope}: {})};
|
|
4981
5131
|
}
|
|
4982
5132
|
/** Ordinary read authority never borrows the console identity or conductor admission. */
|
|
4983
5133
|
function publicReadCurrent(sock: Sock): () => void {
|
|
@@ -5056,9 +5206,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5056
5206
|
function operationalCurrent(sock:Sock,acceptedAfterClose=false):()=>void {
|
|
5057
5207
|
const client=sock.data,peer=client.peer;
|
|
5058
5208
|
return ()=>{
|
|
5059
|
-
if(!peer || sock.data!==client || client.role!=="tools" || !acceptedAfterClose && sock.readyState!==WebSocket.OPEN || client.toolsOwner || readOnlyPeer(peer) || stopping || !membershipAllowed(peer) || contextPii(peer))throw new
|
|
5209
|
+
if(!peer || sock.data!==client || client.role!=="tools" || !acceptedAfterClose && sock.readyState!==WebSocket.OPEN || client.toolsOwner || readOnlyPeer(peer) || stopping || !membershipAllowed(peer) || contextPii(peer))throw new OperationalAuthorityError("operational conductor authority unavailable");
|
|
5060
5210
|
try {requireConductor(peer,config.roles);}
|
|
5061
|
-
catch {throw new
|
|
5211
|
+
catch {throw new OperationalAuthorityError("this operation requires the explicit conductor role","conductor");}
|
|
5062
5212
|
};
|
|
5063
5213
|
}
|
|
5064
5214
|
function admitOperational(sock:Sock,command:string):()=>void {
|
|
@@ -5486,7 +5636,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5486
5636
|
const message = sanitize(error instanceof Error ? error.message : "configuration reload could not be confirmed").slice(0, 600);
|
|
5487
5637
|
const failure = `${fingerprint}:${message}`;
|
|
5488
5638
|
if (lastReloadFailure !== failure) { lastReloadFailure = failure; try { notify(`configuration reload ${published ? "unconfirmed" : "refused"}: ${message}; run ahub settings reload after reviewing or reverting the file`); } catch { log("configuration reload notice unavailable"); } }
|
|
5489
|
-
return { ok: false, error: message, ...(published ? { applied: true } : {}), pendingRestart };
|
|
5639
|
+
return { ok: false, error: message, ...(error instanceof OperationalAuthorityError ? { authorityRefused: true, authorityScope: error.scope } : {}), ...(published ? { applied: true } : {}), pendingRestart };
|
|
5490
5640
|
}
|
|
5491
5641
|
}
|
|
5492
5642
|
|
|
@@ -5633,7 +5783,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5633
5783
|
return {ok:true,saved:true,authorityEnded:true,text:`${peer}: roster write saved; authority ended before remaining lifecycle work; inspect status before continuing`};
|
|
5634
5784
|
}
|
|
5635
5785
|
}
|
|
5636
|
-
return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
|
|
5786
|
+
return { ok: false, error: sanitize((error as Error).message), ...(error instanceof OperationalAuthorityError ? { authorityRefused: true, authorityScope: error.scope } : {}), ...(saved ? { saved: true } : {}) };
|
|
5637
5787
|
} finally { if (rosterChanging === peer) { rosterChanging = undefined; bus.rosterChanged(peer); if (configWatchPending && !stopping) { configWatchPending = false; clearTimeout(reloadDebounce); reloadDebounce = setTimeout(runConfigWatch, 100); reloadDebounce.unref?.(); } } }
|
|
5638
5788
|
}
|
|
5639
5789
|
/** A checked definition and value, or the refusal. Every settings write and preview starts here. */
|
|
@@ -5706,7 +5856,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5706
5856
|
: ["permission_modes", "catalog_roles", "peers"].includes(def.path[0]!) ? "applies at the peer's next start" : def.key === "coordination" ? "applies to new tasks" : "applies at next use";
|
|
5707
5857
|
return { ok: true, text: `${def.key}: ${settingText(to)} (${timing})` };
|
|
5708
5858
|
} catch (error) {
|
|
5709
|
-
return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
|
|
5859
|
+
return { ok: false, error: sanitize((error as Error).message), ...(error instanceof OperationalAuthorityError ? { authorityRefused: true, authorityScope: error.scope } : {}), ...(saved ? { saved: true } : {}) };
|
|
5710
5860
|
}
|
|
5711
5861
|
}
|
|
5712
5862
|
|
|
@@ -6195,11 +6345,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6195
6345
|
const c = sock.data;
|
|
6196
6346
|
let refusalRecorded=false;
|
|
6197
6347
|
const reply = (body: Record<string, unknown>) => {
|
|
6198
|
-
if (c.toolsOwner && !currentImplementerTools(c)) body = { ok: false, error: "Claude child generation or execution root changed" };
|
|
6199
6348
|
const label=operationalRequestLabel(msg);
|
|
6349
|
+
if (c.toolsOwner && !currentImplementerTools(c)) body = { ok: false, error: "Claude child generation or execution root changed", ...(label ? {authorityRefused:true,authorityScope:"operational"}: {}) };
|
|
6200
6350
|
if(c.authed && c.role==="tools" && c.peer && body.ok===false && label) {
|
|
6201
|
-
if(
|
|
6202
|
-
|
|
6351
|
+
if(body.authorityRefused===true) {
|
|
6352
|
+
if(!refusalRecorded)refusalRecorded=event({type:"agent_cli",peer:c.peer,command:label,refused:true});
|
|
6353
|
+
const error=body.authorityScope==="conductor"?operationalRefusal(c.peer,label):body.authorityScope==="person"?String(body.error):`${String(body.error??"operational authority unavailable")}; ${OPERATIONAL_PERSON_GUIDANCE}`;
|
|
6354
|
+
body={...body,error,refusalAudited:refusalRecorded};
|
|
6355
|
+
} else body={...body,authorityRefused:false};
|
|
6203
6356
|
}
|
|
6204
6357
|
if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body }));
|
|
6205
6358
|
};
|
|
@@ -6300,10 +6453,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6300
6453
|
// answer still revalidate native root evidence, without synchronous Git on every tick.
|
|
6301
6454
|
const heartbeat = setInterval(() => { if (currentImplementerTools(c, false) === launch) c.toolsOwner?.owner.keepApprovalAlive?.(); else pending.abort(); }, Math.max(10, Math.min(1000, Math.floor(config.watchdog_ms / 3)))); heartbeat.unref();
|
|
6302
6455
|
c.toolsOwner?.owner.keepApprovalAlive?.();
|
|
6303
|
-
void
|
|
6456
|
+
void (async () => {
|
|
6457
|
+
const updatedInput = msg.wrapperProtocol === 1 ? await automaticImplementerShell(c, msg.tool, msg.input, () => sock.readyState === WebSocket.OPEN && current()) : undefined;
|
|
6458
|
+
if (updatedInput && current()) return void reply({ ok: true, behavior: "allow", wrapperProtocol: 1, updatedInput });
|
|
6459
|
+
if (!current()) return void reply({ ok: true, behavior: "deny", message: "permission denied" });
|
|
6460
|
+
const result = await requestPermission({ peer, title: shown.title, tool: shown.tool, classed: true, options: [{ optionId: "allow", name: "Allow once", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }] }, pending.signal);
|
|
6304
6461
|
const allowed = current() && result.outcome === "answered" && result.optionId === "allow";
|
|
6305
6462
|
reply({ ok: true, behavior: allowed ? "allow" : "deny", ...(allowed ? {} : { message: result.outcome === "expired" ? "no person answered" : "permission denied" }) });
|
|
6306
|
-
}
|
|
6463
|
+
})().catch(() => reply({ ok: true, behavior: "deny", message: "hub permission unavailable" })).finally(() => { clearInterval(heartbeat); launch.signal.removeEventListener("abort", abortPending); }).catch(() => {});
|
|
6307
6464
|
return;
|
|
6308
6465
|
}
|
|
6309
6466
|
case "conductor_operational": {
|
|
@@ -6312,7 +6469,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6312
6469
|
const commands=["roster add","roster remove","stop","queue show","queue resolve","queue discard","task cancel","settings set","doctor orphans kill","logs","export"];
|
|
6313
6470
|
const command=msg.op==="authorize"?msg.command:msg.op==="orphans"?"doctor orphans kill":msg.op;
|
|
6314
6471
|
if(!commands.includes(command))return void reply({ok:false,error:"unknown operational command"});
|
|
6315
|
-
const current=admitOperational(sock,command);
|
|
6472
|
+
const current=msg.op==="authorize"?operationalCurrent(sock):admitOperational(sock,command);current();
|
|
6316
6473
|
if(msg.op==="authorize")return void reply({ok:true,instanceId});
|
|
6317
6474
|
if(msg.op==="logs" || msg.op==="export") {
|
|
6318
6475
|
const lines=dogfoodLines(join(opts.stateDir,msg.op==="logs"?"hub.log":"events.jsonl"),128*1024);
|
|
@@ -6321,11 +6478,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6321
6478
|
if(msg.op==="logs") {
|
|
6322
6479
|
if(msg.follow!==undefined && typeof msg.follow!=="boolean")return void reply({ok:false,error:"invalid log subscription"});
|
|
6323
6480
|
if(msg.follow)operationalLogSubscribers.set(sock,current);
|
|
6324
|
-
return void reply({ok:true,lines:lines.data.slice(-100).map(view.line),complete:lines.complete&&view.complete,truncated:!lines.complete || lines.data.length>100});
|
|
6481
|
+
return void reply({ok:true,lines:lines.data.slice(-100).map(view.line),complete:lines.complete&&view.complete,...(!view.complete?{incompleteReason:view.incompleteReason}:!lines.complete?{incompleteReason:"history read limit"}:{}),truncated:!lines.complete || lines.data.length>100});
|
|
6325
6482
|
}
|
|
6326
6483
|
if(msg.since!==undefined && (typeof msg.since!=="number" || !Number.isFinite(msg.since) || msg.since<0))return void reply({ok:false,error:"invalid export window"});
|
|
6327
6484
|
const records=lines.data.flatMap(line=>{try{const row=JSON.parse(line);return typeof row?.at==="string" && Date.parse(row.at)>=(msg.since??0)?[view.event(row)]:[];}catch{return [{type:"withheld"}];}});
|
|
6328
|
-
return void reply({ok:true,events:records.slice(-1000),complete:lines.complete&&view.complete,truncated:!lines.complete || records.length>1000});
|
|
6485
|
+
return void reply({ok:true,events:records.slice(-1000),complete:lines.complete&&view.complete,...(!view.complete?{incompleteReason:view.incompleteReason}:!lines.complete?{incompleteReason:"history read limit"}:{}),truncated:!lines.complete || records.length>1000});
|
|
6329
6486
|
}
|
|
6330
6487
|
if(msg.op!=="orphans")return void reply({ok:false,error:"unknown operational request"});
|
|
6331
6488
|
const selection={root:opts.cwd,projectId,instanceId,pid:process.pid,executable:process.execPath,programs:[CLI_ENTRY,fileURLToPath(new URL("../cli/main.js",import.meta.url))]},ports={...orphanProcessPorts,current};
|
|
@@ -6339,8 +6496,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6339
6496
|
const fresh=inspectSelectedOrphans(selection,undefined,readControl(opts.stateDir),{...ports,current:accepted});
|
|
6340
6497
|
if(fresh.candidates.some(candidate=>candidate.pid===process.pid))void stop().catch(()=>{});
|
|
6341
6498
|
}catch{/* Changed authority, instance or ownership never stops the hub. */}},0);
|
|
6342
|
-
})().catch(
|
|
6343
|
-
} catch(error) {reply(
|
|
6499
|
+
})().catch(error=>reply(operationalFailure(error,"operational orphan cleanup unconfirmed")));
|
|
6500
|
+
} catch(error) {reply(operationalFailure(error));}
|
|
6344
6501
|
return;
|
|
6345
6502
|
}
|
|
6346
6503
|
case "permission_default": {
|
|
@@ -6362,7 +6519,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6362
6519
|
if (c.role !== "console" && msg.op === "list") {
|
|
6363
6520
|
try { publicReadCurrent(sock)(); } catch { return void reply({ ok: false, error: "public queue read unavailable for this caller" }); }
|
|
6364
6521
|
} else if(c.role!=="console") {
|
|
6365
|
-
try {if(!["show","resolve","discard"].includes(msg.op))throw new Error("queue inspection and resolution are console-only");admitOperational(sock,`queue ${msg.op}`);}catch(error){return void reply(
|
|
6522
|
+
try {if(!["show","resolve","discard"].includes(msg.op))throw new Error("queue inspection and resolution are console-only");const current=msg.op==="show"?operationalCurrent(sock):admitOperational(sock,`queue ${msg.op}`);current();}catch(error){return void reply(operationalFailure(error));}
|
|
6366
6523
|
}
|
|
6367
6524
|
if(c.role!=="console" && msg.reason!==undefined && (typeof msg.reason!=="string" || !tasks.publicMetadataText(msg.reason)))return void reply({ok:false,error:"queue reason withheld by privacy policy"});
|
|
6368
6525
|
if (msg.op === "list") {
|
|
@@ -6959,12 +7116,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6959
7116
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person command" });
|
|
6960
7117
|
return void reloadConfig("terminal", undefined, msg.confirm).then(reply, () => reply({ ok: false, error: "configuration reload could not be confirmed; inspect hub.log" }));
|
|
6961
7118
|
case "settings_preview":
|
|
6962
|
-
if(c.role!=="console")try {const def=settingDef(msg.key);if(msg.undo===true || typeof def==="string" || !(operationalSetting(def)||operationalRosterSetting(def)))throw new
|
|
7119
|
+
if(c.role!=="console")try {const def=settingDef(msg.key);if(msg.undo===true || typeof def==="string" || !(operationalSetting(def)||operationalRosterSetting(def)))throw new OperationalAuthorityError(`this setting is reserved for the person; ${OPERATIONAL_PERSON_GUIDANCE}`,"person");operationalCurrent(sock)();}catch(error){return void reply(operationalFailure(error));}
|
|
6963
7120
|
return void reply(msg.undo === true ? settingsUndoPreview() : settingsPreview(msg.key, msg.value));
|
|
6964
7121
|
case "settings_set":
|
|
6965
7122
|
let settingsCurrent:(()=>void)|undefined;
|
|
6966
|
-
if(c.role!=="console")try {const def=settingDef(msg.key);if(typeof def==="string" || !(operationalSetting(def)||operationalRosterSetting(def)))throw new
|
|
6967
|
-
void operationalContext.run(settingsCurrent?{actor:c.peer!,current:settingsCurrent}:undefined,()=>settingsSet(msg.key,msg.value,settingsCurrent?"conductor":"terminal",msg.confirm,msg.now,msg.reason)).then(reply,
|
|
7123
|
+
if(c.role!=="console")try {const def=settingDef(msg.key);if(typeof def==="string" || !(operationalSetting(def)||operationalRosterSetting(def)))throw new OperationalAuthorityError(`this setting is reserved for the person; ${OPERATIONAL_PERSON_GUIDANCE}`,"person");settingsCurrent=admitOperational(sock,"settings set");}catch(error){return void reply(operationalFailure(error));}
|
|
7124
|
+
void operationalContext.run(settingsCurrent?{actor:c.peer!,current:settingsCurrent}:undefined,()=>settingsSet(msg.key,msg.value,settingsCurrent?"conductor":"terminal",msg.confirm,msg.now,msg.reason)).then(reply, error => reply(operationalFailure(error,"the setting could not be changed; inspect hub.log")));
|
|
6968
7125
|
return;
|
|
6969
7126
|
case "settings_undo":
|
|
6970
7127
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
@@ -7014,8 +7171,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7014
7171
|
return;
|
|
7015
7172
|
case "peer_stop":
|
|
7016
7173
|
let stopCurrent:(()=>void)|undefined;
|
|
7017
|
-
if(c.role!=="console")try{stopCurrent=admitOperational(sock,"stop");}catch(error){return void reply(
|
|
7018
|
-
void stopPeer(msg.peer,false,stopCurrent?{actor:c.peer!,current:stopCurrent}:undefined).then(reply).catch(
|
|
7174
|
+
if(c.role!=="console")try{stopCurrent=admitOperational(sock,"stop");}catch(error){return void reply(operationalFailure(error));}
|
|
7175
|
+
void stopPeer(msg.peer,false,stopCurrent?{actor:c.peer!,current:stopCurrent}:undefined).then(reply).catch(error => reply(operationalFailure(error,"peer stop failed; inspect ahub status and logs before retrying")));
|
|
7019
7176
|
return;
|
|
7020
7177
|
case "start":
|
|
7021
7178
|
if (c.role !== "console") return void reply({ t: "started", ok: false, error: "start is a console command; use hub_peer_start with the conductor role" });
|
|
@@ -7176,6 +7333,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7176
7333
|
}
|
|
7177
7334
|
async function stopOnce(): Promise<void> {
|
|
7178
7335
|
stopping = true;
|
|
7336
|
+
let implementerShellCleanupFailed = false;
|
|
7337
|
+
const implementerShellStopped = implementerShellStart?.then(wrapper => wrapper.close()).catch(() => { implementerShellCleanupFailed = true; log("host shell cleanup failed during shutdown"); });
|
|
7179
7338
|
terminalLaunches.clear();
|
|
7180
7339
|
// Start cancellation immediately; a failed read cleanup must not strand the other owned peers or finalization.
|
|
7181
7340
|
const codeFactsStopped = codeFacts.close().catch(() => { log("code facts cleanup failed during shutdown"); });
|
|
@@ -7194,7 +7353,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7194
7353
|
for (const pending of checkpointWaits.values()) pending.done(undefined);
|
|
7195
7354
|
for (const p of permissions.values()) p.done(undefined);
|
|
7196
7355
|
try {
|
|
7197
|
-
await Promise.all([terminalReadsStopped, codeFactsStopped]);
|
|
7356
|
+
await Promise.all([terminalReadsStopped, codeFactsStopped, implementerShellStopped]);
|
|
7198
7357
|
await terminalLaunches.settled();
|
|
7199
7358
|
// A peer can still be inside memory recall or its native handshake when kill arrives.
|
|
7200
7359
|
// Drain those starts before taking the final owned-process snapshot.
|
|
@@ -7235,6 +7394,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7235
7394
|
} catch { /* another owner or no published state: never remove it */ }
|
|
7236
7395
|
onStop?.();
|
|
7237
7396
|
}
|
|
7397
|
+
if (implementerShellCleanupFailed) throw new Error("host shell cleanup unconfirmed");
|
|
7238
7398
|
}
|
|
7239
7399
|
let onStop: (() => void) | undefined;
|
|
7240
7400
|
|
|
@@ -7296,6 +7456,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7296
7456
|
// After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
|
|
7297
7457
|
const piRecovered = !archivedPiContinuity && !!crashed?.peers.some((p) => definitionOf(p.peer)?.session.restoreOnStart);
|
|
7298
7458
|
if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
|
|
7459
|
+
// #843 AC3: at hub start, after the roster and the control socket exist; never awaited, so a slow probe delays no start.
|
|
7460
|
+
void sweepAgentDrift();
|
|
7299
7461
|
return { bus, token, port: server.port as number, bandEvaluations: () => bandEvaluations, sdlcFileReads: () => sdlcFileReads, stop, stopped: new Promise<void>((r) => (onStop = r)) };
|
|
7300
7462
|
} finally {
|
|
7301
7463
|
if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }
|
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
import type { ReadOnlyProgram } from "./read-only-shell.ts";
|
|
2
2
|
import { isAbsolute, relative } from "node:path";
|
|
3
|
+
import { lstatSync } from "node:fs";
|
|
3
4
|
import { realPath } from "./project.ts";
|
|
4
5
|
|
|
5
6
|
/** A repo-writable PATH shim is not a host-owned authenticated capability. No caller-selected binary. */
|
|
6
|
-
export function hostExecutable(name: "gh" | "container" | "git" | "dot" | "ssh" | "tar" | ReadOnlyProgram, projectDir: string, path = process.env.PATH ?? ""): string {
|
|
7
|
+
export function hostExecutable(name: "gh" | "bun" | "container" | "git" | "dot" | "ssh" | "tar" | ReadOnlyProgram, projectDir: string, path = process.env.PATH ?? ""): string {
|
|
7
8
|
const found = Bun.which(name, { PATH: path });
|
|
8
9
|
if (!found || !isAbsolute(found)) throw new Error(`${name} unavailable`);
|
|
9
10
|
const target = realPath(found), inside = relative(realPath(projectDir), target);
|
|
10
11
|
if (!inside || inside !== ".." && !inside.startsWith("../") && !isAbsolute(inside)) throw new Error(`${name} unavailable`);
|
|
11
12
|
return target;
|
|
12
13
|
}
|
|
14
|
+
|
|
15
|
+
/** A privileged command's children must not discover a project-controlled PATH shim either. */
|
|
16
|
+
export function hostShellPath(projectDir: string, executionDir: string, value = process.env.PATH ?? "/usr/bin:/bin"): string | undefined {
|
|
17
|
+
try {
|
|
18
|
+
if (!value || value.length > 16384 || value.split(":").length > 128) return;
|
|
19
|
+
const roots = [realPath(projectDir), realPath(executionDir)], directories = new Set<string>();
|
|
20
|
+
const inside = (path: string) => roots.some(root => { const p = relative(root, path); return !p || p !== ".." && !p.startsWith("../") && !isAbsolute(p); });
|
|
21
|
+
for (const entry of value.split(":")) {
|
|
22
|
+
if (!isAbsolute(entry) || entry.split("/").includes("..")) return;
|
|
23
|
+
if (inside(entry)) continue;
|
|
24
|
+
try {
|
|
25
|
+
const canonical = realPath(entry);
|
|
26
|
+
if (!inside(canonical) && lstatSync(canonical).isDirectory()) directories.add(canonical);
|
|
27
|
+
} catch { /* A missing PATH directory supplies no trusted program. */ }
|
|
28
|
+
}
|
|
29
|
+
return directories.size ? [...directories].join(":") : undefined;
|
|
30
|
+
} catch { return undefined; }
|
|
31
|
+
}
|
package/src/hub/instances.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, isAbsolute, resolve, sep } from "node:path";
|
|
|
4
4
|
import { canonicalPath, realPath } from "./project.ts";
|
|
5
5
|
import { defaultPeerRoles, definitionOf, type AdapterDefinition, type StartForm, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
6
6
|
import { isPermissionMode, type PermissionMode } from "./permission-mode.ts";
|
|
7
|
+
import { isReviewScope } from "./review-scope.ts";
|
|
7
8
|
|
|
8
9
|
export interface InstanceConfig {
|
|
9
10
|
kind: string;
|
|
@@ -11,9 +12,11 @@ export interface InstanceConfig {
|
|
|
11
12
|
start_mode?: StartForm;
|
|
12
13
|
permission_mode?: PermissionMode;
|
|
13
14
|
review_mode?: "fresh";
|
|
15
|
+
/** A fresh reviewer's review scope (#852): `code` reads the code only, `full` also runs tests and reads CI. Machine-local, tier 3. */
|
|
16
|
+
review_scope?: "full" | "code";
|
|
14
17
|
worktree?: string;
|
|
15
18
|
readOnly?: boolean;
|
|
16
|
-
/** Jcode instances
|
|
19
|
+
/** Jcode instances (#685): their own native data home, and optional model selection. A headless Claude implementer also takes `model` (#844). Machine-local, never editable from settings. */
|
|
17
20
|
jcode_home?: string;
|
|
18
21
|
provider?: string;
|
|
19
22
|
model?: string;
|
|
@@ -129,7 +132,9 @@ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog):
|
|
|
129
132
|
const path = `instances.${id}`, entry = fields(descriptor.value, path);
|
|
130
133
|
const declaredKind: unknown = entry.kind?.value;
|
|
131
134
|
const jcodeFields = typeof declaredKind === "string" && resolve(declaredKind)?.instances?.nativeHome === true;
|
|
132
|
-
|
|
135
|
+
// #844: a kind whose headless variant declares `instances.modelSelect` may name its model; no other kind but Jcode has the field.
|
|
136
|
+
const modelSelect = typeof declaredKind === "string" && resolve(declaredKind)?.headlessVariant?.instances?.modelSelect === true;
|
|
137
|
+
if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode", "readOnly", "review_scope", ...(jcodeFields ? JCODE_FIELDS : []), ...(modelSelect ? ["model"] : [])].includes(key))) return fail(path, "unsupported field");
|
|
133
138
|
const kind: unknown = entry.kind?.value;
|
|
134
139
|
if (typeof kind !== "string") return fail(`${path}.kind`, "must name a native kind");
|
|
135
140
|
const definition = resolve(kind), prefix = `${kind}-`;
|
|
@@ -185,6 +190,17 @@ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog):
|
|
|
185
190
|
if (++jcodeCount > MAX_JCODE_INSTANCES) return fail("instances", `at most ${MAX_JCODE_INSTANCES} extra Jcode instances are supported per project`);
|
|
186
191
|
Object.assign(value, jcodeInstanceFields(entry, path));
|
|
187
192
|
}
|
|
193
|
+
if (entry.model && modelSelect) {
|
|
194
|
+
if (!headlessWriter) return fail(`${path}.model`, "only a headless implementer takes a model");
|
|
195
|
+
const word: unknown = entry.model.value;
|
|
196
|
+
if (typeof word !== "string" || !NATIVE_WORD.test(word)) return fail(`${path}.model`, "must be one bounded printable word without spaces");
|
|
197
|
+
value.model = word;
|
|
198
|
+
}
|
|
199
|
+
if (entry.review_scope) {
|
|
200
|
+
if (!freshReviewer) return fail(`${path}.review_scope`, "only a fresh reviewer instance has a review scope");
|
|
201
|
+
if (!isReviewScope(entry.review_scope.value)) return fail(`${path}.review_scope`, 'must be "code" or "full"');
|
|
202
|
+
value.review_scope = entry.review_scope.value;
|
|
203
|
+
}
|
|
188
204
|
if (entry.readOnly) value.readOnly = entry.readOnly.value as boolean;
|
|
189
205
|
if (entry.roles) { value.roles = roles(entry.roles.value, `${path}.roles`); Object.freeze(value.roles); }
|
|
190
206
|
if (entry.start_mode) {
|
package/src/hub/lifecycle.ts
CHANGED
|
@@ -119,7 +119,7 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
|
|
|
119
119
|
try {
|
|
120
120
|
daemon = await startDaemon({ cwd: project.root, stateDir: project.stateDir, projectId: project.id, instanceId,
|
|
121
121
|
controlPort: base + CONTROL, codexAppPort: base + CODEX_APP, codexProxyPort: base + CODEX_PROXY,
|
|
122
|
-
unattended, onShutdownStart,
|
|
122
|
+
unattended, onShutdownStart, agentDrift: true,
|
|
123
123
|
// #269: the real terminal providers belong to the running product; a daemon built without them opens nothing.
|
|
124
124
|
terminal: (template) => terminalOpener({ template, cwd: project.root, stateDir: project.stateDir }) });
|
|
125
125
|
break;
|