fix(workspace): align clawd + bootstrap

This commit is contained in:
Peter Steinberger
2026-01-06 19:50:06 +01:00
parent bdf597eb95
commit d07e78855c
13 changed files with 105 additions and 33 deletions

View File

@@ -26,6 +26,8 @@
- Onboarding: prompt immediately for OpenAI Codex redirect URL on remote/headless logins. - Onboarding: prompt immediately for OpenAI Codex redirect URL on remote/headless logins.
- Configure: add OpenAI Codex (ChatGPT OAuth) auth choice (align with onboarding). - Configure: add OpenAI Codex (ChatGPT OAuth) auth choice (align with onboarding).
- Doctor: suggest adding the workspace memory system when missing (opt-out via `--no-workspace-suggestions`). - Doctor: suggest adding the workspace memory system when missing (opt-out via `--no-workspace-suggestions`).
- Doctor: normalize default workspace path to `~/clawd` (avoid `~/clawdbot`).
- Workspace: only create `BOOTSTRAP.md` for brand-new workspaces (dont recreate after deletion).
- Build: fix duplicate protocol export, align Codex OAuth options, and add proper-lockfile typings. - Build: fix duplicate protocol export, align Codex OAuth options, and add proper-lockfile typings.
- Build: install Bun in the Dockerfile so `pnpm build` can run Bun scripts. Thanks @loukotal for PR #284. - Build: install Bun in the Dockerfile so `pnpm build` can run Bun scripts. Thanks @loukotal for PR #284.
- Typing indicators: stop typing once the reply dispatcher drains to prevent stuck typing across Discord/Telegram/WhatsApp. - Typing indicators: stop typing once the reply dispatcher drains to prevent stuck typing across Discord/Telegram/WhatsApp.

View File

@@ -9,7 +9,7 @@ CLAWDBOT runs a single embedded agent runtime derived from **p-mono** (internal
## Workspace (required) ## Workspace (required)
You must set an agent home directory via `agent.workspace`. CLAWDBOT uses this as the agents **only** working directory (`cwd`) for tools and context. CLAWDBOT uses a single agent workspace directory (`agent.workspace`) as the agents **only** working directory (`cwd`) for tools and context.
Recommended: use `clawdbot setup` to create `~/.clawdbot/clawdbot.json` if missing and initialize the workspace files. Recommended: use `clawdbot setup` to create `~/.clawdbot/clawdbot.json` if missing and initialize the workspace files.
@@ -31,6 +31,14 @@ On the first turn of a new session, CLAWDBOT injects the contents of these files
If a file is missing, CLAWDBOT injects a single “missing file” marker line (and `clawdbot setup` will create a safe default template). If a file is missing, CLAWDBOT injects a single “missing file” marker line (and `clawdbot setup` will create a safe default template).
`BOOTSTRAP.md` is only created for a **brand new workspace** (no other bootstrap files present). If you delete it after completing the ritual, it should not be recreated on later restarts.
To disable bootstrap file creation entirely (for pre-seeded workspaces), set:
```json5
{ agent: { skipBootstrap: true } }
```
## Built-in tools (internal) ## Built-in tools (internal)
ps embedded core tools (read/bash/edit/write and related internals) are defined in code and always available. `TOOLS.md` does **not** control which tools exist; its guidance for how *you* want them used. ps embedded core tools (read/bash/edit/write and related internals) are defined in code and always available. `TOOLS.md` does **not** control which tools exist; its guidance for how *you* want them used.

View File

@@ -85,7 +85,7 @@ Now message the assistant number from your allowlisted phone.
Clawd reads operating instructions and “memory” from its workspace directory. Clawd reads operating instructions and “memory” from its workspace directory.
By default, Clawdbot uses `~/clawd` as the agent workspace, and will create it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`) automatically on setup/first agent run. By default, Clawdbot uses `~/clawd` as the agent workspace, and will create it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`) automatically on setup/first agent run. `BOOTSTRAP.md` is only created when the workspace is brand new (it should not come back after you delete it).
Tip: treat this folder like Clawds “memory” and make it a git repo (ideally private) so your `AGENTS.md` + memory files are backed up. Tip: treat this folder like Clawds “memory” and make it a git repo (ideally private) so your `AGENTS.md` + memory files are backed up.
@@ -103,6 +103,16 @@ Optional: choose a different workspace with `agent.workspace` (supports `~`).
} }
``` ```
If you already ship your own workspace files from a repo, you can disable bootstrap file creation entirely:
```json5
{
agent: {
skipBootstrap: true
}
}
```
## The config that turns it into “an assistant” ## The config that turns it into “an assistant”
CLAWDBOT defaults to a good assistant setup, but youll usually want to tune: CLAWDBOT defaults to a good assistant setup, but youll usually want to tune:

