@runuai/host 0.1.1 → 0.2.0

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,5 @@
1
+ CREATE TABLE `host_env_vars` (
2
+ `key` text PRIMARY KEY NOT NULL,
3
+ `value_enc` text NOT NULL,
4
+ `updated_at` integer NOT NULL DEFAULT (unixepoch() * 1000)
5
+ );
@@ -0,0 +1,9 @@
1
+ DROP TABLE IF EXISTS `host_env_vars`;
2
+ --> statement-breakpoint
3
+ CREATE TABLE `host_project_env` (
4
+ `project_id` text NOT NULL,
5
+ `key` text NOT NULL,
6
+ `value_enc` text NOT NULL,
7
+ `updated_at` integer NOT NULL DEFAULT (unixepoch() * 1000),
8
+ PRIMARY KEY (`project_id`, `key`)
9
+ );
@@ -36,6 +36,20 @@
36
36
  "when": 1779900005000,
37
37
  "tag": "0004_host_owner_name",
38
38
  "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "6",
43
+ "when": 1779900006000,
44
+ "tag": "0005_host_env_vars",
45
+ "breakpoints": true
46
+ },
47
+ {
48
+ "idx": 6,
49
+ "version": "6",
50
+ "when": 1779900007000,
51
+ "tag": "0006_host_project_env",
52
+ "breakpoints": true
39
53
  }
40
54
  ]
41
55
  }
package/db/schema.ts CHANGED
@@ -7,7 +7,13 @@
7
7
  */
8
8
 
9
9
  import { sql } from "drizzle-orm";
10
- import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
10
+ import {
11
+ blob,
12
+ integer,
13
+ primaryKey,
14
+ sqliteTable,
15
+ text,
16
+ } from "drizzle-orm/sqlite-core";
11
17
 
