fix: session store perms + codex prompt fallback (#1049) (thanks @YuriNachos)

chore: temp landpr 1049 staging
This commit is contained in:
Peter Steinberger
2026-01-17 00:06:17 +00:00
parent e31251293b
commit 1f9a96df8e
7 changed files with 226 additions and 7 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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;
}
}
});
});

View File

@@ -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<CodexPrompt | null> {
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<Dirent> | 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] ?? "";
});
}

View File

@@ -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<string>();
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;

View File

@@ -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");

View File

@@ -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<void> {
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