fix(lobster): forward approvalId alongside resumeToken in tool envelope

@clawdbot/lobster/core returns both resumeToken and approvalId when a
workflow step needs approval, but the lobster plugin was dropping
approvalId in three places: normalizeEnvelope, the tool schema, and the
embedded-runner resume branch.

Agents forced to round-trip the ~155-byte base64url resumeToken across
tool calls are one stray truncation away from "Invalid token". The
8-hex approvalId is a disk-indexed alias (~/.lobster/state/approval_*
.json) — stable and escape-safe.

Changes are additive: token-based resume keeps working unchanged,
callers just gain an approvalId path.
This commit is contained in:
kirkluokun
2026-04-21 03:15:05 +08:00
committed by Peter Steinberger
parent 91dde183dc
commit 905da8bd6b
4 changed files with 91 additions and 4 deletions

View File

@@ -4,6 +4,7 @@ declare module "@clawdbot/lobster/core" {
prompt: string;
items: unknown[];
resumeToken?: string;
approvalId?: string;
} | null;
type LobsterToolContext = {

View File

@@ -236,6 +236,82 @@ describe("createEmbeddedLobsterRunner", () => {
});
});
it("forwards approvalId through resume when token is absent", async () => {
const runtime = {
runToolRequest: vi.fn(),
resumeToolRequest: vi.fn().mockResolvedValue({
ok: true,
protocolVersion: 1,
status: "ok",
output: [],
requiresApproval: null,
}),
};
const runner = createEmbeddedLobsterRunner({
loadRuntime: vi.fn().mockResolvedValue(runtime),
});
await runner.run({
action: "resume",
approvalId: "dbc98d05",
approve: true,
cwd: process.cwd(),
timeoutMs: 2000,
maxStdoutBytes: 4096,
});
expect(runtime.resumeToolRequest).toHaveBeenCalledWith({
approvalId: "dbc98d05",
approved: true,
ctx: expect.objectContaining({ mode: "tool" }),
});
});
it("passes approvalId through the normalized needs_approval envelope", async () => {
const runtime = {
runToolRequest: vi.fn().mockResolvedValue({
ok: true,
protocolVersion: 1,
status: "needs_approval",
output: [],
requiresApproval: {
type: "approval_request",
prompt: "ok?",
items: [],
resumeToken: "eyJ...",
approvalId: "dbc98d05",
},
}),
resumeToolRequest: vi.fn(),
};
const runner = createEmbeddedLobsterRunner({
loadRuntime: vi.fn().mockResolvedValue(runtime),
});
const envelope = await runner.run({
action: "run",
pipeline: "exec --json=true echo hi",
cwd: process.cwd(),
timeoutMs: 2000,
maxStdoutBytes: 4096,
});
expect(envelope).toEqual({
ok: true,
status: "needs_approval",
output: [],
requiresApproval: {
type: "approval_request",
prompt: "ok?",
items: [],
resumeToken: "eyJ...",
approvalId: "dbc98d05",
},
});
});
it("loads the embedded runtime once per runner", async () => {
const runtime = {
runToolRequest: vi.fn().mockResolvedValue({
@@ -310,7 +386,7 @@ describe("createEmbeddedLobsterRunner", () => {
timeoutMs: 2000,
maxStdoutBytes: 4096,
}),
).rejects.toThrow(/token required/);
).rejects.toThrow(/token or approvalId required/);
await expect(
runner.run({

View File

@@ -16,6 +16,7 @@ export type LobsterEnvelope =
prompt: string;
items: unknown[];
resumeToken?: string;
approvalId?: string;
};
}
| {
@@ -28,6 +29,7 @@ export type LobsterRunnerParams = {
pipeline?: string;
argsJson?: string;
token?: string;
approvalId?: string;
approve?: boolean;
cwd: string;
timeoutMs: number;
@@ -61,6 +63,7 @@ type EmbeddedToolEnvelope = {
items: unknown[];
preview?: string;
resumeToken?: string;
approvalId?: string;
} | null;
requiresInput?: {
prompt: string;
@@ -157,6 +160,9 @@ function normalizeEnvelope(envelope: EmbeddedToolEnvelope): LobsterEnvelope {
...(envelope.requiresApproval.resumeToken
? { resumeToken: envelope.requiresApproval.resumeToken }
: {}),
...(envelope.requiresApproval.approvalId
? { approvalId: envelope.requiresApproval.approvalId }
: {}),
}
: null,
};
@@ -296,8 +302,9 @@ export function createEmbeddedLobsterRunner(options?: {
}
const token = params.token?.trim() ?? "";
if (!token) {
throw new Error("token required");
const approvalId = params.approvalId?.trim() ?? "";
if (!token && !approvalId) {
throw new Error("token or approvalId required");
}
if (typeof params.approve !== "boolean") {
throw new Error("approve required");
@@ -306,7 +313,8 @@ export function createEmbeddedLobsterRunner(options?: {
return throwOnErrorEnvelope(
normalizeEnvelope(
await runtime.resumeToolRequest({
token,
...(token ? { token } : {}),
...(approvalId ? { approvalId } : {}),
approved: params.approve,
ctx,
}),

View File

@@ -221,6 +221,7 @@ export function createLobsterTool(api: OpenClawPluginApi, options?: LobsterToolO
pipeline: Type.Optional(Type.String()),
argsJson: Type.Optional(Type.String()),
token: Type.Optional(Type.String()),
approvalId: Type.Optional(Type.String()),
approve: Type.Optional(Type.Boolean()),
cwd: Type.Optional(
Type.String({
@@ -261,6 +262,7 @@ export function createLobsterTool(api: OpenClawPluginApi, options?: LobsterToolO
...(typeof params.pipeline === "string" ? { pipeline: params.pipeline } : {}),
...(typeof params.argsJson === "string" ? { argsJson: params.argsJson } : {}),
...(typeof params.token === "string" ? { token: params.token } : {}),
...(typeof params.approvalId === "string" ? { approvalId: params.approvalId } : {}),
...(typeof params.approve === "boolean" ? { approve: params.approve } : {}),
cwd,
timeoutMs,