Skip to content
This repository was archived by the owner on Sep 7, 2026. It is now read-only.

Commit b2f7811

Browse files
committed
fix review safety and cancellation gaps
1 parent 51437d9 commit b2f7811

6 files changed

Lines changed: 538 additions & 102 deletions

File tree

‎plugins/Hylouis233/cli-agent-bridge/README.md‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,10 @@ you already obtained a valid ID from that backend outside this Plugin.
143143
- Locking leaves the target repository refs, worktree, and index unchanged, but it requires writable
144144
metadata in the private bare lock store. Its initialization inherits the enclosing repository's
145145
`core.sharedRepository` mode for group/multi-user repositories. Each acquisition writes an owner
146-
blob and temporarily updates a coordination ref there. Periodic ownership checks read that ref
147-
without manufacturing new heartbeat blobs; any extant `starting`/`running` ref remains a
148-
conservative attribution-overlap signal regardless of its last state-transition timestamp.
146+
blob and temporarily updates a coordination ref there. Before returning an acquired lease, it
147+
also publishes a unique activity-object ID. Snapshots compare activity IDs rather than clocks
148+
from different hosts, and any extant foreign lease ref (including idle, pending, malformed, or
149+
quarantined state) remains a conservative attribution-overlap signal.
149150
Each normal release schedules Git's safe automatic
150151
maintenance for superseded state. A failed release first leaves an exact-owner recovery record in
151152
the shared store, so another bridge process can finish cleanup after the transient failure clears.
@@ -195,8 +196,9 @@ you already obtained a valid ID from that backend outside this Plugin.
195196
reached through multiple moved refs is counted and logged once with all contributing labels;
196197
ref namespaces alone never prove that a commit came from outside the worker.
197198
Git exposes only the final, overwritable FETCH_HEAD and no complete cross-version per-fetch tip log.
198-
A private, per-delegation Trace2 event stream therefore detects successful fetch/pull operations in
199-
the canonical workspace; when one occurred, the response keeps the worktree/ref snapshot but marks
199+
A private, per-delegation Trace2 event stream therefore detects completed fetch/pull attempts in
200+
the canonical workspace, including nonzero fetches that may have updated only some destinations;
201+
when one occurred, the response keeps the worktree/ref snapshot but marks
200202
commit attribution unavailable instead of guessing which commits were worker-created. The trace is
201203
user-private, bounded, consumed after worker cleanup, and removed before the response. Runs without
202204
fetch/pull batch commit-tip classification, and each changed target uses one boundary graph
@@ -207,6 +209,9 @@ you already obtained a valid ID from that backend outside this Plugin.
207209
and dsh needs a headless profile present under DSH_HOME/profiles.
208210
- Custom wrapper shims that re-bind dashed flags can misreport a backend as unavailable; point
209211
the backend command at the real executable to bypass the wrapper.
212+
- The stdio transport accepts newline-delimited JSON-RPC with a 1,000,000-character per-line
213+
limit. An unterminated or complete line beyond that bound is rejected and closes the dispatch
214+
gate through the same awaited cleanup path as host disconnect.
210215

211216
## Verification
212217

@@ -224,7 +229,7 @@ operator approval rename, interruptible lease state updates, shared quarantine m
224229
discovery-phase cancellation (including list_backends probes), overall deadlines, cancel/timeout
225230
Windows Job Object termination, internal fail-closed process-tree state-machine fixtures,
226231
PID-reuse identity checks before signaling, unusual Git pathnames (including a trailing-space worktree
227-
root), JSON-RPC id typing, unborn HEAD and non-HEAD ref changes, checkout-only HEAD moves,
232+
root), JSON-RPC id typing and bounded lines, unborn HEAD and non-HEAD ref changes, checkout-only HEAD moves,
228233
single-count attribution for commits on the checked-out branch, fork-point diff baselines for
229234
new branches, non-commit refs, fetched-history exclusion, repository-wide serialization and
230235
failed-release recovery between linked worktrees,

‎plugins/Hylouis233/cli-agent-bridge/server.mjs‎

