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