Compare commits
1 Commits
main
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f9a96df8e |
@ -43,6 +43,7 @@
|
|||||||
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
- 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`).
|
- 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.
|
- 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: 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.
|
- 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.
|
- 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: 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: 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.
|
- 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.
|
- 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: 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.
|
- 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.
|
- `/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.
|
- `/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.
|
- `/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).
|
- **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.
|
- **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.
|
- **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 { MsgContext, TemplateContext } from "../templating.js";
|
||||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
|
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
|
||||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||||
|
import { listChatCommands } from "../commands-registry.js";
|
||||||
import { getAbortMemory } from "./abort.js";
|
import { getAbortMemory } from "./abort.js";
|
||||||
import { buildStatusReply, handleCommands } from "./commands.js";
|
import { buildStatusReply, handleCommands } from "./commands.js";
|
||||||
|
import { parseSlashCommand, renderCodexPrompt, resolveCodexPrompt } from "./codex-prompts.js";
|
||||||
import type { InlineDirectives } from "./directive-handling.js";
|
import type { InlineDirectives } from "./directive-handling.js";
|
||||||
import { isDirectiveOnly } from "./directive-handling.js";
|
import { isDirectiveOnly } from "./directive-handling.js";
|
||||||
import type { createModelSelectionState } from "./model-selection.js";
|
import type { createModelSelectionState } from "./model-selection.js";
|
||||||
@ -138,6 +140,46 @@ export async function handleInlineActions(params: {
|
|||||||
cleanedBody = rewrittenBody;
|
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) => {
|
const sendInlineReply = async (reply?: ReplyPayload) => {
|
||||||
if (!reply) return;
|
if (!reply) return;
|
||||||
if (!opts?.onBlockReply) return;
|
if (!opts?.onBlockReply) return;
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
import fsSync from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildGroupDisplayName,
|
buildGroupDisplayName,
|
||||||
@ -157,6 +158,41 @@ describe("sessions", () => {
|
|||||||
expect(store["agent:main:two"]?.sessionId).toBe("sess-2");
|
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 () => {
|
it("updateSessionStore keeps deletions when concurrent writes happen", async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
|
||||||
const storePath = path.join(dir, "sessions.json");
|
const storePath = path.join(dir, "sessions.json");
|
||||||
|
|||||||
@ -45,6 +45,16 @@ export function clearSessionStoreCacheForTest(): void {
|
|||||||
SESSION_STORE_CACHE.clear();
|
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 = {
|
type LoadSessionStoreOptions = {
|
||||||
skipCache?: boolean;
|
skipCache?: boolean;
|
||||||
};
|
};
|
||||||
@ -121,7 +131,8 @@ async function saveSessionStoreUnlocked(
|
|||||||
// We serialize writers via the session-store lock instead.
|
// We serialize writers via the session-store lock instead.
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
try {
|
try {
|
||||||
await fs.promises.writeFile(storePath, json, "utf-8");
|
await fs.promises.writeFile(storePath, json, sessionStoreWriteOptions);
|
||||||
|
await ensurePrivateMode(storePath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code =
|
const code =
|
||||||
err && typeof err === "object" && "code" in err
|
err && typeof err === "object" && "code" in err
|
||||||
@ -135,10 +146,9 @@ async function saveSessionStoreUnlocked(
|
|||||||
|
|
||||||
const tmp = `${storePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
const tmp = `${storePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||||
try {
|
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);
|
await fs.promises.rename(tmp, storePath);
|
||||||
// Ensure permissions are set even if rename loses them
|
await ensurePrivateMode(storePath);
|
||||||
await fs.promises.chmod(storePath, 0o600);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code =
|
const code =
|
||||||
err && typeof err === "object" && "code" in err
|
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.
|
// Best-effort: try a direct write (recreating the parent dir), otherwise ignore.
|
||||||
try {
|
try {
|
||||||
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||||
await fs.promises.writeFile(storePath, json, { mode: 0o600, encoding: "utf-8" });
|
await fs.promises.writeFile(storePath, json, sessionStoreWriteOptions);
|
||||||
await fs.promises.chmod(storePath, 0o600);
|
await ensurePrivateMode(storePath);
|
||||||
} catch (err2) {
|
} catch (err2) {
|
||||||
const code2 =
|
const code2 =
|
||||||
err2 && typeof err2 === "object" && "code" in err2
|
err2 && typeof err2 === "object" && "code" in err2
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user