Skip to content

Commit 896def0

Browse files
committed
docs: document exec approval replay contracts
1 parent 36bf550 commit 896def0

2 files changed

Lines changed: 19 additions & 10 deletions

File tree

src/gateway/exec-approval-manager.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export type ExecApprovalIdLookupResult =
6060
export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
6161
private pending = new Map<string, PendingEntry<TPayload>>();
6262

63+
/** Builds an approval record without registering it, so callers can publish first. */
6364
create(request: TPayload, timeoutMs: number, id?: string | null): ExecApprovalRecord<TPayload> {
6465
const now = Date.now();
6566
const resolvedTimeoutMs = resolveApprovalTimeoutMs(timeoutMs);
@@ -101,7 +102,8 @@ export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
101102
resolvePromise = resolve;
102103
rejectPromise = reject;
103104
});
104-
// Create entry first so we can capture it in the closure (not re-fetch from map)
105+
// Capture the entry object in timeout/cleanup closures; map lookups can see
106+
// a newer record with the same id during the resolved-entry grace window.
105107
const entry: PendingEntry<TPayload> = {
106108
record,
107109
resolve: resolvePromise!,
@@ -132,16 +134,17 @@ export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
132134
if (!pending) {
133135
return false;
134136
}
135-
// Prevent double-resolve (e.g., if called after timeout already resolved)
136137
if (pending.record.resolvedAtMs !== undefined) {
138+
// Timeout and operator resolution can race; only the first transition is
139+
// allowed to publish a decision.
137140
return false;
138141
}
139142
clearTimeout(pending.timer);
140143
pending.record.resolvedAtMs = Date.now();
141144
pending.record.decision = decision;
142145
pending.record.resolvedBy = resolvedBy ?? null;
143-
// Resolve the promise first, then delete after a grace period.
144-
// This allows in-flight awaitDecision calls to find the resolved entry.
146+
// Resolve first, then keep the record briefly so late awaitDecision calls
147+
// and replay guards can still inspect the authoritative decision.
145148
pending.resolve(decision);
146149
scheduleResolvedEntryCleanup(() => {
147150
// Only delete if the entry hasn't been replaced
@@ -178,6 +181,7 @@ export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
178181
return entry?.record ?? null;
179182
}
180183

184+
/** Returns unresolved records that should still be visible to operators. */
181185
listPendingRecords(): ExecApprovalRecord<TPayload>[] {
182186
return Array.from(this.pending.values())
183187
.map((entry) => entry.record)
@@ -200,10 +204,7 @@ export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
200204
return true;
201205
}
202206

203-
/**
204-
* Wait for decision on an already-registered approval.
205-
* Returns the decision promise if the ID is pending, null otherwise.
206-
*/
207+
/** Returns the decision promise for an already-registered approval id. */
207208
awaitDecision(recordId: string): Promise<ExecApprovalDecision | null> | null {
208209
const entry = this.pending.get(recordId);
209210
return entry?.promise ?? null;
@@ -239,6 +240,8 @@ export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
239240
continue;
240241
}
241242
if (normalizeLowercaseStringOrEmpty(id).startsWith(lowerPrefix)) {
243+
// Prefix lookup is operator-facing convenience only; ambiguity is
244+
// surfaced instead of picking a record.
242245
matches.push(id);
243246
}
244247
}

src/gateway/node-invoke-system-run-approval.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ function canBridgeNoDeviceApprovalFromBackend(params: {
9393
);
9494
}
9595

96+
/** Returns whether an approval is bound to channel turn metadata for replay checks. */
9697
function hasChatApprovalReplayBinding(request: ExecApprovalRecord["request"]): boolean {
9798
return (
9899
normalizeComparableString(request.turnSourceChannel, { lowercase: true }) !== null ||
@@ -155,6 +156,8 @@ function canBridgeNoDeviceChatApprovalFromBackend(params: {
155156

156157
const request = params.snapshot.request;
157158
const plan = request.systemRunPlan ?? null;
159+
// Backend chat replays are allowed only when the replay carries the same
160+
// channel/session/agent binding as the original approval request.
158161
return (
159162
matchesRequiredString({
160163
expected: request.turnSourceChannel,
@@ -356,6 +359,8 @@ export function sanitizeSystemRunParamsForForwarding(opts: {
356359
};
357360
}
358361
if (runtimeContext.plan) {
362+
// Rehydrate forwarded params from the approved plan so caller-supplied
363+
// command/cwd/session fields cannot drift after approval.
359364
next.command = [...runtimeContext.plan.argv];
360365
next.systemRunPlan = runtimeContext.plan;
361366
if (runtimeContext.commandText) {
@@ -394,11 +399,12 @@ export function sanitizeSystemRunParamsForForwarding(opts: {
394399
return toSystemRunApprovalMismatchError({ runId, match: approvalMatch });
395400
}
396401

397-
// Normal path: enforce the decision recorded by the gateway.
398402
if (snapshot.decision === "allow-once") {
399403
if (typeof manager.consumeAllowOnce !== "function" || !manager.consumeAllowOnce(runId)) {
400404
return systemRunApprovalRequired(runId);
401405
}
406+
// allow-once is consumed before forwarding so the same approval cannot be
407+
// replayed through the manager's resolved-entry grace period.
402408
next.approved = true;
403409
next.approvalDecision = "allow-once";
404410
return { ok: true, params: next };
@@ -411,7 +417,7 @@ export function sanitizeSystemRunParamsForForwarding(opts: {
411417
}
412418

413419
// If the approval request timed out (decision=null), allow askFallback-driven
414-
// "allow-once" ONLY for clients that are allowed to use exec approvals.
420+
// "allow-once" only for clients that can use exec approvals.
415421
const timedOut =
416422
snapshot.resolvedAtMs !== undefined &&
417423
snapshot.decision === undefined &&

0 commit comments

Comments
 (0)