merge: profile launch policy scope
This commit is contained in:
commit
2eaaac97f5
|
|
@ -0,0 +1,48 @@
|
||||||
|
実装報告(Coder)
|
||||||
|
|
||||||
|
Commit:
|
||||||
|
- implementation: `21bf009a3f95978007468005982903c8d7cae9e7` (`feat: move profile scope to launch policy`)
|
||||||
|
|
||||||
|
変更ファイル:
|
||||||
|
- `resources/profiles/default.lua`
|
||||||
|
- `resources/profiles/companion.lua`
|
||||||
|
- `resources/profiles/intake.lua`
|
||||||
|
- `resources/profiles/orchestrator.lua`
|
||||||
|
- `resources/profiles/coder.lua`
|
||||||
|
- `resources/profiles/reviewer.lua`
|
||||||
|
- `crates/manifest/src/profile.rs`
|
||||||
|
- `crates/manifest/src/config.rs`
|
||||||
|
- `crates/pod/src/entrypoint.rs`
|
||||||
|
- `crates/pod/src/spawn/tool.rs`
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- Builtin reusable Profiles から concrete filesystem `scope` / `delegation_scope` を削除した。
|
||||||
|
- Profile resolution の implicit default workspace-write scope を廃止し、Profiles without scope を empty scope として解決可能にした。
|
||||||
|
- Fresh profile launch の effective authority を `pod` entrypoint の launch policy で付与するようにした。
|
||||||
|
- normal Companion/TUI/default profile launch: workspace write direct scope(`.worktree` write deny)+ workspace read / `.worktree` write delegation。
|
||||||
|
- Ticket Orchestrator role launch: original workspace read direct scope + original workspace read / `<workspace>/.worktree` write delegation。root workspace write delegation は付与しない。
|
||||||
|
- Ticket Intake/Reviewer role launch: workspace read direct scope。
|
||||||
|
- Ticket Coder role launch: workspace write direct scope。
|
||||||
|
- Single-file `--manifest` mode は従来どおり concrete `scope.allow` を要求し、Profile launch policy と混同しないようにした。
|
||||||
|
- SpawnPod child scope replacement path は維持し、narrow Orchestrator delegation で Coder/Reviewer 用の root-read + implementation-worktree-write validation が成立することをテストした。
|
||||||
|
- Existing user Profile `scope` / `delegation_scope` compatibility は deprecated-compatible path として残した(Ticket の選択肢に沿って、builtin role launch authority は Profile scope に依存しない)。
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cargo test -p manifest` ✅
|
||||||
|
- Focused tests ✅
|
||||||
|
- `cargo test -p manifest builtin_role_profiles_are_registered_and_resolve --quiet`
|
||||||
|
- `cargo test -p manifest builtin_default_resolves_without_external_evaluator --quiet`
|
||||||
|
- `cargo test -p manifest resolve_accepts_empty_scope_for_profile_launch_policy --quiet`
|
||||||
|
- `cargo test -p pod --lib entrypoint::tests::normal_startup_uses_default_profile --quiet`
|
||||||
|
- `cargo test -p pod --lib entrypoint::tests::orchestrator_profile_launch_gets_read_root_and_worktree_delegation_from_launch_policy --quiet`
|
||||||
|
- `cargo test -p pod --lib spawn::tool::tests::orchestration_delegation_allows_root_read_and_worktree_writes_not_root_writes --quiet`
|
||||||
|
- `cargo test -p pod --lib pod::pod_metadata_restore_manifest_tests::snapshot_preserves_saved_scope_over_current_manifest --quiet`
|
||||||
|
- `cargo test -p client ticket_role --quiet`
|
||||||
|
- `cargo build -p yoi` ✅
|
||||||
|
- `cargo fmt --check` ✅
|
||||||
|
- `git diff --check` ✅
|
||||||
|
- `nix build .#yoi` not run: no Cargo.lock, packaging, or resource inclusion pattern changed.
|
||||||
|
|
||||||
|
Residual risks / notes:
|
||||||
|
- Full `cargo test -p pod --lib` was attempted and still has two prompt-text assertion failures (`worktree status, diff, and test results`) in prompt catalog/system tests; this diff did not touch prompt resources or those assertions. Focused scope/profile/spawn/restore tests passed.
|
||||||
|
- User Profile `scope` compatibility remains supported for now; future schema cleanup can remove or deprecate it explicitly if desired.
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
Approve implementation review for Ticket 00001KV11DHGZ.
|
||||||
|
|
||||||
|
Scope reviewed: implementation commit 21bf009a plus ticket report commit 77892b94 against base cdb12af9.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- Builtin role profile resources no longer contain `scope` or `delegation_scope`; reusable profile data retains role/model/prompt/feature/tool policy only.
|
||||||
|
- Fresh profile launch scope is applied in `crates/pod/src/entrypoint.rs` by launch policy after profile resolution. Default/Companion launches receive direct workspace write scope with `.worktree` write denied and delegation gets workspace read plus `.worktree` write. Orchestrator ticket-role launches receive direct root read and delegation root read plus `.worktree` write, with no root workspace write delegation.
|
||||||
|
- `SpawnPod` profile/inherit handling continues to replace child direct scope with the explicit delegated child scope and resets child delegation unless explicitly provided; profile/default scope does not leak into child direct authority.
|
||||||
|
- Pod metadata restore uses saved manifest snapshots when present, so saved scope/delegation are preserved instead of being overwritten by current profile/default launch policy.
|
||||||
|
- One-file manifest loading still rejects missing/empty concrete `scope.allow`; the retained user-profile scope compatibility path is separated from builtin role authority and is overwritten by launch/delegation policy on fresh role launches.
|
||||||
|
|
||||||
|
Validation performed:
|
||||||
|
- `cargo test -p manifest --quiet`
|
||||||
|
- Focused pod tests for normal startup launch policy, orchestrator launch policy, SpawnPod delegation scoping, and metadata snapshot restore.
|
||||||
|
- `cargo test -p client ticket_role --quiet`
|
||||||
|
- `cargo build -p yoi`
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `git diff --check cdb12af9..HEAD`
|
||||||
|
|
||||||
|
Result: approve. No blocking requirement or design-boundary concern found.
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
title: 'Profile から concrete scope を外して launch policy で付与する'
|
title: 'Profile から concrete scope を外して launch policy で付与する'
|
||||||
state: 'inprogress'
|
state: 'inprogress'
|
||||||
created_at: '2026-06-13T17:45:32Z'
|
created_at: '2026-06-13T17:45:32Z'
|
||||||
updated_at: '2026-06-14T06:35:44Z'
|
updated_at: '2026-06-14T07:00:13Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
readiness: 'implementation_ready'
|
readiness: 'implementation_ready'
|
||||||
risk_flags: ['scope', 'delegation-scope', 'profiles', 'launch-policy', 'orchestrator', 'spawnpod', 'restore']
|
risk_flags: ['scope', 'delegation-scope', 'profiles', 'launch-policy', 'orchestrator', 'spawnpod', 'restore']
|
||||||
|
|
|
||||||
|
|
@ -82,4 +82,88 @@ Validation:
|
||||||
|
|
||||||
Profile extend removal prerequisite/conflict has been resolved and merged. Ticket evidence, related records, orchestration plan, and clean workspace state were checked. No blockers remain; implementation can proceed in a dedicated child worktree.
|
Profile extend removal prerequisite/conflict has been resolved and merged. Ticket evidence, related records, orchestration plan, and clean workspace state were checked. No blockers remain; implementation can proceed in a dedicated child worktree.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-06-14T06:53:30Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
実装報告(Coder)
|
||||||
|
|
||||||
|
Commit:
|
||||||
|
- implementation: `21bf009a3f95978007468005982903c8d7cae9e7` (`feat: move profile scope to launch policy`)
|
||||||
|
|
||||||
|
変更ファイル:
|
||||||
|
- `resources/profiles/default.lua`
|
||||||
|
- `resources/profiles/companion.lua`
|
||||||
|
- `resources/profiles/intake.lua`
|
||||||
|
- `resources/profiles/orchestrator.lua`
|
||||||
|
- `resources/profiles/coder.lua`
|
||||||
|
- `resources/profiles/reviewer.lua`
|
||||||
|
- `crates/manifest/src/profile.rs`
|
||||||
|
- `crates/manifest/src/config.rs`
|
||||||
|
- `crates/pod/src/entrypoint.rs`
|
||||||
|
- `crates/pod/src/spawn/tool.rs`
|
||||||
|
|
||||||
|
実装内容:
|
||||||
|
- Builtin reusable Profiles から concrete filesystem `scope` / `delegation_scope` を削除した。
|
||||||
|
- Profile resolution の implicit default workspace-write scope を廃止し、Profiles without scope を empty scope として解決可能にした。
|
||||||
|
- Fresh profile launch の effective authority を `pod` entrypoint の launch policy で付与するようにした。
|
||||||
|
- normal Companion/TUI/default profile launch: workspace write direct scope(`.worktree` write deny)+ workspace read / `.worktree` write delegation。
|
||||||
|
- Ticket Orchestrator role launch: original workspace read direct scope + original workspace read / `<workspace>/.worktree` write delegation。root workspace write delegation は付与しない。
|
||||||
|
- Ticket Intake/Reviewer role launch: workspace read direct scope。
|
||||||
|
- Ticket Coder role launch: workspace write direct scope。
|
||||||
|
- Single-file `--manifest` mode は従来どおり concrete `scope.allow` を要求し、Profile launch policy と混同しないようにした。
|
||||||
|
- SpawnPod child scope replacement path は維持し、narrow Orchestrator delegation で Coder/Reviewer 用の root-read + implementation-worktree-write validation が成立することをテストした。
|
||||||
|
- Existing user Profile `scope` / `delegation_scope` compatibility は deprecated-compatible path として残した(Ticket の選択肢に沿って、builtin role launch authority は Profile scope に依存しない)。
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cargo test -p manifest` ✅
|
||||||
|
- Focused tests ✅
|
||||||
|
- `cargo test -p manifest builtin_role_profiles_are_registered_and_resolve --quiet`
|
||||||
|
- `cargo test -p manifest builtin_default_resolves_without_external_evaluator --quiet`
|
||||||
|
- `cargo test -p manifest resolve_accepts_empty_scope_for_profile_launch_policy --quiet`
|
||||||
|
- `cargo test -p pod --lib entrypoint::tests::normal_startup_uses_default_profile --quiet`
|
||||||
|
- `cargo test -p pod --lib entrypoint::tests::orchestrator_profile_launch_gets_read_root_and_worktree_delegation_from_launch_policy --quiet`
|
||||||
|
- `cargo test -p pod --lib spawn::tool::tests::orchestration_delegation_allows_root_read_and_worktree_writes_not_root_writes --quiet`
|
||||||
|
- `cargo test -p pod --lib pod::pod_metadata_restore_manifest_tests::snapshot_preserves_saved_scope_over_current_manifest --quiet`
|
||||||
|
- `cargo test -p client ticket_role --quiet`
|
||||||
|
- `cargo build -p yoi` ✅
|
||||||
|
- `cargo fmt --check` ✅
|
||||||
|
- `git diff --check` ✅
|
||||||
|
- `nix build .#yoi` not run: no Cargo.lock, packaging, or resource inclusion pattern changed.
|
||||||
|
|
||||||
|
Residual risks / notes:
|
||||||
|
- Full `cargo test -p pod --lib` was attempted and still has two prompt-text assertion failures (`worktree status, diff, and test results`) in prompt catalog/system tests; this diff did not touch prompt resources or those assertions. Focused scope/profile/spawn/restore tests passed.
|
||||||
|
- User Profile `scope` compatibility remains supported for now; future schema cleanup can remove or deprecate it explicitly if desired.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: review author: hare at: 2026-06-14T07:00:13Z status: approve -->
|
||||||
|
|
||||||
|
## Review: approve
|
||||||
|
|
||||||
|
Approve implementation review for Ticket 00001KV11DHGZ.
|
||||||
|
|
||||||
|
Scope reviewed: implementation commit 21bf009a plus ticket report commit 77892b94 against base cdb12af9.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- Builtin role profile resources no longer contain `scope` or `delegation_scope`; reusable profile data retains role/model/prompt/feature/tool policy only.
|
||||||
|
- Fresh profile launch scope is applied in `crates/pod/src/entrypoint.rs` by launch policy after profile resolution. Default/Companion launches receive direct workspace write scope with `.worktree` write denied and delegation gets workspace read plus `.worktree` write. Orchestrator ticket-role launches receive direct root read and delegation root read plus `.worktree` write, with no root workspace write delegation.
|
||||||
|
- `SpawnPod` profile/inherit handling continues to replace child direct scope with the explicit delegated child scope and resets child delegation unless explicitly provided; profile/default scope does not leak into child direct authority.
|
||||||
|
- Pod metadata restore uses saved manifest snapshots when present, so saved scope/delegation are preserved instead of being overwritten by current profile/default launch policy.
|
||||||
|
- One-file manifest loading still rejects missing/empty concrete `scope.allow`; the retained user-profile scope compatibility path is separated from builtin role authority and is overwritten by launch/delegation policy on fresh role launches.
|
||||||
|
|
||||||
|
Validation performed:
|
||||||
|
- `cargo test -p manifest --quiet`
|
||||||
|
- Focused pod tests for normal startup launch policy, orchestrator launch policy, SpawnPod delegation scoping, and metadata snapshot restore.
|
||||||
|
- `cargo test -p client ticket_role --quiet`
|
||||||
|
- `cargo build -p yoi`
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `git diff --check cdb12af9..HEAD`
|
||||||
|
|
||||||
|
Result: approve. No blocking requirement or design-boundary concern found.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -729,9 +729,6 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if cfg.scope.allow.is_empty() {
|
|
||||||
return Err(ResolveError::MissingField("scope.allow"));
|
|
||||||
}
|
|
||||||
for rule in &cfg.scope.allow {
|
for rule in &cfg.scope.allow {
|
||||||
ensure_absolute("scope.allow.target", &rule.target)?;
|
ensure_absolute("scope.allow.target", &rule.target)?;
|
||||||
}
|
}
|
||||||
|
|
@ -1028,11 +1025,11 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_rejects_empty_scope() {
|
fn resolve_accepts_empty_scope_for_profile_launch_policy() {
|
||||||
let mut cfg = minimal_valid();
|
let mut cfg = minimal_valid();
|
||||||
cfg.scope.allow.clear();
|
cfg.scope.allow.clear();
|
||||||
let err = PodManifest::try_from(cfg).unwrap_err();
|
let manifest = PodManifest::try_from(cfg).unwrap();
|
||||||
assert!(matches!(err, ResolveError::MissingField("scope.allow")));
|
assert!(manifest.scope.allow.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1262,12 +1262,7 @@ fn profile_scope_to_config(
|
||||||
scope: Option<ProfileScopeConfig>,
|
scope: Option<ProfileScopeConfig>,
|
||||||
workspace_base: &Path,
|
workspace_base: &Path,
|
||||||
) -> Result<ScopeConfig, ProfileError> {
|
) -> Result<ScopeConfig, ProfileError> {
|
||||||
profile_scope_intent_to_config(
|
profile_scope_intent_to_config(scope, workspace_base, None, "scope")
|
||||||
scope,
|
|
||||||
workspace_base,
|
|
||||||
Some(ProfileScopeIntent::WorkspaceWrite),
|
|
||||||
"scope",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn profile_delegation_scope_to_config(
|
fn profile_delegation_scope_to_config(
|
||||||
|
|
@ -1594,10 +1589,9 @@ mod tests {
|
||||||
assert!(companion.feature.task.enabled);
|
assert!(companion.feature.task.enabled);
|
||||||
assert!(companion.feature.pods.enabled);
|
assert!(companion.feature.pods.enabled);
|
||||||
assert!(companion.feature.ticket.enabled);
|
assert!(companion.feature.ticket.enabled);
|
||||||
assert_eq!(companion.scope.allow[0].permission, Permission::Write);
|
assert!(companion.scope.allow.is_empty());
|
||||||
assert_eq!(companion.scope.deny.len(), 1);
|
assert!(companion.scope.deny.is_empty());
|
||||||
assert_eq!(companion.scope.deny[0].permission, Permission::Write);
|
assert!(companion.delegation_scope.allow.is_empty());
|
||||||
assert_eq!(companion.scope.deny[0].target, tmp.path().join(".worktree"));
|
|
||||||
assert_eq!(companion.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
assert_eq!(companion.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
||||||
assert!(companion.web.is_some());
|
assert!(companion.web.is_some());
|
||||||
|
|
||||||
|
|
@ -1605,7 +1599,8 @@ mod tests {
|
||||||
assert!(!intake.feature.task.enabled);
|
assert!(!intake.feature.task.enabled);
|
||||||
assert!(!intake.feature.pods.enabled);
|
assert!(!intake.feature.pods.enabled);
|
||||||
assert!(intake.feature.ticket.enabled);
|
assert!(intake.feature.ticket.enabled);
|
||||||
assert_eq!(intake.scope.allow[0].permission, Permission::Read);
|
assert!(intake.scope.allow.is_empty());
|
||||||
|
assert!(intake.delegation_scope.allow.is_empty());
|
||||||
assert_eq!(intake.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
assert_eq!(intake.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
||||||
assert!(intake.web.is_some());
|
assert!(intake.web.is_some());
|
||||||
assert!(!intake.feature.ticket_orchestration.enabled);
|
assert!(!intake.feature.ticket_orchestration.enabled);
|
||||||
|
|
@ -1615,21 +1610,19 @@ mod tests {
|
||||||
assert!(orchestrator.feature.pods.enabled);
|
assert!(orchestrator.feature.pods.enabled);
|
||||||
assert!(orchestrator.feature.ticket.enabled);
|
assert!(orchestrator.feature.ticket.enabled);
|
||||||
assert!(orchestrator.feature.ticket_orchestration.enabled);
|
assert!(orchestrator.feature.ticket_orchestration.enabled);
|
||||||
assert_eq!(orchestrator.scope.allow[0].permission, Permission::Read);
|
assert!(orchestrator.scope.allow.is_empty());
|
||||||
|
assert!(orchestrator.delegation_scope.allow.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
orchestrator.model.ref_.as_deref(),
|
orchestrator.model.ref_.as_deref(),
|
||||||
Some("codex-oauth/gpt-5.5")
|
Some("codex-oauth/gpt-5.5")
|
||||||
);
|
);
|
||||||
assert!(orchestrator.web.is_some());
|
assert!(orchestrator.web.is_some());
|
||||||
assert_eq!(
|
|
||||||
orchestrator.delegation_scope.allow[0].permission,
|
|
||||||
Permission::Write
|
|
||||||
);
|
|
||||||
|
|
||||||
let coder = resolve("coder");
|
let coder = resolve("coder");
|
||||||
assert!(coder.feature.task.enabled);
|
assert!(coder.feature.task.enabled);
|
||||||
assert!(!coder.feature.pods.enabled);
|
assert!(!coder.feature.pods.enabled);
|
||||||
assert_eq!(coder.scope.allow[0].permission, Permission::Write);
|
assert!(coder.scope.allow.is_empty());
|
||||||
|
assert!(coder.delegation_scope.allow.is_empty());
|
||||||
assert_eq!(coder.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
assert_eq!(coder.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
||||||
assert!(coder.web.is_some());
|
assert!(coder.web.is_some());
|
||||||
|
|
||||||
|
|
@ -1637,7 +1630,8 @@ mod tests {
|
||||||
assert!(!reviewer.feature.task.enabled);
|
assert!(!reviewer.feature.task.enabled);
|
||||||
assert!(!reviewer.feature.pods.enabled);
|
assert!(!reviewer.feature.pods.enabled);
|
||||||
assert!(!reviewer.feature.ticket.enabled);
|
assert!(!reviewer.feature.ticket.enabled);
|
||||||
assert_eq!(reviewer.scope.allow[0].permission, Permission::Read);
|
assert!(reviewer.scope.allow.is_empty());
|
||||||
|
assert!(reviewer.delegation_scope.allow.is_empty());
|
||||||
assert_eq!(reviewer.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
assert_eq!(reviewer.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
||||||
assert!(reviewer.web.is_some());
|
assert!(reviewer.web.is_some());
|
||||||
}
|
}
|
||||||
|
|
@ -1959,11 +1953,8 @@ return profile {
|
||||||
resolved.manifest.model.ref_.as_deref(),
|
resolved.manifest.model.ref_.as_deref(),
|
||||||
Some("codex-oauth/gpt-5.5")
|
Some("codex-oauth/gpt-5.5")
|
||||||
);
|
);
|
||||||
assert_eq!(resolved.manifest.scope.allow[0].target, tmp.path());
|
assert!(resolved.manifest.scope.allow.is_empty());
|
||||||
assert_eq!(
|
assert!(resolved.manifest.delegation_scope.allow.is_empty());
|
||||||
resolved.manifest.scope.allow[0].permission,
|
|
||||||
Permission::Write
|
|
||||||
);
|
|
||||||
assert!(resolved.manifest.session.record_event_trace);
|
assert!(resolved.manifest.session.record_event_trace);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolved.profile.as_ref().unwrap().name.as_deref(),
|
resolved.profile.as_ref().unwrap().name.as_deref(),
|
||||||
|
|
@ -2016,7 +2007,7 @@ record_event_trace = false
|
||||||
resolved.manifest.pod.prompt_pack.as_deref(),
|
resolved.manifest.pod.prompt_pack.as_deref(),
|
||||||
Some(yoi_dir.join("prompts.toml").as_path())
|
Some(yoi_dir.join("prompts.toml").as_path())
|
||||||
);
|
);
|
||||||
assert_eq!(resolved.manifest.scope.allow[0].target, nested);
|
assert!(resolved.manifest.scope.allow.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolved
|
resolved
|
||||||
.manifest
|
.manifest
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ use std::process::ExitCode;
|
||||||
use crate::{Pod, PodController, PromptLoader};
|
use crate::{Pod, PodController, PromptLoader};
|
||||||
use clap::{CommandFactory, FromArgMatches, Parser};
|
use clap::{CommandFactory, FromArgMatches, Parser};
|
||||||
use manifest::{
|
use manifest::{
|
||||||
PodManifest, PodManifestConfig, ProfileResolveOptions, ProfileResolver, ProfileSelector, paths,
|
Permission, PodManifest, PodManifestConfig, ProfileResolveOptions, ProfileResolver,
|
||||||
|
ProfileSelector, ScopeConfig, ScopeRule, paths,
|
||||||
};
|
};
|
||||||
use pod_store::{CombinedStore, FsPodStore, PodMetadataStore};
|
use pod_store::{CombinedStore, FsPodStore, PodMetadataStore};
|
||||||
use session_store::{FsStore, SegmentId, Store};
|
use session_store::{FsStore, SegmentId, Store};
|
||||||
|
|
@ -147,27 +148,43 @@ where
|
||||||
{
|
{
|
||||||
let workspace_root = runtime_workspace_root(cli)?;
|
let workspace_root = runtime_workspace_root(cli)?;
|
||||||
let runtime_pod_name = runtime_pod_name(cli, &workspace_root);
|
let runtime_pod_name = runtime_pod_name(cli, &workspace_root);
|
||||||
let mut manifest_and_loader = if let Some(config_json) = cli.spawn_config_json.as_deref() {
|
let ((mut manifest, loader), manifest_source) =
|
||||||
load_spawn_config_json(config_json)?
|
if let Some(config_json) = cli.spawn_config_json.as_deref() {
|
||||||
} else if let Some(profile) = &cli.profile {
|
(
|
||||||
let selector = ProfileSelector::parse_cli(profile);
|
load_spawn_config_json(config_json)?,
|
||||||
load_profile_fn(&selector, &workspace_root, &runtime_pod_name)?
|
ManifestSource::SpawnConfig,
|
||||||
} else if let Some(path) = &cli.manifest {
|
)
|
||||||
load_single_manifest(path, cli.pod.as_deref(), &runtime_pod_name)?
|
} else if let Some(profile) = &cli.profile {
|
||||||
} else {
|
let selector = ProfileSelector::parse_cli(profile);
|
||||||
if cli.project.is_some() {
|
(
|
||||||
return Err(
|
load_profile_fn(&selector, &workspace_root, &runtime_pod_name)?,
|
||||||
|
ManifestSource::ProfileLaunch,
|
||||||
|
)
|
||||||
|
} else if let Some(path) = &cli.manifest {
|
||||||
|
(
|
||||||
|
load_single_manifest(path, cli.pod.as_deref(), &runtime_pod_name)?,
|
||||||
|
ManifestSource::ManifestFile,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if cli.project.is_some() {
|
||||||
|
return Err(
|
||||||
"--project is no longer supported; normal startup uses profile discovery/default, \
|
"--project is no longer supported; normal startup uses profile discovery/default, \
|
||||||
and --manifest <PATH> is the only one-file manifest mode"
|
and --manifest <PATH> is the only one-file manifest mode"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let selector = ProfileSelector::Default;
|
let selector = ProfileSelector::Default;
|
||||||
load_profile_fn(&selector, &workspace_root, &runtime_pod_name)?
|
(
|
||||||
};
|
load_profile_fn(&selector, &workspace_root, &runtime_pod_name)?,
|
||||||
|
ManifestSource::ProfileLaunch,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
apply_session_restore_overrides(&mut manifest_and_loader.0, cli)?;
|
if manifest_source == ManifestSource::ProfileLaunch {
|
||||||
Ok(manifest_and_loader)
|
apply_profile_launch_policy(&mut manifest, &workspace_root, cli.ticket_role.as_deref())?;
|
||||||
|
}
|
||||||
|
apply_session_restore_overrides(&mut manifest, cli)?;
|
||||||
|
Ok((manifest, loader))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_session_restore_overrides(manifest: &mut PodManifest, cli: &Cli) -> Result<(), String> {
|
fn apply_session_restore_overrides(manifest: &mut PodManifest, cli: &Cli) -> Result<(), String> {
|
||||||
|
|
@ -233,9 +250,106 @@ fn load_single_manifest(
|
||||||
}
|
}
|
||||||
let manifest = PodManifest::try_from(config)
|
let manifest = PodManifest::try_from(config)
|
||||||
.map_err(|e| format!("failed to resolve manifest {}: {e}", path.display()))?;
|
.map_err(|e| format!("failed to resolve manifest {}: {e}", path.display()))?;
|
||||||
|
if manifest.scope.allow.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"manifest {} must declare scope.allow; profile launches receive concrete scope from launch policy",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
Ok((manifest, PromptLoader::builtins_only()))
|
Ok((manifest, PromptLoader::builtins_only()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ManifestSource {
|
||||||
|
ProfileLaunch,
|
||||||
|
ManifestFile,
|
||||||
|
SpawnConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_rule(target: PathBuf) -> ScopeRule {
|
||||||
|
ScopeRule {
|
||||||
|
target,
|
||||||
|
permission: Permission::Read,
|
||||||
|
recursive: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_rule(target: PathBuf) -> ScopeRule {
|
||||||
|
ScopeRule {
|
||||||
|
target,
|
||||||
|
permission: Permission::Write,
|
||||||
|
recursive: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_scope(
|
||||||
|
workspace_root: &Path,
|
||||||
|
permission: Permission,
|
||||||
|
deny_write: &[PathBuf],
|
||||||
|
) -> ScopeConfig {
|
||||||
|
let allow_rule = ScopeRule {
|
||||||
|
target: workspace_root.to_path_buf(),
|
||||||
|
permission,
|
||||||
|
recursive: true,
|
||||||
|
};
|
||||||
|
let deny = deny_write
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(write_rule)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
ScopeConfig {
|
||||||
|
allow: vec![allow_rule],
|
||||||
|
deny,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_worktree_delegation(workspace_root: &Path) -> ScopeConfig {
|
||||||
|
ScopeConfig {
|
||||||
|
allow: vec![
|
||||||
|
read_rule(workspace_root.to_path_buf()),
|
||||||
|
write_rule(workspace_root.join(".worktree")),
|
||||||
|
],
|
||||||
|
deny: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_profile_launch_policy(
|
||||||
|
manifest: &mut PodManifest,
|
||||||
|
workspace_root: &Path,
|
||||||
|
ticket_role: Option<&str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let role = match ticket_role {
|
||||||
|
Some(raw) => {
|
||||||
|
Some(TicketRole::parse(raw).ok_or_else(|| format!("invalid ticket role `{raw}`"))?)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
match role {
|
||||||
|
Some(TicketRole::Orchestrator) => {
|
||||||
|
manifest.scope = workspace_scope(workspace_root, Permission::Read, &[]);
|
||||||
|
manifest.delegation_scope = workspace_worktree_delegation(workspace_root);
|
||||||
|
}
|
||||||
|
Some(TicketRole::Intake) | Some(TicketRole::Reviewer) => {
|
||||||
|
manifest.scope = workspace_scope(workspace_root, Permission::Read, &[]);
|
||||||
|
manifest.delegation_scope = ScopeConfig::default();
|
||||||
|
}
|
||||||
|
Some(TicketRole::Coder) => {
|
||||||
|
manifest.scope = workspace_scope(workspace_root, Permission::Write, &[]);
|
||||||
|
manifest.delegation_scope = ScopeConfig::default();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let worktree_root = workspace_root.join(".worktree");
|
||||||
|
manifest.scope = workspace_scope(
|
||||||
|
workspace_root,
|
||||||
|
Permission::Write,
|
||||||
|
std::slice::from_ref(&worktree_root),
|
||||||
|
);
|
||||||
|
manifest.delegation_scope = workspace_worktree_delegation(workspace_root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn run_cli() -> ExitCode {
|
pub async fn run_cli() -> ExitCode {
|
||||||
run_cli_from("yoi pod", std::env::args_os().skip(1)).await
|
run_cli_from("yoi pod", std::env::args_os().skip(1)).await
|
||||||
}
|
}
|
||||||
|
|
@ -762,6 +876,96 @@ permission = "write"
|
||||||
|
|
||||||
assert!(called);
|
assert!(called);
|
||||||
assert_eq!(manifest.pod.name, "runtime-workspace");
|
assert_eq!(manifest.pod.name, "runtime-workspace");
|
||||||
|
assert_eq!(manifest.scope.allow.len(), 1);
|
||||||
|
assert_eq!(manifest.scope.allow[0].target, workspace);
|
||||||
|
assert_eq!(manifest.scope.allow[0].permission, Permission::Write);
|
||||||
|
assert_eq!(manifest.scope.deny.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.scope.deny[0].target,
|
||||||
|
tmp.path().join("runtime-workspace/.worktree")
|
||||||
|
);
|
||||||
|
assert_eq!(manifest.scope.deny[0].permission, Permission::Write);
|
||||||
|
assert_eq!(manifest.delegation_scope.allow.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.delegation_scope.allow[0].target,
|
||||||
|
tmp.path().join("runtime-workspace")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.delegation_scope.allow[0].permission,
|
||||||
|
Permission::Read
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.delegation_scope.allow[1].target,
|
||||||
|
tmp.path().join("runtime-workspace/.worktree")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.delegation_scope.allow[1].permission,
|
||||||
|
Permission::Write
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orchestrator_profile_launch_gets_read_root_and_worktree_delegation_from_launch_policy() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let workspace = tmp.path().join("original-workspace");
|
||||||
|
std::fs::create_dir(&workspace).unwrap();
|
||||||
|
let cli = Cli::try_parse_from([
|
||||||
|
"yoi pod",
|
||||||
|
"--workspace",
|
||||||
|
workspace.to_str().unwrap(),
|
||||||
|
"--profile",
|
||||||
|
"builtin:orchestrator",
|
||||||
|
"--ticket-role",
|
||||||
|
"orchestrator",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (manifest, _loader) =
|
||||||
|
resolve_manifest_with_profile_loader(&cli, |selector, _workspace_root, pod_name| {
|
||||||
|
assert_eq!(
|
||||||
|
selector,
|
||||||
|
&ProfileSelector::source_named(
|
||||||
|
manifest::ProfileRegistrySource::Builtin,
|
||||||
|
"orchestrator"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let mut manifest =
|
||||||
|
PodManifest::from_toml(&manifest_toml("from-orchestrator-profile", tmp.path()))
|
||||||
|
.unwrap();
|
||||||
|
manifest.pod.name = pod_name.to_string();
|
||||||
|
Ok((manifest, PromptLoader::builtins_only()))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(manifest.scope.allow.len(), 1);
|
||||||
|
assert_eq!(manifest.scope.allow[0].target, workspace);
|
||||||
|
assert_eq!(manifest.scope.allow[0].permission, Permission::Read);
|
||||||
|
assert!(manifest.scope.deny.is_empty());
|
||||||
|
assert_eq!(manifest.delegation_scope.allow.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.delegation_scope.allow[0].target,
|
||||||
|
tmp.path().join("original-workspace")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.delegation_scope.allow[0].permission,
|
||||||
|
Permission::Read
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.delegation_scope.allow[1].target,
|
||||||
|
tmp.path().join("original-workspace/.worktree")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.delegation_scope.allow[1].permission,
|
||||||
|
Permission::Write
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!manifest
|
||||||
|
.delegation_scope
|
||||||
|
.allow
|
||||||
|
.iter()
|
||||||
|
.any(|rule| rule.target == tmp.path().join("original-workspace")
|
||||||
|
&& rule.permission == Permission::Write)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1054,6 +1054,46 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orchestration_delegation_allows_root_read_and_worktree_writes_not_root_writes() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let workspace_root = tmp.path().join("original");
|
||||||
|
let implementation_worktree = workspace_root.join(".worktree/ticket-1");
|
||||||
|
std::fs::create_dir_all(&implementation_worktree).unwrap();
|
||||||
|
let delegation = DelegationScope::from_config(&ScopeConfig {
|
||||||
|
allow: vec![
|
||||||
|
abs_rule(&workspace_root, Permission::Read),
|
||||||
|
abs_rule(&workspace_root.join(".worktree"), Permission::Write),
|
||||||
|
],
|
||||||
|
deny: Vec::new(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let coder_scope = vec![
|
||||||
|
abs_rule(&workspace_root, Permission::Read),
|
||||||
|
abs_rule(&implementation_worktree, Permission::Write),
|
||||||
|
];
|
||||||
|
assert!(
|
||||||
|
coder_scope
|
||||||
|
.iter()
|
||||||
|
.all(|rule| delegation.allows_rule(rule).unwrap())
|
||||||
|
);
|
||||||
|
|
||||||
|
let reviewer_scope = vec![abs_rule(&workspace_root, Permission::Read)];
|
||||||
|
assert!(
|
||||||
|
reviewer_scope
|
||||||
|
.iter()
|
||||||
|
.all(|rule| delegation.allows_rule(rule).unwrap())
|
||||||
|
);
|
||||||
|
|
||||||
|
let root_writer_scope = vec![abs_rule(&workspace_root, Permission::Write)];
|
||||||
|
assert!(
|
||||||
|
root_writer_scope
|
||||||
|
.iter()
|
||||||
|
.any(|rule| !delegation.allows_rule(rule).unwrap())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn parent_manifest(root: &Path, deny: Option<&Path>) -> PodManifest {
|
fn parent_manifest(root: &Path, deny: Option<&Path>) -> PodManifest {
|
||||||
PodManifestConfig {
|
PodManifestConfig {
|
||||||
pod: PodMetaConfig {
|
pod: PodMetaConfig {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ local p = yoi.profile.import("builtin:default")
|
||||||
|
|
||||||
p.slug = "coder"
|
p.slug = "coder"
|
||||||
p.description = "Coder role profile with bundled reusable policy"
|
p.description = "Coder role profile with bundled reusable policy"
|
||||||
p.scope = yoi.scope.workspace_write()
|
|
||||||
p.worker.instruction = "$yoi/role/coder"
|
p.worker.instruction = "$yoi/role/coder"
|
||||||
p.feature = {
|
p.feature = {
|
||||||
task = { enabled = true },
|
task = { enabled = true },
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,6 @@ local p = yoi.profile.import("builtin:default")
|
||||||
|
|
||||||
p.slug = "companion"
|
p.slug = "companion"
|
||||||
p.description = "Companion role profile with bundled reusable policy"
|
p.description = "Companion role profile with bundled reusable policy"
|
||||||
p.scope = yoi.scope.workspace_write({
|
|
||||||
deny_write = { ".worktree" },
|
|
||||||
})
|
|
||||||
p.feature = {
|
p.feature = {
|
||||||
task = { enabled = true },
|
task = { enabled = true },
|
||||||
memory = { enabled = true },
|
memory = { enabled = true },
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ return yoi.profile {
|
||||||
slug = "default",
|
slug = "default",
|
||||||
description = "Default Yoi coding profile",
|
description = "Default Yoi coding profile",
|
||||||
|
|
||||||
scope = yoi.scope.workspace_write(),
|
|
||||||
|
|
||||||
session = {
|
session = {
|
||||||
record_event_trace = true,
|
record_event_trace = true,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ local p = yoi.profile.import("builtin:default")
|
||||||
|
|
||||||
p.slug = "intake"
|
p.slug = "intake"
|
||||||
p.description = "Intake role profile with bundled reusable policy"
|
p.description = "Intake role profile with bundled reusable policy"
|
||||||
p.scope = yoi.scope.workspace_read()
|
|
||||||
p.worker.instruction = "$yoi/role/intake"
|
p.worker.instruction = "$yoi/role/intake"
|
||||||
p.feature = {
|
p.feature = {
|
||||||
task = { enabled = false },
|
task = { enabled = false },
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ local p = yoi.profile.import("builtin:default")
|
||||||
|
|
||||||
p.slug = "orchestrator"
|
p.slug = "orchestrator"
|
||||||
p.description = "Orchestrator role profile with bundled reusable policy"
|
p.description = "Orchestrator role profile with bundled reusable policy"
|
||||||
p.scope = "workspace_read"
|
|
||||||
p.worker.instruction = "$yoi/role/orchestrator"
|
p.worker.instruction = "$yoi/role/orchestrator"
|
||||||
p.feature = {
|
p.feature = {
|
||||||
task = { enabled = false },
|
task = { enabled = false },
|
||||||
|
|
@ -12,6 +11,5 @@ p.feature = {
|
||||||
ticket = { enabled = true, access = "lifecycle" },
|
ticket = { enabled = true, access = "lifecycle" },
|
||||||
ticket_orchestration = { enabled = true },
|
ticket_orchestration = { enabled = true },
|
||||||
}
|
}
|
||||||
p.delegation_scope = "workspace_write"
|
|
||||||
|
|
||||||
return p
|
return p
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ local p = yoi.profile.import("builtin:default")
|
||||||
|
|
||||||
p.slug = "reviewer"
|
p.slug = "reviewer"
|
||||||
p.description = "Reviewer role profile with bundled reusable policy"
|
p.description = "Reviewer role profile with bundled reusable policy"
|
||||||
p.scope = yoi.scope.workspace_read()
|
|
||||||
p.worker.instruction = "$yoi/role/reviewer"
|
p.worker.instruction = "$yoi/role/reviewer"
|
||||||
p.feature = {
|
p.feature = {
|
||||||
task = { enabled = false },
|
task = { enabled = false },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user