@runuai/host 0.1.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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/bin/uai-host.mjs +14 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/meta/_journal.json +41 -0
  10. package/db/schema.ts +82 -0
  11. package/images/standard/Dockerfile +232 -0
  12. package/images/standard/README.md +122 -0
  13. package/images/standard/container/code-server-settings.json +36 -0
  14. package/images/standard/container/uai-init +215 -0
  15. package/images/standard/tool-versions +2 -0
  16. package/lib/agent.ts +292 -0
  17. package/lib/agents/claude.ts +343 -0
  18. package/lib/agents/codex.ts +522 -0
  19. package/lib/agents/factory.ts +34 -0
  20. package/lib/agents/mock.ts +133 -0
  21. package/lib/agents/proc.ts +172 -0
  22. package/lib/agents/registry.ts +109 -0
  23. package/lib/agents/types.ts +133 -0
  24. package/lib/attachments.ts +46 -0
  25. package/lib/cloud-state.ts +56 -0
  26. package/lib/command-db.ts +278 -0
  27. package/lib/db.ts +68 -0
  28. package/lib/env.ts +140 -0
  29. package/lib/git-diff.ts +370 -0
  30. package/lib/git-identity.ts +65 -0
  31. package/lib/github-tokens.ts +321 -0
  32. package/lib/orchestrator.ts +975 -0
  33. package/lib/preview-ports.ts +85 -0
  34. package/lib/repo-clone.ts +127 -0
  35. package/lib/runtime-state.ts +120 -0
  36. package/lib/secrets.ts +71 -0
  37. package/lib/ssh.ts +186 -0
  38. package/lib/standard-image.ts +152 -0
  39. package/lib/task-diff.ts +113 -0
  40. package/lib/task-status.ts +46 -0
  41. package/lib/transcript.ts +30 -0
  42. package/lib/ulid.ts +7 -0
  43. package/package.json +85 -0
  44. package/scripts/agent/_common.sh +248 -0
  45. package/scripts/agent/task-down.sh +113 -0
  46. package/scripts/agent/task-status.sh +54 -0
  47. package/scripts/agent/task-up.sh +457 -0
  48. package/scripts/install/darwin.ts +167 -0
  49. package/scripts/install/linux.ts +115 -0
  50. package/scripts/install/types.ts +35 -0
  51. package/scripts/install/util.ts +39 -0
  52. package/scripts/install/win.ts +130 -0
  53. package/src/cli.ts +445 -0
  54. package/src/index.ts +375 -0
  55. package/src/load-env.ts +52 -0
  56. package/src/main.ts +1156 -0
  57. package/src/paths.ts +64 -0
  58. package/src/protocol.ts +413 -0
  59. package/src/ui/server.ts +343 -0
  60. package/src/ui/types.ts +78 -0
  61. package/ui/app.js +264 -0
  62. package/ui/index.html +55 -0
  63. package/ui/style.css +359 -0
  64. package/ui/uai-logo-black.svg +9 -0
