@runuai/host 0.1.0 → 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.
- package/db/migrations/0005_host_env_vars.sql +5 -0
- package/db/migrations/0006_host_project_env.sql +9 -0
- package/db/migrations/meta/_journal.json +14 -0
- package/db/schema.ts +32 -1
- package/lib/agent.ts +18 -1
- package/lib/host-env.ts +106 -0
- package/lib/standard-image.ts +112 -32
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +40 -4
- package/src/cli.ts +28 -4
- package/src/main.ts +67 -0
- package/src/protocol.ts +21 -2
|
@@ -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 {
|
|
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
|
-
|
|
269
|
+
extraEnv,
|
|
253
270
|
);
|
|
254
271
|
} finally {
|
|
255
272
|
removeCommandDb(commandDbPath);
|
package/lib/host-env.ts
ADDED
|
@@ -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
|
+
}
|
package/lib/standard-image.ts
CHANGED
|
@@ -94,9 +94,94 @@ function run(command: string, args: string[]): Promise<RunResult> {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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.
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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.
|
|
133
|
-
|
|
134
|
-
|
|
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
package/scripts/agent/task-up.sh
CHANGED
|
@@ -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"
|
|
180
|
-
|
|
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/cli.ts
CHANGED
|
@@ -21,8 +21,9 @@ import "./load-env";
|
|
|
21
21
|
|
|
22
22
|
import { spawn, spawnSync } from "node:child_process";
|
|
23
23
|
import { createHash, randomBytes } from "node:crypto";
|
|
24
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
25
25
|
import { hostname } from "node:os";
|
|
26
|
+
import { dirname } from "node:path";
|
|
26
27
|
|
|
27
28
|
import { ulid } from "ulid";
|
|
28
29
|
|
|
@@ -298,9 +299,28 @@ async function cmdSetup(rest: string[]): Promise<void> {
|
|
|
298
299
|
return;
|
|
299
300
|
}
|
|
300
301
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
302
|
+
// The host is already created on the cloud and the token is spent. If the
|
|
303
|
+
// local write fails, don't lose the secret — print it so it can be saved by
|
|
304
|
+
// hand rather than forcing the user to mint a fresh token.
|
|
305
|
+
try {
|
|
306
|
+
upsertEnvLocal("UAI_HOST_ID", hostId);
|
|
307
|
+
upsertEnvLocal("UAI_HOST_TOKEN", secret);
|
|
308
|
+
upsertEnvLocal("UAI_CLOUD_URL", cloud);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
console.error(
|
|
311
|
+
red(
|
|
312
|
+
`enrolled, but could not write ${envLocalPath()}: ${err instanceof Error ? err.message : String(err)}`,
|
|
313
|
+
),
|
|
314
|
+
);
|
|
315
|
+
console.error(
|
|
316
|
+
dim("the host exists on the cloud — save these into that file manually, then `uai-host start`:"),
|
|
317
|
+
);
|
|
318
|
+
console.error(` UAI_HOST_ID=${hostId}`);
|
|
319
|
+
console.error(` UAI_HOST_TOKEN=${secret}`);
|
|
320
|
+
console.error(` UAI_CLOUD_URL=${cloud}`);
|
|
321
|
+
process.exitCode = 1;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
304
324
|
console.log(green("✓ enrolled") + dim(` — config written to ${envLocalPath()}`));
|
|
305
325
|
console.log("");
|
|
306
326
|
console.log("Start the host:");
|
|
@@ -326,6 +346,10 @@ async function cmdPair(token: string | undefined): Promise<void> {
|
|
|
326
346
|
|
|
327
347
|
function upsertEnvLocal(key: string, value: string): void {
|
|
328
348
|
const path = envLocalPath();
|
|
349
|
+
// UAI_HOME (~/.uai on a fresh install) may not exist yet — create it before
|
|
350
|
+
// the first write, or writeFileSync ENOENTs (which on `setup` would orphan a
|
|
351
|
+
// just-redeemed host + burn the one-time token).
|
|
352
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
329
353
|
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
330
354
|
const kept = existing
|
|
331
355
|
.split("\n")
|
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
|
| {
|