totopo 3.2.1 → 3.3.0-rc-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,15 +12,14 @@ Local sandbox for AI agents.
12
12
 
13
13
  ## Motivation
14
14
 
15
- Before they run freely on your machine, ask yourself: **can you trust AI agents?**
15
+ Two fundamental risks when running AI agents locally:
16
16
 
17
- - **Inherently unpredictable**: agents inevitably make mistakes, in ways that may be hard to detect or undo.
18
- - **Vulnerable to prompt injection**: agents with internet access can be subtly manipulated to leak sensitive data or execute unauthorized operations.
17
+ 1. Agents are unpredictable they will make mistakes that may be hard to detect or undo.
18
+ 2. Agents are vulnerable to prompt injection and can be subtly manipulated to leak sensitive data or execute unauthorized operations.
19
19
 
20
- Totopo mitigates both risks with a dev container - when you run totopo in a given directory, that directory is mounted as a workspace where agents get a full, capable environment to work in - without access to anything outside it or to remote Git repositories.
20
+ Totopo mitigates both risks by letting you run agents in a dev container when you run totopo in a given directory, that directory is mounted as a workspace where agents can work freely, without access to the rest of your filesystem or your git remote.
21
21
 
22
- If an agent makes a mistake, damage is contained to the workspace, your git remote is out of reach.<br>
23
- If an agent gets compromised, it can't reach your host files — blast radius is limited to the workspace you chose to share.
22
+ In practice, this means any mistake can be reverted from your git remote, and even a compromised agent can't access sensitive files on your machine SSH keys, credentials, browser data — things a locally-running agent could otherwise read without you ever noticing.
24
23
 
25
24
  > totopo's security approach is basic — it is about the minimal precautions I believe anyone running AI agents should have. If you need more robust protections, look somewhere else.
26
25
 
package/bin/totopo.js CHANGED
@@ -50,10 +50,10 @@ if (!existsSync(new URL("../dist/commands/dev.js", import.meta.url))) {
50
50
  process.exit(1);
51
51
  }
52
52
 
53
- // --- v2 migration check ------------------------------------------------------------------------------------------------------------------
53
+ // --- migrations check --------------------------------------------------------------------------------------------------------------------
54
54
  try {
55
55
  const { runMigration } = await import("../dist/lib/migrate-to-latest.js");
56
- runMigration(process.cwd());
56
+ await runMigration(process.cwd(), false);
57
57
  } catch {
58
58
  // Non-fatal - migration failure should not block startup
59
59
  }
@@ -251,7 +251,7 @@ export async function run(packageDir, ctx, options) {
251
251
  envFilePath,
252
252
  hasGit,
253
253
  shadowPatterns,
254
- workspaceName: ctx.displayName,
254
+ workspaceName: ctx.workspaceId,
255
255
  ...(options?.noCache !== undefined && { noCache: options.noCache }),
256
256
  };
257
257
  startContainer(containerOpts);
@@ -70,12 +70,12 @@ async function clearAgentMemory() {
70
70
  if (w === undefined)
71
71
  return;
72
72
  toClear = [w.workspaceId];
73
- log.info(`Clearing agent memory for ${w.displayName}...`);
73
+ log.info(`Clearing agent memory for ${w.workspaceId}...`);
74
74
  }
