fix subject authority and bounded memory browsing

This commit is contained in:
2026-10-03 01:18:24 +09:00
parent f4966b78ea
commit 13bce155aa
40 changed files with 927 additions and 174 deletions
+4 -1
View File
@@ -422,7 +422,10 @@ impl BackendWorkspaceProductClient {
client
.subjektiv_subject_list(
workspace_id,
SubjektivSubjectListQuery { limit: Some(limit) },
SubjektivSubjectListQuery {
limit: Some(limit),
cursor: None,
},
)
.await
})
+30 -3
View File
@@ -391,6 +391,12 @@ impl ResolvedSubjektivFeatureConfig {
self.profile.enabled
}
/// Returns whether the host attached this profile policy to trusted subject
/// authority. Profile enablement without a settings snapshot is inert.
pub fn execution_enabled(&self) -> bool {
self.profile.enabled && self.workspace_settings.is_some()
}
pub fn bind_workspace_settings(
&mut self,
settings: WorkspaceMemorySettingsSnapshot,
@@ -413,9 +419,9 @@ impl ResolvedSubjektivFeatureConfig {
}
pub fn validate_execution(&self) -> Result<(), &'static str> {
if self.profile.enabled && self.workspace_settings.is_none() {
return Err("enabled subjektiv feature requires trusted Workspace settings");
}
// `profile.enabled` is reusable policy. Only a host-bound settings
// snapshot activates subject execution; an ordinary Worker or Internal
// SubWorker with the same Profile remains inert.
if !self.profile.enabled && self.workspace_settings.is_some() {
return Err("disabled subjektiv feature must not carry Workspace settings");
}
@@ -1719,6 +1725,27 @@ model_id = "claude-sonnet-4-20250514"
assert!(disabled.bind_workspace_settings(snapshot).is_err());
}
#[test]
fn resolved_subjektiv_policy_is_inert_until_trusted_attachment() {
let snapshot = WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(),
settings_revision: 1,
language: "English".to_string(),
};
let mut enabled = ResolvedSubjektivFeatureConfig::default();
enabled.profile.enabled = true;
assert!(enabled.validate_execution().is_ok());
assert!(!enabled.execution_enabled());
enabled.bind_workspace_settings(snapshot.clone()).unwrap();
assert!(enabled.validate_execution().is_ok());
assert!(enabled.execution_enabled());
let mut disabled = ResolvedSubjektivFeatureConfig::default();
disabled.workspace_settings = Some(snapshot.clone());
assert!(disabled.validate_execution().is_err());
assert!(disabled.bind_workspace_settings(snapshot).is_err());
}
#[test]
fn current_manifest_rejects_legacy_top_level_memory_authority() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
+4
View File
@@ -444,6 +444,10 @@ pub struct CreateWorkerRequest {
pub workspace_api: Option<WorkspaceApiRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
/// Backend-attested subject or consolidation attachment. Profile policy alone
/// never activates subjektiv for an ordinary Workspace Worker.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub subjektiv_attached: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+5 -1
View File
@@ -4605,6 +4605,8 @@ pub struct SubjektivSubjectListQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1, max = 100))]
pub limit: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
@@ -4613,6 +4615,8 @@ pub struct SubjektivSubjectListResponse {
#[schemars(range(min = 1, max = 100))]
pub limit: usize,
pub items: Vec<SubjektivSubjectResponse>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
pub has_more: bool,
}
@@ -9849,7 +9853,7 @@ pub fn memory_api_typescript() -> String {
export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, has_more: boolean, };
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
export type SubjektivMemoryState = "active" | "resolved" | "retracted";
+4
View File
@@ -298,6 +298,10 @@ pub struct CreateWorkerRequest {
/// Backend-authored immutable Workspace Memory settings snapshot.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
/// Backend-attested subject or consolidation attachment. A reusable Profile
/// may enable subjektiv policy, but only this host-owned bit activates it.
#[serde(default, skip_serializing_if = "is_false")]
pub subjektiv_attached: bool,
}
/// Last persisted Worker lifecycle status.
+2
View File
@@ -3102,6 +3102,7 @@ mod tests {
settings_revision: 1,
language: "English".to_string(),
}),
subjektiv_attached: false,
}
}
@@ -4081,6 +4082,7 @@ mod ws_tests {
settings_revision: 1,
language: "English".to_string(),
}),
subjektiv_attached: false,
}
}
+1
View File
@@ -5992,6 +5992,7 @@ mod tests {
settings_revision: 1,
language: "English".to_string(),
}),
subjektiv_attached: false,
}
}
+110 -18
View File
@@ -944,7 +944,7 @@ fn bind_workspace_memory_settings(
request: &CreateWorkerRequest,
) -> Result<(), String> {
let Some(snapshot) = request.memory_settings.as_ref() else {
if request.workspace_api.is_some() {
if request.workspace_api.is_some() || request.subjektiv_attached {
return Err(
"Workspace Worker request is missing its bound Memory settings snapshot"
.to_string(),
@@ -967,7 +967,12 @@ fn bind_workspace_memory_settings(
.bind_workspace_settings(snapshot.clone())
.map_err(str::to_string)?;
}
if manifest.feature.subjektiv.profile.enabled {
if request.subjektiv_attached {
if !manifest.feature.subjektiv.profile.enabled {
return Err(
"subject-attached Worker profile does not enable subjektiv policy".to_string(),
);
}
manifest
.feature
.subjektiv
@@ -992,6 +997,12 @@ fn validate_worker_memory_settings(
request: &CreateWorkerRequest,
) -> Result<(), String> {
let Some(expected) = request.memory_settings.as_ref() else {
if request.subjektiv_attached {
return Err(
"subject-attached Worker restore is missing its trusted settings snapshot"
.to_string(),
);
}
return Ok(());
};
manifest
@@ -1004,30 +1015,50 @@ fn validate_worker_memory_settings(
.subjektiv
.validate_execution()
.map_err(str::to_string)?;
for (feature, enabled, actual) in [
(
"Memory",
manifest.feature.memory.profile.enabled,
manifest.feature.memory.workspace_settings(),
),
(
"subjektiv",
manifest.feature.subjektiv.profile.enabled,
manifest.feature.subjektiv.workspace_settings(),
),
] {
if enabled && actual.as_ref() != Some(expected) {
let actual = actual.ok_or_else(|| {
format!("Workspace Worker restored without its bound {feature} settings snapshot")
if manifest.feature.memory.profile.enabled
&& manifest.feature.memory.workspace_settings().as_ref() != Some(expected)
{
let actual = manifest
.feature
.memory
.workspace_settings()
.ok_or_else(|| {
"Workspace Worker restored without its bound Memory settings snapshot".to_string()
})?;
return Err(format!(
"Workspace Worker Memory settings snapshot mismatch: expected {} revision {}, restored {} revision {}",
expected.workspace_id,
expected.settings_revision,
actual.workspace_id,
actual.settings_revision
));
}
let subjektiv_settings = manifest.feature.subjektiv.workspace_settings();
if request.subjektiv_attached {
if !manifest.feature.subjektiv.profile.enabled {
return Err(
"subject-attached Worker restored with a Profile that disables subjektiv policy"
.to_string(),
);
}
if subjektiv_settings.as_ref() != Some(expected) {
let actual = subjektiv_settings.ok_or_else(|| {
"subject-attached Worker restored without its bound subjektiv settings snapshot"
.to_string()
})?;
return Err(format!(
"Workspace Worker {feature} settings snapshot mismatch: expected {} revision {}, restored {} revision {}",
"Workspace Worker subjektiv settings snapshot mismatch: expected {} revision {}, restored {} revision {}",
expected.workspace_id,
expected.settings_revision,
actual.workspace_id,
actual.settings_revision
));
}
} else if subjektiv_settings.is_some() {
return Err(
"ordinary Workspace Worker restored with an unauthorized subjektiv attachment"
.to_string(),
);
}
Ok(())
}
@@ -4412,9 +4443,70 @@ mod tests {
worker_observation_grants: Vec::new(),
workspace_api: None,
memory_settings: None,
subjektiv_attached: false,
}
}
#[test]
fn host_attachment_alone_activates_subjektiv_profile_policy() {
let manifest = || {
let mut manifest = WorkerManifest::from_toml(
r#"
[worker]
name = "subjektiv-attachment-test"
pwd = "./"
[model]
scheme = "anthropic"
model_id = "test-model"
auth = { kind = "none" }
[engine]
max_tokens = 100
[[scope.allow]]
target = "./"
permission = "read"
"#,
)
.unwrap();
manifest.feature.subjektiv.profile.enabled = true;
manifest.feature.subjektiv.profile.extraction.enabled = true;
manifest
};
let settings = manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-subject".to_string(),
settings_revision: 3,
language: "English".to_string(),
};
let mut ordinary_request = create_request("ordinary");
ordinary_request.workspace_api = Some(WorkspaceApiRef {
workspace_id: settings.workspace_id.clone(),
base_url: "http://workspace.invalid".to_string(),
});
ordinary_request.memory_settings = Some(settings.clone());
let mut ordinary = manifest();
bind_workspace_memory_settings(&mut ordinary, &ordinary_request).unwrap();
assert!(ordinary.feature.subjektiv.profile.enabled);
assert!(!ordinary.feature.subjektiv.execution_enabled());
validate_worker_memory_settings(&ordinary, &ordinary_request).unwrap();
let mut attached_request = ordinary_request.clone();
attached_request.subjektiv_attached = true;
let mut attached = manifest();
bind_workspace_memory_settings(&mut attached, &attached_request).unwrap();
assert!(attached.feature.subjektiv.execution_enabled());
assert_eq!(
attached.feature.subjektiv.workspace_settings(),
Some(settings.clone())
);
validate_worker_memory_settings(&attached, &attached_request).unwrap();
assert!(validate_worker_memory_settings(&attached, &ordinary_request).is_err());
attached_request.memory_settings = None;
assert!(bind_workspace_memory_settings(&mut manifest(), &attached_request).is_err());
}
fn git(path: &std::path::Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
+3 -3
View File
@@ -825,7 +825,7 @@ pub(crate) fn is_builtin_subjektiv_consolidation_profile(
}
pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerManifest) -> bool {
manifest.feature.subjektiv.profile.enabled
manifest.feature.subjektiv.execution_enabled()
&& !is_builtin_subjektiv_consolidation_profile(manifest)
}
@@ -890,10 +890,10 @@ impl SubjektivConsolidationFeatureInstallPlan {
config
.validate_execution()
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
if !config.profile.enabled || config.profile.extraction.enabled {
if !config.execution_enabled() || config.profile.extraction.enabled {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv consolidation profile requires subject settings without automatic extraction",
"subjektiv consolidation profile requires trusted subject settings without automatic extraction",
));
}
let workspace_id = client.workspace_id().ok_or_else(|| {
@@ -199,7 +199,7 @@ impl SubjektivLifecycleFeature {
workspace_context: WorkerWorkspaceContext,
event_tx: Option<broadcast::Sender<Event>>,
) -> std::io::Result<Option<Self>> {
if !lifecycle_enabled || !config.profile.enabled || !config.profile.extraction.enabled {
if !lifecycle_enabled || !config.execution_enabled() || !config.profile.extraction.enabled {
return Ok(None);
}
config
@@ -109,6 +109,12 @@ impl SubjektivSurfaceLifecycleFeature {
.subjektiv
.validate_execution()
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
if !manifest.feature.subjektiv.execution_enabled() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv surface generation requires trusted subject attachment",
));
}
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
@@ -78,7 +78,7 @@ impl SubjektivMemoryFeature {
capture: CommittedSessionCaptureHandle,
client: Arc<dyn WorkspaceClient>,
) -> std::io::Result<Option<Self>> {
if !config.profile.enabled {
if !config.execution_enabled() {
return Ok(None);
}
config
@@ -55,7 +55,7 @@ impl SubjektivSessionFeature {
capture: CommittedSessionCaptureHandle,
client: Arc<dyn WorkspaceClient>,
) -> std::io::Result<Option<Self>> {
if !config.profile.enabled {
if !config.execution_enabled() {
return Ok(None);
}
config
+58 -17
View File
@@ -925,25 +925,30 @@ fn bind_child_memory_settings(
parent: &manifest::WorkerManifest,
child: &mut manifest::WorkerManifest,
) -> Result<(), String> {
if !child.feature.memory.profile.enabled {
return child
if child.feature.memory.profile.enabled {
let workspace_settings = parent.feature.memory.workspace_settings().ok_or_else(|| {
"enabled child Memory feature requires the parent's trusted Workspace settings snapshot"
.to_string()
})?;
child
.feature
.memory
.validate_execution()
.map_err(str::to_string);
.bind_workspace_settings(workspace_settings)
.map_err(str::to_string)?;
}
let workspace_settings = parent.feature.memory.workspace_settings().ok_or_else(|| {
"enabled child Memory feature requires the parent's trusted Workspace settings snapshot"
.to_string()
})?;
child
.feature
.memory
.bind_workspace_settings(workspace_settings)
.validate_execution()
.map_err(str::to_string)?;
// An Internal SubWorker has no Backend keyed-singleton lease of its own.
// Preserve reusable subjektiv profile policy but never copy the parent's
// trusted activation snapshot into the child.
child.feature.subjektiv.workspace_settings = None;
child
.feature
.memory
.subjektiv
.validate_execution()
.map_err(str::to_string)
}
@@ -1292,7 +1297,7 @@ enabled = false
}
#[tokio::test]
async fn reviewer_profile_write_scope_exposes_command_tools_and_notifies_parent_controller() {
async fn builtin_reviewer_spawn_from_subject_parent_is_inert_and_notifies_parent() {
let runtime = TempDir::new().unwrap();
let workspace_root = runtime.path().join("project");
let bash_output_dir = runtime.path().join("bash-output");
@@ -1310,6 +1315,17 @@ enabled = false
allow: vec![abs_rule(&workspace_root, Permission::Write)],
deny: Vec::new(),
};
manifest.feature.subjektiv.profile.enabled = true;
manifest.feature.subjektiv.profile.extraction.enabled = true;
manifest
.feature
.subjektiv
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-test".to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
let spawner_scope = SharedScope::new(Scope::from_config(&manifest.scope).unwrap());
let registry = SpawnedWorkerRegistry::new_internal("parent".into(), spawner_scope.clone());
let workspace_context = crate::worker::WorkerWorkspaceContext::with_client(
@@ -1359,7 +1375,7 @@ enabled = false
}));
let input = serde_json::json!({
"name": "reviewer-child",
"profile": "project:reviewer",
"profile": "builtin:reviewer",
"instruction": "role.reviewer",
"task": "review immutable commit",
"scope": [{
@@ -1409,6 +1425,14 @@ enabled = false
record.installed_tools
);
}
assert!(
record
.installed_tools
.iter()
.all(|name| !name.starts_with("Subjektiv")),
"Reviewer child must not install independently scoped subjektiv tools: {:?}",
record.installed_tools
);
assert!(
!record
.installed_tools
@@ -1881,14 +1905,22 @@ enabled = false
let temp = tempfile::tempdir().unwrap();
let mut parent = parent_manifest(temp.path(), None);
parent.feature.memory.profile.enabled = true;
let settings = manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(),
settings_revision: 4,
language: "日本語".to_string(),
};
parent
.feature
.memory
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(),
settings_revision: 4,
language: "日本語".to_string(),
})
.bind_workspace_settings(settings.clone())
.unwrap();
parent.feature.subjektiv.profile.enabled = true;
parent.feature.subjektiv.profile.extraction.enabled = true;
parent
.feature
.subjektiv
.bind_workspace_settings(settings)
.unwrap();
let mut child = parent.clone();
child.feature.memory.workspace_settings = None;
@@ -1898,6 +1930,15 @@ enabled = false
child.feature.memory.workspace_settings(),
parent.feature.memory.workspace_settings()
);
assert!(child.feature.subjektiv.profile.enabled);
assert!(!child.feature.subjektiv.execution_enabled());
let mut ordinary_parent = parent.clone();
ordinary_parent.feature.subjektiv.workspace_settings = None;
let mut ordinary_child = child.clone();
ordinary_child.feature.memory.workspace_settings = None;
bind_child_memory_settings(&ordinary_parent, &mut ordinary_child).unwrap();
assert!(!ordinary_child.feature.subjektiv.execution_enabled());
child.feature.memory.profile.enabled = false;
assert!(bind_child_memory_settings(&parent, &mut child).is_err());
+44 -22
View File
@@ -1037,34 +1037,15 @@ permission = "write"
#[tokio::test]
async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() {
let workspace = tempfile::tempdir().unwrap();
let mut resolved = ProfileResolver::new()
let resolved = ProfileResolver::new()
.with_workspace_base(workspace.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"),
ProfileResolveOptions::with_worker_name("orchestrator-worker"),
)
.unwrap();
let settings = manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-test".to_string(),
settings_revision: 1,
language: "English".to_string(),
};
if resolved.manifest.feature.memory.enabled() {
resolved
.manifest
.feature
.memory
.bind_workspace_settings(settings.clone())
.unwrap();
}
if resolved.manifest.feature.subjektiv.enabled() {
resolved
.manifest
.feature
.subjektiv
.bind_workspace_settings(settings)
.unwrap();
}
assert!(resolved.manifest.feature.subjektiv.enabled());
assert!(!resolved.manifest.feature.subjektiv.execution_enabled());
let workspace_context =
WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient));
let client = MockClient::new(simple_text_events());
@@ -1090,6 +1071,47 @@ async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() {
assert!(installed.iter().any(|name| name == "WorkerRemove"));
assert!(installed.iter().any(|name| name == "WorkdirDelete"));
assert!(
installed.iter().all(|name| !name.starts_with("Subjektiv")),
"ordinary Orchestrator must not install subject-scoped tools: {installed:?}"
);
}
#[tokio::test]
async fn builtin_coder_commits_without_unattached_subjektiv_lifecycle() {
let workspace = tempfile::tempdir().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(workspace.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "coder"),
ProfileResolveOptions::with_worker_name("coder-worker"),
)
.unwrap();
assert!(resolved.manifest.feature.subjektiv.enabled());
assert!(!resolved.manifest.feature.subjektiv.execution_enabled());
let client = MockClient::new(simple_text_events());
let client_for_assert = client.clone();
let (worker, _pwd) = make_worker_with_manifest_and_workspace_context(
client,
resolved.manifest,
WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient)),
)
.await;
let handle = spawn_controller(worker).await;
handle
.send(Method::submit_text(
protocol::new_submission_request_id(),
"Hello",
))
.await
.unwrap();
wait_for_status(&handle, WorkerStatus::Idle).await;
let installed = request_tool_names(&wait_for_captured_request(&client_for_assert).await);
assert!(
installed.iter().all(|name| !name.starts_with("Subjektiv")),
"ordinary Coder must not install subject-scoped tools: {installed:?}"
);
}
#[tokio::test]
+8
View File
@@ -534,6 +534,9 @@ pub struct WorkerSpawnRequest {
/// Backend-authored immutable Workspace Memory settings snapshot.
#[serde(skip, default)]
pub resolved_memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
/// Backend-attested subjektiv singleton attachment. Browser/model input cannot set it.
#[serde(skip, default)]
pub resolved_subjektiv_attached: bool,
/// Backend-owned feature enablement; client input cannot set it.
#[serde(skip, default)]
pub resolved_worker_observation_enabled: bool,
@@ -5353,6 +5356,7 @@ fn runtime_create_worker_request(
worker_observation_grants: request.resolved_worker_observation_grants.clone(),
workspace_api: Some(workspace_api),
memory_settings: request.resolved_memory_settings.clone(),
subjektiv_attached: request.resolved_subjektiv_attached,
}
}
@@ -7075,6 +7079,7 @@ mod tests {
resolved_control_operation: None,
resolved_workspace_api: Some(test_workspace_api()),
resolved_memory_settings: Some(test_memory_settings()),
resolved_subjektiv_attached: false,
}
}
@@ -7363,6 +7368,7 @@ mod tests {
resolved_control_operation: None,
resolved_workspace_api: Some(test_workspace_api()),
resolved_memory_settings: Some(test_memory_settings()),
resolved_subjektiv_attached: false,
},
)
.unwrap();
@@ -7465,6 +7471,7 @@ mod tests {
resolved_control_operation: None,
resolved_workspace_api: Some(test_workspace_api()),
resolved_memory_settings: Some(test_memory_settings()),
resolved_subjektiv_attached: false,
},
)
.unwrap();
@@ -7505,6 +7512,7 @@ mod tests {
resolved_control_operation: None,
resolved_workspace_api: Some(test_workspace_api()),
resolved_memory_settings: Some(test_memory_settings()),
resolved_subjektiv_attached: false,
},
)
.unwrap();
@@ -74,6 +74,7 @@ fn create_request(name: &str) -> CreateWorkerRequest {
settings_revision: 1,
language: "English".to_string(),
}),
subjektiv_attached: false,
}
}
+276 -13
View File
@@ -3410,6 +3410,7 @@ impl WorkspaceApi {
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: Some(control_operation),
},
) {
@@ -5977,6 +5978,7 @@ fn worker_spawn_request_from_api(
resolved_config_bundle: None,
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
resolved_control_operation: None,
@@ -16982,6 +16984,14 @@ async fn scoped_create_subjektiv_subject(
))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct SubjektivSubjectCursor {
workspace_id: String,
created_at: String,
subject_id: String,
}
async fn scoped_list_subjektiv_subjects(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
@@ -16996,9 +17006,45 @@ async fn scoped_list_subjektiv_subjects(
))
.into());
}
let cursor = query
.cursor
.as_deref()
.map(|cursor| decode_subjektiv_cursor::<SubjektivSubjectCursor>("subjects", cursor))
.transpose()?;
if cursor
.as_ref()
.is_some_and(|cursor| cursor.workspace_id != path.workspace_id)
{
return Err(Error::InvalidInput(
"subjektiv subject cursor does not match Workspace".to_string(),
)
.into());
}
let page = open_subjektiv_store(&api)?
.list_subjects(limit)
.list_subjects_after(
limit,
cursor
.as_ref()
.map(|cursor| (cursor.created_at.as_str(), cursor.subject_id.as_str())),
)
.map_err(subjektiv_store_error)?;
let next_cursor = if page.has_more {
page.items
.last()
.map(|subject| {
encode_subjektiv_cursor(
"subjects",
&SubjektivSubjectCursor {
workspace_id: path.workspace_id.clone(),
created_at: subject.created_at.clone(),
subject_id: subject.id.clone(),
},
)
})
.transpose()?
} else {
None
};
Ok(Json(server_api::SubjektivSubjectListResponse {
limit,
items: page
@@ -17006,6 +17052,7 @@ async fn scoped_list_subjektiv_subjects(
.into_iter()
.map(|subject| subjektiv_subject_response(&api, subject))
.collect(),
next_cursor,
has_more: page.has_more,
}))
}
@@ -17179,7 +17226,7 @@ async fn scoped_start_subjektiv_subject_worker(
.into());
}
worker.singleton_key = Some(subjektiv_singleton_key(&path.subject_id)?);
create_workspace_worker(State(api), headers, Json(worker)).await
create_workspace_worker_inner(api, headers, worker, None, true).await
}
async fn scoped_stage_subjektiv_candidate(
@@ -20190,6 +20237,7 @@ fn start_subjektiv_staging_consolidation(
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: true,
resolved_control_operation: None,
},
)?;
@@ -20359,6 +20407,7 @@ fn start_memory_staging_consolidation(
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
},
)?;
@@ -21480,9 +21529,14 @@ async fn spawn_known_worker(
operation_id: scoped_worker_control_operation_id(&controller, &operation_id),
input_fingerprint,
});
let response =
create_workspace_worker_inner(api.clone(), headers, request, resolved_control_operation)
.await?;
let response = create_workspace_worker_inner(
api.clone(),
headers,
request,
resolved_control_operation,
false,
)
.await?;
if let Err(error) = api
.store
.create_worker_control_grant(&WorkerControlGrantRecord {
@@ -21890,6 +21944,7 @@ async fn scoped_start_workspace_orchestrator(
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
},
)?;
@@ -27040,7 +27095,9 @@ fn validate_caller_worker_singleton_key(singleton_key: Option<&str>) -> Result<(
return Ok(());
};
crate::store::validate_worker_singleton_key(singleton_key)?;
if crate::hosts::is_reserved_internal_worker_singleton_key(singleton_key) {
if crate::hosts::is_reserved_internal_worker_singleton_key(singleton_key)
|| singleton_key.starts_with(SUBJEKTIV_SINGLETON_PREFIX)
{
return Err(Error::InvalidInput(format!(
"Worker singleton key {singleton_key:?} is reserved for a Backend-managed Worker"
)));
@@ -27053,7 +27110,7 @@ async fn create_workspace_worker(
headers: HeaderMap,
Json(request): Json<CreateWorkspaceWorkerRequest>,
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
create_workspace_worker_inner(api, headers, request, None).await
create_workspace_worker_inner(api, headers, request, None, false).await
}
async fn create_workspace_worker_inner(
@@ -27061,6 +27118,7 @@ async fn create_workspace_worker_inner(
headers: HeaderMap,
request: CreateWorkspaceWorkerRequest,
resolved_control_operation: Option<WorkerControlOperation>,
resolved_subjektiv_attached: bool,
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
let CreateWorkspaceWorkerRequest {
runtime_id,
@@ -27072,7 +27130,21 @@ async fn create_workspace_worker_inner(
workdir_attachments,
control_operation_id: _,
} = request;
validate_caller_worker_singleton_key(singleton_key.as_deref())?;
if resolved_subjektiv_attached {
let singleton_key = singleton_key.as_deref().ok_or_else(|| {
Error::InvalidInput(
"subject-attached Worker launch requires a Backend-owned singleton key".to_string(),
)
})?;
if !singleton_key.starts_with(SUBJEKTIV_SINGLETON_PREFIX) {
return Err(Error::InvalidInput(
"subject-attached Worker launch requires a subject singleton key".to_string(),
)
.into());
}
} else {
validate_caller_worker_singleton_key(singleton_key.as_deref())?;
}
let config_state = api
.config_store
.load_workspace_config(&api.config.workspace_id)?
@@ -27167,6 +27239,7 @@ async fn create_workspace_worker_inner(
resolved_control_operation,
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached,
};
validate_ticket_assignment_spawn(&api, &runtime_id, &request)?;
let assignment = request.ticket_assignment.clone();
@@ -35967,6 +36040,7 @@ mod tests {
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
};
assert!(
@@ -36223,6 +36297,7 @@ mod tests {
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
};
@@ -37607,7 +37682,7 @@ mod tests {
let request = || CreateWorkspaceWorkerRequest {
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
display_name: "Subject Worker".to_string(),
singleton_key: Some("subjektiv:subject-42".to_string()),
singleton_key: Some("custom:subject-42".to_string()),
profile: Some("builtin:companion".to_string()),
ticket_assignment: None,
initial_submit: Vec::new(),
@@ -37621,7 +37696,7 @@ mod tests {
.unwrap();
assert_eq!(
created.worker.singleton_key.as_deref(),
Some("subjektiv:subject-42")
Some("custom:subject-42")
);
let Json(duplicate) =
create_workspace_worker(State(api.clone()), HeaderMap::new(), Json(request()))
@@ -37676,7 +37751,7 @@ mod tests {
assert_eq!(restored.state, server_api::WorkerRestoreState::Rejected);
assert_eq!(
api.store
.current_worker_singleton_owner(&api.config.workspace_id, "subjektiv:subject-42",)
.current_worker_singleton_owner(&api.config.workspace_id, "custom:subject-42",)
.unwrap()
.unwrap()
.worker,
@@ -38081,6 +38156,7 @@ mod tests {
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
},
)
@@ -38441,6 +38517,126 @@ mod tests {
assert_eq!(repeated.staging_resolutions_deleted, 0);
}
#[tokio::test]
async fn subjektiv_browser_routes_enforce_router_auth_scope_and_owner_reset() {
let workspace = tempfile::tempdir().unwrap();
init_clean_git_workspace(workspace.path());
let api = test_api(workspace.path()).await;
let owner_token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
let non_owner_token = seed_test_api_token(api.store.as_ref(), "subjektiv-non-owner");
let store = open_subjektiv_store(&api).unwrap();
let subject = store
.create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap())
.unwrap();
let memory = store
.create_memory(
&subject.id,
crate::subjektiv::MemoryDraft::active(
memory::extract::CandidateKind::Lesson,
"Router contract",
"Browser routes cross the generated Axum boundary.",
"Proves route wiring",
"fixture",
),
)
.unwrap();
api.store
.upsert_memory_document(&crate::store::MemoryDocumentRecord {
workspace_id: TEST_WORKSPACE_ID.to_string(),
body_md: "# Legacy reset fixture\n".to_string(),
created_at: "2026-10-02T00:00:00Z".to_string(),
updated_at: "2026-10-02T00:00:00Z".to_string(),
})
.unwrap();
let app = build_router(api.clone());
let subject_root = format!(
"/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}",
subject.id
);
let memory_root = format!("{subject_root}/memories/{}", memory.id);
request_json(
app.clone(),
"GET",
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects?limit=1"),
None,
StatusCode::UNAUTHORIZED,
)
.await;
let subjects = request_json_authenticated(
app.clone(),
"GET",
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects?limit=1"),
None,
&owner_token,
StatusCode::OK,
)
.await;
assert_eq!(subjects["items"][0]["id"], subject.id);
for uri in [
subject_root.clone(),
format!("{subject_root}/surface"),
format!("{subject_root}/memories?limit=1"),
memory_root.clone(),
format!("{memory_root}/revisions?limit=1"),
] {
request_json_authenticated(
app.clone(),
"GET",
&uri,
None,
&owner_token,
StatusCode::OK,
)
.await;
}
request_json_authenticated(
app.clone(),
"GET",
"/api/w/another-workspace/subjektiv/subjects?limit=1",
None,
&owner_token,
StatusCode::NOT_FOUND,
)
.await;
request_json_authenticated(
app.clone(),
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/memory/reset"),
Some(serde_json::json!({
"confirmation": format!("RESET LEGACY MEMORY {TEST_WORKSPACE_ID}"),
})),
&non_owner_token,
StatusCode::FORBIDDEN,
)
.await;
assert!(
api.store
.get_memory_document(TEST_WORKSPACE_ID)
.unwrap()
.is_some()
);
let reset = request_json_authenticated(
app,
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/memory/reset"),
Some(serde_json::json!({
"confirmation": format!("RESET LEGACY MEMORY {TEST_WORKSPACE_ID}"),
})),
&owner_token,
StatusCode::OK,
)
.await;
assert_eq!(reset["documents_deleted"], 1);
assert_eq!(
store
.scoped_memory(&subject.id, &memory.id)
.unwrap()
.revision,
1
);
}
#[tokio::test]
async fn subjektiv_browser_reads_are_bounded_scoped_and_revision_exact() {
let workspace = tempfile::tempdir().unwrap();
@@ -38459,7 +38655,10 @@ mod tests {
AxumPath(ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
}),
Query(server_api::SubjektivSubjectListQuery { limit: Some(0) }),
Query(server_api::SubjektivSubjectListQuery {
limit: Some(0),
cursor: None,
}),
)
.await
.unwrap_err();
@@ -38473,12 +38672,31 @@ mod tests {
AxumPath(ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
}),
Query(server_api::SubjektivSubjectListQuery { limit: Some(1) }),
Query(server_api::SubjektivSubjectListQuery {
limit: Some(1),
cursor: None,
}),
)
.await
.unwrap();
assert_eq!(subjects.items.len(), 1);
assert!(subjects.has_more);
let Json(next_subjects) = scoped_list_subjektiv_subjects(
State(api.clone()),
AxumPath(ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
}),
Query(server_api::SubjektivSubjectListQuery {
limit: Some(1),
cursor: subjects.next_cursor.clone(),
}),
)
.await
.unwrap();
assert_eq!(next_subjects.items.len(), 1);
assert_ne!(next_subjects.items[0].id, subjects.items[0].id);
assert!(!next_subjects.has_more);
assert!(next_subjects.next_cursor.is_none());
let missing = scoped_get_subjektiv_subject(
State(api.clone()),
@@ -38744,6 +38962,32 @@ mod tests {
created.worker.singleton_key.as_deref(),
Some(format!("subjektiv:{}", subject.id).as_str())
);
let worker_ref = RuntimeWorkerRef::new(&created.runtime_id, &created.worker_id);
let accepted = api
.runtime
.send_input(
&worker_ref,
WorkerInputRequest {
kind: WorkerInputKind::User,
content: "Commit one subject-scoped Session".to_string(),
submission_request_id: None,
segments: None,
},
)
.unwrap();
assert_eq!(accepted.state, InternalWorkerOperationState::Accepted);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
let detail = api.runtime.worker(&worker_ref).unwrap();
if detail.state == "idle" {
break;
}
assert!(
std::time::Instant::now() < deadline,
"subject-attached Worker did not complete its committed turn"
);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
let context = server_api::ServerRequestContext {
actor: None,
worker_source: None,
@@ -39795,6 +40039,7 @@ mod tests {
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: Some(WorkerControlOperation {
operation_id: operation_id.to_string(),
input_fingerprint: format!("sha256:{operation_id}"),
@@ -40074,6 +40319,7 @@ mod tests {
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
},
)
@@ -41294,6 +41540,7 @@ mod tests {
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
},
)
@@ -42199,6 +42446,7 @@ mod tests {
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
},
)
@@ -44442,6 +44690,7 @@ mod tests {
EMBEDDED_WORKER_RUNTIME_ID,
)),
resolved_memory_settings: Some(test_worker_memory_settings()),
resolved_subjektiv_attached: false,
resolved_control_operation: None,
},
)
@@ -44746,6 +44995,7 @@ mod tests {
EMBEDDED_WORKER_RUNTIME_ID,
)),
resolved_memory_settings: Some(test_worker_memory_settings()),
resolved_subjektiv_attached: false,
resolved_control_operation: None,
},
)
@@ -45489,6 +45739,7 @@ mod tests {
EMBEDDED_WORKER_RUNTIME_ID,
)),
resolved_memory_settings: Some(test_worker_memory_settings()),
resolved_subjektiv_attached: false,
resolved_control_operation: None,
},
)
@@ -45591,6 +45842,7 @@ mod tests {
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: Some(test_worker_workspace_api(EMBEDDED_WORKER_RUNTIME_ID)),
resolved_memory_settings: Some(test_worker_memory_settings()),
resolved_subjektiv_attached: false,
resolved_control_operation: None,
};
let source_worker = api
@@ -46209,6 +46461,7 @@ mod tests {
EMBEDDED_WORKER_RUNTIME_ID,
)),
resolved_memory_settings: Some(test_worker_memory_settings()),
resolved_subjektiv_attached: false,
resolved_control_operation: None,
},
)
@@ -46276,6 +46529,7 @@ mod tests {
EMBEDDED_WORKER_RUNTIME_ID,
)),
resolved_memory_settings: Some(test_worker_memory_settings()),
resolved_subjektiv_attached: false,
resolved_control_operation: None,
},
)
@@ -47666,6 +47920,7 @@ mod tests {
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
},
)
@@ -47725,6 +47980,7 @@ mod tests {
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
},
)
@@ -47838,6 +48094,7 @@ mod tests {
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
},
)
@@ -49776,6 +50033,7 @@ mod tests {
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
},
)
@@ -50880,6 +51138,7 @@ mod tests {
worker_observation_grants: Vec::new(),
workspace_api: None,
memory_settings: Some(memory_settings),
subjektiv_attached: false,
}
}
@@ -52070,6 +52329,7 @@ mod tests {
control_operation_id: None,
},
None,
false,
)
.await
.unwrap();
@@ -52855,6 +53115,7 @@ mod tests {
"embedded-worker-runtime",
)),
resolved_memory_settings: Some(test_worker_memory_settings()),
resolved_subjektiv_attached: false,
resolved_control_operation: None,
},
)
@@ -53508,6 +53769,7 @@ mod tests {
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
};
let spawned = api
@@ -54370,6 +54632,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_control_operation: None,
};
+1
View File
@@ -18872,6 +18872,7 @@ mod tests {
resolved_control_operation: None,
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
};
let stable_fingerprint =
crate::hosts::worker_spawn_create_fingerprint(&request(Some("src"))).unwrap();
+43 -9
View File
@@ -1091,9 +1091,21 @@ impl SubjektivStore {
})
}
/// Lists one deterministic bounded page of subjects. Ordering uses immutable
/// creation time with the host-issued subject id as a total-order tie breaker.
/// Lists the first deterministic bounded page of subjects. Ordering uses
/// immutable creation time with the host-issued subject id as a total-order
/// tie breaker.
pub fn list_subjects(&self, limit: usize) -> Result<SubjectPage> {
self.list_subjects_after(limit, None)
}
/// Lists a deterministic bounded keyset page after an exact `(created_at,
/// subject_id)` cursor. The cursor fields are wrapped by the public API in
/// an opaque Workspace-bound token.
pub fn list_subjects_after(
&self,
limit: usize,
after: Option<(&str, &str)>,
) -> Result<SubjectPage> {
if !(1..=100).contains(&limit) {
return Err(SubjektivError::InvalidRecord(
"subject list limit must be between 1 and 100".to_string(),
@@ -1101,16 +1113,32 @@ impl SubjektivStore {
}
let query_limit = limit.saturating_add(1);
self.database.try_with_connection(|connection| {
let mut statement = connection.prepare(
let sql = if after.is_some() {
"SELECT record_json FROM subjects
WHERE created_at > ?1 OR (created_at = ?1 AND subject_id > ?2)
ORDER BY created_at ASC, subject_id ASC
LIMIT ?3"
} else {
"SELECT record_json FROM subjects
ORDER BY created_at ASC, subject_id ASC
LIMIT ?1",
)?;
let rows = statement
.query_map([to_i64(query_limit as u64)?], |row| row.get::<_, String>(0))?;
LIMIT ?1"
};
let mut statement = connection.prepare(sql)?;
let mut items = Vec::new();
for row in rows {
items.push(parse_subject(&row?)?);
if let Some((created_at, subject_id)) = after {
let rows = statement.query_map(
params![created_at, subject_id, to_i64(query_limit as u64)?],
|row| row.get::<_, String>(0),
)?;
for row in rows {
items.push(parse_subject(&row?)?);
}
} else {
let rows = statement
.query_map([to_i64(query_limit as u64)?], |row| row.get::<_, String>(0))?;
for row in rows {
items.push(parse_subject(&row?)?);
}
}
let has_more = items.len() > limit;
if has_more {
@@ -3559,6 +3587,12 @@ mod tests {
let page = store.list_subjects(2).unwrap();
assert_eq!(page.items, expected[..2]);
assert!(page.has_more);
let after = page.items.last().unwrap();
let next = store
.list_subjects_after(2, Some((&after.created_at, &after.id)))
.unwrap();
assert_eq!(next.items, expected[2..]);
assert!(!next.has_more);
assert!(store.list_subjects(0).is_err());
assert!(store.list_subjects(101).is_err());