@spunto/build 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1932 @@
1
+ import { AVAILABLE_FEATURES } from "../catalogs/index"
2
+ import { codeServerGallery, parseGallery, registryInfo } from "../extensions/extension-registry"
3
+ import {
4
+ EXTENSIONS_DIR,
5
+ FEATURE_ENTRYPOINTS_FILE,
6
+ FEATURE_ENTRYPOINTS_STAGING_FILE,
7
+ workerStatusPath,
8
+ } from "../naming/index"
9
+ import { stepEndLine, stepStartLine, type BuildStep, type BuildStepKind } from "../steps/build-steps"
10
+ import type { SetupStatus } from "../types/index"
11
+ import { localFeatureScript } from "./local-features"
12
+
13
+ // ─── Internal helpers ──────────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Base64 of a string's UTF-8 bytes — what `b64(s)` produced before this
17
+ * file left Node behind.
18
+ *
19
+ * The two-step dance is not decoration: `btoa` takes a *binary string*, one code unit per byte, and
20
+ * throws above U+00FF. Handed a JavaScript string directly it would reject an `é` in a postCreate
21
+ * command, and a surrogate pair would encode to bytes no decoder reads back. These payloads are
22
+ * `base64 -d`'d inside the container, so a wrong byte is a broken worker, not a mojibake.
23
+ */
24
+ function b64(value: string): string {
25
+ const bytes = new TextEncoder().encode(value)
26
+ let binary = ""
27
+ for (const byte of bytes) binary += String.fromCharCode(byte)
28
+ return btoa(binary)
29
+ }
30
+
31
+
32
+ /** Single-quotes a value for safe embedding in a shell command. Survives being
33
+ * nested inside a double-quoted `su -c "..."` context, unlike double quotes. */
34
+ function shQuote(v: string): string {
35
+ return `'${v.replace(/'/g, `'\\''`)}'`
36
+ }
37
+
38
+ /**
39
+ * `export EXTENSIONS_GALLERY=…`, or nothing when the org is on the default (Open VSX) registry.
40
+ *
41
+ * Emitted into the generated scripts rather than relied on from the ambient environment: a
42
+ * `docker build` doesn't inherit the daemon's env at all, and the code-server loop runs under `su`,
43
+ * which is free to strip it. A blob we couldn't parse emits nothing — see lib/extension-registry.ts:
44
+ * handing code-server a gallery the picker didn't search would recreate the split-brain the org
45
+ * setting exists to close.
46
+ */
47
+ function galleryExport(extensionsGallery?: string | null): string[] {
48
+ const forward = codeServerGallery(extensionsGallery)
49
+ return forward ? [`export EXTENSIONS_GALLERY=${shQuote(forward)}`] : []
50
+ }
51
+
52
+ /** Registry name, flattened to something safe to echo from a shell script. */
53
+ function registryLabel(extensionsGallery?: string | null): string {
54
+ return registryInfo(parseGallery(extensionsGallery)).name.replace(/[^\w .:/-]/g, "") || "the configured registry"
55
+ }
56
+
57
+ /** Produces an `env KEY=val ...` prefix for injecting secrets into a single command. */
58
+ function envPrefix(secrets?: Record<string, string>): string {
59
+ if (!secrets || Object.keys(secrets).length === 0) return ""
60
+ const pairs = Object.entries(secrets).map(([k, v]) => `${k}=${shQuote(v)}`).join(" ")
61
+ return `env ${pairs} `
62
+ }
63
+
64
+ /** A project repository, reduced to what the clone step needs. */
65
+ type CloneRepo = {
66
+ provider: string
67
+ project: string
68
+ workspacePath: string
69
+ cloneUrl?: string
70
+ /** Project-level default branch for this repo (optional). */
71
+ branch?: string
72
+ }
73
+
74
+ /**
75
+ * Branch actually checked out for a repo: the worker-level choice (made at creation, applies to
76
+ * every repo of the project) wins over the project-level default carried by the repository itself.
77
+ * Empty/blank on both sides → undefined = clone the remote's default branch (HEAD), the historical
78
+ * behaviour.
79
+ */
80
+ export function resolveRepoBranch(repo: { branch?: string | null }, workerBranch?: string | null): string | undefined {
81
+ return workerBranch?.trim() || repo.branch?.trim() || undefined
82
+ }
83
+
84
+ /**
85
+ * Clone block for one repository — shared by the legacy script and the orchestrated plan (RFC 0017)
86
+ * so the two generators can't drift apart. Returns the `--- Cloning … ---` header line separately
87
+ * because the legacy path stamps its phase status between the header and the block.
88
+ *
89
+ * Clone strategy, in order of preference:
90
+ * 0. Generic "git" repo (RFC 0013): clone the raw cloneUrl using the per-project deploy key.
91
+ * Works for any SSH host (StrictHostKeyChecking off); GIT_SSH_COMMAND is a no-op for HTTPS.
92
+ * 1. Org GitHub App installation token (RFC 0012): clone over HTTPS x-access-token for
93
+ * org-scoped read access, then rewrite the remote to SSH so push/pull use the user's
94
+ * personal key — and the token never persists in .git/config.
95
+ * 2. User personal SSH key (legacy / fallback when org has no installation).
96
+ * 3. Plain HTTPS (public repos only).
97
+ *
98
+ * When a branch is requested, `git clone --branch` posts it directly (it also accepts a tag). A ref
99
+ * that doesn't exist makes git fail immediately ("Remote branch … not found in upstream origin")
100
+ * without leaving a half-cloned workspace: we turn that into an explicit message + `exit 1` so the
101
+ * setup stops in `error` instead of silently landing on the default branch.
102
+ */
103
+ function cloneRepoBlock(params: {
104
+ repo: CloneRepo
105
+ index: number
106
+ total: number
107
+ homeDir: string
108
+ branch?: string
109
+ githubInstallationTokens?: Record<string, string>
110
+ userSshPrivateKey?: string
111
+ }): { header: string; lines: string[] } {
112
+ const { repo: r, index, total, homeDir, branch, githubInstallationTokens, userSshPrivateKey } = params
113
+ // Pick the installation token for this repo's owner. A GitHub App installation only grants
114
+ // access to repos owned by its account, so the owner login (== installation accountLogin)
115
+ // uniquely selects the right token among the org's connected installations. Covers legacy
116
+ // repos with no stored installationId too, since resolution is by owner, not by id.
117
+ const owner = (r.project.split("/")[0] ?? "").toLowerCase()
118
+ const repoToken = githubInstallationTokens?.[owner]
119
+ const b = branch ? ` --branch ${shQuote(branch)}` : ""
120
+ const cloneCmd = r.provider === "git" && r.cloneUrl
121
+ ? `GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ${homeDir}/.ssh/mp_deploy_key" git clone${b} ${shQuote(r.cloneUrl)} /workspace/${r.workspacePath}`
122
+ : r.provider === "github" && repoToken
123
+ ? `git clone${b} https://x-access-token:${repoToken}@github.com/${r.project}.git /workspace/${r.workspacePath} && git -C /workspace/${r.workspacePath} remote set-url origin git@github.com:${r.project}.git`
124
+ : r.provider === "github" && userSshPrivateKey
125
+ ? `GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ${homeDir}/.ssh/mp_user_key" git clone${b} git@github.com:${r.project}.git /workspace/${r.workspacePath}`
126
+ : `git clone${b} https://github.com/${r.project} /workspace/${r.workspacePath}`
127
+
128
+ // Single-quoted only when a (user-provided) branch is interpolated; the plain form is kept
129
+ // verbatim for the no-branch case so the generated script is unchanged there.
130
+ const header = branch
131
+ ? `echo ${shQuote(`--- Cloning ${r.project} (branch ${branch}) (${index + 1}/${total}) ---`)}`
132
+ : `echo "--- Cloning ${r.project} (${index + 1}/${total}) ---"`
133
+ // Single quotes only: this message ends up inside the JSON status written by the failure trap.
134
+ const failMsg = `Clone of ${r.project} failed — branch '${branch}' does not exist on the remote (or the repo is unreachable)`
135
+ const lines = [
136
+ `if [ ! -d "/workspace/${r.workspacePath}/.git" ]; then`,
137
+ ...(branch
138
+ ? [
139
+ // set +e around the clone so the message below is emitted before the script aborts —
140
+ // `set -e` alone would kill the shell on the failing clone with only git's own output.
141
+ ` set +e`,
142
+ ` ${cloneCmd}`,
143
+ ` _CLONE_EXIT=$?`,
144
+ ` set -e`,
145
+ ` if [ $_CLONE_EXIT -ne 0 ]; then`,
146
+ // Two readers for the same message, so both setup paths show it instead of the generic
147
+ // "phase failed": the legacy failure trap reads MP_FAIL_MSG, the agent (RFC 0017) reads
148
+ // the file after a non-zero phase (the phase's own output goes to PID 1, not to the exec).
149
+ ` MP_FAIL_MSG=${shQuote(failMsg)}`,
150
+ ` printf '%s' "$MP_FAIL_MSG" > ${homeDir}/.mp-fail-msg 2>/dev/null || true`,
151
+ ` echo "[setup] ✗ $MP_FAIL_MSG" >&2`,
152
+ ` exit 1`,
153
+ ` fi`,
154
+ ]
155
+ : [` ${cloneCmd}`]),
156
+ `else`,
157
+ ` echo "${r.project}: already present, skipping clone"`,
158
+ `fi`,
159
+ ]
160
+ return { header, lines }
161
+ }
162
+
163
+ /**
164
+ * Git identity + SSH credentials written into the worker's home — shared by the legacy script and
165
+ * the orchestrated plan (RFC 0017) so the two generators can't drift apart.
166
+ *
167
+ * Everything here runs as root (both setup paths do), so every file it creates lands root-owned.
168
+ * The block therefore ends by handing `~/.ssh` back to the worker's user: ssh SILENTLY IGNORES a
169
+ * `~/.ssh/config` it can't read (no error, exit 0 — it just falls back to the default identity
170
+ * files), so a root-owned config doesn't break loudly, it makes every `git pull`/`push` over SSH
171
+ * fail authentication for no visible reason. The legacy path used to be saved by its final
172
+ * `chown -R` (step 8), which the orchestrated plan has no equivalent of; chowning right here fixes
173
+ * both, and also makes the credentials usable by everything that runs as the user *during* setup
174
+ * (postCreateCommand, dotfiles install script).
175
+ */
176
+ function credentialsBlock(params: {
177
+ homeDir: string
178
+ username: string
179
+ userInfo?: { name: string; email?: string | null }
180
+ userSshPrivateKey?: string
181
+ projectDeployKey?: string
182
+ }): string[] {
183
+ const { homeDir, username, userInfo, userSshPrivateKey, projectDeployKey } = params
184
+ const lines: string[] = []
185
+
186
+ if (userInfo) {
187
+ lines.push(`git config --global user.name ${JSON.stringify(userInfo.name)}`)
188
+ if (userInfo.email) lines.push(`git config --global user.email ${JSON.stringify(userInfo.email)}`)
189
+ }
190
+
191
+ if (userSshPrivateKey) {
192
+ lines.push(
193
+ 'echo "Configuring user SSH key..."',
194
+ `mkdir -p ${homeDir}/.ssh`,
195
+ `printf '%b' ${JSON.stringify(userSshPrivateKey)} > ${homeDir}/.ssh/mp_user_key`,
196
+ `chmod 600 ${homeDir}/.ssh/mp_user_key`,
197
+ `grep -qF "mp_user_key" ${homeDir}/.ssh/config 2>/dev/null || printf 'Host *\\n IdentityFile ${homeDir}/.ssh/mp_user_key\\n StrictHostKeyChecking no\\n IdentitiesOnly yes\\n' >> ${homeDir}/.ssh/config`,
198
+ `chmod 600 ${homeDir}/.ssh/config`,
199
+ 'echo "User SSH key configured"',
200
+ )
201
+ }
202
+
203
+ // Per-project deploy key (RFC 0013) — used only for generic "git" repos, referenced per-clone
204
+ // via GIT_SSH_COMMAND (not added to ~/.ssh/config, so it never shadows the user's own key).
205
+ if (projectDeployKey) {
206
+ lines.push(
207
+ 'echo "Configuring project deploy key..."',
208
+ `mkdir -p ${homeDir}/.ssh`,
209
+ `printf '%b' ${JSON.stringify(projectDeployKey)} > ${homeDir}/.ssh/mp_deploy_key`,
210
+ `chmod 600 ${homeDir}/.ssh/mp_deploy_key`,
211
+ 'echo "Project deploy key configured"',
212
+ )
213
+ }
214
+
215
+ // See the block comment: root-written credentials are worthless to the user they're for.
216
+ if (userSshPrivateKey || projectDeployKey) {
217
+ lines.push(
218
+ `chmod 700 ${homeDir}/.ssh`,
219
+ `chown -R ${username}:${username} ${homeDir}/.ssh`,
220
+ )
221
+ }
222
+
223
+ return lines
224
+ }
225
+
226
+ type RS = { name: string; state: "pending" | "cloning" | "done" | "error" }
227
+ type LS = "pending" | "running" | "done" | "error" | null
228
+
229
+ function mkStatus(
230
+ phase: SetupStatus["phase"],
231
+ repos: RS[],
232
+ postCreate: LS,
233
+ postStart: LS,
234
+ ): string {
235
+ return JSON.stringify({ phase, repos, postCreate, postStart } satisfies SetupStatus)
236
+ }
237
+
238
+ function banner(title: string): string[] {
239
+ return [
240
+ "",
241
+ `echo "###########################################################"`,
242
+ `echo "### ${title}"`,
243
+ `echo "###########################################################"`,
244
+ ]
245
+ }
246
+
247
+ function buildFeatureInstallScript(ociRef: string, options?: Record<string, string>): string {
248
+ const match = ociRef.match(/^([^/]+)\/(.+):(.+)$/)
249
+ if (!match) return `echo "[feature] Invalid OCI ref: ${ociRef}"`
250
+ const [, registry, repo, tag] = match
251
+ const featureId = repo.split("/").pop()
252
+
253
+ const lines = [
254
+ `echo "[feature] Installing ${featureId}..."`,
255
+ `_FEAT_DIR=$(mktemp -d)`,
256
+ `echo "[feature] ${featureId}: fetching OCI token..."`,
257
+ `_FEAT_TOKEN=$(curl -fsSL "https://${registry}/token?scope=repository:${repo}:pull" | grep -o '"token":"[^"]*"' | cut -d'"' -f4)`,
258
+ `echo "[feature] ${featureId}: fetching manifest..."`,
259
+ `_FEAT_MANIFEST=$(curl -fsSL -H "Authorization: Bearer $_FEAT_TOKEN" -H "Accept: application/vnd.oci.image.manifest.v1+json" "https://${registry}/v2/${repo}/manifests/${tag}")`,
260
+ `_FEAT_DIGEST=$(echo "$_FEAT_MANIFEST" | grep -o '"digest":"sha256:[^"]*"' | tail -1 | cut -d'"' -f4)`,
261
+ `echo "[feature] ${featureId}: downloading layer $_FEAT_DIGEST..."`,
262
+ `curl -fsSL -H "Authorization: Bearer $_FEAT_TOKEN" "https://${registry}/v2/${repo}/blobs/$_FEAT_DIGEST" -o "$_FEAT_DIR/feature.tar"`,
263
+ `echo "[feature] ${featureId}: extracting $(wc -c < "$_FEAT_DIR/feature.tar") bytes..."`,
264
+ `(tar xzf "$_FEAT_DIR/feature.tar" -C "$_FEAT_DIR" 2>/dev/null || tar xf "$_FEAT_DIR/feature.tar" -C "$_FEAT_DIR")`,
265
+ `rm -f "$_FEAT_DIR/feature.tar"`,
266
+ `echo "[feature] ${featureId}: files extracted: $(ls "$_FEAT_DIR" | tr '\\n' ' ')"`,
267
+ `cd "$_FEAT_DIR"`,
268
+ ]
269
+
270
+ if (options) {
271
+ for (const [k, v] of Object.entries(options)) {
272
+ lines.push(`export ${k.toUpperCase()}=${JSON.stringify(v)}`)
273
+ }
274
+ }
275
+
276
+ lines.push(
277
+ `echo "[feature] ${featureId}: running install.sh..."`,
278
+ `chmod +x install.sh`,
279
+ `set +e`,
280
+ `./install.sh`,
281
+ `_FEAT_EXIT=$?`,
282
+ `set -e`,
283
+ `if [ -f "$_FEAT_DIR/devcontainer-feature.json" ]; then`,
284
+ ` _EP=$(grep -o '"entrypoint"[[:space:]]*:[[:space:]]*"[^"]*"' "$_FEAT_DIR/devcontainer-feature.json" | head -1 | cut -d'"' -f4)`,
285
+ ` if [ -n "$_EP" ]; then`,
286
+ ` echo "$_EP" >> " + FEATURE_ENTRYPOINTS_STAGING_FILE + "`,
287
+ ` echo "[feature] ${featureId}: registered entrypoint $_EP"`,
288
+ ` fi`,
289
+ `fi`,
290
+ `cd /`,
291
+ `rm -rf "$_FEAT_DIR"`,
292
+ `if [ $_FEAT_EXIT -ne 0 ]; then`,
293
+ ` echo "[feature] ${featureId} ✗ FAILED (exit $_FEAT_EXIT)"`,
294
+ ` exit $_FEAT_EXIT`,
295
+ `fi`,
296
+ `echo "[feature] ${featureId} ✓ installed"`,
297
+ )
298
+
299
+ return lines.join("\n")
300
+ }
301
+
302
+ function buildLocalFeatureInstallScript(featureId: string, scriptFile: string): string | null {
303
+ const scriptContent = localFeatureScript(scriptFile)
304
+ if (!scriptContent) return null
305
+ const encoded = b64(scriptContent)
306
+ return [
307
+ `echo "[feature] ${featureId}: running local install script..."`,
308
+ `echo "${encoded}" | base64 -d > /tmp/_mp_feature.sh`,
309
+ `chmod +x /tmp/_mp_feature.sh`,
310
+ `set +e`,
311
+ `/tmp/_mp_feature.sh`,
312
+ `_LOCAL_EXIT=$?`,
313
+ `set -e`,
314
+ `rm -f /tmp/_mp_feature.sh`,
315
+ `if [ $_LOCAL_EXIT -ne 0 ]; then`,
316
+ ` echo "[feature] ${featureId} ✗ FAILED (exit $_LOCAL_EXIT)"`,
317
+ ` exit $_LOCAL_EXIT`,
318
+ `fi`,
319
+ `echo "[feature] ${featureId} ✓ installed"`,
320
+ ].join("\n")
321
+ }
322
+
323
+ export type ResolvedFeature = { id: string; script: string; ociRef?: string }
324
+
325
+ function resolveFeatures(
326
+ features: { id: string; ociRef?: string; options?: Record<string, string> }[],
327
+ warnId?: string,
328
+ ): ResolvedFeature[] {
329
+ const resolved: ResolvedFeature[] = []
330
+ for (const f of features) {
331
+ const known = AVAILABLE_FEATURES.find((af) => af.id === f.id)
332
+ if (known?.localScript) {
333
+ const script = buildLocalFeatureInstallScript(f.id, known.localScript)
334
+ if (script) {
335
+ resolved.push({ id: f.id, script })
336
+ continue
337
+ }
338
+ // A catalog entry naming a script this build doesn't ship: fall through to the OCI ref if the
339
+ // feature has one, rather than silently installing nothing.
340
+ if (warnId) console.warn(`[worker:${warnId}] Feature "${f.id}" names a local script we don't ship`)
341
+ }
342
+ const ociRef = f.ociRef ?? known?.ociRef
343
+ if (!ociRef) {
344
+ if (warnId) console.warn(`[worker:${warnId}] Unknown feature "${f.id}" (no ociRef or localScript), skipping`)
345
+ continue
346
+ }
347
+ const options = { ...known?.defaultOptions, ...f.options }
348
+ resolved.push({
349
+ id: f.id,
350
+ ociRef,
351
+ script: buildFeatureInstallScript(ociRef, Object.keys(options).length > 0 ? options : undefined),
352
+ })
353
+ }
354
+ return resolved
355
+ }
356
+
357
+ // ─── The two features every worker image is made of ───────────────────────────
358
+ //
359
+ // What a worker needs on top of its base image — a non-root user with sudo, an in-browser VS Code,
360
+ // a terminal that survives a disconnect — used to be four hand-written blocks of this file, and the
361
+ // same four blocks, forked and already drifting, in spunto-lite. Both sides speak the devcontainer
362
+ // feature spec already (`buildFeatureInstallScript` below), so that spec is the seam: the shared
363
+ // half is published as features and consumed identically, and nothing private has to be released to
364
+ // share it.
365
+ //
366
+ // common-utils (upstream) → spunto-pack (ours) → the project's own features
367
+ //
368
+ // `spunto-pack` is one feature rather than four (code-server / tmux / dtach / sshd) because the
369
+ // fetcher below reads nothing but `entrypoint` out of a devcontainer-feature.json: `installsAfter`
370
+ // and `dependsOn` are ignored, so features run in the order of the array and nothing resolves an
371
+ // ordering for us. Inside a single install.sh the order is guaranteed by construction — and it
372
+ // costs one OCI round-trip instead of four.
373
+ const COMMON_UTILS_REF = "ghcr.io/devcontainers/features/common-utils:2"
374
+ const SPUNTO_PACK_REF = "ghcr.io/coderhammer/features/spunto-pack:1"
375
+
376
+ /**
377
+ * common-utils replaces the old "System & user" block: it creates `vscode`, drops a
378
+ * `/etc/sudoers.d/vscode` at 0440 (rather than appending to /etc/sudoers), and lays down the base
379
+ * toolchain — on Debian, RedHat, Alpine and azurelinux alike.
380
+ *
381
+ * None of these four options can be left at its default:
382
+ * - `username` pinned instead of `automatic`, whose candidate list starts with users the base image
383
+ * may already have (`node` on a node:* base) — we'd configure sudo for the wrong user.
384
+ * - `upgradePackages` off: on by default, it runs a full distro upgrade in every image build.
385
+ * - `configureZshAsDefaultShell` on: the block it replaces made zsh the login shell, and every
386
+ * worker terminal opens on it.
387
+ * - `installOhMyZsh` off, deliberately: oh-my-zsh lives in a *home directory*, which is per-worker
388
+ * and per-user, and the setup script already installs it there (theme, plugins, aliases) at a
389
+ * point where it knows whether the user has dotfiles of their own. Baking a `.zshrc` into the
390
+ * image would put a file in the way of a dotfiles install script that expects to own it.
391
+ */
392
+ function commonUtilsOptions(): Record<string, string> {
393
+ return {
394
+ username: "vscode",
395
+ upgradePackages: "false",
396
+ installZsh: "true",
397
+ configureZshAsDefaultShell: "true",
398
+ installOhMyZsh: "false",
399
+ installOhMyZshConfig: "false",
400
+ }
401
+ }
402
+
403
+ /**
404
+ * spunto-pack replaces the code-server / tmux / dtach / SSH-server blocks. Its defaults are already
405
+ * what a worker wants (code-server + both terminal backends + the system-wide tmux.conf), so only
406
+ * the SSH server is decided here: the package is worth its size only when a gateway key exists to
407
+ * actually reach it. The pack installs the package and stops there — host keys, sshd_config and who
408
+ * starts the daemon stay ours (docs/architecture-deep-dive.md § SSH gateway).
409
+ *
410
+ * Not strict: a base image where tmux won't install must still produce a usable worker, exactly as
411
+ * the best-effort blocks it replaces did.
412
+ */
413
+ function spuntoPackOptions(params: { sshGatewayPublicKey?: string }): Record<string, string> {
414
+ return { installSshd: params.sshGatewayPublicKey ? "true" : "false" }
415
+ }
416
+
417
+ // The system-wide tmux configuration (mouse, OSC 52 clipboard, 50k scrollback, warm status bar)
418
+ // now lives in spunto-pack's install.sh, where it is written to /etc/tmux.conf — same file, same
419
+ // content, plus the version guard this copy never had: `set -as terminal-features` only exists from
420
+ // tmux 3.2, and on an older base (Debian bullseye ships 3.1c) every client attaching to a worker
421
+ // terminal got an "invalid option" banner to dismiss. The frontend's half of the clipboard story is
422
+ // unchanged — see apps/next/lib/terminal-clipboard.ts.
423
+
424
+ // ─── code-server user settings ───────────────────────────────────────────────
425
+ //
426
+ // Seeded into every worker at start (see buildStartScript §4b), no-clobber so they
427
+ // stay user-overridable. Two of these make parallel worker tabs tellable apart:
428
+ // - window.title leads with the Spunto project name (injected as a literal — VS Code's
429
+ // `${rootName}` only knows the opened folder), so the project survives a narrow tab;
430
+ // the file name (`${activeEditorShort}`) trails.
431
+ // - workbench.colorCustomizations tints the window chrome (title/activity/status
432
+ // bars) with a per-project hue, Peacock-style, so the window itself is distinct.
433
+ // NOTE: code-server's web server only forwards a hardcoded product.json whitelist to
434
+ // the browser — "configurationDefaults" is NOT in that list, so patching product.json
435
+ // is silently ignored client-side. These have to be seeded as real user settings.
436
+
437
+ /** Deterministic 32-bit hash (djb2 xor) — a given project name always maps to the same hue. */
438
+ function hashString(s: string): number {
439
+ let h = 5381
440
+ for (let i = 0; i < s.length; i++) h = ((h * 33) ^ s.charCodeAt(i)) >>> 0
441
+ return h
442
+ }
443
+
444
+ /** HSL (h∈[0,360), s,l∈[0,1]) → #rrggbb. */
445
+ function hslToHex(h: number, s: number, l: number): string {
446
+ const c = (1 - Math.abs(2 * l - 1)) * s
447
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
448
+ const m = l - c / 2
449
+ const [r, g, b] =
450
+ h < 60 ? [c, x, 0]
451
+ : h < 120 ? [x, c, 0]
452
+ : h < 180 ? [0, c, x]
453
+ : h < 240 ? [0, x, c]
454
+ : h < 300 ? [x, 0, c]
455
+ : [c, 0, x]
456
+ const to = (v: number) => Math.round((v + m) * 255).toString(16).padStart(2, "0")
457
+ return `#${to(r)}${to(g)}${to(b)}`
458
+ }
459
+
460
+ /** Per-project window chrome colors, derived from the project name so every worker of a
461
+ * given project shares one recognizable hue on its title/activity/status bars. Backgrounds
462
+ * are dark enough that white foregrounds stay readable across the whole hue circle. */
463
+ function deriveProjectChromeColors(projectName: string): Record<string, string> {
464
+ const hue = hashString(projectName) % 360
465
+ const bg = hslToHex(hue, 0.6, 0.3)
466
+ const bgInactive = hslToHex(hue, 0.4, 0.24)
467
+ const fg = "#ffffff"
468
+ const fgMuted = "#ffffff99"
469
+ return {
470
+ "titleBar.activeBackground": bg,
471
+ "titleBar.activeForeground": fg,
472
+ "titleBar.inactiveBackground": bgInactive,
473
+ "titleBar.inactiveForeground": fgMuted,
474
+ "activityBar.background": bg,
475
+ "activityBar.foreground": fg,
476
+ "activityBar.inactiveForeground": fgMuted,
477
+ "statusBar.background": bg,
478
+ "statusBar.foreground": fg,
479
+ "statusBar.noFolderBackground": bg,
480
+ "statusBar.debuggingBackground": bg,
481
+ }
482
+ }
483
+
484
+ /** Default code-server user settings seeded into every worker. Kills dead Copilot UI, the
485
+ * workspace-trust prompt and the secondary sidebar; makes the tab identifiable via window.title;
486
+ * and, when `colorByProject` is on (org opt-in), tints the window chrome per project. See the
487
+ * block comment above. */
488
+ export function defaultVscodeUserSettings(opts?: {
489
+ projectName?: string
490
+ colorByProject?: boolean
491
+ }): Record<string, unknown> {
492
+ const { projectName, colorByProject } = opts ?? {}
493
+ const settings: Record<string, unknown> = {
494
+ "chat.disableAIFeatures": true,
495
+ // Workers are ephemeral/single-purpose — the trust prompt only adds friction.
496
+ "security.workspace.trust.enabled": false,
497
+ // Ephemeral workers never have a persisted layout, so this always applies.
498
+ "workbench.secondarySideBar.defaultVisibility": "hidden",
499
+ // Spunto project name first so the project stays identifiable even in a narrow browser
500
+ // tab — VS Code's ${rootName} only knows the opened *folder* (e.g. "spunto"), not the
501
+ // Spunto project. Injected as a literal here (we have the name); the file (${activeEditorShort})
502
+ // trails. Falls back to ${rootName} when no project name is available.
503
+ "window.title": projectName
504
+ ? `${projectName}\${separator}\${activeEditorShort}`
505
+ : "${rootName}${separator}${activeEditorShort}",
506
+ }
507
+ // Per-project window color is opt-in at the org level (workerTabColors).
508
+ if (projectName && colorByProject) {
509
+ settings["workbench.colorCustomizations"] = deriveProjectChromeColors(projectName)
510
+ }
511
+ return settings
512
+ }
513
+
514
+ // ─── 1. buildImageScript ──────────────────────────────────────────────────────
515
+ //
516
+ // Runs once during prebuild (docker build / mp-build container).
517
+ // Bakes in: curl, the two base features (common-utils → vscode user; spunto-pack → code-server,
518
+ // tmux, dtach, SSH server), the project's own devcontainer features, then its
519
+ // VS Code extensions (/opt/mp-extensions/).
520
+ //
521
+ // Prewarm images (RFC 0014) are no longer baked here: `docker build`'s `RUN` steps have no daemon
522
+ // available, so RFC 0006 used to pull them into `docker load`-able tarballs via `crane`. That's now
523
+ // done agent-side, after this script runs and the image exists — see
524
+ // `apps/agent/src/docker.ts` (`bakeDindSeed`), which spins up a privileged container from the
525
+ // freshly-built image (real dockerd available) and commits a pre-populated `/var/lib/docker` graph
526
+ // back onto it. Docker's native volume pre-population then seeds each worker's DinD volume from
527
+ // that graph at container creation — no `docker load` anywhere anymore.
528
+
529
+ /**
530
+ * What the baked layer is made of, as a version.
531
+ *
532
+ * A project image (`mp-proj-…:vN`) is keyed by the project's *version*, which only moves when the
533
+ * project's own config does — so a change to this file alone leaves every already-built image in
534
+ * place, and new workers keep spawning on the old base. That is invisible until someone wonders why
535
+ * a fix shipped last week isn't in their container.
536
+ *
537
+ * So the recipe carries a version of its own, stamped on each build row and compared at spawn: a
538
+ * build made by an older recipe is stale, and the image is rebuilt exactly as if the project had
539
+ * been bumped. Bump this whenever a change here must reach existing projects.
540
+ *
541
+ * 1 → the hand-written blocks (vscode user, code-server, tmux, dtach, sshd).
542
+ * 2 → common-utils + spunto-pack.
543
+ */
544
+ export const IMAGE_RECIPE_VERSION = 2
545
+
546
+ export function buildImageScript(params: {
547
+ features: { id: string; ociRef?: string; options?: Record<string, string> }[]
548
+ vscodeExtensions?: string[]
549
+ sshGatewayPublicKey?: string
550
+ /** Project-level DinD opt-in (base image bakes Docker itself, no feature). See projects.dind. */
551
+ dind?: boolean
552
+ /**
553
+ * Org setting (organizations.extensionsGallery): raw code-server gallery blob the
554
+ * `--install-extension` calls below resolve ids against. Absent/unusable = Open VSX.
555
+ */
556
+ extensionsGallery?: string | null
557
+ }): { script: string; hasDinD: boolean; resolvedFeatures: ResolvedFeature[]; steps: BuildStep[] } {
558
+ const lines: string[] = ["set -e"]
559
+
560
+ // Every block of the build declares itself, so the UI can show the whole plan upfront and light
561
+ // each block up as it runs (lib/build-steps.ts). The plan is emitted from the *same* call that
562
+ // emits the script, so the two can never list different blocks.
563
+ const steps: BuildStep[] = []
564
+ function block(
565
+ id: string,
566
+ label: string,
567
+ kind: BuildStepKind,
568
+ detail: string | undefined,
569
+ body: string[],
570
+ ): void {
571
+ steps.push({ id, label, kind, detail, state: "pending" })
572
+ lines.push("", stepStartLine(id), ...body, stepEndLine(id))
573
+ }
574
+
575
+ // Feature env vars (devcontainer spec — features use these to target the right user). They come
576
+ // *first*, before any feature runs: common-utils reads them to decide which user it creates and
577
+ // configures, so exporting them later (as this script used to) would have it fall back to its own
578
+ // `automatic` detection — which on a node base image picks the pre-existing `node` user, not ours.
579
+ // Outside any block: they have to stay set for everything that follows, and exporting them isn't
580
+ // a step.
581
+ lines.push(
582
+ "",
583
+ "export HOME=/root",
584
+ "export _REMOTE_USER=vscode",
585
+ "export _REMOTE_USER_HOME=/home/vscode",
586
+ "export _CONTAINER_USER=vscode",
587
+ "export _CONTAINER_USER_HOME=/home/vscode",
588
+ )
589
+
590
+ // curl stays inline, and can't become a feature: `buildFeatureInstallScript` *is* a curl script
591
+ // (OCI token, manifest, blob), so a base image without curl can't fetch the very feature that
592
+ // would install it. Chicken and egg. images/worker-base bakes curl in, but a project is free to
593
+ // point at any base image.
594
+ block("bootstrap", "Bootstrap", "runtime", "curl — what fetches every feature below", [
595
+ "if ! command -v curl >/dev/null 2>&1; then",
596
+ ' echo "[build] Installing curl (the feature fetcher\'s own dependency)..."',
597
+ " (",
598
+ " set +e",
599
+ " if command -v apt-get >/dev/null 2>&1; then",
600
+ " DEBIAN_FRONTEND=noninteractive apt-get update -qq 2>&1 && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends curl ca-certificates 2>&1",
601
+ " elif command -v apk >/dev/null 2>&1; then",
602
+ " apk add --no-cache curl ca-certificates 2>&1",
603
+ " elif command -v dnf >/dev/null 2>&1; then",
604
+ " dnf install -y curl ca-certificates 2>&1",
605
+ " elif command -v yum >/dev/null 2>&1; then",
606
+ " yum install -y curl ca-certificates 2>&1",
607
+ " fi",
608
+ " set -e",
609
+ " )",
610
+ "fi",
611
+ 'command -v curl >/dev/null 2>&1 || echo "[build] WARNING: curl unavailable — feature installs will fail"',
612
+ ])
613
+
614
+ const hasDinD = params.features.some((f) => f.id === "docker-in-docker") || !!params.dind
615
+ // The two features every worker image is made of, ahead of whatever the project picked. The user
616
+ // can select common-utils in the picker too — its options are then merged over ours rather than
617
+ // installed a second time.
618
+ const userCommonUtils = params.features.find((f) => f.id === "common-utils")
619
+ const baseFeatures = [
620
+ {
621
+ id: "common-utils",
622
+ ociRef: userCommonUtils?.ociRef ?? COMMON_UTILS_REF,
623
+ options: { ...commonUtilsOptions(), ...userCommonUtils?.options },
624
+ },
625
+ { id: "spunto-pack", ociRef: SPUNTO_PACK_REF, options: spuntoPackOptions(params) },
626
+ ]
627
+ const resolvedFeatures = resolveFeatures([
628
+ ...baseFeatures,
629
+ ...params.features.filter((f) => f.id !== "common-utils"),
630
+ ])
631
+
632
+ // Install features — one block each, so a feature that takes four minutes is visibly *the* thing
633
+ // the build is doing rather than an anonymous stretch of log.
634
+ for (const { id, script, ociRef } of resolvedFeatures) {
635
+ block(`feature:${id}`, id, "feature", ociRef, [`echo "[build] Installing feature: ${id}..."`, script])
636
+ }
637
+
638
+ // Save entrypoints to a durable location. Append (not overwrite) + dedup so an entrypoint
639
+ // already baked into the base image (e.g. a dockerd starter for projects.dind, see
640
+ // images/worker-base) survives alongside any registered by features installed here.
641
+ const finalizeBody = [
642
+ "if [ -f " + FEATURE_ENTRYPOINTS_STAGING_FILE + " ]; then",
643
+ " touch " + FEATURE_ENTRYPOINTS_FILE,
644
+ " cat " + FEATURE_ENTRYPOINTS_STAGING_FILE + " >> " + FEATURE_ENTRYPOINTS_FILE,
645
+ " sort -u " + FEATURE_ENTRYPOINTS_FILE + " -o " + FEATURE_ENTRYPOINTS_FILE,
646
+ " rm -f " + FEATURE_ENTRYPOINTS_STAGING_FILE,
647
+ ' echo "[build] Entrypoints saved to ' + FEATURE_ENTRYPOINTS_FILE + '"',
648
+ "fi",
649
+ ]
650
+
651
+ // Install VS Code extensions to a system path (not in /home, which is per-worker)
652
+ if (params.vscodeExtensions && params.vscodeExtensions.length > 0) {
653
+ const extLines = [
654
+ 'echo "[build] Installing VS Code extensions to ' + EXTENSIONS_DIR + '..."',
655
+ "mkdir -p " + EXTENSIONS_DIR,
656
+ // Must precede the first install: this is what makes `--install-extension` resolve ids
657
+ // against the org's gallery instead of Open VSX.
658
+ ...galleryExport(params.extensionsGallery),
659
+ ]
660
+ const registry = registryLabel(params.extensionsGallery)
661
+ for (const ext of params.vscodeExtensions) {
662
+ extLines.push(
663
+ `echo "[build] Installing extension: ${ext}..."`,
664
+ `set +e`,
665
+ `code-server --extensions-dir ${EXTENSIONS_DIR} --install-extension ${JSON.stringify(ext)} 2>&1`,
666
+ `_EXT_EXIT=$?`,
667
+ `set -e`,
668
+ `if [ $_EXT_EXIT -ne 0 ]; then echo "[build] Extension ${ext} failed (exit $_EXT_EXIT) — ids are resolved against ${registry}; an id that isn't published there can't be installed. Continuing"; fi`,
669
+ )
670
+ }
671
+ extLines.push('echo "[build] Extensions installed"')
672
+ const n = params.vscodeExtensions.length
673
+ block("extensions", "VS Code extensions", "extensions", `${n} extension${n > 1 ? "s" : ""} · ${registry}`, extLines)
674
+ }
675
+
676
+ // Fix ownership (features may have created files in /home/vscode as root)
677
+ block("finalize", "Finalizing image", "finalize", "entrypoints, sudo, ownership", [
678
+ ...finalizeBody,
679
+ // common-utils writes /etc/sudoers.d/<user> only when it *creates* the user: a base image
680
+ // that already ships `vscode` is assumed to have configured sudo for it, which the
681
+ // devcontainer bases do and an arbitrary base may not. The block this replaced appended the
682
+ // rule unconditionally, and setup runs as `vscode` (dotfiles, postCreate) — so the guarantee
683
+ // is kept, in the file-per-user form rather than by editing /etc/sudoers.
684
+ // The check has to be the real thing: `sudo -l -U vscode` exits 0 even for a user with no
685
+ // rights at all (it prints the default env settings), so it answers a different question.
686
+ "if ! su vscode -c 'sudo -n true' >/dev/null 2>&1; then",
687
+ " echo 'vscode ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/vscode",
688
+ " chmod 0440 /etc/sudoers.d/vscode",
689
+ ' echo "[build] sudo NOPASSWD configured for vscode"',
690
+ "fi",
691
+ "chown -R vscode:vscode /home/vscode 2>/dev/null || true",
692
+ 'echo "[build] Image build complete"',
693
+ ])
694
+
695
+ return { script: lines.join("\n"), hasDinD, resolvedFeatures, steps }
696
+ }
697
+
698
+ // ─── 2. buildSetupScript ──────────────────────────────────────────────────────
699
+ //
700
+ // Runs once on the first container start (marker: /home/vscode/.mp-setup-done).
701
+ // Handles: ownership, credentials, shell customization, dotfiles, repo cloning,
702
+ // postCreateCommand, gcloud wrapper.
703
+ // Does NOT run on subsequent starts — those only run buildStartScript.
704
+
705
+ export type SetupScriptParams = {
706
+ project: {
707
+ repositories: { id: string; provider: string; project: string; workspacePath: string; installationId?: number; cloneUrl?: string; branch?: string }[]
708
+ features: { id: string; ociRef?: string; options?: Record<string, string> }[] | null
709
+ vscodeExtensions?: string[] | null
710
+ dind?: boolean | null
711
+ postCreateCommand: string | null
712
+ postStartCommand?: string | null
713
+ }
714
+ workerId: string
715
+ userInfo?: { name: string; email?: string | null }
716
+ userSshPrivateKey?: string
717
+ sshGatewayPublicKey?: string
718
+ skipFeatures?: boolean
719
+ dotfilesRepo?: string | null
720
+ userEnvSecrets?: Record<string, string>
721
+ // Short-lived org GitHub App installation tokens (RFC 0012), one per connected GitHub
722
+ // account keyed by lowercased owner login. When a repo's owner has a token, it's cloned via
723
+ // HTTPS x-access-token (org-scoped read access) and the remote is then rewritten to SSH so
724
+ // push/pull keep the user's personal git identity.
725
+ githubInstallationTokens?: Record<string, string>
726
+ // Decrypted per-project SSH deploy key (RFC 0013). Used to clone generic ("git") repos from a
727
+ // raw SSH URL. Written to ~/.ssh/mp_deploy_key and referenced per-clone via GIT_SSH_COMMAND.
728
+ projectDeployKey?: string
729
+ // Git branch chosen when this worker was created (workers.branch): checked out for every repo
730
+ // of the project, overriding each repository's own default branch. Null/absent → default branch.
731
+ branch?: string | null
732
+ }
733
+
734
+ export function buildSetupScript(params: SetupScriptParams): { script: string } {
735
+ const { project, workerId, userInfo, userSshPrivateKey, dotfilesRepo, userEnvSecrets, githubInstallationTokens, projectDeployKey, branch } = params
736
+ const homeDir = "/home/vscode"
737
+ const username = "vscode"
738
+
739
+ const repoNames = project.repositories.map((r) => r.project)
740
+ const hasPostCreate = !!project.postCreateCommand
741
+ const pc0: LS = hasPostCreate ? "pending" : null
742
+
743
+ const allReposPending: RS[] = repoNames.map((n) => ({ name: n, state: "pending" }))
744
+ const allReposDone: RS[] = repoNames.map((n) => ({ name: n, state: "done" }))
745
+
746
+ function reposAtClone(i: number, cur: "cloning" | "done"): RS[] {
747
+ return repoNames.map((n, j) => ({ name: n, state: j < i ? "done" : j === i ? cur : "pending" }))
748
+ }
749
+
750
+ const lines: string[] = []
751
+ const push = (...l: string[]) => lines.push(...l)
752
+ const mp = (json: string) => lines.push(`_mp ${JSON.stringify(json)}`)
753
+ // Stamp a phase-start timestamp (container-side) then write the status. `key` is a
754
+ // fixed identifier, never user input — safe to pass unquoted to _mp_stamp.
755
+ const mpAt = (json: string, key: string) => lines.push(`_mp_stamp ${key}`, `_mp ${JSON.stringify(json)}`)
756
+
757
+ // ── 1. Ownership ─────────────────────────────────────────────────────────
758
+ push(...banner("SETUP: OWNERSHIP"))
759
+ push(
760
+ `chown -R ${username}:${username} /workspace`,
761
+ `chown -R ${username}:${username} ${homeDir}`,
762
+ )
763
+
764
+ // ── 2. Credentials ────────────────────────────────────────────────────────
765
+ const hasCredentials = !!(userInfo || userSshPrivateKey || projectDeployKey)
766
+ if (hasCredentials) {
767
+ push(...banner("SETUP: CREDENTIALS"))
768
+ mpAt(mkStatus("credentials", allReposPending, pc0, null), "credentials")
769
+ }
770
+
771
+ push(...credentialsBlock({ homeDir, username, userInfo, userSshPrivateKey, projectDeployKey }))
772
+
773
+ // ── 3. Shell setup ────────────────────────────────────────────────────────
774
+ // Land in the workspace on a fresh interactive shell (code-server terminal +
775
+ // SSH login). If there is exactly one repo dir under /workspace, cd into it;
776
+ // otherwise cd into /workspace. Guarded by `$PWD == $HOME` so it only fires on
777
+ // a fresh login and never fights a user who has navigated elsewhere.
778
+ // `find` does its own globbing, so this stays safe under zsh's default
779
+ // `nomatch` (a bare `/workspace/*/` would error when the dir is empty).
780
+ const landInWorkspace = [
781
+ ``,
782
+ `# ── Spunto: land in workspace on fresh login ─────────────────────`,
783
+ `if [ "$PWD" = "$HOME" ] && [ -d /workspace ]; then`,
784
+ ` __mp_dirs=$(find /workspace -mindepth 1 -maxdepth 1 -type d -not -name '.*' 2>/dev/null)`,
785
+ ` __mp_n=$(printf '%s\\n' "$__mp_dirs" | grep -c .)`,
786
+ ` if [ "$__mp_n" = 1 ]; then cd "$__mp_dirs" 2>/dev/null; else cd /workspace 2>/dev/null; fi`,
787
+ ` unset __mp_dirs __mp_n`,
788
+ `fi`,
789
+ ].join("\n")
790
+
791
+ const bashrcSnippet = [
792
+ ``,
793
+ `# ── Spunto shell config ──────────────────────────────────────────`,
794
+ `__mp_ps1_git() {`,
795
+ ` local b`,
796
+ ` b=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null) || return`,
797
+ ` printf ' \\e[0;33m(%s)\\e[0m' "$b"`,
798
+ `}`,
799
+ ``,
800
+ `PS1='\\n\\[\\e[0;2m\\]\\u@\\h\\[\\e[0m\\] \\[\\e[1;34m\\]\\w\\[\\e[0m\\]$(__mp_ps1_git)\\n\\[\\e[1;32m\\]❯\\[\\e[0m\\] '`,
801
+ ``,
802
+ `alias ll='ls -lah --color=auto'`,
803
+ `alias la='ls -A --color=auto'`,
804
+ `alias l='ls -CF --color=auto'`,
805
+ `alias gs='git status'`,
806
+ `alias gd='git diff'`,
807
+ `alias gl='git log --oneline --graph --decorate -20'`,
808
+ landInWorkspace,
809
+ ].join("\n")
810
+ const bashrcEncoded = b64(bashrcSnippet)
811
+
812
+ const zshrcAliasSnippet = [
813
+ ``,
814
+ `# ── Spunto config ────────────────────────────────────────────────`,
815
+ `setopt NO_BANG_HIST`,
816
+ `alias ll='ls -lah --color=auto'`,
817
+ `alias la='ls -A --color=auto'`,
818
+ `alias l='ls -CF --color=auto'`,
819
+ `alias gs='git status'`,
820
+ `alias gd='git diff'`,
821
+ `alias gl='git log --oneline --graph --decorate -20'`,
822
+ landInWorkspace,
823
+ ].join("\n")
824
+ const zshrcAliasEncoded = b64(zshrcAliasSnippet)
825
+
826
+ push(...banner("SETUP: SHELL"))
827
+ // Shell setup (oh-my-zsh install is network-bound and can dominate startup), so
828
+ // give it its own timing segment. Phase is unchanged — re-stated so the merged
829
+ // status keeps the phase the UI already shows while recording the shell start.
830
+ mpAt(mkStatus(hasCredentials ? "credentials" : "initializing", allReposPending, pc0, null), "shell")
831
+ push(
832
+ `echo ${JSON.stringify(bashrcEncoded)} | base64 -d >> ${homeDir}/.bashrc`,
833
+ `if command -v zsh >/dev/null 2>&1; then`,
834
+ ` if [ ! -d "${homeDir}/.oh-my-zsh" ]; then`,
835
+ ` echo "Installing oh-my-zsh..."`,
836
+ ` _OMZ_TMP=$(mktemp)`,
837
+ ` curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh -o "$_OMZ_TMP" 2>&1`,
838
+ ` chmod 644 "$_OMZ_TMP"`,
839
+ ` su ${username} -c "HOME=${homeDir} RUNZSH=no CHSH=no KEEP_ZSHRC=no bash $_OMZ_TMP" 2>&1 || true`,
840
+ ` rm -f "$_OMZ_TMP"`,
841
+ ` else`,
842
+ ` echo "oh-my-zsh already installed"`,
843
+ ` fi`,
844
+ ` sed -i 's/ZSH_THEME="robbyrussell"/ZSH_THEME="af-magic"/' ${homeDir}/.zshrc 2>/dev/null || true`,
845
+ ` sed -i 's/^plugins=(git)$/plugins=(git z)/' ${homeDir}/.zshrc 2>/dev/null || true`,
846
+ ` echo ${JSON.stringify(zshrcAliasEncoded)} | base64 -d >> ${homeDir}/.zshrc`,
847
+ `fi`,
848
+ // bash login shells (e.g. SSH when zsh is absent) read .profile, not .bashrc
849
+ `echo ${JSON.stringify(b64(landInWorkspace))} | base64 -d >> ${homeDir}/.profile`,
850
+ )
851
+
852
+ // ── 4. Dotfiles ───────────────────────────────────────────────────────────
853
+ if (dotfilesRepo) {
854
+ const dotfilesUrl = dotfilesRepo.startsWith("http") || dotfilesRepo.startsWith("git@")
855
+ ? dotfilesRepo
856
+ : `https://github.com/${dotfilesRepo}`
857
+ push(...banner("SETUP: DOTFILES"))
858
+ mpAt(mkStatus("dotfiles", allReposPending, pc0, null), "dotfiles")
859
+ push(
860
+ `echo "Cloning dotfiles from ${dotfilesUrl}..."`,
861
+ `set +e`,
862
+ `git clone ${JSON.stringify(dotfilesUrl)} ${homeDir}/dotfiles 2>&1`,
863
+ `_DOTS_EXIT=$?`,
864
+ `set -e`,
865
+ `if [ $_DOTS_EXIT -ne 0 ]; then`,
866
+ ` echo "Dotfiles clone failed (exit $_DOTS_EXIT) — continuing without dotfiles"`,
867
+ `else`,
868
+ ` echo "Dotfiles cloned"`,
869
+ // Cloned as root, but the install script below runs as the user (and so does whoever edits
870
+ // these files later) — hand the clone over before touching it.
871
+ ` chown -R ${username}:${username} ${homeDir}/dotfiles`,
872
+ ` _INSTALL_SCRIPT=""`,
873
+ ` for _candidate in install.sh bootstrap.sh setup.sh script/setup; do`,
874
+ ` if [ -f "${homeDir}/dotfiles/$_candidate" ]; then`,
875
+ ` _INSTALL_SCRIPT="$_candidate"`,
876
+ ` break`,
877
+ ` fi`,
878
+ ` done`,
879
+ ` if [ -n "$_INSTALL_SCRIPT" ]; then`,
880
+ ` echo "Running dotfiles install script: $_INSTALL_SCRIPT"`,
881
+ ` chmod +x "${homeDir}/dotfiles/$_INSTALL_SCRIPT"`,
882
+ ` set +e`,
883
+ ` su ${username} -c "cd ${homeDir}/dotfiles && ${envPrefix(userEnvSecrets)}./$_INSTALL_SCRIPT"`,
884
+ ` _DOTS_INST_EXIT=$?`,
885
+ ` set -e`,
886
+ ` if [ $_DOTS_INST_EXIT -ne 0 ]; then`,
887
+ ` echo "Dotfiles install script exited with $_DOTS_INST_EXIT — continuing"`,
888
+ ` else`,
889
+ ` echo "Dotfiles installed"`,
890
+ ` fi`,
891
+ ` else`,
892
+ ` echo "No install script found — symlinking dotfiles..."`,
893
+ ` for _f in "${homeDir}/dotfiles"/.*; do`,
894
+ ` _name=$(basename "$_f")`,
895
+ ` case "$_name" in .|..|.git|.gitignore|.gitmodules) continue ;; esac`,
896
+ ` ln -sf "$_f" "${homeDir}/$_name" && echo " linked $_name"`,
897
+ ` done`,
898
+ ` echo "Dotfiles symlinked"`,
899
+ ` fi`,
900
+ `fi`,
901
+ )
902
+ }
903
+
904
+ // ── 5. Clone repos ────────────────────────────────────────────────────────
905
+ if (project.repositories.length > 0) {
906
+ push(...banner(`SETUP: CLONE REPOSITORIES (${project.repositories.length})`))
907
+ }
908
+ project.repositories.forEach((r, i) => {
909
+ const { header, lines: cloneLines } = cloneRepoBlock({
910
+ repo: r,
911
+ index: i,
912
+ total: project.repositories.length,
913
+ homeDir,
914
+ branch: resolveRepoBranch(r, branch),
915
+ githubInstallationTokens,
916
+ userSshPrivateKey,
917
+ })
918
+ push("")
919
+ push(header)
920
+ // Stamp the clone phase start once (first repo); _mp_stamp is first-write-wins.
921
+ mpAt(mkStatus("cloning", reposAtClone(i, "cloning"), pc0, null), "cloning")
922
+ push(...cloneLines)
923
+ mp(mkStatus("cloning", reposAtClone(i, "done"), pc0, null))
924
+ })
925
+
926
+ // ── 5b. Re-own /workspace after cloning ───────────────────────────────────
927
+ // Repos are cloned as root (the setup script runs as root), so the cloned
928
+ // directories end up root-owned. The initial chown (step 1) ran *before* the
929
+ // clone, so it didn't cover them. postCreateCommand runs as vscode, so without
930
+ // this the user can't write into the repo (e.g. `npm install` → EACCES on
931
+ // node_modules). Must run before postCreate, not just in the final step.
932
+ if (project.repositories.length > 0) {
933
+ push("", `chown -R ${username}:${username} /workspace`)
934
+ }
935
+
936
+ // ── 6. postCreateCommand ──────────────────────────────────────────────────
937
+ // Feature entrypoints (e.g. dockerd) are normally started in buildStartScript,
938
+ // but postCreateCommand runs before that. Start them here so commands like
939
+ // `docker run` work during postCreate. Also wait for the Docker socket if
940
+ // docker-in-docker is present.
941
+ // `project.dind` makes DinD available without the docker-in-docker feature (baked into the base
942
+ // image, see images/worker-base) — treat it like the feature for entrypoint start + docker wait.
943
+ const hasDinDFeature = (project.features?.some((f) => f.id === "docker-in-docker") ?? false) || !!project.dind
944
+ if (project.postCreateCommand && ((project.features && project.features.length > 0) || project.dind)) {
945
+ push(
946
+ "",
947
+ "if [ -f " + FEATURE_ENTRYPOINTS_FILE + " ]; then",
948
+ ' echo "--- Starting feature entrypoints for postCreate ---"',
949
+ " while IFS= read -r _ep; do",
950
+ ' if [ -f "$_ep" ]; then',
951
+ ' echo "Starting entrypoint: $_ep"',
952
+ ' "$_ep" &',
953
+ " fi",
954
+ " done < " + FEATURE_ENTRYPOINTS_FILE,
955
+ "fi",
956
+ )
957
+ if (hasDinDFeature) {
958
+ push(
959
+ 'echo "Waiting for Docker daemon..."',
960
+ "for _i in $(seq 1 30); do",
961
+ " docker info >/dev/null 2>&1 && break",
962
+ " sleep 1",
963
+ "done",
964
+ 'docker info >/dev/null 2>&1 && echo "Docker daemon ready" || echo "Docker daemon not ready after 30s, continuing anyway"',
965
+ )
966
+ }
967
+ }
968
+
969
+ if (project.postCreateCommand) {
970
+ const postCreateDir = project.repositories.length === 1
971
+ ? `/workspace/${project.repositories[0].workspacePath}`
972
+ : "/workspace"
973
+ push(...banner("SETUP: POST CREATE"))
974
+ mpAt(mkStatus("lifecycle", allReposDone, "running", null), "postCreate")
975
+ const cmdEncoded = b64(project.postCreateCommand)
976
+ push(
977
+ `if [ ! -f "${homeDir}/.mp-post-create-done" ]; then`,
978
+ ` echo "--- Running postCreateCommand ---"`,
979
+ ` echo ${JSON.stringify(cmdEncoded)} | base64 -d > /tmp/mp_postcreate.sh`,
980
+ ` chmod +x /tmp/mp_postcreate.sh`,
981
+ ` su ${username} -c "cd ${postCreateDir} && bash /tmp/mp_postcreate.sh" 2>&1`,
982
+ ` rm -f /tmp/mp_postcreate.sh`,
983
+ ` touch "${homeDir}/.mp-post-create-done"`,
984
+ ` echo "postCreateCommand done"`,
985
+ `fi`,
986
+ )
987
+ mp(mkStatus("lifecycle", allReposDone, "done", null))
988
+ }
989
+
990
+ // ── 8. Final ownership + marker ───────────────────────────────────────────
991
+ push(
992
+ "",
993
+ `chown -R ${username}:${username} ${homeDir}`,
994
+ `chown -R ${username}:${username} /workspace`,
995
+ `touch ${homeDir}/.mp-setup-done`,
996
+ 'echo "[setup] First-start setup complete"',
997
+ )
998
+
999
+ return { script: lines.join("\n") }
1000
+ }
1001
+
1002
+ // ─── 3. buildStartScript ──────────────────────────────────────────────────────
1003
+ //
1004
+ // Runs on every container start (first and subsequent).
1005
+ // Handles: SSH server, feature entrypoints, worker env, extension copy,
1006
+ // code-server start (as vscode), postStartCommand.
1007
+
1008
+ export type StartScriptParams = {
1009
+ project: {
1010
+ name?: string
1011
+ features: { id: string; ociRef?: string; options?: Record<string, string> }[] | null
1012
+ vscodeExtensions?: string[] | null
1013
+ dind?: boolean | null
1014
+ postCreateCommand: string | null
1015
+ postStartCommand: string | null
1016
+ repositories: { project: string; workspacePath: string }[]
1017
+ }
1018
+ sshGatewayPublicKey?: string
1019
+ /** Org opt-in (organizations.workerTabColors): tint the worker window chrome per project. */
1020
+ colorTabsByProject?: boolean
1021
+ /**
1022
+ * Org setting (organizations.extensionsGallery): raw code-server gallery blob, exported into the
1023
+ * code-server loop so the editor's own Extensions view searches the same registry the picker and
1024
+ * the image build used. Absent/unusable = Open VSX.
1025
+ */
1026
+ extensionsGallery?: string | null
1027
+ }
1028
+
1029
+ export function buildStartScript(params: StartScriptParams): { script: string; hasDinD: boolean } {
1030
+ const { project, sshGatewayPublicKey, colorTabsByProject, extensionsGallery } = params
1031
+ const homeDir = "/home/vscode"
1032
+ const username = "vscode"
1033
+
1034
+ const features = project.features ?? []
1035
+ const hasPostCreate = !!project.postCreateCommand
1036
+ const hasPostStart = !!project.postStartCommand
1037
+ const hasDinD = features.some((f) => f.id === "docker-in-docker") || !!project.dind
1038
+
1039
+ const allReposDone: RS[] = project.repositories.map((r) => ({ name: r.project, state: "done" }))
1040
+
1041
+ const lines: string[] = []
1042
+ const push = (...l: string[]) => lines.push(...l)
1043
+ const mp = (json: string) => lines.push(`_mp ${JSON.stringify(json)}`)
1044
+ const mpAt = (json: string, key: string) => lines.push(`_mp_stamp ${key}`, `_mp ${JSON.stringify(json)}`)
1045
+
1046
+ // ── Session separator (visible in logs on every start/restart) ────────────
1047
+ push(
1048
+ `printf '\\033[2m%s\\033[0m\\n' "$(printf '─%.0s' {1..42})"`,
1049
+ `printf '\\033[2m ↺ Session · %s\\033[0m\\n' "$(date +%H:%M:%S)"`,
1050
+ `printf '\\033[2m%s\\033[0m\\n' "$(printf '─%.0s' {1..42})"`,
1051
+ )
1052
+
1053
+ // ── Reset status on restart so postStart stays blocking ───────────────────
1054
+ // .mp-status.json lives on the persistent home volume, so on a *restart* it
1055
+ // still holds the previous run's "ready" status. Emit a non-ready status at
1056
+ // the very top of every start (before code-server / postStart) so a watcher
1057
+ // waiting on postStart doesn't observe that stale "ready" before this start's
1058
+ // postStartCommand has even begun. Only relevant when there is a postStart to
1059
+ // wait for; on first start setup has just emitted lifecycle/postCreate=done,
1060
+ // so this is a harmless no-op-equivalent restatement.
1061
+ if (hasPostStart) {
1062
+ mp(mkStatus("lifecycle", allReposDone, hasPostCreate ? "done" : null, "pending"))
1063
+ }
1064
+
1065
+ // ── 1. SSH server ─────────────────────────────────────────────────────────
1066
+ if (sshGatewayPublicKey) {
1067
+ push(...banner("START: SSH SERVER"))
1068
+ push(
1069
+ "(",
1070
+ " set +e",
1071
+ " mkdir -p /run/sshd /var/run/sshd",
1072
+ // Authorize the gateway key for the `vscode` user so SSH lands as the same
1073
+ // user as code-server (not root), with their env, git creds and shell.
1074
+ ` mkdir -p ${homeDir}/.ssh`,
1075
+ ` chmod 700 ${homeDir}/.ssh`,
1076
+ ` _GW_KEY=${JSON.stringify(sshGatewayPublicKey)}`,
1077
+ ` grep -qF "$_GW_KEY" ${homeDir}/.ssh/authorized_keys 2>/dev/null || printf '%s\\n' "$_GW_KEY" >> ${homeDir}/.ssh/authorized_keys`,
1078
+ ` chmod 600 ${homeDir}/.ssh/authorized_keys`,
1079
+ ` chown -R ${username}:${username} ${homeDir}/.ssh`,
1080
+ " printf '\\nPubkeyAuthentication yes\\nPasswordAuthentication no\\nChallengeResponseAuthentication no\\nUsePAM no\\n' >> /etc/ssh/sshd_config",
1081
+ " ssh-keygen -A 2>&1",
1082
+ " if command -v /usr/sbin/sshd >/dev/null 2>&1; then",
1083
+ " /usr/sbin/sshd 2>&1 &",
1084
+ " elif command -v /usr/bin/sshd >/dev/null 2>&1; then",
1085
+ " /usr/bin/sshd 2>&1 &",
1086
+ " else",
1087
+ ' echo "SSH server: sshd binary not found"',
1088
+ " fi",
1089
+ ' echo "SSH server started"',
1090
+ " set -e",
1091
+ ")",
1092
+ )
1093
+ }
1094
+
1095
+ // ── 2. Feature entrypoints ────────────────────────────────────────────────
1096
+ push(
1097
+ "",
1098
+ "if [ -f " + FEATURE_ENTRYPOINTS_FILE + " ]; then",
1099
+ ' echo "--- Starting feature entrypoints ---"',
1100
+ " while IFS= read -r _ep; do",
1101
+ ' if [ -f "$_ep" ]; then',
1102
+ ' echo "Starting entrypoint: $_ep"',
1103
+ ' "$_ep" &',
1104
+ " fi",
1105
+ " done < " + FEATURE_ENTRYPOINTS_FILE,
1106
+ "fi",
1107
+ )
1108
+
1109
+ // ── 3. Persist env vars for VS Code terminal login shells ─────────────────
1110
+ push(
1111
+ "",
1112
+ "# Persist worker env for login shells (VS Code integrated terminals read /etc/environment)",
1113
+ "for _var in WORKER_SLUG BASE_DOMAIN PUBLIC_PROTOCOL GITHUB_TOKEN GITHUB_USER GOOGLE_APPLICATION_CREDENTIALS; do",
1114
+ ' _val=$(printenv "$_var" 2>/dev/null || true)',
1115
+ ' [ -n "$_val" ] && echo "${_var}=${_val}" >> /etc/environment || true',
1116
+ "done",
1117
+ )
1118
+
1119
+ // ── 4. Copy prebuilt extensions ───────────────────────────────────────────
1120
+ push(
1121
+ "",
1122
+ 'if [ -d ' + EXTENSIONS_DIR + ' ] && [ "$(ls -A ' + EXTENSIONS_DIR + ' 2>/dev/null)" ]; then',
1123
+ ` mkdir -p ${homeDir}/.local/share/code-server/extensions`,
1124
+ ` cp -rn ${EXTENSIONS_DIR}/. ${homeDir}/.local/share/code-server/extensions/ 2>/dev/null || true`,
1125
+ ` chown -R ${username}:${username} ${homeDir}/.local`,
1126
+ "fi",
1127
+ )
1128
+
1129
+ // ── 4b. Seed code-server user settings (no-clobber, stays user-overridable) ─
1130
+ // Reordered window.title + per-project window color make parallel worker tabs
1131
+ // tellable apart at a glance (see defaultVscodeUserSettings). Written no-clobber
1132
+ // (guarded on the file, not `cp -n`) so a user's own settings.json survives restarts.
1133
+ const vscodeSettings = JSON.stringify(
1134
+ defaultVscodeUserSettings({ projectName: project.name, colorByProject: colorTabsByProject }),
1135
+ null,
1136
+ 2,
1137
+ )
1138
+ push(
1139
+ "",
1140
+ `mkdir -p ${homeDir}/.local/share/code-server/User`,
1141
+ `if [ ! -f ${homeDir}/.local/share/code-server/User/settings.json ]; then`,
1142
+ ` echo ${JSON.stringify(b64(vscodeSettings))} | base64 -d > ${homeDir}/.local/share/code-server/User/settings.json`,
1143
+ ` chown -R ${username}:${username} ${homeDir}/.local`,
1144
+ "fi",
1145
+ )
1146
+
1147
+ // ── 4c. Restore the user's PORT secret for code-server's own terminal panel ─
1148
+ // code-server reads $PORT to override --bind-addr (see § 5 below), so its
1149
+ // whole process tree — including the integrated terminal it spawns — inherits
1150
+ // PORT=8080 unless corrected. `terminal.integrated.env.linux` lets VS Code
1151
+ // inject env overrides into terminals it spawns without touching the server
1152
+ // process itself, so a `PORT` project secret still reaches the user's shell.
1153
+ // Uses code-server's own bundled node (guaranteed present regardless of the
1154
+ // devcontainer base image) since jq/node aren't guaranteed there.
1155
+ const seedTerminalPortEnv = [
1156
+ "const fs = require('fs')",
1157
+ "const path = process.env.MP_SETTINGS_PATH",
1158
+ "let settings = {}",
1159
+ "try { settings = JSON.parse(fs.readFileSync(path, 'utf8')) } catch {}",
1160
+ "settings['terminal.integrated.env.linux'] = { ...settings['terminal.integrated.env.linux'], PORT: process.env.MP_ORIGINAL_PORT }",
1161
+ "fs.writeFileSync(path, JSON.stringify(settings, null, 2))",
1162
+ ].join("\n")
1163
+ const seedTerminalPortEnvEncoded = b64(seedTerminalPortEnv)
1164
+
1165
+ push(
1166
+ "",
1167
+ 'if [ -n "${PORT:-}" ]; then',
1168
+ " _mp_cs_bin=$(command -v code-server 2>/dev/null || true)",
1169
+ ' if [ -n "$_mp_cs_bin" ]; then',
1170
+ ' _mp_cs_bin=$(readlink -f "$_mp_cs_bin" 2>/dev/null || echo "$_mp_cs_bin")',
1171
+ ' _mp_cs_node="$(dirname "$(dirname "$_mp_cs_bin")")/lib/node"',
1172
+ ' if [ -x "$_mp_cs_node" ]; then',
1173
+ ` mkdir -p ${homeDir}/.local/share/code-server/User`,
1174
+ ` echo ${JSON.stringify(seedTerminalPortEnvEncoded)} | base64 -d > ${homeDir}/.mp_seed_terminal_port.js`,
1175
+ ` MP_ORIGINAL_PORT="$PORT" MP_SETTINGS_PATH="${homeDir}/.local/share/code-server/User/settings.json" "$_mp_cs_node" ${homeDir}/.mp_seed_terminal_port.js`,
1176
+ ` rm -f ${homeDir}/.mp_seed_terminal_port.js`,
1177
+ ` chown -R ${username}:${username} ${homeDir}/.local`,
1178
+ " fi",
1179
+ " fi",
1180
+ "fi",
1181
+ )
1182
+
1183
+ // ── 5. Start code-server as vscode ────────────────────────────────────────
1184
+ // Write the restart-loop to a temp script so quoting stays clean.
1185
+ // Single-quoted heredoc: $VARS are NOT expanded here, they expand when the script runs
1186
+ // (env is inherited from the start script via `su vscode` without -l).
1187
+ // Write the restart-loop using base64 to avoid heredoc issues in DinD contexts
1188
+ // where dockerd startup can affect /tmp mount visibility for child processes.
1189
+ // Script lives in home dir (persistent volume) rather than /tmp.
1190
+ const codeServerLoop = [
1191
+ "#!/bin/bash",
1192
+ "export SHELL=$(command -v zsh 2>/dev/null || echo /bin/bash)",
1193
+ // Makes the editor's own Extensions view search the same registry the picker and the image
1194
+ // build used (org setting — see lib/extension-registry.ts). Nothing when the org is on Open VSX.
1195
+ ...galleryExport(extensionsGallery),
1196
+ "while true; do",
1197
+ ' VSCODE_PROXY_URI="${PUBLIC_PROTOCOL}://${WORKER_SLUG}-{{port}}.${BASE_DOMAIN}" \\',
1198
+ // PORT is overridden here (not unset globally) so a project secret named
1199
+ // PORT can't make code-server bind elsewhere. This also leaks PORT=8080
1200
+ // into code-server's own process tree (its integrated terminal included) —
1201
+ // § 4c above restores the original value for that terminal specifically.
1202
+ // apps/agent/src/terminal.ts (dashboard's separate terminal feature) is a
1203
+ // different process tree entirely and was never affected.
1204
+ " PORT=8080 code-server --bind-addr 0.0.0.0:8080 --auth none /workspace 2>&1",
1205
+ ' echo "[start] code-server exited ($?), restarting in 3s..."',
1206
+ " sleep 3",
1207
+ "done",
1208
+ ].join("\n")
1209
+ const codeServerEncoded = b64(codeServerLoop)
1210
+
1211
+ push(
1212
+ ...banner("START: CODE-SERVER"),
1213
+ `echo ${JSON.stringify(codeServerEncoded)} | base64 -d > ${homeDir}/.mp_codeserver.sh`,
1214
+ `chmod +x ${homeDir}/.mp_codeserver.sh`,
1215
+ `su ${username} -s /bin/bash -c "${homeDir}/.mp_codeserver.sh" &`,
1216
+ )
1217
+
1218
+ // ── 6. postStartCommand ───────────────────────────────────────────────────
1219
+ // Wait for Docker if feature entrypoints were started — regardless of whether
1220
+ // the project explicitly lists docker-in-docker. Some images have Docker in
1221
+ // the base image or via a differently-named feature. Strategy: give the socket
1222
+ // up to 5s to appear; if it shows up, wait up to 30s for the daemon to be ready.
1223
+ if (project.postStartCommand) {
1224
+ push(
1225
+ "",
1226
+ "if [ -f " + FEATURE_ENTRYPOINTS_FILE + " ]; then",
1227
+ " for _i in 1 2 3 4 5; do [ -S /var/run/docker.sock ] && break || sleep 1; done",
1228
+ " if [ -S /var/run/docker.sock ]; then",
1229
+ ' echo "Waiting for Docker daemon..."',
1230
+ " for _i in $(seq 1 30); do docker info >/dev/null 2>&1 && break; sleep 1; done",
1231
+ ' docker info >/dev/null 2>&1 && echo "Docker daemon ready" || echo "Docker daemon not ready, continuing"',
1232
+ " fi",
1233
+ "fi",
1234
+ )
1235
+ }
1236
+
1237
+ if (project.postStartCommand) {
1238
+ const postStartDir = project.repositories.length === 1
1239
+ ? `/workspace/${project.repositories[0].workspacePath}`
1240
+ : "/workspace"
1241
+ push(...banner("START: POST START"))
1242
+ mpAt(mkStatus("lifecycle", allReposDone, hasPostCreate ? "done" : null, "running"), "postStart")
1243
+ const cmdEncoded = b64(project.postStartCommand)
1244
+ push(
1245
+ `echo "--- Running postStartCommand ---"`,
1246
+ `echo ${JSON.stringify(cmdEncoded)} | base64 -d > /tmp/mp_poststart.sh`,
1247
+ `chmod +x /tmp/mp_poststart.sh`,
1248
+ `su ${username} -c "cd ${postStartDir} && bash /tmp/mp_poststart.sh" 2>&1`,
1249
+ `rm -f /tmp/mp_poststart.sh`,
1250
+ `echo "postStartCommand done"`,
1251
+ )
1252
+ mp(mkStatus("lifecycle", allReposDone, hasPostCreate ? "done" : null, "done"))
1253
+ }
1254
+
1255
+ // ── 7. Ready ─────────────────────────────────────────────────────────────
1256
+ push(...banner("READY"))
1257
+ mpAt(mkStatus("ready", allReposDone, hasPostCreate ? "done" : null, hasPostStart ? "done" : null), "ready")
1258
+ // Setup + start succeeded — disarm the failure trap (see buildWorkerScript _mp_fail) so a later
1259
+ // container stop or a code-server restart-loop blip never gets reported as a setup failure.
1260
+ push("_MP_DONE=1")
1261
+ push("", "wait")
1262
+
1263
+ return { script: lines.join("\n"), hasDinD }
1264
+ }
1265
+
1266
+ // ─── buildWorkerScript ────────────────────────────────────────────────────────
1267
+ //
1268
+ // Assembles the full container CMD by combining setup + start.
1269
+ // Setup runs only on first start (marker file guards it).
1270
+ // Start runs on every start.
1271
+
1272
+ // ─── RFC 0017: agent-orchestrated setup (behind WORKER_SETUP_ORCHESTRATOR=agent) ──
1273
+ //
1274
+ // The functions below power the *new* model where the agent (not the container) drives setup:
1275
+ // `buildContainerScript` is the passive CMD (serve code-server + stay alive, no setup, no status
1276
+ // machinery), and `buildSetupPlan` returns the ordered phases the agent execs one by one, each
1277
+ // wrapped in a native OTel span. The legacy `buildWorkerScript`/`buildSetupScript`/`buildStartScript`
1278
+ // above are left untouched — the runner picks the path by flag, defaulting to legacy. See RFC 0017.
1279
+
1280
+ /** One setup phase the agent execs via `docker exec`, wrapped in a native OTel span. */
1281
+ export type SetupPhase = {
1282
+ /** Timing-key / span-suffix (`setup.<id>`) and the phase's short identity. */
1283
+ id: "credentials" | "shell" | "dotfiles" | "clone" | "postCreate" | "postStart"
1284
+ /** Human label for logs. */
1285
+ label: string
1286
+ /** Self-contained bash (runs as root via `docker exec`; `su vscode` inside where needed). */
1287
+ script: string
1288
+ /** Re-run on every start (restart included). Only postStart; the rest is first-start-only. */
1289
+ runsOnRestart: boolean
1290
+ /** Full SetupStatus to persist when this phase starts / finishes (mirrors legacy `mkStatus`). */
1291
+ statusStart: SetupStatus
1292
+ statusEnd: SetupStatus
1293
+ }
1294
+
1295
+ // The passive container CMD. Same "serve + stay alive" blocks as buildStartScript (SSH, feature
1296
+ // entrypoints, env persistence, extensions, code-server settings + restart loop) — MINUS the setup
1297
+ // status machinery, the lifecycle commands and any OTLP/timing emission (the agent owns all of that
1298
+ // now). Ends by signalling readiness (`.mp-container-up`) then `wait`. Blocks are duplicated from
1299
+ // buildStartScript on purpose: the legacy path stays byte-identical (prod default) until it's
1300
+ // deleted once WORKER_SETUP_ORCHESTRATOR=agent becomes the default. See RFC 0017 §2.1.
1301
+ export function buildContainerScript(params: StartScriptParams): { script: string; hasDinD: boolean } {
1302
+ const { project, sshGatewayPublicKey, colorTabsByProject, extensionsGallery } = params
1303
+ const homeDir = "/home/vscode"
1304
+ const username = "vscode"
1305
+
1306
+ const features = project.features ?? []
1307
+ const hasDinD = features.some((f) => f.id === "docker-in-docker") || !!project.dind
1308
+
1309
+ const lines: string[] = ["set -e", "export HOME=/home/vscode"]
1310
+ const push = (...l: string[]) => lines.push(...l)
1311
+
1312
+ // ── Session separator (visible in logs on every start/restart) ────────────
1313
+ push(
1314
+ `printf '\\033[2m%s\\033[0m\\n' "$(printf '─%.0s' {1..42})"`,
1315
+ `printf '\\033[2m ↺ Session · %s\\033[0m\\n' "$(date +%H:%M:%S)"`,
1316
+ `printf '\\033[2m%s\\033[0m\\n' "$(printf '─%.0s' {1..42})"`,
1317
+ )
1318
+
1319
+ // ── 1. SSH server ─────────────────────────────────────────────────────────
1320
+ if (sshGatewayPublicKey) {
1321
+ push(...banner("START: SSH SERVER"))
1322
+ push(
1323
+ "(",
1324
+ " set +e",
1325
+ " mkdir -p /run/sshd /var/run/sshd",
1326
+ ` mkdir -p ${homeDir}/.ssh`,
1327
+ ` chmod 700 ${homeDir}/.ssh`,
1328
+ ` _GW_KEY=${JSON.stringify(sshGatewayPublicKey)}`,
1329
+ ` grep -qF "$_GW_KEY" ${homeDir}/.ssh/authorized_keys 2>/dev/null || printf '%s\\n' "$_GW_KEY" >> ${homeDir}/.ssh/authorized_keys`,
1330
+ ` chmod 600 ${homeDir}/.ssh/authorized_keys`,
1331
+ ` chown -R ${username}:${username} ${homeDir}/.ssh`,
1332
+ " printf '\\nPubkeyAuthentication yes\\nPasswordAuthentication no\\nChallengeResponseAuthentication no\\nUsePAM no\\n' >> /etc/ssh/sshd_config",
1333
+ " ssh-keygen -A 2>&1",
1334
+ " if command -v /usr/sbin/sshd >/dev/null 2>&1; then",
1335
+ " /usr/sbin/sshd 2>&1 &",
1336
+ " elif command -v /usr/bin/sshd >/dev/null 2>&1; then",
1337
+ " /usr/bin/sshd 2>&1 &",
1338
+ " else",
1339
+ ' echo "SSH server: sshd binary not found"',
1340
+ " fi",
1341
+ ' echo "SSH server started"',
1342
+ " set -e",
1343
+ ")",
1344
+ )
1345
+ }
1346
+
1347
+ // ── 2. Feature entrypoints ────────────────────────────────────────────────
1348
+ push(
1349
+ "",
1350
+ "if [ -f " + FEATURE_ENTRYPOINTS_FILE + " ]; then",
1351
+ ' echo "--- Starting feature entrypoints ---"',
1352
+ " while IFS= read -r _ep; do",
1353
+ ' if [ -f "$_ep" ]; then',
1354
+ ' echo "Starting entrypoint: $_ep"',
1355
+ ' "$_ep" &',
1356
+ " fi",
1357
+ " done < " + FEATURE_ENTRYPOINTS_FILE,
1358
+ "fi",
1359
+ )
1360
+
1361
+ // ── 3. Persist env vars for VS Code terminal login shells ─────────────────
1362
+ push(
1363
+ "",
1364
+ "# Persist worker env for login shells (VS Code integrated terminals read /etc/environment)",
1365
+ "for _var in WORKER_SLUG BASE_DOMAIN PUBLIC_PROTOCOL GITHUB_TOKEN GITHUB_USER GOOGLE_APPLICATION_CREDENTIALS; do",
1366
+ ' _val=$(printenv "$_var" 2>/dev/null || true)',
1367
+ ' [ -n "$_val" ] && echo "${_var}=${_val}" >> /etc/environment || true',
1368
+ "done",
1369
+ )
1370
+
1371
+ // ── 4. Copy prebuilt extensions ───────────────────────────────────────────
1372
+ push(
1373
+ "",
1374
+ 'if [ -d ' + EXTENSIONS_DIR + ' ] && [ "$(ls -A ' + EXTENSIONS_DIR + ' 2>/dev/null)" ]; then',
1375
+ ` mkdir -p ${homeDir}/.local/share/code-server/extensions`,
1376
+ ` cp -rn ${EXTENSIONS_DIR}/. ${homeDir}/.local/share/code-server/extensions/ 2>/dev/null || true`,
1377
+ ` chown -R ${username}:${username} ${homeDir}/.local`,
1378
+ "fi",
1379
+ )
1380
+
1381
+ // ── 4b. Seed code-server user settings (no-clobber) ───────────────────────
1382
+ const vscodeSettings = JSON.stringify(
1383
+ defaultVscodeUserSettings({ projectName: project.name, colorByProject: colorTabsByProject }),
1384
+ null,
1385
+ 2,
1386
+ )
1387
+ push(
1388
+ "",
1389
+ `mkdir -p ${homeDir}/.local/share/code-server/User`,
1390
+ `if [ ! -f ${homeDir}/.local/share/code-server/User/settings.json ]; then`,
1391
+ ` echo ${JSON.stringify(b64(vscodeSettings))} | base64 -d > ${homeDir}/.local/share/code-server/User/settings.json`,
1392
+ ` chown -R ${username}:${username} ${homeDir}/.local`,
1393
+ "fi",
1394
+ )
1395
+
1396
+ // ── 4c. Restore the user's PORT secret for code-server's own terminal panel ─
1397
+ const seedTerminalPortEnv = [
1398
+ "const fs = require('fs')",
1399
+ "const path = process.env.MP_SETTINGS_PATH",
1400
+ "let settings = {}",
1401
+ "try { settings = JSON.parse(fs.readFileSync(path, 'utf8')) } catch {}",
1402
+ "settings['terminal.integrated.env.linux'] = { ...settings['terminal.integrated.env.linux'], PORT: process.env.MP_ORIGINAL_PORT }",
1403
+ "fs.writeFileSync(path, JSON.stringify(settings, null, 2))",
1404
+ ].join("\n")
1405
+ const seedTerminalPortEnvEncoded = b64(seedTerminalPortEnv)
1406
+ push(
1407
+ "",
1408
+ 'if [ -n "${PORT:-}" ]; then',
1409
+ " _mp_cs_bin=$(command -v code-server 2>/dev/null || true)",
1410
+ ' if [ -n "$_mp_cs_bin" ]; then',
1411
+ ' _mp_cs_bin=$(readlink -f "$_mp_cs_bin" 2>/dev/null || echo "$_mp_cs_bin")',
1412
+ ' _mp_cs_node="$(dirname "$(dirname "$_mp_cs_bin")")/lib/node"',
1413
+ ' if [ -x "$_mp_cs_node" ]; then',
1414
+ ` mkdir -p ${homeDir}/.local/share/code-server/User`,
1415
+ ` echo ${JSON.stringify(seedTerminalPortEnvEncoded)} | base64 -d > ${homeDir}/.mp_seed_terminal_port.js`,
1416
+ ` MP_ORIGINAL_PORT="$PORT" MP_SETTINGS_PATH="${homeDir}/.local/share/code-server/User/settings.json" "$_mp_cs_node" ${homeDir}/.mp_seed_terminal_port.js`,
1417
+ ` rm -f ${homeDir}/.mp_seed_terminal_port.js`,
1418
+ ` chown -R ${username}:${username} ${homeDir}/.local`,
1419
+ " fi",
1420
+ " fi",
1421
+ "fi",
1422
+ )
1423
+
1424
+ // ── 5. Start code-server as vscode ────────────────────────────────────────
1425
+ const codeServerLoop = [
1426
+ "#!/bin/bash",
1427
+ "export SHELL=$(command -v zsh 2>/dev/null || echo /bin/bash)",
1428
+ // Same org-level gallery export as the legacy loop above — the two blocks are duplicated on
1429
+ // purpose (RFC 0017 §2.1), so this one has to be kept in step by hand.
1430
+ ...galleryExport(extensionsGallery),
1431
+ "while true; do",
1432
+ ' VSCODE_PROXY_URI="${PUBLIC_PROTOCOL}://${WORKER_SLUG}-{{port}}.${BASE_DOMAIN}" \\',
1433
+ " PORT=8080 code-server --bind-addr 0.0.0.0:8080 --auth none /workspace 2>&1",
1434
+ ' echo "[start] code-server exited ($?), restarting in 3s..."',
1435
+ " sleep 3",
1436
+ "done",
1437
+ ].join("\n")
1438
+ const codeServerEncoded = b64(codeServerLoop)
1439
+ push(
1440
+ ...banner("START: CODE-SERVER"),
1441
+ `echo ${JSON.stringify(codeServerEncoded)} | base64 -d > ${homeDir}/.mp_codeserver.sh`,
1442
+ `chmod +x ${homeDir}/.mp_codeserver.sh`,
1443
+ `su ${username} -s /bin/bash -c "${homeDir}/.mp_codeserver.sh" &`,
1444
+ )
1445
+
1446
+ // ── 6. Signal "container infra up" then stay alive ────────────────────────
1447
+ // The agent waits for this marker before running setup phases that touch Docker (postCreate),
1448
+ // guaranteeing feature entrypoints (dockerd) and env are in place. See RFC 0017 §2.3.
1449
+ push(
1450
+ "",
1451
+ `touch ${homeDir}/.mp-container-up`,
1452
+ 'echo "[container] ready — setup orchestrated by the agent"',
1453
+ "",
1454
+ "wait",
1455
+ )
1456
+
1457
+ return { script: lines.join("\n"), hasDinD }
1458
+ }
1459
+
1460
+ export function buildWorkerScript(params: {
1461
+ setupScript: string
1462
+ startScript: string
1463
+ project: {
1464
+ postCreateCommand: string | null
1465
+ postStartCommand: string | null
1466
+ repositories: { project: string }[]
1467
+ }
1468
+ }): string {
1469
+ const { setupScript, startScript, project } = params
1470
+ const hasPostCreate = !!project.postCreateCommand
1471
+ const hasPostStart = !!project.postStartCommand
1472
+
1473
+ const allReposPending: RS[] = project.repositories.map((r) => ({ name: r.project, state: "pending" }))
1474
+
1475
+ const initialStatus = mkStatus(
1476
+ "initializing",
1477
+ allReposPending,
1478
+ hasPostCreate ? "pending" : null,
1479
+ hasPostStart ? "pending" : null,
1480
+ )
1481
+
1482
+ return [
1483
+ "set -e",
1484
+ "export HOME=/home/vscode",
1485
+ "",
1486
+ // ── Per-phase start timings (worker start-time visibility) ────────────────
1487
+ // Each phase transition stamps a millisecond-epoch timestamp *container-side*
1488
+ // (one clock, unbiased by the backend's 1.5s poll) into a persistent
1489
+ // accumulator file, which `_mp` then merges into .mp-status.json under a
1490
+ // `timings` key on every write. First-write-wins per key, so a restart — which
1491
+ // re-runs only the start script, not setup — never overwrites the original
1492
+ // spawn timings. See docs/worker-states.md § Setup timings.
1493
+ "_MP_TFILE=/home/vscode/.mp-timings",
1494
+ "_mp_now() {",
1495
+ " local n",
1496
+ // Milliseconds epoch. `date +%s%N` = seconds + 9-digit nanoseconds on both GNU and
1497
+ // uutils coreutils (the latter, shipped by some base images, ignores the `%3N`
1498
+ // width and would emit raw nanoseconds — 19 digits that overflow JS safe integers);
1499
+ // dividing by 1e6 in bash 64-bit arithmetic yields a safe 13-digit ms value. Falls
1500
+ // back to seconds×1000 when `%N` is unsupported (busybox → non-numeric output).
1501
+ " n=$(date +%s%N 2>/dev/null || true)",
1502
+ " case \"$n\" in ''|*[!0-9]*) printf '%s' \"$(date +%s 2>/dev/null || echo 0)000\"; return ;; esac",
1503
+ " printf '%s' \"$(( n / 1000000 ))\"",
1504
+ "}",
1505
+ "_mp_stamp() {",
1506
+ " if grep -q \"\\\"$1\\\":\" \"$_MP_TFILE\" 2>/dev/null; then return 0; fi",
1507
+ " local now; now=\"$(_mp_now)\"",
1508
+ " if [ -s \"$_MP_TFILE\" ]; then printf ',' >> \"$_MP_TFILE\"; fi",
1509
+ " printf '\"%s\":%s' \"$1\" \"$now\" >> \"$_MP_TFILE\" || true",
1510
+ // Native OTel: emit this phase transition as a real OTLP span through the node
1511
+ // collector (|| true + suspends set -e inside the fn — telemetry never breaks setup).
1512
+ " _mp_span_on_stamp \"$1\" \"$now\" || true",
1513
+ "}",
1514
+ // mp-status.json lives in the home dir (persistent volume), not /tmp — when a
1515
+ // DinD feature starts dockerd in the background, /tmp's mount visibility can
1516
+ // change for sibling processes (same issue that affected the code-server
1517
+ // restart script, see buildStartScript), silently dropping status writes.
1518
+ "_mp() {",
1519
+ " local j=\"$1\" t=''",
1520
+ " if [ -s \"$_MP_TFILE\" ]; then t=\"$(cat \"$_MP_TFILE\" 2>/dev/null || true)\"; fi",
1521
+ // Splice `,"timings":{…}` in before the status object's closing brace.
1522
+ " if [ -n \"$t\" ]; then j=\"${j%?},\\\"timings\\\":{$t}}\"; fi",
1523
+ // Remember the last status written so the failure trap (_mp_fail) can re-emit it with
1524
+ // phase=error, preserving repos/postCreate/postStart/timings the UI already shows.
1525
+ " _MP_LAST=\"$j\"",
1526
+ " printf '%s' \"$j\" > " + workerStatusPath("/home/vscode") + " || true",
1527
+ "}",
1528
+ "",
1529
+ // ── Native OTel setup spans (emitted by this script, not backdated in the API) ──
1530
+ // Each phase is emitted as a real OTLP span to the node agent's collector (the
1531
+ // auto-injected OTEL_EXPORTER_OTLP_ENDPOINT); the agent forwards to /otlp where org.id
1532
+ // /node.id are stamped server-side. MP_SETUP_TRACEPARENT (the job.worker.spawn span,
1533
+ // injected by the runner) gives the traceId + parent span, so the whole `worker.setup`
1534
+ // subtree nests under the spawn trace. No trace context (or no curl) → silently skipped.
1535
+ "MP_TRACE_ID=''; MP_JOB_SPAN=''",
1536
+ "if [ -n \"${MP_SETUP_TRACEPARENT:-}\" ]; then",
1537
+ " MP_TRACE_ID=$(printf '%s' \"$MP_SETUP_TRACEPARENT\" | cut -d- -f2)",
1538
+ " MP_JOB_SPAN=$(printf '%s' \"$MP_SETUP_TRACEPARENT\" | cut -d- -f3)",
1539
+ "fi",
1540
+ "MP_SETUP_SPAN=''; MP_SETUP_START=''; MP_PREV_PHASE=''; MP_PREV_TS=''",
1541
+ // Failure-trap state. _MP_DONE flips to 1 once setup+start reach `ready` (see buildStartScript),
1542
+ // disarming _mp_fail so a normal container stop / code-server hiccup after ready never looks like
1543
+ // a setup failure. _MP_LAST holds the most recent status JSON (set by _mp) for the error re-emit.
1544
+ "_MP_DONE=0; _MP_LAST=''",
1545
+ "_mp_rand_id() { od -An -tx1 -N8 /dev/urandom 2>/dev/null | tr -d ' \\n'; }",
1546
+ "_mp_phase_span_name() {",
1547
+ " case \"$1\" in",
1548
+ " initializing) echo 'setup.initialize' ;;",
1549
+ " credentials) echo 'setup.credentials' ;;",
1550
+ " shell) echo 'setup.shell' ;;",
1551
+ " cloning) echo 'setup.clone' ;;",
1552
+ " postCreate) echo 'setup.postCreate' ;;",
1553
+ " postStart) echo 'setup.postStart' ;;",
1554
+ " *) echo \"setup.$1\" ;;",
1555
+ " esac",
1556
+ "}",
1557
+ // Emit one OTLP/HTTP JSON span, backgrounded + short timeout so it never adds latency
1558
+ // to (or blocks) the setup path. Args: name start_ms end_ms span_id parent_span_id phase.
1559
+ "_mp_span_emit() {",
1560
+ " if [ -z \"$MP_TRACE_ID\" ] || [ -z \"${OTEL_EXPORTER_OTLP_ENDPOINT:-}\" ]; then return 0; fi",
1561
+ " if ! command -v curl >/dev/null 2>&1; then return 0; fi",
1562
+ // Optional $7 = error message and $8 = exit code turn this into a native ERROR span
1563
+ // (OTel status code 2 + a setup.exit_code attribute) — that's what makes a failed
1564
+ // clone/postCreate/postStart show up red in the waterfall instead of as a hole.
1565
+ " local name=\"$1\" s_ns=$(( $2 * 1000000 )) e_ns=$(( $3 * 1000000 )) sid=\"$4\" pid=\"$5\" phase=\"$6\" errmsg=\"${7:-}\" ecode=\"${8:-}\" dur=$(( $3 - $2 )) attrs='' status=''",
1566
+ " if [ -n \"$phase\" ]; then attrs=\"{\\\"key\\\":\\\"setup.phase\\\",\\\"value\\\":{\\\"stringValue\\\":\\\"$phase\\\"}},{\\\"key\\\":\\\"setup.duration_ms\\\",\\\"value\\\":{\\\"intValue\\\":\\\"$dur\\\"}}\"; fi",
1567
+ " if [ -n \"$ecode\" ]; then [ -n \"$attrs\" ] && attrs=\"$attrs,\"; attrs=\"${attrs}{\\\"key\\\":\\\"setup.exit_code\\\",\\\"value\\\":{\\\"intValue\\\":\\\"$ecode\\\"}}\"; fi",
1568
+ " if [ -n \"$errmsg\" ]; then status=\",\\\"status\\\":{\\\"code\\\":2,\\\"message\\\":\\\"$errmsg\\\"}\"; fi",
1569
+ " local body=\"{\\\"resourceSpans\\\":[{\\\"resource\\\":{\\\"attributes\\\":[{\\\"key\\\":\\\"service.name\\\",\\\"value\\\":{\\\"stringValue\\\":\\\"spunto-worker\\\"}},{\\\"key\\\":\\\"spunto.worker.slug\\\",\\\"value\\\":{\\\"stringValue\\\":\\\"${WORKER_SLUG:-}\\\"}}]},\\\"scopeSpans\\\":[{\\\"scope\\\":{\\\"name\\\":\\\"spunto.setup\\\"},\\\"spans\\\":[{\\\"traceId\\\":\\\"$MP_TRACE_ID\\\",\\\"spanId\\\":\\\"$sid\\\",\\\"parentSpanId\\\":\\\"$pid\\\",\\\"name\\\":\\\"$name\\\",\\\"kind\\\":1,\\\"startTimeUnixNano\\\":\\\"$s_ns\\\",\\\"endTimeUnixNano\\\":\\\"$e_ns\\\",\\\"attributes\\\":[$attrs]$status}]}]}]}\"",
1570
+ " ( curl -s -m 3 -o /dev/null -X POST \"$OTEL_EXPORTER_OTLP_ENDPOINT/v1/traces\" -H 'Content-Type: application/json' -d \"$body\" || true ) &",
1571
+ "}",
1572
+ // Called on each new phase stamp. The first stamp opens the `worker.setup` window; every
1573
+ // later stamp closes the previous phase as a child span; `ready` also emits the parent.
1574
+ "_mp_span_on_stamp() {",
1575
+ " if [ -z \"$MP_TRACE_ID\" ]; then return 0; fi",
1576
+ " local key=\"$1\" now=\"$2\"",
1577
+ " if [ -z \"$MP_SETUP_SPAN\" ]; then",
1578
+ " MP_SETUP_SPAN=$(_mp_rand_id); MP_SETUP_START=\"$now\"; MP_PREV_PHASE=\"$key\"; MP_PREV_TS=\"$now\"; return 0",
1579
+ " fi",
1580
+ " _mp_span_emit \"$(_mp_phase_span_name \"$MP_PREV_PHASE\")\" \"$MP_PREV_TS\" \"$now\" \"$(_mp_rand_id)\" \"$MP_SETUP_SPAN\" \"$MP_PREV_PHASE\"",
1581
+ " if [ \"$key\" = ready ]; then",
1582
+ " _mp_span_emit 'worker.setup' \"$MP_SETUP_START\" \"$now\" \"$MP_SETUP_SPAN\" \"$MP_JOB_SPAN\" ''",
1583
+ " else",
1584
+ " MP_PREV_PHASE=\"$key\"; MP_PREV_TS=\"$now\"",
1585
+ " fi",
1586
+ "}",
1587
+ // ── Native error spans on setup failure (the direct goal of this refactor) ──
1588
+ // set -e makes any uncaught failure abort the CMD; before PR #115 that just left the worker
1589
+ // stuck at its last status with a hole in the trace. This EXIT trap turns that same failure
1590
+ // into: (1) a real OTLP span in ERROR (status code 2 + exit code + the in-flight phase), so the
1591
+ // failing clone/postCreate/postStart shows up red under worker.setup, and (2) a phase=error
1592
+ // status write so the API poller flips the worker to `error` with a message. It fires only on a
1593
+ // non-zero exit and only before `ready` (_MP_DONE guards the normal path); the short sleep lets
1594
+ // the 1.5s poll read the error status before the container exits.
1595
+ "_mp_fail() {",
1596
+ " local code=$?",
1597
+ // We're already exiting — never let errexit abort the trap before it writes the error status.
1598
+ " set +e",
1599
+ " [ \"$_MP_DONE\" = 1 ] && return 0",
1600
+ " [ \"$code\" = 0 ] && return 0",
1601
+ " _MP_DONE=1",
1602
+ " local phase now msg j",
1603
+ " phase=\"$MP_PREV_PHASE\"",
1604
+ " [ -z \"$phase\" ] && phase=$(printf '%s' \"$_MP_LAST\" | sed -n 's/.*\"phase\":\"\\([^\"]*\\)\".*/\\1/p')",
1605
+ " [ -z \"$phase\" ] && phase=initializing",
1606
+ " now=\"$(_mp_now)\"",
1607
+ // A phase that knows *why* it failed (e.g. a clone on a branch that doesn't exist) sets
1608
+ // MP_FAIL_MSG before exiting, so the UI shows that instead of the generic phase message.
1609
+ " msg=\"${MP_FAIL_MSG:-setup phase $phase failed (exit $code)}\"",
1610
+ " echo \"[setup] ✗ $msg\" >&2",
1611
+ // Best-effort error spans: close the failing phase (child of worker.setup) then worker.setup
1612
+ // itself in ERROR. On a restart (postStart failure) the setup window was never opened, so emit
1613
+ // a single worker.setup error span under the job span instead.
1614
+ " if [ -n \"$MP_TRACE_ID\" ]; then",
1615
+ " if [ -n \"$MP_SETUP_SPAN\" ]; then",
1616
+ " _mp_span_emit \"$(_mp_phase_span_name \"$phase\")\" \"${MP_PREV_TS:-$now}\" \"$now\" \"$(_mp_rand_id)\" \"$MP_SETUP_SPAN\" \"$phase\" \"$msg\" \"$code\"",
1617
+ " _mp_span_emit 'worker.setup' \"${MP_SETUP_START:-$now}\" \"$now\" \"$MP_SETUP_SPAN\" \"$MP_JOB_SPAN\" '' \"$msg\" \"$code\"",
1618
+ " else",
1619
+ " _mp_span_emit 'worker.setup' \"${MP_PREV_TS:-$now}\" \"$now\" \"$(_mp_rand_id)\" \"$MP_JOB_SPAN\" \"$phase\" \"$msg\" \"$code\"",
1620
+ " fi",
1621
+ " fi",
1622
+ // Flip .mp-status.json to phase=error (preserving the rest) so monitorSetup surfaces it.
1623
+ " j=\"$_MP_LAST\"",
1624
+ " if [ -n \"$j\" ]; then",
1625
+ " j=$(printf '%s' \"$j\" | sed 's/\"phase\":\"[^\"]*\"/\"phase\":\"error\"/')",
1626
+ " j=\"${j%?},\\\"error\\\":\\\"$msg\\\"}\"",
1627
+ " else",
1628
+ " j=\"{\\\"phase\\\":\\\"error\\\",\\\"repos\\\":[],\\\"postCreate\\\":null,\\\"postStart\\\":null,\\\"error\\\":\\\"$msg\\\"}\"",
1629
+ " fi",
1630
+ " printf '%s' \"$j\" > " + workerStatusPath("/home/vscode") + " || true",
1631
+ // Bridge the API's 1.5s poll interval before the container actually exits.
1632
+ " sleep 5 || true",
1633
+ "}",
1634
+ "trap _mp_fail EXIT",
1635
+ "",
1636
+ "# ─── First-start setup (runs once) ───────────────────────────────────────",
1637
+ "if [ ! -f /home/vscode/.mp-setup-done ]; then",
1638
+ " touch " + workerStatusPath("/home/vscode") + " && chmod 666 " + workerStatusPath("/home/vscode"),
1639
+ " _mp_stamp initializing",
1640
+ ` _mp ${JSON.stringify(initialStatus)}`,
1641
+ setupScript,
1642
+ "fi",
1643
+ "",
1644
+ "# ─── Every-start ─────────────────────────────────────────────────────────",
1645
+ startScript,
1646
+ ].join("\n")
1647
+ }
1648
+
1649
+ // ─── buildSetupPlan (RFC 0017) ─────────────────────────────────────────────────
1650
+ //
1651
+ // The ordered phases the agent execs one by one (each in its own `docker exec` + OTel span) when
1652
+ // WORKER_SETUP_ORCHESTRATOR=agent. Reuses the exact bash blocks from buildSetupScript/buildStartScript
1653
+ // (clone strategies, oh-my-zsh, dotfiles, lifecycle) MINUS the `_mp`/`_mp_stamp`/OTLP machinery — the
1654
+ // agent owns status + spans now. Each phase carries the SetupStatus to persist at its start/end
1655
+ // (mirrors the legacy `mkStatus(...)` writes) so the DB/UI progression is identical. See RFC 0017 §2.2.
1656
+
1657
+ export function buildSetupPlan(params: SetupScriptParams): {
1658
+ phases: SetupPhase[]
1659
+ initialStatus: SetupStatus
1660
+ readyStatus: SetupStatus
1661
+ } {
1662
+ const { project, userInfo, userSshPrivateKey, dotfilesRepo, userEnvSecrets, githubInstallationTokens, projectDeployKey, branch } = params
1663
+ const homeDir = "/home/vscode"
1664
+ const username = "vscode"
1665
+
1666
+ const repoNames = project.repositories.map((r) => r.project)
1667
+ const hasPostCreate = !!project.postCreateCommand
1668
+ const hasPostStart = !!project.postStartCommand
1669
+ const pc0: LS = hasPostCreate ? "pending" : null
1670
+ const ps0: LS = hasPostStart ? "pending" : null
1671
+ const pcDone: LS = hasPostCreate ? "done" : null
1672
+ const psDone: LS = hasPostStart ? "done" : null
1673
+
1674
+ const allReposPending: RS[] = repoNames.map((n) => ({ name: n, state: "pending" }))
1675
+ const allReposCloning: RS[] = repoNames.map((n) => ({ name: n, state: "cloning" }))
1676
+ const allReposDone: RS[] = repoNames.map((n) => ({ name: n, state: "done" }))
1677
+
1678
+ const status = (
1679
+ phase: SetupStatus["phase"],
1680
+ repos: RS[],
1681
+ postCreate: LS,
1682
+ postStart: LS,
1683
+ ): SetupStatus => ({ phase, repos, postCreate, postStart })
1684
+
1685
+ const initialStatus = status("initializing", allReposPending, pc0, ps0)
1686
+ const readyStatus = status("ready", allReposDone, pcDone, psDone)
1687
+
1688
+ const phases: SetupPhase[] = []
1689
+ const hasCredentials = !!(userInfo || userSshPrivateKey || projectDeployKey)
1690
+
1691
+ // ── credentials (+ ownership) ─────────────────────────────────────────────
1692
+ {
1693
+ const l: string[] = ["set -e", ...banner("SETUP: OWNERSHIP"),
1694
+ `chown -R ${username}:${username} /workspace`,
1695
+ `chown -R ${username}:${username} ${homeDir}`,
1696
+ ]
1697
+ if (hasCredentials) l.push(...banner("SETUP: CREDENTIALS"))
1698
+ l.push(...credentialsBlock({ homeDir, username, userInfo, userSshPrivateKey, projectDeployKey }))
1699
+ const st = status(hasCredentials ? "credentials" : "initializing", allReposPending, pc0, ps0)
1700
+ phases.push({ id: "credentials", label: "Credentials & ownership", script: l.join("\n"), runsOnRestart: false, statusStart: st, statusEnd: st })
1701
+ }
1702
+
1703
+ // ── shell (bashrc/zsh/oh-my-zsh, land-in-workspace) ───────────────────────
1704
+ {
1705
+ const landInWorkspace = [
1706
+ ``,
1707
+ `# ── Spunto: land in workspace on fresh login ─────────────────────`,
1708
+ `if [ "$PWD" = "$HOME" ] && [ -d /workspace ]; then`,
1709
+ ` __mp_dirs=$(find /workspace -mindepth 1 -maxdepth 1 -type d -not -name '.*' 2>/dev/null)`,
1710
+ ` __mp_n=$(printf '%s\\n' "$__mp_dirs" | grep -c .)`,
1711
+ ` if [ "$__mp_n" = 1 ]; then cd "$__mp_dirs" 2>/dev/null; else cd /workspace 2>/dev/null; fi`,
1712
+ ` unset __mp_dirs __mp_n`,
1713
+ `fi`,
1714
+ ].join("\n")
1715
+ const bashrcSnippet = [
1716
+ ``,
1717
+ `# ── Spunto shell config ──────────────────────────────────────────`,
1718
+ `__mp_ps1_git() {`,
1719
+ ` local b`,
1720
+ ` b=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null) || return`,
1721
+ ` printf ' \\e[0;33m(%s)\\e[0m' "$b"`,
1722
+ `}`,
1723
+ ``,
1724
+ `PS1='\\n\\[\\e[0;2m\\]\\u@\\h\\[\\e[0m\\] \\[\\e[1;34m\\]\\w\\[\\e[0m\\]$(__mp_ps1_git)\\n\\[\\e[1;32m\\]❯\\[\\e[0m\\] '`,
1725
+ ``,
1726
+ `alias ll='ls -lah --color=auto'`,
1727
+ `alias la='ls -A --color=auto'`,
1728
+ `alias l='ls -CF --color=auto'`,
1729
+ `alias gs='git status'`,
1730
+ `alias gd='git diff'`,
1731
+ `alias gl='git log --oneline --graph --decorate -20'`,
1732
+ landInWorkspace,
1733
+ ].join("\n")
1734
+ const bashrcEncoded = b64(bashrcSnippet)
1735
+ const zshrcAliasSnippet = [
1736
+ ``,
1737
+ `# ── Spunto config ────────────────────────────────────────────────`,
1738
+ `setopt NO_BANG_HIST`,
1739
+ `alias ll='ls -lah --color=auto'`,
1740
+ `alias la='ls -A --color=auto'`,
1741
+ `alias l='ls -CF --color=auto'`,
1742
+ `alias gs='git status'`,
1743
+ `alias gd='git diff'`,
1744
+ `alias gl='git log --oneline --graph --decorate -20'`,
1745
+ landInWorkspace,
1746
+ ].join("\n")
1747
+ const zshrcAliasEncoded = b64(zshrcAliasSnippet)
1748
+ const l: string[] = ["set -e", ...banner("SETUP: SHELL"),
1749
+ `echo ${JSON.stringify(bashrcEncoded)} | base64 -d >> ${homeDir}/.bashrc`,
1750
+ `if command -v zsh >/dev/null 2>&1; then`,
1751
+ ` if [ ! -d "${homeDir}/.oh-my-zsh" ]; then`,
1752
+ ` echo "Installing oh-my-zsh..."`,
1753
+ ` _OMZ_TMP=$(mktemp)`,
1754
+ ` curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh -o "$_OMZ_TMP" 2>&1`,
1755
+ ` chmod 644 "$_OMZ_TMP"`,
1756
+ ` su ${username} -c "HOME=${homeDir} RUNZSH=no CHSH=no KEEP_ZSHRC=no bash $_OMZ_TMP" 2>&1 || true`,
1757
+ ` rm -f "$_OMZ_TMP"`,
1758
+ ` else`,
1759
+ ` echo "oh-my-zsh already installed"`,
1760
+ ` fi`,
1761
+ ` sed -i 's/ZSH_THEME="robbyrussell"/ZSH_THEME="af-magic"/' ${homeDir}/.zshrc 2>/dev/null || true`,
1762
+ ` sed -i 's/^plugins=(git)$/plugins=(git z)/' ${homeDir}/.zshrc 2>/dev/null || true`,
1763
+ ` echo ${JSON.stringify(zshrcAliasEncoded)} | base64 -d >> ${homeDir}/.zshrc`,
1764
+ `fi`,
1765
+ `echo ${JSON.stringify(b64(landInWorkspace))} | base64 -d >> ${homeDir}/.profile`,
1766
+ // Same reason as in credentialsBlock: this phase runs as root, so any rc file it had to
1767
+ // *create* (.bashrc/.zshrc/.profile absent from the image) is root-owned. The legacy script's
1768
+ // final `chown -R` covered that; here each phase leaves the home consistent on its own.
1769
+ `chown -R ${username}:${username} ${homeDir}`,
1770
+ ]
1771
+ const st = status(hasCredentials ? "credentials" : "initializing", allReposPending, pc0, ps0)
1772
+ phases.push({ id: "shell", label: "Shell setup", script: l.join("\n"), runsOnRestart: false, statusStart: st, statusEnd: st })
1773
+ }
1774
+
1775
+ // ── dotfiles ──────────────────────────────────────────────────────────────
1776
+ if (dotfilesRepo) {
1777
+ const dotfilesUrl = dotfilesRepo.startsWith("http") || dotfilesRepo.startsWith("git@")
1778
+ ? dotfilesRepo
1779
+ : `https://github.com/${dotfilesRepo}`
1780
+ const l: string[] = ["set -e", ...banner("SETUP: DOTFILES"),
1781
+ `echo "Cloning dotfiles from ${dotfilesUrl}..."`,
1782
+ `set +e`,
1783
+ `git clone ${JSON.stringify(dotfilesUrl)} ${homeDir}/dotfiles 2>&1`,
1784
+ `_DOTS_EXIT=$?`,
1785
+ `set -e`,
1786
+ `if [ $_DOTS_EXIT -ne 0 ]; then`,
1787
+ ` echo "Dotfiles clone failed (exit $_DOTS_EXIT) — continuing without dotfiles"`,
1788
+ `else`,
1789
+ ` echo "Dotfiles cloned"`,
1790
+ // Cloned as root, but the install script below runs as the user (and so does whoever edits
1791
+ // these files later) — hand the clone over before touching it.
1792
+ ` chown -R ${username}:${username} ${homeDir}/dotfiles`,
1793
+ ` _INSTALL_SCRIPT=""`,
1794
+ ` for _candidate in install.sh bootstrap.sh setup.sh script/setup; do`,
1795
+ ` if [ -f "${homeDir}/dotfiles/$_candidate" ]; then`,
1796
+ ` _INSTALL_SCRIPT="$_candidate"`,
1797
+ ` break`,
1798
+ ` fi`,
1799
+ ` done`,
1800
+ ` if [ -n "$_INSTALL_SCRIPT" ]; then`,
1801
+ ` echo "Running dotfiles install script: $_INSTALL_SCRIPT"`,
1802
+ ` chmod +x "${homeDir}/dotfiles/$_INSTALL_SCRIPT"`,
1803
+ ` set +e`,
1804
+ ` su ${username} -c "cd ${homeDir}/dotfiles && ${envPrefix(userEnvSecrets)}./$_INSTALL_SCRIPT"`,
1805
+ ` _DOTS_INST_EXIT=$?`,
1806
+ ` set -e`,
1807
+ ` if [ $_DOTS_INST_EXIT -ne 0 ]; then`,
1808
+ ` echo "Dotfiles install script exited with $_DOTS_INST_EXIT — continuing"`,
1809
+ ` else`,
1810
+ ` echo "Dotfiles installed"`,
1811
+ ` fi`,
1812
+ ` else`,
1813
+ ` echo "No install script found — symlinking dotfiles..."`,
1814
+ ` for _f in "${homeDir}/dotfiles"/.*; do`,
1815
+ ` _name=$(basename "$_f")`,
1816
+ ` case "$_name" in .|..|.git|.gitignore|.gitmodules) continue ;; esac`,
1817
+ ` ln -sf "$_f" "${homeDir}/$_name" && echo " linked $_name"`,
1818
+ ` done`,
1819
+ ` echo "Dotfiles symlinked"`,
1820
+ ` fi`,
1821
+ `fi`,
1822
+ ]
1823
+ const st = status("dotfiles", allReposPending, pc0, ps0)
1824
+ phases.push({ id: "dotfiles", label: "Dotfiles", script: l.join("\n"), runsOnRestart: false, statusStart: st, statusEnd: st })
1825
+ }
1826
+
1827
+ // ── clone repos ────────────────────────────────────────────────────────────
1828
+ if (project.repositories.length > 0) {
1829
+ const l: string[] = ["set -e", ...banner(`SETUP: CLONE REPOSITORIES (${project.repositories.length})`)]
1830
+ project.repositories.forEach((r, i) => {
1831
+ const { header, lines: cloneLines } = cloneRepoBlock({
1832
+ repo: r,
1833
+ index: i,
1834
+ total: project.repositories.length,
1835
+ homeDir,
1836
+ branch: resolveRepoBranch(r, branch),
1837
+ githubInstallationTokens,
1838
+ userSshPrivateKey,
1839
+ })
1840
+ l.push("", header, ...cloneLines)
1841
+ })
1842
+ // Re-own after cloning (repos cloned as root; postCreate runs as vscode).
1843
+ l.push("", `chown -R ${username}:${username} /workspace`)
1844
+ phases.push({
1845
+ id: "clone",
1846
+ label: "Clone repositories",
1847
+ script: l.join("\n"),
1848
+ runsOnRestart: false,
1849
+ statusStart: status("cloning", allReposCloning, pc0, ps0),
1850
+ statusEnd: status("cloning", allReposDone, pc0, ps0),
1851
+ })
1852
+ }
1853
+
1854
+ // ── postCreate ──────────────────────────────────────────────────────────────
1855
+ if (project.postCreateCommand) {
1856
+ const hasDinDFeature = (project.features?.some((f) => f.id === "docker-in-docker") ?? false) || !!project.dind
1857
+ const postCreateDir = project.repositories.length === 1
1858
+ ? `/workspace/${project.repositories[0].workspacePath}`
1859
+ : "/workspace"
1860
+ const l: string[] = ["set -e"]
1861
+ // Feature entrypoints are started by the passive CMD; just wait for the Docker daemon here.
1862
+ if (hasDinDFeature) {
1863
+ l.push(
1864
+ 'echo "Waiting for Docker daemon..."',
1865
+ "for _i in $(seq 1 30); do",
1866
+ " docker info >/dev/null 2>&1 && break",
1867
+ " sleep 1",
1868
+ "done",
1869
+ 'docker info >/dev/null 2>&1 && echo "Docker daemon ready" || echo "Docker daemon not ready after 30s, continuing anyway"',
1870
+ )
1871
+ }
1872
+ l.push(...banner("SETUP: POST CREATE"))
1873
+ const cmdEncoded = b64(project.postCreateCommand)
1874
+ l.push(
1875
+ `if [ ! -f "${homeDir}/.mp-post-create-done" ]; then`,
1876
+ ` echo "--- Running postCreateCommand ---"`,
1877
+ ` echo ${JSON.stringify(cmdEncoded)} | base64 -d > /tmp/mp_postcreate.sh`,
1878
+ ` chmod +x /tmp/mp_postcreate.sh`,
1879
+ ` su ${username} -c "cd ${postCreateDir} && bash /tmp/mp_postcreate.sh" 2>&1`,
1880
+ ` rm -f /tmp/mp_postcreate.sh`,
1881
+ ` touch "${homeDir}/.mp-post-create-done"`,
1882
+ ` echo "postCreateCommand done"`,
1883
+ `fi`,
1884
+ )
1885
+ phases.push({
1886
+ id: "postCreate",
1887
+ label: "postCreateCommand",
1888
+ script: l.join("\n"),
1889
+ runsOnRestart: false,
1890
+ statusStart: status("lifecycle", allReposDone, "running", ps0),
1891
+ statusEnd: status("lifecycle", allReposDone, "done", ps0),
1892
+ })
1893
+ }
1894
+
1895
+ // ── postStart (runs on every start, restart included) ─────────────────────
1896
+ if (project.postStartCommand) {
1897
+ const postStartDir = project.repositories.length === 1
1898
+ ? `/workspace/${project.repositories[0].workspacePath}`
1899
+ : "/workspace"
1900
+ const l: string[] = ["set -e",
1901
+ // Same defensive Docker wait as legacy: some images ship Docker under a different name.
1902
+ "if [ -f " + FEATURE_ENTRYPOINTS_FILE + " ]; then",
1903
+ " for _i in 1 2 3 4 5; do [ -S /var/run/docker.sock ] && break || sleep 1; done",
1904
+ " if [ -S /var/run/docker.sock ]; then",
1905
+ ' echo "Waiting for Docker daemon..."',
1906
+ " for _i in $(seq 1 30); do docker info >/dev/null 2>&1 && break; sleep 1; done",
1907
+ ' docker info >/dev/null 2>&1 && echo "Docker daemon ready" || echo "Docker daemon not ready, continuing"',
1908
+ " fi",
1909
+ "fi",
1910
+ ...banner("START: POST START"),
1911
+ ]
1912
+ const cmdEncoded = b64(project.postStartCommand)
1913
+ l.push(
1914
+ `echo "--- Running postStartCommand ---"`,
1915
+ `echo ${JSON.stringify(cmdEncoded)} | base64 -d > /tmp/mp_poststart.sh`,
1916
+ `chmod +x /tmp/mp_poststart.sh`,
1917
+ `su ${username} -c "cd ${postStartDir} && bash /tmp/mp_poststart.sh" 2>&1`,
1918
+ `rm -f /tmp/mp_poststart.sh`,
1919
+ `echo "postStartCommand done"`,
1920
+ )
1921
+ phases.push({
1922
+ id: "postStart",
1923
+ label: "postStartCommand",
1924
+ script: l.join("\n"),
1925
+ runsOnRestart: true,
1926
+ statusStart: status("lifecycle", allReposDone, pcDone, "running"),
1927
+ statusEnd: status("lifecycle", allReposDone, pcDone, "done"),
1928
+ })
1929
+ }
1930
+
1931
+ return { phases, initialStatus, readyStatus }
1932
+ }