totopo 3.1.0 → 3.2.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 +6 -5
- package/bin/totopo.js +6 -3
- package/dist/commands/dev.js +43 -39
- package/dist/commands/menu.js +2 -2
- package/dist/lib/constants.js +1 -1
- package/dist/lib/dockerfile-builder.js +2 -2
- package/dist/lib/migrate-to-latest.js +42 -2
- package/dist/lib/workspace-identity.js +3 -17
- package/package.json +1 -1
- package/templates/Dockerfile +4 -3
- package/templates/{post-start.mjs → startup.mjs} +58 -30
package/README.md
CHANGED
|
@@ -12,9 +12,10 @@ Local sandbox for AI agents.
|
|
|
12
12
|
|
|
13
13
|
## Motivation
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
-
|
|
15
|
+
**Can you trust an AI agent?** Two issues make that hard:
|
|
16
|
+
|
|
17
|
+
- **Inherently unpredictable**: they will make mistakes, often without you knowing, and not always possible to undo.
|
|
18
|
+
- **Vulnerable to prompt injection**: a subtle attack that can silently turn your agent against you.
|
|
18
19
|
|
|
19
20
|
totopo addresses both with a dev container - when you run totopo in a given directory, the directory is mounted as a workspace where agents get a full, capable environment to work in — they just can't touch anything outside the workspace, and they can't reach remote git repositories.
|
|
20
21
|
|
|
@@ -48,7 +49,7 @@ Once set up, the flow is simple:
|
|
|
48
49
|
|
|
49
50
|
A few things happen automatically:
|
|
50
51
|
|
|
51
|
-
- **Agents stay up to date** —
|
|
52
|
+
- **Agents stay up to date** — totopo keeps all AI CLIs on their latest versions, checking for updates automatically.
|
|
52
53
|
- **Sessions are persistent** — agent memory and settings survive container restarts and rebuilds.
|
|
53
54
|
- **Your machine stays safe** — the container can't push to remote, can't read outside the workspace, and sensitive paths like `.env` can be hidden from agents entirely (see [Shadow Paths](#shadow-paths)).
|
|
54
55
|
|
|
@@ -168,7 +169,7 @@ codex # Codex (OpenAI)
|
|
|
168
169
|
|
|
169
170
|
Agents are self-aware — sandbox constraints, git remote block, and any active shadow path overlays are injected into agent context at every session start.
|
|
170
171
|
|
|
171
|
-
totopo
|
|
172
|
+
totopo keeps all three CLIs on their latest published versions, checking for updates automatically.
|
|
172
173
|
|
|
173
174
|
### Persistent Agent Memory
|
|
174
175
|
|
package/bin/totopo.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// =========================================================================================================================================
|
|
6
6
|
|
|
7
7
|
import { execSync, spawnSync } from "node:child_process";
|
|
8
|
-
import { existsSync } from "node:fs";
|
|
9
|
-
import { dirname } from "node:path";
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import { cancel, confirm, isCancel, log, select } from "@clack/prompts";
|
|
12
12
|
import { run as dev } from "../dist/commands/dev.js";
|
|
@@ -37,6 +37,9 @@ try {
|
|
|
37
37
|
const packageDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
38
38
|
const cwd = process.cwd();
|
|
39
39
|
|
|
40
|
+
// --- Version -----------------------------------------------------------------------------------------------------------------------------
|
|
41
|
+
const { version } = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
|
|
42
|
+
|
|
40
43
|
// --- Guard: dist/ must exist -------------------------------------------------------------------------------------------------------------
|
|
41
44
|
if (!existsSync(new URL("../dist/commands/dev.js", import.meta.url))) {
|
|
42
45
|
console.error("");
|
|
@@ -149,7 +152,7 @@ while (showMenu) {
|
|
|
149
152
|
const activeCount = activeNames.length;
|
|
150
153
|
const workspaceRunning = activeNames.some((n) => n === containerName);
|
|
151
154
|
|
|
152
|
-
const action = await menu({ ctx: workspace, activeCount, workspaceRunning });
|
|
155
|
+
const action = await menu({ ctx: workspace, activeCount, workspaceRunning, version });
|
|
153
156
|
|
|
154
157
|
switch (action) {
|
|
155
158
|
case "dev":
|
package/dist/commands/dev.js
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
7
7
|
import { join, relative } from "node:path";
|
|
8
|
-
import { cancel, isCancel, log, outro, select } from "@clack/prompts";
|
|
8
|
+
import { cancel, confirm, isCancel, log, outro, select } from "@clack/prompts";
|
|
9
9
|
import { buildAgentContextDocs, buildAgentMountArgs, injectAgentContext } from "../lib/agent-context.js";
|
|
10
|
-
import {
|
|
10
|
+
import { CONTAINER_STARTUP, CONTAINER_WORKSPACE, LABEL_MANAGED, LABEL_PROFILE, LABEL_SHADOWS, PROFILE } from "../lib/constants.js";
|
|
11
11
|
import { buildDockerfile, buildImageWithTempfile } from "../lib/dockerfile-builder.js";
|
|
12
|
+
import { isImageStale } from "../lib/migrate-to-latest.js";
|
|
12
13
|
import { buildShadowMountArgs, ensureShadowsInSync, expandShadowPatterns } from "../lib/shadows.js";
|
|
13
14
|
import { readTotopoYaml } from "../lib/totopo-yaml.js";
|
|
14
|
-
import { readActiveProfile,
|
|
15
|
-
const CLI_UPDATE_THROTTLE_MS = 24 * 60 * 60 * 1000;
|
|
15
|
+
import { readActiveProfile, writeActiveProfile } from "../lib/workspace-identity.js";
|
|
16
16
|
// --- Prompt: working directory selection -------------------------------------------------------------------------------------------------
|
|
17
17
|
async function promptWorkdir(workspaceDir, cwd) {
|
|
18
18
|
if (cwd === workspaceDir)
|
|
@@ -82,32 +82,12 @@ function stopAndRemoveContainer(containerName) {
|
|
|
82
82
|
spawnSync("docker", ["stop", containerName], { stdio: "pipe" });
|
|
83
83
|
spawnSync("docker", ["rm", containerName], { stdio: "pipe" });
|
|
84
84
|
}
|
|
85
|
-
// ---
|
|
86
|
-
function
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
"exec",
|
|
90
|
-
"-u",
|
|
91
|
-
"root",
|
|
92
|
-
containerName,
|
|
93
|
-
"npm",
|
|
94
|
-
"install",
|
|
95
|
-
"-g",
|
|
96
|
-
"opencode-ai@latest",
|
|
97
|
-
"@anthropic-ai/claude-code@latest",
|
|
98
|
-
"@openai/codex@latest",
|
|
99
|
-
], { stdio: "inherit" });
|
|
100
|
-
}
|
|
101
|
-
// --- Run post-start ----------------------------------------------------------------------------------------------------------------------
|
|
102
|
-
function runPostStart(containerName) {
|
|
103
|
-
log.step("Running post-start checks...");
|
|
104
|
-
const postStart = spawnSync("docker", ["exec", containerName, "node", CONTAINER_POST_START], {
|
|
105
|
-
stdio: "inherit",
|
|
85
|
+
// --- Run startup checks (AI CLI update + readiness validation) ---------------------------------------------------------------------------
|
|
86
|
+
function runStartup(containerName, quiet) {
|
|
87
|
+
const result = spawnSync("docker", ["exec", "-u", "root", containerName, "node", CONTAINER_STARTUP], {
|
|
88
|
+
stdio: quiet ? "pipe" : "inherit",
|
|
106
89
|
});
|
|
107
|
-
|
|
108
|
-
outro("Post-start checks failed.");
|
|
109
|
-
process.exit(postStart.status ?? 1);
|
|
110
|
-
}
|
|
90
|
+
return result.status === 0;
|
|
111
91
|
}
|
|
112
92
|
export function startContainer(opts) {
|
|
113
93
|
const { containerName, workspaceRoot, cacheDir, templatesDir, activeProfile, profileHook, expandedShadows, envFilePath, hasGit, shadowPatterns, workspaceName, noCache, quiet = false, } = opts;
|
|
@@ -260,7 +240,7 @@ export async function run(packageDir, ctx, options) {
|
|
|
260
240
|
}
|
|
261
241
|
const hasGit = existsSync(join(workspaceDir, ".git"));
|
|
262
242
|
// --- Start container -----------------------------------------------------------------------------------------------------------------
|
|
263
|
-
const
|
|
243
|
+
const containerOpts = {
|
|
264
244
|
containerName,
|
|
265
245
|
workspaceRoot: workspaceDir,
|
|
266
246
|
cacheDir,
|
|
@@ -273,18 +253,42 @@ export async function run(packageDir, ctx, options) {
|
|
|
273
253
|
shadowPatterns,
|
|
274
254
|
workspaceName: ctx.displayName,
|
|
275
255
|
...(options?.noCache !== undefined && { noCache: options.noCache }),
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
256
|
+
};
|
|
257
|
+
startContainer(containerOpts);
|
|
258
|
+
// --- Stale image check - prompt user to rebuild if image is outdated ------------------------------------------------------------------
|
|
259
|
+
let stale = isImageStale(containerName);
|
|
260
|
+
if (stale) {
|
|
261
|
+
const rebuild = await confirm({
|
|
262
|
+
message: "totopo's latest release includes an updated container image. Rebuild now? (Recommended) The process is quick and will not affect agent memory, settings, or your data.",
|
|
263
|
+
initialValue: true,
|
|
264
|
+
});
|
|
265
|
+
if (isCancel(rebuild)) {
|
|
266
|
+
cancel("Session cancelled.");
|
|
267
|
+
process.exit(0);
|
|
268
|
+
}
|
|
269
|
+
if (rebuild) {
|
|
270
|
+
stopAndRemoveContainer(containerName);
|
|
271
|
+
spawnSync("docker", ["rmi", containerName], { stdio: "pipe" });
|
|
272
|
+
startContainer(containerOpts);
|
|
273
|
+
stale = false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// --- Startup checks (AI CLI update + readiness validation) ----------------------------------------------------------------------------
|
|
277
|
+
if (!runStartup(containerName, stale)) {
|
|
278
|
+
if (stale) {
|
|
279
|
+
const connect = await confirm({
|
|
280
|
+
message: "Startup checks failed (likely due to outdated image). Connect anyway?",
|
|
281
|
+
initialValue: true,
|
|
282
|
+
});
|
|
283
|
+
if (!connect || isCancel(connect)) {
|
|
284
|
+
cancel("Session cancelled.");
|
|
285
|
+
process.exit(0);
|
|
286
|
+
}
|
|
283
287
|
}
|
|
284
288
|
else {
|
|
285
|
-
|
|
289
|
+
outro("Startup checks failed.");
|
|
290
|
+
process.exit(1);
|
|
286
291
|
}
|
|
287
|
-
runPostStart(containerName);
|
|
288
292
|
}
|
|
289
293
|
// --- Connect -------------------------------------------------------------------------------------------------------------------------
|
|
290
294
|
const exec = spawnSync("docker", ["exec", "-it", "-w", workdir, containerName, "bash", "--login"], {
|
package/dist/commands/menu.js
CHANGED
|
@@ -9,14 +9,14 @@ import { box, cancel, isCancel, select } from "@clack/prompts";
|
|
|
9
9
|
import { PROFILE } from "../lib/constants.js";
|
|
10
10
|
import { readActiveProfile } from "../lib/workspace-identity.js";
|
|
11
11
|
export async function run(args) {
|
|
12
|
-
const { ctx, workspaceRunning } = args;
|
|
12
|
+
const { ctx, workspaceRunning, version } = args;
|
|
13
13
|
// --- Read workspace config -----------------------------------------------------------------------------------------------------------
|
|
14
14
|
const activeProfile = readActiveProfile(ctx.workspaceId) ?? PROFILE.default;
|
|
15
15
|
const hasGit = existsSync(join(ctx.workspaceRoot, ".git"));
|
|
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}`,
|
|
19
|
+
box(`workspace: ${ctx.displayName}\nprofile: ${activeProfile}\ncontainer: ${containerStatus}${gitNotice}`, ` totopo v${version} `, {
|
|
20
20
|
contentAlign: "left",
|
|
21
21
|
titleAlign: "center",
|
|
22
22
|
width: "auto",
|
package/dist/lib/constants.js
CHANGED
|
@@ -21,7 +21,7 @@ export const DEFAULT_SHADOW_PATHS = ["node_modules", ".env*"];
|
|
|
21
21
|
export const CONTAINER_USER = "devuser";
|
|
22
22
|
export const CONTAINER_HOME = `/home/${CONTAINER_USER}`;
|
|
23
23
|
export const CONTAINER_WORKSPACE = "/workspace";
|
|
24
|
-
export const
|
|
24
|
+
export const CONTAINER_STARTUP = `${CONTAINER_HOME}/startup.mjs`;
|
|
25
25
|
// Docker container/image naming
|
|
26
26
|
export const CONTAINER_NAME_PREFIX = "totopo-";
|
|
27
27
|
// Docker label keys
|
|
@@ -7,7 +7,7 @@ import { randomBytes } from "node:crypto";
|
|
|
7
7
|
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
-
import { CONTAINER_HOME, CONTAINER_NAME_PREFIX,
|
|
10
|
+
import { CONTAINER_HOME, CONTAINER_NAME_PREFIX, CONTAINER_STARTUP, CONTAINER_USER, LABEL_MANAGED } from "./constants.js";
|
|
11
11
|
// --- User shell config appended after USER instruction -----------------------------------------------------------------------------------
|
|
12
12
|
const USER_SHELL_CONFIG = `
|
|
13
13
|
# ---------------------------------------------------------------------------
|
|
@@ -19,7 +19,7 @@ RUN echo 'export PS1="\\[\\033[01;32m\\][devcontainer]\\[\\033[00m\\] \\[\\033[0
|
|
|
19
19
|
echo 'echo ""' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
20
20
|
echo "echo \\" Run 'opencode', 'claude', or 'codex' to start an agent.\\"" >> ${CONTAINER_HOME}/.bashrc && \\
|
|
21
21
|
echo 'echo ""' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
22
|
-
echo 'alias status="node ${
|
|
22
|
+
echo 'alias status="node ${CONTAINER_STARTUP}"' >> ${CONTAINER_HOME}/.bashrc
|
|
23
23
|
|
|
24
24
|
CMD ["/bin/bash"]
|
|
25
25
|
`;
|
|
@@ -24,7 +24,7 @@ import { homedir } from "node:os";
|
|
|
24
24
|
import { join } from "node:path";
|
|
25
25
|
import { log } from "@clack/prompts";
|
|
26
26
|
import { load as loadYaml } from "js-yaml";
|
|
27
|
-
import { AGENTS_DIR, LOCK_FILE, PROFILE, SHADOWS_DIR, TOTOPO_DIR, TOTOPO_YAML, WORKSPACES_DIR } from "./constants.js";
|
|
27
|
+
import { AGENTS_DIR, CONTAINER_STARTUP, LOCK_FILE, PROFILE, SHADOWS_DIR, TOTOPO_DIR, TOTOPO_YAML, WORKSPACES_DIR } from "./constants.js";
|
|
28
28
|
import { safeRmSync } from "./safe-rm.js";
|
|
29
29
|
import { buildDefaultTotopoYaml, readTotopoYaml, slugifyForWorkspaceId, validateWorkspaceId, writeTotopoYaml, } from "./totopo-yaml.js";
|
|
30
30
|
import { findTotopoYamlDir, getWorkspacesBaseDir, initWorkspaceDir, LOCK_KEYS } from "./workspace-identity.js";
|
|
@@ -296,7 +296,7 @@ function migrateLockFileFormat() {
|
|
|
296
296
|
if (!firstLine || firstLine.includes("="))
|
|
297
297
|
continue; // empty or already new format
|
|
298
298
|
const activeProfile = secondLine ?? PROFILE.default;
|
|
299
|
-
writeFileSync(lockPath, `${LOCK_KEYS.workspaceRoot}=${firstLine}\n${LOCK_KEYS.activeProfile}=${activeProfile}\n
|
|
299
|
+
writeFileSync(lockPath, `${LOCK_KEYS.workspaceRoot}=${firstLine}\n${LOCK_KEYS.activeProfile}=${activeProfile}\n`);
|
|
300
300
|
}
|
|
301
301
|
catch {
|
|
302
302
|
// unreadable -- skip, will surface as a broken workspace elsewhere
|
|
@@ -325,6 +325,32 @@ function migrateLockKeyYamlToRoot() {
|
|
|
325
325
|
}
|
|
326
326
|
}
|
|
327
327
|
}
|
|
328
|
+
/**
|
|
329
|
+
* v3.1.0 and earlier: Remove the "last-cli-update" key from .lock files.
|
|
330
|
+
* CLI update timestamps are now managed inside the container via /home/devuser/.ai-cli-updated.
|
|
331
|
+
* Detects old format by presence of "last-cli-update=" in the file content. Idempotent.
|
|
332
|
+
*/
|
|
333
|
+
function migrateRemoveLastCliUpdate() {
|
|
334
|
+
const baseDir = getWorkspacesBaseDir();
|
|
335
|
+
if (!existsSync(baseDir))
|
|
336
|
+
return;
|
|
337
|
+
for (const entry of readdirSync(baseDir)) {
|
|
338
|
+
const lockPath = join(baseDir, entry, LOCK_FILE);
|
|
339
|
+
try {
|
|
340
|
+
const content = readFileSync(lockPath, "utf8");
|
|
341
|
+
if (!content.includes("last-cli-update="))
|
|
342
|
+
continue;
|
|
343
|
+
const filtered = content
|
|
344
|
+
.split("\n")
|
|
345
|
+
.filter((line) => !line.startsWith("last-cli-update="))
|
|
346
|
+
.join("\n");
|
|
347
|
+
writeFileSync(lockPath, filtered);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
// unreadable -- skip, will surface as a broken workspace elsewhere
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
328
354
|
// Order matters: migrateProjectsDir must run before migrateV2Workspaces because
|
|
329
355
|
// step 2 scans ~/.totopo/workspaces/ which only exists after step 1 renames projects/.
|
|
330
356
|
// Steps 3 and 4 are independent of each other and of steps 1-2.
|
|
@@ -338,6 +364,7 @@ const MIGRATIONS = [
|
|
|
338
364
|
{ from: "v2.x", description: "Remove legacy ~/.totopo/.env global key file", run: migrateGlobalEnv },
|
|
339
365
|
{ from: "v3-rc-6", description: "Upgrade .lock files from positional to key=value format", run: migrateLockFileFormat },
|
|
340
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 },
|
|
341
368
|
];
|
|
342
369
|
/** Run all migrations in order. Called early in bin/totopo.js startup. */
|
|
343
370
|
export function runMigration(cwd) {
|
|
@@ -345,3 +372,16 @@ export function runMigration(cwd) {
|
|
|
345
372
|
migration.run(cwd);
|
|
346
373
|
}
|
|
347
374
|
}
|
|
375
|
+
// =========================================================================================================================================
|
|
376
|
+
// Image staleness detection
|
|
377
|
+
// Called at session start to detect outdated container images that need rebuilding.
|
|
378
|
+
// Add new conditions here when the base image changes in future releases.
|
|
379
|
+
// =========================================================================================================================================
|
|
380
|
+
/** Check if a running container's image is stale (missing expected files/features). */
|
|
381
|
+
export function isImageStale(containerName) {
|
|
382
|
+
// 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)
|
|
385
|
+
return true;
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
@@ -11,7 +11,6 @@ import { readTotopoYaml } from "./totopo-yaml.js";
|
|
|
11
11
|
export const LOCK_KEYS = {
|
|
12
12
|
workspaceRoot: "root",
|
|
13
13
|
activeProfile: "profile",
|
|
14
|
-
lastCliUpdate: "last-cli-update",
|
|
15
14
|
};
|
|
16
15
|
/** Reverse lookup: file key → LockFile field name, used during parsing. */
|
|
17
16
|
const FILE_KEY_TO_FIELD = Object.fromEntries(Object.entries(LOCK_KEYS).map(([field, key]) => [key, field]));
|
|
@@ -54,7 +53,6 @@ function parseLockFile(workspaceId) {
|
|
|
54
53
|
return {
|
|
55
54
|
workspaceRoot: partial.workspaceRoot,
|
|
56
55
|
activeProfile: partial.activeProfile ?? PROFILE.default,
|
|
57
|
-
lastCliUpdate: partial.lastCliUpdate ?? "",
|
|
58
56
|
};
|
|
59
57
|
}
|
|
60
58
|
catch {
|
|
@@ -72,44 +70,32 @@ function writeLockFileInternal(workspaceId, data) {
|
|
|
72
70
|
export function readLockFile(workspaceId) {
|
|
73
71
|
return parseLockFile(workspaceId)?.workspaceRoot ?? null;
|
|
74
72
|
}
|
|
75
|
-
/** Write a workspace's lock file with the owning workspace root path. Preserves active profile
|
|
73
|
+
/** Write a workspace's lock file with the owning workspace root path. Preserves active profile. */
|
|
76
74
|
export function writeLockFile(workspaceId, workspaceRoot) {
|
|
77
75
|
const existing = parseLockFile(workspaceId);
|
|
78
76
|
writeLockFileInternal(workspaceId, {
|
|
79
77
|
workspaceRoot,
|
|
80
78
|
activeProfile: existing?.activeProfile ?? PROFILE.default,
|
|
81
|
-
lastCliUpdate: existing?.lastCliUpdate ?? "",
|
|
82
79
|
});
|
|
83
80
|
}
|
|
84
81
|
/** Read the active profile name. Returns null if lock file is missing. */
|
|
85
82
|
export function readActiveProfile(workspaceId) {
|
|
86
83
|
return parseLockFile(workspaceId)?.activeProfile ?? null;
|
|
87
84
|
}
|
|
88
|
-
/** Write the active profile name. Preserves workspace root path
|
|
85
|
+
/** Write the active profile name. Preserves workspace root path. */
|
|
89
86
|
export function writeActiveProfile(workspaceId, profile) {
|
|
90
87
|
const existing = parseLockFile(workspaceId);
|
|
91
88
|
if (!existing)
|
|
92
89
|
return;
|
|
93
90
|
writeLockFileInternal(workspaceId, { ...existing, activeProfile: profile });
|
|
94
91
|
}
|
|
95
|
-
/** Read the last CLI update timestamp. Returns empty string if lock file is missing or field was never set. */
|
|
96
|
-
export function readLastCliUpdate(workspaceId) {
|
|
97
|
-
return parseLockFile(workspaceId)?.lastCliUpdate ?? "";
|
|
98
|
-
}
|
|
99
|
-
/** Write the last CLI update timestamp. Preserves all other fields. No-op if lock file is missing. */
|
|
100
|
-
export function writeLastCliUpdate(workspaceId, timestamp) {
|
|
101
|
-
const existing = parseLockFile(workspaceId);
|
|
102
|
-
if (!existing)
|
|
103
|
-
return;
|
|
104
|
-
writeLockFileInternal(workspaceId, { ...existing, lastCliUpdate: timestamp });
|
|
105
|
-
}
|
|
106
92
|
// --- Workspace directory initialization --------------------------------------------------------------------------------------------------
|
|
107
93
|
/** Initialize ~/.totopo/workspaces/<workspace_id>/ with lock file and subdirs. */
|
|
108
94
|
export function initWorkspaceDir(workspaceId, workspaceRoot, activeProfile = PROFILE.default) {
|
|
109
95
|
const dir = getWorkspaceDir(workspaceId);
|
|
110
96
|
mkdirSync(join(dir, AGENTS_DIR), { recursive: true });
|
|
111
97
|
mkdirSync(join(dir, SHADOWS_DIR), { recursive: true });
|
|
112
|
-
writeLockFileInternal(workspaceId, { workspaceRoot, activeProfile
|
|
98
|
+
writeLockFileInternal(workspaceId, { workspaceRoot, activeProfile });
|
|
113
99
|
}
|
|
114
100
|
// --- Listing -----------------------------------------------------------------------------------------------------------------------------
|
|
115
101
|
/** List all registered workspace IDs (directories with a .lock file) */
|
package/package.json
CHANGED
package/templates/Dockerfile
CHANGED
|
@@ -86,13 +86,14 @@ RUN npm install -g \
|
|
|
86
86
|
RUN groupadd --gid 1001 devuser && \
|
|
87
87
|
useradd --uid 1001 --gid devuser --shell /bin/bash --create-home devuser && \
|
|
88
88
|
mkdir -p /home/devuser/.local/state /home/devuser/.local/share /home/devuser/.local/bin && \
|
|
89
|
+
date -u +"%Y-%m-%dT%H:%M:%S.000Z" > /home/devuser/.ai-cli-updated && \
|
|
89
90
|
chown -R devuser:devuser /home/devuser
|
|
90
91
|
|
|
91
92
|
# ---------------------------------------------------------------------------
|
|
92
|
-
# Layer 9 — Bake
|
|
93
|
+
# Layer 9 — Bake startup script into image
|
|
93
94
|
# ---------------------------------------------------------------------------
|
|
94
|
-
COPY
|
|
95
|
-
RUN chown devuser:devuser /home/devuser/
|
|
95
|
+
COPY startup.mjs /home/devuser/startup.mjs
|
|
96
|
+
RUN chown devuser:devuser /home/devuser/startup.mjs
|
|
96
97
|
|
|
97
98
|
WORKDIR /workspace
|
|
98
99
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// =============================================================================
|
|
3
|
-
//
|
|
4
|
-
// Baked into the container image at /home/devuser/
|
|
5
|
-
// Must
|
|
3
|
+
// startup.mjs -- Container startup: AI CLI updates + readiness checks
|
|
4
|
+
// Baked into the container image at /home/devuser/startup.mjs
|
|
5
|
+
// Must run as root (npm global install requires root).
|
|
6
|
+
// Must use only Node.js built-ins -- no external packages available in container.
|
|
6
7
|
// =============================================================================
|
|
7
8
|
|
|
8
9
|
import { execSync } from "node:child_process";
|
|
10
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
9
11
|
|
|
10
12
|
const run = (cmd) => {
|
|
11
13
|
try {
|
|
@@ -18,18 +20,17 @@ const run = (cmd) => {
|
|
|
18
20
|
}
|
|
19
21
|
};
|
|
20
22
|
|
|
21
|
-
//
|
|
23
|
+
// -- ANSI helpers -------------------------------------------------------------
|
|
22
24
|
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
23
25
|
const _yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
24
26
|
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
25
27
|
const blue = (s) => `\x1b[34m${s}\x1b[0m`;
|
|
26
28
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
27
29
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
30
|
+
const grey = (s) => `\x1b[90m${s}\x1b[0m`;
|
|
28
31
|
|
|
29
32
|
let errors = 0;
|
|
30
33
|
|
|
31
|
-
const grey = (s) => `\x1b[90m${s}\x1b[0m`;
|
|
32
|
-
|
|
33
34
|
const ok = (label, detail) => console.log(`${green("✓")} ${label.padEnd(24)}${detail ? dim(detail) : ""}`);
|
|
34
35
|
const skip = (label, detail) => console.log(`${grey("–")} ${grey(label.padEnd(24))}${detail ? grey(detail) : ""}`);
|
|
35
36
|
const fail = (label, detail) => {
|
|
@@ -38,53 +39,80 @@ const fail = (label, detail) => {
|
|
|
38
39
|
};
|
|
39
40
|
const section = (title) => console.log(`\n${bold(title)}`);
|
|
40
41
|
|
|
41
|
-
//
|
|
42
|
-
console.log(`\n${bold("totopo
|
|
42
|
+
// -- Header -------------------------------------------------------------------
|
|
43
|
+
console.log(`\n${bold("totopo - Sandbox for AI Agents")}\n`);
|
|
44
|
+
|
|
45
|
+
// -- AI CLI update (requires root - skipped when run via 'status' alias as devuser) -
|
|
46
|
+
section("AI CLI update");
|
|
47
|
+
|
|
48
|
+
const isRoot = process.getuid?.() === 0;
|
|
49
|
+
const TIMESTAMP_FILE = "/home/devuser/.ai-cli-updated";
|
|
50
|
+
const THROTTLE_MS = 24 * 60 * 60 * 1000;
|
|
51
|
+
|
|
52
|
+
let lastUpdate = 0;
|
|
53
|
+
try {
|
|
54
|
+
const raw = readFileSync(TIMESTAMP_FILE, "utf8").trim();
|
|
55
|
+
lastUpdate = new Date(raw).getTime();
|
|
56
|
+
} catch {
|
|
57
|
+
// File missing or unreadable -- treat as never updated
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (Number.isFinite(lastUpdate) && Date.now() - lastUpdate < THROTTLE_MS) {
|
|
61
|
+
ok("AI CLIs", "up to date");
|
|
62
|
+
} else if (!isRoot) {
|
|
63
|
+
skip("AI CLIs", "update skipped (requires root)");
|
|
64
|
+
} else {
|
|
65
|
+
console.log(`${blue("●")} ${dim("Updating AI CLIs to latest...")}`);
|
|
66
|
+
try {
|
|
67
|
+
execSync("npm install -g opencode-ai@latest @anthropic-ai/claude-code@latest @openai/codex@latest", {
|
|
68
|
+
stdio: "inherit",
|
|
69
|
+
});
|
|
70
|
+
writeFileSync(TIMESTAMP_FILE, `${new Date().toISOString()}\n`);
|
|
71
|
+
ok("AI CLIs", "updated");
|
|
72
|
+
} catch {
|
|
73
|
+
fail("AI CLIs", "update failed -- continuing with existing versions");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
43
76
|
|
|
44
|
-
//
|
|
77
|
+
// -- Security -----------------------------------------------------------------
|
|
45
78
|
section("Security");
|
|
46
79
|
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
ok("non-root user",
|
|
80
|
+
const idOutput = run("id devuser");
|
|
81
|
+
if (idOutput?.includes("uid=1001")) {
|
|
82
|
+
ok("non-root user", "devuser (uid=1001)");
|
|
50
83
|
} else {
|
|
51
|
-
fail("non-root user", "
|
|
84
|
+
fail("non-root user", "devuser not found or wrong uid -- container is misconfigured");
|
|
52
85
|
}
|
|
53
86
|
|
|
54
87
|
const gitProtocol = run("git config --system protocol.allow");
|
|
55
88
|
if (gitProtocol === "never") {
|
|
56
89
|
ok("git remote block", "protocol.allow = never");
|
|
57
90
|
} else {
|
|
58
|
-
fail("git remote block", "not set
|
|
91
|
+
fail("git remote block", "not set -- rebuild the container");
|
|
59
92
|
}
|
|
60
93
|
|
|
61
94
|
try {
|
|
62
95
|
execSync("/usr/bin/git -C /workspace push", { stdio: "pipe" });
|
|
63
|
-
fail("push blocked", "git push succeeded
|
|
96
|
+
fail("push blocked", "git push succeeded -- remote access is NOT blocked");
|
|
64
97
|
} catch {
|
|
65
98
|
ok("push blocked", "remote push not possible");
|
|
66
99
|
}
|
|
67
100
|
|
|
68
|
-
//
|
|
101
|
+
// -- AI tools -----------------------------------------------------------------
|
|
69
102
|
section("AI tools");
|
|
70
103
|
|
|
71
|
-
const aiToolResults = [];
|
|
72
|
-
|
|
73
104
|
const checkTool = (cmd) => {
|
|
74
105
|
const out = run(`${cmd} --version`);
|
|
75
106
|
if (out !== null && out.trim() !== "") {
|
|
76
107
|
const version = out.split("\n")[0];
|
|
77
108
|
ok(cmd, version);
|
|
78
|
-
aiToolResults.push({ cmd, version, found: true });
|
|
79
109
|
return;
|
|
80
110
|
}
|
|
81
111
|
const which = run(`which ${cmd}`);
|
|
82
112
|
if (which !== null) {
|
|
83
113
|
ok(cmd, "installed");
|
|
84
|
-
aiToolResults.push({ cmd, version: "installed", found: true });
|
|
85
114
|
} else {
|
|
86
|
-
fail(cmd, "not found
|
|
87
|
-
aiToolResults.push({ cmd, version: null, found: false });
|
|
115
|
+
fail(cmd, "not found -- rebuild container");
|
|
88
116
|
}
|
|
89
117
|
};
|
|
90
118
|
|
|
@@ -92,7 +120,7 @@ checkTool("opencode");
|
|
|
92
120
|
checkTool("claude");
|
|
93
121
|
checkTool("codex");
|
|
94
122
|
|
|
95
|
-
//
|
|
123
|
+
// -- Runtimes -----------------------------------------------------------------
|
|
96
124
|
section("Runtimes");
|
|
97
125
|
|
|
98
126
|
const checkRuntime = (label, version) => {
|
|
@@ -115,7 +143,7 @@ checkRuntime("go", run("go version"));
|
|
|
115
143
|
checkRuntime("cargo", run("cargo --version"));
|
|
116
144
|
checkRuntime("java", run("java --version")?.split("\n")[0] ?? null);
|
|
117
145
|
|
|
118
|
-
//
|
|
146
|
+
// -- Dev tools ----------------------------------------------------------------
|
|
119
147
|
section("Dev tools");
|
|
120
148
|
|
|
121
149
|
ok("gh", run("gh --version")?.split("\n")[0] ?? "not found");
|
|
@@ -125,7 +153,7 @@ ok("fzf", run("fzf --version") ?? "not found");
|
|
|
125
153
|
ok("jq", run("jq --version") ?? "not found");
|
|
126
154
|
ok("yq", run("yq --version") ?? "not found");
|
|
127
155
|
|
|
128
|
-
//
|
|
156
|
+
// -- Database tools -----------------------------------------------------------
|
|
129
157
|
section("Database tools");
|
|
130
158
|
|
|
131
159
|
ok("sqlite3", run("sqlite3 --version")?.split(" ").slice(0, 2).join(" ") ?? "not found");
|
|
@@ -133,21 +161,21 @@ ok("psql", run("psql --version") ?? "not found");
|
|
|
133
161
|
ok("mysql", run("mysql --version") ?? "not found");
|
|
134
162
|
ok("redis-cli", run("redis-cli --version") ?? "not found");
|
|
135
163
|
|
|
136
|
-
//
|
|
164
|
+
// -- API keys -----------------------------------------------------------------
|
|
137
165
|
section("API keys");
|
|
138
166
|
|
|
139
167
|
console.log(`${blue("●")} ${dim("API keys are injected via env_file in totopo.yaml. Set env_file to point to your .env file.")}`);
|
|
140
168
|
|
|
141
|
-
//
|
|
169
|
+
// -- Summary ------------------------------------------------------------------
|
|
142
170
|
if (errors === 0) {
|
|
143
|
-
const workspaceSuffix = process.env.TOTOPO_WORKSPACE ? `
|
|
171
|
+
const workspaceSuffix = process.env.TOTOPO_WORKSPACE ? ` - workspace: ${bold(process.env.TOTOPO_WORKSPACE)}` : "";
|
|
144
172
|
console.log(`\n${blue("●")} ${bold("totopo dev container ready")}${workspaceSuffix}`);
|
|
145
173
|
console.log(
|
|
146
|
-
`${grey(" To adjust settings, ask any agent about")} ${bold("totopo.yaml")} ${grey("
|
|
174
|
+
`${grey(" To adjust settings, ask any agent about")} ${bold("totopo.yaml")} ${grey("- it lives in the workspace root.")}\n`,
|
|
147
175
|
);
|
|
148
176
|
console.log(`${green("●")} ${bold("Ready.")}`);
|
|
149
177
|
console.log(`${grey("Type 'status' to re-run the readiness check.")}\n`);
|
|
150
178
|
} else {
|
|
151
|
-
console.log(`\n${red("●")} ${bold(`${errors} error(s)
|
|
179
|
+
console.log(`\n${red("●")} ${bold(`${errors} error(s) - see above. Rebuild the container to fix.`)}\n`);
|
|
152
180
|
process.exit(1);
|
|
153
181
|
}
|