@@ -0,0 +1,85 @@
1
+ import { z } from "zod";
2
+
3
+ export const PREVIEW_PORT_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
4
+
5
+ export const PreviewPortDeclarationSchema = z.object({
6
+ name: z
7
+ .string()
8
+ .trim()
9
+ .min(1)
10
+ .max(63)
11
+ .regex(
12
+ PREVIEW_PORT_NAME_RE,
13
+ "name must be lowercase letters, digits, _ or -, starting with a letter or digit",
14
+ ),
15
+ containerPort: z.coerce.number().int().min(1).max(65535),
16
+ });
17
+
18
+ export const PreviewPortRuntimeSchema = z.object({
19
+ name: z.string().trim().min(1).max(63).regex(PREVIEW_PORT_NAME_RE),
20
+ hostPort: z.coerce.number().int().min(1).max(65535),
21
+ });
22
+
23
+ export const PreviewPortDeclarationsSchema = z
24
+ .array(PreviewPortDeclarationSchema)
25
+ .max(20)
26
+ .superRefine((ports, ctx) => {
27
+ const seen = new Set<string>();
28
+ for (const [index, port] of ports.entries()) {
29
+ if (seen.has(port.name)) {
30
+ ctx.addIssue({
31
+ code: z.ZodIssueCode.custom,
32
+ path: [index, "name"],
33
+ message: `duplicate preview port name: ${port.name}`,
34
+ });
35
+ }
36
+ seen.add(port.name);
37
+ }
38
+ });
39
+
40
+ export const PreviewPortRuntimesSchema = z
41
+ .array(PreviewPortRuntimeSchema)
42
+ .max(20)
43
+ .superRefine((ports, ctx) => {
44
+ const seen = new Set<string>();
45
+ for (const [index, port] of ports.entries()) {
46
+ if (seen.has(port.name)) {
47
+ ctx.addIssue({
48
+ code: z.ZodIssueCode.custom,
49
+ path: [index, "name"],
50
+ message: `duplicate preview port name: ${port.name}`,
51
+ });
52
+ }
53
+ seen.add(port.name);
54
+ }
55
+ });
56
+
57
+ export type PreviewPortDeclaration = z.infer<
58
+ typeof PreviewPortDeclarationSchema
59
+ >;
60
+ export type PreviewPortRuntime = z.infer<typeof PreviewPortRuntimeSchema>;
61
+
62
+ export function parsePreviewPortDeclarations(
63
+ raw: string | null | undefined,
64
+ ): PreviewPortDeclaration[] {
65
+ return parseJsonList(raw, PreviewPortDeclarationsSchema);
66
+ }
67
+
68
+ export function parsePreviewPortRuntimes(
69
+ raw: string | null | undefined,
70
+ ): PreviewPortRuntime[] {
71
+ return parseJsonList(raw, PreviewPortRuntimesSchema);
72
+ }
73
+
74
+ function parseJsonList<T extends z.ZodTypeAny>(
75
+ raw: string | null | undefined,
76
+ schema: T,
77
+ ): z.infer<T> {
78
+ if (!raw) return [];
79
+ try {
80
+ const parsed = schema.safeParse(JSON.parse(raw));
81
+ return parsed.success ? parsed.data : [];
82
+ } catch {
83
+ return [];
84
+ }
85
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Bare-mirror clone helper.
3
+ *
4
+ * Used by project creation. A project is a single repo (ADR-022), bare-
5
+ * mirrored into `<projectDir>/repo.git/` — one mirror per project, no repoId.
6
+ * Per-task worktrees pick it up from there (see scripts/agent/task-up.sh).
7
+ *
8
+ * Uses the same init + remote-add + refspec + fetch pattern as `task-up`
9
+ * instead of `git clone --bare`. Plain `git clone --bare` copies upstream
10
+ * heads straight into `refs/heads/*` and never populates
11
+ * `refs/remotes/origin/*`, so a later `git worktree add ...
12
+ * origin/<defaultBranch>` would fail with "invalid reference". `task-up`
13
+ * self-heals an existing mirror's refspec on subsequent runs, but only
14
+ * if the first task-up runs online — initialising the mirror correctly
15
+ * here closes the offline-first-task gap.
16
+ *
17
+ * Sync in v0.1 — the spec doesn't pin sync-vs-async, and async pipelines
18
+ * add a state machine we don't need until the UI shows progress.
19
+ *
20
+ * Idempotent: if the bare mirror already exists, (re-)assert the refspec
21
+ * and treat the repo as ready without re-fetching. Healing an existing
22
+ * mirror's refspec is cheap and protects against a mirror created before
23
+ * this helper learned the right refspec.
24
+ */
25
+
26
+ import { spawnSync } from "node:child_process";
27
+ import { existsSync, rmSync, statSync } from "node:fs";
28
+ import { mkdirSync } from "node:fs";
29
+ import { dirname } from "node:path";
30
+
31
+ import { projectRepoMirror } from "./env";
32
+
33
+ export interface CloneRepoInput {
34
+ url: string;
35
+ projectId: string;
36
+ }
37
+
38
+ export interface CloneRepoResult {
39
+ status: "ready" | "error";
40
+ error?: string;
41
+ /** Absolute path to the bare mirror directory. */
42
+ absolutePath: string;
43
+ }
44
+
45
+ const REMOTE_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
46
+
47
+ export function cloneRepo(input: CloneRepoInput): CloneRepoResult {
48
+ const target = projectRepoMirror(input.projectId);
49
+
50
+ if (existsSync(target) && statSync(target).isDirectory()) {
51
+ // Heal an existing mirror's refspec — no-op when already correct.
52
+ const setRefspec = git(target, [
53
+ "config",
54
+ "remote.origin.fetch",
55
+ REMOTE_REFSPEC,
56
+ ]);
57
+ if (setRefspec.status !== 0) {
58
+ return errorResult(target, setRefspec, "git config remote.origin.fetch");
59
+ }
60
+ return { status: "ready", absolutePath: target };
61
+ }
62
+
63
+ mkdirSync(dirname(target), { recursive: true });
64
+
65
+ const init = spawnSync("git", ["init", "--bare", target], { encoding: "utf8" });
66
+ if (init.status !== 0) {
67
+ return errorResult(target, init, "git init --bare");
68
+ }
69
+
70
+ const remote = git(target, ["remote", "add", "origin", input.url]);
71
+ if (remote.status !== 0) {
72
+ cleanup(target);
73
+ return errorResult(target, remote, "git remote add origin");
74
+ }
75
+
76
+ const refspec = git(target, [
77
+ "config",
78
+ "remote.origin.fetch",
79
+ REMOTE_REFSPEC,
80
+ ]);
81
+ if (refspec.status !== 0) {
82
+ cleanup(target);
83
+ return errorResult(target, refspec, "git config remote.origin.fetch");
84
+ }
85
+
86
+ const fetch = git(target, ["fetch", "origin"]);
87
+ if (fetch.status !== 0) {
88
+ // A failed initial fetch leaves a half-built mirror that task-up
89
+ // would later mistake for a healthy one. Tear it down so the next
90
+ // retry starts clean.
91
+ cleanup(target);
92
+ return errorResult(target, fetch, "git fetch origin");
93
+ }
94
+
95
+ return { status: "ready", absolutePath: target };
96
+ }
97
+
98
+ function git(
99
+ cwd: string,
100
+ args: string[],
101
+ ): ReturnType<typeof spawnSync> {
102
+ return spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
103
+ }
104
+
105
+ function cleanup(target: string): void {
106
+ try {
107
+ rmSync(target, { recursive: true, force: true });
108
+ } catch {
109
+ // Best-effort: the caller will surface the original error.
110
+ }
111
+ }
112
+
113
+ function errorResult(
114
+ target: string,
115
+ result: ReturnType<typeof spawnSync>,
116
+ step: string,
117
+ ): CloneRepoResult {
118
+ return {
119
+ status: "error",
120
+ absolutePath: target,
121
+ error: (
122
+ `${step}: ${result.stderr || result.stdout || "failed"}`
123
+ )
124
+ .trim()
125
+ .slice(0, 1024),
126
+ };
127
+ }
@@ -0,0 +1,120 @@
1
+ import { eq } from "drizzle-orm";
2
+
3
+ import { getDb, schema } from "./db";
4
+ import { newId } from "./ulid";
5
+ import type { HostTask } from "../db/schema";
6
+ import type { TaskUpResult } from "../src/protocol";
7
+
8
+ /** Lifecycle event kinds recorded for the local UI's `/api/events` (ADR-028). */
9
+ export type HostEventKind =
10
+ | "task.created"
11
+ | "task.started"
12
+ | "task.ended"
13
+ | "task.ship";
14
+
15
+ export function getHostTask(taskId: string): HostTask | null {
16
+ return (
17
+ getDb()
18
+ .select()
19
+ .from(schema.hostTasks)
20
+ .where(eq(schema.hostTasks.taskId, taskId))
21
+ .get() ?? null
22
+ );
23
+ }
24
+
25
+ export function upsertHostTask(
26
+ taskId: string,
27
+ values: Partial<Omit<HostTask, "taskId" | "updatedAt">>,
28
+ ): void {
29
+ const now = Date.now();
30
+ getDb()
31
+ .insert(schema.hostTasks)
32
+ .values({
33
+ taskId,
34
+ previewPorts: "[]",
35
+ ...values,
36
+ updatedAt: now,
37
+ })
38
+ .onConflictDoUpdate({
39
+ target: schema.hostTasks.taskId,
40
+ set: {
41
+ ...values,
42
+ updatedAt: now,
43
+ },
44
+ })
45
+ .run();
46
+ }
47
+
48
+ export function recordTaskUpResult(taskId: string, result: TaskUpResult): void {
49
+ upsertHostTask(taskId, {
50
+ codeServerPort: result.codeServerPort ?? null,
51
+ previewPorts: JSON.stringify(result.previewPorts ?? []),
52
+ composeProject: result.composeProject,
53
+ worktreePath: result.worktreePath,
54
+ statusMirror: "running",
55
+ lockedAt: null,
56
+ startedAt: Date.now(),
57
+ endedAt: null,
58
+ });
59
+ }
60
+
61
+ export function recordTaskStarting(taskId: string): void {
62
+ upsertHostTask(taskId, {
63
+ statusMirror: "starting",
64
+ lockedAt: Date.now(),
65
+ startedAt: Date.now(),
66
+ endedAt: null,
67
+ });
68
+ }
69
+
70
+ export function recordTaskDown(taskId: string, status: string | undefined): void {
71
+ upsertHostTask(taskId, {
72
+ codeServerPort: null,
73
+ previewPorts: "[]",
74
+ composeProject: null,
75
+ worktreePath: null,
76
+ statusMirror: status ?? "killed",
77
+ lockedAt: null,
78
+ endedAt: Date.now(),
79
+ });
80
+ }
81
+
82
+ export function recordTaskError(taskId: string): void {
83
+ upsertHostTask(taskId, {
84
+ statusMirror: "error",
85
+ lockedAt: null,
86
+ endedAt: Date.now(),
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Persist the owner + project slugs the host received in TaskLaunchInput, so
92
+ * the local UI can show "who/what" for active tasks (ADR-028). The cloud
93
+ * remains the audit authority; this is a local read-convenience mirror.
94
+ */
95
+ export function recordTaskOwner(
96
+ taskId: string,
97
+ ownerUserId: string,
98
+ ownerEmail: string | null,
99
+ ownerName: string | null,
100
+ projectSlugs: string[],
101
+ ): void {
102
+ upsertHostTask(taskId, {
103
+ ownerUserId,
104
+ ownerEmail,
105
+ ownerName,
106
+ projectSlugs: JSON.stringify(projectSlugs),
107
+ });
108
+ }
109
+
110
+ /** Append a task lifecycle event to the host event log (local `/api/events`). */
111
+ export function recordHostEvent(
112
+ taskId: string,
113
+ kind: HostEventKind,
114
+ detail?: string | null,
115
+ ): void {
116
+ getDb()
117
+ .insert(schema.hostEvents)
118
+ .values({ id: newId(), taskId, kind, detail: detail ?? null })
119
+ .run();
120
+ }
package/lib/secrets.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Host secret store crypto (ADR-027) — AES-256-GCM under the host master key.
3
+ * First user of host-side encryption (the ADR-022 project-env store isn't
4
+ * built yet); these helpers anchor it.
5
+ *
6
+ * The 16-byte GCM auth tag is appended to the ciphertext; `nonce` is the random
7
+ * 12-byte IV. Master key: 32 bytes from `env.hostKeyPath` (UAI_HOST_KEY_FILE or
8
+ * $UAI_DATA_DIR/host-master.key), generated 0600 on first use.
9
+ */
10
+
11
+ import {
12
+ createCipheriv,
13
+ createDecipheriv,
14
+ randomBytes,
15
+ } from "node:crypto";
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { dirname } from "node:path";
18
+
19
+ import { env } from "./env";
20
+
21
+ const KEY_BYTES = 32;
22
+ const NONCE_BYTES = 12;
23
+ const TAG_BYTES = 16;
24
+
25
+ let cachedKey: Buffer | null = null;
26
+
27
+ /** Load (or generate, 0600) the 32-byte host master key. */
28
+ export function masterKey(): Buffer {
29
+ if (cachedKey) return cachedKey;
30
+ const path = env.hostKeyPath;
31
+ if (existsSync(path)) {
32
+ const key = readFileSync(path);
33
+ if (key.length !== KEY_BYTES) {
34
+ throw new Error(
35
+ `host master key must be ${KEY_BYTES} bytes; ${path} has ${key.length}`,
36
+ );
37
+ }
38
+ cachedKey = key;
39
+ return key;
40
+ }
41
+ const key = randomBytes(KEY_BYTES);
42
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
43
+ writeFileSync(path, key, { mode: 0o600 });
44
+ cachedKey = key;
45
+ return key;
46
+ }
47
+
48
+ export interface Sealed {
49
+ ct: Buffer; // ciphertext + 16-byte GCM tag
50
+ nonce: Buffer; // 12-byte IV
51
+ }
52
+
53
+ export function sealAesGcm(plaintext: string, key: Buffer = masterKey()): Sealed {
54
+ const nonce = randomBytes(NONCE_BYTES);
55
+ const cipher = createCipheriv("aes-256-gcm", key, nonce);
56
+ const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
57
+ return { ct: Buffer.concat([enc, cipher.getAuthTag()]), nonce };
58
+ }
59
+
60
+ export function openAesGcm(
61
+ ct: Buffer,
62
+ nonce: Buffer,
63
+ key: Buffer = masterKey(),
64
+ ): string {
65
+ if (ct.length < TAG_BYTES) throw new Error("ciphertext too short");
66
+ const enc = ct.subarray(0, ct.length - TAG_BYTES);
67
+ const tag = ct.subarray(ct.length - TAG_BYTES);
68
+ const decipher = createDecipheriv("aes-256-gcm", key, nonce);
69
+ decipher.setAuthTag(tag);
70
+ return Buffer.concat([decipher.update(enc), decipher.final()]).toString("utf8");
71
+ }
package/lib/ssh.ts ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Host-side per-user SSH key manager (ADR-029).
3
+ *
4
+ * Each user gets one ed25519 keypair, GENERATED ON THIS HOST. The private key
5
+ * lives encrypted at rest with the host master key (`host_ssh_keys` —
6
+ * ADR-015 secret-blindness, same shape as host_github_tokens). The cloud only
7
+ * ever sees the PUBLIC key, which the user pastes into GitHub themselves; there
8
+ * is no upload path. At task-up the private key is written into the task
9
+ * container so git-over-SSH works as the task creator.
10
+ */
11
+
12
+ import { spawnSync } from "node:child_process";
13
+ import {
14
+ mkdirSync,
15
+ mkdtempSync,
16
+ readFileSync,
17
+ rmSync,
18
+ writeFileSync,
19
+ } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join, resolve } from "node:path";
22
+
23
+ import { eq } from "drizzle-orm";
24
+
25
+ import { getDb, schema } from "./db";
26
+ import { env } from "./env";
27
+ import { sealAesGcm, openAesGcm } from "./secrets";
28
+
29
+ const withNewline = (s: string): string => (s.endsWith("\n") ? s : `${s}\n`);
30
+
31
+ const EXEC_TIMEOUT_MS = 10_000;
32
+
33
+ export interface GeneratedKey {
34
+ publicKey: string; // "ssh-ed25519 AAAA… <comment>"
35
+ privateKey: string; // OpenSSH private key PEM
36
+ }
37
+
38
+ /**
39
+ * Generate an ed25519 keypair via `ssh-keygen` (canonical OpenSSH formats that
40
+ * git + GitHub accept directly). Runs in a throwaway temp dir; nothing touches
41
+ * the operator's own ~/.ssh.
42
+ */
43
+ export function generateEd25519(comment: string): GeneratedKey {
44
+ const dir = mkdtempSync(join(tmpdir(), "uai-ssh-"));
45
+ try {
46
+ const keyPath = join(dir, "id_ed25519");
47
+ const res = spawnSync(
48
+ "ssh-keygen",
49
+ ["-t", "ed25519", "-N", "", "-C", comment, "-f", keyPath],
50
+ { encoding: "utf8", timeout: EXEC_TIMEOUT_MS },
51
+ );
52
+ if (res.status !== 0) {
53
+ throw new Error(
54
+ `ssh-keygen failed: ${(res.stderr || res.error?.message || "").trim()}`,
55
+ );
56
+ }
57
+ return {
58
+ privateKey: readFileSync(keyPath, "utf8"),
59
+ publicKey: readFileSync(`${keyPath}.pub`, "utf8").trim(),
60
+ };
61
+ } finally {
62
+ rmSync(dir, { recursive: true, force: true });
63
+ }
64
+ }
65
+
66
+ // --- key store --------------------------------------------------------------
67
+
68
+ /** The user's public key, or null if they have no key on this host yet. */
69
+ export function getPublicKey(userId: string): string | null {
70
+ const row = getDb()
71
+ .select({ publicKey: schema.sshKeys.publicKey })
72
+ .from(schema.sshKeys)
73
+ .where(eq(schema.sshKeys.userId, userId))
74
+ .get();
75
+ return row?.publicKey ?? null;
76
+ }
77
+
78
+ export function hasKey(userId: string): boolean {
79
+ return getPublicKey(userId) !== null;
80
+ }
81
+
82
+ /**
83
+ * Ensure the user has a key on this host; generate + persist one if not.
84
+ * Idempotent — returns the (existing or freshly created) public key.
85
+ */
86
+ export function ensureKeyForUser(userId: string): string {
87
+ const existing = getPublicKey(userId);
88
+ if (existing) return existing;
89
+
90
+ const { publicKey, privateKey } = generateEd25519(`uai-${userId}`);
91
+ const sealed = sealAesGcm(privateKey);
92
+ const now = Date.now();
93
+ getDb()
94
+ .insert(schema.sshKeys)
95
+ .values({
96
+ userId,
97
+ publicKey,
98
+ privateKeyCt: sealed.ct,
99
+ privateKeyNonce: sealed.nonce,
100
+ createdAt: now,
101
+ updatedAt: now,
102
+ })
103
+ .onConflictDoNothing({ target: schema.sshKeys.userId })
104
+ .run();
105
+ // Re-read in case a concurrent task-up won the insert race.
106
+ return getPublicKey(userId) ?? publicKey;
107
+ }
108
+
109
+ export function deleteKey(userId: string): void {
110
+ getDb().delete(schema.sshKeys).where(eq(schema.sshKeys.userId, userId)).run();
111
+ }
112
+
113
+ /** Decrypt + return the user's private key, or null if they have none. */
114
+ export function privateKeyForUser(userId: string): string | null {
115
+ const row = getDb()
116
+ .select()
117
+ .from(schema.sshKeys)
118
+ .where(eq(schema.sshKeys.userId, userId))
119
+ .get();
120
+ if (!row) return null;
121
+ return openAesGcm(
122
+ Buffer.from(row.privateKeyCt as Uint8Array),
123
+ Buffer.from(row.privateKeyNonce as Uint8Array),
124
+ );
125
+ }
126
+
127
+ /** Decrypt + return BOTH halves of the user's key, or null if they have none. */
128
+ export function keyPairForUser(
129
+ userId: string,
130
+ ): { privateKey: string; publicKey: string } | null {
131
+ const row = getDb()
132
+ .select()
133
+ .from(schema.sshKeys)
134
+ .where(eq(schema.sshKeys.userId, userId))
135
+ .get();
136
+ if (!row) return null;
137
+ return {
138
+ privateKey: openAesGcm(
139
+ Buffer.from(row.privateKeyCt as Uint8Array),
140
+ Buffer.from(row.privateKeyNonce as Uint8Array),
141
+ ),
142
+ publicKey: row.publicKey,
143
+ };
144
+ }
145
+
146
+ // --- per-task on-disk identity (host clone + container, ADR-029) -------------
147
+ //
148
+ // task-up.sh installs the creator's key into the container (and uses it for the
149
+ // host clone). The cloud relays ssh.key.get/ensure/delete for the Account card
150
+ // via the store functions above; there is no separate docker-exec injection.
151
+
152
+ function taskIdentityDir(taskId: string): string {
153
+ return resolve(env.dataDir, "identity", "tasks", taskId);
154
+ }
155
+
156
+ /**
157
+ * Materialize the task creator's keypair to a per-task on-disk identity that
158
+ * task-up.sh uses for BOTH the host-side bare clone and the container copy
159
+ * (ADR-029) — so a task clones + pushes as its creator, not the operator.
160
+ * Returns the dir, or null when the creator has no key on this host (task-up.sh
161
+ * then falls back to the operator identity from `pnpm setup-identity`). The
162
+ * decrypted key lives only on the trusted host (ADR-015) and is removed right
163
+ * after task-up — see {@link removeTaskIdentity}.
164
+ */
165
+ export function writeTaskIdentity(
166
+ taskId: string,
167
+ userId: string | null | undefined,
168
+ ): string | null {
169
+ if (!userId) return null;
170
+ const pair = keyPairForUser(userId);
171
+ if (!pair) return null;
172
+ const dir = taskIdentityDir(taskId);
173
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
174
+ writeFileSync(resolve(dir, "id_ed25519"), withNewline(pair.privateKey), {
175
+ mode: 0o600,
176
+ });
177
+ writeFileSync(resolve(dir, "id_ed25519.pub"), withNewline(pair.publicKey), {
178
+ mode: 0o644,
179
+ });
180
+ return dir;
181
+ }
182
+
183
+ /** Remove a task's on-disk identity (idempotent; call after task-up). */
184
+ export function removeTaskIdentity(taskId: string): void {
185
+ rmSync(taskIdentityDir(taskId), { recursive: true, force: true });
186
+ }