dreamteamer 0.29.0 → 0.30.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.
@@ -48,7 +48,7 @@ schema:
48
48
  # ---- network ----
49
49
  editor_url:
50
50
  type: string
51
- description: Where the person opens the editor — `http://localhost:<port>/?folder=<workspace dir>`; the host port comes from DT_PORT_BASE upward, bound to DT_BIND (127.0.0.1).
51
+ description: Where the person opens the machine — `http://localhost:<port>/`, its home (every workspace, create, clone); the host port comes from DT_PORT_BASE upward, bound to DT_BIND (127.0.0.1). Never carries the image's URL token — `dt open container <name>` prints the URL that does.
52
52
  port:
53
53
  type: integer
54
54
  description: The host port the editor is published on.
@@ -65,7 +65,7 @@ schema:
65
65
  mounts:
66
66
  type: array
67
67
  items: { type: string }
68
- description: Extra bind or volume mounts passed as `--mount <host-path|volume>:<container-path>[:ro]`.
68
+ description: Extra bind or volume mounts passed as `--mount <host-path|volume>:<container-path>[:ro]` — targets under /workspaces · /home/node · /files · /mnt only, none at or under another mount's target (the own volumes included), and no bind source inside another.
69
69
  # ---- person ----
70
70
  person:
71
71
  type: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dreamteamer",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "A workspace compiler for coding agents — schema-validated records as plain files over git, compiled into every harness",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Gilad Khen <giladkhen@gmail.com>",
package/src/cli.js CHANGED
@@ -17,6 +17,7 @@ import { compile, staleness, warnIfStale, discoverModules, CHANNEL_LABEL, locati
17
17
  import { check } from './check.js';
18
18
  import { collectionCommand, emit, relationsCommand, parseArgs, refuseUnknownFlags } from './collections-cli.js';
19
19
  import { driverTarget, driverCommand, setup as hostSetup, parseFlags as hostFlags, DRIVER_VERBS, LIFECYCLE_VERBS, CONTAINER_FLAGS } from './containers.js';
20
+ import { archiveCommand } from './container-archive.js';
20
21
  import { init, installClone, update, listRepos } from './init.js';
21
22
  import { installCommand, describeCheckout, listWorktrees, worktreeCommand } from './checkout.js';
22
23
  import { proveCommand, readLedger, flagEnabled } from './prove.js';
@@ -251,22 +252,43 @@ these verbs work with NO workspace, so npm i -g dreamteamer and Docker Desktop a
251
252
  DT_DOCKER_TIMEOUT 30 — seconds a request to Docker may sit idle before the verb
252
253
  fails), lists the templates present, pulls one on request [--template <t>] [--json]
253
254
  start container <name> --template <t> create-if-absent and start: a code-server editor at
254
- http://localhost:<port>/?folder=/workspace over a compiled workspace, three named volumes
255
- (workspace · home · files), image <DT_REGISTRY>/<template>:<tag> or DT_IMAGE_<template>.
256
- The workspace is mounted at /workspaces/<name>. Idempotent. NO token is ever injected —
257
- log in INSIDE, once; the home volume keeps it.
255
+ http://localhost:<port>/ — the machine home — over a compiled workspace, three named
256
+ volumes (workspace · home · files), its own bridge network dreamteamer-<name>, image
257
+ <DT_REGISTRY>/<template>:<tag> or DT_IMAGE_<template>. The workspace is mounted at
258
+ /workspaces/<name>. Idempotent. NO credential is ever injected — log in INSIDE, once;
259
+ the home volume keeps it. An image with a URL token (hq 0.6+) prints the URL as
260
+ ?tkn=<token>, read from the container; the token is never written on the host.
261
+ [--rotate-token] replace the image's URL token and print the new URL
262
+ [--workspace [<w>]] open /workspaces/<w> (default: its own) instead of the home
258
263
  [--repo <git url>] clone an EXISTING workspace into the volume on first start, instead
259
264
  of laying the template down — how a person joins one on GitHub
260
- [--mount <host-path|volume>:<container-path>[:ro]] extra mounts, repeatable
265
+ [--mount <host-path|volume>:<container-path>[:ro]] extra mounts, repeatable; targets
266
+ under /workspaces · /home/node · /files · /mnt, none at or under
267
+ another mount's target, no bind source inside another
261
268
  [--name <git name>] [--email <git email>] [--no-open] [--json]
262
269
  stop container <name> stop it; every volume kept [--json]
263
- open container <name> print (and open) its editor URL [--no-open]
270
+ open container <name> print (and open) its URL, token included [--no-open] [--json]
271
+ [--workspace [<w>]]
264
272
  [--vscode] print (and open) the Dev Containers attach URI instead — the host's own
265
273
  VS Code inside the container, extensions from the image's metadata label
266
274
  list containers | images the record verbs, answered over Docker instead of a
267
275
  get container <name> | image <ref> folder — singular or plural, either spelling.
268
- rm container <name> [--force] plain rm keeps the volumes; --force removes them too
276
+ rm container <name> [--force] removes it and its network; keeps the volumes unless --force
269
277
  add image --template <t> pull a template's image; rm image <ref> removes one
278
+ export container <name> --out <file> its WORKSPACES as one file — every folder under
279
+ /workspaces, never the home or a login; node_modules and .files stay behind. Works on
280
+ a stopped container. Encrypted with the owner passphrase (DT_EXPORT_PASSPHRASE, else a
281
+ prompt — never a flag). [--workspace <w>]... only these [--no-encrypt] a plain .tar.gz
282
+ [--json] (both verbs) the summary as JSON on stdout, every other line on stderr
283
+ Secrets stay behind: .env and .env.* (not .example/.sample/.template), .envrc,
284
+ .npmrc, .netrc, .git-credentials, .pypirc, .docker/config.json, and the credentials in
285
+ each .git/config. [--with-secrets] carries them unchanged
286
+ import container <name> <file> unpack an export into a RUNNING container, owned by
287
+ node. Refuses a wrong passphrase, a damaged file, an entry leaving its workspace and a
288
+ workspace already holding files — each before anything is written.
289
+ [--workspace <w>]... only these [--replace] empty a workspace that holds files first
290
+ [--as <name>] land the ONE workspace in /workspaces/<name> — e.g. another container's
291
+ own volume folder (dt-new's name rule; never the container's own layer)
270
292
 
271
293
  changes what changed in every repo that holds records, as record events
