@staix/agent-hub 0.18.0 → 0.20.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 +30 -0
- package/README.md +4 -4
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +13 -10
- package/docs/agent-notes/bus.md +1 -1
- package/docs/agent-notes/daemon.md +11 -4
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +6 -1
- package/docs/agent-notes/tests.md +8 -4
- package/docs/dogfood.md +13 -4
- package/docs/memory.md +1 -1
- package/docs/operations.md +98 -28
- package/docs/quickstart.md +13 -5
- package/docs/sdlc.md +12 -0
- package/docs/security.md +24 -15
- package/docs/smoke.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +101 -8
- 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/2026-10-27-0.20.0.md +75 -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 +25 -17
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +373 -72
- package/src/adapters/acp.ts +8 -0
- package/src/adapters/agy/entry.ts +1 -1
- package/src/adapters/channel-peer.ts +7 -5
- package/src/adapters/claude-channel.ts +7 -3
- package/src/adapters/claude-implementer.ts +6 -3
- package/src/adapters/codex-appserver.ts +11 -1
- package/src/adapters/compat.ts +32 -18
- package/src/adapters/conformance/enrollment.ts +4 -4
- package/src/adapters/conformance/factories.ts +2 -1
- package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
- package/src/adapters/conformance/kit.ts +10 -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/local-worker.ts +2 -0
- package/src/adapters/pi.ts +46 -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/adapters/reviewer-print.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/ci-watch.ts +140 -0
- package/src/hub/claude-launch-proof.ts +67 -2
- package/src/hub/claude-shell-wrapper.ts +349 -0
- package/src/hub/config-apply.ts +2 -2
- package/src/hub/config-edit.ts +2 -2
- package/src/hub/config-tier.ts +2 -2
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/daemon.ts +282 -113
- 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/peers.ts +34 -0
- 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/settings.ts +1 -0
- package/src/hub/task-shell-command.ts +85 -0
- package/src/hub/task-shell-default.ts +79 -0
- package/src/hub/task-shell-proof.ts +171 -0
- package/src/hub/task-shell-sandbox.ts +61 -0
- package/src/hub/tasks.ts +274 -16
- package/src/local/deny.ts +112 -11
- package/src/local/sandbox.ts +70 -29
- package/src/local/shell-environment.ts +10 -0
- package/src/ui/index.html +1 -0
|
@@ -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;
|
|
@@ -41,6 +41,12 @@ export function conductorOperation(command:string,args:readonly string[]): Condu
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
/** Only a failed conductor/operational authority fence carries this marker, never an ordinary operation error. */
|
|
45
|
+
export class OperationalAuthorityError extends Error {
|
|
46
|
+
readonly code = "AHUB_OPERATIONAL_AUTHORITY";
|
|
47
|
+
constructor(message:string,readonly scope:"conductor"|"person"|"operational"="operational") { super(message); }
|
|
48
|
+
}
|
|
49
|
+
|
|
44
50
|
export const OPERATIONAL_PERSON_GUIDANCE="the person runs it in ahub console or a terminal";
|
|
45
51
|
export function operationalRefusal(peer:string,command:string):string {
|
|
46
52
|
return `${peer} cannot run ahub ${command}; ${OPERATIONAL_PERSON_GUIDANCE}; this operation requires the explicit conductor role`;
|
|
@@ -54,6 +60,7 @@ export function operationalRequestLabel(value:unknown):string|undefined {
|
|
|
54
60
|
return message.op==="orphans"?"doctor orphans kill":message.op==="logs"||message.op==="export"?message.op:"unknown";
|
|
55
61
|
}
|
|
56
62
|
if(message.t==="peer_stop")return "stop";
|
|
63
|
+
if(message.t==="task" && message.op==="hub_task_cancel")return "task cancel";
|
|
57
64
|
if(message.t==="queue" && typeof message.op==="string" && ["show","resolve","discard"].includes(message.op))return `queue ${message.op}`;
|
|
58
65
|
if(message.t==="settings_set"||message.t==="settings_preview")return "settings set";
|
|
59
66
|
return undefined;
|
|
@@ -87,16 +87,12 @@ const positive=(value:unknown):value is number=>Number.isSafeInteger(value) && (
|
|
|
87
87
|
|
|
88
88
|
/** Bind the existing task/privacy projections once per read; no private title or path leaves this closure. */
|
|
89
89
|
export function operationalView(ports:OperationalViewPorts) {
|
|
90
|
-
const
|
|
90
|
+
const publicTasks=new Set<number>();
|
|
91
91
|
const privateTitles:string[]=[];
|
|
92
|
-
//
|
|
93
|
-
let known=
|
|
94
|
-
try {for(const task of ports.tasks
|
|
95
|
-
const safeTask=(id:number):boolean=>
|
|
96
|
-
const task=tasks.get(id);
|
|
97
|
-
if (!task) return false;
|
|
98
|
-
try {return !ports.isPii(task) && ports.publicView(task).title===task.title;} catch {return false;}
|
|
99
|
-
};
|
|
92
|
+
// The caller caches this complete inventory by board/privacy revision; there is no task-count display ceiling.
|
|
93
|
+
let known=true;
|
|
94
|
+
try {for(const task of ports.tasks) {const view=ports.publicView(task);if(ports.isPii(task) || view.title!==task.title) privateTitles.push(task.title);else publicTasks.add(task.id);}} catch {known=false;}
|
|
95
|
+
const safeTask=(id:number):boolean=>known && publicTasks.has(id);
|
|
100
96
|
const safeText=(text:string):boolean=>{
|
|
101
97
|
if (!known || Buffer.byteLength(text)>65536 || privateTitles.some(title=>title && text.includes(title))) return false;
|
|
102
98
|
try {return ports.publicMetadataText(text);} catch {return false;}
|
|
@@ -137,5 +133,5 @@ export function operationalView(ports:OperationalViewPorts) {
|
|
|
137
133
|
return JSON.parse(text,(_key,item)=>typeof item==="string"?sanitize(item):item) as Record<string,unknown>;
|
|
138
134
|
} catch {return withheld;}
|
|
139
135
|
};
|
|
140
|
-
return {line,event,complete:known};
|
|
136
|
+
return {line,event,complete:known,...(!known?{incompleteReason:"privacy inventory unavailable"}: {})};
|
|
141
137
|
}
|
package/src/hub/peers.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Envelope, EnvelopeOpts, PeerId, PeerState } from "./envelope.ts";
|
|
2
2
|
import type { PermissionMode } from "./permission-mode.ts";
|
|
3
|
+
import type { StartForm } from "../adapters/registry-types.ts";
|
|
3
4
|
|
|
4
5
|
export interface DeliveryReceipt {
|
|
5
6
|
id: string;
|
|
@@ -14,6 +15,15 @@ export interface OwnedToolsLaunch {
|
|
|
14
15
|
launchId: string; generation: string; sessionId: string; signal: AbortSignal; task?: number; permissionHook: true;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
export interface FrontendProxyPort {
|
|
19
|
+
readonly url: string;
|
|
20
|
+
readonly attached: boolean;
|
|
21
|
+
/** Only before a frontend attaches, while offline and with no native restoration debt. */
|
|
22
|
+
setStartupPermissionMode(mode: PermissionMode): void;
|
|
23
|
+
/** Clear to ask while retaining any native restoration debt; false means its baseline is unknown. */
|
|
24
|
+
clearPermissionMode(): boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
export interface PeerAdapter {
|
|
18
28
|
readonly id: PeerId;
|
|
19
29
|
ownedTools?(launchId: unknown): OwnedToolsLaunch | undefined;
|
|
@@ -27,6 +37,30 @@ export interface PeerAdapter {
|
|
|
27
37
|
readonly permissionModeState?: PermissionMode | "unknown" | "unmanaged";
|
|
28
38
|
/** Resolve only after native acknowledgement. This changes approval frequency, never the sandbox. */
|
|
29
39
|
setPermissionMode?(mode: PermissionMode): Promise<void>;
|
|
40
|
+
/** Never offered delivery retry; absent/false permits retry, present undefined refuses it. */
|
|
41
|
+
readonly deliveryOnly?: boolean;
|
|
42
|
+
/** The hub owns this runtime and may stop it; absent or undefined refuses. */
|
|
43
|
+
readonly hubOwned?: boolean;
|
|
44
|
+
/** An owned process remains whose exit is unconfirmed. */
|
|
45
|
+
readonly pendingOwnedProcess?: boolean;
|
|
46
|
+
/** Native running form; absent keeps the site's default, present undefined is unknown. */
|
|
47
|
+
readonly form?: StartForm;
|
|
48
|
+
/** Absent is settled; false or a present undefined value refuses recovery readiness. */
|
|
49
|
+
readonly recoveryReady?: boolean;
|
|
50
|
+
/** Capture live resume evidence before a requested stop; throws when unavailable. */
|
|
51
|
+
captureResume?(): Promise<Record<string, unknown>>;
|
|
52
|
+
/** Native session binding; absence or undefined is unbound, never inferred from a different reader. */
|
|
53
|
+
readonly nativeSession?: string;
|
|
54
|
+
/** Fixed-builtin native proof only; present undefined is unknown, not an absent proxy. */
|
|
55
|
+
readonly frontendProxy?: FrontendProxyPort;
|
|
56
|
+
/** Fixed operator notices from builtin native adapters, never provider text or native ids. */
|
|
57
|
+
readonly statusNotice?: { code: "unidentified-cancellation" | "qwen-detached" | "qwen-stalled"; message: string };
|
|
58
|
+
/** Fixed builtins with session.nativeRevert only. Native conversation effects remain in the adapter. */
|
|
59
|
+
revertTurn?(nativeTurn: string): Promise<void>;
|
|
60
|
+
/** Leave the roster without stopping a person's terminal. */
|
|
61
|
+
detachForRoster?(resetMode: PermissionMode): void | Promise<void>;
|
|
62
|
+
/** Return to the roster after detachForRoster. */
|
|
63
|
+
resumeFromRoster?(): Promise<void>;
|
|
30
64
|
/** A peer the hub drives itself (Pi, the local worker). It cannot be trusted to rate its own urgency: the bus caps it. */
|
|
31
65
|
readonly hubNative?: boolean;
|
|
32
66
|
/** Never handed a delivery: its queue waits for `Bus.pull` (a Claude session that cannot show channel pushes, issue #205). */
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { accessSync, constants, lstatSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
2
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
4
3
|
import { spawnSync } from "node:child_process";
|
|
5
4
|
import { createHash } from "node:crypto";
|
|
@@ -7,10 +6,11 @@ import { guardPath, type ShellExecutionCheck } from "../local/tools.ts";
|
|
|
7
6
|
import { hostExecutable } from "./host-executable.ts";
|
|
8
7
|
import { hubGitArgv, hubGitEnv } from "./git.ts";
|
|
9
8
|
import { realPath } from "./project.ts";
|
|
10
|
-
import { readOnlyShellForm, type ReadOnlyProgram } from "./read-only-shell.ts";
|
|
9
|
+
import { parseSimpleShell, readOnlyShellForm, type ReadOnlyProgram } from "./read-only-shell.ts";
|
|
10
|
+
import { hostShellEnvironment } from "../local/shell-environment.ts";
|
|
11
11
|
|
|
12
12
|
export interface ShellProofOptions { root:string; projectRoot?:string; deny:string[]; readOnly?:string[]; env?:Record<string,string> }
|
|
13
|
-
export interface ReadOnlyShellProof { fingerprint:string; program:ReadOnlyProgram }
|
|
13
|
+
export interface ReadOnlyShellProof { fingerprint:string; program:ReadOnlyProgram; executable:string; argv:readonly string[] }
|
|
14
14
|
const within=(root:string,path:string)=>{const p=relative(root,path);return p==="" || p!==".."&&!p.startsWith("../")&&!isAbsolute(p);};
|
|
15
15
|
function firstProgram(name:string,root:string,path:string):string|undefined {
|
|
16
16
|
if(path.length>16384)return undefined;
|
|
@@ -49,14 +49,15 @@ export function proveReadOnlyShell(input:unknown,options:ShellProofOptions,curre
|
|
|
49
49
|
const root=realPath(options.root),projectRoot=realPath(options.projectRoot??options.root),names:string[]=[root,projectRoot];
|
|
50
50
|
const form=readOnlyShellForm(args.command,root,(path,base=root)=>{if(path.split("/").includes(".."))throw new Error("ambiguous parent traversal");const lexical=resolve(base,path),canonical=guardPath({cwd:root,deny:options.deny,readOnly:options.readOnly},lexical,"read");names.push(lexical,canonical);return canonical;});
|
|
51
51
|
if(!form || !current())return undefined;
|
|
52
|
-
const env=
|
|
52
|
+
const env=hostShellEnvironment(options.env);
|
|
53
53
|
const lexical=firstProgram(form.program,root,env.PATH);if(!lexical || within(root,lexical) || within(projectRoot,lexical))return undefined;
|
|
54
54
|
const executable=hostExecutable(form.program,root,env.PATH);if(realPath(lexical)!==executable || within(projectRoot,executable) || !current())return undefined;
|
|
55
55
|
if(form.gitCwd && !gitHelpersUnconfigured(executable,form.gitCwd,env,current))return undefined;
|
|
56
56
|
// ponytail: this screens the declared helper-bearing config keys and explicit diff/textconv flags; other config-mediated helpers need dedicated audited forms before extending the allowlist.
|
|
57
57
|
const paths=new Set([...names.flatMap(name=>ancestors(name,root)),...ancestors(lexical,"/"),...ancestors(executable,"/")]);
|
|
58
|
-
const fingerprint=createHash("sha256").update(JSON.stringify([root,projectRoot,args.command,env
|
|
59
|
-
|
|
58
|
+
const fingerprint=createHash("sha256").update(JSON.stringify([root,projectRoot,args.command,env,lexical,executable,[...paths].map(path=>stamp(path,path===lexical||path===executable))])).digest("hex");
|
|
59
|
+
const argv=parseSimpleShell(args.command)!.words.slice(1);
|
|
60
|
+
return current()?{fingerprint,program:form.program,executable,argv}:undefined;
|
|
60
61
|
} catch {return undefined;}
|
|
61
62
|
}
|
|
62
63
|
interface Decision { command:string; current:()=>boolean; proof?:ReadOnlyShellProof }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #852: a reviewer's review scope. `full` reads the code, runs the affected tests and reads CI; `code` reads the code only, because its sandbox
|
|
3
|
+
* cannot run tests or read CI. AC3 (amended 2026-10-26) is structural, never a reading of free text: a `code` reviewer's changes_requested is always
|
|
4
|
+
* recorded, and one with no open structured finding is recorded as evidence-only (see Tasks.review).
|
|
5
|
+
*/
|
|
6
|
+
export type ReviewScope = "full" | "code";
|
|
7
|
+
export const REVIEW_SCOPES: readonly ReviewScope[] = ["full", "code"];
|
|
8
|
+
export const isReviewScope = (value: unknown): value is ReviewScope => value === "full" || value === "code";
|
|
9
|
+
|
|
10
|
+
/** The one review-request line for a `code` reviewer (AC2). Fixed text: no hub or PR content is interpolated. */
|
|
11
|
+
export const CODE_SCOPE_LINE = "Your sandbox cannot run tests or read CI: review the code against the plan. The merger gates on exact-head CI (#852). Findings are only for defects in the reviewed code, docs or tests. A missing check result, CI result, test run or PR/branch identity is an evidence request: put it in unmet, never in findings. Report every code defect as a structured finding with an id (hub_review findings, disposition open); a verdict with no open finding is recorded as evidence-only and does not count as a rejection.";
|
|
12
|
+
/** The one task notice the conductor gets when a code-scope reviewer's verdict is recorded as evidence-only (ids only, never text). */
|
|
13
|
+
export const evidenceOnlyNotice = (task: number, reviewer: string): string => `Task #${task}: a code-scope review by ${reviewer} was recorded as evidence-only (no open finding) and the task returned to its owner. Take the review.`;
|
package/src/hub/sdlc-metrics.ts
CHANGED
|
@@ -11,8 +11,8 @@ export type BandTier = typeof BAND_TIERS[number];
|
|
|
11
11
|
export interface MetricTask {
|
|
12
12
|
id: number;
|
|
13
13
|
private?: boolean;
|
|
14
|
-
/** `requestHead` is the head a `done` asked review for (reviewCost.head); `verdictHead` the head a verdict was bound to (reviewFindings.head). Compared for equality only, never printed. */
|
|
15
|
-
history: { at: number; event: string; requestHead?: string; verdictHead?: string }[];
|
|
14
|
+
/** `evidenceOnly` marks a code-scope verdict with no open finding (#852): it is not a rework round. `requestHead` is the head a `done` asked review for (reviewCost.head); `verdictHead` the head a verdict was bound to (reviewFindings.head). Compared for equality only, never printed. */
|
|
15
|
+
history: { at: number; event: string; requestHead?: string; verdictHead?: string; evidenceOnly?: true }[];
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/** Per-task values; `null` is unknown. */
|
|
@@ -98,6 +98,8 @@ export function taskSample(task: MetricTask, extra: { mergedAt?: number; ciRerun
|
|
|
98
98
|
const spec = first(view, ["spec accepted"]);
|
|
99
99
|
const plan = spec ? first(view, ["plan reviewed"], spec.at) : undefined;
|
|
100
100
|
const verdict = accepted ? first(view, VERDICTS, accepted.at) : first(view, VERDICTS);
|
|
101
|
+
// Evidence-only still ends the actual wait for a first review, but says nothing about whether the work passed.
|
|
102
|
+
const substantiveVerdict = history.find(entry => entry.at >= (accepted?.at ?? -Infinity) && VERDICTS.includes(entry.event) && entry.evidenceOnly !== true);
|
|
101
103
|
const dones = history.filter(entry => entry.event === "done");
|
|
102
104
|
const ci = first(view, ["check passed", "check failed"]);
|
|
103
105
|
const approvedAt = history.find(entry => entry.event === "approved")?.at ?? null;
|
|
@@ -108,10 +110,10 @@ export function taskSample(task: MetricTask, extra: { mergedAt?: number; ciRerun
|
|
|
108
110
|
acceptance_to_plan_ms: spec && plan ? plan.at - spec.at : null,
|
|
109
111
|
acceptance_to_first_review_ms: accepted && verdict ? verdict.at - accepted.at : null,
|
|
110
112
|
acceptance_to_merge_ms: accepted && extra.mergedAt !== undefined && Number.isFinite(extra.mergedAt) && extra.mergedAt >= accepted.at ? extra.mergedAt - accepted.at : null,
|
|
111
|
-
first_pass_approval:
|
|
113
|
+
first_pass_approval: substantiveVerdict ? (substantiveVerdict.event === "approved" ? 1 : 0) : null,
|
|
112
114
|
// Rework grows with every round, so it is a value only once the task is approved: a task still in flight has no final one, and a
|
|
113
115
|
// provisional point would move at each verdict and could start a second breach episode inside one shift.
|
|
114
|
-
rework_rounds: dones.length && approvedAt !== null ? history.filter(entry => entry.event === "changes_requested").length : null,
|
|
116
|
+
rework_rounds: dones.length && approvedAt !== null ? history.filter(entry => entry.event === "changes_requested" && entry.evidenceOnly !== true).length : null,
|
|
115
117
|
first_pass_check: ci ? (ci.event === "check passed" ? 1 : 0) : null,
|
|
116
118
|
same_head_ci_reruns: extra.ciReruns === undefined ? null : extra.ciReruns,
|
|
117
119
|
stale_head_re_reviews: approvedAt !== null ? staleHeadRounds(history) : null,
|
package/src/hub/settings.ts
CHANGED
|
@@ -57,6 +57,7 @@ export const SETTINGS: readonly SettingDef[] = [
|
|
|
57
57
|
...["interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"].map((key): SettingDef => ({ key: `task_sweep.${key}`, group: "Switches", label: `Task sweep: ${key}`, type: "number", min: 1, max: 2_147_483_647 / (key === "interval_s" ? 1000 : 60_000), store: "config", applies: "live", risk: "raises", path: ["task_sweep", key] })),
|
|
58
58
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `tasks.wip_limits.${peer}`, group: "Routing", label: `${peer}: planner WIP limit`, type: "integer", min: 0, max: 32, store: "config", applies: "live", risk: "raises", floor: 0, path: ["tasks", "wip_limits", peer] })),
|
|
59
59
|
{ key: "tasks.escalate_after", group: "Routing", label: "Escalate after N consecutive changes_requested", type: "integer", min: 1, max: 10, store: "config", applies: "live", risk: "raises", path: ["tasks", "escalate_after"] },
|
|
60
|
+
{ key: "tasks.auto_pull", group: "Switches", label: "Auto-pull the top reserved ready card when a planner slot frees", type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["tasks", "auto_pull"] },
|
|
60
61
|
{ key: "planner", group: "Roles", label: "Planners (task-local selection)", type: "peers", values: PLANNER_PEERS, store: "config", applies: "live", risk: "raises", path: ["planner"] },
|
|
61
62
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `catalog_roles.${peer}`, group: "Roles", label: `${peer}: catalog guidance`, type: "roles", store: "config", applies: "next use", risk: "safe", path: ["catalog_roles", peer] })),
|
|
62
63
|
{ key: "scoped_grants.protect_instructions", group: "Permissions", label: "Ask before writing instruction files", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["protect_instruction_files"] },
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { parseSimpleShell } from "./read-only-shell.ts";
|
|
2
|
+
|
|
3
|
+
/** Short branch names from host evidence, never from the permission request. */
|
|
4
|
+
export interface TaskShellBinding { branch: string; defaultBranch: string }
|
|
5
|
+
export interface TaskShellCommand {
|
|
6
|
+
kind: "check" | "build" | "test" | "add" | "commit" | "push" | "pr-view" | "pr-checks" | "pr-create";
|
|
7
|
+
program: "scripts/check.sh" | "bun" | "git" | "gh";
|
|
8
|
+
argv: readonly string[];
|
|
9
|
+
/** Original literal path operands. The host must guard them under its verified execution root. */
|
|
10
|
+
paths: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function shortBranch(value: unknown): value is string {
|
|
14
|
+
return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/.test(value)
|
|
15
|
+
&& value !== "HEAD" && !value.startsWith("refs/") && !value.includes("..") && !value.includes("//")
|
|
16
|
+
&& !value.endsWith("/") && !value.endsWith(".")
|
|
17
|
+
&& value.split("/").every(part => !part.startsWith(".") && !part.endsWith(".lock"));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function literalPath(value: string): boolean {
|
|
21
|
+
if (!value || value.length > 512 || value.startsWith("-") || !/^[A-Za-z0-9_./-]+$/.test(value)) return false;
|
|
22
|
+
const relative = value.startsWith("./") ? value.slice(2) : value;
|
|
23
|
+
return !relative.startsWith("-") && relative.split("/").every(part => !!part && part !== "." && part !== ".."
|
|
24
|
+
&& ![".git", ".agenthub"].includes(part.toLowerCase()));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const data = (value: string | undefined, limit: number): value is string =>
|
|
28
|
+
typeof value === "string" && value.length <= limit && !!value.trim() && !/[\p{Cc}\p{Cf}]/u.test(value);
|
|
29
|
+
|
|
30
|
+
export function taskShellBindingValid(binding: TaskShellBinding): boolean {
|
|
31
|
+
return !!binding && shortBranch(binding.branch) && shortBranch(binding.defaultBranch)
|
|
32
|
+
&& ![binding.defaultBranch.toLowerCase(), "main", "master"].includes(binding.branch.toLowerCase());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Lexical data only: this does not authorize execution, bind a task, resolve a program or read Git/files.
|
|
37
|
+
* The caller supplies authoritative native cwd/environment, current child/task/worktree/branch evidence and
|
|
38
|
+
* action/privacy policy, then guards every returned path and executable before answering the native prompt.
|
|
39
|
+
* Quoted punctuation is data only in commit messages and PR title/body values; path/ref operands stay plain.
|
|
40
|
+
*/
|
|
41
|
+
export function taskShellCommand(command: unknown, binding: TaskShellBinding): TaskShellCommand | undefined {
|
|
42
|
+
if (!taskShellBindingValid(binding)) return undefined;
|
|
43
|
+
const parsed = parseSimpleShell(command);
|
|
44
|
+
if (!parsed) return undefined;
|
|
45
|
+
const argv = parsed.words;
|
|
46
|
+
const result = (kind: TaskShellCommand["kind"], program: TaskShellCommand["program"], paths: readonly string[] = []): TaskShellCommand =>
|
|
47
|
+
({ kind, program, argv: [...argv], paths: [...paths] });
|
|
48
|
+
if (argv.length === 2 && argv[0] === "scripts/check.sh" && argv[1] === "--changed") return result("check", "scripts/check.sh", ["scripts/check.sh"]);
|
|
49
|
+
if (argv[0] === "bun") {
|
|
50
|
+
if (argv.length === 3 && argv[1] === "run" && argv[2] === "build") return result("build", "bun");
|
|
51
|
+
const paths = argv.slice(2);
|
|
52
|
+
if (argv[1] === "test" && paths.length >= 1 && paths.length <= 16 && paths.every(path => {
|
|
53
|
+
const relative = path.startsWith("./") ? path.slice(2) : path;
|
|
54
|
+
return literalPath(path) && relative.startsWith("test/") && /^[A-Za-z0-9_][A-Za-z0-9_.-]*\.test\.ts$/.test(relative.split("/").at(-1)!);
|
|
55
|
+
})) return result("test", "bun", paths);
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
if (argv[0] === "git") {
|
|
59
|
+
if (argv[1] === "add") {
|
|
60
|
+
const paths = argv.slice(argv[2] === "--" ? 3 : 2);
|
|
61
|
+
if (paths.length >= 1 && paths.length <= 64 && paths.every(literalPath)) return result("add", "git", paths);
|
|
62
|
+
}
|
|
63
|
+
if (argv.length === 4 && argv[1] === "commit" && argv[2] === "-m" && data(argv[3], 1000)) return result("commit", "git");
|
|
64
|
+
if (argv[1] === "push" && (argv.length === 4 && argv[2] === "origin" && argv[3] === binding.branch
|
|
65
|
+
|| argv.length === 5 && argv[2] === "-u" && argv[3] === "origin" && argv[4] === binding.branch)) return result("push", "git");
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
if (argv[0] !== "gh" || argv[1] !== "pr") return undefined;
|
|
69
|
+
if (argv.length === 4 && argv[3] === binding.branch) {
|
|
70
|
+
if (argv[2] === "view") return result("pr-view", "gh");
|
|
71
|
+
if (argv[2] === "checks") return result("pr-checks", "gh");
|
|
72
|
+
}
|
|
73
|
+
if (argv[2] !== "create" || argv.length !== 11) return undefined;
|
|
74
|
+
const allowed = new Set(["--base", "--head", "--title", "--body", "--body-file"]), flags = new Map<string, string>();
|
|
75
|
+
for (let index = 3; index < argv.length; index += 2) {
|
|
76
|
+
const flag = argv[index]!, value = argv[index + 1];
|
|
77
|
+
if (!allowed.has(flag) || value === undefined || flags.has(flag)) return undefined;
|
|
78
|
+
flags.set(flag, value);
|
|
79
|
+
}
|
|
80
|
+
if (flags.get("--base") !== binding.defaultBranch || flags.get("--head") !== binding.branch || !data(flags.get("--title"), 1000)) return undefined;
|
|
81
|
+
if (flags.has("--body") === flags.has("--body-file")) return undefined;
|
|
82
|
+
if (flags.has("--body")) return data(flags.get("--body"), 3000) ? result("pr-create", "gh") : undefined;
|
|
83
|
+
const file = flags.get("--body-file")!;
|
|
84
|
+
return literalPath(file) ? result("pr-create", "gh", [file]) : undefined;
|
|
85
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { chmodSync, lstatSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
4
|
+
import { stopOwnedProcess, trackGroup } from "./child-process.ts";
|
|
5
|
+
import { hostExecutable } from "./host-executable.ts";
|
|
6
|
+
import { hubGitArgv, hubGitEnv } from "./git.ts";
|
|
7
|
+
import { realPath } from "./project.ts";
|
|
8
|
+
import { isGitHubRepository } from "./spec-read.ts";
|
|
9
|
+
import { shortBranch } from "./task-shell-command.ts";
|
|
10
|
+
|
|
11
|
+
const OUTPUT_LIMIT = 64 * 1024;
|
|
12
|
+
function remoteHead(bytes: Buffer): string | undefined {
|
|
13
|
+
const output = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
14
|
+
if (!output.endsWith("\n")) return;
|
|
15
|
+
const lines = output.slice(0, -1).split("\n");
|
|
16
|
+
if (lines.length !== 2) return;
|
|
17
|
+
const symbolic = lines.filter(line => /^ref: refs\/heads\/.+\tHEAD$/.test(line));
|
|
18
|
+
const oid = lines.filter(line => /^[0-9a-f]{40}\tHEAD$/.test(line));
|
|
19
|
+
if (symbolic.length !== 1 || oid.length !== 1) return;
|
|
20
|
+
const branch = symbolic[0]!.slice("ref: refs/heads/".length, -"\tHEAD".length);
|
|
21
|
+
return shortBranch(branch) ? branch : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Startup authority comes from the fixed GitHub remote, never a mutable local origin/HEAD or repository config.
|
|
25
|
+
* No task/native environment or account credentials are inherited. Unknown remote evidence keeps automatic execution unavailable. */
|
|
26
|
+
export async function readTaskShellDefaultBranch(root: string, env: Record<string, string>, current: () => boolean, projectRoot: string, repository?: string, timeoutMs = 5000): Promise<string | undefined> {
|
|
27
|
+
let temporary: string | undefined, proc: ChildProcess | undefined;
|
|
28
|
+
try {
|
|
29
|
+
if (!isGitHubRepository(repository) || !current() || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000
|
|
30
|
+
|| !env.PATH || env.PATH.length > 16384 || env.PATH.split(":").some(dir => !isAbsolute(dir) || dir.split("/").includes(".."))) return;
|
|
31
|
+
root = realPath(root);
|
|
32
|
+
const project = realPath(projectRoot), executable = hostExecutable("git", root, env.PATH), rel = relative(project, executable);
|
|
33
|
+
if (!rel || rel !== ".." && !rel.startsWith("../") && !isAbsolute(rel) || !lstatSync(executable).isFile()) return;
|
|
34
|
+
temporary = realPath(mkdtempSync(join(realPath("/tmp"), "ahub-default-"))); chmodSync(temporary, 0o700);
|
|
35
|
+
const own = temporary, stat = lstatSync(own), identity = JSON.stringify([stat.dev, stat.ino, stat.mode]);
|
|
36
|
+
const remove = () => {
|
|
37
|
+
const held = lstatSync(own);
|
|
38
|
+
if (!held.isDirectory() || realPath(own) !== own || JSON.stringify([held.dev, held.ino, held.mode]) !== identity) throw new Error("unavailable");
|
|
39
|
+
rmSync(own, { recursive: true, force: true }); temporary = undefined;
|
|
40
|
+
};
|
|
41
|
+
// Git stops before inspecting a ceiling ancestor; naming cwd itself still permits discovery in its parent.
|
|
42
|
+
const environment = hubGitEnv({ PATH: env.PATH, HOME: own, XDG_CONFIG_HOME: own, LANG: env.LANG ?? "C", TERM: "dumb", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CEILING_DIRECTORIES: dirname(own) }, { repositoryIsolation: true });
|
|
43
|
+
const argv = hubGitArgv(["-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "ls-remote", "--symref", "--exit-code", `https://github.com/${repository}.git`, "HEAD"]);
|
|
44
|
+
if (!current()) { remove(); return; }
|
|
45
|
+
return await new Promise<string | undefined>(resolve => {
|
|
46
|
+
let done = false, bytes = 0, overflowed = false;
|
|
47
|
+
const output: Buffer[] = [];
|
|
48
|
+
let timer: ReturnType<typeof setTimeout> | undefined, watcher: ReturnType<typeof setInterval> | undefined;
|
|
49
|
+
const finish = async (code: number | null) => {
|
|
50
|
+
if (done) return; done = true; clearTimeout(timer); clearInterval(watcher);
|
|
51
|
+
// Do not remove a private cwd/HOME while an owned group may still use it. The existing helper refuses unknown ownership.
|
|
52
|
+
try { if (proc) await stopOwnedProcess(proc, { group: true }); remove(); }
|
|
53
|
+
catch { resolve(undefined); return; }
|
|
54
|
+
try { resolve(code === 0 && !overflowed && current() ? remoteHead(Buffer.concat(output)) : undefined); }
|
|
55
|
+
catch { resolve(undefined); }
|
|
56
|
+
};
|
|
57
|
+
try { proc = spawn(executable, argv.slice(1), { cwd: own, env: environment, detached: true, stdio: ["ignore", "pipe", "pipe"] }); }
|
|
58
|
+
catch { void finish(null); return; }
|
|
59
|
+
timer = setTimeout(() => void finish(null), timeoutMs);
|
|
60
|
+
watcher = setInterval(() => { try { if (!current()) void finish(null); } catch { void finish(null); } }, 50); watcher.unref();
|
|
61
|
+
proc.once("spawn", () => trackGroup(proc!));
|
|
62
|
+
const take = (chunk: Buffer, stdout: boolean) => {
|
|
63
|
+
if (done) return;
|
|
64
|
+
bytes += chunk.length;
|
|
65
|
+
if (bytes > OUTPUT_LIMIT) { overflowed = true; void finish(null); return; }
|
|
66
|
+
if (stdout) output.push(Buffer.from(chunk));
|
|
67
|
+
};
|
|
68
|
+
proc.stdout!.on("data", (chunk: Buffer) => take(chunk, true));
|
|
69
|
+
proc.stderr!.on("data", (chunk: Buffer) => take(chunk, false));
|
|
70
|
+
proc.once("error", () => void finish(null));
|
|
71
|
+
proc.once("exit", code => { if (code !== 0) void finish(null); });
|
|
72
|
+
proc.once("close", code => void finish(code));
|
|
73
|
+
});
|
|
74
|
+
} catch {
|
|
75
|
+
// A spawn/setup failure owns no live group; otherwise only confirmed stop above permits removing the private directory.
|
|
76
|
+
if (!proc && temporary) { try { rmSync(temporary, { recursive: true, force: true }); } catch { /* unknown cleanup remains private */ } }
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|