totopo 3.2.1 → 3.3.0-rc-1

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
 
@@ -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,6 +16,9 @@
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";
@@ -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
  }
@@ -351,6 +341,52 @@ function migrateRemoveLastCliUpdate() {
351
341
  }
352
342
  }
353
343
  }
344
+ /**
345
+ * v3.2.1 and earlier: Remove deprecated fields from totopo.yaml.
346
+ * - schema_version: redundant, totopo validates with the bundled JSON schema at runtime
347
+ * - yaml-language-server header: created stale versioned URLs
348
+ * - name: redundant, workspace_id serves as both identifier and display name
349
+ * Only migrates the current workspace (found by walking up from cwd).
350
+ */
351
+ function migrateRemoveDeprecatedYamlFields(cwd) {
352
+ const dir = findTotopoYamlDir(cwd);
353
+ if (!dir)
354
+ return;
355
+ const filePath = join(dir, TOTOPO_YAML);
356
+ try {
357
+ const content = readFileSync(filePath, "utf8");
358
+ const hasSchemaVersion = /^schema_version:\s/m.test(content);
359
+ const hasYamlLsHeader = content.includes("# yaml-language-server:");
360
+ const hasName = /^name:\s/m.test(content);
361
+ if (!hasSchemaVersion && !hasYamlLsHeader && !hasName)
362
+ return;
363
+ const raw = loadYaml(content);
364
+ if (typeof raw !== "object" || raw === null)
365
+ return;
366
+ const obj = raw;
367
+ delete obj.schema_version;
368
+ delete obj.name;
369
+ try {
370
+ writeTotopoYaml(dir, obj);
371
+ readTotopoYaml(dir);
372
+ }
373
+ catch {
374
+ writeFileSync(filePath, content);
375
+ return;
376
+ }
377
+ const removed = [];
378
+ if (hasSchemaVersion)
379
+ removed.push("schema_version");
380
+ if (hasYamlLsHeader)
381
+ removed.push("yaml-language-server header");
382
+ if (hasName)
383
+ removed.push("name");
384
+ log.success(`Migrated totopo.yaml: removed ${removed.join(", ")}`);
385
+ }
386
+ catch {
387
+ // Unreadable or invalid yaml - skip
388
+ }
389
+ }
354
390
  // Order matters: migrateProjectsDir must run before migrateV2Workspaces because
355
391
  // step 2 scans ~/.totopo/workspaces/ which only exists after step 1 renames projects/.
356
392
  // Steps 3 and 4 are independent of each other and of steps 1-2.
@@ -365,6 +401,11 @@ const MIGRATIONS = [
365
401
  { from: "v3-rc-6", description: "Upgrade .lock files from positional to key=value format", run: migrateLockFileFormat },
366
402
  { from: "v3-rc-8", description: "Rename 'yaml' key to 'root' in .lock files", run: migrateLockKeyYamlToRoot },
367
403
  { from: "v3.1.0", description: "Remove last-cli-update key from .lock files", run: migrateRemoveLastCliUpdate },
404
+ {
405
+ from: "v3.2.1",
406
+ description: "Remove deprecated fields (schema_version, name, yaml-language-server) from totopo.yaml",
407
+ run: migrateRemoveDeprecatedYamlFields,
408
+ },
368
409
  ];
369
410
  /** Run all migrations in order. Called early in bin/totopo.js startup. */
370
411
  export function runMigration(cwd) {
@@ -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-1",
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."