mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix: session store perms + codex prompt fallback (#1049) (thanks @YuriNachos)
chore: temp landpr 1049 staging
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
50
src/auto-reply/reply/codex-prompts.test.ts
Normal file
50
src/auto-reply/reply/codex-prompts.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
78
src/auto-reply/reply/codex-prompts.ts
Normal file
78
src/auto-reply/reply/codex-prompts.ts
Normal 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] ?? "";
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user