Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion scripts/sandbox-probe.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,26 @@ check('读 allow-list: .dashboard-port', 'ALLOWED', ['/bin/cat', join(botmuxHome
check('读 config.json (voice 凭证, codex#1)', 'DENIED', ['/bin/cat', join(botmuxHome, 'config.json')]);
check('读 .env (daemon 配置, codex#1)', 'DENIED', ['/bin/cat', join(botmuxHome, '.env')]);
check('读 data/webhook-master.key (AES 主密钥, codex#1)', 'DENIED', ['/bin/cat', join(sessionDataDir, 'webhook-master.key')]);
check('读写 data/schedules.json (RMW 定时任务, owner 接受泄漏)', 'ALLOWED', ['/bin/cat', join(sessionDataDir, 'schedules.json')]);
// Schedules are per-bot now: own BOT_HOME store fully mutable (file + RMW
// sibling lock), sibling stores invisible. The lock-file write is the exact
// operation the old shared-file grant could NOT cover (schedules.json.lock is
// a SIBLING path of a single-file rule) — probe it explicitly.
const ownSchedules = join(botHome, 'schedules.json');
check('写自己 BOT_HOME 的 schedules.json (per-bot 存储)', 'ALLOWED',
['/bin/sh', '-c', `[ -f '${ownSchedules}' ] || printf '{}' > '${ownSchedules}'; /bin/cat '${ownSchedules}' > /dev/null`]);
check('建自己 schedules.json.lock (RMW 兄弟锁, 旧共享模型的盲区)', 'ALLOWED',
['/bin/sh', '-c', `/usr/bin/touch '${ownSchedules}.lock' && /bin/rm -f '${ownSchedules}.lock'`]);
// Only probe a sibling store that really exists — a missing file also fails
// `cat`, and "absent" must never masquerade as "denied".
const siblingSchedules = (() => {
try {
return readdirSync(join(botmuxHome, 'bots'))
.filter((n) => n.startsWith('cli_') && n !== APP)
.map((n) => join(botmuxHome, 'bots', n, 'schedules.json'))
.find((p) => existsSync(p));
} catch { return undefined; }
})();
if (siblingSchedules) check('读兄弟 bot 的 schedules.json (跨-bot 任务 prompt)', 'DENIED', ['/bin/cat', siblingSchedules]);
check('读 ~/.botmux/bots.json (敏感)', 'DENIED', ['/bin/cat', join(botmuxHome, 'bots.json')]);
check('读 ~/.botmux/logs (跨-bot)', 'DENIED', ['/bin/ls', join(botmuxHome, 'logs')]);
// End-to-end: actually run the botmux CLI inside the sandbox (loads cli.js + reads
Expand Down
12 changes: 5 additions & 7 deletions src/adapters/cli/fs-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,13 +348,11 @@ export function buildFsPolicy(ctx: FsPolicyContext): FsPolicy {
// worker PRE-CREATES this file before spawn so it survives the existence
// filter and bwrap can bind it (bwrap cannot bind a nonexistent source).
if (ctx.sessionId) push([`${sd}/turn-sends/${ctx.sessionId}.jsonl`], 'readWrite', 'internal');
// schedules.json: `botmux schedule` is a READ-MODIFY-WRITE store shared by all
// bots (one file). It must be readWrite — a read-deny makes a sandboxed
// `botmux schedule` load an empty map and overwrite, wiping EVERY bot's tasks.
// This DOES expose other bots' task prompts+routing; accepted by the owner
// (王旭 2026-07-16) as the cost of the schedule feature — same call the old
// read-isolation made (schedules.json deliberately never denied).
push([`${sd}/schedules.json`], 'readWrite', 'internal');
// (schedules: stored PER BOT inside each BOT_HOME — the owner's dir is
// already readWrite above and siblings' stores are denied by construction,
// so the old shared data/schedules.json grant (and the cross-bot task-prompt
// exposure it had to accept) is gone. The RMW sibling lock lives in the same
// rw dir, so sandboxed `botmux schedule` mutations work on both platforms.)
// macOS lark-cli key store carve-out. The baseline DENIES the whole
// `~/Library/Application Support/lark-cli` dir (it holds EVERY bot's appsecret
// ciphertext + the master key — the pre-refactor cross-bot leak). But this bot
Expand Down
123 changes: 95 additions & 28 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5281,8 +5281,37 @@ async function cmdSchedule(sub: string, rest: string[]): Promise<void> {
const scheduler = await import('./core/scheduler.js');
const scheduleStore = await import('./services/schedule-store.js');

// Per-bot stores: bind this invocation to one bot's store — explicit
// --lark-app-id wins, else the surrounding session's bot (env/marker-derived;
// always present inside sandboxed sessions). A bare terminal without either
// binds to the primary bot (bots.json[0]) so mutations keep the legacy
// "ownerless task runs on bot-0" semantics; `list` without a bound bot
// aggregates every readable store instead.
const cliScopeAppId = argValue(rest, '--lark-app-id')
?? detectCurrentSession()?.larkAppId
?? process.env.BOTMUX_LARK_APP_ID;
// LAZY + sandbox-safe bots.json read: sandboxed sessions always carry a
// scope (env-injected appId) and must never touch bots.json — it is denied
// and `loadBotsJson()` process.exit(1)s on the read error. Only the bare
// unsandboxed terminal (aggregate list / cross-store id lookup) reads it,
// and even then failure degrades to "no other stores visible".
const allBotAppIds = (): string[] => {
try {
return parseBotConfigsJson(readFileSync(BOTS_JSON_FILE, 'utf-8'), BOTS_JSON_FILE)
.map((b: { larkAppId?: unknown }) => b?.larkAppId)
.filter((x: unknown): x is string => typeof x === 'string');
} catch { return []; } // absent OR sandbox-denied → own scope only
};
if (cliScopeAppId) scheduleStore.setScheduleScope(cliScopeAppId);
else {
const first = allBotAppIds()[0];
if (first) scheduleStore.setScheduleScope(first);
}

if (!sub || sub === 'list' || sub === 'ls') {
const tasks = scheduleStore.listTasks();
const tasks = cliScopeAppId
? scheduleStore.listTasks()
: scheduleStore.listTasksForBots(allBotAppIds());
if (tasks.length === 0) {
console.log('暂无定时任务。\n\n用法:\n botmux schedule add "每日17:50" "帮我看AI新闻"\n botmux schedule add "every 2h" "检查构建"\n botmux schedule add "0 9 * * *" "每天早安"');
return;
Expand Down Expand Up @@ -5325,7 +5354,12 @@ async function cmdSchedule(sub: string, rest: string[]): Promise<void> {
const explicitRootMessageId = argValue(rest, '--root-msg-id');
const rootMessageId = explicitRootMessageId
?? (chatId && chatId === cur?.chatId ? cur.rootMessageId : undefined);
const larkAppId = argValue(rest, '--lark-app-id') ?? cur?.larkAppId;
// Owner resolution mirrors the store-scope resolution above (flag →
// session marker → daemon-injected env): a sandboxed session without a
// readable marker must still stamp its own bot as owner, or the task
// would land in this bot's store as OWNERLESS — which only the primary
// daemon executes — and never fire (codex review P1).
const larkAppId = cliScopeAppId;
const workingDir = argValue(rest, '--workdir') ?? cur?.workingDir ?? process.cwd();
const name = argValue(rest, '--name') ?? (promptArg.length > 20 ? promptArg.slice(0, 20) + '…' : promptArg);
const legacyDeliver = argValue(rest, '--deliver') as 'origin' | 'local' | 'new-topic' | undefined;
Expand Down Expand Up @@ -5374,25 +5408,36 @@ async function cmdSchedule(sub: string, rest: string[]): Promise<void> {
process.exit(1);
}

const task = scheduler.addTask({
name,
schedule: rawSchedule,
parsed,
prompt: promptArg,
workingDir,
chatId,
rootMessageId,
larkAppId,
creatorChatId: cur?.chatId,
creatorRootMessageId: cur?.rootMessageId,
creatorLarkAppId: cur?.larkAppId,
chatType: cur?.chatType === 'p2p' ? 'p2p' : 'topic_group',
scope,
executionPosition,
topicTitle,
deliver,
silent,
});
let task;
try {
task = scheduler.addTask({
name,
schedule: rawSchedule,
parsed,
prompt: promptArg,
workingDir,
chatId,
rootMessageId,
larkAppId,
creatorChatId: cur?.chatId,
creatorRootMessageId: cur?.rootMessageId,
creatorLarkAppId: cur?.larkAppId,
chatType: cur?.chatType === 'p2p' ? 'p2p' : 'topic_group',
scope,
executionPosition,
topicTitle,
deliver,
silent,
});
} catch (err) {
// Sandboxed sessions can only write their OWN bot's store — a cross-bot
// `--lark-app-id` (or a scope pointing at another bot) fails closed here.
if (/EPERM|EACCES|not permitted/i.test(String(err))) {
console.error(`无法写入目标 bot 的定时任务存储(${larkAppId ?? '未指定'}):沙盒会话只能管理自己 bot 的任务。`);
process.exit(1);
}
throw err;
}

const next = task.nextRunAt ? new Date(task.nextRunAt).toLocaleString('zh-CN', { timeZone: scheduleTimeZone() }) : '—';
console.log(`✅ 已创建定时任务 [${task.id}] ${task.name}`);
Expand All @@ -5411,29 +5456,51 @@ async function cmdSchedule(sub: string, rest: string[]): Promise<void> {
process.exit(1);
}

// Id-addressed op missed the bound store: locate the id across every
// READABLE bot store (bare-terminal admin usage) and rebind the scope to the
// owning bot for this one-shot process. Sandboxed callers cannot read
// sibling stores, so they stay confined to their own tasks by construction.
const retargetIfElsewhere = (): boolean => {
const hit = scheduleStore.findTaskAcrossBots(id, allBotAppIds());
if (!hit || hit.appId === scheduleStore.getScheduleScope()) return false;
scheduleStore.setScheduleScope(hit.appId);
console.log(`(任务属于 bot ${hit.appId} 的存储)`);
return true;
};

switch (sub) {
case 'remove':
case 'rm':
case 'delete':
case 'del':
if (scheduler.removeTask(id)) console.log(`已删除任务 ${id}`);
case 'del': {
let ok = scheduler.removeTask(id);
if (!ok && retargetIfElsewhere()) ok = scheduler.removeTask(id);
if (ok) console.log(`已删除任务 ${id}`);
else { console.error(`未找到任务 ${id}`); process.exit(1); }
break;
}
case 'pause':
case 'disable':
if (scheduler.disableTask(id)) console.log(`已暂停任务 ${id}`);
case 'disable': {
let ok = scheduler.disableTask(id);
if (!ok && retargetIfElsewhere()) ok = scheduler.disableTask(id);
if (ok) console.log(`已暂停任务 ${id}`);
else { console.error(`未找到任务 ${id}`); process.exit(1); }
break;
}
case 'resume':
case 'enable':
if (scheduler.enableTask(id)) console.log(`已恢复任务 ${id}`);
case 'enable': {
let ok = scheduler.enableTask(id);
if (!ok && retargetIfElsewhere()) ok = scheduler.enableTask(id);
if (ok) console.log(`已恢复任务 ${id}`);
else { console.error(`未找到任务 ${id}`); process.exit(1); }
break;
}
case 'run':
// Running requires the daemon (executeCallback is daemon-side).
// CLI can only mark a task to run ASAP; daemon's next tick picks it up.
{
const task = scheduleStore.getTask(id);
let task = scheduleStore.getTask(id);
if (!task && retargetIfElsewhere()) task = scheduleStore.getTask(id);
if (!task) { console.error(`未找到任务 ${id}`); process.exit(1); }
scheduleStore.updateTask(id, { nextRunAt: new Date().toISOString() });
console.log(`已标记任务 ${id} 下次 tick 立即执行(< 30s)`);
Expand Down
7 changes: 6 additions & 1 deletion src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import * as sessionStore from './services/session-store.js';
import * as chatFirstSeenStore from './services/chat-first-seen-store.js';
import { ensureDefaultOncallBound } from './services/oncall-store.js';
import * as scheduleStore from './services/schedule-store.js';
import { migrateSharedSchedulesAtStartup } from './services/schedule-split-migration.js';
import * as messageQueue from './services/message-queue.js';
import { emitHookEvent, emitHookEventLocal, HOOK_EVENTS, type HookEvent } from './services/hook-runner.js';
import { setSessionLifecycleShutdown } from './services/session-lifecycle-hooks.js';
Expand Down Expand Up @@ -17177,8 +17178,12 @@ export async function startDaemon(botIndex?: number): Promise<void> {
}
}, VC_MEETING_DELIVERY_LEASE_SCAN_MS);
vcMeetingDeliveryLeaseTimer.unref?.();
// Watch schedules.json for external writes (e.g. `botmux schedule add`
// Bind the schedule store to this daemon's bot (per-bot stores live in each
// BOT_HOME), split a legacy shared data/schedules.json if one still exists,
// then watch our own store for external writes (e.g. `botmux schedule add`
// running in a separate node process) so dashboard event bus stays in sync.
scheduleStore.setScheduleScope(cfg.larkAppId);
migrateSharedSchedulesAtStartup(botConfigs.map(b => b.larkAppId), botConfigs[0]?.larkAppId ?? cfg.larkAppId);
scheduleStore.startExternalWriteWatcher();
logger.info(`Bot ${idx}/${botConfigs.length}: ${cfg.larkAppId} (cli: ${cfg.cliId})`)
setAskCardDispatcher(createLarkAskCardDispatcher());
Expand Down
Loading