diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2caa61f2b..401c48a27 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,19 +41,19 @@ individual key provenance. Projects and keys are database-owned, with no static business keys or configuration synchronization. Revocation affects one key; archiving a Project revokes all its keys, retaining assets and admitted execution. Do not add Core users, roles, memberships or cross-Project sharing. Management -provides safe reads, public deletion preconditions, explicit hosted Session archive, -independent copies and Project and key operations; it cannot execute or edit arbitrary assets. Keep administrator -target scope separate from caller principals. See [design principles](docs/design-principles.md) and the +provides safe reads, public deletion preconditions, explicit hosted Session archive +and Project and key operations; it cannot copy, execute or edit arbitrary assets. +Keep administrator target scope separate from caller principals. See +[design principles](docs/design-principles.md) and the [administrator contract](contracts/agents-api/admin-api.md). Administrator writes and their audit record share one PostgreSQL transaction. -Reuse existing resource deletion and serialization code. Copies rebind encrypted -content and rewrite included dependencies inside the same transaction, including -large objects, `admin_asset_copies` retry receipts and `admin_resource_owners` -creation anchors. Never call separately committing resource creators from a copy. -These anchors identify administrator copies even when API-key provenance is null; -unknown historical provenance remains unknown. No secrets or request bodies enter -logs. A forwarded console actor name is only a label, never an authorization input. +Reuse existing resource deletion and serialization code. Cross-Project copying was +removed. `admin_resource_owners` alone feeds the historical `source:"admin_copy"` +provenance read; `admin_asset_copies` is retained only because tables are not +dropped. Unknown historical provenance remains unknown. No secrets or request +bodies enter logs. A forwarded console actor name is only a label, never an +authorization input. When requirements conflict, object ownership is unclear, or a design would need parallel compatibility paths, raise the issue with a concrete recommendation and diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 009e82007..9e45a51b5 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -122,7 +122,7 @@ compatibility. | Extension | Operations | Current coverage | | --- | --- | --- | -| Administration | `/core/v1/admin/**` | Deployment-authenticated Projects with shared keys, read/delete projections, atomic asset copies, summary, runtime observations and audit. Separate from caller authority; see [Administrator API](admin-api.md). | +| Administration | `/core/v1/admin/**` | Deployment-authenticated Projects with shared keys, read/delete projections, summary, runtime observations and audit. Separate from caller authority; see [Administrator API](admin-api.md). | | API-key provenance | Project-scoped `/core/v1/admin/projects/{project_id}/resource-owners` and `/write-operations` | Batch ownership and retained cursor-paginated write history; see [write provenance](write-audit.md). | | Runtime observations | `GET /v1/agents/runtime-observations`; `GET /v1/agents/sessions/{session_id}/runtime-observation` | Current, read-only, tenant-scoped Session contexts with stable Session-keyset pagination, bounded concurrent sampling, Docker and microsandbox metrics, explicit unsupported/unavailable states, strict `packages/agents-client` projection, and no lifecycle mutation. Kubernetes, E2B, self-hosted telemetry, and automatic idle policy remain unimplemented. See [Runtime observation API](runtime-observability-api.md). | | Runtime history | `GET /v1/agents/runtime-history/capabilities`; `GET /v1/agents/sessions/{session_id}/runtime-history` | Optional backend-neutral capability and bounded tenant/Session-scoped history contract with allocation/incarnation fencing, explicit coverage and strict client projection. Disabled by default until a production Reader and qualified periodic collection are configured; Durable Web rendering remains pending. See [Runtime history API](runtime-history-api.md). | diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index feab614d4..7cec9d430 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -38,9 +38,9 @@ must match those values. All keys share subject `service_account/project:` Rotate by issuing a new key in the same Project and revoking the old one. Revoking one key leaves other keys, assets and admitted work intact. Archive atomically marks the Project archived, revokes all its keys and records audit. Archived -Projects cannot issue keys or receive copies; their assets remain available for -administrator inspection, deletion and copying to another active Project. There -is no Project deletion, unarchive, key reset or automatic write retry operation. +Projects cannot issue keys; their assets remain available for administrator +inspection and deletion. There is no Project deletion, unarchive, key reset or +automatic write retry operation. After an uncertain issuance response, inspect metadata and explicitly revoke any unusable key before issuing another; plaintext cannot be recovered. @@ -106,39 +106,12 @@ and pending counts are zero before changing provider, resources or Runtime. Snapshots and uncertain cleanup remain blockers. This is not a deployment-wide bulk operation, Session migration or public Session deletion. -## Copies - -`POST /copies` takes `source_project_id`, `target_project_id`, `resource_type`, `resource_id`, -`include_dependencies`, and optional `target_vault_id` for a standalone Credential. -Different source and target Projects are required. An archived target returns 409, -including retries of an earlier copy; an archived source remains readable. `Idempotency-Key` makes identical -retries return the committed result; a changed request conflicts. Without the -header a separate request may create another copy; clients must not retry an -uncertain copy automatically. - -Response: `{mappings: [{type, source_id, target_id}], skipped: [{type, source_id, -reason}]}`. Supported types are `agent`, `skill`, `environment_template`, `file`, -`vault`, and `credential`. - -- Agent configuration and model credentials are copied with new encryption - bindings. Included referenced Vaults and supported Credentials receive new IDs. - Uncopied/skipped MCP Credential references become null. Runtime matching still - requires explicitly attached target Vaults. -- Skills retain every existing version number and their default/latest pointers; - encrypted bundles are rebound to the new tenant and identifiers. -- Templates retain confidential env/setup, files, packages, inline Skills/Plugins, - network and directory settings. Referenced files/Skills require - `include_dependencies=true` and are rewritten to copied IDs. -- Files copy actual PostgreSQL large-object bytes, up to the existing 512 MiB limit. -- A Vault copies its allowed Credentials. `static_bearer` and non-refreshing - `mcp_oauth` values are decrypted/re-encrypted internally. Refreshable OAuth - credentials appear in `skipped`, with no usable copied token. -- Sessions and Artifacts are not copyable. - -One transaction owns all mutations, the idempotency receipt and administrator -log. A failure rolls back every dependency and large object. Copies have -`api_key:null`, `source:"admin_copy"`, and `admin_audit_id` in resource ownership; -historical unknown resources have null source and audit ID. +## Historical copy provenance + +Cross-Project asset copying has been removed; no route creates copies. Resources +copied before the removal keep `api_key:null`, `source:"admin_copy"` and their +`admin_audit_id` in resource ownership, and their `copy` audit entries keep their +`result_ids` mappings. Historical unknown resources have null source and audit ID. ## Monitoring and audit @@ -165,8 +138,6 @@ Each `observation` is the project Runtime observation plus `disk: reported disk usage and capacity, Docker returns null, and microsandbox returns null until its disk semantics are designed. The project-scoped observation routes, including the per-Session administrator read, keep their shape. -`GET /runtime-history/capabilities` and `GET /startup-configuration` reuse the -existing non-secret project projections. `GET /audit-log` lists administrator writes newest first with `project_id`, `resource_type`, `resource_id`, `action`, inclusive `created_after`, exclusive @@ -174,9 +145,9 @@ existing non-secret project projections. is `{data, has_more, next_cursor}`. Each row has `id`, `created_at`, `admin_credential_id` (credential digest prefix), `actor_label`, `action`, `project_id`, `resource_type`, `resource_id`, `result_ids`, `request_id`, -`trace_id`. Non-copy mappings are an empty array. No credential values or request -bodies are recorded. Logs and copy ownership do not cascade away on resource -removal or key revocation. +`trace_id`. `result_ids` is an empty array except on historical `copy` entries. +No credential values or request bodies are recorded. Logs and historical copy +ownership do not cascade away on resource removal or key revocation. ## Private installation transition diff --git a/contracts/agents-api/runtime-history-api.md b/contracts/agents-api/runtime-history-api.md index aba2c10a5..fcaaf339f 100644 --- a/contracts/agents-api/runtime-history-api.md +++ b/contracts/agents-api/runtime-history-api.md @@ -125,9 +125,10 @@ public Session usage, which is null while a root Turn runs or after one ends unmeasured ([item serialization](history-events-usage.md#item-serialization-2026-09-23)). These counters are measured model usage, not price, cost, or billing records. -Core Web queries each current managed Session through this boundary with bounded -concurrency and an all-or-nothing target budget. It offers 1h, 6h, and 24h History -ranges only after capability discovery succeeds. Reloading Web reconstructs the +Core Web queries each current managed Session through the administrator Session +route with bounded concurrency and an all-or-nothing target budget, offering 1h, +6h, and 24h History ranges. Capability discovery exists only on the application +route above; the administrator API has none. Reloading Web reconstructs the charts from the backend; Live browser samples and Durable buckets remain explicit separate sources and are never silently merged. diff --git a/contracts/agents-api/sandbox-manager.openapi.yaml b/contracts/agents-api/sandbox-manager.openapi.yaml index 2f4fd86d9..99103955a 100644 --- a/contracts/agents-api/sandbox-manager.openapi.yaml +++ b/contracts/agents-api/sandbox-manager.openapi.yaml @@ -1,20 +1,5 @@ basePath: / definitions: - api.AdminCopyRequest: - properties: - include_dependencies: - type: boolean - resource_id: - type: string - resource_type: - type: string - source_project_id: - type: string - target_project_id: - type: string - target_vault_id: - type: string - type: object api.AdminRuntimeObservation: properties: observation: @@ -589,24 +574,6 @@ definitions: next_cursor: type: string type: object - store.AssetCopyMapping: - properties: - source_id: - type: string - target_id: - type: string - type: - type: string - type: object - store.AssetCopySkipped: - properties: - reason: - type: string - source_id: - type: string - type: - type: string - type: object store.AuditAPIKey: properties: id: @@ -620,17 +587,6 @@ definitions: revoked_at: type: string type: object - store.CopyAssetsResult: - properties: - mappings: - items: - $ref: '#/definitions/store.AssetCopyMapping' - type: array - skipped: - items: - $ref: '#/definitions/store.AssetCopySkipped' - type: array - type: object store.IssuedExecutorCredential: properties: environment_id: @@ -1101,112 +1057,6 @@ definitions: required: - harness type: object - v1.CoreConfiguredStartupConfiguration: - properties: - daemon_gateway: - type: boolean - default_harness: - enum: - - codex - - claude_sdk - - mcode - type: string - enabled_harnesses: - items: - enum: - - claude_sdk - - codex - - mcode - type: string - type: array - managed_sandbox: - $ref: '#/definitions/v1.CoreManagedSandboxConfiguration' - model_providers: - items: - $ref: '#/definitions/v1.CoreHarnessModelProviderConfiguration' - type: array - self_hosted: - type: boolean - required: - - daemon_gateway - - default_harness - - enabled_harnesses - - managed_sandbox - - model_providers - - self_hosted - type: object - v1.CoreHarnessModelProviderConfiguration: - properties: - endpoint_configured: - type: boolean - harness: - enum: - - codex - - claude_sdk - - mcode - type: string - required: - - endpoint_configured - - harness - type: object - v1.CoreManagedSandboxConfiguration: - properties: - enabled: - type: boolean - maintenance: - type: boolean - provider: - enum: - - docker - - microsandbox - type: string - x-nullable: true - required: - - enabled - - maintenance - - provider - type: object - v1.CoreStartupConfiguration: - properties: - configured: - $ref: '#/definitions/v1.CoreConfiguredStartupConfiguration' - object: - enum: - - agents.core.startup_configuration - type: string - schema_version: - enum: - - 1 - type: integer - supported: - $ref: '#/definitions/v1.CoreSupportedConfiguration' - required: - - configured - - object - - schema_version - - supported - type: object - v1.CoreSupportedConfiguration: - properties: - harnesses: - items: - enum: - - claude_sdk - - codex - - mcode - type: string - type: array - managed_sandbox_providers: - items: - enum: - - docker - - microsandbox - type: string - type: array - required: - - harnesses - - managed_sandbox_providers - type: object v1.Credential: properties: auth: @@ -1778,72 +1628,6 @@ definitions: - contributor_count - utilization_ratio type: object - v1.RuntimeHistoryCapabilities: - properties: - available: - type: boolean - collection_mode: - enum: - - on_read - - periodic - type: string - x-nullable: true - maximum_points: - maximum: 10000 - minimum: 2 - type: integer - x-nullable: true - maximum_range_seconds: - maximum: 9007199254740991 - minimum: 1 - type: integer - x-nullable: true - metrics: - items: - enum: - - cpu - - memory - - tokens - type: string - maxItems: 3 - type: array - minimum_step_seconds: - maximum: 9007199254740991 - minimum: 1 - type: integer - x-nullable: true - object: - enum: - - agent.runtime_history_capabilities - type: string - reason: - enum: - - not_configured - - periodic_collection_required - type: string - x-nullable: true - retention_seconds: - maximum: 9007199254740991 - minimum: 1 - type: integer - x-nullable: true - sample_interval_seconds: - maximum: 9007199254740991 - minimum: 1 - type: integer - x-nullable: true - required: - - available - - collection_mode - - maximum_points - - maximum_range_seconds - - metrics - - minimum_step_seconds - - object - - reason - - retention_seconds - - sample_interval_seconds - type: object v1.RuntimeHistoryCoverage: properties: buckets: @@ -3070,54 +2854,6 @@ paths: summary: Query committed administrator mutations tags: - Core Administration - /core/v1/admin/copies: - post: - consumes: - - application/json - description: Deployment administrator only. Copies content and allowed secrets inside one transaction, rewrites selected dependencies and records administrator provenance. Refreshable OAuth credentials are skipped. Sessions and Artifacts cannot be copied. Reusing an Idempotency-Key with changed input returns conflict. - parameters: - - description: Copy retry identity, up to 128 characters - in: header - name: Idempotency-Key - type: string - - description: Source and target Projects and resource - in: body - name: body - required: true - schema: - $ref: '#/definitions/api.AdminCopyRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/store.CopyAssetsResult' - "400": - description: Bad Request - schema: - $ref: '#/definitions/v1.ErrorResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/v1.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/v1.ErrorResponse' - "409": - description: Conflict - schema: - $ref: '#/definitions/v1.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/v1.ErrorResponse' - security: - - DeploymentAdminAuth: [] - summary: Copy assets into another Project - tags: - - Core Administration /core/v1/admin/core-metrics: get: description: Deployment administrator only. Complete UTC buckets; unknown measurements are null. Samples are process-local and are not backfilled after a restart. @@ -5390,24 +5126,6 @@ paths: summary: Query API-key write operations tags: - Write Audit - /core/v1/admin/runtime-history/capabilities: - get: - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/v1.RuntimeHistoryCapabilities' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/v1.ErrorResponse' - security: - - DeploymentAdminAuth: [] - summary: Retrieve durable Runtime history capabilities - tags: - - Core Administration /core/v1/admin/runtime-observations: get: description: Deployment administrator only. Each observation is labelled with its owning Project ID. Uses the existing read-only Runtime sampler, with bounded concurrency and no execution or provisioning. A provider with a batch metrics read, such as E2B, samples the page's running sandboxes in one bounded request. @@ -5463,29 +5181,6 @@ paths: summary: List Runtime observations across managed Projects tags: - Core Administration - /core/v1/admin/startup-configuration: - get: - description: Deployment administrator only. Returns the same non-secret configuration metadata as the project extension. - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/v1.CoreStartupConfiguration' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/v1.ErrorResponse' - "503": - description: Service Unavailable - schema: - $ref: '#/definitions/v1.ErrorResponse' - security: - - DeploymentAdminAuth: [] - summary: Retrieve deployment startup metadata - tags: - - Core Administration /core/v1/admin/summary: get: description: Administrator only. after/limit/order paginate Projects. Agent grouping returns groups within those spaces. Date bounds filter Session creation, not current asset counts. Usage sums only non-null public Session usage; coverage includes every selected Session. Each Project is read in a consistent database snapshot. Totals are not billing records. diff --git a/contracts/agents-api/write-audit.md b/contracts/agents-api/write-audit.md index 7d5121c23..3221210cf 100644 --- a/contracts/agents-api/write-audit.md +++ b/contracts/agents-api/write-audit.md @@ -90,9 +90,9 @@ routes. ]} ``` -Administrator copies have `api_key:null`, `source:"admin_copy"` and a non-null -`admin_audit_id`. Their ownership anchors and audit mappings commit with the copy. -They do not fabricate a public API-key write. See [administrator operations](admin-api.md). +Resources created by the removed administrator copy operation keep +`api_key:null`, `source:"admin_copy"` and a non-null `admin_audit_id`. They never +had a public API-key write. See [historical copy provenance](admin-api.md#historical-copy-provenance). ### Operations diff --git a/docs/api/README.md b/docs/api/README.md index ec84800d3..3c242c591 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -36,8 +36,8 @@ when given an application key or machine credential. generated `/api/v1` sandbox node routes. The node WebSocket and the private daemon transport are described in the node and Runtime credential guides. - [Administrator contract](../../contracts/agents-api/admin-api.md): Project/key - lifecycle, resources, explicit hosted Session archive, copying, summary, - errors/deletion preconditions and audit. + lifecycle, resources, explicit hosted Session archive, summary, + errors/deletion preconditions, audit and historical copy provenance. - [Web integration](web-management.md): browser/console/Core boundaries and frontend handoff. - [Design rules](../design-principles.md) and [contributor guide](../../CONTRIBUTING.md): ownership, security and change requirements. diff --git a/docs/api/web-management.md b/docs/api/web-management.md index 3618606ba..374ec2385 100644 --- a/docs/api/web-management.md +++ b/docs/api/web-management.md @@ -1,8 +1,8 @@ # Web management API Core Web is an administrator console. Resource inspection, Project/key management, -explicit cross-Project copies, audit, usage and sandbox operations use management -authority. The console has no Agent execution or arbitrary asset editing operation. +audit, usage and sandbox operations use management authority. The console has no +Agent execution, copy or arbitrary asset editing operation. ## Browser to console @@ -32,7 +32,7 @@ Core treats that name as an audit display label, not an authorization input. Use [AdminClient](../../packages/agents-client/src/admin-client.ts) for the typed management client and [the complete administrator reference](../../contracts/agents-api/admin-api.md) -for methods, fields, filters, pagination, copy rules and response shapes. +for methods, fields, filters, pagination and response shapes. Routes below are relative to `/core/v1/admin`: | Workflow | Routes | @@ -42,8 +42,7 @@ Routes below are relative to `/core/v1/admin`: | Resource lists/details/deletion | `/projects/{id}/agents`, `/sessions`, `/environment-templates`, `/skills`, `/files`, `/vaults`, including the documented nested reads | | Asset ownership | `GET /projects/{id}/resource-owners` with batched resource IDs | | Key operation history | `GET /projects/{id}/write-operations` with key/resource/time filters | -| Copies | `POST /copies`, explicit source/target Project IDs and optional dependencies | -| Usage and health | `GET /summary`, `/runtime-observations`, `/startup-configuration`, `/runtime-history/capabilities` | +| Usage and health | `GET /summary`, `/runtime-observations` | | Administrator audit | `GET /audit-log` | A Project UUID in a management path selects the target; it is not a credential. diff --git a/docs/design-principles.md b/docs/design-principles.md index 78db0efa0..788483ff5 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -30,8 +30,8 @@ management API use the same records. Configuration contains deployment settings, not application credentials or Project definitions. Issue a new key in the same Project and revoke the previous one to rotate credentials. Revocation affects only that key and preserves assets, provenance and accepted execution. Explicit Project -archive revokes every key and blocks new issuance and copies into that Project. -Administrators can still inspect, delete and copy from archived Projects. +archive revokes every key and blocks new issuance in that Project. +Administrators can still inspect and delete resources in archived Projects. Store only API-key digests and necessary metadata. Return plaintext once, on issuance, and never replay it after an uncertain response. Project/key mutations @@ -49,24 +49,11 @@ existing public operation's rules. Deployment nodes, configured model endpoints and startup settings are deployment infrastructure, not shared business assets. Administrators can inspect resources and execution history, delete resources under -the same rules as their public deletion operations, copy supported assets between -Projects, manage Projects and API keys, and query operational counts and usage. They cannot use -management endpoints to create or edit arbitrary assets, start a Session, send an -event, cancel work, or read stored credentials. Copying is an explicit exception -to owner-only asset creation, with its own audit and provenance. - -## Shared within a Project, copied across Projects - -Project keys already share all assets. A cross-Project copy receives new resource IDs and has no continuing link to its source. Core -rewrites selected dependency IDs and decrypts/re-encrypts protected values for the -new tenant and resource bindings internally. Content, dependencies, large objects, -idempotency results and administrator audit commit in one database transaction. -Refreshable OAuth credentials are skipped because two copies can invalidate each -other's rotating refresh token. Sessions and Artifacts cannot be copied. - -Copied MCP credentials do not gain automatic visibility. A Session still searches -only its explicitly attached Vaults; copying an Agent never introduces a global -credential lookup or an implicit execution privilege. +the same rules as their public deletion operations, manage Projects and API keys, +and query operational counts and usage. They cannot use management endpoints to +create, copy or edit arbitrary assets, start a Session, send an event, cancel +work, or read stored credentials. Project keys share all assets within their +Project; nothing is shared or copied across Projects. ## Secrets and evidence @@ -79,9 +66,9 @@ who inspect those records see their recorded contents. Public writes retain their actual API-key provenance. Administrator writes retain a separate audit identity and target Project. The console account name is a label; Core trusts the deployment credential, not that forwarded name. Audit failure rolls -back the business transaction. Reads are not audited. Copied-resource ownership is -explicitly distinguished from historical unknown ownership. No request bodies, -secrets or file contents enter audit records. +back the business transaction. Reads are not audited. Resources from the removed +copy operation keep their `admin_copy` ownership, distinct from historical unknown +ownership. No request bodies, secrets or file contents enter audit records. Do not add product users, RBAC, cross-Project shared assets, administrator execution, or old private-protocol compatibility to this management model. Existing public diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 6188b2bb7..ae4cd296d 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -87,10 +87,10 @@ it and save the one-time plaintext response privately. Core stores only its dige The corresponding Web management screens remain pending. Multiple keys in a Project share its assets and execution principal; writes record the actual key separately. Rotate by issuing another key in that Project and revoking the old one. Archiving -the Project disables all its keys and retains assets for inspection, deletion or -copying to another active Project. API callers use their own keys and the public -API endpoint. The deployment credential cannot call `/v1`; the console cannot -execute or create Agent resources on their behalf. See the [management contract](../../contracts/agents-api/admin-api.md). +the Project disables all its keys and retains assets for inspection and deletion. +API callers use their own keys and the public API endpoint. The deployment +credential cannot call `/v1`; the console cannot execute or create Agent resources +on their behalf. See the [management contract](../../contracts/agents-api/admin-api.md). Hosts registered through the console supply sandbox resources for hosted Sessions; self-hosted Sessions use application-managed environments. Neither installation diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index c5513a2a2..97ef2e1bf 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -83,8 +83,7 @@ To rotate, issue another key within the same Project, update the application, th revoke the old key. Renaming a Project or revoking a key preserves its assets and execution principal. Archiving a Project disables all its keys while retaining assets and already accepted execution. Administrators may inspect or delete retained -resources, or copy supported assets to an active Project; they cannot execute them -using the deployment credential. +resources; they cannot execute them using the deployment credential. ## Data and upgrades diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index a8ba98f00..b0d335851 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -91,10 +91,7 @@ Resource-specific boundaries: Summary figures are cumulative per Session and are not billing records. Sessions without reported usage count toward coverage but not toward token sums, and the console shows missing values as missing, never as zero. The administrator audit -log (`GET /audit-log`), Runtime history capabilities -(`GET /runtime-history/capabilities`) and startup configuration -(`GET /startup-configuration`) are not consumed; System shows the sandbox -deployment only. +log (`GET /audit-log`) is not consumed; System shows the sandbox deployment only. ## Sandbox administration diff --git a/packages/agents-client/src/admin-client.test.ts b/packages/agents-client/src/admin-client.test.ts index cd161dffe..f72987d6b 100644 --- a/packages/agents-client/src/admin-client.test.ts +++ b/packages/agents-client/src/admin-client.test.ts @@ -31,8 +31,6 @@ const routeCases: Array<[string, string, (client: AdminClient) => Promise client.listAPIKeys(projectId)], ["POST", `/projects/${projectId}/keys`, (client) => client.issueAPIKey(projectId, { name: "SDK" })], ["DELETE", `/projects/${projectId}/keys/${keyId}`, (client) => client.revokeAPIKey(projectId, keyId)], - ["GET", "/runtime-history/capabilities", (client) => client.getRuntimeHistoryCapabilities()], - ["GET", "/startup-configuration", (client) => client.retrieveStartupConfiguration()], ["GET", "/audit-log", (client) => client.listAuditLog()], ["GET", "/summary", (client) => client.retrieveSummary()], ["GET", "/runtime-observations", (client) => client.listRuntimeObservations()], @@ -137,18 +135,6 @@ describe("AdminClient response contracts", () => { expect(await clientWith({ id: keyId, deleted: true }).client.revokeAPIKey(projectId, keyId)).toEqual({ id: keyId, deleted: true }); }); - it("sends copy idempotency once and projects only safe mappings", async () => { - const result = { mappings: [{ type: "credential", source_id: "old", target_id: "new" }], skipped: [{ type: "credential", source_id: "oauth", reason: "refresh" }] }; - const { client, fetch } = clientWith(result); - const input = { source_project_id: projectId, target_project_id: sessionId, resource_type: "credential" as const, resource_id: "old", include_dependencies: true, target_vault_id: resourceId }; - expect(await client.copyResources(input, { idempotencyKey: "copy-1" })).toEqual(result); - expect(fetch).toHaveBeenCalledOnce(); - expect(fetch.mock.calls[0]![0]).toBe("/core/v1/admin/copies"); - expect(new Headers(fetch.mock.calls[0]![1]?.headers).get("Idempotency-Key")).toBe("copy-1"); - expect(JSON.parse(fetch.mock.calls[0]![1]!.body as string)).toEqual(input); - await expect(clientWith({ ...result, token: "leak" }).client.copyResources(input)).rejects.toMatchObject({ code: "invalid_admin_response" }); - }); - it("reuses Vault metadata validation including write-only credential rejection", async () => { const credential = { id: resourceId, vault_id: sessionId, object: "vault.credential", name: "MCP", created_at: 1, updated_at: 1, auth: { type: "static_bearer", mcp_server_url: "https://mcp.test" } }; const { client } = clientWith(credential); @@ -211,7 +197,7 @@ describe("AdminClient deployment read models", () => { await expect(clientWith({ ...summary, data: [{ ...summary.data[0], coverage: { measured_sessions: 3, total_sessions: 2, ratio: 1.5 } }] }).client.retrieveSummary()).rejects.toBeInstanceOf(AgentCoreError); }); - it("validates administrator copy provenance and safe audit mappings", async () => { + it("validates historical copy provenance and safe audit mappings", async () => { const owners = { data: [{ resource_id: "a", api_key: null, source: "admin_copy", admin_audit_id: "audit" }] }; expect(await clientWith(owners).client.retrieveResourceOwners(projectId, "agent", ["a"])).toEqual(owners); const audit = { data: [{ id: "audit", created_at: "2026-09-24T00:00:00Z", admin_credential_id: "digest", actor_label: "admin", action: "copy", project_id: projectId, resource_type: "agent", resource_id: "a", result_ids: [{ type: "agent", source_id: "a", target_id: "b" }], request_id: "request", trace_id: "trace" }], has_more: false, next_cursor: "" }; diff --git a/packages/agents-client/src/admin-client.ts b/packages/agents-client/src/admin-client.ts index b3023c73c..799d7dd7b 100644 --- a/packages/agents-client/src/admin-client.ts +++ b/packages/agents-client/src/admin-client.ts @@ -1,19 +1,19 @@ import { - AgentCoreError, addVaultPageOptions, projectStartupConfiguration, projectAgentSession, projectRuntimeObservation, + AgentCoreError, addVaultPageOptions, projectAgentSession, projectRuntimeObservation, projectEnvironmentTemplate, projectEnvironmentTemplateList, projectVault, projectVaultList, projectVaultCredential, projectVaultCredentialList, projectSourceFile, projectSourceFileDeleted, } from "./client"; import { projectExecutionConfiguration } from "./execution-configuration-projection"; import { projectAgentTurn, projectSessionItem, projectHistoryPage, validateHistoryPageOptions } from "./history-projection"; -import { projectRuntimeHistory, projectRuntimeHistoryCapabilities } from "./runtime-history-projection"; +import { projectRuntimeHistory } from "./runtime-history-projection"; import { canonicalUuid, isNonnegativeInteger, isRecord } from "./response-projection"; import { invalidAdminResponse, projectAdminProject, projectAdminKey, projectIssuedAdminKey, projectAdminPage, projectAdminDeleted, projectAdminSessionArchive, - projectResourcePage, projectSavedAgent, projectSkill, projectSkillVersion, projectArtifact, projectCopyResult, projectSummary, projectAdminRuntimePage, projectResourceOwners, projectWriteOperations, projectAdminAudit, + projectResourcePage, projectSavedAgent, projectSkill, projectSkillVersion, projectArtifact, projectSummary, projectAdminRuntimePage, projectResourceOwners, projectWriteOperations, projectAdminAudit, } from "./admin-projection"; import type { PageOptions, ReadOptions, RuntimeHistoryQuery, VaultListOptions } from "./types"; import type { - AdminClientOptions, AdminAuditOptions, AdminCopyInput, AdminContent, AdminWriteOptions, ArchiveAdminSessionInput, CreateAdminProjectInput, RenameAdminProjectInput, + AdminClientOptions, AdminAuditOptions, AdminContent, ArchiveAdminSessionInput, CreateAdminProjectInput, RenameAdminProjectInput, IssueAdminAPIKeyInput, AdminSummaryOptions, AdminResourceType, AdminResourceOwner, AdminWriteOperationOptions, AdminWriteOperationPage, } from "./admin-types"; @@ -51,15 +51,11 @@ export class AdminClient { this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis); } - async #response(path: string, options?: ReadOptions, method = "GET", body?: unknown, idempotencyKey?: string): Promise { + async #response(path: string, options?: ReadOptions, method = "GET", body?: unknown): Promise { const headers = new Headers({ Accept: "application/json" }); const token = typeof this.#adminToken === "function" ? this.#adminToken() : this.#adminToken; if (token) headers.set("Authorization", `Bearer ${token}`); if (body !== undefined) headers.set("Content-Type", "application/json"); - if (idempotencyKey !== undefined) { - if (!idempotencyKey.trim()) throw new TypeError("An Idempotency-Key must not be empty."); - headers.set("Idempotency-Key", idempotencyKey); - } const response = await this.#fetch(`${this.#baseUrl}${path}`, { method, headers, signal: options?.signal, credentials: "same-origin", redirect: "error", ...(body === undefined ? {} : { body: JSON.stringify(body) }), @@ -78,8 +74,8 @@ export class AdminClient { } return response; } - async #json(path: string, options?: ReadOptions, method?: string, body?: unknown, idempotencyKey?: string): Promise { - const response = await this.#response(path, options, method, body, idempotencyKey); + async #json(path: string, options?: ReadOptions, method?: string, body?: unknown): Promise { + const response = await this.#response(path, options, method, body); try { return await response.json(); } catch { return invalidAdminResponse(); } } async #delete(path: string, id: string, object: O, options?: ReadOptions) { @@ -113,14 +109,6 @@ export class AdminClient { if (!isRecord(value) || Object.keys(value).length !== 2 || value.id !== keyId || value.deleted !== true) return invalidAdminResponse(); return { id: keyId, deleted: true }; } - async copyResources(input: AdminCopyInput, options?: AdminWriteOptions) { - const body = { - source_project_id: input.source_project_id, target_project_id: input.target_project_id, - resource_type: input.resource_type, resource_id: input.resource_id, include_dependencies: input.include_dependencies, - ...(input.target_vault_id === undefined ? {} : { target_vault_id: input.target_vault_id }), - }; - return projectCopyResult(await this.#json("/copies", options, "POST", body, options?.idempotencyKey)); - } async retrieveSummary(options?: AdminSummaryOptions) { const path = pageQuery("/summary", options, { project_id: options?.project_id, group_by: options?.group_by, @@ -131,12 +119,6 @@ export class AdminClient { async listRuntimeObservations(options?: PageOptions) { return projectAdminRuntimePage(await this.#json(pageQuery("/runtime-observations", options), options)); } - async getRuntimeHistoryCapabilities(options?: ReadOptions) { - return projectRuntimeHistoryCapabilities(await this.#json("/runtime-history/capabilities", options), invalidAdminResponse); - } - async retrieveStartupConfiguration(options?: ReadOptions) { - return projectStartupConfiguration(await this.#json("/startup-configuration", options)); - } async listAgents(projectId: string, options?: PageOptions) { return projectResourcePage(await this.#json(pageQuery(`${scope(projectId)}/agents`, options), options), (value) => projectSavedAgent(value)); diff --git a/packages/agents-client/src/admin-projection.ts b/packages/agents-client/src/admin-projection.ts index 00ff207cd..ede8c0220 100644 --- a/packages/agents-client/src/admin-projection.ts +++ b/packages/agents-client/src/admin-projection.ts @@ -2,7 +2,7 @@ import { AgentCoreError, projectAgentSnapshot, projectRuntimeObservation } from import { projectTokenUsage } from "./usage-projection"; import { canonicalUuid, exactFields, isNonnegativeInteger, isRecord, sameResourceId } from "./response-projection"; import type { ListPage, SavedAgent } from "./types"; -import type { AdminAPIKey, AdminProject, AdminAuditPage, AdminSummary, AdminRuntimeObservation, RuntimeDiskObservation, AdminKeyProvenance, AdminResourceOwner, AdminWriteOperationPage, AdminCopyResult, AdminDeleted, AdminIssuedAPIKey, AdminPage, AdminSessionArchive, SessionArtifact, Skill, SkillVersion } from "./admin-types"; +import type { AdminAPIKey, AdminProject, AdminAuditPage, AdminSummary, AdminRuntimeObservation, RuntimeDiskObservation, AdminKeyProvenance, AdminResourceOwner, AdminWriteOperationPage, AdminAuditResultID, AdminDeleted, AdminIssuedAPIKey, AdminPage, AdminSessionArchive, SessionArtifact, Skill, SkillVersion } from "./admin-types"; export function invalidAdminResponse(): never { throw new AgentCoreError("Core returned an invalid administration response.", 502, "invalid_admin_response"); @@ -94,17 +94,16 @@ export function projectArtifact(value: unknown, sessionId: string, expectedId?: !sameResourceId(artifact.session_id as string, sessionId) || (expectedId !== undefined && !sameResourceId(artifact.id as string, expectedId))) return invalidAdminResponse(); return { ...artifact } as unknown as SessionArtifact; } -export function projectCopyResult(value: unknown): AdminCopyResult { - const result = record(value, ["mappings", "skipped"]); - if (!Array.isArray(result.mappings) || !Array.isArray(result.skipped)) return invalidAdminResponse(); +// Historical copy audit entries retain their result mappings. +function projectAuditResultIDs(value: unknown): AdminAuditResultID[] { + if (!Array.isArray(value)) return invalidAdminResponse(); const types = new Set(["agent", "skill", "skill_version", "environment_template", "file", "vault", "credential"]); - const project = (entry: unknown, field: "target_id" | "reason") => { - const item = record(entry, ["type", "source_id", field]); - strings(item, ["type", "source_id", field]); + return value.map((entry) => { + const item = record(entry, ["type", "source_id", "target_id"]); + strings(item, ["type", "source_id", "target_id"]); if (!types.has(item.type as string)) return invalidAdminResponse(); - return { ...item }; - }; - return { mappings: result.mappings.map((entry) => project(entry, "target_id")), skipped: result.skipped.map((entry) => project(entry, "reason")) } as unknown as AdminCopyResult; + return { ...item } as unknown as AdminAuditResultID; + }); } function projectProvenance(value: unknown): AdminKeyProvenance | null { @@ -192,8 +191,7 @@ export function projectAdminAudit(value: unknown): AdminAuditPage { const audit = record(entry, ["id", "created_at", "admin_credential_id", "actor_label", "action", "project_id", "resource_type", "resource_id", "result_ids", "request_id", "trace_id"]); strings(audit, ["id", "created_at", "admin_credential_id", "actor_label", "action", "project_id", "resource_type", "resource_id", "request_id", "trace_id"]); if (!date(audit.created_at)) return invalidAdminResponse(); - const result = projectCopyResult({ mappings: audit.result_ids, skipped: [] }); - return { ...audit, result_ids: result.mappings }; + return { ...audit, result_ids: projectAuditResultIDs(audit.result_ids) }; }); return { data, has_more: page.has_more, next_cursor: page.next_cursor } as AdminAuditPage; } diff --git a/packages/agents-client/src/admin-types.ts b/packages/agents-client/src/admin-types.ts index 53df006ea..9559797a9 100644 --- a/packages/agents-client/src/admin-types.ts +++ b/packages/agents-client/src/admin-types.ts @@ -1,4 +1,4 @@ -import type { PageOptions, ReadOptions } from "./types"; +import type { PageOptions } from "./types"; export interface AdminClientOptions { baseUrl?: string; @@ -28,20 +28,6 @@ export interface AdminIssuedAPIKey extends AdminAPIKey { key: string } export interface IssueAdminAPIKeyInput { name: string } export interface AdminPage { data: T[]; has_more: boolean } export interface AdminDeleted { id: string; object: O; deleted: true } -export type AdminCopyResourceType = "agent" | "skill" | "environment_template" | "file" | "vault" | "credential"; -export interface AdminCopyInput { - source_project_id: string; - target_project_id: string; - resource_type: AdminCopyResourceType; - resource_id: string; - include_dependencies: boolean; - target_vault_id?: string; -} -export interface AdminCopyResult { - mappings: Array<{ type: AdminCopyResourceType | "skill_version"; source_id: string; target_id: string }>; - skipped: Array<{ type: AdminCopyResourceType | "skill_version"; source_id: string; reason: string }>; -} -export interface AdminWriteOptions extends ReadOptions { idempotencyKey?: string } export interface ArchiveAdminSessionInput { expected_generation: number } /** Current resource disposition; released does not imply that the active Turn has finalized. */ export interface AdminSessionArchive { @@ -78,7 +64,7 @@ export interface SessionArtifact { turn_id: string; } export interface AdminContent { blob: Blob; contentType: string | null; contentDisposition: string | null } -export type AdminResourceType = AdminCopyResourceType | "session" | "environment" | "skill_version" | "artifact"; +export type AdminResourceType = "agent" | "skill" | "environment_template" | "file" | "vault" | "credential" | "session" | "environment" | "skill_version" | "artifact"; export interface AdminKeyProvenance { id: string; name: string; @@ -143,6 +129,11 @@ export interface AdminAuditOptions extends Omit 1 || len(key) > 128 || strings.TrimSpace(key) != key { - writeStoreError(w, r, store.ErrInvalidInput) - return - } - source, err := h.resolveAdminProject(r.Context(), input.SourceProjectID) - if err != nil { - writeStoreError(w, r, err) - return - } - target, err := h.resolveAdminProject(r.Context(), input.TargetProjectID) - if err != nil { - writeStoreError(w, r, err) - return - } - ctx := h.adminAuditContext(r, input.TargetProjectID) - result, err := h.adminManagement.CopyAssets(ctx, source.Principal.TenantID, target.Principal.TenantID, store.CopyAssetsInput{ - ResourceType: input.ResourceType, ResourceID: input.ResourceID, TargetVaultID: input.TargetVaultID, IncludeDependencies: input.IncludeDependencies, IdempotencyKey: key, - }) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, result) -} diff --git a/services/agents-api/internal/api/admin_resources.go b/services/agents-api/internal/api/admin_resources.go index 112f99407..5e2dcba0b 100644 --- a/services/agents-api/internal/api/admin_resources.go +++ b/services/agents-api/internal/api/admin_resources.go @@ -14,7 +14,6 @@ type adminTenantContextKey struct{} type AdminManagementStore interface { GetManagedSessionArchive(context.Context, string, string) (store.ManagedSessionArchive, error) - CopyAssets(context.Context, string, string, store.CopyAssetsInput) (store.CopyAssetsResult, error) ReadAdminSummary(context.Context, string, store.AdminSummaryFilter, func(store.Session, *string) error) (store.AdminAssetCounts, error) ListAdminRuntimeTargets(context.Context, []string, string, int, bool) (store.AdminRuntimeTargetPage, error) ListAdminAudit(context.Context, store.AdminAuditFilter) (store.AdminAuditPage, error) @@ -41,10 +40,7 @@ func (h *Handler) registerAdminResourceRoutes(router chi.Router) { router.Group(func(r chi.Router) { r.Get("/core-metrics", h.getCoreMetrics) - r.Get("/startup-configuration", h.adminStartupConfiguration) - r.Get("/runtime-history/capabilities", h.adminRuntimeHistoryCapabilities) if h.adminManagement != nil { - r.Post("/copies", h.copyAdminAssets) r.Get("/summary", h.adminSummary) r.Get("/runtime-observations", h.adminRuntimeObservations) r.Head("/runtime-observations", methodNotAllowed) @@ -104,26 +100,3 @@ func (h *Handler) registerAdminResourceRoutes(router chi.Router) { }) }) } - -// @Summary Retrieve deployment startup metadata -// @Description Deployment administrator only. Returns the same non-secret configuration metadata as the project extension. -// @Tags Core Administration -// @Produce json -// @Security DeploymentAdminAuth -// @Success 200 {object} v1.CoreStartupConfiguration -// @Failure 401,503 {object} v1.ErrorResponse -// @Router /core/v1/admin/startup-configuration [get] -func (h *Handler) adminStartupConfiguration(w http.ResponseWriter, r *http.Request) { - h.getStartupConfiguration(w, r) -} - -// @Summary Retrieve durable Runtime history capabilities -// @Tags Core Administration -// @Produce json -// @Security DeploymentAdminAuth -// @Success 200 {object} v1.RuntimeHistoryCapabilities -// @Failure 401 {object} v1.ErrorResponse -// @Router /core/v1/admin/runtime-history/capabilities [get] -func (h *Handler) adminRuntimeHistoryCapabilities(w http.ResponseWriter, r *http.Request) { - h.getRuntimeHistoryCapabilities(w, r) -} diff --git a/services/agents-api/internal/api/project_api_keys.go b/services/agents-api/internal/api/project_api_keys.go index 5b4a6c7fa..5d7e3f4cb 100644 --- a/services/agents-api/internal/api/project_api_keys.go +++ b/services/agents-api/internal/api/project_api_keys.go @@ -75,10 +75,6 @@ func setAdminAuditSource(r *http.Request, projectID string) { } *r = *r.WithContext(adminaudit.WithSource(r.Context(), source)) } -func (h *Handler) adminAuditContext(r *http.Request, projectID string) context.Context { - setAdminAuditSource(r, projectID) - return r.Context() -} func adminCatalogPage(r *http.Request) (string, int, bool, error) { values := r.URL.Query() limit := 20 diff --git a/services/agents-api/internal/db/queries/admin_copies.sql b/services/agents-api/internal/db/queries/admin_copies.sql deleted file mode 100644 index 1c4221a66..000000000 --- a/services/agents-api/internal/db/queries/admin_copies.sql +++ /dev/null @@ -1,49 +0,0 @@ --- name: LockAdminCopyTargetProject :one -SELECT id, archived_at FROM projects WHERE tenant_id=$1 FOR SHARE; - --- name: LockAdminAssetCopy :exec -SELECT pg_advisory_xact_lock(hashtextextended(sqlc.arg(lock_key)::text, 0)); - --- name: GetAdminAssetCopy :one -SELECT request_hash, result FROM admin_asset_copies -WHERE target_tenant_id = $1 AND idempotency_key = $2; - --- name: SaveAdminAssetCopy :exec -INSERT INTO admin_asset_copies(target_tenant_id,idempotency_key,request_hash,result,audit_id) -VALUES($1,$2,$3,$4,$5) -ON CONFLICT (target_tenant_id,idempotency_key) DO UPDATE -SET request_hash=admin_asset_copies.request_hash; - --- name: CreateAdminResourceOwner :exec -INSERT INTO admin_resource_owners(tenant_id,resource_type,resource_id,parent_id,audit_id) -VALUES($1,$2,$3,$4,$5); - --- name: CreateAdminCopySkill :one -INSERT INTO skills(id,tenant_id,name,description,default_version,latest_version,next_version) -VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING *; - --- name: AdminCopySkillVersionNumbers :many -SELECT version FROM skill_versions WHERE tenant_id=$1 AND skill_id=$2 ORDER BY version; - --- name: LockAdminCopyTemplate :one -SELECT id FROM environment_templates WHERE tenant_id=$1 AND id=$2 FOR UPDATE; - --- name: LockAdminCopyVault :one -SELECT * FROM vaults WHERE tenant_id=$1 AND id=$2 FOR UPDATE; - --- name: AdminCopyCredentialIDs :many -SELECT c.id FROM vault_credentials c JOIN vaults v ON v.id=c.vault_id -WHERE v.tenant_id=$1 AND v.id=$2 ORDER BY c.id; - --- name: GetAdminCopyCredential :one -SELECT c.* FROM vault_credentials c JOIN vaults v ON v.id=c.vault_id -WHERE v.tenant_id=$1 AND c.id=$2 FOR UPDATE OF c; - --- name: CreateAdminCopyCredential :one -INSERT INTO vault_credentials(id,vault_id,name,auth_type,mcp_server_url,token_ciphertext,oauth_metadata,status) -SELECT sqlc.arg(id),v.id,sqlc.arg(name),sqlc.arg(auth_type),sqlc.arg(mcp_server_url),sqlc.arg(token_ciphertext),sqlc.arg(oauth_metadata),sqlc.arg(status) -FROM vaults v WHERE v.tenant_id=sqlc.arg(tenant_id) AND v.id=sqlc.arg(vault_id) -RETURNING id; - --- name: CreateAdminCopyVault :one -INSERT INTO vaults(id,tenant_id,name,metadata,status) VALUES($1,$2,$3,$4,$5) RETURNING id; diff --git a/services/agents-api/internal/db/sqlc/admin_copies.sql.go b/services/agents-api/internal/db/sqlc/admin_copies.sql.go deleted file mode 100644 index 6cac40a8e..000000000 --- a/services/agents-api/internal/db/sqlc/admin_copies.sql.go +++ /dev/null @@ -1,335 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: admin_copies.sql - -package sqlc - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const adminCopyCredentialIDs = `-- name: AdminCopyCredentialIDs :many -SELECT c.id FROM vault_credentials c JOIN vaults v ON v.id=c.vault_id -WHERE v.tenant_id=$1 AND v.id=$2 ORDER BY c.id -` - -type AdminCopyCredentialIDsParams struct { - TenantID pgtype.UUID `json:"tenant_id"` - ID pgtype.UUID `json:"id"` -} - -func (q *Queries) AdminCopyCredentialIDs(ctx context.Context, arg AdminCopyCredentialIDsParams) ([]pgtype.UUID, error) { - rows, err := q.db.Query(ctx, adminCopyCredentialIDs, arg.TenantID, arg.ID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []pgtype.UUID{} - for rows.Next() { - var id pgtype.UUID - if err := rows.Scan(&id); err != nil { - return nil, err - } - items = append(items, id) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const adminCopySkillVersionNumbers = `-- name: AdminCopySkillVersionNumbers :many -SELECT version FROM skill_versions WHERE tenant_id=$1 AND skill_id=$2 ORDER BY version -` - -type AdminCopySkillVersionNumbersParams struct { - TenantID pgtype.UUID `json:"tenant_id"` - SkillID pgtype.UUID `json:"skill_id"` -} - -func (q *Queries) AdminCopySkillVersionNumbers(ctx context.Context, arg AdminCopySkillVersionNumbersParams) ([]int64, error) { - rows, err := q.db.Query(ctx, adminCopySkillVersionNumbers, arg.TenantID, arg.SkillID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []int64{} - for rows.Next() { - var version int64 - if err := rows.Scan(&version); err != nil { - return nil, err - } - items = append(items, version) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const createAdminCopyCredential = `-- name: CreateAdminCopyCredential :one -INSERT INTO vault_credentials(id,vault_id,name,auth_type,mcp_server_url,token_ciphertext,oauth_metadata,status) -SELECT $1,v.id,$2,$3,$4,$5,$6,$7 -FROM vaults v WHERE v.tenant_id=$8 AND v.id=$9 -RETURNING id -` - -type CreateAdminCopyCredentialParams struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - AuthType string `json:"auth_type"` - McpServerUrl string `json:"mcp_server_url"` - TokenCiphertext []byte `json:"token_ciphertext"` - OauthMetadata []byte `json:"oauth_metadata"` - Status string `json:"status"` - TenantID pgtype.UUID `json:"tenant_id"` - VaultID pgtype.UUID `json:"vault_id"` -} - -func (q *Queries) CreateAdminCopyCredential(ctx context.Context, arg CreateAdminCopyCredentialParams) (pgtype.UUID, error) { - row := q.db.QueryRow(ctx, createAdminCopyCredential, - arg.ID, - arg.Name, - arg.AuthType, - arg.McpServerUrl, - arg.TokenCiphertext, - arg.OauthMetadata, - arg.Status, - arg.TenantID, - arg.VaultID, - ) - var id pgtype.UUID - err := row.Scan(&id) - return id, err -} - -const createAdminCopySkill = `-- name: CreateAdminCopySkill :one -INSERT INTO skills(id,tenant_id,name,description,default_version,latest_version,next_version) -VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id, tenant_id, name, description, created_at, default_version, latest_version, next_version -` - -type CreateAdminCopySkillParams struct { - ID pgtype.UUID `json:"id"` - TenantID pgtype.UUID `json:"tenant_id"` - Name string `json:"name"` - Description string `json:"description"` - DefaultVersion int64 `json:"default_version"` - LatestVersion int64 `json:"latest_version"` - NextVersion int64 `json:"next_version"` -} - -func (q *Queries) CreateAdminCopySkill(ctx context.Context, arg CreateAdminCopySkillParams) (Skill, error) { - row := q.db.QueryRow(ctx, createAdminCopySkill, - arg.ID, - arg.TenantID, - arg.Name, - arg.Description, - arg.DefaultVersion, - arg.LatestVersion, - arg.NextVersion, - ) - var i Skill - err := row.Scan( - &i.ID, - &i.TenantID, - &i.Name, - &i.Description, - &i.CreatedAt, - &i.DefaultVersion, - &i.LatestVersion, - &i.NextVersion, - ) - return i, err -} - -const createAdminCopyVault = `-- name: CreateAdminCopyVault :one -INSERT INTO vaults(id,tenant_id,name,metadata,status) VALUES($1,$2,$3,$4,$5) RETURNING id -` - -type CreateAdminCopyVaultParams struct { - ID pgtype.UUID `json:"id"` - TenantID pgtype.UUID `json:"tenant_id"` - Name pgtype.Text `json:"name"` - Metadata []byte `json:"metadata"` - Status string `json:"status"` -} - -func (q *Queries) CreateAdminCopyVault(ctx context.Context, arg CreateAdminCopyVaultParams) (pgtype.UUID, error) { - row := q.db.QueryRow(ctx, createAdminCopyVault, - arg.ID, - arg.TenantID, - arg.Name, - arg.Metadata, - arg.Status, - ) - var id pgtype.UUID - err := row.Scan(&id) - return id, err -} - -const createAdminResourceOwner = `-- name: CreateAdminResourceOwner :exec -INSERT INTO admin_resource_owners(tenant_id,resource_type,resource_id,parent_id,audit_id) -VALUES($1,$2,$3,$4,$5) -` - -type CreateAdminResourceOwnerParams struct { - TenantID pgtype.UUID `json:"tenant_id"` - ResourceType string `json:"resource_type"` - ResourceID string `json:"resource_id"` - ParentID string `json:"parent_id"` - AuditID pgtype.UUID `json:"audit_id"` -} - -func (q *Queries) CreateAdminResourceOwner(ctx context.Context, arg CreateAdminResourceOwnerParams) error { - _, err := q.db.Exec(ctx, createAdminResourceOwner, - arg.TenantID, - arg.ResourceType, - arg.ResourceID, - arg.ParentID, - arg.AuditID, - ) - return err -} - -const getAdminAssetCopy = `-- name: GetAdminAssetCopy :one -SELECT request_hash, result FROM admin_asset_copies -WHERE target_tenant_id = $1 AND idempotency_key = $2 -` - -type GetAdminAssetCopyParams struct { - TargetTenantID pgtype.UUID `json:"target_tenant_id"` - IdempotencyKey string `json:"idempotency_key"` -} - -type GetAdminAssetCopyRow struct { - RequestHash []byte `json:"request_hash"` - Result []byte `json:"result"` -} - -func (q *Queries) GetAdminAssetCopy(ctx context.Context, arg GetAdminAssetCopyParams) (GetAdminAssetCopyRow, error) { - row := q.db.QueryRow(ctx, getAdminAssetCopy, arg.TargetTenantID, arg.IdempotencyKey) - var i GetAdminAssetCopyRow - err := row.Scan(&i.RequestHash, &i.Result) - return i, err -} - -const getAdminCopyCredential = `-- name: GetAdminCopyCredential :one -SELECT c.id, c.vault_id, c.name, c.auth_type, c.mcp_server_url, c.token_ciphertext, c.created_at, c.updated_at, c.status, c.oauth_metadata FROM vault_credentials c JOIN vaults v ON v.id=c.vault_id -WHERE v.tenant_id=$1 AND c.id=$2 FOR UPDATE OF c -` - -type GetAdminCopyCredentialParams struct { - TenantID pgtype.UUID `json:"tenant_id"` - ID pgtype.UUID `json:"id"` -} - -func (q *Queries) GetAdminCopyCredential(ctx context.Context, arg GetAdminCopyCredentialParams) (VaultCredential, error) { - row := q.db.QueryRow(ctx, getAdminCopyCredential, arg.TenantID, arg.ID) - var i VaultCredential - err := row.Scan( - &i.ID, - &i.VaultID, - &i.Name, - &i.AuthType, - &i.McpServerUrl, - &i.TokenCiphertext, - &i.CreatedAt, - &i.UpdatedAt, - &i.Status, - &i.OauthMetadata, - ) - return i, err -} - -const lockAdminAssetCopy = `-- name: LockAdminAssetCopy :exec -SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0)) -` - -func (q *Queries) LockAdminAssetCopy(ctx context.Context, lockKey string) error { - _, err := q.db.Exec(ctx, lockAdminAssetCopy, lockKey) - return err -} - -const lockAdminCopyTargetProject = `-- name: LockAdminCopyTargetProject :one -SELECT id, archived_at FROM projects WHERE tenant_id=$1 FOR SHARE -` - -type LockAdminCopyTargetProjectRow struct { - ID pgtype.UUID `json:"id"` - ArchivedAt pgtype.Timestamptz `json:"archived_at"` -} - -func (q *Queries) LockAdminCopyTargetProject(ctx context.Context, tenantID pgtype.UUID) (LockAdminCopyTargetProjectRow, error) { - row := q.db.QueryRow(ctx, lockAdminCopyTargetProject, tenantID) - var i LockAdminCopyTargetProjectRow - err := row.Scan(&i.ID, &i.ArchivedAt) - return i, err -} - -const lockAdminCopyTemplate = `-- name: LockAdminCopyTemplate :one -SELECT id FROM environment_templates WHERE tenant_id=$1 AND id=$2 FOR UPDATE -` - -type LockAdminCopyTemplateParams struct { - TenantID pgtype.UUID `json:"tenant_id"` - ID pgtype.UUID `json:"id"` -} - -func (q *Queries) LockAdminCopyTemplate(ctx context.Context, arg LockAdminCopyTemplateParams) (pgtype.UUID, error) { - row := q.db.QueryRow(ctx, lockAdminCopyTemplate, arg.TenantID, arg.ID) - var id pgtype.UUID - err := row.Scan(&id) - return id, err -} - -const lockAdminCopyVault = `-- name: LockAdminCopyVault :one -SELECT id, tenant_id, name, metadata, created_at, status FROM vaults WHERE tenant_id=$1 AND id=$2 FOR UPDATE -` - -type LockAdminCopyVaultParams struct { - TenantID pgtype.UUID `json:"tenant_id"` - ID pgtype.UUID `json:"id"` -} - -func (q *Queries) LockAdminCopyVault(ctx context.Context, arg LockAdminCopyVaultParams) (Vault, error) { - row := q.db.QueryRow(ctx, lockAdminCopyVault, arg.TenantID, arg.ID) - var i Vault - err := row.Scan( - &i.ID, - &i.TenantID, - &i.Name, - &i.Metadata, - &i.CreatedAt, - &i.Status, - ) - return i, err -} - -const saveAdminAssetCopy = `-- name: SaveAdminAssetCopy :exec -INSERT INTO admin_asset_copies(target_tenant_id,idempotency_key,request_hash,result,audit_id) -VALUES($1,$2,$3,$4,$5) -ON CONFLICT (target_tenant_id,idempotency_key) DO UPDATE -SET request_hash=admin_asset_copies.request_hash -` - -type SaveAdminAssetCopyParams struct { - TargetTenantID pgtype.UUID `json:"target_tenant_id"` - IdempotencyKey string `json:"idempotency_key"` - RequestHash []byte `json:"request_hash"` - Result []byte `json:"result"` - AuditID pgtype.UUID `json:"audit_id"` -} - -func (q *Queries) SaveAdminAssetCopy(ctx context.Context, arg SaveAdminAssetCopyParams) error { - _, err := q.db.Exec(ctx, saveAdminAssetCopy, - arg.TargetTenantID, - arg.IdempotencyKey, - arg.RequestHash, - arg.Result, - arg.AuditID, - ) - return err -} diff --git a/services/agents-api/internal/store/admin_audit.go b/services/agents-api/internal/store/admin_audit.go index d508c3dc0..9535e5c2c 100644 --- a/services/agents-api/internal/store/admin_audit.go +++ b/services/agents-api/internal/store/admin_audit.go @@ -2,7 +2,6 @@ package store import ( "context" - "encoding/json" "fmt" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" @@ -12,25 +11,20 @@ import ( ) // recordAdminMutation runs in the business transaction and never records request bodies or secrets. -func recordAdminMutation(ctx context.Context, q *sqlc.Queries, tenant, action, resourceType, resourceID string, mappings json.RawMessage) (string, error) { +func recordAdminMutation(ctx context.Context, q *sqlc.Queries, tenant, action, resourceType, resourceID string) error { source, ok := adminaudit.FromContext(ctx) if !ok || !auditText(source.CredentialID, 64, true) || !auditText(source.ActorLabel, 128, false) || !auditText(source.RequestID, 128, true) || !auditText(source.TraceID, 128, true) || !auditText(source.ProjectID, 128, true) || !auditText(action, 64, true) || !auditText(resourceType, 64, true) || !auditText(resourceID, 256, true) { - return "", fmt.Errorf("%w: invalid administrator audit source", ErrInvalidInput) + return fmt.Errorf("%w: invalid administrator audit source", ErrInvalidInput) } projectID, err := parseID(source.ProjectID) if err != nil { - return "", err + return err } tenantID, err := parseID(tenant) if err != nil { - return "", err + return err } - if len(mappings) == 0 { - mappings = json.RawMessage(`[]`) - } - id, err := q.InsertAdminAudit(ctx, sqlc.InsertAdminAuditParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenantID, AdminCredentialID: source.CredentialID, ActorLabel: source.ActorLabel, Action: action, ProjectID: projectID, ResourceType: resourceType, ResourceID: resourceID, ResultIds: mappings, RequestID: source.RequestID, TraceID: source.TraceID}) - if err != nil { - return "", err - } - return uuid.UUID(id.Bytes).String(), nil + // result_ids is retained for historical copy mappings; current writes record none. + _, err = q.InsertAdminAudit(ctx, sqlc.InsertAdminAuditParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenantID, AdminCredentialID: source.CredentialID, ActorLabel: source.ActorLabel, Action: action, ProjectID: projectID, ResourceType: resourceType, ResourceID: resourceID, ResultIds: []byte(`[]`), RequestID: source.RequestID, TraceID: source.TraceID}) + return err } diff --git a/services/agents-api/internal/store/admin_copy.go b/services/agents-api/internal/store/admin_copy.go deleted file mode 100644 index 0cf116dc6..000000000 --- a/services/agents-api/internal/store/admin_copy.go +++ /dev/null @@ -1,203 +0,0 @@ -package store - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/json" - "errors" - "strings" - "unicode/utf8" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/pgtype" -) - -type CopyAssetsInput struct { - ResourceType string `json:"resource_type"` - ResourceID string `json:"resource_id"` - TargetVaultID string `json:"target_vault_id,omitempty"` - IdempotencyKey string `json:"-"` - IncludeDependencies bool `json:"include_dependencies"` -} - -type AssetCopyMapping struct { - Type string `json:"type"` - SourceID string `json:"source_id"` - TargetID string `json:"target_id"` -} - -type AssetCopySkipped struct { - Type string `json:"type"` - SourceID string `json:"source_id"` - Reason string `json:"reason"` -} - -type CopyAssetsResult struct { - Mappings []AssetCopyMapping `json:"mappings"` - Skipped []AssetCopySkipped `json:"skipped"` -} - -// CopyAssets is the management-only atomic boundary. Callers resolve source and -// target authority before entering; every resource lookup remains tenant scoped. -func (s *Store) CopyAssets(ctx context.Context, sourceTenant, targetTenant string, input CopyAssetsInput) (CopyAssetsResult, error) { - source, err := parseID(sourceTenant) - if err != nil { - return CopyAssetsResult{}, err - } - target, err := parseID(targetTenant) - if err != nil { - return CopyAssetsResult{}, err - } - if source == target || input.ResourceID == "" || len(input.IdempotencyKey) > 256 || !utf8.ValidString(input.IdempotencyKey) || strings.ContainsRune(input.IdempotencyKey, 0) { - return CopyAssetsResult{}, ErrInvalidInput - } - switch input.ResourceType { - case "agent", "skill", "environment_template", "file", "vault": - if input.TargetVaultID != "" { - return CopyAssetsResult{}, ErrInvalidInput - } - case "credential": - if input.TargetVaultID == "" { - return CopyAssetsResult{}, ErrInvalidInput - } - default: - return CopyAssetsResult{}, ErrInvalidInput - } - sourceTenant, targetTenant = uuid.UUID(source.Bytes).String(), uuid.UUID(target.Bytes).String() - request, err := json.Marshal(struct { - Source string - Input CopyAssetsInput - }{sourceTenant, input}) - if err != nil { - return CopyAssetsResult{}, err - } - digest := sha256.Sum256(request) - var result CopyAssetsResult - // A consistent snapshot covers the root and every dependency. Serialization - // retries restart the complete transaction, including all large objects. - for attempt := 0; attempt < 3; attempt++ { - result = CopyAssetsResult{Mappings: []AssetCopyMapping{}, Skipped: []AssetCopySkipped{}} - err = pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead}, func(tx pgx.Tx) error { - q := s.queries.WithTx(tx) - project, err := q.LockAdminCopyTargetProject(ctx, target) - if err != nil { - return err - } - if project.ArchivedAt.Valid { - return ErrProjectArchived - } - if input.IdempotencyKey != "" { - if err := q.LockAdminAssetCopy(ctx, "admin-copy:"+targetTenant+":"+input.IdempotencyKey); err != nil { - return err - } - existing, err := q.GetAdminAssetCopy(ctx, sqlc.GetAdminAssetCopyParams{TargetTenantID: target, IdempotencyKey: input.IdempotencyKey}) - if err == nil { - if !bytes.Equal(existing.RequestHash, digest[:]) { - return ErrIdempotencyConflict - } - return json.Unmarshal(existing.Result, &result) - } - if !errors.Is(err, pgx.ErrNoRows) { - return err - } - } - copier := assetCopier{s: s, tx: tx, q: q, source: source, target: target, sourceTenant: sourceTenant, targetTenant: targetTenant, dependencies: input.IncludeDependencies, result: result, mapped: map[string]string{}, parents: map[string]string{}} - if _, err := copier.copy(ctx, input.ResourceType, input.ResourceID, input.TargetVaultID); err != nil { - return err - } - result = copier.result - mappings, err := json.Marshal(result.Mappings) - if err != nil { - return err - } - audit, err := recordAdminMutation(ctx, q, targetTenant, "copy", input.ResourceType, input.ResourceID, mappings) - if err != nil { - return err - } - auditID, err := parseID(audit) - if err != nil { - return err - } - for _, mapping := range result.Mappings { - if err := q.CreateAdminResourceOwner(ctx, sqlc.CreateAdminResourceOwnerParams{TenantID: target, ResourceType: mapping.Type, ResourceID: mapping.TargetID, ParentID: copier.parents[mapping.Type+":"+mapping.TargetID], AuditID: auditID}); err != nil { - return err - } - } - if input.IdempotencyKey != "" { - raw, err := json.Marshal(result) - if err != nil { - return err - } - return q.SaveAdminAssetCopy(ctx, sqlc.SaveAdminAssetCopyParams{TargetTenantID: target, IdempotencyKey: input.IdempotencyKey, RequestHash: digest[:], Result: raw, AuditID: auditID}) - } - return nil - }) - var postgres *pgconn.PgError - if !errors.As(err, &postgres) || postgres.Code != "40001" { - break - } - } - if errors.Is(err, pgx.ErrNoRows) { - err = ErrNotFound - } - if err != nil { - return CopyAssetsResult{}, err - } - return result, nil -} - -type assetCopier struct { - s *Store - tx pgx.Tx - q *sqlc.Queries - source, target pgtype.UUID - sourceTenant, targetTenant string - dependencies bool - result CopyAssetsResult - mapped map[string]string - parents map[string]string -} - -func (c *assetCopier) add(kind, source, target, parent string) string { - c.mapped[kind+":"+source] = target - c.parents[kind+":"+target] = parent - c.result.Mappings = append(c.result.Mappings, AssetCopyMapping{Type: kind, SourceID: source, TargetID: target}) - return target -} - -func (c *assetCopier) copy(ctx context.Context, kind, id, vault string) (string, error) { - switch kind { - case "agent", "vault", "credential", "environment_template": - source, err := parseID(id) - if err != nil { - return "", ErrNotFound - } - id = copyID(source) - } - if target, ok := c.mapped[kind+":"+id]; ok { - return target, nil - } - switch kind { - case "skill": - return c.skill(ctx, id) - case "file": - return c.file(ctx, id) - case "vault": - return c.vault(ctx, id) - case "credential": - return c.credential(ctx, id, vault) - case "environment_template": - return c.template(ctx, id) - case "agent": - return c.agent(ctx, id) - default: - return "", ErrInvalidInput - } -} - -func copyUUID() pgtype.UUID { return pgtype.UUID{Bytes: uuid.New(), Valid: true} } -func copyID(id pgtype.UUID) string { return uuid.UUID(id.Bytes).String() } diff --git a/services/agents-api/internal/store/admin_copy_content.go b/services/agents-api/internal/store/admin_copy_content.go deleted file mode 100644 index a862609a7..000000000 --- a/services/agents-api/internal/store/admin_copy_content.go +++ /dev/null @@ -1,93 +0,0 @@ -package store - -import ( - "context" - "encoding/hex" - "io" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -func (c *assetCopier) skill(ctx context.Context, id string) (string, error) { - _, source, err := skillIDs(c.sourceTenant, id) - if err != nil { - return "", err - } - original, err := c.q.LockSkill(ctx, sqlc.LockSkillParams{TenantID: c.source, ID: source}) - if err != nil { - return "", err - } - target := copyUUID() - _, err = c.q.CreateAdminCopySkill(ctx, sqlc.CreateAdminCopySkillParams{ID: target, TenantID: c.target, Name: original.Name, Description: original.Description, DefaultVersion: original.DefaultVersion, LatestVersion: original.LatestVersion, NextVersion: original.NextVersion}) - if err != nil { - return "", err - } - targetID := c.add("skill", id, "skill_"+copyID(target), "") - versions, err := c.q.AdminCopySkillVersionNumbers(ctx, sqlc.AdminCopySkillVersionNumbersParams{TenantID: c.source, SkillID: source}) - if err != nil { - return "", err - } - for _, number := range versions { - row, err := c.q.ReadSkillVersion(ctx, sqlc.ReadSkillVersionParams{TenantID: c.source, SkillID: source, Version: number}) - if err != nil { - return "", err - } - originalVersion, archive, err := c.s.openSkillVersion(row) - if err != nil { - return "", err - } - version, err := c.s.saveSkillVersion(ctx, c.q, c.target, target, number, agentskill.Metadata{Type: "inline", Name: row.Name, Description: row.Description}, archive) - if err != nil { - return "", err - } - c.add("skill_version", originalVersion.ID, version.ID, targetID) - } - return targetID, nil -} - -func (c *assetCopier) file(ctx context.Context, id string) (string, error) { - _, source, err := sourceFileIDs(c.sourceTenant, id) - if err != nil { - return "", err - } - row, err := c.q.LockInitialSourceFile(ctx, sqlc.LockInitialSourceFileParams{TenantID: c.source, ID: source}) - if err != nil { - return "", err - } - if row.SizeBytes > MaxSourceFileBytes { - return "", ErrSourceFileTooLarge - } - objects := c.tx.LargeObjects() - oid, err := objects.Create(ctx, 0) - if err != nil { - return "", err - } - body, err := objects.Open(ctx, oid, pgx.LargeObjectModeWrite) - if err != nil { - return "", err - } - writer := newSourceFileWriter(body) - err = consumeSourceFile(ctx, c.tx, row, func(_ SourceFile, reader io.Reader) error { - _, err := io.CopyBuffer(writer, io.LimitReader(reader, MaxSourceFileBytes+1), make([]byte, sourceFileChunkBytes)) - return err - }) - if err != nil { - return "", err - } - if err = body.Close(); err != nil { - return "", err - } - digest := hex.EncodeToString(writer.hash.Sum(nil)) - if writer.size != row.SizeBytes || digest != row.Sha256 { - return "", ErrInvalidInput - } - target := copyUUID() - _, err = c.q.CreateSourceFile(ctx, sqlc.CreateSourceFileParams{ID: target, TenantID: c.target, Filename: row.Filename, Purpose: row.Purpose, BodyOid: pgtype.Uint32{Uint32: oid, Valid: true}, SizeBytes: writer.size, Sha256: digest}) - if err != nil { - return "", err - } - return c.add("file", id, "file-"+copyID(target), ""), nil -} diff --git a/services/agents-api/internal/store/admin_copy_credentials.go b/services/agents-api/internal/store/admin_copy_credentials.go deleted file mode 100644 index f01daa297..000000000 --- a/services/agents-api/internal/store/admin_copy_credentials.go +++ /dev/null @@ -1,187 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" -) - -func (c *assetCopier) vault(ctx context.Context, id string) (string, error) { - source, err := parseID(id) - if err != nil { - return "", ErrNotFound - } - row, err := c.q.LockAdminCopyVault(ctx, sqlc.LockAdminCopyVaultParams{TenantID: c.source, ID: source}) - if err != nil { - return "", err - } - target := copyUUID() - _, err = c.q.CreateAdminCopyVault(ctx, sqlc.CreateAdminCopyVaultParams{ID: target, TenantID: c.target, Name: row.Name, Metadata: row.Metadata, Status: row.Status}) - if err != nil { - return "", err - } - targetID := c.add("vault", id, copyID(target), "") - credentials, err := c.q.AdminCopyCredentialIDs(ctx, sqlc.AdminCopyCredentialIDsParams{TenantID: c.source, ID: source}) - if err != nil { - return "", err - } - for _, credential := range credentials { - if _, err := c.copy(ctx, "credential", copyID(credential), targetID); err != nil { - return "", err - } - } - return targetID, nil -} - -func (c *assetCopier) credential(ctx context.Context, id, vault string) (string, error) { - source, err := parseID(id) - if err != nil { - return "", ErrNotFound - } - targetVault, err := parseID(vault) - if err != nil { - return "", ErrNotFound - } - vault = copyID(targetVault) - if _, err := c.q.LockAdminCopyVault(ctx, sqlc.LockAdminCopyVaultParams{TenantID: c.target, ID: targetVault}); err != nil { - return "", err - } - row, err := c.q.GetAdminCopyCredential(ctx, sqlc.GetAdminCopyCredentialParams{TenantID: c.source, ID: source}) - if err != nil { - return "", err - } - target := copyUUID() - var encrypted []byte - switch row.AuthType { - case "static_bearer": - binding := credentialcrypto.Binding{TenantID: c.sourceTenant, VaultID: copyID(row.VaultID), CredentialID: id, AuthType: row.AuthType, Destination: row.McpServerUrl} - plaintext, err := c.s.credentialCipher.Open(row.TokenCiphertext, binding) - if err != nil { - return "", err - } - binding.TenantID, binding.VaultID, binding.CredentialID = c.targetTenant, vault, copyID(target) - encrypted, err = c.s.credentialCipher.Seal(plaintext, binding) - if err != nil { - return "", err - } - case "mcp_oauth": - credential, err := credentialFromRow(sqlc.GetCredentialRow{ID: row.ID, VaultID: row.VaultID, Name: row.Name, AuthType: row.AuthType, McpServerUrl: row.McpServerUrl, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, OauthMetadata: row.OauthMetadata}) - if err != nil { - return "", err - } - secret, err := c.s.openOAuth(c.sourceTenant, credential, row.TokenCiphertext) - if err != nil { - return "", err - } - if secret.Metadata.Refresh != nil { - c.mapped["credential:"+id] = "" - c.result.Skipped = append(c.result.Skipped, AssetCopySkipped{Type: "credential", SourceID: id, Reason: "OAuth refresh tokens may rotate; independent copies could invalidate each other"}) - return "", nil - } - credential.ID, credential.VaultID = copyID(target), vault - _, encrypted, err = c.s.sealOAuth(c.targetTenant, credential, secret) - if err != nil { - return "", err - } - default: - return "", ErrInvalidInput - } - _, err = c.q.CreateAdminCopyCredential(ctx, sqlc.CreateAdminCopyCredentialParams{ID: target, TenantID: c.target, VaultID: targetVault, Name: row.Name, AuthType: row.AuthType, McpServerUrl: row.McpServerUrl, TokenCiphertext: encrypted, OauthMetadata: row.OauthMetadata, Status: row.Status}) - if err != nil { - return "", err - } - return c.add("credential", id, copyID(target), vault), nil -} - -func (c *assetCopier) agent(ctx context.Context, id string) (string, error) { - source, err := parseID(id) - if err != nil { - return "", ErrNotFound - } - row, err := c.q.LockAgent(ctx, sqlc.LockAgentParams{TenantID: c.source, ID: source}) - if err != nil { - return "", err - } - var configuration map[string]json.RawMessage - if json.Unmarshal(row.Configuration, &configuration) != nil { - return "", ErrInvalidInput - } - var tools []map[string]json.RawMessage - if raw := configuration["tools"]; len(raw) > 0 && json.Unmarshal(raw, &tools) != nil { - return "", ErrInvalidInput - } - for _, tool := range tools { - var kind string - if json.Unmarshal(tool["type"], &kind) != nil { - return "", ErrInvalidInput - } - if kind != "mcp" { - continue - } - raw, exists := tool["credential_id"] - if !exists || string(raw) == "null" { - continue - } - var credentialID string - if json.Unmarshal(raw, &credentialID) != nil { - return "", ErrInvalidInput - } - tool["credential_id"] = json.RawMessage("null") - if !c.dependencies { - continue - } - credential, err := parseID(credentialID) - if err != nil { - return "", ErrNotFound - } - credentialID = copyID(credential) - original, err := c.q.GetAdminCopyCredential(ctx, sqlc.GetAdminCopyCredentialParams{TenantID: c.source, ID: credential}) - if err != nil { - return "", err - } - if _, err := c.copy(ctx, "vault", copyID(original.VaultID), ""); err != nil { - return "", err - } - if target := c.mapped["credential:"+credentialID]; target != "" { - tool["credential_id"], err = json.Marshal(target) - if err != nil { - return "", err - } - } - } - if _, ok := configuration["tools"]; ok { - configuration["tools"], err = json.Marshal(tools) - if err != nil { - return "", err - } - } - raw, err := json.Marshal(configuration) - if err != nil { - return "", err - } - target := copyUUID() - _, err = c.q.CreateAgent(ctx, sqlc.CreateAgentParams{ID: target, TenantID: c.target, Metadata: row.Metadata, Configuration: raw}) - if err != nil { - return "", err - } - model, err := c.q.GetAgentForSession(ctx, sqlc.GetAgentForSessionParams{TenantID: c.source, AgentID: source}) - if err != nil { - return "", err - } - if len(model.EncryptedConfig) > 0 { - plain, err := c.s.credentialCipher.OpenAgentModelExecution(model.EncryptedConfig, c.sourceTenant, id) - if err != nil { - return "", err - } - encrypted, err := c.s.credentialCipher.SealAgentModelExecution(plain, c.targetTenant, copyID(target)) - if err != nil { - return "", err - } - if err := c.q.SaveAgentModelExecution(ctx, sqlc.SaveAgentModelExecutionParams{AgentID: target, EncryptedConfig: encrypted}); err != nil { - return "", err - } - } - return c.add("agent", id, copyID(target), ""), nil -} diff --git a/services/agents-api/internal/store/admin_copy_credentials_test.go b/services/agents-api/internal/store/admin_copy_credentials_test.go deleted file mode 100644 index 80a78a7b7..000000000 --- a/services/agents-api/internal/store/admin_copy_credentials_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package store - -import ( - "bytes" - "encoding/json" - "errors" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" -) - -func TestAdminCopyAgentVaultCredentialsAndEncryptedProvider(t *testing.T) { - s, source, target := adminCopyFixture(t) - vault, err := s.CreateVault(t.Context(), source, CreateVaultInput{Metadata: map[string]string{"purpose": "copy"}}) - if err != nil { - t.Fatal(err) - } - static, err := s.CreateStaticCredential(t.Context(), source, vault.ID, CreateStaticCredentialInput{Name: "static", MCPServerURL: "https://copy.example/mcp", Token: "copy-static-secret"}) - if err != nil { - t.Fatal(err) - } - oauth, err := s.CreateOAuthCredential(t.Context(), source, vault.ID, CreateOAuthCredentialInput{Name: "oauth", MCPServerURL: "https://copy.example/oauth", AccessToken: "copy-oauth-secret"}) - if err != nil { - t.Fatal(err) - } - refresh, err := s.CreateOAuthCredential(t.Context(), source, vault.ID, CreateOAuthCredentialInput{Name: "refresh", MCPServerURL: "https://copy.example/refresh", AccessToken: "copy-access-secret", RefreshToken: "copy-refresh-secret", OAuth: OAuthMetadata{Refresh: &OAuthRefreshMetadata{ClientID: "copy-client", TokenEndpoint: "https://copy.example/token", TokenEndpointAuth: "none"}}}) - if err != nil { - t.Fatal(err) - } - provider := agentProviderFixture(66) - var configuration map[string]any - if err := json.Unmarshal(agentProviderConfiguration(t, provider, "codex"), &configuration); err != nil { - t.Fatal(err) - } - configuration["tools"] = []map[string]any{{"type": "mcp", "server_label": "static", "credential_id": strings.ToUpper(static.ID)}, {"type": "mcp", "server_label": "refresh", "credential_id": refresh.ID}} - raw, err := json.Marshal(configuration) - if err != nil { - t.Fatal(err) - } - agent, err := s.CreateAgent(t.Context(), source, CreateAgentInput{Configuration: raw, ModelProvider: provider}) - if err != nil { - t.Fatal(err) - } - result, err := s.CopyAssets(adminCopyContext(t, target), source, target, CopyAssetsInput{ResourceType: "agent", ResourceID: strings.ToUpper(agent.ID), IncludeDependencies: true}) - if err != nil { - t.Fatal(err) - } - if len(result.Skipped) != 1 || result.Skipped[0].SourceID != refresh.ID { - t.Fatal("refresh grant not skipped") - } - copyAgent := copiedID(t, result, "agent", agent.ID) - copyVault := copiedID(t, result, "vault", vault.ID) - copyStatic := copiedID(t, result, "credential", static.ID) - copyOAuth := copiedID(t, result, "credential", oauth.ID) - saved, inherited, err := s.GetAgentForSession(t.Context(), target, copyAgent, true) - if err != nil || inherited == nil || *inherited != *provider { - t.Fatal("provider not rebound", err) - } - var copied struct { - Tools []struct { - CredentialID *string `json:"credential_id"` - } `json:"tools"` - } - if json.Unmarshal(saved.Configuration, &copied) != nil || len(copied.Tools) != 2 || copied.Tools[0].CredentialID == nil || *copied.Tools[0].CredentialID != copyStatic || copied.Tools[1].CredentialID != nil { - t.Fatal("agent credentials not remapped or cleared") - } - for _, pair := range []struct{ id, kind, token string }{{copyStatic, "static_bearer", "copy-static-secret"}, {copyOAuth, "mcp_oauth", "copy-oauth-secret"}} { - tenantID, _ := parseID(target) - credentialID, _ := parseID(pair.id) - row, err := s.queries.GetAdminCopyCredential(t.Context(), sqlc.GetAdminCopyCredentialParams{TenantID: tenantID, ID: credentialID}) - if err != nil { - t.Fatal(err) - } - binding := credentialcrypto.Binding{TenantID: target, VaultID: copyVault, CredentialID: pair.id, AuthType: pair.kind, Destination: row.McpServerUrl} - plain, err := s.credentialCipher.Open(row.TokenCiphertext, binding) - if err != nil || !bytes.Contains(plain, []byte(pair.token)) { - t.Fatal("credential not rebound", err) - } - binding.TenantID = source - if _, err := s.credentialCipher.Open(row.TokenCiphertext, binding); err == nil { - t.Fatal("old tenant decrypted copied grant") - } - } - without, err := s.CopyAssets(adminCopyContext(t, target), source, target, CopyAssetsInput{ResourceType: "agent", ResourceID: agent.ID}) - if err != nil || len(without.Mappings) != 1 { - t.Fatal("dependency-disabled agent copy", err) - } - cleared, err := s.GetAgent(t.Context(), target, copiedID(t, without, "agent", agent.ID)) - if err != nil { - t.Fatal(err) - } - if json.Unmarshal(cleared.Configuration, &copied) != nil || copied.Tools[0].CredentialID != nil || copied.Tools[1].CredentialID != nil { - t.Fatal("dependency-disabled credential retained") - } - standalone, err := s.CopyAssets(adminCopyContext(t, target), source, target, CopyAssetsInput{ResourceType: "credential", ResourceID: static.ID, TargetVaultID: copyVault}) - if err != nil || len(standalone.Mappings) != 1 { - t.Fatal("standalone credential copy", err) - } - if _, err := s.CopyAssets(adminCopyContext(t, target), source, target, CopyAssetsInput{ResourceType: "credential", ResourceID: static.ID, TargetVaultID: vault.ID}); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign target vault accepted", err) - } - // Copying a Vault directly includes every grant, independently of the optional - // dependency flag used by Agents and Templates. - direct, err := s.CopyAssets(adminCopyContext(t, target), source, target, CopyAssetsInput{ResourceType: "vault", ResourceID: vault.ID}) - if err != nil || len(direct.Mappings) != 3 || len(direct.Skipped) != 1 { - t.Fatal("direct vault copy incomplete", err) - } - raw, _ = json.Marshal(result) - for _, secret := range []string{provider.APIKey, "copy-static-secret", "copy-oauth-secret", "copy-refresh-secret", "copy-access-secret"} { - if bytes.Contains(raw, []byte(secret)) { - t.Fatal("copy response exposes secret") - } - var count int - if err := s.pool.QueryRow(t.Context(), "SELECT count(*) FROM admin_audit_log WHERE tenant_id=$1 AND result_ids::text LIKE '%' || $2 || '%'", target, secret).Scan(&count); err != nil || count != 0 { - t.Fatal("audit exposes secret", err) - } - } -} - -func TestAdminCopyCredentialCanonicalBindings(t *testing.T) { - for _, kind := range []string{"static_bearer", "mcp_oauth"} { - t.Run(kind, func(t *testing.T) { - s, source, target := adminCopyFixture(t) - vault, err := s.CreateVault(t.Context(), source, CreateVaultInput{}) - if err != nil { - t.Fatal(err) - } - destination, err := s.CreateVault(t.Context(), target, CreateVaultInput{}) - if err != nil { - t.Fatal(err) - } - const token = "canonical-copy-private-token" - const url = "https://canonical-copy.example/mcp" - var credential Credential - if kind == "static_bearer" { - credential, err = s.CreateStaticCredential(t.Context(), source, vault.ID, CreateStaticCredentialInput{Name: "canonical", MCPServerURL: url, Token: token}) - } else { - credential, err = s.CreateOAuthCredential(t.Context(), source, vault.ID, CreateOAuthCredentialInput{Name: "canonical", MCPServerURL: url, AccessToken: token}) - } - if err != nil { - t.Fatal(err) - } - for _, sourceID := range []string{credential.ID, strings.ToUpper(credential.ID)} { - result, err := s.CopyAssets(adminCopyContext(t, target), source, target, CopyAssetsInput{ResourceType: "credential", ResourceID: sourceID, TargetVaultID: strings.ToUpper(destination.ID)}) - if err != nil { - t.Fatal("valid UUID copy failed", err) - } - id := copiedID(t, result, "credential", credential.ID) - selected, err := s.ResolveMCPCredentials(t.Context(), target, []string{destination.ID}, []MCPCredentialRequest{{ServerLabel: "copy", ServerURL: url, CredentialID: &id}}) - if err != nil || len(selected) != 1 { - t.Fatal("copied credential cannot be selected", err) - } - got, err := s.MCPBearerToken(t.Context(), target, []string{destination.ID}, selected[0]) - if err != nil || got != token { - t.Fatal("committed copy cannot decrypt through normal execution lookup", err) - } - } - }) - } -} diff --git a/services/agents-api/internal/store/admin_copy_templates.go b/services/agents-api/internal/store/admin_copy_templates.go deleted file mode 100644 index 614c8605d..000000000 --- a/services/agents-api/internal/store/admin_copy_templates.go +++ /dev/null @@ -1,75 +0,0 @@ -package store - -import ( - "context" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/jackc/pgx/v5/pgtype" -) - -func (c *assetCopier) template(ctx context.Context, id string) (string, error) { - source, err := parseID(id) - if err != nil { - return "", ErrNotFound - } - if _, err := c.q.LockAdminCopyTemplate(ctx, sqlc.LockAdminCopyTemplateParams{TenantID: c.source, ID: source}); err != nil { - return "", err - } - original, files, err := c.s.resolveEnvironmentTemplate(ctx, c.q, c.sourceTenant, id) - if err != nil { - return "", err - } - setup := original.Initialization - for i := range setup.Skills { - skill := &setup.Skills[i] - if skill.Metadata.Type != "skill_reference" { - continue - } - if !c.dependencies { - return "", ErrInvalidInput - } - skill.Metadata.SkillID, err = c.copy(ctx, "skill", skill.Metadata.SkillID, "") - if err != nil { - return "", err - } - } - for i := range files { - if files[i].Type != "file_id" { - continue - } - if !c.dependencies { - return "", ErrInvalidInput - } - files[i].FileID, err = c.copy(ctx, "file", files[i].FileID, "") - if err != nil { - return "", err - } - } - target := copyUUID() - targetID := copyID(target) - metadata, encrypted, err := c.s.sealTemplateFiles(c.targetTenant, targetID, files) - if err != nil { - return "", err - } - packages, env, commands, err := c.s.sealTemplateSetup(c.targetTenant, targetID, setup) - if err != nil { - return "", err - } - skills, skillContents, err := c.s.sealTemplateSkills(c.targetTenant, targetID, setup) - if err != nil { - return "", err - } - plugins, pluginContents, err := c.s.sealTemplatePlugins(c.targetTenant, targetID, setup) - if err != nil { - return "", err - } - var name pgtype.Text - if original.Name != nil { - name = pgtype.Text{String: *original.Name, Valid: true} - } - _, err = c.q.CreateEnvironmentTemplate(ctx, sqlc.CreateEnvironmentTemplateParams{ID: target, TenantID: c.target, Name: name, NetworkAccess: original.NetworkAccess, NetworkAllowedDomains: original.AllowedDomains, Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: env, SetupContents: commands, Skills: skills, SkillContents: skillContents, Plugins: plugins, PluginContents: pluginContents, CapabilityDirectories: append([]string{}, setup.CapabilityDirectories...)}) - if err != nil { - return "", err - } - return c.add("environment_template", id, targetID, ""), nil -} diff --git a/services/agents-api/internal/store/admin_copy_test.go b/services/agents-api/internal/store/admin_copy_test.go deleted file mode 100644 index d4c1b424b..000000000 --- a/services/agents-api/internal/store/admin_copy_test.go +++ /dev/null @@ -1,286 +0,0 @@ -package store - -import ( - "archive/zip" - "bytes" - "context" - "encoding/json" - "errors" - "io" - "reflect" - "sync" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/google/uuid" -) - -func adminCopyFixture(t *testing.T) (*Store, string, string) { - t.Helper() - _, pool := testStore(t) - cipher, err := credentialcrypto.New(bytes.Repeat([]byte{52}, 32)) - if err != nil { - t.Fatal(err) - } - source, target := uuid.NewString(), uuid.NewString() - for _, tenant := range []string{source, target} { - if _, err := pool.Exec(t.Context(), "INSERT INTO execution_project_scopes(tenant_id,organization_id,project_id) VALUES($1,'copy-test',$2)", tenant, tenant); err != nil { - t.Fatal(err) - } - if _, err := pool.Exec(t.Context(), "INSERT INTO projects(id,name,tenant_id,subject_kind,subject_id) VALUES($1,'Copy fixture',$1,'service_account',$2)", tenant, "project:"+tenant); err != nil { - t.Fatal(err) - } - } - return NewWithCredentialCipher(pool, cipher), source, target -} - -func adminCopyContext(t *testing.T, projectID string) context.Context { - t.Helper() - return adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "copy-admin", ActorLabel: "copy-test", RequestID: uuid.NewString(), TraceID: uuid.NewString(), ProjectID: projectID}) -} - -func copiedID(t *testing.T, result CopyAssetsResult, kind, source string) string { - t.Helper() - for _, mapping := range result.Mappings { - if mapping.Type == kind && mapping.SourceID == source { - return mapping.TargetID - } - } - t.Fatalf("missing %s mapping", kind) - return "" -} - -func TestAdminCopyFilesRetryIsolationAndAuditRollback(t *testing.T) { - s, source, target := adminCopyFixture(t) - data := bytes.Repeat([]byte("private-copy-file-canary"), 40000) - file, err := s.CreateSourceFile(t.Context(), source, uploadSource(data)) - if err != nil { - t.Fatal(err) - } - input := CopyAssetsInput{ResourceType: "file", ResourceID: file.ID, IdempotencyKey: uuid.NewString()} - count := sourceObjectCount(t, s.pool) - var results [2]CopyAssetsResult - var failures [2]error - var group sync.WaitGroup - for i := range results { - group.Add(1) - go func() { - defer group.Done() - results[i], failures[i] = s.CopyAssets(adminCopyContext(t, target), source, target, input) - }() - } - group.Wait() - for _, err := range failures { - if err != nil { - t.Fatal(err) - } - } - if !reflect.DeepEqual(results[0], results[1]) || sourceObjectCount(t, s.pool) != count+1 { - t.Fatal("concurrent replay duplicated copy") - } - targetID := copiedID(t, results[0], "file", file.ID) - if err := s.ReadSourceFile(t.Context(), target, targetID, func(_ SourceFile, r io.Reader) error { - body, err := io.ReadAll(r) - if !bytes.Equal(body, data) { - t.Error("file copy changed contents") - } - return err - }); err != nil { - t.Fatal(err) - } - if _, err := s.GetSourceFile(t.Context(), source, targetID); !errors.Is(err, ErrNotFound) { - t.Fatal("target leaked to source", err) - } - changed := input - changed.IncludeDependencies = true - if _, err := s.CopyAssets(adminCopyContext(t, target), source, target, changed); !errors.Is(err, ErrIdempotencyConflict) { - t.Fatal("changed retry accepted", err) - } - if _, err := s.CopyAssets(adminCopyContext(t, source), target, source, input); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign source accepted", err) - } - if _, err := s.CopyAssets(adminCopyContext(t, target), source, source, input); !errors.Is(err, ErrInvalidInput) { - t.Fatal("same tenant accepted", err) - } - var owners, audits, publicOwners int - for query, out := range map[string]*int{ - "SELECT count(*) FROM admin_resource_owners WHERE tenant_id=$1": &owners, - "SELECT count(*) FROM admin_audit_log WHERE tenant_id=$1": &audits, - "SELECT count(*) FROM write_audit_owners WHERE tenant_id=$1": &publicOwners, - } { - if err := s.pool.QueryRow(t.Context(), query, target).Scan(out); err != nil { - t.Fatal(err) - } - } - if owners != 1 || audits != 1 || publicOwners != 0 { - t.Fatal("incorrect administrator ownership", owners, audits, publicOwners) - } - // A real database trigger fails only this target's audit after the LO and row - // have been written, proving that both are owned by the same transaction. - function := "copy_audit_" + uuid.NewString()[:8] - ddl := "CREATE FUNCTION " + function + "() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.tenant_id='" + target + "'::uuid THEN RAISE EXCEPTION 'controlled copy audit failure'; END IF; RETURN NEW; END $$" - if _, err := s.pool.Exec(t.Context(), ddl); err != nil { - t.Fatal(err) - } - if _, err := s.pool.Exec(t.Context(), "CREATE TRIGGER "+function+" BEFORE INSERT ON admin_audit_log FOR EACH ROW EXECUTE FUNCTION "+function+"()"); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - _, _ = s.pool.Exec(context.Background(), "DROP TRIGGER IF EXISTS "+function+" ON admin_audit_log") - _, _ = s.pool.Exec(context.Background(), "DROP FUNCTION IF EXISTS "+function+"()") - }) - input.IdempotencyKey = uuid.NewString() - if _, err := s.CopyAssets(adminCopyContext(t, target), source, target, input); err == nil { - t.Fatal("audit failure committed") - } - if sourceObjectCount(t, s.pool) != count+1 { - t.Fatal("rolled back LO leaked") - } - var copies int - if err := s.pool.QueryRow(t.Context(), "SELECT count(*) FROM source_files WHERE tenant_id=$1", target).Scan(&copies); err != nil || copies != 1 { - t.Fatal("rolled back metadata leaked", copies, err) - } - if err := s.pool.QueryRow(t.Context(), "SELECT count(*) FROM admin_asset_copies WHERE target_tenant_id=$1", target).Scan(&copies); err != nil || copies != 1 { - t.Fatal("rolled back idempotency leaked", copies, err) - } -} - -func TestAdminCopySkillVersionsAndTemplateConfidentialDependencies(t *testing.T) { - s, source, target := adminCopyFixture(t) - archive := skillArchive(t, "copy-skill-secret") - skill, err := s.CreateSkill(t.Context(), source, archive) - if err != nil { - t.Fatal(err) - } - for i := 0; i < 3; i++ { - if _, err := s.CreateSkillVersion(t.Context(), source, skill.ID, archive, false); err != nil { - t.Fatal(err) - } - } - if _, err := s.DeleteSkillVersion(t.Context(), source, skill.ID, "2"); err != nil { - t.Fatal(err) - } - if _, err := s.UpdateSkillDefault(t.Context(), source, skill.ID, "3"); err != nil { - t.Fatal(err) - } - if _, err := s.DeleteSkillVersion(t.Context(), source, skill.ID, "4"); err != nil { - t.Fatal(err) - } - file, err := s.CreateSourceFile(t.Context(), source, uploadSource([]byte("copy-template-file"))) - if err != nil { - t.Fatal(err) - } - var pluginArchive bytes.Buffer - writer := zip.NewWriter(&pluginArchive) - for path, body := range map[string]string{"proof/.codex-plugin/plugin.json": `{"name":"copy-plugin","description":"Copy proof.","skills":"./skills"}`, "proof/skills/proof/SKILL.md": "---\nname: proof\ndescription: Copy proof.\n---\nplugin-copy-secret"} { - f, err := writer.Create(path) - if err != nil { - t.Fatal(err) - } - if _, err = f.Write([]byte(body)); err != nil { - t.Fatal(err) - } - } - if err := writer.Close(); err != nil { - t.Fatal(err) - } - setup := EnvironmentSetup{Env: map[string]string{"PRIVATE_VALUE": "copy-env-secret"}, Commands: []SetupCommand{{Command: "printf copy-setup-secret"}}, Skills: []EnvironmentSkill{{Metadata: EnvironmentSkillMetadata{Type: "skill_reference", SkillID: skill.ID, Version: "3"}}}, Plugins: []EnvironmentPlugin{{Metadata: agentplugin.Metadata{Type: "inline", Name: "copy-plugin", Description: "Copy proof."}, Archive: pluginArchive.Bytes()}}, CapabilityDirectories: []string{"/workspace/tools"}} - template, err := s.CreateEnvironmentTemplate(t.Context(), source, EnvironmentTemplateInput{Initialization: setup, Files: []InitialFile{{Type: "file_id", Path: "/workspace/source.txt", FileID: file.ID}, {Type: "inline", Path: "/workspace/inline.txt", Data: []byte("copy-inline-secret")}}}) - if err != nil { - t.Fatal(err) - } - input := CopyAssetsInput{ResourceType: "environment_template", ResourceID: template.ID} - if _, err := s.CopyAssets(adminCopyContext(t, target), source, target, input); !errors.Is(err, ErrInvalidInput) { - t.Fatal("dependencies silently omitted", err) - } - input.IncludeDependencies = true - result, err := s.CopyAssets(adminCopyContext(t, target), source, target, input) - if err != nil { - t.Fatal(err) - } - copySkill := copiedID(t, result, "skill", skill.ID) - meta, err := s.GetSkill(t.Context(), target, copySkill) - if err != nil || meta.DefaultVersion != 3 || meta.LatestVersion != 3 { - t.Fatal("version pointers changed", meta, err) - } - if _, _, err := s.ReadSkillVersion(t.Context(), target, copySkill, "2"); !errors.Is(err, ErrNotFound) { - t.Fatal("version gap lost", err) - } - for _, version := range []string{"1", "3"} { - _, got, err := s.ReadSkillVersion(t.Context(), target, copySkill, version) - if err != nil || !bytes.Equal(got, archive) { - t.Fatal("copied archive cannot decrypt", err) - } - } - next, err := s.CreateSkillVersion(t.Context(), target, copySkill, archive, false) - if err != nil || next.Version != 5 { - t.Fatal("next version reused deleted version", next.Version, err) - } - resolved, files, err := s.ResolveEnvironmentTemplate(t.Context(), target, copiedID(t, result, "environment_template", template.ID)) - if err != nil { - t.Fatal(err) - } - setup.Skills[0].Metadata.SkillID = copySkill - setup.Packages = setup.PackageMetadata() - if !reflect.DeepEqual(resolved.Initialization, setup) || len(files) != 2 || files[0].FileID != copiedID(t, result, "file", file.ID) || string(files[1].Data) != "copy-inline-secret" { - t.Fatal("confidential template fields or references changed") - } - raw, _ := json.Marshal(result) - for _, secret := range []string{"copy-env-secret", "copy-setup-secret", "plugin-copy-secret", "copy-inline-secret", "copy-skill-secret"} { - if bytes.Contains(raw, []byte(secret)) { - t.Fatal("response exposed secret") - } - } - // Fail after the Skill has been copied to prove dependency rollback. - missing := EnvironmentTemplateInput{Initialization: EnvironmentSetup{Skills: setup.Skills}, Files: []InitialFile{{Type: "file_id", Path: "/workspace/missing", FileID: "file-" + uuid.NewString()}}} - missing.Initialization.Skills[0].Metadata.SkillID = skill.ID - broken, err := s.CreateEnvironmentTemplate(t.Context(), source, missing) - if err != nil { - t.Fatal(err) - } - var before, after int - if err := s.pool.QueryRow(t.Context(), "SELECT count(*) FROM skills WHERE tenant_id=$1", target).Scan(&before); err != nil { - t.Fatal(err) - } - if _, err := s.CopyAssets(adminCopyContext(t, target), source, target, CopyAssetsInput{ResourceType: "environment_template", ResourceID: broken.ID, IncludeDependencies: true}); !errors.Is(err, ErrNotFound) { - t.Fatal("missing dependency accepted", err) - } - if err := s.pool.QueryRow(t.Context(), "SELECT count(*) FROM skills WHERE tenant_id=$1", target).Scan(&after); err != nil || before != after { - t.Fatal("dependency copy escaped rollback", err) - } -} - -func TestAdminCopyArchivedProjectBoundaries(t *testing.T) { - s, source, target := adminCopyFixture(t) - file, err := s.CreateSourceFile(t.Context(), source, uploadSource([]byte("retained project asset"))) - if err != nil { - t.Fatal(err) - } - if _, err := s.ArchiveProject(adminCopyContext(t, source), source); err != nil { - t.Fatal(err) - } - input := CopyAssetsInput{ResourceType: "file", ResourceID: file.ID, IdempotencyKey: uuid.NewString()} - result, err := s.CopyAssets(adminCopyContext(t, target), source, target, input) - if err != nil { - t.Fatal("archived source was not readable", err) - } - targetID := copiedID(t, result, "file", file.ID) - if _, err := s.ArchiveProject(adminCopyContext(t, target), target); err != nil { - t.Fatal(err) - } - before := adminMutationSnapshot(t, s, "source_files", "admin_asset_copies", "admin_resource_owners", "admin_audit_log", "pg_largeobject") - for _, key := range []string{input.IdempotencyKey, uuid.NewString()} { - input.IdempotencyKey = key - if _, err := s.CopyAssets(adminCopyContext(t, target), source, target, input); !errors.Is(err, ErrProjectArchived) { - t.Fatal("archived target accepted a copy or replay", err) - } - } - if !reflect.DeepEqual(before, adminMutationSnapshot(t, s, "source_files", "admin_asset_copies", "admin_resource_owners", "admin_audit_log", "pg_largeobject")) { - t.Fatal("rejected copy changed retained resources or audit") - } - if _, err := s.GetSourceFile(t.Context(), target, targetID); err != nil { - t.Fatal("archive lost the existing copy", err) - } -} diff --git a/services/agents-api/internal/store/admin_session_archive.go b/services/agents-api/internal/store/admin_session_archive.go index 8df3cdbac..2835b00f1 100644 --- a/services/agents-api/internal/store/admin_session_archive.go +++ b/services/agents-api/internal/store/admin_session_archive.go @@ -79,7 +79,7 @@ func (s *Store) ArchiveManagedSession(ctx context.Context, tenantID, sessionID s return err } } - if _, err := recordAdminMutation(ctx, q, tenantID, "archive", "session", runtimeUUID(session), nil); err != nil { + if err := recordAdminMutation(ctx, q, tenantID, "archive", "session", runtimeUUID(session)); err != nil { return err } result, err = getManagedSessionArchive(ctx, q, tenant, session) diff --git a/services/agents-api/internal/store/project_api_keys.go b/services/agents-api/internal/store/project_api_keys.go index bccd4a0f6..cd777f1b6 100644 --- a/services/agents-api/internal/store/project_api_keys.go +++ b/services/agents-api/internal/store/project_api_keys.go @@ -97,8 +97,7 @@ func (s *Store) CreateProjectAPIKey(ctx context.Context, project, id, name strin return err } result = IssuedProjectAPIKey{ProjectAPIKey: projectKeyMetadata(row), Key: token} - _, err = recordAdminMutation(ctx, q, uuid.UUID(p.TenantID.Bytes).String(), "create", "api_key", id, nil) - return err + return recordAdminMutation(ctx, q, uuid.UUID(p.TenantID.Bytes).String(), "create", "api_key", id) }) if err != nil { return IssuedProjectAPIKey{}, err @@ -164,8 +163,7 @@ func (s *Store) RevokeProjectAPIKey(ctx context.Context, project, id string) err if err != nil { return err } - _, err = recordAdminMutation(ctx, q, uuid.UUID(p.TenantID.Bytes).String(), "revoke", "api_key", id, nil) - return err + return recordAdminMutation(ctx, q, uuid.UUID(p.TenantID.Bytes).String(), "revoke", "api_key", id) }) } func (s *Store) ResolveProjectAPIKey(ctx context.Context, digest string) (ProjectAPIKeyBinding, error) { diff --git a/services/agents-api/internal/store/projects.go b/services/agents-api/internal/store/projects.go index 8fb1e160f..c05bb6780 100644 --- a/services/agents-api/internal/store/projects.go +++ b/services/agents-api/internal/store/projects.go @@ -84,8 +84,7 @@ func (s *Store) CreateProject(ctx context.Context, id, name string) (Project, er return err } result = projectBinding(row).Project - _, err = recordAdminMutation(ctx, q, result.TenantID, "create", "project", id, nil) - return err + return recordAdminMutation(ctx, q, result.TenantID, "create", "project", id) }) if err != nil { return Project{}, err @@ -173,8 +172,7 @@ func (s *Store) mutateProject(ctx context.Context, id, name string, archive bool return err } result = projectBinding(row).Project - _, err = recordAdminMutation(ctx, q, result.TenantID, action, "project", id, nil) - return err + return recordAdminMutation(ctx, q, result.TenantID, action, "project", id) }) if err != nil { return Project{}, err diff --git a/services/agents-api/internal/store/write_audit.go b/services/agents-api/internal/store/write_audit.go index e3a5bfa50..120af088d 100644 --- a/services/agents-api/internal/store/write_audit.go +++ b/services/agents-api/internal/store/write_audit.go @@ -60,8 +60,7 @@ func validateWriteAuditSource(source writeaudit.Source, tenant string) error { // without a source stay unattributed; a malformed supplied source fails closed. func recordWriteAudit(ctx context.Context, q *sqlc.Queries, tenant, action, resourceType, resourceID, parentID string, created ...AuditResource) error { if _, ok := adminaudit.FromContext(ctx); ok { - _, err := recordAdminMutation(ctx, q, tenant, action, resourceType, resourceID, nil) - return err + return recordAdminMutation(ctx, q, tenant, action, resourceType, resourceID) } source, ok := writeaudit.FromContext(ctx) if !ok { diff --git a/services/agents-api/internal/store/write_audit_test.go b/services/agents-api/internal/store/write_audit_test.go index e7fa79e88..90a3412fe 100644 --- a/services/agents-api/internal/store/write_audit_test.go +++ b/services/agents-api/internal/store/write_audit_test.go @@ -146,6 +146,28 @@ func TestWriteAuditOwnersIdentityReplayAndRevocation(t *testing.T) { } } +// The copy operation was removed; its committed provenance must stay readable. +func TestHistoricalAdminCopyProvenance(t *testing.T) { + s, pool := testStore(t) + project := createTestProject(t, s) + auditID, agentID := uuid.NewString(), uuid.NewString() + if _, err := pool.Exec(t.Context(), `INSERT INTO admin_audit_log(id,tenant_id,project_id,admin_credential_id,actor_label,action,resource_type,resource_id,result_ids,request_id,trace_id) + VALUES($1,$2,$3,'digest','admin','copy','agent','source-agent',$4::jsonb,'request','trace')`, auditID, project.TenantID, project.ID, `[{"type":"agent","source_id":"source-agent","target_id":"`+agentID+`"}]`); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(t.Context(), "INSERT INTO admin_resource_owners(tenant_id,resource_type,resource_id,audit_id) VALUES($1,'agent',$2,$3)", project.TenantID, agentID, auditID); err != nil { + t.Fatal(err) + } + owners, err := s.GetResourceOwners(t.Context(), project.TenantID, "agent", []string{agentID}) + if err != nil || len(owners) != 1 || owners[0].APIKey != nil || owners[0].Source == nil || *owners[0].Source != "admin_copy" || owners[0].AdminAuditID == nil || *owners[0].AdminAuditID != auditID { + t.Fatalf("historical copy owner: %+v %v", owners, err) + } + page, err := s.ListAdminAudit(t.Context(), AdminAuditFilter{ProjectID: project.ID, Action: "copy"}) + if err != nil || len(page.Data) != 1 || page.Data[0].ID != auditID || !strings.Contains(string(page.Data[0].ResultIDs), agentID) { + t.Fatalf("historical copy audit: %+v %v", page, err) + } +} + func TestWriteAuditCursorFiltersAndRetention(t *testing.T) { s, pool := testStore(t) tenant := uuid.NewString() diff --git a/services/agents-api/tests/official_admin_management.py b/services/agents-api/tests/official_admin_management.py index ba65a7bfe..d50b7b737 100644 --- a/services/agents-api/tests/official_admin_management.py +++ b/services/agents-api/tests/official_admin_management.py @@ -1,12 +1,14 @@ -"""Admin management and real copied-asset execution against a deployed Core. +"""Administrator management and real Project-asset execution against a deployed Core. Read private JSON settings from stdin: base, admin, model, harness and optional model_provider (the complete write-only provider object). run_model defaults to true; false performs resource checks only and never reports model acceptance. The deployment and its qualified runtime must already be running. This script -creates two Projects, exercises shared keys, and cleans up only its own resources. +creates two Projects, exercises shared keys and Project isolation, and cleans up +only its own resources. """ import base64 +from datetime import datetime import io import json import secrets @@ -58,7 +60,7 @@ def main(): secret_values = [private, settings["admin"]] if settings.get("model_provider"): secret_values.append(settings["model_provider"]["api_key"]) - owned, keys, clients, projects = set(), [], [], [] + owned, keys, clients, projects, revoked = set(), [], [], [], set() session_id = None with httpx.Client(trust_env=False, timeout=30) as http: def request(method, path, expected=200, token=None, headers=None, **kwargs): @@ -93,6 +95,10 @@ def issue(project, name): uuid.UUID(key["id"]) return key + def revoke(key): + request("DELETE", project_path(key) + "/keys/" + key["id"]) + revoked.add(key["id"]) + def operations(key, kind, resource): return safe(request("GET", project_path(key) + "/write-operations", params={ "resource_type": kind, "resource_id": resource, "limit": 100}))["data"] @@ -108,26 +114,6 @@ def remember(key, kind, resource): owned.add((key["project_id"], kind, resource)) return resource - def copy(source, target, kind, resource, dependencies=False, vault=None): - body = {"source_project_id": source["project_id"], "target_project_id": target["project_id"], - "resource_type": kind, "resource_id": resource, "include_dependencies": dependencies} - if vault: - body["resource_id"] = resource.upper() - body["target_vault_id"] = vault.upper() - headers = {"Idempotency-Key": str(uuid.uuid4())} - result = safe(request("POST", "/core/v1/admin/copies", json=body, headers=headers)) - for item in result["mappings"]: - if item["type"] in ADMIN_PATHS: - remember(target, item["type"], item["target_id"]) - assert safe(request("POST", "/core/v1/admin/copies", json=body, headers=headers)) == result - request("POST", "/core/v1/admin/copies", expected=409, - json={**body, "include_dependencies": not dependencies}, headers=headers) - return result - - def mapped(result, kind, source): - return next(item["target_id"] for item in result["mappings"] - if item["type"] == kind and item["source_id"] == source) - def equal_reads(key, kind, resource): public = request("GET", f"/v1/{PUBLIC_PATHS[kind]}/{resource}", token=key["key"]) management = request("GET", admin_resource(key, kind, resource)) @@ -142,12 +128,12 @@ def wait_idle(client, sid, wanted_turns): while time.monotonic() < deadline: state = client.beta.agents.sessions.retrieve(sid) turns = list(client.beta.agents.sessions.turns.list(sid, order="asc")) - assert state.status != "failed", "Real copied-asset Session failed" - assert all(turn.status not in ("failed", "cancelled") for turn in turns), "Real copied-asset Turn failed" + assert state.status != "failed", "Real Session failed" + assert all(turn.status not in ("failed", "cancelled") for turn in turns), "Real Turn failed" if len(turns) >= wanted_turns and turns[-1].status == "completed" and state.status == "idle": return state time.sleep(0.5) - raise AssertionError("Real copied-asset execution timed out") + raise AssertionError("Real execution timed out") try: for name in ("source", "target"): @@ -175,7 +161,6 @@ def wait_idle(client, sid, wanted_turns): assert safe(request("GET", "/v1/" + path, token=target["key"]))["data"] == [] request("GET", "/core/v1/admin/projects", expected=401, token=source["key"]) request("GET", "/v1/agents", expected=401, headers={"OpenAI-Beta": "agents=v1"}) - safe(request("GET", "/core/v1/admin/startup-configuration")) file = a.files.create(file=("copy-source.txt", private.encode()), purpose="user_data") remember(source, "file", file.id) @@ -184,12 +169,11 @@ def wait_idle(client, sid, wanted_turns): upload_files = [(name, bundle.read(name), "application/octet-stream") for name in bundle.namelist()] skill = a.skills.create(files=upload_files) remember(source, "skill", skill.id) - # Preserve a gap and a non-initial default across copies. + # The peer key edits a Skill created by another key in the same Project. for _ in range(2): a_peer.skills.versions.create(skill_id=skill.id, files=upload_files, default=False) a_peer.skills.update(skill.id, default_version="3") a_peer.skills.versions.delete(version="2", skill_id=skill.id) - archive = a.skills.content.retrieve(skill.id).read() template = a_peer.beta.agents.environments.templates.create( name="Confidential copy proof", env={"COPY_PRIVATE": private}, setup_commands=[{"command": "printf %s " + private + " > /workspace/copy-setup.txt"}], @@ -208,12 +192,6 @@ def wait_idle(client, sid, wanted_turns): remember(source, "vault", vault.id) static = a.beta.agents.vaults.credentials.create(vault.id, name="Static", auth={ "type": "static_bearer", "mcp_server_url": "https://copy.example/mcp", "token": private}) - oauth = a.beta.agents.vaults.credentials.create(vault.id, name="OAuth", auth={ - "type": "mcp_oauth", "mcp_server_url": "https://copy.example/oauth", "access_token": private}) - refresh = a.beta.agents.vaults.credentials.create(vault.id, name="Refresh", auth={ - "type": "mcp_oauth", "mcp_server_url": "https://copy.example/refresh", "access_token": private, - "refresh": {"client_id": "copy-proof", "refresh_token": private, - "token_endpoint": "https://copy.example/token", "token_endpoint_auth": {"type": "none"}}}) shared = [("agent", agent.id), ("skill", skill.id), ("file", file.id), ("environment_template", template.id), ("vault", vault.id)] @@ -248,9 +226,6 @@ def wait_idle(client, sid, wanted_turns): assert renamed == {**projects[0], "name": "Renamed project proof", "active_key_count": 2} projects[0].update(renamed) equal_reads(peer, "agent", agent.id) - request("POST", "/core/v1/admin/copies", expected=400, json={ - "source_project_id": source["project_id"], "target_project_id": source["project_id"], - "resource_type": "agent", "resource_id": agent.id}) for kind, resource in [("agent", agent.id), ("skill", skill.id), ("file", file.id), ("environment_template", template.id), ("vault", vault.id)]: @@ -289,6 +264,7 @@ def wait_idle(client, sid, wanted_turns): target_vault = b.beta.agents.vaults.create(name="Empty target attachment") remember(target, "vault", target_vault.id) + request("GET", "/v1/vaults/" + target_vault.id, expected=404, token=source["key"]) credential_errors = [] # The pinned MCP selection contract returns 400 for both missing # and foreign credentials within an explicitly attached Vault. @@ -300,91 +276,60 @@ def wait_idle(client, sid, wanted_turns): response["error"]["message"] = response["error"]["message"].replace(reference, "reference") credential_errors.append(response) assert credential_errors[0] == credential_errors[1], "Foreign credential differs from missing credential" - - direct_file = copy(source, target, "file", file.id) - copied_file = mapped(direct_file, "file", file.id) - assert b.files.retrieve(copied_file).bytes == len(private.encode()) - request("GET", "/v1/files/" + copied_file + "/content", expected=400, token=target["key"]) - direct_skill = copy(source, target, "skill", skill.id) - copied_skill = mapped(direct_skill, "skill", skill.id) - assert b.skills.retrieve(copied_skill).default_version == "3" - assert [item.version for item in b.skills.versions.list(copied_skill, order="asc")] == ["1", "3"] - assert b.skills.content.retrieve(copied_skill).read() == archive - request("POST", "/core/v1/admin/copies", expected=400, json={"source_project_id": source["project_id"], - "target_project_id": target["project_id"], "resource_type": "environment_template", "resource_id": template.id}) - copied_template = copy(source, target, "environment_template", template.id, dependencies=True) - template_id = mapped(copied_template, "environment_template", template.id) - copied_agent = copy(source, target, "agent", agent.id) - agent_id = mapped(copied_agent, "agent", agent.id) - copied_vault = copy(source, target, "vault", vault.id) - vault_id = mapped(copied_vault, "vault", vault.id) - assert len(copied_vault["skipped"]) == 1 and copied_vault["skipped"][0]["source_id"] == refresh.id - assert {item.id for item in b.beta.agents.vaults.credentials.list(vault_id)} == { - mapped(copied_vault, "credential", static.id), mapped(copied_vault, "credential", oauth.id)} - copy(source, target, "credential", static.id, vault=vault_id) - for kind, resource in [("agent", agent_id), ("environment_template", template_id), ("skill", copied_skill), ("vault", vault_id)]: - equal_reads(target, kind, resource) - print("Admin resource copies, isolation, confidential metadata and idempotency passed.", flush=True) + print("Shared keys, provenance, Project isolation and confidential metadata passed.", flush=True) if settings.get("run_model", True): - session = b.beta.agents.sessions.create(agent_id=agent_id, environment={ - "type": "openai_hosted", "environment_template_id": template_id}, + # One real Turn with the Project's own assets. Its creation key is + # revoked while the Turn runs; a replacement key continues the Session. + session = a.beta.agents.sessions.create(agent_id=agent.id, environment={ + "type": "openai_hosted", "environment_template_id": template.id}, input="Run the installed copy-proof skill's Python verifier now.", extra_headers={"Idempotency-Key": str(uuid.uuid4())}) - session_id = remember(target, "session", session.id) - completed = wait_idle(b, session_id, 1) + session_id = remember(source, "session", session.id) + creation_key, old_secret = source["id"], source["key"] + replacement = issue(projects[0], "source-rotation") + revoke(source) + revoked_at = next(key for key in safe(request("GET", project_path(replacement) + "/keys"))["data"] + if key["id"] == creation_key)["revoked_at"] + source = replacement + a.api_key = replacement["key"] + request("GET", "/v1/agents/sessions/" + session_id, expected=401, token=old_secret) + completed = wait_idle(a, session_id, 1) + turn = list(a.beta.agents.sessions.turns.list(session_id))[0] + assert turn.completed_at >= int(datetime.fromisoformat(revoked_at).timestamp()), "Turn ended before revocation" assert completed.usage is not None and completed.usage.total_tokens > 0, "Real model usage missing" - artifacts = list(b.beta.agents.sessions.artifacts.list(session_id)) + artifacts = list(a.beta.agents.sessions.artifacts.list(session_id)) artifact = next(item for item in artifacts if item.path == "/workspace/outputs/copy-proof.txt") - assert b.beta.agents.sessions.artifacts.content(artifact.id, session_id=session_id).read() == b"COPY_VERIFIED" - equal_reads(target, "session", session_id) + assert a.beta.agents.sessions.artifacts.content(artifact.id, session_id=session_id).read() == b"COPY_VERIFIED" + a.beta.agents.sessions.update(session_id, metadata={"continued_by": "replacement"}) + equal_reads(source, "session", session_id) for suffix in ("", "/turns", "/items", "/artifacts", "/artifacts/" + artifact.id): - management = request("GET", admin_resource(target, "session", session_id) + suffix) - public = request("GET", "/v1/agents/sessions/" + session_id + suffix, token=target["key"]) + management = request("GET", admin_resource(source, "session", session_id) + suffix) + public = request("GET", "/v1/agents/sessions/" + session_id + suffix, token=source["key"]) assert management.content == public.content, "Admin Session history differs" - request("GET", "/v1/agents/sessions/" + session_id + suffix, expected=404, token=source["key"]) - assert request("GET", admin_resource(target, "session", session_id) + "/artifacts/" + artifact.id + "/content").content == b"COPY_VERIFIED" - request("POST", "/core/v1/admin/copies", expected=400, json={"source_project_id": target["project_id"], - "target_project_id": source["project_id"], "resource_type": "session", "resource_id": session_id}) - request("POST", "/v1/agents/sessions/" + session_id, expected=404, token=source["key"], + request("GET", "/v1/agents/sessions/" + session_id + suffix, expected=404, token=target["key"]) + assert request("GET", admin_resource(source, "session", session_id) + "/artifacts/" + artifact.id + "/content").content == b"COPY_VERIFIED" + request("POST", "/v1/agents/sessions/" + session_id, expected=404, token=target["key"], json={"metadata": {"forbidden": "foreign-project"}}) - request("DELETE", "/v1/agents/sessions/" + session_id, expected=404, token=source["key"]) - request("POST", "/v1/agents/sessions/" + session_id + "/events", expected=404, token=source["key"], + request("DELETE", "/v1/agents/sessions/" + session_id, expected=404, token=target["key"]) + request("POST", "/v1/agents/sessions/" + session_id + "/events", expected=404, token=target["key"], json={"events": [{"type": "agent.session.input.message", "input": [ {"role": "user", "content": [{"type": "input_text", "text": "foreign"}]}]}]}) - history = b.beta.agents.sessions.items.list(session_id, order="asc").to_dict() - creation_key = target["id"] - old_secret = target["key"] - replacement = issue(projects[1], "target-rotation") - request("DELETE", project_path(target) + "/keys/" + target["id"]) - target = replacement - b.api_key = replacement["key"] - request("GET", "/v1/agents/sessions/" + session_id, expected=401, token=old_secret) - assert b.beta.agents.sessions.items.list(session_id, order="asc").to_dict() == history - b.beta.agents.sessions.events.create(session_id, events=[{"type": "agent.session.input.message", "input": [ - {"role": "user", "content": [{"type": "input_text", "text": "Reply COPY_CONTINUED."}]}]}]) - wait_idle(b, session_id, 2) - assert len(list(b.beta.agents.sessions.turns.list(session_id))) == 2 - assert owner(target, "session", session_id)["api_key"]["id"] == creation_key - assert {creation_key, replacement["id"]} <= {item["api_key"]["id"] for item in operations(target, "session", session_id)} + assert owner(source, "session", session_id)["api_key"]["id"] == creation_key + assert {creation_key, replacement["id"]} <= {item["api_key"]["id"] for item in operations(source, "session", session_id)} key_summary = safe(request("GET", "/core/v1/admin/summary", params={ - "group_by": "key", "project_id": target["project_id"]}))["data"] + "group_by": "key", "project_id": source["project_id"]}))["data"] assert sum(row["sessions"]["total"] for row in key_summary) == 1 group = next(row for row in key_summary if row["key_id"] == creation_key) - assert group["project_id"] == target["project_id"] and group["sessions"]["total"] == 1 and group["assets"] is None - assert group["usage"]["total_tokens"] == b.beta.agents.sessions.retrieve(session_id).usage.total_tokens + assert group["project_id"] == source["project_id"] and group["sessions"]["total"] == 1 and group["assets"] is None + assert group["usage"]["total_tokens"] == a.beta.agents.sessions.retrieve(session_id).usage.total_tokens assert all(row["sessions"]["total"] == 0 for row in key_summary if row["key_id"] != creation_key) - print("Real copied assets, Artifact reads and same-Project key rotation continuation passed.", flush=True) + print("Real Project-asset execution, Artifact reads and key rotation continuation passed.", flush=True) else: print("Real model execution not requested; resource checks only.", flush=True) - # Keep a receipt whose target will be archived: replay must reject too. - reverse_body = {"source_project_id": target["project_id"], "target_project_id": source["project_id"], - "resource_type": "agent", "resource_id": agent_id} - reverse_headers = {"Idempotency-Key": str(uuid.uuid4())} - reverse = safe(request("POST", "/core/v1/admin/copies", json=reverse_body, headers=reverse_headers)) - remember(source, "agent", mapped(reverse, "agent", agent_id)) + before_archive = {kind: request("GET", admin_resource(source, kind, resource)).content for kind, resource in shared} - request("DELETE", project_path(source) + "/keys/" + source["id"]) + revoke(source) request("GET", "/v1/agents", expected=401, token=source["key"]) equal_reads(peer, "agent", agent.id) peer_metadata = safe(request("GET", project_path(peer) + "/keys"))["data"] @@ -399,29 +344,33 @@ def wait_idle(client, sid, wanted_turns): for kind, resource in shared: assert request("GET", admin_resource(source, kind, resource)).content == before_archive[kind] request("POST", project_path(source) + "/keys", expected=409, json={"name": "archived"}) - request("POST", "/core/v1/admin/copies", expected=409, json=reverse_body, headers=reverse_headers) - request("POST", "/core/v1/admin/copies", expected=409, json=reverse_body, - headers={"Idempotency-Key": str(uuid.uuid4())}) - copy(source, target, "agent", agent.id) for group_by in ("project", "agent", "key"): summary = safe(request("GET", "/core/v1/admin/summary", params={ "group_by": group_by, "project_id": target["project_id"]})) assert all(row["project_id"] == target["project_id"] for row in summary["data"]) safe(request("GET", "/core/v1/admin/audit-log", params={"project_id": target["project_id"]})) - print("Project rename, archive, retained reads, archived-source copy and archived-target rejection passed.", flush=True) + print("Project rename, archive and retained reads passed.", flush=True) finally: failures = [] if session_id is not None: + # Read through the administrator route; cancel only with a key this run + # has not revoked, in a Project that is not archived. + session_path = base + f"/core/v1/admin/projects/{projects[0]['id']}/sessions/{session_id}" try: - state = clients[1].beta.agents.sessions.retrieve(session_id) - if state.status == "in_progress": - clients[1].beta.agents.sessions.events.create(session_id, - events=[{"type": "agent.session.input.cancel"}], idempotency_key=str(uuid.uuid4())) + state = http.get(session_path, headers=admin).json()["status"] + if state == "in_progress": + live = [key for key in keys if key["project_id"] == projects[0]["id"] and key["id"] not in revoked] + if projects[0]["archived_at"] is not None or not live: + raise RuntimeError("no usable key") + http.post(base + f"/v1/agents/sessions/{session_id}/events", json={"events": [{"type": "agent.session.input.cancel"}]}, + headers={"Authorization": "Bearer " + live[0]["key"], "OpenAI-Beta": "agents=v1", "Idempotency-Key": str(uuid.uuid4())}) deadline = time.monotonic() + 60 - while state.status == "in_progress" and time.monotonic() < deadline: + while state == "in_progress" and time.monotonic() < deadline: time.sleep(0.5) - state = clients[1].beta.agents.sessions.retrieve(session_id) - except openai.APIError: + state = http.get(session_path, headers=admin).json()["status"] + if state == "in_progress": + failures.append("session_stop") + except (httpx.HTTPError, KeyError, ValueError, RuntimeError): failures.append("session_stop") # Sessions must finish before deletion; never remove another run's assets. priority = {kind: index for index, kind in enumerate(("session", "agent", "environment_template", "skill", "vault", "file"))} diff --git a/services/core-console/admin_routes.go b/services/core-console/admin_routes.go index ff6599139..503975512 100644 --- a/services/core-console/admin_routes.go +++ b/services/core-console/admin_routes.go @@ -24,15 +24,10 @@ func adminAPIRequest(r *http.Request) bool { switch parts[0] { case "projects": return read || r.Method == http.MethodPost - case "copies": - return r.Method == http.MethodPost - case "summary", "runtime-observations", "startup-configuration", "audit-log", "core-metrics": + case "summary", "runtime-observations", "audit-log", "core-metrics": return read } } - if len(parts) == 2 && parts[0] == "runtime-history" && parts[1] == "capabilities" { - return read - } if len(parts) < 2 || parts[0] != "projects" { return false } diff --git a/services/core-console/api_keys_test.go b/services/core-console/api_keys_test.go index f881edc6f..e22459f70 100644 --- a/services/core-console/api_keys_test.go +++ b/services/core-console/api_keys_test.go @@ -18,7 +18,7 @@ func TestAdminProxyUsesAccountIdentityAndServerCredential(t *testing.T) { w.WriteHeader(200) })) h := accountConsole(t, c) - paths := []struct{ method, path string }{{"GET", "/core/v1/admin/projects?limit=5"}, {"POST", "/core/v1/admin/projects"}, {"POST", "/core/v1/admin/projects/project/keys"}, {"POST", "/core/v1/admin/projects/project/archive"}, {"DELETE", "/core/v1/admin/projects/project/keys/key"}, {"GET", "/core/v1/admin/projects/key/sessions/session/artifacts/artifact/content"}, {"DELETE", "/core/v1/admin/projects/key/skills/skill/versions/1"}, {"POST", "/core/v1/admin/copies"}, {"GET", "/core/v1/admin/summary"}, {"GET", "/core/v1/admin/core-metrics?range=6h"}} + paths := []struct{ method, path string }{{"GET", "/core/v1/admin/projects?limit=5"}, {"POST", "/core/v1/admin/projects"}, {"POST", "/core/v1/admin/projects/project/keys"}, {"POST", "/core/v1/admin/projects/project/archive"}, {"DELETE", "/core/v1/admin/projects/project/keys/key"}, {"GET", "/core/v1/admin/projects/key/sessions/session/artifacts/artifact/content"}, {"DELETE", "/core/v1/admin/projects/key/skills/skill/versions/1"}, {"GET", "/core/v1/admin/summary"}, {"GET", "/core/v1/admin/core-metrics?range=6h"}} for _, tc := range paths { if w := authRequest(h, tc.method, tc.path, `{}`, nil); w.Code != 401 { t.Errorf("unauthenticated %s = %d", tc.path, w.Code)