feat: publish Repository and Workdir WIP objects

Use Host root namespace ownership and disjoint Feature operation contributions. Separate read-only workdir_catalog from manage_workdir, encode all existing identities canonically, publish safe bounded inventory and lifetime-bound attachment Objects, and reuse normal lifecycle/session router/lock authority.

Contextual interfaces resolve the current permission/capability intersection; object and interface validators reject stale cached access. Cover fresh workspace WIP-only discovery/create/attach/read/detach, readonly and unsupported targets, collision/revocation/alias reuse, active cleanup exclusion and terminal unknown outcomes without retries.

Validation: WIP 40 tests; worker 730 unit + 96 integration tests serial; manifest 182 tests; workspace-root cargo check; cargo fmt --all -- --check; git diff --check HEAD.
This commit is contained in:
2026-10-05 18:10:01 +09:00
parent bbfbf92264
commit 42e4ce5550
11 changed files with 3319 additions and 78 deletions
+2
View File
@@ -25,6 +25,8 @@ Does not own:
Profiles are reusable recipes; resolved Manifests are runtime contracts. Keep runtime-bound fields such as Worker name, concrete delegated scope, sockets, session pointers, and raw secrets out of reusable Profiles.
`feature.workdir_catalog.enabled` defaults to `true` for read-only Repository/Workdir/attachment reference discovery in WIP mode. It is independent of `feature.manage_workdir` (which defaults disabled), registers no normal Tools, and does not grant filesystem, attachment, or Workspace authority. Permissions remain independent. Standalone Tools-mode profiles may retain this default; only WIP installation consumes the flag. Set it to `false` to opt out.
## See also
- [`../../docs/design/profiles-manifests-prompts.md`](../../docs/design/profiles-manifests-prompts.md)
+116 -4
View File
@@ -51,8 +51,9 @@ pub struct WorkerManifestConfig {
/// is disabled; `Some` requires `default_action` during final resolve.
#[serde(default)]
pub permissions: Option<PermissionConfigPartial>,
/// Explicit built-in feature/tool-surface enablement. Absent flags resolve
/// disabled after cascade merge.
/// Built-in feature/tool-surface enablement. Absent tool flags resolve
/// disabled after cascade merge; the read-only WIP workdir catalog defaults
/// enabled without granting authority.
#[serde(default)]
pub feature: FeatureConfigPartial,
/// Explicit Model Context Protocol provider declarations. Config parsing
@@ -95,6 +96,8 @@ pub struct FeatureConfigPartial {
#[serde(default)]
pub manage_workdir: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub workdir_catalog: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub ticket: Option<TicketFeatureConfigPartial>,
#[serde(default)]
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
@@ -136,6 +139,11 @@ impl FeatureConfigPartial {
other.manage_workdir,
FeatureFlagConfigPartial::merge,
),
workdir_catalog: merge_option(
self.workdir_catalog,
other.workdir_catalog,
FeatureFlagConfigPartial::merge,
),
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
merge_request: merge_option(
self.merge_request,
@@ -388,6 +396,12 @@ impl From<FeatureConfigPartial> for FeatureConfig {
.manage_workdir
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
workdir_catalog: FeatureFlagConfig {
enabled: value
.workdir_catalog
.and_then(|flag| flag.enabled)
.unwrap_or(defaults::WORKDIR_CATALOG_ENABLED),
},
ticket: value
.ticket
.map(TicketFeatureConfig::from)
@@ -579,6 +593,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
workspace_worker_discovery: Some(value.workspace_worker_discovery.into()),
objective: Some(value.objective.into()),
manage_workdir: Some(value.manage_workdir.into()),
workdir_catalog: Some(value.workdir_catalog.into()),
ticket: Some(value.ticket.into()),
merge_request: Some(value.merge_request.into()),
orchestration: Some(value.orchestration.into()),
@@ -766,7 +781,8 @@ impl WorkerManifestConfig {
/// Base config populated with the in-code per-field defaults listed in
/// [`crate::defaults`]. This is not a selectable Profile and does not
/// enable a launch capability surface. Profile and one-file Manifest
/// enable launch authority or normal Tools. The read-only WIP workdir
/// catalog defaults enabled independently. Profile and one-file Manifest
/// resolvers start from this layer so every per-field default lives at
/// exactly one call site (the `defaults` module).
///
@@ -2232,7 +2248,7 @@ worker_max_turns = 7
}
#[test]
fn feature_flags_default_disabled_in_resolved_manifest() {
fn tool_feature_flags_default_disabled_except_read_only_workdir_catalog() {
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
assert!(!manifest.feature.task.enabled);
assert!(!manifest.feature.memory.profile.enabled);
@@ -2240,10 +2256,106 @@ worker_max_turns = 7
assert!(!manifest.feature.sub_worker.enabled);
assert!(!manifest.feature.objective.enabled);
assert!(!manifest.feature.manage_workdir.enabled);
assert!(manifest.feature.workdir_catalog.enabled);
assert!(!manifest.feature.ticket.enabled);
assert!(!manifest.feature.merge_request.any());
}
#[test]
fn workdir_catalog_defaults_agree_for_partial_and_resolved_configs() {
assert!(FeatureConfig::default().workdir_catalog.enabled);
assert!(FeatureConfigPartial::default().workdir_catalog.is_none());
for source in ["", "[feature]\n", "[feature.workdir_catalog]\n"] {
let partial = WorkerManifestConfig::from_toml(source).unwrap();
let feature = FeatureConfig::from(partial.feature);
assert!(feature.workdir_catalog.enabled, "{source}");
let value: toml::Value = toml::from_str(source).unwrap();
let resolved: FeatureConfig = serde_json::from_value(
serde_json::to_value(
value
.get("feature")
.cloned()
.unwrap_or_else(|| toml::Value::Table(Default::default())),
)
.unwrap(),
)
.unwrap();
assert_eq!(feature, resolved);
}
for source in ["{}", r#"{"workdir_catalog": {}}"#] {
let feature: FeatureConfig = serde_json::from_str(source).unwrap();
assert!(feature.workdir_catalog.enabled);
assert!(!feature.manage_workdir.enabled);
}
}
#[test]
fn workdir_catalog_is_independent_and_merges_partial_layers() {
for catalog in [false, true] {
for manage in [false, true] {
let source = format!(
"[feature.workdir_catalog]\nenabled = {catalog}\n\
[feature.manage_workdir]\nenabled = {manage}\n"
);
let lower = WorkerManifestConfig::from_toml(&source).unwrap();
let empty = WorkerManifestConfig::from_toml("[feature.workdir_catalog]").unwrap();
let cfg = minimal_valid().merge(lower.clone()).merge(empty);
let manifest: WorkerManifest = cfg.try_into().unwrap();
assert_eq!(manifest.feature.workdir_catalog.enabled, catalog);
assert_eq!(manifest.feature.manage_workdir.enabled, manage);
let override_source =
format!("[feature.workdir_catalog]\nenabled = {}\n", !catalog);
let upper = WorkerManifestConfig::from_toml(&override_source).unwrap();
let manifest: WorkerManifest = minimal_valid()
.merge(lower)
.merge(upper)
.try_into()
.unwrap();
assert_eq!(manifest.feature.workdir_catalog.enabled, !catalog);
assert_eq!(manifest.feature.manage_workdir.enabled, manage);
}
}
}
#[test]
fn workdir_catalog_roundtrips_config_and_persisted_snapshots() {
for enabled in [false, true] {
let mut cfg = minimal_valid();
cfg.feature.workdir_catalog = Some(FeatureFlagConfigPartial {
enabled: Some(enabled),
});
let manifest: WorkerManifest = cfg.try_into().unwrap();
let partial = FeatureConfigPartial::from(manifest.feature.clone());
assert_eq!(
partial.workdir_catalog.as_ref().unwrap().enabled,
Some(enabled)
);
let serialized = toml::to_string(&partial).unwrap();
let partial: FeatureConfigPartial = toml::from_str(&serialized).unwrap();
assert_eq!(FeatureConfig::from(partial), manifest.feature);
let serialized = serde_json::to_value(&manifest.feature).unwrap();
assert_eq!(serialized["workdir_catalog"]["enabled"], enabled);
let feature: FeatureConfig = serde_json::from_value(serialized).unwrap();
assert_eq!(feature, manifest.feature);
let snapshot = crate::write_persisted_worker_manifest_snapshot(&manifest).unwrap();
let restored =
crate::read_persisted_worker_manifest_snapshot(snapshot.clone()).unwrap();
assert_eq!(restored.feature.workdir_catalog.enabled, enabled);
assert!(!restored.feature.manage_workdir.enabled);
let mut legacy = snapshot;
legacy["manifest"]["feature"]
.as_object_mut()
.unwrap()
.remove("workdir_catalog");
let restored = crate::read_persisted_worker_manifest_snapshot(legacy).unwrap();
assert!(restored.feature.workdir_catalog.enabled);
assert!(!restored.feature.manage_workdir.enabled);
}
}
#[test]
fn from_toml_parses_explicit_feature_flags() {
let cfg = WorkerManifestConfig::from_toml(
+4
View File
@@ -6,6 +6,10 @@
//! `pub const`. Both paths read from this module, so changing a
//! default requires editing exactly one line.
/// Read-only Repository/Workdir/attachment reference discovery in WIP mode.
/// This enables a reference provider, not normal Tools or any authority grant.
pub const WORKDIR_CATALOG_ENABLED: bool = true;
/// Byte-size cap applied to any tool's `content` output when no
/// per-tool override is set. See [`crate::ToolOutputLimits`].
pub const TOOL_OUTPUT_MAX_BYTES: usize = 64 * 1024;
+29 -2
View File
@@ -65,8 +65,9 @@ pub struct WorkerManifest {
/// permission layer is disabled and tool calls run as before.
#[serde(default)]
pub permissions: Option<ToolPermissionConfig>,
/// Explicit built-in feature/tool-surface enablement. Omitted feature flags
/// resolve disabled so Profile authors choose the exposed built-in surfaces.
/// Built-in feature/tool-surface enablement. Omitted tool features resolve
/// disabled; the read-only WIP workdir catalog defaults enabled without
/// granting authority or registering normal Tools.
#[serde(default)]
pub feature: FeatureConfig,
/// Explicit external Model Context Protocol provider configuration. This
@@ -131,6 +132,15 @@ pub struct FeatureConfig {
pub objective: FeatureFlagConfig,
#[serde(default)]
pub manage_workdir: FeatureFlagConfig,
/// Read-only Repository/Workdir/attachment reference discovery. Defaults
/// enabled, is installed only in WIP mode, and exposes no normal Tools.
/// Independent of `manage_workdir`; permissions and host authority remain
/// separate, and discovering a reference grants no attachment or file access.
#[serde(
default = "default_workdir_catalog",
deserialize_with = "deserialize_workdir_catalog"
)]
pub workdir_catalog: FeatureFlagConfig,
#[serde(default)]
pub ticket: TicketFeatureConfig,
#[serde(default)]
@@ -139,6 +149,22 @@ pub struct FeatureConfig {
pub orchestration: FeatureFlagConfig,
}
fn default_workdir_catalog() -> FeatureFlagConfig {
FeatureFlagConfig {
enabled: defaults::WORKDIR_CATALOG_ENABLED,
}
}
fn deserialize_workdir_catalog<'de, D>(deserializer: D) -> Result<FeatureFlagConfig, D::Error>
where
D: serde::Deserializer<'de>,
{
let flag = config::FeatureFlagConfigPartial::deserialize(deserializer)?;
Ok(FeatureFlagConfig {
enabled: flag.enabled.unwrap_or(defaults::WORKDIR_CATALOG_ENABLED),
})
}
impl Default for FeatureConfig {
fn default() -> Self {
Self {
@@ -153,6 +179,7 @@ impl Default for FeatureConfig {
workspace_worker_discovery: FeatureFlagConfig::disabled(),
objective: FeatureFlagConfig::disabled(),
manage_workdir: FeatureFlagConfig::disabled(),
workdir_catalog: default_workdir_catalog(),
ticket: TicketFeatureConfig::default(),
merge_request: MergeRequestFeatureConfig::default(),
orchestration: FeatureFlagConfig::disabled(),
+55
View File
@@ -181,6 +181,8 @@ pub fn validate_profile_execution_target(
if feature.flow.enabled {
requirements.insert(WorkspaceAuthorityRequirement::Flow);
}
// workdir_catalog is a WIP-only reference provider, not Workspace authority
// or normal Tools. It must not prevent standalone Tools-mode launches.
if feature.manage_workdir.enabled {
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir);
}
@@ -1586,6 +1588,59 @@ mod tests {
assert!(!resolved.manifest.feature.manage_workdir.enabled);
}
#[test]
fn workdir_catalog_does_not_require_workspace_authority_or_grant_scope() {
let tmp = TempDir::new().unwrap();
for catalog in [None, Some(false), Some(true)] {
let mut artifact = serde_json::json!({
"worker": { "mode": "tools" },
"feature": { "manage_workdir": { "enabled": false } },
"permissions": { "default_action": "deny" }
});
if let Some(enabled) = catalog {
artifact["feature"]["workdir_catalog"] = serde_json::json!({ "enabled": enabled });
}
let resolved = resolve_profile_artifact_value(
artifact,
ProfileSource::Archive {
archive_id: "catalog-test".into(),
source: "test".into(),
},
tmp.path(),
"catalog-worker",
)
.unwrap();
let manifest = resolved.manifest;
assert_eq!(
manifest.feature.workdir_catalog.enabled,
catalog.unwrap_or(true)
);
assert!(!manifest.feature.manage_workdir.enabled);
assert_eq!(manifest.worker.mode, crate::WorkerMode::Tools);
assert!(manifest.scope.allow.is_empty());
assert!(manifest.delegation_scope.allow.is_empty());
assert_eq!(
manifest.permissions.as_ref().unwrap().default_action,
crate::ToolPermissionAction::Deny
);
validate_profile_execution_target(&manifest, ProfileExecutionTarget::Standalone)
.unwrap();
let mut manage_manifest = manifest;
manage_manifest.feature.manage_workdir.enabled = true;
let error = validate_profile_execution_target(
&manage_manifest,
ProfileExecutionTarget::Standalone,
)
.unwrap_err();
assert!(matches!(
error,
ProfileError::UnsupportedExecutionTarget { requirements, .. }
if requirements == vec![WorkspaceAuthorityRequirement::ManageWorkdir]
));
}
}
#[test]
fn standalone_rejects_profiles_that_require_workspace_authority() {
let tmp = TempDir::new().unwrap();
+28 -5
View File
@@ -1504,7 +1504,13 @@ where
),
);
}
if feature_config.manage_workdir.enabled {
let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip;
let mut workdir_wip_feature = None;
if feature_config.manage_workdir.enabled
|| (wip_mode
&& feature_config.workdir_catalog.enabled
&& worker.workspace_client_handle().is_available())
{
// Workdir lifecycle is Workspace control-plane authority. The Worker
// receives only the injected WorkspaceClient and never Runtime URLs,
// repository paths, materializer handles, or cleanup sessions.
@@ -1520,7 +1526,7 @@ where
}
let shutdown_registry = spawned_registry.clone();
let reopen_registry = spawned_registry.clone();
feature_registry.add_module(
let module =
crate::feature::builtin::manage_workdir::ManageWorkdirFeature::with_child_lifecycle(
workspace_client,
worker.workdir_sessions(),
@@ -1529,8 +1535,11 @@ where
Box::pin(async move { child_registry.shutdown_internal().await })
}),
Arc::new(move || reopen_registry.reopen_internal()),
),
);
);
if feature_config.manage_workdir.enabled {
feature_registry.add_module(module.clone());
}
workdir_wip_feature = Some(module);
}
if feature_config.workspace_worker_discovery.enabled {
let workspace_client = worker.workspace_client_handle();
@@ -1585,9 +1594,23 @@ where
}
let host_worker_observation_provider = worker.worker_observation_provider();
let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip;
let wip_permissions = worker.manifest().permissions.clone();
let mut wip_mount_registry = crate::wip::WipMountRegistry::new();
if wip_mode && let Some(feature) = &workdir_wip_feature {
crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip(
&mut wip_mount_registry,
feature,
feature_config.workdir_catalog.enabled,
feature_config.manage_workdir.enabled,
wip_permissions.clone(),
)
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("mount Workdir WIP projection: {error}"),
)
})?;
}
{
let workspace_client = worker.workspace_client_handle();
let engine = worker.engine_mut();
@@ -5,6 +5,8 @@
//! endpoints, credentials, materializer handles, and operation sessions stay
//! behind [`WorkspaceClient`].
pub mod wip;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
@@ -67,6 +69,7 @@ pub struct ManageWorkdirFeature {
session_router: Arc<workdir::WorkdirSessionRouter>,
before_workdir_release: Option<BeforeWorkdirRelease>,
after_workdir_attach: Option<AfterWorkdirAttach>,
attachment_mutation_lock: Arc<tokio::sync::Mutex<()>>,
}
impl std::fmt::Debug for ManageWorkdirFeature {
@@ -86,6 +89,7 @@ impl ManageWorkdirFeature {
session_router: Arc::new(workdir::WorkdirSessionRouter::new()),
before_workdir_release: None,
after_workdir_attach: None,
attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
@@ -100,8 +104,19 @@ impl ManageWorkdirFeature {
session_router,
before_workdir_release: Some(before_workdir_release),
after_workdir_attach: Some(after_workdir_attach),
attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
fn backend(&self) -> WorkspaceHttpWorkdirBackend {
let mut backend = WorkspaceHttpWorkdirBackend::new(self.client.clone())
.with_session_router(self.session_router.clone())
.with_child_lifecycle(
self.before_workdir_release.clone(),
self.after_workdir_attach.clone(),
);
backend.attachment_mutation_lock = self.attachment_mutation_lock.clone();
backend
}
}
pub fn manage_workdir_feature(client: Arc<dyn WorkspaceClient>) -> ManageWorkdirFeature {
@@ -120,12 +135,7 @@ impl FeatureModule for ManageWorkdirFeature {
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone())
.with_session_router(self.session_router.clone())
.with_child_lifecycle(
self.before_workdir_release.clone(),
self.after_workdir_attach.clone(),
);
let backend = self.backend();
for (name, definition) in [
(
LIST_TOOL,
@@ -581,11 +591,24 @@ impl WorkspaceHttpWorkdirBackend {
}
fn detach(&self, alias: &str) -> Result<ToolOutput, ToolError> {
self.detach_expected(alias, None)
}
fn detach_expected(
&self,
alias: &str,
expected_connection_id: Option<&str>,
) -> Result<ToolOutput, ToolError> {
let workspace_id = encode_path_segment(self.workspace_id()?);
let alias_path = encode_path_segment(alias);
let suffix = expected_connection_id
.map(|id| format!("?expected_connection_id={}", encode_path_segment(id)))
.unwrap_or_default();
let response = self.execute_json::<WorkdirAttachmentResponse>(WorkspaceRequest {
method: WorkspaceRequestMethod::Delete,
path: format!("/api/w/{workspace_id}/workers/self/workdir-attachments/{alias_path}"),
path: format!(
"/api/w/{workspace_id}/workers/self/workdir-attachments/{alias_path}{suffix}"
),
body: None,
})?;
workdir_output(
@@ -622,6 +645,72 @@ impl WorkspaceHttpWorkdirBackend {
)
}
async fn detach_managed(
&self,
alias: &str,
expected_connection_id: Option<&str>,
) -> Result<ToolOutput, ToolError> {
let _mutation_guard = self.attachment_mutation_lock.lock().await;
let alias = validate_identity(alias, DETACH_TOOL, "alias")?;
if let Some(expected) = expected_connection_id {
// Under the shared local mutation lock, check lifetime before any
// local route exclusion or child cleanup. Backend DELETE still performs
// the authoritative conditional mutation under its lifecycle lock.
let workspace = encode_path_segment(self.workspace_id()?);
let page: serde_json::Value = self.execute_json(WorkspaceRequest::get(format!(
"/api/w/{workspace}/workers/self/workdir-attachments?limit=1&connection_id={}",
encode_path_segment(expected)
)))?;
let current = page
.get("items")
.and_then(serde_json::Value::as_array)
.is_some_and(|items| {
items.iter().any(|item| {
item.get("connection_id")
.and_then(serde_json::Value::as_str)
== Some(expected)
&& item.get("alias").and_then(serde_json::Value::as_str) == Some(alias)
})
});
if !current {
return Err(ToolError::StructuredConflict {
code: "workspace_http_409".into(),
message: "attachment lifetime changed before local cleanup".into(),
});
}
}
let alias_key = workdir::WorkdirAttachmentAlias::new(alias)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
self.session_router
.begin_detach(&alias_key)
.await
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if let Some(before_release) = &self.before_workdir_release
&& let Err(error) = before_release().await
{
self.session_router.cancel_detach(&alias_key);
return Err(ToolError::ExecutionFailed(format!(
"stop Internal SubWorkers before Workdir detach: {error}"
)));
}
let result = match self.detach_expected(alias, expected_connection_id) {
Ok(result) => result,
Err(error) => {
self.session_router.cancel_detach(&alias_key);
return Err(error);
}
};
self.session_router
.finish_detach(&alias_key)
.await
.map_err(|error| {
ToolError::ExecutionFailed(format!(
"detach Workdir session route after Backend release: {error}"
))
})?;
Ok(result)
}
fn execute_json<T: for<'de> Deserialize<'de>>(
&self,
request: WorkspaceRequest,
@@ -697,42 +786,10 @@ impl Tool for WorkspaceHttpWorkdirTool {
result
}
WorkdirOperation::Detach => {
let _mutation_guard = self.backend.attachment_mutation_lock.lock().await;
let input = parse_input::<WorkdirDetachInput>(input_json)?;
let alias = validate_identity(&input.alias, DETACH_TOOL, "alias")?;
let alias_key = workdir::WorkdirAttachmentAlias::new(alias)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
self.backend
.session_router
.begin_detach(&alias_key)
.await
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if let Some(before_release) = &self.backend.before_workdir_release
&& let Err(error) = before_release().await
{
self.backend.session_router.cancel_detach(&alias_key);
return Err(ToolError::ExecutionFailed(format!(
"stop Internal SubWorkers before Workdir detach: {error}"
)));
}
let result = match self.backend.detach(alias) {
Ok(result) => result,
Err(error) => {
self.backend.session_router.cancel_detach(&alias_key);
return Err(error);
}
};
self.backend
.session_router
.finish_detach(&alias_key)
.await
.map_err(|error| {
ToolError::ExecutionFailed(format!(
"detach Workdir session route after Backend release: {error}"
))
})?;
Ok(result)
self.backend.detach_managed(&input.alias, None).await
}
WorkdirOperation::Delete => self
.backend
.delete(parse_input::<WorkdirDeleteInput>(input_json)?),
@@ -747,6 +804,12 @@ fn parse_input<T: for<'de> Deserialize<'de>>(input: &str) -> Result<T, ToolError
fn decode_response<T: for<'de> Deserialize<'de>>(
response: WorkspaceResponse,
) -> Result<T, ToolError> {
if matches!(response.status, 401 | 403 | 404 | 409) {
return Err(ToolError::StructuredConflict {
code: format!("workspace_http_{}", response.status),
message: "Workspace lifecycle authority rejected the request before execution".into(),
});
}
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Workspace Workdir API returned HTTP {}: {}",
File diff suppressed because it is too large Load Diff
+1260 -25
View File
File diff suppressed because it is too large Load Diff
+175
View File
@@ -0,0 +1,175 @@
# Repository, Workdir and attachment WIP projection
## Objects and operations
The Host owns these root namespaces, using the same `WipMountRegistry`
namespace/resolver/contribution contracts as Tickets, Objectives and Merge Requests:
```text
/
├── repositories list
│ └── <encoded repository key> read
├── workdirs list, create
│ └── <encoded Workdir ID> read, attach, delete (when supported)
└── workdir-attachments list
└── <connection lifetime ID> read, detach
```
Operations are Interface declarations, **not child files**. There is no
`/features/manage-workdir`, no writable Repository catalog and no Repository
content tree here. `/checkouts` content projection is a separate provider (T-699),
as is Workspace configuration (T-695). Management Objects do not grant access to
unattached contents. A future content provider should return its entrance from
an authorized, live attachment; it must not infer content/command capability
from catalog or lifecycle access.
`path` in inventory results is an absolute WIP Object path. It is a reference,
not an OS path, a capability grant, or an instruction to reinterpret another
string argument as a route. Source Repository references use `repository_path`;
attachment references use `workdir_path`. Attach results include
`attachments_path` to discover the current connection and its lifetime ID.
### Identity and route encoding
Repository identity is the formal Backend `repository_key`, never its display
name or a guessed `main`. Workdir identity is `working_directory_id`, never a
Worker-local alias. A segment containing only ASCII letters, digits, `-`, `_`
is unchanged. All other UTF-8 identities are encoded as `~` followed by lowercase
hexadecimal UTF-8 bytes. The `~` marker is itself always encoded. Only the
canonical encoding is accepted; no alternate spellings, recursive URL decoding,
case folding, slash traversal or namespace fallback are accepted. For example,
`repo/name` is `/repositories/~7265706f2f6e616d65`. This is injective and accepts
existing valid keys, including Unicode and slash-containing identities.
Attachment identity is a fresh opaque Backend connection ID for each lifetime,
including detach/reattach to the **same** alias and Workdir. Read exposes alias,
Workdir ID and effective attachment capabilities as attributes. Workdir existence
and Worker connection are separate Objects. Detaching a connection does not
remove the Workdir or delete a logical settings space.
## Feature and authority separation
`feature.workdir_catalog.enabled` independently supplies read-only Repository,
Workdir and attachment reference operations. It defaults enabled so a WIP Worker
with Workspace authority and appropriate permissions can find registered keys
without Ticket or conversation hints. It does not add ordinary Tools or require
a filesystem/Bash location. Standalone Workers without Workspace authority have
no Workspace catalog mounted.
`feature.manage_workdir.enabled` supplies management operation implementations
on the **same Objects** using `contribute_operations` and
`contribute_dynamic_operations`. It remains default disabled. It neither owns
another resolver nor confers permission. The object provider is unique; duplicate
namespace, route, operation name or inconsistent Interface contributions fail at
registration through the common registry.
Native permission identities are:
| Operation | Manifest permission identity | Bound identity in permission input |
| --- | --- | --- |
| repositories.list | RepositoryList | list arguments |
| repository.read | RepositoryRead | repository_key |
| workdirs.list | WorkdirList | list arguments |
| workdir.read | WorkdirRead | working_directory_id |
| workdirs.create | WorkdirCreate | original create arguments |
| workdir.attach | WorkdirAttach | working_directory_id + alias |
| workdir.delete | WorkdirDelete | working_directory_id + reason |
| attachments.list | WorkdirAttachmentList | list arguments |
| attachment.read | WorkdirAttachmentRead | connection_id |
| attachment.detach | WorkdirDetach | current bound alias |
Read permissions gate direct item resolution as well as list item visibility.
The manifest's existing default policy is retained; `ask` is denied fail-closed.
An argument-dependent policy may advertise an operation that could be allowed,
but every call checks its exact reconstructed input before execution. Mutation
arguments cannot override path-bound Workdir or connection identity. Having
RepositoryRead is not WorkdirCreate; inventory/management permissions are not
file access, delegation or command execution grants. Backend caller/Workspace
identity, source proof, attachment capability ceilings and lifecycle policy are
checked by the existing paths on every request.
## Contextual Interfaces and stale observations
Providers opt into common Host contextual Interfaces. Operation publication is
the intersection of registered implementation, current target support and current
subject policy. The collection reference feature can remain available while the
management feature is disabled. External Workdirs without cleanup authority do
not expose delete. Only currently supported lifecycle operations are advertised;
no delete/command is added to logical attachments solely because they appear as
workspaces.
A contextual Interface reference is opaque and path-qualified as
`<base>/@/<hex UTF-8 Object path>`. Clients must use the returned reference, not
construct it. Fetch and call freshly resolve the same Object and filtered
Interface; base references cannot fetch an unfiltered descriptor. Descriptor and
Object validators invalidate old observations after target/connection/permission
changes. Validators are managed by the WIP Client, not manually by the LLM.
Cached Interface possession and known direct paths never bypass current checks.
Historic session text remains append-only and is not rewritten or replayed.
## Backend lifecycle and safe output
Catalogs are request-time projections of existing Backend authority, not another
store or synchronization job. WIP list pages default to 50 and accept 1–100
items. Repository/Workdir pages sort by stable identity and use the last identity
as `next_cursor`; they project the existing Backend inventory response rather
than maintaining a catalog copy. Attachment pages use the Backend's SQL-bounded,
alias-ordered limit/offset query and opaque `offset:<n>` cursors. A cursor is not
a snapshot or authority grant: restart listing after concurrent collection
changes. Filtering by exact `connection_id` is applied in the caller-scoped
Backend ledger before paging, so direct item lookup also works beyond the first
page. Unknown/expired IDs yield no item, never a current alias substitute.
Repository projection uses an explicit field
allowlist: formal key, kind/provider, any existing display metadata and explicit
nullable default selector. Source URI/path, credentials, remotes, Runtime
connection information and internal fingerprints are not model-visible.
Workdir projection includes stable ID, optional display name, public source kind
and Repository key, existing selector/ref/tree, status, cleanliness and occupancy
when available. Cleanup handles and External grant internals are not forwarded.
No missing attributes or implicit selectors are fabricated. Backend errors are
sanitized rather than forwarding raw diagnostic bodies with possible host paths.
Create and attach reuse the ordinary Workdir backend, request identity,
Worker-session router, mutation lock and Internal SubWorker lifecycle hooks.
Creation does **not** attach. Attach installs the Backend-attenuated session
capabilities, never a fabricated `ALL` capability for External Workdirs. Detach
uses the same begin/finish/cancel-detach path and command/operation exclusion as
Tools; the Backend checks expected connection identity under its existing
current-Worker lifecycle lock before closing sessions, then conditionally
updates that exact row in the existing connection ledger. Alias reuse cannot
retarget an old connection operation. Compensation retains the original
connection identity. Existing removal dispositions `removed`, `retained`, and
`attention_required`, plus retryability, remain intact.
Stale identity/validator rejection is distinct from unknown operation outcome.
Transport failure, invalid post-dispatch response or uncertain lifecycle failure
is **not** proof that no effect occurred; it must not be automatically retried.
Inspect current state or request operator guidance. No new retry or attachment
ledger is introduced by WIP.
When native management is mounted, `WorkdirList/Create/Attach/Detach/Delete` are
claimed as represented native capabilities, so `/tools/Workdir*` is not also
published. Ordinary Tools mode keeps its names and inputs. Existing native
Ticket/Objective/Merge Request routes retain their T-698 contracts.
## First-Workspace example
1. `WipDiscover(path="/", depth=1)` finds `repositories`, `workdirs` and
`workdir-attachments` when permitted.
2. Inspect the returned Repository collection Interface and call `list` with a
bounded limit. An empty registered/visible catalog explicitly returns an empty
list; do not invent a key. Follow `next_cursor` when supplied.
3. Discover the returned item `path`, inspect its Interface and call `read`.
`default_selector: null` means no configured default, not `main`.
4. Inspect the Workdir collection and `create` using the discovered formal key,
with optional selector, display name and Runtime under the existing contract.
5. Follow created `item.path`, discover/inspect and `attach` with a chosen
Worker-local alias. This is separate from creation.
6. Follow `attachments_path`, `list`, then discover/read the returned connection
path to confirm alias, Workdir and effective capabilities.
7. Call that connection's `detach` with no identity override. The observed
lifetime ID guards alias reuse; the Workdir remains in its inventory.
Each step uses current WIP observations and no Ticket-derived key, host path,
Runtime endpoint or manual validator. Explicitly refresh/reset discovery after
external changes and before reconstructing a rejected stale call.
+1 -1
View File
@@ -75,7 +75,7 @@ The built-in native route migration is:
| `/features/objective/objectives[/<reference>]` | `/objectives[/<reference>]` |
| `/features/merge-request/merge-requests[/<reference>]` | `/merge-requests[/<reference>]` |
`/repositories` and `/workdirs` can be allocated by a later provider using the same registration contract; this layer does not implement or pre-allocate those domains. `/checkouts` content projection is likewise outside this contract.
The built-in Repository/Workdir reference provider allocates `/repositories`, `/workdirs`, and `/workdir-attachments` using this contract; `manage-workdir` contributes operations rather than owning another Object. See [Repository, Workdir and attachment WIP projection](workdir-wip-projection.md) for identity encoding, reference/management permissions, attachment lifetime safety, and first-Workspace discovery. `/checkouts` content projection remains a separate provider.
### Registering a subsequent provider