mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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.
This commit is contained in:
committed by
GitHub
parent
30487606d1
commit
ef95d8f55e
Generated
+2
@@ -1214,6 +1214,7 @@
|
||||
{"id":"native.android.e23fb45cd6a06cd3","source":"Searching","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt"}]},
|
||||
{"id":"native.android.1f09d9e9f3a237c1","source":"Searching threads","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt"}]},
|
||||
{"id":"native.android.e5387d6aaf34ede8","source":"Searching…","surface":"android","sites":[{"kind":"ui-state-text","path":"apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt"},{"kind":"ui-state-text","path":"apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt"}]},
|
||||
{"id":"native.android.5616a95a172222e1","source":"Secret value","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt"}]},
|
||||
{"id":"native.android.2585b0fd6b35688c","source":"Secure (TLS)","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]},
|
||||
{"id":"native.android.19749b2753923dd1","source":"Secure connection is required for this host.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt"}]},
|
||||
{"id":"native.android.e72010e7aa8ccaee","source":"Security","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]},
|
||||
@@ -3510,6 +3511,7 @@
|
||||
{"id":"native.apple.c175b49ad1870aa3","source":"Searching ClawHub…","surface":"apple","sites":[{"kind":"ui-named-argument","path":"apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift"}]},
|
||||
{"id":"native.apple.a9c4ded8f743bc25","source":"Searching…","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryStatusText.swift"}]},
|
||||
{"id":"native.apple.526b0db228c6610c","source":"Seconds (optional)","surface":"apple","sites":[{"kind":"ui-call","path":"apps/macos/Sources/OpenClaw/CronJobEditor.swift"}]},
|
||||
{"id":"native.apple.eb87a5d402f5de88","source":"Secret value","surface":"apple","sites":[{"kind":"ui-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift"},{"kind":"ui-modifier","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift"}]},
|
||||
{"id":"native.apple.1c5b74f182a37abe","source":"Secure","surface":"apple","sites":[{"kind":"conditional-branch","path":"apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift"}]},
|
||||
{"id":"native.apple.03491d2d66766168","source":"Secure (TLS)","surface":"apple","sites":[{"kind":"ui-call","path":"apps/ios/Sources/Design/SettingsProTabSections.swift"},{"kind":"ui-call","path":"apps/ios/Sources/Onboarding/OnboardingWizardView.swift"}]},
|
||||
{"id":"native.apple.25f10dfdc6cd5627","source":"Secure connection is required for this host.","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift"}]},
|
||||
|
||||
@@ -91,6 +91,8 @@ data class Question(
|
||||
val multiSelect: Boolean? = null,
|
||||
val isOther: Boolean? = null,
|
||||
val isSecret: Boolean? = null,
|
||||
val secretStore: QuestionSecretStore? = null,
|
||||
val secretStoreExisting: QuestionSecretStoreExisting? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -344,6 +346,20 @@ data class GatewayNodeInvokeResultParamsError(
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class QuestionSecretStore(
|
||||
val name: String,
|
||||
val kind: String,
|
||||
val allowedHosts: List<String>? = null,
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class QuestionSecretStoreExisting(
|
||||
val updatedAtMs: Long,
|
||||
val updatedBy: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ProjectsListResultProjectsItem(
|
||||
val id: String,
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -32,6 +33,9 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@@ -165,14 +169,21 @@ private fun QuestionSection(
|
||||
}
|
||||
}
|
||||
if (question.options.isEmpty() || question.isOther == true) {
|
||||
// Secret answers must never render on screen or feed the keyboard's
|
||||
// prediction/autocorrect stores; password transformation + keyboard type
|
||||
// cover both, and single-line keeps the masked value one obscured run.
|
||||
val secret = question.isSecret == true
|
||||
OutlinedTextField(
|
||||
value = draft.otherText[question.questionId].orEmpty(),
|
||||
onValueChange = { onDraftChanged(draft.setOther(question, it)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = enabled,
|
||||
label = { Text(nativeString("Other answer")) },
|
||||
label = { Text(if (secret) nativeString("Secret value") else nativeString("Other answer")) },
|
||||
visualTransformation = if (secret) PasswordVisualTransformation() else VisualTransformation.None,
|
||||
keyboardOptions =
|
||||
if (secret) KeyboardOptions(keyboardType = KeyboardType.Password) else KeyboardOptions.Default,
|
||||
minLines = 1,
|
||||
maxLines = 4,
|
||||
maxLines = if (secret) 1 else 4,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -229,8 +240,13 @@ internal fun terminalQuestionAnswer(
|
||||
if (status == ChatQuestionStatus.Cancelled) return nativeString("Skipped")
|
||||
if (status == ChatQuestionStatus.Expired) return nativeString("Expired")
|
||||
if (status == ChatQuestionStatus.Unavailable) return nativeString("Unavailable")
|
||||
prompt.record.answers?.answers?.get(question.questionId)?.takeIf { it.isNotEmpty() }?.let {
|
||||
return it.joinToString(", ")
|
||||
// Secret questions never echo answer text into the persisted timeline; the
|
||||
// record only carries a synthetic marker, but masking here keeps the summary
|
||||
// honest for every secret producer, not just store-bound ones.
|
||||
if (question.isSecret != true) {
|
||||
prompt.record.answers?.answers?.get(question.questionId)?.takeIf { it.isNotEmpty() }?.let {
|
||||
return it.joinToString(", ")
|
||||
}
|
||||
}
|
||||
return if (status == ChatQuestionStatus.AnsweredElsewhere) nativeString("Answered elsewhere") else nativeString("Answered")
|
||||
}
|
||||
|
||||
@@ -256,11 +256,17 @@ public final class OpenClawQuestionCardModel: Identifiable {
|
||||
}
|
||||
|
||||
public func terminalSummaryText(for question: Question) -> String {
|
||||
switch self.status() {
|
||||
// Secret questions never echo answer text into the persisted timeline;
|
||||
// the record only carries a synthetic marker, but masking here keeps the
|
||||
// summary honest for every secret producer, not just store-bound ones.
|
||||
let echoedAnswers = question.issecret == true
|
||||
? nil
|
||||
: self.answerValues(questionID: question.questionid)?.joined(separator: ", ")
|
||||
return switch self.status() {
|
||||
case .answered:
|
||||
self.answerValues(questionID: question.questionid)?.joined(separator: ", ") ?? String(localized: "Answered")
|
||||
echoedAnswers ?? String(localized: "Answered")
|
||||
case .answeredElsewhere:
|
||||
self.answerValues(questionID: question.questionid)?.joined(separator: ", ")
|
||||
echoedAnswers
|
||||
?? String(localized: "Answered elsewhere")
|
||||
case .cancelled:
|
||||
String(localized: "Skipped")
|
||||
@@ -377,16 +383,30 @@ struct OpenClawQuestionCard: View {
|
||||
self.optionRow(question: question, option: option, now: now)
|
||||
}
|
||||
if question.options.isEmpty || question.isother == true {
|
||||
TextField(
|
||||
"Other answer",
|
||||
text: Binding(
|
||||
get: { self.model.otherText[question.questionid] ?? "" },
|
||||
set: { self.model.setOtherText(questionID: question.questionid, value: $0) }),
|
||||
axis: .vertical)
|
||||
.font(OpenClawChatTypography.body)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(self.model.status(at: now) != .pending)
|
||||
.accessibilityLabel("Other answer")
|
||||
if question.issecret == true {
|
||||
// Secret answers must never render on screen: masked entry, no
|
||||
// autocorrect/prediction capture, same submit path as free text.
|
||||
SecureField(
|
||||
"Secret value",
|
||||
text: Binding(
|
||||
get: { self.model.otherText[question.questionid] ?? "" },
|
||||
set: { self.model.setOtherText(questionID: question.questionid, value: $0) }))
|
||||
.font(OpenClawChatTypography.body)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(self.model.status(at: now) != .pending)
|
||||
.accessibilityLabel("Secret value")
|
||||
} else {
|
||||
TextField(
|
||||
"Other answer",
|
||||
text: Binding(
|
||||
get: { self.model.otherText[question.questionid] ?? "" },
|
||||
set: { self.model.setOtherText(questionID: question.questionid, value: $0) }),
|
||||
axis: .vertical)
|
||||
.font(OpenClawChatTypography.body)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(self.model.status(at: now) != .pending)
|
||||
.accessibilityLabel("Other answer")
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"update_goal": {"emoji":"🎯","title":"Update Goal","detailKeys":["status"]},
|
||||
"progress_card": {"emoji":"🗺️","title":"Progress Card","detailKeys":["plan.0.step","markdown"]},
|
||||
"ask_user": {"emoji":"❓","title":"Ask User","detailKeys":["questions.0.question"]},
|
||||
"secrets": {"emoji":"🔑","title":"Secrets","detailKeys":["action","name","kind"]},
|
||||
"suggest_task": {"emoji":"✨","title":"Suggest Task","detailKeys":["title","tldr","cwd"]},
|
||||
"dismiss_task": {"emoji":"🗑️","title":"Dismiss Task","detailKeys":["task_id","reason"]},
|
||||
"skill_workshop": {"emoji":"🧰","title":"Skill Workshop","detailKeys":["action","name","proposal_id"]},
|
||||
|
||||
@@ -18362,6 +18362,50 @@ public struct QuestionOption: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct QuestionSecretStoreBinding: Codable, Sendable {
|
||||
public let name: String
|
||||
public let kind: AnyCodable
|
||||
public let allowedhosts: [String]?
|
||||
public let reason: String?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
kind: AnyCodable,
|
||||
allowedhosts: [String]? = nil,
|
||||
reason: String? = nil)
|
||||
{
|
||||
self.name = name
|
||||
self.kind = kind
|
||||
self.allowedhosts = allowedhosts
|
||||
self.reason = reason
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case kind
|
||||
case allowedhosts = "allowedHosts"
|
||||
case reason
|
||||
}
|
||||
}
|
||||
|
||||
public struct QuestionSecretStoreExisting: Codable, Sendable {
|
||||
public let updatedatms: Int
|
||||
public let updatedby: String?
|
||||
|
||||
public init(
|
||||
updatedatms: Int,
|
||||
updatedby: String? = nil)
|
||||
{
|
||||
self.updatedatms = updatedatms
|
||||
self.updatedby = updatedby
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case updatedatms = "updatedAtMs"
|
||||
case updatedby = "updatedBy"
|
||||
}
|
||||
}
|
||||
|
||||
public struct Question: Codable, Sendable {
|
||||
public let questionid: String
|
||||
public let header: String
|
||||
@@ -18370,6 +18414,8 @@ public struct Question: Codable, Sendable {
|
||||
public let multiselect: Bool?
|
||||
public let isother: Bool?
|
||||
public let issecret: Bool?
|
||||
public let secretstore: QuestionSecretStoreBinding?
|
||||
public let secretstoreexisting: QuestionSecretStoreExisting?
|
||||
|
||||
public init(
|
||||
questionid: String,
|
||||
@@ -18378,7 +18424,9 @@ public struct Question: Codable, Sendable {
|
||||
options: [QuestionOption],
|
||||
multiselect: Bool? = nil,
|
||||
isother: Bool? = nil,
|
||||
issecret: Bool? = nil)
|
||||
issecret: Bool? = nil,
|
||||
secretstore: QuestionSecretStoreBinding? = nil,
|
||||
secretstoreexisting: QuestionSecretStoreExisting? = nil)
|
||||
{
|
||||
self.questionid = questionid
|
||||
self.header = header
|
||||
@@ -18387,6 +18435,8 @@ public struct Question: Codable, Sendable {
|
||||
self.multiselect = multiselect
|
||||
self.isother = isother
|
||||
self.issecret = issecret
|
||||
self.secretstore = secretstore
|
||||
self.secretstoreexisting = secretstoreexisting
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -18397,6 +18447,8 @@ public struct Question: Codable, Sendable {
|
||||
case multiselect = "multiSelect"
|
||||
case isother = "isOther"
|
||||
case issecret = "isSecret"
|
||||
case secretstore = "secretStore"
|
||||
case secretstoreexisting = "secretStoreExisting"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18408,6 +18460,7 @@ public struct QuestionRequestQuestion: Codable, Sendable {
|
||||
public let multiselect: Bool?
|
||||
public let isother: Bool?
|
||||
public let issecret: Bool?
|
||||
public let secretstore: QuestionSecretStoreBinding?
|
||||
|
||||
public init(
|
||||
questionid: String,
|
||||
@@ -18416,7 +18469,8 @@ public struct QuestionRequestQuestion: Codable, Sendable {
|
||||
options: [QuestionOption],
|
||||
multiselect: Bool? = nil,
|
||||
isother: Bool? = nil,
|
||||
issecret: Bool? = nil)
|
||||
issecret: Bool? = nil,
|
||||
secretstore: QuestionSecretStoreBinding? = nil)
|
||||
{
|
||||
self.questionid = questionid
|
||||
self.header = header
|
||||
@@ -18425,6 +18479,7 @@ public struct QuestionRequestQuestion: Codable, Sendable {
|
||||
self.multiselect = multiselect
|
||||
self.isother = isother
|
||||
self.issecret = issecret
|
||||
self.secretstore = secretstore
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -18435,6 +18490,7 @@ public struct QuestionRequestQuestion: Codable, Sendable {
|
||||
case multiselect = "multiSelect"
|
||||
case isother = "isOther"
|
||||
case issecret = "isSecret"
|
||||
case secretstore = "secretStore"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2087,7 +2087,7 @@ src/agents/tools-effective-inventory.ts 4
|
||||
src/agents/tools/agent-step.ts 6
|
||||
src/agents/tools/agents-wait-tool.ts 1
|
||||
src/agents/tools/ask-user-tool-normalization.ts 3
|
||||
src/agents/tools/ask-user-tool.ts 11
|
||||
src/agents/tools/ask-user-tool.ts 8
|
||||
src/agents/tools/automations-tool-name.ts 1
|
||||
src/agents/tools/chat-history-text.ts 3
|
||||
src/agents/tools/common.ts 2
|
||||
|
||||
@@ -1409,6 +1409,7 @@
|
||||
"tools/music-generation",
|
||||
"tools/pdf",
|
||||
"tools/reactions",
|
||||
"tools/secrets",
|
||||
"tools/thinking",
|
||||
"tools/tokenjuice",
|
||||
"tools/tool-search",
|
||||
|
||||
@@ -303,6 +303,8 @@ Reference an entry from `openclaw.json` with the `store` source:
|
||||
|
||||
Control UI set/delete operations automatically refresh the active secrets runtime when the changed name is referenced by a `store` SecretRef in the active source config. Names that are not referenced skip that work. Direct CLI writes remain an offline/local path; after changing a config-referenced value with the CLI, run `openclaw secrets reload` so the active in-memory snapshot picks it up.
|
||||
|
||||
The agent can also ask you to add an entry with the [`secrets` tool](/tools/secrets): it names the entry and the reason, you type the value into a masked prompt, and the Gateway writes it directly into the store. The value never enters the chat, the transcript, or the model's context, and the same automatic runtime refresh applies.
|
||||
|
||||
<Warning>
|
||||
Store values are not encrypted at rest. They are stored unencrypted in the shared state SQLite database (`state/openclaw.sqlite`), protected by the same `0600` file and `0700` directory permissions as other credentials in that database. Operators who need stronger storage isolation should use an external exec provider such as the [1Password plugin](/plugins/onepassword) or [Vault SecretRefs](/plugins/vault).
|
||||
</Warning>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
summary: "How the secrets tool lets the agent request credentials it never sees"
|
||||
read_when:
|
||||
- You want the agent to obtain an API key without it entering the chat
|
||||
- You are answering or debugging a credential request prompt
|
||||
- You need the secrets tool schema, storage, or channel behavior
|
||||
title: "Secrets"
|
||||
---
|
||||
|
||||
`secrets` lets the agent ask you for a credential without ever seeing it. The
|
||||
agent names an entry, you type the value into a trusted prompt, and the Gateway
|
||||
writes it straight into the shared secret store. The value never appears in the
|
||||
chat, the session transcript, the tool result, or the model's context — the
|
||||
agent only learns that the entry now exists.
|
||||
|
||||
The tool is available only in the main session. Subagents and other
|
||||
non-primary runs do not receive it.
|
||||
|
||||
It is enabled by default and governed by the normal tool policy — there is no
|
||||
dedicated config key. To remove it, deny it like any other tool (for example
|
||||
`tools.deny: ["secrets"]` in `openclaw.json`); allowlists and tool profiles
|
||||
apply to it the same way. Creating a credential request also requires an
|
||||
`operator.admin` Gateway client, which the agent's own client satisfies.
|
||||
|
||||
## Actions
|
||||
|
||||
- `request` — ask the human for a credential and store it under a name such as
|
||||
`STRIPE_API_KEY`. Requests are protected-secret only: an `env` value is
|
||||
readable through `list`, so requesting one would break the promise the masked
|
||||
prompt makes. The agent may propose `allowedHosts` and a short `reason` shown
|
||||
on the prompt, and the tool blocks until you answer, skip, or it times out
|
||||
(15 minutes by default). The request is bound to the requesting agent run; if
|
||||
that run ends before you answer, the write is refused.
|
||||
- `list` — entry metadata: name, kind, allowed hosts, and last update. Secret
|
||||
values are structurally absent from the listing. Operator-set `env` entries
|
||||
show their value, since those are injected into exec environments anyway and
|
||||
are agent-readable by design.
|
||||
- `delete` — soft-delete an entry by name. Deleted entries are purged after 30
|
||||
days.
|
||||
|
||||
There is deliberately no action that writes a value the agent supplies. If a
|
||||
value must enter the store, it arrives through the human prompt, the
|
||||
`/settings/secrets` page, or the [`openclaw secrets store` CLI](/cli/secrets).
|
||||
|
||||
## Answering a request
|
||||
|
||||
The web Control UI docks the prompt above the composer with a masked input.
|
||||
The prompt always shows who is asking (agent and session), the entry name and
|
||||
kind, the agent's stated reason, and — for secret entries — an editable list of
|
||||
allowed hosts, so you have the final say on where the credential may be used.
|
||||
If the name already exists, the prompt says so and shows when and by whom the
|
||||
entry was last updated; submitting replaces the stored value.
|
||||
|
||||
iOS, macOS, and Android render the same card with a masked secret field.
|
||||
|
||||
Chat channels never accept the value. On Telegram, Discord, and similar
|
||||
surfaces the request is delivered as a link to the Control UI prompt — typing
|
||||
a credential into a chat message is exactly what this flow exists to avoid, so
|
||||
a plain-text reply is not captured as an answer.
|
||||
|
||||
Creating a credential request requires an `operator.admin` client (the agent's
|
||||
own Gateway client qualifies). Answering needs only the normal question scope,
|
||||
because answering provides a value rather than reading one.
|
||||
|
||||
## Using a stored credential
|
||||
|
||||
A stored entry is a regular shared-store entry (see
|
||||
[Secrets management](/gateway/secrets)):
|
||||
|
||||
- Reference it from config as `{ "source": "store", "id": "STRIPE_API_KEY" }`
|
||||
wherever a SecretRef is accepted (provider API keys, channel tokens). Writes
|
||||
refresh affected config references automatically.
|
||||
- `env` entries are injected into gateway-host exec environments starting with
|
||||
the next agent run.
|
||||
- `secret` entries are substituted into subprocess traffic only when the
|
||||
egress proxy is enabled (`secrets.egressProxy.enabled`) and the destination
|
||||
matches the entry's allowed hosts; the agent and its subprocesses otherwise
|
||||
see only an opaque placeholder.
|
||||
@@ -43,6 +43,8 @@ export const ApprovalProtocolSchemas = {
|
||||
ExecApprovalRequestParams: execApprovals.ExecApprovalRequestParamsSchema,
|
||||
ExecApprovalResolveParams: execApprovals.ExecApprovalResolveParamsSchema,
|
||||
QuestionOption: questions.QuestionOptionSchema,
|
||||
QuestionSecretStoreBinding: questions.QuestionSecretStoreBindingSchema,
|
||||
QuestionSecretStoreExisting: questions.QuestionSecretStoreExistingSchema,
|
||||
Question: questions.QuestionSchema,
|
||||
QuestionRequestQuestion: questions.QuestionRequestQuestionSchema,
|
||||
QuestionAnswers: questions.QuestionAnswersSchema,
|
||||
|
||||
@@ -8,12 +8,28 @@ import { withSince } from "./since.js";
|
||||
const QuestionIdSchema = Type.String({ pattern: "^[a-z][a-z0-9_]*$" });
|
||||
// UI chip/tag display cap shared by every question input and output shape.
|
||||
const QuestionHeaderSchema = Type.String({ maxLength: 12 });
|
||||
const QuestionSecretStoreAllowedHostsSchema = Type.Array(
|
||||
Type.String({ minLength: 1, maxLength: 253 }),
|
||||
{ maxItems: 128, uniqueItems: true },
|
||||
);
|
||||
|
||||
export const QuestionOptionSchema = closedObject({
|
||||
label: NonEmptyString,
|
||||
description: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
export const QuestionSecretStoreBindingSchema = closedObject({
|
||||
name: Type.String({ minLength: 1, maxLength: 128, pattern: "^[A-Z][A-Z0-9_]{0,127}$" }),
|
||||
kind: Type.Union([Type.Literal("secret"), Type.Literal("env")]),
|
||||
allowedHosts: Type.Optional(QuestionSecretStoreAllowedHostsSchema),
|
||||
reason: Type.Optional(Type.String({ maxLength: 200 })),
|
||||
});
|
||||
|
||||
export const QuestionSecretStoreExistingSchema = closedObject({
|
||||
updatedAtMs: Type.Integer({ minimum: 0 }),
|
||||
updatedBy: Type.Optional(NonEmptyString),
|
||||
});
|
||||
|
||||
const QuestionInputFields = {
|
||||
questionId: QuestionIdSchema,
|
||||
header: QuestionHeaderSchema,
|
||||
@@ -22,6 +38,7 @@ const QuestionInputFields = {
|
||||
multiSelect: Type.Optional(Type.Boolean()),
|
||||
isOther: Type.Optional(Type.Boolean()),
|
||||
isSecret: Type.Optional(Type.Boolean()),
|
||||
secretStore: Type.Optional(withSince("2026.8", QuestionSecretStoreBindingSchema)),
|
||||
};
|
||||
|
||||
/** Unnormalized question accepted by question.request. */
|
||||
@@ -29,6 +46,7 @@ export const QuestionRequestQuestionSchema = closedObject(QuestionInputFields);
|
||||
|
||||
const QuestionFields = {
|
||||
...QuestionInputFields,
|
||||
secretStoreExisting: Type.Optional(withSince("2026.8", QuestionSecretStoreExistingSchema)),
|
||||
};
|
||||
|
||||
/** Canonical normalized question shown to an operator. */
|
||||
@@ -94,6 +112,9 @@ export const QuestionResolveParamsSchema = Type.Union([
|
||||
closedObject({
|
||||
id: NonEmptyString,
|
||||
answers: QuestionAnswersSchema,
|
||||
secretStoreAllowedHosts: Type.Optional(
|
||||
withSince("2026.8", QuestionSecretStoreAllowedHostsSchema),
|
||||
),
|
||||
resolvedBy: Type.Optional(NonEmptyString),
|
||||
}),
|
||||
closedObject({
|
||||
|
||||
@@ -29,6 +29,7 @@ const CORE_TOOL_FACTORY_DESCRIPTORS = [
|
||||
{ name: "conversations_turn", family: "openclaw" },
|
||||
{ name: AUTOMATIONS_TOOL_NAME, family: "openclaw" },
|
||||
{ name: "screen", family: "openclaw" },
|
||||
{ name: "secrets", family: "openclaw" },
|
||||
{ name: "dashboard", family: "openclaw" },
|
||||
{ name: "gateway", family: "openclaw" },
|
||||
{ name: "get_goal", family: "openclaw" },
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createUserTurnTranscriptRecorder } from "../../../sessions/user-turn-transcript.js";
|
||||
import { createTestUserTurnTranscriptTarget } from "../../../sessions/user-turn-transcript.test-support.js";
|
||||
import { runAgentHarnessGatewayQuestion } from "../../harness/gateway-question.js";
|
||||
import { registerQueuedUserMessageRetirement } from "../../sessions/queued-user-message-retirement.js";
|
||||
import {
|
||||
reportSteeringMessagePersistenceFailure,
|
||||
@@ -45,6 +46,53 @@ function steerWithDeliveryWait(
|
||||
}
|
||||
|
||||
describe("embedded OpenClaw queued steering cancellation", () => {
|
||||
it("keeps a claimed harness secret out of the session transcript", async () => {
|
||||
const secretValue = "test-secret-value-123";
|
||||
const sessionKey = "agent:main:secret-transcript";
|
||||
const persistedTranscript: string[] = [];
|
||||
const recorder = createUserTurnTranscriptRecorder({
|
||||
input: { text: secretValue },
|
||||
target: createTestUserTurnTranscriptTarget({ sessionKey }),
|
||||
});
|
||||
const persistApproved = vi.spyOn(recorder, "persistApproved").mockImplementation(async () => {
|
||||
persistedTranscript.push(JSON.stringify(recorder.message?.content));
|
||||
return undefined;
|
||||
});
|
||||
const onBlockReply = vi.fn(async () => undefined);
|
||||
const pendingSecret = runAgentHarnessGatewayQuestion({
|
||||
questions: [
|
||||
{
|
||||
id: "credential",
|
||||
header: "API key",
|
||||
question: "Enter the requested credential",
|
||||
isSecret: true,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
sessionKey,
|
||||
timeoutMs: 60_000,
|
||||
gatewayCall: vi.fn(),
|
||||
delivery: { onBlockReply },
|
||||
});
|
||||
const steer = vi.fn(async () => undefined);
|
||||
|
||||
await steerActiveSessionWithOptionalDeliveryWait(
|
||||
{ steer, subscribe: () => () => {} },
|
||||
secretValue,
|
||||
{ isInboundUserMessage: true, userTurnTranscriptRecorder: recorder },
|
||||
sessionKey,
|
||||
);
|
||||
|
||||
await expect(pendingSecret).resolves.toEqual({
|
||||
status: "answered",
|
||||
answers: { answers: { credential: [secretValue] } },
|
||||
});
|
||||
expect(persistApproved).not.toHaveBeenCalled();
|
||||
expect(recorder.hasPersisted()).toBe(false);
|
||||
expect(persistedTranscript.join("\n")).not.toContain(secretValue);
|
||||
expect(steer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards prepared transcript context with a queued steering message", async () => {
|
||||
const steer = vi.fn(async () => undefined);
|
||||
const recorder = createUserTurnTranscriptRecorder({
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
readStringValue,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { normalizeControlUiBasePath } from "@openclaw/session-url-contract";
|
||||
import { resolveControlUiSessionLinkBase } from "../config/control-ui-link-base.js";
|
||||
import { resolveGatewayPort } from "../config/paths.js";
|
||||
import { emitAgentActivityEvent, type AgentItemEventData } from "../infra/agent-activity-events.js";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import { REQUIRED_PARAM_GROUPS, type RequiredParamGroup } from "./agent-tools.params.js";
|
||||
@@ -38,6 +41,7 @@ import {
|
||||
settleAskUserPromptDelivery,
|
||||
waitForAskUserPromptReady,
|
||||
} from "./tools/ask-user-tool.js";
|
||||
import { normalizeSecretsRequestParams } from "./tools/secrets-tool.js";
|
||||
|
||||
const TRACE_REQUIRED_PARAM_GROUPS = {
|
||||
read: [{ keys: ["path", "file_path"], label: "path" }],
|
||||
@@ -45,7 +49,8 @@ const TRACE_REQUIRED_PARAM_GROUPS = {
|
||||
edit: REQUIRED_PARAM_GROUPS.edit,
|
||||
} satisfies Record<string, readonly RequiredParamGroup[]>;
|
||||
|
||||
function buildAskUserPromptPayload(
|
||||
function reserveQuestionPromptDelivery(
|
||||
toolName: "ask_user" | "secrets",
|
||||
toolCallId: string,
|
||||
sessionKey: string | undefined,
|
||||
runId: string,
|
||||
@@ -53,7 +58,8 @@ function buildAskUserPromptPayload(
|
||||
args: unknown,
|
||||
) {
|
||||
try {
|
||||
const { questions, timeoutSeconds } = normalizeAskUserParams(args);
|
||||
const { questions, timeoutSeconds } =
|
||||
toolName === "secrets" ? normalizeSecretsRequestParams(args) : normalizeAskUserParams(args);
|
||||
const reservation = reserveAskUserPromptDelivery({
|
||||
toolCallId,
|
||||
sessionKey,
|
||||
@@ -324,9 +330,17 @@ export function handleToolExecutionStart(
|
||||
): void | Promise<void> {
|
||||
const startToolName = normalizeToolPolicyName(evt.toolName);
|
||||
ctx.state.liveEditDiffStateById.delete(evt.toolCallId);
|
||||
const askUserPromptReservation =
|
||||
startToolName === "ask_user" && ctx.params.onToolResult
|
||||
? buildAskUserPromptPayload(
|
||||
const isQuestionTool =
|
||||
startToolName === "ask_user" ||
|
||||
(startToolName === "secrets" &&
|
||||
evt.args !== null &&
|
||||
typeof evt.args === "object" &&
|
||||
"action" in evt.args &&
|
||||
evt.args.action === "request");
|
||||
const questionPromptReservation =
|
||||
isQuestionTool && ctx.params.onToolResult
|
||||
? reserveQuestionPromptDelivery(
|
||||
startToolName === "ask_user" ? "ask_user" : "secrets",
|
||||
evt.toolCallId,
|
||||
ctx.params.sessionKey,
|
||||
ctx.params.runId,
|
||||
@@ -334,8 +348,8 @@ export function handleToolExecutionStart(
|
||||
evt.args,
|
||||
)
|
||||
: undefined;
|
||||
const cancelAskUserPromptReservation = () => {
|
||||
if (askUserPromptReservation) {
|
||||
const cancelQuestionPromptReservation = () => {
|
||||
if (questionPromptReservation) {
|
||||
cancelAskUserPromptDelivery(
|
||||
evt.toolCallId,
|
||||
ctx.params.sessionKey,
|
||||
@@ -352,14 +366,14 @@ export function handleToolExecutionStart(
|
||||
assistantMessageIndex: ctx.state.assistantMessageIndex,
|
||||
});
|
||||
} catch (error) {
|
||||
cancelAskUserPromptReservation();
|
||||
cancelQuestionPromptReservation();
|
||||
throw error;
|
||||
}
|
||||
if (isPromiseLike<void>(onBlockReplyFlushResult)) {
|
||||
return onBlockReplyFlushResult.then(
|
||||
() => continueToolExecutionStart(),
|
||||
(error: unknown) => {
|
||||
cancelAskUserPromptReservation();
|
||||
cancelQuestionPromptReservation();
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
@@ -583,8 +597,8 @@ export function handleToolExecutionStart(
|
||||
}
|
||||
}
|
||||
|
||||
if (toolName === "ask_user" && ctx.params.onToolResult) {
|
||||
const payload = askUserPromptReservation;
|
||||
if (isQuestionTool && ctx.params.onToolResult) {
|
||||
const payload = questionPromptReservation;
|
||||
if (payload) {
|
||||
const questionId = payload.questionId;
|
||||
void waitForAskUserPromptReady(questionId)
|
||||
@@ -592,6 +606,22 @@ export function handleToolExecutionStart(
|
||||
if (!questions) {
|
||||
return;
|
||||
}
|
||||
if (toolName === "secrets") {
|
||||
const binding = questions[0]?.secretStore;
|
||||
if (!binding) {
|
||||
return;
|
||||
}
|
||||
const config = ctx.params.config;
|
||||
const controlUiBase =
|
||||
resolveControlUiSessionLinkBase(config) ??
|
||||
`http://127.0.0.1:${resolveGatewayPort(config)}${normalizeControlUiBasePath(
|
||||
config?.gateway?.controlUi?.basePath,
|
||||
)}`;
|
||||
const url = `${controlUiBase}/ask/${encodeURIComponent(questionId)}`;
|
||||
return ctx.params.onToolResult?.({
|
||||
text: `🔑 Agent requests credential ${binding.name} (${binding.kind}). Reply is disabled for secrets — open to provide it: ${url}`,
|
||||
});
|
||||
}
|
||||
return ctx.params.onToolResult?.(
|
||||
buildAgentHarnessQuestionPromptPayload({
|
||||
questionId,
|
||||
@@ -607,7 +637,7 @@ export function handleToolExecutionStart(
|
||||
() => settleAskUserPromptDelivery(questionId),
|
||||
(error: unknown) => {
|
||||
settleAskUserPromptDelivery(questionId, error);
|
||||
ctx.log.warn(`failed to deliver ask_user prompt: ${String(error)}`);
|
||||
ctx.log.warn(`failed to deliver ${toolName} prompt: ${String(error)}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -622,14 +652,14 @@ export function handleToolExecutionStart(
|
||||
try {
|
||||
flushBlockReplyBufferResult = ctx.flushBlockReplyBuffer();
|
||||
} catch (error) {
|
||||
cancelAskUserPromptReservation();
|
||||
cancelQuestionPromptReservation();
|
||||
throw error;
|
||||
}
|
||||
if (isPromiseLike<void>(flushBlockReplyBufferResult)) {
|
||||
return flushBlockReplyBufferResult.then(
|
||||
() => continueAfterBlockReplyFlush(),
|
||||
(error: unknown) => {
|
||||
cancelAskUserPromptReservation();
|
||||
cancelQuestionPromptReservation();
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
reserveAskUserPromptDelivery,
|
||||
} from "./tools/ask-user-tool.js";
|
||||
import { resetPendingAskUserQuestionsForTest } from "./tools/ask-user-tool.test-support.js";
|
||||
import { createSecretsTool } from "./tools/secrets-tool.js";
|
||||
|
||||
type ToolExecutionStartEvent = Omit<Extract<AgentEvent, { type: "tool_execution_start" }>, "type">;
|
||||
type ToolExecutionEndEvent = Omit<Extract<AgentEvent, { type: "tool_execution_end" }>, "type">;
|
||||
@@ -438,6 +439,53 @@ describe("handleToolExecutionStart read path checks", () => {
|
||||
await activation.finish();
|
||||
});
|
||||
|
||||
it("delivers credential requests as absolute Control UI links without answer affordances", async () => {
|
||||
const { ctx } = createTestContext();
|
||||
const onToolResult = vi.fn();
|
||||
ctx.params.onToolResult = onToolResult;
|
||||
ctx.params.config = {
|
||||
gateway: {
|
||||
publicOrigin: "https://console.example.test",
|
||||
controlUi: { basePath: "/control" },
|
||||
},
|
||||
};
|
||||
const args = { action: "request", name: "TEST_API_KEY", kind: "secret" };
|
||||
let questionId = "";
|
||||
let resolveAnswer: ((value: { status: "cancelled" }) => void) | undefined;
|
||||
const tool = createSecretsTool({
|
||||
agentId: "agent-test-id",
|
||||
sessionKey: "agent:unit-session",
|
||||
runId: "run-test",
|
||||
gatewayCall: async (method, _options, params) => {
|
||||
if (method === "question.request") {
|
||||
questionId = String(requireRecord(params, "question request").id);
|
||||
return { id: questionId };
|
||||
}
|
||||
if (method === "question.get") {
|
||||
return { question: { questions: [] } };
|
||||
}
|
||||
if (method === "question.waitAnswer") {
|
||||
return await new Promise((resolve) => {
|
||||
resolveAnswer = resolve;
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
},
|
||||
});
|
||||
|
||||
await startTool(ctx, { toolName: "secrets", toolCallId: "secret-call-1", args });
|
||||
const pending = tool.execute("secret-call-1", args);
|
||||
await vi.waitFor(() => expect(onToolResult).toHaveBeenCalledOnce());
|
||||
|
||||
expect(onToolResult).toHaveBeenCalledWith({
|
||||
text: `🔑 Agent requests credential TEST_API_KEY (secret). Reply is disabled for secrets — open to provide it: https://console.example.test/control/ask/${questionId}`,
|
||||
});
|
||||
expect(onToolResult.mock.calls[0]?.[0]).not.toHaveProperty("channelData");
|
||||
expect(onToolResult.mock.calls[0]?.[0]).not.toHaveProperty("presentation");
|
||||
resolveAnswer?.({ status: "cancelled" });
|
||||
await pending;
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "multi-question",
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
collectPresentOpenClawTools,
|
||||
shouldIncludeAskUserToolForOpenClawTools,
|
||||
shouldIncludeProgressCardToolForOpenClawTools,
|
||||
shouldIncludeSecretsToolForOpenClawTools,
|
||||
} from "./openclaw-tools.registration.js";
|
||||
import { textResult, type AnyAgentTool } from "./tools/common.js";
|
||||
import { createPdfTool } from "./tools/pdf-tool.js";
|
||||
@@ -119,19 +120,22 @@ describe("openclaw-tools progress_card gating", () => {
|
||||
expect(defaultTools).not.toContain("ask_user");
|
||||
});
|
||||
|
||||
it("keeps ask_user on primary sessions and excludes spawned worker sessions", () => {
|
||||
expect(shouldIncludeAskUserToolForOpenClawTools({})).toBe(false);
|
||||
expect(shouldIncludeAskUserToolForOpenClawTools({ agentSessionKey: "agent:main:main" })).toBe(
|
||||
true,
|
||||
);
|
||||
it("keeps human-question tools on permitted primary sessions", () => {
|
||||
for (const includeTool of [
|
||||
shouldIncludeAskUserToolForOpenClawTools,
|
||||
shouldIncludeSecretsToolForOpenClawTools,
|
||||
]) {
|
||||
expect(includeTool({})).toBe(false);
|
||||
expect(includeTool({ agentSessionKey: "agent:main:main" })).toBe(true);
|
||||
expect(includeTool({ agentSessionKey: "agent:main:subagent:worker" })).toBe(false);
|
||||
expect(includeTool({ agentSessionKey: "agent:main:acp:worker" })).toBe(false);
|
||||
}
|
||||
expect(
|
||||
shouldIncludeAskUserToolForOpenClawTools({
|
||||
agentSessionKey: "agent:main:subagent:worker",
|
||||
shouldIncludeSecretsToolForOpenClawTools({
|
||||
agentSessionKey: "agent:main:main",
|
||||
pluginToolDenylist: ["secrets"],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldIncludeAskUserToolForOpenClawTools({ agentSessionKey: "agent:main:acp:worker" }),
|
||||
).toBe(false);
|
||||
// ask_user must not depend on the TUI embedded-host flag; normal gateway
|
||||
// runs are the primary consumer.
|
||||
expect(
|
||||
@@ -139,7 +143,7 @@ describe("openclaw-tools progress_card gating", () => {
|
||||
config: {} as OpenClawConfig,
|
||||
runSessionKey: "agent:main:non-embedded",
|
||||
}),
|
||||
).toContain("ask_user");
|
||||
).toEqual(expect.arrayContaining(["ask_user", "secrets"]));
|
||||
setEmbeddedMode(true);
|
||||
|
||||
expect(
|
||||
|
||||
@@ -98,12 +98,16 @@ export function shouldIncludeProgressCardToolForOpenClawTools(params: {
|
||||
);
|
||||
}
|
||||
|
||||
/** Includes ask_user only on a primary session and when normal deny policy permits it. */
|
||||
export function shouldIncludeAskUserToolForOpenClawTools(params: {
|
||||
type PrimarySessionToolRegistrationParams = {
|
||||
config?: OpenClawConfig;
|
||||
agentSessionKey?: string;
|
||||
pluginToolDenylist?: string[];
|
||||
}): boolean {
|
||||
};
|
||||
|
||||
function shouldIncludePrimarySessionToolForOpenClawTools(
|
||||
toolName: "ask_user" | "secrets",
|
||||
params: PrimarySessionToolRegistrationParams,
|
||||
): boolean {
|
||||
const sessionKey = params.agentSessionKey?.trim();
|
||||
if (!sessionKey) {
|
||||
return false;
|
||||
@@ -112,5 +116,19 @@ export function shouldIncludeAskUserToolForOpenClawTools(params: {
|
||||
...(params.config?.tools?.deny ?? []),
|
||||
...(params.pluginToolDenylist ?? []),
|
||||
]);
|
||||
return isPrimaryBootstrapRun(sessionKey) && isToolAllowedByPolicyName("ask_user", { deny });
|
||||
return isPrimaryBootstrapRun(sessionKey) && isToolAllowedByPolicyName(toolName, { deny });
|
||||
}
|
||||
|
||||
/** Includes ask_user only on a primary session and when normal deny policy permits it. */
|
||||
export function shouldIncludeAskUserToolForOpenClawTools(
|
||||
params: PrimarySessionToolRegistrationParams,
|
||||
): boolean {
|
||||
return shouldIncludePrimarySessionToolForOpenClawTools("ask_user", params);
|
||||
}
|
||||
|
||||
/** Keeps credential management on primary sessions allowed by the normal tool policy. */
|
||||
export function shouldIncludeSecretsToolForOpenClawTools(
|
||||
params: PrimarySessionToolRegistrationParams,
|
||||
): boolean {
|
||||
return shouldIncludePrimarySessionToolForOpenClawTools("secrets", params);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
collectPresentOpenClawTools,
|
||||
shouldIncludeAskUserToolForOpenClawTools,
|
||||
shouldIncludeProgressCardToolForOpenClawTools,
|
||||
shouldIncludeSecretsToolForOpenClawTools,
|
||||
} from "./openclaw-tools.registration.js";
|
||||
import { createRequesterYieldCallback } from "./openclaw-tools.requester-yield.js";
|
||||
import { createOpenClawSwarmToolGroups } from "./openclaw-tools.swarm.js";
|
||||
@@ -68,6 +69,7 @@ import { createPdfTool } from "./tools/pdf-tool.js";
|
||||
import { createPortalTool } from "./tools/portal-tool.js";
|
||||
import { createProgressCardTool } from "./tools/progress-card-tool.js";
|
||||
import { createScreenTool } from "./tools/screen-tool.js";
|
||||
import { createSecretsTool } from "./tools/secrets-tool.js";
|
||||
import { createSessionStatusTool } from "./tools/session-status-tool.js";
|
||||
import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
|
||||
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
|
||||
@@ -478,6 +480,19 @@ export function createOpenClawTools(options?: OpenClawToolsOptions): AnyAgentToo
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(shouldIncludeSecretsToolForOpenClawTools({
|
||||
config: resolvedConfig,
|
||||
agentSessionKey: options?.runSessionKey ?? options?.agentSessionKey,
|
||||
pluginToolDenylist: options?.pluginToolDenylist,
|
||||
})
|
||||
? [
|
||||
createSecretsTool({
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: options?.runSessionKey ?? options?.agentSessionKey,
|
||||
runId: options?.runId,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
createSessionsListTool({
|
||||
...sessionLookupToolOptions,
|
||||
requesterAgentIdOverride: sessionAgentId,
|
||||
|
||||
@@ -50,6 +50,7 @@ describe("tool-catalog", () => {
|
||||
"exec",
|
||||
"process",
|
||||
"code_execution",
|
||||
"secrets",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"x_search",
|
||||
@@ -94,6 +95,7 @@ describe("tool-catalog", () => {
|
||||
it("includes bundle MCP tools in coding and messaging profile policies", () => {
|
||||
expect(requirePolicyAllow("coding").at(-1)).toBe("bundle-mcp");
|
||||
expect(requirePolicyAllow("messaging")).toEqual([
|
||||
"secrets",
|
||||
"sessions",
|
||||
"sessions_list",
|
||||
"sessions_history",
|
||||
|
||||
@@ -118,6 +118,14 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
|
||||
profiles: ["coding"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "secrets",
|
||||
label: "secrets",
|
||||
description: "Request and manage write-only credentials",
|
||||
sectionId: "runtime",
|
||||
profiles: ["coding", "messaging"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "web_search",
|
||||
label: "web_search",
|
||||
|
||||
@@ -166,3 +166,15 @@ export function describeAskUserTool(): string {
|
||||
"If the result is no_answer, continue with best judgment.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
/** Describes the secrets tool and the store semantics the model cannot observe. */
|
||||
export function describeSecretsTool(): string {
|
||||
return [
|
||||
"Obtain and manage credentials you never see: `request` asks the human to type a value into a trusted prompt that stores it directly, `list` returns entry metadata, and `delete` removes an entry.",
|
||||
"A requested value is never readable back by any action; use `request` when you need a credential you do not have instead of asking for one in conversation, and never repeat a credential a human pasted into chat.",
|
||||
"`request` blocks until the human answers, so ask only for a credential the current task actually needs.",
|
||||
"Only protected secrets may be requested, and they reach a service through config references or, where the egress proxy is enabled, substitution into outbound requests; plain environment values are set by the operator in Settings or the CLI, never requested here.",
|
||||
"List every hostname that will receive the value in `allowedHosts`: a secret with no allowed hosts can never be substituted, so the request silently produces an unusable credential.",
|
||||
'`reason` is shown to the human deciding whether to provide the value. Stored entries are referenced elsewhere as {source:"store", id:NAME}; if the result is no_answer, continue with best judgment.',
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
@@ -225,6 +225,11 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
|
||||
title: "Ask User",
|
||||
detailKeys: ["questions.0.question"],
|
||||
},
|
||||
secrets: {
|
||||
emoji: "🔑",
|
||||
title: "Secrets",
|
||||
detailKeys: ["action", "name", "kind"],
|
||||
},
|
||||
suggest_task: {
|
||||
emoji: "✨",
|
||||
title: "Suggest Task",
|
||||
|
||||
@@ -16,14 +16,14 @@ import {
|
||||
normalizeAskUserParams,
|
||||
} from "./ask-user-tool-normalization.js";
|
||||
import { type AnyAgentTool, ToolInputError, textResult } from "./common.js";
|
||||
import {
|
||||
createGatewayQuestionCanceller,
|
||||
readQuestionErrorReason,
|
||||
} from "./gateway-question-lifecycle.js";
|
||||
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
||||
|
||||
const ASK_USER_RPC_GRACE_MS = 10_000;
|
||||
const ASK_USER_PROMPT_RECHECK_MS = 50;
|
||||
const TERMINAL_QUESTION_ERROR_REASONS = new Set([
|
||||
"QUESTION_ALREADY_TERMINAL",
|
||||
"QUESTION_NOT_FOUND",
|
||||
]);
|
||||
|
||||
const AskUserToolSchema = Type.Object(
|
||||
{
|
||||
@@ -85,7 +85,6 @@ type AskUserQuestionState = {
|
||||
questions: QuestionRequestQuestion[];
|
||||
expiresAtMs: number;
|
||||
phase: AskUserQuestionPhase;
|
||||
gatewayCall?: AskUserGatewayCall;
|
||||
answer?: Promise<QuestionWaitAnswerResult>;
|
||||
claim?: ReturnType<typeof registerPendingAgentQuestion>;
|
||||
waiters: Set<() => void>;
|
||||
@@ -423,25 +422,60 @@ async function waitForPromptDelivery(
|
||||
return { error: new Error("ask_user prompt is no longer active") };
|
||||
}
|
||||
|
||||
function readQuestionErrorReason(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== "object") {
|
||||
return undefined;
|
||||
/** Shares question ownership and prompt delivery without installing a plaintext answer claim. */
|
||||
export function beginAskUserPromptDelivery(params: {
|
||||
toolCallId: string;
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
agentId?: string;
|
||||
questions: QuestionRequestQuestion[];
|
||||
timeoutSeconds: number;
|
||||
}) {
|
||||
const questionId = buildAskUserQuestionId(
|
||||
params.toolCallId,
|
||||
params.sessionKey,
|
||||
params.runId,
|
||||
params.agentId,
|
||||
);
|
||||
const sessionKey = askUserSessionKey(params.sessionKey, params.agentId);
|
||||
const reserved = askUserQuestions.get(questionId);
|
||||
const existing = findAskUserQuestionForSession(sessionKey);
|
||||
if ((reserved && reserved.phase.kind !== "reserved") || (existing && existing !== reserved)) {
|
||||
throw new ToolInputError(
|
||||
"a question is already pending for this session; wait for it to resolve before requesting another",
|
||||
);
|
||||
}
|
||||
const requestError = error as { details?: unknown; name?: unknown };
|
||||
if (requestError.name !== "GatewayClientRequestError") {
|
||||
return undefined;
|
||||
}
|
||||
const details = requestError.details;
|
||||
if (!details || typeof details !== "object" || Array.isArray(details)) {
|
||||
return undefined;
|
||||
}
|
||||
const reason = (details as { reason?: unknown }).reason;
|
||||
return typeof reason === "string" ? reason : undefined;
|
||||
}
|
||||
|
||||
function isTerminalQuestionResolveError(error: unknown): boolean {
|
||||
const reason = readQuestionErrorReason(error);
|
||||
return reason !== undefined && TERMINAL_QUESTION_ERROR_REASONS.has(reason);
|
||||
const state: AskUserQuestionState = reserved ?? {
|
||||
questionId,
|
||||
sessionKey,
|
||||
questions: params.questions,
|
||||
expiresAtMs: 0,
|
||||
phase: { kind: "registering" },
|
||||
waiters: new Set(),
|
||||
};
|
||||
Object.assign(state, { sessionKey, questions: params.questions });
|
||||
state.expiresAtMs = Date.now() + params.timeoutSeconds * 1_000;
|
||||
transitionAskUserQuestion(state, { kind: "registering" });
|
||||
askUserQuestions.set(questionId, state);
|
||||
return {
|
||||
questionId,
|
||||
hasSubscriber: reserved !== undefined,
|
||||
markReady() {
|
||||
if (reserved) {
|
||||
markAskUserPromptReady(questionId, params.questions);
|
||||
} else {
|
||||
transitionAskUserQuestion(state, { kind: "answerable" });
|
||||
}
|
||||
},
|
||||
waitForDelivery(signal?: AbortSignal) {
|
||||
return waitForPromptDelivery(state, signal);
|
||||
},
|
||||
release() {
|
||||
if (askUserQuestions.get(questionId) === state) {
|
||||
releaseAskUserQuestion(questionId);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resetPendingAskUserQuestionsForTest(): void {
|
||||
@@ -504,44 +538,14 @@ export function createAskUserTool(params: {
|
||||
questions: normalized.questions,
|
||||
expiresAtMs: Date.now() + timeoutMs,
|
||||
phase: { kind: "registering" },
|
||||
gatewayCall,
|
||||
waiters: new Set(),
|
||||
} satisfies AskUserQuestionState);
|
||||
Object.assign(state, { sessionKey, questions: normalized.questions, gatewayCall });
|
||||
Object.assign(state, { sessionKey, questions: normalized.questions });
|
||||
state.expiresAtMs = Date.now() + timeoutMs;
|
||||
transitionAskUserQuestion(state, { kind: "registering" });
|
||||
askUserQuestions.set(questionId, state);
|
||||
let cancellation:
|
||||
| Promise<Extract<QuestionWaitAnswerResult, { status: "answered" }> | undefined>
|
||||
| undefined;
|
||||
let registered = false;
|
||||
const cancelPendingQuestion = (resolvedBy: string) => {
|
||||
cancellation ??= (async () => {
|
||||
try {
|
||||
await gatewayCall(
|
||||
"question.resolve",
|
||||
{ timeoutMs: ASK_USER_RPC_GRACE_MS },
|
||||
{ id: questionId, cancel: true, resolvedBy },
|
||||
);
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
if (!isTerminalQuestionResolveError(error)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const result = (await gatewayCall(
|
||||
"question.waitAnswer",
|
||||
{ timeoutMs: ASK_USER_RPC_GRACE_MS },
|
||||
{ id: questionId, timeoutMs: 1_000 },
|
||||
)) as QuestionWaitAnswerResult;
|
||||
return result.status === "answered" ? result : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
})();
|
||||
return cancellation;
|
||||
};
|
||||
const cancelPendingQuestion = createGatewayQuestionCanceller({ gatewayCall, questionId });
|
||||
const cancelOnAbort = () => {
|
||||
if (askUserQuestions.get(questionId) === state) {
|
||||
releaseAskUserQuestion(questionId);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/** Shared registration, wait, and cancellation for blocking Gateway questions. */
|
||||
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { Value } from "typebox/value";
|
||||
import {
|
||||
QuestionWaitAnswerResultSchema,
|
||||
type QuestionWaitAnswerResult,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import type { OperatorScope } from "../../gateway/operator-scopes.js";
|
||||
import type { GatewayCallOptions } from "./gateway.js";
|
||||
|
||||
/** Grace added to Gateway RPC deadlines so the question's own timeout wins. */
|
||||
const QUESTION_RPC_GRACE_MS = 10_000;
|
||||
|
||||
export type GatewayQuestionCall = (
|
||||
method: string,
|
||||
opts: GatewayCallOptions,
|
||||
params?: unknown,
|
||||
// Mirrors callGatewayTool's extra bag so every question caller shares one type.
|
||||
extra?: {
|
||||
expectFinal?: boolean;
|
||||
scopes?: OperatorScope[];
|
||||
requireAgentRuntimeIdentity?: boolean;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
|
||||
const TERMINAL_QUESTION_ERROR_REASONS = new Set([
|
||||
"QUESTION_ALREADY_TERMINAL",
|
||||
"QUESTION_NOT_FOUND",
|
||||
]);
|
||||
|
||||
/** Reads the Gateway's structured failure reason from a question RPC rejection. */
|
||||
export function readQuestionErrorReason(error: unknown): string | undefined {
|
||||
const requestError = asNullableRecord(error);
|
||||
if (requestError?.name !== "GatewayClientRequestError") {
|
||||
return undefined;
|
||||
}
|
||||
const reason = asNullableRecord(requestError.details)?.reason;
|
||||
return typeof reason === "string" ? reason : undefined;
|
||||
}
|
||||
|
||||
function isTerminalQuestionResolveError(error: unknown): boolean {
|
||||
const reason = readQuestionErrorReason(error);
|
||||
return reason !== undefined && TERMINAL_QUESTION_ERROR_REASONS.has(reason);
|
||||
}
|
||||
|
||||
/** Waits for one question's terminal state, validating the Gateway's payload. */
|
||||
export async function awaitGatewayQuestionAnswer(params: {
|
||||
gatewayCall: GatewayQuestionCall;
|
||||
questionId: string;
|
||||
timeoutMs: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<QuestionWaitAnswerResult> {
|
||||
const result = await params.gatewayCall(
|
||||
"question.waitAnswer",
|
||||
{ timeoutMs: params.timeoutMs + QUESTION_RPC_GRACE_MS },
|
||||
{ id: params.questionId, timeoutMs: params.timeoutMs },
|
||||
params.signal ? { signal: params.signal } : undefined,
|
||||
);
|
||||
if (!Value.Check(QuestionWaitAnswerResultSchema, result)) {
|
||||
throw new Error("question.waitAnswer returned an invalid status");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a pending question at most once. An answer that lands between the
|
||||
* caller's timeout and this cancel makes the Gateway reject it as terminal; the
|
||||
* recovery read returns that answer so a submitted response is never discarded.
|
||||
*/
|
||||
export function createGatewayQuestionCanceller(params: {
|
||||
gatewayCall: GatewayQuestionCall;
|
||||
questionId: string;
|
||||
}): (
|
||||
resolvedBy: string,
|
||||
) => Promise<Extract<QuestionWaitAnswerResult, { status: "answered" }> | undefined> {
|
||||
let cancellation:
|
||||
| Promise<Extract<QuestionWaitAnswerResult, { status: "answered" }> | undefined>
|
||||
| undefined;
|
||||
return (resolvedBy: string) => {
|
||||
cancellation ??= (async () => {
|
||||
try {
|
||||
await params.gatewayCall(
|
||||
"question.resolve",
|
||||
{ timeoutMs: QUESTION_RPC_GRACE_MS },
|
||||
{ id: params.questionId, cancel: true, resolvedBy },
|
||||
);
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
if (!isTerminalQuestionResolveError(error)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const result = await awaitGatewayQuestionAnswer({
|
||||
gatewayCall: params.gatewayCall,
|
||||
questionId: params.questionId,
|
||||
timeoutMs: 1_000,
|
||||
});
|
||||
return result.status === "answered" ? result : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
})();
|
||||
return cancellation;
|
||||
};
|
||||
}
|
||||
@@ -468,6 +468,7 @@ describe("gateway tool runtime identity", () => {
|
||||
it.each([
|
||||
["exec.approval.request", undefined, false],
|
||||
["plugin.approval.request", "codex", false],
|
||||
["secrets.store.delete", undefined, false],
|
||||
["exec.approval.request", undefined, true],
|
||||
["plugin.approval.request", "codex", true],
|
||||
] as const)(
|
||||
|
||||
@@ -231,6 +231,7 @@ const AGENT_RUNTIME_IDENTITY_METHODS = new Set<string>([
|
||||
"cron.remove",
|
||||
"cron.run",
|
||||
"cron.runs",
|
||||
"secrets.store.delete",
|
||||
]);
|
||||
|
||||
const OPTIONAL_LOCAL_AGENT_RUNTIME_IDENTITY_METHODS = new Set<string>(["node.invoke"]);
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
import { Value } from "typebox/value";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { claimPendingAgentQuestionAnswer } from "../harness/gateway-question.js";
|
||||
import { reserveAskUserPromptDelivery, settleAskUserPromptDelivery } from "./ask-user-tool.js";
|
||||
import { resetPendingAskUserQuestionsForTest } from "./ask-user-tool.test-support.js";
|
||||
import { createSecretsTool, normalizeSecretsRequestParams } from "./secrets-tool.js";
|
||||
|
||||
type GatewayCall = NonNullable<Parameters<typeof createSecretsTool>[0]["gatewayCall"]>;
|
||||
|
||||
function gatewayStub(
|
||||
implementation: (
|
||||
method: string,
|
||||
opts: Record<string, unknown>,
|
||||
params: Record<string, unknown>,
|
||||
extra?: { signal?: AbortSignal; requireAgentRuntimeIdentity?: boolean },
|
||||
) => Promise<unknown>,
|
||||
) {
|
||||
const mock = vi.fn(implementation);
|
||||
return { mock, call: mock as unknown as GatewayCall };
|
||||
}
|
||||
|
||||
function requestedQuestionId(mock: ReturnType<typeof gatewayStub>["mock"]): string {
|
||||
const request = mock.mock.calls.find(([method]) => method === "question.request");
|
||||
const questionId = request?.[2].id;
|
||||
if (typeof questionId !== "string") {
|
||||
throw new Error("question.request did not include an id");
|
||||
}
|
||||
return questionId;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetPendingAskUserQuestionsForTest();
|
||||
});
|
||||
|
||||
describe("secrets request normalization", () => {
|
||||
it("builds one store-bound secret question and clamps its timeout", () => {
|
||||
const normalized = normalizeSecretsRequestParams({
|
||||
action: "request",
|
||||
name: "SERVICE_API_KEY",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.test"],
|
||||
reason: "Deploy the service",
|
||||
timeoutSeconds: 5,
|
||||
});
|
||||
|
||||
expect(normalized).toEqual({
|
||||
name: "SERVICE_API_KEY",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.test"],
|
||||
reason: "Deploy the service",
|
||||
timeoutSeconds: 30,
|
||||
questions: [
|
||||
{
|
||||
questionId: "secret_value",
|
||||
header: "API key",
|
||||
question: "Provide the secret for SERVICE_API_KEY. Deploy the service",
|
||||
options: [],
|
||||
isSecret: true,
|
||||
secretStore: {
|
||||
name: "SERVICE_API_KEY",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.test"],
|
||||
reason: "Deploy the service",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(
|
||||
normalizeSecretsRequestParams({
|
||||
name: "SERVICE_SETTING",
|
||||
kind: "secret",
|
||||
timeoutSeconds: 9_999,
|
||||
}).timeoutSeconds,
|
||||
).toBe(3_600);
|
||||
expect(
|
||||
Value.Check(createSecretsTool({}).parameters, {
|
||||
action: "set",
|
||||
name: "SERVICE_API_KEY",
|
||||
value: "test-secret-value-123",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["lowercase names", { name: "bad_name", kind: "secret" }, "uppercase"],
|
||||
["unknown entry kinds", { name: "VALID_NAME", kind: "password" }, "kind must be"],
|
||||
[
|
||||
"environment-value requests the model could read back",
|
||||
{ name: "VALID_NAME", kind: "env" },
|
||||
'kind must be "secret"',
|
||||
],
|
||||
[
|
||||
"duplicate allowed hosts",
|
||||
{ name: "VALID_NAME", kind: "secret", allowedHosts: ["a.test", "a.test"] },
|
||||
"unique",
|
||||
],
|
||||
[
|
||||
"oversized reasons",
|
||||
{ name: "VALID_NAME", kind: "secret", reason: "r".repeat(201) },
|
||||
"at most 200",
|
||||
],
|
||||
["fractional timeouts", { name: "VALID_NAME", kind: "secret", timeoutSeconds: 1.5 }, "integer"],
|
||||
])("rejects %s before contacting the gateway", (_label, params, message) => {
|
||||
expect(() => normalizeSecretsRequestParams(params)).toThrow(message);
|
||||
});
|
||||
});
|
||||
|
||||
describe("secrets tool", () => {
|
||||
it("stores through a human-only question and returns metadata without claiming chat text", async () => {
|
||||
let finishWait: ((value: unknown) => void) | undefined;
|
||||
const gateway = gatewayStub(async (method, _options, params) => {
|
||||
if (method === "question.request") {
|
||||
return { id: params.id };
|
||||
}
|
||||
if (method === "question.get") {
|
||||
return { question: { questions: [{ secretStoreExisting: { updatedAtMs: 123 } }] } };
|
||||
}
|
||||
if (method === "question.waitAnswer") {
|
||||
return await new Promise((resolve) => {
|
||||
finishWait = resolve;
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
});
|
||||
const tool = createSecretsTool({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:secrets",
|
||||
runId: "run-secrets",
|
||||
gatewayCall: gateway.call,
|
||||
});
|
||||
const pending = tool.execute("call-secret", {
|
||||
action: "request",
|
||||
name: "SERVICE_API_KEY",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.test"],
|
||||
reason: "Deploy the service",
|
||||
});
|
||||
await vi.waitFor(() => expect(finishWait).toBeTypeOf("function"));
|
||||
|
||||
await expect(
|
||||
claimPendingAgentQuestionAnswer({
|
||||
sessionKey: "agent:main:secrets",
|
||||
text: "test-secret-value-123",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
finishWait?.({
|
||||
status: "answered",
|
||||
answers: { answers: { secret_value: ["stored"] } },
|
||||
});
|
||||
const result = await pending;
|
||||
|
||||
expect(result.details).toEqual({
|
||||
status: "stored",
|
||||
name: "SERVICE_API_KEY",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.test"],
|
||||
replacedExisting: true,
|
||||
ref: { source: "store", id: "SERVICE_API_KEY" },
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("test-secret-value-123");
|
||||
expect(result.content[0]).toMatchObject({
|
||||
text: expect.stringContaining('{source:"store", id:"SERVICE_API_KEY"}'),
|
||||
});
|
||||
expect(gateway.mock).toHaveBeenCalledWith(
|
||||
"question.request",
|
||||
{},
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^ask_[a-f0-9]{32}$/),
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:secrets",
|
||||
runId: "run-secrets",
|
||||
timeoutMs: 900_000,
|
||||
questions: [
|
||||
expect.objectContaining({
|
||||
questionId: "secret_value",
|
||||
options: [],
|
||||
isSecret: true,
|
||||
secretStore: {
|
||||
name: "SERVICE_API_KEY",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.test"],
|
||||
reason: "Deploy the service",
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
// Store-bound minting is admin-gated server-side; the tool must declare
|
||||
// the scope explicitly instead of the questions-scope default.
|
||||
{ scopes: ["operator.admin"] },
|
||||
);
|
||||
});
|
||||
|
||||
it("continues a registered credential request when optional replacement metadata is unavailable", async () => {
|
||||
const gateway = gatewayStub(async (method, _options, params) => {
|
||||
if (method === "question.request") {
|
||||
return { id: params.id };
|
||||
}
|
||||
if (method === "question.get") {
|
||||
throw new Error("metadata temporarily unavailable");
|
||||
}
|
||||
return { status: "answered", answers: { answers: { secret_value: ["stored"] } } };
|
||||
});
|
||||
|
||||
const result = await createSecretsTool({ gatewayCall: gateway.call }).execute("call-metadata", {
|
||||
action: "request",
|
||||
name: "SERVICE_SETTING",
|
||||
kind: "secret",
|
||||
});
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
status: "stored",
|
||||
kind: "secret",
|
||||
replacedExisting: false,
|
||||
});
|
||||
expect(gateway.mock.mock.calls.some(([method]) => method === "question.resolve")).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["pending", "expired", "cancelled"] as const)(
|
||||
"returns no_answer when a credential request is %s",
|
||||
async (status) => {
|
||||
const gateway = gatewayStub(async (method, _options, params) => {
|
||||
if (method === "question.request") {
|
||||
return { id: params.id };
|
||||
}
|
||||
if (method === "question.get") {
|
||||
return { question: { questions: [{}] } };
|
||||
}
|
||||
return { status };
|
||||
});
|
||||
|
||||
const result = await createSecretsTool({
|
||||
sessionKey: `agent:main:${status}`,
|
||||
gatewayCall: gateway.call,
|
||||
}).execute(`call-${status}`, { action: "request", name: "SERVICE_API_KEY", kind: "secret" });
|
||||
|
||||
expect(result.details).toEqual({ status: "no_answer" });
|
||||
if (status === "pending") {
|
||||
expect(gateway.mock).toHaveBeenCalledWith(
|
||||
"question.resolve",
|
||||
{ timeoutMs: 10_000 },
|
||||
{
|
||||
id: requestedQuestionId(gateway.mock),
|
||||
cancel: true,
|
||||
resolvedBy: "wait-timeout",
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a credential stored when the human answers during the wait timeout", async () => {
|
||||
// The Gateway rejects the late cancel as terminal and hands back the answer;
|
||||
// the value is already in the store, so the tool must not report no_answer.
|
||||
const terminal = Object.assign(new Error("question is already answered"), {
|
||||
name: "GatewayClientRequestError",
|
||||
details: { reason: "QUESTION_ALREADY_TERMINAL" },
|
||||
});
|
||||
let waitCalls = 0;
|
||||
const gateway = gatewayStub(async (method, _options, params) => {
|
||||
if (method === "question.request") {
|
||||
return { id: params.id };
|
||||
}
|
||||
if (method === "question.get") {
|
||||
return { question: { questions: [{}] } };
|
||||
}
|
||||
if (method === "question.resolve") {
|
||||
throw terminal;
|
||||
}
|
||||
waitCalls += 1;
|
||||
return waitCalls === 1
|
||||
? { status: "pending" }
|
||||
: { status: "answered", answers: { answers: { secret_value: ["stored"] } } };
|
||||
});
|
||||
|
||||
const result = await createSecretsTool({
|
||||
sessionKey: "agent:main:late-answer",
|
||||
gatewayCall: gateway.call,
|
||||
}).execute("call-late-answer", {
|
||||
action: "request",
|
||||
name: "SERVICE_API_KEY",
|
||||
kind: "secret",
|
||||
});
|
||||
|
||||
expect(result.details).toMatchObject({ status: "stored", name: "SERVICE_API_KEY" });
|
||||
});
|
||||
|
||||
it("cancels a registered credential request when its agent run aborts", async () => {
|
||||
const controller = new AbortController();
|
||||
const gateway = gatewayStub(async (method, _options, params, extra) => {
|
||||
if (method === "question.request") {
|
||||
return { id: params.id };
|
||||
}
|
||||
if (method === "question.get") {
|
||||
return { question: { questions: [{}] } };
|
||||
}
|
||||
if (method === "question.resolve") {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
return await new Promise((_resolve, reject) => {
|
||||
extra?.signal?.addEventListener("abort", () => reject(new Error("aborted")), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
const pending = createSecretsTool({
|
||||
sessionKey: "agent:main:secret-abort",
|
||||
gatewayCall: gateway.call,
|
||||
}).execute(
|
||||
"call-secret-abort",
|
||||
{ action: "request", name: "SERVICE_API_KEY", kind: "secret" },
|
||||
controller.signal,
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(gateway.mock.mock.calls.some(([method]) => method === "question.waitAnswer")).toBe(
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
controller.abort(new Error("stop"));
|
||||
|
||||
await expect(pending).rejects.toThrow("aborted");
|
||||
expect(gateway.mock).toHaveBeenCalledWith(
|
||||
"question.resolve",
|
||||
{ timeoutMs: 10_000 },
|
||||
{ id: requestedQuestionId(gateway.mock), cancel: true, resolvedBy: "run-abort" },
|
||||
);
|
||||
});
|
||||
|
||||
it("shares the existing subscriber prompt reservation and settlement lifecycle", async () => {
|
||||
const sessionKey = "agent:main:secret-prompt";
|
||||
const args = { action: "request", name: "SERVICE_API_KEY", kind: "secret" };
|
||||
const normalized = normalizeSecretsRequestParams(args);
|
||||
const reservation = reserveAskUserPromptDelivery({
|
||||
toolCallId: "call-secret-prompt",
|
||||
sessionKey,
|
||||
questions: normalized.questions,
|
||||
timeoutSeconds: normalized.timeoutSeconds,
|
||||
});
|
||||
if (!reservation) {
|
||||
throw new Error("expected secret prompt reservation");
|
||||
}
|
||||
let finishWait: ((value: unknown) => void) | undefined;
|
||||
const gateway = gatewayStub(async (method, _options, params) => {
|
||||
if (method === "question.request") {
|
||||
return { id: params.id };
|
||||
}
|
||||
if (method === "question.get") {
|
||||
return { question: { questions: [{}] } };
|
||||
}
|
||||
if (method === "question.waitAnswer") {
|
||||
return await new Promise((resolve) => {
|
||||
finishWait = resolve;
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
});
|
||||
const pending = createSecretsTool({ sessionKey, gatewayCall: gateway.call }).execute(
|
||||
"call-secret-prompt",
|
||||
args,
|
||||
);
|
||||
await vi.waitFor(() => expect(finishWait).toBeTypeOf("function"));
|
||||
|
||||
settleAskUserPromptDelivery(reservation.questionId);
|
||||
finishWait?.({
|
||||
status: "answered",
|
||||
answers: { answers: { secret_value: ["stored"] } },
|
||||
});
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ details: { status: "stored" } });
|
||||
});
|
||||
|
||||
it("lists store metadata and environment previews", async () => {
|
||||
const entries = [
|
||||
{
|
||||
name: "SERVICE_API_KEY",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.test"],
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
updatedBy: "operator:alice",
|
||||
scopeKind: "team",
|
||||
scopeId: "",
|
||||
},
|
||||
{
|
||||
name: "SERVICE_MODE",
|
||||
kind: "env",
|
||||
value: "preview-value",
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
scopeKind: "team",
|
||||
scopeId: "",
|
||||
},
|
||||
];
|
||||
const gateway = gatewayStub(async () => ({ entries }));
|
||||
|
||||
const result = await createSecretsTool({ gatewayCall: gateway.call }).execute("call-list", {
|
||||
action: "list",
|
||||
});
|
||||
|
||||
expect(result.details).toEqual({ entries });
|
||||
expect(result.content[0]).toMatchObject({
|
||||
text: expect.stringContaining("SERVICE_API_KEY | secret | hosts: api.example.test"),
|
||||
});
|
||||
expect(result.content[0]).toMatchObject({
|
||||
text: expect.stringContaining("value: preview-value"),
|
||||
});
|
||||
expect(gateway.mock).toHaveBeenCalledWith("secrets.store.list", {}, {}, undefined);
|
||||
});
|
||||
|
||||
it("requires verified agent runtime identity when deleting a store entry", async () => {
|
||||
const gateway = gatewayStub(async () => ({ ok: true, reloaded: false }));
|
||||
|
||||
const result = await createSecretsTool({ gatewayCall: gateway.call }).execute("call-delete", {
|
||||
action: "delete",
|
||||
name: "SERVICE_API_KEY",
|
||||
});
|
||||
|
||||
expect(result.details).toEqual({ ok: true, reloaded: false });
|
||||
expect(gateway.mock).toHaveBeenCalledWith(
|
||||
"secrets.store.delete",
|
||||
{},
|
||||
{ name: "SERVICE_API_KEY" },
|
||||
{ requireAgentRuntimeIdentity: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
import { asNullableRecord, isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { Type } from "typebox";
|
||||
import {
|
||||
validateSecretsStoreListResult,
|
||||
type QuestionRequestQuestion,
|
||||
type SecretsStoreListResult,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { ENV_SECRET_REF_ID_RE } from "../../config/types.secrets.js";
|
||||
import { ADMIN_SCOPE } from "../../gateway/operator-scopes.js";
|
||||
import { stringEnum } from "../schema/string-enum.js";
|
||||
import { describeSecretsTool } from "../tool-description-presets.js";
|
||||
import { DEFAULT_ASK_USER_TIMEOUT_SECONDS } from "./ask-user-tool-normalization.js";
|
||||
import { beginAskUserPromptDelivery } from "./ask-user-tool.js";
|
||||
import { type AnyAgentTool, readToolStringParam, ToolInputError } from "./common.js";
|
||||
import {
|
||||
awaitGatewayQuestionAnswer,
|
||||
createGatewayQuestionCanceller,
|
||||
type GatewayQuestionCall,
|
||||
} from "./gateway-question-lifecycle.js";
|
||||
import { callGatewayTool } from "./gateway.js";
|
||||
import { jsonResult, textResult } from "./tool-results.js";
|
||||
|
||||
type SecretStoreKind = "secret";
|
||||
const SecretsToolSchema = Type.Object(
|
||||
{
|
||||
action: stringEnum(["request", "list", "delete"], {
|
||||
description: "`request` a value from the human, `list` entry metadata, or `delete` an entry.",
|
||||
}),
|
||||
name: Type.Optional(
|
||||
Type.String({
|
||||
maxLength: 128,
|
||||
pattern: "^[A-Z][A-Z0-9_]{0,127}$",
|
||||
description:
|
||||
"Entry name in uppercase environment-variable form, also its SecretRef id (STRIPE_API_KEY). Required for request and delete.",
|
||||
}),
|
||||
),
|
||||
kind: Type.Optional(
|
||||
stringEnum(["secret"], {
|
||||
description: "Only `secret` may be requested; requested values are never readable back.",
|
||||
}),
|
||||
),
|
||||
allowedHosts: Type.Optional(
|
||||
Type.Array(Type.String({ minLength: 1, maxLength: 253 }), {
|
||||
maxItems: 128,
|
||||
uniqueItems: true,
|
||||
description:
|
||||
"Exact hostnames allowed to receive a secret, without scheme or port (api.stripe.com). Secret entries only; leaving this empty stores a secret that can never be substituted.",
|
||||
}),
|
||||
),
|
||||
reason: Type.Optional(
|
||||
Type.String({
|
||||
maxLength: 200,
|
||||
description: "One line shown to the human explaining why the credential is needed.",
|
||||
}),
|
||||
),
|
||||
timeoutSeconds: Type.Optional(
|
||||
Type.Integer({
|
||||
description: "Seconds to wait for the human on request; defaults to 900, clamped 30-3600.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
type NormalizedSecretsRequestParams = {
|
||||
name: string;
|
||||
kind: SecretStoreKind;
|
||||
allowedHosts?: string[];
|
||||
reason?: string;
|
||||
timeoutSeconds: number;
|
||||
questions: QuestionRequestQuestion[];
|
||||
};
|
||||
|
||||
function readSecretStoreName(params: Record<string, unknown>): string {
|
||||
const name = readToolStringParam(params, "name", { required: true });
|
||||
if (!ENV_SECRET_REF_ID_RE.test(name)) {
|
||||
throw new ToolInputError("name must be an uppercase environment-variable name");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Normalizes one secure question for both tool-start reservation and tool execution. */
|
||||
export function normalizeSecretsRequestParams(value: unknown): NormalizedSecretsRequestParams {
|
||||
if (!isRecord(value)) {
|
||||
throw new ToolInputError("secrets arguments must be an object");
|
||||
}
|
||||
const params = value;
|
||||
const name = readSecretStoreName(params);
|
||||
// Requests are secret-only on purpose: `list` renders env values, so an
|
||||
// agent-requested env entry would be readable straight back through this
|
||||
// tool, breaking the promise the masked prompt makes to the human.
|
||||
const kind = readToolStringParam(params, "kind", { required: false }) ?? "secret";
|
||||
if (kind !== "secret") {
|
||||
throw new ToolInputError(
|
||||
'kind must be "secret"; environment values are set in Settings or the CLI, not requested from the model',
|
||||
);
|
||||
}
|
||||
const allowedHosts = params.allowedHosts;
|
||||
if (allowedHosts !== undefined) {
|
||||
if (
|
||||
!Array.isArray(allowedHosts) ||
|
||||
allowedHosts.length > 128 ||
|
||||
allowedHosts.some((host) => typeof host !== "string" || !host || host.length > 253) ||
|
||||
new Set(allowedHosts).size !== allowedHosts.length
|
||||
) {
|
||||
throw new ToolInputError("allowedHosts must contain up to 128 unique non-empty hostnames");
|
||||
}
|
||||
}
|
||||
if (params.reason !== undefined && typeof params.reason !== "string") {
|
||||
throw new ToolInputError("reason must be a string");
|
||||
}
|
||||
const reason = typeof params.reason === "string" ? params.reason.trim() : undefined;
|
||||
if (reason && reason.length > 200) {
|
||||
throw new ToolInputError("reason must be at most 200 characters");
|
||||
}
|
||||
const timeout = params.timeoutSeconds;
|
||||
if (
|
||||
timeout !== undefined &&
|
||||
(typeof timeout !== "number" || !Number.isFinite(timeout) || !Number.isInteger(timeout))
|
||||
) {
|
||||
throw new ToolInputError("timeoutSeconds must be an integer");
|
||||
}
|
||||
const timeoutSeconds = Math.min(3_600, Math.max(30, timeout ?? DEFAULT_ASK_USER_TIMEOUT_SECONDS));
|
||||
const binding: NonNullable<QuestionRequestQuestion["secretStore"]> = {
|
||||
name,
|
||||
kind: "secret",
|
||||
...(allowedHosts !== undefined ? { allowedHosts } : {}),
|
||||
...(reason ? { reason } : {}),
|
||||
};
|
||||
const question = `Provide the secret for ${name}.${reason ? ` ${reason}` : ""}`;
|
||||
return {
|
||||
...binding,
|
||||
kind: "secret",
|
||||
timeoutSeconds,
|
||||
questions: [
|
||||
{
|
||||
questionId: "secret_value",
|
||||
header: "API key",
|
||||
question,
|
||||
options: [],
|
||||
isSecret: true,
|
||||
secretStore: binding,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function noSecretAnswerResult(status: "pending" | "expired" | "cancelled") {
|
||||
const details = { status: "no_answer" as const };
|
||||
const note =
|
||||
status === "cancelled"
|
||||
? "The credential request was cancelled; proceed with best judgment."
|
||||
: "No credential arrived; proceed with best judgment.";
|
||||
return textResult(`${note}\n\n${JSON.stringify(details, null, 2)}`, details);
|
||||
}
|
||||
|
||||
function storedSecretResult(params: NormalizedSecretsRequestParams, replacedExisting: boolean) {
|
||||
const details = {
|
||||
status: "stored" as const,
|
||||
name: params.name,
|
||||
kind: params.kind,
|
||||
...(params.allowedHosts !== undefined ? { allowedHosts: params.allowedHosts } : {}),
|
||||
replacedExisting,
|
||||
ref: { source: "store" as const, id: params.name },
|
||||
};
|
||||
const guidance = [
|
||||
`Stored ${params.name} without exposing its value.`,
|
||||
`Reference {source:"store", id:"${params.name}"} in config SecretRefs.`,
|
||||
"Secret values are substituted at egress only when secrets.egressProxy.enabled is true and the destination matches their allowed hosts.",
|
||||
];
|
||||
return textResult(`${guidance.join(" ")}\n\n${JSON.stringify(details, null, 2)}`, details);
|
||||
}
|
||||
|
||||
function listSecretStoreResult(result: SecretsStoreListResult) {
|
||||
const lines = result.entries.map((entry) => {
|
||||
const fields = [entry.name, entry.kind];
|
||||
if (entry.kind === "secret" && entry.allowedHosts?.length) {
|
||||
fields.push(`hosts: ${entry.allowedHosts.join(", ")}`);
|
||||
}
|
||||
if (entry.kind === "env") {
|
||||
fields.push(`value: ${entry.value}`);
|
||||
}
|
||||
fields.push(`updated: ${new Date(entry.updatedAtMs).toISOString()}`);
|
||||
if (entry.updatedBy) {
|
||||
fields.push(`by: ${entry.updatedBy}`);
|
||||
}
|
||||
return fields.join(" | ");
|
||||
});
|
||||
return textResult(lines.length ? lines.join("\n") : "The secret store is empty.", result);
|
||||
}
|
||||
|
||||
/** Creates the metadata-only secret-store tool and its human-entered write flow. */
|
||||
export function createSecretsTool(params: {
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
gatewayCall?: GatewayQuestionCall;
|
||||
}): AnyAgentTool {
|
||||
const gatewayCall: GatewayQuestionCall = params.gatewayCall ?? callGatewayTool;
|
||||
return {
|
||||
label: "Secrets",
|
||||
name: "secrets",
|
||||
description: describeSecretsTool(),
|
||||
parameters: SecretsToolSchema,
|
||||
execute: async (toolCallId, args, signal) => {
|
||||
if (!isRecord(args)) {
|
||||
throw new ToolInputError("secrets arguments must be an object");
|
||||
}
|
||||
const input = args;
|
||||
const action = readToolStringParam(input, "action", { required: true });
|
||||
if (action === "list") {
|
||||
const result = await gatewayCall(
|
||||
"secrets.store.list",
|
||||
{},
|
||||
{},
|
||||
signal ? { signal } : undefined,
|
||||
);
|
||||
if (!validateSecretsStoreListResult(result)) {
|
||||
throw new Error("secrets.store.list returned invalid metadata");
|
||||
}
|
||||
return listSecretStoreResult(result);
|
||||
}
|
||||
if (action === "delete") {
|
||||
const name = readSecretStoreName(input);
|
||||
const result = await gatewayCall(
|
||||
"secrets.store.delete",
|
||||
{},
|
||||
{ name },
|
||||
{ requireAgentRuntimeIdentity: true, ...(signal ? { signal } : {}) },
|
||||
);
|
||||
return jsonResult(result);
|
||||
}
|
||||
if (action !== "request") {
|
||||
throw new ToolInputError(`Unknown secrets action: ${action}`);
|
||||
}
|
||||
const request = normalizeSecretsRequestParams(input);
|
||||
const delivery = beginAskUserPromptDelivery({
|
||||
toolCallId,
|
||||
sessionKey: params.sessionKey,
|
||||
runId: params.runId,
|
||||
agentId: params.agentId,
|
||||
questions: request.questions,
|
||||
timeoutSeconds: request.timeoutSeconds,
|
||||
});
|
||||
const timeoutMs = request.timeoutSeconds * 1_000;
|
||||
let registered = false;
|
||||
const cancelPendingQuestion = createGatewayQuestionCanceller({
|
||||
gatewayCall,
|
||||
questionId: delivery.questionId,
|
||||
});
|
||||
const cancelOnAbort = () => {
|
||||
delivery.release();
|
||||
void cancelPendingQuestion("run-abort");
|
||||
};
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const registration = asNullableRecord(
|
||||
await gatewayCall(
|
||||
"question.request",
|
||||
{},
|
||||
{
|
||||
id: delivery.questionId,
|
||||
questions: request.questions,
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
...(params.runId ? { runId: params.runId } : {}),
|
||||
timeoutMs,
|
||||
},
|
||||
// Store-bound requests are gated on an admin client server-side; the
|
||||
// default least-privilege scope for question.request is not enough.
|
||||
{ scopes: [ADMIN_SCOPE], ...(signal ? { signal } : {}) },
|
||||
),
|
||||
);
|
||||
registered = true;
|
||||
if (registration?.id !== delivery.questionId) {
|
||||
throw new Error("question.request returned an unexpected question id");
|
||||
}
|
||||
const record = await gatewayCall(
|
||||
"question.get",
|
||||
{},
|
||||
{ id: delivery.questionId },
|
||||
signal ? { signal } : undefined,
|
||||
).catch(() => undefined);
|
||||
const questionRecord = asNullableRecord(asNullableRecord(record)?.question);
|
||||
const questions = questionRecord?.questions;
|
||||
const replacedExisting =
|
||||
Array.isArray(questions) &&
|
||||
asNullableRecord(questions[0])?.secretStoreExisting !== undefined;
|
||||
signal?.addEventListener("abort", cancelOnAbort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
cancelOnAbort();
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
const answerPromise = awaitGatewayQuestionAnswer({
|
||||
gatewayCall,
|
||||
questionId: delivery.questionId,
|
||||
timeoutMs,
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
delivery.markReady();
|
||||
if (delivery.hasSubscriber) {
|
||||
const first = await Promise.race([
|
||||
delivery.waitForDelivery(signal).then((result) => ({
|
||||
kind: "delivery" as const,
|
||||
result,
|
||||
})),
|
||||
answerPromise.then((result) => ({ kind: "answer" as const, result })),
|
||||
]);
|
||||
if (first.kind === "delivery" && first.result.error !== undefined) {
|
||||
await cancelPendingQuestion("prompt-delivery-failed");
|
||||
throw new Error("credential-request prompt delivery failed", {
|
||||
cause: first.result.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
const result = await answerPromise;
|
||||
signal?.throwIfAborted();
|
||||
if (result.status === "answered") {
|
||||
if (result.answers.answers.secret_value?.[0] !== "stored") {
|
||||
throw new Error("credential request returned an unexpected answer marker");
|
||||
}
|
||||
return storedSecretResult(request, replacedExisting);
|
||||
}
|
||||
if (result.status === "pending") {
|
||||
// The human may have submitted between the wait timeout and this
|
||||
// cancel; the Gateway then rejects the cancel and hands back the
|
||||
// answer, which means the credential is already stored.
|
||||
const answered = await cancelPendingQuestion("wait-timeout");
|
||||
if (answered) {
|
||||
return storedSecretResult(request, replacedExisting);
|
||||
}
|
||||
}
|
||||
if (
|
||||
result.status === "pending" ||
|
||||
result.status === "expired" ||
|
||||
result.status === "cancelled"
|
||||
) {
|
||||
return noSecretAnswerResult(result.status);
|
||||
}
|
||||
throw new Error("question.waitAnswer returned an invalid status");
|
||||
} catch (error) {
|
||||
if (registered || signal?.aborted) {
|
||||
await cancelPendingQuestion(signal?.aborted ? "run-abort" : "tool-error");
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", cancelOnAbort);
|
||||
delivery.release();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
} from "../../media-understanding/runtime.js";
|
||||
import { getImageMetadata } from "../../media/media-services.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { formatHumanList } from "../../shared/human-list.js";
|
||||
import { runCommandWithRuntime } from "../cli-utils.js";
|
||||
import { getModelsCommandSecretTargetIds } from "../command-secret-targets.js";
|
||||
import { readInputFiles, writeOutputAsset } from "../media-output.js";
|
||||
@@ -49,8 +48,6 @@ import {
|
||||
|
||||
const IMAGE_OUTPUT_FORMATS = ["png", "jpeg", "webp"] as const;
|
||||
const IMAGE_BACKGROUNDS = ["transparent", "opaque", "auto"] as const;
|
||||
const IMAGE_QUALITIES = ["low", "medium", "high", "auto"] as const;
|
||||
const IMAGE_OPENAI_MODERATIONS = ["low", "auto"] as const;
|
||||
|
||||
async function runImageGenerate(params: {
|
||||
capability: "image.generate" | "image.edit";
|
||||
@@ -235,20 +232,60 @@ async function runImageDescribe(params: {
|
||||
} satisfies CapabilityEnvelope;
|
||||
}
|
||||
|
||||
function normalizeImageOption<T extends string>(
|
||||
function normalizeImageOutputFormat(
|
||||
raw: string | undefined,
|
||||
values: readonly T[],
|
||||
label: string,
|
||||
): T | undefined {
|
||||
): ImageGenerationOutputFormat | undefined {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(raw);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
const match = values.find((value) => value === normalized);
|
||||
if (match) {
|
||||
return match;
|
||||
if ((IMAGE_OUTPUT_FORMATS as readonly string[]).includes(normalized)) {
|
||||
return normalized as ImageGenerationOutputFormat;
|
||||
}
|
||||
throw new Error(`${label} must be one of ${formatHumanList(values)}`);
|
||||
throw new Error("--output-format must be one of png, jpeg, or webp");
|
||||
}
|
||||
|
||||
function normalizeImageBackground(
|
||||
raw: string | undefined,
|
||||
label = "--background",
|
||||
): ImageGenerationBackground | undefined {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(raw);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if ((IMAGE_BACKGROUNDS as readonly string[]).includes(normalized)) {
|
||||
return normalized as ImageGenerationBackground;
|
||||
}
|
||||
throw new Error(`${label} must be one of transparent, opaque, or auto`);
|
||||
}
|
||||
|
||||
function normalizeImageQuality(raw: string | undefined): ImageGenerationQuality | undefined {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(raw);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
normalized === "low" ||
|
||||
normalized === "medium" ||
|
||||
normalized === "high" ||
|
||||
normalized === "auto"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error("--quality must be one of low, medium, high, or auto");
|
||||
}
|
||||
|
||||
function normalizeOpenAIModeration(
|
||||
raw: string | undefined,
|
||||
): ImageGenerationOpenAIModeration | undefined {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(raw);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized === "low" || normalized === "auto") {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error("--openai-moderation must be one of low or auto");
|
||||
}
|
||||
|
||||
function resolveImageDescribeInput(filePath: string): string {
|
||||
@@ -277,6 +314,11 @@ function addImageGenerationOptions(command: Command): Command {
|
||||
.option("--json", "Output JSON", false);
|
||||
}
|
||||
|
||||
function readStringOption(opts: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = opts[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function resolveImageGenerationOptions(opts: Record<string, unknown>, command: Command) {
|
||||
return {
|
||||
agent: resolveCapabilityAgentOption(command, opts.agent),
|
||||
@@ -285,27 +327,14 @@ function resolveImageGenerationOptions(opts: Record<string, unknown>, command: C
|
||||
size: opts.size as string | undefined,
|
||||
aspectRatio: opts.aspectRatio as string | undefined,
|
||||
resolution: opts.resolution as "1K" | "2K" | "4K" | undefined,
|
||||
outputFormat: normalizeImageOption(
|
||||
opts.outputFormat as string | undefined,
|
||||
IMAGE_OUTPUT_FORMATS,
|
||||
"--output-format",
|
||||
),
|
||||
background: normalizeImageOption(
|
||||
opts.background as string | undefined,
|
||||
IMAGE_BACKGROUNDS,
|
||||
"--background",
|
||||
),
|
||||
openaiBackground: normalizeImageOption(
|
||||
outputFormat: normalizeImageOutputFormat(readStringOption(opts, "outputFormat")),
|
||||
background: normalizeImageBackground(readStringOption(opts, "background")),
|
||||
openaiBackground: normalizeImageBackground(
|
||||
opts.openaiBackground as string | undefined,
|
||||
IMAGE_BACKGROUNDS,
|
||||
"--openai-background",
|
||||
),
|
||||
openaiModeration: normalizeImageOption(
|
||||
opts.openaiModeration as string | undefined,
|
||||
IMAGE_OPENAI_MODERATIONS,
|
||||
"--openai-moderation",
|
||||
),
|
||||
quality: normalizeImageOption(opts.quality as string | undefined, IMAGE_QUALITIES, "--quality"),
|
||||
openaiModeration: normalizeOpenAIModeration(readStringOption(opts, "openaiModeration")),
|
||||
quality: normalizeImageQuality(readStringOption(opts, "quality")),
|
||||
timeoutMs: parseOptionalTimeoutMs(opts.timeoutMs as string | number | undefined),
|
||||
output: opts.output as string | undefined,
|
||||
};
|
||||
|
||||
@@ -148,12 +148,26 @@ export function createGatewayAuxHandlers(
|
||||
),
|
||||
{ cacheRejections: true },
|
||||
);
|
||||
const reloadSecrets = createGatewaySecretsReloader(params);
|
||||
const loadSecretsModule = createLazyPromise(() => import("./server-methods/secrets.js"), {
|
||||
cacheRejections: true,
|
||||
});
|
||||
const loadSecretStoreWriteService = createLazyPromise(
|
||||
async () => {
|
||||
const { createSecretStoreWriteService } = await loadSecretsModule();
|
||||
return createSecretStoreWriteService({ reloadSecrets, log: params.log });
|
||||
},
|
||||
{ cacheRejections: true },
|
||||
);
|
||||
const questionManager = new QuestionManager();
|
||||
const loadQuestionHandlers = createLazyPromise(
|
||||
() =>
|
||||
import("./server-methods/question.js").then(({ createQuestionHandlers }) =>
|
||||
createQuestionHandlers(questionManager),
|
||||
),
|
||||
async () => {
|
||||
const [{ createQuestionHandlers }, storeWriteService] = await Promise.all([
|
||||
import("./server-methods/question.js"),
|
||||
loadSecretStoreWriteService(),
|
||||
]);
|
||||
return createQuestionHandlers(questionManager, storeWriteService);
|
||||
},
|
||||
{ cacheRejections: true },
|
||||
);
|
||||
const pluginApprovalManager = createApprovalManager<PluginApprovalRequestPayload>(
|
||||
@@ -302,41 +316,43 @@ export function createGatewayAuxHandlers(
|
||||
{ cacheRejections: true },
|
||||
);
|
||||
const loadSecretsHandlers = createLazyPromise(
|
||||
() =>
|
||||
import("./server-methods/secrets.js").then(({ createSecretsHandlers }) =>
|
||||
createSecretsHandlers({
|
||||
reloadSecrets: createGatewaySecretsReloader(params),
|
||||
log: params.log,
|
||||
resolveSecrets: async ({
|
||||
allowedPaths,
|
||||
commandName,
|
||||
forcedActivePaths,
|
||||
optionalActivePaths,
|
||||
providerOverrides,
|
||||
targetIds,
|
||||
}) => {
|
||||
const { assignments, diagnostics, inactiveRefPaths } =
|
||||
await resolveCommandSecretsFromActiveRuntimeSnapshot({
|
||||
commandName,
|
||||
targetIds: new Set(targetIds),
|
||||
...(allowedPaths ? { allowedPaths: new Set(allowedPaths) } : {}),
|
||||
...(forcedActivePaths ? { forcedActivePaths: new Set(forcedActivePaths) } : {}),
|
||||
...(optionalActivePaths
|
||||
? { optionalActivePaths: new Set(optionalActivePaths) }
|
||||
: {}),
|
||||
...(providerOverrides ? { providerOverrides } : {}),
|
||||
});
|
||||
if (assignments.length === 0) {
|
||||
return {
|
||||
assignments: [] as CommandSecretAssignment[],
|
||||
diagnostics,
|
||||
inactiveRefPaths,
|
||||
};
|
||||
}
|
||||
return { assignments, diagnostics, inactiveRefPaths };
|
||||
},
|
||||
}),
|
||||
),
|
||||
async () => {
|
||||
const [{ createSecretsHandlers }, storeWriteService] = await Promise.all([
|
||||
loadSecretsModule(),
|
||||
loadSecretStoreWriteService(),
|
||||
]);
|
||||
return createSecretsHandlers({
|
||||
reloadSecrets,
|
||||
storeWriteService,
|
||||
log: params.log,
|
||||
resolveSecrets: async ({
|
||||
allowedPaths,
|
||||
commandName,
|
||||
forcedActivePaths,
|
||||
optionalActivePaths,
|
||||
providerOverrides,
|
||||
targetIds,
|
||||
}) => {
|
||||
const { assignments, diagnostics, inactiveRefPaths } =
|
||||
await resolveCommandSecretsFromActiveRuntimeSnapshot({
|
||||
commandName,
|
||||
targetIds: new Set(targetIds),
|
||||
...(allowedPaths ? { allowedPaths: new Set(allowedPaths) } : {}),
|
||||
...(forcedActivePaths ? { forcedActivePaths: new Set(forcedActivePaths) } : {}),
|
||||
...(optionalActivePaths ? { optionalActivePaths: new Set(optionalActivePaths) } : {}),
|
||||
...(providerOverrides ? { providerOverrides } : {}),
|
||||
});
|
||||
if (assignments.length === 0) {
|
||||
return {
|
||||
assignments: [] as CommandSecretAssignment[],
|
||||
diagnostics,
|
||||
inactiveRefPaths,
|
||||
};
|
||||
}
|
||||
return { assignments, diagnostics, inactiveRefPaths };
|
||||
},
|
||||
});
|
||||
},
|
||||
{ cacheRejections: true },
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { upsertSessionEntryCore } from "../../config/sessions/session-accessor.js";
|
||||
import { addSessionMember } from "../../config/sessions/session-sharing-store.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { clearAgentRunContext, registerAgentRunContext } from "../../infra/agent-run-registry.js";
|
||||
import { isSecretValueRegisteredForRedaction } from "../../logging/secret-redaction-registry.js";
|
||||
import * as secretsRuntimeState from "../../secrets/runtime-state.js";
|
||||
import { listSecretStoreEntries, writeSecretStoreEntry } from "../../secrets/store/secret-store.js";
|
||||
import { ensureProfileForEmail, setUserProfileRole } from "../../state/user-profiles.js";
|
||||
import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
|
||||
import { QuestionManager } from "../question-manager.js";
|
||||
@@ -9,22 +13,34 @@ import { createGatewayBroadcaster } from "../server-broadcast.js";
|
||||
import type { GatewayWsClient } from "../server/ws-types.js";
|
||||
import { canReceiveSessionEvent } from "../session-sharing.js";
|
||||
import { createQuestionHandlers } from "./question.js";
|
||||
import { createSecretStoreWriteService } from "./secrets.js";
|
||||
import type { GatewayClient, GatewayRequestHandlerOptions, RespondFn } from "./types.js";
|
||||
|
||||
let manager: QuestionManager;
|
||||
let broadcast: ReturnType<typeof vi.fn>;
|
||||
let handlers: ReturnType<typeof createQuestionHandlers>;
|
||||
type SecretStoreReload = Parameters<typeof createSecretStoreWriteService>[0]["reloadSecrets"];
|
||||
let reloadSecrets: ReturnType<typeof vi.fn<SecretStoreReload>>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Store-bound resolution revalidates the requesting run at the write, so the
|
||||
// fixtures must present the live run the questions are bound to.
|
||||
registerAgentRunContext(requestParams.runId, {
|
||||
sessionKey: requestParams.sessionKey,
|
||||
agentId: requestParams.agentId,
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
manager = new QuestionManager();
|
||||
broadcast = vi.fn();
|
||||
handlers = createQuestionHandlers(manager);
|
||||
reloadSecrets = vi.fn<SecretStoreReload>().mockResolvedValue({ warningCount: 0 });
|
||||
handlers = createQuestionHandlers(manager, createSecretStoreWriteService({ reloadSecrets }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearAgentRunContext(requestParams.runId);
|
||||
manager.reset();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -71,6 +87,30 @@ const requestParams = {
|
||||
timeoutMs: 100,
|
||||
};
|
||||
|
||||
const secretRequestQuestion = {
|
||||
questionId: "secret_value",
|
||||
header: "API key",
|
||||
question: "Provide SERVICE_API_KEY",
|
||||
options: [],
|
||||
isSecret: true,
|
||||
secretStore: {
|
||||
name: "SERVICE_API_KEY",
|
||||
kind: "secret" as const,
|
||||
allowedHosts: ["api.example.test"],
|
||||
},
|
||||
};
|
||||
|
||||
const secretRequestParams = {
|
||||
...requestParams,
|
||||
questions: [secretRequestQuestion],
|
||||
};
|
||||
|
||||
// Store-bound questions may only be minted by admin-scoped clients; every
|
||||
// store-bound request below presents one so validation errors stay specific.
|
||||
const adminRequestClient = {
|
||||
connect: { scopes: ["operator.admin"] },
|
||||
} as GatewayClient;
|
||||
|
||||
describe("question gateway methods", () => {
|
||||
it("conceals foreign session questions for role-none readers while preserving global prompts", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
@@ -389,6 +429,412 @@ describe("question gateway methods", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
behavior: "bindings without the secret-input marker",
|
||||
questions: [{ ...secretRequestParams.questions[0], isSecret: false }],
|
||||
},
|
||||
{
|
||||
behavior: "secret requests mixed with another question",
|
||||
questions: [secretRequestParams.questions[0], requestParams.questions[0]],
|
||||
},
|
||||
{
|
||||
behavior: "secret requests with answer options",
|
||||
questions: [
|
||||
{
|
||||
...secretRequestParams.questions[0],
|
||||
options: [{ label: "First" }, { label: "Second" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
behavior: "secret requests allowing multiple selections",
|
||||
questions: [{ ...secretRequestParams.questions[0], multiSelect: true }],
|
||||
},
|
||||
{
|
||||
behavior: "invalid secret store entry names",
|
||||
questions: [
|
||||
{
|
||||
...secretRequestParams.questions[0],
|
||||
secretStore: { ...secretRequestQuestion.secretStore, name: "lowercase" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
behavior: "invalid secret store entry kinds",
|
||||
questions: [
|
||||
{
|
||||
...secretRequestParams.questions[0],
|
||||
secretStore: { ...secretRequestQuestion.secretStore, kind: "password" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
behavior: "more than 128 proposed allowed hosts",
|
||||
questions: [
|
||||
{
|
||||
...secretRequestParams.questions[0],
|
||||
secretStore: {
|
||||
...secretRequestQuestion.secretStore,
|
||||
allowedHosts: Array.from({ length: 129 }, (_, index) => `${index}.example.test`),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
behavior: "allowed hosts proposed for environment entries",
|
||||
questions: [
|
||||
{
|
||||
...secretRequestParams.questions[0],
|
||||
secretStore: { ...secretRequestQuestion.secretStore, kind: "env" },
|
||||
},
|
||||
],
|
||||
},
|
||||
])("rejects $behavior before opening a pending secret question", async ({ questions }) => {
|
||||
const response = await call(
|
||||
"question.request",
|
||||
{ ...requestParams, questions },
|
||||
{ client: adminRequestClient },
|
||||
);
|
||||
|
||||
expect(response).toMatchObject([false, undefined, { code: "INVALID_REQUEST" }]);
|
||||
expect(manager.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ behavior: "a connect-less client", client: null },
|
||||
{
|
||||
behavior: "a questions-scoped client",
|
||||
client: {
|
||||
connect: { scopes: ["operator.questions"] },
|
||||
} as GatewayClient,
|
||||
},
|
||||
])(
|
||||
"refuses to mint store-bound questions for $behavior so questions scope cannot reach store writes",
|
||||
async ({ client }) => {
|
||||
const response = await call(
|
||||
"question.request",
|
||||
secretRequestParams,
|
||||
client ? { client } : undefined,
|
||||
);
|
||||
|
||||
expect(response).toMatchObject([
|
||||
false,
|
||||
undefined,
|
||||
{ code: "INVALID_REQUEST", message: expect.stringContaining("operator.admin") },
|
||||
]);
|
||||
expect(manager.list()).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses to write a credential once its requesting run is gone", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
const requested = await call("question.request", secretRequestParams, {
|
||||
client: adminRequestClient,
|
||||
});
|
||||
const id = (requested[1] as { id: string }).id;
|
||||
// The requester dies between the prompt and the human's submission.
|
||||
clearAgentRunContext(requestParams.runId);
|
||||
|
||||
const resolved = await call("question.resolve", {
|
||||
id,
|
||||
answers: { answers: { secret_value: ["test-secret-value-stale-runner-123"] } },
|
||||
});
|
||||
|
||||
expect(resolved).toMatchObject([
|
||||
false,
|
||||
undefined,
|
||||
{ code: "INVALID_REQUEST", details: { reason: "QUESTION_REQUESTER_INACTIVE" } },
|
||||
]);
|
||||
expect(listSecretStoreEntries({ scope: { kind: "team" } })).toEqual([]);
|
||||
expect(manager.get(id)?.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to mint a store-bound question that names no requesting run", async () => {
|
||||
const { runId: _runId, ...withoutRun } = secretRequestParams;
|
||||
|
||||
expect(
|
||||
await call("question.request", withoutRun, { client: adminRequestClient }),
|
||||
).toMatchObject([
|
||||
false,
|
||||
undefined,
|
||||
{ code: "INVALID_REQUEST", message: expect.stringContaining("runId") },
|
||||
]);
|
||||
expect(manager.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it("annotates a store-bound question with replacement metadata without exposing the old value", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
const oldValue = "test-secret-value-existing-123";
|
||||
writeSecretStoreEntry({
|
||||
scope: { kind: "team" },
|
||||
name: "SERVICE_API_KEY",
|
||||
value: oldValue,
|
||||
kind: "secret",
|
||||
updatedBy: "Previous Operator",
|
||||
});
|
||||
|
||||
const response = await call("question.request", secretRequestParams, {
|
||||
client: adminRequestClient,
|
||||
});
|
||||
const id = (response[1] as { id: string }).id;
|
||||
const record = manager.get(id);
|
||||
|
||||
expect(record?.questions[0]).toMatchObject({
|
||||
secretStore: secretRequestQuestion.secretStore,
|
||||
secretStoreExisting: { updatedAtMs: 1_000, updatedBy: "Previous Operator" },
|
||||
});
|
||||
expect(JSON.stringify(record)).not.toContain(oldValue);
|
||||
});
|
||||
});
|
||||
|
||||
it("diverts operator-entered credentials into the store and exposes only a stored marker", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
const response = await call("question.request", secretRequestParams, {
|
||||
client: adminRequestClient,
|
||||
});
|
||||
const id = (response[1] as { id: string }).id;
|
||||
const value = "test-secret-value-gateway-diversion-123";
|
||||
const client = {
|
||||
connect: { client: { displayName: "Trusted Operator" } },
|
||||
} as GatewayClient;
|
||||
|
||||
const resolved = await call(
|
||||
"question.resolve",
|
||||
{ id, answers: { answers: { secret_value: [value] } }, resolvedBy: "control-ui" },
|
||||
{ client },
|
||||
);
|
||||
const safeAnswers = { answers: { secret_value: ["stored"] } };
|
||||
|
||||
expect(resolved).toEqual([true, { status: "answered", answers: safeAnswers }, undefined]);
|
||||
expect(listSecretStoreEntries({ scope: { kind: "team" } })).toMatchObject([
|
||||
{
|
||||
name: "SERVICE_API_KEY",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.test"],
|
||||
updatedBy: "Trusted Operator",
|
||||
},
|
||||
]);
|
||||
expect(manager.get(id)).toMatchObject({ status: "answered", answers: safeAnswers });
|
||||
expect(await call("question.waitAnswer", { id })).toEqual([
|
||||
true,
|
||||
{ status: "answered", answers: safeAnswers },
|
||||
undefined,
|
||||
]);
|
||||
expect(broadcast).toHaveBeenCalledWith("question.resolved", {
|
||||
id,
|
||||
status: "answered",
|
||||
answers: safeAnswers,
|
||||
});
|
||||
expect(JSON.stringify([resolved, manager.get(id), broadcast.mock.calls])).not.toContain(
|
||||
value,
|
||||
);
|
||||
expect(isSecretValueRegisteredForRedaction(value)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses operator-edited hosts and keeps invalid store submissions pending for retry", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
const requested = await call("question.request", secretRequestParams, {
|
||||
client: adminRequestClient,
|
||||
});
|
||||
const id = (requested[1] as { id: string }).id;
|
||||
const value = "test-secret-value-retry-123";
|
||||
const answers = { answers: { secret_value: [value] } };
|
||||
|
||||
const invalid = await call("question.resolve", {
|
||||
id,
|
||||
answers,
|
||||
secretStoreAllowedHosts: ["*.example.test"],
|
||||
});
|
||||
expect(invalid).toMatchObject([
|
||||
false,
|
||||
undefined,
|
||||
{ code: "INVALID_REQUEST", message: expect.stringContaining("wildcard") },
|
||||
]);
|
||||
expect(manager.get(id)?.status).toBe("pending");
|
||||
expect(isSecretValueRegisteredForRedaction(value)).toBe(true);
|
||||
|
||||
const retried = await call("question.resolve", {
|
||||
id,
|
||||
answers,
|
||||
secretStoreAllowedHosts: ["replacement.example.test"],
|
||||
});
|
||||
expect(retried[0]).toBe(true);
|
||||
expect(listSecretStoreEntries({ scope: { kind: "team" } })[0]).toMatchObject({
|
||||
allowedHosts: ["replacement.example.test"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ behavior: "no submitted value", answers: { secret_value: [] } },
|
||||
{
|
||||
behavior: "multiple submitted values",
|
||||
answers: { secret_value: ["test-secret-value-first", "test-secret-value-second"] },
|
||||
},
|
||||
{
|
||||
behavior: "an unrelated submitted answer",
|
||||
answers: { secret_value: ["test-secret-value-only"], destination: ["Home"] },
|
||||
},
|
||||
])("keeps a secret question pending when there is $behavior", async ({ answers }) => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
const requested = await call("question.request", secretRequestParams, {
|
||||
client: adminRequestClient,
|
||||
});
|
||||
const id = (requested[1] as { id: string }).id;
|
||||
|
||||
expect(await call("question.resolve", { id, answers: { answers } })).toMatchObject([
|
||||
false,
|
||||
undefined,
|
||||
{ code: "INVALID_REQUEST" },
|
||||
]);
|
||||
expect(manager.get(id)?.status).toBe("pending");
|
||||
expect(listSecretStoreEntries({ scope: { kind: "team" } })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects host overrides on env entries and ordinary questions without settling them", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
const envQuestion = {
|
||||
...secretRequestParams.questions[0],
|
||||
secretStore: { name: "SERVICE_URL", kind: "env" as const },
|
||||
};
|
||||
const envResponse = await call(
|
||||
"question.request",
|
||||
{ ...secretRequestParams, questions: [envQuestion] },
|
||||
{ client: adminRequestClient },
|
||||
);
|
||||
const envId = (envResponse[1] as { id: string }).id;
|
||||
|
||||
expect(
|
||||
await call("question.resolve", {
|
||||
id: envId,
|
||||
answers: { answers: { secret_value: ["https://example.test"] } },
|
||||
secretStoreAllowedHosts: ["example.test"],
|
||||
}),
|
||||
).toMatchObject([false, undefined, { code: "INVALID_REQUEST" }]);
|
||||
expect(manager.get(envId)?.status).toBe("pending");
|
||||
|
||||
const ordinaryResponse = await call("question.request", requestParams);
|
||||
const ordinaryId = (ordinaryResponse[1] as { id: string }).id;
|
||||
expect(
|
||||
await call("question.resolve", {
|
||||
id: ordinaryId,
|
||||
answers: { answers: { destination: ["Home"] } },
|
||||
secretStoreAllowedHosts: ["example.test"],
|
||||
}),
|
||||
).toMatchObject([false, undefined, { code: "INVALID_REQUEST" }]);
|
||||
expect(manager.get(ordinaryId)?.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
|
||||
it("stores environment entries without host policy and preserves secret-question cancellation", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
const envQuestion = {
|
||||
...secretRequestParams.questions[0],
|
||||
secretStore: { name: "SERVICE_URL", kind: "env" as const },
|
||||
};
|
||||
const envResponse = await call(
|
||||
"question.request",
|
||||
{ ...secretRequestParams, questions: [envQuestion] },
|
||||
{ client: adminRequestClient },
|
||||
);
|
||||
const envId = (envResponse[1] as { id: string }).id;
|
||||
expect(
|
||||
(
|
||||
await call("question.resolve", {
|
||||
id: envId,
|
||||
answers: { answers: { secret_value: ["https://example.test"] } },
|
||||
})
|
||||
)[0],
|
||||
).toBe(true);
|
||||
expect(listSecretStoreEntries({ scope: { kind: "team" } })[0]).toMatchObject({
|
||||
name: "SERVICE_URL",
|
||||
kind: "env",
|
||||
valuePreview: "https://example.test",
|
||||
});
|
||||
|
||||
const cancelledResponse = await call("question.request", secretRequestParams, {
|
||||
client: adminRequestClient,
|
||||
});
|
||||
const cancelledId = (cancelledResponse[1] as { id: string }).id;
|
||||
expect(await call("question.resolve", { id: cancelledId, cancel: true })).toEqual([
|
||||
true,
|
||||
{ status: "cancelled" },
|
||||
undefined,
|
||||
]);
|
||||
expect(manager.get(cancelledId)?.status).toBe("cancelled");
|
||||
});
|
||||
});
|
||||
|
||||
it("cold-refreshes configured SecretRefs after a store-bound question is answered", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
vi.spyOn(secretsRuntimeState, "getActiveSecretsRuntimeSnapshotState").mockReturnValue({
|
||||
sourceConfig: {
|
||||
models: {
|
||||
providers: {
|
||||
test: {
|
||||
baseUrl: "https://provider.example.test",
|
||||
models: [],
|
||||
apiKey: { source: "store", provider: "default", id: "SERVICE_API_KEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
config: {},
|
||||
authStores: [],
|
||||
authStoreCredentialsRevision: 0,
|
||||
warnings: [],
|
||||
webTools: {
|
||||
search: { providerSource: "none", diagnostics: [] },
|
||||
fetch: { providerSource: "none", diagnostics: [] },
|
||||
diagnostics: [],
|
||||
},
|
||||
});
|
||||
const requested = await call("question.request", secretRequestParams, {
|
||||
client: adminRequestClient,
|
||||
});
|
||||
const id = (requested[1] as { id: string }).id;
|
||||
|
||||
expect(
|
||||
(
|
||||
await call("question.resolve", {
|
||||
id,
|
||||
answers: { answers: { secret_value: ["test-secret-value-cold-refresh-123"] } },
|
||||
})
|
||||
)[0],
|
||||
).toBe(true);
|
||||
expect(reloadSecrets).toHaveBeenCalledWith({
|
||||
forceColdRefKeys: new Set(["store:default:SERVICE_API_KEY"]),
|
||||
joinInFlight: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps store-bound questions pending when the write service is unavailable", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
const service = createSecretStoreWriteService({ reloadSecrets });
|
||||
handlers = createQuestionHandlers(manager, {
|
||||
...service,
|
||||
write: vi.fn().mockRejectedValue(new Error("database unavailable")),
|
||||
});
|
||||
const requested = await call("question.request", secretRequestParams, {
|
||||
client: adminRequestClient,
|
||||
});
|
||||
const id = (requested[1] as { id: string }).id;
|
||||
|
||||
expect(
|
||||
await call("question.resolve", {
|
||||
id,
|
||||
answers: { answers: { secret_value: ["test-secret-value-unavailable-123"] } },
|
||||
}),
|
||||
).toMatchObject([false, undefined, { code: "UNAVAILABLE" }]);
|
||||
expect(manager.get(id)?.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns INVALID_REQUEST for answers that violate the stored question", async () => {
|
||||
const requested = await call("question.request", {
|
||||
...requestParams,
|
||||
|
||||
@@ -14,10 +14,18 @@ import {
|
||||
validateQuestionWaitAnswerParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { ENV_SECRET_REF_ID_RE } from "../../config/types.secrets.js";
|
||||
import { getAgentRunContext } from "../../infra/agent-run-registry.js";
|
||||
import {
|
||||
handleQuestionChannelRequested,
|
||||
handleQuestionChannelResolved,
|
||||
} from "../../infra/question-channel-runtime.js";
|
||||
import { registerSecretValueForRedaction } from "../../logging/secret-redaction-registry.js";
|
||||
import {
|
||||
listSecretStoreEntries,
|
||||
SECRET_STORE_ALLOWED_HOSTS_MAX,
|
||||
SecretStoreValidationError,
|
||||
} from "../../secrets/store/secret-store.js";
|
||||
import { hasOperatorBoundary } from "../operator-role-policy.js";
|
||||
import {
|
||||
QuestionManager,
|
||||
@@ -33,6 +41,7 @@ import {
|
||||
resolveSessionSharingTarget,
|
||||
} from "../session-sharing.js";
|
||||
import { resolveStoredSessionKeyForAgentStore } from "../session-store-key.js";
|
||||
import type { SecretStoreWriteService } from "./secrets.js";
|
||||
import type { GatewayClient, GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
|
||||
const DEFAULT_QUESTION_TIMEOUT_MS = 15 * 60 * 1_000;
|
||||
@@ -117,11 +126,54 @@ function normalizeQuestions(params: QuestionRequestParams): Question[] {
|
||||
`question '${question.questionId}' must have either no options or 2 to 4 options`,
|
||||
);
|
||||
}
|
||||
if (question.isSecret) {
|
||||
const binding = question.secretStore;
|
||||
if (question.isSecret && !binding) {
|
||||
throw new QuestionRequestValidationError(
|
||||
`question '${question.questionId}': secret questions are not supported yet`,
|
||||
);
|
||||
}
|
||||
if (binding) {
|
||||
if (!question.isSecret) {
|
||||
throw new QuestionRequestValidationError(
|
||||
`question '${question.questionId}': secret store binding requires a secret question`,
|
||||
);
|
||||
}
|
||||
if (params.questions.length !== 1 || question.options.length !== 0 || question.multiSelect) {
|
||||
throw new QuestionRequestValidationError(
|
||||
`question '${question.questionId}': secret store requests require one free-text, single-select question`,
|
||||
);
|
||||
}
|
||||
if (!ENV_SECRET_REF_ID_RE.test(binding.name)) {
|
||||
throw new QuestionRequestValidationError(
|
||||
`question '${question.questionId}': invalid secret store entry name`,
|
||||
);
|
||||
}
|
||||
if (binding.kind !== "secret" && binding.kind !== "env") {
|
||||
throw new QuestionRequestValidationError(
|
||||
`question '${question.questionId}': invalid secret store entry kind`,
|
||||
);
|
||||
}
|
||||
if ((binding.allowedHosts?.length ?? 0) > SECRET_STORE_ALLOWED_HOSTS_MAX) {
|
||||
throw new QuestionRequestValidationError(
|
||||
`question '${question.questionId}': secret store allowed hosts exceed the limit`,
|
||||
);
|
||||
}
|
||||
if (binding.kind === "env" && binding.allowedHosts !== undefined) {
|
||||
throw new QuestionRequestValidationError("Allowed hosts apply only to secret entries.");
|
||||
}
|
||||
const existing = listSecretStoreEntries({ scope: { kind: "team" } }).find(
|
||||
(entry) => entry.name === binding.name,
|
||||
);
|
||||
if (existing) {
|
||||
return {
|
||||
...question,
|
||||
secretStoreExisting: {
|
||||
updatedAtMs: existing.updatedAtMs,
|
||||
...(existing.updatedBy ? { updatedBy: existing.updatedBy } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const optionLabels = new Set<string>();
|
||||
for (const option of question.options) {
|
||||
const normalizedLabel = option.label.trim().toLowerCase();
|
||||
@@ -137,7 +189,10 @@ function normalizeQuestions(params: QuestionRequestParams): Question[] {
|
||||
}
|
||||
|
||||
/** Creates the lazily loaded question RPC surface for one Gateway lifetime. */
|
||||
export function createQuestionHandlers(manager: QuestionManager): GatewayRequestHandlers {
|
||||
export function createQuestionHandlers(
|
||||
manager: QuestionManager,
|
||||
storeWriteService: SecretStoreWriteService,
|
||||
): GatewayRequestHandlers {
|
||||
return {
|
||||
"question.request": ({ params, respond, context, client }) => {
|
||||
if (!validateQuestionRequestParams(params)) {
|
||||
@@ -145,6 +200,31 @@ export function createQuestionHandlers(manager: QuestionManager): GatewayRequest
|
||||
return;
|
||||
}
|
||||
const request = params as QuestionRequestParams;
|
||||
// Store-bound questions end in a secret-store write on resolve. Without
|
||||
// this gate any operator.questions client could mint and self-answer one,
|
||||
// bypassing the operator.admin requirement on secrets.store.set.
|
||||
if (request.questions.some((question) => question.secretStore) && !request.runId) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"secret store questions must carry the requesting runId",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (request.questions.some((question) => question.secretStore) && !isGatewayAdmin(client)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"secret store questions require an operator.admin client",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const requestedSession = request.sessionKey
|
||||
? resolveRequestedSessionAgentId(
|
||||
@@ -216,6 +296,14 @@ export function createQuestionHandlers(manager: QuestionManager): GatewayRequest
|
||||
return;
|
||||
}
|
||||
if (!managerError(error, respond)) {
|
||||
if (request.questions.some((question) => question.secretStore)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, "Secret store entry metadata is unavailable."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -261,7 +349,7 @@ export function createQuestionHandlers(manager: QuestionManager): GatewayRequest
|
||||
}
|
||||
}
|
||||
},
|
||||
"question.resolve": ({ params, respond, client, context }) => {
|
||||
"question.resolve": async ({ params, respond, client, context }) => {
|
||||
if (!validateQuestionResolveParams(params)) {
|
||||
validationError("question.resolve", validateQuestionResolveParams.errors, respond);
|
||||
return;
|
||||
@@ -281,10 +369,113 @@ export function createQuestionHandlers(manager: QuestionManager): GatewayRequest
|
||||
return;
|
||||
}
|
||||
}
|
||||
const result =
|
||||
"cancel" in request
|
||||
? manager.cancel(request.id, request.resolvedBy)
|
||||
: manager.resolve(request.id, request.answers, request.resolvedBy);
|
||||
if ("cancel" in request) {
|
||||
respond(true, manager.cancel(request.id, request.resolvedBy), undefined);
|
||||
return;
|
||||
}
|
||||
const secretQuestion = question?.questions[0];
|
||||
const binding = secretQuestion?.secretStore;
|
||||
if (!binding || !question) {
|
||||
if (request.secretStoreAllowedHosts !== undefined) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"Secret store allowed hosts require a store-bound question.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(
|
||||
true,
|
||||
manager.resolve(request.id, request.answers, request.resolvedBy),
|
||||
undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (question.status !== "pending") {
|
||||
throw new QuestionManagerError(
|
||||
QuestionManagerErrorCodes.ALREADY_TERMINAL,
|
||||
`question '${request.id}' is already ${question.status}`,
|
||||
);
|
||||
}
|
||||
const submittedAnswers = request.answers.answers;
|
||||
const values = Object.hasOwn(submittedAnswers, secretQuestion.questionId)
|
||||
? submittedAnswers[secretQuestion.questionId]
|
||||
: undefined;
|
||||
const value = values?.[0];
|
||||
if (
|
||||
Object.keys(submittedAnswers).length !== 1 ||
|
||||
values?.length !== 1 ||
|
||||
value === undefined
|
||||
) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`question '${secretQuestion.questionId}' requires exactly one secret value`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
registerSecretValueForRedaction(value);
|
||||
const allowedHosts = request.secretStoreAllowedHosts ?? binding.allowedHosts;
|
||||
if (binding.kind === "env" && allowedHosts !== undefined) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "Allowed hosts apply only to secret entries."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Closure-bound authority: a store write is delegated by one live agent
|
||||
// run, so revalidate that exact run immediately before the sink. The
|
||||
// recorded runId is provenance; a terminated or replaced requester must
|
||||
// not reach secret-store I/O. No await may separate this from the write.
|
||||
if (!question.runId || !getAgentRunContext(question.runId)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"the agent run that requested this credential is no longer active",
|
||||
{ details: { reason: "QUESTION_REQUESTER_INACTIVE" } },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await storeWriteService.write({
|
||||
name: binding.name,
|
||||
value,
|
||||
kind: binding.kind,
|
||||
...(allowedHosts !== undefined ? { allowedHosts } : {}),
|
||||
updatedBy: storeWriteService.resolveUpdatedBy(client),
|
||||
});
|
||||
} catch (error) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
error instanceof SecretStoreValidationError
|
||||
? ErrorCodes.INVALID_REQUEST
|
||||
: ErrorCodes.UNAVAILABLE,
|
||||
error instanceof SecretStoreValidationError
|
||||
? error.message
|
||||
: "Secret store entry could not be saved.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Only the synthetic marker may enter manager state, event fanout,
|
||||
// waiting agent responses, and channel-rendered question outcomes.
|
||||
const result = manager.resolve(
|
||||
request.id,
|
||||
{ answers: { [secretQuestion.questionId]: ["stored"] } },
|
||||
request.resolvedBy,
|
||||
);
|
||||
respond(true, result, undefined);
|
||||
} catch (error) {
|
||||
if (!managerError(error, respond)) {
|
||||
|
||||
@@ -45,11 +45,12 @@ vi.mock("../../secrets/target-registry.js", () => ({
|
||||
isKnownSecretTargetId: () => false,
|
||||
}));
|
||||
|
||||
import { isSecretValueRegisteredForRedaction } from "../../logging/secret-redaction-registry.js";
|
||||
import {
|
||||
TALK_TEST_PROVIDER_API_KEY_PATH,
|
||||
TALK_TEST_PROVIDER_API_KEY_PATH_SEGMENTS,
|
||||
} from "../../test-utils/talk-test-provider.js";
|
||||
import { createSecretsHandlers } from "./secrets.js";
|
||||
import { createSecretsHandlers, createSecretStoreWriteService } from "./secrets.js";
|
||||
|
||||
async function invokeSecretsReload(params: {
|
||||
handlers: ReturnType<typeof createSecretsHandlers>;
|
||||
@@ -211,6 +212,7 @@ describe("secrets handlers", () => {
|
||||
}));
|
||||
return createSecretsHandlers({
|
||||
reloadSecrets,
|
||||
storeWriteService: createSecretStoreWriteService({ reloadSecrets, log: overrides?.log }),
|
||||
resolveSecrets,
|
||||
log: overrides?.log,
|
||||
});
|
||||
@@ -455,6 +457,25 @@ describe("secrets handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("registers submitted store values for redaction before a failing write", async () => {
|
||||
const value = "test-secret-value-redaction-before-write-123";
|
||||
storeMocks.writeEntry.mockImplementationOnce(() => {
|
||||
expect(isSecretValueRegisteredForRedaction(value)).toBe(true);
|
||||
throw new Error("database unavailable");
|
||||
});
|
||||
const respond = vi.fn();
|
||||
|
||||
await invokeStoreMethod({
|
||||
handlers: createHandlers(),
|
||||
method: "secrets.store.set",
|
||||
requestParams: { name: "SERVICE_API_KEY", value, kind: "secret" },
|
||||
respond,
|
||||
});
|
||||
|
||||
expectRespondError(respond, { code: "UNAVAILABLE", message: "secrets.store.set failed" });
|
||||
expect(isSecretValueRegisteredForRedaction(value)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid store params before writing", async () => {
|
||||
const respond = vi.fn();
|
||||
await invokeStoreMethod({
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type SecretStoreEntry,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { formatErrorMessage as errorMessage } from "../../infra/errors.js";
|
||||
import { registerSecretValueForRedaction } from "../../logging/secret-redaction-registry.js";
|
||||
import {
|
||||
collectSecretStoreRefKeysInConfig,
|
||||
getActiveSecretsRuntimeSnapshotState,
|
||||
@@ -60,6 +61,72 @@ function storeUpdatedBy(client: GatewayClient | null): string {
|
||||
);
|
||||
}
|
||||
|
||||
type SecretStoreReload = (options?: {
|
||||
forceColdRefKeys?: ReadonlySet<string>;
|
||||
joinInFlight?: boolean;
|
||||
}) => Promise<{ warningCount: number }>;
|
||||
|
||||
type SecretStoreLogger = {
|
||||
warn?: (message: string) => void;
|
||||
debug?: (message: string) => void;
|
||||
};
|
||||
|
||||
class SecretStorePostWriteError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super(errorMessage(cause), { cause });
|
||||
this.name = "SecretStorePostWriteError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Owns redaction-first store writes and the runtime refresh shared by Gateway RPCs. */
|
||||
export function createSecretStoreWriteService(params: {
|
||||
reloadSecrets: SecretStoreReload;
|
||||
log?: SecretStoreLogger;
|
||||
}) {
|
||||
const purgeRetention = () => {
|
||||
try {
|
||||
purgeExpiredSecretStoreEntries();
|
||||
} catch (error) {
|
||||
params.log?.warn?.(`secrets.store retention purge failed: ${errorMessage(error)}`);
|
||||
}
|
||||
};
|
||||
const reloadReference = async (
|
||||
name: string,
|
||||
): Promise<{ reloaded: boolean; warningCount?: number }> => {
|
||||
const snapshot = getActiveSecretsRuntimeSnapshotState();
|
||||
const refKeys = snapshot
|
||||
? collectSecretStoreRefKeysInConfig(snapshot.sourceConfig, name)
|
||||
: new Set<string>();
|
||||
if (refKeys.size === 0) {
|
||||
return { reloaded: false };
|
||||
}
|
||||
// Explicit replacement must cold-refresh affected owners instead of
|
||||
// retaining an older credential from the active runtime snapshot.
|
||||
const reload = await params.reloadSecrets({ forceColdRefKeys: refKeys, joinInFlight: false });
|
||||
return { reloaded: true, warningCount: reload.warningCount };
|
||||
};
|
||||
|
||||
return {
|
||||
resolveUpdatedBy: storeUpdatedBy,
|
||||
purgeRetention,
|
||||
reloadReference,
|
||||
async write(input: Omit<Parameters<typeof writeSecretStoreEntry>[0], "scope" | "database">) {
|
||||
// Registration precedes validation and SQLite so even write failures
|
||||
// cannot disclose the submitted credential through downstream logging.
|
||||
registerSecretValueForRedaction(input.value);
|
||||
writeSecretStoreEntry({ scope: teamScope, ...input });
|
||||
purgeRetention();
|
||||
try {
|
||||
return await reloadReference(input.name);
|
||||
} catch (error) {
|
||||
throw new SecretStorePostWriteError(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type SecretStoreWriteService = ReturnType<typeof createSecretStoreWriteService>;
|
||||
|
||||
function invalidSecretsResolveField(
|
||||
errors: ValidationError[] | null | undefined,
|
||||
):
|
||||
@@ -99,10 +166,8 @@ function invalidSecretsResolveField(
|
||||
}
|
||||
|
||||
export function createSecretsHandlers(params: {
|
||||
reloadSecrets: (options?: {
|
||||
forceColdRefKeys?: ReadonlySet<string>;
|
||||
joinInFlight?: boolean;
|
||||
}) => Promise<{ warningCount: number }>;
|
||||
reloadSecrets: SecretStoreReload;
|
||||
storeWriteService: SecretStoreWriteService;
|
||||
resolveSecrets: (params: {
|
||||
commandName: string;
|
||||
targetIds: string[];
|
||||
@@ -122,33 +187,8 @@ export function createSecretsHandlers(params: {
|
||||
diagnostics: string[];
|
||||
inactiveRefPaths: string[];
|
||||
}>;
|
||||
log?: {
|
||||
warn?: (message: string) => void;
|
||||
};
|
||||
log?: SecretStoreLogger;
|
||||
}): GatewayRequestHandlers {
|
||||
const purgeStoreRetention = () => {
|
||||
try {
|
||||
purgeExpiredSecretStoreEntries();
|
||||
} catch (error) {
|
||||
params.log?.warn?.(`secrets.store retention purge failed: ${errorMessage(error)}`);
|
||||
}
|
||||
};
|
||||
const reloadStoreReference = async (
|
||||
name: string,
|
||||
): Promise<{ reloaded: boolean; warningCount?: number }> => {
|
||||
const snapshot = getActiveSecretsRuntimeSnapshotState();
|
||||
const refKeys = snapshot
|
||||
? collectSecretStoreRefKeysInConfig(snapshot.sourceConfig, name)
|
||||
: new Set<string>();
|
||||
if (refKeys.size === 0) {
|
||||
return { reloaded: false };
|
||||
}
|
||||
// An explicit store mutation must not reuse an older credential if the
|
||||
// replacement is missing or invalid; affected owners become cold instead.
|
||||
const reload = await params.reloadSecrets({ forceColdRefKeys: refKeys, joinInFlight: false });
|
||||
return { reloaded: true, warningCount: reload.warningCount };
|
||||
};
|
||||
|
||||
return {
|
||||
"secrets.reload": async ({ respond }) => {
|
||||
try {
|
||||
@@ -278,21 +318,16 @@ export function createSecretsHandlers(params: {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let stored = false;
|
||||
try {
|
||||
writeSecretStoreEntry({
|
||||
scope: teamScope,
|
||||
const reload = await params.storeWriteService.write({
|
||||
name: requestParams.name,
|
||||
value: requestParams.value,
|
||||
kind: requestParams.kind,
|
||||
...(requestParams.allowedHosts !== undefined
|
||||
? { allowedHosts: requestParams.allowedHosts }
|
||||
: {}),
|
||||
updatedBy: storeUpdatedBy(client),
|
||||
updatedBy: params.storeWriteService.resolveUpdatedBy(client),
|
||||
});
|
||||
stored = true;
|
||||
purgeStoreRetention();
|
||||
const reload = await reloadStoreReference(requestParams.name);
|
||||
const result = {
|
||||
ok: true as const,
|
||||
...reload,
|
||||
@@ -312,14 +347,14 @@ export function createSecretsHandlers(params: {
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
stored
|
||||
error instanceof SecretStorePostWriteError
|
||||
? "Secret store entry was saved, but post-write runtime refresh failed. Resolve provider errors and retry secrets.reload."
|
||||
: "secrets.store.set failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
"secrets.store.delete": async ({ params: requestParams, respond }) => {
|
||||
"secrets.store.delete": async ({ params: requestParams, respond, client }) => {
|
||||
if (
|
||||
!assertValidParams(
|
||||
requestParams,
|
||||
@@ -332,10 +367,14 @@ export function createSecretsHandlers(params: {
|
||||
}
|
||||
let deleted = false;
|
||||
try {
|
||||
const agentId = client?.internal?.agentRuntimeIdentity?.agentId;
|
||||
if (agentId) {
|
||||
params.log?.debug?.(`secrets.store.delete requested by agent:${agentId}`);
|
||||
}
|
||||
deleteSecretStoreEntry({ scope: teamScope, name: requestParams.name });
|
||||
deleted = true;
|
||||
purgeStoreRetention();
|
||||
const reload = await reloadStoreReference(requestParams.name);
|
||||
params.storeWriteService.purgeRetention();
|
||||
const reload = await params.storeWriteService.reloadReference(requestParams.name);
|
||||
const result = {
|
||||
ok: true as const,
|
||||
...reload,
|
||||
|
||||
@@ -5,6 +5,7 @@ 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> };
|
||||
@@ -33,7 +34,10 @@ afterEach(() => {
|
||||
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);
|
||||
const handlers = createQuestionHandlers(
|
||||
manager,
|
||||
createSecretStoreWriteService({ reloadSecrets: async () => ({ warningCount: 0 }) }),
|
||||
);
|
||||
const gatewayCalls: string[] = [];
|
||||
const dispatch = async ({ method, params }: QuestionGatewayCall): Promise<unknown> => {
|
||||
gatewayCalls.push(method);
|
||||
|
||||
+22
-11
@@ -9,6 +9,10 @@ export const INTERNAL_SESSION_PATH_PARAM = "__openclawSessionPath";
|
||||
export const INTERNAL_MEMORY_PATH_PARAM = "__openclawMemoryPath";
|
||||
export const INTERNAL_PLUGINS_PATH_PARAM = "__openclawPluginsPath";
|
||||
export const INTERNAL_WORKBOARD_PATH_PARAM = "__openclawWorkboardPath";
|
||||
export const CONTROL_UI_DOCUMENT_ROUTE_PATHS = {
|
||||
approval: "/approve",
|
||||
question: "/ask",
|
||||
} as const;
|
||||
|
||||
export type MemoryRouteTab = "overview" | "memories" | "dreams" | "settings";
|
||||
export type PluginsHubRouteTab = "installed" | "discover";
|
||||
@@ -341,6 +345,9 @@ export function inferBasePathFromPathname(pathname: string): string {
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
const candidate = `/${segments.slice(index).join("/")}`;
|
||||
const routePath = routePaths.find((path) => normalizePath(path) === candidate);
|
||||
const documentRoutePath = Object.values(CONTROL_UI_DOCUMENT_ROUTE_PATHS).find(
|
||||
(path) => candidate === path || candidate.startsWith(`${path}/`),
|
||||
);
|
||||
const dynamicAgentRoute = agentRouteFromPath(candidate) !== null;
|
||||
const dynamicWorkboardRoute = workboardBoardIdFromPath(candidate) !== null;
|
||||
const dynamicMemoryRoute = memoryTabFromPath(candidate) !== null;
|
||||
@@ -349,6 +356,7 @@ export function inferBasePathFromPathname(pathname: string): string {
|
||||
const dynamicSessionRoute = sessionNamespace !== null;
|
||||
if (
|
||||
!routePath &&
|
||||
!documentRoutePath &&
|
||||
!dynamicAgentRoute &&
|
||||
!dynamicWorkboardRoute &&
|
||||
!dynamicMemoryRoute &&
|
||||
@@ -358,22 +366,25 @@ export function inferBasePathFromPathname(pathname: string): string {
|
||||
continue;
|
||||
}
|
||||
const previousSegment = segments[index - 1];
|
||||
const dynamicRoutePath = dynamicAgentRoute
|
||||
? APP_ROUTE_DEFINITIONS.agents.path
|
||||
: dynamicWorkboardRoute
|
||||
? APP_ROUTE_DEFINITIONS.workboard.path
|
||||
: dynamicMemoryRoute
|
||||
? APP_ROUTE_DEFINITIONS.memory.path
|
||||
: dynamicPluginsRoute
|
||||
? APP_ROUTE_DEFINITIONS.plugins.path
|
||||
: sessionNamespace
|
||||
? APP_ROUTE_DEFINITIONS[sessionNamespace].path
|
||||
: null;
|
||||
const dynamicRoutePath = documentRoutePath
|
||||
? documentRoutePath
|
||||
: dynamicAgentRoute
|
||||
? APP_ROUTE_DEFINITIONS.agents.path
|
||||
: dynamicWorkboardRoute
|
||||
? APP_ROUTE_DEFINITIONS.workboard.path
|
||||
: dynamicMemoryRoute
|
||||
? APP_ROUTE_DEFINITIONS.memory.path
|
||||
: dynamicPluginsRoute
|
||||
? APP_ROUTE_DEFINITIONS.plugins.path
|
||||
: sessionNamespace
|
||||
? APP_ROUTE_DEFINITIONS[sessionNamespace].path
|
||||
: null;
|
||||
const firstRouteSegment = (routePath ?? dynamicRoutePath ?? "").split("/").find(Boolean);
|
||||
if (
|
||||
index > 0 &&
|
||||
previousSegment === firstRouteSegment &&
|
||||
(candidate === routePath ||
|
||||
Boolean(documentRoutePath) ||
|
||||
dynamicAgentRoute ||
|
||||
dynamicWorkboardRoute ||
|
||||
dynamicMemoryRoute ||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
isOptionalElementDefined,
|
||||
LazyCustomElementRequestController,
|
||||
type OptionalCustomElement,
|
||||
QUESTION_PAGE_ELEMENT,
|
||||
TERMINAL_PANEL_ELEMENT,
|
||||
} from "./lazy-custom-element.ts";
|
||||
import { resolveOnboardingMode } from "./onboarding-mode.ts";
|
||||
@@ -140,6 +141,9 @@ export class OpenClawApp extends OpenClawLightDomElement {
|
||||
if (this.runtime.documentMode?.kind === "approval") {
|
||||
this.requestLazyDocument(APPROVAL_PAGE_ELEMENT);
|
||||
}
|
||||
if (this.runtime.documentMode?.kind === "question") {
|
||||
this.requestLazyDocument(QUESTION_PAGE_ELEMENT);
|
||||
}
|
||||
const context = this.runtime.context;
|
||||
this.pendingGatewayUrl = this.runtime.pendingGatewayConnection?.gatewayUrl ?? null;
|
||||
// Context identity changes only across a full app-tree connection epoch;
|
||||
@@ -262,6 +266,16 @@ export class OpenClawApp extends OpenClawLightDomElement {
|
||||
return html`<openclaw-approval-page .approvalId=${approvalId ?? ""}></openclaw-approval-page>`;
|
||||
}
|
||||
|
||||
private renderQuestionDocument(runtime: ApplicationRuntime) {
|
||||
const lazyState = this.lazyCustomElements.visibleState;
|
||||
if (lazyState?.element === QUESTION_PAGE_ELEMENT) {
|
||||
return this.renderLazyDocumentState(QUESTION_PAGE_ELEMENT);
|
||||
}
|
||||
const questionId =
|
||||
runtime.documentMode?.kind === "question" ? runtime.documentMode.questionId : "";
|
||||
return html`<openclaw-question-page .questionId=${questionId ?? ""}></openclaw-question-page>`;
|
||||
}
|
||||
|
||||
private replaceFocusDashboardLocation(location: RouteLocation, source: RouteLocation): void {
|
||||
const basePath = this.context?.basePath ?? "";
|
||||
const expected = buildControlUiFocusPath(
|
||||
@@ -577,6 +591,13 @@ export class OpenClawApp extends OpenClawLightDomElement {
|
||||
</openclaw-tooltip-provider>
|
||||
`;
|
||||
}
|
||||
if (runtime.documentMode?.kind === "question") {
|
||||
return html`
|
||||
<openclaw-tooltip-provider>
|
||||
${gatewayUrlConfirmation} ${this.renderQuestionDocument(runtime)}
|
||||
</openclaw-tooltip-provider>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<openclaw-tooltip-provider>
|
||||
<openclaw-github-link-hovercard-provider .client=${gatewaySnapshot.client}>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveApprovalDocumentMode } from "./approval-deep-link.ts";
|
||||
import { inferBasePathFromPathname } from "../app-route-paths.ts";
|
||||
import { resolveControlUiDocumentMode } from "./approval-deep-link.ts";
|
||||
|
||||
describe("resolveApprovalDocumentMode", () => {
|
||||
describe("approval document routing", () => {
|
||||
it("resolves root and configured-base approval links", () => {
|
||||
expect(resolveApprovalDocumentMode("/approve/exec%3A123", "")).toEqual({
|
||||
expect(resolveControlUiDocumentMode("/approve/exec%3A123", "")).toEqual({
|
||||
kind: "approval",
|
||||
approvalId: "exec:123",
|
||||
});
|
||||
expect(resolveApprovalDocumentMode("/operator/approve/plugin%3A456", "/operator/")).toEqual({
|
||||
expect(resolveControlUiDocumentMode("/operator/approve/plugin%3A456", "/operator/")).toEqual({
|
||||
kind: "approval",
|
||||
approvalId: "plugin:456",
|
||||
});
|
||||
@@ -16,7 +17,7 @@ describe("resolveApprovalDocumentMode", () => {
|
||||
|
||||
it("decodes one stable path segment without narrowing valid approval ids", () => {
|
||||
const approvalId = "plugin:a/b%🦞";
|
||||
expect(resolveApprovalDocumentMode(`/approve/${encodeURIComponent(approvalId)}`, "")).toEqual({
|
||||
expect(resolveControlUiDocumentMode(`/approve/${encodeURIComponent(approvalId)}`, "")).toEqual({
|
||||
kind: "approval",
|
||||
approvalId,
|
||||
});
|
||||
@@ -31,15 +32,49 @@ describe("resolveApprovalDocumentMode", () => {
|
||||
"/approve/id/extra",
|
||||
"/approve/id/",
|
||||
])("keeps malformed approval-shaped paths shellless: %s", (pathname) => {
|
||||
expect(resolveApprovalDocumentMode(pathname, "")).toEqual({
|
||||
expect(resolveControlUiDocumentMode(pathname, "")).toEqual({
|
||||
kind: "approval",
|
||||
approvalId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not claim ordinary or out-of-mount paths", () => {
|
||||
expect(resolveApprovalDocumentMode("/chat", "")).toBeNull();
|
||||
expect(resolveApprovalDocumentMode("/approve/id", "/operator")).toBeNull();
|
||||
expect(resolveApprovalDocumentMode("/operator/approvals/id", "/operator")).toBeNull();
|
||||
expect(resolveControlUiDocumentMode("/chat", "")).toBeNull();
|
||||
expect(resolveControlUiDocumentMode("/approve/id", "/operator")).toBeNull();
|
||||
expect(resolveControlUiDocumentMode("/operator/approvals/id", "/operator")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("question document routing", () => {
|
||||
it("resolves root and configured-base question links without changing approval routing", () => {
|
||||
expect(resolveControlUiDocumentMode("/ask/question%3A123", "")).toEqual({
|
||||
kind: "question",
|
||||
questionId: "question:123",
|
||||
});
|
||||
expect(resolveControlUiDocumentMode("/operator/ask/question%3A456", "/operator/")).toEqual({
|
||||
kind: "question",
|
||||
questionId: "question:456",
|
||||
});
|
||||
expect(resolveControlUiDocumentMode("/approve/approval%3A123", "")).toEqual({
|
||||
kind: "approval",
|
||||
approvalId: "approval:123",
|
||||
});
|
||||
expect(inferBasePathFromPathname("/ask/question%3A123")).toBe("");
|
||||
expect(inferBasePathFromPathname("/operator/ask/question%3A456")).toBe("/operator");
|
||||
});
|
||||
|
||||
it.each(["/ask", "/ask/", "/ask/%", "/ask/%2e", "/ask/id/extra", "/ask/id/"])(
|
||||
"keeps malformed question-shaped paths shellless: %s",
|
||||
(pathname) => {
|
||||
expect(resolveControlUiDocumentMode(pathname, "")).toEqual({
|
||||
kind: "question",
|
||||
questionId: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not claim ordinary or out-of-mount paths", () => {
|
||||
expect(resolveControlUiDocumentMode("/chat", "")).toBeNull();
|
||||
expect(resolveControlUiDocumentMode("/ask/id", "/operator")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,38 +1,78 @@
|
||||
import { normalizeBasePath } from "../app-route-paths.ts";
|
||||
import { CONTROL_UI_DOCUMENT_ROUTE_PATHS, normalizeBasePath } from "../app-route-paths.ts";
|
||||
|
||||
export type ApprovalDocumentMode = {
|
||||
type ApprovalDocumentMode = {
|
||||
kind: "approval";
|
||||
approvalId: string | null;
|
||||
};
|
||||
|
||||
type QuestionDocumentMode = {
|
||||
kind: "question";
|
||||
questionId: string | null;
|
||||
};
|
||||
|
||||
export type ControlUiDocumentMode = ApprovalDocumentMode | QuestionDocumentMode;
|
||||
|
||||
/**
|
||||
* Recognizes the shellless approval document before the exact-path app router
|
||||
* can replace it with Chat. The Gateway validates the decoded id; this parser
|
||||
* only preserves the one-segment URL contract and rejects ambiguous paths.
|
||||
* Recognizes shellless documents before the exact-path app router can replace
|
||||
* them with Chat. Gateway owners validate decoded ids; this parser preserves
|
||||
* the one-segment URL contract and rejects ambiguous paths.
|
||||
*/
|
||||
export function resolveApprovalDocumentMode(
|
||||
function resolveDocumentId(
|
||||
pathname: string,
|
||||
basePath: string,
|
||||
): ApprovalDocumentMode | null {
|
||||
routePath: string,
|
||||
): string | null | undefined {
|
||||
const normalizedBasePath = normalizeBasePath(basePath);
|
||||
const approvalRoot = `${normalizedBasePath}/approve`;
|
||||
if (pathname === approvalRoot || pathname === `${approvalRoot}/`) {
|
||||
return { kind: "approval", approvalId: null };
|
||||
}
|
||||
const prefix = `${approvalRoot}/`;
|
||||
if (!pathname.startsWith(prefix)) {
|
||||
const documentRoot = `${normalizedBasePath}${routePath}`;
|
||||
if (pathname === documentRoot || pathname === `${documentRoot}/`) {
|
||||
return null;
|
||||
}
|
||||
const prefix = `${documentRoot}/`;
|
||||
if (!pathname.startsWith(prefix)) {
|
||||
return undefined;
|
||||
}
|
||||
const encodedId = pathname.slice(prefix.length);
|
||||
if (!encodedId || encodedId.includes("/")) {
|
||||
return { kind: "approval", approvalId: null };
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const approvalId = decodeURIComponent(encodedId);
|
||||
return approvalId && approvalId !== "." && approvalId !== ".."
|
||||
? { kind: "approval", approvalId }
|
||||
: { kind: "approval", approvalId: null };
|
||||
const documentId = decodeURIComponent(encodedId);
|
||||
return documentId && documentId !== "." && documentId !== ".." ? documentId : null;
|
||||
} catch {
|
||||
return { kind: "approval", approvalId: null };
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveApprovalDocumentMode(
|
||||
pathname: string,
|
||||
basePath: string,
|
||||
): ApprovalDocumentMode | null {
|
||||
const approvalId = resolveDocumentId(
|
||||
pathname,
|
||||
basePath,
|
||||
CONTROL_UI_DOCUMENT_ROUTE_PATHS.approval,
|
||||
);
|
||||
return approvalId === undefined ? null : { kind: "approval", approvalId };
|
||||
}
|
||||
|
||||
function resolveQuestionDocumentMode(
|
||||
pathname: string,
|
||||
basePath: string,
|
||||
): QuestionDocumentMode | null {
|
||||
const questionId = resolveDocumentId(
|
||||
pathname,
|
||||
basePath,
|
||||
CONTROL_UI_DOCUMENT_ROUTE_PATHS.question,
|
||||
);
|
||||
return questionId === undefined ? null : { kind: "question", questionId };
|
||||
}
|
||||
|
||||
export function resolveControlUiDocumentMode(
|
||||
pathname: string,
|
||||
basePath: string,
|
||||
): ControlUiDocumentMode | null {
|
||||
return (
|
||||
resolveApprovalDocumentMode(pathname, basePath) ??
|
||||
resolveQuestionDocumentMode(pathname, basePath)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from "../pages/model-setup/first-run.ts";
|
||||
import { createAgentSelectionCapability } from "./agent-selection.ts";
|
||||
import { isBrowserPanelAvailable } from "./app-shell-chrome.ts";
|
||||
import { resolveApprovalDocumentMode, type ApprovalDocumentMode } from "./approval-deep-link.ts";
|
||||
import { resolveControlUiDocumentMode, type ControlUiDocumentMode } from "./approval-deep-link.ts";
|
||||
import { createBrowserHistory, resolveControlUiPaths } from "./browser.ts";
|
||||
import { createChatAttachmentHandoff } from "./chat-attachment-handoff.ts";
|
||||
import { createApplicationConfigCapability } from "./config.ts";
|
||||
@@ -238,7 +238,7 @@ function createApplicationNavigationPreferences(
|
||||
export type ApplicationRuntime = {
|
||||
readonly context: ApplicationContext<RouteId>;
|
||||
readonly router: ApplicationRouter;
|
||||
readonly documentMode: ApprovalDocumentMode | null;
|
||||
readonly documentMode: ControlUiDocumentMode | null;
|
||||
readonly focusLocation: ControlUiFocusLocation | null;
|
||||
readonly pendingGatewayConnection: {
|
||||
readonly gatewayUrl: string;
|
||||
@@ -268,7 +268,7 @@ export function bootstrapApplication(
|
||||
const [basePath, resourceBasePath] = resolveControlUiPaths(
|
||||
startupLocation.pathname || globalThis.location?.pathname || "/",
|
||||
);
|
||||
const documentMode = resolveApprovalDocumentMode(startupLocation.pathname, basePath);
|
||||
const documentMode = resolveControlUiDocumentMode(startupLocation.pathname, basePath);
|
||||
const persistedSettings = loadSettings();
|
||||
const initialSettings = documentMode
|
||||
? resolvePageGatewaySettings(persistedSettings)
|
||||
|
||||
@@ -282,6 +282,14 @@ export const APPROVAL_PAGE_ELEMENT = {
|
||||
loadModule: () => import("../pages/approval/approval-page-registration.ts"),
|
||||
} satisfies OptionalCustomElement;
|
||||
|
||||
const QUESTION_PAGE_TAG = "openclaw-question-page";
|
||||
|
||||
export const QUESTION_PAGE_ELEMENT = {
|
||||
tagName: QUESTION_PAGE_TAG,
|
||||
label: QUESTION_PAGE_TAG,
|
||||
loadModule: () => import("../pages/question/question-page-registration.ts"),
|
||||
} satisfies OptionalCustomElement;
|
||||
|
||||
// The card is in the chat graph, but modal-only queue controls stay off the
|
||||
// startup path until an approval is actually pending.
|
||||
const EXEC_APPROVAL_TAG = "openclaw-exec-approval";
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
// @vitest-environment node
|
||||
// Store-bound questions never retain submitted credentials in terminal UI state.
|
||||
import {
|
||||
DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS,
|
||||
type GatewayProtocolRequestOptions,
|
||||
} from "@openclaw/gateway-client/browser";
|
||||
import type { Question, QuestionRecord, QuestionResolveResult } from "@openclaw/gateway-protocol";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { GatewayRequestError } from "../api/gateway.ts";
|
||||
import { waitForFast } from "../test-helpers/wait-for.ts";
|
||||
import {
|
||||
createQuestionPromptState,
|
||||
disposeQuestionPromptState,
|
||||
handleQuestionPromptEvent,
|
||||
refreshPendingQuestionsWithRetry,
|
||||
setQuestionPromptClient,
|
||||
submitQuestionPrompt,
|
||||
} from "./question-prompt.ts";
|
||||
|
||||
type RequestFn = (
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: GatewayProtocolRequestOptions,
|
||||
) => Promise<unknown>;
|
||||
type SecretBinding = NonNullable<Question["secretStore"]>;
|
||||
type PromptState = ReturnType<typeof createQuestionPromptState>;
|
||||
|
||||
const states: PromptState[] = [];
|
||||
const storedAnswer = {
|
||||
status: "answered",
|
||||
answers: { answers: { secret_value: ["stored"] } },
|
||||
} satisfies QuestionResolveResult;
|
||||
|
||||
function requestedSecret(
|
||||
secretStore: SecretBinding = { name: "TEST_API_KEY", kind: "secret" },
|
||||
overrides: Partial<Question> = {},
|
||||
): QuestionRecord {
|
||||
return {
|
||||
id: "question-1",
|
||||
questions: [
|
||||
{
|
||||
questionId: "secret_value",
|
||||
header: "API key",
|
||||
question: "Provide the fake test credential.",
|
||||
options: [],
|
||||
isSecret: true,
|
||||
secretStore,
|
||||
...overrides,
|
||||
},
|
||||
],
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
createdAtMs: 1_000,
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
status: "pending",
|
||||
};
|
||||
}
|
||||
|
||||
function createSecretPrompt(request?: RequestFn, record = requestedSecret()) {
|
||||
const state = createQuestionPromptState(vi.fn());
|
||||
states.push(state);
|
||||
const client = request ? { request } : null;
|
||||
if (client) {
|
||||
setQuestionPromptClient(state, client);
|
||||
}
|
||||
handleQuestionPromptEvent(state, { event: "question.requested", payload: record });
|
||||
const prompt = state.prompts.get(record.id);
|
||||
if (!prompt) {
|
||||
throw new Error("valid secret question was not registered");
|
||||
}
|
||||
return { state, prompt, client };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const state of states.splice(0)) {
|
||||
disposeQuestionPromptState(state);
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("store-bound question normalization", () => {
|
||||
it("preserves protocol-derived binding and replacement metadata", () => {
|
||||
const secretStore: SecretBinding = {
|
||||
name: "TEST_API_KEY",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.test"],
|
||||
reason: "The test agent needs a fake credential.",
|
||||
};
|
||||
const secretStoreExisting: NonNullable<Question["secretStoreExisting"]> = {
|
||||
updatedAtMs: 1_234,
|
||||
updatedBy: "operator@example.test",
|
||||
};
|
||||
const { prompt } = createSecretPrompt(
|
||||
undefined,
|
||||
requestedSecret(secretStore, { secretStoreExisting }),
|
||||
);
|
||||
|
||||
expect(prompt.questions[0]).toMatchObject({ isSecret: true, secretStore, secretStoreExisting });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "non-boolean secrecy", overrides: { isSecret: "true" } },
|
||||
{
|
||||
label: "duplicate hosts",
|
||||
overrides: {
|
||||
secretStore: {
|
||||
name: "TEST_API_KEY",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.test", "api.example.test"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "hosts on environment entries",
|
||||
overrides: {
|
||||
secretStore: {
|
||||
name: "TEST_ENV_VALUE",
|
||||
kind: "env",
|
||||
allowedHosts: ["api.example.test"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "invalid replacement metadata",
|
||||
overrides: { secretStoreExisting: { updatedAtMs: -1 } },
|
||||
},
|
||||
])("rejects $label", ({ overrides }) => {
|
||||
const state = createQuestionPromptState(vi.fn());
|
||||
states.push(state);
|
||||
const record = requestedSecret();
|
||||
|
||||
expect(
|
||||
handleQuestionPromptEvent(state, {
|
||||
event: "question.requested",
|
||||
payload: { ...record, questions: [{ ...record.questions[0], ...overrides }] },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("store-bound question submission", () => {
|
||||
it.each([
|
||||
{
|
||||
label: "untouched proposed hosts",
|
||||
binding: { name: "TEST_API_KEY", kind: "secret", allowedHosts: ["proposed.example.test"] },
|
||||
draft: undefined,
|
||||
expectedHosts: ["proposed.example.test"],
|
||||
},
|
||||
{
|
||||
label: "edited comma-and-whitespace-separated hosts",
|
||||
binding: { name: "TEST_API_KEY", kind: "secret" },
|
||||
draft: " first.example.test, second.example.test\nthird.example.test ",
|
||||
expectedHosts: ["first.example.test", "second.example.test", "third.example.test"],
|
||||
},
|
||||
{
|
||||
label: "an explicitly cleared host proposal",
|
||||
binding: { name: "TEST_API_KEY", kind: "secret", allowedHosts: ["proposed.example.test"] },
|
||||
draft: " ",
|
||||
expectedHosts: [],
|
||||
},
|
||||
{
|
||||
label: "an untouched secret without proposed hosts",
|
||||
binding: { name: "TEST_API_KEY", kind: "secret" },
|
||||
draft: undefined,
|
||||
expectedHosts: undefined,
|
||||
},
|
||||
{
|
||||
label: "an environment entry despite stale host input",
|
||||
binding: { name: "TEST_ENV_VALUE", kind: "env" },
|
||||
draft: "ignored.example.test",
|
||||
expectedHosts: undefined,
|
||||
},
|
||||
] satisfies Array<{
|
||||
label: string;
|
||||
binding: SecretBinding;
|
||||
draft: string | undefined;
|
||||
expectedHosts: string[] | undefined;
|
||||
}>)("submits $label", async ({ binding, draft, expectedHosts }) => {
|
||||
const request = vi.fn<RequestFn>(async () => storedAnswer);
|
||||
const { state, prompt } = createSecretPrompt(request, requestedSecret(binding));
|
||||
if (draft !== undefined) {
|
||||
prompt.secretStoreAllowedHostsDraft = draft;
|
||||
}
|
||||
|
||||
await submitQuestionPrompt(state, prompt.id, { secret_value: ["fake-secret-test-value"] });
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"question.resolve",
|
||||
{
|
||||
id: prompt.id,
|
||||
answers: { answers: { secret_value: ["fake-secret-test-value"] } },
|
||||
...(expectedHosts !== undefined ? { secretStoreAllowedHosts: expectedHosts } : {}),
|
||||
},
|
||||
{ timeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS },
|
||||
);
|
||||
expect(prompt.submittedAnswers).toEqual(storedAnswer.answers);
|
||||
});
|
||||
|
||||
it("preserves a secret draft on validation failure and clears it after successful retry", async () => {
|
||||
const fakeSecret = "fake-secret-retry-test-value";
|
||||
const request = vi
|
||||
.fn<RequestFn>()
|
||||
.mockRejectedValueOnce(
|
||||
new GatewayRequestError({ code: "INVALID_REQUEST", message: "Allowed host is invalid." }),
|
||||
)
|
||||
.mockResolvedValueOnce(storedAnswer);
|
||||
const { state, prompt } = createSecretPrompt(request);
|
||||
prompt.drafts.set("secret_value", { selected: new Set(), freeText: fakeSecret });
|
||||
|
||||
await submitQuestionPrompt(state, prompt.id, { secret_value: [fakeSecret] });
|
||||
|
||||
expect(prompt).toMatchObject({
|
||||
status: "pending",
|
||||
submitting: false,
|
||||
error: "Allowed host is invalid.",
|
||||
submittedAnswers: storedAnswer.answers,
|
||||
});
|
||||
expect(prompt.drafts.get("secret_value")?.freeText).toBe(fakeSecret);
|
||||
|
||||
await submitQuestionPrompt(state, prompt.id, { secret_value: [fakeSecret] });
|
||||
|
||||
expect(prompt).toMatchObject({
|
||||
status: "answered",
|
||||
answers: storedAnswer.answers,
|
||||
submittedAnswers: storedAnswer.answers,
|
||||
});
|
||||
expect(prompt.drafts.has("secret_value")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("store-bound question terminal cleanup", () => {
|
||||
it("forgets the secret draft when the local deadline expires", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-17T00:00:00.000Z"));
|
||||
const record = requestedSecret();
|
||||
record.expiresAtMs = Date.now() + 1_000;
|
||||
const { prompt } = createSecretPrompt(undefined, record);
|
||||
prompt.drafts.set("secret_value", {
|
||||
selected: new Set(),
|
||||
freeText: "fake-secret-expired-test-value",
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(1_000);
|
||||
|
||||
expect(prompt.status).toBe("expired");
|
||||
expect(prompt.drafts.has("secret_value")).toBe(false);
|
||||
});
|
||||
|
||||
it("forgets the secret draft when recovery proves the question unavailable", async () => {
|
||||
const request = vi.fn<RequestFn>(async (method) => {
|
||||
if (method === "question.list") {
|
||||
return { questions: [] };
|
||||
}
|
||||
throw new GatewayRequestError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "question was not found",
|
||||
details: { reason: "QUESTION_NOT_FOUND" },
|
||||
});
|
||||
});
|
||||
const { state, prompt, client } = createSecretPrompt(request);
|
||||
prompt.drafts.set("secret_value", {
|
||||
selected: new Set(),
|
||||
freeText: "fake-secret-unavailable-test-value",
|
||||
});
|
||||
if (!client) {
|
||||
throw new Error("connected secret question has no client");
|
||||
}
|
||||
|
||||
refreshPendingQuestionsWithRetry(state, client);
|
||||
await waitForFast(() => expect(prompt.status).toBe("unavailable"));
|
||||
|
||||
expect(prompt.drafts.has("secret_value")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
// Store-bound question ownership keeps credential metadata and plaintext handling together.
|
||||
import { asSafeIntegerInRange } from "@openclaw/normalization-core/number-coercion";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { normalizeNullableString } from "@openclaw/normalization-core/string-coerce";
|
||||
import type {
|
||||
Question,
|
||||
QuestionAnswers,
|
||||
QuestionResolveParams,
|
||||
QuestionResolveResult,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
|
||||
type QuestionSecretStoreFields = Pick<Question, "isSecret" | "secretStore" | "secretStoreExisting">;
|
||||
|
||||
function parseQuestionSecretStore(value: unknown): NonNullable<Question["secretStore"]> | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const { name, kind, allowedHosts, reason } = value;
|
||||
if (
|
||||
typeof name !== "string" ||
|
||||
!/^[A-Z][A-Z0-9_]{0,127}$/.test(name) ||
|
||||
(kind !== "secret" && kind !== "env") ||
|
||||
(reason !== undefined && (typeof reason !== "string" || reason.length > 200))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
allowedHosts !== undefined &&
|
||||
(kind !== "secret" ||
|
||||
!Array.isArray(allowedHosts) ||
|
||||
allowedHosts.length > 128 ||
|
||||
allowedHosts.some((host) => typeof host !== "string" || !host || host.length > 253) ||
|
||||
new Set(allowedHosts).size !== allowedHosts.length)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
kind,
|
||||
...(allowedHosts !== undefined ? { allowedHosts: [...allowedHosts] } : {}),
|
||||
...(reason !== undefined ? { reason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseQuestionSecretStoreExisting(
|
||||
value: unknown,
|
||||
): NonNullable<Question["secretStoreExisting"]> | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const updatedAtMs = asSafeIntegerInRange(value.updatedAtMs, { min: 0 });
|
||||
const updatedBy =
|
||||
value.updatedBy === undefined ? undefined : normalizeNullableString(value.updatedBy);
|
||||
if (updatedAtMs === undefined || (value.updatedBy !== undefined && !updatedBy)) {
|
||||
return null;
|
||||
}
|
||||
return { updatedAtMs, ...(updatedBy ? { updatedBy } : {}) };
|
||||
}
|
||||
|
||||
export function normalizeQuestionSecretStoreFields(
|
||||
value: Record<string, unknown>,
|
||||
): QuestionSecretStoreFields | null {
|
||||
if (value.isSecret !== undefined && typeof value.isSecret !== "boolean") {
|
||||
return null;
|
||||
}
|
||||
const secretStore =
|
||||
value.secretStore === undefined ? undefined : parseQuestionSecretStore(value.secretStore);
|
||||
const secretStoreExisting =
|
||||
value.secretStoreExisting === undefined
|
||||
? undefined
|
||||
: parseQuestionSecretStoreExisting(value.secretStoreExisting);
|
||||
if (
|
||||
secretStore === null ||
|
||||
secretStoreExisting === null ||
|
||||
(secretStore && !value.isSecret) ||
|
||||
(secretStoreExisting && !secretStore)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(typeof value.isSecret === "boolean" ? { isSecret: value.isSecret } : {}),
|
||||
...(secretStore ? { secretStore } : {}),
|
||||
...(secretStoreExisting ? { secretStoreExisting } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function clearSecretQuestionDrafts(
|
||||
questions: readonly Question[],
|
||||
drafts: { delete: (questionId: string) => boolean },
|
||||
): void {
|
||||
for (const question of questions) {
|
||||
if (question.isSecret) {
|
||||
drafts.delete(question.questionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseQuestionSubmissionResult(
|
||||
value: unknown,
|
||||
parseAnswers: (value: unknown) => QuestionAnswers | null,
|
||||
): QuestionResolveResult | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
if (value.status === "cancelled") {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
const answers = parseAnswers(value.answers);
|
||||
return value.status === "answered" && answers ? { status: "answered", answers } : null;
|
||||
}
|
||||
|
||||
export function prepareQuestionSecretStoreSubmission(
|
||||
id: string,
|
||||
questions: readonly Question[],
|
||||
resolution: { answers: QuestionAnswers["answers"] } | { cancel: true },
|
||||
allowedHostsDraft?: string,
|
||||
): { requestParams: QuestionResolveParams; submittedAnswers?: QuestionAnswers } {
|
||||
if (!("answers" in resolution)) {
|
||||
return { requestParams: { id, cancel: true } };
|
||||
}
|
||||
const requestAnswers: QuestionAnswers = {
|
||||
answers: Object.fromEntries(
|
||||
Object.entries(resolution.answers).map(([questionId, answers]) => [questionId, [...answers]]),
|
||||
),
|
||||
};
|
||||
const secretQuestion = questions[0]?.secretStore ? questions[0] : undefined;
|
||||
const allowedHosts =
|
||||
secretQuestion?.secretStore?.kind === "secret"
|
||||
? allowedHostsDraft !== undefined
|
||||
? allowedHostsDraft
|
||||
.split(/[,\s]+/u)
|
||||
.map((host) => host.trim())
|
||||
.filter(Boolean)
|
||||
: secretQuestion.secretStore.allowedHosts
|
||||
: undefined;
|
||||
return {
|
||||
requestParams: {
|
||||
id,
|
||||
answers: requestAnswers,
|
||||
...(allowedHosts !== undefined ? { secretStoreAllowedHosts: allowedHosts } : {}),
|
||||
},
|
||||
// The Gateway owns redaction: retained prompt state receives its synthetic
|
||||
// marker while plaintext remains only in the outbound request payload.
|
||||
submittedAnswers: secretQuestion
|
||||
? { answers: { [secretQuestion.questionId]: ["stored"] } }
|
||||
: requestAnswers,
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
Question,
|
||||
QuestionAnswers,
|
||||
QuestionRecord,
|
||||
QuestionResolveResult,
|
||||
QuestionResolvedEvent,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { GatewayRequestError, type GatewayEventFrame } from "../api/gateway.ts";
|
||||
@@ -20,6 +19,12 @@ import {
|
||||
type QuestionClient,
|
||||
type QuestionClientResolutionOwner,
|
||||
} from "./question-prompt-client.ts";
|
||||
import {
|
||||
clearSecretQuestionDrafts,
|
||||
normalizeQuestionSecretStoreFields,
|
||||
parseQuestionSubmissionResult,
|
||||
prepareQuestionSecretStoreSubmission,
|
||||
} from "./question-prompt-secret-store.ts";
|
||||
|
||||
type QuestionDraft = {
|
||||
selected: Set<string>;
|
||||
@@ -45,6 +50,7 @@ export type QuestionPrompt = {
|
||||
submitting: boolean;
|
||||
error: string | null;
|
||||
drafts: Map<string, QuestionDraft>;
|
||||
secretStoreAllowedHostsDraft?: string;
|
||||
revision: number;
|
||||
};
|
||||
|
||||
@@ -119,6 +125,10 @@ function parseQuestion(value: unknown): Question | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const secretStoreFields = normalizeQuestionSecretStoreFields(value);
|
||||
if (!secretStoreFields) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
questionId,
|
||||
header: clampedHeader,
|
||||
@@ -126,6 +136,7 @@ function parseQuestion(value: unknown): Question | null {
|
||||
options,
|
||||
...(value.multiSelect === true ? { multiSelect: true } : {}),
|
||||
...(typeof value.isOther === "boolean" ? { isOther: value.isOther } : {}),
|
||||
...secretStoreFields,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -245,17 +256,6 @@ function parseQuestionResolvedEvent(payload: unknown): QuestionResolvedEvent | n
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseQuestionResolveResult(payload: unknown): QuestionResolveResult | null {
|
||||
if (!isRecord(payload)) {
|
||||
return null;
|
||||
}
|
||||
if (payload.status === "cancelled") {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
const answers = parseQuestionAnswers(payload.answers);
|
||||
return payload.status === "answered" && answers ? { status: "answered", answers } : null;
|
||||
}
|
||||
|
||||
export function createQuestionPromptState(onChange: () => void): QuestionPromptState {
|
||||
const state: QuestionPromptState = {
|
||||
client: null,
|
||||
@@ -286,6 +286,7 @@ function scheduleTick(state: QuestionPromptState): void {
|
||||
for (const prompt of state.prompts.values()) {
|
||||
if (prompt.status === "pending" && prompt.expiresAtMs <= now) {
|
||||
prompt.status = "expired";
|
||||
clearSecretQuestionDrafts(prompt.questions, prompt.drafts);
|
||||
prompt.locallyExpired = true;
|
||||
prompt.submitting = false;
|
||||
prompt.error = null;
|
||||
@@ -306,6 +307,10 @@ function promptFromRecord(
|
||||
previous?: QuestionPrompt,
|
||||
): QuestionPrompt {
|
||||
const revision = ++state.revision;
|
||||
const drafts = previous?.drafts ?? new Map();
|
||||
if (record.status !== "pending") {
|
||||
clearSecretQuestionDrafts(record.questions, drafts);
|
||||
}
|
||||
return {
|
||||
id: record.id,
|
||||
questions: record.questions,
|
||||
@@ -329,7 +334,10 @@ function promptFromRecord(
|
||||
? (previous?.submitting ?? false)
|
||||
: false,
|
||||
error: record.status === "pending" ? (previous?.error ?? null) : null,
|
||||
drafts: previous?.drafts ?? new Map(),
|
||||
drafts,
|
||||
...(previous?.secretStoreAllowedHostsDraft !== undefined
|
||||
? { secretStoreAllowedHostsDraft: previous.secretStoreAllowedHostsDraft }
|
||||
: {}),
|
||||
revision,
|
||||
};
|
||||
}
|
||||
@@ -340,6 +348,7 @@ function applyQuestionResolution(
|
||||
resolved: QuestionResolvedEvent,
|
||||
): void {
|
||||
prompt.status = resolved.status;
|
||||
clearSecretQuestionDrafts(prompt.questions, prompt.drafts);
|
||||
prompt.answers = resolved.status === "answered" ? resolved.answers : undefined;
|
||||
const matchesSubmittedAnswer =
|
||||
resolved.status === "answered" &&
|
||||
@@ -433,6 +442,7 @@ function markRecoveryUnavailable(state: QuestionPromptState, prompt: QuestionPro
|
||||
// QUESTION_NOT_FOUND means the gateway tombstone aged out. It proves the prompt is
|
||||
// no longer actionable, but not whether it was answered, cancelled, or expired.
|
||||
prompt.status = "unavailable";
|
||||
clearSecretQuestionDrafts(prompt.questions, prompt.drafts);
|
||||
prompt.answers = undefined;
|
||||
prompt.answeredElsewhere = false;
|
||||
prompt.localResolutionConfirmed = false;
|
||||
@@ -656,12 +666,6 @@ export function disposeQuestionPromptState(state: QuestionPromptState): void {
|
||||
state.client = null;
|
||||
}
|
||||
|
||||
function buildAnswers(values: QuestionAnswerValues): QuestionAnswers {
|
||||
return {
|
||||
answers: Object.fromEntries(Object.entries(values).map(([id, answers]) => [id, [...answers]])),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveQuestionPrompt(
|
||||
state: QuestionPromptState,
|
||||
id: string,
|
||||
@@ -680,8 +684,13 @@ async function resolveQuestionPrompt(
|
||||
return;
|
||||
}
|
||||
prompt.submitting = true;
|
||||
const submittedAnswers = "answers" in resolution ? buildAnswers(resolution.answers) : undefined;
|
||||
prompt.submittedAnswers = submittedAnswers;
|
||||
const submission = prepareQuestionSecretStoreSubmission(
|
||||
id,
|
||||
prompt.questions,
|
||||
resolution,
|
||||
prompt.secretStoreAllowedHostsDraft,
|
||||
);
|
||||
prompt.submittedAnswers = submission.submittedAnswers;
|
||||
prompt.error = null;
|
||||
prompt.revision = ++state.revision;
|
||||
state.onChange();
|
||||
@@ -689,11 +698,11 @@ async function resolveQuestionPrompt(
|
||||
const result = await requestQuestionGateway(
|
||||
client,
|
||||
"question.resolve",
|
||||
submittedAnswers ? { id, answers: submittedAnswers } : { id, cancel: true },
|
||||
submission.requestParams,
|
||||
prompt.expiresAtMs,
|
||||
);
|
||||
const resolved = parseQuestionResolveResult(result);
|
||||
if (!resolved || resolved.status !== (submittedAnswers ? "answered" : "cancelled")) {
|
||||
const resolved = parseQuestionSubmissionResult(result, parseQuestionAnswers);
|
||||
if (!resolved || resolved.status !== (submission.submittedAnswers ? "answered" : "cancelled")) {
|
||||
throw new Error("invalid question.resolve response");
|
||||
}
|
||||
if (state.client === client && state.clientGeneration === clientGeneration) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Control UI E2E tests cover composer-replacing Gateway questions through the mocked WebSocket.
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { QuestionResolveResult } from "@openclaw/gateway-protocol";
|
||||
import type { Question, QuestionResolveResult } from "@openclaw/gateway-protocol";
|
||||
import type { BrowserContext, Page } from "playwright";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import type { SessionsListResult } from "../api/types.ts";
|
||||
@@ -27,17 +27,7 @@ const mainSessionKey = "agent:main:main";
|
||||
const questionSessionKey = "agent:main:question-proof";
|
||||
|
||||
let context: BrowserContext | undefined;
|
||||
function questionRecord(
|
||||
id: string,
|
||||
questions: Array<{
|
||||
questionId: string;
|
||||
header: string;
|
||||
question: string;
|
||||
options: Array<{ label: string; description?: string }>;
|
||||
multiSelect?: boolean;
|
||||
isOther?: boolean;
|
||||
}>,
|
||||
) {
|
||||
function questionRecord(id: string, questions: Question[]) {
|
||||
const createdAtMs = Date.now();
|
||||
return {
|
||||
id,
|
||||
@@ -50,6 +40,32 @@ function questionRecord(
|
||||
};
|
||||
}
|
||||
|
||||
function secretStoreQuestion(id: string) {
|
||||
return questionRecord(id, [
|
||||
{
|
||||
questionId: "api_key",
|
||||
header: "API key",
|
||||
question: "Provide the deployment API key",
|
||||
options: [],
|
||||
isSecret: true,
|
||||
secretStore: {
|
||||
name: "DEPLOY_API_KEY",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.test"],
|
||||
reason: "Publish the release artifacts.",
|
||||
},
|
||||
secretStoreExisting: {
|
||||
updatedAtMs: Date.now() - 60_000,
|
||||
updatedBy: "release-operator",
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function storedSecretAnswer(): QuestionResolveResult {
|
||||
return { status: "answered", answers: { answers: { api_key: ["stored"] } } };
|
||||
}
|
||||
|
||||
async function screenshot(page: Page, name: string) {
|
||||
if (!captureUiProof) {
|
||||
return;
|
||||
@@ -347,6 +363,143 @@ suite.define(() => {
|
||||
await screenshot(page, "02-question-answered.png");
|
||||
});
|
||||
|
||||
it("masks a store-bound secret and resolves it with edited hosts without echoing the value", async () => {
|
||||
const { gateway, page } = await openQuestionPage();
|
||||
const request = secretStoreQuestion("question-store-secret-success");
|
||||
const fakeSecret = "fake-secret-never-use-browser-proof-123";
|
||||
await gateway.setMethodResponse("question.resolve", storedSecretAnswer());
|
||||
await emitRequested(gateway, request);
|
||||
|
||||
const panel = panelFor(page, "Provide the deployment API key");
|
||||
await panel.waitFor();
|
||||
await expect.poll(() => panel.getByText("Requested by main", { exact: false }).count()).toBe(1);
|
||||
await expect.poll(() => panel.getByText("Publish the release artifacts.").count()).toBe(1);
|
||||
await expect
|
||||
.poll(() => panel.getByText("Replaces DEPLOY_API_KEY", { exact: false }).count())
|
||||
.toBe(1);
|
||||
const secretInput = panel.locator('input[type="password"]');
|
||||
await expect.poll(() => secretInput.count()).toBe(1);
|
||||
expect(await secretInput.getAttribute("autocomplete")).toBe("off");
|
||||
const hostsInput = panel.locator(".chat-question-panel__hosts");
|
||||
expect(await hostsInput.inputValue()).toBe("api.example.test");
|
||||
await screenshot(page, "07-secret-store-pending.png");
|
||||
|
||||
await secretInput.fill(fakeSecret);
|
||||
await hostsInput.fill("api.example.test, uploads.example.test extra.example.test");
|
||||
expect(await page.locator("body").textContent()).not.toContain(fakeSecret);
|
||||
expect(await page.locator("body").evaluate((element) => element.innerHTML)).not.toContain(
|
||||
fakeSecret,
|
||||
);
|
||||
await screenshot(page, "08-secret-store-masked-input.png");
|
||||
|
||||
await panel.getByRole("button", { name: "Submit", exact: true }).click();
|
||||
const resolveRequest = await gateway.waitForRequest("question.resolve");
|
||||
expect(resolveRequest.params).toEqual({
|
||||
id: request.id,
|
||||
answers: { answers: { api_key: [fakeSecret] } },
|
||||
secretStoreAllowedHosts: ["api.example.test", "uploads.example.test", "extra.example.test"],
|
||||
});
|
||||
await expect.poll(() => panel.count()).toBe(0);
|
||||
const summary = page.locator(".chat-question-summary").filter({ hasText: "API key:" });
|
||||
await summary.waitFor();
|
||||
await expect.poll(() => summary.getByText("Answered", { exact: true }).count()).toBe(1);
|
||||
expect(await summary.textContent()).not.toContain("stored");
|
||||
expect(await page.locator("body").textContent()).not.toContain(fakeSecret);
|
||||
await screenshot(page, "09-secret-store-answered.png");
|
||||
});
|
||||
|
||||
it("keeps a store-bound question interactive after Gateway validation rejects its hosts", async () => {
|
||||
const { gateway, page } = await openQuestionPage();
|
||||
const request = secretStoreQuestion("question-store-secret-validation");
|
||||
const fakeSecret = "fake-secret-never-use-validation-proof-456";
|
||||
const validationMessage = "Allowed hosts must be valid hostnames.";
|
||||
await gateway.setMethodResponse("question.resolve", {
|
||||
__mockError: { code: "INVALID_REQUEST", message: validationMessage },
|
||||
});
|
||||
await emitRequested(gateway, request);
|
||||
|
||||
const panel = panelFor(page, "Provide the deployment API key");
|
||||
const secretInput = panel.locator('input[type="password"]');
|
||||
const hostsInput = panel.locator(".chat-question-panel__hosts");
|
||||
await secretInput.fill(fakeSecret);
|
||||
await hostsInput.fill("bad-host.example.test");
|
||||
await panel.getByRole("button", { name: "Submit", exact: true }).click();
|
||||
await gateway.waitForRequest("question.resolve");
|
||||
await panel.getByText(validationMessage, { exact: false }).waitFor();
|
||||
await expect.poll(() => secretInput.isEnabled()).toBe(true);
|
||||
expect(await secretInput.inputValue()).toBe(fakeSecret);
|
||||
expect(await page.locator("body").textContent()).not.toContain(fakeSecret);
|
||||
await screenshot(page, "10-secret-store-validation-error.png");
|
||||
|
||||
await gateway.setMethodResponse("question.resolve", storedSecretAnswer());
|
||||
await hostsInput.fill("corrected.example.test");
|
||||
const previousRequestCount = (await gateway.getRequests("question.resolve")).length;
|
||||
await panel.getByRole("button", { name: "Submit", exact: true }).click();
|
||||
const retry = await gateway.waitForRequest("question.resolve", {
|
||||
after: previousRequestCount,
|
||||
});
|
||||
expect(retry.params).toEqual({
|
||||
id: request.id,
|
||||
answers: { answers: { api_key: [fakeSecret] } },
|
||||
secretStoreAllowedHosts: ["corrected.example.test"],
|
||||
});
|
||||
await expect.poll(() => panel.count()).toBe(0);
|
||||
expect(await page.locator("body").textContent()).not.toContain(fakeSecret);
|
||||
});
|
||||
|
||||
it("loads a mounted /ask document and resolves its secret through the shared question card", async () => {
|
||||
context = await suite.browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1440 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const request = secretStoreQuestion("question-store-secret-deep-link");
|
||||
const fakeSecret = "fake-secret-never-use-deep-link-proof-789";
|
||||
const gateway = await installMockGateway(page, {
|
||||
basePath: "/operator",
|
||||
featureMethods: ["question.get", "question.resolve"],
|
||||
methodResponses: {
|
||||
"question.get": { question: request },
|
||||
"question.resolve": storedSecretAnswer(),
|
||||
},
|
||||
});
|
||||
const documentUrl = new URL(
|
||||
`operator/ask/${encodeURIComponent(request.id)}`,
|
||||
suite.server.baseUrl,
|
||||
);
|
||||
await page.goto(documentUrl.toString());
|
||||
const getRequest = await gateway.waitForRequest("question.get");
|
||||
expect(getRequest.params).toEqual({ id: request.id });
|
||||
|
||||
const document = page.locator("openclaw-question-page");
|
||||
await document.waitFor();
|
||||
expect(await page.locator("openclaw-app-shell, openclaw-app-sidebar").count()).toBe(0);
|
||||
const panel = document.locator("openclaw-chat-question-panel");
|
||||
await panel.waitFor();
|
||||
await screenshot(page, "11-secret-store-ask-pending.png");
|
||||
const secretInput = panel.locator('input[type="password"]');
|
||||
await secretInput.fill(fakeSecret);
|
||||
expect(await page.locator("body").textContent()).not.toContain(fakeSecret);
|
||||
expect(await page.locator("body").evaluate((element) => element.innerHTML)).not.toContain(
|
||||
fakeSecret,
|
||||
);
|
||||
await screenshot(page, "12-secret-store-ask-masked-input.png");
|
||||
await panel.getByRole("button", { name: "Submit", exact: true }).click();
|
||||
|
||||
const resolveRequest = await gateway.waitForRequest("question.resolve");
|
||||
expect(resolveRequest.params).toEqual({
|
||||
id: request.id,
|
||||
answers: { answers: { api_key: [fakeSecret] } },
|
||||
secretStoreAllowedHosts: ["api.example.test"],
|
||||
});
|
||||
await document.getByRole("heading", { name: "Answered", exact: true }).waitFor();
|
||||
expect(await document.textContent()).not.toContain(fakeSecret);
|
||||
expect(await document.textContent()).not.toContain("stored");
|
||||
expect(new URL(page.url()).pathname).toBe(`/operator/ask/${request.id}`);
|
||||
await screenshot(page, "13-secret-store-ask-answered.png");
|
||||
});
|
||||
|
||||
it("keeps multi-select on one step and submits labels as an array", async () => {
|
||||
const { gateway, page } = await openQuestionPage();
|
||||
const request = questionRecord("question-release-checks", [
|
||||
|
||||
@@ -5705,6 +5705,10 @@ export const en: TranslationMap = {
|
||||
eyebrow: "Question",
|
||||
summaryLabel: "Question outcome",
|
||||
ownAnswerFor: "Your own answer for {header}",
|
||||
storeRequestedBy: "Requested by {agent} · {session}",
|
||||
storeEntry: "Stores {name} as {kind}",
|
||||
storeReplacement: "Replaces {name} — last updated {updated}",
|
||||
storeReplacementBy: "Replaces {name} — last updated {updated} by {updatedBy}",
|
||||
submitting: "Submitting…",
|
||||
submitFailed: "Could not submit: {error}",
|
||||
answered: "Answered",
|
||||
|
||||
@@ -121,6 +121,29 @@ describe("question chat items", () => {
|
||||
).toContain("Format: Detailed");
|
||||
});
|
||||
|
||||
it("never echoes a secret answer in the terminal transcript summary", () => {
|
||||
const answered = prompt("answered");
|
||||
answered.questions = [
|
||||
{
|
||||
questionId: "api_key",
|
||||
header: "API key",
|
||||
question: "Provide the deployment API key",
|
||||
options: [],
|
||||
isSecret: true,
|
||||
secretStore: { name: "FAKE_DEPLOYMENT_API_KEY", kind: "secret" },
|
||||
},
|
||||
];
|
||||
answered.answeredElsewhere = true;
|
||||
answered.answers = { answers: { api_key: ["fake-secret-never-render"] } };
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(renderChatQuestionSummary(answered), container);
|
||||
|
||||
expect(container.textContent?.replace(/\s+/g, " ")).toContain("API key: Answered");
|
||||
expect(container.textContent).not.toContain("fake-secret-never-render");
|
||||
expect(container.innerHTML).not.toContain("fake-secret-never-render");
|
||||
});
|
||||
|
||||
it("omits questions belonging to another session", () => {
|
||||
const other = prompt("pending");
|
||||
other.sessionKey = "agent:other:main";
|
||||
|
||||
@@ -138,6 +138,84 @@ describe("shared question panel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a store-bound secret masked while preserving editable destination hosts", async () => {
|
||||
const prompt = gatewayPrompt({
|
||||
agentId: "release-agent",
|
||||
questions: [
|
||||
{
|
||||
questionId: "api_key",
|
||||
header: "API key",
|
||||
question: "Provide the deployment API key",
|
||||
options: [],
|
||||
isSecret: true,
|
||||
secretStore: {
|
||||
name: "FAKE_DEPLOYMENT_API_KEY",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.test"],
|
||||
reason: "Deploy the approved release",
|
||||
},
|
||||
secretStoreExisting: {
|
||||
updatedAtMs: Date.now() - 60_000,
|
||||
updatedBy: "release-owner",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const onSubmit = vi.fn();
|
||||
drawGateway(prompt, { onSubmit });
|
||||
const panel = await panelIn(container);
|
||||
const hosts = container.querySelector<HTMLInputElement>(".chat-question-panel__hosts")!;
|
||||
const secret = container.querySelector<HTMLInputElement>('input[type="password"]')!;
|
||||
|
||||
expect(hosts.value).toBe("api.example.test");
|
||||
expect(secret.autocomplete).toBe("off");
|
||||
expect(container.textContent).toContain("release-agent");
|
||||
expect(container.textContent).toContain("agent:main:main");
|
||||
expect(container.textContent).toContain("Stores FAKE_DEPLOYMENT_API_KEY as Protected secret");
|
||||
expect(container.textContent).toContain("Deploy the approved release");
|
||||
expect(container.textContent).toContain("Replaces FAKE_DEPLOYMENT_API_KEY — last updated");
|
||||
expect(container.textContent).toContain("by release-owner");
|
||||
|
||||
hosts.value = "api.example.test, uploads.example.test";
|
||||
hosts.dispatchEvent(new InputEvent("input", { bubbles: true }));
|
||||
await panel.updateComplete;
|
||||
expect(prompt.secretStoreAllowedHostsDraft).toBe("api.example.test, uploads.example.test");
|
||||
|
||||
const fakeSecret = "fake-secret-value-for-ui-test";
|
||||
secret.value = fakeSecret;
|
||||
secret.dispatchEvent(new InputEvent("input", { bubbles: true }));
|
||||
await panel.updateComplete;
|
||||
expect(container.textContent).not.toContain(fakeSecret);
|
||||
expect(container.innerHTML).not.toContain(fakeSecret);
|
||||
|
||||
container.querySelector<HTMLButtonElement>(".chat-question-panel__advance")?.click();
|
||||
expect(onSubmit).toHaveBeenCalledWith({ api_key: [fakeSecret] });
|
||||
});
|
||||
|
||||
it("keeps environment store requests masked without exposing a destination-host editor", async () => {
|
||||
drawGateway(
|
||||
gatewayPrompt({
|
||||
questions: [
|
||||
{
|
||||
questionId: "environment_value",
|
||||
header: "Environment",
|
||||
question: "Provide the environment value",
|
||||
options: [],
|
||||
isSecret: true,
|
||||
secretStore: { name: "FAKE_ENVIRONMENT_VALUE", kind: "env" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await panelIn(container);
|
||||
|
||||
expect(container.querySelector('input[type="password"]')).not.toBeNull();
|
||||
expect(container.querySelector(".chat-question-panel__hosts")).toBeNull();
|
||||
expect(container.textContent).toContain(
|
||||
"Stores FAKE_ENVIRONMENT_VALUE as Agent-readable environment",
|
||||
);
|
||||
});
|
||||
|
||||
it("supports numeric selection and Enter submission while focused", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
drawGateway(gatewayPrompt(), { onSubmit });
|
||||
|
||||
@@ -4,20 +4,17 @@ import { property, state } from "lit/decorators.js";
|
||||
import type { QuestionPrompt } from "../../../app/question-prompt.ts";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import { formatRelativeTimestamp } from "../../../lib/format.ts";
|
||||
|
||||
type QuestionPanelQuestion = {
|
||||
questionId: string;
|
||||
header: string;
|
||||
question: string;
|
||||
options: Array<{ label: string; description?: string }>;
|
||||
multiSelect?: boolean;
|
||||
isOther?: boolean;
|
||||
};
|
||||
type QuestionPanelQuestion = QuestionPrompt["questions"][number];
|
||||
|
||||
type QuestionPanelViewModel = {
|
||||
requestKey: string;
|
||||
title: string;
|
||||
questions: QuestionPanelQuestion[];
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
secretStoreAllowedHostsDraft?: string;
|
||||
collapsed: boolean;
|
||||
disabled: boolean;
|
||||
submitting?: boolean;
|
||||
@@ -31,6 +28,7 @@ type QuestionPanelProps = {
|
||||
onSubmit?: (answersById: Record<string, string[]>) => void | Promise<void>;
|
||||
onSkip?: () => void | Promise<void>;
|
||||
onAnswersChange?: (answersById: Record<string, string[]>) => void;
|
||||
onSecretStoreAllowedHostsChange?: (allowedHosts: string) => void;
|
||||
onDismissError?: () => void;
|
||||
onCollapsedChange?: (collapsed: boolean) => void;
|
||||
onPreviousRequest?: () => void;
|
||||
@@ -80,6 +78,9 @@ export function createGatewayQuestionPanelProps(
|
||||
requestKey: prompt.id,
|
||||
title: t("chat.questions.eyebrow"),
|
||||
questions: prompt.questions,
|
||||
agentId: prompt.agentId,
|
||||
sessionKey: prompt.sessionKey,
|
||||
secretStoreAllowedHostsDraft: prompt.secretStoreAllowedHostsDraft,
|
||||
collapsed: options.collapsed ?? false,
|
||||
disabled: prompt.status !== "pending" || prompt.submitting,
|
||||
submitting: prompt.submitting,
|
||||
@@ -91,6 +92,10 @@ export function createGatewayQuestionPanelProps(
|
||||
updatePromptDrafts(prompt, answersById);
|
||||
options.onChange?.();
|
||||
},
|
||||
onSecretStoreAllowedHostsChange: (allowedHosts) => {
|
||||
prompt.secretStoreAllowedHostsDraft = allowedHosts;
|
||||
options.onChange?.();
|
||||
},
|
||||
onSubmit: options.onSubmit
|
||||
? async (answersById) => {
|
||||
await options.onSubmit?.(answersById);
|
||||
@@ -130,6 +135,9 @@ function terminalAnswer(prompt: QuestionPrompt, question: QuestionPanelQuestion)
|
||||
if (prompt.status === "unavailable") {
|
||||
return t("chat.questions.unavailable");
|
||||
}
|
||||
if (question.isSecret) {
|
||||
return t("chat.questions.answered");
|
||||
}
|
||||
const answer = prompt.answers?.answers[question.questionId]?.join(", ");
|
||||
if (answer) {
|
||||
return answer;
|
||||
@@ -577,6 +585,71 @@ class ChatQuestionPanel extends LitElement {
|
||||
})}
|
||||
</div>
|
||||
|
||||
${question.secretStore
|
||||
? html`
|
||||
<div class="chat-question-panel__store">
|
||||
<div class="chat-question-panel__store-requester">
|
||||
${t("chat.questions.storeRequestedBy", {
|
||||
agent: model.agentId ?? t("common.unknown"),
|
||||
session: model.sessionKey ?? t("common.unknown"),
|
||||
})}
|
||||
</div>
|
||||
<div class="chat-question-panel__store-entry">
|
||||
${t("chat.questions.storeEntry", {
|
||||
name: question.secretStore.name,
|
||||
kind:
|
||||
question.secretStore.kind === "secret"
|
||||
? t("secretsStore.protectedSecret")
|
||||
: t("secretsStore.agentReadable"),
|
||||
})}
|
||||
</div>
|
||||
${question.secretStore.reason
|
||||
? html`<div class="chat-question-panel__store-reason">
|
||||
${question.secretStore.reason}
|
||||
</div>`
|
||||
: nothing}
|
||||
${question.secretStoreExisting
|
||||
? html`<div class="chat-question-panel__store-replacement">
|
||||
${question.secretStoreExisting.updatedBy
|
||||
? t("chat.questions.storeReplacementBy", {
|
||||
name: question.secretStore.name,
|
||||
updated: formatRelativeTimestamp(
|
||||
question.secretStoreExisting.updatedAtMs,
|
||||
),
|
||||
updatedBy: question.secretStoreExisting.updatedBy,
|
||||
})
|
||||
: t("chat.questions.storeReplacement", {
|
||||
name: question.secretStore.name,
|
||||
updated: formatRelativeTimestamp(
|
||||
question.secretStoreExisting.updatedAtMs,
|
||||
),
|
||||
})}
|
||||
</div>`
|
||||
: nothing}
|
||||
${question.secretStore.kind === "secret"
|
||||
? html`<label class="chat-question-panel__store-hosts">
|
||||
<span>${t("secretsStore.allowedHosts")}</span>
|
||||
<input
|
||||
class="chat-question-panel__other chat-question-panel__hosts"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder=${t("secretsStore.allowedHostsPlaceholder")}
|
||||
.value=${model.secretStoreAllowedHostsDraft ??
|
||||
question.secretStore.allowedHosts?.join(", ") ??
|
||||
""}
|
||||
?disabled=${disabled}
|
||||
@input=${(event: Event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLInputElement) {
|
||||
props.onSecretStoreAllowedHostsChange?.(target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${question.isOther || question.options.length === 0
|
||||
? html`
|
||||
<label
|
||||
@@ -589,7 +662,7 @@ class ChatQuestionPanel extends LitElement {
|
||||
<span class="chat-question-panel__option-marker" aria-hidden="true"></span>
|
||||
<input
|
||||
class="chat-question-panel__other"
|
||||
type="text"
|
||||
type=${question.isSecret ? "password" : "text"}
|
||||
autocomplete="off"
|
||||
placeholder=${t("chat.questions.other")}
|
||||
aria-label=${t("chat.questions.ownAnswerFor", { header: question.header })}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { QuestionPage } from "./question-page.ts";
|
||||
|
||||
if (!customElements.get("openclaw-question-page")) {
|
||||
customElements.define("openclaw-question-page", QuestionPage);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import "../../styles/approval.css";
|
||||
import "../../styles/chat/question-card.css";
|
||||
import { consume } from "@lit/context";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { html, nothing, type PropertyValues } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { RouteId } from "../../app-route-paths.ts";
|
||||
import {
|
||||
applicationContext,
|
||||
type ApplicationContext,
|
||||
type ApplicationGatewaySnapshot,
|
||||
} from "../../app/context.ts";
|
||||
import { requestQuestionGateway } from "../../app/question-prompt-client.ts";
|
||||
import {
|
||||
cancelQuestionPrompt,
|
||||
createQuestionPromptState,
|
||||
disposeQuestionPromptState,
|
||||
handleQuestionPromptEvent,
|
||||
listQuestionPrompts,
|
||||
setQuestionPromptClient,
|
||||
submitQuestionPrompt,
|
||||
type QuestionPrompt,
|
||||
} from "../../app/question-prompt.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
|
||||
import {
|
||||
createGatewayQuestionPanelProps,
|
||||
renderChatQuestionSummary,
|
||||
} from "../chat/components/chat-question-card.ts";
|
||||
|
||||
type QuestionPageRequestError = "connection" | "unavailable" | null;
|
||||
|
||||
export class QuestionPage extends OpenClawLightDomElement {
|
||||
@consume({ context: applicationContext, subscribe: false })
|
||||
context!: ApplicationContext<RouteId>;
|
||||
|
||||
@property({ attribute: "question-id" }) questionId = "";
|
||||
@state() private loading = true;
|
||||
@state() private requestError: QuestionPageRequestError = null;
|
||||
|
||||
private readonly questionState = createQuestionPromptState(() => this.requestUpdate());
|
||||
private client: GatewayBrowserClient | null = null;
|
||||
private boundQuestionId: string | undefined;
|
||||
private operationGeneration = 0;
|
||||
private stopGateway: (() => void) | undefined;
|
||||
private stopGatewayEvents: (() => void) | undefined;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.boundQuestionId = this.questionId;
|
||||
this.stopGateway = this.context.gateway.subscribe((snapshot) =>
|
||||
this.applyGatewaySnapshot(snapshot),
|
||||
);
|
||||
this.stopGatewayEvents = this.context.gateway.subscribeEvents((event) => {
|
||||
if (isRecord(event.payload) && event.payload.id === this.questionId) {
|
||||
handleQuestionPromptEvent(this.questionState, event);
|
||||
}
|
||||
});
|
||||
this.applyGatewaySnapshot(this.context.gateway.snapshot);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this.stopGateway?.();
|
||||
this.stopGateway = undefined;
|
||||
this.stopGatewayEvents?.();
|
||||
this.stopGatewayEvents = undefined;
|
||||
this.operationGeneration += 1;
|
||||
this.client = null;
|
||||
disposeQuestionPromptState(this.questionState);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected override updated(changed: PropertyValues<this>): void {
|
||||
if (changed.has("questionId") && this.boundQuestionId !== this.questionId) {
|
||||
this.boundQuestionId = this.questionId;
|
||||
this.operationGeneration += 1;
|
||||
this.requestError = this.questionId ? null : "unavailable";
|
||||
this.loading = Boolean(this.questionId);
|
||||
if (this.questionId && this.client) {
|
||||
void this.loadQuestion(this.client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot): void {
|
||||
const nextClient = snapshot.phase === "connected" ? snapshot.client : null;
|
||||
if (this.client === nextClient) {
|
||||
if (
|
||||
!nextClient &&
|
||||
(snapshot.lastError ||
|
||||
snapshot.phase === "offline" ||
|
||||
snapshot.phase === "reload-required" ||
|
||||
snapshot.phase === "stopped")
|
||||
) {
|
||||
this.loading = false;
|
||||
this.requestError = "connection";
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.operationGeneration += 1;
|
||||
this.client = nextClient;
|
||||
setQuestionPromptClient(this.questionState, nextClient);
|
||||
if (!nextClient) {
|
||||
if (listQuestionPrompts(this.questionState).some((prompt) => prompt.id === this.questionId)) {
|
||||
this.loading = false;
|
||||
this.requestError = "connection";
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!this.questionId) {
|
||||
this.loading = false;
|
||||
this.requestError = "unavailable";
|
||||
return;
|
||||
}
|
||||
void this.loadQuestion(nextClient);
|
||||
}
|
||||
|
||||
private async loadQuestion(client: GatewayBrowserClient): Promise<void> {
|
||||
const id = this.questionId;
|
||||
const generation = ++this.operationGeneration;
|
||||
this.loading = true;
|
||||
this.requestError = null;
|
||||
try {
|
||||
const result = await requestQuestionGateway(client, "question.get", { id });
|
||||
if (
|
||||
this.client !== client ||
|
||||
this.operationGeneration !== generation ||
|
||||
this.questionId !== id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!isRecord(result) || !isRecord(result.question) || result.question.id !== id) {
|
||||
this.requestError = "unavailable";
|
||||
return;
|
||||
}
|
||||
const record = result.question;
|
||||
if (
|
||||
record.id !== id ||
|
||||
(record.status !== "pending" &&
|
||||
record.status !== "answered" &&
|
||||
record.status !== "cancelled" &&
|
||||
record.status !== "expired") ||
|
||||
!handleQuestionPromptEvent(this.questionState, {
|
||||
event: "question.requested",
|
||||
payload: { ...record, status: "pending" },
|
||||
})
|
||||
) {
|
||||
this.requestError = "unavailable";
|
||||
return;
|
||||
}
|
||||
if (record.status === "answered") {
|
||||
const accepted = handleQuestionPromptEvent(this.questionState, {
|
||||
event: "question.resolved",
|
||||
payload: { id, status: "answered", answers: record.answers },
|
||||
});
|
||||
if (!accepted) {
|
||||
this.requestError = "unavailable";
|
||||
}
|
||||
} else if (record.status === "cancelled" || record.status === "expired") {
|
||||
handleQuestionPromptEvent(this.questionState, {
|
||||
event: "question.resolved",
|
||||
payload: { id, status: record.status },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (this.client === client && this.operationGeneration === generation) {
|
||||
this.requestError = "unavailable";
|
||||
}
|
||||
} finally {
|
||||
if (this.client === client && this.operationGeneration === generation) {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderQuestion(prompt: QuestionPrompt) {
|
||||
if (prompt.status !== "pending") {
|
||||
return html`
|
||||
<div class="approval-page__state" data-question-status=${prompt.status}>
|
||||
<h1>${this.questionStatusLabel(prompt)}</h1>
|
||||
${renderChatQuestionSummary(prompt)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const props = createGatewayQuestionPanelProps(prompt, {
|
||||
onChange: () => this.requestUpdate(),
|
||||
onSubmit: (answers) => submitQuestionPrompt(this.questionState, prompt.id, answers),
|
||||
onSkip: () => cancelQuestionPrompt(this.questionState, prompt.id),
|
||||
});
|
||||
return html`<openclaw-chat-question-panel .props=${props}></openclaw-chat-question-panel>`;
|
||||
}
|
||||
|
||||
private questionStatusLabel(prompt: QuestionPrompt): string {
|
||||
if (prompt.status === "answered") {
|
||||
return t("chat.questions.answered");
|
||||
}
|
||||
if (prompt.status === "cancelled") {
|
||||
return t("chat.questions.skipped");
|
||||
}
|
||||
if (prompt.status === "expired") {
|
||||
return t("chat.questions.expired");
|
||||
}
|
||||
return t("chat.questions.unavailable");
|
||||
}
|
||||
|
||||
override render() {
|
||||
const prompt = listQuestionPrompts(this.questionState).find(
|
||||
(candidate) => candidate.id === this.questionId,
|
||||
);
|
||||
const content = this.loading
|
||||
? html`<div class="approval-page__state" role="status">${t("common.loading")}</div>`
|
||||
: this.requestError
|
||||
? html`<div class="approval-page__state" role="status">
|
||||
${this.requestError === "connection"
|
||||
? t("chat.questions.disconnected")
|
||||
: t("chat.questions.unavailable")}
|
||||
</div>`
|
||||
: prompt
|
||||
? this.renderQuestion(prompt)
|
||||
: nothing;
|
||||
return html`
|
||||
<main
|
||||
class="approval-page question-page"
|
||||
data-state=${prompt?.status ?? this.requestError ?? "loading"}
|
||||
>
|
||||
<div class="approval-page__card approval-page__card--severity-info">
|
||||
<div class="approval-page__content">${content}</div>
|
||||
</div>
|
||||
</main>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
openclaw-approval-page {
|
||||
openclaw-approval-page,
|
||||
openclaw-question-page {
|
||||
display: block;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
@@ -30,7 +31,8 @@ openclaw-approval-page {
|
||||
}
|
||||
|
||||
@media (display-mode: standalone) {
|
||||
openclaw-approval-page {
|
||||
openclaw-approval-page,
|
||||
openclaw-question-page {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -278,6 +278,28 @@ openclaw-chat-question-panel {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-question-panel__store {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-left: 10px;
|
||||
border-left: 2px solid color-mix(in srgb, var(--accent) 42%, var(--border));
|
||||
color: var(--muted);
|
||||
font-size: var(--control-ui-text-sm);
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.chat-question-panel__store-entry {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-question-panel__store-hosts {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.chat-question-panel__other {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -291,6 +313,23 @@ openclaw-chat-question-panel {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* The hosts field sits outside an option row, so it cannot borrow the row's
|
||||
* border/focus treatment; give it its own so the one field the operator is
|
||||
* meant to review does not read as static text.
|
||||
*/
|
||||
.chat-question-panel__hosts {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.chat-question-panel__hosts:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.chat-question-panel__option--other:has(.chat-question-panel__other:focus-visible) {
|
||||
background: color-mix(in srgb, var(--text-strong) 7%, var(--panel));
|
||||
outline: 2px solid var(--ring);
|
||||
|
||||
Reference in New Issue
Block a user