diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4de9d8b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,268 @@ +{ + "name": "@clawdbot/lobster", + "version": "2026.1.21-1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@clawdbot/lobster", + "version": "2026.1.21-1", + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "yaml": "^2.8.2" + }, + "bin": { + "clawd.invoke": "bin/clawd.invoke.js", + "lobster": "bin/lobster.js", + "openclaw.invoke": "bin/openclaw.invoke.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "oxlint": "^0.15.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@oxlint/darwin-arm64": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/@oxlint/darwin-arm64/-/darwin-arm64-0.15.15.tgz", + "integrity": "sha512-7GOyGM6D36lUhsOvavAVpF72SycPVG0Enunx0bzv8g0+9TklzOSFN3FJlZjLst14VPdZWujZMLgkQC7tOp+Rwg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint/darwin-x64": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/@oxlint/darwin-x64/-/darwin-x64-0.15.15.tgz", + "integrity": "sha512-pbrnYFwMn/fuX0z3IeQ05Nvo/b1zGxjmmWgkrQSDwYHxBxP6NT41hk1pmqkcA+v53xk9wvOa/6vBBI/U30F8Ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint/linux-arm64-gnu": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/@oxlint/linux-arm64-gnu/-/linux-arm64-gnu-0.15.15.tgz", + "integrity": "sha512-QWjG3YVsDlIvDTBUPmtPiyqP34ZQpFJqQh2JO94pBih11lFxQ0IGVMEXDhmW3WdiSFPZSJsZGzWynalM9eg+RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/linux-arm64-musl": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/@oxlint/linux-arm64-musl/-/linux-arm64-musl-0.15.15.tgz", + "integrity": "sha512-4W0YsmMSbNzzExOWhk+6zNfmJEmKFqSjFIn8CKLtYFvH8kF6KjoW4/0HNsDNYW5Fz+KOut/2JgkvxAiKH+r0zA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/linux-x64-gnu": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/@oxlint/linux-x64-gnu/-/linux-x64-gnu-0.15.15.tgz", + "integrity": "sha512-agP3e+eQ6tE5tqN6VI4Uukx2yvjwYFjtrDMcB19J7PmGOaFRwuMuT0sNWK/9guvhuS9aCINNZTi3kEhMy9Qgng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/linux-x64-musl": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/@oxlint/linux-x64-musl/-/linux-x64-musl-0.15.15.tgz", + "integrity": "sha512-L2qE9NhhUafsJOO4pofLx/0hW5IB0sfJa6bS85q0j+ySaI0f3CxMaAadrZLFSuqHWB3oF18B5yvzaPWsc2ohbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/win32-arm64": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/@oxlint/win32-arm64/-/win32-arm64-0.15.15.tgz", + "integrity": "sha512-B7f4VAS/E78n8zy6XZlNeyYOtWTel4BJn/22Ap2yEAlNzO34ot8dGfpLk6MqTUWJrRnARwVBVmc3wRVrsOT5yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint/win32-x64": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/@oxlint/win32-x64/-/win32-x64-0.15.15.tgz", + "integrity": "sha512-ZM9T3/OpaQ3qvrk/VuHO2EQmhNH4cOZdr/b/Ju9VKwBr+ahhqMn3W5srrplWQWxfsb0yd1yBj7iD0jdAps2iLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/oxlint": { + "version": "0.15.15", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-0.15.15.tgz", + "integrity": "sha512-oQNc1mAHrrbKiXyKJMGs9VCZfwGfLy7YiQKa4qupi71X/u4xyWqOh36YKXqWOXnmm2y7vfWFpGZlhJPAa9tMqA==", + "dev": true, + "license": "MIT", + "bin": { + "oxc_language_server": "bin/oxc_language_server", + "oxlint": "bin/oxlint" + }, + "engines": { + "node": ">=8.*" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/darwin-arm64": "0.15.15", + "@oxlint/darwin-x64": "0.15.15", + "@oxlint/linux-arm64-gnu": "0.15.15", + "@oxlint/linux-arm64-musl": "0.15.15", + "@oxlint/linux-x64-gnu": "0.15.15", + "@oxlint/linux-x64-musl": "0.15.15", + "@oxlint/win32-arm64": "0.15.15", + "@oxlint/win32-x64": "0.15.15" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/src/cli.ts b/src/cli.ts index 059bcba..1962332 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,7 +2,8 @@ import { parsePipeline } from './parser.js'; import { createDefaultRegistry } from './commands/registry.js'; import { runPipeline } from './runtime.js'; import { encodeToken } from './token.js'; -import { decodeResumeToken, parseResumeArgs } from './resume.js'; +import { decodeResumeToken, parseResumeArgs, resolveApprovalId } from './resume.js'; +import { cleanupApprovalIndexByStateKey, deleteApprovalId, generateApprovalId, writeApprovalIndex } from './state/store.js'; import { runWorkflowFile } from './workflows/file.js'; import { randomUUID } from 'node:crypto'; import { deleteStateJson, readStateJson, writeStateJson } from './state/store.js'; @@ -172,6 +173,7 @@ async function handleRun({ argv, registry }) { : null; if (approval) { + const aid = generateApprovalId(); const stateKey = await savePipelineResumeState(process.env, { pipeline, resumeAtIndex: (output.haltedAt?.index ?? -1) + 1, @@ -179,6 +181,7 @@ async function handleRun({ argv, registry }) { prompt: approval.prompt, createdAt: new Date().toISOString(), }); + await writeApprovalIndex({ env: process.env, stateKey, approvalId: aid }); const resumeToken = encodeToken({ protocolVersion: 1, @@ -194,6 +197,7 @@ async function handleRun({ argv, registry }) { requiresApproval: { ...approval, resumeToken, + approvalId: aid, }, }); return; @@ -315,17 +319,37 @@ async function handleResume({ argv, registry }) { const mode = 'tool'; let approved: boolean; let payload: any; + let resolvedApprovalId: string | null = null; try { const parsed = parseResumeArgs(argv); approved = parsed.approved; - payload = decodeResumeToken(parsed.token); + resolvedApprovalId = parsed.approvalId; + + // Resolve short approval ID to token if provided + let token: string; + if (parsed.approvalId) { + token = await resolveApprovalId(parsed.approvalId, process.env); + } else { + token = parsed.token!; + } + payload = decodeResumeToken(token); } catch (err) { writeToolEnvelope({ ok: false, error: { type: 'parse_error', message: err?.message ?? String(err) } }); process.exitCode = 2; return; } + // Helper: clean up approval ID index after successful use + const cleanupIndex = async () => { + if (resolvedApprovalId) { + await deleteApprovalId({ env: process.env, approvalId: resolvedApprovalId }); + } else if (payload.stateKey) { + await cleanupApprovalIndexByStateKey({ env: process.env, stateKey: payload.stateKey }); + } + }; + if (!approved) { + await cleanupIndex(); if (payload.kind === 'workflow-file' && payload.stateKey) { await deleteStateJson({ env: process.env, key: payload.stateKey }); } @@ -362,9 +386,11 @@ async function handleResume({ argv, registry }) { return; } + await cleanupIndex(); writeToolEnvelope({ ok: true, status: 'ok', output: output.output, requiresApproval: null }); return; } catch (err) { + // Don't clean up index on error — allow retry by --id writeToolEnvelope({ ok: false, error: { type: 'runtime_error', message: err?.message ?? String(err) } }); process.exitCode = 1; return; @@ -406,8 +432,12 @@ async function handleResume({ argv, registry }) { prompt: approval.prompt, createdAt: new Date().toISOString(), }); + await cleanupIndex(); await deleteStateJson({ env: process.env, key: previousStateKey }); + const nextAid = generateApprovalId(); + await writeApprovalIndex({ env: process.env, stateKey: nextStateKey, approvalId: nextAid }); + const resumeToken = encodeToken({ protocolVersion: 1, v: 1, @@ -419,14 +449,16 @@ async function handleResume({ argv, registry }) { ok: true, status: 'needs_approval', output: [], - requiresApproval: { ...approval, resumeToken }, + requiresApproval: { ...approval, resumeToken, approvalId: nextAid }, }); return; } + await cleanupIndex(); await deleteStateJson({ env: process.env, key: previousStateKey }); writeToolEnvelope({ ok: true, status: 'ok', output: output.items, requiresApproval: null }); } catch (err) { + // Don't clean up index on error — allow retry by --id writeToolEnvelope({ ok: false, error: { type: 'runtime_error', message: err?.message ?? String(err) } }); process.exitCode = 1; } diff --git a/src/core/tool_runtime.ts b/src/core/tool_runtime.ts index 20347a0..8712de8 100644 --- a/src/core/tool_runtime.ts +++ b/src/core/tool_runtime.ts @@ -4,10 +4,10 @@ import path from 'node:path'; import { createDefaultRegistry } from '../commands/registry.js'; import { parsePipeline } from '../parser.js'; -import { decodeResumeToken } from '../resume.js'; +import { decodeResumeToken, kindFromStateKey } from '../resume.js'; import { runPipeline } from '../runtime.js'; import { encodeToken } from '../token.js'; -import { readStateJson, writeStateJson, deleteStateJson } from '../state/store.js'; +import { readStateJson, writeStateJson, deleteStateJson, generateApprovalId, writeApprovalIndex, deleteApprovalId, findStateKeyByApprovalId, cleanupApprovalIndexByStateKey } from '../state/store.js'; import { runWorkflowFile } from '../workflows/file.js'; type PipelineResumeState = { @@ -41,6 +41,7 @@ type ToolEnvelope = { items: unknown[]; preview?: string; resumeToken?: string; + approvalId?: string; } | null; error?: { type: string; @@ -124,6 +125,7 @@ export async function runToolRequest({ : null; if (approval) { + const aid = generateApprovalId(); const stateKey = await savePipelineResumeState(runtime.env, { pipeline: parsed, resumeAtIndex: (output.haltedAt?.index ?? -1) + 1, @@ -131,6 +133,7 @@ export async function runToolRequest({ prompt: approval.prompt, createdAt: new Date().toISOString(), }); + await writeApprovalIndex({ env: runtime.env, stateKey, approvalId: aid }); const resumeToken = encodeToken({ protocolVersion: 1, @@ -142,6 +145,7 @@ export async function runToolRequest({ return okEnvelope('needs_approval', [], { ...approval, resumeToken, + approvalId: aid, }); } @@ -153,23 +157,55 @@ export async function runToolRequest({ export async function resumeToolRequest({ token, + approvalId, approved, ctx = {}, }: { - token: string; + token?: string; + approvalId?: string; approved: boolean; ctx?: ToolRunContext; }): Promise { const runtime = createToolContext(ctx); let payload: any; + let resolvedApprovalId = approvalId ?? null; try { - payload = decodeResumeToken(token); + // Resolve short approval ID to token if provided + let resolvedToken: string; + if (approvalId) { + const stateKey = await findStateKeyByApprovalId({ env: runtime.env, approvalId }); + if (!stateKey) { + return errorEnvelope('parse_error', `Approval ID "${approvalId}" not found or expired`); + } + const kind = kindFromStateKey(stateKey); + resolvedToken = encodeToken({ + protocolVersion: 1, + v: 1, + kind, + stateKey, + }); + } else if (token) { + resolvedToken = token; + } else { + return errorEnvelope('parse_error', 'resume requires token or approvalId'); + } + payload = decodeResumeToken(resolvedToken); } catch (err: any) { return errorEnvelope('parse_error', err?.message ?? String(err)); } + // Helper: clean up approval ID index after successful use + const cleanupIndex = async () => { + if (resolvedApprovalId) { + await deleteApprovalId({ env: runtime.env, approvalId: resolvedApprovalId }); + } else if (payload?.stateKey) { + await cleanupApprovalIndexByStateKey({ env: runtime.env, stateKey: payload.stateKey }); + } + }; + if (!approved) { + await cleanupIndex(); if (payload.kind === 'workflow-file' && payload.stateKey) { await deleteStateJson({ env: runtime.env, key: payload.stateKey }); } @@ -189,13 +225,16 @@ export async function resumeToolRequest({ }); if (output.status === 'needs_approval') { + // Don't clean up index — next gate will issue a new approvalId return okEnvelope('needs_approval', [], output.requiresApproval ?? null); } + await cleanupIndex(); if (output.status === 'cancelled') { return okEnvelope('cancelled', [], null); } return okEnvelope('ok', output.output, null); } catch (err: any) { + // Don't clean up index on error — allow retry by --id return errorEnvelope('runtime_error', err?.message ?? String(err)); } } @@ -230,6 +269,7 @@ export async function resumeToolRequest({ : null; if (approval) { + const nextAid = generateApprovalId(); const nextStateKey = await savePipelineResumeState(runtime.env, { pipeline: remaining, resumeAtIndex: (output.haltedAt?.index ?? -1) + 1, @@ -237,6 +277,8 @@ export async function resumeToolRequest({ prompt: approval.prompt, createdAt: new Date().toISOString(), }); + await writeApprovalIndex({ env: runtime.env, stateKey: nextStateKey, approvalId: nextAid }); + await cleanupIndex(); await deleteStateJson({ env: runtime.env, key: payload.stateKey }); const resumeToken = encodeToken({ @@ -249,12 +291,15 @@ export async function resumeToolRequest({ return okEnvelope('needs_approval', [], { ...approval, resumeToken, + approvalId: nextAid, }); } + await cleanupIndex(); await deleteStateJson({ env: runtime.env, key: payload.stateKey }); return okEnvelope('ok', output.items, null); } catch (err: any) { + // Don't clean up index on error — allow retry by --id return errorEnvelope('runtime_error', err?.message ?? String(err)); } } diff --git a/src/resume.ts b/src/resume.ts index 1c1409d..61de6d2 100644 --- a/src/resume.ts +++ b/src/resume.ts @@ -1,5 +1,17 @@ -import { decodeToken } from './token.js'; +import { decodeToken, encodeToken } from './token.js'; import { decodeWorkflowResumePayload } from './workflows/file.js'; +import { findStateKeyByApprovalId } from './state/store.js'; + +/** + * Determine the resume payload kind from a state key prefix. + * State keys use naming conventions: pipeline_resume_ or workflow_resume_. + */ +export function kindFromStateKey(stateKey: string): 'pipeline-resume' | 'workflow-file' { + if (stateKey.startsWith('pipeline_resume_')) return 'pipeline-resume'; + if (stateKey.startsWith('workflow_resume_')) return 'workflow-file'; + // Fallback for unknown prefixes — workflow-file is the original behavior + return 'workflow-file'; +} export type PipelineResumePayload = { protocolVersion: 1; @@ -9,7 +21,7 @@ export type PipelineResumePayload = { }; export function parseResumeArgs(argv) { - const args = { decision: null, token: null }; + const args = { decision: null, token: null, approvalId: null }; for (let i = 0; i < argv.length; i++) { const tok = argv[i]; @@ -22,6 +34,15 @@ export function parseResumeArgs(argv) { args.token = tok.slice('--token='.length); continue; } + if (tok === '--id') { + args.approvalId = argv[i + 1]; + i++; + continue; + } + if (tok.startsWith('--id=')) { + args.approvalId = tok.slice('--id='.length); + continue; + } if (tok === '--approve' || tok === '--decision') { args.decision = argv[i + 1]; i++; @@ -37,13 +58,37 @@ export function parseResumeArgs(argv) { } } - if (!args.token) throw new Error('resume requires --token'); + if (!args.token && !args.approvalId) throw new Error('resume requires --token or --id'); if (!args.decision) throw new Error('resume requires --approve yes|no'); const decision = String(args.decision).toLowerCase(); if (!['yes', 'y', 'no', 'n'].includes(decision)) throw new Error('resume --approve must be yes or no'); - return { token: String(args.token), approved: decision === 'yes' || decision === 'y' }; + return { + token: args.token ? String(args.token) : null, + approvalId: args.approvalId ? String(args.approvalId) : null, + approved: decision === 'yes' || decision === 'y', + }; +} + +/** + * Resolve an approval ID to a resume token by looking up the state key. + * Detects the kind (workflow-file vs pipeline-resume) from the state key prefix. + */ +export async function resolveApprovalId(approvalId: string, env: Record): Promise { + const stateKey = await findStateKeyByApprovalId({ env, approvalId }); + if (!stateKey) { + throw new Error(`Approval ID "${approvalId}" not found or expired`); + } + + const kind = kindFromStateKey(stateKey); + + return encodeToken({ + protocolVersion: 1, + v: 1, + kind, + stateKey, + }); } export function decodeResumeToken(token) { diff --git a/src/state/store.ts b/src/state/store.ts index acf2288..1b74d2a 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -1,6 +1,7 @@ import os from 'node:os'; import path from 'node:path'; import { promises as fsp } from 'node:fs'; +import { randomBytes } from 'node:crypto'; export function defaultStateDir(env) { return ( @@ -60,6 +61,103 @@ export async function deleteStateJson({ env, key }) { } } +/** + * Generate a short, human-friendly approval ID (8 hex chars). + * These are easy to copy/paste in chat interfaces where full + * base64url resume tokens are unwieldy. + */ +export function generateApprovalId(): string { + return randomBytes(4).toString('hex'); +} + +/** + * Write a reverse-index file that maps approvalId → stateKey. + * Call this after writeStateJson to enable short-ID resume. + */ +export async function writeApprovalIndex({ env, stateKey, approvalId }: { + env: Record; + stateKey: string; + approvalId: string; +}) { + const stateDir = defaultStateDir(env); + const safe = approvalId.replace(/[^a-f0-9]/g, ''); + if (!safe) return; + await fsp.mkdir(stateDir, { recursive: true }); + const indexPath = path.join(stateDir, `approval_${safe}.json`); + await fsp.writeFile(indexPath, JSON.stringify({ stateKey, createdAt: new Date().toISOString() }) + '\n', 'utf8'); +} + +/** + * Look up a state key by short approval ID. + * Returns the stateKey string or null if not found. + */ +export async function findStateKeyByApprovalId({ env, approvalId }: { + env: Record; + approvalId: string; +}): Promise { + const stateDir = defaultStateDir(env); + const safe = approvalId.replace(/[^a-f0-9]/g, ''); + if (!safe) return null; + const indexPath = path.join(stateDir, `approval_${safe}.json`); + try { + const text = await fsp.readFile(indexPath, 'utf8'); + const data = JSON.parse(text); + return typeof data?.stateKey === 'string' ? data.stateKey : null; + } catch (err: any) { + if (err?.code === 'ENOENT') return null; + throw err; + } +} + +/** + * Delete the approval ID index file (cleanup after resume or cancel). + */ +export async function deleteApprovalId({ env, approvalId }: { + env: Record; + approvalId: string; +}) { + const stateDir = defaultStateDir(env); + const safe = approvalId.replace(/[^a-f0-9]/g, ''); + if (!safe) return; + const indexPath = path.join(stateDir, `approval_${safe}.json`); + try { + await fsp.unlink(indexPath); + } catch (err: any) { + if (err?.code === 'ENOENT') return; + throw err; + } +} + +/** + * Clean up any approval index file that points to the given stateKey. + * Used when resuming via --token (where we don't know the approvalId). + * Scans index files in the state dir — O(n) but n is tiny in practice. + */ +export async function cleanupApprovalIndexByStateKey({ env, stateKey }: { + env: Record; + stateKey: string; +}) { + const stateDir = defaultStateDir(env); + let files: string[]; + try { + files = await fsp.readdir(stateDir); + } catch (err: any) { + if (err?.code === 'ENOENT') return; + throw err; + } + for (const file of files) { + if (!file.startsWith('approval_') || !file.endsWith('.json')) continue; + try { + const text = await fsp.readFile(path.join(stateDir, file), 'utf8'); + const data = JSON.parse(text); + if (data?.stateKey === stateKey) { + await fsp.unlink(path.join(stateDir, file)).catch(() => {}); + return; // one index per stateKey + } + } catch { /* skip corrupt files */ } + } +} + export async function diffAndStore({ env, key, value }) { const before = await readStateJson({ env, key }); const changed = stableStringify(before) !== stableStringify(value); diff --git a/src/workflows/file.ts b/src/workflows/file.ts index 8acd164..5e83d05 100644 --- a/src/workflows/file.ts +++ b/src/workflows/file.ts @@ -8,7 +8,7 @@ import { PassThrough } from 'node:stream'; import { parsePipeline } from '../parser.js'; import { runPipeline } from '../runtime.js'; import { encodeToken } from '../token.js'; -import { deleteStateJson, readStateJson, writeStateJson } from '../state/store.js'; +import { deleteStateJson, readStateJson, writeStateJson, generateApprovalId, writeApprovalIndex, deleteApprovalId } from '../state/store.js'; import { readLineFromStream } from '../read_line.js'; import { resolveInlineShellCommand } from '../shell.js'; @@ -61,6 +61,7 @@ export type WorkflowRunResult = { items: unknown[]; preview?: string; resumeToken?: string; + approvalId?: string; }; }; @@ -258,6 +259,7 @@ export async function runWorkflowFile({ const approval = extractApprovalRequest(step, results[step.id]); if (ctx.mode === 'tool' || !isInteractive(ctx.stdin)) { + const approvalId = generateApprovalId(); const stateKey = await saveWorkflowResumeState(ctx.env, { filePath: resolvedFilePath, resumeAtIndex: idx + 1, @@ -267,6 +269,9 @@ export async function runWorkflowFile({ createdAt: new Date().toISOString(), }); + // Write approval ID → stateKey reverse index for short-ID resume + await writeApprovalIndex({ env: ctx.env, stateKey, approvalId }); + if (consumedResumeStateKey && consumedResumeStateKey !== stateKey) { await deleteStateJson({ env: ctx.env, key: consumedResumeStateKey }); } @@ -284,6 +289,7 @@ export async function runWorkflowFile({ requiresApproval: { ...approval, resumeToken, + approvalId, }, }; } diff --git a/test/approval_id.test.ts b/test/approval_id.test.ts new file mode 100644 index 0000000..01c77dc --- /dev/null +++ b/test/approval_id.test.ts @@ -0,0 +1,187 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { promises as fsp } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +function runCli(args: string[], env: Record) { + const bin = path.join(process.cwd(), 'bin', 'lobster.js'); + return spawnSync('node', [bin, ...args], { + encoding: 'utf8', + env: { ...process.env, ...env }, + }); +} + +test('approval gate returns approvalId alongside resumeToken', async () => { + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-aid-')); + const stateDir = path.join(tmpDir, 'state'); + + const pipeline = + "exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{a:1}]))'\" | approve --prompt 'ok?' | pick a"; + + const first = runCli(['run', '--mode', 'tool', pipeline], { LOBSTER_STATE_DIR: stateDir }); + assert.equal(first.status, 0); + const json = JSON.parse(first.stdout); + assert.equal(json.status, 'needs_approval'); + assert.ok(json.requiresApproval?.resumeToken, 'should have resumeToken'); + assert.ok(json.requiresApproval?.approvalId, 'should have approvalId'); + assert.equal(json.requiresApproval.approvalId.length, 8, 'approvalId should be 8 hex chars'); + assert.match(json.requiresApproval.approvalId, /^[a-f0-9]{8}$/, 'approvalId should be hex'); + + // Verify index file was written + const files = await fsp.readdir(stateDir); + const indexFiles = files.filter((name) => name.startsWith('approval_')); + assert.equal(indexFiles.length, 1, 'should have one approval index file'); +}); + +test('resume with --id works as alternative to --token', async () => { + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-aid-resume-')); + const stateDir = path.join(tmpDir, 'state'); + + const pipeline = + "exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{b:2}]))'\" | approve --prompt 'ok?' | pick b"; + + // Step 1: Run pipeline, get approval ID + const first = runCli(['run', '--mode', 'tool', pipeline], { LOBSTER_STATE_DIR: stateDir }); + assert.equal(first.status, 0); + const firstJson = JSON.parse(first.stdout); + assert.equal(firstJson.status, 'needs_approval'); + const approvalId = firstJson.requiresApproval.approvalId; + assert.ok(approvalId); + + // Step 2: Resume using --id instead of --token + const resumed = runCli( + ['resume', '--id', approvalId, '--approve', 'yes'], + { LOBSTER_STATE_DIR: stateDir }, + ); + assert.equal(resumed.status, 0, `stderr: ${resumed.stderr}`); + const resumedJson = JSON.parse(resumed.stdout); + assert.equal(resumedJson.status, 'ok'); + assert.deepEqual(resumedJson.output, [{ b: 2 }]); + + // Step 3: Verify cleanup — approval index should be deleted + const files = await fsp.readdir(stateDir); + const indexFiles = files.filter((name) => name.startsWith('approval_')); + assert.equal(indexFiles.length, 0, 'approval index should be cleaned up after resume'); +}); + +test('resume with --id cancellation works', async () => { + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-aid-cancel-')); + const stateDir = path.join(tmpDir, 'state'); + + const pipeline = + "exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{c:3}]))'\" | approve --prompt 'ok?' | pick c"; + + const first = runCli(['run', '--mode', 'tool', pipeline], { LOBSTER_STATE_DIR: stateDir }); + const firstJson = JSON.parse(first.stdout); + const approvalId = firstJson.requiresApproval.approvalId; + + const cancelled = runCli( + ['resume', '--id', approvalId, '--approve', 'no'], + { LOBSTER_STATE_DIR: stateDir }, + ); + assert.equal(cancelled.status, 0); + const cancelledJson = JSON.parse(cancelled.stdout); + assert.equal(cancelledJson.status, 'cancelled'); +}); + +test('resume with invalid --id returns clear error', async () => { + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-aid-invalid-')); + const stateDir = path.join(tmpDir, 'state'); + + const result = runCli( + ['resume', '--id', 'deadbeef', '--approve', 'yes'], + { LOBSTER_STATE_DIR: stateDir }, + ); + // Should fail with a clear error message + const json = JSON.parse(result.stdout); + assert.equal(json.ok, false); + assert.ok(json.error?.message?.includes('not found'), `Error should mention not found: ${json.error?.message}`); +}); + +test('--token resume cleans up orphaned approval index', async () => { + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-aid-orphan-')); + const stateDir = path.join(tmpDir, 'state'); + + const pipeline = + "exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{e:5}]))'\" | approve --prompt 'ok?' | pick e"; + + // Step 1: Run pipeline, get both approvalId and resumeToken + const first = runCli(['run', '--mode', 'tool', pipeline], { LOBSTER_STATE_DIR: stateDir }); + const firstJson = JSON.parse(first.stdout); + assert.ok(firstJson.requiresApproval?.approvalId); + assert.ok(firstJson.requiresApproval?.resumeToken); + + // Verify index file exists + let files = await fsp.readdir(stateDir); + let indexFiles = files.filter((name) => name.startsWith('approval_')); + assert.equal(indexFiles.length, 1, 'approval index should exist before resume'); + + // Step 2: Resume using --token (NOT --id) + const resumed = runCli( + ['resume', '--token', firstJson.requiresApproval.resumeToken, '--approve', 'yes'], + { LOBSTER_STATE_DIR: stateDir }, + ); + assert.equal(resumed.status, 0); + const resumedJson = JSON.parse(resumed.stdout); + assert.equal(resumedJson.status, 'ok'); + + // Step 3: Verify approval index was cleaned up despite using --token + files = await fsp.readdir(stateDir); + indexFiles = files.filter((name) => name.startsWith('approval_')); + assert.equal(indexFiles.length, 0, 'approval index should be cleaned up even when using --token'); +}); + +test('double-resume with same --id returns clear error', async () => { + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-aid-double-')); + const stateDir = path.join(tmpDir, 'state'); + + const pipeline = + "exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{f:6}]))'\" | approve --prompt 'ok?' | pick f"; + + const first = runCli(['run', '--mode', 'tool', pipeline], { LOBSTER_STATE_DIR: stateDir }); + const firstJson = JSON.parse(first.stdout); + const approvalId = firstJson.requiresApproval.approvalId; + + // First resume — should succeed + const resumed = runCli( + ['resume', '--id', approvalId, '--approve', 'yes'], + { LOBSTER_STATE_DIR: stateDir }, + ); + assert.equal(resumed.status, 0); + const resumedJson = JSON.parse(resumed.stdout); + assert.equal(resumedJson.status, 'ok'); + + // Second resume with same ID — should fail cleanly, not crash + const second = runCli( + ['resume', '--id', approvalId, '--approve', 'yes'], + { LOBSTER_STATE_DIR: stateDir }, + ); + const secondJson = JSON.parse(second.stdout); + assert.equal(secondJson.ok, false); + assert.ok(secondJson.error?.message?.includes('not found'), `Should report not found: ${secondJson.error?.message}`); +}); + +test('backward compat: --token still works when approvalId is present', async () => { + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-aid-compat-')); + const stateDir = path.join(tmpDir, 'state'); + + const pipeline = + "exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{d:4}]))'\" | approve --prompt 'ok?' | pick d"; + + const first = runCli(['run', '--mode', 'tool', pipeline], { LOBSTER_STATE_DIR: stateDir }); + const firstJson = JSON.parse(first.stdout); + assert.ok(firstJson.requiresApproval?.approvalId, 'approvalId present'); + assert.ok(firstJson.requiresApproval?.resumeToken, 'resumeToken present'); + + // Resume using the old --token approach — should still work + const resumed = runCli( + ['resume', '--token', firstJson.requiresApproval.resumeToken, '--approve', 'yes'], + { LOBSTER_STATE_DIR: stateDir }, + ); + assert.equal(resumed.status, 0); + const resumedJson = JSON.parse(resumed.stdout); + assert.equal(resumedJson.status, 'ok'); + assert.deepEqual(resumedJson.output, [{ d: 4 }]); +});