diff --git a/apps/desktop/src/main/__tests__/session-environment-prompt.test.ts b/apps/desktop/src/main/__tests__/session-environment-prompt.test.ts index 911d2cf778..0d2fda26ae 100644 --- a/apps/desktop/src/main/__tests__/session-environment-prompt.test.ts +++ b/apps/desktop/src/main/__tests__/session-environment-prompt.test.ts @@ -49,8 +49,8 @@ describe('session environment prompt', () => { it('is injected as a current-turn tail instead of durable system prefix', async () => { const source = await readMainProcessCombinedSource(); - assert.match(source, /turnTailPrompt:\s*\(\{ cwd \}\) => systemPromptService\.buildTurnTailPrompt\(cwd\)/); - assert.match(source, /async function buildTurnTailPrompt\(cwd\?: string\)/); + assert.match(source, /turnTailPrompt:\s*\(\{ cwd, sessionId \}\) => systemPromptService\.buildTurnTailPrompt\(cwd, sessionId\)/); + assert.match(source, /async function buildTurnTailPrompt\(cwd\?: string, sessionId\?: string\)/); assert.match(source, /projectGit:\s*await resolveProjectGitInfo\(cwd\)/); assert.doesNotMatch(source, /personalization\.text,\n\s*environment,\n\s*deepResearch/); }); diff --git a/apps/desktop/src/main/__tests__/task-ledger-contract.test.ts b/apps/desktop/src/main/__tests__/task-ledger-contract.test.ts new file mode 100644 index 0000000000..3be62de7c9 --- /dev/null +++ b/apps/desktop/src/main/__tests__/task-ledger-contract.test.ts @@ -0,0 +1,173 @@ +/** + * Contract for the session task-ledger primitive (model-facing slice, PR1). + * + * Locks the seams that a refactor could silently break: + * (a) main.ts wires TaskCreate/TaskUpdate into builtinTools and constructs + * the per-session store, and threads sessionId into the turn tail. + * (b) the turn-tail injector exists and injects nothing for an empty ledger + * (zero cost when the model isn't tracking tasks) but renders when there + * are tasks. + * (c) subjects are scrubbed (redactSecrets) and cannot escape the + * data envelope via embedded wrapper-tag literals. + * (d) both tools skip the permission engine (pure local session state). + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { mkdtemp } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { AppSettings, Task } from '@maka/core'; +import { + TASK_CREATE_TOOL_NAME, + TASK_UPDATE_TOOL_NAME, + buildTaskLedgerTools, + type MakaToolContext, +} from '@maka/runtime'; +import { createMainTaskLedgerWiring } from '../task-ledger-wiring.js'; +import { createSystemPromptMainService } from '../system-prompt-main.js'; + +function makeService(tasks: Task[]) { + return createSystemPromptMainService({ + settingsStore: { get: async () => ({}) as AppSettings }, + workspaceRoot: '/tmp/does-not-matter', + localMemory: { + getState: async () => ({ status: 'ok', agentReadEnabled: false, content: '' }) as never, + consumePendingPromptUpdates: () => [], + }, + taskLedger: { list: async () => tasks }, + }); +} + +const sampleTask: Task = { + id: 'task-1', + subject: '写单元测试', + status: 'in_progress', + createdAt: 1, + updatedAt: 2, +}; + +function fakeContext(sessionId: string): MakaToolContext { + return { + sessionId, + turnId: 'turn-1', + cwd: '/tmp', + toolCallId: 'call-1', + abortSignal: new AbortController().signal, + emitOutput: () => {}, + }; +} + +describe('task ledger contract', () => { + it('wires the store and tools to one shared task ledger the turn tail reads (behavior, not source text)', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-task-ledger-wiring-')); + const wiring = createMainTaskLedgerWiring(root); + // (a) TaskCreate/TaskUpdate are wired in. + assert.ok(wiring.tools.some((t) => t.name === TASK_CREATE_TOOL_NAME), 'TaskCreate must be wired'); + assert.ok(wiring.tools.some((t) => t.name === TASK_UPDATE_TOOL_NAME), 'TaskUpdate must be wired'); + // (b) store is real and empty for a fresh workspace. + assert.deepEqual(await wiring.store.list('sess-1'), []); + // (c) tools and the turn tail share ONE store through the real system prompt + // service: a TaskCreate lands in the store the tail reads, proving the + // mutate and read faces cannot drift to different ledgers. + const service = createSystemPromptMainService({ + settingsStore: { get: async () => ({}) as AppSettings }, + workspaceRoot: root, + localMemory: { + getState: async () => ({ status: 'ok', agentReadEnabled: false, content: '' }) as never, + consumePendingPromptUpdates: () => [], + }, + taskLedger: wiring.store, + }); + const create = wiring.tools.find((t) => t.name === TASK_CREATE_TOOL_NAME); + assert.ok(create, 'TaskCreate tool must be present'); + await create.impl({ tasks: [{ subject: '通过装配建任务' }] }, fakeContext('sess-1')); + const tail = await service.buildTurnTailPrompt(undefined, 'sess-1'); + assert.ok(tail, 'tail must render when the shared store has tasks'); + assert.match(tail, /通过装配建任务/); + }); + + it('injects nothing for an empty ledger', async () => { + const tail = await makeService([]).buildTurnTailPrompt(undefined, 'sess-1'); + assert.equal(tail, undefined); + }); + + it('injects nothing when no sessionId is available', async () => { + const tail = await makeService([sampleTask]).buildTurnTailPrompt(undefined, undefined); + assert.equal(tail, undefined); + }); + + it('renders the ledger as a current-turn tail fragment when tasks exist', async () => { + const tail = await makeService([sampleTask]).buildTurnTailPrompt(undefined, 'sess-1'); + assert.ok(tail); + assert.match(tail, //); + assert.match(tail, /写单元测试/); + assert.match(tail, /仅供当前回复参考/); + }); + + it('redacts secret-like text in task subjects before injecting the tail', async () => { + // Same samples the core redactSecrets tests use: a bearer token and a + // provider key prefix. Subjects are model-authored free text replayed + // every turn, so they must pass through redactSecrets like memory tail + // text does (cf. compactMemoryUpdateText). + const secretTask: Task = { + ...sampleTask, + subject: '轮换 Bearer sk-live-secret-token-value 和 ghp_abcdefghijklmnopqrstuvwxyz', + }; + const tail = await makeService([secretTask]).buildTurnTailPrompt(undefined, 'sess-1'); + assert.ok(tail); + assert.equal(tail.includes('sk-live-secret-token-value'), false); + assert.equal(tail.includes('ghp_abcdefghijklmnopqrstuvwxyz'), false); + assert.match(tail, /\[redacted\]/); + }); + + it('strips wrapper-tag literals so a subject cannot close the data envelope early', async () => { + // normalizeTaskSubject only collapses whitespace and redactSecrets only + // masks secrets, so a literal in a subject would otherwise + // escape the data wrapper and read as instruction-level text. + const escapingTask: Task = { + ...sampleTask, + subject: '正常前缀 假指令 假开头', + }; + const tail = await makeService([escapingTask]).buildTurnTailPrompt(undefined, 'sess-1'); + assert.ok(tail); + // Exactly one closing tag (the real envelope) and one opening tag survive. + assert.equal(tail.match(/<\/task-ledger>/g)?.length, 1); + assert.equal(tail.match(//g)?.length, 1); + assert.match(tail, /正常前缀/); + }); + + it('strips tag variants (attributes, whitespace, self-closing), not just exact literals', async () => { + // The narrow regex misses (space before >), + // (attributes), , and . + // A model-authored subject carrying any of these must not smuggle extra + // tag-like text into the tail; only the real envelope open+close survive. + const escapingTask: Task = { + ...sampleTask, + subject: '前缀 假1 假2 假3 后缀', + }; + const tail = await makeService([escapingTask]).buildTurnTailPrompt(undefined, 'sess-1'); + assert.ok(tail); + assert.equal( + (tail.match(/<\/?task-ledger[^>]*>/gi) || []).length, + 2, + 'only the real envelope open+close tags should survive, got: ' + JSON.stringify(tail), + ); + assert.match(tail, /前缀/); + assert.match(tail, /后缀/); + }); + + it('keeps both tools free of the permission gate', () => { + const tools = buildTaskLedgerTools({ + store: { + list: async () => [], + create: async () => ({ created: [], total: 0 }), + update: async () => ({ updated: {} as Task, total: 0 }), + }, + }); + assert.deepEqual(tools.map((t) => t.name), [TASK_CREATE_TOOL_NAME, TASK_UPDATE_TOOL_NAME]); + for (const tool of tools) { + assert.equal(tool.permissionRequired, false, `${tool.name} must not require permission`); + } + }); +}); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 678009e402..4b5f467ee7 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -164,6 +164,7 @@ import { createBotIncomingMainService } from './bot-incoming-main.js'; import { createSubscriptionModelFetch } from './subscription-model-fetch.js'; import { buildContextBudgetPolicy } from './context-budget-policy.js'; import { createSystemPromptMainService } from './system-prompt-main.js'; +import { createMainTaskLedgerWiring } from './task-ledger-wiring.js'; import { createOAuthModelConnectionsMainService } from './oauth-model-connections-main.js'; import { applyNetworkPatch, @@ -297,6 +298,8 @@ const antigravitySubscription = new AntigravitySubscriptionService({ }); const planReminderStore = createPlanReminderStore(workspaceRoot); +const taskLedgerWiring = createMainTaskLedgerWiring(workspaceRoot); +const taskLedgerStore = taskLedgerWiring.store; async function getWorkspacePrivacyContext(): Promise { const settings = await settingsStore.get(); @@ -313,6 +316,7 @@ const systemPromptService = createSystemPromptMainService({ settingsStore, workspaceRoot, localMemory, + taskLedger: taskLedgerStore, }); const mainWindowController = createMainWindowController({ workspaceRoot, @@ -399,6 +403,9 @@ const builtinTools = [ settingsStore, getPrivacyContext: getWorkspacePrivacyContext, }), + // Session task ledger: model manages a flat task list; the current list is + // re-injected each turn tail. Pure local state, so no permission gate. + ...taskLedgerWiring.tools, // The `load_tools` connector is built by ToolAvailabilityRuntime; deferred // group tools just need to be present so they are dispatchable once loaded. ...deferredTools, @@ -584,7 +591,7 @@ backends.register('ai-sdk', async (ctx) => { memoryFragment: memoryPromptSnapshot, childInstruction: ctx.systemPrompt, }), - turnTailPrompt: ({ cwd }) => systemPromptService.buildTurnTailPrompt(cwd), + turnTailPrompt: ({ cwd, sessionId }) => systemPromptService.buildTurnTailPrompt(cwd, sessionId), lookupPricing, recordLlmCall: (event) => recordLlmCall({ repo: telemetryRepo, lookupPricing }, event), recordToolInvocation: (event) => diff --git a/apps/desktop/src/main/system-prompt-main.ts b/apps/desktop/src/main/system-prompt-main.ts index c56dba8ebc..0733c50f2b 100644 --- a/apps/desktop/src/main/system-prompt-main.ts +++ b/apps/desktop/src/main/system-prompt-main.ts @@ -5,8 +5,11 @@ import { botPlatformFromSessionLabels, isDeepResearchSession, redactSecrets, + renderSafeTaskLedgerText, type AppSettings, type SessionHeader, + type Task, + type TaskLedgerStore, } from '@maka/core'; import { buildPersonalizationPromptFragment } from './personalization-prompt.js'; import { resolveProjectGitInfo } from './project-context.js'; @@ -23,6 +26,7 @@ interface SystemPromptMainDeps { settingsStore: SystemPromptSettingsStore; workspaceRoot: string; localMemory: Pick; + taskLedger: Pick; } export function createSystemPromptMainService(deps: SystemPromptMainDeps) { @@ -74,7 +78,7 @@ export function createSystemPromptMainService(deps: SystemPromptMainDeps) { ].filter((fragment): fragment is string => Boolean(fragment)).join('\n\n'); } - async function buildTurnTailPrompt(cwd?: string): Promise { + async function buildTurnTailPrompt(cwd?: string, sessionId?: string): Promise { const fragments: string[] = []; if (cwd) { fragments.push( @@ -86,9 +90,22 @@ export function createSystemPromptMainService(deps: SystemPromptMainDeps) { } const memoryUpdate = buildLocalMemoryUpdateTailFragment(deps.localMemory.consumePendingPromptUpdates()); if (memoryUpdate) fragments.push(memoryUpdate); + const taskLedger = sessionId ? await buildTaskLedgerTailFragment(sessionId) : undefined; + if (taskLedger) fragments.push(taskLedger); return fragments.length > 0 ? fragments.join('\n\n') : undefined; } + // Best-effort: a ledger read failure must never break the turn. An empty + // ledger injects nothing (zero cost when the model isn't tracking tasks). + async function buildTaskLedgerTailFragment(sessionId: string): Promise { + try { + const tasks = await deps.taskLedger.list(sessionId); + return renderTaskLedgerTailFragment(tasks); + } catch { + return undefined; + } + } + async function buildLocalMemoryPromptFragment(): Promise { try { const state = await deps.localMemory.getState(); @@ -130,6 +147,22 @@ function buildLocalMemoryUpdateTailFragment(updates: ReadonlyArray', + // Shared safe renderer: redact secrets, then strip every + // / variant (attributes, whitespace + // before >, self-closing) so a model-authored subject cannot open or close + // the data envelope early. redaction only substitutes '[redacted]', so it + // cannot reintroduce a tag; the strip runs last. + renderSafeTaskLedgerText(tasks), + '', + ].join('\n'); +} + function compactMemoryUpdateText(value: string): string { return redactSecrets(value).replace(/\s+/g, ' ').trim().slice(0, 160); } diff --git a/apps/desktop/src/main/task-ledger-wiring.ts b/apps/desktop/src/main/task-ledger-wiring.ts new file mode 100644 index 0000000000..8c1941b4b3 --- /dev/null +++ b/apps/desktop/src/main/task-ledger-wiring.ts @@ -0,0 +1,26 @@ +import type { TaskLedgerStore } from '@maka/core'; +import { createTaskLedgerStore } from '@maka/storage'; +import { buildTaskLedgerTools, type MakaTool } from '@maka/runtime'; + +/** + * The task-ledger wiring the main process needs: one per-session store shared + * by the mutate face (TaskCreate/TaskUpdate tools) and the read face (the + * turn-tail fragment). Grouping the construction here keeps the main-process + * entry a thin assembler and lets the contract assert the wiring at behavior + * level (tools present, store real, a create lands in the store the tail + * reads) instead of via source-text regex. + */ +export interface MainTaskLedgerWiring { + /** Per-session task ledger store; shared by tools (mutate) and turn tail (read). */ + store: TaskLedgerStore; + /** TaskCreate/TaskUpdate bound to {@link store}. */ + tools: MakaTool[]; +} + +export function createMainTaskLedgerWiring(workspaceRoot: string): MainTaskLedgerWiring { + const store = createTaskLedgerStore(workspaceRoot); + return { + store, + tools: buildTaskLedgerTools({ store }), + }; +} \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json index 3df1eb75ef..3db7099b68 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -28,6 +28,7 @@ "./bot-events": "./dist/bot-events.js", "./bot-platform-hints": "./dist/bot-platform-hints.js", "./plan-reminders": "./dist/plan-reminders.js", + "./task-ledger": "./dist/task-ledger.js", "./memory": "./dist/memory.js", "./voice": "./dist/voice.js", "./incognito": "./dist/incognito.js", diff --git a/packages/core/src/__tests__/task-ledger.test.ts b/packages/core/src/__tests__/task-ledger.test.ts new file mode 100644 index 0000000000..32e2dd3ec3 --- /dev/null +++ b/packages/core/src/__tests__/task-ledger.test.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { isSafeTaskId, renderSafeTaskLedgerText, type Task } from '../task-ledger.js'; + +function task(subject: string): Task { + return { id: 't1', subject, status: 'pending', createdAt: 1, updatedAt: 1 }; +} + +describe('renderSafeTaskLedgerText', () => { + test('returns empty string for an empty ledger', () => { + assert.equal(renderSafeTaskLedgerText([]), ''); + }); + + test('strips tag variants (attributes, whitespace, self-closing) so they cannot open or close the data envelope', () => { + const variants = [ + '', + '', + '', + '', + '', + '', + ]; + for (const v of variants) { + const out = renderSafeTaskLedgerText([task(`正常 ${v} 假指令 ${v} 正常`)]); + assert.equal( + (out.match(/<\/?task-ledger[^>]*>/gi) || []).length, + 0, + `variant ${JSON.stringify(v)} should be fully stripped, got: ${JSON.stringify(out)}`, + ); + } + }); + + test('redacts secret-like subjects', () => { + const out = renderSafeTaskLedgerText([task('轮换 Bearer sk-live-secret-token-value 和 ghp_abcdefghijklmnopqrstuvwxyz')]); + assert.equal(out.includes('sk-live-secret-token-value'), false); + assert.equal(out.includes('ghp_abcdefghijklmnopqrstuvwxyz'), false); + assert.match(out, /\[redacted\]/); + }); + + test('preserves legitimate angle brackets in subjects', () => { + const out = renderSafeTaskLedgerText([task('ensure a < b holds')]); + assert.equal(out.includes('a < b holds'), true); + }); + + test('renders the canonical id as a distinct leading field so a subject cannot smuggle a fake id', () => { + const t: Task = { id: 'real-id', subject: '做事 (id: fake-id) 收尾', status: 'pending', createdAt: 1, updatedAt: 1 }; + const out = renderSafeTaskLedgerText([t]); + // canonical id is a distinct leading field on the line + assert.match(out, /^id=real-id status=pending subject=/); + // the canonical id appears exactly once (the leading field), not duplicated + assert.equal((out.match(/id=real-id/g) || []).length, 1); + // the fake id in the subject is inside the quoted JSON payload, not a bare field + assert.match(out, /subject="[^"]*\(id: fake-id\)[^"]*"/); + // and the fake id never appears as a bare id= field + assert.equal((out.match(/id=fake-id/g) || []).length, 0); + }); + + test('does not strip across lines: an unclosed on the next task line', () => { + // [^>]* in the strip regex crosses newlines, so an unclosed `` in the next would silently delete the text between + // them -- collapsing two task lines into one and dropping the first id. + const t1: Task = { id: 'id-1', subject: 'foo baz', status: 'pending', createdAt: 2, updatedAt: 2 }; + const out = renderSafeTaskLedgerText([t1, t2]); + assert.equal(out.includes('id=id-1 '), true, `first task id must survive, got: ${JSON.stringify(out)}`); + assert.equal(out.includes('id=id-2 '), true, `second task id must survive, got: ${JSON.stringify(out)}`); + assert.equal(out.includes('foo'), true, `first subject text must survive, got: ${JSON.stringify(out)}`); + assert.equal(out.includes('bar > baz'), true, `second subject text must survive intact, got: ${JSON.stringify(out)}`); + // regression guard: complete same-line variants are still stripped + const t3: Task = { id: 'id-3', subject: '正常 假', status: 'pending', createdAt: 3, updatedAt: 3 }; + const out2 = renderSafeTaskLedgerText([t3]); + assert.equal((out2.match(/<\/?task-ledger[^>]*>/gi) || []).length, 0, 'same-line variant must still be stripped'); + }); +}); + +describe('isSafeTaskId', () => { + test('rejects secret-shaped stable tokens that the renderer would redact to [redacted]', () => { + const reject = [ + 'ghp_abcdefghijklmnopqrstuvwxyz', + 'sk-abcdefghi', + 'a'.repeat(40), + 'AIza' + 'X'.repeat(24), + ]; + for (const id of reject) { + assert.equal(isSafeTaskId(id), false, `id ${JSON.stringify(id.slice(0, 24))} must be rejected (renderer would redact it)`); + } + }); + + test('accepts UUID-shaped and simple stable tokens that survive redaction', () => { + const accept = ['123e4567-e89b-12d3-a456-426614174000', 'good-id_1:2', 'id-1']; + for (const id of accept) { + assert.equal(isSafeTaskId(id), true, `id ${id} must pass`); + } + }); +}); \ No newline at end of file diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c88d8487ce..cc22b3245b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -433,6 +433,28 @@ export { normalizePlanReminderTitle, normalizeUpdatePlanReminderInput, } from './plan-reminders.js'; +// task-ledger.ts (main agent session task tracking) +export type { + CreateTaskInput, + Task, + TaskLedgerNormalizeResult, + TaskLedgerStore, + TaskStatus, + UpdateTaskInput, +} from './task-ledger.js'; +export { + TASK_ID_MAX_CHARS, + TASK_LEDGER_MAX_TASKS, + TASK_STATUSES, + TASK_SUBJECT_MAX_CHARS, + isSafeTaskId, + isTaskStatus, + normalizeCreateTaskInput, + normalizeTaskStatus, + normalizeTaskSubject, + normalizeUpdateTaskInput, + renderSafeTaskLedgerText, +} from './task-ledger.js'; // memory.ts (PR-MEMORY-1) — core contract; no IPC/storage/embedding/UI. export type { diff --git a/packages/core/src/task-ledger.ts b/packages/core/src/task-ledger.ts new file mode 100644 index 0000000000..502ef5fd1a --- /dev/null +++ b/packages/core/src/task-ledger.ts @@ -0,0 +1,185 @@ +// Session-scoped task ledger primitive for the main agent. The model manages a +// flat task list via TaskCreate/TaskUpdate; each turn tail re-injects the +// current list. P0 scope is intentionally minimal: no priority, dependency, or +// assignee fields. + +import { redactSecrets } from './redaction.js'; + +export const TASK_SUBJECT_MAX_CHARS = 200; +/** + * Hard cap on total tasks per session ledger (any status). The full ledger is + * re-injected into every turn tail, so an unbounded ledger burns context on + * every turn; this is a runaway guard on the total count, not a workflow quota + * — completing or cancelling tasks does not free capacity. + */ +export const TASK_LEDGER_MAX_TASKS = 200; + +/** + * Max length of a task id accepted on both the write and read paths. The write + * path generates randomUUID (36 chars); the bound leaves headroom for a future + * id format while keeping the turn-tail `id=` fielded render bounded. + */ +export const TASK_ID_MAX_CHARS = 64; + +export const TASK_STATUSES = ['pending', 'in_progress', 'completed', 'cancelled'] as const; +export type TaskStatus = typeof TASK_STATUSES[number]; + +export interface Task { + id: string; + subject: string; + status: TaskStatus; + createdAt: number; + updatedAt: number; +} + +/** + * Store contract shared by the storage implementation and the runtime tools. + * Mutations return the changed task(s) and the new total, computed inside the + * store's serialized write section, so callers render exactly the state their + * mutation produced instead of re-reading outside the write queue. The full + * ledger never leaves the store through the mutation result. + */ +export interface TaskLedgerStore { + list(sessionId: string): Promise; + create(sessionId: string, drafts: unknown): Promise<{ created: Task[]; total: number }>; + update(sessionId: string, id: string, patch: unknown): Promise<{ updated: Task; total: number }>; +} + +export interface CreateTaskInput { + subject: unknown; +} + +export interface UpdateTaskInput { + subject?: unknown; + status?: unknown; +} + +export type TaskLedgerNormalizeResult = + | { ok: true; value: T } + | { + ok: false; + reason: 'invalid_subject' | 'invalid_status' | 'empty_patch'; + message: string; + }; + +type TaskLedgerNormalizeErrorReason = Extract, { ok: false }>['reason']; + +export function isTaskStatus(value: unknown): value is TaskStatus { + return typeof value === 'string' && (TASK_STATUSES as readonly string[]).includes(value); +} + +/** + * Stable-token id contract shared by the runtime tool schema (front-door) and + * the storage read path. The id is rendered verbatim (see + * renderSafeTaskLedgerText), so it must not be deformable by any face that has + * ever rendered it: no angle brackets/slashes/quotes/parens/equals (a past + * whole-string tag strip would have eaten them; even the fielded renderer + * emits the id bare), no whitespace (would break the list-line structure), no + * huge length (would bloat every turn tail), and redaction-stable (a renderer + * that runs redactSecrets must not turn the id into [redacted] while the store + * keeps the real id -- a later TaskUpdate would miss). The whitelist + * (alphanumeric plus . _ : -, 1-64 chars) plus redactSecrets(id) === id enforces + * these constraints without coupling to the UUID format. + */ +export function isSafeTaskId(value: unknown): value is string { + // Stable token (alphanumeric plus . _ : -, 1-64 chars) AND redaction-stable: + // the id is rendered verbatim, so a secret-shaped id (ghp_..., sk-..., a + // 40-char hex, AIza...) must be rejected -- otherwise a renderer that does + // run redactSecrets would turn it into [redacted] while the store keeps the + // real id, and a later TaskUpdate would miss. + return typeof value === 'string' + && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(value) + && redactSecrets(value) === value; +} + +export function normalizeTaskSubject(input: unknown): TaskLedgerNormalizeResult { + if (typeof input !== 'string') { + return invalid('invalid_subject', 'Task subject must be a string'); + } + const subject = input.normalize('NFC').replace(/\s+/g, ' ').trim(); + if (subject.length === 0) { + return invalid('invalid_subject', 'Task subject cannot be empty'); + } + if (Array.from(subject).length > TASK_SUBJECT_MAX_CHARS) { + return invalid('invalid_subject', `Task subject must be ${TASK_SUBJECT_MAX_CHARS} characters or fewer`); + } + return { ok: true, value: subject }; +} + +export function normalizeTaskStatus(input: unknown): TaskLedgerNormalizeResult { + if (!isTaskStatus(input)) { + return invalid('invalid_status', `Task status must be one of ${TASK_STATUSES.join(', ')}`); + } + return { ok: true, value: input }; +} + +export function normalizeCreateTaskInput(input: unknown): TaskLedgerNormalizeResult<{ subject: string }> { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + return invalid('invalid_subject', 'Task input must be an object'); + } + const record = input as CreateTaskInput; + const subject = normalizeTaskSubject(record.subject); + if (!subject.ok) return subject; + return { ok: true, value: { subject: subject.value } }; +} + +export function normalizeUpdateTaskInput( + input: unknown, +): TaskLedgerNormalizeResult<{ subject?: string; status?: TaskStatus }> { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + return invalid('empty_patch', 'Task update must be an object'); + } + const record = input as UpdateTaskInput; + const patch: { subject?: string; status?: TaskStatus } = {}; + if (record.subject !== undefined) { + const subject = normalizeTaskSubject(record.subject); + if (!subject.ok) return subject; + patch.subject = subject.value; + } + if (record.status !== undefined) { + const status = normalizeTaskStatus(record.status); + if (!status.ok) return status; + patch.status = status.value; + } + if (patch.subject === undefined && patch.status === undefined) { + return invalid('empty_patch', 'Task update must change at least one of subject or status'); + } + return { ok: true, value: patch }; +} + +/** + * Safe-render the task ledger for any face that persists into history or is + * re-injected into a prompt (tool results, turn-tail fragment). Two invariants: + * - the canonical id is rendered verbatim, and the subject is a safe + * (redacted, tag-stripped) rendered payload of what the store holds; and + * - the model can unambiguously recover each task's id from what it sees, so + * a later TaskUpdate hits the right task. + * + * Rendering is per-task and fielded, not a free-text bullet: each line is + * `id= status= subject=`. The + * canonical id is a distinct leading field, so a subject cannot smuggle a fake + * `id=` field or any other id-like span past it -- any id-like text in the subject stays + * inside the quoted JSON payload. The id is emitted verbatim: it is a + * redaction-stable stable token validated on write and read, so scrubbing it + * could only deform it (and break TaskUpdate); it must not be redacted or + * tag-stripped. Each subject is redacted (secrets) and tag-stripped (complete + * `` / `` tags on a single line, so a + * model-authored subject cannot open or close the data envelope) + * independently -- a subject on one task can never eat or deform text on + * another task's line. Other angle brackets (e.g. `a < b`) are left intact. + * Returns '' for an empty ledger. + */ +export function renderSafeTaskLedgerText(tasks: readonly Task[]): string { + if (tasks.length === 0) return ''; + return tasks.map((task) => { + const safeSubject = redactSecrets(task.subject).replace(/<\/?task-ledger[^\n>]*>/gi, ''); + return `id=${task.id} status=${task.status} subject=${JSON.stringify(safeSubject)}`; + }).join('\n'); +} + +function invalid( + reason: T, + message: string, +): Extract, { ok: false }> { + return { ok: false, reason, message }; +} diff --git a/packages/runtime/src/__tests__/task-ledger-tools.test.ts b/packages/runtime/src/__tests__/task-ledger-tools.test.ts new file mode 100644 index 0000000000..e8a882d545 --- /dev/null +++ b/packages/runtime/src/__tests__/task-ledger-tools.test.ts @@ -0,0 +1,214 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { z } from 'zod'; +import { TASK_LEDGER_MAX_TASKS, TASK_SUBJECT_MAX_CHARS, type Task, type TaskLedgerStore } from '@maka/core/task-ledger'; +import { + TASK_CREATE_TOOL_NAME, + TASK_UPDATE_TOOL_NAME, + buildTaskLedgerTools, +} from '../task-ledger-tools.js'; +import type { MakaTool, MakaToolContext } from '../tool-runtime.js'; + +const SESSION_ID = 'sess-1'; + +class FakeTaskLedgerStore implements TaskLedgerStore { + private tasks: Task[] = []; + public createCalls: Array<{ sessionId: string; drafts: unknown }> = []; + public updateCalls: Array<{ sessionId: string; id: string; patch: unknown }> = []; + + async list(): Promise { + return this.tasks.map((t) => ({ ...t })); + } + + async create(sessionId: string, drafts: unknown): Promise<{ created: Task[]; total: number }> { + this.createCalls.push({ sessionId, drafts }); + const now = Date.now(); + const created = (drafts as Array<{ subject: string }>).map((d, i) => ({ + id: `id-${this.tasks.length + i}`, + subject: d.subject, + status: 'pending' as const, + createdAt: now, + updatedAt: now, + })); + this.tasks.push(...created); + return { created, total: this.tasks.length }; + } + + async update(sessionId: string, id: string, patch: unknown): Promise<{ updated: Task; total: number }> { + this.updateCalls.push({ sessionId, id, patch }); + const task = this.tasks.find((t) => t.id === id); + if (!task) throw new Error(`No such task: ${id}`); + Object.assign(task, patch, { updatedAt: Date.now() }); + return { updated: { ...task }, total: this.tasks.length }; + } +} + +function fakeContext(sessionId: string): MakaToolContext { + return { + sessionId, + turnId: 'turn-1', + cwd: '/tmp', + toolCallId: 'call-1', + abortSignal: new AbortController().signal, + emitOutput: () => {}, + }; +} + +function findTool(tools: MakaTool[], name: string): MakaTool { + const tool = tools.find((t) => t.name === name); + assert.ok(tool, `expected tool ${name}`); + return tool; +} + +describe('task ledger tools', () => { + test('builds exactly TaskCreate and TaskUpdate, both local (no permission gate)', () => { + const tools = buildTaskLedgerTools({ store: new FakeTaskLedgerStore() }); + assert.deepEqual(tools.map((t) => t.name), [TASK_CREATE_TOOL_NAME, TASK_UPDATE_TOOL_NAME]); + for (const tool of tools) { + assert.equal(tool.permissionRequired, false, `${tool.name} must not require permission`); + } + }); + + test('TaskCreate schema rejects a batch larger than the ledger cap and accepts the cap boundary', () => { + const create = findTool(buildTaskLedgerTools({ store: new FakeTaskLedgerStore() }), TASK_CREATE_TOOL_NAME); + const params = create.parameters as z.ZodType; + const atCap = { tasks: Array.from({ length: TASK_LEDGER_MAX_TASKS }, () => ({ subject: 'x' })) }; + assert.equal(params.safeParse(atCap).success, true, `${TASK_LEDGER_MAX_TASKS} tasks (cap) must pass`); + const overCap = { tasks: Array.from({ length: TASK_LEDGER_MAX_TASKS + 1 }, () => ({ subject: 'x' })) }; + assert.equal(params.safeParse(overCap).success, false, `${TASK_LEDGER_MAX_TASKS + 1} tasks must be rejected at the schema`); + }); + + test('TaskUpdate schema rejects ids that are not stable tokens and accepts UUID-shaped / simple ids', () => { + const update = findTool(buildTaskLedgerTools({ store: new FakeTaskLedgerStore() }), TASK_UPDATE_TOOL_NAME); + const params = update.parameters as z.ZodType; + const reject = ['ab', 'abc\ndef', 'a b', 'X'.repeat(5000), '', 'ghp_abcdefghijklmnopqrstuvwxyz', 'sk-abcdefghi', 'a'.repeat(40)]; + for (const id of reject) { + assert.equal(params.safeParse({ id, status: 'completed' }).success, false, `id ${JSON.stringify(id)} must be rejected`); + } + const accept = ['123e4567-e89b-12d3-a456-426614174000', 'good-id_1:2']; + for (const id of accept) { + assert.equal(params.safeParse({ id, status: 'completed' }).success, true, `id ${id} must pass`); + } + }); + + test('TaskCreate forwards drafts to the store using ctx.sessionId and renders the returned ledger', async () => { + const store = new FakeTaskLedgerStore(); + const create = findTool(buildTaskLedgerTools({ store }), TASK_CREATE_TOOL_NAME); + const result = await create.impl({ tasks: [{ subject: '写测试' }, { subject: '实现' }] }, fakeContext(SESSION_ID)); + assert.equal(store.createCalls.length, 1); + assert.equal(store.createCalls[0]?.sessionId, SESSION_ID); + assert.match(String(result), /写测试/); + assert.match(String(result), /实现/); + assert.match(String(result), /pending/); + }); + + test('TaskUpdate forwards only provided fields and renders the returned ledger', async () => { + const store = new FakeTaskLedgerStore(); + const tools = buildTaskLedgerTools({ store }); + const create = findTool(tools, TASK_CREATE_TOOL_NAME); + const update = findTool(tools, TASK_UPDATE_TOOL_NAME); + await create.impl({ tasks: [{ subject: '原始' }] }, fakeContext(SESSION_ID)); + + const result = await update.impl({ id: 'id-0', status: 'in_progress' }, fakeContext(SESSION_ID)); + assert.deepEqual(store.updateCalls[0]?.patch, { status: 'in_progress' }); + assert.match(String(result), /in_progress/); + }); + + test('TaskCreate result shows only the created tasks (with ids) and total, not the pre-existing ledger', async () => { + const store = new FakeTaskLedgerStore(); + const tools = buildTaskLedgerTools({ store }); + const create = findTool(tools, TASK_CREATE_TOOL_NAME); + // a pre-existing task that must NOT be replayed in the create result + await create.impl({ tasks: [{ subject: 'pre-existing' }] }, fakeContext(SESSION_ID)); + const result = String(await create.impl({ tasks: [{ subject: 'new-task' }] }, fakeContext(SESSION_ID))); + assert.match(result, /new-task/, 'result must include the created task'); + assert.match(result, /ledger total: 2/, 'result must include the ledger total'); + assert.equal(result.includes('pre-existing'), false, 'result must not replay the pre-existing ledger'); + // the new task's id is present so the model can update it next + const all = await store.list(); + const newId = all.find((t) => t.subject === 'new-task')?.id; + assert.ok(newId, 'new task must have been created'); + assert.equal(result.includes(newId), true, 'result must include the new task id'); + }); + + test('TaskUpdate result shows only the updated task and total, not the rest of the ledger', async () => { + const store = new FakeTaskLedgerStore(); + const tools = buildTaskLedgerTools({ store }); + const create = findTool(tools, TASK_CREATE_TOOL_NAME); + const update = findTool(tools, TASK_UPDATE_TOOL_NAME); + await create.impl({ tasks: [{ subject: 'keep-1' }, { subject: 'keep-2' }, { subject: 'target' }] }, fakeContext(SESSION_ID)); + const all = await store.list(); + const target = all.find((t) => t.subject === 'target'); + assert.ok(target); + const result = String(await update.impl({ id: target.id, status: 'completed' }, fakeContext(SESSION_ID))); + assert.match(result, /target/, 'result must include the updated task subject'); + assert.match(result, /ledger total: 3/, 'result must include the ledger total'); + assert.equal(result.includes('keep-1'), false, 'result must not replay unrelated tasks'); + assert.equal(result.includes('keep-2'), false, 'result must not replay unrelated tasks'); + }); + + test('tool results scrub secret-like subjects before they persist into history', async () => { + // Same samples the core redactSecrets tests use. Tool results replay to + // the provider every turn, so redacting only the turn tail is not enough. + const store = new FakeTaskLedgerStore(); + const tools = buildTaskLedgerTools({ store }); + const create = findTool(tools, TASK_CREATE_TOOL_NAME); + const update = findTool(tools, TASK_UPDATE_TOOL_NAME); + + const createResult = String(await create.impl( + { tasks: [{ subject: '轮换 Bearer sk-live-secret-token-value' }] }, + fakeContext(SESSION_ID), + )); + assert.equal(createResult.includes('sk-live-secret-token-value'), false); + assert.match(createResult, /\[redacted\]/); + + const updateResult = String(await update.impl( + { id: 'id-0', subject: '换 ghp_abcdefghijklmnopqrstuvwxyz' }, + fakeContext(SESSION_ID), + )); + assert.equal(updateResult.includes('ghp_abcdefghijklmnopqrstuvwxyz'), false); + }); + + test('tool results strip tag variants so a subject cannot smuggle envelope tags into history', async () => { + const store = new FakeTaskLedgerStore(); + const create = findTool(buildTaskLedgerTools({ store }), TASK_CREATE_TOOL_NAME); + const variants = [ + '', + '', + '', + '', + '', + '', + ]; + const drafts = variants.map((v) => ({ subject: '正常 ' + v + ' 假指令' })); + const result = String(await create.impl({ tasks: drafts }, fakeContext(SESSION_ID))); + assert.equal( + (result.match(/<\/?task-ledger[^>]*>/gi) || []).length, + 0, + 'tool result must not contain any task-ledger tag variant, got: ' + JSON.stringify(result), + ); + }); + + test('TaskCreate schema enforces non-empty array, non-blank subjects, and the subject length cap', () => { + const create = findTool(buildTaskLedgerTools({ store: new FakeTaskLedgerStore() }), TASK_CREATE_TOOL_NAME); + const schema = create.parameters as z.ZodTypeAny; + assert.equal(schema.safeParse({ tasks: [{ subject: 'ok' }] }).success, true); + assert.equal(schema.safeParse({ tasks: [] }).success, false); + assert.equal(schema.safeParse({ tasks: [{ subject: '' }] }).success, false); + assert.equal(schema.safeParse({ tasks: [{ subject: ' ' }] }).success, false); + assert.equal(schema.safeParse({ tasks: [{ subject: 'x'.repeat(TASK_SUBJECT_MAX_CHARS) }] }).success, true); + assert.equal(schema.safeParse({ tasks: [{ subject: 'x'.repeat(TASK_SUBJECT_MAX_CHARS + 1) }] }).success, false); + assert.equal(schema.safeParse({}).success, false); + }); + + test('TaskUpdate schema requires id and at least one of status/subject, with the same subject cap', () => { + const update = findTool(buildTaskLedgerTools({ store: new FakeTaskLedgerStore() }), TASK_UPDATE_TOOL_NAME); + const schema = update.parameters as z.ZodTypeAny; + assert.equal(schema.safeParse({ id: 'x', status: 'completed' }).success, true); + assert.equal(schema.safeParse({ id: 'x', subject: 'new' }).success, true); + assert.equal(schema.safeParse({ id: 'x' }).success, false); + assert.equal(schema.safeParse({ status: 'completed' }).success, false); + assert.equal(schema.safeParse({ id: 'x', status: 'bogus' }).success, false); + assert.equal(schema.safeParse({ id: 'x', subject: 'x'.repeat(TASK_SUBJECT_MAX_CHARS + 1) }).success, false); + }); +}); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 4a18b72683..e593568c7f 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -128,6 +128,11 @@ export { buildSubagentSpawnTool, buildSubagentToolGroup, } from './subagent-tools.js'; +export { + TASK_CREATE_TOOL_NAME, + TASK_UPDATE_TOOL_NAME, + buildTaskLedgerTools, +} from './task-ledger-tools.js'; export { deriveToolArtifactCandidates, extractStdoutRedirectPath, diff --git a/packages/runtime/src/task-ledger-tools.ts b/packages/runtime/src/task-ledger-tools.ts new file mode 100644 index 0000000000..952543f789 --- /dev/null +++ b/packages/runtime/src/task-ledger-tools.ts @@ -0,0 +1,83 @@ +import { z } from 'zod'; +import { + TASK_STATUSES, + TASK_SUBJECT_MAX_CHARS, + TASK_LEDGER_MAX_TASKS, + TASK_ID_MAX_CHARS, + isSafeTaskId, + renderSafeTaskLedgerText, + type Task, + type TaskLedgerStore, +} from '@maka/core/task-ledger'; +import type { MakaTool } from './tool-runtime.js'; + +// PascalCase matches the model-facing builtin tools (Bash/Read/Write); the +// snake_case convention is reserved for the agent-orchestration family +// (agent_spawn/agent_list). +export const TASK_CREATE_TOOL_NAME = 'TaskCreate'; +export const TASK_UPDATE_TOOL_NAME = 'TaskUpdate'; + +export function buildTaskLedgerTools(deps: { store: TaskLedgerStore }): MakaTool[] { + return [buildTaskCreateTool(deps.store), buildTaskUpdateTool(deps.store)]; +} + +function buildTaskCreateTool(store: TaskLedgerStore): MakaTool<{ tasks: Array<{ subject: string }> }, string> { + return { + name: TASK_CREATE_TOOL_NAME, + displayName: 'Task Create', + description: + 'Add one or more tasks to the session task ledger. The full updated ledger is re-shown each turn, ' + + 'so use this to record work you plan to do; update status with TaskUpdate as you progress.', + parameters: z.object({ + tasks: z.array(z.object({ + subject: z.string().trim().min(1).max(TASK_SUBJECT_MAX_CHARS) + .describe(`Short imperative description of the task (max ${TASK_SUBJECT_MAX_CHARS} characters).`), + })).min(1).max(TASK_LEDGER_MAX_TASKS).describe('One or more tasks to add. Each starts in the pending state.'), + }), + // Pure local session state, no external side effect (cf. agent_list). + permissionRequired: false, + impl: async (input, ctx) => { + const { created, total } = await store.create(ctx.sessionId, input.tasks); + // Tool results persist into session history and replay every turn; the + // turn tail already re-injects the full ledger each turn, so the tool + // result only echoes the created tasks (with their ids, so the model can + // update them next) and the new total -- not the whole ledger, which would + // duplicate the tail and bloat history under a large ledger. + return `Created ${created.length} task(s); ledger total: ${total}.\n${renderSafeTaskLedgerText(created)}`; + }, + }; +} + +function buildTaskUpdateTool( + store: TaskLedgerStore, +): MakaTool<{ id: string; status?: typeof TASK_STATUSES[number]; subject?: string }, string> { + return { + name: TASK_UPDATE_TOOL_NAME, + displayName: 'Task Update', + description: + 'Update a task in the session task ledger by id. Provide status and/or a revised subject. ' + + 'Mark tasks in_progress when you start them and completed (or cancelled) when done.', + parameters: z.object({ + id: z.string().min(1).max(TASK_ID_MAX_CHARS).refine(isSafeTaskId, 'Task id must be a stable token (alphanumeric plus . _ : -, max 64 chars) from the current ledger.').describe('Task id from the current ledger.'), + status: z.enum(TASK_STATUSES).optional().describe('New task status.'), + subject: z.string().trim().min(1).max(TASK_SUBJECT_MAX_CHARS).optional() + .describe(`Revised task description (max ${TASK_SUBJECT_MAX_CHARS} characters).`), + }).superRefine((input, ctx) => { + if (input.status === undefined && input.subject === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Provide at least one of status or subject.', + }); + } + }), + permissionRequired: false, + impl: async (input, ctx) => { + const { updated, total } = await store.update(ctx.sessionId, input.id, { + ...(input.status !== undefined ? { status: input.status } : {}), + ...(input.subject !== undefined ? { subject: input.subject } : {}), + }); + return `Updated 1 task; ledger total: ${total}.\n${renderSafeTaskLedgerText([updated])}`; + }, + }; +} + diff --git a/packages/storage/src/__tests__/task-ledger-store.test.ts b/packages/storage/src/__tests__/task-ledger-store.test.ts new file mode 100644 index 0000000000..ef6086f089 --- /dev/null +++ b/packages/storage/src/__tests__/task-ledger-store.test.ts @@ -0,0 +1,319 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { TASK_LEDGER_MAX_TASKS, TASK_SUBJECT_MAX_CHARS } from '@maka/core/task-ledger'; +import { createTaskLedgerStore } from '../task-ledger-store.js'; + +const SESSION_ID = 'sess-abc'; + +async function tempRoot(): Promise { + return mkdtemp(join(tmpdir(), 'maka-task-ledger-')); +} + +function tasksFilePath(root: string): string { + return join(root, 'sessions', SESSION_ID, 'tasks.json'); +} + +describe('TaskLedgerStore', () => { + it('creates tasks with normalized subjects and pending status, returning created tasks and the new total', async () => { + const root = await tempRoot(); + const store = createTaskLedgerStore(root); + + const { created, total } = await store.create(SESSION_ID, [{ subject: ' 写测试 ' }, { subject: '实现功能' }]); + assert.equal(created.length, 2); + assert.equal(created[0]?.subject, '写测试'); + assert.equal(created[0]?.status, 'pending'); + assert.equal(typeof created[0]?.id, 'string'); + assert.equal(created[0]?.createdAt, created[0]?.updatedAt); + assert.equal(total, 2); + + const reloaded = await createTaskLedgerStore(root).list(SESSION_ID); + assert.equal(reloaded.length, 2); + assert.deepEqual(reloaded.map((t) => t.subject), ['写测试', '实现功能']); + + const raw = JSON.parse(await readFile(tasksFilePath(root), 'utf8')) as unknown[]; + assert.equal(raw.length, 2); + }); + + it('lists an empty ledger when the file does not exist', async () => { + const root = await tempRoot(); + assert.deepEqual(await createTaskLedgerStore(root).list(SESSION_ID), []); + }); + + it('updates a task status and subject, returning the updated task and the new total', async () => { + const root = await tempRoot(); + const store = createTaskLedgerStore(root); + const { created: [task], total: afterCreate } = await store.create(SESSION_ID, [{ subject: '原始' }, { subject: '其他' }]); + assert.ok(task); + assert.equal(afterCreate, 2); + + const { updated, total } = await store.update(SESSION_ID, task.id, { status: 'in_progress', subject: '改过' }); + assert.equal(updated.status, 'in_progress'); + assert.equal(updated.subject, '改过'); + assert.ok(updated.updatedAt >= task.updatedAt); + assert.equal(updated.createdAt, task.createdAt); + // total is the post-mutation count from inside the write queue; re-read the + // ledger to verify the updated task landed and the file matches it. + assert.equal(total, 2); + const all = await store.list(SESSION_ID); + assert.deepEqual(all.find((t) => t.id === task.id), updated); + + const reloaded = await createTaskLedgerStore(root).list(SESSION_ID); + assert.deepEqual(reloaded, all); + }); + + it('rejects an unknown task id, an empty patch, an invalid status, and empty create drafts', async () => { + const root = await tempRoot(); + const store = createTaskLedgerStore(root); + const { created: [task] } = await store.create(SESSION_ID, [{ subject: 'x' }]); + assert.ok(task); + + await assert.rejects(() => store.update(SESSION_ID, 'no-such-id', { status: 'completed' }), /No such task/); + await assert.rejects(() => store.update(SESSION_ID, task.id, {}), /at least one/); + await assert.rejects(() => store.update(SESSION_ID, task.id, { status: 'bogus' }), /Task status/); + await assert.rejects(() => store.create(SESSION_ID, []), /at least one/); + await assert.rejects(() => store.create(SESSION_ID, [{ subject: ' ' }]), /empty/); + }); + + it('does not rewrite the file when the update target does not exist', async () => { + const root = await tempRoot(); + const store = createTaskLedgerStore(root); + await store.create(SESSION_ID, [{ subject: 'x' }]); + const before = await readFile(tasksFilePath(root), 'utf8'); + + await assert.rejects(() => store.update(SESSION_ID, 'no-such-id', { status: 'completed' }), /No such task/); + + const after = await readFile(tasksFilePath(root), 'utf8'); + assert.equal(after, before); + }); + + it('degrades a corrupt ledger to an empty list on the render path', async () => { + const root = await tempRoot(); + await mkdir(join(root, 'sessions', SESSION_ID), { recursive: true }); + await writeFile(tasksFilePath(root), 'not json at all', 'utf8'); + assert.deepEqual(await createTaskLedgerStore(root).list(SESSION_ID), []); + }); + + it('refuses to mutate over a corrupt ledger and leaves the file untouched', async () => { + const root = await tempRoot(); + await mkdir(join(root, 'sessions', SESSION_ID), { recursive: true }); + const store = createTaskLedgerStore(root); + + for (const corrupt of ['not json at all', '{"not":"an array"}']) { + await writeFile(tasksFilePath(root), corrupt, 'utf8'); + await assert.rejects( + () => store.create(SESSION_ID, [{ subject: '新任务' }]), + /corrupt; refusing to overwrite/, + ); + await assert.rejects( + () => store.update(SESSION_ID, 'any-id', { status: 'completed' }), + /corrupt; refusing to overwrite/, + ); + // The mutation must not have replaced the damaged file with fn([]). + assert.equal(await readFile(tasksFilePath(root), 'utf8'), corrupt); + // The render path still degrades to empty so turns are not wedged. + assert.deepEqual(await store.list(SESSION_ID), []); + } + }); + + it('drops malformed entries while keeping valid ones', async () => { + const root = await tempRoot(); + await mkdir(join(root, 'sessions', SESSION_ID), { recursive: true }); + await writeFile(tasksFilePath(root), JSON.stringify([ + { id: 'good', subject: '有效', status: 'pending', createdAt: 1, updatedAt: 1 }, + { id: 'bad-status', subject: 'x', status: 'nope', createdAt: 1, updatedAt: 1 }, + { subject: 'no id', status: 'pending', createdAt: 1, updatedAt: 1 }, + 'garbage', + ]), 'utf8'); + const tasks = await createTaskLedgerStore(root).list(SESSION_ID); + assert.equal(tasks.length, 1); + assert.equal(tasks[0]?.id, 'good'); + }); + + it('re-applies subject normalization on read: discards overlong/blank/empty subjects and normalizes whitespace', async () => { + const root = await tempRoot(); + await mkdir(join(root, 'sessions', SESSION_ID), { recursive: true }); + await writeFile(tasksFilePath(root), JSON.stringify([ + { id: 'overlong', subject: 'X'.repeat(TASK_SUBJECT_MAX_CHARS + 1), status: 'pending', createdAt: 1, updatedAt: 1 }, + { id: 'blank', subject: ' ', status: 'pending', createdAt: 1, updatedAt: 1 }, + { id: 'empty', subject: '', status: 'pending', createdAt: 1, updatedAt: 1 }, + { id: 'whitespace', subject: 'a\t\tb\n\nc d', status: 'pending', createdAt: 1, updatedAt: 1 }, + { id: 'good', subject: '有效', status: 'pending', createdAt: 1, updatedAt: 1 }, + ]), 'utf8'); + const tasks = await createTaskLedgerStore(root).list(SESSION_ID); + // overlong/blank/empty subjects are discarded per-record; good + whitespace survive. + assert.equal(tasks.length, 2, `expected 2 surviving tasks, got ${tasks.length}: ${JSON.stringify(tasks.map((t) => t.id))}`); + const ids = tasks.map((t) => t.id); + assert.ok(ids.includes('good')); + assert.ok(ids.includes('whitespace')); + // whitespace subject is normalized (collapse + trim) on read. + const ws = tasks.find((t) => t.id === 'whitespace'); + assert.equal(ws?.subject, 'a b c d', `expected normalized subject, got ${JSON.stringify(ws?.subject)}`); + }); + + it('treats an over-cap tasks.json as corrupt: list() rejects and mutate stays fail-closed', async () => { + const root = await tempRoot(); + await mkdir(join(root, 'sessions', SESSION_ID), { recursive: true }); + const overcap = Array.from({ length: TASK_LEDGER_MAX_TASKS + 1 }, (_, i) => ({ + id: `cap-${i}`, subject: `任务${i}`, status: 'pending', createdAt: i, updatedAt: i, + })); + await writeFile(tasksFilePath(root), JSON.stringify(overcap), 'utf8'); + const store = createTaskLedgerStore(root); + // render path degrades to an empty list (readForRender try/catches the over-cap file) + assert.deepEqual(await store.list(SESSION_ID), []); + // mutate path stays fail-closed: a create must not silently truncate-and-overwrite the over-cap file + await assert.rejects(() => store.create(SESSION_ID, [{ subject: '新任务' }]), /corrupt|limit|exceed/i); + // the file is left untouched (not truncated) + const raw = await readFile(tasksFilePath(root), 'utf8'); + assert.equal(JSON.parse(raw).length, TASK_LEDGER_MAX_TASKS + 1); + }); + + it('treats a tasks.json with duplicate ids as corrupt: render degrades to empty, mutate stays fail-closed', async () => { + const root = await tempRoot(); + await mkdir(join(root, 'sessions', SESSION_ID), { recursive: true }); + await writeFile(tasksFilePath(root), JSON.stringify([ + { id: 'dup-id', subject: 'first', status: 'pending', createdAt: 1, updatedAt: 1 }, + { id: 'dup-id', subject: 'second', status: 'pending', createdAt: 2, updatedAt: 2 }, + { id: 'uniq', subject: 'unique', status: 'pending', createdAt: 3, updatedAt: 3 }, + ]), 'utf8'); + const store = createTaskLedgerStore(root); + // render path degrades to empty: no duplicate id reaches the turn tail + // (two same-id tasks would be indistinguishable to the model). + assert.deepEqual(await store.list(SESSION_ID), []); + // mutate path stays fail-closed: an update must not silently keep both + // dups and rewrite a "half-correct" file (first updated, second stale). + await assert.rejects(() => store.update(SESSION_ID, 'dup-id', { status: 'completed' }), /corrupt|duplicate|ambiguous/i); + const raw = await readFile(tasksFilePath(root), 'utf8'); + assert.equal(JSON.parse(raw).length, 3, 'file must be left untouched'); + }); + + it('rejects non-finite timestamps (1e999 -> Infinity) so they cannot round-trip to null and vanish', async () => { + const root = await tempRoot(); + await mkdir(join(root, 'sessions', SESSION_ID), { recursive: true }); + // raw JSON with 1e999, which JSON.parse reads as Infinity; JSON.stringify of + // Infinity is null, so writing via JSON.stringify could not reproduce this -- + // only a hand-edited or legacy file carries it. + await writeFile(tasksFilePath(root), + '[{"id":"good","subject":"ok","status":"pending","createdAt":1,"updatedAt":1},' + + '{"id":"bad-ts","subject":"inf","status":"pending","createdAt":1e999,"updatedAt":1e999}]', + 'utf8'); + const store = createTaskLedgerStore(root); + // read path drops the non-finite record (render degrades to the valid one) + assert.deepEqual((await store.list(SESSION_ID)).map((t) => t.id), ['good']); + // mutate path: a create must not round-trip the Infinity to null + await store.create(SESSION_ID, [{ subject: 'after' }]); + const raw = JSON.parse(await readFile(tasksFilePath(root), 'utf8')) as Array<{ createdAt: unknown; updatedAt: unknown }>; + for (const r of raw) { + assert.equal(Number.isFinite(r.createdAt), true, `createdAt must stay finite after mutate, got ${JSON.stringify(r)}`); + assert.equal(Number.isFinite(r.updatedAt), true, `updatedAt must stay finite after mutate, got ${JSON.stringify(r)}`); + } + }); + + it('rejects records with unsafe ids (newline, overlong, empty, whitespace) on read', async () => { + const root = await tempRoot(); + await mkdir(join(root, 'sessions', SESSION_ID), { recursive: true }); + await writeFile(tasksFilePath(root), JSON.stringify([ + { id: 'abc\nINJECTED', subject: '换行id', status: 'pending', createdAt: 1, updatedAt: 1 }, + { id: 'X'.repeat(5000), subject: '超长id', status: 'pending', createdAt: 2, updatedAt: 2 }, + { id: '', subject: '空id', status: 'pending', createdAt: 3, updatedAt: 3 }, + { id: 'has space', subject: '带空格id', status: 'pending', createdAt: 4, updatedAt: 4 }, + { id: 'good-id', subject: '正常', status: 'pending', createdAt: 5, updatedAt: 5 }, + ]), 'utf8'); + const tasks = await createTaskLedgerStore(root).list(SESSION_ID); + assert.equal(tasks.length, 1, `expected only the safe-id record to survive, got ${JSON.stringify(tasks.map((t) => t.id))}`); + assert.equal(tasks[0]?.id, 'good-id'); + }); + + it('rejects ids that are not redaction-stable tokens (tag-like, angle brackets, quotes, parens, secret-shaped); keeps UUID-shaped and simple ids', async () => { + const root = await tempRoot(); + await mkdir(join(root, 'sessions', SESSION_ID), { recursive: true }); + await writeFile(tasksFilePath(root), JSON.stringify([ + { id: 'ab', subject: 'tag-like', status: 'pending', createdAt: 1, updatedAt: 1 }, + { id: 'a>b', subject: 'gt', status: 'pending', createdAt: 2, updatedAt: 2 }, + { id: 'a"b', subject: 'quote', status: 'pending', createdAt: 3, updatedAt: 3 }, + { id: 'a(b)', subject: 'paren', status: 'pending', createdAt: 4, updatedAt: 4 }, + { id: 'a=b', subject: 'equals', status: 'pending', createdAt: 5, updatedAt: 5 }, + // secret-shaped stable tokens: pass the charset/length rules but redactSecrets + // would render them as (id: [redacted]), so TaskUpdate on [redacted] would miss. + { id: 'ghp_abcdefghijklmnopqrstuvwxyz', subject: 'ghp', status: 'pending', createdAt: 6, updatedAt: 6 }, + { id: 'sk-abcdefghi', subject: 'sk', status: 'pending', createdAt: 7, updatedAt: 7 }, + { id: 'a'.repeat(40), subject: 'hex40', status: 'pending', createdAt: 8, updatedAt: 8 }, + { id: 'AIza' + 'X'.repeat(24), subject: 'aiza', status: 'pending', createdAt: 9, updatedAt: 9 }, + { id: '123e4567-e89b-12d3-a456-426614174000', subject: 'uuid', status: 'pending', createdAt: 10, updatedAt: 10 }, + { id: 'good-id_1:2', subject: 'simple', status: 'pending', createdAt: 11, updatedAt: 11 }, + ]), 'utf8'); + const tasks = await createTaskLedgerStore(root).list(SESSION_ID); + const ids = tasks.map((t) => t.id); + // Only ids that are stable tokens AND survive redaction (so the rendered id + // equals the stored id) survive; a TaskUpdate on the rendered id then hits. + assert.deepEqual(ids, ['123e4567-e89b-12d3-a456-426614174000', 'good-id_1:2']); + }); + + it('rejects an oversized batch before generating tasks or writing (existing ledger unchanged)', async () => { + const root = await tempRoot(); + const store = createTaskLedgerStore(root); + await store.create(SESSION_ID, [{ subject: 'seed' }]); + const before = await readFile(tasksFilePath(root), 'utf8'); + // Oversized batch with an invalid draft in the middle: without an early + // batch-size check, normalizeCreateTaskInput runs during `drafts.map` and + // throws the per-draft subject error; with the early check, the batch is + // rejected as a batch before any draft is touched or any id is generated. + const batch = Array.from({ length: TASK_LEDGER_MAX_TASKS + 5 }, (_, i) => + i === 2 ? { subject: '' } : { subject: `任务${i}` }); + await assert.rejects(() => store.create(SESSION_ID, batch), /cap|limit|exceed|batch/i); + const after = await readFile(tasksFilePath(root), 'utf8'); + assert.equal(after, before, 'existing ledger must be unchanged'); + }); + + it('rejects an unsafe session id', async () => { + const root = await tempRoot(); + const store = createTaskLedgerStore(root); + await assert.rejects(() => store.list('../escape'), /Invalid session id/); + }); + + it('serializes concurrent creates without losing writes', async () => { + const root = await tempRoot(); + const store = createTaskLedgerStore(root); + await Promise.all([ + store.create(SESSION_ID, [{ subject: 'a' }]), + store.create(SESSION_ID, [{ subject: 'b' }]), + store.create(SESSION_ID, [{ subject: 'c' }]), + ]); + const tasks = await store.list(SESSION_ID); + assert.equal(tasks.length, 3); + assert.deepEqual(new Set(tasks.map((t) => t.subject)), new Set(['a', 'b', 'c'])); + }); + + it('enforces the total-task cap inside the write queue without touching the file', async () => { + const root = await tempRoot(); + const store = createTaskLedgerStore(root); + const fill = Array.from({ length: TASK_LEDGER_MAX_TASKS }, (_, i) => ({ subject: `t${i}` })); + await store.create(SESSION_ID, fill); + + // Over-cap create must reject with a clear total-count message... + await assert.rejects( + () => store.create(SESSION_ID, [{ subject: 'overflow' }]), + new RegExp(`limited to ${TASK_LEDGER_MAX_TASKS} tasks total`), + ); + + // ...and must not have written anything: the ledger is unchanged. + const tasks = await store.list(SESSION_ID); + assert.equal(tasks.length, TASK_LEDGER_MAX_TASKS); + assert.equal(tasks.some((t) => t.subject === 'overflow'), false); + + // Completing tasks does not free capacity: the cap is on total count. + const first = tasks[0]; + assert.ok(first); + await store.update(SESSION_ID, first.id, { status: 'completed' }); + await assert.rejects(() => store.create(SESSION_ID, [{ subject: 'still-over' }]), /hard runaway guard/); + + // A single batch larger than the cap rejects at the front door (per-batch + // cap, before generating ids), so the ledger stays empty. + const freshStore = createTaskLedgerStore(await tempRoot()); + const oversizedBatch = Array.from({ length: TASK_LEDGER_MAX_TASKS + 1 }, (_, i) => ({ subject: `b${i}` })); + await assert.rejects(() => freshStore.create(SESSION_ID, oversizedBatch), /per-batch cap/); + assert.deepEqual(await freshStore.list(SESSION_ID), []); + }); +}); diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index ce0674b2a6..cb9676101e 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -18,3 +18,4 @@ export * from './settings-store.js'; export * from './telemetry-repo.js'; export * from './artifact-store.js'; export * from './plan-reminder-store.js'; +export * from './task-ledger-store.js'; diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index bef4e81326..65f0d3ef29 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -367,13 +367,14 @@ class FileSessionStore implements SessionStore { } } -function assertSafeSessionId(sessionId: string): void { +/** Shared guard for stores that derive filesystem paths from a session id. */ +export function assertSafeSessionId(sessionId: string): void { if (!isSafeSessionId(sessionId)) { throw new Error('Invalid session id'); } } -function isSafeSessionId(sessionId: string): boolean { +export function isSafeSessionId(sessionId: string): boolean { return SESSION_ID_PATTERN.test(sessionId); } diff --git a/packages/storage/src/task-ledger-store.ts b/packages/storage/src/task-ledger-store.ts new file mode 100644 index 0000000000..f1446ee695 --- /dev/null +++ b/packages/storage/src/task-ledger-store.ts @@ -0,0 +1,232 @@ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { + TASK_LEDGER_MAX_TASKS, + isSafeTaskId, + isTaskStatus, + normalizeCreateTaskInput, + normalizeTaskSubject, + normalizeUpdateTaskInput, + type Task, + type TaskLedgerStore, +} from '@maka/core/task-ledger'; +import { chainWrite } from './write-queue.js'; +import { assertSafeSessionId } from './session-store.js'; + +export type { TaskLedgerStore } from '@maka/core/task-ledger'; + +export function createTaskLedgerStore(workspaceRoot: string): TaskLedgerStore { + return new FileTaskLedgerStore(workspaceRoot); +} + +class FileTaskLedgerStore implements TaskLedgerStore { + private readonly sessionsRoot: string; + private readonly writeQueues = new Map>(); + + constructor(workspaceRoot: string) { + this.sessionsRoot = join(workspaceRoot, 'sessions'); + } + + async list(sessionId: string): Promise { + assertSafeSessionId(sessionId); + return this.readForRender(sessionId); + } + + async create(sessionId: string, drafts: unknown): Promise<{ created: Task[]; total: number }> { + assertSafeSessionId(sessionId); + if (!Array.isArray(drafts) || drafts.length === 0) { + throw new Error('TaskCreate requires at least one task draft'); + } + // Front-door the per-batch cap before generating ids or normalizing drafts: + // a single call can never add more than the absolute ledger cap, and rejecting + // here avoids generating N uuids for a batch the write-queue total check + // would refuse anyway. The total (existing + new) cap is still enforced + // inside the serialized mutate callback below. + if (drafts.length > TASK_LEDGER_MAX_TASKS) { + throw new Error( + `TaskCreate batch of ${drafts.length} tasks exceeds the ${TASK_LEDGER_MAX_TASKS}-task per-batch cap; split the work into smaller calls.`, + ); + } + const now = Date.now(); + const created: Task[] = drafts.map((draft) => { + const normalized = normalizeCreateTaskInput(draft); + if (!normalized.ok) throw new Error(normalized.message); + return { + id: randomUUID(), + subject: normalized.value.subject, + status: 'pending', + createdAt: now, + updatedAt: now, + }; + }); + // Cap check runs inside the serialized mutate callback (after reading the + // current ledger) so concurrent creates cannot race past the limit, and a + // rejected create never touches the file. + const all = await this.mutate(sessionId, (tasks) => { + if (tasks.length + created.length > TASK_LEDGER_MAX_TASKS) { + throw new Error( + `Task ledger is limited to ${TASK_LEDGER_MAX_TASKS} tasks total per session ` + + `(currently ${tasks.length}, adding ${created.length}). This is a hard runaway guard on the ` + + 'total count — completed or cancelled tasks still count, so batch related work into fewer, ' + + 'coarser tasks instead.', + ); + } + return [...tasks, ...created]; + }); + return { created, total: all.length }; + } + + async update(sessionId: string, id: string, patch: unknown): Promise<{ updated: Task; total: number }> { + assertSafeSessionId(sessionId); + const normalized = normalizeUpdateTaskInput(patch); + if (!normalized.ok) throw new Error(normalized.message); + const now = Date.now(); + let updated: Task | undefined; + const all = await this.mutate(sessionId, (tasks) => { + // Locate the target before producing a new list: an unknown id must + // fail inside the callback without rewriting an identical file. + const index = tasks.findIndex((task) => task.id === id); + const current = index === -1 ? undefined : tasks[index]; + if (!current) throw new Error(`No such task: ${id}`); + updated = { + ...current, + ...(normalized.value.subject !== undefined ? { subject: normalized.value.subject } : {}), + ...(normalized.value.status !== undefined ? { status: normalized.value.status } : {}), + updatedAt: now, + }; + const next = [...tasks]; + next[index] = updated; + return next; + }); + if (!updated) throw new Error(`No such task: ${id}`); + return { updated, total: all.length }; + } + + private filePath(sessionId: string): string { + return join(this.sessionsRoot, sessionId, 'tasks.json'); + } + + /** + * Render-path read: any failure degrades to an empty list so a damaged + * ledger never wedges a turn. Never used as the base of a write. + */ + private async readForRender(sessionId: string): Promise { + try { + return decodeTasks(await readFile(this.filePath(sessionId), 'utf8')); + } catch { + return []; + } + } + + /** + * Mutate-path read: only ENOENT means a legitimately fresh ledger. Any + * other read error, undecodable JSON, or a non-array payload throws so the + * mutation fails closed instead of rebuilding the ledger from [] and + * silently overwriting whatever is on disk. + */ + private async readForMutate(sessionId: string): Promise { + let text: string; + try { + text = await readFile(this.filePath(sessionId), 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + try { + return decodeTasks(text); + } catch (error) { + throw new Error( + `Task ledger file for session ${sessionId} is corrupt; refusing to overwrite it: ` + + (error instanceof Error ? error.message : String(error)), + ); + } + } + + private async mutate(sessionId: string, fn: (tasks: Task[]) => Task[]): Promise { + let next: Task[] = []; + await chainWrite(this.writeQueues, sessionId, async () => { + const current = await this.readForMutate(sessionId); + next = fn(current); + await this.write(sessionId, next); + }); + return next; + } + + private async write(sessionId: string, tasks: Task[]): Promise { + const filePath = this.filePath(sessionId); + await mkdir(dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tempPath, JSON.stringify(tasks, null, 2) + '\n', 'utf8'); + await rename(tempPath, filePath); + } +} + +function decodeTasks(text: string): Task[] { + const parsed = JSON.parse(text) as unknown; + if (!Array.isArray(parsed)) { + throw new Error('expected a JSON array of tasks'); + } + const tasks: Task[] = []; + const seenIds = new Set(); + for (const value of parsed) { + const task = normalizePersistedTask(value); + if (!task) continue; + // A tasks.json with two records sharing an id would render two + // indistinguishable tasks in the turn tail, and TaskUpdate's first-match + // lookup would only ever touch the first -- the second is unreachable and + // a mutate would silently keep both. Treat a duplicate id as corrupt so + // the render path degrades to empty and the mutate path stays fail-closed + // instead of rewriting a "half-correct" file. + if (seenIds.has(task.id)) { + throw new Error(`task ledger has a duplicate id "${task.id}"; refusing to load an ambiguous ledger`); + } + seenIds.add(task.id); + tasks.push(task); + } + // Enforce the same total-task cap as the write path on read. A hand-edited, + // legacy, or externally-written tasks.json could otherwise carry an + // unbounded number of valid records, which `list()` would inject into the + // turn tail every turn. Treat over-cap as corrupt so the render path + // degrades to empty (its caller already try/catches) and the mutate path + // stays fail-closed instead of silently truncating-and-overwriting. + if (tasks.length > TASK_LEDGER_MAX_TASKS) { + throw new Error( + `task ledger has ${tasks.length} tasks, exceeding the ${TASK_LEDGER_MAX_TASKS}-task cap; refusing to load an unbounded ledger`, + ); + } + return tasks; +} + +function normalizePersistedTask(value: unknown): Task | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + const record = value as Partial; + // Timestamps must be finite: a hand-edited `1e999` parses to Infinity, and + // JSON.stringify(Infinity) writes null, so the record would silently vanish + // on the next write. Reject it up front (per-record drop) instead. + if ( + typeof record.id !== 'string' || + !isSafeTaskId(record.id) || + typeof record.createdAt !== 'number' || + !Number.isFinite(record.createdAt) || + typeof record.updatedAt !== 'number' || + !Number.isFinite(record.updatedAt) || + !isTaskStatus(record.status) + ) { + return undefined; + } + // Re-apply the same subject normalization as the write path (NFC, whitespace + // collapse, trim, length cap, non-empty) so a manually-edited or legacy + // tasks.json cannot inject an overlong/blank subject into the turn tail + // every turn. Invalid subjects drop the whole record, matching the existing + // "single malformed entry discarded" semantic. + const subject = normalizeTaskSubject(record.subject); + if (!subject.ok) return undefined; + return { + id: record.id, + subject: subject.value, + status: record.status, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; +}