totopo 0.8.0 → 0.9.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 +7 -7
- package/bin/totopo.js +5 -36
- package/dist/commands/advanced.js +207 -0
- package/dist/commands/dev.js +151 -39
- package/dist/commands/menu.js +7 -13
- package/dist/commands/onboard.js +21 -14
- package/dist/lib/generate-dockerfile.js +3 -3
- package/package.json +1 -1
- package/templates/Dockerfile +3 -3
- package/templates/README.md +76 -0
- package/templates/env +20 -9
- package/templates/post-start.mjs +2 -2
package/README.md
CHANGED
|
@@ -8,15 +8,15 @@ Spin up a secure, isolated AI coding environment in any git project — in one c
|
|
|
8
8
|
|
|
9
9
|
## How It Works
|
|
10
10
|
|
|
11
|
-
`npx totopo` sets up a hardened Docker container in your project with AI coding assistants (
|
|
11
|
+
`npx totopo` sets up a hardened Docker container in your project with AI coding assistants (OpenCode, Claude Code, Codex) pre-installed. Your code stays on your host machine. The AI tools run isolated inside the container.
|
|
12
12
|
|
|
13
13
|
```
|
|
14
14
|
Host machine
|
|
15
15
|
├── your editor → edits files normally (bind-mounted from container)
|
|
16
16
|
├── terminal → connected to container via docker exec
|
|
17
|
-
│ ├──
|
|
18
|
-
│ ├──
|
|
19
|
-
│ └──
|
|
17
|
+
│ ├── opencode → AI tools run here, isolated
|
|
18
|
+
│ ├── claude
|
|
19
|
+
│ └── codex
|
|
20
20
|
└── git push/pull → only possible from host, blocked inside container
|
|
21
21
|
```
|
|
22
22
|
|
|
@@ -74,9 +74,9 @@ your-project/
|
|
|
74
74
|
Run inside the container terminal:
|
|
75
75
|
|
|
76
76
|
```bash
|
|
77
|
-
claude # Claude Code (Anthropic)
|
|
78
|
-
kilo # Kilo AI
|
|
79
77
|
opencode # OpenCode
|
|
78
|
+
claude # Claude Code (Anthropic)
|
|
79
|
+
codex # Codex (OpenAI)
|
|
80
80
|
status # Re-run security + readiness check
|
|
81
81
|
```
|
|
82
82
|
|
|
@@ -90,7 +90,7 @@ status # Re-run security + readiness check
|
|
|
90
90
|
| Filesystem isolation | Only the repo is mounted — host is not visible |
|
|
91
91
|
| Git remote block | `protocol.allow never` in `/etc/gitconfig` — enforced at the git layer, requires root to override |
|
|
92
92
|
| No privilege escalation | `no-new-privileges:true` security opt |
|
|
93
|
-
| Secrets never in image | API keys
|
|
93
|
+
| Secrets never in image | API keys loaded at runtime from `~/.totopo/.env` — never baked into the image, never mounted into the container |
|
|
94
94
|
|
|
95
95
|
Remote git operations are blocked inside the container. Push from your host terminal instead.
|
|
96
96
|
|
package/bin/totopo.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// =============================================================================
|
|
6
6
|
|
|
7
7
|
import { execSync, spawnSync } from "node:child_process";
|
|
8
|
-
import { existsSync
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
9
|
import { basename, dirname } from "node:path";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
|
|
@@ -60,9 +60,7 @@ const { run: onboard } = await import("../dist/commands/onboard.js");
|
|
|
60
60
|
const { run: menu } = await import("../dist/commands/menu.js");
|
|
61
61
|
const { run: dev } = await import("../dist/commands/dev.js");
|
|
62
62
|
const { run: stop } = await import("../dist/commands/stop.js");
|
|
63
|
-
const { run:
|
|
64
|
-
const { run: manage } = await import("../dist/commands/manage.js");
|
|
65
|
-
const { run: settings } = await import("../dist/commands/settings.js");
|
|
63
|
+
const { run: advanced } = await import("../dist/commands/advanced.js");
|
|
66
64
|
|
|
67
65
|
// ─── Onboarding ───────────────────────────────────────────────────────────────
|
|
68
66
|
if (!existsSync(`${repoRoot}/.totopo/Dockerfile`)) {
|
|
@@ -98,29 +96,12 @@ const projectRunning = (projectContainerResult.stdout ?? "")
|
|
|
98
96
|
.filter(Boolean)
|
|
99
97
|
.some((n) => n === `totopo-managed-${projectName}`);
|
|
100
98
|
|
|
101
|
-
const projectImageResult = spawnSync("docker", ["images", "-q", `totopo-managed-${projectName}`], { encoding: "utf8" });
|
|
102
|
-
const projectImageExists = (projectImageResult.stdout ?? "").trim().length > 0;
|
|
103
|
-
|
|
104
|
-
let hasKey = false;
|
|
105
|
-
const envPath = `${repoRoot}/.totopo/.env`;
|
|
106
|
-
if (existsSync(envPath)) {
|
|
107
|
-
for (const line of readFileSync(envPath, "utf8").split("\n")) {
|
|
108
|
-
const trimmed = line.trim();
|
|
109
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
110
|
-
const value = trimmed.slice(trimmed.indexOf("=") + 1).trim();
|
|
111
|
-
if (value) {
|
|
112
|
-
hasKey = true;
|
|
113
|
-
break;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
99
|
// ─── Interactive menu loop ────────────────────────────────────────────────────
|
|
119
100
|
let showMenu = true;
|
|
120
101
|
while (showMenu) {
|
|
121
102
|
showMenu = false;
|
|
122
103
|
|
|
123
|
-
const action = await menu({ projectName, activeCount,
|
|
104
|
+
const action = await menu({ projectName, activeCount, projectRunning });
|
|
124
105
|
|
|
125
106
|
switch (action) {
|
|
126
107
|
case "dev":
|
|
@@ -129,20 +110,8 @@ while (showMenu) {
|
|
|
129
110
|
case "stop":
|
|
130
111
|
await stop(projectName);
|
|
131
112
|
break;
|
|
132
|
-
case "
|
|
133
|
-
await
|
|
134
|
-
await dev(packageDir, repoRoot);
|
|
135
|
-
break;
|
|
136
|
-
case "manage": {
|
|
137
|
-
const result = await manage(projectName, repoRoot);
|
|
138
|
-
if (result === "back") showMenu = true;
|
|
139
|
-
break;
|
|
140
|
-
}
|
|
141
|
-
case "doctor":
|
|
142
|
-
await doctor(repoRoot, true);
|
|
143
|
-
break;
|
|
144
|
-
case "settings": {
|
|
145
|
-
const result = await settings(packageDir, repoRoot);
|
|
113
|
+
case "advanced": {
|
|
114
|
+
const result = await advanced(packageDir, projectName, repoRoot);
|
|
146
115
|
if (result === "back") showMenu = true;
|
|
147
116
|
break;
|
|
148
117
|
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// =========================================================================================================================================
|
|
2
|
+
// src/core/commands/advanced.ts — Advanced submenu
|
|
3
|
+
// Invoked by bin/totopo.js — do not run directly.
|
|
4
|
+
// =========================================================================================================================================
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { cancel, confirm, isCancel, log, multiselect, outro, select } from "@clack/prompts";
|
|
10
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
11
|
+
function stopAndRemoveContainer(name) {
|
|
12
|
+
spawnSync("docker", ["stop", name], { stdio: "inherit" });
|
|
13
|
+
spawnSync("docker", ["rm", name], { stdio: "inherit" });
|
|
14
|
+
}
|
|
15
|
+
// ─── Clear agent memory ───────────────────────────────────────────────────────
|
|
16
|
+
async function clearAgentMemory(projectName, totopoDir) {
|
|
17
|
+
const containerName = `totopo-managed-${projectName}`;
|
|
18
|
+
// Check if the container is running
|
|
19
|
+
const inspectResult = spawnSync("docker", ["inspect", "--format", "{{.State.Status}}", containerName], {
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
stdio: "pipe",
|
|
22
|
+
});
|
|
23
|
+
const isRunning = inspectResult.status === 0 && inspectResult.stdout.trim() === "running";
|
|
24
|
+
if (isRunning) {
|
|
25
|
+
const confirmed = await confirm({
|
|
26
|
+
message: `The dev container for ${projectName} is running. It must be stopped to clear agent memory. Continue?`,
|
|
27
|
+
});
|
|
28
|
+
if (isCancel(confirmed) || !confirmed) {
|
|
29
|
+
cancel("Cancelled.");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
log.step(`Stopping ${containerName}...`);
|
|
33
|
+
stopAndRemoveContainer(containerName);
|
|
34
|
+
}
|
|
35
|
+
const agentsDir = join(totopoDir, "agents");
|
|
36
|
+
if (existsSync(agentsDir)) {
|
|
37
|
+
rmSync(agentsDir, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
log.success("Agent memory cleared. Context will be regenerated on next session start.");
|
|
40
|
+
}
|
|
41
|
+
// ─── Stop containers ──────────────────────────────────────────────────────────
|
|
42
|
+
async function stopContainers() {
|
|
43
|
+
const listResult = spawnSync("docker", ["ps", "--filter", "name=totopo-managed-", "--format", "{{.Names}}"], {
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
});
|
|
46
|
+
const running = (listResult.stdout ?? "").trim().split("\n").filter(Boolean);
|
|
47
|
+
if (running.length === 0) {
|
|
48
|
+
log.info("No running totopo containers.");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
let toStop;
|
|
52
|
+
if (running.length === 1) {
|
|
53
|
+
toStop = running;
|
|
54
|
+
log.info(`Stopping ${running[0]}...`);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const selected = await multiselect({
|
|
58
|
+
message: "Select containers to stop:",
|
|
59
|
+
options: running.map((name) => ({ value: name, label: name })),
|
|
60
|
+
required: false,
|
|
61
|
+
});
|
|
62
|
+
if (isCancel(selected)) {
|
|
63
|
+
cancel();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
toStop = selected;
|
|
67
|
+
}
|
|
68
|
+
for (const name of toStop) {
|
|
69
|
+
log.step(`Stopping ${name}...`);
|
|
70
|
+
stopAndRemoveContainer(name);
|
|
71
|
+
}
|
|
72
|
+
log.success("Done.");
|
|
73
|
+
}
|
|
74
|
+
// ─── Remove images ────────────────────────────────────────────────────────────
|
|
75
|
+
async function removeImages() {
|
|
76
|
+
const listResult = spawnSync("docker", ["images", "--filter", "label=totopo.managed=true", "--format", "{{.Repository}}\t{{.ID}}"], {
|
|
77
|
+
encoding: "utf8",
|
|
78
|
+
});
|
|
79
|
+
const lines = (listResult.stdout ?? "").trim().split("\n").filter(Boolean);
|
|
80
|
+
if (lines.length === 0) {
|
|
81
|
+
log.info("No totopo images found.");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const images = lines.map((line) => {
|
|
85
|
+
const [repo, id] = line.split("\t");
|
|
86
|
+
const workspace = (repo ?? "").replace(/^totopo-managed-/, "");
|
|
87
|
+
return { repo: repo ?? "", id: id ?? "", workspace };
|
|
88
|
+
});
|
|
89
|
+
const selected = await multiselect({
|
|
90
|
+
message: "Select images to remove:",
|
|
91
|
+
options: images.map((img) => ({
|
|
92
|
+
value: img.repo,
|
|
93
|
+
label: `${img.workspace} (${img.repo})`,
|
|
94
|
+
})),
|
|
95
|
+
required: false,
|
|
96
|
+
});
|
|
97
|
+
if (isCancel(selected)) {
|
|
98
|
+
cancel();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
for (const repo of selected) {
|
|
102
|
+
// Stop any running container using this image first
|
|
103
|
+
const psResult = spawnSync("docker", ["ps", "--filter", `name=${repo}`, "--format", "{{.Names}}"], {
|
|
104
|
+
encoding: "utf8",
|
|
105
|
+
});
|
|
106
|
+
const containers = (psResult.stdout ?? "").trim().split("\n").filter(Boolean);
|
|
107
|
+
for (const c of containers) {
|
|
108
|
+
log.step(`Stopping container ${c} before removing image...`);
|
|
109
|
+
stopAndRemoveContainer(c);
|
|
110
|
+
}
|
|
111
|
+
log.step(`Removing image ${repo}...`);
|
|
112
|
+
spawnSync("docker", ["rmi", repo], { stdio: "inherit" });
|
|
113
|
+
}
|
|
114
|
+
log.success("Done.");
|
|
115
|
+
}
|
|
116
|
+
// ─── Uninstall ────────────────────────────────────────────────────────────────
|
|
117
|
+
async function uninstall(projectName, repoRoot) {
|
|
118
|
+
const containerName = `totopo-managed-${projectName}`;
|
|
119
|
+
const imageName = `totopo-managed-${projectName}`;
|
|
120
|
+
const confirmed = await confirm({
|
|
121
|
+
message: `Remove .totopo/, stop containers, and delete the image for ${projectName}?`,
|
|
122
|
+
});
|
|
123
|
+
if (isCancel(confirmed) || !confirmed) {
|
|
124
|
+
cancel();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const inspectResult = spawnSync("docker", ["inspect", "--type", "container", containerName], { encoding: "utf8" });
|
|
128
|
+
if (inspectResult.status === 0) {
|
|
129
|
+
log.step(`Stopping container ${containerName}...`);
|
|
130
|
+
stopAndRemoveContainer(containerName);
|
|
131
|
+
}
|
|
132
|
+
const imageResult = spawnSync("docker", ["images", "-q", imageName], { encoding: "utf8" });
|
|
133
|
+
if ((imageResult.stdout ?? "").trim().length > 0) {
|
|
134
|
+
log.step(`Removing image ${imageName}...`);
|
|
135
|
+
spawnSync("docker", ["rmi", imageName], { stdio: "inherit" });
|
|
136
|
+
}
|
|
137
|
+
log.step("Removing .totopo/...");
|
|
138
|
+
rmSync(join(repoRoot, ".totopo"), { recursive: true, force: true });
|
|
139
|
+
outro("Uninstalled. Re-run npx totopo to set up again.");
|
|
140
|
+
}
|
|
141
|
+
// ─── Reset API keys ───────────────────────────────────────────────────────────
|
|
142
|
+
async function resetApiKeys(packageDir) {
|
|
143
|
+
const globalEnvPath = join(homedir(), ".totopo", ".env");
|
|
144
|
+
const confirmed = await confirm({
|
|
145
|
+
message: `Reset ${globalEnvPath}? This affects all totopo projects on this machine.`,
|
|
146
|
+
});
|
|
147
|
+
if (isCancel(confirmed) || !confirmed) {
|
|
148
|
+
cancel("Cancelled.");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
mkdirSync(join(homedir(), ".totopo"), { recursive: true });
|
|
152
|
+
cpSync(join(packageDir, "templates", "env"), globalEnvPath);
|
|
153
|
+
log.success(`API keys reset. Edit ${globalEnvPath} to add your keys.`);
|
|
154
|
+
}
|
|
155
|
+
// ─── Advanced submenu ─────────────────────────────────────────────────────────
|
|
156
|
+
export async function run(packageDir, projectName, repoRoot) {
|
|
157
|
+
// Dynamic imports to avoid circular deps — same pattern as bin/totopo.js
|
|
158
|
+
const { run: runSettings } = await import("./settings.js");
|
|
159
|
+
const { run: runRebuild } = await import("./rebuild.js");
|
|
160
|
+
const { run: runDoctor } = await import("./doctor.js");
|
|
161
|
+
const totopoDir = join(repoRoot, ".totopo");
|
|
162
|
+
while (true) {
|
|
163
|
+
const action = await select({
|
|
164
|
+
message: "Advanced:",
|
|
165
|
+
options: [
|
|
166
|
+
{ value: "runtime-mode", label: "Runtime mode", hint: "switch between host-mirror and full" },
|
|
167
|
+
{ value: "rebuild", label: "Rebuild container", hint: "force a fresh image build for this project" },
|
|
168
|
+
{ value: "clear-memory", label: "Clear agent memory", hint: "wipe conversation history for this project" },
|
|
169
|
+
{ value: "uninstall", label: "Uninstall from this project", hint: "removes .totopo/ and deletes the container and image" },
|
|
170
|
+
{ value: "stop-containers", label: "Stop containers", hint: "all projects" },
|
|
171
|
+
{ value: "remove-images", label: "Remove images", hint: "all projects" },
|
|
172
|
+
{ value: "reset-keys", label: "Reset API keys", hint: "overwrites ~/.totopo/.env — affects all projects" },
|
|
173
|
+
{ value: "doctor", label: "Doctor", hint: "check Docker and container health" },
|
|
174
|
+
{ value: "back", label: "← Back" },
|
|
175
|
+
],
|
|
176
|
+
});
|
|
177
|
+
if (isCancel(action) || action === "back") {
|
|
178
|
+
return "back";
|
|
179
|
+
}
|
|
180
|
+
switch (action) {
|
|
181
|
+
case "runtime-mode":
|
|
182
|
+
await runSettings(packageDir, repoRoot);
|
|
183
|
+
break;
|
|
184
|
+
case "rebuild":
|
|
185
|
+
await runRebuild(projectName);
|
|
186
|
+
break;
|
|
187
|
+
case "clear-memory":
|
|
188
|
+
await clearAgentMemory(projectName, totopoDir);
|
|
189
|
+
break;
|
|
190
|
+
case "reset-keys":
|
|
191
|
+
await resetApiKeys(packageDir);
|
|
192
|
+
break;
|
|
193
|
+
case "uninstall":
|
|
194
|
+
await uninstall(projectName, repoRoot);
|
|
195
|
+
return; // uninstall tears down .totopo — exit entirely
|
|
196
|
+
case "doctor":
|
|
197
|
+
await runDoctor(repoRoot, true);
|
|
198
|
+
break;
|
|
199
|
+
case "stop-containers":
|
|
200
|
+
await stopContainers();
|
|
201
|
+
break;
|
|
202
|
+
case "remove-images":
|
|
203
|
+
await removeImages();
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
package/dist/commands/dev.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// Invoked by bin/totopo.js — do not run directly.
|
|
4
4
|
// =========================================================================================================================================
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
|
-
import { existsSync,
|
|
7
|
-
import {
|
|
8
|
-
import { basename, join, relative } from "node:path";
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
9
9
|
import { cancel, confirm, groupMultiselect, isCancel, log, multiselect, note, outro, path, select } from "@clack/prompts";
|
|
10
10
|
// ─── Prompt: scope selection ──────────────────────────────────────────────────
|
|
11
11
|
async function promptScope(workspaceDir, totopoDir, cwd) {
|
|
@@ -30,8 +30,24 @@ async function promptScope(workspaceDir, totopoDir, cwd) {
|
|
|
30
30
|
// Fallback to cwd mode when no visible items exist
|
|
31
31
|
return { mode: "cwd", hostCwd: cwd, selectedPaths: [] };
|
|
32
32
|
}
|
|
33
|
+
log.warn("Scoped workspace — some context may be unavailable to the agent:\n" +
|
|
34
|
+
" · Your personal agent config files (~/.claude/CLAUDE.md, ~/.config/opencode/AGENTS.md, etc.)\n" +
|
|
35
|
+
" are not mounted from the host — only totopo's injected context is available.\n" +
|
|
36
|
+
" · Project-level context files (AGENTS.md, CLAUDE.md, .claude/rules/, etc.) that live\n" +
|
|
37
|
+
" outside your mounted paths will not be visible to the agent.\n" +
|
|
38
|
+
" · Git is unavailable — .git is not mounted in scoped mode (security boundary).\n" +
|
|
39
|
+
" The agent has been instructed to surface its limitations at session start.");
|
|
33
40
|
return { mode, hostCwd: cwd, selectedPaths };
|
|
34
41
|
}
|
|
42
|
+
if (mode === "cwd") {
|
|
43
|
+
log.warn("Scoped workspace — some context may be unavailable to the agent:\n" +
|
|
44
|
+
" · Your personal agent config files (~/.claude/CLAUDE.md, ~/.config/opencode/AGENTS.md, etc.)\n" +
|
|
45
|
+
" are not mounted from the host — only totopo's injected context is available.\n" +
|
|
46
|
+
" · Project-level context files (AGENTS.md, CLAUDE.md, .claude/rules/, etc.) that live\n" +
|
|
47
|
+
" outside this directory will not be visible to the agent.\n" +
|
|
48
|
+
" · Git is unavailable — .git is not mounted in scoped mode (security boundary).\n" +
|
|
49
|
+
" The agent has been instructed to surface its limitations at session start.");
|
|
50
|
+
}
|
|
35
51
|
return { mode, hostCwd: cwd, selectedPaths: [] };
|
|
36
52
|
}
|
|
37
53
|
// ─── Prompt: selective path selection ─────────────────────────────────────────
|
|
@@ -209,14 +225,31 @@ function getTotopoMountPath(scope, workspaceDir) {
|
|
|
209
225
|
return "/workspace/.totopo";
|
|
210
226
|
return "/home/devuser/.totopo";
|
|
211
227
|
}
|
|
228
|
+
// ─── Build agent mount args ───────────────────────────────────────────────────
|
|
229
|
+
// Creates .totopo/agents/ subdirectories on the host (lazily, on first run) and
|
|
230
|
+
// returns volume mount args for all supported agent tools. Each agent tool gets
|
|
231
|
+
// its own read-write bind mount so session data persists across container rebuilds.
|
|
232
|
+
function buildAgentMountArgs(totopoDir) {
|
|
233
|
+
const agentsDir = join(totopoDir, "agents");
|
|
234
|
+
const mounts = [
|
|
235
|
+
{ host: join(agentsDir, "claude"), container: "/home/devuser/.claude" },
|
|
236
|
+
{ host: join(agentsDir, "opencode", "config"), container: "/home/devuser/.config/opencode" },
|
|
237
|
+
{ host: join(agentsDir, "opencode", "data"), container: "/home/devuser/.local/share/opencode" },
|
|
238
|
+
{ host: join(agentsDir, "codex"), container: "/home/devuser/.codex" },
|
|
239
|
+
];
|
|
240
|
+
for (const { host } of mounts)
|
|
241
|
+
mkdirSync(host, { recursive: true });
|
|
242
|
+
return mounts.flatMap(({ host, container }) => ["-v", `${host}:${container}`]);
|
|
243
|
+
}
|
|
212
244
|
// ─── Build mount args ─────────────────────────────────────────────────────────
|
|
213
245
|
function buildMountArgs(scope, workspaceDir, totopoDir, cwd) {
|
|
214
246
|
const totopoMount = getTotopoMountPath(scope, workspaceDir);
|
|
247
|
+
const agentMounts = buildAgentMountArgs(totopoDir);
|
|
215
248
|
if (scope.mode === "repo") {
|
|
216
|
-
return ["-v", `${workspaceDir}:/workspace
|
|
249
|
+
return ["-v", `${workspaceDir}:/workspace`, ...agentMounts];
|
|
217
250
|
}
|
|
218
251
|
if (scope.mode === "cwd") {
|
|
219
|
-
return ["-v", `${cwd}:/workspace`, ...(cwd !== workspaceDir ? ["-v", `${totopoDir}:${totopoMount}:ro`] : [])];
|
|
252
|
+
return ["-v", `${cwd}:/workspace`, ...(cwd !== workspaceDir ? ["-v", `${totopoDir}:${totopoMount}:ro`] : []), ...agentMounts];
|
|
220
253
|
}
|
|
221
254
|
// selective: validate all paths exist first
|
|
222
255
|
for (const p of scope.selectedPaths) {
|
|
@@ -226,7 +259,12 @@ function buildMountArgs(scope, workspaceDir, totopoDir, cwd) {
|
|
|
226
259
|
process.exit(1);
|
|
227
260
|
}
|
|
228
261
|
}
|
|
229
|
-
return [
|
|
262
|
+
return [
|
|
263
|
+
...scope.selectedPaths.flatMap((p) => ["-v", `${join(cwd, p)}:/workspace/${p}`]),
|
|
264
|
+
"-v",
|
|
265
|
+
`${totopoDir}:${totopoMount}:ro`,
|
|
266
|
+
...agentMounts,
|
|
267
|
+
];
|
|
230
268
|
}
|
|
231
269
|
// ─── Build scope env args ─────────────────────────────────────────────────────
|
|
232
270
|
function buildScopeEnvArgs(scope) {
|
|
@@ -290,47 +328,102 @@ function scopesMatch(selected, existing, workspaceDir) {
|
|
|
290
328
|
}
|
|
291
329
|
return true;
|
|
292
330
|
}
|
|
293
|
-
|
|
294
|
-
//
|
|
295
|
-
function buildAgentContextDoc(scope, workspaceDir) {
|
|
331
|
+
function buildAgentContextDocs(scope) {
|
|
332
|
+
// ── Scope section ──────────────────────────────────────────────────────────
|
|
296
333
|
let scopeSection;
|
|
297
334
|
if (scope.mode === "repo") {
|
|
298
335
|
scopeSection = `## Workspace scope: repo
|
|
299
336
|
|
|
300
|
-
You
|
|
337
|
+
You have access to the full repository at \`/workspace\`. Some operations (git push, system-level changes) require running on the host.`;
|
|
301
338
|
}
|
|
302
339
|
else if (scope.mode === "cwd") {
|
|
303
340
|
scopeSection = `## Workspace scope: cwd
|
|
304
341
|
|
|
305
|
-
Workspace is scoped to one directory (\`${scope.hostCwd}\`). Files outside it are not visible. Commands that depend on absent files will fail.`;
|
|
342
|
+
Workspace is scoped to one directory (\`${scope.hostCwd}\`). Files outside it are not visible to you. Commands that depend on absent files will fail.`;
|
|
306
343
|
}
|
|
307
344
|
else {
|
|
308
345
|
const pathList = scope.selectedPaths.map((p) => `- \`/workspace/${p}\``).join("\n");
|
|
309
346
|
scopeSection = `## Workspace scope: selective
|
|
310
347
|
|
|
311
|
-
Workspace is selectively scoped.
|
|
348
|
+
Workspace is selectively scoped. Only the following paths are mounted:\n\n${pathList}`;
|
|
349
|
+
}
|
|
350
|
+
// ── Git section ────────────────────────────────────────────────────────────
|
|
351
|
+
let gitSection;
|
|
352
|
+
if (scope.mode === "repo") {
|
|
353
|
+
gitSection = `## Git availability
|
|
354
|
+
|
|
355
|
+
Git is fully available for local operations (commit, branch, log, diff, status, etc.).
|
|
356
|
+
|
|
357
|
+
Remote access (push, pull, fetch, clone) is **blocked at the system level** by design — \`protocol.allow = never\` is enforced in \`/etc/gitconfig\` and cannot be overridden without root. This is a deliberate security boundary: the container has no access to remote repositories. Ask the user to run any remote git operations from the host.`;
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
gitSection = `## Git availability
|
|
361
|
+
|
|
362
|
+
Git local operations are **not available** in this scope — \`.git\` is not mounted. This is intentional: mounting \`.git\` would expose the full commit history of all repository files, including those outside your current mount, defeating the security boundary of scoped access.
|
|
363
|
+
|
|
364
|
+
Remote access is also **blocked container-wide** by design (\`protocol.allow = never\` in \`/etc/gitconfig\`).
|
|
365
|
+
|
|
366
|
+
If git operations are needed, ask the user to run them on the host.`;
|
|
312
367
|
}
|
|
313
|
-
|
|
368
|
+
// ── Selective-only warning ─────────────────────────────────────────────────
|
|
369
|
+
const selectiveWarning = scope.mode === "selective"
|
|
370
|
+
? `\n\n## Selective scope: file creation warning
|
|
371
|
+
|
|
372
|
+
Any file you create **outside your mounted paths** (e.g. at \`/\`, \`/tmp\`, or any path not listed above) will **not be visible on the host** and will be lost when the container is rebuilt.
|
|
373
|
+
|
|
374
|
+
If the user asks you to create or modify a file at such a location:
|
|
375
|
+
1. Notify the user that the path is outside your mounted workspace.
|
|
376
|
+
2. Explain that files created there will not sync to the host.
|
|
377
|
+
3. Suggest the user run the command on the host instead, or confirm they want the file only inside the container (understanding it will be lost on rebuild).`
|
|
378
|
+
: "";
|
|
379
|
+
// ── Responsibilities section ───────────────────────────────────────────────
|
|
380
|
+
const responsibilitiesSection = `## Your responsibilities at session start
|
|
381
|
+
|
|
382
|
+
At the start of every session:
|
|
383
|
+
- Briefly surface your current workspace scope and its limitations to the user.
|
|
384
|
+
- Tell the user what you cannot access in this session (files, git, remotes).`;
|
|
385
|
+
// ── Assemble per-tool — only the self-referencing path differs ─────────────
|
|
386
|
+
function build(toolPath) {
|
|
387
|
+
const constraintsSection = `## Constraints
|
|
314
388
|
|
|
315
389
|
- Files outside mounted paths cannot be read, written, or executed.
|
|
316
390
|
- If a command fails because of missing files, tell the user: "I have limited workspace scope — please run \`<command>\` on the host."
|
|
317
|
-
-
|
|
318
|
-
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
391
|
+
- \`.totopo/\` is read-only inside the container.
|
|
392
|
+
- This file (\`${toolPath}\`) is managed by totopo and overwritten on every session start. Do not edit it.`;
|
|
393
|
+
return ([
|
|
394
|
+
"# totopo Workspace Context\n\nYou are running inside a totopo dev container.\n",
|
|
395
|
+
scopeSection,
|
|
396
|
+
gitSection,
|
|
397
|
+
constraintsSection,
|
|
398
|
+
responsibilitiesSection,
|
|
399
|
+
].join("\n\n") +
|
|
400
|
+
selectiveWarning +
|
|
401
|
+
"\n");
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
claude: build("~/.claude/CLAUDE.md"),
|
|
405
|
+
opencode: build("~/.config/opencode/AGENTS.md"),
|
|
406
|
+
codex: build("~/.codex/AGENTS.md"),
|
|
407
|
+
};
|
|
326
408
|
}
|
|
327
|
-
// ─── Inject agent context
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
409
|
+
// ─── Inject agent context ─────────────────────────────────────────────────────
|
|
410
|
+
// Writes context files directly to .totopo/agents/ on the host. The agent dirs
|
|
411
|
+
// are created on demand (recursive mkdir) so this is safe to call on first run
|
|
412
|
+
// before any directories exist, as well as on subsequent runs where it simply
|
|
413
|
+
// overwrites existing files with the latest context. The agent dirs are served
|
|
414
|
+
// into the container via volume mounts — no docker cp required. Called before
|
|
415
|
+
// every container start/resume so context always reflects the current scope.
|
|
416
|
+
function injectAgentContext(totopoDir, docs) {
|
|
417
|
+
const a = join(totopoDir, "agents");
|
|
418
|
+
const files = [
|
|
419
|
+
{ path: join(a, "claude", "CLAUDE.md"), content: docs.claude },
|
|
420
|
+
{ path: join(a, "opencode", "config", "AGENTS.md"), content: docs.opencode },
|
|
421
|
+
{ path: join(a, "codex", "AGENTS.md"), content: docs.codex },
|
|
422
|
+
];
|
|
423
|
+
for (const { path, content } of files) {
|
|
424
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
425
|
+
writeFileSync(path, content);
|
|
426
|
+
}
|
|
334
427
|
}
|
|
335
428
|
// ─── Run post-start ───────────────────────────────────────────────────────────
|
|
336
429
|
function runPostStart(name, totopoMountPath) {
|
|
@@ -348,8 +441,22 @@ function removeContainer(name) {
|
|
|
348
441
|
spawnSync("docker", ["stop", name], { stdio: "pipe" });
|
|
349
442
|
spawnSync("docker", ["rm", name], { stdio: "pipe" });
|
|
350
443
|
}
|
|
444
|
+
// ─── Ensure global env file exists ───────────────────────────────────────────
|
|
445
|
+
// ~/.totopo/.env lives outside all project repos so it is never mounted into
|
|
446
|
+
// the container and cannot be read by agents. Created empty on first run so
|
|
447
|
+
// --env-file always has a valid target.
|
|
448
|
+
function ensureGlobalEnvFile() {
|
|
449
|
+
const globalTotopoDir = join(homedir(), ".totopo");
|
|
450
|
+
const envFile = join(globalTotopoDir, ".env");
|
|
451
|
+
mkdirSync(globalTotopoDir, { recursive: true });
|
|
452
|
+
if (!existsSync(envFile)) {
|
|
453
|
+
writeFileSync(envFile, "");
|
|
454
|
+
}
|
|
455
|
+
return envFile;
|
|
456
|
+
}
|
|
351
457
|
// ─── Run container ────────────────────────────────────────────────────────────
|
|
352
458
|
function runContainer(scope, containerName, imageName, workspaceDir, totopoDir, cwd) {
|
|
459
|
+
const envFile = ensureGlobalEnvFile();
|
|
353
460
|
const run = spawnSync("docker", [
|
|
354
461
|
"run",
|
|
355
462
|
"-d",
|
|
@@ -357,7 +464,7 @@ function runContainer(scope, containerName, imageName, workspaceDir, totopoDir,
|
|
|
357
464
|
containerName,
|
|
358
465
|
...buildMountArgs(scope, workspaceDir, totopoDir, cwd),
|
|
359
466
|
"--env-file",
|
|
360
|
-
|
|
467
|
+
envFile,
|
|
361
468
|
...buildScopeEnvArgs(scope),
|
|
362
469
|
...buildScopeLabelArgs(scope),
|
|
363
470
|
"--security-opt",
|
|
@@ -396,10 +503,10 @@ export async function run(_packageDir, repoRoot) {
|
|
|
396
503
|
process.exit(build.status ?? 1);
|
|
397
504
|
}
|
|
398
505
|
const totopoMountPath = getTotopoMountPath(scope, repoRoot);
|
|
506
|
+
log.step("Preparing agent context...");
|
|
507
|
+
injectAgentContext(totopoDir, buildAgentContextDocs(scope));
|
|
399
508
|
log.step("Starting dev container...");
|
|
400
509
|
runContainer(scope, containerName, imageName, repoRoot, totopoDir, cwd);
|
|
401
|
-
log.step("Injecting agent context...");
|
|
402
|
-
injectAgentContext(containerName, buildAgentContextDoc(scope, repoRoot));
|
|
403
510
|
runPostStart(containerName, totopoMountPath);
|
|
404
511
|
}
|
|
405
512
|
else if (containerStatus === "exited") {
|
|
@@ -407,22 +514,22 @@ export async function run(_packageDir, repoRoot) {
|
|
|
407
514
|
const existingScope = readContainerScopeLabel(containerName);
|
|
408
515
|
const totopoMountPath = getTotopoMountPath(scope, repoRoot);
|
|
409
516
|
if (scopesMatch(scope, existingScope, repoRoot)) {
|
|
517
|
+
log.step("Preparing agent context...");
|
|
518
|
+
injectAgentContext(totopoDir, buildAgentContextDocs(scope));
|
|
410
519
|
log.step("Resuming dev container...");
|
|
411
520
|
const start = spawnSync("docker", ["start", containerName], { stdio: "inherit" });
|
|
412
521
|
if (start.status !== 0) {
|
|
413
522
|
outro("Failed to start dev container.");
|
|
414
523
|
process.exit(start.status ?? 1);
|
|
415
524
|
}
|
|
416
|
-
log.step("Injecting agent context...");
|
|
417
|
-
injectAgentContext(containerName, buildAgentContextDoc(scope, repoRoot));
|
|
418
525
|
runPostStart(containerName, totopoMountPath);
|
|
419
526
|
}
|
|
420
527
|
else {
|
|
528
|
+
log.step("Preparing agent context...");
|
|
529
|
+
injectAgentContext(totopoDir, buildAgentContextDocs(scope));
|
|
421
530
|
log.step("Recreating dev container with new scope...");
|
|
422
531
|
removeContainer(containerName);
|
|
423
532
|
runContainer(scope, containerName, imageName, repoRoot, totopoDir, cwd);
|
|
424
|
-
log.step("Injecting agent context...");
|
|
425
|
-
injectAgentContext(containerName, buildAgentContextDoc(scope, repoRoot));
|
|
426
533
|
runPostStart(containerName, totopoMountPath);
|
|
427
534
|
}
|
|
428
535
|
}
|
|
@@ -431,14 +538,19 @@ export async function run(_packageDir, repoRoot) {
|
|
|
431
538
|
const existingScope = readContainerScopeLabel(containerName);
|
|
432
539
|
if (!scopesMatch(scope, existingScope, repoRoot)) {
|
|
433
540
|
const totopoMountPath = getTotopoMountPath(scope, repoRoot);
|
|
541
|
+
log.step("Preparing agent context...");
|
|
542
|
+
injectAgentContext(totopoDir, buildAgentContextDocs(scope));
|
|
434
543
|
log.step("Recreating dev container with new scope...");
|
|
435
544
|
removeContainer(containerName);
|
|
436
545
|
runContainer(scope, containerName, imageName, repoRoot, totopoDir, cwd);
|
|
437
|
-
log.step("Injecting agent context...");
|
|
438
|
-
injectAgentContext(containerName, buildAgentContextDoc(scope, repoRoot));
|
|
439
546
|
runPostStart(containerName, totopoMountPath);
|
|
440
547
|
}
|
|
441
|
-
|
|
548
|
+
else {
|
|
549
|
+
// Same scope and container already running — refresh context in place.
|
|
550
|
+
log.step("Refreshing agent context...");
|
|
551
|
+
injectAgentContext(totopoDir, buildAgentContextDocs(scope));
|
|
552
|
+
}
|
|
553
|
+
// fall through to connect
|
|
442
554
|
}
|
|
443
555
|
// ─── Connect ──────────────────────────────────────────────────────────────────
|
|
444
556
|
const exec = spawnSync("docker", ["exec", "-it", "-w", "/workspace", containerName, "bash", "--login"], {
|
package/dist/commands/menu.js
CHANGED
|
@@ -4,28 +4,22 @@
|
|
|
4
4
|
// =========================================================================================================================================
|
|
5
5
|
import { box, cancel, isCancel, select } from "@clack/prompts";
|
|
6
6
|
export async function run(args) {
|
|
7
|
-
const { projectName, activeCount, projectRunning
|
|
8
|
-
// ─── Status box
|
|
7
|
+
const { projectName, activeCount, projectRunning } = args;
|
|
8
|
+
// ─── Status box ──────────────────────────────────────────────────────────────
|
|
9
9
|
const containersLabel = activeCount === 0 ? "none" : activeCount === 1 ? "1 running" : `${activeCount} running`;
|
|
10
|
-
|
|
11
|
-
lines.push(`workspace: ${projectName}`);
|
|
12
|
-
lines.push(`containers: ${containersLabel}`);
|
|
13
|
-
box(lines.join("\n"), " totopo ", {
|
|
10
|
+
box(`workspace: ${projectName}\ncontainers: ${containersLabel}\nkeys: ~/.totopo/.env`, " totopo ", {
|
|
14
11
|
contentAlign: "center",
|
|
15
12
|
titleAlign: "center",
|
|
16
13
|
width: "auto",
|
|
17
14
|
rounded: true,
|
|
18
15
|
});
|
|
19
|
-
// ─── Menu
|
|
16
|
+
// ─── Menu ─────────────────────────────────────────────────────────────────────
|
|
20
17
|
const action = await select({
|
|
21
18
|
message: "Menu:",
|
|
22
19
|
options: [
|
|
23
|
-
{ value: "dev", label: "
|
|
24
|
-
...(projectRunning ? [{ value: "stop", label: "Stop" }] : []),
|
|
25
|
-
|
|
26
|
-
{ value: "settings", label: "Settings" },
|
|
27
|
-
{ value: "manage", label: "Manage workspaces" },
|
|
28
|
-
{ value: "doctor", label: "Doctor" },
|
|
20
|
+
{ value: "dev", label: "Open session", hint: "start or resume the dev container" },
|
|
21
|
+
...(projectRunning ? [{ value: "stop", label: "Stop dev container", hint: "stops this project's container" }] : []),
|
|
22
|
+
{ value: "advanced", label: "Advanced", hint: "rebuild, memory, settings, and more" },
|
|
29
23
|
{ value: "quit", label: "Quit" },
|
|
30
24
|
],
|
|
31
25
|
});
|
package/dist/commands/onboard.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Invoked by bin/totopo.js when no .totopo/ config is found in the project.
|
|
4
4
|
// =========================================================================================================================================
|
|
5
5
|
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
6
7
|
import { basename, join } from "node:path";
|
|
7
8
|
import { box, cancel, confirm, intro, isCancel, log, outro, select } from "@clack/prompts";
|
|
8
9
|
import { writeSettings } from "../lib/config.js";
|
|
@@ -31,6 +32,7 @@ export async function run(packageDir, repoRoot) {
|
|
|
31
32
|
mkdirSync(totopoDir, { recursive: true });
|
|
32
33
|
cpSync(join(templatesDir, "Dockerfile"), join(totopoDir, "Dockerfile"));
|
|
33
34
|
cpSync(join(templatesDir, "post-start.mjs"), join(totopoDir, "post-start.mjs"));
|
|
35
|
+
cpSync(join(templatesDir, "README.md"), join(totopoDir, "README.md"));
|
|
34
36
|
log.success("Copied config templates to .totopo/");
|
|
35
37
|
// ─── Runtime mode ────────────────────────────────────────────────────────────
|
|
36
38
|
const modeChoice = await select({
|
|
@@ -66,14 +68,17 @@ export async function run(packageDir, repoRoot) {
|
|
|
66
68
|
return false;
|
|
67
69
|
}
|
|
68
70
|
const commitScope = scopeChoice;
|
|
69
|
-
// ─── Create .env
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
// ─── Create global .env ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
// ~/.totopo/.env lives outside all project repos — never mounted into containers, never readable by agents.
|
|
73
|
+
const globalTotopoDir = join(homedir(), ".totopo");
|
|
74
|
+
const globalEnvPath = join(globalTotopoDir, ".env");
|
|
75
|
+
mkdirSync(globalTotopoDir, { recursive: true });
|
|
76
|
+
if (existsSync(globalEnvPath)) {
|
|
77
|
+
log.info(`${globalEnvPath} already exists — leaving it untouched`);
|
|
73
78
|
}
|
|
74
79
|
else {
|
|
75
|
-
cpSync(join(templatesDir, "env"),
|
|
76
|
-
log.success(
|
|
80
|
+
cpSync(join(templatesDir, "env"), globalEnvPath);
|
|
81
|
+
log.success(`Created ${globalEnvPath}`);
|
|
77
82
|
}
|
|
78
83
|
// ─── Gitignore ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
79
84
|
const gitignorePath = join(repoRoot, ".gitignore");
|
|
@@ -91,18 +96,20 @@ export async function run(packageDir, repoRoot) {
|
|
|
91
96
|
}
|
|
92
97
|
}
|
|
93
98
|
else {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
if (gitignoreContent?.includes(
|
|
97
|
-
log.info(".totopo
|
|
99
|
+
const agentsEntry = ".totopo/agents/";
|
|
100
|
+
let content = gitignoreContent ?? "";
|
|
101
|
+
if (gitignoreContent?.includes(agentsEntry)) {
|
|
102
|
+
log.info(".totopo/agents/ already in .gitignore");
|
|
98
103
|
}
|
|
99
104
|
else {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
105
|
+
content += "\n# totopo — agent session data is local only\n.totopo/agents/\n";
|
|
106
|
+
log.success("Added .totopo/agents/ to .gitignore");
|
|
107
|
+
}
|
|
108
|
+
if (content !== (gitignoreContent ?? "")) {
|
|
109
|
+
writeFileSync(gitignorePath, content);
|
|
103
110
|
}
|
|
104
111
|
}
|
|
105
|
-
log.info(
|
|
112
|
+
log.info(`Add API keys to ${globalEnvPath} before starting the container.`);
|
|
106
113
|
outro("Setup complete.");
|
|
107
114
|
return true;
|
|
108
115
|
}
|
|
@@ -34,7 +34,7 @@ function buildHostMirrorDockerfile(selectedTools, host) {
|
|
|
34
34
|
sections.push(`# =============================================================================
|
|
35
35
|
# Secure AI Dev Container — host-mirror mode
|
|
36
36
|
# =============================================================================
|
|
37
|
-
# Non-root user, no git remote access, AI tools:
|
|
37
|
+
# Non-root user, no git remote access, AI tools: opencode, claude, codex
|
|
38
38
|
# Runtimes: selected by totopo host-mirror (regenerated on each session start)
|
|
39
39
|
# =============================================================================
|
|
40
40
|
|
|
@@ -147,9 +147,9 @@ RUN git config --system protocol.allow never && \
|
|
|
147
147
|
# ---------------------------------------------------------------------------
|
|
148
148
|
RUN npm install -g \
|
|
149
149
|
pnpm \
|
|
150
|
-
@anthropic-ai/claude-code \
|
|
151
|
-
@kilocode/cli \
|
|
152
150
|
opencode-ai \
|
|
151
|
+
@anthropic-ai/claude-code \
|
|
152
|
+
@openai/codex \
|
|
153
153
|
&& npm cache clean --force`);
|
|
154
154
|
// ── Layer 10 — Non-root user ─────────────────────────────────────────────
|
|
155
155
|
sections.push(String.raw `# ---------------------------------------------------------------------------
|
package/package.json
CHANGED
package/templates/Dockerfile
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# =============================================================================
|
|
2
2
|
# Secure AI Dev Container — General-purpose multi-stack
|
|
3
3
|
# =============================================================================
|
|
4
|
-
# Non-root user, no git remote access, AI tools:
|
|
4
|
+
# Non-root user, no git remote access, AI tools: opencode, claude, codex
|
|
5
5
|
# Runtimes: Node.js, Python, Go, Rust, Java (Temurin 21), Bun
|
|
6
6
|
# =============================================================================
|
|
7
7
|
|
|
@@ -93,9 +93,9 @@ RUN git config --system protocol.allow never && \
|
|
|
93
93
|
# ---------------------------------------------------------------------------
|
|
94
94
|
RUN npm install -g \
|
|
95
95
|
pnpm \
|
|
96
|
-
@anthropic-ai/claude-code \
|
|
97
|
-
@kilocode/cli \
|
|
98
96
|
opencode-ai \
|
|
97
|
+
@anthropic-ai/claude-code \
|
|
98
|
+
@openai/codex \
|
|
99
99
|
&& npm cache clean --force
|
|
100
100
|
|
|
101
101
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# .totopo — Reference
|
|
2
|
+
|
|
3
|
+
Created by `npx totopo`. Manages the secure dev container for this project.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Files
|
|
8
|
+
|
|
9
|
+
| File | Purpose |
|
|
10
|
+
| ---------------- | ---------------------------------------------------------- |
|
|
11
|
+
| `Dockerfile` | Builds the container image |
|
|
12
|
+
| `post-start.mjs` | Runs on every start — security checks + readiness summary |
|
|
13
|
+
| `settings.json` | Runtime mode + selected tools (committed with project) |
|
|
14
|
+
| `agents/` | Agent session data — created on first session start |
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## agents/
|
|
19
|
+
|
|
20
|
+
Initialised automatically the first time you run a dev session. Contains
|
|
21
|
+
per-tool subdirectories for each supported agent, mounted into the container
|
|
22
|
+
so session history and conversation data persist across rebuilds:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
agents/claude/ → ~/.claude/ (Claude Code)
|
|
26
|
+
agents/opencode/config/ → ~/.config/opencode/ (OpenCode)
|
|
27
|
+
agents/opencode/data/ → ~/.local/share/opencode/
|
|
28
|
+
agents/codex/ → ~/.codex/ (Codex)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Context files (`CLAUDE.md` / `AGENTS.md`) are written into these directories
|
|
32
|
+
by totopo on every session start and overwritten automatically — do not edit them.
|
|
33
|
+
|
|
34
|
+
`agents/` is gitignored — session data stays local to this machine.
|
|
35
|
+
|
|
36
|
+
To reset agent memory: **Advanced → Clear agent memory** from the totopo menu.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Security model
|
|
41
|
+
|
|
42
|
+
- **Non-root user** (`devuser`, uid 1001) — cannot modify system-level config
|
|
43
|
+
- **Git remote access blocked** via `protocol.allow = never` in `/etc/gitconfig` — push, pull, fetch, and clone are all refused; local operations work normally
|
|
44
|
+
- **No host credentials forwarded** — host git credentials are never copied into the container
|
|
45
|
+
- **API keys passed at runtime** via `--env-file ~/.totopo/.env` — never baked into the image and never mounted into the container
|
|
46
|
+
- **No privilege escalation** — `no-new-privileges:true` prevents any process from gaining elevated permissions
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## AI tools
|
|
51
|
+
|
|
52
|
+
| Command | Package |
|
|
53
|
+
| ---------- | --------------------------- |
|
|
54
|
+
| `opencode` | `opencode-ai` |
|
|
55
|
+
| `claude` | `@anthropic-ai/claude-code` |
|
|
56
|
+
| `codex` | `@openai/codex` |
|
|
57
|
+
|
|
58
|
+
Tools are installed during image build. To update a tool version: edit `Dockerfile`,
|
|
59
|
+
then use **Advanced → Rebuild container** from the totopo menu.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Startup check
|
|
64
|
+
|
|
65
|
+
`post-start.mjs` runs on every container start and validates:
|
|
66
|
+
|
|
67
|
+
1. Running as non-root
|
|
68
|
+
2. Git remote block active in `/etc/gitconfig`
|
|
69
|
+
3. `git push` functionally blocked
|
|
70
|
+
4. All AI tools installed and reachable
|
|
71
|
+
|
|
72
|
+
Re-run manually anytime from inside the container:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
status
|
|
76
|
+
```
|
package/templates/env
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
# =============================================================================
|
|
2
2
|
# totopo — API Keys
|
|
3
3
|
# =============================================================================
|
|
4
|
-
#
|
|
5
|
-
#
|
|
4
|
+
#
|
|
5
|
+
# WARNING: This file is loaded into every totopo container at runtime via
|
|
6
|
+
# --env-file. It lives at ~/.totopo/.env on your host machine and is never
|
|
7
|
+
# mounted into the container filesystem — agents cannot read it directly.
|
|
8
|
+
#
|
|
9
|
+
# However, treat this file as sensitive. Do not commit it, do not share it,
|
|
10
|
+
# and do not paste it into agent sessions. If you believe a key has been
|
|
11
|
+
# exposed, rotate it immediately from your provider's dashboard.
|
|
6
12
|
# =============================================================================
|
|
7
13
|
|
|
8
|
-
#
|
|
9
|
-
|
|
14
|
+
# OpenAI — GPT models, used by OpenCode and Codex
|
|
15
|
+
OPENAI_API_KEY=
|
|
10
16
|
|
|
11
|
-
#
|
|
12
|
-
|
|
17
|
+
# Anthropic — Claude models, used by Claude Code and OpenCode
|
|
18
|
+
ANTHROPIC_API_KEY=
|
|
13
19
|
|
|
14
|
-
#
|
|
15
|
-
OPENAI_API_KEY=
|
|
20
|
+
# Google — Gemini models
|
|
16
21
|
GEMINI_API_KEY=
|
|
17
22
|
|
|
18
|
-
#
|
|
23
|
+
# xAI — Grok models
|
|
24
|
+
XAI_API_KEY=
|
|
25
|
+
|
|
26
|
+
# Groq — fast inference for open models (Llama, Mixtral, etc.)
|
|
27
|
+
GROQ_API_KEY=
|
|
28
|
+
|
|
29
|
+
# OpenRouter — unified gateway to 200+ models from multiple providers
|
|
19
30
|
OPENROUTER_API_KEY=
|
package/templates/post-start.mjs
CHANGED
|
@@ -77,9 +77,9 @@ const checkTool = (cmd) => {
|
|
|
77
77
|
}
|
|
78
78
|
};
|
|
79
79
|
|
|
80
|
-
checkTool("claude");
|
|
81
|
-
checkTool("kilo");
|
|
82
80
|
checkTool("opencode");
|
|
81
|
+
checkTool("claude");
|
|
82
|
+
checkTool("codex");
|
|
83
83
|
|
|
84
84
|
// ─── Runtimes ────────────────────────────────────────────────────────────────
|
|
85
85
|
section("Runtimes");
|