@runuai/host 0.9.132 → 0.9.133

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.
@@ -0,0 +1,4 @@
1
+ -- ADR-133: the project's org, so the mirrored parameter can carry the `org`
2
+ -- tag the task-machine lease policy conditions on. Null until the cloud sends
3
+ -- it (env.var.set / env.var.list with orgId) or a stored value carries it.
4
+ ALTER TABLE `host_project_env` ADD `org_id` text;
@@ -162,6 +162,13 @@
162
162
  "when": 1788900000004,
163
163
  "tag": "0022_task_project_ids",
164
164
  "breakpoints": true
165
+ },
166
+ {
167
+ "idx": 23,
168
+ "version": "6",
169
+ "when": 1788900000005,
170
+ "tag": "0023_project_env_org",
171
+ "breakpoints": true
165
172
  }
166
173
  ]
167
174
  }
package/db/schema.ts CHANGED
@@ -188,6 +188,11 @@ export const hostProjectEnv = sqliteTable(
188
188
  .default(sql`(unixepoch() * 1000)`),
189
189
  // ADR-134 slice 3: the credential-store parameter this row mirrors to.
190
190
  storeRef: text("store_ref"),
191
+ // The project's org, learned from an env.var.set/list frame that carries
192
+ // it (cloud from 2026-09-10) or from a stored value that does. It tags the
193
+ // mirrored parameter (`org`, `project`) so a task machine's tag-scoped
194
+ // lease can read it (ADR-133). Null until the cloud has said.
195
+ orgId: text("org_id"),
191
196
  },
