@runuai/host 0.9.139 → 0.9.140

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.
@@ -734,7 +734,12 @@ function runCodex(home, args) {
734
734
  encoding: "utf8",
735
735
  env: Object.assign({}, process.env, { CODEX_HOME: home }),
736
736
  maxBuffer: 1024 * 1024,
737
- timeout: 5000,
737
+ // Codex's FIRST run on a fresh machine initialises its state (sqlite)
738
+ // before answering; 5 s timed out on every hostless task machine
739
+ // (smoke 10, 2026-09-10: ETIMEDOUT for both homes) and the direct
740
+ // config.toml fallback did the work with an error in the log. A minute
741
+ // is generous for a probe that normally answers in well under a second.
742
+ timeout: 60000,
738
743
  killSignal: "SIGKILL",
739
744
  });
740
745
  }
@@ -23,7 +23,10 @@ import { addEngineAccount } from "./engine-accounts";
23
23
  import { connectEngine, type EngineKind } from "./engines";
24
24
 
25
25
  const CONNECT_TIMEOUT_MS = 2 * 60_000;
26
- const MAX_CONCURRENT_CONNECTS = 1;
26
+ // Connects are one store write each; labeled (workspace) connects never touch
27
+ // a shared slot, so they run side by side — a user is never told to wait for
28
+ // someone else's connect (ADR-136 sign-in box). Bounded, not serialised.
29
+ const MAX_CONCURRENT_CONNECTS = 16;
27
30
 
28
31
  export type EmitEngineConnectEvent = (frame: EngineConnectEventFrame) => void;
29
32
 
@@ -94,7 +97,7 @@ export class EngineConnectManager {
94
97
  engine,
95
98
  phase: "done",
96
99
  ok: false,
97
- message: "Another engine connect is already in progress.",
100
+ message: "Too many engine connects are running on this runner right now; try again in a minute.",
98
101
  });
99
102
  return true;
100
103
  }
@@ -64,7 +64,13 @@ export const ENGINE_LOGIN_TIMEOUT_MS = 10 * 60_000;
64
64
  export const SUBMIT_KEYSTROKE_DELAY_MS = 300;
65
65
  // Global serialization keeps a failed per-operation stale-resource sweep from
66
66
  // ever deleting another live login's labeled container or marked temp dir.
67
- export const MAX_CONCURRENT_ENGINE_LOGINS = 1;
67
+ /**
68
+ * Concurrent login containers per runner. Workspace sign-ins run side by
69
+ * side (ADR-136 sign-in box: a user is never told to wait for someone else's
70
+ * login); only the host's DEFAULT slot is exclusive, per engine. Bounded so
71
+ * a burst cannot fork containers without limit.
72
+ */
73
+ export const MAX_CONCURRENT_ENGINE_LOGINS = 16;
68
74
 
69
75
  const LOGIN_MOUNT = "/run/uai-engine-login";
70
76
  const LOGIN_HOME = `${LOGIN_MOUNT}/home`;
