diff --git a/.yoi/tickets/00001KX0DSMPT/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KX0DSMPT/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..03de8e07 --- /dev/null +++ b/.yoi/tickets/00001KX0DSMPT/artifacts/orchestration-plan.jsonl @@ -0,0 +1,3 @@ +{"id":"orch-plan-20260708-104512-1","ticket_id":"00001KX0DSMPT","kind":"after","related_ticket":"00001KX0G06VA","note":"This Ticket is queued while `00001KX0G06VA` is inprogress because both touch Workspace/Backend profile/resource launch surfaces (`workspace-server` settings/API/hosts/server and profile archive/resource fetch integration). Sequence after the active resource-fetch implementation to avoid conflicting public API and ProfileSourceArchive authority-boundary changes.","author":"orchestrator","at":"2026-07-08T10:45:12Z"} +{"id":"orch-plan-20260708-104530-2","ticket_id":"00001KX0DSMPT","kind":"waiting_capacity_note","note":"Queue review after Dashboard authorization. The declared dependency `00001KWZ5KERY` is closed, so that blocker is resolved. However `00001KX0G06VA` is currently inprogress on branch `work/00001KX0G06VA-resource-fetch-api` and changes the same ProfileSourceArchive/resource/workspace-server launch API surfaces this Ticket needs to build on. Leave this Ticket queued until the active resource-fetch branch is reviewed/merged or its API shape is otherwise decided; then re-route/start from the updated orchestration branch.","author":"orchestrator","at":"2026-07-08T10:45:30Z"} +{"id":"orch-plan-20260708-115622-3","ticket_id":"00001KX0DSMPT","kind":"accepted_plan","accepted_plan":{"summary":"`00001KWZ5KERY` と `00001KX0G06VA` が closed/merged になり、previous waiting reason は解除された。Workspace/Profile editing API/pages を updated orchestration branch から実装開始する。","branch":"work/00001KX0DSMPT-workspace-profile-settings","worktree":"/home/hare/Projects/yoi/.worktree/00001KX0DSMPT-workspace-profile-settings","role_plan":"単一 sibling Coder Pod に implementation worktree を委譲し、Workspace/Profile settings scoped APIs、safe Profile registry/source read-write model、settings UI pages、Backend discovery invalidation、launch candidate integration、diagnostic/redaction tests を実装する。完了後、別 sibling Reviewer Pod で Ticket IntentPacket / ProfileSourceArchive/resource-fetch authority boundary / acceptance criteria に照らして read-only review する。"},"author":"orchestrator","at":"2026-07-08T11:56:22Z"} diff --git a/.yoi/tickets/00001KX0DSMPT/item.md b/.yoi/tickets/00001KX0DSMPT/item.md index fee576bf..40b5547a 100644 --- a/.yoi/tickets/00001KX0DSMPT/item.md +++ b/.yoi/tickets/00001KX0DSMPT/item.md @@ -1,8 +1,8 @@ --- title: 'Add Workspace settings API and pages for Workspace/Profile editing' -state: 'queued' +state: 'closed' created_at: '2026-07-08T08:34:01Z' -updated_at: '2026-07-08T10:44:13Z' +updated_at: '2026-07-08T13:20:27Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T10:44:13Z' diff --git a/.yoi/tickets/00001KX0DSMPT/resolution.md b/.yoi/tickets/00001KX0DSMPT/resolution.md new file mode 100644 index 00000000..9bdc534e --- /dev/null +++ b/.yoi/tickets/00001KX0DSMPT/resolution.md @@ -0,0 +1,33 @@ +完了。 + +実装内容: +- Workspace/Profile settings scoped API を追加した。 +- Workspace metadata read/update と safe identity/source summaries を扱う Settings API を追加した。 +- Profile registry/source read/update API を追加し、create/update/delete と revision/etag-style optimistic checks を実装した。 +- Backend profile settings model に safe source ids/display paths、Decodal/ProfileSourceArchive validation、selector uniqueness、path/symlink escape protection、source size checks、typed diagnostics を追加した。 +- Profile updates 後に Backend profile discovery / validation / launch options を再構築し、Worker launch profile candidates と Settings profile list が同じ discovery result を使うようにした。 +- invalid project profile は relevant profile summary に diagnostics を付与し、Worker launch candidates から除外し、candidate validation でも reject するようにした。 +- Browser-facing responses/UI から host absolute paths、secret values、Runtime endpoints/tokens、socket/session paths、runtime-local store paths、archive content/digest、raw resource handles、backend-private paths を出さないようにした。 +- Settings UI に Workspace overview と Profile list/source editor surface を追加した。 +- Focused backend/web tests を追加/強化した。 + +主な commit / merge: +- implementation: `3eefd333 feat: add workspace profile settings` +- review fix: `0c2ca1ea fix: harden profile settings validation` +- merge into orchestration: `0bf3638c merge: workspace profile settings` + +Review: +- 初回 external review は request_changes。 +- follow-up external review は approve。merge-blocking issue なし。 + +Final validation in Orchestrator worktree: +- `git diff --check`: pass +- `cargo test -p yoi-workspace-server`: pass(91 lib tests + 2 main tests) +- `cargo check -p yoi`: pass +- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) +- `cd web/workspace && deno task test`: pass(17 tests) +- `yoi ticket doctor`: ok +- `nix build .#yoi --no-link`: pass + +補足: +- implementation branch merge 時に Ticket record conflict が発生したため、Orchestrator 側の Ticket item/thread を保持して解決した。product/code changes は merge 済み。 \ No newline at end of file diff --git a/.yoi/tickets/00001KX0DSMPT/thread.md b/.yoi/tickets/00001KX0DSMPT/thread.md index c39b023a..282aef3c 100644 --- a/.yoi/tickets/00001KX0DSMPT/thread.md +++ b/.yoi/tickets/00001KX0DSMPT/thread.md @@ -30,4 +30,352 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: blocked_by_dependency_or_missing_authority + +Reason: +- Declared dependency `00001KWZ5KERY` is now closed, so the original Decodal/ProfileSourceArchive prerequisite is satisfied。 +- However `00001KX0G06VA` is currently `inprogress` on branch `work/00001KX0G06VA-resource-fetch-api`。 +- This Ticket’s Workspace/Profile editing API and pages need to build on the same profile/resource/workspace-server launch surfaces that `00001KX0G06VA` is actively changing(ProfileSourceArchive resource handling, workspace-server settings/API/hosts/server, Browser-facing profile launch configuration)。 +- Starting this Ticket now would create a high-conflict parallel branch and risks designing Profile editing against the wrong resource-fetch/public API boundary。 +- Therefore this routing pass leaves the Ticket queued and does not record `queued -> inprogress`, create a worktree, or spawn role Pods。 + +Evidence checked: +- Ticket body / thread / artifacts。 +- `TicketRelationQuery(00001KX0DSMPT)`: depends_on `00001KWZ5KERY`; target is closed。 +- `TicketOrchestrationPlanQuery(00001KX0DSMPT)`: prior record 0 件だったため、今回 `after 00001KX0G06VA` と waiting-capacity note を記録。 +- `TicketList`: queued はこの Ticket 1件、inprogress は `00001KX0G06VA` 1件。 +- Orchestrator worktree git status: clean on `orchestration`。 + +Next action: +- `00001KX0G06VA` の implementation/review/merge outcome を待つ。 +- resource-fetch API shape が merge されたら、この Ticket を再 routing し、updated orchestration branch から start する。 + +Escalate if: +- 人間が `00001KX0G06VA` を中断してこの Ticket を先に実装する、または両者を同一 combined worktree で統合実装する方針に切り替えたい場合。 + +--- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- Previous waiting reason is resolved: `00001KX0G06VA` is now merged/validated/closed, so the ProfileSourceArchive resource-fetch API shape this Ticket depends on is stable enough to build against。 +- Declared dependency `00001KWZ5KERY` is also closed, so Decodal Profile source model / ProfileSourceArchive boundary is available。 +- Ticket body gives concrete Workspace/Profile settings API, read/write source model, UI routes, diagnostics, redaction invariants, and validation criteria。 +- typed relation blocker is resolved (`depends_on 00001KWZ5KERY` target closed); no active inprogress Ticket remains。 +- The user requested sequential consumption once no blocker remains, and the Dashboard queue authorization is already present。 + +Evidence checked: +- Ticket body / thread / artifacts。 +- `TicketRelationQuery(00001KX0DSMPT)`: outgoing `depends_on 00001KWZ5KERY`; target is closed。 +- `TicketOrchestrationPlanQuery(00001KX0DSMPT)`: prior `after 00001KX0G06VA` / waiting note; `00001KX0G06VA` is now closed with merge commit `01f94b75` and close commit `a645ee5b`。 +- Orchestrator worktree git status: clean on `orchestration`。 +- queued Ticket 一覧: this Ticket 1件。inprogress は 0 件。 + +IntentPacket: + +Intent: +- Workspace-scoped Browser API and pages for Workspace metadata and Profile registry/source editing, using Backend as the authority and the Decodal/ProfileSourceArchive model already merged。 +- Frontend should edit Profiles through safe Backend APIs, not filesystem paths, and Worker launch profile candidates should reflect updated Backend discovery/validation state。 + +Binding decisions / invariants: +- Workspace/Profile editing APIs live under `/api/w//settings...` or consistent scoped Workspace API paths。 +- Browser-facing API/UI must not expose host absolute paths, secret values, Runtime endpoints/tokens, socket/session paths, runtime-local store paths, archive content/digest, or raw resource handles。 +- Profile source references use safe ids/artifact ids/display paths rather than raw absolute paths。 +- Writes must validate syntax/schema/selector uniqueness/path safety before commit and return typed diagnostics。 +- Decodal/ProfileSourceArchive source model from `00001KWZ5KERY` and resource-fetch boundary from `00001KX0G06VA` are authority inputs; do not reintroduce Runtime filesystem discovery。 +- Runtime direct sync is out of scope; Backend discovery/launch options invalidation is in scope。 + +Requirements / acceptance criteria: +- Workspace metadata read/update API works for display name and safe identity/source summaries。 +- Profile registry/source read/update APIs support create/update/delete with revision/etag-style optimistic check。 +- Settings UI has Workspace overview and Profile list/source editor routes under `/w//settings...`。 +- Profile updates re-run Backend discovery/validation and update Worker launch profile candidates。 +- Duplicate selector, invalid registry schema, invalid Decodal syntax, path/symlink escape, artifact too large, and concurrent update conflict return typed diagnostics。 +- Browser WorkingDirectory / Worker launch profile candidates and settings Profile list share the same Backend discovery result。 + +Implementation latitude: +- Endpoint naming, module boundaries, revision token shape, source/artifact id representation, UI component split, and editor implementation can follow existing workspace-server / Svelte style。 +- v0 plain textarea/editor is acceptable; no heavyweight editor required。 +- Imported source write support can be limited to bounded module root or deferred with typed diagnostics if not needed for core v0。 + +Escalate if: +- Secret value editor, multi-user auth/RBAC, Runtime direct sync, Plugin/MCP/sandbox artifact sync, Ticket/Objective/Memory editor integration, or a new Profile source semantic model beyond Decodal/ProfileSourceArchive is required。 + +Validation: +- `cargo test -p yoi-workspace-server` +- `cargo check -p yoi` +- `cd web/workspace && deno task check && deno task test` +- `git diff --check` +- `yoi ticket doctor` +- `nix build .#yoi --no-link` + +Current code map: +- Workspace/profile settings backend: `crates/workspace-server/src/server.rs`, `hosts.rs`, `config.rs`, `resource_broker.rs`, Decodal/archive helpers。 +- Profile archive/config: `crates/worker-runtime/src/profile_archive.rs`, `config_bundle.rs` for source model reference。 +- Frontend settings: `web/workspace/src/routes/w/[workspaceId]/settings/**`, `web/workspace/src/lib/workspace-settings/**`, worker launch profile candidate helpers。 + +Critical risks / reviewer focus: +- No raw paths/secrets/runtime internals or resource handles leak to Browser。 +- Profile editing updates the same Backend discovery/launch options used by Worker launch。 +- Optimistic concurrency and path/symlink escape checks fail closed。 +- Decodal validation is real and not only superficial string checks。 +- Existing Runtime resource fetch / ProfileSourceArchive authority boundaries remain intact。 + +--- + + + +## State changed + +Queued acceptance recorded after previous waiting reason was resolved. + +Checked context: +- Ticket body / thread / artifacts。 +- `TicketRelationQuery(00001KX0DSMPT)`: depends_on `00001KWZ5KERY`; target is closed。 +- `TicketOrchestrationPlanQuery(00001KX0DSMPT)`: prior `after 00001KX0G06VA` / waiting note を確認。`00001KX0G06VA` is now closed / merged。 +- Orchestrator worktree git status: clean on `orchestration`。 +- queued Ticket はこの Ticket 1件のみ。inprogress は 0 件。 + +Acceptance basis: +- Previous blocker/waiting reason is resolved。 +- concrete missing decision / information は残っていない。 +- Ticket scope is Workspace/Profile settings API and pages using already-merged Decodal/ProfileSourceArchive and resource-fetch boundaries。 +- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。 + +--- + + + +## Implementation report + +Implementation routing update: + +- Worktree-workflow に従い implementation worktree を作成した。 + - Worktree: `/home/hare/Projects/yoi/.worktree/00001KX0DSMPT-workspace-profile-settings` + - Branch: `work/00001KX0DSMPT-workspace-profile-settings` +- Worktree には tracked `.yoi` project records が見えている。 +- `.yoi/memory` / `.yoi/runtime` / `.yoi/log` / `.yoi/logs` / `.yoi/local` / `.yoi/*.lock` / `.yoi/*secret*` は作成直後に存在しないことを確認した。 +- Sibling Coder Pod `yoi-coder-00001KX0DSMPT-profile-settings` を起動した。 +- Scope policy: runtime workspace root は read-only、implementation worktree は write 可。Coder task では root/original workspace での read/write/validation/git operation を禁止し、この implementation worktree だけを作業対象に指定した。 +- 完了後は diff / validation evidence を Orchestrator が確認し、別 sibling Reviewer Pod に read-only review を依頼する。`StopPod` は既知の不具合があるため使用しない。 + +--- + + + +## Implementation report + +Implementation routing recovery note: + +- Initial Coder Pod `yoi-coder-00001KX0DSMPT-profile-settings` failed its first turn with provider `context_length_exceeded` before producing implementation output。 +- Ticket remains `inprogress`; worktree/branch already exist and are clean at creation baseline。 +- Recovery plan: avoid `StopPod` and retry with a fresh minimal Coder task that reads the Ticket records from the worktree instead of embedding the full IntentPacket in the prompt. If scope registry prevents a fresh Pod, fallback is to use the existing Pod with a much smaller instruction or ask for human cleanup/authority。 + +--- + + + +## Implementation report + +Implementation progress report: + +- Recovery Coder Pod `yoi-coder2-00001KX0DSMPT-profile-settings` completed implementation and committed `3eefd333 feat: add workspace profile settings` on branch `work/00001KX0DSMPT-workspace-profile-settings`. +- Worktree status was clean after commit。 +- Changed files include: + - `crates/workspace-server/src/profile_settings.rs` (new) + - `crates/workspace-server/src/{server.rs,hosts.rs,companion.rs,lib.rs}` + - `web/workspace/src/lib/workspace-settings/{profile-api.ts,profile-types.ts,model.ts}` + - `web/workspace/src/lib/workspace-api/http.ts` + - `web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte` + - child implementation also committed `.yoi/tickets/00001KX0DSMPT/{item.md,thread.md}` changes; Orchestrator will preserve authoritative Ticket thread/item records during integration if conflicts occur。 +- Implementation summary: + - Added scoped Workspace/Profile settings API for workspace metadata and profile registry/source read/write。 + - Added Backend profile settings model with safe source ids/display paths, revision checks, Decodal validation, selector uniqueness checks, path/symlink escape protection, and typed diagnostics。 + - Profile updates rebuild Backend profile discovery/launch options so Worker launch profile candidates and settings profile list use the same discovery result。 + - Extended settings UI with Workspace overview and Profiles list/editor surface using scoped APIs and safe summaries。 + - Browser-facing responses avoid raw host paths, runtime internals, archive digests/content, and resource handles。 +- Orchestrator reran validation in the implementation worktree: + - `git diff --check`: pass + - `cargo test -p yoi-workspace-server`: pass(80 lib tests + 2 main tests) + - `cargo check -p yoi`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(17 tests) + - `yoi ticket doctor`: ok + - `nix build .#yoi --no-link`: pass +- External review will be requested via a read-only sibling Reviewer Pod. `StopPod` は使わない。 + +--- + + + +## Review: request changes + +External review result: request_changes + +Blockers: + +1. Profile mutation validation failures are not returned as typed diagnostics。 + - scoped settings handlers call `profile_settings::*` with `?` directly (`server.rs:881-945`). + - `ApiError::from(Error)` initializes `diagnostics: Vec::new()` (`server.rs:3355-3360`). + - profile errors such as `profile_selector_duplicate`, `profile_source_syntax_invalid`, `profile_source_path_escape`, `profile_registry_revision_conflict` fall through to `BAD_GATEWAY` (`server.rs:3403-3414`) with an empty diagnostics array (`server.rs:3420-3423`). + - Recorded requirement says duplicate selector, invalid registry/schema/Decodal, path/symlink escape, artifact too large, and concurrency conflicts must return typed diagnostics. + +2. Worker launch profile candidates can include invalid project profiles。 + - `load_profile_settings` attaches Decodal/source validation errors from `validate_project_profiles` only to top-level `diagnostics` (`profile_settings.rs:327`, `663-707`). + - each project `WorkspaceProfileSummary` only gets `source_summary` diagnostics (`profile_settings.rs:304-323`). + - `worker_profile_candidates_for_root` includes project profiles when `profile.diagnostics.is_empty()` (`server.rs:2989-2992`). + - `is_profile_candidate` accepts any discovered project profile id (`profile_settings.rs:629-635`). + - Result: registry entry with invalid Decodal source can still appear in launch options and be accepted as a candidate until spawn-time archive resolution fails. + - This violates the intent that profile updates re-run Backend discovery/validation and Worker launch candidates share the same effective validated discovery result. + +3. Registry/default updates can commit references that are not actually Backend-published/valid。 + - `validate_default_profile` accepts any string beginning with `project:` without checking that the project selector exists in submitted registry or discovery result (`profile_settings.rs:946-955`). + - `update_profile_registry` only validates source content if the expected source file already exists (`profile_settings.rs:463-468`) before writing the registry (`profile_settings.rs:470`). + - This permits API writes that persist nonexistent project defaults or missing project source references, contrary to the invariant that writes validate schema/selector/source references before commit and defaults/candidates are Backend-published. + +Validation performed: +- Read Ticket `00001KX0DSMPT` item/thread, IntentPacket, binding invariants, acceptance criteria, implementation report, and reviewer focus。 +- Read-only inspection in implementation worktree `/home/hare/Projects/yoi/.worktree/00001KX0DSMPT-workspace-profile-settings`。 +- Inspected implementation commit scope and relevant backend/frontend files。 +- Relied on Orchestrator-provided validation results; did not modify files or run root/original workspace operations。 + +Non-blocking follow-ups: +- Negative-test coverage is thinner than reviewer focus: duplicate selector, invalid registry schema, symlink escape, artifact size, redaction, concurrent update API response shape, and launch-candidate invalidation need stronger coverage. +- UI implements profile list/editor on single `/settings` page with anchors/forms rather than separate `/settings/profiles/...` pages. This may be acceptable under implementation latitude, but confirm route granularity if it matters. + +--- + + + +## Implementation report + +Review-fix implementation report: + +- Recovery Coder Pod completed requested changes and committed follow-up `0c2ca1ea fix: harden profile settings validation` on branch `work/00001KX0DSMPT-workspace-profile-settings`. +- Worktree status was clean after commit。 +- Fix summary: + - Profile settings validation errors now map to typed Browser diagnostics with non-empty `diagnostics` arrays and appropriate HTTP statuses(revision conflict 409、invalid/duplicate/path/schema/source/profile errors 400、unknown source/selector 404)。 + - Project profile discovery attaches source/Decodal validation failures to the relevant `WorkspaceProfileSummary`。 + - Invalid project profiles are excluded from Worker launch candidates and rejected by candidate validation。 + - Registry/default writes validate default selector existence, selector/source consistency, missing source files, path/symlink escape, source size, Decodal/ProfileSourceArchive validity, and invalid registry schema before commit。 + - Added/strengthened tests for duplicate selector, invalid Decodal/schema, stale revision conflict, too-large source, symlink escape redaction, invalid launch candidate rejection, and missing/default reference rejection。 +- Orchestrator reran validation in the implementation worktree: + - `git diff --check`: pass + - `cargo test -p yoi-workspace-server`: pass(91 lib tests + 2 main tests) + - `cargo check -p yoi`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(17 tests) + - `yoi ticket doctor`: ok + - `nix build .#yoi --no-link`: pass +- Requesting follow-up external review against `3eefd333..0c2ca1ea` and full combined implementation。 + +--- + + + +## Review: approve + +External follow-up review result: approve + +Blockers: none. + +Validation performed read-only in implementation worktree. Reviewer inspected Ticket records, `0c2ca1ea`, fix diff `3eefd333..0c2ca1ea`, and relevant server/profile/UI code. Reviewer did not rerun heavy validation and relied on Orchestrator-reported passing validation. + +Findings: +- Prior blocker 1 resolved: profile mutation failures now return typed Browser diagnostics with populated diagnostics rather than generic empty diagnostics. Server-side handlers/tests cover invalid source mutations and missing-source registry mutations with non-empty diagnostic arrays and bad-request status。 +- Prior blocker 2 resolved: invalid/missing/Decodal-invalid project profile sources are surfaced on relevant profile summaries, invalid project profiles are excluded from Worker launch candidates, and candidate validation rejects invalid project profile ids with regression coverage。 +- Prior blocker 3 resolved: registry/default update paths validate selector/default existence, selector/source-id correspondence, missing source files, source path safety including symlink escape rejection, source size, Decodal/ProfileSourceArchive validity, and invalid registry schema before accepting mutations。 +- Browser-facing profile/settings summaries expose bounded ids, labels, relative display paths, revision/configured booleans, and diagnostics; no host absolute paths, socket/session paths, runtime-local store paths, runtime endpoints/tokens, backend-private paths, archive digests, raw resource handles, or secret values were found in inspected API/UI surfaces。 +- Profile detail exposes editable source content as intended settings-editor payload, not resolved archive/resource bundle。 +- Decodal/ProfileSourceArchive validation remains behind server-side checked source path/archive boundary; Runtime filesystem discovery was not reintroduced for profile validation or launch。 +- Single-page settings UI route structure is acceptable under implementation latitude and not a blocker。 + +Non-blocking follow-up: +- If merge target accumulated additional `.yoi/tickets/00001KX0DSMPT/thread.md` edits, resolve Ticket-record conflicts normally during merge; this does not affect product/code correctness. + +--- + + + +## State changed + +Merge/validation acceptance: + +- Follow-up external review approved the implementation after blocker fixes。 +- Implementation branch `work/00001KX0DSMPT-workspace-profile-settings` was merged into the orchestration branch with merge commit `0bf3638c merge: workspace profile settings`。 +- Ticket-record conflicts during merge were resolved by preserving Orchestrator-side Ticket item/thread records; product/code changes were merged。 +- Final validation in Orchestrator worktree passed: + - `git diff --check`: pass + - `cargo test -p yoi-workspace-server`: pass(91 lib tests + 2 main tests) + - `cargo check -p yoi`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(17 tests) + - `yoi ticket doctor`: ok + - `nix build .#yoi --no-link`: pass + +Implementation commits: +- `3eefd333 feat: add workspace profile settings` +- `0c2ca1ea fix: harden profile settings validation` + +No merge-blocking issues remain. + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +完了。 + +実装内容: +- Workspace/Profile settings scoped API を追加した。 +- Workspace metadata read/update と safe identity/source summaries を扱う Settings API を追加した。 +- Profile registry/source read/update API を追加し、create/update/delete と revision/etag-style optimistic checks を実装した。 +- Backend profile settings model に safe source ids/display paths、Decodal/ProfileSourceArchive validation、selector uniqueness、path/symlink escape protection、source size checks、typed diagnostics を追加した。 +- Profile updates 後に Backend profile discovery / validation / launch options を再構築し、Worker launch profile candidates と Settings profile list が同じ discovery result を使うようにした。 +- invalid project profile は relevant profile summary に diagnostics を付与し、Worker launch candidates から除外し、candidate validation でも reject するようにした。 +- Browser-facing responses/UI から host absolute paths、secret values、Runtime endpoints/tokens、socket/session paths、runtime-local store paths、archive content/digest、raw resource handles、backend-private paths を出さないようにした。 +- Settings UI に Workspace overview と Profile list/source editor surface を追加した。 +- Focused backend/web tests を追加/強化した。 + +主な commit / merge: +- implementation: `3eefd333 feat: add workspace profile settings` +- review fix: `0c2ca1ea fix: harden profile settings validation` +- merge into orchestration: `0bf3638c merge: workspace profile settings` + +Review: +- 初回 external review は request_changes。 +- follow-up external review は approve。merge-blocking issue なし。 + +Final validation in Orchestrator worktree: +- `git diff --check`: pass +- `cargo test -p yoi-workspace-server`: pass(91 lib tests + 2 main tests) +- `cargo check -p yoi`: pass +- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) +- `cd web/workspace && deno task test`: pass(17 tests) +- `yoi ticket doctor`: ok +- `nix build .#yoi --no-link`: pass + +補足: +- implementation branch merge 時に Ticket record conflict が発生したため、Orchestrator 側の Ticket item/thread を保持して解決した。product/code changes は merge 済み。 + --- diff --git a/.yoi/tickets/00001KX0G06VA/item.md b/.yoi/tickets/00001KX0G06VA/item.md index 73c61fbe..479a0574 100644 --- a/.yoi/tickets/00001KX0G06VA/item.md +++ b/.yoi/tickets/00001KX0G06VA/item.md @@ -1,8 +1,8 @@ --- title: 'Add Runtime-to-Backend resource fetch REST API' -state: 'inprogress' +state: 'closed' created_at: '2026-07-08T09:12:33Z' -updated_at: '2026-07-08T10:40:25Z' +updated_at: '2026-07-08T11:54:55Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T10:04:10Z' diff --git a/.yoi/tickets/00001KX0G06VA/resolution.md b/.yoi/tickets/00001KX0G06VA/resolution.md new file mode 100644 index 00000000..50339b2a --- /dev/null +++ b/.yoi/tickets/00001KX0G06VA/resolution.md @@ -0,0 +1,36 @@ +完了。 + +実装内容: +- Runtime-to-Backend resource fetch contract を追加した。 +- Backend-issued typed resource handle と `BackendResourceBroker` を実装した。 +- v0 resource kind `profile_source_archive` を fetch / verify / cache できるようにした。 +- embedded direct Runtime path と remote Runtime HTTP path が同じ typed resource contract を使うようにした。 +- Runtime は handle に基づき archive を fetch し、digest / max bytes / content type / expiry / binding を検証してから cache/reuse するようにした。 +- Backend broker は stored issued handle を authority として扱い、Runtime-provided mutable handle fields による expiry / max_bytes / policy tampering を fail closed するようにした。 +- Runtime cache は Backend authorization / expiry / binding を bypass しないようにした。 +- Decodal ProfileSourceArchive / config bundle path を resource fetch boundary に接続し、Runtime filesystem discovery を再導入しない形にした。 +- Browser-facing summaries は internal resource handle / endpoint / raw path / archive content/digest/store path / token 等を出さないようにした。 +- Focused worker-runtime / workspace-server tests を追加した。 + +主な commit / merge: +- implementation: `57e96d3b feat: add backend resource fetch api` +- review fix: `e716ae44 fix: harden backend resource handles` +- merge into orchestration: `01f94b75 merge: backend resource fetch api` + +Review: +- 初回 external review は request_changes。 +- follow-up external review は approve。merge-blocking issue なし。 + +Final validation in Orchestrator worktree: +- `git diff --check`: pass +- `cargo test -p worker-runtime --features ws-server,fs-store`: pass(45 lib tests + 5 main tests + doc tests) +- `cargo test -p yoi-workspace-server`: pass(77 lib tests + 2 main tests) +- `cargo check -p yoi`: pass +- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) +- `cd web/workspace && deno task test`: pass(17 tests) +- `yoi ticket doctor`: ok +- `nix build .#yoi --no-link`: pass + +補足: +- Reviewer の non-blocking follow-up: stored `audit_correlation_id` echo と Runtime-side response `resource_id` check、remote HTTP response-size limiting のさらなる強化余地。 +- queued `00001KX0DSMPT` については、resource-fetch API shape が確定したため再 routing 可能。 \ No newline at end of file diff --git a/.yoi/tickets/00001KX0G06VA/thread.md b/.yoi/tickets/00001KX0G06VA/thread.md index 6df722b7..689736c6 100644 --- a/.yoi/tickets/00001KX0G06VA/thread.md +++ b/.yoi/tickets/00001KX0G06VA/thread.md @@ -176,3 +176,222 @@ Implementation routing update: - 完了後は diff / validation evidence を Orchestrator が確認し、別 sibling Reviewer Pod に read-only review を依頼する。`StopPod` は既知の不具合があるため使用しない。 --- + + + +## Implementation report + +Implementation progress report: + +- Coder Pod completed implementation and committed `57e96d3b feat: add backend resource fetch api` on branch `work/00001KX0G06VA-resource-fetch-api`. +- Worktree status was clean after commit. +- Changed files include: + - `crates/worker-runtime/src/resource.rs` (new) + - `crates/workspace-server/src/resource_broker.rs` (new) + - `crates/worker-runtime/src/{config_bundle.rs,http_server.rs,main.rs,profile_archive.rs,worker_backend.rs}` + - `crates/workspace-server/src/{hosts.rs,server.rs,companion.rs}` + - `Cargo.lock`, `package.nix` +- Implementation summary: + - Added typed resource handle/request/response model and Runtime resource client/cache path。 + - Added Backend resource broker for v0 `profile_source_archive` resources。 + - Embedded direct path and remote Runtime HTTP path share the resource contract。 + - Runtime verifies digest, max bytes/content type, expiry/binding, and cache reuse before using fetched profile archives。 + - Config bundle/profile archive path can use resource handle without Runtime filesystem discovery。 + - Browser-facing summaries redact internal resource handles/endpoints/raw paths/archive details。 + - Added focused tests for handle issue/verify, runtime mismatch, digest mismatch, oversized response, direct/remote resource fetch, Browser redaction, and config-bundle/resource archive integration。 +- Orchestrator reran validation in the implementation worktree: + - `git diff --check`: pass + - `cargo test -p worker-runtime --features ws-server,fs-store`: pass(42 lib tests + 5 main tests + doc tests) + - `cargo test -p yoi-workspace-server`: pass(71 lib tests + 2 main tests) + - `cargo check -p yoi`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(17 tests) + - `yoi ticket doctor`: ok + - `nix build .#yoi --no-link`: pass +- External review will be requested via a read-only sibling Reviewer Pod. `StopPod` は使わない。 + +--- + + + +## Review: request changes + +External review result: request_changes + +Blockers: + +1. Backend handle verification が Runtime から送られた mutable handle fields を信頼している。 + - `crates/workspace-server/src/resource_broker.rs` の fetch validation は expiry / max-bytes を `request.handle` から確認しており、broker-stored issued handle の authority fields と照合していない。 + - 有効な nonce/resource/digest を持つ Runtime が以下を改変できる可能性がある。 + - `expires_at_unix_seconds` を延長して expiry を bypass。 + - `max_bytes` を増やして oversized rejection を bypass。 + - redaction/scope/generation など他 policy fields も stored issued handle に bind されていない。 + - Backend-issued handle を Backend が検証し、expired/oversized handles が fail closed typed diagnostics になるという Ticket requirement に反する。 + +2. Runtime cache が Backend authorization / expiry / binding を bypass している。 + - `ProfileRuntimeWorkerFactory::resolve_profile_source_archive` は fetch 前に digest keyed `profile_archive_cache` を見る。 + - 一度 archive が cache されると、同一 digest の後続 config bundle は Backend verification なしで reuse でき、expired handle / runtime-worker-workspace mismatch でも成功し得る。 + - 「Runtime cannot fetch/use Workspace-derived resources without Backend-issued handle」「Runtime verifies expiry/binding」「expired/unauthorized/mismatch cases produce diagnostics」に反する。 + +3. security contract の acceptance-level tests が不足している。 + - happy-path broker issue/fetch、runtime mismatch、browser projection redaction はあるが、以下の meaningful coverage が不足している。 + - tampered expired handle / expiry extension。 + - worker mismatch。 + - REST 経由 missing resource。 + - Runtime response verification の digest mismatch。 + - tampered `max_bytes` による oversized response。 + - remote HTTP resource-fetch path が同じ contract を使うこと。 + - 上記 defect が存在しているため、単なる coverage gap ではなく core Ticket intent の boundary regression を許している。 + +Validation / inspection performed: +- implementation worktree `/home/hare/Projects/yoi/.worktree/00001KX0G06VA-resource-fetch-api` のみを使用。 +- Ticket item/thread/orchestration plan を確認。 +- `git show --stat --check 57e96d3b`, `git show --name-only 57e96d3b`, relevant implementation files を inspection。 +- Orchestrator-reported validation pass を参照し、Reviewer は heavy tests を再実行していない。 + +Non-blocking follow-ups: +- New `reqwest` dependency and `package.nix` cargo hash update は HTTP Runtime resource client と `nix build` pass により妥当。 +- broader resource kind 追加前に、stored issued handle policy fields の完全比較、または signed/opaque handle representation を検討するとよい。 + +--- + + + +## Implementation report + +Review-fix implementation report: + +- Coder Pod completed requested changes and committed follow-up `e716ae44 fix: harden backend resource handles` on branch `work/00001KX0G06VA-resource-fetch-api`. +- Worktree status was clean after commit. +- Fix summary: + - Backend broker now treats broker-stored issued handle as authority。 + - Request handle must match stored issued handle; expiry and `max_bytes` validation use stored policy fields, not mutable Runtime-provided fields。 + - Tampered scope/policy fields fail closed。 + - Runtime archive cache no longer bypasses Backend authorization: Runtime calls `BackendResourceClient` and verifies response before reusing digest-cached archive。 + - Expired/unauthorized handles now block cached reuse。 + - Added focused security-contract tests for expiry extension/expired handle rejection, worker mismatch, policy tampering, REST missing-resource diagnostic, Runtime digest mismatch, oversized response despite `max_bytes` tampering, and remote HTTP path using the same Backend contract as direct broker path。 +- Orchestrator reran validation in the implementation worktree: + - `git diff --check`: pass + - `cargo test -p worker-runtime --features ws-server,fs-store`: pass(45 lib tests + 5 main tests + doc tests) + - `cargo test -p yoi-workspace-server`: pass(77 lib tests + 2 main tests) + - `cargo check -p yoi`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(17 tests) + - `yoi ticket doctor`: ok + - `nix build .#yoi --no-link`: pass +- Requesting follow-up external review against `57e96d3b..e716ae44` and full combined implementation。 + +--- + + + +## Review: approve + +External follow-up review result: approve + +Blockers: none. + +Validation performed: +- Read-only review in implementation worktree `/home/hare/Projects/yoi/.worktree/00001KX0G06VA-resource-fetch-api`。 +- Ticket item/thread, branch/log/status, `57e96d3b..e716ae44` fix diff, and relevant implementation files を確認。 +- Reviewer は validation suite を再実行せず、Orchestrator-reported passing validation を evidence として確認。 + +Findings: +- Prior blocker 1 fixed: Backend は mutable request handle policy fields を信頼しない。`BackendResourceBroker` は issued `BackendResourceHandle` を resource と共に保存し、stored expiry/max_bytes を検証してから request handle と stored issued handle を比較する。tampering は `Expired` / `Oversized` / `Unauthorized` diagnostics で fail closed。expiry extension、policy tampering、worker/runtime mismatch、max_bytes tampering tests あり。 +- Prior blocker 2 fixed: Runtime cache は Backend authorization を bypass しない。`resolve_profile_source_archive` は先に Backend fetch を行い、handle に対して response verify してから digest cache を reuse/insert する。cache test は second-use backend `Expired` rejection と backend call count を確認している。 +- Prior blocker 3 fixed: security contract tests が meaningful に追加されている。 + - `resource_broker.rs`: expired/expiry extension、worker mismatch、runtime mismatch、policy tampering、stored max_bytes oversized。 + - `resource.rs`: Runtime digest mismatch と oversized response verification。 + - `server.rs`: REST missing-resource diagnostic と remote HTTP contract/tampering path。 + - `worker_backend.rs`: cache authorization。 + - Browser redaction: `ConfigBundleSummary` redaction test。 +- Typed/direct/remote contract: `BackendResourceFetchRequest/Response/Error` は shared typed structs。embedded direct path は `BackendResourceBroker` を `BackendResourceClient` として使い、remote HTTP は同じ request shape を `/internal/runtime/resources/fetch` に POST する。 +- Browser-facing redaction remains strong: browser-safe summaries は profile source graph summary のみを出し、handle nonce/resource id/archive digest/runtime-worker binding/token/internal endpoint/raw path を出さない。 +- ProfileSourceArchive integration は normal Backend/Browser launch で Backend-owned resource boundary を使う。generated bundles は resource handle を持ち inline archive を持たず、Runtime は fetched archive bytes から resolve する。filesystem profile discovery は explicit no-config-bundle compatibility/debug fallback のみ。 + +Non-blocking follow-ups: +- stored `audit_correlation_id` の echo と Runtime-side response `resource_id` check を追加すると diagnostic integrity がさらに強くなる。 +- remote HTTP resource fetch が trusted Backend/Runtime links を越えて露出する場合は、JSON body materialization 前の explicit client-side timeout/response-size limiting を検討する。 + +Effect on queued `00001KX0DSMPT`: +- blocking concern なし。profile/archive details は redacted summaries behind boundary に留まっており、future settings UI/API が profile metadata を consume しても handles を露出しない設計と互換。 + +--- + + + +## State changed + +Merge/validation acceptance: + +- Follow-up external review approved the implementation after blocker fix。 +- Implementation branch `work/00001KX0G06VA-resource-fetch-api` was merged into the orchestration branch with merge commit `01f94b75 merge: backend resource fetch api`。 +- Final validation in Orchestrator worktree passed: + - `git diff --check`: pass + - `cargo test -p worker-runtime --features ws-server,fs-store`: pass(45 lib tests + 5 main tests + doc tests) + - `cargo test -p yoi-workspace-server`: pass(77 lib tests + 2 main tests) + - `cargo check -p yoi`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(17 tests) + - `yoi ticket doctor`: ok + - `nix build .#yoi --no-link`: pass + +Implementation commits: +- `57e96d3b feat: add backend resource fetch api` +- `e716ae44 fix: harden backend resource handles` + +No merge-blocking issues remain. Reviewer noted no blocking concern for queued `00001KX0DSMPT`; the resource-fetch API shape is now stable enough for that follow-up to be re-routed. + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +完了。 + +実装内容: +- Runtime-to-Backend resource fetch contract を追加した。 +- Backend-issued typed resource handle と `BackendResourceBroker` を実装した。 +- v0 resource kind `profile_source_archive` を fetch / verify / cache できるようにした。 +- embedded direct Runtime path と remote Runtime HTTP path が同じ typed resource contract を使うようにした。 +- Runtime は handle に基づき archive を fetch し、digest / max bytes / content type / expiry / binding を検証してから cache/reuse するようにした。 +- Backend broker は stored issued handle を authority として扱い、Runtime-provided mutable handle fields による expiry / max_bytes / policy tampering を fail closed するようにした。 +- Runtime cache は Backend authorization / expiry / binding を bypass しないようにした。 +- Decodal ProfileSourceArchive / config bundle path を resource fetch boundary に接続し、Runtime filesystem discovery を再導入しない形にした。 +- Browser-facing summaries は internal resource handle / endpoint / raw path / archive content/digest/store path / token 等を出さないようにした。 +- Focused worker-runtime / workspace-server tests を追加した。 + +主な commit / merge: +- implementation: `57e96d3b feat: add backend resource fetch api` +- review fix: `e716ae44 fix: harden backend resource handles` +- merge into orchestration: `01f94b75 merge: backend resource fetch api` + +Review: +- 初回 external review は request_changes。 +- follow-up external review は approve。merge-blocking issue なし。 + +Final validation in Orchestrator worktree: +- `git diff --check`: pass +- `cargo test -p worker-runtime --features ws-server,fs-store`: pass(45 lib tests + 5 main tests + doc tests) +- `cargo test -p yoi-workspace-server`: pass(77 lib tests + 2 main tests) +- `cargo check -p yoi`: pass +- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) +- `cd web/workspace && deno task test`: pass(17 tests) +- `yoi ticket doctor`: ok +- `nix build .#yoi --no-link`: pass + +補足: +- Reviewer の non-blocking follow-up: stored `audit_correlation_id` echo と Runtime-side response `resource_id` check、remote HTTP response-size limiting のさらなる強化余地。 +- queued `00001KX0DSMPT` については、resource-fetch API shape が確定したため再 routing 可能。 + +--- diff --git a/Cargo.lock b/Cargo.lock index e23dd6c8..c90d9941 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5936,6 +5936,7 @@ dependencies = [ "llm-engine", "manifest", "protocol", + "reqwest", "serde", "serde_json", "session-store", diff --git a/crates/worker-runtime/Cargo.toml b/crates/worker-runtime/Cargo.toml index a010ed23..8cf715a3 100644 --- a/crates/worker-runtime/Cargo.toml +++ b/crates/worker-runtime/Cargo.toml @@ -14,7 +14,7 @@ required-features = ["ws-server", "fs-store"] [features] default = [] fs-store = [] -http-server = ["dep:axum", "dep:tower"] +http-server = ["dep:axum", "dep:tower", "dep:reqwest"] ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"] [dependencies] @@ -28,6 +28,7 @@ serde = { workspace = true, features = ["derive"] } session-store.workspace = true sha2.workspace = true serde_json.workspace = true +reqwest = { version = "0.13", optional = true, default-features = false, features = ["json", "rustls"] } tar.workspace = true thiserror = { workspace = true } tokio = { workspace = true, features = ["net", "rt", "sync", "time"] } diff --git a/crates/worker-runtime/src/config_bundle.rs b/crates/worker-runtime/src/config_bundle.rs index beafad32..a098bca7 100644 --- a/crates/worker-runtime/src/config_bundle.rs +++ b/crates/worker-runtime/src/config_bundle.rs @@ -4,6 +4,7 @@ use crate::profile_archive::{ ProfileArchiveError, ProfileSourceArchive, ProfileSourceArchiveRef, VerifiedProfileSourceArchive, }; +use crate::resource::{BackendResourceHandle, validate_resource_handle_text}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -24,6 +25,8 @@ pub struct ConfigBundle { pub declarations: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub profile_source_archive: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_source_archive_handle: Option, } impl ConfigBundle { @@ -75,6 +78,27 @@ impl ConfigBundle { lines.push(format!("profile_archive_entrypoint\0{selector}\0{path}")); } } + if let Some(handle) = &self.profile_source_archive_handle { + lines.push(format!( + "profile_archive_handle\0{}\0{}\0{}\0{}\0{}", + handle.workspace_id, + handle.resource_id, + handle.digest, + handle.revision, + handle.max_bytes + )); + for (selector, path) in handle + .profile_source_graph + .as_ref() + .map(|graph| &graph.entrypoints) + .into_iter() + .flatten() + { + lines.push(format!( + "profile_archive_handle_entrypoint\0{selector}\0{path}" + )); + } + } lines.sort(); let mut hasher = Sha256::new(); @@ -105,7 +129,12 @@ impl ConfigBundle { profile_source_archive: self .profile_source_archive .as_ref() - .map(|archive| archive.reference.source_graph.clone()), + .map(|archive| archive.reference.source_graph.clone()) + .or_else(|| { + self.profile_source_archive_handle + .as_ref() + .and_then(|handle| handle.profile_source_graph.clone()) + }), } } @@ -253,6 +282,33 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim RuntimeError::InvalidRequest(format!("invalid profile source archive: {err}")) })?; } + if let Some(handle) = &bundle.profile_source_archive_handle { + for (label, value) in [ + ("resource handle workspace id", handle.workspace_id.as_str()), + ("resource handle resource id", handle.resource_id.as_str()), + ("resource handle digest", handle.digest.as_str()), + ("resource handle nonce", handle.nonce.as_str()), + ("resource handle revision", handle.revision.as_str()), + ("resource handle content type", handle.content_type.as_str()), + ( + "resource handle audit correlation id", + handle.audit_correlation_id.as_str(), + ), + ] { + validate_resource_handle_text(label, value).map_err(RuntimeError::InvalidRequest)?; + } + if !handle.digest.starts_with("sha256:") { + return Err(RuntimeError::InvalidRequest( + "resource handle digest must use sha256:".to_string(), + )); + } + if handle.profile_source_graph.is_none() { + return Err(RuntimeError::InvalidRequest( + "profile source archive resource handle must include source graph summary" + .to_string(), + )); + } + } Ok(()) } @@ -529,6 +585,7 @@ mod tests { reference: reference.to_string(), }], profile_source_archive: None, + profile_source_archive_handle: None, } .with_computed_digest() } @@ -560,6 +617,53 @@ mod tests { validate_config_bundle(&bundle_with_declaration("vault:team.api-key")).unwrap(); } + #[test] + fn bundle_summary_redacts_runtime_internal_resource_handle() { + let mut bundle = bundle_with_declaration("secret:github-token"); + let source_graph = crate::profile_archive::ProfileSourceGraphSummary { + entrypoints: std::collections::BTreeMap::from([( + "default".to_string(), + "profiles/default.dcdl".to_string(), + )]), + source_count: 1, + import_count: 0, + total_source_bytes: 16, + }; + bundle.profile_source_archive_handle = Some(crate::resource::BackendResourceHandle { + kind: crate::resource::BackendResourceKind::ProfileSourceArchive, + workspace_id: "workspace-1".to_string(), + scope_id: Some("scope-1".to_string()), + runtime_id: Some("runtime-1".to_string()), + worker_id: Some("worker-1".to_string()), + resource_id: "profile-source-archive:test".to_string(), + digest: "sha256:0123456789abcdef".to_string(), + operation: crate::resource::BackendResourceOperation::FetchArchive, + expires_at_unix_seconds: 4_102_444_800, + nonce: "nonce-not-for-browser".to_string(), + revision: "rev-1".to_string(), + generation: Some(1), + max_bytes: 128, + content_type: crate::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly, + audit_correlation_id: "audit-1".to_string(), + profile_source_graph: Some(source_graph), + }); + let rendered = serde_json::to_string(&bundle.summary()).unwrap(); + assert!(rendered.contains("profile_source_archive")); + for forbidden in [ + "nonce-not-for-browser", + "profile-source-archive:test", + "sha256:0123456789abcdef", + "runtime-1", + "worker-1", + ] { + assert!( + !rendered.contains(forbidden), + "leaked {forbidden}: {rendered}" + ); + } + } + #[test] fn rejects_unsafe_bundle_ids_and_refs() { for id in ["bundle/1", "bundle?x", "bundle&x", "bundle#x", " bundle"] { diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index e01dc9b3..f73f2b16 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -850,6 +850,7 @@ mod tests { }], declarations: Vec::new(), profile_source_archive: None, + profile_source_archive_handle: None, } .with_computed_digest() } @@ -1137,6 +1138,7 @@ mod ws_tests { }], declarations: Vec::new(), profile_source_archive: None, + profile_source_archive_handle: None, } .with_computed_digest() } diff --git a/crates/worker-runtime/src/lib.rs b/crates/worker-runtime/src/lib.rs index 405aab74..c897f79c 100644 --- a/crates/worker-runtime/src/lib.rs +++ b/crates/worker-runtime/src/lib.rs @@ -20,6 +20,7 @@ pub mod interaction; pub mod management; pub mod observation; pub mod profile_archive; +pub mod resource; mod runtime; pub mod worker_backend; pub mod working_directory; diff --git a/crates/worker-runtime/src/main.rs b/crates/worker-runtime/src/main.rs index 54112219..1f0f723c 100644 --- a/crates/worker-runtime/src/main.rs +++ b/crates/worker-runtime/src/main.rs @@ -78,6 +78,14 @@ fn build_runtime(config: &ProcessConfig) -> Result { if let Some(runtime_base_dir) = config.worker_runtime_base_dir.clone() { factory = factory.with_runtime_base_dir(runtime_base_dir); } + if let Some(endpoint) = config.backend_resource_endpoint.clone() { + factory = factory.with_resource_client(Arc::new( + worker_runtime::resource::HttpBackendResourceClient::new( + endpoint, + config.backend_resource_token.clone(), + ), + )); + } if let Some(profile) = config.profile.clone() { factory = factory.with_profile(profile); } @@ -173,6 +181,13 @@ where config.worker_runtime_base_dir = Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?)); } + "--backend-resource-endpoint" => { + config.backend_resource_endpoint = + Some(take_value(&flag, inline_value, &mut args)?); + } + "--backend-resource-token" => { + config.backend_resource_token = Some(take_value(&flag, inline_value, &mut args)?); + } "--profile" => { config.profile = Some(take_value(&flag, inline_value, &mut args)?); } @@ -317,6 +332,8 @@ struct ProcessConfig { worker_store_dir: Option, worker_metadata_dir: Option, worker_runtime_base_dir: Option, + backend_resource_endpoint: Option, + backend_resource_token: Option, profile: Option, } @@ -330,6 +347,8 @@ impl ProcessConfig { worker_store_dir: None, worker_metadata_dir: None, worker_runtime_base_dir: None, + backend_resource_endpoint: None, + backend_resource_token: None, profile: None, }) } @@ -405,6 +424,8 @@ Options:\n\ --worker-store-dir Worker session store directory\n\ --worker-metadata-dir Worker metadata directory\n\ --worker-runtime-base-dir Worker controller runtime directory\n\ + --backend-resource-endpoint Internal Backend resource fetch endpoint for resource handles\n\ + --backend-resource-token Optional bearer token for the Backend resource fetch endpoint\n\ --store Runtime catalog store selection (default: memory)\n\ --fs-root Runtime catalog filesystem store root\n\ --local-token Minimal local bearer token placeholder\n\ diff --git a/crates/worker-runtime/src/profile_archive.rs b/crates/worker-runtime/src/profile_archive.rs index cc7a6d09..b759c9d6 100644 --- a/crates/worker-runtime/src/profile_archive.rs +++ b/crates/worker-runtime/src/profile_archive.rs @@ -472,7 +472,7 @@ fn validate_archive_path(path: &str) -> Result<(), ProfileArchiveError> { Ok(()) } -fn sha256_hex(data: &[u8]) -> String { +pub fn sha256_hex(data: &[u8]) -> String { let digest = Sha256::digest(data); let mut out = String::from("sha256:"); for byte in digest.as_slice() { diff --git a/crates/worker-runtime/src/resource.rs b/crates/worker-runtime/src/resource.rs new file mode 100644 index 00000000..17c54978 --- /dev/null +++ b/crates/worker-runtime/src/resource.rs @@ -0,0 +1,336 @@ +use crate::identity::{RuntimeId, WorkerId}; +use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Mutex; + +pub const PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE: &str = + "application/vnd.yoi.profile-source-archive+tar"; +pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BackendResourceKind { + ProfileSourceArchive, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BackendResourceOperation { + FetchArchive, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackendResourceHandle { + pub kind: BackendResourceKind, + pub workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker_id: Option, + pub resource_id: String, + pub digest: String, + pub operation: BackendResourceOperation, + pub expires_at_unix_seconds: i64, + pub nonce: String, + pub revision: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generation: Option, + pub max_bytes: u64, + pub content_type: String, + pub redaction: ResourceRedactionPolicy, + pub audit_correlation_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_source_graph: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResourceRedactionPolicy { + RuntimeInternalOnly, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackendResourceFetchRequest { + pub handle: BackendResourceHandle, + pub runtime_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker_id: Option, + pub audit_correlation_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackendResourceFetchResponse { + pub kind: BackendResourceKind, + pub resource_id: String, + pub digest: String, + pub content_type: String, + pub bytes: Vec, + pub audit_correlation_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)] +#[serde(tag = "code", rename_all = "snake_case")] +pub enum BackendResourceError { + #[error("backend resource handle is expired")] + Expired, + #[error("backend resource handle is unauthorized: {message}")] + Unauthorized { message: String }, + #[error("backend resource kind is unsupported")] + UnsupportedKind, + #[error("backend resource is missing")] + MissingResource, + #[error("backend resource digest mismatch: expected {expected}, got {actual}")] + DigestMismatch { expected: String, actual: String }, + #[error("backend resource response is oversized: limit {max_bytes}, actual {actual_bytes}")] + Oversized { max_bytes: u64, actual_bytes: u64 }, + #[error("backend resource content type mismatch: expected {expected}, got {actual}")] + ContentTypeMismatch { expected: String, actual: String }, + #[error("backend resource transport failed: {message}")] + Transport { message: String }, + #[error("backend resource response is invalid: {message}")] + InvalidResponse { message: String }, +} + +#[async_trait] +pub trait BackendResourceClient: Send + Sync + 'static { + async fn fetch_resource( + &self, + request: BackendResourceFetchRequest, + ) -> Result; +} + +#[cfg(feature = "http-server")] +#[derive(Clone, Debug)] +pub struct HttpBackendResourceClient { + endpoint: String, + bearer_token: Option, + client: reqwest::Client, +} + +#[cfg(feature = "http-server")] +impl HttpBackendResourceClient { + pub fn new(endpoint: impl Into, bearer_token: Option) -> Self { + Self { + endpoint: endpoint.into(), + bearer_token, + client: reqwest::Client::new(), + } + } +} + +#[cfg(feature = "http-server")] +#[async_trait] +impl BackendResourceClient for HttpBackendResourceClient { + async fn fetch_resource( + &self, + request: BackendResourceFetchRequest, + ) -> Result { + let builder = self.client.post(&self.endpoint).json(&request); + let builder = if let Some(token) = self.bearer_token.as_deref() { + builder.bearer_auth(token) + } else { + builder + }; + let response = builder + .send() + .await + .map_err(|err| BackendResourceError::Transport { + message: err.to_string(), + })?; + if response.status().is_success() { + response + .json::() + .await + .map_err(|err| BackendResourceError::InvalidResponse { + message: err.to_string(), + }) + } else { + let status = response.status(); + match response.json::().await { + Ok(error) => Err(error), + Err(err) => Err(BackendResourceError::Transport { + message: format!("backend resource fetch failed with HTTP {status}: {err}"), + }), + } + } + } +} + +#[derive(Default, Debug)] +pub struct ProfileSourceArchiveCache { + archives: Mutex>, +} + +impl ProfileSourceArchiveCache { + pub fn get(&self, digest: &str) -> Option { + self.archives.lock().ok()?.get(digest).cloned() + } + + pub fn insert(&self, archive: ProfileSourceArchive) { + if let Ok(mut archives) = self.archives.lock() { + archives.insert(archive.reference.digest.clone(), archive); + } + } +} + +pub fn build_profile_source_archive_fetch_request( + handle: BackendResourceHandle, + runtime_id: &RuntimeId, + worker_id: Option<&WorkerId>, +) -> BackendResourceFetchRequest { + let audit_correlation_id = handle.audit_correlation_id.clone(); + BackendResourceFetchRequest { + handle, + runtime_id: runtime_id.as_str().to_string(), + worker_id: worker_id.map(|id| id.as_str().to_string()), + audit_correlation_id, + } +} + +pub fn profile_source_archive_from_response( + handle: &BackendResourceHandle, + response: BackendResourceFetchResponse, +) -> Result { + if handle.kind != BackendResourceKind::ProfileSourceArchive + || response.kind != BackendResourceKind::ProfileSourceArchive + { + return Err(BackendResourceError::UnsupportedKind); + } + if handle.operation != BackendResourceOperation::FetchArchive { + return Err(BackendResourceError::Unauthorized { + message: "resource handle operation is not fetch_archive".to_string(), + }); + } + if response.content_type != handle.content_type { + return Err(BackendResourceError::ContentTypeMismatch { + expected: handle.content_type.clone(), + actual: response.content_type, + }); + } + let actual_bytes = response.bytes.len() as u64; + if actual_bytes > handle.max_bytes { + return Err(BackendResourceError::Oversized { + max_bytes: handle.max_bytes, + actual_bytes, + }); + } + let actual_digest = sha256_hex(&response.bytes); + if actual_digest != handle.digest || response.digest != handle.digest { + return Err(BackendResourceError::DigestMismatch { + expected: handle.digest.clone(), + actual: if response.digest != handle.digest { + response.digest + } else { + actual_digest + }, + }); + } + Ok(ProfileSourceArchive { + reference: ProfileSourceArchiveRef { + id: handle.resource_id.clone(), + digest: handle.digest.clone(), + size_bytes: actual_bytes, + source_graph: handle.profile_source_graph.clone().ok_or_else(|| { + BackendResourceError::InvalidResponse { + message: "profile source archive handle omitted source graph summary" + .to_string(), + } + })?, + }, + content: response.bytes, + }) +} + +pub fn validate_resource_handle_text(label: &str, value: &str) -> Result<(), String> { + if value.trim().is_empty() { + return Err(format!("{label} must not be empty")); + } + if value.len() > 256 || value.contains('\0') || value.contains('\n') || value.contains('\r') { + return Err(format!("{label} contains unsupported boundary text")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile_archive::ProfileSourceGraphSummary; + use std::collections::BTreeMap; + + fn graph() -> ProfileSourceGraphSummary { + ProfileSourceGraphSummary { + source_count: 1, + total_source_bytes: 4, + entrypoints: BTreeMap::from([( + "default".to_string(), + "profiles/default.dcdl".to_string(), + )]), + import_count: 0, + } + } + + fn handle_for(bytes: &[u8]) -> BackendResourceHandle { + BackendResourceHandle { + kind: BackendResourceKind::ProfileSourceArchive, + workspace_id: "workspace-test".to_string(), + scope_id: Some("workspace-profile-source".to_string()), + runtime_id: Some("runtime-test".to_string()), + worker_id: Some("worker-test".to_string()), + resource_id: "profile-source-archive:test".to_string(), + digest: sha256_hex(bytes), + operation: BackendResourceOperation::FetchArchive, + expires_at_unix_seconds: 4_102_444_800, + nonce: "nonce-test".to_string(), + revision: sha256_hex(bytes), + generation: Some(1), + max_bytes: 1024, + content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + redaction: ResourceRedactionPolicy::RuntimeInternalOnly, + audit_correlation_id: "audit-test".to_string(), + profile_source_graph: Some(graph()), + } + } + + #[test] + fn response_verification_detects_digest_mismatch() { + let bytes = b"archive-bytes"; + let handle = handle_for(bytes); + let error = profile_source_archive_from_response( + &handle, + BackendResourceFetchResponse { + kind: BackendResourceKind::ProfileSourceArchive, + resource_id: handle.resource_id.clone(), + digest: handle.digest.clone(), + content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + bytes: b"tampered-bytes".to_vec(), + audit_correlation_id: handle.audit_correlation_id.clone(), + }, + ) + .unwrap_err(); + assert!(matches!(error, BackendResourceError::DigestMismatch { .. })); + } + + #[test] + fn response_verification_rejects_oversized_bytes() { + let bytes = b"archive-bytes"; + let mut handle = handle_for(bytes); + handle.max_bytes = 2; + let error = profile_source_archive_from_response( + &handle, + BackendResourceFetchResponse { + kind: BackendResourceKind::ProfileSourceArchive, + resource_id: handle.resource_id.clone(), + digest: handle.digest.clone(), + content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + bytes: bytes.to_vec(), + audit_correlation_id: handle.audit_correlation_id.clone(), + }, + ) + .unwrap_err(); + assert!(matches!(error, BackendResourceError::Oversized { .. })); + } +} diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 8665d6f3..e9d7b87c 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -1628,6 +1628,7 @@ mod tests { reference: "capability:read".to_string(), }], profile_source_archive: None, + profile_source_archive_handle: None, } .with_computed_digest() } diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index a5e62d4e..b55fa011 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -20,6 +20,10 @@ use crate::execution::{ WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult, }; use crate::interaction::{WorkerInput, WorkerInputKind}; +use crate::resource::{ + BackendResourceClient, BackendResourceError, ProfileSourceArchiveCache, + build_profile_source_archive_fetch_request, profile_source_archive_from_response, +}; use crate::working_directory::{WorkingDirectoryBinding, WorkingDirectoryMaterializer}; use async_trait::async_trait; use manifest::paths; @@ -46,7 +50,7 @@ pub trait RuntimeWorkerFactory: Send + Sync + 'static { /// Production factory that resolves a normal Worker profile and spawns it under /// `WorkerController`. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct ProfileRuntimeWorkerFactory { profile_base_dir: PathBuf, cwd: PathBuf, @@ -54,6 +58,8 @@ pub struct ProfileRuntimeWorkerFactory { worker_metadata_dir: Option, profile: Option, runtime_base_dir: Option, + resource_client: Option>, + profile_archive_cache: Arc, } impl ProfileRuntimeWorkerFactory { @@ -66,6 +72,8 @@ impl ProfileRuntimeWorkerFactory { worker_metadata_dir: None, profile: None, runtime_base_dir: None, + resource_client: None, + profile_archive_cache: Arc::new(ProfileSourceArchiveCache::default()), } } @@ -96,6 +104,11 @@ impl ProfileRuntimeWorkerFactory { self } + pub fn with_resource_client(mut self, resource_client: Arc) -> Self { + self.resource_client = Some(resource_client); + self + } + fn store_dir(&self) -> Result { self.store_dir .clone() @@ -152,6 +165,56 @@ impl ProfileRuntimeWorkerFactory { } Self::runtime_profile_value(&request.request.profile) } + async fn resolve_profile_source_archive( + &self, + bundle: &crate::config_bundle::ConfigBundle, + request: &WorkerExecutionSpawnRequest, + ) -> Result { + if let Some(archive) = verified_profile_source_archive(bundle) + .map_err(|err| format!("failed to verify profile source archive: {err}"))? + { + return Ok(archive); + } + let handle = bundle + .profile_source_archive_handle + .clone() + .ok_or_else(|| { + format!( + "config bundle {} does not contain a ProfileSourceArchive resource handle", + bundle.metadata.id + ) + })?; + let client = self.resource_client.as_ref().ok_or_else(|| { + format!( + "config bundle {} requires a Backend resource client for profile source archive fetch", + bundle.metadata.id + ) + })?; + let fetch_request = build_profile_source_archive_fetch_request( + handle.clone(), + &request.worker_ref.runtime_id, + Some(&request.worker_ref.worker_id), + ); + let response = client + .fetch_resource(fetch_request) + .await + .map_err(format_backend_resource_error)?; + let fetched_archive = profile_source_archive_from_response(&handle, response) + .map_err(format_backend_resource_error)?; + if let Some(cached) = self.profile_archive_cache.get(&handle.digest) { + return cached + .verify() + .map_err(|err| format!("failed to verify cached profile source archive: {err}")); + } + self.profile_archive_cache.insert(fetched_archive.clone()); + fetched_archive + .verify() + .map_err(|err| format!("failed to verify fetched profile source archive: {err}")) + } +} + +fn format_backend_resource_error(error: BackendResourceError) -> String { + format!("backend resource fetch failed: {error}") } #[async_trait] @@ -174,14 +237,9 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { .unwrap_or_else(|| self.cwd.clone()); let (mut manifest, loader) = if let Some(bundle) = request.config_bundle.as_ref() { let selector = profile.as_deref().unwrap_or("builtin:default"); - let archive = verified_profile_source_archive(bundle) - .map_err(|err| format!("failed to verify profile source archive: {err}"))? - .ok_or_else(|| { - format!( - "config bundle {} does not contain a ProfileSourceArchive", - bundle.metadata.id - ) - })?; + let archive = self + .resolve_profile_source_archive(bundle, &request) + .await?; let manifest = archive .resolve_profile(selector, &worker_root, &worker_name) .map_err(|err| format!("failed to resolve profile source archive: {err}"))?; @@ -643,6 +701,7 @@ where #[cfg(test)] mod tests { use super::*; + use std::collections::{BTreeMap, VecDeque}; use std::fs; use std::pin::Pin; use std::process::Command; @@ -653,6 +712,7 @@ mod tests { ConfigBundleRef, CreateWorkerRequest, DirtyStatePolicy, MaterializerKind, ProfileSelector, RepositorySelector, WorkingDirectoryRepository, WorkingDirectoryRequest, }; + use crate::execution::WorkerExecutionContext; use crate::identity::RuntimeId; use crate::management::RuntimeOptions; use crate::observation::{TranscriptQuery, TranscriptRole}; @@ -791,10 +851,130 @@ mod tests { }], declarations: Vec::new(), profile_source_archive: None, + profile_source_archive_handle: None, } .with_computed_digest() } + fn sample_profile_archive() -> crate::profile_archive::ProfileSourceArchive { + let entrypoints = + BTreeMap::from([("default".to_string(), "profiles/default.dcdl".to_string())]); + let sources = BTreeMap::from([( + "profiles/default.dcdl".to_string(), + r#"{ + slug = "default"; + description = "Default"; + scope = "workspace_read"; + }"# + .to_string(), + )]); + crate::profile_archive::ProfileSourceArchive::build( + crate::profile_archive::ProfileSourceArchiveInput { + id: "profile-source-archive:test".to_string(), + entrypoints, + imports: BTreeMap::new(), + sources, + }, + ) + .unwrap() + } + + fn handle_for_archive( + archive: &crate::profile_archive::ProfileSourceArchive, + ) -> crate::resource::BackendResourceHandle { + crate::resource::BackendResourceHandle { + kind: crate::resource::BackendResourceKind::ProfileSourceArchive, + workspace_id: "workspace-test".to_string(), + scope_id: Some("workspace-profile-source".to_string()), + runtime_id: Some("runtime-test".to_string()), + worker_id: Some("worker-test".to_string()), + resource_id: archive.reference.id.clone(), + digest: archive.reference.digest.clone(), + operation: crate::resource::BackendResourceOperation::FetchArchive, + expires_at_unix_seconds: 4_102_444_800, + nonce: "nonce-test".to_string(), + revision: archive.reference.digest.clone(), + generation: Some(1), + max_bytes: crate::resource::DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES, + content_type: crate::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly, + audit_correlation_id: "audit-test".to_string(), + profile_source_graph: Some(archive.reference.source_graph.clone()), + } + } + + fn response_for_archive( + handle: &crate::resource::BackendResourceHandle, + archive: &crate::profile_archive::ProfileSourceArchive, + ) -> crate::resource::BackendResourceFetchResponse { + crate::resource::BackendResourceFetchResponse { + kind: crate::resource::BackendResourceKind::ProfileSourceArchive, + resource_id: handle.resource_id.clone(), + digest: handle.digest.clone(), + content_type: crate::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + bytes: archive.content.clone(), + audit_correlation_id: handle.audit_correlation_id.clone(), + } + } + + #[derive(Clone)] + struct SequencedResourceClient { + responses: Arc< + Mutex< + VecDeque< + Result< + crate::resource::BackendResourceFetchResponse, + crate::resource::BackendResourceError, + >, + >, + >, + >, + call_count: Arc, + } + + #[async_trait] + impl crate::resource::BackendResourceClient for SequencedResourceClient { + async fn fetch_resource( + &self, + _request: crate::resource::BackendResourceFetchRequest, + ) -> Result< + crate::resource::BackendResourceFetchResponse, + crate::resource::BackendResourceError, + > { + self.call_count.fetch_add(1, Ordering::SeqCst); + self.responses + .lock() + .unwrap() + .pop_front() + .expect("missing sequenced resource response") + } + } + + fn spawn_request_with_bundle( + bundle: crate::config_bundle::ConfigBundle, + ) -> WorkerExecutionSpawnRequest { + let worker_ref = crate::identity::WorkerRef::new( + RuntimeId::new("runtime-test").unwrap(), + crate::identity::WorkerId::new("worker-test").unwrap(), + ); + WorkerExecutionSpawnRequest { + worker_ref: worker_ref.clone(), + request: CreateWorkerRequest { + profile: ProfileSelector::RuntimeDefault, + config_bundle: ConfigBundleRef { + id: bundle.metadata.id.clone(), + digest: bundle.metadata.digest.clone(), + }, + initial_input: None, + working_directory_request: None, + working_directory: None, + }, + context: WorkerExecutionContext::new(worker_ref, Arc::new(|_, _| panic!("unused"))), + working_directory: None, + config_bundle: Some(bundle), + } + } + fn create_request(_name: &str) -> CreateWorkerRequest { let bundle = test_bundle(); CreateWorkerRequest { @@ -847,6 +1027,37 @@ mod tests { } } + #[tokio::test] + async fn cached_profile_archive_still_requires_backend_authorization() { + let archive = sample_profile_archive(); + let handle = handle_for_archive(&archive); + let call_count = Arc::new(AtomicUsize::new(0)); + let client = SequencedResourceClient { + responses: Arc::new(Mutex::new(VecDeque::from([ + Ok(response_for_archive(&handle, &archive)), + Err(crate::resource::BackendResourceError::Expired), + ]))), + call_count: call_count.clone(), + }; + let factory = ProfileRuntimeWorkerFactory::new(tempfile::tempdir().unwrap().path()) + .with_resource_client(Arc::new(client)); + let mut bundle = test_bundle(); + bundle.profile_source_archive_handle = Some(handle); + bundle = bundle.with_computed_digest(); + + factory + .resolve_profile_source_archive(&bundle, &spawn_request_with_bundle(bundle.clone())) + .await + .expect("first fetch should authorize and cache archive"); + let err = factory + .resolve_profile_source_archive(&bundle, &spawn_request_with_bundle(bundle.clone())) + .await + .unwrap_err(); + + assert!(err.contains("expired"), "unexpected error: {err}"); + assert_eq!(call_count.load(Ordering::SeqCst), 2); + } + #[test] fn builtin_profile_selector_is_not_double_prefixed() { assert_eq!( diff --git a/crates/workspace-server/src/companion.rs b/crates/workspace-server/src/companion.rs index 27a00ed4..74b1a0df 100644 --- a/crates/workspace-server/src/companion.rs +++ b/crates/workspace-server/src/companion.rs @@ -392,6 +392,7 @@ fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, + resolved_config_bundle: None, }, ); @@ -455,6 +456,7 @@ fn companion_config_bundle() -> ConfigBundle { }], declarations: Vec::new(), profile_source_archive: Some(companion_profile_archive()), + profile_source_archive_handle: None, } .with_computed_digest() } diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 290657d3..5334be9b 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -1,4 +1,5 @@ use crate::Error; +use crate::resource_broker::BackendResourceBroker; use chrono::Utc; use reqwest::StatusCode; use reqwest::blocking::{Client as BlockingHttpClient, RequestBuilder}; @@ -306,6 +307,8 @@ pub struct WorkerSpawnRequest { pub resolved_working_directory_request: Option, #[serde(skip, default)] pub resolved_working_directory: Option, + #[serde(skip, default)] + pub resolved_config_bundle: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -1032,6 +1035,7 @@ pub struct EmbeddedWorkerRuntime { host_id: String, runtime: worker_runtime::Runtime, execution_enabled: bool, + resource_broker: BackendResourceBroker, } fn embedded_runtime_options() -> EmbeddedRuntimeOptions { @@ -1082,6 +1086,11 @@ impl EmbeddedWorkerRuntime { Ok(embedded) } + pub fn with_resource_broker(mut self, resource_broker: BackendResourceBroker) -> Self { + self.resource_broker = resource_broker; + self + } + pub fn from_runtime(workspace_id: impl AsRef, runtime: worker_runtime::Runtime) -> Self { let runtime_id = runtime .runtime_id() @@ -1093,6 +1102,7 @@ impl EmbeddedWorkerRuntime { host_id: host_id_for_embedded_workspace(workspace_id.as_ref()), runtime, execution_enabled: false, + resource_broker: BackendResourceBroker::default(), } } @@ -1339,11 +1349,24 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { .profile .clone() .unwrap_or_else(|| embedded_profile_selector(&request.intent)); - let config_bundle = match default_embedded_config_bundle(&profile).and_then(|bundle| { - self.runtime - .store_config_bundle(bundle) - .map_err(|err| err.to_string()) - }) { + let runtime_id = EmbeddedRuntimeId::new(self.runtime_id.clone()); + let config_bundle = match request + .resolved_config_bundle + .clone() + .map(Ok) + .unwrap_or_else(|| { + default_embedded_config_bundle( + &profile, + &self.host_id, + runtime_id.as_ref(), + &self.resource_broker, + ) + }) + .and_then(|bundle| { + self.runtime + .store_config_bundle(bundle) + .map_err(|err| err.to_string()) + }) { Ok(availability) => availability.reference, Err(error) => { diagnostics.push(diagnostic( @@ -1717,6 +1740,7 @@ pub struct RemoteWorkerRuntime { cached_capabilities: RuntimeCapabilitySummary, cached_status: String, host_id: String, + resource_broker: BackendResourceBroker, http: BlockingHttpClient, } @@ -1740,10 +1764,16 @@ impl RemoteWorkerRuntime { bearer_token: config.bearer_token, cached_capabilities: config.cached_capabilities, cached_status: config.cached_status, + resource_broker: BackendResourceBroker::default(), http, }) } + pub fn with_resource_broker(mut self, resource_broker: BackendResourceBroker) -> Self { + self.resource_broker = resource_broker; + self + } + fn endpoint(&self, path: &str) -> String { format!("{}{}", self.base_url, path) } @@ -2025,7 +2055,19 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { .profile .clone() .unwrap_or_else(|| embedded_profile_selector(&request.intent)); - let sync = match default_embedded_config_bundle(&profile) { + let runtime_id = EmbeddedRuntimeId::new(self.runtime_id.clone()); + let sync = match request + .resolved_config_bundle + .clone() + .map(Ok) + .unwrap_or_else(|| { + default_embedded_config_bundle( + &profile, + &self.host_id, + runtime_id.as_ref(), + &self.resource_broker, + ) + }) { Ok(bundle) => self.sync_config_bundle(bundle), Err(error) => ConfigBundleSyncResult { state: WorkerOperationState::Rejected, @@ -2365,19 +2407,31 @@ fn embedded_worker_execution_status_label( } } -fn default_embedded_config_bundle(profile: &ProfileSelector) -> Result { +fn default_embedded_config_bundle( + profile: &ProfileSelector, + workspace_id: &str, + runtime_id: Option<&EmbeddedRuntimeId>, + resource_broker: &BackendResourceBroker, +) -> Result { let id = format!( "workspace-runtime-{}", embedded_profile_label(profile) .unwrap_or_else(|| "default".to_string()) .replace([':', '/', ' '], "-") ); + let archive = default_profile_source_archive(profile)?; + let handle = resource_broker.issue_profile_source_archive_handle( + workspace_id.to_string(), + runtime_id, + None, + archive, + ); Ok(ConfigBundle { metadata: ConfigBundleMetadata { id, digest: String::new(), revision: "workspace-runtime-v0".to_string(), - workspace_id: "workspace-server".to_string(), + workspace_id: workspace_id.to_string(), created_at: "runtime-generated".to_string(), provenance: ConfigBundleProvenance { source: "workspace-server".to_string(), @@ -2389,7 +2443,8 @@ fn default_embedded_config_bundle(profile: &ProfileSelector) -> Result "default".to_string(), ProfileSelector::Builtin(name) => name.clone(), @@ -2996,14 +3070,25 @@ mod tests { #[test] fn embedded_archive_rejects_unknown_selectors() { + let broker = BackendResourceBroker::default(); + let runtime_id = EmbeddedRuntimeId::new("runtime-test".to_string()).unwrap(); assert!( - default_embedded_config_bundle(&ProfileSelector::Builtin( - "builtin:missing".to_string() - )) + default_embedded_config_bundle( + &ProfileSelector::Builtin("builtin:missing".to_string()), + "workspace-test", + Some(&runtime_id), + &broker, + ) .is_err() ); assert!( - default_embedded_config_bundle(&ProfileSelector::Named("custom".to_string())).is_err() + default_embedded_config_bundle( + &ProfileSelector::Named("custom".to_string()), + "workspace-test", + Some(&runtime_id), + &broker, + ) + .is_err() ); } @@ -3030,6 +3115,7 @@ mod tests { reference: "capability:read".to_string(), }], profile_source_archive: None, + profile_source_archive_handle: None, } .with_computed_digest() } @@ -3326,6 +3412,7 @@ mod tests { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, + resolved_config_bundle: None, } } @@ -3458,6 +3545,7 @@ mod tests { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, + resolved_config_bundle: None, }, ) .unwrap(); @@ -3560,6 +3648,7 @@ mod tests { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, + resolved_config_bundle: None, }, ) .unwrap(); @@ -3591,6 +3680,7 @@ mod tests { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, + resolved_config_bundle: None, }, ) .unwrap(); diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index d279a8d2..5c8b11b2 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -9,8 +9,10 @@ pub mod config; pub mod hosts; pub mod identity; pub mod observation; +pub mod profile_settings; pub mod records; pub mod repositories; +pub mod resource_broker; pub mod server; pub mod store; diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs new file mode 100644 index 00000000..f1113064 --- /dev/null +++ b/crates/workspace-server/src/profile_settings.rs @@ -0,0 +1,1478 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::time::UNIX_EPOCH; + +use serde::{Deserialize, Serialize}; +use worker_runtime::config_bundle::{ + ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor, +}; +use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput}; + +use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic}; +use crate::{Error, Result}; + +const PROFILE_REGISTRY_RELATIVE_PATH: &str = ".yoi/profiles.toml"; +const PROFILE_SOURCE_ROOT_RELATIVE_PATH: &str = ".yoi/profiles"; +const MAX_PROFILE_SOURCE_BYTES: u64 = 256 * 1024; +const BUILTIN_PROFILE_IDS: &[&str] = &[ + "builtin:default", + "builtin:companion", + "builtin:intake", + "builtin:orchestrator", + "builtin:coder", + "builtin:reviewer", +]; +const BUILTIN_PROFILE_SLUGS: &[&str] = &[ + "default", + "companion", + "intake", + "orchestrator", + "coder", + "reviewer", +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceMetadataSettingsResponse { + pub workspace_id: String, + pub display_name: String, + pub created_at: String, + pub revision: String, + pub source: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpdateWorkspaceMetadataRequest { + pub display_name: String, + pub revision: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceMetadataMutationResponse { + pub workspace: WorkspaceMetadataSettingsResponse, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfileSettingsResponse { + pub workspace_id: String, + pub registry_revision: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_profile: Option, + pub profiles: Vec, + pub sources: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceProfileSummary { + pub profile_id: String, + pub selector: String, + pub label: String, + pub source_kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_source_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub editable: bool, + pub is_default: bool, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceProfileSourceSummary { + pub profile_source_id: String, + pub display_path: String, + pub kind: String, + pub editable: bool, + pub revision: String, + pub size_bytes: u64, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceProfileSourceDetailResponse { + pub workspace_id: String, + pub profile: WorkspaceProfileSummary, + pub source: WorkspaceProfileSourceSummary, + pub content: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreateWorkspaceProfileSourceRequest { + pub name: String, + #[serde(default)] + pub description: Option, + pub content: String, + pub registry_revision: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpdateWorkspaceProfileRegistryRequest { + pub registry_revision: String, + #[serde(default)] + pub default_profile: Option, + pub profiles: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceProfileRegistryEntryUpdate { + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub profile_source_id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpdateWorkspaceProfileSourceRequest { + pub content: String, + pub revision: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DeleteWorkspaceProfileSourceRequest { + pub registry_revision: String, + pub source_revision: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfileSettingsMutationResponse { + pub workspace_id: String, + pub settings: ProfileSettingsResponse, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct WorkspaceIdentityFile { + workspace_id: String, + created_at: String, + display_name: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProfileRegistryDocument { + #[serde(default)] + default: Option, + #[serde(default)] + profile: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +enum ProfileEntryFile { + Path(String), + Table(ProfileEntryTable), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProfileEntryTable { + path: String, + #[serde(default)] + description: Option, +} + +#[derive(Debug, Clone)] +struct ProjectProfileEntry { + name: String, + description: Option, + relative_path: PathBuf, +} + +pub fn workspace_metadata_settings( + workspace_root: &Path, + fallback_workspace_id: &str, + fallback_created_at: &str, + fallback_display_name: &str, +) -> WorkspaceMetadataSettingsResponse { + let path = workspace_root.join(crate::identity::WORKSPACE_IDENTITY_RELATIVE_PATH); + let mut diagnostics = Vec::new(); + let (workspace_id, created_at, display_name) = match fs::read_to_string(&path) { + Ok(raw) => match toml::from_str::(&raw) { + Ok(file) => (file.workspace_id, file.created_at, file.display_name), + Err(err) => { + diagnostics.push(diagnostic( + "workspace_identity_parse_failed", + DiagnosticSeverity::Error, + format!("Workspace identity could not be parsed: {err}"), + )); + ( + fallback_workspace_id.to_string(), + fallback_created_at.to_string(), + fallback_display_name.to_string(), + ) + } + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + diagnostics.push(diagnostic( + "workspace_identity_missing", + DiagnosticSeverity::Warning, + "Workspace identity record is missing; showing active backend metadata.", + )); + ( + fallback_workspace_id.to_string(), + fallback_created_at.to_string(), + fallback_display_name.to_string(), + ) + } + Err(err) => { + diagnostics.push(diagnostic( + "workspace_identity_read_failed", + DiagnosticSeverity::Error, + format!( + "Workspace identity could not be read: {}", + sanitize_error(&err.to_string()) + ), + )); + ( + fallback_workspace_id.to_string(), + fallback_created_at.to_string(), + fallback_display_name.to_string(), + ) + } + }; + WorkspaceMetadataSettingsResponse { + workspace_id, + display_name, + created_at, + revision: file_revision(&path), + source: "workspace_identity".to_string(), + diagnostics, + } +} + +pub fn update_workspace_metadata( + workspace_root: &Path, + request: UpdateWorkspaceMetadataRequest, +) -> Result { + let path = workspace_root.join(crate::identity::WORKSPACE_IDENTITY_RELATIVE_PATH); + let current_revision = file_revision(&path); + if request.revision != current_revision { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "workspace_metadata_revision_conflict".to_string(), + message: "Workspace metadata changed before this update was applied".to_string(), + }); + } + let raw = fs::read_to_string(&path)?; + let mut file: WorkspaceIdentityFile = toml::from_str(&raw) + .map_err(|err| Error::Config(format!("failed to parse workspace identity: {err}")))?; + let display_name = sanitize_display_name(&request.display_name)?; + file.display_name = display_name; + let encoded = toml::to_string_pretty(&file) + .map_err(|err| Error::Config(format!("failed to serialize workspace identity: {err}")))?; + fs::write(&path, encoded)?; + Ok(workspace_metadata_settings( + workspace_root, + &file.workspace_id, + &file.created_at, + &file.display_name, + )) +} + +pub fn load_profile_settings(workspace_id: &str, workspace_root: &Path) -> ProfileSettingsResponse { + let mut diagnostics = Vec::new(); + let registry = match read_registry(workspace_root) { + Ok(registry) => registry, + Err(err) => { + diagnostics.push(diagnostic( + "profile_registry_schema_invalid", + DiagnosticSeverity::Error, + err, + )); + ProfileRegistryDocument::default() + } + }; + let registry_revision = file_revision(®istry_path(workspace_root)); + let mut profiles = builtin_profile_summaries(registry.default.as_deref()); + let mut sources = Vec::new(); + let mut seen_selectors = BTreeSet::new(); + for profile in &profiles { + seen_selectors.insert(profile.selector.clone()); + } + for entry in project_entries(®istry, &mut diagnostics) { + let selector = project_selector(&entry.name); + let source_id = project_source_id(&entry.name); + let mut entry_diagnostics = Vec::new(); + if !seen_selectors.insert(selector.clone()) { + entry_diagnostics.push(diagnostic( + "profile_selector_duplicate", + DiagnosticSeverity::Error, + format!("Profile selector '{selector}' is duplicated."), + )); + } + let source_summary = summarize_source(workspace_root, &source_id, &entry.relative_path); + entry_diagnostics.extend(source_summary.diagnostics.clone()); + entry_diagnostics.extend(validate_project_profile_entry(workspace_root, &entry)); + if BUILTIN_PROFILE_SLUGS.contains(&entry.name.as_str()) { + entry_diagnostics.push(diagnostic( + "profile_selector_duplicate", + DiagnosticSeverity::Error, + format!( + "Project profile '{}' conflicts with a builtin selector.", + entry.name + ), + )); + } + profiles.push(WorkspaceProfileSummary { + profile_id: selector.clone(), + selector, + label: entry.name.clone(), + source_kind: "project".to_string(), + profile_source_id: Some(source_id.clone()), + description: entry.description.clone(), + editable: true, + is_default: registry.default.as_deref() == Some(project_selector(&entry.name).as_str()), + diagnostics: entry_diagnostics, + }); + sources.push(source_summary); + } + let mut default_diagnostics = validate_registry_default(®istry) + .err() + .map(|err| vec![diagnostic_from_error(&err)]) + .unwrap_or_default(); + diagnostics.extend( + profiles + .iter() + .filter(|profile| profile.source_kind == "project") + .flat_map(|profile| profile.diagnostics.clone()), + ); + diagnostics.append(&mut default_diagnostics); + ProfileSettingsResponse { + workspace_id: workspace_id.to_string(), + registry_revision, + default_profile: registry.default, + profiles, + sources, + diagnostics, + } +} + +pub fn read_profile_source( + workspace_id: &str, + workspace_root: &Path, + source_id: &str, +) -> Result { + let registry = read_registry(workspace_root).map_err(profile_registry_error)?; + let (name, entry) = entry_for_source_id(®istry, source_id)?; + let full = checked_source_path(workspace_root, &entry.relative_path)?; + let metadata = source_metadata(&full)?; + if metadata.len() > MAX_PROFILE_SOURCE_BYTES { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_too_large".to_string(), + message: "Profile source is too large for browser editing".to_string(), + }); + } + let content = fs::read_to_string(&full)?; + let source = summarize_source(workspace_root, source_id, &entry.relative_path); + let selector = project_selector(&name); + let profile = WorkspaceProfileSummary { + profile_id: selector.clone(), + selector: selector.clone(), + label: name, + source_kind: "project".to_string(), + profile_source_id: Some(source_id.to_string()), + description: entry.description, + editable: true, + is_default: registry.default.as_deref() == Some(selector.as_str()), + diagnostics: source.diagnostics.clone(), + }; + Ok(WorkspaceProfileSourceDetailResponse { + workspace_id: workspace_id.to_string(), + profile, + source, + content, + diagnostics: Vec::new(), + }) +} + +pub fn create_profile_source( + workspace_id: &str, + workspace_root: &Path, + request: CreateWorkspaceProfileSourceRequest, +) -> Result { + let registry_path = registry_path(workspace_root); + ensure_revision( + ®istry_path, + &request.registry_revision, + "profile_registry_revision_conflict", + )?; + let mut registry = read_registry(workspace_root).map_err(profile_registry_error)?; + let name = validate_profile_name(&request.name)?; + if registry.profile.contains_key(&name) || BUILTIN_PROFILE_SLUGS.contains(&name.as_str()) { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_selector_duplicate".to_string(), + message: "Profile selector already exists".to_string(), + }); + } + let relative_path = PathBuf::from(".yoi") + .join("profiles") + .join(format!("{name}.dcdl")); + validate_source_content(workspace_root, &name, &relative_path, &request.content)?; + let full = checked_source_path(workspace_root, &relative_path)?; + if let Some(parent) = full.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&full, request.content)?; + registry.profile.insert( + name.clone(), + ProfileEntryFile::Table(ProfileEntryTable { + path: format!("profiles/{name}.dcdl"), + description: request + .description + .and_then(|value| optional_trim(value.as_str())), + }), + ); + validate_registry_default(®istry)?; + validate_all_project_profiles(workspace_root, ®istry)?; + write_registry(workspace_root, ®istry)?; + Ok(ProfileSettingsMutationResponse { + workspace_id: workspace_id.to_string(), + settings: load_profile_settings(workspace_id, workspace_root), + diagnostics: vec![diagnostic( + "profile_settings_updated", + DiagnosticSeverity::Info, + "Profile source was created and profile discovery was refreshed.", + )], + }) +} + +pub fn update_profile_registry( + workspace_id: &str, + workspace_root: &Path, + request: UpdateWorkspaceProfileRegistryRequest, +) -> Result { + let path = registry_path(workspace_root); + ensure_revision( + &path, + &request.registry_revision, + "profile_registry_revision_conflict", + )?; + let mut profile = BTreeMap::new(); + let mut seen = BTreeSet::new(); + for update in request.profiles { + let name = validate_profile_name(&update.name)?; + if !seen.insert(name.clone()) || BUILTIN_PROFILE_SLUGS.contains(&name.as_str()) { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_selector_duplicate".to_string(), + message: "Profile selector duplicate in registry update".to_string(), + }); + } + let source_id = update + .profile_source_id + .as_deref() + .unwrap_or(project_source_id(&name).as_str()) + .to_string(); + let (source_name, _) = parse_project_source_id(&source_id)?; + if source_name != name { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_id_mismatch".to_string(), + message: "Profile source id must match its registry selector".to_string(), + }); + } + profile.insert( + name.clone(), + ProfileEntryFile::Table(ProfileEntryTable { + path: format!("profiles/{name}.dcdl"), + description: update + .description + .and_then(|value| optional_trim(value.as_str())), + }), + ); + } + let registry = ProfileRegistryDocument { + default: request.default_profile, + profile, + }; + validate_registry_default(®istry)?; + validate_all_project_profiles(workspace_root, ®istry)?; + write_registry(workspace_root, ®istry)?; + Ok(ProfileSettingsMutationResponse { + workspace_id: workspace_id.to_string(), + settings: load_profile_settings(workspace_id, workspace_root), + diagnostics: vec![diagnostic( + "profile_registry_updated", + DiagnosticSeverity::Info, + "Profile registry was updated and profile discovery was refreshed.", + )], + }) +} + +pub fn update_profile_source( + workspace_id: &str, + workspace_root: &Path, + source_id: &str, + request: UpdateWorkspaceProfileSourceRequest, +) -> Result { + let registry = read_registry(workspace_root).map_err(profile_registry_error)?; + let (name, entry) = entry_for_source_id(®istry, source_id)?; + let full = checked_source_path(workspace_root, &entry.relative_path)?; + ensure_revision(&full, &request.revision, "profile_source_revision_conflict")?; + validate_source_content( + workspace_root, + &name, + &entry.relative_path, + &request.content, + )?; + fs::write(&full, request.content)?; + Ok(ProfileSettingsMutationResponse { + workspace_id: workspace_id.to_string(), + settings: load_profile_settings(workspace_id, workspace_root), + diagnostics: vec![diagnostic( + "profile_source_updated", + DiagnosticSeverity::Info, + "Profile source was updated and profile discovery was refreshed.", + )], + }) +} + +pub fn delete_profile_source( + workspace_id: &str, + workspace_root: &Path, + source_id: &str, + request: DeleteWorkspaceProfileSourceRequest, +) -> Result { + let registry_path = registry_path(workspace_root); + ensure_revision( + ®istry_path, + &request.registry_revision, + "profile_registry_revision_conflict", + )?; + let mut registry = read_registry(workspace_root).map_err(profile_registry_error)?; + let (name, entry) = entry_for_source_id(®istry, source_id)?; + let full = checked_source_path(workspace_root, &entry.relative_path)?; + ensure_revision( + &full, + &request.source_revision, + "profile_source_revision_conflict", + )?; + registry.profile.remove(&name); + if registry.default.as_deref() == Some(project_selector(&name).as_str()) { + registry.default = None; + } + if full.exists() { + fs::remove_file(&full)?; + } + write_registry(workspace_root, ®istry)?; + Ok(ProfileSettingsMutationResponse { + workspace_id: workspace_id.to_string(), + settings: load_profile_settings(workspace_id, workspace_root), + diagnostics: vec![diagnostic( + "profile_source_deleted", + DiagnosticSeverity::Info, + "Profile source and registry entry were deleted and profile discovery was refreshed.", + )], + }) +} + +pub fn build_workspace_profile_archive( + workspace_root: &Path, + selector: &str, +) -> Result> { + if !selector.starts_with("project:") { + return Ok(None); + } + let registry = read_registry(workspace_root).map_err(profile_registry_error)?; + let mut entrypoints = BTreeMap::new(); + let mut sources = BTreeMap::new(); + for entry in project_entries(®istry, &mut Vec::new()) { + let path = archive_path_for_entry(&entry.name); + let full = checked_source_path(workspace_root, &entry.relative_path)?; + let content = fs::read_to_string(&full)?; + sources.insert(path.clone(), content); + entrypoints.insert(project_selector(&entry.name), path); + } + if !entrypoints.contains_key(selector) { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "unknown_profile_selector".to_string(), + message: "Selected project profile is not present in the workspace profile registry" + .to_string(), + }); + } + if let Some(default) = registry + .default + .as_deref() + .filter(|value| value.starts_with("project:")) + { + if let Some(path) = entrypoints.get(default).cloned() { + entrypoints.insert("default".to_string(), path); + } + } + let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput { + id: "workspace-project-decodal-profiles-v1".to_string(), + entrypoints, + imports: BTreeMap::new(), + sources, + }) + .map_err(|err| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_archive_invalid".to_string(), + message: err.to_string(), + })?; + archive + .verify() + .and_then(|verified| { + verified + .resolve_profile(selector, workspace_root, "workspace-settings-validation") + .map(|_| ()) + }) + .map_err(|err| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_invalid".to_string(), + message: err.to_string(), + })?; + Ok(Some(archive)) +} + +pub fn build_workspace_profile_config_bundle( + workspace_root: &Path, + workspace_id: &str, + workspace_created_at: &str, + selector: &str, +) -> Result> { + let Some(archive) = build_workspace_profile_archive(workspace_root, selector)? else { + return Ok(None); + }; + let bundle = ConfigBundle { + metadata: ConfigBundleMetadata { + id: "workspace-project-profile-settings-v1".to_string(), + digest: String::new(), + revision: file_revision(®istry_path(workspace_root)), + workspace_id: workspace_id.to_string(), + created_at: workspace_created_at.to_string(), + provenance: ConfigBundleProvenance { + source: "workspace_profile_settings".to_string(), + detail: Some("workspace Decodal profile registry".to_string()), + }, + }, + profiles: vec![ConfigProfileDescriptor { + selector: worker_runtime::catalog::ProfileSelector::Named(selector.to_string()), + label: Some(selector.to_string()), + }], + declarations: Vec::new(), + profile_source_archive: Some(archive), + profile_source_archive_handle: None, + } + .with_computed_digest(); + Ok(Some(bundle)) +} + +pub fn project_profile_candidates(workspace_root: &Path) -> Vec { + load_profile_settings("workspace", workspace_root) + .profiles + .into_iter() + .filter(|profile| profile.source_kind == "project" && !has_error(&profile.diagnostics)) + .collect() +} + +pub fn is_profile_candidate(workspace_root: &Path, profile_id: &str) -> bool { + BUILTIN_PROFILE_IDS.contains(&profile_id) + || profile_id == "runtime_default" + || project_profile_candidates(workspace_root) + .into_iter() + .any(|profile| profile.profile_id == profile_id) +} + +fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec { + let labels = [ + ("builtin:default", "Default", "Bundled default Yoi profile"), + ( + "builtin:companion", + "Companion", + "Bundled Companion role profile", + ), + ("builtin:intake", "Intake", "Bundled Intake role profile"), + ( + "builtin:orchestrator", + "Orchestrator", + "Bundled Orchestrator role profile", + ), + ("builtin:coder", "Coder", "Bundled Coder role profile"), + ( + "builtin:reviewer", + "Reviewer", + "Bundled Reviewer role profile", + ), + ]; + labels + .into_iter() + .map(|(id, label, description)| WorkspaceProfileSummary { + profile_id: id.to_string(), + selector: id.to_string(), + label: label.to_string(), + source_kind: "builtin".to_string(), + profile_source_id: None, + description: Some(description.to_string()), + editable: false, + is_default: default_profile == Some(id), + diagnostics: Vec::new(), + }) + .collect() +} + +fn validate_project_profile_entry( + workspace_root: &Path, + entry: &ProjectProfileEntry, +) -> Vec { + let full = match checked_source_path(workspace_root, &entry.relative_path) { + Ok(path) => path, + Err(err) => return vec![diagnostic_from_error(&err)], + }; + match fs::read_to_string(&full) { + Ok(content) => { + validate_source_content(workspace_root, &entry.name, &entry.relative_path, &content) + .err() + .map(|err| vec![diagnostic_from_error(&err)]) + .unwrap_or_default() + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => vec![diagnostic( + "profile_source_missing", + DiagnosticSeverity::Error, + format!("Profile source '{}' is missing.", entry.name), + )], + Err(err) => vec![diagnostic( + "profile_source_read_failed", + DiagnosticSeverity::Error, + sanitize_error(&err.to_string()), + )], + } +} + +fn diagnostic_from_error(err: &Error) -> RuntimeDiagnostic { + match err { + Error::RuntimeOperationFailed { code, message, .. } => diagnostic( + code.clone(), + DiagnosticSeverity::Error, + sanitize_error(message), + ), + other => diagnostic( + "profile_settings_failed", + DiagnosticSeverity::Error, + sanitize_error(&other.to_string()), + ), + } +} + +fn has_error(diagnostics: &[RuntimeDiagnostic]) -> bool { + diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) +} + +fn validate_all_project_profiles( + workspace_root: &Path, + registry: &ProfileRegistryDocument, +) -> Result<()> { + let mut diagnostics = Vec::new(); + let entries = project_entries(registry, &mut diagnostics); + if let Some(diagnostic) = diagnostics + .into_iter() + .find(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) + { + return Err(profile_validation_error( + diagnostic.code, + diagnostic.message, + )); + } + for entry in entries { + if BUILTIN_PROFILE_SLUGS.contains(&entry.name.as_str()) { + return Err(profile_validation_error( + "profile_selector_duplicate", + "Project profile conflicts with a builtin selector", + )); + } + if let Some(diagnostic) = validate_project_profile_entry(workspace_root, &entry) + .into_iter() + .find(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) + { + return Err(profile_validation_error( + diagnostic.code, + diagnostic.message, + )); + } + } + Ok(()) +} + +fn profile_validation_error(code: impl Into, message: impl Into) -> Error { + Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: code.into(), + message: message.into(), + } +} + +fn profile_registry_error(message: String) -> Error { + profile_validation_error("profile_registry_schema_invalid", sanitize_error(&message)) +} + +fn validate_registry_default(registry: &ProfileRegistryDocument) -> Result<()> { + let Some(value) = registry.default.as_deref() else { + return Ok(()); + }; + if BUILTIN_PROFILE_IDS.contains(&value) { + return Ok(()); + } + if let Some(name) = value.strip_prefix("project:") { + let name = validate_profile_name(name)?; + if registry.profile.contains_key(&name) { + return Ok(()); + } + return Err(profile_validation_error( + "profile_default_unknown", + "Default project profile selector is not present in the workspace profile registry", + )); + } + Err(profile_validation_error( + "profile_default_invalid", + "Default profile must be a Backend-published builtin or project selector", + )) +} + +fn validate_source_content( + workspace_root: &Path, + name: &str, + relative_path: &Path, + content: &str, +) -> Result<()> { + if content.as_bytes().len() as u64 > MAX_PROFILE_SOURCE_BYTES { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_too_large".to_string(), + message: "Profile source exceeds the browser editing size limit".to_string(), + }); + } + let archive_path = archive_path_for_entry(name); + let selector = project_selector(name); + let mut entrypoints = BTreeMap::new(); + entrypoints.insert(selector.clone(), archive_path.clone()); + entrypoints.insert("default".to_string(), archive_path.clone()); + let mut sources = BTreeMap::new(); + sources.insert(archive_path, content.to_string()); + let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput { + id: format!("workspace-profile-validation-{name}"), + entrypoints, + imports: BTreeMap::new(), + sources, + }) + .map_err(|err| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_archive_invalid".to_string(), + message: err.to_string(), + })?; + let verified = archive + .verify() + .map_err(|err| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_archive_invalid".to_string(), + message: err.to_string(), + })?; + checked_source_path(workspace_root, relative_path)?; + verified + .resolve_profile(&selector, workspace_root, "workspace-settings-validation") + .map_err(|err| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_syntax_invalid".to_string(), + message: err.to_string(), + })?; + Ok(()) +} + +fn summarize_source( + workspace_root: &Path, + source_id: &str, + relative_path: &Path, +) -> WorkspaceProfileSourceSummary { + let mut diagnostics = Vec::new(); + let display_path = display_source_path(relative_path); + let mut revision = "missing".to_string(); + let mut size_bytes = 0; + match checked_source_path(workspace_root, relative_path) { + Ok(full) => match source_metadata(&full) { + Ok(metadata) => { + size_bytes = metadata.len(); + revision = file_revision(&full); + if size_bytes > MAX_PROFILE_SOURCE_BYTES { + diagnostics.push(diagnostic( + "profile_source_too_large", + DiagnosticSeverity::Error, + "Profile source is too large for browser editing.", + )); + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => diagnostics.push(diagnostic( + "profile_source_missing", + DiagnosticSeverity::Error, + "Profile source file is missing.", + )), + Err(err) => diagnostics.push(diagnostic( + "profile_source_metadata_failed", + DiagnosticSeverity::Error, + sanitize_error(&err.to_string()), + )), + }, + Err(err) => diagnostics.push(diagnostic( + "profile_source_path_escape", + DiagnosticSeverity::Error, + sanitize_error(&err.to_string()), + )), + } + WorkspaceProfileSourceSummary { + profile_source_id: source_id.to_string(), + display_path, + kind: "decodal".to_string(), + editable: diagnostics + .iter() + .all(|d| d.severity != DiagnosticSeverity::Error), + revision, + size_bytes, + diagnostics, + } +} + +fn read_registry(workspace_root: &Path) -> std::result::Result { + let path = registry_path(workspace_root); + match fs::read_to_string(&path) { + Ok(raw) => { + toml::from_str(&raw).map_err(|err| format!("invalid profile registry schema: {err}")) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + Ok(ProfileRegistryDocument::default()) + } + Err(err) => Err(format!( + "failed to read profile registry: {}", + sanitize_error(&err.to_string()) + )), + } +} + +fn write_registry(workspace_root: &Path, registry: &ProfileRegistryDocument) -> Result<()> { + let path = registry_path(workspace_root); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let raw = toml::to_string_pretty(registry) + .map_err(|err| Error::Config(format!("failed to serialize profile registry: {err}")))?; + fs::write(path, raw)?; + Ok(()) +} + +fn project_entries( + registry: &ProfileRegistryDocument, + diagnostics: &mut Vec, +) -> Vec { + registry + .profile + .iter() + .filter_map(|(name, entry)| match validate_profile_name(name) { + Ok(name) => { + if BUILTIN_PROFILE_SLUGS.contains(&name.as_str()) { + diagnostics.push(diagnostic( + "profile_selector_duplicate", + DiagnosticSeverity::Error, + format!("Project profile '{name}' conflicts with a builtin selector."), + )); + } + let (path, description) = match entry { + ProfileEntryFile::Path(path) => (path.clone(), None), + ProfileEntryFile::Table(table) => { + (table.path.clone(), table.description.clone()) + } + }; + match registry_relative_source_path(&path) { + Ok(relative_path) => Some(ProjectProfileEntry { + name, + description, + relative_path, + }), + Err(err) => { + diagnostics.push(diagnostic( + "profile_source_path_escape", + DiagnosticSeverity::Error, + err, + )); + None + } + } + } + Err(err) => { + diagnostics.push(diagnostic( + "profile_selector_invalid", + DiagnosticSeverity::Error, + err.to_string(), + )); + None + } + }) + .collect() +} + +fn entry_for_source_id( + registry: &ProfileRegistryDocument, + source_id: &str, +) -> Result<(String, ProjectProfileEntry)> { + let (name, _) = parse_project_source_id(source_id)?; + let mut diagnostics = Vec::new(); + let entry = project_entries(registry, &mut diagnostics) + .into_iter() + .find(|entry| entry.name == name) + .ok_or_else(|| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "unknown_profile_source".to_string(), + message: "Unknown profile source id".to_string(), + })?; + Ok((name, entry)) +} + +fn registry_relative_source_path(raw: &str) -> std::result::Result { + let path = Path::new(raw); + if path.is_absolute() { + return Err("Profile source path must be workspace-relative and safe.".to_string()); + } + let path = if path.starts_with(".yoi") { + path.to_path_buf() + } else { + PathBuf::from(".yoi").join(path) + }; + validate_relative_source_path(&path)?; + Ok(path) +} + +fn validate_relative_source_path(path: &Path) -> std::result::Result<(), String> { + if !path.starts_with(PROFILE_SOURCE_ROOT_RELATIVE_PATH) { + return Err( + "Profile source path must be under the workspace profile source root.".to_string(), + ); + } + for component in path.components() { + if !matches!(component, Component::Normal(_)) { + return Err( + "Profile source path must not contain absolute, parent, or prefix components." + .to_string(), + ); + } + } + if path.extension().and_then(|value| value.to_str()) != Some("dcdl") { + return Err("Profile source path must use the .dcdl extension.".to_string()); + } + Ok(()) +} + +fn checked_source_path(workspace_root: &Path, relative_path: &Path) -> Result { + validate_relative_source_path(relative_path).map_err(|message| { + Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_path_escape".to_string(), + message, + } + })?; + let source_root = workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH); + fs::create_dir_all(&source_root)?; + let canonical_root = fs::canonicalize(&source_root)?; + let full = workspace_root.join(relative_path); + if let Ok(canonical_full) = fs::canonicalize(&full) { + if !canonical_full.starts_with(&canonical_root) { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_symlink_escape".to_string(), + message: "Profile source resolves outside the workspace profile source root" + .to_string(), + }); + } + } else if let Some(parent) = full.parent() { + let canonical_parent = fs::canonicalize(parent)?; + if !canonical_parent.starts_with(&canonical_root) { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_path_escape".to_string(), + message: "Profile source parent resolves outside the workspace profile source root" + .to_string(), + }); + } + } + Ok(full) +} + +fn validate_profile_name(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() + || trimmed.len() > 64 + || !trimmed + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) + { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_selector_invalid".to_string(), + message: "Profile selector must contain only ASCII letters, digits, '-' or '_'" + .to_string(), + }); + } + Ok(trimmed.to_string()) +} + +fn sanitize_display_name(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.chars().any(char::is_control) || trimmed.len() > 120 { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "workspace_display_name_invalid".to_string(), + message: "Workspace display name must be non-empty, bounded, and must not contain control characters".to_string(), + }); + } + Ok(trimmed.to_string()) +} + +fn parse_project_source_id(source_id: &str) -> Result<(String, String)> { + let Some(name) = source_id.strip_prefix("project:") else { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "unsupported_profile_source_id".to_string(), + message: "Profile source id is not a project profile source".to_string(), + }); + }; + let name = validate_profile_name(name)?; + Ok((name.clone(), project_source_id(&name))) +} + +pub fn project_selector(name: &str) -> String { + format!("project:{name}") +} + +pub fn project_source_id(name: &str) -> String { + format!("project:{name}") +} + +pub fn selector_for_builtin_candidate( + id: &str, +) -> Option { + match id { + "runtime_default" => Some(worker_runtime::catalog::ProfileSelector::RuntimeDefault), + "builtin:default" + | "builtin:companion" + | "builtin:intake" + | "builtin:orchestrator" + | "builtin:coder" + | "builtin:reviewer" => Some(worker_runtime::catalog::ProfileSelector::Builtin( + id.to_string(), + )), + value if value.starts_with("project:") => Some( + worker_runtime::catalog::ProfileSelector::Named(value.to_string()), + ), + _ => None, + } +} + +fn archive_path_for_entry(name: &str) -> String { + format!("profiles/{name}.dcdl") +} + +fn display_source_path(relative_path: &Path) -> String { + relative_path + .strip_prefix(".yoi") + .unwrap_or(relative_path) + .to_string_lossy() + .replace('\\', "/") +} + +fn registry_path(workspace_root: &Path) -> PathBuf { + workspace_root.join(PROFILE_REGISTRY_RELATIVE_PATH) +} + +fn file_revision(path: &Path) -> String { + let Ok(metadata) = fs::metadata(path) else { + return "missing".to_string(); + }; + let modified = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + format!("rev:{modified}:{}", metadata.len()) +} + +fn source_metadata(path: &Path) -> std::io::Result { + fs::symlink_metadata(path) +} + +fn ensure_revision(path: &Path, expected: &str, code: &'static str) -> Result<()> { + let actual = file_revision(path); + if expected != actual { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: code.to_string(), + message: "Settings changed before this update was applied".to_string(), + }); + } + Ok(()) +} + +fn optional_trim(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn diagnostic( + code: impl Into, + severity: DiagnosticSeverity, + message: impl Into, +) -> RuntimeDiagnostic { + RuntimeDiagnostic { + code: code.into(), + severity, + message: message.into(), + } +} + +fn sanitize_error(value: &str) -> String { + value + .split_whitespace() + .map(|token| { + if token.starts_with('/') || token.contains("/.yoi/") || token.contains(".yoi/sessions") + { + "" + } else { + token + } + }) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_decodal(slug: &str) -> String { + format!( + r#"{{ + slug = "{slug}"; + description = "Test"; + scope = "workspace_read"; + }}"# + ) + } + + #[test] + fn profile_settings_create_update_and_discover_project_profile() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); + let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); + let created = create_profile_source( + "workspace-test", + dir.path(), + CreateWorkspaceProfileSourceRequest { + name: "alpha".to_string(), + description: Some("Alpha".to_string()), + content: valid_decodal("alpha"), + registry_revision: revision, + }, + ) + .unwrap(); + assert!( + created + .settings + .profiles + .iter() + .any(|profile| profile.profile_id == "project:alpha") + ); + assert!( + build_workspace_profile_archive(dir.path(), "project:alpha") + .unwrap() + .is_some() + ); + } + + #[test] + fn profile_source_rejects_path_escape_and_revision_conflict() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write( + dir.path().join(".yoi/profiles.toml"), + "[profile.bad]\npath = \"../bad.dcdl\"\n", + ) + .unwrap(); + let settings = load_profile_settings("workspace-test", dir.path()); + assert!( + settings + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "profile_source_path_escape") + ); + + let err = update_profile_registry( + "workspace-test", + dir.path(), + UpdateWorkspaceProfileRegistryRequest { + registry_revision: "stale".to_string(), + default_profile: None, + profiles: Vec::new(), + }, + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("profile_registry_revision_conflict") + ); + } + + #[test] + fn profile_source_rejects_invalid_decodal() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); + let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); + let err = create_profile_source( + "workspace-test", + dir.path(), + CreateWorkspaceProfileSourceRequest { + name: "bad".to_string(), + description: None, + content: "not decodal".to_string(), + registry_revision: revision, + }, + ) + .unwrap_err(); + assert!( + err.to_string().contains("profile_source_syntax_invalid") + || err.to_string().contains("profile_source_archive_invalid") + ); + } + + #[test] + fn invalid_project_profile_is_not_a_launch_candidate() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); + fs::write( + dir.path().join(".yoi/profiles.toml"), + "[profile.bad]\npath = \"profiles/bad.dcdl\"\n", + ) + .unwrap(); + fs::write(dir.path().join(".yoi/profiles/bad.dcdl"), "not decodal").unwrap(); + + let settings = load_profile_settings("workspace-test", dir.path()); + let bad = settings + .profiles + .iter() + .find(|profile| profile.profile_id == "project:bad") + .expect("bad profile summary is still visible for repair"); + assert!( + bad.diagnostics + .iter() + .any(|diagnostic| diagnostic.code.starts_with("profile_source_")) + ); + assert!(project_profile_candidates(dir.path()).is_empty()); + assert!(!is_profile_candidate(dir.path(), "project:bad")); + } + + #[test] + fn registry_update_rejects_missing_source_and_unknown_default() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); + let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); + + let err = update_profile_registry( + "workspace-test", + dir.path(), + UpdateWorkspaceProfileRegistryRequest { + registry_revision: revision.clone(), + default_profile: Some("project:missing".to_string()), + profiles: Vec::new(), + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("profile_default_unknown")); + + let err = update_profile_registry( + "workspace-test", + dir.path(), + UpdateWorkspaceProfileRegistryRequest { + registry_revision: revision, + default_profile: None, + profiles: vec![WorkspaceProfileRegistryEntryUpdate { + name: "missing".to_string(), + description: None, + profile_source_id: None, + }], + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("profile_source_missing")); + assert_eq!( + fs::read_to_string(dir.path().join(".yoi/profiles.toml")).unwrap(), + "" + ); + } + + #[test] + fn profile_source_rejects_too_large_content() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); + let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); + let err = create_profile_source( + "workspace-test", + dir.path(), + CreateWorkspaceProfileSourceRequest { + name: "large".to_string(), + description: None, + content: "x".repeat((MAX_PROFILE_SOURCE_BYTES + 1) as usize), + registry_revision: revision, + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("profile_source_too_large")); + } + + #[cfg(unix)] + #[test] + fn registry_update_rejects_symlink_escape() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); + let outside = dir.path().join("outside.dcdl"); + fs::write(&outside, valid_decodal("escape")).unwrap(); + std::os::unix::fs::symlink(&outside, dir.path().join(".yoi/profiles/escape.dcdl")).unwrap(); + let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); + let err = update_profile_registry( + "workspace-test", + dir.path(), + UpdateWorkspaceProfileRegistryRequest { + registry_revision: revision, + default_profile: None, + profiles: vec![WorkspaceProfileRegistryEntryUpdate { + name: "escape".to_string(), + description: None, + profile_source_id: None, + }], + }, + ) + .unwrap_err(); + let rendered = err.to_string(); + assert!(rendered.contains("profile_source_symlink_escape")); + assert!(!rendered.contains(dir.path().to_string_lossy().as_ref())); + } +} diff --git a/crates/workspace-server/src/resource_broker.rs b/crates/workspace-server/src/resource_broker.rs new file mode 100644 index 00000000..a332d293 --- /dev/null +++ b/crates/workspace-server/src/resource_broker.rs @@ -0,0 +1,336 @@ +use async_trait::async_trait; +use chrono::{Duration, Utc}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use uuid::Uuid; +use worker_runtime::identity::{RuntimeId, WorkerId}; +use worker_runtime::profile_archive::ProfileSourceArchive; +use worker_runtime::resource::{ + BackendResourceClient, BackendResourceError, BackendResourceFetchRequest, + BackendResourceFetchResponse, BackendResourceHandle, BackendResourceKind, + BackendResourceOperation, DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES, + PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE, ResourceRedactionPolicy, +}; + +#[derive(Clone, Default)] +pub struct BackendResourceBroker { + resources: Arc>>, +} + +#[derive(Clone)] +struct StoredResource { + runtime_id: Option, + worker_id: Option, + handle: BackendResourceHandle, + archive: ProfileSourceArchive, +} + +impl BackendResourceBroker { + pub fn issue_profile_source_archive_handle( + &self, + workspace_id: impl Into, + runtime_id: Option<&RuntimeId>, + worker_id: Option<&WorkerId>, + archive: ProfileSourceArchive, + ) -> BackendResourceHandle { + let workspace_id = workspace_id.into(); + let nonce = Uuid::now_v7().to_string(); + let audit_correlation_id = format!("resource-fetch-{nonce}"); + let expires_at = Utc::now() + Duration::minutes(15); + let handle = BackendResourceHandle { + kind: BackendResourceKind::ProfileSourceArchive, + workspace_id: workspace_id.clone(), + scope_id: Some("workspace-profile-source".to_string()), + runtime_id: runtime_id.map(|id| id.as_str().to_string()), + worker_id: worker_id.map(|id| id.as_str().to_string()), + resource_id: archive.reference.id.clone(), + digest: archive.reference.digest.clone(), + operation: BackendResourceOperation::FetchArchive, + expires_at_unix_seconds: expires_at.timestamp(), + nonce: nonce.clone(), + revision: archive.reference.digest.clone(), + generation: None, + max_bytes: DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES, + content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + redaction: ResourceRedactionPolicy::RuntimeInternalOnly, + audit_correlation_id, + profile_source_graph: Some(archive.reference.source_graph.clone()), + }; + let stored = StoredResource { + runtime_id: runtime_id.map(|id| id.as_str().to_string()), + worker_id: worker_id.map(|id| id.as_str().to_string()), + handle: handle.clone(), + archive, + }; + if let Ok(mut resources) = self.resources.lock() { + resources.insert(nonce, stored); + } + handle + } + + pub fn fetch_profile_source_archive( + &self, + request: BackendResourceFetchRequest, + ) -> Result { + verify_handle_shape(&request.handle)?; + let stored = self + .resources + .lock() + .map_err(|_| BackendResourceError::Transport { + message: "resource broker lock poisoned".to_string(), + })? + .get(&request.handle.nonce) + .cloned() + .ok_or(BackendResourceError::MissingResource)?; + verify_handle_shape(&stored.handle)?; + if stored.handle.expires_at_unix_seconds < Utc::now().timestamp() { + return Err(BackendResourceError::Expired); + } + let actual_bytes = stored.archive.content.len() as u64; + if actual_bytes > stored.handle.max_bytes { + return Err(BackendResourceError::Oversized { + max_bytes: stored.handle.max_bytes, + actual_bytes, + }); + } + if request.handle != stored.handle { + return Err(BackendResourceError::Unauthorized { + message: "resource handle does not match broker-issued handle".to_string(), + }); + } + if let Some(expected_runtime_id) = stored.runtime_id.as_deref() { + if expected_runtime_id != request.runtime_id { + return Err(BackendResourceError::Unauthorized { + message: "runtime id does not match resource handle".to_string(), + }); + } + } + if let Some(expected_worker_id) = stored.worker_id.as_deref() { + if Some(expected_worker_id) != request.worker_id.as_deref() { + return Err(BackendResourceError::Unauthorized { + message: "worker id does not match resource handle".to_string(), + }); + } + } + Ok(BackendResourceFetchResponse { + kind: BackendResourceKind::ProfileSourceArchive, + resource_id: stored.archive.reference.id, + digest: stored.archive.reference.digest, + content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + bytes: stored.archive.content, + audit_correlation_id: request.audit_correlation_id, + }) + } +} + +#[async_trait] +impl BackendResourceClient for BackendResourceBroker { + async fn fetch_resource( + &self, + request: BackendResourceFetchRequest, + ) -> Result { + self.fetch_profile_source_archive(request) + } +} + +fn verify_handle_shape(handle: &BackendResourceHandle) -> Result<(), BackendResourceError> { + if handle.kind != BackendResourceKind::ProfileSourceArchive { + return Err(BackendResourceError::UnsupportedKind); + } + if handle.operation != BackendResourceOperation::FetchArchive { + return Err(BackendResourceError::Unauthorized { + message: "resource handle operation is not fetch_archive".to_string(), + }); + } + if handle.content_type != PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE { + return Err(BackendResourceError::ContentTypeMismatch { + expected: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + actual: handle.content_type.clone(), + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use worker_runtime::identity::{RuntimeId, WorkerId}; + use worker_runtime::profile_archive::{ + ProfileSourceArchive, ProfileSourceArchiveRef, ProfileSourceGraphSummary, sha256_hex, + }; + + fn archive() -> ProfileSourceArchive { + let content = b"archive-content".to_vec(); + let mut entrypoints = BTreeMap::new(); + entrypoints.insert("default".to_string(), "profiles/default.dcdl".to_string()); + ProfileSourceArchive { + reference: ProfileSourceArchiveRef { + id: "profile-source-archive:test".to_string(), + digest: sha256_hex(&content), + size_bytes: content.len() as u64, + source_graph: ProfileSourceGraphSummary { + entrypoints, + source_count: 1, + import_count: 0, + total_source_bytes: content.len() as u64, + }, + }, + content, + } + } + + fn archive_with_len(len: usize) -> ProfileSourceArchive { + let content = vec![b'x'; len]; + let mut entrypoints = BTreeMap::new(); + entrypoints.insert("default".to_string(), "profiles/default.dcdl".to_string()); + ProfileSourceArchive { + reference: ProfileSourceArchiveRef { + id: format!("profile-source-archive:test-{len}"), + digest: sha256_hex(&content), + size_bytes: content.len() as u64, + source_graph: ProfileSourceGraphSummary { + entrypoints, + source_count: 1, + import_count: 0, + total_source_bytes: content.len() as u64, + }, + }, + content, + } + } + + fn request( + handle: BackendResourceHandle, + runtime_id: &RuntimeId, + worker_id: Option<&WorkerId>, + ) -> BackendResourceFetchRequest { + BackendResourceFetchRequest { + audit_correlation_id: handle.audit_correlation_id.clone(), + handle, + runtime_id: runtime_id.as_str().to_string(), + worker_id: worker_id.map(|id| id.as_str().to_string()), + } + } + + #[test] + fn broker_issues_and_verifies_profile_source_archive_handles() { + let broker = BackendResourceBroker::default(); + let runtime_id = RuntimeId::new("runtime-test").unwrap(); + let handle = broker.issue_profile_source_archive_handle( + "workspace-test", + Some(&runtime_id), + None, + archive(), + ); + let response = broker + .fetch_profile_source_archive(BackendResourceFetchRequest { + handle: handle.clone(), + runtime_id: runtime_id.as_str().to_string(), + worker_id: None, + audit_correlation_id: handle.audit_correlation_id.clone(), + }) + .expect("fetch succeeds"); + assert_eq!(response.digest, handle.digest); + assert_eq!(response.content_type, PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE); + } + + #[test] + fn broker_rejects_runtime_mismatch() { + let broker = BackendResourceBroker::default(); + let runtime_a = RuntimeId::new("runtime-a").unwrap(); + let handle = broker.issue_profile_source_archive_handle( + "workspace-test", + Some(&runtime_a), + None, + archive(), + ); + let err = broker + .fetch_profile_source_archive(request( + handle, + &RuntimeId::new("runtime-b").unwrap(), + None, + )) + .unwrap_err(); + assert!(matches!(err, BackendResourceError::Unauthorized { .. })); + } + + #[test] + fn broker_rejects_worker_mismatch() { + let broker = BackendResourceBroker::default(); + let runtime_id = RuntimeId::new("runtime-test").unwrap(); + let worker_a = WorkerId::new("worker-a").unwrap(); + let worker_b = WorkerId::new("worker-b").unwrap(); + let handle = broker.issue_profile_source_archive_handle( + "workspace-test", + Some(&runtime_id), + Some(&worker_a), + archive(), + ); + let err = broker + .fetch_profile_source_archive(request(handle, &runtime_id, Some(&worker_b))) + .unwrap_err(); + assert!(matches!(err, BackendResourceError::Unauthorized { .. })); + } + + #[test] + fn broker_rejects_expiry_extension_from_request_handle() { + let broker = BackendResourceBroker::default(); + let runtime_id = RuntimeId::new("runtime-test").unwrap(); + let handle = broker.issue_profile_source_archive_handle( + "workspace-test", + Some(&runtime_id), + None, + archive(), + ); + broker + .resources + .lock() + .unwrap() + .get_mut(&handle.nonce) + .unwrap() + .handle + .expires_at_unix_seconds = 1; + let mut extended = handle; + extended.expires_at_unix_seconds = 4_102_444_800; + let err = broker + .fetch_profile_source_archive(request(extended, &runtime_id, None)) + .unwrap_err(); + assert!(matches!(err, BackendResourceError::Expired)); + } + + #[test] + fn broker_rejects_policy_tampered_request_handle() { + let broker = BackendResourceBroker::default(); + let runtime_id = RuntimeId::new("runtime-test").unwrap(); + let mut handle = broker.issue_profile_source_archive_handle( + "workspace-test", + Some(&runtime_id), + None, + archive(), + ); + handle.scope_id = Some("tampered-scope".to_string()); + let err = broker + .fetch_profile_source_archive(request(handle, &runtime_id, None)) + .unwrap_err(); + assert!(matches!(err, BackendResourceError::Unauthorized { .. })); + } + + #[test] + fn broker_uses_stored_max_bytes_when_request_handle_is_tampered() { + let broker = BackendResourceBroker::default(); + let runtime_id = RuntimeId::new("runtime-test").unwrap(); + let archive = archive_with_len((DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES + 1) as usize); + let mut handle = broker.issue_profile_source_archive_handle( + "workspace-test", + Some(&runtime_id), + None, + archive, + ); + handle.max_bytes = DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES + 1024; + let err = broker + .fetch_profile_source_archive(request(handle, &runtime_id, None)) + .unwrap_err(); + assert!(matches!(err, BackendResourceError::Oversized { .. })); + } +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index b0f1ec80..ffa67b8a 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -6,13 +6,14 @@ use axum::extract::{Path as AxumPath, Query, State}; use axum::http::header::{CONTENT_TYPE, LOCATION}; use axum::http::{StatusCode, Uri}; use axum::response::{IntoResponse, Response}; -use axum::routing::{delete, get, post}; +use axum::routing::{delete, get, post, put}; use axum::{Json, Router}; use chrono::{SecondsFormat, Utc}; use futures::StreamExt; use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; -use worker_runtime::worker_backend::WorkerRuntimeExecutionBackend; +use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; +use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; use worker_runtime::working_directory::{ LocalGitWorktreeMaterializer, WorkingDirectoryDiagnostic, WorkingDirectoryMaterializer, }; @@ -35,6 +36,11 @@ use crate::observation::{ BackendObservationProxy, ClientWorkerEventWsFrame, ClientWorkerEventsWsQuery, ObservationProxyError, RuntimeObservationClient, RuntimeObservationSourceConfig, }; +use crate::profile_settings::{ + CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest, + UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest, + UpdateWorkspaceProfileSourceRequest, +}; use crate::records::{ LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary, }; @@ -42,6 +48,7 @@ use crate::repositories::{ ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError, RepositoryRegistryReader, RepositorySummary, }; +use crate::resource_broker::BackendResourceBroker; use crate::store::{ControlPlaneStore, WorkspaceRecord}; use crate::{Error, Result}; use worker_runtime::catalog::{ @@ -154,6 +161,7 @@ pub struct WorkspaceApi { runtime: Arc, companion: Arc, observation_proxy: BackendObservationProxy, + resource_broker: BackendResourceBroker, working_directory_materializer: Arc, } @@ -162,21 +170,46 @@ impl WorkspaceApi { let materializer = Arc::new(LocalGitWorktreeMaterializer::new( config.embedded_runtime_store_root.clone(), )); - let execution_backend = - WorkerRuntimeExecutionBackend::from_workspace(config.workspace_root.clone()) - .map_err(|err| { - crate::Error::Store(format!( - "failed to initialize embedded Worker backend: {err}" - )) - })? - .with_working_directory_materializer((*materializer).clone()); - Self::new_with_execution_backend(config, store, Arc::new(execution_backend)).await + let resource_broker = BackendResourceBroker::default(); + let execution_backend = WorkerRuntimeExecutionBackend::new( + ProfileRuntimeWorkerFactory::new(config.workspace_root.clone()) + .with_resource_client(Arc::new(resource_broker.clone())), + ) + .map_err(|err| { + crate::Error::Store(format!( + "failed to initialize embedded Worker backend: {err}" + )) + })? + .with_working_directory_materializer((*materializer).clone()); + Self::new_with_execution_backend_and_broker( + config, + store, + Arc::new(execution_backend), + resource_broker, + ) + .await } + #[cfg(test)] async fn new_with_execution_backend( config: ServerConfig, store: Arc, execution_backend: Arc, + ) -> Result { + Self::new_with_execution_backend_and_broker( + config, + store, + execution_backend, + BackendResourceBroker::default(), + ) + .await + } + + async fn new_with_execution_backend_and_broker( + config: ServerConfig, + store: Arc, + execution_backend: Arc, + resource_broker: BackendResourceBroker, ) -> Result { store .upsert_workspace(&WorkspaceRecord { @@ -193,13 +226,17 @@ impl WorkspaceApi { config.embedded_runtime_store_root.clone(), execution_backend, ) + .map(|runtime| runtime.with_resource_broker(resource_broker.clone())) .map_err(|err| { crate::Error::Store(format!("invalid embedded Worker backend: {err}")) })?, ); for remote_config in config.remote_runtime_sources.iter().cloned() { - runtime - .register(RemoteWorkerRuntime::new(remote_config).map_err(|err| err.into_error())?); + runtime.register( + RemoteWorkerRuntime::new(remote_config) + .map(|host| host.with_resource_broker(resource_broker.clone())) + .map_err(|err| err.into_error())?, + ); } let runtime = Arc::new(runtime); let companion = Arc::new(CompanionConsole::new(runtime.clone())); @@ -214,6 +251,7 @@ impl WorkspaceApi { runtime, companion, observation_proxy, + resource_broker, working_directory_materializer, }) } @@ -231,6 +269,24 @@ pub fn build_router(api: WorkspaceApi) -> Router { Router::new() .route("/api/workspace", get(get_workspace)) .route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace)) + .route( + "/api/w/{workspace_id}/settings/workspace", + get(scoped_get_workspace_settings).put(scoped_update_workspace_settings), + ) + .route( + "/api/w/{workspace_id}/settings/profiles", + get(scoped_get_profile_settings).post(scoped_create_profile_source), + ) + .route( + "/api/w/{workspace_id}/settings/profiles/registry", + put(scoped_update_profile_registry), + ) + .route( + "/api/w/{workspace_id}/settings/profiles/{profile_source_id}", + get(scoped_get_profile_source) + .put(scoped_update_profile_source) + .delete(scoped_delete_profile_source), + ) .route("/api/tickets", get(list_tickets)) .route("/api/w/{workspace_id}/tickets", get(scoped_list_tickets)) .route("/api/tickets/{id}", get(get_ticket)) @@ -328,6 +384,10 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/settings/runtime-connections/remotes/{runtime_id}/test", post(scoped_test_remote_runtime_connection), ) + .route( + "/internal/runtime/resources/fetch", + post(post_internal_runtime_resource_fetch), + ) .route("/api/companion/status", get(get_companion_status)) .route( "/api/w/{workspace_id}/companion/status", @@ -777,6 +837,116 @@ async fn scoped_get_workspace( get_workspace(State(api)).await } +async fn scoped_get_workspace_settings( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(crate::profile_settings::workspace_metadata_settings( + &api.config.workspace_root, + &api.config.workspace_id, + &api.config.workspace_created_at, + &api.config.workspace_display_name, + ))) +} + +async fn scoped_update_workspace_settings( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let workspace = + crate::profile_settings::update_workspace_metadata(&api.config.workspace_root, request)?; + Ok(Json( + crate::profile_settings::WorkspaceMetadataMutationResponse { + workspace, + diagnostics: vec![RuntimeDiagnostic { + code: "workspace_metadata_updated".to_string(), + severity: DiagnosticSeverity::Info, + message: "Workspace display metadata was updated.".to_string(), + }], + }, + )) +} + +async fn scoped_get_profile_settings( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(crate::profile_settings::load_profile_settings( + &api.config.workspace_id, + &api.config.workspace_root, + ))) +} + +async fn scoped_create_profile_source( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(crate::profile_settings::create_profile_source( + &api.config.workspace_id, + &api.config.workspace_root, + request, + )?)) +} + +async fn scoped_update_profile_registry( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(crate::profile_settings::update_profile_registry( + &api.config.workspace_id, + &api.config.workspace_root, + request, + )?)) +} + +async fn scoped_get_profile_source( + State(api): State, + AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>, +) -> ApiResult> { + validate_workspace_scope(&api, &workspace_id)?; + Ok(Json(crate::profile_settings::read_profile_source( + &api.config.workspace_id, + &api.config.workspace_root, + &profile_source_id, + )?)) +} + +async fn scoped_update_profile_source( + State(api): State, + AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &workspace_id)?; + Ok(Json(crate::profile_settings::update_profile_source( + &api.config.workspace_id, + &api.config.workspace_root, + &profile_source_id, + request, + )?)) +} + +async fn scoped_delete_profile_source( + State(api): State, + AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &workspace_id)?; + Ok(Json(crate::profile_settings::delete_profile_source( + &api.config.workspace_id, + &api.config.workspace_root, + &profile_source_id, + request, + )?)) +} + async fn scoped_list_tickets( State(api): State, AxumPath(path): AxumPath, @@ -1445,7 +1615,9 @@ async fn add_remote_runtime_connection( vec![diagnostic], ) })?; - let active_runtime = RemoteWorkerRuntime::new(active_config).map_err(|err| err.into_error())?; + let active_runtime = RemoteWorkerRuntime::new(active_config) + .map(|host| host.with_resource_broker(api.resource_broker.clone())) + .map_err(|err| err.into_error())?; local_config.runtimes.remote.push(remote_config); write_workspace_backend_config_for_settings(&api, &local_config)?; api.runtime.register_or_replace(active_runtime); @@ -1600,12 +1772,24 @@ async fn create_workspace_worker( State(api): State, Json(request): Json, ) -> ApiResult> { - let profile_selector = profile_selector_for_candidate(&request.profile).ok_or_else(|| { - settings_bad_request( - "unsupported_worker_profile", - "profile must be selected from Backend-published worker profile candidates", - ) - })?; + let profile_selector = + profile_selector_for_candidate_with_root(&api.config.workspace_root, &request.profile) + .ok_or_else(|| { + settings_bad_request( + "unsupported_worker_profile", + "profile must be selected from Backend-published worker profile candidates", + ) + })?; + let resolved_config_bundle = if request.profile.starts_with("project:") { + crate::profile_settings::build_workspace_profile_config_bundle( + &api.config.workspace_root, + &api.config.workspace_id, + &api.config.workspace_created_at, + &request.profile, + )? + } else { + None + }; let display_name = sanitize_worker_display_name(&request.display_name).ok_or_else(|| { settings_bad_request( "invalid_worker_display_name", @@ -1663,6 +1847,7 @@ async fn create_workspace_worker( working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory, + resolved_config_bundle, }, ) .map_err(|err| err.into_error())?; @@ -1696,6 +1881,33 @@ async fn create_workspace_worker( })) } +async fn post_internal_runtime_resource_fetch( + State(api): State, + Json(request): Json, +) -> std::result::Result< + Json, + (StatusCode, Json), +> { + api.resource_broker + .fetch_profile_source_archive(request) + .map(Json) + .map_err(|error| (backend_resource_error_status(&error), Json(error))) +} + +fn backend_resource_error_status(error: &BackendResourceError) -> StatusCode { + match error { + BackendResourceError::Expired => StatusCode::GONE, + BackendResourceError::Unauthorized { .. } => StatusCode::UNAUTHORIZED, + BackendResourceError::MissingResource => StatusCode::NOT_FOUND, + BackendResourceError::UnsupportedKind + | BackendResourceError::DigestMismatch { .. } + | BackendResourceError::Oversized { .. } + | BackendResourceError::ContentTypeMismatch { .. } + | BackendResourceError::InvalidResponse { .. } => StatusCode::BAD_REQUEST, + BackendResourceError::Transport { .. } => StatusCode::BAD_GATEWAY, + } +} + async fn get_companion_status( State(api): State, ) -> ApiResult> { @@ -2687,7 +2899,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp WorkerLaunchOptionsResponse { workspace_id: api.config.workspace_id.clone(), runtimes, - profiles: worker_profile_candidates(), + profiles: worker_profile_candidates_for_root(&api.config.workspace_root), repositories: working_directory_repository_options(api), working_directories: working_directory_summaries(api).unwrap_or_default(), diagnostics: Vec::new(), @@ -2757,8 +2969,16 @@ fn working_directory_request_for_browser( }) } +#[cfg(test)] fn worker_profile_candidates() -> Vec { - vec![ + worker_profile_candidates_for_root(Path::new(".")) + .into_iter() + .filter(|candidate| candidate.id == "builtin:coder" || candidate.id == "runtime_default") + .collect() +} + +fn worker_profile_candidates_for_root(workspace_root: &Path) -> Vec { + let mut candidates = vec![ WorkerLaunchProfileCandidate { id: "builtin:coder".to_string(), label: "Coding Worker".to_string(), @@ -2769,14 +2989,37 @@ fn worker_profile_candidates() -> Vec { label: "Runtime default".to_string(), description: "Use the selected Runtime's default profile.".to_string(), }, - ] + ]; + candidates.extend( + crate::profile_settings::project_profile_candidates(workspace_root) + .into_iter() + .filter(|profile| profile.diagnostics.is_empty()) + .map(|profile| WorkerLaunchProfileCandidate { + id: profile.profile_id, + label: profile.label, + description: profile + .description + .unwrap_or_else(|| "Workspace Decodal profile source.".to_string()), + }), + ); + candidates } fn profile_selector_for_candidate(profile: &str) -> Option { - match profile { - "builtin:coder" => Some(ProfileSelector::Builtin("builtin:coder".to_string())), - "runtime_default" => Some(ProfileSelector::RuntimeDefault), - _ => None, + crate::profile_settings::selector_for_builtin_candidate(profile) + .filter(|_| matches!(profile, "builtin:coder" | "runtime_default")) +} + +fn profile_selector_for_candidate_with_root( + workspace_root: &Path, + profile: &str, +) -> Option { + if profile_selector_for_candidate(profile).is_some() { + profile_selector_for_candidate(profile) + } else if crate::profile_settings::is_profile_candidate(workspace_root, profile) { + crate::profile_settings::selector_for_builtin_candidate(profile) + } else { + None } } @@ -3118,10 +3361,15 @@ struct ApiError { impl From for ApiError { fn from(error: Error) -> Self { - Self { - error, - diagnostics: Vec::new(), - } + let diagnostics = match &error { + Error::RuntimeOperationFailed { code, message, .. } => vec![RuntimeDiagnostic { + code: code.clone(), + severity: DiagnosticSeverity::Error, + message: sanitize_backend_error(message), + }], + _ => Vec::new(), + }; + Self { error, diagnostics } } } @@ -3161,6 +3409,23 @@ impl IntoResponse for ApiError { Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => { StatusCode::NOT_IMPLEMENTED } + Error::RuntimeOperationFailed { code, .. } + if code == "profile_registry_revision_conflict" + || code == "profile_source_revision_conflict" + || code == "workspace_metadata_revision_conflict" => + { + StatusCode::CONFLICT + } + Error::RuntimeOperationFailed { code, .. } + if code == "unknown_profile_source" || code == "unknown_profile_selector" => + { + StatusCode::NOT_FOUND + } + Error::RuntimeOperationFailed { code, .. } + if code == "workspace_display_name_invalid" || code.starts_with("profile_") => + { + StatusCode::BAD_REQUEST + } Error::RuntimeOperationFailed { code, .. } if code.ends_with("_blocked") => { StatusCode::CONFLICT } @@ -3178,12 +3443,18 @@ impl IntoResponse for ApiError { Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY, _ => StatusCode::INTERNAL_SERVER_ERROR, }; + let response_message = match &self.error { + Error::RuntimeOperationFailed { code, message, .. } => { + format!("{code}: {}", sanitize_backend_error(message)) + } + _ => sanitize_backend_error(&self.error.to_string()), + }; ( status, [(CONTENT_TYPE, "application/json")], Json(serde_json::json!({ "error": status.canonical_reason().unwrap_or("error"), - "message": self.error.to_string(), + "message": response_message, "diagnostics": self.diagnostics, })) .to_string(), @@ -3199,10 +3470,11 @@ mod tests { use axum::http::Request; use futures::{SinkExt, StreamExt}; use serde_json::{Value, json}; - use std::sync::Arc; + use std::{fs, sync::Arc}; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; use tower::ServiceExt; + use worker_runtime::resource::BackendResourceClient; use crate::hosts::{ TicketWorkerRole, WorkerInputKind, WorkerOperationState, WorkerSpawnAcceptanceRequirement, @@ -3255,6 +3527,210 @@ mod tests { assert!(!sanitized.contains("/home/example")); } + #[tokio::test] + async fn profile_settings_api_returns_typed_diagnostics_for_duplicate_selector() { + let dir = tempfile::tempdir().unwrap(); + let response = profile_settings_request( + dir.path(), + "POST", + "/settings/profiles", + json!({ + "name": "coder", + "content": valid_profile_source("coder"), + "registry_revision": "missing" + }), + ) + .await; + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert_diagnostic(&response.1, "profile_selector_duplicate"); + } + + #[tokio::test] + async fn profile_settings_api_returns_typed_diagnostics_for_invalid_decodal() { + let dir = tempfile::tempdir().unwrap(); + let response = profile_settings_request( + dir.path(), + "POST", + "/settings/profiles", + json!({ + "name": "bad", + "content": "not decodal", + "registry_revision": "missing" + }), + ) + .await; + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert!( + diagnostic_codes(&response.1) + .iter() + .any(|code| code.starts_with("profile_source_")) + ); + } + + #[tokio::test] + async fn profile_settings_api_returns_typed_diagnostic_for_invalid_registry_schema() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "[profile\n").unwrap(); + let response = profile_settings_request( + dir.path(), + "POST", + "/settings/profiles", + json!({ + "name": "schema", + "content": valid_profile_source("schema"), + "registry_revision": test_file_revision(&dir.path().join(".yoi/profiles.toml")) + }), + ) + .await; + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert_diagnostic(&response.1, "profile_registry_schema_invalid"); + } + + #[tokio::test] + async fn profile_settings_api_returns_conflict_diagnostic_for_stale_revision() { + let dir = tempfile::tempdir().unwrap(); + let response = profile_settings_request( + dir.path(), + "POST", + "/settings/profiles", + json!({ + "name": "alpha", + "content": valid_profile_source("alpha"), + "registry_revision": "stale" + }), + ) + .await; + assert_eq!(response.0, StatusCode::CONFLICT); + assert_diagnostic(&response.1, "profile_registry_revision_conflict"); + } + + #[tokio::test] + async fn profile_settings_api_returns_typed_diagnostic_for_too_large_source() { + let dir = tempfile::tempdir().unwrap(); + let response = profile_settings_request( + dir.path(), + "POST", + "/settings/profiles", + json!({ + "name": "large", + "content": "x".repeat((256 * 1024) + 1), + "registry_revision": "missing" + }), + ) + .await; + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert_diagnostic(&response.1, "profile_source_too_large"); + } + + #[cfg(unix)] + #[tokio::test] + async fn profile_settings_api_redacts_symlink_escape_response() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); + let outside = dir.path().join("outside.dcdl"); + fs::write(&outside, valid_profile_source("escape")).unwrap(); + std::os::unix::fs::symlink(&outside, dir.path().join(".yoi/profiles/escape.dcdl")).unwrap(); + let response = profile_settings_request( + dir.path(), + "PUT", + "/settings/profiles/registry", + json!({ + "registry_revision": test_file_revision(&dir.path().join(".yoi/profiles.toml")), + "default_profile": null, + "profiles": [{ "name": "escape", "profile_source_id": "project:escape" }] + }), + ) + .await; + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert_diagnostic(&response.1, "profile_source_symlink_escape"); + let rendered = response.1.to_string(); + assert!(!rendered.contains(dir.path().to_string_lossy().as_ref())); + } + + #[tokio::test] + async fn worker_launch_rejects_invalid_project_profile_candidate() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); + fs::write( + dir.path().join(".yoi/profiles.toml"), + "[profile.bad]\npath = \"profiles/bad.dcdl\"\n", + ) + .unwrap(); + fs::write(dir.path().join(".yoi/profiles/bad.dcdl"), "not decodal").unwrap(); + assert!( + worker_profile_candidates_for_root(dir.path()) + .iter() + .all(|candidate| candidate.id != "project:bad") + ); + assert!(profile_selector_for_candidate_with_root(dir.path(), "project:bad").is_none()); + } + + fn valid_profile_source(slug: &str) -> String { + format!( + r#"{{ + slug = "{slug}"; + description = "Test"; + scope = "workspace_read"; + }}"# + ) + } + + fn test_file_revision(path: &Path) -> String { + let Ok(metadata) = fs::metadata(path) else { + return "missing".to_string(); + }; + let modified = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + format!("rev:{modified}:{}", metadata.len()) + } + + async fn profile_settings_request( + workspace_root: &Path, + method: &str, + path: &str, + body: Value, + ) -> (StatusCode, Value) { + let app = test_app(workspace_root.to_path_buf()).await; + let request = Request::builder() + .method(method) + .uri(format!("/api/w/{TEST_WORKSPACE_ID}{path}")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + let json = serde_json::from_slice::(&bytes).unwrap(); + (status, json) + } + + fn diagnostic_codes(response: &Value) -> Vec { + response["diagnostics"] + .as_array() + .expect("diagnostics array") + .iter() + .map(|diagnostic| diagnostic["code"].as_str().unwrap().to_string()) + .collect() + } + + fn assert_diagnostic(response: &Value, code: &str) { + let codes = diagnostic_codes(response); + assert!( + !codes.is_empty(), + "diagnostics must not be empty: {response}" + ); + assert!( + codes.iter().any(|actual| actual == code), + "missing {code}: {codes:?}" + ); + } + #[derive(Default)] struct DeterministicExecutionBackend { contexts: std::sync::Mutex< @@ -3375,16 +3851,161 @@ mod tests { } } - async fn test_app(workspace_root: impl Into) -> Router { + async fn test_api(workspace_root: impl Into) -> WorkspaceApi { let store = SqliteWorkspaceStore::in_memory().unwrap(); - let api = WorkspaceApi::new_with_execution_backend( + WorkspaceApi::new_with_execution_backend( test_server_config(workspace_root), Arc::new(store), Arc::new(DeterministicExecutionBackend::default()), ) .await - .unwrap(); - build_router(api) + .unwrap() + } + + async fn test_app(workspace_root: impl Into) -> Router { + build_router(test_api(workspace_root).await) + } + + fn test_profile_archive() -> worker_runtime::profile_archive::ProfileSourceArchive { + use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput}; + ProfileSourceArchive::build(ProfileSourceArchiveInput { + id: "profile-source-archive:server-test".to_string(), + entrypoints: std::collections::BTreeMap::from([( + "default".to_string(), + "profiles/default.dcdl".to_string(), + )]), + imports: std::collections::BTreeMap::new(), + sources: std::collections::BTreeMap::from([( + "profiles/default.dcdl".to_string(), + r#"{ + slug = "default"; + description = "Default"; + scope = "workspace_read"; + }"# + .to_string(), + )]), + }) + .unwrap() + } + + fn missing_resource_handle() -> worker_runtime::resource::BackendResourceHandle { + worker_runtime::resource::BackendResourceHandle { + kind: worker_runtime::resource::BackendResourceKind::ProfileSourceArchive, + workspace_id: "workspace-test".to_string(), + scope_id: Some("workspace-profile-source".to_string()), + runtime_id: Some("runtime-test".to_string()), + worker_id: None, + resource_id: "profile-source-archive:missing".to_string(), + digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + operation: worker_runtime::resource::BackendResourceOperation::FetchArchive, + expires_at_unix_seconds: 4_102_444_800, + nonce: "missing-nonce".to_string(), + revision: "missing-revision".to_string(), + generation: None, + max_bytes: worker_runtime::resource::DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES, + content_type: worker_runtime::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + redaction: worker_runtime::resource::ResourceRedactionPolicy::RuntimeInternalOnly, + audit_correlation_id: "audit-missing".to_string(), + profile_source_graph: Some( + worker_runtime::profile_archive::ProfileSourceGraphSummary { + entrypoints: std::collections::BTreeMap::from([( + "default".to_string(), + "profiles/default.dcdl".to_string(), + )]), + source_count: 1, + import_count: 0, + total_source_bytes: 0, + }, + ), + } + } + + #[tokio::test] + async fn internal_resource_fetch_rest_returns_typed_missing_resource() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let app = test_app(workspace.path()).await; + let handle = missing_resource_handle(); + let response = app + .oneshot( + Request::post("/internal/runtime/resources/fetch") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec( + &worker_runtime::resource::BackendResourceFetchRequest { + audit_correlation_id: handle.audit_correlation_id.clone(), + runtime_id: "runtime-test".to_string(), + worker_id: None, + handle, + }, + ) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let error: worker_runtime::resource::BackendResourceError = + serde_json::from_slice(&bytes).unwrap(); + assert!(matches!( + error, + worker_runtime::resource::BackendResourceError::MissingResource + )); + } + + #[tokio::test] + async fn remote_http_resource_fetch_uses_backend_resource_contract() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let broker = api.resource_broker.clone(); + let archive = test_profile_archive(); + let runtime_id = worker_runtime::identity::RuntimeId::new("runtime-test").unwrap(); + let handle = broker.issue_profile_source_archive_handle( + "workspace-test", + Some(&runtime_id), + None, + archive, + ); + let app = build_router(api); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = worker_runtime::resource::HttpBackendResourceClient::new( + format!("http://{addr}/internal/runtime/resources/fetch"), + None, + ); + + let response = client + .fetch_resource(worker_runtime::resource::BackendResourceFetchRequest { + audit_correlation_id: handle.audit_correlation_id.clone(), + runtime_id: runtime_id.as_str().to_string(), + worker_id: None, + handle: handle.clone(), + }) + .await + .expect("remote HTTP resource fetch succeeds"); + assert_eq!(response.digest, handle.digest); + + let mut tampered = handle; + tampered.scope_id = Some("tampered".to_string()); + let error = client + .fetch_resource(worker_runtime::resource::BackendResourceFetchRequest { + audit_correlation_id: tampered.audit_correlation_id.clone(), + runtime_id: runtime_id.as_str().to_string(), + worker_id: None, + handle: tampered, + }) + .await + .unwrap_err(); + assert!(matches!( + error, + worker_runtime::resource::BackendResourceError::Unauthorized { .. } + )); + server.abort(); } fn runtime_test_bundle() -> worker_runtime::config_bundle::ConfigBundle { @@ -3406,6 +4027,7 @@ mod tests { }], declarations: Vec::new(), profile_source_archive: None, + profile_source_archive_handle: None, } .with_computed_digest() } @@ -4386,6 +5008,7 @@ mod tests { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, + resolved_config_bundle: None, }, ) .expect("spawn worker"); diff --git a/package.nix b/package.nix index 0b196b78..abe5476c 100644 --- a/package.nix +++ b/package.nix @@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-g9Bh9qQ1fGZtzsjO2/vc4jZdrH5YoCR0rOgs//xUDrU="; + cargoHash = "sha256-Pk6pRrtQlMAw2Shl+nQyX81FHQsHlKYAsxtg0M4Ya8Y="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, diff --git a/web/workspace/src/lib/workspace-api/http.ts b/web/workspace/src/lib/workspace-api/http.ts index 92365eae..b519c8c2 100644 --- a/web/workspace/src/lib/workspace-api/http.ts +++ b/web/workspace/src/lib/workspace-api/http.ts @@ -38,6 +38,31 @@ export async function loadJson( } } +async function requireJson(response: Response, path: string): Promise { + if (!response.ok) { + const text = await response.text(); + throw new Error(text || `${path} request failed (${response.status})`); + } + return (await response.json()) as T; +} + +export async function workspaceApiJson(path: string): Promise { + return requireJson(await fetch(path), path); +} + +export async function workspaceApiJsonWithBody(path: string, init: RequestInit): Promise { + return requireJson( + await fetch(path, { + headers: { + "content-type": "application/json", + ...(init.headers ?? {}), + }, + ...init, + }), + path, + ); +} + export function formatDate(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) { diff --git a/web/workspace/src/lib/workspace-settings/model.ts b/web/workspace/src/lib/workspace-settings/model.ts index c692244e..8c0b4689 100644 --- a/web/workspace/src/lib/workspace-settings/model.ts +++ b/web/workspace/src/lib/workspace-settings/model.ts @@ -6,6 +6,7 @@ export type Diagnostic = { export type SettingsSectionId = | "runtime-connections" + | "profile-sources" | "backend-config" | "workspace-identity"; @@ -84,6 +85,18 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ "Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.", ], }, + { + id: "profile-sources", + label: "Profile Sources", + status: "editable", + summary: + "Manage the workspace-scoped Decodal Profile registry and source files used by Backend-published launch profile discovery.", + bullets: [ + "Selectors are source-qualified (builtin:* or project:*); raw profile source paths, archive content, archive digests, resource handles, and runtime tokens are not exposed.", + "Profile source edits are validated through the Backend ProfileSourceArchive/Decodal boundary before they are persisted.", + "Launch profile candidates refresh from the same Backend projection after registry or source updates.", + ], + }, { id: "backend-config", label: "Backend Config", diff --git a/web/workspace/src/lib/workspace-settings/profile-api.ts b/web/workspace/src/lib/workspace-settings/profile-api.ts new file mode 100644 index 00000000..79141333 --- /dev/null +++ b/web/workspace/src/lib/workspace-settings/profile-api.ts @@ -0,0 +1,81 @@ +import { workspaceApiJson, workspaceApiJsonWithBody } from "../workspace-api/http"; +import type { + ProfileSettingsMutationResponse, + ProfileSettingsResponse, + WorkspaceMetadataMutationResponse, + WorkspaceMetadataSettingsResponse, + WorkspaceProfileSourceDetailResponse, +} from "./profile-types"; + +export function fetchWorkspaceMetadataSettings(workspaceId: string): Promise { + return workspaceApiJson(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`); +} + +export function updateWorkspaceMetadataSettings( + workspaceId: string, + request: { display_name: string; revision: string }, +): Promise { + return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`, { + method: "PUT", + body: JSON.stringify(request), + }); +} + +export function fetchProfileSettings(workspaceId: string): Promise { + return workspaceApiJson(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`); +} + +export function createProfileSource( + workspaceId: string, + request: { name: string; description?: string; content: string; registry_revision: string }, +): Promise { + return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`, { + method: "POST", + body: JSON.stringify(request), + }); +} + +export function updateProfileRegistry( + workspaceId: string, + request: { + registry_revision: string; + default_profile?: string | null; + profiles: Array<{ name: string; description?: string | null; profile_source_id?: string | null }>; + }, +): Promise { + return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/registry`, { + method: "PUT", + body: JSON.stringify(request), + }); +} + +export function fetchProfileSource( + workspaceId: string, + sourceId: string, +): Promise { + return workspaceApiJson( + `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`, + ); +} + +export function updateProfileSource( + workspaceId: string, + sourceId: string, + request: { content: string; revision: string }, +): Promise { + return workspaceApiJsonWithBody( + `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`, + { method: "PUT", body: JSON.stringify(request) }, + ); +} + +export function deleteProfileSource( + workspaceId: string, + sourceId: string, + request: { registry_revision: string; source_revision: string }, +): Promise { + return workspaceApiJsonWithBody( + `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`, + { method: "DELETE", body: JSON.stringify(request) }, + ); +} diff --git a/web/workspace/src/lib/workspace-settings/profile-types.ts b/web/workspace/src/lib/workspace-settings/profile-types.ts new file mode 100644 index 00000000..d421f2f6 --- /dev/null +++ b/web/workspace/src/lib/workspace-settings/profile-types.ts @@ -0,0 +1,60 @@ +import type { Diagnostic } from "./model"; + +export type WorkspaceMetadataSettingsResponse = { + workspace_id: string; + display_name: string; + created_at: string; + revision: string; + source: string; + diagnostics: Diagnostic[]; +}; + +export type WorkspaceMetadataMutationResponse = { + workspace: WorkspaceMetadataSettingsResponse; + diagnostics: Diagnostic[]; +}; + +export type WorkspaceProfileSummary = { + profile_id: string; + selector: string; + label: string; + source_kind: "builtin" | "project" | string; + profile_source_id?: string | null; + description?: string | null; + editable: boolean; + is_default: boolean; + diagnostics: Diagnostic[]; +}; + +export type WorkspaceProfileSourceSummary = { + profile_source_id: string; + display_path: string; + kind: "decodal" | string; + editable: boolean; + revision: string; + size_bytes: number; + diagnostics: Diagnostic[]; +}; + +export type ProfileSettingsResponse = { + workspace_id: string; + registry_revision: string; + default_profile?: string | null; + profiles: WorkspaceProfileSummary[]; + sources: WorkspaceProfileSourceSummary[]; + diagnostics: Diagnostic[]; +}; + +export type WorkspaceProfileSourceDetailResponse = { + workspace_id: string; + profile: WorkspaceProfileSummary; + source: WorkspaceProfileSourceSummary; + content: string; + diagnostics: Diagnostic[]; +}; + +export type ProfileSettingsMutationResponse = { + workspace_id: string; + settings: ProfileSettingsResponse; + diagnostics: Diagnostic[]; +}; diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte index 1737a2bc..4c7faefb 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte @@ -13,6 +13,21 @@ type RuntimeConnectionMutationResponse, type RuntimeConnectionSettingsResponse, } from "$lib/workspace-settings/model"; + import type { + ProfileSettingsResponse, + WorkspaceMetadataSettingsResponse, + WorkspaceProfileSourceDetailResponse, + } from "$lib/workspace-settings/profile-types"; + import { + createProfileSource, + deleteProfileSource, + fetchProfileSettings, + fetchProfileSource, + fetchWorkspaceMetadataSettings, + updateProfileRegistry, + updateProfileSource, + updateWorkspaceMetadataSettings, + } from "$lib/workspace-settings/profile-api"; type RemoteAddForm = { runtime_id: string; @@ -44,6 +59,19 @@ display_name: "", endpoint: "", }); + let workspaceMetadata = $state(null); + let profileSettings = $state(null); + let selectedProfileSource = $state(null); + let profileSourceContent = $state(""); + let newProfileName = $state(""); + let newProfileDescription = $state(""); + let newProfileContent = $state("{\n slug = \"workspace-profile\";\n description = \"Workspace profile\";\n scope = \"workspace_read\";\n}\n"); + let profileLoading = $state(true); + let profileMessage = $state(null); + let profileDiagnostics = $state([]); + let profileSubmitting = $state(false); + let workspaceNameDraft = $state(""); + let defaultProfileDraft = $state(""); $effect(() => { if (!workspaceId) { @@ -83,6 +111,179 @@ }; }); + $effect(() => { + if (!workspaceId) { + profileLoading = false; + return; + } + let cancelled = false; + async function loadProfileAndWorkspaceSettings() { + profileLoading = true; + profileMessage = null; + profileDiagnostics = []; + try { + const [workspace, profiles] = await Promise.all([ + fetchWorkspaceMetadataSettings(workspaceId), + fetchProfileSettings(workspaceId), + ]); + if (!cancelled) { + workspaceMetadata = workspace; + workspaceNameDraft = workspace.display_name; + profileSettings = profiles; + defaultProfileDraft = profiles.default_profile ?? ""; + profileDiagnostics = [...workspace.diagnostics, ...profiles.diagnostics]; + } + } catch (err) { + if (!cancelled) { + profileMessage = err instanceof Error ? err.message : "profile settings request failed"; + } + } finally { + if (!cancelled) { + profileLoading = false; + } + } + } + loadProfileAndWorkspaceSettings(); + return () => { + cancelled = true; + }; + }); + + async function refreshProfileSettings() { + profileSettings = await fetchProfileSettings(workspaceId); + profileDiagnostics = profileSettings.diagnostics; + } + + async function submitWorkspaceName() { + if (!workspaceMetadata) return; + profileSubmitting = true; + profileMessage = null; + try { + const response = await updateWorkspaceMetadataSettings(workspaceId, { + display_name: workspaceNameDraft, + revision: workspaceMetadata.revision, + }); + workspaceMetadata = response.workspace; + workspaceNameDraft = response.workspace.display_name; + profileDiagnostics = response.diagnostics.concat(response.workspace.diagnostics); + profileMessage = "Workspace display name updated."; + } catch (err) { + profileMessage = err instanceof Error ? err.message : "workspace update failed"; + } finally { + profileSubmitting = false; + } + } + + async function submitProfileRegistry() { + if (!profileSettings) return; + profileSubmitting = true; + profileMessage = null; + try { + const response = await updateProfileRegistry(workspaceId, { + registry_revision: profileSettings.registry_revision, + default_profile: defaultProfileDraft || null, + profiles: profileSettings.profiles + .filter((profile) => profile.source_kind === "project") + .map((profile) => ({ + name: profile.selector.replace(/^project:/, ""), + description: profile.description ?? null, + profile_source_id: profile.profile_source_id ?? null, + })), + }); + profileSettings = response.settings; + defaultProfileDraft = response.settings.default_profile ?? ""; + profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics); + profileMessage = "Profile registry updated."; + } catch (err) { + profileMessage = err instanceof Error ? err.message : "profile registry update failed"; + } finally { + profileSubmitting = false; + } + } + + async function submitNewProfileSource() { + if (!profileSettings) return; + profileSubmitting = true; + profileMessage = null; + try { + const response = await createProfileSource(workspaceId, { + name: newProfileName, + description: newProfileDescription || undefined, + content: newProfileContent, + registry_revision: profileSettings.registry_revision, + }); + profileSettings = response.settings; + defaultProfileDraft = response.settings.default_profile ?? ""; + profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics); + newProfileName = ""; + newProfileDescription = ""; + profileMessage = "Profile source created."; + } catch (err) { + profileMessage = err instanceof Error ? err.message : "profile source create failed"; + } finally { + profileSubmitting = false; + } + } + + async function selectProfileSource(sourceId: string) { + profileSubmitting = true; + profileMessage = null; + try { + selectedProfileSource = await fetchProfileSource(workspaceId, sourceId); + profileSourceContent = selectedProfileSource.content; + profileDiagnostics = selectedProfileSource.diagnostics.concat( + selectedProfileSource.source.diagnostics, + selectedProfileSource.profile.diagnostics, + ); + } catch (err) { + profileMessage = err instanceof Error ? err.message : "profile source load failed"; + } finally { + profileSubmitting = false; + } + } + + async function submitProfileSourceUpdate() { + if (!selectedProfileSource) return; + profileSubmitting = true; + profileMessage = null; + try { + const response = await updateProfileSource(workspaceId, selectedProfileSource.source.profile_source_id, { + content: profileSourceContent, + revision: selectedProfileSource.source.revision, + }); + profileSettings = response.settings; + defaultProfileDraft = response.settings.default_profile ?? ""; + profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics); + await selectProfileSource(selectedProfileSource.source.profile_source_id); + profileMessage = "Profile source updated."; + } catch (err) { + profileMessage = err instanceof Error ? err.message : "profile source update failed"; + } finally { + profileSubmitting = false; + } + } + + async function submitProfileSourceDelete() { + if (!profileSettings || !selectedProfileSource) return; + profileSubmitting = true; + profileMessage = null; + try { + const response = await deleteProfileSource(workspaceId, selectedProfileSource.source.profile_source_id, { + registry_revision: profileSettings.registry_revision, + source_revision: selectedProfileSource.source.revision, + }); + profileSettings = response.settings; + selectedProfileSource = null; + profileSourceContent = ""; + profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics); + profileMessage = "Profile source deleted."; + } catch (err) { + profileMessage = err instanceof Error ? err.message : "profile source delete failed"; + } finally { + profileSubmitting = false; + } + } + async function submitRemoteRuntime() { submitting = true; mutationMessage = null; @@ -410,8 +611,109 @@ {/if} +
+
+
+

