fullcourtdefense-cli 1.37.0 → 1.39.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/dist/askDialog.d.ts +35 -3
- package/dist/askDialog.js +100 -7
- package/dist/awakeClock.d.ts +34 -0
- package/dist/awakeClock.js +81 -0
- package/dist/backupManifest.d.ts +14 -1
- package/dist/backupManifest.js +180 -19
- package/dist/callerEnv.d.ts +21 -1
- package/dist/callerEnv.js +40 -0
- package/dist/commands/cmdGuard.d.ts +1 -0
- package/dist/commands/cmdGuard.js +14 -1
- package/dist/commands/daemon.d.ts +20 -0
- package/dist/commands/daemon.js +310 -28
- package/dist/commands/desktopChatGuard.d.ts +20 -0
- package/dist/commands/desktopChatGuard.js +60 -11
- package/dist/commands/deterministicGuard.js +1730 -132
- package/dist/commands/doctor.js +5 -1
- package/dist/commands/hook.d.ts +16 -1
- package/dist/commands/hook.js +286 -49
- package/dist/commands/mcpGateway.js +16 -10
- package/dist/commands/posixShellGuard.js +3 -2
- package/dist/commands/shellGuard.js +25 -6
- package/dist/commands/uninstallAll.js +5 -0
- package/dist/commands/watchdog.d.ts +8 -0
- package/dist/commands/watchdog.js +19 -0
- package/dist/daemonForensics.d.ts +11 -0
- package/dist/daemonForensics.js +25 -1
- package/dist/devConfirm.d.ts +55 -5
- package/dist/devConfirm.js +24 -9
- package/dist/distress.d.ts +10 -0
- package/dist/distress.js +273 -32
- package/dist/gateReports.js +15 -0
- package/dist/hookIo.d.ts +20 -2
- package/dist/hookIo.js +106 -15
- package/dist/hookSlim.js +14 -5
- package/dist/housekeeping.js +1 -0
- package/dist/housekeepingOps.d.ts +5 -5
- package/dist/housekeepingOps.js +17 -2
- package/dist/index.js +26 -0
- package/dist/monitorFastPath.d.ts +39 -3
- package/dist/monitorFastPath.js +67 -8
- package/dist/nativeHook.d.ts +21 -0
- package/dist/nativeHook.js +95 -17
- package/dist/notify.js +51 -1
- package/dist/pendingDrainer.d.ts +14 -4
- package/dist/pendingDrainer.js +74 -24
- package/dist/perfSnapshot.d.ts +40 -2
- package/dist/perfSnapshot.js +117 -31
- package/dist/policyCacheFiles.d.ts +10 -0
- package/dist/policyCacheFiles.js +75 -0
- package/dist/runtimeConfig.d.ts +6 -0
- package/dist/runtimeConfig.js +14 -0
- package/dist/shellAst.js +3 -1
- package/dist/telemetry.d.ts +66 -1
- package/dist/telemetry.js +127 -5
- package/dist/testRunGuard.d.ts +28 -0
- package/dist/testRunGuard.js +228 -0
- package/dist/verdictIpc.js +159 -36
- package/dist/verdictIpcClient.d.ts +80 -3
- package/dist/verdictIpcClient.js +214 -26
- package/dist/version.json +1 -1
- package/package.json +4 -2
package/dist/askDialog.d.ts
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
*
|
|
16
16
|
* and answers the IDE `allow` or `deny`. The IDE's own card is the fallback only
|
|
17
17
|
* where no dialog is possible (no display, dialog binary missing) or where the
|
|
18
|
-
* org chose it (`FCD_ASK_CHANNEL=ide`)
|
|
18
|
+
* org chose it (`FCD_ASK_CHANNEL=ide`) — and never for a client that reports a
|
|
19
|
+
* permission mode in which it may answer its own card (v139 F-13 / F-33): our
|
|
20
|
+
* dialog asks there, or the action is denied. The envelope never reaches the IDE.
|
|
19
21
|
*
|
|
20
22
|
* Deterministic all the way: the rule that asked is named in the envelope, the
|
|
21
23
|
* grant is bound to that rule's hash, and nothing here re-evaluates the action.
|
|
@@ -56,8 +58,31 @@ export interface AskEnvelope {
|
|
|
56
58
|
reason: string;
|
|
57
59
|
/** The client the runtime event records as `agentClient` (cursor, claude-code, …): the dialog names the same agent as the console. */
|
|
58
60
|
agentClient?: string;
|
|
61
|
+
/**
|
|
62
|
+
* The asking call's correlation identity (session / run / tool call id): the
|
|
63
|
+
* dialog's outcome is recorded by this process, outside that call's
|
|
64
|
+
* evaluation, and must name the same action. Older hook builds omit it.
|
|
65
|
+
*/
|
|
66
|
+
identity?: {
|
|
67
|
+
sessionId?: string;
|
|
68
|
+
runId?: string;
|
|
69
|
+
instanceId?: string;
|
|
70
|
+
attemptId?: string;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* The permission mode the asking client reported (Claude Code `permission_mode`).
|
|
74
|
+
* A mode in which the IDE may answer its own card (`idePromptState` = auto) never
|
|
75
|
+
* gets the question: our dialog asks, or the action is denied. Absent: the client
|
|
76
|
+
* does not say (Cursor, VS Code, Copilot CLI) or an older hook build.
|
|
77
|
+
*/
|
|
78
|
+
ideMode?: string;
|
|
59
79
|
}
|
|
60
|
-
|
|
80
|
+
/**
|
|
81
|
+
* How an ask ended in the per-event process. `ide`: handed to the IDE's own card.
|
|
82
|
+
* `ide_auto`: no FullCourtDefense dialog was possible and the IDE runs in a mode that
|
|
83
|
+
* may answer its own card — denied, never handed over.
|
|
84
|
+
*/
|
|
85
|
+
export type AskResolution = 'allow' | 'allow_always' | 'deny' | 'timeout' | 'ide' | 'ide_auto';
|
|
61
86
|
export interface HookVerdict {
|
|
62
87
|
stdout: string;
|
|
63
88
|
stderr: string;
|
|
@@ -81,12 +106,19 @@ export declare function takeAskEnvelope(stdout: string): {
|
|
|
81
106
|
* Last step of the per-event hook process (full and slim entries alike): an `ask`
|
|
82
107
|
* verdict is put to the developer in the FullCourtDefense dialog and becomes the
|
|
83
108
|
* IDE's allow / deny; anything else — and an ask where no dialog is possible — is
|
|
84
|
-
* written as-is with the envelope removed
|
|
109
|
+
* written as-is with the envelope removed (an ask the IDE may answer on its own is
|
|
110
|
+
* denied instead, v139 F-13). Then the process exits.
|
|
85
111
|
*/
|
|
86
112
|
export declare function emitVerdictForIde(args: {
|
|
87
113
|
event?: string;
|
|
88
114
|
agentClient?: string;
|
|
89
115
|
}, result: HookVerdict, entry: 'ipc' | 'local' | 'slim-ipc' | 'slim-local'): Promise<never>;
|
|
116
|
+
/**
|
|
117
|
+
* The verdict when the dialog step itself failed (emitVerdictForIde's catch): the dialog
|
|
118
|
+
* step must never break the verdict, so the IDE ask goes out with the envelope stripped —
|
|
119
|
+
* or, where the IDE may answer its own card, a deny (v139 F-13). Never throws.
|
|
120
|
+
*/
|
|
121
|
+
export declare function verdictAfterDialogError(result: HookVerdict, clientLabel?: string): HookVerdict;
|
|
90
122
|
/**
|
|
91
123
|
* Resolve an `ask` verdict with the developer. Returns the verdict to hand to the
|
|
92
124
|
* IDE (envelope removed) and how it was resolved. Any verdict without an envelope,
|
package/dist/askDialog.js
CHANGED
|
@@ -5,6 +5,7 @@ exports.askChannel = askChannel;
|
|
|
5
5
|
exports.attachAskEnvelope = attachAskEnvelope;
|
|
6
6
|
exports.takeAskEnvelope = takeAskEnvelope;
|
|
7
7
|
exports.emitVerdictForIde = emitVerdictForIde;
|
|
8
|
+
exports.verdictAfterDialogError = verdictAfterDialogError;
|
|
8
9
|
exports.resolveAskWithDialog = resolveAskWithDialog;
|
|
9
10
|
/**
|
|
10
11
|
* The developer's question, answered in OUR dialog — one flow for every IDE.
|
|
@@ -23,13 +24,16 @@ exports.resolveAskWithDialog = resolveAskWithDialog;
|
|
|
23
24
|
*
|
|
24
25
|
* and answers the IDE `allow` or `deny`. The IDE's own card is the fallback only
|
|
25
26
|
* where no dialog is possible (no display, dialog binary missing) or where the
|
|
26
|
-
* org chose it (`FCD_ASK_CHANNEL=ide`)
|
|
27
|
+
* org chose it (`FCD_ASK_CHANNEL=ide`) — and never for a client that reports a
|
|
28
|
+
* permission mode in which it may answer its own card (v139 F-13 / F-33): our
|
|
29
|
+
* dialog asks there, or the action is denied. The envelope never reaches the IDE.
|
|
27
30
|
*
|
|
28
31
|
* Deterministic all the way: the rule that asked is named in the envelope, the
|
|
29
32
|
* grant is bound to that rule's hash, and nothing here re-evaluates the action.
|
|
30
33
|
*/
|
|
31
34
|
const devConfirm_1 = require("./devConfirm");
|
|
32
35
|
const telemetry_1 = require("./telemetry");
|
|
36
|
+
const actionIdentity_1 = require("./actionIdentity");
|
|
33
37
|
const hookIo_1 = require("./hookIo");
|
|
34
38
|
const callerEnv_1 = require("./callerEnv");
|
|
35
39
|
/** Field on the verdict JSON that carries the envelope. Stripped before the IDE sees the verdict. */
|
|
@@ -96,7 +100,8 @@ function rewriteDecision(decision, allowed, userMsg, agentMsg) {
|
|
|
96
100
|
* Last step of the per-event hook process (full and slim entries alike): an `ask`
|
|
97
101
|
* verdict is put to the developer in the FullCourtDefense dialog and becomes the
|
|
98
102
|
* IDE's allow / deny; anything else — and an ask where no dialog is possible — is
|
|
99
|
-
* written as-is with the envelope removed
|
|
103
|
+
* written as-is with the envelope removed (an ask the IDE may answer on its own is
|
|
104
|
+
* denied instead, v139 F-13). Then the process exits.
|
|
100
105
|
*/
|
|
101
106
|
async function emitVerdictForIde(args, result, entry) {
|
|
102
107
|
// This process ends with the verdict below: an upload the dialog's outcome
|
|
@@ -118,9 +123,8 @@ async function emitVerdictForIde(args, result, entry) {
|
|
|
118
123
|
final = resolved;
|
|
119
124
|
}
|
|
120
125
|
catch (err) {
|
|
121
|
-
// The dialog step must never break the verdict: fall back to the IDE ask (envelope stripped).
|
|
122
126
|
(0, hookIo_1.dbg)({ phase: 'ask_dialog_error', event: args.event, entry, error: err instanceof Error ? err.message : String(err) });
|
|
123
|
-
final =
|
|
127
|
+
final = verdictAfterDialogError(result, args.agentClient);
|
|
124
128
|
}
|
|
125
129
|
if (final.stderr) {
|
|
126
130
|
try {
|
|
@@ -136,6 +140,22 @@ async function emitVerdictForIde(args, result, entry) {
|
|
|
136
140
|
}
|
|
137
141
|
return process.exit(final.exitCode);
|
|
138
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* The verdict when the dialog step itself failed (emitVerdictForIde's catch): the dialog
|
|
145
|
+
* step must never break the verdict, so the IDE ask goes out with the envelope stripped —
|
|
146
|
+
* or, where the IDE may answer its own card, a deny (v139 F-13). Never throws.
|
|
147
|
+
*/
|
|
148
|
+
function verdictAfterDialogError(result, clientLabel) {
|
|
149
|
+
const taken = takeAskEnvelope(result.stdout);
|
|
150
|
+
let final = { ...result, stdout: taken.stdout };
|
|
151
|
+
try {
|
|
152
|
+
if (taken.decision && taken.envelope && decisionAsks(taken.decision) && (0, devConfirm_1.idePromptState)(taken.envelope.ideMode) === 'auto') {
|
|
153
|
+
final = denyNoHumanPrompt({ ...final, envelope: taken.envelope }, taken.decision, taken.envelope, clientLabel);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch { /* the verdict must still be written */ }
|
|
157
|
+
return final;
|
|
158
|
+
}
|
|
139
159
|
/**
|
|
140
160
|
* Resolve an `ask` verdict with the developer. Returns the verdict to hand to the
|
|
141
161
|
* IDE (envelope removed) and how it was resolved. Any verdict without an envelope,
|
|
@@ -154,8 +174,18 @@ async function resolveAskWithDialog(verdict, options = {}) {
|
|
|
154
174
|
// left for a person to read and answer: the IDE's own ask card takes the
|
|
155
175
|
// question instead, so the hook still answers inside the IDE's window.
|
|
156
176
|
const dialogTimeoutMs = Math.min(options.timeoutMs ?? exports.ASK_DIALOG_TIMEOUT_MS, options.deadlineAt === undefined ? Number.POSITIVE_INFINITY : options.deadlineAt - Date.now());
|
|
157
|
-
|
|
158
|
-
|
|
177
|
+
// v139 F-13 / F-33: the IDE's card is a channel only where it can reach a person. A
|
|
178
|
+
// client that reports a mode in which it may answer its own card (Claude Code
|
|
179
|
+
// bypassPermissions, acceptEdits, auto, dontAsk) never gets the question, whatever
|
|
180
|
+
// FCD_ASK_CHANNEL / FCD_HOOK_IDE_TIMEOUT_MS say (both reachable from an IDE settings
|
|
181
|
+
// env block the agent can write): our dialog asks, or — no dialog possible here — the
|
|
182
|
+
// action is denied. A client that does not say keeps today's routing; its card's
|
|
183
|
+
// answer is recorded as unverified and never becomes memory (settlePostExecution).
|
|
184
|
+
const ideMayAnswerItself = (0, devConfirm_1.idePromptState)(envelope.ideMode) === 'auto';
|
|
185
|
+
const dialogPossible = canAsk && dialogTimeoutMs >= MIN_DIALOG_TIMEOUT_MS;
|
|
186
|
+
if (!dialogPossible || (askChannel() === 'ide' && !ideMayAnswerItself)) {
|
|
187
|
+
return ideMayAnswerItself ? denyNoHumanPrompt(clean, decision, envelope, options.clientLabel) : { ...clean, resolution: 'ide' };
|
|
188
|
+
}
|
|
159
189
|
const grantHash = envelope.ruleHash || envelope.policyHash;
|
|
160
190
|
const grantLimits = envelope.grantLimits === null ? undefined : (envelope.grantLimits || { ttlMs: devConfirm_1.GRANT_TTL_MS });
|
|
161
191
|
const allowAlways = envelope.remember !== 'none' && Boolean(grantHash) && Boolean(grantLimits) && (0, devConfirm_1.grantOffered)(envelope.scope);
|
|
@@ -186,8 +216,16 @@ async function resolveAskWithDialog(verdict, options = {}) {
|
|
|
186
216
|
outcome = 'unavailable';
|
|
187
217
|
}
|
|
188
218
|
if (outcome === 'unavailable')
|
|
189
|
-
return { ...clean, resolution: 'ide' };
|
|
219
|
+
return ideMayAnswerItself ? denyNoHumanPrompt(clean, decision, envelope, options.clientLabel) : { ...clean, resolution: 'ide' };
|
|
190
220
|
const policyLabel = envelope.policyName ? ` (${envelope.policyName})` : '';
|
|
221
|
+
// The outcome is the asking call's: its client and identity, not this process's.
|
|
222
|
+
// No latency / verdict path: the ask itself recorded the hook's wait (one health
|
|
223
|
+
// sample per call), and the person's answer time is confirmLatencyMs.
|
|
224
|
+
const outcomeFacts = (askOutcome) => ({
|
|
225
|
+
askOutcome,
|
|
226
|
+
...(envelope.agentClient || options.clientLabel ? { agentClient: envelope.agentClient || options.clientLabel } : {}),
|
|
227
|
+
...envelopeIdentity(envelope),
|
|
228
|
+
});
|
|
191
229
|
if (outcome === 'allow' || outcome === 'allow_always') {
|
|
192
230
|
// Same bookkeeping the post-execution hook would have done for an IDE Allow.
|
|
193
231
|
(0, devConfirm_1.settleDeveloperConfirmation)(envelope.event, envelope.toolName, envelope.argsDigest);
|
|
@@ -210,6 +248,7 @@ async function resolveAskWithDialog(verdict, options = {}) {
|
|
|
210
248
|
approvalScope: 'developer',
|
|
211
249
|
approvedBy: 'developer',
|
|
212
250
|
confirmLatencyMs: latencyMs,
|
|
251
|
+
...outcomeFacts(granted ? 'allowed_always' : 'allowed'),
|
|
213
252
|
});
|
|
214
253
|
(0, telemetry_1.triggerFlush)(false);
|
|
215
254
|
const agentMsg = granted
|
|
@@ -230,6 +269,9 @@ async function resolveAskWithDialog(verdict, options = {}) {
|
|
|
230
269
|
policyHash: envelope.policyHash,
|
|
231
270
|
approvalScope: 'developer',
|
|
232
271
|
confirmLatencyMs: latencyMs,
|
|
272
|
+
// No answer is not a rule's block: the action did not run (the IDE gets the
|
|
273
|
+
// same deny), but nobody decided against it — recorded as an unanswered ask.
|
|
274
|
+
...outcomeFacts(timedOut ? 'unanswered' : 'denied'),
|
|
233
275
|
});
|
|
234
276
|
(0, telemetry_1.triggerFlush)(true);
|
|
235
277
|
const userMsg = timedOut
|
|
@@ -240,3 +282,54 @@ async function resolveAskWithDialog(verdict, options = {}) {
|
|
|
240
282
|
: `FullCourtDefense: the developer denied this ${envelope.event} in the confirmation dialog. Do not retry or work around it.`;
|
|
241
283
|
return { ...clean, stdout: JSON.stringify(rewriteDecision(decision, false, userMsg, agentMsg)), exitCode: 0, resolution: outcome };
|
|
242
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* v139 F-13: the ask cannot reach a person — no FullCourtDefense dialog is possible here
|
|
287
|
+
* and the IDE runs in a mode that may answer its own card (or refuse it unseen). Denied
|
|
288
|
+
* with an honest record (nobody answered), and the pending marker is dropped so no later
|
|
289
|
+
* post-execution hook can settle it.
|
|
290
|
+
*/
|
|
291
|
+
function denyNoHumanPrompt(clean, decision, envelope, clientLabel) {
|
|
292
|
+
try {
|
|
293
|
+
(0, devConfirm_1.rejectDeveloperConfirmation)(envelope.event, envelope.toolName, envelope.argsDigest);
|
|
294
|
+
}
|
|
295
|
+
catch { /* the deny below still stands */ }
|
|
296
|
+
const policyLabel = envelope.policyName ? ` (${envelope.policyName})` : '';
|
|
297
|
+
const agentClient = envelope.agentClient || clientLabel;
|
|
298
|
+
const client = agentClient || 'the agent';
|
|
299
|
+
const mode = envelope.ideMode || 'auto-approve';
|
|
300
|
+
try {
|
|
301
|
+
(0, telemetry_1.spoolEvent)({
|
|
302
|
+
decision: 'block',
|
|
303
|
+
toolName: envelope.toolName,
|
|
304
|
+
operation: envelope.operation,
|
|
305
|
+
// The gateway's "confirmation not possible" family: nobody could be asked, which the
|
|
306
|
+
// fleet keeps as a block alert (backend fleet-block-alerts.ts), unlike a dialog timeout.
|
|
307
|
+
reason: `developer confirmation not possible (the FullCourtDefense dialog cannot be shown here and ${client} runs in "${mode}" mode, which may answer its own prompt without showing it to a person) — denied${policyLabel}`,
|
|
308
|
+
ruleId: envelope.policyId,
|
|
309
|
+
policyHash: envelope.policyHash,
|
|
310
|
+
approvalScope: 'developer',
|
|
311
|
+
// Nobody answered: not a rule's block and not a person's deny.
|
|
312
|
+
askOutcome: 'unanswered',
|
|
313
|
+
...(agentClient ? { agentClient } : {}),
|
|
314
|
+
...envelopeIdentity(envelope),
|
|
315
|
+
});
|
|
316
|
+
(0, telemetry_1.triggerFlush)(true);
|
|
317
|
+
}
|
|
318
|
+
catch { /* the deny below still stands */ }
|
|
319
|
+
const userMsg = `FullCourtDefense stopped this ${envelope.event}: a rule asks you to confirm it${policyLabel}, but ${client} is in "${mode}" mode, which may approve its own prompts without showing them to you, and the FullCourtDefense dialog cannot be shown here. Switch the agent to a mode that asks you, or run it where the FullCourtDefense dialog can appear, to be asked.`;
|
|
320
|
+
const agentMsg = `${userMsg} Do not retry or work around it.`;
|
|
321
|
+
return { ...clean, stdout: JSON.stringify(rewriteDecision(decision, false, userMsg, agentMsg)), exitCode: 0, resolution: 'ide_auto' };
|
|
322
|
+
}
|
|
323
|
+
/** The asking call's identity from the envelope: well-formed parts only. */
|
|
324
|
+
function envelopeIdentity(envelope) {
|
|
325
|
+
const raw = envelope.identity;
|
|
326
|
+
if (!raw || typeof raw !== 'object')
|
|
327
|
+
return {};
|
|
328
|
+
const out = {};
|
|
329
|
+
for (const key of ['sessionId', 'runId', 'instanceId', 'attemptId']) {
|
|
330
|
+
const value = (0, actionIdentity_1.identityPart)(raw[key]);
|
|
331
|
+
if (value)
|
|
332
|
+
out[key] = value;
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Was the MACHINE asleep, or only the daemon's event loop?
|
|
3
|
+
*
|
|
4
|
+
* The daemon detects a resume from sleep by a beat that observes far more
|
|
5
|
+
* wall time than its interval. A long synchronous stretch on its own loop
|
|
6
|
+
* looks the same: 2026-10-02 06:58, a 201 s protect-all pass ended and two
|
|
7
|
+
* seconds later the daemon logged "Resume detected: 237s ... suspended",
|
|
8
|
+
* re-reported in and recorded network_down [x30] from timeouts that ran out
|
|
9
|
+
* inside the stall — while hook.log showed hooks every few seconds throughout.
|
|
10
|
+
*
|
|
11
|
+
* This clock ticks on a worker thread, which keeps running while the main
|
|
12
|
+
* loop is blocked and stops only when the whole process (the machine) does.
|
|
13
|
+
* A long main-loop gap with no long gap here was a stall, not a suspend.
|
|
14
|
+
* One thread, a 1 s timer, no process and no I/O.
|
|
15
|
+
*/
|
|
16
|
+
export declare const AWAKE_TICK_MS = 1000;
|
|
17
|
+
/** A worker gap this short through a long main-loop gap: the machine stayed awake. */
|
|
18
|
+
export declare const AWAKE_GAP_MAX_MS = 10000;
|
|
19
|
+
export interface AwakeClock {
|
|
20
|
+
/** Longest gap between ticks since the previous call (including the time since the last tick). Undefined once the clock is gone. */
|
|
21
|
+
takeMaxGapMs(): number | undefined;
|
|
22
|
+
stop(): void;
|
|
23
|
+
}
|
|
24
|
+
export declare function startAwakeClock(tickMs?: number): AwakeClock | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Pure: what a gap of `driftMs` between two beats of the daemon's loop was.
|
|
27
|
+
* Without the awake clock (it could not start) every long gap stays a suspend,
|
|
28
|
+
* as before.
|
|
29
|
+
*/
|
|
30
|
+
export declare function classifyClockJump(input: {
|
|
31
|
+
driftMs: number;
|
|
32
|
+
jumpMs: number;
|
|
33
|
+
awakeGapMs?: number;
|
|
34
|
+
}): 'none' | 'stall' | 'suspend';
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AWAKE_GAP_MAX_MS = exports.AWAKE_TICK_MS = void 0;
|
|
4
|
+
exports.startAwakeClock = startAwakeClock;
|
|
5
|
+
exports.classifyClockJump = classifyClockJump;
|
|
6
|
+
const worker_threads_1 = require("worker_threads");
|
|
7
|
+
/**
|
|
8
|
+
* Was the MACHINE asleep, or only the daemon's event loop?
|
|
9
|
+
*
|
|
10
|
+
* The daemon detects a resume from sleep by a beat that observes far more
|
|
11
|
+
* wall time than its interval. A long synchronous stretch on its own loop
|
|
12
|
+
* looks the same: 2026-10-02 06:58, a 201 s protect-all pass ended and two
|
|
13
|
+
* seconds later the daemon logged "Resume detected: 237s ... suspended",
|
|
14
|
+
* re-reported in and recorded network_down [x30] from timeouts that ran out
|
|
15
|
+
* inside the stall — while hook.log showed hooks every few seconds throughout.
|
|
16
|
+
*
|
|
17
|
+
* This clock ticks on a worker thread, which keeps running while the main
|
|
18
|
+
* loop is blocked and stops only when the whole process (the machine) does.
|
|
19
|
+
* A long main-loop gap with no long gap here was a stall, not a suspend.
|
|
20
|
+
* One thread, a 1 s timer, no process and no I/O.
|
|
21
|
+
*/
|
|
22
|
+
exports.AWAKE_TICK_MS = 1_000;
|
|
23
|
+
/** A worker gap this short through a long main-loop gap: the machine stayed awake. */
|
|
24
|
+
exports.AWAKE_GAP_MAX_MS = 10_000;
|
|
25
|
+
function startAwakeClock(tickMs = exports.AWAKE_TICK_MS) {
|
|
26
|
+
try {
|
|
27
|
+
// [0] last tick (wall ms), [1] longest gap between ticks since the last read.
|
|
28
|
+
const shared = new Float64Array(new SharedArrayBuffer(16));
|
|
29
|
+
shared[0] = Date.now();
|
|
30
|
+
const source = `
|
|
31
|
+
const { workerData } = require('worker_threads');
|
|
32
|
+
const clock = new Float64Array(workerData);
|
|
33
|
+
let last = Date.now();
|
|
34
|
+
setInterval(() => {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
if (now - last > clock[1]) clock[1] = now - last;
|
|
37
|
+
last = now;
|
|
38
|
+
clock[0] = now;
|
|
39
|
+
}, ${Math.max(100, Math.floor(tickMs))});
|
|
40
|
+
`;
|
|
41
|
+
const worker = new worker_threads_1.Worker(source, {
|
|
42
|
+
eval: true,
|
|
43
|
+
workerData: shared.buffer,
|
|
44
|
+
resourceLimits: { maxOldGenerationSizeMb: 16, maxYoungGenerationSizeMb: 4 },
|
|
45
|
+
stdout: true,
|
|
46
|
+
stderr: true,
|
|
47
|
+
});
|
|
48
|
+
worker.unref();
|
|
49
|
+
let alive = true;
|
|
50
|
+
worker.on('error', () => { alive = false; });
|
|
51
|
+
worker.on('exit', () => { alive = false; });
|
|
52
|
+
return {
|
|
53
|
+
takeMaxGapMs() {
|
|
54
|
+
if (!alive)
|
|
55
|
+
return undefined;
|
|
56
|
+
const gap = Math.max(shared[1], Date.now() - shared[0]);
|
|
57
|
+
shared[1] = 0;
|
|
58
|
+
return gap;
|
|
59
|
+
},
|
|
60
|
+
stop() {
|
|
61
|
+
alive = false;
|
|
62
|
+
void worker.terminate().catch(() => { });
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Pure: what a gap of `driftMs` between two beats of the daemon's loop was.
|
|
72
|
+
* Without the awake clock (it could not start) every long gap stays a suspend,
|
|
73
|
+
* as before.
|
|
74
|
+
*/
|
|
75
|
+
function classifyClockJump(input) {
|
|
76
|
+
if (input.driftMs < input.jumpMs)
|
|
77
|
+
return 'none';
|
|
78
|
+
if (input.awakeGapMs !== undefined && input.awakeGapMs < exports.AWAKE_GAP_MAX_MS)
|
|
79
|
+
return 'stall';
|
|
80
|
+
return 'suspend';
|
|
81
|
+
}
|
package/dist/backupManifest.d.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
|
+
/** Distinct recent versions kept per config, besides its oldest (original) copy. */
|
|
2
|
+
export declare const KEEP_RECENT_BACKUPS = 3;
|
|
1
3
|
export declare function backupManifestPath(): string;
|
|
2
4
|
/** Every recorded backup path (absent/corrupt manifest = empty list). */
|
|
3
5
|
export declare function readBackupManifest(): string[];
|
|
4
|
-
/** Record one backup file path. Deduped,
|
|
6
|
+
/** Record one backup file path. Deduped, never throws. */
|
|
5
7
|
export declare function recordConfigBackup(backupPath: string): void;
|
|
6
8
|
/** Drop entries whose backup file no longer exists (post-cleanup compaction). */
|
|
7
9
|
export declare function pruneBackupManifest(): void;
|
|
10
|
+
/** This config's safety copies next to it (`<name>.fcd-backup-<ms>`), oldest first. */
|
|
11
|
+
export declare function listConfigBackups(file: string): string[];
|
|
12
|
+
/**
|
|
13
|
+
* Back `file` up next to itself before FullCourtDefense rewrites it — once per
|
|
14
|
+
* distinct content: a version already kept (a rewrite that only flips the
|
|
15
|
+
* runtime path back and forth, a pass that writes the same content) adds no
|
|
16
|
+
* copy. Afterwards the config keeps its oldest copy (the original, before
|
|
17
|
+
* FullCourtDefense first changed it) and its KEEP_RECENT newest. Returns the
|
|
18
|
+
* new copy, or undefined when none was needed (or it failed). Never throws.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createConfigBackup(file: string, now?: number): string | undefined;
|
package/dist/backupManifest.js
CHANGED
|
@@ -33,10 +33,13 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.KEEP_RECENT_BACKUPS = void 0;
|
|
36
37
|
exports.backupManifestPath = backupManifestPath;
|
|
37
38
|
exports.readBackupManifest = readBackupManifest;
|
|
38
39
|
exports.recordConfigBackup = recordConfigBackup;
|
|
39
40
|
exports.pruneBackupManifest = pruneBackupManifest;
|
|
41
|
+
exports.listConfigBackups = listConfigBackups;
|
|
42
|
+
exports.createConfigBackup = createConfigBackup;
|
|
40
43
|
const fs = __importStar(require("fs"));
|
|
41
44
|
const os = __importStar(require("os"));
|
|
42
45
|
const path = __importStar(require("path"));
|
|
@@ -51,11 +54,21 @@ const path = __importStar(require("path"));
|
|
|
51
54
|
* authoritative list at cleanup time (union-ed with the known-dir scan for
|
|
52
55
|
* backups created by older CLI versions).
|
|
53
56
|
*
|
|
57
|
+
* Bounded (2026-10: 5,517 copies, 9.8 MB, and a manifest silently cut at its
|
|
58
|
+
* newest 2,000 entries, so uninstall missed most of them): a config keeps the
|
|
59
|
+
* copy taken before FullCourtDefense first changed it plus its last
|
|
60
|
+
* KEEP_RECENT distinct versions (createConfigBackup), and the manifest drops
|
|
61
|
+
* only entries whose file is gone — never a live one.
|
|
62
|
+
*
|
|
54
63
|
* Best-effort by design: a manifest write failure must never break a protect
|
|
55
64
|
* operation, and cleanup falls back to the directory scan.
|
|
56
65
|
*/
|
|
57
66
|
const MANIFEST_NAME = 'backup-manifest.json';
|
|
58
|
-
|
|
67
|
+
/** Size guard for the manifest file itself; reached only after every missing-file entry is dropped. */
|
|
68
|
+
const MAX_ENTRIES = 20_000;
|
|
69
|
+
/** Distinct recent versions kept per config, besides its oldest (original) copy. */
|
|
70
|
+
exports.KEEP_RECENT_BACKUPS = 3;
|
|
71
|
+
const BACKUP_INFIX = '.fcd-backup-';
|
|
59
72
|
function backupManifestPath() {
|
|
60
73
|
return path.join(os.homedir(), '.fullcourtdefense', MANIFEST_NAME);
|
|
61
74
|
}
|
|
@@ -70,23 +83,105 @@ function readBackupManifest() {
|
|
|
70
83
|
return [];
|
|
71
84
|
}
|
|
72
85
|
}
|
|
73
|
-
|
|
74
|
-
|
|
86
|
+
function sleepSync(ms) {
|
|
87
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Read-modify-write the manifest under a short lock, written atomically
|
|
91
|
+
* (temp + rename): concurrent protect passes (daemon, CLI, hooks) no longer
|
|
92
|
+
* drop each other's entries. Bounded wait; never throws.
|
|
93
|
+
*/
|
|
94
|
+
function updateManifest(change) {
|
|
95
|
+
const file = backupManifestPath();
|
|
96
|
+
const lock = `${file}.lock`;
|
|
97
|
+
let held = false;
|
|
75
98
|
try {
|
|
76
|
-
const existing = readBackupManifest();
|
|
77
|
-
if (existing.includes(backupPath))
|
|
78
|
-
return;
|
|
79
|
-
const entries = [...existing, backupPath].slice(-MAX_ENTRIES);
|
|
80
|
-
const file = backupManifestPath();
|
|
81
99
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
82
|
-
|
|
100
|
+
// Protect passes only (daemon, housekeeping worker, CLI) — never a hook: a short wait is fine.
|
|
101
|
+
const deadline = Date.now() + 2_000;
|
|
102
|
+
for (let attempt = 0; attempt < 1_000 && !held; attempt += 1) {
|
|
103
|
+
try {
|
|
104
|
+
fs.writeFileSync(lock, String(process.pid), { flag: 'wx' });
|
|
105
|
+
held = true;
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
// EPERM/EACCES: Windows reports a lock file being deleted that way — still contention.
|
|
109
|
+
const code = error.code;
|
|
110
|
+
if (code !== 'EEXIST' && code !== 'EPERM' && code !== 'EACCES')
|
|
111
|
+
break;
|
|
112
|
+
try {
|
|
113
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > 10_000) {
|
|
114
|
+
fs.unlinkSync(lock);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch { /* released meanwhile */ }
|
|
119
|
+
if (Date.now() >= deadline)
|
|
120
|
+
break;
|
|
121
|
+
sleepSync(5);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const before = readBackupManifest();
|
|
125
|
+
const next = change(before);
|
|
126
|
+
if (!next)
|
|
127
|
+
return;
|
|
128
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
129
|
+
try {
|
|
130
|
+
if (next.length === 0) {
|
|
131
|
+
fs.rmSync(file, { force: true });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ backups: next }, null, 2)}\n`, 'utf8');
|
|
135
|
+
for (let attempt = 0;; attempt += 1) {
|
|
136
|
+
try {
|
|
137
|
+
fs.renameSync(tmp, file);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
// Windows: a reader (or a scanner) holding the file open fails the rename for a moment.
|
|
142
|
+
const code = error.code;
|
|
143
|
+
if (attempt >= 20 || (code !== 'EPERM' && code !== 'EACCES' && code !== 'EBUSY'))
|
|
144
|
+
throw error;
|
|
145
|
+
sleepSync(10);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
try {
|
|
151
|
+
fs.unlinkSync(tmp);
|
|
152
|
+
}
|
|
153
|
+
catch { /* not there */ }
|
|
154
|
+
}
|
|
83
155
|
}
|
|
84
156
|
catch { /* best effort — cleanup still has the directory scan */ }
|
|
157
|
+
finally {
|
|
158
|
+
if (held) {
|
|
159
|
+
try {
|
|
160
|
+
fs.unlinkSync(lock);
|
|
161
|
+
}
|
|
162
|
+
catch { /* gone */ }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Drop entries whose file is gone; only then, if still over the guard, the oldest. */
|
|
167
|
+
function bounded(entries) {
|
|
168
|
+
if (entries.length <= MAX_ENTRIES)
|
|
169
|
+
return entries;
|
|
170
|
+
const live = entries.filter(entry => { try {
|
|
171
|
+
return fs.existsSync(entry);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return true;
|
|
175
|
+
} });
|
|
176
|
+
return live.length <= MAX_ENTRIES ? live : live.slice(-MAX_ENTRIES);
|
|
177
|
+
}
|
|
178
|
+
/** Record one backup file path. Deduped, never throws. */
|
|
179
|
+
function recordConfigBackup(backupPath) {
|
|
180
|
+
updateManifest(existing => (existing.includes(backupPath) ? undefined : bounded([...existing, backupPath])));
|
|
85
181
|
}
|
|
86
182
|
/** Drop entries whose backup file no longer exists (post-cleanup compaction). */
|
|
87
183
|
function pruneBackupManifest() {
|
|
88
|
-
|
|
89
|
-
const existing = readBackupManifest();
|
|
184
|
+
updateManifest((existing) => {
|
|
90
185
|
const remaining = existing.filter(entry => {
|
|
91
186
|
try {
|
|
92
187
|
return fs.existsSync(entry);
|
|
@@ -95,14 +190,80 @@ function pruneBackupManifest() {
|
|
|
95
190
|
return true;
|
|
96
191
|
}
|
|
97
192
|
});
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
193
|
+
return remaining.length === existing.length ? undefined : remaining;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
/** This config's safety copies next to it (`<name>.fcd-backup-<ms>`), oldest first. */
|
|
197
|
+
function listConfigBackups(file) {
|
|
198
|
+
const dir = path.dirname(file);
|
|
199
|
+
const prefix = `${path.basename(file)}${BACKUP_INFIX}`;
|
|
200
|
+
try {
|
|
201
|
+
return fs.readdirSync(dir)
|
|
202
|
+
.filter(name => name.startsWith(prefix) && /^\d+$/.test(name.slice(prefix.length)))
|
|
203
|
+
.sort((a, b) => Number(a.slice(prefix.length)) - Number(b.slice(prefix.length)))
|
|
204
|
+
.map(name => path.join(dir, name));
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function sameContent(file, content) {
|
|
211
|
+
try {
|
|
212
|
+
return fs.statSync(file).size === content.length && fs.readFileSync(file).equals(content);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Back `file` up next to itself before FullCourtDefense rewrites it — once per
|
|
220
|
+
* distinct content: a version already kept (a rewrite that only flips the
|
|
221
|
+
* runtime path back and forth, a pass that writes the same content) adds no
|
|
222
|
+
* copy. Afterwards the config keeps its oldest copy (the original, before
|
|
223
|
+
* FullCourtDefense first changed it) and its KEEP_RECENT newest. Returns the
|
|
224
|
+
* new copy, or undefined when none was needed (or it failed). Never throws.
|
|
225
|
+
*/
|
|
226
|
+
function createConfigBackup(file, now = Date.now()) {
|
|
227
|
+
try {
|
|
228
|
+
const content = fs.readFileSync(file);
|
|
229
|
+
const existing = listConfigBackups(file);
|
|
230
|
+
if (existing.some(copy => sameContent(copy, content)))
|
|
231
|
+
return undefined;
|
|
232
|
+
let stamp = now;
|
|
233
|
+
let backup = '';
|
|
234
|
+
for (let attempt = 0; attempt < 5; attempt += 1, stamp += 1) {
|
|
235
|
+
backup = `${file}${BACKUP_INFIX}${stamp}`;
|
|
236
|
+
try {
|
|
237
|
+
fs.copyFileSync(file, backup, fs.constants.COPYFILE_EXCL);
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (error.code !== 'EEXIST' || attempt === 4)
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
104
244
|
}
|
|
105
|
-
|
|
245
|
+
// Backups can embed live credentials and land in project-local dirs no
|
|
246
|
+
// scan can enumerate — record each one so uninstall can delete them all.
|
|
247
|
+
recordConfigBackup(backup);
|
|
248
|
+
const all = [...existing, backup];
|
|
249
|
+
const keep = new Set([all[0], ...all.slice(-exports.KEEP_RECENT_BACKUPS)]);
|
|
250
|
+
const pruned = [];
|
|
251
|
+
for (const copy of all) {
|
|
252
|
+
if (keep.has(copy))
|
|
253
|
+
continue;
|
|
254
|
+
try {
|
|
255
|
+
fs.rmSync(copy, { force: true });
|
|
256
|
+
pruned.push(copy);
|
|
257
|
+
}
|
|
258
|
+
catch { /* left for uninstall */ }
|
|
259
|
+
}
|
|
260
|
+
if (pruned.length > 0) {
|
|
261
|
+
const gone = new Set(pruned);
|
|
262
|
+
updateManifest(entries => entries.filter(entry => !gone.has(entry)));
|
|
263
|
+
}
|
|
264
|
+
return backup;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return undefined;
|
|
106
268
|
}
|
|
107
|
-
catch { /* best effort */ }
|
|
108
269
|
}
|
package/dist/callerEnv.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Node built-ins only: this leaf is on the slim hook's warm-path module graph.
|
|
13
13
|
*/
|
|
14
|
-
export declare const CALLER_ENV_KEYS: readonly ["FCD_HUMAN_PRESENT", "FCD_EPHEMERAL", "FULLCOURTDEFENSE_EPHEMERAL", "CI", "GITHUB_ACTIONS", "GITLAB_CI", "TF_BUILD", "JENKINS_URL", "BUILDKITE", "CIRCLECI", "DISPLAY", "WAYLAND_DISPLAY", "FCD_ASK_CHANNEL", "FCD_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "FCD_RUN_ID", "GITHUB_RUN_ID", "CI_PIPELINE_ID", "FCD_WORKLOAD_INSTANCE_ID", "CLAUDECODE", "CLAUDE_PROJECT_DIR", "VSCODE_PID", "TERM_PROGRAM", "WORKSPACE_PATH", "FCD_HOOK_IDE_TIMEOUT_MS", "FCD_TAINT_APPROVE_ONCE_DISABLED", "FCD_TAINT_APPROVAL_TIMEOUT_MS", "FCD_HOOK_DEBUG", "CLOUDSDK_CORE_PROJECT", "CLOUDSDK_ACTIVE_CONFIG_NAME", "CLOUDSDK_CONFIG", "APPDATA", "AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_ACCESS_KEY_ID", "KUBECONFIG", "HELM_KUBECONTEXT", "HELM_NAMESPACE", "HELM_KUBEAPISERVER", "HELM_KUBETOKEN", "HELM_KUBEASUSER", "DOCKER_HOST", "DOCKER_CONTEXT", "AZURE_CONFIG_DIR", "AZURE_DEFAULTS_GROUP", "TF_WORKSPACE", "TF_DATA_DIR", "PGHOST", "PGDATABASE", "PGSERVICE", "MYSQL_HOST"];
|
|
14
|
+
export declare const CALLER_ENV_KEYS: readonly ["FCD_HUMAN_PRESENT", "FCD_EPHEMERAL", "FULLCOURTDEFENSE_EPHEMERAL", "CI", "GITHUB_ACTIONS", "GITLAB_CI", "TF_BUILD", "JENKINS_URL", "BUILDKITE", "CIRCLECI", "DISPLAY", "WAYLAND_DISPLAY", "FCD_ASK_CHANNEL", "FCD_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "FCD_RUN_ID", "GITHUB_RUN_ID", "CI_PIPELINE_ID", "FCD_WORKLOAD_INSTANCE_ID", "CLAUDECODE", "CLAUDE_PROJECT_DIR", "VSCODE_PID", "TERM_PROGRAM", "WORKSPACE_PATH", "FCD_HOOK_IDE_TIMEOUT_MS", "FCD_TAINT_APPROVE_ONCE_DISABLED", "FCD_TAINT_APPROVAL_TIMEOUT_MS", "FCD_HOOK_DEBUG", "FCD_MONITOR_HINTS", "FCD_PERF_PROBE", "CLOUDSDK_CORE_PROJECT", "CLOUDSDK_ACTIVE_CONFIG_NAME", "CLOUDSDK_CONFIG", "APPDATA", "AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_ACCESS_KEY_ID", "KUBECONFIG", "HELM_KUBECONTEXT", "HELM_NAMESPACE", "HELM_KUBEAPISERVER", "HELM_KUBETOKEN", "HELM_KUBEASUSER", "DOCKER_HOST", "DOCKER_CONTEXT", "AZURE_CONFIG_DIR", "AZURE_DEFAULTS_GROUP", "TF_WORKSPACE", "TF_DATA_DIR", "PGHOST", "PGDATABASE", "PGSERVICE", "MYSQL_HOST"];
|
|
15
15
|
export type CallerEnvSnapshot = Partial<Record<(typeof CALLER_ENV_KEYS)[number], string>>;
|
|
16
16
|
/**
|
|
17
17
|
* Keys a decision reads only for WHETHER they are set (cloudGrantTools: static
|
|
@@ -60,6 +60,26 @@ export declare function ideHookTimeoutMs(env?: NodeJS.ProcessEnv): number;
|
|
|
60
60
|
export declare function applyIdeTimeoutArg(seconds: string | undefined, env?: NodeJS.ProcessEnv): void;
|
|
61
61
|
/** Absolute time every wait inside a hook must end by, leaving the margin before the IDE's timeout. */
|
|
62
62
|
export declare function ideWaitCutoff(startedAt: number, ideTimeoutMs?: number): number;
|
|
63
|
+
/**
|
|
64
|
+
* One truthiness rule for FCD_* switches: `1`, `true`, `yes`, `on` (any case,
|
|
65
|
+
* surrounding spaces ignored). Unset, empty, `0`, `false`, `off` and anything
|
|
66
|
+
* else are off. New switches use it; the existing decision switches
|
|
67
|
+
* (FCD_HUMAN_PRESENT, FCD_EPHEMERAL, ...) keep their exact historical spellings,
|
|
68
|
+
* because widening them would change a verdict for the same input.
|
|
69
|
+
*/
|
|
70
|
+
export declare function envFlag(value: string | undefined): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* The test-run markers set on THIS process (names; empty for every install).
|
|
73
|
+
* scripts/lib/no-real-dialog.js sets FCD_TEST_NO_REAL_DIALOG, the sandboxed
|
|
74
|
+
* daemon harnesses FCD_TEST_SANDBOX_ROOT, scripts/lib/fake-os-dialog.js
|
|
75
|
+
* FCD_FAKE_DIALOG_PID. No install, MSI, scheduled task or IDE hook config sets
|
|
76
|
+
* one. Read from process.env, never from a caller snapshot: the guards they
|
|
77
|
+
* drive (testRunGuard.ts, devConfirm, notify) protect this process's desktop
|
|
78
|
+
* and profile.
|
|
79
|
+
*/
|
|
80
|
+
export declare function testRunMarkers(env?: NodeJS.ProcessEnv): string[];
|
|
81
|
+
/** A test run that promised no real OS dialog or notification on this desktop (devConfirm, notify). */
|
|
82
|
+
export declare function noRealUiUnderTest(env?: NodeJS.ProcessEnv): boolean;
|
|
63
83
|
/** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
|
|
64
84
|
export declare function isCiEnvironment(env?: NodeJS.ProcessEnv): boolean;
|
|
65
85
|
/**
|