totopo 3.6.1 → 3.7.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
@@ -14,7 +14,7 @@ Local sandbox for AI agents.
14
14
 
15
15
  Developers who use `claude`, `codex`, or `opencode` **interactively** — one human pair-programming with one agent.
16
16
 
17
- totopo is not an orchestration tool no SDK, no parallel agents, no per-run worktrees. If you need those, look at dedicated agent-orchestration tools instead.
17
+ totopo isn't an orchestration tool (no SDK, no parallel agents, no per-run worktrees), and its security is basic — just the minimum precautions I think anyone running AI agents should take. If you need more on either front, look elsewhere.
18
18
 
19
19
  ## Motivation
20
20
 
@@ -27,8 +27,6 @@ Totopo mitigates both risks by letting you run agents in a dev container — whe
27
27
 
28
28
  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 do without you ever noticing.
29
29
 
30
- > 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.
31
-
32
30
  ## Requirements
33
31
 
34
32
  - [Docker](https://www.docker.com/products/docker-desktop/) — builds and runs the dev container
@@ -166,6 +164,8 @@ shadow_paths:
166
164
 
167
165
  Patterns follow gitignore syntax — patterns without a `/` match at any depth. Manage via **Manage Workspace > Shadow paths** or edit `totopo.yaml` directly. Changes take effect on the next session.
168
166
 
167
+ Git-tracked paths are skipped to avoid worktree diversions. Shadowing them has no privacy benefit anyway since agents can `git show` tracked content. To hide a file, untrack it and add it to `.gitignore` first.
168
+
169
169
  Common use cases:
170
170
  - **Separate `node_modules`** — the container installs its own dependencies, avoiding platform conflicts between host and container.
171
171
  - **Hide sensitive files** — keep credentials and secrets invisible to agents.
@@ -103,7 +103,10 @@ function stopAndRemoveContainer(containerName) {
103
103
  }
104
104
  // --- Run startup checks (AI CLI update + readiness validation) ---------------------------------------------------------------------------
105
105
  function runStartup(containerName, quiet) {
106
- const result = spawnSync("docker", ["exec", "-u", "root", containerName, "node", CONTAINER_STARTUP], {
106
+ // The SPACE-to-skip prompt in startup.mjs needs raw-mode stdin (-i) and a PTY (-t).
107
+ // Omitted when quiet so test output stays pipe-capturable.
108
+ const ttyFlags = quiet ? [] : ["-i", "-t"];
109
+ const result = spawnSync("docker", ["exec", "-u", "root", ...ttyFlags, containerName, "node", CONTAINER_STARTUP], {
107
110
  stdio: quiet ? "pipe" : "inherit",
108
111
  });
109
112
  return result.status === 0;
@@ -263,10 +266,13 @@ export async function run(packageDir, ctx, options) {
263
266
  const profileHook = profileConfig?.dockerfile_hook;
264
267
  // --- Shadow path expansion -----------------------------------------------------------------------------------------------------------
265
268
  const shadowPatterns = yaml.shadow_paths ?? [];
266
- const expandedShadows = expandShadowPatterns(shadowPatterns, workspaceDir);
269
+ const { paths: expandedShadows, skippedTracked } = expandShadowPatterns(shadowPatterns, workspaceDir);
267
270
  if (expandedShadows.length > 0) {
268
271
  log.warn(`Shadow paths active: ${expandedShadows.join(", ")} (Settings > Shadow paths)`);
269
272
  }
273
+ if (skippedTracked.length > 0) {
274
+ log.warn(`Skipped ${skippedTracked.length} shadow path(s) tracked by git`);
275
+ }
270
276
  // --- Env file ------------------------------------------------------------------------------------------------------------------------
271
277
  let envFilePath;
272
278
  if (yaml.env_file) {
@@ -121,7 +121,7 @@ export async function run(cwd) {
121
121
  const workspaceId = deriveUniqueWorkspaceId(inputId, workspaceRoot);
122
122
  // Build and write totopo.yaml
123
123
  yaml = buildDefaultTotopoYaml(workspaceId);
124
- writeTotopoYaml(workspaceRoot, yaml);
124
+ writeTotopoYaml(workspaceRoot, yaml, { includeExtendedTemplate: true });
125
125
  log.success(`Created ${toTildePath(join(workspaceRoot, TOTOPO_YAML))}`);
126
126
  }
127
127
  // --- Non-git warning -----------------------------------------------------------------------------------------------------------------
@@ -28,7 +28,8 @@ async function shadowPathsMenu(ctx) {
28
28
  }
29
29
  log.message("Shadow patterns block the agent from seeing matching host paths —\n" +
30
30
  "the container gets an empty, isolated copy instead.\n" +
31
- "Supports gitignore-style patterns (e.g. node_modules, .env*).");
31
+ "Supports gitignore-style patterns (e.g. node_modules, .env*).\n" +
32
+ "Git-tracked paths are skipped to avoid worktree diversions.");
32
33
  const options = [{ value: "add", label: "Add pattern or path" }];
33
34
  if (patterns.length > 0) {
34
35
  options.push({ value: "remove", label: "Remove patterns" });
@@ -196,7 +197,7 @@ async function resetTotopoYaml(ctx) {
196
197
  if (isCancel(confirmed) || !confirmed)
197
198
  return;
198
199
  const freshYaml = buildDefaultTotopoYaml(yaml.workspace_id);
199
- writeTotopoYaml(ctx.workspaceRoot, freshYaml);
200
+ writeTotopoYaml(ctx.workspaceRoot, freshYaml, { includeExtendedTemplate: true });
200
201
  log.success("totopo.yaml reset to defaults.");
201
202
  await promptStopContainer(ctx);
202
203
  }
@@ -119,7 +119,7 @@ function migrateSingleV2Workspace(v2, existingIds) {
119
119
  if (v2.shadowPaths.length > 0) {
120
120
  yaml.shadow_paths = [...new Set([...(yaml.shadow_paths ?? []), ...v2.shadowPaths])];
121
121
  }
122
- writeTotopoYaml(v2.projectRoot, yaml);
122
+ writeTotopoYaml(v2.projectRoot, yaml, { includeExtendedTemplate: true });
123
123
  log.info(`Created totopo.yaml for "${v2.displayName}" (workspace_id: ${workspaceId})`);
124
124
  }
125
125
  const newDir = join(getWorkspacesBaseDir(), workspaceId);
@@ -2,22 +2,26 @@
2
2
  // src/lib/shadows.ts - Gitignore-style shadow path expansion and sync
3
3
  // Expands patterns like "node_modules", ".env*" into concrete paths, then syncs shadow directories.
4
4
  // =========================================================================================================================================
5
+ import { spawnSync } from "node:child_process";
5
6
  import { existsSync, lstatSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
6
7
  import { dirname, join, relative } from "node:path";
7
8
  import fg from "fast-glob";
8
9
  import { CONTAINER_WORKSPACE, SHADOWS_DIR } from "./constants.js";
9
10
  import { safeRmSync } from "./safe-rm.js";
10
- // --- Pattern expansion -------------------------------------------------------------------------------------------------------------------
11
11
  /**
12
12
  * Expand gitignore-style patterns into concrete relative paths.
13
13
  *
14
14
  * Patterns without a directory separator are treated as recursive (prepended with **/)
15
15
  * following gitignore convention. Patterns with a / are matched relative to the workspace root.
16
16
  * Matched directories are not recursed into (e.g. node_modules matches once, not its children).
17
+ *
18
+ * Paths that git tracks (or whose descendants git tracks) are dropped from the result and
19
+ * reported in skippedTracked. Shadowing tracked content is a no-op (agents can `git show` it)
20
+ * and breaks `git stash`/`pop` workflows.
17
21
  */
18
22
  export function expandShadowPatterns(patterns, workspaceRoot) {
19
23
  if (patterns.length === 0)
20
- return [];
24
+ return { paths: [], skippedTracked: [] };
21
25
  // Convert gitignore-style patterns to fast-glob patterns
22
26
  const globPatterns = patterns.map((p) => (p.includes("/") ? p : `**/${p}`));
23
27
  // Build ignore list: skip .git and contents of any matched directory
@@ -28,12 +32,51 @@ export function expandShadowPatterns(patterns, workspaceRoot) {
28
32
  dot: true,
29
33
  ignore: ignorePatterns,
30
34
  });
31
- return removeNestedPaths(results.sort());
35
+ const expanded = removeNestedPaths(results.sort());
36
+ const { kept, dropped } = filterGitTrackedPaths(expanded, workspaceRoot);
37
+ return { paths: kept, skippedTracked: dropped };
32
38
  }
33
39
  // --- Hit counting (for menu UX) ----------------------------------------------------------------------------------------------------------
34
- /** Count how many paths a pattern would match in the workspace. */
40
+ /** Count how many paths a pattern would match in the workspace (post git-tracked filter). */
35
41
  export function countPatternHits(pattern, workspaceRoot) {
36
- return expandShadowPatterns([pattern], workspaceRoot).length;
42
+ return expandShadowPatterns([pattern], workspaceRoot).paths.length;
43
+ }
44
+ // --- Git-tracked filtering ---------------------------------------------------------------------------------------------------------------
45
+ /**
46
+ * Partition expanded shadow paths into those safe to shadow and those tracked by git.
47
+ * A path is dropped if it equals a tracked file, or (for directories) any tracked file
48
+ * lives anywhere beneath it. Returns the input unchanged when no .git is present.
49
+ */
50
+ function filterGitTrackedPaths(paths, workspaceRoot) {
51
+ if (paths.length === 0)
52
+ return { kept: [], dropped: [] };
53
+ // .git may be a file (worktrees) or a directory; existsSync handles both
54
+ if (!existsSync(join(workspaceRoot, ".git")))
55
+ return { kept: paths, dropped: [] };
56
+ // Pass shadow paths as pathspecs so git enumerates only tracked files within them.
57
+ // Output then scales with shadow scope (typically tiny), not the size of the repo index.
58
+ const result = spawnSync("git", ["-C", workspaceRoot, "ls-files", "-z", "--", ...paths], {
59
+ encoding: "utf8",
60
+ stdio: ["ignore", "pipe", "pipe"],
61
+ });
62
+ if (result.status !== 0)
63
+ return { kept: paths, dropped: [] };
64
+ const matched = result.stdout.split("\0").filter((s) => s.length > 0);
65
+ if (matched.length === 0)
66
+ return { kept: paths, dropped: [] };
67
+ const matchedSet = new Set(matched);
68
+ const kept = [];
69
+ const dropped = [];
70
+ for (const p of paths) {
71
+ const prefix = `${p}/`;
72
+ if (matchedSet.has(p) || matched.some((f) => f.startsWith(prefix))) {
73
+ dropped.push(p);
74
+ }
75
+ else {
76
+ kept.push(p);
77
+ }
78
+ }
79
+ return { kept, dropped };
37
80
  }
38
81
  // --- Shadow sync -------------------------------------------------------------------------------------------------------------------------
39
82
  /**
@@ -89,7 +89,7 @@ const YAML_COMMENTS = {
89
89
  "# When multiple profiles exist, totopo prompts you to pick one on session start.",
90
90
  };
91
91
  /** Write totopo.yaml to a directory with schema header and inline comments. */
92
- export function writeTotopoYaml(dir, config) {
92
+ export function writeTotopoYaml(dir, config, opts = {}) {
93
93
  const filePath = join(dir, TOTOPO_YAML);
94
94
  const yamlContent = dumpYaml(config, {
95
95
  lineWidth: -1,
@@ -109,11 +109,14 @@ export function writeTotopoYaml(dir, config) {
109
109
  output.push(line);
110
110
  }
111
111
  const body = output.join("\n").trimEnd();
112
- writeFileSync(filePath, `${body}\n${PROFILES_FOOTER_COMMENT}\n`);
112
+ const extendedBlock = opts.includeExtendedTemplate ? `\n\n${EXTENDED_PROFILE_TEMPLATE_PROMPT}\n${renderExtendedAsCommented()}` : "";
113
+ writeFileSync(filePath, `${body}${extendedBlock}\n${PROFILES_FOOTER_COMMENT}\n`);
113
114
  }
114
115
  // --- Defaults ----------------------------------------------------------------------------------------------------------------------------
116
+ const DEFAULT_PROFILE_DESCRIPTION = "Base image: Node.js, git, and AI CLIs";
115
117
  const DEFAULT_PROFILE_HOOK = `# No extras — uses the totopo base image as-is (Node.js + git + AI CLIs).
116
118
  `;
119
+ const EXTENDED_PROFILE_DESCRIPTION = "Base image + Go, Java, Rust, and Bun";
117
120
  const EXTENDED_PROFILE_HOOK = `# Go
118
121
  RUN apt-get update && apt-get install -y --no-install-recommends golang-go && rm -rf /var/lib/apt/lists/*
119
122
 
@@ -133,6 +136,20 @@ RUN curl -fsSL https://bun.sh/install | bash
133
136
  `;
134
137
  // Appended after the last profile to hint at adding more
135
138
  const PROFILES_FOOTER_COMMENT = " # Add more profiles here — or ask the agent inside the container to set one up for you.";
139
+ const EXTENDED_PROFILE_TEMPLATE_PROMPT = " # Uncomment to enable additional runtimes (Go, Java, Rust, Bun):";
140
+ /**
141
+ * Render the `extended` profile as commented-out YAML, indented to live under `profiles:`.
142
+ * Generated via the same dumpYaml call as the live config so uncommenting (strip leading `# `
143
+ * from each line) yields YAML the parser/schema accepts without drift.
144
+ */
145
+ function renderExtendedAsCommented() {
146
+ const dumped = dumpYaml({ extended: { description: EXTENDED_PROFILE_DESCRIPTION, dockerfile_hook: EXTENDED_PROFILE_HOOK } }, { lineWidth: -1, quotingType: '"', forceQuotes: false });
147
+ return dumped
148
+ .split("\n")
149
+ .map((line) => (line.length === 0 ? "" : ` # ${line}`))
150
+ .join("\n")
151
+ .trimEnd();
152
+ }
136
153
  /** Create a default TotopoYamlConfig with sane defaults. */
137
154
  export function buildDefaultTotopoYaml(workspaceId) {
138
155
  return {
@@ -141,13 +158,9 @@ export function buildDefaultTotopoYaml(workspaceId) {
141
158
  shadow_paths: [...DEFAULT_SHADOW_PATHS],
142
159
  profiles: {
143
160
  default: {
144
- description: "Base image: Node.js, git, and AI CLIs",
161
+ description: DEFAULT_PROFILE_DESCRIPTION,
145
162
  dockerfile_hook: DEFAULT_PROFILE_HOOK,
146
163
  },
147
- extended: {
148
- description: "Base image + Go, Java, Rust, and Bun",
149
- dockerfile_hook: EXTENDED_PROFILE_HOOK,
150
- },
151
164
  },
152
165
  };
153
166
  }
@@ -201,7 +214,7 @@ export function repairTotopoYaml(dir) {
201
214
  return { repairedYaml: null, error: `${TOTOPO_YAML} could not be repaired: ${formatValidationErrors(validate.errors)}` };
202
215
  }
203
216
  const yaml = obj;
204
- writeTotopoYaml(dir, yaml);
217
+ writeTotopoYaml(dir, yaml, { includeExtendedTemplate: fixes.includes("added default profiles") });
205
218
  return { repairedYaml: yaml, message: `Repaired ${TOTOPO_YAML}: ${fixes.join(", ")}` };
206
219
  }
207
220
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totopo",
3
- "version": "3.6.1",
3
+ "version": "3.7.0-rc-2",
4
4
  "description": "Run AI coding agents safely in your local codebase",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,6 @@
1
1
  ## Git availability
2
2
 
3
- The user set git mode to **local** in totopo.
4
-
5
- Please respect this choice — do not attempt remote git operations (push, pull, fetch, clone).
3
+ The user set git mode to **local** in totopo - do not attempt remote git operations (push, pull, fetch, clone).
6
4
 
7
5
  totopo enforces this by:
8
6
  - Blocking remote access (push, pull, fetch, clone).
@@ -1,8 +1,6 @@
1
1
  ## Git availability
2
2
 
3
- The user set git mode to **strict** in totopo.
4
-
5
- Please respect this choice — do not attempt git operations that modify state or interact with remotes.
3
+ The user set git mode to **strict** in totopo — do not attempt git operations that modify state or interact with remotes.
6
4
 
7
5
  totopo enforces this by:
8
6
  - Blocking git commands that would modify the repository (attempts return a clear error).
@@ -1,3 +1,3 @@
1
1
  ## Git availability
2
2
 
3
- The user set git mode to **unrestricted** in totopo. totopo does not enforce any git-specific restrictions in this mode; git operations are subject only to git's own behavior and to any credentials configured in the container.
3
+ The user set git mode to **unrestricted** in totopo. totopo does not enforce any git-specific restrictions in this mode.
@@ -1,5 +1,5 @@
1
1
  ## Git availability
2
2
 
3
- Git is **not available** — no `.git` directory was found in the workspace root.
3
+ Git is not available — no `.git` directory was found in the workspace root.
4
4
 
5
5
  If git operations are needed, ask the user to run them on the host.
@@ -1,4 +1,4 @@
1
1
  ## Your responsibilities at session start
2
2
 
3
3
  At the start of every session:
4
- - Briefly tell the user they are in a totopo sandbox and mention key limitations (git remote block, no host filesystem access outside the workspace).
4
+ - Briefly tell the user they are in a totopo sandbox and mention key limitations (totopo git mode, no host filesystem access outside the workspace).
@@ -1,7 +1,6 @@
1
1
  ## Shadow paths
2
2
 
3
- The following patterns are overlaid with container-local storage and do not reflect
4
- the host filesystem:
3
+ The following patterns are overlaid with container-local storage and do not reflect the host filesystem:
5
4
 
6
5
  {{pattern_list}}
7
6
 
@@ -9,3 +8,5 @@ Matching paths are initialized empty on first use. The container may accumulate
9
8
  content in them over time (for example, a shadowed `node_modules` gets
10
9
  populated when you run `npm install` inside the container). Do not assume they
11
10
  are empty, and do not attempt to sync or restore their host contents.
11
+
12
+ Git-tracked paths are skipped to avoid worktree diversions.
@@ -1,3 +1,3 @@
1
1
  ## Workspace
2
2
 
3
- You have access to the full workspace directory at `/workspace`. Some operations (git push, system-level changes) require running on the host.
3
+ You have access to the full workspace directory at `/workspace`. Some operations may require running on the host.
@@ -45,8 +45,10 @@ function removeWrapperIfPresent() {
45
45
  }
46
46
 
47
47
  function applyAsRoot(gitMode, protocolValue, fail) {
48
+ // Use absolute path to bypass /usr/local/bin/git (which may already be the read-only
49
+ // wrapper from a prior strict-mode session and would block this legitimate setup write).
48
50
  try {
49
- execSync(`git config --system protocol.allow ${protocolValue}`, { stdio: "pipe" });
51
+ execSync(`/usr/bin/git config --system protocol.allow ${protocolValue}`, { stdio: "pipe" });
50
52
  } catch {
51
53
  fail("git mode", `failed to set protocol.allow=${protocolValue}`);
52
54
  }
@@ -51,19 +51,17 @@ const TIMESTAMP_FILE = "/home/devuser/.ai-cli-updated";
51
51
  const THROTTLE_MS = 24 * 60 * 60 * 1000;
52
52
 
53
53
  let lastUpdate = 0;
54
+ let timestampFileExists = false;
54
55
  try {
55
56
  const raw = readFileSync(TIMESTAMP_FILE, "utf8").trim();
56
57
  lastUpdate = new Date(raw).getTime();
58
+ timestampFileExists = true;
57
59
  } catch {
58
60
  // File missing or unreadable -- treat as never updated
59
61
  }
60
62
 
61
- if (Number.isFinite(lastUpdate) && Date.now() - lastUpdate < THROTTLE_MS) {
62
- ok("AI CLIs", "up to date");
63
- } else if (!isRoot) {
64
- skip("AI CLIs", "update skipped (requires root)");
65
- } else {
66
- console.log(`${blue("●")} ${dim("Updating AI CLIs to latest...")}`);
63
+ const doUpdate = (label) => {
64
+ console.log(`${blue("")} ${dim(label)}`);
67
65
  try {
68
66
  execSync("npm install -g opencode-ai@latest @anthropic-ai/claude-code@latest @openai/codex@latest", {
69
67
  stdio: "inherit",
@@ -73,6 +71,64 @@ if (Number.isFinite(lastUpdate) && Date.now() - lastUpdate < THROTTLE_MS) {
73
71
  } catch {
74
72
  fail("AI CLIs", "update failed -- continuing with existing versions");
75
73
  }
74
+ };
75
+
76
+ // SPACE within `seconds` -> skip. Any other input is ignored. Ctrl+C exits 130. Non-TTY -> no skip.
77
+ const promptSkipUpdate = (seconds) =>
78
+ new Promise((resolve) => {
79
+ if (!process.stdin.isTTY) {
80
+ resolve(false);
81
+ return;
82
+ }
83
+ let remaining = seconds;
84
+ let tick;
85
+ let timer;
86
+ const line = (s) => `\r\x1b[K${blue("●")} ${dim(`Updating AI CLIs in ${s}s... press SPACE to skip`)}`;
87
+ const cleanup = () => {
88
+ clearInterval(tick);
89
+ clearTimeout(timer);
90
+ process.stdin.removeListener("data", onData);
91
+ process.stdin.setRawMode(false);
92
+ process.stdin.pause();
93
+ process.stdout.write("\r\x1b[K");
94
+ };
95
+ const onData = (chunk) => {
96
+ for (const byte of chunk) {
97
+ if (byte === 0x03) {
98
+ cleanup();
99
+ process.exit(130);
100
+ }
101
+ if (byte === 0x20) {
102
+ cleanup();
103
+ resolve(true);
104
+ return;
105
+ }
106
+ }
107
+ };
108
+ process.stdin.setRawMode(true);
109
+ process.stdin.resume();
110
+ process.stdin.on("data", onData);
111
+ process.stdout.write(line(remaining));
112
+ tick = setInterval(() => {
113
+ remaining -= 1;
114
+ if (remaining > 0) process.stdout.write(line(remaining));
115
+ }, 1000);
116
+ timer = setTimeout(() => {
117
+ cleanup();
118
+ resolve(false);
119
+ }, seconds * 1000);
120
+ });
121
+
122
+ if (Number.isFinite(lastUpdate) && Date.now() - lastUpdate < THROTTLE_MS) {
123
+ ok("AI CLIs", "up to date");
124
+ } else if (!isRoot) {
125
+ skip("AI CLIs", "update skipped (requires root)");
126
+ } else if (!timestampFileExists) {
127
+ doUpdate("Installing AI CLIs...");
128
+ } else if (await promptSkipUpdate(5)) {
129
+ skip("AI CLIs", "update skipped by user");
130
+ } else {
131
+ doUpdate("Updating AI CLIs to latest...");
76
132
  }
77
133
 
78
134
  // -- Security -----------------------------------------------------------------