12
18
  export const hostTasks = sqliteTable("uai_host_tasks", {
13
19
  taskId: text("task_id").primaryKey(),
@@ -80,3 +86,28 @@ export const sshKeys = sqliteTable("host_ssh_keys", {
80
86
 
81
87
  export type SshKey = typeof sshKeys.$inferSelect;
82
88
  export type NewSshKey = typeof sshKeys.$inferInsert;
89
+
90
+ // Per-(project, key) environment variable values, encrypted at rest with the
91
+ // host master key (same secret-blind pattern as host_ssh_keys /
92
+ // host_github_tokens — ADR-015). The cloud only ever sees KEY NAMES, never
93
+ // values (values are write-only). Set by project/org members on the project
94
+ // settings page (grouped by host); at task-up each project on the task gets ITS
95
+ // (projectId, key) values injected under the key name. The same key name is
96
+ // independent per project, and a project's values are independent per host.
97
+ export const hostProjectEnv = sqliteTable(
98
+ "host_project_env",
99
+ {
100
+ projectId: text("project_id").notNull(),
101
+ key: text("key").notNull(),
102
+ valueEnc: text("value_enc").notNull(), // sealAesGcm output (base64 ct.nonce)
103
+ updatedAt: integer("updated_at", { mode: "number" })
104
+ .notNull()
105
+ .default(sql`(unixepoch() * 1000)`),
106
+ },
107
+ (t) => ({
108
+ pk: primaryKey({ columns: [t.projectId, t.key] }),
109
+ }),
110
+ );
111
+
112
+ export type HostProjectEnv = typeof hostProjectEnv.$inferSelect;
113
+ export type NewHostProjectEnv = typeof hostProjectEnv.$inferInsert;
package/lib/agent.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  removeCommandDb,
28
28
  } from "./command-db";
29
29
  import { env } from "./env";
30
+ import { getDecryptedForProject } from "./host-env";
30
31
  import { PreviewPortRuntimesSchema } from "./preview-ports";
31
32
  import { getHostTask } from "./runtime-state";
32
33
  import { removeTaskIdentity, writeTaskIdentity } from "./ssh";
@@ -243,13 +244,29 @@ export const agent = {
243
244
  // Materialize the creator's per-user SSH key so task-up.sh clones + pushes
244
245
  // as them (ADR-029); null → task-up.sh falls back to the operator identity.
245
246
  const identityDir = writeTaskIdentity(input.task.id, input.task.ownerUserId);
247
+ // Per-(project, key) env var VALUES: each project on the task contributes
248
+ // its own decrypted values, which go straight into the child ENV (never
249
+ // args/logs/the compose YAML). task-up.sh already renders a `${KEY:-}`
250
+ // pass-through for each project's DECLARED keys, so docker interpolates the
251
+ // value from this child's env at up-time — mirroring CLAUDE_CODE_OAUTH_TOKEN
252
+ // and secret-blind end to end (ADR-015). On a key collision across projects
253
+ // the LEAD project (position 0) wins: iterate in DESCENDING position order
254
+ // so position-0's assignments are applied LAST and overwrite the rest.
255
+ const extraEnv: Record<string, string> = {};
256
+ const orderedProjects = [...input.projects].sort(
257
+ (a, b) => b.position - a.position,
258
+ );
259
+ for (const project of orderedProjects) {
260
+ Object.assign(extraEnv, getDecryptedForProject(project.id));
261
+ }
262
+ if (identityDir) extraEnv.UAI_TASK_IDENTITY_DIR = identityDir;
246
263
  try {
247
264
  return await runAgent(
248
265
  "task-up.sh",
249
266
  [input.task.id],
250
267
  TaskUpData,
251
268
  commandDbPath,
252
- identityDir ? { UAI_TASK_IDENTITY_DIR: identityDir } : {},
269
+ extraEnv,
253
270
  );
254
271
  } finally {
255
272
  removeCommandDb(commandDbPath);
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Per-(project, key) environment variable store.
3
+ *
4
+ * Project/org members set KEY=VALUE pairs scoped to a (projectId, key) on this
5
+ * host; values are encrypted at rest with the host master key (same
6
+ * secret-blind pattern as host_ssh_keys / host_github_tokens — ADR-015). The
7
+ * cloud + UI only ever see KEY NAMES, never values (values are write-only). The
8
+ * same key name (e.g. DATABASE_URL) is independent per project, and a project's
9
+ * values are independent per host. At task-up each project on the task gets ITS
10
+ * (projectId, key) values injected into the task container env under the key
11
+ * name (see lib/agent.ts + scripts/agent/task-up.sh).
12
+ *
13
+ * `value_enc` is a single TEXT column carrying both halves of the AES-GCM
14
+ * sealed value as "<ct-base64>.<nonce-base64>".
15
+ */
16
+
17
+ import { and, eq } from "drizzle-orm";
18
+
19
+ import { getDb, schema } from "./db";
20
+ import { sealAesGcm, openAesGcm } from "./secrets";
21
+
22
+ /** Pack a sealed value (ct + nonce) into the single TEXT column. */
23
+ function packValue(value: string): string {
24
+ const sealed = sealAesGcm(value);
25
+ return `${sealed.ct.toString("base64")}.${sealed.nonce.toString("base64")}`;
26
+ }
27
+
28
+ /** Reverse {@link packValue}; throws if the column is malformed. */
29
+ function unpackValue(valueEnc: string): string {
30
+ const dot = valueEnc.indexOf(".");
31
+ if (dot === -1) throw new Error("malformed value_enc");
32
+ const ct = Buffer.from(valueEnc.slice(0, dot), "base64");
33
+ const nonce = Buffer.from(valueEnc.slice(dot + 1), "base64");
34
+ return openAesGcm(ct, nonce);
35
+ }
36
+
37
+ /** Set (insert or overwrite) a project env var, encrypting the value at rest. */
38
+ export function setProjectEnvVar(
39
+ projectId: string,
40
+ key: string,
41
+ value: string,
42
+ ): void {
43
+ getDb()
44
+ .insert(schema.hostProjectEnv)
45
+ .values({ projectId, key, valueEnc: packValue(value), updatedAt: Date.now() })
46
+ .onConflictDoUpdate({
47
+ target: [schema.hostProjectEnv.projectId, schema.hostProjectEnv.key],
48
+ set: { valueEnc: packValue(value), updatedAt: Date.now() },
49
+ })
50
+ .run();
51
+ }
52
+
53
+ /** Remove a project env var (idempotent). */
54
+ export function deleteProjectEnvVar(projectId: string, key: string): void {
55
+ getDb()
56
+ .delete(schema.hostProjectEnv)
57
+ .where(
58
+ and(
59
+ eq(schema.hostProjectEnv.projectId, projectId),
60
+ eq(schema.hostProjectEnv.key, key),
61
+ ),
62
+ )
63
+ .run();
64
+ }
65
+
66
+ /** Sorted list of a project's env var KEY NAMES (never the values). */
67
+ export function listProjectEnvKeys(projectId: string): string[] {
68
+ return getDb()
69
+ .select({ key: schema.hostProjectEnv.key })
70
+ .from(schema.hostProjectEnv)
71
+ .where(eq(schema.hostProjectEnv.projectId, projectId))
72
+ .all()
73
+ .map((row) => row.key)
74
+ .sort();
75
+ }
76
+
77
+ /**
78
+ * Decrypt + return a project's env vars as KEY→VALUE. Used at task-up injection
79
+ * (lib/agent.ts) — the decrypted values live only on the trusted host (ADR-015)
80
+ * and go straight into the task container's child env, never logged. Resilient:
81
+ * a single value that fails to decrypt is logged + skipped rather than aborting
82
+ * the whole task launch.
83
+ */
84
+ export function getDecryptedForProject(projectId: string): Record<string, string> {
85
+ const rows = getDb()
86
+ .select({
87
+ key: schema.hostProjectEnv.key,
88
+ valueEnc: schema.hostProjectEnv.valueEnc,
89
+ })
90
+ .from(schema.hostProjectEnv)
91
+ .where(eq(schema.hostProjectEnv.projectId, projectId))
92
+ .all();
93
+ const out: Record<string, string> = {};
94
+ for (const row of rows) {
95
+ try {
96
+ out[row.key] = unpackValue(row.valueEnc);
97
+ } catch (err) {
98
+ console.warn(
99
+ `[host-env] skipping ${projectId}/${row.key}: decrypt failed (${
100
+ err instanceof Error ? err.message : String(err)
101
+ })`,
102
+ );
103
+ }
104
+ }
105
+ return out;
106
+ }
@@ -94,9 +94,94 @@ function run(command: string, args: string[]): Promise<RunResult> {
94
94
  }
95
95
 
96
96
  /**
97
- * Ensure the standard image and the shared asdf data volume exist. Builds the
98
- * image only when `docker image inspect` fails. Best-effort: logs and returns
99
- * on any error so the host keeps booting.
97
+ * Agent CLIs that MUST exist inside the shared asdf volume. The volume mounts
98
+ * over /opt/asdf-data and shadows the image's baked-in shims, so a volume
99
+ * seeded before a CLI was added (or before a version bump) can be missing it —
100
+ * e.g. a stale volume with `codex` but no `claude`, which makes `claude` exit
101
+ * 127 in every task container. Reconciled on each host start.
102
+ */
103
+ const VOLUME_AGENT_CLIS: { bin: string; pkg: string }[] = [
104
+ { bin: "claude", pkg: "@anthropic-ai/claude-code" },
105
+ { bin: "codex", pkg: "@openai/codex" },
106
+ ];
107
+
108
+ /**
109
+ * Self-heal the shared asdf volume: ensure each agent CLI resolves inside it,
110
+ * installing + reshimming any that are missing. Cheap when healthy (one
111
+ * container that just lists absent bins); only pays the npm cost on repair.
112
+ * A fresh/empty volume is seeded from the image by Docker on first mount, so
113
+ * this is a no-op there — it only fixes pre-existing stale volumes.
114
+ */
115
+ async function ensureVolumeAgentClis(): Promise<void> {
116
+ const bins = VOLUME_AGENT_CLIS.map((c) => c.bin).join(" ");
117
+ // Sentinel-prefixed output so login-shell init noise can't be misread as a
118
+ // missing bin.
119
+ const check = await run("docker", [
120
+ "run",
121
+ "--rm",
122
+ "-v",
123
+ `${ASDF_DATA_VOLUME}:/opt/asdf-data`,
124
+ STANDARD_IMAGE_TAG,
125
+ "bash",
126
+ "-lc",
127
+ `for b in ${bins}; do command -v "$b" >/dev/null 2>&1 || echo "UAI_MISSING:$b"; done`,
128
+ ]);
129
+ if (check.code !== 0) {
130
+ console.warn(
131
+ `[host-agent] could not check agent CLIs in ${ASDF_DATA_VOLUME} (exit ` +
132
+ `${check.code ?? "spawn"}); continuing. ${check.stderr.trim()}`,
133
+ );
134
+ return;
135
+ }
136
+ const missing = check.stdout
137
+ .split("\n")
138
+ .map((l) => l.trim())
139
+ .filter((l) => l.startsWith("UAI_MISSING:"))
140
+ .map((l) => l.slice("UAI_MISSING:".length));
141
+ if (missing.length === 0) return;
142
+
143
+ const pkgs = VOLUME_AGENT_CLIS.filter((c) => missing.includes(c.bin))
144
+ .map((c) => c.pkg)
145
+ .join(" ");
146
+ console.log(
147
+ `[host-agent] repairing ${ASDF_DATA_VOLUME}: agent CLI(s) [${missing.join(", ")}] ` +
148
+ `missing from the shared volume — installing ${pkgs}`,
149
+ );
150
+ const repair = await run("docker", [
151
+ "run",
152
+ "--rm",
153
+ "-u",
154
+ "root",
155
+ // Run from /home/node so asdf resolves the node version from
156
+ // /home/node/.tool-versions. As root HOME is /root (no .tool-versions),
157
+ // and without a version asdf's npm shim aborts ("No version is set for
158
+ // command npm", exit 126). root can still write the volume from here.
159
+ "-w",
160
+ "/home/node",
161
+ "-v",
162
+ `${ASDF_DATA_VOLUME}:/opt/asdf-data`,
163
+ STANDARD_IMAGE_TAG,
164
+ "bash",
165
+ "-lc",
166
+ `npm install -g ${pkgs} && asdf reshim nodejs`,
167
+ ]);
168
+ if (repair.code === 0) {
169
+ console.log(
170
+ `[host-agent] ${ASDF_DATA_VOLUME} repaired — installed [${missing.join(", ")}]`,
171
+ );
172
+ } else {
173
+ console.warn(
174
+ `[host-agent] failed to repair ${ASDF_DATA_VOLUME} (exit ${repair.code ?? "spawn"}); ` +
175
+ `tasks may be missing [${missing.join(", ")}].\n${repair.stderr.trim()}`,
176
+ );
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Ensure the standard image and the shared asdf data volume exist, and that the
182
+ * agent CLIs are present inside the volume. Builds the image only when `docker
183
+ * image inspect` fails. Best-effort: logs and continues on any error so the
184
+ * host keeps booting.
100
185
  */
101
186
  export async function ensureStandardImage(): Promise<void> {
102
187
  // 1. Shared asdf data volume — idempotent.
@@ -111,42 +196,37 @@ export async function ensureStandardImage(): Promise<void> {
111
196
  if (vol.code === null) return;
112
197
  }
113
198
 
114
- // 2. Is the image already built?
115
- const inspect = await run("docker", [
116
- "image",
117
- "inspect",
118
- STANDARD_IMAGE_TAG,
119
- ]);
199
+ // 2. Ensure the image is built.
200
+ let imageReady = false;
201
+ const inspect = await run("docker", ["image", "inspect", STANDARD_IMAGE_TAG]);
120
202
  if (inspect.code === 0) {
121
203
  console.log(`[host-agent] standard image ${STANDARD_IMAGE_TAG} present`);
122
- return;
123
- }
124
- if (inspect.code === null) {
204
+ imageReady = true;
205
+ } else if (inspect.code === null) {
125
206
  console.warn(
126
207
  "[host-agent] docker unavailable; skipping standard image build. " +
127
208
  "Tasks will fail until docker is running.",
128
209
  );
129
210
  return;
211
+ } else {
212
+ const context = standardImageDir();
213
+ console.log(
214
+ `[host-agent] building standard image ${STANDARD_IMAGE_TAG} from ${context}`,
215
+ );
216
+ const build = await run("docker", ["build", "-t", STANDARD_IMAGE_TAG, context]);
217
+ if (build.code === 0) {
218
+ console.log(`[host-agent] built standard image ${STANDARD_IMAGE_TAG}`);
219
+ imageReady = true;
220
+ } else {
221
+ console.warn(
222
+ `[host-agent] standard image build failed (exit ${build.code ?? "spawn"}); ` +
223
+ "continuing. Tasks needing the image will surface this error.\n" +
224
+ build.stderr.trim(),
225
+ );
226
+ }
130
227
  }
131
228
 
132
- // 3. Build it.
133
- const context = standardImageDir();
134
- console.log(
135
- `[host-agent] building standard image ${STANDARD_IMAGE_TAG} from ${context}`,
136
- );
137
- const build = await run("docker", [
138
- "build",
139
- "-t",
140
- STANDARD_IMAGE_TAG,
141
- context,
142
- ]);
143
- if (build.code === 0) {
144
- console.log(`[host-agent] built standard image ${STANDARD_IMAGE_TAG}`);
145
- return;
146
- }
147
- console.warn(
148
- `[host-agent] standard image build failed (exit ${build.code ?? "spawn"}); ` +
149
- "continuing. Tasks needing the image will surface this error.\n" +
150
- build.stderr.trim(),
151
- );
229
+ // 3. Reconcile the agent CLIs into the shared volume (it shadows the image's
230
+ // shims, so a stale volume can be missing one — the claude-127 bug).
231
+ if (imageReady) await ensureVolumeAgentClis();
152
232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
@@ -173,11 +173,41 @@ while IFS= read -r project_obj; do
173
173
  | sed 's#^origin/##' || true)
174
174
  [ -n "$project_default" ] || project_default="main"
175
175
 
176
- # Create the worktree on a fresh task branch off origin/<defaultBranch>.
177
- step "WORKTREE_FAILED" "git worktree add ($project_id)"
178
176
  mkdir -p "$(dirname "$worktree_target")"
179
- git -C "$mirror_dir" worktree add "$worktree_target" \
180
- -b "$task_branch" "origin/$project_default"
177
+ if git -C "$mirror_dir" rev-parse --verify --quiet \
178
+ "refs/remotes/origin/$project_default" >/dev/null 2>&1; then
179
+ # Normal case: branch the task worktree off origin/<defaultBranch>.
180
+ step "WORKTREE_FAILED" "git worktree add ($project_id)"
181
+ git -C "$mirror_dir" worktree add "$worktree_target" \
182
+ -b "$task_branch" "origin/$project_default"
183
+ else
184
+ # Empty / branchless remote — there's no origin/<default> to branch from.
185
+ # Don't fail the whole task over it (the old behaviour: "invalid reference"
186
+ # -> WORKTREE_FAILED -> respawn loop). Start an empty, git-initialised
187
+ # workspace so the task still comes up and a first commit can populate the
188
+ # repo. Leave a note (git-excluded so it isn't accidentally committed).
189
+ step "WORKTREE_FAILED" "init empty workspace ($project_id)"
190
+ log "warning: $repo_url has no '$project_default' branch (empty repo?); starting an empty workspace for $project_slug"
191
+ rm -rf "$worktree_target"
192
+ mkdir -p "$worktree_target"
193
+ git -C "$worktree_target" init -q -b "$project_default" 2>/dev/null \
194
+ || { git -C "$worktree_target" init -q \
195
+ && git -C "$worktree_target" symbolic-ref HEAD "refs/heads/$project_default"; }
196
+ git -C "$worktree_target" remote add origin "$repo_url" 2>/dev/null || true
197
+ printf '%s\n' \
198
+ "# Empty repository" \
199
+ "" \
200
+ "\`$repo_url\` has no commits yet, so Uai started an empty workspace on" \
201
+ "branch \`$project_default\`. Make your first commit and run:" \
202
+ "" \
203
+ " git push -u origin $project_default" \
204
+ "" \
205
+ "to initialise the repository." \
206
+ > "$worktree_target/UAI_EMPTY_REPO.md"
207
+ mkdir -p "$worktree_target/.git/info" 2>/dev/null || true
208
+ printf '%s\n' "/UAI_EMPTY_REPO.md" \
209
+ >> "$worktree_target/.git/info/exclude" 2>/dev/null || true
210
+ fi
181
211
 
182
212
  # Tool-version seeding: only when the worktree has no checked-in
183
213
  # .tool-versions AND the project declares one. Never overwrite a file
@@ -302,6 +332,12 @@ fi
302
332
  # per-user SSH identity, not an HTTPS token (uai-init routes remotes over SSH).
303
333
  # A project may declare GH_TOKEN/GITHUB_TOKEN as an env key, so skip them here
304
334
  # rather than trust callers — this is where the rule is enforced.
335
+ #
336
+ # The decrypted per-(project, key) env VALUES live in this script's process
337
+ # env (injected by lib/agent.ts from the host store). The `${KEY:-}`
338
+ # pass-throughs below resolve to them via docker's interpolation at up-time —
339
+ # the value is never written into the YAML (mirrors CLAUDE_CODE_OAUTH_TOKEN),
340
+ # so this stays secret-blind end to end (ADR-015).
305
341
  while IFS= read -r ekey; do
306
342
  [ -n "$ekey" ] || continue
307
343
  case "$ekey" in
package/src/main.ts CHANGED
@@ -34,6 +34,11 @@ import {
34
34
  ensureKeyForUser as ensureSshKeyForUser,
35
35
  getPublicKey as getSshPublicKey,
36
36
  } from "../lib/ssh";
37
+ import {
38
+ deleteProjectEnvVar,
39
+ listProjectEnvKeys,
40
+ setProjectEnvVar,
41
+ } from "../lib/host-env";
37
42
  import {
38
43
  packageVersion,
39
44
  serviceLogPath,
@@ -306,6 +311,33 @@ function connect(): void {
306
311
  }
307
312
  break;
308
313
  }
314
+ case "env.var.list":
315
+ case "env.var.set":
316
+ case "env.var.delete": {
317
+ try {
318
+ if (frame.kind === "env.var.set") {
319
+ setProjectEnvVar(frame.projectId, frame.key, frame.value);
320
+ } else if (frame.kind === "env.var.delete") {
321
+ deleteProjectEnvVar(frame.projectId, frame.key);
322
+ }
323
+ // The ack carries the project's KEY NAMES only — values never cross
324
+ // the bridge.
325
+ send(socket, {
326
+ kind: "env.var.ack",
327
+ opId: frame.opId,
328
+ ok: true,
329
+ keys: listProjectEnvKeys(frame.projectId),
330
+ });
331
+ } catch (err) {
332
+ send(socket, {
333
+ kind: "env.var.ack",
334
+ opId: frame.opId,
335
+ ok: false,
336
+ error: err instanceof Error ? err.message : "env var op failed",
337
+ });
338
+ }
339
+ break;
340
+ }
309
341
  }
310
342
  });
311
343
 
@@ -824,6 +856,41 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
824
856
  ) {
825
857
  return { kind: frame.kind, userId: frame.userId };
826
858
  }
859
+ if (
860
+ frame.kind === "env.var.list" &&
861
+ typeof frame.opId === "string" &&
862
+ typeof frame.projectId === "string"
863
+ ) {
864
+ return { kind: "env.var.list", opId: frame.opId, projectId: frame.projectId };
865
+ }
866
+ if (
867
+ frame.kind === "env.var.set" &&
868
+ typeof frame.opId === "string" &&
869
+ typeof frame.projectId === "string" &&
870
+ typeof frame.key === "string" &&
871
+ typeof frame.value === "string"
872
+ ) {
873
+ return {
874
+ kind: "env.var.set",
875
+ opId: frame.opId,
876
+ projectId: frame.projectId,
877
+ key: frame.key,
878
+ value: frame.value,
879
+ };
880
+ }
881
+ if (
882
+ frame.kind === "env.var.delete" &&
883
+ typeof frame.opId === "string" &&
884
+ typeof frame.projectId === "string" &&
885
+ typeof frame.key === "string"
886
+ ) {
887
+ return {
888
+ kind: "env.var.delete",
889
+ opId: frame.opId,
890
+ projectId: frame.projectId,
891
+ key: frame.key,
892
+ };
893
+ }
827
894
  console.warn("[host-agent] dropping unknown frame");
828
895
  return null;
829
896
  }
package/src/protocol.ts CHANGED
@@ -323,7 +323,21 @@ export type CloudToHost =
323
323
  // without creating, `ensure` creates-if-absent, `delete` removes.
324
324
  | { kind: "ssh.key.get"; userId: string }
325
325
  | { kind: "ssh.key.ensure"; userId: string }
326
- | { kind: "ssh.key.delete"; userId: string };
326
+ | { kind: "ssh.key.delete"; userId: string }
327
+ // Per-(project, key) env var lifecycle. Values are encrypted + stored on the
328
+ // host scoped to a projectId; only KEY NAMES ever cross the bridge
329
+ // (env.var.ack) — the value on `env.var.set` is write-only (never echoed
330
+ // back). `opId` correlates request↔ack; the ack's `keys` are that project's
331
+ // keys.
332
+ | { kind: "env.var.list"; opId: string; projectId: string }
333
+ | {
334
+ kind: "env.var.set";
335
+ opId: string;
336
+ projectId: string;
337
+ key: string;
338
+ value: string;
339
+ }
340
+ | { kind: "env.var.delete"; opId: string; projectId: string; key: string };
327
341
 
328
342
  export type HostToCloud =
329
343
  | { kind: "auth"; token: string; hostId: string }
@@ -352,7 +366,12 @@ export type HostToCloud =
352
366
  // Ack for ssh.key.get / ssh.key.ensure / ssh.key.delete (ADR-029). publicKey
353
367
  // is null when the user has no key (after delete, or a get-miss).
354
368
  | { kind: "ssh.key.ack"; userId: string; ok: true; publicKey: string | null }
355
- | { kind: "ssh.key.ack"; userId: string; ok: false; error: string };
369
+ | { kind: "ssh.key.ack"; userId: string; ok: false; error: string }
370
+ // Ack for env.var.list / env.var.set / env.var.delete. Returns the project's
371
+ // KEY NAMES only — values never cross the bridge (secret-blind, ADR-015).
372
+ // `opId` correlates to the request.
373
+ | { kind: "env.var.ack"; opId: string; ok: true; keys: string[] }
374
+ | { kind: "env.var.ack"; opId: string; ok: false; error: string };
356
375
 
357
376
  export type HostEvent =
358
377
  | {