192
197
  (t) => ({
193
198
  pk: primaryKey({ columns: [t.projectId, t.key] }),
@@ -73,13 +73,40 @@ export function userCredentialListPath(prefix: string): string | null {
73
73
  return `${prefix}/users/${scope.userId}`;
74
74
  }
75
75
 
76
- /** The path a project-env listing walks, or null to skip. */
77
- export function projectEnvListPath(prefix: string): string | null {
76
+ /**
77
+ * The paths a project-env listing walks, or null to skip. An owned or shared
78
+ * host walks the whole tree. A task machine's lease reads
79
+ * `/uai/projects/<id>/…` only for parameters tagged with its org (the policy
80
+ * conditions on `ssm:ResourceTag/org`), and only the task's own projects are
81
+ * worth asking for — their ids arrive with task-up, not with the first grant,
82
+ * so a listing without ids on a task machine is simply "nothing yet".
83
+ */
84
+ export function projectEnvListPaths(
85
+ prefix: string,
86
+ projectIds?: readonly string[],
87
+ ): string[] | null {
78
88
  const scope = storeListScope();
79
- if (scope.kind === "host") return `${prefix}/projects`;
80
- warnOnce(
81
- "project-env",
82
- "not read on a task machine — the lease policy grants no project parameters (they carry no org tag)",
83
- );
84
- return null;
89
+ if (scope.kind === "host") return [`${prefix}/projects`];
90
+ if (scope.kind === "none") {
91
+ warnOnce("project-env", `not read: ${scope.reason}`);
92
+ return null;
93
+ }
94
+ if (!scope.orgId) {
95
+ warnOnce("project-env", "not read on a task machine whose lease carries no org");
96
+ return null;
97
+ }
98
+ if (!projectIds) return null;
99
+ return projectIds
100
+ .filter((id) => isParameterPathSegment(id))
101
+ .map((id) => `${prefix}/projects/${id}`);
102
+ }
103
+
104
+ /**
105
+ * Whether project env is read by name (describe + get-parameter) instead of
106
+ * by path: true under a lease, whose policy conditions on each parameter's
107
+ * `org` tag and therefore denies the path listing itself (see
108
+ * `CredentialStore.listByName`).
109
+ */
110
+ export function projectEnvListsByName(): boolean {
111
+ return storeListScope().kind === "lease";
85
112
  }
@@ -46,6 +46,21 @@ export interface CredentialStore {
46
46
  delete(name: string): Promise<void>;
47
47
  /** Every credential under `path` (recursive), decrypted. Throws on failure. */
48
48
  list(path: string): Promise<StoredParameter[]>;
49
+ /**
50
+ * Every credential under `path` read by NAME: names from
51
+ * `describe-parameters` (allowed on `*`, never returns values), then one
52
+ * `get-parameter` each, decrypted; a name the identity may not read is
53
+ * skipped. This is how a task machine's lease reads `/uai/projects/<id>`:
54
+ * its policy conditions on the parameter's `org` tag, and
55
+ * `get-parameters-by-path` is authorised against the PATH resource, which
56
+ * carries no tags, so it is denied even when every parameter under it is
57
+ * tagged (live 2026-09-10, smoke 12).
58
+ */
59
+ listByName(path: string): Promise<StoredParameter[]>;
60
+ /** Set tags on an existing parameter (a second call, because put-parameter
61
+ * with Overwrite cannot carry tags). Throws on failure — including when
62
+ * the identity lacks `ssm:AddTagsToResource`, which older host policies do. */
63
+ tag(name: string, tags: Record<string, string>): Promise<void>;
49
64
  /** Names and versions under `path` (recursive), NO decryption — the cheap
50
65
  * poll a Uai-operated host runs to notice a broker refresh. */
51
66
  listVersions(path: string): Promise<Array<{ name: string; version: number }>>;
@@ -136,6 +151,10 @@ function isNotFound(result: CliResult): boolean {
136
151
  return /ParameterNotFound/.test(result.stderr);
137
152
  }
138
153
 
154
+ function isAccessDenied(result: CliResult): boolean {
155
+ return /AccessDenied/.test(result.stderr);
156
+ }
157
+
139
158
  /** Summarize a CLI failure WITHOUT echoing stdout (which could carry a value). */
140
159
  function failure(op: string, result: CliResult): Error {
141
160
  const line =
@@ -181,6 +200,28 @@ export class AwsSsmCredentialStore implements CredentialStore {
181
200
  if (result.status !== 0) throw failure("put-parameter", result);
182
201
  }
183
202
 
203
+ async tag(name: string, tags: Record<string, string>): Promise<void> {
204
+ if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
205
+ const entries = Object.entries(tags);
206
+ if (entries.length === 0) return;
207
+ for (const [key, value] of entries) {
208
+ if (!/^[A-Za-z0-9_.:/=+@-]{1,128}$/.test(key) || !/^[A-Za-z0-9_.:/=+@ -]{0,256}$/.test(value)) {
209
+ throw new Error(`invalid tag ${key}`);
210
+ }
211
+ }
212
+ const result = await this.run([
213
+ "ssm",
214
+ "add-tags-to-resource",
215
+ "--resource-type",
216
+ "Parameter",
217
+ "--resource-id",
218
+ name,
219
+ "--tags",
220
+ ...entries.map(([key, value]) => `Key=${key},Value=${value}`),
221
+ ]);
222
+ if (result.status !== 0) throw failure("add-tags-to-resource", result);
223
+ }
224
+
184
225
  async get(name: string): Promise<string | null> {
185
226
  if (!isParameterName(name)) throw new Error(`invalid parameter name: ${name}`);
186
227
  const result = await this.run([
@@ -243,6 +284,36 @@ export class AwsSsmCredentialStore implements CredentialStore {
243
284
  return out;
244
285
  }
245
286
 
287
+ async listByName(path: string): Promise<StoredParameter[]> {
288
+ if (!isParameterName(path)) throw new Error(`invalid parameter path: ${path}`);
289
+ const described = await this.run([
290
+ "ssm",
291
+ "describe-parameters",
292
+ "--parameter-filters",
293
+ `Key=Path,Option=Recursive,Values=${path}`,
294
+ "--query",
295
+ "Parameters[].Name",
296
+ ]);
297
+ if (described.status !== 0) throw failure("describe-parameters", described);
298
+ const parsed: unknown = JSON.parse(described.stdout || "[]");
299
+ const names = (Array.isArray(parsed) ? parsed : []).filter(
300
+ (name): name is string => typeof name === "string" && name.startsWith(`${path}/`),
301
+ );
302
+ const out: StoredParameter[] = [];
303
+ for (const name of names) {
304
+ const result = await this.run(["ssm", "get-parameter", "--name", name, "--with-decryption"]);
305
+ if (result.status !== 0) {
306
+ // Not tagged for this identity (or gone between the two calls):
307
+ // invisible, not an error — the listing is what the lease may read.
308
+ if (isAccessDenied(result) || isNotFound(result)) continue;
309
+ throw failure("get-parameter", result);
310
+ }
311
+ const value = (JSON.parse(result.stdout) as { Parameter?: { Value?: unknown } }).Parameter?.Value;
312
+ if (typeof value === "string") out.push({ name, value });
313
+ }
314
+ return out;
315
+ }
316
+
246
317
  async listVersions(path: string): Promise<Array<{ name: string; version: number }>> {
247
318
  if (!isParameterName(path)) throw new Error(`invalid parameter path: ${path}`);
248
319
  const out: Array<{ name: string; version: number }> = [];
package/lib/host-env.ts CHANGED
@@ -14,13 +14,14 @@
14
14
  * sealed value as "<ct-base64>.<nonce-base64>".
15
15
  */
16
16
 
17
- import { and, eq } from "drizzle-orm";
17
+ import { and, eq, isNotNull } from "drizzle-orm";
18
18
 
19
19
  import {
20
20
  credentialStorePrefix,
21
21
  isParameterPathSegment,
22
22
  parameterName,
23
23
  queueStoreWrite,
24
+ type CredentialStore,
24
25
  } from "./credential-store";
25
26
  import { getDb, schema } from "./db";
26
27
  import { sealAesGcm, openAesGcm } from "./secrets";
@@ -40,23 +41,111 @@ function unpackValue(valueEnc: string): string {
40
41
  return openAesGcm(ct, nonce);
41
42
  }
42
43
 
43
- /** Set (insert or overwrite) a project env var, encrypting the value at rest. */
44
+ /** Set (insert or overwrite) a project env var, encrypting the value at rest.
45
+ * `orgId` is the project's org when the cloud sent it (see `noteProjectOrg`). */
44
46
  export function setProjectEnvVar(
45
47
  projectId: string,
46
48
  key: string,
47
49
  value: string,
50
+ orgId?: string,
48
51
  ): void {
52
+ const now = Date.now();
49
53
  getDb()
50
54
  .insert(schema.hostProjectEnv)
51
- .values({ projectId, key, valueEnc: packValue(value), updatedAt: Date.now() })
55
+ .values({
56
+ projectId,
57
+ key,
58
+ valueEnc: packValue(value),
59
+ updatedAt: now,
60
+ ...(orgId ? { orgId } : {}),
61
+ })
52
62
  .onConflictDoUpdate({
53
63
  target: [schema.hostProjectEnv.projectId, schema.hostProjectEnv.key],
54
- set: { valueEnc: packValue(value), updatedAt: Date.now() },
64
+ set: { valueEnc: packValue(value), updatedAt: now, ...(orgId ? { orgId } : {}) },
55
65
  })
56
66
  .run();
67
+ if (orgId) noteProjectOrg(projectId, orgId, { except: key });
57
68
  mirrorProjectEnvToStore(projectId, key);
58
69
  }
59
70
 
71
+ /**
72
+ * The cloud told us which org owns `projectId` (an env.var.set/list frame
73
+ * carrying `orgId`). Every row of that project mirrored before the org was
74
+ * known gets the org and is re-tagged in the store, so a task machine's
75
+ * tag-scoped lease can read it (ADR-133). Returns the rows updated.
76
+ */
77
+ export function noteProjectOrg(
78
+ projectId: string,
79
+ orgId: string,
80
+ opts: { except?: string } = {},
81
+ ): number {
82
+ const rows = getDb()
83
+ .select({ key: schema.hostProjectEnv.key, orgId: schema.hostProjectEnv.orgId, storeRef: schema.hostProjectEnv.storeRef })
84
+ .from(schema.hostProjectEnv)
85
+ .where(eq(schema.hostProjectEnv.projectId, projectId))
86
+ .all()
87
+ .filter((row) => row.key !== opts.except && row.orgId !== orgId);
88
+ for (const row of rows) {
89
+ getDb()
90
+ .update(schema.hostProjectEnv)
91
+ .set({ orgId })
92
+ .where(and(eq(schema.hostProjectEnv.projectId, projectId), eq(schema.hostProjectEnv.key, row.key)))
93
+ .run();
94
+ if (row.storeRef) {
95
+ const name = row.storeRef;
96
+ queueStoreWrite(`tag project env ${projectId}/${row.key}`, async (store) => {
97
+ await tagProjectEnvParameter(store, name, projectId, orgId);
98
+ });
99
+ }
100
+ }
101
+ return rows.length;
102
+ }
103
+
104
+ /** The org recorded for a project, or null while the cloud has not said. */
105
+ export function projectEnvOrgId(projectId: string): string | null {
106
+ const row = getDb()
107
+ .select({ orgId: schema.hostProjectEnv.orgId })
108
+ .from(schema.hostProjectEnv)
109
+ .where(and(eq(schema.hostProjectEnv.projectId, projectId), isNotNull(schema.hostProjectEnv.orgId)))
110
+ .get();
111
+ return row?.orgId ?? null;
112
+ }
113
+
114
+ /** The tags a project env parameter carries: the lease policy's `org`, plus
115
+ * `project` for operators reading the store. */
116
+ export function projectEnvTags(projectId: string, orgId: string): Record<string, string> {
117
+ return { org: orgId, project: projectId };
118
+ }
119
+
120
+ const tagWarned = new Set<string>();
121
+ /**
122
+ * Tag a mirrored parameter for the task-machine lease. Best-effort: the
123
+ * value is already in the store, and a host whose policy predates
124
+ * `ssm:AddTagsToResource` (host-agent/scripts/credential-store/
125
+ * bootstrap-aws.sh) must keep mirroring; it warns once per parameter and a
126
+ * later set/list retries.
127
+ */
128
+ export async function tagProjectEnvParameter(
129
+ store: Pick<CredentialStore, "tag">,
130
+ name: string,
131
+ projectId: string,
132
+ orgId: string,
133
+ ): Promise<boolean> {
134
+ try {
135
+ await store.tag(name, projectEnvTags(projectId, orgId));
136
+ tagWarned.delete(name);
137
+ return true;
138
+ } catch (err) {
139
+ if (!tagWarned.has(name)) {
140
+ tagWarned.add(name);
141
+ console.warn(
142
+ `[project-env] credential store: tagging ${name} failed (${err instanceof Error ? err.message : String(err)}) — a task machine cannot read it until it carries org=${orgId}; re-run bootstrap-aws.sh for ssm:AddTagsToResource`,
143
+ );
144
+ }
145
+ return false;
146
+ }
147
+ }
148
+
60
149
  /** Remove a project env var (idempotent). */
61
150
  export function deleteProjectEnvVar(projectId: string, key: string): void {
62
151
  const result = getDb()
@@ -87,6 +176,9 @@ export interface StoredProjectEnv {
87
176
  key: string;
88
177
  value: string;
89
178
  updatedAt: number;
179
+ /** The project's org once the cloud has said (ADR-133 tag scope); a
180
+ * replacement host inherits it with the value. */
181
+ orgId?: string;
90
182
  }
91
183
 
92
184
  /** Null when the project id or key cannot be a parameter path segment (a
@@ -111,6 +203,7 @@ export function projectEnvStoreValue(projectId: string, key: string): StoredProj
111
203
  key,
112
204
  value: unpackValue(row.valueEnc),
113
205
  updatedAt: row.updatedAt,
206
+ ...(row.orgId ? { orgId: row.orgId } : {}),
114
207
  };
115
208
  }
116
209
 
@@ -141,6 +234,7 @@ export function mirrorProjectEnvToStore(projectId: string, key: string): void {
141
234
  if (!value) return;
142
235
  await store.put(name, JSON.stringify(value));
143
236
  setProjectEnvStoreRef(projectId, key, name);
237
+ if (value.orgId) await tagProjectEnvParameter(store, name, projectId, value.orgId);
144
238
  });
145
239
  }
146
240
 
@@ -154,6 +248,7 @@ export function importProjectEnvFromStore(value: StoredProjectEnv, storeRef: str
154
248
  valueEnc: packValue(value.value),
155
249
  updatedAt: value.updatedAt,
156
250
  storeRef,
251
+ ...(value.orgId ? { orgId: value.orgId } : {}),
157
252
  })
158
253
  .onConflictDoNothing({ target: [schema.hostProjectEnv.projectId, schema.hostProjectEnv.key] })
159
254
  .run();
@@ -23,7 +23,8 @@ import {
23
23
  isParameterPathSegment,
24
24
  type CredentialStore,
25
25
  } from "./credential-store";
26
- import { projectEnvListPath, userCredentialListPath } from "./credential-store-scope";
26
+ import { projectEnvListPaths,
27
+ projectEnvListsByName, userCredentialListPath } from "./credential-store-scope";
27
28
  import {
28
29
  deleteToken as deleteGithubTokenLocal,
29
30
  githubTokenParameterName,
@@ -49,6 +50,8 @@ import {
49
50
  projectEnvParameterName,
50
51
  projectEnvStoreValue,
51
52
  setProjectEnvStoreRef,
53
+ noteProjectOrg,
54
+ tagProjectEnvParameter,
52
55
  type StoredProjectEnv,
53
56
  } from "./host-env";
54
57
  import {
@@ -347,10 +350,18 @@ export function parseStoredProjectEnv(value: string): StoredProjectEnv {
347
350
  key: str(o, "key"),
348
351
  value: str(o, "value"),
349
352
  updatedAt: num(o, "updatedAt"),
353
+ ...(typeof o.orgId === "string" && o.orgId.length > 0 ? { orgId: o.orgId } : {}),
350
354
  };
351
355
  }
352
356
 
353
- export async function reconcileProjectEnvFromStore(): Promise<ReconcileUserCredentialsResult> {
357
+ /**
358
+ * Host start (whole tree) or, on a task machine, task-up with the task's
359
+ * project ids (ADR-133: the lease reads `/uai/projects/<id>` only for
360
+ * parameters tagged with its org, and only the task's projects matter).
361
+ */
362
+ export async function reconcileProjectEnvFromStore(
363
+ opts: { projectIds?: readonly string[] } = {},
364
+ ): Promise<ReconcileUserCredentialsResult> {
354
365
  const result: ReconcileUserCredentialsResult = {
355
366
  skipped: false,
356
367
  imported: 0,
@@ -361,16 +372,20 @@ export async function reconcileProjectEnvFromStore(): Promise<ReconcileUserCrede
361
372
  const store = configuredCredentialStore();
362
373
  if (!store) return { ...result, skipped: true };
363
374
  const prefix = credentialStorePrefix();
364
- const path = projectEnvListPath(prefix);
365
- if (path === null) return { ...result, skipped: true };
375
+ const paths = projectEnvListPaths(prefix, opts.projectIds);
376
+ if (paths === null) return { ...result, skipped: true };
366
377
  const warn = (line: string): void => console.warn(`[project-env] credential store: ${line}`);
367
- let listed: Array<{ name: string; value: string }>;
368
- try {
369
- listed = await store.list(path);
370
- } catch (err) {
371
- result.errors++;
372
- warn(`listing ${path} failed: ${err instanceof Error ? err.message : String(err)}`);
373
- return result;
378
+ const listed: Array<{ name: string; value: string }> = [];
379
+ const listedPrefixes: string[] = [];
380
+ const byName = projectEnvListsByName();
381
+ for (const path of paths) {
382
+ try {
383
+ listed.push(...(byName ? await store.listByName(path) : await store.list(path)));
384
+ listedPrefixes.push(`${path}/`);
385
+ } catch (err) {
386
+ result.errors++;
387
+ warn(`listing ${path} failed: ${err instanceof Error ? err.message : String(err)}`);
388
+ }
374
389
  }
375
390
  const local = new Map(listProjectEnvStoreRows().map((r) => [`${r.projectId}/${r.key}`, r]));
376
391
  const seen = new Set<string>();
@@ -386,6 +401,7 @@ export async function reconcileProjectEnvFromStore(): Promise<ReconcileUserCrede
386
401
  const row = local.get(`${at.projectId}/${at.key}`);
387
402
  if (row) {
388
403
  if (row.storeRef !== param.name) setProjectEnvStoreRef(at.projectId, at.key, param.name);
404
+ if (value.orgId) noteProjectOrg(at.projectId, value.orgId);
389
405
  } else if (importProjectEnvFromStore(value, param.name)) {
390
406
  result.imported++;
391
407
  }
@@ -397,7 +413,8 @@ export async function reconcileProjectEnvFromStore(): Promise<ReconcileUserCrede
397
413
  for (const row of local.values()) {
398
414
  try {
399
415
  if (row.storeRef) {
400
- if (row.storeRef.startsWith(`${path}/`) && !seen.has(row.storeRef)) {
416
+ const ref = row.storeRef;
417
+ if (listedPrefixes.some((p) => ref.startsWith(p)) && !seen.has(ref)) {
401
418
  deleteProjectEnvLocal(row.projectId, row.key);
402
419
  result.removed++;
403
420
  }
@@ -408,6 +425,7 @@ export async function reconcileProjectEnvFromStore(): Promise<ReconcileUserCrede
408
425
  if (!name || !value) continue;
409
426
  await upload(store, name, value, (ref) => setProjectEnvStoreRef(row.projectId, row.key, ref));
410
427
  result.uploaded++;
428
+ if (value.orgId) await tagProjectEnvParameter(store, name, row.projectId, value.orgId);
411
429
  } catch (err) {
412
430
  result.errors++;
413
431
  warn(`${row.projectId}/${row.key}: ${err instanceof Error ? err.message : String(err)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.132",
3
+ "version": "0.9.133",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
package/src/index.ts CHANGED
@@ -851,6 +851,25 @@ export const hostCommands: HostCommands = {
851
851
  // ADR-133 sidecar: nothing to clone or spawn with until the lease
852
852
  // has landed and the store is reconciled under it.
853
853
  await awaitSidecarReady();
854
+ if (sidecarMode()) {
855
+ // ADR-133: project env lives under /uai/projects/<id>, readable by
856
+ // the lease only for parameters tagged with this org, and only the
857
+ // task's own projects are worth listing — their ids are known here,
858
+ // not at the first grant (docs/credential-store.md).
859
+ const { reconcileProjectEnvFromStore } = await import("../lib/user-credentials-store");
860
+ const projectIds = input.projects.map((project) => project.id);
861
+ const env = await reconcileProjectEnvFromStore({ projectIds }).catch((error: unknown) => {
862
+ console.warn(
863
+ `[project-env] task ${input.task.id}: credential store read failed: ${error instanceof Error ? error.message : String(error)}`,
864
+ );
865
+ return null;
866
+ });
867
+ if (env && !env.skipped) {
868
+ console.log(
869
+ `[project-env] task ${input.task.id}: credential store reconciled for ${projectIds.length} project(s): ${env.imported} imported, ${env.removed} removed, ${env.errors} errors`,
870
+ );
871
+ }
872
+ }
854
873
  // Launch screen: the first stage boundary. A machine-backed task
855
874
  // spends the next minute launching an EC2 instance; a container
856
875
  // task goes straight to preparing its workspace.
package/src/main.ts CHANGED
@@ -72,6 +72,7 @@ import {
72
72
  deleteProjectEnvVar,
73
73
  listProjectEnvKeys,
74
74
  setProjectEnvVar,
75
+ noteProjectOrg,
75
76
  } from "../lib/host-env";
76
77
  import { handleMcpOp } from "../lib/mcp-connections";
77
78
  import { startMcpGateway } from "../lib/mcp-gateway";
@@ -1832,9 +1833,16 @@ async function connect(): Promise<void> {
1832
1833
  case "env.var.delete": {
1833
1834
  try {
1834
1835
  if (frame.kind === "env.var.set") {
1835
- setProjectEnvVar(frame.projectId, frame.key, frame.value);
1836
+ setProjectEnvVar(frame.projectId, frame.key, frame.value, frame.orgId);
1836
1837
  } else if (frame.kind === "env.var.delete") {
1837
1838
  deleteProjectEnvVar(frame.projectId, frame.key);
1839
+ } else if (frame.orgId) {
1840
+ // A list that names the org backfills rows mirrored before the
1841
+ // cloud sent it, and re-tags them for the task-machine lease.
1842
+ const updated = noteProjectOrg(frame.projectId, frame.orgId);
1843
+ if (updated > 0) {
1844
+ console.log(`[project-env] ${frame.projectId}: org recorded on ${updated} row(s); re-tagging`);
1845
+ }
1838
1846
  }
1839
1847
  // The ack carries the project's KEY NAMES only — values never cross
1840
1848
  // the bridge.
package/src/protocol.ts CHANGED
@@ -1657,13 +1657,18 @@ export type CloudToHost =
1657
1657
  // (env.var.ack) — the value on `env.var.set` is write-only (never echoed
1658
1658
  // back). `opId` correlates request↔ack; the ack's `keys` are that project's
1659
1659
  // keys.
1660
- | { kind: "env.var.list"; opId: string; projectId: string }
1660
+ // `orgId` (cloud from 2026-09-10): the project's org, so the host tags the
1661
+ // mirrored parameter for the tag-scoped task-machine lease policy (ADR-133,
1662
+ // docs/credential-store.md). A list carrying it also backfills rows that
1663
+ // were mirrored before the org was known. Absent from an older cloud.
1664
+ | { kind: "env.var.list"; opId: string; projectId: string; orgId?: string }
1661
1665
  | {
1662
1666
  kind: "env.var.set";
1663
1667
  opId: string;
1664
1668
  projectId: string;
1665
1669
  key: string;
1666
1670
  value: string;
1671
+ orgId?: string;
1667
1672
  }
1668
1673
  | { kind: "env.var.delete"; opId: string; projectId: string; key: string }
1669
1674
  // User-authed MCP connections (ADR-057). Ops run entirely host-side —