272
294
  [--since <sha|YYYY-MM-DD>] (default: HEAD~1 — the last commit's own changes) [--json]
@@ -325,7 +347,7 @@ export const WORKSPACE_FLAGS = {
325
347
  // `start` is TWO forms: bare, the REST api (--port); with a `container <name>` target, the
326
348
  // lifecycle verb — whose flags are the driver's. One table, because `flags-honoured` reads it.
327
349
  start: ['port', ...CONTAINER_FLAGS], compile: ['watch'], check: [], status: ['strict'],
328
- setup: ['template', 'json'], stop: ['json'], open: ['json', 'no-open', 'vscode'],
350
+ setup: ['template', 'json'], stop: ['json'], open: ['json', 'no-open', 'vscode', 'workspace'],
329
351
  changes: ['since', 'json'], commit: ['dry-run', 'json'],
330
352
  export: EXPORT_FLAGS,
331
353
  // the UNION of every form's flags — the outer typo gate. Which flags each FORM takes is refused
@@ -798,6 +820,9 @@ function hostDispatch(cmd, rest) {
798
820
  return hostSetup(hostFlags(rest).flags);
799
821
  }
800
822
  const target = driverTarget(rest[0]);
823
+ // `export container` is the driver's; `export notebooklm` stays the workspace verb below
824
+ if (target && (cmd === 'export' || cmd === 'import')) return archiveCommand(cmd, target, rest.slice(1));
825
+ if (cmd === 'import') return Promise.reject(new Error('dt import container <name> <file> [--workspace <w>]... [--replace]'));
801
826
  if (target && DRIVER_VERBS.has(cmd)) return driverCommand(cmd, target, rest.slice(1));
802
827
  // A lifecycle verb aimed at anything else is refused by name: `dt start tasks` is not a
803
828
  // server and not a container, and "unknown collection" would send the reader the wrong way.
@@ -0,0 +1,356 @@
1
+ // container-archive.js — `dt export container` and `dt import container`: a machine's WORKSPACES out
2
+ // of one container and into another, as one file. The move primitive between two laptops (and, when
3
+ // the other side implements docs/container-export-format.md, between a laptop and a hosted machine).
4
+ //
5
+ // WHAT TRAVELS. The trees under /workspaces, one folder per workspace — never the home, so never a
6
+ // login: there is no flag that adds it. `node_modules` and `.files` folders stay behind at any depth
7
+ // (a reinstall and a files folder are both per-machine). Hard links, devices and fifos are skipped on
8
+ // the way out and REFUSED on the way in, with setuid bits and owners dropped both ways: this file
9
+ // writes every tar header itself rather than passing Docker's through.
10
+ //
11
+ // NO SECRETS BY DEFAULT (principle 8: an export strips them unless the owner asks). Every `.env` and
12
+ // `.git-credentials` stays behind, and each `.git/config` travels with the userinfo cut from its
13
+ // http(s) URLs and its `extraheader` lines (where CI tooling parks a token) dropped. `--with-secrets`
14
+ // carries all of it unchanged and says so. Encryption is on either way.
15
+ //
16
+ // WHY THE TAR IS PARSED HERE. The Engine API speaks tar both ways (GET/PUT …/archive) and cannot
17
+ // filter, re-root or check it. Node has gzip and AES-GCM but no tar, and the subset needed — ustar
18
+ // headers, PAX and GNU long names in, ustar plus PAX out — is ~60 lines, against a dependency.
19
+ //
20
+ // ENCRYPTED BY DEFAULT, under the owner's passphrase: scrypt (N=2^17) → AES-256-GCM over 64 KiB
21
+ // chunks, each with its own nonce and tag and the LAST one marked, so a flipped byte, a dropped chunk
22
+ // and a truncated file all fail authentication. The header carries an HMAC under the derived key, so
23
+ // a wrong passphrase is told apart from damage before a single byte is decrypted. The passphrase comes
24
+ // from DT_EXPORT_PASSPHRASE (the process env only, never ~/.dreamteamer/.env) or a no-echo prompt —
25
+ // never a flag, because every local user's `ps` reads argv.
26
+ //
27
+ // IMPORT WRITES NOTHING until the whole file has been read once: pass 1 authenticates every chunk and
28
+ // checks every entry (no absolute path, no `..`, no symlink leaving its workspace, no link or device),
29
+ // pass 2 uploads. Then `chown -R node:node` on the imported folders, as root, by exec.
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+ import zlib from 'node:zlib';
33
+ import crypto from 'node:crypto';
34
+ import { Readable } from 'node:stream';
35
+ import { pipeline } from 'node:stream/promises';
36
+ import { api, ok, inspectContainer, exec, parseFlags } from './containers.js';
37
+ import { emit } from './collections-cli.js';
38
+
39
+ const ROOT = '/workspaces';
40
+ const WS_NAME = /^[a-z0-9][a-z0-9_.-]*$/;
41
+ // `--as` names a NEW folder, so it takes dt-new's rule (hq image, new-workspace.sh), reserved words included
42
+ const NEW_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
43
+ const RESERVED = new Set(['files', 'lost-found', 'trash']);
44
+ const LEFT_BEHIND = new Set(['node_modules', '.files']);
45
+ // credential files by NAME, at any depth: `.env` and every `.env.*` but the three template spellings,
46
+ // and the dotfiles package managers and git keep tokens in — docs/container-export-format.md
47
+ const SECRET_FILES = new Set(['.env', '.envrc', '.npmrc', '.netrc', '.git-credentials', '.pypirc']);
48
+ const ENV_TEMPLATES = new Set(['.env.example', '.env.sample', '.env.template']);
49
+ export const isSecretFile = (segs) => { const b = segs[segs.length - 1]; return SECRET_FILES.has(b) || (b.startsWith('.env.') && !ENV_TEMPLATES.has(b)) || (b === 'config.json' && segs[segs.length - 2] === '.docker'); };
50
+ /** A `.git/config` minus its credentials: every `[credential]` / `[credential "…"]` section dropped
51
+ * (an inline `!` helper can hold a password), every `extraheader` line dropped (where CI parks a
52
+ * token), and the userinfo cut from every http(s) URL — values and `[url "…"]` section names alike. */
53
+ export function stripGitConfig(text) {
54
+ let drop = false;
55
+ const kept = text.split(/(?<=\n)/).filter((line) => {
56
+ const head = /^\s*\[\s*([^\]\s."]+)/.exec(line);
57
+ if (head) drop = head[1].toLowerCase() === 'credential';
58
+ return !drop && !/^\s*extraheader\s*=/i.test(line);
59
+ });
60
+ return kept.join('').replace(/(https?:\/\/)[^@/\s"\]]*@/gi, '$1');
61
+ }
62
+ const FILE = '0', DIR = '5', SYMLINK = '2';
63
+ const FLAGS = { export: ['workspace', 'out', 'no-encrypt', 'with-secrets', 'json'], import: ['workspace', 'replace', 'as', 'json'] };
64
+
65
+ // ---- tar: read any of ustar · PAX · GNU long names, write ustar (+ PAX when a name is long) -------
66
+ const str = (b, o, n) => { const e = b.indexOf(0, o); return b.toString('utf8', o, e === -1 || e > o + n ? o + n : e); };
67
+ const num = (b, o, n) => (b[o] & 0x80 ? b.readUIntBE(o + n - 6, 6) : parseInt(str(b, o, n).trim() || '0', 8)); // base-256 above 8 GiB
68
+ const sum = (h) => { let s = 0; for (let i = 0; i < 512; i++) s += i >= 148 && i < 156 ? 32 : h[i]; return s; };
69
+ const pad = (n) => (512 - (n % 512)) % 512;
70
+ export const TAR_END = Buffer.alloc(1024);
71
+
72
+ function header({ name, type, mode, size, mtime, linkname = '' }) {
73
+ const h = Buffer.alloc(512);
74
+ const oct = (v, o, n) => h.write(`${Math.floor(v).toString(8).padStart(n - 1, '0')}\0`, o, n, 'latin1');
75
+ h.write(name, 0, 100); oct(mode, 100, 8); oct(0, 108, 8); oct(0, 116, 8);
76
+ if (size < 8 ** 11) oct(size, 124, 12); else { h[124] = 0x80; h.writeUIntBE(size, 130, 6); }
77
+ oct(mtime, 136, 12); h.write(type, 156, 1, 'latin1'); h.write(linkname, 157, 100); h.write('ustar\u000000', 257, 8, 'latin1');
78
+ oct(sum(h), 148, 7); h[155] = 32;
79
+ if (Buffer.byteLength(name) <= 100 && Buffer.byteLength(linkname) <= 100) return h;
80
+ // a PAX record's length counts its own digits
81
+ const rec = (k, v) => { const body = ` ${k}=${v}\n`; const n = Buffer.byteLength(body); return `${n + String(n + String(n).length).length}${body}`; };
82
+ const pax = Buffer.from(rec('path', name) + (linkname ? rec('linkpath', linkname) : ''));
83
+ return Buffer.concat([header({ name: 'PaxHeader', type: 'x', mode: 0o644, size: pax.length, mtime }), pax, Buffer.alloc(pad(pax.length)), h]);
84
+ }
85
+
86
+ /** Re-emit a tar: `decide(entry)` answers the name to write it under, or null to drop it, or
87
+ * { name, rewrite } to replace a small file's body. Headers are rewritten (owner 0, permission bits
88
+ * only); bodies stream through. No end marker — the caller adds one. */
89
+ export async function* retar(source, decide) {
90
+ const it = source[Symbol.asyncIterator]();
91
+ let buf = Buffer.alloc(0);
92
+ const fill = async (n) => { while (buf.length < n) { const r = await it.next(); if (r.done) return false; buf = buf.length ? Buffer.concat([buf, r.value]) : r.value; } return true; };
93
+ const take = async (n) => { if (!(await fill(n))) throw new Error('the archive ends in the middle of an entry'); const b = buf.subarray(0, n); buf = buf.subarray(n); return b; };
94
+ let ext = {};
95
+ for (;;) {
96
+ if (!(await fill(512))) throw new Error('the archive ends without its end marker');
97
+ const h = await take(512);
98
+ if (h.every((b) => b === 0)) return;
99
+ if (num(h, 148, 8) !== sum(h)) throw new Error('an archive header fails its checksum');
100
+ const type = h[156] ? String.fromCharCode(h[156]) : FILE;
101
+ let size = num(h, 124, 12);
102
+ if ('xgLK'.includes(type)) {
103
+ if (size > 1 << 20) throw new Error('an archive extension header is over 1 MiB');
104
+ const body = (await take(size + pad(size))).subarray(0, size);
105
+ if (type === 'L') ext.path = str(body, 0, size);
106
+ else if (type === 'K') ext.linkpath = str(body, 0, size);
107
+ else if (type === 'x') for (const m of body.toString('utf8').matchAll(/\d+ ([^=]+)=([^\n]*)\n/g)) ext[m[1]] = m[2];
108
+ continue;
109
+ }
110
+ const prefix = str(h, 345, 155);
111
+ const e = { name: ext.path ?? (prefix ? `${prefix}/${str(h, 0, 100)}` : str(h, 0, 100)), linkname: ext.linkpath ?? str(h, 157, 100), type: type === '7' ? FILE : type, mode: num(h, 100, 8) & 0o777, mtime: num(h, 136, 12) };
112
+ size = ext.size !== undefined ? Number(ext.size) : size;
113
+ e.size = e.type === FILE ? size : 0;
114
+ ext = {};
115
+ const d = decide(e);
116
+ if (d?.rewrite && e.type === FILE && size <= 1 << 20) {
117
+ const body = d.rewrite((await take(size + pad(size))).subarray(0, size));
118
+ yield header({ ...e, name: d.name, size: body.length }); yield body; yield Buffer.alloc(pad(body.length));
119
+ continue;
120
+ }
121
+ const name = d?.name ?? d;
122
+ if (name) yield header({ ...e, name });
123
+ const keep = name && e.type === FILE; // only a file's body is written; any other entry's is read past
124
+ for (let left = size + pad(size); left > 0;) {
125
+ if (!(await fill(1))) throw new Error('the archive ends in the middle of a file');
126
+ const b = buf.subarray(0, Math.min(left, buf.length)); buf = buf.subarray(b.length); left -= b.length;
127
+ if (keep) yield b;
128
+ }
129
+ }
130
+ }
131
+
132
+ // ---- the sealed format (docs/container-export-format.md) ---------------------------------------
133
+ const MAGIC = Buffer.from('DTEXPORT');
134
+ const HEAD = 40, MAC = 32, CHUNK = 64 * 1024;
135
+ const KDF = { log2N: 17, r: 8, p: 1 }; // the ONLY parameters v1 writes or reads — see openSeal
136
+
137
+ function scryptKeys(pass, salt, log2N, r, p) {
138
+ return new Promise((resolve, reject) => crypto.scrypt(pass.normalize('NFC'), salt, 64, { N: 2 ** log2N, r, p, maxmem: 256 * 2 ** log2N * r * p }, (e, k) => (e ? reject(e) : resolve({ enc: k.subarray(0, 32), mac: k.subarray(32) }))));
139
+ }
140
+ const hmac = (key, b) => crypto.createHmac('sha256', key).update(b).digest();
141
+ const nonce = (prefix, i, last) => { const n = Buffer.alloc(12); prefix.copy(n); n.writeUInt32BE(i, 7); n[11] = last ? 1 : 0; return n; };
142
+
143
+ async function newSeal(pass) {
144
+ const h = Buffer.alloc(HEAD);
145
+ MAGIC.copy(h); h[8] = 1; h[9] = KDF.log2N; h[10] = KDF.r; h[11] = KDF.p; h.writeUInt32BE(CHUNK, 12);
146
+ crypto.randomFillSync(h, 16, 23); // salt 16..31 · nonce prefix 32..38 · 39 reserved
147
+ const keys = await scryptKeys(pass, h.subarray(16, 32), KDF.log2N, KDF.r, KDF.p);
148
+ return { keys, head: Buffer.concat([h, hmac(keys.mac, h)]) };
149
+ }
150
+
151
+ /** Parse and authenticate a header — a wrong passphrase fails HERE, before any chunk is opened.
152
+ * The KDF parameters are read BEFORE the MAC can be checked (the MAC needs the key they derive), so
153
+ * they are an attacker's to choose: only the exact values this version writes are accepted, before
154
+ * scrypt runs and before the passphrase is even asked for (`pass` may be a function that asks). */
155
+ export async function openSeal(file, pass) {
156
+ const fd = fs.openSync(file, 'r');
157
+ const h = Buffer.alloc(HEAD + MAC);
158
+ const n = fs.readSync(fd, h, 0, h.length, 0); fs.closeSync(fd);
159
+ if (n < h.length) throw new Error(`${file} is too short to be an encrypted export`);
160
+ if (h[8] !== 1) throw new Error(`${file} is export format version ${h[8]} — this engine reads version 1; upgrade dreamteamer`);
161
+ const [log2N, r, p, chunk] = [h[9], h[10], h[11], h.readUInt32BE(12)];
162
+ if (log2N !== KDF.log2N || r !== KDF.r || p !== KDF.p || chunk !== CHUNK) throw new Error(`${file}: its header asks for scrypt N=2^${log2N} r=${r} p=${p} and ${chunk}-byte chunks — version 1 is exactly N=2^${KDF.log2N} r=${KDF.r} p=${KDF.p}, ${CHUNK}; refusing these parameters before any work`);
163
+ const keys = await scryptKeys(typeof pass === 'function' ? await pass() : pass, h.subarray(16, 32), log2N, r, p);
164
+ if (!crypto.timingSafeEqual(hmac(keys.mac, h.subarray(0, HEAD)), h.subarray(HEAD))) throw new Error(`wrong passphrase for ${file} (or its header is damaged) — nothing was written`);
165
+ return { keys, prefix: h.subarray(32, 39), chunk };
166
+ }
167
+
168
+ async function* seal(src, { keys, head }) {
169
+ const prefix = head.subarray(32, 39);
170
+ const box = (pt, i, last) => { const c = crypto.createCipheriv('aes-256-gcm', keys.enc, nonce(prefix, i, last)); return Buffer.concat([c.update(pt), c.final(), c.getAuthTag()]); };
171
+ yield head;
172
+ let buf = Buffer.alloc(0), i = 0;
173
+ for await (const b of src) { buf = Buffer.concat([buf, b]); while (buf.length > CHUNK) { yield box(buf.subarray(0, CHUNK), i++, false); buf = buf.subarray(CHUNK); } }
174
+ yield box(buf, i, true); // a final chunk always exists, empty or not — its flag is what makes truncation visible
175
+ }
176
+
177
+ async function* unseal(src, { keys, prefix, chunk }) {
178
+ const open = (b, i, last) => {
179
+ const d = crypto.createDecipheriv('aes-256-gcm', keys.enc, nonce(prefix, i, last));
180
+ d.setAuthTag(b.subarray(b.length - 16));
181
+ try { return Buffer.concat([d.update(b.subarray(0, b.length - 16)), d.final()]); } catch { throw new Error(`the export is damaged or truncated (chunk ${i} fails authentication) — nothing was written`); }
182
+ };
183
+ let buf = Buffer.alloc(0), i = 0;
184
+ for await (const b of src) { buf = Buffer.concat([buf, b]); while (buf.length > chunk + 16) { yield open(buf.subarray(0, chunk + 16), i++, false); buf = buf.subarray(chunk + 16); } }
185
+ if (buf.length < 16) throw new Error('the export is truncated (its last chunk is missing) — nothing was written');
186
+ yield open(buf, i, true);
187
+ }
188
+
189
+ // ---- the passphrase: env or a no-echo prompt, never argv -----------------------------------------
190
+ async function passphrase({ confirm }) {
191
+ let v = process.env.DT_EXPORT_PASSPHRASE;
192
+ if (v === undefined) {
193
+ if (!process.stdin.isTTY) throw new Error('an encrypted export needs the owner passphrase — set DT_EXPORT_PASSPHRASE, or run in a terminal to be asked (it is never a flag); --no-encrypt writes a plain .tar.gz');
194
+ v = await ask('passphrase: ');
195
+ if (confirm && (await ask('again: ')) !== v) throw new Error('the two passphrases differ — nothing was written');
196
+ }
197
+ if (confirm && v.length < 8) throw new Error('the passphrase must be at least 8 characters');
198
+ if (!v) throw new Error('the passphrase is empty');
199
+ return v;
200
+ }
201
+
202
+ function ask(prompt) {
203
+ return new Promise((resolve, reject) => {
204
+ const s = process.stdin; let v = '';
205
+ const done = (f) => { s.off('data', on); s.setRawMode(false); s.pause(); process.stderr.write('\n'); f(); };
206
+ const on = (d) => {
207
+ for (const ch of d) {
208
+ if (ch === '\r' || ch === '\n') return done(() => resolve(v));
209
+ if (ch === '\u0003' || ch === '\u0004') return done(() => reject(new Error('cancelled — nothing was written')));
210
+ v = ch === '\u007f' || ch === '\b' ? v.slice(0, -1) : v + ch;
211
+ }
212
+ };
213
+ process.stderr.write(prompt); s.setRawMode(true); s.setEncoding('utf8'); s.on('data', on); s.resume();
214
+ });
215
+ }
216
+
217
+ // ---- export --------------------------------------------------------------------------------------
218
+ export async function exportContainer(name, flags, log = console.log) {
219
+ const out = typeof flags.out === 'string' ? path.resolve(flags.out) : null;
220
+ if (!out) throw new Error(`dt export container ${name} --out <file> — where the export is written`);
221
+ if (fs.existsSync(out)) throw new Error(`${out} already exists — name a new file`);
222
+ const only = flags.workspaces ?? [];
223
+ for (const w of only) if (typeof w !== 'string' || !WS_NAME.test(w)) throw new Error(`--workspace takes a workspace folder name under ${ROOT} — got "${w}"`);
224
+ const c = await inspectContainer(name);
225
+ if (!c) throw new Error(`no container "${name}" — dt list containers`);
226
+ const encrypt = flags['no-encrypt'] !== true;
227
+ const secrets = flags['with-secrets'] === true;
228
+ const sealer = encrypt ? await newSeal(await passphrase({ confirm: true })) : null;
229
+ // one read of the root, or one per named workspace so the others never leave the container
230
+ const sources = only.length ? only.map((w) => ({ at: `${ROOT}/${w}`, strip: '' })) : [{ at: ROOT, strip: `${path.posix.basename(ROOT)}/` }];
231
+ const stats = new Map(); const left = new Set(); const skipped = [];
232
+ const decide = (strip) => (e) => {
233
+ const rel = e.name.slice(strip.length).replace(/\/+$/, '');
234
+ if (!e.name.startsWith(strip) || !rel) return null;
235
+ const segs = rel.split('/');
236
+ if (segs.some((s) => LEFT_BEHIND.has(s))) { left.add(segs.slice(0, segs.findIndex((s) => LEFT_BEHIND.has(s)) + 1).join('/')); return null; }
237
+ if (!WS_NAME.test(segs[0]) || (segs.length === 1 && e.type !== DIR)) { if (segs.length === 1) skipped.push(`${rel} (not a workspace folder)`); return null; }
238
+ if (!secrets && isSecretFile(segs) && e.type !== DIR) { left.add(rel); return null; }
239
+ if (![FILE, DIR, SYMLINK].includes(e.type)) { skipped.push(`${rel} (${e.type === '1' ? 'hard link' : 'device or fifo'})`); return null; }
240
+ const s = stats.get(segs[0]) ?? { files: 0, bytes: 0 }; stats.set(segs[0], s);
241
+ if (e.type === FILE) { s.files++; s.bytes += e.size; }
242
+ if (!secrets && e.type === FILE && rel.endsWith('/.git/config')) return { name: rel, rewrite: (b) => Buffer.from(stripGitConfig(b.toString('utf8'))) };
243
+ return e.type === DIR ? `${rel}/` : rel;
244
+ };
245
+ async function* tar() {
246
+ for (const s of sources) {
247
+ const r = await api('GET', `/containers/${c.Id}/archive?path=${encodeURIComponent(s.at)}`, undefined, { stream: true });
248
+ if (r.status === 404) throw new Error(`${name} has no ${s.at}`);
249
+ ok(r, `read ${s.at} from ${name}`);
250
+ yield* retar(r.res, decide(s.strip));
251
+ }
252
+ yield TAR_END;
253
+ }
254
+ const tmp = `${out}.partial`;
255
+ // an interrupted export leaves nothing: not a half file that looks like a whole one, not ciphertext
256
+ const onSignal = (sig) => { fs.rmSync(tmp, { force: true }); process.exit(sig === 'SIGINT' ? 130 : 143); };
257
+ process.once('SIGINT', onSignal); process.once('SIGTERM', onSignal);
258
+ try {
259
+ await pipeline(Readable.from(tar()), zlib.createGzip(), ...(sealer ? [(src) => seal(src, sealer)] : []), fs.createWriteStream(tmp, { flags: 'wx', mode: 0o600 }));
260
+ fs.renameSync(tmp, out);
261
+ } catch (e) { fs.rmSync(tmp, { force: true }); throw e; } finally { process.off('SIGINT', onSignal); process.off('SIGTERM', onSignal); }
262
+ if (!stats.size) { fs.rmSync(out); throw new Error(`${name} holds no workspace folder under ${ROOT} — nothing was written`); }
263
+ for (const [w, s] of stats) log(` ${w} ${s.files} files · ${(s.bytes / 1e6).toFixed(1)} MB`);
264
+ if (left.size) log(` left behind: ${[...left].join(' · ')}`);
265
+ for (const s of skipped) log(` skipped: ${s}`);
266
+ if (secrets) log('⚠ --with-secrets: every credential file (.env*, .npmrc, .netrc, …) and .git/config credential is INCLUDED, unchanged — whoever opens this file holds them');
267
+ else log(' secrets left out: credential files (.env*, .envrc, .npmrc, .netrc, .git-credentials, .pypirc, .docker/config.json); credentials cut from .git/config (--with-secrets keeps them)');
268
+ log(`✔ exported ${stats.size} workspace(s) from ${name} to ${out} · ${encrypt ? 'encrypted with the owner passphrase' : 'NOT encrypted (--no-encrypt): a plain .tar.gz anyone holding the file can read'}`);
269
+ return { container: name, out, encrypted: encrypt, with_secrets: secrets, workspaces: [...stats].map(([w, s]) => ({ name: w, ...s })), left_behind: [...left], skipped };
270
+ }
271
+
272
+ // ---- import --------------------------------------------------------------------------------------
273
+ /** The name to upload an entry under — re-rooted at `as` when given — null to leave it out
274
+ * (`--workspace`), or a refusal. `found` collects the SOURCE workspace names. */
275
+ function checkEntry(e, only, found, as) {
276
+ const name = e.name.replace(/^(\.\/)+/, '');
277
+ const rel = name.replace(/\/+$/, '');
278
+ const segs = rel.split('/');
279
+ const refuse = (why) => { throw new Error(`the export holds "${e.name}", which ${why} — nothing was written`); };
280
+ if (name.startsWith('/')) refuse('is an absolute path');
281
+ if (segs.some((s) => s === '..' || s === '.' || s === '')) refuse(`leaves ${ROOT}`);
282
+ if (!WS_NAME.test(segs[0]) || (segs.length === 1 && e.type !== DIR)) refuse(`is not inside a workspace folder`);
283
+ if (![FILE, DIR, SYMLINK].includes(e.type)) refuse(`is a ${e.type === '1' ? 'hard link' : 'device, fifo or unknown entry'}`);
284
+ const dest = [as ?? segs[0], ...segs.slice(1)].join('/');
285
+ if (e.type === SYMLINK) {
286
+ const own = `${ROOT}/${as ?? segs[0]}`;
287
+ const to = path.posix.resolve(path.posix.dirname(`${ROOT}/${dest}`), e.linkname);
288
+ if (to !== own && !to.startsWith(`${own}/`)) refuse(`is a symlink to ${e.linkname}, outside its workspace`);
289
+ }
290
+ if (only.size && !only.has(segs[0])) return null;
291
+ found.add(segs[0]);
292
+ if (segs.length === 2 && segs[1] === 'package.json' && e.type === FILE) found.add(`${segs[0]}/package.json`);
293
+ return e.type === DIR ? `${dest}/` : dest;
294
+ }
295
+
296
+ export async function importContainer(name, file, flags, log = console.log) {
297
+ if (!file) throw new Error(`dt import container ${name} <file>`);
298
+ const c = await inspectContainer(name);
299
+ if (!c) throw new Error(`no container "${name}" — dt list containers`);
300
+ if (c.State?.Status !== 'running') throw new Error(`${name} is ${c.State?.Status} — import needs it running: dt start container ${name}`);
301
+ const head = Buffer.alloc(8); { const fd = fs.openSync(file, 'r'); fs.readSync(fd, head, 0, 8, 0); fs.closeSync(fd); }
302
+ const sealed = head.equals(MAGIC);
303
+ if (!sealed && !(head[0] === 0x1f && head[1] === 0x8b)) throw new Error(`${file} is neither a dreamteamer export nor a .tar.gz`);
304
+ const key = sealed ? await openSeal(file, () => passphrase({ confirm: false })) : null;
305
+ if (!sealed) log(`… ${file} is NOT encrypted — reading it as a plain .tar.gz`);
306
+ const only = new Set(flags.workspaces ?? []);
307
+ const as = flags.as;
308
+ if (as !== undefined && (typeof as !== 'string' || !NEW_NAME.test(as) || RESERVED.has(as))) throw new Error(`--as takes a workspace name: 1–40 of a-z, 0-9 and '-', starting with a letter or digit, not files · lost-found · trash (got "${as === true ? '' : as}")`);
309
+ const read = (found) => [fs.createReadStream(file, { start: sealed ? HEAD + MAC : 0 }), ...(key ? [(src) => unseal(src, key)] : []), zlib.createGunzip(),
310
+ async function* (src) { yield* retar(src, (e) => checkEntry(e, only, found, as)); yield TAR_END; }];
311
+ // pass 1: every chunk authenticated and every entry checked before anything is written
312
+ const found = new Set();
313
+ await pipeline(...read(found), async (src) => { for await (const _ of src); });
314
+ const installs = [...found].filter((w) => w.endsWith('/package.json')).map((w) => `${ROOT}/${as ?? w.split('/')[0]}`);
315
+ for (const w of [...found]) if (w.includes('/')) found.delete(w);
316
+ const missing = [...only].filter((w) => !found.has(w));
317
+ if (missing.length) throw new Error(`the export holds no workspace ${missing.join(', ')} — it holds ${[...found].join(', ') || 'none'}`);
318
+ if (!found.size) throw new Error(`${file} holds no workspace`);
319
+ if (as && found.size !== 1) throw new Error(`--as names ONE workspace, and the export holds ${[...found].join(', ')} — pick it with --workspace <w>`);
320
+ const targets = as ? [`${ROOT}/${as}`] : [...found].map((w) => `${ROOT}/${w}`);
321
+ // a folder on the container's own layer vanishes with the container — the rule dt-new keeps
322
+ const mounts = (await exec(name, ['cat', '/proc/mounts'])).stdout.split('\n').map((l) => l.split(' ')).filter((m) => m[1]);
323
+ for (const t of targets) {
324
+ const m = mounts.filter(([, at]) => t === at || t.startsWith(at === '/' ? '/' : `${at}/`)).sort((a, b) => b[1].length - a[1].length)[0];
325
+ if (!m || ['overlay', 'tmpfs', 'ramfs'].includes(m[2])) throw new Error(`${t} would land on ${m ? m[2] : 'an unknown filesystem'}, not a volume, and vanish with the container — import into the container whose own workspace it is (dt start container <w> makes ${ROOT}/<w> a volume). Nothing was written`);
326
+ }
327
+ const full = [];
328
+ for (const t of targets) { const r = await exec(name, ['find', t, '-mindepth', '1', '-maxdepth', '1', '-print', '-quit']); if (r.code === 0 && r.stdout.trim()) full.push(t); }
329
+ if (full.length && flags.replace !== true) throw new Error(`${full.join(', ')} already hold${full.length === 1 ? 's' : ''} files in ${name} — --replace empties and replaces ${full.length === 1 ? 'it' : 'them'}. Nothing was written`);
330
+ for (const t of full) { const r = await exec(name, ['find', t, '-mindepth', '1', '-delete'], { user: 'root' }); if (r.code !== 0) throw new Error(`emptying ${t} failed (exit ${r.code}) — ${r.stderr.trim()}`); }
331
+ // pass 2: the same checks again on the way up, so a file changed between the passes is still refused
332
+ await pipeline(...read(new Set()), async (src) => ok(await api('PUT', `/containers/${c.Id}/archive?path=${encodeURIComponent(ROOT)}&noOverwriteDirNonDir=true`, undefined, { send: Readable.from(src) }), `write ${ROOT} in ${name}`));
333
+ const own = await exec(name, ['chown', '-R', 'node:node', ...targets], { user: 'root' });
334
+ if (own.code !== 0) throw new Error(`chown of ${targets.join(' ')} failed (exit ${own.code}) — ${own.stderr.trim()}`);
335
+ log(`✔ imported ${targets.join(', ')} into ${name}${full.length ? ` · replaced ${full.join(', ')}` : ''} · owned by node`);
336
+ // principle 3: an install runs code the workspace chose (lifecycle scripts, a pinned engine), so
337
+ // import names the step and the person takes it
338
+ if (installs.length) log(`next, in ${installs.join(', ')}: npm ci && npx dreamteamer compile — import ran neither (both run code the workspace chose)`);
339
+ return { container: name, imported: targets, replaced: full, next: installs };
340
+ }
341
+
342
+ /** `dt export|import container <name> …` → exit code. */
343
+ export async function archiveCommand(verb, target, args) {
344
+ const { flags, pos } = parseFlags(args);
345
+ if ('passphrase' in flags) throw new Error('the passphrase is never a flag (every local user\'s `ps` reads argv) — set DT_EXPORT_PASSPHRASE, or let the prompt ask');
346
+ const bad = Object.keys(flags).find((f) => f !== 'workspaces' && !FLAGS[verb].includes(f));
347
+ if (bad) throw new Error(`unknown flag "--${bad}" on \`dt ${verb} container\` — known: ${FLAGS[verb].map((f) => `--${f}`).join(', ')}`);
348
+ const name = target.id ?? pos.shift();
349
+ if (target.collection !== 'containers' || !name) throw new Error(verb === 'export' ? 'dt export container <name> [--workspace <w>]... --out <file> [--no-encrypt]' : 'dt import container <name> <file> [--workspace <w>]... [--replace]');
350
+ // the driver's rule: under --json stdout carries ONLY the JSON, every human line goes to stderr
351
+ const json = flags.json === true;
352
+ const say = json ? console.error : console.log;
353
+ const r = verb === 'export' ? await exportContainer(name, flags, say) : await importContainer(name, pos[0], flags, say);
354
+ if (json) emit(JSON.stringify(r, null, 2));
355
+ return 0;
356
+ }
package/src/containers.js CHANGED
@@ -37,6 +37,7 @@ import fs from 'node:fs';
37
37
  import os from 'node:os';
38
38
  import path from 'node:path';
39
39
  import { execFileSync, spawn } from 'node:child_process';
40
+ import { pipeline } from 'node:stream/promises';
40
41
  import { parseEnvValues } from './env-vars.js';
41
42
  import { emit } from './collections-cli.js';
42
43
 
@@ -63,7 +64,7 @@ export const HOST_DEFAULTS = {
63
64
  DT_PORT_BASE: '8100', // NOT 8080: that is code-server's in-container port, `dt start`'s REST default, and the old dev image's exposed port — three things on one number
64
65
  DT_BIND: '127.0.0.1', // loopback only; a remote tier puts auth in front before this changes
65
66
  DT_REGISTRY: 'ghcr.io/dreamteamer', // `<registry>/<template>:<tag>` is the image a template name resolves to — the public images repo publishes here
66
- DT_TEMPLATE_TAG: 'latest',
67
+ DT_TEMPLATE_TAG: '0.6.0', // the image release this engine is tested against, bumped with it; `setup` never writes it, so an upgrade moves it
67
68
  DT_DOCKER_TIMEOUT: '30', // seconds a request to Docker may sit IDLE before the verb fails — see the header
68
69
  };
69
70
 
@@ -102,19 +103,25 @@ export function dockerTimeoutSeconds() {
102
103
  return Number.isFinite(n) && n >= 0 ? n : Number(HOST_DEFAULTS.DT_DOCKER_TIMEOUT);
103
104
  }
104
105
 
105
- export function api(method, urlPath, body, { onLine } = {}) {
106
+ /** `send` streams a body (a tar upload) instead of JSON; `stream` resolves a 2xx with the response
107
+ * itself, unread (a tar download) — both under the same idle timer, which keeps running while the
108
+ * bytes move. */
109
+ export function api(method, urlPath, body, { onLine, raw, send, stream } = {}) {
106
110
  const sock = socketPath();
107
111
  const seconds = dockerTimeoutSeconds();
108
112
  return new Promise((resolve, reject) => {
109
113
  const payload = body === undefined ? undefined : JSON.stringify(body);
110
114
  const req = http.request({
111
115
  socketPath: sock, method, path: urlPath,
112
- headers: payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {},
116
+ headers: payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : send ? { 'Content-Type': 'application/x-tar' } : {},
113
117
  }, (res) => {
118
+ if (stream && res.statusCode < 300) return resolve({ status: res.statusCode, res });
114
119
  let text = '';
115
120
  let pending = '';
116
- res.setEncoding('utf8');
121
+ const bufs = []; // `raw`: an exec's multiplexed stream is binary framing, so it stays bytes
122
+ if (!raw) res.setEncoding('utf8');
117
123
  res.on('data', (chunk) => {
124
+ if (raw) { bufs.push(chunk); return; }
118
125
  text += chunk;
119
126
  if (!onLine) return;
120
127
  pending += chunk;
@@ -123,6 +130,7 @@ export function api(method, urlPath, body, { onLine } = {}) {
123
130
  for (const l of lines) if (l.trim()) { try { onLine(JSON.parse(l)); } catch { /* not JSON */ } }
124
131
  });
125
132
  res.on('end', () => {
133
+ if (raw) return resolve({ status: res.statusCode, body: Buffer.concat(bufs) });
126
134
  const isJson = /json/.test(res.headers['content-type'] ?? '');
127
135
  let parsed = text;
128
136
  if (isJson && !onLine) { try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; } }
@@ -133,13 +141,14 @@ export function api(method, urlPath, body, { onLine } = {}) {
133
141
  // idle-based: fires only when NOTHING has moved on the socket for `seconds` — a streaming
134
142
  // pull resets it with every progress line, a paused daemon never does
135
143
  if (seconds) req.setTimeout(seconds * 1000, () => req.destroy(new Error(`${method} ${urlPath}: Docker did not answer within ${seconds}s — is Docker Desktop paused or still starting? DT_DOCKER_TIMEOUT=<seconds> in ${path.join(hostDir(), '.env')} changes the wait (0 disables it)`)));
144
+ if (send) return pipeline(send, req).catch((e) => req.destroy(e));
136
145
  if (payload) req.write(payload);
137
146
  req.end();
138
147
  });
139
148
  }
140
149
 
141
150
  /** Throw the daemon's own sentence on a non-2xx, so a refusal reads as Docker's rather than ours. */
142
- function ok(res, what) {
151
+ export function ok(res, what) {
143
152
  if (res.status >= 200 && res.status < 400) return res.body;
144
153
  const msg = res.body && typeof res.body === 'object' && res.body.message ? res.body.message : String(res.body ?? '').trim();
145
154
  throw new Error(`${what}: Docker answered ${res.status}${msg ? ` — ${msg}` : ''}`);
@@ -196,20 +205,27 @@ const containerRow = (c) => {
196
205
  const port = (c.Ports ?? []).find((p) => p.PublicPort)?.PublicPort;
197
206
  return {
198
207
  name, template: c.Labels?.[LABEL.template] ?? '', state: c.State, status: c.Status,
199
- editor_url: port ? editorUrl(port, name) : '', image: c.Image, person: c.Labels?.[LABEL.person] ?? '',
208
+ editor_url: port ? editorUrl(port) : '', image: c.Image, person: c.Labels?.[LABEL.person] ?? '',
200
209
  created: c.Created ? new Date(c.Created * 1000).toISOString().slice(0, 16).replace('T', ' ') : '', id: c.Id,
201
210
  };
202
211
  };
203
212
  /** The dev-container convention: the workspace is mounted at `/workspaces/<name>`, so the folder the
204
213
  * editor opens, the URL, and a VS Code attach all name the workspace rather than a fixed word. */
205
214
  export const workspaceDir = (name) => `/workspaces/${name}`;
206
- const editorUrl = (port, name) => `http://localhost:${port}/?folder=${workspaceDir(name)}`;
215
+ /** A bare URL opens the machine home (`/opt/dt-launcher`: every workspace, create, clone) — `?folder=`
216
+ * only when the caller asks for one workspace (`--workspace`). */
217
+ const editorUrl = (port, folder) => `http://localhost:${port}/${folder ? `?folder=${folder}` : ''}`;
218
+ /** Where `--mount` may land: the image's own trees. Anywhere else (`/etc`, `/usr/local/bin`, `/opt`)
219
+ * replaces what the image runs — a mount there is a way round the image, not a mount. */
220
+ export const MOUNT_ROOTS = ['/workspaces', '/home/node', '/files', '/mnt'];
207
221
  /** `--mount <host-path|volume>:<container-path>[:ro]` → a Docker Mount. A source starting with `/`,
208
222
  * `~` or `.` is a bind mount of a host path (resolved against cwd); anything else is a named volume. */
209
223
  export function parseMount(spec) {
210
224
  const parts = String(spec).split(':');
211
225
  if (parts.length < 2 || parts.length > 3 || !parts[0] || !parts[1].startsWith('/')) throw new Error(`--mount takes <host-path|volume>:<container-path>[:ro] — got "${spec}"`);
212
- const [src, target, mode] = parts;
226
+ const [src, rawTarget, mode] = parts;
227
+ const target = path.posix.normalize(rawTarget).replace(/(.)\/$/, '$1'); // `/mnt/../etc` is `/etc`
228
+ if (!MOUNT_ROOTS.some((r) => target === r || target.startsWith(`${r}/`))) throw new Error(`--mount "${spec}": the target must be under ${MOUNT_ROOTS.join(' · ')} — ${target} is not`);
213
229
  if (mode !== undefined && mode !== 'ro' && mode !== 'rw') throw new Error(`--mount "${spec}": the third part is ro or rw`);
214
230
  const isPath = /^[/~.]/.test(src);
215
231
  const source = isPath ? path.resolve(src.replace(/^~(?=\/|$)/, os.homedir())) : src;
@@ -219,6 +235,12 @@ export function parseMount(spec) {
219
235
  /** The URI VS Code on the host opens to attach to this container (Dev Containers extension). The
220
236
  * container name is hex-encoded, as the extension spells it. */
221
237
  export const attachUri = (name) => `vscode-remote://attached-container+${Buffer.from(name, 'utf8').toString('hex')}${workspaceDir(name)}`;
238
+ /** Each container gets its OWN user-defined bridge, so it shares no network with another
239
+ * workspace and its network carries its name. That is NOT isolation on its own: measured 2026-09-28
240
+ * on Docker Desktop 29.3.1, a container on one user-defined bridge reaches another's by IP and via
241
+ * host.docker.internal:<its published port>. What isolates is the image's egress firewall (see
242
+ * CapAdd below); the bridge still helps where the platform does separate bridges. */
243
+ export const networkName = (name) => `dreamteamer-${name}`;
222
244
  const volumeNames = (name) => ({ workspace: `dreamteamer-${name}-workspace`, home: `dreamteamer-${name}-home`, files: `dreamteamer-${name}-files` });
223
245
 
224
246
  export async function listContainers() {
@@ -246,7 +268,7 @@ export function containerDetail(c) {
246
268
  template: c.Config.Labels[LABEL.template] ?? '', image: c.Config.Image,
247
269
  state: c.State?.Status, started: c.State?.StartedAt, restarts: c.RestartCount ?? 0,
248
270
  bind: binding?.HostIp ?? '', port: binding ? Number(binding.HostPort) : undefined,
249
- editor_url: binding ? editorUrl(binding.HostPort, name) : '',
271
+ editor_url: binding ? editorUrl(binding.HostPort) : '',
250
272
  attach_uri: attachUri(name),
251
273
  workspace_dir: wsDir,
252
274
  volumes: { workspace: mounts[wsDir] ?? '', home: mounts['/home/node'] ?? '', files: mounts['/files'] ?? '' },
@@ -281,7 +303,8 @@ function person(flags, env) {
281
303
  }
282
304
 
283
305
  /** Create-if-absent and start. Idempotent: a second call on an existing name starts it and prints
284
- * the same URL. Never injects a token — the person logs in INSIDE, once, and the home volume keeps it. */
306
+ * the same URL. Never injects a credential — the person logs in INSIDE, once, and the home volume
307
+ * keeps it. The one secret that crosses is the image's own URL token, read back OUT (launchUrl). */
285
308
  export async function startContainer(name, flags, log = console.log) {
286
309
  if (!/^[a-z0-9][a-z0-9_.-]*$/.test(name)) throw new Error(`"${name}" is not a container name — lowercase letters, digits, "-", "_" and "."; e.g. hq-dana`);
287
310
  const env = hostEnv();
@@ -302,16 +325,46 @@ export async function startContainer(name, flags, log = console.log) {
302
325
  // `--mount` adds bind or volume mounts beside the three the container always has; a mount aimed
303
326
  // at one of those three targets is refused rather than silently shadowing the volume.
304
327
  const extra = (flags.mount ?? []).map(parseMount);
305
- for (const m of extra) if ([wsDir, '/home/node', '/files'].includes(m.Target)) throw new Error(`--mount cannot target ${m.Target} — that is one of the container's own volumes (${wsDir} · /home/node · /files)`);
328
+ for (const m of extra) if ([wsDir, '/workspaces', '/home/node', '/files'].includes(m.Target)) throw new Error(`--mount cannot target ${m.Target} — that is one of the container's own volumes, or holds them (${wsDir} · /home/node · /files)`);
329
+ // No mount point INSIDE another mount, the three own volumes included. runc resolves a mount
330
+ // destination through symlinks in the rootfs, and a mounted volume is writable by whoever runs
331
+ // in it: `evil -> /opt` inside /workspaces/<name> makes a mount at /workspaces/<name>/evil land
332
+ // on the image's real /opt (measured with plain Docker). Only the image's own filesystem — no
333
+ // symlinks on these roots, which the image pins with a test — may lie between / and a mount point.
334
+ const under = (t, r) => t === r || t.startsWith(`${r}/`);
335
+ const targets = [wsDir, '/home/node', '/files', ...extra.map((m) => m.Target)];
336
+ extra.forEach((m, i) => { for (const [j, t] of targets.entries()) if (j !== i + 3 && (under(m.Target, t) || under(t, m.Target))) throw new Error(`--mount ${m.Source}:${m.Target} lies at or under ${t}, another mount of this container — a mount point inside a mount resolves through whatever symlinks were written there; mount it beside, e.g. /mnt/<name>`); });
337
+ // A bind whose source lies inside another bind's (or IS it) reaches the same files twice — the
338
+ // way a `:ro` mount of a folder is undone by a writable mount of the folder it sits in.
339
+ // "Inside" is decided by IDENTITY, not spelling: realpath keeps the case it was given, so on a
340
+ // case-insensitive volume (APFS, NTFS by default) `/x/INNER` passes a path comparison against `/x`.
341
+ // So walk up a's real path and compare each ancestor's dev+ino with b's — the filesystem's own
342
+ // rules (case, Unicode normalisation) answer, none guessed here. A source that does not exist has
343
+ // no inode (Docker refuses the bind anyway); it falls back to a path compare, case-folded where
344
+ // the platform's default volume folds.
345
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return p; } };
346
+ const ident = (p) => { try { const s = fs.statSync(p, { bigint: true }); return `${s.dev}:${s.ino}`; } catch { return null; } };
347
+ const fold = (p) => (['darwin', 'win32'].includes(process.platform) ? p.toLowerCase() : p);
348
+ const inside = (a, b) => {
349
+ const want = ident(b.real);
350
+ if (!want) return !path.relative(fold(b.real), fold(a.real)).startsWith('..');
351
+ for (let p = a.real; ; p = path.dirname(p)) { if (ident(p) === want) return true; if (path.dirname(p) === p) return false; }
352
+ };
353
+ const binds = extra.filter((m) => m.Type === 'bind').map((m) => ({ m, real: real(m.Source) }));
354
+ for (const a of binds) for (const b of binds) if (a !== b && inside(a, b)) throw new Error(`--mount ${a.m.Source}:${a.m.Target} lies inside ${b.m.Source} (mounted at ${b.m.Target}) — one container reaches a host folder through one mount`);
306
355
  // `--repo <url>` clones an EXISTING workspace into the workspace volume on first start instead of
307
356
  // laying the template down — the way a person joins a workspace that already lives on GitHub.
308
357
  const repo = typeof flags.repo === 'string' ? flags.repo : undefined;
309
358
  if (repo !== undefined && !/^(https?:\/\/|git@|ssh:\/\/|file:\/\/|\/)/.test(repo)) throw new Error(`--repo takes a git URL or an absolute path — got "${repo}"`);
359
+ const network = await ensureNetwork(name);
310
360
  const body = {
311
361
  Image: ref,
312
362
  Labels: { [LABEL.workspace]: name, [LABEL.template]: template, [LABEL.person]: who.name, [LABEL.workdir]: wsDir },
313
363
  Env: [
314
364
  `DT_WORKSPACE=${name}`, `DT_TEMPLATE=${template}`, `DT_WORKSPACE_DIR=${wsDir}`, 'FILES_FOLDER=/files',
365
+ // the editor listens on every interface INSIDE the container so the port mapping reaches
366
+ // it; the host side stays DT_BIND (loopback) — and the image's proxy checks a token
367
+ 'DT_LOCAL_BIND=0.0.0.0',
315
368
  ...(repo ? [`DT_REPO=${repo}`] : []),
316
369
  ...(who.name ? [`GIT_AUTHOR_NAME=${who.name}`, `GIT_COMMITTER_NAME=${who.name}`] : []),
317
370
  ...(who.email ? [`GIT_AUTHOR_EMAIL=${who.email}`, `GIT_COMMITTER_EMAIL=${who.email}`] : []),
@@ -326,9 +379,29 @@ export async function startContainer(name, flags, log = console.log) {
326
379
  ...extra,
327
380
  ],
328
381
  RestartPolicy: { Name: 'unless-stopped' },
382
+ NetworkMode: network.net, // its own bridge ONLY — never the default one; naming, not the isolation
383
+ // NET_ADMIN, and nothing else, for the image's ROOT phase: the entrypoint applies the local
384
+ // egress policy (what the container may reach — not another workspace, not the host's
385
+ // published ports) before it drops to the editor and agents, which run with NO capabilities,
386
+ // so none of them can change the rules. Separate bridges alone do not isolate (above).
387
+ CapAdd: ['NET_ADMIN'],
329
388
  },
330
389
  };
331
- ok(await api('POST', `/containers/create?name=${encodeURIComponent(name)}`, body), `create container ${name}`);
390
+ // A create Docker refuses must not leave this attempt's debris: the network ensureNetwork just
391
+ // made, and the named volumes Docker makes during create. Only what did NOT exist before is
392
+ // removed — a pre-existing volume holds someone's work. Docker refuses to remove either while a
393
+ // container uses it, which covers the create that did land before its answer was lost.
394
+ const fresh = [];
395
+ try {
396
+ for (const m of body.HostConfig.Mounts) if (m.Type === 'volume' && (await api('GET', `/volumes/${encodeURIComponent(m.Source)}`)).status === 404) fresh.push(m.Source);
397
+ ok(await api('POST', `/containers/create?name=${encodeURIComponent(name)}`, body), `create container ${name}`);
398
+ } catch (e) {
399
+ try {
400
+ for (const v of fresh) await api('DELETE', `/volumes/${encodeURIComponent(v)}`);
401
+ if (network.created) await api('DELETE', `/networks/${encodeURIComponent(network.net)}`);
402
+ } catch { /* the create's own failure is the one to report */ }
403
+ throw e;
404
+ }
332
405
  c = await inspectContainer(name);
333
406
  log(`✔ created ${name} from ${ref} · ${env.DT_BIND}:${port} → ${inner} · ${wsDir} · volumes ${Object.values(vols).join(', ')}${extra.length ? ` · mounts ${extra.map((m) => `${m.Source}→${m.Target}${m.ReadOnly ? ' (ro)' : ''}`).join(', ')}` : ''}${repo ? ` · clones ${repo} on first start` : ''}`);
334
407
  }
@@ -339,11 +412,56 @@ export async function startContainer(name, flags, log = console.log) {
339
412
  }
340
413
  const detail = containerDetail(c);
341
414
  await waitHealthy(detail, log);
342
- log(`✔ container ${name} · ${detail.state} · ${detail.editor_url}`);
343
- if (!flags['no-open'] && detail.editor_url) openUrl(detail.editor_url);
415
+ const url = await launchUrl(detail, flags);
416
+ log(`✔ container ${name} · ${detail.state} · ${url.text}`);
417
+ if (!flags['no-open'] && detail.port) openUrl(url, log);
344
418
  return detail;
345
419
  }
346
420
 
421
+ async function ensureNetwork(name) {
422
+ const net = networkName(name);
423
+ const res = await api('GET', `/networks/${encodeURIComponent(net)}`);
424
+ if (res.status === 404) { ok(await api('POST', '/networks/create', { Name: net, Driver: 'bridge', Labels: { dreamteamer: '1', 'dreamteamer.name': name } }), `create network ${net}`); return { net, created: true }; }
425
+ if (ok(res, `get network ${net}`).Labels?.['dreamteamer.name'] !== name) throw new Error(`a Docker network "${net}" exists that dreamteamer did not make — remove or rename it (docker network rm ${net})`);
426
+ return { net, created: false };
427
+ }
428
+
429
+ /** Run `cmd` (an argv, never a shell line) in a running container as `user` — one Docker exec under
430
+ * the same idle timer as every request. Answers { code, stdout, stderr }; a non-zero exit is the
431
+ * caller's to judge. */
432
+ export async function exec(name, cmd, { user } = {}) {
433
+ const what = `exec ${cmd[0]} in ${name}`;
434
+ const { Id } = ok(await api('POST', `/containers/${encodeURIComponent(name)}/exec`, { Cmd: cmd, AttachStdout: true, AttachStderr: true, ...(user ? { User: user } : {}) }), what);
435
+ const res = await api('POST', `/exec/${Id}/start`, { Detach: false, Tty: false }, { raw: true });
436
+ ok({ status: res.status, body: res.body.toString('utf8') }, what);
437
+ // Tty:false answers Docker's multiplexed stream: per frame an 8-byte header — stream (1 out, 2 err),
438
+ // three zero bytes, a big-endian length — then that many bytes
439
+ const out = { stdout: '', stderr: '' };
440
+ for (let b = res.body, i = 0, n; i + 8 <= b.length; i += 8 + n) { n = b.readUInt32BE(i + 4); out[b[i] === 2 ? 'stderr' : 'stdout'] += b.subarray(i + 8, i + 8 + n).toString('utf8'); }
441
+ return { code: ok(await api('GET', `/exec/${Id}/json`), what).ExitCode, ...out };
442
+ }
443
+
444
+ /** The URL to print and open. An image that lists `url-token` in /opt/dt-image/features (hq 0.6+)
445
+ * holds a secret its proxy checks; it is read by exec as root, lives in this process only, and
446
+ * reaches the person as `?tkn=` on the ONE line that prints the URL. An older image has no file and
447
+ * gets the plain URL. `text` is that line's URL; `secret` says the token rides on it. */
448
+ async function launchUrl(detail, flags) {
449
+ const folder = flags.workspace === true ? detail.workspace_dir : typeof flags.workspace === 'string' ? workspaceDir(flags.workspace) : undefined;
450
+ if (folder && !/^\/workspaces\/[a-z0-9][a-z0-9_.-]*$/.test(folder)) throw new Error(`--workspace takes a workspace folder name under /workspaces — got "${flags.workspace}"`);
451
+ const plain = detail.port ? editorUrl(detail.port, folder) : '';
452
+ const features = await exec(detail.name, ['cat', '/opt/dt-image/features']);
453
+ const tokened = features.code === 0 && features.stdout.split('\n').some((l) => l.trim() === 'url-token');
454
+ const rotate = flags['rotate-token'] === true;
455
+ if (!tokened) {
456
+ if (rotate) throw new Error(`${detail.name} runs an image with no URL token (no url-token in /opt/dt-image/features) — --rotate-token needs hq 0.6 or later`);
457
+ return { text: plain, secret: false };
458
+ }
459
+ const r = await exec(detail.name, ['dt-url-token', rotate ? 'rotate' : 'show'], { user: 'root' });
460
+ const token = r.stdout.trim();
461
+ if (r.code !== 0 || !/^[A-Za-z0-9_-]{16,}$/.test(token)) throw new Error(`dt-url-token ${rotate ? 'rotate' : 'show'} in ${detail.name} failed (exit ${r.code})${r.stderr.trim() ? ` — ${r.stderr.trim()}` : ''}`);
462
+ return { text: `${plain}${folder ? '&' : '?'}tkn=${token}`, secret: true };
463
+ }
464
+
347
465
  /** Poll code-server's /healthz so the URL printed is one that already answers. */
348
466
  async function waitHealthy(detail, log) {
349
467
  const seconds = process.env.DT_HEALTH_TIMEOUT !== undefined ? Number(process.env.DT_HEALTH_TIMEOUT) : 90;
@@ -362,9 +480,19 @@ async function waitHealthy(detail, log) {
362
480
  log(`⚠ the editor did not answer within ${seconds}s — \`docker logs ${detail.name}\` says why`);
363
481
  }
364
482
 
365
- function openUrl(url) {
366
- const cmd = process.platform === 'darwin' ? ['open', url] : process.platform === 'win32' ? ['cmd', '/c', 'start', '', url] : ['xdg-open', url];
367
- try { spawn(cmd[0], cmd.slice(1), { stdio: 'ignore', detached: true }).unref(); } catch { /* printing the URL is the contract; opening it is a courtesy */ }
483
+ /** Printing the URL is the contract; opening it is a courtesy. A tokened URL never becomes a process
484
+ * ARGUMENT (every local user's `ps` reads those): on macOS it reaches `osascript` on stdin; where no
485
+ * opener takes stdin (xdg-open, start) it is printed and left for the person to open. */
486
+ function openUrl({ text, secret }, log) {
487
+ try {
488
+ if (secret && process.platform === 'darwin') {
489
+ const p = spawn('osascript', ['-'], { stdio: ['pipe', 'ignore', 'ignore'], detached: true });
490
+ p.on('error', () => {}); p.stdin.end(`open location "${text}"\n`); p.unref(); return;
491
+ }
492
+ if (secret) { log(' (open the URL above yourself — this platform\'s opener would put the token in a process argument)'); return; }
493
+ const cmd = process.platform === 'darwin' ? ['open', text] : process.platform === 'win32' ? ['cmd', '/c', 'start', '', text] : ['xdg-open', text];
494
+ spawn(cmd[0], cmd.slice(1), { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
495
+ } catch { /* the URL is printed either way */ }
368
496
  }
369
497
 
370
498
  export async function stopContainer(name) {
@@ -380,14 +508,17 @@ export async function removeContainer(name, { force = false } = {}, log = consol
380
508
  const c = await inspectContainer(name);
381
509
  if (!c) throw new Error(`no container "${name}" — dt list containers`);
382
510
  const vols = containerDetail(c).volumes;
383
- if (c.State?.Status === 'running') await api('POST', `/containers/${c.Id}/stop?t=10`);
511
+ // `restarting` too: a crash-looping container refuses DELETE until it is stopped (measured, Docker Desktop)
512
+ if (['running', 'restarting'].includes(c.State?.Status)) await api('POST', `/containers/${c.Id}/stop?t=10`);
384
513
  ok(await api('DELETE', `/containers/${c.Id}?v=false`), `rm container ${name}`);
514
+ const net = await api('GET', `/networks/${encodeURIComponent(networkName(name))}`);
515
+ if (net.status === 200 && net.body?.Labels?.['dreamteamer.name'] === name) ok(await api('DELETE', `/networks/${encodeURIComponent(networkName(name))}`), `rm network ${networkName(name)}`);
385
516
  const named = Object.values(vols).filter(Boolean);
386
517
  if (force) {
387
518
  for (const v of named) { const r = await api('DELETE', `/volumes/${encodeURIComponent(v)}`); if (r.status !== 204 && r.status !== 404) ok(r, `rm volume ${v}`); }
388
- log(`✔ removed ${name} and its volumes ${named.join(', ')}`);
519
+ log(`✔ removed ${name}, its network and its volumes ${named.join(', ')}`);
389
520
  } else {
390
- log(`✔ removed ${name} · kept volumes ${named.join(', ')} (dt rm container ${name} --force removes them too; dt start container ${name} --template <t> reattaches them)`);
521
+ log(`✔ removed ${name} and its network · kept volumes ${named.join(', ')} (dt rm container ${name} --force removes them too; dt start container ${name} --template <t> reattaches them)`);
391
522
  }
392
523
  }
393
524
 
@@ -397,7 +528,7 @@ export async function setup(flags, log = console.log) {
397
528
  const file = path.join(dir, '.env');
398
529
  fs.mkdirSync(dir, { recursive: true });
399
530
  const existing = fs.existsSync(file) ? Object.fromEntries(parseEnvValues(fs.readFileSync(file, 'utf8'))) : {};
400
- const missing = Object.entries(HOST_DEFAULTS).filter(([k]) => !(k in existing));
531
+ const missing = Object.entries(HOST_DEFAULTS).filter(([k]) => k !== 'DT_TEMPLATE_TAG' && !(k in existing));
401
532
  if (missing.length) {
402
533
  const header = fs.existsSync(file) ? '' : '# dreamteamer host configuration — read by `dt setup`, `dt start container` and friends.\n# DT_IMAGE_<template>=<ref> pins a template to an image; DT_PERSON_NAME / DT_PERSON_EMAIL are the git identity containers get.\n';
403
534
  fs.appendFileSync(file, header + missing.map(([k, v]) => `${k}=${v}`).join('\n') + '\n');
@@ -440,6 +571,9 @@ export async function driverCommand(verb, target, args) {
440
571
  const id = target.id ?? pos[0];
441
572
  const json = flags.json === true;
442
573
  const col = target.collection;
574
+ // under --json stdout is the JSON document and nothing else — every human line (the URL carrying
575
+ // the image's token among them) goes to stderr, so a script parsing or logging stdout never holds it
576
+ const say = json ? console.error : console.log;
443
577
  if (!DRIVER_VERBS.has(verb)) throw new Error(`\`${verb}\` is not a verb on ${col} — list · get · add · rm${col === 'containers' ? ' · start · stop · open' : ''}`);
444
578
  if (col === 'images') {
445
579
  if (LIFECYCLE_VERBS.has(verb)) throw new Error(`\`${verb}\` is a container verb — an image is started by starting a container from it: dt start container <name> --template <t>`);
@@ -452,8 +586,8 @@ export async function driverCommand(verb, target, args) {
452
586
  if (verb === 'list') { const rows = await listContainers(); json ? emit(JSON.stringify(rows, null, 2)) : console.log(table(rows, ['name', 'template', 'state', 'editor_url', 'person', 'created'])); return 0; }
453
587
  if (!id) throw new Error(`dt ${verb} container <name>${verb === 'start' || verb === 'add' ? ' --template <t>' : ''}`);
454
588
  if (verb === 'get') { const c = await inspectContainer(id); if (!c) throw new Error(`no container "${id}" — dt list containers`); emit(JSON.stringify(json ? c : containerDetail(c), null, 2)); return 0; }
455
- if (verb === 'start' || verb === 'add') { const d = await startContainer(id, flags); if (json) emit(JSON.stringify(d, null, 2)); return 0; }
456
- if (verb === 'stop') { const d = await stopContainer(id); console.log(`✔ stopped ${id} · volumes kept`); if (json) emit(JSON.stringify(d, null, 2)); return 0; }
589
+ if (verb === 'start' || verb === 'add') { const d = await startContainer(id, flags, say); if (json) emit(JSON.stringify(d, null, 2)); return 0; }
590
+ if (verb === 'stop') { const d = await stopContainer(id); say(`✔ stopped ${id} · volumes kept`); if (json) emit(JSON.stringify(d, null, 2)); return 0; }
457
591
  if (verb === 'open') {
458
592
  const c = await inspectContainer(id); if (!c) throw new Error(`no container "${id}"`);
459
593
  const d = containerDetail(c);
@@ -461,12 +595,17 @@ export async function driverCommand(verb, target, args) {
461
595
  // Dev Containers attach: the host's own VS Code opens the workspace INSIDE the container, and
462
596
  // installs the extensions the image's `devcontainer.metadata` label names into the container's
463
597
  // VS Code Server — a second extension host beside code-server's, over the same files.
464
- console.log(d.attach_uri);
598
+ say(d.attach_uri);
465
599
  if (!flags['no-open']) { try { spawn('code', ['--folder-uri', d.attach_uri], { stdio: 'ignore', detached: true }).unref(); } catch { /* the URI is printed either way */ } }
600
+ if (json) emit(JSON.stringify(d, null, 2));
466
601
  return 0;
467
602
  }
468
603
  if (!d.editor_url) throw new Error(`${id} publishes no port`);
469
- console.log(d.editor_url); if (!flags['no-open']) openUrl(d.editor_url); return 0;
604
+ if (d.state !== 'running') throw new Error(`${id} is ${d.state} — dt start container ${id} starts it and prints its URL`);
605
+ const url = await launchUrl(d, flags);
606
+ say(url.text); if (!flags['no-open']) openUrl(url, say);
607
+ if (json) emit(JSON.stringify(d, null, 2)); // containerDetail: its editor_url never carries the token
608
+ return 0;
470
609
  }
471
610
  if (verb === 'rm') { await removeContainer(id, { force: flags.force === true }); return 0; }
472
611
  return 1;
@@ -476,8 +615,12 @@ export async function driverCommand(verb, target, args) {
476
615
  * record parser's promotion rules — a repeated flag on these verbs is a mistake, not an array. */
477
616
  export function parseFlags(args) {
478
617
  const flags = {}; const pos = [];
479
- // `--mount` is the one flag that repeats — every other repeat is a mistake and the LAST wins.
480
- const put = (k, v) => { if (k === 'mount') flags.mount = [...(flags.mount ?? []), v]; else flags[k] = v; };
618
+ // `--mount` repeats; `--workspace` repeats on export/import, which read every value from
619
+ // `workspaces` while start/open keep the last. Any other repeat is a mistake and the LAST wins.
620
+ const put = (k, v) => {
621
+ if (k === 'mount') flags.mount = [...(flags.mount ?? []), v]; else flags[k] = v;
622
+ if (k === 'workspace') flags.workspaces = [...(flags.workspaces ?? []), v];
623
+ };
481
624
  for (let i = 0; i < args.length; i++) {
482
625
  const a = args[i];
483
626
  if (!a.startsWith('--')) { pos.push(a); continue; }
@@ -489,4 +632,4 @@ export function parseFlags(args) {
489
632
  return { flags, pos };
490
633
  }
491
634
 
492
- export const CONTAINER_FLAGS = ['template', 'name', 'email', 'no-open', 'json', 'force', 'mount', 'repo', 'vscode'];
635
+ export const CONTAINER_FLAGS = ['template', 'name', 'email', 'no-open', 'json', 'force', 'mount', 'repo', 'vscode', 'rotate-token', 'workspace', 'out', 'no-encrypt', 'replace', 'with-secrets', 'as'];