View File

@@ -616,6 +616,18 @@ Default: `~/clawd`.
If `agent.sandbox` is enabled, non-main sessions can override this with their If `agent.sandbox` is enabled, non-main sessions can override this with their
own per-session workspaces under `agent.sandbox.workspaceRoot`. own per-session workspaces under `agent.sandbox.workspaceRoot`.
### `agent.skipBootstrap`
Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`).
Use this for pre-seeded deployments where your workspace files come from a repo.
```json5
{
agent: { skipBootstrap: true }
}
```
### `agent.userTimezone` ### `agent.userTimezone`
Sets the users timezone for **system prompt context** (not for timestamps in Sets the users timezone for **system prompt context** (not for timestamps in

View File

@@ -545,10 +545,10 @@ pkill -f "clawdbot"
# Remove data # Remove data
trash ~/.clawdbot trash ~/.clawdbot
# Remove repo and re-clone # Remove repo and re-clone (adjust path if you cloned elsewhere)
trash ~/clawdbot trash ~/Projects/clawdbot
git clone https://github.com/clawdbot/clawdbot.git git clone https://github.com/clawdbot/clawdbot.git ~/Projects/clawdbot
cd clawdbot && pnpm install && pnpm build cd ~/Projects/clawdbot && pnpm install && pnpm build
pnpm clawdbot onboard pnpm clawdbot onboard
``` ```

View File

@@ -36,4 +36,17 @@ describe("ensureAgentWorkspace", () => {
await ensureAgentWorkspace({ dir, ensureBootstrapFiles: true }); await ensureAgentWorkspace({ dir, ensureBootstrapFiles: true });
expect(await fs.readFile(agentsPath, "utf-8")).toBe("custom"); expect(await fs.readFile(agentsPath, "utf-8")).toBe("custom");
}); });
it("does not recreate BOOTSTRAP.md once workspace exists", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
const agentsPath = path.join(dir, "AGENTS.md");
const bootstrapPath = path.join(dir, "BOOTSTRAP.md");
await fs.writeFile(agentsPath, "custom", "utf-8");
await fs.rm(bootstrapPath, { force: true });
await ensureAgentWorkspace({ dir, ensureBootstrapFiles: true });
await expect(fs.stat(bootstrapPath)).rejects.toBeDefined();
});
}); });

View File

@@ -222,6 +222,21 @@ export async function ensureAgentWorkspace(params?: {
const userPath = path.join(dir, DEFAULT_USER_FILENAME); const userPath = path.join(dir, DEFAULT_USER_FILENAME);
const bootstrapPath = path.join(dir, DEFAULT_BOOTSTRAP_FILENAME); const bootstrapPath = path.join(dir, DEFAULT_BOOTSTRAP_FILENAME);
const isBrandNewWorkspace = await (async () => {
const paths = [agentsPath, soulPath, toolsPath, identityPath, userPath];
const existing = await Promise.all(
paths.map(async (p) => {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}),
);
return existing.every((v) => !v);
})();
const agentsTemplate = await loadTemplate( const agentsTemplate = await loadTemplate(
DEFAULT_AGENTS_FILENAME, DEFAULT_AGENTS_FILENAME,
DEFAULT_AGENTS_TEMPLATE, DEFAULT_AGENTS_TEMPLATE,
@@ -252,7 +267,9 @@ export async function ensureAgentWorkspace(params?: {
await writeFileIfMissing(toolsPath, toolsTemplate); await writeFileIfMissing(toolsPath, toolsTemplate);
await writeFileIfMissing(identityPath, identityTemplate); await writeFileIfMissing(identityPath, identityTemplate);
await writeFileIfMissing(userPath, userTemplate); await writeFileIfMissing(userPath, userTemplate);
await writeFileIfMissing(bootstrapPath, bootstrapTemplate); if (isBrandNewWorkspace) {
await writeFileIfMissing(bootstrapPath, bootstrapTemplate);
}
return { return {
dir, dir,

View File

@@ -268,9 +268,9 @@ describe("doctor", () => {
parsed: { parsed: {
gateway: { mode: "local", bind: "loopback" }, gateway: { mode: "local", bind: "loopback" },
agent: { agent: {
workspace: "/Users/steipete/clawdbot", workspace: "/Users/steipete/clawd",
sandbox: { sandbox: {
workspaceRoot: "/Users/steipete/clawdbot/sandboxes", workspaceRoot: "/Users/steipete/clawd/sandboxes",
docker: { docker: {
image: "clawdbot-sandbox", image: "clawdbot-sandbox",
containerPrefix: "clawdbot-sbx", containerPrefix: "clawdbot-sbx",
@@ -282,9 +282,9 @@ describe("doctor", () => {
config: { config: {
gateway: { mode: "local", bind: "loopback" }, gateway: { mode: "local", bind: "loopback" },
agent: { agent: {
workspace: "/Users/steipete/clawdbot", workspace: "/Users/steipete/clawd",
sandbox: { sandbox: {
workspaceRoot: "/Users/steipete/clawdbot/sandboxes", workspaceRoot: "/Users/steipete/clawd/sandboxes",
docker: { docker: {
image: "clawdbot-sandbox", image: "clawdbot-sandbox",
containerPrefix: "clawdbot-sbx", containerPrefix: "clawdbot-sbx",
@@ -365,8 +365,8 @@ describe("doctor", () => {
const sandbox = agent.sandbox as Record<string, unknown>; const sandbox = agent.sandbox as Record<string, unknown>;
const docker = sandbox.docker as Record<string, unknown>; const docker = sandbox.docker as Record<string, unknown>;
expect(agent.workspace).toBe("/Users/steipete/clawdbot"); expect(agent.workspace).toBe("/Users/steipete/clawd");
expect(sandbox.workspaceRoot).toBe("/Users/steipete/clawdbot/sandboxes"); expect(sandbox.workspaceRoot).toBe("/Users/steipete/clawd/sandboxes");
expect(docker.image).toBe("clawdbot-sandbox"); expect(docker.image).toBe("clawdbot-sandbox");
expect(docker.containerPrefix).toBe("clawdbot-sbx"); expect(docker.containerPrefix).toBe("clawdbot-sbx");
}); });

View File

@@ -247,15 +247,28 @@ async function noteSecurityWarnings(cfg: ClawdbotConfig) {
} }
} }
function replacePathSegment( function normalizeDefaultWorkspacePath(
value: string | undefined, value: string | undefined,
from: string,
to: string,
): string | undefined { ): string | undefined {
if (!value) return value; if (!value) return value;
const pattern = new RegExp(`(^|[\\/])${from}([\\/]|$)`, "g");
if (!pattern.test(value)) return value; const resolved = resolveUserPath(value);
return value.replace(pattern, `$1${to}$2`); const home = os.homedir();
const next = [
["clawdis", "clawd"],
["clawdbot", "clawd"],
].reduce((acc, [from, to]) => {
const fromPrefix = path.join(home, from);
if (acc === fromPrefix) return path.join(home, to);
const withSep = `${fromPrefix}${path.sep}`;
if (acc.startsWith(withSep)) {
return path.join(home, to).concat(acc.slice(fromPrefix.length));
}
return acc;
}, resolved);
return next === resolved ? value : next;
} }
function replaceLegacyName(value: string | undefined): string | undefined { function replaceLegacyName(value: string | undefined): string | undefined {
@@ -556,11 +569,7 @@ function normalizeLegacyConfigValues(cfg: ClawdbotConfig): {
let next: ClawdbotConfig = cfg; let next: ClawdbotConfig = cfg;
const workspace = cfg.agent?.workspace; const workspace = cfg.agent?.workspace;
const updatedWorkspace = replacePathSegment( const updatedWorkspace = normalizeDefaultWorkspacePath(workspace);
replacePathSegment(workspace, "clawdis", "clawdbot"),
"clawd",
"clawdbot",
);
if (updatedWorkspace && updatedWorkspace !== workspace) { if (updatedWorkspace && updatedWorkspace !== workspace) {
next = { next = {
...next, ...next,
@@ -573,11 +582,7 @@ function normalizeLegacyConfigValues(cfg: ClawdbotConfig): {
} }
const workspaceRoot = cfg.agent?.sandbox?.workspaceRoot; const workspaceRoot = cfg.agent?.sandbox?.workspaceRoot;
const updatedWorkspaceRoot = replacePathSegment( const updatedWorkspaceRoot = normalizeDefaultWorkspacePath(workspaceRoot);
replacePathSegment(workspaceRoot, "clawdis", "clawdbot"),
"clawd",
"clawdbot",
);
if (updatedWorkspaceRoot && updatedWorkspaceRoot !== workspaceRoot) { if (updatedWorkspaceRoot && updatedWorkspaceRoot !== workspaceRoot) {
next = { next = {
...next, ...next,

View File

@@ -223,10 +223,11 @@ export async function openUrl(url: string): Promise<boolean> {
export async function ensureWorkspaceAndSessions( export async function ensureWorkspaceAndSessions(
workspaceDir: string, workspaceDir: string,
runtime: RuntimeEnv, runtime: RuntimeEnv,
options?: { skipBootstrap?: boolean },
) { ) {
const ws = await ensureAgentWorkspace({ const ws = await ensureAgentWorkspace({
dir: workspaceDir, dir: workspaceDir,
ensureBootstrapFiles: true, ensureBootstrapFiles: !options?.skipBootstrap,
}); });
runtime.log(`Workspace OK: ${ws.dir}`); runtime.log(`Workspace OK: ${ws.dir}`);
const sessionsDir = resolveSessionTranscriptsDir(); const sessionsDir = resolveSessionTranscriptsDir();

View File

@@ -219,7 +219,9 @@ export async function runNonInteractiveOnboarding(
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
await writeConfigFile(nextConfig); await writeConfigFile(nextConfig);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
await ensureWorkspaceAndSessions(workspaceDir, runtime); await ensureWorkspaceAndSessions(workspaceDir, runtime, {
skipBootstrap: Boolean(nextConfig.agent?.skipBootstrap),
});
if (opts.installDaemon) { if (opts.installDaemon) {
const service = resolveGatewayService(); const service = resolveGatewayService();

View File

@@ -74,7 +74,7 @@ export async function setupCommand(
const ws = await ensureAgentWorkspace({ const ws = await ensureAgentWorkspace({
dir: workspace, dir: workspace,
ensureBootstrapFiles: true, ensureBootstrapFiles: !next.agent?.skipBootstrap,
}); });
runtime.log(`Workspace OK: ${ws.dir}`); runtime.log(`Workspace OK: ${ws.dir}`);

View File

@@ -604,7 +604,9 @@ export async function runOnboardingWizard(
await writeConfigFile(nextConfig); await writeConfigFile(nextConfig);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
await ensureWorkspaceAndSessions(workspaceDir, runtime); await ensureWorkspaceAndSessions(workspaceDir, runtime, {
skipBootstrap: Boolean(nextConfig.agent?.skipBootstrap),
});
nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter); nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter);
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });