@staix/agent-hub 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +4 -4
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -6
- package/docs/agent-notes/benchmarks.md +1 -0
- package/docs/agent-notes/daemon.md +6 -2
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +11 -2
- package/docs/agent-notes/tests.md +7 -3
- package/docs/dogfood.md +22 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +162 -35
- package/docs/profiles.md +36 -0
- package/docs/quickstart.md +15 -5
- package/docs/sdlc.md +29 -3
- package/docs/security.md +44 -13
- package/docs/smoke.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +31 -10
- package/docs/verification/2026-10-25-0.18.0.md +76 -0
- package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
- package/docs/verification/2026-10-25-remote-docker-transport.md +56 -0
- package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
- package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
- package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
- package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
- package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
- package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
- package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
- package/docs/verification/2026-10-27-0.19.0.md +86 -0
- package/docs/verification/claude-permission-transport-native.py +187 -0
- package/docs/verification/claude-shell-wrapper-host.ts +20 -0
- package/docs/verification/claude-shell-wrapper-native.py +145 -0
- package/docs/verification/pi-140-model-free.ts +57 -0
- package/docs/verification/pi-140-native.mjs +21 -0
- package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
- package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
- package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
- package/docs/verified.json +24 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +364 -98
- package/src/adapters/agy/entry.ts +1 -1
- package/src/adapters/claude-channel.ts +7 -3
- package/src/adapters/claude-implementer.ts +5 -3
- package/src/adapters/compat.ts +32 -18
- package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -3
- package/src/adapters/definitions/native-factories.ts +7 -2
- package/src/adapters/definitions/reviewer-variants.ts +2 -2
- package/src/adapters/local-worker.ts +5 -3
- package/src/adapters/pi.ts +39 -3
- package/src/adapters/qwen-dual.ts +25 -0
- package/src/adapters/registry-types.ts +6 -2
- package/src/adapters/registry.ts +1 -0
- package/src/cli/agents.ts +69 -7
- package/src/cli/claude-shell-wrapper.ts +54 -0
- package/src/cli/console-state.ts +9 -2
- package/src/cli/help.ts +29 -28
- package/src/cli/identity.ts +87 -6
- package/src/cli/main.ts +198 -71
- package/src/cli/output.ts +11 -2
- package/src/cli/sdlc-output.ts +242 -0
- package/src/cli/task-output.ts +212 -0
- package/src/cli/upgrade-output.ts +57 -0
- package/src/hub/agent-drift.ts +61 -0
- package/src/hub/board.ts +31 -2
- package/src/hub/check-runner.ts +385 -25
- package/src/hub/claude-shell-wrapper.ts +349 -0
- package/src/hub/conductor.ts +5 -0
- package/src/hub/config-apply.ts +16 -7
- package/src/hub/config-edit-host.ts +1 -1
- package/src/hub/config-edit.ts +2 -2
- package/src/hub/config-tier.ts +3 -3
- package/src/hub/daemon.ts +530 -87
- package/src/hub/dogfood-profile.ts +248 -0
- package/src/hub/dogfood-read.ts +77 -14
- package/src/hub/dogfood.ts +5 -1
- package/src/hub/events.ts +1 -1
- package/src/hub/host-executable.ts +21 -1
- package/src/hub/hub-tools.ts +2 -2
- package/src/hub/instances.ts +18 -2
- package/src/hub/lifecycle.ts +1 -1
- package/src/hub/operational-authority.ts +67 -0
- package/src/hub/operational-view.ts +137 -0
- package/src/hub/orphan-process.ts +69 -0
- package/src/hub/permission-mode.ts +1 -1
- package/src/hub/read-only-shell-proof.ts +81 -0
- package/src/hub/read-only-shell.ts +162 -0
- package/src/hub/review-scope.ts +13 -0
- package/src/hub/reviewer-pool-config.ts +3 -2
- package/src/hub/reviewer-pool.ts +3 -0
- package/src/hub/routing.ts +23 -6
- package/src/hub/sdlc-merge.ts +299 -0
- package/src/hub/sdlc-metrics.ts +6 -4
- package/src/hub/sdlc-mirror.ts +129 -0
- package/src/hub/sdlc.ts +58 -12
- package/src/hub/settings.ts +11 -5
- package/src/hub/snapshots.ts +60 -0
- package/src/hub/task-repository.ts +31 -5
- package/src/hub/task-shell-command.ts +85 -0
- package/src/hub/task-shell-default.ts +78 -0
- package/src/hub/task-shell-proof.ts +171 -0
- package/src/hub/task-shell-sandbox.ts +61 -0
- package/src/hub/tasks.ts +378 -27
- package/src/local/deny.ts +91 -11
- package/src/local/sandbox.ts +67 -28
- package/src/local/shell-environment.ts +10 -0
- package/src/local/tools.ts +17 -4
- package/src/ui/index.html +3 -1
package/src/hub/sdlc.ts
CHANGED
|
@@ -33,7 +33,12 @@ export type StageGate = "issue" | "pr" | "local" | "review" | "checks" | "criter
|
|
|
33
33
|
export interface StageDefinition { base: SdlcBaseStage; actor: string; entry_gates: StageGate[]; exit_gates: StageGate[]; after: string[]; }
|
|
34
34
|
export type SdlcMode = "off" | "advise" | "enforce";
|
|
35
35
|
/** `workflow: "gsd-lite"` marks the retired setting during its window (#481 S8): every task then needs a criteria checklist. */
|
|
36
|
-
|
|
36
|
+
/** #786: `off` never merges, `dry-run` reports the decision and runs nothing, `on` runs the guarded `gh pr merge`. */
|
|
37
|
+
export type AutoMergeMode = "off" | "dry-run" | "on";
|
|
38
|
+
export const AUTO_MERGE_MODES: readonly AutoMergeMode[] = ["off", "dry-run", "on"];
|
|
39
|
+
/** #786: one repository's own gate: its required checks (before the top-level list) and its own merge mode. */
|
|
40
|
+
export interface RepoMergePolicy { requiredChecks?: string[]; autoMerge?: AutoMergeMode }
|
|
41
|
+
export interface SdlcConfig { workflow?: "gsd-lite"; maxNits?: number; /** #481 S7: optional control bands per metric; absent means none. */ bands?: BandsConfig; mode: SdlcMode; requiredChecks: string[]; /** #786: absent reads as "off"; written only when the file names it. */ autoMerge?: AutoMergeMode; /** #786: per-repository tables, keyed by lowercase owner/name. */ repos?: Record<string, RepoMergePolicy>; stages: SdlcStage[]; definitions?: Record<string, StageDefinition>; deprecations?: string[]; }
|
|
37
42
|
export interface PlanReviewReceipt {
|
|
38
43
|
planner: string;
|
|
39
44
|
planAuthor?: string | null;
|
|
@@ -43,7 +48,17 @@ export interface PlanReviewReceipt {
|
|
|
43
48
|
scope?: CostPlan;
|
|
44
49
|
issue?: PlanIssueMetadata;
|
|
45
50
|
}
|
|
51
|
+
/** Host-observed guarded merge evidence. Never accepted from a tool or restored across an artifact relink. */
|
|
52
|
+
export interface MergeRecord {
|
|
53
|
+
repo: string; pr: number; head: string; at: number;
|
|
54
|
+
state: "merged" | "not_merged" | "unknown";
|
|
55
|
+
exit: number; mergeCommit?: string;
|
|
56
|
+
cleanup: "not_started" | "claimed" | "deleted" | "already_gone" | "skipped" | "unknown";
|
|
57
|
+
reason?: "not_merged" | "missing_commit" | "metadata_unknown" | "identity_changed" | "protected_branch" | "default_branch" | "head_changed" | "authority_changed" | "delete_unconfirmed";
|
|
58
|
+
ownerNotice: "not_started" | "claimed" | "sent" | "unknown";
|
|
59
|
+
}
|
|
46
60
|
export interface SdlcReceipt {
|
|
61
|
+
merge?: MergeRecord;
|
|
47
62
|
stage: SdlcStage;
|
|
48
63
|
planReview?: PlanReviewReceipt;
|
|
49
64
|
criteria?: CriteriaReceipt;
|
|
@@ -78,6 +93,8 @@ export interface PrEvidence {
|
|
|
78
93
|
base?: string;
|
|
79
94
|
baseHead?: string;
|
|
80
95
|
mergeCommit?: string;
|
|
96
|
+
/** #786: the PR's head branch name, read so a release branch is refused; never used as an identity. */
|
|
97
|
+
headBranch?: string;
|
|
81
98
|
}
|
|
82
99
|
const sha = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{40}$/.test(value);
|
|
83
100
|
const repo = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith("-");
|
|
@@ -131,12 +148,37 @@ function definitions(raw: unknown, stages: unknown, phaseAlias: { allowed: boole
|
|
|
131
148
|
return { stages: [...stages], definitions: result };
|
|
132
149
|
}
|
|
133
150
|
|
|
151
|
+
const checkList = (value: unknown, what: string): string[] => {
|
|
152
|
+
if (!Array.isArray(value) || value.length > 32 || value.some(c => typeof c !== "string" || !c.trim() || c.length > 120 || /[\r\n\x00-\x1f]/.test(c)) || new Set(value).size !== value.length) throw new Error(`${what}: expected at most 32 unique nonempty names`);
|
|
153
|
+
return [...value] as string[];
|
|
154
|
+
};
|
|
155
|
+
const autoMergeMode = (value: unknown, what: string): AutoMergeMode => {
|
|
156
|
+
if (!AUTO_MERGE_MODES.includes(value as AutoMergeMode)) throw new Error(`${what}: expected off, dry-run or on`);
|
|
157
|
+
return value as AutoMergeMode;
|
|
158
|
+
};
|
|
159
|
+
/** #786: `[repos."owner/name"]` tables. Unknown keys are refused like everywhere else in this file. */
|
|
160
|
+
function mergeRepos(value: unknown): Record<string, RepoMergePolicy> | undefined {
|
|
161
|
+
if (value === undefined) return undefined;
|
|
162
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length > 16) throw new Error("sdlc.repos: expected at most 16 tables named owner/repository");
|
|
163
|
+
const result: Record<string, RepoMergePolicy> = Object.create(null);
|
|
164
|
+
for (const [name, table] of Object.entries(value)) {
|
|
165
|
+
if (!repo(name)) throw new Error(`sdlc.repos: "${String(name).slice(0, 60)}" is not an owner/repository name`);
|
|
166
|
+
if (!table || typeof table !== "object" || Array.isArray(table)) throw new Error(`sdlc.repos.${name}: expected a table`);
|
|
167
|
+
const row = table as Record<string, unknown>;
|
|
168
|
+
for (const key of Object.keys(row)) if (!["required_checks", "auto_merge"].includes(key)) throw new Error(`sdlc.repos.${name}.${key}: unknown field`);
|
|
169
|
+
const key = name.toLowerCase();
|
|
170
|
+
if (result[key]) throw new Error(`sdlc.repos.${name}: repeats another table (names are case-insensitive)`);
|
|
171
|
+
result[key] = { ...(row.required_checks === undefined ? {} : { requiredChecks: checkList(row.required_checks, `sdlc.repos.${name}.required_checks`) }), ...(row.auto_merge === undefined ? {} : { autoMerge: autoMergeMode(row.auto_merge, `sdlc.repos.${name}.auto_merge`) }) };
|
|
172
|
+
}
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
|
|
134
176
|
/** `version` is the running hub's, which closes the #481 S8 window; the daemon and Tasks pass it. Without one the window is open. */
|
|
135
177
|
export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
|
|
136
178
|
if (value === undefined) return { mode: "off", requiredChecks: [], stages: [...SDLC_STAGES] };
|
|
137
179
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdlc: expected a table");
|
|
138
180
|
const raw = value as Record<string, unknown>;
|
|
139
|
-
for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions", "max_nits", "bands"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
|
|
181
|
+
for (const key of Object.keys(raw)) if (!["mode", "required_checks", "stages", "workflow", "definitions", "max_nits", "bands", "auto_merge", "repos"].includes(key)) throw new Error(`sdlc.${key}: unknown field`);
|
|
140
182
|
const maxNits = raw.max_nits === undefined ? DEFAULT_MAX_NITS : raw.max_nits;
|
|
141
183
|
if (typeof maxNits !== "number" || !Number.isSafeInteger(maxNits) || maxNits < 0 || maxNits > 64) throw new Error("sdlc.max_nits: expected an integer from 0 to 64");
|
|
142
184
|
const bands = bandsConfig(raw.bands), bandConfig = Object.keys(bands).length ? { bands } : {};
|
|
@@ -150,12 +192,13 @@ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
|
|
|
150
192
|
if (workflow !== "none" && workflow !== "gsd-lite") throw new Error("sdlc.workflow: expected none or gsd-lite");
|
|
151
193
|
const notes = workflow === "gsd-lite" ? [`sdlc.workflow = "gsd-lite" is deprecated and stops loading in ${RETIRED_WORKFLOW_FROM} (#481): remove it; every task then needs a criteria checklist recorded with hub_sdlc_record`] : [];
|
|
152
194
|
const phaseAlias = { allowed: window, used: false };
|
|
153
|
-
const checks = raw.required_checks ?? [];
|
|
154
|
-
|
|
195
|
+
const checks = checkList(raw.required_checks ?? [], "sdlc.required_checks");
|
|
196
|
+
// #786: absent keys add nothing to the config, so the off-mode default keeps its #257 shape byte for byte.
|
|
197
|
+
const mergeConfig = { ...(raw.auto_merge === undefined ? {} : { autoMerge: autoMergeMode(raw.auto_merge, "sdlc.auto_merge") }), ...(raw.repos === undefined ? {} : { repos: mergeRepos(raw.repos)! }) };
|
|
155
198
|
if (raw.definitions !== undefined) {
|
|
156
199
|
const custom = definitions(raw.definitions, raw.stages, phaseAlias);
|
|
157
200
|
if (phaseAlias.used) notes.push(`the sdlc phase gate is deprecated and stops loading in ${RETIRED_WORKFLOW_FROM} (#481): name it criteria`);
|
|
158
|
-
return { mode, ...findingConfig, requiredChecks: [...checks] as string[], ...custom, ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
|
|
201
|
+
return { mode, ...findingConfig, ...mergeConfig, requiredChecks: [...checks] as string[], ...custom, ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
|
|
159
202
|
}
|
|
160
203
|
// Off keeps the #257 seven stages byte for byte; an enabled lifecycle defaults to the playbook (#481 S0).
|
|
161
204
|
const given = raw.stages ?? (mode === "off" ? [...SDLC_STAGES] : [...PLAYBOOK_STAGES]);
|
|
@@ -163,7 +206,7 @@ export function sdlcConfig(value: unknown, version?: string): SdlcConfig {
|
|
|
163
206
|
// An enabled #257 list keeps its order under canonical ids, so implement/verify stay read-only aliases there too.
|
|
164
207
|
const stages = mode !== "off" && exactly(SDLC_STAGES) ? SDLC_STAGES.map(canonicalStage) : given;
|
|
165
208
|
if (!exactly(SDLC_STAGES) && !exactly(PLAYBOOK_STAGES)) throw new Error(`sdlc.stages: use ${PLAYBOOK_STAGES.join(", ")} in that order (or the #257 list ${SDLC_STAGES.join(", ")}); other lists need definitions`);
|
|
166
|
-
return { mode, ...findingConfig, requiredChecks: [...checks] as string[], stages: [...stages as string[]], ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
|
|
209
|
+
return { mode, ...findingConfig, ...mergeConfig, requiredChecks: [...checks] as string[], stages: [...stages as string[]], ...(workflow === "gsd-lite" ? { workflow } : {}), ...(notes.length ? { deprecations: notes } : {}) };
|
|
167
210
|
}
|
|
168
211
|
|
|
169
212
|
export function loadSdlc(cwd: string, version?: string): SdlcConfig {
|
|
@@ -213,6 +256,7 @@ export function sdlcReceipt(value: unknown, previous: SdlcReceipt | undefined =
|
|
|
213
256
|
|
|
214
257
|
function receiptIdentity(next: SdlcReceipt, previous: SdlcReceipt, raw: Record<string, unknown>): SdlcReceipt {
|
|
215
258
|
const checklist = raw.criteria ?? raw.phase;
|
|
259
|
+
if (next.repo !== previous.repo || next.pr !== previous.pr || next.head !== previous.head || next.reviewedHead !== previous.reviewedHead || next.localHead !== previous.localHead) delete next.merge;
|
|
216
260
|
// A test receipt never carries across a different linked issue or artifact.
|
|
217
261
|
if (raw.redFirst === undefined && (next.repo !== previous.repo || next.issue !== previous.issue || next.pr !== previous.pr)) delete next.redFirst;
|
|
218
262
|
// An acceptance names one spec issue; relinking the receipt to another drops it (#481 S1).
|
|
@@ -272,7 +316,8 @@ export function parsePrEvidence(value: unknown): PrEvidence {
|
|
|
272
316
|
review: p.reviewDecision === "APPROVED" ? "approved" : p.reviewDecision === "CHANGES_REQUESTED" ? "changes_requested" : "unknown",
|
|
273
317
|
pendingReviews: Array.isArray(p.reviewRequests) && p.reviewRequests.length <= 100 && p.reviewRequests.every((r: unknown) => !!r && typeof r === "object") ? p.reviewRequests.length : null,
|
|
274
318
|
unresolved: Number.isSafeInteger(p.unresolvedThreads) && p.unresolvedThreads >= 0 ? p.unresolvedThreads : null,
|
|
275
|
-
...(sha(p.mergeCommit?.oid) ? { mergeCommit: p.mergeCommit.oid } : {})
|
|
319
|
+
...(sha(p.mergeCommit?.oid) ? { mergeCommit: p.mergeCommit.oid } : {}),
|
|
320
|
+
...(typeof p.headRefName === "string" && p.headRefName && p.headRefName.length <= 200 && !/[\x00-\x1f\x7f]/.test(p.headRefName) ? { headBranch: p.headRefName } : {}) };
|
|
276
321
|
}
|
|
277
322
|
|
|
278
323
|
/** Issue identity and edit times only; no body is fetched or stored. `lastEditedAt` moves on a body edit, not on comments. */
|
|
@@ -351,7 +396,7 @@ export function touchingPrs(prs: { pr: number; paths: string[] }[]): { before: n
|
|
|
351
396
|
}
|
|
352
397
|
|
|
353
398
|
export type SdlcGh = (args: string[], timeoutMs?: number, cwd?: string, maxOutputBytes?: number) => Promise<{ code: number; stdout: string }>;
|
|
354
|
-
/**
|
|
399
|
+
/** Fixed argv and bounded output and time; diagnostics never expose credentials or remote text. Reads, except the one guarded `gh pr merge` that `sdlc-merge.ts` builds (#786). */
|
|
355
400
|
export const sdlcGh: SdlcGh = (args, timeoutMs = 10_000, cwd, maxOutputBytes = 256 * 1024) => new Promise((resolve) => {
|
|
356
401
|
const outputLimit = Number.isSafeInteger(maxOutputBytes) ? Math.min(2 * 1024 * 1024, Math.max(1, maxOutputBytes)) : 256 * 1024;
|
|
357
402
|
let child: ReturnType<typeof spawn>;
|
|
@@ -409,7 +454,7 @@ export function linkedReceipt(previous: SdlcReceipt | undefined, link: { repo: s
|
|
|
409
454
|
export async function readSdlcPr(receipt: SdlcReceipt, run: SdlcGh = sdlcGh, withIntegrity = false): Promise<PrEvidence | undefined> {
|
|
410
455
|
if (!repo(receipt.repo) || !id(receipt.pr)) return undefined;
|
|
411
456
|
try {
|
|
412
|
-
const view = await run(["pr", "view", String(receipt.pr), "--repo", receipt.repo, "--json", "headRefOid,state,statusCheckRollup,reviewDecision,reviewRequests,files,changedFiles,mergeCommit,isDraft,baseRefName,baseRefOid"]);
|
|
457
|
+
const view = await run(["pr", "view", String(receipt.pr), "--repo", receipt.repo, "--json", "headRefOid,state,statusCheckRollup,reviewDecision,reviewRequests,files,changedFiles,mergeCommit,isDraft,baseRefName,baseRefOid,headRefName"]);
|
|
413
458
|
if (view.code !== 0) return undefined;
|
|
414
459
|
const [owner, name] = receipt.repo.split("/");
|
|
415
460
|
// The enabled S4 label read shares the existing thread snapshot, never a second readback for a known non-bug.
|
|
@@ -468,10 +513,11 @@ export function reviewReadinessFromEvidence(evidence: PrEvidence | undefined, ex
|
|
|
468
513
|
else if (evidence.head !== expected) blockers.push("review head changed");
|
|
469
514
|
if (evidence.state !== "OPEN") blockers.push("review PR is not open");
|
|
470
515
|
if (!checks.every(name => { const matched = evidence.checks.filter(check => check.name === name); return matched.length === 1 && matched[0]?.state === "passed"; })) blockers.push("exact-head full CI is not green");
|
|
471
|
-
return { head: evidence.head, ready: blockers.length === 0, blockers };
|
|
516
|
+
return { head: evidence.head, ready: blockers.length === 0, blockers, checks };
|
|
472
517
|
}
|
|
473
518
|
|
|
474
|
-
|
|
519
|
+
/** `checks` (#824) names the required checks that were judged, so a fresh reviewer can be told which ones passed at this head; absent when the read was unknown. */
|
|
520
|
+
export interface ReviewReadiness { head?: string; ready: boolean; blockers: string[]; reusedRun?: number; checks?: string[] }
|
|
475
521
|
/** Metadata-only pre-review admission; no remote body or comment text is requested. */
|
|
476
522
|
export async function readReviewReadiness(receipt: SdlcReceipt, expected: string | undefined, run: SdlcGh = sdlcGh, requiredChecks: readonly string[] = []): Promise<ReviewReadiness> {
|
|
477
523
|
const unknown = (): ReviewReadiness => ({ ready: false, blockers: ["review readiness unavailable"] });
|
|
@@ -533,7 +579,7 @@ export async function readReviewReadiness(receipt: SdlcReceipt, expected: string
|
|
|
533
579
|
}
|
|
534
580
|
if (unanswered) blockers.push("bot review threads unanswered");
|
|
535
581
|
}
|
|
536
|
-
return { head: pr.headRefOid, ready: blockers.length === 0, blockers: [...new Set(blockers)], ...(reusedRun !== undefined ? { reusedRun } : {}) };
|
|
582
|
+
return { head: pr.headRefOid, ready: blockers.length === 0, blockers: [...new Set(blockers)], checks: [...full], ...(reusedRun !== undefined ? { reusedRun } : {}) };
|
|
537
583
|
} catch { return unknown(); }
|
|
538
584
|
}
|
|
539
585
|
|
package/src/hub/settings.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { operationalRosterSetting, operationalSetting } from "./operational-authority.ts";
|
|
1
2
|
import { ACTION_CLASSES } from "./action-class.ts";
|
|
2
3
|
import { definitionOf, type KindMap, type ProjectAdapterCatalog, ADAPTERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
@@ -193,12 +194,15 @@ export function parseSettingText(def: SettingDef, text: string): unknown {
|
|
|
193
194
|
}
|
|
194
195
|
export const settingText = (value: unknown): string => value === null || value === undefined ? "(not set)" : Array.isArray(value) ? value.join(",") || "(none)" : String(value);
|
|
195
196
|
|
|
197
|
+
export type SettingSource = "dashboard" | "terminal" | "conductor";
|
|
198
|
+
export type SettingWriteOptions = { preserveUndo?: boolean };
|
|
196
199
|
/** `ordinary`: a dashboard session from `ahub ui`. `settings`: one from `ahub ui --settings`. `terminal`: `ahub settings`. */
|
|
197
|
-
export type SettingAuthority = "ordinary" | "settings" | "terminal";
|
|
200
|
+
export type SettingAuthority = "ordinary" | "settings" | "terminal" | "conductor";
|
|
198
201
|
/** Why this authority may not set this value, or undefined. Removing a stored value can raise, so it counts as one. */
|
|
199
202
|
export function settingRefusal(def: SettingDef | string, value: SettingValue, authority: SettingAuthority): string | undefined {
|
|
200
203
|
if (typeof def === "string") return def || "invalid setting definition";
|
|
201
204
|
if (def.locked) return def.locked;
|
|
205
|
+
if (authority === "conductor") return operationalSetting(def) || operationalRosterSetting(def) ? undefined : `${def.key} is not an operational conductor setting`;
|
|
202
206
|
if (def.risk === "safe" || authority !== "ordinary") return undefined;
|
|
203
207
|
if (value !== null && value === def.floor) return undefined;
|
|
204
208
|
return `${def.key} can widen what agents do without asking: open a settings session with ahub ui --settings, or use ahub settings in a terminal`;
|
|
@@ -244,10 +248,12 @@ function refuseTracked(cwd: string, file: StoredFile): void {
|
|
|
244
248
|
// Unknown is not untracked where there is a repository to ask; only a project outside git has nothing tracked.
|
|
245
249
|
if (tracked === undefined && configRepositoryPresent(cwd)) throw new Error(`git could not confirm that .agenthub/${file} is untracked; nothing was written`);
|
|
246
250
|
}
|
|
247
|
-
/**
|
|
248
|
-
function store(cwd: string, stateDir: string, file: StoredFile, key: string, previous: string | null, next: string | null): void {
|
|
251
|
+
/** Person writes keep the one previous version; conductor writes preserve that existing receipt. */
|
|
252
|
+
function store(cwd: string, stateDir: string, file: StoredFile, key: string, previous: string | null, next: string | null, options: SettingWriteOptions = {}): void {
|
|
249
253
|
refuseTracked(cwd, file);
|
|
250
254
|
replaceFile(join(cwd, ".agenthub", file), file, next);
|
|
255
|
+
// A conductor write never replaces, deletes or rebases the person's undo receipt. The ordinary digest fence still applies.
|
|
256
|
+
if(options.preserveUndo)return;
|
|
251
257
|
// The record follows the file: a write that failed leaves the earlier record valid, because the file is as it left it.
|
|
252
258
|
const record: UndoRecord = { file, key, previous, written: digest(next), at: Date.now() };
|
|
253
259
|
try {
|
|
@@ -282,7 +288,7 @@ export function valueAt(doc: unknown, path: readonly string[]): unknown {
|
|
|
282
288
|
* Sets (or with null removes) one key of `.agenthub/config.local.json`. `validate` gets a scratch project that holds
|
|
283
289
|
* the shared config and the candidate, and throws when the hub's own loader would refuse it: nothing is written then.
|
|
284
290
|
*/
|
|
285
|
-
export function writeConfigSetting(cwd: string, stateDir: string, def: SettingDef, value: SettingValue, validate: (scratch: string) => unknown): boolean {
|
|
291
|
+
export function writeConfigSetting(cwd: string, stateDir: string, def: SettingDef, value: SettingValue, validate: (scratch: string) => unknown, options: SettingWriteOptions = {}): boolean {
|
|
286
292
|
const file = join(cwd, ".agenthub", CONFIG_LOCAL);
|
|
287
293
|
const before = readText(file);
|
|
288
294
|
const doc = before === null ? {} : parseObject(before, CONFIG_LOCAL);
|
|
@@ -315,7 +321,7 @@ export function writeConfigSetting(cwd: string, stateDir: string, def: SettingDe
|
|
|
315
321
|
} else node[leaf] = value;
|
|
316
322
|
const text = `${JSON.stringify(doc, null, 2)}\n`;
|
|
317
323
|
checkConfigText(cwd, text, validate);
|
|
318
|
-
store(cwd, stateDir, CONFIG_LOCAL, def.key, before, text);
|
|
324
|
+
store(cwd, stateDir, CONFIG_LOCAL, def.key, before, text, options);
|
|
319
325
|
return true;
|
|
320
326
|
}
|
|
321
327
|
/** Runs `validate` on a scratch project holding the shared config and this candidate for the machine-local one. */
|
package/src/hub/snapshots.ts
CHANGED
|
@@ -546,6 +546,66 @@ export interface TurnRecord {
|
|
|
546
546
|
native: string | null;
|
|
547
547
|
}
|
|
548
548
|
|
|
549
|
+
/**
|
|
550
|
+
* Parse `git status --porcelain=v2 -z` output into the paths of dirty submodules (#798 D2). A submodule's
|
|
551
|
+
* uncommitted edits never move the superproject's gitlink, so a diff of two snapshot trees cannot see them.
|
|
552
|
+
* Submodule entries are the only ones whose mode field starts with `S`; `N` (normal) entries never qualify.
|
|
553
|
+
*/
|
|
554
|
+
export function parseDirtySubmodules(z: string): string[] {
|
|
555
|
+
const entries = z.split("\0");
|
|
556
|
+
const found = new Set<string>();
|
|
557
|
+
for (let i = 0; i < entries.length; i++) {
|
|
558
|
+
const entry = entries[i]!;
|
|
559
|
+
if (entry === "") continue;
|
|
560
|
+
const kind = entry[0];
|
|
561
|
+
if (kind === "1") {
|
|
562
|
+
// `1 XY SUB mH mI mW hH hI PATH`: the rest carries 7 spaces, so the path starts after the last one.
|
|
563
|
+
const rest = entry.slice(2);
|
|
564
|
+
const cut = nthSpace(rest, 6);
|
|
565
|
+
const path = cut < 0 ? "" : rest.slice(cut + 1);
|
|
566
|
+
if (path && submoduleField(rest.slice(0, cut))) found.add(path);
|
|
567
|
+
} else if (kind === "2") {
|
|
568
|
+
// `2 XY SUB mH mI mW hH hI Xscore PATH`, then the next entry is the original path: skip it.
|
|
569
|
+
const rest = entry.slice(2);
|
|
570
|
+
const cut = nthSpace(rest, 7);
|
|
571
|
+
const path = cut < 0 ? "" : rest.slice(cut + 1);
|
|
572
|
+
if (path && submoduleField(rest.slice(0, cut))) found.add(path);
|
|
573
|
+
i++;
|
|
574
|
+
} else if (kind === "u") {
|
|
575
|
+
// `u XY SUB m1 m2 m3 mW h1 h2 h3 PATH`: nine spaces in the rest, then the path.
|
|
576
|
+
const rest = entry.slice(2);
|
|
577
|
+
const cut = nthSpace(rest, 8);
|
|
578
|
+
const path = cut < 0 ? "" : rest.slice(cut + 1);
|
|
579
|
+
if (path && submoduleField(rest.slice(0, cut))) found.add(path);
|
|
580
|
+
}
|
|
581
|
+
// `?`, `!` and `#` entries carry no mode field: ignore them.
|
|
582
|
+
}
|
|
583
|
+
return [...found].sort();
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** porcelain-v2 fields are `XY SUB ...`: the submodule marker is the first character of the second field. */
|
|
587
|
+
const submoduleField = (fields: string): boolean => fields.slice(3, 4) === "S";
|
|
588
|
+
/** Index of the nth space (0-based) after the leading kind character, or -1 when the entry is malformed. */
|
|
589
|
+
function nthSpace(rest: string, n: number): number {
|
|
590
|
+
let at = -1;
|
|
591
|
+
for (let seen = 0; seen <= n; seen++) {
|
|
592
|
+
at = rest.indexOf(" ", at + 1);
|
|
593
|
+
if (at < 0) return -1;
|
|
594
|
+
}
|
|
595
|
+
return at;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Dirty submodules of a repository: modified, untracked or new commits inside a submodule worktree are invisible
|
|
600
|
+
* to snapshot tree diffs (#798 D2). Returns undefined when git cannot answer. A project without `.gitmodules`
|
|
601
|
+
* pays no git call at all (D4).
|
|
602
|
+
*/
|
|
603
|
+
export function dirtySubmodules(repo: Repo, budget?: SnapshotBudget): string[] | undefined {
|
|
604
|
+
if (repo.shadow || !existsSync(join(repo.top, ".gitmodules"))) return [];
|
|
605
|
+
const result = git(repo, ["status", "--porcelain=v2", "-z", "--ignore-submodules=none", "--", scope(repo)], { GIT_OPTIONAL_LOCKS: "0" }, budget, 1024 * 1024);
|
|
606
|
+
return result.status === 0 ? parseDirtySubmodules(result.stdout) : undefined;
|
|
607
|
+
}
|
|
608
|
+
|
|
549
609
|
/** Turn records in hub.db: the daemon writes them, `ahub turns` and `ahub undo` read them. */
|
|
550
610
|
export class Turns {
|
|
551
611
|
private readonly db: Database;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
1
2
|
import { statSync } from "node:fs";
|
|
2
3
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
4
|
import type { TaskPlan, TaskRefs } from "./board.ts";
|
|
4
|
-
import { hubGitSync } from "./git.ts";
|
|
5
|
+
import { hubGitArgv, hubGitEnv, hubGitSync } from "./git.ts";
|
|
6
|
+
import { hostExecutable } from "./host-executable.ts";
|
|
5
7
|
import { canonicalPath, realPath } from "./project.ts";
|
|
6
8
|
import { isGitHubRepository } from "./spec-read.ts";
|
|
7
9
|
|
|
@@ -22,6 +24,33 @@ function slug(value: string): string | undefined {
|
|
|
22
24
|
} catch { return undefined; }
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/** Share the advisory reader's bounds; only authority reads pin Git and isolate the actual checkout. */
|
|
28
|
+
function boundedGit(dir: string, args: string[], isolated = false): string | undefined {
|
|
29
|
+
try {
|
|
30
|
+
const options = { encoding: "utf8" as const, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BYTES };
|
|
31
|
+
const argv = ["-C", dir, ...args];
|
|
32
|
+
const found = isolated
|
|
33
|
+
? spawnSync(hostExecutable("git", dir), hubGitArgv(argv).slice(1), { ...options, cwd: dir, env: hubGitEnv(process.env, { repositoryIsolation: true }) })
|
|
34
|
+
: hubGitSync(argv, options);
|
|
35
|
+
return found.status === 0 && typeof found.stdout === "string" && found.stdout.trim() ? isolated ? found.stdout : found.stdout.trim() : undefined;
|
|
36
|
+
} catch { return undefined; } // Unknown identity never grants; never log remote URLs or stderr.
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Actual project-local GitHub origin only, never task refs, a bare slug or global-config fallback. */
|
|
40
|
+
export function githubOriginRepository(cwd: string): string | undefined {
|
|
41
|
+
let root: string;
|
|
42
|
+
try { root = realPath(cwd); } catch { return undefined; }
|
|
43
|
+
const reply = boundedGit(root, ["config", "--show-scope", "--get", "remote.origin.url"], true);
|
|
44
|
+
const remote = reply && /^(?:local|worktree)\t([^\r\n]+)\n$/.exec(reply)?.[1];
|
|
45
|
+
if (!remote || /[\s?#]/.test(remote)) return undefined;
|
|
46
|
+
const scp = /^(?:[^/@:]+@)?github\.com:[^/]+\/[^/]+\/?$/i.test(remote);
|
|
47
|
+
if (!scp) {
|
|
48
|
+
if (!/^(?:https?|ssh):\/\/(?:[^/@]+@)?github\.com(?::[0-9]+)?\//i.test(remote)) return undefined;
|
|
49
|
+
try { const url = new URL(remote); if (url.search || url.hash) return undefined; } catch { return undefined; }
|
|
50
|
+
}
|
|
51
|
+
return slug(remote);
|
|
52
|
+
}
|
|
53
|
+
|
|
25
54
|
/** Repository metadata is advisory, never an execution root or a grant. Git reads are bounded, local and cached. */
|
|
26
55
|
export class TaskRepositories {
|
|
27
56
|
readonly project: TaskRepository;
|
|
@@ -37,10 +66,7 @@ export class TaskRepositories {
|
|
|
37
66
|
this.roots.set(root, this.project);
|
|
38
67
|
}
|
|
39
68
|
private git(dir: string, args: string[]): string | undefined {
|
|
40
|
-
|
|
41
|
-
const found = hubGitSync(["-C", dir, ...args], { encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BYTES });
|
|
42
|
-
return found.status === 0 && typeof found.stdout === "string" && found.stdout.trim() ? found.stdout.trim() : undefined;
|
|
43
|
-
} catch { return undefined; } // Unknown identity keeps a path key; never log remote URLs or stderr.
|
|
69
|
+
return boundedGit(dir, args);
|
|
44
70
|
}
|
|
45
71
|
private directory(path: string): string {
|
|
46
72
|
let cursor = resolve(path);
|
|
@@ -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,78 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { chmodSync, lstatSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { 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
|
+
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: own }, { repositoryIsolation: true });
|
|
42
|
+
const argv = hubGitArgv(["-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "ls-remote", "--symref", "--exit-code", `https://github.com/${repository}.git`, "HEAD"]);
|
|
43
|
+
if (!current()) { remove(); return; }
|
|
44
|
+
return await new Promise<string | undefined>(resolve => {
|
|
45
|
+
let done = false, bytes = 0, overflowed = false;
|
|
46
|
+
const output: Buffer[] = [];
|
|
47
|
+
let timer: ReturnType<typeof setTimeout> | undefined, watcher: ReturnType<typeof setInterval> | undefined;
|
|
48
|
+
const finish = async (code: number | null) => {
|
|
49
|
+
if (done) return; done = true; clearTimeout(timer); clearInterval(watcher);
|
|
50
|
+
// Do not remove a private cwd/HOME while an owned group may still use it. The existing helper refuses unknown ownership.
|
|
51
|
+
try { if (proc) await stopOwnedProcess(proc, { group: true }); remove(); }
|
|
52
|
+
catch { resolve(undefined); return; }
|
|
53
|
+
try { resolve(code === 0 && !overflowed && current() ? remoteHead(Buffer.concat(output)) : undefined); }
|
|
54
|
+
catch { resolve(undefined); }
|
|
55
|
+
};
|
|
56
|
+
try { proc = spawn(executable, argv.slice(1), { cwd: own, env: environment, detached: true, stdio: ["ignore", "pipe", "pipe"] }); }
|
|
57
|
+
catch { void finish(null); return; }
|
|
58
|
+
timer = setTimeout(() => void finish(null), timeoutMs);
|
|
59
|
+
watcher = setInterval(() => { try { if (!current()) void finish(null); } catch { void finish(null); } }, 50); watcher.unref();
|
|
60
|
+
proc.once("spawn", () => trackGroup(proc!));
|
|
61
|
+
const take = (chunk: Buffer, stdout: boolean) => {
|
|
62
|
+
if (done) return;
|
|
63
|
+
bytes += chunk.length;
|
|
64
|
+
if (bytes > OUTPUT_LIMIT) { overflowed = true; void finish(null); return; }
|
|
65
|
+
if (stdout) output.push(Buffer.from(chunk));
|
|
66
|
+
};
|
|
67
|
+
proc.stdout!.on("data", (chunk: Buffer) => take(chunk, true));
|
|
68
|
+
proc.stderr!.on("data", (chunk: Buffer) => take(chunk, false));
|
|
69
|
+
proc.once("error", () => void finish(null));
|
|
70
|
+
proc.once("exit", code => { if (code !== 0) void finish(null); });
|
|
71
|
+
proc.once("close", code => void finish(code));
|
|
72
|
+
});
|
|
73
|
+
} catch {
|
|
74
|
+
// A spawn/setup failure owns no live group; otherwise only confirmed stop above permits removing the private directory.
|
|
75
|
+
if (!proc && temporary) { try { rmSync(temporary, { recursive: true, force: true }); } catch { /* unknown cleanup remains private */ } }
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|