diff --git a/Cargo.lock b/Cargo.lock index 8b282534..086887b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3290,6 +3290,7 @@ dependencies = [ "api", "async-trait", "environment-protocol", + "serde_json", "thiserror 1.0.69", ] diff --git a/README.md b/README.md index bcad8ff6..4bfb6482 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@

Run thousands of agents. Efficient, durable, auditable.

-Lightspeed is open-source infrastructure for running long-lived agent fleets as durable workflows. +Lightspeed is open-source infrastructure for running managed agent fleets as durable workflows. -Agents survive restarts, can run for months, and stay cheap when idle. When they +"Managed agents" is an emerging pattern that separates the core agent loops from the VM or sandbox they use. Agents survive restarts, can run for months, and stay cheap when idle. When they need an operating system, they borrow a real machine for as long as the task requires. @@ -22,7 +22,7 @@ requires. Lightspeed's Rust core runs on [Temporal](https://temporal.io/) today and stores production data in Postgres with optional S3. The frontend is TypeScript and -React. Support for other durable workflow engines is planned. +React. ## Why Lightspeed? @@ -107,8 +107,10 @@ Lightspeed covers the table stakes of a modern agent harness. Everything below w non-Anthropic routes - [x] **Catalogs**: one keyed text representation for VFS, skill, sub-agent, and client catalogs, with independent source data and version history -- [x] **Environment tool grants**: independent Off/Read only/Edit file tools, - command execution, and durable jobs; transfers respect file grants. +- [x] **Environment attachments**: a session attaches the machines it may use, + each with a read/edit/exec/jobs access level, optional working directory, + and one default; the toolset is the union of those grants and transfers + respect them. - [x] **Filesystem sources**: independent VFS/environment working directories, opt-in prompt instructions from direct `.md`/`.txt` files in filename order, skill discovery, and optional root overrides @@ -145,7 +147,8 @@ Lightspeed covers the table stakes of a modern agent harness. Everything below w **Borrowed compute** - [x] **Dedicated VMs**: attach an existing machine or provision one through the - included Incus provider + included Incus provider; environment lifecycles remain independent of sessions. + Session selection checks attachment membership and registry state without waking or connecting to the machine - [x] **Bring your own compute**: start `lightspeed-envd` anywhere with a registration key and it dials in and registers itself, so NATed VMs, Kubernetes pods, and benchmark sandboxes need no inbound address @@ -173,7 +176,7 @@ Lightspeed covers the table stakes of a modern agent harness. Everything below w **Interfaces** - [x] **Web app**: manage universes, sessions, profiles, bots, and channels - from the browser + from the browser, with per-resource attachment access and MCP tool subsets - [x] **Progressive transcripts**: open at recent activity and automatically load earlier history as you scroll, while live updates continue - [x] **Input origin metadata**: distinguish direct human input from event deliveries diff --git a/clients/typescript/schema/api.schema.json b/clients/typescript/schema/api.schema.json index ad8cedee..fdebbae0 100644 --- a/clients/typescript/schema/api.schema.json +++ b/clients/typescript/schema/api.schema.json @@ -2407,17 +2407,6 @@ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/definitions/ProfileEnvironment" - }, - { - "type": "null" - } - ], - "description": "How the session obtains its active environment when this profile is\napplied: activate an existing universe environment, or provision a\nfresh one for this session. Absence leaves the session's current\nactive environment unchanged." - }, "instructions": { "anyOf": [ { @@ -2491,17 +2480,6 @@ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/definitions/ProfileEnvironment" - }, - { - "type": "null" - } - ], - "description": "How the session obtains its active environment when this profile is\napplied: activate an existing universe environment, or provision a\nfresh one for this session. Absence leaves the session's current\nactive environment unchanged." - }, "instructions": { "anyOf": [ { @@ -7758,6 +7736,49 @@ ], "type": "object" }, + "EnvironmentAccess": { + "description": "Per-attachment environment access, an ordered ladder: `edit` adds file\nediting to `read`, `exec` adds processes, `jobs` adds durable jobs.\nProcesses can write files regardless of the file-tool level, so\nread-only files with commands is deliberately not expressible.", + "enum": [ + "read", + "edit", + "exec", + "jobs" + ], + "type": "string" + }, + "EnvironmentAttachment": { + "additionalProperties": false, + "description": "One environment the session may use. Exactly one of `environmentId` and\n`inherit` identifies the machine. `inherit` is valid only in a profile\ndocument applied to a sub-agent: it resolves to the delegating parent's\nactive environment at spawn and is stored on the child as a concrete id.\nIf the parent's environment is also listed explicitly, the explicit\nattachment wins; if the parent has none, the inherit attachment is dropped.", + "properties": { + "access": { + "$ref": "#/definitions/EnvironmentAccess" + }, + "default": { + "description": "Activated when a profile is applied while the session has no active\nenvironment; creation is the trivial case. Never overrides a live\nselection and never applies on a plain `session/config/put`.", + "type": "boolean" + }, + "environmentId": { + "type": [ + "string", + "null" + ] + }, + "inherit": { + "type": "boolean" + }, + "workingDirectory": { + "description": "Absolute machine working directory for file tools, commands, jobs,\nand sources; absent uses the machine's advertised default.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "access" + ], + "type": "object" + }, "EnvironmentCloseParams": { "properties": { "environmentId": { @@ -8449,13 +8470,6 @@ "description": "Only environments carrying every listed metadata pair (AND\nsemantics); the same filter `session/list` accepts.", "type": "object" }, - "originSessionId": { - "description": "Only environments a profile provisioned for this session.", - "type": [ - "string", - "null" - ] - }, "providerId": { "type": [ "string", @@ -8494,32 +8508,6 @@ }, "type": "object" }, - "EnvironmentOriginSessionView": { - "properties": { - "closeWithSession": { - "description": "When true, Lightspeed closes the environment once the session closes.", - "type": "boolean" - }, - "profileId": { - "anyOf": [ - { - "$ref": "#/definitions/ProfileId" - }, - { - "type": "null" - } - ] - }, - "sessionId": { - "type": "string" - } - }, - "required": [ - "sessionId", - "closeWithSession" - ], - "type": "object" - }, "EnvironmentPowerPutParams": { "properties": { "environmentId": { @@ -9109,14 +9097,6 @@ ], "type": "object" }, - "EnvironmentToolSurface": { - "description": "Agent-facing environment filesystem tools; independent of execution grants.", - "enum": [ - "readOnly", - "edit" - ], - "type": "string" - }, "EnvironmentView": { "properties": { "createdAtMs": { @@ -9167,17 +9147,6 @@ }, "type": "object" }, - "originSession": { - "anyOf": [ - { - "$ref": "#/definitions/EnvironmentOriginSessionView" - }, - { - "type": "null" - } - ], - "description": "Present when a profile provisioned this environment for a session.\nProvenance and an optional close trigger, not ownership: the\nenvironment remains an ordinary universe resource." - }, "publicEndpoint": { "type": [ "string", @@ -9216,17 +9185,14 @@ }, "EnvironmentsFeature": { "additionalProperties": false, - "description": "Grants active session environments. Filesystem tools, commands, selection,\ndurable jobs, prompts, and skills are independent, default-off sub-grants.", + "description": "Grants session environments. The `environments` list is the allowed set:\nthe session can select, read, and run work only on a listed machine, each\nwith its own access grant and working directory. The installed tool\nsurface is the union of every attachment's grant; a call the active\nmachine's grant does not cover fails at execution, so switching machines\nnever changes the toolset. `{}` grants the feature with no reachable\nmachine.", "properties": { - "commands": { - "default": false, - "description": "Grants command execution and process continuation. Commands may modify\nfiles even when filesystem tools are read-only or disabled.", - "type": "boolean" - }, - "jobs": { - "default": false, - "description": "Grants the advanced durable-job tool surface. The workflow binding is\ninstalled for the session when granted; invocations still require an\nactive, ready environment with matching job capabilities.", - "type": "boolean" + "environments": { + "description": "The environments this session may use; unique ids, at most one\ndefault, at most one `inherit` (profiles only).", + "items": { + "$ref": "#/definitions/EnvironmentAttachment" + }, + "type": "array" }, "prompts": { "anyOf": [ @@ -9239,29 +9205,9 @@ ], "description": "Independent environment prompt loading; absent disables sourced instructions." }, - "providers": { - "description": "Absent means every registered provider is allowed.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "registrationKeys": { - "description": "Registration keys whose registered environments the session may\nlist and activate; absent means every key. Independent of\n`providers`: each list scopes its own environment source, and\nexternal environments pass only when neither list is set.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "selectionTools": { + "selection": { "default": false, - "description": "Exposes `environment_list`, `environment_activate`, and\n`environment_deactivate` to the model. `environment_read` is available\nwhenever environments are enabled, and external API/profile activation\nremains available when this is false.", + "description": "Exposes `environment_list`, `environment_activate`, and\n`environment_deactivate` over the attached environments.\n`environment_read` is available whenever environments are enabled, and\nexternal API/profile activation remains available when this is false.", "type": "boolean" }, "skills": { @@ -9275,29 +9221,11 @@ ], "description": "Independent environment skill discovery. Absent disables discovery." }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/EnvironmentToolSurface" - }, - { - "type": "null" - } - ], - "description": "Filesystem tool surface. Absent installs no filesystem tools; sources\nremain independent. Read-only does not restrict commands or durable jobs." - }, "version": { "default": 1, "format": "uint32", "minimum": 0, "type": "integer" - }, - "workingDirectory": { - "description": "Absolute machine working directory for file tools, commands, jobs, and sources; absent uses the endpoint default.", - "type": [ - "string", - "null" - ] } }, "type": "object" @@ -9603,17 +9531,6 @@ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/definitions/ProfileEnvironment" - }, - { - "type": "null" - } - ], - "description": "How the session obtains its active environment when this profile is\napplied: activate an existing universe environment, or provision a\nfresh one for this session. Absence leaves the session's current\nactive environment unchanged." - }, "instructions": { "anyOf": [ { @@ -9919,17 +9836,6 @@ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/definitions/SessionEnvironmentOverride" - }, - { - "type": "null" - } - ], - "description": "Optional creation-time override for the selected profile's environment\nintent. Omit to use the profile's intent unchanged." - }, "metadata": { "additionalProperties": { "type": "string" @@ -10033,11 +9939,12 @@ }, "McpFeature": { "additionalProperties": false, - "description": "Grants remote MCP tools by declaring linked servers from the universe MCP\ncatalog; must link at least one server, with unique server ids.", + "description": "Grants remote MCP tools by declaring attached servers from the universe MCP\ncatalog. Server ids must be unique; an empty list grants no MCP tools.", "properties": { "servers": { + "default": [], "items": { - "$ref": "#/definitions/McpServerLink" + "$ref": "#/definitions/McpServerAttachment" }, "type": "array" }, @@ -10076,6 +9983,29 @@ ], "type": "object" }, + "McpServerAttachment": { + "additionalProperties": false, + "description": "A selected universe MCP server. Its catalog record owns connection,\nexecution, exposure, approval, and auth; the attachment may only narrow the\nrecord's tool allowlist for this session.", + "properties": { + "serverId": { + "type": "string" + }, + "tools": { + "description": "Non-empty subset of the record's allowed tools exposed to this\nsession, under both injection and search; absent exposes the record's\nfull allowlist.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "serverId" + ], + "type": "object" + }, "McpServerAuthDiscoverParams": { "description": "Read-only authentication discovery for a prospective MCP endpoint. A\nmissing OAuth result is deliberately inconclusive: the server may be\npublic, use bearer auth, or expose OAuth metadata only through an explicit\nURL. No catalog or auth records are created by this probe.", "properties": { @@ -10273,13 +10203,14 @@ "null" ] }, - "approvalDefault": { + "approval": { "allOf": [ { "$ref": "#/definitions/RemoteMcpApprovalPolicy" } ], - "default": "never" + "default": "never", + "description": "Approval policy for every session linking this server." }, "authPolicy": { "allOf": [ @@ -10304,7 +10235,8 @@ "defaultServerLabel": { "type": "string" }, - "deferLoadingDefault": { + "deferLoading": { + "description": "Provider-side deferred loading of tool definitions where supported.", "type": [ "boolean", "null" @@ -10360,19 +10292,6 @@ ], "type": "object" }, - "McpServerLink": { - "additionalProperties": false, - "description": "A selected universe MCP server. Its catalog record owns all connection and\nbehavior configuration.", - "properties": { - "serverId": { - "type": "string" - } - }, - "required": [ - "serverId" - ], - "type": "object" - }, "McpServerListParams": { "properties": { "status": { @@ -10539,7 +10458,7 @@ "null" ] }, - "approvalDefault": { + "approval": { "$ref": "#/definitions/RemoteMcpApprovalPolicy" }, "authPolicy": { @@ -10562,7 +10481,7 @@ "defaultServerLabel": { "type": "string" }, - "deferLoadingDefault": { + "deferLoading": { "type": [ "boolean", "null" @@ -10611,7 +10530,7 @@ "defaultServerLabel", "execution", "exposure", - "approvalDefault", + "approval", "allowPrivateNetwork", "authPolicy", "status", @@ -12055,7 +11974,6 @@ "default": { "activeEnvironmentChanged": false, "configChanged": false, - "environmentProvisioned": false, "instructionsChanged": false } }, @@ -12076,11 +11994,6 @@ "configChanged": { "type": "boolean" }, - "environmentProvisioned": { - "default": false, - "description": "True when this apply created a new environment for the session.", - "type": "boolean" - }, "instructionsChanged": { "type": "boolean" } @@ -12136,137 +12049,6 @@ ], "type": "object" }, - "ProfileEnvironment": { - "description": "Environment intent carried by a profile document.", - "oneOf": [ - { - "additionalProperties": false, - "description": "Activate an existing universe environment. The profile never closes\nit.", - "properties": { - "environmentId": { - "type": "string" - }, - "type": { - "const": "existing", - "type": "string" - } - }, - "required": [ - "type", - "environmentId" - ], - "type": "object" - }, - { - "additionalProperties": false, - "description": "Activate the delegating parent's active environment. Resolved at\nsub-agent spawn, shared not copied, never closed by the\nchild; rejected on a session without a delegation origin or whose\nparent has no active environment.", - "properties": { - "type": { - "const": "inherit", - "type": "string" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - { - "additionalProperties": false, - "description": "Provision one environment for the session from the universe's enabled\nbinding for `providerId`, then activate it. The provision request id\nis derived from the session id, so retries and repeated applies\nconverge on the same environment.", - "properties": { - "credentials": { - "description": "Credentials bound to the environment right after it is\nprovisioned before activation: references to universe\ngrants/providers/secrets, never values. They become ordinary\nenvironment credential bindings; the profile is the initial set,\nnot a live sync. Not available for `existing` environments.", - "items": { - "$ref": "#/definitions/ProfileEnvironmentCredential" - }, - "type": "array" - }, - "displayName": { - "type": [ - "string", - "null" - ] - }, - "idlePolicy": { - "anyOf": [ - { - "$ref": "#/definitions/EnvironmentIdlePolicyView" - }, - { - "type": "null" - } - ], - "description": "Optional staged idle policy for the provisioned environment.\nStages the provider cannot realize are skipped." - }, - "metadata": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - "providerId": { - "type": "string" - }, - "retention": { - "allOf": [ - { - "$ref": "#/definitions/ProfileEnvironmentRetention" - } - ], - "default": "closeWithSession" - }, - "templateId": { - "description": "Immutable provider template-version identity.", - "type": "string" - }, - "type": { - "const": "provision", - "type": "string" - } - }, - "required": [ - "type", - "providerId", - "templateId" - ], - "type": "object" - } - ] - }, - "ProfileEnvironmentCredential": { - "additionalProperties": false, - "description": "One environment credential binding requested by a profile: the same shape\nas `environments/credentials/bind`.", - "properties": { - "envName": { - "description": "Environment variable name (`[A-Za-z_][A-Za-z0-9_]{0,127}`).", - "type": "string" - }, - "source": { - "$ref": "#/definitions/EnvironmentCredentialSourceView" - } - }, - "required": [ - "envName", - "source" - ], - "type": "object" - }, - "ProfileEnvironmentRetention": { - "description": "What happens to a profile-provisioned environment when its originating\nsession closes.", - "oneOf": [ - { - "const": "closeWithSession", - "description": "Close the environment when the session that provisioned it closes.", - "type": "string" - }, - { - "const": "retain", - "description": "Leave the environment open; the universe owns its cleanup.", - "type": "string" - } - ] - }, "ProfileId": { "type": "string" }, @@ -13323,41 +13105,6 @@ ], "type": "object" }, - "SessionEnvironmentOverride": { - "description": "Creation-time override for the environment intent carried by a profile.\nAbsence uses the profile unchanged; `none` suppresses its environment\nintent, while `existing` activates the specified universe environment.", - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "type": { - "const": "none", - "type": "string" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "environmentId": { - "type": "string" - }, - "type": { - "const": "existing", - "type": "string" - } - }, - "required": [ - "type", - "environmentId" - ], - "type": "object" - } - ] - }, "SessionEventDirection": { "enum": [ "forward", @@ -15541,17 +15288,6 @@ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/definitions/SessionEnvironmentOverride" - }, - { - "type": "null" - } - ], - "description": "Optional creation-time override for the selected profile's environment\nintent. Omit to use the profile's intent unchanged." - }, "metadata": { "additionalProperties": { "type": "string" @@ -16640,7 +16376,7 @@ }, "VfsFeature": { "additionalProperties": false, - "description": "Grants the session virtual filesystem. Workspace links declare the\nsession-visible namespace and the VFS catalog is surfaced. Sub-grants are independent; `{}` grants a VFS with\nno tools and no sourcing.", + "description": "Grants the session virtual filesystem. Workspace attachments declare the\nsession-visible namespace and the VFS catalog is surfaced. The file tool\nsurface is derived from the attachments: any attachment installs the read\ntools, any `edit` attachment adds the write tools, and with the\nenvironments feature granted the matching transfer tools appear. `{}`\ngrants a VFS with no attachments, no tools, and no sourcing.", "properties": { "prompts": { "anyOf": [ @@ -16651,7 +16387,7 @@ "type": "null" } ], - "description": "Prompt-instruction sourcing from the VFS. Absent disables loading;\nan empty block discovers conventional linked roots." + "description": "Prompt-instruction sourcing from the VFS. Absent disables loading;\nan empty block discovers conventional attached roots." }, "skills": { "anyOf": [ @@ -16662,18 +16398,7 @@ "type": "null" } ], - "description": "Independent VFS skill discovery. Absent disables discovery and removes\nits runtime catalog; an empty block discovers conventional linked roots." - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/VfsToolSurface" - }, - { - "type": "null" - } - ], - "description": "Agent-facing filesystem tool surface; absent = no fs tools. Per-path\nwritability is defined by each workspace link's own access.\nWith the environments feature granted, `readOnly` also exposes\n`vfs_materialize`; `edit` additionally exposes `vfs_capture`.\nPrompt/skill sourcing alone does not grant transfer tools." + "description": "Independent VFS skill discovery. Absent disables discovery and removes\nits runtime catalog; an empty block discovers conventional attached roots." }, "version": { "default": 1, @@ -16688,10 +16413,10 @@ "null" ] }, - "workspaceLinks": { - "description": "Catalog resources exposed in the session's workspace namespace.", + "workspaces": { + "description": "Catalog resources exposed in the session's workspace namespace at\ndisjoint absolute paths.", "items": { - "$ref": "#/definitions/WorkspaceLink" + "$ref": "#/definitions/WorkspaceAttachment" }, "type": "array" } @@ -16702,7 +16427,7 @@ "additionalProperties": false, "properties": { "roots": { - "description": "Absent searches .agents/prompts and .lightspeed/prompts beneath each\nworkspace link. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace links.", + "description": "Absent searches .agents/prompts and .lightspeed/prompts beneath each\nworkspace attachment. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace attachments.", "items": { "type": "string" }, @@ -16718,7 +16443,7 @@ "additionalProperties": false, "properties": { "roots": { - "description": "Absent searches .agents/skills and .lightspeed/skills beneath each\nworkspace link. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace links.", + "description": "Absent searches .agents/skills and .lightspeed/skills beneath each\nworkspace attachment. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace attachments.", "items": { "type": "string" }, @@ -16799,13 +16524,6 @@ ], "type": "object" }, - "VfsToolSurface": { - "enum": [ - "readOnly", - "edit" - ], - "type": "string" - }, "VfsWorkspaceCreateParams": { "properties": { "displayName": { @@ -17476,68 +17194,42 @@ } ] }, - "WorkspaceLink": { + "WorkspaceAccess": { + "description": "Per-attachment VFS access; `edit` implies `read`.", + "enum": [ + "read", + "edit" + ], + "type": "string" + }, + "WorkspaceAttachment": { "additionalProperties": false, + "description": "One catalog resource mounted into the session namespace. Exactly one of\n`workspaceId` and `snapshotRef` names the resource; snapshots are\nimmutable and must be attached with `read` access.", "properties": { "access": { - "$ref": "#/definitions/WorkspaceLinkAccess" + "$ref": "#/definitions/WorkspaceAccess" }, "path": { "type": "string" }, - "target": { - "$ref": "#/definitions/WorkspaceLinkTarget" + "snapshotRef": { + "type": [ + "string", + "null" + ] + }, + "workspaceId": { + "type": [ + "string", + "null" + ] } }, "required": [ "path", - "target", "access" ], "type": "object" - }, - "WorkspaceLinkAccess": { - "enum": [ - "readOnly", - "readWrite" - ], - "type": "string" - }, - "WorkspaceLinkTarget": { - "oneOf": [ - { - "properties": { - "type": { - "const": "workspace", - "type": "string" - }, - "workspaceId": { - "type": "string" - } - }, - "required": [ - "type", - "workspaceId" - ], - "type": "object" - }, - { - "properties": { - "snapshotRef": { - "type": "string" - }, - "type": { - "const": "snapshot", - "type": "string" - } - }, - "required": [ - "type", - "snapshotRef" - ], - "type": "object" - } - ] } }, "description": "All JSON-RPC wire types of the Lightspeed agent API.", diff --git a/clients/typescript/src/generated/methods.ts b/clients/typescript/src/generated/methods.ts index 83529edf..eb00b049 100644 --- a/clients/typescript/src/generated/methods.ts +++ b/clients/typescript/src/generated/methods.ts @@ -143,12 +143,12 @@ export const METHOD_INFO = { "session/start": { scope: "universe", summary: "Create or reopen a session", - description: "Creates a session with optional config/profile setup. Profile metadata and retention supply creation defaults; explicit start values override them. An existing-or-none environment override can replace the profile intent. Retrying an existing session id returns that session.", + description: "Creates a session with optional config/profile setup. Profile metadata and retention supply creation defaults; explicit start values override them. The default environment attachment in the effective config supplies the initial active environment. Retrying an existing session id returns that session.", }, "session/managed/start": { scope: "universe", summary: "Create or reopen a managed session", - description: "Creates a session with immutable lifecycle and workflow-tool declarations using explicit bound dispatch. Profile metadata, retention, and environment overrides follow session/start semantics. Retrying an id requires the same managed declaration; an ordinary session cannot be upgraded.", + description: "Creates a session with immutable lifecycle and workflow-tool declarations using explicit bound dispatch. Profile metadata, retention, and default environment attachment selection follow session/start semantics. Retrying an id requires the same managed declaration; an ordinary session cannot be upgraded.", }, "session/read": { scope: "universe", @@ -797,7 +797,7 @@ export interface MethodMap { /** * Create or reopen a session * - * Creates a session with optional config/profile setup. Profile metadata and retention supply creation defaults; explicit start values override them. An existing-or-none environment override can replace the profile intent. Retrying an existing session id returns that session. + * Creates a session with optional config/profile setup. Profile metadata and retention supply creation defaults; explicit start values override them. The default environment attachment in the effective config supplies the initial active environment. Retrying an existing session id returns that session. */ "session/start": { params: Api.SessionStartParams; @@ -806,7 +806,7 @@ export interface MethodMap { /** * Create or reopen a managed session * - * Creates a session with immutable lifecycle and workflow-tool declarations using explicit bound dispatch. Profile metadata, retention, and environment overrides follow session/start semantics. Retrying an id requires the same managed declaration; an ordinary session cannot be upgraded. + * Creates a session with immutable lifecycle and workflow-tool declarations using explicit bound dispatch. Profile metadata, retention, and default environment attachment selection follow session/start semantics. Retrying an id requires the same managed declaration; an ordinary session cannot be upgraded. */ "session/managed/start": { params: Api.ManagedSessionStartParams; @@ -1949,7 +1949,7 @@ export const rpc = { /** * Create or reopen a session * - * Creates a session with optional config/profile setup. Profile metadata and retention supply creation defaults; explicit start values override them. An existing-or-none environment override can replace the profile intent. Retrying an existing session id returns that session. + * Creates a session with optional config/profile setup. Profile metadata and retention supply creation defaults; explicit start values override them. The default environment attachment in the effective config supplies the initial active environment. Retrying an existing session id returns that session. */ sessionStart(client: RpcCaller, params: Api.SessionStartParams): Promise { return client.call("session/start", params); @@ -1957,7 +1957,7 @@ export const rpc = { /** * Create or reopen a managed session * - * Creates a session with immutable lifecycle and workflow-tool declarations using explicit bound dispatch. Profile metadata, retention, and environment overrides follow session/start semantics. Retrying an id requires the same managed declaration; an ordinary session cannot be upgraded. + * Creates a session with immutable lifecycle and workflow-tool declarations using explicit bound dispatch. Profile metadata, retention, and default environment attachment selection follow session/start semantics. Retrying an id requires the same managed declaration; an ordinary session cannot be upgraded. */ sessionManagedStart(client: RpcCaller, params: Api.ManagedSessionStartParams): Promise { return client.call("session/managed/start", params); diff --git a/clients/typescript/src/generated/types.ts b/clients/typescript/src/generated/types.ts index ff363dfb..87d269e3 100644 --- a/clients/typescript/src/generated/types.ts +++ b/clients/typescript/src/generated/types.ts @@ -270,40 +270,27 @@ export type CompactionPolicy = targetTokens?: number | null; }; /** - * Agent-facing environment filesystem tools; independent of execution grants. + * Per-attachment environment access, an ordered ladder: `edit` adds file + * editing to `read`, `exec` adds processes, `jobs` adds durable jobs. + * Processes can write files regardless of the file-tool level, so + * read-only files with commands is deliberately not expressible. * * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema - * via the `definition` "EnvironmentToolSurface". + * via the `definition` "EnvironmentAccess". */ -export type EnvironmentToolSurface = "readOnly" | "edit"; +export type EnvironmentAccess = "read" | "edit" | "exec" | "jobs"; /** * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema * via the `definition` "ProfileId". */ export type ProfileId = string; /** + * Per-attachment VFS access; `edit` implies `read`. + * * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema - * via the `definition` "VfsToolSurface". - */ -export type VfsToolSurface = "readOnly" | "edit"; -/** - * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema - * via the `definition` "WorkspaceLinkAccess". + * via the `definition` "WorkspaceAccess". */ -export type WorkspaceLinkAccess = "readOnly" | "readWrite"; -/** - * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema - * via the `definition` "WorkspaceLinkTarget". - */ -export type WorkspaceLinkTarget = - | { - type: "workspace"; - workspaceId: string; - } - | { - snapshotRef: string; - type: "snapshot"; - }; +export type WorkspaceAccess = "read" | "edit"; /** * Provider processing class used by session defaults and per-run overrides. * @@ -1480,54 +1467,6 @@ export type OperatorEnvironmentProviderTransport = providerType: string; type: "provider"; }; -/** - * Environment intent carried by a profile document. - * - * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema - * via the `definition` "ProfileEnvironment". - */ -export type ProfileEnvironment = - | { - environmentId: string; - type: "existing"; - } - | { - type: "inherit"; - } - | { - /** - * Credentials bound to the environment right after it is - * provisioned before activation: references to universe - * grants/providers/secrets, never values. They become ordinary - * environment credential bindings; the profile is the initial set, - * not a live sync. Not available for `existing` environments. - */ - credentials?: ProfileEnvironmentCredential[]; - displayName?: string | null; - /** - * Optional staged idle policy for the provisioned environment. - * Stages the provider cannot realize are skipped. - */ - idlePolicy?: EnvironmentIdlePolicyView | null; - metadata?: { - [k: string]: string; - }; - providerId: string; - retention?: ProfileEnvironmentRetention & string; - /** - * Immutable provider template-version identity. - */ - templateId: string; - type: "provision"; - }; -/** - * What happens to a profile-provisioned environment when its originating - * session closes. - * - * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema - * via the `definition` "ProfileEnvironmentRetention". - */ -export type ProfileEnvironmentRetention = "closeWithSession" | "retain"; /** * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema * via the `definition` "ProfileInstructions". @@ -1701,22 +1640,6 @@ export type SessionJobCancelScopeView = "job" | "dependents"; * via the `definition` "SessionJobDependencyPolicyView". */ export type SessionJobDependencyPolicyView = "allSucceeded" | "allTerminal"; -/** - * Creation-time override for the environment intent carried by a profile. - * Absence uses the profile unchanged; `none` suppresses its environment - * intent, while `existing` activates the specified universe environment. - * - * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema - * via the `definition` "SessionEnvironmentOverride". - */ -export type SessionEnvironmentOverride = - | { - type: "none"; - } - | { - environmentId: string; - type: "existing"; - }; /** * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema * via the `definition` "ProfileSource". @@ -2067,58 +1990,64 @@ export interface FeaturesConfig { web?: WebFeature | null; } /** - * Grants active session environments. Filesystem tools, commands, selection, - * durable jobs, prompts, and skills are independent, default-off sub-grants. + * Grants session environments. The `environments` list is the allowed set: + * the session can select, read, and run work only on a listed machine, each + * with its own access grant and working directory. The installed tool + * surface is the union of every attachment's grant; a call the active + * machine's grant does not cover fails at execution, so switching machines + * never changes the toolset. `{}` grants the feature with no reachable + * machine. * * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema * via the `definition` "EnvironmentsFeature". */ export interface EnvironmentsFeature { /** - * Grants command execution and process continuation. Commands may modify - * files even when filesystem tools are read-only or disabled. + * The environments this session may use; unique ids, at most one + * default, at most one `inherit` (profiles only). */ - commands?: boolean; - /** - * Grants the advanced durable-job tool surface. The workflow binding is - * installed for the session when granted; invocations still require an - * active, ready environment with matching job capabilities. - */ - jobs?: boolean; + environments?: EnvironmentAttachment[]; /** * Independent environment prompt loading; absent disables sourced instructions. */ prompts?: EnvironmentPromptsConfig | null; - /** - * Absent means every registered provider is allowed. - */ - providers?: string[] | null; - /** - * Registration keys whose registered environments the session may - * list and activate; absent means every key. Independent of - * `providers`: each list scopes its own environment source, and - * external environments pass only when neither list is set. - */ - registrationKeys?: string[] | null; /** * Exposes `environment_list`, `environment_activate`, and - * `environment_deactivate` to the model. `environment_read` is available - * whenever environments are enabled, and external API/profile activation - * remains available when this is false. + * `environment_deactivate` over the attached environments. + * `environment_read` is available whenever environments are enabled, and + * external API/profile activation remains available when this is false. */ - selectionTools?: boolean; + selection?: boolean; /** * Independent environment skill discovery. Absent disables discovery. */ skills?: EnvironmentSkillsConfig | null; + version?: number; +} +/** + * One environment the session may use. Exactly one of `environmentId` and + * `inherit` identifies the machine. `inherit` is valid only in a profile + * document applied to a sub-agent: it resolves to the delegating parent's + * active environment at spawn and is stored on the child as a concrete id. + * If the parent's environment is also listed explicitly, the explicit + * attachment wins; if the parent has none, the inherit attachment is dropped. + * + * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema + * via the `definition` "EnvironmentAttachment". + */ +export interface EnvironmentAttachment { + access: EnvironmentAccess; /** - * Filesystem tool surface. Absent installs no filesystem tools; sources - * remain independent. Read-only does not restrict commands or durable jobs. + * Activated when a profile is applied while the session has no active + * environment; creation is the trivial case. Never overrides a live + * selection and never applies on a plain `session/config/put`. */ - tools?: EnvironmentToolSurface | null; - version?: number; + default?: boolean; + environmentId?: string | null; + inherit?: boolean; /** - * Absolute machine working directory for file tools, commands, jobs, and sources; absent uses the endpoint default. + * Absolute machine working directory for file tools, commands, jobs, + * and sources; absent uses the machine's advertised default. */ workingDirectory?: string | null; } @@ -2157,25 +2086,32 @@ export interface EnvironmentSkillsConfig { roots?: [string, ...string[]] | null; } /** - * Grants remote MCP tools by declaring linked servers from the universe MCP - * catalog; must link at least one server, with unique server ids. + * Grants remote MCP tools by declaring attached servers from the universe MCP + * catalog. Server ids must be unique; an empty list grants no MCP tools. * * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema * via the `definition` "McpFeature". */ export interface McpFeature { - servers?: McpServerLink[]; + servers?: McpServerAttachment[]; version?: number; } /** - * A selected universe MCP server. Its catalog record owns all connection and - * behavior configuration. + * A selected universe MCP server. Its catalog record owns connection, + * execution, exposure, approval, and auth; the attachment may only narrow the + * record's tool allowlist for this session. * * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema - * via the `definition` "McpServerLink". + * via the `definition` "McpServerAttachment". */ -export interface McpServerLink { +export interface McpServerAttachment { serverId: string; + /** + * Non-empty subset of the record's allowed tools exposed to this + * session, under both injection and search; absent exposes the record's + * full allowlist. + */ + tools?: string[] | null; } /** * Grants sub-agent delegation: `agent_run` (joined, result inline) and @@ -2230,9 +2166,12 @@ export interface TimersFeature { version?: number; } /** - * Grants the session virtual filesystem. Workspace links declare the - * session-visible namespace and the VFS catalog is surfaced. Sub-grants are independent; `{}` grants a VFS with - * no tools and no sourcing. + * Grants the session virtual filesystem. Workspace attachments declare the + * session-visible namespace and the VFS catalog is surfaced. The file tool + * surface is derived from the attachments: any attachment installs the read + * tools, any `edit` attachment adds the write tools, and with the + * environments feature granted the matching transfer tools appear. `{}` + * grants a VFS with no attachments, no tools, and no sourcing. * * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema * via the `definition` "VfsFeature". @@ -2240,31 +2179,24 @@ export interface TimersFeature { export interface VfsFeature { /** * Prompt-instruction sourcing from the VFS. Absent disables loading; - * an empty block discovers conventional linked roots. + * an empty block discovers conventional attached roots. */ prompts?: VfsPromptsConfig | null; /** * Independent VFS skill discovery. Absent disables discovery and removes - * its runtime catalog; an empty block discovers conventional linked roots. + * its runtime catalog; an empty block discovers conventional attached roots. */ skills?: VfsSkillsConfig | null; - /** - * Agent-facing filesystem tool surface; absent = no fs tools. Per-path - * writability is defined by each workspace link's own access. - * With the environments feature granted, `readOnly` also exposes - * `vfs_materialize`; `edit` additionally exposes `vfs_capture`. - * Prompt/skill sourcing alone does not grant transfer tools. - */ - tools?: VfsToolSurface | null; version?: number; /** * Absolute VFS tool working directory; absent uses /. */ workingDirectory?: string | null; /** - * Catalog resources exposed in the session's workspace namespace. + * Catalog resources exposed in the session's workspace namespace at + * disjoint absolute paths. */ - workspaceLinks?: WorkspaceLink[]; + workspaces?: WorkspaceAttachment[]; } /** * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema @@ -2273,8 +2205,8 @@ export interface VfsFeature { export interface VfsPromptsConfig { /** * Absent searches .agents/prompts and .lightspeed/prompts beneath each - * workspace link. Explicit roots replace these defaults and must be - * non-empty absolute paths contained in workspace links. + * workspace attachment. Explicit roots replace these defaults and must be + * non-empty absolute paths contained in workspace attachments. */ roots?: string[] | null; } @@ -2285,21 +2217,26 @@ export interface VfsPromptsConfig { export interface VfsSkillsConfig { /** * Absent searches .agents/skills and .lightspeed/skills beneath each - * workspace link. Explicit roots replace these defaults and must be - * non-empty absolute paths contained in workspace links. + * workspace attachment. Explicit roots replace these defaults and must be + * non-empty absolute paths contained in workspace attachments. * * @minItems 1 */ roots?: [string, ...string[]] | null; } /** + * One catalog resource mounted into the session namespace. Exactly one of + * `workspaceId` and `snapshotRef` names the resource; snapshots are + * immutable and must be attached with `read` access. + * * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema - * via the `definition` "WorkspaceLink". + * via the `definition` "WorkspaceAttachment". */ -export interface WorkspaceLink { - access: WorkspaceLinkAccess; +export interface WorkspaceAttachment { + access: WorkspaceAccess; path: string; - target: WorkspaceLinkTarget; + snapshotRef?: string | null; + workspaceId?: string | null; } /** * Grants network access through the web toolset; `fetch` and `search` are @@ -4238,12 +4175,6 @@ export interface EnvironmentView { metadata?: { [k: string]: string; }; - /** - * Present when a profile provisioned this environment for a session. - * Provenance and an optional close trigger, not ownership: the - * environment remains an ordinary universe resource. - */ - originSession?: EnvironmentOriginSessionView | null; publicEndpoint?: string | null; publicIngressEnabled: boolean; requestId: string; @@ -4282,18 +4213,6 @@ export interface EnvironmentIncarnationView { templateId?: string | null; updatedAtMs: number; } -/** - * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema - * via the `definition` "EnvironmentOriginSessionView". - */ -export interface EnvironmentOriginSessionView { - /** - * When true, Lightspeed closes the environment once the session closes. - */ - closeWithSession: boolean; - profileId?: ProfileId | null; - sessionId: string; -} /** * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema * via the `definition` "EnvironmentConnectionView". @@ -4909,12 +4828,12 @@ export interface McpServerDeleteResponse { export interface McpServerView { allowPrivateNetwork: boolean; allowedTools?: string[] | null; - approvalDefault: RemoteMcpApprovalPolicy; + approval: RemoteMcpApprovalPolicy; authPolicy: McpServerAuthPolicy; createdAtMs: number; credential?: McpServerCredential | null; defaultServerLabel: string; - deferLoadingDefault?: boolean | null; + deferLoading?: boolean | null; description?: string | null; displayName?: string | null; execution: RemoteMcpExecution; @@ -5445,10 +5364,6 @@ export interface ProfileApplyResponse { export interface ProfileApplySummary { activeEnvironmentChanged: boolean; configChanged: boolean; - /** - * True when this apply created a new environment for the session. - */ - environmentProvisioned?: boolean; instructionsChanged: boolean; } /** @@ -5475,13 +5390,6 @@ export interface AgentProfile { createdAtMs: number; description?: string | null; displayName?: string | null; - /** - * How the session obtains its active environment when this profile is - * applied: activate an existing universe environment, or provision a - * fresh one for this session. Absence leaves the session's current - * active environment unchanged. - */ - environment?: ProfileEnvironment | null; instructions?: ProfileInstructions | null; /** * Descriptive metadata defaults copied to a session when it is created @@ -5501,20 +5409,6 @@ export interface AgentProfile { revision: number; updatedAtMs: number; } -/** - * One environment credential binding requested by a profile: the same shape - * as `environments/credentials/bind`. - * - * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema - * via the `definition` "ProfileEnvironmentCredential". - */ -export interface ProfileEnvironmentCredential { - /** - * Environment variable name (`[A-Za-z_][A-Za-z0-9_]{0,127}`). - */ - envName: string; - source: EnvironmentCredentialSourceView; -} /** * Root-session retention policy supplied by a profile at session creation. * @@ -6133,13 +6027,6 @@ export interface AgentProfileInput { config?: SessionConfig | null; description?: string | null; displayName?: string | null; - /** - * How the session obtains its active environment when this profile is - * applied: activate an existing universe environment, or provision a - * fresh one for this session. Absence leaves the session's current - * active environment unchanged. - */ - environment?: ProfileEnvironment | null; instructions?: ProfileInstructions | null; /** * Descriptive metadata defaults copied to a session when it is created @@ -6969,10 +6856,6 @@ export interface EnvironmentListParams { metadata?: { [k: string]: string; }; - /** - * Only environments a profile provisioned for this session. - */ - originSessionId?: string | null; providerId?: string | null; /** * Only registered environments admitted by this registration key. @@ -7085,13 +6968,6 @@ export interface InlineAgentProfile { config?: SessionConfig | null; description?: string | null; displayName?: string | null; - /** - * How the session obtains its active environment when this profile is - * applied: activate an existing universe environment, or provision a - * fresh one for this session. Absence leaves the session's current - * active environment unchanged. - */ - environment?: ProfileEnvironment | null; instructions?: ProfileInstructions | null; /** * Descriptive metadata defaults copied to a session when it is created @@ -7133,11 +7009,6 @@ export interface ManagedSessionStartParams { */ deleteAfterCloseMs?: number | null; displayName?: string | null; - /** - * Optional creation-time override for the selected profile's environment - * intent. Omit to use the profile's intent unchanged. - */ - environment?: SessionEnvironmentOverride | null; /** * Descriptive key/value metadata with the same bounds as * `session/start`; applied only when the session is first created. @@ -7182,11 +7053,17 @@ export interface McpServerDeleteParams { export interface McpServerInput { allowPrivateNetwork?: boolean; allowedTools?: string[] | null; - approvalDefault?: RemoteMcpApprovalPolicy & string; + /** + * Approval policy for every session linking this server. + */ + approval?: RemoteMcpApprovalPolicy & string; authPolicy?: McpServerAuthPolicy; credential?: McpServerCredential | null; defaultServerLabel: string; - deferLoadingDefault?: boolean | null; + /** + * Provider-side deferred loading of tool definitions where supported. + */ + deferLoading?: boolean | null; description?: string | null; displayName?: string | null; execution?: RemoteMcpExecution & string; @@ -7724,11 +7601,6 @@ export interface SessionStartParams { */ deleteAfterCloseMs?: number | null; displayName?: string | null; - /** - * Optional creation-time override for the selected profile's environment - * intent. Omit to use the profile's intent unchanged. - */ - environment?: SessionEnvironmentOverride | null; /** * Descriptive key/value metadata, applied only when the session is * first created: at most 32 entries, keys 1..=64 bytes, values 1..=256 diff --git a/crates/api-projection/src/lib.rs b/crates/api-projection/src/lib.rs index aebfc6f5..b19deae4 100644 --- a/crates/api-projection/src/lib.rs +++ b/crates/api-projection/src/lib.rs @@ -2098,66 +2098,71 @@ fn features_config_to_api( .environments .as_ref() .map(|environments| api::EnvironmentsFeature { - tools: environments.tools.map(|surface| match surface { - engine::EnvironmentToolSurface::ReadOnly => { - api::EnvironmentToolSurface::ReadOnly - } - engine::EnvironmentToolSurface::Edit => api::EnvironmentToolSurface::Edit, - }), - commands: environments.commands, version: environments.version, - working_directory: environments.working_directory.clone(), + selection: environments.selection, prompts: environments.prompts.as_ref().map(|source| { api::EnvironmentPromptsConfig { roots: source.roots.clone(), } }), - providers: environments.providers.clone(), - registration_keys: environments.registration_keys.clone(), - selection_tools: environments.selection_tools, - jobs: environments.jobs, skills: environments .skills .as_ref() .map(|skills| api::EnvironmentSkillsConfig { roots: skills.roots.clone(), }), + environments: environments + .environments + .iter() + .map(|attachment| api::EnvironmentAttachment { + environment_id: Some(attachment.environment_id.clone()), + inherit: false, + default: attachment.default, + access: environment_access_to_api(attachment.access), + working_directory: attachment.working_directory.clone(), + }) + .collect(), }), mcp: features.mcp.as_ref().map(mcp_feature_to_api), }) } +pub fn environment_access_to_api(access: engine::EnvironmentAccess) -> api::EnvironmentAccess { + match access { + engine::EnvironmentAccess::Read => api::EnvironmentAccess::Read, + engine::EnvironmentAccess::Edit => api::EnvironmentAccess::Edit, + engine::EnvironmentAccess::Exec => api::EnvironmentAccess::Exec, + engine::EnvironmentAccess::Jobs => api::EnvironmentAccess::Jobs, + } +} + fn vfs_feature_to_api(vfs: &engine::VfsFeature) -> api::VfsFeature { api::VfsFeature { version: vfs.version, working_directory: vfs.working_directory.clone(), - workspace_links: vfs - .workspace_links + workspaces: vfs + .workspaces .iter() - .map(|link| api::WorkspaceLink { - path: link.path.clone(), - target: match &link.target { - engine::WorkspaceLinkTarget::Workspace { workspace_id } => { - api::WorkspaceLinkTarget::Workspace { - workspace_id: workspace_id.clone(), - } + .map(|attachment| { + let (workspace_id, snapshot_ref) = match &attachment.target { + engine::WorkspaceAttachmentTarget::Workspace { workspace_id } => { + (Some(workspace_id.clone()), None) } - engine::WorkspaceLinkTarget::Snapshot { snapshot_ref } => { - api::WorkspaceLinkTarget::Snapshot { - snapshot_ref: snapshot_ref.clone(), - } + engine::WorkspaceAttachmentTarget::Snapshot { snapshot_ref } => { + (None, Some(snapshot_ref.clone())) } - }, - access: match link.access { - engine::WorkspaceLinkAccess::ReadOnly => api::WorkspaceLinkAccess::ReadOnly, - engine::WorkspaceLinkAccess::ReadWrite => api::WorkspaceLinkAccess::ReadWrite, - }, + }; + api::WorkspaceAttachment { + path: attachment.path.clone(), + workspace_id, + snapshot_ref, + access: match attachment.access { + engine::WorkspaceAccess::Read => api::WorkspaceAccess::Read, + engine::WorkspaceAccess::Edit => api::WorkspaceAccess::Edit, + }, + } }) .collect(), - tools: vfs.tools.map(|tools| match tools { - engine::VfsToolSurface::ReadOnly => api::VfsToolSurface::ReadOnly, - engine::VfsToolSurface::Edit => api::VfsToolSurface::Edit, - }), prompts: vfs.prompts.as_ref().map(|prompts| api::VfsPromptsConfig { roots: prompts.roots.clone(), }), @@ -2247,8 +2252,9 @@ fn mcp_feature_to_api(mcp: &engine::McpFeature) -> api::McpFeature { servers: mcp .servers .iter() - .map(|link| api::McpServerLink { - server_id: link.server_id.clone(), + .map(|attachment| api::McpServerAttachment { + server_id: attachment.server_id.clone(), + tools: attachment.tools.clone(), }) .collect(), } @@ -4337,8 +4343,13 @@ mod tests { vfs: Some(engine::VfsFeature { working_directory: None, version: engine::CURRENT_FEATURE_VERSION, - workspace_links: Vec::new(), - tools: Some(engine::VfsToolSurface::ReadOnly), + workspaces: vec![engine::WorkspaceAttachment { + path: "/workspace".to_owned(), + target: engine::WorkspaceAttachmentTarget::Workspace { + workspace_id: "ws_1".to_owned(), + }, + access: engine::WorkspaceAccess::Read, + }], prompts: Some(engine::VfsPromptsConfig { roots: Some(vec!["/prompts".to_owned()]), }), @@ -4368,14 +4379,19 @@ mod tests { }), timers: Some(engine::TimersFeature::default()), environments: Some(engine::EnvironmentsFeature { - tools: Some(engine::EnvironmentToolSurface::Edit), - commands: true, + environments: vec![engine::EnvironmentAttachment { + environment_id: "env_1".to_owned(), + default: true, + access: engine::EnvironmentAccess::Exec, + working_directory: Some("/srv".to_owned()), + }], ..Default::default() }), mcp: Some(engine::McpFeature { version: engine::CURRENT_FEATURE_VERSION, - servers: vec![engine::McpServerLink { + servers: vec![engine::McpServerAttachment { server_id: "linear".to_owned(), + tools: Some(vec!["search".to_owned()]), }], }), }, @@ -4414,8 +4430,12 @@ mod tests { vfs: Some(api::VfsFeature { working_directory: None, version: api::CURRENT_FEATURE_VERSION, - workspace_links: Vec::new(), - tools: Some(api::VfsToolSurface::ReadOnly), + workspaces: vec![api::WorkspaceAttachment { + path: "/workspace".to_owned(), + workspace_id: Some("ws_1".to_owned()), + snapshot_ref: None, + access: api::WorkspaceAccess::Read, + }], prompts: Some(api::VfsPromptsConfig { roots: Some(vec!["/prompts".to_owned()]), }), @@ -4446,21 +4466,23 @@ mod tests { version: api::CURRENT_FEATURE_VERSION, }), environments: Some(api::EnvironmentsFeature { - tools: Some(api::EnvironmentToolSurface::Edit), - commands: true, - working_directory: None, - prompts: None, version: api::CURRENT_FEATURE_VERSION, - providers: None, - registration_keys: None, - selection_tools: false, - jobs: false, + selection: false, + prompts: None, skills: None, + environments: vec![api::EnvironmentAttachment { + environment_id: Some("env_1".to_owned()), + inherit: false, + default: true, + access: api::EnvironmentAccess::Exec, + working_directory: Some("/srv".to_owned()), + }], }), mcp: Some(api::McpFeature { version: api::CURRENT_FEATURE_VERSION, - servers: vec![api::McpServerLink { + servers: vec![api::McpServerAttachment { server_id: "linear".to_owned(), + tools: Some(vec!["search".to_owned()]), }], }), }), diff --git a/crates/api/contract/api-reference.md b/crates/api/contract/api-reference.md index d93afa49..cac07b11 100644 --- a/crates/api/contract/api-reference.md +++ b/crates/api/contract/api-reference.md @@ -17,7 +17,7 @@ Returns protocol version, server identity, and supported capabilities without ch **Create or reopen a session** -Creates a session with optional config/profile setup. Profile metadata and retention supply creation defaults; explicit start values override them. An existing-or-none environment override can replace the profile intent. Retrying an existing session id returns that session. +Creates a session with optional config/profile setup. Profile metadata and retention supply creation defaults; explicit start values override them. The default environment attachment in the effective config supplies the initial active environment. Retrying an existing session id returns that session. - Params: `SessionStartParams` - Result: `AgentApiOutcome` @@ -26,7 +26,7 @@ Creates a session with optional config/profile setup. Profile metadata and reten **Create or reopen a managed session** -Creates a session with immutable lifecycle and workflow-tool declarations using explicit bound dispatch. Profile metadata, retention, and environment overrides follow session/start semantics. Retrying an id requires the same managed declaration; an ordinary session cannot be upgraded. +Creates a session with immutable lifecycle and workflow-tool declarations using explicit bound dispatch. Profile metadata, retention, and default environment attachment selection follow session/start semantics. Retrying an id requires the same managed declaration; an ordinary session cannot be upgraded. - Params: `ManagedSessionStartParams` - Result: `AgentApiOutcome` diff --git a/crates/api/contract/api.schema.json b/crates/api/contract/api.schema.json index ad8cedee..fdebbae0 100644 --- a/crates/api/contract/api.schema.json +++ b/crates/api/contract/api.schema.json @@ -2407,17 +2407,6 @@ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/definitions/ProfileEnvironment" - }, - { - "type": "null" - } - ], - "description": "How the session obtains its active environment when this profile is\napplied: activate an existing universe environment, or provision a\nfresh one for this session. Absence leaves the session's current\nactive environment unchanged." - }, "instructions": { "anyOf": [ { @@ -2491,17 +2480,6 @@ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/definitions/ProfileEnvironment" - }, - { - "type": "null" - } - ], - "description": "How the session obtains its active environment when this profile is\napplied: activate an existing universe environment, or provision a\nfresh one for this session. Absence leaves the session's current\nactive environment unchanged." - }, "instructions": { "anyOf": [ { @@ -7758,6 +7736,49 @@ ], "type": "object" }, + "EnvironmentAccess": { + "description": "Per-attachment environment access, an ordered ladder: `edit` adds file\nediting to `read`, `exec` adds processes, `jobs` adds durable jobs.\nProcesses can write files regardless of the file-tool level, so\nread-only files with commands is deliberately not expressible.", + "enum": [ + "read", + "edit", + "exec", + "jobs" + ], + "type": "string" + }, + "EnvironmentAttachment": { + "additionalProperties": false, + "description": "One environment the session may use. Exactly one of `environmentId` and\n`inherit` identifies the machine. `inherit` is valid only in a profile\ndocument applied to a sub-agent: it resolves to the delegating parent's\nactive environment at spawn and is stored on the child as a concrete id.\nIf the parent's environment is also listed explicitly, the explicit\nattachment wins; if the parent has none, the inherit attachment is dropped.", + "properties": { + "access": { + "$ref": "#/definitions/EnvironmentAccess" + }, + "default": { + "description": "Activated when a profile is applied while the session has no active\nenvironment; creation is the trivial case. Never overrides a live\nselection and never applies on a plain `session/config/put`.", + "type": "boolean" + }, + "environmentId": { + "type": [ + "string", + "null" + ] + }, + "inherit": { + "type": "boolean" + }, + "workingDirectory": { + "description": "Absolute machine working directory for file tools, commands, jobs,\nand sources; absent uses the machine's advertised default.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "access" + ], + "type": "object" + }, "EnvironmentCloseParams": { "properties": { "environmentId": { @@ -8449,13 +8470,6 @@ "description": "Only environments carrying every listed metadata pair (AND\nsemantics); the same filter `session/list` accepts.", "type": "object" }, - "originSessionId": { - "description": "Only environments a profile provisioned for this session.", - "type": [ - "string", - "null" - ] - }, "providerId": { "type": [ "string", @@ -8494,32 +8508,6 @@ }, "type": "object" }, - "EnvironmentOriginSessionView": { - "properties": { - "closeWithSession": { - "description": "When true, Lightspeed closes the environment once the session closes.", - "type": "boolean" - }, - "profileId": { - "anyOf": [ - { - "$ref": "#/definitions/ProfileId" - }, - { - "type": "null" - } - ] - }, - "sessionId": { - "type": "string" - } - }, - "required": [ - "sessionId", - "closeWithSession" - ], - "type": "object" - }, "EnvironmentPowerPutParams": { "properties": { "environmentId": { @@ -9109,14 +9097,6 @@ ], "type": "object" }, - "EnvironmentToolSurface": { - "description": "Agent-facing environment filesystem tools; independent of execution grants.", - "enum": [ - "readOnly", - "edit" - ], - "type": "string" - }, "EnvironmentView": { "properties": { "createdAtMs": { @@ -9167,17 +9147,6 @@ }, "type": "object" }, - "originSession": { - "anyOf": [ - { - "$ref": "#/definitions/EnvironmentOriginSessionView" - }, - { - "type": "null" - } - ], - "description": "Present when a profile provisioned this environment for a session.\nProvenance and an optional close trigger, not ownership: the\nenvironment remains an ordinary universe resource." - }, "publicEndpoint": { "type": [ "string", @@ -9216,17 +9185,14 @@ }, "EnvironmentsFeature": { "additionalProperties": false, - "description": "Grants active session environments. Filesystem tools, commands, selection,\ndurable jobs, prompts, and skills are independent, default-off sub-grants.", + "description": "Grants session environments. The `environments` list is the allowed set:\nthe session can select, read, and run work only on a listed machine, each\nwith its own access grant and working directory. The installed tool\nsurface is the union of every attachment's grant; a call the active\nmachine's grant does not cover fails at execution, so switching machines\nnever changes the toolset. `{}` grants the feature with no reachable\nmachine.", "properties": { - "commands": { - "default": false, - "description": "Grants command execution and process continuation. Commands may modify\nfiles even when filesystem tools are read-only or disabled.", - "type": "boolean" - }, - "jobs": { - "default": false, - "description": "Grants the advanced durable-job tool surface. The workflow binding is\ninstalled for the session when granted; invocations still require an\nactive, ready environment with matching job capabilities.", - "type": "boolean" + "environments": { + "description": "The environments this session may use; unique ids, at most one\ndefault, at most one `inherit` (profiles only).", + "items": { + "$ref": "#/definitions/EnvironmentAttachment" + }, + "type": "array" }, "prompts": { "anyOf": [ @@ -9239,29 +9205,9 @@ ], "description": "Independent environment prompt loading; absent disables sourced instructions." }, - "providers": { - "description": "Absent means every registered provider is allowed.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "registrationKeys": { - "description": "Registration keys whose registered environments the session may\nlist and activate; absent means every key. Independent of\n`providers`: each list scopes its own environment source, and\nexternal environments pass only when neither list is set.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "selectionTools": { + "selection": { "default": false, - "description": "Exposes `environment_list`, `environment_activate`, and\n`environment_deactivate` to the model. `environment_read` is available\nwhenever environments are enabled, and external API/profile activation\nremains available when this is false.", + "description": "Exposes `environment_list`, `environment_activate`, and\n`environment_deactivate` over the attached environments.\n`environment_read` is available whenever environments are enabled, and\nexternal API/profile activation remains available when this is false.", "type": "boolean" }, "skills": { @@ -9275,29 +9221,11 @@ ], "description": "Independent environment skill discovery. Absent disables discovery." }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/EnvironmentToolSurface" - }, - { - "type": "null" - } - ], - "description": "Filesystem tool surface. Absent installs no filesystem tools; sources\nremain independent. Read-only does not restrict commands or durable jobs." - }, "version": { "default": 1, "format": "uint32", "minimum": 0, "type": "integer" - }, - "workingDirectory": { - "description": "Absolute machine working directory for file tools, commands, jobs, and sources; absent uses the endpoint default.", - "type": [ - "string", - "null" - ] } }, "type": "object" @@ -9603,17 +9531,6 @@ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/definitions/ProfileEnvironment" - }, - { - "type": "null" - } - ], - "description": "How the session obtains its active environment when this profile is\napplied: activate an existing universe environment, or provision a\nfresh one for this session. Absence leaves the session's current\nactive environment unchanged." - }, "instructions": { "anyOf": [ { @@ -9919,17 +9836,6 @@ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/definitions/SessionEnvironmentOverride" - }, - { - "type": "null" - } - ], - "description": "Optional creation-time override for the selected profile's environment\nintent. Omit to use the profile's intent unchanged." - }, "metadata": { "additionalProperties": { "type": "string" @@ -10033,11 +9939,12 @@ }, "McpFeature": { "additionalProperties": false, - "description": "Grants remote MCP tools by declaring linked servers from the universe MCP\ncatalog; must link at least one server, with unique server ids.", + "description": "Grants remote MCP tools by declaring attached servers from the universe MCP\ncatalog. Server ids must be unique; an empty list grants no MCP tools.", "properties": { "servers": { + "default": [], "items": { - "$ref": "#/definitions/McpServerLink" + "$ref": "#/definitions/McpServerAttachment" }, "type": "array" }, @@ -10076,6 +9983,29 @@ ], "type": "object" }, + "McpServerAttachment": { + "additionalProperties": false, + "description": "A selected universe MCP server. Its catalog record owns connection,\nexecution, exposure, approval, and auth; the attachment may only narrow the\nrecord's tool allowlist for this session.", + "properties": { + "serverId": { + "type": "string" + }, + "tools": { + "description": "Non-empty subset of the record's allowed tools exposed to this\nsession, under both injection and search; absent exposes the record's\nfull allowlist.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "serverId" + ], + "type": "object" + }, "McpServerAuthDiscoverParams": { "description": "Read-only authentication discovery for a prospective MCP endpoint. A\nmissing OAuth result is deliberately inconclusive: the server may be\npublic, use bearer auth, or expose OAuth metadata only through an explicit\nURL. No catalog or auth records are created by this probe.", "properties": { @@ -10273,13 +10203,14 @@ "null" ] }, - "approvalDefault": { + "approval": { "allOf": [ { "$ref": "#/definitions/RemoteMcpApprovalPolicy" } ], - "default": "never" + "default": "never", + "description": "Approval policy for every session linking this server." }, "authPolicy": { "allOf": [ @@ -10304,7 +10235,8 @@ "defaultServerLabel": { "type": "string" }, - "deferLoadingDefault": { + "deferLoading": { + "description": "Provider-side deferred loading of tool definitions where supported.", "type": [ "boolean", "null" @@ -10360,19 +10292,6 @@ ], "type": "object" }, - "McpServerLink": { - "additionalProperties": false, - "description": "A selected universe MCP server. Its catalog record owns all connection and\nbehavior configuration.", - "properties": { - "serverId": { - "type": "string" - } - }, - "required": [ - "serverId" - ], - "type": "object" - }, "McpServerListParams": { "properties": { "status": { @@ -10539,7 +10458,7 @@ "null" ] }, - "approvalDefault": { + "approval": { "$ref": "#/definitions/RemoteMcpApprovalPolicy" }, "authPolicy": { @@ -10562,7 +10481,7 @@ "defaultServerLabel": { "type": "string" }, - "deferLoadingDefault": { + "deferLoading": { "type": [ "boolean", "null" @@ -10611,7 +10530,7 @@ "defaultServerLabel", "execution", "exposure", - "approvalDefault", + "approval", "allowPrivateNetwork", "authPolicy", "status", @@ -12055,7 +11974,6 @@ "default": { "activeEnvironmentChanged": false, "configChanged": false, - "environmentProvisioned": false, "instructionsChanged": false } }, @@ -12076,11 +11994,6 @@ "configChanged": { "type": "boolean" }, - "environmentProvisioned": { - "default": false, - "description": "True when this apply created a new environment for the session.", - "type": "boolean" - }, "instructionsChanged": { "type": "boolean" } @@ -12136,137 +12049,6 @@ ], "type": "object" }, - "ProfileEnvironment": { - "description": "Environment intent carried by a profile document.", - "oneOf": [ - { - "additionalProperties": false, - "description": "Activate an existing universe environment. The profile never closes\nit.", - "properties": { - "environmentId": { - "type": "string" - }, - "type": { - "const": "existing", - "type": "string" - } - }, - "required": [ - "type", - "environmentId" - ], - "type": "object" - }, - { - "additionalProperties": false, - "description": "Activate the delegating parent's active environment. Resolved at\nsub-agent spawn, shared not copied, never closed by the\nchild; rejected on a session without a delegation origin or whose\nparent has no active environment.", - "properties": { - "type": { - "const": "inherit", - "type": "string" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - { - "additionalProperties": false, - "description": "Provision one environment for the session from the universe's enabled\nbinding for `providerId`, then activate it. The provision request id\nis derived from the session id, so retries and repeated applies\nconverge on the same environment.", - "properties": { - "credentials": { - "description": "Credentials bound to the environment right after it is\nprovisioned before activation: references to universe\ngrants/providers/secrets, never values. They become ordinary\nenvironment credential bindings; the profile is the initial set,\nnot a live sync. Not available for `existing` environments.", - "items": { - "$ref": "#/definitions/ProfileEnvironmentCredential" - }, - "type": "array" - }, - "displayName": { - "type": [ - "string", - "null" - ] - }, - "idlePolicy": { - "anyOf": [ - { - "$ref": "#/definitions/EnvironmentIdlePolicyView" - }, - { - "type": "null" - } - ], - "description": "Optional staged idle policy for the provisioned environment.\nStages the provider cannot realize are skipped." - }, - "metadata": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - "providerId": { - "type": "string" - }, - "retention": { - "allOf": [ - { - "$ref": "#/definitions/ProfileEnvironmentRetention" - } - ], - "default": "closeWithSession" - }, - "templateId": { - "description": "Immutable provider template-version identity.", - "type": "string" - }, - "type": { - "const": "provision", - "type": "string" - } - }, - "required": [ - "type", - "providerId", - "templateId" - ], - "type": "object" - } - ] - }, - "ProfileEnvironmentCredential": { - "additionalProperties": false, - "description": "One environment credential binding requested by a profile: the same shape\nas `environments/credentials/bind`.", - "properties": { - "envName": { - "description": "Environment variable name (`[A-Za-z_][A-Za-z0-9_]{0,127}`).", - "type": "string" - }, - "source": { - "$ref": "#/definitions/EnvironmentCredentialSourceView" - } - }, - "required": [ - "envName", - "source" - ], - "type": "object" - }, - "ProfileEnvironmentRetention": { - "description": "What happens to a profile-provisioned environment when its originating\nsession closes.", - "oneOf": [ - { - "const": "closeWithSession", - "description": "Close the environment when the session that provisioned it closes.", - "type": "string" - }, - { - "const": "retain", - "description": "Leave the environment open; the universe owns its cleanup.", - "type": "string" - } - ] - }, "ProfileId": { "type": "string" }, @@ -13323,41 +13105,6 @@ ], "type": "object" }, - "SessionEnvironmentOverride": { - "description": "Creation-time override for the environment intent carried by a profile.\nAbsence uses the profile unchanged; `none` suppresses its environment\nintent, while `existing` activates the specified universe environment.", - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "type": { - "const": "none", - "type": "string" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "environmentId": { - "type": "string" - }, - "type": { - "const": "existing", - "type": "string" - } - }, - "required": [ - "type", - "environmentId" - ], - "type": "object" - } - ] - }, "SessionEventDirection": { "enum": [ "forward", @@ -15541,17 +15288,6 @@ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/definitions/SessionEnvironmentOverride" - }, - { - "type": "null" - } - ], - "description": "Optional creation-time override for the selected profile's environment\nintent. Omit to use the profile's intent unchanged." - }, "metadata": { "additionalProperties": { "type": "string" @@ -16640,7 +16376,7 @@ }, "VfsFeature": { "additionalProperties": false, - "description": "Grants the session virtual filesystem. Workspace links declare the\nsession-visible namespace and the VFS catalog is surfaced. Sub-grants are independent; `{}` grants a VFS with\nno tools and no sourcing.", + "description": "Grants the session virtual filesystem. Workspace attachments declare the\nsession-visible namespace and the VFS catalog is surfaced. The file tool\nsurface is derived from the attachments: any attachment installs the read\ntools, any `edit` attachment adds the write tools, and with the\nenvironments feature granted the matching transfer tools appear. `{}`\ngrants a VFS with no attachments, no tools, and no sourcing.", "properties": { "prompts": { "anyOf": [ @@ -16651,7 +16387,7 @@ "type": "null" } ], - "description": "Prompt-instruction sourcing from the VFS. Absent disables loading;\nan empty block discovers conventional linked roots." + "description": "Prompt-instruction sourcing from the VFS. Absent disables loading;\nan empty block discovers conventional attached roots." }, "skills": { "anyOf": [ @@ -16662,18 +16398,7 @@ "type": "null" } ], - "description": "Independent VFS skill discovery. Absent disables discovery and removes\nits runtime catalog; an empty block discovers conventional linked roots." - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/VfsToolSurface" - }, - { - "type": "null" - } - ], - "description": "Agent-facing filesystem tool surface; absent = no fs tools. Per-path\nwritability is defined by each workspace link's own access.\nWith the environments feature granted, `readOnly` also exposes\n`vfs_materialize`; `edit` additionally exposes `vfs_capture`.\nPrompt/skill sourcing alone does not grant transfer tools." + "description": "Independent VFS skill discovery. Absent disables discovery and removes\nits runtime catalog; an empty block discovers conventional attached roots." }, "version": { "default": 1, @@ -16688,10 +16413,10 @@ "null" ] }, - "workspaceLinks": { - "description": "Catalog resources exposed in the session's workspace namespace.", + "workspaces": { + "description": "Catalog resources exposed in the session's workspace namespace at\ndisjoint absolute paths.", "items": { - "$ref": "#/definitions/WorkspaceLink" + "$ref": "#/definitions/WorkspaceAttachment" }, "type": "array" } @@ -16702,7 +16427,7 @@ "additionalProperties": false, "properties": { "roots": { - "description": "Absent searches .agents/prompts and .lightspeed/prompts beneath each\nworkspace link. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace links.", + "description": "Absent searches .agents/prompts and .lightspeed/prompts beneath each\nworkspace attachment. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace attachments.", "items": { "type": "string" }, @@ -16718,7 +16443,7 @@ "additionalProperties": false, "properties": { "roots": { - "description": "Absent searches .agents/skills and .lightspeed/skills beneath each\nworkspace link. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace links.", + "description": "Absent searches .agents/skills and .lightspeed/skills beneath each\nworkspace attachment. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace attachments.", "items": { "type": "string" }, @@ -16799,13 +16524,6 @@ ], "type": "object" }, - "VfsToolSurface": { - "enum": [ - "readOnly", - "edit" - ], - "type": "string" - }, "VfsWorkspaceCreateParams": { "properties": { "displayName": { @@ -17476,68 +17194,42 @@ } ] }, - "WorkspaceLink": { + "WorkspaceAccess": { + "description": "Per-attachment VFS access; `edit` implies `read`.", + "enum": [ + "read", + "edit" + ], + "type": "string" + }, + "WorkspaceAttachment": { "additionalProperties": false, + "description": "One catalog resource mounted into the session namespace. Exactly one of\n`workspaceId` and `snapshotRef` names the resource; snapshots are\nimmutable and must be attached with `read` access.", "properties": { "access": { - "$ref": "#/definitions/WorkspaceLinkAccess" + "$ref": "#/definitions/WorkspaceAccess" }, "path": { "type": "string" }, - "target": { - "$ref": "#/definitions/WorkspaceLinkTarget" + "snapshotRef": { + "type": [ + "string", + "null" + ] + }, + "workspaceId": { + "type": [ + "string", + "null" + ] } }, "required": [ "path", - "target", "access" ], "type": "object" - }, - "WorkspaceLinkAccess": { - "enum": [ - "readOnly", - "readWrite" - ], - "type": "string" - }, - "WorkspaceLinkTarget": { - "oneOf": [ - { - "properties": { - "type": { - "const": "workspace", - "type": "string" - }, - "workspaceId": { - "type": "string" - } - }, - "required": [ - "type", - "workspaceId" - ], - "type": "object" - }, - { - "properties": { - "snapshotRef": { - "type": "string" - }, - "type": { - "const": "snapshot", - "type": "string" - } - }, - "required": [ - "type", - "snapshotRef" - ], - "type": "object" - } - ] } }, "description": "All JSON-RPC wire types of the Lightspeed agent API.", diff --git a/crates/api/contract/methods.json b/crates/api/contract/methods.json index 3eb566d2..4f3948da 100644 --- a/crates/api/contract/methods.json +++ b/crates/api/contract/methods.json @@ -19,7 +19,7 @@ "summary": "Inspect the Lightspeed protocol" }, { - "description": "Creates a session with optional config/profile setup. Profile metadata and retention supply creation defaults; explicit start values override them. An existing-or-none environment override can replace the profile intent. Retrying an existing session id returns that session.", + "description": "Creates a session with optional config/profile setup. Profile metadata and retention supply creation defaults; explicit start values override them. The default environment attachment in the effective config supplies the initial active environment. Retrying an existing session id returns that session.", "method": "session/start", "params": { "schema": { @@ -37,7 +37,7 @@ "summary": "Create or reopen a session" }, { - "description": "Creates a session with immutable lifecycle and workflow-tool declarations using explicit bound dispatch. Profile metadata, retention, and environment overrides follow session/start semantics. Retrying an id requires the same managed declaration; an ordinary session cannot be upgraded.", + "description": "Creates a session with immutable lifecycle and workflow-tool declarations using explicit bound dispatch. Profile metadata, retention, and default environment attachment selection follow session/start semantics. Retrying an id requires the same managed declaration; an ordinary session cannot be upgraded.", "method": "session/managed/start", "params": { "schema": { diff --git a/crates/api/contract/openrpc.json b/crates/api/contract/openrpc.json index c5459a4a..1647713f 100644 --- a/crates/api/contract/openrpc.json +++ b/crates/api/contract/openrpc.json @@ -2407,17 +2407,6 @@ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/components/schemas/ProfileEnvironment" - }, - { - "type": "null" - } - ], - "description": "How the session obtains its active environment when this profile is\napplied: activate an existing universe environment, or provision a\nfresh one for this session. Absence leaves the session's current\nactive environment unchanged." - }, "instructions": { "anyOf": [ { @@ -2491,17 +2480,6 @@ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/components/schemas/ProfileEnvironment" - }, - { - "type": "null" - } - ], - "description": "How the session obtains its active environment when this profile is\napplied: activate an existing universe environment, or provision a\nfresh one for this session. Absence leaves the session's current\nactive environment unchanged." - }, "instructions": { "anyOf": [ { @@ -7758,6 +7736,49 @@ ], "type": "object" }, + "EnvironmentAccess": { + "description": "Per-attachment environment access, an ordered ladder: `edit` adds file\nediting to `read`, `exec` adds processes, `jobs` adds durable jobs.\nProcesses can write files regardless of the file-tool level, so\nread-only files with commands is deliberately not expressible.", + "enum": [ + "read", + "edit", + "exec", + "jobs" + ], + "type": "string" + }, + "EnvironmentAttachment": { + "additionalProperties": false, + "description": "One environment the session may use. Exactly one of `environmentId` and\n`inherit` identifies the machine. `inherit` is valid only in a profile\ndocument applied to a sub-agent: it resolves to the delegating parent's\nactive environment at spawn and is stored on the child as a concrete id.\nIf the parent's environment is also listed explicitly, the explicit\nattachment wins; if the parent has none, the inherit attachment is dropped.", + "properties": { + "access": { + "$ref": "#/components/schemas/EnvironmentAccess" + }, + "default": { + "description": "Activated when a profile is applied while the session has no active\nenvironment; creation is the trivial case. Never overrides a live\nselection and never applies on a plain `session/config/put`.", + "type": "boolean" + }, + "environmentId": { + "type": [ + "string", + "null" + ] + }, + "inherit": { + "type": "boolean" + }, + "workingDirectory": { + "description": "Absolute machine working directory for file tools, commands, jobs,\nand sources; absent uses the machine's advertised default.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "access" + ], + "type": "object" + }, "EnvironmentCloseParams": { "properties": { "environmentId": { @@ -8449,13 +8470,6 @@ "description": "Only environments carrying every listed metadata pair (AND\nsemantics); the same filter `session/list` accepts.", "type": "object" }, - "originSessionId": { - "description": "Only environments a profile provisioned for this session.", - "type": [ - "string", - "null" - ] - }, "providerId": { "type": [ "string", @@ -8494,32 +8508,6 @@ }, "type": "object" }, - "EnvironmentOriginSessionView": { - "properties": { - "closeWithSession": { - "description": "When true, Lightspeed closes the environment once the session closes.", - "type": "boolean" - }, - "profileId": { - "anyOf": [ - { - "$ref": "#/components/schemas/ProfileId" - }, - { - "type": "null" - } - ] - }, - "sessionId": { - "type": "string" - } - }, - "required": [ - "sessionId", - "closeWithSession" - ], - "type": "object" - }, "EnvironmentPowerPutParams": { "properties": { "environmentId": { @@ -9109,14 +9097,6 @@ ], "type": "object" }, - "EnvironmentToolSurface": { - "description": "Agent-facing environment filesystem tools; independent of execution grants.", - "enum": [ - "readOnly", - "edit" - ], - "type": "string" - }, "EnvironmentView": { "properties": { "createdAtMs": { @@ -9167,17 +9147,6 @@ }, "type": "object" }, - "originSession": { - "anyOf": [ - { - "$ref": "#/components/schemas/EnvironmentOriginSessionView" - }, - { - "type": "null" - } - ], - "description": "Present when a profile provisioned this environment for a session.\nProvenance and an optional close trigger, not ownership: the\nenvironment remains an ordinary universe resource." - }, "publicEndpoint": { "type": [ "string", @@ -9216,17 +9185,14 @@ }, "EnvironmentsFeature": { "additionalProperties": false, - "description": "Grants active session environments. Filesystem tools, commands, selection,\ndurable jobs, prompts, and skills are independent, default-off sub-grants.", + "description": "Grants session environments. The `environments` list is the allowed set:\nthe session can select, read, and run work only on a listed machine, each\nwith its own access grant and working directory. The installed tool\nsurface is the union of every attachment's grant; a call the active\nmachine's grant does not cover fails at execution, so switching machines\nnever changes the toolset. `{}` grants the feature with no reachable\nmachine.", "properties": { - "commands": { - "default": false, - "description": "Grants command execution and process continuation. Commands may modify\nfiles even when filesystem tools are read-only or disabled.", - "type": "boolean" - }, - "jobs": { - "default": false, - "description": "Grants the advanced durable-job tool surface. The workflow binding is\ninstalled for the session when granted; invocations still require an\nactive, ready environment with matching job capabilities.", - "type": "boolean" + "environments": { + "description": "The environments this session may use; unique ids, at most one\ndefault, at most one `inherit` (profiles only).", + "items": { + "$ref": "#/components/schemas/EnvironmentAttachment" + }, + "type": "array" }, "prompts": { "anyOf": [ @@ -9239,29 +9205,9 @@ ], "description": "Independent environment prompt loading; absent disables sourced instructions." }, - "providers": { - "description": "Absent means every registered provider is allowed.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "registrationKeys": { - "description": "Registration keys whose registered environments the session may\nlist and activate; absent means every key. Independent of\n`providers`: each list scopes its own environment source, and\nexternal environments pass only when neither list is set.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "selectionTools": { + "selection": { "default": false, - "description": "Exposes `environment_list`, `environment_activate`, and\n`environment_deactivate` to the model. `environment_read` is available\nwhenever environments are enabled, and external API/profile activation\nremains available when this is false.", + "description": "Exposes `environment_list`, `environment_activate`, and\n`environment_deactivate` over the attached environments.\n`environment_read` is available whenever environments are enabled, and\nexternal API/profile activation remains available when this is false.", "type": "boolean" }, "skills": { @@ -9275,29 +9221,11 @@ ], "description": "Independent environment skill discovery. Absent disables discovery." }, - "tools": { - "anyOf": [ - { - "$ref": "#/components/schemas/EnvironmentToolSurface" - }, - { - "type": "null" - } - ], - "description": "Filesystem tool surface. Absent installs no filesystem tools; sources\nremain independent. Read-only does not restrict commands or durable jobs." - }, "version": { "default": 1, "format": "uint32", "minimum": 0, "type": "integer" - }, - "workingDirectory": { - "description": "Absolute machine working directory for file tools, commands, jobs, and sources; absent uses the endpoint default.", - "type": [ - "string", - "null" - ] } }, "type": "object" @@ -9603,17 +9531,6 @@ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/components/schemas/ProfileEnvironment" - }, - { - "type": "null" - } - ], - "description": "How the session obtains its active environment when this profile is\napplied: activate an existing universe environment, or provision a\nfresh one for this session. Absence leaves the session's current\nactive environment unchanged." - }, "instructions": { "anyOf": [ { @@ -9919,17 +9836,6 @@ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionEnvironmentOverride" - }, - { - "type": "null" - } - ], - "description": "Optional creation-time override for the selected profile's environment\nintent. Omit to use the profile's intent unchanged." - }, "metadata": { "additionalProperties": { "type": "string" @@ -10033,11 +9939,12 @@ }, "McpFeature": { "additionalProperties": false, - "description": "Grants remote MCP tools by declaring linked servers from the universe MCP\ncatalog; must link at least one server, with unique server ids.", + "description": "Grants remote MCP tools by declaring attached servers from the universe MCP\ncatalog. Server ids must be unique; an empty list grants no MCP tools.", "properties": { "servers": { + "default": [], "items": { - "$ref": "#/components/schemas/McpServerLink" + "$ref": "#/components/schemas/McpServerAttachment" }, "type": "array" }, @@ -10076,6 +9983,29 @@ ], "type": "object" }, + "McpServerAttachment": { + "additionalProperties": false, + "description": "A selected universe MCP server. Its catalog record owns connection,\nexecution, exposure, approval, and auth; the attachment may only narrow the\nrecord's tool allowlist for this session.", + "properties": { + "serverId": { + "type": "string" + }, + "tools": { + "description": "Non-empty subset of the record's allowed tools exposed to this\nsession, under both injection and search; absent exposes the record's\nfull allowlist.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "serverId" + ], + "type": "object" + }, "McpServerAuthDiscoverParams": { "description": "Read-only authentication discovery for a prospective MCP endpoint. A\nmissing OAuth result is deliberately inconclusive: the server may be\npublic, use bearer auth, or expose OAuth metadata only through an explicit\nURL. No catalog or auth records are created by this probe.", "properties": { @@ -10273,13 +10203,14 @@ "null" ] }, - "approvalDefault": { + "approval": { "allOf": [ { "$ref": "#/components/schemas/RemoteMcpApprovalPolicy" } ], - "default": "never" + "default": "never", + "description": "Approval policy for every session linking this server." }, "authPolicy": { "allOf": [ @@ -10304,7 +10235,8 @@ "defaultServerLabel": { "type": "string" }, - "deferLoadingDefault": { + "deferLoading": { + "description": "Provider-side deferred loading of tool definitions where supported.", "type": [ "boolean", "null" @@ -10360,19 +10292,6 @@ ], "type": "object" }, - "McpServerLink": { - "additionalProperties": false, - "description": "A selected universe MCP server. Its catalog record owns all connection and\nbehavior configuration.", - "properties": { - "serverId": { - "type": "string" - } - }, - "required": [ - "serverId" - ], - "type": "object" - }, "McpServerListParams": { "properties": { "status": { @@ -10539,7 +10458,7 @@ "null" ] }, - "approvalDefault": { + "approval": { "$ref": "#/components/schemas/RemoteMcpApprovalPolicy" }, "authPolicy": { @@ -10562,7 +10481,7 @@ "defaultServerLabel": { "type": "string" }, - "deferLoadingDefault": { + "deferLoading": { "type": [ "boolean", "null" @@ -10611,7 +10530,7 @@ "defaultServerLabel", "execution", "exposure", - "approvalDefault", + "approval", "allowPrivateNetwork", "authPolicy", "status", @@ -12055,7 +11974,6 @@ "default": { "activeEnvironmentChanged": false, "configChanged": false, - "environmentProvisioned": false, "instructionsChanged": false } }, @@ -12076,11 +11994,6 @@ "configChanged": { "type": "boolean" }, - "environmentProvisioned": { - "default": false, - "description": "True when this apply created a new environment for the session.", - "type": "boolean" - }, "instructionsChanged": { "type": "boolean" } @@ -12136,137 +12049,6 @@ ], "type": "object" }, - "ProfileEnvironment": { - "description": "Environment intent carried by a profile document.", - "oneOf": [ - { - "additionalProperties": false, - "description": "Activate an existing universe environment. The profile never closes\nit.", - "properties": { - "environmentId": { - "type": "string" - }, - "type": { - "const": "existing", - "type": "string" - } - }, - "required": [ - "type", - "environmentId" - ], - "type": "object" - }, - { - "additionalProperties": false, - "description": "Activate the delegating parent's active environment. Resolved at\nsub-agent spawn, shared not copied, never closed by the\nchild; rejected on a session without a delegation origin or whose\nparent has no active environment.", - "properties": { - "type": { - "const": "inherit", - "type": "string" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - { - "additionalProperties": false, - "description": "Provision one environment for the session from the universe's enabled\nbinding for `providerId`, then activate it. The provision request id\nis derived from the session id, so retries and repeated applies\nconverge on the same environment.", - "properties": { - "credentials": { - "description": "Credentials bound to the environment right after it is\nprovisioned before activation: references to universe\ngrants/providers/secrets, never values. They become ordinary\nenvironment credential bindings; the profile is the initial set,\nnot a live sync. Not available for `existing` environments.", - "items": { - "$ref": "#/components/schemas/ProfileEnvironmentCredential" - }, - "type": "array" - }, - "displayName": { - "type": [ - "string", - "null" - ] - }, - "idlePolicy": { - "anyOf": [ - { - "$ref": "#/components/schemas/EnvironmentIdlePolicyView" - }, - { - "type": "null" - } - ], - "description": "Optional staged idle policy for the provisioned environment.\nStages the provider cannot realize are skipped." - }, - "metadata": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - "providerId": { - "type": "string" - }, - "retention": { - "allOf": [ - { - "$ref": "#/components/schemas/ProfileEnvironmentRetention" - } - ], - "default": "closeWithSession" - }, - "templateId": { - "description": "Immutable provider template-version identity.", - "type": "string" - }, - "type": { - "const": "provision", - "type": "string" - } - }, - "required": [ - "type", - "providerId", - "templateId" - ], - "type": "object" - } - ] - }, - "ProfileEnvironmentCredential": { - "additionalProperties": false, - "description": "One environment credential binding requested by a profile: the same shape\nas `environments/credentials/bind`.", - "properties": { - "envName": { - "description": "Environment variable name (`[A-Za-z_][A-Za-z0-9_]{0,127}`).", - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/EnvironmentCredentialSourceView" - } - }, - "required": [ - "envName", - "source" - ], - "type": "object" - }, - "ProfileEnvironmentRetention": { - "description": "What happens to a profile-provisioned environment when its originating\nsession closes.", - "oneOf": [ - { - "const": "closeWithSession", - "description": "Close the environment when the session that provisioned it closes.", - "type": "string" - }, - { - "const": "retain", - "description": "Leave the environment open; the universe owns its cleanup.", - "type": "string" - } - ] - }, "ProfileId": { "type": "string" }, @@ -13323,41 +13105,6 @@ ], "type": "object" }, - "SessionEnvironmentOverride": { - "description": "Creation-time override for the environment intent carried by a profile.\nAbsence uses the profile unchanged; `none` suppresses its environment\nintent, while `existing` activates the specified universe environment.", - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "type": { - "const": "none", - "type": "string" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "environmentId": { - "type": "string" - }, - "type": { - "const": "existing", - "type": "string" - } - }, - "required": [ - "type", - "environmentId" - ], - "type": "object" - } - ] - }, "SessionEventDirection": { "enum": [ "forward", @@ -15541,17 +15288,6 @@ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionEnvironmentOverride" - }, - { - "type": "null" - } - ], - "description": "Optional creation-time override for the selected profile's environment\nintent. Omit to use the profile's intent unchanged." - }, "metadata": { "additionalProperties": { "type": "string" @@ -16640,7 +16376,7 @@ }, "VfsFeature": { "additionalProperties": false, - "description": "Grants the session virtual filesystem. Workspace links declare the\nsession-visible namespace and the VFS catalog is surfaced. Sub-grants are independent; `{}` grants a VFS with\nno tools and no sourcing.", + "description": "Grants the session virtual filesystem. Workspace attachments declare the\nsession-visible namespace and the VFS catalog is surfaced. The file tool\nsurface is derived from the attachments: any attachment installs the read\ntools, any `edit` attachment adds the write tools, and with the\nenvironments feature granted the matching transfer tools appear. `{}`\ngrants a VFS with no attachments, no tools, and no sourcing.", "properties": { "prompts": { "anyOf": [ @@ -16651,7 +16387,7 @@ "type": "null" } ], - "description": "Prompt-instruction sourcing from the VFS. Absent disables loading;\nan empty block discovers conventional linked roots." + "description": "Prompt-instruction sourcing from the VFS. Absent disables loading;\nan empty block discovers conventional attached roots." }, "skills": { "anyOf": [ @@ -16662,18 +16398,7 @@ "type": "null" } ], - "description": "Independent VFS skill discovery. Absent disables discovery and removes\nits runtime catalog; an empty block discovers conventional linked roots." - }, - "tools": { - "anyOf": [ - { - "$ref": "#/components/schemas/VfsToolSurface" - }, - { - "type": "null" - } - ], - "description": "Agent-facing filesystem tool surface; absent = no fs tools. Per-path\nwritability is defined by each workspace link's own access.\nWith the environments feature granted, `readOnly` also exposes\n`vfs_materialize`; `edit` additionally exposes `vfs_capture`.\nPrompt/skill sourcing alone does not grant transfer tools." + "description": "Independent VFS skill discovery. Absent disables discovery and removes\nits runtime catalog; an empty block discovers conventional attached roots." }, "version": { "default": 1, @@ -16688,10 +16413,10 @@ "null" ] }, - "workspaceLinks": { - "description": "Catalog resources exposed in the session's workspace namespace.", + "workspaces": { + "description": "Catalog resources exposed in the session's workspace namespace at\ndisjoint absolute paths.", "items": { - "$ref": "#/components/schemas/WorkspaceLink" + "$ref": "#/components/schemas/WorkspaceAttachment" }, "type": "array" } @@ -16702,7 +16427,7 @@ "additionalProperties": false, "properties": { "roots": { - "description": "Absent searches .agents/prompts and .lightspeed/prompts beneath each\nworkspace link. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace links.", + "description": "Absent searches .agents/prompts and .lightspeed/prompts beneath each\nworkspace attachment. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace attachments.", "items": { "type": "string" }, @@ -16718,7 +16443,7 @@ "additionalProperties": false, "properties": { "roots": { - "description": "Absent searches .agents/skills and .lightspeed/skills beneath each\nworkspace link. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace links.", + "description": "Absent searches .agents/skills and .lightspeed/skills beneath each\nworkspace attachment. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace attachments.", "items": { "type": "string" }, @@ -16799,13 +16524,6 @@ ], "type": "object" }, - "VfsToolSurface": { - "enum": [ - "readOnly", - "edit" - ], - "type": "string" - }, "VfsWorkspaceCreateParams": { "properties": { "displayName": { @@ -17476,68 +17194,42 @@ } ] }, - "WorkspaceLink": { + "WorkspaceAccess": { + "description": "Per-attachment VFS access; `edit` implies `read`.", + "enum": [ + "read", + "edit" + ], + "type": "string" + }, + "WorkspaceAttachment": { "additionalProperties": false, + "description": "One catalog resource mounted into the session namespace. Exactly one of\n`workspaceId` and `snapshotRef` names the resource; snapshots are\nimmutable and must be attached with `read` access.", "properties": { "access": { - "$ref": "#/components/schemas/WorkspaceLinkAccess" + "$ref": "#/components/schemas/WorkspaceAccess" }, "path": { "type": "string" }, - "target": { - "$ref": "#/components/schemas/WorkspaceLinkTarget" + "snapshotRef": { + "type": [ + "string", + "null" + ] + }, + "workspaceId": { + "type": [ + "string", + "null" + ] } }, "required": [ "path", - "target", "access" ], "type": "object" - }, - "WorkspaceLinkAccess": { - "enum": [ - "readOnly", - "readWrite" - ], - "type": "string" - }, - "WorkspaceLinkTarget": { - "oneOf": [ - { - "properties": { - "type": { - "const": "workspace", - "type": "string" - }, - "workspaceId": { - "type": "string" - } - }, - "required": [ - "type", - "workspaceId" - ], - "type": "object" - }, - { - "properties": { - "snapshotRef": { - "type": "string" - }, - "type": { - "const": "snapshot", - "type": "string" - } - }, - "required": [ - "type", - "snapshotRef" - ], - "type": "object" - } - ] } } }, @@ -17569,7 +17261,7 @@ "summary": "Inspect the Lightspeed protocol" }, { - "description": "Creates a session with optional config/profile setup. Profile metadata and retention supply creation defaults; explicit start values override them. An existing-or-none environment override can replace the profile intent. Retrying an existing session id returns that session.", + "description": "Creates a session with optional config/profile setup. Profile metadata and retention supply creation defaults; explicit start values override them. The default environment attachment in the effective config supplies the initial active environment. Retrying an existing session id returns that session.", "name": "session/start", "paramStructure": "by-name", "params": [ @@ -17590,7 +17282,7 @@ "summary": "Create or reopen a session" }, { - "description": "Creates a session with immutable lifecycle and workflow-tool declarations using explicit bound dispatch. Profile metadata, retention, and environment overrides follow session/start semantics. Retrying an id requires the same managed declaration; an ordinary session cannot be upgraded.", + "description": "Creates a session with immutable lifecycle and workflow-tool declarations using explicit bound dispatch. Profile metadata, retention, and default environment attachment selection follow session/start semantics. Retrying an id requires the same managed declaration; an ordinary session cannot be upgraded.", "name": "session/managed/start", "paramStructure": "by-name", "params": [ diff --git a/crates/api/src/environments.rs b/crates/api/src/environments.rs index 90e51178..d53a85c5 100644 --- a/crates/api/src/environments.rs +++ b/crates/api/src/environments.rs @@ -65,9 +65,6 @@ pub struct EnvironmentReadResponse { pub struct EnvironmentListParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_id: Option, - /// Only environments a profile provisioned for this session. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub origin_session_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub binding_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -409,11 +406,6 @@ pub struct EnvironmentView { pub public_ingress_enabled: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub public_endpoint: Option, - /// Present when a profile provisioned this environment for a session. - /// Provenance and an optional close trigger, not ownership: the - /// environment remains an ordinary universe resource. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub origin_session: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub metadata: BTreeMap, /// Registered environments only: when the gateway last saw the daemon's @@ -424,16 +416,6 @@ pub struct EnvironmentView { pub updated_at_ms: i64, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct EnvironmentOriginSessionView { - pub session_id: SessionId, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile_id: Option, - /// When true, Lightspeed closes the environment once the session closes. - pub close_with_session: bool, -} - #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub enum EnvironmentProviderBindingStatusView { diff --git a/crates/api/src/mcp.rs b/crates/api/src/mcp.rs index 559fd5e0..8435dec1 100644 --- a/crates/api/src/mcp.rs +++ b/crates/api/src/mcp.rs @@ -14,9 +14,9 @@ pub struct McpServerView { pub allowed_tools: Option>, pub execution: RemoteMcpExecution, pub exposure: RemoteMcpExposure, - pub approval_default: RemoteMcpApprovalPolicy, + pub approval: RemoteMcpApprovalPolicy, #[serde(default, skip_serializing_if = "Option::is_none")] - pub defer_loading_default: Option, + pub defer_loading: Option, pub allow_private_network: bool, pub auth_policy: McpServerAuthPolicy, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -215,10 +215,12 @@ pub struct McpServerInput { pub execution: RemoteMcpExecution, #[serde(default)] pub exposure: RemoteMcpExposure, + /// Approval policy for every session linking this server. #[serde(default)] - pub approval_default: RemoteMcpApprovalPolicy, + pub approval: RemoteMcpApprovalPolicy, + /// Provider-side deferred loading of tool definitions where supported. #[serde(default, skip_serializing_if = "Option::is_none")] - pub defer_loading_default: Option, + pub defer_loading: Option, #[serde(default)] pub allow_private_network: bool, #[serde(default)] diff --git a/crates/api/src/profiles.rs b/crates/api/src/profiles.rs index a56670cf..3038bee7 100644 --- a/crates/api/src/profiles.rs +++ b/crates/api/src/profiles.rs @@ -175,18 +175,12 @@ pub struct AgentProfileSummary { } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ProfileDocument { #[serde(default, skip_serializing_if = "Option::is_none")] pub config: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub instructions: Option, - /// How the session obtains its active environment when this profile is - /// applied: activate an existing universe environment, or provision a - /// fresh one for this session. Absence leaves the session's current - /// active environment unchanged. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub environment: Option, /// Descriptive metadata defaults copied to a session when it is created /// from this profile. Explicit `session/start` metadata wins key by key. /// Applying the profile to an existing session does not change metadata. @@ -208,73 +202,6 @@ pub struct ProfileSessionRetention { pub delete_after_close_ms: u64, } -/// Environment intent carried by a profile document. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde( - tag = "type", - rename_all = "camelCase", - rename_all_fields = "camelCase", - deny_unknown_fields -)] -pub enum ProfileEnvironment { - /// Activate an existing universe environment. The profile never closes - /// it. - Existing { environment_id: EnvironmentId }, - /// Activate the delegating parent's active environment. Resolved at - /// sub-agent spawn, shared not copied, never closed by the - /// child; rejected on a session without a delegation origin or whose - /// parent has no active environment. - Inherit {}, - /// Provision one environment for the session from the universe's enabled - /// binding for `providerId`, then activate it. The provision request id - /// is derived from the session id, so retries and repeated applies - /// converge on the same environment. - Provision { - provider_id: EnvironmentProviderId, - /// Immutable provider template-version identity. - template_id: EnvironmentTemplateId, - #[serde(default, skip_serializing_if = "Option::is_none")] - display_name: Option, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - metadata: BTreeMap, - #[serde(default)] - retention: ProfileEnvironmentRetention, - /// Optional staged idle policy for the provisioned environment. - /// Stages the provider cannot realize are skipped. - #[serde(default, skip_serializing_if = "Option::is_none")] - idle_policy: Option, - /// Credentials bound to the environment right after it is - /// provisioned before activation: references to universe - /// grants/providers/secrets, never values. They become ordinary - /// environment credential bindings; the profile is the initial set, - /// not a live sync. Not available for `existing` environments. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - credentials: Vec, - }, -} - -/// One environment credential binding requested by a profile: the same shape -/// as `environments/credentials/bind`. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ProfileEnvironmentCredential { - /// Environment variable name (`[A-Za-z_][A-Za-z0-9_]{0,127}`). - pub env_name: String, - pub source: EnvironmentCredentialSourceView, -} - -/// What happens to a profile-provisioned environment when its originating -/// session closes. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub enum ProfileEnvironmentRetention { - /// Close the environment when the session that provisioned it closes. - #[default] - CloseWithSession, - /// Leave the environment open; the universe owns its cleanup. - Retain, -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde( tag = "type", @@ -396,7 +323,4 @@ pub struct ProfileApplySummary { pub config_changed: bool, pub instructions_changed: bool, pub active_environment_changed: bool, - /// True when this apply created a new environment for the session. - #[serde(default)] - pub environment_provisioned: bool, } diff --git a/crates/api/src/rpc.rs b/crates/api/src/rpc.rs index 44b16f48..8f080ef8 100644 --- a/crates/api/src/rpc.rs +++ b/crates/api/src/rpc.rs @@ -321,9 +321,9 @@ api_methods! { METHOD_INITIALIZE => initialize(InitializeParams) -> InitializeResponse => ["Inspect the Lightspeed protocol", "Returns protocol version, server identity, and supported capabilities without changing universe state."], METHOD_SESSION_START => start_session(SessionStartParams) -> SessionStartResponse => - ["Create or reopen a session", "Creates a session with optional config/profile setup. Profile metadata and retention supply creation defaults; explicit start values override them. An existing-or-none environment override can replace the profile intent. Retrying an existing session id returns that session."], + ["Create or reopen a session", "Creates a session with optional config/profile setup. Profile metadata and retention supply creation defaults; explicit start values override them. The default environment attachment in the effective config supplies the initial active environment. Retrying an existing session id returns that session."], METHOD_SESSION_MANAGED_START => start_managed_session(ManagedSessionStartParams) -> SessionStartResponse => - ["Create or reopen a managed session", "Creates a session with immutable lifecycle and workflow-tool declarations using explicit bound dispatch. Profile metadata, retention, and environment overrides follow session/start semantics. Retrying an id requires the same managed declaration; an ordinary session cannot be upgraded."], + ["Create or reopen a managed session", "Creates a session with immutable lifecycle and workflow-tool declarations using explicit bound dispatch. Profile metadata, retention, and default environment attachment selection follow session/start semantics. Retrying an id requires the same managed declaration; an ordinary session cannot be upgraded."], METHOD_SESSION_READ => read_session(SessionReadParams) -> SessionReadResponse => ["Read a session", "Returns current state plus a bounded newest-first run-summary page. Follow nextRunCursor with session/runs/list when hasOlderRuns is true; use session/events/read for the transcript."], METHOD_SESSION_LIST => list_sessions(SessionListParams) -> SessionListResponse => diff --git a/crates/api/src/sessions.rs b/crates/api/src/sessions.rs index d62aa796..b08116f5 100644 --- a/crates/api/src/sessions.rs +++ b/crates/api/src/sessions.rs @@ -23,21 +23,6 @@ fn optional_nullable_delete_after_close_ms_schema( }) } -/// Creation-time override for the environment intent carried by a profile. -/// Absence uses the profile unchanged; `none` suppresses its environment -/// intent, while `existing` activates the specified universe environment. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde( - tag = "type", - rename_all = "camelCase", - rename_all_fields = "camelCase", - deny_unknown_fields -)] -pub enum SessionEnvironmentOverride { - None {}, - Existing { environment_id: EnvironmentId }, -} - #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SessionStartParams { @@ -55,10 +40,6 @@ pub struct SessionStartParams { pub config: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, - /// Optional creation-time override for the selected profile's environment - /// intent. Omit to use the profile's intent unchanged. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub environment: Option, /// Root-owned automatic deletion measured from close. Absent inherits a /// profile default, explicit null keeps the tree, and a duration overrides /// the profile. @@ -87,10 +68,6 @@ pub struct ManagedSessionStartParams { pub config: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, - /// Optional creation-time override for the selected profile's environment - /// intent. Omit to use the profile's intent unchanged. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub environment: Option, /// Root-owned automatic deletion measured from close. Absent inherits a /// profile default, explicit null keeps the tree, and a duration overrides /// the profile. @@ -409,9 +386,12 @@ pub struct FeaturesConfig { pub mcp: Option, } -/// Grants the session virtual filesystem. Workspace links declare the -/// session-visible namespace and the VFS catalog is surfaced. Sub-grants are independent; `{}` grants a VFS with -/// no tools and no sourcing. +/// Grants the session virtual filesystem. Workspace attachments declare the +/// session-visible namespace and the VFS catalog is surfaced. The file tool +/// surface is derived from the attachments: any attachment installs the read +/// tools, any `edit` attachment adds the write tools, and with the +/// environments feature granted the matching transfer tools appear. `{}` +/// grants a VFS with no attachments, no tools, and no sourcing. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct VfsFeature { @@ -420,56 +400,41 @@ pub struct VfsFeature { /// Absolute VFS tool working directory; absent uses /. #[serde(default, skip_serializing_if = "Option::is_none")] pub working_directory: Option, - /// Catalog resources exposed in the session's workspace namespace. + /// Catalog resources exposed in the session's workspace namespace at + /// disjoint absolute paths. #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub workspace_links: Vec, - /// Agent-facing filesystem tool surface; absent = no fs tools. Per-path - /// writability is defined by each workspace link's own access. - /// With the environments feature granted, `readOnly` also exposes - /// `vfs_materialize`; `edit` additionally exposes `vfs_capture`. - /// Prompt/skill sourcing alone does not grant transfer tools. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tools: Option, + pub workspaces: Vec, /// Prompt-instruction sourcing from the VFS. Absent disables loading; - /// an empty block discovers conventional linked roots. + /// an empty block discovers conventional attached roots. #[serde(default, skip_serializing_if = "Option::is_none")] pub prompts: Option, /// Independent VFS skill discovery. Absent disables discovery and removes - /// its runtime catalog; an empty block discovers conventional linked roots. + /// its runtime catalog; an empty block discovers conventional attached roots. #[serde(default, skip_serializing_if = "Option::is_none")] pub skills: Option, } +/// One catalog resource mounted into the session namespace. Exactly one of +/// `workspaceId` and `snapshotRef` names the resource; snapshots are +/// immutable and must be attached with `read` access. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct WorkspaceLink { +pub struct WorkspaceAttachment { pub path: String, - pub target: WorkspaceLinkTarget, - pub access: WorkspaceLinkAccess, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snapshot_ref: Option, + pub access: WorkspaceAccess, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde( - tag = "type", - rename_all = "camelCase", - rename_all_fields = "camelCase" +/// Per-attachment VFS access; `edit` implies `read`. +#[derive( + Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, )] -pub enum WorkspaceLinkTarget { - Workspace { workspace_id: String }, - Snapshot { snapshot_ref: String }, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] -pub enum WorkspaceLinkAccess { - ReadOnly, - ReadWrite, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub enum VfsToolSurface { - ReadOnly, +pub enum WorkspaceAccess { + Read, Edit, } @@ -477,8 +442,8 @@ pub enum VfsToolSurface { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct VfsPromptsConfig { /// Absent searches .agents/prompts and .lightspeed/prompts beneath each - /// workspace link. Explicit roots replace these defaults and must be - /// non-empty absolute paths contained in workspace links. + /// workspace attachment. Explicit roots replace these defaults and must be + /// non-empty absolute paths contained in workspace attachments. #[serde(default, skip_serializing_if = "Option::is_none")] pub roots: Option>, } @@ -487,8 +452,8 @@ pub struct VfsPromptsConfig { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct VfsSkillsConfig { /// Absent searches .agents/skills and .lightspeed/skills beneath each - /// workspace link. Explicit roots replace these defaults and must be - /// non-empty absolute paths contained in workspace links. + /// workspace attachment. Explicit roots replace these defaults and must be + /// non-empty absolute paths contained in workspace attachments. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(length(min = 1))] pub roots: Option>, @@ -580,58 +545,74 @@ pub struct TimersFeature { pub version: u32, } -/// Grants active session environments. Filesystem tools, commands, selection, -/// durable jobs, prompts, and skills are independent, default-off sub-grants. +/// Grants session environments. The `environments` list is the allowed set: +/// the session can select, read, and run work only on a listed machine, each +/// with its own access grant and working directory. The installed tool +/// surface is the union of every attachment's grant; a call the active +/// machine's grant does not cover fails at execution, so switching machines +/// never changes the toolset. `{}` grants the feature with no reachable +/// machine. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct EnvironmentsFeature { #[serde(default = "default_feature_version")] pub version: u32, - /// Filesystem tool surface. Absent installs no filesystem tools; sources - /// remain independent. Read-only does not restrict commands or durable jobs. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tools: Option, - /// Grants command execution and process continuation. Commands may modify - /// files even when filesystem tools are read-only or disabled. - #[serde(default)] - pub commands: bool, - /// Absolute machine working directory for file tools, commands, jobs, and sources; absent uses the endpoint default. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub working_directory: Option, - /// Absent means every registered provider is allowed. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub providers: Option>, - /// Registration keys whose registered environments the session may - /// list and activate; absent means every key. Independent of - /// `providers`: each list scopes its own environment source, and - /// external environments pass only when neither list is set. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub registration_keys: Option>, /// Exposes `environment_list`, `environment_activate`, and - /// `environment_deactivate` to the model. `environment_read` is available - /// whenever environments are enabled, and external API/profile activation - /// remains available when this is false. - #[serde(default)] - pub selection_tools: bool, - /// Grants the advanced durable-job tool surface. The workflow binding is - /// installed for the session when granted; invocations still require an - /// active, ready environment with matching job capabilities. + /// `environment_deactivate` over the attached environments. + /// `environment_read` is available whenever environments are enabled, and + /// external API/profile activation remains available when this is false. #[serde(default)] - pub jobs: bool, + pub selection: bool, /// Independent environment prompt loading; absent disables sourced instructions. #[serde(default, skip_serializing_if = "Option::is_none")] pub prompts: Option, /// Independent environment skill discovery. Absent disables discovery. #[serde(default, skip_serializing_if = "Option::is_none")] pub skills: Option, + /// The environments this session may use; unique ids, at most one + /// default, at most one `inherit` (profiles only). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub environments: Vec, } -/// Agent-facing environment filesystem tools; independent of execution grants. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +/// One environment the session may use. Exactly one of `environmentId` and +/// `inherit` identifies the machine. `inherit` is valid only in a profile +/// document applied to a sub-agent: it resolves to the delegating parent's +/// active environment at spawn and is stored on the child as a concrete id. +/// If the parent's environment is also listed explicitly, the explicit +/// attachment wins; if the parent has none, the inherit attachment is dropped. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EnvironmentAttachment { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environment_id: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub inherit: bool, + /// Activated when a profile is applied while the session has no active + /// environment; creation is the trivial case. Never overrides a live + /// selection and never applies on a plain `session/config/put`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub default: bool, + pub access: EnvironmentAccess, + /// Absolute machine working directory for file tools, commands, jobs, + /// and sources; absent uses the machine's advertised default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_directory: Option, +} + +/// Per-attachment environment access, an ordered ladder: `edit` adds file +/// editing to `read`, `exec` adds processes, `jobs` adds durable jobs. +/// Processes can write files regardless of the file-tool level, so +/// read-only files with commands is deliberately not expressible. +#[derive( + Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] #[serde(rename_all = "camelCase")] -pub enum EnvironmentToolSurface { - ReadOnly, +pub enum EnvironmentAccess { + Read, Edit, + Exec, + Jobs, } /// Prompt loading scope resolved on the selected machine, never on the worker. @@ -660,23 +641,29 @@ pub struct EnvironmentSkillsConfig { pub roots: Option>, } -/// Grants remote MCP tools by declaring linked servers from the universe MCP -/// catalog; must link at least one server, with unique server ids. +/// Grants remote MCP tools by declaring attached servers from the universe MCP +/// catalog. Server ids must be unique; an empty list grants no MCP tools. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct McpFeature { #[serde(default = "default_feature_version")] pub version: u32, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub servers: Vec, + #[serde(default)] + pub servers: Vec, } -/// A selected universe MCP server. Its catalog record owns all connection and -/// behavior configuration. +/// A selected universe MCP server. Its catalog record owns connection, +/// execution, exposure, approval, and auth; the attachment may only narrow the +/// record's tool allowlist for this session. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct McpServerLink { +pub struct McpServerAttachment { pub server_id: String, + /// Non-empty subset of the record's allowed tools exposed to this + /// session, under both injection and search; absent exposes the record's + /// full allowlist. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option>, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] diff --git a/crates/api/src/tests.rs b/crates/api/src/tests.rs index eba972a0..94d4857e 100644 --- a/crates/api/src/tests.rs +++ b/crates/api/src/tests.rs @@ -3,6 +3,15 @@ use serde_json::{Value, json}; use super::*; +#[test] +fn mcp_feature_preserves_an_empty_server_list() { + for value in [json!({}), json!({ "servers": [] })] { + let feature: McpFeature = serde_json::from_value(value).expect("empty MCP feature"); + assert!(feature.servers.is_empty()); + assert_eq!(serde_json::to_value(feature).unwrap()["servers"], json!([])); + } +} + #[test] fn session_retention_put_requires_an_explicit_nullable_policy() { assert!( @@ -409,26 +418,24 @@ fn ordinary_session_start_rejects_managed_creation_fields() { } #[test] -fn session_start_decodes_creation_environment_overrides() { - let existing: SessionStartParams = serde_json::from_value(json!({ - "profile": {"kind": "named", "profileId": "developer"}, - "environment": {"type": "existing", "environmentId": "workstation"} - })) - .expect("existing environment override"); - assert!(matches!( - existing.environment, - Some(SessionEnvironmentOverride::Existing { environment_id }) - if environment_id == "workstation" - )); - - let none: SessionStartParams = serde_json::from_value(json!({ - "environment": {"type": "none"} - })) - .expect("none environment override"); - assert!(matches!( - none.environment, - Some(SessionEnvironmentOverride::None {}) - )); +fn session_start_rejects_creation_environment_overrides() { + for environment in [ + json!({"type": "existing", "environmentId": "workstation"}), + json!({"type": "none"}), + json!(null), + ] { + let mut request = json!({ + "profile": {"kind": "named", "profileId": "developer"}, + "environment": environment, + }); + let error = serde_json::from_value::(request.clone()) + .expect_err("ordinary start rejects the removed environment field"); + assert!(error.to_string().contains("unknown field `environment`")); + request["workflowTools"] = json!({"version": 1, "tools": []}); + let error = serde_json::from_value::(request) + .expect_err("managed start rejects the removed environment field"); + assert!(error.to_string().contains("unknown field `environment`")); + } } #[test] @@ -684,7 +691,9 @@ async fn dispatch_json_rpc_routes_session_config_put() { "generation": { "reasoningEffort": "high" }, "features": { "timers": {}, - "vfs": { "tools": "edit" } + "vfs": { "workspaces": [ + { "path": "/workspace", "workspaceId": "ws_1", "access": "edit" } + ] } } } })), @@ -1183,10 +1192,7 @@ fn mcp_server_put_params_default_approval_is_never_and_revision_optional() { })) .expect("params"); - assert_eq!( - params.server.approval_default, - RemoteMcpApprovalPolicy::Never - ); + assert_eq!(params.server.approval, RemoteMcpApprovalPolicy::Never); assert_eq!(params.expected_revision, None); assert_eq!(params.server.credential, None); } @@ -1228,17 +1234,17 @@ fn mcp_server_put_rejects_internal_transport_field() { } #[test] -fn mcp_session_links_reject_removed_connection_and_policy_fields() { +fn mcp_session_attachments_reject_removed_connection_and_policy_fields() { for (field, value) in [ ("authGrantId", json!("authgrant_1")), ("allowedTools", json!(["search"])), ("approval", json!("never")), ("deferLoading", json!(true)), ] { - let mut link = serde_json::Map::from_iter([("serverId".to_owned(), json!("echo"))]); - link.insert(field.to_owned(), value); - let error = serde_json::from_value::(Value::Object(link)) - .expect_err("session MCP links must reject removed fields"); + let mut attachment = serde_json::Map::from_iter([("serverId".to_owned(), json!("echo"))]); + attachment.insert(field.to_owned(), value); + let error = serde_json::from_value::(Value::Object(attachment)) + .expect_err("session MCP attachments must reject removed fields"); assert!( error.to_string().contains("unknown field"), "{field}: {error}" @@ -3112,7 +3118,6 @@ fn test_profile(profile_id: ProfileId) -> AgentProfile { instructions: Some(ProfileInstructions::Text { text: "Be concise.".to_owned(), }), - environment: None, }, created_at_ms: 1, updated_at_ms: 2, @@ -3215,7 +3220,6 @@ fn test_environment_instance() -> EnvironmentView { }, public_ingress_enabled: false, public_endpoint: None, - origin_session: None, metadata: BTreeMap::new(), last_seen_at_ms: None, created_at_ms: 10, @@ -3279,7 +3283,6 @@ fn test_external_environment() -> EnvironmentView { }, public_ingress_enabled: false, public_endpoint: None, - origin_session: None, metadata: BTreeMap::new(), last_seen_at_ms: None, created_at_ms: 10, @@ -3313,8 +3316,8 @@ fn test_mcp_server(server_id: String) -> McpServerView { allowed_tools: None, execution: RemoteMcpExecution::Provider, exposure: RemoteMcpExposure::Inject, - approval_default: RemoteMcpApprovalPolicy::Never, - defer_loading_default: None, + approval: RemoteMcpApprovalPolicy::Never, + defer_loading: None, allow_private_network: false, auth_policy: McpServerAuthPolicy::None, credential: None, diff --git a/crates/api/tests/schema_artifacts.rs b/crates/api/tests/schema_artifacts.rs index 199464a5..d77fdcf5 100644 --- a/crates/api/tests/schema_artifacts.rs +++ b/crates/api/tests/schema_artifacts.rs @@ -199,18 +199,29 @@ fn vfs_skills_support_default_roots_and_nonempty_overrides() { assert!(validator.is_valid(&enabled)); let config: api::VfsSkillsConfig = serde_json::from_value(enabled.clone()).unwrap(); assert_eq!(serde_json::to_value(config).unwrap(), enabled); - let feature: api::VfsFeature = serde_json::from_value(json!({"tools": "edit"})).unwrap(); + let feature: api::VfsFeature = serde_json::from_value(json!({ + "workspaces": [{"path": "/workspace", "workspaceId": "ws_1", "access": "edit"}] + })) + .unwrap(); assert!(feature.skills.is_none()); + assert_eq!(feature.workspaces[0].access, api::WorkspaceAccess::Edit); + assert!(serde_json::from_value::(json!({"tools": "edit"})).is_err()); } #[test] -fn environment_sources_use_domain_directory_and_reject_old_scope_fields() { +fn environment_sources_use_attachment_directory_and_reject_old_scope_fields() { let bundle = api::export_schemas().schema_bundle; - let config = - json!({"workingDirectory":"/project", "skills":{}, "prompts":{"roots":["./prompts"]}}); + let config = json!({ + "skills":{}, + "prompts":{"roots":["./prompts"]}, + "environments":[{"environmentId":"env_a","access":"exec","workingDirectory":"/project"}] + }); assert_validates(&bundle, "EnvironmentsFeature", &config); let feature: api::EnvironmentsFeature = serde_json::from_value(config).unwrap(); - assert_eq!(feature.working_directory.as_deref(), Some("/project")); + assert_eq!( + feature.environments[0].working_directory.as_deref(), + Some("/project") + ); assert!(feature.skills.unwrap().roots.is_none()); assert_eq!(feature.prompts.unwrap().roots.unwrap(), vec!["./prompts"]); for name in ["EnvironmentPromptsConfig", "EnvironmentSkillsConfig"] { @@ -225,21 +236,75 @@ fn environment_sources_use_domain_directory_and_reject_old_scope_fields() { assert!(serde_json::from_value::(old.clone()).is_err()); assert!(serde_json::from_value::(old).is_err()); } + for old in [ + json!({"workingDirectory":"/project"}), + json!({"tools":"edit"}), + json!({"commands":true}), + json!({"jobs":true}), + json!({"selectionTools":true}), + json!({"providers":["incus"]}), + json!({"registrationKeys":["key"]}), + ] { + assert!(serde_json::from_value::(old).is_err()); + } } #[test] -fn environment_tool_grants_are_explicit_and_independent() { +fn environment_attachments_carry_access_default_and_inherit() { let bundle = api::export_schemas().schema_bundle; let empty: api::EnvironmentsFeature = serde_json::from_value(json!({})).unwrap(); - assert!(empty.tools.is_none()); - assert!(!empty.commands); - for surface in ["readOnly", "edit"] { - let value = json!({"tools":surface,"commands":true,"jobs":false,"prompts":{},"skills":{}}); - assert_validates(&bundle, "EnvironmentsFeature", &value); - let config: api::EnvironmentsFeature = serde_json::from_value(value).unwrap(); - assert!(config.commands); - assert!(!config.jobs); - assert!(config.prompts.is_some()); - assert!(config.skills.is_some()); + assert!(!empty.selection); + assert!(empty.environments.is_empty()); + let value = json!({ + "version": api::CURRENT_FEATURE_VERSION, + "selection": true, + "environments": [ + {"environmentId":"env_a","default":true,"access":"jobs","workingDirectory":"/srv"}, + {"inherit":true,"access":"read"} + ] + }); + assert_validates(&bundle, "EnvironmentsFeature", &value); + let config: api::EnvironmentsFeature = serde_json::from_value(value.clone()).unwrap(); + assert!(config.selection); + assert!(config.environments[0].default); + assert_eq!(config.environments[0].access, api::EnvironmentAccess::Jobs); + assert!(config.environments[1].inherit); + assert!(config.environments[1].environment_id.is_none()); + assert_eq!(serde_json::to_value(config).unwrap(), value); + for access in ["read", "edit", "exec", "jobs"] { + assert_validates( + &bundle, + "EnvironmentAttachment", + &json!({"environmentId":"env_a","access":access}), + ); } + assert!( + serde_json::from_value::( + json!({"environmentId":"env_a","access":"write"}) + ) + .is_err() + ); +} + +#[test] +fn mcp_attachments_may_narrow_tools_and_profiles_carry_no_environment_intent() { + let bundle = api::export_schemas().schema_bundle; + let value = json!({ + "version": api::CURRENT_FEATURE_VERSION, + "servers":[{"serverId":"github"},{"serverId":"notion","tools":["search"]}] + }); + assert_validates(&bundle, "McpFeature", &value); + let feature: api::McpFeature = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(feature.servers[0].tools, None); + assert_eq!( + feature.servers[1].tools.as_deref(), + Some(&["search".to_owned()][..]) + ); + assert_eq!(serde_json::to_value(feature).unwrap(), value); + assert!( + serde_json::from_value::( + json!({"environment":{"type":"existing","environmentId":"env_a"}}) + ) + .is_err() + ); } diff --git a/crates/cli/src/api_client.rs b/crates/cli/src/api_client.rs index a2e9ceba..1c9b3f99 100644 --- a/crates/cli/src/api_client.rs +++ b/crates/cli/src/api_client.rs @@ -533,14 +533,6 @@ impl HttpAgentApi { .await } - pub(crate) async fn list_environment_templates( - &self, - params: api::EnvironmentTemplateListParams, - ) -> Result, AgentApiError> { - self.request(api::METHOD_ENVIRONMENTS_TEMPLATES_LIST, params) - .await - } - pub(crate) async fn list_environments( &self, params: EnvironmentListParams, diff --git a/crates/cli/src/chat/driver.rs b/crates/cli/src/chat/driver.rs index 76b00b42..f3c455fa 100644 --- a/crates/cli/src/chat/driver.rs +++ b/crates/cli/src/chat/driver.rs @@ -9,7 +9,7 @@ use api::{ ModelConfig, ProfileId, ProfileSource, RunStartConfig, RunStartParams, RunStartResponse, RunStartSource, SessionEventKindView, SessionEventView, SessionEventsReadParams, SessionReadParams, SessionStartParams, SessionView, TimersFeature, ToolCallEventView, - VfsFeature, VfsPromptsConfig, VfsToolSurface, WebFeature, WebFetchFeature, WebSearchFeature, + VfsFeature, VfsPromptsConfig, WebFeature, WebFetchFeature, WebSearchFeature, WorkspaceAccess, }; #[cfg(test)] use api::{ContextEntryKindView, ContextEntryView, ToolBatchView, ToolCallView, ToolItemStatus}; @@ -68,7 +68,9 @@ pub(crate) struct ChatArgs { /// Disable web fetch for this session. #[arg(long = "no-web-fetch")] no_web_fetch: bool, - /// Filesystem tool mode for this session: edit, read-only, or none. + /// Access granted on the `--mount` workspace attachment: edit or read. + /// File tools are derived from attachments, so without a mount the + /// session has a VFS but no file tools. #[arg(long = "filesystem-tools")] filesystem_tools: Option, /// Start with no feature grants at all (model + runs only) instead of @@ -240,7 +242,6 @@ impl ChatSessionDriver { display_name: None, config: Some(session_start_config(&options.draft_settings)), profile: options.profile.clone(), - environment: None, delete_after_close_ms: None, }) .await @@ -312,6 +313,7 @@ impl ChatSessionDriver { self.session_id.clone(), mount_path, workspace.workspace_id, + mount_access(&self.settings), ) .await .context("failed to mount chat workspace")?; @@ -1048,7 +1050,6 @@ impl ChatSessionDriver { display_name: None, config: Some(session_start_config(&self.settings)), profile: None, - environment: None, delete_after_close_ms: None, }) .await @@ -1523,18 +1524,22 @@ fn draft_settings(args: &ChatArgs) -> Result { }) } -fn parse_filesystem_tool_mode(value: &str) -> Result { - use crate::chat::protocol::FilesystemToolMode; +fn parse_filesystem_tool_mode(value: &str) -> Result { match value { - "edit" => Ok(FilesystemToolMode::Edit), - "read-only" | "read_only" | "readonly" => Ok(FilesystemToolMode::ReadOnly), - "none" | "off" | "disabled" => Ok(FilesystemToolMode::None), + "edit" => Ok(WorkspaceAccess::Edit), + "read" | "read-only" | "read_only" | "readonly" => Ok(WorkspaceAccess::Read), other => Err(anyhow!( - "invalid filesystem tool mode '{other}'; expected edit, read-only, or none" + "invalid filesystem tool mode '{other}'; expected edit or read" )), } } +/// Access of the workspace the chat client attaches for `--mount`; edit +/// unless the user narrowed it. +fn mount_access(settings: &ChatDraftSettings) -> WorkspaceAccess { + settings.filesystem_tools.unwrap_or(WorkspaceAccess::Edit) +} + fn model_config(settings: &ChatDraftSettings) -> ModelConfig { ModelConfig { provider_id: settings.provider.clone(), @@ -1555,15 +1560,10 @@ fn session_start_config(settings: &ChatDraftSettings) -> api::SessionConfig { /// The CLI's development defaults: features are secure-by-default on the /// server (absent = off), so the chat client grants a usable dev surface -/// explicitly — VFS with fs tools and prompt sourcing, web, timers. Skill discovery -/// requires an explicit profile/session configuration. +/// explicitly — VFS with prompt sourcing, web, timers. File tools appear once +/// a workspace is attached (`--mount`); skill discovery requires an explicit +/// profile/session configuration. fn dev_features(settings: &ChatDraftSettings) -> FeaturesConfig { - let vfs_tools = match settings.filesystem_tools { - None => Some(VfsToolSurface::Edit), - Some(crate::chat::protocol::FilesystemToolMode::Edit) => Some(VfsToolSurface::Edit), - Some(crate::chat::protocol::FilesystemToolMode::ReadOnly) => Some(VfsToolSurface::ReadOnly), - Some(crate::chat::protocol::FilesystemToolMode::None) => None, - }; let web_fetch = settings.web_fetch.unwrap_or(true); let web_search = settings.web_search.unwrap_or(true) && matches!( @@ -1574,8 +1574,7 @@ fn dev_features(settings: &ChatDraftSettings) -> FeaturesConfig { vfs: Some(VfsFeature { working_directory: None, version: api::CURRENT_FEATURE_VERSION, - workspace_links: Vec::new(), - tools: vfs_tools, + workspaces: Vec::new(), prompts: Some(VfsPromptsConfig::default()), skills: None, }), @@ -1987,7 +1986,7 @@ mod tests { let features = config.features.expect("features"); let vfs = features.vfs.expect("vfs"); - assert_eq!(vfs.tools, Some(VfsToolSurface::Edit)); + assert!(vfs.workspaces.is_empty()); assert!(vfs.prompts.is_some()); assert!(vfs.skills.is_none()); let web = features.web.expect("web"); @@ -2023,15 +2022,18 @@ mod tests { } #[test] - fn session_start_config_can_select_read_only_filesystem_tools() { + fn mount_access_defaults_to_edit_and_can_be_narrowed_to_read() { + let settings = draft_settings(&chat_args_with_effort(None)).expect("draft settings"); + assert_eq!(mount_access(&settings), WorkspaceAccess::Edit); + let mut args = chat_args_with_effort(None); args.filesystem_tools = Some("read-only".to_owned()); let settings = draft_settings(&args).expect("draft settings"); + assert_eq!(mount_access(&settings), WorkspaceAccess::Read); - let config = session_start_config(&settings); - - let vfs = config.features.expect("features").vfs.expect("vfs"); - assert_eq!(vfs.tools, Some(VfsToolSurface::ReadOnly)); + let mut args = chat_args_with_effort(None); + args.filesystem_tools = Some("none".to_owned()); + assert!(draft_settings(&args).is_err()); } #[test] diff --git a/crates/cli/src/chat/protocol.rs b/crates/cli/src/chat/protocol.rs index 86fe8a3f..9dd4707f 100644 --- a/crates/cli/src/chat/protocol.rs +++ b/crates/cli/src/chat/protocol.rs @@ -1,14 +1,4 @@ -use api::{RunStatus, SessionStatus}; - -/// CLI-local filesystem tool surface setting: `None` grants a VFS without fs -/// tools; the api-level surface is `api::VfsToolSurface`. -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum FilesystemToolMode { - None, - ReadOnly, - Edit, -} +use api::{RunStatus, SessionStatus, WorkspaceAccess}; use clap::ValueEnum; use serde::{Deserialize, Serialize}; @@ -27,7 +17,9 @@ pub(crate) struct ChatDraftSettings { pub max_tokens: Option, pub web_search: Option, pub web_fetch: Option, - pub filesystem_tools: Option, + /// Access of the workspace attached for `--mount`; file tools are + /// derived from attachments, so `None` means the default (edit). + pub filesystem_tools: Option, /// Send no feature grants at all: the true secure default (model + /// runs only) instead of the CLI's dev feature set. #[serde(default)] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 274bab21..f1b718ea 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -313,7 +313,8 @@ mod tests { "/workspace", "--workspace", "workspace_1", - "--read-write", + "--access", + "edit", ]) .expect("parse vfs mount put"); assert!(matches!(cli.command, Command::Vfs(_))); diff --git a/crates/cli/src/mcp_cli.rs b/crates/cli/src/mcp_cli.rs index 508b1ce5..aab2a1df 100644 --- a/crates/cli/src/mcp_cli.rs +++ b/crates/cli/src/mcp_cli.rs @@ -292,6 +292,10 @@ struct McpLinkArgs { session: String, /// Registered MCP server id to link. server_id: String, + /// Comma-separated subset of the server's allowed tools to expose; + /// omitted exposes the record's full allowlist. + #[arg(long, value_delimiter = ',')] + tools: Vec, } #[derive(Args, Debug, Clone)] @@ -519,8 +523,8 @@ fn server_input_from_view(server: &api::McpServerView) -> api::McpServerInput { allowed_tools: server.allowed_tools.clone(), execution: server.execution, exposure: server.exposure, - approval_default: server.approval_default, - defer_loading_default: server.defer_loading_default, + approval: server.approval, + defer_loading: server.defer_loading, allow_private_network: server.allow_private_network, auth_policy: server.auth_policy.clone(), credential: server.credential.clone(), @@ -610,8 +614,8 @@ async fn server_put(args: McpServerPutArgs) -> Result<()> { allowed_tools: nonempty_vec(args.allowed_tools), execution: args.execution.into(), exposure: args.exposure.into(), - approval_default: args.approval.into(), - defer_loading_default: defer_loading_arg(args.defer_loading, args.no_defer_loading), + approval: args.approval.into(), + defer_loading: defer_loading_arg(args.defer_loading, args.no_defer_loading), allow_private_network: args.allow_private_network, auth_policy, credential: args @@ -709,8 +713,9 @@ async fn link(args: McpLinkArgs) -> Result<()> { servers: Vec::new(), }); mcp.servers.retain(|link| link.server_id != args.server_id); - mcp.servers.push(api::McpServerLink { + mcp.servers.push(api::McpServerAttachment { server_id: args.server_id.clone(), + tools: (!args.tools.is_empty()).then_some(args.tools.clone()), }); features.mcp = Some(mcp); config.features = Some(features); @@ -844,10 +849,7 @@ fn print_server(server: &api::McpServerView) { println!("serverId {}", server.server_id); println!("serverUrl {}", server.server_url); println!("label {}", server.default_server_label); - println!( - "approvalDefault {}", - approval_label(server.approval_default) - ); + println!("approval {}", approval_label(server.approval)); println!("status {}", status_label(server.status)); println!("revision {}", server.revision); print_auth_policy(&server.auth_policy); @@ -863,7 +865,7 @@ fn print_server(server: &api::McpServerView) { if let Some(allowed_tools) = &server.allowed_tools { println!("allowedTools {}", allowed_tools.join(",")); } - if let Some(defer_loading) = server.defer_loading_default { + if let Some(defer_loading) = server.defer_loading { println!("deferLoading {}", defer_loading); } } diff --git a/crates/cli/src/profile_cli.rs b/crates/cli/src/profile_cli.rs index 51581096..6e9e8768 100644 --- a/crates/cli/src/profile_cli.rs +++ b/crates/cli/src/profile_cli.rs @@ -8,8 +8,8 @@ use api::{ AgentApiErrorKind, AgentProfile, AgentProfileInput, EnvironmentLifecycleStatusView, EnvironmentProviderBindingListParams, EnvironmentProviderBindingStatusView, EnvironmentSourceView, InlineAgentProfile, ProfileApplyParams, ProfileDeleteParams, ProfileId, - ProfileListParams, ProfilePutParams, ProfileReadParams, ProfileSource, WorkspaceLink, - WorkspaceLinkAccess, WorkspaceLinkTarget, + ProfileListParams, ProfilePutParams, ProfileReadParams, ProfileSource, WorkspaceAccess, + WorkspaceAttachment, }; use clap::{Args, Subcommand}; use serde::Deserialize; @@ -92,14 +92,14 @@ struct ProvisionConfig { #[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct ProvisionValidate { - workspace_links: Option, + workspaces: Option, mcp: Option, environments: Option, } impl ProvisionValidate { - fn workspace_links(&self) -> bool { - self.workspace_links.unwrap_or(true) + fn workspaces(&self) -> bool { + self.workspaces.unwrap_or(true) } fn mcp(&self) -> bool { @@ -115,7 +115,8 @@ impl ProvisionValidate { #[serde(rename_all = "camelCase")] struct ProvisionVfs { path: PathBuf, - link_path: String, + #[serde(rename = "linkPath")] + attachment_path: String, #[serde(default)] mode: ProvisionVfsMode, #[serde(default)] @@ -270,12 +271,12 @@ pub(crate) async fn handle(args: ProfilesArgs) -> Result<()> { async fn provision_vfs(api: &HttpAgentApi, document: &mut ProfileImportDocument) -> Result<()> { validate_local_vfs(&document.provision, &document.base_dir).ensure_success()?; - let mut link_paths = BTreeSet::new(); + let mut attachment_paths = BTreeSet::new(); for entry in document.provision.vfs.clone() { - if !link_paths.insert(entry.link_path.clone()) { + if !attachment_paths.insert(entry.attachment_path.clone()) { bail!( - "duplicate provision.vfs linkPath {}; each workspace link can be provisioned once", - entry.link_path + "duplicate provision.vfs linkPath {}; each workspace attachment can be provisioned once", + entry.attachment_path ); } let source_path = resolve_local_path(&document.base_dir, &entry.path); @@ -286,21 +287,25 @@ async fn provision_vfs(api: &HttpAgentApi, document: &mut ProfileImportDocument) let workspace_id = provision_workspace_id(&document.profile, &entry); upsert_vfs_workspace(api, workspace_id.clone(), summary.snapshot_ref.clone()) .await?; - upsert_profile_link( + upsert_profile_attachment( &mut document.profile, - &entry.link_path, - WorkspaceLinkTarget::Workspace { workspace_id }, - WorkspaceLinkAccess::ReadWrite, + WorkspaceAttachment { + path: entry.attachment_path.clone(), + workspace_id: Some(workspace_id), + snapshot_ref: None, + access: WorkspaceAccess::Edit, + }, )?; } ProvisionVfsMode::Snapshot => { - upsert_profile_link( + upsert_profile_attachment( &mut document.profile, - &entry.link_path, - WorkspaceLinkTarget::Snapshot { - snapshot_ref: summary.snapshot_ref, + WorkspaceAttachment { + path: entry.attachment_path.clone(), + workspace_id: None, + snapshot_ref: Some(summary.snapshot_ref), + access: WorkspaceAccess::Read, }, - WorkspaceLinkAccess::ReadOnly, )?; } } @@ -392,8 +397,8 @@ async fn validate_import_document( ) -> ValidationReport { let mut report = ValidationReport::default(); report.extend(validate_local_vfs(&document.provision, &document.base_dir)); - if document.provision.validate.workspace_links() { - validate_workspace_links(api, document, provision_has_run, &mut report).await; + if document.provision.validate.workspaces() { + validate_workspace_attachments(api, document, provision_has_run, &mut report).await; } if document.provision.validate.mcp() { validate_mcp(api, &document.profile, &mut report).await; @@ -441,12 +446,12 @@ fn prefix_validation_report(profile_id: &str, mut report: ValidationReport) -> V fn validate_local_vfs(provision: &ProvisionConfig, base_dir: &Path) -> ValidationReport { let mut report = ValidationReport::default(); - let mut link_paths = BTreeSet::new(); + let mut attachment_paths = BTreeSet::new(); for entry in &provision.vfs { - if !link_paths.insert(entry.link_path.clone()) { + if !attachment_paths.insert(entry.attachment_path.clone()) { report.error(format!( - "duplicate provision.vfs linkPath {}; each workspace link can be provisioned once", - entry.link_path + "duplicate provision.vfs linkPath {}; each workspace attachment can be provisioned once", + entry.attachment_path )); } let path = resolve_local_path(base_dir, &entry.path); @@ -482,56 +487,60 @@ fn validate_local_vfs(provision: &ProvisionConfig, base_dir: &Path) -> Validatio report } -async fn validate_workspace_links( +async fn validate_workspace_attachments( api: &HttpAgentApi, document: &ProfileImportDocument, provision_has_run: bool, report: &mut ValidationReport, ) { - let local_links = document + let local_paths = document .provision .vfs .iter() - .map(|entry| entry.link_path.as_str()) + .map(|entry| entry.attachment_path.as_str()) .collect::>(); - let Some(links) = profile_workspace_links(&document.profile) else { + let Some(attachments) = profile_workspace_attachments(&document.profile) else { return; }; - for link in links { - if !provision_has_run && local_links.contains(link.path.as_str()) { + for attachment in attachments { + if !provision_has_run && local_paths.contains(attachment.path.as_str()) { continue; } - match &link.target { - WorkspaceLinkTarget::Snapshot { snapshot_ref } => { + match (&attachment.workspace_id, &attachment.snapshot_ref) { + (Some(workspace_id), _) => { if let Err(error) = api - .read_vfs_snapshot(api::VfsSnapshotReadParams { - snapshot_ref: snapshot_ref.clone(), + .read_vfs_workspace(api::VfsWorkspaceReadParams { + workspace_id: workspace_id.clone(), }) .await { report.error(format!( - "workspace link {} references missing snapshot {}: {}", - link.path, - snapshot_ref, + "workspace attachment {} references missing workspace {}: {}", + attachment.path, + workspace_id, api_error(error) )); } } - WorkspaceLinkTarget::Workspace { workspace_id } => { + (None, Some(snapshot_ref)) => { if let Err(error) = api - .read_vfs_workspace(api::VfsWorkspaceReadParams { - workspace_id: workspace_id.clone(), + .read_vfs_snapshot(api::VfsSnapshotReadParams { + snapshot_ref: snapshot_ref.clone(), }) .await { report.error(format!( - "workspace link {} references missing workspace {}: {}", - link.path, - workspace_id, + "workspace attachment {} references missing snapshot {}: {}", + attachment.path, + snapshot_ref, api_error(error) )); } } + (None, None) => report.error(format!( + "workspace attachment {} names neither a workspaceId nor a snapshotRef", + attachment.path + )), } } } @@ -565,60 +574,73 @@ async fn validate_mcp( } } +/// Checks every concrete environment attachment in the profile config. +/// `inherit` attachments resolve at spawn from the delegating parent, so +/// there is nothing to check for them here. async fn validate_environments( api: &HttpAgentApi, profile: &AgentProfileInput, report: &mut ValidationReport, ) { - let environment_id = match profile.document.environment.as_ref() { - None => return, - Some(api::ProfileEnvironment::Provision { - provider_id, - template_id, - credentials, - .. - }) => { - validate_provision_environment(api, provider_id, template_id, report).await; - validate_provision_credentials(api, credentials, report).await; - return; - } - // Resolved at spawn from the delegating parent; nothing to check here. - Some(api::ProfileEnvironment::Inherit {}) => return, - Some(api::ProfileEnvironment::Existing { environment_id }) => environment_id, - }; - let environment = match api - .read_environment(api::EnvironmentReadParams { - environment_id: environment_id.clone(), - }) + let environment_ids = profile + .document + .config + .as_ref() + .and_then(|config| config.features.as_ref()) + .and_then(|features| features.environments.as_ref()) + .map(|environments| environments.environments.as_slice()) + .unwrap_or_default() + .iter() + .filter(|attachment| !attachment.inherit) + .filter_map(|attachment| attachment.environment_id.clone()) + .collect::>(); + if environment_ids.is_empty() { + return; + } + let bindings = match api + .list_environment_provider_bindings(EnvironmentProviderBindingListParams::default()) .await { - Ok(response) => response.result.environment, + Ok(response) => response.result.bindings, Err(error) => { report.error(format!( - "profile references missing environment {}: {}", - environment_id, + "failed to list environment provider bindings: {}", api_error(error) )); return; } }; - let bindings = match api - .list_environment_provider_bindings(EnvironmentProviderBindingListParams::default()) + let bindings = bindings + .into_iter() + .map(|binding| (binding.binding_id.clone(), binding)) + .collect::>(); + for environment_id in environment_ids { + validate_environment_attachment(api, &environment_id, &bindings, report).await; + } +} + +async fn validate_environment_attachment( + api: &HttpAgentApi, + environment_id: &str, + bindings: &BTreeMap, + report: &mut ValidationReport, +) { + let environment = match api + .read_environment(api::EnvironmentReadParams { + environment_id: environment_id.to_owned(), + }) .await { - Ok(response) => response.result.bindings, + Ok(response) => response.result.environment, Err(error) => { report.error(format!( - "failed to list environment provider bindings: {}", + "profile references missing environment {}: {}", + environment_id, api_error(error) )); return; } }; - let bindings = bindings - .into_iter() - .map(|binding| (binding.binding_id.clone(), binding)) - .collect::>(); match environment.status { EnvironmentLifecycleStatusView::Ready => {} EnvironmentLifecycleStatusView::Closing @@ -627,7 +649,9 @@ async fn validate_environments( "profile environment {environment_id} is {:?}; applying the profile will be rejected until it points at an open environment", environment.status )), - status => report.warning(format!("profile environment is {status:?}, not ready")), + status => report.warning(format!( + "profile environment {environment_id} is {status:?}, not ready" + )), } // A long-lived box (a bot's, or one shared by several sessions) sleeps // only through its own idle policy; nothing on the profile can add one. @@ -660,162 +684,36 @@ async fn validate_environments( } } -/// Profile provision credentials must reference active grants / configured -/// providers in this universe; the applier rejects broken references at -/// session start, so surface them at validation time. -async fn validate_provision_credentials( - api: &HttpAgentApi, - credentials: &[api::ProfileEnvironmentCredential], - report: &mut ValidationReport, -) { - for credential in credentials { - match &credential.source { - api::EnvironmentCredentialSourceView::AuthGrant { grant_id } => { - match api - .read_auth_grant(api::AuthGrantReadParams { - grant_id: grant_id.clone(), - }) - .await - { - Ok(response) - if response.result.grant.status != api::AuthGrantStatus::Active => - { - report.error(format!( - "profile environment credential {} references grant {grant_id}, which is {:?}", - credential.env_name, response.result.grant.status - )); - } - Ok(_) => {} - Err(error) => report.error(format!( - "profile environment credential {} references grant {grant_id}: {}", - credential.env_name, - api_error(error) - )), - } - } - api::EnvironmentCredentialSourceView::AuthProviderCredential { provider_id } => { - match api - .read_auth_provider(api::AuthProviderReadParams { - provider_id: provider_id.clone(), - }) - .await - { - Ok(response) if !response.result.provider.has_credential => { - report.error(format!( - "profile environment credential {} references provider {provider_id}, which has no credential", - credential.env_name - )); - } - Ok(_) => {} - Err(error) => report.error(format!( - "profile environment credential {} references provider {provider_id}: {}", - credential.env_name, - api_error(error) - )), - } - } - api::EnvironmentCredentialSourceView::DirectSecret { .. } => { - // Secrets have no read method by design; the applier checks - // existence at session start. - } - } - } -} - -async fn validate_provision_environment( - api: &HttpAgentApi, - provider_id: &str, - template_id: &str, - report: &mut ValidationReport, -) { - let bindings = match api - .list_environment_provider_bindings(EnvironmentProviderBindingListParams::default()) - .await - { - Ok(response) => response.result.bindings, - Err(error) => { - report.error(format!( - "failed to list environment provider bindings: {}", - api_error(error) - )); - return; - } - }; - let Some(binding) = bindings - .iter() - .find(|binding| binding.provider_id == provider_id) - else { - report.error(format!( - "profile provisions from provider {provider_id}, but this universe has no binding for it" - )); - return; - }; - if binding.status != EnvironmentProviderBindingStatusView::Enabled { - report.warning(format!( - "profile provision binding {} for provider {provider_id} is {:?}", - binding.binding_id, binding.status - )); - } - match api - .list_environment_templates(api::EnvironmentTemplateListParams { - binding_id: Some(binding.binding_id.clone()), - }) - .await - { - Ok(response) => { - match response - .result - .templates - .iter() - .find(|template| template.template_id == template_id) - { - None => report.error(format!( - "profile provision template {template_id} is not offered by provider {provider_id}" - )), - Some(template) if template.deprecated => report.warning(format!( - "profile provision template {template_id} is deprecated" - )), - Some(_) => {} - } - } - Err(error) => report.warning(format!( - "failed to list templates for provider {provider_id}: {}", - api_error(error) - )), - } -} - -fn upsert_profile_link( +/// Inserts the attachment or repoints the existing one at the same path, +/// keeping its access except that a snapshot can never stay `edit`. +fn upsert_profile_attachment( profile: &mut AgentProfileInput, - link_path: &str, - target: WorkspaceLinkTarget, - default_access: WorkspaceLinkAccess, + attachment: WorkspaceAttachment, ) -> Result<()> { - let links = profile + let attachments = profile .document .config .as_mut() .and_then(|config| config.features.as_mut()) .and_then(|features| features.vfs.as_mut()) - .map(|vfs| &mut vfs.workspace_links) + .map(|vfs| &mut vfs.workspaces) .ok_or_else(|| anyhow!("profile provisioning requires config.features.vfs"))?; - let target_is_snapshot = matches!(target, WorkspaceLinkTarget::Snapshot { .. }); - if let Some(link) = links.iter_mut().find(|link| link.path == link_path) { - link.target = target; - if target_is_snapshot && link.access == WorkspaceLinkAccess::ReadWrite { - link.access = WorkspaceLinkAccess::ReadOnly; + if let Some(existing) = attachments + .iter_mut() + .find(|existing| existing.path == attachment.path) + { + existing.workspace_id = attachment.workspace_id; + existing.snapshot_ref = attachment.snapshot_ref; + if existing.snapshot_ref.is_some() && existing.access == WorkspaceAccess::Edit { + existing.access = WorkspaceAccess::Read; } return Ok(()); } - links.push(WorkspaceLink { - path: link_path.to_owned(), - target, - access: default_access, - }); + attachments.push(attachment); Ok(()) } -fn profile_workspace_links(profile: &AgentProfileInput) -> Option<&[WorkspaceLink]> { +fn profile_workspace_attachments(profile: &AgentProfileInput) -> Option<&[WorkspaceAttachment]> { profile .document .config @@ -824,7 +722,7 @@ fn profile_workspace_links(profile: &AgentProfileInput) -> Option<&[WorkspaceLin .as_ref()? .vfs .as_ref() - .map(|vfs| vfs.workspace_links.as_slice()) + .map(|vfs| vfs.workspaces.as_slice()) } fn provision_workspace_id(profile: &AgentProfileInput, entry: &ProvisionVfs) -> String { @@ -832,18 +730,15 @@ fn provision_workspace_id(profile: &AgentProfileInput, entry: &ProvisionVfs) -> .workspace_id .clone() .or_else(|| { - profile_workspace_links(profile) + profile_workspace_attachments(profile) .unwrap_or_default() .iter() - .find(|link| link.path == entry.link_path) - .and_then(|link| match &link.target { - WorkspaceLinkTarget::Workspace { workspace_id } => Some(workspace_id.clone()), - WorkspaceLinkTarget::Snapshot { .. } => None, - }) + .find(|attachment| attachment.path == entry.attachment_path) + .and_then(|attachment| attachment.workspace_id.clone()) }) .unwrap_or_else(|| { - let link = sanitize_id_component(&entry.link_path); - format!("profile_{}_{}", profile.profile_id.as_str(), link) + let attachment = sanitize_id_component(&entry.attachment_path); + format!("profile_{}_{}", profile.profile_id.as_str(), attachment) }) } @@ -1107,7 +1002,10 @@ mod tests { assert_eq!(batch.documents[0].profile.profile_id.as_str(), "support"); assert_eq!(batch.documents[1].profile.profile_id.as_str(), "review"); assert_eq!(batch.documents[1].provision.vfs.len(), 1); - assert_eq!(batch.documents[1].provision.vfs[0].link_path, "/workspace"); + assert_eq!( + batch.documents[1].provision.vfs[0].attachment_path, + "/workspace" + ); } #[test] @@ -1124,63 +1022,70 @@ mod tests { } #[test] - fn provisioned_workspace_link_is_inserted_when_missing() { + fn provisioned_workspace_attachment_is_inserted_when_missing() { let mut profile = AgentProfileInput { profile_id: ProfileId::new("support"), display_name: None, description: None, document: profile_document_with_vfs(Vec::new()), }; - upsert_profile_link( + upsert_profile_attachment( &mut profile, - "/workspace", - WorkspaceLinkTarget::Workspace { - workspace_id: "profile_support_workspace".to_owned(), + WorkspaceAttachment { + path: "/workspace".to_owned(), + workspace_id: Some("profile_support_workspace".to_owned()), + snapshot_ref: None, + access: WorkspaceAccess::Edit, }, - WorkspaceLinkAccess::ReadWrite, ) .unwrap(); - let links = profile_workspace_links(&profile).unwrap(); - assert_eq!(links.len(), 1); - assert_eq!(links[0].path, "/workspace"); - assert_eq!(links[0].access, WorkspaceLinkAccess::ReadWrite); + let attachments = profile_workspace_attachments(&profile).unwrap(); + assert_eq!(attachments.len(), 1); + assert_eq!(attachments[0].path, "/workspace"); + assert_eq!( + attachments[0].workspace_id.as_deref(), + Some("profile_support_workspace") + ); + assert_eq!(attachments[0].access, WorkspaceAccess::Edit); } #[test] - fn snapshot_workspace_link_forces_read_only_access() { + fn snapshot_workspace_attachment_forces_read_access() { + let snapshot_ref = format!("sha256:{}", "a".repeat(64)); let mut profile = AgentProfileInput { profile_id: ProfileId::new("support"), display_name: None, description: None, - document: profile_document_with_vfs(vec![WorkspaceLink { + document: profile_document_with_vfs(vec![WorkspaceAttachment { path: "/workspace".to_owned(), - target: WorkspaceLinkTarget::Workspace { - workspace_id: "profile_support_workspace".to_owned(), - }, - access: WorkspaceLinkAccess::ReadWrite, + workspace_id: Some("profile_support_workspace".to_owned()), + snapshot_ref: None, + access: WorkspaceAccess::Edit, }]), }; - upsert_profile_link( + upsert_profile_attachment( &mut profile, - "/workspace", - WorkspaceLinkTarget::Snapshot { - snapshot_ref: format!("sha256:{}", "a".repeat(64)), + WorkspaceAttachment { + path: "/workspace".to_owned(), + workspace_id: None, + snapshot_ref: Some(snapshot_ref.clone()), + access: WorkspaceAccess::Read, }, - WorkspaceLinkAccess::ReadOnly, ) .unwrap(); - let links = profile_workspace_links(&profile).unwrap(); - assert_eq!(links.len(), 1); - assert_eq!(links[0].access, WorkspaceLinkAccess::ReadOnly); - assert!(matches!( - links[0].target, - WorkspaceLinkTarget::Snapshot { .. } - )); + let attachments = profile_workspace_attachments(&profile).unwrap(); + assert_eq!(attachments.len(), 1); + assert_eq!(attachments[0].access, WorkspaceAccess::Read); + assert_eq!(attachments[0].workspace_id, None); + assert_eq!( + attachments[0].snapshot_ref.as_deref(), + Some(snapshot_ref.as_str()) + ); } - fn profile_document_with_vfs(workspace_links: Vec) -> api::ProfileDocument { + fn profile_document_with_vfs(workspaces: Vec) -> api::ProfileDocument { api::ProfileDocument { config: Some(api::SessionConfig { model: None, @@ -1191,8 +1096,7 @@ mod tests { vfs: Some(api::VfsFeature { working_directory: None, version: api::CURRENT_FEATURE_VERSION, - workspace_links, - tools: None, + workspaces, prompts: None, skills: None, }), diff --git a/crates/cli/src/session_cli.rs b/crates/cli/src/session_cli.rs index 68724930..d4440859 100644 --- a/crates/cli/src/session_cli.rs +++ b/crates/cli/src/session_cli.rs @@ -197,7 +197,6 @@ async fn start(args: StartArgs) -> Result<()> { metadata: args.metadata.map(), config: None, profile, - environment: None, delete_after_close_ms: args.delete_after_close_ms.map(Some), }) .await diff --git a/crates/cli/src/vfs_cli.rs b/crates/cli/src/vfs_cli.rs index 3ec01d7f..b2686a3b 100644 --- a/crates/cli/src/vfs_cli.rs +++ b/crates/cli/src/vfs_cli.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use anyhow::Result; -use clap::{Args, Subcommand}; +use clap::{Args, Subcommand, ValueEnum}; use crate::api_client::HttpAgentApi; use crate::vfs_transfer::{ @@ -201,15 +201,28 @@ struct MountPutArgs { /// Workspace id to mount. #[arg(long, conflicts_with = "snapshot")] workspace: Option, - /// Snapshot ref to mount read-only. + /// Snapshot ref to mount; snapshots are immutable and always `read`. #[arg(long, conflicts_with = "workspace")] snapshot: Option, - /// Mount read-only. - #[arg(long = "read-only", conflicts_with = "read_write")] - read_only: bool, - /// Mount read-write. Only valid for workspace mounts. - #[arg(long = "read-write", conflicts_with = "read_only")] - read_write: bool, + /// Access granted on the attachment. Defaults to `edit` for workspaces + /// and `read` for snapshots; `edit` is invalid for snapshots. + #[arg(long, value_enum)] + access: Option, +} + +#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] +enum MountAccess { + Read, + Edit, +} + +impl From for api::WorkspaceAccess { + fn from(access: MountAccess) -> Self { + match access { + MountAccess::Read => api::WorkspaceAccess::Read, + MountAccess::Edit => api::WorkspaceAccess::Edit, + } + } } #[derive(Args, Debug, Clone)] @@ -395,34 +408,35 @@ async fn mount(args: MountArgs) -> Result<()> { } async fn mount_put(args: MountPutArgs) -> Result<()> { - let source = match (args.workspace, args.snapshot) { - (Some(workspace_id), None) => api::WorkspaceLinkTarget::Workspace { workspace_id }, - (None, Some(snapshot_ref)) => api::WorkspaceLinkTarget::Snapshot { snapshot_ref }, + let (workspace_id, snapshot_ref) = match (args.workspace, args.snapshot) { + (Some(workspace_id), None) => (Some(workspace_id), None), + (None, Some(snapshot_ref)) => (None, Some(snapshot_ref)), _ => anyhow::bail!("exactly one of --workspace or --snapshot is required"), }; - let access = match (&source, args.read_only, args.read_write) { - (api::WorkspaceLinkTarget::Snapshot { .. }, false, true) => { - anyhow::bail!("snapshot workspace links cannot be read-write") - } - (api::WorkspaceLinkTarget::Snapshot { .. }, _, _) => api::WorkspaceLinkAccess::ReadOnly, - (api::WorkspaceLinkTarget::Workspace { .. }, true, false) => { - api::WorkspaceLinkAccess::ReadOnly + let access = match ( + snapshot_ref.is_some(), + args.access.map(api::WorkspaceAccess::from), + ) { + (true, Some(api::WorkspaceAccess::Edit)) => { + anyhow::bail!("snapshot attachments cannot be edit; they are always read") } - (api::WorkspaceLinkTarget::Workspace { .. }, _, _) => api::WorkspaceLinkAccess::ReadWrite, + (true, _) => api::WorkspaceAccess::Read, + (false, access) => access.unwrap_or(api::WorkspaceAccess::Edit), }; let api = HttpAgentApi::new(args.api_url); - let link = api::WorkspaceLink { + let attachment = api::WorkspaceAttachment { path: args.mount_path, - target: source, + workspace_id, + snapshot_ref, access, }; - let response = put_workspace_link(&api, args.session, link.clone()).await?; + let response = put_workspace_attachment(&api, args.session, attachment.clone()).await?; if args.json { println!("{}", serde_json::to_string_pretty(&response)?); return Ok(()); } - print_workspace_link(&link); + print_workspace_attachment(&attachment); println!("session {}", response.session.id); Ok(()) } @@ -446,11 +460,11 @@ async fn mount_delete(args: MountDeleteArgs) -> Result<()> { .as_mut() .and_then(|features| features.vfs.as_mut()) .ok_or_else(|| anyhow::anyhow!("session does not grant VFS"))?; - let before = vfs.workspace_links.len(); - vfs.workspace_links - .retain(|link| link.path != args.mount_path); - if vfs.workspace_links.len() == before { - anyhow::bail!("workspace link not found at {}", args.mount_path); + let before = vfs.workspaces.len(); + vfs.workspaces + .retain(|attachment| attachment.path != args.mount_path); + if vfs.workspaces.len() == before { + anyhow::bail!("workspace attachment not found at {}", args.mount_path); } let response = api .put_session_config(api::SessionConfigPutParams { @@ -482,19 +496,19 @@ async fn mount_list(args: MountListArgs) -> Result<()> { .map_err(crate::api_client::api_error)? .result .session; - let links = session + let attachments = session .config .and_then(|config| config.features) .and_then(|features| features.vfs) - .map(|vfs| vfs.workspace_links) + .map(|vfs| vfs.workspaces) .unwrap_or_default(); if args.json { - println!("{}", serde_json::to_string_pretty(&links)?); + println!("{}", serde_json::to_string_pretty(&attachments)?); return Ok(()); } - for link in &links { - print_workspace_link(link); + for attachment in &attachments { + print_workspace_attachment(attachment); } Ok(()) } @@ -520,23 +534,27 @@ pub(crate) async fn mount_workspace( session_id: String, mount_path: String, workspace_id: String, + access: api::WorkspaceAccess, ) -> Result { - put_workspace_link( + put_workspace_attachment( api, session_id, - api::WorkspaceLink { + api::WorkspaceAttachment { path: mount_path, - target: api::WorkspaceLinkTarget::Workspace { workspace_id }, - access: api::WorkspaceLinkAccess::ReadWrite, + workspace_id: Some(workspace_id), + snapshot_ref: None, + access, }, ) .await } -async fn put_workspace_link( +/// Workspace attachments are declarative session config: mount put/delete +/// are sugar that read-modify-put `features.vfs.workspaces`. +async fn put_workspace_attachment( api: &HttpAgentApi, session_id: String, - link: api::WorkspaceLink, + attachment: api::WorkspaceAttachment, ) -> Result { let session = api .read_session(api::SessionReadParams { @@ -555,9 +573,9 @@ async fn put_workspace_link( .as_mut() .and_then(|features| features.vfs.as_mut()) .ok_or_else(|| anyhow::anyhow!("session does not grant VFS"))?; - vfs.workspace_links - .retain(|existing| existing.path != link.path); - vfs.workspace_links.push(link); + vfs.workspaces + .retain(|existing| existing.path != attachment.path); + vfs.workspaces.push(attachment); Ok(api .put_session_config(api::SessionConfigPutParams { session_id, @@ -569,18 +587,19 @@ async fn put_workspace_link( .result) } -fn print_workspace_link(link: &api::WorkspaceLink) { - let access = match link.access { - api::WorkspaceLinkAccess::ReadOnly => "readOnly", - api::WorkspaceLinkAccess::ReadWrite => "readWrite", +fn print_workspace_attachment(attachment: &api::WorkspaceAttachment) { + let access = match attachment.access { + api::WorkspaceAccess::Read => "read", + api::WorkspaceAccess::Edit => "edit", }; - match &link.target { - api::WorkspaceLinkTarget::Snapshot { snapshot_ref } => { - println!("{} snapshot {} {access}", link.path, snapshot_ref); + match (&attachment.workspace_id, &attachment.snapshot_ref) { + (Some(workspace_id), _) => { + println!("{} workspace {workspace_id} {access}", attachment.path); } - api::WorkspaceLinkTarget::Workspace { workspace_id } => { - println!("{} workspace {} {access}", link.path, workspace_id); + (None, Some(snapshot_ref)) => { + println!("{} snapshot {snapshot_ref} {access}", attachment.path); } + (None, None) => println!("{} (no resource) {access}", attachment.path), } } diff --git a/crates/engine/src/core/admit.rs b/crates/engine/src/core/admit.rs index 57b700c7..9fe755e7 100644 --- a/crates/engine/src/core/admit.rs +++ b/crates/engine/src/core/admit.rs @@ -155,13 +155,36 @@ pub fn admit_command( "config revision exhausted".to_owned(), )) })?; - Ok(vec![CoreAgentEventProposal::new( + // The attachment list is the allowed set: an active environment + // the new document no longer attaches is cleared in the same + // batch so no batch runs against an unlisted machine. The + // pointer is never filled here; defaults apply at profile + // application only. + let clears_active = state + .environment + .active_environment_id + .as_ref() + .is_some_and(|active| { + !config + .features + .environments + .as_ref() + .is_some_and(|environments| environments.is_attached(active.as_str())) + }); + let mut proposals = vec![CoreAgentEventProposal::new( CoreAgentJoins::default(), CoreAgentEvent::Lifecycle(CoreAgentLifecycleEvent::ConfigChanged { config, revision, }), - )]) + )]; + if clears_active { + proposals.push(CoreAgentEventProposal::new( + CoreAgentJoins::default(), + CoreAgentEvent::Environment(crate::EnvironmentEvent::ActiveEnvironmentCleared), + )); + } + Ok(proposals) } CoreAgentCommand::RequestRun(request) => { // Duplicate detection precedes every other check so a retried @@ -393,9 +416,15 @@ pub fn admit_command( )); Ok(proposals) } - CoreAgentCommand::RequestRunSteering { input } => { + CoreAgentCommand::RequestRunSteering { run_id, input } => { require_open(state)?; let active_run = active_run_for_command(state)?; + if active_run.run_id != run_id { + return reject( + CommandRejectionKind::UnknownReference, + format!("steering target run {run_id} is no longer active"), + ); + } crate::core::components::context::validate_steering_input_entries(&input) .map_err(command_rejection_from_domain)?; let next_steering_id = state @@ -509,15 +538,7 @@ pub fn admit_command( format!("approval {} is already terminal", command.approval_id), ); } - let Some(active) = state.runs.active.as_ref() else { - return reject( - CommandRejectionKind::MissingActiveRun, - "approval decision requires an active run", - ); - }; - if active.run_id != command.run_id - || !matches!(active.status, RunStatus::Active | RunStatus::Parked) - { + if !matches!(active_run.status, RunStatus::Active | RunStatus::Parked) { return reject( CommandRejectionKind::ActiveWork, "approval decision does not target the accepting active run", @@ -657,23 +678,12 @@ pub fn admit_command( // A dead receiver must never leave an unresolvable pending // promise: fail every still-pending keyed completion promise of // this invocation in the same append. - if let Some(promises) = &invocation.completion_promises { - for promise_id in promises.values() { - let Some(promise) = state.promises.promises.get(promise_id) else { - continue; - }; - if promise.status.is_terminal() { - continue; - } - proposals.push(CoreAgentEventProposal::new( - CoreAgentJoins::default(), - CoreAgentEvent::Promise(PromiseEvent::Failed { - promise_id: promise_id.clone(), - error_ref: Some(error_ref.clone()), - }), - )); - } - } + fail_pending_completion_promises( + state, + invocation.completion_promises.as_ref(), + &error_ref, + &mut proposals, + ); Ok(proposals) } CoreAgentCommand::FailWorkflowToolStart { @@ -708,23 +718,12 @@ pub fn admit_command( )]; // An unstartable execution must never leave an unresolvable // pending promise. - if let Some(promises) = &invocation.completion_promises { - for promise_id in promises.values() { - let Some(promise) = state.promises.promises.get(promise_id) else { - continue; - }; - if promise.status.is_terminal() { - continue; - } - proposals.push(CoreAgentEventProposal::new( - CoreAgentJoins::default(), - CoreAgentEvent::Promise(PromiseEvent::Failed { - promise_id: promise_id.clone(), - error_ref: Some(error_ref.clone()), - }), - )); - } - } + fail_pending_completion_promises( + state, + invocation.completion_promises.as_ref(), + &error_ref, + &mut proposals, + ); Ok(proposals) } CoreAgentCommand::ForceCancelRun { run_id } => { @@ -789,17 +788,22 @@ pub fn admit_command( } CoreAgentCommand::SetActiveEnvironment { environment_id } => { require_open(state)?; - if state + let Some(environments) = state .lifecycle .config .as_ref() .and_then(|config| config.features.environments.as_ref()) - .is_none() - { + else { return reject( CommandRejectionKind::InvalidConfiguration, "active environment requires the environments feature", ); + }; + if !environments.is_attached(environment_id.as_str()) { + return reject( + CommandRejectionKind::InvalidConfiguration, + format!("environment {environment_id} is not attached to this session"), + ); } if state.environment.active_environment_id.as_ref() == Some(&environment_id) { return Ok(Vec::new()); @@ -826,6 +830,31 @@ pub fn admit_command( } } +fn fail_pending_completion_promises( + state: &CoreAgentState, + completion_promises: Option<&std::collections::BTreeMap>, + error_ref: &crate::BlobRef, + proposals: &mut Vec, +) { + if let Some(promises) = completion_promises { + for promise_id in promises.values() { + let Some(promise) = state.promises.promises.get(promise_id) else { + continue; + }; + if promise.status.is_terminal() { + continue; + } + proposals.push(CoreAgentEventProposal::new( + CoreAgentJoins::default(), + CoreAgentEvent::Promise(PromiseEvent::Failed { + promise_id: promise_id.clone(), + error_ref: Some(error_ref.clone()), + }), + )); + } + } +} + fn require_no_active_or_queued_work( state: &CoreAgentState, message: &'static str, @@ -933,3 +962,73 @@ fn unknown_reference_rejection_from_domain(error: DomainError) -> CommandError { error.to_string(), )) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + BlobRef, Promise, PromiseId, PromiseOwnership, PromiseScope, PromiseSource, PromiseStatus, + }; + use std::collections::BTreeMap; + + #[test] + fn completion_failure_preserves_key_order_and_skips_missing_or_terminal_promises() { + let mut state = CoreAgentState::new(); + for (number, status) in [ + (1, PromiseStatus::Pending), + (2, PromiseStatus::Resolved), + (3, PromiseStatus::Failed), + (4, PromiseStatus::Cancelled), + (5, PromiseStatus::Pending), + ] { + let promise_id = PromiseId::from_number(number); + state.promises.promises.insert( + promise_id.clone(), + Promise { + promise_id, + source: PromiseSource::Workflow { + producer_workflow_id: "producer".into(), + producer_workflow_kind: "test".into(), + invocation_id: "invocation".into(), + completion_key: format!("key-{number}"), + }, + scope: PromiseScope::Session, + ownership: PromiseOwnership::Runtime, + status, + payload_ref: None, + error_ref: None, + deadline_ms: None, + }, + ); + } + // Completion-key order deliberately differs from promise-ID order. + let completions = BTreeMap::from([ + ("a".into(), PromiseId::from_number(5)), + ("b".into(), PromiseId::from_number(2)), + ("c".into(), PromiseId::from_number(3)), + ("d".into(), PromiseId::from_number(4)), + ("e".into(), PromiseId::from_number(6)), + ("f".into(), PromiseId::from_number(1)), + ]); + let error_ref = BlobRef::from_bytes(b"workflow failed"); + let mut proposals = Vec::new(); + fail_pending_completion_promises(&state, None, &error_ref, &mut proposals); + assert!(proposals.is_empty()); + fail_pending_completion_promises(&state, Some(&completions), &error_ref, &mut proposals); + let failed: Vec<_> = proposals + .iter() + .map(|proposal| { + let CoreAgentEvent::Promise(PromiseEvent::Failed { + promise_id, + error_ref: actual_error, + }) = &proposal.event + else { + panic!("expected promise failure"); + }; + assert_eq!(actual_error.as_ref(), Some(&error_ref)); + promise_id.number() + }) + .collect(); + assert_eq!(failed, [5, 1]); + } +} diff --git a/crates/engine/src/core/components/command.rs b/crates/engine/src/core/components/command.rs index b58588cf..3646428c 100644 --- a/crates/engine/src/core/components/command.rs +++ b/crates/engine/src/core/components/command.rs @@ -75,6 +75,7 @@ pub enum CoreAgentCommand { CompactContext, RequestRun(RunRequestCommand), RequestRunSteering { + run_id: RunId, input: Vec, }, /// Cancel one run owned by this session. Queued runs are dequeued as @@ -122,3 +123,26 @@ pub enum CoreAgentCommand { force: bool, }, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn steering_requires_an_explicit_run_target() { + let command = CoreAgentCommand::RequestRunSteering { + run_id: RunId::new(7), + input: Vec::new(), + }; + let mut wire = serde_json::to_value(&command).unwrap(); + assert_eq!( + serde_json::from_value::(wire.clone()).unwrap(), + command + ); + wire["request_run_steering"] + .as_object_mut() + .unwrap() + .remove("run_id"); + assert!(serde_json::from_value::(wire).is_err()); + } +} diff --git a/crates/engine/src/core/components/config.rs b/crates/engine/src/core/components/config.rs index 68a81323..175350b2 100644 --- a/crates/engine/src/core/components/config.rs +++ b/crates/engine/src/core/components/config.rs @@ -151,12 +151,11 @@ impl FeaturesConfig { } } -/// Grants the session virtual filesystem. Workspace links declare the +/// Grants the session virtual filesystem. Workspace attachments declare the /// session-visible namespace and the VFS catalog is surfaced to the session. -/// The sub-blocks grant the agent tool -/// surface and prompt/skill sourcing independently — `{}` grants a VFS with -/// no tools and no sourcing. Environment sources belong to the independent -/// environment capability. +/// The agent tool surface is derived from the attachments: any attachment +/// installs the read tools and any `edit` attachment adds the write tools. +/// Prompt/skill sourcing is granted independently. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct VfsFeature { #[serde(default = "default_feature_version")] @@ -166,16 +165,7 @@ pub struct VfsFeature { pub working_directory: Option, /// Catalog resources exposed in the session's workspace namespace. #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub workspace_links: Vec, - /// Agent-facing filesystem tool surface: absent = no fs tools (a - /// sourcing-only VFS is valid); `read_only` installs the read surface; - /// `edit` adds the write tools. Per-path writability is defined and - /// enforced by each workspace link's own access — this field shapes which tools - /// exist, not path permissions. - /// With environments granted, read-only tools also expose materialize; - /// editing tools additionally expose capture into writable workspace links. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tools: Option, + pub workspaces: Vec, /// Prompt-instruction sourcing from the VFS; absent = prompts are not /// sourced from the VFS. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -190,48 +180,58 @@ impl Default for VfsFeature { fn default() -> Self { Self { version: CURRENT_FEATURE_VERSION, - workspace_links: Vec::new(), + workspaces: Vec::new(), working_directory: None, - tools: None, prompts: None, skills: None, } } } +impl VfsFeature { + /// The widest access any workspace attachment grants; `None` without + /// attachments, which installs no filesystem tools. + pub fn tool_access(&self) -> Option { + self.workspaces + .iter() + .map(|attachment| attachment.access) + .max() + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorkspaceLink { +pub struct WorkspaceAttachment { pub path: String, - pub target: WorkspaceLinkTarget, - pub access: WorkspaceLinkAccess, + pub target: WorkspaceAttachmentTarget, + pub access: WorkspaceAccess, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "type")] -pub enum WorkspaceLinkTarget { +pub enum WorkspaceAttachmentTarget { Workspace { workspace_id: String }, Snapshot { snapshot_ref: String }, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +/// Per-attachment VFS access. Ordered: `edit` implies `read`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum WorkspaceLinkAccess { - ReadOnly, - ReadWrite, +pub enum WorkspaceAccess { + Read, + Edit, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum VfsToolSurface { - ReadOnly, - Edit, +impl WorkspaceAccess { + pub fn allows_edit(self) -> bool { + self == Self::Edit + } } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct VfsPromptsConfig { /// Absent searches .agents/prompts and .lightspeed/prompts beneath each - /// workspace link. Explicit roots replace these defaults and must be - /// non-empty absolute paths contained in workspace links. + /// workspace attachment. Explicit roots replace these defaults and must be + /// non-empty absolute paths contained in workspace attachments. #[serde(default, skip_serializing_if = "Option::is_none")] pub roots: Option>, } @@ -239,8 +239,8 @@ pub struct VfsPromptsConfig { #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct VfsSkillsConfig { /// Absent searches .agents/skills and .lightspeed/skills beneath each - /// workspace link. Explicit roots replace these defaults and must be - /// non-empty absolute paths contained in workspace links. + /// workspace attachment. Explicit roots replace these defaults and must be + /// non-empty absolute paths contained in workspace attachments. #[serde(default, skip_serializing_if = "Option::is_none")] pub roots: Option>, } @@ -401,68 +401,122 @@ impl Default for TimersFeature { } } -/// Grants active session environments. Filesystem tools, commands, selection, -/// durable jobs, prompts, and skills are independent, default-off sub-grants. +/// Grants session environments. The attachment list is the allowed set: +/// the session can only select, read, or run jobs on a listed machine, and +/// each attachment carries its own access grant and working directory. The +/// installed tool surface is the union of every attachment's grant; a call +/// the active machine's grant does not cover is rejected at execution, so +/// switching machines never changes the toolset. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct EnvironmentsFeature { #[serde(default = "default_feature_version")] pub version: u32, - /// Filesystem tool surface. Absent installs no filesystem tools; sources - /// remain independent. Read-only does not restrict commands or durable jobs. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tools: Option, - /// Grants command execution and process continuation. Commands may modify - /// files even when filesystem tools are read-only or disabled. - #[serde(default)] - pub commands: bool, - /// Absolute machine working directory for file tools, commands, jobs, and sources; absent uses the endpoint default. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub working_directory: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub providers: Option>, - /// Registration keys whose registered environments the session may use; - /// absent allows every key. Independent of `providers`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub registration_keys: Option>, /// Installs model-facing list/activate/deactivate tools. Environment read /// is present whenever the environments feature is granted. #[serde(default)] - pub selection_tools: bool, - /// Installs the session's durable-job workflow binding. Actual tool - /// execution remains gated by active environment capabilities. - #[serde(default)] - pub jobs: bool, + pub selection: bool, /// Independent environment prompt loading; absent disables sourced instructions. #[serde(default, skip_serializing_if = "Option::is_none")] pub prompts: Option, /// Independent environment skill discovery. Absent disables discovery. #[serde(default, skip_serializing_if = "Option::is_none")] pub skills: Option, + /// The environments this session may use, each with its own grant. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub environments: Vec, } impl Default for EnvironmentsFeature { fn default() -> Self { Self { version: CURRENT_FEATURE_VERSION, - tools: None, - commands: false, - providers: None, - working_directory: None, + selection: false, prompts: None, - registration_keys: None, - selection_tools: false, - jobs: false, skills: None, + environments: Vec::new(), } } } -/// Agent-facing environment filesystem tools; independent of execution grants. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +impl EnvironmentsFeature { + pub fn attachment(&self, environment_id: &str) -> Option<&EnvironmentAttachment> { + self.environments + .iter() + .find(|attachment| attachment.environment_id == environment_id) + } + + pub fn is_attached(&self, environment_id: &str) -> bool { + self.attachment(environment_id).is_some() + } + + /// The attachment activated when a profile is applied and nothing is + /// active; validation admits at most one. + pub fn default_attachment(&self) -> Option<&EnvironmentAttachment> { + self.environments + .iter() + .find(|attachment| attachment.default) + } + + /// The widest grant across attachments; the installed tool surface. + pub fn tool_access(&self) -> Option { + self.environments + .iter() + .map(|attachment| attachment.access) + .max() + } +} + +/// One environment the session may use. Access and working directory are +/// properties of the pairing, not of the machine. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvironmentAttachment { + pub environment_id: String, + /// Activated when a profile is applied while nothing is active; never + /// overrides a live selection. + #[serde(default)] + pub default: bool, + pub access: EnvironmentAccess, + /// Absolute machine working directory for file tools, commands, jobs, + /// and sources; absent uses the machine's advertised default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_directory: Option, +} + +/// Per-attachment environment access. Ordered: each level implies the ones +/// before it. `exec` grants processes, which can write files regardless of +/// file-tool level, so a read-only file surface with commands is not a +/// meaningful restriction and is not expressible. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum EnvironmentToolSurface { - ReadOnly, +pub enum EnvironmentAccess { + Read, Edit, + Exec, + Jobs, +} + +impl EnvironmentAccess { + pub fn allows_edit(self) -> bool { + self >= Self::Edit + } + + pub fn allows_exec(self) -> bool { + self >= Self::Exec + } + + pub fn allows_jobs(self) -> bool { + self >= Self::Jobs + } + + /// The ladder as the model sees it, e.g. `read, edit, exec`. + pub fn describe(self) -> &'static str { + match self { + Self::Read => "read", + Self::Edit => "read, edit", + Self::Exec => "read, edit, exec", + Self::Jobs => "read, edit, exec, jobs", + } + } } /// Prompt loading scope resolved on the selected machine, never on the worker. @@ -487,17 +541,16 @@ pub struct EnvironmentSkillsConfig { pub roots: Option>, } -/// Grants remote MCP tools by declaring linked servers from the universe MCP +/// Grants remote MCP tools by declaring attached servers from the universe MCP /// catalog. Reconciliation into tool specs happens in the runtime /// materialization layer, not in the engine. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct McpFeature { #[serde(default = "default_feature_version")] pub version: u32, - /// Must be non-empty with unique server ids; omit the feature instead of - /// linking zero servers. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub servers: Vec, + /// Attached servers have unique ids. An empty list grants no MCP tools. + #[serde(default)] + pub servers: Vec, } impl Default for McpFeature { @@ -509,11 +562,16 @@ impl Default for McpFeature { } } -/// A selected universe MCP server. Its catalog record owns all connection and -/// behavior configuration. +/// A selected universe MCP server. Its catalog record owns connection, +/// execution, exposure, approval, and auth; the attachment may only narrow the +/// record's tool allowlist for this session. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct McpServerLink { +pub struct McpServerAttachment { pub server_id: String, + /// Subset of the record's allowed tools exposed to this session; absent + /// exposes the record's full allowlist. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option>, } fn default_feature_version() -> u32 { @@ -654,21 +712,21 @@ fn validate_features( ) -> Result<(), DomainError> { if let Some(vfs) = &features.vfs { validate_feature_version("vfs", vfs.version)?; - let link_paths = validate_workspace_links(&vfs.workspace_links)?; + let attachment_paths = validate_workspace_attachments(&vfs.workspaces)?; if let Some(cwd) = &vfs.working_directory && cwd != "/" { validate_source_roots( "vfs working directory", Some(std::slice::from_ref(cwd)), - &link_paths, + &attachment_paths, )?; } if let Some(prompts) = &vfs.prompts { - validate_source_roots("vfs prompts", prompts.roots.as_deref(), &link_paths)?; + validate_source_roots("vfs prompts", prompts.roots.as_deref(), &attachment_paths)?; } if let Some(skills) = &vfs.skills { - validate_source_roots("vfs skills", skills.roots.as_deref(), &link_paths)?; + validate_source_roots("vfs skills", skills.roots.as_deref(), &attachment_paths)?; } } if let Some(web) = &features.web { @@ -684,13 +742,7 @@ fn validate_features( } if let Some(environments) = &features.environments { validate_feature_version("environments", environments.version)?; - if let Some(cwd) = &environments.working_directory - && (!cwd.starts_with('/') || cwd.contains('\0')) - { - return Err(DomainError::InvariantViolation( - "environment working directory must be absolute".into(), - )); - } + validate_environment_attachments(&environments.environments)?; for roots in [ environments .skills @@ -757,10 +809,47 @@ fn validate_subagents_feature(subagents: &SubagentsFeature) -> Result<(), Domain Ok(()) } -fn validate_workspace_links(links: &[WorkspaceLink]) -> Result, DomainError> { - let mut paths = Vec::with_capacity(links.len()); - for link in links { - let path = canonical_workspace_link_path(&link.path)?; +fn validate_environment_attachments( + attachments: &[EnvironmentAttachment], +) -> Result<(), DomainError> { + let mut seen = std::collections::BTreeSet::new(); + let mut defaults = 0; + for attachment in attachments { + crate::EnvironmentId::try_new(attachment.environment_id.clone()).map_err(|error| { + DomainError::InvariantViolation(format!("invalid environment attachment: {error}")) + })?; + if !seen.insert(attachment.environment_id.as_str()) { + return Err(DomainError::InvariantViolation(format!( + "environment {} is attached more than once", + attachment.environment_id + ))); + } + if attachment.default { + defaults += 1; + } + if let Some(cwd) = &attachment.working_directory + && (!cwd.starts_with('/') || cwd.contains('\0')) + { + return Err(DomainError::InvariantViolation(format!( + "environment {} working directory must be absolute", + attachment.environment_id + ))); + } + } + if defaults > 1 { + return Err(DomainError::InvariantViolation( + "at most one environment attachment may be the default".to_owned(), + )); + } + Ok(()) +} + +fn validate_workspace_attachments( + attachments: &[WorkspaceAttachment], +) -> Result, DomainError> { + let mut paths = Vec::with_capacity(attachments.len()); + for attachment in attachments { + let path = canonical_workspace_attachment_path(&attachment.path)?; if paths.iter().any(|existing: &String| { existing == &path || existing == "/" @@ -769,26 +858,26 @@ fn validate_workspace_links(links: &[WorkspaceLink]) -> Result, Doma || path.starts_with(&format!("{existing}/")) }) { return Err(DomainError::InvariantViolation(format!( - "workspace link path {path:?} overlaps another workspace link" + "workspace attachment path {path:?} overlaps another workspace attachment" ))); } - match &link.target { - WorkspaceLinkTarget::Workspace { workspace_id } => { + match &attachment.target { + WorkspaceAttachmentTarget::Workspace { workspace_id } => { if workspace_id.trim().is_empty() { return Err(DomainError::InvariantViolation( - "workspace link workspace_id must not be empty".to_owned(), + "workspace attachment workspace_id must not be empty".to_owned(), )); } } - WorkspaceLinkTarget::Snapshot { snapshot_ref } => { + WorkspaceAttachmentTarget::Snapshot { snapshot_ref } => { if snapshot_ref.trim().is_empty() { return Err(DomainError::InvariantViolation( - "workspace link snapshot_ref must not be empty".to_owned(), + "workspace attachment snapshot_ref must not be empty".to_owned(), )); } - if link.access != WorkspaceLinkAccess::ReadOnly { + if attachment.access != WorkspaceAccess::Read { return Err(DomainError::InvariantViolation(format!( - "snapshot workspace link at {path:?} must be read_only" + "snapshot workspace attachment at {path:?} must be read" ))); } } @@ -798,15 +887,15 @@ fn validate_workspace_links(links: &[WorkspaceLink]) -> Result, Doma Ok(paths) } -fn canonical_workspace_link_path(path: &str) -> Result { +fn canonical_workspace_attachment_path(path: &str) -> Result { if path.is_empty() || !path.starts_with('/') { return Err(DomainError::InvariantViolation(format!( - "workspace link path {path:?} must be absolute" + "workspace attachment path {path:?} must be absolute" ))); } if path.len() > 1 && path.ends_with('/') { return Err(DomainError::InvariantViolation(format!( - "workspace link path {path:?} must be canonical" + "workspace attachment path {path:?} must be canonical" ))); } if path @@ -815,7 +904,7 @@ fn canonical_workspace_link_path(path: &str) -> Result { .any(|part| part.is_empty() || part == "." || part == ".." || part.contains('\0')) { return Err(DomainError::InvariantViolation(format!( - "workspace link path {path:?} must be canonical" + "workspace attachment path {path:?} must be canonical" ))); } Ok(path.to_owned()) @@ -835,7 +924,7 @@ fn validate_feature_version(feature: &str, version: u32) -> Result<(), DomainErr fn validate_source_roots( feature: &str, roots: Option<&[String]>, - link_paths: &[String], + attachment_paths: &[String], ) -> Result<(), DomainError> { let Some(roots) = roots else { return Ok(()); @@ -848,17 +937,19 @@ fn validate_source_roots( } let mut seen = std::collections::BTreeSet::new(); for root in roots { - let root = canonical_workspace_link_path(root)?; + let root = canonical_workspace_attachment_path(root)?; if !seen.insert(root.clone()) { return Err(DomainError::InvariantViolation(format!( "{feature} root {root:?} is declared more than once" ))); } - if !link_paths.iter().any(|link_path| { - root == *link_path || link_path == "/" || root.starts_with(&format!("{link_path}/")) + if !attachment_paths.iter().any(|attachment_path| { + root == *attachment_path + || attachment_path == "/" + || root.starts_with(&format!("{attachment_path}/")) }) { return Err(DomainError::InvariantViolation(format!( - "{feature} root {root:?} is not under a workspace link" + "{feature} root {root:?} is not under a workspace attachment" ))); } } @@ -917,24 +1008,42 @@ fn validate_web_feature(web: &WebFeature, api_kind: &ProviderApiKind) -> Result< } fn validate_mcp_feature(mcp: &McpFeature) -> Result<(), DomainError> { - if mcp.servers.is_empty() { - return Err(DomainError::InvariantViolation( - "mcp feature links zero servers; omit the feature instead".to_owned(), - )); - } let mut seen = std::collections::BTreeSet::new(); - for link in &mcp.servers { - if link.server_id.trim().is_empty() { + for attachment in &mcp.servers { + if attachment.server_id.trim().is_empty() { return Err(DomainError::InvariantViolation( - "mcp server link requires a non-empty server_id".to_owned(), + "mcp server attachment requires a non-empty server_id".to_owned(), )); } - if !seen.insert(link.server_id.as_str()) { + if !seen.insert(attachment.server_id.as_str()) { return Err(DomainError::InvariantViolation(format!( - "mcp server {} is linked more than once", - link.server_id + "mcp server {} is attached more than once", + attachment.server_id ))); } + if let Some(tools) = &attachment.tools { + if tools.is_empty() { + return Err(DomainError::InvariantViolation(format!( + "mcp server {} tools subset must be non-empty; omit it to expose the record's allowlist", + attachment.server_id + ))); + } + let mut names = std::collections::BTreeSet::new(); + for tool in tools { + if tool.trim().is_empty() { + return Err(DomainError::InvariantViolation(format!( + "mcp server {} tools subset contains an empty tool name", + attachment.server_id + ))); + } + if !names.insert(tool.as_str()) { + return Err(DomainError::InvariantViolation(format!( + "mcp server {} tools subset lists {tool} twice", + attachment.server_id + ))); + } + } + } } Ok(()) } @@ -1291,11 +1400,16 @@ mod tests { fn domain_working_directories_and_source_overrides_validate_independently() { let mut config = config(ProviderApiKind::OpenAiResponses, None); config.features.environments = Some(EnvironmentsFeature { - working_directory: Some("/project".into()), prompts: Some(Default::default()), skills: Some(EnvironmentSkillsConfig { roots: Some(vec!["./custom".into()]), }), + environments: vec![EnvironmentAttachment { + environment_id: "env_a".into(), + default: true, + access: EnvironmentAccess::Exec, + working_directory: Some("/project".into()), + }], ..Default::default() }); config.features.vfs = Some(VfsFeature { @@ -1303,30 +1417,21 @@ mod tests { ..Default::default() }); config.validate().unwrap(); - config - .features - .environments - .as_mut() - .unwrap() - .working_directory = Some("relative".into()); + config.features.environments.as_mut().unwrap().environments[0].working_directory = + Some("relative".into()); assert!(config.validate().is_err()); - config - .features - .environments - .as_mut() - .unwrap() - .working_directory = None; + config.features.environments.as_mut().unwrap().environments[0].working_directory = None; config.features.environments.as_mut().unwrap().prompts = Some(EnvironmentPromptsConfig { roots: Some(vec![]), }); assert!(config.validate().is_err()); config.features.environments.as_mut().unwrap().prompts = None; - config.features.vfs.as_mut().unwrap().working_directory = Some("/unlinked".into()); + config.features.vfs.as_mut().unwrap().working_directory = Some("/unattached".into()); assert!(config.validate().is_err()); } #[test] - fn empty_vfs_source_blocks_enable_defaults_without_links() { + fn empty_vfs_source_blocks_enable_defaults_without_attachments() { let mut config = config(ProviderApiKind::OpenAiResponses, None); config.features.vfs = Some(VfsFeature { skills: Some(VfsSkillsConfig::default()), @@ -1361,20 +1466,19 @@ mod tests { } #[test] - fn vfs_skills_require_linked_roots_and_are_independent_of_environment_skills() { + fn vfs_skills_require_attached_roots_and_are_independent_of_environment_skills() { let mut config = config(ProviderApiKind::OpenAiResponses, None); config.features.environments = Some(EnvironmentsFeature { skills: Some(EnvironmentSkillsConfig::default()), ..Default::default() }); config.features.vfs = Some(VfsFeature { - tools: Some(VfsToolSurface::ReadOnly), - workspace_links: vec![WorkspaceLink { + workspaces: vec![WorkspaceAttachment { path: "/workspace".into(), - target: WorkspaceLinkTarget::Workspace { + target: WorkspaceAttachmentTarget::Workspace { workspace_id: "project".into(), }, - access: WorkspaceLinkAccess::ReadOnly, + access: WorkspaceAccess::Read, }], ..Default::default() }); @@ -1408,31 +1512,33 @@ mod tests { } #[test] - fn workspace_links_validate_topology_access_and_explicit_roots() { - let workspace = WorkspaceLink { + fn workspace_attachments_validate_topology_access_and_explicit_roots() { + let workspace = WorkspaceAttachment { path: "/workspace".to_owned(), - target: WorkspaceLinkTarget::Workspace { + target: WorkspaceAttachmentTarget::Workspace { workspace_id: "workspace_1".to_owned(), }, - access: WorkspaceLinkAccess::ReadWrite, + access: WorkspaceAccess::Edit, }; let mut config = config(ProviderApiKind::OpenAiResponses, None); config.features.vfs = Some(VfsFeature { - workspace_links: vec![workspace.clone()], + workspaces: vec![workspace.clone()], prompts: Some(VfsPromptsConfig { roots: Some(vec!["/workspace/.agents/prompts".to_owned()]), }), ..VfsFeature::default() }); - config.validate().expect("valid workspace link topology"); + config + .validate() + .expect("valid workspace attachment topology"); config .features .vfs .as_mut() .unwrap() - .workspace_links - .push(WorkspaceLink { + .workspaces + .push(WorkspaceAttachment { path: "/workspace/nested".to_owned(), ..workspace.clone() }); @@ -1441,19 +1547,19 @@ mod tests { Err(DomainError::InvariantViolation(_)) )); - config.features.vfs.as_mut().unwrap().workspace_links = vec![WorkspaceLink { + config.features.vfs.as_mut().unwrap().workspaces = vec![WorkspaceAttachment { path: "/skills".to_owned(), - target: WorkspaceLinkTarget::Snapshot { + target: WorkspaceAttachmentTarget::Snapshot { snapshot_ref: format!("sha256:{}", "a".repeat(64)), }, - access: WorkspaceLinkAccess::ReadWrite, + access: WorkspaceAccess::Edit, }]; assert!(matches!( config.validate(), Err(DomainError::InvariantViolation(_)) )); - config.features.vfs.as_mut().unwrap().workspace_links = vec![workspace]; + config.features.vfs.as_mut().unwrap().workspaces = vec![workspace]; config.features.vfs.as_mut().unwrap().prompts = Some(VfsPromptsConfig { roots: Some(vec!["/outside/prompts".to_owned()]), }); @@ -1476,26 +1582,213 @@ mod tests { } #[test] - fn mcp_feature_requires_unique_nonempty_servers() { + fn mcp_feature_requires_unique_nonempty_server_ids() { let mut config = config(ProviderApiKind::OpenAiResponses, None); config.features.mcp = Some(McpFeature::default()); - let error = config + config .validate() - .expect_err("zero linked servers must fail"); - assert!(matches!(error, DomainError::InvariantViolation(_))); + .expect("an empty attachment list is valid"); - let link = McpServerLink { + let attachment = McpServerAttachment { server_id: "linear".to_owned(), + tools: None, }; - let mut duplicated = config; + let mut duplicated = config.clone(); duplicated.features.mcp = Some(McpFeature { - servers: vec![link.clone(), link], + servers: vec![attachment.clone(), attachment.clone()], ..McpFeature::default() }); let error = duplicated .validate() - .expect_err("duplicate server links must fail"); + .expect_err("duplicate server attachments must fail"); assert!(matches!(error, DomainError::InvariantViolation(_))); + + let mut blank = config.clone(); + blank + .features + .mcp + .as_mut() + .unwrap() + .servers + .push(McpServerAttachment { + server_id: " ".to_owned(), + tools: None, + }); + assert!(matches!( + blank.validate(), + Err(DomainError::InvariantViolation(_)) + )); + + for tools in [vec![], vec![""], vec!["search", "search"]] { + let mut narrowed = config.clone(); + narrowed.features.mcp = Some(McpFeature { + servers: vec![McpServerAttachment { + tools: Some(tools.into_iter().map(String::from).collect()), + ..attachment.clone() + }], + ..McpFeature::default() + }); + assert!(matches!( + narrowed.validate(), + Err(DomainError::InvariantViolation(_)) + )); + } + let mut narrowed = config; + narrowed.features.mcp = Some(McpFeature { + servers: vec![McpServerAttachment { + tools: Some(vec!["search".to_owned()]), + ..attachment + }], + ..McpFeature::default() + }); + narrowed + .validate() + .expect("a non-empty unique subset is valid"); + } + + #[test] + fn mcp_empty_attachments_survive_lifecycle_replay() { + use crate::core::components::lifecycle::{Event, apply_event}; + + let mut empty = config(ProviderApiKind::OpenAiResponses, None); + empty.features.mcp = Some(McpFeature::default()); + let mut attached = empty.clone(); + attached + .features + .mcp + .as_mut() + .unwrap() + .servers + .push(McpServerAttachment { + server_id: "catalog".to_owned(), + tools: None, + }); + let events = [ + Event::Opened { + config: empty.clone(), + }, + Event::ConfigChanged { + config: attached, + revision: 1, + }, + Event::ConfigChanged { + config: empty.clone(), + revision: 2, + }, + ]; + let mut original = CoreAgentState::new(); + let mut replayed = CoreAgentState::new(); + for event in events { + apply_event(&mut original, &event).expect("apply lifecycle event"); + let bytes = serde_json::to_vec(&event).expect("encode event"); + let decoded: Event = serde_json::from_slice(&bytes).expect("decode event"); + apply_event(&mut replayed, &decoded).expect("replay lifecycle event"); + } + assert_eq!(original, replayed); + assert_eq!(replayed.lifecycle.config, Some(empty.clone())); + assert_eq!(replayed.lifecycle.config_revision, 2); + assert_eq!( + serde_json::to_value(empty).unwrap()["features"]["mcp"]["servers"], + serde_json::json!([]) + ); + } + + #[test] + fn environment_attachments_validate_identity_default_and_working_directory() { + fn attachment(id: &str) -> EnvironmentAttachment { + EnvironmentAttachment { + environment_id: id.to_owned(), + default: false, + access: EnvironmentAccess::Read, + working_directory: None, + } + } + let mut config = config(ProviderApiKind::OpenAiResponses, None); + config.features.environments = Some(EnvironmentsFeature { + environments: vec![ + EnvironmentAttachment { + default: true, + access: EnvironmentAccess::Jobs, + working_directory: Some("/srv/app".to_owned()), + ..attachment("env_a") + }, + attachment("env_b"), + ], + ..EnvironmentsFeature::default() + }); + config.validate().expect("two attachments with one default"); + let feature = config.features.environments.as_ref().unwrap(); + assert_eq!( + feature + .default_attachment() + .map(|a| a.environment_id.as_str()), + Some("env_a") + ); + assert_eq!(feature.tool_access(), Some(EnvironmentAccess::Jobs)); + assert!(feature.is_attached("env_b")); + assert!(!feature.is_attached("env_c")); + + let invalid = [ + vec![attachment("env_a"), attachment("env_a")], + vec![ + EnvironmentAttachment { + default: true, + ..attachment("env_a") + }, + EnvironmentAttachment { + default: true, + ..attachment("env_b") + }, + ], + vec![attachment(" ")], + vec![attachment("env/bad")], + vec![attachment("env bad")], + vec![attachment("-invalid-start")], + vec![attachment(&"a".repeat(129))], + vec![EnvironmentAttachment { + working_directory: Some("relative".to_owned()), + ..attachment("env_a") + }], + ]; + for environments in invalid { + config.features.environments = Some(EnvironmentsFeature { + environments, + ..EnvironmentsFeature::default() + }); + assert!(matches!( + config.validate(), + Err(DomainError::InvariantViolation(_)) + )); + } + + config.features.environments = Some(EnvironmentsFeature::default()); + config + .validate() + .expect("an environments grant without attachments is valid"); + assert_eq!( + config.features.environments.as_ref().unwrap().tool_access(), + None + ); + } + + #[test] + fn access_ladders_are_ordered_and_implied() { + assert!(WorkspaceAccess::Edit > WorkspaceAccess::Read); + assert!(WorkspaceAccess::Edit.allows_edit()); + assert!(!WorkspaceAccess::Read.allows_edit()); + assert!(EnvironmentAccess::Jobs > EnvironmentAccess::Exec); + assert!(EnvironmentAccess::Exec > EnvironmentAccess::Edit); + assert!(EnvironmentAccess::Edit > EnvironmentAccess::Read); + assert!(EnvironmentAccess::Exec.allows_edit()); + assert!(EnvironmentAccess::Exec.allows_exec()); + assert!(!EnvironmentAccess::Exec.allows_jobs()); + assert!(EnvironmentAccess::Jobs.allows_jobs()); + assert!(!EnvironmentAccess::Read.allows_edit()); + assert_eq!(EnvironmentAccess::Exec.describe(), "read, edit, exec"); + assert_eq!( + serde_json::to_value(EnvironmentAccess::Jobs).unwrap(), + serde_json::json!("jobs") + ); } #[test] @@ -1513,7 +1806,8 @@ mod tests { let feature: VfsFeature = serde_json::from_value(serde_json::json!({})) .expect("empty vfs grant decodes with defaults"); assert_eq!(feature.version, CURRENT_FEATURE_VERSION); - assert_eq!(feature.tools, None); + assert!(feature.workspaces.is_empty()); + assert_eq!(feature.tool_access(), None); let value = serde_json::to_value(&feature).expect("serialize"); assert_eq!( @@ -1523,29 +1817,47 @@ mod tests { } #[test] - fn environment_tool_subgrants_are_default_off() { + fn environment_grant_is_default_off_with_no_attachments() { let feature: EnvironmentsFeature = serde_json::from_value(serde_json::json!({})) .expect("empty environment grant decodes with defaults"); - assert!(!feature.selection_tools); - assert!(!feature.jobs); + assert!(!feature.selection); + assert!(feature.environments.is_empty()); assert_eq!( serde_json::to_value(feature).expect("serialize"), serde_json::json!({ "version": CURRENT_FEATURE_VERSION, - "selection_tools": false, - "commands": false, - "jobs": false, + "selection": false, }) ); } #[test] - fn vfs_tool_surface_grant_decodes() { - let feature: VfsFeature = serde_json::from_value(serde_json::json!({ "tools": "edit" })) - .expect("vfs tool surface grant decodes"); - - assert_eq!(feature.tools, Some(VfsToolSurface::Edit)); + fn vfs_tool_access_is_the_widest_attachment_grant() { + let read = WorkspaceAttachment { + path: "/ref".to_owned(), + target: WorkspaceAttachmentTarget::Workspace { + workspace_id: "ref".to_owned(), + }, + access: WorkspaceAccess::Read, + }; + let edit = WorkspaceAttachment { + path: "/workspace".to_owned(), + target: WorkspaceAttachmentTarget::Workspace { + workspace_id: "app".to_owned(), + }, + access: WorkspaceAccess::Edit, + }; + let feature = VfsFeature { + workspaces: vec![read.clone()], + ..VfsFeature::default() + }; + assert_eq!(feature.tool_access(), Some(WorkspaceAccess::Read)); + let feature = VfsFeature { + workspaces: vec![read, edit], + ..VfsFeature::default() + }; + assert_eq!(feature.tool_access(), Some(WorkspaceAccess::Edit)); } #[test] @@ -1553,7 +1865,6 @@ mod tests { let mut config = config(ProviderApiKind::OpenAiResponses, None); config.generation.reasoning_effort = Some("high".to_owned()); config.features.vfs = Some(VfsFeature { - tools: Some(VfsToolSurface::Edit), prompts: Some(VfsPromptsConfig::default()), ..VfsFeature::default() }); diff --git a/crates/engine/src/core/components/ids.rs b/crates/engine/src/core/components/ids.rs index 31f973dd..0d88759a 100644 --- a/crates/engine/src/core/components/ids.rs +++ b/crates/engine/src/core/components/ids.rs @@ -1,90 +1,11 @@ -use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use crate::string_id::string_id; +use serde::{Deserialize, Serialize}; use std::fmt; -use std::str::FromStr; pub use crate::session::{ CorrelationId, EventSeq, SessionId, StringIdError, validate_general_string_id, }; -macro_rules! string_id { - ($name:ident, $validator:ident) => { - #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] - #[cfg_attr(feature = "contract", derive(schemars::JsonSchema))] - pub struct $name(String); - - impl $name { - pub fn new(value: impl Into) -> Self { - let value = value.into(); - Self::try_new(value) - .unwrap_or_else(|error| panic!("invalid {}: {error}", stringify!($name))) - } - - pub fn try_new(value: impl Into) -> Result { - let value = value.into(); - $validator(stringify!($name), &value)?; - Ok(Self(value)) - } - - pub fn parse(value: impl Into) -> Result { - Self::try_new(value) - } - - pub fn as_str(&self) -> &str { - &self.0 - } - } - - impl TryFrom for $name { - type Error = StringIdError; - - fn try_from(value: String) -> Result { - Self::try_new(value) - } - } - - impl TryFrom<&str> for $name { - type Error = StringIdError; - - fn try_from(value: &str) -> Result { - Self::try_new(value) - } - } - - impl FromStr for $name { - type Err = StringIdError; - - fn from_str(value: &str) -> Result { - Self::try_new(value) - } - } - - impl Serialize for $name { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&self.0) - } - } - - impl<'de> Deserialize<'de> for $name { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::try_new(value).map_err(de::Error::custom) - } - } - - impl fmt::Display for $name { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } - } - }; -} - const TOOL_NAME_MAX_LEN: usize = 64; macro_rules! numeric_id { diff --git a/crates/engine/src/core/drive.rs b/crates/engine/src/core/drive.rs index dcfaa4fb..209b7423 100644 --- a/crates/engine/src/core/drive.rs +++ b/crates/engine/src/core/drive.rs @@ -884,20 +884,19 @@ pub fn next_tool_batch_request( .as_ref() .and_then(|config| config.features.vfs.as_ref()) .and_then(|vfs| vfs.working_directory.clone()), - workspace_links: state + workspace_attachments: state .lifecycle .config .as_ref() .and_then(|config| config.features.vfs.as_ref()) - .map(|vfs| vfs.workspace_links.clone()) + .map(|vfs| vfs.workspaces.clone()) .unwrap_or_default(), active_environment_id: state.environment.active_environment_id.clone(), environment_policy: state .lifecycle .config .as_ref() - .and_then(|config| config.features.environments.as_ref()) - .map(crate::EnvironmentPolicyRuntime::from_feature), + .and_then(|config| config.features.environments.clone()), subagents_policy: state .lifecycle .config @@ -2194,7 +2193,13 @@ mod tests { let mut input = user_input(BlobRef::from_bytes(b"automated steering")); input[0].origin = Some("event".into()); let action = drive - .admit_command(CoreAgentCommand::RequestRunSteering { input }, 23) + .admit_command( + CoreAgentCommand::RequestRunSteering { + run_id: crate::RunId::new(1), + input, + }, + 23, + ) .unwrap(); log.extend(commit_action(&mut drive, action)); let action = drive.next_action(24, 64).unwrap(); @@ -2528,11 +2533,17 @@ mod tests { let session_id = SessionId::new("session-environment-runtime"); let mut drive = CoreAgentDrive::from_replayed(session_id, CoreAgentState::new(), None); let mut session_config = config(); - session_config.features.environments = Some(crate::EnvironmentsFeature { - providers: Some(vec!["provider-a".to_owned(), "provider-b".to_owned()]), - selection_tools: true, + let environments = crate::EnvironmentsFeature { + selection: true, + environments: vec![crate::EnvironmentAttachment { + environment_id: "environment-a".to_owned(), + default: false, + access: crate::EnvironmentAccess::Exec, + working_directory: Some("/srv".to_owned()), + }], ..crate::EnvironmentsFeature::default() - }); + }; + session_config.features.environments = Some(environments.clone()); session_config.features.subagents = Some(test_subagents_feature()); open_session_with_config(&mut drive, session_config); let set_active = drive @@ -2553,14 +2564,90 @@ mod tests { request.active_environment_id, Some(crate::EnvironmentId::new("environment-a")) ); + assert_eq!(request.environment_policy, Some(environments)); + assert_eq!(request.subagents_policy, Some(test_subagents_feature())); + } + + #[test] + fn config_replace_clears_an_active_environment_that_is_no_longer_attached() { + let session_id = SessionId::new("session-environment-detach"); + let mut drive = CoreAgentDrive::from_replayed(session_id, CoreAgentState::new(), None); + let attachment = |id: &str| crate::EnvironmentAttachment { + environment_id: id.to_owned(), + default: false, + access: crate::EnvironmentAccess::Read, + working_directory: None, + }; + let mut session_config = config(); + session_config.features.environments = Some(crate::EnvironmentsFeature { + environments: vec![attachment("environment-a"), attachment("environment-b")], + ..crate::EnvironmentsFeature::default() + }); + open_session_with_config(&mut drive, session_config.clone()); + + let unlisted = drive.admit_command( + CoreAgentCommand::SetActiveEnvironment { + environment_id: crate::EnvironmentId::new("environment-c"), + }, + 10, + ); + assert!( + unlisted.is_err(), + "an unattached environment cannot be activated" + ); + let set_active = drive + .admit_command( + CoreAgentCommand::SetActiveEnvironment { + environment_id: crate::EnvironmentId::new("environment-a"), + }, + 11, + ) + .expect("set active environment"); + commit_action(&mut drive, set_active); + + // Dropping the default only: the live selection is untouched. + let mut narrowed = session_config.clone(); + narrowed + .features + .environments + .as_mut() + .unwrap() + .environments[1] + .default = true; + let replace = drive + .admit_command( + CoreAgentCommand::ReplaceSessionConfig { + expected_revision: None, + config: narrowed.clone(), + }, + 12, + ) + .expect("replace config keeping the active attachment"); + commit_action(&mut drive, replace); assert_eq!( - request.environment_policy, - Some(crate::EnvironmentPolicyRuntime::new( - Some(vec!["provider-a".to_owned(), "provider-b".to_owned()]), - None, - )) + drive.state().environment.active_environment_id, + Some(crate::EnvironmentId::new("environment-a")) ); - assert_eq!(request.subagents_policy, Some(test_subagents_feature())); + + // Removing the active attachment clears the pointer in the same batch. + narrowed + .features + .environments + .as_mut() + .unwrap() + .environments + .remove(0); + let replace = drive + .admit_command( + CoreAgentCommand::ReplaceSessionConfig { + expected_revision: None, + config: narrowed, + }, + 13, + ) + .expect("replace config dropping the active attachment"); + commit_action(&mut drive, replace); + assert_eq!(drive.state().environment.active_environment_id, None); } fn test_subagents_feature() -> crate::SubagentsFeature { @@ -4587,6 +4674,64 @@ mod tests { /// until that turn completes (its request is frozen at the planned /// context revision and the runtime re-derives it from state); it then /// lands before the next turn, in admission order. + #[test] + fn steering_rejects_a_previous_run_target_and_replays_the_matching_target() { + let mut drive = CoreAgentDrive::from_replayed( + SessionId::new("steering-target"), + CoreAgentState::new(), + None, + ); + open_session(&mut drive); + request_run(&mut drive, BlobRef::from_bytes(b"first")); + let first = drive_until_generate(&mut drive).run_id; + let cancel = drive + .admit_command(CoreAgentCommand::ForceCancelRun { run_id: first }, 40) + .unwrap(); + commit_action(&mut drive, cancel); + request_run(&mut drive, BlobRef::from_bytes(b"second")); + let second = drive_until_generate(&mut drive).run_id; + assert_ne!(first, second); + let checkpoint = drive.state().clone(); + let error = drive + .admit_command( + CoreAgentCommand::RequestRunSteering { + run_id: first, + input: user_input(BlobRef::from_bytes(b"stale")), + }, + 50, + ) + .unwrap_err(); + assert!( + matches!(error, CoreAgentDriveError::Command(CommandError::Rejected(rejection)) + if rejection.kind == crate::CommandRejectionKind::UnknownReference) + ); + assert_eq!(drive.state(), &checkpoint); + let valid = drive + .admit_command( + CoreAgentCommand::RequestRunSteering { + run_id: second, + input: user_input(BlobRef::from_bytes(b"current")), + }, + 51, + ) + .unwrap(); + let entries = commit_action(&mut drive, valid); + let mut replayed = checkpoint; + for entry in entries { + let stored = CoreAgentCodec.encode_entry(&entry).unwrap(); + crate::apply_event( + &mut replayed, + &CoreAgentCodec.decode_entry(&stored).unwrap(), + ) + .unwrap(); + } + assert_eq!(&replayed, drive.state()); + assert_eq!( + drive.state().runs.active.as_ref().unwrap().steering.len(), + 1 + ); + } + #[test] fn steering_materializes_after_in_flight_turn_completes() { let session_id = SessionId::new("session-a"); @@ -4600,6 +4745,7 @@ mod tests { let steering_one = drive .admit_command( CoreAgentCommand::RequestRunSteering { + run_id: crate::RunId::new(1), input: user_input(BlobRef::from_bytes(b"steering one")), }, 30, @@ -4609,6 +4755,7 @@ mod tests { let steering_two = drive .admit_command( CoreAgentCommand::RequestRunSteering { + run_id: crate::RunId::new(1), input: user_input(BlobRef::from_bytes(b"steering two")), }, 31, @@ -4688,6 +4835,7 @@ mod tests { let steering = drive .admit_command( CoreAgentCommand::RequestRunSteering { + run_id: crate::RunId::new(1), input: user_input(BlobRef::from_bytes(b"steer while parked")), }, 91, @@ -4746,6 +4894,7 @@ mod tests { let error = drive .admit_command( CoreAgentCommand::RequestRunSteering { + run_id: crate::RunId::new(1), input: user_input(BlobRef::from_bytes(b"too late")), }, 31, @@ -4958,6 +5107,7 @@ mod tests { let steering = drive .admit_command( CoreAgentCommand::RequestRunSteering { + run_id: crate::RunId::new(1), input: user_input(BlobRef::from_bytes(b"late steering")), }, 30, @@ -7720,7 +7870,7 @@ mod tests { turn_id: TurnId::new(1), batch_id: ToolBatchId::new(1), promise_id_base: 1, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), active_environment_id: None, environment_policy: None, subagents_policy: None, @@ -8074,7 +8224,7 @@ mod tests { let mut drive = CoreAgentDrive::from_replayed(session_id, CoreAgentState::new(), None); let mut session_config = config(); session_config.features.environments = Some(crate::EnvironmentsFeature { - selection_tools: true, + selection: true, ..crate::EnvironmentsFeature::default() }); let request = two_call_tool_batch(&mut drive, session_config); diff --git a/crates/engine/src/core/io.rs b/crates/engine/src/core/io.rs index 2d3056df..5a66f4b8 100644 --- a/crates/engine/src/core/io.rs +++ b/crates/engine/src/core/io.rs @@ -22,7 +22,7 @@ use crate::{ ContextEntryKind, EnvironmentId, LlmGenerationFacts, LlmGenerationStatus, LlmRequest, PromiseId, PromiseOwnership, PromiseScope, PromiseStatus, RunId, SessionId, ToolBatchId, ToolCallId, ToolCallStatus, ToolExecutionSpec, ToolName, TurnId, WorkflowToolBinding, - WorkspaceLink, + WorkspaceAttachment, }; #[async_trait] @@ -98,12 +98,15 @@ pub struct ToolInvocationBatchRequest { pub turn_id: TurnId, pub batch_id: ToolBatchId, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub workspace_links: Vec, + pub workspace_attachments: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub vfs_working_directory: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub active_environment_id: Option, - pub environment_policy: Option, + /// The admitted environments grant for this tool batch: the allowed + /// attachments and their access. Executors look up the active + /// attachment for execution-time denial and working directory. + pub environment_policy: Option, /// Admitted sub-agent grant for this tool batch. Runtime executors pin /// it into sub-agent invocations instead of reconstructing the owning /// session. @@ -117,53 +120,6 @@ pub struct ToolInvocationBatchRequest { pub calls: Vec, } -/// Admitted session policy needed while resolving live environment resources. -/// -/// This is transient runtime input recorded on the activity request, not a -/// durable session document. Environment records and provider observations -/// remain live resolver reads. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct EnvironmentPolicyRuntime { - pub version: u32, - #[serde(default)] - pub tools: Option, - #[serde(default)] - pub commands: bool, - pub allowed_provider_ids: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub working_directory: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub allowed_registration_key_ids: Option>, -} - -impl EnvironmentPolicyRuntime { - pub const VERSION: u32 = 2; - - pub fn new( - allowed_provider_ids: Option>, - allowed_registration_key_ids: Option>, - ) -> Self { - Self { - version: Self::VERSION, - tools: None, - commands: false, - allowed_provider_ids, - working_directory: None, - allowed_registration_key_ids, - } - } - - /// Lower the session's environments grant into the runtime policy. - pub fn from_feature(feature: &crate::EnvironmentsFeature) -> Self { - Self { - working_directory: feature.working_directory.clone(), - tools: feature.tools, - commands: feature.commands, - ..Self::new(feature.providers.clone(), feature.registration_keys.clone()) - } - } -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ToolInvocationRequest { pub call_id: ToolCallId, @@ -354,12 +310,12 @@ pub struct ToolInvocationCallRequest { pub turn_id: TurnId, pub batch_id: ToolBatchId, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub workspace_links: Vec, + pub workspace_attachments: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub vfs_working_directory: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub active_environment_id: Option, - pub environment_policy: Option, + pub environment_policy: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub subagents_policy: Option, /// The one promise id this call may mint: the batch base plus the @@ -391,7 +347,7 @@ impl ToolInvocationCallRequest { run_id: self.run_id, turn_id: self.turn_id, batch_id: self.batch_id, - workspace_links: self.workspace_links, + workspace_attachments: self.workspace_attachments, vfs_working_directory: self.vfs_working_directory, active_environment_id: self.active_environment_id, environment_policy: self.environment_policy, @@ -428,7 +384,7 @@ impl ToolInvocationBatchRequest { run_id: self.run_id, turn_id: self.turn_id, batch_id: self.batch_id, - workspace_links: self.workspace_links.clone(), + workspace_attachments: self.workspace_attachments.clone(), vfs_working_directory: self.vfs_working_directory.clone(), active_environment_id: self.active_environment_id.clone(), environment_policy: self.environment_policy.clone(), @@ -668,9 +624,9 @@ mod tests { turn_id: TurnId::new(2), batch_id: ToolBatchId::new(3), promise_id_base: 1, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), active_environment_id: Some(EnvironmentId::new("environment-a")), - environment_policy: Some(EnvironmentPolicyRuntime::new(None, None)), + environment_policy: Some(crate::EnvironmentsFeature::default()), subagents_policy: None, calls: call_ids .iter() @@ -793,7 +749,7 @@ mod promise_base_tests { run_id: RunId::new(1), turn_id: TurnId::new(1), batch_id: ToolBatchId::new(1), - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), active_environment_id: None, environment_policy: None, subagents_policy: None, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e71cc6cf..0867345d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,6 +13,7 @@ pub mod emission; pub mod media; pub mod session; pub mod storage; +mod string_id; pub use blob::*; pub use core::*; diff --git a/crates/engine/src/session/ids.rs b/crates/engine/src/session/ids.rs index 7d4502e2..ebba2e60 100644 --- a/crates/engine/src/session/ids.rs +++ b/crates/engine/src/session/ids.rs @@ -1,87 +1,8 @@ -use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use crate::string_id::string_id; +use serde::{Deserialize, Serialize}; use std::fmt; -use std::str::FromStr; use thiserror::Error; -macro_rules! string_id { - ($name:ident, $validator:ident) => { - #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] - #[cfg_attr(feature = "contract", derive(schemars::JsonSchema))] - pub struct $name(String); - - impl $name { - pub fn new(value: impl Into) -> Self { - let value = value.into(); - Self::try_new(value) - .unwrap_or_else(|error| panic!("invalid {}: {error}", stringify!($name))) - } - - pub fn try_new(value: impl Into) -> Result { - let value = value.into(); - $validator(stringify!($name), &value)?; - Ok(Self(value)) - } - - pub fn parse(value: impl Into) -> Result { - Self::try_new(value) - } - - pub fn as_str(&self) -> &str { - &self.0 - } - } - - impl TryFrom for $name { - type Error = StringIdError; - - fn try_from(value: String) -> Result { - Self::try_new(value) - } - } - - impl TryFrom<&str> for $name { - type Error = StringIdError; - - fn try_from(value: &str) -> Result { - Self::try_new(value) - } - } - - impl FromStr for $name { - type Err = StringIdError; - - fn from_str(value: &str) -> Result { - Self::try_new(value) - } - } - - impl Serialize for $name { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&self.0) - } - } - - impl<'de> Deserialize<'de> for $name { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::try_new(value).map_err(de::Error::custom) - } - } - - impl fmt::Display for $name { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } - } - }; -} - macro_rules! numeric_id { ($name:ident) => { #[derive( diff --git a/crates/engine/src/string_id.rs b/crates/engine/src/string_id.rs new file mode 100644 index 00000000..31376d53 --- /dev/null +++ b/crates/engine/src/string_id.rs @@ -0,0 +1,84 @@ +//! Crate-local implementation shared by session and core string IDs. + +macro_rules! string_id { + ($name:ident, $validator:ident) => { + #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "contract", derive(schemars::JsonSchema))] + pub struct $name(String); + + impl $name { + pub fn new(value: impl Into) -> Self { + let value = value.into(); + Self::try_new(value) + .unwrap_or_else(|error| panic!("invalid {}: {error}", stringify!($name))) + } + + pub fn try_new( + value: impl Into, + ) -> Result { + let value = value.into(); + $validator(stringify!($name), &value)?; + Ok(Self(value)) + } + + pub fn parse(value: impl Into) -> Result { + Self::try_new(value) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl TryFrom for $name { + type Error = $crate::session::StringIdError; + + fn try_from(value: String) -> Result { + Self::try_new(value) + } + } + + impl TryFrom<&str> for $name { + type Error = $crate::session::StringIdError; + + fn try_from(value: &str) -> Result { + Self::try_new(value) + } + } + + impl std::str::FromStr for $name { + type Err = $crate::session::StringIdError; + + fn from_str(value: &str) -> Result { + Self::try_new(value) + } + } + + impl serde::Serialize for $name { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0) + } + } + + impl<'de> serde::Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = >::deserialize(deserializer)?; + Self::try_new(value).map_err(serde::de::Error::custom) + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } + } + }; +} + +pub(crate) use string_id; diff --git a/crates/environment-daemon/src/jobs.rs b/crates/environment-daemon/src/jobs.rs index 6d09a472..19fdd355 100644 --- a/crates/environment-daemon/src/jobs.rs +++ b/crates/environment-daemon/src/jobs.rs @@ -26,7 +26,10 @@ use tokio::{ task::JoinHandle, }; -use crate::process_group; +use crate::{ + process_group, + redaction::{redact_bytes, redactions_for_secret_env}, +}; #[derive(Clone)] pub struct JobManager { @@ -943,37 +946,6 @@ async fn read_job_stream( } } -fn redactions_for_secret_env(secret_env: &BTreeMap) -> Vec> { - secret_env - .values() - .filter(|value| !value.is_empty()) - .map(|value| value.expose().as_bytes().to_vec()) - .collect() -} - -fn redact_bytes(bytes: &[u8], redactions: &[Vec]) -> Vec { - let mut output = bytes.to_vec(); - for secret in redactions { - if secret.is_empty() || secret.len() > output.len() { - continue; - } - let mut index = 0; - while let Some(offset) = find_subslice(&output[index..], secret) { - let start = index + offset; - let end = start + secret.len(); - output.splice(start..end, b"".iter().copied()); - index = start + b"".len(); - } - } - output -} - -fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { - haystack - .windows(needle.len()) - .position(|window| window == needle) -} - fn validate_and_resolve_start( state: &JobManagerState, params: &StartJobsParams, diff --git a/crates/environment-daemon/src/lib.rs b/crates/environment-daemon/src/lib.rs index d4f9977b..717fc7c1 100644 --- a/crates/environment-daemon/src/lib.rs +++ b/crates/environment-daemon/src/lib.rs @@ -4,6 +4,7 @@ pub mod identity; pub mod jobs; pub mod process; mod process_group; +mod redaction; pub mod registration; pub mod rpc; pub mod server; diff --git a/crates/environment-daemon/src/process.rs b/crates/environment-daemon/src/process.rs index 13fe99fe..f1f8aa30 100644 --- a/crates/environment-daemon/src/process.rs +++ b/crates/environment-daemon/src/process.rs @@ -39,7 +39,10 @@ use tokio::{ time::Instant, }; -use crate::process_group; +use crate::{ + process_group, + redaction::{redact_bytes, redactions_for_secret_env}, +}; /// How long a finished process entry stays readable after the first read /// that observed its exit. @@ -1119,39 +1122,6 @@ fn response_from_state( } } -fn redactions_for_secret_env( - secret_env: &BTreeMap, -) -> Vec> { - secret_env - .values() - .filter(|value| !value.is_empty()) - .map(|value| value.expose().as_bytes().to_vec()) - .collect() -} - -fn redact_bytes(bytes: &[u8], redactions: &[Vec]) -> Vec { - let mut output = bytes.to_vec(); - for secret in redactions { - if secret.is_empty() || secret.len() > output.len() { - continue; - } - let mut index = 0; - while let Some(offset) = find_subslice(&output[index..], secret) { - let start = index + offset; - let end = start + secret.len(); - output.splice(start..end, b"".iter().copied()); - index = start + b"".len(); - } - } - output -} - -fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { - haystack - .windows(needle.len()) - .position(|window| window == needle) -} - fn normalize_path(path: PathBuf) -> PathBuf { let mut normalized = PathBuf::new(); for component in path.components() { diff --git a/crates/environment-daemon/src/redaction.rs b/crates/environment-daemon/src/redaction.rs new file mode 100644 index 00000000..47d6bde6 --- /dev/null +++ b/crates/environment-daemon/src/redaction.rs @@ -0,0 +1,62 @@ +//! Shared byte redaction for captured process and job output. + +use environment_protocol::shared::SecretString; +use std::collections::BTreeMap; + +pub(crate) fn redactions_for_secret_env( + secret_env: &BTreeMap, +) -> Vec> { + secret_env + .values() + .filter(|value| !value.is_empty()) + .map(|value| value.expose().as_bytes().to_vec()) + .collect() +} + +pub(crate) fn redact_bytes(bytes: &[u8], redactions: &[Vec]) -> Vec { + let mut output = bytes.to_vec(); + for secret in redactions { + if secret.is_empty() || secret.len() > output.len() { + continue; + } + let mut index = 0; + while let Some(offset) = find_subslice(&output[index..], secret) { + let start = index + offset; + let end = start + secret.len(); + output.splice(start..end, b"".iter().copied()); + index = start + b"".len(); + } + } + output +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacts_repeated_and_adjacent_secrets_without_decoding_output() { + let secrets = vec![b"token".to_vec(), b"password".to_vec()]; + assert_eq!( + redact_bytes(b"\xfftoken tokenpassword\0", &secrets), + b"\xff \0" + ); + } + + #[test] + fn empty_or_absent_secrets_leave_output_unchanged() { + let secrets = vec![ + Vec::new(), + b"longer-than-output".to_vec(), + b"other".to_vec(), + ]; + assert_eq!(redact_bytes(b"text", &secrets), b"text"); + assert!(redact_bytes(b"", &secrets).is_empty()); + } +} diff --git a/crates/environments/src/lib.rs b/crates/environments/src/lib.rs index 20139967..d166cc8b 100644 --- a/crates/environments/src/lib.rs +++ b/crates/environments/src/lib.rs @@ -4,15 +4,11 @@ //! environments are universe-scoped, and provider target facts live on an //! environment incarnation rather than on stable environment identity. -use std::{ - collections::{BTreeMap, BTreeSet}, - fmt, - str::FromStr, -}; +use std::{collections::BTreeMap, fmt, str::FromStr}; use async_trait::async_trait; use auth::{AuthGrantId, AuthProviderId, SecretId, SecretValue}; -pub use engine::{EnvironmentId, SessionId}; +pub use engine::EnvironmentId; use engine::{StringIdError, validate_general_string_id}; pub use environment_protocol::control::targets::PowerState; use environment_protocol::{ @@ -106,31 +102,7 @@ registry_string_id!(EnvironmentJobGroupId); registry_string_id!(EnvironmentRegistrationKeyId); registry_string_id!(EnvironmentDaemonId); -/// Prefix of the request id a profile-provisioned environment derives from -/// its originating session, so retries and repeated applies converge on the -/// same environment through the `(universe, request_id)` unique key. -pub const SESSION_PROVISION_REQUEST_PREFIX: &str = "session:"; - impl EnvironmentProvisionRequestId { - /// The deterministic provision request id for the one environment a - /// profile may provision for `session_id`. Uses the session id verbatim - /// when it fits the request-id length limit and a SHA-256 digest - /// otherwise. - pub fn for_session(session_id: &SessionId) -> Self { - let plain = format!("{SESSION_PROVISION_REQUEST_PREFIX}{}", session_id.as_str()); - if let Ok(id) = Self::try_new(plain) { - return id; - } - use sha2::{Digest, Sha256}; - let digest = Sha256::digest(session_id.as_str().as_bytes()); - let mut hex = String::with_capacity(64); - for byte in digest { - use std::fmt::Write as _; - let _ = write!(hex, "{byte:02x}"); - } - Self::new(format!("{SESSION_PROVISION_REQUEST_PREFIX}sha256-{hex}")) - } - /// The deterministic request id of the one environment a daemon identity /// may register, so a retried first registration converges on the same /// environment through the `(universe, request_id)` unique key. @@ -576,9 +548,6 @@ pub struct EnvironmentRecord { pub incarnation: EnvironmentIncarnationRecord, pub public_ingress_enabled: bool, pub public_endpoint: Option, - /// Provenance recorded when a profile provisioned this environment for a - /// session. Not ownership: the environment stays a universe resource. - pub origin_session: Option, pub metadata: BTreeMap, /// Registered environments only: when the gateway last saw the daemon's /// control connection (connect, heartbeat). A stale stamp under `Ready` @@ -643,9 +612,6 @@ impl EnvironmentRecord { self.incarnation.updated_at_ms, )?; validate_nonempty_optional("public_endpoint", self.public_endpoint.as_deref())?; - if let Some(origin) = &self.origin_session { - origin.validate()?; - } if let Some(policy) = &self.idle_policy { policy.validate()?; } @@ -741,91 +707,6 @@ impl EnvironmentRecord { } } -/// Which environments a session may list, read, and activate, lowered from -/// the session's environments grant. Each list is independent: an absent -/// list allows every environment of that source kind. Provider-less -/// environments that are also key-less (external) pass only when nothing is -/// restricted at all. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct EnvironmentAccessPolicy { - pub providers: Option>, - pub registration_keys: Option>, -} - -impl EnvironmentAccessPolicy { - pub const ALLOW_ALL: Self = Self { - providers: None, - registration_keys: None, - }; - - pub fn new( - providers: Option>, - registration_keys: Option>, - ) -> Self { - Self { - providers: providers.map(|ids| ids.into_iter().collect()), - registration_keys: registration_keys.map(|ids| ids.into_iter().collect()), - } - } - - pub fn is_unrestricted(&self) -> bool { - self.providers.is_none() && self.registration_keys.is_none() - } - - pub fn allows(&self, environment: &EnvironmentRecord) -> bool { - match &environment.source { - EnvironmentSource::Provisioned { provider_id, .. } => self - .providers - .as_ref() - .is_none_or(|allowed| allowed.contains(provider_id.as_str())), - EnvironmentSource::Registered { - registration_key_id, - .. - } => self - .registration_keys - .as_ref() - .is_none_or(|allowed| allowed.contains(registration_key_id.as_str())), - EnvironmentSource::External { .. } => self.is_unrestricted(), - } - } - - /// Why `allows` refused, for typed rejections. - pub fn refusal(&self, environment: &EnvironmentRecord) -> String { - match &environment.source { - EnvironmentSource::Provisioned { provider_id, .. } => format!( - "environment provider {provider_id} is not allowed by features.environments.providers" - ), - EnvironmentSource::Registered { - registration_key_id, - .. - } => format!( - "registration key {registration_key_id} is not allowed by features.environments.registrationKeys" - ), - EnvironmentSource::External { .. } => { - "external environments are not allowed by a restricted environments grant" - .to_owned() - } - } - } -} - -/// Session provenance of a profile-provisioned environment plus its optional -/// close trigger. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct EnvironmentOriginSession { - pub session_id: SessionId, - pub profile_id: Option, - /// When true, the lifecycle reconciler closes the environment once the - /// originating session is closed. - pub close_with_session: bool, -} - -impl EnvironmentOriginSession { - pub fn validate(&self) -> Result<(), EnvironmentRegistryError> { - validate_nonempty_optional("origin profile id", self.profile_id.as_deref()) - } -} - /// Default disconnect grace for ephemeral registered environments whose key /// does not set one: long enough for a pod restart or a brief network /// interruption, short enough that leaked benchmark sandboxes disappear. @@ -1139,7 +1020,6 @@ pub struct CreateEnvironment { pub template_id: EnvironmentTemplateId, pub display_name: Option, pub metadata: BTreeMap, - pub origin_session: Option, pub idle_policy: Option, pub created_at_ms: i64, } @@ -1172,7 +1052,6 @@ pub struct ListEnvironments { pub provider_id: Option, pub binding_id: Option, pub status: Option, - pub origin_session_id: Option, /// Only environments admitted by this registration key. pub registration_key_id: Option, /// Only environments carrying every listed metadata pair (containment). @@ -1374,11 +1253,6 @@ pub trait EnvironmentStore: Send + Sync { async fn list_environments_needing_reconcile( &self, ) -> Result, EnvironmentRegistryError>; - /// Open (not closing/closed) environments whose origin session asked for - /// close-with-session. The caller decides whether the session is closed. - async fn list_environments_closing_with_session( - &self, - ) -> Result, EnvironmentRegistryError>; async fn observe_provisioned_environment( &self, request: ObserveProvisionedEnvironment, diff --git a/crates/environments/src/memory.rs b/crates/environments/src/memory.rs index b83df5dd..c1a8cab3 100644 --- a/crates/environments/src/memory.rs +++ b/crates/environments/src/memory.rs @@ -273,7 +273,6 @@ impl EnvironmentStore for InMemoryEnvironmentRegistryStore { }, public_ingress_enabled: false, public_endpoint: None, - origin_session: request.origin_session, metadata: request.metadata, last_seen_at_ms: None, created_at_ms: request.created_at_ms, @@ -347,7 +346,6 @@ impl EnvironmentStore for InMemoryEnvironmentRegistryStore { }, public_ingress_enabled: false, public_endpoint: None, - origin_session: None, metadata: request.metadata, last_seen_at_ms: None, created_at_ms: request.created_at_ms, @@ -405,7 +403,6 @@ impl EnvironmentStore for InMemoryEnvironmentRegistryStore { }, public_ingress_enabled: false, public_endpoint: None, - origin_session: None, metadata: request.metadata, last_seen_at_ms: None, created_at_ms: request.created_at_ms, @@ -500,7 +497,6 @@ impl EnvironmentStore for InMemoryEnvironmentRegistryStore { }, public_ingress_enabled: false, public_endpoint: None, - origin_session: None, metadata: request.metadata, last_seen_at_ms: Some(request.created_at_ms), created_at_ms: request.created_at_ms, @@ -617,14 +613,6 @@ impl EnvironmentStore for InMemoryEnvironmentRegistryStore { .is_none_or(|id| record.binding_id() == Some(id)) }) .filter(|record| request.status.is_none_or(|status| status == record.status)) - .filter(|record| { - request.origin_session_id.as_ref().is_none_or(|session_id| { - record - .origin_session - .as_ref() - .is_some_and(|origin| &origin.session_id == session_id) - }) - }) .filter(|record| { request .registration_key_id @@ -636,27 +624,6 @@ impl EnvironmentStore for InMemoryEnvironmentRegistryStore { .collect()) } - async fn list_environments_closing_with_session( - &self, - ) -> Result, EnvironmentRegistryError> { - Ok(self - .read_state()? - .environments - .values() - .filter(|record| { - record - .origin_session - .as_ref() - .is_some_and(|origin| origin.close_with_session) - && !matches!( - record.status, - EnvironmentStatus::Closing | EnvironmentStatus::Closed - ) - }) - .cloned() - .collect()) - } - async fn list_environments_needing_reconcile( &self, ) -> Result, EnvironmentRegistryError> { diff --git a/crates/environments/src/tests.rs b/crates/environments/src/tests.rs index c19f5f91..605df821 100644 --- a/crates/environments/src/tests.rs +++ b/crates/environments/src/tests.rs @@ -39,7 +39,7 @@ fn create(request: &str, environment: &str, incarnation: &str, at: i64) -> Creat template_id: EnvironmentTemplateId::new("rust-v1"), display_name: None, metadata: BTreeMap::new(), - origin_session: None, + idle_policy: None, created_at_ms: at, } @@ -365,39 +365,8 @@ async fn disabled_binding_blocks_create_and_live_references_block_delete() { } #[tokio::test(flavor = "current_thread")] -async fn origin_session_is_recorded_listed_and_swept() { +async fn environment_metadata_filters_require_every_pair() { let (_universe, store) = store().await; - let mut request = create("session:s-1", "env-s1", "inc-s1", 1); - request.origin_session = Some(EnvironmentOriginSession { - session_id: SessionId::new("s-1"), - profile_id: Some("coder".to_owned()), - close_with_session: true, - }); - let created = store.create_environment(request).await.expect("create"); - assert_eq!( - created - .origin_session - .as_ref() - .map(|origin| origin.session_id.as_str()), - Some("s-1") - ); - let plain = store - .create_environment(create("plain", "env-plain", "inc-plain", 2)) - .await - .expect("create plain"); - assert!(plain.origin_session.is_none()); - - let by_session = store - .list_environments(ListEnvironments { - metadata: Default::default(), - origin_session_id: Some(SessionId::new("s-1")), - ..ListEnvironments::default() - }) - .await - .expect("list"); - assert_eq!(by_session.len(), 1); - assert_eq!(by_session[0].environment_id.as_str(), "env-s1"); - // Metadata filters by containment: every listed pair must match. let mut tagged = create("tagged", "env-tagged", "inc-tagged", 3); tagged.metadata = BTreeMap::from([ @@ -428,46 +397,6 @@ async fn origin_session_is_recorded_listed_and_swept() { .await .expect("list by mismatched metadata"); assert!(mismatched.is_empty()); - - let sweep = store - .list_environments_closing_with_session() - .await - .expect("sweep"); - assert_eq!(sweep.len(), 1); - assert_eq!(sweep[0].environment_id.as_str(), "env-s1"); - - store - .begin_close_environment(BeginCloseEnvironment { - environment_id: EnvironmentId::new("env-s1"), - updated_at_ms: 3, - }) - .await - .expect("begin close"); - assert!( - store - .list_environments_closing_with_session() - .await - .expect("sweep") - .is_empty() - ); -} - -#[test] -fn session_provision_request_id_is_deterministic_and_bounded() { - let short = SessionId::new("session-1"); - assert_eq!( - EnvironmentProvisionRequestId::for_session(&short).as_str(), - "session:session-1" - ); - assert_eq!( - EnvironmentProvisionRequestId::for_session(&short), - EnvironmentProvisionRequestId::for_session(&short) - ); - let long = SessionId::new("s".repeat(128)); - let derived = EnvironmentProvisionRequestId::for_session(&long); - assert!(derived.as_str().starts_with("session:sha256-")); - assert!(derived.as_str().len() <= 128); - assert_eq!(derived, EnvironmentProvisionRequestId::for_session(&long)); } #[test] @@ -1025,7 +954,7 @@ async fn registration_key_policy_gates_admission_without_touching_reconnects() { } #[tokio::test(flavor = "current_thread")] -async fn registered_environments_group_by_key_and_access_policy_scopes_them() { +async fn registered_environments_list_by_key() { let (_universe_id, store) = store().await; minted_key( &store, @@ -1043,29 +972,10 @@ async fn registered_environments_group_by_key_and_access_policy_scopes_them() { .create_registered_environment(register("rk-a", "env-a", &daemon_key(0x31), 2_000)) .await .expect("a"); - let b = store + store .create_registered_environment(register("rk-b", "env-b", &daemon_key(0x32), 2_000)) .await .expect("b"); - let provisioned = store - .create_environment(create("p", "env-p", "inc-p", 2_000)) - .await - .expect("provisioned"); - let external = store - .create_external_environment(CreateExternalEnvironment { - request_id: EnvironmentProvisionRequestId::new("ext"), - environment_id: EnvironmentId::new("env-x"), - incarnation_id: EnvironmentIncarnationId::new("inc-x"), - connection: EnvironmentConnectionSpec::new( - "ws://envd.internal:19091", - EnvironmentTransport::WebSocket, - ), - display_name: None, - metadata: BTreeMap::new(), - created_at_ms: 2_000, - }) - .await - .expect("external"); let by_key = store .list_environments(ListEnvironments { @@ -1077,31 +987,6 @@ async fn registered_environments_group_by_key_and_access_policy_scopes_them() { .expect("list"); assert_eq!(by_key.len(), 1); assert_eq!(by_key[0].environment_id, a.environment_id); - - let open = EnvironmentAccessPolicy::ALLOW_ALL; - assert!( - open.allows(&a) && open.allows(&b) && open.allows(&provisioned) && open.allows(&external) - ); - - let keys_only = - EnvironmentAccessPolicy::new(None::>, Some(vec!["rk-a".to_owned()])); - assert!(keys_only.allows(&a)); - assert!(!keys_only.allows(&b)); - assert!(keys_only.allows(&provisioned)); - assert!(!keys_only.allows(&external)); - assert!(keys_only.refusal(&b).contains("rk-b")); - - let providers_only = - EnvironmentAccessPolicy::new(Some(vec!["incus-local".to_owned()]), None::>); - assert!(providers_only.allows(&provisioned)); - assert!(providers_only.allows(&a)); - assert!(!providers_only.allows(&external)); - - let neither = - EnvironmentAccessPolicy::new(Some(vec!["other".to_owned()]), Some(Vec::::new())); - assert!(!neither.allows(&provisioned)); - assert!(!neither.allows(&a)); - assert!(!neither.allows(&external)); } /// The gateway stamps reserved entries on top of whatever a daemon sent, so diff --git a/crates/llm-clients/src/openai/audio.rs b/crates/llm-clients/src/openai/audio.rs index 0278b349..23587e3b 100644 --- a/crates/llm-clients/src/openai/audio.rs +++ b/crates/llm-clients/src/openai/audio.rs @@ -8,7 +8,7 @@ use crate::error::{ }; use crate::transport::http::{join_url, normalize_base_url}; use crate::transport::{ApiResponse, HeaderSnapshot, HttpClient, HttpClientConfig}; -use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue}; +use reqwest::header::{AUTHORIZATION, HeaderValue}; use reqwest::{Method, StatusCode, Url}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -71,15 +71,12 @@ impl Config { } fn with_env_overrides(mut self) -> Self { - if let Ok(base_url) = std::env::var("OPENAI_BASE_URL") { - self.base_url = base_url; - } - if let Ok(organization) = std::env::var("OPENAI_ORG_ID") { - self.organization = Some(organization); - } - if let Ok(project) = std::env::var("OPENAI_PROJECT_ID") { - self.project = Some(project); - } + super::config::apply_env_overrides( + &mut self.base_url, + &mut self.organization, + &mut self.project, + |name| std::env::var(name).ok(), + ); self } } @@ -100,23 +97,10 @@ impl Client { .as_deref() .map(bearer_auth_value) .transpose()?; - let mut headers = HeaderMap::new(); - if let Some(organization) = &config.organization { - headers.insert( - "OpenAI-Organization", - HeaderValue::from_str(organization).map_err(|err| { - ConfigurationError::new(format!("invalid OpenAI organization header: {err}")) - })?, - ); - } - if let Some(project) = &config.project { - headers.insert( - "OpenAI-Project", - HeaderValue::from_str(project).map_err(|err| { - ConfigurationError::new(format!("invalid OpenAI project header: {err}")) - })?, - ); - } + let headers = super::config::default_headers( + config.organization.as_deref(), + config.project.as_deref(), + )?; Ok(Self { http: HttpClient::with_headers(config.http, headers)?, diff --git a/crates/llm-clients/src/openai/completions.rs b/crates/llm-clients/src/openai/completions.rs index 270e4a2b..71406ec5 100644 --- a/crates/llm-clients/src/openai/completions.rs +++ b/crates/llm-clients/src/openai/completions.rs @@ -13,7 +13,7 @@ use crate::transport::{ use crate::{SseEvent, SseParser}; use bytes::Bytes; use futures_util::{Stream, StreamExt}; -use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; +use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderValue}; use reqwest::{Method, Url}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -73,15 +73,12 @@ impl Config { } fn with_env_overrides(mut self) -> Self { - if let Ok(base_url) = std::env::var("OPENAI_BASE_URL") { - self.base_url = base_url; - } - if let Ok(organization) = std::env::var("OPENAI_ORG_ID") { - self.organization = Some(organization); - } - if let Ok(project) = std::env::var("OPENAI_PROJECT_ID") { - self.project = Some(project); - } + super::config::apply_env_overrides( + &mut self.base_url, + &mut self.organization, + &mut self.project, + |name| std::env::var(name).ok(), + ); self } } @@ -104,24 +101,11 @@ impl Client { .as_deref() .map(bearer_auth_value) .transpose()?; - let mut headers = HeaderMap::new(); + let mut headers = super::config::default_headers( + config.organization.as_deref(), + config.project.as_deref(), + )?; headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - if let Some(organization) = &config.organization { - headers.insert( - "OpenAI-Organization", - HeaderValue::from_str(organization).map_err(|err| { - ConfigurationError::new(format!("invalid OpenAI organization header: {err}")) - })?, - ); - } - if let Some(project) = &config.project { - headers.insert( - "OpenAI-Project", - HeaderValue::from_str(project).map_err(|err| { - ConfigurationError::new(format!("invalid OpenAI project header: {err}")) - })?, - ); - } Ok(Self { http: HttpClient::with_headers(config.http, headers)?, diff --git a/crates/llm-clients/src/openai/config.rs b/crates/llm-clients/src/openai/config.rs new file mode 100644 index 00000000..797cf8b7 --- /dev/null +++ b/crates/llm-clients/src/openai/config.rs @@ -0,0 +1,107 @@ +//! Configuration policy shared by the native OpenAI clients. + +use crate::error::ConfigurationError; +use reqwest::header::{HeaderMap, HeaderValue}; + +pub(super) fn apply_env_overrides( + base_url: &mut String, + organization: &mut Option, + project: &mut Option, + mut lookup: impl FnMut(&str) -> Option, +) { + if let Some(value) = lookup("OPENAI_BASE_URL") { + *base_url = value; + } + if let Some(value) = lookup("OPENAI_ORG_ID") { + *organization = Some(value); + } + if let Some(value) = lookup("OPENAI_PROJECT_ID") { + *project = Some(value); + } +} + +pub(super) fn default_headers( + organization: Option<&str>, + project: Option<&str>, +) -> Result { + let mut headers = HeaderMap::new(); + if let Some(organization) = organization { + headers.insert( + "OpenAI-Organization", + HeaderValue::from_str(organization).map_err(|err| { + ConfigurationError::new(format!("invalid OpenAI organization header: {err}")) + })?, + ); + } + if let Some(project) = project { + headers.insert( + "OpenAI-Project", + HeaderValue::from_str(project).map_err(|err| { + ConfigurationError::new(format!("invalid OpenAI project header: {err}")) + })?, + ); + } + Ok(headers) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn environment_overrides_preserve_missing_values_and_apply_empty_values() { + let mut base_url = "https://configured.example/v1".to_owned(); + let mut organization = Some("configured-org".to_owned()); + let mut project = Some("configured-project".to_owned()); + apply_env_overrides( + &mut base_url, + &mut organization, + &mut project, + |name| match name { + "OPENAI_BASE_URL" => Some("https://override.example/v1".to_owned()), + "OPENAI_PROJECT_ID" => Some(String::new()), + _ => None, + }, + ); + assert_eq!(base_url, "https://override.example/v1"); + assert_eq!(organization.as_deref(), Some("configured-org")); + assert_eq!(project.as_deref(), Some("")); + + apply_env_overrides( + &mut base_url, + &mut organization, + &mut project, + |name| match name { + "OPENAI_BASE_URL" => Some(String::new()), + "OPENAI_ORG_ID" => Some("override-org".to_owned()), + _ => None, + }, + ); + assert_eq!(base_url, ""); + assert_eq!(organization.as_deref(), Some("override-org")); + assert_eq!(project.as_deref(), Some("")); + } + + #[test] + fn optional_headers_preserve_empty_values_without_adding_auth_or_content_type() { + assert!(default_headers(None, None).unwrap().is_empty()); + let headers = default_headers(Some(""), Some("project-test")).unwrap(); + assert_eq!(headers.len(), 2); + assert_eq!(headers["openai-organization"], ""); + assert_eq!(headers["openai-project"], "project-test"); + } + + #[test] + fn invalid_headers_report_the_field_and_preserve_validation_order() { + for (organization, project, field) in [ + (Some("bad\norg"), Some("bad\nproject"), "organization"), + (Some("valid-org"), Some("bad\nproject"), "project"), + ] { + let error: ConfigurationError = default_headers(organization, project).unwrap_err(); + assert_eq!( + error.message, + format!("invalid OpenAI {field} header: failed to parse header value") + ); + } + } +} diff --git a/crates/llm-clients/src/openai/mod.rs b/crates/llm-clients/src/openai/mod.rs index 7afea295..8a68c4dc 100644 --- a/crates/llm-clients/src/openai/mod.rs +++ b/crates/llm-clients/src/openai/mod.rs @@ -2,4 +2,5 @@ pub mod audio; pub mod completions; +mod config; pub mod responses; diff --git a/crates/llm-clients/src/openai/responses.rs b/crates/llm-clients/src/openai/responses.rs index c5bb2b5c..41ed724d 100644 --- a/crates/llm-clients/src/openai/responses.rs +++ b/crates/llm-clients/src/openai/responses.rs @@ -13,7 +13,7 @@ use crate::transport::{ use crate::{SseEvent, SseParser}; use bytes::Bytes; use futures_util::{Stream, StreamExt}; -use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; +use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderValue}; use reqwest::{Method, Url}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -80,15 +80,12 @@ impl Config { } fn with_env_overrides(mut self) -> Self { - if let Ok(base_url) = std::env::var("OPENAI_BASE_URL") { - self.base_url = base_url; - } - if let Ok(organization) = std::env::var("OPENAI_ORG_ID") { - self.organization = Some(organization); - } - if let Ok(project) = std::env::var("OPENAI_PROJECT_ID") { - self.project = Some(project); - } + super::config::apply_env_overrides( + &mut self.base_url, + &mut self.organization, + &mut self.project, + |name| std::env::var(name).ok(), + ); self } } @@ -117,24 +114,11 @@ impl Client { .as_deref() .map(bearer_auth_value) .transpose()?; - let mut headers = HeaderMap::new(); + let mut headers = super::config::default_headers( + config.organization.as_deref(), + config.project.as_deref(), + )?; headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - if let Some(organization) = &config.organization { - headers.insert( - "OpenAI-Organization", - HeaderValue::from_str(organization).map_err(|err| { - ConfigurationError::new(format!("invalid OpenAI organization header: {err}")) - })?, - ); - } - if let Some(project) = &config.project { - headers.insert( - "OpenAI-Project", - HeaderValue::from_str(project).map_err(|err| { - ConfigurationError::new(format!("invalid OpenAI project header: {err}")) - })?, - ); - } Ok(Self { http: HttpClient::with_headers(config.http, headers)?, diff --git a/crates/llm-clients/tests/openai_endpoint_override.rs b/crates/llm-clients/tests/openai_endpoint_override.rs index adc563a0..c22d8b46 100644 --- a/crates/llm-clients/tests/openai_endpoint_override.rs +++ b/crates/llm-clients/tests/openai_endpoint_override.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; -use llm_clients::openai::{completions, responses}; +use llm_clients::openai::{audio, completions, responses}; use llm_clients::{EndpointOverride, RequestAuth}; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use tokio::net::TcpListener; @@ -54,6 +54,102 @@ async fn one_request_server( (format!("http://{address}/v1"), receiver, task) } +#[tokio::test(flavor = "current_thread")] +async fn default_openai_headers_preserve_each_clients_path_and_content_type() { + for (kind, body, path, content_type) in [ + ( + "audio", + r#"{"text":"ok"}"#, + "/v1/audio/transcriptions", + "multipart/form-data; boundary=", + ), + ( + "completions", + r#"{"id":"chatcmpl_test","object":"chat.completion","created":1,"model":"test","choices":[]}"#, + "/v1/chat/completions", + "application/json", + ), + ( + "responses", + r#"{"id":"resp_test","object":"response","status":"completed","output":[]}"#, + "/v1/responses", + "application/json", + ), + ] { + let (base_url, request, server) = one_request_server(body).await; + match kind { + "audio" => { + let mut config = audio::Config::new("test-key"); + config.base_url = base_url; + config.organization = Some("org-test".into()); + config.project = Some("project-test".into()); + audio::Client::new(config) + .expect("client") + .create_transcription(audio::CreateTranscriptionRequest::new( + audio::AudioFile { + filename: "test.wav".into(), + mime: "audio/wav".into(), + bytes: b"RIFF".to_vec(), + }, + )) + .await + .expect("transcription"); + } + "completions" => { + let mut config = completions::Config::new("test-key"); + config.base_url = base_url; + config.organization = Some("org-test".into()); + config.project = Some("project-test".into()); + completions::Client::new(config) + .expect("client") + .create(completions::CreateCompletionRequest::user_text( + "test", "hello", + )) + .await + .expect("completion"); + } + "responses" => { + let mut config = responses::Config::new("test-key"); + config.base_url = base_url; + config.organization = Some("org-test".into()); + config.project = Some("project-test".into()); + responses::Client::new(config) + .expect("client") + .create(responses::CreateResponseRequest::text("test", "hello")) + .await + .expect("response"); + } + _ => unreachable!(), + } + let request = request + .await + .expect("captured request") + .to_ascii_lowercase(); + let headers = request.split_once("\r\n\r\n").expect("headers").0; + assert!( + headers.starts_with(&format!("post {path} http/1.1")), + "{kind}" + ); + for header in [ + "authorization: bearer test-key", + "openai-organization: org-test", + "openai-project: project-test", + ] { + assert!( + headers.lines().any(|line| line == header), + "{kind}: {header}" + ); + } + assert!( + headers + .lines() + .any(|line| line.starts_with(&format!("content-type: {content_type}"))), + "{kind}" + ); + server.await.expect("server"); + } +} + #[tokio::test(flavor = "current_thread")] async fn completions_override_routes_auth_and_custom_headers_without_openai_defaults() { let (base_url, request, server) = one_request_server( diff --git a/crates/llm-runtime/src/anthropic_messages.rs b/crates/llm-runtime/src/anthropic_messages.rs index fb26f390..0aa493b6 100644 --- a/crates/llm-runtime/src/anthropic_messages.rs +++ b/crates/llm-runtime/src/anthropic_messages.rs @@ -31,9 +31,7 @@ use crate::{ blob_io::{put_json, put_text, read_json, read_text}, error::{LlmAdapterError, LlmAdapterResult}, executor::{LlmCompactionAdapter, LlmGenerationAdapter}, - mcp::{ - MAX_NATIVE_MCP_TOOLS_PER_REQUEST, McpInventoryResolver, UnconfiguredMcpInventoryResolver, - }, + mcp::{McpInventoryResolver, UnconfiguredMcpInventoryResolver, injected_native_tools}, params::{ anthropic_messages_params, anthropic_thinking_from_effort, default_anthropic_thinking_display, @@ -911,38 +909,14 @@ async fn materialize_tools( } (RemoteMcpExecution::Native, RemoteMcpExposure::Search) => {} (RemoteMcpExecution::Native, RemoteMcpExposure::Inject) => { - let mut native = - inventory.list_tools(remote_mcp).await.map_err(|error| { - LlmAdapterError::McpInventory { - server: remote_mcp.server_id.clone(), - message: error.to_string(), - } - })?; - native.sort_by(|left, right| left.remote_name.cmp(&right.remote_name)); - let advertised_count = native.len(); - native.retain(|native_tool| { - let name = format!("{}__{}", tool.name, native_tool.remote_name); - crate::tool_catalog::valid_exposed_name(&name) - }); - let omitted_count = advertised_count - native.len(); - if omitted_count != 0 { - tracing::warn!( - server_id = %remote_mcp.server_id, - omitted_tool_count = omitted_count, - "omitted native MCP tools with provider-incompatible names" - ); - } - if native_mcp_tool_count.saturating_add(native.len()) - > MAX_NATIVE_MCP_TOOLS_PER_REQUEST - { - return Err(LlmAdapterError::McpInventory { - server: remote_mcp.server_id.clone(), - message: "native MCP inventory exceeds the per-request tool cap; author a Selected allowlist or switch the record to search exposure".to_owned(), - }); - } - native_mcp_tool_count += native.len(); - for native_tool in native { - let name = format!("{}__{}", tool.name, native_tool.remote_name); + let native = injected_native_tools( + inventory, + remote_mcp, + &tool.name, + &mut native_mcp_tool_count, + ) + .await?; + for (name, native_tool) in native { catalog .names .insert(ToolName::new(name.clone()), Some(tool.id.clone()))?; diff --git a/crates/llm-runtime/src/mcp.rs b/crates/llm-runtime/src/mcp.rs index eaec05e4..27054ac5 100644 --- a/crates/llm-runtime/src/mcp.rs +++ b/crates/llm-runtime/src/mcp.rs @@ -1,7 +1,9 @@ use async_trait::async_trait; -use engine::RemoteMcpToolSpec; +use engine::{RemoteMcpToolSpec, ToolName}; use serde_json::Value; +use crate::{LlmAdapterError, LlmAdapterResult}; + pub const MAX_NATIVE_MCP_TOOLS_PER_REQUEST: usize = 256; #[derive(Clone, Debug, PartialEq)] @@ -36,6 +38,49 @@ pub trait McpInventoryResolver: Send + Sync { ) -> Result, McpInventoryError>; } +/// Resolve the shared injection policy before adapters construct native wire tools. +/// The counter belongs to the request, so all injected servers share the cap. +pub(crate) async fn injected_native_tools( + inventory: &dyn McpInventoryResolver, + spec: &RemoteMcpToolSpec, + server_name: &ToolName, + request_tool_count: &mut usize, +) -> LlmAdapterResult> { + let mut native = + inventory + .list_tools(spec) + .await + .map_err(|error| LlmAdapterError::McpInventory { + server: spec.server_id.clone(), + message: error.to_string(), + })?; + native.sort_by(|left, right| left.remote_name.cmp(&right.remote_name)); + let advertised_count = native.len(); + let native: Vec<_> = native + .into_iter() + .filter_map(|tool| { + let name = format!("{server_name}__{}", tool.remote_name); + crate::tool_catalog::valid_exposed_name(&name).then_some((name, tool)) + }) + .collect(); + let omitted_count = advertised_count - native.len(); + if omitted_count != 0 { + tracing::warn!( + server_id = %spec.server_id, + omitted_tool_count = omitted_count, + "omitted native MCP tools with provider-incompatible names" + ); + } + if request_tool_count.saturating_add(native.len()) > MAX_NATIVE_MCP_TOOLS_PER_REQUEST { + return Err(LlmAdapterError::McpInventory { + server: spec.server_id.clone(), + message: "native MCP inventory exceeds the per-request tool cap; author a Selected allowlist or switch the record to search exposure".to_owned(), + }); + } + *request_tool_count += native.len(); + Ok(native) +} + #[derive(Default)] pub struct UnconfiguredMcpInventoryResolver; @@ -51,3 +96,142 @@ impl McpInventoryResolver for UnconfiguredMcpInventoryResolver { ))) } } + +#[cfg(test)] +mod tests { + use super::*; + use engine::{RemoteMcpApprovalPolicy, RemoteMcpExecution, RemoteMcpExposure}; + use serde_json::json; + + struct Inventory(Result, McpInventoryError>); + + #[async_trait] + impl McpInventoryResolver for Inventory { + async fn list_tools( + &self, + _: &RemoteMcpToolSpec, + ) -> Result, McpInventoryError> { + self.0.clone() + } + } + + fn spec(server: &str) -> RemoteMcpToolSpec { + RemoteMcpToolSpec { + server_id: server.to_owned(), + record_revision: 1, + server_label: server.to_owned(), + server_url: "https://example.com/mcp".to_owned(), + description_ref: None, + allowed_tools: None, + execution: RemoteMcpExecution::Native, + exposure: RemoteMcpExposure::Inject, + approval: RemoteMcpApprovalPolicy::Never, + defer_loading: None, + auth_ref: None, + auth_required: false, + allow_private_network: false, + } + } + + fn tool(name: &str) -> NativeMcpTool { + NativeMcpTool { + remote_name: name.to_owned(), + description: Some(format!("Description for {name}")), + input_schema: json!({"type": "object", "properties": {"query": {"type": "string"}}}), + annotations: Some(json!({"readOnlyHint": true})), + } + } + + #[tokio::test(flavor = "current_thread")] + async fn injected_inventory_sorts_filters_and_preserves_tool_metadata() { + let inventory = Inventory(Ok(vec![tool("z"), tool("bad.name"), tool("a")])); + let mut count = 0; + let tools = injected_native_tools( + &inventory, + &spec("docs"), + &ToolName::new("mcp_docs"), + &mut count, + ) + .await + .expect("inventory"); + assert_eq!( + tools, + vec![ + ("mcp_docs__a".to_owned(), tool("a")), + ("mcp_docs__z".to_owned(), tool("z")) + ] + ); + assert_eq!(count, 2); + } + + #[tokio::test(flavor = "current_thread")] + async fn cap_is_cumulative_and_counts_only_provider_compatible_names() { + let mut count = 0; + let server_name = ToolName::new("mcp_docs"); + let first = Inventory(Ok((0..MAX_NATIVE_MCP_TOOLS_PER_REQUEST - 1) + .map(|i| tool(&format!("read_{i}"))) + .collect())); + injected_native_tools(&first, &spec("first"), &server_name, &mut count) + .await + .expect("first server"); + let last = Inventory(Ok(vec![ + tool("last"), + tool("bad.name"), + tool(&"x".repeat(64)), + ])); + injected_native_tools(&last, &spec("second"), &server_name, &mut count) + .await + .expect("exact cap"); + assert_eq!(count, MAX_NATIVE_MCP_TOOLS_PER_REQUEST); + let error = injected_native_tools( + &Inventory(Ok(vec![tool("extra")])), + &spec("third"), + &server_name, + &mut count, + ) + .await + .expect_err("over cap"); + assert!(matches!(error, LlmAdapterError::McpInventory { server, .. } if server == "third")); + assert_eq!(count, MAX_NATIVE_MCP_TOOLS_PER_REQUEST); + let omitted = injected_native_tools( + &Inventory(Ok(vec![tool("bad.name")])), + &spec("omitted"), + &server_name, + &mut count, + ) + .await + .expect("filtered tools do not consume quota"); + assert!(omitted.is_empty()); + } + + #[tokio::test(flavor = "current_thread")] + async fn inventory_errors_keep_server_identity_and_do_not_consume_quota() { + let mut count = 7; + let error = injected_native_tools( + &Inventory(Err(McpInventoryError::new("unavailable"))), + &spec("docs"), + &ToolName::new("mcp_docs"), + &mut count, + ) + .await + .expect_err("resolver error"); + assert!( + matches!(error, LlmAdapterError::McpInventory { server, message } if server == "docs" && message == "unavailable") + ); + assert_eq!(count, 7); + } + + #[tokio::test(flavor = "current_thread")] + async fn duplicate_names_remain_visible_to_catalog_collision_checks() { + let tools = injected_native_tools( + &Inventory(Ok(vec![tool("read"), tool("read")])), + &spec("docs"), + &ToolName::new("mcp_docs"), + &mut 0, + ) + .await + .expect("inventory"); + assert_eq!(tools.len(), 2); + assert_eq!(tools[0].0, tools[1].0); + } +} diff --git a/crates/llm-runtime/src/openai_completions.rs b/crates/llm-runtime/src/openai_completions.rs index c13ac708..71260a25 100644 --- a/crates/llm-runtime/src/openai_completions.rs +++ b/crates/llm-runtime/src/openai_completions.rs @@ -22,9 +22,7 @@ use crate::{ blob_io::{put_json, put_text, read_json, read_text}, error::{LlmAdapterError, LlmAdapterResult}, executor::{LlmCompactionAdapter, LlmGenerationAdapter}, - mcp::{ - MAX_NATIVE_MCP_TOOLS_PER_REQUEST, McpInventoryResolver, UnconfiguredMcpInventoryResolver, - }, + mcp::{McpInventoryResolver, UnconfiguredMcpInventoryResolver, injected_native_tools}, params::{openai_completions_params, validate_openai_reasoning_effort}, provider_keys::{ModelProviderResolver, NoStoredModelProviders, resolve_model_provider}, result::{ @@ -776,37 +774,10 @@ async fn materialize_tools( if spec.execution == RemoteMcpExecution::Native && spec.exposure == RemoteMcpExposure::Inject => { - let mut native = inventory.list_tools(spec).await.map_err(|error| { - LlmAdapterError::McpInventory { - server: spec.server_id.clone(), - message: error.to_string(), - } - })?; - native.sort_by(|left, right| left.remote_name.cmp(&right.remote_name)); - let advertised_count = native.len(); - native.retain(|native_tool| { - let name = format!("{}__{}", tool.name, native_tool.remote_name); - crate::tool_catalog::valid_exposed_name(&name) - }); - let omitted_count = advertised_count - native.len(); - if omitted_count != 0 { - tracing::warn!( - server_id = %spec.server_id, - omitted_tool_count = omitted_count, - "omitted native MCP tools with provider-incompatible names" - ); - } - if native_mcp_tool_count.saturating_add(native.len()) - > MAX_NATIVE_MCP_TOOLS_PER_REQUEST - { - return Err(LlmAdapterError::McpInventory { - server: spec.server_id.clone(), - message: "native MCP inventory exceeds the per-request tool cap; author a Selected allowlist or switch the record to search exposure".to_owned(), - }); - } - native_mcp_tool_count += native.len(); - for native_tool in native { - let name = format!("{}__{}", tool.name, native_tool.remote_name); + let native = + injected_native_tools(inventory, spec, &tool.name, &mut native_mcp_tool_count) + .await?; + for (name, native_tool) in native { catalog .names .insert(ToolName::new(name.clone()), Some(tool.id.clone()))?; diff --git a/crates/llm-runtime/src/openai_responses.rs b/crates/llm-runtime/src/openai_responses.rs index bef6087c..6b14eda2 100644 --- a/crates/llm-runtime/src/openai_responses.rs +++ b/crates/llm-runtime/src/openai_responses.rs @@ -21,9 +21,7 @@ use crate::{ blob_io::{put_json, put_text, read_json, read_text}, error::{LlmAdapterError, LlmAdapterResult}, executor::{LlmCompactionAdapter, LlmGenerationAdapter}, - mcp::{ - MAX_NATIVE_MCP_TOOLS_PER_REQUEST, McpInventoryResolver, UnconfiguredMcpInventoryResolver, - }, + mcp::{McpInventoryResolver, UnconfiguredMcpInventoryResolver, injected_native_tools}, params::{openai_reasoning_from_effort, openai_responses_params}, provider_keys::{ModelProviderResolver, NoStoredModelProviders, resolve_model_provider}, result::{ @@ -586,37 +584,10 @@ async fn materialize_tools( } (RemoteMcpExecution::Native, RemoteMcpExposure::Search) => {} (RemoteMcpExecution::Native, RemoteMcpExposure::Inject) => { - let mut native = inventory.list_tools(spec).await.map_err(|error| { - LlmAdapterError::McpInventory { - server: spec.server_id.clone(), - message: error.to_string(), - } - })?; - native.sort_by(|left, right| left.remote_name.cmp(&right.remote_name)); - let advertised_count = native.len(); - native.retain(|native_tool| { - let name = format!("{}__{}", tool.name, native_tool.remote_name); - crate::tool_catalog::valid_exposed_name(&name) - }); - let omitted_count = advertised_count - native.len(); - if omitted_count != 0 { - tracing::warn!( - server_id = %spec.server_id, - omitted_tool_count = omitted_count, - "omitted native MCP tools with provider-incompatible names" - ); - } - if native_mcp_tool_count.saturating_add(native.len()) - > MAX_NATIVE_MCP_TOOLS_PER_REQUEST - { - return Err(LlmAdapterError::McpInventory { - server: spec.server_id.clone(), - message: "native MCP inventory exceeds the per-request tool cap; author a Selected allowlist or switch the record to search exposure".to_owned(), - }); - } - native_mcp_tool_count += native.len(); - for native_tool in native { - let name = format!("{}__{}", tool.name, native_tool.remote_name); + let native = + injected_native_tools(inventory, spec, &tool.name, &mut native_mcp_tool_count) + .await?; + for (name, native_tool) in native { catalog .names .insert(ToolName::new(name.clone()), Some(tool.id.clone()))?; diff --git a/crates/llm-runtime/tests/anthropic_messages_caching_live.rs b/crates/llm-runtime/tests/anthropic_messages_caching_live.rs index ba1b154c..5bb44896 100644 --- a/crates/llm-runtime/tests/anthropic_messages_caching_live.rs +++ b/crates/llm-runtime/tests/anthropic_messages_caching_live.rs @@ -3,7 +3,6 @@ //! cache, and the ordinary things that happen to a session — a tool round //! trip, a catalog update — keep the hit. -use std::path::PathBuf; use std::sync::Arc; use engine::{ @@ -13,7 +12,6 @@ use engine::{ ToolChoice, ToolName, TurnId, storage::{BlobStore, InMemoryBlobStore}, }; -use llm_clients::anthropic::messages::{Client, Config}; use llm_runtime::{ AnthropicMessagesLlmAdapter, LlmGenerationAdapter, params::AnthropicMessagesParams, }; @@ -21,66 +19,16 @@ use serde_json::json; mod support; +use support::{ + anthropic_messages_live_client as live_client, anthropic_messages_live_model as live_model, +}; + use support::{ anthropic_params, caching::{assert_cached_share, long_instructions}, retrying_anthropic_messages_client, }; -fn live_model() -> String { - env_or_dotenv_var("ANTHROPIC_MESSAGES_MODEL") - .or_else(|_| env_or_dotenv_var("ANTHROPIC_LIVE_MODEL")) - .unwrap_or_else(|_| "claude-opus-5".to_string()) -} - -fn live_client() -> Client { - let api_key = env_or_dotenv_var("ANTHROPIC_API_KEY").expect( - "ANTHROPIC_API_KEY must be set in env or root .env to run anthropic:messages caching live tests", - ); - assert!( - !api_key.trim().is_empty(), - "ANTHROPIC_API_KEY is set but empty" - ); - let mut config = Config::new(api_key); - if let Ok(base_url) = env_or_dotenv_var("ANTHROPIC_BASE_URL") { - config.base_url = base_url; - } - Client::new(config).expect("Anthropic Messages client") -} - -fn env_or_dotenv_var(name: &str) -> Result { - match std::env::var(name) { - Ok(value) => Ok(value), - Err(env_error) => dotenv_var(name).ok_or(env_error), - } -} - -fn dotenv_var(name: &str) -> Option { - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2) - .expect("repo root") - .join(".env"); - let contents = std::fs::read_to_string(path).ok()?; - for line in contents.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let (key, value) = line.split_once('=')?; - if key.trim() == name { - return Some( - value - .trim() - .trim_matches('"') - .trim_matches('\'') - .to_string(), - ); - } - } - None -} - async fn text_blob(blobs: &InMemoryBlobStore, text: &str) -> BlobRef { blobs.insert_text(text).await } diff --git a/crates/llm-runtime/tests/anthropic_messages_compaction_live.rs b/crates/llm-runtime/tests/anthropic_messages_compaction_live.rs index b0a5f4ff..86b8bf05 100644 --- a/crates/llm-runtime/tests/anthropic_messages_compaction_live.rs +++ b/crates/llm-runtime/tests/anthropic_messages_compaction_live.rs @@ -5,7 +5,7 @@ //! compaction task, the adapter runs a summarization request, and the engine //! prunes the compacted history in favor of the summary entry. -use std::{path::PathBuf, sync::Arc}; +use std::sync::Arc; use engine::{ ANTHROPIC_MESSAGES_COMPACTION_PROVIDER_KIND, BlobRef, CompactionPolicy, @@ -15,7 +15,6 @@ use engine::{ SessionId, TokenEstimate, TokenEstimateQuality, storage::{BlobStore, CreateSession, InMemoryBlobStore, InMemorySessionStore, SessionStore}, }; -use llm_clients::anthropic::messages::{Client, Config}; use llm_runtime::{ ANTHROPIC_MESSAGES_INPUT_MESSAGE_PROVIDER_KIND, AnthropicMessagesLlmAdapter, LlmAdapterRegistry, LlmRuntime, @@ -24,74 +23,14 @@ use test_support::{DriveCommand, RunnerQuiescence, RunnerStores, SessionRunner}; mod support; +use support::{ + anthropic_messages_live_client as live_client, anthropic_messages_live_model as live_model, +}; + use support::retrying_anthropic_messages_client; const LIVE_MARKER: &str = "LIGHTSPEED-ANTHROPIC-COMPACTION-LIVE-4217"; -fn live_model() -> String { - env_or_dotenv_var("ANTHROPIC_MESSAGES_MODEL") - .or_else(|_| env_or_dotenv_var("ANTHROPIC_LIVE_MODEL")) - .unwrap_or_else(|_| "claude-opus-5".to_string()) -} - -fn live_client() -> Client { - let api_key = env_or_dotenv_var("ANTHROPIC_API_KEY").expect( - "ANTHROPIC_API_KEY must be set in env or root .env to run Anthropic compaction live tests", - ); - assert!( - !api_key.trim().is_empty(), - "ANTHROPIC_API_KEY is set but empty" - ); - - let mut config = Config::new(api_key); - if let Ok(base_url) = env_or_dotenv_var("ANTHROPIC_BASE_URL") { - config.base_url = base_url; - } - Client::new(config).expect("Anthropic Messages client") -} - -fn env_or_dotenv_var(name: &str) -> Result { - match std::env::var(name) { - Ok(value) => Ok(value), - Err(env_error) => dotenv_var(name).ok_or(env_error), - } -} - -fn dotenv_var(name: &str) -> Option { - let contents = std::fs::read_to_string(root_dotenv_path()).ok()?; - for line in contents.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let (key, value) = line.split_once('=')?; - if key.trim() == name { - return Some(unquote_dotenv_value(value.trim())); - } - } - None -} - -fn root_dotenv_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2) - .expect("repo root") - .join(".env") -} - -fn unquote_dotenv_value(value: &str) -> String { - if value.len() >= 2 { - let bytes = value.as_bytes(); - if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"') - || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'') - { - return value[1..value.len() - 1].to_string(); - } - } - value.to_string() -} - #[tokio::test(flavor = "current_thread")] #[ignore = "requires ANTHROPIC_API_KEY (costs real money)"] async fn anthropic_messages_live_manual_standalone_compaction_preserves_marker() { diff --git a/crates/llm-runtime/tests/anthropic_messages_live.rs b/crates/llm-runtime/tests/anthropic_messages_live.rs index 0db0fc92..e93acf80 100644 --- a/crates/llm-runtime/tests/anthropic_messages_live.rs +++ b/crates/llm-runtime/tests/anthropic_messages_live.rs @@ -1,4 +1,3 @@ -use std::path::PathBuf; use std::sync::Arc; use engine::{ @@ -12,77 +11,17 @@ use engine::{ ToolName, TurnId, storage::{BlobStore, InMemoryBlobStore}, }; -use llm_clients::anthropic::messages::{Client, Config}; use llm_runtime::{AnthropicMessagesLlmAdapter, LlmCompactionAdapter, LlmGenerationAdapter}; use serde_json::{Value, json}; mod support; -use support::retrying_anthropic_messages_client; - -fn live_model() -> String { - env_or_dotenv_var("ANTHROPIC_MESSAGES_MODEL") - .or_else(|_| env_or_dotenv_var("ANTHROPIC_LIVE_MODEL")) - .unwrap_or_else(|_| "claude-opus-5".to_string()) -} - -fn live_client() -> Client { - let api_key = env_or_dotenv_var("ANTHROPIC_API_KEY").expect( - "ANTHROPIC_API_KEY must be set in env or root .env to run llm-runtime anthropic:messages live tests", - ); - assert!( - !api_key.trim().is_empty(), - "ANTHROPIC_API_KEY is set but empty" - ); - - let mut config = Config::new(api_key); - if let Ok(base_url) = env_or_dotenv_var("ANTHROPIC_BASE_URL") { - config.base_url = base_url; - } - Client::new(config).expect("Anthropic Messages client") -} - -fn env_or_dotenv_var(name: &str) -> Result { - match std::env::var(name) { - Ok(value) => Ok(value), - Err(env_error) => dotenv_var(name).ok_or(env_error), - } -} - -fn dotenv_var(name: &str) -> Option { - let contents = std::fs::read_to_string(root_dotenv_path()).ok()?; - for line in contents.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let (key, value) = line.split_once('=')?; - if key.trim() == name { - return Some(unquote_dotenv_value(value.trim())); - } - } - None -} - -fn root_dotenv_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2) - .expect("repo root") - .join(".env") -} +use support::{ + anthropic_messages_live_client as live_client, anthropic_messages_live_model as live_model, + env_or_dotenv_var, +}; -fn unquote_dotenv_value(value: &str) -> String { - if value.len() >= 2 { - let bytes = value.as_bytes(); - if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"') - || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'') - { - return value[1..value.len() - 1].to_string(); - } - } - value.to_string() -} +use support::retrying_anthropic_messages_client; async fn text_blob(blobs: &InMemoryBlobStore, text: &str) -> BlobRef { blobs.insert_text(text).await diff --git a/crates/llm-runtime/tests/anthropic_messages_mcp_live.rs b/crates/llm-runtime/tests/anthropic_messages_mcp_live.rs index aecaf812..16748068 100644 --- a/crates/llm-runtime/tests/anthropic_messages_mcp_live.rs +++ b/crates/llm-runtime/tests/anthropic_messages_mcp_live.rs @@ -4,7 +4,7 @@ //! `mcp_tool_use`/`mcp_tool_result` blocks come back as provider-opaque //! context without any Lightspeed tool events. -use std::{collections::BTreeMap, path::PathBuf, sync::Arc}; +use std::{collections::BTreeMap, sync::Arc}; use engine::{ ContextConfig, ContextEntryInput, ContextEntryKind, ContextMessageRole, CoreAgentCommand, @@ -12,82 +12,26 @@ use engine::{ RunConfig, RunStatus, SessionConfig, SessionId, ToolKind, ToolName, ToolParallelism, ToolSpec, storage::{BlobStore, CreateSession, InMemoryBlobStore, InMemorySessionStore, SessionStore}, }; -use llm_clients::anthropic::messages::{ANTHROPIC_MCP_BETA, Client, Config}; +use llm_clients::anthropic::messages::{ANTHROPIC_MCP_BETA, Client}; use llm_runtime::{AnthropicMessagesLlmAdapter, LlmAdapterRegistry, LlmRuntime}; use serde_json::Value; use test_support::{DriveCommand, RunnerQuiescence, RunnerStores, SessionRunner}; mod support; +use support::anthropic_messages_live_model as live_model; + use support::retrying_anthropic_messages_client; const MCP_TEST_SERVER_URL: &str = "https://mcpplaygroundonline.com/mcp-stateless-server"; const MCP_TEST_TOOL: &str = "which_protocol_era"; -fn live_model() -> String { - env_or_dotenv_var("ANTHROPIC_MESSAGES_MODEL") - .or_else(|_| env_or_dotenv_var("ANTHROPIC_LIVE_MODEL")) - .unwrap_or_else(|_| "claude-opus-5".to_string()) -} fn live_client() -> Client { - let api_key = env_or_dotenv_var("ANTHROPIC_API_KEY").expect( - "ANTHROPIC_API_KEY must be set in env or root .env to run Anthropic MCP live tests", - ); - assert!( - !api_key.trim().is_empty(), - "ANTHROPIC_API_KEY is set but empty" - ); - - let mut config = Config::new(api_key); + let mut config = support::anthropic_messages_live_config(); config.beta_headers = vec![ANTHROPIC_MCP_BETA.to_string()]; - if let Ok(base_url) = env_or_dotenv_var("ANTHROPIC_BASE_URL") { - config.base_url = base_url; - } Client::new(config).expect("Anthropic Messages client") } -fn env_or_dotenv_var(name: &str) -> Result { - match std::env::var(name) { - Ok(value) => Ok(value), - Err(env_error) => dotenv_var(name).ok_or(env_error), - } -} - -fn dotenv_var(name: &str) -> Option { - let contents = std::fs::read_to_string(root_dotenv_path()).ok()?; - for line in contents.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let (key, value) = line.split_once('=')?; - if key.trim() == name { - return Some(unquote_dotenv_value(value.trim())); - } - } - None -} - -fn root_dotenv_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2) - .expect("repo root") - .join(".env") -} - -fn unquote_dotenv_value(value: &str) -> String { - if value.len() >= 2 { - let bytes = value.as_bytes(); - if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"') - || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'') - { - return value[1..value.len() - 1].to_string(); - } - } - value.to_string() -} - #[tokio::test(flavor = "current_thread")] #[ignore = "requires ANTHROPIC_API_KEY and public MCP server access (costs real money)"] async fn anthropic_messages_live_core_session_uses_public_remote_mcp() { diff --git a/crates/llm-runtime/tests/anthropic_messages_prompts_live.rs b/crates/llm-runtime/tests/anthropic_messages_prompts_live.rs index cb9c01c8..ec7fec1f 100644 --- a/crates/llm-runtime/tests/anthropic_messages_prompts_live.rs +++ b/crates/llm-runtime/tests/anthropic_messages_prompts_live.rs @@ -3,7 +3,6 @@ use std::{ collections::BTreeMap, - path::PathBuf, sync::{Arc, Mutex}, }; @@ -11,10 +10,9 @@ use async_trait::async_trait; use engine::{ ContextConfig, ContextEntryInput, ContextEntryKind, ContextMessageRole, CoreAgentCommand, CoreAgentEvent, ModelSelection, ProviderApiKind, RunConfig, RunStatus, SessionConfig, - SessionId, WorkspaceLink, WorkspaceLinkAccess, WorkspaceLinkTarget, + SessionId, WorkspaceAccess, WorkspaceAttachment, WorkspaceAttachmentTarget, storage::{BlobStore, CreateSession, InMemoryBlobStore, InMemorySessionStore, SessionStore}, }; -use llm_clients::anthropic::messages::{Client, Config}; use llm_runtime::{AnthropicMessagesLlmAdapter, LlmAdapterRegistry, LlmRuntime}; use test_support::{DriveCommand, RunnerQuiescence, RunnerStores, SessionRunner}; use tools::prompts::{PROMPT_INSTRUCTIONS_CONTEXT_KEY_PREFIX, active_prompt_instruction_entries}; @@ -26,74 +24,14 @@ use vfs::{ mod support; +use support::{ + anthropic_messages_live_client as live_client, anthropic_messages_live_model as live_model, +}; + use support::retrying_anthropic_messages_client; const EXPECTED_CAPACITY: &str = "slots=12"; -fn live_model() -> String { - env_or_dotenv_var("ANTHROPIC_MESSAGES_MODEL") - .or_else(|_| env_or_dotenv_var("ANTHROPIC_LIVE_MODEL")) - .unwrap_or_else(|_| "claude-opus-5".to_string()) -} - -fn live_client() -> Client { - let api_key = env_or_dotenv_var("ANTHROPIC_API_KEY").expect( - "ANTHROPIC_API_KEY must be set in env or root .env to run Anthropic prompts live tests", - ); - assert!( - !api_key.trim().is_empty(), - "ANTHROPIC_API_KEY is set but empty" - ); - - let mut config = Config::new(api_key); - if let Ok(base_url) = env_or_dotenv_var("ANTHROPIC_BASE_URL") { - config.base_url = base_url; - } - Client::new(config).expect("Anthropic Messages client") -} - -fn env_or_dotenv_var(name: &str) -> Result { - match std::env::var(name) { - Ok(value) => Ok(value), - Err(env_error) => dotenv_var(name).ok_or(env_error), - } -} - -fn dotenv_var(name: &str) -> Option { - let contents = std::fs::read_to_string(root_dotenv_path()).ok()?; - for line in contents.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let (key, value) = line.split_once('=')?; - if key.trim() == name { - return Some(unquote_dotenv_value(value.trim())); - } - } - None -} - -fn root_dotenv_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2) - .expect("repo root") - .join(".env") -} - -fn unquote_dotenv_value(value: &str) -> String { - if value.len() >= 2 { - let bytes = value.as_bytes(); - if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"') - || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'') - { - return value[1..value.len() - 1].to_string(); - } - } - value.to_string() -} - #[derive(Default)] struct LiveVfsCatalog { workspaces: Mutex>, @@ -240,12 +178,12 @@ async fn anthropic_messages_live_uses_vfs_prompt_instructions() { }) .await .expect("create workspace"); - let workspace_links = vec![WorkspaceLink { + let workspace_attachments = vec![WorkspaceAttachment { path: "/workspace".to_owned(), - target: WorkspaceLinkTarget::Workspace { + target: WorkspaceAttachmentTarget::Workspace { workspace_id: workspace_id.to_string(), }, - access: WorkspaceLinkAccess::ReadWrite, + access: WorkspaceAccess::Edit, }]; let model = ModelSelection { @@ -270,7 +208,7 @@ async fn anthropic_messages_live_uses_vfs_prompt_instructions() { session_id: session_id.clone(), observed_at_ms: 10, command: CoreAgentCommand::OpenSession { - config: session_config(model, workspace_links), + config: session_config(model, workspace_attachments), }, max_steps: None, }) @@ -336,7 +274,10 @@ async fn anthropic_messages_live_uses_vfs_prompt_instructions() { ); } -fn session_config(model: ModelSelection, workspace_links: Vec) -> SessionConfig { +fn session_config( + model: ModelSelection, + workspace_attachments: Vec, +) -> SessionConfig { SessionConfig { model, generation: engine::GenerationConfig { @@ -350,7 +291,7 @@ fn session_config(model: ModelSelection, workspace_links: Vec) -> context: ContextConfig { compaction: None }, features: engine::FeaturesConfig { vfs: Some(engine::VfsFeature { - workspace_links, + workspaces: workspace_attachments, prompts: Some(engine::VfsPromptsConfig::default()), ..engine::VfsFeature::default() }), diff --git a/crates/llm-runtime/tests/anthropic_messages_skills_live.rs b/crates/llm-runtime/tests/anthropic_messages_skills_live.rs index 5c41e830..414f2feb 100644 --- a/crates/llm-runtime/tests/anthropic_messages_skills_live.rs +++ b/crates/llm-runtime/tests/anthropic_messages_skills_live.rs @@ -5,7 +5,6 @@ use std::{ collections::BTreeMap, - path::PathBuf, sync::{Arc, Mutex}, }; @@ -13,94 +12,33 @@ use async_trait::async_trait; use engine::{ BlobRef, ContextConfig, ContextEntryInput, ContextEntryKind, ContextMessageRole, CoreAgentCommand, CoreAgentEvent, ModelSelection, ProviderApiKind, RunConfig, RunStatus, - SessionConfig, SessionId, WorkspaceLink, WorkspaceLinkAccess, WorkspaceLinkTarget, + SessionConfig, SessionId, WorkspaceAccess, WorkspaceAttachment, WorkspaceAttachmentTarget, storage::{BlobStore, CreateSession, InMemoryBlobStore, InMemorySessionStore, SessionStore}, }; -use llm_clients::anthropic::messages::{Client, Config}; use llm_runtime::{AnthropicMessagesLlmAdapter, LlmAdapterRegistry, LlmRuntime}; use test_support::{DriveCommand, RunnerQuiescence, RunnerStores, SessionRunner}; use tools::{ fs::tools::ReadFileResult, - fs::{FsPath, FsToolContext, LinkedVfsFileSystem}, + fs::{AttachedVfsFileSystem, FsPath, FsToolContext}, runtime::InlineToolRuntime, toolset::{ToolsetConfig, register_toolset}, }; use vfs::{ CompareAndSetVfsWorkspaceHead, CreateInlineSnapshotRequest, CreateVfsWorkspaceRecord, - InlineFile, ResolvedWorkspaceLink, ResolvedWorkspaceLinkTarget, VfsCatalogError, VfsPath, - VfsWorkspaceId, VfsWorkspaceRecord, VfsWorkspaceStore, create_inline_snapshot, + InlineFile, ResolvedWorkspaceAttachment, ResolvedWorkspaceAttachmentTarget, VfsCatalogError, + VfsPath, VfsWorkspaceId, VfsWorkspaceRecord, VfsWorkspaceStore, create_inline_snapshot, }; mod support; +use support::{ + anthropic_messages_live_client as live_client, anthropic_messages_live_model as live_model, +}; + use support::retrying_anthropic_messages_client; const MIGRATION_FIRST_STEP: &str = "Create an immutable checkpoint of the matrix before migration."; -fn live_model() -> String { - env_or_dotenv_var("ANTHROPIC_MESSAGES_MODEL") - .or_else(|_| env_or_dotenv_var("ANTHROPIC_LIVE_MODEL")) - .unwrap_or_else(|_| "claude-opus-5".to_string()) -} - -fn live_client() -> Client { - let api_key = env_or_dotenv_var("ANTHROPIC_API_KEY").expect( - "ANTHROPIC_API_KEY must be set in env or root .env to run Anthropic skills live tests", - ); - assert!( - !api_key.trim().is_empty(), - "ANTHROPIC_API_KEY is set but empty" - ); - - let mut config = Config::new(api_key); - if let Ok(base_url) = env_or_dotenv_var("ANTHROPIC_BASE_URL") { - config.base_url = base_url; - } - Client::new(config).expect("Anthropic Messages client") -} - -fn env_or_dotenv_var(name: &str) -> Result { - match std::env::var(name) { - Ok(value) => Ok(value), - Err(env_error) => dotenv_var(name).ok_or(env_error), - } -} - -fn dotenv_var(name: &str) -> Option { - let contents = std::fs::read_to_string(root_dotenv_path()).ok()?; - for line in contents.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let (key, value) = line.split_once('=')?; - if key.trim() == name { - return Some(unquote_dotenv_value(value.trim())); - } - } - None -} - -fn root_dotenv_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2) - .expect("repo root") - .join(".env") -} - -fn unquote_dotenv_value(value: &str) -> String { - if value.len() >= 2 { - let bytes = value.as_bytes(); - if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"') - || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'') - { - return value[1..value.len() - 1].to_string(); - } - } - value.to_string() -} - #[derive(Default)] struct LiveVfsCatalog { workspaces: Mutex>, @@ -245,27 +183,27 @@ async fn anthropic_messages_live_selects_and_reads_the_matching_skill() { ) .await .expect("create skill snapshot"); - let workspace_links = vec![WorkspaceLink { + let workspace_attachments = vec![WorkspaceAttachment { path: "/skills/system".to_owned(), - target: WorkspaceLinkTarget::Snapshot { + target: WorkspaceAttachmentTarget::Snapshot { snapshot_ref: snapshot.snapshot_ref.to_string(), }, - access: WorkspaceLinkAccess::ReadOnly, + access: WorkspaceAccess::Read, }]; - let linked_fs = LinkedVfsFileSystem::new( + let attached_fs = AttachedVfsFileSystem::new( blobs.clone(), vfs.clone(), - vec![ResolvedWorkspaceLink { + vec![ResolvedWorkspaceAttachment { path: VfsPath::parse("/skills/system").unwrap(), - target: ResolvedWorkspaceLinkTarget::AvailableSnapshot { + target: ResolvedWorkspaceAttachmentTarget::AvailableSnapshot { snapshot_ref: snapshot.snapshot_ref, }, - access: WorkspaceLinkAccess::ReadOnly, + access: WorkspaceAccess::Read, }], ) - .expect("linked fs"); - let fs_ctx = FsToolContext::new(Arc::new(linked_fs), blobs.clone()).with_cwd(FsPath::root()); + .expect("attached fs"); + let fs_ctx = FsToolContext::new(Arc::new(attached_fs), blobs.clone()).with_cwd(FsPath::root()); let model = ModelSelection { api_kind: ProviderApiKind::AnthropicMessages, provider_id: "anthropic".to_string(), @@ -294,7 +232,7 @@ async fn anthropic_messages_live_selects_and_reads_the_matching_skill() { session_id: session_id.clone(), observed_at_ms: 10, command: CoreAgentCommand::OpenSession { - config: session_config(model, workspace_links), + config: session_config(model, workspace_attachments), }, max_steps: None, }) @@ -393,7 +331,10 @@ async fn anthropic_messages_live_selects_and_reads_the_matching_skill() { ); } -fn session_config(model: ModelSelection, workspace_links: Vec) -> SessionConfig { +fn session_config( + model: ModelSelection, + workspace_attachments: Vec, +) -> SessionConfig { SessionConfig { model, generation: engine::GenerationConfig { @@ -409,14 +350,13 @@ fn session_config(model: ModelSelection, workspace_links: Vec) -> vfs: Some(engine::VfsFeature { skills: Some(engine::VfsSkillsConfig { roots: Some( - workspace_links + workspace_attachments .iter() - .map(|link| link.path.clone()) + .map(|attachment| attachment.path.clone()) .collect(), ), }), - workspace_links, - tools: Some(engine::VfsToolSurface::ReadOnly), + workspaces: workspace_attachments, ..engine::VfsFeature::default() }), ..engine::FeaturesConfig::default() diff --git a/crates/llm-runtime/tests/builtin_catalog_parity.rs b/crates/llm-runtime/tests/builtin_catalog_parity.rs index 06f48916..9f9fa0eb 100644 --- a/crates/llm-runtime/tests/builtin_catalog_parity.rs +++ b/crates/llm-runtime/tests/builtin_catalog_parity.rs @@ -132,7 +132,7 @@ async fn fixture(api: ProviderApiKind, case: &str) -> Value { .expect("request json") } -/// Captured from the pre-refactor executable builders (commit 5707d076). +/// Captured provider contracts track intentional changes to the builtin surface. /// Compare complete requests: descriptions, schemas, strictness, order, helper /// placement, and cache breakpoints. The resolver runs with an empty blob store. #[tokio::test(flavor = "current_thread")] diff --git a/crates/llm-runtime/tests/fixtures/builtin_catalogs.json b/crates/llm-runtime/tests/fixtures/builtin_catalogs.json index 6c60d128..30512677 100644 --- a/crates/llm-runtime/tests/fixtures/builtin_catalogs.json +++ b/crates/llm-runtime/tests/fixtures/builtin_catalogs.json @@ -13,7 +13,7 @@ "tool_choice": "auto", "tools": [ { - "description": "Apply a Codex-style apply_patch patch to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "Apply a Codex-style apply_patch patch to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "name": "vfs_apply_patch", "parameters": { "additionalProperties": false, @@ -32,7 +32,7 @@ "type": "function" }, { - "description": "Replace exact text in a UTF-8 file. Multiple matches require replace_all=true. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "Replace exact text in a UTF-8 file. Multiple matches require replace_all=true. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "name": "vfs_edit_file", "parameters": { "additionalProperties": false, @@ -65,7 +65,7 @@ "type": "function" }, { - "description": "Find files recursively with a glob pattern. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "Find files recursively with a glob pattern. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "name": "vfs_glob", "parameters": { "additionalProperties": false, @@ -115,7 +115,7 @@ "type": "function" }, { - "description": "Search UTF-8 files recursively with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "Search UTF-8 files recursively with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "name": "vfs_grep", "parameters": { "additionalProperties": false, @@ -176,7 +176,7 @@ "type": "function" }, { - "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "name": "vfs_list_dir", "parameters": { "additionalProperties": false, @@ -193,7 +193,7 @@ "type": "function" }, { - "description": "Read a UTF-8 file with optional 1-based line offset and line limit. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "Read a UTF-8 file with optional 1-based line offset and line limit. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "name": "vfs_read_file", "parameters": { "additionalProperties": false, @@ -236,7 +236,7 @@ "type": "function" }, { - "description": "Write full UTF-8 file content, creating parent directories when needed. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "Write full UTF-8 file content, creating parent directories when needed. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "name": "vfs_write_file", "parameters": { "additionalProperties": false, @@ -276,7 +276,7 @@ "tool_choice": "auto", "tools": [ { - "description": "Apply a Codex-style apply_patch patch to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Apply a Codex-style apply_patch patch to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "apply_patch", "parameters": { "additionalProperties": false, @@ -295,7 +295,7 @@ "type": "function" }, { - "description": "Replace exact text in a UTF-8 file. Multiple matches require replace_all=true. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Replace exact text in a UTF-8 file. Multiple matches require replace_all=true. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "edit_file", "parameters": { "additionalProperties": false, @@ -328,7 +328,7 @@ "type": "function" }, { - "description": "Select one allowed, ready universe environment as this session's active environment. Environment-dependent tools must be called in a later turn.", + "description": "Select one attached environment as this session's active environment. The tool surface does not change; calls outside the active environment's access are rejected. Environment-dependent tools must be called in a later turn.", "name": "environment_activate", "parameters": { "additionalProperties": false, @@ -358,41 +358,18 @@ "type": "function" }, { - "description": "List the live universe environments allowed by this session. Use this before activation when you do not know the environment id. Registered environments carry a group, the name of the pool they registered under; filter by it to pick from one pool.", + "description": "List the environments attached to this session with their status, this session's access on each, and which one is active.", "name": "environment_list", "parameters": { "additionalProperties": false, - "properties": { - "cursor": { - "type": [ - "string", - "null" - ] - }, - "group": { - "description": "Only environments in this group (registered pool name).", - "minLength": 1, - "type": [ - "string", - "null" - ] - }, - "limit": { - "maximum": 100, - "minimum": 1, - "type": [ - "integer", - "null" - ] - } - }, + "properties": {}, "type": "object" }, "strict": false, "type": "function" }, { - "description": "Read live details for an environment. Omit environment_id to inspect this session's active environment; provide a known id to inspect another environment allowed by the session.", + "description": "Read live details and this session's access for an environment. Omit environment_id to inspect the active environment; provide the id of another environment attached to this session to inspect it.", "name": "environment_read", "parameters": { "additionalProperties": false, @@ -411,7 +388,7 @@ "type": "function" }, { - "description": "Runs a command in a shell (a PTY when `tty` is true), returning output or a session ID for ongoing interaction. A command may leave services running for later calls; they keep running until stopped or the environment closes. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Runs a command in a shell (a PTY when `tty` is true), returning output or a session ID for ongoing interaction. A command may leave services running for later calls; they keep running until stopped or the environment closes. Operates only in the active environment; attached VFS files are not implicitly available.", "name": "exec_command", "parameters": { "additionalProperties": false, @@ -475,7 +452,7 @@ "type": "function" }, { - "description": "Find files recursively with a glob pattern. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Find files recursively with a glob pattern. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "glob", "parameters": { "additionalProperties": false, @@ -525,7 +502,7 @@ "type": "function" }, { - "description": "Search UTF-8 files recursively with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Search UTF-8 files recursively with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "grep", "parameters": { "additionalProperties": false, @@ -586,7 +563,7 @@ "type": "function" }, { - "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "list_dir", "parameters": { "additionalProperties": false, @@ -603,7 +580,7 @@ "type": "function" }, { - "description": "Read a UTF-8 file with optional 1-based line offset and line limit. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Read a UTF-8 file with optional 1-based line offset and line limit. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "read_file", "parameters": { "additionalProperties": false, @@ -646,7 +623,7 @@ "type": "function" }, { - "description": "Write full UTF-8 file content, creating parent directories when needed. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Write full UTF-8 file content, creating parent directories when needed. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "write_file", "parameters": { "additionalProperties": false, @@ -670,7 +647,7 @@ "type": "function" }, { - "description": "Writes characters to an existing unified exec session and returns recent output. Empty `chars` polls without writing; Ctrl-C (\\u0003) interrupts the session. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Writes characters to an existing unified exec session and returns recent output. Empty `chars` polls without writing; Ctrl-C (\\u0003) interrupts the session. Operates only in the active environment; attached VFS files are not implicitly available.", "name": "write_stdin", "parameters": { "additionalProperties": false, @@ -736,7 +713,7 @@ "tool_choice": "auto", "tools": [ { - "description": "Apply a Codex-style apply_patch patch to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Apply a Codex-style apply_patch patch to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "apply_patch", "parameters": { "additionalProperties": false, @@ -755,7 +732,7 @@ "type": "function" }, { - "description": "Replace exact text in a UTF-8 file. Multiple matches require replace_all=true. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Replace exact text in a UTF-8 file. Multiple matches require replace_all=true. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "edit_file", "parameters": { "additionalProperties": false, @@ -788,7 +765,7 @@ "type": "function" }, { - "description": "Select one allowed, ready universe environment as this session's active environment. Environment-dependent tools must be called in a later turn.", + "description": "Select one attached environment as this session's active environment. The tool surface does not change; calls outside the active environment's access are rejected. Environment-dependent tools must be called in a later turn.", "name": "environment_activate", "parameters": { "additionalProperties": false, @@ -818,41 +795,18 @@ "type": "function" }, { - "description": "List the live universe environments allowed by this session. Use this before activation when you do not know the environment id. Registered environments carry a group, the name of the pool they registered under; filter by it to pick from one pool.", + "description": "List the environments attached to this session with their status, this session's access on each, and which one is active.", "name": "environment_list", "parameters": { "additionalProperties": false, - "properties": { - "cursor": { - "type": [ - "string", - "null" - ] - }, - "group": { - "description": "Only environments in this group (registered pool name).", - "minLength": 1, - "type": [ - "string", - "null" - ] - }, - "limit": { - "maximum": 100, - "minimum": 1, - "type": [ - "integer", - "null" - ] - } - }, + "properties": {}, "type": "object" }, "strict": false, "type": "function" }, { - "description": "Read live details for an environment. Omit environment_id to inspect this session's active environment; provide a known id to inspect another environment allowed by the session.", + "description": "Read live details and this session's access for an environment. Omit environment_id to inspect the active environment; provide the id of another environment attached to this session to inspect it.", "name": "environment_read", "parameters": { "additionalProperties": false, @@ -871,7 +825,7 @@ "type": "function" }, { - "description": "Runs a command to completion and returns its output. The process is terminated on timeout or cancellation and cannot be resumed. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Runs a command to completion and returns its output. The process is terminated on timeout or cancellation and cannot be resumed. Operates only in the active environment; attached VFS files are not implicitly available.", "name": "exec_command", "parameters": { "additionalProperties": false, @@ -928,7 +882,7 @@ "type": "function" }, { - "description": "Find files recursively with a glob pattern. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Find files recursively with a glob pattern. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "glob", "parameters": { "additionalProperties": false, @@ -978,7 +932,7 @@ "type": "function" }, { - "description": "Search UTF-8 files recursively with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Search UTF-8 files recursively with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "grep", "parameters": { "additionalProperties": false, @@ -1039,7 +993,7 @@ "type": "function" }, { - "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "list_dir", "parameters": { "additionalProperties": false, @@ -1056,7 +1010,7 @@ "type": "function" }, { - "description": "Read a UTF-8 file with optional 1-based line offset and line limit. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Read a UTF-8 file with optional 1-based line offset and line limit. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "read_file", "parameters": { "additionalProperties": false, @@ -1099,7 +1053,7 @@ "type": "function" }, { - "description": "Write full UTF-8 file content, creating parent directories when needed. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Write full UTF-8 file content, creating parent directories when needed. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "write_file", "parameters": { "additionalProperties": false, @@ -1139,7 +1093,7 @@ "tool_choice": "auto", "tools": [ { - "description": "Apply a Codex-style apply_patch patch to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Apply a Codex-style apply_patch patch to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "apply_patch", "parameters": { "additionalProperties": false, @@ -1158,7 +1112,7 @@ "type": "function" }, { - "description": "Continue with a running handle: optionally send input or a signal, then wait up to `wait_ms` and return the output produced since the last call. With nothing but the handle it only waits. Once the process has exited it returns the remaining output and the exit code. Paths are resolved within the configured filesystem scope. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Continue with a running handle: optionally send input or a signal, then wait up to `wait_ms` and return the output produced since the last call. With nothing but the handle it only waits. Once the process has exited it returns the remaining output and the exit code. Paths are resolved within the configured filesystem scope. Operates only in the active environment; attached VFS files are not implicitly available.", "name": "continue_process", "parameters": { "additionalProperties": false, @@ -1227,7 +1181,7 @@ "type": "function" }, { - "description": "Replace exact text in a UTF-8 file. Multiple matches require replace_all=true. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Replace exact text in a UTF-8 file. Multiple matches require replace_all=true. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "edit_file", "parameters": { "additionalProperties": false, @@ -1260,7 +1214,7 @@ "type": "function" }, { - "description": "Select one allowed, ready universe environment as this session's active environment. Environment-dependent tools must be called in a later turn.", + "description": "Select one attached environment as this session's active environment. The tool surface does not change; calls outside the active environment's access are rejected. Environment-dependent tools must be called in a later turn.", "name": "environment_activate", "parameters": { "additionalProperties": false, @@ -1290,41 +1244,18 @@ "type": "function" }, { - "description": "List the live universe environments allowed by this session. Use this before activation when you do not know the environment id. Registered environments carry a group, the name of the pool they registered under; filter by it to pick from one pool.", + "description": "List the environments attached to this session with their status, this session's access on each, and which one is active.", "name": "environment_list", "parameters": { "additionalProperties": false, - "properties": { - "cursor": { - "type": [ - "string", - "null" - ] - }, - "group": { - "description": "Only environments in this group (registered pool name).", - "minLength": 1, - "type": [ - "string", - "null" - ] - }, - "limit": { - "maximum": 100, - "minimum": 1, - "type": [ - "integer", - "null" - ] - } - }, + "properties": {}, "type": "object" }, "strict": false, "type": "function" }, { - "description": "Read live details for an environment. Omit environment_id to inspect this session's active environment; provide a known id to inspect another environment allowed by the session.", + "description": "Read live details and this session's access for an environment. Omit environment_id to inspect the active environment; provide the id of another environment attached to this session to inspect it.", "name": "environment_read", "parameters": { "additionalProperties": false, @@ -1343,7 +1274,7 @@ "type": "function" }, { - "description": "Find files recursively with a glob pattern. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Find files recursively with a glob pattern. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "glob", "parameters": { "additionalProperties": false, @@ -1393,7 +1324,7 @@ "type": "function" }, { - "description": "Search UTF-8 files recursively with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Search UTF-8 files recursively with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "grep", "parameters": { "additionalProperties": false, @@ -1454,7 +1385,7 @@ "type": "function" }, { - "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "list_dir", "parameters": { "additionalProperties": false, @@ -1471,7 +1402,7 @@ "type": "function" }, { - "description": "Read a UTF-8 file with optional 1-based line offset and line limit. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Read a UTF-8 file with optional 1-based line offset and line limit. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "read_file", "parameters": { "additionalProperties": false, @@ -1514,7 +1445,7 @@ "type": "function" }, { - "description": "Run a command. Waits until it exits, or until `yield_ms` if set, and returns its output. If it is still running you get a handle for `continue_process`. With `timeout_ms` the command is killed at that deadline; without it a running command keeps running until stopped or the environment closes. Interactive programs need `tty: true`. Paths are resolved within the configured filesystem scope. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Run a command. Waits until it exits, or until `yield_ms` if set, and returns its output. If it is still running you get a handle for `continue_process`. With `timeout_ms` the command is killed at that deadline; without it a running command keeps running until stopped or the environment closes. Interactive programs need `tty: true`. Paths are resolved within the configured filesystem scope. Operates only in the active environment; attached VFS files are not implicitly available.", "name": "run_process", "parameters": { "additionalProperties": false, @@ -1597,7 +1528,7 @@ "type": "function" }, { - "description": "Write full UTF-8 file content, creating parent directories when needed. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Write full UTF-8 file content, creating parent directories when needed. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "write_file", "parameters": { "additionalProperties": false, @@ -1838,7 +1769,7 @@ "type": "function" }, { - "description": "Run one durable environment job and wait for its terminal readable result. Use job_submit for dependency groups, longer work, or explicit Promise control. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Run one durable environment job and wait for its terminal readable result. Use job_submit for dependency groups, longer work, or explicit Promise control. Operates only in the active environment; attached VFS files are not implicitly available.", "name": "job_run", "parameters": { "additionalProperties": false, @@ -1901,7 +1832,7 @@ "type": "function" }, { - "description": "Start one or more durable environment jobs asynchronously. Returns one Promise per job; use await, cancel, or detach when appropriate. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Start one or more durable environment jobs asynchronously. Returns one Promise per job; use await, cancel, or detach when appropriate. Operates only in the active environment; attached VFS files are not implicitly available.", "name": "job_submit", "parameters": { "additionalProperties": false, @@ -2041,7 +1972,7 @@ }, "tools": [ { - "description": "Performs exact string replacements in a file. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "Performs exact string replacements in a file. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "input_schema": { "additionalProperties": false, "properties": { @@ -2075,7 +2006,7 @@ "name": "VfsEdit" }, { - "description": "Finds files by glob pattern. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "Finds files by glob pattern. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "input_schema": { "additionalProperties": false, "properties": { @@ -2099,7 +2030,7 @@ "name": "VfsGlob" }, { - "description": "Searches file contents with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "Searches file contents with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "input_schema": { "additionalProperties": false, "properties": { @@ -2246,7 +2177,7 @@ "name": "VfsGrep" }, { - "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "input_schema": { "additionalProperties": false, "properties": { @@ -2261,7 +2192,7 @@ "name": "VfsListDir" }, { - "description": "Reads a file from the filesystem. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "Reads a file from the filesystem. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "input_schema": { "additionalProperties": false, "properties": { @@ -2312,7 +2243,7 @@ "cache_control": { "type": "ephemeral" }, - "description": "Writes a file to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "Writes a file to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "input_schema": { "additionalProperties": false, "properties": { @@ -2349,7 +2280,7 @@ }, "tools": [ { - "description": "Executes a shell command. Waits for it to finish, killing it at `timeout`, unless `run_in_background` is true, in which case it returns at once with an ID for BashOutput and KillShell. A command may leave services running; they keep running until stopped or the environment is closed. Paths are resolved within the configured filesystem scope. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Executes a shell command. Waits for it to finish, killing it at `timeout`, unless `run_in_background` is true, in which case it returns at once with an ID for BashOutput and KillShell. A command may leave services running; they keep running until stopped or the environment is closed. Paths are resolved within the configured filesystem scope. Operates only in the active environment; attached VFS files are not implicitly available.", "input_schema": { "additionalProperties": false, "properties": { @@ -2399,7 +2330,7 @@ "name": "Bash" }, { - "description": "Wait up to `timeout` for a background command to finish and return the output produced since the last call. Returns at once if it has already exited, with its exit code. Paths are resolved within the configured filesystem scope. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Wait up to `timeout` for a background command to finish and return the output produced since the last call. Returns at once if it has already exited, with its exit code. Paths are resolved within the configured filesystem scope. Operates only in the active environment; attached VFS files are not implicitly available.", "input_schema": { "additionalProperties": false, "properties": { @@ -2435,7 +2366,7 @@ "name": "BashOutput" }, { - "description": "Performs exact string replacements in a file. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Performs exact string replacements in a file. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "input_schema": { "additionalProperties": false, "properties": { @@ -2469,7 +2400,7 @@ "name": "Edit" }, { - "description": "Finds files by glob pattern. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Finds files by glob pattern. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "input_schema": { "additionalProperties": false, "properties": { @@ -2493,7 +2424,7 @@ "name": "Glob" }, { - "description": "Searches file contents with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Searches file contents with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "input_schema": { "additionalProperties": false, "properties": { @@ -2640,7 +2571,7 @@ "name": "Grep" }, { - "description": "Kills a running background command by its ID and returns the output it produced since the last call. Paths are resolved within the configured filesystem scope. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Kills a running background command by its ID and returns the output it produced since the last call. Paths are resolved within the configured filesystem scope. Operates only in the active environment; attached VFS files are not implicitly available.", "input_schema": { "additionalProperties": false, "properties": { @@ -2657,7 +2588,7 @@ "name": "KillShell" }, { - "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "input_schema": { "additionalProperties": false, "properties": { @@ -2672,7 +2603,7 @@ "name": "ListDir" }, { - "description": "Reads a file from the filesystem. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Reads a file from the filesystem. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "input_schema": { "additionalProperties": false, "properties": { @@ -2720,7 +2651,7 @@ "name": "Read" }, { - "description": "Writes a file to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Writes a file to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "input_schema": { "additionalProperties": false, "properties": { @@ -2742,7 +2673,7 @@ "name": "Write" }, { - "description": "Select one allowed, ready universe environment as this session's active environment. Environment-dependent tools must be called in a later turn.", + "description": "Select one attached environment as this session's active environment. The tool surface does not change; calls outside the active environment's access are rejected. Environment-dependent tools must be called in a later turn.", "input_schema": { "additionalProperties": false, "properties": { @@ -2768,33 +2699,10 @@ "name": "environment_deactivate" }, { - "description": "List the live universe environments allowed by this session. Use this before activation when you do not know the environment id. Registered environments carry a group, the name of the pool they registered under; filter by it to pick from one pool.", + "description": "List the environments attached to this session with their status, this session's access on each, and which one is active.", "input_schema": { "additionalProperties": false, - "properties": { - "cursor": { - "type": [ - "string", - "null" - ] - }, - "group": { - "description": "Only environments in this group (registered pool name).", - "minLength": 1, - "type": [ - "string", - "null" - ] - }, - "limit": { - "maximum": 100, - "minimum": 1, - "type": [ - "integer", - "null" - ] - } - }, + "properties": {}, "type": "object" }, "name": "environment_list" @@ -2803,7 +2711,7 @@ "cache_control": { "type": "ephemeral" }, - "description": "Read live details for an environment. Omit environment_id to inspect this session's active environment; provide a known id to inspect another environment allowed by the session.", + "description": "Read live details and this session's access for an environment. Omit environment_id to inspect the active environment; provide the id of another environment attached to this session to inspect it.", "input_schema": { "additionalProperties": false, "properties": { @@ -2835,7 +2743,7 @@ }, "tools": [ { - "description": "Executes a shell command and waits for it to finish, killing it at `timeout`. A command may leave services running; they keep running until stopped or the environment is closed. Paths are resolved within the configured filesystem scope. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Executes a shell command and waits for it to finish, killing it at `timeout`. A command may leave services running; they keep running until stopped or the environment is closed. Paths are resolved within the configured filesystem scope. Operates only in the active environment; attached VFS files are not implicitly available.", "input_schema": { "additionalProperties": false, "properties": { @@ -2878,7 +2786,7 @@ "name": "Bash" }, { - "description": "Performs exact string replacements in a file. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Performs exact string replacements in a file. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "input_schema": { "additionalProperties": false, "properties": { @@ -2912,7 +2820,7 @@ "name": "Edit" }, { - "description": "Finds files by glob pattern. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Finds files by glob pattern. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "input_schema": { "additionalProperties": false, "properties": { @@ -2936,7 +2844,7 @@ "name": "Glob" }, { - "description": "Searches file contents with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Searches file contents with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "input_schema": { "additionalProperties": false, "properties": { @@ -3083,7 +2991,7 @@ "name": "Grep" }, { - "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "input_schema": { "additionalProperties": false, "properties": { @@ -3098,7 +3006,7 @@ "name": "ListDir" }, { - "description": "Reads a file from the filesystem. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Reads a file from the filesystem. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "input_schema": { "additionalProperties": false, "properties": { @@ -3146,7 +3054,7 @@ "name": "Read" }, { - "description": "Writes a file to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Writes a file to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "input_schema": { "additionalProperties": false, "properties": { @@ -3168,7 +3076,7 @@ "name": "Write" }, { - "description": "Select one allowed, ready universe environment as this session's active environment. Environment-dependent tools must be called in a later turn.", + "description": "Select one attached environment as this session's active environment. The tool surface does not change; calls outside the active environment's access are rejected. Environment-dependent tools must be called in a later turn.", "input_schema": { "additionalProperties": false, "properties": { @@ -3194,33 +3102,10 @@ "name": "environment_deactivate" }, { - "description": "List the live universe environments allowed by this session. Use this before activation when you do not know the environment id. Registered environments carry a group, the name of the pool they registered under; filter by it to pick from one pool.", + "description": "List the environments attached to this session with their status, this session's access on each, and which one is active.", "input_schema": { "additionalProperties": false, - "properties": { - "cursor": { - "type": [ - "string", - "null" - ] - }, - "group": { - "description": "Only environments in this group (registered pool name).", - "minLength": 1, - "type": [ - "string", - "null" - ] - }, - "limit": { - "maximum": 100, - "minimum": 1, - "type": [ - "integer", - "null" - ] - } - }, + "properties": {}, "type": "object" }, "name": "environment_list" @@ -3229,7 +3114,7 @@ "cache_control": { "type": "ephemeral" }, - "description": "Read live details for an environment. Omit environment_id to inspect this session's active environment; provide a known id to inspect another environment allowed by the session.", + "description": "Read live details and this session's access for an environment. Omit environment_id to inspect the active environment; provide the id of another environment attached to this session to inspect it.", "input_schema": { "additionalProperties": false, "properties": { @@ -3261,7 +3146,7 @@ }, "tools": [ { - "description": "Apply a Codex-style apply_patch patch to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Apply a Codex-style apply_patch patch to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "input_schema": { "additionalProperties": false, "properties": { @@ -3278,7 +3163,7 @@ "name": "apply_patch" }, { - "description": "Continue with a running handle: optionally send input or a signal, then wait up to `wait_ms` and return the output produced since the last call. With nothing but the handle it only waits. Once the process has exited it returns the remaining output and the exit code. Paths are resolved within the configured filesystem scope. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Continue with a running handle: optionally send input or a signal, then wait up to `wait_ms` and return the output produced since the last call. With nothing but the handle it only waits. Once the process has exited it returns the remaining output and the exit code. Paths are resolved within the configured filesystem scope. Operates only in the active environment; attached VFS files are not implicitly available.", "input_schema": { "additionalProperties": false, "properties": { @@ -3345,7 +3230,7 @@ "name": "continue_process" }, { - "description": "Replace exact text in a UTF-8 file. Multiple matches require replace_all=true. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Replace exact text in a UTF-8 file. Multiple matches require replace_all=true. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "input_schema": { "additionalProperties": false, "properties": { @@ -3376,7 +3261,7 @@ "name": "edit_file" }, { - "description": "Select one allowed, ready universe environment as this session's active environment. Environment-dependent tools must be called in a later turn.", + "description": "Select one attached environment as this session's active environment. The tool surface does not change; calls outside the active environment's access are rejected. Environment-dependent tools must be called in a later turn.", "input_schema": { "additionalProperties": false, "properties": { @@ -3402,39 +3287,16 @@ "name": "environment_deactivate" }, { - "description": "List the live universe environments allowed by this session. Use this before activation when you do not know the environment id. Registered environments carry a group, the name of the pool they registered under; filter by it to pick from one pool.", + "description": "List the environments attached to this session with their status, this session's access on each, and which one is active.", "input_schema": { "additionalProperties": false, - "properties": { - "cursor": { - "type": [ - "string", - "null" - ] - }, - "group": { - "description": "Only environments in this group (registered pool name).", - "minLength": 1, - "type": [ - "string", - "null" - ] - }, - "limit": { - "maximum": 100, - "minimum": 1, - "type": [ - "integer", - "null" - ] - } - }, + "properties": {}, "type": "object" }, "name": "environment_list" }, { - "description": "Read live details for an environment. Omit environment_id to inspect this session's active environment; provide a known id to inspect another environment allowed by the session.", + "description": "Read live details and this session's access for an environment. Omit environment_id to inspect the active environment; provide the id of another environment attached to this session to inspect it.", "input_schema": { "additionalProperties": false, "properties": { @@ -3451,7 +3313,7 @@ "name": "environment_read" }, { - "description": "Find files recursively with a glob pattern. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Find files recursively with a glob pattern. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "input_schema": { "additionalProperties": false, "properties": { @@ -3499,7 +3361,7 @@ "name": "glob" }, { - "description": "Search UTF-8 files recursively with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Search UTF-8 files recursively with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "input_schema": { "additionalProperties": false, "properties": { @@ -3558,7 +3420,7 @@ "name": "grep" }, { - "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "input_schema": { "additionalProperties": false, "properties": { @@ -3573,7 +3435,7 @@ "name": "list_dir" }, { - "description": "Read a UTF-8 file with optional 1-based line offset and line limit. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Read a UTF-8 file with optional 1-based line offset and line limit. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "input_schema": { "additionalProperties": false, "properties": { @@ -3614,7 +3476,7 @@ "name": "read_file" }, { - "description": "Run a command. Waits until it exits, or until `yield_ms` if set, and returns its output. If it is still running you get a handle for `continue_process`. With `timeout_ms` the command is killed at that deadline; without it a running command keeps running until stopped or the environment closes. Interactive programs need `tty: true`. Paths are resolved within the configured filesystem scope. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Run a command. Waits until it exits, or until `yield_ms` if set, and returns its output. If it is still running you get a handle for `continue_process`. With `timeout_ms` the command is killed at that deadline; without it a running command keeps running until stopped or the environment closes. Interactive programs need `tty: true`. Paths are resolved within the configured filesystem scope. Operates only in the active environment; attached VFS files are not implicitly available.", "input_schema": { "additionalProperties": false, "properties": { @@ -3698,7 +3560,7 @@ "cache_control": { "type": "ephemeral" }, - "description": "Write full UTF-8 file content, creating parent directories when needed. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Write full UTF-8 file content, creating parent directories when needed. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "input_schema": { "additionalProperties": false, "properties": { @@ -3907,7 +3769,7 @@ "name": "detach" }, { - "description": "Run one durable environment job and wait for its terminal readable result. Use job_submit for dependency groups, longer work, or explicit Promise control. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Run one durable environment job and wait for its terminal readable result. Use job_submit for dependency groups, longer work, or explicit Promise control. Operates only in the active environment; attached VFS files are not implicitly available.", "input_schema": { "additionalProperties": false, "description": "job_run", @@ -3968,7 +3830,7 @@ "name": "job_run" }, { - "description": "Start one or more durable environment jobs asynchronously. Returns one Promise per job; use await, cancel, or detach when appropriate. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Start one or more durable environment jobs asynchronously. Returns one Promise per job; use await, cancel, or detach when appropriate. Operates only in the active environment; attached VFS files are not implicitly available.", "input_schema": { "additionalProperties": false, "description": "job_submit", @@ -4107,7 +3969,7 @@ "tools": [ { "function": { - "description": "Apply a Codex-style apply_patch patch to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "Apply a Codex-style apply_patch patch to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "name": "vfs_apply_patch", "parameters": { "additionalProperties": false, @@ -4128,7 +3990,7 @@ }, { "function": { - "description": "Replace exact text in a UTF-8 file. Multiple matches require replace_all=true. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "Replace exact text in a UTF-8 file. Multiple matches require replace_all=true. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "name": "vfs_edit_file", "parameters": { "additionalProperties": false, @@ -4163,7 +4025,7 @@ }, { "function": { - "description": "Find files recursively with a glob pattern. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "Find files recursively with a glob pattern. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "name": "vfs_glob", "parameters": { "additionalProperties": false, @@ -4215,7 +4077,7 @@ }, { "function": { - "description": "Search UTF-8 files recursively with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "Search UTF-8 files recursively with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "name": "vfs_grep", "parameters": { "additionalProperties": false, @@ -4278,7 +4140,7 @@ }, { "function": { - "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "name": "vfs_list_dir", "parameters": { "additionalProperties": false, @@ -4297,7 +4159,7 @@ }, { "function": { - "description": "Read a UTF-8 file with optional 1-based line offset and line limit. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "Read a UTF-8 file with optional 1-based line offset and line limit. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "name": "vfs_read_file", "parameters": { "additionalProperties": false, @@ -4342,7 +4204,7 @@ }, { "function": { - "description": "Write full UTF-8 file content, creating parent directories when needed. Paths are resolved within the configured filesystem scope. Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands.", + "description": "Write full UTF-8 file content, creating parent directories when needed. Paths are resolved within the configured filesystem scope. Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands.", "name": "vfs_write_file", "parameters": { "additionalProperties": false, @@ -4382,7 +4244,7 @@ "tools": [ { "function": { - "description": "Apply a Codex-style apply_patch patch to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Apply a Codex-style apply_patch patch to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "apply_patch", "parameters": { "additionalProperties": false, @@ -4403,7 +4265,7 @@ }, { "function": { - "description": "Continue with a running handle: optionally send input or a signal, then wait up to `wait_ms` and return the output produced since the last call. With nothing but the handle it only waits. Once the process has exited it returns the remaining output and the exit code. Paths are resolved within the configured filesystem scope. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Continue with a running handle: optionally send input or a signal, then wait up to `wait_ms` and return the output produced since the last call. With nothing but the handle it only waits. Once the process has exited it returns the remaining output and the exit code. Paths are resolved within the configured filesystem scope. Operates only in the active environment; attached VFS files are not implicitly available.", "name": "continue_process", "parameters": { "additionalProperties": false, @@ -4474,7 +4336,7 @@ }, { "function": { - "description": "Replace exact text in a UTF-8 file. Multiple matches require replace_all=true. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Replace exact text in a UTF-8 file. Multiple matches require replace_all=true. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "edit_file", "parameters": { "additionalProperties": false, @@ -4509,7 +4371,7 @@ }, { "function": { - "description": "Select one allowed, ready universe environment as this session's active environment. Environment-dependent tools must be called in a later turn.", + "description": "Select one attached environment as this session's active environment. The tool surface does not change; calls outside the active environment's access are rejected. Environment-dependent tools must be called in a later turn.", "name": "environment_activate", "parameters": { "additionalProperties": false, @@ -4543,34 +4405,11 @@ }, { "function": { - "description": "List the live universe environments allowed by this session. Use this before activation when you do not know the environment id. Registered environments carry a group, the name of the pool they registered under; filter by it to pick from one pool.", + "description": "List the environments attached to this session with their status, this session's access on each, and which one is active.", "name": "environment_list", "parameters": { "additionalProperties": false, - "properties": { - "cursor": { - "type": [ - "string", - "null" - ] - }, - "group": { - "description": "Only environments in this group (registered pool name).", - "minLength": 1, - "type": [ - "string", - "null" - ] - }, - "limit": { - "maximum": 100, - "minimum": 1, - "type": [ - "integer", - "null" - ] - } - }, + "properties": {}, "type": "object" }, "strict": false @@ -4579,7 +4418,7 @@ }, { "function": { - "description": "Read live details for an environment. Omit environment_id to inspect this session's active environment; provide a known id to inspect another environment allowed by the session.", + "description": "Read live details and this session's access for an environment. Omit environment_id to inspect the active environment; provide the id of another environment attached to this session to inspect it.", "name": "environment_read", "parameters": { "additionalProperties": false, @@ -4600,7 +4439,7 @@ }, { "function": { - "description": "Find files recursively with a glob pattern. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Find files recursively with a glob pattern. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "glob", "parameters": { "additionalProperties": false, @@ -4652,7 +4491,7 @@ }, { "function": { - "description": "Search UTF-8 files recursively with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Search UTF-8 files recursively with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "grep", "parameters": { "additionalProperties": false, @@ -4715,7 +4554,7 @@ }, { "function": { - "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "list_dir", "parameters": { "additionalProperties": false, @@ -4734,7 +4573,7 @@ }, { "function": { - "description": "Read a UTF-8 file with optional 1-based line offset and line limit. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Read a UTF-8 file with optional 1-based line offset and line limit. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "read_file", "parameters": { "additionalProperties": false, @@ -4779,7 +4618,7 @@ }, { "function": { - "description": "Run a command. Waits until it exits, or until `yield_ms` if set, and returns its output. If it is still running you get a handle for `continue_process`. With `timeout_ms` the command is killed at that deadline; without it a running command keeps running until stopped or the environment closes. Interactive programs need `tty: true`. Paths are resolved within the configured filesystem scope. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Run a command. Waits until it exits, or until `yield_ms` if set, and returns its output. If it is still running you get a handle for `continue_process`. With `timeout_ms` the command is killed at that deadline; without it a running command keeps running until stopped or the environment closes. Interactive programs need `tty: true`. Paths are resolved within the configured filesystem scope. Operates only in the active environment; attached VFS files are not implicitly available.", "name": "run_process", "parameters": { "additionalProperties": false, @@ -4864,7 +4703,7 @@ }, { "function": { - "description": "Write full UTF-8 file content, creating parent directories when needed. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Write full UTF-8 file content, creating parent directories when needed. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "write_file", "parameters": { "additionalProperties": false, @@ -4904,7 +4743,7 @@ "tools": [ { "function": { - "description": "Apply a Codex-style apply_patch patch to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Apply a Codex-style apply_patch patch to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "apply_patch", "parameters": { "additionalProperties": false, @@ -4925,7 +4764,7 @@ }, { "function": { - "description": "Replace exact text in a UTF-8 file. Multiple matches require replace_all=true. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Replace exact text in a UTF-8 file. Multiple matches require replace_all=true. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "edit_file", "parameters": { "additionalProperties": false, @@ -4960,7 +4799,7 @@ }, { "function": { - "description": "Select one allowed, ready universe environment as this session's active environment. Environment-dependent tools must be called in a later turn.", + "description": "Select one attached environment as this session's active environment. The tool surface does not change; calls outside the active environment's access are rejected. Environment-dependent tools must be called in a later turn.", "name": "environment_activate", "parameters": { "additionalProperties": false, @@ -4994,34 +4833,11 @@ }, { "function": { - "description": "List the live universe environments allowed by this session. Use this before activation when you do not know the environment id. Registered environments carry a group, the name of the pool they registered under; filter by it to pick from one pool.", + "description": "List the environments attached to this session with their status, this session's access on each, and which one is active.", "name": "environment_list", "parameters": { "additionalProperties": false, - "properties": { - "cursor": { - "type": [ - "string", - "null" - ] - }, - "group": { - "description": "Only environments in this group (registered pool name).", - "minLength": 1, - "type": [ - "string", - "null" - ] - }, - "limit": { - "maximum": 100, - "minimum": 1, - "type": [ - "integer", - "null" - ] - } - }, + "properties": {}, "type": "object" }, "strict": false @@ -5030,7 +4846,7 @@ }, { "function": { - "description": "Read live details for an environment. Omit environment_id to inspect this session's active environment; provide a known id to inspect another environment allowed by the session.", + "description": "Read live details and this session's access for an environment. Omit environment_id to inspect the active environment; provide the id of another environment attached to this session to inspect it.", "name": "environment_read", "parameters": { "additionalProperties": false, @@ -5051,7 +4867,7 @@ }, { "function": { - "description": "Find files recursively with a glob pattern. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Find files recursively with a glob pattern. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "glob", "parameters": { "additionalProperties": false, @@ -5103,7 +4919,7 @@ }, { "function": { - "description": "Search UTF-8 files recursively with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Search UTF-8 files recursively with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "grep", "parameters": { "additionalProperties": false, @@ -5166,7 +4982,7 @@ }, { "function": { - "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "list_dir", "parameters": { "additionalProperties": false, @@ -5185,7 +5001,7 @@ }, { "function": { - "description": "Read a UTF-8 file with optional 1-based line offset and line limit. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Read a UTF-8 file with optional 1-based line offset and line limit. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "read_file", "parameters": { "additionalProperties": false, @@ -5230,7 +5046,7 @@ }, { "function": { - "description": "Run a command and wait until it exits, returning its output. With `timeout_ms` the command is killed at that deadline. A command may leave services running; they keep running until stopped or the environment closes. Interactive programs need `tty: true`. Paths are resolved within the configured filesystem scope. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Run a command and wait until it exits, returning its output. With `timeout_ms` the command is killed at that deadline. A command may leave services running; they keep running until stopped or the environment closes. Interactive programs need `tty: true`. Paths are resolved within the configured filesystem scope. Operates only in the active environment; attached VFS files are not implicitly available.", "name": "run_process", "parameters": { "additionalProperties": false, @@ -5303,7 +5119,7 @@ }, { "function": { - "description": "Write full UTF-8 file content, creating parent directories when needed. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Write full UTF-8 file content, creating parent directories when needed. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "write_file", "parameters": { "additionalProperties": false, @@ -5343,7 +5159,7 @@ "tools": [ { "function": { - "description": "Apply a Codex-style apply_patch patch to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Apply a Codex-style apply_patch patch to the filesystem. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "apply_patch", "parameters": { "additionalProperties": false, @@ -5364,7 +5180,7 @@ }, { "function": { - "description": "Continue with a running handle: optionally send input or a signal, then wait up to `wait_ms` and return the output produced since the last call. With nothing but the handle it only waits. Once the process has exited it returns the remaining output and the exit code. Paths are resolved within the configured filesystem scope. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Continue with a running handle: optionally send input or a signal, then wait up to `wait_ms` and return the output produced since the last call. With nothing but the handle it only waits. Once the process has exited it returns the remaining output and the exit code. Paths are resolved within the configured filesystem scope. Operates only in the active environment; attached VFS files are not implicitly available.", "name": "continue_process", "parameters": { "additionalProperties": false, @@ -5435,7 +5251,7 @@ }, { "function": { - "description": "Replace exact text in a UTF-8 file. Multiple matches require replace_all=true. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Replace exact text in a UTF-8 file. Multiple matches require replace_all=true. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "edit_file", "parameters": { "additionalProperties": false, @@ -5470,7 +5286,7 @@ }, { "function": { - "description": "Select one allowed, ready universe environment as this session's active environment. Environment-dependent tools must be called in a later turn.", + "description": "Select one attached environment as this session's active environment. The tool surface does not change; calls outside the active environment's access are rejected. Environment-dependent tools must be called in a later turn.", "name": "environment_activate", "parameters": { "additionalProperties": false, @@ -5504,34 +5320,11 @@ }, { "function": { - "description": "List the live universe environments allowed by this session. Use this before activation when you do not know the environment id. Registered environments carry a group, the name of the pool they registered under; filter by it to pick from one pool.", + "description": "List the environments attached to this session with their status, this session's access on each, and which one is active.", "name": "environment_list", "parameters": { "additionalProperties": false, - "properties": { - "cursor": { - "type": [ - "string", - "null" - ] - }, - "group": { - "description": "Only environments in this group (registered pool name).", - "minLength": 1, - "type": [ - "string", - "null" - ] - }, - "limit": { - "maximum": 100, - "minimum": 1, - "type": [ - "integer", - "null" - ] - } - }, + "properties": {}, "type": "object" }, "strict": false @@ -5540,7 +5333,7 @@ }, { "function": { - "description": "Read live details for an environment. Omit environment_id to inspect this session's active environment; provide a known id to inspect another environment allowed by the session.", + "description": "Read live details and this session's access for an environment. Omit environment_id to inspect the active environment; provide the id of another environment attached to this session to inspect it.", "name": "environment_read", "parameters": { "additionalProperties": false, @@ -5561,7 +5354,7 @@ }, { "function": { - "description": "Find files recursively with a glob pattern. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Find files recursively with a glob pattern. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "glob", "parameters": { "additionalProperties": false, @@ -5613,7 +5406,7 @@ }, { "function": { - "description": "Search UTF-8 files recursively with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Search UTF-8 files recursively with a regular expression. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "grep", "parameters": { "additionalProperties": false, @@ -5676,7 +5469,7 @@ }, { "function": { - "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "List one directory. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "list_dir", "parameters": { "additionalProperties": false, @@ -5695,7 +5488,7 @@ }, { "function": { - "description": "Read a UTF-8 file with optional 1-based line offset and line limit. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Read a UTF-8 file with optional 1-based line offset and line limit. Images (PNG, JPEG, GIF, WebP) and PDFs are shown to you as media and named by a media: handle you can reference. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "read_file", "parameters": { "additionalProperties": false, @@ -5740,7 +5533,7 @@ }, { "function": { - "description": "Run a command. Waits until it exits, or until `yield_ms` if set, and returns its output. If it is still running you get a handle for `continue_process`. With `timeout_ms` the command is killed at that deadline; without it a running command keeps running until stopped or the environment closes. Interactive programs need `tty: true`. Paths are resolved within the configured filesystem scope. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Run a command. Waits until it exits, or until `yield_ms` if set, and returns its output. If it is still running you get a handle for `continue_process`. With `timeout_ms` the command is killed at that deadline; without it a running command keeps running until stopped or the environment closes. Interactive programs need `tty: true`. Paths are resolved within the configured filesystem scope. Operates only in the active environment; attached VFS files are not implicitly available.", "name": "run_process", "parameters": { "additionalProperties": false, @@ -5825,7 +5618,7 @@ }, { "function": { - "description": "Write full UTF-8 file content, creating parent directories when needed. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify linked VFS files.", + "description": "Write full UTF-8 file content, creating parent directories when needed. Paths are resolved within the configured filesystem scope. Accesses only the active environment filesystem; it does not read or modify attached VFS files.", "name": "write_file", "parameters": { "additionalProperties": false, @@ -6067,7 +5860,7 @@ }, { "function": { - "description": "Run one durable environment job and wait for its terminal readable result. Use job_submit for dependency groups, longer work, or explicit Promise control. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Run one durable environment job and wait for its terminal readable result. Use job_submit for dependency groups, longer work, or explicit Promise control. Operates only in the active environment; attached VFS files are not implicitly available.", "name": "job_run", "parameters": { "additionalProperties": false, @@ -6132,7 +5925,7 @@ }, { "function": { - "description": "Start one or more durable environment jobs asynchronously. Returns one Promise per job; use await, cancel, or detach when appropriate. Operates only in the active environment; linked VFS files are not implicitly available.", + "description": "Start one or more durable environment jobs asynchronously. Returns one Promise per job; use await, cancel, or detach when appropriate. Operates only in the active environment; attached VFS files are not implicitly available.", "name": "job_submit", "parameters": { "additionalProperties": false, diff --git a/crates/llm-runtime/tests/live_config.rs b/crates/llm-runtime/tests/live_config.rs new file mode 100644 index 00000000..a9eadb04 --- /dev/null +++ b/crates/llm-runtime/tests/live_config.rs @@ -0,0 +1,97 @@ +//! Offline checks for shared live-test setup; never read credentials or call providers. +#[path = "support/config.rs"] +mod config; + +use std::env::VarError; + +fn lookup<'a>(values: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Result + 'a { + move |name| { + values + .iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| (*value).to_owned()) + .ok_or(VarError::NotPresent) + } +} + +#[test] +fn dotenv_lookup_skips_malformed_lines_and_preserves_values() { + let text = "# comment\n\nnot an assignment\n KEY = 'a=b'\nKEY=second\nEMPTY=\n"; + assert_eq!(config::dotenv_value(text, "KEY").as_deref(), Some("a=b")); + assert_eq!(config::dotenv_value(text, "EMPTY").as_deref(), Some("")); + assert_eq!(config::dotenv_value(text, "MISSING"), None); +} + +#[test] +fn dotenv_lookup_removes_only_matching_outer_quotes() { + for (input, expected) in [ + ("KEY=\"a'b\"", "a'b"), + ("KEY='a\"b'", "a\"b"), + ("KEY=\"unterminated", "\"unterminated"), + ("KEY=plain'", "plain'"), + ("KEY='\"nested\"'", "\"nested\""), + ] { + assert_eq!( + config::dotenv_value(input, "KEY").as_deref(), + Some(expected) + ); + } +} + +#[test] +fn completions_specific_credentials_and_endpoint_take_precedence() { + let config = config::openai_completions_config_with(lookup(&[ + ("OPENAI_API_KEY", "general"), + ("OPENAI_COMPLETIONS_API_KEY", "specific"), + ("OPENAI_BASE_URL", "https://general.example/v1"), + ("OPENAI_COMPLETIONS_BASE_URL", "https://specific.example/v1"), + ("OPENAI_ORG_ID", "org"), + ("OPENAI_PROJECT_ID", "project"), + ])); + assert_eq!(config.api_key.as_deref(), Some("specific")); + assert_eq!(config.base_url, "https://specific.example/v1"); + assert_eq!(config.organization.as_deref(), Some("org")); + assert_eq!(config.project.as_deref(), Some("project")); +} + +#[test] +fn completions_falls_back_to_shared_openai_settings() { + let config = config::openai_completions_config_with(lookup(&[ + ("OPENAI_API_KEY", "general"), + ("OPENAI_BASE_URL", "https://general.example/v1"), + ])); + assert_eq!(config.api_key.as_deref(), Some("general")); + assert_eq!(config.base_url, "https://general.example/v1"); +} + +#[test] +fn responses_uses_shared_openai_settings_even_when_completions_overrides_exist() { + let config = config::openai_responses_config_with(lookup(&[ + ("OPENAI_API_KEY", "general"), + ("OPENAI_COMPLETIONS_API_KEY", "specific"), + ("OPENAI_BASE_URL", "https://general.example/v1"), + ("OPENAI_COMPLETIONS_BASE_URL", "https://specific.example/v1"), + ("OPENAI_ORG_ID", "org"), + ("OPENAI_PROJECT_ID", "project"), + ])); + assert_eq!(config.api_key.as_deref(), Some("general")); + assert_eq!(config.base_url, "https://general.example/v1"); + assert_eq!(config.organization.as_deref(), Some("org")); + assert_eq!(config.project.as_deref(), Some("project")); +} + +#[test] +fn anthropic_preserves_endpoint_override_and_leaves_beta_selection_to_the_suite() { + let config = config::anthropic_messages_config_with(lookup(&[ + ("ANTHROPIC_API_KEY", "anthropic"), + ("ANTHROPIC_BASE_URL", "https://anthropic.example"), + ])); + assert_eq!(config.base_url, "https://anthropic.example"); + assert!(config.beta_headers.is_empty()); +} + +#[test] +#[should_panic(expected = "OPENAI_API_KEY is set but empty")] +fn empty_credentials_fail_instead_of_silently_skipping_live_tests() { + config::openai_responses_config_with(lookup(&[("OPENAI_API_KEY", " ")])); +} diff --git a/crates/llm-runtime/tests/openai_completions_skills_live.rs b/crates/llm-runtime/tests/openai_completions_skills_live.rs index 41c6ec1c..722c6214 100644 --- a/crates/llm-runtime/tests/openai_completions_skills_live.rs +++ b/crates/llm-runtime/tests/openai_completions_skills_live.rs @@ -104,9 +104,9 @@ async fn openai_completions_runtime_live_skill_catalog_exposes_relevant_skill_pa trust: SkillTrustLevel::Project, interface: None, dependencies: SkillDependencies::default(), - location: SkillLocation::LinkedWorkspace { + location: SkillLocation::AttachedWorkspace { workspace_id, - source_link_path: VfsPath::parse("/skills").expect("link path"), + source_attachment_path: VfsPath::parse("/skills").expect("attachment path"), skill_dir_path: VfsPath::parse("/skills/release-audit").expect("skill path"), skill_doc_path: VfsPath::parse("/skills/release-audit/SKILL.md") .expect("skill doc path"), diff --git a/crates/llm-runtime/tests/openai_responses_caching_live.rs b/crates/llm-runtime/tests/openai_responses_caching_live.rs index 8a37f9a7..af93adc1 100644 --- a/crates/llm-runtime/tests/openai_responses_caching_live.rs +++ b/crates/llm-runtime/tests/openai_responses_caching_live.rs @@ -2,7 +2,6 @@ //! the session id as `prompt_cache_key`, the second request reports most of //! the previous prompt as cached, and a superseded catalog keeps the hit. -use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -12,11 +11,14 @@ use engine::{ LlmGenerationStatus, LlmRequest, LlmUsage, ModelSelection, ProviderApiKind, RunId, SessionId, TurnId, storage::InMemoryBlobStore, }; -use llm_clients::openai::responses::{Client, Config}; use llm_runtime::{LlmGenerationAdapter, OpenAiResponsesLlmAdapter, OpenAiResponsesParams}; mod support; +use support::{ + openai_responses_live_client as live_client, openai_responses_live_model as live_model, +}; + use support::{ caching::{MIN_CACHED_SHARE, assert_cached_share, long_instructions}, openai_params, retrying_openai_responses_client, @@ -26,66 +28,6 @@ use support::{ /// misses right after the write is retried a few times before failing. const CACHE_READ_ATTEMPTS: usize = 3; -fn live_model() -> String { - env_or_dotenv_var("OPENAI_RESPONSES_MODEL") - .or_else(|_| env_or_dotenv_var("OPENAI_LIVE_MODEL")) - .unwrap_or_else(|_| "gpt-5.5".to_string()) -} - -fn live_client() -> Client { - let api_key = env_or_dotenv_var("OPENAI_API_KEY").expect( - "OPENAI_API_KEY must be set in env or root .env to run openai:responses caching live tests", - ); - assert!( - !api_key.trim().is_empty(), - "OPENAI_API_KEY is set but empty" - ); - let mut config = Config::new(api_key); - if let Ok(base_url) = env_or_dotenv_var("OPENAI_BASE_URL") { - config.base_url = base_url; - } - if let Ok(org_id) = env_or_dotenv_var("OPENAI_ORG_ID") { - config.organization = Some(org_id); - } - if let Ok(project) = env_or_dotenv_var("OPENAI_PROJECT_ID") { - config.project = Some(project); - } - Client::new(config).expect("OpenAI Responses client") -} - -fn env_or_dotenv_var(name: &str) -> Result { - match std::env::var(name) { - Ok(value) => Ok(value), - Err(env_error) => dotenv_var(name).ok_or(env_error), - } -} - -fn dotenv_var(name: &str) -> Option { - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2) - .expect("repo root") - .join(".env"); - let contents = std::fs::read_to_string(path).ok()?; - for line in contents.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let (key, value) = line.split_once('=')?; - if key.trim() == name { - return Some( - value - .trim() - .trim_matches('"') - .trim_matches('\'') - .to_string(), - ); - } - } - None -} - async fn text_blob(blobs: &InMemoryBlobStore, text: &str) -> BlobRef { blobs.insert_text(text).await } diff --git a/crates/llm-runtime/tests/openai_responses_compaction_live.rs b/crates/llm-runtime/tests/openai_responses_compaction_live.rs index 51f0be7d..4f255c55 100644 --- a/crates/llm-runtime/tests/openai_responses_compaction_live.rs +++ b/crates/llm-runtime/tests/openai_responses_compaction_live.rs @@ -1,4 +1,4 @@ -use std::{path::PathBuf, sync::Arc}; +use std::sync::Arc; use engine::{ BlobRef, CompactionPolicy, ContextCompactionStatus, ContextCompactionTrigger, ContextConfig, @@ -8,12 +8,13 @@ use engine::{ TokenEstimateQuality, storage::{BlobStore, CreateSession, InMemoryBlobStore, InMemorySessionStore, SessionStore}, }; -use llm_clients::openai::responses::{Client, Config}; use llm_runtime::{LlmAdapterRegistry, LlmRuntime, OpenAiResponsesLlmAdapter}; use test_support::{DriveCommand, RunnerQuiescence, RunnerStores, SessionRunner}; mod support; +use support::{env_or_dotenv_var, openai_responses_live_client as live_client}; + use support::retrying_openai_responses_client; const LIVE_MARKER: &str = "LIGHTSPEED-COMPACTION-LIVE-87421"; @@ -25,71 +26,6 @@ fn live_compaction_model() -> String { .unwrap_or_else(|_| "gpt-5.5".to_string()) } -fn live_client() -> Client { - let api_key = env_or_dotenv_var("OPENAI_API_KEY").expect( - "OPENAI_API_KEY must be set in env or root .env to run llm-runtime compaction live tests", - ); - assert!( - !api_key.trim().is_empty(), - "OPENAI_API_KEY is set but empty" - ); - - let mut config = Config::new(api_key); - if let Ok(base_url) = env_or_dotenv_var("OPENAI_BASE_URL") { - config.base_url = base_url; - } - if let Ok(org_id) = env_or_dotenv_var("OPENAI_ORG_ID") { - config.organization = Some(org_id); - } - if let Ok(project) = env_or_dotenv_var("OPENAI_PROJECT_ID") { - config.project = Some(project); - } - - Client::new(config).expect("OpenAI Responses client") -} - -fn env_or_dotenv_var(name: &str) -> Result { - match std::env::var(name) { - Ok(value) => Ok(value), - Err(env_error) => dotenv_var(name).ok_or(env_error), - } -} - -fn dotenv_var(name: &str) -> Option { - let contents = std::fs::read_to_string(root_dotenv_path()).ok()?; - for line in contents.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let (key, value) = line.split_once('=')?; - if key.trim() == name { - return Some(unquote_dotenv_value(value.trim())); - } - } - None -} - -fn root_dotenv_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2) - .expect("repo root") - .join(".env") -} - -fn unquote_dotenv_value(value: &str) -> String { - if value.len() >= 2 { - let bytes = value.as_bytes(); - if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"') - || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'') - { - return value[1..value.len() - 1].to_string(); - } - } - value.to_string() -} - #[tokio::test(flavor = "current_thread")] #[ignore = "requires OPENAI_API_KEY and a compaction-capable OpenAI Responses model (costs real money)"] async fn openai_responses_live_engine_prunes_and_reuses_provider_compaction() { diff --git a/crates/llm-runtime/tests/openai_responses_live.rs b/crates/llm-runtime/tests/openai_responses_live.rs index c4f869f6..c42c1a5d 100644 --- a/crates/llm-runtime/tests/openai_responses_live.rs +++ b/crates/llm-runtime/tests/openai_responses_live.rs @@ -1,4 +1,3 @@ -use std::path::PathBuf; use std::sync::Arc; use engine::{ @@ -9,7 +8,6 @@ use engine::{ ProviderApiKind, ProviderParams, RunId, SessionId, ToolChoice, TurnId, storage::{BlobStore, InMemoryBlobStore}, }; -use llm_clients::openai::responses::{Client, Config}; use llm_runtime::{ LlmGenerationAdapter, OpenAiResponsesLlmAdapter, OpenAiResponsesParams, params::{ @@ -22,13 +20,12 @@ use tools::web::search::{OpenAiResponsesWebSearchConfig, WebSearchContextSize, W mod support; -use support::retrying_openai_responses_client; +use support::{ + env_or_dotenv_var, openai_responses_live_client as live_client, + openai_responses_live_model as live_model, +}; -fn live_model() -> String { - env_or_dotenv_var("OPENAI_RESPONSES_MODEL") - .or_else(|_| env_or_dotenv_var("OPENAI_LIVE_MODEL")) - .unwrap_or_else(|_| "gpt-5.5".to_string()) -} +use support::retrying_openai_responses_client; fn live_web_search_model() -> String { env_or_dotenv_var("OPENAI_RESPONSES_WEB_SEARCH_MODEL").unwrap_or_else(|_| live_model()) @@ -38,71 +35,6 @@ fn live_compaction_model() -> String { env_or_dotenv_var("OPENAI_RESPONSES_COMPACTION_MODEL").unwrap_or_else(|_| "gpt-5.5".to_string()) } -fn live_client() -> Client { - let api_key = env_or_dotenv_var("OPENAI_API_KEY").expect( - "OPENAI_API_KEY must be set in env or root .env to run llm-runtime openai:responses live tests", - ); - assert!( - !api_key.trim().is_empty(), - "OPENAI_API_KEY is set but empty" - ); - - let mut config = Config::new(api_key); - if let Ok(base_url) = env_or_dotenv_var("OPENAI_BASE_URL") { - config.base_url = base_url; - } - if let Ok(org_id) = env_or_dotenv_var("OPENAI_ORG_ID") { - config.organization = Some(org_id); - } - if let Ok(project) = env_or_dotenv_var("OPENAI_PROJECT_ID") { - config.project = Some(project); - } - - Client::new(config).expect("OpenAI Responses client") -} - -fn env_or_dotenv_var(name: &str) -> Result { - match std::env::var(name) { - Ok(value) => Ok(value), - Err(env_error) => dotenv_var(name).ok_or(env_error), - } -} - -fn dotenv_var(name: &str) -> Option { - let contents = std::fs::read_to_string(root_dotenv_path()).ok()?; - for line in contents.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let (key, value) = line.split_once('=')?; - if key.trim() == name { - return Some(unquote_dotenv_value(value.trim())); - } - } - None -} - -fn root_dotenv_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2) - .expect("repo root") - .join(".env") -} - -fn unquote_dotenv_value(value: &str) -> String { - if value.len() >= 2 { - let bytes = value.as_bytes(); - if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"') - || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'') - { - return value[1..value.len() - 1].to_string(); - } - } - value.to_string() -} - async fn text_blob(blobs: &InMemoryBlobStore, text: &str) -> BlobRef { blobs.insert_text(text).await } diff --git a/crates/llm-runtime/tests/openai_responses_mcp_live.rs b/crates/llm-runtime/tests/openai_responses_mcp_live.rs index c77b6cbd..71d15862 100644 --- a/crates/llm-runtime/tests/openai_responses_mcp_live.rs +++ b/crates/llm-runtime/tests/openai_responses_mcp_live.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, path::PathBuf, sync::Arc}; +use std::{collections::BTreeMap, sync::Arc}; use engine::{ ContextConfig, ContextEntryInput, ContextEntryKind, ContextMessageRole, CoreAgentCommand, @@ -6,89 +6,21 @@ use engine::{ RunConfig, RunStatus, SessionConfig, SessionId, ToolKind, ToolName, ToolParallelism, ToolSpec, storage::{BlobStore, CreateSession, InMemoryBlobStore, InMemorySessionStore, SessionStore}, }; -use llm_clients::openai::responses::{Client, Config}; use llm_runtime::{LlmAdapterRegistry, LlmRuntime, OpenAiResponsesLlmAdapter}; use serde_json::Value; use test_support::{DriveCommand, RunnerQuiescence, RunnerStores, SessionRunner}; mod support; +use support::{ + openai_responses_live_client as live_client, openai_responses_live_model as live_model, +}; + use support::retrying_openai_responses_client; const MCP_TEST_SERVER_URL: &str = "https://mcpplaygroundonline.com/mcp-stateless-server"; const MCP_TEST_TOOL: &str = "which_protocol_era"; -fn live_model() -> String { - env_or_dotenv_var("OPENAI_RESPONSES_MODEL") - .or_else(|_| env_or_dotenv_var("OPENAI_LIVE_MODEL")) - .unwrap_or_else(|_| "gpt-5.5".to_string()) -} - -fn live_client() -> Client { - let api_key = env_or_dotenv_var("OPENAI_API_KEY").expect( - "OPENAI_API_KEY must be set in env or root .env to run llm-runtime OpenAI MCP live tests", - ); - assert!( - !api_key.trim().is_empty(), - "OPENAI_API_KEY is set but empty" - ); - - let mut config = Config::new(api_key); - if let Ok(base_url) = env_or_dotenv_var("OPENAI_BASE_URL") { - config.base_url = base_url; - } - if let Ok(org_id) = env_or_dotenv_var("OPENAI_ORG_ID") { - config.organization = Some(org_id); - } - if let Ok(project) = env_or_dotenv_var("OPENAI_PROJECT_ID") { - config.project = Some(project); - } - - Client::new(config).expect("OpenAI Responses client") -} - -fn env_or_dotenv_var(name: &str) -> Result { - match std::env::var(name) { - Ok(value) => Ok(value), - Err(env_error) => dotenv_var(name).ok_or(env_error), - } -} - -fn dotenv_var(name: &str) -> Option { - let contents = std::fs::read_to_string(root_dotenv_path()).ok()?; - for line in contents.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let (key, value) = line.split_once('=')?; - if key.trim() == name { - return Some(unquote_dotenv_value(value.trim())); - } - } - None -} - -fn root_dotenv_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2) - .expect("repo root") - .join(".env") -} - -fn unquote_dotenv_value(value: &str) -> String { - if value.len() >= 2 { - let bytes = value.as_bytes(); - if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"') - || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'') - { - return value[1..value.len() - 1].to_string(); - } - } - value.to_string() -} - #[tokio::test(flavor = "current_thread")] #[ignore = "requires OPENAI_API_KEY and public MCP server access (costs real money)"] async fn openai_responses_live_core_session_uses_public_remote_mcp() { diff --git a/crates/llm-runtime/tests/openai_responses_prompts_live.rs b/crates/llm-runtime/tests/openai_responses_prompts_live.rs index c718c94d..54c42af7 100644 --- a/crates/llm-runtime/tests/openai_responses_prompts_live.rs +++ b/crates/llm-runtime/tests/openai_responses_prompts_live.rs @@ -1,6 +1,5 @@ use std::{ collections::BTreeMap, - path::PathBuf, sync::{Arc, Mutex}, }; @@ -8,10 +7,9 @@ use async_trait::async_trait; use engine::{ ContextConfig, ContextEntryInput, ContextEntryKind, ContextMessageRole, CoreAgentCommand, CoreAgentEvent, ModelSelection, ProviderApiKind, RunConfig, RunStatus, SessionConfig, - SessionId, WorkspaceLink, WorkspaceLinkAccess, WorkspaceLinkTarget, + SessionId, WorkspaceAccess, WorkspaceAttachment, WorkspaceAttachmentTarget, storage::{BlobStore, CreateSession, InMemoryBlobStore, InMemorySessionStore, SessionStore}, }; -use llm_clients::openai::responses::{Client, Config}; use llm_runtime::{LlmAdapterRegistry, LlmRuntime, OpenAiResponsesLlmAdapter}; use test_support::{DriveCommand, RunnerQuiescence, RunnerStores, SessionRunner}; use tools::prompts::{PROMPT_INSTRUCTIONS_CONTEXT_KEY_PREFIX, active_prompt_instruction_entries}; @@ -23,6 +21,8 @@ use vfs::{ mod support; +use support::{env_or_dotenv_var, openai_responses_live_client as live_client}; + use support::retrying_openai_responses_client; const LIVE_PROMPT_MARKER: &str = "LIVE-PROMPT-AXIS-8642"; @@ -34,71 +34,6 @@ fn live_model() -> String { .unwrap_or_else(|_| "gpt-5.5".to_string()) } -fn live_client() -> Client { - let api_key = env_or_dotenv_var("OPENAI_API_KEY").expect( - "OPENAI_API_KEY must be set in env or root .env to run llm-runtime prompts live tests", - ); - assert!( - !api_key.trim().is_empty(), - "OPENAI_API_KEY is set but empty" - ); - - let mut config = Config::new(api_key); - if let Ok(base_url) = env_or_dotenv_var("OPENAI_BASE_URL") { - config.base_url = base_url; - } - if let Ok(org_id) = env_or_dotenv_var("OPENAI_ORG_ID") { - config.organization = Some(org_id); - } - if let Ok(project) = env_or_dotenv_var("OPENAI_PROJECT_ID") { - config.project = Some(project); - } - - Client::new(config).expect("OpenAI Responses client") -} - -fn env_or_dotenv_var(name: &str) -> Result { - match std::env::var(name) { - Ok(value) => Ok(value), - Err(env_error) => dotenv_var(name).ok_or(env_error), - } -} - -fn dotenv_var(name: &str) -> Option { - let contents = std::fs::read_to_string(root_dotenv_path()).ok()?; - for line in contents.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let (key, value) = line.split_once('=')?; - if key.trim() == name { - return Some(unquote_dotenv_value(value.trim())); - } - } - None -} - -fn root_dotenv_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2) - .expect("repo root") - .join(".env") -} - -fn unquote_dotenv_value(value: &str) -> String { - if value.len() >= 2 { - let bytes = value.as_bytes(); - if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"') - || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'') - { - return value[1..value.len() - 1].to_string(); - } - } - value.to_string() -} - #[derive(Default)] struct LiveVfsCatalog { workspaces: Mutex>, @@ -248,12 +183,12 @@ async fn openai_responses_live_uses_vfs_prompt_instructions() { }) .await .expect("create workspace"); - let workspace_links = vec![WorkspaceLink { + let workspace_attachments = vec![WorkspaceAttachment { path: "/workspace".to_owned(), - target: WorkspaceLinkTarget::Workspace { + target: WorkspaceAttachmentTarget::Workspace { workspace_id: workspace_id.to_string(), }, - access: WorkspaceLinkAccess::ReadWrite, + access: WorkspaceAccess::Edit, }]; let model = ModelSelection { @@ -278,7 +213,7 @@ async fn openai_responses_live_uses_vfs_prompt_instructions() { session_id: session_id.clone(), observed_at_ms: 10, command: CoreAgentCommand::OpenSession { - config: session_config(model, workspace_links), + config: session_config(model, workspace_attachments), }, max_steps: None, }) @@ -357,7 +292,10 @@ async fn openai_responses_live_uses_vfs_prompt_instructions() { ); } -fn session_config(model: ModelSelection, workspace_links: Vec) -> SessionConfig { +fn session_config( + model: ModelSelection, + workspace_attachments: Vec, +) -> SessionConfig { SessionConfig { model, generation: engine::GenerationConfig { @@ -371,7 +309,7 @@ fn session_config(model: ModelSelection, workspace_links: Vec) -> context: ContextConfig { compaction: None }, features: engine::FeaturesConfig { vfs: Some(engine::VfsFeature { - workspace_links, + workspaces: workspace_attachments, prompts: Some(engine::VfsPromptsConfig::default()), ..engine::VfsFeature::default() }), diff --git a/crates/llm-runtime/tests/openai_responses_skills_live.rs b/crates/llm-runtime/tests/openai_responses_skills_live.rs index bb6c989e..b8f0566a 100644 --- a/crates/llm-runtime/tests/openai_responses_skills_live.rs +++ b/crates/llm-runtime/tests/openai_responses_skills_live.rs @@ -1,6 +1,5 @@ use std::{ collections::BTreeMap, - path::PathBuf, sync::{Arc, Mutex}, }; @@ -8,101 +7,33 @@ use async_trait::async_trait; use engine::{ BlobRef, ContextConfig, ContextEntryInput, ContextEntryKind, ContextMessageRole, CoreAgentCommand, CoreAgentEvent, ModelSelection, ProviderApiKind, RunConfig, RunStatus, - SessionConfig, SessionId, WorkspaceLink, WorkspaceLinkAccess, WorkspaceLinkTarget, + SessionConfig, SessionId, WorkspaceAccess, WorkspaceAttachment, WorkspaceAttachmentTarget, storage::{BlobStore, CreateSession, InMemoryBlobStore, InMemorySessionStore, SessionStore}, }; -use llm_clients::openai::responses::{Client, Config}; use llm_runtime::{LlmAdapterRegistry, LlmRuntime, OpenAiResponsesLlmAdapter}; use test_support::{DriveCommand, RunnerQuiescence, RunnerStores, SessionRunner}; use tools::{ fs::tools::ReadFileResult, - fs::{FsPath, FsToolContext, LinkedVfsFileSystem}, + fs::{AttachedVfsFileSystem, FsPath, FsToolContext}, runtime::InlineToolRuntime, toolset::{ToolsetConfig, register_toolset}, }; use vfs::{ CompareAndSetVfsWorkspaceHead, CreateInlineSnapshotRequest, CreateVfsWorkspaceRecord, - InlineFile, ResolvedWorkspaceLink, ResolvedWorkspaceLinkTarget, VfsCatalogError, VfsPath, - VfsWorkspaceId, VfsWorkspaceRecord, VfsWorkspaceStore, create_inline_snapshot, + InlineFile, ResolvedWorkspaceAttachment, ResolvedWorkspaceAttachmentTarget, VfsCatalogError, + VfsPath, VfsWorkspaceId, VfsWorkspaceRecord, VfsWorkspaceStore, create_inline_snapshot, }; mod support; +use support::{ + openai_responses_live_client as live_client, openai_responses_live_model as live_model, +}; + use support::retrying_openai_responses_client; const LIVE_MARKER: &str = "LIVE-SKILL-MATRIX-7392"; -fn live_model() -> String { - env_or_dotenv_var("OPENAI_RESPONSES_MODEL") - .or_else(|_| env_or_dotenv_var("OPENAI_LIVE_MODEL")) - .unwrap_or_else(|_| "gpt-5.5".to_string()) -} - -fn live_client() -> Client { - let api_key = env_or_dotenv_var("OPENAI_API_KEY").expect( - "OPENAI_API_KEY must be set in env or root .env to run llm-runtime skills live tests", - ); - assert!( - !api_key.trim().is_empty(), - "OPENAI_API_KEY is set but empty" - ); - - let mut config = Config::new(api_key); - if let Ok(base_url) = env_or_dotenv_var("OPENAI_BASE_URL") { - config.base_url = base_url; - } - if let Ok(org_id) = env_or_dotenv_var("OPENAI_ORG_ID") { - config.organization = Some(org_id); - } - if let Ok(project) = env_or_dotenv_var("OPENAI_PROJECT_ID") { - config.project = Some(project); - } - - Client::new(config).expect("OpenAI Responses client") -} - -fn env_or_dotenv_var(name: &str) -> Result { - match std::env::var(name) { - Ok(value) => Ok(value), - Err(env_error) => dotenv_var(name).ok_or(env_error), - } -} - -fn dotenv_var(name: &str) -> Option { - let contents = std::fs::read_to_string(root_dotenv_path()).ok()?; - for line in contents.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let (key, value) = line.split_once('=')?; - if key.trim() == name { - return Some(unquote_dotenv_value(value.trim())); - } - } - None -} - -fn root_dotenv_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2) - .expect("repo root") - .join(".env") -} - -fn unquote_dotenv_value(value: &str) -> String { - if value.len() >= 2 { - let bytes = value.as_bytes(); - if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"') - || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'') - { - return value[1..value.len() - 1].to_string(); - } - } - value.to_string() -} - #[derive(Default)] struct LiveVfsCatalog { workspaces: Mutex>, @@ -247,27 +178,27 @@ async fn openai_responses_live_selects_and_reads_the_matching_skill() { ) .await .expect("create skill snapshot"); - let workspace_links = vec![WorkspaceLink { + let workspace_attachments = vec![WorkspaceAttachment { path: "/skills/system".to_owned(), - target: WorkspaceLinkTarget::Snapshot { + target: WorkspaceAttachmentTarget::Snapshot { snapshot_ref: snapshot.snapshot_ref.to_string(), }, - access: WorkspaceLinkAccess::ReadOnly, + access: WorkspaceAccess::Read, }]; - let linked_fs = LinkedVfsFileSystem::new( + let attached_fs = AttachedVfsFileSystem::new( blobs.clone(), vfs.clone(), - vec![ResolvedWorkspaceLink { + vec![ResolvedWorkspaceAttachment { path: VfsPath::parse("/skills/system").unwrap(), - target: ResolvedWorkspaceLinkTarget::AvailableSnapshot { + target: ResolvedWorkspaceAttachmentTarget::AvailableSnapshot { snapshot_ref: snapshot.snapshot_ref, }, - access: WorkspaceLinkAccess::ReadOnly, + access: WorkspaceAccess::Read, }], ) - .expect("linked fs"); - let fs_ctx = FsToolContext::new(Arc::new(linked_fs), blobs.clone()).with_cwd(FsPath::root()); + .expect("attached fs"); + let fs_ctx = FsToolContext::new(Arc::new(attached_fs), blobs.clone()).with_cwd(FsPath::root()); let model = ModelSelection { api_kind: ProviderApiKind::OpenAiResponses, provider_id: "openai".to_string(), @@ -296,7 +227,7 @@ async fn openai_responses_live_selects_and_reads_the_matching_skill() { session_id: session_id.clone(), observed_at_ms: 10, command: CoreAgentCommand::OpenSession { - config: session_config(model, workspace_links), + config: session_config(model, workspace_attachments), }, max_steps: None, }) @@ -395,7 +326,10 @@ async fn openai_responses_live_selects_and_reads_the_matching_skill() { ); } -fn session_config(model: ModelSelection, workspace_links: Vec) -> SessionConfig { +fn session_config( + model: ModelSelection, + workspace_attachments: Vec, +) -> SessionConfig { SessionConfig { model, generation: engine::GenerationConfig { @@ -411,14 +345,13 @@ fn session_config(model: ModelSelection, workspace_links: Vec) -> vfs: Some(engine::VfsFeature { skills: Some(engine::VfsSkillsConfig { roots: Some( - workspace_links + workspace_attachments .iter() - .map(|link| link.path.clone()) + .map(|attachment| attachment.path.clone()) .collect(), ), }), - workspace_links, - tools: Some(engine::VfsToolSurface::ReadOnly), + workspaces: workspace_attachments, ..engine::VfsFeature::default() }), ..engine::FeaturesConfig::default() diff --git a/crates/llm-runtime/tests/support/config.rs b/crates/llm-runtime/tests/support/config.rs new file mode 100644 index 00000000..f93e58e2 --- /dev/null +++ b/crates/llm-runtime/tests/support/config.rs @@ -0,0 +1,166 @@ +//! Provider setup shared by live suites; pure builders also support offline checks. +#![allow(dead_code)] + +use llm_clients::{ + anthropic::messages as am, + openai::{ + completions::{Client as CompletionsClient, Config as CompletionsConfig}, + responses as oai, + }, +}; +use std::{env::VarError, path::PathBuf}; + +pub fn openai_completions_live_model() -> String { + env_or_dotenv_var("OPENAI_COMPLETIONS_MODEL") + .or_else(|_| env_or_dotenv_var("OPENAI_LIVE_MODEL")) + .unwrap_or_else(|_| "gpt-5.5".to_owned()) +} + +pub fn openai_completions_live_client() -> CompletionsClient { + CompletionsClient::new(openai_completions_config_with(env_or_dotenv_var)) + .expect("OpenAI Completions client") +} + +pub(super) fn openai_completions_config_with( + lookup: impl Fn(&str) -> Result, +) -> CompletionsConfig { + let api_key = lookup("OPENAI_COMPLETIONS_API_KEY") + .or_else(|_| lookup("OPENAI_API_KEY")) + .expect( + "OPENAI_COMPLETIONS_API_KEY or OPENAI_API_KEY must be set in env or root .env to run openai:completions live tests", + ); + assert!(!api_key.trim().is_empty(), "OpenAI API key is empty"); + let mut config = CompletionsConfig::new(api_key); + if let Ok(base_url) = + lookup("OPENAI_COMPLETIONS_BASE_URL").or_else(|_| lookup("OPENAI_BASE_URL")) + { + config.base_url = base_url; + } + if let Ok(organization) = lookup("OPENAI_ORG_ID") { + config.organization = Some(organization); + } + if let Ok(project) = lookup("OPENAI_PROJECT_ID") { + config.project = Some(project); + } + config +} + +pub fn deepseek_completions_live_model() -> String { + env_or_dotenv_var("DEEPSEEK_COMPLETIONS_MODEL").unwrap_or_else(|_| "deepseek-v4-pro".to_owned()) +} + +pub fn env_or_dotenv_var(name: &str) -> Result { + match std::env::var(name) { + Ok(value) => Ok(value), + Err(env_error) => dotenv_var(name).ok_or(env_error), + } +} + +fn dotenv_var(name: &str) -> Option { + let contents = std::fs::read_to_string(root_dotenv_path()).ok()?; + dotenv_value(&contents, name) +} + +pub(super) fn dotenv_value(contents: &str, name: &str) -> Option { + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + if key.trim() == name { + return Some(unquote_dotenv_value(value.trim())); + } + } + None +} + +fn root_dotenv_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("repo root") + .join(".env") +} + +fn unquote_dotenv_value(value: &str) -> String { + if value.len() >= 2 { + let bytes = value.as_bytes(); + if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"') + || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'') + { + return value[1..value.len() - 1].to_owned(); + } + } + value.to_owned() +} + +pub fn openai_responses_live_model() -> String { + env_or_dotenv_var("OPENAI_RESPONSES_MODEL") + .or_else(|_| env_or_dotenv_var("OPENAI_LIVE_MODEL")) + .unwrap_or_else(|_| "gpt-5.5".to_string()) +} + +pub fn openai_responses_live_client() -> oai::Client { + oai::Client::new(openai_responses_config_with(env_or_dotenv_var)) + .expect("OpenAI Responses client") +} + +pub(super) fn openai_responses_config_with( + lookup: impl Fn(&str) -> Result, +) -> oai::Config { + let api_key = lookup("OPENAI_API_KEY").expect( + "OPENAI_API_KEY must be set in env or root .env to run llm-runtime openai:responses live tests", + ); + assert!( + !api_key.trim().is_empty(), + "OPENAI_API_KEY is set but empty" + ); + + let mut config = oai::Config::new(api_key); + if let Ok(base_url) = lookup("OPENAI_BASE_URL") { + config.base_url = base_url; + } + if let Ok(org_id) = lookup("OPENAI_ORG_ID") { + config.organization = Some(org_id); + } + if let Ok(project) = lookup("OPENAI_PROJECT_ID") { + config.project = Some(project); + } + + config +} + +pub fn anthropic_messages_live_model() -> String { + env_or_dotenv_var("ANTHROPIC_MESSAGES_MODEL") + .or_else(|_| env_or_dotenv_var("ANTHROPIC_LIVE_MODEL")) + .unwrap_or_else(|_| "claude-opus-5".to_string()) +} + +pub fn anthropic_messages_live_client() -> am::Client { + am::Client::new(anthropic_messages_live_config()).expect("Anthropic Messages client") +} + +pub fn anthropic_messages_live_config() -> am::Config { + anthropic_messages_config_with(env_or_dotenv_var) +} + +pub(super) fn anthropic_messages_config_with( + lookup: impl Fn(&str) -> Result, +) -> am::Config { + let api_key = lookup("ANTHROPIC_API_KEY").expect( + "ANTHROPIC_API_KEY must be set in env or root .env to run llm-runtime anthropic:messages live tests", + ); + assert!( + !api_key.trim().is_empty(), + "ANTHROPIC_API_KEY is set but empty" + ); + + let mut config = am::Config::new(api_key); + if let Ok(base_url) = lookup("ANTHROPIC_BASE_URL") { + config.base_url = base_url; + } + config +} diff --git a/crates/llm-runtime/tests/support/mod.rs b/crates/llm-runtime/tests/support/mod.rs index ed09bfbc..a20404de 100644 --- a/crates/llm-runtime/tests/support/mod.rs +++ b/crates/llm-runtime/tests/support/mod.rs @@ -1,4 +1,12 @@ -use std::{path::PathBuf, sync::Arc, time::Duration}; +mod config; +#[allow(unused_imports)] +pub use config::{ + anthropic_messages_live_client, anthropic_messages_live_config, anthropic_messages_live_model, + deepseek_completions_live_model, env_or_dotenv_var, openai_completions_live_client, + openai_completions_live_model, openai_responses_live_client, openai_responses_live_model, +}; + +use std::{sync::Arc, time::Duration}; #[allow(dead_code)] pub mod caching; @@ -11,10 +19,7 @@ use llm_clients::{ ApiResponse, LlmApiError, anthropic::messages::{self as am}, openai::{ - completions::{ - Client as CompletionsClient, Completion, Config as CompletionsConfig, - CreateCompletionRequest, - }, + completions::{Client as CompletionsClient, Completion, CreateCompletionRequest}, responses::{ Client, CompactResponse, CompactResponseRequest, CreateResponseRequest, Response, }, @@ -27,86 +32,6 @@ use llm_runtime::{ const MAX_LIVE_ATTEMPTS: usize = 3; -#[allow(dead_code)] -pub fn openai_completions_live_model() -> String { - env_or_dotenv_var("OPENAI_COMPLETIONS_MODEL") - .or_else(|_| env_or_dotenv_var("OPENAI_LIVE_MODEL")) - .unwrap_or_else(|_| "gpt-5.5".to_owned()) -} - -#[allow(dead_code)] -pub fn openai_completions_live_client() -> CompletionsClient { - let api_key = env_or_dotenv_var("OPENAI_COMPLETIONS_API_KEY") - .or_else(|_| env_or_dotenv_var("OPENAI_API_KEY")) - .expect( - "OPENAI_COMPLETIONS_API_KEY or OPENAI_API_KEY must be set in env or root .env to run openai:completions live tests", - ); - assert!(!api_key.trim().is_empty(), "OpenAI API key is empty"); - let mut config = CompletionsConfig::new(api_key); - if let Ok(base_url) = env_or_dotenv_var("OPENAI_COMPLETIONS_BASE_URL") - .or_else(|_| env_or_dotenv_var("OPENAI_BASE_URL")) - { - config.base_url = base_url; - } - if let Ok(organization) = env_or_dotenv_var("OPENAI_ORG_ID") { - config.organization = Some(organization); - } - if let Ok(project) = env_or_dotenv_var("OPENAI_PROJECT_ID") { - config.project = Some(project); - } - CompletionsClient::new(config).expect("OpenAI Completions client") -} - -#[allow(dead_code)] -pub fn deepseek_completions_live_model() -> String { - env_or_dotenv_var("DEEPSEEK_COMPLETIONS_MODEL").unwrap_or_else(|_| "deepseek-v4-pro".to_owned()) -} - -#[allow(dead_code)] -pub fn env_or_dotenv_var(name: &str) -> Result { - match std::env::var(name) { - Ok(value) => Ok(value), - Err(env_error) => dotenv_var(name).ok_or(env_error), - } -} - -fn dotenv_var(name: &str) -> Option { - let contents = std::fs::read_to_string(root_dotenv_path()).ok()?; - for line in contents.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let Some((key, value)) = line.split_once('=') else { - continue; - }; - if key.trim() == name { - return Some(unquote_dotenv_value(value.trim())); - } - } - None -} - -fn root_dotenv_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2) - .expect("repo root") - .join(".env") -} - -fn unquote_dotenv_value(value: &str) -> String { - if value.len() >= 2 { - let bytes = value.as_bytes(); - if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"') - || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'') - { - return value[1..value.len() - 1].to_owned(); - } - } - value.to_owned() -} - #[allow(dead_code)] pub fn openai_params(params: &OpenAiResponsesParams) -> ProviderParams { ProviderParams::new( diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index ad7c616c..73734c7f 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -250,8 +250,8 @@ pub struct McpServerRecord { pub allowed_tools: Option>, pub execution: McpExecution, pub exposure: McpExposure, - pub approval_default: McpApprovalPolicy, - pub defer_loading_default: Option, + pub approval: McpApprovalPolicy, + pub defer_loading: Option, pub allow_private_network: bool, pub auth_policy: McpServerAuthPolicy, /// Universe-owned grant used to authenticate this configured server. @@ -315,8 +315,8 @@ pub struct PutMcpServerRecord { pub allowed_tools: Option>, pub execution: McpExecution, pub exposure: McpExposure, - pub approval_default: McpApprovalPolicy, - pub defer_loading_default: Option, + pub approval: McpApprovalPolicy, + pub defer_loading: Option, pub allow_private_network: bool, pub auth_policy: McpServerAuthPolicy, pub auth_grant_id: Option, @@ -337,8 +337,8 @@ impl PutMcpServerRecord { allowed_tools: self.allowed_tools, execution: self.execution, exposure: self.exposure, - approval_default: self.approval_default, - defer_loading_default: self.defer_loading_default, + approval: self.approval, + defer_loading: self.defer_loading, allow_private_network: self.allow_private_network, auth_policy: self.auth_policy, auth_grant_id: self.auth_grant_id, @@ -381,8 +381,8 @@ impl PutMcpServerRecord { allowed_tools: self.allowed_tools, execution: self.execution, exposure: self.exposure, - approval_default: self.approval_default, - defer_loading_default: self.defer_loading_default, + approval: self.approval, + defer_loading: self.defer_loading, allow_private_network: self.allow_private_network, auth_policy: self.auth_policy, auth_grant_id: self.auth_grant_id, @@ -862,8 +862,8 @@ mod tests { allowed_tools: Some(vec!["hello".to_owned()]), execution: McpExecution::Provider, exposure: McpExposure::Inject, - approval_default: McpApprovalPolicy::Never, - defer_loading_default: Some(true), + approval: McpApprovalPolicy::Never, + defer_loading: Some(true), allow_private_network: false, auth_policy: McpServerAuthPolicy::None, auth_grant_id: None, diff --git a/crates/profiles/Cargo.toml b/crates/profiles/Cargo.toml index 05145dd3..194fe351 100644 --- a/crates/profiles/Cargo.toml +++ b/crates/profiles/Cargo.toml @@ -8,3 +8,6 @@ api = { path = "../api" } async-trait = "0.1" environment-protocol = { path = "../environment-protocol" } thiserror = "1" + +[dev-dependencies] +serde_json = "1" diff --git a/crates/profiles/src/lib.rs b/crates/profiles/src/lib.rs index 830e90db..aed0900d 100644 --- a/crates/profiles/src/lib.rs +++ b/crates/profiles/src/lib.rs @@ -4,8 +4,8 @@ //! This crate owns the runtime registry/store boundary around those DTOs. use api::{ - AgentProfile, AgentProfileInput, AgentProfileSummary, InlineAgentProfile, ProfileDocument, - ProfileEnvironment, ProfileId, ProfileInstructions, ProfileSource, + AgentProfile, AgentProfileInput, AgentProfileSummary, EnvironmentAttachment, + InlineAgentProfile, ProfileDocument, ProfileId, ProfileInstructions, ProfileSource, }; use async_trait::async_trait; use thiserror::Error; @@ -181,122 +181,40 @@ pub fn validate_profile_document(document: &ProfileDocument) -> Result<(), Profi if let Some(instructions) = &document.instructions { validate_profile_instructions(instructions)?; } - if let Some(environment) = &document.environment { - validate_profile_environment(environment)?; + if let Some(attachments) = document + .config + .as_ref() + .and_then(|config| config.features.as_ref()) + .and_then(|features| features.environments.as_ref()) + .map(|environments| environments.environments.as_slice()) + { + validate_environment_attachments(attachments)?; } Ok(()) } -fn validate_profile_environment(environment: &ProfileEnvironment) -> Result<(), ProfileError> { - match environment { - ProfileEnvironment::Existing { environment_id } => { - validate_nonempty_string("environment.environmentId", environment_id) - } - ProfileEnvironment::Inherit {} => Ok(()), - ProfileEnvironment::Provision { - provider_id, - template_id, - display_name, - metadata, - retention: _, - idle_policy, - credentials, - } => { - validate_nonempty_string("environment.providerId", provider_id)?; - validate_nonempty_string("environment.templateId", template_id)?; - validate_nonempty_optional("environment.displayName", display_name.as_deref())?; - for (key, value) in metadata { - validate_nonempty_string("environment.metadata key", key)?; - validate_nonempty_string("environment.metadata value", value)?; - } - if let Some(policy) = idle_policy { - validate_idle_policy(policy)?; - } - validate_profile_environment_credentials(credentials)?; - Ok(()) - } - } -} - -/// Credential bindings a profile requests for its provisioned environment: -/// valid, unique env names and non-empty source ids. Whether the referenced -/// grant/provider/secret exists is checked by the applier in the universe. -fn validate_profile_environment_credentials( - credentials: &[api::ProfileEnvironmentCredential], +/// The profile-level attachment rules: each attachment names exactly one +/// machine (an id or `inherit`) and at most one attachment inherits. The +/// engine validates the concrete list (unique ids, one default, absolute +/// working directories) once the document is applied to a session. +pub fn validate_environment_attachments( + attachments: &[EnvironmentAttachment], ) -> Result<(), ProfileError> { - let mut seen = std::collections::BTreeSet::new(); - for credential in credentials { - if !is_valid_env_name(&credential.env_name) { - return Err(ProfileError::InvalidInput { - message: format!( - "environment.credentials[].envName {:?} is not a valid environment variable name", - credential.env_name - ), - }); - } - if !seen.insert(credential.env_name.as_str()) { - return Err(ProfileError::InvalidInput { - message: format!( - "environment.credentials[].envName {} is bound more than once", - credential.env_name - ), - }); - } - let source_id = match &credential.source { - api::EnvironmentCredentialSourceView::AuthGrant { grant_id } => grant_id.as_str(), - api::EnvironmentCredentialSourceView::AuthProviderCredential { provider_id } => { - provider_id.as_str() + let mut inherits = 0; + for attachment in attachments { + match (&attachment.environment_id, attachment.inherit) { + (Some(_), true) | (None, false) => { + return Err(ProfileError::InvalidInput { + message: "each environment attachment must set exactly one of environmentId and inherit".to_owned(), + }); } - api::EnvironmentCredentialSourceView::DirectSecret { secret_id } => secret_id.as_str(), - }; - validate_nonempty_string("environment.credentials[].source", source_id)?; - } - Ok(()) -} - -fn is_valid_env_name(value: &str) -> bool { - let mut chars = value.chars(); - let Some(first) = chars.next() else { - return false; - }; - (first.is_ascii_alphabetic() || first == '_') - && value.len() <= 128 - && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') -} - -/// Idle-policy shape rules shared with `environments/create`: every stage -/// positive and non-decreasing in the order pause, suspend, stop, close. -fn validate_idle_policy(policy: &api::EnvironmentIdlePolicyView) -> Result<(), ProfileError> { - let stages = [ - ("pauseAfterMs", policy.pause_after_ms), - ("suspendAfterMs", policy.suspend_after_ms), - ("stopAfterMs", policy.stop_after_ms), - ("closeAfterMs", policy.close_after_ms), - ]; - let mut previous: Option<(&str, u64)> = None; - let mut any = false; - for (name, threshold) in stages { - let Some(threshold) = threshold else { - continue; - }; - any = true; - if threshold == 0 { - return Err(ProfileError::InvalidInput { - message: format!("environment.idlePolicy.{name} must be positive"), - }); + (None, true) => inherits += 1, + (Some(_), false) => {} } - if let Some((earlier, earlier_threshold)) = previous - && threshold < earlier_threshold - { - return Err(ProfileError::InvalidInput { - message: format!("environment.idlePolicy.{name} must not be below {earlier}"), - }); - } - previous = Some((name, threshold)); } - if !any { + if inherits > 1 { return Err(ProfileError::InvalidInput { - message: "environment.idlePolicy must set at least one stage".to_owned(), + message: "at most one environment attachment may inherit".to_owned(), }); } Ok(()) @@ -346,8 +264,6 @@ fn validate_nonnegative_i64(name: &str, value: i64) -> Result<(), ProfileError> mod tests { use std::collections::BTreeMap; - use api::ProfileEnvironmentRetention; - use super::*; #[test] @@ -367,17 +283,51 @@ mod tests { } #[test] - fn document_validation_rejects_empty_existing_environment_id() { - let empty_environment = ProfileDocument { - environment: Some(ProfileEnvironment::Existing { - environment_id: String::new(), - }), - ..ProfileDocument::default() - }; - assert!(matches!( - validate_profile_document(&empty_environment), - Err(ProfileError::InvalidInput { message }) if message.contains("environment.environmentId") - )); + fn document_validation_checks_environment_attachment_identity() { + fn document(environments: Vec) -> ProfileDocument { + ProfileDocument { + config: Some(api::SessionConfig { + features: Some(api::FeaturesConfig { + environments: Some(api::EnvironmentsFeature { + version: api::CURRENT_FEATURE_VERSION, + selection: false, + prompts: None, + skills: None, + environments, + }), + ..Default::default() + }), + ..Default::default() + }), + ..ProfileDocument::default() + } + } + fn attachment(id: Option<&str>, inherit: bool) -> EnvironmentAttachment { + EnvironmentAttachment { + environment_id: id.map(str::to_owned), + inherit, + default: false, + access: api::EnvironmentAccess::Read, + working_directory: None, + } + } + assert!( + validate_profile_document(&document(vec![ + attachment(Some("env_a"), false), + attachment(None, true), + ])) + .is_ok() + ); + for invalid in [ + vec![attachment(Some("env_a"), true)], + vec![attachment(None, false)], + vec![attachment(None, true), attachment(None, true)], + ] { + assert!(matches!( + validate_profile_document(&document(invalid)), + Err(ProfileError::InvalidInput { .. }) + )); + } } #[test] @@ -421,125 +371,6 @@ mod tests { )); } - #[test] - fn document_validation_checks_provision_environment_fields() { - let provision = - |provider_id: &str, template_id: &str, metadata: BTreeMap| { - ProfileDocument { - environment: Some(ProfileEnvironment::Provision { - provider_id: provider_id.to_owned(), - template_id: template_id.to_owned(), - display_name: None, - metadata, - retention: ProfileEnvironmentRetention::default(), - idle_policy: None, - credentials: Vec::new(), - }), - ..ProfileDocument::default() - } - }; - let with_policy = |policy: api::EnvironmentIdlePolicyView| ProfileDocument { - environment: Some(ProfileEnvironment::Provision { - provider_id: "incus".to_owned(), - template_id: "dev-small-v1".to_owned(), - display_name: None, - metadata: BTreeMap::new(), - retention: ProfileEnvironmentRetention::default(), - idle_policy: Some(policy), - credentials: Vec::new(), - }), - ..ProfileDocument::default() - }; - assert!( - validate_profile_document(&with_policy(api::EnvironmentIdlePolicyView { - pause_after_ms: Some(60_000), - close_after_ms: Some(3_600_000), - ..api::EnvironmentIdlePolicyView::default() - })) - .is_ok() - ); - assert!(matches!( - validate_profile_document(&with_policy(api::EnvironmentIdlePolicyView::default())), - Err(ProfileError::InvalidInput { message }) if message.contains("idlePolicy") - )); - assert!(matches!( - validate_profile_document(&with_policy(api::EnvironmentIdlePolicyView { - pause_after_ms: Some(60_000), - stop_after_ms: Some(1_000), - ..api::EnvironmentIdlePolicyView::default() - })), - Err(ProfileError::InvalidInput { message }) if message.contains("stopAfterMs") - )); - assert!( - validate_profile_document(&provision("incus", "dev-small-v1", BTreeMap::new())).is_ok() - ); - assert!(matches!( - validate_profile_document(&provision(" ", "dev-small-v1", BTreeMap::new())), - Err(ProfileError::InvalidInput { message }) if message.contains("environment.providerId") - )); - assert!(matches!( - validate_profile_document(&provision("incus", "", BTreeMap::new())), - Err(ProfileError::InvalidInput { message }) if message.contains("environment.templateId") - )); - assert!(matches!( - validate_profile_document(&provision( - "incus", - "dev-small-v1", - BTreeMap::from([("role".to_owned(), String::new())]) - )), - Err(ProfileError::InvalidInput { message }) if message.contains("environment.metadata value") - )); - assert_eq!( - ProfileEnvironmentRetention::default(), - ProfileEnvironmentRetention::CloseWithSession - ); - } - - #[test] - fn document_validation_checks_provision_environment_credentials() { - let with_credentials = - |credentials: Vec| ProfileDocument { - environment: Some(ProfileEnvironment::Provision { - provider_id: "incus".to_owned(), - template_id: "dev-small-v1".to_owned(), - display_name: None, - metadata: BTreeMap::new(), - retention: ProfileEnvironmentRetention::default(), - idle_policy: None, - credentials, - }), - ..ProfileDocument::default() - }; - let grant = |env_name: &str, grant_id: &str| api::ProfileEnvironmentCredential { - env_name: env_name.to_owned(), - source: api::EnvironmentCredentialSourceView::AuthGrant { - grant_id: grant_id.to_owned(), - }, - }; - assert!( - validate_profile_document(&with_credentials(vec![ - grant("CLAUDE_CODE_OAUTH_TOKEN", "authgrant_1"), - grant("GITHUB_TOKEN", "authgrant_2"), - ])) - .is_ok() - ); - assert!(matches!( - validate_profile_document(&with_credentials(vec![grant("1BAD", "authgrant_1")])), - Err(ProfileError::InvalidInput { message }) if message.contains("envName") - )); - assert!(matches!( - validate_profile_document(&with_credentials(vec![ - grant("GITHUB_TOKEN", "authgrant_1"), - grant("GITHUB_TOKEN", "authgrant_2"), - ])), - Err(ProfileError::InvalidInput { message }) if message.contains("more than once") - )); - assert!(matches!( - validate_profile_document(&with_credentials(vec![grant("GITHUB_TOKEN", " ")])), - Err(ProfileError::InvalidInput { message }) if message.contains("credentials[].source") - )); - } - #[test] fn inline_source_validation_rejects_empty_instruction_text() { let source = ProfileSource::Inline { diff --git a/crates/store-pg/migrations/010_independent_environment_lifecycle.sql b/crates/store-pg/migrations/010_independent_environment_lifecycle.sql new file mode 100644 index 00000000..28f08644 --- /dev/null +++ b/crates/store-pg/migrations/010_independent_environment_lifecycle.sql @@ -0,0 +1,9 @@ +-- Environments have independent lifecycles. Existing resources remain intact; +-- sessions no longer create them or trigger their closure. +DROP INDEX IF EXISTS environments_origin_session_idx; +DROP INDEX IF EXISTS environments_close_with_session_idx; +ALTER TABLE environments + DROP CONSTRAINT IF EXISTS environments_origin_session_shape, + DROP COLUMN origin_session_id, + DROP COLUMN origin_profile_id, + DROP COLUMN origin_close_with_session; diff --git a/crates/store-pg/src/environment.rs b/crates/store-pg/src/environment.rs index 605f0bfc..63660f7b 100644 --- a/crates/store-pg/src/environment.rs +++ b/crates/store-pg/src/environment.rs @@ -5,16 +5,15 @@ use environments::{ AdoptEnvironment, BeginCloseEnvironment, CreateEnvironment, CreateExternalEnvironment, CreateRegisteredEnvironment, EnvironmentCredentialRecord, EnvironmentCredentialSource, EnvironmentCredentialStore, EnvironmentDaemonId, EnvironmentId, EnvironmentIncarnationId, - EnvironmentIncarnationRecord, EnvironmentOriginSession, EnvironmentProviderBindingId, - EnvironmentProviderBindingRecord, EnvironmentProviderBindingStatus, - EnvironmentProviderBindingStore, EnvironmentProviderId, EnvironmentProviderRecord, - EnvironmentProviderStore, EnvironmentProvisionRequestId, EnvironmentRecord, - EnvironmentRegistrationKeyId, EnvironmentRegistryError, EnvironmentSource, EnvironmentStatus, - EnvironmentStore, EnvironmentTemplateId, FailEnvironmentLifecycle, FinishCloseEnvironment, - ListEnvironmentCredentials, ListEnvironmentProviders, ListEnvironments, + EnvironmentIncarnationRecord, EnvironmentProviderBindingId, EnvironmentProviderBindingRecord, + EnvironmentProviderBindingStatus, EnvironmentProviderBindingStore, EnvironmentProviderId, + EnvironmentProviderRecord, EnvironmentProviderStore, EnvironmentProvisionRequestId, + EnvironmentRecord, EnvironmentRegistrationKeyId, EnvironmentRegistryError, EnvironmentSource, + EnvironmentStatus, EnvironmentStore, EnvironmentTemplateId, FailEnvironmentLifecycle, + FinishCloseEnvironment, ListEnvironmentCredentials, ListEnvironmentProviders, ListEnvironments, ObserveProvisionedEnvironment, ObserveRegisteredEnvironment, PowerState, PutEnvironmentCredential, PutEnvironmentProvider, PutEnvironmentProviderBinding, - RegisteredConnectionObservation, RegisteredIdentityMode, SessionId, SetEnvironmentIdlePolicy, + RegisteredConnectionObservation, RegisteredIdentityMode, SetEnvironmentIdlePolicy, SetEnvironmentIngress, SetEnvironmentPower, }; use sqlx::{Postgres, Row, Transaction}; @@ -37,7 +36,6 @@ const ENVIRONMENT_COLUMNS: &str = r#" e.registration_key_id, e.daemon_id, e.daemon_public_key, e.identity_mode, e.last_seen_at_ms, e.display_name, e.status, e.desired_power, e.idle_policy_json, e.public_ingress_enabled, e.public_endpoint, e.metadata_json, - e.origin_session_id, e.origin_profile_id, e.origin_close_with_session, e.created_at_ms, e.updated_at_ms, i.incarnation_id, i.provision_request_id, i.provider_target_id, i.template_id, i.adoption_source_target, i.power_states_json, @@ -346,9 +344,8 @@ impl EnvironmentStore for PgStore { INSERT INTO environments ( universe_id, environment_id, request_id, source_kind, provider_id, binding_id, display_name, status, current_incarnation_id, metadata_json, - origin_session_id, origin_profile_id, origin_close_with_session, idle_policy_json, created_at_ms, updated_at_ms - ) VALUES ($1,$2,$3,'provisioned',$4,$5,$6,'provisioning',$7,$8,$9,$10,$11,$12,$13,$13) + ) VALUES ($1,$2,$3,'provisioned',$4,$5,$6,'provisioning',$7,$8,$9,$10,$10) "#, ) .bind(self.config.universe_id) @@ -362,24 +359,6 @@ impl EnvironmentStore for PgStore { "encode environment metadata", &request.metadata, )?) - .bind( - request - .origin_session - .as_ref() - .map(|origin| origin.session_id.as_str().to_owned()), - ) - .bind( - request - .origin_session - .as_ref() - .and_then(|origin| origin.profile_id.clone()), - ) - .bind( - request - .origin_session - .as_ref() - .is_some_and(|origin| origin.close_with_session), - ) .bind( request .idle_policy @@ -546,7 +525,6 @@ impl EnvironmentStore for PgStore { }, public_ingress_enabled: false, public_endpoint: None, - origin_session: None, metadata: request.metadata.clone(), last_seen_at_ms: None, created_at_ms: request.created_at_ms, @@ -853,10 +831,6 @@ impl EnvironmentStore for PgStore { query.push_str(&format!(" AND e.status = ${next}")); next += 1; } - if request.origin_session_id.is_some() { - query.push_str(&format!(" AND e.origin_session_id = ${next}")); - next += 1; - } if request.registration_key_id.is_some() { query.push_str(&format!(" AND e.registration_key_id = ${next}")); next += 1; @@ -876,9 +850,6 @@ impl EnvironmentStore for PgStore { if let Some(status) = request.status { sql = sql.bind(environment_status_to_str(status)); } - if let Some(session_id) = request.origin_session_id { - sql = sql.bind(session_id.as_str().to_owned()); - } if let Some(id) = request.registration_key_id { sql = sql.bind(id.to_string()); } @@ -906,20 +877,6 @@ impl EnvironmentStore for PgStore { rows.iter().map(environment_from_row).collect() } - async fn list_environments_closing_with_session( - &self, - ) -> Result, EnvironmentRegistryError> { - let query = format!( - "SELECT {ENVIRONMENT_COLUMNS} {ENVIRONMENT_JOIN} WHERE e.universe_id = $1 AND e.origin_close_with_session = true AND e.status NOT IN ('closing','closed') ORDER BY e.updated_at_ms, e.environment_id" - ); - let rows = sqlx::query(&query) - .bind(self.config.universe_id) - .fetch_all(&self.pool) - .await - .map_err(|error| sql_error("list environments closing with session", error))?; - rows.iter().map(environment_from_row).collect() - } - async fn list_environments_with_idle_policy( &self, ) -> Result, EnvironmentRegistryError> { @@ -1305,7 +1262,6 @@ fn environment_from_row( public_endpoint: row .try_get("public_endpoint") .map_err(|e| sql_error("decode public endpoint", e))?, - origin_session: origin_session_from_row(row)?, metadata: json_column(row, "metadata_json")?, last_seen_at_ms: row .try_get("last_seen_at_ms") @@ -1324,27 +1280,6 @@ pub(crate) fn identity_mode_from_str( .ok_or_else(|| store_message(format!("unknown identity mode: {value}"))) } -fn origin_session_from_row( - row: &sqlx::postgres::PgRow, -) -> Result, EnvironmentRegistryError> { - let session_id: Option = row - .try_get("origin_session_id") - .map_err(|e| sql_error("decode origin session id", e))?; - let Some(session_id) = session_id else { - return Ok(None); - }; - Ok(Some(EnvironmentOriginSession { - session_id: SessionId::try_new(session_id) - .map_err(|e| store_message(format!("decode origin session id: {e}")))?, - profile_id: row - .try_get("origin_profile_id") - .map_err(|e| sql_error("decode origin profile id", e))?, - close_with_session: row - .try_get("origin_close_with_session") - .map_err(|e| sql_error("decode origin close-with-session", e))?, - })) -} - fn credential_from_row( row: &sqlx::postgres::PgRow, ) -> Result { diff --git a/crates/store-pg/src/lib.rs b/crates/store-pg/src/lib.rs index 968e714b..9c21d144 100644 --- a/crates/store-pg/src/lib.rs +++ b/crates/store-pg/src/lib.rs @@ -371,11 +371,7 @@ pub async fn list_universes(pool: &PgPool) -> Result)> pub async fn list_universes_with_pending_environments( pool: &PgPool, ) -> Result, PgStoreError> { - // Universes with lifecycle work in flight, plus universes holding an open - // profile-provisioned environment that closes with its session (the - // sweep decides per environment whether that session is closed), plus - // universes with an open registered environment (stale-heartbeat - // repair and ephemeral disconnect cleanup). + // Include pending provider work and registered-environment heartbeat repair. let rows: Vec<(Uuid,)> = sqlx::query_as( "SELECT DISTINCT e.universe_id FROM environments e \ WHERE e.status IN ('provisioning','booting','closing','unknown') \ @@ -385,13 +381,6 @@ pub async fn list_universes_with_pending_environments( OR (e.status = 'paused' AND e.desired_power <> 'paused') \ OR (e.status = 'suspended' AND e.desired_power <> 'suspended') \ OR (e.status = 'offline' AND e.desired_power <> 'stopped'))) \ - OR (e.origin_close_with_session = true \ - AND e.status NOT IN ('closing','closed') \ - AND NOT EXISTS ( \ - SELECT 1 FROM sessions s \ - WHERE s.universe_id = e.universe_id \ - AND s.session_id = e.origin_session_id \ - AND s.lifecycle_status <> 'closed')) \ ORDER BY e.universe_id", ) .fetch_all(pool) diff --git a/crates/store-pg/src/mcp.rs b/crates/store-pg/src/mcp.rs index 2d1d9d60..5c6719cf 100644 --- a/crates/store-pg/src/mcp.rs +++ b/crates/store-pg/src/mcp.rs @@ -299,8 +299,8 @@ impl PgStore { .bind(record.allowed_tools.as_deref()) .bind(execution_to_str(record.execution)) .bind(exposure_to_str(record.exposure)) - .bind(approval_policy_to_str(record.approval_default)) - .bind(record.defer_loading_default) + .bind(approval_policy_to_str(record.approval)) + .bind(record.defer_loading) .bind(record.allow_private_network) .bind(auth_policy) .bind(auth_metadata_json) @@ -387,8 +387,8 @@ impl PgStore { .bind(replaced.allowed_tools.as_deref()) .bind(execution_to_str(replaced.execution)) .bind(exposure_to_str(replaced.exposure)) - .bind(approval_policy_to_str(replaced.approval_default)) - .bind(replaced.defer_loading_default) + .bind(approval_policy_to_str(replaced.approval)) + .bind(replaced.defer_loading) .bind(replaced.allow_private_network) .bind(auth_policy) .bind(auth_metadata_json) @@ -418,7 +418,7 @@ fn server_record_from_row( let transport: String = row .try_get("transport") .map_err(|error| mcp_sql_error("decode mcp transport", error))?; - let approval_default: String = row + let approval: String = row .try_get("approval_default") .map_err(|error| mcp_sql_error("decode mcp approval default", error))?; let execution: String = row @@ -465,8 +465,8 @@ fn server_record_from_row( .map_err(|error| mcp_sql_error("decode mcp allowed tools", error))?, execution: execution_from_str(&execution)?, exposure: exposure_from_str(&exposure)?, - approval_default: approval_policy_from_str(&approval_default)?, - defer_loading_default: row + approval: approval_policy_from_str(&approval)?, + defer_loading: row .try_get("defer_loading_default") .map_err(|error| mcp_sql_error("decode mcp defer loading default", error))?, allow_private_network: row diff --git a/crates/store-pg/src/migrations.rs b/crates/store-pg/src/migrations.rs index 43631741..f037c817 100644 --- a/crates/store-pg/src/migrations.rs +++ b/crates/store-pg/src/migrations.rs @@ -101,9 +101,14 @@ pub const MIGRATIONS: &[EmbeddedMigration] = &[ name: "channels", sql: include_str!("../migrations/009_channels.sql"), }, + EmbeddedMigration { + version: 10, + name: "independent_environment_lifecycle", + sql: include_str!("../migrations/010_independent_environment_lifecycle.sql"), + }, ]; -pub const REQUIRED_SCHEMA_REVISION: i64 = 9; +pub const REQUIRED_SCHEMA_REVISION: i64 = 10; #[derive(Clone, Debug, PartialEq, Eq)] pub struct SchemaStatus { diff --git a/crates/store-pg/tests/store_pg_live.rs b/crates/store-pg/tests/store_pg_live.rs index 30c0f468..562bd6bf 100644 --- a/crates/store-pg/tests/store_pg_live.rs +++ b/crates/store-pg/tests/store_pg_live.rs @@ -1920,7 +1920,7 @@ async fn pg_live_mcp_crud_and_universe_isolation() { let mut replacement = put_mcp_server("crm", McpServerStatus::Disabled); replacement.server_url = "https://crm2.example.com/mcp".to_owned(); replacement.description = None; - replacement.approval_default = McpApprovalPolicy::Always; + replacement.approval = McpApprovalPolicy::Always; replacement.auth_policy = McpServerAuthPolicy::OptionalBearer; replacement.auth_grant_id = Some(AuthGrantId::new("authgrant_mcp_crm")); replacement.now_ms = created.updated_at_ms + 5; @@ -1931,7 +1931,7 @@ async fn pg_live_mcp_crud_and_universe_isolation() { assert_eq!(replaced.revision, 2); assert_eq!(replaced.server_url, "https://crm2.example.com/mcp"); assert_eq!(replaced.description, None); - assert_eq!(replaced.approval_default, McpApprovalPolicy::Always); + assert_eq!(replaced.approval, McpApprovalPolicy::Always); assert_eq!( replaced.auth_grant_id, Some(AuthGrantId::new("authgrant_mcp_crm")) @@ -2054,7 +2054,7 @@ async fn pg_live_universe_environments_are_independent_of_sessions() { template_id: EnvironmentTemplateId::new("rust-v1"), display_name: Some("Local host".to_owned()), metadata: Default::default(), - origin_session: None, + idle_policy: Some(environments::EnvironmentIdlePolicy { pause_after_ms: Some(60_000), suspend_after_ms: None, @@ -2213,65 +2213,6 @@ async fn pg_live_universe_environments_are_independent_of_sessions() { .expect("close environment while a session exists"); assert_eq!(closing.status, EnvironmentStatus::Closing); - // Origin-session provenance round-trips, filters, and feeds the - // close-with-session sweep query. - let owned_id = EnvironmentId::new("environment-owned"); - let owned = store - .create_environment(CreateEnvironment { - request_id: EnvironmentProvisionRequestId::for_session(&session_id), - environment_id: owned_id.clone(), - incarnation_id: EnvironmentIncarnationId::new("incarnation-owned"), - binding_id: EnvironmentProviderBindingId::new("primary"), - template_id: EnvironmentTemplateId::new("rust-v1"), - display_name: None, - metadata: Default::default(), - origin_session: Some(environments::EnvironmentOriginSession { - session_id: session_id.clone(), - profile_id: Some("coder".to_owned()), - close_with_session: true, - }), - idle_policy: None, - created_at_ms: 60, - }) - .await - .expect("create session-provisioned environment"); - assert_eq!( - owned.origin_session, - Some(environments::EnvironmentOriginSession { - session_id: session_id.clone(), - profile_id: Some("coder".to_owned()), - close_with_session: true, - }) - ); - let by_session = store - .list_environments(ListEnvironments { - metadata: Default::default(), - origin_session_id: Some(session_id.clone()), - ..ListEnvironments::default() - }) - .await - .expect("list by origin session"); - assert_eq!(by_session, vec![owned.clone()]); - let sweep = store - .list_environments_closing_with_session() - .await - .expect("sweep candidates"); - assert_eq!(sweep, vec![owned]); - store - .begin_close_environment(BeginCloseEnvironment { - environment_id: owned_id, - updated_at_ms: 70, - }) - .await - .expect("close owned environment"); - assert!( - store - .list_environments_closing_with_session() - .await - .expect("sweep candidates") - .is_empty() - ); - // Leave nothing behind for a running dev reconciler to chase: the // provider endpoint is fictional and its closing environments would be // retried forever. @@ -2885,7 +2826,7 @@ async fn pg_live_environment_credentials_round_trip() { template_id: EnvironmentTemplateId::new("rust-v1"), display_name: None, metadata: Default::default(), - origin_session: None, + idle_policy: None, created_at_ms: 3, }) @@ -3219,8 +3160,8 @@ fn put_mcp_server(server_id: &str, status: McpServerStatus) -> PutMcpServerRecor allowed_tools: Some(vec!["lookup_customer".to_owned()]), execution: mcp::McpExecution::Provider, exposure: mcp::McpExposure::Inject, - approval_default: McpApprovalPolicy::Never, - defer_loading_default: Some(true), + approval: McpApprovalPolicy::Never, + defer_loading: Some(true), allow_private_network: false, auth_policy: McpServerAuthPolicy::None, auth_grant_id: None, diff --git a/crates/temporal-server/src/bots/fires.rs b/crates/temporal-server/src/bots/fires.rs index de642f16..7ed0eb83 100644 --- a/crates/temporal-server/src/bots/fires.rs +++ b/crates/temporal-server/src/bots/fires.rs @@ -13,8 +13,8 @@ use api::{ AgentApiError, AgentApiErrorKind, AuthGrantLeaseParams, BotEventDocument, BotTriggerDisabledReason, BotTriggerId, BotTriggerKind, BotTriggerSpec, EnvironmentJobCancelParams, EnvironmentJobCreateParams, EnvironmentJobReadParams, - PollCursorSpec, PollCursorState, PollHttpAuth, PollHttpMethod, PollSource, ProfileEnvironment, - ProfileId, ProfileReadParams, SessionJobCancelScopeView, SessionJobHandleInput, + PollCursorSpec, PollCursorState, PollHttpAuth, PollHttpMethod, PollSource, ProfileId, + ProfileReadParams, SessionJobCancelScopeView, SessionJobHandleInput, SessionJobOutputStreamView, SessionJobStartSpecInput, SessionJobStatusView, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; @@ -689,8 +689,10 @@ async fn fetch_http_payload( // ── Exec sources ──────────────────────────────────────────────────────────── /// The environment an exec poll without an explicit `environmentId` runs -/// in: the `existing` environment of the bot's profile. A profile with -/// another intent (none, per-session provision, inherit) cannot run such a +/// in: the default attachment of the bot's profile. This is resolved from +/// the profile, not the bot session's live selection, so the two can differ +/// once the session has switched; a poll that must share the session's +/// machine names it. A profile without a concrete default cannot run such a /// poll — a configuration error, not a transient failure. async fn resolve_bot_profile_environment( api: &GatewayAgentApi, @@ -706,10 +708,23 @@ async fn resolve_bot_profile_environment( })? .result .profile; - match profile.document.environment { - Some(ProfileEnvironment::Existing { environment_id }) => Ok(environment_id), - _ => Err(PollFetchError::Failed(format!( - "the poll names no environment and profile {profile_id} does not activate an existing one: set environmentId on the trigger, or point the profile at an existing environment" + let default = profile + .document + .config + .as_ref() + .and_then(|config| config.features.as_ref()) + .and_then(|features| features.environments.as_ref()) + .and_then(|environments| { + environments + .environments + .iter() + .find(|attachment| attachment.default) + }) + .and_then(|attachment| attachment.environment_id.clone()); + match default { + Some(environment_id) => Ok(environment_id), + None => Err(PollFetchError::Failed(format!( + "the poll names no environment and profile {profile_id} has no default environment attachment: set environmentId on the trigger, or mark one attached environment as the default" ))), } } diff --git a/crates/temporal-server/src/bots/sessions.rs b/crates/temporal-server/src/bots/sessions.rs index 8ddc99a9..60d8c4a5 100644 --- a/crates/temporal-server/src/bots/sessions.rs +++ b/crates/temporal-server/src/bots/sessions.rs @@ -295,7 +295,6 @@ fn resolve_bot_profile(profile: &AgentProfile, instructions: String) -> InlineAg retention: profile.document.retention.clone(), config: profile.document.config.clone(), instructions: Some(ProfileInstructions::Text { text: instructions }), - environment: profile.document.environment.clone(), }, } } @@ -466,7 +465,6 @@ pub async fn ensure_session( profile: Some(ProfileSource::Inline { profile: Box::new(resolved.clone()), }), - environment: None, delete_after_close_ms: None, workflow_tools: ManagedSessionWorkflowToolsInput { version: MANAGED_TOOLS_VERSION, diff --git a/crates/temporal-server/src/environment_skills.rs b/crates/temporal-server/src/environment_skills.rs deleted file mode 100644 index 6ea7249a..00000000 --- a/crates/temporal-server/src/environment_skills.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! Shared gateway/activity discovery at eligible idle boundaries. Never wakes a machine. -use crate::{ - environment_gateway::EnvironmentGatewayClientConfig, environment_resolver::EnvironmentResolver, -}; -use engine::{ - ContextEntryInput, CoreAgentCommand, EnvironmentId, EnvironmentsFeature, SessionId, - storage::{BlobStore, BlobStoreError}, -}; -use environment_client::EnvironmentDataClient; -use environment_protocol::{ - data::handshake::{InitializeParams, InitializedParams}, - shared::CURRENT_PROTOCOL_VERSION, -}; -use std::{ - collections::BTreeMap, - sync::{Mutex, OnceLock}, - time::Duration, -}; -use tools::skills::environment::*; - -#[derive(Clone)] -struct CachedObservation { - fingerprint: String, - catalog: EnvironmentSkillCatalog, -} -// An optimization only: losing/evicting this cache forces a complete scan. The -// durable semantic snapshot in context remains the last observation on failure. -static OBSERVATIONS: OnceLock>> = OnceLock::new(); -fn observations() -> &'static Mutex> { - OBSERVATIONS.get_or_init(Default::default) -} - -pub(crate) async fn refresh( - blobs: &dyn BlobStore, - resolver: Option<&EnvironmentResolver>, - gateway: Option<&EnvironmentGatewayClientConfig>, - session_id: &SessionId, - feature: Option<&EnvironmentsFeature>, - environment_id: Option<&EnvironmentId>, - current: Option<&ContextEntryInput>, -) -> Result, BlobStoreError> { - // Only runtime-owned entries are refreshed. Public controller entries are independent. - if current.is_some_and(|entry| { - !entry - .origin - .as_deref() - .is_some_and(|origin| origin.starts_with("runtime.environment:")) - }) { - return Ok(None); - } - let Some((feature, config, environment_id)) = - feature.and_then(|f| Some((f, f.skills.as_ref()?, environment_id?))) - else { - return Ok(tools::catalog::clear_catalog_command( - current, - ENVIRONMENT_SKILL_CATALOG_CONTEXT_KEY, - )); - }; - let attempt = async { - let resolver = resolver.ok_or("environment discovery resolver unavailable")?; - let gateway = gateway.ok_or("environment gateway unavailable")?; - let policy = environments::EnvironmentAccessPolicy::new( - feature.providers.clone(), - feature.registration_keys.clone(), - ); - let environment = resolver - .read_allowed(environment_id, &policy) - .await - .map_err(|e| e.to_string())?; - if environment.status != environments::EnvironmentStatus::Ready - || environment.desired_power != environments::PowerState::Running - { - return Err("environment is not accessible; discovery does not wake it".to_owned()); - } - let connection = gateway.connection_for(resolver.universe_id(), &environment); - let mut client = EnvironmentDataClient::connect( - &connection.endpoint, - gateway.connect_options("lightspeed-skill-discovery"), - ) - .await - .map_err(|e| e.to_string())?; - let result = async { - let initialized = client - .initialize(&InitializeParams { - protocol_version: CURRENT_PROTOCOL_VERSION, - client_name: "lightspeed-skill-discovery".into(), - scope: connection.scope.clone(), - resume_connection_id: None, - }) - .await - .map_err(|e| e.to_string())?; - if initialized.protocol_version != CURRENT_PROTOCOL_VERSION - || !initialized.capabilities.filesystem_scan - || !initialized.capabilities.filesystem_read - { - return Err("endpoint does not support fs/scan discovery".to_owned()); - } - client - .initialized(&InitializedParams {}) - .await - .map_err(|e| e.to_string())?; - let cwd = crate::environment_sources::working_directory( - &mut client, - feature.working_directory.as_deref(), - initialized.default_cwd.as_deref(), - ) - .await?; - let mut query = environment_skill_scan_query( - config, - Some(&cwd), - initialized.home_directory.as_deref(), - )?; - let cache_key = serde_json::to_string(&( - resolver.universe_id(), - session_id, - environment_id, - feature, - &connection, - &query, - )) - .map_err(|e| e.to_string())?; - let cached = observations() - .lock() - .expect("observation lock") - .get(&cache_key) - .cloned(); - query.if_none_match = cached.as_ref().map(|c| c.fingerprint.clone()); - let scan = client.scan(&query).await.map_err(|e| e.to_string())?; - if !scan.complete { - return Err(format!( - "incomplete environment discovery: {:?}", - scan.diagnostics - )); - } - if scan.unchanged { - return cached - .filter(|cached| scan.fingerprint.as_ref() == Some(&cached.fingerprint)) - .map(|cached| cached.catalog) - .ok_or("unexpected unchanged scan without matching observation".to_owned()); - } - let catalog = environment_skill_catalog(environment_id.as_str(), &scan)?; - if let Some(fingerprint) = scan.fingerprint { - let mut cache = observations().lock().expect("observation lock"); - if cache.len() >= 128 { - cache.clear(); - } - cache.insert( - cache_key, - CachedObservation { - fingerprint, - catalog: catalog.clone(), - }, - ); - } - Ok(catalog) - } - .await; - let _ = client.close().await; - result - }; - let catalog = match tokio::time::timeout(Duration::from_secs(4), attempt).await { - Ok(Ok(catalog)) => catalog, - failure => { - tracing::debug!(?failure, %environment_id, "environment skill discovery unavailable"); - let mut catalog = EnvironmentSkillCatalog::unavailable(environment_id.as_str()); - catalog.warnings.push(format!( - "Environment skill discovery unavailable: {failure:?}" - )); - catalog - } - }; - publish_environment_skill_catalog(blobs, current, &catalog).await -} diff --git a/crates/temporal-server/src/environment_sources.rs b/crates/temporal-server/src/environment_sources.rs deleted file mode 100644 index c37f8a93..00000000 --- a/crates/temporal-server/src/environment_sources.rs +++ /dev/null @@ -1,108 +0,0 @@ -//! Environment source connections and directory validation; discovery never wakes a machine. -use crate::{ - environment_gateway::EnvironmentGatewayClientConfig, environment_resolver::EnvironmentResolver, -}; -use engine::{EnvironmentId, EnvironmentsFeature}; -use environment_client::{EnvironmentDataClient, JsonRpcTransport, WebSocketTransport}; -use environment_protocol::{ - data::{ - fs::GetMetadataParams, - handshake::{InitializeParams, InitializeResponse, InitializedParams}, - }, - shared::{CURRENT_PROTOCOL_VERSION, EnvironmentPath}, -}; - -pub(crate) async fn working_directory( - client: &mut EnvironmentDataClient, - configured: Option<&str>, - default: Option<&str>, -) -> Result { - let value = configured - .or(default) - .ok_or("environment does not advertise a default working directory")?; - if !value.starts_with('/') { - return Err("environment working directory must be absolute".into()); - } - let cwd = tools::environment::sources::absolute(std::path::Path::new("/"), value)? - .to_string_lossy() - .into_owned(); - let metadata = client - .get_metadata(&GetMetadataParams { - path: EnvironmentPath::new(&cwd).map_err(|e| e.to_string())?, - }) - .await - .map_err(|e| format!("working directory {cwd}: {e}"))?; - if !metadata.is_directory { - return Err(format!("working directory is not a directory: {cwd}")); - } - Ok(cwd) -} - -pub(crate) async fn connect( - resolver: &EnvironmentResolver, - gateway: &EnvironmentGatewayClientConfig, - feature: &EnvironmentsFeature, - id: &EnvironmentId, -) -> Result< - ( - EnvironmentDataClient, - InitializeResponse, - String, - ), - String, -> { - let policy = environments::EnvironmentAccessPolicy::new( - feature.providers.clone(), - feature.registration_keys.clone(), - ); - let environment = resolver - .read_allowed(id, &policy) - .await - .map_err(|e| e.to_string())?; - if environment.status != environments::EnvironmentStatus::Ready - || environment.desired_power != environments::PowerState::Running - { - return Err("environment is not accessible; discovery does not wake it".into()); - } - let connection = gateway.connection_for(resolver.universe_id(), &environment); - let mut client = EnvironmentDataClient::connect( - &connection.endpoint, - gateway.connect_options("lightspeed-source-discovery"), - ) - .await - .map_err(|e| e.to_string())?; - let initialized = client - .initialize(&InitializeParams { - protocol_version: CURRENT_PROTOCOL_VERSION, - client_name: "lightspeed-source-discovery".into(), - scope: connection.scope, - resume_connection_id: None, - }) - .await - .map_err(|e| e.to_string())?; - client - .initialized(&InitializedParams {}) - .await - .map_err(|e| e.to_string())?; - if initialized.protocol_version != CURRENT_PROTOCOL_VERSION - || !initialized.capabilities.filesystem_read - || !initialized.capabilities.filesystem_scan - { - let _ = client.close().await; - return Err("endpoint does not support filesystem source discovery".into()); - } - let cwd = match working_directory( - &mut client, - feature.working_directory.as_deref(), - initialized.default_cwd.as_deref(), - ) - .await - { - Ok(cwd) => cwd, - Err(error) => { - let _ = client.close().await; - return Err(error); - } - }; - Ok((client, initialized, cwd)) -} diff --git a/crates/temporal-server/src/gateway/service/environment_credentials.rs b/crates/temporal-server/src/environments/credentials.rs similarity index 94% rename from crates/temporal-server/src/gateway/service/environment_credentials.rs rename to crates/temporal-server/src/environments/credentials.rs index fe85cc62..dcd1de37 100644 --- a/crates/temporal-server/src/gateway/service/environment_credentials.rs +++ b/crates/temporal-server/src/environments/credentials.rs @@ -1,4 +1,5 @@ use super::*; +use crate::gateway::service::auth_api::map_auth_error; use ::environments::{ EnvironmentCredentialRecord, EnvironmentCredentialSource, EnvironmentCredentialStore, @@ -6,8 +7,8 @@ use ::environments::{ }; use auth::{AuthGrantId, AuthGrantStatus, AuthProviderId, AuthProviderStatus, SecretId}; -impl GatewayAgentApi { - pub(super) async fn bind_environment_credential_record( +impl EnvironmentService { + pub(crate) async fn bind_environment_credential_record( &self, params: EnvironmentCredentialBindParams, ) -> Result { @@ -30,7 +31,7 @@ impl GatewayAgentApi { }) } - pub(super) async fn list_environment_credential_records( + pub(crate) async fn list_environment_credential_records( &self, params: EnvironmentCredentialListParams, ) -> Result { @@ -49,7 +50,7 @@ impl GatewayAgentApi { }) } - pub(super) async fn unbind_environment_credential_record( + pub(crate) async fn unbind_environment_credential_record( &self, params: EnvironmentCredentialUnbindParams, ) -> Result { @@ -67,7 +68,7 @@ impl GatewayAgentApi { }) } - pub(super) async fn credential_source_from_api( + pub(crate) async fn credential_source_from_api( &self, source: EnvironmentCredentialSourceView, ) -> Result { @@ -119,7 +120,7 @@ impl GatewayAgentApi { } } -pub(super) fn environment_credential_view( +pub(crate) fn environment_credential_view( record: EnvironmentCredentialRecord, ) -> EnvironmentCredentialView { EnvironmentCredentialView { @@ -151,7 +152,7 @@ fn credential_source_view(source: EnvironmentCredentialSource) -> EnvironmentCre } } -pub(super) fn validate_credential_env_name(value: &str) -> Result<(), AgentApiError> { +pub(crate) fn validate_credential_env_name(value: &str) -> Result<(), AgentApiError> { let mut chars = value.chars(); let Some(first) = chars.next() else { return Err(AgentApiError::invalid_request( diff --git a/crates/temporal-server/src/environment_gateway.rs b/crates/temporal-server/src/environments/gateway.rs similarity index 100% rename from crates/temporal-server/src/environment_gateway.rs rename to crates/temporal-server/src/environments/gateway.rs diff --git a/crates/temporal-server/src/gateway/service/environment_lifecycle.rs b/crates/temporal-server/src/environments/lifecycle.rs similarity index 84% rename from crates/temporal-server/src/gateway/service/environment_lifecycle.rs rename to crates/temporal-server/src/environments/lifecycle.rs index 17f0b0aa..b88a7d53 100644 --- a/crates/temporal-server/src/gateway/service/environment_lifecycle.rs +++ b/crates/temporal-server/src/environments/lifecycle.rs @@ -1,4 +1,4 @@ -use super::environment_providers::{ +use super::providers::{ binding_context, environment_view, map_environments_error, parse_environment_provider_binding_id, registry_idle_policy, registry_lifecycle_status, registry_power_state, @@ -23,7 +23,7 @@ use environment_protocol::control::targets::{ /// Map a provider target observation to the logical lifecycle status. A /// passive provider reports Ready only after its private envd is reachable; /// no provider presence has to register separately. -pub(super) fn lifecycle_status_from_target(status: ProviderTargetStatus) -> EnvironmentStatus { +pub(crate) fn lifecycle_status_from_target(status: ProviderTargetStatus) -> EnvironmentStatus { match status { ProviderTargetStatus::Ready => EnvironmentStatus::Ready, ProviderTargetStatus::Creating | ProviderTargetStatus::Starting => { @@ -39,8 +39,8 @@ pub(super) fn lifecycle_status_from_target(status: ProviderTargetStatus) -> Envi } } -impl GatewayAgentApi { - pub(super) async fn put_environment_ingress_record( +impl EnvironmentService { + pub(crate) async fn put_environment_ingress_record( &self, params: EnvironmentIngressPutParams, ) -> Result { @@ -142,7 +142,7 @@ impl GatewayAgentApi { }) } - pub(super) async fn create_external_environment_record( + pub(crate) async fn create_external_environment_record( &self, params: EnvironmentExternalCreateParams, ) -> Result { @@ -169,25 +169,20 @@ impl GatewayAgentApi { environment: environment_view(&environment), }) } - pub(super) async fn create_environment_record( + pub(crate) async fn create_environment_record( &self, params: EnvironmentCreateParams, ) -> Result { - let environment = self - .create_environment_record_with_origin(params, None) - .await?; + let environment = self.accept_environment_create(params).await?; Ok(EnvironmentCreateResponse { environment: environment_view(&environment), }) } - /// Shared acceptance boundary for `environments/create` and - /// profile-provisioned environments. Provider I/O is deliberately left to - /// the independently restartable reconciler. - pub(super) async fn create_environment_record_with_origin( + /// Accept environment creation; provider I/O belongs to the reconciler. + pub(crate) async fn accept_environment_create( &self, params: EnvironmentCreateParams, - origin_session: Option<::environments::EnvironmentOriginSession>, ) -> Result { let request_id = EnvironmentProvisionRequestId::try_new(params.request_id).map_err(|error| { @@ -211,7 +206,6 @@ impl GatewayAgentApi { template_id, display_name: params.display_name, metadata: validated_caller_metadata(params.metadata)?, - origin_session, idle_policy, created_at_ms: now_ms()?, }, @@ -223,7 +217,7 @@ impl GatewayAgentApi { /// `environments/power/put`: record power intent. Provider support is /// checked against the states observed on the current incarnation; the /// reconciler converges asynchronously. - pub(super) async fn put_environment_power_record( + pub(crate) async fn put_environment_power_record( &self, params: EnvironmentPowerPutParams, ) -> Result { @@ -279,7 +273,7 @@ impl GatewayAgentApi { /// `environments/idle-policy/put`: replace or clear the staged idle /// policy of a provisioned environment. - pub(super) async fn put_environment_idle_policy_record( + pub(crate) async fn put_environment_idle_policy_record( &self, params: EnvironmentIdlePolicyPutParams, ) -> Result { @@ -303,7 +297,7 @@ impl GatewayAgentApi { }) } - pub(super) async fn read_environment_record( + pub(crate) async fn read_environment_record( &self, params: EnvironmentReadParams, ) -> Result { @@ -316,7 +310,7 @@ impl GatewayAgentApi { }) } - pub(super) async fn list_environment_records( + pub(crate) async fn list_environment_records( &self, params: EnvironmentListParams, ) -> Result { @@ -333,16 +327,6 @@ impl GatewayAgentApi { .map(parse_environment_provider_binding_id) .transpose()?, status: params.status.map(registry_lifecycle_status), - origin_session_id: params - .origin_session_id - .map(|id| { - engine::SessionId::try_new(id).map_err(|error| { - AgentApiError::invalid_request(format!( - "invalid origin session id: {error}" - )) - }) - }) - .transpose()?, registration_key_id: params .registration_key_id .map(parse_registration_key_id) @@ -356,7 +340,7 @@ impl GatewayAgentApi { }) } - pub(super) async fn close_environment_record( + pub(crate) async fn close_environment_record( &self, params: EnvironmentCloseParams, ) -> Result { @@ -375,91 +359,6 @@ impl GatewayAgentApi { }) } - /// Close every open profile-provisioned environment whose origin session - /// asked for close-with-session and is now closed (or gone). Idempotent - /// and restart-safe: this is the backstop behind the eager close in - /// `session/close` and covers sessions closed from inside the workflow. - pub(crate) async fn reconcile_close_with_session_once(&self) -> Result { - let candidates = - EnvironmentStore::list_environments_closing_with_session(self.store.as_ref()) - .await - .map_err(map_environments_error)?; - let mut changed = 0; - for environment in candidates { - let Some(origin) = environment.origin_session.as_ref() else { - continue; - }; - let session_closed = match self.store.load_session(&origin.session_id).await { - Ok(Some(record)) => { - record.lifecycle_status == engine::storage::SessionLifecycleStatus::Closed - } - // A deleted session cannot come back; its environment goes too. - Ok(None) => true, - Err(error) => return Err(map_session_store_error(error)), - }; - if !session_closed { - continue; - } - match EnvironmentStore::begin_close_environment( - self.store.as_ref(), - BeginCloseEnvironment { - environment_id: environment.environment_id.clone(), - updated_at_ms: now_ms()?, - }, - ) - .await - { - Ok(_) => changed += 1, - // Already closing/closed by someone else: converged. - Err(::environments::EnvironmentRegistryError::InvalidInput { .. }) => {} - Err(error) => return Err(map_environments_error(error)), - } - } - Ok(changed) - } - - /// Eagerly request close for the environments a profile provisioned for - /// this session with `closeWithSession`. Best effort: the reconciler - /// sweep converges the rest. - pub(super) async fn close_session_owned_environments(&self, session_id: &SessionId) { - let Ok(environments) = EnvironmentStore::list_environments( - self.store.as_ref(), - ListEnvironments { - metadata: Default::default(), - origin_session_id: Some(session_id.clone()), - ..ListEnvironments::default() - }, - ) - .await - else { - return; - }; - for environment in environments { - let close = environment - .origin_session - .as_ref() - .is_some_and(|origin| origin.close_with_session) - && !matches!( - environment.status, - EnvironmentStatus::Closing | EnvironmentStatus::Closed - ); - if !close { - continue; - } - let Ok(updated_at_ms) = now_ms() else { - return; - }; - let _ = EnvironmentStore::begin_close_environment( - self.store.as_ref(), - BeginCloseEnvironment { - environment_id: environment.environment_id.clone(), - updated_at_ms, - }, - ) - .await; - } - } - /// Public entry point for one reconciliation pass; used by acceptance /// tests that drive the reconciler deterministically instead of running /// the background loop. @@ -473,8 +372,7 @@ impl GatewayAgentApi { pub(crate) async fn reconcile_environment_lifecycle_once( &self, ) -> Result { - let mut changed = self.reconcile_close_with_session_once().await?; - changed += self.reconcile_registered_once().await?; + let mut changed = self.reconcile_registered_once().await?; let environments = EnvironmentStore::list_environments_needing_reconcile(self.store.as_ref()) .await @@ -801,12 +699,12 @@ impl ReconcileFailureLog { } } -pub(super) fn parse_registry_environment_id(value: String) -> Result { +pub(crate) fn parse_registry_environment_id(value: String) -> Result { EnvironmentId::try_new(value) .map_err(|error| AgentApiError::invalid_request(format!("invalid environment id: {error}"))) } -pub(super) fn parse_registration_key_id( +pub(crate) fn parse_registration_key_id( value: String, ) -> Result<::environments::EnvironmentRegistrationKeyId, AgentApiError> { ::environments::EnvironmentRegistrationKeyId::try_new(value).map_err(|error| { @@ -814,11 +712,11 @@ pub(super) fn parse_registration_key_id( }) } -pub(super) fn allocate_environment_id() -> EnvironmentId { +pub(crate) fn allocate_environment_id() -> EnvironmentId { EnvironmentId::new(format!("environment_{}", uuid::Uuid::new_v4().simple())) } -pub(super) fn allocate_incarnation_id() -> EnvironmentIncarnationId { +pub(crate) fn allocate_incarnation_id() -> EnvironmentIncarnationId { EnvironmentIncarnationId::new(format!("incarnation_{}", uuid::Uuid::new_v4().simple())) } diff --git a/crates/temporal-server/src/environments/mod.rs b/crates/temporal-server/src/environments/mod.rs new file mode 100644 index 00000000..6164bdca --- /dev/null +++ b/crates/temporal-server/src/environments/mod.rs @@ -0,0 +1,42 @@ +//! Environment lifecycle management, connectivity, runtime resolution, and source discovery. +use crate::environments::gateway::EnvironmentGatewayClientConfig; +use api::*; +use lifecycle::parse_registry_environment_id; +use provider_controllers::{ProviderControllerConnector, finish_provider_controller}; +use providers::{map_environments_error, parse_environment_provider_id}; +use std::{collections::BTreeMap, sync::Arc}; +use store_pg::PgStore; +pub(crate) mod credentials; +pub(crate) mod lifecycle; +pub(crate) mod power; +pub(crate) mod provider_controllers; +pub(crate) mod providers; +pub(crate) mod registration; + +pub mod gateway; +pub(crate) mod prompts; +pub(crate) mod resolver; +pub mod runtime; +pub(crate) mod skills; +pub(crate) mod sources; + +#[derive(Clone)] +pub(crate) struct EnvironmentService { + pub(crate) store: Arc, + pub(crate) environment_gateway: EnvironmentGatewayClientConfig, + pub(crate) provider_controller_connector: Arc, +} +fn now_ms() -> Result { + i64::try_from( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| AgentApiError::internal(e.to_string()))? + .as_millis(), + ) + .map_err(|e| AgentApiError::internal(e.to_string())) +} + +fn validate_caller_metadata(metadata: &BTreeMap) -> Result<(), AgentApiError> { + environment_protocol::registration::validate_registration_metadata(None, metadata) + .map_err(|message| AgentApiError::invalid_request(format!("invalid metadata: {message}"))) +} diff --git a/crates/temporal-server/src/gateway/service/environment_power.rs b/crates/temporal-server/src/environments/power.rs similarity index 98% rename from crates/temporal-server/src/gateway/service/environment_power.rs rename to crates/temporal-server/src/environments/power.rs index 8f28f35c..9f2f0c2d 100644 --- a/crates/temporal-server/src/gateway/service/environment_power.rs +++ b/crates/temporal-server/src/environments/power.rs @@ -6,7 +6,7 @@ //! (or a close). Activity is never persisted: the daemon owns the clock and //! reports a monotonic idle duration; Lightspeed only decides. -use super::environment_providers::map_environments_error; +use super::providers::map_environments_error; use super::*; use ::environments::{ @@ -50,7 +50,7 @@ pub(crate) fn decide_idle_action( policy.due_action(report.idle_for_ms, &environment.incarnation.power_states) } -impl GatewayAgentApi { +impl EnvironmentService { /// Public entry point for one reaper pass; used by acceptance tests that /// drive the reaper deterministically instead of running the loop. pub async fn reap_idle_environments_once(&self) -> Result { @@ -205,7 +205,6 @@ mod tests { }, public_ingress_enabled: false, public_endpoint: None, - origin_session: None, metadata: BTreeMap::new(), last_seen_at_ms: None, created_at_ms: 1, diff --git a/crates/temporal-server/src/environment_prompts.rs b/crates/temporal-server/src/environments/prompts.rs similarity index 77% rename from crates/temporal-server/src/environment_prompts.rs rename to crates/temporal-server/src/environments/prompts.rs index 344e1538..4fe0285e 100644 --- a/crates/temporal-server/src/environment_prompts.rs +++ b/crates/temporal-server/src/environments/prompts.rs @@ -1,50 +1,46 @@ //! Idle-boundary environment prompt refresh, independent of VFS instruction ownership. -use crate::{ - environment_gateway::EnvironmentGatewayClientConfig, environment_resolver::EnvironmentResolver, -}; +use crate::environments::sources::{Discovery, PhaseTimer}; use engine::{ - ContextEntryInput, ContextEntryKey, EnvironmentId, EnvironmentsFeature, + ContextEntryInput, ContextEntryKey, storage::{BlobStore, BlobStoreError}, }; use std::{collections::BTreeMap, time::Duration}; pub(crate) async fn refresh( blobs: &dyn BlobStore, - resolver: Option<&EnvironmentResolver>, - gateway: Option<&EnvironmentGatewayClientConfig>, - feature: Option<&EnvironmentsFeature>, - id: Option<&EnvironmentId>, + discovery: &mut Discovery<'_>, ) -> Result, BlobStoreError> { - let Some((feature, source, id)) = - feature.and_then(|feature| Some((feature, feature.prompts.as_ref()?, id?))) + let Some((source, id)) = discovery + .feature + .and_then(|feature| Some((feature.prompts.as_ref()?, discovery.environment_id?))) else { return Ok(BTreeMap::new()); }; let attempt = async { - let (mut client, initialized, cwd) = crate::environment_sources::connect( - resolver.ok_or("environment resolver unavailable")?, - gateway.ok_or("environment gateway unavailable")?, - feature, - id, - ) - .await?; - let result = async { - let query = tools::environment::sources::scan_query( - source.roots.as_deref(), - &cwd, - initialized.home_directory.as_deref(), - "prompts", - )?; - let scan = client.scan(&query).await.map_err(|e| e.to_string())?; - tools::prompts::environment::assemble(&scan) - } - .await; - let _ = client.close().await; - result + let connection = discovery.connection().await?; + let query = tools::environment::sources::scan_query( + source.roots.as_deref(), + &connection.cwd, + connection.initialized.home_directory.as_deref(), + "prompts", + )?; + let scan = { + let _timer = PhaseTimer::new("prompts_scan"); + connection + .client + .scan(&query) + .await + .map_err(|e| e.to_string())? + }; + tools::prompts::environment::assemble(&scan) }; let observation = tokio::time::timeout(Duration::from_secs(4), attempt) .await .unwrap_or_else(|_| Err("environment prompt discovery timed out".into())); + if observation.is_err() { + discovery.discard_connection(); + } + let _timer = PhaseTimer::new("prompts_publication"); tools::prompts::environment::publication(blobs, id.as_str(), observation).await } @@ -52,6 +48,7 @@ pub(crate) async fn refresh( mod tests { use super::*; use engine::storage::{BlobStore, InMemoryBlobStore}; + use engine::{EnvironmentId, EnvironmentsFeature, SessionId}; use tools::prompts::environment::{ ENVIRONMENT_PROMPT_CONTEXT_KEY, EnvironmentPromptReport, assemble, publication, }; @@ -143,15 +140,18 @@ mod tests { ..Default::default() }; assert!( - refresh( + crate::environments::sources::refresh( &blobs, None, None, + &SessionId::new("session"), Some(&feature), - Some(&EnvironmentId::new("machine")) + Some(&EnvironmentId::new("machine")), + None, ) .await .unwrap() + .prompt_entries .is_empty() ); } diff --git a/crates/temporal-server/src/gateway/service/provider_controllers.rs b/crates/temporal-server/src/environments/provider_controllers.rs similarity index 99% rename from crates/temporal-server/src/gateway/service/provider_controllers.rs rename to crates/temporal-server/src/environments/provider_controllers.rs index 1ca3ff98..896fc577 100644 --- a/crates/temporal-server/src/gateway/service/provider_controllers.rs +++ b/crates/temporal-server/src/environments/provider_controllers.rs @@ -83,7 +83,7 @@ pub(crate) trait ProviderControllerConnector: Send + Sync { } #[derive(Default)] -pub(super) struct WebSocketProviderControllerConnector { +pub(crate) struct WebSocketProviderControllerConnector { fake_backend: Arc>, } @@ -476,7 +476,7 @@ where /// Finish one scoped provider-controller operation and close its transport on /// both success and failure. A close error does not replace the operation's /// result: controller calls are already complete when this runs. -pub(super) async fn finish_provider_controller( +pub(crate) async fn finish_provider_controller( mut controller: Box, result: Result, ) -> Result { @@ -484,7 +484,7 @@ pub(super) async fn finish_provider_controller( result } -pub(super) fn map_environment_client_error(error: EnvironmentClientError) -> AgentApiError { +pub(crate) fn map_environment_client_error(error: EnvironmentClientError) -> AgentApiError { match error { EnvironmentClientError::Protocol(error) => { AgentApiError::rejected(format!("provider controller error: {}", error.message)) diff --git a/crates/temporal-server/src/gateway/service/environment_providers.rs b/crates/temporal-server/src/environments/providers.rs similarity index 92% rename from crates/temporal-server/src/gateway/service/environment_providers.rs rename to crates/temporal-server/src/environments/providers.rs index c1e54dfd..21204d8c 100644 --- a/crates/temporal-server/src/gateway/service/environment_providers.rs +++ b/crates/temporal-server/src/environments/providers.rs @@ -9,8 +9,8 @@ use environment_protocol::control::targets::{ EnvironmentTemplate, ListTemplatesParams, ProviderBindingContext, }; -impl GatewayAgentApi { - pub(super) async fn list_environment_provider_binding_records( +impl EnvironmentService { + pub(crate) async fn list_environment_provider_binding_records( &self, _params: EnvironmentProviderBindingListParams, ) -> Result { @@ -28,7 +28,7 @@ impl GatewayAgentApi { }) } - pub(super) async fn read_environment_provider_binding_record( + pub(crate) async fn read_environment_provider_binding_record( &self, params: EnvironmentProviderBindingReadParams, ) -> Result { @@ -45,7 +45,7 @@ impl GatewayAgentApi { }) } - pub(super) async fn list_environment_template_records( + pub(crate) async fn list_environment_template_records( &self, params: EnvironmentTemplateListParams, ) -> Result { @@ -100,7 +100,7 @@ impl GatewayAgentApi { Ok(EnvironmentTemplateListResponse { templates }) } - pub(super) async fn read_environment_template_record( + pub(crate) async fn read_environment_template_record( &self, params: EnvironmentTemplateReadParams, ) -> Result { @@ -120,7 +120,7 @@ impl GatewayAgentApi { Ok(EnvironmentTemplateReadResponse { template }) } - pub(super) async fn read_environment_provider( + pub(crate) async fn read_environment_provider( &self, provider_id: &EnvironmentProviderId, ) -> Result { @@ -130,21 +130,21 @@ impl GatewayAgentApi { } } -pub(super) fn binding_context(record: &EnvironmentProviderBindingRecord) -> ProviderBindingContext { +pub(crate) fn binding_context(record: &EnvironmentProviderBindingRecord) -> ProviderBindingContext { ProviderBindingContext { universe_id: record.universe_id.to_string(), binding_id: record.binding_id.to_string(), } } -pub(super) fn parse_environment_provider_id( +pub(crate) fn parse_environment_provider_id( value: String, ) -> Result { EnvironmentProviderId::try_new(value) .map_err(|error| AgentApiError::invalid_request(format!("invalid provider id: {error}"))) } -pub(super) fn parse_environment_provider_binding_id( +pub(crate) fn parse_environment_provider_binding_id( value: String, ) -> Result { EnvironmentProviderBindingId::try_new(value).map_err(|error| { @@ -173,7 +173,7 @@ pub(crate) fn environment_provider_binding_view( } } -pub(super) fn environment_template_view( +pub(crate) fn environment_template_view( binding: &EnvironmentProviderBindingRecord, template: &EnvironmentTemplate, ) -> EnvironmentTemplateView { @@ -240,16 +240,6 @@ pub(crate) fn environment_view(record: &EnvironmentRecord) -> EnvironmentView { status: lifecycle_status_view(record.status), desired_power: power_state_view(record.desired_power), idle_policy: record.idle_policy.as_ref().map(idle_policy_view), - origin_session: record.origin_session.as_ref().map(|origin| { - api::EnvironmentOriginSessionView { - session_id: origin.session_id.as_str().to_owned(), - profile_id: origin - .profile_id - .as_deref() - .and_then(|id| api::ProfileId::try_new(id).ok()), - close_with_session: origin.close_with_session, - } - }), incarnation: EnvironmentIncarnationView { incarnation_id: record.incarnation.incarnation_id.to_string(), provision_request_id: record @@ -312,7 +302,7 @@ pub(crate) fn registry_identity_mode( } } -pub(super) fn power_state_view(value: ::environments::PowerState) -> EnvironmentPowerStateView { +pub(crate) fn power_state_view(value: ::environments::PowerState) -> EnvironmentPowerStateView { match value { ::environments::PowerState::Running => EnvironmentPowerStateView::Running, ::environments::PowerState::Paused => EnvironmentPowerStateView::Paused, @@ -321,7 +311,7 @@ pub(super) fn power_state_view(value: ::environments::PowerState) -> Environment } } -pub(super) fn registry_power_state(value: EnvironmentPowerStateView) -> ::environments::PowerState { +pub(crate) fn registry_power_state(value: EnvironmentPowerStateView) -> ::environments::PowerState { match value { EnvironmentPowerStateView::Running => ::environments::PowerState::Running, EnvironmentPowerStateView::Paused => ::environments::PowerState::Paused, @@ -330,7 +320,7 @@ pub(super) fn registry_power_state(value: EnvironmentPowerStateView) -> ::enviro } } -pub(super) fn idle_policy_view( +pub(crate) fn idle_policy_view( value: &::environments::EnvironmentIdlePolicy, ) -> EnvironmentIdlePolicyView { EnvironmentIdlePolicyView { @@ -341,7 +331,7 @@ pub(super) fn idle_policy_view( } } -pub(super) fn registry_idle_policy( +pub(crate) fn registry_idle_policy( value: &EnvironmentIdlePolicyView, ) -> ::environments::EnvironmentIdlePolicy { ::environments::EnvironmentIdlePolicy { @@ -367,7 +357,7 @@ fn lifecycle_status_view(value: EnvironmentStatus) -> EnvironmentLifecycleStatus } } -pub(super) fn registry_lifecycle_status( +pub(crate) fn registry_lifecycle_status( value: EnvironmentLifecycleStatusView, ) -> EnvironmentStatus { match value { diff --git a/crates/temporal-server/src/gateway/service/environment_registration.rs b/crates/temporal-server/src/environments/registration.rs similarity index 97% rename from crates/temporal-server/src/gateway/service/environment_registration.rs rename to crates/temporal-server/src/environments/registration.rs index 986e67c0..132926b0 100644 --- a/crates/temporal-server/src/gateway/service/environment_registration.rs +++ b/crates/temporal-server/src/environments/registration.rs @@ -17,7 +17,7 @@ use ::environments::{ /// that held its control connection stopped without recording the /// disconnect. Three missed heartbeats leaves room for one slow pong. pub(crate) const REGISTERED_STALE_AFTER_MS: i64 = - 3 * super::super::registration::HEARTBEAT_INTERVAL.as_millis() as i64; + 3 * crate::gateway::registration::HEARTBEAT_INTERVAL.as_millis() as i64; /// What the reconciler does with one open registered environment. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -55,12 +55,12 @@ pub(crate) fn registered_sweep_action( use super::*; use super::{ - environment_lifecycle::parse_registration_key_id, - environment_providers::{identity_mode_view, registry_identity_mode}, + lifecycle::parse_registration_key_id, + providers::{identity_mode_view, registry_identity_mode}, }; -impl GatewayAgentApi { - pub(super) async fn create_environment_registration_key_record( +impl EnvironmentService { + pub(crate) async fn create_environment_registration_key_record( &self, params: EnvironmentRegistrationKeyCreateParams, ) -> Result { @@ -99,7 +99,7 @@ impl GatewayAgentApi { }) } - pub(super) async fn read_environment_registration_key_record( + pub(crate) async fn read_environment_registration_key_record( &self, params: EnvironmentRegistrationKeyReadParams, ) -> Result { @@ -115,7 +115,7 @@ impl GatewayAgentApi { }) } - pub(super) async fn list_environment_registration_key_records( + pub(crate) async fn list_environment_registration_key_records( &self, _params: EnvironmentRegistrationKeyListParams, ) -> Result { @@ -130,7 +130,7 @@ impl GatewayAgentApi { Ok(EnvironmentRegistrationKeyListResponse { registration_keys }) } - pub(super) async fn revoke_environment_registration_key_record( + pub(crate) async fn revoke_environment_registration_key_record( &self, params: EnvironmentRegistrationKeyRevokeParams, ) -> Result { @@ -364,7 +364,6 @@ mod tests { }, public_ingress_enabled: false, public_endpoint: None, - origin_session: None, metadata: BTreeMap::new(), last_seen_at_ms, created_at_ms: 0, diff --git a/crates/temporal-server/src/environment_resolver.rs b/crates/temporal-server/src/environments/resolver.rs similarity index 58% rename from crates/temporal-server/src/environment_resolver.rs rename to crates/temporal-server/src/environments/resolver.rs index de46722e..a67b70ff 100644 --- a/crates/temporal-server/src/environment_resolver.rs +++ b/crates/temporal-server/src/environments/resolver.rs @@ -5,9 +5,8 @@ use std::sync::Arc; use environments::{ - EnvironmentAccessPolicy, EnvironmentId, EnvironmentProviderStore, EnvironmentRecord, - EnvironmentRegistryError, EnvironmentSource, EnvironmentStatus, EnvironmentStore, - ListEnvironments, PowerState, SetEnvironmentPower, + EnvironmentId, EnvironmentProviderStore, EnvironmentRecord, EnvironmentRegistryError, + EnvironmentSource, EnvironmentStatus, EnvironmentStore, PowerState, SetEnvironmentPower, }; use store_pg::PgStore; use thiserror::Error; @@ -16,7 +15,7 @@ use thiserror::Error; pub(crate) struct EnvironmentResolver { environments: Arc, providers: Arc, - gateway: Option, + gateway: Option, universe_id: uuid::Uuid, } @@ -45,79 +44,59 @@ impl EnvironmentResolver { pub(crate) fn with_gateway( mut self, - gateway: crate::environment_gateway::EnvironmentGatewayClientConfig, + gateway: crate::environments::gateway::EnvironmentGatewayClientConfig, ) -> Self { self.gateway = Some(gateway); self } - pub(crate) async fn list_allowed( - &self, - policy: &EnvironmentAccessPolicy, - ) -> Result, EnvironmentResolveError> { - let mut environments = self - .environments - .list_environments(ListEnvironments::default()) - .await?; - environments.retain(|environment| policy.allows(environment)); - Ok(environments) - } - - pub(crate) async fn read_allowed( + /// The registry record. Whether a session may use the environment is a + /// membership check against its attachment list, made by the caller. + pub(crate) async fn read( &self, environment_id: &EnvironmentId, - policy: &EnvironmentAccessPolicy, ) -> Result { - let environment = self.environments.read_environment(environment_id).await?; - if !policy.allows(&environment) { - return Err(EnvironmentResolveError::NotAllowed { - environment_id: environment.environment_id.to_string(), - reason: policy.refusal(&environment), - }); - } - Ok(environment) + Ok(self.environments.read_environment(environment_id).await?) } - /// Activation admission: like [`Self::selectable`], but a - /// `provisioning`/`booting` environment is admitted as valid intent and - /// returned with `ready == false` instead of failing. Environment tools - /// wait for readiness at call time. - pub(crate) async fn activatable( + /// Validate selection using registry state only. Selecting or reselecting + /// an environment never changes power or proves data-plane reachability. + pub(crate) async fn selectable( &self, environment_id: &EnvironmentId, - policy: &EnvironmentAccessPolicy, - now_ms: i64, - ) -> Result<(EnvironmentRecord, bool), EnvironmentResolveError> { - match self.selectable(environment_id, policy, now_ms).await { - Ok(environment) => Ok((environment, true)), - Err(EnvironmentResolveError::NotReady { .. }) => { - Ok((self.read_allowed(environment_id, policy).await?, false)) + ) -> Result { + let environment = self.read(environment_id).await?; + match environment.status { + EnvironmentStatus::Failed => Err(EnvironmentResolveError::Failed { + environment_id: environment.environment_id.to_string(), + message: environment + .metadata + .get(LIFECYCLE_ERROR_METADATA_KEY) + .cloned() + .unwrap_or_else(|| "environment provisioning failed".to_owned()), + }), + EnvironmentStatus::Closing | EnvironmentStatus::Closed => { + Err(EnvironmentResolveError::Closed { + environment_id: environment.environment_id.to_string(), + }) } - Err(error) => Err(error), + _ => Ok(environment), } } - /// Status-aware selection admission. `provisioning`/`booting` - /// environments are admitted as intent without a route probe (they cannot - /// be reachable yet) and reported as `NotReady`; `failed`, `closing`, and - /// `closed` are rejected with typed errors; a powered-down provisioned - /// environment whose provider supports power control is woken (desired - /// power set to `running`) and reported as `NotReady`; everything - /// else must prove the full data-plane route. - pub(crate) async fn selectable( + /// Check readiness for actual use, requesting wake-up where supported and + /// probing the data route. Selection itself uses only registry validation. + pub(crate) async fn ready_for_use( &self, environment_id: &EnvironmentId, - policy: &EnvironmentAccessPolicy, now_ms: i64, ) -> Result { - let environment = self - .resolve_for_connection(environment_id, policy, now_ms) - .await?; + let environment = self.resolve_for_connection(environment_id, now_ms).await?; if let Some(gateway) = &self.gateway { let connection = gateway.connection_for(self.universe_id, &environment); if let Ok(mut client) = environment_client::EnvironmentDataClient::connect( &connection.endpoint, - gateway.connect_options("lightspeed-environment-selection"), + gateway.connect_options("lightspeed-environment-readiness"), ) .await { @@ -132,15 +111,14 @@ impl EnvironmentResolver { } /// Validate lifecycle and policy immediately before opening a real - /// data-plane connection. Unlike [`Self::selectable`], this does not open + /// data-plane connection. Unlike [`Self::ready_for_use`], this does not open /// a second connection merely to prove reachability. pub(crate) async fn resolve_for_connection( &self, environment_id: &EnvironmentId, - policy: &EnvironmentAccessPolicy, now_ms: i64, ) -> Result { - let environment = self.read_allowed(environment_id, policy).await?; + let environment = self.selectable(environment_id).await?; if let Some(provider_id) = environment.provider_id() { self.providers.read_provider(provider_id).await?; } @@ -170,21 +148,6 @@ impl EnvironmentResolver { status: environment.status, }); } - EnvironmentStatus::Failed => { - return Err(EnvironmentResolveError::Failed { - environment_id: environment.environment_id.as_str().to_owned(), - message: environment - .metadata - .get(LIFECYCLE_ERROR_METADATA_KEY) - .cloned() - .unwrap_or_else(|| "environment provisioning failed".to_owned()), - }); - } - EnvironmentStatus::Closing | EnvironmentStatus::Closed => { - return Err(EnvironmentResolveError::Closed { - environment_id: environment.environment_id.as_str().to_owned(), - }); - } EnvironmentStatus::Ready if environment.desired_power != PowerState::Running => { // Use cancels a pending power-down: the idle reaper has asked // for a lower power state but the reconciler has not converged @@ -202,11 +165,7 @@ impl EnvironmentResolver { .await .map_err(EnvironmentResolveError::from); } - EnvironmentStatus::Ready - | EnvironmentStatus::Paused - | EnvironmentStatus::Suspended - | EnvironmentStatus::Offline - | EnvironmentStatus::Unknown => {} + _ => {} } Ok(environment) } @@ -217,12 +176,6 @@ pub(crate) enum EnvironmentResolveError { #[error(transparent)] Store(#[from] EnvironmentRegistryError), - #[error("environment {environment_id} is not allowed by session config: {reason}")] - NotAllowed { - environment_id: String, - reason: String, - }, - #[error("environment is unavailable: {environment_id} ({status})")] EnvironmentUnavailable { environment_id: String, @@ -317,7 +270,7 @@ mod tests { template_id: EnvironmentTemplateId::new("test-template"), display_name: None, metadata: BTreeMap::new(), - origin_session: None, + idle_policy: None, created_at_ms: 10, }) @@ -340,7 +293,7 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn environment_skills_idle_discovery_reuses_observations_and_never_wakes() { + async fn environment_discovery_shares_connection_preserves_freshness_and_never_wakes() { use engine::{ CoreAgentCommand, storage::{BlobStore, InMemoryBlobStore}, @@ -357,99 +310,142 @@ mod tests { let skill_path = root.join(".agents/skills/review/SKILL.md"); std::fs::write(&skill_path, doc).unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let gateway = crate::environment_gateway::EnvironmentGatewayClientConfig::new( + let gateway = crate::environments::gateway::EnvironmentGatewayClientConfig::new( format!("http://{}", listener.local_addr().unwrap()), "test", ); + let connections = Arc::new(AtomicUsize::new(0)); + let initializations = Arc::new(AtomicUsize::new(0)); + let metadata_reads = Arc::new(AtomicUsize::new(0)); + let stall_skills = Arc::new(AtomicBool::new(false)); let scans = Arc::new(AtomicUsize::new(0)); let unchanged = Arc::new(AtomicUsize::new(0)); let supported = Arc::new(AtomicBool::new(true)); let stall = Arc::new(AtomicBool::new(false)); let task = { + let connections = connections.clone(); + let initializations = initializations.clone(); + let metadata_reads = metadata_reads.clone(); + let stall_skills = stall_skills.clone(); let scans = scans.clone(); let unchanged = unchanged.clone(); let supported = supported.clone(); let stall = stall.clone(); let root = root.clone(); tokio::spawn(async move { + let mut clients = tokio::task::JoinSet::new(); loop { let (socket, _) = listener.accept().await.unwrap(); - let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap(); - while let Some(Ok(message)) = socket.next().await { - let Ok(text) = message.to_text() else { - continue; - }; - let Ok(request) = serde_json::from_str::(text) else { - continue; - }; - let Some(id) = request.get("id") else { - continue; - }; - if stall.load(Ordering::SeqCst) { - std::future::pending::<()>().await; - } - let result = match request["method"].as_str().unwrap() { - "initialize" => { - serde_json::json!({ "protocolVersion": environment_protocol::shared::CURRENT_PROTOCOL_VERSION, "connectionId": "test", "capabilities": {"filesystemRead": true, "filesystemScan": supported.load(Ordering::SeqCst)}, "defaultCwd": root, "homeDirectory": root, "implementation": {"name": "test", "version": "1"} }) - } - "fs/getMetadata" => { - let fs = environment_daemon::filesystem::LocalFileSystem::new( - root.clone(), - root.clone(), - false, - ); - serde_json::to_value( - fs.get_metadata( - serde_json::from_value(request["params"].clone()).unwrap(), - ) - .await - .unwrap(), - ) - .unwrap() + connections.fetch_add(1, Ordering::SeqCst); + while let Some(result) = clients.try_join_next() { + result.unwrap(); + } + let initializations = initializations.clone(); + let metadata_reads = metadata_reads.clone(); + let stall_skills = stall_skills.clone(); + let scans = scans.clone(); + let unchanged = unchanged.clone(); + let supported = supported.clone(); + let stall = stall.clone(); + let root = root.clone(); + clients.spawn(async move { + let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap(); + while let Some(Ok(message)) = socket.next().await { + let Ok(text) = message.to_text() else { + continue; + }; + let Ok(request) = serde_json::from_str::(text) else { + continue; + }; + let Some(id) = request.get("id") else { + continue; + }; + if stall.load(Ordering::SeqCst) { + std::future::pending::<()>().await; } - "fs/scan" => { - scans.fetch_add(1, Ordering::SeqCst); - let fs = environment_daemon::filesystem::LocalFileSystem::new( - root.clone(), - root.clone(), - false, - ); - let result = fs - .scan( - serde_json::from_value(request["params"].clone()).unwrap(), + let result = match request["method"].as_str().unwrap() { + "initialize" => { + initializations.fetch_add(1, Ordering::SeqCst); + serde_json::json!({ "protocolVersion": environment_protocol::shared::CURRENT_PROTOCOL_VERSION, "connectionId": "test", "capabilities": {"filesystemRead": true, "filesystemScan": supported.load(Ordering::SeqCst)}, "defaultCwd": root, "homeDirectory": root, "implementation": {"name": "test", "version": "1"} }) + } + "fs/getMetadata" => { + metadata_reads.fetch_add(1, Ordering::SeqCst); + let fs = environment_daemon::filesystem::LocalFileSystem::new( + root.clone(), + root.clone(), + false, + ); + serde_json::to_value( + fs.get_metadata( + serde_json::from_value(request["params"].clone()).unwrap(), + ) + .await + .unwrap(), ) - .await - .unwrap(); - if result.unchanged { - unchanged.fetch_add(1, Ordering::SeqCst); + .unwrap() + } + "fs/scan" => { + scans.fetch_add(1, Ordering::SeqCst); + if stall_skills.load(Ordering::SeqCst) + && request["params"]["includePatterns"].as_array().unwrap().iter().any(|p| p == "SKILL.md") { + std::future::pending::<()>().await; + } + let fs = environment_daemon::filesystem::LocalFileSystem::new( + root.clone(), + root.clone(), + false, + ); + let result = fs + .scan( + serde_json::from_value(request["params"].clone()).unwrap(), + ) + .await + .unwrap(); + if result.unchanged { + unchanged.fetch_add(1, Ordering::SeqCst); + } + serde_json::to_value(result).unwrap() } - serde_json::to_value(result).unwrap() + other => panic!("unexpected discovery RPC: {other}"), + }; + if socket + .send(tokio_tungstenite::tungstenite::Message::Text( + serde_json::json!({"jsonrpc":"2.0", "id":id, "result":result}) + .to_string() + .into(), + )) + .await + .is_err() + { + break; } - other => panic!("unexpected discovery RPC: {other}"), - }; - if socket - .send(tokio_tungstenite::tungstenite::Message::Text( - serde_json::json!({"jsonrpc":"2.0", "id":id, "result":result}) - .to_string() - .into(), - )) - .await - .is_err() - { - break; } - } + }); } }) }; + let counts = || { + ( + connections.load(Ordering::SeqCst), + initializations.load(Ordering::SeqCst), + metadata_reads.load(Ordering::SeqCst), + scans.load(Ordering::SeqCst), + ) + }; let blobs = InMemoryBlobStore::new(); let session_id = engine::SessionId::new(uuid::Uuid::new_v4().to_string()); let feature = engine::EnvironmentsFeature { skills: Some(Default::default()), + environments: vec![engine::EnvironmentAttachment { + environment_id: environment_id.as_str().to_owned(), + default: false, + access: engine::EnvironmentAccess::Read, + working_directory: None, + }], ..Default::default() }; - let refresh = |current| { - crate::environment_skills::refresh( + let refresh = async |current| { + crate::environments::sources::refresh( &blobs, Some(&resolver), Some(&gateway), @@ -458,6 +454,8 @@ mod tests { Some(&environment_id), current, ) + .await + .map(|publication| publication.skill_command) }; let entry = |command| match command { Some(CoreAgentCommand::UpsertContext { entry, .. }) => entry, @@ -505,6 +503,114 @@ mod tests { std::fs::write(&skill_path, doc.replace("Review code.", "Review changes.")).unwrap(); let edited = entry(refresh(Some(&available)).await.unwrap()); assert_ne!(edited.content, available.content); + // Both sources share setup, but retain their own scans and observe every edit. + let mut both = feature.clone(); + both.prompts = Some(Default::default()); + std::fs::create_dir_all(root.join(".agents/prompts")).unwrap(); + let prompt_path = root.join(".agents/prompts/instructions.md"); + let prompt_key = engine::ContextEntryKey::new( + tools::prompts::environment::ENVIRONMENT_PROMPT_CONTEXT_KEY, + ); + let refresh_sources = async |config, current| { + crate::environments::sources::refresh( + &blobs, + Some(&resolver), + Some(&gateway), + &session_id, + Some(config), + Some(&environment_id), + current, + ) + .await + .unwrap() + }; + for text in ["First instructions", "Updated instructions"] { + std::fs::write(&prompt_path, text).unwrap(); + let before = counts(); + let result = refresh_sources(&both, Some(&edited)).await; + assert!(result.skill_command.is_none()); + assert_eq!( + counts(), + (before.0 + 1, before.1 + 1, before.2 + 1, before.3 + 2) + ); + assert_eq!( + blobs + .read_bytes(&result.prompt_entries[&prompt_key].content.content_ref) + .await + .unwrap(), + text.as_bytes() + ); + } + let before = counts(); + let prompts_only = engine::EnvironmentsFeature { + prompts: Some(Default::default()), + environments: feature.environments.clone(), + ..Default::default() + }; + let result = refresh_sources(&prompts_only, None).await; + assert!(result.skill_command.is_none()); + assert!(result.prompt_entries.contains_key(&prompt_key)); + assert_eq!( + counts(), + (before.0 + 1, before.1 + 1, before.2 + 1, before.3 + 1) + ); + + let before = counts(); + let disabled_feature = engine::EnvironmentsFeature::default(); + let disabled = refresh_sources(&disabled_feature, None).await; + assert!(disabled.skill_command.is_none()); + assert!(disabled.prompt_entries.is_empty()); + let mut controller_owned = edited.clone(); + controller_owned.origin = Some("controller".into()); + assert!( + refresh_sources(&feature, Some(&controller_owned)) + .await + .skill_command + .is_none() + ); + assert_eq!( + counts(), + before, + "disabled and controller-owned sources need no connection" + ); + let result = refresh_sources(&both, Some(&controller_owned)).await; + assert!(result.skill_command.is_none()); + assert!(result.prompt_entries.contains_key(&prompt_key)); + assert_eq!( + counts(), + (before.0 + 1, before.1 + 1, before.2 + 1, before.3 + 1) + ); + + // A timed-out skill RPC must not poison the prompt scan with an unread response. + stall_skills.store(true, Ordering::SeqCst); + let before = counts(); + let result = refresh_sources(&both, Some(&edited)).await; + let failed_skills = entry(result.skill_command); + let failed_catalog: EnvironmentSkillCatalog = serde_json::from_slice( + &blobs + .read_bytes(failed_skills.provenance_ref.as_ref().unwrap()) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!( + failed_catalog.availability, + EnvironmentSkillAvailability::Unavailable + ); + assert!(failed_catalog.skills.is_empty()); + assert_eq!( + blobs + .read_bytes(&result.prompt_entries[&prompt_key].content.content_ref) + .await + .unwrap(), + b"Updated instructions" + ); + assert_eq!( + counts(), + (before.0 + 2, before.1 + 2, before.2 + 2, before.3 + 2) + ); + stall_skills.store(false, Ordering::SeqCst); + // An incomplete scan reports unavailable and removes obsolete catalog paths. std::fs::write(&skill_path, vec![b'x'; 65537]).unwrap(); let stale = entry(refresh(Some(&edited)).await.unwrap()); @@ -521,6 +627,44 @@ mod tests { ); assert!(catalog.skills.is_empty()); assert!(refresh(Some(&stale)).await.unwrap().is_none()); + let result = refresh_sources(&both, Some(&edited)).await; + assert!(result.skill_command.is_some()); + assert_eq!( + blobs + .read_bytes(&result.prompt_entries[&prompt_key].content.content_ref) + .await + .unwrap(), + b"Updated instructions" + ); + std::fs::write(&skill_path, doc).unwrap(); + std::fs::write(&prompt_path, vec![b'x'; 65537]).unwrap(); + let result = refresh_sources(&both, Some(&stale)).await; + let recovered = entry(result.skill_command); + let recovered_catalog: EnvironmentSkillCatalog = serde_json::from_slice( + &blobs + .read_bytes(recovered.provenance_ref.as_ref().unwrap()) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!( + recovered_catalog.availability, + EnvironmentSkillAvailability::Available + ); + let prompt_report: tools::prompts::environment::EnvironmentPromptReport = + serde_json::from_slice( + &blobs + .read_bytes( + result.prompt_entries[&prompt_key] + .provenance_ref + .as_ref() + .unwrap(), + ) + .await + .unwrap(), + ) + .unwrap(); + assert!(!prompt_report.available); // Missing fs/scan is explicit unavailable discovery, with no RPC fallback. supported.store(false, Ordering::SeqCst); let before = scans.load(Ordering::SeqCst); @@ -543,7 +687,7 @@ mod tests { EnvironmentSkillAvailability::Available ); // Deselection removes only this catalog key. - let cleared = crate::environment_skills::refresh( + let cleared = crate::environments::sources::refresh( &blobs, Some(&resolver), Some(&gateway), @@ -555,12 +699,12 @@ mod tests { .await .unwrap(); assert!( - matches!(cleared, Some(CoreAgentCommand::RemoveContext { key, .. }) if key.as_str() == "runtime.catalog.skills.environment") + matches!(cleared.skill_command, Some(CoreAgentCommand::RemoveContext { key, .. }) if key.as_str() == "runtime.catalog.skills.environment") ); let mut denied = feature.clone(); - denied.providers = Some(vec!["not-granted".into()]); + denied.environments.clear(); let denied_entry = entry( - crate::environment_skills::refresh( + crate::environments::sources::refresh( &blobs, Some(&resolver), Some(&gateway), @@ -570,7 +714,8 @@ mod tests { Some(&available), ) .await - .unwrap(), + .unwrap() + .skill_command, ); let denied_catalog: EnvironmentSkillCatalog = serde_json::from_slice( &blobs @@ -608,46 +753,105 @@ mod tests { task.abort(); } - #[tokio::test(flavor = "current_thread")] - async fn provider_filter_applies_to_list_read_and_selection() { - let (resolver, environment_id) = resolver().await; - let denied = - EnvironmentAccessPolicy::new(Some(vec!["other".to_owned()]), None::>); - assert!(resolver.list_allowed(&denied).await.unwrap().is_empty()); - assert!(matches!( - resolver.read_allowed(&environment_id, &denied).await, - Err(EnvironmentResolveError::NotAllowed { .. }) - )); - assert!(matches!( - resolver.selectable(&environment_id, &denied, 20).await, - Err(EnvironmentResolveError::NotAllowed { .. }) - )); - } - #[tokio::test(flavor = "current_thread")] async fn offline_environment_without_gateway_is_unavailable_but_readable() { let (resolver, environment_id) = resolver().await; + assert!(resolver.read(&environment_id).await.is_ok()); assert!( resolver - .read_allowed(&environment_id, &EnvironmentAccessPolicy::ALLOW_ALL) - .await - .is_ok() - ); - assert!( - resolver - .resolve_for_connection(&environment_id, &EnvironmentAccessPolicy::ALLOW_ALL, 111) + .resolve_for_connection(&environment_id, 111) .await .is_ok(), "execution resolution should defer reachability to the real connection" ); assert!(matches!( - resolver - .selectable(&environment_id, &EnvironmentAccessPolicy::ALLOW_ALL, 111) - .await, + resolver.ready_for_use(&environment_id, 111).await, Err(EnvironmentResolveError::EnvironmentUnavailable { .. }) )); } + #[tokio::test(flavor = "current_thread")] + async fn selection_preserves_power_and_needs_no_gateway_in_any_nonterminal_state() { + let (resolver, environment_id) = resolver().await; + let store = resolver.environments.clone(); + store + .set_environment_power(SetEnvironmentPower { + environment_id: environment_id.clone(), + desired_power: PowerState::Paused, + updated_at_ms: 20, + }) + .await + .unwrap(); + for status in [ + EnvironmentStatus::Provisioning, + EnvironmentStatus::Booting, + EnvironmentStatus::Ready, + EnvironmentStatus::Paused, + EnvironmentStatus::Suspended, + EnvironmentStatus::Offline, + EnvironmentStatus::Unknown, + ] { + store + .observe_provisioned_environment(ObserveProvisionedEnvironment { + environment_id: environment_id.clone(), + provider_target_id: ProviderTargetId::new("target-1"), + status, + power_states: vec![PowerState::Running, PowerState::Paused], + observed_at_ms: 30, + }) + .await + .unwrap(); + let before = store.read_environment(&environment_id).await.unwrap(); + for _ in 0..2 { + let selected = resolver + .selectable(&environment_id) + .await + .expect("selection requires only valid registry state"); + assert_eq!(selected, before); + assert_eq!( + store.read_environment(&environment_id).await.unwrap(), + before + ); + } + } + } + + #[tokio::test(flavor = "current_thread")] + async fn reselection_checks_terminal_status() { + let (resolver, environment_id) = resolver().await; + let store = resolver.environments.clone(); + resolver.selectable(&environment_id).await.unwrap(); + assert!(matches!( + resolver.selectable(&EnvironmentId::new("missing")).await, + Err(EnvironmentResolveError::Store( + EnvironmentRegistryError::NotFound { .. } + )) + )); + store + .fail_environment_lifecycle(environments::FailEnvironmentLifecycle { + environment_id: environment_id.clone(), + message: "no capacity".into(), + observed_at_ms: 40, + }) + .await + .unwrap(); + assert!(matches!( + resolver.selectable(&environment_id).await, + Err(EnvironmentResolveError::Failed { .. }) + )); + store + .begin_close_environment(environments::BeginCloseEnvironment { + environment_id: environment_id.clone(), + updated_at_ms: 50, + }) + .await + .unwrap(); + assert!(matches!( + resolver.selectable(&environment_id).await, + Err(EnvironmentResolveError::Closed { .. }) + )); + } + #[tokio::test(flavor = "current_thread")] async fn use_cancels_a_pending_power_down() { let (resolver, environment_id) = resolver().await; @@ -673,7 +877,7 @@ mod tests { .expect("pause intent"); let resolved = resolver - .resolve_for_connection(&environment_id, &EnvironmentAccessPolicy::ALLOW_ALL, 30) + .resolve_for_connection(&environment_id, 30) .await .expect("a ready environment resolves for use"); assert_eq!(resolved.status, EnvironmentStatus::Ready); @@ -715,12 +919,10 @@ mod tests { .expect("pause intent"); observe(EnvironmentStatus::Paused, 22).await; - // Selecting a paused environment requests a wake and reports it as + // Using a paused environment requests a wake and reports it as // not ready instead of probing an unreachable daemon. assert!(matches!( - resolver - .selectable(&environment_id, &EnvironmentAccessPolicy::ALLOW_ALL, 30) - .await, + resolver.ready_for_use(&environment_id, 30).await, Err(EnvironmentResolveError::NotReady { status: EnvironmentStatus::Paused, .. @@ -730,20 +932,17 @@ mod tests { assert_eq!(woken.desired_power, PowerState::Running); assert!(woken.power_diverges()); // Activation admits it as intent. - let (record, ready) = resolver - .activatable(&environment_id, &EnvironmentAccessPolicy::ALLOW_ALL, 31) + let record = resolver + .selectable(&environment_id) .await .expect("activation admits a paused environment"); - assert!(!ready); assert_eq!(record.status, EnvironmentStatus::Paused); // Once the provider observed it running again the ordinary probe // path applies (no gateway here → unavailable, not NotReady). observe(EnvironmentStatus::Ready, 40).await; assert!(matches!( - resolver - .selectable(&environment_id, &EnvironmentAccessPolicy::ALLOW_ALL, 50) - .await, + resolver.ready_for_use(&environment_id, 50).await, Err(EnvironmentResolveError::EnvironmentUnavailable { .. }) )); @@ -760,9 +959,7 @@ mod tests { .await .expect("observe offline without power control"); assert!(matches!( - resolver - .selectable(&environment_id, &EnvironmentAccessPolicy::ALLOW_ALL, 70) - .await, + resolver.ready_for_use(&environment_id, 70).await, Err(EnvironmentResolveError::EnvironmentUnavailable { .. }) )); assert_eq!( @@ -776,7 +973,7 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn selection_is_status_aware() { + async fn readiness_is_status_aware() { let (resolver, environment_id) = resolver().await; let store = resolver.environments.clone(); let observe = |status: EnvironmentStatus| { @@ -800,9 +997,7 @@ mod tests { // probe and reported as not ready. observe(EnvironmentStatus::Provisioning).await; assert!(matches!( - resolver - .selectable(&environment_id, &EnvironmentAccessPolicy::ALLOW_ALL, 30) - .await, + resolver.ready_for_use(&environment_id, 30).await, Err(EnvironmentResolveError::NotReady { status: EnvironmentStatus::Provisioning, .. @@ -810,19 +1005,16 @@ mod tests { )); observe(EnvironmentStatus::Booting).await; assert!(matches!( - resolver - .selectable(&environment_id, &EnvironmentAccessPolicy::ALLOW_ALL, 30) - .await, + resolver.ready_for_use(&environment_id, 30).await, Err(EnvironmentResolveError::NotReady { status: EnvironmentStatus::Booting, .. }) )); - let (record, ready) = resolver - .activatable(&environment_id, &EnvironmentAccessPolicy::ALLOW_ALL, 30) + let record = resolver + .selectable(&environment_id) .await .expect("activation admits a booting environment"); - assert!(!ready); assert_eq!(record.status, EnvironmentStatus::Booting); store @@ -834,7 +1026,7 @@ mod tests { .await .expect("fail"); assert!(matches!( - resolver.selectable(&environment_id, &EnvironmentAccessPolicy::ALLOW_ALL, 50).await, + resolver.ready_for_use(&environment_id, 50).await, Err(EnvironmentResolveError::Failed { message, .. }) if message == "no capacity" )); @@ -846,9 +1038,7 @@ mod tests { .await .expect("close"); assert!(matches!( - resolver - .selectable(&environment_id, &EnvironmentAccessPolicy::ALLOW_ALL, 70) - .await, + resolver.ready_for_use(&environment_id, 70).await, Err(EnvironmentResolveError::Closed { .. }) )); } diff --git a/crates/temporal-server/src/environment.rs b/crates/temporal-server/src/environments/runtime.rs similarity index 99% rename from crates/temporal-server/src/environment.rs rename to crates/temporal-server/src/environments/runtime.rs index a1b1ceb3..2fb323b3 100644 --- a/crates/temporal-server/src/environment.rs +++ b/crates/temporal-server/src/environments/runtime.rs @@ -179,7 +179,6 @@ mod tests { }, public_ingress_enabled: false, public_endpoint: None, - origin_session: None, metadata: BTreeMap::from([("fsRoot".to_owned(), "/sandbox".to_owned())]), last_seen_at_ms: None, created_at_ms: 1, diff --git a/crates/temporal-server/src/environments/skills.rs b/crates/temporal-server/src/environments/skills.rs new file mode 100644 index 00000000..07a169c7 --- /dev/null +++ b/crates/temporal-server/src/environments/skills.rs @@ -0,0 +1,121 @@ +//! Workflow activity discovery at eligible idle boundaries. Never wakes a machine. +use crate::environments::sources::{Discovery, PhaseTimer}; +use engine::{ + ContextEntryInput, CoreAgentCommand, SessionId, + storage::{BlobStore, BlobStoreError}, +}; +use std::{ + collections::BTreeMap, + sync::{Mutex, OnceLock}, + time::Duration, +}; +use tools::skills::environment::*; + +#[derive(Clone)] +struct CachedObservation { + fingerprint: String, + catalog: EnvironmentSkillCatalog, +} +// An optimization only: losing/evicting this cache forces a complete scan. The +// cached catalog is reused only after the environment confirms its fingerprint. +static OBSERVATIONS: OnceLock>> = OnceLock::new(); +fn observations() -> &'static Mutex> { + OBSERVATIONS.get_or_init(Default::default) +} + +pub(crate) async fn refresh( + blobs: &dyn BlobStore, + discovery: &mut Discovery<'_>, + session_id: &SessionId, + current: Option<&ContextEntryInput>, +) -> Result, BlobStoreError> { + // Only runtime-owned entries are refreshed. Public controller entries are independent. + if current.is_some_and(|entry| { + !entry + .origin + .as_deref() + .is_some_and(|origin| origin.starts_with("runtime.environment:")) + }) { + return Ok(None); + } + let Some((feature, config, environment_id)) = discovery + .feature + .and_then(|f| Some((f, f.skills.as_ref()?, discovery.environment_id?))) + else { + return Ok(tools::catalog::clear_catalog_command( + current, + ENVIRONMENT_SKILL_CATALOG_CONTEXT_KEY, + )); + }; + let attempt = async { + let connection = discovery.connection().await?; + let mut query = environment_skill_scan_query( + config, + Some(&connection.cwd), + connection.initialized.home_directory.as_deref(), + )?; + let cache_key = serde_json::to_string(&( + session_id, + environment_id, + feature, + &connection.identity, + &query, + )) + .map_err(|e| e.to_string())?; + let cached = observations() + .lock() + .expect("observation lock") + .get(&cache_key) + .cloned(); + query.if_none_match = cached.as_ref().map(|c| c.fingerprint.clone()); + let scan = { + let _timer = PhaseTimer::new("skills_scan"); + connection + .client + .scan(&query) + .await + .map_err(|e| e.to_string())? + }; + if !scan.complete { + return Err(format!( + "incomplete environment discovery: {:?}", + scan.diagnostics + )); + } + if scan.unchanged { + return cached + .filter(|cached| scan.fingerprint.as_ref() == Some(&cached.fingerprint)) + .map(|cached| cached.catalog) + .ok_or("unexpected unchanged scan without matching observation".to_owned()); + } + let catalog = environment_skill_catalog(environment_id.as_str(), &scan)?; + if let Some(fingerprint) = scan.fingerprint { + let mut cache = observations().lock().expect("observation lock"); + if cache.len() >= 128 { + cache.clear(); + } + cache.insert( + cache_key, + CachedObservation { + fingerprint, + catalog: catalog.clone(), + }, + ); + } + Ok(catalog) + }; + let catalog = match tokio::time::timeout(Duration::from_secs(4), attempt).await { + Ok(Ok(catalog)) => catalog, + failure => { + discovery.discard_connection(); + tracing::debug!(?failure, %environment_id, "environment skill discovery unavailable"); + let mut catalog = EnvironmentSkillCatalog::unavailable(environment_id.as_str()); + catalog.warnings.push(format!( + "Environment skill discovery unavailable: {failure:?}" + )); + catalog + } + }; + let _timer = PhaseTimer::new("skills_publication"); + publish_environment_skill_catalog(blobs, current, &catalog).await +} diff --git a/crates/temporal-server/src/environments/sources.rs b/crates/temporal-server/src/environments/sources.rs new file mode 100644 index 00000000..51fa015e --- /dev/null +++ b/crates/temporal-server/src/environments/sources.rs @@ -0,0 +1,224 @@ +//! Environment source connections and directory validation; discovery never wakes a machine. +use crate::{ + environments::gateway::EnvironmentGatewayClientConfig, + environments::resolver::EnvironmentResolver, +}; +use engine::{ + ContextEntryInput, ContextEntryKey, CoreAgentCommand, EnvironmentId, EnvironmentsFeature, + SessionId, + storage::{BlobStore, BlobStoreError}, +}; +use environment_client::{EnvironmentDataClient, JsonRpcTransport, WebSocketTransport}; +use environment_protocol::{ + data::{ + fs::GetMetadataParams, + handshake::{InitializeParams, InitializeResponse, InitializedParams}, + }, + shared::{CURRENT_PROTOCOL_VERSION, EnvironmentPath}, +}; +use std::{ + collections::BTreeMap, + time::{Duration, Instant}, +}; + +pub(crate) async fn working_directory( + client: &mut EnvironmentDataClient, + configured: Option<&str>, + default: Option<&str>, +) -> Result { + let value = configured + .or(default) + .ok_or("environment does not advertise a default working directory")?; + if !value.starts_with('/') { + return Err("environment working directory must be absolute".into()); + } + let cwd = tools::environment::sources::absolute(std::path::Path::new("/"), value)? + .to_string_lossy() + .into_owned(); + let metadata = client + .get_metadata(&GetMetadataParams { + path: EnvironmentPath::new(&cwd).map_err(|e| e.to_string())?, + }) + .await + .map_err(|e| format!("working directory {cwd}: {e}"))?; + if !metadata.is_directory { + return Err(format!("working directory is not a directory: {cwd}")); + } + Ok(cwd) +} + +pub(crate) struct Discovery<'a> { + resolver: Option<&'a EnvironmentResolver>, + gateway: Option<&'a EnvironmentGatewayClientConfig>, + pub feature: Option<&'a EnvironmentsFeature>, + pub environment_id: Option<&'a EnvironmentId>, + connection: Option, +} + +pub(crate) struct SourceConnection { + pub client: EnvironmentDataClient, + pub initialized: InitializeResponse, + pub cwd: String, + // Includes universe and incarnation so cached observations cannot cross routes. + pub identity: String, +} + +impl Discovery<'_> { + pub async fn connection(&mut self) -> Result<&mut SourceConnection, String> { + if self.connection.is_none() { + self.connection = Some( + connect( + self.resolver + .ok_or("environment discovery resolver unavailable")?, + self.gateway.ok_or("environment gateway unavailable")?, + self.feature.ok_or("environment feature unavailable")?, + self.environment_id.ok_or("no environment selected")?, + ) + .await?, + ); + } + Ok(self + .connection + .as_mut() + .expect("connected source discovery")) + } + + pub fn discard_connection(&mut self) { + // A cancelled RPC can leave an unread response. Never reuse that stream. + self.connection = None; + } +} + +pub(crate) struct Publication { + pub skill_command: Option, + pub prompt_entries: BTreeMap, +} + +#[tracing::instrument(skip_all, fields(%session_id, environment_id = ?environment_id))] +pub(crate) async fn refresh( + blobs: &dyn BlobStore, + resolver: Option<&EnvironmentResolver>, + gateway: Option<&EnvironmentGatewayClientConfig>, + session_id: &SessionId, + feature: Option<&EnvironmentsFeature>, + environment_id: Option<&EnvironmentId>, + current_skills: Option<&ContextEntryInput>, +) -> Result { + let _total = PhaseTimer::new("total"); + let mut discovery = Discovery { + resolver, + gateway, + feature, + environment_id, + connection: None, + }; + let result = async { + let skill_command = + crate::environments::skills::refresh(blobs, &mut discovery, session_id, current_skills) + .await?; + let prompt_entries = crate::environments::prompts::refresh(blobs, &mut discovery).await?; + Ok(Publication { + skill_command, + prompt_entries, + }) + } + .await; + if let Some(mut connection) = discovery.connection.take() { + let _close = PhaseTimer::new("close"); + let _ = tokio::time::timeout(Duration::from_secs(1), connection.client.close()).await; + } + result +} + +// Drop also records elapsed time when a bounded attempt is cancelled. +pub(crate) struct PhaseTimer { + phase: &'static str, + started: Instant, +} +impl PhaseTimer { + pub fn new(phase: &'static str) -> Self { + Self { + phase, + started: Instant::now(), + } + } +} +impl Drop for PhaseTimer { + fn drop(&mut self) { + tracing::debug!( + phase = self.phase, + elapsed_ms = self.started.elapsed().as_secs_f64() * 1000.0, + "environment source discovery timing" + ); + } +} + +async fn connect( + resolver: &EnvironmentResolver, + gateway: &EnvironmentGatewayClientConfig, + feature: &EnvironmentsFeature, + id: &EnvironmentId, +) -> Result { + let Some(attachment) = feature.attachment(id.as_str()) else { + return Err("active environment is not attached to this session".into()); + }; + let environment = { + let _timer = PhaseTimer::new("registry"); + resolver.read(id).await.map_err(|e| e.to_string())? + }; + if environment.status != environments::EnvironmentStatus::Ready + || environment.desired_power != environments::PowerState::Running + { + return Err("environment is not accessible; discovery does not wake it".into()); + } + let connection = gateway.connection_for(resolver.universe_id(), &environment); + let identity = + serde_json::to_string(&(resolver.universe_id(), &connection)).map_err(|e| e.to_string())?; + let mut client = { + let _timer = PhaseTimer::new("connect"); + EnvironmentDataClient::connect( + &connection.endpoint, + gateway.connect_options("lightspeed-source-discovery"), + ) + .await + .map_err(|e| e.to_string())? + }; + let initialized = { + let _timer = PhaseTimer::new("initialize"); + let initialized = client + .initialize(&InitializeParams { + protocol_version: CURRENT_PROTOCOL_VERSION, + client_name: "lightspeed-source-discovery".into(), + scope: connection.scope, + resume_connection_id: None, + }) + .await + .map_err(|e| e.to_string())?; + client + .initialized(&InitializedParams {}) + .await + .map_err(|e| e.to_string())?; + initialized + }; + if initialized.protocol_version != CURRENT_PROTOCOL_VERSION + || !initialized.capabilities.filesystem_read + || !initialized.capabilities.filesystem_scan + { + return Err("endpoint does not support filesystem source discovery".into()); + } + let cwd = { + let _timer = PhaseTimer::new("working_directory"); + working_directory( + &mut client, + attachment.working_directory.as_deref(), + initialized.default_cwd.as_deref(), + ) + .await? + }; + Ok(SourceConnection { + client, + initialized, + cwd, + identity, + }) +} diff --git a/crates/temporal-server/src/gateway/http.rs b/crates/temporal-server/src/gateway/http.rs index 9d6bbdfd..0d69a835 100644 --- a/crates/temporal-server/src/gateway/http.rs +++ b/crates/temporal-server/src/gateway/http.rs @@ -28,7 +28,7 @@ use uuid::Uuid; use crate::{ config::{DeploymentStores, GatewayAuthMode, gateway_auth_mode_from_env}, - environment_gateway::{RouteKey, bearer_matches, close_message}, + environments::gateway::{RouteKey, bearer_matches, close_message}, universe::{UniverseError, UniverseRuntime}, }; @@ -527,7 +527,11 @@ pub async fn serve_gateway_with_client_store( let universe_id = reconciler_api.universe_id(); loop { interval.tick().await; - match reconciler_api.reconcile_environment_lifecycle_once().await { + match reconciler_api + .environment_service() + .reconcile_environment_lifecycle_once() + .await + { Ok(_) => failures.succeeded(universe_id), Err(error) => failures.failed(universe_id, &error), } @@ -540,7 +544,11 @@ pub async fn serve_gateway_with_client_store( let universe_id = power_api.universe_id(); loop { interval.tick().await; - match power_api.reconcile_idle_power_once().await { + match power_api + .environment_service() + .reconcile_idle_power_once() + .await + { Ok(_) => failures.succeeded(universe_id), Err(error) => failures.failed(universe_id, &error), } diff --git a/crates/temporal-server/src/gateway/registration.rs b/crates/temporal-server/src/gateway/registration.rs index 4e254253..91ece36f 100644 --- a/crates/temporal-server/src/gateway/registration.rs +++ b/crates/temporal-server/src/gateway/registration.rs @@ -45,7 +45,7 @@ use tokio::sync::{Semaphore, mpsc, oneshot}; use uuid::Uuid; use super::http::GatewayState; -use crate::environment_gateway::{RouteKey, close_message}; +use crate::environments::gateway::{RouteKey, close_message}; /// Interval between gateway pings on a control connection; each pong /// refreshes the environment's heartbeat stamp. @@ -864,7 +864,7 @@ fn now_ms() -> i64 { pub fn data_url(public_base_url: &str) -> String { format!( "{}{DATA_PATH}", - crate::environment_gateway::websocket_base(public_base_url.trim_end_matches('/')) + crate::environments::gateway::websocket_base(public_base_url.trim_end_matches('/')) ) } diff --git a/crates/temporal-server/src/gateway/service/api_config.rs b/crates/temporal-server/src/gateway/service/api_config.rs index 94bcbff0..4dc118f0 100644 --- a/crates/temporal-server/src/gateway/service/api_config.rs +++ b/crates/temporal-server/src/gateway/service/api_config.rs @@ -12,7 +12,7 @@ impl GatewayAgentApi { config .validate() .map_err(|error| AgentApiError::invalid_request(error.to_string()))?; - self.validate_workspace_link_targets(&config.features) + self.validate_workspace_attachment_targets(&config.features) .await?; self.validate_subagent_agents(&config.features).await?; Ok(config) @@ -150,41 +150,26 @@ fn features_from_api( return Ok(engine::FeaturesConfig::default()); }; Ok(engine::FeaturesConfig { - vfs: features.vfs.map(|vfs| engine::VfsFeature { - version: vfs.version, - working_directory: vfs.working_directory, - workspace_links: vfs - .workspace_links - .into_iter() - .map(|link| engine::WorkspaceLink { - path: link.path, - target: match link.target { - api::WorkspaceLinkTarget::Workspace { workspace_id } => { - engine::WorkspaceLinkTarget::Workspace { workspace_id } - } - api::WorkspaceLinkTarget::Snapshot { snapshot_ref } => { - engine::WorkspaceLinkTarget::Snapshot { snapshot_ref } - } - }, - access: match link.access { - api::WorkspaceLinkAccess::ReadOnly => engine::WorkspaceLinkAccess::ReadOnly, - api::WorkspaceLinkAccess::ReadWrite => { - engine::WorkspaceLinkAccess::ReadWrite - } - }, + vfs: features + .vfs + .map(|vfs| { + Ok::<_, AgentApiError>(engine::VfsFeature { + version: vfs.version, + working_directory: vfs.working_directory, + workspaces: vfs + .workspaces + .into_iter() + .map(workspace_attachment_from_api) + .collect::, _>>()?, + prompts: vfs.prompts.map(|prompts| engine::VfsPromptsConfig { + roots: prompts.roots, + }), + skills: vfs.skills.map(|skills| engine::VfsSkillsConfig { + roots: skills.roots, + }), }) - .collect(), - tools: vfs.tools.map(|tools| match tools { - api::VfsToolSurface::ReadOnly => engine::VfsToolSurface::ReadOnly, - api::VfsToolSurface::Edit => engine::VfsToolSurface::Edit, - }), - prompts: vfs.prompts.map(|prompts| engine::VfsPromptsConfig { - roots: prompts.roots, - }), - skills: vfs.skills.map(|skills| engine::VfsSkillsConfig { - roots: skills.roots, - }), - }), + }) + .transpose()?, web: features.web.map(|web| engine::WebFeature { version: web.version, fetch: web.fetch.map(|_| engine::WebFetchFeature {}), @@ -216,44 +201,103 @@ fn features_from_api( }), environments: features .environments - .map(|environments| engine::EnvironmentsFeature { - tools: environments.tools.map(|surface| match surface { - api::EnvironmentToolSurface::ReadOnly => { - engine::EnvironmentToolSurface::ReadOnly - } - api::EnvironmentToolSurface::Edit => engine::EnvironmentToolSurface::Edit, - }), - commands: environments.commands, - version: environments.version, - working_directory: environments.working_directory, - prompts: environments - .prompts - .map(|source| engine::EnvironmentPromptsConfig { - roots: source.roots, - }), - providers: environments.providers, - registration_keys: environments.registration_keys, - selection_tools: environments.selection_tools, - jobs: environments.jobs, - skills: environments - .skills - .map(|skills| engine::EnvironmentSkillsConfig { - roots: skills.roots, - }), - }), + .map(|environments| { + Ok::<_, AgentApiError>(engine::EnvironmentsFeature { + version: environments.version, + selection: environments.selection, + prompts: environments + .prompts + .map(|source| engine::EnvironmentPromptsConfig { + roots: source.roots, + }), + skills: environments + .skills + .map(|skills| engine::EnvironmentSkillsConfig { + roots: skills.roots, + }), + environments: environments + .environments + .into_iter() + .map(environment_attachment_from_api) + .collect::, _>>()?, + }) + }) + .transpose()?, mcp: features.mcp.map(|mcp| engine::McpFeature { version: mcp.version, servers: mcp .servers .into_iter() - .map(|link| engine::McpServerLink { - server_id: link.server_id, + .map(|attachment| engine::McpServerAttachment { + server_id: attachment.server_id, + tools: attachment.tools, }) .collect(), }), }) } +fn workspace_attachment_from_api( + attachment: api::WorkspaceAttachment, +) -> Result { + let target = match (attachment.workspace_id, attachment.snapshot_ref) { + (Some(workspace_id), None) => engine::WorkspaceAttachmentTarget::Workspace { workspace_id }, + (None, Some(snapshot_ref)) => engine::WorkspaceAttachmentTarget::Snapshot { snapshot_ref }, + _ => { + return Err(AgentApiError::invalid_request(format!( + "workspace attachment at {} must set exactly one of workspaceId and snapshotRef", + attachment.path + ))); + } + }; + Ok(engine::WorkspaceAttachment { + path: attachment.path, + target, + access: match attachment.access { + api::WorkspaceAccess::Read => engine::WorkspaceAccess::Read, + api::WorkspaceAccess::Edit => engine::WorkspaceAccess::Edit, + }, + }) +} + +/// A session configuration names concrete machines only. `inherit` is a +/// profile-document notion resolved at sub-agent spawn, so it is rejected +/// here rather than silently dropped. +fn environment_attachment_from_api( + attachment: api::EnvironmentAttachment, +) -> Result { + let environment_id = match (attachment.environment_id, attachment.inherit) { + (Some(environment_id), false) => environment_id, + (None, true) => { + return Err(AgentApiError::invalid_request( + "environment attachment inherit is resolved when a sub-agent profile is spawned; a session configuration must name the environment", + )); + } + _ => { + return Err(AgentApiError::invalid_request( + "environment attachment must set exactly one of environmentId and inherit", + )); + } + }; + Ok(engine::EnvironmentAttachment { + environment_id, + default: attachment.default, + access: environment_access_from_api(attachment.access), + working_directory: attachment.working_directory, + }) +} + +pub(super) fn environment_access_from_api( + access: api::EnvironmentAccess, +) -> engine::EnvironmentAccess { + match access { + api::EnvironmentAccess::Read => engine::EnvironmentAccess::Read, + api::EnvironmentAccess::Edit => engine::EnvironmentAccess::Edit, + api::EnvironmentAccess::Exec => engine::EnvironmentAccess::Exec, + api::EnvironmentAccess::Jobs => engine::EnvironmentAccess::Jobs, + } +} + pub(super) fn apply_run_start_config( run_config: &mut RunConfig, session_config: &SessionConfig, diff --git a/crates/temporal-server/src/gateway/service/auth_api.rs b/crates/temporal-server/src/gateway/service/auth_api.rs index 781e73ab..616b14d7 100644 --- a/crates/temporal-server/src/gateway/service/auth_api.rs +++ b/crates/temporal-server/src/gateway/service/auth_api.rs @@ -115,7 +115,7 @@ pub(super) fn map_auth_broker_error(error: auth::AuthBrokerError) -> AgentApiErr } } -pub(super) fn map_auth_error(error: auth::AuthRegistryError) -> AgentApiError { +pub(crate) fn map_auth_error(error: auth::AuthRegistryError) -> AgentApiError { match error { auth::AuthRegistryError::GrantAlreadyExists { grant_id } => { AgentApiError::conflict(format!("auth grant already exists: {grant_id}")) diff --git a/crates/temporal-server/src/gateway/service/catalogs.rs b/crates/temporal-server/src/gateway/service/catalogs.rs index 85795c14..eb62b7cd 100644 --- a/crates/temporal-server/src/gateway/service/catalogs.rs +++ b/crates/temporal-server/src/gateway/service/catalogs.rs @@ -1,50 +1,5 @@ use super::*; -impl GatewayAgentApi { - pub(super) async fn apply_catalog_refresh_commands( - &self, - session_id: &SessionId, - commands: Vec, - ) -> Result<(), AgentApiError> { - let expected = commands - .iter() - .filter_map(|command| match command { - CoreAgentCommand::UpsertContext { key, entry, .. } => { - Some((key.clone(), entry.clone())) - } - _ => None, - }) - .collect::>(); - let removed = commands - .iter() - .filter_map(|command| match command { - CoreAgentCommand::RemoveContext { key, .. } => Some(key.clone()), - _ => None, - }) - .collect::>(); - let mut correlations = BTreeMap::new(); - for command in commands { - correlations.extend( - self.submit_correlated_context_commands(session_id, vec![command]) - .await?, - ); - } - if !expected.is_empty() { - self.wait_for_context_entries_applied(session_id, &expected, &correlations) - .await?; - } - if !removed.is_empty() { - let (_, outcomes) = self - .wait_for_context_keys_removed(session_id, &removed, &correlations) - .await?; - if let Some(failure) = outcomes.into_values().flatten().next() { - return Err(map_admission_failure_to_api_error(&failure)); - } - } - Ok(()) - } -} - /// Public context edits cannot write or remove runtime-owned slots. pub(super) fn parse_client_context_key(value: String) -> Result { let key = ContextEntryKey::try_new(value) @@ -62,6 +17,7 @@ pub(super) fn parse_client_context_key(value: String) -> Result Result<(), AgentApiError> { - if state.lifecycle.status != CoreAgentStatus::Open - || state.runs.active.is_some() - || !state.runs.queued.is_empty() - { - return Ok(()); - } - let commands = self.environment_projection_refresh_commands(state).await?; - if commands.is_empty() { - return Ok(()); - } - self.apply_catalog_refresh_commands(session_id, commands) - .await - } - - pub(super) async fn environment_projection_refresh_commands( - &self, - state: &engine::CoreAgentState, - ) -> Result, AgentApiError> { - let enabled = state - .lifecycle - .config - .as_ref() - .is_some_and(|config| config.features.vfs.is_some()); - if !enabled { - return Ok(state - .context - .entries - .iter() - .any(|entry| { - entry - .key - .as_ref() - .is_some_and(|key| key.as_str() == VFS_CATALOG_CONTEXT_KEY) - }) - .then(|| CoreAgentCommand::RemoveContext { - expected_revision: None, - key: ContextEntryKey::new(VFS_CATALOG_CONTEXT_KEY), - }) - .into_iter() - .collect()); - } - let links = self.resolve_session_workspace_links(state).await?; - let catalog = tools::environment::projection::vfs_catalog_from_workspace_links(&links) - .map_err(|error| AgentApiError::internal(error.to_string()))?; - let publication = tools::environment::projection::prepare_vfs_catalog_publication( - self.store.as_ref(), - Some(self.store.as_ref()), - engine::current_catalog_inputs(state) - .get(&ContextEntryKey::new(VFS_CATALOG_CONTEXT_KEY)), - catalog, - ) - .await - .map_err(|error| AgentApiError::internal(error.to_string()))?; - Ok(publication.command.into_iter().collect()) - } -} diff --git a/crates/temporal-server/src/gateway/service/environments.rs b/crates/temporal-server/src/gateway/service/environments.rs index 1dc0e673..4de29600 100644 --- a/crates/temporal-server/src/gateway/service/environments.rs +++ b/crates/temporal-server/src/gateway/service/environments.rs @@ -16,15 +16,14 @@ impl GatewayAgentApi { "environment activation requires the environments feature to be granted", ) })?; - let policy = ::environments::EnvironmentAccessPolicy::new( - feature.providers.clone(), - feature.registration_keys.clone(), - ); - crate::environment_resolver::EnvironmentResolver::from_pg_store(self.store.clone()) - .with_gateway(self.environment_gateway.clone()) - .activatable(environment_id, &policy, now_ms()?) + if !feature.is_attached(environment_id.as_str()) { + return Err(AgentApiError::rejected(format!( + "environment {environment_id} is not attached to this session" + ))); + } + crate::environments::resolver::EnvironmentResolver::from_pg_store(self.store.clone()) + .selectable(environment_id) .await - .map(|(environment, _ready)| environment) .map_err(map_environment_resolve_error) } @@ -68,13 +67,13 @@ impl GatewayAgentApi { /// power to `running` where that applies, and the caller's correct move is /// retry-with-backoff, not failure. pub(super) fn map_environment_resolve_error( - error: crate::environment_resolver::EnvironmentResolveError, + error: crate::environments::resolver::EnvironmentResolveError, ) -> AgentApiError { match error { - crate::environment_resolver::EnvironmentResolveError::Store(error) => { + crate::environments::resolver::EnvironmentResolveError::Store(error) => { map_environments_error(error) } - not_ready @ crate::environment_resolver::EnvironmentResolveError::NotReady { .. } => { + not_ready @ crate::environments::resolver::EnvironmentResolveError::NotReady { .. } => { AgentApiError::environment_not_ready(not_ready.to_string()) } other => AgentApiError::rejected(other.to_string()), diff --git a/crates/temporal-server/src/gateway/service/errors.rs b/crates/temporal-server/src/gateway/service/errors.rs index cfdce043..265f812c 100644 --- a/crates/temporal-server/src/gateway/service/errors.rs +++ b/crates/temporal-server/src/gateway/service/errors.rs @@ -5,6 +5,9 @@ pub(super) fn is_not_found(error: &AgentApiError) -> bool { } pub(super) fn map_admission_failure_to_api_error(failure: &AgentAdmissionFailure) -> AgentApiError { + if let Some(error) = &failure.preparation_error { + return error.clone(); + } match failure.kind { AgentAdmissionFailureKind::RejectedCommand if failure.rejection.as_ref().is_some_and(|rejection| { diff --git a/crates/temporal-server/src/gateway/service/instructions.rs b/crates/temporal-server/src/gateway/service/instructions.rs deleted file mode 100644 index 6e7174f5..00000000 --- a/crates/temporal-server/src/gateway/service/instructions.rs +++ /dev/null @@ -1,228 +0,0 @@ -use super::*; - -pub(super) const DEFAULT_INSTRUCTIONS_CONTEXT_KEY: &str = "instructions.000.default"; -pub(super) const INSTRUCTIONS_CONTEXT_PREFIX: &str = "instructions"; - -impl GatewayAgentApi { - pub(super) async fn reconcile_managed_instructions( - &self, - session_id: &SessionId, - state: &engine::CoreAgentState, - owned_prefix: &str, - source_entries: BTreeMap, - ) -> Result { - if state.lifecycle.status != CoreAgentStatus::Open { - return Err(AgentApiError::rejected(format!( - "session is not open: {session_id}" - ))); - } - if state.runs.active.is_some() || !state.runs.queued.is_empty() { - return Err(AgentApiError::rejected( - "managed instructions can only change while no run is active or queued", - )); - } - - let default_ref = self - .store - .as_ref() - .put_bytes( - temporal_workflow::default_instructions() - .as_bytes() - .to_vec(), - ) - .await - .map_err(map_blob_store_error)?; - let desired = replace_managed_instruction_source( - active_instruction_inputs(state), - owned_prefix, - source_entries, - default_instruction_input(default_ref), - )?; - - if active_instruction_inputs(state) == desired { - return Ok(false); - } - - let correlations = self - .submit_correlated_context_commands( - session_id, - vec![CoreAgentCommand::ReplaceContextPrefix { - expected_revision: Some(state.context.revision), - key_prefix: ContextEntryKey::new(INSTRUCTIONS_CONTEXT_PREFIX), - entries: desired.clone(), - }], - ) - .await?; - self.wait_for_managed_instruction_map(session_id, &desired, &correlations) - .await?; - Ok(true) - } - - async fn wait_for_managed_instruction_map( - &self, - session_id: &SessionId, - desired: &BTreeMap, - correlations: &BTreeMap, - ) -> Result<(), AgentApiError> { - let started = Instant::now(); - loop { - if started.elapsed() > self.operation_timeout { - return Err(AgentApiError::internal(format!( - "timed out waiting for managed instructions update: {session_id}" - ))); - } - if let Some(status) = self.query_status_optional(session_id).await? { - if let Some(failure) = status.admission_failures.iter().find(|failure| { - failure - .correlation_token - .as_ref() - .is_some_and(|token| correlations.contains_key(token)) - }) { - return Err(map_admission_failure_to_api_error(failure)); - } - if let Some(error) = status.last_error { - return Err(AgentApiError::internal(format!( - "agent workflow reported error: {error}" - ))); - } - } - let loaded = self.load_session_state(session_id).await?; - if active_instruction_inputs(&loaded.state) == *desired { - return Ok(()); - } - tokio::time::sleep(self.poll_interval).await; - } - } -} - -pub(super) fn active_instruction_inputs( - state: &engine::CoreAgentState, -) -> BTreeMap { - state - .context - .entries - .iter() - .filter(|entry| matches!(entry.kind, ContextEntryKind::Instructions)) - .filter_map(|entry| { - let key = entry.key.clone()?; - if !context_key_is_in_prefix(&key, INSTRUCTIONS_CONTEXT_PREFIX) { - return None; - } - Some((key, active_entry_input(entry))) - }) - .collect() -} - -fn context_key_is_in_prefix(key: &ContextEntryKey, prefix: &str) -> bool { - key.as_str() == prefix - || key - .as_str() - .strip_prefix(prefix) - .is_some_and(|suffix| suffix.starts_with('.')) -} - -fn replace_managed_instruction_source( - mut active: BTreeMap, - owned_prefix: &str, - source_entries: BTreeMap, - default_entry: ContextEntryInput, -) -> Result, AgentApiError> { - active.retain(|key, _| !context_key_is_in_prefix(key, owned_prefix)); - for (key, entry) in source_entries { - if !context_key_is_in_prefix(&key, owned_prefix) { - return Err(AgentApiError::internal(format!( - "managed instruction key {key} is outside owned prefix {owned_prefix}" - ))); - } - active.insert(key, entry); - } - - active.remove(&ContextEntryKey::new(DEFAULT_INSTRUCTIONS_CONTEXT_KEY)); - if active.is_empty() { - active.insert( - ContextEntryKey::new(DEFAULT_INSTRUCTIONS_CONTEXT_KEY), - default_entry, - ); - } - Ok(active) -} - -fn default_instruction_input(content_ref: BlobRef) -> ContextEntryInput { - ContextEntryInput { - kind: ContextEntryKind::Instructions, - content: engine::ContentRef::text(content_ref), - preview: None, - origin: None, - provenance_ref: None, - token_estimate: None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn instruction(bytes: &[u8]) -> ContextEntryInput { - ContextEntryInput { - kind: ContextEntryKind::Instructions, - content: engine::ContentRef::text(BlobRef::from_bytes(bytes)), - preview: None, - origin: None, - provenance_ref: None, - token_estimate: None, - } - } - - #[test] - fn managed_sources_replace_only_their_subset_and_control_fallback() { - let default = instruction(b"default"); - let profile = instruction(b"profile"); - let prompt = instruction(b"prompt"); - let default_key = ContextEntryKey::new(DEFAULT_INSTRUCTIONS_CONTEXT_KEY); - let profile_key = ContextEntryKey::new("instructions.050.profile"); - let prompt_key = ContextEntryKey::new("instructions.100.prompts.0000.project"); - - let active = BTreeMap::from([(default_key.clone(), default.clone())]); - let with_profile = replace_managed_instruction_source( - active, - "instructions.050.profile", - BTreeMap::from([(profile_key.clone(), profile.clone())]), - default.clone(), - ) - .expect("apply profile"); - assert_eq!( - with_profile, - BTreeMap::from([(profile_key.clone(), profile)]) - ); - - let with_both = replace_managed_instruction_source( - with_profile, - "instructions.100.prompts", - BTreeMap::from([(prompt_key.clone(), prompt.clone())]), - default.clone(), - ) - .expect("apply prompts"); - assert_eq!(with_both.len(), 2); - assert!(with_both.contains_key(&profile_key)); - assert!(with_both.contains_key(&prompt_key)); - assert!(!with_both.contains_key(&default_key)); - - let prompts_only = replace_managed_instruction_source( - with_both, - "instructions.050.profile", - BTreeMap::new(), - default.clone(), - ) - .expect("clear profile"); - assert_eq!(prompts_only, BTreeMap::from([(prompt_key, prompt)])); - - let fallback = replace_managed_instruction_source( - prompts_only, - "instructions.100.prompts", - BTreeMap::new(), - default.clone(), - ) - .expect("clear prompts"); - assert_eq!(fallback, BTreeMap::from([(default_key, default)])); - } -} diff --git a/crates/temporal-server/src/gateway/service/mcp_api.rs b/crates/temporal-server/src/gateway/service/mcp_api.rs index 803edc1e..e53f67b1 100644 --- a/crates/temporal-server/src/gateway/service/mcp_api.rs +++ b/crates/temporal-server/src/gateway/service/mcp_api.rs @@ -22,8 +22,8 @@ pub(super) fn put_mcp_server_record( allowed_tools: server.allowed_tools, execution: registry_execution(server.execution), exposure: registry_exposure(server.exposure), - approval_default: registry_approval(server.approval_default), - defer_loading_default: server.defer_loading_default, + approval: registry_approval(server.approval), + defer_loading: server.defer_loading, allow_private_network: server.allow_private_network, auth_policy: registry_auth_policy(server.auth_policy), auth_grant_id, @@ -42,8 +42,8 @@ pub(super) fn mcp_server_view(record: mcp::McpServerRecord) -> api::McpServerVie allowed_tools: record.allowed_tools, execution: api_execution(record.execution), exposure: api_exposure(record.exposure), - approval_default: api_approval(record.approval_default), - defer_loading_default: record.defer_loading_default, + approval: api_approval(record.approval), + defer_loading: record.defer_loading, allow_private_network: record.allow_private_network, auth_policy: api_auth_policy(record.auth_policy), credential: record @@ -134,11 +134,11 @@ pub(super) fn validate_mcp_server_credential( } } -/// Resolve one declared config link against its catalog record and grant +/// Resolve one declared config attachment against its catalog record and grant /// into the remote MCP tool spec. Shared by put-time validation and toolset -/// reconciliation, so a config put fails fast when a link cannot resolve. -pub(super) fn mcp_tool_from_config_link( - _link: &engine::McpServerLink, +/// reconciliation, so a config put fails fast when an attachment cannot resolve. +pub(super) fn mcp_tool_from_config_attachment( + attachment: &engine::McpServerAttachment, record: &mcp::McpServerRecord, grant: Option<&auth::AuthGrantRecord>, ) -> Result { @@ -151,13 +151,27 @@ pub(super) fn mcp_tool_from_config_link( } mcp::McpServerStatus::NeedsAuthConfig => { return Err(AgentApiError::rejected(format!( - "MCP server needs auth configuration before linking: {}", + "MCP server needs auth configuration before attaching: {}", record.server_id ))); } mcp::McpServerStatus::Active | mcp::McpServerStatus::Unverified => {} } + let allowed_tools = match &attachment.tools { + Some(tools) => { + if let Some(allowed) = &record.allowed_tools + && let Some(tool) = tools.iter().find(|tool| !allowed.contains(tool)) + { + return Err(AgentApiError::invalid_request(format!( + "MCP tool {tool} is outside the allowlist for server {}", + record.server_id + ))); + } + Some(tools.clone()) + } + None => record.allowed_tools.clone(), + }; let tool_name = default_mcp_tool_name(&record.default_server_label)?; let auth_ref = auth_ref_for_server(record, grant)?; Ok(engine::ToolSpec { @@ -169,11 +183,11 @@ pub(super) fn mcp_tool_from_config_link( server_label: record.default_server_label.clone(), server_url: record.server_url.clone(), description_ref: None, - allowed_tools: record.allowed_tools.clone(), + allowed_tools, execution: engine_execution(record.execution), exposure: engine_exposure(record.exposure), - approval: engine_approval(api_approval(record.approval_default)), - defer_loading: record.defer_loading_default, + approval: engine_approval(api_approval(record.approval)), + defer_loading: record.defer_loading, auth_ref, auth_required: matches!( record.auth_policy, @@ -186,11 +200,11 @@ pub(super) fn mcp_tool_from_config_link( }) } -impl GatewayAgentApi { - /// Resolve the config's declared MCP links into the desired remote tool +impl super::session_preparation::SessionPreparationService { + /// Resolve the config's declared MCP attachments into the desired remote tool /// specs, loading catalog records and auth grants. Used both to validate /// a config document at admission and to reconcile the session toolset. - pub(super) async fn desired_mcp_tools( + pub(crate) async fn desired_mcp_tools( &self, features: &engine::FeaturesConfig, ) -> Result, AgentApiError> { @@ -199,8 +213,8 @@ impl GatewayAgentApi { }; let mut tools = BTreeMap::new(); let mut search_descriptions = Vec::new(); - for link in &mcp.servers { - let server_id = parse_mcp_server_id(link.server_id.clone())?; + for attachment in &mcp.servers { + let server_id = parse_mcp_server_id(attachment.server_id.clone())?; let record = self .store .read_server(&server_id) @@ -215,7 +229,7 @@ impl GatewayAgentApi { ), None => None, }; - let tool = mcp_tool_from_config_link(link, &record, grant.as_ref())?; + let tool = mcp_tool_from_config_attachment(attachment, &record, grant.as_ref())?; if record.execution == mcp::McpExecution::Native && record.exposure == mcp::McpExposure::Search { @@ -484,6 +498,15 @@ fn api_status(value: mcp::McpServerStatus) -> api::McpServerStatus { } } +impl GatewayAgentApi { + pub(super) async fn desired_mcp_tools( + &self, + features: &engine::FeaturesConfig, + ) -> Result, AgentApiError> { + self.preparation_service().desired_mcp_tools(features).await + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/temporal-server/src/gateway/service/mod.rs b/crates/temporal-server/src/gateway/service/mod.rs index b8dcfa66..2316f110 100644 --- a/crates/temporal-server/src/gateway/service/mod.rs +++ b/crates/temporal-server/src/gateway/service/mod.rs @@ -1,36 +1,31 @@ //! `api` gateway for the Temporal-backed agent workflow. mod api_config; -mod auth_api; +pub(crate) mod auth_api; mod blobs; mod bots_api; mod catalogs; pub(crate) mod channels_api; mod common; -mod environment_credentials; -mod environment_lifecycle; -mod environment_power; -mod environment_projection; -pub(crate) mod environment_providers; -mod environment_registration; +pub(crate) use crate::environments::lifecycle as environment_lifecycle; +pub(crate) use crate::environments::power as environment_power; +pub(crate) use crate::environments::providers as environment_providers; mod environments; mod errors; mod event_history; mod github_api; mod input; -mod instructions; mod mcp_api; pub(crate) mod mcp_discovery; mod models_api; mod oauth_api; mod parse; mod profiles; -mod prompts; -mod provider_controllers; +pub(crate) use crate::environments::provider_controllers; mod session_jobs; -mod session_toolset; +mod session_lifecycle; +pub(crate) mod session_preparation; mod skills; -mod subagents_api; mod vfs_api; mod workflow; @@ -48,7 +43,7 @@ use common::now_ms; pub use environment_lifecycle::ReconcileFailureLog; use environment_lifecycle::parse_registry_environment_id; pub use environment_power::PowerReaperStats; -use environment_providers::{map_environments_error, parse_environment_provider_id}; +use environment_providers::map_environments_error; use environments::{activate_environment_command, deactivate_environment_command}; use errors::*; use github_api::{ @@ -67,9 +62,7 @@ use oauth_api::{ parse_oauth_client_id, }; use parse::*; -use provider_controllers::{ - ProviderControllerConnector, WebSocketProviderControllerConnector, finish_provider_controller, -}; +use provider_controllers::{ProviderControllerConnector, WebSocketProviderControllerConnector}; #[cfg(test)] use skills::skill_list_response; #[cfg(test)] @@ -119,17 +112,14 @@ use temporalio_client::{ use temporalio_common::protos::temporal::api::enums::v1::WorkflowExecutionStatus; use tools::{ builtin::{BuiltinTool, BuiltinToolOperation}, - catalog::{SKILL_CATALOG_CONTEXT_KEY, SUBAGENT_CATALOG_CONTEXT_KEY, VFS_CATALOG_CONTEXT_KEY}, + catalog::SKILL_CATALOG_CONTEXT_KEY, environment::jobs::{ JOB_RUN_DEADLINE_AFTER_MS, JOB_RUN_WORKFLOW_SEMANTIC_TYPE, JOB_RUN_WORKFLOW_TOOL_ID, JOB_SUBMIT_WORKFLOW_SEMANTIC_TYPE, JOB_SUBMIT_WORKFLOW_TOOL_ID, }, - skills::{ - SkillCatalogSnapshot, SkillLocation, configured_vfs_skill_root_specs, - resolve_linked_vfs_skill_roots, - }, + skills::{SkillCatalogSnapshot, SkillLocation}, toolset::{ - RegisteredToolset, ToolsetConfig, enable_concurrency_for_workflow_tools, register_toolset, + ToolsetConfig, enable_concurrency_for_workflow_tools, register_toolset, register_workflow_tools, }, web::search::WebSearchToolConfig, @@ -555,7 +545,7 @@ pub struct GatewayAgentApiBuilder { model_discovery_openai: Option>, model_discovery_anthropic: Option>, provider_controller_connector: Arc, - environment_gateway: crate::environment_gateway::EnvironmentGatewayClientConfig, + environment_gateway: crate::environments::gateway::EnvironmentGatewayClientConfig, } impl GatewayAgentApiBuilder { @@ -632,7 +622,7 @@ impl GatewayAgentApiBuilder { pub fn with_environment_gateway( mut self, - gateway: crate::environment_gateway::EnvironmentGatewayClientConfig, + gateway: crate::environments::gateway::EnvironmentGatewayClientConfig, ) -> Self { self.environment_gateway = gateway; self @@ -787,12 +777,12 @@ pub struct GatewayAgentApi { github_api: Arc, model_discovery: ModelDiscoveryService, provider_controller_connector: Arc, - pub(crate) environment_gateway: crate::environment_gateway::EnvironmentGatewayClientConfig, + pub(crate) environment_gateway: crate::environments::gateway::EnvironmentGatewayClientConfig, } impl GatewayAgentApi { pub fn builder(client: Client, store: Arc) -> GatewayAgentApiBuilder { - let environment_gateway = crate::environment_gateway::EnvironmentGatewayClientConfig::new( + let environment_gateway = crate::environments::gateway::EnvironmentGatewayClientConfig::new( DEFAULT_PUBLIC_BASE_URL, format!("local-{}", uuid::Uuid::new_v4()), ); @@ -848,19 +838,6 @@ impl GatewayAgentApi { .build()) } - pub async fn open_or_start_session( - &self, - params: SessionStartParams, - ) -> Result, AgentApiError> { - // `start_session` is idempotent on client-supplied session ids; this - // wrapper remains for callers predating that behavior. - self.start_session(params).await - } - - fn allocate_session_id(&self) -> SessionId { - SessionId::new(format!("session_{}", uuid::Uuid::new_v4().simple())) - } - fn allocate_submission_id(&self) -> SubmissionId { SubmissionId::new(format!("submit_{}", uuid::Uuid::new_v4().simple())) } @@ -873,139 +850,11 @@ impl GatewayAgentApi { include_environment_tools: bool, include_job_read_tool: bool, ) -> ToolsetConfig { - let features = &session_config.features; - let mut config = ToolsetConfig::empty(); - config.environment_read = features.environments.is_some(); - config.environment_selection = features - .environments - .as_ref() - .is_some_and(|environments| environments.selection_tools); - config.builtin = match features.vfs.as_ref().and_then(|vfs| vfs.tools) { - None => tools::toolset::BuiltinToolsetConfig::disabled(), - Some(engine::VfsToolSurface::ReadOnly) => tools::toolset::BuiltinToolsetConfig { - vfs: tools::toolset::FilesystemToolsetConfig::read_only(), - ..tools::toolset::BuiltinToolsetConfig::disabled() - }, - Some(engine::VfsToolSurface::Edit) => tools::toolset::BuiltinToolsetConfig::workspace(), - }; - if let Some(web) = features.web.as_ref() { - if let Some(search) = &web.search { - config.web.search = Some(WebSearchToolConfig::new( - search.allowed_domains.clone().unwrap_or_default(), - search.blocked_domains.clone(), - )); - } - if web.fetch.is_some() { - config.web.fetch = true; - } - } - if features.timers.is_some() || features.subagents.is_some() { - // Joining spawned sub-agents depends on the base concurrency - // tools, so the subagents grant implies them; the timers grant - // adds nothing extra today beyond the same surface. - config.concurrency = tools::concurrency::ConcurrencyToolsetConfig::timer(); - } - if include_environment_tools && let Some(environment) = &features.environments { - config.builtin.environment.filesystem = match environment.tools { - None => tools::toolset::FilesystemToolsetConfig::disabled(), - Some(engine::EnvironmentToolSurface::ReadOnly) => { - tools::toolset::FilesystemToolsetConfig::read_only() - } - Some(engine::EnvironmentToolSurface::Edit) => { - tools::toolset::FilesystemToolsetConfig::workspace_edit() - } - }; - config.builtin.environment.run_process = environment.commands; - config.builtin.environment.continue_process = environment.commands; - } - if include_job_read_tool { - config.builtin.environment.job_read = true; - } - config - } - - #[allow(clippy::too_many_arguments)] - fn workflow_args( - &self, - session_id: SessionId, - display_name: Option, - metadata: BTreeMap, - delete_after_close_ms: Option, - session_config: SessionConfig, - workflow_tools: Option, - close_on_terminal: bool, - auto_reject_approvals: bool, - ) -> AgentSessionArgs { - AgentSessionArgs { - universe_id: self.universe_id(), - session_id, - display_name, - metadata, - delete_after_close_ms, + session_preparation::SessionPreparationService::session_toolset_config( session_config, - workflow_tools, - legacy_max_steps_per_input: None, - continue_as_new_history_threshold: self.continue_as_new_history_threshold, - close_on_terminal, - auto_reject_approvals, - continuation_state: None, - } - } - - /// Sub-agent child creation: the child's store row already - /// exists with its origin (the execution's reservation); this opens its - /// workflow with the pinned profile applied. The execution closes the - /// child, so `close_on_terminal` stays off. - pub(crate) async fn start_session_for_subagent( - &self, - session_id: &SessionId, - profile: ProfileSource, - ) -> Result<(), AgentApiError> { - self.start_session_internal( - SessionStartParams { - metadata: Default::default(), - session_id: Some(session_id.as_str().to_owned()), - display_name: None, - config: None, - profile: Some(profile), - environment: None, - // Delegated children inherit their retention root and never - // apply a profile's root-session default. - delete_after_close_ms: Some(None), - }, - false, - true, - None, + include_environment_tools, + include_job_read_tool, ) - .await?; - Ok(()) - } - - /// Trusted in-process workflow-plugin entry point. The main API exposes - /// the same immutable target and completion vocabulary through wire DTOs. - pub async fn start_managed_session_for_workflow_with_profile( - &self, - session_id: &SessionId, - close_on_terminal: bool, - profile: Option, - workflow_tools: ManagedSessionWorkflowTools, - ) -> Result<(), AgentApiError> { - self.start_session_internal( - SessionStartParams { - metadata: Default::default(), - session_id: Some(session_id.as_str().to_owned()), - display_name: None, - config: None, - profile, - environment: None, - delete_after_close_ms: None, - }, - close_on_terminal, - false, - Some(workflow_tools), - ) - .await?; - Ok(()) } /// Sub-agent run start: identical to the public `session/runs/start` @@ -1094,21 +943,6 @@ impl GatewayAgentApi { "session is not open: {session_id}" ))); } - // MCP server records are universe-owned mutable policy. Reconcile the - // linked records before every new run so exposure and allowlist edits - // do not remain pinned to the session's previous materialization. A - // tool patch cannot move the revision of a request already in flight, - // so signal it first and let the workflow apply it at the next turn - // boundary before the subsequently queued run uses the toolset. - let turn_in_flight = loaded - .state - .runs - .active - .as_ref() - .is_some_and(|run| run.active_turn_id.is_some()); - let _ = self - .configure_session_toolset(&session_id, &loaded, !turn_in_flight) - .await?; let status_before_signal = self.query_status_optional(&session_id).await?; let baseline_admission_failures = status_before_signal .as_ref() @@ -1137,621 +971,6 @@ impl GatewayAgentApi { Ok(AgentApiOutcome::new(RunStartResponse { run })) } - async fn start_session_internal( - &self, - params: SessionStartParams, - close_on_terminal: bool, - auto_reject_approvals: bool, - trusted_workflow_tools: Option, - ) -> Result, AgentApiError> { - let SessionStartParams { - session_id, - display_name, - metadata, - config, - profile, - environment, - delete_after_close_ms, - } = params; - validate_caller_metadata(&metadata)?; - let workflow_tools = trusted_workflow_tools; - let client_supplied_id = session_id.is_some(); - let session_id = match session_id { - Some(session_id) => { - // System workflow ids share the `{universe}/…` namespace - // with sessions; their segments are reserved. - if let Some(prefix) = ::bots::ids::RESERVED_SESSION_ID_PREFIXES - .iter() - .find(|prefix| session_id.starts_with(*prefix)) - { - return Err(AgentApiError::invalid_request(format!( - "session id prefix `{prefix}` is reserved for system workflows" - ))); - } - SessionId::try_new(session_id).map_err(|error| { - AgentApiError::invalid_request(format!("invalid session id: {error}")) - })? - } - None => self.allocate_session_id(), - }; - if let Some(workflow_tools) = workflow_tools.as_ref() { - self.validate_managed_session_declaration(workflow_tools)?; - } - if client_supplied_id { - match self.load_session_state(&session_id).await { - Ok(loaded) if loaded.state.lifecycle.status == CoreAgentStatus::Closed => { - if let Some(workflow_tools) = workflow_tools.as_ref() { - validate_managed_session_retry( - &loaded.state, - self.universe_id(), - workflow_tools, - )?; - } - let session = self.session_mutation_view_by_id(&session_id).await?; - return Ok(AgentApiOutcome::new(SessionStartResponse { session })); - } - Ok(loaded) => { - if let Some(workflow_tools) = workflow_tools.as_ref() { - validate_managed_session_retry( - &loaded.state, - self.universe_id(), - workflow_tools, - )?; - } - } - Err(error) if is_not_found(&error) => {} - Err(error) => return Err(error), - } - } - let mut resolved_profile = match profile { - Some(source) => Some(self.resolve_profile_source(source).await?), - None => None, - }; - if let Some(environment) = environment { - let environment = match environment { - SessionEnvironmentOverride::None {} => None, - SessionEnvironmentOverride::Existing { environment_id } => { - Some(ProfileEnvironment::Existing { environment_id }) - } - }; - ::profiles::validate_profile_document(&ProfileDocument { - environment: environment.clone(), - ..Default::default() - }) - .map_err(profiles::map_profile_error)?; - if let Some(profile) = resolved_profile.as_mut() { - profile.document.environment = environment; - } else if environment.is_some() { - resolved_profile = Some(profiles::ResolvedAgentProfile { - profile_id: None, - document: ProfileDocument { - environment, - ..Default::default() - }, - }); - } - } - let effective_metadata = profiles::merge_profile_start_metadata( - resolved_profile - .as_ref() - .map(|profile| &profile.document.metadata), - metadata, - ); - validate_caller_metadata(&effective_metadata)?; - let effective_delete_after_close_ms = profiles::merge_profile_start_retention( - resolved_profile - .as_ref() - .and_then(|profile| profile.document.retention.as_ref()) - .map(|retention| retention.delete_after_close_ms), - delete_after_close_ms, - ); - validate_delete_after_close_ms(effective_delete_after_close_ms)?; - let start_config = self.merge_profile_start_config( - resolved_profile - .as_ref() - .and_then(|profile| profile.document.config.clone()), - config, - ); - let session_config = self.session_config_for_start(start_config).await?; - if let Some(ProfileEnvironment::Provision { - provider_id, - credentials, - .. - }) = resolved_profile - .as_ref() - .and_then(|profile| profile.document.environment.as_ref()) - { - // Fail the common misconfigurations before a session or a VM - // exists: the universe needs an enabled binding for the provider, - // the requested credentials must resolve here, and the effective - // config must let the session use it. - self.resolve_profile_provision_binding(provider_id).await?; - self.validate_profile_environment_credentials(credentials) - .await?; - let feature = session_config.features.environments.as_ref().ok_or_else(|| { - AgentApiError::rejected( - "profile provisions an environment but the effective session config does not grant features.environments", - ) - })?; - if feature - .providers - .as_ref() - .is_some_and(|providers| !providers.iter().any(|id| id == provider_id)) - { - return Err(AgentApiError::rejected(format!( - "profile provisions from environment provider {provider_id}, which features.environments.providers does not allow" - ))); - } - } - if let Some(workflow_tools) = workflow_tools.as_ref() { - self.validate_managed_session_materialization(&session_config, workflow_tools) - .await?; - } - let args = self.workflow_args( - session_id.clone(), - display_name, - effective_metadata, - effective_delete_after_close_ms, - session_config, - workflow_tools.clone(), - close_on_terminal, - auto_reject_approvals, - ); - self.refresh_input_blob_grace(&args).await?; - let started = self - .client - .start_workflow( - AgentSessionWorkflow::run, - args, - WorkflowStartOptions::new( - self.task_queue.clone(), - self.workflow_id_for(&session_id), - ) - .build(), - ) - .await - .map_err(map_workflow_start_error); - match started { - Ok(_) => {} - Err(error) - if matches!(error.kind, AgentApiErrorKind::Conflict) && client_supplied_id => - { - let loaded = self.load_session_state(&session_id).await?; - if let Some(workflow_tools) = workflow_tools.as_ref() { - validate_managed_session_retry( - &loaded.state, - self.universe_id(), - workflow_tools, - )?; - } - if loaded.state.lifecycle.status == CoreAgentStatus::Closed { - let session = self.session_mutation_view_by_id(&session_id).await?; - return Ok(AgentApiOutcome::new(SessionStartResponse { session })); - } - self.wait_for_open_session(&session_id).await?; - let session = self.session_mutation_view_by_id(&session_id).await?; - return Ok(AgentApiOutcome::new(SessionStartResponse { session })); - } - Err(error) => return Err(error), - } - self.wait_for_open_session(&session_id).await?; - let loaded = self.load_session_state(&session_id).await?; - if let Some(workflow_tools) = workflow_tools.as_ref() { - validate_managed_session_retry(&loaded.state, self.universe_id(), workflow_tools)?; - } - let _ = self - .configure_session_toolset(&session_id, &loaded, true) - .await?; - if let Some(profile) = resolved_profile { - self.apply_profile_document(&session_id, &profile, false, None, None) - .await?; - } - self.load_session_state_with_current_run_context(&session_id) - .await?; - let session = self.session_mutation_view_by_id(&session_id).await?; - Ok(AgentApiOutcome::new(SessionStartResponse { session })) - } - - async fn core_environment_job_workflow_tool_declarations( - &self, - ) -> Result, AgentApiError> { - let recipe_bytes = serde_json::to_vec(&temporal_workflow::WorkflowToolRecipeV1 { - workflow_type: "EnvironmentJobWorkflow".to_owned(), - task_queue: self.task_queue.clone(), - }) - .map_err(|error| { - AgentApiError::internal(format!( - "encode core environment-job workflow recipe: {error}" - )) - })?; - let recipe_fingerprint = temporal_workflow::workflow_tool_recipe_fingerprint(&recipe_bytes); - let recipe_ref = self - .store - .put_bytes(recipe_bytes) - .await - .map_err(map_blob_store_error)?; - - let definitions = [ - ( - BuiltinToolOperation::JobSubmit, - JOB_SUBMIT_WORKFLOW_TOOL_ID, - JOB_SUBMIT_WORKFLOW_SEMANTIC_TYPE, - WorkflowToolCompletion::Promises { - reply_schema_ref: None, - deadline_after_ms: None, - max_promises: engine::MAX_COMPLETION_PROMISES, - key_source: WorkflowToolCompletionKeySource::ArrayItemField { - pointer: "/jobs".to_owned(), - field: "job_id".to_owned(), - }, - }, - ), - ( - BuiltinToolOperation::JobRun, - JOB_RUN_WORKFLOW_TOOL_ID, - JOB_RUN_WORKFLOW_SEMANTIC_TYPE, - WorkflowToolCompletion::Joined { - reply_schema_ref: None, - deadline_after_ms: JOB_RUN_DEADLINE_AFTER_MS, - }, - ), - ]; - let mut declarations = Vec::with_capacity(definitions.len()); - for (operation, tool_id, semantic_type, completion) in definitions { - let builtin = BuiltinTool::environment_canonical(operation); - let tool = tools::definitions::register( - builtin.logical_id(), - tools::definitions::BuiltinSettings { - presentation: tools::toolset::BuiltinToolPresentation::Canonical, - unscoped_paths: true, - ..Default::default() - }, - builtin.parallelism(), - builtin.execution_spec(), - ); - declarations.push(WorkflowToolDeclaration::new( - WorkflowToolDefinition { - tool_id: WorkflowToolId::new(tool_id), - revision: 1, - semantic_type: semantic_type.to_owned(), - tool, - }, - WorkflowToolTarget::Start { - start: WorkflowStartRef { - recipe_format: temporal_workflow::WORKFLOW_TOOL_RECIPE_FORMAT_V1, - revision: 1, - recipe_ref: recipe_ref.clone(), - recipe_fingerprint: recipe_fingerprint.clone(), - }, - }, - completion, - )); - } - Ok(declarations) - } - - async fn core_subagent_workflow_tool_declarations( - &self, - ) -> Result, AgentApiError> { - let recipe_bytes = serde_json::to_vec(&temporal_workflow::WorkflowToolRecipeV1 { - workflow_type: tools::subagents::SUBAGENT_WORKFLOW_TYPE.to_owned(), - task_queue: self.task_queue.clone(), - }) - .map_err(|error| { - AgentApiError::internal(format!("encode core subagent workflow recipe: {error}")) - })?; - let recipe_fingerprint = temporal_workflow::workflow_tool_recipe_fingerprint(&recipe_bytes); - let recipe_ref = self - .store - .put_bytes(recipe_bytes) - .await - .map_err(map_blob_store_error)?; - // The binding carries the hard ceiling; the grant's `deadlineMs` is - // pinned per call and enforced inside the execution, so the - // immutable binding never has to change with the grant. - let definitions = [ - ( - tools::subagents::SubagentToolKind::Run, - WorkflowToolCompletion::Joined { - reply_schema_ref: None, - deadline_after_ms: engine::SUBAGENT_DEADLINE_CEILING_MS, - }, - ), - ( - tools::subagents::SubagentToolKind::Spawn, - WorkflowToolCompletion::Promises { - reply_schema_ref: None, - deadline_after_ms: Some(engine::SUBAGENT_DEADLINE_CEILING_MS), - max_promises: 1, - key_source: WorkflowToolCompletionKeySource::Reply, - }, - ), - ]; - let mut declarations = Vec::with_capacity(definitions.len()); - for (kind, completion) in definitions { - let tool = tools::definitions::register( - match kind { - tools::subagents::SubagentToolKind::Run => "subagent.run", - tools::subagents::SubagentToolKind::Spawn => "subagent.spawn", - }, - Default::default(), - engine::ToolParallelism::ParallelSafe, - Default::default(), - ); - declarations.push(WorkflowToolDeclaration::new( - WorkflowToolDefinition { - tool_id: WorkflowToolId::new(kind.workflow_tool_id()), - revision: 1, - semantic_type: kind.semantic_type().to_owned(), - tool, - }, - WorkflowToolTarget::Start { - start: WorkflowStartRef { - recipe_format: temporal_workflow::WORKFLOW_TOOL_RECIPE_FORMAT_V1, - revision: 1, - recipe_ref: recipe_ref.clone(), - recipe_fingerprint: recipe_fingerprint.clone(), - }, - }, - completion, - )); - } - Ok(declarations) - } - - async fn ensure_core_subagent_workflow_tools( - &self, - session_id: &SessionId, - state: &engine::CoreAgentState, - ) -> Result<(), AgentApiError> { - if has_all_core_subagent_bindings(state) { - return Ok(()); - } - let baseline_failures = self - .query_status_optional(session_id) - .await? - .map(|status| status.admission_failures.len()) - .unwrap_or(0); - let declarations = self.core_subagent_workflow_tool_declarations().await?; - for declaration in declarations { - if state - .workflow_tools - .bindings - .contains_key(&declaration.definition.tool_id) - { - continue; - } - self.submit_core_command( - session_id, - CoreAgentCommand::AdmitSystemWorkflowTool { - session_universe_id: self.universe_id(), - declaration, - }, - ) - .await?; - } - self.wait_for_core_subagent_bindings(session_id, baseline_failures) - .await - } - - async fn wait_for_core_subagent_bindings( - &self, - session_id: &SessionId, - baseline_failures: usize, - ) -> Result<(), AgentApiError> { - let started = Instant::now(); - loop { - if started.elapsed() > self.operation_timeout { - return Err(AgentApiError::internal(format!( - "timed out waiting for core subagent workflow tool admission: {session_id}" - ))); - } - if let Some(status) = self.query_status_optional(session_id).await? { - if status.admission_failures.len() > baseline_failures - && let Some(failure) = status.admission_failures.last() - { - return Err(map_admission_failure_to_api_error(failure)); - } - if let Some(error) = status.last_error { - return Err(AgentApiError::internal(format!( - "agent workflow reported error: {error}" - ))); - } - } - let loaded = self.load_session_state(session_id).await?; - if has_all_core_subagent_bindings(&loaded.state) { - return Ok(()); - } - tokio::time::sleep(self.poll_interval).await; - } - } - - async fn ensure_core_environment_job_workflow_tools( - &self, - session_id: &SessionId, - state: &engine::CoreAgentState, - ) -> Result<(), AgentApiError> { - if has_all_core_environment_job_bindings(state) { - return Ok(()); - } - let baseline_failures = self - .query_status_optional(session_id) - .await? - .map(|status| status.admission_failures.len()) - .unwrap_or(0); - let declarations = self - .core_environment_job_workflow_tool_declarations() - .await?; - for declaration in declarations { - if state - .workflow_tools - .bindings - .contains_key(&declaration.definition.tool_id) - { - continue; - } - self.submit_core_command( - session_id, - CoreAgentCommand::AdmitSystemWorkflowTool { - session_universe_id: self.universe_id(), - declaration, - }, - ) - .await?; - } - let started = Instant::now(); - loop { - if started.elapsed() > self.operation_timeout { - return Err(AgentApiError::internal(format!( - "timed out waiting for core environment-job workflow tool admission: {session_id}" - ))); - } - if let Some(status) = self.query_status_optional(session_id).await? { - if status.admission_failures.len() > baseline_failures - && let Some(failure) = status.admission_failures.last() - { - return Err(map_admission_failure_to_api_error(failure)); - } - if let Some(error) = status.last_error { - return Err(AgentApiError::internal(format!( - "agent workflow reported error: {error}" - ))); - } - } - let loaded = self.load_session_state(session_id).await?; - if has_all_core_environment_job_bindings(&loaded.state) { - return Ok(()); - } - tokio::time::sleep(self.poll_interval).await; - } - } - - fn validate_managed_session_declaration( - &self, - workflow_tools: &ManagedSessionWorkflowTools, - ) -> Result<(), AgentApiError> { - workflow_tools.admit(self.universe_id()).map_err(|error| { - AgentApiError::invalid_request(format!( - "invalid managed-session workflow-tool declaration: {error}" - )) - })?; - Ok(()) - } - - async fn validate_managed_session_materialization( - &self, - session_config: &SessionConfig, - workflow_tools: &ManagedSessionWorkflowTools, - ) -> Result<(), AgentApiError> { - let admitted = workflow_tools.admit(self.universe_id()).map_err(|error| { - AgentApiError::invalid_request(format!( - "invalid managed-session workflow-tool declaration: {error}" - )) - })?; - for binding in &admitted.bindings { - validate_workflow_tool_definition_documents(self.store.as_ref(), &binding.definition) - .await - .map_err(|error| { - AgentApiError::invalid_request(format!( - "invalid workflow tool {} documents: {error}", - binding.definition.tool_id - )) - })?; - if let WorkflowToolCompletion::Joined { - reply_schema_ref: Some(reply_schema_ref), - .. - } - | WorkflowToolCompletion::Promises { - reply_schema_ref: Some(reply_schema_ref), - .. - } = &binding.completion - { - validate_workflow_tool_reply_schema(self.store.as_ref(), reply_schema_ref) - .await - .map_err(|error| { - AgentApiError::invalid_request(format!( - "invalid workflow tool {} reply schema: {error}", - binding.definition.tool_id - )) - })?; - } - if let WorkflowToolTarget::Start { start } = &binding.target { - self.validate_workflow_tool_start_recipe(&binding.definition.tool_id, start) - .await?; - } - } - - let materialized_bindings = admitted - .bindings - .iter() - .filter(|binding| !is_core_environment_job_binding(binding)) - .collect::>(); - - let mut config = Self::session_toolset_config(session_config, false, false); - enable_concurrency_for_workflow_tools(&mut config, materialized_bindings.iter().copied()); - let mut toolset = register_toolset(&config).map_err(|error| { - AgentApiError::invalid_request(format!("build session tools: {error}")) - })?; - register_workflow_tools(&mut toolset, materialized_bindings.iter().copied()).map_err( - |error| { - AgentApiError::invalid_request(format!("materialize workflow tool tools: {error}")) - }, - )?; - let desired_mcp = self.desired_mcp_tools(&session_config.features).await?; - if let Some(colliding) = materialized_bindings - .iter() - .copied() - .map(|binding| &binding.definition.tool.name) - .find(|tool_name| desired_mcp.contains_key(*tool_name)) - { - return Err(AgentApiError::invalid_request(format!( - "workflow tool tool name {colliding} collides with a remote MCP tool" - ))); - } - Ok(()) - } - - async fn validate_workflow_tool_start_recipe( - &self, - tool_id: &WorkflowToolId, - start: &WorkflowStartRef, - ) -> Result<(), AgentApiError> { - let recipe_bytes = self - .store - .read_bytes(&start.recipe_ref) - .await - .map_err(|error| { - AgentApiError::invalid_request(format!( - "invalid workflow tool {tool_id} start recipe: {error}" - )) - })?; - let observed = temporal_workflow::workflow_tool_recipe_fingerprint(&recipe_bytes); - if observed != start.recipe_fingerprint { - return Err(AgentApiError::invalid_request(format!( - "invalid workflow tool {tool_id} start recipe fingerprint: admitted {} observed {observed}", - start.recipe_fingerprint - ))); - } - if start.recipe_format != temporal_workflow::WORKFLOW_TOOL_RECIPE_FORMAT_V1 { - return Err(AgentApiError::invalid_request(format!( - "invalid workflow tool {tool_id} start recipe format {}", - start.recipe_format - ))); - } - let recipe: temporal_workflow::WorkflowToolRecipeV1 = serde_json::from_slice(&recipe_bytes) - .map_err(|error| { - AgentApiError::invalid_request(format!( - "invalid workflow tool {tool_id} start recipe v1: {error}" - )) - })?; - if recipe.workflow_type.is_empty() || recipe.task_queue.is_empty() { - return Err(AgentApiError::invalid_request(format!( - "invalid workflow tool {tool_id} start recipe v1: workflowType and taskQueue are required" - ))); - } - Ok(()) - } - fn projector(&self) -> CoreAgentProjector<'_> { CoreAgentProjector::new(self.store.as_ref()) } @@ -2250,36 +1469,6 @@ fn run_terminal_notify_intents( }]) } -fn validate_managed_session_retry( - state: &engine::CoreAgentState, - session_universe_id: uuid::Uuid, - workflow_tools: &ManagedSessionWorkflowTools, -) -> Result<(), AgentApiError> { - let expected = workflow_tools - .creation_fingerprint(session_universe_id) - .map_err(|error| { - AgentApiError::invalid_request(format!( - "invalid managed-session workflow-tool declaration: {error}" - )) - })?; - match ( - state.workflow_tools.session_universe_id, - state.workflow_tools.managed_creation_fingerprint.as_deref(), - ) { - (Some(actual_universe), Some(actual)) - if actual_universe == session_universe_id && actual == expected => - { - Ok(()) - } - (Some(_), Some(_)) => Err(AgentApiError::conflict( - "managed-session controller, receiver, or tool declaration conflicts with durable creation state", - )), - _ => Err(AgentApiError::conflict( - "existing standalone session cannot be reopened as a managed session", - )), - } -} - fn is_core_environment_job_tool_id(tool_id: &str) -> bool { matches!( tool_id, @@ -2295,20 +1484,6 @@ fn is_core_subagent_binding(binding: &engine::WorkflowToolBinding) -> bool { tools::subagents::is_subagent_workflow_tool_id(binding.definition.tool_id.as_str()) } -fn has_all_core_subagent_bindings(state: &engine::CoreAgentState) -> bool { - [ - tools::subagents::AGENT_RUN_WORKFLOW_TOOL_ID, - tools::subagents::AGENT_SPAWN_WORKFLOW_TOOL_ID, - ] - .into_iter() - .all(|tool_id| { - state - .workflow_tools - .bindings - .contains_key(&WorkflowToolId::new(tool_id)) - }) -} - fn validate_subagent_deadline_for_existing_bindings( state: &engine::CoreAgentState, features: &engine::FeaturesConfig, @@ -2351,17 +1526,6 @@ fn validate_subagent_deadline_for_existing_bindings( Ok(()) } -fn has_all_core_environment_job_bindings(state: &engine::CoreAgentState) -> bool { - [JOB_SUBMIT_WORKFLOW_TOOL_ID, JOB_RUN_WORKFLOW_TOOL_ID] - .into_iter() - .all(|tool_id| { - state - .workflow_tools - .bindings - .contains_key(&WorkflowToolId::new(tool_id)) - }) -} - #[async_trait] impl AgentApiService for GatewayAgentApi { // ── Bots ──────────────────────────────────────────────────────────── @@ -2677,7 +1841,7 @@ impl AgentApiService for GatewayAgentApi { /// Idempotent on a client-supplied session id: when the session already /// exists, the existing session view is returned (creation fields such as - /// config, metadata, profile, and environment override are ignored). + /// config, metadata, profile, and retention are ignored). /// This keeps a retried `session/start` + `session/runs/start` pair safe /// end to end. async fn start_session( @@ -2698,7 +1862,6 @@ impl AgentApiService for GatewayAgentApi { metadata, config, profile, - environment, delete_after_close_ms, workflow_tools, } = params; @@ -2710,7 +1873,6 @@ impl AgentApiService for GatewayAgentApi { metadata, config, profile, - environment, delete_after_close_ms, }, false, @@ -2792,9 +1954,6 @@ impl AgentApiService for GatewayAgentApi { "session config can only change while no run is active or queued", )); } - let current_config = loaded.state.lifecycle.config.as_ref().ok_or_else(|| { - AgentApiError::invalid_request(format!("session is missing config: {session_id}")) - })?; if let Some(expected) = params.expected_config_revision { let actual = loaded.state.lifecycle.config_revision; if expected != actual { @@ -2810,50 +1969,18 @@ impl AgentApiService for GatewayAgentApi { // Declared MCP links must resolve (catalog record, grant/policy // compatibility) before the document enters the session log. self.desired_mcp_tools(&config.features).await?; - self.validate_workspace_link_targets(&config.features) + self.validate_workspace_attachment_targets(&config.features) .await?; self.validate_subagent_agents(&config.features).await?; validate_subagent_deadline_for_existing_bindings(&loaded.state, &config.features)?; - if &config == current_config { - // The config event is an idempotent no-op, but derived tools and - // managed context may still need repair or reflect newer - // universe-owned registry records. - let _ = self - .configure_session_toolset(&session_id, &loaded, true) - .await?; - self.load_session_state_with_current_run_context(&session_id) - .await?; - return Ok(AgentApiOutcome::new(SessionConfigPutResponse { - session: self.session_mutation_view_by_id(&session_id).await?, - })); - } - let baseline_failures = self - .query_status_optional(&session_id) - .await? - .map(|status| status.admission_failures.len()) - .unwrap_or(0); - let target_revision = loaded - .state - .lifecycle - .config_revision - .checked_add(1) - .ok_or_else(|| AgentApiError::internal("config revision exhausted"))?; - self.submit_core_command( + self.prepare_session_operation( &session_id, - CoreAgentCommand::ReplaceSessionConfig { - expected_revision: Some(loaded.state.lifecycle.config_revision), + temporal_workflow::SessionOperation::Configure { config, + expected_revision: Some(loaded.state.lifecycle.config_revision), }, ) .await?; - self.wait_for_config_revision(&session_id, target_revision, baseline_failures) - .await?; - let loaded = self.load_session_state(&session_id).await?; - let _ = self - .configure_session_toolset(&session_id, &loaded, true) - .await?; - self.load_session_state_with_current_run_context(&session_id) - .await?; let session = self.session_mutation_view_by_id(&session_id).await?; Ok(AgentApiOutcome::new(SessionConfigPutResponse { session })) } @@ -3096,7 +2223,6 @@ impl AgentApiService for GatewayAgentApi { .await?; self.wait_for_closed_session(&session_id).await?; let session = self.session_mutation_view_by_id(&session_id).await?; - self.close_session_owned_environments(&session_id).await; return Ok(AgentApiOutcome::new(SessionCloseResponse { session })); } @@ -3108,7 +2234,6 @@ impl AgentApiService for GatewayAgentApi { .await .is_ok(); if signalled && self.wait_for_closed_session(&session_id).await.is_ok() { - self.close_session_owned_environments(&session_id).await; let session = self.session_mutation_view_by_id(&session_id).await?; return Ok(AgentApiOutcome::new(SessionCloseResponse { session })); } @@ -3125,7 +2250,6 @@ impl AgentApiService for GatewayAgentApi { // run status are projections of the log, so this alone recovers the // row; the expected-head CAS protects against a concurrent writer. self.force_close_session_in_store(&session_id).await?; - self.close_session_owned_environments(&session_id).await; let session = self.session_mutation_view_by_id(&session_id).await?; Ok(AgentApiOutcome::new(SessionCloseResponse { session })) } @@ -3824,7 +2948,10 @@ impl AgentApiService for GatewayAgentApi { self.signal_submit_admissions( &session_id, vec![AgentAdmission { - command: CoreAgentCommand::RequestRunSteering { input }, + command: CoreAgentCommand::RequestRunSteering { + run_id: requested_run_id, + input, + }, correlation_token: Some(correlation_token.clone()), }], ) @@ -4974,3 +4101,197 @@ mod tests; pub(crate) fn cimd_document_for(public_base_url: &str) -> serde_json::Value { oauth_api::cimd_document(public_base_url) } + +impl GatewayAgentApi { + pub(crate) fn environment_service(&self) -> crate::environments::EnvironmentService { + crate::environments::EnvironmentService { + store: self.store.clone(), + environment_gateway: self.environment_gateway.clone(), + provider_controller_connector: self.provider_controller_connector.clone(), + } + } + pub(super) async fn put_environment_ingress_record( + &self, + params: EnvironmentIngressPutParams, + ) -> Result { + self.environment_service() + .put_environment_ingress_record(params) + .await + } + + pub(super) async fn create_external_environment_record( + &self, + params: EnvironmentExternalCreateParams, + ) -> Result { + self.environment_service() + .create_external_environment_record(params) + .await + } + + pub(super) async fn create_environment_record( + &self, + params: EnvironmentCreateParams, + ) -> Result { + self.environment_service() + .create_environment_record(params) + .await + } + + pub(super) async fn put_environment_power_record( + &self, + params: EnvironmentPowerPutParams, + ) -> Result { + self.environment_service() + .put_environment_power_record(params) + .await + } + + pub(super) async fn put_environment_idle_policy_record( + &self, + params: EnvironmentIdlePolicyPutParams, + ) -> Result { + self.environment_service() + .put_environment_idle_policy_record(params) + .await + } + + pub(super) async fn read_environment_record( + &self, + params: EnvironmentReadParams, + ) -> Result { + self.environment_service() + .read_environment_record(params) + .await + } + + pub(super) async fn list_environment_records( + &self, + params: EnvironmentListParams, + ) -> Result { + self.environment_service() + .list_environment_records(params) + .await + } + + pub(super) async fn close_environment_record( + &self, + params: EnvironmentCloseParams, + ) -> Result { + self.environment_service() + .close_environment_record(params) + .await + } + + pub async fn reconcile_environments_once(&self) -> Result { + self.environment_service() + .reconcile_environments_once() + .await + } + + pub async fn reap_idle_environments_once(&self) -> Result { + self.environment_service() + .reap_idle_environments_once() + .await + } + + pub(super) async fn create_environment_registration_key_record( + &self, + params: EnvironmentRegistrationKeyCreateParams, + ) -> Result { + self.environment_service() + .create_environment_registration_key_record(params) + .await + } + + pub(super) async fn read_environment_registration_key_record( + &self, + params: EnvironmentRegistrationKeyReadParams, + ) -> Result { + self.environment_service() + .read_environment_registration_key_record(params) + .await + } + + pub(super) async fn list_environment_registration_key_records( + &self, + _params: EnvironmentRegistrationKeyListParams, + ) -> Result { + self.environment_service() + .list_environment_registration_key_records(_params) + .await + } + + pub(super) async fn revoke_environment_registration_key_record( + &self, + params: EnvironmentRegistrationKeyRevokeParams, + ) -> Result { + self.environment_service() + .revoke_environment_registration_key_record(params) + .await + } + + pub(super) async fn list_environment_provider_binding_records( + &self, + _params: EnvironmentProviderBindingListParams, + ) -> Result { + self.environment_service() + .list_environment_provider_binding_records(_params) + .await + } + + pub(super) async fn read_environment_provider_binding_record( + &self, + params: EnvironmentProviderBindingReadParams, + ) -> Result { + self.environment_service() + .read_environment_provider_binding_record(params) + .await + } + + pub(super) async fn list_environment_template_records( + &self, + params: EnvironmentTemplateListParams, + ) -> Result { + self.environment_service() + .list_environment_template_records(params) + .await + } + + pub(super) async fn read_environment_template_record( + &self, + params: EnvironmentTemplateReadParams, + ) -> Result { + self.environment_service() + .read_environment_template_record(params) + .await + } +} + +impl GatewayAgentApi { + pub(super) async fn bind_environment_credential_record( + &self, + params: EnvironmentCredentialBindParams, + ) -> Result { + self.environment_service() + .bind_environment_credential_record(params) + .await + } + + pub(super) async fn list_environment_credential_records( + &self, + params: EnvironmentCredentialListParams, + ) -> Result { + self.environment_service() + .list_environment_credential_records(params) + .await + } + + pub(super) async fn unbind_environment_credential_record( + &self, + params: EnvironmentCredentialUnbindParams, + ) -> Result { + self.environment_service() + .unbind_environment_credential_record(params) + .await + } +} diff --git a/crates/temporal-server/src/gateway/service/profiles.rs b/crates/temporal-server/src/gateway/service/profiles.rs index fa998202..8e653e5a 100644 --- a/crates/temporal-server/src/gateway/service/profiles.rs +++ b/crates/temporal-server/src/gateway/service/profiles.rs @@ -1,17 +1,7 @@ use super::api_config::engine_session_config_from_api; use super::*; -use ::environments::{EnvironmentProviderBindingStore, EnvironmentStore}; use ::profiles::{ProfileError, ProfileSourceExt, ProfileStore}; -const PROFILE_INSTRUCTIONS_CONTEXT_KEY: &str = "instructions.050.profile"; - -#[derive(Clone, Debug)] -pub(super) struct ResolvedAgentProfile { - /// Registry identity for named profiles; inline profiles have none. - pub(super) profile_id: Option, - pub(super) document: ProfileDocument, -} - pub(super) fn merge_profile_start_metadata( profile_metadata: Option<&BTreeMap>, explicit_metadata: BTreeMap, @@ -101,22 +91,25 @@ impl GatewayAgentApi { AgentApiError::invalid_request(format!("invalid session id: {error}")) })?; let resolved = self.resolve_profile_source(params.profile).await?; - let (session, applied) = self - .apply_profile_document( + let profile = self.profile_intent(&resolved, true)?; + let applied = self + .prepare_session_operation( &session_id, - &resolved, - true, - params.expected_config_revision, - params.expected_tools_revision, + temporal_workflow::SessionOperation::ApplyProfile { + profile, + expected_config_revision: params.expected_config_revision, + expected_tools_revision: params.expected_tools_revision, + }, ) .await?; + let session = self.project_session_by_id(&session_id).await?; Ok(ProfileApplyResponse { session, applied }) } pub(super) async fn resolve_profile_source( &self, source: ProfileSource, - ) -> Result { + ) -> Result { source.validate().map_err(map_profile_error)?; match source { ProfileSource::Named { profile_id } => { @@ -125,109 +118,12 @@ impl GatewayAgentApi { .read_agent_profile(&profile_id) .await .map_err(map_profile_error)?; - Ok(ResolvedAgentProfile { - profile_id: Some(profile.profile_id), - document: profile.document, - }) + Ok(profile.document) } - ProfileSource::Inline { profile } => Ok(ResolvedAgentProfile { - profile_id: None, - document: profile.document, - }), + ProfileSource::Inline { profile } => Ok(profile.document), } } - pub(super) async fn apply_profile_document( - &self, - session_id: &SessionId, - profile: &ResolvedAgentProfile, - apply_config: bool, - expected_config_revision: Option, - expected_tools_revision: Option, - ) -> Result<(SessionView, ProfileApplySummary), AgentApiError> { - let document = &profile.document; - let mut applied = ProfileApplySummary::default(); - - if apply_config { - if let Some(config) = document.config.clone() { - applied.config_changed = self - .apply_profile_config(session_id, config, expected_config_revision) - .await?; - } else if expected_config_revision.is_some() { - self.assert_config_revision(session_id, expected_config_revision) - .await?; - } - } - - applied.instructions_changed = self - .apply_profile_instructions(session_id, document.instructions.clone()) - .await?; - - if expected_tools_revision.is_some() { - self.assert_tools_revision(session_id, expected_tools_revision) - .await?; - } - - match &document.environment { - None => {} - Some(ProfileEnvironment::Existing { environment_id }) => { - applied.active_environment_changed = self - .apply_profile_active_environment(session_id, environment_id.clone()) - .await?; - } - Some(ProfileEnvironment::Inherit {}) => { - let environment_id = self.resolve_inherited_environment(session_id).await?; - applied.active_environment_changed = self - .apply_inherited_environment(session_id, environment_id) - .await?; - } - Some(ProfileEnvironment::Provision { - provider_id, - template_id, - display_name, - metadata, - retention, - idle_policy, - credentials, - }) => { - let (environment, provisioned) = self - .ensure_profile_provisioned_environment( - session_id, - profile.profile_id.as_ref(), - provider_id, - template_id, - display_name.clone(), - metadata.clone(), - *retention, - idle_policy.clone(), - ) - .await?; - if provisioned { - // Initial credential set for a freshly provisioned - // environment: ordinary bindings from here on; - // a re-apply that finds the environment does not resync. - self.bind_profile_environment_credentials( - environment.environment_id.as_str(), - credentials, - ) - .await?; - } - applied.environment_provisioned = provisioned; - applied.active_environment_changed = self - .apply_profile_active_environment( - session_id, - environment.environment_id.as_str().to_owned(), - ) - .await?; - } - } - - self.load_session_state_with_current_run_context(session_id) - .await?; - let session = self.project_session_by_id(session_id).await?; - Ok((session, applied)) - } - pub(super) fn merge_profile_start_config( &self, profile_config: Option, @@ -248,310 +144,51 @@ impl GatewayAgentApi { }) } - async fn assert_config_revision( - &self, - session_id: &SessionId, - expected: Option, - ) -> Result<(), AgentApiError> { - let Some(expected) = expected else { - return Ok(()); - }; - let loaded = self.load_session_state(session_id).await?; - let actual = loaded.state.lifecycle.config_revision; - if expected != actual { - return Err(AgentApiError::conflict(format!( - "expected config revision {expected}, got {actual}" - ))); - } - Ok(()) - } - - async fn assert_tools_revision( + /// With `apply_config`, the profile's own configuration is applied and + /// its default attachment is the environment fill candidate. Without it + /// (session start), the caller has merged the effective configuration + /// and sets the candidate itself. + pub(super) fn profile_intent( &self, - session_id: &SessionId, - expected: Option, - ) -> Result<(), AgentApiError> { - let Some(expected) = expected else { - return Ok(()); + profile: &ProfileDocument, + apply_config: bool, + ) -> Result { + let config = if apply_config { + profile + .config + .clone() + .map(|config| engine_session_config_from_api(config, self.default_model.clone())) + .transpose()? + } else { + None }; - let loaded = self.load_session_state(session_id).await?; - let actual = loaded.state.tooling.revision; - if expected != actual { - return Err(AgentApiError::conflict(format!( - "expected tools revision {expected}, got {actual}" - ))); - } - Ok(()) - } - - async fn apply_profile_config( - &self, - session_id: &SessionId, - config: api::SessionConfig, - expected_revision: Option, - ) -> Result { - let loaded = self.load_session_state(session_id).await?; - self.require_open_idle_session(session_id, &loaded, "profile config apply")?; - let current = loaded.state.lifecycle.config.as_ref().ok_or_else(|| { - AgentApiError::invalid_request(format!("session is missing config: {session_id}")) - })?; - if let Some(expected) = expected_revision { - let actual = loaded.state.lifecycle.config_revision; - if expected != actual { - return Err(AgentApiError::conflict(format!( - "expected config revision {expected}, got {actual}" - ))); - } - } - // Apply means "make the session's config the profile's config": - // full-document put semantics, sections absent from the profile - // revert to defaults. - let candidate = engine_session_config_from_api(config.clone(), self.default_model.clone())?; - candidate - .validate() - .map_err(|error| AgentApiError::invalid_request(error.to_string()))?; - if &candidate == current { - return Ok(false); - } - self.put_session_config(SessionConfigPutParams { - session_id: session_id.as_str().to_owned(), - expected_config_revision: Some(loaded.state.lifecycle.config_revision), + let environment = config + .as_ref() + .map(|config| default_environment_id(&config.features)) + .transpose()? + .flatten(); + Ok(temporal_workflow::SessionProfileIntent { config, + instructions: profile.instructions.clone(), + environment, }) - .await?; - Ok(true) - } - - async fn apply_profile_instructions( - &self, - session_id: &SessionId, - instructions: Option, - ) -> Result { - let mut source_entries = BTreeMap::new(); - if let Some(instructions) = instructions { - let content_ref = match instructions { - ProfileInstructions::Text { text } => self - .store - .as_ref() - .put_bytes(text.into_bytes()) - .await - .map_err(map_blob_store_error)?, - ProfileInstructions::TextRef { blob_ref } => { - let blob_ref = parse_blob_ref(&blob_ref)?; - if !self - .store - .as_ref() - .has_blob(&blob_ref) - .await - .map_err(map_blob_store_error)? - { - return Err(AgentApiError::not_found(format!( - "profile instructions blob not found: {blob_ref}" - ))); - } - blob_ref - } - }; - source_entries.insert( - ContextEntryKey::new(PROFILE_INSTRUCTIONS_CONTEXT_KEY), - ContextEntryInput { - kind: ContextEntryKind::Instructions, - content: engine::ContentRef::text(content_ref), - preview: Some("Profile instructions".to_owned()), - origin: None, - provenance_ref: None, - token_estimate: None, - }, - ); - } - let loaded = self.load_session_state(session_id).await?; - self.require_open_idle_session(session_id, &loaded, "profile instructions apply")?; - self.reconcile_managed_instructions( - session_id, - &loaded.state, - PROFILE_INSTRUCTIONS_CONTEXT_KEY, - source_entries, - ) - .await - } - - /// Validate that a `provision` profile can be applied in this universe - /// before any session exists: the provider must have an enabled binding - /// here. Returns the binding so the applier can create from it. - pub(super) async fn resolve_profile_provision_binding( - &self, - provider_id: &str, - ) -> Result<::environments::EnvironmentProviderBindingRecord, AgentApiError> { - let provider_id = parse_environment_provider_id(provider_id.to_owned())?; - let bindings = EnvironmentProviderBindingStore::list_provider_bindings( - self.store.as_ref(), - self.universe_id(), - ) - .await - .map_err(map_environments_error)?; - let binding = bindings - .into_iter() - .find(|binding| binding.provider_id == provider_id) - .ok_or_else(|| { - AgentApiError::rejected(format!( - "profile provisions from environment provider {provider_id}, but this universe has no binding for it" - )) - })?; - if binding.status != ::environments::EnvironmentProviderBindingStatus::Enabled { - return Err(AgentApiError::rejected(format!( - "profile provisions from environment provider {provider_id}, but binding {} is disabled", - binding.binding_id - ))); - } - Ok(binding) - } - - /// Create (or find) the one environment a profile may provision for this - /// session. The request id is derived from the session id, so retries and - /// repeated applies converge on the same environment. - #[allow(clippy::too_many_arguments)] - async fn ensure_profile_provisioned_environment( - &self, - session_id: &SessionId, - profile_id: Option<&api::ProfileId>, - provider_id: &str, - template_id: &str, - display_name: Option, - metadata: BTreeMap, - retention: api::ProfileEnvironmentRetention, - idle_policy: Option, - ) -> Result<(::environments::EnvironmentRecord, bool), AgentApiError> { - let request_id = ::environments::EnvironmentProvisionRequestId::for_session(session_id); - let existing = match EnvironmentStore::read_environment_by_request_id( - self.store.as_ref(), - &request_id, - ) - .await - { - Ok(environment) => Some(environment), - Err(::environments::EnvironmentRegistryError::NotFound { .. }) => None, - Err(error) => return Err(map_environments_error(error)), - }; - if let Some(environment) = existing { - return match environment.status { - ::environments::EnvironmentStatus::Closing - | ::environments::EnvironmentStatus::Closed - | ::environments::EnvironmentStatus::Failed => { - Err(AgentApiError::rejected(format!( - "the environment provisioned for session {session_id} ({}) is {}; activate another environment or create one through environments/create", - environment.environment_id, - format!("{:?}", environment.status).to_lowercase() - ))) - } - _ => Ok((environment, false)), - }; - } - let binding = self.resolve_profile_provision_binding(provider_id).await?; - let display_name = display_name.or_else(|| { - profile_id - .map(|id| format!("{id} · {session_id}")) - .or_else(|| Some(format!("session {session_id}"))) - }); - let environment = self - .create_environment_record_with_origin( - EnvironmentCreateParams { - request_id: request_id.as_str().to_owned(), - binding_id: binding.binding_id.as_str().to_owned(), - template_id: template_id.to_owned(), - display_name, - metadata, - idle_policy, - }, - Some(::environments::EnvironmentOriginSession { - session_id: session_id.clone(), - profile_id: profile_id.map(|id| id.as_str().to_owned()), - close_with_session: matches!( - retention, - api::ProfileEnvironmentRetention::CloseWithSession - ), - }), - ) - .await?; - Ok((environment, true)) - } - - /// Bind the profile's requested credentials to a just-provisioned - /// environment. Each entry goes through the same validation as - /// `environments/credentials/bind`; a failure surfaces as the apply - /// error (the session start fails; a `closeWithSession` environment is - /// cleaned up when the session closes). - async fn bind_profile_environment_credentials( - &self, - environment_id: &str, - credentials: &[api::ProfileEnvironmentCredential], - ) -> Result<(), AgentApiError> { - for credential in credentials { - self.bind_environment_credential_record(EnvironmentCredentialBindParams { - environment_id: environment_id.to_owned(), - env_name: credential.env_name.clone(), - source: credential.source.clone(), - }) - .await - .map_err(|error| { - AgentApiError::new( - error.kind, - format!( - "profile environment credential {}: {}", - credential.env_name, error.message - ), - ) - })?; - } - Ok(()) - } - - /// Validate profile credential references against this universe before a - /// session or environment exists (grant active, provider has a credential, - /// secret present). Mirrors the bind-time checks so a broken reference - /// fails at admission with a typed error. - pub(super) async fn validate_profile_environment_credentials( - &self, - credentials: &[api::ProfileEnvironmentCredential], - ) -> Result<(), AgentApiError> { - for credential in credentials { - environment_credentials::validate_credential_env_name(&credential.env_name)?; - self.credential_source_from_api(credential.source.clone()) - .await - .map_err(|error| { - AgentApiError::new( - error.kind, - format!( - "profile environment credential {}: {}", - credential.env_name, error.message - ), - ) - })?; - } - Ok(()) } +} - async fn apply_profile_active_environment( - &self, - session_id: &SessionId, - environment_id: api::EnvironmentId, - ) -> Result { - let loaded = self.load_session_state(session_id).await?; - if loaded - .state - .environment - .active_environment_id - .as_ref() - .is_some_and(|active| active.as_str() == environment_id) - { - return Ok(false); - } - self.activate_session_environment(SessionEnvironmentActivateParams { - session_id: session_id.as_str().to_owned(), - environment_id, +/// The attachment a profile activates when the session has no active +/// environment. +pub(super) fn default_environment_id( + features: &engine::FeaturesConfig, +) -> Result, AgentApiError> { + features + .environments + .as_ref() + .and_then(|environments| environments.default_attachment()) + .map(|attachment| { + engine::EnvironmentId::try_new(attachment.environment_id.clone()) + .map_err(|error| AgentApiError::invalid_request(error.to_string())) }) - .await?; - Ok(true) - } + .transpose() } pub(super) fn map_profile_error(error: ProfileError) -> AgentApiError { @@ -578,6 +215,33 @@ pub(super) fn map_profile_error(error: ProfileError) -> AgentApiError { mod tests { use super::*; + #[test] + fn default_environment_ids_are_fallible_even_before_config_validation() { + let mut features = engine::FeaturesConfig::default(); + assert_eq!(default_environment_id(&features).unwrap(), None); + features.environments = Some(engine::EnvironmentsFeature { + environments: vec![engine::EnvironmentAttachment { + environment_id: "env_ok".into(), + default: true, + access: engine::EnvironmentAccess::Read, + working_directory: None, + }], + ..Default::default() + }); + assert_eq!( + default_environment_id(&features).unwrap(), + Some(engine::EnvironmentId::new("env_ok")) + ); + features.environments.as_mut().unwrap().environments[0].default = false; + assert_eq!(default_environment_id(&features).unwrap(), None); + features.environments.as_mut().unwrap().environments[0].default = true; + for invalid in ["".to_owned(), "env/bad".into(), "a".repeat(129)] { + features.environments.as_mut().unwrap().environments[0].environment_id = invalid; + let error = default_environment_id(&features).unwrap_err(); + assert_eq!(error.kind, AgentApiErrorKind::InvalidRequest); + } + } + #[test] fn explicit_start_metadata_overrides_profile_defaults() { let profile = BTreeMap::from([ diff --git a/crates/temporal-server/src/gateway/service/prompts.rs b/crates/temporal-server/src/gateway/service/prompts.rs deleted file mode 100644 index 10edb174..00000000 --- a/crates/temporal-server/src/gateway/service/prompts.rs +++ /dev/null @@ -1,142 +0,0 @@ -use std::sync::Arc; - -use super::*; - -impl GatewayAgentApi { - pub(super) async fn load_session_state_with_current_run_context( - &self, - session_id: &SessionId, - ) -> Result { - let loaded = self.load_session_state(session_id).await?; - if loaded.state.lifecycle.status != CoreAgentStatus::Open - || loaded.state.runs.active.is_some() - || !loaded.state.runs.queued.is_empty() - { - return Ok(loaded); - } - - self.refresh_environment_projection_for_idle_session(session_id, &loaded.state) - .await?; - - let loaded = self.load_session_state(session_id).await?; - self.refresh_prompt_instructions_for_idle_session(session_id, &loaded.state) - .await?; - - let loaded = self.load_session_state(session_id).await?; - self.refresh_skill_catalog_for_idle_session(session_id, &loaded.state) - .await?; - - let loaded = self.load_session_state(session_id).await?; - self.refresh_subagent_catalog_for_idle_session(session_id, &loaded.state) - .await?; - - self.load_session_state(session_id).await - } - - pub(super) async fn refresh_prompt_instructions_for_idle_session( - &self, - session_id: &SessionId, - state: &engine::CoreAgentState, - ) -> Result<(), AgentApiError> { - if state.runs.active.is_some() || !state.runs.queued.is_empty() { - return Ok(()); - } - let desired = self - .prompt_instruction_source_map(session_id, state) - .await?; - self.reconcile_managed_instructions( - session_id, - state, - tools::prompts::PROMPT_INSTRUCTIONS_CONTEXT_KEY_PREFIX, - desired, - ) - .await?; - let loaded = self.load_session_state(session_id).await?; - let resolver = - crate::environment_resolver::EnvironmentResolver::from_pg_store(self.store.clone()); - let desired = crate::environment_prompts::refresh( - self.store.as_ref(), - Some(&resolver), - Some(&self.environment_gateway), - loaded - .state - .lifecycle - .config - .as_ref() - .and_then(|config| config.features.environments.as_ref()), - loaded.state.environment.active_environment_id.as_ref(), - ) - .await - .map_err(|e| AgentApiError::internal(e.to_string()))?; - self.reconcile_managed_instructions( - session_id, - &loaded.state, - tools::prompts::environment::ENVIRONMENT_PROMPT_CONTEXT_KEY, - desired, - ) - .await?; - Ok(()) - } - - pub(super) async fn prompt_instruction_source_map( - &self, - _session_id: &SessionId, - state: &engine::CoreAgentState, - ) -> Result, AgentApiError> { - let prompts_config = state - .lifecycle - .config - .as_ref() - .and_then(|config| config.features.vfs.as_ref()) - .and_then(|vfs| vfs.prompts.as_ref()); - let links = if prompts_config.is_some() { - self.resolve_session_workspace_links(state).await? - } else { - Vec::new() - }; - let specs = match prompts_config { - Some(config) => { - tools::prompts::configured_vfs_prompt_root_specs(&links, config.roots.as_deref()) - .map_err(|error| AgentApiError::invalid_request(error.to_string()))? - } - None => Vec::new(), - }; - if specs.is_empty() { - let publication = tools::prompts::prepare_prompt_instructions_publication( - self.store.as_ref(), - Some(self.store.as_ref()), - &[], - tools::prompts::PromptAssemblyLimits::default(), - ) - .await - .map_err(|error| AgentApiError::internal(error.to_string()))?; - return Ok(publication.desired); - } - - let blobs: Arc = self.store.clone(); - let workspace_store: Arc = self.store.clone(); - let resolved = - tools::prompts::resolve_linked_vfs_prompt_roots(blobs, workspace_store, links, specs) - .await - .map_err(|error| AgentApiError::internal(error.to_string()))?; - let inputs = resolved - .existing_directory_inputs() - .await - .map_err(|error| AgentApiError::internal(error.to_string()))?; - let publication = tools::prompts::prepare_prompt_instructions_publication_with_warnings( - self.store.as_ref(), - Some(self.store.as_ref()), - &inputs, - tools::prompts::PromptAssemblyLimits::default(), - resolved.warnings().to_vec(), - ) - .await - .map_err(|error| AgentApiError::internal(error.to_string()))?; - Ok(publication.desired) - } -} - -#[cfg(test)] -pub(super) fn active_prompt_context_entries(state: &engine::CoreAgentState) -> Vec<&ContextEntry> { - tools::prompts::active_prompt_instruction_entries(state) -} diff --git a/crates/temporal-server/src/gateway/service/session_jobs.rs b/crates/temporal-server/src/gateway/service/session_jobs.rs index 00641647..673d07cd 100644 --- a/crates/temporal-server/src/gateway/service/session_jobs.rs +++ b/crates/temporal-server/src/gateway/service/session_jobs.rs @@ -404,13 +404,9 @@ impl GatewayAgentApi { &self, environment_id: &EnvironmentId, ) -> Result { - crate::environment_resolver::EnvironmentResolver::from_pg_store(self.store.clone()) + crate::environments::resolver::EnvironmentResolver::from_pg_store(self.store.clone()) .with_gateway(self.environment_gateway.clone()) - .resolve_for_connection( - environment_id, - &::environments::EnvironmentAccessPolicy::ALLOW_ALL, - super::now_ms()?, - ) + .resolve_for_connection(environment_id, super::now_ms()?) .await .map_err(super::environments::map_environment_resolve_error) } diff --git a/crates/temporal-server/src/gateway/service/session_lifecycle.rs b/crates/temporal-server/src/gateway/service/session_lifecycle.rs new file mode 100644 index 00000000..77333076 --- /dev/null +++ b/crates/temporal-server/src/gateway/service/session_lifecycle.rs @@ -0,0 +1,996 @@ +//! Session creation, recovery, and preparation at the gateway boundary. +use super::*; +use engine::AdmittedManagedSessionWorkflowTools; + +// Only the effects needed to recover and await session creation. Keeping this +// boundary small lets the same recovery logic run against scripted offline I/O. +#[async_trait] +trait SessionLifecycleIo: Sync { + async fn load(&self, session_id: &SessionId) -> Result; + async fn is_running(&self, session_id: &SessionId) -> Result; + async fn retry_setup(&self, session_id: &SessionId) -> Result<(), AgentApiError>; + async fn status( + &self, + session_id: &SessionId, + ) -> Result, AgentApiError>; +} + +#[async_trait] +impl SessionLifecycleIo for GatewayAgentApi { + async fn load(&self, session_id: &SessionId) -> Result { + self.load_session_state(session_id).await + } + + async fn is_running(&self, session_id: &SessionId) -> Result { + match self + .workflow_handle(session_id) + .describe(WorkflowDescribeOptions::default()) + .await + { + Ok(description) => Ok(description.status() == WorkflowExecutionStatus::Running), + Err(WorkflowInteractionError::NotFound(_)) => Ok(false), + Err(error) => Err(map_workflow_interaction_error(error)), + } + } + + async fn retry_setup(&self, session_id: &SessionId) -> Result<(), AgentApiError> { + self.workflow_handle(session_id) + .signal( + AgentSessionWorkflow::retry_setup, + (), + WorkflowSignalOptions::default(), + ) + .await + .map_err(map_workflow_interaction_error) + } + + async fn status( + &self, + session_id: &SessionId, + ) -> Result, AgentApiError> { + self.query_status_optional(session_id).await + } +} + +struct SessionLifecycle<'a, T> { + io: &'a T, + operation_timeout: Duration, + poll_interval: Duration, +} + +impl SessionLifecycle<'_, T> { + async fn recover_existing( + &self, + session_id: &SessionId, + admitted: Option<&AdmittedManagedSessionWorkflowTools>, + ) -> Result, AgentApiError> { + match self.io.load(session_id).await { + Ok(loaded) + if matches!( + loaded.state.lifecycle.status, + CoreAgentStatus::Open | CoreAgentStatus::Closed + ) => + { + return self + .resume(session_id, Some(loaded), admitted) + .await + .map(Some); + } + Ok(_) => {} + Err(error) if is_not_found(&error) => {} + Err(error) => return Err(error), + } + // Temporal can accept creation before the session row exists. + if self.io.is_running(session_id).await? { + return self.resume(session_id, None, admitted).await.map(Some); + } + Ok(None) + } + + async fn recover_conflict( + &self, + session_id: &SessionId, + admitted: Option<&AdmittedManagedSessionWorkflowTools>, + ) -> Result { + // Preserve the start-conflict boundary: a missing row is still an + // error here, rather than silently changing the recovery policy. + let loaded = self.io.load(session_id).await?; + self.resume(session_id, Some(loaded), admitted).await + } + + async fn resume( + &self, + session_id: &SessionId, + known: Option, + admitted: Option<&AdmittedManagedSessionWorkflowTools>, + ) -> Result { + let validate_after_wait = known.is_none(); + if let Some(loaded) = known { + validate_managed_session_retry(&loaded.state, admitted)?; + if loaded.state.lifecycle.status == CoreAgentStatus::Closed { + return Ok(loaded); + } + } + self.io.retry_setup(session_id).await?; + let loaded = self.wait_for_open_session(session_id).await?; + if validate_after_wait { + validate_managed_session_retry(&loaded.state, admitted)?; + } + Ok(loaded) + } + + async fn wait_for_open_session( + &self, + session_id: &SessionId, + ) -> Result { + let started = Instant::now(); + loop { + if started.elapsed() > self.operation_timeout { + return Err(AgentApiError::internal(format!( + "timed out waiting for agent session to open: {session_id}" + ))); + } + if let Some(status) = self.io.status(session_id).await? { + if let Some(error) = status.setup_error { + return Err(error); + } + if let Some(error) = status.last_error { + return Err(AgentApiError::internal(error)); + } + if status.ready { + return self.io.load(session_id).await; + } + } + tokio::time::sleep(self.poll_interval).await; + } + } +} + +impl GatewayAgentApi { + pub async fn open_or_start_session( + &self, + params: SessionStartParams, + ) -> Result, AgentApiError> { + // `start_session` is idempotent on client-supplied session ids; this + // wrapper remains for callers predating that behavior. + self.start_session(params).await + } + + fn allocate_session_id(&self) -> SessionId { + SessionId::new(format!("session_{}", uuid::Uuid::new_v4().simple())) + } + + #[allow(clippy::too_many_arguments)] + fn workflow_args( + &self, + session_id: SessionId, + display_name: Option, + metadata: BTreeMap, + delete_after_close_ms: Option, + session_config: SessionConfig, + workflow_tools: Option, + close_on_terminal: bool, + auto_reject_approvals: bool, + ) -> AgentSessionArgs { + AgentSessionArgs { + setup: None, + universe_id: self.universe_id(), + session_id, + display_name, + metadata, + delete_after_close_ms, + session_config, + workflow_tools, + legacy_max_steps_per_input: None, + continue_as_new_history_threshold: self.continue_as_new_history_threshold, + close_on_terminal, + auto_reject_approvals, + continuation_state: None, + } + } + + /// Sub-agent child creation: the child's store row already + /// exists with its origin (the execution's reservation); this opens its + /// workflow with the pinned profile applied. The execution closes the + /// child, so `close_on_terminal` stays off. + pub(crate) async fn start_session_for_subagent( + &self, + session_id: &SessionId, + profile: ProfileSource, + ) -> Result<(), AgentApiError> { + self.start_session_internal( + SessionStartParams { + metadata: Default::default(), + session_id: Some(session_id.as_str().to_owned()), + display_name: None, + config: None, + profile: Some(profile), + // Delegated children inherit their retention root and never + // apply a profile's root-session default. + delete_after_close_ms: Some(None), + }, + false, + true, + None, + ) + .await?; + Ok(()) + } + + /// Trusted in-process workflow-plugin entry point. The main API exposes + /// the same immutable target and completion vocabulary through wire DTOs. + pub async fn start_managed_session_for_workflow_with_profile( + &self, + session_id: &SessionId, + close_on_terminal: bool, + profile: Option, + workflow_tools: ManagedSessionWorkflowTools, + ) -> Result<(), AgentApiError> { + self.start_session_internal( + SessionStartParams { + metadata: Default::default(), + session_id: Some(session_id.as_str().to_owned()), + display_name: None, + config: None, + profile, + delete_after_close_ms: None, + }, + close_on_terminal, + false, + Some(workflow_tools), + ) + .await?; + Ok(()) + } + + pub(super) async fn start_session_internal( + &self, + params: SessionStartParams, + close_on_terminal: bool, + auto_reject_approvals: bool, + trusted_workflow_tools: Option, + ) -> Result, AgentApiError> { + let SessionStartParams { + session_id, + display_name, + metadata, + config, + profile, + delete_after_close_ms, + } = params; + validate_caller_metadata(&metadata)?; + let workflow_tools = trusted_workflow_tools; + let client_supplied_id = session_id.is_some(); + let session_id = match session_id { + Some(session_id) => { + // System workflow ids share the `{universe}/…` namespace + // with sessions; their segments are reserved. + if let Some(prefix) = ::bots::ids::RESERVED_SESSION_ID_PREFIXES + .iter() + .find(|prefix| session_id.starts_with(*prefix)) + { + return Err(AgentApiError::invalid_request(format!( + "session id prefix `{prefix}` is reserved for system workflows" + ))); + } + SessionId::try_new(session_id).map_err(|error| { + AgentApiError::invalid_request(format!("invalid session id: {error}")) + })? + } + None => self.allocate_session_id(), + }; + let admitted = workflow_tools + .as_ref() + .map(|declaration| { + declaration.admit(self.universe_id()).map_err(|error| { + AgentApiError::invalid_request(format!( + "invalid managed-session workflow-tool declaration: {error}" + )) + }) + }) + .transpose()?; + let lifecycle = SessionLifecycle { + io: self, + operation_timeout: self.operation_timeout, + poll_interval: self.poll_interval, + }; + // Recover the original intent before resolving a mutable named profile. + if client_supplied_id + && let Some(loaded) = lifecycle + .recover_existing(&session_id, admitted.as_ref()) + .await? + { + return Ok(self.session_start_response(&loaded)); + } + let resolved_profile = match profile { + Some(source) => Some(self.resolve_profile_source(source).await?), + None => None, + }; + let effective_metadata = profiles::merge_profile_start_metadata( + resolved_profile.as_ref().map(|profile| &profile.metadata), + metadata, + ); + validate_caller_metadata(&effective_metadata)?; + let effective_delete_after_close_ms = profiles::merge_profile_start_retention( + resolved_profile + .as_ref() + .and_then(|profile| profile.retention.as_ref()) + .map(|retention| retention.delete_after_close_ms), + delete_after_close_ms, + ); + validate_delete_after_close_ms(effective_delete_after_close_ms)?; + let start_config = self.merge_profile_start_config( + resolved_profile + .as_ref() + .and_then(|profile| profile.config.clone()), + config, + ); + let session_config = self.session_config_for_start(start_config).await?; + let setup_environment = profiles::default_environment_id(&session_config.features)?; + + if let Some(admitted) = admitted.as_ref() { + self.validate_managed_session_materialization(&session_config, admitted) + .await?; + } + let mut args = self.workflow_args( + session_id.clone(), + display_name, + effective_metadata, + effective_delete_after_close_ms, + session_config, + workflow_tools, + close_on_terminal, + auto_reject_approvals, + ); + args.setup = match resolved_profile.as_ref() { + Some(profile) => { + let mut intent = self.profile_intent(profile, false)?; + intent.environment = setup_environment; + Some(intent) + } + None => setup_environment.map(|environment| temporal_workflow::SessionProfileIntent { + config: None, + instructions: None, + environment: Some(environment), + }), + }; + self.refresh_input_blob_grace(&args).await?; + let started = self + .client + .start_workflow( + AgentSessionWorkflow::run, + args, + WorkflowStartOptions::new( + self.task_queue.clone(), + self.workflow_id_for(&session_id), + ) + .build(), + ) + .await + .map_err(map_workflow_start_error); + match started { + Ok(_) => {} + Err(error) + if matches!(error.kind, AgentApiErrorKind::Conflict) && client_supplied_id => + { + let loaded = lifecycle + .recover_conflict(&session_id, admitted.as_ref()) + .await?; + return Ok(self.session_start_response(&loaded)); + } + Err(error) => return Err(error), + } + let loaded = lifecycle.wait_for_open_session(&session_id).await?; + validate_managed_session_retry(&loaded.state, admitted.as_ref())?; + Ok(self.session_start_response(&loaded)) + } + + fn session_start_response( + &self, + loaded: &LoadedSession, + ) -> AgentApiOutcome { + AgentApiOutcome::new(SessionStartResponse { + session: self.session_mutation_view(loaded), + }) + } + + async fn validate_managed_session_materialization( + &self, + session_config: &SessionConfig, + admitted: &AdmittedManagedSessionWorkflowTools, + ) -> Result<(), AgentApiError> { + for binding in &admitted.bindings { + validate_workflow_tool_definition_documents(self.store.as_ref(), &binding.definition) + .await + .map_err(|error| { + AgentApiError::invalid_request(format!( + "invalid workflow tool {} documents: {error}", + binding.definition.tool_id + )) + })?; + if let WorkflowToolCompletion::Joined { + reply_schema_ref: Some(reply_schema_ref), + .. + } + | WorkflowToolCompletion::Promises { + reply_schema_ref: Some(reply_schema_ref), + .. + } = &binding.completion + { + validate_workflow_tool_reply_schema(self.store.as_ref(), reply_schema_ref) + .await + .map_err(|error| { + AgentApiError::invalid_request(format!( + "invalid workflow tool {} reply schema: {error}", + binding.definition.tool_id + )) + })?; + } + if let WorkflowToolTarget::Start { start } = &binding.target { + self.validate_workflow_tool_start_recipe(&binding.definition.tool_id, start) + .await?; + } + } + + let materialized_bindings = admitted + .bindings + .iter() + .filter(|binding| !is_core_environment_job_binding(binding)) + .collect::>(); + + let mut config = Self::session_toolset_config(session_config, false, false); + enable_concurrency_for_workflow_tools(&mut config, materialized_bindings.iter().copied()); + let mut toolset = register_toolset(&config).map_err(|error| { + AgentApiError::invalid_request(format!("build session tools: {error}")) + })?; + register_workflow_tools(&mut toolset, materialized_bindings.iter().copied()).map_err( + |error| { + AgentApiError::invalid_request(format!("materialize workflow tool tools: {error}")) + }, + )?; + let desired_mcp = self.desired_mcp_tools(&session_config.features).await?; + if let Some(colliding) = materialized_bindings + .iter() + .copied() + .map(|binding| &binding.definition.tool.name) + .find(|tool_name| desired_mcp.contains_key(*tool_name)) + { + return Err(AgentApiError::invalid_request(format!( + "workflow tool tool name {colliding} collides with a remote MCP tool" + ))); + } + Ok(()) + } + + async fn validate_workflow_tool_start_recipe( + &self, + tool_id: &WorkflowToolId, + start: &WorkflowStartRef, + ) -> Result<(), AgentApiError> { + let recipe_bytes = self + .store + .read_bytes(&start.recipe_ref) + .await + .map_err(|error| { + AgentApiError::invalid_request(format!( + "invalid workflow tool {tool_id} start recipe: {error}" + )) + })?; + let observed = temporal_workflow::workflow_tool_recipe_fingerprint(&recipe_bytes); + if observed != start.recipe_fingerprint { + return Err(AgentApiError::invalid_request(format!( + "invalid workflow tool {tool_id} start recipe fingerprint: admitted {} observed {observed}", + start.recipe_fingerprint + ))); + } + if start.recipe_format != temporal_workflow::WORKFLOW_TOOL_RECIPE_FORMAT_V1 { + return Err(AgentApiError::invalid_request(format!( + "invalid workflow tool {tool_id} start recipe format {}", + start.recipe_format + ))); + } + let recipe: temporal_workflow::WorkflowToolRecipeV1 = serde_json::from_slice(&recipe_bytes) + .map_err(|error| { + AgentApiError::invalid_request(format!( + "invalid workflow tool {tool_id} start recipe v1: {error}" + )) + })?; + if recipe.workflow_type.is_empty() || recipe.task_queue.is_empty() { + return Err(AgentApiError::invalid_request(format!( + "invalid workflow tool {tool_id} start recipe v1: workflowType and taskQueue are required" + ))); + } + Ok(()) + } + + pub(super) async fn prepare_session_operation( + &self, + session_id: &SessionId, + operation: temporal_workflow::SessionOperation, + ) -> Result { + let request = temporal_workflow::SessionOperationRequest { + operation_id: format!("prepare_{}", uuid::Uuid::new_v4().simple()), + submitted_at_ms: u64::try_from(now_ms()?) + .map_err(|error| AgentApiError::internal(error.to_string()))?, + operation, + }; + let receipt = request + .receipt() + .map_err(|error| AgentApiError::internal(error.to_string()))?; + self.refresh_input_blob_grace(&request).await?; + self.workflow_handle(session_id) + .signal( + AgentSessionWorkflow::prepare_session, + request.clone(), + WorkflowSignalOptions::default(), + ) + .await + .map_err(map_workflow_interaction_error)?; + let started = Instant::now(); + loop { + if started.elapsed() > self.operation_timeout { + return Err(AgentApiError::internal(format!( + "timed out waiting for session preparation: {}", + request.operation_id + ))); + } + let outcome = self + .workflow_handle(session_id) + .query( + AgentSessionWorkflow::operation_outcome, + receipt.clone(), + WorkflowQueryOptions::default(), + ) + .await + .map_err(map_workflow_query_error)? + .outcome?; + if let Some(outcome) = outcome { + return outcome.result; + } + if let Some(status) = self.query_status_optional(session_id).await? { + if let Some(error) = status.setup_error { + return Err(error); + } + if let Some(error) = status.last_error { + return Err(AgentApiError::internal(error)); + } + } + tokio::time::sleep(self.poll_interval).await; + } + } +} + +fn validate_managed_session_retry( + state: &engine::CoreAgentState, + admitted: Option<&AdmittedManagedSessionWorkflowTools>, +) -> Result<(), AgentApiError> { + let Some(admitted) = admitted else { + return Ok(()); + }; + match ( + state.workflow_tools.session_universe_id, + state.workflow_tools.managed_creation_fingerprint.as_deref(), + ) { + (Some(actual_universe), Some(actual)) + if actual_universe == admitted.session_universe_id + && actual == admitted.creation_fingerprint => + { + Ok(()) + } + (Some(_), Some(_)) => Err(AgentApiError::conflict( + "managed-session controller, receiver, or tool declaration conflicts with durable creation state", + )), + _ => Err(AgentApiError::conflict( + "existing standalone session cannot be reopened as a managed session", + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{collections::VecDeque, sync::Mutex}; + + enum Step { + Load(Result, AgentApiError>), + Running(Result), + Retry(Result<(), AgentApiError>), + Status(Result>, AgentApiError>), + } + + struct ScriptedIo(Mutex>); + + impl ScriptedIo { + fn new(steps: impl IntoIterator) -> Self { + Self(Mutex::new(steps.into_iter().collect())) + } + + fn next(&self) -> Step { + self.0 + .lock() + .unwrap() + .pop_front() + .expect("unexpected lifecycle I/O") + } + + fn assert_drained(&self) { + assert!( + self.0.lock().unwrap().is_empty(), + "expected lifecycle I/O was not performed" + ); + } + + fn lifecycle(&self) -> SessionLifecycle<'_, Self> { + SessionLifecycle { + io: self, + operation_timeout: Duration::from_secs(5), + poll_interval: Duration::ZERO, + } + } + } + + #[async_trait] + impl SessionLifecycleIo for ScriptedIo { + async fn load(&self, _: &SessionId) -> Result { + let Step::Load(result) = self.next() else { + panic!("expected load"); + }; + result.map(|loaded| *loaded) + } + async fn is_running(&self, _: &SessionId) -> Result { + let Step::Running(result) = self.next() else { + panic!("expected describe"); + }; + result + } + async fn retry_setup(&self, _: &SessionId) -> Result<(), AgentApiError> { + let Step::Retry(result) = self.next() else { + panic!("expected retry signal"); + }; + result + } + async fn status(&self, _: &SessionId) -> Result, AgentApiError> { + let Step::Status(result) = self.next() else { + panic!("expected status query"); + }; + result.map(|status| status.map(|status| *status)) + } + } + + fn admitted() -> AdmittedManagedSessionWorkflowTools { + ManagedSessionWorkflowTools::v1(None, Vec::new()) + .admit(uuid::Uuid::from_u128(1)) + .unwrap() + } + + fn loaded(status: CoreAgentStatus, revision: u64, managed: bool) -> Box { + let session_id = SessionId::new("session-lifecycle"); + let mut state = engine::CoreAgentState::new(); + state.lifecycle.status = status; + state.lifecycle.config_revision = revision; + if managed { + let admitted = admitted(); + state.workflow_tools.session_universe_id = Some(admitted.session_universe_id); + state.workflow_tools.managed_creation_fingerprint = Some(admitted.creation_fingerprint); + } + Box::new(LoadedSession { + state, + record: engine::storage::SessionRecord { + session_id: session_id.clone(), + display_name: None, + metadata: BTreeMap::new(), + lifecycle_status: Default::default(), + closed_at_seq: None, + closed_at_ms: None, + retention_root_session_id: session_id, + delete_after_close_ms: None, + delete_at_ms: None, + managed, + head: None, + source_session_id: None, + source_seq: None, + origin: None, + created_at_ms: 0, + updated_at_ms: 0, + }, + }) + } + + fn status(ready: bool) -> Box { + Box::new(AgentSessionStatus { + session_id: "session-lifecycle".into(), + initialized: true, + ready, + setup_error: None, + pending_admissions: 0, + pending_tool_batch_resumes: 0, + active_waits: 0, + pending_emissions: 0, + active_run: None, + queued_runs: Vec::new(), + completed_runs: Vec::new(), + admission_failures: Vec::new(), + last_error: None, + bootstrap_failed: false, + }) + } + + #[tokio::test(flavor = "current_thread")] + async fn stored_sessions_validate_before_retry_and_reuse_the_loaded_response_state() { + let session_id = SessionId::new("session-lifecycle"); + for closed in [false, true] { + for managed_matches in [false, true] { + let initial_status = if closed { + CoreAgentStatus::Closed + } else { + CoreAgentStatus::Open + }; + let mut steps = vec![Step::Load(Ok(loaded(initial_status, 1, managed_matches)))]; + if managed_matches && !closed { + steps.extend([ + Step::Retry(Ok(())), + Step::Status(Ok(Some(status(true)))), + Step::Load(Ok(loaded(CoreAgentStatus::Open, 2, true))), + ]); + } + let io = ScriptedIo::new(steps); + let result = io + .lifecycle() + .recover_existing(&session_id, Some(&admitted())) + .await; + if managed_matches { + let loaded = result.unwrap().expect("recovered session"); + assert_eq!( + loaded.state.lifecycle.config_revision, + if closed { 1 } else { 2 } + ); + } else { + assert_eq!( + result.err().expect("mismatch").kind, + AgentApiErrorKind::Conflict + ); + } + io.assert_drained(); + } + } + } + + #[tokio::test(flavor = "current_thread")] + async fn running_workflow_recovers_without_fresh_creation_and_validates_after_readiness() { + let session_id = SessionId::new("session-lifecycle"); + for row_exists in [false, true] { + for managed_matches in [false, true] { + let initial = if row_exists { + Ok(loaded(CoreAgentStatus::New, 0, false)) + } else { + Err(AgentApiError::not_found("not yet stored")) + }; + let io = ScriptedIo::new([ + Step::Load(initial), + Step::Running(Ok(true)), + Step::Retry(Ok(())), + Step::Status(Ok(None)), + Step::Status(Ok(Some(status(false)))), + Step::Status(Ok(Some(status(true)))), + Step::Load(Ok(loaded(CoreAgentStatus::Open, 7, managed_matches))), + ]); + // The recovered value makes the caller return before profile resolution. + let result = io + .lifecycle() + .recover_existing(&session_id, Some(&admitted())) + .await; + if managed_matches { + assert_eq!( + result + .unwrap() + .expect("recovered original intent") + .state + .lifecycle + .config_revision, + 7 + ); + } else { + assert_eq!( + result.err().expect("mismatch after setup").kind, + AgentApiErrorKind::Conflict + ); + } + io.assert_drained(); + } + } + } + + #[tokio::test(flavor = "current_thread")] + async fn missing_or_new_sessions_without_a_running_workflow_continue_to_creation() { + for initial in [ + Err(AgentApiError::not_found("missing")), + Ok(loaded(CoreAgentStatus::New, 0, false)), + ] { + let io = ScriptedIo::new([Step::Load(initial), Step::Running(Ok(false))]); + assert!( + io.lifecycle() + .recover_existing(&SessionId::new("session-lifecycle"), None) + .await + .unwrap() + .is_none() + ); + io.assert_drained(); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn concurrent_start_conflicts_recover_stored_sessions_but_preserve_missing_row_errors() { + let session_id = SessionId::new("session-lifecycle"); + for closed in [false, true] { + let initial_status = if closed { + CoreAgentStatus::Closed + } else { + CoreAgentStatus::Open + }; + let mut steps = vec![Step::Load(Ok(loaded(initial_status, 1, true)))]; + if !closed { + steps.extend([ + Step::Retry(Ok(())), + Step::Status(Ok(Some(status(true)))), + Step::Load(Ok(loaded(CoreAgentStatus::Open, 2, true))), + ]); + } + let io = ScriptedIo::new(steps); + let recovered = io + .lifecycle() + .recover_conflict(&session_id, Some(&admitted())) + .await + .unwrap(); + assert_eq!( + recovered.state.lifecycle.config_revision, + if closed { 1 } else { 2 } + ); + io.assert_drained(); + } + let missing = AgentApiError::not_found("row not yet stored"); + let io = ScriptedIo::new([Step::Load(Err(missing.clone()))]); + assert_eq!( + io.lifecycle() + .recover_conflict(&session_id, None) + .await + .err() + .unwrap(), + missing + ); + io.assert_drained(); + } + + #[tokio::test(flavor = "current_thread")] + async fn readiness_checks_errors_before_ready_and_loads_only_once_after_success() { + let session_id = SessionId::new("session-lifecycle"); + let setup_error = AgentApiError::invalid_request("setup failed"); + let mut setup = status(true); + setup.setup_error = Some(setup_error.clone()); + setup.last_error = Some("secondary error".into()); + let mut failed = status(true); + failed.last_error = Some("workflow failed".into()); + for (query, expected) in [ + (Ok(Some(setup)), setup_error), + (Ok(Some(failed)), AgentApiError::internal("workflow failed")), + ( + Err(AgentApiError::not_found("query failed")), + AgentApiError::not_found("query failed"), + ), + ] { + let io = ScriptedIo::new([Step::Status(query)]); + assert_eq!( + io.lifecycle() + .wait_for_open_session(&session_id) + .await + .err() + .unwrap(), + expected + ); + io.assert_drained(); + } + let io = ScriptedIo::new([ + Step::Status(Ok(Some(status(true)))), + Step::Load(Ok(loaded(CoreAgentStatus::Open, 9, false))), + ]); + assert_eq!( + io.lifecycle() + .wait_for_open_session(&session_id) + .await + .unwrap() + .state + .lifecycle + .config_revision, + 9 + ); + io.assert_drained(); + } + + #[tokio::test(flavor = "current_thread")] + async fn recovery_propagates_load_describe_and_signal_errors_without_further_io() { + let error = AgentApiError::internal("unavailable"); + for steps in [ + vec![Step::Load(Err(error.clone()))], + vec![ + Step::Load(Err(AgentApiError::not_found("missing"))), + Step::Running(Err(error.clone())), + ], + vec![ + Step::Load(Ok(loaded(CoreAgentStatus::Open, 1, false))), + Step::Retry(Err(error.clone())), + ], + ] { + let io = ScriptedIo::new(steps); + assert_eq!( + io.lifecycle() + .recover_existing(&SessionId::new("session-lifecycle"), None) + .await + .err() + .unwrap(), + error + ); + io.assert_drained(); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn readiness_timeout_does_not_load_session_state() { + // At most one query if the first elapsed-time reading is exactly zero. + let io = ScriptedIo::new([Step::Status(Ok(None))]); + let lifecycle = SessionLifecycle { + io: &io, + operation_timeout: Duration::ZERO, + poll_interval: Duration::from_millis(1), + }; + let error = lifecycle + .wait_for_open_session(&SessionId::new("session-lifecycle")) + .await + .err() + .unwrap(); + assert_eq!(error.kind, AgentApiErrorKind::Internal); + assert!( + error + .message + .contains("timed out waiting for agent session to open") + ); + } + #[test] + fn managed_session_retry_requires_the_durable_creation_fingerprint() { + let universe_id = uuid::Uuid::from_u128(1); + let declaration = engine::ManagedSessionWorkflowTools::v1( + Some(engine::WorkflowEndpointRef { + workflow_id: "global controller/work-1".to_owned(), + workflow_kind: "agent_work".to_owned(), + }), + Vec::new(), + ); + let admitted = declaration.admit(universe_id).expect("admit"); + let mut state = engine::CoreAgentState::new(); + state.workflow_tools.session_universe_id = Some(universe_id); + state.workflow_tools.managed_creation_fingerprint = Some( + declaration + .creation_fingerprint(universe_id) + .expect("creation fingerprint"), + ); + validate_managed_session_retry(&state, Some(&admitted)).expect("matching retry"); + + let conflicting = engine::ManagedSessionWorkflowTools::v1( + Some(engine::WorkflowEndpointRef { + workflow_id: "another controller".to_owned(), + workflow_kind: "agent_work".to_owned(), + }), + Vec::new(), + ); + assert_eq!( + validate_managed_session_retry(&state, Some(&conflicting.admit(universe_id).unwrap())) + .expect_err("conflicting retry") + .kind, + AgentApiErrorKind::Conflict + ); + assert_eq!( + validate_managed_session_retry(&engine::CoreAgentState::new(), Some(&admitted)) + .expect_err("standalone session cannot become managed") + .kind, + AgentApiErrorKind::Conflict + ); + } +} diff --git a/crates/temporal-server/src/gateway/service/session_preparation.rs b/crates/temporal-server/src/gateway/service/session_preparation.rs new file mode 100644 index 00000000..d2d18c5c --- /dev/null +++ b/crates/temporal-server/src/gateway/service/session_preparation.rs @@ -0,0 +1,446 @@ +//! Shared materialization service used by workflow activities and API preflight. +use super::*; +use ::profiles::ProfileStore; +use temporal_workflow::{SessionToolsetPreparation, SessionToolsetSource}; + +#[derive(Clone)] +pub(crate) struct SessionPreparationService { + pub(crate) store: Arc, + pub(crate) task_queue: String, +} + +impl SessionPreparationService { + pub(crate) fn session_toolset_config( + session_config: &SessionConfig, + include_environment_tools: bool, + include_job_read_tool: bool, + ) -> ToolsetConfig { + let features = &session_config.features; + let mut config = ToolsetConfig::empty(); + config.environment_read = features.environments.is_some(); + config.environment_selection = features + .environments + .as_ref() + .is_some_and(|environments| environments.selection); + // Tool surfaces are the union of attachment grants: any attachment + // installs the read tools, any editing attachment the write tools. + config.builtin = match features.vfs.as_ref().and_then(|vfs| vfs.tool_access()) { + None => tools::toolset::BuiltinToolsetConfig::disabled(), + Some(engine::WorkspaceAccess::Read) => tools::toolset::BuiltinToolsetConfig { + vfs: tools::toolset::FilesystemToolsetConfig::read_only(), + ..tools::toolset::BuiltinToolsetConfig::disabled() + }, + Some(engine::WorkspaceAccess::Edit) => { + tools::toolset::BuiltinToolsetConfig::workspace() + } + }; + if let Some(web) = features.web.as_ref() { + if let Some(search) = &web.search { + config.web.search = Some(WebSearchToolConfig::new( + search.allowed_domains.clone().unwrap_or_default(), + search.blocked_domains.clone(), + )); + } + if web.fetch.is_some() { + config.web.fetch = true; + } + } + if features.timers.is_some() || features.subagents.is_some() { + // Joining spawned sub-agents depends on the base concurrency + // tools, so the subagents grant implies them; the timers grant + // adds nothing extra today beyond the same surface. + config.concurrency = tools::concurrency::ConcurrencyToolsetConfig::timer(); + } + if include_environment_tools && let Some(environment) = &features.environments { + let access = environment.tool_access(); + config.builtin.environment.filesystem = match access { + None => tools::toolset::FilesystemToolsetConfig::disabled(), + Some(access) if access.allows_edit() => { + tools::toolset::FilesystemToolsetConfig::workspace_edit() + } + Some(_) => tools::toolset::FilesystemToolsetConfig::read_only(), + }; + let exec = access.is_some_and(|access| access.allows_exec()); + config.builtin.environment.run_process = exec; + config.builtin.environment.continue_process = exec; + } + if include_job_read_tool { + config.builtin.environment.job_read = true; + } + config + } + + pub(crate) async fn core_environment_job_workflow_tool_declarations( + &self, + ) -> Result, AgentApiError> { + let recipe_bytes = serde_json::to_vec(&temporal_workflow::WorkflowToolRecipeV1 { + workflow_type: "EnvironmentJobWorkflow".to_owned(), + task_queue: self.task_queue.clone(), + }) + .map_err(|error| { + AgentApiError::internal(format!( + "encode core environment-job workflow recipe: {error}" + )) + })?; + let recipe_fingerprint = temporal_workflow::workflow_tool_recipe_fingerprint(&recipe_bytes); + let recipe_ref = self + .store + .put_bytes(recipe_bytes) + .await + .map_err(map_blob_store_error)?; + + let definitions = [ + ( + BuiltinToolOperation::JobSubmit, + JOB_SUBMIT_WORKFLOW_TOOL_ID, + JOB_SUBMIT_WORKFLOW_SEMANTIC_TYPE, + WorkflowToolCompletion::Promises { + reply_schema_ref: None, + deadline_after_ms: None, + max_promises: engine::MAX_COMPLETION_PROMISES, + key_source: WorkflowToolCompletionKeySource::ArrayItemField { + pointer: "/jobs".to_owned(), + field: "job_id".to_owned(), + }, + }, + ), + ( + BuiltinToolOperation::JobRun, + JOB_RUN_WORKFLOW_TOOL_ID, + JOB_RUN_WORKFLOW_SEMANTIC_TYPE, + WorkflowToolCompletion::Joined { + reply_schema_ref: None, + deadline_after_ms: JOB_RUN_DEADLINE_AFTER_MS, + }, + ), + ]; + let mut declarations = Vec::with_capacity(definitions.len()); + for (operation, tool_id, semantic_type, completion) in definitions { + let builtin = BuiltinTool::environment_canonical(operation); + let tool = tools::definitions::register( + builtin.logical_id(), + tools::definitions::BuiltinSettings { + presentation: tools::toolset::BuiltinToolPresentation::Canonical, + unscoped_paths: true, + ..Default::default() + }, + builtin.parallelism(), + builtin.execution_spec(), + ); + declarations.push(WorkflowToolDeclaration::new( + WorkflowToolDefinition { + tool_id: WorkflowToolId::new(tool_id), + revision: 1, + semantic_type: semantic_type.to_owned(), + tool, + }, + WorkflowToolTarget::Start { + start: WorkflowStartRef { + recipe_format: temporal_workflow::WORKFLOW_TOOL_RECIPE_FORMAT_V1, + revision: 1, + recipe_ref: recipe_ref.clone(), + recipe_fingerprint: recipe_fingerprint.clone(), + }, + }, + completion, + )); + } + Ok(declarations) + } + + pub(crate) async fn core_subagent_workflow_tool_declarations( + &self, + ) -> Result, AgentApiError> { + let recipe_bytes = serde_json::to_vec(&temporal_workflow::WorkflowToolRecipeV1 { + workflow_type: tools::subagents::SUBAGENT_WORKFLOW_TYPE.to_owned(), + task_queue: self.task_queue.clone(), + }) + .map_err(|error| { + AgentApiError::internal(format!("encode core subagent workflow recipe: {error}")) + })?; + let recipe_fingerprint = temporal_workflow::workflow_tool_recipe_fingerprint(&recipe_bytes); + let recipe_ref = self + .store + .put_bytes(recipe_bytes) + .await + .map_err(map_blob_store_error)?; + // The binding carries the hard ceiling; the grant's `deadlineMs` is + // pinned per call and enforced inside the execution, so the + // immutable binding never has to change with the grant. + let definitions = [ + ( + tools::subagents::SubagentToolKind::Run, + WorkflowToolCompletion::Joined { + reply_schema_ref: None, + deadline_after_ms: engine::SUBAGENT_DEADLINE_CEILING_MS, + }, + ), + ( + tools::subagents::SubagentToolKind::Spawn, + WorkflowToolCompletion::Promises { + reply_schema_ref: None, + deadline_after_ms: Some(engine::SUBAGENT_DEADLINE_CEILING_MS), + max_promises: 1, + key_source: WorkflowToolCompletionKeySource::Reply, + }, + ), + ]; + let mut declarations = Vec::with_capacity(definitions.len()); + for (kind, completion) in definitions { + let tool = tools::definitions::register( + match kind { + tools::subagents::SubagentToolKind::Run => "subagent.run", + tools::subagents::SubagentToolKind::Spawn => "subagent.spawn", + }, + Default::default(), + engine::ToolParallelism::ParallelSafe, + Default::default(), + ); + declarations.push(WorkflowToolDeclaration::new( + WorkflowToolDefinition { + tool_id: WorkflowToolId::new(kind.workflow_tool_id()), + revision: 1, + semantic_type: kind.semantic_type().to_owned(), + tool, + }, + WorkflowToolTarget::Start { + start: WorkflowStartRef { + recipe_format: temporal_workflow::WORKFLOW_TOOL_RECIPE_FORMAT_V1, + revision: 1, + recipe_ref: recipe_ref.clone(), + recipe_fingerprint: recipe_fingerprint.clone(), + }, + }, + completion, + )); + } + Ok(declarations) + } + + pub(crate) async fn prepare_toolset( + &self, + source: SessionToolsetSource, + ) -> Result { + let session_config = &source.config; + let jobs = session_config + .features + .environments + .as_ref() + .and_then(|environments| environments.tool_access()) + .is_some_and(|access| access.allows_jobs()); + let subagents = session_config.features.subagents.is_some(); + let mut declarations = Vec::new(); + if jobs { + declarations.extend( + self.core_environment_job_workflow_tool_declarations() + .await?, + ); + } + if subagents { + declarations.extend(self.core_subagent_workflow_tool_declarations().await?); + } + for declaration in &declarations { + let id = &declaration.definition.tool_id; + if let Some(existing) = source.bindings.get(id) + && (!source.system_binding_ids.contains(id) + || existing.session_universe_id != self.store.config().universe_id + || existing.definition.semantic_type != declaration.definition.semantic_type + || existing.definition.tool.name != declaration.definition.tool.name) + { + return Err(AgentApiError::invalid_request(format!( + "system workflow tool {id} conflicts with an existing immutable binding" + ))); + } + } + let mut validation_state = engine::CoreAgentState::new(); + validation_state.workflow_tools.bindings = source.bindings.clone(); + validate_subagent_deadline_for_existing_bindings( + &validation_state, + &session_config.features, + )?; + declarations.retain(|declaration| { + !source + .bindings + .contains_key(&declaration.definition.tool_id) + }); + let mut bindings = source.bindings.clone(); + for declaration in &declarations { + let binding = engine::WorkflowToolBinding::admit( + self.store.config().universe_id, + declaration.definition.clone(), + declaration.target.clone(), + declaration.completion.clone(), + ) + .map_err(|error| AgentApiError::invalid_request(error.to_string()))?; + bindings.insert(binding.definition.tool_id.clone(), binding); + } + let materialized = bindings + .values() + .filter(|binding| { + (jobs || !is_core_environment_job_binding(binding)) + && (subagents || !is_core_subagent_binding(binding)) + }) + .collect::>(); + let mut config = Self::session_toolset_config( + session_config, + session_config.features.environments.is_some(), + jobs, + ); + enable_concurrency_for_workflow_tools(&mut config, materialized.iter().copied()); + let mut toolset = register_toolset(&config) + .map_err(|e| AgentApiError::internal(format!("build session tools: {e}")))?; + register_workflow_tools(&mut toolset, materialized.iter().copied()).map_err(|e| { + AgentApiError::invalid_request(format!("materialize workflow tools: {e}")) + })?; + let desired_mcp = self.desired_mcp_tools(&session_config.features).await?; + if let Some(name) = toolset + .tools + .keys() + .find(|name| desired_mcp.contains_key(*name)) + { + return Err(AgentApiError::invalid_request(format!( + "tool name {name} collides with a remote MCP tool" + ))); + } + let mut tools = toolset.tools; + tools.extend(desired_mcp); + Ok(SessionToolsetPreparation { + source, + declarations, + tools, + }) + } +} + +impl SessionPreparationService { + pub(crate) async fn validate_workspace_attachment_targets( + &self, + features: &engine::FeaturesConfig, + ) -> Result<(), AgentApiError> { + let Some(vfs) = features.vfs.as_ref() else { + return Ok(()); + }; + if vfs.workspaces.is_empty() { + return Ok(()); + } + let blobs: Arc = self.store.clone(); + let workspace_store: Arc = self.store.clone(); + let resolved = vfs::resolve_workspace_attachments(blobs, workspace_store, &vfs.workspaces) + .await + .map_err(map_vfs_catalog_error)?; + if let Some(link) = resolved.iter().find(|link| !link.is_available()) { + return Err(AgentApiError::invalid_request(format!( + "workspace attachment target at {} is unavailable: {}", + link.path, + link.unavailable_reason().unwrap_or("unknown reason") + ))); + } + Ok(()) + } + + pub(crate) async fn validate_configuration( + &self, + config: &SessionConfig, + ) -> Result<(), AgentApiError> { + config + .validate() + .map_err(|e| AgentApiError::invalid_request(e.to_string()))?; + self.validate_workspace_attachment_targets(&config.features) + .await?; + if let Some(subagents) = &config.features.subagents { + for agent in &subagents.agents { + let id = api::ProfileId::try_new(agent.profile_id.clone()) + .map_err(|e| AgentApiError::invalid_request(e.to_string()))?; + self.store + .read_agent_profile(&id) + .await + .map_err(profiles::map_profile_error)?; + } + } + Ok(()) + } + + pub(crate) async fn prepare_profile( + &self, + request: temporal_workflow::SessionProfilePreparationRequest, + ) -> Result { + self.validate_configuration(&request.source.config).await?; + let mut instructions = BTreeMap::new(); + if let Some(input) = request.instructions { + let reference = match input { + ProfileInstructions::Text { text } => self + .store + .put_bytes(text.into_bytes()) + .await + .map_err(map_blob_store_error)?, + ProfileInstructions::TextRef { blob_ref } => { + let reference = parse_blob_ref(&blob_ref)?; + if !self + .store + .has_blob(&reference) + .await + .map_err(map_blob_store_error)? + { + return Err(AgentApiError::not_found(format!( + "profile instructions blob not found: {reference}" + ))); + } + reference + } + }; + instructions.insert( + ContextEntryKey::new("instructions.050.profile"), + ContextEntryInput { + kind: ContextEntryKind::Instructions, + content: engine::ContentRef::text(reference), + preview: Some("Profile instructions".to_owned()), + origin: None, + provenance_ref: None, + token_estimate: None, + }, + ); + } + // The fill candidate must be an attachment of the configuration being + // applied and selectable in the registry; whether it is applied is + // decided by the workflow against the live pointer. + let environment_id = match request.environment { + None => None, + Some(environment_id) => { + let attached = request + .source + .config + .features + .environments + .as_ref() + .is_some_and(|environments| environments.is_attached(environment_id.as_str())); + if !attached { + return Err(AgentApiError::rejected(format!( + "environment {environment_id} is not attached in the session configuration" + ))); + } + crate::environments::resolver::EnvironmentResolver::from_pg_store( + self.store.clone(), + ) + .selectable(&environment_id) + .await + .map_err(super::environments::map_environment_resolve_error)?; + Some(environment_id) + } + }; + let toolset = self.prepare_toolset(request.source).await?; + Ok(temporal_workflow::SessionProfilePreparation { + toolset, + instructions, + environment_id, + }) + } +} + +impl GatewayAgentApi { + pub(super) fn preparation_service(&self) -> SessionPreparationService { + SessionPreparationService { + store: self.store.clone(), + task_queue: self.task_queue.clone(), + } + } +} diff --git a/crates/temporal-server/src/gateway/service/session_toolset.rs b/crates/temporal-server/src/gateway/service/session_toolset.rs deleted file mode 100644 index ae7a100f..00000000 --- a/crates/temporal-server/src/gateway/service/session_toolset.rs +++ /dev/null @@ -1,163 +0,0 @@ -use super::*; - -impl GatewayAgentApi { - pub(super) async fn configure_session_toolset( - &self, - session_id: &SessionId, - loaded: &LoadedSession, - wait_for_reconciliation: bool, - ) -> Result { - let session_config = loaded.state.lifecycle.config.as_ref().ok_or_else(|| { - AgentApiError::invalid_request(format!("session is missing config: {session_id}")) - })?; - let jobs_granted = session_config - .features - .environments - .as_ref() - .is_some_and(|environments| environments.jobs); - let subagents_granted = session_config.features.subagents.is_some(); - let environments_granted = session_config.features.environments.is_some(); - let mut refreshed = None; - if jobs_granted && !super::has_all_core_environment_job_bindings(&loaded.state) { - self.ensure_core_environment_job_workflow_tools(session_id, &loaded.state) - .await?; - refreshed = Some(self.load_session_state(session_id).await?); - } - if subagents_granted { - let current = refreshed.as_ref().unwrap_or(loaded); - if !super::has_all_core_subagent_bindings(¤t.state) { - self.ensure_core_subagent_workflow_tools(session_id, ¤t.state) - .await?; - refreshed = Some(self.load_session_state(session_id).await?); - } - } - let loaded = refreshed.as_ref().unwrap_or(loaded); - let session_config = loaded.state.lifecycle.config.as_ref().ok_or_else(|| { - AgentApiError::invalid_request(format!("session is missing config: {session_id}")) - })?; - let expose_environment_jobs = jobs_granted; - let expose_subagents = subagents_granted; - let mut config = - Self::session_toolset_config(session_config, environments_granted, jobs_granted); - let materialized_workflow_tools = loaded - .state - .workflow_tools - .bindings - .values() - .filter(|binding| { - (expose_environment_jobs || !super::is_core_environment_job_binding(binding)) - && (expose_subagents || !super::is_core_subagent_binding(binding)) - }) - .collect::>(); - enable_concurrency_for_workflow_tools( - &mut config, - materialized_workflow_tools.iter().copied(), - ); - let mut toolset = register_toolset(&config) - .map_err(|error| AgentApiError::internal(format!("build session tools: {error}")))?; - register_workflow_tools(&mut toolset, materialized_workflow_tools.iter().copied()) - .map_err(|error| { - AgentApiError::invalid_request(format!("materialize workflow tool tools: {error}")) - })?; - - // Remote MCP tools are derived from the config's declared links, - // exactly like the standard toolset is derived from the features. - let desired_mcp = self.desired_mcp_tools(&session_config.features).await?; - if let Some(colliding) = materialized_workflow_tools - .iter() - .copied() - .map(|binding| &binding.definition.tool.name) - .find(|tool_name| desired_mcp.contains_key(*tool_name)) - { - return Err(AgentApiError::invalid_request(format!( - "workflow tool tool name {colliding} collides with a remote MCP tool" - ))); - } - let expected_tools = toolset - .tools - .iter() - .chain(desired_mcp.iter()) - .map(|(name, tool)| (name.clone(), tool.clone())) - .collect::>(); - let patch = toolset_reconcile_patch(&loaded.state.tooling.tools, toolset, desired_mcp); - - let baseline_failures = self - .query_status_optional(session_id) - .await? - .map(|status| status.admission_failures.len()) - .unwrap_or(0); - if !patch.is_empty() { - self.submit_core_command( - session_id, - CoreAgentCommand::PatchTools { - expected_revision: Some(loaded.state.tooling.revision), - patch, - }, - ) - .await?; - } - if !wait_for_reconciliation { - return self.project_session_by_id(session_id).await; - } - self.wait_for_session_toolset(session_id, expected_tools, baseline_failures) - .await - } - - pub(super) async fn wait_for_session_toolset( - &self, - session_id: &SessionId, - expected_tools: BTreeMap, - baseline_failures: usize, - ) -> Result { - let started = Instant::now(); - loop { - if started.elapsed() > self.operation_timeout { - return Err(AgentApiError::internal(format!( - "timed out waiting for session tools to configure: {session_id}" - ))); - } - if let Some(status) = self.query_status_optional(session_id).await? { - if status.admission_failures.len() > baseline_failures - && let Some(failure) = status.admission_failures.last() - { - return Err(map_admission_failure_to_api_error(failure)); - } - if let Some(error) = status.last_error { - return Err(AgentApiError::internal(format!( - "agent workflow reported error: {error}" - ))); - } - } - let loaded = self.load_session_state(session_id).await?; - if loaded.state.tooling.tools == expected_tools { - return self.project_session_by_id(session_id).await; - } - tokio::time::sleep(self.poll_interval).await; - } - } -} - -/// Level-triggered reconciliation: converge the installed tools to what the -/// current config implies (standard toolset from features, remote MCP tools -/// from declared links). Re-running against a converged state is a no-op. -pub(super) fn toolset_reconcile_patch( - active: &BTreeMap, - toolset: RegisteredToolset, - desired_mcp: BTreeMap, -) -> engine::ToolPatch { - let mut remove = Vec::new(); - for tool_name in active.keys() { - if !toolset.tools.contains_key(tool_name) && !desired_mcp.contains_key(tool_name) { - remove.push(tool_name.clone()); - } - } - - let mut upsert = Vec::new(); - for (tool_name, tool) in toolset.tools.into_iter().chain(desired_mcp) { - if active.get(&tool_name) != Some(&tool) { - upsert.push(tool); - } - } - - engine::ToolPatch { upsert, remove } -} diff --git a/crates/temporal-server/src/gateway/service/skills.rs b/crates/temporal-server/src/gateway/service/skills.rs index 4c08952f..066d7fde 100644 --- a/crates/temporal-server/src/gateway/service/skills.rs +++ b/crates/temporal-server/src/gateway/service/skills.rs @@ -6,129 +6,17 @@ impl GatewayAgentApi { session_id: &SessionId, ) -> Result { let loaded = self.load_session_state(session_id).await?; - if loaded.state.lifecycle.status == CoreAgentStatus::Open - && loaded.state.runs.active.is_none() - && loaded.state.runs.queued.is_empty() - { - self.refresh_environment_projection_for_idle_session(session_id, &loaded.state) - .await?; - let loaded = self.load_session_state(session_id).await?; - self.refresh_skill_catalog_for_idle_session(session_id, &loaded.state) - .await?; + if loaded.state.lifecycle.status == CoreAgentStatus::Open { + self.prepare_session_operation( + session_id, + temporal_workflow::SessionOperation::RefreshContext, + ) + .await?; return self.load_session_state(session_id).await; } Ok(loaded) } - pub(super) async fn refresh_skill_catalog_for_idle_session( - &self, - session_id: &SessionId, - state: &engine::CoreAgentState, - ) -> Result<(), AgentApiError> { - if state.runs.active.is_some() || !state.runs.queued.is_empty() { - return Ok(()); - } - let mut commands: Vec<_> = self - .skill_catalog_refresh_command(session_id, state) - .await? - .into_iter() - .collect(); - let resolver = - crate::environment_resolver::EnvironmentResolver::from_pg_store(self.store.clone()); - let catalogs = engine::current_catalog_inputs(state); - if let Some(mut command) = crate::environment_skills::refresh( - self.store.as_ref(), - Some(&resolver), - Some(&self.environment_gateway), - session_id, - state - .lifecycle - .config - .as_ref() - .and_then(|config| config.features.environments.as_ref()), - state.environment.active_environment_id.as_ref(), - catalogs.get(&ContextEntryKey::new( - tools::skills::environment::ENVIRONMENT_SKILL_CATALOG_CONTEXT_KEY, - )), - ) - .await - .map_err(|error| AgentApiError::internal(error.to_string()))? - { - match &mut command { - CoreAgentCommand::UpsertContext { - expected_revision, .. - } - | CoreAgentCommand::RemoveContext { - expected_revision, .. - } => *expected_revision = Some(state.context.revision), - _ => {} - } - commands.insert(0, command); - } - self.apply_catalog_refresh_commands(session_id, commands) - .await - } - - pub(super) async fn skill_catalog_refresh_command( - &self, - _session_id: &SessionId, - state: &engine::CoreAgentState, - ) -> Result, AgentApiError> { - let catalogs = engine::current_catalog_inputs(state); - let current = catalogs.get(&ContextEntryKey::new(SKILL_CATALOG_CONTEXT_KEY)); - if current.is_some_and(|entry| entry.origin.as_deref() != Some("runtime.vfs.skills")) { - return Ok(None); - } - let skills_config = state - .lifecycle - .config - .as_ref() - .and_then(|config| config.features.vfs.as_ref()) - .and_then(|vfs| vfs.skills.as_ref()); - let Some(skills_config) = skills_config else { - return Ok(tools::catalog::clear_catalog_command( - current, - SKILL_CATALOG_CONTEXT_KEY, - )); - }; - let links = self.resolve_session_workspace_links(state).await?; - let specs = configured_vfs_skill_root_specs(&links, skills_config.roots.as_deref()) - .map_err(|error| AgentApiError::invalid_request(error.to_string()))?; - if specs.is_empty() { - return Ok(tools::catalog::clear_catalog_command( - current, - SKILL_CATALOG_CONTEXT_KEY, - )); - } - - let blobs: Arc = self.store.clone(); - let workspace_store: Arc = self.store.clone(); - let resolved = resolve_linked_vfs_skill_roots(blobs, workspace_store, links, specs) - .await - .map_err(|error| AgentApiError::internal(error.to_string()))?; - let inputs = resolved - .existing_directory_inputs() - .await - .map_err(|error| AgentApiError::internal(error.to_string()))?; - if inputs.is_empty() && resolved.warnings().is_empty() { - return Ok(tools::catalog::clear_catalog_command( - current, - SKILL_CATALOG_CONTEXT_KEY, - )); - } - - let publication = tools::skills::prepare_skill_catalog_publication_with_warnings( - self.store.as_ref(), - Some(self.store.as_ref()), - current, - &inputs, - resolved.warnings().to_vec(), - ) - .await - .map_err(|error| AgentApiError::internal(error.to_string()))?; - Ok(publication.command) - } - pub(super) async fn project_skill_list( &self, loaded: &LoadedSession, @@ -175,7 +63,7 @@ pub(super) fn skill_list_response( .map(|warning| { use tools::skills::SkillLoadWarningKind; let message = match &warning.kind { - SkillLoadWarningKind::UnavailableWorkspaceLink { reason } => { + SkillLoadWarningKind::UnavailableWorkspaceAttachment { reason } => { reason.as_str() } SkillLoadWarningKind::MissingSkillDoc => "missing SKILL.md", @@ -201,12 +89,12 @@ pub(super) fn skill_list_response( short_description: skill.short_description.clone(), enabled: skill.enabled, location: match &skill.location { - SkillLocation::LinkedSnapshot { + SkillLocation::AttachedSnapshot { skill_dir_path, skill_doc_path, .. } - | SkillLocation::LinkedWorkspace { + | SkillLocation::AttachedWorkspace { skill_dir_path, skill_doc_path, .. diff --git a/crates/temporal-server/src/gateway/service/subagents_api.rs b/crates/temporal-server/src/gateway/service/subagents_api.rs deleted file mode 100644 index ba2e2f80..00000000 --- a/crates/temporal-server/src/gateway/service/subagents_api.rs +++ /dev/null @@ -1,147 +0,0 @@ -use super::*; - -impl GatewayAgentApi { - /// Sub-agent catalog refresh for an idle session: the same - /// publish-if-changed shape as the skill catalog, computed from the - /// admitted grant and the current profile records. - pub(super) async fn refresh_subagent_catalog_for_idle_session( - &self, - session_id: &SessionId, - state: &engine::CoreAgentState, - ) -> Result<(), AgentApiError> { - let catalogs = engine::current_catalog_inputs(state); - let current = catalogs.get(&ContextEntryKey::new(SUBAGENT_CATALOG_CONTEXT_KEY)); - let subagents = state - .lifecycle - .config - .as_ref() - .and_then(|config| config.features.subagents.as_ref()); - let command = match subagents { - Some(subagents) => { - let profiles: Arc = self.store.clone(); - let snapshot = - crate::worker::subagent_catalog_snapshot(Some(profiles.as_ref()), subagents) - .await; - tools::subagents::prepare_subagent_catalog_publication( - self.store.as_ref(), - current, - &snapshot, - ) - .await - .map_err(|error| AgentApiError::internal(error.to_string()))? - } - None => tools::catalog::clear_catalog_command(current, SUBAGENT_CATALOG_CONTEXT_KEY), - }; - let Some(command) = command else { - return Ok(()); - }; - self.apply_catalog_refresh_commands(session_id, vec![command]) - .await - } - - /// `ProfileEnvironment::Inherit`: the delegating parent's active - /// environment, resolved at apply time from the child's origin. - pub(super) async fn resolve_inherited_environment( - &self, - session_id: &SessionId, - ) -> Result { - let record = self - .store - .load_session(session_id) - .await - .map_err(map_session_store_error)? - .ok_or_else(|| AgentApiError::not_found(format!("session not found: {session_id}")))?; - let Some(origin) = record.origin.as_ref() else { - return Err(AgentApiError::invalid_request( - "profile environment `inherit` requires a sub-agent session with a delegation origin", - )); - }; - let parent = self.load_session_state(&origin.parent_session_id).await?; - parent - .state - .environment - .active_environment_id - .as_ref() - .map(|environment_id| environment_id.as_str().to_owned()) - .ok_or_else(|| { - AgentApiError::rejected(format!( - "profile environment `inherit`: parent session {} has no active environment", - origin.parent_session_id - )) - }) - } -} - -impl GatewayAgentApi { - /// Activate an inherited environment in a sub-agent: the parent already - /// passed the activation gate for this environment, so the child copies - /// the selection after checking only its own grant (the environments - /// feature and its provider allowlist) and that the environment is not - /// gone. No reachability probe: a not-ready environment makes the - /// child's tools wait, exactly as it does for the parent. - pub(super) async fn apply_inherited_environment( - &self, - session_id: &SessionId, - environment_id: api::EnvironmentId, - ) -> Result { - let environment_id = parse_registry_environment_id(environment_id)?; - let loaded = self.load_session_state(session_id).await?; - if loaded.state.environment.active_environment_id.as_ref() == Some(&environment_id) { - return Ok(false); - } - let feature = loaded - .state - .lifecycle - .config - .as_ref() - .and_then(|config| config.features.environments.as_ref()) - .ok_or_else(|| { - AgentApiError::rejected( - "profile environment `inherit` requires the environments feature to be granted", - ) - })?; - let registry_id = ::environments::EnvironmentId::try_new( - environment_id.as_str().to_owned(), - ) - .map_err(|error| AgentApiError::internal(format!("invalid environment id: {error}")))?; - let environments: Arc = self.store.clone(); - let record = environments - .read_environment(®istry_id) - .await - .map_err(map_environments_error)?; - if matches!( - record.status, - ::environments::EnvironmentStatus::Closing - | ::environments::EnvironmentStatus::Closed - | ::environments::EnvironmentStatus::Failed - ) { - return Err(AgentApiError::rejected(format!( - "profile environment `inherit`: parent environment {environment_id} is {:?}", - record.status - ))); - } - let policy = ::environments::EnvironmentAccessPolicy::new( - feature.providers.clone(), - feature.registration_keys.clone(), - ); - if !policy.allows(&record) { - return Err(AgentApiError::rejected(format!( - "profile environment `inherit`: parent environment {environment_id}: {}", - policy.refusal(&record) - ))); - } - let baseline_failures = self - .query_status_optional(session_id) - .await? - .map(|status| status.admission_failures.len()) - .unwrap_or(0); - self.submit_core_command( - session_id, - activate_environment_command(environment_id.clone()), - ) - .await?; - self.wait_for_active_environment(session_id, Some(&environment_id), baseline_failures) - .await?; - Ok(true) - } -} diff --git a/crates/temporal-server/src/gateway/service/tests.rs b/crates/temporal-server/src/gateway/service/tests.rs index 8eefa93b..75b9d67e 100644 --- a/crates/temporal-server/src/gateway/service/tests.rs +++ b/crates/temporal-server/src/gateway/service/tests.rs @@ -1,7 +1,7 @@ use api::BlobPutItem; use super::*; -use crate::gateway::service::prompts::active_prompt_context_entries; +use tools::prompts::active_prompt_instruction_entries as active_prompt_context_entries; use tools::skills::SkillLocation; use vfs::VfsPath; @@ -44,46 +44,6 @@ fn admission_failure_mapping_uses_gateway_error_kinds() { ); } -#[test] -fn managed_session_retry_requires_the_durable_creation_fingerprint() { - let universe_id = uuid::Uuid::from_u128(1); - let declaration = engine::ManagedSessionWorkflowTools::v1( - Some(engine::WorkflowEndpointRef { - workflow_id: "global controller/work-1".to_owned(), - workflow_kind: "agent_work".to_owned(), - }), - Vec::new(), - ); - let mut state = engine::CoreAgentState::new(); - state.workflow_tools.session_universe_id = Some(universe_id); - state.workflow_tools.managed_creation_fingerprint = Some( - declaration - .creation_fingerprint(universe_id) - .expect("creation fingerprint"), - ); - validate_managed_session_retry(&state, universe_id, &declaration).expect("matching retry"); - - let conflicting = engine::ManagedSessionWorkflowTools::v1( - Some(engine::WorkflowEndpointRef { - workflow_id: "another controller".to_owned(), - workflow_kind: "agent_work".to_owned(), - }), - Vec::new(), - ); - assert_eq!( - validate_managed_session_retry(&state, universe_id, &conflicting) - .expect_err("conflicting retry") - .kind, - AgentApiErrorKind::Conflict - ); - assert_eq!( - validate_managed_session_retry(&engine::CoreAgentState::new(), universe_id, &declaration,) - .expect_err("standalone session cannot become managed") - .kind, - AgentApiErrorKind::Conflict - ); -} - #[test] fn legacy_subagent_bindings_retain_their_immutable_deadline_ceiling() { const LEGACY_CEILING_MS: u64 = 4 * 60 * 60 * 1_000; @@ -510,23 +470,23 @@ fn environment_deactivation_lowers_to_clear_active_environment_command() { } #[test] -fn declared_mcp_link_materializes_remote_tool() { +fn declared_mcp_attachment_materializes_remote_tool() { let tool_name = ToolName::new("mcp_crm"); let active = BTreeMap::new(); let mut record = test_mcp_server_record("durable-crm-server", mcp::McpServerStatus::Active); record.default_server_label = "crm".to_owned(); record.allowed_tools = Some(vec!["lookup_customer".to_owned()]); - record.approval_default = mcp::McpApprovalPolicy::Never; - record.defer_loading_default = Some(true); - let link = engine::McpServerLink { + record.approval = mcp::McpApprovalPolicy::Never; + record.defer_loading = Some(true); + let attachment = engine::McpServerAttachment { server_id: "durable-crm-server".to_owned(), + tools: None, }; - let tool = mcp_api::mcp_tool_from_config_link(&link, &record, None) - .expect("materialize MCP tool from config link"); + let tool = mcp_api::mcp_tool_from_config_attachment(&attachment, &record, None) + .expect("materialize MCP tool from config attachment"); let desired = BTreeMap::from([(tool.name.clone(), tool)]); - let patch = - super::session_toolset::toolset_reconcile_patch(&active, empty_resolved_toolset(), desired); + let patch = temporal_workflow::session_toolset_patch(&active, &desired); let tools = patch.apply_to(&active).expect("apply MCP patch"); let tool = tools.get(&tool_name).expect("MCP tool"); @@ -540,6 +500,50 @@ fn declared_mcp_link_materializes_remote_tool() { assert_eq!(spec.defer_loading, Some(true)); } +#[test] +fn mcp_attachment_subsets_constrain_every_execution_and_exposure_mode() { + for (execution, exposure) in [ + (mcp::McpExecution::Provider, mcp::McpExposure::Inject), + (mcp::McpExecution::Native, mcp::McpExposure::Inject), + (mcp::McpExecution::Native, mcp::McpExposure::Search), + ] { + let mut record = test_mcp_server_record("crm", mcp::McpServerStatus::Active); + record.execution = execution; + record.exposure = exposure; + record.allowed_tools = Some(vec!["search".into(), "delete_customer".into()]); + let attachment = engine::McpServerAttachment { + server_id: "crm".into(), + tools: Some(vec!["search".into()]), + }; + for allowlist in [record.allowed_tools.clone(), None] { + record.allowed_tools = allowlist; + let tool = + mcp_api::mcp_tool_from_config_attachment(&attachment, &record, None).unwrap(); + let engine::ToolKind::RemoteMcp(spec) = tool.kind else { + panic!("expected remote MCP tool"); + }; + assert_eq!(spec.allowed_tools, Some(vec!["search".into()])); + } + // Record edits must not let a retained session attachment restore revoked tools. + record.allowed_tools = Some(vec!["delete_customer".into()]); + let error = + mcp_api::mcp_tool_from_config_attachment(&attachment, &record, None).unwrap_err(); + assert_eq!(error.kind, AgentApiErrorKind::InvalidRequest); + + let unrestricted_attachment = engine::McpServerAttachment { + tools: None, + ..attachment + }; + let tool = + mcp_api::mcp_tool_from_config_attachment(&unrestricted_attachment, &record, None) + .unwrap(); + let engine::ToolKind::RemoteMcp(spec) = tool.kind else { + panic!("expected remote MCP tool"); + }; + assert_eq!(spec.allowed_tools, record.allowed_tools); + } +} + fn test_auth_grant_record( grant_id: &str, provider_kind: auth::AuthProviderKind, @@ -583,9 +587,10 @@ fn grant_leases_require_creation_time_retrievable_exposure() { require_retrievable_grant(&retrievable).expect("retrievable grant accepted"); } -fn mcp_config_link() -> engine::McpServerLink { - engine::McpServerLink { +fn mcp_config_attachment() -> engine::McpServerAttachment { + engine::McpServerAttachment { server_id: "crm".to_owned(), + tools: None, } } @@ -608,7 +613,7 @@ fn mcp_server_put_enforces_required_and_optional_binding_states() { } #[test] -fn mcp_link_with_grant_materializes_auth_ref_for_bearer_server() { +fn mcp_attachment_with_grant_materializes_auth_ref_for_bearer_server() { let mut record = test_mcp_server_record("crm", mcp::McpServerStatus::Active); record.auth_policy = mcp::McpServerAuthPolicy::RequiredBearer; record.auth_grant_id = Some(auth::AuthGrantId::new("authgrant_1")); @@ -619,8 +624,9 @@ fn mcp_link_with_grant_materializes_auth_ref_for_bearer_server() { Some("https://crm.example.com"), ); - let tool = mcp_api::mcp_tool_from_config_link(&mcp_config_link(), &record, Some(&grant)) - .expect("materialize MCP tool with grant"); + let tool = + mcp_api::mcp_tool_from_config_attachment(&mcp_config_attachment(), &record, Some(&grant)) + .expect("materialize MCP tool with grant"); let engine::ToolKind::RemoteMcp(spec) = &tool.kind else { panic!("expected remote MCP tool"); @@ -635,7 +641,7 @@ fn mcp_link_with_grant_materializes_auth_ref_for_bearer_server() { } #[test] -fn mcp_link_rejects_revoked_grant() { +fn mcp_attachment_rejects_revoked_grant() { let mut record = test_mcp_server_record("crm", mcp::McpServerStatus::Active); record.auth_policy = mcp::McpServerAuthPolicy::RequiredBearer; record.auth_grant_id = Some(auth::AuthGrantId::new("authgrant_1")); @@ -646,14 +652,15 @@ fn mcp_link_rejects_revoked_grant() { None, ); - let error = mcp_api::mcp_tool_from_config_link(&mcp_config_link(), &record, Some(&grant)) - .expect_err("revoked grant must be rejected"); + let error = + mcp_api::mcp_tool_from_config_attachment(&mcp_config_attachment(), &record, Some(&grant)) + .expect_err("revoked grant must be rejected"); assert_eq!(error.kind, api::AgentApiErrorKind::Rejected); } #[test] -fn mcp_link_rejects_grant_kind_incompatible_with_auth_policy() { +fn mcp_attachment_rejects_grant_kind_incompatible_with_auth_policy() { let mut record = test_mcp_server_record("crm", mcp::McpServerStatus::Active); record.auth_policy = mcp::McpServerAuthPolicy::RequiredOAuth { resource: "https://crm.example.com".to_owned(), @@ -669,14 +676,15 @@ fn mcp_link_rejects_grant_kind_incompatible_with_auth_policy() { None, ); - let error = mcp_api::mcp_tool_from_config_link(&mcp_config_link(), &record, Some(&grant)) - .expect_err("bearer grant must not satisfy OAuth policy"); + let error = + mcp_api::mcp_tool_from_config_attachment(&mcp_config_attachment(), &record, Some(&grant)) + .expect_err("bearer grant must not satisfy OAuth policy"); assert_eq!(error.kind, api::AgentApiErrorKind::Rejected); } #[test] -fn mcp_link_rejects_grant_audience_that_does_not_cover_server() { +fn mcp_attachment_rejects_grant_audience_that_does_not_cover_server() { let mut record = test_mcp_server_record("crm", mcp::McpServerStatus::Active); record.auth_policy = mcp::McpServerAuthPolicy::OptionalBearer; record.auth_grant_id = Some(auth::AuthGrantId::new("authgrant_1")); @@ -687,8 +695,9 @@ fn mcp_link_rejects_grant_audience_that_does_not_cover_server() { Some("https://other.example.com"), ); - let error = mcp_api::mcp_tool_from_config_link(&mcp_config_link(), &record, Some(&grant)) - .expect_err("audience mismatch must be rejected"); + let error = + mcp_api::mcp_tool_from_config_attachment(&mcp_config_attachment(), &record, Some(&grant)) + .expect_err("audience mismatch must be rejected"); assert_eq!(error.kind, api::AgentApiErrorKind::Rejected); } @@ -710,8 +719,9 @@ fn mcp_server_rejects_grant_audience_that_does_not_cover_oauth_resource() { Some("https://crm.example.com"), ); - let error = mcp_api::mcp_tool_from_config_link(&mcp_config_link(), &record, Some(&grant)) - .expect_err("grant audience must cover the OAuth resource as well as the server URL"); + let error = + mcp_api::mcp_tool_from_config_attachment(&mcp_config_attachment(), &record, Some(&grant)) + .expect_err("grant audience must cover the OAuth resource as well as the server URL"); assert_eq!(error.kind, api::AgentApiErrorKind::Rejected); } @@ -740,32 +750,32 @@ fn two_server_ids_can_share_an_endpoint_with_distinct_credentials() { Some("https://crm.example.com"), ); - let mut work_link = mcp_config_link(); - work_link.server_id = "crm_work".to_owned(); - let mut personal_link = mcp_config_link(); - personal_link.server_id = "crm_personal".to_owned(); + let mut work_attachment = mcp_config_attachment(); + work_attachment.server_id = "crm_work".to_owned(); + let mut personal_attachment = mcp_config_attachment(); + personal_attachment.server_id = "crm_personal".to_owned(); let work_tool = - mcp_api::mcp_tool_from_config_link(&work_link, &work, Some(&work_grant)).expect("work"); - let personal_tool = - mcp_api::mcp_tool_from_config_link(&personal_link, &personal, Some(&personal_grant)) - .expect("personal"); + mcp_api::mcp_tool_from_config_attachment(&work_attachment, &work, Some(&work_grant)) + .expect("work"); + let personal_tool = mcp_api::mcp_tool_from_config_attachment( + &personal_attachment, + &personal, + Some(&personal_grant), + ) + .expect("personal"); let desired = BTreeMap::from([ (work_tool.name.clone(), work_tool), (personal_tool.name.clone(), personal_tool), ]); - let tools = super::session_toolset::toolset_reconcile_patch( - &BTreeMap::new(), - empty_resolved_toolset(), - desired, - ) - .apply_to(&BTreeMap::new()) - .expect("both identities may coexist when their server labels differ"); + let tools = temporal_workflow::session_toolset_patch(&BTreeMap::new(), &desired) + .apply_to(&BTreeMap::new()) + .expect("both identities may coexist when their server labels differ"); assert_eq!(tools.len(), 2); } #[test] -fn mcp_link_rejects_grant_for_no_auth_server() { +fn mcp_attachment_rejects_grant_for_no_auth_server() { let record = test_mcp_server_record("crm", mcp::McpServerStatus::Active); let grant = test_auth_grant_record( "authgrant_1", @@ -774,171 +784,24 @@ fn mcp_link_rejects_grant_for_no_auth_server() { None, ); - let error = mcp_api::mcp_tool_from_config_link(&mcp_config_link(), &record, Some(&grant)) - .expect_err("grant on no-auth server must be rejected"); + let error = + mcp_api::mcp_tool_from_config_attachment(&mcp_config_attachment(), &record, Some(&grant)) + .expect_err("grant on no-auth server must be rejected"); assert_eq!(error.kind, api::AgentApiErrorKind::InvalidRequest); } #[test] -fn mcp_link_requires_grant_for_required_auth_server() { +fn mcp_attachment_requires_grant_for_required_auth_server() { let mut record = test_mcp_server_record("crm", mcp::McpServerStatus::Active); record.auth_policy = mcp::McpServerAuthPolicy::RequiredBearer; - let error = mcp_api::mcp_tool_from_config_link(&mcp_config_link(), &record, None) + let error = mcp_api::mcp_tool_from_config_attachment(&mcp_config_attachment(), &record, None) .expect_err("missing grant must be rejected for required auth"); assert_eq!(error.kind, api::AgentApiErrorKind::Rejected); } -#[test] -fn toolset_reconcile_patch_preserves_declared_remote_mcp_tools() { - let remote_tool_name = ToolName::new("mcp_crm"); - let old_tool_name = ToolName::new("old_tool"); - let new_tool_name = ToolName::new("new_tool"); - let active = BTreeMap::from([ - ( - remote_tool_name.clone(), - test_remote_mcp_tool(remote_tool_name.clone()), - ), - ( - old_tool_name.clone(), - test_function_tool(old_tool_name.clone()), - ), - ]); - let toolset = RegisteredToolset { - tools: BTreeMap::from([( - new_tool_name.clone(), - test_function_tool(new_tool_name.clone()), - )]), - }; - let desired_mcp = BTreeMap::from([( - remote_tool_name.clone(), - test_remote_mcp_tool(remote_tool_name.clone()), - )]); - - let patch = super::session_toolset::toolset_reconcile_patch(&active, toolset, desired_mcp); - let tools = patch.apply_to(&active).expect("apply reconcile patch"); - - assert!(tools.contains_key(&remote_tool_name)); - assert!(!tools.contains_key(&old_tool_name)); - assert!(tools.contains_key(&new_tool_name)); -} - -#[test] -fn toolset_reconcile_patch_removes_undeclared_remote_mcp_tools() { - let remote_tool_name = ToolName::new("mcp_crm"); - let active = BTreeMap::from([( - remote_tool_name.clone(), - test_remote_mcp_tool(remote_tool_name.clone()), - )]); - - let patch = super::session_toolset::toolset_reconcile_patch( - &active, - empty_resolved_toolset(), - BTreeMap::new(), - ); - let tools = patch.apply_to(&active).expect("apply reconcile patch"); - - assert!(!tools.contains_key(&remote_tool_name)); -} - -#[test] -fn toolset_reconcile_patch_tracks_every_mcp_policy_transition() { - let remote_tool_name = ToolName::new("mcp_crm"); - let find_tool_name = ToolName::new("mcp_find_tools"); - let call_tool_name = ToolName::new("mcp_call"); - - let mut inject_all = test_remote_mcp_tool(remote_tool_name.clone()); - let engine::ToolKind::RemoteMcp(spec) = &mut inject_all.kind else { - unreachable!("test helper must produce a remote MCP tool"); - }; - spec.execution = engine::RemoteMcpExecution::Native; - let mut active = BTreeMap::from([(remote_tool_name.clone(), inject_all)]); - - let mut search_selected = test_remote_mcp_tool(remote_tool_name.clone()); - let engine::ToolKind::RemoteMcp(spec) = &mut search_selected.kind else { - unreachable!("test helper must produce a remote MCP tool"); - }; - spec.record_revision = 2; - spec.execution = engine::RemoteMcpExecution::Native; - spec.exposure = engine::RemoteMcpExposure::Search; - spec.allowed_tools = Some(vec!["lookup_customer".to_owned()]); - let desired = BTreeMap::from([ - (remote_tool_name.clone(), search_selected), - ( - find_tool_name.clone(), - test_function_tool(find_tool_name.clone()), - ), - ( - call_tool_name.clone(), - test_function_tool(call_tool_name.clone()), - ), - ]); - active = - super::session_toolset::toolset_reconcile_patch(&active, empty_resolved_toolset(), desired) - .apply_to(&active) - .expect("switch inject-all to search-selected"); - assert!(active.contains_key(&find_tool_name)); - assert!(active.contains_key(&call_tool_name)); - let engine::ToolKind::RemoteMcp(spec) = &active[&remote_tool_name].kind else { - panic!("expected remote MCP tool"); - }; - assert_eq!(spec.exposure, engine::RemoteMcpExposure::Search); - assert_eq!(spec.allowed_tools, Some(vec!["lookup_customer".to_owned()])); - - let mut search_other_selection = active[&remote_tool_name].clone(); - let engine::ToolKind::RemoteMcp(spec) = &mut search_other_selection.kind else { - unreachable!("expected remote MCP tool"); - }; - spec.record_revision = 3; - spec.allowed_tools = Some(vec!["create_customer".to_owned()]); - let desired = BTreeMap::from([ - (remote_tool_name.clone(), search_other_selection), - (find_tool_name.clone(), active[&find_tool_name].clone()), - (call_tool_name.clone(), active[&call_tool_name].clone()), - ]); - active = - super::session_toolset::toolset_reconcile_patch(&active, empty_resolved_toolset(), desired) - .apply_to(&active) - .expect("change selected search tools"); - let engine::ToolKind::RemoteMcp(spec) = &active[&remote_tool_name].kind else { - panic!("expected remote MCP tool"); - }; - assert_eq!(spec.allowed_tools, Some(vec!["create_customer".to_owned()])); - - let mut inject_selected = active[&remote_tool_name].clone(); - let engine::ToolKind::RemoteMcp(spec) = &mut inject_selected.kind else { - unreachable!("expected remote MCP tool"); - }; - spec.record_revision = 4; - spec.exposure = engine::RemoteMcpExposure::Inject; - let desired = BTreeMap::from([(remote_tool_name.clone(), inject_selected)]); - active = - super::session_toolset::toolset_reconcile_patch(&active, empty_resolved_toolset(), desired) - .apply_to(&active) - .expect("switch search to inject-selected"); - assert!(!active.contains_key(&find_tool_name)); - assert!(!active.contains_key(&call_tool_name)); - - let mut inject_all = active[&remote_tool_name].clone(); - let engine::ToolKind::RemoteMcp(spec) = &mut inject_all.kind else { - unreachable!("expected remote MCP tool"); - }; - spec.record_revision = 5; - spec.allowed_tools = None; - let desired = BTreeMap::from([(remote_tool_name.clone(), inject_all)]); - active = - super::session_toolset::toolset_reconcile_patch(&active, empty_resolved_toolset(), desired) - .apply_to(&active) - .expect("switch inject-selected to inject-all"); - let engine::ToolKind::RemoteMcp(spec) = &active[&remote_tool_name].kind else { - panic!("expected remote MCP tool"); - }; - assert_eq!(spec.exposure, engine::RemoteMcpExposure::Inject); - assert_eq!(spec.allowed_tools, None); -} - #[test] fn prompt_report_ref_reads_prompt_provider_metadata() { let prompt_ref = BlobRef::from_bytes(b"prompt"); @@ -1355,41 +1218,80 @@ fn features_default_off_for_sessions() { assert!(config.features.vfs.is_none()); } +fn environments_feature(attachments: Vec) -> api::EnvironmentsFeature { + api::EnvironmentsFeature { + version: api::CURRENT_FEATURE_VERSION, + selection: true, + prompts: None, + skills: None, + environments: attachments, + } +} + +fn environment_attachment(id: Option<&str>, inherit: bool) -> api::EnvironmentAttachment { + api::EnvironmentAttachment { + environment_id: id.map(str::to_owned), + inherit, + default: false, + access: api::EnvironmentAccess::Jobs, + working_directory: Some("/srv".to_owned()), + } +} + #[test] -fn environment_tool_subgrants_are_default_off_and_map_explicit_opt_in() { +fn environment_attachments_map_to_grants_and_reject_inherit_in_session_config() { let default_feature: api::EnvironmentsFeature = serde_json::from_value(serde_json::json!({})).expect("empty environment feature"); - assert!(!default_feature.selection_tools); - assert!(!default_feature.jobs); - assert!(!default_feature.commands); - assert!(default_feature.tools.is_none()); + assert!(!default_feature.selection); + assert!(default_feature.environments.is_empty()); let config = engine_session_config_from_api( api::SessionConfig { features: Some(api::FeaturesConfig { - environments: Some(api::EnvironmentsFeature { - tools: Some(api::EnvironmentToolSurface::Edit), - commands: true, - working_directory: None, - prompts: None, - version: api::CURRENT_FEATURE_VERSION, - providers: None, - registration_keys: None, - selection_tools: true, - jobs: true, - skills: None, - }), + environments: Some(environments_feature(vec![environment_attachment( + Some("env_a"), + false, + )])), ..api::FeaturesConfig::default() }), ..api::SessionConfig::default() }, openai_model(), ) - .expect("map environment jobs grant"); - + .expect("map environment attachment"); let environments = config.features.environments.expect("environment feature"); - assert!(environments.selection_tools); - assert!(environments.jobs); + assert!(environments.selection); + assert_eq!( + environments.tool_access(), + Some(engine::EnvironmentAccess::Jobs) + ); + assert_eq!( + environments + .attachment("env_a") + .unwrap() + .working_directory + .as_deref(), + Some("/srv") + ); + + for attachment in [ + environment_attachment(None, true), + environment_attachment(Some("env_a"), true), + environment_attachment(None, false), + ] { + let error = engine_session_config_from_api( + api::SessionConfig { + features: Some(api::FeaturesConfig { + environments: Some(environments_feature(vec![attachment])), + ..api::FeaturesConfig::default() + }), + ..api::SessionConfig::default() + }, + openai_model(), + ) + .expect_err("inherit and malformed attachments are rejected in session config"); + assert_eq!(error.kind, AgentApiErrorKind::InvalidRequest); + } } #[test] @@ -1484,76 +1386,85 @@ fn web_search_accepts_anthropic_messages() { } #[test] -fn vfs_feature_grant_maps_tool_surfaces() { - for (api_surface, engine_surface) in [ - ( - api::VfsToolSurface::ReadOnly, - engine::VfsToolSurface::ReadOnly, - ), - (api::VfsToolSurface::Edit, engine::VfsToolSurface::Edit), - ] { - let config = engine_session_config_from_api( - api::SessionConfig { - features: Some(api::FeaturesConfig { - vfs: Some(api::VfsFeature { - working_directory: None, - version: api::CURRENT_FEATURE_VERSION, - workspace_links: Vec::new(), - tools: Some(api_surface), - prompts: None, - skills: None, - }), - ..api::FeaturesConfig::default() - }), - ..api::SessionConfig::default() - }, - openai_model(), - ) - .expect("map config"); - - assert_eq!( - config.features.vfs.expect("vfs feature").tools, - Some(engine_surface) - ); - } - - // A VFS grant without tools yields a VFS with no fs tool surface. - let config = engine_session_config_from_api( +fn vfs_attachments_derive_the_tool_surface() { + fn vfs(workspaces: Vec) -> api::SessionConfig { api::SessionConfig { features: Some(api::FeaturesConfig { vfs: Some(api::VfsFeature { working_directory: None, version: api::CURRENT_FEATURE_VERSION, - workspace_links: Vec::new(), - tools: None, + workspaces, prompts: None, skills: None, }), ..api::FeaturesConfig::default() }), ..api::SessionConfig::default() - }, - openai_model(), - ) - .expect("map config"); + } + } + fn attachment(path: &str, access: api::WorkspaceAccess) -> api::WorkspaceAttachment { + api::WorkspaceAttachment { + path: path.to_owned(), + workspace_id: Some("ws_1".to_owned()), + snapshot_ref: None, + access, + } + } + for (workspaces, expected) in [ + (vec![], None), + ( + vec![attachment("/ref", api::WorkspaceAccess::Read)], + Some(engine::WorkspaceAccess::Read), + ), + ( + vec![ + attachment("/ref", api::WorkspaceAccess::Read), + attachment("/workspace", api::WorkspaceAccess::Edit), + ], + Some(engine::WorkspaceAccess::Edit), + ), + ] { + let config = + engine_session_config_from_api(vfs(workspaces), openai_model()).expect("map config"); + assert_eq!( + config.features.vfs.expect("vfs feature").tool_access(), + expected + ); + } - assert_eq!(config.features.vfs.expect("vfs feature").tools, None); + let both = api::WorkspaceAttachment { + snapshot_ref: Some(format!("sha256:{}", "a".repeat(64))), + ..attachment("/both", api::WorkspaceAccess::Read) + }; + let neither = api::WorkspaceAttachment { + workspace_id: None, + ..attachment("/neither", api::WorkspaceAccess::Read) + }; + for invalid in [both, neither] { + let error = engine_session_config_from_api(vfs(vec![invalid]), openai_model()) + .expect_err("an attachment names exactly one resource"); + assert_eq!(error.kind, AgentApiErrorKind::InvalidRequest); + } } #[test] fn profile_and_session_grants_derive_vfs_transfer_tools() { for environments in [false, true] { - for surface in [None, Some("none"), Some("readOnly"), Some("edit")] { + for surface in [None, Some("none"), Some("read"), Some("edit")] { let mut features = serde_json::json!({}); if let Some(surface) = surface { features["vfs"] = serde_json::json!({}); if surface != "none" { - features["vfs"]["tools"] = serde_json::json!(surface); + features["vfs"]["workspaces"] = serde_json::json!([ + {"path":"/workspace","workspaceId":"ws_1","access":surface} + ]); } } if environments { - // Selection tools and jobs are independent of transfer availability. - features["environments"] = serde_json::json!({"tools":"edit"}); + // Selection and jobs are independent of transfer availability. + features["environments"] = serde_json::json!({ + "environments":[{"environmentId":"env_a","access":"edit"}] + }); } let profile: api::ProfileDocument = serde_json::from_value(serde_json::json!({"config": {"features": features}})) @@ -1566,7 +1477,7 @@ fn profile_and_session_grants_derive_vfs_transfer_tools() { for (id, expected) in [ ( "vfs.materialize", - environments && matches!(surface, Some("readOnly" | "edit")), + environments && matches!(surface, Some("read" | "edit")), ), ("vfs.capture", environments && surface == Some("edit")), ] { @@ -2199,6 +2110,7 @@ async fn vfs_snapshot_commit_rejects_missing_file_blob_refs() { fn failure(kind: AgentAdmissionFailureKind) -> AgentAdmissionFailure { AgentAdmissionFailure { + preparation_error: None, submission_id: Some(SubmissionId::new("submit_test")), correlation_token: None, kind, @@ -2257,9 +2169,9 @@ fn test_skill_metadata_with_snapshot( trust: tools::skills::SkillTrustLevel::System, interface: None, dependencies: tools::skills::SkillDependencies::default(), - location: SkillLocation::LinkedSnapshot { + location: SkillLocation::AttachedSnapshot { source_snapshot_ref: snapshot_ref, - source_link_path: VfsPath::parse("/skills/system").unwrap(), + source_attachment_path: VfsPath::parse("/skills/system").unwrap(), skill_dir_path: VfsPath::parse(format!("/skills/system/{name}")).unwrap(), skill_doc_path: VfsPath::parse(format!("/skills/system/{name}/SKILL.md")).unwrap(), }, @@ -2281,8 +2193,8 @@ fn test_mcp_server_put(server_id: &str, status: mcp::McpServerStatus) -> mcp::Pu allowed_tools: None, execution: mcp::McpExecution::Provider, exposure: mcp::McpExposure::Inject, - approval_default: mcp::McpApprovalPolicy::Never, - defer_loading_default: None, + approval: mcp::McpApprovalPolicy::Never, + defer_loading: None, allow_private_network: false, auth_policy: mcp::McpServerAuthPolicy::None, auth_grant_id: None, @@ -2291,50 +2203,6 @@ fn test_mcp_server_put(server_id: &str, status: mcp::McpServerStatus) -> mcp::Pu } } -fn empty_resolved_toolset() -> RegisteredToolset { - RegisteredToolset { - tools: BTreeMap::new(), - } -} - -fn test_remote_mcp_tool(tool_name: ToolName) -> engine::ToolSpec { - engine::ToolSpec { - name: tool_name, - execution: Default::default(), - kind: engine::ToolKind::RemoteMcp(engine::RemoteMcpToolSpec { - server_id: "crm".to_owned(), - record_revision: 1, - server_label: "crm".to_owned(), - server_url: "https://crm.example.com/mcp".to_owned(), - description_ref: None, - allowed_tools: None, - execution: engine::RemoteMcpExecution::Provider, - exposure: engine::RemoteMcpExposure::Inject, - approval: engine::RemoteMcpApprovalPolicy::Never, - defer_loading: None, - auth_ref: None, - auth_required: false, - allow_private_network: false, - }), - parallelism: engine::ToolParallelism::ParallelSafe, - } -} - -fn test_function_tool(tool_name: ToolName) -> engine::ToolSpec { - engine::ToolSpec { - name: tool_name, - execution: Default::default(), - kind: engine::ToolKind::Function(engine::FunctionToolSpec { - description_ref: None, - input_schema_ref: BlobRef::from_bytes(b"schema"), - output_schema_ref: None, - strict: Some(true), - provider_options_ref: None, - }), - parallelism: engine::ToolParallelism::Exclusive, - } -} - fn client_create_params() -> AuthClientCreateParams { serde_json::from_value(serde_json::json!({ "clientId": "crm", @@ -2834,47 +2702,72 @@ async fn skill_list_reads_latest_structured_provenance() { } #[test] -fn environment_filesystem_and_commands_are_independent_grants() { - for surface in [None, Some("readOnly"), Some("edit")] { - for commands in [false, true] { - let mut features = serde_json::json!({"vfs":{"tools":"edit"},"environments":{"commands":commands,"jobs":true,"skills":{},"prompts":{}}}); - if let Some(surface) = surface { - features["environments"]["tools"] = serde_json::json!(surface); - } - let config = engine_session_config_from_api( - serde_json::from_value(serde_json::json!({"features":features})).unwrap(), - openai_model(), - ) - .unwrap(); - let registered = tools::toolset::register_toolset( - &GatewayAgentApi::session_toolset_config(&config, true, false), - ) - .unwrap(); - for (id, granted) in [ - ("env.read_file", surface.is_some()), - ("env.grep", surface.is_some()), - ("env.glob", surface.is_some()), - ("env.list_dir", surface.is_some()), - ("env.write_file", surface == Some("edit")), - ("env.edit_file", surface == Some("edit")), - ("env.apply_patch", surface == Some("edit")), - ("env.run_process", commands), - ("env.continue_process", commands), - ("vfs.materialize", surface == Some("edit")), - ("vfs.capture", surface.is_some()), - ] { - assert_eq!( - registered.tools.contains_key(&ToolName::new(id)), - granted, - "{id}, {surface:?}, commands={commands}" - ); - } - let absent = tools::toolset::register_toolset( - &GatewayAgentApi::session_toolset_config(&config, false, false), - ) - .unwrap(); - assert!(!absent.tools.contains_key(&ToolName::new("env.read_file"))); - assert!(!absent.tools.contains_key(&ToolName::new("env.run_process"))); +fn environment_access_ladder_derives_the_union_tool_surface() { + fn config_for(attachments: serde_json::Value) -> engine::SessionConfig { + let features = serde_json::json!({ + "vfs":{"workspaces":[{"path":"/workspace","workspaceId":"ws_1","access":"edit"}]}, + "environments":{"skills":{},"prompts":{},"environments":attachments} + }); + engine_session_config_from_api( + serde_json::from_value(serde_json::json!({"features":features})).unwrap(), + openai_model(), + ) + .unwrap() + } + fn assert_surface(config: &engine::SessionConfig, access: Option) { + let registered = tools::toolset::register_toolset( + &GatewayAgentApi::session_toolset_config(config, true, false), + ) + .unwrap(); + let edit = access.is_some_and(|access| access.allows_edit()); + let exec = access.is_some_and(|access| access.allows_exec()); + for (id, granted) in [ + ("env.read_file", access.is_some()), + ("env.grep", access.is_some()), + ("env.glob", access.is_some()), + ("env.list_dir", access.is_some()), + ("env.write_file", edit), + ("env.edit_file", edit), + ("env.apply_patch", edit), + ("env.run_process", exec), + ("env.continue_process", exec), + ("vfs.materialize", edit), + ("vfs.capture", access.is_some()), + ] { + assert_eq!( + registered.tools.contains_key(&ToolName::new(id)), + granted, + "{id} under {access:?}" + ); } + let absent = tools::toolset::register_toolset(&GatewayAgentApi::session_toolset_config( + config, false, false, + )) + .unwrap(); + assert!(!absent.tools.contains_key(&ToolName::new("env.read_file"))); + assert!(!absent.tools.contains_key(&ToolName::new("env.run_process"))); } + + // No attachment: the feature is granted but installs no machine tools. + assert_surface(&config_for(serde_json::json!([])), None); + for (access, level) in [ + ("read", engine::EnvironmentAccess::Read), + ("edit", engine::EnvironmentAccess::Edit), + ("exec", engine::EnvironmentAccess::Exec), + ("jobs", engine::EnvironmentAccess::Jobs), + ] { + assert_surface( + &config_for(serde_json::json!([{"environmentId":"env_a","access":access}])), + Some(level), + ); + } + // Two attachments install the union; the narrower one is enforced at + // execution, not by hiding tools. + assert_surface( + &config_for(serde_json::json!([ + {"environmentId":"env_a","access":"read"}, + {"environmentId":"env_b","access":"exec"} + ])), + Some(engine::EnvironmentAccess::Exec), + ); } diff --git a/crates/temporal-server/src/gateway/service/vfs_api.rs b/crates/temporal-server/src/gateway/service/vfs_api.rs index 05c5e679..fe18c674 100644 --- a/crates/temporal-server/src/gateway/service/vfs_api.rs +++ b/crates/temporal-server/src/gateway/service/vfs_api.rs @@ -1,50 +1,13 @@ use super::*; impl GatewayAgentApi { - pub(super) async fn validate_workspace_link_targets( + pub(super) async fn validate_workspace_attachment_targets( &self, features: &engine::FeaturesConfig, ) -> Result<(), AgentApiError> { - let Some(vfs) = features.vfs.as_ref() else { - return Ok(()); - }; - if vfs.workspace_links.is_empty() { - return Ok(()); - } - let blobs: Arc = self.store.clone(); - let workspace_store: Arc = self.store.clone(); - let resolved = vfs::resolve_workspace_links(blobs, workspace_store, &vfs.workspace_links) + self.preparation_service() + .validate_workspace_attachment_targets(features) .await - .map_err(map_vfs_catalog_error)?; - if let Some(link) = resolved.iter().find(|link| !link.is_available()) { - return Err(AgentApiError::invalid_request(format!( - "workspace link target at {} is unavailable: {}", - link.path, - link.unavailable_reason().unwrap_or("unknown reason") - ))); - } - Ok(()) - } - - pub(super) async fn resolve_session_workspace_links( - &self, - state: &engine::CoreAgentState, - ) -> Result, AgentApiError> { - let declarations = state - .lifecycle - .config - .as_ref() - .and_then(|config| config.features.vfs.as_ref()) - .map(|vfs| vfs.workspace_links.as_slice()) - .unwrap_or_default(); - if declarations.is_empty() { - return Ok(Vec::new()); - } - let blobs: Arc = self.store.clone(); - let workspace_store: Arc = self.store.clone(); - vfs::resolve_workspace_links(blobs, workspace_store, declarations) - .await - .map_err(map_vfs_catalog_error) } pub(super) async fn create_vfs_workspace_record( diff --git a/crates/temporal-server/src/gateway/service/workflow.rs b/crates/temporal-server/src/gateway/service/workflow.rs index 8660c388..e2be4a9c 100644 --- a/crates/temporal-server/src/gateway/service/workflow.rs +++ b/crates/temporal-server/src/gateway/service/workflow.rs @@ -118,87 +118,9 @@ impl GatewayAgentApi { .map_err(map_blob_store_error) } - pub(super) async fn wait_for_open_session( - &self, - session_id: &SessionId, - ) -> Result { - let started = Instant::now(); - loop { - if started.elapsed() > self.operation_timeout { - return Err(AgentApiError::internal(format!( - "timed out waiting for agent session to open: {session_id}" - ))); - } - if let Some(status) = self.query_status_optional(session_id).await? - && let Some(error) = status.last_error - { - return Err(AgentApiError::internal(format!( - "agent workflow reported error: {error}" - ))); - } - match self.project_session_by_id(session_id).await { - Ok(session) if session.config.is_some() => return Ok(session), - Ok(_) => {} - Err(error) if is_not_found(&error) => {} - Err(error) => return Err(error), - } - tokio::time::sleep(self.poll_interval).await; - } - } - - pub(super) async fn wait_for_config_revision( - &self, - session_id: &SessionId, - target_revision: u64, - baseline_failures: usize, - ) -> Result { - let started = Instant::now(); - loop { - if started.elapsed() > self.operation_timeout { - return Err(AgentApiError::internal(format!( - "timed out waiting for agent session config update: {session_id}" - ))); - } - if let Some(status) = self.query_status_optional(session_id).await? { - if status.admission_failures.len() > baseline_failures - && let Some(failure) = status.admission_failures.last() - { - return Err(map_admission_failure_to_api_error(failure)); - } - if let Some(error) = status.last_error { - return Err(AgentApiError::internal(format!( - "agent workflow reported error: {error}" - ))); - } - } - let session = self.project_session_by_id(session_id).await?; - if session.config_revision >= target_revision { - return Ok(session); - } - tokio::time::sleep(self.poll_interval).await; - } - } - /// Waits for exact context entries to commit; any per-entry admission /// failure is escalated to a call-level typed error. Built on the same /// wait loop as `session/context/append`. - pub(super) async fn wait_for_context_entries_applied( - &self, - session_id: &SessionId, - expected: &[(ContextEntryKey, ContextEntryInput)], - correlations: &BTreeMap, - ) -> Result { - let (context_revision, outcomes) = self - .wait_for_context_append_outcomes(session_id, expected, correlations) - .await?; - for outcome in outcomes.values() { - if let ContextAppendWaitOutcome::Failed { failure } = outcome { - return Err(map_admission_failure_to_api_error(failure)); - } - } - Ok(context_revision) - } - pub(super) async fn wait_for_context_append_outcomes( &self, session_id: &SessionId, diff --git a/crates/temporal-server/src/lib.rs b/crates/temporal-server/src/lib.rs index 39e6bea5..8eb12d69 100644 --- a/crates/temporal-server/src/lib.rs +++ b/crates/temporal-server/src/lib.rs @@ -8,12 +8,7 @@ pub mod channels; pub(crate) mod checkpoint; pub mod config; pub(crate) mod credential_injection; -pub mod environment; -pub mod environment_gateway; -mod environment_prompts; -pub(crate) mod environment_resolver; -mod environment_skills; -mod environment_sources; +pub mod environments; pub mod gateway; pub mod roles; pub(crate) mod session_deletion; diff --git a/crates/temporal-server/src/session_deletion.rs b/crates/temporal-server/src/session_deletion.rs index b0ff0f3f..ca96853e 100644 --- a/crates/temporal-server/src/session_deletion.rs +++ b/crates/temporal-server/src/session_deletion.rs @@ -1,10 +1,6 @@ -use std::time::{SystemTime, UNIX_EPOCH}; - -use engine::{ - SessionId, - storage::{DeleteClosedSessions, DeleteClosedSessionsResult, SessionStore, SessionStoreError}, +use engine::storage::{ + DeleteClosedSessions, DeleteClosedSessionsResult, SessionStore, SessionStoreError, }; -use environments::{BeginCloseEnvironment, EnvironmentStatus, EnvironmentStore, ListEnvironments}; use store_pg::PgStore; #[derive(Clone, Copy, Debug)] @@ -22,9 +18,7 @@ impl SessionDeletionCause { } } -/// Delete a closed session subtree and eagerly close every owned -/// `closeWithSession` environment. Environment cleanup is best effort: the -/// lifecycle reconciler also observes missing origin sessions and converges. +/// Delete a closed session subtree. Environment lifecycles are independent. pub(crate) async fn delete_session_subtree( store: &PgStore, request: DeleteClosedSessions, @@ -33,9 +27,6 @@ pub(crate) async fn delete_session_subtree( let requested_session_id = request.session_id.clone(); let cascade = request.cascade; let deleted = SessionStore::delete_closed_sessions(store, request).await?; - for session_id in &deleted.deleted_session_ids { - close_session_owned_environments(store, session_id).await; - } tracing::info!( target: "temporal_server", requested_session_id = %requested_session_id, @@ -47,48 +38,3 @@ pub(crate) async fn delete_session_subtree( ); Ok(deleted) } - -async fn close_session_owned_environments(store: &PgStore, session_id: &SessionId) { - let Ok(environments) = EnvironmentStore::list_environments( - store, - ListEnvironments { - metadata: Default::default(), - origin_session_id: Some(session_id.clone()), - ..ListEnvironments::default() - }, - ) - .await - else { - return; - }; - for environment in environments { - let should_close = environment - .origin_session - .as_ref() - .is_some_and(|origin| origin.close_with_session) - && !matches!( - environment.status, - EnvironmentStatus::Closing | EnvironmentStatus::Closed - ); - if !should_close { - continue; - } - let _ = EnvironmentStore::begin_close_environment( - store, - BeginCloseEnvironment { - environment_id: environment.environment_id, - updated_at_ms: now_ms(), - }, - ) - .await; - } -} - -fn now_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .try_into() - .unwrap_or(i64::MAX) -} diff --git a/crates/temporal-server/src/subagents.rs b/crates/temporal-server/src/subagents.rs index 7892c921..8623564d 100644 --- a/crates/temporal-server/src/subagents.rs +++ b/crates/temporal-server/src/subagents.rs @@ -342,9 +342,10 @@ impl SubagentService { Err(error) => return Err(api_projection::map_session_store_error(error)), } // The profile is applied inline so the child runs the revision that - // was pinned on its origin, not whatever the registry holds later. - // A profile that cannot be applied (an `inherit` without a parent - // environment, a missing binding, ...) is a rejected delegation the + // was pinned on its origin, not whatever the registry holds later, + // with any `inherit` attachment resolved against the parent + // environment captured at admission. A profile that cannot be + // applied (a missing binding, ...) is a rejected delegation the // parent must see, not an activity retry: a retried start finds the // child workflow already running and would skip the profile. if let Err(error) = self @@ -355,7 +356,10 @@ impl SubagentService { profile: Box::new(InlineAgentProfile { display_name: profile.display_name.clone(), description: profile.description.clone(), - document: profile.document.clone(), + document: resolve_inherited_environment( + profile.document.clone(), + context.parent_active_environment_id.as_deref(), + ), }), }, ) @@ -651,6 +655,48 @@ fn is_already_closed(error: &AgentApiError) -> bool { matches!(error.kind, api::AgentApiErrorKind::Rejected) && error.to_string().contains("closed") } +/// Resolve an `inherit` attachment against the parent's active environment +/// captured at admission, so the child's stored configuration names a +/// concrete machine. An explicit attachment for the same machine wins and +/// the inherit attachment is dropped; without a parent environment it is +/// dropped as well, and a `default` on it activates nothing. +pub(crate) fn resolve_inherited_environment( + mut document: api::ProfileDocument, + parent_environment_id: Option<&str>, +) -> api::ProfileDocument { + let Some(environments) = document + .config + .as_mut() + .and_then(|config| config.features.as_mut()) + .and_then(|features| features.environments.as_mut()) + else { + return document; + }; + let Some(index) = environments + .environments + .iter() + .position(|attachment| attachment.inherit) + else { + return document; + }; + match parent_environment_id { + Some(parent) + if !environments + .environments + .iter() + .any(|attachment| attachment.environment_id.as_deref() == Some(parent)) => + { + let attachment = &mut environments.environments[index]; + attachment.inherit = false; + attachment.environment_id = Some(parent.to_owned()); + } + _ => { + environments.environments.remove(index); + } + } + document +} + #[cfg(test)] mod tests { use std::{ @@ -711,7 +757,6 @@ mod tests { retention: None, config: None, instructions: None, - environment: None, }, created_at_ms: 1, updated_at_ms: 1, @@ -852,6 +897,7 @@ mod tests { 3, admitted_agent.to_owned(), limits, + Some("environment-parent".to_owned()), )) .unwrap(), ) @@ -914,6 +960,93 @@ mod tests { } } + #[test] + fn inherit_attachment_resolves_against_the_captured_parent_environment() { + fn document(attachments: Vec) -> ProfileDocument { + ProfileDocument { + config: Some(api::SessionConfig { + features: Some(api::FeaturesConfig { + environments: Some(api::EnvironmentsFeature { + version: api::CURRENT_FEATURE_VERSION, + selection: false, + prompts: None, + skills: None, + environments: attachments, + }), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + } + } + fn attachment( + id: Option<&str>, + inherit: bool, + default: bool, + ) -> api::EnvironmentAttachment { + api::EnvironmentAttachment { + environment_id: id.map(str::to_owned), + inherit, + default, + access: api::EnvironmentAccess::Exec, + working_directory: None, + } + } + fn attachments(document: &ProfileDocument) -> &[api::EnvironmentAttachment] { + &document + .config + .as_ref() + .unwrap() + .features + .as_ref() + .unwrap() + .environments + .as_ref() + .unwrap() + .environments + } + + // Concrete: the inherit attachment becomes the parent's machine. + let resolved = resolve_inherited_environment( + document(vec![attachment(None, true, true)]), + Some("env_parent"), + ); + let resolved = attachments(&resolved); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].environment_id.as_deref(), Some("env_parent")); + assert!(!resolved[0].inherit); + assert!(resolved[0].default); + + // The explicit attachment for the same machine wins. + let explicit = resolve_inherited_environment( + document(vec![ + attachment(Some("env_parent"), false, false), + attachment(None, true, true), + ]), + Some("env_parent"), + ); + let explicit = attachments(&explicit); + assert_eq!(explicit.len(), 1); + assert_eq!(explicit[0].environment_id.as_deref(), Some("env_parent")); + assert!(!explicit[0].default); + + // No parent environment: the inherit attachment is dropped. + let dropped = resolve_inherited_environment( + document(vec![ + attachment(Some("env_other"), false, false), + attachment(None, true, true), + ]), + None, + ); + assert_eq!(attachments(&dropped).len(), 1); + assert!(attachments(&dropped)[0].environment_id.as_deref() == Some("env_other")); + + // Nothing to resolve leaves the document untouched. + let untouched = resolve_inherited_environment(ProfileDocument::default(), Some("x")); + assert_eq!(untouched, ProfileDocument::default()); + } + #[tokio::test(flavor = "current_thread")] async fn prepare_creates_the_child_with_its_origin_and_starts_its_run() { let runtime = FakeChildRuntime::with_profile("reviewer", 4); diff --git a/crates/temporal-server/src/universe.rs b/crates/temporal-server/src/universe.rs index fb397d11..fbd3cceb 100644 --- a/crates/temporal-server/src/universe.rs +++ b/crates/temporal-server/src/universe.rs @@ -34,7 +34,7 @@ use uuid::Uuid; use crate::{ config::{DeploymentStores, TaskQueues}, - environment_gateway::EnvironmentGatewayClientConfig, + environments::gateway::EnvironmentGatewayClientConfig, gateway::GatewayAgentApi, subagents::AgentApiSubagentRuntime, worker::{ActivityState, AudioTranscoder}, @@ -289,7 +289,12 @@ impl UniverseRuntime { continue; } }; - match state.api.reconcile_environment_lifecycle_once().await { + match state + .api + .environment_service() + .reconcile_environment_lifecycle_once() + .await + { Ok(_) => failures.succeeded(universe_id), Err(error) => failures.failed(universe_id, &error), } @@ -324,7 +329,12 @@ impl UniverseRuntime { continue; } }; - match state.api.reconcile_idle_power_once().await { + match state + .api + .environment_service() + .reconcile_idle_power_once() + .await + { Ok(_) => failures.succeeded(universe_id), Err(error) => failures.failed(universe_id, &error), } diff --git a/crates/temporal-server/src/worker/activities/runtime_projection.rs b/crates/temporal-server/src/worker/activities/context_refresh.rs similarity index 71% rename from crates/temporal-server/src/worker/activities/runtime_projection.rs rename to crates/temporal-server/src/worker/activities/context_refresh.rs index 3c270d4b..1d9528df 100644 --- a/crates/temporal-server/src/worker/activities/runtime_projection.rs +++ b/crates/temporal-server/src/worker/activities/context_refresh.rs @@ -11,20 +11,22 @@ use tools::subagents::{ SubagentCatalogAgent, SubagentCatalogSnapshot, prepare_subagent_catalog_publication, }; use tools::{ - environment::projection::{prepare_vfs_catalog_publication, vfs_catalog_from_workspace_links}, + environment::projection::{ + prepare_vfs_catalog_publication, vfs_catalog_from_workspace_attachments, + }, prompts::{ PromptAssemblyLimits, configured_vfs_prompt_root_specs, - prepare_prompt_instructions_publication_with_warnings, resolve_linked_vfs_prompt_roots, + prepare_prompt_instructions_publication_with_warnings, resolve_attached_vfs_prompt_roots, }, skills::{ configured_vfs_skill_root_specs, prepare_skill_catalog_publication_with_warnings, - resolve_linked_vfs_skill_roots, + resolve_attached_vfs_skill_roots, }, }; use super::{common::activity_error, state::RuntimeProjectionActivityDeps}; -pub(super) async fn refresh_runtime_projection( +pub(super) async fn refresh_context( deps: Option<&RuntimeProjectionActivityDeps>, request: RuntimeProjectionRefreshActivityRequest, ) -> Result { @@ -34,10 +36,10 @@ pub(super) async fn refresh_runtime_projection( }); }; - let links = vfs::resolve_workspace_links( + let attachments = vfs::resolve_workspace_attachments( deps.blobs.clone(), deps.workspace_store.clone(), - &request.workspace_links, + &request.workspace_attachments, ) .await .map_err(activity_error)?; @@ -50,8 +52,11 @@ pub(super) async fn refresh_runtime_projection( let current_subagents = request .active_catalogs .get(&ContextEntryKey::new(SUBAGENT_CATALOG_CONTEXT_KEY)); + let current_environments = request.active_catalogs.get(&ContextEntryKey::new( + tools::catalog::ENVIRONMENT_CATALOG_CONTEXT_KEY, + )); let mut commands = Vec::new(); - if let Some(command) = crate::environment_skills::refresh( + let environment_sources = crate::environments::sources::refresh( deps.blobs.as_ref(), deps.environment_resolver.as_ref(), deps.environment_gateway.as_ref(), @@ -63,13 +68,14 @@ pub(super) async fn refresh_runtime_projection( )), ) .await - .map_err(activity_error)? - { + .map_err(activity_error)?; + if let Some(command) = environment_sources.skill_command { commands.push(command); } if request.vfs_catalog_enabled { - let catalog = vfs_catalog_from_workspace_links(&links).map_err(activity_error)?; + let catalog = + vfs_catalog_from_workspace_attachments(&attachments).map_err(activity_error)?; if let Some(command) = prepare_vfs_catalog_publication( deps.blobs.as_ref(), deps.blob_graph.as_deref(), @@ -114,13 +120,47 @@ pub(super) async fn refresh_runtime_projection( } } + // Environment catalog: the attachment list with this session's access on + // each machine, joined with registry names and status. Built from the + // grant and records only; it never connects to or wakes a machine. + match request.environments.as_ref() { + Some(environments) => { + let snapshot = environment_catalog_snapshot( + deps.environment_resolver.as_ref(), + environments, + request.active_environment_id.as_ref(), + ) + .await; + if let Some(command) = + tools::environment::attachments::prepare_environment_catalog_publication( + deps.blobs.as_ref(), + current_environments, + &snapshot, + ) + .await + .map_err(activity_error)? + { + commands.push(command); + } + } + None => { + if let Some(command) = clear_catalog_command( + current_environments, + tools::catalog::ENVIRONMENT_CATALOG_CONTEXT_KEY, + ) { + commands.push(command); + } + } + } + let prompt_entries = if request.vfs_prompts_enabled { - let specs = configured_vfs_prompt_root_specs(&links, request.vfs_prompt_roots.as_deref()) - .map_err(activity_error)?; - let resolved = resolve_linked_vfs_prompt_roots( + let specs = + configured_vfs_prompt_root_specs(&attachments, request.vfs_prompt_roots.as_deref()) + .map_err(activity_error)?; + let resolved = resolve_attached_vfs_prompt_roots( deps.blobs.clone(), deps.workspace_store.clone(), - links.clone(), + attachments.clone(), specs, ) .await @@ -142,20 +182,11 @@ pub(super) async fn refresh_runtime_projection( } else { Default::default() }; - let environment_prompts = crate::environment_prompts::refresh( - deps.blobs.as_ref(), - deps.environment_resolver.as_ref(), - deps.environment_gateway.as_ref(), - request.environments.as_ref(), - request.active_environment_id.as_ref(), - ) - .await - .map_err(activity_error)?; let mut active_instructions = request.active_instruction_inputs.clone(); active_instructions.remove(&ContextEntryKey::new( tools::prompts::environment::ENVIRONMENT_PROMPT_CONTEXT_KEY, )); - active_instructions.extend(environment_prompts); + active_instructions.extend(environment_sources.prompt_entries); let desired_instructions = replace_prompt_instruction_source(active_instructions, prompt_entries, deps.blobs.as_ref()) .await @@ -179,7 +210,7 @@ pub(super) async fn refresh_runtime_projection( ), }); }; - let specs = configured_vfs_skill_root_specs(&links, skills_config.roots.as_deref()) + let specs = configured_vfs_skill_root_specs(&attachments, skills_config.roots.as_deref()) .map_err(activity_error)?; if specs.is_empty() { return Ok(RuntimeProjectionRefreshActivityResult { @@ -190,10 +221,10 @@ pub(super) async fn refresh_runtime_projection( }); } - let resolved = resolve_linked_vfs_skill_roots( + let resolved = resolve_attached_vfs_skill_roots( deps.blobs.clone(), deps.workspace_store.clone(), - links, + attachments, specs, ) .await @@ -280,6 +311,39 @@ fn append_optional( /// Join the grant's allowlist with the current profile records. A missing /// profile keeps its id in the menu with no revision, so the model learns /// it is unavailable instead of silently losing the option. +pub async fn environment_catalog_snapshot( + resolver: Option<&crate::environments::resolver::EnvironmentResolver>, + environments: &engine::EnvironmentsFeature, + active_environment_id: Option<&engine::EnvironmentId>, +) -> tools::environment::attachments::EnvironmentCatalogSnapshot { + use tools::environment::attachments::{EnvironmentCatalogRecord, EnvironmentCatalogSnapshot}; + let mut records: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + if let Some(resolver) = resolver { + for attachment in &environments.environments { + let Ok(environment_id) = + environments::EnvironmentId::try_new(attachment.environment_id.clone()) + else { + continue; + }; + if let Ok(record) = resolver.read(&environment_id).await { + records.insert( + attachment.environment_id.clone(), + EnvironmentCatalogRecord { + display_name: record.display_name.clone(), + status: Some(format!("{:?}", record.status).to_lowercase()), + }, + ); + } + } + } + EnvironmentCatalogSnapshot::new( + environments, + active_environment_id.map(|id| id.as_str()), + |id| records.get(id).cloned().unwrap_or_default(), + ) +} + pub async fn subagent_catalog_snapshot( profiles: Option<&dyn ::profiles::ProfileStore>, subagents: &engine::SubagentsFeature, diff --git a/crates/temporal-server/src/worker/activities/environment_jobs.rs b/crates/temporal-server/src/worker/activities/environment_jobs.rs index d113a37d..59114af1 100644 --- a/crates/temporal-server/src/worker/activities/environment_jobs.rs +++ b/crates/temporal-server/src/worker/activities/environment_jobs.rs @@ -112,21 +112,8 @@ pub(super) async fn prepare_workflow_tool( } let environment_id = EnvironmentId::try_new(execution_context.environment_id).map_err(activity_error)?; - let instance = deps - .environments - .read_environment(&environment_id) - .await - .map_err(activity_error)?; - let policy = environments::EnvironmentAccessPolicy::new( - execution_context.allowed_provider_ids, - execution_context.allowed_registration_key_ids, - ); - if !policy.allows(&instance) { - return Err(activity_error(anyhow::anyhow!( - "environment is not allowed for this session: {}", - policy.refusal(&instance) - ))); - } + // The executor admitted this call against the session's attachment + // grant; the context names the machine it admitted. let request_id = format!( "jobreq:{}:{}:{}:{}", start.invocation.run_id.as_u64(), @@ -545,7 +532,7 @@ pub(super) async fn cancel( async fn initialized_client( connection: &EnvironmentDataConnection, - gateway: &crate::environment_gateway::EnvironmentGatewayClientConfig, + gateway: &crate::environments::gateway::EnvironmentGatewayClientConfig, ) -> Result< ( EnvironmentDataClient, diff --git a/crates/temporal-server/src/worker/activities/mod.rs b/crates/temporal-server/src/worker/activities/mod.rs index 40f2271d..296f753d 100644 --- a/crates/temporal-server/src/worker/activities/mod.rs +++ b/crates/temporal-server/src/worker/activities/mod.rs @@ -35,11 +35,11 @@ use crate::worker::{ mod common; mod compaction; +mod context_refresh; mod environment_jobs; mod llm; mod preprocess; -mod runtime_projection; -pub use runtime_projection::subagent_catalog_snapshot; +pub use context_refresh::subagent_catalog_snapshot; mod state; mod storage; mod subagents; @@ -189,6 +189,14 @@ mod tests { #[test] fn activity_names_match_workflow_definitions() { + assert_eq!( + WorkerActivities::prepare_session_toolset.name(), + temporal_workflow::WorkflowActivities::prepare_session_toolset.name() + ); + assert_eq!( + WorkerActivities::prepare_session_profile.name(), + temporal_workflow::WorkflowActivities::prepare_session_profile.name() + ); assert_eq!( WorkerActivities::create_or_load_session.name(), temporal_workflow::WorkflowActivities::create_or_load_session.name() @@ -337,7 +345,7 @@ mod tests { active_environment_id: None, environment_policy: None, subagents_policy: None, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), calls: vec![ToolInvocationRequest { builtin: None, call_id: tool_call.call_id.clone(), @@ -591,6 +599,39 @@ impl WorkerActivities { tools::prepare_promise_controls(state.tools().blobs.as_ref(), request.request).await } + #[activity(name = "WorkflowActivities::prepare_session_toolset")] + pub async fn prepare_session_toolset( + self: Arc, + ctx: ActivityContext, + request: temporal_workflow::SessionToolsetRequest, + ) -> Result< + Result, + ActivityError, + > { + let state = self.state_for(&ctx).await?; + let service = preparation_service(&state, &ctx)?; + if request.validate_configuration + && let Err(error) = service.validate_configuration(&request.source.config).await + { + return preparation_activity_result(Err(error)); + } + preparation_activity_result(service.prepare_toolset(request.source).await) + } + + #[activity(name = "WorkflowActivities::prepare_session_profile")] + pub async fn prepare_session_profile( + self: Arc, + ctx: ActivityContext, + request: temporal_workflow::SessionProfilePreparationRequest, + ) -> Result< + Result, + ActivityError, + > { + let state = self.state_for(&ctx).await?; + let service = preparation_service(&state, &ctx)?; + preparation_activity_result(service.prepare_profile(request).await) + } + #[activity(name = ACTIVITY_RUNTIME_PROJECTION_REFRESH)] pub async fn runtime_projection_refresh( self: Arc, @@ -598,7 +639,7 @@ impl WorkerActivities { request: RuntimeProjectionRefreshActivityRequest, ) -> Result { let state = self.state_for(&ctx).await?; - runtime_projection::refresh_runtime_projection(state.runtime_projection(), request).await + context_refresh::refresh_context(state.runtime_projection(), request).await } #[activity(name = ACTIVITY_ENVIRONMENT_JOB_START)] @@ -713,3 +754,32 @@ impl WorkerActivities { subagents::close(state.subagents(), request).await } } + +fn preparation_activity_result( + result: Result, +) -> Result, ActivityError> { + match result { + Err(error) if error.kind == api::AgentApiErrorKind::Internal => { + Err(common::activity_error(error)) + } + other => Ok(other), + } +} + +fn preparation_service( + state: &ActivityState, + ctx: &ActivityContext, +) -> Result +{ + let store = state.preparation_store.clone().ok_or_else(|| { + common::activity_error(anyhow::anyhow!( + "session preparation store is not configured" + )) + })?; + Ok( + crate::gateway::service::session_preparation::SessionPreparationService { + store, + task_queue: ctx.info().task_queue.clone(), + }, + ) +} diff --git a/crates/temporal-server/src/worker/activities/state.rs b/crates/temporal-server/src/worker/activities/state.rs index 13b64ad5..f3c538bf 100644 --- a/crates/temporal-server/src/worker/activities/state.rs +++ b/crates/temporal-server/src/worker/activities/state.rs @@ -26,7 +26,7 @@ use crate::worker::mcp::{McpPrivateNetworkPolicy, NativeMcpInventoryResolver, Na use crate::{ config::pg_store_from_env, credential_injection::EnvironmentCredentialResolver, - environment_gateway::EnvironmentGatewayClientConfig, + environments::gateway::EnvironmentGatewayClientConfig, subagents::{SubagentChildRuntime, SubagentService}, worker::{BrokerSecretResolver, SessionTools, StoredProviderKeyResolver}, }; @@ -66,7 +66,7 @@ pub struct ToolActivityDeps { #[derive(Clone)] pub struct RuntimeProjectionActivityDeps { - pub(super) environment_resolver: Option, + pub(super) environment_resolver: Option, pub(super) environment_gateway: Option, pub(super) blobs: Arc, /// Records catalog-, report-, and projection-to-child edges. @@ -118,6 +118,7 @@ pub struct ActivityState { llm: LlmActivityDeps, tools: ToolActivityDeps, runtime_projection: Option, + pub(super) preparation_store: Option>, preprocess: PreprocessActivityDeps, environment_jobs: Option, workflow_tool_executions: Option, @@ -149,6 +150,7 @@ impl ActivityState { native_mcp: None, }, runtime_projection: None, + preparation_store: None, preprocess: PreprocessActivityDeps { blobs: blobs.clone(), transcriber: Arc::new(UnavailableAudioTranscriber), @@ -225,6 +227,7 @@ impl ActivityState { let workspace_store: Arc = store.clone(); let profile_store: Arc = store.clone(); let mut state = Self::new(sessions, blobs, llm, tools); + state.preparation_store = Some(store.clone()); state.storage.blob_graph = Some(blob_graph.clone()); state.tools.blob_graph = Some(blob_graph.clone()); let mut state = state @@ -232,7 +235,7 @@ impl ActivityState { .with_profile_store(profile_store); if let Some(projection) = state.runtime_projection.as_mut() { projection.environment_resolver = Some( - crate::environment_resolver::EnvironmentResolver::from_pg_store(store.clone()), + crate::environments::resolver::EnvironmentResolver::from_pg_store(store.clone()), ); } state.environment_jobs = Some(EnvironmentJobActivityDeps { diff --git a/crates/temporal-server/src/worker/activities/storage.rs b/crates/temporal-server/src/worker/activities/storage.rs index 33bed6d9..acbc954e 100644 --- a/crates/temporal-server/src/worker/activities/storage.rs +++ b/crates/temporal-server/src/worker/activities/storage.rs @@ -1275,6 +1275,106 @@ mod tests { assert_eq!(page.entries, first.entries); } + #[tokio::test(flavor = "current_thread")] + async fn preparation_batch_retry_after_lost_commit_response_preserves_all_changes_once() { + use engine::{ + ContextEntryInput, ContextEntryKey, ContextEntryKind, CoreAgentAction, + CoreAgentCommand, CoreAgentDrive, CoreAgentState, EnvironmentsFeature, EventSeq, + }; + let store = Arc::new(InMemorySessionStore::new()); + let deps = storage_deps(store.clone()); + let session_id = create_test_session(store.as_ref()).await; + let mut config = temporal_workflow::default_session_config(engine::ModelSelection { + api_kind: engine::ProviderApiKind::OpenAiResponses, + provider_id: "openai".into(), + model: "test-model".into(), + }); + let open = CoreAgentCommand::OpenSession { + config: config.clone(), + }; + config.features.environments = Some(EnvironmentsFeature { + environments: vec![engine::EnvironmentAttachment { + environment_id: "existing".to_owned(), + default: false, + access: engine::EnvironmentAccess::Read, + working_directory: None, + }], + ..EnvironmentsFeature::default() + }); + let mut staged = + CoreAgentDrive::from_replayed(session_id.clone(), CoreAgentState::new(), None); + let mut events = Vec::new(); + for command in [ + open, + CoreAgentCommand::ReplaceSessionConfig { + expected_revision: Some(0), + config, + }, + CoreAgentCommand::SetActiveEnvironment { + environment_id: engine::EnvironmentId::new("existing"), + }, + CoreAgentCommand::ReplaceContextPrefix { + expected_revision: None, + key_prefix: ContextEntryKey::new("instructions"), + entries: BTreeMap::from([( + ContextEntryKey::new("instructions.050.profile"), + ContextEntryInput { + kind: ContextEntryKind::Instructions, + content: engine::ContentRef::text(BlobRef::from_bytes(b"prepared profile")), + preview: None, + origin: None, + provenance_ref: None, + token_estimate: None, + }, + )]), + }, + ] { + let CoreAgentAction::AppendEvents { events: next, .. } = + staged.admit_command(command, 10).unwrap() + else { + panic!("fixture command must produce events"); + }; + let start = events.len() as u64; + let entries = next + .iter() + .enumerate() + .map(|(index, event)| StoredSessionEntry { + position: SessionPosition { + seq: EventSeq::new(start + index as u64 + 1), + }, + observed_at_ms: event.observed_at_ms, + joins: event.joins.clone(), + event: event.event.clone(), + }) + .collect(); + staged.resume_appended(entries).unwrap(); + events.extend(next); + } + let request = AppendEventsRequest { + session_id: session_id.clone(), + expected_head: None, + events, + }; + // The database committed, but the activity completion was lost. + let committed = store + .append(AppendSessionEvents { + session_id: session_id.clone(), + expected_head: None, + events: request.events.clone(), + }) + .await + .unwrap(); + let retry = append_events(&deps, request.clone()).await.unwrap(); + assert_eq!(retry, committed); + assert_eq!(append_events(&deps, request).await.unwrap(), committed); + let durable = read_all(store.as_ref(), &session_id).await; + assert_eq!(durable.entries, committed.entries); + let mut replay = CoreAgentDrive::from_replayed(session_id, CoreAgentState::new(), None); + replay.resume_appended(durable.entries).unwrap(); + assert_eq!(replay.state(), staged.state()); + assert_eq!(replay.state().lifecycle.config_revision, 1); + } + #[tokio::test(flavor = "current_thread")] async fn tool_emission_retry_and_restarted_reads_are_complete_across_pages() { use engine::{ diff --git a/crates/temporal-server/src/worker/activities/tools.rs b/crates/temporal-server/src/worker/activities/tools.rs index 524f0ccc..a8d2f42c 100644 --- a/crates/temporal-server/src/worker/activities/tools.rs +++ b/crates/temporal-server/src/worker/activities/tools.rs @@ -712,7 +712,7 @@ mod tests { run_id: RunId::new(1), turn_id: TurnId::new(1), batch_id: ToolBatchId::new(1), - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), active_environment_id: None, environment_policy: None, subagents_policy: None, diff --git a/crates/temporal-server/src/worker/bots.rs b/crates/temporal-server/src/worker/bots.rs index 2f6320fc..add6cca7 100644 --- a/crates/temporal-server/src/worker/bots.rs +++ b/crates/temporal-server/src/worker/bots.rs @@ -5,73 +5,28 @@ use std::sync::Arc; +use super::universes::WorkerUniverses; + use temporal_workflow::bots::*; -use temporalio_common::error::ApplicationFailure; use temporalio_macros::activities; use temporalio_sdk::activities::{ActivityContext, ActivityError}; -use crate::{ - gateway::GatewayAgentApi, - universe::{UniverseError, UniverseRuntime}, -}; - -enum BotWorkerUniverses { - /// One pre-built service for one universe (tests). - Fixed { - universe_id: uuid::Uuid, - api: Arc, - }, - /// Lazy per-universe resolution over the deployment runtime. - Runtime(Arc), -} +use crate::{gateway::GatewayAgentApi, universe::UniverseRuntime}; pub struct BotWorkerActivities { - universes: BotWorkerUniverses, + universes: WorkerUniverses, } impl BotWorkerActivities { pub fn for_universe(universe_id: uuid::Uuid, api: Arc) -> Self { Self { - universes: BotWorkerUniverses::Fixed { universe_id, api }, + universes: WorkerUniverses::Fixed { universe_id, api }, } } pub fn with_runtime(runtime: Arc) -> Self { Self { - universes: BotWorkerUniverses::Runtime(runtime), - } - } - - async fn api_for( - &self, - universe_id: uuid::Uuid, - ) -> Result, ActivityError> { - match &self.universes { - BotWorkerUniverses::Fixed { - universe_id: served, - api, - } => { - if *served != universe_id { - return Err(ActivityError::application( - ApplicationFailure::non_retryable(anyhow::anyhow!( - "worker serves universe {served} but activity requested {universe_id}" - )), - )); - } - Ok(api.clone()) - } - BotWorkerUniverses::Runtime(runtime) => runtime - .state_for(universe_id, false) - .await - .map(|state| state.api.clone()) - .map_err(|error| match error { - UniverseError::Unknown { .. } => ActivityError::application( - ApplicationFailure::non_retryable(anyhow::anyhow!("{error}")), - ), - UniverseError::Runtime(_) => ActivityError::application( - ApplicationFailure::new(anyhow::anyhow!("{error}")), - ), - }), + universes: WorkerUniverses::Runtime(runtime), } } } @@ -84,7 +39,7 @@ impl BotWorkerActivities { _ctx: ActivityContext, request: BotEnsureSessionRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::bots::sessions::ensure_session(&api, request).await } @@ -94,7 +49,7 @@ impl BotWorkerActivities { _ctx: ActivityContext, request: BotRenameSessionRequest, ) -> Result<(), ActivityError> { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::bots::sessions::rename_session(&api, request).await } @@ -104,7 +59,7 @@ impl BotWorkerActivities { _ctx: ActivityContext, request: BotSessionRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::bots::sessions::read_session_status(&api, request).await } @@ -114,7 +69,7 @@ impl BotWorkerActivities { _ctx: ActivityContext, request: BotReadRunUsageRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::bots::sessions::read_run_usage(&api, request).await } @@ -124,7 +79,7 @@ impl BotWorkerActivities { _ctx: ActivityContext, request: BotStartRunRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::bots::sessions::start_run(&api, request).await } @@ -134,7 +89,7 @@ impl BotWorkerActivities { _ctx: ActivityContext, request: BotSteerRunRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::bots::sessions::steer_run(&api, request).await } @@ -144,7 +99,7 @@ impl BotWorkerActivities { _ctx: ActivityContext, request: BotAppendContextRequest, ) -> Result<(), ActivityError> { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::bots::sessions::append_context(&api, request).await } @@ -154,7 +109,7 @@ impl BotWorkerActivities { _ctx: ActivityContext, request: BotCloseSessionRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::bots::sessions::close_session(&api, request).await } @@ -164,7 +119,7 @@ impl BotWorkerActivities { _ctx: ActivityContext, request: BotCountDescendantsRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::bots::sessions::count_descendants(&api, request).await } @@ -174,7 +129,7 @@ impl BotWorkerActivities { _ctx: ActivityContext, request: BotReadToolInvocationsRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::bots::sessions::read_tool_invocations(&api, request).await } @@ -184,7 +139,7 @@ impl BotWorkerActivities { _ctx: ActivityContext, request: BotReadJsonBlobRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::bots::sessions::read_json_blob(&api, request).await } @@ -194,7 +149,7 @@ impl BotWorkerActivities { _ctx: ActivityContext, request: BotExecuteToolRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::bots::tools::execute_tool(&api, request).await } @@ -204,7 +159,7 @@ impl BotWorkerActivities { _ctx: ActivityContext, request: BotRecordOutcomesRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::bots::receipts::record_outcomes(&api, request).await } @@ -214,7 +169,7 @@ impl BotWorkerActivities { _ctx: ActivityContext, request: BotRecordClosedRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::bots::receipts::record_closed(&api, request).await } @@ -224,7 +179,7 @@ impl BotWorkerActivities { _ctx: ActivityContext, request: BotSendDeliveryReceiptsRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::bots::receipts::send_delivery_receipts(&api, request).await } @@ -234,7 +189,7 @@ impl BotWorkerActivities { _ctx: ActivityContext, request: BotSendBotReceiptsRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::bots::receipts::send_bot_receipts(&api, request).await } @@ -244,7 +199,7 @@ impl BotWorkerActivities { _ctx: ActivityContext, request: BotPublishDirectoryRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::bots::receipts::publish_directory(&api, request).await } @@ -254,7 +209,7 @@ impl BotWorkerActivities { _ctx: ActivityContext, request: BotTriggerFireRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::bots::fires::admit_schedule_event(&api, request).await } @@ -264,7 +219,7 @@ impl BotWorkerActivities { _ctx: ActivityContext, request: BotTriggerFireRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::bots::fires::poll_trigger(&api, request).await } } diff --git a/crates/temporal-server/src/worker/channels.rs b/crates/temporal-server/src/worker/channels.rs index 8b856111..d7f581f4 100644 --- a/crates/temporal-server/src/worker/channels.rs +++ b/crates/temporal-server/src/worker/channels.rs @@ -3,71 +3,28 @@ use std::sync::Arc; +use super::universes::WorkerUniverses; + use temporal_workflow::channels::*; -use temporalio_common::error::ApplicationFailure; use temporalio_macros::activities; use temporalio_sdk::activities::{ActivityContext, ActivityError}; -use crate::{ - gateway::GatewayAgentApi, - universe::{UniverseError, UniverseRuntime}, -}; - -enum ChannelWorkerUniverses { - Fixed { - universe_id: uuid::Uuid, - api: Arc, - }, - Runtime(Arc), -} +use crate::{gateway::GatewayAgentApi, universe::UniverseRuntime}; pub struct ChannelWorkerActivities { - universes: ChannelWorkerUniverses, + universes: WorkerUniverses, } impl ChannelWorkerActivities { pub fn for_universe(universe_id: uuid::Uuid, api: Arc) -> Self { Self { - universes: ChannelWorkerUniverses::Fixed { universe_id, api }, + universes: WorkerUniverses::Fixed { universe_id, api }, } } pub fn with_runtime(runtime: Arc) -> Self { Self { - universes: ChannelWorkerUniverses::Runtime(runtime), - } - } - - async fn api_for( - &self, - universe_id: uuid::Uuid, - ) -> Result, ActivityError> { - match &self.universes { - ChannelWorkerUniverses::Fixed { - universe_id: served, - api, - } => { - if *served != universe_id { - return Err(ActivityError::application( - ApplicationFailure::non_retryable(anyhow::anyhow!( - "worker serves universe {served} but activity requested {universe_id}" - )), - )); - } - Ok(api.clone()) - } - ChannelWorkerUniverses::Runtime(runtime) => runtime - .state_for(universe_id, false) - .await - .map(|state| state.api.clone()) - .map_err(|error| match error { - UniverseError::Unknown { .. } => ActivityError::application( - ApplicationFailure::non_retryable(anyhow::anyhow!("{error}")), - ), - UniverseError::Runtime(_) => ActivityError::application( - ApplicationFailure::new(anyhow::anyhow!("{error}")), - ), - }), + universes: WorkerUniverses::Runtime(runtime), } } } @@ -80,7 +37,7 @@ impl ChannelWorkerActivities { _ctx: ActivityContext, request: ChatToolDeclarationsRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::channels::activities::chat_tool_declarations(&api, request).await } @@ -90,7 +47,7 @@ impl ChannelWorkerActivities { _ctx: ActivityContext, request: ChatReadJsonBlobRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::channels::activities::read_json_blob(&api, request).await } @@ -100,7 +57,7 @@ impl ChannelWorkerActivities { _ctx: ActivityContext, request: ChatPutJsonBlobRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::channels::activities::put_json_blob(&api, request).await } @@ -110,7 +67,7 @@ impl ChannelWorkerActivities { _ctx: ActivityContext, request: ChatReconcileDeliveryRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::channels::activities::reconcile_delivery(&api, request).await } @@ -120,7 +77,7 @@ impl ChannelWorkerActivities { _ctx: ActivityContext, request: ChatEmitEventRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::channels::activities::emit_chat_event(&api, request).await } @@ -130,7 +87,7 @@ impl ChannelWorkerActivities { _ctx: ActivityContext, request: ChatStoreSentRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::channels::activities::store_chat_sent(&api, request).await } @@ -140,7 +97,7 @@ impl ChannelWorkerActivities { _ctx: ActivityContext, request: ChatResolveHandleRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::channels::activities::resolve_chat_handle(&api, request).await } @@ -150,7 +107,7 @@ impl ChannelWorkerActivities { _ctx: ActivityContext, request: ChatAssertTriggerActiveRequest, ) -> Result { - let api = self.api_for(request.universe_id).await?; + let api = self.universes.api_for(request.universe_id).await?; crate::channels::activities::assert_trigger_active(&api, request).await } } diff --git a/crates/temporal-server/src/worker/mcp.rs b/crates/temporal-server/src/worker/mcp.rs index 47d8dce7..e4346f11 100644 --- a/crates/temporal-server/src/worker/mcp.rs +++ b/crates/temporal-server/src/worker/mcp.rs @@ -964,10 +964,6 @@ fn visible_mcp_result( fallback: &serde_json::Value, assets: &[NativeMcpAsset], ) -> (String, Vec) { - use engine::media::{ - MediaRejection, admit_tool_media, tool_media_line, tool_media_omitted_line, - }; - let mut lines = Vec::new(); let mut media = Vec::new(); let mut next_asset = 0usize; @@ -1003,49 +999,14 @@ fn visible_mcp_result( continue; }; let index = next_index(if label == "image" { "image" } else { "audio" }); - let admission = if label == "audio" { - Err(MediaRejection::UnsupportedMediaType { - media_type: asset - .media_type - .clone() - .unwrap_or_else(|| "audio".to_owned()), - }) - } else { - admit_tool_media(asset.media_type.as_deref(), asset.bytes.len() as u64) - }; - let admission = admission.and_then(|kind| { - if media.len() >= engine::media::MAX_TOOL_MEDIA_ITEMS { - Err(MediaRejection::TooMany { - limit: engine::media::MAX_TOOL_MEDIA_ITEMS, - }) - } else { - Ok(kind) - } - }); - match admission { - Ok(kind) => { - let media_type = engine::media::normalized_media_type( - asset.media_type.as_deref().unwrap_or_default(), - ); - lines.push(tool_media_line( - kind, - index, - &asset.blob_ref, - &media_type, - None, - asset.bytes.len() as u64, - )); - media.push(NativeMcpMedia { - asset_index, - kind, - media_type, - name: None, - }); - } - Err(rejection) => { - lines.push(tool_media_omitted_line(label, index, &rejection)); - } - } + lines.push(render_mcp_asset( + asset, + asset_index, + label, + index, + None, + &mut media, + )); } Some("resource") => { let Some(resource) = block.get("resource") else { @@ -1071,41 +1032,14 @@ fn visible_mcp_result( }; let name = uri.map(resource_name); let index = next_index("document"); - let admission = - admit_tool_media(asset.media_type.as_deref(), asset.bytes.len() as u64) - .and_then(|kind| { - if media.len() >= engine::media::MAX_TOOL_MEDIA_ITEMS { - Err(MediaRejection::TooMany { - limit: engine::media::MAX_TOOL_MEDIA_ITEMS, - }) - } else { - Ok(kind) - } - }); - match admission { - Ok(kind) => { - let media_type = engine::media::normalized_media_type( - asset.media_type.as_deref().unwrap_or_default(), - ); - lines.push(tool_media_line( - kind, - index, - &asset.blob_ref, - &media_type, - name.as_deref(), - asset.bytes.len() as u64, - )); - media.push(NativeMcpMedia { - asset_index, - kind, - media_type, - name, - }); - } - Err(rejection) => { - lines.push(tool_media_omitted_line("document", index, &rejection)); - } - } + lines.push(render_mcp_asset( + asset, + asset_index, + "document", + index, + name, + &mut media, + )); } Some("resource_link") => { let uri = block @@ -1131,6 +1065,63 @@ fn visible_mcp_result( (visible, media) } +/// Admit and render one binary block against the shared result-wide media cap. +fn render_mcp_asset( + asset: &NativeMcpAsset, + asset_index: usize, + label: &str, + index: usize, + name: Option, + media: &mut Vec, +) -> String { + use engine::media::{ + MediaRejection, admit_tool_media, tool_media_line, tool_media_omitted_line, + }; + + let admission = if label == "audio" { + Err(MediaRejection::UnsupportedMediaType { + media_type: asset + .media_type + .clone() + .unwrap_or_else(|| "audio".to_owned()), + }) + } else { + admit_tool_media(asset.media_type.as_deref(), asset.bytes.len() as u64) + }; + let admission = admission.and_then(|kind| { + if media.len() >= engine::media::MAX_TOOL_MEDIA_ITEMS { + Err(MediaRejection::TooMany { + limit: engine::media::MAX_TOOL_MEDIA_ITEMS, + }) + } else { + Ok(kind) + } + }); + match admission { + Ok(kind) => { + let media_type = engine::media::normalized_media_type( + asset.media_type.as_deref().unwrap_or_default(), + ); + let line = tool_media_line( + kind, + index, + &asset.blob_ref, + &media_type, + name.as_deref(), + asset.bytes.len() as u64, + ); + media.push(NativeMcpMedia { + asset_index, + kind, + media_type, + name, + }); + line + } + Err(rejection) => tool_media_omitted_line(label, index, &rejection), + } +} + /// The last path segment of a resource URI, used as a document name. fn resource_name(uri: &str) -> String { let trimmed = uri.trim_end_matches('/'); @@ -1634,6 +1625,90 @@ mod tests { assert_eq!(lines.len(), 12); } + #[test] + fn mixed_media_shares_the_cap_and_preserves_asset_indices_and_rejection_precedence() { + let mut content = vec![ + serde_json::json!({"type": "image", "mimeType": "image/svg+xml", "data": b64(b"svg")}), + // Audio blocks remain unsupported even if they claim an image MIME. + serde_json::json!({"type": "audio", "mimeType": "image/png", "data": b64(b"audio")}), + ]; + let mut expected_media = Vec::new(); + for index in 0..engine::media::MAX_TOOL_MEDIA_ITEMS { + let (kind, media_type, name) = if index % 2 == 0 { + content.push(serde_json::json!({ + "type": "image", "mimeType": "Image/PNG; charset=binary", "data": b64(b"png") + })); + (engine::media::MediaKind::Image, "image/png", None) + } else { + let name = format!("report-{index}.pdf"); + content.push(serde_json::json!({"type": "resource", "resource": { + "uri": format!("docs://reports/{name}"), + "mimeType": "Application/PDF", "blob": b64(b"pdf") + }})); + ( + engine::media::MediaKind::Document, + "application/pdf", + Some(name), + ) + }; + expected_media.push(NativeMcpMedia { + asset_index: index + 2, + kind, + media_type: media_type.to_owned(), + name, + }); + } + content.extend([ + serde_json::json!({"type": "text", "text": "between blocks"}), + serde_json::json!({"type": "image", "mimeType": "image/png", "data": b64(b"excess")}), + serde_json::json!({"type": "resource", "resource": { + "uri": "docs://excess.pdf", "mimeType": "application/pdf", "blob": b64(b"excess") + }}), + serde_json::json!({"type": "resource", "resource": { + "mimeType": "image/svg+xml", "blob": b64(b"svg") + }}), + ]); + let (visible, media, assets) = native_result(serde_json::Value::Array(content)); + assert_eq!(media, expected_media); + assert_eq!(assets.len(), engine::media::MAX_TOOL_MEDIA_ITEMS + 5); + let lines: Vec<_> = visible.lines().collect(); + assert_eq!( + lines[0], + "[image 1 omitted: image/svg+xml is not supported]" + ); + assert_eq!(lines[1], "[audio 1 omitted: image/png is not supported]"); + for (index, item) in media.iter().enumerate() { + let position = if index % 2 == 0 { + index / 2 + 2 + } else { + index / 2 + 1 + }; + let label = if index % 2 == 0 { "image" } else { "document" }; + let handle = engine::media::media_handle(&assets[item.asset_index].blob_ref); + let name = item + .name + .as_ref() + .map(|name| format!(" · {name}")) + .unwrap_or_default(); + assert_eq!( + lines[index + 2], + format!( + "[{label} {position} · {handle} · {}{name} · 3 B]", + item.media_type + ) + ); + } + assert_eq!( + &lines[engine::media::MAX_TOOL_MEDIA_ITEMS + 2..], + &[ + "between blocks", + "[image 6 omitted: at most 8 media items per result]", + "[document 5 omitted: at most 8 media items per result]", + "[document 6 omitted: image/svg+xml is not supported]", + ] + ); + } + #[test] fn media_entries_are_built_from_admitted_assets_in_order() { let (_, media, assets) = native_result(serde_json::json!([ diff --git a/crates/temporal-server/src/worker/mod.rs b/crates/temporal-server/src/worker/mod.rs index 4d6ab79f..bfebe1f1 100644 --- a/crates/temporal-server/src/worker/mod.rs +++ b/crates/temporal-server/src/worker/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod mcp; mod reaper; mod secrets; mod session_tools; +mod universes; use temporalio_client::Client; use temporalio_common::{telemetry::TelemetryOptions, worker::WorkerTaskTypes}; diff --git a/crates/temporal-server/src/worker/secrets.rs b/crates/temporal-server/src/worker/secrets.rs index eb3c936a..38f26f41 100644 --- a/crates/temporal-server/src/worker/secrets.rs +++ b/crates/temporal-server/src/worker/secrets.rs @@ -348,8 +348,8 @@ mod tests { allowed_tools: None, execution: mcp::McpExecution::Provider, exposure: mcp::McpExposure::Inject, - approval_default: McpApprovalPolicy::Never, - defer_loading_default: None, + approval: McpApprovalPolicy::Never, + defer_loading: None, allow_private_network: false, auth_policy: McpServerAuthPolicy::RequiredBearer, auth_grant_id: Some(AuthGrantId::new("authgrant_1")), @@ -511,8 +511,8 @@ mod tests { allowed_tools: current.allowed_tools, execution: current.execution, exposure: current.exposure, - approval_default: current.approval_default, - defer_loading_default: current.defer_loading_default, + approval: current.approval, + defer_loading: current.defer_loading, allow_private_network: current.allow_private_network, auth_policy: current.auth_policy, auth_grant_id: Some(AuthGrantId::new("authgrant_2")), diff --git a/crates/temporal-server/src/worker/session_tools.rs b/crates/temporal-server/src/worker/session_tools.rs index 2dbf27a9..204d0291 100644 --- a/crates/temporal-server/src/worker/session_tools.rs +++ b/crates/temporal-server/src/worker/session_tools.rs @@ -16,10 +16,7 @@ use environment_protocol::{ }, shared::{CURRENT_PROTOCOL_VERSION, EnvironmentDataConnection, EnvironmentTransport}, }; -use environments::{ - EnvironmentAccessPolicy, EnvironmentId, EnvironmentRecord, EnvironmentRegistryError, - EnvironmentStore, -}; +use environments::{EnvironmentId, EnvironmentRecord, EnvironmentRegistryError, EnvironmentStore}; use store_pg::PgStore; use tools::{ builtin::BuiltinToolRequirements, @@ -29,9 +26,8 @@ use tools::{ detach_promises_model_visible_text, is_concurrency_tool, sleep_model_visible_text, }, environment::control::{ - DEFAULT_ENVIRONMENT_LIST_LIMIT, EnvironmentActivateArgs, EnvironmentDeactivateArgs, - EnvironmentListArgs, EnvironmentReadArgs, MAX_ENVIRONMENT_LIST_LIMIT, - is_environment_control_tool, is_environment_selection_tool, + EnvironmentActivateArgs, EnvironmentDeactivateArgs, EnvironmentListArgs, + EnvironmentReadArgs, is_environment_control_tool, is_environment_selection_tool, }, environment::jobs::{ JOB_RUN_WORKFLOW_SEMANTIC_TYPE, JOB_RUN_WORKFLOW_TOOL_ID, @@ -40,18 +36,20 @@ use tools::{ NormalizeJobResultInput, normalize_job_result, }, environment_protocol::RemoteEnvironmentConnection, - fs::{FsPath, FsToolContext, LinkedVfsFileSystem}, + fs::{AttachedVfsFileSystem, FsPath, FsToolContext}, limits::ToolLimits, runtime::InlineToolRuntime, runtime::ToolCatalog, subagents::{AgentCallArgs, SubagentExecutionContextV1, SubagentToolKind}, workflow_tool::invoke_workflow_tool, }; -use vfs::{ResolvedWorkspaceLink, VfsCatalogError, VfsWorkspaceStore}; +use vfs::{ResolvedWorkspaceAttachment, VfsCatalogError, VfsWorkspaceStore}; use crate::{ credential_injection::EnvironmentCredentialResolver, - environment::{ActiveEnvironmentBlocker, RuntimeEnvironment, SessionEnvironmentManager}, + environments::runtime::{ + ActiveEnvironmentBlocker, RuntimeEnvironment, SessionEnvironmentManager, + }, subagents::await_spec_from_args, }; @@ -62,10 +60,9 @@ pub struct SessionTools { workspace_store: Arc, environments: SessionEnvironmentManager, environment_store: Option>, - registration_keys: Option>, - environment_resolver: Option, + environment_resolver: Option, environment_credentials: Option, - environment_gateway: Option, + environment_gateway: Option, } impl SessionTools { @@ -77,7 +74,6 @@ impl SessionTools { workspace_store, environments, environment_store: None, - registration_keys: None, environment_resolver: None, environment_credentials: None, environment_gateway: None, @@ -89,19 +85,9 @@ impl SessionTools { self } - /// Registration keys name the groups registered environments belong to; - /// the model sees the group name, never the key. - pub fn with_registration_key_store( - mut self, - registration_keys: Arc, - ) -> Self { - self.registration_keys = Some(registration_keys); - self - } - pub(crate) fn with_environment_resolver( mut self, - resolver: crate::environment_resolver::EnvironmentResolver, + resolver: crate::environments::resolver::EnvironmentResolver, ) -> Self { self.environment_resolver = Some(resolver); self @@ -118,7 +104,7 @@ impl SessionTools { /// Route environment calls through this deployment's gateway. pub fn with_environment_gateway( mut self, - gateway: crate::environment_gateway::EnvironmentGatewayClientConfig, + gateway: crate::environments::gateway::EnvironmentGatewayClientConfig, ) -> Self { if let Some(resolver) = self.environment_resolver.take() { self.environment_resolver = Some(resolver.with_gateway(gateway.clone())); @@ -137,15 +123,12 @@ impl SessionTools { let blob_graph: Arc = store.clone(); let workspace_store: Arc = store.clone(); let environments: Arc = store.clone(); - let registration_keys: Arc = - store.clone(); let credentials = EnvironmentCredentialResolver::from_pg_store(store.clone()); let resolver = - crate::environment_resolver::EnvironmentResolver::from_pg_store(store.clone()); + crate::environments::resolver::EnvironmentResolver::from_pg_store(store.clone()); Self::new(blobs, workspace_store) .with_blob_graph(blob_graph) .with_environment_store(environments) - .with_registration_key_store(registration_keys) .with_environment_resolver(resolver) .with_environment_credentials(credentials) } @@ -419,6 +402,7 @@ impl SessionTools { .read_environment_jobs( &request.session_id, request.active_environment_id.as_ref(), + request.environment_policy.as_ref(), environments, args.jobs, args.output_bytes, @@ -444,6 +428,7 @@ impl SessionTools { &self, session_id: &SessionId, active_environment_id: Option<&EnvironmentId>, + policy: Option<&engine::EnvironmentsFeature>, environments: &SessionEnvironmentManager, handles: Vec, output_bytes: Option, @@ -469,6 +454,15 @@ impl SessionTools { continue; } }; + // A handle may name a machine other than the active one (a job + // started before a switch), but only an attached one. + if !policy.is_some_and(|policy| policy.is_attached(environment_id.as_str())) { + entries.push(model_job_error( + Some(resolved), + format!("environment {environment_id} is not attached to this session"), + )); + continue; + } let (environment, close_after_read) = if let Some(environment) = environments.environment(environment_id.as_str()).cloned() { @@ -603,18 +597,34 @@ impl SessionTools { ) .await; }; + // Durable job tools are installed for the union of attachment + // grants; the active attachment decides whether this call may + // start work, before any workflow invocation is emitted. let policy = supplied_environment_policy(request)?; - let mut context = JobSubmitExecutionContextV1::new( + let Some(attachment) = policy.attachment(environment_id.as_str()) else { + return failed_result( + self.blobs.as_ref(), + call.call_id.clone(), + format!("active environment {environment_id} is not attached to this session"), + ) + .await; + }; + if !attachment.access.allows_jobs() { + return failed_result( + self.blobs.as_ref(), + call.call_id.clone(), + format!( + "{} requires jobs access on the active environment {environment_id}, which grants {}", + binding.definition.tool.name, + attachment.access.describe() + ), + ) + .await; + } + let context = JobSubmitExecutionContextV1::new( environment_id.as_str().to_owned(), - policy.providers.map(|ids| ids.into_iter().collect()), - policy - .registration_keys - .map(|ids| ids.into_iter().collect()), + attachment.working_directory.clone(), ); - context.working_directory = request - .environment_policy - .as_ref() - .and_then(|policy| policy.working_directory.clone()); Some( self.blobs .put_bytes(serde_json::to_vec(&context).map_err(io_error)?) @@ -679,6 +689,10 @@ impl SessionTools { request.run_id.as_u64(), args.agent, policy.limits, + request + .active_environment_id + .as_ref() + .map(|id| id.as_str().to_owned()), ); Some( self.blobs @@ -847,48 +861,42 @@ impl SessionTools { ) .await; }; - let allowed = supplied_environment_policy(request)?; + let policy = supplied_environment_policy(request)?; let active = request.active_environment_id.as_ref(); match call.tool_id.as_ref().map(|id| id.as_str()) { Some("environment.list") => { - let args: EnvironmentListArgs = self.read_tool_args(call).await?; - let limit = args - .limit - .unwrap_or(DEFAULT_ENVIRONMENT_LIST_LIMIT) - .clamp(1, MAX_ENVIRONMENT_LIST_LIMIT); - let mut environments = match resolver.list_allowed(&allowed).await { - Ok(environments) => environments, - Err(error) => { - return failed_result( - self.blobs.as_ref(), - call.call_id.clone(), - error.to_string(), - ) - .await; - } - }; - let groups = self.environment_groups(&environments).await; - if let Some(group) = args.group.as_deref() { - environments.retain(|environment| { - group_of(environment, &groups) - .is_some_and(|name| name.eq_ignore_ascii_case(group)) - }); - } - if let Some(cursor) = args.cursor.as_deref() { - environments.retain(|environment| environment.environment_id.as_str() > cursor); + let _: EnvironmentListArgs = self.read_tool_args(call).await?; + let mut environments = Vec::with_capacity(policy.environments.len()); + for attachment in &policy.environments { + let environment_id = + match EnvironmentId::try_new(attachment.environment_id.clone()) { + Ok(id) => id, + Err(error) => { + return failed_result( + self.blobs.as_ref(), + call.call_id.clone(), + error.to_string(), + ) + .await; + } + }; + let record = match resolver.read(&environment_id).await { + Ok(record) => Some(record), + Err(crate::environments::resolver::EnvironmentResolveError::Store( + EnvironmentRegistryError::NotFound { .. }, + )) => None, + Err(error) => { + return failed_result( + self.blobs.as_ref(), + call.call_id.clone(), + error.to_string(), + ) + .await; + } + }; + environments.push(environment_model_view(attachment, record.as_ref(), active)); } - let has_more = environments.len() > limit; - environments.truncate(limit); - let next_cursor = has_more - .then(|| environments.last()) - .flatten() - .map(|environment| environment.environment_id.as_str().to_owned()); - let output = serde_json::json!({ - "environments": environments.iter().map(|environment| { - environment_model_view(environment, active, group_of(environment, &groups)) - }).collect::>(), - "next_cursor": next_cursor, - }); + let output = serde_json::json!({ "environments": environments }); self.succeeded_tool_result( call, &output, @@ -914,7 +922,15 @@ impl SessionTools { .await; } }; - let environment = match resolver.read_allowed(&environment_id, &allowed).await { + let Some(attachment) = policy.attachment(environment_id.as_str()) else { + return failed_result( + self.blobs.as_ref(), + call.call_id.clone(), + unattached_message(&environment_id, policy), + ) + .await; + }; + let environment = match resolver.read(&environment_id).await { Ok(environment) => environment, Err(error) => { return failed_result( @@ -925,12 +941,8 @@ impl SessionTools { .await; } }; - let groups = self - .environment_groups(std::slice::from_ref(&environment)) - .await; - let mut output = - environment_model_view(&environment, active, group_of(&environment, &groups)); - if crate::environment_resolver::wake_on_use_applies(&environment) { + let mut output = environment_model_view(attachment, Some(&environment), active); + if crate::environments::resolver::wake_on_use_applies(&environment) { output["status_message"] = serde_json::json!(format!( "Environment is {}. Tools that use this environment will automatically wake it and wait until it is ready. You can proceed normally.", format!("{:?}", environment.status).to_lowercase(), @@ -956,14 +968,15 @@ impl SessionTools { .await; } }; - let (environment, ready) = match resolver - .activatable( - &environment_id, - &allowed, - i64::try_from(now_unix_ms()?).map_err(io_error)?, + let Some(attachment) = policy.attachment(environment_id.as_str()) else { + return failed_result( + self.blobs.as_ref(), + call.call_id.clone(), + unattached_message(&environment_id, policy), ) - .await - { + .await; + }; + let environment = match resolver.selectable(&environment_id).await { Ok(environment) => environment, Err(error) => { return failed_result( @@ -974,18 +987,26 @@ impl SessionTools { .await; } }; + let ready = environment.status == environments::EnvironmentStatus::Ready; let output = serde_json::json!({ "environment_id": environment.environment_id.as_str(), "active": true, "ready": ready, "status": format!("{:?}", environment.status).to_lowercase(), + "access": attachment.access.describe(), + "working_directory": attachment.working_directory, }); let summary = if ready { - format!("Active environment set to {}.", environment.environment_id) + format!( + "Active environment set to {} (access: {}).", + environment.environment_id, + attachment.access.describe() + ) } else { format!( - "Active environment set to {} (still {}; environment tools wait until it is ready).", + "Active environment set to {} (access: {}; currently {}; availability is checked when an environment tool uses it).", environment.environment_id, + attachment.access.describe(), format!("{:?}", environment.status).to_lowercase() ) }; @@ -1023,13 +1044,19 @@ impl SessionTools { let Some(environment_id) = request.active_environment_id.as_ref() else { return Ok(environments); }; - let allowed = supplied_environment_policy(request)?; + let policy = supplied_environment_policy(request)?; + let Some(attachment) = policy.attachment(environment_id.as_str()) else { + return Ok( + environments.with_active_blocker(ActiveEnvironmentBlocker::Unavailable { + message: format!( + "active environment {environment_id} is not attached to this session" + ), + }), + ); + }; + let working_directory = attachment.working_directory.as_deref(); if let Some(environment) = environments.environment(environment_id.as_str()).cloned() { - if let Some(cwd) = request - .environment_policy - .as_ref() - .and_then(|policy| policy.working_directory.as_deref()) - { + if let Some(cwd) = working_directory { let environment = environment .with_working_directory(FsPath::new(cwd).map_err(io_error)?) .await @@ -1042,17 +1069,16 @@ impl SessionTools { match resolver .resolve_for_connection( environment_id, - &allowed, i64::try_from(now_unix_ms()?) .map_err(|_| io_error("current timestamp does not fit in i64"))?, ) .await { Ok(resource) => resource, - Err(crate::environment_resolver::EnvironmentResolveError::Store( + Err(crate::environments::resolver::EnvironmentResolveError::Store( environments::EnvironmentRegistryError::Store { message }, )) => return Err(io_error(message)), - Err(crate::environment_resolver::EnvironmentResolveError::NotReady { + Err(crate::environments::resolver::EnvironmentResolveError::NotReady { environment_id, status, }) => { @@ -1076,27 +1102,16 @@ impl SessionTools { .environment_store .as_ref() .ok_or_else(|| io_error("environment store is not configured on this runtime"))?; - let resource = match store.read_environment(environment_id).await { + match store.read_environment(environment_id).await { Ok(resource) => resource, Err(environments::EnvironmentRegistryError::Store { message }) => { return Err(io_error(message)); } Err(_) => return Ok(environments), - }; - if !allowed.allows(&resource) { - return Ok(environments); } - resource }; let environment = match self - .runtime_environment_for_resource( - &request.session_id, - resource, - request - .environment_policy - .as_ref() - .and_then(|policy| policy.working_directory.as_deref()), - ) + .runtime_environment_for_resource(&request.session_id, resource, working_directory) .await { Ok(environment) => environment, @@ -1112,31 +1127,6 @@ impl SessionTools { Ok(environments) } - /// Registration-key display names for the registered environments in a - /// listing, keyed by key id. A key that fails to load simply yields no - /// group; the listing itself is never blocked by it. - async fn environment_groups( - &self, - environments: &[EnvironmentRecord], - ) -> BTreeMap { - let mut groups = BTreeMap::new(); - let Some(registration_keys) = self.registration_keys.as_ref() else { - return groups; - }; - for key_id in environments - .iter() - .filter_map(|environment| environment.registration_key_id()) - { - if groups.contains_key(key_id.as_str()) { - continue; - } - if let Ok(key) = registration_keys.read_registration_key(key_id).await { - groups.insert(key_id.to_string(), key.display_name); - } - } - groups - } - async fn runtime_environment_for_resource( &self, session_id: &SessionId, @@ -1179,7 +1169,7 @@ impl SessionTools { .await .map_err(map_environment_client_error)?; let cwd = if response.capabilities.filesystem_read { - match crate::environment_sources::working_directory( + match crate::environments::sources::working_directory( &mut client, working_directory, response.default_cwd.as_deref(), @@ -1223,18 +1213,18 @@ impl SessionTools { async fn runtime_for_domains( &self, - links: Vec, + attachments: Vec, environments: &SessionEnvironmentManager, active_environment_id: Option<&EnvironmentId>, vfs_working_directory: Option<&str>, ) -> Result { - let vfs = if links.is_empty() { + let vfs = if attachments.is_empty() { None } else { - let fs = LinkedVfsFileSystem::new( + let fs = AttachedVfsFileSystem::new( self.blobs.clone(), self.workspace_store.clone(), - links.clone(), + attachments.clone(), ) .map_err(io_error)? .with_blob_graph(self.blob_graph.clone()); @@ -1265,32 +1255,45 @@ struct EnvironmentJobRead { entries: Vec, } +/// One attached environment as the model sees it: the attachment's grant +/// joined with the registry record, which may be missing. fn environment_model_view( - environment: &EnvironmentRecord, + attachment: &engine::EnvironmentAttachment, + environment: Option<&EnvironmentRecord>, active: Option<&EnvironmentId>, - group: Option<&str>, ) -> serde_json::Value { serde_json::json!({ - "environment_id": environment.environment_id.as_str(), - "provider_id": environment.provider_id().map(|id| id.as_str()), - "group": group, - "display_name": environment.display_name, - "status": format!("{:?}", environment.status).to_lowercase(), - "active": active == Some(&environment.environment_id), - "observed_at_ms": environment.observed_at_ms(), + "environment_id": attachment.environment_id, + "provider_id": environment.and_then(|environment| environment.provider_id().map(|id| id.as_str())), + "display_name": environment.and_then(|environment| environment.display_name.clone()), + "status": environment.map(|environment| format!("{:?}", environment.status).to_lowercase()), + "access": attachment.access.describe(), + "default": attachment.default, + "working_directory": attachment.working_directory, + "active": active.is_some_and(|active| active.as_str() == attachment.environment_id), + "observed_at_ms": environment.map(|environment| environment.observed_at_ms()), }) } -/// The group name of a registered environment: its registration key's -/// display name, when the key resolved. -fn group_of<'a>( - environment: &EnvironmentRecord, - groups: &'a BTreeMap, -) -> Option<&'a str> { - environment - .registration_key_id() - .and_then(|id| groups.get(id.as_str())) - .map(String::as_str) +fn unattached_message( + environment_id: &EnvironmentId, + policy: &engine::EnvironmentsFeature, +) -> String { + let attached = policy + .environments + .iter() + .map(|attachment| attachment.environment_id.as_str()) + .collect::>() + .join(", "); + if attached.is_empty() { + format!( + "environment {environment_id} is not attached to this session (no environments are attached)" + ) + } else { + format!( + "environment {environment_id} is not attached to this session (attached: {attached})" + ) + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -1312,56 +1315,54 @@ fn environment_read_target( } } -/// Reject ungranted operations before connecting to or waking a machine. +/// Reject operations the active attachment does not grant before +/// connecting to or waking a machine. Tools are installed for the union of +/// attachment grants, so this is where the active machine's own access +/// applies. Without an active environment the environment-required path +/// reports that instead. fn environment_tool_denial( - policy: Option<&engine::EnvironmentPolicyRuntime>, + policy: Option<&engine::EnvironmentsFeature>, + active: Option<&EnvironmentId>, call: &engine::ToolInvocationRequest, -) -> Option<&'static str> { +) -> Option { let id = call.tool_id.as_ref()?.as_str(); - let surface = policy.and_then(|policy| policy.tools); - match id { - "env.read_file" | "env.grep" | "env.glob" | "env.list_dir" | "vfs.capture" - if surface.is_none() => - { - Some("environment filesystem read tools are not granted") + let required = match id { + "env.read_file" | "env.grep" | "env.glob" | "env.list_dir" | "vfs.capture" => { + engine::EnvironmentAccess::Read } - "env.write_file" | "env.edit_file" | "env.apply_patch" | "vfs.materialize" - if surface != Some(engine::EnvironmentToolSurface::Edit) => - { - Some("environment filesystem editing is not granted") + "env.write_file" | "env.edit_file" | "env.apply_patch" | "vfs.materialize" => { + engine::EnvironmentAccess::Edit } - "env.run_process" | "env.continue_process" - if !policy.is_some_and(|policy| policy.commands) => - { - Some("environment command execution is not granted") - } - _ => None, + "env.run_process" | "env.continue_process" => engine::EnvironmentAccess::Exec, + _ => return None, + }; + let active = active?; + match policy.and_then(|policy| policy.attachment(active.as_str())) { + None => Some(format!( + "active environment {active} is not attached to this session" + )), + Some(attachment) if attachment.access >= required => None, + Some(attachment) => Some(format!( + "{} requires {} access on the active environment {active}, which grants {}", + call.tool_name, + match required { + engine::EnvironmentAccess::Read => "read", + engine::EnvironmentAccess::Edit => "edit", + engine::EnvironmentAccess::Exec => "exec", + engine::EnvironmentAccess::Jobs => "jobs", + }, + attachment.access.describe() + )), } } fn supplied_environment_policy( request: &ToolInvocationBatchRequest, -) -> Result { - let policy = request +) -> Result<&engine::EnvironmentsFeature, CoreAgentIoError> { + request .environment_policy .as_ref() - .ok_or_else(|| io_error("environment runtime policy is missing"))?; - if policy.version != engine::EnvironmentPolicyRuntime::VERSION { - return Err(io_error(format!( - "unsupported environment runtime policy version {}", - policy.version - ))); - } - Ok(access_policy_from_runtime(policy)) -} - -fn access_policy_from_runtime( - policy: &engine::EnvironmentPolicyRuntime, -) -> EnvironmentAccessPolicy { - EnvironmentAccessPolicy::new( - policy.allowed_provider_ids.clone(), - policy.allowed_registration_key_ids.clone(), - ) + .ok_or_else(|| io_error("environment grant is missing from the batch request")) } async fn job_read_entry_from_response( @@ -1429,6 +1430,59 @@ fn unsupported_environment_data_transport(transport: impl std::fmt::Display) -> )) } +#[derive(Clone, Copy)] +enum BatchCallRoute { + Workflow, + Concurrency, + EnvironmentControl, + EnvironmentJobRead, + Inline, +} + +impl BatchCallRoute { + fn requires_runtime(self) -> bool { + matches!(self, Self::EnvironmentJobRead | Self::Inline) + } +} + +struct BatchCall<'a> { + call: &'a engine::ToolInvocationRequest, + route: BatchCallRoute, + denial: Option, + needs_vfs: bool, + needs_environment: bool, +} + +impl<'a> BatchCall<'a> { + fn new(request: &ToolInvocationBatchRequest, call: &'a engine::ToolInvocationRequest) -> Self { + let id = call.tool_id.as_ref(); + let requirements = id.map(BuiltinToolRequirements::for_id).unwrap_or_default(); + let is_job_read = id.is_some_and(|id| id.as_str() == "env.job_read"); + let route = if call.workflow_tool.is_some() { + BatchCallRoute::Workflow + } else if id.is_some_and(is_concurrency_tool) { + BatchCallRoute::Concurrency + } else if id.is_some_and(is_environment_control_tool) { + BatchCallRoute::EnvironmentControl + } else if is_job_read { + BatchCallRoute::EnvironmentJobRead + } else { + BatchCallRoute::Inline + }; + Self { + call, + route, + denial: environment_tool_denial( + request.environment_policy.as_ref(), + request.active_environment_id.as_ref(), + call, + ), + needs_vfs: requirements.vfs, + needs_environment: requirements.active_environment || is_job_read, + } + } +} + #[async_trait] impl CoreAgentTools for SessionTools { async fn invoke_batch( @@ -1488,82 +1542,28 @@ impl CoreAgentTools for SessionTools { if has_await_call { return self.invoke_mixed_await_batch(request).await; } - let has_generic_runtime_call = request.calls.iter().any(|call| { - !call.tool_id.as_ref().is_some_and(is_concurrency_tool) - && !call - .tool_id - .as_ref() - .is_some_and(is_environment_control_tool) - && call.workflow_tool.is_none() - }); + let calls: Vec<_> = request + .calls + .iter() + .map(|call| BatchCall::new(&request, call)) + .collect(); + let has_generic_runtime_call = calls.iter().any(|call| call.route.requires_runtime()); + // Preserve the fast path: workflow/concurrency/control-only batches do + // not resolve generic domains, even if a supplied call has a builtin ID. + let has_vfs_call = has_generic_runtime_call + && calls + .iter() + .any(|call| call.denial.is_none() && call.needs_vfs); + let has_environment_call = has_generic_runtime_call + && calls + .iter() + .any(|call| call.denial.is_none() && call.needs_environment); let mut successful_workflow_siblings = BTreeMap::new(); - if !has_generic_runtime_call { - // Workflow-tool/concurrency-only batches skip generic VFS/runtime - // setup entirely. - let mut results = Vec::with_capacity(request.calls.len()); - for call in &request.calls { - if let Some(message) = - environment_tool_denial(request.environment_policy.as_ref(), call) - { - results.push( - failed_result(self.blobs.as_ref(), call.call_id.clone(), message).await?, - ); - } else if call.workflow_tool.is_some() { - results.push( - self.invoke_supplied_workflow_tool_call( - &request, - call, - &mut successful_workflow_siblings, - &promise_ids, - ) - .await?, - ); - } else if call - .tool_id - .as_ref() - .is_some_and(is_environment_control_tool) - { - results.push(self.invoke_environment_control_call(&request, call).await?); - } else { - results.push( - self.invoke_concurrency_call(&request, call, &promise_ids) - .await?, - ); - } - } - return Ok(ToolBatchOutcome::completed(ToolInvocationBatchResult { - run_id: request.run_id, - turn_id: request.turn_id, - batch_id: request.batch_id, - results, - })); - } - - let has_vfs_call = request.calls.iter().any(|call| { - if environment_tool_denial(request.environment_policy.as_ref(), call).is_some() { - return false; - } - call.tool_id - .as_ref() - .is_some_and(|id| BuiltinToolRequirements::for_id(id).vfs) - }); - let has_environment_call = request.calls.iter().any(|call| { - if environment_tool_denial(request.environment_policy.as_ref(), call).is_some() { - return false; - } - call.tool_id - .as_ref() - .is_some_and(|id| BuiltinToolRequirements::for_id(id).active_environment) - || call - .tool_id - .as_ref() - .is_some_and(|id| id.as_str() == "env.job_read") - }); - let links = if has_vfs_call { - vfs::resolve_workspace_links( + let attachments = if has_vfs_call { + vfs::resolve_workspace_attachments( self.blobs.clone(), self.workspace_store.clone(), - &request.workspace_links, + &request.workspace_attachments, ) .await .map_err(map_catalog_error)? @@ -1576,74 +1576,69 @@ impl CoreAgentTools for SessionTools { SessionEnvironmentManager::new(self.blobs.clone()) }; let outcome = async { - let runtime = self - .runtime_for_domains( - links, - &environments, - request.active_environment_id.as_ref(), - request.vfs_working_directory.as_deref(), + let runtime = if has_generic_runtime_call { + Some( + self.runtime_for_domains( + attachments, + &environments, + request.active_environment_id.as_ref(), + request.vfs_working_directory.as_deref(), + ) + .await?, ) - .await?; + } else { + None + }; - let mut results = Vec::with_capacity(request.calls.len()); - for call in &request.calls { - if let Some(message) = - environment_tool_denial(request.environment_policy.as_ref(), call) - { - results.push( - failed_result(self.blobs.as_ref(), call.call_id.clone(), message).await?, - ); - } else if call.workflow_tool.is_some() { - results.push( - self.invoke_supplied_workflow_tool_call( - &request, - call, - &mut successful_workflow_siblings, - &promise_ids, - ) - .await?, - ); - } else if call.tool_id.as_ref().is_some_and(is_concurrency_tool) { - results.push( - self.invoke_concurrency_call(&request, call, &promise_ids) - .await?, - ); - } else if call - .tool_id - .as_ref() - .is_some_and(is_environment_control_tool) + let mut results = Vec::with_capacity(calls.len()); + for planned in calls { + let call = planned.call; + let result = if let Some(message) = planned.denial { + failed_result(self.blobs.as_ref(), call.call_id.clone(), message).await? + } else if planned.route.requires_runtime() + && planned.needs_environment + && let Some(blocker) = environments.active_blocker() { - results.push(self.invoke_environment_control_call(&request, call).await?); - } else if let Some(blocker) = environments.active_blocker().filter(|_| { - call.tool_id - .as_ref() - .is_some_and(|id| id.as_str() == "env.job_read") - || call.tool_id.as_ref().is_some_and(|id| { - BuiltinToolRequirements::for_id(id).active_environment - }) - }) { // Batch-unit execution has no workflow-level readiness wait; // report the blocker as an ordinary failed call. - results.push( - failed_result( - self.blobs.as_ref(), - call.call_id.clone(), - active_environment_blocker_message(blocker), - ) - .await?, - ); - } else if call - .tool_id - .as_ref() - .is_some_and(|id| id.as_str() == "env.job_read") - { - results.push( - self.invoke_environment_job_call(&request, call, &environments) - .await?, - ); + failed_result( + self.blobs.as_ref(), + call.call_id.clone(), + active_environment_blocker_message(blocker), + ) + .await? } else { - results.push(runtime.invoke_call(call).await?); - } + match planned.route { + BatchCallRoute::Workflow => { + self.invoke_supplied_workflow_tool_call( + &request, + call, + &mut successful_workflow_siblings, + &promise_ids, + ) + .await? + } + BatchCallRoute::Concurrency => { + self.invoke_concurrency_call(&request, call, &promise_ids) + .await? + } + BatchCallRoute::EnvironmentControl => { + self.invoke_environment_control_call(&request, call).await? + } + BatchCallRoute::EnvironmentJobRead => { + self.invoke_environment_job_call(&request, call, &environments) + .await? + } + BatchCallRoute::Inline => { + runtime + .as_ref() + .expect("inline calls require runtime setup") + .invoke_call(call) + .await? + } + } + }; + results.push(result); } Ok(ToolBatchOutcome::completed(ToolInvocationBatchResult { run_id: request.run_id, @@ -1705,7 +1700,11 @@ impl SessionTools { request: engine::ToolInvocationCallRequest, ) -> Result { let call = request.call.clone(); - if let Some(message) = environment_tool_denial(request.environment_policy.as_ref(), &call) { + if let Some(message) = environment_tool_denial( + request.environment_policy.as_ref(), + request.active_environment_id.as_ref(), + &call, + ) { return failed_result(self.blobs.as_ref(), call.call_id, message) .await .map(ToolCallExecution::Completed); @@ -1798,11 +1797,11 @@ impl SessionTools { environments.close().await; return outcome; } - let links = if is_vfs_call { - vfs::resolve_workspace_links( + let attachments = if is_vfs_call { + vfs::resolve_workspace_attachments( self.blobs.clone(), self.workspace_store.clone(), - &batch_request.workspace_links, + &batch_request.workspace_attachments, ) .await .map_err(map_catalog_error)? @@ -1812,7 +1811,7 @@ impl SessionTools { let outcome = async { let runtime = self .runtime_for_domains( - links, + attachments, &environments, batch_request.active_environment_id.as_ref(), batch_request.vfs_working_directory.as_deref(), @@ -1831,7 +1830,7 @@ impl SessionTools { impl SessionTools { /// Poll the registry (and probe the route) until the environment is - /// selectable, terminally unusable, or `deadline` passes. `heartbeat` is + /// ready for use, terminally unusable, or `deadline` passes. `heartbeat` is /// invoked on every poll so the hosting activity stays alive. pub async fn await_environment_ready( &self, @@ -1853,25 +1852,20 @@ impl SessionTools { }; } }; - let allowed = request - .environment_policy - .as_ref() - .map(access_policy_from_runtime) - .unwrap_or_default(); let mut last_status; loop { heartbeat(); let now = i64::try_from(now_unix_ms().unwrap_or_default()).unwrap_or(i64::MAX); - match resolver.selectable(&environment_id, &allowed, now).await { + match resolver.ready_for_use(&environment_id, now).await { Ok(_) => return Outcome::Ready, - Err(crate::environment_resolver::EnvironmentResolveError::NotReady { + Err(crate::environments::resolver::EnvironmentResolveError::NotReady { status, .. }) => { last_status = format!("{status:?}").to_lowercase(); } Err( - crate::environment_resolver::EnvironmentResolveError::EnvironmentUnavailable { + crate::environments::resolver::EnvironmentResolveError::EnvironmentUnavailable { status, .. }, @@ -1881,7 +1875,7 @@ impl SessionTools { // be coming up. last_status = status; } - Err(crate::environment_resolver::EnvironmentResolveError::Store( + Err(crate::environments::resolver::EnvironmentResolveError::Store( environments::EnvironmentRegistryError::Store { message }, )) => { // Transient store trouble: keep polling. @@ -2062,12 +2056,12 @@ mod tests { use tools::concurrency::AWAIT_TOOL_NAME; use tools::environment::control::ENVIRONMENT_LIST_TOOL_NAME; - use crate::environment::RuntimeEnvironment; + use crate::environments::runtime::RuntimeEnvironment; use engine::{ BlobRef, ContextEntryKind, FunctionToolSpec, RunId, SessionId, ToolBatchId, ToolCallId, ToolKind, ToolName, ToolParallelism, ToolSpec, TurnId, WorkflowEndpointRef, - WorkflowToolDefinition, WorkflowToolId, WorkspaceLink, WorkspaceLinkAccess, - WorkspaceLinkTarget, + WorkflowToolDefinition, WorkflowToolId, WorkspaceAccess, WorkspaceAttachment, + WorkspaceAttachmentTarget, storage::{ AppendSessionEvents, CreateSession, InMemoryBlobStore, InMemorySessionStore, SessionStore, @@ -2142,87 +2136,93 @@ mod tests { } } - fn test_environment_policy( - providers: Option>, - keys: Option>, - ) -> engine::EnvironmentPolicyRuntime { - engine::EnvironmentPolicyRuntime { - tools: Some(engine::EnvironmentToolSurface::Edit), - commands: true, - ..engine::EnvironmentPolicyRuntime::new(providers, keys) + /// An environments grant attaching `attached` with jobs access and + /// selection tools: the widest grant, so tests exercise policy through + /// the active attachment rather than the tool surface. + fn test_environment_policy(attached: &[&str]) -> engine::EnvironmentsFeature { + test_environment_policy_with_access(attached, engine::EnvironmentAccess::Jobs) + } + + fn test_environment_policy_with_access( + attached: &[&str], + access: engine::EnvironmentAccess, + ) -> engine::EnvironmentsFeature { + engine::EnvironmentsFeature { + selection: true, + environments: attached + .iter() + .map(|id| engine::EnvironmentAttachment { + environment_id: (*id).to_owned(), + default: false, + access, + working_directory: None, + }) + .collect(), + ..engine::EnvironmentsFeature::default() } } #[tokio::test(flavor = "current_thread")] async fn ungranted_environment_operations_fail_before_resolving_domains() { - for tools_grant in [ - None, - Some(engine::EnvironmentToolSurface::ReadOnly), - Some(engine::EnvironmentToolSurface::Edit), + use engine::EnvironmentAccess; + let active = EnvironmentId::new("environment-active"); + for access in [ + EnvironmentAccess::Read, + EnvironmentAccess::Edit, + EnvironmentAccess::Exec, + EnvironmentAccess::Jobs, ] { - for commands in [false, true] { - let policy = engine::EnvironmentPolicyRuntime { - tools: tools_grant, - commands, - ..engine::EnvironmentPolicyRuntime::new(None, None) - }; - for (id, allowed) in [ - ("env.read_file", tools_grant.is_some()), - ("env.grep", tools_grant.is_some()), - ("env.glob", tools_grant.is_some()), - ("env.list_dir", tools_grant.is_some()), - ( - "env.write_file", - tools_grant == Some(engine::EnvironmentToolSurface::Edit), - ), - ( - "env.edit_file", - tools_grant == Some(engine::EnvironmentToolSurface::Edit), - ), - ( - "env.apply_patch", - tools_grant == Some(engine::EnvironmentToolSurface::Edit), - ), - ( - "vfs.materialize", - tools_grant == Some(engine::EnvironmentToolSurface::Edit), - ), - ("vfs.capture", tools_grant.is_some()), - ("env.run_process", commands), - ("env.continue_process", commands), - ] { - let blobs = Arc::new(InMemoryBlobStore::new()); - let tools = SessionTools::new(blobs.clone(), Arc::new(TestCatalog::default())); - let mut request = per_call_request("read_file", b"{}", &[]); - request.call.tool_id = Some(ToolName::new(id)); - request.environment_policy = Some(policy.clone()); - assert_eq!( - environment_tool_denial(Some(&policy), &request.call).is_none(), - allowed, - "{id}" + // Tools are installed for the union of grants; the active + // attachment's own access decides at execution. + let policy = test_environment_policy_with_access(&["environment-active"], access); + for (id, allowed) in [ + ("env.read_file", true), + ("env.grep", true), + ("env.glob", true), + ("env.list_dir", true), + ("env.write_file", access.allows_edit()), + ("env.edit_file", access.allows_edit()), + ("env.apply_patch", access.allows_edit()), + ("vfs.materialize", access.allows_edit()), + ("vfs.capture", true), + ("env.run_process", access.allows_exec()), + ("env.continue_process", access.allows_exec()), + ] { + let blobs = Arc::new(InMemoryBlobStore::new()); + let tools = SessionTools::new(blobs.clone(), Arc::new(TestCatalog::default())); + let mut request = per_call_request("read_file", b"{}", &[]); + request.call.tool_id = Some(ToolName::new(id)); + request.active_environment_id = Some(active.clone()); + request.environment_policy = Some(policy.clone()); + let denial = environment_tool_denial(Some(&policy), Some(&active), &request.call); + assert_eq!(denial.is_none(), allowed, "{id} under {access:?}"); + if let Some(message) = &denial { + assert!( + message.contains("environment-active") && message.contains("grants"), + "{message}" + ); + } + if allowed { + continue; + } + // No resolver, workspace attachments, or argument blob: refusal + // must happen before resolving any of these effectful resources. + let call = tools.invoke_call(request.clone()).await.unwrap(); + let batch = tools + .invoke_batch(request.into_batch_request()) + .await + .unwrap() + .completed_result() + .unwrap(); + for result in [call, batch.results[0].clone()] { + assert_eq!(result.status, ToolCallStatus::Failed); + assert!( + blobs + .read_text(result.error_ref.as_ref().unwrap()) + .await + .unwrap() + .contains("grants") ); - if allowed { - continue; - } - // No active environment, workspace links, or argument blob: refusal - // must happen before resolving any of these effectful resources. - let call = tools.invoke_call(request.clone()).await.unwrap(); - let batch = tools - .invoke_batch(request.into_batch_request()) - .await - .unwrap() - .completed_result() - .unwrap(); - for result in [call, batch.results[0].clone()] { - assert_eq!(result.status, ToolCallStatus::Failed); - assert!( - blobs - .read_text(result.error_ref.as_ref().unwrap()) - .await - .unwrap() - .contains("not granted") - ); - } } } } @@ -2230,11 +2230,11 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn denied_environment_call_does_not_block_an_independent_vfs_read() { - let (blobs, tools, session_id, links) = session_tools_with_readme_link().await; + let (blobs, tools, session_id, attachments) = session_tools_with_readme_attachment().await; let mut request = per_call_request("vfs_read_file", br#"{"path":"/workspace/README.md"}"#, &[]); request.session_id = session_id; - request.workspace_links = links; + request.workspace_attachments = attachments; request.call.arguments_ref = blobs .put_bytes(br#"{"path":"/workspace/README.md"}"#.to_vec()) .await @@ -2243,6 +2243,11 @@ mod tests { denied.call_id = ToolCallId::new("denied"); denied.tool_id = Some(ToolName::new("env.write_file")); let mut batch = request.into_batch_request(); + batch.active_environment_id = Some(EnvironmentId::new("environment-active")); + batch.environment_policy = Some(test_environment_policy_with_access( + &["environment-active"], + engine::EnvironmentAccess::Read, + )); batch.calls.push(denied); let results = tools .invoke_batch(batch) @@ -2258,7 +2263,7 @@ mod tests { .read_text(results[1].error_ref.as_ref().unwrap()) .await .unwrap() - .contains("not granted") + .contains("requires edit access") ); } @@ -2274,7 +2279,7 @@ mod tests { turn_id: TurnId::new(1), batch_id: ToolBatchId::new(1), promise_id_base: 1, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), active_environment_id: None, environment_policy: None, subagents_policy: None, @@ -2514,6 +2519,139 @@ mod tests { (session_id, binding) } + #[tokio::test(flavor = "current_thread")] + async fn special_dispatch_preserves_order_and_accounting_with_or_without_inline_calls() { + for with_inline in [false, true] { + let (blobs, tools, _, workspace_attachments) = + session_tools_with_readme_attachment().await; + let sessions = InMemorySessionStore::new(); + let (session_id, binding) = workflow_tool_session(blobs.as_ref(), &sessions).await; + let registry = Arc::new(InMemoryEnvironmentRegistryStore::new()); + let tools = tools.with_environment_resolver( + crate::environments::resolver::EnvironmentResolver::new(registry.clone(), registry), + ); + let sleep_args = blobs.put_bytes(br#"{"ms":50}"#.to_vec()).await.unwrap(); + let workflow_args = blobs + .put_bytes(br#"{"status":"complete"}"#.to_vec()) + .await + .unwrap(); + let list_args = blobs.put_bytes(b"{}".to_vec()).await.unwrap(); + let read_args = blobs + .put_bytes(br#"{"path":"README.md"}"#.to_vec()) + .await + .unwrap(); + let call = |name: &str, id: &str, arguments_ref: BlobRef| { + let mut call = per_call_request(name, b"{}", &[]).call; + call.call_id = ToolCallId::new(id); + call.arguments_ref = arguments_ref; + call + }; + let mut workflow = call("work_report", "workflow", workflow_args); + workflow.builtin = None; + workflow.workflow_tool = Some(engine::WorkflowToolCallRuntime::v1( + binding, + engine::MAX_WORKFLOW_TOOL_EMISSIONS_PER_RUN - 1, + )); + let mut bad_route = workflow.clone(); + bad_route.call_id = ToolCallId::new("bad-route"); + // Supplied workflow routing wins over a builtin-looking ID. The + // mismatch must fail validation without demanding VFS setup on + // the special-only path, and must not consume the sibling cap. + bad_route.tool_id = Some(test_tool_id("vfs_read_file")); + let mut over_cap = workflow.clone(); + over_cap.call_id = ToolCallId::new("over-cap"); + let mut calls = vec![ + call( + ::tools::concurrency::SLEEP_TOOL_NAME, + "sleep-a", + sleep_args.clone(), + ), + bad_route, + workflow, + call(ENVIRONMENT_LIST_TOOL_NAME, "list", list_args), + call(::tools::concurrency::SLEEP_TOOL_NAME, "sleep-b", sleep_args), + over_cap, + ]; + if with_inline { + calls.push(call("vfs_read_file", "read", read_args)); + } + let expected_ids: Vec<_> = calls.iter().map(|call| call.call_id.clone()).collect(); + let results = tools + .invoke_batch(ToolInvocationBatchRequest { + session_id, + run_id: RunId::new(9), + turn_id: TurnId::new(1), + batch_id: ToolBatchId::new(1), + promise_id_base: 5, + // A special-only batch must not inspect this nonexistent cwd. + vfs_working_directory: Some( + if with_inline { + "/workspace" + } else { + "/missing" + } + .into(), + ), + workspace_attachments, + active_environment_id: None, + environment_policy: Some(test_environment_policy(&[])), + subagents_policy: None, + calls, + }) + .await + .expect("dispatch") + .completed_result() + .expect("completed") + .results; + assert_eq!( + results + .iter() + .map(|result| result.call_id.clone()) + .collect::>(), + expected_ids + ); + for (index, result) in results.iter().enumerate() { + let expected = if matches!(index, 1 | 5) { + ToolCallStatus::Failed + } else { + ToolCallStatus::Succeeded + }; + assert_eq!( + result.status, expected, + "inline={with_inline}, call={}", + result.call_id + ); + } + assert_eq!( + results[0].effects[0] + .data + .get("promise_id") + .map(String::as_str), + Some("promise_5") + ); + assert_eq!( + results[4].effects[0] + .data + .get("promise_id") + .map(String::as_str), + Some("promise_6") + ); + assert_eq!( + results[2].effects[0].kind, + engine::WORKFLOW_TOOL_EMIT_EFFECT_KIND + ); + assert!(results[1].effects.is_empty()); + assert!(results[5].effects.is_empty()); + if with_inline { + let output = blobs + .read_text(results[6].output_ref.as_ref().unwrap()) + .await + .unwrap(); + assert!(output.contains("hello")); + } + } + } + #[tokio::test(flavor = "current_thread")] async fn workflow_tool_calls_validate_schema_ack_and_per_run_cap() { let blobs = Arc::new(InMemoryBlobStore::new()); @@ -2621,7 +2759,7 @@ mod tests { active_environment_id: None, environment_policy: None, subagents_policy: None, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), calls, }; let retry_request = request.clone(); @@ -2787,19 +2925,13 @@ mod tests { batch_id: ToolBatchId::new(1), promise_id_base: 1, active_environment_id: Some(EnvironmentId::new("environment-original")), - environment_policy: Some(engine::EnvironmentPolicyRuntime::new( - Some(vec!["provider-b".to_owned(), "provider-a".to_owned()]), - None, - )), + environment_policy: Some(test_environment_policy(&["environment-original"])), subagents_policy: None, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), calls: vec![call.clone()], }; - request - .environment_policy - .as_mut() - .unwrap() - .working_directory = Some("/project".into()); + request.environment_policy.as_mut().unwrap().environments[0].working_directory = + Some("/project".into()); let tools = SessionTools::new(blobs.clone(), catalog); let first = tools @@ -2831,10 +2963,6 @@ mod tests { .expect("decode execution context"); assert_eq!(context.environment_id, "environment-original"); assert_eq!(context.working_directory.as_deref(), Some("/project")); - assert_eq!( - context.allowed_provider_ids, - Some(vec!["provider-a".to_owned(), "provider-b".to_owned()]) - ); let retried = tools .invoke_batch(request) .await @@ -2852,10 +2980,10 @@ mod tests { batch_id: ToolBatchId::new(1), promise_id_base: 1, active_environment_id: None, - environment_policy: Some(engine::EnvironmentPolicyRuntime::new(None, None)), + environment_policy: Some(test_environment_policy(&[])), subagents_policy: None, - workspace_links: Vec::new(), - calls: vec![call], + workspace_attachments: Vec::new(), + calls: vec![call.clone()], }) .await .expect("invoke job_submit without active environment") @@ -2948,7 +3076,7 @@ mod tests { active_environment_id: None, environment_policy: None, subagents_policy: policy, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), calls: vec![engine::ToolInvocationRequest { builtin: Some(test_builtin_runtime()), call_id: ToolCallId::new("call-agent-run"), @@ -3093,7 +3221,8 @@ mod tests { "session-parent".to_owned(), 7, "reviewer".to_owned(), - limits + limits, + None ) ); assert_eq!(context.version, SubagentExecutionContextV1::VERSION); @@ -3248,11 +3377,11 @@ mod tests { } } - async fn session_tools_with_readme_link() -> ( + async fn session_tools_with_readme_attachment() -> ( Arc, SessionTools, SessionId, - Vec, + Vec, ) { let blobs = Arc::new(InMemoryBlobStore::new()); let catalog = Arc::new(TestCatalog::default()); @@ -3278,15 +3407,15 @@ mod tests { }) .await .expect("workspace"); - let workspace_links = vec![WorkspaceLink { + let workspace_attachments = vec![WorkspaceAttachment { path: "/workspace".to_owned(), - target: WorkspaceLinkTarget::Workspace { + target: WorkspaceAttachmentTarget::Workspace { workspace_id: workspace_id.to_string(), }, - access: WorkspaceLinkAccess::ReadWrite, + access: WorkspaceAccess::Edit, }]; let tools = SessionTools::new(blobs.clone(), catalog); - (blobs, tools, session_id, workspace_links) + (blobs, tools, session_id, workspace_attachments) } fn test_environment( @@ -3317,7 +3446,6 @@ mod tests { }, public_ingress_enabled: false, public_endpoint: None, - origin_session: None, metadata: BTreeMap::new(), last_seen_at_ms: None, created_at_ms: 1, @@ -3404,8 +3532,8 @@ mod tests { ProviderApiKind::AnthropicMessages, ProviderApiKind::OpenAiCompletions, ] { - let (blobs, tools, session_id, workspace_links) = - session_tools_with_readme_link().await; + let (blobs, tools, session_id, workspace_attachments) = + session_tools_with_readme_attachment().await; let root = tempfile::tempdir().unwrap(); let fs = environment_daemon::filesystem::LocalFileSystem::new( root.path().into(), @@ -3431,9 +3559,9 @@ mod tests { .unwrap(); let mut request = per_call_request("vfs_materialize", &args, &[]); request.session_id = session_id; - request.workspace_links = workspace_links.clone(); + request.workspace_attachments = workspace_attachments.clone(); request.active_environment_id = Some(EnvironmentId::new("test")); - request.environment_policy = Some(test_environment_policy(None, None)); + request.environment_policy = Some(test_environment_policy(&["test"])); request.call.arguments_ref = blobs.put_bytes(args).await.unwrap(); let builtin = request.call.builtin.as_mut().unwrap(); builtin.model.api_kind = api_kind; @@ -3517,20 +3645,20 @@ mod tests { "workspace effects must be drained from the VFS context" ); - // Link permissions are enforced even if a call has an admitted + // Attachment permissions are enforced even if a call has an admitted // editing-tool identity. capture.call.call_id = ToolCallId::new("capture-readonly"); - capture.workspace_links[0].access = WorkspaceLinkAccess::ReadOnly; + capture.workspace_attachments[0].access = WorkspaceAccess::Read; assert_eq!( dispatch(&tools, capture, batch).await.status, ToolCallStatus::Failed ); - request.workspace_links.clear(); + request.workspace_attachments.clear(); assert_eq!( dispatch(&tools, request.clone(), batch).await.status, ToolCallStatus::Failed ); - request.workspace_links = workspace_links; + request.workspace_attachments = workspace_attachments; request.active_environment_id = None; assert_eq!( dispatch(&tools, request, batch).await.status, @@ -3591,7 +3719,7 @@ mod tests { template_id: EnvironmentTemplateId::new("test-template"), display_name: None, metadata: BTreeMap::new(), - origin_session: None, + idle_policy: None, created_at_ms: observed_at_ms.saturating_sub(1), }) @@ -3665,7 +3793,7 @@ mod tests { .await .expect("observe status and power support"); let resolver = - crate::environment_resolver::EnvironmentResolver::new(registry.clone(), registry); + crate::environments::resolver::EnvironmentResolver::new(registry.clone(), registry); let tools = SessionTools::new(blobs.clone(), Arc::new(TestCatalog::default())) .with_environment_resolver(resolver); blobs.put_bytes(b"{}".to_vec()).await.expect("arguments"); @@ -3673,10 +3801,10 @@ mod tests { for tool_name in ["environment_read", "environment_list"] { let mut request = per_call_request(tool_name, b"{}", &[]); request.active_environment_id = Some(environment_id.clone()); - request.environment_policy = Some(test_environment_policy( - Some(vec!["allowed".to_owned()]), - None, - )); + request.environment_policy = Some(test_environment_policy(&[ + "environment-allowed-1", + "environment-allowed-2", + ])); let result = tools.invoke_call(request).await.expect("invoke tool"); assert_eq!(result.status, ToolCallStatus::Succeeded); let output: serde_json::Value = serde_json::from_str( @@ -3713,7 +3841,7 @@ mod tests { let registry = Arc::new(InMemoryEnvironmentRegistryStore::new()); register_test_environment_provider(registry.as_ref(), "allowed").await; observe_test_environment(registry.as_ref(), "environment-allowed-1", "allowed", 10).await; - let resolver = crate::environment_resolver::EnvironmentResolver::new( + let resolver = crate::environments::resolver::EnvironmentResolver::new( registry.clone(), registry.clone(), ); @@ -3729,12 +3857,12 @@ mod tests { turn_id: TurnId::new(1), batch_id: ToolBatchId::new(1), promise_id_base: 1, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), active_environment_id: Some(EnvironmentId::new("environment-allowed-1")), - environment_policy: Some(test_environment_policy( - Some(vec!["allowed".to_owned()]), - None, - )), + environment_policy: Some(test_environment_policy(&[ + "environment-allowed-1", + "environment-allowed-2", + ])), subagents_policy: None, call: engine::ToolInvocationRequest { builtin: Some(test_builtin_runtime()), @@ -3782,13 +3910,13 @@ mod tests { template_id: EnvironmentTemplateId::new("test-template"), display_name: None, metadata: BTreeMap::new(), - origin_session: None, + idle_policy: None, created_at_ms: 10, }) .await .expect("create environment"); - let resolver = crate::environment_resolver::EnvironmentResolver::new( + let resolver = crate::environments::resolver::EnvironmentResolver::new( registry.clone(), registry.clone(), ); @@ -3804,9 +3932,9 @@ mod tests { turn_id: TurnId::new(1), batch_id: ToolBatchId::new(1), promise_id_base: 1, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), active_environment_id: Some(EnvironmentId::new("environment-pending")), - environment_policy: Some(test_environment_policy(None, None)), + environment_policy: Some(test_environment_policy(&["environment-pending"])), subagents_policy: None, call: engine::ToolInvocationRequest { builtin: Some(test_builtin_runtime()), @@ -3894,7 +4022,7 @@ mod tests { }) .await .expect("fail environment"); - let resolver = crate::environment_resolver::EnvironmentResolver::new( + let resolver = crate::environments::resolver::EnvironmentResolver::new( registry.clone(), registry.clone(), ); @@ -3933,7 +4061,7 @@ mod tests { turn_id: TurnId::new(1), batch_id: ToolBatchId::new(1), promise_id_base: 1, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), active_environment_id: None, environment_policy: None, subagents_policy: None, @@ -3962,7 +4090,7 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn environment_list_uses_supplied_policy_and_live_resolver_state_without_session_store() { + async fn environment_list_shows_attachments_with_live_registry_state() { let blobs = Arc::new(InMemoryBlobStore::new()); let catalog = Arc::new(TestCatalog::default()); let registry = Arc::new(InMemoryEnvironmentRegistryStore::new()); @@ -3970,7 +4098,7 @@ mod tests { register_test_environment_provider(registry.as_ref(), "denied").await; observe_test_environment(registry.as_ref(), "environment-allowed-1", "allowed", 10).await; observe_test_environment(registry.as_ref(), "environment-denied", "denied", 10).await; - let resolver = crate::environment_resolver::EnvironmentResolver::new( + let resolver = crate::environments::resolver::EnvironmentResolver::new( registry.clone(), registry.clone(), ); @@ -3987,12 +4115,12 @@ mod tests { batch_id: ToolBatchId::new(1), promise_id_base: 1, active_environment_id: Some(EnvironmentId::new("environment-allowed-1")), - environment_policy: Some(test_environment_policy( - Some(vec!["allowed".to_owned()]), - None, - )), + environment_policy: Some(test_environment_policy(&[ + "environment-allowed-1", + "environment-allowed-2", + ])), subagents_policy: None, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), calls: vec![engine::ToolInvocationRequest { builtin: Some(test_builtin_runtime()), call_id: ToolCallId::new("call-environment-list"), @@ -4021,12 +4149,26 @@ mod tests { let first_environments = first_output["environments"] .as_array() .expect("first environments"); - assert_eq!(first_environments.len(), 1); + // Every attachment is listed, never an unattached registry record; + // a missing record shows as unknown status rather than vanishing. + assert_eq!(first_environments.len(), 2); assert_eq!( first_environments[0]["environment_id"], "environment-allowed-1" ); assert_eq!(first_environments[0]["active"], true); + assert_eq!(first_environments[0]["access"], "read, edit, exec, jobs"); + assert_eq!(first_environments[0]["status"], "offline"); + assert_eq!( + first_environments[1]["environment_id"], + "environment-allowed-2" + ); + assert_eq!(first_environments[1]["status"], serde_json::Value::Null); + assert!( + !first_environments + .iter() + .any(|environment| environment["environment_id"] == "environment-denied") + ); observe_test_environment(registry.as_ref(), "environment-allowed-2", "allowed", 20).await; let second = tools @@ -4047,22 +4189,21 @@ mod tests { .expect("read second output"), ) .expect("decode second output"); - assert_eq!( - second_output["environments"] - .as_array() - .expect("second environments") - .len(), - 2 - ); + let second_environments = second_output["environments"] + .as_array() + .expect("second environments"); + assert_eq!(second_environments.len(), 2); + assert_eq!(second_environments[1]["status"], "offline"); } #[tokio::test(flavor = "current_thread")] async fn vfs_relative_paths_use_only_the_configured_directory() { for (cwd, succeeds) in [(None, false), (Some("/workspace"), true)] { - let (blobs, tools, session_id, links) = session_tools_with_readme_link().await; + let (blobs, tools, session_id, attachments) = + session_tools_with_readme_attachment().await; let mut request = per_call_request("vfs_read_file", br#"{"path":"README.md"}"#, &[]); request.session_id = session_id; - request.workspace_links = links; + request.workspace_attachments = attachments; request.vfs_working_directory = cwd.map(String::from); request.call.arguments_ref = blobs .put_bytes(br#"{"path":"README.md"}"#.to_vec()) @@ -4083,8 +4224,9 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn session_tools_read_vfs_workspace_link() { - let (blobs, tools, session_id, workspace_links) = session_tools_with_readme_link().await; + async fn session_tools_read_vfs_workspace_attachment() { + let (blobs, tools, session_id, workspace_attachments) = + session_tools_with_readme_attachment().await; let arguments_ref = blobs .put_bytes(br#"{"path":"README.md","offset":1,"limit":10}"#.to_vec()) .await @@ -4101,7 +4243,7 @@ mod tests { active_environment_id: None, environment_policy: None, subagents_policy: None, - workspace_links, + workspace_attachments, calls: vec![engine::ToolInvocationRequest { builtin: Some(test_builtin_runtime()), call_id: ToolCallId::new("call_1"), @@ -4128,7 +4270,8 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn session_tools_accept_claude_style_vfs_read_tool() { - let (blobs, tools, session_id, workspace_links) = session_tools_with_readme_link().await; + let (blobs, tools, session_id, workspace_attachments) = + session_tools_with_readme_attachment().await; let arguments_ref = blobs .put_bytes(br#"{"file_path":"README.md","offset":1,"limit":10}"#.to_vec()) .await @@ -4145,7 +4288,7 @@ mod tests { active_environment_id: None, environment_policy: None, subagents_policy: None, - workspace_links, + workspace_attachments, calls: vec![engine::ToolInvocationRequest { builtin: Some(engine::BuiltinToolCallRuntime { spec: engine::BuiltinToolSpec::default(), @@ -4179,7 +4322,8 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn session_tools_route_vfs_file_tools_and_environment_process_tools_separately() { - let (blobs, tools, session_id, workspace_links) = session_tools_with_readme_link().await; + let (blobs, tools, session_id, workspace_attachments) = + session_tools_with_readme_attachment().await; let process = Arc::new(RecordingProcessExecutor::default()); let tools = tools.with_environment(test_environment(blobs.clone(), process.clone())); let read_args = blobs @@ -4200,9 +4344,9 @@ mod tests { batch_id: ToolBatchId::new(1), promise_id_base: 1, active_environment_id: Some(EnvironmentId::new("test")), - environment_policy: Some(test_environment_policy(None, None)), + environment_policy: Some(test_environment_policy(&["test"])), subagents_policy: None, - workspace_links, + workspace_attachments, calls: vec![ engine::ToolInvocationRequest { builtin: Some(test_builtin_runtime()), @@ -4338,7 +4482,7 @@ mod tests { active_environment_id: None, environment_policy: None, subagents_policy: None, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), calls: vec![ engine::ToolInvocationRequest { builtin: Some(test_builtin_runtime()), @@ -4443,7 +4587,7 @@ mod tests { active_environment_id: None, environment_policy: None, subagents_policy: None, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), calls: vec![engine::ToolInvocationRequest { builtin: Some(test_builtin_runtime()), call_id: ToolCallId::new("call_wait"), @@ -4494,7 +4638,7 @@ mod tests { active_environment_id: None, environment_policy: None, subagents_policy: None, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), calls: vec![engine::ToolInvocationRequest { builtin: Some(test_builtin_runtime()), call_id: ToolCallId::new("call_cancel"), @@ -4550,7 +4694,7 @@ mod tests { active_environment_id: None, environment_policy: None, subagents_policy: None, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), calls: vec![engine::ToolInvocationRequest { builtin: Some(test_builtin_runtime()), call_id: ToolCallId::new("call_detach"), @@ -4624,7 +4768,7 @@ mod tests { active_environment_id: None, environment_policy: None, subagents_policy: None, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), calls: vec![sleep_call("call_sleep_a"), sleep_call("call_sleep_b")], }) .await @@ -4692,7 +4836,7 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn session_tools_fail_vfs_tool_without_workspace_links() { + async fn session_tools_fail_vfs_tool_without_workspace_attachments() { let blobs = Arc::new(InMemoryBlobStore::new()); let catalog = Arc::new(TestCatalog::default()); let tools = SessionTools::new(blobs.clone(), catalog); @@ -4709,7 +4853,7 @@ mod tests { active_environment_id: None, environment_policy: None, subagents_policy: None, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), calls: vec![engine::ToolInvocationRequest { builtin: Some(test_builtin_runtime()), call_id: ToolCallId::new("call_1"), @@ -4731,7 +4875,7 @@ mod tests { .read_text(result.results[0].error_ref.as_ref().expect("error ref")) .await .expect("error"); - assert!(error.contains("no_vfs_workspace_links")); + assert!(error.contains("no_vfs_workspace_attachments")); } #[tokio::test(flavor = "current_thread")] @@ -4755,7 +4899,7 @@ mod tests { active_environment_id: None, environment_policy: None, subagents_policy: None, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), calls: vec![engine::ToolInvocationRequest { builtin: Some(test_builtin_runtime()), call_id: ToolCallId::new("call_1"), @@ -4778,6 +4922,6 @@ mod tests { .await .expect("error"); assert!(error.contains("non-public")); - assert!(!error.contains("no_vfs_workspace_links")); + assert!(!error.contains("no_vfs_workspace_attachments")); } } diff --git a/crates/temporal-server/src/worker/universes.rs b/crates/temporal-server/src/worker/universes.rs new file mode 100644 index 00000000..3c941401 --- /dev/null +++ b/crates/temporal-server/src/worker/universes.rs @@ -0,0 +1,101 @@ +//! Universe resolution shared by role-specific activity adapters. +use std::sync::Arc; + +use temporalio_common::error::ApplicationFailure; +use temporalio_sdk::activities::ActivityError; +use uuid::Uuid; + +use crate::{ + gateway::GatewayAgentApi, + universe::{UniverseError, UniverseRuntime}, +}; + +pub(super) enum WorkerUniverses { + /// One pre-built service for one universe. + Fixed { + universe_id: Uuid, + api: Arc, + }, + /// Lazy per-universe resolution over the deployment runtime. + Runtime(Arc), +} + +impl WorkerUniverses { + pub(super) async fn api_for( + &self, + universe_id: Uuid, + ) -> Result, ActivityError> { + match self { + Self::Fixed { + universe_id: served, + api, + } => { + require_matching_universe(*served, universe_id)?; + Ok(api.clone()) + } + Self::Runtime(runtime) => runtime + .state_for(universe_id, false) + .await + .map(|state| state.api.clone()) + .map_err(map_universe_error), + } + } +} + +fn require_matching_universe(served: Uuid, requested: Uuid) -> Result<(), ActivityError> { + if served != requested { + return Err(ActivityError::application( + ApplicationFailure::non_retryable(anyhow::anyhow!( + "worker serves universe {served} but activity requested {requested}" + )), + )); + } + Ok(()) +} + +fn map_universe_error(error: UniverseError) -> ActivityError { + ActivityError::application(match error { + UniverseError::Unknown { .. } => { + ApplicationFailure::non_retryable(anyhow::anyhow!("{error}")) + } + UniverseError::Runtime(_) => ApplicationFailure::new(anyhow::anyhow!("{error}")), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fixed_workers_accept_only_their_served_universe() { + let served = Uuid::from_u128(1); + require_matching_universe(served, served).expect("matching universe"); + let ActivityError::Application(error) = + require_matching_universe(served, Uuid::from_u128(2)).expect_err("different universe") + else { + panic!("expected application failure"); + }; + assert!(error.is_non_retryable()); + } + + #[test] + fn unknown_universes_are_terminal_but_runtime_failures_are_retryable() { + for (error, non_retryable) in [ + ( + UniverseError::Unknown { + universe_id: Uuid::from_u128(2), + }, + true, + ), + ( + UniverseError::Runtime(anyhow::anyhow!("store unavailable")), + false, + ), + ] { + let ActivityError::Application(error) = map_universe_error(error) else { + panic!("expected application failure"); + }; + assert_eq!(error.is_non_retryable(), non_retryable); + } + } +} diff --git a/crates/temporal-server/tests/environment_provider_live.rs b/crates/temporal-server/tests/environment_provider_live.rs index 7d380bd4..e978f3bf 100644 --- a/crates/temporal-server/tests/environment_provider_live.rs +++ b/crates/temporal-server/tests/environment_provider_live.rs @@ -466,9 +466,8 @@ async fn run_environment_power_live_client( .any(|environment| environment.environment_id == environment_id) ); - // Wake-on-use: activating the paused environment for a session admits it - // as intent and flips desired power back to running; the reconciler then - // brings it to ready. + // Selection only records the environment. A paused machine stays paused + // until an operation actually needs to use it. api.start_session(SessionStartParams { metadata: Default::default(), session_id: Some(session_id.as_str().to_owned()), @@ -477,23 +476,23 @@ async fn run_environment_power_live_client( model: Some(model_to_api(&model)), features: Some(api::FeaturesConfig { environments: Some(api::EnvironmentsFeature { - tools: Some(api::EnvironmentToolSurface::Edit), - commands: true, - working_directory: None, - prompts: None, version: api::CURRENT_FEATURE_VERSION, - providers: None, - registration_keys: None, - selection_tools: false, - jobs: false, + selection: false, + prompts: None, skills: None, + environments: vec![api::EnvironmentAttachment { + environment_id: Some(environment_id.clone()), + inherit: false, + default: false, + access: api::EnvironmentAccess::Exec, + working_directory: None, + }], }), ..api::FeaturesConfig::default() }), ..SessionConfig::default() }), profile: None, - environment: None, delete_after_close_ms: None, }) .await?; @@ -509,20 +508,18 @@ async fn run_environment_power_live_client( activated_view.active_environment_id.as_deref(), Some(environment_id.as_str()) ); - let woken = api + let selected = api .read_environment(api::EnvironmentReadParams { environment_id: environment_id.clone(), }) .await? .result .environment; - assert_eq!(woken.desired_power, api::EnvironmentPowerStateView::Running); - wait_for_environment_status( - &api, - &environment_id, - api::EnvironmentLifecycleStatusView::Ready, - ) - .await?; + assert_eq!( + selected.desired_power, + api::EnvironmentPowerStateView::Paused + ); + assert_eq!(selected.status, api::EnvironmentLifecycleStatusView::Paused); // Suspend and stop are ordinary intents on a provider that supports them. api.put_environment_power(api::EnvironmentPowerPutParams { diff --git a/crates/temporal-server/tests/environment_registration_live.rs b/crates/temporal-server/tests/environment_registration_live.rs index b65ff767..e86094f7 100644 --- a/crates/temporal-server/tests/environment_registration_live.rs +++ b/crates/temporal-server/tests/environment_registration_live.rs @@ -280,7 +280,7 @@ async fn scenario( // Workers reach this test's gateway, not whatever the sourced dev // environment points LIGHTSPEED_ENVIRONMENT_GATEWAY_URL at; the route // bearer is the deployment token the gateway state checks. - let gateway = temporal_server::environment_gateway::EnvironmentGatewayClientConfig::new( + let gateway = temporal_server::environments::gateway::EnvironmentGatewayClientConfig::new( base_url, runtime.environment_gateway().deployment_token(), ); diff --git a/crates/temporal-server/tests/mcp_live.rs b/crates/temporal-server/tests/mcp_live.rs index d610f959..cdc87bcb 100644 --- a/crates/temporal-server/tests/mcp_live.rs +++ b/crates/temporal-server/tests/mcp_live.rs @@ -9,7 +9,7 @@ use std::{collections::BTreeSet, path::PathBuf, process::Stdio, sync::Arc, time: use api::{ AgentApiService, ApprovalDecisionInput, ApprovalDecisionKind, ApprovalDecisionStatus, - FeaturesConfig, InputItem, McpServerDeleteParams, McpServerInput, McpServerLink, + FeaturesConfig, InputItem, McpServerAttachment, McpServerDeleteParams, McpServerInput, McpServerListParams, McpServerPutParams, McpServerReadParams, McpServerStatus, McpServerToolsDiscoverParams, McpServerToolsDiscoverResponse, RemoteMcpApprovalPolicy, RemoteMcpExecution, RemoteMcpExposure, RunApprovalsDecideParams, RunLimitsConfig, @@ -490,14 +490,17 @@ async fn run_matrix_client( mcp: Some(api::McpFeature { version: api::CURRENT_FEATURE_VERSION, servers: vec![ - McpServerLink { + McpServerAttachment { server_id: ids.small.clone(), + tools: None, }, - McpServerLink { + McpServerAttachment { server_id: ids.large.clone(), + tools: None, }, - McpServerLink { + McpServerAttachment { server_id: ids.selected.clone(), + tools: None, }, ], }), @@ -506,7 +509,6 @@ async fn run_matrix_client( ..SessionConfig::default() }), profile: None, - environment: None, delete_after_close_ms: None, }) .await?; @@ -614,7 +616,7 @@ async fn put_fixture_server( server_url: &str, exposure: RemoteMcpExposure, allowed_tools: Option>, - approval_default: RemoteMcpApprovalPolicy, + approval: RemoteMcpApprovalPolicy, ) -> anyhow::Result<()> { api.put_mcp_server(McpServerPutParams { server: McpServerInput { @@ -626,8 +628,8 @@ async fn put_fixture_server( allowed_tools, execution: RemoteMcpExecution::Native, exposure, - approval_default, - defer_loading_default: None, + approval, + defer_loading: None, allow_private_network: true, auth_policy: api::McpServerAuthPolicy::None, credential: None, @@ -1390,7 +1392,6 @@ async fn run_approval_live_client( ..SessionConfig::default() }), profile: None, - environment: None, delete_after_close_ms: None, }) .await?; @@ -1537,8 +1538,8 @@ async fn run_native_mcp_live_client( allowed_tools: None, execution: api::RemoteMcpExecution::Native, exposure: api::RemoteMcpExposure::Search, - approval_default: RemoteMcpApprovalPolicy::Always, - defer_loading_default: None, + approval: RemoteMcpApprovalPolicy::Always, + defer_loading: None, allow_private_network: true, auth_policy: api::McpServerAuthPolicy::None, credential: None, @@ -1557,8 +1558,9 @@ async fn run_native_mcp_live_client( features: Some(FeaturesConfig { mcp: Some(api::McpFeature { version: api::CURRENT_FEATURE_VERSION, - servers: vec![api::McpServerLink { + servers: vec![api::McpServerAttachment { server_id: server_id.clone(), + tools: None, }], }), ..FeaturesConfig::default() @@ -1566,7 +1568,6 @@ async fn run_native_mcp_live_client( ..SessionConfig::default() }), profile: None, - environment: None, delete_after_close_ms: None, }) .await?; @@ -1651,8 +1652,8 @@ async fn run_mcp_live_client( allowed_tools: Some(vec![selected_tool.clone()]), execution: api::RemoteMcpExecution::Provider, exposure: api::RemoteMcpExposure::Inject, - approval_default: RemoteMcpApprovalPolicy::Never, - defer_loading_default: Some(true), + approval: RemoteMcpApprovalPolicy::Never, + defer_loading: Some(true), allow_private_network: true, auth_policy: api::McpServerAuthPolicy::None, credential: None, @@ -1712,7 +1713,6 @@ async fn run_mcp_live_client( ..SessionConfig::default() }), profile: None, - environment: None, delete_after_close_ms: None, }) .await?; @@ -1731,8 +1731,9 @@ async fn run_mcp_live_client( let mut features = linked_config.features.clone().unwrap_or_default(); features.mcp = Some(api::McpFeature { version: api::CURRENT_FEATURE_VERSION, - servers: vec![api::McpServerLink { + servers: vec![api::McpServerAttachment { server_id: server_id.clone(), + tools: None, }], }); linked_config.features = Some(features); @@ -2201,8 +2202,8 @@ async fn run_mixed_batch_live_client( allowed_tools: Some(vec![selected_tool.to_owned()]), execution: api::RemoteMcpExecution::Native, exposure: api::RemoteMcpExposure::Inject, - approval_default: RemoteMcpApprovalPolicy::Always, - defer_loading_default: None, + approval: RemoteMcpApprovalPolicy::Always, + defer_loading: None, allow_private_network: true, auth_policy: api::McpServerAuthPolicy::None, credential: None, @@ -2224,8 +2225,9 @@ async fn run_mixed_batch_live_client( }), mcp: Some(api::McpFeature { version: api::CURRENT_FEATURE_VERSION, - servers: vec![api::McpServerLink { + servers: vec![api::McpServerAttachment { server_id: server_id.clone(), + tools: None, }], }), ..FeaturesConfig::default() @@ -2233,7 +2235,6 @@ async fn run_mixed_batch_live_client( ..SessionConfig::default() }), profile: None, - environment: None, delete_after_close_ms: None, }) .await?; diff --git a/crates/temporal-server/tests/preprocess_live.rs b/crates/temporal-server/tests/preprocess_live.rs index fa9b2a2a..52f9bfa9 100644 --- a/crates/temporal-server/tests/preprocess_live.rs +++ b/crates/temporal-server/tests/preprocess_live.rs @@ -100,7 +100,6 @@ async fn run_audio_preprocess_live_client( ..SessionConfig::default() }), profile: None, - environment: None, delete_after_close_ms: None, }) .await?; @@ -216,7 +215,6 @@ async fn run_transcodable_audio_preprocess_live_client( ..SessionConfig::default() }), profile: None, - environment: None, delete_after_close_ms: None, }) .await?; diff --git a/crates/temporal-server/tests/profiles_live.rs b/crates/temporal-server/tests/profiles_live.rs index f72a367e..ed2d3392 100644 --- a/crates/temporal-server/tests/profiles_live.rs +++ b/crates/temporal-server/tests/profiles_live.rs @@ -1,5 +1,4 @@ -//! Live coverage for profile CRUD, application, and profile-provisioned -//! environments. +//! Live coverage for profile CRUD, application, and independent environments. mod support; @@ -33,19 +32,18 @@ async fn temporal_live_profiles_create_start_and_apply_idempotently() -> anyhow: #[tokio::test(flavor = "current_thread")] #[ignore = "requires ./dev.sh infra or compatible Temporal + Postgres env"] -async fn temporal_live_profile_provisions_environment_for_session() -> anyhow::Result<()> { +async fn temporal_live_profile_selection_leaves_environment_lifecycle_independent() +-> anyhow::Result<()> { let _lock = LIVE_TEST_LOCK.lock().await; let _ = dotenvy::dotenv(); require_storage_live_env()?; let activities = fake_worker_activities().await?; - run_with_live_worker(activities, run_profile_provision_live_client).await + run_with_live_worker(activities, run_profile_environment_selection_live_client).await } -/// A `provision` profile creates one environment for the session it -/// starts, activates it while it is still provisioning, converges on retries -/// and repeated applies, and closes it with the session (or retains it). -async fn run_profile_provision_live_client( +/// Closing and deleting a session never closes its selected environment. +async fn run_profile_environment_selection_live_client( client: Client, task_queue: String, session_id: SessionId, @@ -68,7 +66,7 @@ async fn run_profile_provision_live_client( let suffix = uuid::Uuid::new_v4().simple().to_string(); let provider_id = format!("fake-profile-{suffix}"); let binding_id = format!("binding-profile-{suffix}"); - let profile_id = ProfileId::new(format!("live_provision_{suffix}")); + let profile_id = ProfileId::new(format!("live_selection_{suffix}")); // Register the in-process fake provider and bind it to this universe // directly through the store: the operator API is deployment-scoped and @@ -99,161 +97,74 @@ async fn run_profile_provision_live_client( }) .await?; - let provision_document = |retention: api::ProfileEnvironmentRetention| ProfileDocument { - metadata: Default::default(), - config: Some(SessionConfig { - model: Some(model_to_api(&model)), - features: Some(api::FeaturesConfig { - environments: Some(api::EnvironmentsFeature { - tools: Some(api::EnvironmentToolSurface::Edit), - commands: true, - working_directory: None, - prompts: None, - version: api::CURRENT_FEATURE_VERSION, - providers: None, - registration_keys: None, - selection_tools: false, - jobs: false, - skills: None, - }), - ..api::FeaturesConfig::default() - }), - ..SessionConfig::default() - }), - instructions: None, - environment: Some(api::ProfileEnvironment::Provision { - provider_id: provider_id.clone(), - template_id: "rust-v1".to_owned(), + let environment = api + .create_environment(api::EnvironmentCreateParams { + request_id: format!("independent-{suffix}"), + binding_id: binding_id.clone(), + template_id: "rust-v1".into(), display_name: None, - metadata: BTreeMap::from([("role".to_owned(), "sandbox".to_owned())]), - retention, + metadata: BTreeMap::new(), idle_policy: None, - credentials: Vec::new(), - }), - retention: None, - }; + }) + .await? + .result + .environment; + let environment_id = environment.environment_id.clone(); api.create_profile(ProfileCreateParams { profile: AgentProfileInput { profile_id: profile_id.clone(), - display_name: Some("Live provisioning profile".to_owned()), + display_name: None, description: None, - document: provision_document(api::ProfileEnvironmentRetention::CloseWithSession), + document: ProfileDocument { + config: Some(SessionConfig { + model: Some(model_to_api(&model)), + features: Some(api::FeaturesConfig { + environments: Some(api::EnvironmentsFeature { + version: api::CURRENT_FEATURE_VERSION, + selection: false, + prompts: None, + skills: None, + environments: vec![api::EnvironmentAttachment { + environment_id: Some(environment_id.clone()), + inherit: false, + default: true, + access: api::EnvironmentAccess::Read, + working_directory: None, + }], + }), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }, }, }) .await?; - - // A profile that provisions from an unknown provider fails before any - // session exists. - let rejected = api - .start_session(SessionStartParams { - metadata: Default::default(), - session_id: Some(format!("{}_rejected", session_id.as_str())), - display_name: None, - config: None, - environment: None, - delete_after_close_ms: None, - profile: Some(ProfileSource::Inline { - profile: Box::new(api::InlineAgentProfile { - display_name: None, - description: None, - document: ProfileDocument { - environment: Some(api::ProfileEnvironment::Provision { - provider_id: format!("missing-{suffix}"), - template_id: "rust-v1".to_owned(), - display_name: None, - metadata: BTreeMap::new(), - retention: api::ProfileEnvironmentRetention::CloseWithSession, - idle_policy: None, - credentials: Vec::new(), - }), - ..provision_document(api::ProfileEnvironmentRetention::CloseWithSession) - }, - }), - }), - }) - .await; - assert!( - rejected.is_err(), - "unknown provider must be rejected before start" - ); - assert!( - api.read_session(api::SessionReadParams { - session_id: format!("{}_rejected", session_id.as_str()), - run_limit: None, - }) - .await - .is_err(), - "no session may exist after a pre-start rejection" - ); - - // Start: the environment is created and activated while still - // provisioning (no reconciler has run yet). - let start = |session_id: String| { + let start = || { api.start_session(SessionStartParams { - metadata: Default::default(), - session_id: Some(session_id), + session_id: Some(session_id.to_string()), display_name: None, + metadata: Default::default(), config: None, - environment: None, delete_after_close_ms: None, profile: Some(ProfileSource::Named { profile_id: profile_id.clone(), }), }) }; - start(session_id.as_str().to_owned()).await?; - let started_view = read_session_view(&api, &session_id).await?; - let active = started_view - .active_environment_id - .clone() - .expect("profile provisioning activates the new environment"); - let listed = api - .list_environments(api::EnvironmentListParams { - metadata: Default::default(), - origin_session_id: Some(session_id.as_str().to_owned()), - ..api::EnvironmentListParams::default() - }) - .await? - .result - .environments; - assert_eq!(listed.len(), 1); - let environment = &listed[0]; - assert_eq!(environment.environment_id, active); - // A concurrently running development reconciler may finish provisioning - // before this read; both states preserve the asynchronous start contract. - assert!(matches!( - environment.status, - api::EnvironmentLifecycleStatusView::Provisioning - | api::EnvironmentLifecycleStatusView::Ready - )); - assert_eq!( - environment.request_id, - environments::EnvironmentProvisionRequestId::for_session(&session_id) - .as_str() - .to_owned() - ); - let origin = environment - .origin_session - .as_ref() - .expect("origin session provenance"); - assert_eq!(origin.session_id, session_id.as_str()); - assert_eq!(origin.profile_id.as_ref(), Some(&profile_id)); - assert!(origin.close_with_session); - assert_eq!( - environment.metadata.get("role").map(String::as_str), - Some("sandbox") - ); - - // Retry the start and re-apply the profile: still exactly one environment. - start(session_id.as_str().to_owned()).await?; - let restarted_view = read_session_view(&api, &session_id).await?; + start().await?; + start().await?; assert_eq!( - restarted_view.active_environment_id.as_deref(), - Some(active.as_str()) + read_session_view(&api, &session_id) + .await? + .active_environment_id + .as_deref(), + Some(environment_id.as_str()) ); let applied = api .apply_profile(ProfileApplyParams { - session_id: session_id.as_str().to_owned(), + session_id: session_id.to_string(), profile: ProfileSource::Named { profile_id: profile_id.clone(), }, @@ -261,141 +172,66 @@ async fn run_profile_provision_live_client( expected_tools_revision: None, }) .await?; - assert!(!applied.result.applied.environment_provisioned); assert!(!applied.result.applied.active_environment_changed); - assert_eq!( - api.list_environments(api::EnvironmentListParams { - metadata: Default::default(), - origin_session_id: Some(session_id.as_str().to_owned()), - ..api::EnvironmentListParams::default() - }) - .await? - .result - .environments - .len(), - 1 - ); - - // Drive the reconciler: the fake provider brings the environment to ready. - wait_for_environment_status(&api, &active, api::EnvironmentLifecycleStatusView::Ready).await?; - - // Closing the session closes the environment (eager close, then the - // reconciler finishes it). - api.close_session(api::SessionCloseParams { - session_id: session_id.as_str().to_owned(), - force: false, - }) - .await?; - wait_for_environment_status(&api, &active, api::EnvironmentLifecycleStatusView::Closed).await?; - - // The sweep alone (no eager close) also converges: an environment whose - // origin session is already closed is picked up by reconciliation. - let swept = api - .create_environment(api::EnvironmentCreateParams { - request_id: format!("sweep-{suffix}"), - binding_id: binding_id.clone(), - template_id: "rust-v1".to_owned(), - display_name: None, - metadata: BTreeMap::new(), - idle_policy: None, - }) - .await? - .result - .environment; - sqlx::query( - "UPDATE environments SET origin_session_id = $3, origin_close_with_session = true \ - WHERE universe_id = $1 AND environment_id = $2", - ) - .bind(store.config().universe_id) - .bind(&swept.environment_id) - .bind(session_id.as_str()) - .execute(store.pool()) - .await?; wait_for_environment_status( &api, - &swept.environment_id, - api::EnvironmentLifecycleStatusView::Closed, - ) - .await?; - - // `retain`: the environment outlives its session. - let retained_session = format!("{}_retain", session_id.as_str()); - api.start_session(SessionStartParams { - metadata: Default::default(), - session_id: Some(retained_session.clone()), - display_name: None, - config: None, - environment: None, - delete_after_close_ms: None, - profile: Some(ProfileSource::Inline { - profile: Box::new(api::InlineAgentProfile { - display_name: None, - description: None, - document: provision_document(api::ProfileEnvironmentRetention::Retain), - }), - }), - }) - .await?; - let retained_view = read_session_view(&api, &SessionId::new(retained_session.clone())).await?; - let retained_environment = retained_view - .active_environment_id - .clone() - .expect("retained environment activated"); - wait_for_environment_status( - &api, - &retained_environment, + &environment_id, api::EnvironmentLifecycleStatusView::Ready, ) .await?; api.close_session(api::SessionCloseParams { - session_id: retained_session.clone(), + session_id: session_id.to_string(), force: false, }) .await?; - for _ in 0..5 { - api.reconcile_environments_once().await?; - } - let retained = api - .read_environment(api::EnvironmentReadParams { - environment_id: retained_environment.clone(), + api.reconcile_environments_once().await?; + assert_eq!( + api.read_environment(api::EnvironmentReadParams { + environment_id: environment_id.clone() }) .await? .result - .environment; - assert_eq!(retained.status, api::EnvironmentLifecycleStatusView::Ready); - assert!( - !retained - .origin_session - .as_ref() - .expect("origin") - .close_with_session + .environment + .status, + api::EnvironmentLifecycleStatusView::Ready + ); + api.delete_session(api::SessionDeleteParams { + session_id: session_id.to_string(), + cascade: false, + }) + .await?; + api.reconcile_environments_once().await?; + assert_eq!( + api.read_environment(api::EnvironmentReadParams { + environment_id: environment_id.clone() + }) + .await? + .result + .environment + .status, + api::EnvironmentLifecycleStatusView::Ready ); api.close_environment(api::EnvironmentCloseParams { - environment_id: retained_environment.clone(), + environment_id: environment_id.clone(), }) .await?; wait_for_environment_status( &api, - &retained_environment, + &environment_id, api::EnvironmentLifecycleStatusView::Closed, ) .await?; - - let _ = api - .delete_profile(api::ProfileDeleteParams { - profile_id: profile_id.clone(), - }) - .await; - // Every environment above is closed, so the binding and provider can go. - let _ = store + api.delete_profile(ProfileDeleteParams { profile_id }) + .await?; + store .delete_provider_binding( store.config().universe_id, - &EnvironmentProviderBindingId::new(binding_id.clone()), + &EnvironmentProviderBindingId::new(binding_id), ) - .await; - let _ = store - .delete_provider(&EnvironmentProviderId::new(provider_id.clone())) - .await; + .await?; + store + .delete_provider(&EnvironmentProviderId::new(provider_id)) + .await?; Ok(()) } @@ -423,8 +259,8 @@ async fn run_profiles_live_client( allowed_tools: Some(vec!["lookup_customer".to_owned()]), execution: api::RemoteMcpExecution::Provider, exposure: api::RemoteMcpExposure::Inject, - approval_default: RemoteMcpApprovalPolicy::Never, - defer_loading_default: Some(true), + approval: RemoteMcpApprovalPolicy::Never, + defer_loading: Some(true), allow_private_network: false, auth_policy: api::McpServerAuthPolicy::None, credential: None, @@ -446,8 +282,9 @@ async fn run_profiles_live_client( features: Some(api::FeaturesConfig { mcp: Some(api::McpFeature { version: api::CURRENT_FEATURE_VERSION, - servers: vec![api::McpServerLink { + servers: vec![api::McpServerAttachment { server_id: server_id.clone(), + tools: None, }], }), timers: Some(api::TimersFeature { @@ -460,7 +297,6 @@ async fn run_profiles_live_client( instructions: Some(ProfileInstructions::Text { text: "Use the profile instructions in this live test.".to_owned(), }), - environment: None, retention: None, }, }, @@ -512,7 +348,6 @@ async fn run_profiles_live_client( model: Some(model_to_api(&model)), ..SessionConfig::default() }), - environment: None, delete_after_close_ms: None, profile: Some(ProfileSource::Named { profile_id: profile_id.clone(), diff --git a/crates/temporal-server/tests/runs_live.rs b/crates/temporal-server/tests/runs_live.rs index b8e8cbf2..d4d28994 100644 --- a/crates/temporal-server/tests/runs_live.rs +++ b/crates/temporal-server/tests/runs_live.rs @@ -184,17 +184,17 @@ async fn temporal_live_queued_runs_return_promptly_and_run_in_order() -> anyhow: .await } -fn run_control_session_config(model: &ModelSelection) -> SessionConfig { - // The VFS read-only tool surface gives the fake model a function tool to - // call, so runs have a tool-call turn followed by a final turn. +fn run_control_session_config(model: &ModelSelection, workspace_id: &str) -> SessionConfig { + // A read-only workspace attachment derives the VFS read tools, giving the + // fake model a function tool to call, so runs have a tool-call turn + // followed by a final turn. SessionConfig { model: Some(model_to_api(model)), features: Some(api::FeaturesConfig { vfs: Some(api::VfsFeature { working_directory: None, version: api::CURRENT_FEATURE_VERSION, - workspace_links: Vec::new(), - tools: Some(api::VfsToolSurface::ReadOnly), + workspaces: vec![read_only_workspace(workspace_id)], prompts: None, skills: None, }), @@ -204,6 +204,15 @@ fn run_control_session_config(model: &ModelSelection) -> SessionConfig { } } +fn read_only_workspace(workspace_id: &str) -> api::WorkspaceAttachment { + api::WorkspaceAttachment { + path: "/workspace".to_owned(), + workspace_id: Some(workspace_id.to_owned()), + snapshot_ref: None, + access: api::WorkspaceAccess::Read, + } +} + async fn run_control_api( client: &Client, task_queue: String, @@ -217,7 +226,12 @@ async fn run_control_api( .with_default_model(model.clone()) .build(); let config = if with_tools { - run_control_session_config(&model) + let workspace = api + .create_vfs_workspace(api::VfsWorkspaceCreateParams::default()) + .await? + .result + .workspace; + run_control_session_config(&model, &workspace.workspace_id) } else { SessionConfig { model: Some(model_to_api(&model)), @@ -230,7 +244,6 @@ async fn run_control_api( display_name: None, config: Some(config), profile: None, - environment: None, delete_after_close_ms: None, }) .await?; @@ -717,29 +730,20 @@ async fn run_parallel_tool_batch_live_client( .with_default_model(model.clone()) .build(); - // The VFS tool surface derives parallel-safe function tools (vfs reads), - // so the fake model's three calls form one concurrent per-call group. + // A read-only workspace attachment derives parallel-safe function tools + // (vfs reads), so the fake model's three calls form one concurrent + // per-call group. + let workspace = api + .create_vfs_workspace(api::VfsWorkspaceCreateParams::default()) + .await? + .result + .workspace; api.start_session(SessionStartParams { metadata: Default::default(), session_id: Some(session_id.as_str().to_owned()), display_name: None, - config: Some(SessionConfig { - model: Some(model_to_api(&model)), - features: Some(api::FeaturesConfig { - vfs: Some(api::VfsFeature { - working_directory: None, - version: api::CURRENT_FEATURE_VERSION, - workspace_links: Vec::new(), - tools: Some(api::VfsToolSurface::ReadOnly), - prompts: None, - skills: None, - }), - ..api::FeaturesConfig::default() - }), - ..SessionConfig::default() - }), + config: Some(run_control_session_config(&model, &workspace.workspace_id)), profile: None, - environment: None, delete_after_close_ms: None, }) .await?; @@ -859,7 +863,6 @@ async fn run_transient_llm_retry_live_client( display_name: None, config: None, profile: None, - environment: None, delete_after_close_ms: None, }) .await?; @@ -940,7 +943,6 @@ async fn run_llm_retry_exhaustion_live_client( display_name: None, config: None, profile: None, - environment: None, delete_after_close_ms: None, }) .await?; @@ -1052,8 +1054,7 @@ async fn run_unbounded_hosted_run_live_client( vfs: Some(api::VfsFeature { working_directory: None, version: api::CURRENT_FEATURE_VERSION, - workspace_links: Vec::new(), - tools: None, + workspaces: Vec::new(), prompts: None, skills: None, }), @@ -1062,7 +1063,6 @@ async fn run_unbounded_hosted_run_live_client( ..SessionConfig::default() }), profile: None, - environment: None, delete_after_close_ms: None, }) .await?; diff --git a/crates/temporal-server/tests/runs_live_slow.rs b/crates/temporal-server/tests/runs_live_slow.rs index 3f8ebee0..e0818ccf 100644 --- a/crates/temporal-server/tests/runs_live_slow.rs +++ b/crates/temporal-server/tests/runs_live_slow.rs @@ -73,7 +73,6 @@ async fn run_llm_timeout_live_client( display_name: None, config: None, profile: None, - environment: None, delete_after_close_ms: None, }) .await?; diff --git a/crates/temporal-server/tests/sessions_live.rs b/crates/temporal-server/tests/sessions_live.rs index 00c24ca7..c0870bf6 100644 --- a/crates/temporal-server/tests/sessions_live.rs +++ b/crates/temporal-server/tests/sessions_live.rs @@ -193,7 +193,6 @@ async fn run_checkpoint_and_bounded_reads_live_client( ..SessionConfig::default() }), profile: None, - environment: None, delete_after_close_ms: None, }) .await?; @@ -392,7 +391,6 @@ async fn run_fake_live_client( ..SessionConfig::default() }), profile: None, - environment: None, delete_after_close_ms: None, }) .await?; @@ -414,22 +412,16 @@ async fn run_fake_live_client( enabled_features.vfs = Some(api::VfsFeature { working_directory: None, version: api::CURRENT_FEATURE_VERSION, - workspace_links: Vec::new(), - tools: None, + workspaces: Vec::new(), prompts: None, skills: None, }); enabled_features.environments = Some(api::EnvironmentsFeature { - tools: Some(api::EnvironmentToolSurface::Edit), - commands: true, - working_directory: None, - prompts: None, version: api::CURRENT_FEATURE_VERSION, - providers: None, - registration_keys: None, - selection_tools: false, - jobs: false, + selection: false, + prompts: None, skills: None, + environments: Vec::new(), }); enabled_config.features = Some(enabled_features); let enabled = api @@ -476,7 +468,7 @@ async fn run_fake_live_client( .as_mut() .and_then(|features| features.environments.as_mut()) .expect("environment feature") - .selection_tools = true; + .selection = true; let selection_enabled = api .put_session_config(SessionConfigPutParams { session_id: session_id.as_str().to_owned(), @@ -606,7 +598,6 @@ async fn run_fake_live_client( display_name: None, config: None, profile: None, - environment: None, delete_after_close_ms: None, }) .await?; @@ -679,7 +670,6 @@ async fn run_lifecycle_delete_live_client( display_name: Some("Lifecycle delete live test".to_owned()), config: None, profile: None, - environment: None, delete_after_close_ms: None, }) .await?; @@ -772,7 +762,6 @@ async fn run_continue_as_new_live_client( ..SessionConfig::default() }), profile: None, - environment: None, delete_after_close_ms: None, }) .await?; @@ -902,7 +891,6 @@ async fn run_context_append_live_client( ..SessionConfig::default() }), profile: None, - environment: None, delete_after_close_ms: None, }) .await?; @@ -1114,7 +1102,6 @@ async fn run_admission_failure_live_client( ..SessionConfig::default() }), profile: None, - environment: None, delete_after_close_ms: None, }) .await?; @@ -1126,7 +1113,10 @@ async fn run_admission_failure_live_client( vec![AgentAdmission { // No run is active, so admission rejects this command; the // session must keep serving later admissions regardless. - command: CoreAgentCommand::RequestRunSteering { input: Vec::new() }, + command: CoreAgentCommand::RequestRunSteering { + run_id: engine::RunId::new(1), + input: Vec::new(), + }, correlation_token: None, }], WorkflowSignalOptions::default(), @@ -1236,7 +1226,6 @@ async fn run_openai_live_client( }, }), }), - environment: None, delete_after_close_ms: None, }) .await?; @@ -1316,7 +1305,6 @@ async fn run_builtin_tool_live_client( }, }), }), - environment: None, delete_after_close_ms: None, }) .await?; @@ -1452,7 +1440,6 @@ async fn run_session_metadata_live_client( metadata: job.clone(), config: None, profile: None, - environment: None, delete_after_close_ms: None, }) .await?; @@ -1521,7 +1508,6 @@ async fn run_session_metadata_live_client( metadata: BTreeMap::from([pair("lightspeed.owner", "x")]), config: None, profile: None, - environment: None, delete_after_close_ms: None, }) .await diff --git a/crates/temporal-server/tests/subagents_live.rs b/crates/temporal-server/tests/subagents_live.rs index 17737c44..9c9b601b 100644 --- a/crates/temporal-server/tests/subagents_live.rs +++ b/crates/temporal-server/tests/subagents_live.rs @@ -606,7 +606,6 @@ async fn create_child_profile_with_config( instructions: Some(ProfileInstructions::Text { text: "You are a scripted live sub-agent.".to_owned(), }), - environment: None, retention: None, }, }, @@ -650,7 +649,6 @@ async fn start_subagent_parent_with_features( ..SessionConfig::default() }), profile: None, - environment: None, delete_after_close_ms: None, }) .await?; @@ -749,11 +747,10 @@ async fn run_agent_run_media_live_client( .workspace; let child_config: SessionConfig = serde_json::from_value(serde_json::json!({ "features": {"vfs": { - "tools": "readOnly", - "workspaceLinks": [{ + "workspaces": [{ "path": "/workspace", - "target": {"type": "workspace", "workspaceId": workspace.workspace_id}, - "access": "readOnly" + "workspaceId": workspace.workspace_id, + "access": "read" }] }} }))?; @@ -1175,17 +1172,12 @@ async fn run_agent_run_inherit_environment_live_client( updated_at_ms: 1, }) .await?; - let environments_feature = api::EnvironmentsFeature { - tools: Some(api::EnvironmentToolSurface::Edit), - commands: true, - working_directory: None, - prompts: None, + let environments_feature = |attachment: api::EnvironmentAttachment| api::EnvironmentsFeature { version: api::CURRENT_FEATURE_VERSION, - providers: None, - registration_keys: None, - selection_tools: false, - jobs: false, + selection: false, + prompts: None, skills: None, + environments: vec![attachment], }; // Child profile: inherits whatever environment its parent has active. @@ -1199,7 +1191,13 @@ async fn run_agent_run_inherit_environment_live_client( metadata: Default::default(), config: Some(SessionConfig { features: Some(api::FeaturesConfig { - environments: Some(environments_feature.clone()), + environments: Some(environments_feature(api::EnvironmentAttachment { + environment_id: None, + inherit: true, + default: true, + access: api::EnvironmentAccess::Exec, + working_directory: None, + })), ..api::FeaturesConfig::default() }), ..SessionConfig::default() @@ -1207,20 +1205,30 @@ async fn run_agent_run_inherit_environment_live_client( instructions: Some(ProfileInstructions::Text { text: "You are a scripted live sub-agent.".to_owned(), }), - environment: Some(api::ProfileEnvironment::Inherit {}), retention: None, }, }, }) .await?; - // Parent: provisions its own environment and may run the child. + let independent_environment = api + .create_environment(api::EnvironmentCreateParams { + request_id: format!("inherit-env-{suffix}"), + binding_id: binding_id.clone(), + template_id: "rust-v1".into(), + display_name: None, + metadata: BTreeMap::new(), + idle_policy: None, + }) + .await? + .result + .environment; + // Parent selects an independently created environment and may run the child. api.start_session(SessionStartParams { metadata: Default::default(), session_id: Some(session_id.as_str().to_owned()), display_name: None, config: None, - environment: None, delete_after_close_ms: None, profile: Some(ProfileSource::Inline { profile: Box::new(api::InlineAgentProfile { @@ -1231,21 +1239,20 @@ async fn run_agent_run_inherit_environment_live_client( config: Some(SessionConfig { model: Some(model_to_api(&model)), features: Some(api::FeaturesConfig { - environments: Some(environments_feature), + environments: Some(environments_feature(api::EnvironmentAttachment { + environment_id: Some( + independent_environment.environment_id.clone(), + ), + inherit: false, + default: true, + access: api::EnvironmentAccess::Exec, + working_directory: None, + })), ..subagents_features(&child_profile_id, 16) }), ..SessionConfig::default() }), instructions: None, - environment: Some(api::ProfileEnvironment::Provision { - provider_id: provider_id.clone(), - template_id: "rust-v1".to_owned(), - display_name: None, - metadata: BTreeMap::new(), - retention: api::ProfileEnvironmentRetention::CloseWithSession, - idle_policy: None, - credentials: Vec::new(), - }), retention: None, }, }), @@ -1331,6 +1338,10 @@ async fn run_agent_run_inherit_environment_live_client( let mut all = vec![session_id]; all.extend(children.iter().map(|child| child.session_id.clone())); cleanup_subagent_test(&client, api.as_ref(), child_profile_id, &all).await; + api.close_environment(api::EnvironmentCloseParams { + environment_id: independent_environment.environment_id, + }) + .await?; Ok(()) } diff --git a/crates/temporal-server/tests/tenancy_live.rs b/crates/temporal-server/tests/tenancy_live.rs index b088cd0b..e6db1106 100644 --- a/crates/temporal-server/tests/tenancy_live.rs +++ b/crates/temporal-server/tests/tenancy_live.rs @@ -74,7 +74,6 @@ async fn temporal_live_two_universes_share_one_worker_with_isolation() -> anyhow display_name: None, config: None, profile: None, - environment: None, delete_after_close_ms: None, }) .await?; diff --git a/crates/temporal-server/tests/vfs_transfer_live.rs b/crates/temporal-server/tests/vfs_transfer_live.rs index 9d91e3ff..b94583a3 100644 --- a/crates/temporal-server/tests/vfs_transfer_live.rs +++ b/crates/temporal-server/tests/vfs_transfer_live.rs @@ -87,10 +87,11 @@ async fn temporal_live_vfs_transfers_follow_profile_grants_and_publish_large_fil .await?; // The sourced development URL names the normal gateway. This fixture // binds an ephemeral port, while retaining the deployment's route token. - let gateway_config = temporal_server::environment_gateway::EnvironmentGatewayClientConfig::new( - &base_url, - runtime.environment_gateway().deployment_token(), - ); + let gateway_config = + temporal_server::environments::gateway::EnvironmentGatewayClientConfig::new( + &base_url, + runtime.environment_gateway().deployment_token(), + ); let state = Arc::new(GatewayState::multi( GatewayAuthMode::Single { universe_id }, runtime.clone(), @@ -299,28 +300,34 @@ async fn run_case( ] { std::fs::write(prompts.join(name), body)?; } + // Prompt and skill sourcing needs only read access on both attachments. + let vfs_access = if matches!(mode, "readonly" | "sourcing") { + "read" + } else { + "edit" + }; let mut features = json!({"vfs": { - "workspaceLinks": [{"path":"/workspace","target":{"type":"workspace","workspaceId":workspace.workspace_id},"access":"readWrite"}] + "workspaces": [{"path":"/workspace","workspaceId":workspace.workspace_id,"access":vfs_access}] }}); - if mode != "sourcing" { - features["vfs"]["tools"] = json!(if mode == "readonly" { - "readOnly" - } else { - "edit" - }); - } else { + if mode == "sourcing" { features["vfs"]["skills"] = json!({"roots": ["/workspace"]}); features["vfs"]["prompts"] = json!({}); } if mode != "noenv" { - features["environments"] = if mode == "sourcing" { - json!({}) - } else { - json!({"tools":"edit"}) - }; + let access = if mode == "sourcing" { "read" } else { "edit" }; + features["environments"] = json!({ + "environments": [{"environmentId": environment, "default": true, "access": access}] + }); } if mode == "sourcing" { - features["environments"]["workingDirectory"] = json!(root); + let skills = root.join(".agents/skills/review"); + std::fs::create_dir_all(&skills)?; + std::fs::write( + skills.join("SKILL.md"), + "---\nname: review\ndescription: Review the live fixture.\n---\nReview the files.", + )?; + features["environments"]["skills"] = json!({"roots":[".agents/skills"]}); + features["environments"]["environments"][0]["workingDirectory"] = json!(root); features["environments"]["prompts"] = json!({"roots":[".agents/prompts"]}); } let mut model = temporal_server::default_model_from_env(); @@ -329,7 +336,6 @@ async fn run_case( profile_id: api::ProfileId::new(format!("profile_{session}")), display_name: None, description: None, document: api::ProfileDocument { config: Some(serde_json::from_value(json!({"model": api_projection::model_to_api(&model), "features": features}))?), - environment: (mode != "noenv").then(|| api::ProfileEnvironment::Existing { environment_id: environment.into() }), ..Default::default() }, }}).await?.result.profile; @@ -493,6 +499,7 @@ impl CoreAgentLlm for TransferLlm { if mode == "sourcing" { let mut vfs_prompts = Vec::new(); let mut environment_prompts = Vec::new(); + let mut environment_skills = Vec::new(); for entry in &request.request.context.entries { let key = entry.key.as_ref().map(|key| key.as_str()).unwrap_or(""); if key.starts_with("instructions.100.prompts") { @@ -502,6 +509,13 @@ impl CoreAgentLlm for TransferLlm { .await .unwrap(), ); + } else if key == "runtime.catalog.skills.environment" { + environment_skills.push( + self.blobs + .read_text(&entry.content.content_ref) + .await + .unwrap(), + ); } else if key == "instructions.110.environment" { environment_prompts.push( self.blobs @@ -511,6 +525,12 @@ impl CoreAgentLlm for TransferLlm { ); } } + assert_eq!( + environment_skills.len(), + 1, + "environment skills and prompts must both reach the model" + ); + assert!(environment_skills[0].contains("Review the live fixture.")); assert_eq!(vfs_prompts, vec!["VFS first", "VFS second", "VFS last"]); assert_eq!( environment_prompts, diff --git a/crates/temporal-workflow/src/activities.rs b/crates/temporal-workflow/src/activities.rs index 83767773..a2cba413 100644 --- a/crates/temporal-workflow/src/activities.rs +++ b/crates/temporal-workflow/src/activities.rs @@ -175,6 +175,22 @@ impl WorkflowActivities { unimplemented!("workflow activity definition only") } + #[activity(name = "WorkflowActivities::prepare_session_toolset")] + pub async fn prepare_session_toolset( + _ctx: ActivityContext, + _request: crate::SessionToolsetRequest, + ) -> Result, ActivityError> { + unimplemented!("workflow activity definition only") + } + + #[activity(name = "WorkflowActivities::prepare_session_profile")] + pub async fn prepare_session_profile( + _ctx: ActivityContext, + _request: crate::SessionProfilePreparationRequest, + ) -> Result, ActivityError> { + unimplemented!("workflow activity definition only") + } + #[activity(name = ACTIVITY_RUNTIME_PROJECTION_REFRESH)] pub async fn runtime_projection_refresh( _ctx: ActivityContext, diff --git a/crates/temporal-workflow/src/lib.rs b/crates/temporal-workflow/src/lib.rs index a59ac5b7..441a7997 100644 --- a/crates/temporal-workflow/src/lib.rs +++ b/crates/temporal-workflow/src/lib.rs @@ -3,8 +3,10 @@ mod activities; mod config; mod rehydrate; +mod session_preparation; mod temporal_helpers; mod types; +pub use session_preparation::*; pub mod workflow_contract; mod workflows; diff --git a/crates/temporal-workflow/src/session_preparation.rs b/crates/temporal-workflow/src/session_preparation.rs new file mode 100644 index 00000000..a05de33d --- /dev/null +++ b/crates/temporal-workflow/src/session_preparation.rs @@ -0,0 +1,586 @@ +//! Durable session preparation requests. These carry intent and references, +//! never credentials or provider transport payloads. +use engine::{ + CoreAgentState, SessionConfig, SessionId, ToolName, ToolSpec, WorkflowToolBinding, + WorkflowToolDeclaration, WorkflowToolId, +}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionToolsetSource { + pub config: SessionConfig, + pub config_revision: u64, + pub bindings: BTreeMap, + pub system_binding_ids: BTreeSet, +} + +impl SessionToolsetSource { + pub fn from_state(state: &CoreAgentState) -> Option { + Some(Self { + config: state.lifecycle.config.clone()?, + config_revision: state.lifecycle.config_revision, + bindings: state.workflow_tools.bindings.clone(), + system_binding_ids: state.workflow_tools.system_binding_ids.clone(), + }) + } + pub fn matches(&self, state: &CoreAgentState) -> bool { + Self::from_state(state).as_ref() == Some(self) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionToolsetPreparation { + pub source: SessionToolsetSource, + pub declarations: Vec, + pub tools: BTreeMap, +} + +/// The resolved profile is captured at submission so workflow retries do not +/// reread a mutable named profile. Configuration has already been translated +/// into the engine's provider-neutral document. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionProfileIntent { + pub config: Option, + pub instructions: Option, + /// The environment to activate when the session has none after the + /// configuration is applied: the config's default attachment, or a + /// creation-time override. Never overrides a live selection. + pub environment: Option, +} + +impl SessionProfileIntent { + /// Only prepare a fill candidate when the proposed configuration will + /// leave no active environment. An unused default may be unavailable + /// without blocking an update that preserves the current selection. + pub(crate) fn environment_to_prepare( + &self, + state: &CoreAgentState, + ) -> Option { + let config = self.config.as_ref().or(state.lifecycle.config.as_ref()); + let retains_active = state + .environment + .active_environment_id + .as_ref() + .is_some_and(|id| { + config + .and_then(|config| config.features.environments.as_ref()) + .is_some_and(|environments| environments.is_attached(id.as_str())) + }); + if retains_active { + None + } else { + self.environment.clone() + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionOperationRequest { + pub operation_id: String, + pub submitted_at_ms: u64, + pub operation: SessionOperation, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SessionOperation { + Configure { + config: SessionConfig, + expected_revision: Option, + }, + ApplyProfile { + profile: SessionProfileIntent, + expected_config_revision: Option, + expected_tools_revision: Option, + }, + RefreshContext, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionOperationOutcome { + pub receipt: SessionOperationReceipt, + pub result: Result, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionOperationStatus { + pub outcome: Result, api::AgentApiError>, +} + +/// Compact identity retained after an operation completes. The timestamp is +/// part of the identity: callers must preserve it when retrying the same intent. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionOperationReceipt { + pub operation_id: String, + pub submitted_at_ms: u64, + pub fingerprint: String, +} + +impl SessionOperationRequest { + pub fn receipt(&self) -> Result { + use sha2::{Digest, Sha256}; + Ok(SessionOperationReceipt { + operation_id: self.operation_id.clone(), + submitted_at_ms: self.submitted_at_ms, + fingerprint: hex::encode(Sha256::digest(serde_json::to_vec(self)?)), + }) + } +} + +/// Retain recent results across workflow rollover without retaining full inputs. +/// Eviction retires every request at or before that submission timestamp, so an +/// evicted retry cannot execute again. Callers must reload state before making +/// a new operation after an expired receipt. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionOperationReceipts { + retired_through_ms: Option, + outcomes: BTreeMap, +} + +impl SessionOperationReceipts { + const CAPACITY: usize = 256; + + pub fn lookup( + &self, + receipt: &SessionOperationReceipt, + ) -> Result, api::AgentApiError> { + if self + .retired_through_ms + .is_some_and(|time| receipt.submitted_at_ms <= time) + { + return Err(api::AgentApiError::conflict( + "session preparation receipt expired; reload session state before submitting a new operation", + )); + } + match self.outcomes.get(&receipt.operation_id) { + Some(outcome) if outcome.receipt != *receipt => Err(api::AgentApiError::conflict( + "operation ID was reused with different intent", + )), + outcome => Ok(outcome.cloned()), + } + } + + pub(crate) fn insert(&mut self, outcome: SessionOperationOutcome) { + self.outcomes + .insert(outcome.receipt.operation_id.clone(), outcome); + if self.outcomes.len() > Self::CAPACITY { + let oldest = self + .outcomes + .values() + .map(|outcome| outcome.receipt.submitted_at_ms) + .min() + .unwrap(); + self.retired_through_ms = Some(oldest); + self.outcomes + .retain(|_, outcome| outcome.receipt.submitted_at_ms > oldest); + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionProfilePreparationRequest { + pub session_id: SessionId, + pub instructions: Option, + /// Candidate to fill an empty active pointer; must be attached in the + /// source configuration and selectable in the registry. + pub environment: Option, + pub source: SessionToolsetSource, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionProfilePreparation { + pub toolset: SessionToolsetPreparation, + pub instructions: BTreeMap, + /// The validated fill candidate; applied only while nothing is active. + pub environment_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionToolsetRequest { + pub source: SessionToolsetSource, + pub validate_configuration: bool, +} + +/// Compute a minimal patch from the currently published tools to an observed desired set. +pub fn session_toolset_patch( + active: &BTreeMap, + desired: &BTreeMap, +) -> engine::ToolPatch { + engine::ToolPatch { + remove: active + .keys() + .filter(|name| !desired.contains_key(*name)) + .cloned() + .collect(), + upsert: desired + .iter() + .filter(|(name, tool)| active.get(*name) != Some(*tool)) + .map(|(_, tool)| tool.clone()) + .collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use engine::BlobRef; + + #[test] + fn profile_preparation_omits_unused_defaults_and_prepares_replacements() { + let mut config = crate::default_session_config(engine::ModelSelection { + api_kind: engine::ProviderApiKind::OpenAiResponses, + provider_id: "openai".into(), + model: "test".into(), + }); + config.features.environments = Some(engine::EnvironmentsFeature { + environments: ["active", "default"] + .into_iter() + .map(|id| engine::EnvironmentAttachment { + environment_id: id.into(), + default: id == "default", + access: engine::EnvironmentAccess::Read, + working_directory: None, + }) + .collect(), + ..Default::default() + }); + let mut state = CoreAgentState::new(); + state.lifecycle.config = Some(config.clone()); + state.environment.active_environment_id = Some(engine::EnvironmentId::new("active")); + let mut profile = SessionProfileIntent { + config: Some(config), + instructions: Some(api::ProfileInstructions::Text { + text: "updated instructions".into(), + }), + environment: Some(engine::EnvironmentId::new("default")), + }; + // The preparation activity receives no candidate, so it cannot try + // to select a closed or missing default when the active item survives. + assert_eq!(profile.environment_to_prepare(&state), None); + let mut empty = state.clone(); + empty.environment.active_environment_id = None; + assert_eq!(profile.environment_to_prepare(&empty), profile.environment); + profile + .config + .as_mut() + .unwrap() + .features + .environments + .as_mut() + .unwrap() + .environments + .retain(|attachment| attachment.default); + assert_eq!(profile.environment_to_prepare(&state), profile.environment); + + profile.config = None; + assert_eq!(profile.environment_to_prepare(&state), None); + profile.environment = None; + assert_eq!(profile.environment_to_prepare(&empty), None); + } + + fn operation(id: usize, submitted_at_ms: u64) -> SessionOperationRequest { + SessionOperationRequest { + operation_id: format!("operation_{id}"), + submitted_at_ms, + operation: SessionOperation::RefreshContext, + } + } + + fn completed(request: &SessionOperationRequest) -> SessionOperationOutcome { + SessionOperationOutcome { + receipt: request.receipt().unwrap(), + result: Ok(api::ProfileApplySummary::default()), + } + } + + #[test] + fn receipts_preserve_success_and_failure_and_reject_changed_intent() { + let mut receipts = SessionOperationReceipts::default(); + let request = operation(1, 1); + let outcome = completed(&request); + assert_eq!(receipts.lookup(&outcome.receipt), Ok(None)); + receipts.insert(outcome.clone()); + assert_eq!(receipts.lookup(&outcome.receipt), Ok(Some(outcome.clone()))); + + let mut changed = request.clone(); + changed.operation = SessionOperation::ApplyProfile { + profile: SessionProfileIntent { + config: None, + instructions: None, + environment: None, + }, + expected_config_revision: None, + expected_tools_revision: None, + }; + assert_eq!( + receipts + .lookup(&changed.receipt().unwrap()) + .unwrap_err() + .kind, + api::AgentApiErrorKind::Conflict + ); + assert_eq!(receipts.lookup(&outcome.receipt), Ok(Some(outcome))); + + let mut failed = completed(&operation(2, 2)); + failed.result = Err(api::AgentApiError::rejected("configuration unavailable")); + receipts.insert(failed.clone()); + assert_eq!(receipts.lookup(&failed.receipt), Ok(Some(failed))); + } + + #[test] + fn receipt_eviction_remains_bounded_and_rejects_old_retries_after_rollover() { + let mut receipts = SessionOperationReceipts::default(); + for id in 0..1000 { + let outcome = completed(&operation(id, id as u64)); + assert_eq!(receipts.lookup(&outcome.receipt), Ok(None)); + receipts.insert(outcome); + assert!(receipts.outcomes.len() <= SessionOperationReceipts::CAPACITY); + } + let mut continuation = crate::AgentSessionContinuationState::v1(Vec::new()); + continuation.operation_outcomes = receipts; + let restored: crate::AgentSessionContinuationState = + serde_json::from_slice(&serde_json::to_vec(&continuation).unwrap()).unwrap(); + let receipts = restored.operation_outcomes; + assert_eq!(receipts.outcomes.len(), SessionOperationReceipts::CAPACITY); + let old = operation(0, 0).receipt().unwrap(); + assert_eq!( + receipts.lookup(&old).unwrap_err().kind, + api::AgentApiErrorKind::Conflict + ); + let recent = completed(&operation(999, 999)); + assert_eq!(receipts.lookup(&recent.receipt), Ok(Some(recent))); + // A delayed request inside the retired time range must also not execute. + assert_eq!( + receipts + .lookup(&operation(2000, 1).receipt().unwrap()) + .unwrap_err() + .kind, + api::AgentApiErrorKind::Conflict + ); + } + + #[test] + fn eviction_retires_equal_timestamps_together() { + let mut receipts = SessionOperationReceipts::default(); + for id in 0..=SessionOperationReceipts::CAPACITY { + receipts.insert(completed(&operation(id, 10))); + } + assert!(receipts.outcomes.is_empty()); + assert_eq!( + receipts + .lookup(&operation(0, 10).receipt().unwrap()) + .unwrap_err() + .kind, + api::AgentApiErrorKind::Conflict + ); + assert_eq!( + receipts.lookup(&operation(1000, 11).receipt().unwrap()), + Ok(None) + ); + } + + #[test] + fn receipts_do_not_retain_profile_payloads() { + let mut request = operation(1, 1); + request.operation = SessionOperation::ApplyProfile { + profile: SessionProfileIntent { + config: None, + instructions: Some(api::ProfileInstructions::Text { + text: "large profile content".repeat(10_000), + }), + environment: None, + }, + expected_config_revision: None, + expected_tools_revision: None, + }; + let mut receipts = SessionOperationReceipts::default(); + receipts.insert(completed(&request)); + let encoded = serde_json::to_string(&receipts).unwrap(); + assert!(encoded.len() < 1024); + assert!(!encoded.contains("large profile content")); + assert_eq!( + request.receipt().unwrap(), + request.clone().receipt().unwrap() + ); + } + + #[test] + fn toolset_reconcile_patch_preserves_declared_remote_mcp_tools() { + let remote_tool_name = ToolName::new("mcp_crm"); + let old_tool_name = ToolName::new("old_tool"); + let new_tool_name = ToolName::new("new_tool"); + let active = BTreeMap::from([ + ( + remote_tool_name.clone(), + test_remote_mcp_tool(remote_tool_name.clone()), + ), + ( + old_tool_name.clone(), + test_function_tool(old_tool_name.clone()), + ), + ]); + let mut desired = BTreeMap::from([( + new_tool_name.clone(), + test_function_tool(new_tool_name.clone()), + )]); + let desired_mcp = BTreeMap::from([( + remote_tool_name.clone(), + test_remote_mcp_tool(remote_tool_name.clone()), + )]); + + desired.extend(desired_mcp); + let patch = session_toolset_patch(&active, &desired); + let tools = patch.apply_to(&active).expect("apply reconcile patch"); + + assert!(tools.contains_key(&remote_tool_name)); + assert!(!tools.contains_key(&old_tool_name)); + assert!(tools.contains_key(&new_tool_name)); + assert_eq!(patch.upsert.len(), 1); + assert_eq!(patch.remove, vec![old_tool_name]); + assert!(session_toolset_patch(&tools, &desired).is_empty()); + } + + #[test] + fn toolset_reconcile_patch_removes_undeclared_remote_mcp_tools() { + let remote_tool_name = ToolName::new("mcp_crm"); + let active = BTreeMap::from([( + remote_tool_name.clone(), + test_remote_mcp_tool(remote_tool_name.clone()), + )]); + + let patch = session_toolset_patch(&active, &BTreeMap::new()); + let tools = patch.apply_to(&active).expect("apply reconcile patch"); + + assert!(!tools.contains_key(&remote_tool_name)); + } + + #[test] + fn toolset_reconcile_patch_tracks_every_mcp_policy_transition() { + let remote_tool_name = ToolName::new("mcp_crm"); + let find_tool_name = ToolName::new("mcp_find_tools"); + let call_tool_name = ToolName::new("mcp_call"); + + let mut inject_all = test_remote_mcp_tool(remote_tool_name.clone()); + let engine::ToolKind::RemoteMcp(spec) = &mut inject_all.kind else { + unreachable!("test helper must produce a remote MCP tool"); + }; + spec.execution = engine::RemoteMcpExecution::Native; + let mut active = BTreeMap::from([(remote_tool_name.clone(), inject_all)]); + + let mut search_selected = test_remote_mcp_tool(remote_tool_name.clone()); + let engine::ToolKind::RemoteMcp(spec) = &mut search_selected.kind else { + unreachable!("test helper must produce a remote MCP tool"); + }; + spec.record_revision = 2; + spec.execution = engine::RemoteMcpExecution::Native; + spec.exposure = engine::RemoteMcpExposure::Search; + spec.allowed_tools = Some(vec!["lookup_customer".to_owned()]); + let desired = BTreeMap::from([ + (remote_tool_name.clone(), search_selected), + ( + find_tool_name.clone(), + test_function_tool(find_tool_name.clone()), + ), + ( + call_tool_name.clone(), + test_function_tool(call_tool_name.clone()), + ), + ]); + active = session_toolset_patch(&active, &desired) + .apply_to(&active) + .expect("switch inject-all to search-selected"); + assert!(active.contains_key(&find_tool_name)); + assert!(active.contains_key(&call_tool_name)); + let engine::ToolKind::RemoteMcp(spec) = &active[&remote_tool_name].kind else { + panic!("expected remote MCP tool"); + }; + assert_eq!(spec.exposure, engine::RemoteMcpExposure::Search); + assert_eq!(spec.allowed_tools, Some(vec!["lookup_customer".to_owned()])); + + let mut search_other_selection = active[&remote_tool_name].clone(); + let engine::ToolKind::RemoteMcp(spec) = &mut search_other_selection.kind else { + unreachable!("expected remote MCP tool"); + }; + spec.record_revision = 3; + spec.allowed_tools = Some(vec!["create_customer".to_owned()]); + let desired = BTreeMap::from([ + (remote_tool_name.clone(), search_other_selection), + (find_tool_name.clone(), active[&find_tool_name].clone()), + (call_tool_name.clone(), active[&call_tool_name].clone()), + ]); + active = session_toolset_patch(&active, &desired) + .apply_to(&active) + .expect("change selected search tools"); + let engine::ToolKind::RemoteMcp(spec) = &active[&remote_tool_name].kind else { + panic!("expected remote MCP tool"); + }; + assert_eq!(spec.allowed_tools, Some(vec!["create_customer".to_owned()])); + + let mut inject_selected = active[&remote_tool_name].clone(); + let engine::ToolKind::RemoteMcp(spec) = &mut inject_selected.kind else { + unreachable!("expected remote MCP tool"); + }; + spec.record_revision = 4; + spec.exposure = engine::RemoteMcpExposure::Inject; + let desired = BTreeMap::from([(remote_tool_name.clone(), inject_selected)]); + active = session_toolset_patch(&active, &desired) + .apply_to(&active) + .expect("switch search to inject-selected"); + assert!(!active.contains_key(&find_tool_name)); + assert!(!active.contains_key(&call_tool_name)); + + let mut inject_all = active[&remote_tool_name].clone(); + let engine::ToolKind::RemoteMcp(spec) = &mut inject_all.kind else { + unreachable!("expected remote MCP tool"); + }; + spec.record_revision = 5; + spec.allowed_tools = None; + let desired = BTreeMap::from([(remote_tool_name.clone(), inject_all)]); + active = session_toolset_patch(&active, &desired) + .apply_to(&active) + .expect("switch inject-selected to inject-all"); + let engine::ToolKind::RemoteMcp(spec) = &active[&remote_tool_name].kind else { + panic!("expected remote MCP tool"); + }; + assert_eq!(spec.exposure, engine::RemoteMcpExposure::Inject); + assert_eq!(spec.allowed_tools, None); + } + + fn test_remote_mcp_tool(tool_name: ToolName) -> engine::ToolSpec { + engine::ToolSpec { + name: tool_name, + execution: Default::default(), + kind: engine::ToolKind::RemoteMcp(engine::RemoteMcpToolSpec { + server_id: "crm".to_owned(), + record_revision: 1, + server_label: "crm".to_owned(), + server_url: "https://crm.example.com/mcp".to_owned(), + description_ref: None, + allowed_tools: None, + execution: engine::RemoteMcpExecution::Provider, + exposure: engine::RemoteMcpExposure::Inject, + approval: engine::RemoteMcpApprovalPolicy::Never, + defer_loading: None, + auth_ref: None, + auth_required: false, + allow_private_network: false, + }), + parallelism: engine::ToolParallelism::ParallelSafe, + } + } + + fn test_function_tool(tool_name: ToolName) -> engine::ToolSpec { + engine::ToolSpec { + name: tool_name, + execution: Default::default(), + kind: engine::ToolKind::Function(engine::FunctionToolSpec { + description_ref: None, + input_schema_ref: BlobRef::from_bytes(b"schema"), + output_schema_ref: None, + strict: Some(true), + provider_options_ref: None, + }), + parallelism: engine::ToolParallelism::Exclusive, + } + } +} diff --git a/crates/temporal-workflow/src/types.rs b/crates/temporal-workflow/src/types.rs index 20fc4689..aac3c03d 100644 --- a/crates/temporal-workflow/src/types.rs +++ b/crates/temporal-workflow/src/types.rs @@ -28,6 +28,8 @@ pub struct AgentSessionArgs { #[serde(default, skip_serializing_if = "Option::is_none")] pub delete_after_close_ms: Option, pub session_config: SessionConfig, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub setup: Option, /// Present only for the trusted managed-session creation path. The /// declaration is validated against `universe_id` and recorded as an /// immutable creation fact on the first append. @@ -56,6 +58,8 @@ pub struct AgentSessionArgs { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentSessionContinuationState { pub version: u32, + pub ready: bool, + pub operation_outcomes: crate::SessionOperationReceipts, #[serde(default)] pub admission_failures: Vec, } @@ -66,6 +70,8 @@ impl AgentSessionContinuationState { pub fn v1(admission_failures: Vec) -> Self { Self { version: Self::VERSION, + ready: false, + operation_outcomes: crate::SessionOperationReceipts::default(), admission_failures, } } @@ -112,6 +118,9 @@ pub struct AgentAdmission { pub struct AgentSessionStatus { pub session_id: String, pub initialized: bool, + pub ready: bool, + #[serde(default)] + pub setup_error: Option, pub pending_admissions: usize, #[serde(default)] pub pending_tool_batch_resumes: usize, @@ -133,6 +142,8 @@ pub struct AgentSessionStatus { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentAdmissionFailure { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preparation_error: Option, pub submission_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub correlation_token: Option, @@ -818,7 +829,7 @@ pub struct AwaitEnvironmentReadyActivityRequest { pub session_id: SessionId, pub environment_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub environment_policy: Option, + pub environment_policy: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -850,7 +861,7 @@ pub struct RuntimeProjectionRefreshActivityRequest { pub active_environment_id: Option, pub session_id: SessionId, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub workspace_links: Vec, + pub workspace_attachments: Vec, pub vfs_catalog_enabled: bool, pub vfs_prompts_enabled: bool, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/temporal-workflow/src/workflows/session/admissions.rs b/crates/temporal-workflow/src/workflows/session/admissions.rs index 95b3910c..9999a56c 100644 --- a/crates/temporal-workflow/src/workflows/session/admissions.rs +++ b/crates/temporal-workflow/src/workflows/session/admissions.rs @@ -1,15 +1,5 @@ use super::*; -pub(super) async fn process_admissions( - ctx: &mut WorkflowContext, - args: &AgentSessionArgs, - admissions: Vec, -) -> anyhow::Result { - let mut drive = drive_from_state(ctx)?; - admit_admissions(ctx, &mut drive, admissions).await?; - drive_until_idle(ctx, args, &mut drive).await -} - /// Admit a batch of client admissions against the live drive, appending the /// resulting events. Used by the outer loop and from inside /// the drive loop at every action boundary and while an activity is in @@ -17,34 +7,160 @@ pub(super) async fn process_admissions( pub(super) async fn admit_admissions( ctx: &mut WorkflowContext, drive: &mut CoreAgentDrive, - admissions: Vec, + admissions: Vec, ) -> anyhow::Result<()> { - for admission in admissions { + let mut admissions = admissions.into_iter(); + while let Some(admission) = admissions.next() { + let mut observed_tools = None; + let admission = match admission { + SessionAdmission::Operation(request) => { + preparation::process_operation(ctx, drive, request).await?; + continue; + } + SessionAdmission::Core(admission) => admission, + SessionAdmission::PreparedRun { admission, result } => { + let admission = *admission; + match result { + Ok(prepared) => observed_tools = Some(prepared), + Err(error) => { + record_admission_failure( + ctx, + preparation::failure( + &admission.command, + admission.correlation_token, + error, + ), + ); + continue; + } + } + admission + } + }; let correlation_token = admission.correlation_token.clone(); let mut command = admission.command; - if command_needs_input_preprocessing(&command) { + if let CoreAgentCommand::ReplaceSessionConfig { + config, + expected_revision, + } = &command + { + if let Err(error) = preparation::execute_operation( + ctx, + drive, + crate::SessionOperation::Configure { + config: config.clone(), + expected_revision: *expected_revision, + }, + ) + .await? + { + record_admission_failure( + ctx, + preparation::failure(&command, correlation_token, error), + ); + } + continue; + } + if observed_tools.is_none() && command_needs_input_preprocessing(&command) { let session_id = drive.session_id().clone(); match preprocess_input_entries(ctx, session_id, command).await? { - RunInputPreprocessResult::Succeeded { command: rewritten } => { - command = *rewritten; - } + RunInputPreprocessResult::Succeeded { command: rewritten } => command = *rewritten, RunInputPreprocessResult::Failed { failure } => { record_admission_failure( ctx, - failure.with_correlation_token(correlation_token.clone()), + failure.with_correlation_token(correlation_token), ); continue; } } } - if should_refresh_runtime_projection_before_admitting(drive.state(), &command) { - refresh_runtime_projection_before_run(ctx, &mut *drive).await?; + let mut deferred_tools = None; + if drive.state().lifecycle.status == CoreAgentStatus::Open + && matches!(command, CoreAgentCommand::RequestRun(_)) + && !preparation::known_submission(drive.state(), &command) + { + if !ctx.state(|state| state.ready) { + let error = ctx + .state(|state| state.setup_error.clone()) + .unwrap_or_else(|| { + api::AgentApiError::rejected("session setup has not completed") + }); + record_admission_failure( + ctx, + preparation::failure(&command, correlation_token, error), + ); + continue; + } + let Some(prepared) = observed_tools else { + preparation::begin_run_preparation( + ctx, + drive, + AgentAdmission { + command, + correlation_token, + }, + ); + let remaining = admissions.collect::>(); + ctx.state_mut(|state| { + state.pending_admissions.splice(0..0, remaining); + }); + break; + }; + let result = async { + if !preparation::preparation_matches( + &prepared, + drive.state(), + ctx.state(|state| state.universe_id).unwrap(), + ) { + return Err(api::AgentApiError::conflict( + "configuration changed during tool preparation", + )); + } + if turn_in_flight(drive.state()) { + deferred_tools = Some(prepared); + } else { + preparation::publish_tools(ctx, drive, prepared).await?; + } + if should_refresh_runtime_projection_before_admitting(drive.state(), &command) { + refresh_runtime_projection_before_run(ctx, drive) + .await + .map_err(|e| api::AgentApiError::internal(e.to_string()))?; + } + Ok::<(), api::AgentApiError>(()) + } + .await; + if let Err(error) = result { + record_admission_failure( + ctx, + preparation::failure(&command, correlation_token, error), + ); + continue; + } } + let prepared_admission = AgentAdmission { + command: command.clone(), + correlation_token: correlation_token.clone(), + }; match admit_and_append_command(ctx, drive, command, correlation_token).await? { - CommandAdmissionResult::Accepted => {} - CommandAdmissionResult::Rejected(failure) => { - record_admission_failure(ctx, failure); + CommandAdmissionResult::Accepted => { + if let Some(prepared) = deferred_tools { + let run_id = drive + .state() + .runs + .queued + .last() + .expect("accepted queued run") + .run_id; + ctx.state_mut(|state| { + state.pending_toolsets.push(preparation::PendingToolset { + prepared, + run_id, + admission: prepared_admission, + }) + }); + } } + CommandAdmissionResult::Rejected(failure) => record_admission_failure(ctx, failure), } } Ok(()) @@ -71,12 +187,19 @@ pub(super) async fn drain_pending_admissions( } let turn_in_flight = turn_in_flight(drive.state()); let admissions = ctx.state_mut(|state| { + if state.run_preparation.is_some() { + let (now, later) = std::mem::take(&mut state.pending_admissions) + .into_iter() + .partition(preparation::admission_can_pass_preparation); + state.pending_admissions = later; + return now; + } if !turn_in_flight { return std::mem::take(&mut state.pending_admissions); } let (now, later): (Vec<_>, Vec<_>) = std::mem::take(&mut state.pending_admissions) .into_iter() - .partition(|admission| admissible_during_turn(&admission.command)); + .partition(|admission| admission.admissible_during_turn()); state.pending_admissions = later; now }); @@ -92,16 +215,22 @@ pub(super) fn has_admissible_admissions(state: &AgentSessionWorkflow) -> bool { if state.core_state.context.pending_compaction { return false; } + if state.run_preparation.is_some() { + return state + .pending_admissions + .iter() + .any(preparation::admission_can_pass_preparation); + } if !turn_in_flight(&state.core_state) { return !state.pending_admissions.is_empty(); } state .pending_admissions .iter() - .any(|admission| admissible_during_turn(&admission.command)) + .any(|admission| admission.admissible_during_turn()) } -fn turn_in_flight(state: &CoreAgentState) -> bool { +pub(super) fn turn_in_flight(state: &CoreAgentState) -> bool { state .runs .active @@ -111,7 +240,7 @@ fn turn_in_flight(state: &CoreAgentState) -> bool { /// Commands that do not move the config/context/toolset revisions an /// in-flight turn was planned against. -fn admissible_during_turn(command: &CoreAgentCommand) -> bool { +pub(super) fn admissible_during_turn(command: &CoreAgentCommand) -> bool { matches!( command, CoreAgentCommand::CancelRun { .. } @@ -256,6 +385,7 @@ pub(super) fn preprocess_failure_to_admission_failure( failure: PreprocessRunInputFailure, ) -> AgentAdmissionFailure { AgentAdmissionFailure { + preparation_error: None, submission_id, correlation_token: None, kind: match failure.kind { @@ -295,12 +425,28 @@ pub(super) fn should_refresh_runtime_projection_before_admitting( && state.runs.queued.is_empty() } -async fn refresh_runtime_projection_before_run( +pub(super) async fn refresh_runtime_projection_before_run( ctx: &mut WorkflowContext, drive: &mut CoreAgentDrive, ) -> anyhow::Result<()> { - let vfs = drive - .state() + let request = runtime_projection_request(drive.session_id(), drive.state()); + let commands = prepare_runtime_projection(ctx, drive, request).await?; + for command in commands { + match admit_and_append_command(ctx, drive, command, None).await? { + CommandAdmissionResult::Accepted => {} + CommandAdmissionResult::Rejected(failure) => { + anyhow::bail!("run context refresh command rejected: {}", failure.message) + } + } + } + Ok(()) +} + +pub(super) fn runtime_projection_request( + session_id: &SessionId, + state: &CoreAgentState, +) -> RuntimeProjectionRefreshActivityRequest { + let vfs = state .lifecycle .config .as_ref() @@ -311,51 +457,48 @@ async fn refresh_runtime_projection_before_run( .and_then(|vfs| vfs.prompts.as_ref()) .and_then(|prompts| prompts.roots.clone()); let vfs_skills = vfs.and_then(|vfs| vfs.skills.clone()); - let result = ctx - .start_activity( - WorkflowActivities::runtime_projection_refresh, - RuntimeProjectionRefreshActivityRequest { - environments: drive - .state() - .lifecycle - .config - .as_ref() - .and_then(|config| config.features.environments.clone()), - active_environment_id: drive.state().environment.active_environment_id.clone(), - session_id: drive.session_id().clone(), - workspace_links: vfs - .map(|vfs| vfs.workspace_links.clone()) - .unwrap_or_default(), - vfs_catalog_enabled, - vfs_prompts_enabled, - vfs_prompt_roots, - active_instruction_inputs: active_instruction_inputs(drive.state()), - vfs_skills, - active_catalogs: engine::current_catalog_inputs(drive.state()), - subagents: drive - .state() - .lifecycle - .config - .as_ref() - .and_then(|config| config.features.subagents.clone()), - }, - activity_options(), - ) - .await + RuntimeProjectionRefreshActivityRequest { + environments: state + .lifecycle + .config + .as_ref() + .and_then(|config| config.features.environments.clone()), + active_environment_id: state.environment.active_environment_id.clone(), + session_id: session_id.clone(), + workspace_attachments: vfs.map(|vfs| vfs.workspaces.clone()).unwrap_or_default(), + vfs_catalog_enabled, + vfs_prompts_enabled, + vfs_prompt_roots, + active_instruction_inputs: active_instruction_inputs(state), + vfs_skills, + active_catalogs: engine::current_catalog_inputs(state), + subagents: state + .lifecycle + .config + .as_ref() + .and_then(|config| config.features.subagents.clone()), + } +} + +pub(super) async fn prepare_runtime_projection( + ctx: &mut WorkflowContext, + drive: &mut CoreAgentDrive, + request: RuntimeProjectionRefreshActivityRequest, +) -> anyhow::Result> { + let activity_ctx = ctx.clone(); + let activity = activity_ctx.start_activity( + WorkflowActivities::runtime_projection_refresh, + request, + preparation::activity_options(), + ); + let result = preparation::await_activity(ctx, drive, activity) + .await? .map_err(|error| anyhow::anyhow!("{error}"))?; - for command in result.commands { - match admit_and_append_command(ctx, drive, command, None).await? { - CommandAdmissionResult::Accepted => {} - CommandAdmissionResult::Rejected(failure) => { - anyhow::bail!("run context refresh command rejected: {}", failure.message) - } - } - } - Ok(()) + Ok(result.commands) } -fn active_instruction_inputs( +pub(super) fn active_instruction_inputs( state: &CoreAgentState, ) -> BTreeMap { state diff --git a/crates/temporal-workflow/src/workflows/session/bootstrap.rs b/crates/temporal-workflow/src/workflows/session/bootstrap.rs index 52d00e83..8e848ddd 100644 --- a/crates/temporal-workflow/src/workflows/session/bootstrap.rs +++ b/crates/temporal-workflow/src/workflows/session/bootstrap.rs @@ -61,6 +61,8 @@ pub(super) async fn initialize( state.run_submissions = run_submissions; if let Some(continuation) = args.continuation_state.as_ref() { state.admission_failures = continuation.admission_failures.clone(); + state.ready = continuation.ready; + state.operation_outcomes = continuation.operation_outcomes.clone(); } state.initialized = true; state.last_error = None; diff --git a/crates/temporal-workflow/src/workflows/session/drive.rs b/crates/temporal-workflow/src/workflows/session/drive.rs index 81f1dc59..1d92bacd 100644 --- a/crates/temporal-workflow/src/workflows/session/drive.rs +++ b/crates/temporal-workflow/src/workflows/session/drive.rs @@ -36,13 +36,15 @@ pub(super) async fn admit_and_append_command( let submission_id = command_submission_id(&command); if environment_prompt_publication_is_obsolete(drive.state(), &command) || environment_catalog_publication_is_obsolete(drive.state(), &command) + || environment_attachment_catalog_publication_is_obsolete(drive.state(), &command) || vfs_skill_catalog_publication_is_obsolete(drive.state(), &command) { let rejection = engine::CommandRejection::new( engine::CommandRejectionKind::ActiveWork, - "skill catalog observation no longer matches an idle configured source", + "context observation no longer matches the configured source", ); return Ok(CommandAdmissionResult::Rejected(AgentAdmissionFailure { + preparation_error: None, submission_id, correlation_token, kind: AgentAdmissionFailureKind::RejectedCommand, @@ -55,6 +57,7 @@ pub(super) async fn admit_and_append_command( Err(CoreAgentDriveError::Command(CommandError::Rejected(rejection))) => { let message = rejection.to_string(); return Ok(CommandAdmissionResult::Rejected(AgentAdmissionFailure { + preparation_error: None, submission_id, correlation_token, kind: AgentAdmissionFailureKind::RejectedCommand, @@ -118,8 +121,15 @@ pub(super) async fn drive_until_idle( if let Some(outcome) = history_boundary_outcome(ctx, args) { return Ok(outcome); } + if drive.state().lifecycle.status == CoreAgentStatus::Closed { + ctx.state_mut(preparation::abandon_pending_run); + } + preparation::publish_pending_tools(ctx, drive).await?; let mut action = drive.next_action_unbounded(workflow_time_ms(ctx))?; loop { + if preparation::publish_pending_tools(ctx, drive).await? { + action = drive.next_action_unbounded(workflow_time_ms(ctx))?; + } // Client admissions (cancel, steer, queue, context edits) land // at every action boundary against the live drive, and the plan is // recomputed so a cancel stops the next turn/batch from starting and @@ -262,6 +272,11 @@ fn history_boundary_outcome( // the marker and active-run rollover becomes eligible. return None; } + // Pending preparation may need the current turn to finish before it can + // publish. Let the driver reach that boundary instead of yielding forever. + if ctx.state(|state| state.run_preparation.is_some() || !state.pending_toolsets.is_empty()) { + return None; + } history_boundary_outcome_for( wait_loop::history_rollover_due(ctx, args), ctx.state(wait_loop::workflow_state_allows_continue_as_new), @@ -345,6 +360,10 @@ pub(super) async fn append_events( state.head = appended.head; state.execution_has_rollover_checkpoint = true; state.last_error = None; + if state.core_state.lifecycle.status == CoreAgentStatus::Closed { + preparation::abandon_pending_run(state); + state.pending_toolsets.clear(); + } Ok(()) })?; // Invalidation uses only recorded source identity. It performs no discovery, @@ -355,6 +374,9 @@ pub(super) async fn append_events( if let Some(command) = invalid_environment_catalog_command(drive.state()) { Box::pin(append_command(ctx, drive, command)).await?; } + if let Some(command) = invalid_environment_attachment_catalog_command(drive.state()) { + Box::pin(append_command(ctx, drive, command)).await?; + } if let Some(command) = invalid_vfs_skill_catalog_command(drive.state()) { Box::pin(append_command(ctx, drive, command)).await?; } @@ -410,7 +432,7 @@ async fn queue_detached_promise_followups( // ordinary run; the submission id is derived from the promise so a // replayed follow-up is a no-op. ctx.state_mut(|state| { - state.pending_admissions.push(AgentAdmission { + state.queue_admission(AgentAdmission { command: CoreAgentCommand::RequestRun(engine::RunRequestCommand { notify_on_terminal: Vec::new(), submission_id: Some(submission_id), @@ -618,7 +640,7 @@ pub(super) fn invalid_environment_prompt_command( }) } -fn environment_prompt_publication_is_obsolete( +pub(super) fn environment_prompt_publication_is_obsolete( state: &CoreAgentState, command: &CoreAgentCommand, ) -> bool { @@ -653,6 +675,48 @@ fn environment_prompt_publication_is_obsolete( .is_none_or(|current| current.content != entry.content)) } +const ENVIRONMENT_ATTACHMENT_CATALOG_KEY: &str = "runtime.catalog.environments"; + +fn environment_attachment_catalog_matches(state: &CoreAgentState, origin: Option<&str>) -> bool { + state + .lifecycle + .config + .as_ref() + .is_some_and(|config| config.features.environments.is_some()) + && origin.and_then(|origin| origin.strip_prefix("runtime.environments:")) + == Some( + state + .environment + .active_environment_id + .as_ref() + .map_or("", |id| id.as_str()), + ) +} + +/// Drop the attachment catalog as soon as its recorded selection is stale. +/// A later runtime projection rebuilds it; switching performs no discovery. +pub(super) fn invalid_environment_attachment_catalog_command( + state: &CoreAgentState, +) -> Option { + let key = ContextEntryKey::new(ENVIRONMENT_ATTACHMENT_CATALOG_KEY); + let entry = engine::current_context_entry(state, &key)?; + (state.lifecycle.status == CoreAgentStatus::Open + && !environment_attachment_catalog_matches(state, entry.origin.as_deref())) + .then_some(CoreAgentCommand::RemoveContext { + expected_revision: None, + key, + }) +} + +pub(super) fn environment_attachment_catalog_publication_is_obsolete( + state: &CoreAgentState, + command: &CoreAgentCommand, +) -> bool { + matches!(command, CoreAgentCommand::UpsertContext { key, entry, .. } + if key.as_str() == ENVIRONMENT_ATTACHMENT_CATALOG_KEY + && !environment_attachment_catalog_matches(state, entry.origin.as_deref())) +} + pub(super) fn invalid_environment_catalog_command( state: &CoreAgentState, ) -> Option { diff --git a/crates/temporal-workflow/src/workflows/session/mod.rs b/crates/temporal-workflow/src/workflows/session/mod.rs index b2e31bca..f3609d2b 100644 --- a/crates/temporal-workflow/src/workflows/session/mod.rs +++ b/crates/temporal-workflow/src/workflows/session/mod.rs @@ -7,7 +7,10 @@ mod control; mod drive; mod errors; mod observability; +mod preparation; +mod preparation_candidate; mod promise_sources; +use preparation::SessionAdmission; mod session_state; #[cfg(test)] mod tests; @@ -51,7 +54,6 @@ use crate::{ }; use activity_calls::{call_context_compact, call_llm_generate, call_tool_prepare_promise_controls}; -use admissions::process_admissions; use bootstrap::initialize; use clock::workflow_time_ms; use drive::{ @@ -70,7 +72,13 @@ pub struct AgentSessionWorkflow { initialized: bool, core_state: CoreAgentState, head: Option, - pending_admissions: Vec, + pending_admissions: Vec, + ready: bool, + setup_requested: bool, + setup_error: Option, + operation_outcomes: crate::SessionOperationReceipts, + pending_toolsets: Vec, + run_preparation: Option, pending_tool_batch_resumes: Vec, pending_emissions: Vec, pending_source_resolutions: Vec, @@ -97,6 +105,12 @@ impl Default for AgentSessionWorkflow { core_state: CoreAgentState::new(), head: None, pending_admissions: Vec::new(), + ready: false, + setup_requested: true, + setup_error: None, + operation_outcomes: crate::SessionOperationReceipts::default(), + pending_toolsets: Vec::new(), + run_preparation: None, pending_tool_batch_resumes: Vec::new(), pending_emissions: Vec::new(), pending_source_resolutions: Vec::new(), @@ -128,102 +142,100 @@ impl AgentSessionWorkflow { return Err(anyhow::anyhow!("{error}").into()); } - loop { - if workflow_state_should_complete(ctx) { - return Ok(()); - } - reconcile_cancelling_watchdog(ctx); - promise_sources::reconcile_polls(ctx); - wait_for_workflow_work(ctx).await; - if let Err(error) = flush_pending_emissions(ctx).await { - record_error(ctx, &error, "pending_emission"); - return Err(anyhow::anyhow!("{error}").into()); - } - if let Err(error) = promise_sources::process_pending_source_resolutions(ctx).await { - record_error(ctx, &error, "promise_source_resolution"); - return Err(anyhow::anyhow!("{error}").into()); - } - if let Err(error) = workflow_starts::process_pending_starts(ctx).await { - record_error(ctx, &error, "workflow_start"); - return Err(anyhow::anyhow!("{error}").into()); - } - if let Err(error) = promise_sources::flush_pending_promise_cancellations(ctx).await { - record_error(ctx, &error, "promise_cancellation"); - return Err(anyhow::anyhow!("{error}").into()); - } - if let Err(error) = workflow_starts::process_execution_cancels(ctx).await { - record_error(ctx, &error, "workflow_execution_cancel"); - return Err(anyhow::anyhow!("{error}").into()); - } - match process_cancelling_watchdog(ctx, &args).await { - Ok(DriveOutcome::ContinueAsNew) => { - return observability::request_continue_as_new(ctx, &args); + let preparation_ctx = ctx.clone(); + let preparation = preparation::run_preparation_loop(preparation_ctx).fuse(); + let session = async { + loop { + preparation::prepare_initial_session(ctx, &args).await?; + if workflow_state_should_complete(ctx) { + return Ok(()); } - Ok(DriveOutcome::Idle | DriveOutcome::YieldForWorkflowWork) => {} - Err(error) => { - record_error(ctx, &error, "cancellation_watchdog"); + reconcile_cancelling_watchdog(ctx); + promise_sources::reconcile_polls(ctx); + wait_for_workflow_work(ctx).await; + if let Err(error) = flush_pending_emissions(ctx).await { + record_error(ctx, &error, "pending_emission"); return Err(anyhow::anyhow!("{error}").into()); } - } - if let Err(error) = awaits::process_satisfied_await(ctx).await { - record_error(ctx, &error, "await_resolution"); - return Err(anyhow::anyhow!("{error}").into()); - } - promise_sources::process_due_promise_deadlines(ctx); - if let Err(error) = promise_sources::process_due(ctx).await { - record_error(ctx, &error, "promise_source_poll"); - return Err(anyhow::anyhow!("{error}").into()); - } - match process_pending_tool_batch_resumes(ctx, &args).await { - Ok(DriveOutcome::ContinueAsNew) => { - return observability::request_continue_as_new(ctx, &args); + if let Err(error) = promise_sources::process_pending_source_resolutions(ctx).await { + record_error(ctx, &error, "promise_source_resolution"); + return Err(anyhow::anyhow!("{error}").into()); } - Ok(DriveOutcome::Idle | DriveOutcome::YieldForWorkflowWork) => {} - Err(error) => { - record_error(ctx, &error, "tool_batch_resume"); + if let Err(error) = workflow_starts::process_pending_starts(ctx).await { + record_error(ctx, &error, "workflow_start"); return Err(anyhow::anyhow!("{error}").into()); } - } - let admissions = ctx.state_mut(|state| std::mem::take(&mut state.pending_admissions)); - if !admissions.is_empty() { - match process_admissions(ctx, &args, admissions).await { + if let Err(error) = promise_sources::flush_pending_promise_cancellations(ctx).await + { + record_error(ctx, &error, "promise_cancellation"); + return Err(anyhow::anyhow!("{error}").into()); + } + if let Err(error) = workflow_starts::process_execution_cancels(ctx).await { + record_error(ctx, &error, "workflow_execution_cancel"); + return Err(anyhow::anyhow!("{error}").into()); + } + match process_cancelling_watchdog(ctx, &args).await { Ok(DriveOutcome::ContinueAsNew) => { return observability::request_continue_as_new(ctx, &args); } Ok(DriveOutcome::Idle | DriveOutcome::YieldForWorkflowWork) => {} Err(error) => { - record_error(ctx, &error, "admission"); + record_error(ctx, &error, "cancellation_watchdog"); return Err(anyhow::anyhow!("{error}").into()); } } - } - if wait_loop::workflow_state_needs_core_drive(ctx) { - let mut drive = match drive_from_state(ctx) { - Ok(drive) => drive, - Err(error) => { - record_error(ctx, &error, "drive_rehydrate"); - return Err(anyhow::anyhow!("{error}").into()); - } - }; - match drive_until_idle(ctx, &args, &mut drive).await { + if let Err(error) = awaits::process_satisfied_await(ctx).await { + record_error(ctx, &error, "await_resolution"); + return Err(anyhow::anyhow!("{error}").into()); + } + promise_sources::process_due_promise_deadlines(ctx); + if let Err(error) = promise_sources::process_due(ctx).await { + record_error(ctx, &error, "promise_source_poll"); + return Err(anyhow::anyhow!("{error}").into()); + } + match process_pending_tool_batch_resumes(ctx, &args).await { Ok(DriveOutcome::ContinueAsNew) => { return observability::request_continue_as_new(ctx, &args); } Ok(DriveOutcome::Idle | DriveOutcome::YieldForWorkflowWork) => {} Err(error) => { - record_error(ctx, &error, "core_drive"); + record_error(ctx, &error, "tool_batch_resume"); return Err(anyhow::anyhow!("{error}").into()); } } + let mut admission_drive = drive_from_state(ctx)?; + admissions::drain_pending_admissions(ctx, &mut admission_drive).await?; + if wait_loop::workflow_state_needs_core_drive(ctx) { + let mut drive = match drive_from_state(ctx) { + Ok(drive) => drive, + Err(error) => { + record_error(ctx, &error, "drive_rehydrate"); + return Err(anyhow::anyhow!("{error}").into()); + } + }; + match drive_until_idle(ctx, &args, &mut drive).await { + Ok(DriveOutcome::ContinueAsNew) => { + return observability::request_continue_as_new(ctx, &args); + } + Ok(DriveOutcome::Idle | DriveOutcome::YieldForWorkflowWork) => {} + Err(error) => { + record_error(ctx, &error, "core_drive"); + return Err(anyhow::anyhow!("{error}").into()); + } + } + } + if workflow_state_should_complete(ctx) { + return Ok(()); + } + if can_continue_as_new(ctx, &args) { + return observability::request_continue_as_new(ctx, &args); + } + observability::observe_rollover_delay(ctx, &args); } - if workflow_state_should_complete(ctx) { - return Ok(()); - } - if can_continue_as_new(ctx, &args) { - return observability::request_continue_as_new(ctx, &args); - } - observability::observe_rollover_delay(ctx, &args); } + .fuse(); + pin_mut!(session, preparation); + futures::select_biased! { result = session => result, _ = preparation => unreachable!() } } /// Queues a batch of admissions atomically: entries in one signal are @@ -240,6 +252,35 @@ impl AgentSessionWorkflow { } } + #[signal(name = "prepare_session")] + pub fn prepare_session( + &mut self, + _ctx: &mut SyncWorkflowContext, + request: crate::SessionOperationRequest, + ) { + self.pending_admissions + .push(SessionAdmission::Operation(request)); + } + + #[signal(name = "retry_setup")] + pub fn retry_setup(&mut self, _ctx: &mut SyncWorkflowContext) { + if !self.ready { + self.setup_error = None; + self.setup_requested = true; + } + } + + #[query(name = "operation_outcome")] + pub fn operation_outcome( + &self, + _ctx: &WorkflowContextView, + receipt: crate::SessionOperationReceipt, + ) -> crate::SessionOperationStatus { + crate::SessionOperationStatus { + outcome: self.operation_outcomes.lookup(&receipt), + } + } + /// Fixed inbound funnel for cross-workflow facts. Promise-bearing /// emissions become ordinary `ResolvePromise` admissions, preserving the /// engine's idempotent first-writer-wins semantics. @@ -271,8 +312,11 @@ fn continuation_args( ) -> AgentSessionArgs { let mut next = args.clone(); next.legacy_max_steps_per_input = None; - next.continuation_state = Some( - ctx.state(|state| AgentSessionContinuationState::v1(state.admission_failures.clone())), - ); + next.continuation_state = Some(ctx.state(|state| { + let mut continuation = AgentSessionContinuationState::v1(state.admission_failures.clone()); + continuation.ready = state.ready; + continuation.operation_outcomes = state.operation_outcomes.clone(); + continuation + })); next } diff --git a/crates/temporal-workflow/src/workflows/session/observability.rs b/crates/temporal-workflow/src/workflows/session/observability.rs index 842ea5f2..ff6ba4f3 100644 --- a/crates/temporal-workflow/src/workflows/session/observability.rs +++ b/crates/temporal-workflow/src/workflows/session/observability.rs @@ -19,6 +19,7 @@ impl RolloverReason { pub(super) struct RolloverBlockers { pub awaiting_safe_checkpoint: bool, pub pending_admissions: usize, + pub pending_preparations: usize, pub pending_tool_batch_resumes: usize, pub pending_emissions: usize, pub pending_source_resolutions: usize, @@ -32,6 +33,9 @@ impl RolloverBlockers { Self { awaiting_safe_checkpoint, pending_admissions: state.pending_admissions.len(), + pending_preparations: usize::from(state.run_preparation.is_some()) + + state.pending_toolsets.len() + + usize::from(!state.ready), pending_tool_batch_resumes: state.pending_tool_batch_resumes.len(), pending_emissions: state.pending_emissions.len(), pending_source_resolutions: state.pending_source_resolutions.len(), @@ -44,6 +48,7 @@ impl RolloverBlockers { fn is_empty(self) -> bool { !self.awaiting_safe_checkpoint && self.pending_admissions == 0 + && self.pending_preparations == 0 && self.pending_tool_batch_resumes == 0 && self.pending_emissions == 0 && self.pending_source_resolutions == 0 @@ -115,6 +120,7 @@ pub(super) fn observe_rollover_delay( history_threshold = rollover_threshold(args), awaiting_safe_checkpoint = blockers.awaiting_safe_checkpoint, pending_admissions = blockers.pending_admissions, + pending_preparations = blockers.pending_preparations, pending_tool_batch_resumes = blockers.pending_tool_batch_resumes, pending_emissions = blockers.pending_emissions, pending_source_resolutions = blockers.pending_source_resolutions, @@ -275,7 +281,7 @@ mod tests { #[test] fn rollover_blockers_report_transient_workflow_state() { let mut state = AgentSessionWorkflow::default(); - state.pending_admissions.push(AgentAdmission { + state.queue_admission(AgentAdmission { command: CoreAgentCommand::CloseSession { force: false }, correlation_token: None, }); diff --git a/crates/temporal-workflow/src/workflows/session/preparation.rs b/crates/temporal-workflow/src/workflows/session/preparation.rs new file mode 100644 index 00000000..1e576fa8 --- /dev/null +++ b/crates/temporal-workflow/src/workflows/session/preparation.rs @@ -0,0 +1,794 @@ +//! Session preparation is serialized with admissions. Only run controls may +//! pass an outstanding observation; derived tools publish at a safe boundary. +use super::preparation_candidate::PreparationCandidate; +use super::*; +use crate::{ + SessionOperation, SessionOperationOutcome, SessionOperationRequest, SessionToolsetPreparation, + SessionToolsetSource, +}; +use api::{AgentApiError, ProfileApplySummary}; +use temporalio_sdk::CancellableFuture; + +#[derive(Clone, Debug)] +pub(super) enum SessionAdmission { + Core(AgentAdmission), + Operation(SessionOperationRequest), + PreparedRun { + admission: Box, + result: Result, + }, +} + +impl SessionAdmission { + pub(super) fn core(&self) -> Option<&AgentAdmission> { + match self { + Self::Core(admission) => Some(admission), + Self::PreparedRun { admission, .. } => Some(admission), + Self::Operation(_) => None, + } + } + pub(super) fn admissible_during_turn(&self) -> bool { + match self { + Self::Core(admission) => admissions::admissible_during_turn(&admission.command), + // Configuration/profile requests are rejected against active work; + // explicit reads return the already published catalog. + Self::Operation(_) | Self::PreparedRun { .. } => true, + } + } +} + +pub(super) fn activity_options() -> temporalio_sdk::ActivityOptions { + temporalio_sdk::ActivityOptions::with_close_timeouts( + temporalio_sdk::ActivityCloseTimeouts::Both { + start_to_close: Duration::from_secs(30), + schedule_to_close: Duration::from_secs(90), + }, + ) + .retry_policy( + temporalio_common::protos::temporal::api::common::v1::RetryPolicy { + maximum_attempts: 3, + ..Default::default() + }, + ) + .build() +} + +fn control_admission(admission: &SessionAdmission) -> bool { + admission.core().is_some_and(|admission| { + admissions::admissible_during_turn(&admission.command) + && !matches!(admission.command, CoreAgentCommand::RequestRun(_)) + }) +} + +/// Preparation must not make a slow registry/source read a cancellation lock. +/// Other submissions retain their order until the observation is processed. +pub(super) async fn await_activity( + ctx: &mut WorkflowContext, + drive: &mut CoreAgentDrive, + activity: F, +) -> Result +where + F: CancellableFuture, +{ + pin_mut!(activity); + loop { + { + let wait = + ctx.wait_condition(|state| state.pending_admissions.iter().any(control_admission)); + pin_mut!(wait); + futures::select_biased! { + _ = wait => {}, + result = activity => return Ok(result), + } + } + let controls = ctx.state_mut(|state| { + let (now, later) = std::mem::take(&mut state.pending_admissions) + .into_iter() + .partition(control_admission); + state.pending_admissions = later; + now + }); + for control in controls { + let SessionAdmission::Core(admission) = control else { + unreachable!() + }; + match admit_and_append_command( + ctx, + drive, + admission.command, + admission.correlation_token, + ) + .await + .map_err(|error| AgentApiError::internal(error.to_string()))? + { + CommandAdmissionResult::Accepted => {} + CommandAdmissionResult::Rejected(failure) => record_admission_failure(ctx, failure), + } + } + if drive.state().lifecycle.status != CoreAgentStatus::Open { + activity.as_ref().get_ref().cancel(); + let _ = activity.await; + return Err(AgentApiError::rejected("session closed during preparation")); + } + } +} + +pub(super) fn known_submission(state: &CoreAgentState, command: &CoreAgentCommand) -> bool { + let CoreAgentCommand::RequestRun(request) = command else { + return false; + }; + let Some(id) = &request.submission_id else { + return false; + }; + state + .runs + .active + .as_ref() + .is_some_and(|run| run.submission_id.as_ref() == Some(id)) + || state + .runs + .queued + .iter() + .any(|run| run.submission_id.as_ref() == Some(id)) + || state + .runs + .completed + .iter() + .any(|run| run.submission_id.as_ref() == Some(id)) +} + +pub(super) fn failure( + command: &CoreAgentCommand, + correlation_token: Option, + error: AgentApiError, +) -> AgentAdmissionFailure { + AgentAdmissionFailure { + submission_id: drive::command_submission_id(command), + correlation_token, + kind: AgentAdmissionFailureKind::RejectedCommand, + message: error.message.clone(), + rejection: None, + preparation_error: Some(error), + } +} + +pub(super) fn preparation_matches( + prepared: &SessionToolsetPreparation, + state: &CoreAgentState, + universe_id: uuid::Uuid, +) -> bool { + if prepared.source.matches(state) { + return true; + } + // A preceding observation may already have installed these same immutable + // system bindings. This does not invalidate the desired tool observation. + let mut source = prepared.source.clone(); + for declaration in &prepared.declarations { + let Ok(binding) = engine::WorkflowToolBinding::admit( + universe_id, + declaration.definition.clone(), + declaration.target.clone(), + declaration.completion.clone(), + ) else { + return false; + }; + source + .system_binding_ids + .insert(binding.definition.tool_id.clone()); + source + .bindings + .insert(binding.definition.tool_id.clone(), binding); + } + source.matches(state) +} + +pub(super) async fn publish_tools( + ctx: &mut WorkflowContext, + drive: &mut CoreAgentDrive, + prepared: SessionToolsetPreparation, +) -> Result<(), AgentApiError> { + if admissions::turn_in_flight(drive.state()) { + return Err(AgentApiError::conflict( + "cannot publish tools during an in-flight turn", + )); + } + let universe_id = ctx + .state(|state| state.universe_id) + .ok_or_else(|| AgentApiError::internal("session universe is missing"))?; + if !preparation_matches(&prepared, drive.state(), universe_id) { + return Err(AgentApiError::conflict( + "tool preparation no longer matches session configuration", + )); + } + for declaration in prepared.declarations { + apply( + ctx, + drive, + CoreAgentCommand::AdmitSystemWorkflowTool { + session_universe_id: universe_id, + declaration, + }, + ) + .await?; + } + let patch = crate::session_toolset_patch(&drive.state().tooling.tools, &prepared.tools); + if !patch.is_empty() { + apply( + ctx, + drive, + CoreAgentCommand::PatchTools { + expected_revision: Some(drive.state().tooling.revision), + patch, + }, + ) + .await?; + } + Ok(()) +} + +pub(super) struct PendingToolset { + pub prepared: SessionToolsetPreparation, + pub run_id: engine::RunId, + pub admission: AgentAdmission, +} + +pub(super) async fn publish_pending_tools( + ctx: &mut WorkflowContext, + drive: &mut CoreAgentDrive, +) -> anyhow::Result { + if admissions::turn_in_flight(drive.state()) { + return Ok(false); + } + let pending = ctx.state_mut(|state| std::mem::take(&mut state.pending_toolsets)); + let changed = !pending.is_empty(); + for pending in pending { + if drive.state().lifecycle.status != CoreAgentStatus::Open { + break; + } + if let Err(error) = publish_tools(ctx, drive, pending.prepared).await { + // An accepted run must never execute using an obsolete observation. + // Reject this submission and cancel its queued work, keeping the + // session and unrelated runs usable. + record_admission_failure( + ctx, + failure( + &pending.admission.command, + pending.admission.correlation_token, + error, + ), + ); + apply( + ctx, + drive, + CoreAgentCommand::CancelRun { + run_id: pending.run_id, + }, + ) + .await?; + } + } + Ok(changed) +} + +pub(super) async fn apply( + ctx: &mut WorkflowContext, + drive: &mut CoreAgentDrive, + command: CoreAgentCommand, +) -> Result<(), AgentApiError> { + match admit_and_append_command(ctx, drive, command, None) + .await + .map_err(|e| AgentApiError::internal(e.to_string()))? + { + CommandAdmissionResult::Accepted => Ok(()), + CommandAdmissionResult::Rejected(failure) => Err(admission_error(failure)), + } +} + +fn admission_error(failure: AgentAdmissionFailure) -> AgentApiError { + if let Some(error) = failure.preparation_error { + return error; + } + if failure + .rejection + .as_ref() + .is_some_and(|rejection| rejection.kind == engine::CommandRejectionKind::RevisionConflict) + { + AgentApiError::conflict(failure.message) + } else { + AgentApiError::rejected(failure.message) + } +} + +pub(super) async fn process_operation( + ctx: &mut WorkflowContext, + drive: &mut CoreAgentDrive, + request: SessionOperationRequest, +) -> anyhow::Result<()> { + let receipt = request.receipt().expect("session operation serialization"); + // Lookup reports both conflicting IDs and expired retries to the caller; + // neither may execute or create another retained admission failure. + if !matches!( + ctx.state(|state| state.operation_outcomes.lookup(&receipt)), + Ok(None) + ) { + return Ok(()); + } + let result = if !ctx.state(|state| state.ready) { + Err(AgentApiError::rejected("session setup has not completed")) + } else { + execute_operation(ctx, drive, request.operation).await? + }; + ctx.state_mut(|state| { + state + .operation_outcomes + .insert(SessionOperationOutcome { receipt, result }); + }); + Ok(()) +} + +pub(super) async fn execute_operation( + ctx: &mut WorkflowContext, + drive: &mut CoreAgentDrive, + operation: SessionOperation, +) -> anyhow::Result> { + let prepared = prepare_operation(ctx, drive, operation).await; + let (candidate, summary) = match prepared { + Ok(prepared) => prepared, + Err(error) => return Ok(Err(error)), + }; + let request = match candidate.finish(drive) { + Ok(request) => request, + Err(error) => return Ok(Err(error)), + }; + // Storage confirms exact-batch retries after a lost response. A commit + // failure is not evidence of rejection: propagate it without a receipt. + drive::append_events(ctx, drive, request.expected_head, request.events).await?; + Ok(Ok(summary)) +} + +async fn prepare_operation( + ctx: &mut WorkflowContext, + drive: &mut CoreAgentDrive, + operation: SessionOperation, +) -> Result<(PreparationCandidate, ProfileApplySummary), AgentApiError> { + let mut candidate = PreparationCandidate::new(drive); + if drive.state().lifecycle.status != CoreAgentStatus::Open { + return Err(AgentApiError::rejected("session is not open")); + } + let busy = drive.state().runs.active.is_some() || !drive.state().runs.queued.is_empty(); + if matches!(operation, SessionOperation::RefreshContext) && busy { + return Ok((candidate, ProfileApplySummary::default())); + } + if busy || drive.state().context.pending_compaction { + return Err(AgentApiError::rejected( + "session preparation requires no active or queued work", + )); + } + let mut summary = ProfileApplySummary::default(); + match operation { + SessionOperation::Configure { + config, + expected_revision, + } => { + if expected_revision + .is_some_and(|revision| revision != drive.state().lifecycle.config_revision) + { + return Err(AgentApiError::conflict( + "session configuration revision changed", + )); + } + summary.config_changed = drive.state().lifecycle.config.as_ref() != Some(&config); + configure(ctx, drive, &mut candidate, config, expected_revision).await?; + } + SessionOperation::ApplyProfile { + profile, + expected_config_revision, + expected_tools_revision, + } => { + if expected_config_revision + .is_some_and(|revision| revision != drive.state().lifecycle.config_revision) + || expected_tools_revision + .is_some_and(|revision| revision != drive.state().tooling.revision) + { + return Err(AgentApiError::conflict( + "session configuration or tool revision changed", + )); + } + summary = apply_profile( + ctx, + drive, + &mut candidate, + profile, + expected_config_revision, + ) + .await?; + } + SessionOperation::RefreshContext => {} + } + let request = admissions::runtime_projection_request(drive.session_id(), candidate.state()); + let commands = admissions::prepare_runtime_projection(ctx, drive, request) + .await + .map_err(|error| AgentApiError::internal(error.to_string()))?; + for command in commands { + candidate.push(command, workflow_time_ms(ctx))?; + } + Ok((candidate, summary)) +} + +async fn configure( + ctx: &mut WorkflowContext, + drive: &mut CoreAgentDrive, + candidate: &mut PreparationCandidate, + config: engine::SessionConfig, + expected_revision: Option, +) -> Result<(), AgentApiError> { + // Validate materialization before committing configuration. Registry reads + // belong to this activity; the engine remains the final state validator. + let original = SessionToolsetSource::from_state(drive.state()).unwrap(); + let mut source = original.clone(); + source.config = config.clone(); + let activity_ctx = ctx.clone(); + let activity = activity_ctx.start_activity( + WorkflowActivities::prepare_session_toolset, + crate::SessionToolsetRequest { + source: source.clone(), + validate_configuration: true, + }, + activity_options(), + ); + let prepared = await_activity(ctx, drive, activity) + .await? + .map_err(|e| AgentApiError::internal(format!("configuration preparation failed: {e}")))??; + if prepared.source != source || !original.matches(drive.state()) { + return Err(AgentApiError::conflict( + "configuration changed during preparation", + )); + } + candidate.tools( + prepared, + ctx.state(|state| state.universe_id).unwrap(), + workflow_time_ms(ctx), + )?; + candidate.push( + CoreAgentCommand::ReplaceSessionConfig { + expected_revision, + config, + }, + workflow_time_ms(ctx), + ) +} + +async fn apply_profile( + ctx: &mut WorkflowContext, + drive: &mut CoreAgentDrive, + candidate: &mut PreparationCandidate, + profile: crate::SessionProfileIntent, + expected_revision: Option, +) -> Result { + let config = profile + .config + .clone() + .or_else(|| drive.state().lifecycle.config.clone()) + .unwrap(); + let mut summary = ProfileApplySummary::default(); + let original = SessionToolsetSource::from_state(drive.state()).unwrap(); + let mut source = original.clone(); + source.config = config.clone(); + let environment = profile.environment_to_prepare(candidate.state()); + let request = crate::SessionProfilePreparationRequest { + session_id: drive.session_id().clone(), + instructions: profile.instructions, + environment, + source: source.clone(), + }; + let activity_ctx = ctx.clone(); + let activity = activity_ctx.start_activity( + WorkflowActivities::prepare_session_profile, + request, + activity_options(), + ); + let prepared = await_activity(ctx, drive, activity) + .await? + .map_err(|e| AgentApiError::internal(format!("profile preparation failed: {e}")))??; + if prepared.toolset.source != source || !original.matches(drive.state()) { + return Err(AgentApiError::conflict( + "session changed during profile preparation", + )); + } + candidate.tools( + prepared.toolset, + ctx.state(|state| state.universe_id).unwrap(), + workflow_time_ms(ctx), + )?; + if profile.config.is_some() { + summary.config_changed = drive.state().lifecycle.config.as_ref() != Some(&config); + candidate.push( + CoreAgentCommand::ReplaceSessionConfig { + expected_revision, + config, + }, + workflow_time_ms(ctx), + )?; + } + // The default fills an empty pointer and never overrides a live + // selection. The configuration replacement above has already cleared a + // selection the new document no longer attaches, so the candidate state + // is the one to consult. + if let Some(environment_id) = prepared.environment_id + && candidate + .state() + .environment + .active_environment_id + .is_none() + { + summary.active_environment_changed = true; + candidate.push( + CoreAgentCommand::SetActiveEnvironment { environment_id }, + workflow_time_ms(ctx), + )?; + } + let mut desired = admissions::active_instruction_inputs(candidate.state()); + desired.retain(|key, _| { + key.as_str() != "instructions.050.profile" + && !key.as_str().starts_with("instructions.050.profile.") + }); + desired.extend(prepared.instructions); + desired.remove(&ContextEntryKey::new("instructions.000.default")); + if desired.is_empty() { + let activity_ctx = ctx.clone(); + let activity = activity_ctx.start_activity( + WorkflowActivities::put_blob, + PutBlobRequest { + bytes: default_instructions().as_bytes().to_vec(), + }, + activity_options(), + ); + let reference = await_activity(ctx, drive, activity) + .await? + .map_err(|e| AgentApiError::internal(e.to_string()))?; + desired.insert( + ContextEntryKey::new("instructions.000.default"), + ContextEntryInput { + kind: ContextEntryKind::Instructions, + content: engine::ContentRef::text(reference), + preview: None, + origin: None, + provenance_ref: None, + token_estimate: None, + }, + ); + } + summary.instructions_changed = + desired != admissions::active_instruction_inputs(candidate.state()); + if summary.instructions_changed { + candidate.push( + CoreAgentCommand::ReplaceContextPrefix { + expected_revision: Some(candidate.state().context.revision), + key_prefix: ContextEntryKey::new("instructions"), + entries: desired, + }, + workflow_time_ms(ctx), + )?; + } + Ok(summary) +} + +pub(super) async fn prepare_initial_session( + ctx: &mut WorkflowContext, + args: &AgentSessionArgs, +) -> anyhow::Result<()> { + if ctx.state(|state| state.ready || !state.setup_requested) { + return Ok(()); + } + ctx.state_mut(|state| { + state.setup_requested = false; + state.setup_error = None; + }); + let mut drive = drive_from_state(ctx)?; + let operation = match &args.setup { + Some(profile) => SessionOperation::ApplyProfile { + profile: profile.clone(), + expected_config_revision: None, + expected_tools_revision: None, + }, + None => SessionOperation::Configure { + config: drive.state().lifecycle.config.clone().unwrap(), + expected_revision: None, + }, + }; + let result = execute_operation(ctx, &mut drive, operation) + .await? + .map(|_| ()); + ctx.state_mut(|state| match result { + Ok(()) => { + state.ready = true; + state.setup_error = None; + } + Err(error) => state.setup_error = Some(error), + }); + Ok(()) +} + +/// The observation intent is workflow state; its activity future stays in the +/// concurrent preparation loop so the workflow's public handle remains Send. +#[derive(Clone)] +pub(super) struct PendingRunPreparation { + admission: AgentAdmission, + source: SessionToolsetSource, +} + +pub(super) fn begin_run_preparation( + ctx: &WorkflowContext, + drive: &CoreAgentDrive, + admission: AgentAdmission, +) { + let source = SessionToolsetSource::from_state(drive.state()).expect("open session config"); + ctx.state_mut(|state| { + debug_assert!(state.run_preparation.is_none()); + state.run_preparation = Some(PendingRunPreparation { admission, source }); + }); +} + +/// Poll policy reads alongside the session driver, including while its model +/// or tool activity is in flight. Only the driver publishes the observation. +pub(super) async fn run_preparation_loop(ctx: WorkflowContext) { + loop { + ctx.wait_condition(|state| state.run_preparation.is_some()) + .await; + let pending = ctx.state(|state| state.run_preparation.clone()).unwrap(); + let activity = ctx.start_activity( + WorkflowActivities::prepare_session_toolset, + crate::SessionToolsetRequest { + source: pending.source, + validate_configuration: false, + }, + activity_options(), + ); + let abandoned = ctx.wait_condition(|state| state.run_preparation.is_none()); + pin_mut!(activity, abandoned); + let result = futures::select_biased! { + result = activity => result.map_err(|error| AgentApiError::internal(format!("tool preparation failed: {error}"))).and_then(|result| result), + _ = abandoned => { + activity.as_ref().get_ref().cancel(); + let _ = activity.await; + continue; + } + }; + ctx.state_mut(|state| { + if let Some(pending) = state.run_preparation.take() { + state.pending_admissions.insert( + 0, + SessionAdmission::PreparedRun { + admission: Box::new(pending.admission), + result, + }, + ); + } + }); + } +} + +pub(super) fn admission_can_pass_preparation(admission: &SessionAdmission) -> bool { + control_admission(admission) +} + +pub(super) fn abandon_pending_run(state: &mut AgentSessionWorkflow) { + if let Some(pending) = state.run_preparation.take() { + state.admission_failures.push(failure( + &pending.admission.command, + pending.admission.correlation_token, + AgentApiError::rejected("session closed during tool preparation"), + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pending() -> PendingRunPreparation { + let config = crate::default_session_config(engine::ModelSelection { + api_kind: engine::ProviderApiKind::OpenAiResponses, + provider_id: "openai".into(), + model: "gpt-test".into(), + }); + let mut state = CoreAgentState::new(); + state.lifecycle.config = Some(config); + PendingRunPreparation { + admission: AgentAdmission { + command: CoreAgentCommand::RequestRun(engine::RunRequestCommand { + submission_id: Some(SubmissionId::new("prepared")), + source: engine::RunRequestSource::Input { input: Vec::new() }, + run_config: crate::default_run_config(), + notify_on_terminal: Vec::new(), + }), + correlation_token: Some("receipt".into()), + }, + source: SessionToolsetSource::from_state(&state).unwrap(), + } + } + + #[test] + fn slow_preparation_keeps_controls_admissible_and_blocks_later_mutations() { + let pending = pending(); + let mut state = AgentSessionWorkflow { + ready: true, + setup_requested: false, + ..Default::default() + }; + state.core_state.lifecycle.config = Some(pending.source.config.clone()); + state.run_preparation = Some(pending); + state.queue_admission(AgentAdmission { + command: CoreAgentCommand::ReplaceSessionConfig { + config: crate::default_session_config(engine::ModelSelection { + api_kind: engine::ProviderApiKind::OpenAiResponses, + provider_id: "openai".into(), + model: "gpt-test".into(), + }), + expected_revision: None, + }, + correlation_token: None, + }); + assert!(!admissions::has_admissible_admissions(&state)); + assert!(!wait_loop::workflow_state_has_immediate_work(&state)); + assert!(!wait_loop::workflow_state_allows_continue_as_new(&state)); + assert_eq!(state.status_snapshot().pending_admissions, 2); + state.queue_admission(AgentAdmission { + command: CoreAgentCommand::CancelRun { + run_id: engine::RunId::new(1), + }, + correlation_token: None, + }); + assert!(admissions::has_admissible_admissions(&state)); + assert!(wait_loop::workflow_state_has_immediate_work(&state)); + } + + #[test] + fn closing_pending_preparation_reports_its_receipt_without_poisoning_session() { + let mut state = AgentSessionWorkflow { + run_preparation: Some(pending()), + ..Default::default() + }; + abandon_pending_run(&mut state); + assert!(state.run_preparation.is_none()); + assert_eq!(state.admission_failures.len(), 1); + let failure = &state.admission_failures[0]; + assert_eq!(failure.submission_id, Some(SubmissionId::new("prepared"))); + assert_eq!(failure.correlation_token.as_deref(), Some("receipt")); + assert!(failure.preparation_error.is_some()); + assert!(state.last_error.is_none()); + abandon_pending_run(&mut state); + assert_eq!(state.admission_failures.len(), 1); + } + + #[test] + fn unfinished_setup_cannot_drive_or_continue_as_new() { + let mut state = AgentSessionWorkflow { + setup_requested: false, + ..Default::default() + }; + state.core_state.context.pending_compaction = true; + assert!(!wait_loop::workflow_state_needs_core_drive_for_state( + &state + )); + assert!(!wait_loop::workflow_state_allows_continue_as_new(&state)); + state.ready = true; + assert!(wait_loop::workflow_state_needs_core_drive_for_state(&state)); + } + + #[test] + fn observation_rejects_changed_configuration_and_system_ownership() { + let pending = pending(); + let mut state = CoreAgentState::new(); + state.lifecycle.config = Some(pending.source.config.clone()); + assert!(pending.source.matches(&state)); + state.lifecycle.config_revision += 1; + assert!(!pending.source.matches(&state)); + state.lifecycle.config_revision -= 1; + state + .workflow_tools + .system_binding_ids + .insert(engine::WorkflowToolId::new("foreign")); + assert!(!pending.source.matches(&state)); + } +} diff --git a/crates/temporal-workflow/src/workflows/session/preparation_candidate.rs b/crates/temporal-workflow/src/workflows/session/preparation_candidate.rs new file mode 100644 index 00000000..b98befd8 --- /dev/null +++ b/crates/temporal-workflow/src/workflows/session/preparation_candidate.rs @@ -0,0 +1,486 @@ +//! Private preparation state. Commands are admitted and reduced locally; only +//! a fully prepared candidate can become one durable event batch. +use super::*; +use api::AgentApiError; +use engine::storage::{StoredSessionEntry, UncommittedStoredEvent}; + +pub(super) struct PreparationCandidate { + drive: CoreAgentDrive, + original_head: Option, + events: Vec, +} + +impl PreparationCandidate { + pub(super) fn new(live: &CoreAgentDrive) -> Self { + Self { + drive: CoreAgentDrive::from_replayed( + live.session_id().clone(), + live.state().clone(), + live.head().cloned(), + ), + original_head: live.head().cloned(), + events: Vec::new(), + } + } + + pub(super) fn state(&self) -> &CoreAgentState { + self.drive.state() + } + + pub(super) fn push( + &mut self, + command: CoreAgentCommand, + now: u64, + ) -> Result<(), AgentApiError> { + self.push_command(command, now)?; + // Include the same source invalidations as ordinary publication, so + // commit requires no second append and refresh sees the proposed sources. + for invalidation in [ + drive::invalid_environment_prompt_command, + drive::invalid_environment_catalog_command, + drive::invalid_environment_attachment_catalog_command, + drive::invalid_vfs_skill_catalog_command, + ] { + if let Some(command) = invalidation(self.state()) { + self.push_command(command, now)?; + } + } + Ok(()) + } + + fn push_command(&mut self, command: CoreAgentCommand, now: u64) -> Result<(), AgentApiError> { + if drive::environment_prompt_publication_is_obsolete(self.state(), &command) + || drive::environment_catalog_publication_is_obsolete(self.state(), &command) + || drive::environment_attachment_catalog_publication_is_obsolete(self.state(), &command) + || drive::vfs_skill_catalog_publication_is_obsolete(self.state(), &command) + { + return Err(AgentApiError::conflict( + "context observation does not match the proposed session sources", + )); + } + match self + .drive + .admit_command(command, now) + .map_err(map_candidate_error)? + { + CoreAgentAction::AppendEvents { + expected_head, + events, + } => { + let mut seq = expected_head.as_ref().map_or(0, |head| head.seq.as_u64()); + let entries = events + .iter() + .map(|event| { + seq = seq.checked_add(1).ok_or_else(|| { + AgentApiError::internal("session event sequence exhausted") + })?; + Ok(StoredSessionEntry { + position: SessionPosition { + seq: engine::EventSeq::new(seq), + }, + observed_at_ms: event.observed_at_ms, + joins: event.joins.clone(), + event: event.event.clone(), + }) + }) + .collect::, AgentApiError>>()?; + self.drive + .resume_appended(entries) + .map_err(map_candidate_error)?; + self.events.extend(events); + Ok(()) + } + CoreAgentAction::Idle | CoreAgentAction::Closed => Ok(()), + _ => Err(AgentApiError::internal( + "preparation command produced a runtime action", + )), + } + } + + pub(super) fn tools( + &mut self, + prepared: crate::SessionToolsetPreparation, + universe_id: uuid::Uuid, + now: u64, + ) -> Result<(), AgentApiError> { + for declaration in prepared.declarations { + self.push( + CoreAgentCommand::AdmitSystemWorkflowTool { + session_universe_id: universe_id, + declaration, + }, + now, + )?; + } + let patch = crate::session_toolset_patch(&self.state().tooling.tools, &prepared.tools); + if !patch.is_empty() { + self.push( + CoreAgentCommand::PatchTools { + expected_revision: Some(self.state().tooling.revision), + patch, + }, + now, + )?; + } + Ok(()) + } + + pub(super) fn finish( + self, + live: &CoreAgentDrive, + ) -> Result { + if self.drive.session_id() != live.session_id() + || self.original_head.as_ref() != live.head() + { + return Err(AgentApiError::conflict( + "session changed during preparation", + )); + } + Ok(AppendEventsRequest { + session_id: live.session_id().clone(), + expected_head: self.original_head, + events: self.events, + }) + } +} + +fn map_candidate_error(error: CoreAgentDriveError) -> AgentApiError { + match error { + CoreAgentDriveError::Command(CommandError::Rejected(rejection)) => { + AgentApiError::rejected(rejection.to_string()) + } + error => AgentApiError::internal(error.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn commit(live: &mut CoreAgentDrive, request: AppendEventsRequest) { + assert_eq!(live.head(), request.expected_head.as_ref()); + let start = live.head().map_or(0, |head| head.seq.as_u64()); + let entries = request + .events + .into_iter() + .enumerate() + .map(|(index, event)| StoredSessionEntry { + position: SessionPosition { + seq: engine::EventSeq::new(start + index as u64 + 1), + }, + observed_at_ms: event.observed_at_ms, + joins: event.joins, + event: event.event, + }) + .collect(); + live.resume_appended(entries).unwrap(); + } + + fn live() -> CoreAgentDrive { + let mut live = CoreAgentDrive::from_replayed( + SessionId::new("atomic-preparation"), + CoreAgentState::new(), + None, + ); + let mut candidate = PreparationCandidate::new(&live); + candidate + .push( + CoreAgentCommand::OpenSession { + config: crate::default_session_config(engine::ModelSelection { + api_kind: engine::ProviderApiKind::OpenAiResponses, + provider_id: "openai".into(), + model: "test-model".into(), + }), + }, + 1, + ) + .unwrap(); + let batch = candidate.finish(&live).unwrap(); + commit(&mut live, batch); + live + } + + fn instructions(text: &str) -> ContextEntryInput { + ContextEntryInput { + kind: ContextEntryKind::Instructions, + content: engine::ContentRef::text(BlobRef::from_bytes(text.as_bytes())), + preview: None, + origin: None, + provenance_ref: None, + token_estimate: None, + } + } + + fn attachment(id: &str) -> engine::EnvironmentAttachment { + engine::EnvironmentAttachment { + environment_id: id.to_owned(), + default: false, + access: engine::EnvironmentAccess::Read, + working_directory: None, + } + } + + fn proposed(live: &CoreAgentDrive) -> PreparationCandidate { + let mut candidate = PreparationCandidate::new(live); + let tool = engine::ToolSpec { + name: engine::ToolName::new("new_tool"), + kind: engine::ToolKind::Function(engine::FunctionToolSpec { + description_ref: None, + input_schema_ref: BlobRef::from_bytes(b"schema"), + output_schema_ref: None, + strict: None, + provider_options_ref: None, + }), + parallelism: engine::ToolParallelism::ParallelSafe, + execution: Default::default(), + }; + let tools = BTreeMap::from([(tool.name.clone(), tool)]); + candidate + .tools( + crate::SessionToolsetPreparation { + source: crate::SessionToolsetSource::from_state(live.state()).unwrap(), + declarations: Vec::new(), + tools, + }, + uuid::Uuid::nil(), + 2, + ) + .unwrap(); + let mut config = live.state().lifecycle.config.clone().unwrap(); + config.features.environments = Some(engine::EnvironmentsFeature { + environments: vec![attachment("selected")], + ..Default::default() + }); + config.generation.tool_choice = Some(engine::ToolChoice::Specific { + tool_name: engine::ToolName::new("new_tool"), + }); + candidate + .push( + CoreAgentCommand::ReplaceSessionConfig { + expected_revision: Some(live.state().lifecycle.config_revision), + config, + }, + 2, + ) + .unwrap(); + candidate + .push( + CoreAgentCommand::SetActiveEnvironment { + environment_id: engine::EnvironmentId::new("selected"), + }, + 2, + ) + .unwrap(); + candidate + .push( + CoreAgentCommand::ReplaceContextPrefix { + expected_revision: None, + key_prefix: ContextEntryKey::new("instructions"), + entries: BTreeMap::from([( + ContextEntryKey::new("instructions.050.profile"), + instructions("new profile"), + )]), + }, + 2, + ) + .unwrap(); + candidate + } + + #[test] + fn preparation_observes_proposed_sources_and_publishes_one_replayable_batch() { + let mut live = live(); + let before = live.state().clone(); + let mut candidate = proposed(&live); + let request = admissions::runtime_projection_request(live.session_id(), candidate.state()); + assert!(request.environments.is_some()); + assert_eq!( + request.active_environment_id, + Some(engine::EnvironmentId::new("selected")) + ); + assert_eq!( + request.active_instruction_inputs[&ContextEntryKey::new("instructions.050.profile")], + instructions("new profile") + ); + assert_eq!(live.state(), &before); + candidate + .push( + CoreAgentCommand::UpsertContext { + expected_revision: None, + key: ContextEntryKey::new("runtime.catalog.prepared"), + entry: ContextEntryInput { + kind: ContextEntryKind::Catalog { + title: "Prepared catalog".into(), + }, + ..instructions("new catalog") + }, + }, + 3, + ) + .unwrap(); + let expected = candidate.state().clone(); + let batch = candidate.finish(&live).unwrap(); + assert_eq!(batch.expected_head.as_ref(), live.head()); + assert!(batch.events.len() >= 5); + assert_eq!(live.state(), &before); + commit(&mut live, batch); + assert_eq!(live.state(), &expected); + } + + #[test] + fn late_validation_failure_discards_all_prepared_changes() { + let live = live(); + let before = live.state().clone(); + let mut candidate = proposed(&live); + let error = candidate + .push( + CoreAgentCommand::UpsertContext { + expected_revision: Some(999), + key: ContextEntryKey::new("runtime.catalog.prepared"), + entry: ContextEntryInput { + kind: ContextEntryKind::Catalog { + title: "Prepared catalog".into(), + }, + ..instructions("bad refresh") + }, + }, + 3, + ) + .unwrap_err(); + assert_eq!(error.kind, api::AgentApiErrorKind::Rejected); + drop(candidate); + assert_eq!(live.state(), &before); + // A later valid attempt can still prepare from exactly the original state. + assert!(proposed(&live).finish(&live).is_ok()); + } + + #[test] + fn concurrent_close_or_revision_change_rejects_the_entire_candidate() { + for close in [false, true] { + let mut live = live(); + let candidate = proposed(&live); + let mut concurrent = PreparationCandidate::new(&live); + let command = if close { + CoreAgentCommand::CloseSession { force: true } + } else { + let mut config = live.state().lifecycle.config.clone().unwrap(); + config.model.model = "other-model".into(); + CoreAgentCommand::ReplaceSessionConfig { + expected_revision: None, + config, + } + }; + concurrent.push(command, 3).unwrap(); + let batch = concurrent.finish(&live).unwrap(); + commit(&mut live, batch); + let after_concurrent = live.state().clone(); + assert_eq!( + candidate.finish(&live).unwrap_err().kind, + api::AgentApiErrorKind::Conflict + ); + assert_eq!(live.state(), &after_concurrent); + assert!(live.state().tooling.tools.is_empty()); + assert!(live.state().environment.active_environment_id.is_none()); + } + } + + #[test] + fn source_invalidation_is_part_of_the_batch_and_obsolete_publication_is_rejected() { + let mut live = live(); + let mut initial = PreparationCandidate::new(&live); + let mut config = live.state().lifecycle.config.clone().unwrap(); + config.features.environments = Some(engine::EnvironmentsFeature { + prompts: Some(Default::default()), + environments: vec![attachment("old"), attachment("new")], + ..Default::default() + }); + initial + .push( + CoreAgentCommand::ReplaceSessionConfig { + expected_revision: None, + config, + }, + 2, + ) + .unwrap(); + initial + .push( + CoreAgentCommand::SetActiveEnvironment { + environment_id: engine::EnvironmentId::new("old"), + }, + 2, + ) + .unwrap(); + let old = ContextEntryInput { + origin: Some("runtime.environment:old".into()), + ..instructions("old machine prompt") + }; + let catalog_key = ContextEntryKey::new("runtime.catalog.environments"); + let old_catalog = CoreAgentCommand::UpsertContext { + expected_revision: None, + key: catalog_key.clone(), + entry: ContextEntryInput { + origin: Some("runtime.environments:old".into()), + kind: ContextEntryKind::Catalog { + title: "Environments".into(), + }, + ..instructions("old selection") + }, + }; + initial.push(old_catalog.clone(), 2).unwrap(); + initial + .push( + CoreAgentCommand::ReplaceContextPrefix { + expected_revision: None, + key_prefix: ContextEntryKey::new("instructions"), + entries: BTreeMap::from([( + ContextEntryKey::new("instructions.110.environment"), + old.clone(), + )]), + }, + 2, + ) + .unwrap(); + let batch = initial.finish(&live).unwrap(); + commit(&mut live, batch); + let mut candidate = PreparationCandidate::new(&live); + candidate + .push( + CoreAgentCommand::SetActiveEnvironment { + environment_id: engine::EnvironmentId::new("new"), + }, + 3, + ) + .unwrap(); + assert!( + !admissions::runtime_projection_request(live.session_id(), candidate.state()) + .active_instruction_inputs + .contains_key(&ContextEntryKey::new("instructions.110.environment")) + ); + assert!(drive::invalid_environment_prompt_command(candidate.state()).is_none()); + let batch = candidate.finish(&live).unwrap(); + assert_eq!(batch.events.len(), 3); + commit(&mut live, batch); + assert!(drive::invalid_environment_prompt_command(live.state()).is_none()); + assert!(engine::current_context_entry(live.state(), &catalog_key).is_none()); + let mut candidate = PreparationCandidate::new(&live); + assert_eq!( + candidate.push(old_catalog, 4).unwrap_err().kind, + api::AgentApiErrorKind::Conflict + ); + let error = candidate + .push( + CoreAgentCommand::ReplaceContextPrefix { + expected_revision: None, + key_prefix: ContextEntryKey::new("instructions"), + entries: BTreeMap::from([( + ContextEntryKey::new("instructions.110.environment"), + old, + )]), + }, + 4, + ) + .unwrap_err(); + assert_eq!(error.kind, api::AgentApiErrorKind::Conflict); + } +} diff --git a/crates/temporal-workflow/src/workflows/session/promise_sources.rs b/crates/temporal-workflow/src/workflows/session/promise_sources.rs index ced69ebd..51d668a1 100644 --- a/crates/temporal-workflow/src/workflows/session/promise_sources.rs +++ b/crates/temporal-workflow/src/workflows/session/promise_sources.rs @@ -364,7 +364,7 @@ fn queue_resolution( return; } }; - state.pending_admissions.push(AgentAdmission { + state.queue_admission(AgentAdmission { command: CoreAgentCommand::ResolvePromise { promise_id, resolution, diff --git a/crates/temporal-workflow/src/workflows/session/session_state.rs b/crates/temporal-workflow/src/workflows/session/session_state.rs index 67c5091d..288d4646 100644 --- a/crates/temporal-workflow/src/workflows/session/session_state.rs +++ b/crates/temporal-workflow/src/workflows/session/session_state.rs @@ -2,7 +2,8 @@ use super::*; impl AgentSessionWorkflow { pub fn queue_admission(&mut self, admission: AgentAdmission) { - self.pending_admissions.push(admission); + self.pending_admissions + .push(SessionAdmission::Core(admission)); } /// Inbound push delivery converges on ordinary promise resolution @@ -179,7 +180,10 @@ impl AgentSessionWorkflow { .map(ToString::to_string) .unwrap_or_default(), initialized: self.initialized, - pending_admissions: self.pending_admissions.len(), + ready: self.ready, + setup_error: self.setup_error.clone(), + pending_admissions: self.pending_admissions.len() + + usize::from(self.run_preparation.is_some()), pending_tool_batch_resumes: self.pending_tool_batch_resumes.len(), active_waits: usize::from(awaits::parked_tool_batch(&self.core_state).is_some()) + self.promise_source_polls.len(), diff --git a/crates/temporal-workflow/src/workflows/session/tests.rs b/crates/temporal-workflow/src/workflows/session/tests.rs index 4733f7f8..02f3b992 100644 --- a/crates/temporal-workflow/src/workflows/session/tests.rs +++ b/crates/temporal-workflow/src/workflows/session/tests.rs @@ -13,11 +13,11 @@ fn pending_admissions_are_fifo() { let pending = std::mem::take(&mut workflow.pending_admissions); assert_eq!( - pending[0].command.submission_id_for_test(), + pending[0].core().unwrap().command.submission_id_for_test(), Some(SubmissionId::new("submit_1")) ); assert_eq!( - pending[1].command.submission_id_for_test(), + pending[1].core().unwrap().command.submission_id_for_test(), Some(SubmissionId::new("submit_2")) ); } @@ -27,6 +27,7 @@ fn admission_failure_status_does_not_poison_later_admission() { let mut workflow = AgentSessionWorkflow::default(); let rejection = engine::CommandRejection::context_revision_conflict(3, 4); workflow.admission_failures.push(AgentAdmissionFailure { + preparation_error: None, submission_id: Some(SubmissionId::new("submit_rejected")), correlation_token: Some("admit_test".to_owned()), kind: AgentAdmissionFailureKind::RejectedCommand, @@ -369,6 +370,7 @@ fn legacy_step_limit_decodes_but_is_never_serialized() { fn continuation_state_round_trips_admission_failure_correlation() { let rejection = engine::CommandRejection::context_revision_conflict(3, 4); let continuation = AgentSessionContinuationState::v1(vec![AgentAdmissionFailure { + preparation_error: None, submission_id: Some(SubmissionId::new("submit_rejected")), correlation_token: Some("admit_test".to_owned()), kind: AgentAdmissionFailureKind::RejectedCommand, @@ -420,6 +422,7 @@ fn admission(command: CoreAgentCommand) -> AgentAdmission { fn agent_session_args_with_close_on_terminal(close_on_terminal: bool) -> AgentSessionArgs { AgentSessionArgs { + setup: None, metadata: Default::default(), universe_id: test_universe(), session_id: SessionId::new("session_test"), @@ -543,7 +546,11 @@ fn pending_promise_cancellation(promise_id: &str) -> PendingPromiseCancellation } fn workflow_with_parked_tool_batch(spec: engine::AwaitSpec) -> AgentSessionWorkflow { - let mut workflow = AgentSessionWorkflow::default(); + let mut workflow = AgentSessionWorkflow { + ready: true, + setup_requested: false, + ..Default::default() + }; let run_id = RunId::new(1); let turn_id = TurnId::new(1); let batch_id = ToolBatchId::new(1); @@ -1493,7 +1500,10 @@ fn promise_source_polls_rehydrate_from_pending_poll_sources() { #[test] fn continue_as_new_is_blocked_by_non_reconstructible_workflow_state() { - let mut workflow = AgentSessionWorkflow::default(); + let mut workflow = AgentSessionWorkflow { + ready: true, + ..Default::default() + }; assert!(wait_loop::workflow_state_allows_continue_as_new(&workflow)); workflow.queue_admission(admission(request_input_run("submit_1"))); @@ -1569,6 +1579,124 @@ fn closed_quiescent_workflow_can_complete() { assert!(wait_loop::workflow_state_is_closed_and_quiescent(&workflow)); } +#[test] +fn attachment_catalog_invalidation_tracks_selection_and_replays() { + fn append( + state: &mut CoreAgentState, + log: &mut Vec, + command: CoreAgentCommand, + ) { + for proposal in engine::admit_command(state, command, 1).unwrap() { + let entry = CoreAgentEntry { + position: SessionPosition { + seq: EventSeq::new(log.len() as u64 + 1), + }, + observed_at_ms: 1, + joins: proposal.joins, + event: proposal.event, + }; + engine::apply_event(state, &entry).unwrap(); + log.push(entry); + } + } + fn publication(key: &str, origin: Option) -> CoreAgentCommand { + CoreAgentCommand::UpsertContext { + expected_revision: None, + key: ContextEntryKey::new(key), + entry: ContextEntryInput { + origin, + kind: ContextEntryKind::Catalog { + title: "Catalog".into(), + }, + content: engine::ContentRef::text(BlobRef::from_bytes(b"catalog")), + preview: None, + provenance_ref: None, + token_estimate: None, + }, + } + } + let mut state = CoreAgentState::new(); + let mut log = Vec::new(); + let mut config = agent_session_args_with_close_on_terminal(false).session_config; + config.features.environments = Some(engine::EnvironmentsFeature { + environments: ["first", "second"] + .into_iter() + .map(|id| engine::EnvironmentAttachment { + environment_id: id.into(), + default: false, + access: engine::EnvironmentAccess::Read, + working_directory: None, + }) + .collect(), + ..Default::default() + }); + append( + &mut state, + &mut log, + CoreAgentCommand::OpenSession { config }, + ); + append( + &mut state, + &mut log, + publication("runtime.catalog.vfs", None), + ); + let vfs = engine::current_context_entry(&state, &ContextEntryKey::new("runtime.catalog.vfs")) + .unwrap() + .clone(); + let tools = state.tooling.clone(); + let key = ContextEntryKey::new("runtime.catalog.environments"); + for next in [Some("first"), Some("second"), None] { + let current = state + .environment + .active_environment_id + .as_ref() + .map_or("", |id| id.as_str()); + let observed = publication( + key.as_str(), + Some(format!("runtime.environments:{current}")), + ); + assert!(!drive::environment_attachment_catalog_publication_is_obsolete(&state, &observed)); + append(&mut state, &mut log, observed.clone()); + assert!(drive::invalid_environment_attachment_catalog_command(&state).is_none()); + let command = match next { + Some(id) => CoreAgentCommand::SetActiveEnvironment { + environment_id: engine::EnvironmentId::new(id), + }, + None => CoreAgentCommand::ClearActiveEnvironment, + }; + append(&mut state, &mut log, command); + assert!(drive::environment_attachment_catalog_publication_is_obsolete(&state, &observed)); + let removal = drive::invalid_environment_attachment_catalog_command(&state).unwrap(); + append(&mut state, &mut log, removal); + assert!(engine::current_context_entry(&state, &key).is_none()); + assert_eq!( + engine::current_context_entry(&state, &ContextEntryKey::new("runtime.catalog.vfs")), + Some(&vfs) + ); + assert_eq!(state.tooling, tools); + } + let unselected = publication(key.as_str(), Some("runtime.environments:".into())); + append(&mut state, &mut log, unselected.clone()); + let mut config = state.lifecycle.config.clone().unwrap(); + config.features.environments = None; + append( + &mut state, + &mut log, + CoreAgentCommand::ReplaceSessionConfig { + expected_revision: None, + config, + }, + ); + assert!(drive::environment_attachment_catalog_publication_is_obsolete(&state, &unselected)); + let removal = drive::invalid_environment_attachment_catalog_command(&state).unwrap(); + append(&mut state, &mut log, removal); + let mut replayed = CoreAgentState::new(); + for entry in &log { + engine::apply_event(&mut replayed, entry).unwrap(); + } + assert_eq!(state, replayed); +} + #[test] fn environment_catalog_switch_removal_replays_without_mutating_vfs() { fn append( @@ -1594,6 +1722,15 @@ fn environment_catalog_switch_removal_replays_without_mutating_vfs() { let mut config = agent_session_args_with_close_on_terminal(false).session_config; config.features.environments = Some(engine::EnvironmentsFeature { skills: Some(Default::default()), + environments: ["first", "second"] + .into_iter() + .map(|id| engine::EnvironmentAttachment { + environment_id: id.to_owned(), + default: false, + access: engine::EnvironmentAccess::Read, + working_directory: None, + }) + .collect(), ..Default::default() }); append( @@ -1751,6 +1888,15 @@ fn environment_prompt_switch_removal_replays_without_mutating_vfs() { let mut config = agent_session_args_with_close_on_terminal(false).session_config; config.features.environments = Some(engine::EnvironmentsFeature { prompts: Some(Default::default()), + environments: ["first", "second"] + .into_iter() + .map(|id| engine::EnvironmentAttachment { + environment_id: id.to_owned(), + default: false, + access: engine::EnvironmentAccess::Read, + working_directory: None, + }) + .collect(), ..Default::default() }); append( @@ -1909,12 +2055,12 @@ fn vfs_skill_revocation_is_source_scoped_and_replays() { let mut log = Vec::new(); let mut config = agent_session_args_with_close_on_terminal(false).session_config; config.features.vfs = Some(engine::VfsFeature { - workspace_links: vec![engine::WorkspaceLink { + workspaces: vec![engine::WorkspaceAttachment { path: "/skills".into(), - target: engine::WorkspaceLinkTarget::Workspace { + target: engine::WorkspaceAttachmentTarget::Workspace { workspace_id: "skills".into(), }, - access: engine::WorkspaceLinkAccess::ReadOnly, + access: engine::WorkspaceAccess::Read, }], skills: Some(engine::VfsSkillsConfig::default()), ..Default::default() @@ -2028,3 +2174,43 @@ fn vfs_skill_revocation_is_source_scoped_and_replays() { append(&mut state, &mut log, command); assert!(drive::invalid_vfs_skill_catalog_command(&state).is_none()); } + +#[test] +fn accepted_submission_skips_new_policy_observations_even_after_config_is_removed() { + let mut workflow = + workflow_with_parked_tool_batch(await_spec(&["promise_1"], engine::AwaitMode::All, None)); + workflow + .core_state + .runs + .active + .as_mut() + .unwrap() + .submission_id = Some(SubmissionId::new("accepted")); + workflow.core_state.lifecycle.config = None; + assert!(preparation::known_submission( + &workflow.core_state, + &request_input_run("accepted") + )); + assert!(!preparation::known_submission( + &workflow.core_state, + &request_input_run("new") + )); +} + +#[test] +fn continuations_require_explicit_preparation_state() { + for ready in [false, true] { + let mut current = AgentSessionContinuationState::v1(Vec::new()); + current.ready = ready; + let wire = serde_json::to_value(¤t).unwrap(); + assert_eq!( + serde_json::from_value::(wire.clone()).unwrap(), + current + ); + for field in ["ready", "operation_outcomes"] { + let mut incomplete = wire.clone(); + incomplete.as_object_mut().unwrap().remove(field); + assert!(serde_json::from_value::(incomplete).is_err()); + } + } +} diff --git a/crates/temporal-workflow/src/workflows/session/tool_batches.rs b/crates/temporal-workflow/src/workflows/session/tool_batches.rs index c383bca2..4fc73a2f 100644 --- a/crates/temporal-workflow/src/workflows/session/tool_batches.rs +++ b/crates/temporal-workflow/src/workflows/session/tool_batches.rs @@ -543,7 +543,7 @@ mod tests { turn_id: engine::TurnId::new(1), batch_id: engine::ToolBatchId::new(1), promise_id_base: 1, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), active_environment_id: None, environment_policy: None, subagents_policy: None, diff --git a/crates/temporal-workflow/src/workflows/session/wait_loop.rs b/crates/temporal-workflow/src/workflows/session/wait_loop.rs index e1728854..edd3e343 100644 --- a/crates/temporal-workflow/src/workflows/session/wait_loop.rs +++ b/crates/temporal-workflow/src/workflows/session/wait_loop.rs @@ -9,7 +9,9 @@ pub(super) async fn wait_for_workflow_work(ctx: &mut WorkflowContext, now: } pub(super) fn workflow_state_has_immediate_work(state: &AgentSessionWorkflow) -> bool { - !state.pending_admissions.is_empty() + (!state.ready && state.setup_requested) + || admissions::has_admissible_admissions(state) || !state.pending_tool_batch_resumes.is_empty() || session_state::has_due_emissions(state) || !state.pending_source_resolutions.is_empty() @@ -59,13 +62,15 @@ pub(super) fn workflow_state_needs_core_drive(ctx: &WorkflowContext bool { - !state.core_state.runs.queued.is_empty() - || state.core_state.context.pending_compaction - || state.core_state.runs.active.as_ref().is_some_and(|run| { - awaits::parked_tool_batch(&state.core_state).is_none() - && !(run.status == engine::RunStatus::Parked - && run.pending_approvals().next().is_some()) - }) + state.ready + && (!state.pending_toolsets.is_empty() + || !state.core_state.runs.queued.is_empty() + || state.core_state.context.pending_compaction + || state.core_state.runs.active.as_ref().is_some_and(|run| { + awaits::parked_tool_batch(&state.core_state).is_none() + && !(run.status == engine::RunStatus::Parked + && run.pending_approvals().next().is_some()) + })) } fn nearest_workflow_wake_ms(ctx: &WorkflowContext) -> Option { @@ -120,7 +125,10 @@ pub(super) fn history_rollover_due( /// Confirmed starts and issued execution cancellations may be retried safely /// because their workflow execution identities are stable. pub(super) fn workflow_state_allows_continue_as_new(state: &AgentSessionWorkflow) -> bool { - state.pending_admissions.is_empty() + state.ready + && state.run_preparation.is_none() + && state.pending_toolsets.is_empty() + && state.pending_admissions.is_empty() && state.pending_tool_batch_resumes.is_empty() && state.pending_emissions.is_empty() && state.pending_source_resolutions.is_empty() @@ -136,6 +144,8 @@ pub(super) fn workflow_state_should_complete(ctx: &WorkflowContext bool { state.initialized && state.core_state.lifecycle.status == CoreAgentStatus::Closed + && state.run_preparation.is_none() + && state.pending_toolsets.is_empty() && state.pending_admissions.is_empty() && state.pending_tool_batch_resumes.is_empty() && state.pending_emissions.is_empty() diff --git a/crates/test-support/src/runner/drive.rs b/crates/test-support/src/runner/drive.rs index 85cf9765..5e18f1de 100644 --- a/crates/test-support/src/runner/drive.rs +++ b/crates/test-support/src/runner/drive.rs @@ -10,15 +10,17 @@ use engine::{ }; use tools::{ catalog::{SKILL_CATALOG_CONTEXT_KEY, VFS_CATALOG_CONTEXT_KEY, clear_catalog_command}, - environment::projection::{prepare_vfs_catalog_publication, vfs_catalog_from_workspace_links}, + environment::projection::{ + prepare_vfs_catalog_publication, vfs_catalog_from_workspace_attachments, + }, prompts::{ PromptAssemblyLimits, configured_vfs_prompt_root_specs, prepare_prompt_instructions_publication, - prepare_prompt_instructions_publication_with_warnings, resolve_linked_vfs_prompt_roots, + prepare_prompt_instructions_publication_with_warnings, resolve_attached_vfs_prompt_roots, }, skills::{ configured_vfs_skill_root_specs, prepare_skill_catalog_publication_with_warnings, - resolve_linked_vfs_skill_roots, + resolve_attached_vfs_skill_roots, }, }; @@ -127,13 +129,13 @@ impl SessionRunner { .as_ref() .and_then(|config| config.features.vfs.as_ref()) .and_then(|vfs| vfs.prompts.as_ref()); - let links = if prompt_config.is_some() { - self.resolve_workspace_links(state).await? + let attachments = if prompt_config.is_some() { + self.resolve_workspace_attachments(state).await? } else { Vec::new() }; let specs = match prompt_config { - Some(config) => configured_vfs_prompt_root_specs(&links, config.roots.as_deref()) + Some(config) => configured_vfs_prompt_root_specs(&attachments, config.roots.as_deref()) .map_err(|error| RunnerError::InvalidRequest { message: format!("configure VFS prompt roots: {error}"), })?, @@ -157,10 +159,10 @@ impl SessionRunner { message: "VFS prompt sourcing requires a workspace store".to_owned(), } })?; - let resolved = resolve_linked_vfs_prompt_roots( + let resolved = resolve_attached_vfs_prompt_roots( self.stores.blobs.clone(), workspace_store.clone(), - links, + attachments, specs, ) .await @@ -298,8 +300,8 @@ impl SessionRunner { .as_ref() .map(|config| &config.features); let vfs_catalog_enabled = features.is_some_and(|features| features.vfs.is_some()); - let links = if vfs_catalog_enabled { - self.resolve_workspace_links(state).await? + let attachments = if vfs_catalog_enabled { + self.resolve_workspace_attachments(state).await? } else { Vec::new() }; @@ -321,7 +323,7 @@ impl SessionRunner { .into_iter() .collect()); } - let catalog = vfs_catalog_from_workspace_links(&links).map_err(|error| { + let catalog = vfs_catalog_from_workspace_attachments(&attachments).map_err(|error| { RunnerError::InvalidRequest { message: format!("prepare VFS catalog: {error}"), } @@ -384,19 +386,19 @@ impl SessionRunner { let Some(workspace_store) = self.stores.vfs_workspace_store.as_ref() else { return Ok(clear_catalog_command(current, SKILL_CATALOG_CONTEXT_KEY)); }; - let links = self.resolve_workspace_links(state).await?; - let specs = configured_vfs_skill_root_specs(&links, skills_config.roots.as_deref()) + let attachments = self.resolve_workspace_attachments(state).await?; + let specs = configured_vfs_skill_root_specs(&attachments, skills_config.roots.as_deref()) .map_err(|error| RunnerError::InvalidRequest { - message: format!("configure VFS skill roots: {error}"), - })?; + message: format!("configure VFS skill roots: {error}"), + })?; if specs.is_empty() { return Ok(clear_catalog_command(current, SKILL_CATALOG_CONTEXT_KEY)); } - let resolved = resolve_linked_vfs_skill_roots( + let resolved = resolve_attached_vfs_skill_roots( self.stores.blobs.clone(), workspace_store.clone(), - links, + attachments, specs, ) .await @@ -427,33 +429,33 @@ impl SessionRunner { Ok(publication.command) } - async fn resolve_workspace_links( + async fn resolve_workspace_attachments( &self, state: &CoreAgentState, - ) -> Result, RunnerError> { + ) -> Result, RunnerError> { let declarations = state .lifecycle .config .as_ref() .and_then(|config| config.features.vfs.as_ref()) - .map(|vfs| vfs.workspace_links.as_slice()) + .map(|vfs| vfs.workspaces.as_slice()) .unwrap_or_default(); if declarations.is_empty() { return Ok(Vec::new()); } let workspace_store = self.stores.vfs_workspace_store.as_ref().ok_or_else(|| { RunnerError::InvalidRequest { - message: "workspace links require a VFS workspace store".to_owned(), + message: "workspace attachments require a VFS workspace store".to_owned(), } })?; - vfs::resolve_workspace_links( + vfs::resolve_workspace_attachments( self.stores.blobs.clone(), workspace_store.clone(), declarations, ) .await .map_err(|error| RunnerError::InvalidRequest { - message: format!("resolve workspace links: {error}"), + message: format!("resolve workspace attachments: {error}"), }) } @@ -825,7 +827,7 @@ mod tests { ContextEntryKind, ContextMessageRole, CoreAgentCommand, CoreAgentEvent, FunctionToolSpec, LlmFinish, ModelSelection, ObservedToolCall, ProviderApiKind, RunConfig, RunStatus, SessionConfig, SessionId, ToolCallResult, ToolKind, ToolName, ToolParallelism, ToolSpec, - TurnEvent, WorkspaceLink, WorkspaceLinkAccess, WorkspaceLinkTarget, + TurnEvent, WorkspaceAccess, WorkspaceAttachment, WorkspaceAttachmentTarget, storage::{ BlobStore, CreateForkedSession, CreateSession, InMemoryBlobStore, InMemorySessionStore, SessionStore, @@ -838,14 +840,15 @@ mod tests { use tools::skills::{SkillCatalogSnapshot, SkillLocation}; use tools::{ fs::tools::ReadFileResult, - fs::{FsPath, FsToolContext, LinkedVfsFileSystem}, + fs::{AttachedVfsFileSystem, FsPath, FsToolContext}, runtime::InlineToolRuntime, toolset::{ToolsetConfig, register_toolset}, }; use vfs::{ CompareAndSetVfsWorkspaceHead, CreateInlineSnapshotRequest, CreateVfsWorkspaceRecord, - InlineFile, ResolvedWorkspaceLink, ResolvedWorkspaceLinkTarget, VfsCatalogError, VfsPath, - VfsWorkspaceId, VfsWorkspaceRecord, VfsWorkspaceStore, create_inline_snapshot, + InlineFile, ResolvedWorkspaceAttachment, ResolvedWorkspaceAttachmentTarget, + VfsCatalogError, VfsPath, VfsWorkspaceId, VfsWorkspaceRecord, VfsWorkspaceStore, + create_inline_snapshot, }; use super::*; @@ -1210,33 +1213,33 @@ mod tests { config } - fn vfs_config_with_links( + fn vfs_config_with_attachments( prompts: bool, skills: bool, - workspace_links: Vec, + workspace_attachments: Vec, ) -> SessionConfig { let mut config = vfs_config(prompts, skills); - config.features.vfs.as_mut().unwrap().workspace_links = workspace_links; + config.features.vfs.as_mut().unwrap().workspaces = workspace_attachments; config } - fn snapshot_link(path: &str, snapshot_ref: &BlobRef) -> WorkspaceLink { - WorkspaceLink { + fn snapshot_attachment(path: &str, snapshot_ref: &BlobRef) -> WorkspaceAttachment { + WorkspaceAttachment { path: path.to_owned(), - target: WorkspaceLinkTarget::Snapshot { + target: WorkspaceAttachmentTarget::Snapshot { snapshot_ref: snapshot_ref.to_string(), }, - access: WorkspaceLinkAccess::ReadOnly, + access: WorkspaceAccess::Read, } } - fn workspace_link(path: &str, workspace_id: &VfsWorkspaceId) -> WorkspaceLink { - WorkspaceLink { + fn workspace_attachment(path: &str, workspace_id: &VfsWorkspaceId) -> WorkspaceAttachment { + WorkspaceAttachment { path: path.to_owned(), - target: WorkspaceLinkTarget::Workspace { + target: WorkspaceAttachmentTarget::Workspace { workspace_id: workspace_id.to_string(), }, - access: WorkspaceLinkAccess::ReadWrite, + access: WorkspaceAccess::Edit, } } @@ -1389,7 +1392,7 @@ mod tests { ) .await .expect("create snapshot"); - let link = snapshot_link("/workspace", &snapshot.snapshot_ref); + let attachment = snapshot_attachment("/workspace", &snapshot.snapshot_ref); let llm = Arc::new(CaptureFinalLlm::default()); let runner = SessionRunner::new(stores, llm.clone()); @@ -1398,7 +1401,7 @@ mod tests { session_id: session_id.clone(), observed_at_ms: 10, command: CoreAgentCommand::OpenSession { - config: vfs_config_with_links(false, false, vec![link]), + config: vfs_config_with_attachments(false, false, vec![attachment]), }, max_steps: None, }) @@ -1566,7 +1569,6 @@ mod tests { for vfs in [ None, Some(engine::VfsFeature { - tools: Some(engine::VfsToolSurface::Edit), prompts: Some(Default::default()), ..Default::default() }), @@ -1621,7 +1623,7 @@ mod tests { ) .await .expect("create snapshot"); - let link = snapshot_link("/skills/system", &snapshot.snapshot_ref); + let attachment = snapshot_attachment("/skills/system", &snapshot.snapshot_ref); let runner = SessionRunner::new( stores, Arc::new(ToolThenFinalLlm { @@ -1634,7 +1636,7 @@ mod tests { observed_at_ms: 10, command: CoreAgentCommand::OpenSession { config: { - let mut config = vfs_config_with_links(false, true, vec![link]); + let mut config = vfs_config_with_attachments(false, true, vec![attachment]); config.features.vfs.as_mut().unwrap().skills = Some(Default::default()); config }, @@ -1670,13 +1672,13 @@ mod tests { assert_eq!(catalog.skills[0].name, "deploy-review"); assert!(matches!( &catalog.skills[0].location, - SkillLocation::LinkedSnapshot { + SkillLocation::AttachedSnapshot { source_snapshot_ref, - source_link_path, + source_attachment_path, skill_doc_path, .. } if source_snapshot_ref == &snapshot.snapshot_ref - && source_link_path.as_str() == "/skills/system" + && source_attachment_path.as_str() == "/skills/system" && skill_doc_path.as_str() == "/skills/system/.agents/skills/deploy-review/SKILL.md" )); assert!(outcome.emitted_entries.iter().any(|entry| { @@ -1738,7 +1740,7 @@ mod tests { }) .await .expect("create workspace"); - let link = workspace_link("/workspace", &workspace_id); + let attachment = workspace_attachment("/workspace", &workspace_id); let llm = Arc::new(CaptureFinalLlm::default()); let runner = SessionRunner::new(stores, llm.clone()); runner @@ -1746,7 +1748,7 @@ mod tests { session_id: session_id.clone(), observed_at_ms: 10, command: CoreAgentCommand::OpenSession { - config: vfs_config_with_links(true, false, vec![link]), + config: vfs_config_with_attachments(true, false, vec![attachment]), }, max_steps: None, }) @@ -1988,20 +1990,20 @@ mod tests { ) .await .expect("create snapshot"); - let linked_fs = LinkedVfsFileSystem::new( + let attached_fs = AttachedVfsFileSystem::new( blob_store.clone(), vfs.clone(), - vec![ResolvedWorkspaceLink { + vec![ResolvedWorkspaceAttachment { path: VfsPath::parse("/skills/system").unwrap(), - target: ResolvedWorkspaceLinkTarget::AvailableSnapshot { + target: ResolvedWorkspaceAttachmentTarget::AvailableSnapshot { snapshot_ref: snapshot.snapshot_ref.clone(), }, - access: WorkspaceLinkAccess::ReadOnly, + access: WorkspaceAccess::Read, }], ) - .expect("linked fs"); + .expect("attached fs"); let ctx = - FsToolContext::new(Arc::new(linked_fs), blob_store.clone()).with_cwd(FsPath::root()); + FsToolContext::new(Arc::new(attached_fs), blob_store.clone()).with_cwd(FsPath::root()); let toolset = register_toolset(&ToolsetConfig::workspace()).expect("toolset"); let tool_set = toolset.tools.clone(); let tools = InlineToolRuntime::with_vfs_filesystem(ctx, tools::runtime::ToolCatalog::new()); @@ -2100,20 +2102,20 @@ mod tests { }) .await .expect("create workspace"); - let linked_fs = LinkedVfsFileSystem::new( + let attached_fs = AttachedVfsFileSystem::new( blob_store.clone(), vfs.clone(), - vec![ResolvedWorkspaceLink { + vec![ResolvedWorkspaceAttachment { path: VfsPath::parse("/skills/system").unwrap(), - target: ResolvedWorkspaceLinkTarget::AvailableWorkspace { + target: ResolvedWorkspaceAttachmentTarget::AvailableWorkspace { workspace: vfs.read_workspace(&workspace_id).await.unwrap(), }, - access: WorkspaceLinkAccess::ReadWrite, + access: WorkspaceAccess::Edit, }], ) - .expect("linked fs"); + .expect("attached fs"); let ctx = - FsToolContext::new(Arc::new(linked_fs), blob_store.clone()).with_cwd(FsPath::root()); + FsToolContext::new(Arc::new(attached_fs), blob_store.clone()).with_cwd(FsPath::root()); let toolset = register_toolset(&ToolsetConfig::workspace()).expect("toolset"); let tool_set = toolset.tools.clone(); let tools = InlineToolRuntime::with_vfs_filesystem(ctx, tools::runtime::ToolCatalog::new()); @@ -2191,18 +2193,18 @@ mod tests { .await .expect("update workspace head"); - let current_fs = LinkedVfsFileSystem::new( + let current_fs = AttachedVfsFileSystem::new( blob_store.clone(), vfs.clone(), - vec![ResolvedWorkspaceLink { + vec![ResolvedWorkspaceAttachment { path: VfsPath::parse("/skills/system").unwrap(), - target: ResolvedWorkspaceLinkTarget::AvailableWorkspace { + target: ResolvedWorkspaceAttachmentTarget::AvailableWorkspace { workspace: vfs.read_workspace(&workspace_id).await.unwrap(), }, - access: WorkspaceLinkAccess::ReadWrite, + access: WorkspaceAccess::Edit, }], ) - .expect("current linked fs"); + .expect("current attached fs"); let current_skill = tools::fs::tools::invoke_read_file( &FsToolContext::new(Arc::new(current_fs), blob_store.clone()).with_cwd(FsPath::root()), tools::fs::tools::ReadFileArgs { diff --git a/crates/tools/src/builtin/mod.rs b/crates/tools/src/builtin/mod.rs index df238348..3c3d6ca9 100644 --- a/crates/tools/src/builtin/mod.rs +++ b/crates/tools/src/builtin/mod.rs @@ -133,7 +133,7 @@ impl<'a> BuiltinToolContext<'a> { match self { Self::Vfs(vfs) | Self::Transfer { vfs, .. } => Ok(vfs), Self::Environment(_) => Err(ToolError::InvalidRequest { - message: "no_vfs_workspace_links".into(), + message: "no_vfs_workspace_attachments".into(), }), } } @@ -705,17 +705,17 @@ impl BuiltinTool { BuiltinToolSurface::ClaudeCodeLike => claude::description(self, scoped_paths), }?; let boundary = if self.is_transfer() { - " Explicitly transfers between session-linked VFS paths and the selected environment. Both domains' access rules apply." + " Explicitly transfers between session-attached VFS paths and the selected environment. Both domains' access rules apply." } else { match self.domain { BuiltinToolDomain::Vfs => { - " Accesses only session-linked VFS workspaces and snapshots; these files are not visible to environment commands." + " Accesses only session-attached VFS workspaces and snapshots; these files are not visible to environment commands." } BuiltinToolDomain::Environment if self.is_filesystem_operation() => { - " Accesses only the active environment filesystem; it does not read or modify linked VFS files." + " Accesses only the active environment filesystem; it does not read or modify attached VFS files." } BuiltinToolDomain::Environment => { - " Operates only in the active environment; linked VFS files are not implicitly available." + " Operates only in the active environment; attached VFS files are not implicitly available." } } }; @@ -812,7 +812,7 @@ mod tests { ); let description = tool.description(true).unwrap(); assert!(description.contains("Both domains' access rules apply")); - assert!(!description.contains("Accesses only session-linked")); + assert!(!description.contains("Accesses only session-attached")); } } } diff --git a/crates/tools/src/catalog.rs b/crates/tools/src/catalog.rs index 32388b09..bc3a2f55 100644 --- a/crates/tools/src/catalog.rs +++ b/crates/tools/src/catalog.rs @@ -6,6 +6,7 @@ use engine::{BlobRef, ContextEntryInput, ContextEntryKey, ContextEntryKind, Core pub const VFS_CATALOG_CONTEXT_KEY: &str = "runtime.catalog.vfs"; pub const SKILL_CATALOG_CONTEXT_KEY: &str = "runtime.catalog.skills.vfs"; pub const SUBAGENT_CATALOG_CONTEXT_KEY: &str = "runtime.catalog.subagents"; +pub const ENVIRONMENT_CATALOG_CONTEXT_KEY: &str = "runtime.catalog.environments"; /// Store the provider-neutral body once. The structured source remains a /// separate durable root through provenance; its writer owns any nested edges. diff --git a/crates/tools/src/environment.rs b/crates/tools/src/environment.rs index 53fd9fed..43040fad 100644 --- a/crates/tools/src/environment.rs +++ b/crates/tools/src/environment.rs @@ -10,6 +10,7 @@ use crate::{ limits::ToolLimits, }; +pub mod attachments; pub(crate) mod catalog_text; pub mod control; pub mod jobs; diff --git a/crates/tools/src/environment/attachments.rs b/crates/tools/src/environment/attachments.rs new file mode 100644 index 00000000..7064c724 --- /dev/null +++ b/crates/tools/src/environment/attachments.rs @@ -0,0 +1,296 @@ +//! The environment catalog: the session's attached environments as the model +//! sees them, with this session's access on each and which one is active. +//! Built from the admitted grant and registry records, never from a live +//! machine, and published like the sub-agent catalog. + +use engine::storage::{BlobStore, BlobStoreError}; +use engine::{ + BlobRef, ContextEntryInput, CoreAgentCommand, EnvironmentAccess, EnvironmentsFeature, +}; +use serde::{Deserialize, Serialize}; + +use crate::catalog::{ + ENVIRONMENT_CATALOG_CONTEXT_KEY, catalog_context_input, catalog_publication_command, +}; +use crate::environment::control::{ + ENVIRONMENT_ACTIVATE_TOOL_NAME, ENVIRONMENT_LIST_TOOL_NAME, ENVIRONMENT_READ_TOOL_NAME, +}; + +pub const ENVIRONMENT_CATALOG_SCHEMA_VERSION: &str = "lightspeed.environments.catalog.v1"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvironmentCatalogSnapshot { + pub schema_version: String, + pub environments: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_environment_id: Option, + pub selection: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvironmentCatalogEntry { + pub environment_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Lowercase lifecycle status from the registry; absent when the record + /// is missing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + pub access: EnvironmentAccess, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_directory: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub default: bool, +} + +/// A registry fact joined onto an attachment by the publisher. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct EnvironmentCatalogRecord { + pub display_name: Option, + pub status: Option, +} + +impl EnvironmentCatalogSnapshot { + pub fn new( + feature: &EnvironmentsFeature, + active_environment_id: Option<&str>, + record: impl Fn(&str) -> EnvironmentCatalogRecord, + ) -> Self { + Self { + schema_version: ENVIRONMENT_CATALOG_SCHEMA_VERSION.to_owned(), + environments: feature + .environments + .iter() + .map(|attachment| { + let record = record(&attachment.environment_id); + EnvironmentCatalogEntry { + environment_id: attachment.environment_id.clone(), + display_name: record.display_name, + status: record.status, + access: attachment.access, + working_directory: attachment.working_directory.clone(), + default: attachment.default, + } + }) + .collect(), + active_environment_id: active_environment_id.map(str::to_owned), + selection: feature.selection, + } + } +} + +pub(crate) fn environment_catalog_text(catalog: &EnvironmentCatalogSnapshot) -> String { + let mut text = String::new(); + if catalog.environments.is_empty() { + text.push_str("No environments are attached to this session."); + return text; + } + text.push_str( + "Environments attached to this session. Ordinary file, command, and job tools operate on the active environment; a call the active environment's access does not cover is rejected, and the tool list does not change when you switch.\n\n", + ); + for entry in &catalog.environments { + let name = entry + .display_name + .as_deref() + .filter(|name| !name.trim().is_empty() && *name != entry.environment_id) + .map(|name| format!(" ({name})")) + .unwrap_or_default(); + let mut markers = Vec::new(); + if catalog.active_environment_id.as_deref() == Some(entry.environment_id.as_str()) { + markers.push("active"); + } + if entry.default { + markers.push("default"); + } + let markers = if markers.is_empty() { + String::new() + } else { + format!(" [{}]", markers.join(", ")) + }; + text.push_str(&format!("- {}{name}{markers}\n", entry.environment_id)); + text.push_str(&format!(" access: {}", entry.access.describe())); + if let Some(cwd) = &entry.working_directory { + text.push_str(&format!("; working directory: {cwd}")); + } + match &entry.status { + Some(status) => text.push_str(&format!("; status: {status}\n")), + None => text.push_str("; status: unknown (record missing)\n"), + } + } + if catalog.active_environment_id.is_none() { + text.push_str("\nNo environment is active."); + } + if catalog.selection { + text.push_str(&format!( + "\nUse {ENVIRONMENT_LIST_TOOL_NAME} to see live status and {ENVIRONMENT_ACTIVATE_TOOL_NAME} to switch; {ENVIRONMENT_READ_TOOL_NAME} inspects one environment." + )); + } else { + text.push_str(&format!( + "\nThe active environment is selected outside this session; {ENVIRONMENT_READ_TOOL_NAME} inspects it." + )); + } + text +} + +#[derive(Debug, thiserror::Error)] +pub enum EnvironmentCatalogError { + #[error(transparent)] + BlobStore(#[from] BlobStoreError), + + #[error("failed to encode environment catalog: {message}")] + Encode { message: String }, +} + +pub async fn environment_catalog_context_input( + blobs: &dyn BlobStore, + snapshot: &EnvironmentCatalogSnapshot, + snapshot_ref: BlobRef, +) -> Result { + let mut entry = catalog_context_input( + blobs, + "Environment catalog", + environment_catalog_text(snapshot), + snapshot_ref, + ) + .await?; + // Empty suffix records the absence of a selection. The workflow can + // invalidate this observation after a switch without reading its blobs. + entry.origin = Some(format!( + "runtime.environments:{}", + snapshot.active_environment_id.as_deref().unwrap_or("") + )); + Ok(entry) +} + +/// Write the snapshot to CAS and return the upsert when it differs from the +/// active entry (content-addressed, so an unchanged catalog is a no-op). +pub async fn prepare_environment_catalog_publication( + blobs: &dyn BlobStore, + current: Option<&ContextEntryInput>, + snapshot: &EnvironmentCatalogSnapshot, +) -> Result, EnvironmentCatalogError> { + let bytes = serde_json::to_vec(snapshot).map_err(|error| EnvironmentCatalogError::Encode { + message: error.to_string(), + })?; + let catalog_ref = blobs.put_bytes(bytes).await?; + let entry = environment_catalog_context_input(blobs, snapshot, catalog_ref).await?; + Ok(catalog_publication_command( + current, + ENVIRONMENT_CATALOG_CONTEXT_KEY, + entry, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use engine::EnvironmentAttachment; + + fn feature() -> EnvironmentsFeature { + EnvironmentsFeature { + selection: true, + environments: vec![ + EnvironmentAttachment { + environment_id: "env_ci".to_owned(), + default: true, + access: EnvironmentAccess::Jobs, + working_directory: Some("/srv/app".to_owned()), + }, + EnvironmentAttachment { + environment_id: "env_logs".to_owned(), + default: false, + access: EnvironmentAccess::Read, + working_directory: None, + }, + ], + ..EnvironmentsFeature::default() + } + } + + #[test] + fn catalog_text_lists_access_markers_and_status() { + let snapshot = EnvironmentCatalogSnapshot::new(&feature(), Some("env_ci"), |id| { + EnvironmentCatalogRecord { + display_name: (id == "env_ci").then(|| "CI runner".to_owned()), + status: (id == "env_ci").then(|| "ready".to_owned()), + } + }); + let text = environment_catalog_text(&snapshot); + assert!(text.contains("- env_ci (CI runner) [active, default]")); + assert!(text.contains( + "access: read, edit, exec, jobs; working directory: /srv/app; status: ready" + )); + assert!(text.contains("- env_logs\n access: read; status: unknown (record missing)")); + assert!(text.contains(ENVIRONMENT_ACTIVATE_TOOL_NAME)); + assert!(!text.contains("No environment is active")); + } + + #[test] + fn catalog_text_without_selection_or_active_environment() { + let mut feature = feature(); + feature.selection = false; + let snapshot = EnvironmentCatalogSnapshot::new(&feature, None, |_| { + EnvironmentCatalogRecord::default() + }); + let text = environment_catalog_text(&snapshot); + assert!(text.contains("No environment is active")); + assert!(text.contains("selected outside this session")); + assert!(!text.contains(ENVIRONMENT_ACTIVATE_TOOL_NAME)); + + let empty = EnvironmentCatalogSnapshot::new(&EnvironmentsFeature::default(), None, |_| { + EnvironmentCatalogRecord::default() + }); + assert_eq!( + environment_catalog_text(&empty), + "No environments are attached to this session." + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn publication_records_selection_including_its_absence() { + let blobs = engine::storage::InMemoryBlobStore::new(); + for active in [None, Some("env_ci"), Some("env_logs")] { + let snapshot = EnvironmentCatalogSnapshot::new(&feature(), active, |_| { + EnvironmentCatalogRecord::default() + }); + let entry = environment_catalog_context_input( + &blobs, + &snapshot, + BlobRef::from_bytes(b"snapshot"), + ) + .await + .unwrap(); + assert_eq!( + entry.origin, + Some(format!("runtime.environments:{}", active.unwrap_or(""))) + ); + let text = blobs.read_text(&entry.content.content_ref).await.unwrap(); + assert_eq!(text.contains("No environment is active."), active.is_none()); + if let Some(id) = active { + assert!(text.contains(&format!("- {id} [active"))); + } + } + } + + #[tokio::test(flavor = "current_thread")] + async fn publication_is_a_no_op_when_unchanged() { + let blobs = engine::storage::InMemoryBlobStore::new(); + let snapshot = EnvironmentCatalogSnapshot::new(&feature(), None, |_| { + EnvironmentCatalogRecord::default() + }); + let first = prepare_environment_catalog_publication(&blobs, None, &snapshot) + .await + .unwrap() + .expect("first publication upserts"); + let CoreAgentCommand::UpsertContext { entry, key, .. } = first else { + panic!("expected an upsert"); + }; + assert_eq!(key.as_str(), ENVIRONMENT_CATALOG_CONTEXT_KEY); + assert!( + prepare_environment_catalog_publication(&blobs, Some(&entry), &snapshot) + .await + .unwrap() + .is_none() + ); + } +} diff --git a/crates/tools/src/environment/control.rs b/crates/tools/src/environment/control.rs index 2c519162..d863eb2c 100644 --- a/crates/tools/src/environment/control.rs +++ b/crates/tools/src/environment/control.rs @@ -10,21 +10,10 @@ pub const ENVIRONMENT_LIST_TOOL_NAME: &str = "environment_list"; pub const ENVIRONMENT_READ_TOOL_NAME: &str = "environment_read"; pub const ENVIRONMENT_ACTIVATE_TOOL_NAME: &str = "environment_activate"; pub const ENVIRONMENT_DEACTIVATE_TOOL_NAME: &str = "environment_deactivate"; -pub const DEFAULT_ENVIRONMENT_LIST_LIMIT: usize = 20; -pub const MAX_ENVIRONMENT_LIST_LIMIT: usize = 100; #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case", deny_unknown_fields)] -pub struct EnvironmentListArgs { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cursor: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub limit: Option, - /// Only environments in this group (the registration key that admitted - /// them, by display name). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub group: Option, -} +#[serde(deny_unknown_fields)] +pub struct EnvironmentListArgs {} #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case", deny_unknown_fields)] @@ -58,23 +47,23 @@ pub fn is_environment_selection_tool(tool_id: &ToolName) -> bool { } pub fn environment_control_tool_definitions( - selection_tools: bool, + selection: bool, ) -> ToolResult> { let mut tools = vec![( ENVIRONMENT_READ_TOOL_NAME, - "Read live details for an environment. Omit environment_id to inspect this session's active environment; provide a known id to inspect another environment allowed by the session.", + "Read live details and this session's access for an environment. Omit environment_id to inspect the active environment; provide the id of another environment attached to this session to inspect it.", optional_environment_id_schema(), )]; - if selection_tools { + if selection { tools.extend([ ( ENVIRONMENT_LIST_TOOL_NAME, - "List the live universe environments allowed by this session. Use this before activation when you do not know the environment id. Registered environments carry a group, the name of the pool they registered under; filter by it to pick from one pool.", - list_schema(), + "List the environments attached to this session with their status, this session's access on each, and which one is active.", + empty_schema(), ), ( ENVIRONMENT_ACTIVATE_TOOL_NAME, - "Select one allowed, ready universe environment as this session's active environment. Environment-dependent tools must be called in a later turn.", + "Select one attached environment as this session's active environment. The tool surface does not change; calls outside the active environment's access are rejected. Environment-dependent tools must be called in a later turn.", required_environment_id_schema(), ), ( @@ -102,18 +91,6 @@ fn function_definition( )) } -fn list_schema() -> Value { - json!({ - "type": "object", - "properties": { - "cursor": { "type": ["string", "null"] }, - "limit": { "type": ["integer", "null"], "minimum": 1, "maximum": MAX_ENVIRONMENT_LIST_LIMIT }, - "group": { "type": ["string", "null"], "minLength": 1, "description": "Only environments in this group (registered pool name)." } - }, - "additionalProperties": false - }) -} - fn optional_environment_id_schema() -> Value { json!({ "type": "object", diff --git a/crates/tools/src/environment/jobs.rs b/crates/tools/src/environment/jobs.rs index e6a1fea3..abaa0a1a 100644 --- a/crates/tools/src/environment/jobs.rs +++ b/crates/tools/src/environment/jobs.rs @@ -127,28 +127,22 @@ impl JobRunArgs { #[serde(deny_unknown_fields)] pub struct JobSubmitExecutionContextV1 { pub version: u32, + /// The active attachment's working directory at admission. #[serde(default, skip_serializing_if = "Option::is_none")] pub working_directory: Option, + /// The active environment at admission; the executor has already + /// checked that its attachment grants durable jobs. pub environment_id: String, - pub allowed_provider_ids: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub allowed_registration_key_ids: Option>, } impl JobSubmitExecutionContextV1 { - pub const VERSION: u32 = 1; + pub const VERSION: u32 = 2; - pub fn new( - environment_id: String, - allowed_provider_ids: Option>, - allowed_registration_key_ids: Option>, - ) -> Self { + pub fn new(environment_id: String, working_directory: Option) -> Self { Self { version: Self::VERSION, - working_directory: None, + working_directory, environment_id, - allowed_provider_ids, - allowed_registration_key_ids, } } } @@ -636,18 +630,12 @@ mod tests { #[test] fn job_submit_execution_context_is_versioned_and_runtime_owned() { - let context = JobSubmitExecutionContextV1::new( - "environment-active".to_owned(), - Some(vec!["provider-a".to_owned()]), - None, - ); + let context = + JobSubmitExecutionContextV1::new("environment-active".to_owned(), Some("/srv".into())); assert_eq!(context.version, JobSubmitExecutionContextV1::VERSION); assert_eq!(context.environment_id, "environment-active"); - assert_eq!( - context.allowed_provider_ids, - Some(vec!["provider-a".to_owned()]) - ); + assert_eq!(context.working_directory.as_deref(), Some("/srv")); } #[tokio::test(flavor = "current_thread")] diff --git a/crates/tools/src/environment/projection.rs b/crates/tools/src/environment/projection.rs index f0719975..0f014da6 100644 --- a/crates/tools/src/environment/projection.rs +++ b/crates/tools/src/environment/projection.rs @@ -3,12 +3,12 @@ use std::collections::BTreeSet; use engine::{ - BlobRef, ContextEntryInput, CoreAgentCommand, WorkspaceLinkAccess, WorkspaceLinkTarget, + BlobRef, ContextEntryInput, CoreAgentCommand, WorkspaceAccess, WorkspaceAttachmentTarget, storage::{BlobGraphStore, BlobStore, BlobStoreError, record_contains_edges}, }; use serde::{Deserialize, Serialize}; use thiserror::Error; -use vfs::{ResolvedWorkspaceLink, ResolvedWorkspaceLinkTarget}; +use vfs::{ResolvedWorkspaceAttachment, ResolvedWorkspaceAttachmentTarget}; use crate::catalog::{VFS_CATALOG_CONTEXT_KEY, catalog_context_input, catalog_publication_command}; use crate::fs::FsPath; @@ -56,11 +56,11 @@ pub enum FsRouteAccess { ReadWrite, } -impl From for FsRouteAccess { - fn from(value: WorkspaceLinkAccess) -> Self { +impl From for FsRouteAccess { + fn from(value: WorkspaceAccess) -> Self { match value { - WorkspaceLinkAccess::ReadOnly => Self::ReadOnly, - WorkspaceLinkAccess::ReadWrite => Self::ReadWrite, + WorkspaceAccess::Read => Self::ReadOnly, + WorkspaceAccess::Edit => Self::ReadWrite, } } } @@ -125,12 +125,12 @@ pub fn vfs_catalog_blob_refs(catalog: &VfsCatalog) -> BTreeSet { .collect() } -pub fn vfs_catalog_from_workspace_links( - links: &[ResolvedWorkspaceLink], +pub fn vfs_catalog_from_workspace_attachments( + attachments: &[ResolvedWorkspaceAttachment], ) -> Result { - let mut routes = links + let mut routes = attachments .iter() - .map(fs_route_from_workspace_link) + .map(fs_route_from_workspace_attachment) .collect::, _>>()?; routes.sort_by(|left, right| left.path.cmp(&right.path)); let revision = stable_revision(&encode_json(&routes)?); @@ -151,43 +151,47 @@ pub async fn vfs_catalog_context_input( .await } -fn fs_route_from_workspace_link( - link: &ResolvedWorkspaceLink, +fn fs_route_from_workspace_attachment( + attachment: &ResolvedWorkspaceAttachment, ) -> Result { - let path = FsPath::new(link.path.as_str()).map_err(|error| { + let path = FsPath::new(attachment.path.as_str()).map_err(|error| { EnvironmentProjectionError::InvalidPath { - path: link.path.as_str().to_owned(), + path: attachment.path.as_str().to_owned(), message: error.to_string(), } })?; - let (source, availability) = match &link.target { - ResolvedWorkspaceLinkTarget::AvailableSnapshot { snapshot_ref } => ( + let (source, availability) = match &attachment.target { + ResolvedWorkspaceAttachmentTarget::AvailableSnapshot { snapshot_ref } => ( FsRouteSource::VfsSnapshot { snapshot_ref: snapshot_ref.clone(), }, FsRouteAvailability::Available, ), - ResolvedWorkspaceLinkTarget::AvailableWorkspace { workspace } => ( + ResolvedWorkspaceAttachmentTarget::AvailableWorkspace { workspace } => ( FsRouteSource::VfsWorkspace { workspace_id: workspace.workspace_id.as_str().to_owned(), }, FsRouteAvailability::Available, ), - ResolvedWorkspaceLinkTarget::Unavailable { + ResolvedWorkspaceAttachmentTarget::Unavailable { declared_target, reason, } => { let source = match declared_target { - WorkspaceLinkTarget::Snapshot { snapshot_ref } => FsRouteSource::VfsSnapshot { - snapshot_ref: BlobRef::parse(snapshot_ref.clone()).map_err(|error| { - EnvironmentProjectionError::Encode { - message: error.to_string(), - } - })?, - }, - WorkspaceLinkTarget::Workspace { workspace_id } => FsRouteSource::VfsWorkspace { - workspace_id: workspace_id.clone(), - }, + WorkspaceAttachmentTarget::Snapshot { snapshot_ref } => { + FsRouteSource::VfsSnapshot { + snapshot_ref: BlobRef::parse(snapshot_ref.clone()).map_err(|error| { + EnvironmentProjectionError::Encode { + message: error.to_string(), + } + })?, + } + } + WorkspaceAttachmentTarget::Workspace { workspace_id } => { + FsRouteSource::VfsWorkspace { + workspace_id: workspace_id.clone(), + } + } }; ( source, @@ -200,7 +204,7 @@ fn fs_route_from_workspace_link( Ok(FsRoute { path, source_path: None, - access: link.access.into(), + access: attachment.access.into(), source, availability, }) @@ -226,8 +230,10 @@ fn stable_revision(bytes: &[u8]) -> u64 { #[cfg(test)] mod tests { - use engine::{WorkspaceLinkAccess, storage::InMemoryBlobStore}; - use vfs::{ResolvedWorkspaceLink, ResolvedWorkspaceLinkTarget, VfsPath, VfsWorkspaceId}; + use engine::{WorkspaceAccess, storage::InMemoryBlobStore}; + use vfs::{ + ResolvedWorkspaceAttachment, ResolvedWorkspaceAttachmentTarget, VfsPath, VfsWorkspaceId, + }; use super::*; @@ -300,10 +306,10 @@ mod tests { } #[test] - fn vfs_catalog_from_workspace_links_projects_routes() { - let link = workspace_link(); + fn vfs_catalog_from_workspace_attachments_projects_routes() { + let attachment = workspace_attachment(); - let catalog = vfs_catalog_from_workspace_links(&[link]).expect("catalog"); + let catalog = vfs_catalog_from_workspace_attachments(&[attachment]).expect("catalog"); assert_ne!(catalog.revision, 0); assert_eq!(catalog.routes.len(), 1); @@ -315,10 +321,10 @@ mod tests { )); } - fn workspace_link() -> ResolvedWorkspaceLink { - ResolvedWorkspaceLink { - path: VfsPath::parse("/workspace").expect("link path"), - target: ResolvedWorkspaceLinkTarget::AvailableWorkspace { + fn workspace_attachment() -> ResolvedWorkspaceAttachment { + ResolvedWorkspaceAttachment { + path: VfsPath::parse("/workspace").expect("attachment path"), + target: ResolvedWorkspaceAttachmentTarget::AvailableWorkspace { workspace: vfs::VfsWorkspaceRecord { workspace_id: VfsWorkspaceId::new("workspace_1"), display_name: None, @@ -330,7 +336,7 @@ mod tests { updated_at_ms: 1, }, }, - access: WorkspaceLinkAccess::ReadWrite, + access: WorkspaceAccess::Edit, } } } diff --git a/crates/tools/src/fs/mod.rs b/crates/tools/src/fs/mod.rs index 6b953d98..e4070db6 100644 --- a/crates/tools/src/fs/mod.rs +++ b/crates/tools/src/fs/mod.rs @@ -25,7 +25,7 @@ pub use scoped::ScopedFileSystem; pub use scoped_local::ScopedLocalFileSystem; use serde::{Deserialize, Serialize}; use thiserror::Error; -pub use vfs::{LinkedVfsFileSystem, VfsSnapshotFileSystem, VfsWorkspaceFileSystem}; +pub use vfs::{AttachedVfsFileSystem, VfsSnapshotFileSystem, VfsWorkspaceFileSystem}; use crate::limits::ToolLimits; @@ -45,7 +45,7 @@ pub enum FsError { #[error("filesystem permission denied for path: {path}")] PermissionDenied { path: FsPath }, - #[error("workspace link unavailable for path {path}: {message}")] + #[error("workspace attachment unavailable for path {path}: {message}")] Unavailable { path: FsPath, message: String }, #[error("filesystem operation unsupported: {message}")] diff --git a/crates/tools/src/fs/vfs.rs b/crates/tools/src/fs/vfs.rs index 7a7170bb..bd524092 100644 --- a/crates/tools/src/fs/vfs.rs +++ b/crates/tools/src/fs/vfs.rs @@ -173,17 +173,17 @@ impl VfsWorkspaceFileSystem { } #[derive(Clone)] -pub struct LinkedVfsFileSystem { +pub struct AttachedVfsFileSystem { blobs: Arc, blob_graph: Option>, workspace_store: Arc, - links: Arc>, + attachments: Arc>, effects: ToolEffectLog, } #[derive(Clone, Debug)] -struct RoutedWorkspaceLink { - link: ::vfs::ResolvedWorkspaceLink, +struct RoutedWorkspaceAttachment { + attachment: ::vfs::ResolvedWorkspaceAttachment, inner_path: FsPath, } @@ -211,14 +211,14 @@ impl ToolEffectLog { } } -impl LinkedVfsFileSystem { +impl AttachedVfsFileSystem { pub fn new( blobs: Arc, workspace_store: Arc, - mut links: Vec<::vfs::ResolvedWorkspaceLink>, + mut attachments: Vec<::vfs::ResolvedWorkspaceAttachment>, ) -> FsResult { - validate_links(&links)?; - links.sort_by(|left, right| { + validate_attachments(&attachments)?; + attachments.sort_by(|left, right| { right .path .depth() @@ -229,7 +229,7 @@ impl LinkedVfsFileSystem { blobs, blob_graph: None, workspace_store, - links: Arc::new(links), + attachments: Arc::new(attachments), effects: ToolEffectLog::default(), }) } @@ -241,36 +241,39 @@ impl LinkedVfsFileSystem { self } - pub fn links(&self) -> &[::vfs::ResolvedWorkspaceLink] { - self.links.as_slice() + pub fn attachments(&self) -> &[::vfs::ResolvedWorkspaceAttachment] { + self.attachments.as_slice() } - fn route_link(&self, path: &FsPath) -> FsResult> { + fn route_attachment(&self, path: &FsPath) -> FsResult> { let vfs_path = fs_path_to_vfs_path(path)?; - for link in self.links.iter() { - if vfs_path_starts_with(&vfs_path, &link.path) { - return Ok(Some(RoutedWorkspaceLink { - link: link.clone(), - inner_path: vfs_path_to_fs_path(&strip_link_path(&vfs_path, &link.path)?)?, + for attachment in self.attachments.iter() { + if vfs_path_starts_with(&vfs_path, &attachment.path) { + return Ok(Some(RoutedWorkspaceAttachment { + attachment: attachment.clone(), + inner_path: vfs_path_to_fs_path(&strip_attachment_path( + &vfs_path, + &attachment.path, + )?)?, })); } } Ok(None) } - async fn file_system_for_link( + async fn file_system_for_attachment( &self, - link: &::vfs::ResolvedWorkspaceLink, + attachment: &::vfs::ResolvedWorkspaceAttachment, request_path: &FsPath, ) -> FsResult> { - match &link.target { - ::vfs::ResolvedWorkspaceLinkTarget::AvailableSnapshot { snapshot_ref } => { + match &attachment.target { + ::vfs::ResolvedWorkspaceAttachmentTarget::AvailableSnapshot { snapshot_ref } => { let fs = VfsSnapshotFileSystem::new(self.blobs.clone(), snapshot_ref.clone()) .await .map_err(|error| map_vfs_error(error, request_path))?; Ok(Box::new(fs)) } - ::vfs::ResolvedWorkspaceLinkTarget::AvailableWorkspace { workspace } => { + ::vfs::ResolvedWorkspaceAttachmentTarget::AvailableWorkspace { workspace } => { Ok(Box::new(VfsWorkspaceFileSystem::with_effect_log( self.blobs.clone(), self.blob_graph.clone(), @@ -279,7 +282,7 @@ impl LinkedVfsFileSystem { self.effects.clone(), ))) } - ::vfs::ResolvedWorkspaceLinkTarget::Unavailable { reason, .. } => { + ::vfs::ResolvedWorkspaceAttachmentTarget::Unavailable { reason, .. } => { Err(FsError::Unavailable { path: request_path.clone(), message: reason.clone(), @@ -288,18 +291,18 @@ impl LinkedVfsFileSystem { } } - fn writable_workspace_for_link( + fn writable_workspace_for_attachment( &self, - link: &::vfs::ResolvedWorkspaceLink, + attachment: &::vfs::ResolvedWorkspaceAttachment, request_path: &FsPath, ) -> FsResult { - if !link.is_writable() { + if !attachment.is_writable() { return Err(FsError::PermissionDenied { path: request_path.clone(), }); } - match &link.target { - ::vfs::ResolvedWorkspaceLinkTarget::AvailableWorkspace { workspace } => { + match &attachment.target { + ::vfs::ResolvedWorkspaceAttachmentTarget::AvailableWorkspace { workspace } => { Ok(VfsWorkspaceFileSystem::with_effect_log( self.blobs.clone(), self.blob_graph.clone(), @@ -308,12 +311,12 @@ impl LinkedVfsFileSystem { self.effects.clone(), )) } - ::vfs::ResolvedWorkspaceLinkTarget::AvailableSnapshot { .. } => { + ::vfs::ResolvedWorkspaceAttachmentTarget::AvailableSnapshot { .. } => { Err(FsError::PermissionDenied { path: request_path.clone(), }) } - ::vfs::ResolvedWorkspaceLinkTarget::Unavailable { reason, .. } => { + ::vfs::ResolvedWorkspaceAttachmentTarget::Unavailable { reason, .. } => { Err(FsError::Unavailable { path: request_path.clone(), message: reason.clone(), @@ -325,8 +328,8 @@ impl LinkedVfsFileSystem { fn synthetic_directory_entries(&self, path: &FsPath) -> FsResult> { let vfs_path = fs_path_to_vfs_path(path)?; let mut entries = BTreeMap::new(); - for link in self.links.iter() { - if let Some(file_name) = immediate_link_child(&vfs_path, &link.path) { + for attachment in self.attachments.iter() { + if let Some(file_name) = immediate_attachment_child(&vfs_path, &attachment.path) { entries.insert( file_name.to_owned(), ReadDirectoryEntry { @@ -660,14 +663,14 @@ impl FileSystem for VfsWorkspaceFileSystem { } #[async_trait] -impl FileSystem for LinkedVfsFileSystem { +impl FileSystem for AttachedVfsFileSystem { async fn export_vfs(&self, path: &FsPath) -> FsResult<::vfs::VfsEntry> { let route = self - .route_link(path)? + .route_attachment(path)? .ok_or_else(|| FsError::InvalidInput { - message: "select one linked VFS workspace or snapshot".into(), + message: "select one attached VFS workspace or snapshot".into(), })?; - self.file_system_for_link(&route.link, path) + self.file_system_for_attachment(&route.attachment, path) .await? .export_vfs(&route.inner_path) .await @@ -678,17 +681,21 @@ impl FileSystem for LinkedVfsFileSystem { replace: bool, ) -> FsResult> { let route = self - .route_link(path)? + .route_attachment(path)? .ok_or_else(|| FsError::InvalidInput { - message: "select one linked VFS workspace".into(), + message: "select one attached VFS workspace".into(), })?; - self.writable_workspace_for_link(&route.link, path)? + self.writable_workspace_for_attachment(&route.attachment, path)? .prepare_vfs_capture(&route.inner_path, replace) .await } fn access_policy(&self) -> FileAccessPolicy { - if self.links.iter().any(|link| link.is_writable()) { + if self + .attachments + .iter() + .any(|attachment| attachment.is_writable()) + { FileAccessPolicy::FullReadWrite } else { FileAccessPolicy::FullReadOnly @@ -696,8 +703,10 @@ impl FileSystem for LinkedVfsFileSystem { } async fn read_file(&self, path: &FsPath) -> FsResult> { - if let Some(resolved) = self.route_link(path)? { - let fs = self.file_system_for_link(&resolved.link, path).await?; + if let Some(resolved) = self.route_attachment(path)? { + let fs = self + .file_system_for_attachment(&resolved.attachment, path) + .await?; return fs.read_file(&resolved.inner_path).await; } if self.synthetic_metadata(path)?.is_some() { @@ -709,10 +718,10 @@ impl FileSystem for LinkedVfsFileSystem { } async fn write_file(&self, path: &FsPath, contents: Vec) -> FsResult<()> { - let Some(resolved) = self.route_link(path)? else { + let Some(resolved) = self.route_attachment(path)? else { return Err(FsError::PermissionDenied { path: path.clone() }); }; - let fs = self.writable_workspace_for_link(&resolved.link, path)?; + let fs = self.writable_workspace_for_attachment(&resolved.attachment, path)?; fs.write_file(&resolved.inner_path, contents).await } @@ -721,8 +730,8 @@ impl FileSystem for LinkedVfsFileSystem { path: &FsPath, options: CreateDirectoryOptions, ) -> FsResult<()> { - if let Some(resolved) = self.route_link(path)? { - let fs = self.writable_workspace_for_link(&resolved.link, path)?; + if let Some(resolved) = self.route_attachment(path)? { + let fs = self.writable_workspace_for_attachment(&resolved.attachment, path)?; return fs.create_directory(&resolved.inner_path, options).await; } if self.synthetic_metadata(path)?.is_some() { @@ -736,8 +745,10 @@ impl FileSystem for LinkedVfsFileSystem { } async fn get_metadata(&self, path: &FsPath) -> FsResult { - if let Some(resolved) = self.route_link(path)? { - let fs = self.file_system_for_link(&resolved.link, path).await?; + if let Some(resolved) = self.route_attachment(path)? { + let fs = self + .file_system_for_attachment(&resolved.attachment, path) + .await?; return fs.get_metadata(&resolved.inner_path).await; } if let Some(metadata) = self.synthetic_metadata(path)? { @@ -747,8 +758,10 @@ impl FileSystem for LinkedVfsFileSystem { } async fn read_directory(&self, path: &FsPath) -> FsResult> { - if let Some(resolved) = self.route_link(path)? { - let fs = self.file_system_for_link(&resolved.link, path).await?; + if let Some(resolved) = self.route_attachment(path)? { + let fs = self + .file_system_for_attachment(&resolved.attachment, path) + .await?; return fs.read_directory(&resolved.inner_path).await; } let entries = self.synthetic_directory_entries(path)?; @@ -759,10 +772,10 @@ impl FileSystem for LinkedVfsFileSystem { } async fn remove(&self, path: &FsPath, options: RemoveOptions) -> FsResult<()> { - let Some(resolved) = self.route_link(path)? else { + let Some(resolved) = self.route_attachment(path)? else { return Err(FsError::PermissionDenied { path: path.clone() }); }; - let fs = self.writable_workspace_for_link(&resolved.link, path)?; + let fs = self.writable_workspace_for_attachment(&resolved.attachment, path)?; fs.remove(&resolved.inner_path, options).await } @@ -773,11 +786,12 @@ impl FileSystem for LinkedVfsFileSystem { options: CopyOptions, ) -> FsResult<()> { if let (Some(source), Some(destination)) = ( - self.route_link(source_path)?, - self.route_link(destination_path)?, - ) && source.link.path == destination.link.path + self.route_attachment(source_path)?, + self.route_attachment(destination_path)?, + ) && source.attachment.path == destination.attachment.path { - let fs = self.writable_workspace_for_link(&destination.link, destination_path)?; + let fs = + self.writable_workspace_for_attachment(&destination.attachment, destination_path)?; return fs .copy(&source.inner_path, &destination.inner_path, options) .await; @@ -820,53 +834,59 @@ fn vfs_path_to_fs_path(path: &::vfs::VfsPath) -> FsResult { FsPath::new(path.as_str()).map_err(Into::into) } -fn strip_link_path(path: &::vfs::VfsPath, link_path: &::vfs::VfsPath) -> FsResult<::vfs::VfsPath> { - if link_path.is_root() { +fn strip_attachment_path( + path: &::vfs::VfsPath, + attachment_path: &::vfs::VfsPath, +) -> FsResult<::vfs::VfsPath> { + if attachment_path.is_root() { return Ok(path.clone()); } - if path == link_path { + if path == attachment_path { return Ok(::vfs::VfsPath::root()); } let suffix = path .as_str() - .strip_prefix(link_path.as_str()) + .strip_prefix(attachment_path.as_str()) .ok_or_else(|| FsError::InvalidInput { - message: format!("path {path} is not under workspace link {link_path}"), + message: format!("path {path} is not under workspace attachment {attachment_path}"), })?; ::vfs::VfsPath::parse(suffix).map_err(|error| FsError::InvalidInput { message: error.to_string(), }) } -fn validate_links(links: &[::vfs::ResolvedWorkspaceLink]) -> FsResult<()> { +fn validate_attachments(attachments: &[::vfs::ResolvedWorkspaceAttachment]) -> FsResult<()> { let mut seen = BTreeSet::new(); - for link in links { - if !seen.insert(link.path.clone()) { + for attachment in attachments { + if !seen.insert(attachment.path.clone()) { return Err(FsError::InvalidInput { - message: format!("duplicate workspace link path: {}", link.path), + message: format!("duplicate workspace attachment path: {}", attachment.path), }); } - if link.is_writable() + if attachment.is_writable() && matches!( - link.target, - ::vfs::ResolvedWorkspaceLinkTarget::AvailableSnapshot { .. } + attachment.target, + ::vfs::ResolvedWorkspaceAttachmentTarget::AvailableSnapshot { .. } ) { return Err(FsError::InvalidInput { - message: format!("snapshot workspace link cannot be writable: {}", link.path), + message: format!( + "snapshot workspace attachment cannot be writable: {}", + attachment.path + ), }); } } - let links = links.iter().collect::>(); - for (index, left) in links.iter().enumerate() { - for right in links.iter().skip(index + 1) { + let attachments = attachments.iter().collect::>(); + for (index, left) in attachments.iter().enumerate() { + for right in attachments.iter().skip(index + 1) { if vfs_path_starts_with(&left.path, &right.path) || vfs_path_starts_with(&right.path, &left.path) { return Err(FsError::InvalidInput { message: format!( - "nested workspace links are not supported: {} and {}", + "nested workspace attachments are not supported: {} and {}", left.path, right.path ), }); @@ -876,21 +896,21 @@ fn validate_links(links: &[::vfs::ResolvedWorkspaceLink]) -> FsResult<()> { Ok(()) } -fn immediate_link_child<'a>( +fn immediate_attachment_child<'a>( parent: &::vfs::VfsPath, - link_path: &'a ::vfs::VfsPath, + attachment_path: &'a ::vfs::VfsPath, ) -> Option<&'a str> { let parent_components = parent.components(); - let link_components = link_path.components(); - if parent_components.len() >= link_components.len() { + let attachment_components = attachment_path.components(); + if parent_components.len() >= attachment_components.len() { return None; } if parent_components .iter() - .zip(link_components.iter()) + .zip(attachment_components.iter()) .all(|(left, right)| left == right) { - Some(link_components[parent_components.len()]) + Some(attachment_components[parent_components.len()]) } else { None } @@ -1348,22 +1368,22 @@ mod tests { workspace_id } - fn resolved_link( + fn resolved_attachment( path: &str, - target: ::vfs::ResolvedWorkspaceLinkTarget, - access: engine::WorkspaceLinkAccess, - ) -> ::vfs::ResolvedWorkspaceLink { - ::vfs::ResolvedWorkspaceLink { + target: ::vfs::ResolvedWorkspaceAttachmentTarget, + access: engine::WorkspaceAccess, + ) -> ::vfs::ResolvedWorkspaceAttachment { + ::vfs::ResolvedWorkspaceAttachment { path: ::vfs::VfsPath::parse(path).unwrap(), target, access, } } - async fn test_linked_fs() -> ( + async fn test_attached_fs() -> ( Arc, Arc, - LinkedVfsFileSystem, + AttachedVfsFileSystem, ::vfs::VfsWorkspaceId, ) { let blobs = Arc::new(InMemoryBlobStore::new()); @@ -1388,25 +1408,25 @@ mod tests { .read_workspace(&workspace_id) .await .expect("workspace"); - let fs = LinkedVfsFileSystem::new( + let fs = AttachedVfsFileSystem::new( blobs.clone(), store.clone(), vec![ - resolved_link( + resolved_attachment( "/skills/rust", - ::vfs::ResolvedWorkspaceLinkTarget::AvailableSnapshot { + ::vfs::ResolvedWorkspaceAttachmentTarget::AvailableSnapshot { snapshot_ref: skill_snapshot.snapshot_ref, }, - engine::WorkspaceLinkAccess::ReadOnly, + engine::WorkspaceAccess::Read, ), - resolved_link( + resolved_attachment( "/workspace", - ::vfs::ResolvedWorkspaceLinkTarget::AvailableWorkspace { workspace }, - engine::WorkspaceLinkAccess::ReadWrite, + ::vfs::ResolvedWorkspaceAttachmentTarget::AvailableWorkspace { workspace }, + engine::WorkspaceAccess::Edit, ), ], ) - .expect("linked fs"); + .expect("attached fs"); (blobs, store, fs, workspace_id) } @@ -1715,7 +1735,7 @@ mod tests { active_environment_id: None, environment_policy: None, subagents_policy: None, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), calls: vec![ToolInvocationRequest { builtin: Some(engine::BuiltinToolCallRuntime { spec: match &toolset.tools[&ToolName::new("vfs.write_file")].kind { @@ -1878,8 +1898,8 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn linked_vfs_file_system_lists_synthetic_directories_and_routes_links() { - let (_blobs, _store, fs, workspace_id) = test_linked_fs().await; + async fn attached_vfs_file_system_lists_synthetic_directories_and_routes_attachments() { + let (_blobs, _store, fs, workspace_id) = test_attached_fs().await; assert_eq!(fs.access_policy(), FileAccessPolicy::FullReadWrite); assert_eq!( @@ -1950,7 +1970,7 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn linked_vfs_file_system_rejects_invalid_link_tables() { + async fn attached_vfs_file_system_rejects_invalid_attachment_tables() { let blobs = Arc::new(InMemoryBlobStore::new()); let store = Arc::new(TestWorkspaceStore::default()); let snapshot_ref = BlobRef::from_bytes(b"snapshot"); @@ -1966,59 +1986,59 @@ mod tests { }; let duplicate = vec![ - resolved_link( + resolved_attachment( "/workspace", - ::vfs::ResolvedWorkspaceLinkTarget::AvailableWorkspace { + ::vfs::ResolvedWorkspaceAttachmentTarget::AvailableWorkspace { workspace: workspace.clone(), }, - engine::WorkspaceLinkAccess::ReadWrite, + engine::WorkspaceAccess::Edit, ), - resolved_link( + resolved_attachment( "/workspace", - ::vfs::ResolvedWorkspaceLinkTarget::AvailableWorkspace { workspace }, - engine::WorkspaceLinkAccess::ReadWrite, + ::vfs::ResolvedWorkspaceAttachmentTarget::AvailableWorkspace { workspace }, + engine::WorkspaceAccess::Edit, ), ]; assert!(matches!( - LinkedVfsFileSystem::new(blobs.clone(), store.clone(), duplicate), + AttachedVfsFileSystem::new(blobs.clone(), store.clone(), duplicate), Err(FsError::InvalidInput { .. }) )); let nested = vec![ - resolved_link( + resolved_attachment( "/skills", - ::vfs::ResolvedWorkspaceLinkTarget::AvailableSnapshot { + ::vfs::ResolvedWorkspaceAttachmentTarget::AvailableSnapshot { snapshot_ref: snapshot_ref.clone(), }, - engine::WorkspaceLinkAccess::ReadOnly, + engine::WorkspaceAccess::Read, ), - resolved_link( + resolved_attachment( "/skills/rust", - ::vfs::ResolvedWorkspaceLinkTarget::AvailableSnapshot { + ::vfs::ResolvedWorkspaceAttachmentTarget::AvailableSnapshot { snapshot_ref: snapshot_ref.clone(), }, - engine::WorkspaceLinkAccess::ReadOnly, + engine::WorkspaceAccess::Read, ), ]; assert!(matches!( - LinkedVfsFileSystem::new(blobs.clone(), store.clone(), nested), + AttachedVfsFileSystem::new(blobs.clone(), store.clone(), nested), Err(FsError::InvalidInput { .. }) )); - let writable_snapshot = vec![resolved_link( + let writable_snapshot = vec![resolved_attachment( "/skills/rust", - ::vfs::ResolvedWorkspaceLinkTarget::AvailableSnapshot { snapshot_ref }, - engine::WorkspaceLinkAccess::ReadWrite, + ::vfs::ResolvedWorkspaceAttachmentTarget::AvailableSnapshot { snapshot_ref }, + engine::WorkspaceAccess::Edit, )]; assert!(matches!( - LinkedVfsFileSystem::new(blobs, store, writable_snapshot), + AttachedVfsFileSystem::new(blobs, store, writable_snapshot), Err(FsError::InvalidInput { .. }) )); } #[tokio::test(flavor = "current_thread")] - async fn linked_vfs_file_system_copies_across_links() { - let (_blobs, _store, fs, _workspace_id) = test_linked_fs().await; + async fn attached_vfs_file_system_copies_across_attachments() { + let (_blobs, _store, fs, _workspace_id) = test_attached_fs().await; fs.copy( &FsPath::new("/skills/rust/SKILL.md").unwrap(), @@ -2060,8 +2080,8 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn existing_file_tools_work_against_linked_vfs_file_system() { - let (blobs, _store, fs, _workspace_id) = test_linked_fs().await; + async fn existing_file_tools_work_against_attached_vfs_file_system() { + let (blobs, _store, fs, _workspace_id) = test_attached_fs().await; let fs_ctx = FsToolContext::new(Arc::new(fs.clone()), blobs) .with_cwd(FsPath::new("/workspace").unwrap()); diff --git a/crates/tools/src/prompts/assembler.rs b/crates/tools/src/prompts/assembler.rs index d5e188b8..72789632 100644 --- a/crates/tools/src/prompts/assembler.rs +++ b/crates/tools/src/prompts/assembler.rs @@ -130,13 +130,13 @@ pub fn prompt_report_blob_refs(report: &PromptInstructionsReport) -> BTreeSet { refs.insert(source_snapshot_ref.clone()); } - PromptSourceLocation::LinkedWorkspace { + PromptSourceLocation::AttachedWorkspace { workspace_head_ref, .. } => { refs.insert(workspace_head_ref.clone()); @@ -396,7 +396,7 @@ async fn read_prompt_source( content_ref, text, bytes: bytes.len() as u64, - writable: input.root.access == engine::WorkspaceLinkAccess::ReadWrite, + writable: input.root.access == engine::WorkspaceAccess::Edit, warnings: Vec::new(), })) } @@ -518,24 +518,24 @@ fn source_location( ) -> Result { let prompt_file_path = vfs_path(path)?; match &root.source { - PromptRootSource::LinkedSnapshot { + PromptRootSource::AttachedSnapshot { snapshot_ref, - link_path, - } => Ok(PromptSourceLocation::LinkedSnapshot { + attachment_path, + } => Ok(PromptSourceLocation::AttachedSnapshot { source_snapshot_ref: snapshot_ref.clone(), - source_link_path: link_path.clone(), + source_attachment_path: attachment_path.clone(), prompt_file_path, }), - PromptRootSource::LinkedWorkspace { + PromptRootSource::AttachedWorkspace { workspace_id, workspace_head_ref, workspace_revision, - link_path, - } => Ok(PromptSourceLocation::LinkedWorkspace { + attachment_path, + } => Ok(PromptSourceLocation::AttachedWorkspace { workspace_id: workspace_id.clone(), workspace_revision: *workspace_revision, workspace_head_ref: workspace_head_ref.clone(), - source_link_path: link_path.clone(), + source_attachment_path: attachment_path.clone(), prompt_file_path, }), } @@ -546,14 +546,14 @@ fn source_input_for_root( ) -> Result { let root_path = vfs_path(&root.root_path)?; match &root.source { - PromptRootSource::LinkedSnapshot { snapshot_ref, .. } => { + PromptRootSource::AttachedSnapshot { snapshot_ref, .. } => { Ok(PromptSourceFingerprintInput::SnapshotRoot { root_id: root.root_id.clone(), snapshot_ref: snapshot_ref.clone(), root_path, }) } - PromptRootSource::LinkedWorkspace { + PromptRootSource::AttachedWorkspace { workspace_id, workspace_head_ref, workspace_revision, @@ -854,7 +854,7 @@ struct SourceFingerprintPayload<'a> { mod tests { use std::sync::Arc; - use engine::WorkspaceLinkAccess; + use engine::WorkspaceAccess; use engine::storage::{BlobStore, InMemoryBlobStore}; use super::*; @@ -1111,11 +1111,11 @@ mod tests { root: PromptRoot { root_id: root_id.to_owned(), root_path: FsPath::new(root_path).unwrap(), - source: PromptRootSource::LinkedSnapshot { + source: PromptRootSource::AttachedSnapshot { snapshot_ref: BlobRef::from_bytes(b"snapshot-1"), - link_path: VfsPath::parse("/workspace").unwrap(), + attachment_path: VfsPath::parse("/workspace").unwrap(), }, - access: WorkspaceLinkAccess::ReadOnly, + access: WorkspaceAccess::Read, }, fs, } diff --git a/crates/tools/src/prompts/mod.rs b/crates/tools/src/prompts/mod.rs index f6c98882..2b579a1e 100644 --- a/crates/tools/src/prompts/mod.rs +++ b/crates/tools/src/prompts/mod.rs @@ -15,6 +15,7 @@ pub use assembler::{ }; pub use model::*; pub use vfs::{ - LinkedVfsPromptRoots, PromptVfsRootError, VfsPromptRootSpec, configured_vfs_prompt_root_specs, - conventional_vfs_prompt_root_specs, resolve_linked_vfs_prompt_roots, + AttachedVfsPromptRoots, PromptVfsRootError, VfsPromptRootSpec, + configured_vfs_prompt_root_specs, conventional_vfs_prompt_root_specs, + resolve_attached_vfs_prompt_roots, }; diff --git a/crates/tools/src/prompts/model.rs b/crates/tools/src/prompts/model.rs index 4eb4a2ba..3750b7b4 100644 --- a/crates/tools/src/prompts/model.rs +++ b/crates/tools/src/prompts/model.rs @@ -1,4 +1,4 @@ -use engine::{BlobRef, ContextEntryKey, WorkspaceLinkAccess}; +use engine::{BlobRef, ContextEntryKey, WorkspaceAccess}; use serde::{Deserialize, Serialize}; use vfs::{VfsPath, VfsWorkspaceId}; @@ -102,19 +102,24 @@ pub struct PromptSourceReport { pub writable: bool, } +// Preserve the serialized names used by stored source reports. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum PromptSourceLocation { - LinkedSnapshot { + #[serde(rename = "linked_snapshot")] + AttachedSnapshot { source_snapshot_ref: BlobRef, - source_link_path: VfsPath, + #[serde(rename = "source_link_path")] + source_attachment_path: VfsPath, prompt_file_path: VfsPath, }, - LinkedWorkspace { + #[serde(rename = "linked_workspace")] + AttachedWorkspace { workspace_id: VfsWorkspaceId, workspace_revision: u64, workspace_head_ref: BlobRef, - source_link_path: VfsPath, + #[serde(rename = "source_link_path")] + source_attachment_path: VfsPath, prompt_file_path: VfsPath, }, } @@ -139,7 +144,7 @@ impl PromptWarning { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum PromptWarningKind { - UnavailableWorkspaceLink { reason: String }, + UnavailableWorkspaceAttachment { reason: String }, Filesystem { message: String }, InvalidPath { message: String }, InvalidUtf8 { message: String }, @@ -167,19 +172,49 @@ pub struct PromptRoot { pub root_id: String, pub root_path: FsPath, pub source: PromptRootSource, - pub access: WorkspaceLinkAccess, + pub access: WorkspaceAccess, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum PromptRootSource { - LinkedSnapshot { + AttachedSnapshot { snapshot_ref: BlobRef, - link_path: VfsPath, + attachment_path: VfsPath, }, - LinkedWorkspace { + AttachedWorkspace { workspace_id: VfsWorkspaceId, workspace_head_ref: BlobRef, workspace_revision: u64, - link_path: VfsPath, + attachment_path: VfsPath, }, } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn stored_attachment_sources_keep_their_serialized_format() { + let snapshot_ref = format!("sha256:{}", "a".repeat(64)); + let sources = [ + json!({ + "type": "linked_snapshot", + "source_snapshot_ref": snapshot_ref, + "source_link_path": "/workspace", + "prompt_file_path": "/workspace/AGENTS.md" + }), + json!({ + "type": "linked_workspace", + "workspace_id": "workspace_1", "workspace_revision": 1, "workspace_head_ref": snapshot_ref, + "source_link_path": "/workspace", + "prompt_file_path": "/workspace/AGENTS.md" + }), + ]; + for source in sources { + let decoded: PromptSourceLocation = + serde_json::from_value(source.clone()).expect("read stored source"); + assert_eq!(serde_json::to_value(decoded).expect("write source"), source); + } + } +} diff --git a/crates/tools/src/prompts/vfs.rs b/crates/tools/src/prompts/vfs.rs index bdd75ee7..1ff7ca93 100644 --- a/crates/tools/src/prompts/vfs.rs +++ b/crates/tools/src/prompts/vfs.rs @@ -1,15 +1,16 @@ -//! Prompt root resolution for CAS-backed VFS workspace links. +//! Prompt root resolution for CAS-backed VFS workspace attachments. use std::{collections::BTreeSet, sync::Arc}; use engine::storage::BlobStore; use thiserror::Error; use vfs::{ - ResolvedWorkspaceLink, ResolvedWorkspaceLinkTarget, VfsPath, VfsWorkspaceId, VfsWorkspaceStore, + ResolvedWorkspaceAttachment, ResolvedWorkspaceAttachmentTarget, VfsPath, VfsWorkspaceId, + VfsWorkspaceStore, }; use crate::{ - fs::{FileSystem, FsError, FsPath, LinkedVfsFileSystem}, + fs::{AttachedVfsFileSystem, FileSystem, FsError, FsPath}, prompts::{PromptRoot, PromptRootInput, PromptRootSource, PromptWarning, PromptWarningKind}, }; @@ -28,14 +29,14 @@ impl VfsPromptRootSpec { } } -pub struct LinkedVfsPromptRoots { - fs: LinkedVfsFileSystem, +pub struct AttachedVfsPromptRoots { + fs: AttachedVfsFileSystem, roots: Vec, warnings: Vec, } -impl LinkedVfsPromptRoots { - pub fn fs(&self) -> &LinkedVfsFileSystem { +impl AttachedVfsPromptRoots { + pub fn fs(&self) -> &AttachedVfsFileSystem { &self.fs } @@ -47,7 +48,7 @@ impl LinkedVfsPromptRoots { &self.warnings } - pub fn into_parts(self) -> (LinkedVfsFileSystem, Vec, Vec) { + pub fn into_parts(self) -> (AttachedVfsFileSystem, Vec, Vec) { (self.fs, self.roots, self.warnings) } @@ -92,8 +93,8 @@ pub enum PromptVfsRootError { #[error("invalid configured VFS prompt root {root}: {message}")] InvalidConfiguredRoot { root: String, message: String }, - #[error("VFS prompt root {root_id} at {root_path} is not under a workspace link")] - UnlinkedRoot { root_id: String, root_path: VfsPath }, + #[error("VFS prompt root {root_id} at {root_path} is not under a workspace attachment")] + UnattachedRoot { root_id: String, root_path: VfsPath }, #[error("invalid VFS prompt root {root_id} at {root_path}: {message}")] InvalidRootPath { @@ -102,7 +103,7 @@ pub enum PromptVfsRootError { message: String, }, - #[error("failed to build linked VFS filesystem: {message}")] + #[error("failed to build attached VFS filesystem: {message}")] Filesystem { message: String }, #[error("failed to read VFS workspace {workspace_id}: {message}")] @@ -112,14 +113,14 @@ pub enum PromptVfsRootError { }, } -pub async fn resolve_linked_vfs_prompt_roots( +pub async fn resolve_attached_vfs_prompt_roots( blobs: Arc, workspace_store: Arc, - links: Vec, + attachments: Vec, specs: Vec, -) -> Result { +) -> Result { validate_specs(&specs)?; - let fs = LinkedVfsFileSystem::new(blobs, workspace_store, links).map_err(|error| { + let fs = AttachedVfsFileSystem::new(blobs, workspace_store, attachments).map_err(|error| { PromptVfsRootError::Filesystem { message: error.to_string(), } @@ -128,23 +129,24 @@ pub async fn resolve_linked_vfs_prompt_roots( let mut roots = Vec::with_capacity(specs.len()); let mut warnings = Vec::new(); for spec in specs { - if let Some(link) = link_for_root(fs.links(), &spec.root_path) - && let ResolvedWorkspaceLinkTarget::Unavailable { reason, .. } = &link.target + if let Some(attachment) = attachment_for_root(fs.attachments(), &spec.root_path) + && let ResolvedWorkspaceAttachmentTarget::Unavailable { reason, .. } = + &attachment.target { warnings.push(PromptWarning::new( spec.root_id.clone(), Some(spec.root_path.to_string()), - PromptWarningKind::UnavailableWorkspaceLink { + PromptWarningKind::UnavailableWorkspaceAttachment { reason: reason.clone(), }, )); } - if let Some(root) = resolve_root(fs.links(), spec).await? { + if let Some(root) = resolve_root(fs.attachments(), spec).await? { roots.push(root); } } - Ok(LinkedVfsPromptRoots { + Ok(AttachedVfsPromptRoots { fs, roots, warnings, @@ -152,31 +154,31 @@ pub async fn resolve_linked_vfs_prompt_roots( } pub fn conventional_vfs_prompt_root_specs( - links: &[ResolvedWorkspaceLink], + attachments: &[ResolvedWorkspaceAttachment], ) -> Vec { let mut specs = Vec::new(); let mut seen = BTreeSet::new(); - for link in links { + for attachment in attachments { push_spec( &mut specs, &mut seen, - workspace_prompt_root(&link.path, ".lightspeed/prompts"), + workspace_prompt_root(&attachment.path, ".lightspeed/prompts"), ); push_spec( &mut specs, &mut seen, - workspace_prompt_root(&link.path, ".agents/prompts"), + workspace_prompt_root(&attachment.path, ".agents/prompts"), ); } specs } pub fn configured_vfs_prompt_root_specs( - links: &[ResolvedWorkspaceLink], + attachments: &[ResolvedWorkspaceAttachment], roots: Option<&[String]>, ) -> Result, PromptVfsRootError> { let Some(roots) = roots else { - return Ok(conventional_vfs_prompt_root_specs(links)); + return Ok(conventional_vfs_prompt_root_specs(attachments)); }; roots .iter() @@ -205,8 +207,8 @@ fn push_spec( } } -fn workspace_prompt_root(link_path: &VfsPath, suffix: &str) -> VfsPromptRootSpec { - let path = append_vfs_path(link_path, suffix); +fn workspace_prompt_root(attachment_path: &VfsPath, suffix: &str) -> VfsPromptRootSpec { + let path = append_vfs_path(attachment_path, suffix); VfsPromptRootSpec::new(root_id_for_vfs_path("workspace", &path), path) } @@ -241,14 +243,15 @@ fn validate_specs(specs: &[VfsPromptRootSpec]) -> Result<(), PromptVfsRootError> } async fn resolve_root( - links: &[ResolvedWorkspaceLink], + attachments: &[ResolvedWorkspaceAttachment], spec: VfsPromptRootSpec, ) -> Result, PromptVfsRootError> { - let link = - link_for_root(links, &spec.root_path).ok_or_else(|| PromptVfsRootError::UnlinkedRoot { + let attachment = attachment_for_root(attachments, &spec.root_path).ok_or_else(|| { + PromptVfsRootError::UnattachedRoot { root_id: spec.root_id.clone(), root_path: spec.root_path.clone(), - })?; + } + })?; let root_path = FsPath::new(spec.root_path.as_str()).map_err(|error| { PromptVfsRootError::InvalidRootPath { root_id: spec.root_id.clone(), @@ -256,39 +259,39 @@ async fn resolve_root( message: error.to_string(), } })?; - let source = match &link.target { - ResolvedWorkspaceLinkTarget::AvailableSnapshot { snapshot_ref } => { - PromptRootSource::LinkedSnapshot { + let source = match &attachment.target { + ResolvedWorkspaceAttachmentTarget::AvailableSnapshot { snapshot_ref } => { + PromptRootSource::AttachedSnapshot { snapshot_ref: snapshot_ref.clone(), - link_path: link.path.clone(), + attachment_path: attachment.path.clone(), } } - ResolvedWorkspaceLinkTarget::AvailableWorkspace { workspace } => { - PromptRootSource::LinkedWorkspace { + ResolvedWorkspaceAttachmentTarget::AvailableWorkspace { workspace } => { + PromptRootSource::AttachedWorkspace { workspace_id: workspace.workspace_id.clone(), workspace_head_ref: workspace.head_snapshot_ref.clone(), workspace_revision: workspace.revision, - link_path: link.path.clone(), + attachment_path: attachment.path.clone(), } } - ResolvedWorkspaceLinkTarget::Unavailable { .. } => return Ok(None), + ResolvedWorkspaceAttachmentTarget::Unavailable { .. } => return Ok(None), }; Ok(Some(PromptRoot { root_id: spec.root_id, root_path, source, - access: link.access, + access: attachment.access, })) } -fn link_for_root<'a>( - links: &'a [ResolvedWorkspaceLink], +fn attachment_for_root<'a>( + attachments: &'a [ResolvedWorkspaceAttachment], root_path: &VfsPath, -) -> Option<&'a ResolvedWorkspaceLink> { - links +) -> Option<&'a ResolvedWorkspaceAttachment> { + attachments .iter() - .find(|link| vfs_path_starts_with(root_path, &link.path)) + .find(|attachment| vfs_path_starts_with(root_path, &attachment.path)) } fn vfs_path_starts_with(path: &VfsPath, base: &VfsPath) -> bool { @@ -306,11 +309,11 @@ mod tests { use std::collections::BTreeMap; use async_trait::async_trait; - use engine::{BlobRef, WorkspaceLinkAccess, storage::InMemoryBlobStore}; + use engine::{BlobRef, WorkspaceAccess, storage::InMemoryBlobStore}; use vfs::{ CompareAndSetVfsWorkspaceHead, CreateInlineSnapshotRequest, CreateVfsWorkspaceRecord, - InlineFile, ResolvedWorkspaceLink, ResolvedWorkspaceLinkTarget, VfsCatalogError, - VfsWorkspaceRecord, create_inline_snapshot, + InlineFile, ResolvedWorkspaceAttachment, ResolvedWorkspaceAttachmentTarget, + VfsCatalogError, VfsWorkspaceRecord, create_inline_snapshot, }; use super::*; @@ -345,15 +348,15 @@ mod tests { }) .await .expect("workspace"); - let links = vec![resolved_link( + let attachments = vec![resolved_attachment( "/workspace", - ResolvedWorkspaceLinkTarget::AvailableWorkspace { workspace }, - WorkspaceLinkAccess::ReadWrite, + ResolvedWorkspaceAttachmentTarget::AvailableWorkspace { workspace }, + WorkspaceAccess::Edit, )]; - let specs = conventional_vfs_prompt_root_specs(&links); + let specs = conventional_vfs_prompt_root_specs(&attachments); let resolved = - resolve_linked_vfs_prompt_roots(blobs.clone(), workspace_store, links, specs) + resolve_attached_vfs_prompt_roots(blobs.clone(), workspace_store, attachments, specs) .await .expect("resolve roots"); let inputs = resolved @@ -378,36 +381,36 @@ mod tests { ); assert!(matches!( &build.report.sources[0].source, - crate::prompts::PromptSourceLocation::LinkedWorkspace { + crate::prompts::PromptSourceLocation::AttachedWorkspace { workspace_id: source_workspace_id, workspace_revision, - source_link_path, + source_attachment_path, prompt_file_path, .. } if source_workspace_id == &workspace_id && *workspace_revision == 0 - && source_link_path.as_str() == "/workspace" + && source_attachment_path.as_str() == "/workspace" && prompt_file_path.as_str() == "/workspace/.lightspeed/prompts/instructions.md" )); assert!(build.report.sources[0].writable); } #[test] - fn conventional_prompt_roots_cover_workspace_and_snapshot_links() { + fn conventional_prompt_roots_cover_workspace_and_snapshot_attachments() { let roots = conventional_vfs_prompt_root_specs(&[ - resolved_link( + resolved_attachment( "/workspace", - ResolvedWorkspaceLinkTarget::AvailableWorkspace { + ResolvedWorkspaceAttachmentTarget::AvailableWorkspace { workspace: workspace_record("workspace_1", BlobRef::from_bytes(b"head")), }, - WorkspaceLinkAccess::ReadWrite, + WorkspaceAccess::Edit, ), - resolved_link( + resolved_attachment( "/skills/system", - ResolvedWorkspaceLinkTarget::AvailableSnapshot { + ResolvedWorkspaceAttachmentTarget::AvailableSnapshot { snapshot_ref: engine::BlobRef::from_bytes(b"snapshot"), }, - WorkspaceLinkAccess::ReadOnly, + WorkspaceAccess::Read, ), ]); @@ -442,12 +445,12 @@ mod tests { ); } - fn resolved_link( + fn resolved_attachment( path: &str, - target: ResolvedWorkspaceLinkTarget, - access: WorkspaceLinkAccess, - ) -> ResolvedWorkspaceLink { - ResolvedWorkspaceLink { + target: ResolvedWorkspaceAttachmentTarget, + access: WorkspaceAccess, + ) -> ResolvedWorkspaceAttachment { + ResolvedWorkspaceAttachment { path: VfsPath::parse(path).unwrap(), target, access, diff --git a/crates/tools/src/runtime/inline.rs b/crates/tools/src/runtime/inline.rs index 7d6f9c29..d96fe783 100644 --- a/crates/tools/src/runtime/inline.rs +++ b/crates/tools/src/runtime/inline.rs @@ -176,7 +176,7 @@ impl InlineToolRuntime { let requirements = tool.requirements(); let vfs = if requirements.vfs { Some(self.vfs.as_ref().ok_or_else(|| ToolError::InvalidRequest { - message: "no_vfs_workspace_links".into(), + message: "no_vfs_workspace_attachments".into(), })?) } else { None @@ -198,7 +198,7 @@ impl InlineToolRuntime { .as_ref() .map(BuiltinToolContext::Vfs) .ok_or_else(|| ToolError::InvalidRequest { - message: "no_vfs_workspace_links".to_owned(), + message: "no_vfs_workspace_attachments".to_owned(), }) } BuiltinToolDomain::Environment => { @@ -641,7 +641,7 @@ mod tests { active_environment_id: None, environment_policy: None, subagents_policy: None, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), calls: vec![call], } } @@ -959,7 +959,7 @@ mod tests { active_environment_id: None, environment_policy: None, subagents_policy: None, - workspace_links: Vec::new(), + workspace_attachments: Vec::new(), calls: vec![call(args_ref, "vfs_read_file")], }, ) diff --git a/crates/tools/src/skills/catalog.rs b/crates/tools/src/skills/catalog.rs index 0833f86b..dd02ea27 100644 --- a/crates/tools/src/skills/catalog.rs +++ b/crates/tools/src/skills/catalog.rs @@ -102,7 +102,7 @@ pub fn skill_catalog_blob_refs(catalog: &SkillCatalogSnapshot) -> BTreeSet SkillSource { match &root.source { - SkillCatalogRootSource::LinkedSnapshot { snapshot_ref, .. } => SkillSource::Snapshot { + SkillCatalogRootSource::AttachedSnapshot { snapshot_ref, .. } => SkillSource::Snapshot { root_id: root.root_id.clone(), snapshot_ref: snapshot_ref.clone(), }, - SkillCatalogRootSource::LinkedWorkspace { workspace_id, .. } => SkillSource::Workspace { + SkillCatalogRootSource::AttachedWorkspace { workspace_id, .. } => SkillSource::Workspace { root_id: root.root_id.clone(), workspace_id: workspace_id.clone(), }, @@ -337,22 +337,22 @@ fn location_for_skill( skill_doc_path: &FsPath, ) -> Result { match &root.source { - SkillCatalogRootSource::LinkedSnapshot { + SkillCatalogRootSource::AttachedSnapshot { snapshot_ref, - link_path, - } => Ok(SkillLocation::LinkedSnapshot { + attachment_path, + } => Ok(SkillLocation::AttachedSnapshot { source_snapshot_ref: snapshot_ref.clone(), - source_link_path: link_path.clone(), + source_attachment_path: attachment_path.clone(), skill_dir_path: vfs_path(skill_dir_path)?, skill_doc_path: vfs_path(skill_doc_path)?, }), - SkillCatalogRootSource::LinkedWorkspace { + SkillCatalogRootSource::AttachedWorkspace { workspace_id, workspace_head_ref: _, - link_path, - } => Ok(SkillLocation::LinkedWorkspace { + attachment_path, + } => Ok(SkillLocation::AttachedWorkspace { workspace_id: workspace_id.clone(), - source_link_path: link_path.clone(), + source_attachment_path: attachment_path.clone(), skill_dir_path: vfs_path(skill_dir_path)?, skill_doc_path: vfs_path(skill_doc_path)?, }), @@ -374,13 +374,13 @@ fn skill_id_for_path(root: &SkillCatalogRoot, skill_doc_path: &FsPath) -> SkillI fn source_key(source: &SkillCatalogRootSource) -> String { match source { - SkillCatalogRootSource::LinkedSnapshot { snapshot_ref, .. } => { + SkillCatalogRootSource::AttachedSnapshot { snapshot_ref, .. } => { format!("snapshot:{snapshot_ref}") } - SkillCatalogRootSource::LinkedWorkspace { + SkillCatalogRootSource::AttachedWorkspace { workspace_id, workspace_head_ref: _, - link_path: _, + attachment_path: _, } => format!("workspace:{workspace_id}"), } } @@ -429,17 +429,17 @@ struct RootScanResult { fn source_input_for_root(input: &SkillCatalogRootInput<'_>) -> SkillCatalogSourceInput { match &input.root.source { - SkillCatalogRootSource::LinkedSnapshot { snapshot_ref, .. } => { + SkillCatalogRootSource::AttachedSnapshot { snapshot_ref, .. } => { SkillCatalogSourceInput::SnapshotRoot { root_id: input.root.root_id.clone(), snapshot_ref: snapshot_ref.clone(), root_path: vfs_path(&input.root.root_path).unwrap_or_else(|_| VfsPath::root()), } } - SkillCatalogRootSource::LinkedWorkspace { + SkillCatalogRootSource::AttachedWorkspace { workspace_id, workspace_head_ref, - link_path: _, + attachment_path: _, } => SkillCatalogSourceInput::WorkspaceRoot { root_id: input.root.root_id.clone(), workspace_id: workspace_id.clone(), @@ -568,9 +568,9 @@ mod tests { SkillCatalogRoot { root_id: "system".to_owned(), root_path: FsPath::new("/skills").unwrap(), - source: SkillCatalogRootSource::LinkedSnapshot { + source: SkillCatalogRootSource::AttachedSnapshot { snapshot_ref: BlobRef::from_bytes(b"snapshot-1"), - link_path: VfsPath::parse("/skills").unwrap(), + attachment_path: VfsPath::parse("/skills").unwrap(), }, trust: SkillTrustLevel::System, scope: SkillScope::Global, @@ -594,7 +594,7 @@ mod tests { ); assert!(matches!( build.catalog.skills[0].location, - SkillLocation::LinkedSnapshot { .. } + SkillLocation::AttachedSnapshot { .. } )); } @@ -627,9 +627,9 @@ mod tests { SkillCatalogRoot { root_id: "system".to_owned(), root_path: FsPath::new("/skills").unwrap(), - source: SkillCatalogRootSource::LinkedSnapshot { + source: SkillCatalogRootSource::AttachedSnapshot { snapshot_ref: snapshot_ref.clone(), - link_path: VfsPath::parse("/skills").unwrap(), + attachment_path: VfsPath::parse("/skills").unwrap(), }, trust: SkillTrustLevel::System, scope: SkillScope::Global, @@ -684,9 +684,9 @@ mod tests { SkillCatalogRoot { root_id: "one".to_owned(), root_path: FsPath::new("/skills-one").unwrap(), - source: SkillCatalogRootSource::LinkedSnapshot { + source: SkillCatalogRootSource::AttachedSnapshot { snapshot_ref: BlobRef::from_bytes(b"snapshot-1"), - link_path: VfsPath::parse("/skills-one").unwrap(), + attachment_path: VfsPath::parse("/skills-one").unwrap(), }, trust: SkillTrustLevel::System, scope: SkillScope::Global, @@ -697,9 +697,9 @@ mod tests { SkillCatalogRoot { root_id: "two".to_owned(), root_path: FsPath::new("/skills-two").unwrap(), - source: SkillCatalogRootSource::LinkedSnapshot { + source: SkillCatalogRootSource::AttachedSnapshot { snapshot_ref: BlobRef::from_bytes(b"snapshot-2"), - link_path: VfsPath::parse("/skills-two").unwrap(), + attachment_path: VfsPath::parse("/skills-two").unwrap(), }, trust: SkillTrustLevel::User, scope: SkillScope::Global, @@ -738,9 +738,9 @@ mod tests { SkillCatalogRoot { root_id: "system".to_owned(), root_path: FsPath::new("/skills").unwrap(), - source: SkillCatalogRootSource::LinkedSnapshot { + source: SkillCatalogRootSource::AttachedSnapshot { snapshot_ref: BlobRef::from_bytes(b"snapshot-1"), - link_path: VfsPath::parse("/skills").unwrap(), + attachment_path: VfsPath::parse("/skills").unwrap(), }, trust: SkillTrustLevel::System, scope: SkillScope::Global, @@ -774,10 +774,10 @@ mod tests { let mut root = SkillCatalogRoot { root_id: "vfs".to_owned(), root_path: FsPath::new("/skills").unwrap(), - source: SkillCatalogRootSource::LinkedWorkspace { + source: SkillCatalogRootSource::AttachedWorkspace { workspace_id: VfsWorkspaceId::new("workspace-skills"), workspace_head_ref: BlobRef::from_bytes(b"head-1"), - link_path: VfsPath::parse("/skills").unwrap(), + attachment_path: VfsPath::parse("/skills").unwrap(), }, trust: SkillTrustLevel::User, scope: SkillScope::Global, @@ -800,7 +800,7 @@ mod tests { .await .expect("edit body"); - if let SkillCatalogRootSource::LinkedWorkspace { + if let SkillCatalogRootSource::AttachedWorkspace { workspace_head_ref, .. } = &mut root.source { @@ -835,10 +835,10 @@ mod tests { let root = |head: &[u8]| SkillCatalogRoot { root_id: "workspace".to_owned(), root_path: FsPath::new("/workspace/.lightspeed/skills").unwrap(), - source: SkillCatalogRootSource::LinkedWorkspace { + source: SkillCatalogRootSource::AttachedWorkspace { workspace_id: VfsWorkspaceId::new("workspace-1"), workspace_head_ref: BlobRef::from_bytes(head), - link_path: VfsPath::parse("/workspace").unwrap(), + attachment_path: VfsPath::parse("/workspace").unwrap(), }, trust: SkillTrustLevel::Project, scope: SkillScope::Global, @@ -942,9 +942,9 @@ mod tests { SkillCatalogRoot { root_id: root_id.to_owned(), root_path: FsPath::new(root_path).unwrap(), - source: SkillCatalogRootSource::LinkedSnapshot { + source: SkillCatalogRootSource::AttachedSnapshot { snapshot_ref: BlobRef::from_bytes(format!("{root_id}:{root_path}").as_bytes()), - link_path: VfsPath::parse(root_path).unwrap(), + attachment_path: VfsPath::parse(root_path).unwrap(), }, trust: SkillTrustLevel::System, scope: SkillScope::Global, diff --git a/crates/tools/src/skills/catalog_text.rs b/crates/tools/src/skills/catalog_text.rs index cd6a2654..774e6fe5 100644 --- a/crates/tools/src/skills/catalog_text.rs +++ b/crates/tools/src/skills/catalog_text.rs @@ -36,14 +36,14 @@ fn skill_catalog_entry(skill: &SkillMetadata) -> String { fn skill_doc_path(location: &SkillLocation) -> &str { match location { - SkillLocation::LinkedSnapshot { skill_doc_path, .. } - | SkillLocation::LinkedWorkspace { skill_doc_path, .. } => skill_doc_path.as_str(), + SkillLocation::AttachedSnapshot { skill_doc_path, .. } + | SkillLocation::AttachedWorkspace { skill_doc_path, .. } => skill_doc_path.as_str(), } } fn skill_dir_path(location: &SkillLocation) -> &str { match location { - SkillLocation::LinkedSnapshot { skill_dir_path, .. } - | SkillLocation::LinkedWorkspace { skill_dir_path, .. } => skill_dir_path.as_str(), + SkillLocation::AttachedSnapshot { skill_dir_path, .. } + | SkillLocation::AttachedWorkspace { skill_dir_path, .. } => skill_dir_path.as_str(), } } diff --git a/crates/tools/src/skills/environment.rs b/crates/tools/src/skills/environment.rs index 985088ab..a72e8af4 100644 --- a/crates/tools/src/skills/environment.rs +++ b/crates/tools/src/skills/environment.rs @@ -228,9 +228,9 @@ mod tests { root: SkillCatalogRoot { root_id: "workspace".into(), root_path: FsPath::new("/skills").unwrap(), - source: SkillCatalogRootSource::LinkedSnapshot { + source: SkillCatalogRootSource::AttachedSnapshot { snapshot_ref: BlobRef::from_bytes(b"snapshot"), - link_path: ::vfs::VfsPath::parse("/skills").unwrap(), + attachment_path: ::vfs::VfsPath::parse("/skills").unwrap(), }, trust: SkillTrustLevel::User, scope: SkillScope::Global, diff --git a/crates/tools/src/skills/mod.rs b/crates/tools/src/skills/mod.rs index 21fd37ad..20d8e85e 100644 --- a/crates/tools/src/skills/mod.rs +++ b/crates/tools/src/skills/mod.rs @@ -17,8 +17,8 @@ pub use id::SkillId; pub use model::*; pub use parser::{SkillFrontmatter, SkillParseError, parse_skill_frontmatter}; pub use vfs::{ - LinkedVfsSkillCatalogRoots, SkillVfsRootError, VfsSkillRootSpec, - configured_vfs_skill_root_specs, resolve_linked_vfs_skill_roots, + AttachedVfsSkillCatalogRoots, SkillVfsRootError, VfsSkillRootSpec, + configured_vfs_skill_root_specs, resolve_attached_vfs_skill_roots, }; pub mod environment; diff --git a/crates/tools/src/skills/model.rs b/crates/tools/src/skills/model.rs index 534e44fd..eac5f016 100644 --- a/crates/tools/src/skills/model.rs +++ b/crates/tools/src/skills/model.rs @@ -149,18 +149,23 @@ pub struct SkillDependencies { pub tools: Vec, } +// Preserve the serialized names used by stored source reports. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum SkillLocation { - LinkedSnapshot { + #[serde(rename = "linked_snapshot")] + AttachedSnapshot { source_snapshot_ref: BlobRef, - source_link_path: VfsPath, + #[serde(rename = "source_link_path")] + source_attachment_path: VfsPath, skill_dir_path: VfsPath, skill_doc_path: VfsPath, }, - LinkedWorkspace { + #[serde(rename = "linked_workspace")] + AttachedWorkspace { workspace_id: VfsWorkspaceId, - source_link_path: VfsPath, + #[serde(rename = "source_link_path")] + source_attachment_path: VfsPath, skill_dir_path: VfsPath, skill_doc_path: VfsPath, }, @@ -190,7 +195,7 @@ impl SkillLoadWarning { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum SkillLoadWarningKind { - UnavailableWorkspaceLink { reason: String }, + UnavailableWorkspaceAttachment { reason: String }, MissingSkillDoc, InvalidSkillDoc { message: String }, Filesystem { message: String }, @@ -207,13 +212,43 @@ pub struct SkillCatalogRoot { #[derive(Clone, Debug, PartialEq, Eq)] pub enum SkillCatalogRootSource { - LinkedSnapshot { + AttachedSnapshot { snapshot_ref: BlobRef, - link_path: VfsPath, + attachment_path: VfsPath, }, - LinkedWorkspace { + AttachedWorkspace { workspace_id: VfsWorkspaceId, workspace_head_ref: BlobRef, - link_path: VfsPath, + attachment_path: VfsPath, }, } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn stored_attachment_sources_keep_their_serialized_format() { + let snapshot_ref = format!("sha256:{}", "a".repeat(64)); + let sources = [ + json!({ + "type": "linked_snapshot", + "source_snapshot_ref": snapshot_ref, + "source_link_path": "/workspace", + "skill_dir_path": "/workspace/skills/check", "skill_doc_path": "/workspace/skills/check/SKILL.md" + }), + json!({ + "type": "linked_workspace", + "workspace_id": "workspace_1", + "source_link_path": "/workspace", + "skill_dir_path": "/workspace/skills/check", "skill_doc_path": "/workspace/skills/check/SKILL.md" + }), + ]; + for source in sources { + let decoded: SkillLocation = + serde_json::from_value(source.clone()).expect("read stored source"); + assert_eq!(serde_json::to_value(decoded).expect("write source"), source); + } + } +} diff --git a/crates/tools/src/skills/vfs.rs b/crates/tools/src/skills/vfs.rs index 1f29b383..b76ec7ee 100644 --- a/crates/tools/src/skills/vfs.rs +++ b/crates/tools/src/skills/vfs.rs @@ -1,15 +1,16 @@ -//! Skill catalog root resolution for CAS-backed VFS workspace links. +//! Skill catalog root resolution for CAS-backed VFS workspace attachments. use std::{collections::BTreeSet, sync::Arc}; use engine::storage::BlobStore; use thiserror::Error; use vfs::{ - ResolvedWorkspaceLink, ResolvedWorkspaceLinkTarget, VfsPath, VfsWorkspaceId, VfsWorkspaceStore, + ResolvedWorkspaceAttachment, ResolvedWorkspaceAttachmentTarget, VfsPath, VfsWorkspaceId, + VfsWorkspaceStore, }; use crate::{ - fs::{FileSystem, FsError, FsPath, LinkedVfsFileSystem}, + fs::{AttachedVfsFileSystem, FileSystem, FsError, FsPath}, skills::{ SkillCatalogRoot, SkillCatalogRootInput, SkillCatalogRootSource, SkillLoadWarning, SkillLoadWarningKind, SkillScope, SkillTrustLevel, @@ -40,14 +41,14 @@ impl VfsSkillRootSpec { } } -pub struct LinkedVfsSkillCatalogRoots { - fs: LinkedVfsFileSystem, +pub struct AttachedVfsSkillCatalogRoots { + fs: AttachedVfsFileSystem, roots: Vec, warnings: Vec, } -impl LinkedVfsSkillCatalogRoots { - pub fn fs(&self) -> &LinkedVfsFileSystem { +impl AttachedVfsSkillCatalogRoots { + pub fn fs(&self) -> &AttachedVfsFileSystem { &self.fs } @@ -62,7 +63,7 @@ impl LinkedVfsSkillCatalogRoots { pub fn into_parts( self, ) -> ( - LinkedVfsFileSystem, + AttachedVfsFileSystem, Vec, Vec, ) { @@ -110,8 +111,8 @@ pub enum SkillVfsRootError { #[error("invalid configured VFS skill root {root}: {message}")] InvalidConfiguredRoot { root: String, message: String }, - #[error("VFS skill root {root_id} at {root_path} is not under a workspace link")] - UnlinkedRoot { root_id: String, root_path: VfsPath }, + #[error("VFS skill root {root_id} at {root_path} is not under a workspace attachment")] + UnattachedRoot { root_id: String, root_path: VfsPath }, #[error("invalid VFS skill root {root_id} at {root_path}: {message}")] InvalidRootPath { @@ -120,7 +121,7 @@ pub enum SkillVfsRootError { message: String, }, - #[error("failed to build linked VFS filesystem: {message}")] + #[error("failed to build attached VFS filesystem: {message}")] Filesystem { message: String }, #[error("failed to read VFS workspace {workspace_id}: {message}")] @@ -130,14 +131,14 @@ pub enum SkillVfsRootError { }, } -pub async fn resolve_linked_vfs_skill_roots( +pub async fn resolve_attached_vfs_skill_roots( blobs: Arc, workspace_store: Arc, - links: Vec, + attachments: Vec, specs: Vec, -) -> Result { +) -> Result { validate_specs(&specs)?; - let fs = LinkedVfsFileSystem::new(blobs, workspace_store, links).map_err(|error| { + let fs = AttachedVfsFileSystem::new(blobs, workspace_store, attachments).map_err(|error| { SkillVfsRootError::Filesystem { message: error.to_string(), } @@ -146,23 +147,24 @@ pub async fn resolve_linked_vfs_skill_roots( let mut roots = Vec::with_capacity(specs.len()); let mut warnings = Vec::new(); for spec in specs { - if let Some(link) = link_for_root(fs.links(), &spec.root_path) - && let ResolvedWorkspaceLinkTarget::Unavailable { reason, .. } = &link.target + if let Some(attachment) = attachment_for_root(fs.attachments(), &spec.root_path) + && let ResolvedWorkspaceAttachmentTarget::Unavailable { reason, .. } = + &attachment.target { warnings.push(SkillLoadWarning::new( spec.root_id.clone(), Some(spec.root_path.to_string()), - SkillLoadWarningKind::UnavailableWorkspaceLink { + SkillLoadWarningKind::UnavailableWorkspaceAttachment { reason: reason.clone(), }, )); } - if let Some(root) = resolve_root(fs.links(), spec).await? { + if let Some(root) = resolve_root(fs.attachments(), spec).await? { roots.push(root); } } - Ok(LinkedVfsSkillCatalogRoots { + Ok(AttachedVfsSkillCatalogRoots { fs, roots, warnings, @@ -170,18 +172,21 @@ pub async fn resolve_linked_vfs_skill_roots( } pub fn configured_vfs_skill_root_specs( - links: &[ResolvedWorkspaceLink], + attachments: &[ResolvedWorkspaceAttachment], roots: Option<&[String]>, ) -> Result, SkillVfsRootError> { let defaults; let roots = match roots { Some(roots) => roots, None => { - defaults = links + defaults = attachments .iter() - .flat_map(|link| { + .flat_map(|attachment| { [".agents/skills", ".lightspeed/skills"].map(|suffix| { - format!("{}/{suffix}", link.path.as_str().trim_end_matches('/')) + format!( + "{}/{suffix}", + attachment.path.as_str().trim_end_matches('/') + ) }) }) .collect::>(); @@ -198,10 +203,10 @@ pub fn configured_vfs_skill_root_specs( })?; let trust = if path.as_str() == "/skills/system" { SkillTrustLevel::System - } else if link_for_root(links, &path).is_some_and(|link| { + } else if attachment_for_root(attachments, &path).is_some_and(|attachment| { matches!( - link.target, - ResolvedWorkspaceLinkTarget::AvailableWorkspace { .. } + attachment.target, + ResolvedWorkspaceAttachmentTarget::AvailableWorkspace { .. } ) }) { SkillTrustLevel::Project @@ -240,14 +245,15 @@ fn validate_specs(specs: &[VfsSkillRootSpec]) -> Result<(), SkillVfsRootError> { } async fn resolve_root( - links: &[ResolvedWorkspaceLink], + attachments: &[ResolvedWorkspaceAttachment], spec: VfsSkillRootSpec, ) -> Result, SkillVfsRootError> { - let link = - link_for_root(links, &spec.root_path).ok_or_else(|| SkillVfsRootError::UnlinkedRoot { + let attachment = attachment_for_root(attachments, &spec.root_path).ok_or_else(|| { + SkillVfsRootError::UnattachedRoot { root_id: spec.root_id.clone(), root_path: spec.root_path.clone(), - })?; + } + })?; let root_path = FsPath::new(spec.root_path.as_str()).map_err(|error| { SkillVfsRootError::InvalidRootPath { root_id: spec.root_id.clone(), @@ -255,21 +261,21 @@ async fn resolve_root( message: error.to_string(), } })?; - let source = match &link.target { - ResolvedWorkspaceLinkTarget::AvailableSnapshot { snapshot_ref } => { - SkillCatalogRootSource::LinkedSnapshot { + let source = match &attachment.target { + ResolvedWorkspaceAttachmentTarget::AvailableSnapshot { snapshot_ref } => { + SkillCatalogRootSource::AttachedSnapshot { snapshot_ref: snapshot_ref.clone(), - link_path: link.path.clone(), + attachment_path: attachment.path.clone(), } } - ResolvedWorkspaceLinkTarget::AvailableWorkspace { workspace } => { - SkillCatalogRootSource::LinkedWorkspace { + ResolvedWorkspaceAttachmentTarget::AvailableWorkspace { workspace } => { + SkillCatalogRootSource::AttachedWorkspace { workspace_id: workspace.workspace_id.clone(), workspace_head_ref: workspace.head_snapshot_ref.clone(), - link_path: link.path.clone(), + attachment_path: attachment.path.clone(), } } - ResolvedWorkspaceLinkTarget::Unavailable { .. } => return Ok(None), + ResolvedWorkspaceAttachmentTarget::Unavailable { .. } => return Ok(None), }; Ok(Some(SkillCatalogRoot { @@ -281,13 +287,13 @@ async fn resolve_root( })) } -fn link_for_root<'a>( - links: &'a [ResolvedWorkspaceLink], +fn attachment_for_root<'a>( + attachments: &'a [ResolvedWorkspaceAttachment], root_path: &VfsPath, -) -> Option<&'a ResolvedWorkspaceLink> { - links +) -> Option<&'a ResolvedWorkspaceAttachment> { + attachments .iter() - .find(|link| vfs_path_starts_with(root_path, &link.path)) + .find(|attachment| vfs_path_starts_with(root_path, &attachment.path)) } fn vfs_path_starts_with(path: &VfsPath, base: &VfsPath) -> bool { @@ -305,18 +311,18 @@ mod tests { use std::collections::BTreeMap; use async_trait::async_trait; - use engine::{WorkspaceLinkAccess, WorkspaceLinkTarget, storage::InMemoryBlobStore}; + use engine::{WorkspaceAccess, WorkspaceAttachmentTarget, storage::InMemoryBlobStore}; use vfs::{ CompareAndSetVfsWorkspaceHead, CreateInlineSnapshotRequest, CreateVfsWorkspaceRecord, - InlineFile, ResolvedWorkspaceLink, ResolvedWorkspaceLinkTarget, VfsCatalogError, - VfsWorkspaceRecord, create_inline_snapshot, + InlineFile, ResolvedWorkspaceAttachment, ResolvedWorkspaceAttachmentTarget, + VfsCatalogError, VfsWorkspaceRecord, create_inline_snapshot, }; use super::*; use crate::skills::{SkillLocation, build_skill_catalog}; #[tokio::test] - async fn resolves_snapshot_link_as_skill_catalog_root() { + async fn resolves_snapshot_attachment_as_skill_catalog_root() { let blobs = Arc::new(InMemoryBlobStore::new()); let workspace_store = Arc::new(TestWorkspaceStore::default()); let snapshot = create_inline_snapshot( @@ -330,18 +336,18 @@ mod tests { ) .await .expect("snapshot"); - let links = vec![resolved_link( + let attachments = vec![resolved_attachment( "/skills/system", - ResolvedWorkspaceLinkTarget::AvailableSnapshot { + ResolvedWorkspaceAttachmentTarget::AvailableSnapshot { snapshot_ref: snapshot.snapshot_ref.clone(), }, - WorkspaceLinkAccess::ReadOnly, + WorkspaceAccess::Read, )]; - let resolved = resolve_linked_vfs_skill_roots( + let resolved = resolve_attached_vfs_skill_roots( blobs.clone(), workspace_store, - links, + attachments, vec![VfsSkillRootSpec::new( "system", VfsPath::parse("/skills/system").unwrap(), @@ -356,7 +362,7 @@ mod tests { assert_eq!(resolved.roots()[0].root_path.as_str(), "/skills/system"); assert!(matches!( resolved.roots()[0].source, - SkillCatalogRootSource::LinkedSnapshot { .. } + SkillCatalogRootSource::AttachedSnapshot { .. } )); let inputs = resolved.inputs(); @@ -368,13 +374,13 @@ mod tests { assert_eq!(build.catalog.skills[0].name, "review"); assert!(matches!( &build.catalog.skills[0].location, - SkillLocation::LinkedSnapshot { + SkillLocation::AttachedSnapshot { source_snapshot_ref, - source_link_path, + source_attachment_path, skill_doc_path, .. } if source_snapshot_ref == &snapshot.snapshot_ref - && source_link_path.as_str() == "/skills/system" + && source_attachment_path.as_str() == "/skills/system" && skill_doc_path.as_str() == "/skills/system/review/SKILL.md" )); } @@ -406,16 +412,16 @@ mod tests { }) .await .expect("workspace"); - let links = vec![resolved_link( + let attachments = vec![resolved_attachment( "/workspace", - ResolvedWorkspaceLinkTarget::AvailableWorkspace { workspace }, - WorkspaceLinkAccess::ReadWrite, + ResolvedWorkspaceAttachmentTarget::AvailableWorkspace { workspace }, + WorkspaceAccess::Edit, )]; - let resolved = resolve_linked_vfs_skill_roots( + let resolved = resolve_attached_vfs_skill_roots( blobs.clone(), workspace_store, - links, + attachments, vec![VfsSkillRootSpec::new( "project", VfsPath::parse("/workspace/.lightspeed/skills").unwrap(), @@ -428,13 +434,13 @@ mod tests { assert!(matches!( &resolved.roots()[0].source, - SkillCatalogRootSource::LinkedWorkspace { + SkillCatalogRootSource::AttachedWorkspace { workspace_id: resolved_workspace_id, workspace_head_ref, - link_path, + attachment_path, } if resolved_workspace_id == &workspace_id && workspace_head_ref == &snapshot.snapshot_ref - && link_path.as_str() == "/workspace" + && attachment_path.as_str() == "/workspace" )); let inputs = resolved.inputs(); @@ -445,23 +451,23 @@ mod tests { assert_eq!(build.catalog.skills.len(), 1); assert!(matches!( &build.catalog.skills[0].location, - SkillLocation::LinkedWorkspace { + SkillLocation::AttachedWorkspace { workspace_id: resolved_workspace_id, - source_link_path, + source_attachment_path, skill_doc_path, .. } if resolved_workspace_id == &workspace_id - && source_link_path.as_str() == "/workspace" + && source_attachment_path.as_str() == "/workspace" && skill_doc_path.as_str() == "/workspace/.lightspeed/skills/review/SKILL.md" )); } #[tokio::test] - async fn rejects_unlinked_skill_root() { + async fn rejects_unattached_skill_root() { let blobs = Arc::new(InMemoryBlobStore::new()); let workspace_store = Arc::new(TestWorkspaceStore::default()); - let result = resolve_linked_vfs_skill_roots( + let result = resolve_attached_vfs_skill_roots( blobs, workspace_store, Vec::new(), @@ -476,7 +482,7 @@ mod tests { assert_eq!( result.err(), - Some(SkillVfsRootError::UnlinkedRoot { + Some(SkillVfsRootError::UnattachedRoot { root_id: "system".to_owned(), root_path: VfsPath::parse("/skills/system").unwrap(), }) @@ -484,19 +490,19 @@ mod tests { } #[tokio::test] - async fn unavailable_link_becomes_a_source_warning_without_a_root() { - let resolved = resolve_linked_vfs_skill_roots( + async fn unavailable_attachment_becomes_a_source_warning_without_a_root() { + let resolved = resolve_attached_vfs_skill_roots( Arc::new(InMemoryBlobStore::new()), Arc::new(TestWorkspaceStore::default()), - vec![resolved_link( + vec![resolved_attachment( "/skills/system", - ResolvedWorkspaceLinkTarget::Unavailable { - declared_target: WorkspaceLinkTarget::Workspace { + ResolvedWorkspaceAttachmentTarget::Unavailable { + declared_target: WorkspaceAttachmentTarget::Workspace { workspace_id: "deleted".to_owned(), }, reason: "workspace was deleted".to_owned(), }, - WorkspaceLinkAccess::ReadWrite, + WorkspaceAccess::Edit, )], vec![VfsSkillRootSpec::new( "system", @@ -506,30 +512,30 @@ mod tests { )], ) .await - .expect("unavailable links degrade per source"); + .expect("unavailable attachments degrade per source"); assert!(resolved.roots().is_empty()); assert!(matches!( resolved.warnings(), [SkillLoadWarning { - kind: SkillLoadWarningKind::UnavailableWorkspaceLink { reason }, + kind: SkillLoadWarningKind::UnavailableWorkspaceAttachment { reason }, .. }] if reason == "workspace was deleted" )); } #[test] - fn default_skill_roots_cover_links_and_explicit_roots_replace_defaults() { - let links = ["/", "/workspace"].map(|path| { - resolved_link( + fn default_skill_roots_cover_attachments_and_explicit_roots_replace_defaults() { + let attachments = ["/", "/workspace"].map(|path| { + resolved_attachment( path, - ResolvedWorkspaceLinkTarget::AvailableSnapshot { + ResolvedWorkspaceAttachmentTarget::AvailableSnapshot { snapshot_ref: engine::BlobRef::from_bytes(b"snapshot"), }, - WorkspaceLinkAccess::ReadOnly, + WorkspaceAccess::Read, ) }); - let defaults = configured_vfs_skill_root_specs(&links, None).unwrap(); + let defaults = configured_vfs_skill_root_specs(&attachments, None).unwrap(); assert_eq!( defaults .iter() @@ -543,7 +549,8 @@ mod tests { ] ); let overrides = - configured_vfs_skill_root_specs(&links, Some(&["/workspace/custom".into()])).unwrap(); + configured_vfs_skill_root_specs(&attachments, Some(&["/workspace/custom".into()])) + .unwrap(); assert_eq!(overrides.len(), 1); assert_eq!(overrides[0].root_path.as_str(), "/workspace/custom"); assert!( @@ -554,19 +561,19 @@ mod tests { } #[test] - fn empty_configured_roots_do_not_infer_roots_from_links() { - let links = vec![resolved_link( + fn empty_configured_roots_do_not_infer_roots_from_attachments() { + let attachments = vec![resolved_attachment( "/skills/system", - ResolvedWorkspaceLinkTarget::Unavailable { - declared_target: engine::WorkspaceLinkTarget::Workspace { + ResolvedWorkspaceAttachmentTarget::Unavailable { + declared_target: engine::WorkspaceAttachmentTarget::Workspace { workspace_id: "skills".into(), }, reason: "deleted".into(), }, - WorkspaceLinkAccess::ReadOnly, + WorkspaceAccess::Read, )]; assert!( - configured_vfs_skill_root_specs(&links, Some(&[])) + configured_vfs_skill_root_specs(&attachments, Some(&[])) .unwrap() .is_empty() ); @@ -594,12 +601,12 @@ mod tests { .unwrap() } - fn resolved_link( + fn resolved_attachment( path: &str, - target: ResolvedWorkspaceLinkTarget, - access: WorkspaceLinkAccess, - ) -> ResolvedWorkspaceLink { - ResolvedWorkspaceLink { + target: ResolvedWorkspaceAttachmentTarget, + access: WorkspaceAccess, + ) -> ResolvedWorkspaceAttachment { + ResolvedWorkspaceAttachment { path: VfsPath::parse(path).unwrap(), target, access, diff --git a/crates/tools/src/subagents.rs b/crates/tools/src/subagents.rs index c73bd892..40dcc6c8 100644 --- a/crates/tools/src/subagents.rs +++ b/crates/tools/src/subagents.rs @@ -127,16 +127,23 @@ pub struct SubagentExecutionContextV1 { /// The parent's grant limits at admission; the prepare activity /// attenuates them by the parent's own origin. pub grant_limits: SubagentLimits, + /// The parent's active environment at admission, or its absence. An + /// `inherit` attachment in the child's profile resolves against this + /// captured value, so a parent switch after admission or an activity + /// retry never changes what the child inherits. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_active_environment_id: Option, } impl SubagentExecutionContextV1 { - pub const VERSION: u32 = 1; + pub const VERSION: u32 = 2; pub fn new( parent_session_id: String, parent_run_id: u64, agent_profile_id: String, grant_limits: SubagentLimits, + parent_active_environment_id: Option, ) -> Self { Self { version: Self::VERSION, @@ -144,6 +151,7 @@ impl SubagentExecutionContextV1 { parent_run_id, agent_profile_id, grant_limits, + parent_active_environment_id, } } } diff --git a/crates/vfs/src/link.rs b/crates/vfs/src/attachment.rs similarity index 67% rename from crates/vfs/src/link.rs rename to crates/vfs/src/attachment.rs index 1495b446..6199dbb6 100644 --- a/crates/vfs/src/link.rs +++ b/crates/vfs/src/attachment.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use engine::{ - BlobRef, WorkspaceLink, WorkspaceLinkAccess, WorkspaceLinkTarget, storage::BlobStore, + BlobRef, WorkspaceAccess, WorkspaceAttachment, WorkspaceAttachmentTarget, storage::BlobStore, }; use crate::{ @@ -9,17 +9,17 @@ use crate::{ read_snapshot_manifest, }; -/// A session workspace link resolved against one coherent catalog view. +/// A session workspace attachment resolved against one coherent catalog view. /// This value is transient and must never be persisted as session authority. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct ResolvedWorkspaceLink { +pub struct ResolvedWorkspaceAttachment { pub path: VfsPath, - pub target: ResolvedWorkspaceLinkTarget, - pub access: WorkspaceLinkAccess, + pub target: ResolvedWorkspaceAttachmentTarget, + pub access: WorkspaceAccess, } #[derive(Clone, Debug, PartialEq, Eq)] -pub enum ResolvedWorkspaceLinkTarget { +pub enum ResolvedWorkspaceAttachmentTarget { AvailableSnapshot { snapshot_ref: BlobRef, }, @@ -27,61 +27,68 @@ pub enum ResolvedWorkspaceLinkTarget { workspace: VfsWorkspaceRecord, }, Unavailable { - declared_target: WorkspaceLinkTarget, + declared_target: WorkspaceAttachmentTarget, reason: String, }, } -impl ResolvedWorkspaceLink { +impl ResolvedWorkspaceAttachment { pub fn is_available(&self) -> bool { - !matches!(self.target, ResolvedWorkspaceLinkTarget::Unavailable { .. }) + !matches!( + self.target, + ResolvedWorkspaceAttachmentTarget::Unavailable { .. } + ) } pub fn unavailable_reason(&self) -> Option<&str> { match &self.target { - ResolvedWorkspaceLinkTarget::Unavailable { reason, .. } => Some(reason), + ResolvedWorkspaceAttachmentTarget::Unavailable { reason, .. } => Some(reason), _ => None, } } pub fn is_writable(&self) -> bool { - self.access == WorkspaceLinkAccess::ReadWrite + self.access == WorkspaceAccess::Edit } } /// Resolve declarations without turning missing catalog resources into a /// global failure. Invalid durable identifiers remain request errors; missing -/// or unreadable targets become per-link unavailable projections. -pub async fn resolve_workspace_links( +/// or unreadable targets become per-attachment unavailable projections. +pub async fn resolve_workspace_attachments( blobs: Arc, workspace_store: Arc, - links: &[WorkspaceLink], -) -> Result, VfsCatalogError> { - let mut resolved = Vec::with_capacity(links.len()); - for link in links { - let path = VfsPath::parse(&link.path).map_err(|error| VfsCatalogError::InvalidInput { - message: format!("invalid workspace link path {:?}: {error}", link.path), - })?; - let target = match &link.target { - WorkspaceLinkTarget::Snapshot { snapshot_ref } => { + attachments: &[WorkspaceAttachment], +) -> Result, VfsCatalogError> { + let mut resolved = Vec::with_capacity(attachments.len()); + for attachment in attachments { + let path = + VfsPath::parse(&attachment.path).map_err(|error| VfsCatalogError::InvalidInput { + message: format!( + "invalid workspace attachment path {:?}: {error}", + attachment.path + ), + })?; + let target = match &attachment.target { + WorkspaceAttachmentTarget::Snapshot { snapshot_ref } => { let snapshot_ref = BlobRef::parse(snapshot_ref.clone()).map_err(|error| { VfsCatalogError::InvalidInput { - message: format!("invalid workspace link snapshot ref: {error}"), + message: format!("invalid workspace attachment snapshot ref: {error}"), } })?; match read_snapshot_manifest(blobs.as_ref(), &snapshot_ref).await { - Ok(_) => ResolvedWorkspaceLinkTarget::AvailableSnapshot { snapshot_ref }, - Err(error) => ResolvedWorkspaceLinkTarget::Unavailable { - declared_target: link.target.clone(), + Ok(_) => ResolvedWorkspaceAttachmentTarget::AvailableSnapshot { snapshot_ref }, + Err(error) => ResolvedWorkspaceAttachmentTarget::Unavailable { + declared_target: attachment.target.clone(), reason: error.to_string(), }, } } - WorkspaceLinkTarget::Workspace { workspace_id } => { + WorkspaceAttachmentTarget::Workspace { workspace_id } => { let workspace_id = VfsWorkspaceId::try_new(workspace_id.clone()).map_err(|error| { VfsCatalogError::InvalidInput { - message: format!("invalid workspace link workspace id: {error}"), + message: format!("invalid workspace attachment workspace id: {error}"), } })?; match workspace_store.read_workspace(&workspace_id).await { @@ -89,24 +96,26 @@ pub async fn resolve_workspace_links( match read_snapshot_manifest(blobs.as_ref(), &workspace.head_snapshot_ref) .await { - Ok(_) => ResolvedWorkspaceLinkTarget::AvailableWorkspace { workspace }, - Err(error) => ResolvedWorkspaceLinkTarget::Unavailable { - declared_target: link.target.clone(), + Ok(_) => { + ResolvedWorkspaceAttachmentTarget::AvailableWorkspace { workspace } + } + Err(error) => ResolvedWorkspaceAttachmentTarget::Unavailable { + declared_target: attachment.target.clone(), reason: error.to_string(), }, } } - Err(error) => ResolvedWorkspaceLinkTarget::Unavailable { - declared_target: link.target.clone(), + Err(error) => ResolvedWorkspaceAttachmentTarget::Unavailable { + declared_target: attachment.target.clone(), reason: error.to_string(), }, } } }; - resolved.push(ResolvedWorkspaceLink { + resolved.push(ResolvedWorkspaceAttachment { path, target, - access: link.access, + access: attachment.access, }); } Ok(resolved) @@ -194,7 +203,7 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn deleting_a_linked_workspace_preserves_the_declaration_as_unavailable() { + async fn deleting_an_attached_workspace_preserves_the_declaration_as_unavailable() { let blobs = Arc::new(InMemoryBlobStore::new()); let store = Arc::new(TestWorkspaceStore::default()); let snapshot = create_inline_snapshot( @@ -206,7 +215,7 @@ mod tests { ) .await .unwrap(); - let workspace_id = VfsWorkspaceId::new("workspace-linked"); + let workspace_id = VfsWorkspaceId::new("workspace-attached"); store .create_workspace(CreateVfsWorkspaceRecord { workspace_id: workspace_id.clone(), @@ -218,15 +227,15 @@ mod tests { }) .await .unwrap(); - let declaration = WorkspaceLink { + let declaration = WorkspaceAttachment { path: "/workspace".to_owned(), - target: WorkspaceLinkTarget::Workspace { + target: WorkspaceAttachmentTarget::Workspace { workspace_id: workspace_id.to_string(), }, - access: WorkspaceLinkAccess::ReadWrite, + access: WorkspaceAccess::Edit, }; - let available = resolve_workspace_links( + let available = resolve_workspace_attachments( blobs.clone(), store.clone(), std::slice::from_ref(&declaration), @@ -236,13 +245,14 @@ mod tests { assert!(available[0].is_available()); store.delete_workspace(&workspace_id).await.unwrap(); - let unavailable = resolve_workspace_links(blobs, store, std::slice::from_ref(&declaration)) - .await - .unwrap(); + let unavailable = + resolve_workspace_attachments(blobs, store, std::slice::from_ref(&declaration)) + .await + .unwrap(); assert!(!unavailable[0].is_available()); assert_eq!( unavailable[0].target, - ResolvedWorkspaceLinkTarget::Unavailable { + ResolvedWorkspaceAttachmentTarget::Unavailable { declared_target: declaration.target, reason: format!("vfs catalog workspace not found: {workspace_id}"), } diff --git a/crates/vfs/src/lib.rs b/crates/vfs/src/lib.rs index 99338fed..b698380a 100644 --- a/crates/vfs/src/lib.rs +++ b/crates/vfs/src/lib.rs @@ -5,14 +5,14 @@ //! Host filesystem access, materialization, and process execution live outside //! this crate. +pub mod attachment; pub mod catalog; -pub mod link; pub mod manifest; pub mod path; pub mod snapshot; +pub use attachment::*; pub use catalog::*; -pub use link::*; pub use manifest::*; pub use path::*; pub use snapshot::*; diff --git a/docs/documentation/environments/bring-your-own-compute.md b/docs/documentation/environments/bring-your-own-compute.md index 639b0abc..cdadc221 100644 --- a/docs/documentation/environments/bring-your-own-compute.md +++ b/docs/documentation/environments/bring-your-own-compute.md @@ -123,8 +123,8 @@ within its limits. Removing this local file does not revoke that key. Open a session with a working model, such as the one from the [first-agent walkthrough](../getting-started/first-agent.md). When the session is idle, open the sliders button labeled **Session settings**. Enable -**Environments**, choose **My workstation** under **Active environment**, and -choose **Apply setup**. +**Environments**, attach **My workstation** with **Exec** access, choose it +under **Active environment**, and choose **Apply setup**. You do not need to enable model-driven selection or background jobs to use this selected machine. Send: diff --git a/docs/documentation/environments/credentials.md b/docs/documentation/environments/credentials.md index 21966296..5753faea 100644 --- a/docs/documentation/environments/credentials.md +++ b/docs/documentation/environments/credentials.md @@ -75,26 +75,13 @@ into every machine. Binding that variable to a machine does not select or authenticate the model used by the Lightspeed session. See [Models and credentials](../using-lightspeed/models-and-credentials.md). -## Supply credentials when a profile provisions a machine +## Share credentials through an environment -In a profile's **Environments** settings, select **Provision a new environment -for the session** and choose its provider and template. Under **Environment -credentials**, choose **Add credential**, then set **Environment variable -name** and **Credential source**. Save the profile. - -When the profile creates a fresh environment, Lightspeed binds those sources -before activating it. They become ordinary environment bindings that you can -inspect and change in the Environments page. The profile supplies an initial -set, not a live synchronization rule. - -Editing or reapplying the profile does not rewrite bindings on its existing -machine. For **Activate an existing environment** or **Inherit the parent's -active environment**, configure the environment's bindings directly. Those -modes do not carry a separate credential set for each session. - -This is especially relevant to bots: different conversations using one -existing machine receive the same bindings. Provision separate environments -when those conversations need different credential access. +Configure credential bindings directly on the Environments page. Profiles may +attach existing environments or, for sub-agents, inherit a parent's +selection, but they do not create machines or initialize credentials. Sessions using the same machine +receive the same environment bindings. Create separate environments when work +requires different credential access. ## Understand resolution and renewal @@ -226,7 +213,7 @@ checks like the one above rather than printing credentials into the transcript. | The variable is absent | Check the target environment and exact name, then start a new process rather than continuing one already running. | | Submission reports an environment-variable collision | Remove the bound name from explicit command or job `env`. | | Credential resolution fails before execution | Check that the referenced source is active, unexpired, and suitable for the requested audience. | -| A profile change did not rotate the machine's token | Profile credentials are applied only to a fresh provision. Change the existing environment binding. | +| A profile change did not rotate the machine's token | Profiles do not manage credentials. Change the environment binding directly. | | A queued job uses an old token | Its values were resolved at admission. Submit new work after updating the binding. | | An imported subscription stopped working | Reimport a current credential and update its binding; the static imported record is not refreshed by core. | | Unassigning did not remove an on-disk login | A program or bootstrap wrote a separate copy. Manage that file and any running processes explicitly. | diff --git a/docs/documentation/environments/incus-vms.md b/docs/documentation/environments/incus-vms.md index c8861f33..6a3453d9 100644 --- a/docs/documentation/environments/incus-vms.md +++ b/docs/documentation/environments/incus-vms.md @@ -197,8 +197,9 @@ test, then configure it under [Power and cleanup](power-and-cleanup.md). The environment progresses through provisioning and booting toward ready. Expand **Details** to inspect the environment ID, provider, template, and -provider target. Select it in a session with **Environments** enabled, then ask -the agent to run `pwd` and a harmless file check. +provider target. Attach it with **Exec** access and select it in a session +with **Environments** enabled, then ask the agent to run `pwd` and a harmless +file check. The provider configures the guest daemon during provisioning. The stock setup listens privately on port 19091 and starts commands in `/workspace`; it uses diff --git a/docs/documentation/environments/overview.md b/docs/documentation/environments/overview.md index 29f617ed..75c7087b 100644 --- a/docs/documentation/environments/overview.md +++ b/docs/documentation/environments/overview.md @@ -17,8 +17,8 @@ tools and separate contents: | Domain | Where the files live | How the session gets access | | --- | --- | --- | -| VFS workspace | Lightspeed's persistent storage | Workspace links and VFS capabilities in session configuration | -| Environment filesystem | The machine or container running the environment daemon | Environment capability and an active environment | +| VFS workspace | Lightspeed's persistent storage | Workspace attachments in the session's VFS feature, each with `read` or `edit` access | +| Environment filesystem | The machine or container running the environment daemon | An environment attachment with its access level, and an active environment | Suppose an agent writes a test plan in its VFS workspace, then selects a VM to run the tests. The plan does not appear in the VM automatically. If a command @@ -52,21 +52,26 @@ can then create environments from its available templates. ## Select an environment for a session -Environments belong to a universe. A session records one active environment -at a time, and its environment file and process tools operate there. Enable -the **Environments** capability and select an **Active environment** in the -session setup, or configure the environment in the profile used to start it. - -A profile can select an existing environment or request a provisioned one. -For a provisioned environment, the runtime can wait for readiness before -executing an environment-dependent tool call. The session does not need to -guess how long provisioning takes. - -Model-driven selection is a separate capability: selection tools let the agent -discover and change its active environment. They are unnecessary when you -choose the machine yourself. Background job tools are another separate grant. -Provider and registration-key filters can restrict which environments a -session may use. +Environments belong to a universe. A session's configuration attaches the +environments it may use, each with an access level (`read`, `edit`, `exec`, +or `jobs`, each including the previous ones) and an optional working +directory, and records one active environment at a time; its environment file +and process tools operate there. Enable the **Environments** capability, +attach the machine, and select it as the **Active environment** in the +session setup, or mark it as the profile's default attachment so it is +activated when the profile is applied. + +A profile attaches existing environments and, for sub-agents, can inherit the +parent's active machine. For a provisioned environment, the runtime can wait +for readiness before executing an environment-dependent tool call. The +session does not need to guess how long provisioning takes. + +Model-driven selection is a separate switch: selection tools let the agent +list, activate, and deactivate the attached environments. They are +unnecessary when you choose the machine yourself. The attachment list is the +only allowed set; the toolset is the union of the attachments' access and +does not change when the agent switches machines, while a call the active +machine's access does not cover is refused when it executes. Selecting an environment does not reserve it. Several sessions and bots can use the same environment, and their processes and file writes share that @@ -106,12 +111,10 @@ A persistent registered environment becomes offline while its daemon is away and reconnects under the same identity. Ephemeral registration closes the environment after its configured disconnect grace period. -Closing a session ordinarily leaves a shared environment available. A -profile-provisioned environment can instead use `closeWithSession` retention, -which is the default for that provisioning mode. That policy closes the -environment with its originating session even if another session has selected -it. Choose an existing shared environment when its lifetime should be managed -independently of individual sessions. +Closing or deleting a session leaves its environment available. Environments +are created, credentialed, powered, and closed independently. Profiles attach +existing environments or, for sub-agents, inherit a parent's selection; they +do not provision machines or attach cleanup to a session's lifetime. Closing a registered or external environment removes its availability in Lightspeed; it does not delete or shut down your computer. Closing a provisioned diff --git a/docs/documentation/environments/power-and-cleanup.md b/docs/documentation/environments/power-and-cleanup.md index bc34036a..0cc9f8d9 100644 --- a/docs/documentation/environments/power-and-cleanup.md +++ b/docs/documentation/environments/power-and-cleanup.md @@ -33,9 +33,9 @@ For a disposable test VM from [Incus VMs](incus-vms.md), save any needed output, then expand its **Details** and choose **Pause**. Wait for the observed status to become paused. **Resume** requests running again. -You can also leave it paused and ask a session using it to run `pwd`. Selecting -or using a sleeping provisioned environment with power support requests a -wake-up. The session's environment-dependent tool waits for readiness before +You can also leave it paused and ask a session using it to run `pwd`. Using +a sleeping provisioned environment with power support requests a wake-up; +selecting it leaves its power state unchanged. The environment-dependent tool waits for readiness before executing. Ordinary model conversation and VFS work do not need that machine to wake. @@ -143,26 +143,12 @@ with the application's availability needs; see ## Choose who owns cleanup -An environment created directly through **New environment** has a lifetime -managed independently of sessions. Closing a session that selected it does -not close the machine. Its idle policy and explicit environment operations -still apply. - -A profile provision defaults to **Close with the session**. Its origin record -identifies the session responsible for that cleanup, and closing that session -requests closure of the environment. This remains true when another session -selects the same machine. Selecting it as **existing** elsewhere adds access -without removing its original cleanup policy. - -For a shared bot machine, use an independently created environment or provision -with **Retain after the session closes**. Retention transfers cleanup -responsibility to the universe; it does not keep the machine immune from an -idle close policy or an explicit close action. - -Profile credentials and retention initialize the fresh environment. Reapplying -an edited profile does not turn the old machine into a fresh provision. Inspect -and manage existing resources directly rather than relying on profile edits -to rebuild them. +Every environment has a lifetime managed independently of sessions. Create +machines through **New environment**, configure their credentials and idle +policy on the Environments page, and close them explicitly when finished. +Closing or deleting a session or bot never closes its selected environment. +Profiles only attach existing environments or, for sub-agents, inherit a +parent's selection. Session-owned job promises are canceled when the session closes. Standalone API jobs have no session promise, and ordinary remote processes may outlive @@ -226,7 +212,7 @@ in [Bring your own compute](bring-your-own-compute.md). | The VM never pauses | Check the idle policy, tracked running work, and calls from other sessions. | | A paused VM never reaches its later stop threshold | Later stages do not escalate while powered down. Choose a single stage or perform the later action explicitly. | | An application goes offline despite receiving traffic | App traffic does not reset daemon idle time or wake the VM. Review its power policy. | -| Closing one session removes another session's machine | Check the environment's originating session and close-with-session retention. Shared selection did not change that ownership. | +| An environment disappears while a session uses it | Inspect its idle close policy, explicit close requests, and provider lifecycle. Session closure never closes the environment. | | A borrowed machine cannot resume through Lightspeed | Restart its daemon or machine directly; it has no provider power control. | | A provisioned environment remains closing | Inspect provider reachability and deletion errors. A recorded close request does not prove the VM was removed. | diff --git a/docs/documentation/environments/processes-and-jobs.md b/docs/documentation/environments/processes-and-jobs.md index 9514bd3c..4d29d886 100644 --- a/docs/documentation/environments/processes-and-jobs.md +++ b/docs/documentation/environments/processes-and-jobs.md @@ -12,15 +12,16 @@ commands manage environments rather than execute commands. You can use the CLI's chat interface with the same session and capabilities. Start with [an active environment](using-environments.md) and a model that -can call its process tools. Enable **Command execution** under **Environments** -in the profile or session setup (`features.environments.commands: true`). This -is independent of **Durable jobs**, which keeps its own grant. The first exercise needs a POSIX shell and common +can call its process tools. Attach the machine under **Environments** in the +profile or session setup with **Exec** access (`"access": "exec"`), which +includes file reading and editing; **Jobs** access adds durable jobs on top. +The first exercise needs a POSIX shell and common utilities such as `grep`. The example Incus image supplies them. ## Run a check against a file Use the release notes from [Build your first agent](../getting-started/first-agent.md). -Set environment **File tools** to **Edit files** for this exercise, then ask the +The `exec` attachment already includes environment file editing, so ask the agent to prepare a separate machine copy: ```text @@ -80,10 +81,9 @@ above. Replace the directory with the absolute path created by the agent: `argv` is an argument array. It does not interpret pipes, redirection, or other shell syntax by itself; use a shell explicitly when the command needs them. -The working directory is on the environment machine. The session's -`features.environments.workingDirectory` supplies the default for file tools, -commands, jobs, and prompt/skill discovery; when unset, the endpoint's default -is used. A per-command `cwd` overrides this base and relative overrides resolve +The working directory is on the environment machine. The active attachment's +`workingDirectory` supplies the default for file tools, commands, jobs, and +prompt/skill discovery; when unset, the machine's default is used. A per-command `cwd` overrides this base and relative overrides resolve against it. A shell `cd` does not persist into later tool calls. VFS has a separate `features.vfs.workingDirectory`, defaulting to `/`. @@ -133,8 +133,11 @@ agent to execute the same side-effecting command again. ## Submit jobs with dependencies -In the profile or idle session setup, enable **Environments → Durable jobs**. -The environment must advertise job support too. The agent receives: +In the profile or idle session setup, give the environment attachment +**Jobs** access (`"access": "jobs"`). The environment must advertise job +support too. Because the toolset is the union of every attachment's access, +the job tools can be visible while a lower-access machine is active; `job_run` +and `job_submit` are then refused before any job starts. The agent receives: | Tool | Use | | --- | --- | @@ -205,8 +208,9 @@ same promise controls are explained in [Sub-agents and federation](../using-lightspeed/subagents-and-federation.md#join-a-result-or-use-a-promise). Job handles include their originating environment ID. Unlike ordinary process -continuation, reading a job by its handle still targets that machine after the -session selects another active environment. +continuation, reading or canceling a job by its handle still targets that +machine after the session selects another active environment, as long as that +machine remains attached in the session's configuration. ## Know what survives a restart diff --git a/docs/documentation/environments/using-environments.md b/docs/documentation/environments/using-environments.md index a70de484..a34e6a6c 100644 --- a/docs/documentation/environments/using-environments.md +++ b/docs/documentation/environments/using-environments.md @@ -5,6 +5,12 @@ The session selects one environment at a time, while the environment remains a resource in the universe. Several sessions can select the same machine; selection does not reserve it or create a private copy. +Selection checks that the environment is attached in the session's +configuration and is not failed, closing, or closed. It does not connect to the machine or change +its power state. Sleeping, offline, and starting environments can be selected; +tools check readiness and request wake-up where supported when they use it. +Selecting the same environment again rechecks its current registry state. + This guide starts with a machine that already appears under **Environments**. Use [Bring your own compute](bring-your-own-compute.md) to connect one you control, or [Incus VMs](incus-vms.md) to configure provider-managed machines. @@ -26,17 +32,21 @@ the ID is the stable value profiles and API calls reference. value to select; its status and power state describe the machine's current state.* Open the session you want to use. With no active or queued runs, choose -**Session settings**, enable **Environments**, and select the machine under -**Active environment**. Choose **Apply setup**. +**Session settings**, enable **Environments**, add the machine under +**Environments**, and select it under **Active environment**. Choose **Apply +setup**. -Enabling **Environments** in the profile or session editor selects **Edit files** -and enables **Command execution**, **Durable jobs**, **Prompt loading**, and -**Skill discovery**. You can adjust each independently. Existing configurations -keep their saved settings; omitted API grants remain off. +Each attached environment carries its own **Access**: **Read**, **Edit**, +**Exec**, or **Jobs**, each including the levels before it. Enabling +**Environments** in the profile or session editor also turns on **Prompt +loading** and **Skill discovery**; adjust each independently. Existing +configurations keep their saved settings; a machine that is not attached +cannot be selected. **Environment selection tools**, below **Skill discovery**, remains off by -default. For this first check, turn **Durable jobs** off too; **Command execution** -is sufficient to run a simple command on the machine you selected. +default. For this first check, give the attachment **Exec** access; that is +sufficient to run a simple command on the machine you selected without +granting durable jobs. Send: @@ -55,24 +65,35 @@ Commands can choose a different working directory. Neither setting confines the process to that directory; it runs with the operating-system permissions of the daemon user. -## Choose the tool grants +## Choose the access level -Under **Environments** in a profile or session setup, **File tools** offers -**No file tools**, **Read only**, or **Edit files**. Read only exposes reading, -listing, search, and glob tools. Edit adds write, edit, and patch tools. -**Command execution** independently enables starting and continuing processes; -**Durable jobs** independently enables workflow-backed jobs. All are off when -omitted. Prompt loading and skill discovery do not implicitly grant file or -command tools; enable Read only if the agent should read discovered skills. +A session can attach several environments, each with its own **Access**. The +levels form a ladder: `read` exposes reading, listing, search, and glob tools; +`edit` adds write, edit, and patch tools; `exec` adds starting and continuing +processes; `jobs` adds workflow-backed durable jobs. Each level includes the +ones before it. Read-only files with command execution is deliberately not +expressible, because a process can write files regardless of the file tools. +Attach with the lowest level that covers the task. ```json -{"features":{"environments":{"tools":"readOnly","commands":false,"jobs":false}}} +{"features":{"environments":{"environments":[{"environmentId":"","default":true,"access":"read"}]}}} ``` -These are tool grants, subject to the endpoint's capabilities and operating-system -permissions. Read-only file tools do not constrain commands or jobs: either can -modify files when enabled. Source discovery remains separately authorized by -its prompt/skill capability. Selection and environment status remain separate. +The list is the allowed set: the session can select, read, and run only on a +listed machine. The tools the agent sees are the union of every attachment's +access, installed once; a call that the active machine's access does not +cover is refused when it executes, with that machine's access named. +Switching machines therefore never changes the toolset. Access is a tool +grant, subject to the endpoint's capabilities and operating-system +permissions. Prompt loading and skill discovery are authorized separately by +their own blocks, and any attachment lets the agent read discovered skills. +Selection and environment status remain separate. + +The session's context includes an **Environment catalog** listing every +attachment with its display name, status, access, working directory, and +which one is active, so the agent knows what it may use before calling a +tool. After a switch, the old catalog is removed until the next idle refresh. +Use `environment_list` or `environment_read` for current status during a run. ## Keep files in the right domain @@ -93,15 +114,19 @@ Use explicit source and destination paths in tasks that cross this boundary. ## Reuse a machine through a profile -In **Profiles**, open the profile and enable **Environments**. Under -**Session environment → Mode**, choose **Activate an existing environment**, -then select its **Environment**. Save the profile and start a new session -from it. +In **Profiles**, open the profile and enable **Environments**. Add the +machine under **Environments** with the access the job needs and mark it as +the **Default** attachment. Save the profile and start a new session from it. -Every session using this setup selects that existing machine. This is useful -for a shared repository checkout, a long-lived service, or several bot -conversations working with the same operating-system state. The profile does -not close the existing environment when one of those sessions ends. +Every session created from this setup activates that machine. The default +fills an empty active pointer whenever the profile is applied, creation +included, and never overrides a live selection: applying the profile to an +existing session first clears an active environment the profile no longer +lists, then activates the default only if nothing is active. At most one +attachment can be the default. This is useful for a shared repository +checkout, a long-lived service, or several bot conversations working with the +same operating-system state. The profile does not close the environment when +one of those sessions ends. Sharing also means sharing file changes, processes, installed tools, and environment-bound credentials. Lightspeed does not coordinate edits between @@ -113,95 +138,62 @@ borrowed machine. An ephemeral registration closes after its disconnect grace period; once closed, that identity cannot return and the profile's saved selection becomes unavailable. -## Provision a machine per session - -A profile can request its own environment when a session starts. The universe -must have an enabled provider binding and an available template first. In the -profile's **Session environment** settings: - -1. Choose **Mode → Provision a new environment for the session**. -2. Select **Provider** and **Template**. The template identifies an immutable - provider version, including its machine setup. -3. Choose **Retention → Close with the session** for a task-specific machine, - or **Retain after the session closes** when its files must stay available - afterward. -4. Optionally set a display name, an idle policy, and - [environment credentials](credentials.md). -5. Save the profile and create a new session from it. - -The environment can be selected while it is still provisioning or booting. -When an environment-dependent tool reaches it before it is ready, the runtime -waits for readiness and then dispatches the tool. Inspect the environment's -status if that wait takes longer than expected; a failed provision needs -attention at the provider. - -Provisioning is tied to the session identity. Retrying creation or reapplying -the profile finds the same environment instead of allocating another one. -Changing the profile template or credentials does not rebuild or resynchronize -that existing machine. If its environment has closed or failed, explicitly -create and select a replacement, or start a new session. - -**Close with the session** is the default. The environment can still be -selected by another session, but that does not transfer or extend its cleanup -policy. Closing its originating session can remove the machine another -session is using. Choose a separately managed existing environment for a -shared lifetime, and read [Power and cleanup](power-and-cleanup.md) before -retaining machines beyond their original tasks. - -You can create that separate resource directly through **Environments → New -environment**. Select a **Template**, enter a **Display name**, configure any -idle policy, and choose **Provision**. The button appears only when the -universe has a non-deprecated template from an enabled binding. The created -environment has no originating session that automatically owns its closure. +## Create an independent machine + +Use **Environments → New environment**. Select a **Template**, enter a +**Display name**, configure an idle policy if needed, and choose **Provision**. +The button appears when the universe has a non-deprecated template from an +enabled binding. Configure [credentials](credentials.md) on the environment, +then select it in a session or profile. + +The environment can be selected while it is provisioning or booting. Tools +wait for readiness before dispatching. If provisioning fails, inspect the +provider and explicitly create or select a replacement. Session creation and +profile application never allocate machines. + +Environments remain available when sessions close or are deleted. Manage +cleanup through explicit environment operations and idle policies; see +[Power and cleanup](power-and-cleanup.md). ## Use environments with bots and sub-agents For a bot whose Main conversation, routed threads, and chat conversations -should use one machine, select the same **existing** environment in its -profile. Provisioning in the profile instead creates a machine for each -session that uses that intent. Resetting a conversation can therefore close -its old machine and provision another for its successor. - -A child profile can choose **Inherit the parent's active environment -(sub-agents only)**. The child shares the parent's selected machine without -copying it and does not close it as its own resource. The parent must have an -active environment, and the child's grants must allow access to it. Using -this mode for a standalone session is rejected. - -The child can also select a different existing machine or provision one of -its own. Its VFS links remain independent of all these choices. See +should use one machine, attach the same environment as the **Default** in its +profile. Resetting a conversation leaves that environment intact for the +successor and other sessions using it. Execution polls that name no +environment run on the profile's default attachment; because a live selection +is never overridden, a bot conversation that switched to another attached +machine can diverge from its polls. Name the environment on the poll when +they must match. + +A child profile can attach **Inherit the parent's active environment +(sub-agents only)** (`"inherit": true`) with its own access level. The +attachment resolves to the parent's active machine when the child is spawned; +the child shares that machine without copying it and does not close it as its +own resource. If the parent has no active environment, the inherit attachment +is dropped; if the child also attaches the same machine explicitly, the +explicit attachment wins. An `inherit` attachment in a standalone session or +a plain session configuration is rejected. + +The child can also attach a different existing machine. Create any new machine +through the environment API first. Its VFS attachments remain independent of these choices. See [Sub-agents and federation](../using-lightspeed/subagents-and-federation.md) for the rest of the child-profile boundary. ## Grant model-driven selection carefully -Enable **Environment selection tools** when the model should list, activate, -and deactivate allowed environments itself. Without that switch, you can -still select a machine through the client or profile, and the model can read -its active environment. The switch does not grant environment provisioning. - -The feature supports two independent filters: - -| Configuration field | Source it restricts | -| --- | --- | -| `providers` | Provisioned environments from the listed provider IDs. | -| `registrationKeys` | Registered environments admitted by the listed registration-key IDs. | - -An absent filter allows every environment of its source kind. An explicit -empty list denies that source kind. External environments are allowed only -when both filters are absent. A provider filter alone therefore still allows -registered machines; it is not an allowlist covering every environment source. - -The form exposes **Allowed providers**, with an empty selection meaning no -provider restriction. Registration-key filtering and explicit empty lists -need the profile's JSON view or the API. The current form's model-configuration -normalizer does not preserve those advanced values when editing the config; -keep such edits in JSON/API and inspect the saved configuration afterward. +Enable **Environment selection tools** (`"selection": true`) when the model +should list, activate, and deactivate attached environments itself. Without +that switch, you can still select a machine through the client or profile, +and the model can still read its active environment with `environment_read`. +The switch does not grant environment provisioning and does not widen the +allowed set: `environment_list` lists the attachments, and `environment_read` +and `environment_activate` accept only attached ids. Their results carry the +attachment's access line. -The session settings form also currently rejects a registered environment -when a nonempty provider filter is present, even when runtime policy permits -it. Use the profile JSON/API or CLI to apply that valid combination instead -of removing the intended restriction. +Session configuration has no provider or registration-key filters. Which +machines a session may use is exactly its attachment list; registration keys +remain an operator concept for enrolling machines. The runtime also rejects an ambiguous tool batch that changes selection and uses the selected environment in the same batch. The agent must select first, @@ -210,13 +202,18 @@ handles keep their original environment even after selection changes. ## Change or clear the selection -In an idle session's settings, choose another **Active environment**, or choose -**No active environment**, then **Apply setup**. This changes the reference; -it does not migrate files, terminate existing jobs, or close the machine. +In an idle session's settings, choose another attached **Active environment**, +or choose **No active environment**, then **Apply setup**. This changes the +reference; it does not migrate files, terminate existing jobs, or close the +machine. -A profile's **Do not change the active environment** mode also leaves an -existing selection in place. It does not mean “clear the selection.” Use the -session control or explicit deactivation for that operation. +A plain configuration replacement (`session/config/put`) never activates a +default; it only clears an active environment that the new configuration no +longer lists, so a deliberate deactivation is not undone by a configuration +edit. Applying a profile fills an empty pointer from the profile's default +attachment and leaves a live, still-listed selection alone. Creating a session +with `session/start` can name an attached environment or `none` to suppress +the default. With the [CLI connection settings](../using-lightspeed/sessions-and-runs.md#continue-from-the-cli) configured, the equivalent controls are: @@ -230,7 +227,8 @@ target/debug/lightspeed env deactivate --session "" The public methods are `environments/list`, `environments/read`, `session/environments/activate`, and `session/environments/deactivate` in the -[API reference](../../../crates/api/contract/api-reference.md). +[API reference](../../../crates/api/contract/api-reference.md). Activation +accepts only an environment attached in the session's configuration. ## If the machine is unavailable @@ -239,6 +237,7 @@ The public methods are `environments/list`, `environments/read`, | **New environment** is missing | Check the universe binding, provider templates, and whether the template is deprecated. Borrowed machines use registration or attachment instead. | | Selection succeeds but the first tool waits | A provisioned machine may still be booting or waking. Inspect its status and provider health. | | A registered machine is offline | Restart or reconnect its daemon using the retained identity. Lightspeed cannot power on that borrowed machine. | -| A visible environment is rejected by the session | Inspect the feature grant and both source filters, plus the machine's capabilities and lifecycle status. | +| A visible environment is rejected by the session | Check that it is attached in the session's configuration, plus the machine's capabilities and lifecycle status. | +| A file, process, or job call is refused on the active machine | The active attachment's access does not cover it. The toolset is the union of all attachments; raise that attachment's access or switch to one that covers the call. | | A saved profile points to a closed machine | Select a replacement explicitly. The runtime does not silently switch to another environment. | | A VFS file is missing on the machine | Transfer it explicitly and check which filesystem each tool used. | diff --git a/docs/documentation/environments/vfs-transfer.md b/docs/documentation/environments/vfs-transfer.md index 378d9631..da694afe 100644 --- a/docs/documentation/environments/vfs-transfer.md +++ b/docs/documentation/environments/vfs-transfer.md @@ -28,20 +28,25 @@ during preparation and may remain if a transfer fails; VFS parents are published with the captured content in one workspace commit. There is no merging or automatic sync. -The session needs both environment access and VFS tools. Materialize requires -read access to its VFS source; capture requires an editable workspace link. -Snapshot links are read-only. A selected VFS path must belong to one linked +The session needs both a workspace attachment and an environment attachment. +Materialize requires read access to its VFS source and an environment +attachment with `edit` or higher; capture requires an `edit` workspace attachment +and any environment attachment. Snapshot attachments are read-only. A selected VFS path must belong to one attached workspace or snapshot, rather than a synthetic directory spanning several -links. Ordinary VFS operations do not require a selected environment. - -Profiles and session settings use the same grants. Under **Virtual File System**, -**Read only** file tools plus **Environments** enable materialize; **Edit files** -plus **Environments** enable both directions. A VFS configured only to source -prompts or skills enables neither transfer tool. Read-only VFS access allows -materialization to write the environment while preserving the VFS source. -Environment selection tools are optional: a profile or API can select the machine. -The tool catalog stays stable while environments change; calls check current -readiness and daemon support when they execute. +attachments. Ordinary VFS operations do not require a selected environment. + +Profiles and session settings use the same grants, and the transfer tools +follow the union of attachment access in both domains: any workspace attachment +plus an environment attached with `edit` or higher installs materialize; an +`edit` attachment plus any environment attachment installs capture. A VFS or +environment feature with no attachments installs neither. A call runs against +the active environment and is refused when that machine's own access does +not cover it. Read-level VFS access allows materialization to write the +environment while preserving the VFS source. Command execution (`exec`) is +not needed for either transfer. Environment selection tools are optional: a +profile or API can select the machine. The tool catalog stays stable while +environments change; calls check current readiness and daemon support when +they execute. Capture saves an immutable snapshot first, with the selected node at `/selection`. It then publishes to the workspace using the revision read before @@ -51,11 +56,6 @@ replace concurrent workspace edits. The recorded tool result retains that snapshot and its file blobs even when workspace publication fails. -Environment permissions also apply: materialize requires environment **Edit files**; -capture requires environment **Read only** or **Edit files**. VFS read access -is required for materialize, and a writable VFS link plus VFS editing tools for -capture. Command execution is not needed for either transfer. - ## Content reuse and large files A transfer is one logical operation across many bounded exchanges. Inventories diff --git a/docs/documentation/getting-started/concepts.md b/docs/documentation/getting-started/concepts.md index 7a4a1ba4..a1449503 100644 --- a/docs/documentation/getting-started/concepts.md +++ b/docs/documentation/getting-started/concepts.md @@ -64,7 +64,7 @@ mean sending the entire conversation to the model on every turn. ## A profile gives sessions a reusable setup A profile describes how to start an agent: its model, instructions, -capabilities, limits, workspace links, and optional environment setup. The +capabilities, limits, and the workspaces and environments it attaches. The incident response team could create an `incident-reviewer` profile that asks the agent to distinguish evidence from speculation and gives it access to a workspace for notes. @@ -82,7 +82,7 @@ can converse with a model without any optional tool capabilities. A VFS workspace stores persistent files in Lightspeed's virtual filesystem. The incident reviewer can write notes there without an operating system -attached. A session uses files through workspace links, and several sessions +attached. A session uses files through workspace attachments, and several sessions can link to the same workspace. Those sessions still have separate conversations even though they can work with shared files. @@ -104,7 +104,7 @@ machine lifecycle work. ```mermaid flowchart TB - Session["Incident reviewer session"] -->|Workspace link and VFS tools| Notes + Session["Incident reviewer session"] -->|Workspace attachment and VFS tools| Notes Session -->|Active environment and environment tools| Copy subgraph VFS["VFS workspace"] Notes["Incident report and notes"] diff --git a/docs/documentation/getting-started/first-agent.md b/docs/documentation/getting-started/first-agent.md index 329d88e5..6ad2555c 100644 --- a/docs/documentation/getting-started/first-agent.md +++ b/docs/documentation/getting-started/first-agent.md @@ -53,22 +53,21 @@ provider that supports tool calls. Select it explicitly so the profile does not depend on a different deployment default. Instructions describe the work, but they do not grant access to files. Enable -**Virtual File System: Files, Instructions, Skills**, then set **File tools** -to **Edit files**. +**Virtual File System: Files, Instructions, Skills**. -Under **Workspace links**, choose **Add link** and configure: +Under **Workspace attachments**, choose **Add link** and configure: | Field | Value | | --- | --- | | Target type | Workspace | | Workspace | Release notes (`release-notes`) | | Session path | `/workspace` | -| Access | Read and write | +| Access | Edit | Leave the other capabilities and prompt/skill roots unset, then choose -**Save**. The profile now grants file operations and links a writable -workspace. Both are needed: tools without a link have no workspace to operate -on, while a read-only link cannot accept the release notes. +**Save**. The profile now links an editable workspace, and the link is what +installs the file tools: any attachment grants reading, and an **Edit** link adds +writing. A **Read** link could not accept the release notes. The session path is how this agent sees the workspace. Its source file will be `/workspace/changes.md`. In the workspace browser, the same file is simply @@ -136,9 +135,9 @@ Existing sessions keep their setup until you explicitly change or reapply it. | Symptom | What to check | | --- | --- | -| The agent prints release notes in chat but never saves a file | Confirm that the task asks for a saved file and the profile has **File tools → Edit files**. Inspect the transcript for an actual write operation. | -| The agent cannot find `changes.md` | Check the workspace link and session path. The agent needs `/workspace/changes.md`; the workspace browser shows `changes.md`. | -| The write is refused | The workspace link must use **Read and write** as well as granting edit tools. | +| The agent prints release notes in chat but never saves a file | Confirm that the task asks for a saved file and the workspace attachment has **Edit** access. Inspect the transcript for an actual write operation. | +| The agent cannot find `changes.md` | Check the workspace attachment and session path. The agent needs `/workspace/changes.md`; the workspace browser shows `changes.md`. | +| The write is refused | The workspace attachment must use **Edit** access; a **Read** link or a snapshot never accepts writes. | | Fixing the profile does not fix the session | Create a fresh session from the corrected profile, or explicitly update the existing session's setup. | | The file contains unsupported claims | Revise the instructions or ask for a correction against the source. Tool success verifies that a file was written, not that its contents are correct. | diff --git a/docs/documentation/how-it-works/architecture.md b/docs/documentation/how-it-works/architecture.md index caa2c4f8..8405e262 100644 --- a/docs/documentation/how-it-works/architecture.md +++ b/docs/documentation/how-it-works/architecture.md @@ -27,10 +27,42 @@ continuity between the two tasks. A **session** is that continuing conversation and execution state. A **run** is one admitted task within it. Completing a run does not discard the session; -another task can use the same configuration, workspace links, and accumulated +another task can use the same configuration, workspace attachments, and accumulated context. A **profile** supplies reusable setup when creating or configuring a session. It is resolved by the hosted runtime, outside the deterministic core. +The session workflow owns setup, profile/configuration application, and runtime +context refresh. It observes the desired toolset for every new run submission, +including internal followups, through activities that read current records and +grants. The gateway submits intent and waits for a correlated outcome. Tools +publish at safe turn boundaries; cancellation and existing effects continue +while a new submission's policy read is pending. Duplicate submissions resolve +before fresh policy reads. Initial setup must finish before a session can run. + +Configuration, profile, and explicit refresh operations retain up to 256 compact +result receipts across workflow rollover. Retained retries return their original +result. Once a receipt expires, the workflow rejects the old request; callers +must reload session state before submitting a new operation. + +These operations build a private candidate through ordinary engine command +admission. Activities prepare context against the proposed configuration, +instructions, and environment. After every command validates, the workflow +publishes the complete event batch in one database transaction against the +original session head. Failed preparation leaves the proposed changes +unpublished; a concurrent session change rejects the candidate. Source +invalidation is included in the batch. Storage confirms retries of an already +committed batch, so a lost append response cannot apply the change twice. + +Environments have independent lifecycles. Their service owns provisioning, +registration, credentials, power, and cleanup; environment runtime roles run +reconciliation. Session configuration attaches the environments a session may +use, each with its own access level; setup only activates one of them, filling +an empty active pointer from a profile's default attachment or, for +sub-agents, from the parent's active machine resolved at spawn. Session +closure and deletion never close environments. Selection validates attachment +membership and a nonterminal registry record without waking or probing the +machine. Readiness checks and wake-on-use happen during actual use. + The core represents a session as events reduced into state. Admission checks whether a command is valid against that state. Planning decides which fact or effect comes next. The effect might be a model request, a tool invocation, or diff --git a/docs/documentation/how-it-works/context-and-storage.md b/docs/documentation/how-it-works/context-and-storage.md index 89ca375d..9cba117d 100644 --- a/docs/documentation/how-it-works/context-and-storage.md +++ b/docs/documentation/how-it-works/context-and-storage.md @@ -83,8 +83,9 @@ for the resolution rules. ## Assemble a turn from recorded inputs Before an idle session admits new run work, the session workflow refreshes -material derived from its linked workspaces and selected environment. That -includes enabled prompt sources, skill catalogs, and the sub-agent menu. The +material derived from its attached workspaces and selected environment. That +includes enabled prompt sources, skill catalogs, the environment catalog, and +the sub-agent menu. The gateway submits the run without first repeating this discovery. Session setup, configuration changes, and explicit skill reads retain their own refresh paths. The admission refresh occurs when no run is active or already queued; it is not @@ -124,15 +125,19 @@ Prompt caching rewards repeated request material, but an agent's context also needs to evolve. Lightspeed makes ordering and updates deliberate so changes need not disturb more of the request than necessary. -VFS, skill, sub-agent, and client catalogs share one `Catalog { title }` -context kind. Each runtime publisher stores the rendered text in CAS and keeps +VFS, skill, environment, sub-agent, and client catalogs share one +`Catalog { title }` context kind. Each runtime publisher stores the rendered text in CAS and keeps its structured snapshot in `provenance_ref` for API reads and source retention. Provider adapters read that stored text and add only the title and, for a successor, an update header. Replaying an old entry does not rerender its source with newer publisher code. Catalog identity comes from its key. The runtime owns `runtime.catalog.vfs`, -`runtime.catalog.skills.vfs`, and `runtime.catalog.subagents`. Public context +`runtime.catalog.skills.vfs`, `runtime.catalog.environments`, and +`runtime.catalog.subagents`. The environment catalog lists every attached +environment with its id, display name, status, access, working directory, +default marker, and which one is active; it is built from configuration and +display names without discovery. Public context append and remove reject `runtime` and `runtime.*`, as well as `run` and `run.*`. Client catalogs use other keys. Source discovery and refresh policy remain specific to each publisher; publishing compares both text and provenance. @@ -255,7 +260,7 @@ copied again. A workspace adds a named, mutable head over those snapshots. Updating that head uses a revision check so a writer does not silently overwrite another -writer's move. A snapshot link pins a particular version; a workspace link +writer's move. A snapshot attachment pins a particular version; a workspace attachment resolves its current head at the relevant operation boundary. Session history has related fork primitives at the core/storage layer. A diff --git a/docs/documentation/how-it-works/tools-and-controller-workflows.md b/docs/documentation/how-it-works/tools-and-controller-workflows.md index 69a2f8e5..939baf93 100644 --- a/docs/documentation/how-it-works/tools-and-controller-workflows.md +++ b/docs/documentation/how-it-works/tools-and-controller-workflows.md @@ -259,7 +259,9 @@ promise, and closes the child. Cancellation and deadline paths also close it. The child receives its brief and its own profile. It does not automatically inherit the parent's transcript or every capability. Workspace sharing and -environment inheritance require the relevant grants. Root-scoped limits +environment inheritance require the relevant attachments; an `inherit` +environment attachment is resolved against the parent's active machine +captured at admission and stored on the child as a concrete id. Root-scoped limits constrain depth, total descendants, concurrent open descendants, and deadlines. These policies live around normal session execution; the engine does not need a delegation-specific transport. @@ -281,7 +283,10 @@ The job still runs on a real machine. Durable orchestration does not make its operating-system process replayable, and a daemon restart has different consequences from a session worker restart. VFS tools also remain separate from environment file and process tools: one operates on CAS-backed workspace files, -the other on the batch's selected machine. [Processes and jobs](../environments/processes-and-jobs.md) +the other on the batch's selected machine. The installed environment tools +are the union of the configuration's attachments; a call the active machine's +own access does not cover is refused at execution rather than removed from +the toolset, so switching machines never changes the advertised tools. [Processes and jobs](../environments/processes-and-jobs.md) describes those execution limits. The common structure is now visible. A session records an admitted operation, diff --git a/docs/documentation/integrating-and-extending/custom-tools-and-model-providers.md b/docs/documentation/integrating-and-extending/custom-tools-and-model-providers.md index 7187d972..47330801 100644 --- a/docs/documentation/integrating-and-extending/custom-tools-and-model-providers.md +++ b/docs/documentation/integrating-and-extending/custom-tools-and-model-providers.md @@ -73,7 +73,8 @@ These code locations explain how the existing paths fit together: | [Toolset](../../../crates/tools/src/toolset.rs) | Construct the selected tool surface and provider-specific presentation. | | [Tool runtime interfaces](../../../crates/tools/src/runtime/mod.rs) | Typed invocation/output helpers and runtime contracts. | | [Inline dispatch](../../../crates/tools/src/runtime/inline.rs) | Execute supported compiled operations with the correct context. | -| [Session tool admission](../../../crates/temporal-server/src/gateway/service/session_toolset.rs) | Reconcile declared session capabilities with installed tools. | +| [Session tool preparation](../../../crates/temporal-server/src/gateway/service/session_preparation.rs) | Materialize the desired tools from session capabilities and current registry records. | +| [Workflow preparation](../../../crates/temporal-workflow/src/workflows/session/preparation.rs) | Order preparation and publish tool changes at safe session boundaries. | | [Hosted tool execution](../../../crates/temporal-server/src/worker/session_tools.rs) | Assemble runtime adapters for session tool batches. | The provider-facing function name is not necessarily the logical ID. Existing diff --git a/docs/documentation/using-lightspeed/bots-and-triggers.md b/docs/documentation/using-lightspeed/bots-and-triggers.md index 5ac61921..d6e4edd8 100644 --- a/docs/documentation/using-lightspeed/bots-and-triggers.md +++ b/docs/documentation/using-lightspeed/bots-and-triggers.md @@ -145,10 +145,13 @@ next poll produces an event. An execution poll needs an existing, lasting execution environment and a command that prints JSON to stdout. The UI enables **Run a command** when the -profile selects an existing environment. Enter one argument per line in the -command form. It cannot rely on a new machine that would only be provisioned -when the event starts a session. Leaving **Environment** blank is appropriate only when the -profile already selects an existing machine. +profile attaches a default environment. Enter one argument per line in the +command form. Create and configure the machine independently before enabling +the trigger. Leaving **Environment** blank runs the poll on the profile's +default attachment. The poll follows that default even when the bot's +conversation has since switched to another attached machine, because a live +selection is never overridden; name the environment explicitly when the poll +must share the conversation's machine. Changing a poll specification resets its cursor and establishes a new baseline. Ten consecutive poll failures disable the trigger; fix the source @@ -225,10 +228,9 @@ schedules, and refuses new work while keeping the bot and its history. Deleting additionally removes the bot record, triggers, events, and conversations, and makes its ID available again. -Profiles and shared existing environments remain independent resources. -Environments provisioned for a session follow their `closeWithSession` -policy when that session closes. Closing a bot therefore does not imply that -every machine is retained or that every machine is removed. +Profiles and environments remain independent resources. Closing or deleting a +bot or any of its sessions leaves its environments intact. Manage machine +cleanup through environment operations and idle policies. ## If an event does not produce the expected work diff --git a/docs/documentation/using-lightspeed/profiles-and-instructions.md b/docs/documentation/using-lightspeed/profiles-and-instructions.md index 6b748353..a935d589 100644 --- a/docs/documentation/using-lightspeed/profiles-and-instructions.md +++ b/docs/documentation/using-lightspeed/profiles-and-instructions.md @@ -1,8 +1,8 @@ # Profiles and instructions A profile is a reusable agent setup. It collects the model, instructions, -capabilities, workspace links, and optional environment selection that a -session needs. You can use the same profile for an interactive session, a +capabilities, and the workspaces, environments, and MCP servers it attaches +that a session needs. You can use the same profile for an interactive session, a bot, or a delegated sub-agent. For example, a release editor needs instructions about factual claims, a @@ -49,9 +49,9 @@ report the uncertainty instead of filling in the gap. ``` Select an explicit **Model** and enable the capabilities this job requires. -For the reviewer, enable **Virtual File System: Files, Instructions, Skills**, -set **File tools** to **Read only**, and link the `release-notes` workspace at -`/workspace` with **Read only** access. Save the profile as `release-reviewer`. +For the reviewer, enable **Virtual File System: Files, Instructions, Skills** +and link the `release-notes` workspace at `/workspace` with **Read** access. +Save the profile as `release-reviewer`. Create a session from it and ask it to compare `/workspace/changes.md` with `/workspace/release-notes.md`. Verify both the review and the tool activity. @@ -67,30 +67,37 @@ the file-access boundary even if the model asks to write. The profile editor groups grants into VFS, Web, Sub-agents, Timers, Environments, and MCP Servers. Leaving a feature absent supplies no tools -from that feature. Enabling one can require further choices, such as which -workspace to link, which child profiles can be called, or which MCP server -to expose. - -VFS file tools and Environments together also grant -[VFS transfer tools](../environments/vfs-transfer.md). **Read only** VFS tools -enable materialize into the selected environment; **Edit files** additionally -enable capture into writable workspace links. Prompt or skill sourcing without -file tools does not enable transfers. These rules also apply in session settings. +from that feature. Resource-backed features are lists of attachments, each +naming a workspace, environment, or MCP server together with what the session +may do with it: a workspace attachment carries `read` or `edit` access, an +environment attachment carries `read`, `edit`, `exec`, or `jobs` (each level +including the ones before it), and an MCP server entry can narrow the +server's tool allowlist. The tools the agent sees follow from the union of +those grants; there is no separate file-tool switch, and a call the active +environment's own access does not cover is refused when it executes. + +Workspace attachments and environment attachments together also grant +[VFS transfer tools](../environments/vfs-transfer.md). Any workspace attachment plus +an environment attached with `edit` or higher enables materialize into the +active environment; an `edit` attachment plus any environment attachment enables +capture into that workspace. A VFS or environment feature with no attachments +enables neither. These rules also apply in session settings. VFS **Skill discovery** and **Prompt loading** are separate opt-in switches. Each enables its configuration block (`features.vfs.skills` or `features.vfs.prompts`); an empty block uses conventional directories beneath -workspace links. Optional root overrides replace the defaults. Clearing an -override restores defaults; switching off disables that source. File tools and -links alone enable neither source. Environment skill discovery is configured +workspace attachments. Optional root overrides replace the defaults. Clearing an +override restores defaults; switching off disables that source. Links alone +enable neither source. Environment skill discovery is configured independently under `features.environments.skills`; environment prompts use `features.environments.prompts` with the same enablement and override rules. Each domain has a **Working directory** setting: `features.vfs.workingDirectory` -(default `/`) and `features.environments.workingDirectory` (default supplied by -the selected machine). Configure `/workspace` explicitly if that is the desired -VFS base. Environment file tools, commands, jobs, and discovery share the machine -base; a per-command `cwd` override does not change the session setting. +(default `/`) for the VFS, and `workingDirectory` on each environment +attachment (default supplied by that machine). Configure `/workspace` +explicitly if that is the desired VFS base. Environment file tools, commands, +jobs, and discovery share the active attachment's base; a per-command `cwd` +override does not change the session setting. Apply the same reasoning to delegated work. A parent that can call a powerful child profile can ask that child to use its capabilities. The child's setup @@ -128,7 +135,9 @@ both prompt sourcing and a review skill. ## Apply changes deliberately -A new ordinary session receives the profile's setup at creation. Saving a +A new ordinary session completes the profile's setup in its durable workflow +before it reports readiness. Retrying session creation finishes the original +setup even if the named profile has since changed. Saving a later profile revision affects future sessions; it does not alter those existing conversations automatically. @@ -150,11 +159,11 @@ Applying a profile is not a deep merge of every field: | Profile content | Effect on an existing session | | --- | --- | -| `config` present | Replaces the session configuration as a whole. Include the capabilities and links you intend to retain. | +| `config` present | Replaces the session configuration as a whole. Include the capabilities and attachments you intend to retain. | | `config` absent | Leaves the current configuration in place. | | `instructions` present or absent | Replaces or clears the profile instruction layer. Sourced prompt files follow the resulting VFS setup. | -| `environment` absent | Leaves the active environment unchanged. | -| `environment` present | Applies that selection or provisioning intent. | +| The active environment is no longer attached | Clears the active environment. | +| An environment attachment marked `default` | Activates it only when the session has no active environment afterwards; a live selection of a still-attached machine is left alone. | | Metadata and retention defaults | Remain creation defaults; applying the profile does not rewrite the existing session's metadata or retention. | Bots follow their named profile differently. Their Main conversation adopts @@ -164,7 +173,7 @@ several bots; review its users before changing their grants or model. If a new API kind requires a fresh Main conversation, the controller creates a successor. See [Bots and triggers](bots-and-triggers.md). -## Set limits and environment intent +## Set limits and a default environment The advanced **Run limits** fields include **Max turns** and **Max tool rounds**. They bound a run's work under the selected defaults. API callers can @@ -172,13 +181,21 @@ provide per-run overrides, so these fields should not be treated as hard authorization ceilings. Bot daily budgets and sub-agent tree limits govern different scopes. -An environment intent can select an existing environment or provision one -for a session. A delegated child can also explicitly inherit its parent's -active environment. Existing and inherited environments are shared machines; -provisioning can create a separate one with its own session-close policy. -The profile must grant the relevant environment capability as well as select -the machine. Read [Environments](../environments/overview.md) before adding -compute to a profile that currently needs only VFS files. +A profile activates a machine through its environment attachments. Mark one +attachment `"default": true` and it is activated at session creation and +whenever the profile is applied to a session that has no active environment. +Session creation uses that default attachment and has no separate environment +override in the UI or the ordinary and managed session APIs. To start with a +different selection, customize the attachments and their default. With no +default attachment, a new session has no active environment. +A sub-agent profile can instead attach `"inherit": true`, which resolves to +the delegating parent's active environment when the child is spawned. A plain +session configuration replacement never activates a default. Environments +are created and managed independently: profiles never provision machines, +bind credentials, or close them with the session. Attaching a machine both +allows it and sets what the session may do there. Read +[Environments](../environments/overview.md) before adding compute to a profile +that currently needs only VFS files. Metadata and retention settings supply defaults for newly created sessions. Use metadata for organization, such as `project=acorn`, and retention to @@ -190,7 +207,7 @@ instructions to the agent. | Symptom | What to check | | --- | --- | | A saved profile change has no effect in an ordinary session | Apply it explicitly, edit session setup, or start a new session. | -| The agent describes a tool it cannot call | Check the feature grant and its target configuration; instructions alone do not expose tools. | +| The agent describes a tool it cannot call | Check the feature grant and its attachments' access; instructions alone do not expose tools. | | Applying a profile removes a previous capability | A supplied `config` replaces the whole configuration. Include all intended grants. | | Clearing custom instructions leaves instructions active | Check VFS prompt roots and their files. Those are a separate authored source. | | A bot thread still uses the previous setup | Main reconciles at idle, while an existing routed thread keeps its setup. Reset the appropriate conversation when ready. | diff --git a/docs/documentation/using-lightspeed/sessions-and-runs.md b/docs/documentation/using-lightspeed/sessions-and-runs.md index a10005d6..7bf59f1a 100644 --- a/docs/documentation/using-lightspeed/sessions-and-runs.md +++ b/docs/documentation/using-lightspeed/sessions-and-runs.md @@ -32,7 +32,7 @@ Report any mismatch, but leave the files unchanged. When the answer arrives, send a follow-up in the same session. The agent can use the earlier conversation and its linked files. Starting a new session -from the same profile gives you a fresh conversation; workspace links may +from the same profile gives you a fresh conversation; workspace attachments may still point to the same shared files. ## Queue, steer, or stop work diff --git a/docs/documentation/using-lightspeed/subagents-and-federation.md b/docs/documentation/using-lightspeed/subagents-and-federation.md index a05156a1..0eb42465 100644 --- a/docs/documentation/using-lightspeed/subagents-and-federation.md +++ b/docs/documentation/using-lightspeed/subagents-and-federation.md @@ -23,8 +23,8 @@ platform administrator account to configure them. First create the `release-reviewer` profile from [Profiles and instructions](profiles-and-instructions.md#create-a-profile-for-a-job). -It should have a clear description, read-only VFS tools, and its own read-only -link to the `release-notes` workspace at `/workspace`. +It should have a clear description and its own `read` link to the +`release-notes` workspace at `/workspace`. Open the parent `release-editor` profile and enable **Sub-agents**. In **Agents**, select `release-reviewer`. Optional limits are hidden by default; @@ -61,18 +61,21 @@ that can write to a database delegates that authority even if the parent cannot call the database tool directly. Review the child profile as part of the parent's access design. -Workspace links are shared only when both profiles point to the same live -workspace. They do not create isolated copies. Give a reviewer read-only +Workspace attachments are shared only when both profiles point to the same live +workspace. They do not create isolated copies. Give a reviewer `read` access, or use a snapshot when it must review a fixed version while another agent continues editing. -Environment behavior is also explicit. An existing environment or an inherited -parent environment shares a real filesystem. The child profile option -**Inherit the parent's active environment (sub-agents only)** needs a parent -with an active environment and the appropriate capability. -Provisioning can give the child a separate machine, normally closed with its -session according to the selected policy. VFS files remain separate from -these machine files; see [Environments](../environments/overview.md). +Environment behavior is also explicit. An attached environment or an inherited +parent environment shares a real filesystem. The child profile's +**Inherit the parent's active environment (sub-agents only)** attachment +(`"inherit": true`) resolves to the parent's active machine when the child is +spawned, with the access level the child's attachment declares. It is +dropped when the parent has no active environment, and an explicit attachment +of the same machine wins over it. The child can also attach a different +existing machine with its own access; profiles never provision one. VFS files +remain separate from these machine files; see +[Environments](../environments/overview.md). A sub-agent spawned by a bot does not become another bot. It gets its profile and brief, without the parent's bot history, inbox, or controller-specific @@ -216,7 +219,7 @@ its neighbors. | Symptom | What to check | | --- | --- | | The parent cannot find a specialist | Add the named profile to **Sub-agents → Agents** and give it a useful description. | -| The child cannot read the parent's files | Configure links in the child profile; the brief alone grants no access. | +| The child cannot read the parent's files | Link the workspace in the child profile; the brief alone grants no access. | | New children are refused despite none currently running | Check the lifetime descendant budget as well as concurrency and depth. | | A spawned child ends before its result is used | The parent run may have ended with a run-scoped promise still pending. Await it or deliberately detach it. | | A bot is absent from the federation directory | Check sending permission, target state, and the recipient's enabled inbox allowlist. | diff --git a/docs/documentation/using-lightspeed/tools-and-mcp.md b/docs/documentation/using-lightspeed/tools-and-mcp.md index 611f6c37..01b340be 100644 --- a/docs/documentation/using-lightspeed/tools-and-mcp.md +++ b/docs/documentation/using-lightspeed/tools-and-mcp.md @@ -28,8 +28,9 @@ The model-configuration editor groups capabilities by what the agent can do: | **Environments** | Working with execution environments and their processes. See [Environments](../environments/overview.md). | | **MCP Servers** | Calling tools supplied by registered external MCP servers. | -Each feature has its own settings. A file-tool grant still needs workspace -links, and process access needs an environment. After changing the profile, +Each feature has its own settings. VFS tools come from attached workspaces, +and process access needs an environment attached with `exec` access. After +changing the profile, create a new session or [apply the setup](profiles-and-instructions.md#apply-changes-deliberately) to an existing idle one. @@ -87,10 +88,12 @@ connection; it does not grant the server to a profile yet. ## Select tools and grant the server A newly registered server initially allows all of its advertised tools. -Before adding it to a session, edit the server, choose **Load tools**, then -**Allow only selected tools**. Select the operations needed for the job and -choose **Save**. If you changed the URL or credential first, save those -connection changes before loading tools. +To restrict that connection for every profile and session, edit the server +and choose **Selected tools**. Tool selection is always visible here, with +the live inventory loaded when the editor opens. Search by name or +description, select the allowed operations, and choose **Save**. If you changed +the URL, credential, or network access first, save those connection changes +before loading tools. For an issue-tracker integration, an initial review profile might need issue search and issue read operations. Add a write operation when the task also @@ -101,15 +104,33 @@ Loading tools discovers their metadata without invoking them. Read descriptions and safety annotations as claims from that server. The allowlist and approval policy are the controls you configure in Lightspeed. -Now open the profile, enable **MCP Servers**, choose **Add server**, and select -the registered **Server**. Save and start a session from that profile. Ask it -to perform a small read-only lookup against a known object, then inspect the -arguments and returned result in the transcript. - -The profile references the server ID. The universe record owns its endpoint, -credential, execution path, exposure, tool selection, and approval policy. -Changes to that shared record can affect every profile using it; it is not -copied into each profile as an independent connection. +Now open the profile and enable **MCP Servers**. It starts with no server +attachments and grants no MCP tools until you choose **Add server** and select +the registered **Server**. Remove all server attachments before disabling the +feature. Each attachment defaults to **All server-allowed tools**. +To narrow them for this profile, choose **Customize tools**, then **Selected +tools**. The same picker shows only tools within the server's allowance, plus +any saved selections that are no longer allowed so you can remove them. + +The all-tools mode includes future tools within the server's allowance; +**Selected tools** keeps the explicit names you chose, even if you selected +every currently available tool. Switching modes preserves your selection draft +while the editor is open. Refreshing discovers live metadata without changing +your selections. Missing tools remain visible and removable, and an empty +selection must be filled or switched back to all tools before saving. + +Save and start a session from that profile. Ask it to perform a small read-only +lookup against a known object, then inspect the arguments and returned result +in the transcript. + +The profile references the server ID and may narrow the server's allowlist +further for that session. In JSON, `features.mcp.servers` is a list such as +`[{ "serverId": "tracker", "tools": ["search_issues", "get_issue"] }]`; the +optional `tools` subset must fall within the record's allowlist and narrows +both up-front injection and search-on-demand. The universe record owns its +endpoint, credential, execution path, exposure, allowlist, deferral, and +approval policy. Changes to that shared record can affect every profile using +it; it is not copied into each profile as an independent connection. ## Choose how MCP executes @@ -169,8 +190,8 @@ return documents. ## Require approval for tool calls -The server's **Advanced options → Tool approval** defaults to **Never require -approval**. Choose **Always require approval** to pause proposed calls for a +The server's **Advanced options → Tool approval** (the record's `approval` +field) defaults to **Never require approval**. Choose **Always require approval** to pause proposed calls for a decision. The transcript shows each pending operation and its arguments; choose **Approve** to allow it or **Reject** to refuse it. A batch continues after all pending decisions are supplied. @@ -199,7 +220,7 @@ already completed. | Calls cannot reach a private endpoint | Check native execution, runtime network access, server egress permission, and the deployment allowlist. | | A run is waiting without another model response | Look for pending approvals and decide every call in the batch. | | A provider rejects MCP or web configuration | Check its API kind and execution mode against the compatibility rules above. | -| The server advertises a tool but calls are refused | Check the tool allowlist, credential scopes, and remote service's own permissions. | +| The server advertises a tool but calls are refused | Check the record's tool allowlist, the profile's `tools` subset for that server, credential scopes, and the remote service's own permissions. | This guide connects external tools to Lightspeed agents. To let another MCP client manage Lightspeed itself, use diff --git a/docs/documentation/using-lightspeed/workspaces-and-skills.md b/docs/documentation/using-lightspeed/workspaces-and-skills.md index e0141cbe..93ae1432 100644 --- a/docs/documentation/using-lightspeed/workspaces-and-skills.md +++ b/docs/documentation/using-lightspeed/workspaces-and-skills.md @@ -1,8 +1,8 @@ # Workspaces and skills A VFS workspace holds persistent files that agents and people can read and -edit. A session links the workspace at an absolute path, such as `/workspace`. -The link makes those files visible to the agent without attaching an operating +edit. A session attaches the workspace at an absolute path, such as `/workspace`. +The attachment makes those files visible to the agent without attaching an operating system or starting a machine. The same files can also supply instructions and reusable skills. Prompt files @@ -14,33 +14,33 @@ This guide extends the `release-notes` workspace from [Build your first agent](../getting-started/first-agent.md). Use a universe owner/admin or platform administrator account to manage workspaces and profiles. -## Link files into a session +## Attach files to a session Create or select a workspace under **Workspaces**. **New file** accepts a path relative to that workspace, and creates directories in the path as needed. Open a file, edit its contents, and choose **Save**. In a profile's **Virtual File System: Files, Instructions, Skills** section, -enabling VFS selects **Edit files** and turns on **Prompt loading** and **Skill -discovery**. Adjust these independently, then add a **Workspace link**. Existing -configurations keep their saved settings. +enabling VFS turns on **Prompt loading** and **Skill discovery** and lets you +add a **Workspace attachment**. Adjust these independently. Existing configurations +keep their saved settings. New rows attach workspaces. JSON and API configurations +can also attach immutable snapshots, which always use **Read** access. | Setting | Meaning | | --- | --- | -| **File tools → No file tools** | Supplies no model-callable VFS file operations. | -| **File tools → Read only** | Lets the agent inspect accessible files. | -| **File tools → Edit files** | Adds editing operations, subject to link permissions. | -| **Target type → Workspace** | Reads the live workspace as it changes. | -| **Target type → Snapshot** | Reads an immutable snapshot. Snapshot links are always read-only. | -| **Session path** | The absolute path where this agent sees the linked files. | -| **Access** | Whether this link permits writes to a live workspace. | - -To edit `release-notes`, use **Edit files**, a live workspace link at -`/workspace`, and **Read and write** access. A reviewer can use **Read only** -for both the tools and the link. Link paths cannot overlap, and prompt or skill -roots must fall inside a configured link. - -A profile linking the same workspace into several sessions shares its live +| **Workspace** | Selects the live workspace to attach. | +| **Session path** | The absolute path where this agent sees the attached files. | +| **Access → Read** | Lets the agent inspect the attached files. | +| **Access → Edit** | Also lets the agent write, edit, and patch files under this path. | + +There is no separate file-tool switch. Attaching any workspace installs the VFS +read tools, and an **Edit** attachment adds the write tools; the toolset follows the +union of the attachments' access, and a write under a **Read** attachment is refused. To +edit `release-notes`, attach the live workspace at `/workspace` with **Edit** +access. A reviewer attaches it with **Read**. Attachment paths cannot overlap, and +prompt or skill roots must fall inside a configured attachment. + +A profile attaching the same workspace into several sessions shares its live files. A change made by one session becomes visible to another on a subsequent file operation. A snapshot gives a reader a fixed version instead. For tasks that must produce independent artifacts, create separate workspaces or use @@ -62,7 +62,7 @@ an absent compatibility statement is not evidence of compatibility. Open the release-editor profile and enable **Prompt loading** under **Virtual File System**. Root overrides are optional: choose **Customize roots** under **Prompt loading** to edit them. Leave **VFS prompt roots** empty to use conventional -directories beneath each workspace link. Save, then create a new session from the +directories beneath each workspace attachment. Save, then create a new session from the profile or apply the updated setup to an existing idle session. Lightspeed loads all `.md` and `.txt` files directly inside each configured @@ -78,7 +78,7 @@ For example, this optional file adds a second instruction source: Use it for a short convention such as “Use sentence case for headings.” The capability is explicit: merely placing `.lightspeed` files in a workspace does not enable sourcing. With `prompts: {}`, Lightspeed searches `.agents/prompts` -and `.lightspeed/prompts` beneath each workspace link, including snapshot links. +and `.lightspeed/prompts` beneath each workspace attachment, including snapshot attachments. Optional comma-separated root overrides replace these defaults. Clearing the field restores defaults; switching off **Prompt loading** disables sourcing and removes its sourced instructions at the next reconciliation. Roots have a @@ -118,8 +118,8 @@ Ask before editing either file. workspace-relative path; the instructions use session paths under `/workspace`.* Enable **Skill discovery** under **Virtual File System** and set **VFS skill roots** in the profile to `/workspace/.lightspeed/skills`, keeping -readable file tools and the workspace link enabled. Save the profile and -start a session from it. The resulting workspace layout is: +the workspace attachment in place; any attachment grants the read tools the agent needs +to read the skill. Save the profile and start a session from it. The resulting workspace layout is: ```text release-notes/ @@ -187,19 +187,16 @@ belongs in the existing instruction mechanism. VFS skill discovery is independently opt-in through the session or profile's `features.vfs.skills` block. An empty block enables `.agents/skills` and -`.lightspeed/skills` beneath each workspace link, including snapshot links. -No links means nothing to discover. For example: +`.lightspeed/skills` beneath each workspace attachment, including snapshot attachments. +No attachments means nothing to discover. For example: ```json { "features": { "vfs": { - "workspaceLinks": [{ - "path": "/workspace", - "target": { "type": "workspace", "workspaceId": "release-notes" }, - "access": "readOnly" - }], - "tools": "readOnly", + "workspaces": [ + { "path": "/workspace", "workspaceId": "release-notes", "access": "read" } + ], "skills": {} } } @@ -208,10 +205,12 @@ No links means nothing to discover. For example: Omitting `features.vfs.skills` disables discovery and removes its runtime catalog. To replace the conventional roots, supply a nonempty `roots` list of -absolute paths inside workspace links, such as +absolute paths inside workspace attachments, such as `"skills": { "roots": ["/workspace/team-skills"] }`. An explicit empty list -and paths outside links are invalid. Workspace links, filesystem tools, prompt -sourcing, and CLI chat defaults do not enable skill discovery. +and paths outside attachments are invalid. Each entry of `workspaces` names a +`workspaceId` or an immutable `snapshotRef` at an absolute `path` with `read` +or `edit` access; snapshots must be `read`. Workspace attachments, prompt sourcing, +and CLI chat defaults do not enable skill discovery. The profile editor, new-session form, and session settings expose a **Skill discovery** switch under **Virtual File System**. It enables defaults @@ -232,18 +231,26 @@ separate identities with no cross-domain merging, deduplication, or fallback. ## Discover skills installed on a machine -In the profile editor, new-session form, or session settings, set **Working -directory** directly under **Environments**, then enable **Skill discovery** or -**Prompt loading** independently. Enable environment **Read only** file tools -if the agent should read discovered skill documents. The directory is shared by file tools, -commands, jobs, and discovery; empty uses the selected endpoint's default. +In the profile editor, new-session form, or session settings, attach the +machine under **Environments** and set that attachment's **Working +directory**, then enable **Skill discovery** or **Prompt loading** +independently. Any attachment grants the environment read tools the agent +needs to read discovered skill documents. The working directory belongs to +the attachment and is shared by file tools, commands, jobs, and discovery; +empty uses the machine's default. ```json { "features": { "environments": { - "workingDirectory": "/workspace/project", - "tools": "readOnly", + "environments": [ + { + "environmentId": "", + "default": true, + "access": "read", + "workingDirectory": "/workspace/project" + } + ], "skills": {}, "prompts": { "roots": ["./team-prompts"] } } @@ -256,7 +263,7 @@ Empty source blocks search `.agents/skills` and `.lightspeed/skills`, or directory and execution user's home. No ancestors, `.claude`, or `.codex` directories are searched automatically. Optional `roots` lists replace **all** defaults, including home. These paths name actual source directories and can -be absolute or relative to the working directory. Users may explicitly include +be absolute or relative to the active attachment's working directory. Users may explicitly include Claude/Codex directories as overrides. Empty override lists are invalid; clearing the editor field restores defaults. Omitting a source block disables it. @@ -329,18 +336,19 @@ Transfer files explicitly when a process needs them; see | Symptom | What to check | | --- | --- | -| A file exists in the browser but the agent cannot find it | Combine its workspace-relative path with the link's session path, and check the current session setup. | -| A write fails despite edit tools | Check link access and whether the target is a snapshot. Read-only links remain read-only. | -| Prompt files have no effect | Enable Prompt loading, check the default or overridden roots inside links, use direct .md or .txt files, and start the next run after the update. | +| A file exists in the browser but the agent cannot find it | Combine its workspace-relative path with the attachment's session path, and check the current session setup. | +| A write fails despite edit tools | Check attachment access and whether the target is a snapshot. Read-only attachments remain read-only. | +| Prompt files have no effect | Enable Prompt loading, check the default or overridden roots inside attachments, use direct .md or .txt files, and start the next run after the update. | | A skill is absent from the catalog | Enable Skill discovery and check the default or overridden root, direct child directory, exact `SKILL.md` name, and required frontmatter. | | A discovered skill has not affected the answer | Inspect whether the agent read it, or select it with `/skill` or `skills use`. Discovery alone loads only its catalog entry. | | Saving reports a revision conflict | Reload and reconcile with the intervening edit; do not assume the save was merged. | ## Copy files to or from a machine -Linking a VFS workspace does not put it on an execution environment. With both -VFS tools and environment access enabled, use `vfs_materialize` for a file, -subtree or whole workspace, and `vfs_capture` to save machine outputs into an -editable workspace. These tools handle binary files and executable scripts +Attaching a VFS workspace does not put it on an execution environment. With a +workspace attached and an environment attached with `edit` or higher access, use +`vfs_materialize` for a file, subtree or whole workspace; with an `edit` +workspace attachment and any environment attachment, use `vfs_capture` to save +machine outputs into that editable workspace. These tools handle binary files and executable scripts without passing their bytes through the model. See [VFS transfer](../environments/vfs-transfer.md) for replacement and retry behavior. diff --git a/docs/roadmap/archive/p125-profile-provisioned-environments.md b/docs/roadmap/archive/p125-profile-provisioned-environments.md index 20c26f02..fadced34 100644 --- a/docs/roadmap/archive/p125-profile-provisioned-environments.md +++ b/docs/roadmap/archive/p125-profile-provisioned-environments.md @@ -1,5 +1,7 @@ # P125: Profile-Provisioned Environments +> Superseded: profile provisioning and session-bound environment cleanup were removed by [workflow-owned session preparation](../p172-workflow-owned-toolset-reconciliation.md). Environments now have independent lifecycles. The material below records the former design. + **Status** - Proposed and implemented 2026-08-16 (all slices; live-validated against diff --git a/docs/roadmap/later/pNNN-environment-browser-ide.md b/docs/roadmap/later/pNNN-environment-browser-ide.md index 6f304380..4b3e02b2 100644 --- a/docs/roadmap/later/pNNN-environment-browser-ide.md +++ b/docs/roadmap/later/pNNN-environment-browser-ide.md @@ -100,7 +100,7 @@ Verified against the repository when this proposal was written: already provides filesystem operations, search, process execution, and PTYs. A full code-server integration uses its own backend for IDE operations; these existing methods continue serving agent tools. -- The [environment gateway](../../../crates/temporal-server/src/environment_gateway.rs) +- The [environment gateway](../../../crates/temporal-server/src/environments/gateway.rs) routes by universe, environment, and incarnation. Registered daemons can serve envd traffic through reverse-dialed data sockets. Those sockets carry the envd protocol today, not arbitrary application HTTP or TCP traffic. diff --git a/docs/roadmap/later/report.md b/docs/roadmap/later/report.md new file mode 100644 index 00000000..742e84db --- /dev/null +++ b/docs/roadmap/later/report.md @@ -0,0 +1,154 @@ +# Lightspeed Rust maintenance audit — 2026-09-13 + +The Rust count is materially inflated by tests: **150,726 non-test code lines and 106,960 test/test-support code lines**. The audit found a concentrated set of complex functions and several worthwhile consolidation opportunities. It did **not** establish that the repository needs a broad rewrite or that a large fraction of production code can be deleted. + +The strongest initial changes are shared live-test setup, shared daemon redaction helpers, and shared native MCP inventory policy. Large command/state-machine functions deserve targeted tests and careful decomposition, not an automatic “extract until the score passes” refactor. + +This was a read-only repository audit. No source changes were made. Static analysis used a snapshot of 521 tracked Rust files, including existing staged/unstaged changes. The Rust files still matched the snapshot after the coverage runs. Git revision and initial worktree status are recorded in `git-head.txt` and `git-status.txt`. + +## Size breakdown + +| Category | Rust code lines | Share | +|---|---:|---:| +| Non-test source | 150,726 | 58.5% | +| Inline test-only source | 62,456 | 24.2% | +| Separate test files/modules | 41,986 | 16.3% | +| `test-support` crate | 2,518 | 1.0% | +| **Total** | **257,686** | **100%** | + +“Non-test” is a source classification, not a measurement of runtime reachability or CPU usage. It includes ordinary library code, CLI/eval code, build scripts, public conformance helpers, and in-memory implementations compiled without `cfg(test)`. It includes OS-specific branches regardless of the current platform. No generated Rust files were identified by the header/path classification; macro-generated code is not expanded or counted. Generated JSON/TypeScript artifacts are outside this Rust audit. + +All 521 per-file category sums reconcile exactly with the baseline `cloc` code count. Whole-repository code count at capture was 481,009 across languages; that number includes documentation/data categories understood by cloc. + +| Crate | Non-test | Tests and support | +|---|---:|---:| +| temporal-server | 33,812 | 29,897 | +| engine | 16,344 | 10,545 | +| tools | 15,390 | 8,270 | +| cli | 12,233 | 3,872 | +| temporal-workflow | 11,875 | 5,563 | +| store-pg | 10,064 | 5,469 | +| environment-daemon | 7,470 | 3,584 | +| api | 7,096 | 3,913 | +| llm-runtime | 5,700 | 16,034 | + +The most misleading whole-file count is [engine/core/drive.rs](/Users/lukas/dev/lightspeed/crates/engine/src/core/drive.rs): 8,081 code lines become **1,968 non-test code lines** after excluding its test module. By contrast, [gateway/service/mod.rs](/Users/lukas/dev/lightspeed/crates/temporal-server/src/gateway/service/mod.rs) remains **4,365 non-test code lines**. + +Complete crate totals, the top 20 complex functions, the largest production files, and file churn are in [inventory.md](inventory.md). Raw results are in `summary.json` and `production-functions.json`. + +## Complexity and maintenance hotspots + +The current Rust parser identified **6,313 named non-test function definitions**. All matched a rust-code-analysis result. Anonymous closures are not ranked separately; complexity of nested closures is included in the containing function's aggregate. + +- Median function PLOC: 10; 95th percentile: 62; 99th percentile: 118. +- Median cognitive complexity: 0; 95th percentile: 9; 99th percentile: 20. +- 30 named functions have cognitive complexity above 25; 107 have PLOC above 100. + +These are descriptive thresholds, not pass/fail rules. PLOC is rust-code-analysis's physical instruction-line measure and does not exactly equal cloc code lines. Large enum dispatchers naturally have many branches. Review the responsibilities and invariants behind the numbers. + +| Function / location | PLOC | Cognitive | Cyclomatic | Containing-file changes in 90 days | +|---|---:|---:|---:|---:| +| [scan](/Users/lukas/dev/lightspeed/crates/environment-daemon/src/filesystem/scan.rs:17) | 289 | 191 | 115 | 2 | +| [Operation::execute](/Users/lukas/dev/lightspeed/crates/environment-daemon/src/filesystem/transfer/session.rs:720) | 349 | 134 | 114 | 1 | +| [admit_command](/Users/lukas/dev/lightspeed/crates/engine/src/core/admit.rs:15) | 820 | 130 | 164 | 15 | +| [tool_call_completed_proposals](/Users/lukas/dev/lightspeed/crates/engine/src/core/drive.rs:1638) | 327 | 107 | 56 | 35 | +| [Scanner::advance](/Users/lukas/dev/lightspeed/crates/environment-daemon/src/filesystem/transfer/session.rs:102) | 137 | 69 | 41 | 1 | +| [TransferManager::execute](/Users/lukas/dev/lightspeed/crates/environment-daemon/src/filesystem/transfer/session.rs:409) | 253 | 67 | 82 | 1 | +| [visible_mcp_result](/Users/lukas/dev/lightspeed/crates/temporal-server/src/worker/mcp.rs:962) | 170 | 48 | 31 | 6 | +| [invoke_batch](/Users/lukas/dev/lightspeed/crates/temporal-server/src/worker/session_tools.rs:1435) | 233 | 38 | 59 | 54 | +| [start_session_internal](/Users/lukas/dev/lightspeed/crates/temporal-server/src/gateway/service/mod.rs:1071) | 208 | 32 | 81 | 68 | +| [project_event_kind](/Users/lukas/dev/lightspeed/crates/api-projection/src/lib.rs:658) | 450 | 29 | 91 | 41 | + +**Maintenance priority:** gateway lifecycle handling, session tool dispatch, and core tool-result/admission handling combine substantive responsibilities with frequent containing-file changes. Filesystem scanning/transfer has the highest static complexity even though its current files have little recorded churn. + +Churn is the count of non-merge commits touching a file, including test-only changes, formatting, and initial additions. It does not mean the named function changed that many times. Rename history is not reconstructed; new/renamed paths can look artificially quiet, including the in-progress environment refactor. Uncommitted changes are in the source snapshot but are not additional commits in these counts. This is why no single blended “health score” is used. + +## Duplication results + +Scans used jscpd 5.2.0, Rust only, weak mode (comments ignored), minimum 100 tokens and 15 physical lines. Production and tests were scanned separately. + +| Scan | Reported clone matches | Tool-reported duplicated tokens | +|---|---:|---:| +| Production, exact token matches | 77 | 12,828 / 1,006,031 = 1.28% | +| Tests and support, exact token matches | 173 | 32,216 / 717,487 = 4.49% | +| Production, identifiers normalized | 453 | 77,724 / 1,006,031 = 7.73% | + +These ratios are detector statistics, **not percentages of safely removable code**. Matches can overlap, include boilerplate, and cross function boundaries. Normalizing identifiers produces useful candidates but also makes unrelated typed forwarding methods look alike. Literal values were not normalized. Semantic duplication can remain undetected. + +Masked files preserve original line positions using whitespace. Consequently, jscpd's physical-line denominator includes padding and its reported line-duplication percentages should not be used. The token ratios above avoid that particular denominator problem. Minimum-line thresholds can also be affected by internal gaps; the findings below were inspected in original source. + +### Reviewed clusters + +1. **Live-test environment/client setup — high confidence.** There are 13 `dotenv_var` definitions under `llm-runtime/tests`, including an existing shared implementation in [support/mod.rs](/Users/lukas/dev/lightspeed/crates/llm-runtime/tests/support/mod.rs:66). Exact matches between Responses caching/prompts/skills setup extend to approximately 169–170 physical lines. The copies have already diverged: [the skills copy](/Users/lukas/dev/lightspeed/crates/llm-runtime/tests/openai_responses_skills_live.rs:75) uses `split_once('=')?`, so a nonempty noncomment line without `=` ends lookup before subsequent keys. The shared helper explicitly skips that line. Some copies also strip quotes differently. This is observed control-flow divergence; no real `.env` was read to investigate it. +2. **Daemon secret-redaction helpers — high confidence.** [jobs.rs](/Users/lukas/dev/lightspeed/crates/environment-daemon/src/jobs.rs:946) and [process.rs](/Users/lukas/dev/lightspeed/crates/environment-daemon/src/process.rs:1122) repeat `redactions_for_secret_env`, `redact_bytes`, and `find_subslice`. The detector found a 32-line/246-token match. Both callers live in one crate and use the same algorithm. Sharing a private helper would make future fixes consistent without unifying process/job lifecycle logic. +3. **Native MCP inventory policy — high confidence.** [Responses](/Users/lukas/dev/lightspeed/crates/llm-runtime/src/openai_responses.rs:588), [Completions](/Users/lukas/dev/lightspeed/crates/llm-runtime/src/openai_completions.rs:777), and [Anthropic](/Users/lukas/dev/lightspeed/crates/llm-runtime/src/anthropic_messages.rs:914) repeat inventory retrieval, ordering, exposed-name filtering, omitted-name logging, and per-request cap accounting. These are shared policy operations. The construction of native provider request types afterward should remain separate. +4. **Worker universe resolution — high confidence, small extraction.** [bots.rs](/Users/lukas/dev/lightspeed/crates/temporal-server/src/worker/bots.rs:45) and [channels.rs](/Users/lukas/dev/lightspeed/crates/temporal-server/src/worker/channels.rs:41) repeat the fixed-versus-runtime universe resolver, wrong-universe rejection, and retryability mapping. The normalized detector reports a much larger 156-line cluster that also includes activity wrappers. Share the resolver, not the role-specific activity transport. +5. **ID macros — real duplication, lower priority.** The two `string_id!` definitions in [engine/session/ids.rs](/Users/lukas/dev/lightspeed/crates/engine/src/session/ids.rs:6) and [engine/core/components/ids.rs](/Users/lukas/dev/lightspeed/crates/engine/src/core/components/ids.rs:9) account for the largest exact production match, about 80 lines. A private engine-local macro could serve both. Similar macros across auth/MCP/VFS/domain crates are less compelling: adding a cross-domain dependency solely to eliminate wrappers may make the architecture worse. +6. **Prompt/skill VFS root resolution — mixed.** [prompts/vfs.rs](/Users/lukas/dev/lightspeed/crates/tools/src/prompts/vfs.rs:223) and [skills/vfs.rs](/Users/lukas/dev/lightspeed/crates/tools/src/skills/vfs.rs:222) share path-derived IDs, duplicate-ID validation, attachment selection, and snapshot/workspace inspection. However, prompt revision tracking and skill trust/scope differ. Consider sharing attachment/path primitives; do not create one generic prompt/skill subsystem just because the source looks similar. +7. **Rust API service/client forwarding — mostly mechanical.** Large identifier-normalized matches in [api/service.rs](/Users/lukas/dev/lightspeed/crates/api/src/service.rs:84) and [cli/api_client.rs](/Users/lukas/dev/lightspeed/crates/cli/src/api_client.rs:145) include regular typed method wrappers, sometimes overlapping within one file. These could be candidates for existing-manifest-driven generation in a separate design review, but are not evidence of duplicated business policy. +8. **OpenAI client configuration — plausible small shared helper.** Audio, Completions, and Responses clients repeat environment overrides and organization/project header construction. Endpoint defaults and request types differ. A private OpenAI header/config utility is more appropriate than merging clients. + +Raw browsable reports: [production clones](dup-production/jscpd-report.html), [test clones](dup-tests/jscpd-report.html). Normalized matches are in `dup-renamed/jscpd-report.json`. + +## Proposed changes, in practical order + +| Change | Concrete benefit | Risk / validation | +|---|---|---| +| Consolidate live-test dotenv/model/client setup into existing support modules | Remove large copied setup blocks and the observed parser divergence | Low–medium. Preserve each suite's variable precedence/defaults; add offline parser/client-config tests; compile live targets without running them. | +| Share daemon redaction helpers privately | One implementation for both jobs and processes | Low for extraction. Existing daemon unit tests plus explicit byte-level cases; preserve current streaming/chunk semantics. This audit does not certify the redaction algorithm against all leak cases. | +| Centralize native MCP inventory policy in llm-runtime | One place for naming, filtering, caps, and error mapping | Medium. Test ordering, invalid names, collisions, cumulative caps across servers, and unchanged native provider output. | +| Share worker universe resolution | One place for universe admission and retryability mapping | Low–medium. Test wrong fixed universe, unknown runtime universe, transient runtime failure; preserve separate role queues and activities. | +| Classify calls once and consolidate common dispatch inside `invoke_batch` | Remove duplicated denial/workflow/concurrency/control dispatch in the fast and generic paths | Medium. Preserve lazy VFS/environment setup, await paths, sibling promise accounting, ordering, and cleanup. Add path-parity tests before moving code. | +| Decompose `admit_command` by command family | Reduce the 820-PLOC function's review surface while retaining a visible exhaustive dispatcher | Medium–high. Cover uncovered rejection/idempotency paths first; preserve event order and replay behavior. Extraction may not reduce total LOC. | +| Give tool-result completion a small explicit per-batch accumulator and effect handlers | Localize promise, workflow emission, environment selection, and join handling | High. Shared state currently enforces cross-call invariants; preserve duplicate-ID detection, exclusivity across per-call resumes, and exact event ordering with replay vectors. | +| Separate filesystem scan policy/budget accounting from traversal; split transfer request handlers by operation | Make the most deeply nested I/O logic auditable | High. Preserve anchored path confinement, symlink races, quotas, retry identity, validate-before-mutate behavior, and staging/publication boundaries. Avoid a new generic filesystem framework. | +| Move gateway session lifecycle implementation into a focused module, then simplify start/retry/setup handling | Reduce navigation and review burden in the 4,365-line gateway service module | Medium. A file move alone is organizational; substantive simplification must preserve workflow start retry and managed-binding validation semantics. | + +I would start with the first two changes and measure the result. The native MCP policy extraction is the first production change likely to improve maintenance beyond a small helper. I would not start by rewriting the core state machine or imposing a global LOC/complexity gate. + +Additional inspection: `visible_mcp_result` repeats admitted/omitted media rendering for image/audio versus resource blocks, and could use one private admission/rendering helper. `project_event_kind` is large mainly because it exhaustively translates an event vocabulary; splitting by event family can aid navigation, but replacing typed mappings with generic JSON machinery would sacrifice useful compiler checks. Neither is a reason to chase code deletion for its own sake. + +## Focused coverage and tests + +Executed on the current macOS toolchain, default features, with an isolated coverage target directory: + +- `engine --lib`: **225 passed**. +- `environment-daemon --lib`: **78 passed**. +- `llm-runtime --lib`: **146 passed**. +- **449 passed total; no failed or ignored tests in these selected targets.** + +No credentialed/live integration suites were run. The daemon's local unit tests exercised local filesystem/process behavior; they did not require the development Temporal/PostgreSQL services. + +The table below combines each named function with instrumented regions inside its source span, including nested closures/async bodies. Identical source-coordinate regions across instantiations are merged and considered covered if any execution covers them. This is an audit-derived source-region metric, not branch coverage or the raw cargo-llvm-cov whole-crate summary. + +| Function | Covered source regions | Percentage | +|---|---:|---:| +| `admit_command` | 563 / 768 | 73.3% | +| `tool_call_completed_proposals` | 296 / 336 | 88.1% | +| filesystem `scan` | 400 / 471 | 84.9% | +| transfer `Operation::execute` | 416 / 496 | 83.9% | +| transfer `TransferManager::execute` | 244 / 331 | 73.7% | +| transfer `Scanner::advance` | 162 / 202 | 80.2% | +| Anthropic `materialize_tools` | 128 / 146 | 87.7% | + +Earlier progress figures of 76%/89% for engine admission/completion referred to the direct function body regions only. The final table also includes nested closures, matching the inclusive complexity ranking. + +These numbers do not include tests from all dependent crates or live suites, and unexecuted regions are not proof of dead code. They establish a useful local baseline for refactoring. They do not establish assertion quality. Test code remains visible in the stock HTML coverage reports; do not interpret their overall percentages as production-only coverage. + +[Engine coverage HTML](coverage-engine-html/html/index.html) · [Daemon/runtime coverage HTML](coverage-runtime-daemon-html/html/index.html). Raw exports and the source-span aggregation script are included. + +## Reproduction and limitations + +Tools: cloc 2.06; Rust 1.97.1; tree-sitter Python 0.25.2 with tree-sitter-rust 0.24.2; rust-code-analysis-cli 0.0.25; jscpd 5.2.0; cargo-llvm-cov 0.9.1. Tooling was installed outside the repository. The matching `llvm-tools-preview` component was installed for coverage. + +`audit.py` snapshots tracked `.rs` files, marks `cfg(test)`/test-annotated items, handles the repository's `cfg(all(test, ...))` form, follows test-only out-of-line modules, and writes complementary whitespace-masked files preserving original byte/line positions. It uses the current source tree rather than Cargo macro expansion. The current parser reported no Rust syntax errors and no unresolved test modules. There were no relevant inner `cfg(test)` or more complex conditional test expressions requiring evaluation in this source set. Future syntax/configuration patterns may require extending the classifier. + +The older parser bundled with rust-code-analysis reported five recoverable errors around `?` syntax in `crates/vfs/src/snapshot.rs`; its metrics for that file are advisory. None of the highlighted hotspots is in that file. The newer parser used for the size split parsed all files successfully. `rca-parse-errors.txt` records the older parser's warnings. + +`reproduce-static.sh` installs pinned tooling into this audit directory if needed and regenerates static data. Run it from a shell with Cargo, uv, Node/npm, and cloc available. It overwrites the generated snapshot/split/complexity subdirectories of this audit directory. It does not modify the repository. The hand-reviewed narrative in this report is specific to this snapshot and is not automatically refreshed. + +Coverage commands are recorded in `reproduce-coverage.sh`; they run only the selected unit-test targets, regenerate JSON and HTML, and write to this audit directory. They deliberately do not source `.env` or run ignored tests. These are optional separate steps from static analysis. + +This audit did not execute CPU profiling, inspect production telemetry, prove dead-code reachability, analyze every abstraction's value, or perform an exhaustive security/correctness review. Its output is a prioritized maintenance shortlist backed by measurements and source inspection. + +Tool references: [rust-code-analysis metrics](https://mozilla.github.io/rust-code-analysis/metrics.html), [jscpd](https://github.com/kucherenko/jscpd), [cargo-llvm-cov](https://github.com/taiki-e/cargo-llvm-cov). diff --git a/docs/roadmap/p172-workflow-owned-toolset-reconciliation.md b/docs/roadmap/p172-workflow-owned-toolset-reconciliation.md new file mode 100644 index 00000000..6bb137fe --- /dev/null +++ b/docs/roadmap/p172-workflow-owned-toolset-reconciliation.md @@ -0,0 +1,294 @@ +# P172 — Workflow-Owned Session Preparation + +**Status:** Implemented, including atomic preparation publication, 2026-09-12. +Covers workflow-owned session setup, +profile/configuration application, tool and context reconciliation, targeted +steering, and removal of session-bound environment provisioning. Unit and live +validation are complete, including credentialed provider calls and full-budget +timeout recovery; live suites remain opt-in. + +## Problem + +Previously, before submitting each new run, the gateway called +`configure_session_toolset()`. It rebuilt the desired tools from session +features, workflow bindings, and current MCP server records and auth grants, +then signals `PatchTools` and optionally waits before submitting `RequestRun`. +This keeps MCP exposure, allowlists, endpoint, execution, approval, and auth +settings current, but puts session execution ordering in the gateway. Run +producers that submit directly to the workflow do not share this preparation. + +This is database-backed policy reconciliation, not remote MCP `tools/list` +discovery. Unlike prompt/skill refresh, there is no equivalent workflow step +that makes the gateway call safe to simply delete. + +## Decision + +The session workflow owns tool preparation and its ordering relative to run +admission. Extract shared reconciliation logic from the gateway and execute +record/grant reads in a workflow activity. The activity returns desired tool +changes; the workflow applies them through the existing command/event path. +Keep all I/O outside the deterministic engine and workflow logic. + +The same ownership applies to setup and explicit refresh operations. The +gateway validates and translates requests, submits durable intent, and waits +for a correlated outcome. It does not orchestrate a sequence of session +mutations. Persist resolved profile/setup intent so retries finish the original +setup, even if the named profile changes or the request process exits. + +- Retain gateway request validation and early MCP link/grant validation when + saving configuration. Run submission must not separately patch and wait. +- Preserve built-in and workflow-backed tools: the current reconciler does + more than MCP. Account for missing core bindings, name collisions, removals, + and session setup/configuration callers when extracting it. +- Preserve active and queued run ordering. Reconciliation must cover new run + requests while work is already active or queued; do not reuse the + prompt/skill refresh's idle-only guard. Never change a toolset frozen for an + in-flight turn, or allow the new run to use tools before its patch applies. +- Use current session state when applying results; reject or recompute stale + results after configuration changes. Preserve submission idempotency and + surface reconciliation failures through admission outcomes. +- Observe MCP policy for each new submission. Tools remain session-wide: + publication at a safe turn boundary can affect subsequent turns of the + current run. Queued runs do not acquire independent tool snapshots or an + additional policy refresh merely by starting. A queued run must not execute + before the preparation associated with its admission has been applied. +- Preserve prompt queue acknowledgement and control responsiveness while + preparation is pending. Cancellation, steering, approvals, and effect + completion must continue to progress. Preparation errors belong to their + correlated operation; they must not fail the entire session workflow. +- Resolve duplicate run submissions before mutable policy reads. A retry of + an accepted submission remains successful after a linked server is disabled. +- Activities return desired tools, required system bindings, and a source + identity. Compute the final patch against current workflow state and discard + obsolete observations. Never put credential values in workflow history. + +## Extended scope + +### Durable session initialization and profile application + +Capture the resolved setup intent in workflow input. Opening the session is +not sufficient evidence that setup completed: initialize tools, profile +instructions, selection of an existing environment, and runtime context before reporting readiness or driving runs. Retries resume +unfinished setup rather than returning early after a workflow-start conflict. +Existing-session profile application is a correlated durable operation with +explicit revision checks and one atomic publication. Profiles may select +an existing environment or inherit the parent session’s selection. They never +create environments or bind credentials. + +Preparation builds a private candidate using deterministic command admission, +prepares runtime context against that candidate, and validates all resulting +commands before publishing the complete event batch once against the original +session head. Preparation failures and stale-source rejection leave the proposed +changes unpublished. Source invalidations are included in the candidate, avoiding +additional cleanup appends after commit. Initial setup and explicit refresh use +the same commit boundary; controls remain responsive during reads. + +The existing store append activity confirms an exact batch after a lost commit +response. Commit errors propagate as workflow errors without recording a failed +operation receipt, because an uncertain commit is not a definitive rejection. +Preparation rejections and successful commits retain their ordinary receipts. +Caller-supplied API operation IDs and audio preprocessing remain separate work. + +### Configuration and runtime context + +The workflow sequences configuration replacement with tool and context +reconciliation. Identical configuration still requests repair of derived state. +Setup, configuration/profile application, and explicit skill reads reuse the +workflow refresh path. Skill reads retain their current freshness contract by +waiting for a correlated refresh result. Gateway request validation and early +reference validation remain available, while authoritative state checks happen +at admission. Pure reads project the resulting durable state. + +Preparation retains at most 256 completed operation receipts, containing an +intent fingerprint and result rather than the full profile/configuration input. +Retries preserve their operation ID, submission timestamp, and intent. Retained +receipts return the original result; conflicting intent returns a conflict. +Eviction advances a persisted submission-time watermark and retires the whole +oldest timestamp group. Requests at or below that watermark, including delayed +first deliveries, return an explicit expired-receipt conflict without executing. +Callers must reload session state before submitting a new operation after expiry. +The cache and watermark survive workflow rollover. + +Profile preparation validates references and materializes its toolset in one +activity. The workflow publishes that observation directly instead of repeating +configuration validation and MCP reads in another preparation activity. Resolved +profiles carry their document; unused registry identity is not copied into setup. + +### Steering target + +Carry the requested run ID on steering commands and validate it against the +active run during deterministic admission. A run transition between gateway +validation and signal processing must reject the steering rather than deliver +it to another run. Include replay coverage. + +### Environment service boundary + +Extract environment lifecycle and idle-power reconciliation from the gateway +API implementation into a shared environment service. Keep existing runtime +role ownership and recovery behavior. This is not a transfer of environment +lifecycle into the session workflow. Gateway endpoints and background +reconcilers call the same service. + +Remove the unused profile-based provisioning abstraction entirely. Environments +are created, credentialed, powered, and closed independently through the +environment API. Remove profile provision intents, environment retention tied +to session closure, session-derived provision request IDs, environment session +origin records/filters, and session-deletion cleanup. Session closure or deletion +must never close an environment. Keep session environment access policy and +existing/inherited selection: those describe use, not resource ownership. +Update public contracts, persisted schema, UI, demo fixtures, tests, and current +documentation to match this boundary. + +Selection is a registry-only check shared by profiles, explicit activation, and +selection tools. It checks existence, access, and nonterminal lifecycle even +when selecting the same environment again. It never changes desired power or +probes a data route; readiness and wake-on-use remain on actual-use paths. + +## Acceptance + +- API and internal run submissions follow the same reconciliation path. +- MCP policy edits are reflected before the affected new run uses its tools; + disabled or invalid links retain their explicit failure behavior. +- Unchanged settings produce no tool patch; changed settings are applied once. +- Tests cover idle, active, and queued submissions, concurrent configuration + changes, retries/replay, and preservation of built-in/workflow tools. +- The gateway run-start path performs no toolset reconciliation or waiting. +- Setup and profile application survive request-process failure and retries; + a session cannot run with partially applied initial setup. +- Configuration outcomes cover derived tools and runtime context, including + identical-document repair, with no gateway multi-step mutation sequence. +- Explicit skill refreshes run through the workflow and retain freshness. +- Controls remain responsive during delayed or failing preparation; a later + valid operation succeeds after a correlated preparation failure. +- Steering cannot target a different run after a concurrent run transition. +- Environment lifecycle/power orchestration has a shared service owner outside + the gateway API. Closing or deleting a session leaves environments intact. +- Regenerate workflow contracts and any changed public API contracts; update + architecture and user documentation. Run focused deterministic/unit suites + and component checks. Credentialed live tests require separately confirmed + safe local services and are not part of the default validation run. + +## Environment discovery latency + +Environment skill and prompt discovery are consolidated inside the runtime +projection activity. Both sources share one registry access check, connection, handshake, +and working-directory validation per refresh, retaining their separate scoped scans, +freshness checks, publication semantics, and bounded attempts. A failed +or timed-out connection is discarded before discovering the other source. Discovery remains +read-only and never wakes an environment. Server-side phase timings cover +registry lookup, connection, initialization, directory validation, scans, +publication, and total discovery. No daemon or protocol changes, cross-run +connection pool, TTL, or filesystem watcher are needed. + +## Implementation progress + +- [x] Expand the design and acceptance criteria before implementation. +- [x] Extract tool preparation and environment reconciliation services. +- [x] Add workflow-owned run preparation and correlated operation outcomes. +- [x] Make setup and profile/configuration application durable. +- [x] Route explicit context refresh through the workflow. +- [x] Admit steering against its requested run ID. +- [x] Remove all profile provisioning and session-bound environment lifecycle. +- [x] Complete regression coverage, generated contracts, and documentation. +- [x] Bound preparation receipts, consolidate profile preparation, remove the + unused profile wrapper, and move reconciliation tests beside the shared diff. +- [x] Separate registry-only selection from use-time readiness and wake-up. +- [x] Prepare the complete profile/configuration candidate before atomic publication. +- [x] Share environment discovery setup across skills and instructions and record phase timings. + +Audio preprocessing is intentionally unchanged; its admission behavior will be +handled in the planned audio refactor. + +## Validation and rollout + +### Live validation, 2026-09-12 + +With the local services and credentialed tests explicitly authorized, applied +schema migration 10 and ran all ordinary Temporal live suites serially. All +67 tests passed, including OpenAI Responses/Completions and Anthropic tool calls, +profile setup/application, cancellation/steering/queueing, workflow rollover, +MCP/OAuth, sub-agents, workflow tools, tenancy, bots, channels, preprocessing, +environment registration/power, and hosted VFS transfers. The registered-envd +transfer fixture now enables environment skills and prompts together and verifies +both reach the model. The environment-power fixture's obsolete wake-on-selection +assertion was corrected; selection remains paused and actual job use requests wake-up. + +All 37 PostgreSQL live tests also passed: the migration ledger, session lifecycle, +and store suites cover atomic event appends, profile records, environment and +credential persistence, CAS, and independent environment lifecycles. + +The first profile-list attempt encountered an old provisioning profile in the +shared development universe. Subsequent session tests used a fresh test universe, +preserving existing development records. The upgrade requirement below records +this incompatibility. The full-budget timeout-recovery test also passed, running +alone for 1,267 seconds: Temporal retried the stalled activity, exhausted its +21-minute budget, failed the run, and completed the next run on the same session +workflow execution. Total: 105 passing live tests (68 runtime, 37 storage). + +### Earlier validation and deployment requirements + +- Shared environment discovery: 331 server unit tests passed (one ignored), + along with `cargo check -p temporal-server --all-targets`. + Regression coverage verifies one connection/initialization/directory check for + two scans, fresh edits, disabled and controller-owned sources, unchanged skill + observations, independent failure publication, and reconnection after a scan + timeout. Real-environment latency has not been benchmarked; phase timings + expose the remaining cost without changing envd or the protocol. + +This is a coordinated breaking deployment requiring fresh sessions and workflow +histories. Close existing sessions with the old runtime, stop old workers, apply +schema migration 10, and deploy matching runtime and clients before creating new +sessions. Retain stored sessions for historical reads and retain environment +resources; no database wipe is required. Restarting workers alone still replays +old histories and is not a migration strategy. Remove or convert saved profiles +that still contain the removed `environment.type: provision` variant before +reading/listing profiles with the new runtime; schema migration 10 removes the +session-origin columns but does not rewrite stored profile documents. + +Preparation patch gates, legacy continuation readiness/receipt defaults, and +untargeted steering replay have been removed. Existing running histories and +old continuation payloads are unsupported; new continuations explicitly carry +readiness and operation receipts, and steering always names its target run. + + +- Compatibility cleanup validation passed: 222 engine and 133 workflow unit + tests, plus `cargo check --workspace --all-targets`. The workflow contract + exporter produced no artifact changes. Live histories were not replayed. +- Rust tests passed for `engine`, `temporal-workflow`, `temporal-server`, + `profiles`, `environments`, `store-pg`, and `api`, including engine replay + and committed contract checks. Final workflow/runtime unit reruns passed. +- `cargo check --workspace --all-targets` passed. Live suites, including the + replacement environment-selection/independent-cleanup regression, compile; + credentialed and service-dependent tests were not run. +- TypeScript typechecks, the full consumer test command, and production + client/configurator/web/demo builds passed. The web suite has 328 tests, + including start/close/delete preserving the selected environment. +- API and workflow contracts were regenerated. TypeScript consumers and the + configuration reference were regenerated and verified stable across a + second generation. `npm run check` reaches its Git-based generated-file + check and reports the intentional uncommitted contract changes; its remaining + typecheck, consumer-test, and build steps passed separately. +- Schema revision 10 removes environment session-origin columns and indexes. + It preserves existing environments. Release metadata verification passed. + The migration is included but was not applied to local services. +- Cleanup validation passed: 129 workflow and 328 runtime unit tests (one + ignored), plus `cargo check --workspace --all-targets`. Receipt tests cover + duplicate/conflicting intent, bounded eviction, delayed/expired requests, + timestamp ties, rollover, and omission of large profile inputs. The workflow + contract exporter completed with no additional artifact changes. +- Registry-only selection validation passed: 330 runtime and 129 workflow unit + tests (one ignored). New regressions cover all nonterminal statuses without a + gateway, unchanged power state, and fresh access/lifecycle checks on reselection; + existing wake-on-use tests remain green. +- Atomic publication validation passed: 133 workflow and 331 runtime unit tests + (one ignored), plus `cargo check --workspace --all-targets`. New coverage checks + proposed sources, late validation failure without live mutation, concurrent + closure/configuration changes, source invalidation inside the batch, full-batch + replay, and storage confirmation after a lost commit response. The workflow + contract was regenerated without additional artifact changes. Live Temporal + tests were not run. This change needs no new schema migration or public API. + +Environment creation, credentials, power, and cleanup now belong exclusively +to environment operations and their runtime service. Profiles retain only +existing-environment selection and inheritance. No session close/delete path +or session activity calls the environment lifecycle service. diff --git a/docs/roadmap/p173-session-config-attachments.md b/docs/roadmap/p173-session-config-attachments.md new file mode 100644 index 00000000..cddc5567 --- /dev/null +++ b/docs/roadmap/p173-session-config-attachments.md @@ -0,0 +1,390 @@ +# P173 — Session config attachments + +**Status:** Implemented across Rust, web consumers, Configurator MCP, and demo +fixtures, 2026-09-13. Follows the workflow-owned preparation work in +[P172](p172-workflow-owned-toolset-reconciliation.md). Greenfield: wire shapes, +engine config types, and stored `ConfigChanged` payloads change in place; +sessions are reset and contracts regenerated. No compatibility aliases. + +## Problem + +The session config mixes three statements without one grammar: + +- **Which resource** is attached: `vfs.workspaceLinks`, `mcp.servers`, + `subagents.agents`, and for environments only the indirect filters + `providers` and `registrationKeys`. +- **What the session may do with it**: per-link `access` for VFS, but only + session-wide `tools`, `commands`, `jobs` for environments, and nothing at + all for MCP. +- **Which tool surface is presented**: `vfs.tools`, `environments.tools`, + `selectionTools`. This is where the duplication lives. A workspace link says + `readWrite` and the feature separately says `edit`; an environment feature + says `edit` for every machine the session might ever select. + +Two concrete limits follow. A session cannot hold two environments with +different access, because the grant is session-wide. And the environment +allowed set is expressed as registry filters rather than as the machines +themselves, which is the only place the config talks about an operator +concept (registration keys) instead of a resource. + +## What the research showed + +- The asymmetry between environments and everything else is smaller than it + looks. Environment *policy* already lives inside the config. What lives + outside is the *active* pointer, and that is runtime state, not config. + VFS is a namespace (many links at once, disjoint mount points, no fusing), + an environment is a cursor (one active at a time). Both want an allowed set + with per-member access in config; only the cursor is state. +- [P95](archive/p95-config-redesign.md) originally kept attachments as + imperative records outside the config and + [P107](archive/p107-session-workspace-links.md) reversed that, because a + profile is precisely the document that says "this agent works on workspace + X with server Y". Lifting attachments out again would need a second + document type in profiles and split full-document put across two documents. + Not revisited. +- MCP record fields fall into three groups. Identity and connection (url, + label, auth, credential, private network, status) are never per session. + Transport and presentation (`execution`, `exposure`, `deferLoading`) say + how tools reach the model, not what a session may do. Only `allowedTools` + and `approval` have a per-attachment analogue. The allowlist already narrows + the inventory under both exposures, so a session-level subset is the same + filter applied once more; search over a narrowed set is pointless but not + contradictory. +- The current `EnvironmentAccessPolicy` (providers plus registration keys) is + threaded through the resolver's `selectable` check, the durable job + execution context, the selection tools, and the listing's key display-name + grouping. Replacing it with list membership removes all of that. +- The VFS link validator rejects equal paths, `/` beside any other link, and + prefix relations such as `/data` with `/data/one`. Siblings are fine and + appear under a synthetic parent. One working directory per VFS domain is + therefore still right; environments need one per machine. +- Read-only environment file tools do not restrict commands, which can write + files anyway. That grant combination was a tool-surface preference, not a + security boundary, and can be folded into an ordered ladder. + +## Decision + +Every resource-backed feature block is **domain-wide settings plus a list of +attachments**. An attachment is a reference into a universe catalog plus a +domain-specific `access` grant. The presented tool surface is derived from the +union of grants. Nothing else in the config changes shape. + +| Feature | Attachment | `access` | +| --- | --- | --- | +| `vfs.workspaces` | `workspaceId` or `snapshotRef` at `path` | `read`, `edit` | +| `environments.environments` | `environmentId` or `inherit` | `read`, `edit`, `exec`, `jobs` | +| `mcp.servers` | `serverId` | optional `tools` subset of the record allowlist | +| `subagents.agents` | `profileId` | none (limits are block-wide and attenuate) | + +Ladders are ordered: `edit` implies `read`, `exec` implies `edit`, `jobs` +implies `exec`. Lists are always arrays on the wire; the editor hides that. + +### Environments + +- The list is the allowed set. Registry filters, registration-key scoping, + and provider scoping are gone from session config. Selection checks + membership and a nonterminal record; readiness and wake-on-use stay on + actual-use paths. +- Each item carries its own `workingDirectory`, falling back to the machine's + advertised default. Prompt and skill roots stay domain-wide; relative roots + resolve against each machine's working directory. +- At most one item carries `default: true`. The default **fills an empty + active pointer at profile application and never overrides a live + selection**. Creation is the trivial empty case. Applying a profile to an + existing session first drops an active environment that is no longer + listed, then activates the default if nothing is active. If the model or + an API call switched to another listed machine, a profile edit that only + moves the default does nothing. No default means nothing is activated. + Plain `session/config/put` never fills the pointer, so a deliberate + deactivation is not undone by a config edit. This replaces + `ProfileDocument.environment: existing`; bot exec polls without an explicit + environment resolve the profile's default item instead of the profile + intent. Polls and the bot session can therefore diverge: if A and B stay + listed and the default moves from A to B, the session keeps A while polls + run on B. That is the price of never overriding a live selection and is + documented rather than reconciled; a poll that must share the session's + machine names it with `environmentId`. + Preparation validates the default's registry state only when it will fill + the pointer; an unavailable unused default does not block profile updates. +- `inherit: true` is an item kind for sub-agent profiles: the parent's active + environment at spawn, with the item's own grant. At most one per list. + Spawning has two stages: the batch executor admits the call and writes a + `SubagentExecutionContextV1`, and a later preparation activity reads that + context and resolves the profile. The executor has the parent's active + environment on its batch request; the context does not carry it today. + Add `parentActiveEnvironmentId` (nullable) to the context, captured at + admission, bump its version, and resolve `inherit` from that field in the + preparation activity. Retries reuse the captured value. The checkpoint + reread of the parent during child setup is deleted, and a session config + never contains `inherit`. If the resolved id already + appears as an explicit item, the explicit item wins and the inherit item is + dropped. If the parent has no active environment, the inherit item is + dropped; if it carried `default`, nothing is activated. +- Tools that accept an explicit environment id (`environment_read`, + `environment_activate`, and job handles on job read and cancel) require + the id to be a listed attachment, checked like selection. Job handles keep + their id so a job started on one machine can still be read after + switching. +- `selection: true` (renamed from `selectionTools`) exposes list, activate, + and deactivate tools over the list. Harmless with one item. +- Session creation has no separate environment override, in the web, + `session/start`, or `session/start-managed`. The effective configuration + supplies its default attachment; without one, no environment is activated. + Customizing creation means changing the attachment list or its default. +- A config put that removes the active environment clears the pointer in the + same deterministic command. Put already requires an idle session. +- Runtime policy becomes a lookup: the batch carries the active id, and the + engine derives files, exec, jobs, and working directory from that item. +- **Tool visibility is the union of all items' grants**, installed once. + A call the active machine's grant does not cover is rejected at execution + with a message naming the active machine's access. The existing denial + path covers file and process operations only; durable job submit and run + are workflow tools whose binding was previously installed only when `jobs` + was granted. With union visibility that binding exists whenever any item + grants `jobs`, so the batch executor must check the active item's grant + before building the job execution context and emitting the workflow + invocation. The toolset does not change on a switch: a session that moves + between machines often must not invalidate the provider prompt cache each + time, and switches happen inside a turn where a patch could not be + published anyway. +- A new `runtime.catalog.environments` context document lists every + attachment with id, display name, its access ladder ("access: read, edit, + exec"), and which one is active. It is built from config and display names + with no discovery, in the same projection step as the sub-agent menu, and + shares the `Catalog { title }` shape. `environment_list` and + `environment_read` results carry the same access line. + The catalog records its observed selection. Activation, switching, + deactivation, or removal of the feature invalidates a stale catalog before + another turn can use it; the next idle runtime projection rebuilds it. + +### VFS + +- `workspaceLinks` becomes `workspaces`; `access` is `read` or `edit`; + snapshot links must be `read`. `vfs.tools` is removed. Any link grants read + tools, any `edit` link grants edit tools, and transfer tools appear when the + environments feature is granted with matching access on both sides. +- Sourcing prompts or skills from a workspace without file tools is no longer + expressible. Accepted loss. + +### MCP + +- Server items are named `McpServerAttachment` in Rust and generated consumers. + An empty `servers` list is valid and grants no MCP tools. Enabling the feature + in the editor starts empty; servers must be added explicitly, and all server + attachments must be removed before disabling the feature. +- Items become `{ serverId, tools? }`. `tools` must be a nonempty subset of the + record's allowlist and narrows both injection and search. Execution, + exposure, deferral, approval, and auth remain on the record. +- Rename `approvalDefault` and `deferLoadingDefault` on the record to + `approval` and `deferLoading`. The suffix promised per-link overrides this + design does not add. + +## Example + +Root profile config: + +```json +{ + "model": { "providerId": "anthropic", "modelId": "claude-opus-5" }, + "generation": { "reasoningEffort": "high", "parallelToolUse": true }, + "limits": { "maxTurns": 200 }, + "features": { + "vfs": { + "workingDirectory": "/workspace", + "prompts": {}, + "skills": { "roots": ["/workspace/.agents/skills", "/team-skills"] }, + "workspaces": [ + { "path": "/workspace", "workspaceId": "ws_acorn", "access": "edit" }, + { "path": "/team-skills", "workspaceId": "ws_shared_skills", "access": "read" }, + { "path": "/ref/v1", "snapshotRef": "sha256:9f2c…", "access": "read" } + ] + }, + "web": { "fetch": {}, "search": { "allowedDomains": ["docs.rs"] } }, + "environments": { + "selection": true, + "prompts": {}, + "skills": { "roots": ["./.agents/skills"] }, + "environments": [ + { "environmentId": "env_ci_runner", "default": true, "access": "jobs", "workingDirectory": "/srv/acorn" }, + { "environmentId": "env_prod_readonly", "access": "read", "workingDirectory": "/var/log/acorn" } + ] + }, + "mcp": { + "servers": [ + { "serverId": "github" }, + { "serverId": "notion", "tools": ["search", "fetch_page"] } + ] + }, + "subagents": { + "maxDepth": 2, "maxConcurrent": 4, + "agents": [ { "profileId": "reviewer" }, { "profileId": "log-analyst" } ] + }, + "timers": {} + } +} +``` + +This yields VFS read and edit tools, transfer tools, prompts under all three +links, skills from the two explicit roots, the full environment tool surface +(union of `jobs` and `read`), `env_ci_runner` active at creation with +processes and durable jobs, edit and process calls rejected while +`env_prod_readonly` is active, an environment catalog naming both machines +with their access, the full `github` allowlist, two `notion` tools, and the +two-profile agent menu. + +The `reviewer` sub-agent profile config: + +```json +{ + "model": { "providerId": "anthropic", "modelId": "claude-sonnet-5" }, + "limits": { "maxTurns": 40 }, + "features": { + "vfs": { + "workingDirectory": "/workspace", + "prompts": {}, + "workspaces": [ { "path": "/workspace", "workspaceId": "ws_acorn", "access": "read" } ] + }, + "environments": { + "environments": [ { "inherit": true, "default": true, "access": "exec" } ] + }, + "mcp": { + "servers": [ { "serverId": "github", "tools": ["get_pull_request", "list_pull_request_files"] } ] + }, + "subagents": { "maxDepth": 1, "maxConcurrent": 2, "agents": [ { "profileId": "log-analyst" } ] } + } +} +``` + +Spawned from the root above, the parent's spawn activity stores the child +config with `env_ci_runner` in place of `inherit`; setup activates it and +grants processes but not durable jobs. There is no `selection`, so the child +cannot switch. + +## Dropped + +Never re-propose without new evidence: + +- `environments.providers`, `environments.registrationKeys`, registry-scan + listing, and `EnvironmentAccessPolicy` with its resolver, job-context, and + selection-tool plumbing. +- `environments.tools`, `environments.commands`, `environments.jobs`, + `vfs.tools`, `selectionTools`. +- `ProfileDocument.environment`. +- Object-or-array sugar on attachment lists. It costs `anyOf` schemas, + `T | T[]` client types, and canonicalization for the identical-put no-op, + and reads would not return what was written. +- Per-session overrides of MCP execution, exposure, deferral, or approval. +- Active-environment tool visibility with toolset patches on switch. +- Defaults that override a live selection on profile application. +- Lifting attachments out of the config into imperative records (the pre-P107 + shape), and feature-level tool caps layered over per-item grants. +- Sourcing-only VFS links. + +## Implementation + +- [x] Consolidate hosted environment lifecycle, gateway, resolver, runtime, and + source discovery modules under `crates/temporal-server/src/environments/`. + +- [x] Engine config types and validation: attachment lists, ladders, uniqueness, + one `default`, one `inherit` (profiles only), snapshot links read-only. + Config replacement clears an active environment that is no longer listed. + Replay vectors for the new `ConfigChanged` payloads. +- [x] Runtime policy: per-batch environment policy derived from the active item; + tool reconciler derives VFS, environment, and transfer surfaces from grant + unions; denial messages name the active machine's access; durable job + tools gate on the active item's `jobs` grant before invocation; + explicit-id tools check membership; MCP materialization applies the item subset; + environment catalog projection. +- [x] Setup and selection: membership-only `selectable`, fill-if-empty default + at creation and profile application, `parentActiveEnvironmentId` on the + sub-agent execution context with inherit resolved from it in preparation + (parent checkpoint reread deleted), bot fire path reads the + default item, start override validates membership. +- [x] API: DTO renames, MCP record field renames, contract export, TypeScript + clients, Configurator reference data. +- [x] Editor: attachment rows with an access picker per row; environment rows + carry default and working directory; MCP rows carry an optional tool + subset loaded from discovery. +- [x] Editor refinements: environments precede VFS, with attachment controls + at the top of both sections. Environment working directory overrides are + collapsed, and the access note lists what the selected level includes. New + environments start with jobs access. Adding the first marks it as default; + adding a second or later environment enables selection tools. Removing the + default promotes the next row, or the previous row when removing the last. + New VFS rows attach workspaces without a target-type picker; + snapshots supplied through JSON or the API remain supported. +- [x] Shared MCP picker: server allowances and session subsets use the same + searchable inventory with visible descriptions, annotations, and structured + discovery errors. Discovery observations are temporary and scoped to the + universe, server, and revision; unsaved connection changes prevent loading. + The server editor always shows tool selection; profile and session configs + use an inline Customize toggle with the current selection beside it. + Selection drafts survive mode switches, unavailable selections stay removable, + and discovery never rewrites explicit tool names. Attachment remove controls + sit at the top of each MCP, workspace, and environment row. +- [x] MCP attachment lifecycle: enabling starts with an empty list, adding a + server is explicit, and disabling requires removing every attachment. Rust + validation and lifecycle replay accept the empty list; API contracts preserve + it and use `McpServerAttachment` consistently with other attachment types. +- [x] Attachment terminology: workspace and environment DTOs, VFS adapters, + prompt and skill source types, config helpers, and editor copy use attachment + names. Stored source reports retain their existing JSON names. +- [x] Web consumers: profile and bot forms use config attachments; session + activation offers only attached environments. Session creation uses the + configured default without a separate override in the web or APIs. MCP + forms and gateway mappings use the renamed record fields. Demo profiles + use the new shapes, reject unlisted activation, and clear removed active + selections without filling a default on config put. +- [x] Docs: tools-and-mcp, workspaces-and-skills, profiles-and-instructions, + environments overview, CLI help. + +## Acceptance + +- Two environments with different grants in one session; the batch policy + follows the active one, including working directory. +- Config put removing the active environment clears it and does not fill + the default; profile application fills an empty pointer with the default + and leaves a live listed selection alone; after a bot profile swaps its + default from A to B with both listed, the session stays on A and polls run + on B; put with an unlisted `default` or duplicate ids is rejected. +- Sub-agent spawn captures the parent's active environment (or its absence) + on the execution context at admission and resolves `inherit` from it + (explicit item wins, absent parent environment drops the item), storing a + concrete config; a parent switch after admission or an activity retry does + not change the child. +- Durable job submit and run are rejected before any workflow invocation + while the active item grants only `read`, `edit`, or `exec`, and succeed + under `jobs`. +- Derived tool surfaces match the grant union for every VFS and environment + combination, including transfer tools; the toolset is unchanged across + switches, and calls outside the active grant are denied with the access + named. Explicit ids on read, activate, and job handles are rejected when + unlisted. +- The environment catalog lists every attachment with its access and marks + the active one; list and read results carry the same line. +- MCP subset narrows both inject and search exposure; a subset outside the + record allowlist is rejected at put. +- No session config, profile validation, or runtime path reads providers or + registration keys. +- Review regressions cover MCP subset materialization for provider injection, + native injection, and native search; invalid environment IDs returning + errors; skipping unused default validation; and catalog invalidation, + stale-publication rejection, and replay across selection changes. +- Contracts regenerated; engine, tools, temporal-workflow, temporal-server, + profiles, api, and platform checks green. + +Review fixes passed 933 unit tests across engine, temporal-server, +temporal-workflow, and tools (one ignored), plus +`cargo check --workspace --all-targets`. + +The authorized live run passed 67 ordinary Temporal tests, 43 PostgreSQL +tests, and four OpenAI/Anthropic prompt and skill tests. The Temporal and +PostgreSQL runs preceded the final Rust module reorganization; the provider +tests completed afterward. The VFS sourcing fixture now uses read access on +both attachments. MCP fixtures needed loopback access enabled for the test +process. The separate long-running timeout suite was not rerun. + +Web consumer validation passed `npm run check`, including 333 web tests, +TypeScript checks, generated-artifact checks, and live/demo production builds. +Browser verification covered environment attachment rows and MCP discovery, +selection, and the resulting config document. diff --git a/docs/roadmap/p174-gateway-session-lifecycle.md b/docs/roadmap/p174-gateway-session-lifecycle.md new file mode 100644 index 00000000..c6a44194 --- /dev/null +++ b/docs/roadmap/p174-gateway-session-lifecycle.md @@ -0,0 +1,75 @@ +# Gateway session lifecycle cleanup + +Implemented 2026-09-13. + +## Implementation + +- [x] Group session creation wrappers, argument construction, start recovery, + readiness waiting, managed-creation validation, and preparation operations in + `gateway/service/session_lifecycle.rs`. Public `AgentApi` methods remain thin; + general workflow interaction stays in `workflow.rs`, profile resolution/CRUD + in `profiles.rs`, and activity materialization in `session_preparation.rs`. +- [x] Return `LoadedSession` after readiness. The start response and managed + fingerprint check reuse that state. Start no longer builds a discarded full + session view, looks up its retention root, or reloads the same state for the + response. Full-view projection errors consequently no longer block a start + response that does not require that projection. +- [x] Share retry completion for stored open sessions, running workflows whose + session row is not ready, and concurrent-start conflicts. Closed sessions + return their already-loaded state without signaling or waiting. +- [x] Retain the admitted managed declaration for materialization and creation + fingerprint comparison within the gateway start request. Original workflow + arguments and independent workflow/engine admission remain unchanged. +- [x] Share workspace-target validation on `SessionPreparationService`, with + gateway preflight delegating to it. Move the preparation-service factory + beside that service. Keep both API-time and activity-time validation. + +Known stored sessions validate a supplied managed declaration before signaling +setup retry. A running workflow without ready durable state validates after +setup completes. Recovery still precedes resolution of mutable named profiles. +Blob-grace refresh still precedes the Temporal start; sub-agent retention, +setup intent, and metadata validation order remain unchanged. + +Sub-agent profile validation retains its existing boundary-specific error +mapping. Environment checks at creation and preparation retain their distinct +work and timing. This refactor does not merge those policies. + +## Validation + +Seven new offline tests use a private four-operation I/O interface around the +actual recovery and readiness code. Ordered scripts fail on unexpected or +missing loads, describes, retry signals, and status queries. They cover: + +- Matching/conflicting managed declarations against closed/open sessions, + including validation before any retry signal. +- A running workflow with a missing or new session row, readiness polling, + validation after setup, and returning recovered state before fresh creation. +- Missing/new sessions without a running workflow proceeding to creation. +- Concurrent-start conflict recovery and propagation of a missing-row error. +- Setup/workflow errors taking precedence over readiness, query errors, + successful readiness followed by exactly one state load, and timeout. +- Load, describe, and retry-signal errors terminating further I/O. + +The existing fingerprint-matching test moved beside its implementation. + +- `cargo check -p temporal-server --lib` passed. +- `cargo test -p temporal-server --lib gateway::service::`: 128 tests passed. +- `cargo test -p temporal-server --lib`: 344 tests passed; one existing test + remained ignored. +- The eight lifecycle tests passed again after a test-fixture lint cleanup. +- `cargo clippy -p temporal-server --lib --tests --no-deps` completed with four + pre-existing diagnostics and no new warnings. Moving the lifecycle methods + removed the previous test-module-ordering warning in `workflow.rs`. +- Changed-file formatting and `git diff --check` passed. + +No live or credentialed suites ran. The offline recovery tests do not replace +end-to-end Temporal/PostgreSQL coverage of first creation or profile mutation +between requests. The existing live suites remain available under the +repository's local-service confirmation requirement. + +## Preserved race behavior + +After a concurrent start returns a conflict, the gateway still immediately +loads the session row. A missing row remains an error in that branch. Extending +it to the earlier describe/recovery path would be a separate behavior change; +this refactor explicitly preserves and tests the existing result. diff --git a/docs/roadmap/p175-rust-maintenance.md b/docs/roadmap/p175-rust-maintenance.md new file mode 100644 index 00000000..a4619b46 --- /dev/null +++ b/docs/roadmap/p175-rust-maintenance.md @@ -0,0 +1,125 @@ +# Rust maintenance audit follow-up + +## Scope + +The source audit found repeated live-test setup, daemon byte-redaction helpers, +native MCP injection policy, worker universe resolution, batch dispatch, and +engine string-ID definitions. Consolidate those within their existing +components before attempting larger command/state-machine refactors. + +## Implementation + +- [x] Move live provider configuration and dotenv lookup into + `crates/llm-runtime/tests/support/config.rs`. Responses and Anthropic suites + share client/model defaults; suite-specific model overrides and Anthropic MCP + beta headers remain explicit. All suites now skip malformed dotenv lines and + remove only matching outer quotes, using the existing shared-parser behavior. +- [x] Share byte-redaction and secret-value collection privately within + `environment-daemon`. Preserve the current per-buffer algorithm and process/job + lifecycle behavior. +- [x] Share native MCP inventory retrieval, ordering, exposed-name filtering, + error mapping, and cumulative request-cap accounting in `llm-runtime::mcp`. + Provider adapters still own native request construction and catalog collision + checks; search/provider-hosted exposure is unchanged. +- [x] Add offline tests for configuration precedence and dotenv edge cases, + redaction of binary/repeated output, and MCP filtering, cumulative caps, + resolver errors, and duplicate-name retention. + +## Validation + +Completed 2026-09-13: + +- `cargo test -p llm-runtime --lib --test live_config`: 150 runtime tests and + seven offline configuration tests passed. +- `cargo test -p environment-daemon --lib`: 80 tests passed. +- `cargo test -p llm-runtime --tests --no-run`: all integration/live targets + compiled without running credentialed suites. +- `cargo clippy -p llm-runtime -p environment-daemon --all-targets --no-deps -- -D warnings` + passed, as did formatting checks for changed Rust files and `git diff --check`. + +Larger changes to admission, tool-result handling, and filesystem traversal +remain follow-up work; they need targeted behavioral/replay coverage before +restructuring. + +## Worker dispatch and engine ID follow-up + +- [x] Share fixed/runtime universe resolution between bot and channel activity + adapters. Wrong or unknown universes remain non-retryable; runtime lookup + failures remain retryable. Role-specific activity registration stays separate. +- [x] Classify each ordinary batch call once and use one dispatch loop for + workflow, concurrency, environment-control, job-read, and inline calls. + Preserve the special-only path's skipped domain setup, denial precedence, + await handling, input order, shared promise numbering, workflow sibling caps, + and environment cleanup on success or an ordinary error. +- [x] Share the engine's identical string-ID macro privately between session and + core IDs, retaining validators, serialization, and optional schema derives. + Numeric-ID macros retain their differing default behavior. +- [x] Add offline checks for universe mismatch/retryability and for equivalent + special-call dispatch with and without an inline VFS sibling, including + failed workflow validation and cumulative emission caps. + +Validation on 2026-09-13: + +- `cargo test -p engine --features contract`: 225 tests passed, including ID + validation/serialization and replay coverage with schema derives enabled. +- `cargo test -p temporal-server --lib worker::`: 97 tests passed; one existing + test remained ignored. No live or credentialed suites ran. +- `cargo test -p api --test schema_artifacts -p temporal-workflow --test workflow_contract`: + all ten tests passed, including the committed-artifact staleness gates. +- Formatting checks for changed Rust files and `git diff --check` passed. +- Strict Clippy (`--lib --tests --features engine/contract --no-deps -- -D warnings` + for `engine` and `temporal-server`) found five pre-existing diagnostics in + untouched code: a blank line after a doc comment, two collapsible conditionals, + and two test-module ordering warnings. These are outside this refactor. The + same command without `-D warnings` completed with only those diagnostics. + +## MCP rendering and OpenAI configuration follow-up + +- [x] Share binary MCP result admission, result-wide media caps, MIME + normalization, omission notes, and media record construction in a private + renderer. Preserve per-kind numbering, asset indices, resource names, audio + rejection, and validation precedence when the shared cap is already full. +- [x] Share OpenAI base-URL/organization/project environment overrides and + organization/project header validation privately within `llm-clients`. + Preserve empty-value overrides, field-specific errors, endpoint defaults, + request timeouts, and each client's JSON or multipart content type. +- [x] Add a mixed image/resource cap regression, offline configuration tests + using an injected environment lookup, and local HTTP checks for all three + clients' outgoing headers and endpoint paths. + +Validation on 2026-09-13: + +- `cargo test -p llm-clients --lib --test openai_endpoint_override`: 42 unit + tests and six local HTTP/endpoint tests passed. +- `cargo test -p temporal-server --lib worker::mcp::`: all 15 MCP tests passed. +- `cargo clippy -p llm-clients --all-targets --no-deps -- -D warnings` passed. +- `cargo clippy -p temporal-server --lib --tests --no-deps` completed with only + the same five pre-existing diagnostics recorded above. +- Formatting checks for changed Rust files and `git diff --check` passed. + No live or credentialed suites ran. + +## Targeted command-admission cleanup + +Completed 2026-09-13: + +- Reuse the already-validated active run in approval decisions, removing an + unreachable missing-run branch and a redundant run-ID comparison. Preserve + the remaining validation order and keep the exhaustive command match. +- Share pending completion-promise failure proposals between workflow-tool + delivery and start failures. Preserve completion-key order, missing/terminal + promise handling, and each command's leading event and retry checks. +- Add a regression for mixed pending, missing, and terminal promises with + completion-key order different from promise-ID order. +- `cargo test -p engine --features contract`: 226 tests passed, including + existing replay and workflow-tool failure/idempotency coverage. +- `cargo clippy -p engine --all-targets --features contract --no-deps -- -D warnings` + passed, as did changed-file formatting and `git diff --check`. + +## Gateway session lifecycle cleanup + +The [gateway lifecycle record](gateway-session-lifecycle.md) describes the +completed extraction, removal of discarded full-view projection and repeated +state loads, shared retry completion, retained declaration admission, and shared +workspace validation. Seven new offline tests exercise recovery and readiness; +the complete server library suite passed with 344 tests and one existing +ignored test. Live suites were not run. diff --git a/platform/README.md b/platform/README.md index edf26d8b..5f2d5b17 100644 --- a/platform/README.md +++ b/platform/README.md @@ -37,6 +37,15 @@ for each model. Transcripts preserve each call's original `toolName` alongside its optional admitted `toolId`; the UI does not resolve historical names again. Demo tool fixtures record these identities explicitly as well. +Session and profile editors configure workspace and environment attachments +with access per resource. Environment rows also carry a default selection and +working directory; saved profiles can inherit a parent environment for +sub-agents. MCP server allowances and session subsets share a searchable +tool picker with live descriptions and connection errors. Session subsets +can only narrow the server's allowance. Session activation controls offer attached environments +and remain separate from profile defaults. Session creation uses the default +environment attachment in the effective config, without a separate override. + Context views and run outputs share a content descriptor. Assistant messages, reasoning, and audio transcripts can reference JSON; API views include their full projected text. Detailed run reads include `output` and `outputText` even diff --git a/platform/configurator-mcp/src/generated/tools.ts b/platform/configurator-mcp/src/generated/tools.ts index bdff3205..7f060199 100644 --- a/platform/configurator-mcp/src/generated/tools.ts +++ b/platform/configurator-mcp/src/generated/tools.ts @@ -9,7 +9,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "name": "lightspeed_session_start", "method": "session/start", "summary": "Create or reopen a session", - "description": "Creates a session with optional config/profile setup. Profile metadata and retention supply creation defaults; explicit start values override them. An existing-or-none environment override can replace the profile intent. Retrying an existing session id returns that session.", + "description": "Creates a session with optional config/profile setup. Profile metadata and retention supply creation defaults; explicit start values override them. The default environment attachment in the effective config supplies the initial active environment. Retrying an existing session id returns that session.", "paramsType": "SessionStartParams", "resultType": "AgentApiOutcome", "inputSchema": { @@ -44,17 +44,6 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/definitions/SessionEnvironmentOverride" - }, - { - "type": "null" - } - ], - "description": "Optional creation-time override for the selected profile's environment\nintent. Omit to use the profile's intent unchanged." - }, "metadata": { "additionalProperties": { "type": "string" @@ -163,94 +152,49 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "EnvironmentCredentialSourceView": { - "oneOf": [ - { - "properties": { - "grantId": { - "type": "string" - }, - "type": { - "const": "authGrant", - "type": "string" - } - }, - "required": [ - "type", - "grantId" - ], - "type": "object" - }, - { - "properties": { - "providerId": { - "type": "string" - }, - "type": { - "const": "authProviderCredential", - "type": "string" - } - }, - "required": [ - "type", - "providerId" - ], - "type": "object" - }, - { - "properties": { - "secretId": { - "type": "string" - }, - "type": { - "const": "directSecret", - "type": "string" - } - }, - "required": [ - "type", - "secretId" - ], - "type": "object" - } - ] + "EnvironmentAccess": { + "description": "Per-attachment environment access, an ordered ladder: `edit` adds file\nediting to `read`, `exec` adds processes, `jobs` adds durable jobs.\nProcesses can write files regardless of the file-tool level, so\nread-only files with commands is deliberately not expressible.", + "enum": [ + "read", + "edit", + "exec", + "jobs" + ], + "type": "string" }, - "EnvironmentIdlePolicyView": { - "description": "Staged idle policy. Thresholds are milliseconds of daemon-reported idle\ntime and must be non-decreasing in the order pause, suspend, stop, close.\nStages whose power state the provider does not support are skipped.", + "EnvironmentAttachment": { + "additionalProperties": { + "not": {} + }, + "description": "One environment the session may use. Exactly one of `environmentId` and\n`inherit` identifies the machine. `inherit` is valid only in a profile\ndocument applied to a sub-agent: it resolves to the delegating parent's\nactive environment at spawn and is stored on the child as a concrete id.\nIf the parent's environment is also listed explicitly, the explicit\nattachment wins; if the parent has none, the inherit attachment is dropped.", "properties": { - "closeAfterMs": { - "format": "uint64", - "minimum": 0, - "type": [ - "integer", - "null" - ] + "access": { + "$ref": "#/definitions/EnvironmentAccess" }, - "pauseAfterMs": { - "format": "uint64", - "minimum": 0, - "type": [ - "integer", - "null" - ] + "default": { + "description": "Activated when a profile is applied while the session has no active\nenvironment; creation is the trivial case. Never overrides a live\nselection and never applies on a plain `session/config/put`.", + "type": "boolean" }, - "stopAfterMs": { - "format": "uint64", - "minimum": 0, + "environmentId": { "type": [ - "integer", + "string", "null" ] }, - "suspendAfterMs": { - "format": "uint64", - "minimum": 0, + "inherit": { + "type": "boolean" + }, + "workingDirectory": { + "description": "Absolute machine working directory for file tools, commands, jobs,\nand sources; absent uses the machine's advertised default.", "type": [ - "integer", + "string", "null" ] } }, + "required": [ + "access" + ], "type": "object" }, "EnvironmentPromptsConfig": { @@ -293,29 +237,18 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "EnvironmentToolSurface": { - "description": "Agent-facing environment filesystem tools; independent of execution grants.", - "enum": [ - "readOnly", - "edit" - ], - "type": "string" - }, "EnvironmentsFeature": { "additionalProperties": { "not": {} }, - "description": "Grants active session environments. Filesystem tools, commands, selection,\ndurable jobs, prompts, and skills are independent, default-off sub-grants.", + "description": "Grants session environments. The `environments` list is the allowed set:\nthe session can select, read, and run work only on a listed machine, each\nwith its own access grant and working directory. The installed tool\nsurface is the union of every attachment's grant; a call the active\nmachine's grant does not cover fails at execution, so switching machines\nnever changes the toolset. `{}` grants the feature with no reachable\nmachine.", "properties": { - "commands": { - "default": false, - "description": "Grants command execution and process continuation. Commands may modify\nfiles even when filesystem tools are read-only or disabled.", - "type": "boolean" - }, - "jobs": { - "default": false, - "description": "Grants the advanced durable-job tool surface. The workflow binding is\ninstalled for the session when granted; invocations still require an\nactive, ready environment with matching job capabilities.", - "type": "boolean" + "environments": { + "description": "The environments this session may use; unique ids, at most one\ndefault, at most one `inherit` (profiles only).", + "items": { + "$ref": "#/definitions/EnvironmentAttachment" + }, + "type": "array" }, "prompts": { "anyOf": [ @@ -328,29 +261,9 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ ], "description": "Independent environment prompt loading; absent disables sourced instructions." }, - "providers": { - "description": "Absent means every registered provider is allowed.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "registrationKeys": { - "description": "Registration keys whose registered environments the session may\nlist and activate; absent means every key. Independent of\n`providers`: each list scopes its own environment source, and\nexternal environments pass only when neither list is set.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "selectionTools": { + "selection": { "default": false, - "description": "Exposes `environment_list`, `environment_activate`, and\n`environment_deactivate` to the model. `environment_read` is available\nwhenever environments are enabled, and external API/profile activation\nremains available when this is false.", + "description": "Exposes `environment_list`, `environment_activate`, and\n`environment_deactivate` over the attached environments.\n`environment_read` is available whenever environments are enabled, and\nexternal API/profile activation remains available when this is false.", "type": "boolean" }, "skills": { @@ -364,29 +277,11 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ ], "description": "Independent environment skill discovery. Absent disables discovery." }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/EnvironmentToolSurface" - }, - { - "type": "null" - } - ], - "description": "Filesystem tool surface. Absent installs no filesystem tools; sources\nremain independent. Read-only does not restrict commands or durable jobs." - }, "version": { "default": 1, "format": "uint32", "minimum": 0, "type": "integer" - }, - "workingDirectory": { - "description": "Absolute machine working directory for file tools, commands, jobs, and sources; absent uses the endpoint default.", - "type": [ - "string", - "null" - ] } }, "type": "object" @@ -536,17 +431,6 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/definitions/ProfileEnvironment" - }, - { - "type": "null" - } - ], - "description": "How the session obtains its active environment when this profile is\napplied: activate an existing universe environment, or provision a\nfresh one for this session. Absence leaves the session's current\nactive environment unchanged." - }, "instructions": { "anyOf": [ { @@ -607,11 +491,12 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "additionalProperties": { "not": {} }, - "description": "Grants remote MCP tools by declaring linked servers from the universe MCP\ncatalog; must link at least one server, with unique server ids.", + "description": "Grants remote MCP tools by declaring attached servers from the universe MCP\ncatalog. Server ids must be unique; an empty list grants no MCP tools.", "properties": { "servers": { + "default": [], "items": { - "$ref": "#/definitions/McpServerLink" + "$ref": "#/definitions/McpServerAttachment" }, "type": "array" }, @@ -624,14 +509,24 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "McpServerLink": { + "McpServerAttachment": { "additionalProperties": { "not": {} }, - "description": "A selected universe MCP server. Its catalog record owns all connection and\nbehavior configuration.", + "description": "A selected universe MCP server. Its catalog record owns connection,\nexecution, exposure, approval, and auth; the attachment may only narrow the\nrecord's tool allowlist for this session.", "properties": { "serverId": { "type": "string" + }, + "tools": { + "description": "Non-empty subset of the record's allowed tools exposed to this\nsession, under both injection and search; absent exposes the record's\nfull allowlist.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -667,214 +562,75 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ ], "type": "string" }, - "ProfileEnvironment": { - "description": "Environment intent carried by a profile document.", + "ProfileId": { + "type": "string" + }, + "ProfileInstructions": { "oneOf": [ { - "additionalProperties": { - "not": {} - }, - "description": "Activate an existing universe environment. The profile never closes\nit.", "properties": { - "environmentId": { + "text": { "type": "string" }, "type": { - "const": "existing", + "const": "text", "type": "string" } }, "required": [ "type", - "environmentId" - ], - "type": "object" - }, - { - "additionalProperties": { - "not": {} - }, - "description": "Activate the delegating parent's active environment. Resolved at\nsub-agent spawn, shared not copied, never closed by the\nchild; rejected on a session without a delegation origin or whose\nparent has no active environment.", - "properties": { - "type": { - "const": "inherit", - "type": "string" - } - }, - "required": [ - "type" + "text" ], "type": "object" }, { - "additionalProperties": { - "not": {} - }, - "description": "Provision one environment for the session from the universe's enabled\nbinding for `providerId`, then activate it. The provision request id\nis derived from the session id, so retries and repeated applies\nconverge on the same environment.", + "description": "Borrowed CAS content: saving a profile does not retain the blob. Use\ninline text, or keep this ref retained by another durable resource.", "properties": { - "credentials": { - "description": "Credentials bound to the environment right after it is\nprovisioned before activation: references to universe\ngrants/providers/secrets, never values. They become ordinary\nenvironment credential bindings; the profile is the initial set,\nnot a live sync. Not available for `existing` environments.", - "items": { - "$ref": "#/definitions/ProfileEnvironmentCredential" - }, - "type": "array" - }, - "displayName": { - "type": [ - "string", - "null" - ] - }, - "idlePolicy": { - "anyOf": [ - { - "$ref": "#/definitions/EnvironmentIdlePolicyView" - }, - { - "type": "null" - } - ], - "description": "Optional staged idle policy for the provisioned environment.\nStages the provider cannot realize are skipped." - }, - "metadata": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - "providerId": { - "type": "string" - }, - "retention": { - "allOf": [ - { - "$ref": "#/definitions/ProfileEnvironmentRetention" - } - ], - "default": "closeWithSession" - }, - "templateId": { - "description": "Immutable provider template-version identity.", + "blobRef": { "type": "string" }, "type": { - "const": "provision", + "const": "textRef", "type": "string" } }, "required": [ "type", - "providerId", - "templateId" + "blobRef" ], "type": "object" } ] }, - "ProfileEnvironmentCredential": { + "ProfileSessionRetention": { "additionalProperties": { "not": {} }, - "description": "One environment credential binding requested by a profile: the same shape\nas `environments/credentials/bind`.", + "description": "Root-session retention policy supplied by a profile at session creation.", "properties": { - "envName": { - "description": "Environment variable name (`[A-Za-z_][A-Za-z0-9_]{0,127}`).", - "type": "string" - }, - "source": { - "$ref": "#/definitions/EnvironmentCredentialSourceView" + "deleteAfterCloseMs": { + "description": "Positive close-relative automatic-deletion duration.", + "format": "uint64", + "maximum": 3153600000000, + "minimum": 1, + "type": "integer" } }, "required": [ - "envName", - "source" + "deleteAfterCloseMs" ], "type": "object" }, - "ProfileEnvironmentRetention": { - "description": "What happens to a profile-provisioned environment when its originating\nsession closes.", - "oneOf": [ - { - "const": "closeWithSession", - "description": "Close the environment when the session that provisioned it closes.", - "type": "string" - }, - { - "const": "retain", - "description": "Leave the environment open; the universe owns its cleanup.", - "type": "string" - } - ] - }, - "ProfileId": { - "type": "string" - }, - "ProfileInstructions": { + "ProfileSource": { "oneOf": [ { "properties": { - "text": { + "kind": { + "const": "named", "type": "string" }, - "type": { - "const": "text", - "type": "string" - } - }, - "required": [ - "type", - "text" - ], - "type": "object" - }, - { - "description": "Borrowed CAS content: saving a profile does not retain the blob. Use\ninline text, or keep this ref retained by another durable resource.", - "properties": { - "blobRef": { - "type": "string" - }, - "type": { - "const": "textRef", - "type": "string" - } - }, - "required": [ - "type", - "blobRef" - ], - "type": "object" - } - ] - }, - "ProfileSessionRetention": { - "additionalProperties": { - "not": {} - }, - "description": "Root-session retention policy supplied by a profile at session creation.", - "properties": { - "deleteAfterCloseMs": { - "description": "Positive close-relative automatic-deletion duration.", - "format": "uint64", - "maximum": 3153600000000, - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "deleteAfterCloseMs" - ], - "type": "object" - }, - "ProfileSource": { - "oneOf": [ - { - "properties": { - "kind": { - "const": "named", - "type": "string" - }, - "profileId": { - "$ref": "#/definitions/ProfileId" + "profileId": { + "$ref": "#/definitions/ProfileId" } }, "required": [ @@ -961,45 +717,6 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "SessionEnvironmentOverride": { - "description": "Creation-time override for the environment intent carried by a profile.\nAbsence uses the profile unchanged; `none` suppresses its environment\nintent, while `existing` activates the specified universe environment.", - "oneOf": [ - { - "additionalProperties": { - "not": {} - }, - "properties": { - "type": { - "const": "none", - "type": "string" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - { - "additionalProperties": { - "not": {} - }, - "properties": { - "environmentId": { - "type": "string" - }, - "type": { - "const": "existing", - "type": "string" - } - }, - "required": [ - "type", - "environmentId" - ], - "type": "object" - } - ] - }, "SubagentAgentRef": { "additionalProperties": { "not": {} @@ -1142,7 +859,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "additionalProperties": { "not": {} }, - "description": "Grants the session virtual filesystem. Workspace links declare the\nsession-visible namespace and the VFS catalog is surfaced. Sub-grants are independent; `{}` grants a VFS with\nno tools and no sourcing.", + "description": "Grants the session virtual filesystem. Workspace attachments declare the\nsession-visible namespace and the VFS catalog is surfaced. The file tool\nsurface is derived from the attachments: any attachment installs the read\ntools, any `edit` attachment adds the write tools, and with the\nenvironments feature granted the matching transfer tools appear. `{}`\ngrants a VFS with no attachments, no tools, and no sourcing.", "properties": { "prompts": { "anyOf": [ @@ -1153,7 +870,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "type": "null" } ], - "description": "Prompt-instruction sourcing from the VFS. Absent disables loading;\nan empty block discovers conventional linked roots." + "description": "Prompt-instruction sourcing from the VFS. Absent disables loading;\nan empty block discovers conventional attached roots." }, "skills": { "anyOf": [ @@ -1164,18 +881,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "type": "null" } ], - "description": "Independent VFS skill discovery. Absent disables discovery and removes\nits runtime catalog; an empty block discovers conventional linked roots." - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/VfsToolSurface" - }, - { - "type": "null" - } - ], - "description": "Agent-facing filesystem tool surface; absent = no fs tools. Per-path\nwritability is defined by each workspace link's own access.\nWith the environments feature granted, `readOnly` also exposes\n`vfs_materialize`; `edit` additionally exposes `vfs_capture`.\nPrompt/skill sourcing alone does not grant transfer tools." + "description": "Independent VFS skill discovery. Absent disables discovery and removes\nits runtime catalog; an empty block discovers conventional attached roots." }, "version": { "default": 1, @@ -1190,10 +896,10 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "null" ] }, - "workspaceLinks": { - "description": "Catalog resources exposed in the session's workspace namespace.", + "workspaces": { + "description": "Catalog resources exposed in the session's workspace namespace at\ndisjoint absolute paths.", "items": { - "$ref": "#/definitions/WorkspaceLink" + "$ref": "#/definitions/WorkspaceAttachment" }, "type": "array" } @@ -1206,7 +912,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "properties": { "roots": { - "description": "Absent searches .agents/prompts and .lightspeed/prompts beneath each\nworkspace link. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace links.", + "description": "Absent searches .agents/prompts and .lightspeed/prompts beneath each\nworkspace attachment. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace attachments.", "items": { "type": "string" }, @@ -1224,7 +930,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "properties": { "roots": { - "description": "Absent searches .agents/skills and .lightspeed/skills beneath each\nworkspace link. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace links.", + "description": "Absent searches .agents/skills and .lightspeed/skills beneath each\nworkspace attachment. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace attachments.", "items": { "type": "string" }, @@ -1237,13 +943,6 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "VfsToolSurface": { - "enum": [ - "readOnly", - "edit" - ], - "type": "string" - }, "WebFeature": { "additionalProperties": { "not": {} @@ -1309,70 +1008,44 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "WorkspaceLink": { + "WorkspaceAccess": { + "description": "Per-attachment VFS access; `edit` implies `read`.", + "enum": [ + "read", + "edit" + ], + "type": "string" + }, + "WorkspaceAttachment": { "additionalProperties": { "not": {} }, + "description": "One catalog resource mounted into the session namespace. Exactly one of\n`workspaceId` and `snapshotRef` names the resource; snapshots are\nimmutable and must be attached with `read` access.", "properties": { "access": { - "$ref": "#/definitions/WorkspaceLinkAccess" + "$ref": "#/definitions/WorkspaceAccess" }, "path": { "type": "string" }, - "target": { - "$ref": "#/definitions/WorkspaceLinkTarget" + "snapshotRef": { + "type": [ + "string", + "null" + ] + }, + "workspaceId": { + "type": [ + "string", + "null" + ] } }, "required": [ "path", - "target", "access" ], "type": "object" - }, - "WorkspaceLinkAccess": { - "enum": [ - "readOnly", - "readWrite" - ], - "type": "string" - }, - "WorkspaceLinkTarget": { - "oneOf": [ - { - "properties": { - "type": { - "const": "workspace", - "type": "string" - }, - "workspaceId": { - "type": "string" - } - }, - "required": [ - "type", - "workspaceId" - ], - "type": "object" - }, - { - "properties": { - "snapshotRef": { - "type": "string" - }, - "type": { - "const": "snapshot", - "type": "string" - } - }, - "required": [ - "type", - "snapshotRef" - ], - "type": "object" - } - ] } } } @@ -1575,6 +1248,51 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, + "EnvironmentAccess": { + "description": "Per-attachment environment access, an ordered ladder: `edit` adds file\nediting to `read`, `exec` adds processes, `jobs` adds durable jobs.\nProcesses can write files regardless of the file-tool level, so\nread-only files with commands is deliberately not expressible.", + "enum": [ + "read", + "edit", + "exec", + "jobs" + ], + "type": "string" + }, + "EnvironmentAttachment": { + "additionalProperties": { + "not": {} + }, + "description": "One environment the session may use. Exactly one of `environmentId` and\n`inherit` identifies the machine. `inherit` is valid only in a profile\ndocument applied to a sub-agent: it resolves to the delegating parent's\nactive environment at spawn and is stored on the child as a concrete id.\nIf the parent's environment is also listed explicitly, the explicit\nattachment wins; if the parent has none, the inherit attachment is dropped.", + "properties": { + "access": { + "$ref": "#/definitions/EnvironmentAccess" + }, + "default": { + "description": "Activated when a profile is applied while the session has no active\nenvironment; creation is the trivial case. Never overrides a live\nselection and never applies on a plain `session/config/put`.", + "type": "boolean" + }, + "environmentId": { + "type": [ + "string", + "null" + ] + }, + "inherit": { + "type": "boolean" + }, + "workingDirectory": { + "description": "Absolute machine working directory for file tools, commands, jobs,\nand sources; absent uses the machine's advertised default.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "access" + ], + "type": "object" + }, "EnvironmentPromptsConfig": { "additionalProperties": { "not": {} @@ -1615,29 +1333,18 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "EnvironmentToolSurface": { - "description": "Agent-facing environment filesystem tools; independent of execution grants.", - "enum": [ - "readOnly", - "edit" - ], - "type": "string" - }, "EnvironmentsFeature": { "additionalProperties": { "not": {} }, - "description": "Grants active session environments. Filesystem tools, commands, selection,\ndurable jobs, prompts, and skills are independent, default-off sub-grants.", + "description": "Grants session environments. The `environments` list is the allowed set:\nthe session can select, read, and run work only on a listed machine, each\nwith its own access grant and working directory. The installed tool\nsurface is the union of every attachment's grant; a call the active\nmachine's grant does not cover fails at execution, so switching machines\nnever changes the toolset. `{}` grants the feature with no reachable\nmachine.", "properties": { - "commands": { - "default": false, - "description": "Grants command execution and process continuation. Commands may modify\nfiles even when filesystem tools are read-only or disabled.", - "type": "boolean" - }, - "jobs": { - "default": false, - "description": "Grants the advanced durable-job tool surface. The workflow binding is\ninstalled for the session when granted; invocations still require an\nactive, ready environment with matching job capabilities.", - "type": "boolean" + "environments": { + "description": "The environments this session may use; unique ids, at most one\ndefault, at most one `inherit` (profiles only).", + "items": { + "$ref": "#/definitions/EnvironmentAttachment" + }, + "type": "array" }, "prompts": { "anyOf": [ @@ -1650,29 +1357,9 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ ], "description": "Independent environment prompt loading; absent disables sourced instructions." }, - "providers": { - "description": "Absent means every registered provider is allowed.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "registrationKeys": { - "description": "Registration keys whose registered environments the session may\nlist and activate; absent means every key. Independent of\n`providers`: each list scopes its own environment source, and\nexternal environments pass only when neither list is set.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "selectionTools": { + "selection": { "default": false, - "description": "Exposes `environment_list`, `environment_activate`, and\n`environment_deactivate` to the model. `environment_read` is available\nwhenever environments are enabled, and external API/profile activation\nremains available when this is false.", + "description": "Exposes `environment_list`, `environment_activate`, and\n`environment_deactivate` over the attached environments.\n`environment_read` is available whenever environments are enabled, and\nexternal API/profile activation remains available when this is false.", "type": "boolean" }, "skills": { @@ -1686,29 +1373,11 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ ], "description": "Independent environment skill discovery. Absent disables discovery." }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/EnvironmentToolSurface" - }, - { - "type": "null" - } - ], - "description": "Filesystem tool surface. Absent installs no filesystem tools; sources\nremain independent. Read-only does not restrict commands or durable jobs." - }, "version": { "default": 1, "format": "uint32", "minimum": 0, "type": "integer" - }, - "workingDirectory": { - "description": "Absolute machine working directory for file tools, commands, jobs, and sources; absent uses the endpoint default.", - "type": [ - "string", - "null" - ] } }, "type": "object" @@ -1863,11 +1532,12 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "additionalProperties": { "not": {} }, - "description": "Grants remote MCP tools by declaring linked servers from the universe MCP\ncatalog; must link at least one server, with unique server ids.", + "description": "Grants remote MCP tools by declaring attached servers from the universe MCP\ncatalog. Server ids must be unique; an empty list grants no MCP tools.", "properties": { "servers": { + "default": [], "items": { - "$ref": "#/definitions/McpServerLink" + "$ref": "#/definitions/McpServerAttachment" }, "type": "array" }, @@ -1880,14 +1550,24 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "McpServerLink": { + "McpServerAttachment": { "additionalProperties": { "not": {} }, - "description": "A selected universe MCP server. Its catalog record owns all connection and\nbehavior configuration.", + "description": "A selected universe MCP server. Its catalog record owns connection,\nexecution, exposure, approval, and auth; the attachment may only narrow the\nrecord's tool allowlist for this session.", "properties": { "serverId": { "type": "string" + }, + "tools": { + "description": "Non-empty subset of the record's allowed tools exposed to this\nsession, under both injection and search; absent exposes the record's\nfull allowlist.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -2128,7 +1808,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "additionalProperties": { "not": {} }, - "description": "Grants the session virtual filesystem. Workspace links declare the\nsession-visible namespace and the VFS catalog is surfaced. Sub-grants are independent; `{}` grants a VFS with\nno tools and no sourcing.", + "description": "Grants the session virtual filesystem. Workspace attachments declare the\nsession-visible namespace and the VFS catalog is surfaced. The file tool\nsurface is derived from the attachments: any attachment installs the read\ntools, any `edit` attachment adds the write tools, and with the\nenvironments feature granted the matching transfer tools appear. `{}`\ngrants a VFS with no attachments, no tools, and no sourcing.", "properties": { "prompts": { "anyOf": [ @@ -2139,7 +1819,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "type": "null" } ], - "description": "Prompt-instruction sourcing from the VFS. Absent disables loading;\nan empty block discovers conventional linked roots." + "description": "Prompt-instruction sourcing from the VFS. Absent disables loading;\nan empty block discovers conventional attached roots." }, "skills": { "anyOf": [ @@ -2150,18 +1830,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "type": "null" } ], - "description": "Independent VFS skill discovery. Absent disables discovery and removes\nits runtime catalog; an empty block discovers conventional linked roots." - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/VfsToolSurface" - }, - { - "type": "null" - } - ], - "description": "Agent-facing filesystem tool surface; absent = no fs tools. Per-path\nwritability is defined by each workspace link's own access.\nWith the environments feature granted, `readOnly` also exposes\n`vfs_materialize`; `edit` additionally exposes `vfs_capture`.\nPrompt/skill sourcing alone does not grant transfer tools." + "description": "Independent VFS skill discovery. Absent disables discovery and removes\nits runtime catalog; an empty block discovers conventional attached roots." }, "version": { "default": 1, @@ -2176,10 +1845,10 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "null" ] }, - "workspaceLinks": { - "description": "Catalog resources exposed in the session's workspace namespace.", + "workspaces": { + "description": "Catalog resources exposed in the session's workspace namespace at\ndisjoint absolute paths.", "items": { - "$ref": "#/definitions/WorkspaceLink" + "$ref": "#/definitions/WorkspaceAttachment" }, "type": "array" } @@ -2192,7 +1861,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "properties": { "roots": { - "description": "Absent searches .agents/prompts and .lightspeed/prompts beneath each\nworkspace link. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace links.", + "description": "Absent searches .agents/prompts and .lightspeed/prompts beneath each\nworkspace attachment. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace attachments.", "items": { "type": "string" }, @@ -2210,7 +1879,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "properties": { "roots": { - "description": "Absent searches .agents/skills and .lightspeed/skills beneath each\nworkspace link. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace links.", + "description": "Absent searches .agents/skills and .lightspeed/skills beneath each\nworkspace attachment. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace attachments.", "items": { "type": "string" }, @@ -2223,13 +1892,6 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "VfsToolSurface": { - "enum": [ - "readOnly", - "edit" - ], - "type": "string" - }, "WebFeature": { "additionalProperties": { "not": {} @@ -2295,70 +1957,44 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "WorkspaceLink": { + "WorkspaceAccess": { + "description": "Per-attachment VFS access; `edit` implies `read`.", + "enum": [ + "read", + "edit" + ], + "type": "string" + }, + "WorkspaceAttachment": { "additionalProperties": { "not": {} }, + "description": "One catalog resource mounted into the session namespace. Exactly one of\n`workspaceId` and `snapshotRef` names the resource; snapshots are\nimmutable and must be attached with `read` access.", "properties": { "access": { - "$ref": "#/definitions/WorkspaceLinkAccess" + "$ref": "#/definitions/WorkspaceAccess" }, "path": { "type": "string" }, - "target": { - "$ref": "#/definitions/WorkspaceLinkTarget" + "snapshotRef": { + "type": [ + "string", + "null" + ] + }, + "workspaceId": { + "type": [ + "string", + "null" + ] } }, "required": [ "path", - "target", "access" ], "type": "object" - }, - "WorkspaceLinkAccess": { - "enum": [ - "readOnly", - "readWrite" - ], - "type": "string" - }, - "WorkspaceLinkTarget": { - "oneOf": [ - { - "properties": { - "type": { - "const": "workspace", - "type": "string" - }, - "workspaceId": { - "type": "string" - } - }, - "required": [ - "type", - "workspaceId" - ], - "type": "object" - }, - { - "properties": { - "snapshotRef": { - "type": "string" - }, - "type": { - "const": "snapshot", - "type": "string" - } - }, - "required": [ - "type", - "snapshotRef" - ], - "type": "object" - } - ] } } } @@ -3638,94 +3274,49 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "EnvironmentCredentialSourceView": { - "oneOf": [ - { - "properties": { - "grantId": { - "type": "string" - }, - "type": { - "const": "authGrant", - "type": "string" - } - }, - "required": [ - "type", - "grantId" - ], - "type": "object" - }, - { - "properties": { - "providerId": { - "type": "string" - }, - "type": { - "const": "authProviderCredential", - "type": "string" - } - }, - "required": [ - "type", - "providerId" - ], - "type": "object" - }, - { - "properties": { - "secretId": { - "type": "string" - }, - "type": { - "const": "directSecret", - "type": "string" - } - }, - "required": [ - "type", - "secretId" - ], - "type": "object" - } - ] + "EnvironmentAccess": { + "description": "Per-attachment environment access, an ordered ladder: `edit` adds file\nediting to `read`, `exec` adds processes, `jobs` adds durable jobs.\nProcesses can write files regardless of the file-tool level, so\nread-only files with commands is deliberately not expressible.", + "enum": [ + "read", + "edit", + "exec", + "jobs" + ], + "type": "string" }, - "EnvironmentIdlePolicyView": { - "description": "Staged idle policy. Thresholds are milliseconds of daemon-reported idle\ntime and must be non-decreasing in the order pause, suspend, stop, close.\nStages whose power state the provider does not support are skipped.", + "EnvironmentAttachment": { + "additionalProperties": { + "not": {} + }, + "description": "One environment the session may use. Exactly one of `environmentId` and\n`inherit` identifies the machine. `inherit` is valid only in a profile\ndocument applied to a sub-agent: it resolves to the delegating parent's\nactive environment at spawn and is stored on the child as a concrete id.\nIf the parent's environment is also listed explicitly, the explicit\nattachment wins; if the parent has none, the inherit attachment is dropped.", "properties": { - "closeAfterMs": { - "format": "uint64", - "minimum": 0, - "type": [ - "integer", - "null" - ] + "access": { + "$ref": "#/definitions/EnvironmentAccess" }, - "pauseAfterMs": { - "format": "uint64", - "minimum": 0, - "type": [ - "integer", - "null" - ] + "default": { + "description": "Activated when a profile is applied while the session has no active\nenvironment; creation is the trivial case. Never overrides a live\nselection and never applies on a plain `session/config/put`.", + "type": "boolean" }, - "stopAfterMs": { - "format": "uint64", - "minimum": 0, + "environmentId": { "type": [ - "integer", + "string", "null" ] }, - "suspendAfterMs": { - "format": "uint64", - "minimum": 0, + "inherit": { + "type": "boolean" + }, + "workingDirectory": { + "description": "Absolute machine working directory for file tools, commands, jobs,\nand sources; absent uses the machine's advertised default.", "type": [ - "integer", + "string", "null" ] } }, + "required": [ + "access" + ], "type": "object" }, "EnvironmentPromptsConfig": { @@ -3768,29 +3359,18 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "EnvironmentToolSurface": { - "description": "Agent-facing environment filesystem tools; independent of execution grants.", - "enum": [ - "readOnly", - "edit" - ], - "type": "string" - }, "EnvironmentsFeature": { "additionalProperties": { "not": {} }, - "description": "Grants active session environments. Filesystem tools, commands, selection,\ndurable jobs, prompts, and skills are independent, default-off sub-grants.", + "description": "Grants session environments. The `environments` list is the allowed set:\nthe session can select, read, and run work only on a listed machine, each\nwith its own access grant and working directory. The installed tool\nsurface is the union of every attachment's grant; a call the active\nmachine's grant does not cover fails at execution, so switching machines\nnever changes the toolset. `{}` grants the feature with no reachable\nmachine.", "properties": { - "commands": { - "default": false, - "description": "Grants command execution and process continuation. Commands may modify\nfiles even when filesystem tools are read-only or disabled.", - "type": "boolean" - }, - "jobs": { - "default": false, - "description": "Grants the advanced durable-job tool surface. The workflow binding is\ninstalled for the session when granted; invocations still require an\nactive, ready environment with matching job capabilities.", - "type": "boolean" + "environments": { + "description": "The environments this session may use; unique ids, at most one\ndefault, at most one `inherit` (profiles only).", + "items": { + "$ref": "#/definitions/EnvironmentAttachment" + }, + "type": "array" }, "prompts": { "anyOf": [ @@ -3803,29 +3383,9 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ ], "description": "Independent environment prompt loading; absent disables sourced instructions." }, - "providers": { - "description": "Absent means every registered provider is allowed.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "registrationKeys": { - "description": "Registration keys whose registered environments the session may\nlist and activate; absent means every key. Independent of\n`providers`: each list scopes its own environment source, and\nexternal environments pass only when neither list is set.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "selectionTools": { + "selection": { "default": false, - "description": "Exposes `environment_list`, `environment_activate`, and\n`environment_deactivate` to the model. `environment_read` is available\nwhenever environments are enabled, and external API/profile activation\nremains available when this is false.", + "description": "Exposes `environment_list`, `environment_activate`, and\n`environment_deactivate` over the attached environments.\n`environment_read` is available whenever environments are enabled, and\nexternal API/profile activation remains available when this is false.", "type": "boolean" }, "skills": { @@ -3839,29 +3399,11 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ ], "description": "Independent environment skill discovery. Absent disables discovery." }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/EnvironmentToolSurface" - }, - { - "type": "null" - } - ], - "description": "Filesystem tool surface. Absent installs no filesystem tools; sources\nremain independent. Read-only does not restrict commands or durable jobs." - }, "version": { "default": 1, "format": "uint32", "minimum": 0, "type": "integer" - }, - "workingDirectory": { - "description": "Absolute machine working directory for file tools, commands, jobs, and sources; absent uses the endpoint default.", - "type": [ - "string", - "null" - ] } }, "type": "object" @@ -4011,17 +3553,6 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/definitions/ProfileEnvironment" - }, - { - "type": "null" - } - ], - "description": "How the session obtains its active environment when this profile is\napplied: activate an existing universe environment, or provision a\nfresh one for this session. Absence leaves the session's current\nactive environment unchanged." - }, "instructions": { "anyOf": [ { @@ -4082,204 +3613,76 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "additionalProperties": { "not": {} }, - "description": "Grants remote MCP tools by declaring linked servers from the universe MCP\ncatalog; must link at least one server, with unique server ids.", + "description": "Grants remote MCP tools by declaring attached servers from the universe MCP\ncatalog. Server ids must be unique; an empty list grants no MCP tools.", "properties": { "servers": { + "default": [], "items": { - "$ref": "#/definitions/McpServerLink" - }, - "type": "array" - }, - "version": { - "default": 1, - "format": "uint32", - "minimum": 0, - "type": "integer" - } - }, - "type": "object" - }, - "McpServerLink": { - "additionalProperties": { - "not": {} - }, - "description": "A selected universe MCP server. Its catalog record owns all connection and\nbehavior configuration.", - "properties": { - "serverId": { - "type": "string" - } - }, - "required": [ - "serverId" - ], - "type": "object" - }, - "ModelConfig": { - "properties": { - "apiKind": { - "type": "string" - }, - "model": { - "type": "string" - }, - "providerId": { - "type": "string" - } - }, - "required": [ - "providerId", - "apiKind", - "model" - ], - "type": "object" - }, - "ModelProcessingTier": { - "description": "Provider processing class used by session defaults and per-run overrides.", - "enum": [ - "standard", - "fast", - "flex" - ], - "type": "string" - }, - "ProfileEnvironment": { - "description": "Environment intent carried by a profile document.", - "oneOf": [ - { - "additionalProperties": { - "not": {} - }, - "description": "Activate an existing universe environment. The profile never closes\nit.", - "properties": { - "environmentId": { - "type": "string" - }, - "type": { - "const": "existing", - "type": "string" - } - }, - "required": [ - "type", - "environmentId" - ], - "type": "object" - }, - { - "additionalProperties": { - "not": {} - }, - "description": "Activate the delegating parent's active environment. Resolved at\nsub-agent spawn, shared not copied, never closed by the\nchild; rejected on a session without a delegation origin or whose\nparent has no active environment.", - "properties": { - "type": { - "const": "inherit", - "type": "string" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - { - "additionalProperties": { - "not": {} - }, - "description": "Provision one environment for the session from the universe's enabled\nbinding for `providerId`, then activate it. The provision request id\nis derived from the session id, so retries and repeated applies\nconverge on the same environment.", - "properties": { - "credentials": { - "description": "Credentials bound to the environment right after it is\nprovisioned before activation: references to universe\ngrants/providers/secrets, never values. They become ordinary\nenvironment credential bindings; the profile is the initial set,\nnot a live sync. Not available for `existing` environments.", - "items": { - "$ref": "#/definitions/ProfileEnvironmentCredential" - }, - "type": "array" - }, - "displayName": { - "type": [ - "string", - "null" - ] - }, - "idlePolicy": { - "anyOf": [ - { - "$ref": "#/definitions/EnvironmentIdlePolicyView" - }, - { - "type": "null" - } - ], - "description": "Optional staged idle policy for the provisioned environment.\nStages the provider cannot realize are skipped." - }, - "metadata": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - "providerId": { - "type": "string" - }, - "retention": { - "allOf": [ - { - "$ref": "#/definitions/ProfileEnvironmentRetention" - } - ], - "default": "closeWithSession" - }, - "templateId": { - "description": "Immutable provider template-version identity.", - "type": "string" - }, - "type": { - "const": "provision", - "type": "string" - } - }, - "required": [ - "type", - "providerId", - "templateId" - ], - "type": "object" + "$ref": "#/definitions/McpServerAttachment" + }, + "type": "array" + }, + "version": { + "default": 1, + "format": "uint32", + "minimum": 0, + "type": "integer" } - ] + }, + "type": "object" }, - "ProfileEnvironmentCredential": { + "McpServerAttachment": { "additionalProperties": { "not": {} }, - "description": "One environment credential binding requested by a profile: the same shape\nas `environments/credentials/bind`.", + "description": "A selected universe MCP server. Its catalog record owns connection,\nexecution, exposure, approval, and auth; the attachment may only narrow the\nrecord's tool allowlist for this session.", "properties": { - "envName": { - "description": "Environment variable name (`[A-Za-z_][A-Za-z0-9_]{0,127}`).", + "serverId": { "type": "string" }, - "source": { - "$ref": "#/definitions/EnvironmentCredentialSourceView" + "tools": { + "description": "Non-empty subset of the record's allowed tools exposed to this\nsession, under both injection and search; absent exposes the record's\nfull allowlist.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] } }, "required": [ - "envName", - "source" + "serverId" ], "type": "object" }, - "ProfileEnvironmentRetention": { - "description": "What happens to a profile-provisioned environment when its originating\nsession closes.", - "oneOf": [ - { - "const": "closeWithSession", - "description": "Close the environment when the session that provisioned it closes.", + "ModelConfig": { + "properties": { + "apiKind": { "type": "string" }, - { - "const": "retain", - "description": "Leave the environment open; the universe owns its cleanup.", + "model": { + "type": "string" + }, + "providerId": { "type": "string" } - ] + }, + "required": [ + "providerId", + "apiKind", + "model" + ], + "type": "object" + }, + "ModelProcessingTier": { + "description": "Provider processing class used by session defaults and per-run overrides.", + "enum": [ + "standard", + "fast", + "flex" + ], + "type": "string" }, "ProfileId": { "type": "string" @@ -4578,7 +3981,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "additionalProperties": { "not": {} }, - "description": "Grants the session virtual filesystem. Workspace links declare the\nsession-visible namespace and the VFS catalog is surfaced. Sub-grants are independent; `{}` grants a VFS with\nno tools and no sourcing.", + "description": "Grants the session virtual filesystem. Workspace attachments declare the\nsession-visible namespace and the VFS catalog is surfaced. The file tool\nsurface is derived from the attachments: any attachment installs the read\ntools, any `edit` attachment adds the write tools, and with the\nenvironments feature granted the matching transfer tools appear. `{}`\ngrants a VFS with no attachments, no tools, and no sourcing.", "properties": { "prompts": { "anyOf": [ @@ -4589,7 +3992,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "type": "null" } ], - "description": "Prompt-instruction sourcing from the VFS. Absent disables loading;\nan empty block discovers conventional linked roots." + "description": "Prompt-instruction sourcing from the VFS. Absent disables loading;\nan empty block discovers conventional attached roots." }, "skills": { "anyOf": [ @@ -4600,18 +4003,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "type": "null" } ], - "description": "Independent VFS skill discovery. Absent disables discovery and removes\nits runtime catalog; an empty block discovers conventional linked roots." - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/VfsToolSurface" - }, - { - "type": "null" - } - ], - "description": "Agent-facing filesystem tool surface; absent = no fs tools. Per-path\nwritability is defined by each workspace link's own access.\nWith the environments feature granted, `readOnly` also exposes\n`vfs_materialize`; `edit` additionally exposes `vfs_capture`.\nPrompt/skill sourcing alone does not grant transfer tools." + "description": "Independent VFS skill discovery. Absent disables discovery and removes\nits runtime catalog; an empty block discovers conventional attached roots." }, "version": { "default": 1, @@ -4626,10 +4018,10 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "null" ] }, - "workspaceLinks": { - "description": "Catalog resources exposed in the session's workspace namespace.", + "workspaces": { + "description": "Catalog resources exposed in the session's workspace namespace at\ndisjoint absolute paths.", "items": { - "$ref": "#/definitions/WorkspaceLink" + "$ref": "#/definitions/WorkspaceAttachment" }, "type": "array" } @@ -4642,7 +4034,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "properties": { "roots": { - "description": "Absent searches .agents/prompts and .lightspeed/prompts beneath each\nworkspace link. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace links.", + "description": "Absent searches .agents/prompts and .lightspeed/prompts beneath each\nworkspace attachment. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace attachments.", "items": { "type": "string" }, @@ -4660,7 +4052,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "properties": { "roots": { - "description": "Absent searches .agents/skills and .lightspeed/skills beneath each\nworkspace link. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace links.", + "description": "Absent searches .agents/skills and .lightspeed/skills beneath each\nworkspace attachment. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace attachments.", "items": { "type": "string" }, @@ -4673,13 +4065,6 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "VfsToolSurface": { - "enum": [ - "readOnly", - "edit" - ], - "type": "string" - }, "WebFeature": { "additionalProperties": { "not": {} @@ -4745,70 +4130,44 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "WorkspaceLink": { + "WorkspaceAccess": { + "description": "Per-attachment VFS access; `edit` implies `read`.", + "enum": [ + "read", + "edit" + ], + "type": "string" + }, + "WorkspaceAttachment": { "additionalProperties": { "not": {} }, + "description": "One catalog resource mounted into the session namespace. Exactly one of\n`workspaceId` and `snapshotRef` names the resource; snapshots are\nimmutable and must be attached with `read` access.", "properties": { "access": { - "$ref": "#/definitions/WorkspaceLinkAccess" + "$ref": "#/definitions/WorkspaceAccess" }, "path": { "type": "string" }, - "target": { - "$ref": "#/definitions/WorkspaceLinkTarget" + "snapshotRef": { + "type": [ + "string", + "null" + ] + }, + "workspaceId": { + "type": [ + "string", + "null" + ] } }, "required": [ "path", - "target", "access" ], "type": "object" - }, - "WorkspaceLinkAccess": { - "enum": [ - "readOnly", - "readWrite" - ], - "type": "string" - }, - "WorkspaceLinkTarget": { - "oneOf": [ - { - "properties": { - "type": { - "const": "workspace", - "type": "string" - }, - "workspaceId": { - "type": "string" - } - }, - "required": [ - "type", - "workspaceId" - ], - "type": "object" - }, - { - "properties": { - "snapshotRef": { - "type": "string" - }, - "type": { - "const": "snapshot", - "type": "string" - } - }, - "required": [ - "type", - "snapshotRef" - ], - "type": "object" - } - ] } } } @@ -5119,13 +4478,6 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "description": "Only environments carrying every listed metadata pair (AND\nsemantics); the same filter `session/list` accepts.", "type": "object" }, - "originSessionId": { - "description": "Only environments a profile provisioned for this session.", - "type": [ - "string", - "null" - ] - }, "providerId": { "type": [ "string", @@ -5589,17 +4941,6 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/definitions/ProfileEnvironment" - }, - { - "type": "null" - } - ], - "description": "How the session obtains its active environment when this profile is\napplied: activate an existing universe environment, or provision a\nfresh one for this session. Absence leaves the session's current\nactive environment unchanged." - }, "instructions": { "anyOf": [ { @@ -5719,94 +5060,49 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "EnvironmentCredentialSourceView": { - "oneOf": [ - { - "properties": { - "grantId": { - "type": "string" - }, - "type": { - "const": "authGrant", - "type": "string" - } - }, - "required": [ - "type", - "grantId" - ], - "type": "object" - }, - { - "properties": { - "providerId": { - "type": "string" - }, - "type": { - "const": "authProviderCredential", - "type": "string" - } - }, - "required": [ - "type", - "providerId" - ], - "type": "object" - }, - { - "properties": { - "secretId": { - "type": "string" - }, - "type": { - "const": "directSecret", - "type": "string" - } - }, - "required": [ - "type", - "secretId" - ], - "type": "object" - } - ] + "EnvironmentAccess": { + "description": "Per-attachment environment access, an ordered ladder: `edit` adds file\nediting to `read`, `exec` adds processes, `jobs` adds durable jobs.\nProcesses can write files regardless of the file-tool level, so\nread-only files with commands is deliberately not expressible.", + "enum": [ + "read", + "edit", + "exec", + "jobs" + ], + "type": "string" }, - "EnvironmentIdlePolicyView": { - "description": "Staged idle policy. Thresholds are milliseconds of daemon-reported idle\ntime and must be non-decreasing in the order pause, suspend, stop, close.\nStages whose power state the provider does not support are skipped.", + "EnvironmentAttachment": { + "additionalProperties": { + "not": {} + }, + "description": "One environment the session may use. Exactly one of `environmentId` and\n`inherit` identifies the machine. `inherit` is valid only in a profile\ndocument applied to a sub-agent: it resolves to the delegating parent's\nactive environment at spawn and is stored on the child as a concrete id.\nIf the parent's environment is also listed explicitly, the explicit\nattachment wins; if the parent has none, the inherit attachment is dropped.", "properties": { - "closeAfterMs": { - "format": "uint64", - "minimum": 0, - "type": [ - "integer", - "null" - ] + "access": { + "$ref": "#/definitions/EnvironmentAccess" }, - "pauseAfterMs": { - "format": "uint64", - "minimum": 0, - "type": [ - "integer", - "null" - ] + "default": { + "description": "Activated when a profile is applied while the session has no active\nenvironment; creation is the trivial case. Never overrides a live\nselection and never applies on a plain `session/config/put`.", + "type": "boolean" }, - "stopAfterMs": { - "format": "uint64", - "minimum": 0, + "environmentId": { "type": [ - "integer", + "string", "null" ] }, - "suspendAfterMs": { - "format": "uint64", - "minimum": 0, + "inherit": { + "type": "boolean" + }, + "workingDirectory": { + "description": "Absolute machine working directory for file tools, commands, jobs,\nand sources; absent uses the machine's advertised default.", "type": [ - "integer", + "string", "null" ] } }, + "required": [ + "access" + ], "type": "object" }, "EnvironmentPromptsConfig": { @@ -5849,100 +5145,51 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "EnvironmentToolSurface": { - "description": "Agent-facing environment filesystem tools; independent of execution grants.", - "enum": [ - "readOnly", - "edit" - ], - "type": "string" - }, "EnvironmentsFeature": { "additionalProperties": { - "not": {} - }, - "description": "Grants active session environments. Filesystem tools, commands, selection,\ndurable jobs, prompts, and skills are independent, default-off sub-grants.", - "properties": { - "commands": { - "default": false, - "description": "Grants command execution and process continuation. Commands may modify\nfiles even when filesystem tools are read-only or disabled.", - "type": "boolean" - }, - "jobs": { - "default": false, - "description": "Grants the advanced durable-job tool surface. The workflow binding is\ninstalled for the session when granted; invocations still require an\nactive, ready environment with matching job capabilities.", - "type": "boolean" - }, - "prompts": { - "anyOf": [ - { - "$ref": "#/definitions/EnvironmentPromptsConfig" - }, - { - "type": "null" - } - ], - "description": "Independent environment prompt loading; absent disables sourced instructions." - }, - "providers": { - "description": "Absent means every registered provider is allowed.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "registrationKeys": { - "description": "Registration keys whose registered environments the session may\nlist and activate; absent means every key. Independent of\n`providers`: each list scopes its own environment source, and\nexternal environments pass only when neither list is set.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "selectionTools": { - "default": false, - "description": "Exposes `environment_list`, `environment_activate`, and\n`environment_deactivate` to the model. `environment_read` is available\nwhenever environments are enabled, and external API/profile activation\nremains available when this is false.", - "type": "boolean" + "not": {} + }, + "description": "Grants session environments. The `environments` list is the allowed set:\nthe session can select, read, and run work only on a listed machine, each\nwith its own access grant and working directory. The installed tool\nsurface is the union of every attachment's grant; a call the active\nmachine's grant does not cover fails at execution, so switching machines\nnever changes the toolset. `{}` grants the feature with no reachable\nmachine.", + "properties": { + "environments": { + "description": "The environments this session may use; unique ids, at most one\ndefault, at most one `inherit` (profiles only).", + "items": { + "$ref": "#/definitions/EnvironmentAttachment" + }, + "type": "array" }, - "skills": { + "prompts": { "anyOf": [ { - "$ref": "#/definitions/EnvironmentSkillsConfig" + "$ref": "#/definitions/EnvironmentPromptsConfig" }, { "type": "null" } ], - "description": "Independent environment skill discovery. Absent disables discovery." + "description": "Independent environment prompt loading; absent disables sourced instructions." }, - "tools": { + "selection": { + "default": false, + "description": "Exposes `environment_list`, `environment_activate`, and\n`environment_deactivate` over the attached environments.\n`environment_read` is available whenever environments are enabled, and\nexternal API/profile activation remains available when this is false.", + "type": "boolean" + }, + "skills": { "anyOf": [ { - "$ref": "#/definitions/EnvironmentToolSurface" + "$ref": "#/definitions/EnvironmentSkillsConfig" }, { "type": "null" } ], - "description": "Filesystem tool surface. Absent installs no filesystem tools; sources\nremain independent. Read-only does not restrict commands or durable jobs." + "description": "Independent environment skill discovery. Absent disables discovery." }, "version": { "default": 1, "format": "uint32", "minimum": 0, "type": "integer" - }, - "workingDirectory": { - "description": "Absolute machine working directory for file tools, commands, jobs, and sources; absent uses the endpoint default.", - "type": [ - "string", - "null" - ] } }, "type": "object" @@ -6097,11 +5344,12 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "additionalProperties": { "not": {} }, - "description": "Grants remote MCP tools by declaring linked servers from the universe MCP\ncatalog; must link at least one server, with unique server ids.", + "description": "Grants remote MCP tools by declaring attached servers from the universe MCP\ncatalog. Server ids must be unique; an empty list grants no MCP tools.", "properties": { "servers": { + "default": [], "items": { - "$ref": "#/definitions/McpServerLink" + "$ref": "#/definitions/McpServerAttachment" }, "type": "array" }, @@ -6114,14 +5362,24 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "McpServerLink": { + "McpServerAttachment": { "additionalProperties": { "not": {} }, - "description": "A selected universe MCP server. Its catalog record owns all connection and\nbehavior configuration.", + "description": "A selected universe MCP server. Its catalog record owns connection,\nexecution, exposure, approval, and auth; the attachment may only narrow the\nrecord's tool allowlist for this session.", "properties": { "serverId": { "type": "string" + }, + "tools": { + "description": "Non-empty subset of the record's allowed tools exposed to this\nsession, under both injection and search; absent exposes the record's\nfull allowlist.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -6157,145 +5415,6 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ ], "type": "string" }, - "ProfileEnvironment": { - "description": "Environment intent carried by a profile document.", - "oneOf": [ - { - "additionalProperties": { - "not": {} - }, - "description": "Activate an existing universe environment. The profile never closes\nit.", - "properties": { - "environmentId": { - "type": "string" - }, - "type": { - "const": "existing", - "type": "string" - } - }, - "required": [ - "type", - "environmentId" - ], - "type": "object" - }, - { - "additionalProperties": { - "not": {} - }, - "description": "Activate the delegating parent's active environment. Resolved at\nsub-agent spawn, shared not copied, never closed by the\nchild; rejected on a session without a delegation origin or whose\nparent has no active environment.", - "properties": { - "type": { - "const": "inherit", - "type": "string" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - { - "additionalProperties": { - "not": {} - }, - "description": "Provision one environment for the session from the universe's enabled\nbinding for `providerId`, then activate it. The provision request id\nis derived from the session id, so retries and repeated applies\nconverge on the same environment.", - "properties": { - "credentials": { - "description": "Credentials bound to the environment right after it is\nprovisioned before activation: references to universe\ngrants/providers/secrets, never values. They become ordinary\nenvironment credential bindings; the profile is the initial set,\nnot a live sync. Not available for `existing` environments.", - "items": { - "$ref": "#/definitions/ProfileEnvironmentCredential" - }, - "type": "array" - }, - "displayName": { - "type": [ - "string", - "null" - ] - }, - "idlePolicy": { - "anyOf": [ - { - "$ref": "#/definitions/EnvironmentIdlePolicyView" - }, - { - "type": "null" - } - ], - "description": "Optional staged idle policy for the provisioned environment.\nStages the provider cannot realize are skipped." - }, - "metadata": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - "providerId": { - "type": "string" - }, - "retention": { - "allOf": [ - { - "$ref": "#/definitions/ProfileEnvironmentRetention" - } - ], - "default": "closeWithSession" - }, - "templateId": { - "description": "Immutable provider template-version identity.", - "type": "string" - }, - "type": { - "const": "provision", - "type": "string" - } - }, - "required": [ - "type", - "providerId", - "templateId" - ], - "type": "object" - } - ] - }, - "ProfileEnvironmentCredential": { - "additionalProperties": { - "not": {} - }, - "description": "One environment credential binding requested by a profile: the same shape\nas `environments/credentials/bind`.", - "properties": { - "envName": { - "description": "Environment variable name (`[A-Za-z_][A-Za-z0-9_]{0,127}`).", - "type": "string" - }, - "source": { - "$ref": "#/definitions/EnvironmentCredentialSourceView" - } - }, - "required": [ - "envName", - "source" - ], - "type": "object" - }, - "ProfileEnvironmentRetention": { - "description": "What happens to a profile-provisioned environment when its originating\nsession closes.", - "oneOf": [ - { - "const": "closeWithSession", - "description": "Close the environment when the session that provisioned it closes.", - "type": "string" - }, - { - "const": "retain", - "description": "Leave the environment open; the universe owns its cleanup.", - "type": "string" - } - ] - }, "ProfileId": { "type": "string" }, @@ -6557,7 +5676,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "additionalProperties": { "not": {} }, - "description": "Grants the session virtual filesystem. Workspace links declare the\nsession-visible namespace and the VFS catalog is surfaced. Sub-grants are independent; `{}` grants a VFS with\nno tools and no sourcing.", + "description": "Grants the session virtual filesystem. Workspace attachments declare the\nsession-visible namespace and the VFS catalog is surfaced. The file tool\nsurface is derived from the attachments: any attachment installs the read\ntools, any `edit` attachment adds the write tools, and with the\nenvironments feature granted the matching transfer tools appear. `{}`\ngrants a VFS with no attachments, no tools, and no sourcing.", "properties": { "prompts": { "anyOf": [ @@ -6568,7 +5687,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "type": "null" } ], - "description": "Prompt-instruction sourcing from the VFS. Absent disables loading;\nan empty block discovers conventional linked roots." + "description": "Prompt-instruction sourcing from the VFS. Absent disables loading;\nan empty block discovers conventional attached roots." }, "skills": { "anyOf": [ @@ -6579,18 +5698,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "type": "null" } ], - "description": "Independent VFS skill discovery. Absent disables discovery and removes\nits runtime catalog; an empty block discovers conventional linked roots." - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/VfsToolSurface" - }, - { - "type": "null" - } - ], - "description": "Agent-facing filesystem tool surface; absent = no fs tools. Per-path\nwritability is defined by each workspace link's own access.\nWith the environments feature granted, `readOnly` also exposes\n`vfs_materialize`; `edit` additionally exposes `vfs_capture`.\nPrompt/skill sourcing alone does not grant transfer tools." + "description": "Independent VFS skill discovery. Absent disables discovery and removes\nits runtime catalog; an empty block discovers conventional attached roots." }, "version": { "default": 1, @@ -6605,10 +5713,10 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "null" ] }, - "workspaceLinks": { - "description": "Catalog resources exposed in the session's workspace namespace.", + "workspaces": { + "description": "Catalog resources exposed in the session's workspace namespace at\ndisjoint absolute paths.", "items": { - "$ref": "#/definitions/WorkspaceLink" + "$ref": "#/definitions/WorkspaceAttachment" }, "type": "array" } @@ -6621,7 +5729,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "properties": { "roots": { - "description": "Absent searches .agents/prompts and .lightspeed/prompts beneath each\nworkspace link. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace links.", + "description": "Absent searches .agents/prompts and .lightspeed/prompts beneath each\nworkspace attachment. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace attachments.", "items": { "type": "string" }, @@ -6639,7 +5747,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "properties": { "roots": { - "description": "Absent searches .agents/skills and .lightspeed/skills beneath each\nworkspace link. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace links.", + "description": "Absent searches .agents/skills and .lightspeed/skills beneath each\nworkspace attachment. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace attachments.", "items": { "type": "string" }, @@ -6652,13 +5760,6 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "VfsToolSurface": { - "enum": [ - "readOnly", - "edit" - ], - "type": "string" - }, "WebFeature": { "additionalProperties": { "not": {} @@ -6724,70 +5825,44 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "WorkspaceLink": { + "WorkspaceAccess": { + "description": "Per-attachment VFS access; `edit` implies `read`.", + "enum": [ + "read", + "edit" + ], + "type": "string" + }, + "WorkspaceAttachment": { "additionalProperties": { "not": {} }, + "description": "One catalog resource mounted into the session namespace. Exactly one of\n`workspaceId` and `snapshotRef` names the resource; snapshots are\nimmutable and must be attached with `read` access.", "properties": { "access": { - "$ref": "#/definitions/WorkspaceLinkAccess" + "$ref": "#/definitions/WorkspaceAccess" }, "path": { "type": "string" }, - "target": { - "$ref": "#/definitions/WorkspaceLinkTarget" + "snapshotRef": { + "type": [ + "string", + "null" + ] + }, + "workspaceId": { + "type": [ + "string", + "null" + ] } }, "required": [ "path", - "target", "access" ], "type": "object" - }, - "WorkspaceLinkAccess": { - "enum": [ - "readOnly", - "readWrite" - ], - "type": "string" - }, - "WorkspaceLinkTarget": { - "oneOf": [ - { - "properties": { - "type": { - "const": "workspace", - "type": "string" - }, - "workspaceId": { - "type": "string" - } - }, - "required": [ - "type", - "workspaceId" - ], - "type": "object" - }, - { - "properties": { - "snapshotRef": { - "type": "string" - }, - "type": { - "const": "snapshot", - "type": "string" - } - }, - "required": [ - "type", - "snapshotRef" - ], - "type": "object" - } - ] } } } @@ -6881,17 +5956,6 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "null" ] }, - "environment": { - "anyOf": [ - { - "$ref": "#/definitions/ProfileEnvironment" - }, - { - "type": "null" - } - ], - "description": "How the session obtains its active environment when this profile is\napplied: activate an existing universe environment, or provision a\nfresh one for this session. Absence leaves the session's current\nactive environment unchanged." - }, "instructions": { "anyOf": [ { @@ -7011,94 +6075,49 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "EnvironmentCredentialSourceView": { - "oneOf": [ - { - "properties": { - "grantId": { - "type": "string" - }, - "type": { - "const": "authGrant", - "type": "string" - } - }, - "required": [ - "type", - "grantId" - ], - "type": "object" - }, - { - "properties": { - "providerId": { - "type": "string" - }, - "type": { - "const": "authProviderCredential", - "type": "string" - } - }, - "required": [ - "type", - "providerId" - ], - "type": "object" - }, - { - "properties": { - "secretId": { - "type": "string" - }, - "type": { - "const": "directSecret", - "type": "string" - } - }, - "required": [ - "type", - "secretId" - ], - "type": "object" - } - ] + "EnvironmentAccess": { + "description": "Per-attachment environment access, an ordered ladder: `edit` adds file\nediting to `read`, `exec` adds processes, `jobs` adds durable jobs.\nProcesses can write files regardless of the file-tool level, so\nread-only files with commands is deliberately not expressible.", + "enum": [ + "read", + "edit", + "exec", + "jobs" + ], + "type": "string" }, - "EnvironmentIdlePolicyView": { - "description": "Staged idle policy. Thresholds are milliseconds of daemon-reported idle\ntime and must be non-decreasing in the order pause, suspend, stop, close.\nStages whose power state the provider does not support are skipped.", + "EnvironmentAttachment": { + "additionalProperties": { + "not": {} + }, + "description": "One environment the session may use. Exactly one of `environmentId` and\n`inherit` identifies the machine. `inherit` is valid only in a profile\ndocument applied to a sub-agent: it resolves to the delegating parent's\nactive environment at spawn and is stored on the child as a concrete id.\nIf the parent's environment is also listed explicitly, the explicit\nattachment wins; if the parent has none, the inherit attachment is dropped.", "properties": { - "closeAfterMs": { - "format": "uint64", - "minimum": 0, - "type": [ - "integer", - "null" - ] + "access": { + "$ref": "#/definitions/EnvironmentAccess" }, - "pauseAfterMs": { - "format": "uint64", - "minimum": 0, - "type": [ - "integer", - "null" - ] + "default": { + "description": "Activated when a profile is applied while the session has no active\nenvironment; creation is the trivial case. Never overrides a live\nselection and never applies on a plain `session/config/put`.", + "type": "boolean" }, - "stopAfterMs": { - "format": "uint64", - "minimum": 0, + "environmentId": { "type": [ - "integer", + "string", "null" ] }, - "suspendAfterMs": { - "format": "uint64", - "minimum": 0, + "inherit": { + "type": "boolean" + }, + "workingDirectory": { + "description": "Absolute machine working directory for file tools, commands, jobs,\nand sources; absent uses the machine's advertised default.", "type": [ - "integer", + "string", "null" ] } }, + "required": [ + "access" + ], "type": "object" }, "EnvironmentPromptsConfig": { @@ -7141,29 +6160,18 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "EnvironmentToolSurface": { - "description": "Agent-facing environment filesystem tools; independent of execution grants.", - "enum": [ - "readOnly", - "edit" - ], - "type": "string" - }, "EnvironmentsFeature": { "additionalProperties": { "not": {} }, - "description": "Grants active session environments. Filesystem tools, commands, selection,\ndurable jobs, prompts, and skills are independent, default-off sub-grants.", + "description": "Grants session environments. The `environments` list is the allowed set:\nthe session can select, read, and run work only on a listed machine, each\nwith its own access grant and working directory. The installed tool\nsurface is the union of every attachment's grant; a call the active\nmachine's grant does not cover fails at execution, so switching machines\nnever changes the toolset. `{}` grants the feature with no reachable\nmachine.", "properties": { - "commands": { - "default": false, - "description": "Grants command execution and process continuation. Commands may modify\nfiles even when filesystem tools are read-only or disabled.", - "type": "boolean" - }, - "jobs": { - "default": false, - "description": "Grants the advanced durable-job tool surface. The workflow binding is\ninstalled for the session when granted; invocations still require an\nactive, ready environment with matching job capabilities.", - "type": "boolean" + "environments": { + "description": "The environments this session may use; unique ids, at most one\ndefault, at most one `inherit` (profiles only).", + "items": { + "$ref": "#/definitions/EnvironmentAttachment" + }, + "type": "array" }, "prompts": { "anyOf": [ @@ -7176,29 +6184,9 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ ], "description": "Independent environment prompt loading; absent disables sourced instructions." }, - "providers": { - "description": "Absent means every registered provider is allowed.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "registrationKeys": { - "description": "Registration keys whose registered environments the session may\nlist and activate; absent means every key. Independent of\n`providers`: each list scopes its own environment source, and\nexternal environments pass only when neither list is set.", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "selectionTools": { + "selection": { "default": false, - "description": "Exposes `environment_list`, `environment_activate`, and\n`environment_deactivate` to the model. `environment_read` is available\nwhenever environments are enabled, and external API/profile activation\nremains available when this is false.", + "description": "Exposes `environment_list`, `environment_activate`, and\n`environment_deactivate` over the attached environments.\n`environment_read` is available whenever environments are enabled, and\nexternal API/profile activation remains available when this is false.", "type": "boolean" }, "skills": { @@ -7212,29 +6200,11 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ ], "description": "Independent environment skill discovery. Absent disables discovery." }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/EnvironmentToolSurface" - }, - { - "type": "null" - } - ], - "description": "Filesystem tool surface. Absent installs no filesystem tools; sources\nremain independent. Read-only does not restrict commands or durable jobs." - }, "version": { "default": 1, "format": "uint32", "minimum": 0, "type": "integer" - }, - "workingDirectory": { - "description": "Absolute machine working directory for file tools, commands, jobs, and sources; absent uses the endpoint default.", - "type": [ - "string", - "null" - ] } }, "type": "object" @@ -7389,11 +6359,12 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "additionalProperties": { "not": {} }, - "description": "Grants remote MCP tools by declaring linked servers from the universe MCP\ncatalog; must link at least one server, with unique server ids.", + "description": "Grants remote MCP tools by declaring attached servers from the universe MCP\ncatalog. Server ids must be unique; an empty list grants no MCP tools.", "properties": { "servers": { + "default": [], "items": { - "$ref": "#/definitions/McpServerLink" + "$ref": "#/definitions/McpServerAttachment" }, "type": "array" }, @@ -7406,14 +6377,24 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "McpServerLink": { + "McpServerAttachment": { "additionalProperties": { "not": {} }, - "description": "A selected universe MCP server. Its catalog record owns all connection and\nbehavior configuration.", + "description": "A selected universe MCP server. Its catalog record owns connection,\nexecution, exposure, approval, and auth; the attachment may only narrow the\nrecord's tool allowlist for this session.", "properties": { "serverId": { "type": "string" + }, + "tools": { + "description": "Non-empty subset of the record's allowed tools exposed to this\nsession, under both injection and search; absent exposes the record's\nfull allowlist.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -7449,145 +6430,6 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ ], "type": "string" }, - "ProfileEnvironment": { - "description": "Environment intent carried by a profile document.", - "oneOf": [ - { - "additionalProperties": { - "not": {} - }, - "description": "Activate an existing universe environment. The profile never closes\nit.", - "properties": { - "environmentId": { - "type": "string" - }, - "type": { - "const": "existing", - "type": "string" - } - }, - "required": [ - "type", - "environmentId" - ], - "type": "object" - }, - { - "additionalProperties": { - "not": {} - }, - "description": "Activate the delegating parent's active environment. Resolved at\nsub-agent spawn, shared not copied, never closed by the\nchild; rejected on a session without a delegation origin or whose\nparent has no active environment.", - "properties": { - "type": { - "const": "inherit", - "type": "string" - } - }, - "required": [ - "type" - ], - "type": "object" - }, - { - "additionalProperties": { - "not": {} - }, - "description": "Provision one environment for the session from the universe's enabled\nbinding for `providerId`, then activate it. The provision request id\nis derived from the session id, so retries and repeated applies\nconverge on the same environment.", - "properties": { - "credentials": { - "description": "Credentials bound to the environment right after it is\nprovisioned before activation: references to universe\ngrants/providers/secrets, never values. They become ordinary\nenvironment credential bindings; the profile is the initial set,\nnot a live sync. Not available for `existing` environments.", - "items": { - "$ref": "#/definitions/ProfileEnvironmentCredential" - }, - "type": "array" - }, - "displayName": { - "type": [ - "string", - "null" - ] - }, - "idlePolicy": { - "anyOf": [ - { - "$ref": "#/definitions/EnvironmentIdlePolicyView" - }, - { - "type": "null" - } - ], - "description": "Optional staged idle policy for the provisioned environment.\nStages the provider cannot realize are skipped." - }, - "metadata": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - "providerId": { - "type": "string" - }, - "retention": { - "allOf": [ - { - "$ref": "#/definitions/ProfileEnvironmentRetention" - } - ], - "default": "closeWithSession" - }, - "templateId": { - "description": "Immutable provider template-version identity.", - "type": "string" - }, - "type": { - "const": "provision", - "type": "string" - } - }, - "required": [ - "type", - "providerId", - "templateId" - ], - "type": "object" - } - ] - }, - "ProfileEnvironmentCredential": { - "additionalProperties": { - "not": {} - }, - "description": "One environment credential binding requested by a profile: the same shape\nas `environments/credentials/bind`.", - "properties": { - "envName": { - "description": "Environment variable name (`[A-Za-z_][A-Za-z0-9_]{0,127}`).", - "type": "string" - }, - "source": { - "$ref": "#/definitions/EnvironmentCredentialSourceView" - } - }, - "required": [ - "envName", - "source" - ], - "type": "object" - }, - "ProfileEnvironmentRetention": { - "description": "What happens to a profile-provisioned environment when its originating\nsession closes.", - "oneOf": [ - { - "const": "closeWithSession", - "description": "Close the environment when the session that provisioned it closes.", - "type": "string" - }, - { - "const": "retain", - "description": "Leave the environment open; the universe owns its cleanup.", - "type": "string" - } - ] - }, "ProfileId": { "type": "string" }, @@ -7849,7 +6691,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "additionalProperties": { "not": {} }, - "description": "Grants the session virtual filesystem. Workspace links declare the\nsession-visible namespace and the VFS catalog is surfaced. Sub-grants are independent; `{}` grants a VFS with\nno tools and no sourcing.", + "description": "Grants the session virtual filesystem. Workspace attachments declare the\nsession-visible namespace and the VFS catalog is surfaced. The file tool\nsurface is derived from the attachments: any attachment installs the read\ntools, any `edit` attachment adds the write tools, and with the\nenvironments feature granted the matching transfer tools appear. `{}`\ngrants a VFS with no attachments, no tools, and no sourcing.", "properties": { "prompts": { "anyOf": [ @@ -7860,7 +6702,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "type": "null" } ], - "description": "Prompt-instruction sourcing from the VFS. Absent disables loading;\nan empty block discovers conventional linked roots." + "description": "Prompt-instruction sourcing from the VFS. Absent disables loading;\nan empty block discovers conventional attached roots." }, "skills": { "anyOf": [ @@ -7871,18 +6713,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "type": "null" } ], - "description": "Independent VFS skill discovery. Absent disables discovery and removes\nits runtime catalog; an empty block discovers conventional linked roots." - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/VfsToolSurface" - }, - { - "type": "null" - } - ], - "description": "Agent-facing filesystem tool surface; absent = no fs tools. Per-path\nwritability is defined by each workspace link's own access.\nWith the environments feature granted, `readOnly` also exposes\n`vfs_materialize`; `edit` additionally exposes `vfs_capture`.\nPrompt/skill sourcing alone does not grant transfer tools." + "description": "Independent VFS skill discovery. Absent disables discovery and removes\nits runtime catalog; an empty block discovers conventional attached roots." }, "version": { "default": 1, @@ -7897,10 +6728,10 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "null" ] }, - "workspaceLinks": { - "description": "Catalog resources exposed in the session's workspace namespace.", + "workspaces": { + "description": "Catalog resources exposed in the session's workspace namespace at\ndisjoint absolute paths.", "items": { - "$ref": "#/definitions/WorkspaceLink" + "$ref": "#/definitions/WorkspaceAttachment" }, "type": "array" } @@ -7913,7 +6744,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "properties": { "roots": { - "description": "Absent searches .agents/prompts and .lightspeed/prompts beneath each\nworkspace link. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace links.", + "description": "Absent searches .agents/prompts and .lightspeed/prompts beneath each\nworkspace attachment. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace attachments.", "items": { "type": "string" }, @@ -7931,7 +6762,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "properties": { "roots": { - "description": "Absent searches .agents/skills and .lightspeed/skills beneath each\nworkspace link. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace links.", + "description": "Absent searches .agents/skills and .lightspeed/skills beneath each\nworkspace attachment. Explicit roots replace these defaults and must be\nnon-empty absolute paths contained in workspace attachments.", "items": { "type": "string" }, @@ -7944,13 +6775,6 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "VfsToolSurface": { - "enum": [ - "readOnly", - "edit" - ], - "type": "string" - }, "WebFeature": { "additionalProperties": { "not": {} @@ -8016,70 +6840,44 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ }, "type": "object" }, - "WorkspaceLink": { + "WorkspaceAccess": { + "description": "Per-attachment VFS access; `edit` implies `read`.", + "enum": [ + "read", + "edit" + ], + "type": "string" + }, + "WorkspaceAttachment": { "additionalProperties": { "not": {} }, + "description": "One catalog resource mounted into the session namespace. Exactly one of\n`workspaceId` and `snapshotRef` names the resource; snapshots are\nimmutable and must be attached with `read` access.", "properties": { "access": { - "$ref": "#/definitions/WorkspaceLinkAccess" + "$ref": "#/definitions/WorkspaceAccess" }, "path": { "type": "string" }, - "target": { - "$ref": "#/definitions/WorkspaceLinkTarget" + "snapshotRef": { + "type": [ + "string", + "null" + ] + }, + "workspaceId": { + "type": [ + "string", + "null" + ] } }, "required": [ "path", - "target", "access" ], "type": "object" - }, - "WorkspaceLinkAccess": { - "enum": [ - "readOnly", - "readWrite" - ], - "type": "string" - }, - "WorkspaceLinkTarget": { - "oneOf": [ - { - "properties": { - "type": { - "const": "workspace", - "type": "string" - }, - "workspaceId": { - "type": "string" - } - }, - "required": [ - "type", - "workspaceId" - ], - "type": "object" - }, - { - "properties": { - "snapshotRef": { - "type": "string" - }, - "type": { - "const": "snapshot", - "type": "string" - } - }, - "required": [ - "type", - "snapshotRef" - ], - "type": "object" - } - ] } } } @@ -8524,13 +7322,14 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "null" ] }, - "approvalDefault": { + "approval": { "allOf": [ { "$ref": "#/definitions/RemoteMcpApprovalPolicy" } ], - "default": "never" + "default": "never", + "description": "Approval policy for every session linking this server." }, "authPolicy": { "allOf": [ @@ -8555,7 +7354,8 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "defaultServerLabel": { "type": "string" }, - "deferLoadingDefault": { + "deferLoading": { + "description": "Provider-side deferred loading of tool definitions where supported.", "type": [ "boolean", "null" diff --git a/platform/configurator-mcp/test/generated.test.ts b/platform/configurator-mcp/test/generated.test.ts index d86ef9fc..c09f2785 100644 --- a/platform/configurator-mcp/test/generated.test.ts +++ b/platform/configurator-mcp/test/generated.test.ts @@ -81,7 +81,7 @@ describe("generated universe tools", () => { expect(environments).toMatchObject({ additionalProperties: { not: {} }, properties: { - jobs: { + selection: { default: false, type: "boolean", }, diff --git a/platform/server/src/routes/gateway.test.ts b/platform/server/src/routes/gateway.test.ts index 1003b751..96d1d39d 100644 --- a/platform/server/src/routes/gateway.test.ts +++ b/platform/server/src/routes/gateway.test.ts @@ -7,6 +7,7 @@ import { mcpServerInputWithOAuthGrant, modelProviderCredentialId, modelProviderCredentialView, + sessionCreateSchema, } from "./gateway.js"; describe("model provider credential ids", () => { @@ -113,8 +114,8 @@ describe("MCP OAuth completion", () => { allowedTools: ["search"], execution: "provider", exposure: "inject", - approvalDefault: "never", - deferLoadingDefault: true, + approval: "never", + deferLoading: true, allowPrivateNetwork: false, authPolicy: { type: "requiredOAuth", @@ -149,3 +150,18 @@ describe("external environment request ids", () => { ); }); }); + +describe("session creation setup", () => { + it("accepts profile-based creation without an environment override", () => { + const request = { profile: { kind: "named", profileId: "developer" } }; + expect(sessionCreateSchema.parse(request)).toEqual(request); + }); + + it.each([{ type: "none" }, { type: "existing", environmentId: "runner" }, null])( + "rejects the removed environment override: %j", (environment) => { + expect(sessionCreateSchema.safeParse({ + profile: { kind: "named", profileId: "developer" }, environment, + }).success).toBe(false); + }, + ); +}); diff --git a/platform/server/src/routes/gateway.ts b/platform/server/src/routes/gateway.ts index fa4d7fbb..c0bc6fcb 100644 --- a/platform/server/src/routes/gateway.ts +++ b/platform/server/src/routes/gateway.ts @@ -78,18 +78,12 @@ const profileSourceSchema = z.discriminatedUnion("kind", [ /// the reserved prefix; the schema only keeps the shape honest. const metadataSchema = z.record(z.string().min(1).max(64), z.string().min(1).max(256)); -const sessionEnvironmentOverrideSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal("none") }), - z.object({ type: z.literal("existing"), environmentId: z.string().min(1) }), -]); - -const sessionCreateSchema = z.object({ +export const sessionCreateSchema = z.object({ displayName: z.string().trim().min(1).max(200).optional(), metadata: metadataSchema.optional(), deleteAfterCloseMs: z.number().int().positive().nullable().optional(), profile: profileSourceSchema, - environment: sessionEnvironmentOverrideSchema.optional(), -}); +}).strict(); /// Put replaces the whole map; an empty map clears it. const sessionMetadataPutSchema = z.object({ @@ -604,7 +598,6 @@ export function gatewayRoutes(ctx: AppContext) { ? { deleteAfterCloseMs: input.deleteAfterCloseMs } : {}), profile: input.profile as ProfileSource, - ...(input.environment ? { environment: input.environment } : {}), }); const current = await client.call("session/read", { sessionId: response.result.session.id, @@ -1556,10 +1549,7 @@ export function gatewayRoutes(ctx: AppContext) { if (status) { params.status = status as EnvironmentListParams["status"]; } - const originSessionId = c.req.query("originSessionId"); - if (originSessionId) { - params.originSessionId = originSessionId; - } + const registrationKeyId = c.req.query("registrationKeyId"); if (registrationKeyId) { params.registrationKeyId = registrationKeyId; @@ -2025,8 +2015,8 @@ export function mcpServerInputWithOAuthGrant( allowedTools: server.allowedTools, execution: server.execution, exposure: server.exposure, - approvalDefault: server.approvalDefault, - deferLoadingDefault: server.deferLoadingDefault, + approval: server.approval, + deferLoading: server.deferLoading, allowPrivateNetwork: server.allowPrivateNetwork, authPolicy: server.authPolicy, credential: { type: "authGrant", grantId }, diff --git a/platform/server/src/routes/setups.ts b/platform/server/src/routes/setups.ts index 4e7544d8..0351627c 100644 --- a/platform/server/src/routes/setups.ts +++ b/platform/server/src/routes/setups.ts @@ -362,7 +362,7 @@ async function ensureMcpServer( description: "Configure and operate this Lightspeed universe through its generated API.", execution: "native", exposure: "search", - approvalDefault: "never", + approval: "never", allowPrivateNetwork, ...auth, status: "active", diff --git a/platform/web/src/api.ts b/platform/web/src/api.ts index 0b7d7d80..0b2bc4a6 100644 --- a/platform/web/src/api.ts +++ b/platform/web/src/api.ts @@ -9,10 +9,7 @@ import type { EnvironmentRegistrationKeyView, EnvironmentTemplateView, EnvironmentView, - ProfileEnvironment as ProfileEnvironmentView, - ProfileEnvironmentCredential as ProfileEnvironmentCredentialView, ProfileSessionRetention as ProfileSessionRetentionView, - SessionEnvironmentOverride as SessionEnvironmentOverrideView, SessionEventView, SessionEventsReadResponse, ToolCallDisplayView, @@ -155,16 +152,12 @@ export interface ProfileSummary { updatedAtMs: number; } -export type ProfileEnvironment = ProfileEnvironmentView; -export type ProfileEnvironmentCredential = ProfileEnvironmentCredentialView; export type ProfileSessionRetention = ProfileSessionRetentionView; -export type SessionEnvironmentOverride = SessionEnvironmentOverrideView; export type ProfileDocument = { profileId: string; metadata?: Record; retention?: ProfileSessionRetention | null; - environment?: ProfileEnvironment | null; revision?: number; createdAtMs?: number; updatedAtMs?: number; @@ -177,7 +170,6 @@ export type InlineProfile = { instructions?: | { type: "text"; text: string } | { type: "textRef"; blobRef: string }; - environment?: ProfileEnvironment | null; }; export type ProfileSource = @@ -333,8 +325,8 @@ export interface McpServer { allowedTools?: string[] | null; execution: "provider" | "native"; exposure: "inject" | "search"; - approvalDefault: "always" | "never"; - deferLoadingDefault?: boolean | null; + approval: "always" | "never"; + deferLoading?: boolean | null; allowPrivateNetwork: boolean; authPolicy: { type: string } & Record; credential?: { type: "authGrant"; grantId: string } | null; @@ -490,24 +482,11 @@ export interface SessionView { export type SessionRunView = RunSummaryView; export type SessionRunStatus = RunStatus; -export type WorkspaceLinkTarget = - | { type: "workspace"; workspaceId: string } - | { type: "snapshot"; snapshotRef: string }; - -export interface WorkspaceLink { - path: string; - access: "readOnly" | "readWrite"; - target: WorkspaceLinkTarget; -} - -export type WorkspaceLinkDraft = { +export type WorkspaceAttachmentDraft = { path?: string; access?: string; - target?: { - type?: string; - workspaceId?: string; - snapshotRef?: string; - } & Record; + workspaceId?: string; + snapshotRef?: string; } & Record; export interface SessionInstructionState { diff --git a/platform/web/src/components/bot/detail.test.ts b/platform/web/src/components/bot/detail.test.ts index c7b0caaa..1eabcc0d 100644 --- a/platform/web/src/components/bot/detail.test.ts +++ b/platform/web/src/components/bot/detail.test.ts @@ -122,10 +122,7 @@ describe("setup summaries", () => { expect(otherBotsSummary(false, "off")).toBe("cannot send · receives from nobody"); }); it("names the environment", () => { - expect(environmentSummary(undefined)).toBe("No environment"); - expect(environmentSummary({ type: "provision", providerId: "incus", templateId: "t", retention: "closeWithSession" })).toBe( - "A fresh environment per session", - ); - expect(environmentSummary({ type: "existing", environmentId: "env-1" })).toBe("env-1"); + expect(environmentSummary(undefined)).toBe("No default environment"); + expect(environmentSummary({ features: { environments: { environments: [{ environmentId: "env-1", access: "read", default: true }] } } })).toBe("env-1"); }); }); diff --git a/platform/web/src/components/bot/session-profile.test.ts b/platform/web/src/components/bot/session-profile.test.ts index 46f5531e..0b0381d4 100644 --- a/platform/web/src/components/bot/session-profile.test.ts +++ b/platform/web/src/components/bot/session-profile.test.ts @@ -9,18 +9,16 @@ describe("bot session profile saves", () => { createdAtMs: 10, updatedAtMs: 20, description: "shared profile", - config: { features: { web: { search: {} } } }, - environment: { type: "existing", environmentId: "old-box" }, + config: { features: { environments: { environments: [{ environmentId: "old-box", access: "read", default: true }] } } }, metadata: { owner: "ops" }, }, { - environment: { type: "existing", environmentId: "new-box" }, + config: { features: { environments: { environments: [{ environmentId: "new-box", access: "jobs", default: true }] } } }, retention: { deleteAfterCloseMs: 86_400_000 }, })).toEqual({ profileId: "triage", revision: 4, description: "shared profile", - config: { features: { web: { search: {} } } }, - environment: { type: "existing", environmentId: "new-box" }, + config: { features: { environments: { environments: [{ environmentId: "new-box", access: "jobs", default: true }] } } }, metadata: { owner: "ops" }, retention: { deleteAfterCloseMs: 86_400_000 }, }); @@ -31,11 +29,11 @@ describe("bot session profile saves", () => { profileId: "triage", revision: 2, instructions: { type: "text", text: "old" }, - environment: { type: "existing", environmentId: "ops-box" }, + config: { features: { environments: { environments: [{ environmentId: "ops-box", access: "exec", default: true }] } } }, metadata: { team: "ops" }, }, { instructions: undefined, - environment: undefined, + config: undefined, })).toEqual({ profileId: "triage", revision: 2, diff --git a/platform/web/src/components/bot/session-profile.ts b/platform/web/src/components/bot/session-profile.ts index 70a80ca7..cb1424f3 100644 --- a/platform/web/src/components/bot/session-profile.ts +++ b/platform/web/src/components/bot/session-profile.ts @@ -1,9 +1,8 @@ -import type { ProfileDocument, ProfileEnvironment, ProfileSessionRetention } from "@/api"; +import type { ProfileDocument, ProfileSessionRetention } from "@/api"; export type SessionProfileFields = { config?: Record | undefined; instructions?: { type: "text"; text: string } | undefined; - environment?: ProfileEnvironment | undefined; metadata?: Record | undefined; retention?: ProfileSessionRetention | undefined; }; diff --git a/platform/web/src/components/bot/setup-summary.ts b/platform/web/src/components/bot/setup-summary.ts index cf55d9e5..b2e1eaf9 100644 --- a/platform/web/src/components/bot/setup-summary.ts +++ b/platform/web/src/components/bot/setup-summary.ts @@ -1,4 +1,5 @@ -import type { BotView, Environment, ProfileEnvironment } from "@/api"; +import { defaultEnvironmentAttachment } from "@/lib/sessions/resource-features"; +import type { BotView, Environment } from "@/api"; import { describeIdlePolicy } from "@/components/environment/power-controls"; const FEATURE_LABELS: Record = { @@ -24,17 +25,17 @@ export function capabilitySummary(config: Record | undefined): } export function environmentSummary( - environment: ProfileEnvironment | null | undefined, + config: unknown, environments?: Environment[], ): string { - if (!environment) return "No environment"; - if (environment.type === "existing") { + const environment = defaultEnvironmentAttachment(config); + if (!environment) return "No default environment"; + if (environment.environmentId) { const current = environments?.find((entry) => entry.environmentId === environment.environmentId); const name = current?.displayName ?? environment.environmentId; const policy = current?.idlePolicy ? ` · ${describeIdlePolicy(current.idlePolicy)}` : ""; return `${name}${current ? ` · ${current.status}` : ""}${policy}`; } - if (environment.type === "provision") return "A fresh environment per session"; return "Inherits the session's environment"; } diff --git a/platform/web/src/components/bot/setup.tsx b/platform/web/src/components/bot/setup.tsx index 99ba9cb5..c1ec4ba6 100644 --- a/platform/web/src/components/bot/setup.tsx +++ b/platform/web/src/components/bot/setup.tsx @@ -1,3 +1,4 @@ +import { defaultEnvironmentAttachment } from "@/lib/sessions/resource-features"; import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowUpRight, ChevronRight, SlidersHorizontal } from "lucide-react"; @@ -15,7 +16,6 @@ import { type BotView, type Environment, type ProfileDocument, - type ProfileEnvironment, type ProfileSummary, } from "@/api"; import { @@ -42,7 +42,6 @@ import { } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; -import { ProfileEnvironmentEditor } from "@/components/session/profile-environment-editor"; import { MetadataMapEditor } from "@/components/session/metadata-editor"; import { ProfileRetentionEditor } from "@/components/session/profile-retention-editor"; import { SessionConfigEditor } from "@/components/session/session-config-editor"; @@ -103,14 +102,13 @@ export function BotSetup({ api<{ triggers?: BotTriggerView[] }>("GET", `/api/v1/universes/${universeId}/bots/${bot.botId}/triggers`), }); const accounts = useChannelAccounts(universeId); + const defaultEnvironmentId = defaultEnvironmentAttachment(profile.data?.config)?.environmentId; const env: BotEnvStatus = profile.isLoading || profile.isError || profile.data === undefined ? { kind: "unknown" } - : profile.data.environment == null - ? { kind: "none" } - : profile.data.environment.type === "existing" - ? { kind: "existing", environmentId: profile.data.environment.environmentId } - : { kind: "provision" }; + : defaultEnvironmentId + ? { kind: "existing", environmentId: defaultEnvironmentId } + : { kind: "none" }; const triggerList = triggers.data?.triggers ?? []; const wakeups = triggerList.filter((trigger) => trigger.kind !== "bot"); const triggersLine = @@ -410,7 +408,6 @@ function SessionProfileSection({ const [configDraft, setConfigDraft] = useState | undefined>(); const [instructionsDraft, setInstructionsDraft] = useState(""); - const [environmentDraft, setEnvironmentDraft] = useState(); const [metadataDraft, setMetadataDraft] = useState | undefined>(); const [retentionDraft, setRetentionDraft] = useState(); const [configError, setConfigError] = useState(null); @@ -421,7 +418,6 @@ function SessionProfileSection({ setConfigDraft(profile.config ? structuredClone(profile.config as Record) : undefined); const instructions = profile.instructions as { type: "text"; text: string } | { type: "textRef" } | undefined; setInstructionsDraft(instructions?.type === "text" ? instructions.text : ""); - setEnvironmentDraft(profile.environment ?? undefined); setMetadataDraft(profile.metadata ? structuredClone(profile.metadata) : undefined); setRetentionDraft(profile.retention?.deleteAfterCloseMs); // Re-sync on a new revision only; an unrelated refetch must not wipe edits. @@ -435,8 +431,6 @@ function SessionProfileSection({ ? ((profile?.instructions as { text: string }).text ?? "") : ""; const instructionsDirty = profile !== undefined && instructionsDraft !== baseInstructions; - const environmentDirty = - profile !== undefined && JSON.stringify(environmentDraft ?? null) !== JSON.stringify(profile.environment ?? null); const metadataDirty = profile !== undefined && JSON.stringify(metadataDraft ?? null) !== JSON.stringify(profile.metadata ?? null); const retentionDirty = @@ -458,12 +452,13 @@ function SessionProfileSection({ ]); }, }); - const merged = { ...(profile ?? {}), config: configDraft, environment: environmentDraft }; + const merged = { ...(profile ?? {}), config: configDraft }; const closed = bot.closedAtMs != null; const readOnly = !manage || closed; + const defaultEnvironment = defaultEnvironmentAttachment(configDraft); const capabilities = capabilitySummary(profileConfig); const environment = hasSessionFeature(profileConfig, "environments") - ? environmentSummary(profile?.environment, environments.data) + ? environmentSummary(profileConfig, environments.data) : null; const textRef = (profile?.instructions as { type?: string } | undefined)?.type === "textRef"; @@ -507,31 +502,12 @@ function SessionProfileSection({ workspacesLoading={options.workspacesLoading} models={options.models} profiles={options.profiles} - environmentProviders={options.environmentProviders} + environments={options.environments} + mcpToolDiscovery={options.mcpToolDiscovery} featureDisableReasons={resourceFeatureDisableReasons(merged)} - environmentSetup={( -
- - {environmentDraft?.type === "existing" && ( - - )} -
- )} + environmentSetup={defaultEnvironment?.environmentId ? ( + + ) : undefined} metadataSetup={( )} @@ -550,7 +526,7 @@ function SessionProfileSection({ /> {manage && ( { + const React = await import("react"); + const Part = () => null; + return { + Select: ({ + value, + onValueChange, + children, + }: { + value: string; + onValueChange: (value: string) => void; + children: React.ReactNode; + }) => { + const options: { value: string; label: React.ReactNode }[] = []; + const visit = (children: React.ReactNode) => + React.Children.forEach(children, (child) => { + if (!React.isValidElement(child)) return; + const props = child.props as { + value?: string; + children?: React.ReactNode; + }; + if (props.value !== undefined) + options.push({ value: props.value, label: props.children }); + else if (props.children) visit(props.children); + }); + visit(children); + return ( + + ); + }, + SelectContent: Part, + SelectItem: Part, + SelectTrigger: Part, + SelectValue: Part, + }; +}); + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +Object.assign(window, { PointerEvent: MouseEvent }); +let root: ReturnType; +let container: HTMLDivElement; +let selected: string[] | undefined; +type Props = ComponentProps; +afterEach(async () => { + await act(async () => root?.unmount()); + container?.remove(); +}); + +function Harness(props: Omit) { + const [value, setValue] = useState(props.value); + selected = value; + return ; +} + +async function setup(props: Omit) { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => root.render()); +} + +async function button(name: string) { + const target = [ + ...container.querySelectorAll("button"), + ].find((button) => button.textContent === name)!; + expect(target).toBeDefined(); + await act(async () => target.click()); +} + +async function mode(label: string) { + const select = container.querySelector("select")!; + const option = [...select.options].find( + (option) => option.textContent === label, + )!; + expect(option).toBeDefined(); + await act(async () => { + select.value = option.value; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +async function checkbox(name: string) { + const target = container.querySelector( + `[role="checkbox"][aria-label="${name}"]`, + )!; + expect(target).not.toBeNull(); + await act(async () => target.click()); +} + +it.each(["server", "session"] as const)( + "shares metadata, search, and selection drafts in the %s picker", + async (scope) => { + const discover = vi.fn(async (): Promise => ({ + status: "success", + tools: [ + { + name: "search", + title: "Find issues", + description: "Locate tickets by keyword.", + annotations: { readOnlyHint: true }, + }, + { + name: "delete", + title: "Delete issues", + annotations: { destructiveHint: true }, + }, + ], + })); + await setup({ + scope, + serverId: "catalog", + allowedTools: ["search"], + source: { universeId: "test", discover }, + }); + if (scope === "session") { + expect(discover).not.toHaveBeenCalled(); + await button("Customize tools"); + } else { + expect(container.textContent).not.toContain("Customize tools"); + expect(container.textContent).not.toContain("Hide tool settings"); + } + expect(discover).toHaveBeenCalledWith("catalog"); + expect(container.textContent).toContain("Find issues"); + expect(container.textContent).toContain("read only"); + if (scope === "session") + expect(container.textContent).not.toContain("Delete issues"); + else expect(container.textContent).toContain("destructive"); + const search = container.querySelector( + '[aria-label="Search MCP tools"]', + )!; + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!.call(search, "tickets"); + search.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(container.textContent).toContain("Find issues"); + expect(container.textContent).not.toContain("Delete issues"); + await mode("Selected tools"); + expect(selected).toEqual([]); + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + "Select at least one tool", + ); + await checkbox("search"); + expect(selected).toEqual(["search"]); + await mode( + scope === "server" ? "All advertised tools" : "All server-allowed tools", + ); + expect(selected).toBeUndefined(); + await mode("Selected tools"); + expect(selected).toEqual(["search"]); + expect(discover).toHaveBeenCalledTimes(1); + }, +); + +it.each(["server", "session"] as const)( + "keeps unavailable %s selections removable", + async (scope) => { + await setup({ + scope, + serverId: "catalog", + value: ["missing", "denied"], + allowedTools: ["missing"], + source: { + universeId: "test", + discover: async () => ({ + status: "success", + tools: [{ name: "denied" }], + }), + }, + }); + expect(container.textContent).toContain("Not currently advertised"); + if (scope === "session") + expect(container.textContent).toContain("No longer allowed"); + await checkbox("missing"); + expect(selected).toEqual(["denied"]); + await checkbox("denied"); + expect(selected).toEqual([]); + if (scope === "session") + expect(container.querySelector('[aria-label="denied"]')).toBeNull(); + }, +); + +it("preserves selections through structured failures, transport errors, and refreshed inventory", async () => { + const discover = vi + .fn<() => Promise>() + .mockResolvedValueOnce({ + status: "failure", + code: "additionalConsentRequired", + message: "Consent needed.", + requiredScopes: ["issues:read"], + }) + .mockRejectedValueOnce(new Error("Server unavailable")) + .mockResolvedValueOnce({ + status: "success", + tools: [{ name: "search" }, { name: "new_tool" }], + }); + await setup({ + scope: "session", + serverId: "catalog", + value: ["search"], + source: { universeId: "test", discover }, + }); + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + "Reconnect", + ); + expect(container.textContent).toContain("issues:read"); + expect(container.textContent).toContain("Not verified"); + expect(selected).toEqual(["search"]); + await button("Refresh tools"); + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + "Server unavailable", + ); + expect(selected).toEqual(["search"]); + await button("Refresh tools"); + expect( + container + .querySelector('[aria-label="new_tool"]') + ?.getAttribute("aria-checked"), + ).toBe("false"); + expect(selected).toEqual(["search"]); +}); + +function deferred() { + let resolve!: (result: McpToolDiscovery) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +it.each(["universe", "server", "revision"])( + "discards discovery from an earlier %s", + async (change) => { + const first = deferred(); + const second = deferred(); + const discover = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const props: Omit = { + scope: "session", + serverId: "catalog", + revision: 1, + value: ["search"], + source: { universeId: "first", discover }, + }; + await setup(props); + const next = { + ...props, + ...(change === "universe" + ? { source: { universeId: "second", discover } } + : {}), + ...(change === "server" ? { serverId: "other" } : {}), + ...(change === "revision" ? { revision: 2 } : {}), + }; + await act(async () => root.render()); + await act(async () => + first.resolve({ + status: "failure", + code: "unauthorized", + message: "Stale failure", + }), + ); + expect(container.textContent).not.toContain("Stale failure"); + await act(async () => + second.resolve({ status: "success", tools: [{ name: "current_tool" }] }), + ); + expect(container.textContent).toContain("current_tool"); + expect(selected).toEqual(["search"]); + }, +); + +it("blocks discovery for unsaved connections and discards an in-flight observation when edited", async () => { + const request = deferred(); + const discover = vi + .fn() + .mockReturnValueOnce(request.promise) + .mockResolvedValueOnce({ + status: "success", + tools: [{ name: "saved_connection" }], + }); + const props: Omit = { + scope: "server", + serverId: "catalog", + value: ["search"], + source: { universeId: "test", discover }, + discoveryDisabledReason: "Save connection changes first.", + }; + await setup(props); + expect(discover).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Save connection changes first."); + await act(async () => + root.render(), + ); + expect(discover).toHaveBeenCalledTimes(1); + await act(async () => root.render()); + await act(async () => + request.resolve({ status: "success", tools: [{ name: "old_connection" }] }), + ); + expect(container.textContent).not.toContain("old_connection"); + await act(async () => + root.render(), + ); + expect(container.textContent).toContain("saved_connection"); + expect(selected).toEqual(["search"]); +}); diff --git a/platform/web/src/components/mcp/tool-picker.tsx b/platform/web/src/components/mcp/tool-picker.tsx new file mode 100644 index 00000000..a9eff13c --- /dev/null +++ b/platform/web/src/components/mcp/tool-picker.tsx @@ -0,0 +1,339 @@ +import { useEffect, useId, useState } from "react"; +import { RotateCcw, Search } from "lucide-react"; +import type { McpAdvertisedTool } from "@/api"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + mcpDiscoveryFailureAction, + useMcpToolDiscovery, + type McpToolDiscoverySource, +} from "@/lib/mcp/tool-discovery"; + +type Props = { + scope: "server" | "session"; + serverId: string; + revision?: number; + source?: McpToolDiscoverySource; + allowedTools?: string[] | null; + value?: string[]; + onChange: (tools: string[] | undefined) => void; + discoveryDisabledReason?: string; +}; + +export function McpToolPicker(props: Props) { + return ( + + ); +} + +function ToolPicker({ + scope, + serverId, + revision, + source, + allowedTools, + value, + onChange, + discoveryDisabledReason, +}: Props) { + const id = useId(); + const limited = value !== undefined; + const [expanded, setExpanded] = useState(limited); + const open = scope === "server" || expanded; + const [selectionDraft, setSelectionDraft] = useState(value ?? []); + const [search, setSearch] = useState(""); + useEffect(() => { + if (value !== undefined) setSelectionDraft(value); + }, [value]); + const discovery = useMcpToolDiscovery({ + source, + serverId, + revision, + enabled: open, + disabled: Boolean(discoveryDisabledReason), + }); + const result = discovery.result; + const advertised = new Map( + (result?.status === "success" ? result.tools : []).map((tool) => [ + tool.name, + tool, + ]), + ); + const allowed = (name: string) => + scope === "server" || allowedTools == null || allowedTools.includes(name); + const names = [ + ...new Set([ + ...[...advertised.keys()].filter(allowed), + ...(scope === "session" ? (allowedTools ?? []) : []), + ...(value ?? []), + ]), + ].sort((left, right) => left.localeCompare(right)); + const query = search.trim().toLocaleLowerCase(); + const visible = names.filter((name) => { + const tool = advertised.get(name); + return `${name} ${tool?.title ?? ""} ${tool?.description ?? ""}` + .toLocaleLowerCase() + .includes(query); + }); + const allLabel = + scope === "server" ? "All advertised tools" : "All server-allowed tools"; + const title = scope === "server" ? "Allowed tools" : "Tools"; + return ( +
+
+ +

+ {scope === "server" + ? "Sets the tools available to every profile and session using this server." + : "Use the server’s allowance, or narrow it for this profile or session."} +

+ {scope === "session" && ( +
+ + {!open && ( + + · {limited ? `${value.length} tools selected` : allLabel} + + )} +
+ )} +
+ {open && ( +
+ +

+ {limited + ? `${value.length} selected. Only these names are included, even if new tools appear.` + : scope === "server" + ? "Includes tools this server advertises in the future." + : "Includes tools this server allows in the future."} +

+ {limited && value.length === 0 && ( +

+ Select at least one tool, or choose {allLabel.toLowerCase()}. +

+ )} +
+
+ + setSearch(event.target.value)} + placeholder="Search tools" + aria-label="Search MCP tools" + className="pl-8" + /> +
+ {source && ( + + )} +
+ {discoveryDisabledReason && ( +

+ {discoveryDisabledReason} +

+ )} + {discovery.error && ( +

+ {discovery.error} +

+ )} + {result?.status === "failure" && ( +
+

{result.message}

+

+ {mcpDiscoveryFailureAction(result.code)} + {!!result.requiredScopes?.length && + ` Required scopes: ${result.requiredScopes.join(", ")}.`} +

+
+ )} +
+ {visible.map((name) => ( + + onChange( + checked + ? [...new Set([...(value ?? []), name])] + : (value ?? []).filter((tool) => tool !== name), + ) + } + /> + ))} + {!visible.length && ( +

+ {query + ? "No tools match your search." + : discovery.loading + ? "Loading available tools…" + : result?.status === "success" + ? "No tools available with this server’s allowance." + : "Load tools to view the available inventory."} +

+ )} +
+

+ Descriptions and badges are supplied by the server; they do not + grant access. +

+
+ )} +
+ ); +} + +function ToolRow({ + name, + tool, + selected, + status, + disabled, + onChange, +}: { + name: string; + tool?: McpAdvertisedTool; + selected?: boolean; + status?: string; + disabled: boolean; + onChange: (checked: boolean) => void; +}) { + const id = useId(); + return ( +
+ {selected !== undefined && ( + onChange(checked === true)} + /> + )} +
+ + {tool?.title && tool.title !== name && ( + + {name} + + )} +
+ {status && ( + + {status} + + )} + {tool?.annotations?.readOnlyHint === true && ( + read only + )} + {tool?.annotations?.readOnlyHint === false && ( + may write + )} + {tool?.annotations?.destructiveHint === true && ( + destructive + )} + {tool?.annotations?.idempotentHint === true && ( + idempotent + )} + {tool?.annotations?.openWorldHint === true && ( + external access + )} +
+ {tool?.description && ( +

+ {tool.description} +

+ )} +
+
+ ); +} diff --git a/platform/web/src/components/session/profile-environment-editor.tsx b/platform/web/src/components/session/profile-environment-editor.tsx deleted file mode 100644 index 43d3d0c4..00000000 --- a/platform/web/src/components/session/profile-environment-editor.tsx +++ /dev/null @@ -1,570 +0,0 @@ -import { Plus, Trash2 } from "lucide-react"; -import { SetupEditorSection } from "@/components/session/setup-editor-section"; -import { Button } from "@/components/ui/button"; -import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import type { ProfileEnvironment, ProfileEnvironmentCredential, SecretsInventory } from "@/api"; -import { - environmentCredentialOptions, - environmentCredentialSourceFromValue, - environmentCredentialSourceLabel, - environmentCredentialSourceValue, -} from "@/lib/environment-credentials"; -import { isTerminalEnvironmentStatus, selectableEnvironments } from "@/lib/sessions/resource-features"; -import { IdlePolicyFields } from "@/components/environment/idle-policy-fields"; - -export type EnvironmentOption = { - environmentId: string; - displayName?: string | null; - incarnation: { - providerTargetId?: string | null; - templateId?: string | null; - }; - status?: string; - /// Present on core environment views; registered environments show their - /// identity mode so an ephemeral pick can be flagged. - source?: { type: string; identityMode?: string }; -}; - -function isEphemeralRegistered(environment: EnvironmentOption | undefined): boolean { - return environment?.source?.type === "registered" && environment.source.identityMode === "ephemeral"; -} - -export type ProviderBindingOption = { - bindingId: string; - providerId: string; - status: "enabled" | "disabled"; -}; - -export type TemplateOption = { - templateId: string; - providerId: string; - bindingId: string; - displayName: string; - deprecated: boolean; -}; - -type Mode = "none" | "existing" | "provision" | "inherit"; - -const NONE = "__no_profile_environment__"; - -/// Profile environment intent: leave the session's selection alone, -/// activate an existing universe environment, or provision a fresh one for the -/// session from a provider template. -export function ProfileEnvironmentEditor({ - value, - environments: allEnvironments = [], - bindings = [], - templates = [], - secrets, - disabled = false, - embedded = false, - title = "Environment", - description = "How the session obtains its active environment when this profile is applied.", - onChange, -}: { - value?: ProfileEnvironment | null; - environments?: EnvironmentOption[]; - bindings?: ProviderBindingOption[]; - templates?: TemplateOption[]; - /// Universe secrets inventory for the provision credentials picker. - secrets?: SecretsInventory; - disabled?: boolean; - /** Render inside the Environments capability panel instead of as its own section. */ - embedded?: boolean; - title?: string; - description?: string; - onChange: (environment: ProfileEnvironment | undefined) => void; -}) { - const mode: Mode = value?.type ?? "none"; - const environments = selectableEnvironments( - allEnvironments, - value?.type === "existing" ? value.environmentId : undefined, - ); - const providerIds = [...new Set([ - ...bindings.map((binding) => binding.providerId), - ...(value?.type === "provision" ? [value.providerId] : []), - ])]; - - const content = ( - <> - {disabled ? ( -

- Enable Environment access above to select or provision an environment. -

- ) : ( -
- - Mode - - - - {value?.type === "existing" && ( - - onChange(environmentId ? { type: "existing", environmentId } : undefined) - } - /> - )} - - {value?.type === "inherit" && ( - - Applied only when this profile runs as a sub-agent: the child activates the delegating - parent's environment (shared, never copied, never closed by the child). - - )} - - {value?.type === "provision" && ( - - )} -
- )} - - ); - - if (embedded) { - return ( -
-
-

Session environment

-

{description}

-
- {content} -
- ); - } - - return ( - - {content} - - ); -} - -function modeLabel(mode: Mode): string { - switch (mode) { - case "none": - return "Do not change the active environment"; - case "existing": - return "Activate an existing environment"; - case "provision": - return "Provision a new environment for the session"; - case "inherit": - return "Inherit the parent's active environment (sub-agents only)"; - } -} - -function ExistingEnvironmentField({ - value, - environments, - onChange, -}: { - value: string; - environments: EnvironmentOption[]; - onChange: (environmentId: string | undefined) => void; -}) { - const ids = [...new Set([ - ...environments.map((environment) => environment.environmentId), - ...(value ? [value] : []), - ])]; - const selected = value - ? environments.find((environment) => environment.environmentId === value) - : undefined; - const unavailable = Boolean(value) && !selected; - const closed = isTerminalEnvironmentStatus(selected?.status); - return ( - - Environment - - - {unavailable - ? "This saved environment is no longer available." - : closed - ? "This saved environment is closed and can no longer be activated." - : isEphemeralRegistered(selected) - ? "This is an ephemeral registered environment: it closes on its own once its daemon has been away longer than its key's disconnect grace, and sessions that name it will then fail to start. Prefer a persistent key for anything a profile or bot points at." - : "The profile activates this environment and never closes it; a bot's sessions share it this way. Whether it sleeps while idle is the environment's own idle policy, set on the Environments page. Closed environments are not offered."} - - - ); -} - -function ProvisionFields({ - value, - providerIds, - bindings, - templates, - secrets, - onChange, -}: { - value: Extract; - providerIds: string[]; - bindings: ProviderBindingOption[]; - templates: TemplateOption[]; - secrets?: SecretsInventory; - onChange: (environment: ProfileEnvironment) => void; -}) { - const binding = bindings.find((candidate) => candidate.providerId === value.providerId); - const providerTemplates = templates.filter((template) => template.providerId === value.providerId); - const templateIds = [...new Set([ - ...providerTemplates.map((template) => template.templateId), - ...(value.templateId ? [value.templateId] : []), - ])]; - const templateKnown = providerTemplates.some((template) => template.templateId === value.templateId); - const retention = value.retention ?? "closeWithSession"; - return ( - <> - - Provider - - - {!value.providerId - ? "Providers this universe is bound to." - : !binding - ? "This universe has no binding for the provider; provisioning will be rejected." - : binding.status !== "enabled" - ? `Binding ${binding.bindingId} is disabled; provisioning will be rejected.` - : `Binding ${binding.bindingId}.`} - - - - Template - - - {value.templateId && !templateKnown - ? "This template is not offered by the selected provider." - : "Provider-owned immutable template version."} - - - - Retention - - - One environment is provisioned per session and activated while it boots; environment tools wait until it is ready. - - - - Display name (optional) - { - const displayName = event.target.value; - const next = { ...value }; - if (displayName) next.displayName = displayName; - else delete next.displayName; - onChange(next); - }} - /> - - { - const next = { ...value }; - if (credentials.length) next.credentials = credentials; - else delete next.credentials; - onChange(next); - }} - /> - { - const next = { ...value }; - if (idlePolicy) next.idlePolicy = idlePolicy; - else delete next.idlePolicy; - onChange(next); - }} - /> - - ); -} - -function retentionLabel(retention: string): string { - return retention === "retain" - ? "Retain after the session closes" - : "Close with the session"; -} - -function templateLabel(template: TemplateOption): string { - return `${template.displayName} (${template.templateId})${template.deprecated ? " · deprecated" : ""}`; -} - -function environmentLabel(environment: EnvironmentOption): string { - const status = environment.status && environment.status !== "ready" ? ` — ${environment.status}` : ""; - return `${environment.displayName - ?? environment.incarnation.templateId - ?? environment.incarnation.providerTargetId - ?? environment.environmentId} (${environment.environmentId})${status}`; -} - -const NO_SOURCE = "__no_credential_source__"; - -/// Credentials bound to the provisioned environment right after creation: -/// references to universe secrets, never values. Suggested env -/// names come from the credential (e.g. CLAUDE_CODE_OAUTH_TOKEN). -function ProvisionCredentialsField({ - credentials, - secrets, - onChange, -}: { - credentials: ProfileEnvironmentCredential[]; - secrets?: SecretsInventory; - onChange: (credentials: ProfileEnvironmentCredential[]) => void; -}) { - const options = environmentCredentialOptions(secrets); - const update = (index: number, patch: Partial) => - onChange(credentials.map((c, i) => (i === index ? { ...c, ...patch } : c))); - const remove = (index: number) => onChange(credentials.filter((_, i) => i !== index)); - const duplicates = new Set( - credentials - .map((c) => c.envName) - .filter((name, i, all) => name && all.indexOf(name) !== i), - ); - return ( - - Environment credentials -
- {credentials.map((credential, index) => { - const currentValue = environmentCredentialSourceValue(credential.source); - const known = options.some((option) => option.value === currentValue); - const invalidName = - credential.envName !== "" && !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(credential.envName); - return ( -
-
- update(index, { envName: event.target.value })} - placeholder="ENV_VAR_NAME" - spellCheck={false} - className="font-mono text-xs" - aria-label="Environment variable name" - /> - {(invalidName || duplicates.has(credential.envName)) && ( - - {invalidName ? "Invalid variable name." : "Bound more than once."} - - )} -
- - -
- ); - })} -
- -
-
- - Bound to the environment right after it is provisioned, before activation. References - universe secrets and integrations (never values); they become ordinary environment - credential bindings you can change later under Environments. - {options.length === 0 && " No secrets or integrations are available in this universe yet."} - -
- ); -} diff --git a/platform/web/src/components/session/session-config-editor.test.ts b/platform/web/src/components/session/session-config-editor.test.ts index becfaf4e..79212a81 100644 --- a/platform/web/src/components/session/session-config-editor.test.ts +++ b/platform/web/src/components/session/session-config-editor.test.ts @@ -7,8 +7,10 @@ import { normalizeSessionConfig, SessionConfigEditor, type ModelOption, - workspaceLinksError, - workspaceLinksFromConfig, + workspaceAttachmentsError, + configError, + mcpAttachmentError, + workspaceAttachmentsFromConfig, } from "./session-config-editor"; describe("specific tool choice", () => { @@ -158,93 +160,76 @@ describe("OpenAI processing tier config", () => { }); }); -describe("workspace link config", () => { +describe("workspace attachment config", () => { it("round-trips links inside the VFS feature", () => { const config = normalizeSessionConfig({ features: { vfs: { - tools: "edit", - workspaceLinks: [ + workspaces: [ { path: "/workspace", - access: "readWrite", - target: { type: "workspace", workspaceId: "primary" }, + access: "edit", + workspaceId: "primary" , }, { path: "/skills", - access: "readOnly", - target: { type: "snapshot", snapshotRef: "sha256:skills" }, + access: "read", + snapshotRef: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" , }, ], }, }, }); - expect(workspaceLinksFromConfig(config)).toEqual([ + expect(workspaceAttachmentsFromConfig(config)).toEqual([ { path: "/workspace", - access: "readWrite", - target: { type: "workspace", workspaceId: "primary" }, + access: "edit", + workspaceId: "primary" , }, { path: "/skills", - access: "readOnly", - target: { type: "snapshot", snapshotRef: "sha256:skills" }, + access: "read", + snapshotRef: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" , }, ]); }); - it("omits an empty workspace-link collection from the sparse config", () => { + it("keeps an empty attachment list without granting tools", () => { expect(normalizeSessionConfig({ - features: { vfs: { tools: "edit", workspaceLinks: [] } }, - })).toEqual({ features: { vfs: { tools: "edit" } } }); + features: { vfs: { workspaces: [] } }, + })).toEqual({ features: { vfs: { workspaces: [] } } }); }); it("rejects overlapping paths and writable snapshots", () => { - expect(workspaceLinksError([ + expect(workspaceAttachmentsError([ { path: "/workspace", - access: "readWrite", - target: { type: "workspace", workspaceId: "primary" }, + access: "edit", + workspaceId: "primary" , }, { path: "/workspace/docs", - access: "readOnly", - target: { type: "snapshot", snapshotRef: "sha256:docs" }, + access: "read", + snapshotRef: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" , }, ])).toContain("cannot overlap"); - expect(workspaceLinksError([{ + expect(workspaceAttachmentsError([{ path: "/archive", - access: "readWrite", - target: { type: "snapshot", snapshotRef: "sha256:archive" }, + access: "edit", + snapshotRef: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" , }])).toContain("must be read only"); }); }); describe("environment feature config", () => { - it("preserves independent selection and jobs grants", () => { - expect(normalizeSessionConfig({ - features: { - environments: { - providers: ["sandbox-a"], - tools: "readOnly", - commands: true, - selectionTools: true, - jobs: true, - }, - }, - })).toEqual({ - features: { - environments: { - providers: ["sandbox-a"], - tools: "readOnly", - commands: true, - selectionTools: true, - jobs: true, - }, - }, - }); + it("preserves each attachment's access, default, and working directory", () => { + const environments = { selection: true, environments: [ + { environmentId: "runner", access: "jobs", default: true, workingDirectory: "/project" }, + { environmentId: "logs", access: "read", workingDirectory: "/var/log" }, + ] }; + expect(normalizeSessionConfig({ features: { environments } })).toEqual({ features: { environments } }); }); it("keeps setup collapsed for an initially enabled capability", () => { @@ -290,9 +275,9 @@ describe("environment feature config", () => { })); const labels = [ "Environments", + "Virtual File System: Files, Instructions, Skills", "MCP Servers", "Sub-agents", - "Virtual File System: Files, Instructions, Skills", "Web", "Timers", "Session data", @@ -347,6 +332,12 @@ describe("sub-agent feature config", () => { }); describe("MCP feature config", () => { + it.each([{}, { servers: [] }])("preserves an enabled MCP feature without attachments: %j", (mcp) => { + const config = normalizeSessionConfig({ features: { mcp } }); + expect(config).toEqual({ features: { mcp: { servers: [] } } }); + expect(configError(config!)).toBeNull(); + }); + it("keeps only server selection", () => { expect(normalizeSessionConfig({ features: { @@ -371,24 +362,34 @@ describe("MCP feature config", () => { }); }); -describe("independent skill discovery configuration", () => { - it("preserves default and explicit VFS roots without changing the environment scope", () => { - const environments = { workingDirectory: "/project", skills: { roots: ["/team"] } }; - expect(normalizeSessionConfig({ features: { - vfs: { tools: "edit" }, environments, - } })).toEqual({ features: { vfs: { tools: "edit" }, environments } }); - for (const skills of [{}, { roots: [] }]) { +describe("attachment validation and source discovery", () => { + it("keeps source roots and per-environment directories independent", () => { + const environments = { environments: [{ environmentId: "runner", access: "exec", workingDirectory: "/project" }], skills: { roots: ["/team"] } }; + for (const skills of [{}, { roots: [] }, { roots: ["/workspace/skills"] }]) { expect(normalizeSessionConfig({ features: { vfs: { skills }, environments } })) - .toEqual({ features: { vfs: { skills }, environments } }); + .toEqual({ features: { vfs: { workspaces: [], skills }, environments } }); } - const normalized = normalizeSessionConfig({ features: { - vfs: { skills: { roots: ["/workspace/skills"] } }, environments, - } }); - expect(normalized).toEqual({ features: { vfs: { skills: { roots: ["/workspace/skills"] } }, environments } }); }); -}); - -it.each([undefined, "readOnly", "edit"])("preserves environment file surface %s without granting commands", (tools) => { - const environments = tools ? { tools } : {}; - expect(normalizeSessionConfig({ features: { environments } })).toEqual({ features: { environments } }); + it.each(["read", "edit", "exec", "jobs"])("preserves %s access without separate tool switches", (access) => { + const environments = { environments: [{ environmentId: "machine", access }] }; + expect(normalizeSessionConfig({ features: { environments } })).toEqual({ features: { environments } }); + }); + it("rejects duplicate defaults and ids and restricts inherit to profiles", () => { + const attachment = { environmentId: "one", access: "read", default: true }; + expect(configError({ features: { environments: { environments: [attachment, { ...attachment, environmentId: "two" }] } } })).toContain("one default"); + expect(configError({ features: { environments: { environments: [attachment, { ...attachment, default: false }] } } })).toContain("only once"); + const config = { features: { environments: { environments: [{ inherit: true, default: true, access: "exec" }] } } }; + expect(configError(config)).toContain("sub-agent profiles"); + expect(configError(config, undefined, true)).toBeNull(); + }); + it("keeps incomplete MCP drafts while requiring a nonempty permitted subset", () => { + for (const tools of [[], ["search"]]) { + const config = { features: { mcp: { servers: [{ serverId: "catalog", tools }] } } }; + expect(normalizeSessionConfig(config)).toEqual(config); + if (tools.length) expect(configError(config)).toBeNull(); + else expect(configError(config)).toContain("at least one tool"); + expect(mcpAttachmentError(config, [{ serverId: "catalog", allowedTools: ["search"] }])).toBeNull(); + } + expect(mcpAttachmentError({ features: { mcp: { servers: [{ serverId: "catalog", tools: ["delete"] }] } } }, [{ serverId: "catalog", allowedTools: ["search"] }])).toContain("not allowed"); + }); }); diff --git a/platform/web/src/components/session/session-config-editor.tsx b/platform/web/src/components/session/session-config-editor.tsx index 39468702..c1f35566 100644 --- a/platform/web/src/components/session/session-config-editor.tsx +++ b/platform/web/src/components/session/session-config-editor.tsx @@ -1,5 +1,5 @@ import { useEffect, useId, useState, type ReactNode } from "react"; -import type { WorkspaceLinkDraft } from "@/api"; +import type { WorkspaceAttachmentDraft } from "@/api"; import { ChevronDown, ChevronRight, @@ -42,6 +42,9 @@ import { import { Switch } from "@/components/ui/switch"; import { supportsOpenAiProcessingTier } from "@/lib/sessions/run-options"; import { cn } from "@/lib/utils"; +import { resourceFeatureDisableReasons, selectableEnvironments } from "@/lib/sessions/resource-features"; +import { McpToolPicker } from "@/components/mcp/tool-picker"; +import type { McpToolDiscoverySource } from "@/lib/mcp/tool-discovery"; export type SessionConfig = Record; type FeatureName = "vfs" | "web" | "subagents" | "timers" | "environments" | "mcp"; @@ -50,6 +53,8 @@ export type McpServerOption = { serverId: string; displayName?: string | null; status?: "active" | "needsAuthConfig" | "unverified" | "disabled"; + allowedTools?: string[] | null; + revision?: number; }; export type WorkspaceOption = { @@ -76,9 +81,10 @@ export type ProfileOption = { displayName?: string | null; }; -export type EnvironmentProviderOption = { - providerId: string; +export type EnvironmentOption = { + environmentId: string; displayName?: string | null; + status?: string; }; type Props = { @@ -90,7 +96,9 @@ type Props = { workspacesLoading?: boolean; models?: ModelOption[]; profiles?: ProfileOption[]; - environmentProviders?: EnvironmentProviderOption[]; + environments?: EnvironmentOption[]; + allowInherit?: boolean; + mcpToolDiscovery?: McpToolDiscoverySource; featureDisableReasons?: Partial>; environmentSetup?: ReactNode; metadataSetup?: ReactNode; @@ -115,7 +123,7 @@ const featureInfo: Record< > = { vfs: { title: "Virtual File System: Files, Instructions, Skills", - description: "Grant access to workspace-linked files and source instructions or skills from them.", + description: "Grant access to workspace-attached files and source instructions or skills from them.", icon: FolderOpen, }, web: { @@ -147,13 +155,20 @@ const featureInfo: Record< const featureDisplayOrder: FeatureName[] = [ "environments", + "vfs", "mcp", "subagents", - "vfs", "web", "timers", ]; +const environmentAccessDescriptions: Record = { + read: "Includes reading files.", + edit: "Includes reading and editing files.", + exec: "Includes reading and editing files, and running commands.", + jobs: "Includes reading and editing files, running commands, and running durable jobs.", +}; + function record(value: unknown): RecordValue { return value && typeof value === "object" && !Array.isArray(value) ? (value as RecordValue) @@ -273,32 +288,17 @@ export function normalizeSessionConfig(value: unknown): SessionConfig | undefine const feature = record(sourceFeatures[name]); const next: RecordValue = {}; - if (name === "vfs" || name === "environments") { - if (["readOnly", "edit"].includes(string(feature.tools))) next.tools = feature.tools; - if (string(feature.workingDirectory)) next.workingDirectory = feature.workingDirectory; - } if (name === "vfs") { - if (["readOnly", "edit"].includes(string(feature.tools))) next.tools = feature.tools; - if (Array.isArray(feature.workspaceLinks) && feature.workspaceLinks.length) { - next.workspaceLinks = feature.workspaceLinks.map((item) => { - const link = record(item); - const target = record(link.target); - const normalizedTarget: RecordValue = { type: string(target.type) || "workspace" }; - if (normalizedTarget.type === "snapshot") { - normalizedTarget.snapshotRef = string(target.snapshotRef).trim(); - } else { - normalizedTarget.type = "workspace"; - normalizedTarget.workspaceId = string(target.workspaceId).trim(); - } - return { - path: string(link.path).trim(), - access: ["readOnly", "readWrite"].includes(string(link.access)) - ? link.access - : "readWrite", - target: normalizedTarget, - }; - }); - } + if (string(feature.workingDirectory)) next.workingDirectory = feature.workingDirectory; + next.workspaces = Array.isArray(feature.workspaces) ? feature.workspaces.map((item) => { + const attachment = record(item); + return { + path: string(attachment.path).trim(), + access: string(attachment.access) || ("snapshotRef" in attachment ? "read" : "edit"), + ...("workspaceId" in attachment ? { workspaceId: string(attachment.workspaceId).trim() } : {}), + ...("snapshotRef" in attachment ? { snapshotRef: string(attachment.snapshotRef).trim() } : {}), + }; + }) : []; for (const key of ["prompts", "skills"] as const) { const roots = stringList(record(feature[key]).roots).filter(Boolean); if (feature[key] != null) { @@ -332,11 +332,17 @@ export function normalizeSessionConfig(value: unknown): SessionConfig | undefine } } if (name === "environments") { - const providers = stringList(feature.providers).filter(Boolean); - if (providers.length) next.providers = providers; - if (feature.selectionTools === true) next.selectionTools = true; - if (feature.commands === true) next.commands = true; - if (feature.jobs === true) next.jobs = true; + if (feature.selection === true) next.selection = true; + next.environments = Array.isArray(feature.environments) ? feature.environments.map((item) => { + const attachment = record(item); + return { + ...("environmentId" in attachment ? { environmentId: string(attachment.environmentId).trim() } : {}), + ...(attachment.inherit === true ? { inherit: true } : {}), + ...(attachment.default === true ? { default: true } : {}), + access: string(attachment.access) || "read", + ...(string(attachment.workingDirectory) ? { workingDirectory: string(attachment.workingDirectory) } : {}), + }; + }) : []; for (const key of ["skills", "prompts"] as const) { if (feature[key] != null) { const source = record(feature[key]); @@ -349,12 +355,9 @@ export function normalizeSessionConfig(value: unknown): SessionConfig | undefine ? feature.servers.map((item) => { const server = record(item); const serverId = string(server.serverId).trim(); - return { serverId }; + return { serverId, ...(Array.isArray(server.tools) ? { tools: stringList(server.tools) } : {}) }; }) : []; - // Keep an incomplete row while it is being edited. Validation prevents - // saving it, and a completed row is normalized to the thin document. - if (!servers.length) servers.push({ serverId: "" }); next.servers = servers; } @@ -367,7 +370,7 @@ export function normalizeSessionConfig(value: unknown): SessionConfig | undefine return omitEmptyRecord(result); } -function configError(config: SessionConfig | undefined, pinnedApiKind?: string): string | null { +export function configError(config: SessionConfig | undefined, pinnedApiKind?: string, allowInherit = false): string | null { if (!config) return null; const model = record(config.model); if (Object.keys(model).length && !["providerId", "apiKind", "model"].every((key) => string(model[key]))) { @@ -389,12 +392,24 @@ function configError(config: SessionConfig | undefined, pinnedApiKind?: string): if ("mcp" in record(config.features) && (!Array.isArray(mcp.servers) || mcp.servers.some((server) => !string(record(server).serverId)))) { return "Each enabled MCP server needs a server id."; } + const serverIds = new Set(); + for (const item of Array.isArray(mcp.servers) ? mcp.servers : []) { + const server = record(item); + const id = string(server.serverId); + if (serverIds.has(id)) return "Each MCP server may be attached only once."; + serverIds.add(id); + if (Array.isArray(server.tools)) { + if (!server.tools.length) return "Choose at least one tool for each MCP subset, or use all allowed tools."; + const names = stringList(server.tools); + if (names.length !== server.tools.length || names.some((name) => !name.trim()) || new Set(names).size !== names.length) return "MCP tool selections must contain distinct nonempty names."; + } + } const subagents = record(record(config.features).subagents); if ("subagents" in record(config.features) && !subagentProfileIds(subagents.agents).length) { return "Sub-agents require at least one agent profile."; } - const linkError = workspaceLinksError(workspaceLinksFromConfig(config)); - if (linkError) return linkError; + const attachmentError = workspaceAttachmentsError(workspaceAttachmentsFromConfig(config)); + if (attachmentError) return attachmentError; const features = record(config.features); const vfs = record(features.vfs); for (const key of ["skills", "prompts"] as const) { @@ -403,18 +418,35 @@ function configError(config: SessionConfig | undefined, pinnedApiKind?: string): const label = key === "skills" ? "VFS skill" : "VFS prompt"; const roots = stringList(source.roots); if (!roots.length) return `${label} root overrides must not be empty; clear the override to use defaults.`; - const links = workspaceLinksFromConfig(config); + const attachments = workspaceAttachmentsFromConfig(config); if (roots.some((root) => !isCanonicalAbsolutePath(root) - || !links.some((link) => link.path === "/" || root === link.path || root.startsWith(`${link.path}/`)))) { - return `${label} roots must be absolute paths inside workspace links.`; + || !attachments.some((attachment) => attachment.path === "/" || root === attachment.path || root.startsWith(`${attachment.path}/`)))) { + return `${label} roots must be absolute paths inside workspace attachments.`; } } const vfsCwd = string(vfs.workingDirectory); - if (vfsCwd && (!isCanonicalAbsolutePath(vfsCwd) || (vfsCwd !== "/" && !workspaceLinksFromConfig(config).some((link) => link.path === "/" || vfsCwd === link.path || vfsCwd.startsWith(`${link.path}/`))))) { - return "VFS working directory must be / or an absolute path inside a workspace link."; + if (vfsCwd && (!isCanonicalAbsolutePath(vfsCwd) || (vfsCwd !== "/" && !workspaceAttachmentsFromConfig(config).some((attachment) => attachment.path === "/" || vfsCwd === attachment.path || vfsCwd.startsWith(`${attachment.path}/`))))) { + return "VFS working directory must be / or an absolute path inside a workspace attachment."; } const environment = record(features.environments); - if (string(environment.workingDirectory) && !string(environment.workingDirectory).startsWith("/")) return "Environment working directory must be absolute."; + const attachments = Array.isArray(environment.environments) ? environment.environments.map(record) : []; + const ids = new Set(); + let inherited = 0; + let defaults = 0; + for (const attachment of attachments) { + const id = string(attachment.environmentId); + if (attachment.inherit === true) { + if (!allowInherit) return "Inheriting an environment is only available in sub-agent profiles."; + if (attachment.environmentId != null || ++inherited > 1) return "Use at most one inherited environment, without an environment id."; + } else { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id)) return "Each environment attachment needs a valid environment id."; + if (ids.has(id)) return "Each environment may be attached only once."; + ids.add(id); + } + if (attachment.default === true && ++defaults > 1) return "Choose at most one default environment."; + if (!["read", "edit", "exec", "jobs"].includes(string(attachment.access))) return "Choose an access level for each environment."; + if (string(attachment.workingDirectory) && !string(attachment.workingDirectory).startsWith("/")) return "Environment working directory must be absolute."; + } for (const key of ["skills", "prompts"] as const) { const roots = record(environment[key]).roots; if (roots != null && !stringList(roots).length) return "Environment source root overrides must not be empty; clear the override to use defaults."; @@ -422,32 +454,45 @@ function configError(config: SessionConfig | undefined, pinnedApiKind?: string): return null; } -export function workspaceLinksFromConfig(config: unknown): WorkspaceLinkDraft[] { - const links = record(record(record(config).features).vfs).workspaceLinks; - return Array.isArray(links) - ? structuredClone(links.filter((link) => link && typeof link === "object")) as WorkspaceLinkDraft[] +export function workspaceAttachmentsFromConfig(config: unknown): WorkspaceAttachmentDraft[] { + const attachments = record(record(record(config).features).vfs).workspaces; + return Array.isArray(attachments) + ? structuredClone(attachments.filter((attachment) => attachment && typeof attachment === "object")) as WorkspaceAttachmentDraft[] : []; } -export function workspaceLinksError(links: WorkspaceLinkDraft[]): string | null { +export function workspaceAttachmentsError(attachments: WorkspaceAttachmentDraft[]): string | null { const paths: string[] = []; - for (const link of links) { - const path = link.path?.trim() ?? ""; - if (!path) return "Each workspace link needs a session path."; + for (const attachment of attachments) { + const path = attachment.path?.trim() ?? ""; + if (!path) return "Each workspace attachment needs a session path."; if (!isCanonicalAbsolutePath(path)) { - return `Workspace link path must be canonical and absolute: ${path}`; + return `Workspace attachment path must be canonical and absolute: ${path}`; } if (paths.some((existing) => pathsOverlap(existing, path))) { - return `Workspace link paths cannot overlap: ${path}`; + return `Workspace attachment paths cannot overlap: ${path}`; } paths.push(path); - if (link.target?.type === "workspace") { - if (!link.target.workspaceId?.trim()) return `Workspace link ${path} needs a workspace.`; - } else if (link.target?.type === "snapshot") { - if (!link.target.snapshotRef?.trim()) return `Workspace link ${path} needs a snapshot ref.`; - if (link.access !== "readOnly") return `Snapshot link ${path} must be read only.`; - } else { - return `Workspace link ${path} needs a target.`; + if (("workspaceId" in attachment) === ("snapshotRef" in attachment)) return `Workspace attachment ${path} needs exactly one workspace or snapshot.`; + if ("workspaceId" in attachment && !attachment.workspaceId?.trim()) return `Workspace attachment ${path} needs a workspace.`; + if ("snapshotRef" in attachment) { + if (!/^sha256:[a-f0-9]{64}$/.test(attachment.snapshotRef ?? "")) return `Workspace attachment ${path} needs a valid snapshot ref.`; + if (attachment.access !== "read") return `Snapshot attachment ${path} must be read only.`; + } + if (attachment.access !== "read" && attachment.access !== "edit") return `Workspace attachment ${path} needs read or edit access.`; + } + return null; +} + +export function mcpAttachmentError(config: unknown, servers: McpServerOption[]): string | null { + const attachments = record(record(record(config).features).mcp).servers; + for (const item of Array.isArray(attachments) ? attachments : []) { + const attachment = record(item); + const server = servers.find((server) => server.serverId === attachment.serverId); + if (server?.allowedTools != null && Array.isArray(attachment.tools)) { + const denied = attachment.tools.find((name) => !server.allowedTools!.includes(String(name))); + if (denied !== undefined) + return `Tool ${String(denied)} is not allowed by server ${server.serverId}.`; } } return null; @@ -473,7 +518,9 @@ export function SessionConfigEditor({ workspacesLoading = false, models = [], profiles = [], - environmentProviders = [], + environments = [], + allowInherit = false, + mcpToolDiscovery, featureDisableReasons = {}, environmentSetup, metadataSetup, @@ -484,7 +531,7 @@ export function SessionConfigEditor({ className, }: Props) { const config = normalizeSessionConfig(value) ?? {}; - const error = configError(config, pinnedApiKind); + const error = configError(config, pinnedApiKind, allowInherit) ?? mcpAttachmentError(config, mcpServers); const [manualModel, setManualModel] = useState(false); useEffect(() => onValidityChange?.(error), [error, onValidityChange]); @@ -496,14 +543,15 @@ export function SessionConfigEditor({ }; const features = record(config.features); + const disableReasons = { ...featureDisableReasons, ...resourceFeatureDisableReasons({ config }) }; const setFeature = (name: FeatureName, enabled: boolean) => change((next) => { const nextFeatures = record(next.features); if (enabled) { - if (name === "vfs") nextFeatures.vfs = { tools: "edit", prompts: {}, skills: {} }; + if (name === "vfs") nextFeatures.vfs = { workspaces: [], prompts: {}, skills: {} }; else if (name === "web") nextFeatures.web = { search: {}, fetch: {} }; else if (name === "mcp") { - nextFeatures.mcp = { servers: [{ serverId: firstUsableMcpServerId(mcpServers) }] }; + nextFeatures.mcp = { servers: [] }; } else nextFeatures[name] = {}; } else { @@ -549,8 +597,9 @@ export function SessionConfigEditor({ {environmentSetup} @@ -561,7 +610,7 @@ export function SessionConfigEditor({ name={name} enabled={name in features} feature={record(features[name])} - disableReason={featureDisableReasons[name]} + disableReason={disableReasons[name]} expandable={name !== "timers"} onEnabledChange={(enabled) => setFeature(name, enabled)} > @@ -582,7 +631,7 @@ export function SessionConfigEditor({ /> )} {name === "subagents" && patchFeature("subagents", fn)} />} - {name === "mcp" && patchFeature("mcp", fn)} />} + {name === "mcp" && patchFeature("mcp", fn)} />} ))} {(metadataSetup || retentionSetup) && ( @@ -652,18 +701,20 @@ function ExpandableSetupPanel({ } /** - * The environment capability and the environment a session should use are - * separate on the wire, but belong together in the editor. + * Environment attachments define access; the optional child controls manage + * the active selection of an existing session. */ function EnvironmentFeatureEditor({ value, - providers = [], + environments = [], + allowInherit = false, disableReason, children, onChange, }: { value?: unknown; - providers?: EnvironmentProviderOption[]; + environments?: EnvironmentOption[]; + allowInherit?: boolean; disableReason?: string; children?: ReactNode; onChange: (config: SessionConfig | undefined) => void; @@ -678,7 +729,7 @@ function EnvironmentFeatureEditor({ }; const setEnabled = (nextEnabled: boolean) => change((next) => { const nextFeatures = record(next.features); - if (nextEnabled) nextFeatures.environments = { tools: "edit", commands: true, jobs: true, prompts: {}, skills: {} }; + if (nextEnabled) nextFeatures.environments = { environments: [], prompts: {}, skills: {} }; else delete nextFeatures.environments; if (Object.keys(nextFeatures).length) next.features = nextFeatures; else delete next.features; @@ -702,7 +753,8 @@ function EnvironmentFeatureEditor({
{children &&
{children}
} @@ -1209,142 +1261,79 @@ function VfsFields({ workspacesLoading: boolean; patch: (fn: (feature: RecordValue) => void) => void; }) { - const links = Array.isArray(feature.workspaceLinks) - ? feature.workspaceLinks.map(record) + const attachments = Array.isArray(feature.workspaces) + ? feature.workspaces.map(record) : []; const workspaceOptions = new Map(); for (const workspace of workspaces) workspaceOptions.set(workspace.workspaceId, workspace); - for (const link of links) { - const target = record(link.target); - const workspaceId = string(target.workspaceId); - if (target.type === "workspace" && workspaceId && !workspaceOptions.has(workspaceId)) { + for (const attachment of attachments) { + const workspaceId = string(attachment.workspaceId); + if ("workspaceId" in attachment && workspaceId && !workspaceOptions.has(workspaceId)) { workspaceOptions.set(workspaceId, { workspaceId }); } } const options = [...workspaceOptions.values()]; - const updateLinks = (nextLinks: RecordValue[]) => + const updateAttachments = (nextAttachments: RecordValue[]) => patch((next) => { - if (nextLinks.length) next.workspaceLinks = nextLinks; - else delete next.workspaceLinks; + if (nextAttachments.length) next.workspaces = nextAttachments; + else delete next.workspaces; }); - const updateLink = (index: number, mutate: (link: RecordValue) => void) => - updateLinks( - links.map((link, linkIndex) => { - if (linkIndex !== index) return link; - const next = { ...link }; + const updateAttachment = (index: number, mutate: (attachment: RecordValue) => void) => + updateAttachments( + attachments.map((attachment, attachmentIndex) => { + if (attachmentIndex !== index) return attachment; + const next = { ...attachment }; mutate(next); return next; }), ); - const nextPath = nextWorkspaceLinkPath(links); + const nextPath = nextWorkspaceAttachmentPath(attachments); return (
-
- - File tools - - - {!environmentsGranted - ? "Enable Environments to also transfer files between linked workspaces and a selected environment." - : feature.tools === "edit" - ? "Transfers into the environment require environment Edit files; capture requires environment read access and a writable workspace link." - : feature.tools === "readOnly" - ? "Materialize also requires Edit files on the environment. Linked VFS files remain read only through these tools." - : "Choose Read only or Edit files to enable workspace transfer tools. Prompt and skill sourcing alone does not enable transfers."} - - -
- - - - - - -
-
+
+
-

Workspace links

+

Workspace attachments

- Expose catalog workspaces or pinned snapshots at session paths. + Attach workspaces at session paths with their own access grants.

- {links.length === 0 && ( -

No workspace links.

+ {attachments.length === 0 && ( +

No workspace attachments.

)} - {links.map((link, index) => { - const target = record(link.target); - const targetType = string(target.type) || "workspace"; + {attachments.map((attachment, index) => { + const snapshot = "snapshotRef" in attachment; return (
-
- - Target type - - - {targetType === "snapshot" ? ( +
+ {snapshot ? ( Snapshot ref updateLink(index, (next) => { - next.target = { ...record(next.target), type: "snapshot", snapshotRef: event.target.value }; + value={string(attachment.snapshotRef)} + onChange={(event) => updateAttachment(index, (next) => { + next.snapshotRef = event.target.value; })} /> @@ -1353,10 +1342,10 @@ function VfsFields({ Workspace {options.length || workspacesLoading ? ( updateLink(index, (next) => { - next.target = { ...record(next.target), type: "workspace", workspaceId: event.target.value }; + value={string(attachment.workspaceId)} + onChange={(event) => updateAttachment(index, (next) => { + next.workspaceId = event.target.value; })} placeholder="workspace id" /> @@ -1390,8 +1379,8 @@ function VfsFields({ Session path updateLink(index, (next) => { + value={string(attachment.path)} + onChange={(event) => updateAttachment(index, (next) => { next.path = event.target.value; })} /> @@ -1399,16 +1388,16 @@ function VfsFields({ Access @@ -1416,9 +1405,9 @@ function VfsFields({ @@ -1426,12 +1415,20 @@ function VfsFields({ ); })}
+

File tools follow each workspace’s access. {environmentsGranted + ? "Materialize requires environment edit access. Capture requires workspace edit access and environment read access." + : "Enable Environments to also transfer files between attached workspaces and an active environment."}

+ + + + +
); } -function nextWorkspaceLinkPath(links: RecordValue[]): string { - const paths = new Set(links.map((link) => string(link.path))); +function nextWorkspaceAttachmentPath(attachments: RecordValue[]): string { + const paths = new Set(attachments.map((attachment) => string(attachment.path))); if (!paths.has("/workspace")) return "/workspace"; let suffix = 2; while (paths.has(`/workspace-${suffix}`)) suffix += 1; @@ -1664,18 +1661,39 @@ function WorkingDirectoryField({ environment, feature, patch }: { patch: (fn: (feature: RecordValue) => void) => void; }) { const id = useId(); - return - Working directory - patch((next) => { - if (event.target.value) next.workingDirectory = event.target.value; - else delete next.workingDirectory; - })} /> - {environment - ? "Absolute machine directory for file tools, commands, jobs, and discovery. Empty uses the environment default." - : "Absolute linked VFS directory for relative file paths. Empty uses /. Source discovery still searches every workspace link."} - ; + const [open, setOpen] = useState(false); + const directory = string(feature.workingDirectory); + return ( +
+ {environment && ( + + )} + {(!environment || open) && ( + + Working directory + patch((next) => { + if (event.target.value) next.workingDirectory = event.target.value; + else delete next.workingDirectory; + })} /> + {environment + ? "Absolute machine directory for file tools, commands, jobs, and discovery. Empty uses the environment default." + : "Absolute attached VFS directory for relative file paths. Empty uses /. Source discovery still searches every workspace attachment."} + + )} +
+ ); } function SourceDiscoveryFields({ source, feature, patch }: { @@ -1743,10 +1761,10 @@ function SourceDiscoveryFields({ source, feature, patch }: { update("roots", roots.length ? roots : undefined); }} /> - {`Empty searches .agents/${configKey} and .lightspeed/${configKey} ${environment ? "under the working directory and execution user’s home" : "beneath each workspace link"}. `} + {`Empty searches .agents/${configKey} and .lightspeed/${configKey} ${environment ? "under the working directory and execution user’s home" : "beneath each workspace attachment"}. `} {environment ? "Comma-separated overrides replace all defaults, including home. Paths may be absolute or relative to the working directory." - : "Comma-separated overrides replace all defaults and must be absolute paths inside workspace links."} + : "Comma-separated overrides replace all defaults and must be absolute paths inside workspace attachments."}
@@ -1757,182 +1775,243 @@ function SourceDiscoveryFields({ source, feature, patch }: { function EnvironmentFields({ feature, - providers, + environments, + allowInherit, patch, }: { feature: RecordValue; - providers: EnvironmentProviderOption[]; + environments: EnvironmentOption[]; + allowInherit: boolean; patch: (fn: (feature: RecordValue) => void) => void; }) { - const jobsId = useId(); - const commandsId = useId(); - const filesId = useId(); - const selectionToolsId = useId(); - const value = stringList(feature.providers); - const providerMap = new Map(providers.map((provider) => [provider.providerId, provider])); - const items = [ - ...new Set([...providers.map((provider) => provider.providerId), ...value]), - ].sort((left, right) => - providerLabel(providerMap.get(left), left).localeCompare( - providerLabel(providerMap.get(right), right), - ), - ); + const selectionId = useId(); + const attachments = Array.isArray(feature.environments) ? feature.environments.map(record) : []; + const update = (index: number, mutate: (attachment: RecordValue) => void) => + patch((next) => { + next.environments = attachments.map((attachment, position) => { + const value = { ...attachment }; + if (position === index) mutate(value); + return value; + }); + }); return ( -
- - File tools - - Read only allows reading, listing, and searching. Edit also allows file changes and transfers into the environment. Prompts and skills are independent. - -
-
- -

Run commands and continue processes. Commands can modify files regardless of the File tools setting.

-
- patch((next) => { - if (checked) next.commands = true; - else delete next.commands; - })} /> -
-
-
- +
+
+
+

Environment attachments

- Run durable jobs independently of command execution. Jobs can modify files regardless of the File tools setting. + Access applies to the active environment. A default fills an empty selection when a + profile is applied.

- patch((next) => { - if (checked === true) next.jobs = true; - else delete next.jobs; - })} - /> +
+ {!attachments.length && ( +

No environments attached.

+ )} + {attachments.map((attachment, index) => { + const id = string(attachment.environmentId); + const inherited = attachment.inherit === true; + const options = selectableEnvironments(environments, id).filter( + (candidate) => + candidate.environmentId === id || + !attachments.some((item) => item.environmentId === candidate.environmentId), + ); + if (id && !options.some((candidate) => candidate.environmentId === id)) + options.push({ environmentId: id, status: "unavailable" }); + return ( +
+
+ + Environment + + {inherited && ( + + Uses the parent’s active environment captured when the sub-agent starts. + + )} + + + Access + + + {environmentAccessDescriptions[string(attachment.access)]} + + + +
+
+ + patch((next) => { + next.environments = attachments.map((item, position) => { + const value = { ...item }; + if (position === index && checked === true) value.default = true; + else if (position === index || checked === true) delete value.default; + return value; + }); + }) + } + /> + Default environment +
+ update(index, mutate)} + /> +
+ ); + })} -
-
- +
+
+

- Let the model list, activate, and deactivate allowed environments. Reading the active environment is always available. + Let the agent list, activate, and deactivate attached environments.

patch((next) => { - if (checked === true) next.selectionTools = true; - else delete next.selectionTools; - })} + id={selectionId} + checked={feature.selection === true} + onCheckedChange={(checked) => + patch((next) => { + if (checked) next.selection = true; + else delete next.selection; + }) + } />
- - - Allowed providers - patch((next) => { - if (nextValue.length) next.providers = nextValue; - else delete next.providers; - })} - itemToStringLabel={(providerId) => - providerLabel(providerMap.get(providerId), providerId) - } - filter={(providerId, query) => { - const provider = providerMap.get(providerId); - const search = `${providerLabel(provider, providerId)} ${providerId}`.toLocaleLowerCase(); - return search.includes(query.toLocaleLowerCase()); - }} - > - - - {value.map((providerId) => ( - - {providerLabel(providerMap.get(providerId), providerId)} - - ))} - - - - - No matching providers. - - {(providerId: string) => ( - - - - {providerLabel(providerMap.get(providerId), providerId)} - - {providerMap.get(providerId)?.displayName && ( - - {providerId} - - )} - - - )} - - - - - Empty allows every registered provider. Selection resolves live universe environments. - -
); } -function providerLabel( - provider: EnvironmentProviderOption | undefined, - providerId: string, -): string { - return provider?.displayName || providerId; -} - function McpFields({ feature, servers, + discoverySource, patch, }: { feature: RecordValue; servers: McpServerOption[]; + discoverySource?: McpToolDiscoverySource; patch: (fn: (feature: RecordValue) => void) => void; }) { - const links = Array.isArray(feature.servers) ? feature.servers.map(record) : []; + const attachments = Array.isArray(feature.servers) ? feature.servers.map(record) : []; const options = new Map(); for (const server of servers) options.set(server.serverId, server); - for (const link of links) { - const serverId = string(link.serverId); + for (const attachment of attachments) { + const serverId = string(attachment.serverId); if (serverId && !options.has(serverId)) options.set(serverId, { serverId }); } const serverOptions = [...options.values()]; const firstServerId = firstUsableMcpServerId(serverOptions); - const updateLinks = (nextLinks: RecordValue[]) => + const updateAttachments = (nextAttachments: RecordValue[]) => patch((next) => { - next.servers = nextLinks; + next.servers = nextAttachments; }); - const updateLink = (index: number, mutate: (link: RecordValue) => void) => - updateLinks( - links.map((link, linkIndex) => { - if (linkIndex !== index) return link; - const next = { ...link }; + const updateAttachment = (index: number, mutate: (attachment: RecordValue) => void) => + updateAttachments( + attachments.map((attachment, attachmentIndex) => { + if (attachmentIndex !== index) return attachment; + const next = { ...attachment }; mutate(next); return next; }), @@ -1942,18 +2021,19 @@ function McpFields({

- Only declared servers can materialize remote tools. + Attach servers to make their tools available.

- {links.map((link, index) => ( + {!attachments.length &&

No server attachments.

} + {attachments.map((attachment, index) => (
Server {serverOptions.length ? ( updateLink(index, (next) => { next.serverId = e.target.value; })} + value={string(attachment.serverId)} + onChange={(e) => updateAttachment(index, (next) => { next.serverId = e.target.value; delete next.tools; })} /> )} +
+ updateAttachment(index, (next) => { + if (tools === undefined) delete next.tools; + else next.tools = tools; + })} + /> +
diff --git a/platform/web/src/components/session/session-config-skills.test.tsx b/platform/web/src/components/session/session-config-skills.test.tsx index 8dd6e835..fc7085cc 100644 --- a/platform/web/src/components/session/session-config-skills.test.tsx +++ b/platform/web/src/components/session/session-config-skills.test.tsx @@ -2,7 +2,7 @@ import { act, useState } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, expect, it } from "vitest"; -import { SessionConfigEditor, type SessionConfig } from "./session-config-editor"; +import { SessionConfigEditor, type EnvironmentOption, type McpServerOption, type SessionConfig, type WorkspaceOption } from "./session-config-editor"; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); Object.assign(window, { PointerEvent: MouseEvent }); @@ -14,14 +14,14 @@ afterEach(async () => { await act(async () => root?.unmount()); container?.remove(); }); -async function setup(value: SessionConfig) { +async function setup(value: SessionConfig, options: { environments?: EnvironmentOption[]; workspaces?: WorkspaceOption[]; mcpServers?: McpServerOption[] } = {}) { container = document.createElement("div"); document.body.append(container); root = createRoot(container); function Harness() { const [config, setConfig] = useState(value); current = config; - return { error = value; }} />; + return { error = value; }} />; } await act(async () => root.render()); for (const button of container.querySelectorAll("button[aria-expanded]")) { @@ -51,8 +51,35 @@ async function input(label: string, value: string) { field.dispatchEvent(new Event("input", { bubbles: true })); }); } +it("requires explicit MCP attachments and removal before disabling the feature", async () => { + await setup({}, { mcpServers: [{ serverId: "catalog", status: "active" }] }); + await toggle("Enable MCP Servers"); + expect(current).toHaveProperty("features.mcp.servers", []); + expect(container.textContent).toContain("No server attachments."); + expect(error).toBeNull(); + const featureSwitch = container.querySelector('[role="switch"][aria-label="Enable MCP Servers"]')!; + expect(featureSwitch.getAttribute("aria-disabled")).not.toBe("true"); + const add = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Add server")!; + await act(async () => add.click()); + expect(current).toHaveProperty("features.mcp.servers", [{ serverId: "catalog" }]); + expect(featureSwitch.getAttribute("aria-disabled")).toBe("true"); + expect(container.textContent).toContain("Remove the server attachments before disabling this feature."); + await toggle("Enable MCP Servers"); + expect(current).toHaveProperty("features.mcp.servers", [{ serverId: "catalog" }]); + const remove = container.querySelector('[aria-label="Remove MCP server"]')!; + await act(async () => remove.click()); + expect(current).toHaveProperty("features.mcp.servers", []); + expect(error).toBeNull(); + expect(featureSwitch.getAttribute("aria-disabled")).not.toBe("true"); + await toggle("Enable MCP Servers"); + expect(current ?? {}).not.toHaveProperty("features.mcp"); + expect(error).toBeNull(); +}); + it("shares the environment working directory while source overrides remain independent", async () => { - await setup({ features: { environments: { jobs: true }, vfs: { tools: "edit" } } }); + await setup({ features: { environments: { environments: [{ environmentId: "runner", access: "jobs" }] }, vfs: { workspaces: [] } } }); + await expand("Environment working directory"); await input("Environment working directory", "relative"); expect(error).toContain("absolute"); await input("Environment working directory", "/project"); @@ -63,27 +90,96 @@ it("shares the environment working directory while source overrides remain indep await expand("Environment prompt loading"); await input("Environment skill roots", "./skills, /team/skills"); await input("Environment prompt roots", "./prompts"); - expect(current).toMatchObject({ features: { environments: { workingDirectory: "/project", jobs: true, skills: { roots: ["./skills", "/team/skills"] }, prompts: { roots: ["./prompts"] } } } }); + expect(current).toMatchObject({ features: { environments: { environments: [{ environmentId: "runner", access: "jobs", workingDirectory: "/project" }], skills: { roots: ["./skills", "/team/skills"] }, prompts: { roots: ["./prompts"] } } } }); await input("Environment skill roots", ""); expect(current).toHaveProperty("features.environments.skills", {}); await toggle("Environment skill discovery"); expect(current).not.toHaveProperty("features.environments.skills"); expect(current).toHaveProperty("features.environments.prompts.roots", ["./prompts"]); - expect(current).toHaveProperty("features.environments.workingDirectory", "/project"); + expect(current).toHaveProperty("features.environments.environments.0.workingDirectory", "/project"); +}); +it("keeps a saved environment working directory collapsed and preserves it when toggled", async () => { + await setup({ features: { environments: { environments: [ + { environmentId: "runner", access: "exec", workingDirectory: "/project" }, + ] } } }); + expect(container.querySelector('[aria-label="Environment working directory"]')).toBeNull(); + expect(container.textContent).toContain("/project"); + const before = structuredClone(current); + await expand("Environment working directory"); + expect(current).toEqual(before); + await input("Environment working directory", "/work"); + const collapse = container.querySelector('[aria-label="Configure Environment working directory"]')!; + await act(async () => collapse.click()); + expect(container.querySelector('[aria-label="Environment working directory"]')).toBeNull(); + expect(current).toHaveProperty("features.environments.environments.0.workingDirectory", "/work"); + await expand("Environment working directory"); + await input("Environment working directory", ""); + expect(current).not.toHaveProperty("features.environments.environments.0.workingDirectory"); + expect(error).toBeNull(); +}); + +it("adds environments with jobs access, defaults the first, and enables selection on the second", async () => { + await setup({ features: { environments: {} } }, { + environments: ["first", "second", "third"].map((environmentId) => ({ environmentId, status: "ready" })), + }); + const add = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent === "Add environment")!; + await act(async () => add.click()); + expect(current).toHaveProperty("features.environments.environments", [ + { environmentId: "first", access: "jobs", default: true }, + ]); + expect(current).not.toHaveProperty("features.environments.selection"); + await act(async () => add.click()); + expect(current).toHaveProperty("features.environments.selection", true); + await act(async () => add.click()); + expect(current).toHaveProperty("features.environments", { + environments: [ + { environmentId: "first", access: "jobs", default: true }, + { environmentId: "second", access: "jobs" }, + { environmentId: "third", access: "jobs" }, + ], + selection: true, + }); + const label = Array.from(container.querySelectorAll("label")) + .find((label) => label.textContent === "Environment selection tools")!; + await act(async () => document.getElementById(label.htmlFor)!.click()); + const nextDefault = container.querySelector('[aria-label="Default environment 2"]')!; + await act(async () => nextDefault.click()); + expect(current).not.toHaveProperty("features.environments.selection"); + expect(current).toHaveProperty("features.environments.environments.1.default", true); + expect(error).toBeNull(); +}); + +it("adds workspaces without changing an existing snapshot attachment", async () => { + const snapshot = { path: "/archive", access: "read", snapshotRef: `sha256:${"a".repeat(64)}` }; + await setup({ features: { vfs: { workspaces: [snapshot] } } }, { + workspaces: [{ workspaceId: "files" }], + }); + const add = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent === "Add workspace")!; + await act(async () => add.click()); + await act(async () => add.click()); + expect(current).toHaveProperty("features.vfs.workspaces", [ + snapshot, + { workspaceId: "files", path: "/workspace", access: "edit" }, + { workspaceId: "files", path: "/workspace-2", access: "edit" }, + ]); + expect(container.textContent).not.toContain("Target type"); + expect(error).toBeNull(); }); it.each([ ["skills", "VFS skill discovery", "VFS skill roots"], ["prompts", "VFS prompt loading", "VFS prompt roots"], ])("enables %s defaults, validates overrides, and restores defaults when cleared", async (key, switchName, label) => { await setup({ features: { environments: { skills: {} }, vfs: { - workspaceLinks: [{ path: "/workspace", access: "readOnly", target: { type: "workspace", workspaceId: "workspace_1" } }], + workspaces: [{ path: "/workspace", access: "read", workspaceId: "workspace_1" }], } } }); await toggle(switchName); expect(error).toBeNull(); expect(current).toHaveProperty(`features.vfs.${key}`, {}); await expand(switchName); await input(label, "/outside/custom"); - expect(error).toContain("inside workspace links"); + expect(error).toContain("inside workspace attachments"); await input(label, "/workspace/custom"); expect(error).toBeNull(); expect(current).toHaveProperty(`features.vfs.${key}.roots`, ["/workspace/custom"]); @@ -96,19 +192,19 @@ it.each([ expect(current).not.toHaveProperty(`features.vfs.${key}`); expect(current).toHaveProperty("features.environments.skills", {}); }); -it("allows both VFS sources to be enabled without links", async () => { +it("allows both VFS sources to be enabled without attachments", async () => { await setup({ features: { vfs: {} } }); await toggle("VFS skill discovery"); await toggle("VFS prompt loading"); expect(error).toBeNull(); - expect(current).toEqual({ features: { vfs: { skills: {}, prompts: {} } } }); + expect(current).toEqual({ features: { vfs: { workspaces: [], skills: {}, prompts: {} } } }); }); it("uses explicit VFS directory settings without inferring /workspace", async () => { - await setup({features:{vfs:{workspaceLinks:[{path:"/workspace",access:"readOnly",target:{type:"workspace",workspaceId:"workspace_1"}}]}}}); + await setup({features:{vfs:{workspaces:[{path:"/workspace",access:"read",workspaceId:"workspace_1"}]}}}); expect(current).not.toHaveProperty("features.vfs.workingDirectory"); await input("VFS working directory", "/outside"); - expect(error).toContain("workspace link"); + expect(error).toContain("workspace attachment"); await input("VFS working directory", "/workspace"); expect(error).toBeNull(); await input("VFS working directory", ""); @@ -192,34 +288,77 @@ it("preserves exclusive domain filter behavior when customized", async () => { expect(current).toHaveProperty("features.web.search", {}); }); -it("configures environment file tools and commands independently of sources and jobs", async () => { - await setup({ features: { environments: { tools: "readOnly", jobs: true, skills: {}, prompts: {} } } }); - expect(current).not.toHaveProperty("features.environments.commands"); - await toggle("Environment command execution"); - expect(current).toHaveProperty("features.environments.commands", true); - expect(current).toHaveProperty("features.environments.tools", "readOnly"); - expect(container.querySelector('[aria-label="Environment file tools"]')?.textContent).toContain("Read only"); - await toggle("Environment command execution"); - expect(current).toEqual({ features: { environments: { tools: "readOnly", jobs: true, skills: {}, prompts: {} } } }); +it("selects a single default without changing access or source grants", async () => { + await setup({ features: { environments: { environments: [ + { environmentId: "logs", access: "read", default: true }, + { environmentId: "runner", access: "jobs" }, + ], skills: {}, prompts: {} } } }); + const checkbox = container.querySelector('[role="checkbox"][aria-label="Default environment 2"]'); + expect(checkbox).not.toBeNull(); + await act(async () => checkbox!.click()); + expect(current).toHaveProperty("features.environments.environments", [ + { environmentId: "logs", access: "read" }, + { environmentId: "runner", access: "jobs", default: true }, + ]); + expect(current).toHaveProperty("features.environments.skills", {}); + expect(current).toHaveProperty("features.environments.prompts", {}); + expect(error).toBeNull(); +}); + +it("moves a deleted default to the next environment, falling back to the previous row", async () => { + await setup({ features: { environments: { environments: [ + { environmentId: "logs", access: "read" }, + { environmentId: "runner", access: "jobs", default: true }, + { environmentId: "build", access: "exec", workingDirectory: "/project" }, + ], selection: true, skills: {}, prompts: {} } } }); + const remove = async (index: number) => { + const buttons = container.querySelectorAll('[aria-label="Remove environment attachment"]'); + await act(async () => buttons[index]!.click()); + }; + await remove(1); + expect(current).toHaveProperty("features.environments.environments", [ + { environmentId: "logs", access: "read" }, + { environmentId: "build", access: "exec", workingDirectory: "/project", default: true }, + ]); + await remove(1); + expect(current).toHaveProperty("features.environments.environments", [ + { environmentId: "logs", access: "read", default: true }, + ]); + await remove(0); + expect(current).toHaveProperty("features.environments", { + environments: [], selection: true, skills: {}, prompts: {}, + }); + expect(error).toBeNull(); +}); + +it.each([false, true])("preserves the default choice when removing another environment (default=%s)", async (hasDefault) => { + const remaining = { environmentId: "runner", access: "jobs", ...(hasDefault ? { default: true } : {}) }; + await setup({ features: { environments: { environments: [ + { environmentId: "logs", access: "read" }, remaining, + ] } } }); + const remove = container.querySelector('[aria-label="Remove environment attachment"]')!; + await act(async () => remove.click()); + expect(current).toHaveProperty("features.environments.environments", [remaining]); + expect(error).toBeNull(); }); -it("enables environments with ergonomic grants and places selection after discovery", async () => { +it("enables environments with an empty attachment list and places selection after discovery", async () => { await setup({}); await toggle("Enable Environments"); expect(current).toEqual({ features: { environments: { - tools: "edit", commands: true, jobs: true, prompts: {}, skills: {}, + environments: [], prompts: {}, skills: {}, } } }); const text = container.textContent!; expect(text.indexOf("Environment selection tools")).toBeGreaterThan(text.indexOf("Skill discovery")); - expect(container.querySelector('[aria-label="Environment command execution"]')?.getAttribute("aria-checked")).toBe("true"); + expect(container.textContent).toContain("No environments attached."); await toggle("Enable Environments"); expect(current ?? {}).not.toHaveProperty("features.environments"); }); -it("enables VFS with editing and default prompt and skill discovery", async () => { +it("enables VFS with an empty attachment list and source discovery", async () => { await setup({}); await toggle("Enable Virtual File System: Files, Instructions, Skills"); - expect(current).toEqual({ features: { vfs: { tools: "edit", prompts: {}, skills: {} } } }); + expect(current).toEqual({ features: { vfs: { workspaces: [], prompts: {}, skills: {} } } }); expect(container.querySelector('[aria-label="VFS prompt loading"]')?.getAttribute("aria-checked")).toBe("true"); expect(container.querySelector('[aria-label="VFS skill discovery"]')?.getAttribute("aria-checked")).toBe("true"); await toggle("Enable Virtual File System: Files, Instructions, Skills"); diff --git a/platform/web/src/components/session/session-config-transfer.test.tsx b/platform/web/src/components/session/session-config-transfer.test.tsx index 7c73b88c..81f9bb31 100644 --- a/platform/web/src/components/session/session-config-transfer.test.tsx +++ b/platform/web/src/components/session/session-config-transfer.test.tsx @@ -17,16 +17,14 @@ describe("profile and session transfer availability", () => { }); it.each([ - ["edit", false, "Enable Environments to also transfer files"], - ["readOnly", true, "Materialize also requires Edit files on the environment."], - ["edit", true, "capture requires environment read access and a writable workspace link"], - [undefined, true, "Prompt and skill sourcing alone does not enable transfers"], - ] as const)("explains tools=%s and environments=%s", async (tools, environments, expected) => { + [false, "Enable Environments to also transfer files"], + [true, "Capture requires workspace edit access and environment read access."], + ] as const)("explains attachment grants with environments=%s", async (environments, expected) => { document.body.append(container); root = createRoot(container); await act(async () => { root!.render( {}} />); }); diff --git a/platform/web/src/components/session/session-settings-sheet.tsx b/platform/web/src/components/session/session-settings-sheet.tsx index ad701ea0..422464ac 100644 --- a/platform/web/src/components/session/session-settings-sheet.tsx +++ b/platform/web/src/components/session/session-settings-sheet.tsx @@ -1,3 +1,4 @@ +import { attachedEnvironments, isEnvironmentAttached } from "@/lib/sessions/resource-features"; import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -17,8 +18,8 @@ import { SetupEditorSection } from "@/components/session/setup-editor-section"; import { normalizeSessionConfig, SessionConfigEditor, - workspaceLinksError, - workspaceLinksFromConfig, + workspaceAttachmentsError, + workspaceAttachmentsFromConfig, type SessionConfig, } from "@/components/session/session-config-editor"; import { Button } from "@/components/ui/button"; @@ -171,7 +172,7 @@ function LiveSessionSetup({ setError(null); }, [instructions.data]); - const nextWorkspaceLinks = workspaceLinksFromConfig(configDraft); + const nextWorkspaceAttachments = workspaceAttachmentsFromConfig(configDraft); const configDirty = !sameConfig(configDraft, originalConfig); const instructionsDirty = instructionsDraft !== undefined && originalInstructions !== undefined @@ -197,8 +198,8 @@ function LiveSessionSetup({ const save = useMutation({ mutationFn: async () => { if (!session) throw new Error("Session is still loading."); - const linkError = workspaceLinksError(nextWorkspaceLinks); - if (linkError) throw new Error(linkError); + const attachmentError = workspaceAttachmentsError(nextWorkspaceAttachments); + if (attachmentError) throw new Error(attachmentError); const desiredConfig = normalizeSessionConfig(configDraft) ?? {}; const selectionError = activeEnvironmentSelectionError( desiredConfig, @@ -320,7 +321,7 @@ function LiveSessionSetup({ value={configDraft} onChange={(config) => { setConfigDraft(config); - if (!hasSessionFeature(config, "environments")) setActiveEnvironmentDraft(null); + if (activeEnvironmentDraft && !isEnvironmentAttached(config, activeEnvironmentDraft)) setActiveEnvironmentDraft(null); }} onValidityChange={setConfigError} mcpServers={options.mcpServers} @@ -328,7 +329,8 @@ function LiveSessionSetup({ workspacesLoading={options.workspacesLoading} models={options.models} profiles={options.profiles} - environmentProviders={options.environmentProviders} + environments={options.environments} + mcpToolDiscovery={options.mcpToolDiscovery} featureDisableReasons={resourceFeatureDisableReasons({ config: configDraft, })} @@ -373,7 +375,7 @@ function LiveSessionSetup({ candidate.environmentId === environmentId); if (!environment) { return environmentId === originalEnvironmentId @@ -551,16 +554,6 @@ function activeEnvironmentSelectionError( if (environmentId !== originalEnvironmentId && !isActivatableEnvironmentStatus(environment.status)) { return `Environment is not currently selectable: ${environmentId} (${environment.status})`; } - const allowedProviders = record(record(record(config).features).environments).providers; - const providerId = environment.source.type === "provisioned" - ? environment.source.providerId - : null; - if (Array.isArray(allowedProviders) && allowedProviders.length - && (!providerId || !allowedProviders.includes(providerId))) { - return providerId - ? `Environment provider is not allowed by the session config: ${providerId}` - : "External environments are not allowed by this provider-restricted session config."; - } return null; } diff --git a/platform/web/src/demo/fixtures/builders.ts b/platform/web/src/demo/fixtures/builders.ts index ccd6584c..9113aa79 100644 --- a/platform/web/src/demo/fixtures/builders.ts +++ b/platform/web/src/demo/fixtures/builders.ts @@ -188,7 +188,7 @@ export function writeFile(path: string, content: string, detail: string): DemoTo ); } -/// `vfs_write_file` into a writable workspace link. +/// `vfs_write_file` into a writable workspace attachment. export function vfsWriteFile(path: string, content: string, detail: string): DemoToolCall { return tool( "vfs.write_file", @@ -334,7 +334,6 @@ export interface ProfileInit { instructions: string; config: Record; metadata?: Record; - environment?: ProfileDocument["environment"]; revision: number; createdAtMs: number; updatedAtMs: number; @@ -348,7 +347,6 @@ export function profile(init: ProfileInit): ProfileDocument { instructions: { type: "text", text: init.instructions }, config: structuredClone(init.config), ...(init.metadata === undefined ? {} : { metadata: structuredClone(init.metadata) }), - ...(init.environment === undefined ? {} : { environment: init.environment }), revision: init.revision, createdAtMs: init.createdAtMs, updatedAtMs: init.updatedAtMs, @@ -500,8 +498,8 @@ export function mcpServer(init: McpServerInit): McpServer { allowedTools: null, execution: "provider", exposure: "inject", - approvalDefault: "never", - deferLoadingDefault: null, + approval: "never", + deferLoading: null, allowPrivateNetwork: false, credential: null, revision: 1, diff --git a/platform/web/src/demo/fixtures/personal-assistant.ts b/platform/web/src/demo/fixtures/personal-assistant.ts index 2e6100d1..cfaf7929 100644 --- a/platform/web/src/demo/fixtures/personal-assistant.ts +++ b/platform/web/src/demo/fixtures/personal-assistant.ts @@ -6,7 +6,7 @@ /// writes, delegates research to a second bot that runs sub-agents, and /// takes the Monday numbers from a metrics bot — the personal-agent pattern /// built from bots, triggers, workspaces, skills, and one Mac mini at home. -import type { Environment, ProfileEnvironment, SecretGrant, UniverseSetup } from "@/api"; +import type { Environment, SecretGrant, UniverseSetup } from "@/api"; import type { SessionSummaryView } from "@lightspeed-ai/agent-client"; import { appendExchange, appendScriptedRun, closeSession, newSession } from "../engine"; import type { DemoResponder, DemoStore, DemoToolCall, DemoTurn, SessionRecord, UniverseState } from "../store"; @@ -678,16 +678,16 @@ const WRITER_INSTRUCTIONS = `You turn Ada's memory and briefs into documents oth Read /memory/commitments.md and the relevant /briefs first; every number traces to a digest or a log line, and you name it in a footnote. Plain sentences, no adjectives, no claims memory does not support. When something is missing, leave a bracketed question for Ada rather than filling it in.`; -const link = (workspaceId: string, path: string, access: "readOnly" | "readWrite") => ({ +const link = (workspaceId: string, path: string, access: "read" | "edit") => ({ path, access, - target: { type: "workspace", workspaceId }, + workspaceId, }); -const MEMORY_RW = link(WORKSPACE.memory, "/memory", "readWrite"); -const MEMORY_RO = link(WORKSPACE.memory, "/memory", "readOnly"); -const SKILLS_RO = link(WORKSPACE.skills, "/skills", "readOnly"); -const BRIEFS_RW = link(WORKSPACE.briefs, "/briefs", "readWrite"); -const BRIEFS_RO = link(WORKSPACE.briefs, "/briefs", "readOnly"); +const MEMORY_RW = link(WORKSPACE.memory, "/memory", "edit"); +const MEMORY_RO = link(WORKSPACE.memory, "/memory", "read"); +const SKILLS_RO = link(WORKSPACE.skills, "/skills", "read"); +const BRIEFS_RW = link(WORKSPACE.briefs, "/briefs", "edit"); +const BRIEFS_RO = link(WORKSPACE.briefs, "/briefs", "read"); const ASSISTANT_LIMITS = { maxDepth: 1, maxDescendants: 4, maxConcurrent: 2, deadlineMs: 15 * MINUTE_MS }; const RESEARCH_LIMITS = { maxDepth: 1, maxDescendants: 6, maxConcurrent: 3, deadlineMs: 10 * MINUTE_MS }; @@ -710,19 +710,18 @@ const ASSISTANT_CONFIG: Record = { generation: { reasoningEffort: "medium", maxOutputTokens: 8_000 }, limits: { maxToolRounds: 16 }, features: { - vfs: { tools: "edit", workspaceLinks: [MEMORY_RW, SKILLS_RO, BRIEFS_RW], skills: { roots: ["/skills"] } }, + vfs: { workspaces: [MEMORY_RW, SKILLS_RO, BRIEFS_RW], skills: { roots: ["/skills"] } }, mcp: { servers: [ { serverId: MCP.google }, { serverId: MCP.slack }, ], }, - environments: { selectionTools: false }, + environments: { environments: [{ environmentId: ENV_MAC_MINI, default: true, access: "jobs" }] }, subagents: { agents: [{ profileId: PROFILE.researcher }], ...ASSISTANT_LIMITS }, web: { fetch: {} }, }, }; -const ASSISTANT_ENVIRONMENT: ProfileEnvironment = { type: "existing", environmentId: ENV_MAC_MINI }; const RESEARCHER_CONFIG: Record = { model: SONNET, @@ -730,7 +729,8 @@ const RESEARCHER_CONFIG: Record = { limits: { maxToolRounds: 30 }, features: { web: { fetch: {}, search: { blockedDomains: ["pinterest.com", "quora.com"] } }, - vfs: { tools: "edit", workspaceLinks: [MEMORY_RW] }, + vfs: { workspaces: [MEMORY_RW] }, + environments: { environments: [{ inherit: true, default: true, access: "exec" }] }, subagents: { agents: [{ profileId: PROFILE.researcher }], ...RESEARCH_LIMITS }, }, }; @@ -741,7 +741,7 @@ const METRICS_CONFIG: Record = { limits: { maxToolRounds: 10, maxTurns: 8 }, features: { mcp: { servers: [{ serverId: MCP.stripe }, { serverId: MCP.hubspot }] }, - vfs: { tools: "readOnly", workspaceLinks: [MEMORY_RO] }, + vfs: { workspaces: [MEMORY_RO] }, }, }; @@ -750,7 +750,7 @@ const WRITER_CONFIG: Record = { generation: { reasoningEffort: "high", maxOutputTokens: 16_000 }, limits: { maxTurns: 20 }, features: { - vfs: { tools: "edit", workspaceLinks: [MEMORY_RO, BRIEFS_RW] }, + vfs: { workspaces: [MEMORY_RO, BRIEFS_RW] }, }, }; @@ -776,7 +776,6 @@ const ASSISTANT_PROFILE: ProfileInit = { description: "Ada's assistant: briefs, inbox triage with drafts for approval, meeting prep, calendar, travel, memory it keeps itself; delegates research.", instructions: ASSISTANT_INSTRUCTIONS, config: ASSISTANT_CONFIG, - environment: ASSISTANT_ENVIRONMENT, revision: 17, createdAtMs: ago(41 * DAY_MS), updatedAtMs: ago(4 * DAY_MS), @@ -787,7 +786,6 @@ const RESEARCHER_PROFILE: ProfileInit = { description: "Sourced answers from public pages; splits a question across sub-agents and writes the result under /memory/research.", instructions: RESEARCHER_INSTRUCTIONS, config: RESEARCHER_CONFIG, - environment: { type: "inherit" }, revision: 6, createdAtMs: ago(30 * DAY_MS), updatedAtMs: ago(9 * DAY_MS), @@ -974,8 +972,8 @@ function seedIntegrations(universe: UniverseState): void { serverUrl: `${WORKSPACE_MCP_URL}/mcp`, description: "Gmail and Calendar for ada@lumen.example: search, drafts, sends, events. The inbox and calendar polls read through the same server.", allowedTools: GOOGLE_TOOLS, - approvalDefault: "never", - deferLoadingDefault: false, + approval: "never", + deferLoading: false, authPolicy: { type: "requiredOAuth", resource: `${WORKSPACE_MCP_URL}/mcp`, scopes: ["gmail.modify", "calendar.events"] }, credential: { type: "authGrant", grantId: GRANT.google }, status: "active", @@ -989,7 +987,7 @@ function seedIntegrations(universe: UniverseState): void { serverUrl: "https://mcp.slack.example/mcp", description: "Read-only on the Lumen workspace: search and channel history, for what Ada was told where.", allowedTools: SLACK_TOOLS, - approvalDefault: "never", + approval: "never", authPolicy: { type: "requiredBearer" }, credential: { type: "authGrant", grantId: GRANT.slack }, status: "active", @@ -1003,7 +1001,7 @@ function seedIntegrations(universe: UniverseState): void { serverUrl: "https://mcp.stripe.example/v1", description: "Subscriptions, invoices, and customers for the metrics bot; restricted read-only key.", allowedTools: ["search_subscriptions", "list_invoices", "retrieve_invoice", "retrieve_customer", "retrieve_subscription"], - approvalDefault: "never", + approval: "never", authPolicy: { type: "requiredBearer" }, credential: { type: "authGrant", grantId: GRANT.stripe }, status: "active", @@ -1017,7 +1015,7 @@ function seedIntegrations(universe: UniverseState): void { serverUrl: "https://mcp.hubspot.example/mcp", description: "Deals and pipelines for coverage numbers and the AE hiring pipeline Marco keeps there.", allowedTools: ["search_deals", "get_pipeline", "get_deal"], - approvalDefault: "never", + approval: "never", authPolicy: { type: "requiredOAuth", resource: "https://mcp.hubspot.example/mcp" }, credential: { type: "authGrant", grantId: GRANT.hubspot }, status: "active", diff --git a/platform/web/src/demo/fixtures/software-factory.ts b/platform/web/src/demo/fixtures/software-factory.ts index 1b175ae5..24a40f99 100644 --- a/platform/web/src/demo/fixtures/software-factory.ts +++ b/platform/web/src/demo/fixtures/software-factory.ts @@ -6,7 +6,7 @@ /// steers a CI failure into the running task, pr-reviewer reviews, and /// release-scribe drafts the changelog. Everything the universe pages show /// is seeded here, with timestamps hung off boot time. -import type { Environment, GitHubApp, ProfileEnvironment, SecretGrant, SessionOrigin, UniverseSetup } from "@/api"; +import type { Environment, GitHubApp, SecretGrant, SessionOrigin, UniverseSetup } from "@/api"; import type { BotEventOutcome, ModelConfig, SessionSummaryView } from "@lightspeed-ai/agent-client"; import { appendExchange, appendScriptedRun, closeSession, newSession } from "../engine"; import type { DemoResponder, DemoStore, DemoToolCall, DemoTurn, SessionRecord, UniverseState } from "../store"; @@ -110,7 +110,7 @@ const PLAN_PATH = "/specs/LIN-1421-plan.md"; /// Deliberately realistic correlation data for exercising the sessions UI /// with the long identifiers produced by external evaluation harnesses. const EVALUATION_METADATA = { - agent: "lightspeed-software-factory-agent-with-provisioned-incus-environment", + agent: "lightspeed-software-factory-agent-with-existing-incus-environment", campaign: "terminal-bench-lightspeed-rerun-hosted-20260904-113000-software-factory", harborContextId: "802d0778-f22c-4a1e-ab4d-3da8486ab4d8", job: "software-factory-regression-benchmark-linux-amd64-production-candidate", @@ -324,7 +324,7 @@ const PLANNER_INSTRUCTIONS = [ ].join("\n"); const IMPLEMENTER_INSTRUCTIONS = [ - "You implement one task of a plan in a fresh sandbox with acme-web checked out on main.", + "You implement one task of a plan in the selected sandbox with acme-web checked out on main.", "", "Read the plan and the spec it links first. Use repo-explorer to find what exists before writing, and test-writer for the tests; keep the change to the files the task names. Run the affected tests before opening the pull request; name the branch after the task id and put the issue key in the PR title. Tell pr-reviewer with bot_emit (kind pr.opened, reply requested) as soon as the PR exists.", "", @@ -382,10 +382,10 @@ const GITHUB_IMPLEMENT_TOOLS = ["create_pull_request", "get_pull_request", "get_ const GITHUB_CI_TOOLS = ["list_workflow_runs", "list_workflow_jobs", "get_job_logs", "create_issue", "add_issue_comment"]; const GITHUB_SCRIBE_TOOLS = ["list_commits", "list_pull_requests", "get_pull_request", "list_tags", "create_pull_request", "create_or_update_file"]; -const link = (workspaceId: string, access: "readOnly" | "readWrite") => ({ +const link = (workspaceId: string, access: "read" | "edit") => ({ path: `/${workspaceId}`, access, - target: { type: "workspace", workspaceId }, + workspaceId, }); const INTAKE_CONFIG: Record = { @@ -394,7 +394,7 @@ const INTAKE_CONFIG: Record = { limits: { maxTurns: 12, maxToolRounds: 20 }, features: { mcp: { servers: [{ serverId: "linear" }] }, - vfs: { tools: "edit", workspaceLinks: [link(WORKSPACE.specs, "readWrite"), link(WORKSPACE.web, "readOnly")] }, + vfs: { workspaces: [link(WORKSPACE.specs, "edit"), link(WORKSPACE.web, "read")] }, }, }; @@ -403,7 +403,7 @@ const PLANNER_CONFIG: Record = { generation: { reasoningEffort: "high", maxOutputTokens: 16_000 }, limits: { maxTurns: 12, maxToolRounds: 24 }, features: { - vfs: { tools: "edit", workspaceLinks: [link(WORKSPACE.specs, "readWrite"), link(WORKSPACE.web, "readOnly")] }, + vfs: { workspaces: [link(WORKSPACE.specs, "edit"), link(WORKSPACE.web, "read")] }, }, }; @@ -412,30 +412,19 @@ const IMPLEMENTER_CONFIG: Record = { generation: { reasoningEffort: "high", maxOutputTokens: 32_000 }, limits: { maxTurns: 40, maxToolRounds: 120 }, features: { - environments: { selectionTools: false }, + environments: { environments: [{ environmentId: ENV.taskC, default: true, access: "jobs" }] }, mcp: { servers: [{ serverId: "github" }] }, subagents: { agents: [{ profileId: PROFILE.explorer }, { profileId: PROFILE.tests }], ...SUBAGENT_LIMITS }, - vfs: { tools: "readOnly", workspaceLinks: [link(WORKSPACE.specs, "readOnly")] }, + vfs: { workspaces: [link(WORKSPACE.specs, "read")] }, }, }; -const IMPLEMENTER_ENVIRONMENT: ProfileEnvironment = { - type: "provision", - providerId: INCUS_PROVIDER_ID, - templateId: "dev-small-v1", - retention: "closeWithSession", - displayName: "implementer sandbox", - idlePolicy: { pauseAfterMs: 15 * MINUTE_MS, stopAfterMs: 2 * HOUR_MS }, - metadata: { repo: "acme/acme-web", checkout: "main" }, - credentials: [{ envName: "GITHUB_TOKEN", source: { type: "authGrant", grantId: GRANT.github } }], -}; - const EXPLORER_CONFIG: Record = { model: OPUS, generation: { reasoningEffort: "medium" }, limits: { maxToolRounds: 60 }, features: { - environments: { selectionTools: false }, + environments: { environments: [{ inherit: true, default: true, access: "exec" }] }, web: { fetch: {}, search: { allowedDomains: ["docs.github.com", "nodejs.org", "developer.mozilla.org", "hono.dev", "vitest.dev"] } }, }, }; @@ -445,7 +434,7 @@ const TEST_WRITER_CONFIG: Record = { generation: { reasoningEffort: "medium", maxOutputTokens: 16_000 }, limits: { maxToolRounds: 40 }, features: { - environments: { selectionTools: false }, + environments: { environments: [{ inherit: true, default: true, access: "exec" }] }, subagents: { agents: [{ profileId: PROFILE.explorer }], ...SUBAGENT_LIMITS }, }, }; @@ -455,7 +444,7 @@ const REVIEWER_CONFIG: Record = { generation: { reasoningEffort: "high", maxOutputTokens: 16_000 }, limits: { maxTurns: 24, maxToolRounds: 40 }, features: { - environments: {}, + environments: { environments: [{ environmentId: ENV.ci, default: true, access: "exec" }] }, mcp: { servers: [{ serverId: "github" }] }, subagents: { agents: [{ profileId: PROFILE.explorer }], maxDepth: 1, maxDescendants: 4, maxConcurrent: 2, deadlineMs: 15 * MINUTE_MS }, web: { fetch: {} }, @@ -468,7 +457,7 @@ const SCRIBE_CONFIG: Record = { limits: { maxTurns: 12 }, features: { mcp: { servers: [{ serverId: "github" }] }, - vfs: { tools: "edit", workspaceLinks: [link(WORKSPACE.web, "readWrite")] }, + vfs: { workspaces: [link(WORKSPACE.web, "edit")] }, web: { fetch: {} }, }, }; @@ -531,11 +520,10 @@ function seedProfiles(universe: UniverseState): void { profile({ profileId: PROFILE.implementer, displayName: "Implementer", - description: "Builds one task per session in a fresh Incus sandbox, delegating exploration and tests to sub-agents, and opens the pull request.", + description: "Builds one task per session in an independently managed Incus sandbox, delegating exploration and tests to sub-agents, and opens the pull request.", instructions: IMPLEMENTER_INSTRUCTIONS, config: IMPLEMENTER_CONFIG, metadata: IMPLEMENTER_PROFILE.metadata, - environment: IMPLEMENTER_ENVIRONMENT, revision: IMPLEMENTER_PROFILE.revision, createdAtMs: ago(30 * DAY_MS), updatedAtMs: ago(2 * DAY_MS), @@ -546,7 +534,6 @@ function seedProfiles(universe: UniverseState): void { description: "Read-only sub-agent that answers repository questions with file:line citations in the environment it inherits.", instructions: EXPLORER_INSTRUCTIONS, config: EXPLORER_CONFIG, - environment: { type: "inherit" }, revision: EXPLORER_PROFILE.revision, createdAtMs: ago(62 * DAY_MS), updatedAtMs: ago(12 * DAY_MS), @@ -557,7 +544,6 @@ function seedProfiles(universe: UniverseState): void { description: "Sub-agent that writes vitest coverage for a change in the inherited sandbox and may ask repo-explorer about conventions.", instructions: TEST_WRITER_INSTRUCTIONS, config: TEST_WRITER_CONFIG, - environment: { type: "inherit" }, revision: TEST_WRITER_PROFILE.revision, createdAtMs: ago(28 * DAY_MS), updatedAtMs: ago(12 * DAY_MS), @@ -568,7 +554,6 @@ function seedProfiles(universe: UniverseState): void { description: "Reviews acme-web pull requests with the GitHub tools and the shared CI runner; delegates repo-wide questions to repo-explorer.", instructions: REVIEWER_INSTRUCTIONS, config: REVIEWER_CONFIG, - environment: { type: "existing", environmentId: ENV.ci }, revision: REVIEWER_PROFILE.revision, createdAtMs: ago(65 * DAY_MS), updatedAtMs: ago(3 * DAY_MS), @@ -1215,15 +1200,14 @@ const POWER_STATES: Environment["desiredPower"][] = ["running", "paused", "stopp interface SandboxInit { id: string; displayName: string; - session: string; createdAtMs: number; - /// Closed with its session at this time; open (ready) when absent. + /// Explicitly closed at this time; open (ready) when absent. closedAtMs?: number; } -/// A task sandbox the implementer profile provisioned for one session. +/// An independently managed sandbox selected by task sessions. function sandbox(init: SandboxInit): Environment { - const requestId = `req-${hex(init.session, 12)}`; + const requestId = `req-${hex(init.id, 12)}`; const updatedAtMs = init.closedAtMs ?? init.createdAtMs + 3 * MINUTE_MS; return { environmentId: init.id, @@ -1242,7 +1226,6 @@ function sandbox(init: SandboxInit): Environment { createdAtMs: init.createdAtMs, updatedAtMs, }, - originSession: { sessionId: init.session, profileId: PROFILE.implementer, closeWithSession: true }, publicIngressEnabled: false, metadata: { repo: "acme/acme-web", checkout: "main", issue: ISSUE }, createdAtMs: init.createdAtMs, @@ -1347,15 +1330,14 @@ function seedEnvironments(universe: UniverseState): void { createdAtMs: ago(33 * DAY_MS + 2 * HOUR_MS), updatedAtMs: ago(33 * DAY_MS), }, - originSession: { sessionId: SESSION.specsSpike, profileId: PROFILE.explorer, closeWithSession: true }, publicIngressEnabled: false, metadata: { repo: "acme/acme-web", checkout: "main" }, createdAtMs: ago(33 * DAY_MS + 2 * HOUR_MS), updatedAtMs: ago(33 * DAY_MS), }); - universe.environments.set(ENV.taskA, sandbox({ id: ENV.taskA, displayName: "implementer sandbox · lin-1421-a", session: SESSION.taskA, createdAtMs: p(2.1), closedAtMs: p(4.9) })); - universe.environments.set(ENV.taskB, sandbox({ id: ENV.taskB, displayName: "implementer sandbox · lin-1421-b", session: SESSION.taskB, createdAtMs: p(2.1), closedAtMs: p(12.4) })); - universe.environments.set(ENV.taskC, sandbox({ id: ENV.taskC, displayName: "implementer sandbox · lin-1421-c", session: SESSION.taskC, createdAtMs: p(2.1) })); + universe.environments.set(ENV.taskA, sandbox({ id: ENV.taskA, displayName: "implementer sandbox · lin-1421-a", createdAtMs: p(2.1), closedAtMs: p(4.9) })); + universe.environments.set(ENV.taskB, sandbox({ id: ENV.taskB, displayName: "implementer sandbox · lin-1421-b", createdAtMs: p(2.1), closedAtMs: p(12.4) })); + universe.environments.set(ENV.taskC, sandbox({ id: ENV.taskC, displayName: "implementer sandbox · lin-1421-c", createdAtMs: p(2.1) })); universe.environmentCredentials.push( { environmentId: ENV.ci, @@ -1387,8 +1369,8 @@ function seedIntegrations(universe: UniverseState): void { serverUrl: "https://api.githubcopilot.com/mcp/", description: "GitHub's hosted MCP server, scoped to the acme organisation through the App installation.", allowedTools: [...new Set([...GITHUB_REVIEW_TOOLS, ...GITHUB_IMPLEMENT_TOOLS, ...GITHUB_CI_TOOLS, ...GITHUB_SCRIBE_TOOLS])], - approvalDefault: "never", - deferLoadingDefault: false, + approval: "never", + deferLoading: false, authPolicy: { type: "requiredBearer" }, credential: { type: "authGrant", grantId: GRANT.github }, status: "active", @@ -2790,7 +2772,7 @@ function seedImplementer(store: DemoStore, universe: UniverseState): void { const record = bot(universe, { botId: BOT.implementer, displayName: "Implementer", - description: "Builds one task per thread in a fresh sandbox with repo-explorer and test-writer sub-agents, opens the PR, and tells pr-reviewer.", + description: "Builds one task per thread in the selected sandbox with repo-explorer and test-writer sub-agents, opens the PR, and tells pr-reviewer.", profileId: PROFILE.implementer, brief: [ "You are Implementer for the acme-web feature pipeline. Each task.ready from planner is one thread and one sandbox.", @@ -4397,7 +4379,7 @@ function defaultReply(turn: number): DemoTurn { if (turn === 1) { return { text: [ - "Happy to help. I'm working in a fresh sandbox with acme-web checked out, with file and process tools, GitHub access through the App installation, sub-agents, and the specs workspace. Three concrete things I can do right now:", + "Happy to help. I'm working in the selected sandbox with acme-web checked out, with file and process tools, GitHub access through the App installation, sub-agents, and the specs workspace. Three concrete things I can do right now:", "", "1. **Build a feature the way the pipeline does** — read the spec, delegate exploration, write, test, open the PR.", "2. **Review a pull request** — read the diff, run the affected tests, post one clear verdict (#493 is waiting).", diff --git a/platform/web/src/demo/fixtures/technical-support.ts b/platform/web/src/demo/fixtures/technical-support.ts index 9625bdd0..28c97ba7 100644 --- a/platform/web/src/demo/fixtures/technical-support.ts +++ b/platform/web/src/demo/fixtures/technical-support.ts @@ -480,7 +480,7 @@ Sources, in this order: Zendesk metrics for the week, /postmortems for incidents Format: one page. Headline numbers first (conversations, tickets, median first response, escalations to engineering, issues filed), then the top issues as a table with counts and the KB page that answers each, then three bullets on what changed and why, then open action items with owners. Plain language, no adjectives. Flag any topic with more than five tickets that has no KB page.`; -const KB_LINK = { path: "/kb", access: "readOnly", target: { type: "workspace", workspaceId: WORKSPACE.kb } }; +const KB_LINK = { path: "/kb", access: "read", workspaceId: WORKSPACE.kb }; const SUPPORT_PROFILE: ProfileInit = { profileId: PROFILE.support, @@ -491,12 +491,11 @@ const SUPPORT_PROFILE: ProfileInit = { model: SONNET, generation: { reasoningEffort: "low" }, features: { - vfs: { tools: "readOnly", workspaceLinks: [KB_LINK] }, - environments: {}, + vfs: { workspaces: [KB_LINK] }, + environments: { environments: [{ environmentId: ENV_SUPPORT_TOOLS, default: true, access: "exec" }] }, mcp: { servers: [{ serverId: MCP.zendesk }] }, }, }, - environment: { type: "existing", environmentId: ENV_SUPPORT_TOOLS }, revision: 9, createdAtMs: ago(44 * DAY_MS), updatedAtMs: at(1, 16, 20), @@ -511,13 +510,12 @@ const TRIAGE_PROFILE: ProfileInit = { model: GPT, generation: { reasoningEffort: "medium" }, features: { - environments: {}, + environments: { environments: [{ environmentId: ENV_SUPPORT_TOOLS, default: true, access: "exec" }] }, web: { fetch: {} }, mcp: { servers: [{ serverId: MCP.statuspage }, { serverId: MCP.pagerduty }] }, }, limits: { maxToolRounds: 12 }, }, - environment: { type: "existing", environmentId: ENV_SUPPORT_TOOLS }, revision: 4, createdAtMs: ago(31 * DAY_MS), updatedAtMs: ago(3 * DAY_MS), @@ -532,7 +530,7 @@ const ESCALATION_PROFILE: ProfileInit = { model: OPUS, generation: { reasoningEffort: "high" }, features: { - vfs: { tools: "readOnly", workspaceLinks: [KB_LINK] }, + vfs: { workspaces: [KB_LINK] }, mcp: { servers: [{ serverId: MCP.github }], }, @@ -554,10 +552,9 @@ const DIGEST_PROFILE: ProfileInit = { generation: { reasoningEffort: "high", maxOutputTokens: 16_000 }, features: { vfs: { - tools: "readOnly", - workspaceLinks: [ + workspaces: [ KB_LINK, - { path: "/postmortems", access: "readOnly", target: { type: "workspace", workspaceId: WORKSPACE.postmortems } }, + { path: "/postmortems", access: "read", workspaceId: WORKSPACE.postmortems }, ], }, mcp: { servers: [{ serverId: MCP.zendesk }] }, @@ -575,7 +572,7 @@ const KB_AUTHOR_CONFIG: Record = { model: SONNET, generation: { reasoningEffort: "medium" }, features: { - vfs: { tools: "readWrite", workspaceLinks: [{ ...KB_LINK, access: "readWrite" }] }, + vfs: { workspaces: [{ ...KB_LINK, access: "edit" }] }, }, }; @@ -784,8 +781,8 @@ function seedIntegrations(universe: UniverseState): void { serverUrl: "https://northwind-dev.zendesk.com/mcp", description: "Tickets, requesters, and weekly metrics for the developer helpdesk.", allowedTools: ["search_tickets", "get_ticket", "create_ticket", "add_ticket_comment", "close_ticket", "get_ticket_metrics"], - approvalDefault: "never", - deferLoadingDefault: false, + approval: "never", + deferLoading: false, authPolicy: { type: "requiredOAuth", resource: "https://northwind-dev.zendesk.com/mcp" }, credential: { type: "authGrant", grantId: GRANT.zendesk }, status: "active", @@ -798,7 +795,7 @@ function seedIntegrations(universe: UniverseState): void { displayName: "Statuspage", serverUrl: `${STATUS_PAGE_URL}/mcp`, description: "Incident and component state from the public status page.", - deferLoadingDefault: true, + deferLoading: true, authPolicy: { type: "requiredBearer" }, credential: { type: "authGrant", grantId: GRANT.statuspage }, status: "unverified", @@ -811,7 +808,7 @@ function seedIntegrations(universe: UniverseState): void { serverUrl: "https://api.githubcopilot.com/mcp/", description: "GitHub's hosted MCP server, scoped to the northwind organisation through the App installation.", allowedTools: ["search_issues", "get_issue", "create_issue", "add_issue_comment", "list_issues"], - approvalDefault: "never", + approval: "never", authPolicy: { type: "gitHubApp", providerId: "github" }, credential: { type: "authGrant", grantId: GRANT.github }, status: "active", @@ -825,7 +822,7 @@ function seedIntegrations(universe: UniverseState): void { serverUrl: "https://mcp.pagerduty.com/mcp", description: "Incidents on the Northwind API service: acknowledge, list, trigger.", allowedTools: ["list_incidents", "get_incident", "acknowledge", "trigger"], - approvalDefault: "never", + approval: "never", authPolicy: { type: "requiredBearer" }, credential: { type: "authGrant", grantId: GRANT.pagerdutyApi }, status: "active", diff --git a/platform/web/src/demo/router.test.ts b/platform/web/src/demo/router.test.ts index 0ec4cb8e..24a9360f 100644 --- a/platform/web/src/demo/router.test.ts +++ b/platform/web/src/demo/router.test.ts @@ -1,3 +1,4 @@ +import { defaultEnvironmentAttachment, environmentAttachments } from "@/lib/sessions/resource-features"; import { describe, expect, it } from "vitest"; import { createDemoStore } from "./fixtures"; import { createDemoRouter } from "./router"; @@ -312,37 +313,85 @@ describe("demo router", () => { expect(view.runs.find((run) => run.id === runId)?.status).toBe("completed"); }, 30_000); - it("copies profile metadata and accepts lightweight environment overrides", async () => { + it("copies profile metadata and selects only its default environment attachment", async () => { const { call } = await boot(); const base = `/api/v1/universes/${SOFTWARE_FACTORY_UNIVERSE_ID}`; - const environments = (await call("GET", `${base}/environments`)).json as Environment[]; - const existing = environments.find((environment) => environment.status !== "closed"); - expect(existing).toBeDefined(); + const profile = (await call("GET", `${base}/profiles/implementer`)).json as { config: Record }; + const defaultId = defaultEnvironmentAttachment(profile.config)?.environmentId; + expect(defaultId).toBeDefined(); - const withoutEnvironment = await call("POST", `${base}/sessions`, { + const created = await call("POST", `${base}/sessions`, { profile: { kind: "named", profileId: "implementer" }, metadata: { campaign: "explicit-campaign" }, - environment: { type: "none" }, }); - expect(withoutEnvironment.status).toBe(200); - expect(withoutEnvironment.json as SessionView).toMatchObject({ - activeEnvironmentId: null, + expect(created.status).toBe(200); + expect(created.json as SessionView).toMatchObject({ + activeEnvironmentId: defaultId, metadata: { - agent: "lightspeed-software-factory-agent-with-provisioned-incus-environment", + agent: "lightspeed-software-factory-agent-with-existing-incus-environment", campaign: "explicit-campaign", profileRole: "parallel-task-implementation-and-pull-request-authoring", }, }); - const withExisting = await call("POST", `${base}/sessions`, { - profile: { kind: "named", profileId: "implementer" }, - environment: { type: "existing", environmentId: existing!.environmentId }, + const config = structuredClone(profile.config); + const attachments = environmentAttachments(config); + for (const attachment of attachments) delete attachment.default; + const withoutDefault = await call("POST", `${base}/sessions`, { + profile: { kind: "inline", profile: { config } }, }); - expect(withExisting.status).toBe(200); - expect(withExisting.json as SessionView).toMatchObject({ - activeEnvironmentId: existing!.environmentId, - metadata: { campaign: expect.stringContaining("terminal-bench-lightspeed") }, + expect(withoutDefault.status).toBe(200); + expect((withoutDefault.json as SessionView).activeEnvironmentId).toBeNull(); + }); + + it.each([{ type: "none" }, { type: "existing", environmentId: "runner" }, null])( + "rejects a removed creation-time environment field: %j", async (environment) => { + const { call } = await boot(); + const base = `/api/v1/universes/${SOFTWARE_FACTORY_UNIVERSE_ID}`; + const response = await call("POST", `${base}/sessions`, { + profile: { kind: "named", profileId: "implementer" }, environment, + }); + expect(response.status).toBe(400); + }, + ); + + it("session start, close, and deletion leave environments independently managed", async () => { + const { call } = await boot(); + const base = `/api/v1/universes/${SOFTWARE_FACTORY_UNIVERSE_ID}`; + const before = (await call("GET", `${base}/environments`)).json as Environment[]; + const profile = (await call("GET", `${base}/profiles/implementer`)).json as { config: unknown }; + const existing = before.find((environment) => environment.environmentId === defaultEnvironmentAttachment(profile.config)?.environmentId)!; + expect(existing).toBeDefined(); + const created = await call("POST", `${base}/sessions`, { + profile: { kind: "named", profileId: "implementer" }, }); + expect(created.status).toBe(200); + const session = created.json as SessionView; + expect(((await call("GET", `${base}/environments`)).json as Environment[]).map((env) => env.environmentId)) + .toEqual(before.map((env) => env.environmentId)); + expect((await call("POST", `${base}/sessions/${session.id}/close`, { force: true })).status).toBe(200); + expect((await call("DELETE", `${base}/sessions/${session.id}`)).status).toBe(200); + const after = (await call("GET", `${base}/environments`)).json as Environment[]; + expect(after.find((env) => env.environmentId === existing.environmentId)).toEqual(existing); + }); + + it("rejects unlisted activation and clears removed selections without filling defaults", async () => { + const { call } = await boot(); + const base = `/api/v1/universes/${SOFTWARE_FACTORY_UNIVERSE_ID}`; + const profile = (await call("GET", `${base}/profiles/implementer`)).json as { config: Record }; + const defaultId = defaultEnvironmentAttachment(profile.config)!.environmentId!; + const environments = (await call("GET", `${base}/environments`)).json as Environment[]; + const other = environments.find((environment) => environment.environmentId !== defaultId && environment.status === "ready")!; + expect(other).toBeDefined(); + const created = (await call("POST", `${base}/sessions`, { profile: { kind: "named", profileId: "implementer" } })).json as SessionView; + expect(created.activeEnvironmentId).toBe(defaultId); + expect((await call("POST", `${base}/sessions/${created.id}/environments/${other.environmentId}/activate`, {})).status).toBe(409); + const config = { ...profile.config, features: { environments: { environments: [{ environmentId: other.environmentId, access: "read", default: true }] } } }; + const changed = await call("PUT", `${base}/sessions/${created.id}/config`, { config, expectedConfigRevision: created.configRevision }); + expect(changed.status).toBe(200); + const session = (await call("GET", `${base}/sessions/${created.id}`)).json as SessionView; + expect(session.activeEnvironmentId).toBeNull(); + expect((await call("POST", `${base}/sessions/${created.id}/environments/${other.environmentId}/activate`, {})).status).toBe(200); }); it("sets and clears session-tree retention through the web routes", async () => { diff --git a/platform/web/src/demo/routes/environments.ts b/platform/web/src/demo/routes/environments.ts index 7ffbe5d1..77194e23 100644 --- a/platform/web/src/demo/routes/environments.ts +++ b/platform/web/src/demo/routes/environments.ts @@ -41,7 +41,6 @@ export interface ProvisionParams { displayName?: string | null; idlePolicy?: unknown; metadata?: Record; - originSession?: { sessionId: string; profileId?: string; closeWithSession: boolean } | null; } /// Pending simulated transitions per environment. A newer intent (close, a @@ -124,8 +123,6 @@ function findByRequestId(universe: UniverseState, requestId: string): Environmen /// `environments/create` semantics: the request id dedupes inside the /// universe, the binding must be enabled, and the record is accepted before /// any provider work. An unknown template fails asynchronously, the way a -/// provider would reject it. `originSession` is set only by session start -/// on behalf of a `provision` profile. export function provisionEnvironment( store: DemoStore, universe: UniverseState, @@ -164,7 +161,6 @@ export function provisionEnvironment( updatedAtMs: now, }, publicIngressEnabled: false, - ...(params.originSession ? { originSession: params.originSession } : {}), metadata: stringMetadata(params.metadata), createdAtMs: now, updatedAtMs: now, @@ -298,14 +294,12 @@ export function environmentRoutes(store: DemoStore): Hono { const providerId = c.req.query("providerId"); const bindingId = c.req.query("bindingId"); const status = c.req.query("status"); - const originSessionId = c.req.query("originSessionId"); const registrationKeyId = c.req.query("registrationKeyId"); const environments = [...universe.environments.values()].filter((environment) => { const source = environment.source; return (!providerId || (source.type === "provisioned" && source.providerId === providerId)) && (!bindingId || (source.type === "provisioned" && source.bindingId === bindingId)) && (!status || environment.status === status) - && (!originSessionId || environment.originSession?.sessionId === originSessionId) && (!registrationKeyId || (source.type === "registered" && source.registrationKeyId === registrationKeyId)); }); diff --git a/platform/web/src/demo/routes/mcp.ts b/platform/web/src/demo/routes/mcp.ts index 3bde31cd..909538a2 100644 --- a/platform/web/src/demo/routes/mcp.ts +++ b/platform/web/src/demo/routes/mcp.ts @@ -70,11 +70,11 @@ function materialize( allowedTools: allowedTools.length > 0 ? allowedTools : null, execution: input.execution === "native" ? "native" : "provider", exposure: input.execution === "native" && input.exposure === "search" ? "search" : "inject", - approvalDefault: typeof input.approvalDefault === "string" && APPROVALS.has(input.approvalDefault) - ? (input.approvalDefault as McpServer["approvalDefault"]) + approval: typeof input.approval === "string" && APPROVALS.has(input.approval) + ? (input.approval as McpServer["approval"]) : "never", - deferLoadingDefault: typeof input.deferLoadingDefault === "boolean" - ? input.deferLoadingDefault + deferLoading: typeof input.deferLoading === "boolean" + ? input.deferLoading : null, allowPrivateNetwork: input.allowPrivateNetwork === true, authPolicy, diff --git a/platform/web/src/demo/routes/secrets.ts b/platform/web/src/demo/routes/secrets.ts index 654fbcd4..ac67b1c1 100644 --- a/platform/web/src/demo/routes/secrets.ts +++ b/platform/web/src/demo/routes/secrets.ts @@ -362,8 +362,8 @@ function finishConfiguratorInstall(store: DemoStore, universe: UniverseState, se allowedTools: null, execution: "native", exposure: "search", - approvalDefault: "never", - deferLoadingDefault: null, + approval: "never", + deferLoading: null, allowPrivateNetwork: false, authPolicy: { type: "requiredBearer" }, credential: { type: "authGrant", grantId: grant.grantId }, diff --git a/platform/web/src/demo/routes/sessions.ts b/platform/web/src/demo/routes/sessions.ts index e3ee1b3c..8036ae3b 100644 --- a/platform/web/src/demo/routes/sessions.ts +++ b/platform/web/src/demo/routes/sessions.ts @@ -1,10 +1,11 @@ +import { defaultEnvironmentAttachment, environmentAttachments, isEnvironmentAttached } from "@/lib/sessions/resource-features"; /// Session routes over the engine simulation: the sessions browser, the /// transcript's long-poll tail, run control, and the settings sheet. Shapes /// and status codes follow the platform server's gateway so the UI cannot /// tell the difference. import { Hono, type Context } from "hono"; import type { Environment, ProfileSessionRetention, ProfileSource, SessionView } from "@/api"; -import type { ProfileEnvironment, ProfileInstructions } from "@lightspeed-ai/agent-client"; +import type { ProfileInstructions } from "@lightspeed-ai/agent-client"; import { DEFAULT_MODEL, PROFILE_INSTRUCTIONS_KEY, @@ -21,7 +22,6 @@ import { } from "../engine"; import { sessionSummary, type DemoStore, type SessionRecord, type UniverseState } from "../store"; import { badRequest, conflict, intQuery, notFound, readBody, universeFor } from "./common"; -import { closeEnvironment, provisionEnvironment } from "./environments"; /// What a session start consumes from a profile, whichever source it came /// from. `profileId` is null for inline profiles. @@ -31,7 +31,6 @@ interface ResolvedProfile { retention: ProfileSessionRetention | null; config: Record; instructions: ProfileInstructions | null; - environment: ProfileEnvironment | null; } /// `?metadata=key` or `?metadata=key=value`, repeatable. Empty values request @@ -86,9 +85,8 @@ export function sessionRoutes(store: DemoStore): Hono { }); }); - /// The environment intent is resolved before the session exists so a - /// refused profile leaves nothing behind; the id is minted early because - /// a provisioned environment is keyed by it. + /// Resolve the default attachment before creating the session so a refused + /// profile leaves no session behind. app.post("/:id/sessions", async (c) => { const universe = universeFor(store, c); if (!universe) return notFound(c); @@ -97,17 +95,14 @@ export function sessionRoutes(store: DemoStore): Hono { metadata?: Record; deleteAfterCloseMs?: number | null; profile?: ProfileSource; - environment?: { type: "none" } | { type: "existing"; environmentId: string }; }>(c); + if (Object.hasOwn(body, "environment")) return badRequest(c, "environment is not a session creation field; configure environment attachments instead"); if (!body.profile) return badRequest(c, "profile is required"); const profile = resolveProfile(universe, body.profile); if (!profile) return notFound(c, "not found in engine"); - if (body.environment) { - profile.environment = body.environment.type === "none" ? null : body.environment; - } const config = sessionConfig(profile.config); const sessionId = store.nextId("session"); - const resolved = resolveEnvironment(store, universe, profile, sessionId, config); + const resolved = resolveEnvironment(universe, profile); if ("error" in resolved) return conflict(c, `engine conflict: ${resolved.error}`); const session = newSession(store, universe, { id: sessionId, @@ -171,17 +166,15 @@ export function sessionRoutes(store: DemoStore): Hono { }); /// Closing keeps history; `force` cancels active and queued work first. - /// Environments a profile provisioned for this session go with it. + /// Environment lifecycles are independent. app.post("/:id/sessions/:sessionId/close", async (c) => { const found = lookup(c); if (!found) return notFound(c, "not found in engine"); - const { universe, session } = found; + const { session } = found; const body = await readBody<{ force?: boolean }>(c); - const wasClosed = session.view.status === "closed"; if (!closeSession(session, body.force === true)) { return conflict(c, "engine conflict: session has active work; close with force to cancel it"); } - if (!wasClosed) closeOriginEnvironments(universe, session.view.id); return c.json(session.view); }); @@ -266,6 +259,7 @@ export function sessionRoutes(store: DemoStore): Hono { } const config = sessionConfig(body.config); session.view.config = config; + if (session.view.activeEnvironmentId && !isEnvironmentAttached(config, session.view.activeEnvironmentId)) session.view.activeEnvironmentId = null; session.view.configRevision += 1; pushEvent(session, { type: "sessionConfigChanged", @@ -327,6 +321,7 @@ export function sessionRoutes(store: DemoStore): Hono { `engine conflict: environment is ${environment.status}: ${environment.environmentId}`, ); } + if (!isEnvironmentAttached(session.view.config, environment.environmentId)) return conflict(c, "engine conflict: environment is not attached to the session"); session.view.activeEnvironmentId = environment.environmentId; session.view.updatedAtMs = Date.now(); return c.json(session.view); @@ -451,7 +446,6 @@ function resolveProfile(universe: UniverseState, source: ProfileSource): Resolve retention: profile.retention ?? null, config: isRecord(profile.config) ? profile.config : {}, instructions: profile.instructions ?? null, - environment: profile.environment ?? null, }; } const document = universe.profiles.get(source.profileId); @@ -463,7 +457,6 @@ function resolveProfile(universe: UniverseState, source: ProfileSource): Resolve retention: document.retention ?? null, config: isRecord(document.config) ? document.config : {}, instructions: isRecord(instructions) ? (instructions as unknown as ProfileInstructions) : null, - environment: document.environment ?? null, }; } @@ -479,76 +472,19 @@ function instructionText(store: DemoStore, instructions: ProfileInstructions | n return instructions.type === "text" ? instructions.text : store.readText(instructions.blobRef); } -/// `existing` activates a universe environment; `provision` creates one -/// keyed by the session id so a retried start finds it again. Provisioning -/// needs the feature grant and an enabled binding for the provider, as the -/// engine checks before it touches a provider. +/// Profiles select resources whose lifecycle is managed independently. function resolveEnvironment( - store: DemoStore, universe: UniverseState, profile: ResolvedProfile, - sessionId: string, - config: Record, ): { environmentId: string | null } | { error: string } { - const intent = profile.environment; - if (!intent || intent.type === "inherit") return { environmentId: null }; - if (intent.type === "existing") { - const environment = universe.environments.get(intent.environmentId); - if (!environment) return { error: `environment not found: ${intent.environmentId}` }; - if (!usable(environment)) { - return { error: `environment is ${environment.status}: ${intent.environmentId}` }; - } - return { environmentId: intent.environmentId }; - } - if (!grantsEnvironments(config)) { - return { - error: - "profile provisions an environment but the effective session config does not grant features.environments", - }; - } - const binding = universe.providerBindings.find( - (candidate) => candidate.providerId === intent.providerId, - ); - if (!binding) { - return { - error: `profile provisions from environment provider ${intent.providerId}, but this universe has no binding for it`, - }; - } - if (binding.status !== "enabled") { - return { - error: `profile provisions from environment provider ${intent.providerId}, but binding ${binding.bindingId} is disabled`, - }; - } - const result = provisionEnvironment(store, universe, { - requestId: `session:${sessionId}`, - bindingId: binding.bindingId, - templateId: intent.templateId, - displayName: - intent.displayName ?? - (profile.profileId ? `${profile.profileId} · ${sessionId}` : `session ${sessionId}`), - idlePolicy: intent.idlePolicy ?? null, - metadata: intent.metadata ?? {}, - originSession: { - sessionId, - ...(profile.profileId ? { profileId: profile.profileId } : {}), - closeWithSession: (intent.retention ?? "closeWithSession") === "closeWithSession", - }, - }); - if ("error" in result) return { error: result.error }; - return { environmentId: result.environment.environmentId }; -} - -/// The reconciler's sweep, done eagerly: environments a profile provisioned -/// for this session with `closeWithSession` close when it does. -function closeOriginEnvironments(universe: UniverseState, sessionId: string): void { - for (const environment of universe.environments.values()) { - if ( - environment.originSession?.sessionId === sessionId && - environment.originSession.closeWithSession - ) { - closeEnvironment(universe, environment.environmentId); - } - } + if (environmentAttachments(profile.config).some((attachment) => attachment.inherit)) return { error: "inherited environments require a parent session" }; + const environmentId = defaultEnvironmentAttachment(profile.config)?.environmentId; + if (!environmentId) return { environmentId: null }; + if (!isEnvironmentAttached(profile.config, environmentId)) return { error: `environment is not attached: ${environmentId}` }; + const environment = universe.environments.get(environmentId); + if (!environment) return { error: `environment not found: ${environmentId}` }; + if (!usable(environment)) return { error: `environment is ${environment.status}: ${environmentId}` }; + return { environmentId }; } /// Provisioning and booting are valid activation targets; a terminal or diff --git a/platform/web/src/lib/mcp/tool-discovery.ts b/platform/web/src/lib/mcp/tool-discovery.ts new file mode 100644 index 00000000..c26a7a7c --- /dev/null +++ b/platform/web/src/lib/mcp/tool-discovery.ts @@ -0,0 +1,117 @@ +import { useEffect, useMemo, useState } from "react"; +import { api, type McpToolDiscovery } from "@/api"; + +export type McpToolDiscoverySource = { + universeId: string; + discover: (serverId: string) => Promise; +}; + +export function useMcpToolDiscoverySource( + universeId: string, +): McpToolDiscoverySource { + return useMemo( + () => ({ + universeId, + discover: (serverId: string) => + api( + "POST", + `/api/v1/universes/${universeId}/mcp-servers/${encodeURIComponent(serverId)}/tools/discover`, + ), + }), + [universeId], + ); +} + +type Observation = { + source: McpToolDiscoverySource; + serverId: string; + revision?: number; + refresh: number; + result?: McpToolDiscovery; + error?: string; +}; + +/** One temporary live observation. Connection changes discard it; responses never change selections. */ +export function useMcpToolDiscovery({ + source, + serverId, + revision, + enabled, + disabled = false, +}: { + source?: McpToolDiscoverySource; + serverId: string; + revision?: number; + enabled: boolean; + disabled?: boolean; +}) { + const [refresh, setRefresh] = useState(0); + const [observation, setObservation] = useState(); + const canLoad = Boolean(source && serverId && enabled && !disabled); + useEffect(() => { + let cancelled = false; + setObservation(undefined); + if (!source || !serverId || !enabled || disabled) return; + const identity = { source, serverId, revision, refresh }; + void source.discover(serverId).then( + (result) => { + if (!cancelled) setObservation({ ...identity, result }); + }, + (error: unknown) => { + if (!cancelled) + setObservation({ + ...identity, + error: + error instanceof Error ? error.message : "Unable to load tools.", + }); + }, + ); + return () => { + cancelled = true; + }; + }, [source, serverId, revision, enabled, disabled, refresh]); + const current = + canLoad && + observation?.source === source && + observation?.serverId === serverId && + observation?.revision === revision && + observation?.refresh === refresh + ? observation + : undefined; + return { + result: current?.result, + error: current?.error, + loading: canLoad && !current, + refresh: () => setRefresh((value) => value + 1), + }; +} + +export function mcpDiscoveryFailureAction( + code: Extract["code"], +): string { + switch (code) { + case "credentialAbsent": + return "Connect a credential to this server, then try again."; + case "grantNeedsReauth": + case "unauthorized": + return "Reconnect this server to refresh its access."; + case "grantAudienceMismatch": + return "Use a credential issued for this exact server address."; + case "forbidden": + return "Check the account's scopes and workspace or administrator policy."; + case "additionalConsentRequired": + return "Reconnect this server and explicitly approve the additional scopes."; + case "remoteRateLimited": + return "Wait briefly before refreshing again."; + case "unreachable": + return "Check the server address, network reachability, and TLS setup."; + case "unsupportedProtocol": + case "invalidResponse": + return "Check that this address is a current Streamable HTTP MCP endpoint."; + case "paginationLimit": + case "responseTooLarge": + return "The server's inventory exceeded safe discovery limits; narrow or fix the server response."; + case "remoteFailure": + return "Check the server or provider status, then retry."; + } +} diff --git a/platform/web/src/lib/profile-config-reference.ts b/platform/web/src/lib/profile-config-reference.ts index d9e6179d..fb7051d6 100644 --- a/platform/web/src/lib/profile-config-reference.ts +++ b/platform/web/src/lib/profile-config-reference.ts @@ -13,39 +13,41 @@ export const PROFILE_CONFIG_REFERENCE = `// Every field is optional — omit any }, // Capability grants. An absent feature is not granted; \`{}\` grants it with defaults. Every block carries a behavior \`version\` that pins semantics. "features": { - // Grants active session environments. Filesystem tools, commands, selection, durable jobs, prompts, and skills are independent, default-off sub-grants. + // Grants session environments. The \`environments\` list is the allowed set: the session can select, read, and run work only on a listed machine, each with its own access grant and working directory. The installed tool surface is the union of every attachment's grant; a call the active machine's grant does not cover fails at execution, so switching machines never changes the toolset. \`{}\` grants the feature with no reachable machine. "environments": { - // Grants command execution and process continuation. Commands may modify files even when filesystem tools are read-only or disabled. - "commands": true | false, - // Grants the advanced durable-job tool surface. The workflow binding is installed for the session when granted; invocations still require an active, ready environment with matching job capabilities. - "jobs": true | false, + // The environments this session may use; unique ids, at most one default, at most one \`inherit\` (profiles only). + "environments": [{ + // Per-attachment environment access, an ordered ladder: \`edit\` adds file editing to \`read\`, \`exec\` adds processes, \`jobs\` adds durable jobs. Processes can write files regardless of the file-tool level, so read-only files with commands is deliberately not expressible. + // (required when this object is present) + "access": "read" | "edit" | "exec" | "jobs", + // Activated when a profile is applied while the session has no active environment; creation is the trivial case. Never overrides a live selection and never applies on a plain \`session/config/put\`. + "default": true | false, + "environmentId": "string", + "inherit": true | false, + // Absolute machine working directory for file tools, commands, jobs, and sources; absent uses the machine's advertised default. + "workingDirectory": "string", + }], // Independent environment prompt loading; absent disables sourced instructions. "prompts": { // Optional source directories, absolute or relative to the environment working directory. Explicit nonempty lists replace all defaults, including home roots. Defaults are .agents/prompts and .lightspeed/prompts under working directory and execution home. "roots": ["string"], }, - // Absent means every registered provider is allowed. - "providers": ["string"], - // Registration keys whose registered environments the session may list and activate; absent means every key. Independent of \`providers\`: each list scopes its own environment source, and external environments pass only when neither list is set. - "registrationKeys": ["string"], - // Exposes \`environment_list\`, \`environment_activate\`, and \`environment_deactivate\` to the model. \`environment_read\` is available whenever environments are enabled, and external API/profile activation remains available when this is false. - "selectionTools": true | false, + // Exposes \`environment_list\`, \`environment_activate\`, and \`environment_deactivate\` over the attached environments. \`environment_read\` is available whenever environments are enabled, and external API/profile activation remains available when this is false. + "selection": true | false, // Independent environment skill discovery. Absent disables discovery. "skills": { // Optional source directories, absolute or relative to the environment working directory. Explicit nonempty lists replace all defaults, including home roots. Defaults are .agents/skills and .lightspeed/skills under working directory and execution home. "roots": ["string"], }, - // Filesystem tool surface. Absent installs no filesystem tools; sources remain independent. Read-only does not restrict commands or durable jobs. - "tools": "readOnly" | "edit", "version": 0, - // Absolute machine working directory for file tools, commands, jobs, and sources; absent uses the endpoint default. - "workingDirectory": "string", }, - // Grants remote MCP tools by declaring linked servers from the universe MCP catalog; must link at least one server, with unique server ids. + // Grants remote MCP tools by declaring attached servers from the universe MCP catalog. Server ids must be unique; an empty list grants no MCP tools. "mcp": { "servers": [{ // (required when this object is present) "serverId": "string", + // Non-empty subset of the record's allowed tools exposed to this session, under both injection and search; absent exposes the record's full allowlist. + "tools": ["string"], }], "version": 0, }, @@ -71,33 +73,30 @@ export const PROFILE_CONFIG_REFERENCE = `// Every field is optional — omit any "timers": { "version": 0, }, - // Grants the session virtual filesystem. Workspace links declare the session-visible namespace and the VFS catalog is surfaced. Sub-grants are independent; \`{}\` grants a VFS with no tools and no sourcing. + // Grants the session virtual filesystem. Workspace attachments declare the session-visible namespace and the VFS catalog is surfaced. The file tool surface is derived from the attachments: any attachment installs the read tools, any \`edit\` attachment adds the write tools, and with the environments feature granted the matching transfer tools appear. \`{}\` grants a VFS with no attachments, no tools, and no sourcing. "vfs": { - // Prompt-instruction sourcing from the VFS. Absent disables loading; an empty block discovers conventional linked roots. + // Prompt-instruction sourcing from the VFS. Absent disables loading; an empty block discovers conventional attached roots. "prompts": { - // Absent searches .agents/prompts and .lightspeed/prompts beneath each workspace link. Explicit roots replace these defaults and must be non-empty absolute paths contained in workspace links. + // Absent searches .agents/prompts and .lightspeed/prompts beneath each workspace attachment. Explicit roots replace these defaults and must be non-empty absolute paths contained in workspace attachments. "roots": ["string"], }, - // Independent VFS skill discovery. Absent disables discovery and removes its runtime catalog; an empty block discovers conventional linked roots. + // Independent VFS skill discovery. Absent disables discovery and removes its runtime catalog; an empty block discovers conventional attached roots. "skills": { - // Absent searches .agents/skills and .lightspeed/skills beneath each workspace link. Explicit roots replace these defaults and must be non-empty absolute paths contained in workspace links. + // Absent searches .agents/skills and .lightspeed/skills beneath each workspace attachment. Explicit roots replace these defaults and must be non-empty absolute paths contained in workspace attachments. "roots": ["string"], }, - // Agent-facing filesystem tool surface; absent = no fs tools. Per-path writability is defined by each workspace link's own access. With the environments feature granted, \`readOnly\` also exposes \`vfs_materialize\`; \`edit\` additionally exposes \`vfs_capture\`. Prompt/skill sourcing alone does not grant transfer tools. - "tools": "readOnly" | "edit", "version": 0, // Absolute VFS tool working directory; absent uses /. "workingDirectory": "string", - // Catalog resources exposed in the session's workspace namespace. - "workspaceLinks": [{ + // Catalog resources exposed in the session's workspace namespace at disjoint absolute paths. + "workspaces": [{ + // Per-attachment VFS access; \`edit\` implies \`read\`. // (required when this object is present) - "access": "readOnly" | "readWrite", + "access": "read" | "edit", // (required when this object is present) "path": "string", - // (required when this object is present) - "target": // one of: - { "type": "workspace", "workspaceId": "string" } | - { "snapshotRef": "string", "type": "snapshot" }, + "snapshotRef": "string", + "workspaceId": "string", }], }, // Grants network access through the web toolset; \`fetch\` and \`search\` are independently granted, and a web block granting neither is rejected. diff --git a/platform/web/src/lib/sessions/editor-options.test.ts b/platform/web/src/lib/sessions/editor-options.test.ts index 32ef6eb5..71f2b798 100644 --- a/platform/web/src/lib/sessions/editor-options.test.ts +++ b/platform/web/src/lib/sessions/editor-options.test.ts @@ -1,30 +1,23 @@ import { describe, expect, it } from "vitest"; -import type { EnvironmentProviderBinding } from "@/api"; -import { environmentProviderOptions } from "./editor-options"; +import { attachedEnvironments, defaultEnvironmentAttachment, isEnvironmentAttached, resourceFeatureDisableReasons } from "./resource-features"; -const binding = ( - bindingId: string, - providerId: string, - status: EnvironmentProviderBinding["status"], -): EnvironmentProviderBinding => ({ - bindingId, - providerId, - status, - revision: 1, - createdAtMs: 1, - updatedAtMs: 1, -}); - -describe("environment provider options", () => { - it("includes each enabled physical provider once", () => { - expect(environmentProviderOptions([ - binding("disabled-a", "provider-a", "disabled"), - binding("enabled-a-2", "provider-a", "enabled"), - binding("enabled-b", "provider-b", "enabled"), - binding("enabled-a-1", "provider-a", "enabled"), - ])).toEqual([ - { providerId: "provider-a", displayName: undefined }, - { providerId: "provider-b", displayName: undefined }, - ]); +describe("environment attachment options", () => { + const config = { features: { environments: { environments: [ + { environmentId: "primary", access: "jobs", default: true }, + { environmentId: "logs", access: "read" }, + ] } } }; + it("offers only attached environments and resolves the declared default", () => { + expect(attachedEnvironments(config, [{ environmentId: "logs" }, { environmentId: "unlisted" }])).toEqual([{ environmentId: "logs" }]); + expect(defaultEnvironmentAttachment(config)?.environmentId).toBe("primary"); + expect(isEnvironmentAttached(config, "unlisted")).toBe(false); + expect(defaultEnvironmentAttachment({})).toBeUndefined(); + }); + it.each([ + ["environments", "environments", { environmentId: "primary" }], + ["vfs", "workspaces", { workspaceId: "files" }], + ["mcp", "servers", { serverId: "catalog" }], + ])("requires removing %s attachments before disabling the feature", (feature, field, attachment) => { + expect(resourceFeatureDisableReasons({ config: { features: { [feature]: { [field]: [attachment] } } } })).toHaveProperty(feature); + expect(resourceFeatureDisableReasons({ config: { features: { [feature]: { [field]: [] } } } })).toEqual({}); }); }); diff --git a/platform/web/src/lib/sessions/editor-options.ts b/platform/web/src/lib/sessions/editor-options.ts index fb5d2e41..506abbdc 100644 --- a/platform/web/src/lib/sessions/editor-options.ts +++ b/platform/web/src/lib/sessions/editor-options.ts @@ -1,11 +1,10 @@ +import { useMcpToolDiscoverySource } from "@/lib/mcp/tool-discovery"; import { useQuery } from "@tanstack/react-query"; import { api, - type EnvironmentProviderBinding, - type EnvironmentTemplate, + type Environment, type ModelListResponse, type ProfileSummary, - type SecretsInventory, } from "@/api"; import type { McpServerOption, @@ -35,49 +34,19 @@ export function useSessionConfigEditorOptions(universeId: string, enabled = true queryFn: () => api("GET", `/api/v1/universes/${universeId}/profiles`), enabled, }); - const environmentProviders = useQuery({ - queryKey: ["environment-provider-bindings", universeId], - queryFn: () => - api( - "GET", - `/api/v1/universes/${universeId}/environment-provider-bindings`, - ), - enabled, - }); - const environmentTemplates = useQuery({ - queryKey: ["environment-templates", universeId], - queryFn: () => - api( - "GET", - `/api/v1/universes/${universeId}/environment-templates`, - ), - enabled, - }); - const secrets = useQuery({ - queryKey: ["secrets", universeId], - queryFn: () => api("GET", `/api/v1/universes/${universeId}/secrets`), + const environments = useQuery({ + queryKey: ["environments", universeId], + queryFn: () => api("GET", `/api/v1/universes/${universeId}/environments`), enabled, }); + const mcpToolDiscovery = useMcpToolDiscoverySource(universeId); return { - secrets: secrets.data, mcpServers: servers.data, workspaces: workspaces.data, workspacesLoading: workspaces.isLoading, models: models.data?.models, profiles: profiles.data, - environmentProviders: environmentProviderOptions(environmentProviders.data ?? []), - environmentBindings: environmentProviders.data, - environmentTemplates: environmentTemplates.data, + environments: environments.data, + mcpToolDiscovery, }; } - -export function environmentProviderOptions(bindings: EnvironmentProviderBinding[]) { - return [...new Map( - bindings - .filter((binding) => binding.status === "enabled") - .map((binding) => [binding.providerId, { - providerId: binding.providerId, - displayName: binding.metadata?.displayName, - }]), - ).values()].sort((left, right) => left.providerId.localeCompare(right.providerId)); -} diff --git a/platform/web/src/lib/sessions/resource-features.ts b/platform/web/src/lib/sessions/resource-features.ts index 0c3d3f13..eb5a663c 100644 --- a/platform/web/src/lib/sessions/resource-features.ts +++ b/platform/web/src/lib/sessions/resource-features.ts @@ -1,62 +1,63 @@ -export type ResourceFeature = "vfs" | "environments"; +import type { EnvironmentAttachment } from "@lightspeed-ai/agent-client"; -export function resourceFeatureDisableReasons( - setup: unknown, -): Partial> { - const document = record(setup); - const workspaceLinkCount = arrayLength( - record(record(record(document.config).features).vfs).workspaceLinks, - ); - const hasEnvironmentIntent = hasProfileEnvironment(document); - return { - ...(workspaceLinkCount > 0 - ? { vfs: removeFirstMessage(workspaceLinkCount, "workspace link", "VFS") } - : {}), - ...(hasEnvironmentIntent - ? { environments: "Clear the profile environment before disabling the Environments feature." } - : {}), - }; +export type ResourceFeature = "vfs" | "environments" | "mcp"; + +export function environmentAttachments(config: unknown): EnvironmentAttachment[] { + const value = record(record(record(config).features).environments).environments; + return Array.isArray(value) ? value.filter((item) => item && typeof item === "object") as EnvironmentAttachment[] : []; } -/// True when the profile document names an environment intent (`existing` -/// or `provision`); absence leaves a session's selection unchanged. -export function hasProfileEnvironment(document: Record): boolean { - const environment = record(document.environment); - return environment.type === "existing" || environment.type === "provision" || environment.type === "inherit"; +export function defaultEnvironmentAttachment(config: unknown): EnvironmentAttachment | undefined { + return environmentAttachments(config).find((attachment) => attachment.default === true); } -export function hasSessionFeature(config: unknown, name: ResourceFeature): boolean { - return name in record(record(config).features); +export function isEnvironmentAttached(config: unknown, environmentId: string): boolean { + return environmentAttachments(config).some((attachment) => attachment.environmentId === environmentId); } -export function setupResourceFeatureError(setup: unknown): string | null { - const document = record(setup); - if (hasProfileEnvironment(document) && !hasSessionFeature(document.config, "environments")) { - return "A profile environment requires the Environments feature to be enabled."; - } - const environment = record(document.environment); - if (environment.type === "existing" && !environment.environmentId) { - return "Select an existing environment or clear the environment mode."; - } - if (environment.type === "provision" && (!environment.providerId || !environment.templateId)) { - return "Provisioning needs a provider and a template."; +export function attachedEnvironments(config: unknown, environments: T[]): T[] { + return environments.filter((environment) => isEnvironmentAttached(config, environment.environmentId)); +} + +export function resourceFeatureDisableReasons(setup: unknown): Partial> { + const features = record(record(record(setup).config).features); + const result: Partial> = {}; + for (const [name, field, label] of [ + ["vfs", "workspaces", "workspace"], + ["environments", "environments", "environment"], + ["mcp", "servers", "server"], + ] as const) { + const attachments = record(features[name])[field]; + if (Array.isArray(attachments) && attachments.length) result[name] = `Remove the ${label} attachments before disabling this feature.`; } - return null; + return result; } -function arrayLength(value: unknown): number { - return Array.isArray(value) ? value.length : 0; +export function hasSessionFeature(config: unknown, name: ResourceFeature): boolean { + return name in record(record(config).features); } -function removeFirstMessage(count: number, resource: string, feature: string): string { - const resources = count === 1 ? `the ${resource}` : `all ${count} ${resource}s`; - return `Remove ${resources} before disabling ${feature}.`; +export function setupResourceFeatureError(setup: unknown): string | null { + const attachments = environmentAttachments(record(setup).config); + const ids = new Set(); + let inherited = 0; + let defaults = 0; + for (const attachment of attachments) { + if (attachment.default && ++defaults > 1) return "Choose at most one default environment."; + if (attachment.inherit) { + if (attachment.environmentId != null || ++inherited > 1) return "Use at most one inherited environment without an environment id."; + } else { + const id = attachment.environmentId; + if (!id || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id)) return "Select an environment for each attachment."; + if (ids.has(id)) return "Each environment may be attached only once."; + ids.add(id); + } + } + return null; } function record(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? value as Record - : {}; + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } /// Environments a session may still select. Closed and closing environments @@ -72,11 +73,11 @@ export function selectableEnvironments; baseInstructions: string; - environment?: ProfileEnvironment; metadata?: Record; retention?: number; }): ProfileDocument { @@ -91,7 +87,6 @@ export function botOwnedProfileDocument({ description: `Setup of bot ${profileId}`, ...(config ? { config } : {}), ...(baseInstructions.trim() ? { instructions: { type: "text", text: baseInstructions } } : {}), - ...(environment ? { environment } : {}), ...(metadata ? { metadata } : {}), ...(retention !== undefined ? { retention: { deleteAfterCloseMs: retention } } : {}), }; @@ -170,7 +165,6 @@ function Wizard({ const [config, setConfig] = useState | undefined>(undefined); const [configError, setConfigError] = useState(null); const [baseInstructions, setBaseInstructions] = useState(""); - const [environment, setEnvironment] = useState(undefined); const [metadata, setMetadata] = useState | undefined>(); const [retention, setRetention] = useState(); const [retentionError, setRetentionError] = useState(null); @@ -224,20 +218,14 @@ function Wizard({ queryFn: () => api("GET", `/api/v1/universes/${universeId}/bots`), }); const options = useSessionConfigEditorOptions(universeId, step === "profile" || step === "wakeups"); - const environments = useQuery({ - queryKey: ["environments", universeId], - queryFn: () => api("GET", `/api/v1/universes/${universeId}/environments`), - enabled: step === "profile" || step === "wakeups", - }); + const defaultEnvironmentId = defaultEnvironmentAttachment(config)?.environmentId; const env: BotEnvStatus = setupMode === "shared" ? { kind: "unknown" } - : environment?.type === "existing" - ? { kind: "existing", environmentId: environment.environmentId } - : environment?.type === "provision" - ? { kind: "provision" } - : { kind: "none" }; + : defaultEnvironmentId + ? { kind: "existing", environmentId: defaultEnvironmentId } + : { kind: "none" }; const applyTemplate = (template: BotTemplate) => { setTemplateId(template.id); @@ -302,7 +290,7 @@ function Wizard({ ? `Session profile: ${configError}` : retentionError ? `Session profile: ${retentionError}` - : setupResourceFeatureError({ config, environment }) + : setupResourceFeatureError({ config }) : sharedProfileId ? null : "Pick the shared profile this bot applies."; @@ -332,7 +320,6 @@ function Wizard({ displayName: displayName.trim() || id, config, baseInstructions, - environment, metadata, retention, }), @@ -687,30 +674,8 @@ function Wizard({ workspacesLoading={options.workspacesLoading} models={options.models} profiles={options.profiles} - environmentProviders={options.environmentProviders} - environmentSetup={ -
- - {environments.data?.length === 0 && ( -

- No environments yet — create one under{" "} - - Settings › Environments - {" "} - if the bot needs a machine. -

- )} -
- } + environments={options.environments} + mcpToolDiscovery={options.mcpToolDiscovery} metadataSetup={} metadataDescription="Defaults copied to every session this bot creates. Metadata helps with filtering and does not affect runtime behavior." retentionSetup={ diff --git a/platform/web/src/pages/BotsPage.test.ts b/platform/web/src/pages/BotsPage.test.ts index d1acc709..dfd7a002 100644 --- a/platform/web/src/pages/BotsPage.test.ts +++ b/platform/web/src/pages/BotsPage.test.ts @@ -116,18 +116,16 @@ describe("wizard helpers", () => { expect(botOwnedProfileDocument({ profileId: "triage", displayName: "Triage", - config: { features: { environments: {} } }, + config: { features: { environments: { environments: [{ environmentId: "ops-box", access: "exec", default: true }] } } }, baseInstructions: "Always cite the incident.", - environment: { type: "existing", environmentId: "ops-box" }, metadata: { team: "ops" }, retention: 604_800_000, })).toEqual({ profileId: "triage", displayName: "Triage", description: "Setup of bot triage", - config: { features: { environments: {} } }, + config: { features: { environments: { environments: [{ environmentId: "ops-box", access: "exec", default: true }] } } }, instructions: { type: "text", text: "Always cite the incident." }, - environment: { type: "existing", environmentId: "ops-box" }, metadata: { team: "ops" }, retention: { deleteAfterCloseMs: 604_800_000 }, }); diff --git a/platform/web/src/pages/EnvironmentsPage.tsx b/platform/web/src/pages/EnvironmentsPage.tsx index 2c5080df..2aa8b481 100644 --- a/platform/web/src/pages/EnvironmentsPage.tsx +++ b/platform/web/src/pages/EnvironmentsPage.tsx @@ -350,15 +350,7 @@ function EnvironmentCard({ {source.type === "provisioned" && } {environment.incarnation.templateId && } {environment.incarnation.providerTargetId && } - {environment.originSession && ( - - )} + {source.type === "provisioned" && ( ( - server?.approvalDefault ?? "never", + server?.approval ?? "never", ); - const [allToolsAllowed, setAllToolsAllowed] = useState(server?.allowedTools == null); - const [allowedTools, setAllowedTools] = useState(server?.allowedTools ?? []); - const [toolSearch, setToolSearch] = useState(""); - const [toolDiscoveryObservation, setToolDiscoveryObservation] = useState<{ - connectionKey: string; - result: McpToolDiscovery; - } | null>(null); + const [allowedTools, setAllowedTools] = useState(server?.allowedTools ?? undefined); + const toolDiscoverySource = useMcpToolDiscoverySource(universeId); const [description, setDescription] = useState(server?.description ?? ""); const [authPolicy, setAuthPolicy] = useState(server?.authPolicy.type ?? "none"); const [authTouched, setAuthTouched] = useState(Boolean(server)); @@ -434,6 +429,7 @@ function ServerDialog({ authPolicy !== server.authPolicy.type || credentialGrantId !== (server.credential?.grantId ?? "") || status !== server.status || + allowPrivateNetwork !== server.allowPrivateNetwork || (isOAuthPolicy(authPolicy) && ( oauthResource.trim() !== oauthPolicyString(server.authPolicy, "resource") || JSON.stringify(currentOAuthScopes) !== JSON.stringify(oauthPolicyScopes(server.authPolicy)) || @@ -441,26 +437,8 @@ function ServerDialog({ oauthAuthorizationServer.trim() !== oauthPolicyString(server.authPolicy, "authorizationServer") )) )); - const toolConnectionKey = `${server?.serverId ?? "new"}:${server?.revision ?? 0}`; - const toolConnectionKeyRef = useRef(toolConnectionKey); - toolConnectionKeyRef.current = toolConnectionKey; - const toolDiscovery = !connectionSettingsDirty && - toolDiscoveryObservation?.connectionKey === toolConnectionKey - ? toolDiscoveryObservation.result - : null; - const parsedTools = [...new Set(allowedTools.map((tool) => tool.trim()).filter(Boolean))] + const parsedTools = [...new Set((allowedTools ?? []).map((tool) => tool.trim()).filter(Boolean))] .sort((left, right) => left.localeCompare(right)); - const advertisedTools = toolDiscovery?.status === "success" - ? toolDiscovery.tools.slice().sort((left, right) => left.name.localeCompare(right.name)) - : []; - const normalizedToolSearch = toolSearch.trim().toLocaleLowerCase(); - const visibleTools = advertisedTools.filter((tool) => - !normalizedToolSearch || `${tool.name} ${tool.title ?? ""} ${tool.description ?? ""}` - .toLocaleLowerCase() - .includes(normalizedToolSearch), - ); - const advertisedNames = new Set(advertisedTools.map((tool) => tool.name)); - const unavailableSelectedTools = parsedTools.filter((name) => !advertisedNames.has(name)); const probe = useMutation({ mutationFn: (url: string) => api( @@ -482,29 +460,6 @@ function ServerDialog({ }, }); - const discoverTools = useMutation({ - mutationFn: (_connectionKey: string) => api( - "POST", - `/api/v1/universes/${universeId}/mcp-servers/${server!.serverId}/tools/discover`, - ), - onSuccess: (result, connectionKey) => { - if (connectionKey === toolConnectionKeyRef.current) { - setToolDiscoveryObservation({ connectionKey, result }); - } - }, - onError: (_error, connectionKey) => { - if (connectionKey === toolConnectionKeyRef.current) { - setToolDiscoveryObservation(null); - } - }, - }); - - const toggleAllowedTool = (name: string, checked: boolean) => { - setAllowedTools((current) => checked - ? [...new Set([...current, name])] - : current.filter((tool) => tool !== name)); - }; - const discoverAuth = async () => { const url = serverUrl.trim(); if (editing || !isValidMcpUrl(url) || url === lastProbedUrl || probe.isPending) return; @@ -540,7 +495,7 @@ function ServerDialog({ defaultServerLabel: serverId, execution, exposure: execution === "native" ? exposure : "inject", - approvalDefault: approval, + approval: approval, allowPrivateNetwork, authPolicy: policy, credential, @@ -560,14 +515,14 @@ function ServerDialog({ execution, exposure: execution === "native" ? exposure : "inject", revision: server.revision, - approvalDefault: approval, + approval: approval, authPolicy: policy, credential, status: nextStatus, displayName: displayName.trim() || null, description: description.trim() || null, - allowedTools: allToolsAllowed ? null : parsedTools, - deferLoadingDefault: server.deferLoadingDefault ?? null, + allowedTools: allowedTools === undefined ? null : parsedTools, + deferLoading: server.deferLoading ?? null, allowPrivateNetwork, }, ); @@ -610,8 +565,8 @@ function ServerDialog({ setError(credentialError); return; } - if (editing && !allToolsAllowed && parsedTools.length === 0) { - setError("Select at least one tool, or allow every advertised tool."); + if (editing && allowedTools !== undefined && parsedTools.length === 0) { + setError("Select at least one tool, or choose all advertised tools."); return; } save.mutate(); @@ -793,130 +748,23 @@ function ServerDialog({ )} {editing ? ( - -
-
- Available tools - - Read live with the connected account's permissions and never cached. - Server-provided descriptions and safety annotations are untrusted hints. - -
- -
- - {connectionSettingsDirty && ( - - Save connection or credential changes before loading its tools. - - )} - {discoverTools.error && ( -

{discoverTools.error.message}

- )} - {toolDiscovery?.status === "failure" && ( -
-

{toolDiscovery.message}

- - {mcpDiscoveryFailureAction(toolDiscovery.code)} - {toolDiscovery.requiredScopes?.length - ? ` Required scopes: ${toolDiscovery.requiredScopes.join(", ")}.` - : ""} - -
- )} - {!allToolsAllowed && toolDiscovery?.status !== "success" && parsedTools.length > 0 && ( -
-

Authored selection

-
- {parsedTools.map((name) => ( - {name} - ))} -
-
- )} - {toolDiscovery?.status === "success" && ( -
-
- - setToolSearch(event.target.value)} - placeholder={`Search ${advertisedTools.length} tool${advertisedTools.length === 1 ? "" : "s"}`} - aria-label="Search MCP tools" - className="pl-8" - /> -
-
- {visibleTools.length === 0 ? ( -

- {advertisedTools.length === 0 - ? "No tools advertised. Check this account's access, requested scopes, and workspace or admin policy, then refresh or reconnect this server." - : "No tools match your search."} -

- ) : visibleTools.map((tool) => ( - - ))} -
- {!allToolsAllowed && unavailableSelectedTools.length > 0 && ( - - Still selected but not currently advertised: {unavailableSelectedTools.join(", ")}. - They are preserved until you deselect them. - - )} -
- )} -
+ ) : (

Tool selection after connection

This server will initially allow every advertised tool. After adding it and completing any - authentication, edit the server to load its live inventory and restrict access. + authentication, edit the server and select its allowed tools to restrict access.

)} @@ -1483,35 +1331,6 @@ export function mcpServerStatusForCredential( return status; } -export function mcpDiscoveryFailureAction( - code: Extract["code"], -): string { - switch (code) { - case "credentialAbsent": - return "Connect a credential to this server, then try again."; - case "grantNeedsReauth": - case "unauthorized": - return "Reconnect this server to refresh its access."; - case "grantAudienceMismatch": - return "Use a credential issued for this exact server address."; - case "forbidden": - return "Check the account's scopes and workspace or administrator policy."; - case "additionalConsentRequired": - return "Reconnect this server and explicitly approve the additional scopes."; - case "remoteRateLimited": - return "Wait briefly before refreshing again."; - case "unreachable": - return "Check the server address, network reachability, and TLS setup."; - case "unsupportedProtocol": - case "invalidResponse": - return "Check that this address is a current Streamable HTTP MCP endpoint."; - case "paginationLimit": - case "responseTooLarge": - return "The server's inventory exceeded safe discovery limits; narrow or fix the server response."; - case "remoteFailure": - return "Check the server or provider status, then retry."; - } -} function authGrantLabel(grant: AuthGrantOption): string { const name = grant.displayName || grant.subjectHint || grant.grantId; diff --git a/platform/web/src/pages/ProfilesPage.tsx b/platform/web/src/pages/ProfilesPage.tsx index d2275de6..dde49af0 100644 --- a/platform/web/src/pages/ProfilesPage.tsx +++ b/platform/web/src/pages/ProfilesPage.tsx @@ -5,7 +5,6 @@ import { slugify } from "@lightspeed/platform-shared"; import { ChevronRight, Plus, Trash2 } from "lucide-react"; import { api, - type Environment, type ProfileDocument, type ProfileSummary, } from "@/api"; @@ -24,7 +23,6 @@ import { Button } from "@/components/ui/button"; import { MetadataMapEditor, } from "@/components/session/metadata-editor"; -import { ProfileEnvironmentEditor } from "@/components/session/profile-environment-editor"; import { ProfileRetentionEditor } from "@/components/session/profile-retention-editor"; import { SessionConfigEditor } from "@/components/session/session-config-editor"; import { @@ -531,11 +529,6 @@ function ConfigSection({ onRetentionValidityChange: (message: string | null) => void; }) { const options = useSessionConfigEditorOptions(universeId); - const environments = useQuery({ - queryKey: ["environments", universeId], - queryFn: () => - api("GET", `/api/v1/universes/${universeId}/environments`), - }); return (
)} retentionDescription="Default automatic deletion for new root sessions created from this profile." - environmentSetup={( - - mutate((document) => { - if (environment) document.environment = environment; - else delete document.environment; - }) - } - /> - )} onValidityChange={onValidityChange} onChange={(config) => mutate((document) => { diff --git a/platform/web/src/pages/SessionsPage.tsx b/platform/web/src/pages/SessionsPage.tsx index a32a6474..75a4a948 100644 --- a/platform/web/src/pages/SessionsPage.tsx +++ b/platform/web/src/pages/SessionsPage.tsx @@ -12,13 +12,11 @@ import { api, botLabel, type BotListResponse, - type Environment, type InlineProfile, type ProfileDocument, type ProfileSource, type ProfileSummary, type SessionListPage, - type SessionEnvironmentOverride, type SessionOrigin, type SessionRunAccepted, type SessionRunApprovalsDecided, @@ -52,7 +50,6 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; -import { ProfileEnvironmentEditor } from "@/components/session/profile-environment-editor"; import { MetadataMapEditor } from "@/components/session/metadata-editor"; import { SessionMenuIdentity, SessionMenuMetadata } from "@/components/session/session-menu-details"; import { SessionMenuPreferences } from "@/components/session/session-menu-preferences"; @@ -115,8 +112,6 @@ import { import { useSessionConfigEditorOptions } from "@/lib/sessions/editor-options"; import { managedSessionBotId, managedSessionOwnerLabel } from "@/lib/sessions/management"; import { - hasSessionFeature, - selectableEnvironments, resourceFeatureDisableReasons, setupResourceFeatureError, } from "@/lib/sessions/resource-features"; @@ -912,7 +907,6 @@ function NewSessionDialog({ const [profileId, setProfileId] = useState(""); const [step, setStep] = useState<"basics" | "setup">("basics"); const [inlineProfile, setInlineProfile] = useState(null); - const [environmentOverride, setEnvironmentOverride] = useState(); const [configError, setConfigError] = useState(null); const [retentionError, setRetentionError] = useState(null); const [error, setError] = useState(null); @@ -931,17 +925,11 @@ function NewSessionDialog({ enabled: open && Boolean(profileId), }); const editorOptions = useSessionConfigEditorOptions(universeId, open && step === "setup"); - const environments = useQuery({ - queryKey: ["environments", universeId], - queryFn: () => api("GET", `/api/v1/universes/${universeId}/environments`), - enabled: open, - }); const create = useMutation({ mutationFn: () => api("POST", `/api/v1/universes/${universeId}/sessions`, { ...(displayName.trim() ? { displayName: displayName.trim() } : {}), profile: profileForCreate(profileId, inlineProfile, selectedProfile.data), - ...(environmentOverride ? { environment: environmentOverride } : {}), }), onSuccess: async (session) => { await queryClient.invalidateQueries({ queryKey: ["sessions", universeId] }); @@ -951,7 +939,6 @@ function NewSessionDialog({ setProfileId(""); setStep("basics"); setInlineProfile(null); - setEnvironmentOverride(undefined); setConfigError(null); setRetentionError(null); setError(null); @@ -979,7 +966,6 @@ function NewSessionDialog({ setProfileId(""); setStep("basics"); setInlineProfile(null); - setEnvironmentOverride(undefined); setConfigError(null); setRetentionError(null); setError(null); @@ -997,7 +983,6 @@ function NewSessionDialog({ ? inlineProfileFromDocument(selectedProfile.data) : {}, ); - setEnvironmentOverride(undefined); setStep("setup"); }; const resourceFeatureError = inlineProfile @@ -1037,7 +1022,6 @@ function NewSessionDialog({ onValueChange={(value) => { setProfileId(value as string); setInlineProfile(null); - setEnvironmentOverride(undefined); setConfigError(null); setRetentionError(null); setError(null); @@ -1065,40 +1049,6 @@ function NewSessionDialog({ The profile is resolved at creation; later profile edits do not change this session. - {profileId - && selectedProfile.data - && hasSessionFeature(selectedProfile.data.config, "environments") - && !inlineProfile ? ( - - Environment - - - Override this session’s environment without converting the profile to an inline setup. - - - ) : null}