@cortexkit/subc-client 0.21.2 → 0.22.2
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/client.d.ts +33 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +50 -14
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/launch-nonce.d.ts +45 -6
- package/dist/launch-nonce.d.ts.map +1 -1
- package/dist/launch-nonce.js +159 -3
- package/dist/launch-nonce.js.map +1 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +5 -5
- package/dist/provider.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +71 -17
- package/src/index.ts +6 -0
- package/src/launch-nonce.ts +167 -5
- package/src/provider.ts +6 -5
package/src/launch-nonce.ts
CHANGED
|
@@ -9,14 +9,15 @@
|
|
|
9
9
|
* {@link launchNonce} reads that descriptor once, closes it, and caches the
|
|
10
10
|
* value for the life of the process. While modules move over, the daemon also
|
|
11
11
|
* keeps setting the environment copy (`SUBC_LAUNCH_NONCE`), which
|
|
12
|
-
* {@link launchNonce} reads when no descriptor is named. Windows
|
|
13
|
-
*
|
|
12
|
+
* {@link launchNonce} reads when no descriptor is named. Windows uses a
|
|
13
|
+
* PID-authenticated named pipe; call {@link launchNonceAsync} to initialize it.
|
|
14
14
|
*
|
|
15
15
|
* This mirrors the Rust accessor in `subc-os` (`subc_os::launch_nonce`): the
|
|
16
16
|
* same checks in the same order, the same error names and messages.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { closeSync, fstatSync, readSync, type BigIntStats } from "node:fs";
|
|
20
|
+
import { connect, type Socket } from "node:net";
|
|
20
21
|
|
|
21
22
|
/** The environment copy of the nonce, kept only while modules move to the descriptor. */
|
|
22
23
|
export const SUBC_LAUNCH_NONCE_ENV = "SUBC_LAUNCH_NONCE";
|
|
@@ -30,11 +31,20 @@ export const SUBC_LAUNCH_NONCE_ENV = "SUBC_LAUNCH_NONCE";
|
|
|
30
31
|
*/
|
|
31
32
|
export const SUBC_LAUNCH_NONCE_FD_ENV = "SUBC_LAUNCH_NONCE_FD";
|
|
32
33
|
|
|
34
|
+
/** The one-time Windows pipe name. Its name is not a secret. */
|
|
35
|
+
export const SUBC_LAUNCH_NONCE_PIPE_ENV = "SUBC_LAUNCH_NONCE_PIPE";
|
|
36
|
+
|
|
37
|
+
/** When the daemon sets this variable to `env`, a failed pipe read may use
|
|
38
|
+
* `SUBC_LAUNCH_NONCE` instead. The daemon keeps that copy until every module
|
|
39
|
+
* reads its secret from the pipe, so older modules can still register.
|
|
40
|
+
*/
|
|
41
|
+
export const SUBC_LAUNCH_NONCE_PIPE_FALLBACK_ENV = "SUBC_LAUNCH_NONCE_PIPE_FALLBACK";
|
|
42
|
+
|
|
33
43
|
/** The descriptor number the daemon gives the pipe's read end in the module. */
|
|
34
44
|
export const LAUNCH_NONCE_FD = 3;
|
|
35
45
|
|
|
36
46
|
/** Where a process got its launch nonce from, as modules report it in provenance. */
|
|
37
|
-
export type LaunchNonceSource = "fd" | "env";
|
|
47
|
+
export type LaunchNonceSource = "fd" | "pipe" | "env";
|
|
38
48
|
|
|
39
49
|
/** The nonce this process was launched with, and where it came from. */
|
|
40
50
|
export interface LaunchNonce {
|
|
@@ -46,7 +56,9 @@ export interface LaunchNonce {
|
|
|
46
56
|
* Why the descriptor named by `SUBC_LAUNCH_NONCE_FD` gave no nonce. The names
|
|
47
57
|
* are the Rust `LaunchNonceError` variants.
|
|
48
58
|
*
|
|
49
|
-
*
|
|
59
|
+
* These errors never fall back unless the Windows daemon explicitly sets
|
|
60
|
+
* `SUBC_LAUNCH_NONCE_PIPE_FALLBACK=env` while offering a nonempty environment copy.
|
|
61
|
+
* A named descriptor that
|
|
50
62
|
* cannot be read means the handoff went wrong, or that this process inherited
|
|
51
63
|
* the variable from a module without inheriting the pipe; reading the
|
|
52
64
|
* environment instead would hide the first and defeat the second.
|
|
@@ -65,7 +77,12 @@ export type LaunchNonceErrorKind =
|
|
|
65
77
|
/** Reading the named pipe failed. */
|
|
66
78
|
| "Unreadable"
|
|
67
79
|
/** The named pipe held bytes that are not UTF-8. */
|
|
68
|
-
| "NotUtf8"
|
|
80
|
+
| "NotUtf8"
|
|
81
|
+
| "PipeNeedsAsync"
|
|
82
|
+
| "PipeNotOpen"
|
|
83
|
+
| "PipeEmpty"
|
|
84
|
+
| "PipeUnreadable"
|
|
85
|
+
| "PipeNotUtf8";
|
|
69
86
|
|
|
70
87
|
export interface LaunchNonceErrorDetails {
|
|
71
88
|
/** The descriptor number, for every kind except `Malformed`. */
|
|
@@ -134,6 +151,10 @@ export function isLaunchNonceError(error: unknown): error is LaunchNonceError {
|
|
|
134
151
|
* names is taken only if it is a pipe with the named inode and holds bytes;
|
|
135
152
|
* it is then read to end of file and closed. Anything else throws and leaves
|
|
136
153
|
* the descriptor as it was, and never falls back to the environment.
|
|
154
|
+
* - Windows pipe reads use the async accessor. An uninitialized sync pipe
|
|
155
|
+
* reader throws `PipeNeedsAsync`, except when the daemon explicitly permits
|
|
156
|
+
* fallback with `SUBC_LAUNCH_NONCE_PIPE_FALLBACK=env`; then a sync reader
|
|
157
|
+
* caches the environment copy.
|
|
137
158
|
* - Otherwise the value of `SUBC_LAUNCH_NONCE` is used.
|
|
138
159
|
*
|
|
139
160
|
* It never changes `process.env`. Removing either variable would break any
|
|
@@ -164,6 +185,33 @@ export function launchNonceOrUndefined(): LaunchNonce | undefined {
|
|
|
164
185
|
return result.ok ? result.nonce ?? undefined : undefined;
|
|
165
186
|
}
|
|
166
187
|
|
|
188
|
+
/** Initialize the Windows pipe, or use the synchronous Unix/environment reader.
|
|
189
|
+
* Concurrent callers share one in-flight read and its cached result. After this
|
|
190
|
+
* completes, synchronous accessors return that same result. With the daemon's
|
|
191
|
+
* explicit `SUBC_LAUNCH_NONCE_PIPE_FALLBACK=env` permission, failed or incomplete
|
|
192
|
+
* pipe reads cache `SUBC_LAUNCH_NONCE` and its `env` source. Without that variable
|
|
193
|
+
* set to `env`, a failed pipe read remains an error even if the copy exists.
|
|
194
|
+
* Node's public pipe API cannot ask Windows to let the server inspect the
|
|
195
|
+
* client's identity without also allowing it to impersonate the client's token.
|
|
196
|
+
*/
|
|
197
|
+
export async function launchNonceAsync(): Promise<LaunchNonce | undefined> {
|
|
198
|
+
const cell = processCell();
|
|
199
|
+
return unwrap(await (cell.resultAsync?.() ?? cell.result()));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Return the cached nonce asynchronously, or `undefined` if its read failed.
|
|
203
|
+
* The SDK uses this when building `route.open`: without a nonce it omits
|
|
204
|
+
* `consumer_identity` instead of presenting an unread or invalid credential.
|
|
205
|
+
*/
|
|
206
|
+
export async function launchNonceOrUndefinedAsync(): Promise<LaunchNonce | undefined> {
|
|
207
|
+
try {
|
|
208
|
+
return await launchNonceAsync();
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (isLaunchNonceError(error)) return undefined;
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
167
215
|
type CachedResult =
|
|
168
216
|
| { readonly ok: true; readonly nonce: LaunchNonce | null }
|
|
169
217
|
| { readonly ok: false; readonly error: LaunchNonceError };
|
|
@@ -174,6 +222,7 @@ type CachedResult =
|
|
|
174
222
|
*/
|
|
175
223
|
interface SharedLaunchNonceCell {
|
|
176
224
|
result(): CachedResult;
|
|
225
|
+
resultAsync?(): Promise<CachedResult>;
|
|
177
226
|
}
|
|
178
227
|
|
|
179
228
|
/**
|
|
@@ -219,27 +268,68 @@ export type LaunchNonceStat = (fd: number) => Pick<BigIntStats, "isFIFO" | "ino"
|
|
|
219
268
|
*/
|
|
220
269
|
export class LaunchNonceCell implements SharedLaunchNonceCell {
|
|
221
270
|
private cached: CachedResult | undefined;
|
|
271
|
+
private pending: Promise<CachedResult> | undefined;
|
|
222
272
|
/** How many times this cell has taken a descriptor. At most one. */
|
|
223
273
|
descriptorReads = 0;
|
|
224
274
|
private readonly lookup: (key: string) => string | undefined;
|
|
225
275
|
private readonly platform: NodeJS.Platform;
|
|
226
276
|
private readonly stat: LaunchNonceStat;
|
|
277
|
+
private readonly pipeReader: (name: string) => Promise<LaunchNonce>;
|
|
227
278
|
|
|
228
279
|
constructor(
|
|
229
280
|
lookup: (key: string) => string | undefined,
|
|
230
281
|
platform: NodeJS.Platform = process.platform,
|
|
231
282
|
stat: LaunchNonceStat = (fd) => fstatSync(fd, { bigint: true }),
|
|
283
|
+
pipeReader: (name: string) => Promise<LaunchNonce> = readLaunchNoncePipe,
|
|
232
284
|
) {
|
|
233
285
|
this.lookup = lookup;
|
|
234
286
|
this.platform = platform;
|
|
235
287
|
this.stat = stat;
|
|
288
|
+
this.pipeReader = pipeReader;
|
|
236
289
|
}
|
|
237
290
|
|
|
238
291
|
result(): CachedResult {
|
|
292
|
+
if (this.cached === undefined && this.platform === "win32" && this.lookup(SUBC_LAUNCH_NONCE_PIPE_ENV) !== undefined) {
|
|
293
|
+
const fallback = this.permittedPipeFallback();
|
|
294
|
+
if (fallback === undefined) {
|
|
295
|
+
throw new LaunchNonceError("PipeNeedsAsync", "call launchNonceAsync() before the synchronous reader when SUBC_LAUNCH_NONCE_PIPE is set");
|
|
296
|
+
}
|
|
297
|
+
// Legacy synchronous readers cannot use net.connect. Only the daemon's
|
|
298
|
+
// explicit fallback permission lets them retain environment startup while
|
|
299
|
+
// environment-only modules migrate to async pipe reads.
|
|
300
|
+
this.cached = { ok: true, nonce: fallback };
|
|
301
|
+
}
|
|
239
302
|
if (this.cached === undefined) this.cached = this.read();
|
|
240
303
|
return this.cached;
|
|
241
304
|
}
|
|
242
305
|
|
|
306
|
+
async resultAsync(): Promise<CachedResult> {
|
|
307
|
+
if (this.cached !== undefined) return this.cached;
|
|
308
|
+
if (this.pending !== undefined) return this.pending;
|
|
309
|
+
const named = this.platform === "win32" ? this.lookup(SUBC_LAUNCH_NONCE_PIPE_ENV) : undefined;
|
|
310
|
+
if (named === undefined) return this.result();
|
|
311
|
+
const fallback = this.permittedPipeFallback();
|
|
312
|
+
this.pending = this.pipeReader(named).then(
|
|
313
|
+
(nonce): CachedResult => ({ ok: true, nonce: fallback !== undefined && nonce.value !== fallback.value ? fallback : nonce }),
|
|
314
|
+
(error: unknown): CachedResult => {
|
|
315
|
+
if (fallback !== undefined) return { ok: true, nonce: fallback };
|
|
316
|
+
if (!isLaunchNonceError(error)) throw error;
|
|
317
|
+
return { ok: false, error };
|
|
318
|
+
},
|
|
319
|
+
).then((result) => { this.cached ??= result; return this.cached; });
|
|
320
|
+
return this.pending;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private permittedPipeFallback(): LaunchNonce | undefined {
|
|
324
|
+
if (this.lookup(SUBC_LAUNCH_NONCE_PIPE_FALLBACK_ENV) !== "env") return undefined;
|
|
325
|
+
const value = this.lookup(SUBC_LAUNCH_NONCE_ENV);
|
|
326
|
+
return value ? makeLaunchNonce(value, "env") : undefined;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async getAsync(): Promise<LaunchNonce | undefined> {
|
|
330
|
+
return unwrap(await this.resultAsync());
|
|
331
|
+
}
|
|
332
|
+
|
|
243
333
|
/** The cached value, reading it first if no caller has yet; throws a refusal. */
|
|
244
334
|
get(): LaunchNonce | undefined {
|
|
245
335
|
return unwrap(this.result());
|
|
@@ -445,3 +535,75 @@ function closeQuietly(fd: number): void {
|
|
|
445
535
|
// Nothing useful can be done if closing the consumed pipe fails.
|
|
446
536
|
}
|
|
447
537
|
}
|
|
538
|
+
|
|
539
|
+
/** Read a one-time pipe to EOF. The injected connector and budgets let unit
|
|
540
|
+
* tests exercise busy retry and timeouts without requiring a Windows host.
|
|
541
|
+
*/
|
|
542
|
+
export async function readLaunchNoncePipe(
|
|
543
|
+
name: string,
|
|
544
|
+
connector: (name: string) => Socket = (path) => connect(path),
|
|
545
|
+
openWindowMs = 500,
|
|
546
|
+
readWindowMs = 2000,
|
|
547
|
+
): Promise<LaunchNonce> {
|
|
548
|
+
const deadline = Date.now() + openWindowMs;
|
|
549
|
+
for (;;) {
|
|
550
|
+
try {
|
|
551
|
+
const bytes = await pipeAttempt(name, connector, Math.max(1, deadline - Date.now()), readWindowMs);
|
|
552
|
+
if (bytes.length === 0) throw new LaunchNonceError("PipeEmpty", "the launch nonce named pipe is empty");
|
|
553
|
+
let value: string;
|
|
554
|
+
try {
|
|
555
|
+
value = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
556
|
+
} catch {
|
|
557
|
+
throw new LaunchNonceError("PipeNotUtf8", "the launch nonce named pipe held bytes that are not UTF-8");
|
|
558
|
+
}
|
|
559
|
+
return makeLaunchNonce(value, "pipe");
|
|
560
|
+
} catch (error) {
|
|
561
|
+
if (isLaunchNonceError(error)) throw error;
|
|
562
|
+
const busy = (error as NodeJS.ErrnoException)?.code === "EBUSY" || errnoOf(error) === 231;
|
|
563
|
+
if (!busy || Date.now() >= deadline) {
|
|
564
|
+
throw new LaunchNonceError("PipeNotOpen", "SUBC_LAUNCH_NONCE_PIPE names a pipe that could not be opened", { errno: errnoOf(error) });
|
|
565
|
+
}
|
|
566
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(10, Math.max(1, deadline - Date.now()))));
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function pipeAttempt(
|
|
572
|
+
name: string,
|
|
573
|
+
connector: (name: string) => Socket,
|
|
574
|
+
openWindowMs: number,
|
|
575
|
+
readWindowMs: number,
|
|
576
|
+
): Promise<Buffer> {
|
|
577
|
+
return new Promise((resolve, reject) => {
|
|
578
|
+
let socket: Socket;
|
|
579
|
+
try { socket = connector(name); } catch (error) { reject(error); return; }
|
|
580
|
+
let connected = false;
|
|
581
|
+
let settled = false;
|
|
582
|
+
let size = 0;
|
|
583
|
+
const chunks: Buffer[] = [];
|
|
584
|
+
let timer = setTimeout(() => finish(new LaunchNonceError("PipeNotOpen", "launch nonce pipe open timed out")), openWindowMs);
|
|
585
|
+
function finish(error?: unknown): void {
|
|
586
|
+
if (settled) return;
|
|
587
|
+
settled = true;
|
|
588
|
+
clearTimeout(timer);
|
|
589
|
+
socket.destroy();
|
|
590
|
+
if (error !== undefined) reject(error);
|
|
591
|
+
else resolve(Buffer.concat(chunks));
|
|
592
|
+
}
|
|
593
|
+
socket.once("connect", () => {
|
|
594
|
+
connected = true;
|
|
595
|
+
clearTimeout(timer);
|
|
596
|
+
timer = setTimeout(() => finish(new LaunchNonceError("PipeUnreadable", "launch nonce pipe read timed out")), readWindowMs);
|
|
597
|
+
});
|
|
598
|
+
socket.on("data", (chunk: Buffer) => {
|
|
599
|
+
size += chunk.length;
|
|
600
|
+
if (size > 4096) { finish(new LaunchNonceError("PipeUnreadable", "launch nonce pipe exceeded its byte limit")); return; }
|
|
601
|
+
chunks.push(Buffer.from(chunk));
|
|
602
|
+
});
|
|
603
|
+
socket.once("end", () => finish());
|
|
604
|
+
socket.once("error", (error) => finish(connected ? new LaunchNonceError("PipeUnreadable", "could not read the launch nonce from the named pipe", { errno: errnoOf(error) }) : error));
|
|
605
|
+
socket.once("close", () => {
|
|
606
|
+
if (!settled) finish(new LaunchNonceError("PipeUnreadable", "launch nonce pipe closed before EOF"));
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
}
|
package/src/provider.ts
CHANGED
|
@@ -27,7 +27,8 @@ import {
|
|
|
27
27
|
} from "./envelope.js";
|
|
28
28
|
import {
|
|
29
29
|
isLaunchNonceError,
|
|
30
|
-
|
|
30
|
+
launchNonceAsync as processLaunchNonce,
|
|
31
|
+
launchNonceOrUndefinedAsync,
|
|
31
32
|
launchNonceOrUndefined,
|
|
32
33
|
type LaunchNonceError,
|
|
33
34
|
type LaunchNonceSource,
|
|
@@ -680,7 +681,7 @@ export class SubcProvider {
|
|
|
680
681
|
);
|
|
681
682
|
}
|
|
682
683
|
|
|
683
|
-
const normalized = normalizeProviderConnectOptions(opts);
|
|
684
|
+
const normalized = await normalizeProviderConnectOptions(opts);
|
|
684
685
|
const opened = await SubcProvider.openConnection(normalized);
|
|
685
686
|
return new SubcProvider(opened.sock, opened.conn, normalized, opened.ack);
|
|
686
687
|
}
|
|
@@ -1466,7 +1467,7 @@ function nonEmpty(value: string | undefined): string | undefined {
|
|
|
1466
1467
|
return value && value.length > 0 ? value : undefined;
|
|
1467
1468
|
}
|
|
1468
1469
|
|
|
1469
|
-
function normalizeProviderConnectOptions(opts: SubcProviderConnectOptions): NormalizedSubcProviderConnectOptions {
|
|
1470
|
+
async function normalizeProviderConnectOptions(opts: SubcProviderConnectOptions): Promise<NormalizedSubcProviderConnectOptions> {
|
|
1470
1471
|
// The environment is read once here. The daemon injects both variables when it
|
|
1471
1472
|
// spawns a module; together they mark the process as supervised, and the nonce
|
|
1472
1473
|
// is also what HELLO echoes for a reserved module_id.
|
|
@@ -1478,13 +1479,13 @@ function normalizeProviderConnectOptions(opts: SubcProviderConnectOptions): Norm
|
|
|
1478
1479
|
let processNonce: string | undefined;
|
|
1479
1480
|
if (opts.launchNonce === undefined) {
|
|
1480
1481
|
try {
|
|
1481
|
-
processNonce = processLaunchNonce()?.value;
|
|
1482
|
+
processNonce = (await processLaunchNonce())?.value;
|
|
1482
1483
|
} catch (error) {
|
|
1483
1484
|
if (isLaunchNonceError(error)) throw launchNonceUnavailable(error);
|
|
1484
1485
|
throw error;
|
|
1485
1486
|
}
|
|
1486
1487
|
} else {
|
|
1487
|
-
processNonce =
|
|
1488
|
+
processNonce = (await launchNonceOrUndefinedAsync())?.value;
|
|
1488
1489
|
}
|
|
1489
1490
|
const envLaunchNonce = nonEmpty(processNonce);
|
|
1490
1491
|
const supervised = envModuleId !== undefined && envLaunchNonce !== undefined;
|