@staix/agent-hub 0.15.0 → 0.17.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 +43 -0
- package/README.md +12 -8
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +24 -8
- package/docs/agent-notes/budget.md +4 -2
- package/docs/agent-notes/daemon.md +11 -4
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +15 -3
- package/docs/agent-notes/tests.md +8 -3
- package/docs/dogfood.md +18 -6
- package/docs/events.md +1 -1
- package/docs/memory.md +78 -10
- package/docs/operations.md +160 -47
- package/docs/quickstart.md +13 -5
- package/docs/roles.md +1 -0
- package/docs/sdlc.md +11 -11
- package/docs/security.md +94 -22
- package/docs/smoke.md +58 -6
- package/docs/specs/2026-09-19-agent-hub-design.md +124 -12
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
- package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
- package/docs/verification/2026-10-24-amem-runtime.md +69 -0
- package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
- package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
- package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
- package/docs/verification/2026-10-25-0.16.0.md +82 -0
- package/docs/verification/2026-10-25-0.17.0.md +74 -0
- package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -0
- package/docs/verification/2026-10-25-kimi-231-reviewer-compat.md +48 -0
- package/docs/verification/2026-10-25-kimi-usage-ac11.md +48 -0
- package/docs/verification/2026-10-25-qwen-dual-output-hub-e2e.md +23 -0
- package/docs/verification/2026-10-25-qwen-dual-output-t0.md +20 -0
- package/docs/verification/2026-10-25-reviewer-no-verdict-diagnostics.md +35 -0
- package/docs/verified.json +30 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3421 -2176
- package/src/adapters/acp.ts +5 -2
- package/src/adapters/codex-appserver.ts +69 -1
- package/src/adapters/codex-review-isolation.ts +10 -1
- package/src/adapters/compat.ts +9 -4
- package/src/adapters/conformance/enrollment.ts +18 -4
- package/src/adapters/conformance/factories.ts +4 -1
- package/src/adapters/conformance/kit.ts +11 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -3
- package/src/adapters/definitions/native-cli-entry-qwen.ts +32 -4
- package/src/adapters/definitions/native-cli-preview-qwen.ts +9 -1
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/definitions/native-recovery.ts +8 -0
- package/src/adapters/definitions/qwen.ts +7 -4
- package/src/adapters/local-worker.ts +5 -4
- package/src/adapters/qwen-dual.ts +578 -0
- package/src/adapters/registry-types.ts +6 -2
- package/src/adapters/registry.ts +9 -1
- package/src/adapters/reviewer-mcp.ts +12 -6
- package/src/adapters/reviewer-print.ts +100 -17
- package/src/cli/console-state.ts +20 -2
- package/src/cli/console-symbols.ts +3 -2
- package/src/cli/console-usage.ts +5 -2
- package/src/cli/console.ts +5 -1
- package/src/cli/help.ts +7 -5
- package/src/cli/identity.ts +3 -1
- package/src/cli/main.ts +44 -10
- package/src/cli/memory.ts +119 -7
- package/src/cli/output.ts +12 -4
- package/src/cli/upgrade.ts +4 -3
- package/src/hub/action-class.ts +211 -17
- package/src/hub/action-enforce.ts +13 -5
- package/src/hub/action-policy.ts +19 -8
- package/src/hub/ask.ts +16 -4
- package/src/hub/board.ts +86 -31
- package/src/hub/budget-notes.ts +65 -0
- package/src/hub/budget.ts +42 -10
- package/src/hub/bus.ts +1 -1
- package/src/hub/code-facts.ts +56 -16
- package/src/hub/conductor.ts +5 -2
- package/src/hub/config-apply.ts +7 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +384 -71
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +2 -2
- package/src/hub/hub-tools.ts +3 -3
- package/src/hub/kimi-usage.ts +12 -0
- package/src/hub/lifecycle.ts +10 -5
- package/src/hub/memory-code-head.ts +44 -0
- package/src/hub/memory-code-overlay.ts +119 -0
- package/src/hub/memory-plan-guard.ts +11 -27
- package/src/hub/memory-stale-anchors.ts +159 -0
- package/src/hub/memory-symbols.ts +74 -0
- package/src/hub/planning.ts +10 -4
- package/src/hub/read-tools.ts +21 -3
- package/src/hub/sdlc.ts +34 -0
- package/src/hub/settings.ts +17 -2
- package/src/hub/settled-roles.ts +15 -3
- package/src/hub/task-sweep.ts +25 -3
- package/src/hub/tasks.ts +378 -76
- package/src/hub/token-window.ts +15 -2
- package/src/hub/window-kind.ts +13 -0
- package/src/local/tools.ts +5 -5
- package/src/memory/brief.ts +66 -16
- package/src/memory/capture.ts +26 -40
- package/src/memory/client.ts +137 -65
- package/src/memory/recall.ts +13 -22
- package/src/ui/index.html +100 -18
package/src/cli/memory.ts
CHANGED
|
@@ -1,15 +1,32 @@
|
|
|
1
|
+
import { TaskRepositories } from "../hub/task-repository.ts";
|
|
2
|
+
import type { TaskPlan, TaskRefs } from "../hub/board.ts";
|
|
3
|
+
import { Database } from "bun:sqlite";
|
|
4
|
+
import { lstatSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { CodeFacts } from "../hub/code-facts.ts";
|
|
7
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "../hub/db-timeout.ts";
|
|
8
|
+
import { hubGitEnv, hubGitSync } from "../hub/git.ts";
|
|
9
|
+
import { stateDirFor } from "../hub/control-client.ts";
|
|
10
|
+
import { isDenied } from "../local/deny.ts";
|
|
11
|
+
import { foldSegment } from "../hub/permission-mode.ts";
|
|
12
|
+
import { matchesPolicyLexically } from "../hub/path-policy.ts";
|
|
13
|
+
import { memoryAnchorTaskIdentity, memoryStaleAnchors, unavailableMemoryStaleAnchors, type MemoryStaleAnchorReport, type MemoryTaskPrivacy } from "../hub/memory-stale-anchors.ts";
|
|
14
|
+
import { MEMORY_SYMBOL_START_BUDGET_MS } from "../hub/memory-symbols.ts";
|
|
1
15
|
import { detectCliIdentity } from "./identity.ts";
|
|
2
16
|
import { createStalenessProbe } from "../hub/memory-staleness.ts";
|
|
3
|
-
import { looksSecret, memoryProjectKey, type MemoryStore } from "@staix/agent-mem";
|
|
17
|
+
import { looksSecret, memoryProjectKey, pathCandidates, readProfileJSON, resolveShardProfile, type MemoryStore } from "@staix/agent-mem";
|
|
4
18
|
import type { SyncOptions } from "@staix/agent-mem";
|
|
5
|
-
import {
|
|
19
|
+
import { memoryUsage, goalUsage, rawMemoryCommand, rawGoalCommand } from "@staix/agent-mem";
|
|
20
|
+
|
|
21
|
+
/** The hub's command names in agent-mem usage and refusal text (agent-mem 0.7.0 defaults to `amem`). */
|
|
22
|
+
const AHUB_NAMES = { memory: "ahub memory", goal: "ahub goal" };
|
|
6
23
|
import { mentionsPrivatePath, promotionScreen, withoutPii } from "../hub/memory-policy.ts";
|
|
7
24
|
import { currentRouting, detectSignals } from "../hub/routing.ts";
|
|
8
25
|
import { loadConfig } from "../hub/daemon.ts";
|
|
9
|
-
import { bindProfileMemory, memoryProjectFor, memoryProjectForStore, openProfileMemory, MEMORY_PROFILE_UNAVAILABLE, MEMORY_SPLIT_INCOMPLETE, type MemoryProfileReceipt, type ProfileMemoryOptions } from "../hub/memory-profile.ts";
|
|
26
|
+
import { bindProfileMemory, memoryProjectFor, memoryProjectForStore, openProfileMemory, MemoryProfileBinding, MEMORY_PROFILE_UNAVAILABLE, MEMORY_SPLIT_INCOMPLETE, type MemoryProfileReceipt, type ProfileMemoryOptions } from "../hub/memory-profile.ts";
|
|
10
27
|
|
|
11
|
-
export
|
|
12
|
-
export const MEMORY_USAGE = `${
|
|
28
|
+
export const GOAL_USAGE = goalUsage(AHUB_NAMES.goal);
|
|
29
|
+
export const MEMORY_USAGE = `${memoryUsage(AHUB_NAMES.memory)} | decay [--days N] [--json] | profile-check [--json]`;
|
|
13
30
|
|
|
14
31
|
const fail = (message: string): never => { throw new Error(message); };
|
|
15
32
|
/** The project's config, or none when there is no valid one (reading memory needs none). */
|
|
@@ -75,7 +92,7 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
|
|
|
75
92
|
screen: promotionScreen(project, cwd, () => currentRouting(cwd), privatePaths) }, cwd);
|
|
76
93
|
try {
|
|
77
94
|
context.binding.assertCurrent();
|
|
78
|
-
const text = await rawMemoryCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
|
|
95
|
+
const text = await rawMemoryCommand(args, cwd, { ...opts, names: AHUB_NAMES, project, store: context.store, open: () => context.store,
|
|
79
96
|
readable: notes => {
|
|
80
97
|
const paths = privatePaths(), visible = withoutPii(notes, currentRouting(cwd), cwd, paths);
|
|
81
98
|
if (args[0] !== "show" || args.includes("--json")) return visible;
|
|
@@ -99,9 +116,104 @@ export async function goalCommand(args: string[], cwd: string, opts: { store?: M
|
|
|
99
116
|
const context = commandStores(opts.store, { project }, cwd);
|
|
100
117
|
try {
|
|
101
118
|
context.binding.assertCurrent();
|
|
102
|
-
const text = await rawGoalCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
|
|
119
|
+
const text = await rawGoalCommand(args, cwd, { ...opts, names: AHUB_NAMES, project, store: context.store, open: () => context.store,
|
|
103
120
|
clean: (title, milestones, project) => !looksSecret(`${title}\n${milestones.join("\n")}`) && withoutPii([{ title, text: milestones.join("\n"), project }], currentRouting(cwd), cwd, policy()).length > 0,
|
|
104
121
|
});
|
|
105
122
|
return profileOutput(text, args.includes("--json"), context.binding.status(context.store), person);
|
|
106
123
|
} finally { if (context.owned) context.store.close(); }
|
|
107
124
|
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
interface StaleAnchorCommandOptions {
|
|
128
|
+
store?: MemoryStore;
|
|
129
|
+
facts?: Pick<CodeFacts, "exists">;
|
|
130
|
+
/** Trusted host test seam; never read from CLI arguments or project configuration. */
|
|
131
|
+
createFacts?: () => Pick<CodeFacts, "exists" | "close">;
|
|
132
|
+
stateDir?: string;
|
|
133
|
+
head?: () => string | undefined;
|
|
134
|
+
taskPrivacy?: (key: string) => MemoryTaskPrivacy;
|
|
135
|
+
now?: () => number;
|
|
136
|
+
}
|
|
137
|
+
/** Unlike an ordinary core open, inspection must not initialize a missing profile, machine or shard. */
|
|
138
|
+
function existingInspectionProfile(project: string, cwd: string): boolean {
|
|
139
|
+
try {
|
|
140
|
+
const binding = new MemoryProfileBinding({ project, projectKey: memoryProjectKey(cwd) });
|
|
141
|
+
const profiles = [binding.profile, ...(binding.profile.sharded ? [resolveShardProfile(binding.home, { user: true })] : [])];
|
|
142
|
+
for (const profile of profiles) {
|
|
143
|
+
const machineFile = join(profile.sharded ? profile.home : profile.dir, "machine.json"), machine = lstatSync(machineFile);
|
|
144
|
+
if (!machine.isFile() || machine.isSymbolicLink()) return false;
|
|
145
|
+
const metadata = readProfileJSON(machineFile, 4096) as { id?: unknown } | undefined;
|
|
146
|
+
if (!metadata || typeof metadata.id !== "string" || !/^[a-f0-9]{16}$/.test(metadata.id)) return false;
|
|
147
|
+
const logDirectory = lstatSync(join(profile.dir, "log"));
|
|
148
|
+
const localDirectory = lstatSync(join(profile.dir, "local"));
|
|
149
|
+
if (!logDirectory.isDirectory() || logDirectory.isSymbolicLink() || !localDirectory.isDirectory() || localDirectory.isSymbolicLink()) return false;
|
|
150
|
+
}
|
|
151
|
+
binding.assertCurrent(); return true;
|
|
152
|
+
} catch { return false; }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Host-only doctor report: selected amem reads may maintain derived indexes/leases, never notes or candidates. */
|
|
156
|
+
export async function memoryStaleAnchorsCommand(cwd: string, opts: StaleAnchorCommandOptions = {}): Promise<MemoryStaleAnchorReport> {
|
|
157
|
+
let ownedFacts: Pick<CodeFacts, "exists" | "close"> | undefined;
|
|
158
|
+
let context: ReturnType<typeof commandStores> | undefined;
|
|
159
|
+
try {
|
|
160
|
+
const project = projectOf(cwd, opts.store);
|
|
161
|
+
if (!opts.store && !existingInspectionProfile(project, cwd)) return unavailableMemoryStaleAnchors();
|
|
162
|
+
context = commandStores(opts.store, { project, projectKey: memoryProjectKey(cwd), deferPromotions: true }, cwd);
|
|
163
|
+
const config = () => loadConfig(cwd);
|
|
164
|
+
const publicMetadataText = (text: string): boolean => {
|
|
165
|
+
try {
|
|
166
|
+
const c = config(), paths = [...(c.pii?.paths ?? []), ...c.local.deny];
|
|
167
|
+
return !looksSecret(text) && !detectSignals({ title: "", detail: text, refs: {} }, currentRouting(cwd), cwd).includes("pii")
|
|
168
|
+
&& !mentionsPrivatePath(text, [], paths, cwd)
|
|
169
|
+
&& !pathCandidates(text).some(path => isDenied(path, c.local.deny) || isDenied(foldSegment(path), c.local.deny.map(foldSegment)));
|
|
170
|
+
} catch { return false; }
|
|
171
|
+
};
|
|
172
|
+
const clock = opts.now ?? performance.now.bind(performance), metadataDeadline = clock() + MEMORY_SYMBOL_START_BUDGET_MS;
|
|
173
|
+
const blockedTasks = new Map<string, "private" | "unknown">(), repositories = new TaskRepositories(cwd); let taskSourceUnavailable = false;
|
|
174
|
+
const taskPrivacy = opts.taskPrivacy ?? ((key: string): MemoryTaskPrivacy => {
|
|
175
|
+
const identity = memoryAnchorTaskIdentity(key, project); if (!identity) return "unknown";
|
|
176
|
+
const blocked = blockedTasks.get(key); if (blocked) return blocked;
|
|
177
|
+
if (taskSourceUnavailable || clock() >= metadataDeadline) return "unknown";
|
|
178
|
+
let db: Database | undefined;
|
|
179
|
+
try {
|
|
180
|
+
const file = join(opts.stateDir ?? stateDirFor(cwd), "hub.db"), stat = lstatSync(file);
|
|
181
|
+
if (!stat.isFile() || stat.isSymbolicLink()) { taskSourceUnavailable = true; return "unknown"; }
|
|
182
|
+
db = new Database(file, { readonly: true, create: false });
|
|
183
|
+
db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
184
|
+
const row = db.query(`SELECT id,created,signals,title,detail,refs,plan FROM tasks WHERE id=? AND created=?
|
|
185
|
+
AND length(signals)<=65536 AND length(title)<=300 AND length(detail)<=8000 AND length(refs)<=65536 AND length(plan)<=65536`).get(identity.id, identity.created) as { id: number; created: number; signals: string; title: string; detail: string; refs: string; plan: string } | null;
|
|
186
|
+
if (!row || row.id !== identity.id || row.created !== identity.created) { blockedTasks.set(key, "unknown"); return "unknown"; }
|
|
187
|
+
const signals: unknown = JSON.parse(row.signals), refs: unknown = JSON.parse(row.refs), plan: unknown = JSON.parse(row.plan);
|
|
188
|
+
if (!Array.isArray(signals) || !signals.every(signal => typeof signal === "string") || !refs || typeof refs !== "object" || Array.isArray(refs) || !plan || typeof plan !== "object" || Array.isArray(plan)) { blockedTasks.set(key, "unknown"); return "unknown"; }
|
|
189
|
+
const raw = [row.title, row.detail];
|
|
190
|
+
JSON.stringify({ refs, plan }, (key: string, value: unknown) => { if (key) raw.push(key); if (typeof value === "string") raw.push(value); return value; });
|
|
191
|
+
if (signals.includes("pii") || signals.includes("pii_path") || !publicMetadataText(raw.join("\n"))) { blockedTasks.set(key, "private"); return "private"; }
|
|
192
|
+
const paths = [...Object.values(refs), ...Object.values(plan)].flatMap(value => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []);
|
|
193
|
+
const c = config();
|
|
194
|
+
if (paths.some(path => isDenied(path, c.local.deny) || matchesPolicyLexically(cwd, path, [...(c.pii?.paths ?? []), ...c.local.deny]))) { blockedTasks.set(key, "private"); return "private"; }
|
|
195
|
+
const scope = repositories.scope(refs as TaskRefs);
|
|
196
|
+
if (scope.key !== repositories.project.key || repositories.mismatch(refs as TaskRefs, plan as TaskPlan)) { blockedTasks.set(key, "unknown"); return "unknown"; }
|
|
197
|
+
return "public";
|
|
198
|
+
} catch { taskSourceUnavailable = true; return "unknown"; }
|
|
199
|
+
finally { db?.close(); }
|
|
200
|
+
});
|
|
201
|
+
const head = opts.head ?? (() => {
|
|
202
|
+
try {
|
|
203
|
+
const result = hubGitSync(["-C", cwd, "rev-parse", "--verify", "HEAD"], { encoding: "utf8", env: hubGitEnv(process.env, { repositoryIsolation: true }), timeout: 1000, maxBuffer: 4096 });
|
|
204
|
+
const value = result.stdout.trim(); return result.status === 0 && /^[a-f0-9]{40}$/.test(value) ? value : undefined;
|
|
205
|
+
} catch { return undefined; }
|
|
206
|
+
});
|
|
207
|
+
const facts = opts.facts ?? (ownedFacts = opts.createFacts?.() ?? new CodeFacts({ timeoutMs: MEMORY_SYMBOL_START_BUDGET_MS }));
|
|
208
|
+
const binding = context.binding;
|
|
209
|
+
return await memoryStaleAnchors({ cwd, project, store: context.store, head, now: opts.now, taskPrivacy, publicMetadataText,
|
|
210
|
+
assertCurrent: () => binding.assertCurrent(),
|
|
211
|
+
context: () => ({ cwd, project, repo: project, deny: config().local.deny, allowed: publicMetadataText }),
|
|
212
|
+
exists: (repo, at, name) => facts.exists(repo, at, name),
|
|
213
|
+
finishQueries: async () => { if (ownedFacts) { await ownedFacts.close(); ownedFacts = undefined; } } });
|
|
214
|
+
} catch { return unavailableMemoryStaleAnchors(); }
|
|
215
|
+
finally {
|
|
216
|
+
try { if (ownedFacts) await ownedFacts.close(); } catch { /* Optional cleanup failure remains neutral, including a second failed close. */ }
|
|
217
|
+
try { if (context?.owned) context.store.close(); } catch { /* Inspection must still return its neutral report. */ }
|
|
218
|
+
}
|
|
219
|
+
}
|
package/src/cli/output.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { definitionOf } from "../adapters/registry.ts";
|
|
2
|
+
import { windowKind } from "../hub/window-kind.ts";
|
|
2
3
|
import { boardColumns, type BoardPeerInput } from "../ui/task-board.ts";
|
|
3
4
|
import { peerStyle } from "../ui/peer-palette.ts";
|
|
4
5
|
import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, terminalText, initialConsoleState, stateTone, type Span, type Tone } from "./console-state.ts";
|
|
@@ -16,12 +17,14 @@ export interface StatusOutput {
|
|
|
16
17
|
taskStalls?: readonly { task: number; cause: { kind: string; peer?: string; dependency?: number; code: string } }[];
|
|
17
18
|
pendingTaskOffers?: readonly { task: number; peer: string }[];
|
|
18
19
|
isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
|
|
20
|
+
memoryRead?: { truncated: boolean; scanned: number; limit: number };
|
|
19
21
|
memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string };
|
|
20
22
|
taskWip?: Record<string, { wip: number; wipLimit: number }>;
|
|
21
23
|
pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; actionPolicyInfo?: Record<string, { note: string; enforceable: boolean }>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
|
|
22
24
|
tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[];
|
|
23
25
|
}
|
|
24
|
-
|
|
26
|
+
/** `peers`: one note per peer that shows no number, and why (no quota, nothing read yet); absent from an older hub. */
|
|
27
|
+
export interface BudgetOutput { budget: Record<string, any>; gate?: number; peers?: { peer: string; kind?: string; note: string }[] }
|
|
25
28
|
export interface RosterRow { peer: string; on: boolean; startMode: string; state: string; queued: number; tasks: number }
|
|
26
29
|
export function renderRoster(data: RosterRow[], columns?: number): Span[][] {
|
|
27
30
|
return rows(["PEER", "ROSTER", "START", "STATE", "Q", "TASKS"], data.map(row => [span(row.peer, "info", true), span(row.on ? "on" : "off", row.on ? "success" : "muted", true), span(row.startMode, undefined, true), span(row.state, stateTone(row.state), true), span(row.queued, undefined, true), span(row.tasks, undefined, true)]), columns);
|
|
@@ -207,6 +210,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
207
210
|
const profile = memory.profile === "generation" ? `generation ${memory.generation?.slice(0, 8) ?? "unknown"}` : memory.profile ?? "unbound";
|
|
208
211
|
out.push(...detail("memory", `${profile}; ${memory.state ?? "unknown"}; agent-mem ${memory.agentMemVersion ?? "unknown"}${memory.skippedRows ? `; malformed legacy rows skipped: ${memory.skippedRows}` : ""}${memory.error ? `; ${memory.error}` : ""}`, columns, memory.state === "unavailable" ? "attention" : undefined));
|
|
209
212
|
}
|
|
213
|
+
if (data.memoryRead?.truncated) out.push(...detail("memory view", `truncated: newest ${data.memoryRead.limit} project candidates considered`, columns, "attention"));
|
|
210
214
|
if (data.planner) out.push(...detail("planner", `${data.planner.peers?.join(", ") || "nobody"} (${data.planner.source ?? "unknown"})${data.planner.source === "automatic" || data.planner.source === "none" ? "; pin it with ahub settings set planner <peer>" : ""}`, columns, data.planner.source === "none" ? "attention" : undefined));
|
|
211
215
|
if (data.deliveryError) out.push(...detail("delivery storage", `${data.deliveryError}; dispatch is stopped`, columns, "failure"));
|
|
212
216
|
for (const pending of data.permissionDefaults ?? []) out.push(...detail("permission default", `${pending.peer}: never-ask from ${pending.source}, stays ask until a person confirms in ahub console`, columns, "attention"));
|
|
@@ -358,9 +362,13 @@ export function renderBoard(tasks: any[], columns?: number, now = Date.now(), fu
|
|
|
358
362
|
}
|
|
359
363
|
export function renderBudget(data: BudgetOutput, columns?: number, now = Date.now(), _full = false): Span[][] {
|
|
360
364
|
const out: Span[][] = [];
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
365
|
+
// KIND names what the number is (#285): the provider's own (native), tokens the hub counted against a ceiling (count), or a person's (manual).
|
|
366
|
+
// A window without a kind, from an older hub, is classified by `windowKind`. A fresh window that does not gate says so in STATE.
|
|
367
|
+
const windows = Object.entries(data.budget).flatMap(([peer, b]) => (b.windows ?? []).map((w: any) => [span(peer, peerTone(peer)), span(w.id), span(`${Math.round(w.used * 100)}%`, "number"), span(typeof w.resetsAt === "number" ? relative(w.resetsAt, now) : "-", "muted"), span(windowKind({ id: w.id, source: w.source ?? "", kind: w.kind }), "muted"), span(w.source ?? "unknown", "muted"), span(typeof w.at === "number" ? relative(w.at, now) : "unknown", "muted"), span(w.stale ? "stale" : w.superseded ? "info only" : "fresh", w.stale ? "attention" : undefined)]));
|
|
368
|
+
if (windows.length) out.push(...rows(["PEER", "WINDOW", "USED", "RESETS", "KIND", "SOURCE", "MEASURED", "STATE"], windows, columns));
|
|
369
|
+
else if (!data.peers?.length) out.push(...lines(`no quota readings yet (gate ${data.gate ?? "unknown"}). Sources: Codex rate limits, Claude's status line (ahub claude), ahub budget set.`, columns));
|
|
370
|
+
// A peer with no number says why, in its own line, so a peer without quota is not mistaken for one that has not reported.
|
|
371
|
+
for (const note of data.peers ?? []) out.push(...detail(note.peer, note.note, columns, peerTone(note.peer)));
|
|
364
372
|
for (const [peer, b] of Object.entries(data.budget)) if (b.paused) out.push(...detail(`paused ${peer}`, `${b.paused.reason ?? "budget"}${typeof b.paused.resetsAt === "number" ? `; resumes ${relative(b.paused.resetsAt, now)}` : ""}`, columns, "attention"));
|
|
365
373
|
return out;
|
|
366
374
|
}
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -143,8 +143,8 @@ export interface RecoveryOperation {
|
|
|
143
143
|
/** Positive typed store observation; disposal rechecks it before accepting a repaired-session loss. */
|
|
144
144
|
missingRecordedSession?: { projectId: string; peer: RecoveryNativePeer; sessionId: string; kind: "rollout" | "transcript"; checkedPath?: string };
|
|
145
145
|
freshOverride?: { peer: RecoveryNativePeer; evidence: NonNullable<RecoveryOperation["missingRecordedSession"]>; checked: "missing"; at: number };
|
|
146
|
-
/** #215: every human disposition, in order. Operator reasons only; never task or message text. */
|
|
147
|
-
audit?: { at: number; action: "fresh-session" | "stop-and-archive"; reason: string; peer?: string; projects: string[] }[];
|
|
146
|
+
/** #215: every human disposition, in order. Operator reasons only; never task or message text. #776: `cause` is the operation's own `error` at that moment, the text the receipt already held. */
|
|
147
|
+
audit?: { at: number; action: "fresh-session" | "stop-and-archive"; reason: string; peer?: string; cause?: string; projects: string[] }[];
|
|
148
148
|
disposition?: { choice: "stop-and-archive"; at: number; projects: Record<string, string>; continuity?: Record<string, "not-archived" | "unavailable"> };
|
|
149
149
|
/** #215: a refusal no resume can get past was recorded (FinalRefusal); `next` no longer offers resume. */
|
|
150
150
|
final?: boolean;
|
|
@@ -1070,7 +1070,8 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
1070
1070
|
delete op.blockedReattachment;
|
|
1071
1071
|
delete op.presentationCause;
|
|
1072
1072
|
abandoned(op); // #227: in the same write as the disposition
|
|
1073
|
-
|
|
1073
|
+
// #776: the error that blocked the operation is cleared on disposal, so the audit keeps it.
|
|
1074
|
+
(op.audit ??= []).push({ at, action: "stop-and-archive", reason, ...(op.error ? { cause: op.error } : {}), projects: acts.map((a) => a.planned.project.id) });
|
|
1074
1075
|
// Recorded before the first act: a crash inside a stop leaves a disposition that resume refuses to override.
|
|
1075
1076
|
delete op.failure;
|
|
1076
1077
|
op.updatedAt = driver.now(); writeOperation(id, op, home);
|
package/src/hub/action-class.ts
CHANGED
|
@@ -5,7 +5,8 @@ export type ActionClass = typeof ACTION_CLASSES[number];
|
|
|
5
5
|
|
|
6
6
|
const SHELL_TOOLS = new Set(["bash", "shell", "shell_command", "exec_command", "run_shell_command"]);
|
|
7
7
|
const LIMIT = 16_384;
|
|
8
|
-
|
|
8
|
+
/** `nested`: the commands of each substitution in the segment (`$(...)`, a backtick). They run in a subshell, so their `cd` never moves the outer cwd. */
|
|
9
|
+
type Command = { words: string[]; outputs: string[]; nested?: Command[][] };
|
|
9
10
|
|
|
10
11
|
/** Bound the whole input before accepting any prefix, including unsupported suffixes. */
|
|
11
12
|
function segmentLimit(text: string): boolean {
|
|
@@ -28,8 +29,79 @@ function segmentLimit(text: string): boolean {
|
|
|
28
29
|
return true;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* An expansion (`$VAR`, `${...}`, `$(...)`, a backtick) stands in a word as this one character. The input itself may not contain it
|
|
34
|
+
* (`commands` refuses a NUL), so a word has it only where the hub put it: the word's value is unknown (#762).
|
|
35
|
+
*/
|
|
36
|
+
const OPAQUE = "\0";
|
|
37
|
+
const opaque = (word: string | undefined): boolean => word !== undefined && word.includes(OPAQUE);
|
|
38
|
+
|
|
39
|
+
/** The index after the bracket that closes `text[at]` (`(` or `{`), quotes and nested expansions respected, or -1 when it never closes. */
|
|
40
|
+
function matching(text: string, at: number, nest = 0): number {
|
|
41
|
+
if (nest > 16) return -1;
|
|
42
|
+
const open = text[at]!, close = open === "(" ? ")" : "}";
|
|
43
|
+
let depth = 0;
|
|
44
|
+
for (let i = at; i < text.length; i++) {
|
|
45
|
+
const c = text[i]!;
|
|
46
|
+
if (c === "\\") { i++; continue; }
|
|
47
|
+
if (c === "'") { const end = text.indexOf("'", i + 1); if (end < 0) return -1; i = end; continue; }
|
|
48
|
+
if (c === "`") { const end = text.indexOf("`", i + 1); if (end < 0) return -1; i = end; continue; }
|
|
49
|
+
if (c === '"') {
|
|
50
|
+
for (i++; i < text.length && text[i] !== '"'; i++) {
|
|
51
|
+
if (text[i] === "\\") i++;
|
|
52
|
+
else if (text[i] === "$" && (text[i + 1] === "(" || text[i + 1] === "{")) { const end = matching(text, i + 1, nest + 1); if (end < 0) return -1; i = end - 1; }
|
|
53
|
+
}
|
|
54
|
+
if (i >= text.length) return -1;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (c === "$" && (text[i + 1] === "(" || text[i + 1] === "{") && i + 1 !== at) { const end = matching(text, i + 1, nest + 1); if (end < 0) return -1; i = end - 1; continue; }
|
|
58
|
+
if (c === open) depth++;
|
|
59
|
+
else if (c === close && --depth === 0) return i + 1;
|
|
60
|
+
}
|
|
61
|
+
return -1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The expansion that starts at `text[at]` (a `$` or a backtick): where it ends, the command texts it runs (`inners`: `$(...)` and a backtick
|
|
66
|
+
* run their text; `${...}` runs only the substitutions nested in its parameter text, never the text itself), and `drop` for a `$"` that only
|
|
67
|
+
* opens a quote. Undefined for a `$` that is a literal dollar. An expansion that never closes ends at the end of the text.
|
|
68
|
+
*/
|
|
69
|
+
function expansion(text: string, at: number, quoted: boolean, nest = 0): { end: number; inners: string[]; drop?: true } | undefined {
|
|
70
|
+
if (text[at] === "`") {
|
|
71
|
+
for (let i = at + 1; i < text.length; i++) {
|
|
72
|
+
if (text[i] === "\\") { i++; continue; }
|
|
73
|
+
if (text[i] === "`") return { end: i + 1, inners: [text.slice(at + 1, i)] };
|
|
74
|
+
}
|
|
75
|
+
return { end: text.length, inners: [] };
|
|
76
|
+
}
|
|
77
|
+
const next = text[at + 1];
|
|
78
|
+
if (next === "(") {
|
|
79
|
+
const end = matching(text, at + 1);
|
|
80
|
+
return end < 0 ? { end: text.length, inners: [] } : { end, inners: [text.slice(at + 2, end - 1)] };
|
|
81
|
+
}
|
|
82
|
+
if (next === "{") { // parameter text is data: only the substitutions inside it run
|
|
83
|
+
const end = matching(text, at + 1);
|
|
84
|
+
const body = end < 0 ? text.slice(at + 2) : text.slice(at + 2, end - 1), inners: string[] = [];
|
|
85
|
+
for (let i = 0; i < body.length && nest < 4; i++) {
|
|
86
|
+
if (body[i] === "\\") { i++; continue; }
|
|
87
|
+
if (body[i] !== "$" && body[i] !== "`") continue;
|
|
88
|
+
const inner = expansion(body, i, false, nest + 1);
|
|
89
|
+
if (inner) { inners.push(...inner.inners); i = inner.end - 1; }
|
|
90
|
+
}
|
|
91
|
+
return { end: end < 0 ? text.length : end, inners };
|
|
92
|
+
}
|
|
93
|
+
if (next === "'") { // $'...': ANSI-C quoting, whose escapes build the word
|
|
94
|
+
for (let i = at + 2; i < text.length; i++) { if (text[i] === "\\") i++; else if (text[i] === "'") return { end: i + 1, inners: [] }; }
|
|
95
|
+
return { end: text.length, inners: [] };
|
|
96
|
+
}
|
|
97
|
+
if (next === '"') return quoted ? undefined : { end: at + 1, inners: [], drop: true };
|
|
98
|
+
if (next !== undefined && /[A-Za-z_]/.test(next)) { let end = at + 2; while (end < text.length && /[A-Za-z0-9_]/.test(text[end]!)) end++; return { end, inners: [] }; }
|
|
99
|
+
if (next !== undefined && /[0-9@*#?$!-]/.test(next)) return { end: at + 2, inners: [] };
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Shell words, literal where they can be read. An expansion becomes an opaque word and the commands it runs are read too; unsupported syntax discards only its unfinished segment. */
|
|
104
|
+
function commands(text: string, nest = 0): Command[] | undefined {
|
|
33
105
|
if (text.length > LIMIT || text.includes("\0") || !segmentLimit(text)) return;
|
|
34
106
|
const result: Command[] = [];
|
|
35
107
|
let words: string[] = [], outputs: string[] = [], word = "", active = false, quote = "";
|
|
@@ -40,7 +112,8 @@ function commands(text: string): Command[] | undefined {
|
|
|
40
112
|
else if (!redirect) words.push(word);
|
|
41
113
|
word = ""; active = false; redirect = false;
|
|
42
114
|
};
|
|
43
|
-
|
|
115
|
+
let nested: Command[][] = [];
|
|
116
|
+
const finish = () => { flush(); if (words.length || outputs.length || nested.length) result.push({ words, outputs, ...(nested.length ? { nested } : {}) }); words = []; outputs = []; nested = []; };
|
|
44
117
|
for (let i = 0; i < text.length; i++) {
|
|
45
118
|
const c = text[i]!;
|
|
46
119
|
if (quote === "'") { if (c === "'") quote = ""; else word += c; continue; }
|
|
@@ -51,7 +124,13 @@ function commands(text: string): Command[] | undefined {
|
|
|
51
124
|
if (next !== "\n") word += next;
|
|
52
125
|
active = true; continue;
|
|
53
126
|
}
|
|
54
|
-
if (c === "$" || c === "`")
|
|
127
|
+
if (c === "$" || c === "`") {
|
|
128
|
+
const found = expansion(text, i, quote === '"');
|
|
129
|
+
if (!found) { word += c; active = true; continue; } // a lone `$` is a literal dollar
|
|
130
|
+
if (nest < 4) for (const text of found.inners) { const inner = commands(text, nest + 1); if (inner?.length) nested.push(inner); }
|
|
131
|
+
if (!found.drop) { word += OPAQUE; active = true; }
|
|
132
|
+
i = found.end - 1; continue;
|
|
133
|
+
}
|
|
55
134
|
if (quote) { if (c === quote) quote = ""; else word += c; continue; }
|
|
56
135
|
if (c === "'" || c === '"') { quote = c; active = true; continue; }
|
|
57
136
|
if (c === "#" && !active) { while (i < text.length && text[i] !== "\n") i++; finish(); continue; }
|
|
@@ -97,6 +176,14 @@ function outside(target: string, root: string, cwd: string): boolean {
|
|
|
97
176
|
const rel = relative(resolve(root), resolve(cwd, target));
|
|
98
177
|
return rel === ".." || rel.startsWith("../") || isAbsolute(rel);
|
|
99
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* A word that starts with an expansion may expand to a dash word (`$OPTS` as `-rf`), so a class that depends on a flag counts it as that flag
|
|
181
|
+
* (#762). Only the words before `--` can be options. A word that merely has an expansion later (`build/$X`, `--name=$X`) cannot start one.
|
|
182
|
+
*/
|
|
183
|
+
function mayBeFlag(words: readonly string[]): boolean {
|
|
184
|
+
const end = words.indexOf("--");
|
|
185
|
+
return words.slice(0, end < 0 ? undefined : end).some(w => w.startsWith(OPAQUE));
|
|
186
|
+
}
|
|
100
187
|
function flags(words: string[], letter: string): boolean {
|
|
101
188
|
const end = words.indexOf("--");
|
|
102
189
|
return words.slice(0, end < 0 ? undefined : end).some(w => /^-[^-]/.test(w) && w.slice(1).includes(letter));
|
|
@@ -130,7 +217,7 @@ function commandHeads(words: string[], values: readonly string[], booleans: read
|
|
|
130
217
|
continue;
|
|
131
218
|
}
|
|
132
219
|
if (!uncertain) return [{ name: word, args: words.slice(i + 1) }];
|
|
133
|
-
if (actions.includes(word)) heads.push({ name: word, args: words.slice(i + 1) });
|
|
220
|
+
if (actions.includes(word) || opaque(word)) heads.push({ name: word, args: words.slice(i + 1) });
|
|
134
221
|
}
|
|
135
222
|
return heads;
|
|
136
223
|
}
|
|
@@ -153,6 +240,7 @@ function forcedClean(words: string[]): boolean {
|
|
|
153
240
|
if (flag === "n") dry = true;
|
|
154
241
|
}
|
|
155
242
|
}
|
|
243
|
+
else if (word.startsWith(OPAQUE)) force = true; // an expansion may be `-f`; a literal dry run still wins (#762)
|
|
156
244
|
}
|
|
157
245
|
return force && !dry;
|
|
158
246
|
}
|
|
@@ -182,12 +270,110 @@ function unwrapped(input: string[]): string[] {
|
|
|
182
270
|
else if (flag.startsWith("--") && !flag.includes("=") && !["--ignore-environment", "--foreground", "--preserve-status", "--no-run-if-empty", "--verbose"].includes(flag)) return [];
|
|
183
271
|
}
|
|
184
272
|
if (name === "timeout") {
|
|
185
|
-
|
|
273
|
+
const duration = words.shift() ?? "";
|
|
274
|
+
if (!/^\d+(?:\.\d+)?[smhd]?$/.test(duration) && !opaque(duration)) return [];
|
|
186
275
|
}
|
|
187
276
|
}
|
|
188
277
|
return [];
|
|
189
278
|
}
|
|
190
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Whether every definition of a GraphQL document is a read: a `query` (or shorthand `{...}`) or a `fragment`. Strings and comments are skipped, so
|
|
282
|
+
* a keyword inside one counts for nothing. A `mutation`, a `subscription`, any other top-level word, unbalanced text or no operation is false:
|
|
283
|
+
* unknown is never a read.
|
|
284
|
+
*/
|
|
285
|
+
function graphqlQueryOnly(text: string): boolean {
|
|
286
|
+
const skipString = (at: number): number => { // `at` is the opening quote; the index after the string, or -1 when it never ends
|
|
287
|
+
// A block string ends at its first `"""`: GraphQL's escaped `\"""` ends it later, so a lexer that ends early only turns a read into a write (the strict direction).
|
|
288
|
+
if (text.startsWith('"""', at)) { const end = text.indexOf('"""', at + 3); return end < 0 ? -1 : end + 3; }
|
|
289
|
+
// GraphQL's line terminators are LF, CR and CRLF, and none may appear in a single-line string: ending early is the strict direction.
|
|
290
|
+
for (let i = at + 1; i < text.length; i++) { if (text[i] === "\\") { i++; if (text[i] === "\n" || text[i] === "\r") return -1; } else if (text[i] === '"') return i + 1; else if (text[i] === "\n" || text[i] === "\r") return -1; }
|
|
291
|
+
return -1;
|
|
292
|
+
};
|
|
293
|
+
const skipComment = (at: number): number => { let i = at; while (i < text.length && text[i] !== "\n" && text[i] !== "\r") i++; return i; }; // a comment ends at LF or CR (so also CRLF), never later than GraphQL ends it
|
|
294
|
+
const closers: Record<string, string> = { "{": "}", "(": ")", "[": "]" };
|
|
295
|
+
const skipBlock = (at: number): number => { // `at` is an opening bracket; the index after its matching close, or -1
|
|
296
|
+
const close = closers[text[at]!]!;
|
|
297
|
+
for (let i = at + 1; i < text.length; i++) {
|
|
298
|
+
const c = text[i]!;
|
|
299
|
+
if (c === '"') { const end = skipString(i); if (end < 0) return -1; i = end - 1; }
|
|
300
|
+
else if (c === "#") i = skipComment(i) - 1;
|
|
301
|
+
else if (c in closers) { const end = skipBlock(i); if (end < 0) return -1; i = end - 1; }
|
|
302
|
+
else if (c === "}" || c === ")" || c === "]") return c === close ? i + 1 : -1;
|
|
303
|
+
}
|
|
304
|
+
return -1;
|
|
305
|
+
};
|
|
306
|
+
let start = true, operations = 0; // `start`: the next token begins a top-level definition
|
|
307
|
+
for (let i = 0; i < text.length;) {
|
|
308
|
+
const c = text[i]!;
|
|
309
|
+
if (/[\s,\uFEFF]/.test(c)) { i++; continue; }
|
|
310
|
+
if (c === "#") { i = skipComment(i); continue; }
|
|
311
|
+
if (c === "{") { const end = skipBlock(i); if (end < 0) return false; if (start) operations++; i = end; start = true; continue; } // shorthand query, or the body that ends a definition
|
|
312
|
+
if (c === "(") { if (start) return false; const end = skipBlock(i); if (end < 0) return false; i = end; continue; }
|
|
313
|
+
const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(text.slice(i, i + 256))?.[0];
|
|
314
|
+
if (name) {
|
|
315
|
+
if (start) { if (name === "query") operations++; else if (name !== "fragment") return false; start = false; }
|
|
316
|
+
i += name.length; continue;
|
|
317
|
+
}
|
|
318
|
+
if (!start && (c === "@" || c === ":")) { i++; continue; } // a directive, or `fragment X on Y`; nothing else is read at the top level
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
return start && operations > 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* `gh api graphql` with fields (#756): undefined when the call does not name the graphql endpoint, true only for ONE literal `query` field whose
|
|
326
|
+
* every operation is a read. A query from a file or stdin (`@file`, `--input`), a second `query` field, an option this does not know,
|
|
327
|
+
* another positional and every unparsable document are false: the caller keeps them external-write.
|
|
328
|
+
*/
|
|
329
|
+
function ghGraphqlRead(words: string[]): boolean | undefined {
|
|
330
|
+
const rest = words.slice(1); // `words` is `api ...`
|
|
331
|
+
if (!rest.includes("graphql")) return undefined;
|
|
332
|
+
const fieldOptions = ["-f", "-F", "--field", "--raw-field"];
|
|
333
|
+
const valued = ["-H", "--header", "--hostname", "-q", "--jq", "-t", "--template", "--cache", "-p", "--preview", "-X", "--method"];
|
|
334
|
+
const booleans = ["--paginate", "--slurp", "--silent", "-i", "--include", "--verbose"];
|
|
335
|
+
const positional: string[] = [], queries: string[] = [];
|
|
336
|
+
for (let i = 0; i < rest.length; i++) {
|
|
337
|
+
const word = rest[i]!;
|
|
338
|
+
if (word === "--" || !word.startsWith("-")) { positional.push(word); continue; }
|
|
339
|
+
let field: string | undefined;
|
|
340
|
+
if (fieldOptions.includes(word)) { field = rest[++i]; if (field === undefined) return false; }
|
|
341
|
+
else if (word.startsWith("--field=") || word.startsWith("--raw-field=")) field = word.slice(word.indexOf("=") + 1);
|
|
342
|
+
else if (/^-[fF][^=]/.test(word)) field = word.slice(2);
|
|
343
|
+
else if (valued.includes(word)) { if (++i >= rest.length) return false; }
|
|
344
|
+
else if (valued.some(key => key.startsWith("--") ? word.startsWith(`${key}=`) : /^-[HqtpX]./.test(word) && word.startsWith(key))) continue;
|
|
345
|
+
else if (!booleans.includes(word)) return false;
|
|
346
|
+
if (field !== undefined) {
|
|
347
|
+
const eq = field.indexOf("=");
|
|
348
|
+
if (eq < 0) return false;
|
|
349
|
+
if (field.slice(0, eq) === "query") queries.push(field.slice(eq + 1));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const method = option(rest, "-X", "--method");
|
|
353
|
+
if (method !== undefined && method.toUpperCase() !== "POST") return false;
|
|
354
|
+
const [query] = queries;
|
|
355
|
+
if (queries.some(opaque)) return false; // an expansion can close a brace or add an operation: unknown is never a read
|
|
356
|
+
if (positional.length !== 1 || positional[0] !== "graphql" || queries.length !== 1 || query === undefined || query.startsWith("@")) return false;
|
|
357
|
+
return graphqlQueryOnly(query);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* `gh api` with a word the hub cannot read where an option may stand (#762): an option name with an expansion in it, or a later operand that
|
|
362
|
+
* starts with one (`gh api repos/o/r $FLAGS`, which can hold `-X POST`). The endpoint (the first operand) and the values of the known valued
|
|
363
|
+
* options are data. Method, field and query values are judged by the ordinary rules, where an unknown method is never GET.
|
|
364
|
+
*/
|
|
365
|
+
function ghApiDynamic(words: string[]): boolean {
|
|
366
|
+
const valued = ["-H", "--header", "--hostname", "-q", "--jq", "-t", "--template", "--cache", "-p", "--preview", "-X", "--method", "-f", "-F", "--field", "--raw-field", "--input"];
|
|
367
|
+
let operands = 0;
|
|
368
|
+
for (let i = 1; i < words.length; i++) {
|
|
369
|
+
const word = words[i]!;
|
|
370
|
+
if (word === "--") return words.slice(i + 1).some((w, n) => opaque(w) && operands + n > 0);
|
|
371
|
+
if (word.startsWith("-")) { if (opaque(word)) return true; if (valued.includes(word)) i++; continue; }
|
|
372
|
+
if (operands++ > 0 && word.startsWith(OPAQUE)) return true;
|
|
373
|
+
}
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
|
|
191
377
|
/** Scan curl options once: data, headers and other option values never become options. */
|
|
192
378
|
function curlWrites(input: string[]): boolean {
|
|
193
379
|
const longValues = new Set([
|
|
@@ -249,7 +435,7 @@ function sequence(parsed: Command[], root: string, initial: string[], found: Set
|
|
|
249
435
|
const [program, ...args] = unwrapped(command.words);
|
|
250
436
|
const target = args[0] === "--" ? args[1] : args[0];
|
|
251
437
|
// Retain both outcomes: cd may fail, or be in a pipeline/conditional branch.
|
|
252
|
-
if (program === "cd" && (!target || target.startsWith("~"))) {
|
|
438
|
+
if (program === "cd" && (!target || target.startsWith("~") || opaque(target))) { // an unknown directory is an unknown cwd
|
|
253
439
|
if (!cwd.has("") && cwd.size >= 128) cwd.delete(cwd.values().next().value!);
|
|
254
440
|
cwd.add("");
|
|
255
441
|
}
|
|
@@ -262,6 +448,7 @@ function sequence(parsed: Command[], root: string, initial: string[], found: Set
|
|
|
262
448
|
}
|
|
263
449
|
}
|
|
264
450
|
function classify(command: Command, root: string, cwd: string[], found: Set<ActionClass>, depth = 0): void {
|
|
451
|
+
for (const group of command.nested ?? []) sequence(group, root, cwd, found, depth); // a substitution's own cwd state; only its classes come back
|
|
265
452
|
const words = unwrapped(command.words);
|
|
266
453
|
const program = basename(words.shift() ?? "");
|
|
267
454
|
if (["sh", "bash", "zsh"].includes(program) && depth < 2) {
|
|
@@ -270,7 +457,7 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
|
|
|
270
457
|
const mode = shell.shift();
|
|
271
458
|
if (mode && /^-[A-Za-z]*c[A-Za-z]*$/.test(mode)) {
|
|
272
459
|
if (shell[0] === "--") shell.shift();
|
|
273
|
-
const nested = shell[0] === undefined ? undefined : commands(shell[0]);
|
|
460
|
+
const nested = shell[0] === undefined ? undefined : commands(shell[0].replaceAll(OPAQUE, "$_"));
|
|
274
461
|
if (nested) sequence(nested, root, cwd, found, depth + 1);
|
|
275
462
|
}
|
|
276
463
|
}
|
|
@@ -278,11 +465,12 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
|
|
|
278
465
|
if (program === "git") {
|
|
279
466
|
const heads = commandHeads(words, ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"],
|
|
280
467
|
["-P", "-p", "--no-pager", "--paginate", "--literal-pathspecs", "--bare", "--no-optional-locks", "--no-replace-objects"], ["push", "reset", "clean", "branch"]);
|
|
468
|
+
if (heads.some(head => opaque(head.name))) { found.add("publish"); found.add("destroy"); } // `git $CMD`: the strictest classes git reaches (#762)
|
|
281
469
|
for (const { name: sub, args } of heads) {
|
|
282
470
|
if (sub === "push") found.add("publish");
|
|
283
|
-
if (sub === "reset" && args.some(w => w === "--hard" || w.startsWith("--hard="))) found.add("destroy");
|
|
471
|
+
if (sub === "reset" && (args.some(w => w === "--hard" || w.startsWith("--hard=")) || mayBeFlag(args))) found.add("destroy");
|
|
284
472
|
if (sub === "clean" && forcedClean(args)) found.add("destroy");
|
|
285
|
-
if (sub === "branch" && (flags(args, "D") || args.includes("--delete") && args.includes("--force"))) found.add("destroy");
|
|
473
|
+
if (sub === "branch" && (flags(args, "D") || args.includes("--delete") && args.includes("--force") || mayBeFlag(args))) found.add("destroy");
|
|
286
474
|
}
|
|
287
475
|
}
|
|
288
476
|
if (["npm", "cargo", "pnpm", "yarn", "bun"].includes(program)) {
|
|
@@ -300,8 +488,9 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
|
|
|
300
488
|
for (const head of heads) {
|
|
301
489
|
const candidates = program === "yarn" && head.name === "npm" ? commandHeads(head.args, values, booleans, actions) : [head];
|
|
302
490
|
for (const { name: sub, args } of candidates) {
|
|
491
|
+
if (opaque(sub) && program !== "bun") { found.add("publish"); found.add("install"); } // `npm $CMD`; bun runs a file by default, so its expansion is no subcommand (#762)
|
|
303
492
|
if (sub === "publish") found.add("publish");
|
|
304
|
-
const global = words.includes("--global") || flags(words, "g");
|
|
493
|
+
const global = words.includes("--global") || flags(words, "g") || mayBeFlag(words);
|
|
305
494
|
if ((program === "cargo" && sub === "install") || (["install", "i", "add"].includes(sub) && global) || (program === "yarn" && sub === "global" && args[0] === "add")) found.add("install");
|
|
306
495
|
}
|
|
307
496
|
}
|
|
@@ -310,20 +499,25 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
|
|
|
310
499
|
if (program === "gh") {
|
|
311
500
|
if (words[0] === "release" && ["create", "upload", "edit", "delete"].includes(words[1] ?? "")) found.add("publish");
|
|
312
501
|
if (["issue", "pr", "gist"].includes(words[0] ?? "") && ["create", "comment", "edit", "close", "reopen", "merge", "review", "delete"].includes(words[1] ?? "")) found.add("external-write");
|
|
313
|
-
if (words[0]
|
|
502
|
+
if (opaque(words[0])) { found.add("publish"); found.add("external-write"); } // `gh $GROUP ...`: the strictest classes gh reaches (#762)
|
|
503
|
+
else if (opaque(words[1]) && ["release"].includes(words[0] ?? "")) found.add("publish");
|
|
504
|
+
else if (opaque(words[1]) && ["issue", "pr", "gist", "run", "workflow", "repo", "secret", "variable", "label"].includes(words[0] ?? "")) found.add("external-write");
|
|
505
|
+
if (words[0] === "repo" && ["delete", "archive", "edit"].includes(words[1] ?? "") || ["secret", "variable"].includes(words[0] ?? "") && ["set", "delete"].includes(words[1] ?? "") || words[0] === "label" && ["create", "edit", "delete"].includes(words[1] ?? "")) found.add("external-write");
|
|
506
|
+
if (words[0] === "run" && ["rerun", "cancel", "delete"].includes(words[1] ?? "") || words[0] === "workflow" && ["run", "enable", "disable"].includes(words[1] ?? "")) found.add("external-write");
|
|
507
|
+
if (words[0] === "api" && ghGraphqlRead(words) !== true) { // a GraphQL document of queries only is a read (#756); everything else keeps the REST rule
|
|
314
508
|
const method = option(words, "-X", "--method");
|
|
315
509
|
const field = words.some(w => w === "-f" || w === "-F" || w.startsWith("-f") || w.startsWith("-F") || w === "--input" || w.startsWith("--input=") || w.startsWith("--field") || w.startsWith("--raw-field"));
|
|
316
|
-
if (method ? !["GET", "HEAD"].includes(method.toUpperCase()) : field) found.add("external-write");
|
|
510
|
+
if (method ? !["GET", "HEAD"].includes(method.toUpperCase()) : field || ghApiDynamic(words)) found.add("external-write");
|
|
317
511
|
}
|
|
318
512
|
}
|
|
319
513
|
if (program === "curl" && curlWrites(words)) found.add("external-write");
|
|
320
514
|
if (["http", "https"].includes(program) && words.some(w => /^(?:POST|PUT|PATCH|DELETE|OPTIONS|TRACE|CONNECT)$/.test(w))) found.add("external-write");
|
|
321
515
|
if (["mail", "mailx", "sendmail"].includes(program) && operands(words).length > 0) found.add("external-write");
|
|
322
516
|
if (["slack", "telegram", "discord"].includes(program) && words.some(w => ["send", "post", "chat.postMessage"].includes(w))) found.add("external-write");
|
|
323
|
-
if (program === "rm" && (flags(words, "r") || flags(words, "R") || words.includes("--recursive")) && operands(words).some(p => cwd.some(at => outside(p, root, at)))) found.add("destroy");
|
|
324
|
-
if (["cp", "mv"].includes(program) && (flags(words, "f") || words.includes("--force"))) {
|
|
517
|
+
if (program === "rm" && (flags(words, "r") || flags(words, "R") || words.includes("--recursive") || operands(words).length > 1 && mayBeFlag(words)) && operands(words).some(p => opaque(p) /* an expansion may hold `..` or an absolute path (#762) */ || cwd.some(at => outside(p, root, at)))) found.add("destroy");
|
|
518
|
+
if (["cp", "mv"].includes(program) && (flags(words, "f") || words.includes("--force") || words.length > 2 && mayBeFlag(words.slice(0, -1)))) { // with two words the first cannot be a flag: `cp -f x` has no target
|
|
325
519
|
const target = option(words, "-t", "--target-directory") ?? operands(words).at(-1);
|
|
326
|
-
if (target && cwd.some(at => outside(target, root, at))) found.add("destroy");
|
|
520
|
+
if (target && (opaque(target) || cwd.some(at => outside(target, root, at)))) found.add("destroy");
|
|
327
521
|
}
|
|
328
522
|
}
|
|
329
523
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ACTION_CLASSES, classifyAction, type ActionClass } from "./action-class.ts";
|
|
2
|
-
import {
|
|
2
|
+
import { actionApprovalView, actionPolicyConfig, type ActionBudgets, type ActionPolicyConfig } from "./action-policy.ts";
|
|
3
3
|
import type { ActionGateVerdict } from "../adapters/acp.ts";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -66,14 +66,22 @@ export interface ActionGatePorts<T> {
|
|
|
66
66
|
* within an approved class budget, and every unclassed call, return undefined and follow the permission mode as before (the budget
|
|
67
67
|
* spends here, once per request).
|
|
68
68
|
*/
|
|
69
|
-
export function actionGate<T>(ports: ActionGatePorts<T>, peer: string): (call: ActionCall, session: string) => ActionGateVerdict | undefined {
|
|
70
|
-
return (call, session) => {
|
|
69
|
+
export function actionGate<T>(ports: ActionGatePorts<T>, peer: string): (call: ActionCall, session: string, onPremToolTitle?: string) => ActionGateVerdict | undefined {
|
|
70
|
+
return (call, session, onPremToolTitle) => {
|
|
71
71
|
const classes = classifyCall(call, ports.root(peer));
|
|
72
72
|
if (!classes.length) return undefined;
|
|
73
73
|
const decision = ports.budgets.request(ports.budgets.bindSession(peer, session), classes);
|
|
74
74
|
if (decision.kind !== "ask") return undefined; // native, or within an approved class budget
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
// Only the host's local/Pi permit callback supplies this full title. Match the exact executed tool form before bypassing public redaction.
|
|
76
|
+
const input = call.input && typeof call.input === "object" && !Array.isArray(call.input) ? call.input as Record<string, unknown> : undefined;
|
|
77
|
+
const expected = call.names.includes("bash") && typeof input?.command === "string" ? `bash: ${input.command}`
|
|
78
|
+
: call.names.includes("git") && Array.isArray(input?.args) && input.args.every(word => typeof word === "string") ? `git ${input.args.join(" ")}` : undefined;
|
|
79
|
+
const complete = expected !== undefined && expected === onPremToolTitle;
|
|
80
|
+
const view = onPremToolTitle === undefined ? actionApprovalView(decision.classes, commandText(call.input), ports.task(peer), {
|
|
81
|
+
publicView: task => ports.publicView(task), private: ports.isPrivate(peer), commandView: command => ports.nameable(command) ? command : undefined })
|
|
82
|
+
: { title: `Action approval (${decision.classes.join(", ")})\n${complete ? onPremToolTitle : "[complete command unavailable]"}`, complete };
|
|
83
|
+
const { title } = view;
|
|
84
|
+
if (!view.complete) return { title, denyOnly: true };
|
|
77
85
|
const challenge = decision.challenge, limits = actionPolicyConfig(ports.policy()).budgets;
|
|
78
86
|
// A budget of one is this call alone: the ordinary allow already says that, so no second choice is offered for it.
|
|
79
87
|
if (!challenge || !decision.classes.some(key => limits[key] > 1)) return { title };
|