feat: integrate Subject permission management with Worker contracts

This commit is contained in:
2026-10-30 05:02:30 +09:00
33 changed files with 4122 additions and 288 deletions
+3
View File
@@ -53,6 +53,9 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
mod catalog;
pub use catalog::*;
pub const MAX_SCOPED_PERMISSIONS: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
@@ -0,0 +1,935 @@
//! Management metadata over the existing policy, not an authorization source.
//!
//! Only the management replacement path validates against this catalog. Historical
//! deserialization and execution grants retain their exact spelling and scope. New
//! operations require an explicit catalog entry; dotted prefixes grant nothing.
use super::*;
#[cfg(test)]
#[path = "catalog_tests.rs"]
mod tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PermissionResourceKind {
Job,
Ticket,
Objective,
MergeRequest,
Subject,
DriveEntry,
DriveRequest,
Repository,
WorkdirAttachment,
WorkspaceConfig,
Network,
SubWorker,
Feature,
Workspace,
Workdir,
Worker,
WorkerOperation,
}
impl PermissionResource {
pub fn kind(&self) -> PermissionResourceKind {
use PermissionResourceKind as K;
match self {
Self::Job { .. } => K::Job,
Self::Ticket { .. } => K::Ticket,
Self::Objective { .. } => K::Objective,
Self::MergeRequest { .. } => K::MergeRequest,
Self::Subject { .. } => K::Subject,
Self::DriveEntry { .. } => K::DriveEntry,
Self::DriveRequest { .. } => K::DriveRequest,
Self::Repository { .. } => K::Repository,
Self::WorkdirAttachment { .. } => K::WorkdirAttachment,
Self::WorkspaceConfig { .. } => K::WorkspaceConfig,
Self::Network { .. } => K::Network,
Self::SubWorker { .. } => K::SubWorker,
Self::Feature => K::Feature,
Self::Workspace { .. } => K::Workspace,
Self::Workdir { .. } => K::Workdir,
Self::Worker { .. } => K::Worker,
Self::WorkerOperation { .. } => K::WorkerOperation,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PermissionOperationAvailability {
Available,
Unavailable { reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PermissionOperationDescriptor {
pub operation: String,
pub label: String,
pub description: String,
/// Accepted management input kinds, not a claim that a target exists or is usable.
pub resource_kinds: Vec<PermissionResourceKind>,
pub availability: PermissionOperationAvailability,
}
/// Available means the operation/resource contract is implemented, not that the
/// current Profile, provider, target, assignment or Job permits its execution.
/// Body execution and delegation ceilings use the same descriptors, independently.
pub fn subject_permission_operations() -> Vec<PermissionOperationDescriptor> {
use PermissionResourceKind as K;
let mut catalog = Vec::new();
let mut add = |kind, entries: &[(&str, &str, &str)]| {
catalog.extend(entries.iter().map(|(operation, label, description)| {
PermissionOperationDescriptor {
operation: (*operation).into(),
label: (*label).into(),
description: (*description).into(),
resource_kinds: vec![kind],
availability: PermissionOperationAvailability::Available,
}
}));
};
add(
K::Workdir,
&[
(
"filesystem.read",
"Read files",
"Read files and enumerate directories within the selected path and recursive scope; provider scope still applies.",
),
(
"filesystem.write",
"Edit files",
"Create or edit files within the selected path and recursive scope; does not permit commands.",
),
(
"bash.execute",
"Run commands",
"Requires the whole Workdir: path '.' and recursive true. A shell is not confined to a subdirectory. Explicit provider command authority is also required.",
),
(
"workdir.read",
"Inspect Workdir",
"Read selected Workdir metadata; does not grant file access.",
),
(
"workdir.attach",
"Attach Workdir",
"Attach the selected Workdir subject to existing provider and attachment constraints.",
),
(
"workdir.delete",
"Delete Workdir",
"Delete the selected Workdir only if existing occupancy and retention conditions permit it.",
),
],
);
add(
K::Network,
&[
(
"web.fetch",
"Fetch web page",
"Fetch this exact HTTP(S) URL. Each redirect needs its own permission; private/local hosts remain denied.",
),
(
"web.search",
"Search web",
"Contact this exact search provider endpoint; credentials and network restrictions still apply.",
),
],
);
add(
K::Workspace,
&[
(
"ticket.create",
"Create Tickets",
"Create open Tickets in this Workspace; no read or edit rights to existing Tickets are implied.",
),
(
"ticket.query",
"Search Tickets",
"Query the Workspace Ticket collection.",
),
(
"ticket.list",
"List Tickets",
"List the Workspace Ticket collection.",
),
(
"ticket.doctor",
"Diagnose Tickets",
"Inspect Workspace Ticket diagnostics.",
),
(
"ticket.intake.default_body",
"Read intake template",
"Read the default intake transition body.",
),
(
"ticket.tag.list",
"List classification tags",
"Read Workspace classification tags.",
),
(
"ticket.tag.create",
"Create classification tags",
"Create Workspace classification tags, not workflow authority.",
),
(
"ticket.tag.rename",
"Rename classification tags",
"Rename Workspace tags with existing replay and compare checks.",
),
(
"ticket.tag.delete",
"Delete classification tags",
"Delete Workspace tags and their memberships with existing compare checks.",
),
(
"ticket.relation.query",
"Search Ticket relations",
"Query Workspace Ticket relation metadata.",
),
(
"ticket.orchestration_plan.query",
"Read orchestration plans",
"Query retained orchestration plan metadata; does not start work.",
),
(
"objective.query",
"Search Objectives",
"Query the Workspace Objective collection.",
),
(
"objective.create",
"Create Objectives",
"Create an Objective in this Workspace.",
),
(
"repository.list",
"List repositories",
"List repository metadata, not checkout or credential access.",
),
(
"workdir.list",
"List Workdirs",
"List Workdir metadata; does not grant attachment or file access.",
),
(
"workdir.attachment.list",
"List attachments",
"List Workdir attachment metadata.",
),
(
"worker.list",
"List Workers",
"List known Workers. Discovery does not grant control.",
),
(
"worker.discover",
"Discover Workspace Workers",
"Search bounded Worker metadata. Discovery does not grant control or observation.",
),
(
"worker.observe.list",
"List observable Workers",
"List observation subjects; does not grant access to individual sessions.",
),
(
"worker.create",
"Create background Workers",
"Use existing Profile-resolved Worker creation with explicit execution grants. Control and reply without duplicate target permissions await T-803.",
),
(
"drive.capabilities",
"Read Drive capabilities",
"Read provider-supported Drive operations.",
),
(
"drive.root",
"Find Drive root",
"Read the Workspace Drive root reference.",
),
(
"drive.search",
"Search Drive",
"Search the Workspace Drive; provider visibility still applies.",
),
],
);
add(
K::Ticket,
&[
(
"ticket.read",
"Read Ticket",
"Read the selected Ticket and its thread.",
),
(
"ticket.edit_item",
"Edit Ticket",
"Edit the selected Ticket content; actor and concurrency checks still apply.",
),
(
"ticket.edit_post",
"Edit Ticket post",
"Edit an eligible post using its exact observed body.",
),
(
"ticket.event.add",
"Add Ticket event",
"Append an allowed typed event to the selected Ticket.",
),
(
"ticket.case.update",
"Judge Ticket case",
"Close, reopen or correct closure with explicit judgment and current compare keys.",
),
(
"ticket.dependency.check",
"Check dependencies",
"Inspect selected Ticket dependencies; does not start them.",
),
(
"ticket.intake_summary.add",
"Add intake summary",
"Append an intake summary to the selected Ticket.",
),
(
"ticket.tags.set",
"Classify Ticket",
"Replace selected Ticket classification tags using the observed full set.",
),
(
"ticket.relation.read",
"Read Ticket relations",
"Read selected Ticket relations.",
),
(
"ticket.relation.record",
"Record Ticket relation",
"Record an explicit relation from this Ticket.",
),
(
"ticket.relation.remove",
"Remove Ticket relation",
"Remove an exact relation from this Ticket.",
),
(
"ticket.orchestration_plan.record",
"Record orchestration plan",
"Record plan metadata for this Ticket; does not grant execution authority.",
),
(
"ticket.mark_ready",
"Update intake readiness",
"Record an intake readiness change; does not grant execution authority.",
),
(
"ticket.queue",
"Request Ticket queueing",
"Use the existing authorized Ticket queue route; does not bypass actor restrictions.",
),
(
"ticket.assignment.assign",
"Delegate Ticket",
"Delegate to an authorized Orchestrator principal; does not start a Worker. Subject assignment awaits T-778.",
),
(
"ticket.assignment.clear",
"Release Ticket delegation",
"Release the observed current Orchestrator delegation; does not stop Workers.",
),
(
"ticket.assignment.update",
"Report assignment handling",
"Report handling only for the authenticated authorized current delegation with event CAS.",
),
(
"ticket.assignment.answer",
"Answer assignment",
"Append fenced continuing input to the current assignment; not a new assignment.",
),
(
"ticket.assignment.query",
"Read current assignment",
"Read selected Ticket assignment tracking.",
),
(
"ticket.assignment.history",
"Read assignment history",
"Read retained assignment tracking events.",
),
(
"ticket.assignment.diagnostics",
"Read assignment diagnostics",
"Read retained delivery and start evidence, not execution authority.",
),
(
"ticket.implementation_result.write",
"Report implementation result",
"Save a bounded result, handoff or blocker; not approval or closure.",
),
(
"ticket.implementation_result.read",
"Read implementation result",
"Read a saved implementation result; not a new request.",
),
(
"merge_request.open",
"Open Merge Request",
"Open or reuse a repository-scoped Merge Request for this Ticket.",
),
],
);
add(
K::Objective,
&[
(
"objective.read",
"Read Objective",
"Read selected Objective and linked Ticket context.",
),
(
"objective.edit",
"Edit Objective",
"Edit selected Objective content.",
),
(
"objective.set_state",
"Set Objective state",
"Change selected Objective state.",
),
(
"objective.link_ticket",
"Link Ticket",
"Link a Ticket to this Objective; required Ticket read permission is separate.",
),
(
"objective.unlink_ticket",
"Unlink Ticket",
"Unlink a Ticket from this Objective.",
),
],
);
add(
K::MergeRequest,
&[
(
"merge_request.read",
"Read Merge Request",
"Read selected Merge Request and review evidence.",
),
(
"merge_request.readiness",
"Check MR readiness",
"Check recording-workflow readiness; does not adopt changes.",
),
(
"merge_request.review",
"Review Merge Request",
"Submit a review only with independently bound review capability and current source.",
),
(
"merge_request.complete",
"Record MR integration",
"Record an already-applied exact-source integration with existing approval and source checks.",
),
],
);
add(
K::Repository,
&[
(
"repository.read",
"Read repository metadata",
"Read selected repository metadata without exposing secrets.",
),
(
"workdir.create",
"Create Workdir",
"Materialize selected repository using existing provider grants and explicit selectors.",
),
],
);
add(
K::WorkdirAttachment,
&[
(
"workdir.attachment.read",
"Read attachment",
"Read metadata for this exact attachment connection.",
),
(
"workdir.detach",
"Detach Workdir",
"Detach this exact observed connection; stale aliases do not select a replacement.",
),
],
);
add(
K::WorkspaceConfig,
&[
(
"workspace_config.attach",
"Attach configuration",
"Attach the selected configuration path and recursive scope.",
),
(
"workspace_config.observe",
"Observe configuration",
"Observe selected configuration metadata within path scope.",
),
(
"workspace_config.read",
"Read configuration",
"Read selected configuration within path scope; secret rules still apply.",
),
(
"workspace_config.commit",
"Commit configuration",
"Commit selected configuration within path scope and provider compare checks.",
),
],
);
add(
K::DriveEntry,
&[
(
"drive.metadata",
"Read Drive metadata",
"Read metadata for this exact entry.",
),
(
"drive.list",
"List Drive folder",
"List children of this exact folder; does not include descendants implicitly.",
),
(
"drive.read_text",
"Read Drive text",
"Read text of this exact entry.",
),
(
"drive.read_chunk",
"Read Drive chunk",
"Read a bounded chunk of this exact entry.",
),
(
"drive.create_folder",
"Create Drive folder",
"Create a folder under this exact parent.",
),
(
"drive.create_text",
"Create Drive text",
"Create a text entry under this exact parent.",
),
(
"drive.update_text",
"Update Drive text",
"Update this exact entry with provider concurrency checks.",
),
(
"drive.delete",
"Delete Drive entry",
"Delete this exact entry if provider conditions permit.",
),
(
"drive.relocate",
"Move Drive entry",
"Move an entry; source and destination each require explicit permission.",
),
(
"drive.upload",
"Upload Drive file",
"Upload under this exact parent, subject to provider limits.",
),
],
);
add(
K::DriveRequest,
&[(
"drive.request_status",
"Read Drive request",
"Read the status of this exact provider request; no new execution authority.",
)],
);
add(
K::Feature,
&[(
"mcp",
"Enable MCP (temporary coarse permission)",
"Retained MCP activation permission before stdio discovery. Not per-server, per-tool or per-call authorization; no equivalent gate is needed by other Features.",
)],
);
add(
K::Subject,
&[
(
"session.record",
"Record Subject session",
"Attribute and record sessions for this Subject; current body ownership is still required.",
),
(
"session.list",
"List Subject sessions",
"List sessions attributed to this Subject.",
),
(
"session.search",
"Search Subject sessions",
"Search committed sessions attributed to this Subject.",
),
(
"session.read",
"Read Subject session",
"Read committed entries from this Subject's sessions.",
),
(
"memory.query",
"Search Subject Memory",
"Query this Subject's Memory, not Workspace Memory.",
),
(
"memory.read",
"Read Subject Memory",
"Read this Subject's selected Memory document.",
),
(
"memory.changes.list",
"List Memory changes",
"Read this Subject's retained Memory changes.",
),
(
"memory.resident.read",
"Read resident Memory",
"Read this Subject's resident Memory data.",
),
(
"memory.resident.supply",
"Supply resident Memory",
"Supply protected resident Memory to a model; read permission is separate.",
),
(
"memory.staging.write",
"Stage Memory candidate",
"Write a candidate to this Subject's staging area.",
),
(
"memory.staging.list",
"List Memory candidates",
"List this Subject's staging candidates.",
),
(
"memory.staging.read",
"Read Memory candidate",
"Read this Subject's staging candidate.",
),
(
"memory.candidate.decide",
"Decide Memory candidate",
"Decide a staged candidate using existing Job/domain constraints.",
),
(
"memory.proposal.validate",
"Validate Memory proposal",
"Validate a proposal for this Subject.",
),
(
"memory.receipt.read",
"Read Memory receipt",
"Read retained Memory operation evidence, not new authority.",
),
(
"memory.surface.prepare",
"Prepare Memory surface",
"Prepare this Subject's surface update under domain checks.",
),
(
"memory.surface.supply",
"Supply Memory surface",
"Supply protected surface input to a model.",
),
(
"memory.surface.publish",
"Publish Memory surface",
"Publish this Subject's prepared surface under writer and Job checks.",
),
(
"memory.surface.fail",
"Record surface failure",
"Record failure of this Subject's prepared surface.",
),
(
"memory.extract.read",
"Read extraction input",
"Read protected session input for Memory extraction.",
),
(
"memory.extract.supply",
"Supply extraction input",
"Supply protected extraction input to a model.",
),
(
"memory.extract.commit",
"Commit extraction",
"Commit extracted candidates under existing source/writer checks.",
),
(
"memory.consolidation.request",
"Request consolidation",
"Request a bounded consolidation Job; Job grants and origin constraints remain separate.",
),
(
"memory.consolidation.supply",
"Supply consolidation input",
"Supply frozen consolidation input only within its current Job capability.",
),
(
"journal.startup.read",
"Read startup Journal",
"Read this Subject's startup Journal context.",
),
(
"journal.startup.supply",
"Supply startup Journal",
"Supply protected startup Journal context to a model.",
),
(
"journal.compaction.read",
"Read compaction Journal",
"Read this Subject's compaction Journal context.",
),
(
"journal.compaction.supply",
"Supply compaction Journal",
"Supply protected compaction context to a model.",
),
(
"journal.recording.request",
"Request Journal recording",
"Request a bounded recording Job; does not grant arbitrary Job operations.",
),
(
"journal.job.input.read",
"Read Journal Job input",
"Read frozen input only with the bound current Journal Job capability.",
),
(
"journal.job.input.supply",
"Supply Journal Job input",
"Supply frozen input only with current Job, origin and policy authority.",
),
(
"journal.state.read",
"Read Journal state",
"Read this Subject's Journal state.",
),
(
"journal.text.read",
"Read Journal text",
"Read this Subject's Journal text.",
),
(
"journal.operation.query",
"Query Journal operations",
"Query retained Journal operations.",
),
(
"journal.operation.list",
"List Journal operations",
"List retained Journal operations.",
),
(
"journal.operation.coverage.read",
"Read Journal coverage",
"Read operation coverage evidence.",
),
(
"journal.entry.create",
"Create Journal entry",
"Create an entry within the bound Journal Job.",
),
(
"journal.entry.fix",
"Fix Journal entry",
"Fix an entry within the bound Journal Job.",
),
(
"journal.entry.rewrite",
"Rewrite Journal entry",
"Rewrite an entry within the bound Journal Job.",
),
(
"journal.position.update",
"Update Journal position",
"Update position within the current Journal writer/attempt.",
),
(
"journal.unchanged.confirm",
"Confirm unchanged Journal",
"Confirm no change within the current Journal writer/attempt.",
),
(
"journal.checkpoint",
"Checkpoint Journal",
"Checkpoint the current Journal recording Job.",
),
(
"journal.finish",
"Finish Journal recording",
"Finish the bound Journal Job; not a general work-completion judgment.",
),
],
);
add(
K::Workspace,
&[
(
"memory.document.read",
"Read Workspace Memory document",
"Read a legacy Workspace Memory document; not Subject Memory.",
),
(
"memory.document.update",
"Update Workspace Memory document",
"Update a legacy Workspace Memory document with existing storage constraints.",
),
(
"memory.audit.append",
"Append Workspace Memory audit",
"Append a Workspace Memory audit event.",
),
(
"memory.staging.close",
"Close Workspace Memory candidate",
"Close a legacy Workspace staging candidate.",
),
],
);
// The same exact operation may target either independent Memory domain. This
// adds accepted kinds, never cross-kind containment or a second descriptor.
for descriptor in &mut catalog {
if matches!(
descriptor.operation.as_str(),
"memory.query"
| "memory.resident.read"
| "memory.resident.supply"
| "memory.staging.write"
| "memory.staging.list"
| "memory.staging.read"
| "memory.extract.read"
| "memory.extract.supply"
| "memory.extract.commit"
| "memory.consolidation.request"
) {
descriptor.resource_kinds.push(K::Workspace);
descriptor.description.push_str(" The Workspace kind selects the separate legacy Workspace Memory domain, never all Subjects.");
}
}
catalog.push(PermissionOperationDescriptor {
operation: "notification.diagnostics.read".into(), label: "Read notification diagnostics".into(),
description: "Read bounded saved delivery evidence for the exact source resource; Runtime acceptance does not prove processing.".into(),
resource_kinds: vec![K::Workspace, K::Ticket, K::MergeRequest, K::Job],
availability: PermissionOperationAvailability::Available,
});
// T-803 owns operation-only control and name migration. No Worker resource
// kinds are offered here: adding a per-target setting would duplicate Backend
// control grants and dropping an old target would widen authority.
for (operation, label) in [
("worker.messaging", "Message Workers"),
("worker.send_input", "Submit to Worker"),
("worker.cancel", "Cancel Worker turn"),
("worker.stop", "Stop Worker"),
("worker.restore", "Restore Worker"),
("worker.remove", "Remove Worker"),
("worker.observe", "Observe Worker session"),
] {
catalog.push(PermissionOperationDescriptor {
operation: operation.into(), label: label.into(),
description: "Operation use permission and directional Backend target control grants are separate. Acceptance does not prove processing or success; lifecycle state conditions still apply.".into(),
resource_kinds: vec![],
availability: PermissionOperationAvailability::Unavailable { reason: "T-803 connection pending: current control still requires target-specific Worker permissions and uses worker.notify/worker.result. Existing restricted entries are preserved without conversion.".into() },
});
}
for (operation, label) in [
("subworker.spawn", "Create Internal SubWorker"),
("subworker.send_input", "Submit to Internal SubWorker"),
("subworker.stop", "Stop Internal SubWorker"),
("subworker.observe", "Observe Internal SubWorker"),
] {
catalog.push(PermissionOperationDescriptor {
operation: operation.into(), label: label.into(),
description: "Parent-owned Internal SubWorkers are distinct from independently controlled Runtime Workers. Existing exact parent/name scopes are retained.".into(),
resource_kinds: vec![],
availability: PermissionOperationAvailability::Unavailable { reason: "A safe parent-bound management selector is not yet available. Existing scopes may be retained or removed, not widened by replacing parent identifiers.".into() },
});
}
catalog
}
/// Validate additions only, after receipt lookup and current-policy CAS in the
/// management store transaction. Unsupported entries may be kept exactly in their
/// original lane or removed, never copied to another lane or broadened.
pub fn validate_managed_subject_permissions(
current: Option<&SubjectPermissions>,
update: &SubjectPermissionsUpdate,
) -> Result<(), String> {
let catalog = subject_permission_operations();
for (next, previous) in [
(
&update.execution,
current.map(|p| p.execution.as_slice()).unwrap_or_default(),
),
(
&update.delegation,
current.map(|p| p.delegation.as_slice()).unwrap_or_default(),
),
] {
for permission in next {
if previous.contains(permission) {
continue;
}
permission.validate()?;
let descriptor = catalog
.iter()
.find(|d| d.operation == permission.operation)
.ok_or_else(|| format!("unknown management operation: {}", permission.operation))?;
if !matches!(
descriptor.availability,
PermissionOperationAvailability::Available
) {
return Err(format!(
"operation is unavailable for management: {}",
permission.operation
));
}
if !descriptor
.resource_kinds
.contains(&permission.resource.kind())
{
return Err(format!(
"invalid resource kind for operation: {}",
permission.operation
));
}
if permission.operation == "bash.execute"
&& !matches!(&permission.resource,
PermissionResource::Workdir { path, recursive: true, .. } if path == ".")
{
return Err(
"bash.execute requires the whole Workdir (path '.' and recursive true)".into(),
);
}
}
}
Ok(())
}
@@ -0,0 +1,119 @@
//! Management validation is exercised from JSON; stored legacy policy remains readable.
use super::*;
use serde_json::json;
fn permission(operation: &str, resource: serde_json::Value) -> ScopedPermission {
serde_json::from_value(json!({"operation":operation,"resource":resource})).unwrap()
}
fn update(
execution: Vec<ScopedPermission>,
delegation: Vec<ScopedPermission>,
) -> SubjectPermissionsUpdate {
SubjectPermissionsUpdate {
operation_id: "save".into(),
expected_change_id: Some("observed".into()),
execution,
delegation,
}
}
#[test]
fn managed_additions_reject_unknown_mismatched_and_unavailable_entries() {
let workspace = json!({"kind":"workspace","workspace_id":"w"});
for grant in [
permission("ticket.future", workspace.clone()),
permission("ticket.read", workspace.clone()),
permission(
"ticket.create",
json!({"kind":"ticket","workspace_id":"w","ticket_id":"T-1"}),
),
permission("worker.messaging", workspace.clone()),
permission(
"worker.stop",
json!({"kind":"worker","runtime_id":"r","worker_id":"worker"}),
),
permission("ticket", json!({"kind":"feature"})),
permission(
"bash.execute",
json!({"kind":"workdir","working_directory_id":"wd","path":"src","recursive":true}),
),
] {
assert!(
validate_managed_subject_permissions(None, &update(vec![grant.clone()], vec![]))
.is_err(),
"{grant:?}"
);
assert!(
validate_managed_subject_permissions(None, &update(vec![], vec![grant.clone()]))
.is_err(),
"{grant:?}"
);
}
assert!(validate_managed_subject_permissions(None, &update(vec![permission("ticket.create", workspace)], vec![permission("filesystem.write", json!({"kind":"workdir","working_directory_id":"wd","path":"src","recursive":true}))])).is_ok());
}
#[test]
fn unsupported_legacy_entries_are_exact_lane_preserved_not_converted_or_extended() {
let legacy: SubjectPermissions = serde_json::from_value(json!({
"change_id":"observed",
"execution":[
{"operation":"ticket","resource":{"kind":"feature"}},
{"operation":"worker.notify","resource":{"kind":"worker","runtime_id":"r","worker_id":"one"}},
{"operation":"old.unknown","resource":{"kind":"workspace","workspace_id":"w"}}
],
"delegation":[{"operation":"worker.result","resource":{"kind":"worker","runtime_id":"r","worker_id":"origin"}}]
})).unwrap();
let mut next = update(legacy.execution.clone(), legacy.delegation.clone());
next.execution.push(permission(
"ticket.create",
json!({"kind":"workspace","workspace_id":"w"}),
));
assert!(validate_managed_subject_permissions(Some(&legacy), &next).is_ok());
assert_eq!(
serde_json::to_value(&legacy).unwrap()["execution"][1]["operation"],
"worker.notify"
);
next.delegation.push(legacy.execution[1].clone());
assert!(validate_managed_subject_permissions(Some(&legacy), &next).is_err());
next.delegation.pop();
next.execution[1].resource = PermissionResource::Worker {
runtime_id: "r".into(),
worker_id: "two".into(),
};
assert!(validate_managed_subject_permissions(Some(&legacy), &next).is_err());
assert!(validate_managed_subject_permissions(Some(&legacy), &update(vec![], vec![])).is_ok());
}
#[test]
fn catalog_wire_has_no_worker_target_editors_and_no_implicit_feature_grants() {
let wire = serde_json::to_value(subject_permission_operations()).unwrap();
let entries = wire.as_array().unwrap();
let mut names = std::collections::BTreeSet::new();
for entry in entries {
assert!(
names.insert(entry["operation"].as_str().unwrap()),
"duplicate descriptor: {entry}"
);
assert!(!entry["label"].as_str().unwrap().is_empty());
assert!(!entry["description"].as_str().unwrap().is_empty());
assert!(
!entry["resource_kinds"]
.as_array()
.unwrap()
.contains(&json!("worker"))
);
if entry["availability"]["kind"] == "available" {
assert!(!entry["resource_kinds"].as_array().unwrap().is_empty());
}
}
assert!(!names.contains("ticket"));
assert!(!names.contains("worker.notify"));
assert!(!names.contains("worker.result"));
let mcp = entries.iter().find(|d| d["operation"] == "mcp").unwrap();
assert_eq!(mcp["resource_kinds"], json!(["feature"]));
let messaging = entries
.iter()
.find(|d| d["operation"] == "worker.messaging")
.unwrap();
assert_eq!(messaging["availability"]["kind"], "unavailable");
}
+12
View File
@@ -34,6 +34,8 @@ pub use workspace_config::*;
#[serde(deny_unknown_fields)]
pub struct SubjectPermissionsResponse {
pub permissions: Option<authorization::SubjectPermissions>,
pub can_manage: bool,
pub operations: Vec<authorization::PermissionOperationDescriptor>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
@@ -11092,6 +11094,16 @@ pub fn memory_api_typescript() -> String {
MemoryStagingRecord::decl(&config),
MemoryStagingEntry::decl(&config),
MemoryStagingListResponse::decl(&config),
authorization::PermissionResource::decl(&config),
authorization::ScopedPermission::decl(&config),
authorization::SubjectPermissions::decl(&config),
authorization::SubjectPermissionsUpdate::decl(&config),
authorization::PermissionResourceKind::decl(&config),
authorization::PermissionOperationAvailability::decl(&config),
authorization::PermissionOperationDescriptor::decl(&config),
SubjectPermissionsResponse::decl(&config),
SubjectPermissionsMutationResponse::decl(&config),
SubjectPermissionInterruption::decl(&config),
];
// These browser DTOs include nested types owned by `memory` and the Worker
// launch API. Keep their exported names explicit so the generated Web
+25
View File
@@ -36,6 +36,27 @@ impl SubjektivStore {
&self,
subject_id: &str,
update: &SubjectPermissionsUpdate,
) -> Result<SubjectPermissions> {
self.replace_subject_permissions(subject_id, update, false)
}
/// Management replacement rejects new unsupported operation/resource pairs while
/// retaining exact legacy entries in their original execution/delegation lane.
/// Validation shares the existing CAS/receipt transaction so a stale editor cannot
/// retain a removed legacy grant. Exact replay remains historical evidence.
pub fn set_managed_subject_permissions(
&self,
subject_id: &str,
update: &SubjectPermissionsUpdate,
) -> Result<SubjectPermissions> {
self.replace_subject_permissions(subject_id, update, true)
}
fn replace_subject_permissions(
&self,
subject_id: &str,
update: &SubjectPermissionsUpdate,
managed: bool,
) -> Result<SubjectPermissions> {
validate_authorization_identifier(subject_id).map_err(SubjektivError::InvalidRecord)?;
validate_authorization_identifier(&update.operation_id)
@@ -70,6 +91,10 @@ impl SubjektivStore {
if current.as_ref().map(|source| &source.change_id) != update.expected_change_id.as_ref() {
return Err(SubjektivError::SubjectPermissionsConflict(subject_id.into()));
}
if managed {
server_api::authorization::validate_managed_subject_permissions(current.as_ref(), update)
.map_err(SubjektivError::InvalidRecord)?;
}
let source = SubjectPermissions {
change_id: issued_id("permissions"),
execution: update.execution.clone(),
+18 -10
View File
@@ -9774,20 +9774,28 @@ impl server_api::ServerApi for ServerApiContractService {
let api = self.workspace_api()?.clone();
let actor = subject_authorization::administration_actor(&context)
.map_err(ApiError::into_repository_api_error)?;
require_workspace_owner(
&api,
&workspace_id,
actor,
"Subject permission administration",
)
.await
.map_err(ApiError::into_repository_api_error)?;
validate_workspace_scope(&api, &workspace_id)
.map_err(ApiError::into_repository_api_error)?;
let workspace = api
.store
.get_workspace(&workspace_id)
.await
.map_err(ApiError::from)
.map_err(ApiError::into_repository_api_error)?
.ok_or_else(|| {
ApiError::from(Error::WorkspaceIdMismatch).into_repository_api_error()
})?;
let can_manage = workspace.owner_account_id == actor.account_id;
api.store
.db_executor()
.run(move || -> ApiResult<_> {
Ok(open_subjektiv_store(&api)?
.subject_permissions(&subject_id)
.map(|permissions| server_api::SubjectPermissionsResponse { permissions })
.map(|permissions| server_api::SubjectPermissionsResponse {
permissions,
can_manage,
operations: server_api::authorization::subject_permission_operations(),
})
.map_err(subjektiv_store_error)?)
})
.await
@@ -9828,7 +9836,7 @@ impl server_api::ServerApi for ServerApiContractService {
let was_current = before.as_ref().map(|policy| &policy.change_id)
== request.expected_change_id.as_ref();
let permissions = store
.set_subject_permissions(&mutation_subject, &request)
.set_managed_subject_permissions(&mutation_subject, &request)
.map_err(subjektiv_store_error)?;
let changed = was_current
&& before.as_ref().map(|source| &source.change_id)
@@ -13,6 +13,8 @@ mod ticket_worker_messaging_connected;
mod worker_messaging_connected;
#[path = "worker_operation_authorization_tests.rs"]
mod worker_operation_authorization;
#[path = "subject_permission_management_tests.rs"]
mod subject_permission_management;
use agen::tool::{Tool, ToolExecutionContext, ToolOutput};
use server_api::authorization::{
PermissionResource, ScopedPermission, SubjectPermissions, SubjectPermissionsUpdate,
@@ -48,6 +50,14 @@ fn create_permission() -> ScopedPermission {
},
}
}
fn query_permission() -> ScopedPermission {
ScopedPermission {
operation: "ticket.query".into(),
resource: PermissionResource::Workspace {
workspace_id: TEST_WORKSPACE_ID.into(),
},
}
}
fn authorization_path() -> String {
format!("/api/w/{TEST_WORKSPACE_ID}/authorization/check")
}
@@ -185,6 +195,27 @@ impl Fixture {
self.subject
)
}
// Seed only intentionally unsupported saved input, never a management save.
// This models pre-catalog policies without teaching the HTTP validator to issue them.
fn seed_saved_policy(
&self,
execution: Vec<ScopedPermission>,
delegation: Vec<ScopedPermission>,
) -> SubjectPermissions {
let store = open_subjektiv_store(&self.api).unwrap();
let previous = store.subject_permissions(&self.subject).unwrap();
store
.set_subject_permissions(
&self.subject,
&SubjectPermissionsUpdate {
operation_id: Uuid::now_v7().to_string(),
expected_change_id: previous.map(|policy| policy.change_id),
execution,
delegation,
},
)
.unwrap()
}
async fn assign(
&self,
execution: Vec<ScopedPermission>,
@@ -418,6 +449,7 @@ async fn signed_current_body_uses_explicit_policy_and_old_unset_body_denies() {
let (status, body) = f.check(&f.source, &permission).await;
assert_eq!(status, StatusCode::FORBIDDEN, "unset policy: {body}");
}
f.seed_saved_policy(vec![activation("ticket")], vec![]);
f.assign(vec![activation("ticket"), create_permission()], vec![])
.await;
for permission in [activation("ticket"), create_permission()] {
@@ -603,7 +635,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso
},
})
.collect();
f.assign(workspace_grants.clone(), vec![]).await;
f.seed_saved_policy(workspace_grants.clone(), vec![]);
for permission in &requested {
let (status, body) = f.check(&f.source, permission).await;
assert_eq!(
@@ -618,8 +650,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso
.iter()
.map(|permission| permission.operation.split('.').next().unwrap())
.collect();
f.assign(feature_names.into_iter().map(activation).collect(), vec![])
.await;
f.seed_saved_policy(feature_names.into_iter().map(activation).collect(), vec![]);
for permission in &requested {
assert_eq!(
f.check(&f.source, permission).await.0,
@@ -627,7 +658,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso
"{permission:?}"
);
}
f.assign(requested.clone(), vec![]).await;
f.seed_saved_policy(requested.clone(), vec![]);
for permission in &requested {
let (status, body) = f.check(&f.source, permission).await;
assert_eq!(
@@ -653,7 +684,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso
async fn signed_host_rejects_cross_workspace_resource_claims_before_alias_resolution() {
let f = Fixture::new().await;
let permissions = concrete_resource_permissions(&f);
f.assign(permissions.clone(), vec![]).await;
f.seed_saved_policy(permissions.clone(), vec![]);
for permission in permissions
.iter()
.filter(|permission| permission.resource.workspace_id().is_some())
@@ -802,8 +833,8 @@ async fn signed_host_ungranted_operation_does_not_disclose_ticket_or_subject_exi
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn authorization_rejects_missing_source_unknown_identity_fields_and_tampered_signed_body() {
let f = Fixture::new().await;
f.assign(vec![activation("ticket")], vec![]).await;
let body = serde_json::to_value(activation("ticket")).unwrap();
f.assign(vec![create_permission()], vec![]).await;
let body = serde_json::to_value(create_permission()).unwrap();
let (status, error) = response(
&f.api,
human_request(&f.owner, "POST", &authorization_path(), Some(body.clone())),
@@ -901,7 +932,7 @@ async fn authorization_rejects_missing_source_unknown_identity_fields_and_tamper
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permissions() {
async fn only_owner_account_can_assign_shrink_and_revoke_subject_permissions() {
let f = Fixture::new().await;
let (status, body) = response(
&f.api,
@@ -912,8 +943,8 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission
assert!(body["permissions"].is_null());
let original = f
.assign(
vec![activation("ticket"), create_permission()],
vec![activation("ticket")],
vec![create_permission(), query_permission()],
vec![create_permission()],
)
.await;
let (status, body) = response(
@@ -965,9 +996,20 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"foreign account {method}: {body}"
if method == "GET" {
StatusCode::OK
} else {
StatusCode::FORBIDDEN
},
"non-owner account {method}: {body}"
);
if method == "GET" {
assert_eq!(body["can_manage"], false);
assert_eq!(
body["permissions"],
serde_json::to_value(&original).unwrap()
);
}
}
assert_eq!(
open_subjektiv_store(&f.api)
@@ -976,7 +1018,7 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission
.unwrap(),
Some(original.clone())
);
let shrunk = f.assign(vec![activation("ticket")], vec![]).await;
let shrunk = f.assign(vec![query_permission()], vec![]).await;
assert_ne!(shrunk.change_id, original.change_id);
assert_eq!(
f.check(&f.source, &create_permission()).await.0,
@@ -995,7 +1037,7 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission
let revoked = f.assign(vec![], vec![]).await;
assert!(revoked.execution.is_empty() && revoked.delegation.is_empty());
assert_eq!(
f.check(&f.source, &activation("ticket")).await.0,
f.check(&f.source, &query_permission()).await.0,
StatusCode::FORBIDDEN
);
}
@@ -1146,6 +1188,7 @@ async fn ticket_registration_is_local_and_only_create_permission_allows_backend_
("old-feature-only", vec![activation("ticket")]),
("wrong-resource", vec![wrong_workspace]),
] {
f.seed_saved_policy(permissions.clone(), vec![]);
f.assign(permissions, vec![]).await;
client.requests.lock().unwrap().clear();
let (installed, tool) = ticket_tool_host(&f._dir.path().join(name), client.clone()).await;
@@ -1188,7 +1231,7 @@ async fn ticket_registration_is_local_and_only_create_permission_allows_backend_
1,
"genuine SQLite Ticket backend result: {created}"
);
f.assign(vec![activation("ticket")], vec![]).await;
f.assign(vec![], vec![]).await;
let result = tool
.execute(
r#"{"title":"revoked after install"}"#,
@@ -1528,7 +1571,7 @@ fn creation_record_counts(f: &Fixture) -> [i64; 3] {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn generic_create_routes_refuse_body_and_child_delegation_before_any_creation_effect() {
let f = Fixture::new().await;
let permissions = vec![activation("ticket"), create_permission()];
let permissions = vec![query_permission(), create_permission()];
f.assign(vec![], permissions.clone()).await;
let mut child_request = f.child_request("redelegating-editor", permissions.clone());
child_request["execution_grant"]["redelegation"] = json!(permissions);
@@ -1645,7 +1688,7 @@ async fn generic_create_routes_preserve_human_and_non_subject_worker_creation()
async fn subject_control_creation_recovers_control_grant_failure_and_lost_response_without_duplicates()
{
let f = Fixture::new().await;
let permissions = vec![activation("ticket"), create_permission()];
let permissions = vec![query_permission(), create_permission()];
f.assign(vec![], permissions.clone()).await;
let mut missing_id = f.child_request("control-grant-recovery", permissions.clone());
missing_id
@@ -1842,7 +1885,7 @@ async fn explicit_delegation_creates_workdirless_ticketless_editor_and_replays_o
let (status, body) = f
.spawn(
&child,
f.child_request("undelegatable-grandchild", vec![activation("ticket")]),
f.child_request("undelegatable-grandchild", vec![query_permission()]),
)
.await;
assert_eq!(
@@ -1855,12 +1898,12 @@ async fn explicit_delegation_creates_workdirless_ticketless_editor_and_replays_o
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn delegated_creation_rejects_excess_permissions_and_body_identity_before_runtime_spawn() {
let f = Fixture::new().await;
f.assign(vec![], vec![activation("ticket")]).await;
let mut singleton = f.child_request("singleton-escalation", vec![activation("ticket")]);
f.assign(vec![], vec![query_permission()]).await;
let mut singleton = f.child_request("singleton-escalation", vec![query_permission()]);
singleton["singleton_key"] = json!("model-chosen-singleton");
let mut body_attachment = f.child_request("subject-escalation", vec![activation("ticket")]);
let mut body_attachment = f.child_request("subject-escalation", vec![query_permission()]);
body_attachment["feature_connections"] = json!({"subjektiv":{"subject_id":f.subject}});
let mut spoof = f.child_request("origin-escalation", vec![activation("ticket")]);
let mut spoof = f.child_request("origin-escalation", vec![query_permission()]);
spoof["execution_grant"]["subject_id"] = json!(f.subject);
for request in [
singleton,
@@ -1882,12 +1925,9 @@ async fn delegated_creation_rejects_excess_permissions_and_body_identity_before_
async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_falls_back_to_legacy()
{
let f = Fixture::new().await;
f.assign(vec![], vec![activation("ticket")]).await;
let child = f.child("revocable-child", vec![activation("ticket")]).await;
assert_eq!(
f.check(&child, &activation("ticket")).await.0,
StatusCode::OK
);
f.assign(vec![], vec![query_permission()]).await;
let child = f.child("revocable-child", vec![query_permission()]).await;
assert_eq!(f.check(&child, &query_permission()).await.0, StatusCode::OK);
let before = open_subjektiv_store(&f.api)
.unwrap()
.worker_execution_grant(&child.runtime_id, &child.worker_id)
@@ -1895,7 +1935,7 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_
.unwrap();
f.assign(vec![], vec![]).await;
assert_eq!(
f.check(&child, &activation("ticket")).await.0,
f.check(&child, &query_permission()).await.0,
StatusCode::FORBIDDEN
);
let observation = f
@@ -1913,7 +1953,7 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_
Some(json!({"expected_observation_token":observation,"request_id":"restore-revoked-child"})))).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(
f.check(&child, &activation("ticket")).await.0,
f.check(&child, &query_permission()).await.0,
StatusCode::FORBIDDEN
);
assert_eq!(
@@ -1925,33 +1965,33 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_
);
// Regranting identical permissions changes the assignment identity. Old
// grants remain revoked rather than refreshing during restore/reconnect.
f.assign(vec![], vec![activation("ticket")]).await;
f.assign(vec![], vec![query_permission()]).await;
assert_eq!(
f.check(&child, &activation("ticket")).await.0,
f.check(&child, &query_permission()).await.0,
StatusCode::FORBIDDEN
);
let current = f
.child("current-before-origin-release", vec![activation("ticket")])
.child("current-before-origin-release", vec![query_permission()])
.await;
assert_eq!(
f.check(&current, &activation("ticket")).await.0,
f.check(&current, &query_permission()).await.0,
StatusCode::OK
);
f.release_source_lease();
assert_eq!(
f.check(&current, &activation("ticket")).await.0,
f.check(&current, &query_permission()).await.0,
StatusCode::CONFLICT,
"the captured source lease is no longer current"
);
assert_eq!(
f.check(&f.source, &activation("ticket")).await.0,
f.check(&f.source, &query_permission()).await.0,
StatusCode::UNAUTHORIZED,
"released body fails signed-source authentication instead of falling through to legacy compatibility"
);
let (status, _) = f
.spawn(
&f.source,
f.child_request("released-origin-spawn", vec![activation("ticket")]),
f.child_request("released-origin-spawn", vec![query_permission()]),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
@@ -1960,7 +2000,7 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn native_wip_ticket_create_uses_same_signed_authorization_before_real_backend() {
let f = Fixture::new().await;
f.assign(vec![activation("ticket")], vec![]).await;
f.assign(vec![], vec![]).await;
let client = f.client(&f.source);
let (mut host, installed) =
ticket_worker_host(&f._dir.path().join("wip-host"), client.clone()).await;
@@ -2027,7 +2067,7 @@ async fn native_wip_ticket_create_uses_same_signed_authorization_before_real_bac
let backend = browser_ticket_backend(&f.api).unwrap();
let tickets = backend.list(Default::default()).unwrap();
assert_eq!(tickets.len(), 1);
f.assign(vec![activation("ticket")], vec![]).await;
f.assign(vec![], vec![]).await;
assert!(
invoke
.execute(&args, ToolExecutionContext::direct())
@@ -2047,7 +2087,7 @@ async fn policy_replacement_reports_interruption_evidence_but_exact_replay_does_
{
let f = Fixture::new().await;
let update = json!({"operation_id":"policy-interruption-once","expected_change_id":null,
"execution":[activation("ticket")],"delegation":[]});
"execution":[create_permission()],"delegation":[]});
let (status, body) = response(
&f.api,
human_request(&f.owner, "POST", &f.policy_path(), Some(update.clone())),
@@ -2101,13 +2141,13 @@ async fn policy_replacement_reports_interruption_evidence_but_exact_replay_does_
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling() {
let f = Fixture::new().await;
f.assign(vec![], vec![activation("ticket"), create_permission()])
f.assign(vec![], vec![query_permission(), create_permission()])
.await;
let mut request = f.child_request(
"redelegating-parent",
vec![activation("ticket"), create_permission()],
vec![query_permission(), create_permission()],
);
request["execution_grant"]["redelegation"] = json!([activation("ticket")]);
request["execution_grant"]["redelegation"] = json!([query_permission()]);
let (status, body) = f.spawn(&f.source, request).await;
assert_eq!(status, StatusCode::OK, "{body}");
let parent = RuntimeWorkerRef::new(
@@ -2117,7 +2157,7 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling(
let (status, body) = f
.spawn(
&parent,
f.child_request("allowed-grandchild", vec![activation("ticket")]),
f.child_request("allowed-grandchild", vec![query_permission()]),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
@@ -2135,7 +2175,7 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling(
"keep immediate lineage instead of flattening to the body"
);
assert_eq!(
f.check(&grandchild, &activation("ticket")).await.0,
f.check(&grandchild, &query_permission()).await.0,
StatusCode::OK
);
assert_eq!(
@@ -2157,7 +2197,7 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling(
assert_eq!(f.runtime.spawn_requests().len(), calls);
f.assign(vec![], vec![]).await;
assert_eq!(
f.check(&grandchild, &activation("ticket")).await.0,
f.check(&grandchild, &query_permission()).await.0,
StatusCode::FORBIDDEN
);
}
@@ -2165,8 +2205,8 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling(
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn same_profile_name_with_wider_resolved_scope_cannot_rebind_existing_operation() {
let f = Fixture::new().await;
f.assign(vec![], vec![activation("ticket")]).await;
let mut request = f.child_request("profile-bound-once", vec![activation("ticket")]);
f.assign(vec![], vec![query_permission()]).await;
let mut request = f.child_request("profile-bound-once", vec![query_permission()]);
request["profile"] = json!("project:authority-editor");
let (status, body) = f.spawn(&f.source, request.clone()).await;
assert_eq!(status, StatusCode::OK, "{body}");
@@ -2202,7 +2242,7 @@ async fn same_profile_name_with_wider_resolved_scope_cannot_rebind_existing_oper
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn installed_ticket_service_and_raw_backend_recheck_current_signed_source() {
let f = Fixture::new().await;
f.assign(vec![activation("ticket")], vec![]).await;
f.assign(vec![], vec![]).await;
let client = f.client(&f.source);
let (_host, installed, service) =
ticket_worker_host_with_service(&f._dir.path().join("service-host"), client.clone()).await;
@@ -2278,7 +2318,7 @@ async fn installed_ticket_service_and_raw_backend_recheck_current_signed_source(
"Service authorized"
);
assert_eq!(ticket_posts(&client), 1);
f.assign(vec![activation("ticket")], vec![]).await;
f.assign(vec![], vec![]).await;
assert!(
tokio::task::spawn_blocking(move || service.create(NewTicket::new("Revoked service")))
.await
@@ -0,0 +1,579 @@
//! Management HTTP integration: real router/account authentication, catalog
//! validation, Subject SQLite policy/receipts and deletion; no running service or
//! model. The connected Ticket case also uses the signed Worker Host, real Tool
//! and Ticket backend via the parent suite's isolated Runtime fixture.
use super::*;
struct ManagementFixture {
api: WorkspaceApi,
owner: String,
subject: String,
_dir: tempfile::TempDir,
}
impl ManagementFixture {
async fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let owner = seed_test_workspace_owner_token(api.store.as_ref(), "permission-manager").await;
let subject = open_subjektiv_store(&api)
.unwrap()
.create_subject(crate::subjektiv::SubjectRole::new("permission-management").unwrap())
.unwrap()
.id;
Self {
api,
owner,
subject,
_dir: dir,
}
}
fn path(&self) -> String {
format!(
"/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}/permissions",
self.subject
)
}
async fn read(&self) -> Value {
let (status, body) = response(
&self.api,
human_request(&self.owner, "GET", &self.path(), None),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
body
}
async fn save(&self, update: Value) -> Value {
let (status, body) = response(
&self.api,
human_request(&self.owner, "POST", &self.path(), Some(update)),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
body
}
fn assert_current(&self, expected: &Value) {
let saved = open_subjektiv_store(&self.api)
.unwrap()
.subject_permissions(&self.subject)
.unwrap();
assert_eq!(serde_json::to_value(saved).unwrap(), *expected);
}
}
fn update(screen: &Value, operation: &str, execution: Value, delegation: Value) -> Value {
json!({"operation_id":operation,"expected_change_id":screen["permissions"]["change_id"],
"execution":execution,"delegation":delegation})
}
#[tokio::test]
async fn management_read_exposes_capability_and_catalog_without_enabling_pending_worker_operations()
{
let f = ManagementFixture::new().await;
let screen = f.read().await;
assert!(screen["permissions"].is_null());
assert_eq!(screen["can_manage"], true);
let operations = screen["operations"].as_array().expect("management catalog");
let create = operations
.iter()
.find(|entry| entry["operation"] == "ticket.create")
.unwrap();
assert_eq!(create["availability"]["kind"], "available");
assert!(
create["resource_kinds"]
.as_array()
.unwrap()
.contains(&json!("workspace"))
);
assert!(!create["label"].as_str().unwrap().is_empty());
assert!(!create["description"].as_str().unwrap().is_empty());
let messaging = operations
.iter()
.find(|entry| entry["operation"] == "worker.messaging")
.unwrap();
assert_eq!(messaging["availability"]["kind"], "unavailable");
assert!(
!messaging["availability"]["reason"]
.as_str()
.unwrap()
.is_empty()
);
for name in [
"worker.messaging",
"worker.send_input",
"worker.cancel",
"worker.stop",
"worker.restore",
"worker.remove",
"worker.observe",
] {
let operation = operations
.iter()
.find(|entry| entry["operation"] == name)
.unwrap();
assert_eq!(
operation["availability"]["kind"], "unavailable",
"pending Worker contracts: {operation}"
);
assert_eq!(
operation["resource_kinds"],
json!([]),
"do not offer duplicate target control grants"
);
}
}
#[tokio::test]
async fn two_management_screens_conflict_without_overwriting_saved_execution_or_delegation() {
let f = ManagementFixture::new().await;
let first = f.read().await;
let second = f.read().await;
let saved = f
.save(update(
&first,
"screen-one",
json!([create_permission()]),
json!([query_permission()]),
))
.await;
let stale = update(
&second,
"screen-two",
json!([]),
json!([create_permission()]),
);
let (status, body) = response(
&f.api,
human_request(&f.owner, "POST", &f.path(), Some(stale)),
)
.await;
assert_eq!(status, StatusCode::CONFLICT, "{body}");
assert!(
body["message"].is_string(),
"conflict must be visible: {body}"
);
assert_eq!(f.read().await["permissions"], saved["permissions"]);
f.assert_current(&saved["permissions"]);
}
#[tokio::test]
async fn discarded_save_response_recovers_by_exact_replay_and_authoritative_reread() {
let f = ManagementFixture::new().await;
let input = update(
&f.read().await,
"lost-save-response",
json!([create_permission()]),
json!([query_permission()]),
);
// Commit through the real HTTP route, then discard the entire response. This
// models response loss, not process death or rollback of the committed save.
drop(
build_router(f.api.clone())
.oneshot(human_request(
&f.owner,
"POST",
&f.path(),
Some(input.clone()),
))
.await
.unwrap(),
);
let reread = f.read().await;
assert_eq!(reread["permissions"]["execution"], input["execution"]);
assert_eq!(reread["permissions"]["delegation"], input["delegation"]);
let replay = f.save(input.clone()).await;
assert_eq!(replay["permissions"], reread["permissions"]);
assert_eq!(replay["interruptions"], json!([]));
assert_eq!(f.read().await["permissions"], reread["permissions"]);
f.assert_current(&reread["permissions"]);
let mut changed = input;
changed["execution"] = json!([]);
let (status, body) = response(
&f.api,
human_request(&f.owner, "POST", &f.path(), Some(changed)),
)
.await;
assert_eq!(
status,
StatusCode::CONFLICT,
"operation identity cannot be reused: {body}"
);
f.assert_current(&reread["permissions"]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn historical_save_receipt_after_shrink_and_revoke_never_restores_current_authority() {
let f = Fixture::new().await;
let (status, screen) = response(
&f.api,
human_request(&f.owner, "GET", &f.policy_path(), None),
)
.await;
assert_eq!(status, StatusCode::OK);
let original_input = update(
&screen,
"before-shrink",
json!([create_permission()]),
json!([create_permission()]),
);
let (status, original) = response(
&f.api,
human_request(
&f.owner,
"POST",
&f.policy_path(),
Some(original_input.clone()),
),
)
.await;
assert_eq!(status, StatusCode::OK, "{original}");
assert_eq!(
f.check(&f.source, &create_permission()).await.0,
StatusCode::OK
);
for delegation in [vec![create_permission()], vec![]] {
let current = f.assign(vec![], delegation).await;
let (status, replay) = response(
&f.api,
human_request(
&f.owner,
"POST",
&f.policy_path(),
Some(original_input.clone()),
),
)
.await;
assert_eq!(status, StatusCode::OK, "historical receipt: {replay}");
assert_eq!(replay["permissions"], original["permissions"]);
assert_eq!(replay["interruptions"], json!([]));
let (status, read) = response(
&f.api,
human_request(&f.owner, "GET", &f.policy_path(), None),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(read["permissions"], serde_json::to_value(&current).unwrap());
assert_ne!(read["permissions"], replay["permissions"]);
assert_eq!(
open_subjektiv_store(&f.api)
.unwrap()
.subject_permissions(&f.subject)
.unwrap(),
Some(current)
);
assert_eq!(
f.check(&f.source, &create_permission()).await.0,
StatusCode::FORBIDDEN
);
}
}
#[tokio::test]
async fn subject_deletion_rejects_stale_screen_save_and_exact_receipt_replay_without_revival() {
let f = ManagementFixture::new().await;
let screen = f.read().await;
let initial_input = update(
&screen,
"save-before-delete",
json!([create_permission()]),
json!([query_permission()]),
);
let saved = f.save(initial_input.clone()).await;
let stale_screen = json!({"permissions":saved["permissions"]});
let late_input = update(
&stale_screen,
"late-screen-save",
json!([query_permission()]),
json!([]),
);
let (status, body) = response(
&f.api,
human_request(
&f.owner,
"DELETE",
&format!(
"/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}",
f.subject
),
Some(json!({"operation_id":"delete-after-save"})),
),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["completed"], true);
for input in [late_input, initial_input] {
let (status, body) = response(
&f.api,
human_request(&f.owner, "POST", &f.path(), Some(input)),
)
.await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"deleted Subject cannot be revived: {body}"
);
assert!(
open_subjektiv_store(&f.api)
.unwrap()
.subject(&f.subject)
.unwrap()
.is_none()
);
}
let (status, body) = response(&f.api, human_request(&f.owner, "GET", &f.path(), None)).await;
assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
assert!(
open_subjektiv_store(&f.api)
.unwrap()
.subject_permissions(&f.subject)
.is_err()
);
}
#[tokio::test]
async fn read_only_account_cannot_save_or_replay_an_owner_permission_change() {
let f = ManagementFixture::new().await;
let account = seed_test_api_token(f.api.store.as_ref(), "permission-reader");
let initial = f.read().await;
let input = update(
&initial,
"owner-only-save",
json!([create_permission()]),
json!([]),
);
let (status, read) = response(&f.api, human_request(&account, "GET", &f.path(), None)).await;
assert_eq!(status, StatusCode::OK, "{read}");
assert_eq!(read["can_manage"], false);
assert_eq!(read["permissions"], initial["permissions"]);
let (status, body) = response(
&f.api,
human_request(&account, "POST", &f.path(), Some(input.clone())),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
f.assert_current(&Value::Null);
let saved = f.save(input.clone()).await;
let (status, body) = response(
&f.api,
human_request(&account, "POST", &f.path(), Some(input)),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"exact owner receipt replay must still authorize the caller: {body}"
);
f.assert_current(&saved["permissions"]);
}
#[tokio::test]
async fn new_unknown_legacy_unavailable_and_operation_resource_mismatch_are_rejected_without_policy_write()
{
let f = ManagementFixture::new().await;
let screen = f.read().await;
let mut unknown = create_permission();
unknown.operation = "ticket.future_operation".into();
let mut mismatch = create_permission();
mismatch.resource = PermissionResource::Ticket {
workspace_id: TEST_WORKSPACE_ID.into(),
ticket_id: "not-a-collection".into(),
};
let mut unavailable = query_permission();
unavailable.operation = "worker.messaging".into();
for (name, permission) in [
("unknown", unknown),
("legacy", activation("ticket")),
("resource-mismatch", mismatch),
("unavailable", unavailable),
] {
for lane in ["execution", "delegation"] {
let mut input = update(
&screen,
&format!("reject-{name}-{lane}"),
json!([]),
json!([]),
);
input[lane] = json!([permission]);
let (status, body) = response(
&f.api,
human_request(&f.owner, "POST", &f.path(), Some(input)),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{name}/{lane}: {body}");
assert!(body["message"].is_string(), "{body}");
f.assert_current(&Value::Null);
assert!(f.read().await["permissions"].is_null());
}
}
}
#[tokio::test]
async fn unsupported_saved_entries_can_only_be_retained_in_the_same_lane_or_removed() {
let f = ManagementFixture::new().await;
let execution = vec![activation("ticket")];
let mut unknown = query_permission();
unknown.operation = "legacy.custom_operation".into();
let delegation = vec![unknown];
open_subjektiv_store(&f.api)
.unwrap()
.set_subject_permissions(
&f.subject,
&SubjectPermissionsUpdate {
operation_id: "seed-pre-catalog-policy".into(),
expected_change_id: None,
execution: execution.clone(),
delegation: delegation.clone(),
},
)
.unwrap();
let screen = f.read().await;
let saved = f
.save(update(
&screen,
"retain-existing",
json!([execution[0], create_permission()]),
json!(delegation),
))
.await;
assert_eq!(
saved["permissions"]["execution"],
json!([execution[0], create_permission()])
);
assert_eq!(saved["permissions"]["delegation"], json!(delegation));
let screen = f.read().await;
for (name, execution, delegation) in [
(
"copy-legacy-lane",
json!([activation("ticket"), create_permission()]),
json!([activation("ticket")]),
),
(
"copy-unknown-lane",
json!([delegation[0], create_permission()]),
json!(delegation),
),
] {
let (status, body) = response(
&f.api,
human_request(
&f.owner,
"POST",
&f.path(),
Some(update(&screen, name, execution, delegation)),
),
)
.await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"retention cannot expand into another lane: {body}"
);
f.assert_current(&saved["permissions"]);
}
let removed = f
.save(update(
&screen,
"remove-unsupported",
json!([create_permission()]),
json!([]),
))
.await;
assert_eq!(
removed["permissions"]["execution"],
json!([create_permission()])
);
assert_eq!(removed["permissions"]["delegation"], json!([]));
f.assert_current(&removed["permissions"]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn management_saved_policy_controls_real_ticket_tool_and_raw_http_before_backend_writes() {
let f = Fixture::new().await;
let client = f.client(&f.source);
let (installed, tool) = ticket_tool_host(
&f._dir.path().join("management-ticket-tool"),
client.clone(),
)
.await;
assert!(installed);
let tool = tool.unwrap();
assert!(
client.requests.lock().unwrap().is_empty(),
"local registration is not authorization"
);
let backend = browser_ticket_backend(&f.api).unwrap();
for state in ["unset", "out-of-scope", "allowed", "revoked"] {
match state {
"unset" => {}
"out-of-scope" => {
// Intentionally migrated, unsupported kind: management may retain
// this exact saved entry but it must never authorize collection creation.
let mut wrong_kind = create_permission();
wrong_kind.resource = PermissionResource::Ticket {
workspace_id: TEST_WORKSPACE_ID.into(),
ticket_id: "legacy-ticket-scope".into(),
};
f.seed_saved_policy(vec![wrong_kind.clone()], vec![]);
f.assign(vec![wrong_kind], vec![]).await;
}
"allowed" => {
f.assign(vec![create_permission()], vec![]).await;
}
"revoked" => {
f.assign(vec![], vec![]).await;
}
_ => unreachable!(),
}
let before = backend.list(Default::default()).unwrap().len();
let posts = ticket_posts(&client);
let result = tool
.execute(
&json!({"title":format!("Managed policy {state}")}).to_string(),
ToolExecutionContext::direct(),
)
.await;
if state == "allowed" {
let output = value(result.unwrap());
assert_eq!(ticket_posts(&client), posts + 1);
let records = backend.list(Default::default()).unwrap();
assert_eq!(records.len(), before + 1, "real SQLite write: {output}");
assert!(
records
.iter()
.any(|record| record.title == "Managed policy allowed")
);
} else {
assert!(result.is_err(), "{state}: Tool must deny");
assert_eq!(
ticket_posts(&client),
posts,
"{state}: no downstream create dispatch"
);
let (status, body) = response(
&f.api,
runtime_source_request(
&f.identity,
Some(&f.source.worker_id),
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets"),
serde_json::to_vec(&NewTicket::new(format!("Raw denied {state}"))).unwrap(),
),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"{state}: direct Backend path also rechecks: {body}"
);
assert_eq!(
backend.list(Default::default()).unwrap().len(),
before,
"{state}: denied writes = 0"
);
}
}
}
+102 -1
View File
@@ -7023,6 +7023,72 @@
],
"type": "object"
},
"PermissionOperationAvailability": {
"oneOf": [
{
"additionalProperties": false,
"properties": {
"kind": {
"const": "available",
"type": "string"
}
},
"required": [
"kind"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"kind": {
"const": "unavailable",
"type": "string"
},
"reason": {
"type": "string"
}
},
"required": [
"kind",
"reason"
],
"type": "object"
}
]
},
"PermissionOperationDescriptor": {
"additionalProperties": false,
"properties": {
"availability": {
"$ref": "#/components/schemas/PermissionOperationAvailability"
},
"description": {
"type": "string"
},
"label": {
"type": "string"
},
"operation": {
"type": "string"
},
"resource_kinds": {
"description": "Accepted management input kinds, not a claim that a target exists or is usable.",
"items": {
"$ref": "#/components/schemas/PermissionResourceKind"
},
"type": "array"
}
},
"required": [
"operation",
"label",
"description",
"resource_kinds",
"availability"
],
"type": "object"
},
"PermissionResource": {
"oneOf": [
{
@@ -7377,6 +7443,28 @@
}
]
},
"PermissionResourceKind": {
"enum": [
"job",
"ticket",
"objective",
"merge_request",
"subject",
"drive_entry",
"drive_request",
"repository",
"workdir_attachment",
"workspace_config",
"network",
"sub_worker",
"feature",
"workspace",
"workdir",
"worker",
"worker_operation"
],
"type": "string"
},
"PluginAddArtifactRequest": {
"additionalProperties": false,
"properties": {
@@ -11276,6 +11364,15 @@
"SubjectPermissionsResponse": {
"additionalProperties": false,
"properties": {
"can_manage": {
"type": "boolean"
},
"operations": {
"items": {
"$ref": "#/components/schemas/PermissionOperationDescriptor"
},
"type": "array"
},
"permissions": {
"anyOf": [
{
@@ -11287,6 +11384,10 @@
]
}
},
"required": [
"can_manage",
"operations"
],
"type": "object"
},
"SubjectPermissionsUpdate": {
@@ -19114,7 +19215,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3"
"x-yoi-source-digest": "sha256:2dc24ccd7bfaafdde2f79df2bc064ce11b2362065402191517535848a4105b5a"
},
"openapi": "3.1.0",
"paths": {
+2
View File
@@ -11,3 +11,5 @@
- 認証状態と成果物を分離し、scenario・引数・成果物に秘密の値を含めない。
- PlaywrightとNixで固定するbrowserの対応を、toolchain変更として同時に検証する。
- 変更時は`deno.json`のcheck・testと必要なbrowser testを選ぶ。scenario変更では実際の画像と`review-context.json`を確認する。browser testの成功を目視の代わりにしない。
Subject権限の操作確認には[`subject_permissions_review.ts`](browser-tests/subject_permissions_review.ts)、変更前の撮影には[`subject_permissions_before.ts`](browser-tests/subject_permissions_before.ts)を使う。実Webのビルドを合成HTTP fixtureで動かすため、採取結果は実Backendの認証・保存・認可・Worker制御の証拠ではない。画像と診断の目視確認も別途必要である。
@@ -1,5 +1,9 @@
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
import { journalBrowserFixture } from "./journal_browser_fixture.ts";
import {
createSubjectPermissionsFixture,
permissionFixtureSubjects,
} from "./subject_permissions_fixture.ts";
const port = Number(Deno.args[0]);
const buildRoot = Deno.args[1];
@@ -11,6 +15,15 @@ if (
throw new Error("usage: server.ts <port> <build-root> [section-count: 3..100]");
}
// 既存Memory scenarioとpaginationを変えないよう、明示選択時だけ有効にする。
const permissionsMode = Deno.args[3] === "permissions";
if (permissionsMode) {
// 権限の証拠は親から渡された実製品buildを使って採取する。
// build欠落時に/healthを成功させたり、fixture HTMLで代替したりしない。
await Deno.stat(join(buildRoot, "index.html"));
await Deno.stat(join(buildRoot, "_app/immutable"));
}
const permissionsFixture = createSubjectPermissionsFixture();
const workspaceId = "memory-review";
const representativeSubjectId =
"release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review";
@@ -81,6 +94,11 @@ const subjects = [
subject(ungeneratedSubjectId, "Ungenerated subject", "active", "fingerprint-0"),
subject(errorSubjectId, "Unavailable subject", "retired", "fingerprint-3"),
];
if (permissionsMode) {
subjects.push(
...permissionFixtureSubjects.map(([id, role]) => subject(id, role, "active", "fingerprint-0")),
);
}
const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", "fingerprint-1");
function connectedWorker(displayName: string) {
@@ -132,7 +150,13 @@ function memoriesFor(subjectId: string) {
if (subjectId === emptySubjectId || subjectId === ungeneratedSubjectId) return [];
if (subjectId === staleSubjectId) {
return [
memorySummary("stale-memory-1", "change-earlier", "lesson", "resolved", "A resolved stale-surface lesson"),
memorySummary(
"stale-memory-1",
"change-earlier",
"lesson",
"resolved",
"A resolved stale-surface lesson",
),
];
}
if (subjectId === failedSubjectId) {
@@ -227,7 +251,12 @@ function surfaceFor(subjectId: string) {
function memoryDetail(memoryId: string, requestedChangeId: string | null) {
const currentChangeId = memoryId === derivedMemoryId ? "change-parent" : "change-current";
const changeId = requestedChangeId ?? currentChangeId;
const states: Record<string, string> = { "change-original": "retracted", "change-earlier": "resolved", "change-current": "active", "change-parent": "active" };
const states: Record<string, string> = {
"change-original": "retracted",
"change-earlier": "resolved",
"change-current": "active",
"change-parent": "active",
};
return {
memory_id: memoryId,
change_id: changeId,
@@ -361,6 +390,10 @@ async function staticResponse(pathname: string): Promise<Response> {
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const url = new URL(request.url);
if (url.pathname === "/health") return new Response("ok");
if (permissionsMode) {
const response = await permissionsFixture(request);
if (response) return response;
}
if (url.pathname === "/fixture/subject-create-requests") {
return json({ count: subjectCreateRequests.length, requests: subjectCreateRequests });
}
@@ -393,6 +426,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
manage_secrets: false,
manage_runtimes: false,
delete_workspace: false,
delete_subjects: false,
},
extension_points: {
store: "fixture",
@@ -409,7 +443,13 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
}
if (url.pathname === `/api/w/${workspaceId}/workers`) {
return json({ workspace_id: workspaceId, limit: 200, items: [], source: "fixture", diagnostics: [] });
return json({
workspace_id: workspaceId,
limit: 200,
items: [],
source: "fixture",
diagnostics: [],
});
}
if (url.pathname === `/api/w/${workspaceId}/workers/launch-options`) {
return json({
@@ -521,7 +561,15 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const paginated = subjectId === representativeSubjectId;
const continued = paginated && url.searchParams.get("cursor") === memoryPageCursor;
const items = continued
? [memorySummary("memory-page-two", "change-original", "lesson", "active", "Memory on the next page")]
? [
memorySummary(
"memory-page-two",
"change-original",
"lesson",
"active",
"Memory on the next page",
),
]
: memoriesFor(subjectId);
return json({
items,
@@ -536,7 +584,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
}
if (parts.length === 3) {
const requested = url.searchParams.get("change_id");
const knownChanges = memoryId === derivedMemoryId ? ["change-parent"] : ["change-original", "change-earlier", "change-current"];
const knownChanges = memoryId === derivedMemoryId
? ["change-parent"]
: ["change-original", "change-earlier", "change-current"];
if (requested !== null && !knownChanges.includes(requested)) return json({}, 404);
return json(memoryDetail(memoryId, requested));
}
@@ -0,0 +1,101 @@
#!/usr/bin/env -S deno run -A
/** 変更前の実Web buildでSubject詳細を撮影する。Web自体はbuildしない。 */
import { dirname, fromFileUrl, join, resolve } from "@std/path";
import { capture } from "../src/capture.ts";
import type { Scenario } from "../src/types.ts";
import { freePort } from "./memory_document_harness.ts";
const repositoryRoot = resolve(dirname(fromFileUrl(import.meta.url)), "../../..");
const outputRoot = join(repositoryRoot, "target/web-ux/t-802");
const buildRoot = resolve(Deno.args[0] ?? join(repositoryRoot, "web/workspace/build"));
const runPrefix = Deno.args[1] ?? `before-${new Date().toISOString().replaceAll(/[:.]/g, "-")}`;
if (!/^[a-zA-Z0-9._-]+$/.test(runPrefix)) throw new Error("run id must be a safe filename");
// Preflightは必須。buildの欠落をfixture HTMLで代替しない。
for (const filename of ["index.html", "_app/immutable"]) {
await Deno.stat(join(buildRoot, filename)).catch(() => {
throw new Error(
`Actual Web build missing: ${
join(buildRoot, filename)
}. Parent must build/supply it; this harness does not build Web or bypass preflight.`,
);
});
}
await Deno.mkdir(outputRoot, { recursive: true });
const index = await Deno.readFile(join(buildRoot, "index.html"));
const indexSha256 = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", index)))
.map((b) => b.toString(16).padStart(2, "0")).join("");
await Deno.writeTextFile(
join(outputRoot, `${runPrefix}-build.json`),
JSON.stringify(
{
evidence: "actual-Web-with-synthetic-HTTP-NOT-real-Backend",
buildRoot,
indexSha256,
capturedAt: new Date().toISOString(),
visualVerdict: "UNREVIEWED — parent must ViewImage before pass",
},
null,
2,
),
);
const port = await freePort();
for (const theme of ["light", "dark"] as const) {
const scenario: Scenario = {
schemaVersion: 1,
id: `subject-permissions-before-${theme}`,
title: "T-802 BEFORE — actual Subject detail / synthetic HTTP, NOT real Backend",
baseUrl: `http://127.0.0.1:${port}`,
colorScheme: theme,
reducedMotion: "reduce",
personas: [{
id: "synthetic-owner",
label: "Synthetic HTTP owner, NOT real Backend authentication",
auth: { kind: "anonymous" },
}],
viewports: [
{ label: "desktop", width: 1440, height: 900 },
{ label: "narrow", width: 768, height: 900 },
{ label: "mobile", width: 390, height: 844 },
],
routes: [{
id: "subject-detail",
label: "Existing Subject detail before permission UI",
path:
"/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review",
goal:
"Find Subject identity, current state and ordinary routes before permission management changes.",
dataState:
"Active Subject with current Worker and representative long identifiers. All HTTP is synthetic; no real Backend evidence.",
ready: { kind: "selector", selector: ".subject-overview" },
capturePoints: [{ id: "initial", label: "BEFORE Subject detail" }],
}],
processes: [{
id: "t-802-owned-synthetic-http",
command: Deno.execPath(),
cwd: join(repositoryRoot, "tools/web-ux"),
args: [
"run",
"-A",
join(repositoryRoot, "tools/web-ux/browser-tests/memory_document_fixture_server.ts"),
String(port),
buildRoot,
"3",
"permissions",
],
readyUrl: `http://127.0.0.1:${port}/health`,
}],
};
const scenarioPath = join(outputRoot, `${runPrefix}-${theme}-scenario.json`);
await Deno.writeTextFile(scenarioPath, JSON.stringify(scenario, null, 2));
const bundle = await capture({
scenarioPath,
outputDirectory: outputRoot,
runId: `${runPrefix}-${theme}`,
});
console.log(JSON.stringify({ runId: bundle.runId, status: bundle.status }));
if (bundle.status !== "completed") {
throw new Error(
`BEFORE capture was not clean: ${bundle.runId}; inspect review-context.json. No visual pass is implied.`,
);
}
}
@@ -0,0 +1,186 @@
/**
* Browser UXだけの合成HTTP状態。実Backend、認証、認可、操作catalog、取消の実装ではない。
* 実Backendの結合証拠は、このbrowser fixtureと分けて保持する。
*/
export const permissionFixtureSubjects = [
["permissions-empty", "New Subject — permissions not configured"],
["permissions-edit", "Release coordination"],
["permissions-legacy", "Subject with preserved legacy permissions"],
["permissions-reader", "Read-only Subject permissions"],
["permissions-unavailable", "Unavailable Subject permissions"],
] as const;
export type FixturePermission = {
operation: string;
resource: Record<string, string | boolean> & { kind: string };
};
export type FixturePolicy = {
change_id: string;
execution: FixturePermission[];
delegation: FixturePermission[];
};
type FixtureUpdate = Omit<FixturePolicy, "change_id"> & {
operation_id: string;
expected_change_id: string | null;
};
const workspaceId = "memory-review";
const resource = { kind: "ticket", workspace_id: workspaceId, ticket_id: "T-802" };
export const fixtureLegacyPermission: FixturePermission = {
operation: "worker.notify",
resource: { kind: "worker", runtime_id: "legacy-runtime", worker_id: "legacy-recipient" },
};
// 小さな合成catalogに限定する。製品catalogの複製や第二正本ではない。
export const fixturePermissionOperations: {
operation: string;
label: string;
description: string;
resource_kinds: string[];
availability: { kind: "available" } | { kind: "unavailable"; reason: string };
}[] = [
{
operation: "ticket.read",
label: "Read ticket",
description: "Read the selected ticket. Does not allow editing or closing it.",
resource_kinds: ["ticket"],
availability: { kind: "available" },
},
{
operation: "ticket.comment",
label: "Comment on ticket",
description: "Append a comment to the selected ticket; no completion or assignment authority.",
resource_kinds: ["ticket"],
availability: { kind: "available" },
},
{
operation: "worker.messaging",
label: "Message a Worker (T-803)",
description:
"Operation use is separate from Backend directional target control grants and execution conditions.",
resource_kinds: [],
availability: {
kind: "unavailable",
reason: "T-803 is not implemented in this synthetic browser fixture.",
},
},
];
function json(value: unknown, status = 200): Response {
return Response.json(value, {
status,
headers: { "x-yoi-evidence": "synthetic-http-not-real-backend" },
});
}
function error(message: string, status: number): Response {
return json({ error: "Synthetic fixture response", message }, status);
}
function isUpdate(value: unknown): value is FixtureUpdate {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return typeof v.operation_id === "string" && v.operation_id.length > 0 &&
(v.expected_change_id === null || typeof v.expected_change_id === "string") &&
Array.isArray(v.execution) && Array.isArray(v.delegation) &&
[...v.execution, ...v.delegation].every((p) =>
p && typeof p.operation === "string" && p.resource && typeof p.resource.kind === "string"
);
}
export function createSubjectPermissionsFixture() {
const policies = new Map<string, FixturePolicy | null>();
const receipts = new Map<string, { input: string; permissions: FixturePolicy }>();
const requests: { subject_id: string; request: FixtureUpdate; outcome: string }[] = [];
let issuedChangeCount = 0;
for (const [id] of permissionFixtureSubjects) {
policies.set(
id,
id === "permissions-empty" ? null : {
change_id: `fixture-initial-${id}`,
execution: [{ operation: "ticket.read", resource: { ...resource } }],
delegation: id === "permissions-legacy" ? [structuredClone(fixtureLegacyPermission)] : [],
},
);
}
return async (request: Request): Promise<Response | null> => {
const url = new URL(request.url);
if (url.pathname === "/api/auth/whoami") {
// 合成HTTP session projectionで実Webの画面入場判定を通す。
// 実Backendのsessionやcookie/tokenは作成・迂回しない。
return json({
actor: {
user_id: "synthetic-owner",
account_id: "owner",
handle: "fixture-owner",
display_name: "Synthetic browser owner",
auth_method: "browser_session",
},
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
authentication_reference: "synthetic-http-not-real-authentication",
});
}
if (url.pathname === "/fixture/permissions/evidence") {
if (request.method !== "GET") return error("GET required", 405);
return json({ authority: "synthetic-http-not-real-backend", requests });
}
const match = url.pathname.match(
/^\/api\/w\/([^/]+)\/subjektiv\/subjects\/([^/]+)\/permissions$/,
);
if (!match) return null;
const [, workspace, id] = match.map(decodeURIComponent);
if (workspace !== workspaceId || !policies.has(id)) return error("Subject unavailable", 404);
if (id === "permissions-unavailable") return error("Permission service unavailable", 503);
const current = policies.get(id)!;
if (request.method === "GET") {
return json({
permissions: current,
can_manage: id !== "permissions-reader",
operations: fixturePermissionOperations,
});
}
if (request.method !== "POST") return error("POST required", 405);
if (id === "permissions-reader") return error("Actor cannot manage permissions", 403);
const payload: unknown = await request.json().catch(() => null);
if (!isUpdate(payload)) return error("Invalid synthetic policy update", 400);
const input = JSON.stringify(payload);
const receiptKey = JSON.stringify([id, payload.operation_id]);
const previous = receipts.get(receiptKey);
if (previous) {
requests.push({ subject_id: id, request: payload, outcome: "replay" });
if (previous.input !== input) {
return error("Operation identity reused with changed input", 409);
}
return json({ permissions: previous.permissions, interruptions: [] });
}
if (payload.expected_change_id !== (current?.change_id ?? null)) {
requests.push({ subject_id: id, request: payload, outcome: "conflict" });
return error("Policy changed in another browser; reload and review", 409);
}
// fixtureで使う組合せだけを拒否する。Backend検証の証拠にはしない。
for (const p of [...payload.execution, ...payload.delegation]) {
const legacy = JSON.stringify(p) === JSON.stringify(fixtureLegacyPermission) &&
id === "permissions-legacy";
const supported = fixturePermissionOperations.some((o) =>
o.availability.kind === "available" && o.operation === p.operation &&
o.resource_kinds.includes(p.resource.kind)
);
if (!legacy && !supported) {
return error("Unknown operation or invalid resource combination", 400);
}
if (
p.resource.kind === "ticket" &&
(p.resource.workspace_id !== workspaceId || !p.resource.ticket_id)
) {
return error("Invalid ticket target", 400);
}
}
const saved: FixturePolicy = {
change_id: `fixture-change-${++issuedChangeCount}`,
execution: structuredClone(payload.execution),
delegation: structuredClone(payload.delegation),
};
policies.set(id, saved);
receipts.set(receiptKey, { input, permissions: structuredClone(saved) });
requests.push({ subject_id: id, request: payload, outcome: "saved" });
return json({ permissions: saved, interruptions: [] });
};
}
@@ -0,0 +1,477 @@
#!/usr/bin/env -S deno run -A
/** 実Web buildを合成HTTPで操作する。実Backend認証やWorker動作の証拠ではない。 */
import { assert, assertEquals } from "@std/assert";
import { dirname, fromFileUrl, join, resolve } from "@std/path";
import { chromium, type Page } from "playwright";
import { sha256File } from "../src/artifacts.ts";
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
import { assertNoPageWideOverflow, freePort } from "./memory_document_harness.ts";
const root = resolve(dirname(fromFileUrl(import.meta.url)), "../../..");
const buildRoot = resolve(Deno.args[0] ?? join(root, "web/workspace/build"));
const runId = Deno.args[1] ?? `after-${new Date().toISOString().replaceAll(/[:.]/g, "-")}`;
if (!/^[a-zA-Z0-9._-]+$/.test(runId)) throw new Error("run id must be a safe filename");
// Web buildは親の責務。欠落時は即失敗させ、合成shellで代替しない。
for (const name of ["index.html", "_app/immutable"]) {
await Deno.stat(join(buildRoot, name)).catch(() => {
throw new Error(`Actual Web build missing: ${join(buildRoot, name)}; parent must supply it.`);
});
}
const output = join(root, "target/web-ux/t-802", runId);
await Deno.mkdir(dirname(output), { recursive: true });
await Deno.mkdir(output); // 同じrun IDで既存の証拠を上書きしない。
const sourceCommit = await new Deno.Command("git", {
args: ["rev-parse", "HEAD"],
cwd: root,
stdout: "piped",
}).output();
const sourceStatus = await new Deno.Command("git", {
args: ["status", "--porcelain"],
cwd: root,
stdout: "piped",
}).output();
const manifest: {
evidence: string;
buildRoot: string;
buildIndexSha256: string;
visualVerdict: string;
status: string;
captures: unknown[];
failures: string[];
source: { commitSha: string | null; dirty: boolean | null };
browser: { name: string; version: string } | null;
expectedFaults: string[];
} = {
evidence: "actual-Web-with-synthetic-HTTP-NOT-real-Backend",
buildRoot,
buildIndexSha256: await sha256File(join(buildRoot, "index.html")),
visualVerdict: "UNREVIEWED — parent must ViewImage and inspect diagnostics before pass",
status: "running",
captures: [],
failures: [],
source: {
commitSha: sourceCommit.success ? new TextDecoder().decode(sourceCommit.stdout).trim() : null,
dirty: sourceStatus.success
? new TextDecoder().decode(sourceStatus.stdout).trim().length > 0
: null,
},
browser: null,
expectedFaults: [
"One intentionally aborted POST response after synthetic persistence per width/theme",
"HTTP 409 for stale two-page policy save",
"HTTP 503 for permissions-unavailable GET",
],
};
const browser = await chromium.launch({ headless: true });
manifest.browser = { name: "chromium", version: browser.version() };
type BrowserDiagnostic = {
kind: string;
message: string;
url?: string;
method?: string;
resourceType?: string;
pageUrl?: string;
};
const diagnostics = new WeakMap<Page, BrowserDiagnostic[]>();
const documents = new WeakMap<Page, number>();
const errorCounts = new WeakMap<Page, number>();
function observe(page: Page) {
const errors: BrowserDiagnostic[] = [];
diagnostics.set(page, errors);
const add = (e: typeof errors[number]) => {
errorCounts.set(page, (errorCounts.get(page) ?? 0) + 1);
if (errors.length < 100) errors.push(e);
};
page.on("pageerror", (e) => add({ kind: "page", message: e.stack || e.message || String(e) }));
page.on("console", (e) => {
if (e.type() === "error" || e.type() === "warning") add({ kind: "console", message: e.text() });
});
page.on(
"requestfailed",
(r) =>
add({
kind: "request",
message: r.failure()?.errorText ?? "request failed",
url: r.url(),
method: r.method(),
resourceType: r.resourceType(),
pageUrl: page.url(),
}),
);
page.on("response", (r) => {
if (r.request().isNavigationRequest()) documents.set(page, r.status());
if (r.status() >= 400) {
add({
kind: "http",
message: `HTTP ${r.status()}`,
url: r.url(),
method: r.request().method(),
resourceType: r.request().resourceType(),
pageUrl: page.url(),
});
}
});
}
async function capturePoint(page: Page, id: string, expectedErrors: string[] = []) {
// 製品CSSやlayoutを変えず、確認対象の判断が見える位置へ移動する。
if (id.includes("preview")) await page.locator(".diff").scrollIntoViewIfNeeded();
else if (/saved|re-read|unknown|recovered|conflict/.test(id)) {
await page.locator(".permissions > header").scrollIntoViewIfNeeded();
}
// overflow検査が失敗する場合も、先に画像を保存して根拠を残す。
const image = join(output, `${id}.png`);
const snapshot = join(output, `${id}.aria.txt`);
await page.screenshot({ path: image });
await Deno.writeTextFile(snapshot, await page.locator("body").ariaSnapshot());
const mobileRemoveButtons = page.viewportSize()?.width === 390
? await page.locator(".lane button").evaluateAll((buttons) =>
buttons.map((button) => {
const style = button.ownerDocument.defaultView!.getComputedStyle(button);
const bounds = button.getBoundingClientRect();
const lane = button.closest(".lane")!.getBoundingClientRect();
const range = button.ownerDocument.createRange();
range.selectNodeContents(button);
const textBounds = range.getBoundingClientRect();
return {
label: button.getAttribute("aria-label"),
text: button.textContent?.trim(),
whiteSpace: style.whiteSpace,
flexShrink: style.flexShrink,
textLineBoxes: Array.from(range.getClientRects()).filter((rect) => rect.width > 0).length,
textFitsButton: textBounds.left >=
bounds.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft) - 1 &&
textBounds.right <=
bounds.right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight) +
1 &&
textBounds.top >= bounds.top && textBounds.bottom <= bounds.bottom,
buttonFitsLane: bounds.left >= lane.left - 1 && bounds.right <= lane.right + 1,
noInternalClipping: button.scrollWidth <= button.clientWidth &&
button.scrollHeight <= button.clientHeight,
width: bounds.width,
textWidth: textBounds.width,
};
})
)
: [];
manifest.captures.push({
id,
route: new URL(page.url()).pathname,
viewport: page.viewportSize(),
theme: await page.evaluate(() =>
(globalThis as any).matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
),
documentStatus: documents.get(page) ?? null,
visibleAlerts: await page.getByRole("alert").allTextContents(),
permissionState: await page.locator("[data-permission-state]").getAttribute(
"data-permission-state",
),
image,
imageSha256: await sha256File(image),
accessibilitySnapshot: snapshot,
expectedErrors,
// 再読込みで復旧の証拠を消さないよう、pageごとにerrorを累積する。
errors: [...diagnostics.get(page) ?? []],
errorSummary: {
observed: errorCounts.get(page) ?? 0,
retained: diagnostics.get(page)?.length ?? 0,
truncated: (errorCounts.get(page) ?? 0) > 100,
limit: 100,
},
mobileRemoveButtons,
reviewed: false,
});
await Deno.writeTextFile(join(output, "review-context.json"), JSON.stringify(manifest, null, 2));
for (const button of mobileRemoveButtons) {
assertEquals(button.text, "Remove", `${id}: expected Remove control`);
assertEquals(button.whiteSpace, "nowrap", `${id}: ${button.label} can wrap`);
assertEquals(button.flexShrink, "0", `${id}: ${button.label} can shrink`);
assertEquals(button.textLineBoxes, 1, `${id}: ${button.label} text breaks across lines`);
assert(
button.textFitsButton && button.buttonFitsLane && button.noInternalClipping,
`${id}: ${button.label} clips or exceeds its lane`,
);
}
await assertNoPageWideOverflow(page);
}
async function open(page: Page, base: string, id: string, state = "ready") {
await page.goto(`${base}/w/memory-review/memory/${id}/permissions`);
await page.locator(`[data-permission-state=${state}][aria-busy=false]`).waitFor();
}
async function add(
page: Page,
lane: "execution" | "delegation",
operation: string,
target: string,
resourceCaptureId?: string,
) {
await page.getByRole("combobox", { name: "Permission scope", exact: true }).selectOption(lane);
await page.getByRole("combobox", { name: "Operation", exact: true }).selectOption(operation);
// 内部JSON入力ではなく、実UIの名称・説明・資源入力を操作する。
await page.getByText(
operation === "ticket.read"
? "Read the selected ticket. Does not allow editing or closing it."
: "Append a comment to the selected ticket; no completion or assignment authority.",
{ exact: true },
).waitFor();
await page.getByLabel("Ticket identifier", { exact: true }).fill(target);
if (resourceCaptureId) {
await page.getByLabel("Ticket identifier", { exact: true }).focus();
await page.keyboard.press("Tab");
assertEquals(
await page.getByRole("button", { name: "Add to unsaved changes", exact: true }).evaluate((
element,
) => element === element.ownerDocument.activeElement),
true,
);
await capturePoint(page, resourceCaptureId);
}
await page.getByRole("button", { name: "Add to unsaved changes", exact: true }).click();
await page.locator("[data-permission-state=unsaved]").waitFor();
}
async function save(page: Page, state = "saved") {
await page.getByLabel("I reviewed the operation, resource and delegation differences.").check();
await page.getByRole("button", { name: "Save permissions", exact: true }).click();
await page.locator(`[data-permission-state=${state}][aria-busy=false]`).waitFor();
}
try {
for (const theme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const prefix = `${theme}-${width}`;
const port = await freePort();
const base = `http://127.0.0.1:${port}`;
const owned = await startOwnedProcesses(
[{
id: `t-802-${prefix}-synthetic-http`,
command: Deno.execPath(),
cwd: join(root, "tools/web-ux"),
args: [
"run",
"-A",
join(root, "tools/web-ux/browser-tests/memory_document_fixture_server.ts"),
String(port),
buildRoot,
"3",
"permissions",
],
readyUrl: `${base}/health`,
}],
join(output, "scenario.json"),
join(output, `logs-${prefix}`),
[],
);
const context = await browser.newContext({
viewport: { width, height: width === 390 ? 844 : 900 },
colorScheme: theme,
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
observe(page);
// 通常のSubject詳細から管理導線へ進む。
await page.goto(`${base}/w/memory-review/memory/permissions-empty`);
await page.locator(".subject-overview").waitFor();
await page.locator('main a[href$="/permissions"]').click();
await page.locator("[data-permission-state=ready]").waitFor();
await page.getByText("Not configured · no permissions", { exact: true }).waitFor();
await capturePoint(page, `${prefix}-empty`);
await add(
page,
"execution",
"ticket.read",
"T-802",
`${prefix}-operation-description-resource-input`,
);
await add(page, "delegation", "ticket.comment", "T-802");
// 名前付きlaneで検査し、一つのgrantが両方へ黙って追加されないことを確認する。
assertEquals(
await page.locator('.lane[aria-label="Direct operations"]').getByText("Read ticket", {
exact: true,
}).count(),
1,
);
assertEquals(
await page.locator('.lane[aria-label="Direct operations"]').getByText(
"Comment on ticket",
{ exact: true },
).count(),
0,
);
assertEquals(
await page.locator('.lane[aria-label="Background Worker delegation ceiling"]').getByText(
"Comment on ticket",
{ exact: true },
).count(),
1,
);
await capturePoint(page, `${prefix}-preview-separate-lanes`);
await save(page);
await capturePoint(page, `${prefix}-saved`);
await page.reload();
await page.locator("[data-permission-state=ready]").waitFor();
await capturePoint(page, `${prefix}-re-read`);
// 合成HTTP fixtureがPOSTを受け付けた後、実browserで応答を消失させる。
await add(page, "execution", "ticket.comment", "T-803");
let lost = false;
await page.route("**/subjektiv/subjects/permissions-empty/permissions", async (route) => {
if (route.request().method() === "POST" && !lost) {
const accepted = await route.fetch();
assertEquals(accepted.status(), 200);
lost = true;
await route.abort("failed");
} else await route.continue();
});
await save(page, "unknown");
assert(lost);
// 障害注入は一度だけとし、撮影前に通常のbrowser通信へ戻す。
await page.unroute("**/subjektiv/subjects/permissions-empty/permissions");
await capturePoint(page, `${prefix}-unknown`, [
"Intentional POST response loss after synthetic save",
]);
await page.reload();
await page.locator("[data-permission-state=unknown][aria-busy=false]").waitFor();
await page.getByRole("button", { name: "Reconcile the same save", exact: true }).click();
await page.locator("[data-permission-state=saved]").waitFor();
const evidence = await (await fetch(`${base}/fixture/permissions/evidence`)).json();
const saves = evidence.requests.filter((r: { subject_id: string }) =>
r.subject_id === "permissions-empty"
);
assertEquals(saves.length, 3);
assertEquals(saves[1].request, saves[2].request);
assertEquals(saves[2].outcome, "replay");
await capturePoint(page, `${prefix}-recovered-same-operation`, [
"Intentional earlier POST response loss",
]);
// 二つのbrowser画面で同じ合成change identityを観測してから保存する。
const other = await context.newPage();
observe(other);
await open(page, base, "permissions-edit");
await open(other, base, "permissions-edit");
await add(page, "delegation", "ticket.comment", "T-802");
await other.getByRole("button", {
name: "Remove Read ticket from direct operations",
exact: true,
}).click();
await save(page);
await save(other, "error");
await other.getByRole("alert").filter({ hasText: "Conflict:" }).waitFor();
await capturePoint(other, `${prefix}-two-page-conflict`, [
"Intentional stale policy POST returns HTTP 409",
]);
await other.close();
await page.getByRole("combobox", { name: "Operation", exact: true }).selectOption(
"worker.messaging",
);
await page.getByText("Target unavailable:", { exact: false }).waitFor();
assertEquals(
await page.getByRole("button", { name: "Add to unsaved changes", exact: true }).count(),
0,
);
await capturePoint(page, `${prefix}-unavailable-operation`);
await open(page, base, "permissions-reader");
assertEquals(await page.locator("form.permission-form").count(), 0);
assertEquals(
await page.getByRole("button", { name: "Save permissions", exact: true }).count(),
0,
);
await capturePoint(page, `${prefix}-read-only`);
await open(page, base, "permissions-unavailable", "error");
await capturePoint(page, `${prefix}-service-unavailable`, [
"Intentional permission GET returns HTTP 503",
]);
await open(page, base, "permissions-legacy");
await page.getByText(
"Preserved, not editable here. No conversion or expansion is implied.",
{ exact: true },
).waitFor();
await add(page, "execution", "ticket.comment", "T-802");
await save(page);
const legacy = await (await fetch(
`${base}/api/w/memory-review/subjektiv/subjects/permissions-legacy/permissions`,
)).json();
assertEquals(legacy.permissions.delegation, [{
operation: "worker.notify",
resource: { kind: "worker", runtime_id: "legacy-runtime", worker_id: "legacy-recipient" },
}]);
await capturePoint(page, `${prefix}-legacy-preserved`, [
"Intentional earlier service unavailable GET",
]);
// 直接操作の失効を確認する。ここで証明するのはUIの入力であり、実取消ではない。
await page.getByRole("button", {
name: "Remove Read ticket from direct operations",
exact: true,
}).click();
await save(page);
await capturePoint(page, `${prefix}-revoked-saved`);
const finalAudit = await (await fetch(`${base}/fixture/permissions/evidence`)).json();
const finalLegacy = await (await fetch(
`${base}/api/w/memory-review/subjektiv/subjects/permissions-legacy/permissions`,
)).json();
assertEquals(finalAudit.requests.length, 7);
assertEquals(
finalAudit.requests.filter((r: { outcome: string }) => r.outcome === "conflict").length,
1,
);
assertEquals(finalLegacy.permissions.execution, [{
operation: "ticket.comment",
resource: { kind: "ticket", workspace_id: "memory-review", ticket_id: "T-802" },
}]);
assertEquals(finalLegacy.permissions.delegation, legacy.permissions.delegation);
await Deno.writeTextFile(
join(output, `${prefix}-synthetic-http-evidence.json`),
JSON.stringify(
{
authority: "synthetic-http-not-real-backend",
replayAssertions: {
originalAndRetryInputsEqual: true,
retryOutcome: saves[2].outcome,
},
legacyDelegationPreserved: legacy.permissions.delegation,
requests: finalAudit.requests,
currentLegacyPolicyAfterShrink: finalLegacy,
},
null,
2,
),
);
} catch (e) {
manifest.failures.push(
`${prefix}: ${e instanceof Error ? e.stack ?? e.message : String(e)}`,
);
for (const [index, failedPage] of context.pages().entries()) {
const image = join(output, `${prefix}-failure-${index}.png`);
const snapshot = join(output, `${prefix}-failure-${index}.aria.txt`);
await failedPage.screenshot({ path: image, timeout: 5_000 }).catch(() => {});
await Deno.writeTextFile(snapshot, await failedPage.locator("body").ariaSnapshot());
manifest.captures.push({
id: `${prefix}-failure-${index}`,
route: failedPage.url(),
documentStatus: documents.get(failedPage) ?? null,
image,
accessibilitySnapshot: snapshot,
errors: diagnostics.get(failedPage) ?? [],
reviewed: false,
});
}
throw e;
} finally {
await context.close();
await stopOwnedProcesses(owned);
}
}
}
assertEquals(
await sha256File(join(buildRoot, "index.html")),
manifest.buildIndexSha256,
"Web build changed during the browser matrix",
);
manifest.status = "browser-assertions-passed-visual-unreviewed";
} catch (e) {
manifest.status = "failed";
throw e;
} finally {
await browser.close();
await Deno.writeTextFile(join(output, "review-context.json"), JSON.stringify(manifest, null, 2));
console.log(join(output, "review-context.json"));
}
@@ -0,0 +1,138 @@
{
"schemaVersion": 1,
"id": "subject-permissions-dark",
"title": "T-802 actual Web / synthetic HTTP NOT real Backend — dark",
"baseUrl": "http://127.0.0.1:15289",
"colorScheme": "dark",
"reducedMotion": "reduce",
"personas": [
{
"id": "fixture",
"label": "Synthetic HTTP projection, NOT real Backend authentication or authorization",
"auth": {
"kind": "anonymous"
}
}
],
"viewports": [
{
"label": "desktop",
"width": 1440,
"height": 900
},
{
"label": "narrow",
"width": 768,
"height": 900
},
{
"label": "mobile",
"width": 390,
"height": 844
}
],
"routes": [
{
"id": "permissions-empty",
"label": "Unset permissions",
"path": "/w/memory-review/memory/permissions-empty/permissions",
"goal": "Distinguish unset permission policy from unavailable or read-only; locate separate direct and delegation scopes.",
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Unset permissions.",
"ready": {
"kind": "selector",
"selector": "[data-permission-state=ready][aria-busy=false]"
},
"capturePoints": [
{
"id": "initial",
"label": "Unset permissions"
}
]
},
{
"id": "permissions-edit",
"label": "Configured permissions",
"path": "/w/memory-review/memory/permissions-edit/permissions",
"goal": "Read exact operation and target before editing.",
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Configured permissions.",
"ready": {
"kind": "selector",
"selector": "[data-permission-state=ready][aria-busy=false]"
},
"capturePoints": [
{
"id": "initial",
"label": "Configured permissions"
}
]
},
{
"id": "permissions-legacy",
"label": "Preserved unsupported permission",
"path": "/w/memory-review/memory/permissions-legacy/permissions",
"goal": "Recognize preserved legacy Worker target without treating T-803 messaging as available.",
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Preserved unsupported permission.",
"ready": {
"kind": "selector",
"selector": "[data-permission-state=ready][aria-busy=false]"
},
"capturePoints": [
{
"id": "initial",
"label": "Preserved unsupported permission"
}
]
},
{
"id": "permissions-reader",
"label": "Read-only permissions",
"path": "/w/memory-review/memory/permissions-reader/permissions",
"goal": "Read policy without unauthorized editing controls.",
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Read-only permissions.",
"ready": {
"kind": "selector",
"selector": "[data-permission-state=ready][aria-busy=false]"
},
"capturePoints": [
{
"id": "initial",
"label": "Read-only permissions"
}
]
},
{
"id": "permissions-unavailable",
"label": "Unavailable permission service",
"path": "/w/memory-review/memory/permissions-unavailable/permissions",
"goal": "Recover without success claims.",
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Unavailable permission service.",
"ready": {
"kind": "selector",
"selector": "[data-permission-state=error][aria-busy=false]"
},
"capturePoints": [
{
"id": "initial",
"label": "Unavailable permission service"
}
]
}
],
"processes": [
{
"id": "subject-permissions-synthetic-http",
"command": "deno",
"cwd": "..",
"args": [
"run",
"-A",
"browser-tests/memory_document_fixture_server.ts",
"15289",
"${WEB_UX_BUILD_ROOT}",
"3",
"permissions"
],
"readyUrl": "http://127.0.0.1:15289/health"
}
]
}
@@ -0,0 +1,138 @@
{
"schemaVersion": 1,
"id": "subject-permissions-light",
"title": "T-802 actual Web / synthetic HTTP NOT real Backend — light",
"baseUrl": "http://127.0.0.1:15289",
"colorScheme": "light",
"reducedMotion": "reduce",
"personas": [
{
"id": "fixture",
"label": "Synthetic HTTP projection, NOT real Backend authentication or authorization",
"auth": {
"kind": "anonymous"
}
}
],
"viewports": [
{
"label": "desktop",
"width": 1440,
"height": 900
},
{
"label": "narrow",
"width": 768,
"height": 900
},
{
"label": "mobile",
"width": 390,
"height": 844
}
],
"routes": [
{
"id": "permissions-empty",
"label": "Unset permissions",
"path": "/w/memory-review/memory/permissions-empty/permissions",
"goal": "Distinguish unset permission policy from unavailable or read-only; locate separate direct and delegation scopes.",
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Unset permissions.",
"ready": {
"kind": "selector",
"selector": "[data-permission-state=ready][aria-busy=false]"
},
"capturePoints": [
{
"id": "initial",
"label": "Unset permissions"
}
]
},
{
"id": "permissions-edit",
"label": "Configured permissions",
"path": "/w/memory-review/memory/permissions-edit/permissions",
"goal": "Read exact operation and target before editing.",
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Configured permissions.",
"ready": {
"kind": "selector",
"selector": "[data-permission-state=ready][aria-busy=false]"
},
"capturePoints": [
{
"id": "initial",
"label": "Configured permissions"
}
]
},
{
"id": "permissions-legacy",
"label": "Preserved unsupported permission",
"path": "/w/memory-review/memory/permissions-legacy/permissions",
"goal": "Recognize preserved legacy Worker target without treating T-803 messaging as available.",
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Preserved unsupported permission.",
"ready": {
"kind": "selector",
"selector": "[data-permission-state=ready][aria-busy=false]"
},
"capturePoints": [
{
"id": "initial",
"label": "Preserved unsupported permission"
}
]
},
{
"id": "permissions-reader",
"label": "Read-only permissions",
"path": "/w/memory-review/memory/permissions-reader/permissions",
"goal": "Read policy without unauthorized editing controls.",
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Read-only permissions.",
"ready": {
"kind": "selector",
"selector": "[data-permission-state=ready][aria-busy=false]"
},
"capturePoints": [
{
"id": "initial",
"label": "Read-only permissions"
}
]
},
{
"id": "permissions-unavailable",
"label": "Unavailable permission service",
"path": "/w/memory-review/memory/permissions-unavailable/permissions",
"goal": "Recover without success claims.",
"dataState": "Synthetic HTTP only; no real Backend persistence, authorization, lifecycle or Worker evidence. Unavailable permission service.",
"ready": {
"kind": "selector",
"selector": "[data-permission-state=error][aria-busy=false]"
},
"capturePoints": [
{
"id": "initial",
"label": "Unavailable permission service"
}
]
}
],
"processes": [
{
"id": "subject-permissions-synthetic-http",
"command": "deno",
"cwd": "..",
"args": [
"run",
"-A",
"browser-tests/memory_document_fixture_server.ts",
"15289",
"${WEB_UX_BUILD_ROOT}",
"3",
"permissions"
],
"readyUrl": "http://127.0.0.1:15289/health"
}
]
}
@@ -0,0 +1,129 @@
import { assert, assertEquals } from "@std/assert";
import {
createSubjectPermissionsFixture,
fixtureLegacyPermission,
type FixturePolicy,
} from "../browser-tests/subject_permissions_fixture.ts";
const root = "http://127.0.0.1:12345/api/w/memory-review/subjektiv/subjects/";
const target = { kind: "ticket", workspace_id: "memory-review", ticket_id: "T-802" };
const direct = { operation: "ticket.read", resource: target };
const delegated = { operation: "ticket.comment", resource: target };
function request(id: string, body?: unknown): Request {
return new Request(
`${root}${id}/permissions`,
body === undefined ? {} : {
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
},
);
}
Deno.test("synthetic browser admission projection is explicitly labeled, with no real credentials", async () => {
const handle = createSubjectPermissionsFixture();
const response = await handle(new Request("http://127.0.0.1:12345/api/auth/whoami"));
assert(response);
assertEquals(response.headers.get("x-yoi-evidence"), "synthetic-http-not-real-backend");
assertEquals(response.headers.get("set-cookie"), null);
const view = await response.json();
assertEquals(view.authentication_reference, "synthetic-http-not-real-authentication");
assertEquals(view.session.kind, "authenticated");
assertEquals(view.actor.auth_method, "browser_session");
});
Deno.test("synthetic UX fixture: unset, separate lanes, exact replay and two-editor CAS", async () => {
const handle = createSubjectPermissionsFixture();
const initial = await handle(request("permissions-empty"));
assert(initial);
assertEquals(initial.headers.get("x-yoi-evidence"), "synthetic-http-not-real-backend");
assertEquals((await initial.json()).permissions, null);
const update = {
operation_id: "browser-save-1",
expected_change_id: null,
execution: [direct],
delegation: [delegated],
};
const save = await handle(request("permissions-empty", update));
assert(save);
assertEquals(save.status, 200);
const saved = await save.json();
assertEquals(saved.permissions.execution, [direct]);
assertEquals(saved.permissions.delegation, [delegated]);
const replay = await handle(request("permissions-empty", update));
assert(replay);
assertEquals(await replay.json(), saved);
const collision = await handle(request("permissions-empty", { ...update, execution: [] }));
assertEquals(collision?.status, 409);
const stale = await handle(
request("permissions-empty", { ...update, operation_id: "second-tab" }),
);
assertEquals(stale?.status, 409);
const readback = await handle(request("permissions-empty"));
assertEquals((await readback!.json()).permissions, saved.permissions);
});
Deno.test("synthetic UX fixture: read-only, unavailable, wrong workspace and unknown targets", async () => {
const handle = createSubjectPermissionsFixture();
const view = await handle(request("permissions-reader"));
assertEquals((await view!.json()).can_manage, false);
assertEquals((await handle(request("permissions-reader", {})))?.status, 403);
assertEquals((await handle(request("permissions-unavailable")))?.status, 503);
assertEquals((await handle(request("missing")))?.status, 404);
assertEquals(
(await handle(
new Request(`${root.replace("memory-review", "other")}permissions-edit/permissions`),
))?.status,
404,
);
assertEquals(await handle(new Request("http://127.0.0.1:12345/not-owned")), null);
});
Deno.test("synthetic UX fixture preserves old Worker target without making T-803 usable", async () => {
const handle = createSubjectPermissionsFixture();
const view = await handle(request("permissions-legacy"));
const initial = await view!.json();
assertEquals(initial.permissions.delegation, [fixtureLegacyPermission]);
const workerOperation = initial.operations.find((o: { operation: string }) =>
o.operation === "worker.messaging"
);
assertEquals(workerOperation.availability.kind, "unavailable");
assertEquals(workerOperation.resource_kinds, []);
assertEquals(Object.hasOwn(workerOperation, "available"), false);
assertEquals(Object.hasOwn(workerOperation, "unavailable_reason"), false);
assertEquals(
initial.operations.find((o: { operation: string }) => o.operation === "ticket.read")
.availability,
{ kind: "available" },
);
const update = {
operation_id: "preserve-legacy",
expected_change_id: initial.permissions.change_id,
execution: [direct, delegated],
delegation: initial.permissions.delegation,
};
const saved = await handle(request("permissions-legacy", update));
assertEquals(saved?.status, 200);
const value = await saved!.json();
assertEquals(value.permissions.delegation, [fixtureLegacyPermission]);
const policy = value.permissions as FixturePolicy;
for (
const invalid of [
{ operation: "future.all", resource: target },
{ operation: "ticket.read", resource: { kind: "workspace", workspace_id: "memory-review" } },
{
operation: "worker.messaging",
resource: { kind: "workspace", workspace_id: "memory-review" },
},
]
) {
const rejected = await handle(request("permissions-legacy", {
...update,
operation_id: `reject-${invalid.operation}`,
expected_change_id: policy.change_id,
execution: [invalid],
}));
assertEquals(rejected?.status, 400);
}
assertEquals((await (await handle(request("permissions-legacy")))!.json()).permissions, policy);
});
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
"test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
"test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts",
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
// Input contract: 0.1.0 (sha256:2dc24ccd7bfaafdde2f79df2bc064ce11b2362065402191517535848a4105b5a)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
+58 -215
View File
@@ -15,250 +15,93 @@ export const SUBJEKTIV_API_LIMITS = {
export type DiagnosticSeverity = "info" | "warning" | "error";
export type Diagnostic = {
code: string;
severity: DiagnosticSeverity;
message: string;
};
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
export type MemoryDocumentResponse = {
body_md: string;
created_at: string;
updated_at: string;
bytes: number;
record_source: string;
};
export type MemoryDocumentResponse = { body_md: string, created_at: string, updated_at: string, bytes: number, record_source: string, };
export type MemoryCandidateKind =
| "preference"
| "working_assumption"
| "constraint"
| "decision"
| "open_question"
| "lesson";
export type MemoryCandidateKind = "preference" | "working_assumption" | "constraint" | "decision" | "open_question" | "lesson";
export type MemoryEvidenceOriginKind =
| "human_input"
| "worker_input"
| "flow_instruction"
| "backend_instruction"
| "model_output"
| "tool_output"
| "derived_summary"
| "legacy_unknown";
export type MemoryEvidenceOriginKind = "human_input" | "worker_input" | "flow_instruction" | "backend_instruction" | "model_output" | "tool_output" | "derived_summary" | "legacy_unknown";
export type MemoryEvidenceOrigin = {
kind: MemoryEvidenceOriginKind;
account_id?: string | null;
workspace_id?: string | null;
runtime_id?: string | null;
worker_id?: string | null;
flow_selector?: string | null;
flow_definition_id?: string | null;
flow_definition_fingerprint?: number | null;
};
export type MemoryEvidenceOrigin = { kind: MemoryEvidenceOriginKind, account_id?: string | null, workspace_id?: string | null, runtime_id?: string | null, worker_id?: string | null, flow_selector?: string | null, flow_definition_id?: string | null, flow_definition_fingerprint?: number | null, };
export type MemorySourceRef = { segment_id: string; range: [number, number] };
export type MemorySourceRef = { segment_id: string, range: [number, number], };
export type MemoryStagingEvidence = {
id: string;
kind: string;
entry_range: [number, number] | null;
origin?: MemoryEvidenceOrigin | null;
excerpt: string | null;
summary: string | null;
};
export type MemoryStagingEvidence = { id: string, kind: string, entry_range: [number, number] | null, origin?: MemoryEvidenceOrigin | null, excerpt: string | null, summary: string | null, };
export type MemorySourceEvidenceRef = {
session_id: string | null;
segment_id: string | null;
entry_range: [number, number] | null;
evidence_id: string | null;
origin?: MemoryEvidenceOrigin | null;
evidence_kind: string | null;
label: string | null;
summary: string | null;
};
export type MemorySourceEvidenceRef = { session_id: string | null, segment_id: string | null, entry_range: [number, number] | null, evidence_id: string | null, origin?: MemoryEvidenceOrigin | null, evidence_kind: string | null, label: string | null, summary: string | null, };
export type MemoryStagingRecord = {
schema_version: number;
id: string;
extract_run_id: string;
source: MemorySourceRef;
kind: MemoryCandidateKind;
claim: string;
why_useful: string;
staleness: string | null;
evidence: Array<MemoryStagingEvidence>;
source_refs: Array<MemorySourceEvidenceRef>;
};
export type MemoryStagingRecord = { schema_version: number, id: string, extract_run_id: string, source: MemorySourceRef, kind: MemoryCandidateKind, claim: string, why_useful: string, staleness: string | null, evidence: Array<MemoryStagingEvidence>, source_refs: Array<MemorySourceEvidenceRef>, };
export type MemoryStagingEntry = {
id: string;
byte_len: number;
record: MemoryStagingRecord;
};
export type MemoryStagingEntry = { id: string, byte_len: number, record: MemoryStagingRecord, };
export type MemoryStagingListResponse = {
limit: number;
returned_count: number;
total_valid_count: number;
invalid_count: number;
truncated: boolean;
order: string;
record_authority: string;
items: Array<MemoryStagingEntry>;
diagnostics: Array<Diagnostic>;
};
export type MemoryStagingListResponse = { limit: number, returned_count: number, total_valid_count: number, invalid_count: number, truncated: boolean, order: string, record_authority: string, items: Array<MemoryStagingEntry>, diagnostics: Array<Diagnostic>, };
export type PermissionResource = { "kind": "job", workspace_id: string, job_id: string, } | { "kind": "ticket", workspace_id: string, ticket_id: string, } | { "kind": "objective", workspace_id: string, objective_id: string, } | { "kind": "merge_request", workspace_id: string, merge_request_id: string, } | { "kind": "subject", workspace_id: string, subject_id: string, } | { "kind": "drive_entry", workspace_id: string, node_id: string, } | { "kind": "drive_request", workspace_id: string, request_id: string, } | { "kind": "repository", workspace_id: string, repository_key: string, } | { "kind": "workdir_attachment", workspace_id: string, connection_id: string, } | { "kind": "workspace_config", workspace_id: string, path: string, recursive: boolean, } | { "kind": "network", url: string, } | { "kind": "sub_worker", runtime_id: string, worker_id: string, name: string, } | { "kind": "feature" } | { "kind": "workspace", workspace_id: string, } | { "kind": "workdir", working_directory_id: string, path: string, recursive: boolean, } | { "kind": "worker_operation", workspace_id: string, } | { "kind": "worker", runtime_id: string, worker_id: string, };
export type ScopedPermission = { operation: string, resource: PermissionResource, };
export type SubjectPermissions = { change_id: string, execution: Array<ScopedPermission>, delegation: Array<ScopedPermission>, };
export type SubjectPermissionsUpdate = { operation_id: string, expected_change_id: string | null, execution: Array<ScopedPermission>, delegation: Array<ScopedPermission>, };
export type PermissionResourceKind = "job" | "ticket" | "objective" | "merge_request" | "subject" | "drive_entry" | "drive_request" | "repository" | "workdir_attachment" | "workspace_config" | "network" | "sub_worker" | "feature" | "workspace" | "workdir" | "worker" | "worker_operation";
export type PermissionOperationAvailability = { "kind": "available" } | { "kind": "unavailable", reason: string, };
export type PermissionOperationDescriptor = { operation: string, label: string, description: string,
/**
* Accepted management input kinds, not a claim that a target exists or is usable.
*/
resource_kinds: Array<PermissionResourceKind>, availability: PermissionOperationAvailability, };
export type SubjectPermissionsResponse = { permissions: SubjectPermissions | null, can_manage: boolean, operations: Array<PermissionOperationDescriptor>, };
export type SubjectPermissionsMutationResponse = { permissions: SubjectPermissions,
/**
* New operations are already fenced. These are cancellation requests and
* Backend command cleanup evidence, not proof that a Runtime turn stopped.
*/
interruptions: Array<SubjectPermissionInterruption>, };
export type SubjectPermissionInterruption = { runtime_id: string, worker_id: string, cancellation_accepted: boolean, backend_command_sessions_closed: boolean, diagnostics: Array<string>, };
export type SubjektivSubjectState = "active" | "retired";
export type SubjektivSubjectCreateRequest = {
role: string;
behavior_md?: string;
};
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
export type SubjektivSubjectDeleteRequest = { operation_id: string };
export type SubjektivSubjectDeleteRequest = { operation_id: string, };
export type SubjektivSubjectDeleteResponse = {
subject_id: string;
operation_id: string;
completed: boolean;
};
export type SubjektivSubjectDeleteResponse = { subject_id: string, operation_id: string, completed: boolean, };
export type SubjektivSubjectBehaviorUpdateRequest = {
expected_behavior_md: string;
behavior_md: string;
};
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_md: string, behavior_md: string, };
export type SubjektivSubjectResponse = {
id: string;
role: string;
behavior_md: string;
state: SubjektivSubjectState;
memory_fingerprint: string;
created_at: string;
updated_at: string;
current_worker?:
| import("./worker-launch-api").WorkerLaunchWorkerSummary
| null;
};
export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, state: SubjektivSubjectState, memory_fingerprint: string, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
export type SubjektivSubjectListResponse = {
limit: number;
items: Array<SubjektivSubjectResponse>;
next_cursor?: string | null;
has_more: boolean;
};
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
export type SubjektivMemoryState = "active" | "resolved" | "retracted";
export type SubjektivMemoryChangeRef = { memory_id: string; change_id: string };
export type SubjektivMemoryChangeRef = { memory_id: string, change_id: string, };
export type SubjektivResidentSurfaceAvailability =
| "ungenerated"
| "stale"
| "failed"
| "ready";
export type SubjektivResidentSurfaceAvailability = "ungenerated" | "stale" | "failed" | "ready";
export type SubjektivResidentSurfaceSnapshot = {
snapshot_id: string;
body_md: string;
memory_refs: Array<SubjektivMemoryChangeRef>;
built_from_memory_fingerprint: string;
created_at: string;
};
export type SubjektivResidentSurfaceSnapshot = { snapshot_id: string, body_md: string, memory_refs: Array<SubjektivMemoryChangeRef>, built_from_memory_fingerprint: string, created_at: string, };
export type SubjektivResidentSurfaceResponse = {
subject_id: string;
availability: SubjektivResidentSurfaceAvailability;
snapshot?: SubjektivResidentSurfaceSnapshot | null;
};
export type SubjektivResidentSurfaceResponse = { subject_id: string, availability: SubjektivResidentSurfaceAvailability, snapshot?: SubjektivResidentSurfaceSnapshot | null, };
export type SubjektivMemoryQueryItem = {
id: string;
change_id: string;
kind: MemoryCandidateKind;
state: SubjektivMemoryState;
claim: string;
excerpt: string;
updated_at: string;
};
export type SubjektivMemoryQueryItem = { id: string, change_id: string, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, excerpt: string, updated_at: string, };
export type SubjektivMemoryQueryResponse = {
items: Array<SubjektivMemoryQueryItem>;
next_cursor?: string | null;
has_more: boolean;
};
export type SubjektivMemoryQueryResponse = { items: Array<SubjektivMemoryQueryItem>, next_cursor?: string | null, has_more: boolean, };
export type SubjektivMemoryEvidence = {
id: string;
kind: string;
entry_range?: [number, number] | null;
origin?: MemoryEvidenceOrigin | null;
excerpt?: string | null;
summary?: string | null;
};
export type SubjektivMemoryEvidence = { id: string, kind: string, entry_range?: [number, number] | null, origin?: MemoryEvidenceOrigin | null, excerpt?: string | null, summary?: string | null, };
export type SubjektivMemorySourceEvidenceRef = {
session_id?: string | null;
segment_id?: string | null;
entry_range?: [number, number] | null;
evidence_id?: string | null;
origin?: MemoryEvidenceOrigin | null;
evidence_kind?: string | null;
label?: string | null;
summary?: string | null;
};
export type SubjektivMemorySourceEvidenceRef = { session_id?: string | null, segment_id?: string | null, entry_range?: [number, number] | null, evidence_id?: string | null, origin?: MemoryEvidenceOrigin | null, evidence_kind?: string | null, label?: string | null, summary?: string | null, };
export type SubjektivMemoryEvidenceCandidate = {
candidate_id: string;
evidence: Array<SubjektivMemoryEvidence>;
evidence_total: number;
evidence_truncated: boolean;
source_refs: Array<SubjektivMemorySourceEvidenceRef>;
source_refs_total: number;
source_refs_truncated: boolean;
};
export type SubjektivMemoryEvidenceCandidate = { candidate_id: string, evidence: Array<SubjektivMemoryEvidence>, evidence_total: number, evidence_truncated: boolean, source_refs: Array<SubjektivMemorySourceEvidenceRef>, source_refs_total: number, source_refs_truncated: boolean, };
export type SubjektivMemoryReadResponse = {
memory_id: string;
change_id: string;
current_change_id: string;
kind: MemoryCandidateKind;
state: SubjektivMemoryState;
claim: string;
body_md: string;
why_useful: string;
staleness?: string | null;
change_reason: string;
created_at: string;
updated_at: string;
body_offset: number;
body_byte_offset: number;
body_next_offset?: number | null;
body_next_byte_offset?: number | null;
body_truncated: boolean;
source_candidate_ids: Array<string>;
source_candidates: Array<SubjektivMemoryEvidenceCandidate>;
derived_from: Array<SubjektivMemoryChangeRef>;
evidence_next_cursor?: string | null;
evidence_has_more: boolean;
};
export type SubjektivMemoryReadResponse = { memory_id: string, change_id: string, current_change_id: string, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, body_md: string, why_useful: string, staleness?: string | null, change_reason: string, created_at: string, updated_at: string, body_offset: number, body_byte_offset: number, body_next_offset?: number | null, body_next_byte_offset?: number | null, body_truncated: boolean, source_candidate_ids: Array<string>, source_candidates: Array<SubjektivMemoryEvidenceCandidate>, derived_from: Array<SubjektivMemoryChangeRef>, evidence_next_cursor?: string | null, evidence_has_more: boolean, };
export type SubjektivMemoryChangeItem = {
change_id: string;
kind: MemoryCandidateKind;
state: SubjektivMemoryState;
claim: string;
change_reason: string;
updated_at: string;
};
export type SubjektivMemoryChangeItem = { change_id: string, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, change_reason: string, updated_at: string, };
export type SubjektivMemoryListChangesResponse = {
memory_id: string;
current_change_id: string;
items: Array<SubjektivMemoryChangeItem>;
next_cursor?: string | null;
has_more: boolean;
};
export type SubjektivMemoryListChangesResponse = { memory_id: string, current_change_id: string, items: Array<SubjektivMemoryChangeItem>, next_cursor?: string | null, has_more: boolean, };
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
// Input contract: 0.1.0 (sha256:2dc24ccd7bfaafdde2f79df2bc064ce11b2362065402191517535848a4105b5a)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
// Input contract: 0.1.0 (sha256:2dc24ccd7bfaafdde2f79df2bc064ce11b2362065402191517535848a4105b5a)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
// Input contract: 0.1.0 (sha256:2dc24ccd7bfaafdde2f79df2bc064ce11b2362065402191517535848a4105b5a)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
// Input contract: 0.1.0 (sha256:2dc24ccd7bfaafdde2f79df2bc064ce11b2362065402191517535848a4105b5a)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -0,0 +1,264 @@
// @vitest-environment happy-dom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import SubjectPermissions from "./SubjectPermissions.svelte";
import type { Policy, PolicyUpdate } from "./permissions.ts";
const ops = [
{
operation: "ticket.create",
label: "Create Tickets",
description: "Create an open Ticket in this Workspace.",
resource_kinds: ["workspace"],
availability: { kind: "available" },
},
{
operation: "filesystem.read",
label: "Read files",
description: "Read only the selected Workdir path.",
resource_kinds: ["workdir"],
availability: { kind: "available" },
},
{
operation: "filesystem.write",
label: "Edit files",
description: "Write only the selected Workdir path.",
resource_kinds: ["workdir"],
availability: { kind: "available" },
},
{
operation: "worker.messaging",
label: "Message Worker",
description: "Separate operation use and directional target control.",
resource_kinds: ["workspace"],
availability: { kind: "unavailable", reason: "T-803 connection pending" },
},
];
const props = { workspaceId: "w", subjectId: "s" };
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
sessionStorage.clear();
});
function harness(initial: Policy | null = null, canManage = true) {
let policy = initial;
const requests: PolicyUpdate[] = [];
const fetch = vi.fn(async (_path: string, init?: RequestInit) => {
if (init?.method === "POST") {
const request = JSON.parse(init.body as string) as PolicyUpdate;
requests.push(request);
policy = {
change_id: request.operation_id,
execution: request.execution,
delegation: request.delegation,
};
return Response.json({ permissions: policy, interruptions: [] });
}
return Response.json({
permissions: policy,
can_manage: canManage,
operations: ops,
});
});
vi.stubGlobal("fetch", fetch);
return { fetch, requests, setPolicy: (next: Policy | null) => policy = next };
}
async function choose(name = "ticket.create") {
await screen.findByRole("combobox", { name: "Operation" });
await fireEvent.change(screen.getByRole("combobox", { name: "Operation" }), {
target: { value: name },
});
}
async function add(which = "execution", name = "ticket.create") {
await choose(name);
await fireEvent.change(
screen.getByRole("combobox", { name: "Permission scope" }),
{ target: { value: which } },
);
if (name.startsWith("filesystem")) {
await fireEvent.input(
screen.getByRole("textbox", { name: "Workdir identifier" }),
{ target: { value: "wd-only" } },
);
await fireEvent.input(
screen.getByRole("textbox", { name: "Relative path" }),
{ target: { value: "src" } },
);
}
await fireEvent.click(
screen.getByRole("button", { name: "Add to unsaved changes" }),
);
}
async function confirmSave() {
await fireEvent.click(screen.getByRole("checkbox", { name: /I reviewed/ }));
await fireEvent.click(
screen.getByRole("button", { name: "Save permissions" }),
);
}
test("new Subject selects direct and narrow delegation independently, previews exact diff and saves through existing endpoint", async () => {
const h = harness();
render(SubjectPermissions, props);
await add();
await add("delegation", "filesystem.write");
expect(h.requests).toHaveLength(0);
expect(
screen.getByRole("region", { name: "Unsaved permission differences" })
.textContent,
).toContain("Edit files");
expect(
(screen.getByRole("button", {
name: "Save permissions",
}) as HTMLButtonElement).disabled,
).toBe(true);
await confirmSave();
await screen.findByText("Saved and re-read from the current policy.");
expect(h.requests).toHaveLength(1);
expect(h.requests[0].expected_change_id).toBe(null);
expect(h.requests[0].execution[0].operation).toBe("ticket.create");
expect(h.requests[0].delegation[0]).toEqual({
operation: "filesystem.write",
resource: {
kind: "workdir",
working_directory_id: "wd-only",
path: "src",
recursive: false,
},
});
expect(h.fetch.mock.calls[1][0]).toBe(
"/api/w/w/subjektiv/subjects/s/permissions",
);
});
test("unsupported legacy target is retained verbatim while an unrelated operation is added", async () => {
const legacy = {
operation: "worker.notify",
resource: {
kind: "worker" as const,
runtime_id: "r",
worker_id: "only-target",
},
};
const h = harness({ change_id: "old", execution: [], delegation: [legacy] });
render(SubjectPermissions, props);
await add();
await confirmSave();
await screen.findByText("Saved and re-read from the current policy.");
expect(h.requests[0].delegation).toEqual([legacy]);
expect(screen.getByText(/Preserved, not editable/)).toBeTruthy();
});
test("unimplemented Worker operation has reason, no Worker-ID fields and cannot be added", async () => {
harness();
render(SubjectPermissions, props);
await choose("worker.messaging");
expect(screen.getByText(/Target unavailable: T-803/)).toBeTruthy();
expect(screen.queryByRole("button", { name: "Add to unsaved changes" }))
.toBeNull();
expect(screen.queryByRole("textbox", { name: /Worker identifier/ }))
.toBeNull();
});
test("read-only projection retains current permission visibility without save or edit actions", async () => {
harness({
change_id: "p",
execution: [{
operation: "ticket.create",
resource: { kind: "workspace", workspace_id: "w" },
}],
delegation: [],
}, false);
render(SubjectPermissions, props);
await screen.findByText(/Read-only. The Backend/);
expect(screen.queryByRole("combobox")).toBeNull();
expect(screen.queryByRole("button", { name: /^Remove/ })).toBeNull();
expect(
within(screen.getByRole("region", { name: "Direct operations" })).getByText(
"Create Tickets",
),
).toBeTruthy();
});
test("conflict retains unsaved diff and blocks editing until explicit reload, never rebases CAS silently", async () => {
const h = harness();
h.fetch.mockImplementation(async (_path, init) =>
init?.method === "POST"
? new Response(null, { status: 409 })
: Response.json({ permissions: null, can_manage: true, operations: ops })
);
render(SubjectPermissions, props);
await add();
await confirmSave();
await screen.findByText(/Conflict: another save/);
expect(screen.getByRole("region", { name: "Unsaved permission differences" }))
.toBeTruthy();
expect(screen.queryByRole("button", { name: "Save permissions" })).toBeNull();
expect(sessionStorage.length).toBe(0);
});
test("lost response survives remount and exact replay confirms receipt without reviving later policy", async () => {
const h = harness();
let saved: PolicyUpdate | null = null;
const later: Policy = {
change_id: "later-shrink",
execution: [],
delegation: [],
};
let lost = false;
h.fetch.mockImplementation(async (_path, init) => {
if (init?.method === "POST") {
const request = JSON.parse(init.body as string) as PolicyUpdate;
if (!lost) {
saved = request;
lost = true;
throw new Error("lost after commit");
}
expect(request).toEqual(saved);
return Response.json({
permissions: {
change_id: request.operation_id,
execution: request.execution,
delegation: request.delegation,
},
interruptions: [],
});
}
return Response.json({
permissions: lost ? later : null,
can_manage: true,
operations: ops,
});
});
const first = render(SubjectPermissions, props);
await add();
await confirmSave();
await screen.findByText("Save outcome not yet confirmed.");
first.unmount();
render(SubjectPermissions, props);
await waitFor(() =>
expect(
(screen.getByRole("button", {
name: "Reconcile the same save",
}) as HTMLButtonElement).disabled,
).toBe(false)
);
await fireEvent.click(
screen.getByRole("button", { name: "Reconcile the same save" }),
);
await screen.findByText(/original save was confirmed, but a later change/);
expect(
within(screen.getByRole("region", { name: "Direct operations" }))
.queryByText("Create Tickets"),
).toBeNull();
expect(sessionStorage.length).toBe(0);
});
test("deleted Subject is refused with no success indication", async () => {
const h = harness();
h.fetch.mockImplementation(async () => new Response(null, { status: 404 }));
render(SubjectPermissions, props);
await screen.findByText(
/Target unavailable. This Subject may have been deleted/,
);
expect(screen.queryByRole("combobox")).toBeNull();
expect(h.requests).toHaveLength(0);
});
@@ -0,0 +1,183 @@
<script lang="ts">
import { onMount } from 'svelte';
import { attemptKey, fieldLabel, makeResource, PermissionHttpError, permissionKey, policyDiff, readAttempt, readPermissions, resourceFields, resourceLabel, writePermissions } from './permissions.ts';
import type { Interruption, Lane, PermissionResource, PermissionView, PolicyUpdate, ScopedPermission } from './permissions.ts';
let { workspaceId, subjectId }: { workspaceId: string; subjectId: string } = $props();
let view = $state<PermissionView | null>(null);
let execution = $state<ScopedPermission[]>([]);
let delegation = $state<ScopedPermission[]>([]);
let pending = $state<PolicyUpdate | null>(null);
let storageBlocked = $state(false);
let busy = $state(false);
let error = $state('');
let notice = $state('');
let confirming = $state(false);
let interruptions = $state<Interruption[]>([]);
let lane = $state<Lane>('execution');
let operationName = $state('');
let resourceKind = $state<PermissionResource['kind']>('workspace');
let inputs = $state<Record<string, string>>({});
let recursive = $state(false);
const selected = $derived(view?.operations.find((o) => o.operation === operationName));
const changes = $derived({ execution: policyDiff(view?.permissions?.execution ?? [], execution), delegation: policyDiff(view?.permissions?.delegation ?? [], delegation) });
const dirty = $derived(Object.values(changes).some((d) => d.added.length || d.removed.length));
const editable = $derived(view?.can_manage && !busy && !pending && !storageBlocked);
const label = (p: ScopedPermission) => view?.operations.find((o) => o.operation === p.operation)?.label ?? 'Preserved unsupported permission';
const supported = (p: ScopedPermission) => view?.operations.some((o) => o.operation === p.operation && o.available && o.resource_kinds.includes(p.resource.kind));
function accept(v: PermissionView) {
view = v;
execution = structuredClone(v.permissions?.execution ?? []);
delegation = structuredClone(v.permissions?.delegation ?? []);
confirming = false;
}
function describe(error: unknown) {
if (error instanceof PermissionHttpError) {
if (error.status === 403 || error.status === 401) return 'Read-only / permission unavailable. This account cannot manage this Subject.';
if (error.status === 404) return 'Target unavailable. This Subject may have been deleted; an old screen cannot restore it.';
if (error.status === 409) return 'Conflict: another save changed the policy. Your edits were not applied. Reload current permissions and review again.';
if (error.status === 400) return 'The operation or resource was rejected. Check the selected target; no new permissions were saved.';
}
return 'The request could not be confirmed. Do not assume success or create another save.';
}
async function reload() {
if (busy) return;
busy = true; error = ''; notice = '';
try { accept(await readPermissions(fetch, workspaceId, subjectId)); }
catch (e) { error = describe(e); }
finally { busy = false; }
}
onMount(() => {
try { pending = readAttempt(sessionStorage, workspaceId, subjectId); }
catch { storageBlocked = true; error = 'The saved request cannot be read. Saving is blocked; do not start another request.'; }
if (!storageBlocked) void reload();
});
function chooseOperation() {
resourceKind = selected?.resource_kinds[0] ?? 'workspace'; inputs = {}; recursive = false; confirming = false;
}
function add() {
if (!editable || !selected?.available || !selected.resource_kinds.includes(resourceKind)) return;
try {
const p = { operation: selected.operation, resource: makeResource(resourceKind, workspaceId, subjectId, inputs, recursive) };
const current = lane === 'execution' ? execution : delegation;
if (!current.some((x) => permissionKey(x) === permissionKey(p))) {
if (lane === 'execution') execution = [...execution, p]; else delegation = [...delegation, p];
}
confirming = false; error = ''; notice = '';
} catch (e) { error = e instanceof Error ? e.message : 'Invalid resource'; }
}
function remove(which: Lane, index: number) {
if (!editable) return;
if (which === 'execution') execution = execution.filter((_, i) => i !== index);
else delegation = delegation.filter((_, i) => i !== index);
confirming = false; notice = '';
}
async function save(request?: PolicyUpdate) {
if (busy || storageBlocked || !view?.can_manage) return;
const attempt = request ?? { operation_id: crypto.randomUUID(), expected_change_id: view.permissions?.change_id ?? null, execution: $state.snapshot(execution), delegation: $state.snapshot(delegation) };
busy = true; error = ''; notice = '';
try {
// 永続入力を保持できなければPOSTしない。再接続で新しいoperationを作らない。
sessionStorage.setItem(attemptKey(workspaceId, subjectId), JSON.stringify(attempt));
pending = attempt;
} catch { error = 'Cannot retain the save request in this tab. Nothing was sent.'; busy = false; return; }
let receiptAccepted = false;
try {
const receipt = await writePermissions(fetch, workspaceId, subjectId, attempt);
receiptAccepted = true;
const current = await readPermissions(fetch, workspaceId, subjectId);
accept(current); interruptions = receipt.interruptions;
sessionStorage.removeItem(attemptKey(workspaceId, subjectId)); pending = null;
notice = current.permissions?.change_id === receipt.permissions.change_id
? 'Saved and re-read from the current policy.'
: 'The original save was confirmed, but a later change is now current. The current policy is shown; the receipt did not restore older permissions.';
} catch (e) {
const definitive = !receiptAccepted && e instanceof PermissionHttpError && [400, 401, 403, 404, 409].includes(e.status);
if (definitive) {
sessionStorage.removeItem(attemptKey(workspaceId, subjectId)); pending = null;
// 競合後の新しい観測を元の下書きのCASへ黙って差し替えない。
view = view ? { ...view, can_manage: false } : null;
}
error = receiptAccepted ? 'The save receipt was accepted, but current policy could not be re-read. Reconcile the same request; do not treat its receipt as current authority.' : describe(e);
} finally { busy = false; }
}
</script>
<section class="permissions" aria-labelledby="permissions-heading" aria-busy={busy} data-permission-state={pending ? 'unknown' : error ? 'error' : dirty ? 'unsaved' : notice ? 'saved' : view ? 'ready' : 'loading'}>
<header><h2 id="permissions-heading">Operation permissions</h2><span class="state">{pending ? 'Outcome unknown' : dirty ? 'Unsaved changes' : notice ? 'Saved / current policy re-read' : view?.permissions ? 'Saved policy' : view ? 'Not configured · no permissions' : 'Loading…'}</span></header>
<p>Direct operations and the background Worker delegation ceiling are independent. Profile and Feature selection never grant permissions. Workdir/provider grants, target existence and assignment conditions still apply.</p>
{#if error}<p class="attention" role="alert">{error}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if}
{#if pending}
<div class="attention"><strong>Save outcome not yet confirmed.</strong><p>This tab retained the exact request. Reconcile it using the same operation and then re-read the current policy. Do not start another save.</p><button disabled={busy || !view?.can_manage} onclick={() => save(pending!)}>Reconcile the same save</button></div>
{/if}
{#if !view}
<button disabled={busy || storageBlocked} onclick={reload}>Read current permissions</button>
{:else}
{#if !view.can_manage}<p role="status">Read-only. The Backend must authorize permission management; hiding controls is not authorization.</p>{/if}
<div class="lanes">
{#each ['execution', 'delegation'] as which}
{@const permissions = which === 'execution' ? execution : delegation}
<section class="lane" aria-label={which === 'execution' ? 'Direct operations' : 'Background Worker delegation ceiling'}>
<h3>{which === 'execution' ? 'Direct operations' : 'Background Worker delegation ceiling'}</h3>
<p>{which === 'execution' ? 'Operations the Subject body may use, if its tools and current connections support them.' : 'Maximum operations and resources that may be explicitly passed to background Workers. This does not give the body work tools or start a Worker.'}</p>
{#if permissions.length === 0}<p class="empty">No permissions in this scope.</p>{/if}
<ul>{#each permissions as p, index}
<li><div><strong>{label(p)}</strong><span class="resource">{resourceLabel(p.resource)}</span>
{#if !supported(p)}<span class="legacy">Preserved, not editable here. No conversion or expansion is implied.</span><details><summary>Saved operation identifier</summary><code>{p.operation}</code></details>{/if}
</div>{#if editable}<button aria-label={`Remove ${label(p)} from ${which === 'execution' ? 'direct operations' : 'delegation ceiling'}`} onclick={() => remove(which as Lane, index)}>Remove</button>{/if}</li>
{/each}</ul>
</section>
{/each}
</div>
{#if view.can_manage}
<form onsubmit={(event) => { event.preventDefault(); add(); }} class="permission-form">
<h3>Add an explicit operation</h3>
<fieldset disabled={!editable}>
<label>Permission scope<select value={lane} onchange={(event) => { lane = event.currentTarget.value as Lane; }}><option value="execution">Direct operations</option><option value="delegation">Background Worker delegation ceiling</option></select></label>
<label>Operation<select value={operationName} onchange={(event) => { operationName = event.currentTarget.value; chooseOperation(); }}><option value="">Choose an operation</option>{#each view.operations as o}<option value={o.operation}>{o.label}{o.available ? '' : ' — unavailable'}</option>{/each}</select></label>
{#if selected}
<p>{selected.description}</p>
{#if !selected.available}<p class="attention">Target unavailable: {selected.unavailable_reason} No permission will be added.</p>
{:else}
<label>Resource type<select value={resourceKind} onchange={(event) => { resourceKind = event.currentTarget.value as PermissionResource['kind']; inputs = {}; recursive = false; }}>{#each selected.resource_kinds as kind}<option value={kind}>{kind.replaceAll('_', ' ')}</option>{/each}</select></label>
{#if resourceKind === 'workspace'}<p>Collection operations in this Workspace only; this does not cover individual resources.</p>{/if}
{#if resourceKind === 'feature'}<p>MCP is a temporary coarse activation exception. This is not per-server or per-tool authorization and does not grant ordinary Feature operations.</p>{/if}
{#each resourceFields(resourceKind) as field}<label>{fieldLabel[field]}<input bind:value={inputs[field]} placeholder={field === 'subject_id' ? subjectId : field === 'path' ? '.' : ''} required={field !== 'subject_id' && field !== 'path'} /></label>{/each}
{#if resourceKind === 'workdir' || resourceKind === 'workspace_config'}<label class="check"><input type="checkbox" bind:checked={recursive} />Include descendant paths</label>{/if}
<button type="submit">Add to unsaved changes</button>
{/if}
{/if}
</fieldset>
</form>
{/if}
<details class="help"><summary>Examples and enforcement boundaries</summary><p>For read-only work, add a read operation and its exact resource to the delegation ceiling. For limited editing, separately add the write operation for only the intended Workdir and relative path. Nothing else is implied; no wildcard or fixed all-powerful role is added.</p><p>Worker operations require both permission to use the operation and a directional Backend control grant for the actual target. Idle/CAS and lifecycle rules remain separate. This editor never asks for per-Worker notification/stop permissions or copies creation grants into the Subject policy. The T-803 connection is not yet available. Subject Ticket assignment migration (T-778) is not enabled by this screen.</p><p>Saving fences later operations using the current policy. Running work may receive cancellation requests; acceptance is not a confirmed stop. Already completed effects are not undone. Existing child grants are invalidated rather than silently refreshed.</p></details>
{#if dirty && !pending}
<section class="diff" aria-label="Unsaved permission differences"><h3>Review before saving</h3>
{#each ['execution', 'delegation'] as which}<h4>{which === 'execution' ? 'Direct operations' : 'Delegation ceiling'}</h4>
<ul>{#each changes[which as Lane].added as p}<li>+ Add: {label(p)} — {resourceLabel(p.resource)}</li>{/each}{#each changes[which as Lane].removed as p}<li>− Remove: {label(p)} — {resourceLabel(p.resource)}</li>{/each}</ul>
{/each}
{#if editable}<label class="check"><input type="checkbox" bind:checked={confirming} />I reviewed the operation, resource and delegation differences.</label><button class="save" disabled={!confirming || busy} onclick={() => save()}>Save permissions</button>{/if}
</section>
{/if}
<button class="reload" disabled={busy || !!pending || storageBlocked} onclick={reload}>{dirty ? 'Discard unsaved changes and reload current policy' : 'Reload current policy'}</button>
{#if interruptions.length}<section aria-label="Cancellation evidence"><h3>Cancellation requests, not stop confirmation</h3><ul>{#each interruptions as i}<li>Worker {i.worker_id}: cancellation {i.cancellation_accepted ? 'accepted' : 'not confirmed'}; Backend command cleanup {i.backend_command_sessions_closed ? 'confirmed' : 'not confirmed'}. Runtime stop remains unconfirmed.</li>{/each}</ul></section>{/if}
{/if}
</section>
<style>
.permissions { max-width: 72rem; min-width: 0; }
header { display: flex; flex-wrap: wrap; align-items: baseline; gap: .75rem; }
h2 { margin: 0; font-size: var(--font-size-body); } h3 { font-size: var(--font-size-body); margin: 0 0 .5rem; } h4 { margin: 1rem 0 .4rem; }
p { line-height: 1.55; color: var(--text-muted); } .state { font-size: var(--font-size-compact); }
.lanes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.5rem; margin: 1.5rem 0; }
.lane { min-width: 0; border-top: 1px solid var(--line); padding-top: 1rem; }
ul { list-style: none; padding: 0; margin: .75rem 0; } li { display: flex; align-items: start; gap: .75rem; justify-content: space-between; padding: .75rem 0; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
li > div { min-width: 0; } li button { flex-shrink: 0; white-space: nowrap; } .resource, .legacy { display: block; margin-top: .35rem; font-size: var(--font-size-body); } .legacy { color: var(--text-muted); }
.permission-form, .diff { border-top: 1px solid var(--line); margin-top: 1.5rem; padding-top: 1rem; }
fieldset { border: 0; padding: 0; min-width: 0; display: grid; gap: .75rem; max-width: 38rem; }
label { display: grid; gap: .35rem; font-size: var(--font-size-body); } input, select { width: 100%; min-width: 0; padding: .55rem; border: 1px solid var(--line); border-radius: 3px; background: var(--bg); color: var(--text); font: inherit; }
button { background: transparent; color: var(--text); border: 1px solid var(--line); padding: .45rem .7rem; border-radius: 3px; cursor: pointer; font: inherit; font-size: var(--font-size-body); } button:disabled { opacity: .5; cursor: default; } button.save { background: var(--text); color: var(--bg); margin-top: .75rem; }
.check { display: flex; align-items: start; gap: .5rem; } .check input { width: auto; margin-top: .2rem; } .reload { margin: 1rem 0; }
.attention { border-left: 3px solid var(--text-muted); padding: .7rem 1rem; overflow-wrap: anywhere; } .help { margin: 1.5rem 0; } summary { cursor: pointer; } code { overflow-wrap: anywhere; }
@media (max-width: 900px) { .lanes { grid-template-columns: 1fr; gap: 1rem; } }
</style>
@@ -0,0 +1,149 @@
import {
attemptKey,
makeResource,
parseView,
permissionKey,
policyDiff,
readAttempt,
readPermissions,
writePermissions,
} from "./permissions.ts";
import type { ScopedPermission } from "./permissions.ts";
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
function assert(condition: unknown): asserts condition {
if (!condition) throw new Error("Assertion failed");
}
const p: ScopedPermission = {
operation: "filesystem.read",
resource: {
kind: "workdir",
working_directory_id: "wd",
path: "src",
recursive: true,
},
};
Deno.test("permission differences preserve exact legacy resource and separate lanes", () => {
const legacy: ScopedPermission = {
operation: "worker.notify",
resource: {
kind: "worker",
runtime_id: "runtime",
worker_id: "only-worker",
},
};
const direct = [legacy, p];
assert(policyDiff([legacy], direct).added.length === 1);
assert(policyDiff([legacy], direct).removed.length === 0);
assert(
permissionKey(p) ===
permissionKey({
operation: p.operation,
resource: {
recursive: true,
path: "src",
working_directory_id: "wd",
kind: "workdir",
},
}),
);
assert(policyDiff([legacy], []).removed[0] === legacy);
});
Deno.test("typed resource input binds workspace, preserves narrow paths and rejects missing targets", () => {
assert(
JSON.stringify(
makeResource("workdir", "w", "s", {
working_directory_id: "wd",
path: "src",
}, false),
) ===
JSON.stringify({
kind: "workdir",
working_directory_id: "wd",
path: "src",
recursive: false,
}),
);
assert(
JSON.stringify(makeResource("subject", "w", "s", {}, false)) ===
JSON.stringify({ kind: "subject", workspace_id: "w", subject_id: "s" }),
);
let rejected = false;
try {
makeResource("ticket", "w", "s", {}, false);
} catch {
rejected = true;
}
assert(rejected);
});
Deno.test("catalog parsing refuses unknown resource types and malformed management permission", () => {
for (
const value of [
{ permissions: null, can_manage: "yes", operations: [] },
{
permissions: null,
can_manage: true,
operations: [{
operation: "unknown",
label: "Unknown",
description: "",
resource_kinds: ["everything"],
availability: { kind: "available" },
}],
},
]
) {
let rejected = false;
try {
parseView(value);
} catch {
rejected = true;
}
assert(rejected);
}
});
Deno.test("save retry keeps exact complete input scoped to Workspace and Subject", async () => {
const request = {
operation_id: "stable-operation",
expected_change_id: "old",
execution: [p],
delegation: [],
};
const memory = new Map([[attemptKey("w", "s"), JSON.stringify(request)]]);
const storage = { getItem: (key: string) => memory.get(key) ?? null };
assert(
JSON.stringify(readAttempt(storage, "w", "s")) === JSON.stringify(request),
);
assert(readAttempt(storage, "other", "s") === null);
assert(readAttempt(storage, "w", "other") === null);
const calls: { url: string; body: string }[] = [];
const fake = ((input: string, init?: RequestInit) => {
calls.push({ url: input, body: init?.body as string });
return Promise.resolve(
Response.json({
permissions: { change_id: "new", execution: [p], delegation: [] },
interruptions: [],
}),
);
}) as typeof fetch;
await writePermissions(fake, "w", "s", request);
await writePermissions(fake, "w", "s", readAttempt(storage, "w", "s")!);
assert(calls[0].url === "/api/w/w/subjektiv/subjects/s/permissions");
assert(calls[0].body === calls[1].body);
});
Deno.test("read permission projection uses the existing scoped endpoint and no fallback grant", async () => {
let path = "";
const fake = ((input: string) => {
path = input;
return Promise.resolve(
Response.json({ permissions: null, can_manage: false, operations: [] }),
);
}) as typeof fetch;
const view = await readPermissions(fake, "space", "subject");
assert(path === "/api/w/space/subjektiv/subjects/subject/permissions");
assert(
view.permissions === null && !view.can_manage &&
view.operations.length === 0,
);
});
@@ -0,0 +1,295 @@
import type {
PermissionOperationDescriptor,
PermissionResource,
ScopedPermission,
SubjectPermissionInterruption,
SubjectPermissions,
SubjectPermissionsMutationResponse,
SubjectPermissionsResponse,
SubjectPermissionsUpdate,
} from "#lib/generated/memory-api.ts";
import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
export type { PermissionResource, ScopedPermission };
export type Policy = SubjectPermissions;
export type PolicyUpdate = SubjectPermissionsUpdate;
export type Operation = Omit<PermissionOperationDescriptor, "availability"> & {
available: boolean;
unavailable_reason: string | null;
};
export type PermissionView = Omit<SubjectPermissionsResponse, "operations"> & {
operations: Operation[];
};
export type Interruption = SubjectPermissionInterruption;
export type Mutation = SubjectPermissionsMutationResponse;
export type Lane = "execution" | "delegation";
const MAX_BYTES = 1_048_576;
const fields = {
workspace: ["workspace_id"],
ticket: ["workspace_id", "ticket_id"],
objective: ["workspace_id", "objective_id"],
merge_request: ["workspace_id", "merge_request_id"],
subject: ["workspace_id", "subject_id"],
drive_entry: ["workspace_id", "node_id"],
drive_request: ["workspace_id", "request_id"],
repository: ["workspace_id", "repository_key"],
workdir_attachment: ["workspace_id", "connection_id"],
workspace_config: ["workspace_id", "path", "recursive"],
network: ["url"],
sub_worker: ["runtime_id", "worker_id", "name"],
feature: [],
workdir: ["working_directory_id", "path", "recursive"],
worker: ["runtime_id", "worker_id"],
job: ["workspace_id", "job_id"],
} satisfies Record<PermissionResource["kind"], string[]>;
function object(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Invalid permission response");
}
return value as Record<string, unknown>;
}
function text(value: unknown): string {
if (typeof value !== "string" || value.length > 16_384) {
throw new Error("Invalid permission text");
}
return value;
}
function bool(value: unknown): boolean {
if (typeof value !== "boolean") throw new Error("Invalid permission flag");
return value;
}
function list<T>(value: unknown, parse: (item: unknown) => T): T[] {
if (!Array.isArray(value) || value.length > 256) {
throw new Error("Invalid permission list");
}
return value.map(parse);
}
export function parseResource(value: unknown): PermissionResource {
const r = object(value);
const kind = text(r.kind) as PermissionResource["kind"];
if (!Object.hasOwn(fields, kind)) throw new Error("Unknown resource kind");
const keys: string[] = fields[kind];
if (
Object.keys(r).some((key) => key !== "kind" && !keys.includes(key)) ||
keys.some((key) => !(key in r))
) throw new Error("Invalid resource fields");
for (const key of keys) key === "recursive" ? bool(r[key]) : text(r[key]);
return r as PermissionResource;
}
export function parsePermission(value: unknown): ScopedPermission {
const p = object(value);
return { operation: text(p.operation), resource: parseResource(p.resource) };
}
export function parsePolicy(value: unknown): Policy {
const p = object(value);
return {
change_id: text(p.change_id),
execution: list(p.execution, parsePermission),
delegation: list(p.delegation, parsePermission),
};
}
export function parseView(value: unknown): PermissionView {
const v = object(value);
return {
permissions: v.permissions === null ? null : parsePolicy(v.permissions),
can_manage: bool(v.can_manage),
operations: list(v.operations, (value) => {
const o = object(value);
const availability = object(o.availability);
if (
availability.kind !== "available" && availability.kind !== "unavailable"
) throw new Error("Unknown operation availability");
return {
operation: text(o.operation),
label: text(o.label),
description: text(o.description),
resource_kinds: list(o.resource_kinds, (value) => {
const kind = text(value) as PermissionResource["kind"];
if (!Object.hasOwn(fields, kind)) {
throw new Error("Unknown resource kind");
}
return kind;
}),
available: availability.kind === "available",
unavailable_reason: availability.kind === "unavailable"
? text(availability.reason)
: null,
};
}),
};
}
export class PermissionHttpError extends Error {
constructor(public status: number) {
super(`Permission request rejected (HTTP ${status}).`);
}
}
const path = (workspaceId: string, subjectId: string) =>
workspaceApiPath(
workspaceId,
`/subjektiv/subjects/${encodeURIComponent(subjectId)}/permissions`,
);
export async function readPermissions(
fetchFn: typeof fetch,
workspaceId: string,
subjectId: string,
): Promise<PermissionView> {
const response = await fetchFn(path(workspaceId, subjectId));
if (!response.ok) {
await response.body?.cancel();
throw new PermissionHttpError(response.status);
}
return parseView(await readBoundedJson(response, MAX_BYTES));
}
export async function writePermissions(
fetchFn: typeof fetch,
workspaceId: string,
subjectId: string,
request: PolicyUpdate,
): Promise<Mutation> {
const response = await fetchFn(path(workspaceId, subjectId), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
});
if (!response.ok) {
await response.body?.cancel();
throw new PermissionHttpError(response.status);
}
const v = object(await readBoundedJson(response, MAX_BYTES));
return {
permissions: parsePolicy(v.permissions),
interruptions: list(v.interruptions, (value) => {
const i = object(value);
return {
runtime_id: text(i.runtime_id),
worker_id: text(i.worker_id),
cancellation_accepted: bool(i.cancellation_accepted),
backend_command_sessions_closed: bool(
i.backend_command_sessions_closed,
),
diagnostics: list(i.diagnostics, text),
};
}),
};
}
export function permissionKey(permission: ScopedPermission): string {
return JSON.stringify([
permission.operation,
Object.entries(permission.resource).sort(([a], [b]) => a.localeCompare(b)),
]);
}
export function policyDiff(
before: ScopedPermission[],
after: ScopedPermission[],
) {
return {
added: after.filter((p) =>
!before.some((b) => permissionKey(b) === permissionKey(p))
),
removed: before.filter((p) =>
!after.some((a) => permissionKey(a) === permissionKey(p))
),
};
}
export function resourceLabel(resource: PermissionResource): string {
const labels: Record<string, string> = {
workspace: "Current workspace",
subject: "Subject",
ticket: "Ticket",
objective: "Objective",
merge_request: "Merge request",
workdir: "Workdir",
workspace_config: "Workspace configuration",
network: "URL",
drive_entry: "Drive entry",
drive_request: "Drive request",
repository: "Repository",
workdir_attachment: "Workdir connection",
feature: "MCP activation / legacy feature",
worker: "Legacy Worker target",
sub_worker: "Internal SubWorker",
job: "Job",
};
const identifiers = Object.entries(resource).filter(([key]) =>
!["kind", "workspace_id", "recursive"].includes(key)
).map(([_, value]) => String(value));
return `${labels[resource.kind]}${
identifiers.length ? ` · ${identifiers.join(" / ")}` : ""
}${
"recursive" in resource && resource.recursive
? " (including descendants)"
: ""
}`;
}
// 保存値の正本ではなく、同じ操作の完全一致再送に必要な入力だけをtab内に保持する。
export function attemptKey(workspaceId: string, subjectId: string): string {
return `yoi.subject-permissions:${JSON.stringify([workspaceId, subjectId])}`;
}
export function readAttempt(
storage: Pick<Storage, "getItem">,
workspaceId: string,
subjectId: string,
): PolicyUpdate | null {
const raw = storage.getItem(attemptKey(workspaceId, subjectId));
if (raw === null) return null;
if (raw.length > MAX_BYTES) {
throw new Error(
"Saved permission request is unreadable. Do not start another save.",
);
}
const v = object(JSON.parse(raw));
return {
operation_id: text(v.operation_id),
expected_change_id: v.expected_change_id === null
? null
: text(v.expected_change_id),
execution: list(v.execution, parsePermission),
delegation: list(v.delegation, parsePermission),
};
}
export function makeResource(
kind: PermissionResource["kind"],
workspaceId: string,
subjectId: string,
inputs: Record<string, string>,
recursive: boolean,
): PermissionResource {
const r: Record<string, unknown> = { kind };
for (const key of fields[kind]) {
r[key] = key === "workspace_id"
? workspaceId
: key === "recursive"
? recursive
: key === "subject_id"
? (inputs[key]?.trim() || subjectId)
: inputs[key]?.trim() ?? "";
if (
typeof r[key] === "string" && !(r[key] as string).length && key !== "path"
) throw new Error("Enter the required target resource.");
}
if (kind === "workdir" && !r.path) r.path = ".";
return parseResource(r);
}
export function resourceFields(kind: PermissionResource["kind"]): string[] {
return fields[kind].filter((key) =>
!["workspace_id", "recursive"].includes(key)
);
}
export const fieldLabel: Record<string, string> = {
ticket_id: "Ticket identifier",
objective_id: "Objective identifier",
merge_request_id: "Merge request identifier",
subject_id: "Subject identifier (empty means this Subject)",
node_id: "Drive entry number",
request_id: "Drive request identifier",
repository_key: "Repository key",
connection_id: "Workdir connection identifier",
path: "Relative path",
url: "Exact HTTP(S) URL",
working_directory_id: "Workdir identifier",
job_id: "Job identifier",
runtime_id: "Runtime identifier",
worker_id: "Worker identifier",
name: "Internal SubWorker name",
};
@@ -180,7 +180,7 @@
{/if}
{#if !deletionStarted}
<nav aria-label="Subjectの記録"><a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/journal`)}>Journal · 最近の出来事とSessionを探す →</a></nav>
<nav aria-label="Subjectの記録と管理"><a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/journal`)}>Journal · 最近の出来事とSessionを探す →</a> · <a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/permissions`)}>Operation permissions · 直接操作と委譲上限 →</a></nav>
{#if subject}
<section class="subject-overview" aria-label="Subject status">
@@ -0,0 +1,9 @@
<script lang="ts">
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
import PageContent from './PageContent.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<DeferredPage {data} title="Subject permissions">
{#snippet children(resolved)}<PageContent data={resolved} />{/snippet}
</DeferredPage>
@@ -0,0 +1,24 @@
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import {
MEMORY_API_LOAD_POLICY,
parseSubjektivSubjectResponse,
} from "#lib/workspace/memory/api.ts";
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
import type { PageLoad } from "./$types";
export const load = deferPage(async (
{ fetch, params }: Parameters<PageLoad>[0],
) => ({
workspaceId: params.workspaceId,
subjectId: params.subjectId,
subject: await loadJson(
fetch,
workspaceApiPath(
params.workspaceId,
`/subjektiv/subjects/${encodeURIComponent(params.subjectId)}`,
),
undefined,
(value) => parseSubjektivSubjectResponse(value, params.subjectId),
MEMORY_API_LOAD_POLICY,
),
}));
@@ -0,0 +1,16 @@
<script lang="ts">
import SubjectPermissions from '#lib/workspace/memory/SubjectPermissions.svelte';
import { workspaceRoute } from '#lib/workspace/api/http.ts';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
import type { PageData } from './$types';
let { data }: { data: ResolvedPageData<PageData> } = $props();
</script>
<svelte:head><title>Permissions · {data.subject.data?.role ?? 'Subject'} · Yoi Workspace</title></svelte:head>
<section class="permission-page">
<a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}`)}>← Subject details</a>
<h1>{data.subject.data?.role ?? 'Subject'} permissions</h1>
{#if data.subject.data}
{#key JSON.stringify([data.workspaceId, data.subjectId])}<SubjectPermissions workspaceId={data.workspaceId} subjectId={data.subjectId} />{/key}
{:else}<p role="alert">Target unavailable. {data.subject.error} An old page cannot recreate a deleted Subject.</p>{/if}
</section>
<style>.permission-page { padding: 1.5rem; min-width: 0; } h1 { font-size: var(--font-size-title); margin: 1rem 0 1.5rem; overflow-wrap: anywhere; } @media (max-width: 600px) { .permission-page { padding: 1rem; } }</style>
@@ -78,6 +78,7 @@ function subject() {
function subjectIndexData(items = [subject()]) {
return {
workspace: { permissions: { delete_subjects: false } },
workspaceId: "workspace-1",
cursor: null,
subjects: result({
@@ -91,6 +92,7 @@ function subjectIndexData(items = [subject()]) {
test("renders the subject index as the Memory product entry", () => {
render(SubjectIndexPage, {
data: {
workspace: { permissions: { delete_subjects: false } },
workspaceId: "workspace-1",
cursor: "subject-current",
subjects: result({
@@ -273,6 +275,7 @@ test("warns about an unknown create outcome without replaying the POST", async (
test("renders a ready resident surface and committed Memory lifecycle states", async () => {
render(SubjectPage, {
data: {
workspace: { permissions: { delete_subjects: false } },
workspaceId: "workspace-1",
workspace: memoryWorkspace(),
subjectId: "subject-1",
@@ -368,6 +371,7 @@ test("edits and clears user-managed behavior with CAS while distinguishing save
vi.stubGlobal("fetch", fetchMock);
render(SubjectPage, {
data: {
workspace: { permissions: { delete_subjects: false } },
workspaceId: "workspace-1",
workspace: memoryWorkspace(),
subjectId: "subject-1",
@@ -440,6 +444,7 @@ test.each([false, true])(
function subjectPageData(record = subject(), workspaceId = "workspace-1") {
return {
workspace: { permissions: { delete_subjects: false } },
workspaceId,
workspace: memoryWorkspace(workspaceId),
subjectId: record.id,
@@ -773,6 +778,7 @@ test("candidate-only Subjects show no committed Memories, not an inferred candid
// the same empty committed list as a new Subject; candidates are not a page input.
render(SubjectPage, {
data: {
workspace: { permissions: { delete_subjects: false } },
workspaceId: "workspace-1",
workspace: memoryWorkspace(),
subjectId: "subject-1",
@@ -857,6 +863,7 @@ test.each(["ready", "ungenerated", "stale", "failed"] as const)(
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
const base = {
workspace: { permissions: { delete_subjects: false } },
workspaceId: "workspace-1",
workspace: memoryWorkspace(),
subjectId: "subject-1",
@@ -943,6 +950,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
test("renders committed Memory detail, provenance, derivation, and immutable changes", async () => {
render(MemoryDetailPage, {
data: {
workspace: { permissions: { delete_subjects: false } },
workspaceId: "workspace-1",
workspace: memoryWorkspace(),
subjectId: "subject-1",