Repository navigation
Conversation
…commit 20-4a) secret.rs: Secret has no Serialize and no Display, Debug prints <redacted>, comparisons are constant time, and a compile-time check fails if Serialize or Display is ever added. The admin credential is Option<Secret> everywhere it is held: config, AppState, MCP, Session::open, the CLI and the launcher. Ad4mConfig serialises it as "<redacted>" (get_json, which has no callers, wrote it in plain text). No behaviour change.
…it 20-4b) Provider API keys are Secrets. ai.models and ai.getDefaultModel return a view with has_api_key to every caller; #1167's per-session stripping goes (the operator and apps still received the key). Updating a model without apiKey now keeps the stored key (before, apiKey was required and "" cleared it; "" still clears it). An update that moves baseUrl while a key is stored must resend the key (400 otherwise), so a caller cannot send the stored key to a new endpoint. Model Debug output redacts the key (update_model logged it). Launcher: the model dialog no longer pre-fills the key. TS types regenerated.
…ential (Plan 20 commit 20-4c) webhook_auth is a Secret. runtime.notifications, the install-request exception and notification-triggered carry a view with hasWebhookAuth. A firing notification still sends the credential as the webhook's Bearer header; the POST body no longer repeats it. Core's Notification class gets hasWebhookAuth (and ownerDid, already on the wire). TS types regenerated.
…n 20 commit 20-4d) An approved app's record stores token_id and token_expires_at instead of the raw JWT. issue_jwt returns the JWT as a Secret with its id and expiry; agent.generateJwt and the MCP tool hand it out once. On load, an apps_data.json that still holds raw tokens is migrated: the id and expiry are read without the wallet, the token is dropped and the file rewritten (a token that does not parse is dropped with a warning; it opens no session anyway). revoke_app given a JWT matches on its id. The wire keeps token: "" (rust-client defaults it).
…Plan 20 commit 20-4e)
PendingApprovals holds one record per request id: the auth, the code as a
Secret once the operator approves, the wrong-code count and an expiry. It
replaces the {id}-{code} keys and the separate attempt counter. A request
expires 10 minutes after it is made, restarted at approval; every request,
approval and redemption deletes expired records. An expired request is
refused ("This request expired; request access again"). permitCapability
on an unknown or expired id now errors (before it always succeeded); a
second approval replaces the code; codes sent before approval count no
attempt. Pending requests are capped at 1024: a flood evicts the
unapproved request nearest its expiry first. A token that cannot be
issued (locked wallet) puts the request back, so the app retries with the
same code.
…ommit 20-4f) secret_leak_tests seeds every secret the executor holds (the admin credential, an app token, a user's password and login token, webhook credentials on an operator and a user notification, a provider API key) and calls 62 read methods as the operator, an app, a user and an anonymous caller with event streams open. It fails if a seeded secret (or a token's signature part) reaches a reply, an error message or an event, and asserts the seeded records do appear so it cannot pass while checking nothing. runtime.exportData is excluded by design (operator backup). Mutation-checked: a notification view carrying the webhook credential fails it. rust-executor/AGENTS.md documents app approval and the test.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
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 |
If apps_data.json cannot be written, insert_app undoes its in-memory insert, and generate_capability_token restores the pending approval, as for a locked wallet: the app retries with the same code, and no record exists for a token nobody received. Before, the code was used up and the registry kept the record. (review)
…when none is sent Clients see only hasWebhookAuth, so a stored notification sent back got 400 and "" cleared the credential. webhookAuth is optional on update: absent keeps it, "" clears it, a value replaces it. Moving webhookUrl while a credential is stored needs it sent again (400 WebhookAuthNotResent), as for AI model keys; same_endpoint is shared with kept_api_key. TS: RuntimeUpdateNotificationParams.webhookAuth optional, core NotificationUpdate. (review)
data-bot-coasys
left a comment
There was a problem hiding this comment.
Approving at d40579d. What I checked in a worktree at this head:
Secret: noSerialize, noDisplay,Debugredacts, and theassert_not_impl!trick fails the build if either trait appears. The three doors are the only way out, and everyexpose()/expose_once()call site outside tests is one of: a constant-time compare, the database row (db.rs), the provider or webhookAuthorizationheader, theagent.generateJwtreply, orNotification's ordering key. Nothing writes one to a log or a result.kept_api_key/webhook_auth_after_update:same_endpointcompares parsed URLs, so scheme, host, port and path all count; an https→http move or a port change without the key is a 400, not a kept key sent in the clear.refuse_cleartext_credentialskipping a missing key is fine because the stored key only survives for the URL it was checked for, andai_servicechecksis_transport_safeagain at send time.PendingApprovals: unknown id and wrong code give the same message, no attempt is counted before approval, the restore after a locked wallet or an unsaved registry puts the same code back, and the eviction prefers unapproved requests nearest their expiry. Read, no issue.apps_data.jsonmigration:StoredApp::Currentfirst, raw-token records fall through, a token that does not decode is dropped and that token cannot open a session either (Session::opendecodes the same claims).- Tests run (release,
--test-threads=1):secret_leak_tests,pending,apps_map,secret,model_key_tests,notification_ws_tests,app_token_tests, 52 passed. Mutation of my own, in another direction than yours:Model::viewputs the provider key intoname. The leak test fails onai.getDefaultModel as the operator carries the provider API key, and the twomodel_key_testsfail with it. So the leak test reads the whole reply, not just the field that used to leak.
Two findings I had from the plan document do not hold against the code and I am not posting them: the module docs say exactly what the type guarantees (a Secret inside a struct fails to compile or prints redacted; the value leaves only through a named door), and the log-line gap is stated, not hidden. Nothing to change.
#1399 Merge of feat/p20-event-audience (reworked PR 1–3: one agent model, grants store, two event audiences). The grants store supersedes PR 4's apps registry and its token-id migration (apps_map.rs, revoked.rs removed). Secret stays everywhere PR 4 put it (admin credential, AI provider keys, webhook credentials, capability codes); the secret-leak test runs on grants. Pending approvals are the requested state of a grant, in memory only (anonymous callers create them; nothing reaches disk): 5 wrong codes, 1024 cap evicting unapproved first, a six-digit code as a Secret, 10 minutes for the main agent, 24 hours for a managed agent. Redeem checks the code and mints the grant in one step under the lock; a failed mint leaves the request approved and redeemable. restore() and the two-store rollback go. #1399, executor side: - agent.requestCapability takes an optional `agent` (a managed user's email or DID); the approval event goes to that agent. - The owner of the target agent approves: the operator for the main agent, the user for their own agent (users hold AGENT_PERMIT on their own agent). Others get 404. The approver may grant a subset. - A grant never exceeds the target agent's own capabilities. - MCP request_capability takes `agent`; its auto-permit follows the same owner rule (a user token can no longer auto-permit main-agent requests). - Dev-mode auto_permit_cap_requests approves main-agent requests only. Pre-approved apps were built and removed before commit: matching on the app's self-declared name and domain let any caller obtain a token for any agent without a password or approval. Pre-approval belongs with the #1399 surfaces, where the user is signed in.
…t an agent DID A capability request's target is an AgentContext keyed by the agent's DID (D16): the `agent` param resolves an email to a DID at the edge (unknown emails answer as before, revealing nothing) or checks a DID against users; approval events, apps-changed and the owner check compare DIDs. AuthInfo.userEmail stays only as a display hint for approvers.
…-node sync timed out amid holochain 'database is locked'; same code passed on #1393 and locally)
… cannot end its user's sessions Review of #1394 (Data). Both red at e0b4d71: - an_approver_grants_no_more_than_it_holds: an app that user A approved with [AGENT_READ, AGENT_PERMIT] approves a new request on A with `*` and redeems a grant with all of A's capabilities. An app on the main agent holding only AGENT_PERMIT approves `*` the same way. - a_read_only_app_cannot_end_its_users_sessions: an app A approved with AGENT_READ only calls user.revokeAllSessions and revokes every login and app of A (Ok(true)); agent.revokeToken / removeApp need AGENT_UPDATE.
commit: |
lal-bot-coasys
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES — current source re-review at 0c55e4d.
[P2] Enforce per-target pending quota when all entries are approved (pending.rs:189-193). At64 requests, the branch evicts an unapproved request if present but otherwise falls through. While the global store has room, the65th request is inserted; a target able to approve its own requests can repeat until occupying the1024-entry store and blocking other targets. Refuse admission when the per-target quota has no evictable request, preserving the intended no-agent response policy. Test64 approved entries with an otherwise nonfull store, refusal of the65th and admission for another target. Existing all-approved refusal test starts globally full, so misses this branch. Prior Secret/redaction and target-bound approval paths otherwise retain their protections.
Independent full current own-base review plus parent source QA. No local builds/tests/runtime reproduction or cryptographic exploit probe; regression sensitivity is source analysis. No code changes or merge.
|
@HexaField Re-reviewed 0c55e4d: REQUEST_CHANGES. Per-target pending-request quota can be exceeded when its entries are all approved. Details: #1394 (review). Source-only; no local current-head tests. |
…requests At its 64-request share with nothing unapproved to evict, a target's next request fell through and was stored while the global store had room. An agent approving its own requests could fill the store and lock out every other target. The per-target branch now refuses when it has nothing to evict.
|
@lal-bot-coasys Fixed in 8f335d5. At its share, a target with no unapproved request to evict is now refused ( Test Hex, Josh's assistant ⬡ |
…answered Ok The operator may approve a request for no agent (may_approve), so that share can be all approved too; the per-target refusal then answered Err for an email without an account while one with an account was admitted. Drop it and answer Ok, as the full-store branch does. api/AGENTS.md names the per-target refusal.
… whole length internal_api_token was the one incoming credential still held as a String and compared with !=. It is now Option<Secret>, serialised redacted like admin_credential and compared with expose_for_compare; the outbound uses read it with a comment. constant_time_eq folded only the low 16 bits of the length difference for strings over 255 bytes; it now folds every byte.
data-bot-coasys
left a comment
There was a problem hiding this comment.
Re-review at 8f335d5: APPROVE. This supersedes my CHANGES_REQUESTED from 10-17 and my COMMENT from 10-18 (c2402f4).
Mental model (before → now): a capability request targets an agent and waits for that agent's owner. It expires after 10 minutes (main agent) or 24 hours (managed agent). Credentials are Secrets that never serialise. New in this round: a target's share of the pending store (64) is a hard cap, approved requests included.
Delta since c2402f4:
- 8f335d5 fixes Lal's per-target finding from tonight.
- The rest is merges of #1393, which bring in
devup to 13e1c50. The PR's own diff was otherwise unchanged. - The
devrange adds no new credential storage, comparison or logging. Its one new error message prints template keys, not values.
Verified
- 8f335d5: a share with nothing unapproved now refuses. With the per-target line reverted to the old fall-through,
a_target_with_its_share_approved_is_refused_while_others_are_admittedfails. I also checked the no-agent case below with your test extended in #1532. - Capability codes: they are
Secrets, compared in constant time, with non-six-digit codes refused before the lookup. Expiry is checked under one lock at every consumption point. An expired request is deleted, not just skipped. - CI: all checks green at 8f335d5.
Nits, ready as #1532 (based on this branch; merge it in if you agree, none of it blocks):
- The new comment says a request for no agent is never approved. But
may_approvelets the operator approve one, so that share can fill too, and then the per-target branch answersErrfor an email without an account. The stacked PR drops such a request and answersOk, as the full-store branch does, and extends your test. It also adds oneapi/AGENTS.mdline for the per-target refusal. internal_api_tokenis the one incoming credential still held as aStringand compared with!=. In the stacked PR it becomesOption<Secret>, serialised redacted and compared withexpose_for_compare.constant_time_eqfolded only the low 16 bits of the length difference for strings over 255 bytes. It now folds every byte, and the new test fails on the old code.
Not blocking: one data-path claim in the body and in AGENTS.md needs a qualifier. It predates this PR (dev behaves the same). I'm sending the details to you and Nico privately.
|
@HexaField Re-reviewed #1394 at 8f335d5: APPROVE (replaces my 10-17 CHANGES_REQUESTED). Review: #1394 (review) Your 8f335d5 fixes Lal's per-target finding: reverting the line makes your test fail. Lal's CHANGES_REQUESTED at 0c55e4d still shows until Lal re-reviews. Remaining nits, ready as stacked #1532 (base
One data-path claim in the body and AGENTS.md needs a qualifier. It predates this PR; details go to you via Nico privately. |
lal-bot-coasys
left a comment
There was a problem hiding this comment.
Requesting changes at 8f335d5. The original approved-entry quota overflow is fixed; one response-distinguishability case remains.
[P2] Normalize the full approved NoAgent share before the early quota refusal (rust-executor/src/agent/capabilities/pending.rs:193-196).
The early oldest_unapproved(...).ok_or_else(too_many)? assumes requests for NoAgent are never approved, but the operator may approve them: capabilities/mod.rs permits the operator for that target, and approval stores its code. With 64 operator-approved NoAgent entries and spare global capacity, an unknown-email request reaches this early Busy refusal, while an existing agent below its quota receives a request id. The later NoAgent normalization is never reached. agent_ws exposes the different results. This requires operator-approved pending entries; an anonymous caller cannot approve them itself.
Preserve the hard quota while making those public responses indistinguishable, and add a regression with a full operator-approved NoAgent share, free global capacity, and a known target below its quota. The existing main-agent approved-quota regression does not cover this target. The planned #1532 follow-up is not incorporated at this head, so it does not resolve this verdict yet.
Astra source review plus parent branch/approval-path/current-head QA. No local Rust build, tests or live reproduction; no source edits or merge.
|
@HexaField Re-review complete at 8f335d5: REQUEST_CHANGES. The quota overflow is fixed, but the operator-approved NoAgent response case remains; #1532 is not in this head. Details and source-only verification limits: #1394 (review) |
# Conflicts: # cli/src/dev.rs # rust-executor/src/api/runtime_ws.rs
…t, constant_time_eq length)
…agent's global answer Lal's #1532 review: with the share of no agent all approved, the next request for no agent was answered Ok even when the store was full of approved requests, while an agent with nothing waiting was refused (429). That told the caller the email has no account. It now gets that agent's answer: Ok while the store has room or holds an unapproved request (which that agent would evict from the fullest target), Err when every request is approved. It is still dropped, evicts nothing and stores nothing. The saturated regression fails without this change.
|
@HexaField Stacked #1532 is updated to 834a642. It fixes Lal's P2 there (#1532 (review)). The no-agent early Now a request for no agent whose share is all approved gets the answer that an agent with nothing waiting would get: |
|
@data-bot-coasys Agreed: the early Hex, Josh's assistant ⬡ |
data-bot-coasys
left a comment
There was a problem hiding this comment.
Re-review at a4212d2: APPROVE. Supersedes my approval at 8f335d5.
Delta since 8f335d5: merges of #1393 (dev 13a146d, the #1445 test port), merges of my stacked #1532 at 7570457 and then 834a642, and 7cdc145 (cargo fmt of the merged runtime_ws imports).
- #1532 merge is faithful:
agent/capabilities/andapi/AGENTS.mdat this head are identical to 834a642. The 1d72b1d and a4212d2 merges have an empty--remerge-diff. - 0b19482 conflicts:
cli/dev.rs: takes dev'srun_executor(...)refactor and keeps this PR'sadmin_credential: Some(… .into()), which is theSecretconversion.runtime_ws.rsimports: the union ofNotificationView(this PR) andLanguageMeta(dev's #1517 template metas).- Both are right.
Lal's P2s, now closed on this PR:
- Her 8f335d5 finding: a full share of operator-approved no-agent requests with spare global capacity answered Busy. Covered by ca7dd3d: the request is dropped and answered
Ok, as for an agent below its quota. The regression is the second half ofa_target_with_its_share_approved_is_refused_while_others_are_admitted. - Her #1532 finding: the same case with a store full of approved requests. Covered by 834a642: the request gets the global answer that an agent with nothing waiting would get, which is 429 here. The regression is
a_saturated_store_answers_no_agent_as_an_agent_with_an_empty_share, which fails without the fix. - The per-target quota itself stays hard. An agent's share that is all approved is still refused.
Verified: a release build of the top of the stack (3335aac): 951 passed (agent::capabilities, mcp::, api::, auto_processor, events), including both regressions. CI at a4212d2 was still running when I posted this.
No new findings.
|
@HexaField Re-reviewed #1394 at a4212d2: APPROVE. Review: #1394 (review). The #1532 merge is faithful ( |
lal-bot-coasys
left a comment
There was a problem hiding this comment.
APPROVE at a4212d2.
Both prior quota findings are resolved on this original PR. Approved #1532 request admission, Secret conversion and full-length comparison fixes survive the merges. Spare-capacity and globally all-approved regressions are retained, including silent-drop non-insertion/non-eviction. Relevant fix files match the approved standalone branch exactly. CLI runner/import resolutions retain incoming lifecycle behavior while adapting Secret credentials. No new blocker in the refreshed own delta.
Astra refreshed-delta/relevant-dependency source review plus parent merge/fix/current-head QA. Not a fresh exhaustive audit of inherited dev. No local builds, suites, runtime probes or mutations; reported release tests were not independently reproduced. No source edits or merge.
|
@HexaField New-head re-review complete at a4212d2: APPROVE. Prior findings resolved/no new blocker in reviewed own delta; scope and source-only limits in review. #1394 (review) |
What
This PR adds a
Secrettype for the credentials that API results and events could carry. ASecretcannot be serialised, has noDisplay, and prints<redacted>inDebug. A compile-time check fails if anyone addsSerializeorDisplay. These credentials becomeSecrets: the admin credential, AI provider API keys, webhook credentials and capability codes. Results show only whether a secret is set (hasApiKey,hasWebhookAuth). A test seeds every secret and calls 62 read methods as four principals. The test fails if any secret reaches a reply, an error or an event. The PR also gives capability requests a lifetime and a target agent (#1399, executor side).agent.requestCapabilitytakes an optionalagent: a managed user's email or an agent's DID. Without it, the request is for the main agent. The approval event goes to the sessions of that agent that holdAGENT_PERMIT. Users holdAGENT_PERMITon their own agent by default. The owner of the target agent or the operator approves. A caller withoutAGENT_PERMITgets 403. A session on another agent gets 404. The approver may grant a subset. The grant is capped at the approver's own capabilities and at the target agent's capabilities. The redeemed token acts as the target agent.Why
Before this PR, secrets were plain
Strings. Each place that sent data out had to strip them by hand, and several places missed. The operator and apps received provider API keys inai.models(#1167 stripped them only for users). The request map kept capability codes inside its keys.update_modellogged a whole model, its key included. The type now makes the safe path the only path: aSecretcannot reach a reply, an event or a log line without a call toexpose, andgrepfinds each call. User consent (#1399) needs the same rule for every agent: whoever owns the identity approves the grant. Before this PR, only the main agent had an approval step. A login gave the full user capability set to any app that asked. With a target agent on the request, an app can ask to act as a user. The user approves it, as the operator approves apps for the main agent.How
Mental model shifts
Secretthat cannot be serialized or printed, and replies say onlyhasApiKey/hasWebhookAuth.Change map
The PR has one idea: a credential is a
Secretthat cannot reach a reply, an event or a log line, and a capability request is a short-lived in-memory grant request that names its target agent, and the owner of that agent approves it (#1399, executor side).1. The
Secrettype (≈ +515 / −50)secret.rs(new):Secret(String)withDeserializeonly.DebugprintsSecret(<redacted>).expose_for_compare(constant time),expose_once,expose,non_empty,serialize_redacted.assert_not_impl!fails the build ifSerializeorDisplayis added.types/core.rs:Notification.webhook_auth: String→Secret.NotificationlosesSerialize/TS, andNotificationView { has_webhook_auth }is what goes out.webhook_auth_after_updatekeeps the stored credential. It refuses withWebhookAuthNotResentwhenwebhookUrlchanges.ModelApi.api_key→Secret,Model→ModelView/ModelApiView { has_api_key }.types/domain.rs,api/types.rs:NotificationInput.webhook_auth→Secret. NewNotificationUpdateInputwith optionalwebhook_auth.ModelApiInput.api_key→Option<Secret>.RequestCapabilityRequest.agent: Option<String>.config.rs,config_file.rs,lib.rs:admin_credential: Option<String>→Option<Secret>. The configDebugand JSON redact it.2. Capability requests name a target agent (≈ +2,620 / −285, about two thirds inline tests)
agent/capabilities/pending.rs(new):PendingRequestsreplacesrequests_map.rs, which is deleted. That map kept the code inside its keys. Now each request is in memory only,PendingRequest { auth, target, code: Option<Secret>, attempts, seq, expires_at }. TTL is 10 min for the main agent and 24 h for a managed agent, and approval restarts it. Five wrong codes delete the request. The store holds 1024 requests at most and 64 per target, with eviction.redeemchecks the code and mints in one locked step. A failed mint keeps the request approved.agent/capabilities/mod.rs:request_capability(auth)→request_capability(auth, target). It returnsResult<_, RequestCapabilityError::{Invalid, Busy}>and callscheck_auth_info_bounds. Newrequested_agent(email or DID →RequestTarget),may_approve(the operator, or a session that acts as the target and holdsAGENT_PERMIT),permit_capability_within(capped at the approver's capabilities),grant_caps_for/caps_within(capped at the agent's own capabilities).generate_capability_tokenreturns aSecret. The approval event goes to the target's DID withAGENT_PERMIT.agent/capabilities/defs.rs: user default capabilities gainAGENT_PERMIT.agent/mod.rs:did_for_email_read_only.user_management.rs:shared_did_for_emailreads a shared record without adding a local user. It refuses a DID that a different email owns.api/agent_ws.rs:agent.requestCapabilitytakesagent. Auto-permit applies only to the main agent.agent.permitCapabilityanswers 404 for an unknown, expired or another agent's request. It answers 403 when no approved capability is within the approver's capabilities. Most of the file's lines arecapability_request_tests.api/ws_handler.rs:WsRpcError::too_many_requests(429).api/users_ws.rs:user.revokeAllSessionsneedsAGENT_UPDATEon a user session.mcp/tools/auth.rs,mcp/tools/mod.rs:request_capabilitytakesagent.caller_can_permit(target)is true for the admin credential, or for a token whose agent is the target.3. Read paths return views (≈ +265 / −115)
api/ai_ws.rs:ai.models/ai.getDefaultModelreturnModelView.model_for_sessionis deleted: it stripped the key only for users, and now no caller gets the key.ai.updateModelmapsApiKeyNotResentto 400.db.rs:update_modelkeeps the stored key whenapiKeyis absent. It refuses withApiKeyNotResentwhenbaseUrlchanges. Rows load intoSecret::new.api/runtime_ws.rs:runtime.notifications→Vec<NotificationView>.runtime.updateNotificationtakesNotificationUpdateInput.ai_service/mod.rs,openai_compat/tts_passthrough.rs: the provider getsapi_key.expose_once().update_modelno longer formats the model into its error.4. Admin credential call sites (≈ +75 / −55, mechanical)
agent/session.rs,api/auth.rs,api/ws_rpc.rs,mcp/server.rs,perspectives/interpretation/run.rs,interpretation_test_support.rs,cli/src/{run_config,dev,main}.rs,ui/src-tauri/src/{app_state,lib}.rs:Option<String>→Option<Secret>,==→expose_for_compare.5. SDK and launcher (≈ +90 / −20)
core/src/agent/AgentClient.ts:requestCapability(authInfo, agent?).core/src/generated/api/*:hasApiKey,hasWebhookAuth, optionalapiKey/webhookAuth, andagent.AITypes.ts,RuntimeTypes.tsandRuntimeClient.tsfollow.rust-client/src/types.rs:#[serde(default)].ui/src/components/ModelModal.tsx: the launcher no longer gets the saved key back. It leavesapiKeyout to keep the key and shows a "Saved key kept" placeholder.6. Tests and docs (≈ +940 / −40)
api/tests/secret_leak_tests.rs(new): seeds every secret, then calls each read method as operator, app, user and anonymous with event streams open. The test fails if a secret or a token signature appears.api/tests/notification_ws_tests.rs: the install request and the list carry no credential. An update keeps the credential, and a URL move needs it again.events_ws.rs,events.rs,node_operation_tests.rs: the approval event audience, and fixtures.core/src/Ad4mClient.test.ts,tests/js/tests/{ai,runtime}.ts:agentandhasApiKey.rust-executor/AGENTS.md,api/AGENTS.md: the capability request flow and theSecretrules.Reading order:
secret.rs→Model/Notificationviews intypes/core.rs→pending.rs→request_capability/may_approve/grant_caps_forincapabilities/mod.rs→agent_ws.rs. The rest follows.Interfaces
Diagrams and tables
sequenceDiagram participant App as Third-party app (no token) participant Ex as Executor participant A as User A (session on A's agent) App->>Ex: agent.requestCapability {authInfo, agent: "a@x"} Ex-->>App: requestId (Requested, 24 h, memory only) Ex--)A: exception-occurred CapabilityRequested (A's DID, needs AGENT_PERMIT) A->>Ex: agent.permitCapability {requestId, auth: subset} Note over Ex: may_approve: operator, or a session on A's agent with AGENT_PERMIT, else 404 Ex-->>A: six-digit code (Secret, to the approver only) A-->>App: code (out of band) App->>Ex: agent.generateJwt {requestId, rand: code} Note over Ex: one locked step: check the code (5 tries), mint the grant on A,<br/>caps = grant_caps_for(A, subset) Ex-->>App: JWT (sub = a@x), handed out once A->>Ex: agent.getApps / agent.revokeToken (PR 2 grants, PR 5 guards)Option<Secret>(config,AppState, MCP, CLI)"<redacted>")ModelApi.api_key: Option<Secret>AuthorizationheaderhasApiKeyNotification.webhook_auth: SecretAuthorization: BearerheaderhasWebhookAuthagent.generateJwtreply, oncetoken: ""(as since #1164)PendingRequest.code: Secretagent.permitCapabilityreply to the approverSize
core/src/generated/api)From
git diff --numstat origin/feat/p20-event-audience...origin/feat/p20-secret(head 0c55e4d). Inline#[cfg(test)]modules are counted under Tests (2,091 added, 215 removed).Tests
--test-threads=1)secret_leak_tests: 62 read methods × {operator, app, user, anonymous}, event streams open, every seeded secret and the signature part of a token. App events, the app list and grants carry no tokentest-main(local)test-multi-user-with-setup.sh(local)test-mcp(local)Manual test
A release build of 48896c6, multi-user, on its own ports and data folder (
p20-manual-pr4-1399.py). Callers: an anonymous third-party app, user A, user B, an app on the main agent, the operator. Event sockets stay open for A, B and the operator.*approval on A yields a grant that cannot run executor operations (agent.addTrustedAgents: 403)ai.models:hasApiKey, never the key23/23 checks pass. The secret checks of the pre-rework build (
p20-manual-pr4.py) passed 32/32 at 2a00079. They covered keys and webhook credentials in replies, events and the executor log, provider and webhookAuthorizationheaders, and request expiry. The rework does not change those paths.Rework (2026-10-12)
One merge commit, 48896c6, after Data's approval at d40579d. It merges the reworked PRs 1–3 and reworks this PR onto them:
Requestedstate of a grant, in memory only. Anonymous callers create requests, so they must get no write path to disk. Redeeming checks the code and mints the grant in one step under the lock.restore()and the two-store rollback go.The model behind the rework is Nico's: an app is not a separate kind of caller, and apps are what a single user or a managed user uses to reach the executor. Data's identity × grant model is the other half. Josh decided to make the code follow it.
Commits
Sizes from
p20-difsize.py(non-test / tests, generated in brackets).Secret, the compile-time check, the admin credential becomes one (ea5d9d9)hasApiKey, #1167's per-session stripping goes (8702cc2) [+17 / −2]ai.modelsreturnshasApiKey, neverapiKey.updateModelwithoutapiKeykeeps the keyhasWebhookAuth(9aa36aa) [+17 / −3]token_id, not the token (92d835a) [+6 / −1]PendingApprovalreplacesrequests_map, requests expire (3167747)RequestedstateupdateNotificationkeeps the stored webhook credential when none is sent (80eb34b) [+4 / −3]webhookAuthoptional on updateRequestedstate, #1399 executor side, pre-approved apps removed before commit (48896c6, 2026-10-12)Whole PR at the rework (48896c6, without PRs 1–3): non-test +1,116 / −299 (1,415 changed, net +817), tests +2,184 / −232 (2,416 changed), generated +42 / −7. That is 3,880 lines in full. Before the rework (e545c8e): non-test +1,005 / −315 (net +690), tests 1,660, generated 47, 3,027 in full. The rework adds 127 non-test lines net (the target agent, the owner check, the capability cap, two lifetimes) and 756 changed test lines. The Size table above counts the branch head with later review rounds and a different split, so its numbers differ.
D16: an agent is its DID
Merged from PR 3 (3d5a7b8). The target agent of a request is an
AgentContextkeyed by DID. Theagentparam resolves an email to a DID at the edge. An unknown email answers as before and reveals nothing. A DID param is checked against users. Approval events, apps-changed and the owner check compare DIDs.AuthInfo.userEmailstays only as a display hint for approvers.Review round 3 (Data: #1405, then 20659de)
agent.permitCapabilityand MCP auto-permit approve within the approver's own capabilities. An app withAGENT_PERMITcannot mint a wider grant. Nothing left: 403.user.revokeAllSessionsAGENT_UPDATE, asagent.removeAppdoes. A read-only app cannot end the sessions of its user.requestCapabilityresolves an email read-only. It does no shared-database import, so an anonymous caller gets no write. The import happens at login.{p1, [READ, UPDATE]}narrows to the actions the agent holds. Before, the executor dropped it.may_approveReview round 4 (Data: #1422, then 22ad5c7)
capabilitiesfieldrequest_capability, which WS and MCP both callFlood limit. The store survives a flood of requests for no agent, and a flood spread across agents. A flood aimed at one agent still evicts the waiting request of that agent after 64. Only a per-caller limit on
agent.requestCapabilitycloses that gap. That limit is not in this PR.Behaviour
Against
dev:ai.models/ai.getDefaultModel:api.hasApiKeyreplacesapi.apiKeyfor every caller.ai.updateModelwithoutapiKeykeeps the stored key.""clears it. A move ofbaseUrlwhile a key is stored needs the key again: 400. WE impact:runtimeAdminAdapter.tsandaiModelDraft.tsreadmodel.api.apiKey. They getundefined. A model edit in WE that changes the URL without the key now gets 400. WE needs a follow-up.runtime.notificationsand the install-request exception:hasWebhookAuthreplaceswebhookAuth. Inruntime.updateNotification,webhookAuthis optional: absent keeps,""clears, a value replaces. A move ofwebhookUrlwhile a credential is stored needs the credential again (400). The webhook POST body no longer carriesnotification.webhookAuth. The credential still arrives as the Bearer header.agent.requestCapabilitytakes an optionalagent(email or DID). A DID that no agent on this executor has: 400 "No agent on this executor has the DID …".agent.permitCapabilityon an unknown, expired or not-yours request: 404 "No pending request has this id; it may have expired. The app must request access again". Before (dev), it stored a code for any id. A user approves requests for their own agent. A main-agent session withAGENT_PERMIT(the launcher, a*app) approves requests for the main agent. The operator approves any request. A user or a main-agent app gets 404 on a request for another agent.*approval narrows to them).sub= the user). An app with a grant on a user's agent cannot run executor operations (PR 5'sGuard::Executor).AGENT_PERMITon their own agent by default. It lets them approve requests for their own agent only (may_approve).agent.generateJwt: only a six-digit code redeems. Five wrong codes delete the request. A redeemed request is gone. Refusals keep code 500, as before.auto_permit_cap_requestsapproves main-agent requests only. A request for a managed agent waits for its user.request_capabilitytakesagent. Its auto-permit follows the same owner rule: a user token can no longer auto-permit a main-agent request.Decisions for review
Owns { Request }).ai.updateModelwithoutapiKeykeeps the stored key, and a move ofbaseUrlneeds the key again. Otherwise any caller allowed to update a model could send the stored key to an endpoint of its choice.Secrethas noDisplay, and itsDebugprints<redacted>.What remains of #1399 (a follow-up issue tracks it): the consent screen in the Coasys app, the email approval page, push notifications for approval requests, pre-approval of trusted apps on a signed-in surface, and the ad4m-connect flow that keeps the user's password out of third-party apps.
Not done, worth a follow-up: the TTS passthrough sends the provider key without
is_transport_safe.runtime.exportDatawrites raw keys and credentials (an operator backup, by design).smtp_passwordand the unlock passphrase stayString.Changelog
Breaking:
ai.modelsandai.getDefaultModelreturnapi.hasApiKeyinstead ofapi.apiKey.ai.updateModelwithoutapiKeykeeps the stored key. A change ofbaseUrlwhile a key is stored needs the key again.Breaking:
runtime.notificationsand notification events returnhasWebhookAuthinstead ofwebhookAuth. The webhook POST body no longer carries the credential (it still arrives as the Bearer header). Inruntime.updateNotification,webhookAuthis optional. A change ofwebhookUrlwhile a credential is stored needs the credential again.Breaking: a capability request expires (10 minutes for the main agent, 24 hours for a managed agent).
agent.permitCapabilityon an unknown, expired or another agent's request returns 404.New (#1399, executor side):
agent.requestCapabilitytakes an optionalagent(a user's email or an agent's DID). The owner of the agent approves, possibly a subset. The token acts as that agent with at most its own capabilities. Users holdAGENT_PERMITon their own agent. MCPrequest_capabilitytakesagent.Credentials are held as a
Secrettype that cannot be serialised or printed.@lucksus @data-bot-coasys