editable

+

Profile Sources

+
+ Backend scoped +
+

+ Workspace profile settings are surfaced as source-qualified selectors and Decodal source summaries. The browser never receives raw host paths, runtime endpoints, tokens, resource handles, archive content, or archive digests. +

+ + {#if profileLoading} +

Loading profile settings…

+ {:else} +
+
{ event.preventDefault(); submitWorkspaceName(); }}> +

Workspace display name

+ +

Workspace id: {workspaceMetadata?.workspace_id ?? workspaceId}

+ +
+ +
{ event.preventDefault(); submitProfileRegistry(); }}> +

Default launch profile

+ + +
+
+ +
+

Discovered profiles

+ + + + + + {#each profileSettings?.profiles ?? [] as profile} + + + + + + + + {/each} + +
SelectorLabelSourceStatusAction
{profile.selector}{profile.label}{profile.source_kind}{profile.diagnostics.length === 0 ? "ok" : `${profile.diagnostics.length} diagnostics`} + {#if profile.profile_source_id} + + {:else} + builtin + {/if} +
+
+ +
+
{ event.preventDefault(); submitNewProfileSource(); }}> +

Create project profile source

+ + + + +
+ + {#if selectedProfileSource} +
{ event.preventDefault(); submitProfileSourceUpdate(); }}> +

Edit {selectedProfileSource.profile.label}

+

Source id: {selectedProfileSource.source.profile_source_id}; display path: {selectedProfileSource.source.display_path}

+ +
+ + +
+
+ {:else} +
+

No profile source selected

+

Open a project source from the discovered profile table to edit it.

+
+ {/if} +
+ + {#if profileMessage} +

{profileMessage}

+ {/if} + {@render DiagnosticsList({ diagnostics: profileDiagnostics })} + {/if} +
+
- {#each SETTINGS_SECTIONS.filter((section) => section.id !== "runtime-connections") as section} + {#each SETTINGS_SECTIONS.filter((section) => section.id !== "runtime-connections" && section.id !== "profile-sources") as section}