From 1f9a96df8ebb42162a04636f2399bcc106e5e96f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 00:06:17 +0000 Subject: [PATCH] fix: session store perms + codex prompt fallback (#1049) (thanks @YuriNachos) chore: temp landpr 1049 staging --- CHANGELOG.md | 2 + docs/tools/slash-commands.md | 1 + src/auto-reply/reply/codex-prompts.test.ts | 50 ++++++++++++ src/auto-reply/reply/codex-prompts.ts | 78 +++++++++++++++++++ .../reply/get-reply-inline-actions.ts | 42 ++++++++++ src/config/sessions.test.ts | 38 ++++++++- src/config/sessions/store.ts | 22 ++++-- 7 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 src/auto-reply/reply/codex-prompts.test.ts create mode 100644 src/auto-reply/reply/codex-prompts.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ecdc6ef6ff1..af4c15ca909 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ - Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields. - macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`). - Docs: add Date & Time guide and update prompt/timezone configuration docs. +- Tools: fall back to local Codex prompt files for unknown slash commands (supports `$1`, `$2`, `$*`). - Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc. - Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev. - Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs. @@ -72,6 +73,7 @@ - Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj. - Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr. - Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen. +- Sessions: keep session store writes private on disk even when `chmod` fails. (#1049) — thanks @YuriNachos. - Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter. - Agents: avoid false positives when logging unsupported Google tool schema keywords. - Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index d4c7c6c43bc..90c9a07bf05 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -94,6 +94,7 @@ Notes: - `/restart` is disabled by default; set `commands.restart: true` to enable it. - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. +- **Unknown slash commands:** if a command-only message starts with `/foo` and no built-in or skill command matches, Clawdbot looks for `~/.codex/prompts/foo.*` (or `$CODEX_HOME/prompts/foo.*`) and uses that file as the request body (front matter is stripped). Arguments are available as `$1`, `$2`, and `$*`. - **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model). - **Group mention gating:** command-only messages from allowlisted senders bypass mention requirements. - **Inline shortcuts (allowlisted senders only):** certain commands also work when embedded in a normal message and are stripped before the model sees the remaining text. diff --git a/src/auto-reply/reply/codex-prompts.test.ts b/src/auto-reply/reply/codex-prompts.test.ts new file mode 100644 index 00000000000..f7db1805056 --- /dev/null +++ b/src/auto-reply/reply/codex-prompts.test.ts @@ -0,0 +1,50 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + parseSlashCommand, + renderCodexPrompt, + resolveCodexPrompt, + stripFrontMatter, +} from "./codex-prompts.js"; + +describe("codex prompts", () => { + it("parses slash command names and args", () => { + expect(parseSlashCommand("/landpr 123 abc")).toEqual({ name: "landpr", args: "123 abc" }); + expect(parseSlashCommand("nope")).toBeNull(); + }); + + it("resolves prompt files and substitutes args", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-codex-prompts-")); + const promptDir = path.join(tmp, "prompts"); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile( + path.join(promptDir, "landpr.md"), + `---\nsummary: test\n---\nHello $1 [$*] $0\n`, + "utf-8", + ); + + const prev = process.env.CODEX_HOME; + process.env.CODEX_HOME = tmp; + try { + const resolved = await resolveCodexPrompt("landpr"); + expect(resolved?.path).toContain("landpr.md"); + const stripped = stripFrontMatter(`---\nsummary: test\n---\nHello`); + expect(stripped).toBe("Hello"); + const rendered = renderCodexPrompt({ + body: resolved?.body ?? "", + args: "123 abc", + commandName: "landpr", + }); + expect(rendered).toContain("Hello 123 [123 abc] landpr"); + } finally { + if (prev === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = prev; + } + } + }); +}); diff --git a/src/auto-reply/reply/codex-prompts.ts b/src/auto-reply/reply/codex-prompts.ts new file mode 100644 index 00000000000..404617c8ef4 --- /dev/null +++ b/src/auto-reply/reply/codex-prompts.ts @@ -0,0 +1,78 @@ +import type { Dirent } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { resolveUserPath } from "../../utils.js"; + +const PROMPT_NAME_RE = /^[a-z0-9_-]+$/i; + +export type CodexPrompt = { + path: string; + body: string; +}; + +export function parseSlashCommand(input: string): { name: string; args?: string } | null { + const trimmed = input.trim(); + if (!trimmed.startsWith("/")) return null; + const match = trimmed.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/); + if (!match) return null; + const name = match[1]?.trim(); + if (!name) return null; + const args = match[2]?.trim(); + return { name, args: args || undefined }; +} + +export function stripFrontMatter(input: string): string { + if (!input.startsWith("---")) return input; + const match = input.match(/^---\s*\r?\n[\s\S]*?\r?\n---\s*\r?\n?/); + return match ? input.slice(match[0].length) : input; +} + +export async function resolveCodexPrompt( + commandName: string, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const normalized = commandName.trim().toLowerCase(); + if (!PROMPT_NAME_RE.test(normalized)) return null; + const home = env.CODEX_HOME?.trim() + ? resolveUserPath(env.CODEX_HOME.trim()) + : resolveUserPath("~/.codex"); + const promptsDir = path.join(home, "prompts"); + let entries: Array | null = null; + try { + entries = await fs.readdir(promptsDir, { withFileTypes: true }); + } catch { + return null; + } + const match = entries.find((entry) => { + if (!entry.isFile()) return false; + const base = path.parse(entry.name).name.toLowerCase(); + return base === normalized; + }); + if (!match) return null; + const promptPath = path.join(promptsDir, match.name); + try { + const raw = await fs.readFile(promptPath, "utf-8"); + const body = stripFrontMatter(raw).trim(); + if (!body) return null; + return { path: promptPath, body }; + } catch { + return null; + } +} + +export function renderCodexPrompt(params: { + body: string; + args?: string; + commandName?: string; +}): string { + const args = params.args?.trim() ?? ""; + const tokens = args ? args.split(/\s+/) : []; + return params.body.replace(/\$(\d+|\*|@|0)/g, (match, token) => { + if (token === "*" || token === "@") return args; + if (token === "0") return params.commandName ?? ""; + const index = Number(token); + if (!Number.isFinite(index) || index < 1) return match; + return tokens[index - 1] ?? ""; + }); +} diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index c1e64819805..16e87548125 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -5,8 +5,10 @@ import type { SessionEntry } from "../../config/sessions.js"; import type { MsgContext, TemplateContext } from "../templating.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; +import { listChatCommands } from "../commands-registry.js"; import { getAbortMemory } from "./abort.js"; import { buildStatusReply, handleCommands } from "./commands.js"; +import { parseSlashCommand, renderCodexPrompt, resolveCodexPrompt } from "./codex-prompts.js"; import type { InlineDirectives } from "./directive-handling.js"; import { isDirectiveOnly } from "./directive-handling.js"; import type { createModelSelectionState } from "./model-selection.js"; @@ -138,6 +140,46 @@ export async function handleInlineActions(params: { cleanedBody = rewrittenBody; } + const slashCommand = + allowTextCommands && command.isAuthorizedSender + ? parseSlashCommand(command.commandBodyNormalized) + : null; + if (slashCommand) { + const reserved = new Set(); + for (const entry of listChatCommands()) { + if (entry.nativeName) reserved.add(entry.nativeName.toLowerCase()); + for (const alias of entry.textAliases) { + if (!alias.startsWith("/")) continue; + reserved.add(alias.slice(1).toLowerCase()); + } + } + for (const entry of skillCommands) { + reserved.add(entry.name.toLowerCase()); + } + const hasInlineDirective = + directives.hasThinkDirective || + directives.hasVerboseDirective || + directives.hasReasoningDirective || + directives.hasElevatedDirective || + directives.hasModelDirective || + directives.hasQueueDirective || + directives.hasStatusDirective; + if (!hasInlineDirective && !reserved.has(slashCommand.name.toLowerCase())) { + const prompt = await resolveCodexPrompt(slashCommand.name); + if (prompt) { + const rendered = renderCodexPrompt({ + body: prompt.body, + args: slashCommand.args, + commandName: slashCommand.name, + }); + ctx.Body = rendered; + sessionCtx.Body = rendered; + sessionCtx.BodyStripped = rendered; + cleanedBody = rendered; + } + } + } + const sendInlineReply = async (reply?: ReplyPayload) => { if (!reply) return; if (!opts?.onBlockReply) return; diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index bc94dce3ebe..52ed3c8f971 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -1,7 +1,8 @@ import fs from "node:fs/promises"; +import fsSync from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { buildGroupDisplayName, @@ -157,6 +158,41 @@ describe("sessions", () => { expect(store["agent:main:two"]?.sessionId).toBe("sess-2"); }); + it("updateSessionStore preserves sessions.json permissions", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-")); + const storePath = path.join(dir, "sessions.json"); + await fs.writeFile(storePath, "{}", "utf-8"); + await fs.chmod(storePath, 0o600); + + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { sessionId: "sess-1", updatedAt: 1 }; + }); + + const mode = (await fs.stat(storePath)).mode & 0o777; + if (process.platform === "win32") { + expect([0o600, 0o666, 0o777]).toContain(mode); + } else { + expect(mode).toBe(0o600); + } + }); + + it("updateSessionStore ignores chmod failures", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-")); + const storePath = path.join(dir, "sessions.json"); + await fs.writeFile(storePath, "{}", "utf-8"); + + const spy = vi.spyOn(fsSync.promises, "chmod").mockRejectedValueOnce(new Error("nope")); + try { + await expect( + updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { sessionId: "sess-1", updatedAt: 1 }; + }), + ).resolves.toBeUndefined(); + } finally { + spy.mockRestore(); + } + }); + it("updateSessionStore keeps deletions when concurrent writes happen", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-")); const storePath = path.join(dir, "sessions.json"); diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index d5de85b47c0..fdddca68b14 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -45,6 +45,16 @@ export function clearSessionStoreCacheForTest(): void { SESSION_STORE_CACHE.clear(); } +const sessionStoreWriteOptions = { mode: 0o600, encoding: "utf-8" } as const; + +async function ensurePrivateMode(storePath: string): Promise { + try { + await fs.promises.chmod(storePath, 0o600); + } catch { + // Best-effort: ignore chmod failures (e.g., Windows or filesystem quirks). + } +} + type LoadSessionStoreOptions = { skipCache?: boolean; }; @@ -121,7 +131,8 @@ async function saveSessionStoreUnlocked( // We serialize writers via the session-store lock instead. if (process.platform === "win32") { try { - await fs.promises.writeFile(storePath, json, "utf-8"); + await fs.promises.writeFile(storePath, json, sessionStoreWriteOptions); + await ensurePrivateMode(storePath); } catch (err) { const code = err && typeof err === "object" && "code" in err @@ -135,10 +146,9 @@ async function saveSessionStoreUnlocked( const tmp = `${storePath}.${process.pid}.${crypto.randomUUID()}.tmp`; try { - await fs.promises.writeFile(tmp, json, { mode: 0o600, encoding: "utf-8" }); + await fs.promises.writeFile(tmp, json, sessionStoreWriteOptions); await fs.promises.rename(tmp, storePath); - // Ensure permissions are set even if rename loses them - await fs.promises.chmod(storePath, 0o600); + await ensurePrivateMode(storePath); } catch (err) { const code = err && typeof err === "object" && "code" in err @@ -150,8 +160,8 @@ async function saveSessionStoreUnlocked( // Best-effort: try a direct write (recreating the parent dir), otherwise ignore. try { await fs.promises.mkdir(path.dirname(storePath), { recursive: true }); - await fs.promises.writeFile(storePath, json, { mode: 0o600, encoding: "utf-8" }); - await fs.promises.chmod(storePath, 0o600); + await fs.promises.writeFile(storePath, json, sessionStoreWriteOptions); + await ensurePrivateMode(storePath); } catch (err2) { const code2 = err2 && typeof err2 === "object" && "code" in err2