Repository navigation
Add batched secret approval errors with deep links - #72
Conversation
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 4 minutes and 28 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (15)
📝 WalkthroughWalkthroughAdds capability-prefill plumbing (URL query → editor state → handler), constructs capability-specific approval URLs, introduces batch approval-denied message formats/parsers, and updates execution flow to aggregate and report batch host/capability approval requirements. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client
participant Server as AccountSecrets<br/>Handler
participant ApprovalView as SecretApprovalView
Client->>Server: GET /secrets?capability=home_lutron
Server->>Server: readRequestedCapability(url)
Server->>ApprovalView: resolveSecretApprovalView(requestedCapability)
ApprovalView-->>Server: SecretApprovalView { requestedCapability }
Server-->>Client: HTML with approval banner
Client->>Client: applyCapabilityPrefill(capability)
Client->>Server: POST host approval
Server-->>Client: Redirect to /secrets (capability cleared)
sequenceDiagram
participant Codemode as Codemode Execution
participant Registry as runCodemodeWithRegistry
participant Storage as Secret Storage
participant Executor as Executor
Codemode->>Registry: runCodemodeWithRegistry(code)
Registry->>Storage: resolve secret placeholders
Storage-->>Registry: error (capability denied)
Registry->>Registry: rewriteCapabilitySecretError(error)
Registry->>Storage: resolve all referenced secrets
Storage-->>Registry: secret objects
Registry->>Registry: buildSecretCapabilityApprovalUrl for each missing
Registry->>Executor: throw batch denial message
Executor->>Executor: parseCapabilityAccessRequiredBatchMessage
Executor-->>Client: ExecutionErrorDetails { kind: batch, missingApprovals[] }
Estimated code review effort🎯 4 (Complex) | ⏱️ ~55 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
🔎 Preview deployed: https://kody-pr-72.kentcdodds.workers.dev Worker: Mocks:
|
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/worker/client/routes/account-secrets.tsx`:
- Around line 798-803: The shared ApprovalView type is missing the
requestedCapability field referenced by account-secrets.tsx; update the
ApprovalView export (in account-approval-shared.ts) to include
requestedCapability: string | null so it matches the backend SecretApprovalView
and fixes TS2339 for approval.requestedCapability used in the
ApprovalView-consuming components.
In `@packages/worker/src/mcp/run-codemode-registry.ts`:
- Around line 195-215: collectSecretNamesFromCode currently strips scope and
parses only error messages; change it to parse real secret placeholders from
wrapped code by calling parseSecretPlaceholders(code) (not
parseSecretNamesFromMessage) and return a list of ReferencedSecret objects
(preserving name and scope) while still extracting bare names from errors with
parseSecretNamesFromMessage when needed; update downstream rewrite logic that
consumes collectSecretNamesFromCode (and any use of
normalizeSecretNameList/parseSecretNamesFromMessage) to accept and propagate
ReferencedSecret so multi-secret calls aren’t collapsed and scopes are preserved
for the resolver and approval URLs.
- Around line 151-159: The current post-execute path calls
rewriteCapabilitySecretError and can throw, causing runCodemodeWithRegistry to
propagate enrichment failures instead of returning the original ExecuteResult;
change this to best-effort by wrapping the call to rewriteCapabilitySecretError
(and any awaits like resolveSecret it triggers) in a try/catch inside the block
that follows executor.execute(wrapped, [provider]) and, on any thrown error,
log/ignore the enrichment error and return the original result object (the
variable result) unchanged; ensure you still return the enriched result when
rewriteCapabilitySecretError succeeds (i.e., batchError ? { ...result, error:
batchError } : result).
- Around line 106-112: Normalize callerContext.storageContext once into the
required StorageContext shape (ensuring sessionId and appId are present) and
reuse that normalized variable when calling buildSecretCapabilityApprovalUrl
instead of passing callerContext.storageContext directly; likewise, in
findMissingCapabilityApprovals normalize input.storageContext up-front and pass
that normalized value to both resolveSecret and buildSecretCapabilityApprovalUrl
so both receive a StorageContext | null with the required properties (update
usages around resolveSecret and buildSecretCapabilityApprovalUrl to reference
the normalized variable).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 0d2add05-98fe-4192-9007-0e50f5586a06
📒 Files selected for processing (10)
packages/worker/client/routes/account-secrets.tsxpackages/worker/src/app/handlers/account-secrets.tspackages/worker/src/mcp/executor.node.test.tspackages/worker/src/mcp/executor.tspackages/worker/src/mcp/fetch-gateway.tspackages/worker/src/mcp/run-codemode-registry.node.test.tspackages/worker/src/mcp/run-codemode-registry.tspackages/worker/src/mcp/secrets/capability-approval-url.tspackages/worker/src/mcp/secrets/errors.node.test.tspackages/worker/src/mcp/secrets/errors.ts
| function collectSecretNamesFromCode( | ||
| error: unknown, | ||
| code: string | null, | ||
| ) { | ||
| const fromError = | ||
| error instanceof Error ? parseSecretNamesFromMessage(error.message) : [] | ||
| const fromCode = code ? parseSecretNamesFromMessage(code) : [] | ||
| return normalizeSecretNameList([...fromError, ...fromCode]) | ||
| } | ||
|
|
||
| function parseSecretNamesFromMessage(message: string) { | ||
| const matches = Array.from(message.matchAll(/Secret "([^"]+)"/g)) | ||
| return matches | ||
| .map((match) => match[1]) | ||
| .filter((value): value is string => Boolean(value)) | ||
| } | ||
|
|
||
| function normalizeSecretNameList(names: Array<string>) { | ||
| return Array.from( | ||
| new Set(names.map((name) => name.trim()).filter((name) => name.length > 0)), | ||
| ).sort((left, right) => left.localeCompare(right)) |
There was a problem hiding this comment.
Parse actual secret placeholders here and keep their scope.
wrapped code contains placeholders like {{secret:lutronUsername|scope=user}}, not Secret "lutronUsername", so fromCode is usually empty. A multi-secret capability call will still rewrite to a one-entry batch, and collapsing everything to bare names also strips the scope the downstream resolver needs for app/session approval URLs. Parse code with parseSecretPlaceholders() and carry ReferencedSecret through the rest of the rewrite path.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/worker/src/mcp/run-codemode-registry.ts` around lines 195 - 215,
collectSecretNamesFromCode currently strips scope and parses only error
messages; change it to parse real secret placeholders from wrapped code by
calling parseSecretPlaceholders(code) (not parseSecretNamesFromMessage) and
return a list of ReferencedSecret objects (preserving name and scope) while
still extracting bare names from errors with parseSecretNamesFromMessage when
needed; update downstream rewrite logic that consumes collectSecretNamesFromCode
(and any use of normalizeSecretNameList/parseSecretNamesFromMessage) to accept
and propagate ReferencedSecret so multi-secret calls aren’t collapsed and scopes
are preserved for the resolver and approval URLs.
There was a problem hiding this comment.
♻️ Duplicate comments (1)
packages/worker/src/mcp/run-codemode-registry.ts (1)
223-257:⚠️ Potential issue | 🔴 CriticalNormalize
storageContextto fix type errors at lines 237 and 248.Static analysis flags type mismatches at lines 237 and 248. The parameter
input.storageContextis typed asMcpCallerContext['storageContext'] | nullwhich has optionalsessionId/appId, but bothresolveSecretandbuildSecretCapabilityApprovalUrlexpectStorageContext | nullwith required properties.🔧 Proposed fix - normalize once at the start of the function
async function findMissingCapabilityApprovals(input: { env: Env userId: string secretNames: Array<string> capabilityName: string storageContext: McpCallerContext['storageContext'] | null baseUrl: string }) { + const normalizedStorageContext = input.storageContext + ? { + sessionId: input.storageContext.sessionId ?? null, + appId: input.storageContext.appId ?? null, + } + : null const entries = await Promise.all( input.secretNames.map(async (name) => { const resolved = await resolveSecret({ env: input.env, userId: input.userId, name, - storageContext: input.storageContext, + storageContext: normalizedStorageContext, }) if (!resolved.found) return null if (resolved.allowedCapabilities.includes(input.capabilityName)) { return null } const approvalUrl = buildSecretCapabilityApprovalUrl({ baseUrl: input.baseUrl, name, scope: resolved.scope ?? 'user', capabilityName: input.capabilityName, - storageContext: input.storageContext, + storageContext: normalizedStorageContext, })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/worker/src/mcp/run-codemode-registry.ts` around lines 223 - 257, Normalize the optional storageContext at the start of findMissingCapabilityApprovals so the downstream calls get a StorageContext | null with required fields: create a local normalizedStorageContext (type StorageContext | null) from input.storageContext by returning null when absent and otherwise constructing an object that supplies the required appId/sessionId (or sensible defaults) and use that normalizedStorageContext in the calls to resolveSecret and buildSecretCapabilityApprovalUrl instead of input.storageContext; update the two call sites that currently pass input.storageContext (the resolveSecret call and the buildSecretCapabilityApprovalUrl call) to use the normalized variable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@packages/worker/src/mcp/run-codemode-registry.ts`:
- Around line 223-257: Normalize the optional storageContext at the start of
findMissingCapabilityApprovals so the downstream calls get a StorageContext |
null with required fields: create a local normalizedStorageContext (type
StorageContext | null) from input.storageContext by returning null when absent
and otherwise constructing an object that supplies the required appId/sessionId
(or sensible defaults) and use that normalizedStorageContext in the calls to
resolveSecret and buildSecretCapabilityApprovalUrl instead of
input.storageContext; update the two call sites that currently pass
input.storageContext (the resolveSecret call and the
buildSecretCapabilityApprovalUrl call) to use the normalized variable.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 2a341c40-db4d-4fbf-86d5-da8d6884f5a5
📒 Files selected for processing (1)
packages/worker/src/mcp/run-codemode-registry.ts
There was a problem hiding this comment.
♻️ Duplicate comments (4)
packages/worker/src/mcp/run-codemode-registry.ts (4)
110-116:⚠️ Potential issue | 🟠 MajorNormalize
storageContextbefore passing tobuildSecretCapabilityApprovalUrl.The
callerContext.storageContexthas optionalsessionId/appIdproperties (may beundefined), butbuildSecretCapabilityApprovalUrlexpectsStorageContext | nullwhere both are required (but nullable). Apply the same normalization pattern used at lines 99-104:const approvalUrl = buildSecretCapabilityApprovalUrl({ baseUrl: callerContext.baseUrl, name: secret.name, scope: resolved.scope ?? secret.scope ?? 'user', capabilityName, - storageContext: callerContext.storageContext ?? null, + storageContext: callerContext.storageContext + ? { + sessionId: callerContext.storageContext.sessionId ?? null, + appId: callerContext.storageContext.appId ?? null, + } + : null, }),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/worker/src/mcp/run-codemode-registry.ts` around lines 110 - 116, Normalize callerContext.storageContext before calling buildSecretCapabilityApprovalUrl: create a local storageContext variable that, if callerContext.storageContext is present, maps sessionId and appId to either their values or null (i.e. { sessionId: storage.sessionId ?? null, appId: storage.appId ?? null }), otherwise set storageContext to null; then pass that normalized storageContext into buildSecretCapabilityApprovalUrl (referencing buildSecretCapabilityApprovalUrl, callerContext.storageContext, and approvalUrl).
203-214:⚠️ Potential issue | 🟠 MajorSecret scope is discarded, causing incorrect resolution downstream.
collectSecretNamesFromCodereturnsArray<string>(bare names), butparseSecretPlaceholdersprovidesReferencedSecret[]including scope (e.g.,{{secret:foo|scope=app}}). Discarding scope meansfindMissingCapabilityApprovalscannot pass scope toresolveSecret, potentially resolving the wrong secret or generating approval URLs for the wrong scope.Consider returning
ReferencedSecret[]and threading scope through tofindMissingCapabilityApprovals.,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/worker/src/mcp/run-codemode-registry.ts` around lines 203 - 214, collectSecretNamesFromCode currently flattens secrets to bare strings and drops scope info, which breaks downstream resolution; change collectSecretNamesFromCode to return ReferencedSecret[] (preserving scope) by combining outputs of parseSecretNamesFromMessage / parseSecretNamesFromCapabilityCalls (or the parseSecretPlaceholders variant that yields ReferencedSecret) instead of calling normalizeSecretNameList, and update any callers such as findMissingCapabilityApprovals to accept and thread ReferencedSecret objects into resolveSecret so scope is used when constructing approval URLs and resolving secrets.
318-336:⚠️ Potential issue | 🟠 MajorNormalize
storageContextand passscopetoresolveSecret.Two issues in this resolution logic:
Type mismatch:
input.storageContexthas optionalsessionId/appIdbutresolveSecretandbuildSecretCapabilityApprovalUrlexpect required-but-nullable properties (same issue as lines 110-116).Missing scope:
resolveSecretis called without ascopeparameter (compare to lines 94-98 wherescope: secret.scopeis passed). Without scope, the resolver may find a different secret than what the code actually references.Both stem from losing
ReferencedSecretinformation upstream. OncecollectSecretNamesFromCodereturnsReferencedSecret[]instead ofstring[], this function should accept and use those objects:Partial fix for type normalization
+const normalizedStorageContext = input.storageContext + ? { + sessionId: input.storageContext.sessionId ?? null, + appId: input.storageContext.appId ?? null, + } + : null const entries = await Promise.all( input.secretNames.map(async (name) => { const resolved = await resolveSecret({ env: input.env, userId: input.userId, name, - storageContext: input.storageContext, + storageContext: normalizedStorageContext, }) // ... const approvalUrl = buildSecretCapabilityApprovalUrl({ baseUrl: input.baseUrl, name, scope: resolved.scope ?? 'user', capabilityName: input.capabilityName, - storageContext: input.storageContext, + storageContext: normalizedStorageContext, }),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/worker/src/mcp/run-codemode-registry.ts` around lines 318 - 336, The loop in run-codemode-registry must treat input.secretNames as ReferencedSecret[] and normalize storageContext before calling resolvers: update the parameter type where collectSecretNamesFromCode feeds into runCodemodeRegistry so secret entries are objects with {name, scope, storageContext?}, pass the secret's scope into resolveSecret (e.g., scope: secret.scope) and ensure storageContext is converted to the required shape (provide sessionId/appId keys as null when absent) before calling both resolveSecret and buildSecretCapabilityApprovalUrl; adjust references to input.secretNames.map and the variables used inside the async callback to use the ReferencedSecret fields rather than plain string names.
155-163:⚠️ Potential issue | 🟠 MajorWrap error enrichment in try-catch to preserve best-effort behavior.
If
rewriteCapabilitySecretErroror its internalresolveSecretcalls throw,runCodemodeWithRegistrywill propagate that exception instead of returning the originalExecuteResult. Error enrichment should be best-effort:Suggested fix
const result = await executor.execute(wrapped, [provider]) if (!result.error) return result -const batchError = await rewriteCapabilitySecretError({ - error: result.error, - code: wrapped, - env, - callerContext, -}) -return batchError ? { ...result, error: batchError } : result +try { + const batchError = await rewriteCapabilitySecretError({ + error: result.error, + code: wrapped, + env, + callerContext, + }) + return batchError ? { ...result, error: batchError } : result +} catch { + return result +},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/worker/src/mcp/run-codemode-registry.ts` around lines 155 - 163, The call that enriches errors with rewriteCapabilitySecretError inside runCodemodeWithRegistry should be guarded so enrichment is best-effort: wrap the await rewriteCapabilitySecretError({...}) call in a try-catch, and if it throws simply ignore/log the enrichment failure and return the original ExecuteResult (i.e., keep returning result if enrichment fails); reference the existing symbols result, wrapped, env, callerContext, and rewriteCapabilitySecretError to locate and modify the code.
🧹 Nitpick comments (1)
packages/worker/src/app/saved-ui-hosted-html.ts (1)
944-947: Avoid double parsing the batch host-approval payload.Line 946 parses the batch once in the condition, then Line 948 triggers a second parse via
extractApprovalDetails(...). Parse once and reuse for cleaner flow.♻️ Suggested refactor
-function extractApprovalDetails(message, fallbackSecretNames = []) { +function extractApprovalDetails(message, fallbackSecretNames = [], preParsedBatch = null) { const text = typeof message === 'string' ? message : String(message ?? ''); - const batch = parseHostApprovalBatchMessage(text); + const batch = preParsedBatch ?? parseHostApprovalBatchMessage(text); if (batch) { return { message: text, @@ } catch (error) { const message = error instanceof Error ? error.message : String(error); + const batch = parseHostApprovalBatchMessage(message); if ( message.includes('not allowed for host') || - parseHostApprovalBatchMessage(message) + batch ) { - const approval = extractApprovalDetails(message, fallbackSecretNames); + const approval = extractApprovalDetails(message, fallbackSecretNames, batch); return { ok: false, kind: 'host_approval_required',🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/worker/src/app/saved-ui-hosted-html.ts` around lines 944 - 947, The code currently calls parseHostApprovalBatchMessage(message) in the if condition and then calls extractApprovalDetails(...) which reparses the same payload; update the flow to parse once into a variable (e.g., const batchPayload = parseHostApprovalBatchMessage(message)) before the if, use that variable in the conditional (check batchPayload truthiness) instead of re-parsing, and pass batchPayload into extractApprovalDetails or otherwise reuse it where extractApprovalDetails is invoked (ensure you still handle the case when parse returns falsy). This will eliminate duplicate parsing calls for parseHostApprovalBatchMessage and make extractApprovalDetails consume the already-parsed payload.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@packages/worker/src/mcp/run-codemode-registry.ts`:
- Around line 110-116: Normalize callerContext.storageContext before calling
buildSecretCapabilityApprovalUrl: create a local storageContext variable that,
if callerContext.storageContext is present, maps sessionId and appId to either
their values or null (i.e. { sessionId: storage.sessionId ?? null, appId:
storage.appId ?? null }), otherwise set storageContext to null; then pass that
normalized storageContext into buildSecretCapabilityApprovalUrl (referencing
buildSecretCapabilityApprovalUrl, callerContext.storageContext, and
approvalUrl).
- Around line 203-214: collectSecretNamesFromCode currently flattens secrets to
bare strings and drops scope info, which breaks downstream resolution; change
collectSecretNamesFromCode to return ReferencedSecret[] (preserving scope) by
combining outputs of parseSecretNamesFromMessage /
parseSecretNamesFromCapabilityCalls (or the parseSecretPlaceholders variant that
yields ReferencedSecret) instead of calling normalizeSecretNameList, and update
any callers such as findMissingCapabilityApprovals to accept and thread
ReferencedSecret objects into resolveSecret so scope is used when constructing
approval URLs and resolving secrets.
- Around line 318-336: The loop in run-codemode-registry must treat
input.secretNames as ReferencedSecret[] and normalize storageContext before
calling resolvers: update the parameter type where collectSecretNamesFromCode
feeds into runCodemodeRegistry so secret entries are objects with {name, scope,
storageContext?}, pass the secret's scope into resolveSecret (e.g., scope:
secret.scope) and ensure storageContext is converted to the required shape
(provide sessionId/appId keys as null when absent) before calling both
resolveSecret and buildSecretCapabilityApprovalUrl; adjust references to
input.secretNames.map and the variables used inside the async callback to use
the ReferencedSecret fields rather than plain string names.
- Around line 155-163: The call that enriches errors with
rewriteCapabilitySecretError inside runCodemodeWithRegistry should be guarded so
enrichment is best-effort: wrap the await rewriteCapabilitySecretError({...})
call in a try-catch, and if it throws simply ignore/log the enrichment failure
and return the original ExecuteResult (i.e., keep returning result if enrichment
fails); reference the existing symbols result, wrapped, env, callerContext, and
rewriteCapabilitySecretError to locate and modify the code.
---
Nitpick comments:
In `@packages/worker/src/app/saved-ui-hosted-html.ts`:
- Around line 944-947: The code currently calls
parseHostApprovalBatchMessage(message) in the if condition and then calls
extractApprovalDetails(...) which reparses the same payload; update the flow to
parse once into a variable (e.g., const batchPayload =
parseHostApprovalBatchMessage(message)) before the if, use that variable in the
conditional (check batchPayload truthiness) instead of re-parsing, and pass
batchPayload into extractApprovalDetails or otherwise reuse it where
extractApprovalDetails is invoked (ensure you still handle the case when parse
returns falsy). This will eliminate duplicate parsing calls for
parseHostApprovalBatchMessage and make extractApprovalDetails consume the
already-parsed payload.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a9d71d13-b50b-45ae-b215-e7d8341e81d7
📒 Files selected for processing (3)
packages/worker/client/mcp-apps/generated-ui-shell.tspackages/worker/src/app/saved-ui-hosted-html.tspackages/worker/src/mcp/run-codemode-registry.ts
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Unhandled throw in rewrite turns returned error into exception
- Wrapped the rewrite step in a try/catch to preserve the original ExecuteResult when secret rewrite logic throws.
Preview (81badff46d)
diff --git a/packages/worker/client/mcp-apps/generated-ui-shell.ts b/packages/worker/client/mcp-apps/generated-ui-shell.ts
--- a/packages/worker/client/mcp-apps/generated-ui-shell.ts
+++ b/packages/worker/client/mcp-apps/generated-ui-shell.ts
@@ -916,10 +916,53 @@
}
return collected;
}
+function parseHostApprovalBatchMessage(message) {
+ if (typeof message !== 'string') return null;
+ const prefix = 'Secrets require host approval:';
+ if (!message.startsWith(prefix)) return null;
+ const raw = message.slice(prefix.length).trim();
+ if (!raw) return null;
+ try {
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return null;
+ const entries = [];
+ for (const entry of parsed) {
+ if (!entry || typeof entry !== 'object') continue;
+ if (
+ typeof entry.secretName !== 'string' ||
+ typeof entry.host !== 'string' ||
+ typeof entry.approvalUrl !== 'string'
+ ) {
+ continue;
+ }
+ entries.push({
+ secretName: entry.secretName,
+ host: entry.host,
+ approvalUrl: entry.approvalUrl,
+ });
+ }
+ return entries.length > 0 ? entries : null;
+ } catch {
+ return null;
+ }
+}
function extractApprovalDetails(message, fallbackSecretNames = []) {
const text = typeof message === 'string' ? message : String(message ?? '');
+ const batch = parseHostApprovalBatchMessage(text);
+ if (batch) {
+ return {
+ message: text,
+ approvalUrl: batch[0]?.approvalUrl ?? null,
+ host: batch[0]?.host ?? null,
+ secretNames: normalizeSecretNameList(
+ batch.map((entry) => entry.secretName),
+ ),
+ };
+ }
const secretNames = normalizeSecretNameList([
- ...Array.from(text.matchAll(/Secret "([^"]+)"/g)).map((match) => match[1]).filter(Boolean),
+ ...Array.from(text.matchAll(/Secret "([^"]+)"/g))
+ .map((match) => match[1])
+ .filter(Boolean),
...fallbackSecretNames,
]);
const hostMatch = text.match(/host "([^"]+)"/);
@@ -1543,7 +1586,10 @@
return normalizeFetchWithSecretsResult(result);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
- if (message.includes('not allowed for host')) {
+ if (
+ message.includes('not allowed for host') ||
+ parseHostApprovalBatchMessage(message)
+ ) {
const approval = extractApprovalDetails(message, fallbackSecretNames);
return {
ok: false,
diff --git a/packages/worker/client/routes/account-secrets.tsx b/packages/worker/client/routes/account-secrets.tsx
--- a/packages/worker/client/routes/account-secrets.tsx
+++ b/packages/worker/client/routes/account-secrets.tsx
@@ -222,6 +222,28 @@
return `${pathname}${search}`
}
+function readCapabilityPrefill(href: string) {
+ const url = new URL(href, 'http://localhost')
+ const value = url.searchParams.get('capability')
+ return value?.trim() ? value.trim() : null
+}
+
+function applyCapabilityPrefill(state: EditorState, capability: string | null) {
+ if (!capability) return state
+ if (state.allowedCapabilities.some((entry) => entry.trim() === capability)) {
+ return state
+ }
+ const nextAllowedCapabilities =
+ state.allowedCapabilities.length === 1 &&
+ state.allowedCapabilities[0]?.trim() === ''
+ ? [capability]
+ : [...state.allowedCapabilities, capability]
+ return {
+ ...state,
+ allowedCapabilities: nextAllowedCapabilities,
+ }
+}
+
function buildSecretHref(secret: {
name: string
scope: SecretScope
@@ -258,7 +280,8 @@
const url = new URL(href, 'http://localhost')
const request = url.searchParams.get('request') ?? ''
const requestedHost = url.searchParams.get('allowed-host') ?? ''
- return `${url.pathname}?request=${request}&allowed-host=${requestedHost}`
+ const requestedCapability = url.searchParams.get('capability') ?? ''
+ return `${url.pathname}?request=${request}&allowed-host=${requestedHost}&capability=${requestedCapability}`
}
function readFilterState(
@@ -359,15 +382,25 @@
function syncEditorState(selection: SelectionState) {
deleteSecretCheck.reset()
showSecretValue = false
+ const capabilityPrefill = readCapabilityPrefill(getCurrentHref())
if (selection.isCreating) {
- editorState = createEmptyEditorState(apps)
+ editorState = applyCapabilityPrefill(
+ createEmptyEditorState(apps),
+ capabilityPrefill,
+ )
return
}
if (selectedSecret) {
- editorState = createEditorStateFromSecret(selectedSecret)
+ editorState = applyCapabilityPrefill(
+ createEditorStateFromSecret(selectedSecret),
+ capabilityPrefill,
+ )
return
}
- editorState = createEmptyEditorState(apps)
+ editorState = applyCapabilityPrefill(
+ createEmptyEditorState(apps),
+ capabilityPrefill,
+ )
}
function applyPayload(
@@ -471,6 +504,7 @@
const nextUrl = new URL(nextHref, window.location.href)
nextUrl.searchParams.delete('request')
nextUrl.searchParams.delete('allowed-host')
+ nextUrl.searchParams.delete('capability')
navigate(`${nextUrl.pathname}${nextUrl.search}`)
lastLoadedDataKey = getDataRefreshKey(nextUrl.toString())
}
@@ -761,6 +795,12 @@
<code>{approval.name}</code> from the{' '}
{getScopeLabel(approval.scope)} scope.
</p>
+ {approval.requestedCapability ? (
+ <p css={{ margin: 0, color: colors.textMuted }}>
+ Requested capability:{' '}
+ <code>{approval.requestedCapability}</code>
+ </p>
+ ) : null}
<p css={{ margin: 0, color: colors.textMuted }}>
Current allowed hosts:{' '}
{approval.currentAllowedHosts.length > 0
diff --git a/packages/worker/src/app/handlers/account-secrets.ts b/packages/worker/src/app/handlers/account-secrets.ts
--- a/packages/worker/src/app/handlers/account-secrets.ts
+++ b/packages/worker/src/app/handlers/account-secrets.ts
@@ -56,6 +56,7 @@
name: string
scope: SecretScope
requestedHost: string
+ requestedCapability: string | null
currentAllowedHosts: Array<string>
}
@@ -164,6 +165,7 @@
const url = new URL(input.request.url)
const approvalToken = url.searchParams.get('request')
const requestedApprovalHost = readApprovalHost(url)
+ const requestedCapability = readRequestedCapability(url)
const savedApps =
input.savedApps ??
@@ -191,6 +193,7 @@
userId: input.user.mcpUser.userId,
token: approvalToken,
requestedHost: requestedApprovalHost,
+ requestedCapability,
}).catch(() => null)
: null
@@ -272,6 +275,7 @@
userId: string
token: string
requestedHost: string | null
+ requestedCapability: string | null
}) {
const approval = await verifySecretHostApprovalToken(input.env, input.token)
if (approval.userId !== input.userId) {
@@ -300,6 +304,7 @@
name: approval.name,
scope: approval.scope,
requestedHost: approval.requestedHost,
+ requestedCapability: input.requestedCapability,
currentAllowedHosts: secret.allowedHosts,
} satisfies SecretApprovalView
}
@@ -632,6 +637,11 @@
return value?.trim() ? value.trim() : null
}
+function readRequestedCapability(url: URL) {
+ const value = url.searchParams.get('capability')
+ return value?.trim() ? value.trim() : null
+}
+
function readString(body: object, key: string) {
const value = (body as Record<string, unknown>)[key]
return typeof value === 'string' && value.trim() ? value.trim() : null
diff --git a/packages/worker/src/app/saved-ui-hosted-html.ts b/packages/worker/src/app/saved-ui-hosted-html.ts
--- a/packages/worker/src/app/saved-ui-hosted-html.ts
+++ b/packages/worker/src/app/saved-ui-hosted-html.ts
@@ -184,10 +184,53 @@
}
return collected;
}
+function parseHostApprovalBatchMessage(message) {
+ if (typeof message !== 'string') return null;
+ const prefix = 'Secrets require host approval:';
+ if (!message.startsWith(prefix)) return null;
+ const raw = message.slice(prefix.length).trim();
+ if (!raw) return null;
+ try {
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return null;
+ const entries = [];
+ for (const entry of parsed) {
+ if (!entry || typeof entry !== 'object') continue;
+ if (
+ typeof entry.secretName !== 'string' ||
+ typeof entry.host !== 'string' ||
+ typeof entry.approvalUrl !== 'string'
+ ) {
+ continue;
+ }
+ entries.push({
+ secretName: entry.secretName,
+ host: entry.host,
+ approvalUrl: entry.approvalUrl,
+ });
+ }
+ return entries.length > 0 ? entries : null;
+ } catch {
+ return null;
+ }
+}
function extractApprovalDetails(message, fallbackSecretNames = []) {
const text = typeof message === 'string' ? message : String(message ?? '');
+ const batch = parseHostApprovalBatchMessage(text);
+ if (batch) {
+ return {
+ message: text,
+ approvalUrl: batch[0]?.approvalUrl ?? null,
+ host: batch[0]?.host ?? null,
+ secretNames: normalizeSecretNameList(
+ batch.map((entry) => entry.secretName),
+ ),
+ };
+ }
const secretNames = normalizeSecretNameList([
- ...Array.from(text.matchAll(/Secret "([^"]+)"/g)).map((match) => match[1]).filter(Boolean),
+ ...Array.from(text.matchAll(/Secret "([^"]+)"/g))
+ .map((match) => match[1])
+ .filter(Boolean),
...fallbackSecretNames,
]);
const hostMatch = text.match(/host "([^"]+)"/);
@@ -898,7 +941,10 @@
return normalizeFetchWithSecretsResult(result);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
- if (message.includes('not allowed for host')) {
+ if (
+ message.includes('not allowed for host') ||
+ parseHostApprovalBatchMessage(message)
+ ) {
const approval = extractApprovalDetails(message, fallbackSecretNames);
return {
ok: false,
diff --git a/packages/worker/src/mcp/executor.node.test.ts b/packages/worker/src/mcp/executor.node.test.ts
--- a/packages/worker/src/mcp/executor.node.test.ts
+++ b/packages/worker/src/mcp/executor.node.test.ts
@@ -1,6 +1,8 @@
import { expect, test } from 'vitest'
import {
+ createCapabilitySecretAccessDeniedBatchMessage,
createCapabilitySecretAccessDeniedMessage,
+ createHostSecretAccessDeniedBatchMessage,
createMissingSecretMessage,
} from '#mcp/secrets/errors.ts'
import { formatExecutionOutput, getExecutionErrorDetails } from './executor.ts'
@@ -10,17 +12,20 @@
createCapabilitySecretAccessDeniedMessage(
'cloudflareToken',
'cloudflare_rest',
+ 'https://example.com/account/secrets/user/cloudflareToken?capability=cloudflare_rest',
),
)
expect(getExecutionErrorDetails(error)).toEqual({
kind: 'secret_capability_access_required',
message:
- 'Secret "cloudflareToken" is not allowed for capability "cloudflare_rest". If this capability should be able to use the secret, ask the user whether to add "cloudflare_rest" to the secret\'s allowed capabilities in the account secrets UI, then retry after they approve that policy change.',
+ 'Secret "cloudflareToken" is not allowed for capability "cloudflare_rest". If this capability should be able to use the secret, ask the user whether to add "cloudflare_rest" to the secret\'s allowed capabilities in the account secrets UI, then retry after they approve that policy change. Approval link: https://example.com/account/secrets/user/cloudflareToken?capability=cloudflare_rest',
nextStep:
"Ask the user whether this capability should be allowed to use the secret. If they approve, help them add this capability name to the secret's allowed capabilities in the account secrets UI, then retry.",
secretNames: ['cloudflareToken'],
capabilityName: 'cloudflare_rest',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/cloudflareToken?capability=cloudflare_rest',
suggestedAction: {
type: 'edit_secret_policy',
policyField: 'allowed_capabilities',
@@ -34,6 +39,7 @@
createCapabilitySecretAccessDeniedMessage(
'cloudflareToken',
'cloudflare_rest',
+ 'https://example.com/account/secrets/user/cloudflareToken?capability=cloudflare_rest',
),
),
} as const
@@ -52,3 +58,92 @@
'Open a generated UI so the user can provide and save this secret',
)
})
+
+test('getExecutionErrorDetails returns batch capability approvals', () => {
+ const error = new Error(
+ createCapabilitySecretAccessDeniedBatchMessage([
+ {
+ secretName: 'lutronUsername',
+ capabilityName: 'home_lutron_set_credentials',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/lutronUsername?capability=home_lutron_set_credentials',
+ },
+ {
+ secretName: 'lutronPassword',
+ capabilityName: 'home_lutron_set_credentials',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/lutronPassword?capability=home_lutron_set_credentials',
+ },
+ ]),
+ )
+
+ expect(getExecutionErrorDetails(error)).toEqual({
+ kind: 'secret_capability_access_required_batch',
+ message:
+ 'Secrets require capability approval: [{"secretName":"lutronUsername","capabilityName":"home_lutron_set_credentials","approvalUrl":"https://example.com/account/secrets/user/lutronUsername?capability=home_lutron_set_credentials"},{"secretName":"lutronPassword","capabilityName":"home_lutron_set_credentials","approvalUrl":"https://example.com/account/secrets/user/lutronPassword?capability=home_lutron_set_credentials"}]',
+ nextStep:
+ 'Ask the user whether they want to approve these capabilities for the listed secrets in the account secrets UI, then retry after approval.',
+ missingApprovals: [
+ {
+ secretName: 'lutronUsername',
+ capabilityName: 'home_lutron_set_credentials',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/lutronUsername?capability=home_lutron_set_credentials',
+ },
+ {
+ secretName: 'lutronPassword',
+ capabilityName: 'home_lutron_set_credentials',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/lutronPassword?capability=home_lutron_set_credentials',
+ },
+ ],
+ suggestedAction: {
+ type: 'edit_secret_policy',
+ policyField: 'allowed_capabilities',
+ },
+ })
+})
+
+test('getExecutionErrorDetails returns batch host approvals', () => {
+ const error = new Error(
+ createHostSecretAccessDeniedBatchMessage([
+ {
+ secretName: 'cloudflareToken',
+ host: 'api.cloudflare.com',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/cloudflareToken?allowed-host=api.cloudflare.com&request=token',
+ },
+ {
+ secretName: 'slackToken',
+ host: 'slack.com',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/slackToken?allowed-host=slack.com&request=token',
+ },
+ ]),
+ )
+
+ expect(getExecutionErrorDetails(error)).toEqual({
+ kind: 'host_approval_required_batch',
+ message:
+ 'Secrets require host approval: [{"secretName":"cloudflareToken","host":"api.cloudflare.com","approvalUrl":"https://example.com/account/secrets/user/cloudflareToken?allowed-host=api.cloudflare.com&request=token"},{"secretName":"slackToken","host":"slack.com","approvalUrl":"https://example.com/account/secrets/user/slackToken?allowed-host=slack.com&request=token"}]',
+ nextStep:
+ 'Ask the user whether they want to approve these hosts for the listed secrets in the account web UI, then retry after approval.',
+ missingApprovals: [
+ {
+ secretName: 'cloudflareToken',
+ host: 'api.cloudflare.com',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/cloudflareToken?allowed-host=api.cloudflare.com&request=token',
+ },
+ {
+ secretName: 'slackToken',
+ host: 'slack.com',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/slackToken?allowed-host=slack.com&request=token',
+ },
+ ],
+ suggestedAction: {
+ type: 'approve_secret_host',
+ },
+ })
+})
diff --git a/packages/worker/src/mcp/executor.ts b/packages/worker/src/mcp/executor.ts
--- a/packages/worker/src/mcp/executor.ts
+++ b/packages/worker/src/mcp/executor.ts
@@ -4,7 +4,9 @@
import { type FetchGatewayProps } from '#mcp/fetch-gateway.ts'
import {
isSecretAuthRequiredMessage,
+ parseCapabilityAccessRequiredBatchMessage,
parseCapabilityAccessRequiredMessage,
+ parseHostApprovalRequiredBatchMessage,
parseHostApprovalRequiredMessage,
parseMissingSecretMessage,
} from '#mcp/secrets/errors.ts'
@@ -46,17 +48,45 @@
}
}
| {
+ kind: 'host_approval_required_batch'
+ message: string
+ nextStep: string
+ missingApprovals: Array<{
+ secretName: string
+ host: string
+ approvalUrl: string
+ }>
+ suggestedAction: {
+ type: 'approve_secret_host'
+ }
+ }
+ | {
kind: 'secret_capability_access_required'
message: string
nextStep: string
secretNames: Array<string>
capabilityName: string
+ approvalUrl: string | null
suggestedAction: {
type: 'edit_secret_policy'
policyField: 'allowed_capabilities'
}
}
| {
+ kind: 'secret_capability_access_required_batch'
+ message: string
+ nextStep: string
+ missingApprovals: Array<{
+ secretName: string
+ capabilityName: string
+ approvalUrl: string
+ }>
+ suggestedAction: {
+ type: 'edit_secret_policy'
+ policyField: 'allowed_capabilities'
+ }
+ }
+ | {
kind: 'secret_required'
message: string
nextStep: string
@@ -96,6 +126,35 @@
}
}
+ const hostApprovalBatch = parseHostApprovalRequiredBatchMessage(message)
+ if (hostApprovalBatch) {
+ return {
+ kind: 'host_approval_required_batch',
+ message,
+ nextStep:
+ 'Ask the user whether they want to approve these hosts for the listed secrets in the account web UI, then retry after approval.',
+ missingApprovals: hostApprovalBatch,
+ suggestedAction: {
+ type: 'approve_secret_host',
+ },
+ }
+ }
+
+ const capabilityAccessBatch = parseCapabilityAccessRequiredBatchMessage(message)
+ if (capabilityAccessBatch) {
+ return {
+ kind: 'secret_capability_access_required_batch',
+ message,
+ nextStep:
+ 'Ask the user whether they want to approve these capabilities for the listed secrets in the account secrets UI, then retry after approval.',
+ missingApprovals: capabilityAccessBatch,
+ suggestedAction: {
+ type: 'edit_secret_policy',
+ policyField: 'allowed_capabilities',
+ },
+ }
+ }
+
const capabilityAccessDetails = parseCapabilityAccessRequiredMessage(message)
if (capabilityAccessDetails) {
return {
@@ -105,6 +164,7 @@
"Ask the user whether this capability should be allowed to use the secret. If they approve, help them add this capability name to the secret's allowed capabilities in the account secrets UI, then retry.",
secretNames: [capabilityAccessDetails.secretName],
capabilityName: capabilityAccessDetails.capabilityName,
+ approvalUrl: extractFirstUrl(message),
suggestedAction: {
type: 'edit_secret_policy',
policyField: 'allowed_capabilities',
diff --git a/packages/worker/src/mcp/fetch-gateway.ts b/packages/worker/src/mcp/fetch-gateway.ts
--- a/packages/worker/src/mcp/fetch-gateway.ts
+++ b/packages/worker/src/mcp/fetch-gateway.ts
@@ -10,6 +10,7 @@
type ReferencedSecret,
} from '#mcp/secrets/placeholders.ts'
import {
+ createHostSecretAccessDeniedBatchMessage,
createMissingSecretMessage,
fetchSecretAuthRequiredMessage,
} from '#mcp/secrets/errors.ts'
@@ -85,27 +86,15 @@
)
}
const normalizedHost = normalizeHost(requestedHost)
- for (const { referenced, resolved } of resolvedSecrets) {
- const allowedForHost = resolved.allowedHosts.includes(normalizedHost)
- if (allowedForHost) continue
- const approvalToken = await createSecretHostApprovalToken(input.env, {
- userId: input.props.userId!,
- name: referenced.name,
- scope: resolved.scope ?? referenced.scope ?? 'user',
- requestedHost,
- storageContext: input.props.storageContext,
- })
- const approvalUrl = buildSecretHostApprovalUrl({
- baseUrl: input.props.baseUrl,
- token: approvalToken,
- name: referenced.name,
- scope: resolved.scope ?? referenced.scope ?? 'user',
- requestedHost,
- storageContext: input.props.storageContext,
- })
- throw new Error(
- `Secret "${referenced.name}" is not allowed for host "${requestedHost}". If this request is expected, ask the user whether this host should be added to the secret's allowed hosts: ${approvalUrl}`,
- )
+ const missingApprovals = await collectHostApprovalEntries({
+ env: input.env,
+ props: input.props,
+ requestedHost,
+ normalizedHost,
+ resolvedSecrets,
+ })
+ if (missingApprovals.length > 0) {
+ throw new Error(createHostSecretAccessDeniedBatchMessage(missingApprovals))
}
}
for (const [key, value] of Array.from(headers.entries())) {
@@ -133,6 +122,45 @@
})
}
+async function collectHostApprovalEntries(input: {
+ env: Pick<Env, 'COOKIE_SECRET'>
+ props: FetchGatewayProps
+ requestedHost: string
+ normalizedHost: string
+ resolvedSecrets: Array<{
+ referenced: ReferencedSecret
+ resolved: ResolvedSecret
+ }>
+}) {
+ const entries = await Promise.all(
+ input.resolvedSecrets.map(async ({ referenced, resolved }) => {
+ const allowedForHost = resolved.allowedHosts.includes(input.normalizedHost)
+ if (allowedForHost) return null
+ const approvalToken = await createSecretHostApprovalToken(input.env, {
+ userId: input.props.userId!,
+ name: referenced.name,
+ scope: resolved.scope ?? referenced.scope ?? 'user',
+ requestedHost: input.requestedHost,
+ storageContext: input.props.storageContext,
+ })
+ const approvalUrl = buildSecretHostApprovalUrl({
+ baseUrl: input.props.baseUrl,
+ token: approvalToken,
+ name: referenced.name,
+ scope: resolved.scope ?? referenced.scope ?? 'user',
+ requestedHost: input.requestedHost,
+ storageContext: input.props.storageContext,
+ })
+ return {
+ secretName: referenced.name,
+ host: input.requestedHost,
+ approvalUrl,
+ }
+ }),
+ )
+ return entries.filter((entry): entry is NonNullable<typeof entry> => entry != null)
+}
+
function ensureFetchAllowed(props: FetchGatewayProps) {
if (!props.userId) {
throw new Error(fetchSecretAuthRequiredMessage)
diff --git a/packages/worker/src/mcp/run-codemode-registry.node.test.ts b/packages/worker/src/mcp/run-codemode-registry.node.test.ts
--- a/packages/worker/src/mcp/run-codemode-registry.node.test.ts
+++ b/packages/worker/src/mcp/run-codemode-registry.node.test.ts
@@ -167,7 +167,7 @@
username: '{{secret:lutronUsername|scope=user}}',
}),
).rejects.toThrow(
- 'Secret "lutronUsername" is not allowed for capability "home_lutron_set_credentials"',
+ 'Secret "lutronUsername" is not allowed for capability "home_lutron_set_credentials". If this capability should be able to use the secret, ask the user whether to add "home_lutron_set_credentials" to the secret\'s allowed capabilities in the account secrets UI, then retry after they approve that policy change. Approval link: https://heykody.dev/account/secrets/user/lutronUsername?capability=home_lutron_set_credentials',
)
expect(resolveSecretSpy).toHaveBeenCalledWith(
expect.objectContaining({
diff --git a/packages/worker/src/mcp/run-codemode-registry.ts b/packages/worker/src/mcp/run-codemode-registry.ts
--- a/packages/worker/src/mcp/run-codemode-registry.ts
+++ b/packages/worker/src/mcp/run-codemode-registry.ts
@@ -1,3 +1,4 @@
+import * as acorn from 'acorn'
import {
resolveProvider,
type ResolvedProvider,
@@ -9,10 +10,15 @@
import {
capabilityInputSecretAuthRequiredMessage,
createCapabilitySecretAccessDeniedMessage,
+ createCapabilitySecretAccessDeniedBatchMessage,
createMissingSecretMessage,
} from '#mcp/secrets/errors.ts'
+import { buildSecretCapabilityApprovalUrl } from '#mcp/secrets/capability-approval-url.ts'
import { resolveSecret } from '#mcp/secrets/service.ts'
-import { type ReferencedSecret } from '#mcp/secrets/placeholders.ts'
+import {
+ parseSecretPlaceholders,
+ type ReferencedSecret,
+} from '#mcp/secrets/placeholders.ts'
import { buildParameterizedSkillCode } from '#mcp/skills/skill-parameters.ts'
import { getCapabilityRegistryForContext } from '#mcp/capabilities/registry.ts'
@@ -101,8 +107,19 @@
throw new Error(createMissingSecretMessage(secret.name))
}
if (!resolved.allowedCapabilities.includes(capabilityName)) {
+ const approvalUrl = buildSecretCapabilityApprovalUrl({
+ baseUrl: callerContext.baseUrl,
+ name: secret.name,
+ scope: resolved.scope ?? secret.scope ?? 'user',
+ capabilityName,
+ storageContext: callerContext.storageContext ?? null,
+ })
throw new Error(
- createCapabilitySecretAccessDeniedMessage(secret.name, capabilityName),
+ createCapabilitySecretAccessDeniedMessage(
+ secret.name,
+ capabilityName,
+ approvalUrl,
+ ),
)
}
return resolved.value
@@ -134,5 +151,202 @@
params !== undefined
? await buildParameterizedSkillCode(code, params)
: code
- return executor.execute(wrapped, [provider])
+ const result = await executor.execute(wrapped, [provider])
+ if (!result.error) return result
+ let batchError: Error | null = null
+ try {
+ batchError = await rewriteCapabilitySecretError({
+ error: result.error,
+ code: wrapped,
+ env,
+ callerContext,
+ })
+ } catch {
+ return result
+ }
+ return batchError ? { ...result, error: batchError } : result
}
+
+async function rewriteCapabilitySecretError(input: {
+ error: unknown
+ code: string
+ env: Env
+ callerContext: McpCallerContext
+}) {
+ const message =
+ input.error instanceof Error ? input.error.message : String(input.error)
+ const capabilityMatch = message.match(
+ /^Secret "([^"]+)" is not allowed for capability "([^"]+)"/,
+ )
+ if (!capabilityMatch?.[1] || !capabilityMatch?.[2]) return null
+ const capabilityName = capabilityMatch[2]
+ const userId = input.callerContext.user?.userId ?? null
+ if (!userId)
+ return input.error instanceof Error ? input.error : new Error(message)
+ const secretNames = collectSecretNamesFromCode(
+ input.error,
+ input.code,
+ capabilityName,
+ )
+ if (secretNames.length === 0) {
+ return input.error instanceof Error ? input.error : new Error(message)
+ }
+ const missing = await findMissingCapabilityApprovals({
+ env: input.env,
+ userId,
+ secretNames,
+ capabilityName,
+ storageContext: input.callerContext.storageContext ?? null,
+ baseUrl: input.callerContext.baseUrl,
+ })
+ if (missing.length === 0) {
+ return input.error instanceof Error ? input.error : new Error(message)
+ }
+ return new Error(createCapabilitySecretAccessDeniedBatchMessage(missing))
+}
+
+function collectSecretNamesFromCode(
+ error: unknown,
+ code: string | null,
+ capabilityName: string | null,
+) {
+ const fromError =
+ error instanceof Error ? parseSecretNamesFromMessage(error.message) : []
+ const fromCode = code
+ ? parseSecretNamesFromCapabilityCalls(code, capabilityName)
+ : []
+ return normalizeSecretNameList([...fromError, ...fromCode])
+}
+
+function parseSecretNamesFromCapabilityCalls(
+ code: string,
+ capabilityName: string | null,
+) {
+ if (!capabilityName) {
+ return parseSecretPlaceholders(code).map((secret) => secret.name)
+ }
+ try {
+ const ast = acorn.parse(code, {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ }) as acorn.Node
+ const collected = new Set<string>()
+ const walk = (node: unknown) => {
+ if (!node || typeof node !== 'object') return
+ const typed = node as Record<string, unknown>
+ if (typed.type === 'CallExpression') {
+ const callee = typed.callee as unknown
+ const matchedName = getCodemodeCapabilityName(callee)
+ if (matchedName === capabilityName) {
+ const args = (typed.arguments ?? []) as Array<unknown>
+ for (const arg of args) {
+ const start = (arg as { start?: number }).start
+ const end = (arg as { end?: number }).end
+ if (typeof start !== 'number' || typeof end !== 'number') continue
+ const snippet = code.slice(start, end)
+ for (const secret of parseSecretPlaceholders(snippet)) {
+ collected.add(secret.name)
+ }
+ }
+ }
+ }
+ for (const value of Object.values(typed)) {
+ if (Array.isArray(value)) {
+ for (const entry of value) walk(entry)
+ continue
+ }
+ if (value && typeof value === 'object') walk(value)
+ }
+ }
+ walk(ast)
+ return Array.from(collected)
+ } catch {
... diff truncated: showing 800 of 1136 lines81badff to
973b561
Compare
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
973b561 to
9a2d4ee
Compare
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

Summary
Testing
npm run test -- packages/worker/src/mcp/capabilities/capability-search.workers.test.ts packages/worker/src/mcp/run-codemode-registry.node.test.ts packages/worker/src/mcp/executor.node.test.ts packages/worker/src/mcp/secrets/errors.node.test.tsnpm run test:mcp -- packages/worker/src/mcp/mcp-server.mcp-e2e.test.tsSummary by CodeRabbit