Files
openclaw/test/telegram-question-gateway.test.ts
Peter Steinberger ef95d8f55e feat(secrets): agent-requested credentials the model never sees (#129670)
* 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.
2026-08-26 08:10:16 -07:00

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();
}
});
});