my-durable 0.0.7 → 0.0.9

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.
Files changed (44) hide show
  1. package/README.md +6 -0
  2. package/apps/dpi-backend/src/config.ts +10 -3
  3. package/apps/dpi-backend/src/daemon.ts +422 -0
  4. package/apps/dpi-backend/src/main.ts +148 -7
  5. package/apps/dpi-backend/src/pair-page.ts +111 -0
  6. package/apps/dpi-backend/src/pairing-files.ts +29 -3
  7. package/apps/dpi-backend/src/pairing.ts +368 -59
  8. package/apps/dpi-backend/src/routes.ts +14 -1
  9. package/apps/dpi-backend/src/store.ts +1 -0
  10. package/apps/dpi-backend/src/tunnel.ts +294 -55
  11. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Cg3vey95.css +1 -0
  12. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CoYMgU-2.js +77 -0
  13. package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
  14. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +17 -4
  15. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +29 -0
  16. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +107 -18
  17. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +1 -1
  18. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceHandoff.vue +132 -0
  19. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +2 -2
  20. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +36 -6
  21. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +107 -5
  22. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +3 -1
  23. package/apps/dpi-playground/apps/dpi-playground-hub/src/admin-auth.ts +19 -17
  24. package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +1 -0
  25. package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +4 -0
  26. package/apps/dpi-playground/apps/dpi-playground-hub/src/hub-identity.ts +98 -0
  27. package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +31 -3
  28. package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +172 -25
  29. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +24 -0
  30. package/apps/dpi-sdk/src/api.ts +2 -0
  31. package/apps/dpi-sdk/src/node/private-file.ts +140 -0
  32. package/apps/dpi-sdk/src/node/registry.ts +6 -0
  33. package/apps/dpi-sdk/src/protocol/pairing.ts +137 -16
  34. package/apps/dpi-sdk/src/protocol/tunnel.ts +162 -38
  35. package/apps/dpi-tui/src/app.ts +889 -187
  36. package/apps/dpi-tui/src/hub-feed.ts +71 -0
  37. package/apps/dpi-tui/src/hub-mux.ts +168 -0
  38. package/apps/dpi-tui/src/main.ts +5 -1
  39. package/apps/dpi-tui/src/theme.ts +14 -0
  40. package/apps/dpi-tui/src/views.ts +311 -0
  41. package/bin/dpi.mjs +21 -4
  42. package/package.json +2 -2
  43. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index--aoZ5Tj0.css +0 -1
  44. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Dq0CZYr7.js +0 -77
@@ -33,6 +33,8 @@ import {
33
33
  PAIR_SIGNATURE_FAIL_LIMIT,
34
34
  PAIR_POLL_MS,
35
35
  } from "../../../../dpi-sdk/src/protocol/limits.js";