75
75
  else {
76
76
  const selected = await multiselect({
77
77
  message: "Select workspaces to clear agent memory for: (space to toggle, enter to confirm)",
78
- options: workspaces.map((w) => ({ value: w.workspaceId, label: w.displayName, hint: w.workspaceRoot })),
78
+ options: workspaces.map((w) => ({ value: w.workspaceId, label: w.workspaceId, hint: w.workspaceRoot })),
79
79
  required: false,
80
80
  });
81
81
  if (isCancel(selected)) {
@@ -95,7 +95,7 @@ async function clearAgentMemory() {
95
95
  const isRunning = inspectResult.status === 0 && inspectResult.stdout.trim() === "running";
96
96
  if (isRunning) {
97
97
  const confirmed = await confirm({
98
- message: `Container for ${w.displayName} is running. Stop it to clear memory?`,
98
+ message: `Container for ${w.workspaceId} is running. Stop it to clear memory?`,
99
99
  });
100
100
  if (isCancel(confirmed) || !confirmed)
101
101
  continue;
@@ -104,7 +104,7 @@ async function clearAgentMemory() {
104
104
  }
105
105
  const agentsDir = join(w.workspaceDir, AGENTS_DIR);
106
106
  safeRmSync(agentsDir, { recursive: true, force: true });
107
- log.success(`Cleared agent memory for ${w.displayName}.`);
107
+ log.success(`Cleared agent memory for ${w.workspaceId}.`);
108
108
  }
109
109
  }
110
110
  // --- Remove images (multi-select across all workspaces) ----------------------------------------------------------------------------------
@@ -163,7 +163,7 @@ async function uninstallWorkspaces(currentWorkspaceId) {
163
163
  message: "Select workspaces to uninstall: (space to toggle, enter to confirm)",
164
164
  options: sorted.map((w) => ({
165
165
  value: w.workspaceId,
166
- label: w.displayName,
166
+ label: w.workspaceId,
167
167
  hint: w.workspaceRoot + (w.workspaceId === currentWorkspaceId ? " (current)" : ""),
168
168
  })),
169
169
  required: false,
@@ -199,7 +199,7 @@ async function uninstallWorkspaces(currentWorkspaceId) {
199
199
  removeTotopoYaml = !isCancel(ans) && ans;
200
200
  }
201
201
  removeWorkspaceFiles(w.workspaceRoot, w.workspaceDir, removeTotopoYaml);
202
- log.success(`Uninstalled workspace ${w.displayName}.`);
202
+ log.success(`Uninstalled workspace ${w.workspaceId}.`);
203
203
  }
204
204
  return currentWorkspaceId !== undefined && selectedIds.includes(currentWorkspaceId);
205
205
  }
@@ -16,7 +16,7 @@ export async function run(args) {
16
16
  // --- Status box ----------------------------------------------------------------------------------------------------------------------
17
17
  const containerStatus = workspaceRunning ? "running" : "stopped";
18
18
  const gitNotice = hasGit ? "" : `\n${styleText("yellow", "●")} no git — agent changes are not tracked`;
19
- box(`workspace: ${ctx.displayName}\nprofile: ${activeProfile}\ncontainer: ${containerStatus}${gitNotice}`, ` totopo v${version} `, {
19
+ box(`workspace: ${ctx.workspaceId}\nprofile: ${activeProfile}\ncontainer: ${containerStatus}${gitNotice}`, ` totopo v${version} `, {
20
20
  contentAlign: "left",
21
21
  titleAlign: "center",
22
22
  width: "auto",
@@ -60,11 +60,6 @@ export async function run(cwd) {
60
60
  }
61
61
  workspaceRoot = yamlDir;
62
62
  yaml = existing;
63
- // Show welcome message
64
- if (yaml.name) {
65
- log.info(yaml.name);
66
- process.stdout.write("\n");
67
- }
68
63
  const ok = await confirm({ message: `Set up totopo for: ${toTildePath(workspaceRoot)}?` });
69
64
  if (isCancel(ok) || !ok) {
70
65
  cancel("Setup cancelled.");
@@ -109,22 +104,23 @@ export async function run(cwd) {
109
104
  else {
110
105
  workspaceRoot = rootChoice;
111
106
  }
112
- // Ask for workspace name (used as display name, also derives workspace_id)
113
- const defaultName = basename(workspaceRoot);
114
- const nameInput = await text({
115
- message: "Workspace name:",
116
- placeholder: defaultName,
117
- defaultValue: defaultName,
107
+ // Ask for workspace ID
108
+ const defaultId = slugifyForWorkspaceId(basename(workspaceRoot));
109
+ const idInput = await text({
110
+ message: "Workspace ID:",
111
+ placeholder: defaultId,
112
+ defaultValue: defaultId,
113
+ validate: (v) => validateWorkspaceId((v ?? "").trim() || defaultId),
118
114
  });
119
- if (isCancel(nameInput)) {
115
+ if (isCancel(idInput)) {
120
116
  cancel("Setup cancelled.");
121
117
  return null;
122
118
  }
123
- const workspaceName = nameInput.trim() || defaultName;
124
- // Derive workspace_id from name, auto-resolve collisions with numeric suffix
125
- const workspaceId = deriveUniqueWorkspaceId(slugifyForWorkspaceId(workspaceName), workspaceRoot);
119
+ const inputId = idInput.trim() || defaultId;
120
+ // Auto-resolve collisions with numeric suffix
121
+ const workspaceId = deriveUniqueWorkspaceId(inputId, workspaceRoot);
126
122
  // Build and write totopo.yaml
127
- yaml = buildDefaultTotopoYaml(workspaceId, workspaceName);
123
+ yaml = buildDefaultTotopoYaml(workspaceId);
128
124
  writeTotopoYaml(workspaceRoot, yaml);
129
125
  log.success(`Created ${toTildePath(join(workspaceRoot, TOTOPO_YAML))}`);
130
126
  }
@@ -205,6 +201,5 @@ export async function run(cwd) {
205
201
  workspaceRoot,
206
202
  containerName: deriveContainerName(finalId),
207
203
  workspaceDir: getWorkspaceDir(finalId),
208
- displayName: yaml.name || finalId,
209
204
  };
210
205
  }
@@ -147,12 +147,12 @@ async function resetTotopoYaml(ctx) {
147
147
  return;
148
148
  }
149
149
  note("This will reset totopo.yaml to factory defaults.\n" +
150
- "Your workspace_id and name will be preserved.\n" +
150
+ "Your workspace_id will be preserved.\n" +
151
151
  "Shadow paths, profiles, and env_file will be reset to defaults.", "Reset totopo.yaml");
152
152
  const confirmed = await confirm({ message: "Reset totopo.yaml to defaults?" });
153
153
  if (isCancel(confirmed) || !confirmed)
154
154
  return;
155
- const freshYaml = buildDefaultTotopoYaml(yaml.workspace_id, yaml.name);
155
+ const freshYaml = buildDefaultTotopoYaml(yaml.workspace_id);
156
156
  writeTotopoYaml(ctx.workspaceRoot, freshYaml);
157
157
  log.success("totopo.yaml reset to defaults.");
158
158
  await promptStopContainer(ctx);
@@ -6,7 +6,7 @@
6
6
  // v2.x (~/.totopo/projects/<sha256-hash>/)
7
7
  // Each workspace stored as: meta.json, settings.json, agents/, shadows/
8
8
  // Global API keys in ~/.totopo/.env
9
- // Optional totopo.yaml with name field (no schema_version)
9
+ // Optional totopo.yaml with name field (removed in v3.3)
10
10
  //
11
11
  // v3-rc-1/rc-2 (~/.totopo/workspaces/<workspace_id>/)
12
12
  // Renamed projects/ to workspaces/, hash dirs to workspace_id dirs
@@ -16,13 +16,16 @@
16
16
  // v3-rc-3+ (latest)
17
17
  // project_id renamed to workspace_id in totopo.yaml
18
18
  //
19
+ // v3.2.1 and earlier
20
+ // totopo.yaml had schema_version field and yaml-language-server header (both redundant)
21
+ //
19
22
  // All migrations are idempotent - each checks if needed and skips if not.
20
23
  // =========================================================================================================================================
21
24
  import { spawnSync } from "node:child_process";
22
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
25
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
23
26
  import { homedir } from "node:os";
24
27
  import { join } from "node:path";
25
- import { log } from "@clack/prompts";
28
+ import { confirm, isCancel, log } from "@clack/prompts";
26
29
  import { load as loadYaml } from "js-yaml";
27
30
  import { AGENTS_DIR, CONTAINER_STARTUP, LOCK_FILE, PROFILE, SHADOWS_DIR, TOTOPO_DIR, TOTOPO_YAML, WORKSPACES_DIR } from "./constants.js";
28
31
  import { safeRmSync } from "./safe-rm.js";
@@ -54,18 +57,6 @@ function readV2ShadowPaths(dirPath) {
54
57
  return [];
55
58
  }
56
59
  }
57
- function readV2YamlName(workspaceRoot) {
58
- try {
59
- const raw = loadYaml(readFileSync(join(workspaceRoot, TOTOPO_YAML), "utf8"));
60
- if (typeof raw !== "object" || raw === null)
61
- return null;
62
- const obj = raw;
63
- return typeof obj.name === "string" ? obj.name : null;
64
- }
65
- catch {
66
- return null;
67
- }
68
- }
69
60
  function detectV2Projects() {
70
61
  const baseDir = getWorkspacesBaseDir();
71
62
  if (!existsSync(baseDir))
@@ -123,9 +114,8 @@ function migrateSingleV2Workspace(v2, existingIds) {
123
114
  workspaceId = yaml.workspace_id;
124
115
  }
125
116
  else {
126
- const v2Name = readV2YamlName(v2.projectRoot);
127
117
  workspaceId = generateUniqueWorkspaceId(v2.displayName, existingIds);
128
- yaml = buildDefaultTotopoYaml(workspaceId, v2Name ?? v2.displayName);
118
+ yaml = buildDefaultTotopoYaml(workspaceId);
129
119
  if (v2.shadowPaths.length > 0) {
130
120
  yaml.shadow_paths = [...new Set([...(yaml.shadow_paths ?? []), ...v2.shadowPaths])];
131
121
  }
@@ -161,6 +151,55 @@ function migrateSingleV2Workspace(v2, existingIds) {
161
151
  // =========================================================================================================================================
162
152
  // Migration steps - each is idempotent and checks if needed before acting
163
153
  // =========================================================================================================================================
154
+ const V1_WORKSPACE_FILES = ["Dockerfile", "README.md", "post-start.mjs", "settings.json"];
155
+ function getCandidateWorkspaceRoots(cwd) {
156
+ const roots = [cwd];
157
+ const gitRoot = spawnSync("git", ["rev-parse", "--show-toplevel"], {
158
+ cwd,
159
+ encoding: "utf8",
160
+ stdio: "pipe",
161
+ });
162
+ const root = (gitRoot.stdout ?? "").trim();
163
+ if (gitRoot.status === 0 && root.length > 0 && root !== cwd)
164
+ roots.push(root);
165
+ return roots;
166
+ }
167
+ function detectLegacyV1WorkspaceDir(cwd) {
168
+ for (const root of getCandidateWorkspaceRoots(cwd)) {
169
+ const legacyDir = join(root, TOTOPO_DIR);
170
+ try {
171
+ if (!statSync(legacyDir).isDirectory())
172
+ continue;
173
+ }
174
+ catch {
175
+ continue;
176
+ }
177
+ const hasLegacyFile = V1_WORKSPACE_FILES.some((file) => existsSync(join(legacyDir, file)));
178
+ if (hasLegacyFile)
179
+ return legacyDir;
180
+ }
181
+ return null;
182
+ }
183
+ /**
184
+ * v1.0.3 -> latest: Remove workspace-local .totopo/ artifacts.
185
+ * These files are now bundled in the totopo CLI package.
186
+ */
187
+ async function migrateLegacyV1WorkspaceArtifacts(cwd, requireConfirmation = true) {
188
+ const legacyDir = detectLegacyV1WorkspaceDir(cwd);
189
+ if (!legacyDir)
190
+ return;
191
+ log.warn(`Found legacy v1 totopo artifacts at ${legacyDir}.\n` +
192
+ " Latest totopo bundles these files in the binary, so this directory can be safely removed.");
193
+ if (requireConfirmation) {
194
+ const shouldRemove = await confirm({ message: "Remove legacy .totopo/ directory?", initialValue: true });
195
+ if (isCancel(shouldRemove) || !shouldRemove) {
196
+ log.info("Kept legacy .totopo/ directory.");
197
+ return;
198
+ }
199
+ }
200
+ safeRmSync(legacyDir, { recursive: true, force: true });
201
+ log.success("Removed legacy .totopo/ directory.");
202
+ }
164
203
  /**
165
204
  * v3-rc-1/rc-2 → latest: Rename ~/.totopo/projects/ → ~/.totopo/workspaces/.
166
205
  * Stops running containers first because they have bind mounts into the old path.
@@ -351,25 +390,83 @@ function migrateRemoveLastCliUpdate() {
351
390
  }
352
391
  }
353
392
  }
393
+ /**
394
+ * v3.2.1 and earlier: Remove deprecated fields from totopo.yaml.
395
+ * - schema_version: redundant, totopo validates with the bundled JSON schema at runtime
396
+ * - yaml-language-server header: created stale versioned URLs
397
+ * - name: redundant, workspace_id serves as both identifier and display name
398
+ * Only migrates the current workspace (found by walking up from cwd).
399
+ */
400
+ function migrateRemoveDeprecatedYamlFields(cwd) {
401
+ const dir = findTotopoYamlDir(cwd);
402
+ if (!dir)
403
+ return;
404
+ const filePath = join(dir, TOTOPO_YAML);
405
+ try {
406
+ const content = readFileSync(filePath, "utf8");
407
+ const hasSchemaVersion = /^schema_version:\s/m.test(content);
408
+ const hasYamlLsHeader = content.includes("# yaml-language-server:");
409
+ const hasName = /^name:\s/m.test(content);
410
+ if (!hasSchemaVersion && !hasYamlLsHeader && !hasName)
411
+ return;
412
+ const raw = loadYaml(content);
413
+ if (typeof raw !== "object" || raw === null)
414
+ return;
415
+ const obj = raw;
416
+ delete obj.schema_version;
417
+ delete obj.name;
418
+ try {
419
+ writeTotopoYaml(dir, obj);
420
+ readTotopoYaml(dir);
421
+ }
422
+ catch {
423
+ writeFileSync(filePath, content);
424
+ return;
425
+ }
426
+ const removed = [];
427
+ if (hasSchemaVersion)
428
+ removed.push("schema_version");
429
+ if (hasYamlLsHeader)
430
+ removed.push("yaml-language-server header");
431
+ if (hasName)
432
+ removed.push("name");
433
+ log.success(`Migrated totopo.yaml: removed ${removed.join(", ")}`);
434
+ }
435
+ catch {
436
+ // Unreadable or invalid yaml - skip
437
+ }
438
+ }
354
439
  // Order matters: migrateProjectsDir must run before migrateV2Workspaces because
355
440
  // step 2 scans ~/.totopo/workspaces/ which only exists after step 1 renames projects/.
356
441
  // Steps 3 and 4 are independent of each other and of steps 1-2.
357
442
  // migrateLockFileFormat and migrateLockKeyYamlToRoot must run last so all workspace
358
443
  // dirs are in their final location first. migrateLockKeyYamlToRoot runs after
359
444
  // migrateLockFileFormat so the latter always writes "root=" for freshly upgraded files.
360
- const MIGRATIONS = [
361
- { from: "v3-rc-1/rc-2", description: "Rename ~/.totopo/projects/ to ~/.totopo/workspaces/", run: migrateProjectsDir },
362
- { from: "v2.x", description: "Hash-based dirs to workspace_id-based dirs + totopo.yaml", run: migrateV2Workspaces },
363
- { from: "v3-rc-1/rc-2", description: "Rename project_id to workspace_id in totopo.yaml", run: migrateTotopoYaml },
364
- { from: "v2.x", description: "Remove legacy ~/.totopo/.env global key file", run: migrateGlobalEnv },
365
- { from: "v3-rc-6", description: "Upgrade .lock files from positional to key=value format", run: migrateLockFileFormat },
366
- { from: "v3-rc-8", description: "Rename 'yaml' key to 'root' in .lock files", run: migrateLockKeyYamlToRoot },
367
- { from: "v3.1.0", description: "Remove last-cli-update key from .lock files", run: migrateRemoveLastCliUpdate },
368
- ];
445
+ function buildMigrations(cwd, skipAnyConfirmations) {
446
+ return [
447
+ {
448
+ from: "v1.0.3",
449
+ description: "Remove workspace-local .totopo/ artifacts",
450
+ run: () => migrateLegacyV1WorkspaceArtifacts(cwd, !skipAnyConfirmations),
451
+ },
452
+ { from: "v3-rc-1/rc-2", description: "Rename ~/.totopo/projects/ to ~/.totopo/workspaces/", run: migrateProjectsDir },
453
+ { from: "v2.x", description: "Hash-based dirs to workspace_id-based dirs + totopo.yaml", run: migrateV2Workspaces },
454
+ { from: "v3-rc-1/rc-2", description: "Rename project_id to workspace_id in totopo.yaml", run: () => migrateTotopoYaml(cwd) },
455
+ { from: "v2.x", description: "Remove legacy ~/.totopo/.env global key file", run: migrateGlobalEnv },
456
+ { from: "v3-rc-6", description: "Upgrade .lock files from positional to key=value format", run: migrateLockFileFormat },
457
+ { from: "v3-rc-8", description: "Rename 'yaml' key to 'root' in .lock files", run: migrateLockKeyYamlToRoot },
458
+ { from: "v3.1.0", description: "Remove last-cli-update key from .lock files", run: migrateRemoveLastCliUpdate },
459
+ {
460
+ from: "v3.2.1",
461
+ description: "Remove deprecated fields (schema_version, name, yaml-language-server) from totopo.yaml",
462
+ run: () => migrateRemoveDeprecatedYamlFields(cwd),
463
+ },
464
+ ];
465
+ }
369
466
  /** Run all migrations in order. Called early in bin/totopo.js startup. */
370
- export function runMigration(cwd) {
371
- for (const migration of MIGRATIONS) {
372
- migration.run(cwd);
467
+ export async function runMigration(cwd, skipAnyConfirmations = true) {
468
+ for (const migration of buildMigrations(cwd, skipAnyConfirmations)) {
469
+ await migration.run();
373
470
  }
374
471
  }
375
472
  // =========================================================================================================================================
@@ -380,8 +477,16 @@ export function runMigration(cwd) {
380
477
  /** Check if a running container's image is stale (missing expected files/features). */
381
478
  export function isImageStale(containerName) {
382
479
  // v3.2.0: startup.mjs replaced post-start.mjs + update-ai-clis.mjs
383
- const check = spawnSync("docker", ["exec", containerName, "test", "-f", CONTAINER_STARTUP], { stdio: "pipe" });
384
- if (check.status !== 0)
480
+ const startupCheck = spawnSync("docker", ["exec", containerName, "test", "-f", CONTAINER_STARTUP], { stdio: "pipe" });
481
+ if (startupCheck.status !== 0)
482
+ return true;
483
+ // v3.3.0: file added to the base image for artifact inspection
484
+ const fileCheck = spawnSync("docker", ["exec", containerName, "test", "-x", "/usr/bin/file"], { stdio: "pipe" });
485
+ if (fileCheck.status !== 0)
486
+ return true;
487
+ // v3.3.0: bubblewrap added for Codex sandboxing prerequisites
488
+ const bubblewrapCheck = spawnSync("docker", ["exec", containerName, "test", "-x", "/usr/bin/bwrap"], { stdio: "pipe" });
489
+ if (bubblewrapCheck.status !== 0)
385
490
  return true;
386
491
  return false;
387
492
  }
@@ -13,14 +13,19 @@ const TEST_TMP_PREFIX = join(tmpdir(), `${CONTAINER_NAME_PREFIX}test-`);
13
13
  /**
14
14
  * Safe wrapper around rmSync. Throws if the path is outside a totopo-owned location:
15
15
  * - ~/.totopo/ (workspace caches, agents, shadows, global config)
16
+ * - A directory named .totopo (legacy v1 workspace-local artifacts)
16
17
  * - A file named totopo.yaml (workspace config file in any user workspace root)
17
18
  * - <tmpdir>/totopo-test-* (test temp directories)
18
19
  */
19
20
  export function safeRmSync(path, options) {
20
21
  const r = resolve(path);
21
- const ok = r === TOTOPO_HOME || r.startsWith(TOTOPO_HOME + sep) || basename(r) === TOTOPO_YAML || r.startsWith(TEST_TMP_PREFIX);
22
+ const ok = r === TOTOPO_HOME ||
23
+ r.startsWith(TOTOPO_HOME + sep) ||
24
+ basename(r) === TOTOPO_DIR ||
25
+ basename(r) === TOTOPO_YAML ||
26
+ r.startsWith(TEST_TMP_PREFIX);
22
27
  if (!ok) {
23
- throw new Error(`safeRmSync: refusing to delete '${r}' must be under ~/.totopo/, named totopo.yaml, or a test temp dir`);
28
+ throw new Error(`safeRmSync: refusing to delete '${r}' - must be under ~/.totopo/, named .totopo, named totopo.yaml, or a test temp dir`);
24
29
  }
25
30
  rmSync(r, options);
26
31
  }
@@ -78,18 +78,14 @@ export function readTotopoYaml(dir) {
78
78
  // Every published version (rc or release) has a corresponding git tag created by pnpm rc / pnpm rc:promote.
79
79
  // We rely on that tag existing so these URLs resolve correctly for every installed version.
80
80
  const { version } = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
81
- const GITHUB_RAW_BASE = `https://raw.githubusercontent.com/asafratzon/totopo/v${version}`;
82
81
  export const GITHUB_README_URL = `https://github.com/asafratzon/totopo/blob/v${version}/README.md`;
83
- const YAML_HEADER = `# yaml-language-server: $schema=${GITHUB_RAW_BASE}/schema/totopo.schema.json
84
- `;
85
82
  // Inline comments injected before specific YAML keys (preceded by a blank line)
86
83
  const YAML_COMMENTS = {
87
- workspace_id: "# totopo workspace config run 'npx totopo' from anywhere under this directory tree to start your dev container.\n" +
84
+ workspace_id: "# totopo workspace config - run 'npx totopo' from anywhere under this directory tree to start your dev container.\n" +
88
85
  "# Ask the AI agent inside the container to help you edit this file if needed.\n" +
89
86
  "# This file may be rewritten by totopo (repair, reset, settings changes). Custom comments will not be preserved.",
90
- shadow_paths: "# .gitignore-style patterns agents see an empty, isolated copy instead of the real host data.",
91
- profiles: "# Dockerfile profiles each adds on top of the totopo base image (Debian + Node.js + git + AI CLIs).\n" +
92
- `# Base Dockerfile: ${GITHUB_RAW_BASE}/templates/Dockerfile\n` +
87
+ shadow_paths: "# .gitignore-style patterns - agents see an empty, isolated copy instead of the real host data.",
88
+ profiles: "# Dockerfile profiles - each adds on top of the totopo base image (Debian + Node.js + git + AI CLIs).\n" +
93
89
  "# Switch profiles in the totopo settings menu, or ask the agent inside the container to help you add a new one.",
94
90
  };
95
91
  /** Write totopo.yaml to a directory with schema header and inline comments. */
@@ -113,7 +109,7 @@ export function writeTotopoYaml(dir, config) {
113
109
  output.push(line);
114
110
  }
115
111
  const body = output.join("\n").trimEnd();
116
- writeFileSync(filePath, `${YAML_HEADER}${body}\n${PROFILES_FOOTER_COMMENT}\n`);
112
+ writeFileSync(filePath, `${body}\n${PROFILES_FOOTER_COMMENT}\n`);
117
113
  }
118
114
  // --- Defaults ----------------------------------------------------------------------------------------------------------------------------
119
115
  const DEFAULT_PROFILE_HOOK = `# No extras — uses the totopo base image as-is (Node.js + git + AI CLIs).
@@ -138,9 +134,8 @@ RUN curl -fsSL https://bun.sh/install | bash
138
134
  // Appended after the last profile to hint at adding more
139
135
  const PROFILES_FOOTER_COMMENT = " # Add more profiles here — or ask the agent inside the container to set one up for you.";
140
136
  /** Create a default TotopoYamlConfig with sane defaults. */
141
- export function buildDefaultTotopoYaml(workspaceId, name) {
142
- const config = {
143
- schema_version: 3,
137
+ export function buildDefaultTotopoYaml(workspaceId) {
138
+ return {
144
139
  workspace_id: workspaceId,
145
140
  shadow_paths: [...DEFAULT_SHADOW_PATHS],
146
141
  profiles: {
@@ -154,18 +149,10 @@ export function buildDefaultTotopoYaml(workspaceId, name) {
154
149
  },
155
150
  },
156
151
  };
157
- if (name)
158
- config.name = name;
159
- // Reorder keys so name appears after workspace_id
160
- const { schema_version, workspace_id, name: n, ...rest } = config;
161
- const ordered = { schema_version, workspace_id };
162
- if (n !== undefined)
163
- ordered.name = n;
164
- return Object.assign(ordered, rest);
165
152
  }
166
153
  // --- Repair -------------------------------------------------------------------------------------------------------------------------------
167
154
  /** Set of keys that TotopoYamlConfig allows (used to strip unknown fields). */
168
- const KNOWN_KEYS = new Set(["schema_version", "workspace_id", "name", "env_file", "shadow_paths", "profiles"]);
155
+ const KNOWN_KEYS = new Set(["workspace_id", "env_file", "shadow_paths", "profiles"]);
169
156
  /**
170
157
  * Attempt to repair an invalid totopo.yaml on disk.
171
158
  * Strips unknown fields, fills missing required/optional fields from defaults,
@@ -192,10 +179,6 @@ export function repairTotopoYaml(dir) {
192
179
  const fallbackId = slugifyForWorkspaceId(basename(dir));
193
180
  const defaults = buildDefaultTotopoYaml(obj.workspace_id || fallbackId);
194
181
  // Fill missing required fields
195
- if (!("schema_version" in obj)) {
196
- obj.schema_version = defaults.schema_version;
197
- fixes.push("added missing schema_version");
198
- }
199
182
  if (!("workspace_id" in obj)) {
200
183
  obj.workspace_id = defaults.workspace_id;
201
184
  fixes.push(`added missing workspace_id ("${defaults.workspace_id}")`);
@@ -126,7 +126,6 @@ export function listWorkspaces() {
126
126
  workspaceRoot: lockPath,
127
127
  containerName: deriveContainerName(workspaceId),
128
128
  workspaceDir: getWorkspaceDir(workspaceId),
129
- displayName: yaml.name || workspaceId,
130
129
  };
131
130
  }
132
131
  catch {
@@ -154,7 +153,6 @@ export function resolveWorkspace(fromPath) {
154
153
  workspaceRoot: current,
155
154
  containerName: deriveContainerName(yaml.workspace_id),
156
155
  workspaceDir: getWorkspaceDir(yaml.workspace_id),
157
- displayName: yaml.name || yaml.workspace_id,
158
156
  };
159
157
  }
160
158
  // totopo.yaml found but workspace not initialized or lock mismatch - return null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totopo",
3
- "version": "3.2.1",
3
+ "version": "3.3.0-rc-2",
4
4
  "description": "Run AI coding agents safely in your local codebase",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,14 +3,9 @@
3
3
  "title": "totopo.yaml",
4
4
  "description": "Configuration file for totopo — secure AI dev containers",
5
5
  "type": "object",
6
- "required": ["schema_version", "workspace_id"],
6
+ "required": ["workspace_id"],
7
7
  "additionalProperties": false,
8
8
  "properties": {
9
- "schema_version": {
10
- "type": "integer",
11
- "const": 3,
12
- "description": "Schema version — must be 3"
13
- },
14
9
  "workspace_id": {
15
10
  "type": "string",
16
11
  "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$",
@@ -18,10 +13,6 @@
18
13
  "maxLength": 48,
19
14
  "description": "Unique workspace identifier. Used for container naming and cache directory. Lowercase alphanumeric and hyphens only."
20
15
  },
21
- "name": {
22
- "type": "string",
23
- "description": "Human-readable workspace name (shown in menus and welcome message)"
24
- },
25
16
  "env_file": {
26
17
  "type": "string",
27
18
  "description": "Path to env file relative to totopo.yaml (e.g. '.env'). Injected into container at runtime via --env-file."
@@ -23,7 +23,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
23
23
  # Build essentials
24
24
  build-essential pkg-config libssl-dev \
25
25
  # Utilities
26
- jq unzip zip tree htop procps lsb-release gnupg ca-certificates sox \
26
+ jq unzip zip tree htop procps lsb-release gnupg ca-certificates sox file bubblewrap \
27
27
  # Modern search/navigation tools
28
28
  ripgrep fzf \
29
29
  # Database clients