@@ -402,7 +408,14 @@ export class EngineLoginManager {
402
408
  private readonly seams: EngineLoginSeams;
403
409
  private readonly workspaceOnly: boolean;
404
410
  private readonly operations = new Map<string, LoginOperation>();
405
- private readonly engineOperations = new Map<EngineLoginKind, LoginOperation>();
411
+ /**
412
+ * The DEFAULT-SLOT lock: one unlabeled capture per engine at a time, since
413
+ * two of them would race on the host's own `~/.codex`-style slot. Labeled
414
+ * (workspace) captures land in isolated accounts and never take it — on
415
+ * the ADR-136 sign-in box every workspace's sign-in runs alongside every
416
+ * other's; a user is never told to wait for someone else's login.
417
+ */
418
+ private readonly defaultSlotOperations = new Map<EngineLoginKind, LoginOperation>();
406
419
  private reconciliation: Promise<boolean>;
407
420
 
408
421
  constructor(options: EngineLoginManagerOptions) {
@@ -499,7 +512,7 @@ export class EngineLoginManager {
499
512
  safeEmit(emit, failedEvent(opId, engine, "unavailable", "Login operation already exists."));
500
513
  return false;
501
514
  }
502
- if (this.engineOperations.has(engine)) {
515
+ if (label === undefined && this.defaultSlotOperations.has(engine)) {
503
516
  safeEmit(
504
517
  emit,
505
518
  failedEvent(
@@ -511,10 +524,16 @@ export class EngineLoginManager {
511
524
  );
512
525
  return false;
513
526
  }
527
+ // Every login is a container; bound the fan-out without serialising it.
514
528
  if (this.operations.size >= MAX_CONCURRENT_ENGINE_LOGINS) {
515
529
  safeEmit(
516
530
  emit,
517
- failedEvent(opId, engine, "unavailable", "Too many login operations are active."),
531
+ failedEvent(
532
+ opId,
533
+ engine,
534
+ "unavailable",
535
+ "Too many sign-ins are running on this runner right now; try again in a minute.",
536
+ ),
518
537
  );
519
538
  return false;
520
539
  }
@@ -546,7 +565,7 @@ export class EngineLoginManager {
546
565
  postInputOutputSeen: false,
547
566
  };
548
567
  this.operations.set(opId, operation);
549
- this.engineOperations.set(engine, operation);
568
+ if (label === undefined) this.defaultSlotOperations.set(engine, operation);
550
569
  operation.timer = this.seams.timers.setTimeout(() => {
551
570
  void this.timeout(operation);
552
571
  }, ENGINE_LOGIN_TIMEOUT_MS);
@@ -1309,8 +1328,8 @@ export class EngineLoginManager {
1309
1328
  if (this.operations.get(operation.opId) === operation) {
1310
1329
  this.operations.delete(operation.opId);
1311
1330
  }
1312
- if (this.engineOperations.get(operation.engine) === operation) {
1313
- this.engineOperations.delete(operation.engine);
1331
+ if (this.defaultSlotOperations.get(operation.engine) === operation) {
1332
+ this.defaultSlotOperations.delete(operation.engine);
1314
1333
  }
1315
1334
  });
1316
1335
  return operation.settlementPromise;
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { and, eq } from "drizzle-orm";
11
11
 
12
- import { queueStoreWrite, userParameterName } from "./credential-store";
12
+ import { configuredCredentialStore, queueStoreWrite, userParameterName } from "./credential-store";
13
13
  import { getDb, schema } from "./db";
14
14
  import { sealAesGcm, openAesGcm } from "./secrets";
15
15
  import { dockerCli } from "./docker-exec";
@@ -34,6 +34,7 @@ import {
34
34
  } from "./git-identity";
35
35
  import type { TaskEnvironmentHandle } from "./task-environment/types";
36
36
  import { sidecarMode } from "./sidecar";
37
+ import { signInBoxMode } from "./signin-box";
37
38
  import {
38
39
  MAX_GITHUB_INSTALLATIONS,
39
40
  MAX_GITHUB_REPOSITORIES_PER_INSTALLATION,
@@ -403,7 +404,22 @@ export async function onConnectSet(
403
404
  // Invalidate and drain them before publishing the replacement grant.
404
405
  await deps.invalidateCredentials?.(frame.userId);
405
406
 
406
- (deps.storeLocal ?? storeTokenRow)(frame.userId, fields);
407
+ if (deps.storeLocal) deps.storeLocal(frame.userId, fields);
408
+ else if (signInBoxMode()) {
409
+ // ADR-136 sign-in box: the store first, awaited; a row with no
410
+ // material; the token in the bounded cache only.
411
+ await storeGithubTokenStoreFirst(
412
+ frame.userId,
413
+ {
414
+ installationId: fields.installationId,
415
+ generation: fields.generation,
416
+ refreshTokenExpiresAt: fields.refreshTokenExpiresAt,
417
+ kind,
418
+ updatedAt: now,
419
+ },
420
+ token,
421
+ );
422
+ } else storeTokenRow(frame.userId, fields);
407
423
  replacementStored = true;
408
424
  // Advance again only after the replacement is durable. This detaches work
409
425
  // started during the no-grant fence and lets reconciliation capture B.
@@ -448,6 +464,7 @@ export async function onConnectSet(
448
464
  }
449
465
 
450
466
  function deleteStoredTokenRow(userId: string): void {
467
+ forgetGithubToken(userId);
451
468
  const result = getDb()
452
469
  .delete(schema.githubTokens)
453
470
  .where(eq(schema.githubTokens.userId, userId))
@@ -515,7 +532,9 @@ export function githubTokenParameterName(userId: string): string {
515
532
  return userParameterName(userId, "github");
516
533
  }
517
534
 
518
- /** The row as it is mirrored (token decrypted). Never logged. */
535
+ /** The row as it is mirrored (token decrypted). Never logged. On the
536
+ * sign-in box a store-held row yields the cached token, or null when the
537
+ * cache is cold — there is nothing here to mirror then. */
519
538
  export function githubTokenStoreValue(userId: string): StoredGithubToken | null {
520
539
  const row = getDb()
521
540
  .select()
@@ -523,6 +542,13 @@ export function githubTokenStoreValue(userId: string): StoredGithubToken | null
523
542
  .where(eq(schema.githubTokens.userId, userId))
524
543
  .get();
525
544
  if (!row) return null;
545
+ const token = isStoreHeldGithubRow(row)
546
+ ? cachedGithubToken(userId)
547
+ : openAesGcm(
548
+ Buffer.from(row.refreshTokenCt as Uint8Array),
549
+ Buffer.from(row.refreshTokenNonce as Uint8Array),
550
+ );
551
+ if (token === null) return null;
526
552
  return {
527
553
  v: 1,
528
554
  kind: "github",
@@ -530,10 +556,7 @@ export function githubTokenStoreValue(userId: string): StoredGithubToken | null
530
556
  installationId: row.installationId,
531
557
  generation: row.generation,
532
558
  tokenKind: row.kind,
533
- token: openAesGcm(
534
- Buffer.from(row.refreshTokenCt as Uint8Array),
535
- Buffer.from(row.refreshTokenNonce as Uint8Array),
536
- ),
559
+ token,
537
560
  expiresAt: row.refreshTokenExpiresAt ?? null,
538
561
  updatedAt: row.updatedAt,
539
562
  };
@@ -555,6 +578,8 @@ export function listGithubTokenStoreRows(): Array<{ userId: string; storeRef: st
555
578
  }
556
579
 
557
580
  export function mirrorGithubTokenToStore(userId: string): void {
581
+ // ADR-136 sign-in box: rows hold no material; the store was written first.
582
+ if (signInBoxMode()) return;
558
583
  queueStoreWrite(`mirror github token ${userId}`, async (store) => {
559
584
  const value = githubTokenStoreValue(userId);
560
585
  if (!value) return;
@@ -754,10 +779,15 @@ function readStoredToken(
754
779
  .where(eq(schema.githubTokens.userId, userId))
755
780
  .get();
756
781
  if (!row) return null;
757
- const token = openAesGcm(
758
- Buffer.from(row.refreshTokenCt as Uint8Array),
759
- Buffer.from(row.refreshTokenNonce as Uint8Array),
760
- );
782
+ // ADR-136 sign-in box: a store-held row answers from the bounded cache
783
+ // (filled by `loadGithubTokenIntoCache` on the async paths) or not at all.
784
+ const token = isStoreHeldGithubRow(row)
785
+ ? cachedGithubToken(userId)
786
+ : openAesGcm(
787
+ Buffer.from(row.refreshTokenCt as Uint8Array),
788
+ Buffer.from(row.refreshTokenNonce as Uint8Array),
789
+ );
790
+ if (token === null) return null;
761
791
  return {
762
792
  token,
763
793
  kind: row.kind,
@@ -775,6 +805,7 @@ function readCurrentStoredToken(userId: string): PersistedGitHubGrant | null {
775
805
  }
776
806
 
777
807
  export function deleteToken(userId: string): void {
808
+ forgetGithubToken(userId);
778
809
  getDb()
779
810
  .delete(schema.githubTokens)
780
811
  .where(eq(schema.githubTokens.userId, userId))
@@ -785,7 +816,14 @@ export function deleteToken(userId: string): void {
785
816
  }
786
817
 
787
818
  export function hasToken(userId: string): boolean {
788
- return readCurrentStoredToken(userId) !== null;
819
+ // Metadata only: a store-held row (sign-in box) counts as connected whether
820
+ // or not its token is in the cache right now.
821
+ const row = getDb()
822
+ .select({ generation: schema.githubTokens.generation })
823
+ .from(schema.githubTokens)
824
+ .where(eq(schema.githubTokens.userId, userId))
825
+ .get();
826
+ return row !== undefined && githubCredentialGenerationMatches(userId, row.generation);
789
827
  }
790
828
 
791
829
  /** All cloud user ids with a GitHub token on this host (for capabilities). */
@@ -1299,9 +1337,20 @@ export function clearAllAccessCache(): void {
1299
1337
  githubAuthGenerations.clear();
1300
1338
  }
1301
1339
 
1302
- export function requestAccessToken(
1340
+ export async function requestAccessToken(
1303
1341
  userId: string,
1304
1342
  ): Promise<{ accessToken: string; expiresAt: number | null } | null> {
1343
+ // ADR-136 sign-in box: the token is read from the store on demand for
1344
+ // the installation listing and a legacy exchange, never kept on disk.
1345
+ if (signInBoxMode()) {
1346
+ try {
1347
+ await loadGithubTokenIntoCache(userId);
1348
+ } catch (err) {
1349
+ console.warn(
1350
+ `[github] user ${userId}: could not read the token from the credential store: ${err instanceof Error ? err.message : String(err)}`,
1351
+ );
1352
+ }
1353
+ }
1305
1354
  const generation = githubAuthGeneration(userId);
1306
1355
  // ADR-033 non-expiring path: the stored token IS the access token — return it
1307
1356
  // directly, no exchange/cache/rotation. `expiresAt: null` ⇒ no refresh.
@@ -1361,10 +1410,13 @@ async function doRequestAccessToken(
1361
1410
  });
1362
1411
  if (!row) return null;
1363
1412
 
1364
- const refreshToken = openAesGcm(
1365
- Buffer.from(row.refreshTokenCt as Uint8Array),
1366
- Buffer.from(row.refreshTokenNonce as Uint8Array),
1367
- );
1413
+ const refreshToken = isStoreHeldGithubRow(row)
1414
+ ? cachedGithubToken(userId)
1415
+ : openAesGcm(
1416
+ Buffer.from(row.refreshTokenCt as Uint8Array),
1417
+ Buffer.from(row.refreshTokenNonce as Uint8Array),
1418
+ );
1419
+ if (refreshToken === null) return null;
1368
1420
  const res = await fetch(exchangeUrl(), {
1369
1421
  method: "POST",
1370
1422
  headers: {
@@ -1394,6 +1446,28 @@ async function doRequestAccessToken(
1394
1446
  // exchange was in flight. Never overwrite that newer grant with the rotated
1395
1447
  // refresh token from the superseded exchange.
1396
1448
  if (
1449
+ data.refreshToken &&
1450
+ githubAuthGeneration(userId) === generation &&
1451
+ isStoreHeldGithubRow(row)
1452
+ ) {
1453
+ // ADR-136 sign-in box: the rotated token goes to the cache and the store,
1454
+ // never onto the row.
1455
+ rememberGithubToken(userId, data.refreshToken);
1456
+ const rotated: StoredGithubToken = {
1457
+ v: 1,
1458
+ kind: "github",
1459
+ userId,
1460
+ installationId: row.installationId,
1461
+ generation: row.generation,
1462
+ tokenKind: row.kind,
1463
+ token: data.refreshToken,
1464
+ expiresAt: data.refreshTokenExpiresAt ?? row.refreshTokenExpiresAt ?? null,
1465
+ updatedAt: Date.now(),
1466
+ };
1467
+ queueStoreWrite(`rotate github token ${userId}`, (store) =>
1468
+ store.put(githubTokenParameterName(userId), JSON.stringify(rotated)),
1469
+ );
1470
+ } else if (
1397
1471
  data.refreshToken &&
1398
1472
  githubAuthGeneration(userId) === generation
1399
1473
  ) {
@@ -1433,6 +1507,196 @@ async function doRequestAccessToken(
1433
1507
  return { accessToken: data.accessToken, expiresAt: data.expiresAt };
1434
1508
  }
1435
1509
 
1510
+ // ---------------------------------------------------------------------------
1511
+ // ADR-136 sign-in box: a token row here holds NO ciphertext — `refresh_token_ct`
1512
+ // is empty, the row is "store-held" — the token lives in the store and, only
1513
+ // while the box needs it (the installation listing, a legacy exchange, the
1514
+ // revoke of a superseded grant), in this bounded in-memory cache. Never on
1515
+ // disk. The row's metadata still drives `hasToken` / `connectedUserIds` and
1516
+ // the generation fence exactly as on any host.
1517
+ // ---------------------------------------------------------------------------
1518
+
1519
+ const BOX_TOKEN_CACHE_MS = 10 * 60_000;
1520
+ const boxTokens = new Map<string, { token: string; until: number }>();
1521
+
1522
+ export const GITHUB_TOKEN_TAGS = {
1523
+ installationId: "uai_installation_id",
1524
+ generation: "uai_generation",
1525
+ tokenKind: "uai_token_kind",
1526
+ expiresAt: "uai_expires_at",
1527
+ updatedAt: "uai_updated_at",
1528
+ } as const;
1529
+
1530
+ export function isStoreHeldGithubRow(row: { refreshTokenCt: unknown }): boolean {
1531
+ const ct = row.refreshTokenCt;
1532
+ if (ct === null || ct === undefined) return true;
1533
+ if (ct instanceof Uint8Array) return ct.byteLength === 0;
1534
+ return false;
1535
+ }
1536
+
1537
+ export function rememberGithubToken(userId: string, token: string): void {
1538
+ boxTokens.set(userId, { token, until: Date.now() + BOX_TOKEN_CACHE_MS });
1539
+ }
1540
+
1541
+ export function cachedGithubToken(userId: string): string | null {
1542
+ const entry = boxTokens.get(userId);
1543
+ if (!entry) return null;
1544
+ if (entry.until <= Date.now()) {
1545
+ boxTokens.delete(userId);
1546
+ return null;
1547
+ }
1548
+ return entry.token;
1549
+ }
1550
+
1551
+ export function forgetGithubToken(userId: string): void {
1552
+ boxTokens.delete(userId);
1553
+ }
1554
+
1555
+ /** Test seam. */
1556
+ export function clearGithubTokenCacheForTesting(): void {
1557
+ boxTokens.clear();
1558
+ }
1559
+
1560
+ /** The secret-free metadata a token parameter carries as tags, so a
1561
+ * replacement box lists the user as connected without reading the value. */
1562
+ export function githubTokenMetadataTags(value: {
1563
+ installationId: number;
1564
+ generation: number;
1565
+ tokenKind: string;
1566
+ expiresAt: number | null;
1567
+ updatedAt: number;
1568
+ }): Record<string, string> {
1569
+ return {
1570
+ [GITHUB_TOKEN_TAGS.installationId]: String(value.installationId),
1571
+ [GITHUB_TOKEN_TAGS.generation]: String(value.generation),
1572
+ [GITHUB_TOKEN_TAGS.tokenKind]: value.tokenKind,
1573
+ [GITHUB_TOKEN_TAGS.expiresAt]: value.expiresAt === null ? "" : String(value.expiresAt),
1574
+ [GITHUB_TOKEN_TAGS.updatedAt]: String(value.updatedAt),
1575
+ };
1576
+ }
1577
+
1578
+ /** Fill the cache from the store for a store-held row. True when a token is
1579
+ * cached afterwards (or the row is not store-held); false when there is no
1580
+ * row, no store, or no parameter. Throws on a store failure. */
1581
+ export async function loadGithubTokenIntoCache(userId: string): Promise<boolean> {
1582
+ if (cachedGithubToken(userId) !== null) return true;
1583
+ const row = getDb()
1584
+ .select({
1585
+ refreshTokenCt: schema.githubTokens.refreshTokenCt,
1586
+ generation: schema.githubTokens.generation,
1587
+ })
1588
+ .from(schema.githubTokens)
1589
+ .where(eq(schema.githubTokens.userId, userId))
1590
+ .get();
1591
+ if (!row) return false;
1592
+ if (!isStoreHeldGithubRow(row)) return true;
1593
+ const store = configuredCredentialStore();
1594
+ if (!store) return false;
1595
+ const raw = await store.get(githubTokenParameterName(userId));
1596
+ if (!raw) return false;
1597
+ const parsed: unknown = JSON.parse(raw);
1598
+ const value =
1599
+ parsed && typeof parsed === "object" && !Array.isArray(parsed)
1600
+ ? (parsed as { token?: unknown; generation?: unknown })
1601
+ : {};
1602
+ if (typeof value.token !== "string" || value.token.length === 0) {
1603
+ throw new Error("the stored GitHub token is malformed");
1604
+ }
1605
+ if (value.generation !== row.generation) {
1606
+ console.warn(
1607
+ `[github] user ${userId}: the credential store holds generation ${String(value.generation)}, this box recorded ${row.generation}`,
1608
+ );
1609
+ }
1610
+ rememberGithubToken(userId, value.token);
1611
+ return true;
1612
+ }
1613
+
1614
+ /** A token row with no material (sign-in box). Same fence as `storeTokenRow`. */
1615
+ export function insertStoreHeldGithubTokenRow(
1616
+ userId: string,
1617
+ fields: {
1618
+ installationId: number;
1619
+ generation: number;
1620
+ refreshTokenExpiresAt: number | null;
1621
+ kind: string;
1622
+ updatedAt: number;
1623
+ storeRef: string;
1624
+ },
1625
+ ): void {
1626
+ const values = {
1627
+ ...fields,
1628
+ refreshTokenCt: Buffer.alloc(0),
1629
+ refreshTokenNonce: Buffer.alloc(0),
1630
+ };
1631
+ getDb().transaction((tx) => {
1632
+ const fence = tx
1633
+ .select({
1634
+ generation: schema.githubCredentialGenerations.generation,
1635
+ mutation: schema.githubCredentialGenerations.mutation,
1636
+ })
1637
+ .from(schema.githubCredentialGenerations)
1638
+ .where(eq(schema.githubCredentialGenerations.userId, userId))
1639
+ .get();
1640
+ if (fence?.mutation !== "set" || fence.generation !== fields.generation) {
1641
+ throw new Error("GitHub credential generation changed before storage");
1642
+ }
1643
+ tx.insert(schema.githubTokens)
1644
+ .values({ userId, ...values })
1645
+ .onConflictDoUpdate({ target: schema.githubTokens.userId, set: values })
1646
+ .run();
1647
+ });
1648
+ }
1649
+
1650
+ /** Sign-in box (ADR-136): the store first, awaited — a refused put is the
1651
+ * connect's failure — then tags, a store-held row, and the cache. */
1652
+ export async function storeGithubTokenStoreFirst(
1653
+ userId: string,
1654
+ fields: {
1655
+ installationId: number;
1656
+ generation: number;
1657
+ refreshTokenExpiresAt: number | null;
1658
+ kind: string;
1659
+ updatedAt: number;
1660
+ },
1661
+ token: string,
1662
+ ): Promise<void> {
1663
+ const store = configuredCredentialStore();
1664
+ if (!store) throw new Error("the sign-in box has no credential store to hold the token");
1665
+ const name = githubTokenParameterName(userId);
1666
+ const value: StoredGithubToken = {
1667
+ v: 1,
1668
+ kind: "github",
1669
+ userId,
1670
+ installationId: fields.installationId,
1671
+ generation: fields.generation,
1672
+ tokenKind: fields.kind,
1673
+ token,
1674
+ expiresAt: fields.refreshTokenExpiresAt,
1675
+ updatedAt: fields.updatedAt,
1676
+ };
1677
+ await store.put(name, JSON.stringify(value));
1678
+ try {
1679
+ await store.tag(name, githubTokenMetadataTags(value));
1680
+ } catch (err) {
1681
+ console.warn(
1682
+ `[github] user ${userId}: tagging ${name} failed (${err instanceof Error ? err.message : String(err)}) — a replacement box reads the value once to list this user`,
1683
+ );
1684
+ }
1685
+ insertStoreHeldGithubTokenRow(userId, { ...fields, storeRef: name });
1686
+ rememberGithubToken(userId, token);
1687
+ }
1688
+
1689
+ /** Delete the row only (no store call) — the start-time reconcile's "gone
1690
+ * elsewhere" move on the sign-in box. */
1691
+ export function deleteGithubTokenRowLocal(userId: string): void {
1692
+ forgetGithubToken(userId);
1693
+ getDb()
1694
+ .delete(schema.githubTokens)
1695
+ .where(eq(schema.githubTokens.userId, userId))
1696
+ .run();
1697
+ notifyGithubChange();
1698
+ }
1699
+
1436
1700
  // --- container injection ----------------------------------------------------
1437
1701
 
1438
1702
  /** Injectable docker-exec seam (mocked in tests). Async so the docker call
package/lib/host-env.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  } from "./credential-store";
26
26
  import { getDb, schema } from "./db";
27
27
  import { sealAesGcm, openAesGcm } from "./secrets";
28
+ import { signInBoxMode } from "./signin-box";
28
29
 
29
30
  /** Pack a sealed value (ct + nonce) into the single TEXT column. */
30
31
  function packValue(value: string): string {
@@ -227,6 +228,9 @@ export function listProjectEnvStoreRows(): Array<{ projectId: string; key: strin
227
228
  }
228
229
 
229
230
  export function mirrorProjectEnvToStore(projectId: string, key: string): void {
231
+ // ADR-136 sign-in box: no local rows; env.var.* is a store passthrough
232
+ // (lib/signin-box-credentials.ts).
233
+ if (signInBoxMode()) return;
230
234
  const name = projectEnvParameterName(projectId, key);
231
235
  if (!name) return;
232
236
  queueStoreWrite(`mirror project env ${projectId}/${key}`, async (store) => {