Lines changed: 138 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ const backgroundFinalizers = new Set();
4747
let quarantineReadTestCallCount = 0;
4848
const RAW_TAIL_CHARS = 60_000;
4949
const FETCH_PROVENANCE_TIPS = Symbol("fetchProvenanceTips");
50+
const LOCK_HISTORY_REFS = Symbol("lockHistoryRefs");
51+
const MAX_JSON_RPC_LINE_CHARS = 1_000_000;
5052
const QUARANTINE_RECORD_FILE = "record.json";
5153
const WORKSPACE_QUARANTINE_DIRECTORY = "cli-agent-bridge-quarantines";
5254
const TEST_RUNTIME_PLATFORM = process.env.NODE_ENV === "test"
@@ -1342,10 +1344,10 @@ export function backendGitProvenanceEnvironment(tracePath, baseEnvironment = pro
13421344
delete env[name];
13431345
}
13441346
}
1345-
// Trace2 identifies successful fetch/pull commands in the canonical target
1346-
// repository. Git does not expose a complete, cross-version per-fetch tip
1347-
// log, so their presence makes commit attribution explicitly unavailable
1348-
// instead of relying on the last (overwritable) FETCH_HEAD contents.
1347+
// Trace2 identifies completed fetch/pull attempts in the canonical target
1348+
// repository. A nonzero fetch may already have updated some refs, and Git
1349+
// exposes no complete cross-version per-fetch tip log, so any such attempt
1350+
// makes attribution unavailable instead of trusting the last FETCH_HEAD.
13491351
env.GIT_TRACE2_EVENT = tracePath;
13501352
return env;
13511353
}
@@ -1555,7 +1557,11 @@ export async function readBackendGitProvenance(provenance, worktreeRoot, options
15551557
uncertain = true;
15561558
continue;
15571559
}
1558-
if (session.worktree === targetWorktree && session.exitCode === 0) {
1560+
// A non-zero fetch can still update a subset of its destinations before a
1561+
// later ref is rejected. Completion in the target repository is therefore
1562+
// enough to make exact commit attribution unavailable; exit status cannot
1563+
// prove that the repository was left untouched.
1564+
if (session.worktree === targetWorktree) {
15591565
sawFetch = true;
15601566
uncertain = true;
15611567
}
@@ -1636,14 +1642,65 @@ export async function runGitCommand(args, {
16361642
return result;
16371643
}
16381644

1639-
// A starting/running lease ref is conservatively active until its exact owner
1640-
// CAS removes or transitions it. Periodic ownership probes intentionally avoid
1641-
// writing heartbeat blobs, and a stale timestamp cannot prove that an escaped
1642-
// worker (or a worker on another host sharing the repository) has stopped.
1645+
// Every foreign exact lease ref is conservatively active until its owner CAS
1646+
// removes it. State and wall-clock timestamps cannot prove that a worker (or a
1647+
// worker on another host sharing the repository) is outside our snapshot.
1648+
1649+
export async function readRepositoryLockActivity(lockStoreRoot, ownLockRef, options = {}) {
1650+
if (!lockStoreRoot) return { activeRefs: 0, historyRefs: new Map() };
1651+
const readRefs = async (label) => {
1652+
const storedRefs = await runGitCommand([
1653+
"for-each-ref", "--format=%(refname)%09%(objectname)", WORKSPACE_LOCK_REF_PREFIX,
1654+
], { cwd: lockStoreRoot, ...options });
1655+
const failure = snapshotFailure(label, storedRefs);
1656+
if (failure) throw new Error("git snapshot unreliable: " + failure);
1657+
return String(storedRefs.stdout ?? "").split(/\r?\n/u);
1658+
};
1659+
1660+
// These are intentionally two ordered Git snapshots, not two views parsed
1661+
// from one for-each-ref result. A lease exists before its marker is
1662+
// published: if the history scan already sees a new marker, the later live
1663+
// scan must see its lease unless that run completed before target ref capture.
1664+
const historyRefs = new Map();
1665+
for (const line of await readRefs("git for-each-ref workspace activity history")) {
1666+
if (!line) continue;
1667+
const separator = line.indexOf("\t");
1668+
if (separator <= 0) continue;
1669+
const ref = line.slice(0, separator);
1670+
const oid = line.slice(separator + 1);
1671+
if (ref === ownLockRef || ref === ownLockRef + ".history") continue;
1672+
if (/^refs\/cli-agent-bridge\/workspace-locks\/[0-9a-f]{64}\.history$/u.test(ref)) {
1673+
historyRefs.set(ref, oid);
1674+
}
1675+
}
1676+
let activeRefs = 0;
1677+
for (const line of await readRefs("git for-each-ref workspace active leases")) {
1678+
if (!line) continue;
1679+
const separator = line.indexOf("\t");
1680+
if (separator <= 0) continue;
1681+
const ref = line.slice(0, separator);
1682+
if (ref === ownLockRef) continue;
1683+
if (/^refs\/cli-agent-bridge\/workspace-locks\/[0-9a-f]{64}$/u.test(ref)) {
1684+
// Ref existence is the conservative signal. Malformed, idle, pending,
1685+
// or quarantined owners cannot be dismissed using a foreign host clock.
1686+
activeRefs += 1;
1687+
}
1688+
}
1689+
return { activeRefs, historyRefs };
1690+
}
16431691

16441692
async function gitSnapshot(worktreeRoot, options = {}) {
16451693
const ownLockRef = typeof options.ownLockRef === "string" ? options.ownLockRef : null;
16461694
const lockStoreRoot = typeof options.lockStoreRoot === "string" ? options.lockStoreRoot : null;
1695+
const historyBaseline = options.concurrencyHistoryBaseline instanceof Map
1696+
? options.concurrencyHistoryBaseline
1697+
: null;
1698+
// The before vector must precede every target-repository observation. If it
1699+
// were sampled at the end, a run completing between ref capture and this
1700+
// marker could be swallowed into the baseline and misattributed later.
1701+
let lockActivity = historyBaseline === null
1702+
? await readRepositoryLockActivity(lockStoreRoot, ownLockRef, options)
1703+
: null;
16471704
const jobs = [
16481705
["git status --short", "status", ["status", "--short", "--untracked-files=all", "--ignore-submodules=none"], false, false, true],
16491706
["git diff --stat", "diffStat", ["diff", "--ignore-submodules=none", "--stat"], false, false, true],
@@ -1751,46 +1808,21 @@ async function gitSnapshot(worktreeRoot, options = {}) {
17511808
throw new Error("git snapshot unreliable: cannot read FETCH_HEAD: " + error.message);
17521809
}
17531810
}
1754-
const lockRefs = [];
1755-
if (lockStoreRoot) {
1756-
const storedRefs = await runGitCommand([
1757-
"for-each-ref", "--format=%(refname)%09%(objectname)", WORKSPACE_LOCK_REF_PREFIX,
1758-
], { cwd: lockStoreRoot, ...options });
1759-
const storedRefsFailure = snapshotFailure("git for-each-ref workspace lock store", storedRefs);
1760-
if (storedRefsFailure) throw new Error("git snapshot unreliable: " + storedRefsFailure);
1761-
for (const line of String(storedRefs.stdout ?? "").split(/\r?\n/u)) {
1762-
if (!line) continue;
1763-
const separator = line.indexOf("\t");
1764-
if (separator <= 0) continue;
1765-
const ref = line.slice(0, separator);
1766-
if (ref !== ownLockRef) lockRefs.push(line.slice(separator + 1));
1767-
}
1768-
}
1811+
lockActivity ??= await readRepositoryLockActivity(lockStoreRoot, ownLockRef, options);
17691812
// Linked worktrees serialize per worktree but share repository refs, so a
17701813
// commit from a parallel delegation can land between our two snapshots.
1771-
// Detection combines two signals: leases that are active right now, and the
1772-
// persistent run-history records completed delegations leave behind, whose
1773-
// [acquiredAt, endedAt] window is checked against this snapshot's window.
1774-
const windowStart = Number.isFinite(options.concurrencyWindowStart)
1775-
? options.concurrencyWindowStart
1776-
: Number.POSITIVE_INFINITY;
1777-
let concurrentDelegations = 0;
1778-
for (const oid of lockRefs) {
1779-
const blob = await runGitCommand(["cat-file", "blob", oid], { cwd: lockStoreRoot, ...options });
1780-
if (blob.exitCode !== 0) continue; // unreadable owner blob: ignore for disclosure
1781-
try {
1782-
const record = JSON.parse(blob.stdout);
1783-
if (Number.isFinite(record?.endedAt)) {
1784-
const acquiredAt = Number.isFinite(record.acquiredAt) ? record.acquiredAt : record.endedAt;
1785-
if (acquiredAt <= Date.now() && record.endedAt >= windowStart) {
1786-
concurrentDelegations += 1;
1787-
}
1788-
continue;
1814+
// Detection combines two clock-independent signals: leases that are active
1815+
// at either snapshot, and a changed persistent history-ref OID between the
1816+
// snapshots. Wall clocks from two hosts sharing a repository are not
1817+
// comparable and must never decide whether attribution is exact.
1818+
let concurrentDelegations = lockActivity.activeRefs;
1819+
if (historyBaseline) {
1820+
const historyNames = new Set([...historyBaseline.keys(), ...lockActivity.historyRefs.keys()]);
1821+
for (const ref of historyNames) {
1822+
if (historyBaseline.get(ref) !== lockActivity.historyRefs.get(ref)) {
1823+
concurrentDelegations += 1;
17891824
}
1790-
const active = record &&
1791-
(record.workerState === "starting" || record.workerState === "running");
1792-
if (active) concurrentDelegations += 1;
1793-
} catch { /* malformed owner blob: ignore for disclosure */ }
1825+
}
17941826
}
17951827
const snapshot = {
17961828
// The leading space in porcelain's first XY column is significant (for
@@ -1804,6 +1836,7 @@ async function gitSnapshot(worktreeRoot, options = {}) {
18041836
fetchHeads,
18051837
concurrentDelegations,
18061838
};
1839+
Object.defineProperty(snapshot, LOCK_HISTORY_REFS, { value: lockActivity.historyRefs });
18071840
return snapshot;
18081841
}
18091842

@@ -2127,10 +2160,25 @@ export async function committedDelta(worktreeRoot, before, after, options = {})
21272160
}
21282161

21292162
export function backendEntryFromProbe(name, spec, check) {
2130-
const available = check.exitCode === 0 && check.treeTerminated === true;
2131-
const probeError = check.treeTerminated !== true
2132-
? (check.terminationError || "backend version probe process tree could not be confirmed terminated")
2133-
: (check.errorMessage || "command not found or not executable");
2163+
const available = check.exitCode === 0 && check.treeTerminated === true && check.timedOut !== true;
2164+
const probeStderr = tail(String(check.stderr ?? ""), 500).trim();
2165+
let probeError = "";
2166+
if (check.treeTerminated !== true) {
2167+
probeError = check.terminationError ||
2168+
"backend version probe process tree could not be confirmed terminated";
2169+
} else if (check.timedOut) {
2170+
probeError = "backend version probe timed out after " + VERSION_CHECK_TIMEOUT_MS + " ms" +
2171+
(probeStderr ? ": " + probeStderr : "");
2172+
} else if (typeof check.exitCode === "number" && check.exitCode !== 0) {
2173+
probeError = "backend version probe exited with code " + check.exitCode +
2174+
(probeStderr ? ": " + probeStderr : "");
2175+
} else if (check.errorMessage) {
2176+
probeError = check.errorMessage;
2177+
} else if (probeStderr) {
2178+
probeError = "backend version probe failed: " + probeStderr;
2179+
} else {
2180+
probeError = "command not found or not executable";
2181+
}
21342182
return {
21352183
name,
21362184
label: typeof spec.label === "string" ? spec.label : name,
@@ -2145,6 +2193,7 @@ export function backendEntryFromProbe(name, spec, check) {
21452193
}
21462194

21472195
async function listBackends(cancel = null) {
2196+
if (cancel?.cancelled) throw new OperationCancelledError("list_backends cancelled by client");
21482197
if (!supportsReliableProcessContainment()) {
21492198
return [{
21502199
name: "unsupported-platform",
@@ -2162,20 +2211,18 @@ async function listBackends(cancel = null) {
21622211
try {
21632212
backends = await loadBackends({ cancel });
21642213
} catch (error) {
2165-
if (error instanceof OperationCancelledError) return [];
21662214
throw error;
21672215
}
21682216
const entries = [];
21692217
for (const [name, spec] of Object.entries(backends)) {
21702218
// A hung `--version` probe must not pin the request: the client can cancel
21712219
// the discovery call, terminating the current probe and skipping the rest.
2172-
if (cancel?.cancelled) break;
2220+
if (cancel?.cancelled) throw new OperationCancelledError("list_backends cancelled by client");
21732221
if (!spec || typeof spec.command !== "string") continue;
21742222
let resolvedCommand;
21752223
try {
21762224
resolvedCommand = await resolveBackendCommand(spec.command, { cancel });
21772225
} catch (error) {
2178-
if (error instanceof OperationCancelledError) break;
21792226
throw error;
21802227
}
21812228
if (!resolvedCommand) {
@@ -2201,6 +2248,13 @@ async function listBackends(cancel = null) {
22012248
},
22022249
});
22032250
if (cancel?.controller) cancel.controller = null;
2251+
if (cancel?.cancelled) {
2252+
if (check.treeTerminated !== true) {
2253+
throw new Error(check.terminationError ||
2254+
"backend version probe process tree could not be confirmed terminated");
2255+
}
2256+
throw new OperationCancelledError("list_backends cancelled by client");
2257+
}
22042258
entries.push(backendEntryFromProbe(name, spec, check));
22052259
}
22062260
return entries;
@@ -2818,9 +2872,6 @@ async function delegateTask(rawArgs, cancel) {
28182872
return gitProcessQuarantine;
28192873
};
28202874
const allowDirty = rawArgs.allowDirty === true;
2821-
// Attribution window for concurrency disclosure: everything between the
2822-
// before-snapshot and the after-snapshot.
2823-
const attributionWindowStart = Date.now();
28242875
let before;
28252876
try {
28262877
before = await gitSnapshot(worktreeRoot, {
@@ -3104,7 +3155,7 @@ async function delegateTask(rawArgs, cancel) {
31043155
deadline,
31053156
ownLockRef: workspaceLease.ref,
31063157
lockStoreRoot,
3107-
concurrencyWindowStart: attributionWindowStart,
3158+
concurrencyHistoryBaseline: before[LOCK_HISTORY_REFS],
31083159
onUnconfirmedProcessTree: quarantineGitProcessTree,
31093160
});
31103161
Object.defineProperty(after, FETCH_PROVENANCE_TIPS, { value: fetchProvenanceTips });
@@ -3323,6 +3374,7 @@ function installShutdownHandlers(stdin, stdout = process.stdout) {
33233374
process.once("exit", () => {
33243375
for (const { cancel } of activeRequests.values()) cancel.cancel();
33253376
});
3377+
return shutdown;
33263378
}
33273379

33283380
async function handleMessage(message) {
@@ -3376,6 +3428,11 @@ async function handleMessage(message) {
33763428
content: [{ type: "text", text: lines.join("\n") }],
33773429
structuredContent: { backends: entries },
33783430
});
3431+
} catch (error) {
3432+
if (error instanceof OperationCancelledError) {
3433+
return jsonRpcError(message.id, -32800, "list_backends cancelled by client");
3434+
}
3435+
throw error;
33793436
} finally {
33803437
finishRequest();
33813438
}
@@ -3536,7 +3593,11 @@ async function handleMessage(message) {
35363593
}
35373594
}
35383595

3539-
function startStdioServer({ stdin = process.stdin, stdout = process.stdout } = {}) {
3596+
export function startStdioServer({
3597+
stdin = process.stdin,
3598+
stdout = process.stdout,
3599+
onOversizedLine = () => stdin.destroy?.(),
3600+
} = {}) {
35403601
stdin.setEncoding("utf8");
35413602
let buffer = "";
35423603
stdin.on("data", (chunk) => {
@@ -3548,6 +3609,14 @@ function startStdioServer({ stdin = process.stdin, stdout = process.stdout } = {
35483609
buffer = "";
35493610
break;
35503611
}
3612+
if (newlineIndex > MAX_JSON_RPC_LINE_CHARS) {
3613+
buffer = "";
3614+
stdout.write(JSON.stringify(jsonRpcError(
3615+
null, -32700, "JSON-RPC request line exceeds the configured size limit",
3616+
)) + "\n");
3617+
onOversizedLine();
3618+
return;
3619+
}
35513620
const line = buffer.slice(0, newlineIndex).trim();
35523621
buffer = buffer.slice(newlineIndex + 1);
35533622
newlineIndex = buffer.indexOf("\n");
@@ -3565,10 +3634,19 @@ function startStdioServer({ stdin = process.stdin, stdout = process.stdout } = {
35653634
stdout.write(JSON.stringify(jsonRpcError(null, -32603, error.message)) + "\n");
35663635
});
35673636
}
3637+
if (buffer.length > MAX_JSON_RPC_LINE_CHARS) {
3638+
buffer = "";
3639+
stdout.write(JSON.stringify(jsonRpcError(
3640+
null, -32700, "JSON-RPC request line exceeds the configured size limit",
3641+
)) + "\n");
3642+
onOversizedLine();
3643+
}
35683644
});
35693645
}
35703646

35713647
if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) {
3572-
startStdioServer();
3573-
installShutdownHandlers(process.stdin, process.stdout);
3648+
const shutdown = installShutdownHandlers(process.stdin, process.stdout);
3649+
startStdioServer({
3650+
onOversizedLine: () => shutdown(1),
3651+
});
35743652
}

0 commit comments

Comments
 (0)