mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
ef95d8f55e
* feat(secrets): agent-requested credentials the model never sees The new main-session secrets tool lets the agent request a credential by name: the human enters the value in a masked question card (Control UI, /ask/<id> deep link, iOS/macOS/Android), and the gateway diverts the answer straight into the shared secret store at question.resolve. The record, broadcast, waitAnswer, tool result, transcript, and model context only ever carry a synthetic stored marker. - protocol: additive secretStore binding, secretStoreExisting replacement metadata, and resolve-time secretStoreAllowedHosts (since 2026.8) - gateway: store-bound question validation, admin-gated minting (blocks questions-scope self-answer escalation past secrets.store.set), shared redaction-first store write service reused by secrets.store.set - tool: secrets request/list/delete; write-only by design, delete carries verified agent runtime identity; channel delivery is link-only so chat text is never captured as a secret - Control UI: masked composer card with requester identity, store banner, editable allowed hosts, replacement warning, retry-on-validation-error, a standalone /ask/<id> page, and a named startup-JS baseline bump - mobile: SecureField / password transformation for isSecret questions, no answer echo in terminal summaries; new native string registered in the locale-refresh inventory (generated artifacts stay workflow-owned) - regression: claimed harness secret input stays out of session transcripts Live-proven on an isolated dev gateway: real model turn, masked entry via Playwright, value present only in secret_store_entries, absent from every transcript, log, and the DOM. * chore(protocol): regenerate protocol models and tool display * fix(cli): read image string options through a typed helper PR #129463 added four commander option narrowings in image.ts without SAFETY coverage, leaving the assertion-safety ratchet red (21 > 17) for every branch on current main. Replace the casts with a typeof-checked read so the assertions are removed rather than annotated; each value is still validated by its normalizer. SAFETY comments cannot work in this file: the ratchet's raw scanner never rescans template tokens, so comments after the first substitution template are unreadable to it. * chore(protocol): refresh Swift models against current main * chore(i18n): re-baseline the native inventory on current main * docs(secrets): state the default-on tool policy and how to disable it * fix(secrets): tell the model what the store actually does The shipped tool description named the three actions and nothing else, and no parameter carried a description. The model could not tell that request blocks a human, that reason is shown to that human, what secret and env select, or - the silent-failure case - that a secret stored with no allowedHosts can never be substituted, so a successful request could produce a permanently unusable credential. Move the description to the presets module beside ask_user and document every parameter. * refactor(agents): share one blocking-question lifecycle between tools ask_user and secrets each carried their own registration, wait, and cancel logic, and they had diverged: ask_user recovers an answer that lands between its wait timeout and the cancel, while secrets discarded it and reported no_answer even though the Gateway had already stored the credential. One shared canceller and answer reader fixes that race for both, folds the two divergent gateway-call types into one, and drops two type assertions in favour of the canonical record guard (ask_user's assertion baseline shrinks 11 -> 8). Net +49 production lines: the shared module costs more than the duplication it removes, and buys the correctness fix plus a single owner for question lifecycle. * fix(ui): keep the allowed-hosts field readable as an input Main's composer restructure moved the free-text input styling into the option-row context, so the store-request hosts field - which sits outside a row - lost its border and read as static text. It is the one field the operator is meant to review and edit before releasing a credential, so give it its own border and focus ring. * fix(secrets): close two credential-boundary holes in agent requests Requests are now protected-secret only. list renders env values, so an agent could request kind=env, watch a human type it into a masked box under a no-visibility promise, then read it straight back; the tool text even claimed values are never returned. Environment values stay operator -set in Settings or the CLI, where they are agent-readable by design. Store-bound questions are also bound to the run that requested them. The resolve path authorized only the answering client, so a terminated or replaced agent run could still have a credential written on its behalf - the recorded runId was provenance, not closure-bound authority. Minting now requires a runId and resolution revalidates that exact live run immediately before the store write, with no await in between, failing closed as QUESTION_REQUESTER_INACTIVE. Both reported by ClawSweeper as P1 credential-boundary findings.
140 lines
5.3 KiB
TypeScript
140 lines
5.3 KiB
TypeScript
// Root-owned integration may combine public plugin surfaces with Gateway-owned runtime.
|
|
import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { telegramOutbound } from "../extensions/telegram/api.js";
|
|
import { buildAgentHarnessQuestionPromptPayload } from "../src/agents/harness/user-input-bridge.js";
|
|
import { QuestionManager } from "../src/gateway/question-manager.js";
|
|
import { createQuestionHandlers } from "../src/gateway/server-methods/question.js";
|
|
import { createSecretStoreWriteService } from "../src/gateway/server-methods/secrets.js";
|
|
import { callGatewayHandler } from "../src/gateway/server-methods/skills.test-helpers.js";
|
|
|
|
type QuestionGatewayCall = { method: string; params?: Record<string, unknown> };
|
|
|
|
const questionGatewayTransport = vi.hoisted(() => ({
|
|
dispatch: undefined as ((request: QuestionGatewayCall) => Promise<unknown>) | undefined,
|
|
}));
|
|
|
|
vi.mock("../src/gateway/call.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../src/gateway/call.js")>();
|
|
return {
|
|
...actual,
|
|
callGateway: async (request: QuestionGatewayCall) => {
|
|
if (!questionGatewayTransport.dispatch) {
|
|
throw new Error("expected the in-process question Gateway transport");
|
|
}
|
|
return await questionGatewayTransport.dispatch(request);
|
|
},
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
questionGatewayTransport.dispatch = undefined;
|
|
});
|
|
|
|
describe("Telegram question Gateway resolution", () => {
|
|
it("resolves canonical option C when rendered option A repeats across blocks", async () => {
|
|
const manager = new QuestionManager();
|
|
const handlers = createQuestionHandlers(
|
|
manager,
|
|
createSecretStoreWriteService({ reloadSecrets: async () => ({ warningCount: 0 }) }),
|
|
);
|
|
const gatewayCalls: string[] = [];
|
|
const dispatch = async ({ method, params }: QuestionGatewayCall): Promise<unknown> => {
|
|
gatewayCalls.push(method);
|
|
const result = await callGatewayHandler(handlers, method, params ?? {}, {
|
|
context: { broadcast: () => undefined },
|
|
});
|
|
if (!result.ok) {
|
|
throw new Error(`question Gateway method ${method} rejected its request`);
|
|
}
|
|
return result.response;
|
|
};
|
|
questionGatewayTransport.dispatch = dispatch;
|
|
|
|
try {
|
|
const questionId = "ask_0123456789abcdef0123456789abcdef";
|
|
const optionValues = ["A", "B", "C"];
|
|
await dispatch({
|
|
method: "question.request",
|
|
params: {
|
|
id: questionId,
|
|
questions: [
|
|
{
|
|
questionId: "destination",
|
|
header: "Destination",
|
|
question: "Where next?",
|
|
options: optionValues.map((label) => ({ label })),
|
|
multiSelect: false,
|
|
isOther: false,
|
|
isSecret: false,
|
|
},
|
|
],
|
|
timeoutMs: 15_000,
|
|
},
|
|
});
|
|
expect(manager.get(questionId)?.questions[0]?.options).toEqual(
|
|
optionValues.map((label) => ({ label })),
|
|
);
|
|
|
|
const questionButton = (optionValue: string) => ({
|
|
label: optionValue,
|
|
action: { type: "question" as const, questionId, optionValue },
|
|
});
|
|
const presentation = {
|
|
blocks: [
|
|
{ type: "buttons" as const, buttons: [questionButton("C"), questionButton("A")] },
|
|
{ type: "buttons" as const, buttons: [questionButton("A"), questionButton("B")] },
|
|
],
|
|
};
|
|
const payload = buildAgentHarnessQuestionPromptPayload({
|
|
questionId,
|
|
questions: [
|
|
{
|
|
id: "destination",
|
|
header: "Destination",
|
|
question: "Where next?",
|
|
options: optionValues.map((label) => ({ label })),
|
|
},
|
|
],
|
|
options: { presentation },
|
|
});
|
|
expect(payload.channelData.askUser).toEqual({ questionId, optionValues });
|
|
const rendered = await telegramOutbound.renderPresentation?.({
|
|
payload,
|
|
presentation,
|
|
ctx: { cfg: {}, to: "42", text: payload.text, payload },
|
|
});
|
|
const telegram = rendered?.channelData?.telegram as
|
|
| { buttons?: ReadonlyArray<ReadonlyArray<{ callback_data?: string }>> }
|
|
| undefined;
|
|
const rows = telegram?.buttons;
|
|
expect(rows?.map((row) => row.length)).toEqual([2, 2]);
|
|
expect(rows?.flatMap((row) => row.map((button) => button.callback_data))).toEqual([
|
|
`tgq1:${questionId}:2`,
|
|
`tgq1:${questionId}:0`,
|
|
`tgq1:${questionId}:0`,
|
|
`tgq1:${questionId}:1`,
|
|
]);
|
|
|
|
const callbackData = rows?.[0]?.[0]?.callback_data;
|
|
if (!callbackData) {
|
|
throw new Error("expected canonical Telegram option C callback data");
|
|
}
|
|
const optionIndex = Number(callbackData.slice(`tgq1:${questionId}:`.length));
|
|
expect(optionIndex).toBe(2);
|
|
|
|
await expect(
|
|
questionGatewayRuntime.resolveOption({ cfg: {}, questionId, optionIndex, senderId: "42" }),
|
|
).resolves.toEqual({ status: "answered", questionId: "destination", optionValue: "C" });
|
|
expect(gatewayCalls).toEqual(["question.request", "question.get", "question.resolve"]);
|
|
expect(manager.get(questionId)).toMatchObject({
|
|
status: "answered",
|
|
answers: { answers: { destination: ["C"] } },
|
|
resolvedBy: "42",
|
|
});
|
|
} finally {
|
|
manager.reset();
|
|
}
|
|
});
|
|
});
|