minutes-mcp 0.24.0 → 0.25.1

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.
@@ -1,5 +1,6 @@
1
1
  type ExecFileAsync = (file: string, args: readonly string[], options?: {
2
2
  timeout?: number;
3
+ env?: NodeJS.ProcessEnv;
3
4
  }) => Promise<unknown>;
4
5
  export type Sha256Entry = {
5
6
  filename: string;
@@ -9,6 +10,27 @@ export declare function parseSha256Sums(raw: string): Sha256Entry[];
9
10
  export declare function findSha256ForAsset(raw: string, assetName: string): string | null;
10
11
  export declare function computeFileSha256(path: string): Promise<string>;
11
12
  export declare function verifyDownloadedAsset(path: string, expectedSha256: string): Promise<void>;
13
+ /**
14
+ * Extract a zip on Windows via PowerShell's Expand-Archive.
15
+ *
16
+ * The paths travel in the environment, never in the command text. Two ways to
17
+ * get this wrong, both of which shipped:
18
+ *
19
+ * - Interpolating into a single-quoted PowerShell string breaks on a home
20
+ * directory containing an apostrophe (`C:\Users\O'Brien`).
21
+ * - Passing them as trailing arguments and reading `$args[0]` does not work at
22
+ * all: `powershell -Command` appends trailing arguments to the command text
23
+ * and leaves `$args` empty (only `-File` binds them), so `$args[0]` is
24
+ * `$null` and `Expand-Archive` rejects the parameter on every machine.
25
+ *
26
+ * `$env:` lookups are read at runtime and are never parsed as script, so no
27
+ * value of either path can alter the command.
28
+ */
29
+ export declare function extractZipWithPowerShell(options: {
30
+ archivePath: string;
31
+ destDir: string;
32
+ execFileAsync: ExecFileAsync;
33
+ }): Promise<void>;
12
34
  export declare function downloadReleaseBinaryWithChecksum(options: {
13
35
  binaryName: string;
14
36
  targetPath: string;
@@ -36,6 +36,38 @@ export async function verifyDownloadedAsset(path, expectedSha256) {
36
36
  throw new Error(`checksum mismatch: expected ${expectedSha256.toLowerCase()}, got ${actual}`);
37
37
  }
38
38
  }
39
+ /**
40
+ * Extract a zip on Windows via PowerShell's Expand-Archive.
41
+ *
42
+ * The paths travel in the environment, never in the command text. Two ways to
43
+ * get this wrong, both of which shipped:
44
+ *
45
+ * - Interpolating into a single-quoted PowerShell string breaks on a home
46
+ * directory containing an apostrophe (`C:\Users\O'Brien`).
47
+ * - Passing them as trailing arguments and reading `$args[0]` does not work at
48
+ * all: `powershell -Command` appends trailing arguments to the command text
49
+ * and leaves `$args` empty (only `-File` binds them), so `$args[0]` is
50
+ * `$null` and `Expand-Archive` rejects the parameter on every machine.
51
+ *
52
+ * `$env:` lookups are read at runtime and are never parsed as script, so no
53
+ * value of either path can alter the command.
54
+ */
55
+ export async function extractZipWithPowerShell(options) {
56
+ const { archivePath, destDir, execFileAsync } = options;
57
+ await execFileAsync("powershell", [
58
+ "-NoProfile",
59
+ "-NonInteractive",
60
+ "-Command",
61
+ "Expand-Archive -Path $env:MINUTES_ZIP_PATH -DestinationPath $env:MINUTES_ZIP_DEST -Force",
62
+ ], {
63
+ timeout: 120000,
64
+ env: {
65
+ ...process.env,
66
+ MINUTES_ZIP_PATH: archivePath,
67
+ MINUTES_ZIP_DEST: destDir,
68
+ },
69
+ });
70
+ }
39
71
  export async function downloadReleaseBinaryWithChecksum(options) {
40
72
  const { binaryName, targetPath, execFileAsync } = options;
41
73
  const baseUrl = options.baseUrl ?? "https://github.com/silverstein/minutes/releases/latest/download";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,94 @@
1
+ import { createInterface } from "node:readline";
2
+ import { markCorpusLeaseWorkerProcess, runCorpusLeaseWorkerRequest, } from "./corpus-lease.js";
3
+ import { retireBoundReadersForProcessShutdown } from "./secure-read.js";
4
+ // Content is sent in 64 KiB raw chunks, so every control line stays far below
5
+ // this fixed ceiling and never scales with the full corpus size.
6
+ const MAX_CONTROL_LINE_BYTES = 512 * 1024;
7
+ markCorpusLeaseWorkerProcess();
8
+ function fail() {
9
+ process.exit(70);
10
+ }
11
+ function send(message) {
12
+ const serialized = JSON.stringify(message);
13
+ if (Buffer.byteLength(serialized) > MAX_CONTROL_LINE_BYTES)
14
+ fail();
15
+ process.stdout.write(serialized + "\n");
16
+ }
17
+ const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
18
+ const responses = [];
19
+ const responseWaiters = [];
20
+ const pauseWaiters = new Map();
21
+ lines.on("line", (line) => {
22
+ if (Buffer.byteLength(line) > MAX_CONTROL_LINE_BYTES)
23
+ fail();
24
+ let message;
25
+ try {
26
+ message = JSON.parse(line);
27
+ }
28
+ catch {
29
+ fail();
30
+ }
31
+ if (message?.type === "resume" && Number.isSafeInteger(message.id)) {
32
+ const resume = pauseWaiters.get(message.id);
33
+ if (!resume)
34
+ fail();
35
+ pauseWaiters.delete(message.id);
36
+ resume();
37
+ return;
38
+ }
39
+ const waiter = responseWaiters.shift();
40
+ if (waiter)
41
+ waiter(message);
42
+ else
43
+ responses.push(message);
44
+ });
45
+ lines.on("close", () => fail());
46
+ function nextResponse() {
47
+ const ready = responses.shift();
48
+ if (ready !== undefined)
49
+ return Promise.resolve(ready);
50
+ return new Promise((resolve) => responseWaiters.push(resolve));
51
+ }
52
+ const bridge = {
53
+ exchange: async (message) => {
54
+ send(message);
55
+ return nextResponse();
56
+ },
57
+ pause: (id, reservedEvent) => {
58
+ if (!Number.isSafeInteger(id) || pauseWaiters.has(id))
59
+ fail();
60
+ let resume;
61
+ const promise = new Promise((resolve) => {
62
+ resume = resolve;
63
+ });
64
+ pauseWaiters.set(id, resume);
65
+ return {
66
+ promise,
67
+ onReserved: () => send({ type: reservedEvent, id }),
68
+ };
69
+ },
70
+ };
71
+ let exitCode = 0;
72
+ try {
73
+ const first = await nextResponse();
74
+ if (!first ||
75
+ first.type !== "begin" ||
76
+ !first.request ||
77
+ typeof first.request.root !== "string" ||
78
+ !first.request.budgets ||
79
+ !Number.isSafeInteger(first.request.timeoutMs) ||
80
+ !Array.isArray(first.request.hookNames)) {
81
+ fail();
82
+ }
83
+ await runCorpusLeaseWorkerRequest(first.request, bridge);
84
+ }
85
+ catch {
86
+ exitCode = 70;
87
+ }
88
+ try {
89
+ await retireBoundReadersForProcessShutdown();
90
+ }
91
+ catch {
92
+ exitCode = 70;
93
+ }
94
+ process.exit(exitCode);
@@ -0,0 +1,176 @@
1
+ export type CorpusReadBudgets = {
2
+ maxFileBytes: number;
3
+ maxCorpusBytes: number;
4
+ maxRetainedPathBytes: number;
5
+ maxFileCount: number;
6
+ maxDirectoryCount: number;
7
+ maxDirectoryEntries: number;
8
+ maxWatcherCount: number;
9
+ maxReaderCount: number;
10
+ };
11
+ export declare const DEFAULT_CORPUS_READ_BUDGETS: Readonly<CorpusReadBudgets>;
12
+ export type CorpusVerificationStats = Readonly<{
13
+ fileCount: number;
14
+ retainedContentBytes: number;
15
+ totalBytes: number;
16
+ }>;
17
+ export type StableCorpusFile = Readonly<{
18
+ readonly path: string;
19
+ readonly relativePath: string;
20
+ readonly content: string;
21
+ }>;
22
+ export type StableCorpusSnapshot = Readonly<{
23
+ readonly canonicalRoot: string;
24
+ files: readonly StableCorpusFile[];
25
+ }>;
26
+ export type CorpusLeaseControls = {
27
+ failWatcher: (reason?: string) => void;
28
+ suppressNextFence: () => void;
29
+ requireRepulseForNextFence: () => void;
30
+ failNextFencePulse: () => void;
31
+ failNextSentinelOpen: () => void;
32
+ pauseNextSentinelOpen: (until: Promise<void>, onReserved?: () => void) => void;
33
+ pauseNextFenceAfterPending: (until: Promise<void>, onPending?: () => void) => void;
34
+ };
35
+ /**
36
+ * Test/diagnostic hooks for deterministic authorization-race coverage.
37
+ * Every awaited hook runs before the final sentinel fence; the successful
38
+ * final fence remains the operation's linearization point.
39
+ */
40
+ export type CorpusLeaseHooks = {
41
+ /** Explicit corpus resource limits; omitted fields use the safe defaults. */
42
+ budgets?: Partial<CorpusReadBudgets>;
43
+ timeoutMs?: number;
44
+ beforeSentinelCreate?: (context: {
45
+ attempt: number;
46
+ slot: number;
47
+ capacity: Readonly<{
48
+ globalReserved: number;
49
+ globalRetained: number;
50
+ rootReserved: number;
51
+ }>;
52
+ }) => void | Promise<void>;
53
+ onWatcherReady?: (context: {
54
+ attempt: number;
55
+ controls: CorpusLeaseControls;
56
+ }) => void | Promise<void>;
57
+ afterBaseline?: (context: {
58
+ attempt: number;
59
+ controls: CorpusLeaseControls;
60
+ }) => void | Promise<void>;
61
+ beforeFinalManifest?: (context: {
62
+ attempt: number;
63
+ controls: CorpusLeaseControls;
64
+ }) => void | Promise<void>;
65
+ afterFinalManifest?: (context: {
66
+ attempt: number;
67
+ controls: CorpusLeaseControls;
68
+ verification: CorpusVerificationStats;
69
+ }) => void | Promise<void>;
70
+ beforeFinalFence?: (context: {
71
+ attempt: number;
72
+ controls: CorpusLeaseControls;
73
+ }) => void | Promise<void>;
74
+ /** Test-only worker entry override for hostile protocol fixtures. */
75
+ workerScriptForTest?: string;
76
+ /** Test-only deterministic worker stall injection. */
77
+ workerStallPhaseForTest?: "before-baseline" | "after-baseline" | "before-finalize" | "before-authorized";
78
+ /** Test-only deterministic parent deadline after the projection has started. */
79
+ operationDeadlineForTest?: Promise<void>;
80
+ /**
81
+ * Test-only: treat worker termination as unconfirmed, whatever really happened.
82
+ *
83
+ * Whether a kill is confirmed inside the grace window is a race a test cannot
84
+ * force: SIGKILL is untrappable, so no fixture can refuse to die on cue, and
85
+ * a shortened window is not enough either. A never-fed child dies almost
86
+ * immediately, so the termination promise usually wins even a zero-length
87
+ * race, and a test written that way passes without ever entering the branch
88
+ * it claims to cover.
89
+ *
90
+ * A boolean forces the branch instead. It is also the least powerful shape
91
+ * available: no delay to coerce, nothing to hold cleanup open, and it can
92
+ * only make the parent more conservative, since an unconfirmed kill of a
93
+ * worker that knew the corpus still poisons.
94
+ */
95
+ forceUnconfirmedTerminationForTest?: boolean;
96
+ /**
97
+ * Held *in addition to* the real reap before a stranded child counts as
98
+ * confirmed dead. Without it both halves of the contract race, because the
99
+ * child is SIGKILLed and so reaped within milliseconds: a test cannot
100
+ * observe "still refused" before recovery overtakes it.
101
+ *
102
+ * A promise the test never settles pins the refusing half; one it settles
103
+ * pins the recovering half. Because recovery waits for this promise *and*
104
+ * the real termination, it can only ever delay recovery relative to
105
+ * production. It cannot unlock the process before the child is genuinely
106
+ * reaped, so it cannot manufacture a pass out of a parent that fails closed
107
+ * too weakly. An earlier draft used `??` here, which substituted the hook
108
+ * for real termination and did exactly that.
109
+ */
110
+ confirmTerminationForTest?: Promise<void>;
111
+ };
112
+ /**
113
+ * Test-only: give this process's deferred releases a chance to settle.
114
+ *
115
+ * Best-effort and bounded, deliberately. The flake this exists for is a release
116
+ * that has not settled YET: a killed child whose termination lands as soon as
117
+ * the event loop breathes. Awaiting gives it that chance, so the next case
118
+ * starts from a clean charge instead of inheriting one.
119
+ *
120
+ * It does NOT throw on an unsettled deferral, because "never settles" is a
121
+ * supported end state, not a defect: a projection that ignores cancellation
122
+ * poisons admission permanently and by design, and the test covering that would
123
+ * fail a stricter hook for doing its job. Returns whether everything settled,
124
+ * for a caller that wants to know.
125
+ *
126
+ * Measured, so the budget is not a guess: one stranded child holds 15,335,424
127
+ * bytes against MAX_RETAINED_CORPUS_MEMORY_BYTES, awaiting while the child is
128
+ * still unreaped clears nothing, and awaiting after the reap lands clears it to
129
+ * zero. The bound only has to cover the gap between a case ending and its
130
+ * child's reap arriving, which is milliseconds unless the machine is saturated,
131
+ * so it is set well above that and still far below vitest's hook timeout.
132
+ */
133
+ export declare function awaitDeferredCorpusReleasesForTests(timeoutMs?: number): Promise<boolean>;
134
+ /** Test-only: the live retained-corpus charge, for leak assertions. */
135
+ export declare function reservedCorpusMemoryBytesForTests(): number;
136
+ export type CorpusLeaseWorkerRequest = Readonly<{
137
+ root: string;
138
+ budgets: Readonly<CorpusReadBudgets>;
139
+ timeoutMs: number;
140
+ hookNames: readonly string[];
141
+ stallPhase?: CorpusLeaseHooks["workerStallPhaseForTest"];
142
+ }>;
143
+ export type CorpusLeaseWorkerBridge = {
144
+ exchange: (message: unknown) => Promise<any>;
145
+ pause: (id: number, reservedEvent: string) => {
146
+ promise: Promise<void>;
147
+ onReserved: () => void;
148
+ };
149
+ };
150
+ /** Mark this short-lived helper before it touches the caller's corpus. */
151
+ export declare function markCorpusLeaseWorkerProcess(): void;
152
+ /** Worker-only entry point used by corpus-lease-worker.ts. */
153
+ export declare function runCorpusLeaseWorkerRequest(request: CorpusLeaseWorkerRequest, bridge: CorpusLeaseWorkerBridge): Promise<void>;
154
+ /**
155
+ * Run a multi-source projection while a killable worker owns every corpus
156
+ * traversal, exact read, watcher, fence, and cleanup filesystem operation.
157
+ */
158
+ /**
159
+ * Authorize a stable corpus read, presenting one error for one condition.
160
+ *
161
+ * Every way the lease can refuse is a refusal, and callers get the same
162
+ * sentence for all of them. Without this, which sentence you saw depended on
163
+ * where the failure landed: `fail()` wraps refusals raised once the worker is
164
+ * running, but a `CorpusLeaseChangedError` thrown before that, such as the
165
+ * deadline guard at the top of the dispatch below, escaped raw.
166
+ *
167
+ * That is observable, not cosmetic. On a contended Windows runner a short
168
+ * budget can elapse between computing the deadline and the very next
169
+ * statement checking it, so the same timeout surfaced as
170
+ * "meeting corpus authorization deadline elapsed" instead of the documented
171
+ * refusal, and CI failed with a message mismatch rather than a real defect.
172
+ *
173
+ * `CorpusLeaseBudgetError` and the plain `Error` refusals keep their own text:
174
+ * those name a caller-actionable limit rather than an authorization outcome.
175
+ */
176
+ export declare function withStableCorpusLease<T>(root: string, operation: (snapshot: StableCorpusSnapshot, attempt: number, signal: AbortSignal) => T | Promise<T>, hooks?: CorpusLeaseHooks): Promise<T>;