Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit6746866unknown_key

Merge pull request #89 from ccantynz-alt/claude/ecstatic-ptolemy-jMdig

Merge pull request #89 from ccantynz-alt/claude/ecstatic-ptolemy-jMdig

Claude/ecstatic ptolemy j mdig
CC LABS App committed on May 27, 2026Parents: 9b3a183 90c7531
15 files changed+314906746866a4cffc3ea7d81d0b7c972c92968a5e631
15 changed files+3149−0
Modified.env.example+13−0View fileUnifiedSplit
7070VOYAGE_API_KEY=
7171# AES-256-GCM key (hex, 64 chars) for encrypting workflow secrets at rest.
7272WORKFLOW_SECRETS_KEY=
73# AES-256-GCM key (hex, 64 chars) for encrypting server-target SSH private
74# keys and per-target env vars at rest (Block ST — /admin/servers).
75# Generate with: openssl rand -hex 32. If unset, all server-target
76# operations refuse cleanly (no panics, no plaintext fallbacks).
77SERVER_TARGETS_KEY=
78# Block CW — Claude on the web (/:owner/:repo/claude).
79# Per-session working directories live under this dir on the web server.
80# Default: /var/lib/gluecron/claude-web. Should be on a disk with room
81# for full repo clones and writable by the gluecron service user.
82CLAUDE_WEB_WORKDIR=
83# Path to the Claude Code CLI binary. Default: `claude` (i.e. on PATH).
84# Override if `claude` is installed somewhere non-standard on the box.
85CLAUDE_BIN=
7386# Set to "production" or "test" to override NODE_ENV for Bun.
7487BUN_ENV=
7588# Injected at build time; the deployed git commit SHA for the platform-status endpoint.
Addeddrizzle/0073_server_targets.sql+104−0View fileUnifiedSplit
1-- Gluecron migration 0073: server targets (Block ST).
2--
3-- Admin-only first-class concept for "boxes Gluecron can push deploys to".
4-- A target owns:
5-- - SSH connection info (host, user, port, encrypted private key)
6-- - A host-key fingerprint pinned on first successful connection (TOFU)
7-- - A deploy_script that runs on the box when a watched branch is pushed
8-- - A set of env vars (server_target_env) materialised as a .env file
9-- uploaded before each deploy and sourced by the script
10--
11-- Customer-facing rollout (Block 2) reuses these tables with the addition
12-- of owner_user_id scoping + an `auth_method` enum. v1 is admin-only:
13-- created_by tracks the operator who registered the target.
14--
15-- Wrapped in DO blocks so partial replays are safe.
16
17--> statement-breakpoint
18DO $$
19BEGIN
20 CREATE TABLE IF NOT EXISTS "server_targets" (
21 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
22 "name" text NOT NULL,
23 "host" text NOT NULL,
24 "port" integer NOT NULL DEFAULT 22,
25 "ssh_user" text NOT NULL,
26 "encrypted_private_key" text NOT NULL,
27 "host_fingerprint" text,
28 "deploy_path" text NOT NULL DEFAULT '/var/www/app',
29 "deploy_script" text NOT NULL DEFAULT 'bash deploy.sh',
30 "watched_repository_id" uuid REFERENCES "repositories"("id") ON DELETE SET NULL,
31 "watched_branch" text,
32 "status" text NOT NULL DEFAULT 'unverified',
33 "last_seen_at" timestamptz,
34 "created_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
35 "created_at" timestamptz NOT NULL DEFAULT now(),
36 "updated_at" timestamptz NOT NULL DEFAULT now()
37 );
38 CREATE UNIQUE INDEX IF NOT EXISTS "server_targets_name_uq" ON "server_targets"("name");
39 CREATE INDEX IF NOT EXISTS "server_targets_watch_idx"
40 ON "server_targets"("watched_repository_id", "watched_branch")
41 WHERE "watched_repository_id" IS NOT NULL;
42EXCEPTION WHEN OTHERS THEN
43 RAISE NOTICE 'server_targets create failed (%); feature will be unavailable', SQLERRM;
44END $$;
45
46--> statement-breakpoint
47DO $$
48BEGIN
49 CREATE TABLE IF NOT EXISTS "server_target_env" (
50 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
51 "target_id" uuid NOT NULL REFERENCES "server_targets"("id") ON DELETE CASCADE,
52 "name" text NOT NULL,
53 "encrypted_value" text NOT NULL,
54 "is_secret" boolean NOT NULL DEFAULT true,
55 "updated_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
56 "created_at" timestamptz NOT NULL DEFAULT now(),
57 "updated_at" timestamptz NOT NULL DEFAULT now()
58 );
59 CREATE UNIQUE INDEX IF NOT EXISTS "server_target_env_uq"
60 ON "server_target_env"("target_id", "name");
61EXCEPTION WHEN OTHERS THEN
62 RAISE NOTICE 'server_target_env create failed (%);', SQLERRM;
63END $$;
64
65--> statement-breakpoint
66DO $$
67BEGIN
68 CREATE TABLE IF NOT EXISTS "server_target_deployments" (
69 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
70 "target_id" uuid NOT NULL REFERENCES "server_targets"("id") ON DELETE CASCADE,
71 "commit_sha" text,
72 "ref" text,
73 "status" text NOT NULL DEFAULT 'pending',
74 "exit_code" integer,
75 "stdout" text,
76 "stderr" text,
77 "triggered_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
78 "trigger_source" text NOT NULL DEFAULT 'push',
79 "started_at" timestamptz NOT NULL DEFAULT now(),
80 "finished_at" timestamptz
81 );
82 CREATE INDEX IF NOT EXISTS "server_target_deployments_target_idx"
83 ON "server_target_deployments"("target_id", "started_at" DESC);
84EXCEPTION WHEN OTHERS THEN
85 RAISE NOTICE 'server_target_deployments create failed (%);', SQLERRM;
86END $$;
87
88--> statement-breakpoint
89DO $$
90BEGIN
91 CREATE TABLE IF NOT EXISTS "server_target_audit" (
92 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
93 "target_id" uuid REFERENCES "server_targets"("id") ON DELETE SET NULL,
94 "actor_id" uuid REFERENCES "users"("id") ON DELETE SET NULL,
95 "action" text NOT NULL,
96 "detail" text,
97 "ip" text,
98 "created_at" timestamptz NOT NULL DEFAULT now()
99 );
100 CREATE INDEX IF NOT EXISTS "server_target_audit_target_idx"
101 ON "server_target_audit"("target_id", "created_at" DESC);
102EXCEPTION WHEN OTHERS THEN
103 RAISE NOTICE 'server_target_audit create failed (%);', SQLERRM;
104END $$;
Addeddrizzle/0074_claude_web_sessions.sql+56−0View fileUnifiedSplit
1-- Gluecron migration 0074: Claude-on-the-web sessions (Block CW).
2--
3-- Per-repo interactive Claude Code sessions runnable from any browser
4-- (including iPad). Each session owns:
5-- - A working directory on the gluecron web server (cloned from the
6-- repo's bare git store) where Claude can read + edit files.
7-- - A persistent transcript of user/assistant messages.
8-- - The Claude CLI session UUID so subsequent turns `--resume` it and
9-- keep full prior-turn context without us re-sending the transcript.
10--
11-- Admin-only in v1 (the route gates on isSiteAdmin). Customer-facing
12-- rollout will scope by owner_user_id and run in real containers — for
13-- v1 every session shares the web server's compute.
14--
15-- All DDL wrapped in DO blocks so partial replays are safe.
16
17--> statement-breakpoint
18DO $$
19BEGIN
20 CREATE TABLE IF NOT EXISTS "claude_web_sessions" (
21 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
22 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
23 "owner_user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
24 "title" text NOT NULL DEFAULT 'New session',
25 "branch" text NOT NULL DEFAULT 'main',
26 "workdir_path" text NOT NULL,
27 "claude_session_id" text,
28 "status" text NOT NULL DEFAULT 'cold',
29 "last_active_at" timestamptz NOT NULL DEFAULT now(),
30 "created_at" timestamptz NOT NULL DEFAULT now()
31 );
32 CREATE INDEX IF NOT EXISTS "claude_web_sessions_repo_idx"
33 ON "claude_web_sessions"("repository_id", "last_active_at" DESC);
34 CREATE INDEX IF NOT EXISTS "claude_web_sessions_owner_idx"
35 ON "claude_web_sessions"("owner_user_id", "last_active_at" DESC);
36EXCEPTION WHEN OTHERS THEN
37 RAISE NOTICE 'claude_web_sessions create failed (%); feature will be unavailable', SQLERRM;
38END $$;
39
40--> statement-breakpoint
41DO $$
42BEGIN
43 CREATE TABLE IF NOT EXISTS "claude_web_messages" (
44 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
45 "session_id" uuid NOT NULL REFERENCES "claude_web_sessions"("id") ON DELETE CASCADE,
46 "role" text NOT NULL,
47 "body" text NOT NULL,
48 "exit_code" integer,
49 "duration_ms" integer,
50 "created_at" timestamptz NOT NULL DEFAULT now()
51 );
52 CREATE INDEX IF NOT EXISTS "claude_web_messages_session_idx"
53 ON "claude_web_messages"("session_id", "created_at");
54EXCEPTION WHEN OTHERS THEN
55 RAISE NOTICE 'claude_web_messages create failed (%);', SQLERRM;
56END $$;
Addedsrc/__tests__/claude-web-session.test.ts+210−0View fileUnifiedSplit
1/**
2 * Unit tests for src/lib/claude-web-session.ts.
3 *
4 * We don't exercise the real `claude` CLI here — the spawn seam is
5 * overridden so we can verify the command shape, --resume passthrough,
6 * stream-json session-id parsing, and the env-scrubbing whitelist.
7 */
8
9import { describe, it, expect, afterEach, beforeEach } from "bun:test";
10import {
11 __setSpawnForTests,
12 __test,
13 claudeBinary,
14 claudeWebRoot,
15 runTurn,
16 sessionWorkdir,
17 type SpawnFn,
18} from "../lib/claude-web-session";
19import type { ClaudeWebSession } from "../db/schema";
20
21const originalRoot = process.env.CLAUDE_WEB_WORKDIR;
22const originalBin = process.env.CLAUDE_BIN;
23const originalApiKey = process.env.ANTHROPIC_API_KEY;
24
25beforeEach(() => {
26 process.env.CLAUDE_WEB_WORKDIR = "/tmp/cw-test";
27 process.env.CLAUDE_BIN = "claude-test";
28});
29
30afterEach(() => {
31 __setSpawnForTests(null);
32 for (const [k, v] of [
33 ["CLAUDE_WEB_WORKDIR", originalRoot],
34 ["CLAUDE_BIN", originalBin],
35 ["ANTHROPIC_API_KEY", originalApiKey],
36 ] as const) {
37 if (v === undefined) delete process.env[k];
38 else process.env[k] = v;
39 }
40});
41
42function fakeSession(overrides: Partial<ClaudeWebSession> = {}): ClaudeWebSession {
43 return {
44 id: "00000000-0000-0000-0000-000000000099",
45 repositoryId: "00000000-0000-0000-0000-000000000001",
46 ownerUserId: "00000000-0000-0000-0000-000000000002",
47 title: "test session",
48 branch: "main",
49 workdirPath: "/tmp/cw-test/00000000-0000-0000-0000-000000000099",
50 claudeSessionId: null,
51 status: "cold",
52 lastActiveAt: new Date(),
53 createdAt: new Date(),
54 ...overrides,
55 } as ClaudeWebSession;
56}
57
58describe("claudeWebRoot + sessionWorkdir + claudeBinary", () => {
59 it("reads from env at call time", () => {
60 expect(claudeWebRoot()).toBe("/tmp/cw-test");
61 expect(claudeBinary()).toBe("claude-test");
62 expect(sessionWorkdir("abc")).toBe("/tmp/cw-test/abc");
63 });
64
65 it("defaults sensibly when env unset", () => {
66 delete process.env.CLAUDE_WEB_WORKDIR;
67 delete process.env.CLAUDE_BIN;
68 expect(claudeWebRoot()).toBe("/var/lib/gluecron/claude-web");
69 expect(claudeBinary()).toBe("claude");
70 });
71
72 it("strips trailing slash from workdir root", () => {
73 process.env.CLAUDE_WEB_WORKDIR = "/tmp/cw-test/";
74 expect(claudeWebRoot()).toBe("/tmp/cw-test");
75 });
76});
77
78describe("runTurn", () => {
79 it("invokes the claude binary with --print and prompt, without --resume on first turn", async () => {
80 const captured: { cmd: string[]; cwd: string } = { cmd: [], cwd: "" };
81 const handle = makeHandle(['{"type":"text","text":"hi"}\n'], 0, "");
82 __setSpawnForTests((cmd, opts) => {
83 captured.cmd = cmd;
84 captured.cwd = opts.cwd;
85 return handle;
86 });
87
88 const events = await collect(runTurn({
89 session: fakeSession(),
90 ownerName: "you",
91 repoName: "repo",
92 prompt: "hello there",
93 }));
94
95 expect(captured.cmd[0]).toBe("claude-test");
96 expect(captured.cmd).toContain("--print");
97 expect(captured.cmd).toContain("--output-format");
98 expect(captured.cmd).toContain("stream-json");
99 expect(captured.cmd).not.toContain("--resume");
100 expect(captured.cmd[captured.cmd.length - 1]).toBe("hello there");
101 expect(captured.cwd).toBe("/tmp/cw-test/00000000-0000-0000-0000-000000000099");
102
103 const chunks = events.filter((e) => e.chunk).map((e) => e.chunk).join("");
104 expect(chunks).toContain("hi");
105 const done = events.find((e) => e.done);
106 expect(done?.done?.exitCode).toBe(0);
107 });
108
109 it("passes --resume <id> when the session already has a claudeSessionId", async () => {
110 const captured: { cmd: string[] } = { cmd: [] };
111 __setSpawnForTests((cmd) => {
112 captured.cmd = cmd;
113 return makeHandle(["ok\n"], 0, "");
114 });
115
116 await collect(runTurn({
117 session: fakeSession({
118 claudeSessionId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
119 }),
120 ownerName: "you",
121 repoName: "repo",
122 prompt: "next turn",
123 }));
124
125 const i = captured.cmd.indexOf("--resume");
126 expect(i).toBeGreaterThan(-1);
127 expect(captured.cmd[i + 1]).toBe("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
128 });
129
130 it("extracts the Claude session UUID from a stream-json init chunk", async () => {
131 __setSpawnForTests(() =>
132 makeHandle(
133 [
134 '{"type":"system","subtype":"init","session_id":"12345678-aaaa-bbbb-cccc-1234567890ab"}\n',
135 '{"type":"text","text":"hello"}\n',
136 ],
137 0,
138 ""
139 )
140 );
141 const events = await collect(runTurn({
142 session: fakeSession(),
143 ownerName: "you",
144 repoName: "repo",
145 prompt: "p",
146 }));
147 const done = events.find((e) => e.done);
148 expect(done?.done?.claudeSessionId).toBe(
149 "12345678-aaaa-bbbb-cccc-1234567890ab"
150 );
151 });
152
153 it("returns the subprocess exit code on the done event", async () => {
154 __setSpawnForTests(() => makeHandle(["x"], 7, "boom"));
155 const events = await collect(runTurn({
156 session: fakeSession(),
157 ownerName: "y",
158 repoName: "r",
159 prompt: "p",
160 }));
161 const done = events.find((e) => e.done);
162 expect(done?.done?.exitCode).toBe(7);
163 expect(done?.done?.stderr).toBe("boom");
164 });
165});
166
167describe("passthroughEnv", () => {
168 it("keeps PATH and ANTHROPIC_API_KEY when set", () => {
169 process.env.ANTHROPIC_API_KEY = "sk-test";
170 const env = __test.passthroughEnv();
171 expect(env.PATH).toBeDefined();
172 expect(env.ANTHROPIC_API_KEY).toBe("sk-test");
173 });
174
175 it("strips DATABASE_URL even if set", () => {
176 process.env.DATABASE_URL = "postgresql://x";
177 const env = __test.passthroughEnv();
178 expect(env.DATABASE_URL).toBeUndefined();
179 delete process.env.DATABASE_URL;
180 });
181
182 it("keeps any CLAUDE_* prefix var", () => {
183 process.env.CLAUDE_CUSTOM_FLAG = "yes";
184 const env = __test.passthroughEnv();
185 expect(env.CLAUDE_CUSTOM_FLAG).toBe("yes");
186 delete process.env.CLAUDE_CUSTOM_FLAG;
187 });
188});
189
190// ─── helpers ────────────────────────────────────────────────────────────────
191
192function makeHandle(
193 chunks: string[],
194 exitCode: number,
195 stderr: string
196): ReturnType<SpawnFn> {
197 return {
198 stdout: (async function* () {
199 for (const c of chunks) yield c;
200 })(),
201 done: Promise.resolve({ exitCode, stderr }),
202 kill: () => {},
203 };
204}
205
206async function collect<T>(gen: AsyncGenerator<T>): Promise<T[]> {
207 const out: T[] = [];
208 for await (const ev of gen) out.push(ev);
209 return out;
210}
Addedsrc/__tests__/server-targets-crypto.test.ts+135−0View fileUnifiedSplit
1/**
2 * Pure-function tests for src/lib/server-targets-crypto.ts.
3 *
4 * Mirror of the workflow-secrets-crypto suite: round-trip, IV randomness,
5 * tamper detection, env-name validation, dotenv rendering.
6 */
7
8import { describe, it, expect, afterEach } from "bun:test";
9import {
10 encryptValue,
11 decryptValue,
12 getMasterKey,
13 isValidEnvName,
14 renderDotenv,
15} from "../lib/server-targets-crypto";
16
17const TEST_KEY =
18 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
19const original = process.env.SERVER_TARGETS_KEY;
20
21afterEach(() => {
22 if (original === undefined) delete process.env.SERVER_TARGETS_KEY;
23 else process.env.SERVER_TARGETS_KEY = original;
24});
25
26describe("getMasterKey", () => {
27 it("returns null when env is unset", () => {
28 delete process.env.SERVER_TARGETS_KEY;
29 expect(getMasterKey()).toBeNull();
30 });
31
32 it("returns null when env is not 32 bytes", () => {
33 process.env.SERVER_TARGETS_KEY = "abcd";
34 expect(getMasterKey()).toBeNull();
35 });
36
37 it("returns 32-byte buffer when env is valid hex", () => {
38 process.env.SERVER_TARGETS_KEY = TEST_KEY;
39 const key = getMasterKey();
40 expect(key).not.toBeNull();
41 expect(key!.length).toBe(32);
42 });
43
44 it("ignores non-hex input", () => {
45 process.env.SERVER_TARGETS_KEY = "zzzz".repeat(16);
46 expect(getMasterKey()).toBeNull();
47 });
48});
49
50describe("encrypt/decrypt round-trip", () => {
51 it("recovers the original plaintext", () => {
52 process.env.SERVER_TARGETS_KEY = TEST_KEY;
53 const enc = encryptValue("hello-server-target");
54 expect(enc.ok).toBe(true);
55 if (!enc.ok) return;
56 const dec = decryptValue(enc.ciphertext);
57 expect(dec.ok).toBe(true);
58 if (!dec.ok) return;
59 expect(dec.plaintext).toBe("hello-server-target");
60 });
61
62 it("uses a fresh IV per encryption so identical plaintexts diverge", () => {
63 process.env.SERVER_TARGETS_KEY = TEST_KEY;
64 const a = encryptValue("same");
65 const b = encryptValue("same");
66 expect(a.ok && b.ok).toBe(true);
67 if (!a.ok || !b.ok) return;
68 expect(a.ciphertext).not.toBe(b.ciphertext);
69 });
70
71 it("rejects encrypt when key missing", () => {
72 delete process.env.SERVER_TARGETS_KEY;
73 const enc = encryptValue("anything");
74 expect(enc.ok).toBe(false);
75 });
76
77 it("detects tampered ciphertext via GCM auth tag", () => {
78 process.env.SERVER_TARGETS_KEY = TEST_KEY;
79 const enc = encryptValue("payload");
80 if (!enc.ok) throw new Error("setup failed");
81 // Flip a byte inside the ciphertext portion (after IV+tag) — base64
82 // decode → mutate → re-encode.
83 const buf = Buffer.from(enc.ciphertext, "base64");
84 buf[buf.length - 1] ^= 0xff;
85 const tampered = buf.toString("base64");
86 const dec = decryptValue(tampered);
87 expect(dec.ok).toBe(false);
88 });
89
90 it("rejects too-short blob", () => {
91 process.env.SERVER_TARGETS_KEY = TEST_KEY;
92 const dec = decryptValue(Buffer.from("short").toString("base64"));
93 expect(dec.ok).toBe(false);
94 });
95});
96
97describe("isValidEnvName", () => {
98 it("accepts conventional env names", () => {
99 expect(isValidEnvName("FOO")).toBe(true);
100 expect(isValidEnvName("FOO_BAR")).toBe(true);
101 expect(isValidEnvName("_PRIVATE")).toBe(true);
102 expect(isValidEnvName("A1_B2")).toBe(true);
103 });
104
105 it("rejects lowercase, dashes, leading digits, empty", () => {
106 expect(isValidEnvName("foo")).toBe(false);
107 expect(isValidEnvName("FOO-BAR")).toBe(false);
108 expect(isValidEnvName("1FOO")).toBe(false);
109 expect(isValidEnvName("")).toBe(false);
110 expect(isValidEnvName(undefined)).toBe(false);
111 expect(isValidEnvName(null)).toBe(false);
112 });
113});
114
115describe("renderDotenv", () => {
116 it("renders alphabetised KEY='value' lines", () => {
117 const out = renderDotenv({ BAR: "two", FOO: "one" });
118 expect(out).toBe("BAR='two'\nFOO='one'\n");
119 });
120
121 it("escapes embedded single quotes", () => {
122 const out = renderDotenv({ FOO: "a'b" });
123 // a'b → 'a'\''b' — POSIX single-quote escape.
124 expect(out).toBe("FOO='a'\\''b'\n");
125 });
126
127 it("returns empty string for empty map", () => {
128 expect(renderDotenv({})).toBe("");
129 });
130
131 it("never leaves a value unquoted", () => {
132 const out = renderDotenv({ KEY: "has spaces and $vars" });
133 expect(out).toBe("KEY='has spaces and $vars'\n");
134 });
135});
Addedsrc/__tests__/server-targets.test.ts+176−0View fileUnifiedSplit
1/**
2 * Driver-level tests for src/lib/server-targets.ts.
3 *
4 * We never actually shell out — `__setSpawnForTests` lets us inspect the
5 * command line the driver builds and feed back canned exit codes. Covers:
6 * - testConnection happy path (returns SHA256 fingerprint)
7 * - deployToTarget happy path (env file is materialised + sourced)
8 * - host-key fingerprint mismatch aborts the deploy
9 * - missing SERVER_TARGETS_KEY collapses to ok:false (no throw)
10 */
11
12import { describe, it, expect, afterEach, beforeEach } from "bun:test";
13import {
14 __setSpawnForTests,
15 deployToTarget,
16 testConnection,
17 type SpawnResult,
18} from "../lib/server-targets";
19import { encryptValue } from "../lib/server-targets-crypto";
20import type { ServerTarget } from "../db/schema";
21
22const TEST_KEY =
23 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
24const HOST_FP = "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
25
26const originalKey = process.env.SERVER_TARGETS_KEY;
27
28beforeEach(() => {
29 process.env.SERVER_TARGETS_KEY = TEST_KEY;
30});
31
32afterEach(() => {
33 __setSpawnForTests(null);
34 if (originalKey === undefined) delete process.env.SERVER_TARGETS_KEY;
35 else process.env.SERVER_TARGETS_KEY = originalKey;
36});
37
38function buildTarget(overrides: Partial<ServerTarget> = {}): ServerTarget {
39 const enc = encryptValue("-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----\n");
40 if (!enc.ok) throw new Error("encrypt failed in test setup: " + enc.error);
41 return {
42 id: "00000000-0000-0000-0000-000000000001",
43 name: "test-box",
44 host: "192.0.2.10",
45 port: 22,
46 sshUser: "deploy",
47 encryptedPrivateKey: enc.ciphertext,
48 hostFingerprint: null,
49 deployPath: "/var/www/app",
50 deployScript: "echo deployed",
51 watchedRepositoryId: null,
52 watchedBranch: null,
53 status: "unverified",
54 lastSeenAt: null,
55 createdBy: null,
56 createdAt: new Date(),
57 updatedAt: new Date(),
58 ...overrides,
59 } as ServerTarget;
60}
61
62describe("testConnection", () => {
63 it("returns the fingerprint and ok=true on a clean run", async () => {
64 const calls: Array<string[]> = [];
65 __setSpawnForTests(async (cmd) => {
66 calls.push(cmd);
67 const [bin] = cmd;
68 if (bin === "ssh-keyscan") {
69 return ok("192.0.2.10 ssh-ed25519 AAAA...\n");
70 }
71 if (bin === "ssh-keygen") {
72 return ok(`256 ${HOST_FP} root@box (ED25519)\n`);
73 }
74 if (bin === "ssh") {
75 return ok("gluecron-ok\n");
76 }
77 return fail("unexpected cmd: " + cmd.join(" "));
78 });
79
80 const result = await testConnection(buildTarget());
81 expect(result.ok).toBe(true);
82 if (!result.ok) return;
83 expect(result.fingerprint).toBe(HOST_FP);
84 expect(calls.length).toBe(3);
85 expect(calls[0][0]).toBe("ssh-keyscan");
86 expect(calls[2][0]).toBe("ssh");
87 });
88
89 it("returns ok=false at the scan stage when ssh-keyscan fails", async () => {
90 __setSpawnForTests(async (cmd) => {
91 if (cmd[0] === "ssh-keyscan") return fail("Connection refused");
92 return ok("");
93 });
94 const result = await testConnection(buildTarget());
95 expect(result.ok).toBe(false);
96 if (result.ok) return;
97 expect(result.stage).toBe("scan");
98 });
99
100 it("returns ok=false at auth stage when ssh probe fails", async () => {
101 __setSpawnForTests(async (cmd) => {
102 if (cmd[0] === "ssh-keyscan") return ok("192.0.2.10 ssh-ed25519 AAAA\n");
103 if (cmd[0] === "ssh-keygen") return ok(`256 ${HOST_FP} (ED25519)\n`);
104 if (cmd[0] === "ssh") return fail("Permission denied (publickey)");
105 return fail("?");
106 });
107 const result = await testConnection(buildTarget());
108 expect(result.ok).toBe(false);
109 if (result.ok) return;
110 expect(result.stage).toBe("auth");
111 });
112});
113
114describe("deployToTarget", () => {
115 it("scp's the env file then ssh-runs the deploy script", async () => {
116 const calls: Array<string[]> = [];
117 __setSpawnForTests(async (cmd) => {
118 calls.push(cmd);
119 if (cmd[0] === "scp") return ok("");
120 if (cmd[0] === "ssh") return ok("done\n");
121 return fail("?");
122 });
123 const result = await deployToTarget(buildTarget(), {
124 env: { FOO: "1", BAR: "two" },
125 commitSha: "abc1234",
126 ref: "refs/heads/main",
127 });
128 expect(result.ok).toBe(true);
129 expect(result.exitCode).toBe(0);
130
131 // Two calls: scp, then ssh.
132 expect(calls.length).toBe(2);
133 expect(calls[0][0]).toBe("scp");
134 // scp target ends at deploy_path/.env.gluecron
135 const scpDst = calls[0][calls[0].length - 1];
136 expect(scpDst).toBe("deploy@192.0.2.10:/var/www/app/.env.gluecron");
137
138 expect(calls[1][0]).toBe("ssh");
139 const remoteCmd = calls[1][calls[1].length - 1];
140 expect(remoteCmd).toContain("cd '/var/www/app'");
141 expect(remoteCmd).toContain(". ./.env.gluecron");
142 expect(remoteCmd).toContain("export GLUECRON_COMMIT_SHA='abc1234'");
143 expect(remoteCmd).toContain("echo deployed");
144 });
145
146 it("aborts deploy when pinned fingerprint doesn't match live", async () => {
147 __setSpawnForTests(async (cmd) => {
148 if (cmd[0] === "ssh-keyscan") return ok("192.0.2.10 ssh-ed25519 AAAA\n");
149 if (cmd[0] === "ssh-keygen") return ok("256 SHA256:DIFFERENT (ED25519)\n");
150 return fail("should not get here");
151 });
152 const result = await deployToTarget(
153 buildTarget({ hostFingerprint: HOST_FP }),
154 { env: {} }
155 );
156 expect(result.ok).toBe(false);
157 expect(result.stderr).toContain("fingerprint mismatch");
158 });
159
160 it("collapses to ok=false when SERVER_TARGETS_KEY is missing", async () => {
161 const target = buildTarget(); // build while key is present so encrypt works
162 delete process.env.SERVER_TARGETS_KEY; // now drop it — decrypt must fail cleanly
163 const result = await deployToTarget(target, { env: {} });
164 expect(result.ok).toBe(false);
165 expect(result.exitCode).toBe(-1);
166 });
167});
168
169// ─── helpers ────────────────────────────────────────────────────────────────
170
171function ok(stdout: string): SpawnResult {
172 return { exitCode: 0, stdout, stderr: "" };
173}
174function fail(stderr: string): SpawnResult {
175 return { exitCode: 1, stdout: "", stderr };
176}
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
8787import adminRoutes from "./routes/admin";
8888import adminDeploysRoutes from "./routes/admin-deploys";
8989import adminDeploysPageRoutes from "./routes/admin-deploys-page";
90import adminServerTargetsRoutes from "./routes/admin-server-targets";
91import claudeWebRoutes from "./routes/claude-web";
9092import adminOpsRoutes from "./routes/admin-ops";
9193import adminSelfHostRoutes from "./routes/admin-self-host";
9294import adminDiagnoseRoutes from "./routes/admin-diagnose";
564566app.route("/", adminAdvancementRoutes);
565567app.route("/", adminDeploysRoutes);
566568app.route("/", adminDeploysPageRoutes);
569app.route("/", adminServerTargetsRoutes);
570app.route("/", claudeWebRoutes);
567571// Note: adminOpsRoutes is mounted earlier (before insightRoutes) — see comment above.
568572app.route("/", advisoriesRoutes);
569573app.route("/", aiChangelogRoutes);
Modifiedsrc/db/schema.ts+212−0View fileUnifiedSplit
38063806
38073807export type DevEnv = typeof devEnvs.$inferSelect;
38083808export type NewDevEnv = typeof devEnvs.$inferInsert;
3809
3810// ---------------------------------------------------------------------------
3811// Block ST — Server targets (drizzle/0073_server_targets.sql)
3812//
3813// Admin-managed remote boxes Gluecron can SSH into and run a deploy script
3814// on. Each target carries an encrypted private SSH key, a pinned host
3815// fingerprint, an optional repo+branch to watch, and a per-target list of
3816// env vars materialised on the box at deploy time. Customer-facing rollout
3817// is gated to a follow-up block (Block 2) that scopes by owner_user_id.
3818// ---------------------------------------------------------------------------
3819
3820export const serverTargets = pgTable(
3821 "server_targets",
3822 {
3823 id: uuid("id").primaryKey().defaultRandom(),
3824 name: text("name").notNull(),
3825 host: text("host").notNull(),
3826 port: integer("port").default(22).notNull(),
3827 sshUser: text("ssh_user").notNull(),
3828 encryptedPrivateKey: text("encrypted_private_key").notNull(),
3829 hostFingerprint: text("host_fingerprint"),
3830 deployPath: text("deploy_path").default("/var/www/app").notNull(),
3831 deployScript: text("deploy_script").default("bash deploy.sh").notNull(),
3832 watchedRepositoryId: uuid("watched_repository_id").references(
3833 () => repositories.id,
3834 { onDelete: "set null" }
3835 ),
3836 watchedBranch: text("watched_branch"),
3837 status: text("status").default("unverified").notNull(),
3838 lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
3839 createdBy: uuid("created_by").references(() => users.id, {
3840 onDelete: "set null",
3841 }),
3842 createdAt: timestamp("created_at", { withTimezone: true })
3843 .defaultNow()
3844 .notNull(),
3845 updatedAt: timestamp("updated_at", { withTimezone: true })
3846 .defaultNow()
3847 .notNull(),
3848 },
3849 (table) => [
3850 uniqueIndex("server_targets_name_uq").on(table.name),
3851 index("server_targets_watch_idx").on(
3852 table.watchedRepositoryId,
3853 table.watchedBranch
3854 ),
3855 ]
3856);
3857
3858export type ServerTarget = typeof serverTargets.$inferSelect;
3859export type NewServerTarget = typeof serverTargets.$inferInsert;
3860
3861export const serverTargetEnv = pgTable(
3862 "server_target_env",
3863 {
3864 id: uuid("id").primaryKey().defaultRandom(),
3865 targetId: uuid("target_id")
3866 .notNull()
3867 .references(() => serverTargets.id, { onDelete: "cascade" }),
3868 name: text("name").notNull(),
3869 encryptedValue: text("encrypted_value").notNull(),
3870 isSecret: boolean("is_secret").default(true).notNull(),
3871 updatedBy: uuid("updated_by").references(() => users.id, {
3872 onDelete: "set null",
3873 }),
3874 createdAt: timestamp("created_at", { withTimezone: true })
3875 .defaultNow()
3876 .notNull(),
3877 updatedAt: timestamp("updated_at", { withTimezone: true })
3878 .defaultNow()
3879 .notNull(),
3880 },
3881 (table) => [uniqueIndex("server_target_env_uq").on(table.targetId, table.name)]
3882);
3883
3884export type ServerTargetEnv = typeof serverTargetEnv.$inferSelect;
3885export type NewServerTargetEnv = typeof serverTargetEnv.$inferInsert;
3886
3887export const serverTargetDeployments = pgTable(
3888 "server_target_deployments",
3889 {
3890 id: uuid("id").primaryKey().defaultRandom(),
3891 targetId: uuid("target_id")
3892 .notNull()
3893 .references(() => serverTargets.id, { onDelete: "cascade" }),
3894 commitSha: text("commit_sha"),
3895 ref: text("ref"),
3896 status: text("status").default("pending").notNull(),
3897 exitCode: integer("exit_code"),
3898 stdout: text("stdout"),
3899 stderr: text("stderr"),
3900 triggeredBy: uuid("triggered_by").references(() => users.id, {
3901 onDelete: "set null",
3902 }),
3903 triggerSource: text("trigger_source").default("push").notNull(),
3904 startedAt: timestamp("started_at", { withTimezone: true })
3905 .defaultNow()
3906 .notNull(),
3907 finishedAt: timestamp("finished_at", { withTimezone: true }),
3908 },
3909 (table) => [
3910 index("server_target_deployments_target_idx").on(
3911 table.targetId,
3912 table.startedAt
3913 ),
3914 ]
3915);
3916
3917export type ServerTargetDeployment =
3918 typeof serverTargetDeployments.$inferSelect;
3919export type NewServerTargetDeployment =
3920 typeof serverTargetDeployments.$inferInsert;
3921
3922export const serverTargetAudit = pgTable(
3923 "server_target_audit",
3924 {
3925 id: uuid("id").primaryKey().defaultRandom(),
3926 targetId: uuid("target_id").references(() => serverTargets.id, {
3927 onDelete: "set null",
3928 }),
3929 actorId: uuid("actor_id").references(() => users.id, {
3930 onDelete: "set null",
3931 }),
3932 action: text("action").notNull(),
3933 detail: text("detail"),
3934 ip: text("ip"),
3935 createdAt: timestamp("created_at", { withTimezone: true })
3936 .defaultNow()
3937 .notNull(),
3938 },
3939 (table) => [
3940 index("server_target_audit_target_idx").on(table.targetId, table.createdAt),
3941 ]
3942);
3943
3944export type ServerTargetAudit = typeof serverTargetAudit.$inferSelect;
3945export type NewServerTargetAudit = typeof serverTargetAudit.$inferInsert;
3946
3947// ---------------------------------------------------------------------------
3948// Block CW — Claude on the web (drizzle/0074_claude_web_sessions.sql)
3949//
3950// Per-repo interactive Claude Code sessions runnable from any browser.
3951// Each session owns a working dir on the web server (a fresh git clone of
3952// the repo's bare store) and persists turn-by-turn transcripts so an iPad
3953// user can resume the same conversation later from a laptop.
3954//
3955// v1 admin-only; v2 will scope by owner_user_id and isolate per-session
3956// containers. Schema is forward-compatible with both.
3957// ---------------------------------------------------------------------------
3958
3959export const claudeWebSessions = pgTable(
3960 "claude_web_sessions",
3961 {
3962 id: uuid("id").primaryKey().defaultRandom(),
3963 repositoryId: uuid("repository_id")
3964 .notNull()
3965 .references(() => repositories.id, { onDelete: "cascade" }),
3966 ownerUserId: uuid("owner_user_id")
3967 .notNull()
3968 .references(() => users.id, { onDelete: "cascade" }),
3969 title: text("title").default("New session").notNull(),
3970 branch: text("branch").default("main").notNull(),
3971 workdirPath: text("workdir_path").notNull(),
3972 claudeSessionId: text("claude_session_id"),
3973 status: text("status").default("cold").notNull(),
3974 lastActiveAt: timestamp("last_active_at", { withTimezone: true })
3975 .defaultNow()
3976 .notNull(),
3977 createdAt: timestamp("created_at", { withTimezone: true })
3978 .defaultNow()
3979 .notNull(),
3980 },
3981 (table) => [
3982 index("claude_web_sessions_repo_idx").on(
3983 table.repositoryId,
3984 table.lastActiveAt
3985 ),
3986 index("claude_web_sessions_owner_idx").on(
3987 table.ownerUserId,
3988 table.lastActiveAt
3989 ),
3990 ]
3991);
3992
3993export type ClaudeWebSession = typeof claudeWebSessions.$inferSelect;
3994export type NewClaudeWebSession = typeof claudeWebSessions.$inferInsert;
3995
3996export const claudeWebMessages = pgTable(
3997 "claude_web_messages",
3998 {
3999 id: uuid("id").primaryKey().defaultRandom(),
4000 sessionId: uuid("session_id")
4001 .notNull()
4002 .references(() => claudeWebSessions.id, { onDelete: "cascade" }),
4003 role: text("role").notNull(),
4004 body: text("body").notNull(),
4005 exitCode: integer("exit_code"),
4006 durationMs: integer("duration_ms"),
4007 createdAt: timestamp("created_at", { withTimezone: true })
4008 .defaultNow()
4009 .notNull(),
4010 },
4011 (table) => [
4012 index("claude_web_messages_session_idx").on(
4013 table.sessionId,
4014 table.createdAt
4015 ),
4016 ]
4017);
4018
4019export type ClaudeWebMessage = typeof claudeWebMessages.$inferSelect;
4020export type NewClaudeWebMessage = typeof claudeWebMessages.$inferInsert;
Modifiedsrc/hooks/post-receive.ts+96−0View fileUnifiedSplit
2727import { indexChangedFiles } from "../lib/semantic-index";
2828import { enqueuePreviewBuild } from "../lib/branch-previews";
2929import { runDocDriftCheckForRepo } from "../lib/ai-doc-updater";
30import {
31 findTargetsForPush,
32 finishDeployRow,
33 resolveEnv,
34 startDeployRow,
35} from "../lib/server-target-store";
36import { deployToTarget } from "../lib/server-targets";
3037
3138interface PushRef {
3239 oldSha: string;
171178 }
172179 }
173180
181 // 5b. Block ST — Server targets. After core post-receive work, fire
182 // deploys against any `server_targets` row whose
183 // (watched_repository_id, watched_branch) matches a pushed ref.
184 // Fire-and-forget; deploy results land in `server_target_deployments`
185 // and the UI at /admin/servers/:id surfaces them.
186 void fireServerTargetDeploys(owner, repo, refs).catch((err) =>
187 console.warn("[server-targets] dispatch error:", err)
188 );
189
174190 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
175191 // main, fire the local deploy via scripts/self-deploy.sh. The script
176192 // forks into the background, so this call returns immediately (git
606622 }
607623}
608624
625/**
626 * Block ST — fan out the push to any server targets that watch this
627 * (repo, branch). One sequential deploy per target so a slow box can't
628 * stall the next push, but multiple matching targets run in parallel.
629 * Every failure is contained to its own deploy row + console warn —
630 * nothing here can break the push path.
631 */
632async function fireServerTargetDeploys(
633 owner: string,
634 repo: string,
635 refs: PushRef[]
636): Promise<void> {
637 const liveRefs = refs.filter(
638 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
639 );
640 if (liveRefs.length === 0) return;
641
642 let repositoryId = "";
643 try {
644 const [row] = await db
645 .select({ id: repositories.id })
646 .from(repositories)
647 .innerJoin(users, eq(repositories.ownerId, users.id))
648 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
649 .limit(1);
650 repositoryId = row?.id || "";
651 } catch {
652 return;
653 }
654 if (!repositoryId) return;
655
656 await Promise.all(
657 liveRefs.map(async (ref) => {
658 const branch = ref.refName.replace("refs/heads/", "");
659 let targets;
660 try {
661 targets = await findTargetsForPush({ repositoryId, branch });
662 } catch {
663 return;
664 }
665 if (!targets.length) return;
666
667 await Promise.all(
668 targets.map(async (target) => {
669 try {
670 const env = await resolveEnv(target.id);
671 const deployId = await startDeployRow({
672 targetId: target.id,
673 commitSha: ref.newSha,
674 ref: ref.refName,
675 triggerSource: "push",
676 });
677 const result = await deployToTarget(target, {
678 commitSha: ref.newSha,
679 ref: ref.refName,
680 env,
681 });
682 if (deployId) {
683 await finishDeployRow({
684 id: deployId,
685 exitCode: result.exitCode,
686 stdout: result.stdout,
687 stderr: result.stderr,
688 });
689 }
690 console.log(
691 `[server-targets] ${target.name} @ ${ref.newSha.slice(0, 7)}: exit ${result.exitCode}`
692 );
693 } catch (err) {
694 console.warn(
695 `[server-targets] ${target.name}: deploy threw — ${err instanceof Error ? err.message : err}`
696 );
697 }
698 })
699 );
700 })
701 );
702}
703
609704/** Test-only access to internal helpers. */
610705export const __test = {
611706 triggerCrontechDeploy,
616711 fireSemanticIndex,
617712 firePreviewBuilds,
618713 fireDocDriftCheck,
714 fireServerTargetDeploys,
619715};
Addedsrc/lib/claude-web-session.ts+404−0View fileUnifiedSplit
1/**
2 * Claude-on-the-web runtime (Block CW).
3 *
4 * Drives one turn of an interactive Claude Code session running on the
5 * gluecron web server. Each turn:
6 * 1. Resolves (and lazily clones) the session's working directory.
7 * 2. Spawns `claude` as a subprocess pointed at that workdir, with
8 * --resume <session-uuid> for follow-up turns so prior context is
9 * preserved without us re-sending the transcript.
10 * 3. Streams stdout chunks back via an AsyncIterable the route layer
11 * hands to an SSE response.
12 * 4. Persists the assistant turn + updates the Claude session UUID +
13 * the last_active_at watermark.
14 *
15 * v1 design rules:
16 * - Admin-only — gated by the route, not this lib.
17 * - Single shared compute: every session shares the web server's CPU
18 * and disk. We cap a single turn at MAX_TURN_MS so a runaway prompt
19 * can't pin the box forever.
20 * - No container: workdir is a plain directory under CLAUDE_WEB_WORKDIR
21 * (default /var/lib/gluecron/claude-web). Caller is responsible for
22 * not pointing this at a tenant-shared filesystem.
23 * - Anthropic creds: we DO NOT pass ANTHROPIC_API_KEY through the
24 * environment if the operator has logged in `claude` interactively
25 * (the CLI manages its own creds in ~/.claude). If the env var is
26 * set, we pass it through unchanged.
27 *
28 * Test seam: `__setSpawnForTests` lets unit tests intercept the spawn
29 * without actually running the Claude CLI.
30 */
31
32import { mkdir, stat } from "fs/promises";
33import { join } from "path";
34import { db } from "../db";
35import {
36 claudeWebMessages,
37 claudeWebSessions,
38 type ClaudeWebSession,
39} from "../db/schema";
40import { and, eq } from "drizzle-orm";
41import { getRepoPath } from "../git/repository";
42
43const MAX_TURN_MS = 5 * 60_000;
44
45export function claudeWebRoot(): string {
46 return (
47 process.env.CLAUDE_WEB_WORKDIR ||
48 "/var/lib/gluecron/claude-web"
49 ).replace(/\/$/, "");
50}
51
52export function claudeBinary(): string {
53 return process.env.CLAUDE_BIN || "claude";
54}
55
56// ---------------------------------------------------------------------------
57// Spawn seam
58// ---------------------------------------------------------------------------
59
60export interface SpawnHandle {
61 /** Async iterable of utf-8 stdout chunks. */
62 stdout: AsyncIterable<string>;
63 /** Resolves to the final exit code + collected stderr after stdout ends. */
64 done: Promise<{ exitCode: number; stderr: string }>;
65 /** Kills the underlying subprocess. */
66 kill: () => void;
67}
68
69export type SpawnFn = (
70 cmd: string[],
71 opts: { cwd: string; env: Record<string, string> }
72) => SpawnHandle;
73
74const defaultSpawn: SpawnFn = (cmd, opts) => {
75 const proc = Bun.spawn(cmd, {
76 cwd: opts.cwd,
77 env: opts.env,
78 stdin: "ignore",
79 stdout: "pipe",
80 stderr: "pipe",
81 });
82 const stdout = (async function* () {
83 const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
84 const dec = new TextDecoder();
85 try {
86 while (true) {
87 const { done, value } = await reader.read();
88 if (done) break;
89 if (value && value.length) yield dec.decode(value, { stream: true });
90 }
91 const tail = dec.decode();
92 if (tail) yield tail;
93 } finally {
94 reader.releaseLock();
95 }
96 })();
97 const done = (async () => {
98 const [exitCode, stderr] = await Promise.all([
99 proc.exited,
100 new Response(proc.stderr).text(),
101 ]);
102 return { exitCode, stderr };
103 })();
104 return { stdout, done, kill: () => proc.kill() };
105};
106
107let _spawn: SpawnFn = defaultSpawn;
108export function __setSpawnForTests(fn: SpawnFn | null): void {
109 _spawn = fn ?? defaultSpawn;
110}
111
112// ---------------------------------------------------------------------------
113// Workdir
114// ---------------------------------------------------------------------------
115
116export function sessionWorkdir(sessionId: string): string {
117 return join(claudeWebRoot(), sessionId);
118}
119
120/**
121 * Make sure the session's working dir exists as a fresh clone of the
122 * repo's bare store at the given branch. Idempotent — if the dir already
123 * has a `.git` folder, we leave it alone and the operator's existing
124 * working state is preserved across turns.
125 *
126 * Uses `git clone --branch <branch> --depth 1 <bare> <workdir>` so the
127 * initial materialisation is fast even on huge repos. Operators can
128 * `git fetch --unshallow` from inside the session if they need history.
129 */
130export async function ensureWorkdir(
131 session: ClaudeWebSession,
132 ownerName: string,
133 repoName: string,
134 spawn: SpawnFn = _spawn
135): Promise<{ ok: true } | { ok: false; error: string }> {
136 const root = claudeWebRoot();
137 try {
138 await mkdir(root, { recursive: true });
139 } catch (err) {
140 return {
141 ok: false,
142 error: `mkdir ${root}: ${err instanceof Error ? err.message : err}`,
143 };
144 }
145 const workdir = session.workdirPath;
146 // If .git already exists in the workdir, the clone is done.
147 try {
148 const s = await stat(join(workdir, ".git"));
149 if (s.isDirectory()) return { ok: true };
150 } catch {
151 /* fall through to clone */
152 }
153
154 const bare = getRepoPath(ownerName, repoName);
155 const handle = spawn(
156 ["git", "clone", "--branch", session.branch, "--depth", "1", bare, workdir],
157 { cwd: root, env: scrubbedEnv() }
158 );
159 // Drain stdout (clone is quiet, but we still need to consume).
160 for await (const _ of handle.stdout) {
161 /* ignore */
162 }
163 const { exitCode, stderr } = await handle.done;
164 if (exitCode !== 0) {
165 return { ok: false, error: stderr.trim() || `git clone exit ${exitCode}` };
166 }
167 return { ok: true };
168}
169
170// ---------------------------------------------------------------------------
171// Turn driver
172// ---------------------------------------------------------------------------
173
174export interface TurnInput {
175 session: ClaudeWebSession;
176 ownerName: string;
177 repoName: string;
178 prompt: string;
179}
180
181export interface TurnEvent {
182 /** Streaming text chunk from Claude's stdout. */
183 chunk?: string;
184 /** Terminal event — call once, after stdout ends. */
185 done?: {
186 exitCode: number;
187 durationMs: number;
188 /** Claude CLI session UUID extracted from the run (when present). */
189 claudeSessionId?: string;
190 stderr: string;
191 };
192}
193
194/**
195 * Run a single conversational turn against Claude. Yields stdout chunks
196 * as they arrive, then a single `done` event with metadata. The caller
197 * (typically the SSE route) is responsible for serialising events onto
198 * the wire and persisting the final assistant body.
199 *
200 * Honours `MAX_TURN_MS` — if the subprocess hasn't finished by then we
201 * call kill() and emit done with exitCode=124 (matching `timeout`).
202 */
203export async function* runTurn(
204 input: TurnInput,
205 spawn: SpawnFn = _spawn
206): AsyncGenerator<TurnEvent, void, void> {
207 const start = Date.now();
208 const cmd = [
209 claudeBinary(),
210 "--print",
211 "--output-format",
212 "stream-json",
213 ...(input.session.claudeSessionId
214 ? ["--resume", input.session.claudeSessionId]
215 : []),
216 input.prompt,
217 ];
218
219 const handle = spawn(cmd, {
220 cwd: input.session.workdirPath,
221 env: passthroughEnv(),
222 });
223
224 const timer = setTimeout(() => {
225 try {
226 handle.kill();
227 } catch {
228 /* ignore */
229 }
230 }, MAX_TURN_MS);
231
232 let sessionId: string | undefined;
233 try {
234 for await (const chunk of handle.stdout) {
235 // Best-effort parse for the Claude session UUID inside stream-json
236 // events. The CLI emits a `"session_id":"<uuid>"` key on its init
237 // event; we just grep the chunk text so format drift doesn't break.
238 if (!sessionId) {
239 const m = chunk.match(/"session_id"\s*:\s*"([0-9a-f-]{32,36})"/i);
240 if (m) sessionId = m[1];
241 }
242 yield { chunk };
243 }
244 } finally {
245 clearTimeout(timer);
246 }
247
248 const { exitCode, stderr } = await handle.done;
249 yield {
250 done: {
251 exitCode,
252 durationMs: Date.now() - start,
253 claudeSessionId: sessionId,
254 stderr,
255 },
256 };
257}
258
259/**
260 * Env scrubbing for child subprocesses. Drops Postgres / SMTP / SSH
261 * creds the Claude binary doesn't need. Keeps ANTHROPIC_API_KEY, PATH,
262 * HOME, and the CLAUDE_* family.
263 */
264function passthroughEnv(): Record<string, string> {
265 const out: Record<string, string> = {};
266 const passlist = new Set([
267 "PATH",
268 "HOME",
269 "USER",
270 "LANG",
271 "LC_ALL",
272 "TERM",
273 "ANTHROPIC_API_KEY",
274 "ANTHROPIC_BASE_URL",
275 "ANTHROPIC_AUTH_TOKEN",
276 ]);
277 for (const [k, v] of Object.entries(process.env)) {
278 if (typeof v !== "string") continue;
279 if (passlist.has(k) || k.startsWith("CLAUDE_")) out[k] = v;
280 }
281 return out;
282}
283
284function scrubbedEnv(): Record<string, string> {
285 // For `git clone` we want even fewer — just PATH + HOME so git can
286 // find its config and ssh wrappers.
287 const out: Record<string, string> = {};
288 for (const k of ["PATH", "HOME", "USER", "LANG"]) {
289 if (typeof process.env[k] === "string") out[k] = process.env[k] as string;
290 }
291 // Disable any interactive prompt — clone should be non-interactive.
292 out.GIT_TERMINAL_PROMPT = "0";
293 return out;
294}
295
296// ---------------------------------------------------------------------------
297// Transcript persistence
298// ---------------------------------------------------------------------------
299
300export async function listMessages(sessionId: string) {
301 return db
302 .select()
303 .from(claudeWebMessages)
304 .where(eq(claudeWebMessages.sessionId, sessionId))
305 .orderBy(claudeWebMessages.createdAt);
306}
307
308export async function appendMessage(input: {
309 sessionId: string;
310 role: "user" | "assistant" | "system";
311 body: string;
312 exitCode?: number;
313 durationMs?: number;
314}): Promise<void> {
315 await db.insert(claudeWebMessages).values({
316 sessionId: input.sessionId,
317 role: input.role,
318 body: input.body,
319 exitCode: input.exitCode ?? null,
320 durationMs: input.durationMs ?? null,
321 });
322}
323
324export async function touchSession(input: {
325 sessionId: string;
326 claudeSessionId?: string;
327 status?: string;
328}): Promise<void> {
329 const set: Record<string, unknown> = { lastActiveAt: new Date() };
330 if (input.claudeSessionId) set.claudeSessionId = input.claudeSessionId;
331 if (input.status) set.status = input.status;
332 await db
333 .update(claudeWebSessions)
334 .set(set)
335 .where(eq(claudeWebSessions.id, input.sessionId));
336}
337
338export async function createSession(input: {
339 repositoryId: string;
340 ownerUserId: string;
341 title?: string;
342 branch?: string;
343}): Promise<ClaudeWebSession> {
344 // Insert first so we have the id for the workdir path.
345 const [row] = await db
346 .insert(claudeWebSessions)
347 .values({
348 repositoryId: input.repositoryId,
349 ownerUserId: input.ownerUserId,
350 title: input.title ?? "New session",
351 branch: input.branch ?? "main",
352 workdirPath: "pending",
353 status: "cold",
354 })
355 .returning();
356 if (!row) throw new Error("createSession: insert returned no row");
357 const workdir = sessionWorkdir(row.id);
358 const [updated] = await db
359 .update(claudeWebSessions)
360 .set({ workdirPath: workdir })
361 .where(eq(claudeWebSessions.id, row.id))
362 .returning();
363 return updated ?? { ...row, workdirPath: workdir };
364}
365
366export async function getSession(
367 id: string,
368 ownerUserId?: string
369): Promise<ClaudeWebSession | null> {
370 const conds = ownerUserId
371 ? and(
372 eq(claudeWebSessions.id, id),
373 eq(claudeWebSessions.ownerUserId, ownerUserId)
374 )
375 : eq(claudeWebSessions.id, id);
376 const [row] = await db
377 .select()
378 .from(claudeWebSessions)
379 .where(conds)
380 .limit(1);
381 return row ?? null;
382}
383
384export async function listSessionsForRepo(
385 repositoryId: string,
386 limit = 50
387): Promise<ClaudeWebSession[]> {
388 return db
389 .select()
390 .from(claudeWebSessions)
391 .where(eq(claudeWebSessions.repositoryId, repositoryId))
392 .limit(limit);
393}
394
395export async function deleteSession(id: string): Promise<void> {
396 await db.delete(claudeWebSessions).where(eq(claudeWebSessions.id, id));
397}
398
399/** Test-only access to internals. */
400export const __test = {
401 passthroughEnv,
402 scrubbedEnv,
403 MAX_TURN_MS,
404};
Addedsrc/lib/server-target-store.ts+338−0View fileUnifiedSplit
1/**
2 * DB-side helpers for server targets — exists to keep route + hook code
3 * thin and to centralise the audit-log call so every mutation logs.
4 */
5
6import { and, desc, eq } from "drizzle-orm";
7import { db } from "../db";
8import {
9 serverTargetAudit,
10 serverTargetDeployments,
11 serverTargetEnv,
12 serverTargets,
13 type ServerTarget,
14 type NewServerTarget,
15 type ServerTargetEnv,
16} from "../db/schema";
17import { decryptValue, encryptValue, isValidEnvName } from "./server-targets-crypto";
18
19export async function listTargets(): Promise<ServerTarget[]> {
20 return db
21 .select()
22 .from(serverTargets)
23 .orderBy(desc(serverTargets.createdAt));
24}
25
26export async function getTarget(
27 id: string
28): Promise<ServerTarget | null> {
29 const [row] = await db
30 .select()
31 .from(serverTargets)
32 .where(eq(serverTargets.id, id))
33 .limit(1);
34 return row ?? null;
35}
36
37export async function getTargetByName(
38 name: string
39): Promise<ServerTarget | null> {
40 const [row] = await db
41 .select()
42 .from(serverTargets)
43 .where(eq(serverTargets.name, name))
44 .limit(1);
45 return row ?? null;
46}
47
48export interface CreateTargetInput {
49 name: string;
50 host: string;
51 port?: number;
52 sshUser: string;
53 privateKey: string;
54 deployPath?: string;
55 deployScript?: string;
56 watchedRepositoryId?: string | null;
57 watchedBranch?: string | null;
58 createdBy: string;
59}
60
61export async function createTarget(
62 input: CreateTargetInput
63): Promise<{ ok: true; target: ServerTarget } | { ok: false; error: string }> {
64 const enc = encryptValue(input.privateKey);
65 if (!enc.ok) return { ok: false, error: enc.error };
66 const insert: NewServerTarget = {
67 name: input.name,
68 host: input.host,
69 port: input.port ?? 22,
70 sshUser: input.sshUser,
71 encryptedPrivateKey: enc.ciphertext,
72 deployPath: input.deployPath ?? "/var/www/app",
73 deployScript: input.deployScript ?? "bash deploy.sh",
74 watchedRepositoryId: input.watchedRepositoryId ?? null,
75 watchedBranch: input.watchedBranch ?? null,
76 createdBy: input.createdBy,
77 };
78 try {
79 const [row] = await db.insert(serverTargets).values(insert).returning();
80 if (!row) return { ok: false, error: "insert returned no row" };
81 await logAudit({
82 targetId: row.id,
83 actorId: input.createdBy,
84 action: "target.created",
85 detail: `${row.sshUser}@${row.host}:${row.port}`,
86 });
87 return { ok: true, target: row };
88 } catch (err) {
89 return {
90 ok: false,
91 error: err instanceof Error ? err.message : String(err),
92 };
93 }
94}
95
96export async function deleteTarget(
97 id: string,
98 actorId: string
99): Promise<void> {
100 await db.delete(serverTargets).where(eq(serverTargets.id, id));
101 await logAudit({
102 targetId: id,
103 actorId,
104 action: "target.deleted",
105 });
106}
107
108export async function recordPin(
109 targetId: string,
110 fingerprint: string,
111 actorId: string
112): Promise<void> {
113 await db
114 .update(serverTargets)
115 .set({
116 hostFingerprint: fingerprint,
117 status: "verified",
118 lastSeenAt: new Date(),
119 updatedAt: new Date(),
120 })
121 .where(eq(serverTargets.id, targetId));
122 await logAudit({
123 targetId,
124 actorId,
125 action: "target.fingerprint_pinned",
126 detail: fingerprint,
127 });
128}
129
130// --- env vars ---------------------------------------------------------------
131
132export async function listEnv(targetId: string): Promise<ServerTargetEnv[]> {
133 return db
134 .select()
135 .from(serverTargetEnv)
136 .where(eq(serverTargetEnv.targetId, targetId))
137 .orderBy(serverTargetEnv.name);
138}
139
140/**
141 * Decrypt the env vars for a target into a KEY→value map. Skips rows whose
142 * ciphertext fails to decrypt (logged) — a corrupt row should not abort a
143 * deploy, but the missing var becomes obvious in the run log.
144 */
145export async function resolveEnv(
146 targetId: string
147): Promise<Record<string, string>> {
148 const rows = await listEnv(targetId);
149 const out: Record<string, string> = {};
150 for (const row of rows) {
151 const dec = decryptValue(row.encryptedValue);
152 if (dec.ok) out[row.name] = dec.plaintext;
153 else console.warn(`[server-targets] decrypt env ${row.name}: ${dec.error}`);
154 }
155 return out;
156}
157
158export async function upsertEnv(input: {
159 targetId: string;
160 name: string;
161 value: string;
162 isSecret?: boolean;
163 actorId: string;
164}): Promise<{ ok: true } | { ok: false; error: string }> {
165 if (!isValidEnvName(input.name)) {
166 return {
167 ok: false,
168 error: "name must match /^[A-Z_][A-Z0-9_]*$/ (uppercase, digits, _)",
169 };
170 }
171 const enc = encryptValue(input.value);
172 if (!enc.ok) return { ok: false, error: enc.error };
173 const now = new Date();
174 try {
175 const [existing] = await db
176 .select()
177 .from(serverTargetEnv)
178 .where(
179 and(
180 eq(serverTargetEnv.targetId, input.targetId),
181 eq(serverTargetEnv.name, input.name)
182 )
183 )
184 .limit(1);
185 if (existing) {
186 await db
187 .update(serverTargetEnv)
188 .set({
189 encryptedValue: enc.ciphertext,
190 isSecret: input.isSecret ?? existing.isSecret,
191 updatedBy: input.actorId,
192 updatedAt: now,
193 })
194 .where(eq(serverTargetEnv.id, existing.id));
195 } else {
196 await db.insert(serverTargetEnv).values({
197 targetId: input.targetId,
198 name: input.name,
199 encryptedValue: enc.ciphertext,
200 isSecret: input.isSecret ?? true,
201 updatedBy: input.actorId,
202 });
203 }
204 await logAudit({
205 targetId: input.targetId,
206 actorId: input.actorId,
207 action: "env.upserted",
208 detail: input.name,
209 });
210 return { ok: true };
211 } catch (err) {
212 return {
213 ok: false,
214 error: err instanceof Error ? err.message : String(err),
215 };
216 }
217}
218
219export async function deleteEnv(input: {
220 targetId: string;
221 name: string;
222 actorId: string;
223}): Promise<void> {
224 await db
225 .delete(serverTargetEnv)
226 .where(
227 and(
228 eq(serverTargetEnv.targetId, input.targetId),
229 eq(serverTargetEnv.name, input.name)
230 )
231 );
232 await logAudit({
233 targetId: input.targetId,
234 actorId: input.actorId,
235 action: "env.deleted",
236 detail: input.name,
237 });
238}
239
240// --- deployments + audit ----------------------------------------------------
241
242export interface RecordDeployInput {
243 targetId: string;
244 commitSha?: string | null;
245 ref?: string | null;
246 triggeredBy?: string | null;
247 triggerSource: "push" | "manual";
248}
249
250export async function startDeployRow(
251 input: RecordDeployInput
252): Promise<string | null> {
253 try {
254 const [row] = await db
255 .insert(serverTargetDeployments)
256 .values({
257 targetId: input.targetId,
258 commitSha: input.commitSha ?? null,
259 ref: input.ref ?? null,
260 triggeredBy: input.triggeredBy ?? null,
261 triggerSource: input.triggerSource,
262 status: "running",
263 })
264 .returning();
265 return row?.id ?? null;
266 } catch {
267 return null;
268 }
269}
270
271export async function finishDeployRow(input: {
272 id: string;
273 exitCode: number;
274 stdout: string;
275 stderr: string;
276}): Promise<void> {
277 try {
278 await db
279 .update(serverTargetDeployments)
280 .set({
281 status: input.exitCode === 0 ? "success" : "failed",
282 exitCode: input.exitCode,
283 stdout: input.stdout.slice(0, 1_000_000),
284 stderr: input.stderr.slice(0, 1_000_000),
285 finishedAt: new Date(),
286 })
287 .where(eq(serverTargetDeployments.id, input.id));
288 } catch {
289 /* ignore */
290 }
291}
292
293export async function recentDeploys(
294 targetId: string,
295 limit = 20
296): Promise<Array<typeof serverTargetDeployments.$inferSelect>> {
297 return db
298 .select()
299 .from(serverTargetDeployments)
300 .where(eq(serverTargetDeployments.targetId, targetId))
301 .orderBy(desc(serverTargetDeployments.startedAt))
302 .limit(limit);
303}
304
305export async function logAudit(input: {
306 targetId?: string | null;
307 actorId?: string | null;
308 action: string;
309 detail?: string | null;
310 ip?: string | null;
311}): Promise<void> {
312 try {
313 await db.insert(serverTargetAudit).values({
314 targetId: input.targetId ?? null,
315 actorId: input.actorId ?? null,
316 action: input.action,
317 detail: input.detail ?? null,
318 ip: input.ip ?? null,
319 });
320 } catch {
321 /* never fail the caller because of audit */
322 }
323}
324
325export async function findTargetsForPush(input: {
326 repositoryId: string;
327 branch: string;
328}): Promise<ServerTarget[]> {
329 return db
330 .select()
331 .from(serverTargets)
332 .where(
333 and(
334 eq(serverTargets.watchedRepositoryId, input.repositoryId),
335 eq(serverTargets.watchedBranch, input.branch)
336 )
337 );
338}
Addedsrc/lib/server-targets-crypto.ts+140−0View fileUnifiedSplit
1/**
2 * Server-target crypto primitives (no I/O, no DB).
3 *
4 * Mirrors `workflow-secrets-crypto.ts` — AES-256-GCM under a 32-byte master
5 * key sourced from `SERVER_TARGETS_KEY` (hex). Used for two ciphertext
6 * columns:
7 * - `server_targets.encrypted_private_key` (the SSH private key)
8 * - `server_target_env.encrypted_value` (per-target env var values)
9 *
10 * Both are addressed through the same primitives because they share the
11 * same threat model: an attacker who reads the DB without the master key
12 * cannot connect to or impersonate the target.
13 *
14 * Every fn returns `{ok:true,...}` / `{ok:false,error}` — never throws.
15 */
16
17import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
18
19const ALGO = "aes-256-gcm";
20const IV_LEN = 12;
21const TAG_LEN = 16;
22const KEY_LEN = 32;
23
24export function getMasterKey(): Buffer | null {
25 const hex = process.env.SERVER_TARGETS_KEY;
26 if (!hex) return null;
27 const trimmed = hex.trim();
28 if (!/^[0-9a-fA-F]+$/.test(trimmed)) return null;
29 let buf: Buffer;
30 try {
31 buf = Buffer.from(trimmed, "hex");
32 } catch {
33 return null;
34 }
35 if (buf.length !== KEY_LEN) return null;
36 return buf;
37}
38
39export function encryptValue(
40 plaintext: string
41): { ok: true; ciphertext: string } | { ok: false; error: string } {
42 const key = getMasterKey();
43 if (!key) {
44 return {
45 ok: false,
46 error:
47 "SERVER_TARGETS_KEY missing or not a 32-byte hex value (64 hex chars)",
48 };
49 }
50 if (typeof plaintext !== "string") {
51 return { ok: false, error: "plaintext must be a string" };
52 }
53 try {
54 const iv = randomBytes(IV_LEN);
55 const cipher = createCipheriv(ALGO, key, iv);
56 const enc = Buffer.concat([
57 cipher.update(plaintext, "utf8"),
58 cipher.final(),
59 ]);
60 const tag = cipher.getAuthTag();
61 if (tag.length !== TAG_LEN) {
62 return { ok: false, error: "unexpected auth tag length" };
63 }
64 const blob = Buffer.concat([iv, tag, enc]);
65 return { ok: true, ciphertext: blob.toString("base64") };
66 } catch (err) {
67 return {
68 ok: false,
69 error: `encrypt failed: ${err instanceof Error ? err.message : String(err)}`,
70 };
71 }
72}
73
74export function decryptValue(
75 ciphertext: string
76): { ok: true; plaintext: string } | { ok: false; error: string } {
77 const key = getMasterKey();
78 if (!key) {
79 return {
80 ok: false,
81 error:
82 "SERVER_TARGETS_KEY missing or not a 32-byte hex value (64 hex chars)",
83 };
84 }
85 if (typeof ciphertext !== "string" || ciphertext.length === 0) {
86 return { ok: false, error: "ciphertext must be a non-empty string" };
87 }
88 let blob: Buffer;
89 try {
90 blob = Buffer.from(ciphertext, "base64");
91 } catch {
92 return { ok: false, error: "ciphertext is not valid base64" };
93 }
94 if (blob.length < IV_LEN + TAG_LEN) {
95 return { ok: false, error: "ciphertext blob too short" };
96 }
97 const iv = blob.subarray(0, IV_LEN);
98 const tag = blob.subarray(IV_LEN, IV_LEN + TAG_LEN);
99 const enc = blob.subarray(IV_LEN + TAG_LEN);
100 try {
101 const decipher = createDecipheriv(ALGO, key, iv);
102 decipher.setAuthTag(tag);
103 const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
104 return { ok: true, plaintext: dec.toString("utf8") };
105 } catch (err) {
106 return {
107 ok: false,
108 error: `decrypt failed: ${err instanceof Error ? err.message : String(err)}`,
109 };
110 }
111}
112
113/**
114 * Validate an env var name. Same rule as POSIX-ish env: leading
115 * letter/underscore, then letters/digits/underscores. We're a little
116 * stricter than POSIX in that we require uppercase + digits + underscore
117 * so KEY=value lines in the materialised .env file are predictable.
118 */
119export function isValidEnvName(name: unknown): name is string {
120 return typeof name === "string" && /^[A-Z_][A-Z0-9_]*$/.test(name);
121}
122
123/**
124 * Render an env-vars map as a `.env` file body — `KEY=value\n` lines with
125 * values single-quoted and embedded single quotes escaped. This matches the
126 * common `set -a; source /path/to/file; set +a` deploy-script pattern and
127 * is what `materializeEnv` produces before scp'ing it to the box.
128 */
129export function renderDotenv(env: Record<string, string>): string {
130 const keys = Object.keys(env).sort();
131 return (
132 keys
133 .map((k) => {
134 const v = env[k] ?? "";
135 const quoted = "'" + v.replace(/'/g, "'\\''") + "'";
136 return `${k}=${quoted}`;
137 })
138 .join("\n") + (keys.length ? "\n" : "")
139 );
140}
Addedsrc/lib/server-targets.ts+339−0View fileUnifiedSplit
1/**
2 * Server-target driver (Block ST).
3 *
4 * Drives the SSH/scp subprocess pipeline that takes a `server_targets` row
5 * and either tests the connection or runs a deploy on the box. All process
6 * spawning is funnelled through a single injectable seam (`__setSpawnForTests`)
7 * so the test suite can drive the lib without actually shelling out.
8 *
9 * Design notes:
10 * - We use the host's `ssh`/`scp` CLI rather than a Node SSH library. It
11 * keeps the surface tiny (no new deps) and lets the operator manage
12 * ciphers/algorithms via /etc/ssh/ssh_config like any other server tool.
13 * - The private key is materialised into a Bun temp file with mode 0600,
14 * used for the call, then unlinked in a `finally`. Never touches /tmp
15 * persistently and never sits on disk longer than the call.
16 * - Host-key verification uses TOFU: on first connect (status='unverified'
17 * and no host_fingerprint) we accept-new and record the fingerprint.
18 * Subsequent connects pin against that fingerprint. A mismatch aborts.
19 *
20 * Public surface:
21 * - `testConnection(target)` → returns ok/fail + pinned fingerprint
22 * - `deployToTarget(target, ctx)`→ uploads env, runs deploy_script
23 * - `materializeEnv(env)` → render env-vars map to a .env body
24 */
25
26import { mkdtemp, writeFile, rm } from "fs/promises";
27import { tmpdir } from "os";
28import path from "path";
29import { decryptValue, renderDotenv } from "./server-targets-crypto";
30import type { ServerTarget } from "../db/schema";
31
32export interface SpawnResult {
33 exitCode: number;
34 stdout: string;
35 stderr: string;
36}
37
38/**
39 * Subprocess seam. Production calls `Bun.spawn`; tests override.
40 *
41 * Returns the captured stdout/stderr + exit code. `stdin` is optional; when
42 * provided, it's written and closed before the process is awaited so we can
43 * pipe a .env body in without a temp file.
44 */
45export type SpawnFn = (
46 cmd: string[],
47 opts: { cwd?: string; stdin?: string; env?: Record<string, string> }
48) => Promise<SpawnResult>;
49
50const defaultSpawn: SpawnFn = async (cmd, opts) => {
51 const proc = Bun.spawn(cmd, {
52 cwd: opts.cwd,
53 env: { ...process.env, ...(opts.env || {}) },
54 stdin: opts.stdin ? "pipe" : "ignore",
55 stdout: "pipe",
56 stderr: "pipe",
57 });
58 if (opts.stdin) {
59 proc.stdin?.write(opts.stdin);
60 proc.stdin?.end();
61 }
62 const [stdout, stderr] = await Promise.all([
63 new Response(proc.stdout).text(),
64 new Response(proc.stderr).text(),
65 ]);
66 const exitCode = await proc.exited;
67 return { exitCode, stdout, stderr };
68};
69
70let _spawn: SpawnFn = defaultSpawn;
71export function __setSpawnForTests(fn: SpawnFn | null): void {
72 _spawn = fn ?? defaultSpawn;
73}
74
75/**
76 * Run a fn with the decrypted SSH private key written to a 0600 file in a
77 * private temp dir. The dir is rm -rf'd in `finally`, no matter what.
78 */
79async function withKeyFile<T>(
80 encryptedKey: string,
81 fn: (keyPath: string) => Promise<T>
82): Promise<T> {
83 const dec = decryptValue(encryptedKey);
84 if (!dec.ok) {
85 throw new Error(`decrypt private key: ${dec.error}`);
86 }
87 const dir = await mkdtemp(path.join(tmpdir(), "gluecron-st-"));
88 const keyPath = path.join(dir, "id");
89 await writeFile(keyPath, dec.plaintext, { mode: 0o600 });
90 try {
91 return await fn(keyPath);
92 } finally {
93 await rm(dir, { recursive: true, force: true }).catch(() => {});
94 }
95}
96
97/**
98 * Build the common `ssh` arg list. On a target with no pinned fingerprint we
99 * pass `accept-new`; once pinned we require strict host-key checking via the
100 * known-hosts file we materialise alongside the key.
101 *
102 * Returns the args up to (but not including) the remote command — the
103 * caller appends `[user@host, ...cmd]` or scp paths.
104 */
105function sshBaseArgs(target: ServerTarget, keyPath: string): string[] {
106 const knownHostsArg = target.hostFingerprint
107 ? // Pinned: hand ssh a known_hosts line via -o KnownHostsCommand-ish
108 // bypass — easier route is a temp file passed via UserKnownHostsFile.
109 // We can't easily inline a fingerprint without the full key, so we
110 // fall back to strict checking against a per-target known_hosts file
111 // written next to the key. With no host_fingerprint we accept-new.
112 ["-o", "StrictHostKeyChecking=yes"]
113 : ["-o", "StrictHostKeyChecking=accept-new"];
114 return [
115 "-i",
116 keyPath,
117 "-o",
118 "BatchMode=yes",
119 "-o",
120 "ConnectTimeout=10",
121 "-o",
122 `UserKnownHostsFile=${path.join(path.dirname(keyPath), "known_hosts")}`,
123 ...knownHostsArg,
124 "-p",
125 String(target.port),
126 ];
127}
128
129/**
130 * Test connectivity. Returns the host fingerprint (SHA256 of the host key)
131 * captured via `ssh-keyscan` so the caller can pin it on the row.
132 *
133 * Flow:
134 * 1. ssh-keyscan -p <port> <host> → host key text
135 * 2. ssh-keygen -lf <keyfile> → SHA256:<fp> fingerprint
136 * 3. ssh -i <key> user@host 'echo gluecron' → confirms key works
137 *
138 * Any step's non-zero exit collapses to {ok:false}. Never throws.
139 */
140export async function testConnection(
141 target: ServerTarget
142): Promise<
143 | { ok: true; fingerprint: string }
144 | { ok: false; error: string; stage: "scan" | "fingerprint" | "auth" }
145> {
146 try {
147 return await withKeyFile(target.encryptedPrivateKey, async (keyPath) => {
148 const dir = path.dirname(keyPath);
149 const scan = await _spawn(
150 ["ssh-keyscan", "-T", "5", "-p", String(target.port), target.host],
151 {}
152 );
153 if (scan.exitCode !== 0 || !scan.stdout.trim()) {
154 return {
155 ok: false as const,
156 error: scan.stderr.trim() || "ssh-keyscan returned nothing",
157 stage: "scan" as const,
158 };
159 }
160 const knownHostsPath = path.join(dir, "known_hosts");
161 await writeFile(knownHostsPath, scan.stdout);
162
163 const fp = await _spawn(["ssh-keygen", "-lf", knownHostsPath], {});
164 if (fp.exitCode !== 0) {
165 return {
166 ok: false as const,
167 error: fp.stderr.trim() || "ssh-keygen failed",
168 stage: "fingerprint" as const,
169 };
170 }
171 // Output format: "<bits> SHA256:<base64> comment (TYPE)"
172 const match = fp.stdout.match(/SHA256:[A-Za-z0-9+/=]+/);
173 const fingerprint = match ? match[0] : fp.stdout.trim().split(/\s+/)[1] || "";
174
175 const probe = await _spawn(
176 [
177 "ssh",
178 ...sshBaseArgs(target, keyPath),
179 `${target.sshUser}@${target.host}`,
180 "echo gluecron-ok",
181 ],
182 {}
183 );
184 if (probe.exitCode !== 0 || !probe.stdout.includes("gluecron-ok")) {
185 return {
186 ok: false as const,
187 error: probe.stderr.trim() || "ssh probe failed",
188 stage: "auth" as const,
189 };
190 }
191 return { ok: true as const, fingerprint };
192 });
193 } catch (err) {
194 return {
195 ok: false,
196 error: err instanceof Error ? err.message : String(err),
197 stage: "auth",
198 };
199 }
200}
201
202export interface DeployContext {
203 commitSha?: string;
204 ref?: string;
205 /** Decrypted env-var map (KEY → value). Render to .env before upload. */
206 env: Record<string, string>;
207}
208
209export interface DeployResult {
210 ok: boolean;
211 exitCode: number;
212 stdout: string;
213 stderr: string;
214}
215
216/**
217 * Run the deploy on the target box.
218 *
219 * Sequence:
220 * 1. Write env map → /tmp/gluecron-<rand>.env (locally), scp to
221 * `<deploy_path>/.env.gluecron` on the box (0600 via umask).
222 * 2. ssh user@host 'cd <deploy_path> && set -a && . ./.env.gluecron \
223 * && set +a && <deploy_script>'
224 * 3. Capture stdout+stderr+exit and return.
225 *
226 * Never throws — every failure is folded into the DeployResult with the
227 * stderr line that explains why. Caller is responsible for inserting the
228 * `server_target_deployments` row.
229 */
230export async function deployToTarget(
231 target: ServerTarget,
232 ctx: DeployContext
233): Promise<DeployResult> {
234 try {
235 return await withKeyFile(target.encryptedPrivateKey, async (keyPath) => {
236 const dir = path.dirname(keyPath);
237
238 // Materialise known_hosts up front so we can run ssh in strict mode
239 // for a target that's already been pinned.
240 if (target.hostFingerprint) {
241 // We don't have the raw host key, only the fingerprint, so we
242 // re-scan and verify the fingerprint matches before writing the
243 // known_hosts file. A mismatch is a hard abort.
244 const scan = await _spawn(
245 ["ssh-keyscan", "-T", "5", "-p", String(target.port), target.host],
246 {}
247 );
248 if (scan.exitCode !== 0 || !scan.stdout.trim()) {
249 return {
250 ok: false,
251 exitCode: scan.exitCode,
252 stdout: "",
253 stderr: `ssh-keyscan: ${scan.stderr.trim() || "no host key returned"}`,
254 };
255 }
256 const knownHostsPath = path.join(dir, "known_hosts");
257 await writeFile(knownHostsPath, scan.stdout);
258 const fp = await _spawn(["ssh-keygen", "-lf", knownHostsPath], {});
259 const live = (fp.stdout.match(/SHA256:[A-Za-z0-9+/=]+/) || [""])[0];
260 if (!live || live !== target.hostFingerprint) {
261 return {
262 ok: false,
263 exitCode: 255,
264 stdout: "",
265 stderr: `host key fingerprint mismatch — pinned=${target.hostFingerprint} live=${live || "<none>"}. Aborting deploy.`,
266 };
267 }
268 }
269
270 const dotenv = renderDotenv(ctx.env);
271 const envPath = path.join(dir, "deploy.env");
272 await writeFile(envPath, dotenv, { mode: 0o600 });
273
274 // scp the .env up. The remote path is fixed for predictability.
275 const remoteEnv = `${target.deployPath.replace(/\/$/, "")}/.env.gluecron`;
276 const scp = await _spawn(
277 [
278 "scp",
279 ...sshBaseArgs(target, keyPath),
280 envPath,
281 `${target.sshUser}@${target.host}:${remoteEnv}`,
282 ],
283 {}
284 );
285 if (scp.exitCode !== 0) {
286 return {
287 ok: false,
288 exitCode: scp.exitCode,
289 stdout: scp.stdout,
290 stderr: `scp env: ${scp.stderr.trim()}`,
291 };
292 }
293
294 const commitExport = ctx.commitSha
295 ? `export GLUECRON_COMMIT_SHA='${ctx.commitSha.replace(/'/g, "")}'; `
296 : "";
297 const refExport = ctx.ref
298 ? `export GLUECRON_REF='${ctx.ref.replace(/'/g, "")}'; `
299 : "";
300 const remoteCmd =
301 `cd '${target.deployPath.replace(/'/g, "'\\''")}' && ` +
302 `set -a && . ./.env.gluecron && set +a && ` +
303 commitExport +
304 refExport +
305 target.deployScript;
306
307 const run = await _spawn(
308 [
309 "ssh",
310 ...sshBaseArgs(target, keyPath),
311 `${target.sshUser}@${target.host}`,
312 remoteCmd,
313 ],
314 {}
315 );
316 return {
317 ok: run.exitCode === 0,
318 exitCode: run.exitCode,
319 stdout: run.stdout,
320 stderr: run.stderr,
321 };
322 });
323 } catch (err) {
324 return {
325 ok: false,
326 exitCode: -1,
327 stdout: "",
328 stderr: err instanceof Error ? err.message : String(err),
329 };
330 }
331}
332
333export { renderDotenv } from "./server-targets-crypto";
334
335/** Test-only access to internal seams. */
336export const __test = {
337 sshBaseArgs,
338 withKeyFile,
339};
Addedsrc/routes/admin-server-targets.tsx+528−0View fileUnifiedSplit
1/**
2 * Block ST — Admin server targets UI + mutation endpoints.
3 *
4 * GET /admin/servers — list targets
5 * GET /admin/servers/new — new-target form
6 * POST /admin/servers — create
7 * GET /admin/servers/:id — detail (env vars + recent deploys)
8 * POST /admin/servers/:id/env — upsert an env var
9 * POST /admin/servers/:id/env/:name/delete — delete an env var
10 * POST /admin/servers/:id/test — test connection (pin fingerprint)
11 * POST /admin/servers/:id/deploy — manual deploy
12 * POST /admin/servers/:id/delete — delete target
13 *
14 * v1 is site-admin only. Customer scoping arrives in a follow-up block.
15 */
16
17import { Hono } from "hono";
18import { eq } from "drizzle-orm";
19import { db } from "../db";
20import { repositories, users } from "../db/schema";
21import { Layout } from "../views/layout";
22import { softAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24import { isSiteAdmin } from "../lib/admin";
25import {
26 createTarget,
27 deleteEnv,
28 deleteTarget,
29 finishDeployRow,
30 getTarget,
31 listEnv,
32 listTargets,
33 recentDeploys,
34 recordPin,
35 resolveEnv,
36 startDeployRow,
37 upsertEnv,
38} from "../lib/server-target-store";
39import { deployToTarget, testConnection } from "../lib/server-targets";
40
41const admin = new Hono<AuthEnv>();
42admin.use("*", softAuth);
43
44async function gate(c: any): Promise<{ userId: string } | Response> {
45 const user = c.get("user");
46 if (!user) return c.redirect("/login?next=/admin/servers");
47 if (!(await isSiteAdmin(user.id))) {
48 return c.html(
49 <Layout title="Forbidden" user={user}>
50 <main style="max-width:640px;margin:40px auto;padding:0 20px">
51 <h1>403 — site admin required</h1>
52 <p>The server-targets section is admin-only in v1.</p>
53 </main>
54 </Layout>,
55 403
56 );
57 }
58 return { userId: user.id };
59}
60
61function flash(msg: string | undefined, kind: "ok" | "err"): any {
62 if (!msg) return null;
63 const bg = kind === "ok" ? "#0f2a18" : "#2a1212";
64 const border = kind === "ok" ? "#1f5a32" : "#5a1f1f";
65 const fg = kind === "ok" ? "#7fe1a3" : "#ffb4b4";
66 return (
67 <div
68 style={`background:${bg};border:1px solid ${border};color:${fg};padding:10px 14px;border-radius:8px;margin-bottom:16px;font-size:14px`}
69 >
70 {msg}
71 </div>
72 );
73}
74
75const wrap =
76 "max-width:980px;margin:32px auto;padding:0 20px;color:#e5e7eb;font-family:system-ui,sans-serif";
77const card =
78 "background:#0e1117;border:1px solid #1f2937;border-radius:10px;padding:18px 20px;margin-bottom:18px";
79const inputStyle =
80 "width:100%;background:#0b0e13;border:1px solid #1f2937;color:#e5e7eb;padding:8px 10px;border-radius:6px;font-family:ui-monospace,monospace";
81const btn =
82 "background:#1f6feb;border:0;color:#fff;padding:8px 14px;border-radius:6px;cursor:pointer;font-size:14px";
83const btnDanger =
84 "background:#a02020;border:0;color:#fff;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:13px";
85const btnSecondary =
86 "background:#1f2937;border:0;color:#e5e7eb;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:13px";
87const label = "display:block;font-size:13px;color:#9ca3af;margin:10px 0 4px";
88
89// ─── GET /admin/servers ─────────────────────────────────────────────────────
90
91admin.get("/admin/servers", async (c) => {
92 const g = await gate(c);
93 if (g instanceof Response) return g;
94 const user = c.get("user")!;
95 const targets = await listTargets();
96 const ok = c.req.query("ok") ?? undefined;
97 const err = c.req.query("err") ?? undefined;
98
99 return c.html(
100 <Layout title="Server targets — admin" user={user}>
101 <main style={wrap}>
102 <h1 style="margin:0 0 6px;font-size:24px">Server targets</h1>
103 <p style="margin:0 0 20px;color:#9ca3af;font-size:14px">
104 Boxes Gluecron can SSH into. Admin-only. A push to a watched
105 branch fires the target's deploy script with its env vars
106 materialised on the box.
107 </p>
108 {flash(ok, "ok")}
109 {flash(err, "err")}
110
111 <div style="margin-bottom:16px">
112 <a href="/admin/servers/new" style={btn + ";text-decoration:none"}>
113 + New target
114 </a>
115 </div>
116
117 <div style={card}>
118 {targets.length === 0 ? (
119 <p style="color:#9ca3af;margin:0">
120 No targets yet. Add your first box.
121 </p>
122 ) : (
123 <table style="width:100%;border-collapse:collapse;font-size:14px">
124 <thead>
125 <tr style="text-align:left;color:#9ca3af;border-bottom:1px solid #1f2937">
126 <th style="padding:8px 6px">Name</th>
127 <th style="padding:8px 6px">Host</th>
128 <th style="padding:8px 6px">Watch</th>
129 <th style="padding:8px 6px">Status</th>
130 <th style="padding:8px 6px;text-align:right"></th>
131 </tr>
132 </thead>
133 <tbody>
134 {targets.map((t) => (
135 <tr style="border-bottom:1px solid #1f2937">
136 <td style="padding:10px 6px">
137 <a
138 href={`/admin/servers/${t.id}`}
139 style="color:#7aa2f7;text-decoration:none;font-weight:600"
140 >
141 {t.name}
142 </a>
143 </td>
144 <td style="padding:10px 6px;font-family:ui-monospace,monospace;color:#cbd5e1">
145 {t.sshUser}@{t.host}:{t.port}
146 </td>
147 <td style="padding:10px 6px;color:#9ca3af;font-size:13px">
148 {t.watchedRepositoryId && t.watchedBranch
149 ? `${t.watchedBranch}`
150 : "—"}
151 </td>
152 <td style="padding:10px 6px">
153 <span
154 style={
155 "padding:2px 8px;border-radius:99px;font-size:12px;" +
156 (t.status === "verified"
157 ? "background:#0f2a18;color:#7fe1a3"
158 : "background:#2a2410;color:#e1c47f")
159 }
160 >
161 {t.status}
162 </span>
163 </td>
164 <td style="padding:10px 6px;text-align:right">
165 <a href={`/admin/servers/${t.id}`} style="color:#9ca3af;font-size:13px;text-decoration:none">
166 open →
167 </a>
168 </td>
169 </tr>
170 ))}
171 </tbody>
172 </table>
173 )}
174 </div>
175 </main>
176 </Layout>
177 );
178});
179
180// ─── GET /admin/servers/new ─────────────────────────────────────────────────
181
182admin.get("/admin/servers/new", async (c) => {
183 const g = await gate(c);
184 if (g instanceof Response) return g;
185 const user = c.get("user")!;
186 const repos = await db
187 .select({
188 id: repositories.id,
189 name: repositories.name,
190 ownerName: users.username,
191 })
192 .from(repositories)
193 .innerJoin(users, eq(repositories.ownerId, users.id))
194 .limit(200);
195
196 return c.html(
197 <Layout title="New server target" user={user}>
198 <main style={wrap}>
199 <p style="margin:0 0 6px"><a href="/admin/servers" style="color:#9ca3af;text-decoration:none">← Server targets</a></p>
200 <h1 style="margin:0 0 16px;font-size:22px">New server target</h1>
201 <form method="post" action="/admin/servers" style={card}>
202 <label style={label}>Name (unique identifier)</label>
203 <input name="name" required pattern="[a-z0-9-]+" placeholder="crontech-prod-1" style={inputStyle} />
204
205 <label style={label}>Host</label>
206 <input name="host" required placeholder="1.2.3.4 or box.crontech.ai" style={inputStyle} />
207
208 <div style="display:flex;gap:14px">
209 <div style="flex:1">
210 <label style={label}>SSH user</label>
211 <input name="ssh_user" required placeholder="deploy" style={inputStyle} />
212 </div>
213 <div style="width:120px">
214 <label style={label}>Port</label>
215 <input name="port" type="number" value="22" style={inputStyle} />
216 </div>
217 </div>
218
219 <label style={label}>Private SSH key (OpenSSH PEM)</label>
220 <textarea
221 name="private_key"
222 required
223 rows={8}
224 placeholder="-----BEGIN OPENSSH PRIVATE KEY-----..."
225 style={inputStyle + ";font-size:12px"}
226 />
227 <p style="color:#6b7280;font-size:12px;margin:6px 0 0">
228 Encrypted at rest with <code>SERVER_TARGETS_KEY</code>. Never
229 displayed again after save.
230 </p>
231
232 <label style={label}>Deploy path on the box</label>
233 <input name="deploy_path" value="/var/www/app" style={inputStyle} />
234
235 <label style={label}>Deploy script</label>
236 <input name="deploy_script" value="bash deploy.sh" style={inputStyle} />
237 <p style="color:#6b7280;font-size:12px;margin:6px 0 0">
238 Runs inside the deploy path. <code>./.env.gluecron</code> is
239 sourced first so all env vars are available.
240 </p>
241
242 <div style="display:flex;gap:14px;margin-top:6px">
243 <div style="flex:1">
244 <label style={label}>Watched repo (optional)</label>
245 <select name="watched_repository_id" style={inputStyle}>
246 <option value="">— none —</option>
247 {repos.map((r) => (
248 <option value={r.id}>{r.ownerName}/{r.name}</option>
249 ))}
250 </select>
251 </div>
252 <div style="flex:1">
253 <label style={label}>Watched branch</label>
254 <input name="watched_branch" placeholder="main" style={inputStyle} />
255 </div>
256 </div>
257
258 <div style="margin-top:18px">
259 <button type="submit" style={btn}>Create target</button>
260 </div>
261 </form>
262 </main>
263 </Layout>
264 );
265});
266
267// ─── POST /admin/servers ────────────────────────────────────────────────────
268
269admin.post("/admin/servers", async (c) => {
270 const g = await gate(c);
271 if (g instanceof Response) return g;
272 const form = await c.req.parseBody();
273 const name = String(form.name || "").trim();
274 const host = String(form.host || "").trim();
275 const sshUser = String(form.ssh_user || "").trim();
276 const port = Number(form.port || 22);
277 const privateKey = String(form.private_key || "");
278 const deployPath = String(form.deploy_path || "/var/www/app").trim();
279 const deployScript = String(form.deploy_script || "bash deploy.sh");
280 const watchedRepositoryId = String(form.watched_repository_id || "") || null;
281 const watchedBranch = String(form.watched_branch || "").trim() || null;
282
283 if (!name || !host || !sshUser || !privateKey) {
284 return c.redirect("/admin/servers?err=missing+required+fields");
285 }
286 if (!/^[a-z0-9-]+$/.test(name)) {
287 return c.redirect("/admin/servers?err=name+must+be+lowercase+alphanumeric+with+dashes");
288 }
289
290 const out = await createTarget({
291 name,
292 host,
293 port,
294 sshUser,
295 privateKey,
296 deployPath,
297 deployScript,
298 watchedRepositoryId,
299 watchedBranch,
300 createdBy: g.userId,
301 });
302 if (!out.ok) {
303 return c.redirect(`/admin/servers?err=${encodeURIComponent(out.error)}`);
304 }
305 return c.redirect(`/admin/servers/${out.target.id}?ok=created`);
306});
307
308// ─── GET /admin/servers/:id ─────────────────────────────────────────────────
309
310admin.get("/admin/servers/:id", async (c) => {
311 const g = await gate(c);
312 if (g instanceof Response) return g;
313 const user = c.get("user")!;
314 const id = c.req.param("id");
315 const target = await getTarget(id);
316 if (!target) return c.notFound();
317
318 const envRows = await listEnv(id);
319 const deploys = await recentDeploys(id, 10);
320 const ok = c.req.query("ok") ?? undefined;
321 const err = c.req.query("err") ?? undefined;
322
323 return c.html(
324 <Layout title={`${target.name} — server target`} user={user}>
325 <main style={wrap}>
326 <p style="margin:0 0 6px"><a href="/admin/servers" style="color:#9ca3af;text-decoration:none">← Server targets</a></p>
327 <h1 style="margin:0 0 4px;font-size:22px">{target.name}</h1>
328 <p style="margin:0 0 18px;color:#9ca3af;font-size:14px;font-family:ui-monospace,monospace">
329 {target.sshUser}@{target.host}:{target.port} · {target.deployPath}
330 </p>
331 {flash(ok, "ok")}
332 {flash(err, "err")}
333
334 {/* Connection / status */}
335 <div style={card}>
336 <h2 style="margin:0 0 10px;font-size:16px">Connection</h2>
337 <p style="margin:0 0 10px;font-size:14px">
338 Status: <strong>{target.status}</strong>
339 {target.hostFingerprint && (
340 <span style="color:#9ca3af;font-family:ui-monospace,monospace;font-size:12px">
341 {" "}· pinned {target.hostFingerprint}
342 </span>
343 )}
344 </p>
345 <form method="post" action={`/admin/servers/${target.id}/test`} style="display:inline-block;margin-right:8px">
346 <button type="submit" style={btnSecondary}>Test connection</button>
347 </form>
348 <form method="post" action={`/admin/servers/${target.id}/deploy`} style="display:inline-block;margin-right:8px">
349 <button type="submit" style={btn}>Deploy now</button>
350 </form>
351 <form method="post" action={`/admin/servers/${target.id}/delete`} style="display:inline-block;float:right" onsubmit="return confirm('Delete this target?')">
352 <button type="submit" style={btnDanger}>Delete</button>
353 </form>
354 </div>
355
356 {/* Env vars */}
357 <div style={card}>
358 <h2 style="margin:0 0 10px;font-size:16px">Environment variables</h2>
359 <p style="margin:0 0 12px;color:#9ca3af;font-size:13px">
360 Encrypted at rest. Materialised as <code>./.env.gluecron</code>
361 on the box and sourced before the deploy script runs.
362 </p>
363
364 {envRows.length === 0 ? (
365 <p style="color:#6b7280;font-size:14px;margin:0 0 12px">No env vars yet.</p>
366 ) : (
367 <table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:12px">
368 <tbody>
369 {envRows.map((row) => (
370 <tr style="border-bottom:1px solid #1f2937">
371 <td style="padding:8px 6px;font-family:ui-monospace,monospace;color:#cbd5e1;width:35%">{row.name}</td>
372 <td style="padding:8px 6px;font-family:ui-monospace,monospace;color:#6b7280">
373 {row.isSecret ? "••••••••" : "(non-secret)"}
374 </td>
375 <td style="padding:8px 6px;color:#6b7280;font-size:12px">
376 {row.isSecret ? "secret" : "value"}
377 </td>
378 <td style="padding:8px 6px;text-align:right">
379 <form method="post" action={`/admin/servers/${target.id}/env/${encodeURIComponent(row.name)}/delete`} style="display:inline">
380 <button type="submit" style={btnDanger}>Delete</button>
381 </form>
382 </td>
383 </tr>
384 ))}
385 </tbody>
386 </table>
387 )}
388
389 <form method="post" action={`/admin/servers/${target.id}/env`} style="display:grid;grid-template-columns:1fr 2fr auto;gap:8px;align-items:center">
390 <input name="name" placeholder="VAR_NAME" required pattern="[A-Z_][A-Z0-9_]*" style={inputStyle} />
391 <input name="value" placeholder="value" required style={inputStyle} />
392 <button type="submit" style={btn}>Save</button>
393 <label style="grid-column:1/-1;font-size:12px;color:#9ca3af;display:flex;gap:6px;align-items:center;margin-top:2px">
394 <input type="checkbox" name="is_secret" value="1" checked />
395 Treat as secret (mask in UI)
396 </label>
397 </form>
398 </div>
399
400 {/* Recent deploys */}
401 <div style={card}>
402 <h2 style="margin:0 0 10px;font-size:16px">Recent deploys</h2>
403 {deploys.length === 0 ? (
404 <p style="color:#6b7280;font-size:14px;margin:0">No deploys yet.</p>
405 ) : (
406 <table style="width:100%;border-collapse:collapse;font-size:13px">
407 <thead>
408 <tr style="text-align:left;color:#9ca3af;border-bottom:1px solid #1f2937">
409 <th style="padding:6px">When</th>
410 <th style="padding:6px">Commit</th>
411 <th style="padding:6px">Source</th>
412 <th style="padding:6px">Status</th>
413 </tr>
414 </thead>
415 <tbody>
416 {deploys.map((d) => (
417 <tr style="border-bottom:1px solid #1f2937">
418 <td style="padding:6px;color:#9ca3af">{d.startedAt.toISOString()}</td>
419 <td style="padding:6px;font-family:ui-monospace,monospace">{d.commitSha?.slice(0, 7) ?? "—"}</td>
420 <td style="padding:6px;color:#9ca3af">{d.triggerSource}</td>
421 <td style={`padding:6px;color:${d.status === "success" ? "#7fe1a3" : d.status === "failed" ? "#ffb4b4" : "#e1c47f"}`}>
422 {d.status} {d.exitCode != null ? `(${d.exitCode})` : ""}
423 </td>
424 </tr>
425 ))}
426 </tbody>
427 </table>
428 )}
429 </div>
430 </main>
431 </Layout>
432 );
433});
434
435// ─── POST /admin/servers/:id/env ────────────────────────────────────────────
436
437admin.post("/admin/servers/:id/env", async (c) => {
438 const g = await gate(c);
439 if (g instanceof Response) return g;
440 const id = c.req.param("id");
441 const target = await getTarget(id);
442 if (!target) return c.notFound();
443 const form = await c.req.parseBody();
444 const name = String(form.name || "").trim();
445 const value = String(form.value || "");
446 const isSecret = !!form.is_secret;
447 const out = await upsertEnv({
448 targetId: id,
449 name,
450 value,
451 isSecret,
452 actorId: g.userId,
453 });
454 if (!out.ok) {
455 return c.redirect(`/admin/servers/${id}?err=${encodeURIComponent(out.error)}`);
456 }
457 return c.redirect(`/admin/servers/${id}?ok=saved+${encodeURIComponent(name)}`);
458});
459
460admin.post("/admin/servers/:id/env/:name/delete", async (c) => {
461 const g = await gate(c);
462 if (g instanceof Response) return g;
463 const id = c.req.param("id");
464 const name = c.req.param("name");
465 await deleteEnv({ targetId: id, name, actorId: g.userId });
466 return c.redirect(`/admin/servers/${id}?ok=deleted+${encodeURIComponent(name)}`);
467});
468
469// ─── POST /admin/servers/:id/test ───────────────────────────────────────────
470
471admin.post("/admin/servers/:id/test", async (c) => {
472 const g = await gate(c);
473 if (g instanceof Response) return g;
474 const id = c.req.param("id");
475 const target = await getTarget(id);
476 if (!target) return c.notFound();
477 const result = await testConnection(target);
478 if (result.ok) {
479 await recordPin(id, result.fingerprint, g.userId);
480 return c.redirect(`/admin/servers/${id}?ok=connection+verified`);
481 }
482 return c.redirect(
483 `/admin/servers/${id}?err=${encodeURIComponent(`${result.stage}: ${result.error}`)}`
484 );
485});
486
487// ─── POST /admin/servers/:id/deploy ─────────────────────────────────────────
488
489admin.post("/admin/servers/:id/deploy", async (c) => {
490 const g = await gate(c);
491 if (g instanceof Response) return g;
492 const id = c.req.param("id");
493 const target = await getTarget(id);
494 if (!target) return c.notFound();
495 const env = await resolveEnv(id);
496 const deployId = await startDeployRow({
497 targetId: id,
498 triggeredBy: g.userId,
499 triggerSource: "manual",
500 });
501 const result = await deployToTarget(target, { env });
502 if (deployId) {
503 await finishDeployRow({
504 id: deployId,
505 exitCode: result.exitCode,
506 stdout: result.stdout,
507 stderr: result.stderr,
508 });
509 }
510 if (result.ok) {
511 return c.redirect(`/admin/servers/${id}?ok=deploy+succeeded`);
512 }
513 return c.redirect(
514 `/admin/servers/${id}?err=${encodeURIComponent(`deploy exit ${result.exitCode}: ${result.stderr.slice(0, 200)}`)}`
515 );
516});
517
518// ─── POST /admin/servers/:id/delete ─────────────────────────────────────────
519
520admin.post("/admin/servers/:id/delete", async (c) => {
521 const g = await gate(c);
522 if (g instanceof Response) return g;
523 const id = c.req.param("id");
524 await deleteTarget(id, g.userId);
525 return c.redirect("/admin/servers?ok=deleted");
526});
527
528export default admin;
Addedsrc/routes/claude-web.tsx+394−0View fileUnifiedSplit
1/**
2 * Block CW — Claude on the web.
3 *
4 * GET /:owner/:repo/claude — session list + new-session form
5 * POST /:owner/:repo/claude — create session, redirect to detail
6 * GET /:owner/:repo/claude/:sessionId — chat UI (messages + composer)
7 * GET /:owner/:repo/claude/:sessionId/stream — SSE stream of a single turn
8 * POST /:owner/:repo/claude/:sessionId/delete — delete session
9 *
10 * v1 admin-only. The page renders server-side with a small inline
11 * EventSource client that POSTs nothing — the SSE GET is parameterised
12 * by `?prompt=...` so iPad keyboards (no JS fetch issues) just need to
13 * follow a link the form submits to. The endpoint persists the user
14 * message before opening the stream, so a flaky network mid-stream
15 * still leaves the question in the transcript.
16 */
17
18import { Hono } from "hono";
19import { and, eq } from "drizzle-orm";
20import { db } from "../db";
21import { repositories, users } from "../db/schema";
22import { Layout } from "../views/layout";
23import { softAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25import { isSiteAdmin } from "../lib/admin";
26import {
27 appendMessage,
28 createSession,
29 deleteSession,
30 ensureWorkdir,
31 getSession,
32 listMessages,
33 listSessionsForRepo,
34 runTurn,
35 touchSession,
36} from "../lib/claude-web-session";
37
38const claudeWeb = new Hono<AuthEnv>();
39claudeWeb.use("*", softAuth);
40
41async function gate(
42 c: any
43): Promise<{ userId: string; repoId: string; ownerName: string; repoName: string } | Response> {
44 const user = c.get("user");
45 if (!user) {
46 const target = encodeURIComponent(c.req.url);
47 return c.redirect(`/login?next=${target}`);
48 }
49 if (!(await isSiteAdmin(user.id))) {
50 return c.html(
51 <Layout title="Forbidden" user={user}>
52 <main style="max-width:640px;margin:40px auto;padding:0 20px">
53 <h1>403 — site admin required</h1>
54 <p>Claude on the web is admin-only in v1.</p>
55 </main>
56 </Layout>,
57 403
58 );
59 }
60 const ownerName = c.req.param("owner");
61 const repoName = c.req.param("repo");
62 const [row] = await db
63 .select({ id: repositories.id })
64 .from(repositories)
65 .innerJoin(users, eq(repositories.ownerId, users.id))
66 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
67 .limit(1);
68 if (!row) return c.notFound();
69 return { userId: user.id, repoId: row.id, ownerName, repoName };
70}
71
72const wrap =
73 "max-width:980px;margin:24px auto;padding:0 16px;color:#e5e7eb;font-family:system-ui,sans-serif";
74const card =
75 "background:#0e1117;border:1px solid #1f2937;border-radius:10px;padding:16px 18px;margin-bottom:14px";
76const inputStyle =
77 "width:100%;background:#0b0e13;border:1px solid #1f2937;color:#e5e7eb;padding:10px 12px;border-radius:6px;font-size:16px";
78const btn =
79 "background:#1f6feb;border:0;color:#fff;padding:10px 16px;border-radius:6px;cursor:pointer;font-size:15px";
80
81// ─── GET /:owner/:repo/claude ───────────────────────────────────────────────
82
83claudeWeb.get("/:owner/:repo/claude", async (c) => {
84 const g = await gate(c);
85 if (g instanceof Response) return g;
86 const user = c.get("user")!;
87 const sessions = await listSessionsForRepo(g.repoId);
88
89 return c.html(
90 <Layout title={`Claude — ${g.ownerName}/${g.repoName}`} user={user}>
91 <main style={wrap}>
92 <p style="margin:0 0 6px">
93 <a href={`/${g.ownerName}/${g.repoName}`} style="color:#9ca3af;text-decoration:none">
94 ← {g.ownerName}/{g.repoName}
95 </a>
96 </p>
97 <h1 style="margin:0 0 6px;font-size:22px">Claude on this repo</h1>
98 <p style="margin:0 0 18px;color:#9ca3af;font-size:14px">
99 iPad-friendly Claude Code sessions on a per-session clone of the repo.
100 </p>
101
102 <form method="post" action={`/${g.ownerName}/${g.repoName}/claude`} style={card}>
103 <label style="display:block;font-size:13px;color:#9ca3af;margin-bottom:6px">
104 New session title
105 </label>
106 <div style="display:flex;gap:8px">
107 <input name="title" placeholder="What you want to work on" style={inputStyle} />
108 <input name="branch" placeholder="main" style={inputStyle + ";max-width:160px"} />
109 <button type="submit" style={btn}>Start</button>
110 </div>
111 </form>
112
113 <div style={card}>
114 <h2 style="margin:0 0 10px;font-size:15px;color:#cbd5e1">Sessions</h2>
115 {sessions.length === 0 ? (
116 <p style="color:#6b7280;margin:0;font-size:14px">None yet.</p>
117 ) : (
118 <ul style="list-style:none;padding:0;margin:0">
119 {sessions.map((s) => (
120 <li style="padding:10px 0;border-top:1px solid #1f2937;display:flex;justify-content:space-between;align-items:center">
121 <a
122 href={`/${g.ownerName}/${g.repoName}/claude/${s.id}`}
123 style="color:#7aa2f7;text-decoration:none;font-weight:600"
124 >
125 {s.title}
126 </a>
127 <span style="color:#6b7280;font-size:12px;font-family:ui-monospace,monospace">
128 {s.branch} · {s.status}
129 </span>
130 </li>
131 ))}
132 </ul>
133 )}
134 </div>
135 </main>
136 </Layout>
137 );
138});
139
140// ─── POST /:owner/:repo/claude ──────────────────────────────────────────────
141
142claudeWeb.post("/:owner/:repo/claude", async (c) => {
143 const g = await gate(c);
144 if (g instanceof Response) return g;
145 const form = await c.req.parseBody();
146 const title = String(form.title || "").trim() || "New session";
147 const branch = String(form.branch || "").trim() || "main";
148 const session = await createSession({
149 repositoryId: g.repoId,
150 ownerUserId: g.userId,
151 title,
152 branch,
153 });
154 return c.redirect(`/${g.ownerName}/${g.repoName}/claude/${session.id}`);
155});
156
157// ─── GET /:owner/:repo/claude/:sessionId ────────────────────────────────────
158
159claudeWeb.get("/:owner/:repo/claude/:sessionId", async (c) => {
160 const g = await gate(c);
161 if (g instanceof Response) return g;
162 const user = c.get("user")!;
163 const sessionId = c.req.param("sessionId");
164 const session = await getSession(sessionId, g.userId);
165 if (!session || session.repositoryId !== g.repoId) return c.notFound();
166
167 const messages = await listMessages(sessionId);
168
169 // Inline SSE client. ~30 lines of vanilla JS — keeps the bundle empty.
170 const clientJs = `
171 (function() {
172 var f = document.getElementById('cw-composer');
173 if (!f) return;
174 f.addEventListener('submit', function(ev) {
175 ev.preventDefault();
176 var input = document.getElementById('cw-prompt');
177 var prompt = (input.value || '').trim();
178 if (!prompt) return;
179 input.value = '';
180 var list = document.getElementById('cw-messages');
181 var u = document.createElement('div');
182 u.className = 'cw-msg cw-msg-user';
183 u.textContent = prompt;
184 list.appendChild(u);
185 var a = document.createElement('pre');
186 a.className = 'cw-msg cw-msg-assistant';
187 a.textContent = '';
188 list.appendChild(a);
189 var url = ${JSON.stringify(`/${g.ownerName}/${g.repoName}/claude/${sessionId}/stream`)} + '?prompt=' + encodeURIComponent(prompt);
190 var es = new EventSource(url);
191 es.addEventListener('chunk', function(e) {
192 a.textContent += e.data;
193 window.scrollTo(0, document.body.scrollHeight);
194 });
195 es.addEventListener('done', function() {
196 es.close();
197 });
198 es.addEventListener('error', function() {
199 a.textContent += '\\n[stream error — reload to retry]';
200 es.close();
201 });
202 });
203 })();
204 `;
205
206 const styleCss = `
207 .cw-msg { padding:10px 12px;border-radius:8px;margin:8px 0;white-space:pre-wrap;word-wrap:break-word;font-family:ui-monospace,monospace;font-size:14px;line-height:1.5; }
208 .cw-msg-user { background:#172033;color:#e5e7eb;border:1px solid #1f6feb55; }
209 .cw-msg-assistant { background:#0e1117;color:#cbd5e1;border:1px solid #1f2937; }
210 .cw-msg-system { background:#2a1f10;color:#e1c47f;border:1px solid #5a4a1f;font-size:13px; }
211 `;
212
213 return c.html(
214 <Layout title={`${session.title} — Claude`} user={user}>
215 <main style={wrap}>
216 <p style="margin:0 0 6px">
217 <a href={`/${g.ownerName}/${g.repoName}/claude`} style="color:#9ca3af;text-decoration:none">
218 ← Claude sessions
219 </a>
220 </p>
221 <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px">
222 <h1 style="margin:0;font-size:20px">{session.title}</h1>
223 <span style="color:#6b7280;font-size:12px;font-family:ui-monospace,monospace">
224 {session.branch} · {session.status}
225 </span>
226 </div>
227 <style dangerouslySetInnerHTML={{ __html: styleCss }} />
228
229 <div id="cw-messages" style="margin-bottom:14px">
230 {messages.length === 0 ? (
231 <p style="color:#6b7280;font-size:14px">No turns yet. Ask Claude something below.</p>
232 ) : (
233 messages.map((m) => (
234 <div
235 class={"cw-msg cw-msg-" + m.role}
236 data-role={m.role}
237 >
238 {m.body}
239 </div>
240 ))
241 )}
242 </div>
243
244 <form id="cw-composer" style={card}>
245 <textarea
246 id="cw-prompt"
247 name="prompt"
248 placeholder="Ask Claude..."
249 rows={3}
250 style={inputStyle}
251 />
252 <div style="margin-top:8px;display:flex;justify-content:space-between;align-items:center">
253 <span style="color:#6b7280;font-size:12px">
254 Streams over SSE. Long turns cap at 5 minutes.
255 </span>
256 <button type="submit" style={btn}>Send</button>
257 </div>
258 </form>
259
260 <form
261 method="post"
262 action={`/${g.ownerName}/${g.repoName}/claude/${session.id}/delete`}
263 style="text-align:right;margin-top:12px"
264 onsubmit="return confirm('Delete this session and its transcript?')"
265 >
266 <button type="submit" style="background:#a02020;border:0;color:#fff;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:13px">
267 Delete session
268 </button>
269 </form>
270
271 <script dangerouslySetInnerHTML={{ __html: clientJs }} />
272 </main>
273 </Layout>
274 );
275});
276
277// ─── GET /:owner/:repo/claude/:sessionId/stream ─────────────────────────────
278
279claudeWeb.get("/:owner/:repo/claude/:sessionId/stream", async (c) => {
280 const g = await gate(c);
281 if (g instanceof Response) return g;
282 const sessionId = c.req.param("sessionId");
283 const session = await getSession(sessionId, g.userId);
284 if (!session || session.repositoryId !== g.repoId) return c.notFound();
285 const prompt = (c.req.query("prompt") || "").slice(0, 16_000);
286 if (!prompt.trim()) {
287 return c.text("missing prompt", 400);
288 }
289
290 // Persist the user turn *before* we start the stream so a network blip
291 // doesn't lose the question.
292 await appendMessage({ sessionId, role: "user", body: prompt });
293 await touchSession({ sessionId, status: "running" });
294
295 // Lazily clone the workdir on first turn.
296 const workdir = await ensureWorkdir(session, g.ownerName, g.repoName);
297 if (!workdir.ok) {
298 await appendMessage({
299 sessionId,
300 role: "system",
301 body: `workdir error: ${workdir.error}`,
302 });
303 await touchSession({ sessionId, status: "failed" });
304 return c.text(`workdir: ${workdir.error}`, 500);
305 }
306
307 const stream = new ReadableStream<Uint8Array>({
308 async start(controller) {
309 const enc = new TextEncoder();
310 let assistantBody = "";
311 let finalExit = 0;
312 let finalDuration = 0;
313 let finalClaudeId: string | undefined;
314
315 function send(event: string, data: string) {
316 controller.enqueue(enc.encode(`event: ${event}\n`));
317 // Split data on newlines so we never break the SSE wire format.
318 for (const line of data.split("\n")) {
319 controller.enqueue(enc.encode(`data: ${line}\n`));
320 }
321 controller.enqueue(enc.encode(`\n`));
322 }
323
324 try {
325 for await (const ev of runTurn({
326 session,
327 ownerName: g.ownerName,
328 repoName: g.repoName,
329 prompt,
330 })) {
331 if (ev.chunk) {
332 assistantBody += ev.chunk;
333 send("chunk", ev.chunk);
334 }
335 if (ev.done) {
336 finalExit = ev.done.exitCode;
337 finalDuration = ev.done.durationMs;
338 finalClaudeId = ev.done.claudeSessionId;
339 if (ev.done.stderr && ev.done.exitCode !== 0) {
340 assistantBody += `\n[stderr] ${ev.done.stderr}`;
341 send("chunk", `\n[stderr] ${ev.done.stderr}`);
342 }
343 send("done", String(ev.done.exitCode));
344 }
345 }
346 } catch (err) {
347 const msg = err instanceof Error ? err.message : String(err);
348 assistantBody += `\n[exception] ${msg}`;
349 send("error", msg);
350 } finally {
351 try {
352 await appendMessage({
353 sessionId,
354 role: "assistant",
355 body: assistantBody,
356 exitCode: finalExit,
357 durationMs: finalDuration,
358 });
359 await touchSession({
360 sessionId,
361 claudeSessionId: finalClaudeId,
362 status: finalExit === 0 ? "ready" : "failed",
363 });
364 } catch {
365 /* persistence errors don't break the response */
366 }
367 controller.close();
368 }
369 },
370 });
371
372 return new Response(stream, {
373 headers: {
374 "Content-Type": "text/event-stream; charset=utf-8",
375 "Cache-Control": "no-cache, no-transform",
376 "Connection": "keep-alive",
377 "X-Accel-Buffering": "no",
378 },
379 });
380});
381
382// ─── POST /:owner/:repo/claude/:sessionId/delete ────────────────────────────
383
384claudeWeb.post("/:owner/:repo/claude/:sessionId/delete", async (c) => {
385 const g = await gate(c);
386 if (g instanceof Response) return g;
387 const sessionId = c.req.param("sessionId");
388 const session = await getSession(sessionId, g.userId);
389 if (!session || session.repositoryId !== g.repoId) return c.notFound();
390 await deleteSession(sessionId);
391 return c.redirect(`/${g.ownerName}/${g.repoName}/claude`);
392});
393
394export default claudeWeb;
0395