mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - When an agent is denied a board-only action, the denial names the
board page where a person can do it, and agents relay that link
> - Cost recording retry ("Retry cost recording") is board-only and
lives on the run's row on the Costs page
> - The denial for every `/companies/:id/costs` route named the Budgets
page instead
> - So an agent asked to fix a run's missing cost sent the person to
Budgets, where the control does not exist
> - This pull request maps cost routes to the Costs page and names that
page in the retry route's catalog entry
> - The benefit is that the person lands on the control the agent told
them to use
## Linked Issues or Issue Description
**What happened**
In paid chat eval run 43086418488
(`budgets-costs.repair-cost-recording`), the CEO tried `POST
/api/companies/{companyId}/costs/retry-accounting`, got `403
board_access_required` whose remediation said `[Open
budgets](/<prefix>/activity/budgets)`, and told the person to use the
retry on Budgets. The retry control is on the Costs page.
**Expected behavior**
Board denials for cost routes name `[Open
costs](/<prefix>/activity/costs)`. Budget and budget-incident routes
still name Budgets.
**Steps to reproduce**
1. As an agent, call `POST
/api/companies/{companyId}/costs/retry-accounting`.
2. Read the 403 `remediation`.
3. It links `/<prefix>/activity/budgets`, a page with no cost retry
control.
## What Changed
- `server/src/routes/authz.ts`: `COMPANY_COSTS_PAGE`
(`/<prefix>/activity/costs`); `/companies/:id/costs/*` denials use it.
Budgets and budget incidents keep `COMPANY_BUDGETS_PAGE`.
- `server/src/routes/openapi.ts`: the retry route's description says a
board user retries with Retry cost recording on the run's row on the
Costs page.
- `board-access-denial.test.ts`: regression row for the retry route (red
before the change, green after).
## Verification
- `pnpm exec vitest run src/__tests__/board-access-denial.test.ts
src/__tests__/openapi-routes.test.ts
src/__tests__/agent-refusal-finality.test.ts
src/__tests__/cost-data-quality-routes.test.ts
src/services/native-runtime/runner-api.test.ts` (server): all pass. The
new row fails without the `authz.ts` change.
- End-to-end: the chat eval case `budgets-costs.repair-cost-recording`
grades that the agent's guidance links the Costs page; it will be rerun
after this merges.
## Risks
- Low. Only the link text and target in cost-route denials and one
catalog description change. Agents that previously relayed Budgets now
relay Costs.
## Model Used
- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code
execution in Claude Code.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
428 lines
19 KiB
TypeScript
428 lines
19 KiB
TypeScript
import type { Request, Response } from "express";
|
|
import type { SecretBindingTargetType } from "@paperclipai/shared";
|
|
import { forbidden, HttpError, unauthorized } from "../errors.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { responsibleUserAuthzShadowMode } from "../services/authorization.js";
|
|
import { getHiddenSettings } from "../services/settings-visibility.js";
|
|
|
|
function throwOrShadowResponsibleUserCompanyAccessDeny(
|
|
req: Request,
|
|
companyId: string,
|
|
code: "RESPONSIBLE_USER_UNAUTHORIZED" | "RESPONSIBLE_USER_UNAVAILABLE",
|
|
message: string,
|
|
) {
|
|
logger.warn({
|
|
authzMode: responsibleUserAuthzShadowMode() ? "shadow" : "enforce",
|
|
code,
|
|
action: "company_access",
|
|
companyId,
|
|
actorAgentId: req.actor.agentId ?? null,
|
|
responsibleUserId: req.actor.onBehalfOfUserId ?? null,
|
|
method: req.method,
|
|
}, "responsible-user company access intersection denied");
|
|
if (responsibleUserAuthzShadowMode()) return;
|
|
throw new HttpError(403, message, { code });
|
|
}
|
|
|
|
export function assertAuthenticated(req: Request) {
|
|
if (req.actor.type === "none") {
|
|
throw unauthorized();
|
|
}
|
|
}
|
|
|
|
export const BOARD_ACCESS_REQUIRED_CODE = "board_access_required";
|
|
|
|
// Agent API subresources whose controls live on their own profile tab.
|
|
const AGENT_PROFILE_TABS: Record<string, [string, string]> = {
|
|
keys: ["API keys", "api-keys"],
|
|
permissions: ["permissions", "permissions"],
|
|
budgets: ["budget", "budgets"],
|
|
identity: ["identity and instructions", "identity"],
|
|
"instructions-bundle": ["identity and instructions", "identity"],
|
|
"instructions-path": ["identity and instructions", "identity"],
|
|
configuration: ["harness settings", "runtime"],
|
|
"runtime-state": ["harness settings", "runtime"],
|
|
};
|
|
|
|
/** A board page as [label, path], where the path keeps <prefix> for the agent to fill. */
|
|
export type BoardControlPage = [label: string, path: string];
|
|
|
|
/** Budgets are board-only. These pages hold the budget controls behind any route that writes one. */
|
|
export const COMPANY_BUDGETS_PAGE: BoardControlPage = ["budgets", "/<prefix>/activity/budgets"];
|
|
/** Cost recording controls (such as retrying a run's saved-usage cost) are on the Costs page, not Budgets. */
|
|
export const COMPANY_COSTS_PAGE: BoardControlPage = ["costs", "/<prefix>/activity/costs"];
|
|
export function agentBudgetPage(agentId: string): BoardControlPage {
|
|
return [`the agent's ${AGENT_PROFILE_TABS.budgets![0]}`, `/<prefix>/agents/${agentId}/${AGENT_PROFILE_TABS.budgets![1]}`];
|
|
}
|
|
|
|
// The board page that holds the control behind a board-only API route, when one
|
|
// is known. Paths keep <prefix> for the agent to fill from its own task identifier.
|
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
|
const BOARD_CONTROL_PAGES: Array<[RegExp, (match: RegExpExecArray, req: Request) => [string, string] | null]> = [
|
|
[/^\/(?:companies\/[^/]+\/)?secrets?(?:[-/]|$)/, () => ["company secrets", "/<prefix>/company/settings/secrets"]],
|
|
[/^\/companies\/[^/]+\/(?:budgets|budget-incidents)(?:\/|$)/, () => COMPANY_BUDGETS_PAGE],
|
|
[/^\/companies\/[^/]+\/costs(?:\/|$)/, () => COMPANY_COSTS_PAGE],
|
|
[/^\/companies\/[^/]+\/(?:prompt-injection-prevention|decision-model)(?:\/|$)/, () => ["connection settings", "/<prefix>/company/settings/connections"]],
|
|
[/^\/companies\/[^/]+\/(?:members|invites|join-requests)(?:\/|$)/, () => ["company members", "/<prefix>/company/settings/members"]],
|
|
[/^\/companies\/[^/]+\/(?:tools\/)?gateways(?:\/|$)|^\/tool-gateway\//, () => ["tool gateways", "/<prefix>/apps/gateways"]],
|
|
[/^\/approvals\/([^/]+)/, match => ["the approval", `/<prefix>/approvals/${match[1]}`]],
|
|
[/^\/projects\/([^/]+)\/access-members(?:\/|$)/, (match, req) => {
|
|
const projectId = [req.params?.id, match[1]].find((id): id is string => typeof id === "string" && UUID_PATTERN.test(id));
|
|
return projectId ? ["the project's access settings", `/<prefix>/projects/${projectId}/configuration`] : null;
|
|
}],
|
|
[/^\/agents\/([0-9a-f-]{36})(?:\/([^/]+))?(?:\/|$)/i, match => {
|
|
const tab = match[2] && Object.hasOwn(AGENT_PROFILE_TABS, match[2]) ? AGENT_PROFILE_TABS[match[2]] : undefined;
|
|
return tab ? [`the agent's ${tab[0]}`, `/<prefix>/agents/${match[1]}/${tab[1]}`] : ["the agent's profile", `/<prefix>/agents/${match[1]}`];
|
|
}],
|
|
[/^\/companies\/[^/]+(?:\/(?:archive|branding|settings))?\/?$/, () => ["company settings", "/<prefix>/company/settings"]],
|
|
// An operator can hide the Experimental page, so link it only while it is visible.
|
|
[/^\/instance\/settings\/experimental\/?$/, () => getHiddenSettings().has("instance.experimental")
|
|
? null : ["Experimental settings", "/<prefix>/company/settings/instance/experimental"]],
|
|
];
|
|
|
|
// Plugin management pages. A plugin id or key segment opens that plugin's settings
|
|
// (the page accepts either); a plugin-manager endpoint segment opens the manager.
|
|
// An operator can hide plugin management, so link it only while it is visible.
|
|
const PLUGIN_REFERENCE = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
const PLUGIN_MANAGER_SEGMENTS = new Set(["examples", "install", "tools", "ui-contributions"]);
|
|
function pluginControlPage(path: string): [string, string] | null {
|
|
const match = /^\/plugins(?:\/([^/]+))?(?:\/|$)/.exec(path);
|
|
if (!match || getHiddenSettings().has("instance.plugins")) return null;
|
|
const segment = match[1];
|
|
const plugin = segment && PLUGIN_REFERENCE.test(segment) && !PLUGIN_MANAGER_SEGMENTS.has(segment) ? segment : null;
|
|
return plugin
|
|
? ["the plugin's settings", `/<prefix>/company/settings/instance/plugins/${plugin}`]
|
|
: ["plugin settings", "/<prefix>/company/settings/instance/plugins"];
|
|
}
|
|
|
|
// Every plugin management route is board-only, so a denial on any one of them
|
|
// (often the list an agent reads first) also answers the plugin's other management steps.
|
|
const PLUGIN_ROUTE = /^\/plugins(?:\/|$)/;
|
|
const PLUGIN_ADMINISTRATION_SCOPE = "Plugin management is board-only throughout: listing, installing, configuring (including choosing a secret for a plugin setting), enabling, disabling, upgrading and removing plugins have no agent route either.";
|
|
|
|
const ENVIRONMENT_ROUTE = /^\/(?:companies\/[^/]+\/)?environments(?:\/|$)|^\/environment-(?:leases|custom-image-setup-sessions)(?:\/|$)/;
|
|
const ENVIRONMENT_ADMINISTRATION_SCOPE = "Execution environments are board-only throughout: listing computers, reading which coding harnesses they support, testing (probing) them and changing them have no agent route either.";
|
|
function environmentControlPage(path: string): [string, string] | null {
|
|
if (!ENVIRONMENT_ROUTE.test(path) || getHiddenSettings().has("instance.environments")) return null;
|
|
return ["environment settings", "/<prefix>/company/settings/instance/environments"];
|
|
}
|
|
|
|
function deniedApiPath(req: Request) {
|
|
return (req.originalUrl ?? "").split("?")[0]!.replace(/^\/api(?=\/)/, "");
|
|
}
|
|
|
|
function boardControlPage(req: Request) {
|
|
const path = deniedApiPath(req);
|
|
const pluginPage = pluginControlPage(path);
|
|
if (pluginPage) return pluginPage;
|
|
const environmentPage = environmentControlPage(path);
|
|
if (environmentPage) return environmentPage;
|
|
for (const [pattern, page] of BOARD_CONTROL_PAGES) {
|
|
const match = pattern.exec(path);
|
|
if (match) return page(match, req);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* A board-only denial. Agents get a final, actionable remediation so they hand the
|
|
* action to the person instead of searching other routes for one that does not exist.
|
|
*/
|
|
export function boardAccessRequired(req: Request, controlPage?: BoardControlPage) {
|
|
if (req.actor.type !== "agent") return forbidden("Board access required");
|
|
const page = controlPage ?? boardControlPage(req);
|
|
const deniedPath = deniedApiPath(req);
|
|
const scope = PLUGIN_ROUTE.test(deniedPath) ? `${PLUGIN_ADMINISTRATION_SCOPE} `
|
|
: ENVIRONMENT_ROUTE.test(deniedPath) ? `${ENVIRONMENT_ADMINISTRATION_SCOPE} ` : "";
|
|
const where = page
|
|
? `They can do it in the Paperclip web app: link [Open ${page[0]}](${page[1]}), with this conversation's company prefix as <prefix>.`
|
|
: "They can do it in the Paperclip web app or with their own board credentials. Link a page only if you already know its route; do not search for one.";
|
|
return forbidden("Board access required", {
|
|
code: BOARD_ACCESS_REQUIRED_CODE,
|
|
remediation: `This action is board-only by design and this denial is final: no agent credential, tool or other API route can do it, so do not search for one. Stop working on this step and reply now: tell the person that a board user must do it. ${scope}${where}`,
|
|
});
|
|
}
|
|
|
|
export const ISSUE_NOT_VISIBLE_CODE = "issue_not_visible";
|
|
|
|
/**
|
|
* The 404 for a task lookup. A deleted task, a private task the caller cannot
|
|
* see and another company's task get this same body, so it never says which.
|
|
* Agents also learn that the lookup is final, so they reply instead of
|
|
* searching other routes for the task.
|
|
*/
|
|
export function issueNotFoundBody(req: Request): Record<string, unknown> {
|
|
if (req.actor.type !== "agent") return { error: "Issue not found" };
|
|
return {
|
|
error: "Issue not found",
|
|
code: ISSUE_NOT_VISIBLE_CODE,
|
|
remediation: "No task with this ID or identifier is visible to you. A deleted task, a private task you were not given and a task in another company all get this same answer, so this lookup is final: do not search for the task through other routes, lists, activity, files or hosts, and do not open a similarly named task in its place. Reply now: tell the person you cannot open it here, and that if it still exists they can share it with you from its sharing controls or paste the parts they need.",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Board-only check. Pass `controlPage` when the board control lives on a different
|
|
* page from the one the route path implies, e.g. a budget field on a general route.
|
|
*/
|
|
export function assertBoard(req: Request, controlPage?: BoardControlPage) {
|
|
if (req.actor.type !== "board") {
|
|
throw boardAccessRequired(req, controlPage);
|
|
}
|
|
}
|
|
|
|
export function hasBoardOrgAccess(req: Request) {
|
|
if (req.actor.type !== "board") {
|
|
return false;
|
|
}
|
|
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
|
|
return true;
|
|
}
|
|
return Array.isArray(req.actor.companyIds) && req.actor.companyIds.length > 0;
|
|
}
|
|
|
|
export function assertBoardOrgAccess(req: Request) {
|
|
assertBoard(req);
|
|
if (hasBoardOrgAccess(req)) {
|
|
return;
|
|
}
|
|
throw forbidden("Company membership or instance admin access required");
|
|
}
|
|
|
|
export function assertBoardOrAgent(req: Request) {
|
|
if (req.actor.type === "agent") {
|
|
return;
|
|
}
|
|
if (req.actor.type === "board") {
|
|
assertBoardOrgAccess(req);
|
|
return;
|
|
}
|
|
throw forbidden("Board or agent access required");
|
|
}
|
|
|
|
export function assertInstanceAdmin(req: Request) {
|
|
assertBoard(req);
|
|
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
|
|
return;
|
|
}
|
|
throw forbidden("Instance admin access required");
|
|
}
|
|
|
|
export function assertCompanyAccess(req: Request, companyId: string) {
|
|
assertAuthenticated(req);
|
|
if (req.actor.type === "agent" && req.actor.companyId !== companyId) {
|
|
throw forbidden("Agent key cannot access another company");
|
|
}
|
|
if (req.actor.type === "agent" && req.actor.onBehalfOfUserId?.trim()) {
|
|
const membership = req.actor.onBehalfOfMemberships?.find(
|
|
(item) => item.companyId === companyId && item.status === "active",
|
|
);
|
|
if (!membership) {
|
|
throwOrShadowResponsibleUserCompanyAccessDeny(
|
|
req,
|
|
companyId,
|
|
"RESPONSIBLE_USER_UNAVAILABLE",
|
|
"Responsible user is unavailable for this company",
|
|
);
|
|
return;
|
|
}
|
|
const method = typeof req.method === "string" ? req.method.toUpperCase() : "GET";
|
|
const isSafeMethod = ["GET", "HEAD", "OPTIONS"].includes(method);
|
|
if (!isSafeMethod && membership.membershipRole === "viewer") {
|
|
throwOrShadowResponsibleUserCompanyAccessDeny(
|
|
req,
|
|
companyId,
|
|
"RESPONSIBLE_USER_UNAUTHORIZED",
|
|
"Responsible user is not authorized for write access",
|
|
);
|
|
}
|
|
}
|
|
if (req.actor.type === "board" && req.actor.source !== "local_implicit") {
|
|
const allowedCompanies = req.actor.companyIds ?? [];
|
|
if (!allowedCompanies.includes(companyId)) {
|
|
throw forbidden("User does not have access to this company");
|
|
}
|
|
const method = typeof req.method === "string" ? req.method.toUpperCase() : "GET";
|
|
const isSafeMethod = ["GET", "HEAD", "OPTIONS"].includes(method);
|
|
if (!isSafeMethod && !req.actor.isInstanceAdmin && Array.isArray(req.actor.memberships)) {
|
|
const membership = req.actor.memberships.find((item) => item.companyId === companyId);
|
|
if (!membership || membership.status !== "active") {
|
|
throw forbidden("User does not have active company access");
|
|
}
|
|
if (membership.membershipRole === "viewer") {
|
|
throw forbidden("Viewer access is read-only");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Non-throwing access check for routes that look up a resource by id
|
|
* before responding. Prefer this over `assertCompanyAccess` whenever the
|
|
* route can reach the access check only after a successful `getById`
|
|
* (i.e. after confirming the resource exists).
|
|
*
|
|
* Using `assertCompanyAccess` in that position leaks resource existence
|
|
* across tenants: a 404 means "no such resource" while a 403 means "exists
|
|
* in another tenant". Any authenticated user can enumerate IDs and
|
|
* distinguish the two responses.
|
|
*
|
|
* Most routes should use `getAccessibleResource` below, which wraps the
|
|
* whole pattern. When composing manually (bespoke not-found responses),
|
|
* the shape is:
|
|
*
|
|
* const issue = await svc.getById(id);
|
|
* if (!issue || !hasCompanyAccess(req, issue.companyId)) {
|
|
* res.status(404).json({ error: "Issue not found" });
|
|
* return;
|
|
* }
|
|
*
|
|
* so both "does not exist" and "exists but cross-tenant" return the same
|
|
* 404, removing the oracle.
|
|
*
|
|
* Note: this intentionally does not replicate the write-path membership
|
|
* checks in `assertCompanyAccess` (active membership, viewer read-only).
|
|
* Routes that need those checks for authorized tenants should still call
|
|
* `assertCompanyAccess` after the 404 gate — the oracle concern is only
|
|
* about the existence check.
|
|
*
|
|
* The company-scope semantics must stay in lockstep with
|
|
* `assertCompanyAccess`: in particular, signed-in instance admins do NOT
|
|
* get blanket access to companies they are not a member of.
|
|
*/
|
|
export function hasCompanyAccess(req: Request, companyId: string): boolean {
|
|
if (req.actor.type === "none") return false;
|
|
if (req.actor.type === "agent") return req.actor.companyId === companyId;
|
|
if (req.actor.source === "local_implicit") return true;
|
|
return (req.actor.companyIds ?? []).includes(companyId);
|
|
}
|
|
|
|
/**
|
|
* Preferred way to fetch a company-scoped resource by id inside a route
|
|
* handler. Wraps the two-step pattern described on `hasCompanyAccess` so
|
|
* new routes cannot accidentally reintroduce the existence oracle:
|
|
*
|
|
* - missing resource → 404 `{ error: notFoundMessage }`, returns null
|
|
* - exists but cross-tenant → identical 404, returns null
|
|
* - accessible → runs `assertCompanyAccess` (write-path
|
|
* membership checks on non-safe methods) and returns the resource
|
|
*
|
|
* Usage:
|
|
*
|
|
* const goal = await getAccessibleResource(req, res, svc.getById(id), "Goal not found");
|
|
* if (!goal) return;
|
|
*
|
|
* Routes with bespoke not-found behavior (legacy `200 []` contracts,
|
|
* audit-logged denials) should still compose `hasCompanyAccess` directly.
|
|
*/
|
|
export async function getAccessibleResource<T extends { companyId: string }>(
|
|
req: Request,
|
|
res: Response,
|
|
resource: T | null | undefined | Promise<T | null | undefined>,
|
|
notFoundMessage: string,
|
|
notFoundBody: Record<string, unknown> = { error: notFoundMessage },
|
|
): Promise<T | null> {
|
|
const resolved = await resource;
|
|
if (!resolved || !hasCompanyAccess(req, resolved.companyId)) {
|
|
res.status(404).json(notFoundBody);
|
|
return null;
|
|
}
|
|
assertCompanyAccess(req, resolved.companyId);
|
|
return resolved;
|
|
}
|
|
|
|
export function getActorInfo(req: Request): (
|
|
{
|
|
actorType: "agent";
|
|
actorId: string;
|
|
agentId: string | null;
|
|
runId: string | null;
|
|
agentApiKeyId: string | null;
|
|
actorSource: "agent_key" | "agent_jwt";
|
|
}
|
|
| {
|
|
actorType: "user";
|
|
actorId: string;
|
|
sessionId: string | null;
|
|
agentId: null;
|
|
runId: string | null;
|
|
agentApiKeyId: null;
|
|
actorSource: "local_implicit" | "session" | "board_key" | "mcp_oauth" | "cloud_tenant";
|
|
}
|
|
) {
|
|
assertAuthenticated(req);
|
|
if (req.actor.type === "agent") {
|
|
const actorSource = req.actor.source === "agent_jwt" ? "agent_jwt" : "agent_key";
|
|
return {
|
|
actorType: "agent" as const,
|
|
actorId: req.actor.agentId ?? "unknown-agent",
|
|
agentId: req.actor.agentId ?? null,
|
|
runId: req.actor.runId ?? null,
|
|
agentApiKeyId: req.actor.keyId ?? null,
|
|
actorSource,
|
|
};
|
|
}
|
|
|
|
const actorSource =
|
|
req.actor.source === "local_implicit" ||
|
|
req.actor.source === "board_key" ||
|
|
req.actor.source === "cloud_tenant" ||
|
|
req.actor.source === "mcp_oauth"
|
|
? req.actor.source
|
|
: "session";
|
|
|
|
return {
|
|
actorType: "user" as const,
|
|
actorId: req.actor.userId ?? "board",
|
|
sessionId: req.actor.sessionId ?? null,
|
|
agentId: null,
|
|
runId: req.actor.runId ?? null,
|
|
agentApiKeyId: null,
|
|
actorSource,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The actor-scoped fields of a secret-binding context, keyed to a caller-supplied
|
|
* consumer identity. Structurally matches `SecretConsumerContext` in
|
|
* `services/secrets.ts` (whose types are not exported), so the return value slots
|
|
* into `resolveAdapterConfigForRuntime`'s 3rd argument
|
|
* (`Omit<SecretBindingContext, "configPath">`) unchanged.
|
|
*/
|
|
export type ActorSecretContext = {
|
|
consumerType: SecretBindingTargetType;
|
|
consumerId: string;
|
|
actorType: "agent" | "user";
|
|
actorId: string | null;
|
|
actorSource: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "mcp_oauth" | "cloud_tenant";
|
|
responsibleUserId: string | null;
|
|
};
|
|
|
|
/**
|
|
* Build the actor-scoped portion of a secret-binding context from `req.actor`,
|
|
* taking the consumer identity as parameters. The responsible user is derived
|
|
* server-side (`req.actor.userId ?? req.actor.onBehalfOfUserId ?? null`) and is
|
|
* never request-body-controllable; a `null` result surfaces downstream as the
|
|
* intended `responsible_user_missing` loud failure for a required user secret.
|
|
*
|
|
* `consumerType` is a parameter (not hardcoded `"agent"`) so callers can record an
|
|
* honest consumer — `agent` for a persisted agent, `environment`/`system` for a
|
|
* prospective config with no persisted consumer.
|
|
*
|
|
* Never sets `configPath` (the resolver injects it) or `allowedBindingIds`.
|
|
*/
|
|
export function buildActorSecretContext(
|
|
req: Request,
|
|
params: { consumerType: SecretBindingTargetType; consumerId: string },
|
|
): ActorSecretContext {
|
|
const info = getActorInfo(req);
|
|
return {
|
|
consumerType: params.consumerType,
|
|
consumerId: params.consumerId,
|
|
actorType: info.actorType,
|
|
actorId: info.actorId,
|
|
actorSource: info.actorSource,
|
|
responsibleUserId: req.actor.userId ?? req.actor.onBehalfOfUserId ?? null,
|
|
};
|
|
}
|