36
+ import { hubProofSigningPayload, PairDeviceRequest as PairDeviceRequestSchema } from "../../../../dpi-sdk/src/protocol/pairing.js";
37
+ import type { HubIdentity } from "./hub-identity.js";
36
38
  import {
37
39
  PairApproval as PairApprovalSchema,
38
40
  PairCodeState as PairCodeStateSchema,
@@ -80,6 +82,9 @@ interface Enrollment {
80
82
  code: string;
81
83
  deviceUid: string;
82
84
  deviceName: string;
85
+ /** Machine description shown to the operator before approval (device-initiated flow). */
86
+ hostname?: string;
87
+ platform?: string;
83
88
  publicKeyPem: string;
84
89
  computedFingerprint: string;
85
90
  declaredFingerprint: string;
@@ -109,11 +114,39 @@ interface CodeEntry {
109
114
  enrollmentId?: string;
110
115
  }
111
116
 
117
+ /** One live pairing request as the admin list renders it (no secrets). */
118
+ export interface PendingPairing {
119
+ codeId: string;
120
+ expiresAt: number;
121
+ phase: "awaiting_device" | "awaiting_proof" | "approved_ready" | "cancelled";
122
+ enrollmentId?: string;
123
+ deviceUid?: string;
124
+ deviceName?: string;
125
+ hostname?: string;
126
+ platform?: string;
127
+ keyFingerprint?: string;
128
+ proofVerified?: boolean;
129
+ startedAt?: number;
130
+ }
131
+
132
+ export interface PairDeviceAck {
133
+ type: "pair.device-ack";
134
+ v: 1;
135
+ codeId: string;
136
+ code: string;
137
+ expiresAt: number;
138
+ hubOrigin: string;
139
+ hubId: string;
140
+ challenge: unknown;
141
+ }
142
+
112
143
  export interface PairingDeps {
113
144
  registry: NetworkRegistry;
114
145
  audit: Audit;
115
146
  hubOrigin: string;
116
147
  hubId: string;
148
+ /** Offer-v3 pinning: present on every real hub; absent only in legacy harnesses. */
149
+ identity?: HubIdentity;
117
150
  /**
118
151
  * Issuance boundary (P2-10 CredentialService). Returns the committed TokenIssue. It must reject with
119
152
  * `code: "identity_changed"` when `expectedIdentity` no longer matches the registry (GA-05).
@@ -206,27 +239,30 @@ export class PairingService {
206
239
  }
207
240
 
208
241
  /**
209
- * v2 offer link, minted from the SAME values the v1 pairUrl uses (same code,
210
- * same expiry): the whole handoff — hub origin, hubId, code, expiry — rides
211
- * the #offer= fragment, which no server or CDN ever sees in a log.
242
+ * v3 offer link (v2 without an identity key), minted from the SAME values
243
+ * the v1 pairUrl uses (same code, same expiry): the whole handoff — hub
244
+ * origin, hubId, code, expiry, pinned hub-key fingerprint — rides the
245
+ * #offer= fragment, which no server or CDN ever sees in a log.
212
246
  */
213
247
  private offerUrlFor(code: string, expiresAt: number): string {
214
- return `${this.deps.hubOrigin}/pair#offer=${encodePairOffer({
215
- v: 2,
216
- hub: this.deps.hubOrigin,
217
- id: this.deps.hubId,
218
- code,
219
- exp: expiresAt,
220
- })}`;
248
+ const identity = this.deps.identity;
249
+ return `${this.deps.hubOrigin}/pair#offer=${encodePairOffer(
250
+ identity
251
+ ? {
252
+ v: 3,
253
+ hub: this.deps.hubOrigin,
254
+ id: this.deps.hubId,
255
+ code,
256
+ exp: expiresAt,
257
+ kfp: identity.fingerprint,
258
+ }
259
+ : { v: 2, hub: this.deps.hubOrigin, id: this.deps.hubId, code, exp: expiresAt },
260
+ )}`;
221
261
  }
222
262
 
223
- /** POST /api/pair/code (admin) — mint a fresh single-use code. */
224
- createCode(address: string): { ok: true; value: PairCodeIssue } | PairingFailure {
263
+ /** Mint a single-use code entry (shared by the admin and device-initiated flows). */
264
+ private mintEntry(actor: "admin" | "device"): CodeEntry {
225
265
  const now = this.now();
226
- const taken = this.createLimiter.take(ipKey(address));
227
- if (!taken.ok) return this.rateLimited(taken, "Too many codes; slow down");
228
- if (this.codes.size >= PAIR_RAM_CAPS.codes)
229
- return this.fail(429, "rate_limited", "Pairing capacity reached; retry later", 60_000);
230
266
  let code = "";
231
267
  do {
232
268
  // A code is a bearer secret authorising one enrollment: canonical UUID v4 = 122 random bits.
@@ -239,15 +275,25 @@ export class PairingService {
239
275
  expiresAt: now + CODE_TTL_MS,
240
276
  cancelled: false,
241
277
  };
242
- // Audit row first: a code that cannot be audited must not exist (and must not eat the code capacity).
243
278
  this.deps.audit.append({
244
- actor: "admin",
279
+ actor,
245
280
  action: "pair.code_created",
246
281
  outcome: "ok",
247
- detail: { codeId: entry.codeId, expiresAt: entry.expiresAt },
282
+ detail: { codeId: entry.codeId, expiresAt: entry.expiresAt, ...(actor === "device" ? { initiated: "device" } : {}) },
248
283
  });
249
284
  this.codes.set(entry.codeId, entry);
250
285
  this.byCode.set(code, entry.codeId);
286
+ return entry;
287
+ }
288
+
289
+ /** POST /api/pair/code (admin) — mint a fresh single-use code. */
290
+ createCode(address: string): { ok: true; value: PairCodeIssue } | PairingFailure {
291
+ const taken = this.createLimiter.take(ipKey(address));
292
+ if (!taken.ok) return this.rateLimited(taken, "Too many codes; slow down");
293
+ if (this.codes.size >= PAIR_RAM_CAPS.codes)
294
+ return this.fail(429, "rate_limited", "Pairing capacity reached; retry later", 60_000);
295
+ const entry = this.mintEntry("admin");
296
+ const code = entry.code;
251
297
  return this.ok({
252
298
  type: "pair.code",
253
299
  v: 1,
@@ -260,16 +306,92 @@ export class PairingService {
260
306
  });
261
307
  }
262
308
 
309
+ /**
310
+ * POST /api/pair/request (public, rate-limited) — DEVICE-INITIATED enrollment.
311
+ *
312
+ * The machine that wants to pair asks for a code instead of receiving one, so
313
+ * the request carries its whole identity (name, hostname, platform, public
314
+ * key) and the operator reads it in the approval list — the shape orca/paseo
315
+ * offers have. Nothing gets weaker: the hub mints the same 122-bit single-use
316
+ * code, parses the key itself, owns the challenge nonce, and the credential is
317
+ * still issued only by a human approval carrying the hub-computed fingerprint.
318
+ */
319
+ requestForDevice(body: unknown, address: string): { ok: true; value: PairDeviceAck } | PairingFailure {
320
+ const ip = ipKey(address);
321
+ if (this.failureBlocked(ip))
322
+ return this.fail(429, "rate_limited", "Too many pairing failures; slow down", 60_000);
323
+ const taken = this.createLimiter.take(ip);
324
+ if (!taken.ok) return this.rateLimited(taken, "Too many pairing requests; slow down");
325
+ if (this.codes.size >= PAIR_RAM_CAPS.codes)
326
+ return this.fail(429, "rate_limited", "Pairing capacity reached; retry later", 60_000);
327
+ const request = body as { type?: unknown } | null;
328
+ if (request?.type !== "pair.device-request" || !checkPairingDto(PairDeviceRequestSchema, request))
329
+ return this.fail(400, "invalid", "pair.device-request body failed schema validation");
330
+
331
+ const entry = this.mintEntry("device");
332
+ const started = this.start({ ...(request as object), type: "pair.request", v: 1, code: entry.code }, address);
333
+ if (!started.ok) {
334
+ // The enrollment never existed: drop the code so a failed request leaves nothing behind.
335
+ this.codes.delete(entry.codeId);
336
+ this.byCode.delete(entry.code);
337
+ return started;
338
+ }
339
+ return this.ok({
340
+ type: "pair.device-ack",
341
+ v: 1,
342
+ codeId: entry.codeId,
343
+ code: entry.code,
344
+ expiresAt: entry.expiresAt,
345
+ hubOrigin: this.deps.hubOrigin,
346
+ hubId: this.deps.hubId,
347
+ challenge: started.value,
348
+ });
349
+ }
350
+
351
+ /**
352
+ * GET /api/pair/codes (admin) — every live code with the machine identity the
353
+ * operator needs to decide. Deliberately carries NO code, token or key
354
+ * material: identity + hub-computed fingerprint + phase only.
355
+ */
356
+ listPending(): PendingPairing[] {
357
+ const now = this.now();
358
+ const out: PendingPairing[] = [];
359
+ for (const entry of this.codes.values()) {
360
+ if (entry.cancelled || now >= entry.expiresAt) continue;
361
+ const enrollment = entry.enrollmentId ? this.enrollments.get(entry.enrollmentId) : undefined;
362
+ const phase = !enrollment
363
+ ? "awaiting_device"
364
+ : enrollment.cancelled
365
+ ? "cancelled"
366
+ : enrollment.proofVerified
367
+ ? "approved_ready"
368
+ : "awaiting_proof";
369
+ out.push({
370
+ codeId: entry.codeId,
371
+ expiresAt: entry.expiresAt,
372
+ phase,
373
+ ...(enrollment
374
+ ? {
375
+ enrollmentId: enrollment.enrollmentId,
376
+ deviceUid: enrollment.deviceUid,
377
+ deviceName: enrollment.deviceName,
378
+ ...(enrollment.hostname ? { hostname: enrollment.hostname } : {}),
379
+ ...(enrollment.platform ? { platform: enrollment.platform } : {}),
380
+ keyFingerprint: enrollment.computedFingerprint,
381
+ proofVerified: enrollment.proofVerified,
382
+ startedAt: enrollment.nonceIssuedAt,
383
+ }
384
+ : {}),
385
+ });
386
+ }
387
+ return out;
388
+ }
389
+
263
390
  /** Admin handoff lookup. Reuses a code; never creates an enrollment or approves it. */
264
391
  resolveCode(body: unknown): { ok: true; value: PairCodeIssue } | PairingFailure {
265
392
  const raw = (body as { code?: unknown } | null)?.code;
266
393
  const code = typeof raw === "string" ? normalizePairCode(raw) : undefined;
267
- if (
268
- !body ||
269
- typeof body !== "object" ||
270
- Object.keys(body).length !== 1 ||
271
- !isPairCode(code)
272
- )
394
+ if (!body || typeof body !== "object" || Object.keys(body).length !== 1 || !isPairCode(code))
273
395
  return this.fail(400, "code_invalid", "Invalid pairing handoff");
274
396
  const codeId = this.byCode.get(code);
275
397
  const entry = codeId ? this.codes.get(codeId) : undefined;
@@ -297,6 +419,24 @@ export class PairingService {
297
419
  return entry;
298
420
  }
299
421
 
422
+ /** Hub-identity proof for a device that pinned kfp (offer v3): sign the canonical payload over the enrollment. */
423
+ private hubProof(hubNonce: string, enrollmentId: string) {
424
+ const identity = this.deps.identity;
425
+ if (!identity) return undefined;
426
+ return {
427
+ hubPublicKey: identity.publicKeyPem,
428
+ hubKeyFingerprint: identity.fingerprint,
429
+ hubSignature: identity.signPayload(
430
+ hubProofSigningPayload({
431
+ hubOrigin: this.deps.hubOrigin,
432
+ hubId: this.deps.hubId,
433
+ subject: `pair:${enrollmentId}`,
434
+ nonce: hubNonce,
435
+ }),
436
+ ),
437
+ };
438
+ }
439
+
300
440
  /** POST /api/pair/start — issue a challenge bound to one immutable tuple. */
301
441
  start(body: unknown, address: string): { ok: true; value: PairChallengeOut } | PairingFailure {
302
442
  const now = this.now();
@@ -316,9 +456,12 @@ export class PairingService {
316
456
  code: string;
317
457
  deviceUid: string;
318
458
  deviceName: string;
459
+ hostname?: string;
460
+ platform?: string;
319
461
  publicKey: string;
320
462
  keyFingerprint: string;
321
463
  clientTime: number;
464
+ hubNonce?: string;
322
465
  };
323
466
  const live = this.liveCode(req.code, now);
324
467
  if (live === "invalid") {
@@ -352,6 +495,7 @@ export class PairingService {
352
495
  nonce: existing.nonce,
353
496
  issuedAt: existing.nonceIssuedAt,
354
497
  hubId: this.deps.hubId,
498
+ ...(req.hubNonce ? this.hubProof(req.hubNonce, existing.enrollmentId) : {}),
355
499
  });
356
500
  }
357
501
 
@@ -388,6 +532,8 @@ export class PairingService {
388
532
  code: req.code,
389
533
  deviceUid: req.deviceUid,
390
534
  deviceName: req.deviceName,
535
+ ...(req.hostname ? { hostname: req.hostname } : {}),
536
+ ...(req.platform ? { platform: req.platform } : {}),
391
537
  publicKeyPem: req.publicKey,
392
538
  computedFingerprint: fingerprint,
393
539
  declaredFingerprint: req.keyFingerprint,
@@ -407,6 +553,7 @@ export class PairingService {
407
553
  nonce: enrollment.nonce,
408
554
  issuedAt: enrollment.nonceIssuedAt,
409
555
  hubId: this.deps.hubId,
556
+ ...(req.hubNonce ? this.hubProof(req.hubNonce, enrollment.enrollmentId) : {}),
410
557
  });
411
558
  }
412
559
 
@@ -32,6 +32,8 @@ import {
32
32
  type TunnelFrame,
33
33
  type TunnelWireMessage,
34
34
  } from "../../../../dpi-sdk/src/protocol/tunnel.js";
35
+ import { hubProofSigningPayload } from "../../../../dpi-sdk/src/protocol/pairing.js";
36
+ import type { HubIdentity } from "./hub-identity.js";
35
37
  import {
36
38
  HANDSHAKE_DEADLINE_MS,
37
39
  IDLE_TIMEOUT_MS,
@@ -162,6 +164,9 @@ export class TunnelServer {
162
164
  handlers: TunnelHandlers = {},
163
165
  policy: Partial<TunnelPolicy> = {},
164
166
  hooks: TunnelTestHooks = {},
167
+ /** Offer-v3 pinning: signs hello proofs for proofNonce-carrying handshakes. */
168
+ private readonly hubOrigin: string = "",
169
+ private readonly identity?: HubIdentity,
165
170
  ) {
166
171
  this.policy = { ...DEFAULT_TUNNEL_POLICY, ...policy };
167
172
  if (policy.ipCapacity === undefined)
@@ -753,6 +758,25 @@ export class TunnelServer {
753
758
  hubId: this.hubId,
754
759
  connectionId: conn.connectionId,
755
760
  serverTime: this.now(),
761
+ // Offer-v3 pinning: a device that sent proofNonce gets the hub identity
762
+ // proof bound to ITS device+epoch, so a captured proof cannot replay.
763
+ ...(hs.proofNonce && this.identity && pairing
764
+ ? (() => {
765
+ const i = this.identity!;
766
+ return {
767
+ hubPublicKey: i.publicKeyPem,
768
+ hubKeyFingerprint: i.fingerprint,
769
+ hubSignature: i.signPayload(
770
+ hubProofSigningPayload({
771
+ hubOrigin: this.hubOrigin,
772
+ hubId: this.hubId,
773
+ subject: `tunnel:${conn.auth.device_id}:${pairing.pairing_epoch}`,
774
+ nonce: hs.proofNonce,
775
+ }),
776
+ ),
777
+ };
778
+ })()
779
+ : {}),
756
780
  } satisfies TunnelFrame),
757
781
  );
758
782
  this.handlers.onOpen?.(this.asConnection(conn));
@@ -94,6 +94,8 @@ export interface DeviceInfo {
94
94
  startDirs: string[];
95
95
  models: ModelInfo[];
96
96
  defaultModel: string;
97
+ /** Provider id of this device ("openrouter" | "faux"); FE model fallback. */
98
+ provider?: string;
97
99
  storage: { dataDir: string; catalogPath: string; appStatePath: string };
98
100
  /** The checkout the backend runs from (repo root locally, `code_dir` on deployed devices). */
99
101
  codeDir?: string;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Private-file hygiene shared by every app (device backend, hub, sdk registry):
3
+ * atomic writes for credentials and owner-only access on every platform.
4
+ *
5
+ * POSIX gets file modes (0600/0700). Windows ignores them, so the same calls
6
+ * install the hardened DACL Orca uses (`windows-secure-dacl.ts`): protected
7
+ * from inheritance, full control for SYSTEM, Administrators and the user —
8
+ * nobody else. `icacls` ships with every Windows box, so no native addon is
9
+ * needed. Like Orca (and paseo's applyPrivateMode) a restriction failure never
10
+ * crashes the caller: it reports and returns false so the boot can continue and
11
+ * a later pass can retry.
12
+ */
13
+ import { chmodSync, closeSync, fsyncSync, mkdirSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
14
+ import { execFile } from "node:child_process";
15
+ import { basename, dirname, join } from "node:path";
16
+ import { randomBytes } from "node:crypto";
17
+
18
+ export interface RestrictDeps {
19
+ platform?: NodeJS.Platform;
20
+ /** Injectable for tests: run a command, resolve stdout or reject. */
21
+ execFile?: (cmd: string, args: string[]) => Promise<string>;
22
+ }
23
+
24
+ /** orca's SIDs: SYSTEM and local Administrators can take ownership anyway. */
25
+ const LOCAL_SYSTEM_SID = "S-1-5-18";
26
+ const BUILTIN_ADMINISTRATORS_SID = "S-1-5-32-544";
27
+
28
+ const execDefault = (cmd: string, args: string[]) =>
29
+ new Promise<string>((resolve, reject) => {
30
+ execFile(cmd, args, { timeout: 15_000, windowsHide: true }, (error, stdout) => {
31
+ if (error) reject(error);
32
+ else resolve(String(stdout ?? ""));
33
+ });
34
+ });
35
+
36
+ /** The current user's SID via `whoami` (locale-proof: extract the S-1-5-* token). */
37
+ async function userSid(deps: RestrictDeps): Promise<string> {
38
+ const out = await deps.execFile!("whoami", ["/user", "/fo", "csv", "/nh"]);
39
+ const m = out.match(/S-1-5-[0-9-]+/);
40
+ if (!m) throw Error(`whoami returned no SID: ${out.trim()}`);
41
+ return m[0];
42
+ }
43
+
44
+ /**
45
+ * Restrict one FILE to its owner. POSIX: chmod 0600 (idempotent). Windows:
46
+ * strip inheritance, grant SYSTEM + Administrators + the user. Never throws.
47
+ */
48
+ export async function restrictToOwner(path: string, deps: RestrictDeps = {}): Promise<boolean> {
49
+ const platform = deps.platform ?? process.platform;
50
+ const exec = deps.execFile ?? execDefault;
51
+ if (platform === "win32") {
52
+ try {
53
+ const sid = await userSid({ execFile: exec });
54
+ const grants = [LOCAL_SYSTEM_SID, BUILTIN_ADMINISTRATORS_SID, sid].map(
55
+ (s) => `*${s}:F`,
56
+ );
57
+ await exec("icacls", [path, "/inheritance:r", "/grant:r", ...grants]);
58
+ return true;
59
+ } catch (e) {
60
+ console.error(`private-file: could not restrict ${path} (${(e as Error).message})`);
61
+ return false;
62
+ }
63
+ }
64
+ try {
65
+ chmodSync(path, 0o600);
66
+ return true;
67
+ } catch (e) {
68
+ console.error(`private-file: could not restrict ${path} (${(e as Error).message})`);
69
+ return false;
70
+ }
71
+ }
72
+
73
+ /** Restrict a DIRECTORY (children inherit on Windows via (OI)(CI)). Never throws. */
74
+ export async function restrictDirToOwner(path: string, deps: RestrictDeps = {}): Promise<boolean> {
75
+ const platform = deps.platform ?? process.platform;
76
+ const exec = deps.execFile ?? execDefault;
77
+ if (platform === "win32") {
78
+ try {
79
+ const sid = await userSid({ execFile: exec });
80
+ const grants = [LOCAL_SYSTEM_SID, BUILTIN_ADMINISTRATORS_SID, sid].map(
81
+ (s) => `*${s}:(OI)(CI)F`,
82
+ );
83
+ await exec("icacls", [path, "/inheritance:r", "/grant:r", ...grants]);
84
+ return true;
85
+ } catch (e) {
86
+ console.error(`private-file: could not restrict ${path} (${(e as Error).message})`);
87
+ return false;
88
+ }
89
+ }
90
+ try {
91
+ chmodSync(path, 0o700);
92
+ return true;
93
+ } catch (e) {
94
+ console.error(`private-file: could not restrict ${path} (${(e as Error).message})`);
95
+ return false;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Atomic write for credential files (orca STA-3941 / paseo writePrivateFileAtomicSync):
101
+ * temp file 0600 + fsync + rename, so readers only ever see the old bytes or the
102
+ * complete new ones — a crash mid-write can never truncate a token. Then the
103
+ * owner restriction runs on every platform. Throws only if the disk write
104
+ * itself fails.
105
+ */
106
+ export async function writePrivateFileAtomic(
107
+ path: string,
108
+ contents: string,
109
+ deps: RestrictDeps = {},
110
+ ): Promise<void> {
111
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
112
+ const temp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString("hex")}.tmp`);
113
+ let fd: number | undefined;
114
+ try {
115
+ fd = openSync(temp, "w", 0o600);
116
+ writeSync(fd, Buffer.from(contents, "utf8"));
117
+ closeSync(fd);
118
+ fd = undefined;
119
+ renameSync(temp, path);
120
+ } catch (e) {
121
+ if (fd !== undefined) closeSync(fd);
122
+ rmSync(temp, { force: true });
123
+ throw e;
124
+ }
125
+ // POSIX: fsync the directory so the rename itself is durable (Windows dir
126
+ // fds cannot be fsynced — EPERM; NTFS journaling carries it there).
127
+ if ((deps.platform ?? process.platform) !== "win32") {
128
+ try {
129
+ const dirFd = openSync(dirname(path), "r");
130
+ try {
131
+ fsyncSync(dirFd);
132
+ } finally {
133
+ closeSync(dirFd);
134
+ }
135
+ } catch {
136
+ /* best effort, like pairing-files' fsyncReadOnlySupported probe */
137
+ }
138
+ }
139
+ await restrictToOwner(path, deps);
140
+ }
@@ -1,6 +1,7 @@
1
1
  // The client-side device registry: ~/.dpi/network.sqlite. Shared by the playground hub,
2
2
  // dpi-tui and scripts/net.ts on this machine. Holds tokens (mode 0600), live status and UI state.
3
3
  import { DatabaseSync } from "node:sqlite";
4
+ import { restrictToOwner } from "./private-file.js";
4
5
  import { homedir } from "node:os";
5
6
  import { dirname, join } from "node:path";
6
7
  import { migrateRegistryFile } from "./registry-migration.js";
@@ -100,6 +101,11 @@ export class NetworkRegistry {
100
101
  // migration). Any failure throws — the hub/TUI must not boot on it.
101
102
  migrateRegistryFile(path);
102
103
  this.db = new DatabaseSync(path);
104
+ // The registry carries device tokens: sqlite owns the file's creation mode,
105
+ // so tighten it here — 0600 on POSIX is ensurePrivateDir's job for the
106
+ // directory; on Windows this installs the owner-only DACL (registry.ts is
107
+ // shared by the hub, so keep it fire-and-forget and non-fatal).
108
+ void restrictToOwner(path);
103
109
  this.db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=3000; PRAGMA foreign_keys=ON;");
104
110
  }
105
111