From a674f6f196a27775e71533758c8fd3657aaed640 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 07:47:15 -0800 Subject: [PATCH 01/43] =?UTF-8?q?docs(aws):=20phase=205=20plan=20=E2=80=94?= =?UTF-8?q?=20Fargate=20workspaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../2026-11-15-aws-fargate-workspaces.md | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-15-aws-fargate-workspaces.md diff --git a/docs/superpowers/plans/2026-11-15-aws-fargate-workspaces.md b/docs/superpowers/plans/2026-11-15-aws-fargate-workspaces.md new file mode 100644 index 0000000..29c89b2 --- /dev/null +++ b/docs/superpowers/plans/2026-11-15-aws-fargate-workspaces.md @@ -0,0 +1,177 @@ +# AWS Fargate Workspaces Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A project can run on an AWS environment as a Fargate task: start, connect (terminals, files, auth bridge through the agent), stop with a guard that pushes unsaved work, idle auto-stop, logs, and it outlives the app. + +**Architecture:** `ProjectHost::Aws` registers a per-project task definition (fingerprint-tagged), keeps every project value in one per-project Secrets Manager secret, ensures a per-project EFS access point for Hybrid storage, runs the task, and opens the phase 2b `PortForward`, whose `AgentEndpoint` becomes the project's `ContainerTarget::Agent`. A tunnel registry in `host/` lets `ContainerTarget::for_project` find it. `ExitSource::Aws` polls `DescribeTasks`. The image's entrypoint gains an `aws` mode (secret files, repo clone, Hybrid persistence, WIP push on SIGTERM, idle watcher). + +**Tech Stack:** Rust (aws-sdk-ecs, -secretsmanager, + new aws-sdk-efs, aws-sdk-cloudwatchlogs at the workspace's pinned versions), bash entrypoint, React/TS. + +**Spec:** `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md` (sections Storage, Secrets and uploads, Lifecycle and status, Image, Data model, Fit with existing conventions). Phase 5 scope: "Ephemeral and Hybrid storage, Secrets Manager delivery, git bootstrap, start/stop/status, stop guard with WIP push on by default, idle auto-stop, logs." + +## Decisions this plan makes (rulings; the spec doesn't settle them) + +- **P1. One secret per project holds everything project-shaped.** `triple-c///` (SecretString = JSON object). The task definition references keys with `valueFrom: :::`. *Why:* every workspace user has `ecs:Describe*` on `*`, so anything in a task definition's `environment` is readable by every other user in the account — not just credentials but instructions, settings JSON, repo URLs, custom env. The task definition carries only fixed constants (`TRIPLEC_HOST=aws`, `COLORTERM`, `TRIPLEC_SECRET_KEYS`). The spec's `` in secret names becomes `owner_key` (`aws:userid` contains `:`, which secret names, log streams and EFS paths reject). 64 KB cap enforced with a clear error. +- **P2. The agent token rotates every start** (written into the project secret before `RunTask`) and the user may `secretsmanager:GetSecretValue` their **own** secrets (owner tag), so a second machine can attach to a running task. The task role still has no Secrets Manager permission. +- **P3. Workspace users register task definitions.** New policy file `aws/policies/workspace-user-tasks.json` (the current file has ~34 characters of headroom): `ecs:RegisterTaskDefinition` with request tag owner = `${aws:userid}` and the three-key tag set, `ecs:TagResource` with `ecs:CreateAction=RegisterTaskDefinition`, `ecs:DeregisterTaskDefinition` + `ecs:DeleteTaskDefinitions` on owner-tagged `task-definition/triple-c-*`, and `secretsmanager:GetSecretValue` on owner-tagged `secret:triple-c/*`. Family: `triple-c---`. Whether RegisterTaskDefinition honours resource-level `task-definition/triple-c-*` must be checked live; fallback is `Resource: "*"` with the request-tag condition (recorded in README either way). +- **P4. Hybrid persists by symlink, not by masking `/home/claude`.** EFS (access point root `/triple-c//`, POSIX 1000:1000, created 0700) mounts at `/mnt/triple-c-home`; the entrypoint links `~/.claude`, `~/.claude.json` and `~/.ssh` into it (seeding from the image's copy on first use). *Why:* EFS has no Docker-volume "copy the image's contents on first mount", so mounting over `/home/claude` would hide Claude Code, cargo and uv; this way the image's tooling stays upgradable and only user state persists. The scheduler's state under `~/.claude` comes along. +- **P5. AWS uses a registry image only.** `LocalBuild` maps to the registry image; `Custom` must name a registry reference. Note for release: `:latest` must contain the agent (true once the next release rebuilds the image from its tag). +- **P6. Not in phase 5:** EFS storage mode, Copy-at-connect, setup scripts, cost estimate (phase 6); port mappings, LLM gateway sidecar, Docker access, VPN, the admin's maximum lifetime (no field in the environment record yet). The Config UI shows these disabled with the reason (`HostCaps` idea from the spec). Ollama/llama.cpp are allowed with a warning that the URL must be reachable from AWS. +- **P7. Idle = no authenticated agent activity for N minutes, no `claude` process, no running scheduler job.** Default off; 5–1440 minutes when on. +- **P8. Spot** uses capacity provider `FARGATE_SPOT`; stop code `SpotInterruption` → Stopped with a "Spot" reason. +- **P9. Bubblewrap on Fargate:** verified live in Task 9. Until then the AWS host forces `sandbox_mode_enabled` off in the generated settings with a notice. + +## Global Constraints + +- Never use `aws_config::defaults().load()`; SDK configs come from `aws::connection::sdk_config(conn, region)` only. +- Every per-user resource is tagged `triple-c:owner=`, `triple-c:project=`, `triple-c:environment=`, and nothing else (the policy's `ForAllValues:StringEquals aws:TagKeys` set). +- Names built only in `aws/tofu_modules.rs` (or a new `aws/workspace_names.rs` it re-exports): family, secret name, access point root, log stream prefix. All use `owner_key`, never `aws:userid`. +- No project data in a task definition's `environment`, tags or `overrides` (P1). Secrets only by `valueFrom` ARN. +- Container name `workspace`; agent port `host::ssm::AGENT_PORT` (47900); task runs with `enableExecuteCommand: true`, `initProcessEnabled: true`, cap add `SYS_PTRACE` only. +- Health check exactly as the spec: `curl -sf --max-time 3 -H "Authorization: Bearer $(cat /dev/shm/triple-c/agent-token)" http://127.0.0.1:47900/v1/health`, interval 30, retries 3, startPeriod 120. +- Fargate CPU/memory pairs are AWS's valid table (256: 512–2048; 512: 1024–4096; 1024: 2048–8192; 2048: 4096–16384 in 1024 steps; 4096: 8192–30720 in 1024 steps; 8192: 16384–61440 in 4096 steps; 16384: 32768–122880 in 8192 steps). Ephemeral 21–200 GiB. +- Boundary tests in `host/mod.rs` still pass: no bollard or Docker lifecycle outside `host/`/`docker/`. +- **Change a policy JSON, its checks and `aws/README.md` together**; every policy ≤ 6,144 characters; re-run the live tests after a policy change. +- New Tauri commands: `#[tauri::command]` + `generate_handler!` + `allow-*` in `capabilities/default.json`. +- New `Project` fields: serde defaults; `wip_push_on_stop` defaults **true** (the `default_full_permissions` pattern); TS types mirror Rust; existing `projects.json` loads unchanged. +- Live AWS: only the scratchpad `awsx` wrapper; spend cap $10 total; every live test cleans up. + +## Review Focus + +1. A project whose task was stopped outside the app (console, Spot, idle stop) while the app was closed: reopening shows Stopped, not Running, and Start works. +2. Two app instances (two machines) on one project: the second attaches to the running task (P2) instead of starting another; Start never runs a second task for a project with one RUNNING/PENDING. +3. A repo folder that already exists on restart (Hybrid keeps nothing in `/workspace`, but a re-run entrypoint in one task must not re-clone) and a clone that fails (bad URL, private repo without token) — the task still becomes ready and the failure is in the start log. +4. Stop guard when the agent is unreachable (tunnel down): the app says it cannot check, offering Stop anyway / Cancel, never silently stopping. +5. Project secret over 64 KB (large CA bundle + instructions): clear error naming the cause before any AWS write. + +--- + +### Task 1: Project model for the AWS host + +**Files:** Modify `app/src-tauri/src/models/project.rs`, `app/src/lib/types.ts`, `app/src-tauri/src/commands/project_commands.rs` (`restore_store_owned_fields`, `Project::new`), store setter in `storage/projects_store.rs`. Test in `models/project.rs` tests. + +**Interfaces — Produces:** +- `Project.host_id: Option` (None = Local), `Project.aws_placement: Option`, `Project.aws: Option`. +- `AwsWorkspaceConfig { cpu: u32, memory_mib: u32, arch: Arch /*X86_64|Arm64*/, storage: StorageMode /*Ephemeral|Hybrid (default)*/, ephemeral_gib: u32 /*21*/, spot: bool, idle_stop_minutes: Option, repos: Vec, wip_push_on_stop: bool /*default true*/, task_arn: Option, access_point_id: Option, task_definition_arn: Option }`; `RepoSource { url: String, branch: Option, folder: String }`. Defaults: 1024 CPU / 4096 MiB / X86_64 / Hybrid / 21. +- `pub fn validate_aws_workspace(cfg: &AwsWorkspaceConfig) -> Result<(), String>` (CPU/memory table, ephemeral range, idle 5–1440, folder `^[A-Za-z0-9._-]{1,64}$` not `.`/`..`, unique folders, URL is `https://…` or `git@host:path` or `ssh://…`, branch passes `git check-ref-format --branch` rules, ≤ 20 repos). +- `Project::is_aws(&self) -> bool` (`host_id.is_some()`). +- `task_arn`, `access_point_id`, `task_definition_arn` are **store-owned** (restored in `restore_store_owned_fields`); setter `ProjectsStore::set_aws_runtime(id, task_arn, access_point_id, task_definition_arn)`. +- `update_project` refuses changing `host_id`/`aws_placement` while the project is not Stopped, and refuses `host_id` naming a connection that doesn't exist. + +- [ ] Tests: old `projects.json` fixture without the fields loads with `host_id == None`; JSON with `aws: {}` gets `wip_push_on_stop == true`; each validation rule has a pass and a fail case (incl. 256/4096 rejected, 1024/2048 accepted, 16384/122880 accepted, ephemeral 20 and 201 rejected, duplicate folder rejected, `..` rejected); store-owned fields survive an `update_project` that tries to change them. +- [ ] Implement; mirror in `types.ts`; `cargo test models::project` and `npx tsc --noEmit` pass. +- [ ] Commit `feat(aws): project model for the AWS host`. + +### Task 2: One env builder for both hosts + +**Files:** Modify `app/src-tauri/src/docker/container.rs` (lines ~1231–1625 of `create_container`), create `app/src-tauri/src/docker/workspace_env.rs` (or a `pub(crate)` fn in container.rs). + +**Interfaces — Produces:** `pub(crate) struct WorkspaceEnvInputs<'a>` (every `create_container` parameter the env block reads: globals, timezone, defaults, instructions, cc settings) and `pub(crate) fn workspace_env(project: &Project, inputs: &WorkspaceEnvInputs) -> Vec<(String, String)>` returning exactly the `KEY=VALUE` pairs `create_container` pushes today **except** `HOST_UID`/`HOST_GID` (host-specific, stay in `create_container`). `create_container` becomes: host pairs + `workspace_env(..)` formatted as `KEY=VALUE`. No behaviour change on Docker. + +- [ ] Test: a table of representative projects (Anthropic shared token, Bedrock keys, Bedrock profile, Ollama, OpenAI-compatible, custom env, instructions, cc settings, VPN on) — the env `create_container` would build before the change (capture via a test-only seam that returns the vector instead of calling Docker) equals the new one, order included. Add the seam first, record the vectors, then refactor. +- [ ] Refactor; all existing `docker::container` tests pass unchanged. +- [ ] Commit `refactor(docker): build container env in one host-neutral function`. + +### Task 3: Workspace-user task policy (P2, P3) + +**Files:** Create `aws/policies/workspace-user-tasks.json`; modify `app/src-tauri/src/aws/policies.rs` (`policy_files(Tier::WorkspaceUser)` lists both files; new checks in `WORKSPACE_USER_CHECKS`; `OWNER_SCOPED`), `aws/README.md`, `app/src/components/settings/hosts/PolicyFiles.tsx` if it hardcodes names (it shouldn't), `aws/live_tests.rs` (workspace-user role gets both files). + +**Interfaces — Produces:** statements `RegisterOwnTaskDefinitions`, `TagOwnTaskDefinitionsAtRegister`, `RetireOwnTaskDefinitions`, `ReadOwnSecrets` as in P3. + +- [ ] Tests (existing lock-step machinery): every new action has a check; checks allowed for the owner and denied for another owner; RegisterTaskDefinition denied without the owner request tag and with an extra tag key; GetSecretValue denied on a secret tagged `triple-c:component`; both workspace files ≤ 6,144. +- [ ] Implement; README's policy table + "Still open" (resource-level support of RegisterTaskDefinition to verify live). +- [ ] Commit `feat(aws): workspace users register their own task definitions`. + +### Task 4: Workspace resources: names, secret, access point, task definition + +**Files:** Create `app/src-tauri/src/aws/workspace.rs` (+ `workspace/tests.rs`); modify `aws/mod.rs`, `aws/tofu_modules.rs` (names), `Cargo.toml` (+ `aws-sdk-efs`, `aws-sdk-cloudwatchlogs`, same feature set and version line as the other aws crates; regenerate `Cargo.lock` with `--locked`-compatible update). + +**Interfaces — Produces:** +- Names: `task_family(env, owner_key, project_id) -> String` (`triple-c---`, ≤ 255, `[A-Za-z0-9_-]`), `project_secret_name(env, owner_key, project_id)`, `access_point_root(owner_key, project_id)`, `log_stream_prefix(owner_key, project_id)`, `user_file_system_token(env, owner_key)` (= the module's `creation_token`, `triple-c--`). +- `pub struct WorkspacePlan { family, cpu, memory, arch, storage, ephemeral_gib, image, exec_role_arn, task_role_arn, log_group, region, secret_arn, secret_keys: Vec, file_system_id: Option, access_point_id: Option, tags }` and `pub fn task_definition_request(plan: &WorkspacePlan) -> RegisterTaskDefinitionInput` (Fargate, awsvpc, runtimePlatform, ephemeralStorage, container `workspace` essential, `secrets` = one entry per key with `valueFrom: :::`, `environment` = only `TRIPLEC_HOST=aws`, `COLORTERM=truecolor`, `TRIPLEC_SECRET_KEYS=`, health check per Global Constraints, `linuxParameters { initProcessEnabled: true, capabilities.add: [SYS_PTRACE] }`, awslogs to `log_group` with prefix, Hybrid → volume `home` with `efsVolumeConfiguration { fileSystemId, transitEncryption: ENABLED, authorizationConfig { accessPointId, iam: ENABLED } }` mounted at `/mnt/triple-c-home`), and the container `dockerLabels` `triple-c.fingerprint` (a sha256, not project data; tags can't carry it because the policy allows only the three tag keys). +- `pub fn workspace_fingerprint(plan: &WorkspacePlan) -> String` (sha256 over everything in the request except the fingerprint label). +- `pub fn project_secret_payload(env_pairs: &[(String,String)], agent_token: &str, files: &[(String, Vec)], repos: &[RepoSource], wip: bool, idle: Option, storage) -> Result` — files as `TRIPLEC_FILE_` base64 (SSH key dir entries, CA cert), repos as `TRIPLEC_REPOS` (JSON), `TRIPLEC_AGENT_TOKEN`, `TRIPLEC_WIP_PUSH`, `TRIPLEC_IDLE_STOP_MINUTES`, `TRIPLEC_STORAGE`; error if serialized > 65,536 bytes (Review Focus 5). +- `#[async_trait] pub trait WorkspaceApi` (fake-able, like `DetectApi`): `find_file_system(token) -> Option<(id, arn)>`, `find_access_point(fs_id, project_id, owner) -> Option`, `create_access_point(fs_id, root, tags) -> String`, `put_project_secret(name, kms_key_arn, json, tags) -> arn` (create, or put value if exists), `latest_task_definition(family) -> Option<(arn, fingerprint)>`, `register_task_definition(req) -> arn`. `SdkWorkspaceApi::new(&SdkConfig)`. +- `pub async fn prepare_workspace(api, who: &CallerIdentity, env: &EnvironmentInfo, project: &Project, env_pairs, files, image) -> Result` — agent token = 48 random bytes, URL-safe base64. + +- [ ] Tests (fake API): request has no project data in `environment` (assert the exact three keys) and every secret key appears as a `valueFrom`; Ephemeral has no volume; Hybrid mounts the access point with IAM + TLS; Arm64 sets `runtimePlatform.cpuArchitecture=ARM64`; unchanged inputs don't register a new revision (fingerprint match) but a changed CPU does; existing access point is reused; payload over 64 KB fails before any API write; names use `owner_key` and never contain `:`. +- [ ] Implement; `cargo test aws::workspace`. +- [ ] Commit `feat(aws): per-project secret, access point and task definition`. + +### Task 5: Run, find, stop and watch tasks + +**Files:** Create `app/src-tauri/src/aws/tasks.rs` (+ tests); modify `host/watch.rs` (`ExitSource::Aws`), `container_watch.rs`. + +**Interfaces — Produces:** +- `#[async_trait] pub trait TaskApi`: `run_task(RunTaskInput) -> TaskInfo`, `describe(cluster, arns) -> Vec`, `list_project_tasks(cluster, family) -> Vec` (ListTasks by family, RUNNING and PENDING), `stop_task(cluster, arn, reason)`, `log_tail(group, stream, limit) -> Vec`. +- `TaskInfo { arn, last_status, desired_status, health: Option, stop_code: Option, stopped_reason: Option, task_definition_arn, runtime_id: Option, exec_agent_running: bool }`. +- `pub fn run_task_request(env: &EnvironmentInfo, task_def_arn, spot: bool, tags) -> RunTaskInput` (cluster, `FARGATE` or capacity provider `FARGATE_SPOT`, awsvpc subnets/SG/`assignPublicIp` from the record, `enableExecuteCommand: true`, the three tags given explicitly). +- `pub async fn find_or_run(api, env, project, task_def_arn, spot) -> Result` — if `list_project_tasks` finds a RUNNING/PENDING task of the family, return it (Review Focus 2); else run. +- `pub async fn wait_ready(api, cluster, arn, progress) -> Result` — poll every 5 s up to 6 min until RUNNING and `exec_agent_running` and `runtime_id`; on STOPPED, error with `stopped_reason` plus the last 50 log lines. +- `pub fn classify_stop(info: &TaskInfo) -> StopKind { Exited, Spot, FailedToStart(String), UserInitiated }` per spec's mapping. +- `pub fn is_quota_error(&AwsError) -> bool` — RunTask quota failures surface as such, no retry. +- `ExitSource::Aws` → `deaths()` polls every 15 s the RUNNING AWS projects' task ARNs (grouped per connection+region) and yields targets whose task is STOPPED; `ContainerTarget::has_exited` for an Agent target consults the tunnel registry's task (Task 6) instead of returning false. + +- [ ] Tests (fake API): existing RUNNING task is reused, no RunTask; PENDING reused; Spot uses capacity provider strategy and no launch type; STOPPED during wait returns the reason and log lines; quota error message; classify_stop table. +- [ ] Implement; `cargo test aws::tasks host::watch container_watch`. +- [ ] Commit `feat(aws): run, find, stop and watch workspace tasks`. + +### Task 6: `ProjectHost::Aws` and the tunnel registry + +**Files:** Modify `host/lifecycle.rs`, `host/mod.rs` (drop `#[allow(dead_code)]` on ssm; `ContainerTarget::for_project`), create `host/aws_host.rs` (or `host/tunnels.rs` + lifecycle arm), modify `commands/project_commands.rs` (start/stop/reconcile/remove), `lib.rs` (shutdown closes tunnels; startup reconcile re-attaches). + +**Interfaces — Produces:** +- `host::tunnels`: global registry `project_id -> LiveTunnel { task: EcsTask, task_arn, cluster_arn, connection_id, region, forward: PortForward, endpoint: AgentEndpoint }`; `register`, `get_endpoint(project_id) -> Option`, `task_of(&AgentEndpoint) -> Option<(conn, region, cluster, task_arn)>`, `close(project_id)`, `close_all()` (called from the `Lifecycle` shutdown path within its budget). +- `ContainerTarget::for_project(p)`: AWS project → `tunnels::get_endpoint(&p.id).map(ContainerTarget::Agent)`; Local unchanged. +- `ProjectHost::for_project(p)` → `Aws` when `p.is_aws()`. `ensure_started`: connection → `sdk_config` → `caller_identity` → environment record (single `GetParameter` read, `parse_environment_record`) → check onboarding (task role exists) → build env via Task 2 + files (SSH dir, CA) → `prepare_workspace` → `find_or_run` → `wait_ready` → `PortForward::open` → agent health through the tunnel → register → `set_aws_runtime`. Progress strings: "Reading environment…", "Preparing workspace…", "Starting task…", "Waiting for the task (about 2 minutes)…", "Connecting…". When `find_or_run` returned an existing task, the agent token is read back with `GetSecretValue` (P2). +- `stop(target)`: Aws → `StopTask` with reason "Stopped from Triple-C", close tunnel. `remove`: deregister + delete the family's revisions, delete the project secret (`ForceDeleteWithoutRecovery` false, 7-day window), and delete the access point only when the caller passes `discard_data: true`. +- `reconcile_project_statuses`: an AWS project marked Running → describe its `task_arn`; RUNNING → re-open tunnel in the background (status stays Running; terminals wait for it); otherwise Stopped (Review Focus 1). +- Boundary tests unchanged and passing. + +- [ ] Tests: `for_project` returns None for an AWS project with no tunnel and the registered endpoint when present; `ProjectHost::for_project` picks Aws; reconcile marks a STOPPED task's project Stopped (fake TaskApi seam); the existing Docker live test still compiles. Live coverage in Task 9. +- [ ] Implement; full `cargo test --locked`. +- [ ] Commit `feat(aws): start, attach and stop a project on its AWS environment`. + +### Task 7: Entrypoint `aws` mode, WIP push, idle stop + +**Files:** Modify `container/entrypoint.sh`, `container/Dockerfile` (COPY two scripts), create `container/triple-c-wip-push`, `container/triple-c-idle-watch`; tests where the existing marked-block shell tests live (`docker/image.rs` pattern: `# >>> name` / `# <<< name` blocks run under bash in a temp dir). + +**Interfaces — Produces (environment contract from Task 4's payload):** +- When `TRIPLEC_HOST=aws` (before the agent-token block): for each `TRIPLEC_FILE_` write base64-decoded content to its mapped path (`SSH_` → `/tmp/.host-ssh/`, `CA_` → `/tmp/.host-ca/`), mode 600, then unset the var; skip `remap_uid_gid` and the Docker-socket step; Hybrid (`TRIPLEC_STORAGE=hybrid`) → `link_persistent_home` (block): for each of `.claude`, `.claude.json`, `.ssh`: if missing under `/mnt/triple-c-home`, move the image's copy there (or create empty), then replace with a symlink; owned by claude. +- `clone_repos` (block): parse `TRIPLEC_REPOS` with `python3 -I`, for each repo whose `/workspace/` is not a git repo, `git clone [--branch b] url folder` as `claude` with `GIT_TERMINAL_PROMPT=0`, 10-minute timeout each; failures printed (`Triple-C: clone of failed: …`) and never fatal (Review Focus 3). +- `triple-c-wip-push [--dry-run] [--reason idle|stop|sigterm]`, run as `claude`, for each git repo directly under `/workspace`: if the tree is dirty or `@{u}..HEAD` non-empty (or no upstream and commits exist), build a commit of the full working tree using a temporary `GIT_INDEX_FILE` (`git add -A` into it, `write-tree`, `commit-tree -p HEAD`), never touching the user's index, HEAD or working tree; push it to `refs/heads/triple-c/wip/`. `--dry-run` prints one JSON line per repo `{"folder","dirty":bool,"unpushed":n,"branch"}` and changes nothing. Exit 0 if all pushes succeeded or nothing to do. +- SIGTERM trap in aws mode: if `TRIPLEC_WIP_PUSH=1`, `timeout 90 triple-c-wip-push --reason sigterm` before `stop_keepalive`. +- `triple-c-idle-watch` (started in aws mode when `TRIPLEC_IDLE_STOP_MINUTES` is set, as root, alongside the agent): every 60 s reads `/v1/activity` with the token file; idle when `now - last_activity_unix ≥ N*60`, `active_execs == 0`, `active_tunnels == 0`, no `pgrep -x claude`, no running scheduler job (`triple-c-scheduler status` reports none). Then WIP push if enabled, then `kill -TERM 1`. + +- [ ] Tests (bash, temp dirs): file vars decode to the right paths with mode 600 and are unset; `link_persistent_home` seeds on first run and is idempotent on second; `clone_repos` skips an existing repo and reports a bad URL without failing; `triple-c-wip-push` on a repo with an untracked file, a staged change and an unpushed commit pushes a branch to a local bare remote whose tree contains all three, and leaves `git status --porcelain` and `HEAD` exactly as before; `--dry-run` JSON shape; clean repo → nothing pushed; idle-watch decision as a pure function `is_idle now last execs tunnels claude_running jobs_running minutes` (block test). +- [ ] Implement; `docker build` of the image succeeds locally (amd64). +- [ ] Commit `feat(container): aws mode — secret files, repo clone, Hybrid home, WIP push, idle stop`. + +### Task 8: Stop guard and the AWS project UI + +**Files:** Modify `commands/project_commands.rs` (new commands), `lib.rs`, `capabilities/default.json`, `app/src/lib/tauri-commands.ts`, `types.ts`; create `app/src/components/projects/home/config/HostSection.tsx`, `app/src/components/projects/StopGuardModal.tsx`; modify `ConfigTab.tsx`, `AddProjectDialog.tsx`, `ProjectHome.tsx`, `ProjectRow.tsx`, `hooks/useProjects.ts`, sections that must disable options on AWS (`RuntimeSection`, `AccessSection`, `WorkspaceSection`). + +**Interfaces — Produces:** +- `check_unsaved_work(project_id) -> UnsavedWork { checked: bool, repos: Vec, error: Option }` — runs `triple-c-wip-push --dry-run` through the agent (`exec_oneshot_as`, user claude); `checked:false` + error when the agent is unreachable (Review Focus 4). +- `stop_project_container(project_id, wip: Option)` where `WipChoice = Push | Discard` (Local ignores it): `Push` runs `triple-c-wip-push --reason stop` first and aborts the stop with its output if it fails. +- `aws_task_log(project_id, limit) -> Vec` (last lines of the current/last task's stream). +- UI: AddProjectDialog gets a host choice (Local | each AWS connection); AWS → region (default region preselected) and environment (from `detectAwsEnvironments`, only `Ready`/`UpdateAvailable` where the user is Joined; otherwise a line pointing at Settings → Hosts), repos instead of folders. HostSection in Config: CPU/memory (valid pairs only), arch, storage (Ephemeral / Hybrid; EFS disabled "coming later"), ephemeral size, Spot, idle stop, WIP push, repos editor; Ephemeral shows "Claude login and history are lost when the workspace stops". On AWS, Docker access, VPN, port mappings and the gateway backend show disabled with "Not available on AWS (Fargate)". Stop on an AWS project calls `check_unsaved_work` first; if anything is dirty/unpushed or `checked:false`, StopGuardModal offers Push to WIP branch (preselected; absent when `checked:false`), Stop anyway, Cancel. Start failure shows `aws_task_log` lines. +- [ ] Tests (vitest + RTL): modal default is Push; unreachable agent hides Push and says why; AWS project hides/locks Local-only options; AddProjectDialog sends `host_id`/`aws_placement`/`aws`; Config saves a valid CPU/memory pair and refuses an invalid one; Rust tests for the command's `WipChoice` handling with a fake target; capability census passes. +- [ ] Implement; `npm run build`, `npx vitest run`, `cargo test --locked`. +- [ ] Commit `feat(aws): AWS project settings and the stop guard`. + +### Task 9: Live verification (needs refreshed credentials) + +**Files:** Extend `app/src-tauri/src/aws/live_tests.rs` (ignored), `aws/README.md` ("Live verification"), spec "To verify". + +- [ ] Using a deployed throwaway environment (public network mode) and a role holding **only the workspace-user files** (onboarded by the admin role first), the ignored test: creates an AWS project (Hybrid, 1 vCPU/2 GB, one public repo), starts it through `ProjectHost::Aws`, runs `echo` and `id` through the agent target, checks the repo was cloned, writes `~/.claude/marker`, stops, starts again (new task, same family revision), finds the marker, verifies a second `ensure_started` while running reuses the task (no second RunTask), sets idle stop to 5 minutes and observes the task stop by itself, checks `bwrap --unshare-user true` (P9 outcome recorded), then removes the project (secret scheduled for deletion, task definitions deleted, access point deleted) and destroys the environment. +- [ ] Record: RegisterTaskDefinition resource-level support (P3), PassRole-by-tag on RunTask (spec's open item), time to HEALTHY, Spot not exercised unless cheap. +- [ ] Spend estimate before running (≈ 2 task-hours ≈ $0.10 + NAT none); cleanup inventory after. +- [ ] Commit `test(aws): live Fargate workspace lifecycle`. + +### Task 10: Docs + +**Files:** spec (phase 5 done, rulings P1–P9, Data model updated: `task_definition_arn`, no `launch_type`/`secrets_delivery` yet), `CLAUDE.md` (`aws/` and `host/` paragraphs: tunnel registry, project secret rule, entrypoint aws mode), `aws/README.md`. + +- [ ] Commit `docs(aws): phase 5 Fargate workspaces`. -- 2.52.0 From 0fc99738bbc119438e214d813446b5e747d5b18a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 08:16:56 -0800 Subject: [PATCH 02/43] feat(aws): project model for the AWS host Co-Authored-By: Claude Opus 5.5 --- .../src/commands/project_commands.rs | 118 ++++++ app/src-tauri/src/models/project.rs | 353 ++++++++++++++++++ app/src-tauri/src/storage/projects_store.rs | 55 +++ app/src/lib/types.ts | 35 ++ 4 files changed, 561 insertions(+) diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index 412b17c..842c457 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -1054,6 +1054,8 @@ pub async fn update_project( // [`crate::models::validate_env_vars_update`]. crate::models::validate_env_vars_update(&stored.custom_env_vars, &project.custom_env_vars)?; + validate_aws_update(&stored, &project, &state.settings_store.get().hosts)?; + restore_store_owned_fields(&mut project, &stored); project.updated_at = chrono::Utc::now().to_rfc3339(); @@ -1115,6 +1117,45 @@ fn restore_store_owned_fields(project: &mut Project, stored: &Project) { // Owned by the marketplace commands; a Config-tab save carries a stale copy. project.marketplace_installs = stored.marketplace_installs.clone(); project.marketplace_disabled = stored.marketplace_disabled.clone(); + // Runtime handles of an AWS task belong to the start/stop path. + if let Some(aws) = project.aws.as_mut() { + let s = stored.aws.as_ref(); + aws.task_arn = s.and_then(|a| a.task_arn.clone()); + aws.access_point_id = s.and_then(|a| a.access_point_id.clone()); + aws.task_definition_arn = s.and_then(|a| a.task_definition_arn.clone()); + } +} + +/// Checks an incoming save's host placement against what is stored and the +/// configured connections. +fn validate_aws_update( + stored: &Project, + project: &Project, + hosts: &[crate::models::HostConnection], +) -> Result<(), String> { + match (&project.host_id, &project.aws_placement, &project.aws) { + (None, None, None) => {} + (Some(id), Some(place), Some(cfg)) => { + if !hosts.iter().any(|h| &h.id == id) { + return Err(format!("Host connection \"{}\" does not exist", id.escape_debug())); + } + crate::models::validate_region(&place.region)?; + crate::aws::tofu_modules::validate_environment_name(&place.environment)?; + crate::models::validate_aws_workspace(cfg)?; + } + _ => { + return Err( + "A host project needs a host, a placement and a workspace configuration together" + .into(), + ) + } + } + if (stored.host_id != project.host_id || stored.aws_placement != project.aws_placement) + && stored.status != ProjectStatus::Stopped + { + return Err("Stop the project before changing its host or placement".into()); + } + Ok(()) } #[tauri::command] @@ -2161,4 +2202,81 @@ mod tests { assert_eq!(payload.marketplace_installs, stored.marketplace_installs); assert_eq!(payload.marketplace_disabled, stored.marketplace_disabled); } + + #[test] + fn a_save_cannot_change_the_aws_runtime_handles() { + let (mut stored, mut payload) = stored_and_stale_payload(); + let mut sc = crate::models::AwsWorkspaceConfig::default(); + sc.task_arn = Some("arn:task".into()); + sc.access_point_id = Some("fsap-1".into()); + sc.task_definition_arn = Some("arn:td".into()); + stored.aws = Some(sc.clone()); + let mut pc = crate::models::AwsWorkspaceConfig::default(); + pc.cpu = 2048; + pc.memory_mib = 8192; + pc.task_arn = Some("evil".into()); + pc.access_point_id = None; + payload.aws = Some(pc); + + restore_store_owned_fields(&mut payload, &stored); + + let a = payload.aws.unwrap(); + assert_eq!(a.task_arn, sc.task_arn); + assert_eq!(a.access_point_id, sc.access_point_id); + assert_eq!(a.task_definition_arn, sc.task_definition_arn); + assert_eq!(a.cpu, 2048, "the edit itself goes through"); + } + + fn host(id: &str) -> crate::models::HostConnection { + crate::models::HostConnection { + id: id.into(), + name: "n".into(), + kind: crate::models::HostKind::Aws { + auth: crate::models::AwsAuth::Profile { name: "p".into() }, + default_region: "us-west-2".into(), + }, + } + } + + fn aws_project() -> Project { + let (mut p, _) = stored_and_stale_payload(); + p.status = ProjectStatus::Stopped; + p.host_id = Some("h1".into()); + p.aws_placement = Some(crate::models::AwsPlacement { region: "us-west-2".into(), environment: "dev".into() }); + p.aws = Some(Default::default()); + p + } + + #[test] + fn host_update_rules() { + let hosts = vec![host("h1")]; + let local = { let mut p = aws_project(); p.host_id = None; p.aws_placement = None; p.aws = None; p }; + assert!(validate_aws_update(&local, &local, &hosts).is_ok()); + let aws = aws_project(); + assert!(validate_aws_update(&aws, &aws, &hosts).is_ok()); + // moving a stopped project onto a host is fine + assert!(validate_aws_update(&local, &aws, &hosts).is_ok()); + // unknown connection + assert!(validate_aws_update(&local, &aws, &[]).unwrap_err().contains("does not exist")); + // partial triple + let mut half = aws_project(); half.aws = None; + assert!(validate_aws_update(&aws, &half, &hosts).is_err()); + let mut half = local.clone(); half.aws = Some(Default::default()); + assert!(validate_aws_update(&local, &half, &hosts).is_err()); + // bad region / environment / workspace + let mut bad = aws_project(); bad.aws_placement.as_mut().unwrap().region = "nope".into(); + assert!(validate_aws_update(&aws, &bad, &hosts).is_err()); + let mut bad = aws_project(); bad.aws_placement.as_mut().unwrap().environment = "Bad_Env".into(); + assert!(validate_aws_update(&aws, &bad, &hosts).is_err()); + let mut bad = aws_project(); bad.aws.as_mut().unwrap().cpu = 300; + assert!(validate_aws_update(&aws, &bad, &hosts).is_err()); + // host/placement frozen while not stopped; other edits still fine + let mut running = aws_project(); running.status = ProjectStatus::Running; + let mut moved = running.clone(); moved.aws_placement.as_mut().unwrap().environment = "prod".into(); + assert!(validate_aws_update(&running, &moved, &hosts).unwrap_err().contains("Stop the project")); + let mut moved = running.clone(); moved.host_id = None; moved.aws_placement = None; moved.aws = None; + assert!(validate_aws_update(&running, &moved, &hosts).is_err()); + let mut edited = running.clone(); edited.aws.as_mut().unwrap().spot = true; + assert!(validate_aws_update(&running, &edited, &hosts).is_ok()); + } } diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index fb32c34..a9a6d34 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -452,10 +452,235 @@ pub struct Project { /// Global marketplace installs this project opts out of. #[serde(default)] pub marketplace_disabled: Vec, + /// The host connection this project runs on. `None` is the local Docker + /// host; `Some(id)` names an entry of `AppSettings.hosts`. + #[serde(default)] + pub host_id: Option, + /// Region and environment of an AWS-hosted project. Set together with + /// `host_id` and `aws`, or not at all. + #[serde(default)] + pub aws_placement: Option, + #[serde(default)] + pub aws: Option, pub created_at: String, pub updated_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AwsPlacement { + pub region: String, + pub environment: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum Arch { + #[default] + X86_64, + Arm64, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum StorageMode { + Ephemeral, + #[default] + Hybrid, +} + +/// One git repository cloned into the workspace. `branch: None` means the +/// remote's default branch. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RepoSource { + pub url: String, + #[serde(default)] + pub branch: Option, + pub folder: String, +} + +fn default_aws_cpu() -> u32 { + 1024 +} +fn default_aws_memory_mib() -> u32 { + 4096 +} +fn default_aws_ephemeral_gib() -> u32 { + 21 +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AwsWorkspaceConfig { + #[serde(default = "default_aws_cpu")] + pub cpu: u32, + #[serde(default = "default_aws_memory_mib")] + pub memory_mib: u32, + #[serde(default)] + pub arch: Arch, + #[serde(default)] + pub storage: StorageMode, + #[serde(default = "default_aws_ephemeral_gib")] + pub ephemeral_gib: u32, + #[serde(default)] + pub spot: bool, + #[serde(default)] + pub idle_stop_minutes: Option, + #[serde(default)] + pub repos: Vec, + /// Push uncommitted work to a WIP branch when the task stops. Defaults on. + #[serde(default = "default_full_permissions")] + pub wip_push_on_stop: bool, + /// Store-owned runtime state: see `ProjectsStore::set_aws_runtime`. + #[serde(default)] + pub task_arn: Option, + #[serde(default)] + pub access_point_id: Option, + #[serde(default)] + pub task_definition_arn: Option, +} + +impl Default for AwsWorkspaceConfig { + fn default() -> Self { + Self { + cpu: default_aws_cpu(), + memory_mib: default_aws_memory_mib(), + arch: Arch::default(), + storage: StorageMode::default(), + ephemeral_gib: default_aws_ephemeral_gib(), + spot: false, + idle_stop_minutes: None, + repos: Vec::new(), + wip_push_on_stop: true, + task_arn: None, + access_point_id: None, + task_definition_arn: None, + } + } +} + +/// The valid Fargate memory range (min, max, step) in MiB for a vCPU size. +fn fargate_memory_range(cpu: u32) -> Option<(u32, u32, u32)> { + match cpu { + 256 => Some((512, 2048, 512)), + 512 => Some((1024, 4096, 1024)), + 1024 => Some((2048, 8192, 1024)), + 2048 => Some((4096, 16384, 1024)), + 4096 => Some((8192, 30720, 1024)), + 8192 => Some((16384, 61440, 4096)), + 16384 => Some((32768, 122880, 8192)), + _ => None, + } +} + +fn validate_repo_url(url: &str) -> Result<(), String> { + let bad = |m: &str| Err(format!("Repository URL \"{}\": {}", url.escape_debug(), m)); + if url.is_empty() || url.len() > 2048 { + return bad("must be 1 to 2048 characters"); + } + if url.chars().any(|c| c.is_control() || c.is_whitespace()) { + return bad("must not contain whitespace or control characters"); + } + if url.starts_with('-') { + return bad("must not start with '-'"); + } + let ok = if let Some(rest) = url.strip_prefix("https://") { + !rest.is_empty() + } else if let Some(rest) = url.strip_prefix("ssh://") { + !rest.is_empty() + } else if let Some((host, path)) = url.split_once(':') { + // scp-like `git@host:path` + !host.is_empty() && !host.contains('/') && !path.is_empty() && host.contains('@') + } else { + false + }; + if ok { + Ok(()) + } else { + bad("use https://..., ssh://... or git@host:path") + } +} + +/// `git check-ref-format --branch` rules. +fn validate_branch(branch: &str) -> Result<(), String> { + let bad = |m: &str| Err(format!("Branch \"{}\": {}", branch.escape_debug(), m)); + if branch.is_empty() || branch.len() > 255 { + return bad("must be 1 to 255 characters"); + } + if branch.starts_with('-') || branch.starts_with('/') || branch.ends_with('/') { + return bad("must not start with '-' or start/end with '/'"); + } + if branch == "@" || branch.ends_with('.') || branch.contains("..") || branch.contains("@{") || branch.contains("//") { + return bad("is not a valid branch name"); + } + if branch + .chars() + .any(|c| c.is_control() || c.is_whitespace() || "~^:?*[\\".contains(c)) + { + return bad("contains a character git does not allow"); + } + for comp in branch.split('/') { + if comp.starts_with('.') || comp.ends_with(".lock") { + return bad("components must not start with '.' or end with '.lock'"); + } + } + Ok(()) +} + +fn validate_repo_folder(folder: &str) -> Result<(), String> { + let ok = !folder.is_empty() + && folder.len() <= 64 + && folder != "." + && folder != ".." + && folder + .bytes() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'-')); + if ok { + Ok(()) + } else { + Err(format!( + "Repository folder \"{}\" must be 1 to 64 characters of letters, digits, '.', '_' or '-', and not '.' or '..'", + folder.escape_debug() + )) + } +} + +pub fn validate_aws_workspace(cfg: &AwsWorkspaceConfig) -> Result<(), String> { + let (min, max, step) = fargate_memory_range(cfg.cpu).ok_or_else(|| { + format!( + "{} is not a Fargate CPU size (256, 512, 1024, 2048, 4096, 8192 or 16384)", + cfg.cpu + ) + })?; + if cfg.memory_mib < min || cfg.memory_mib > max || (cfg.memory_mib - min) % step != 0 { + return Err(format!( + "{} MiB of memory is not valid with {} CPU units: use {} to {} MiB in steps of {}", + cfg.memory_mib, cfg.cpu, min, max, step + )); + } + if !(21..=200).contains(&cfg.ephemeral_gib) { + return Err("Ephemeral storage must be 21 to 200 GiB".into()); + } + if let Some(m) = cfg.idle_stop_minutes { + if !(5..=1440).contains(&m) { + return Err("Idle stop must be 5 to 1440 minutes".into()); + } + } + if cfg.repos.len() > 20 { + return Err("At most 20 repositories are allowed".into()); + } + let mut seen = std::collections::HashSet::new(); + for r in &cfg.repos { + validate_repo_url(&r.url)?; + if let Some(b) = &r.branch { + validate_branch(b)?; + } + validate_repo_folder(&r.folder)?; + if !seen.insert(r.folder.as_str()) { + return Err(format!("Repository folder \"{}\" is used twice", r.folder)); + } + } + Ok(()) +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum ProjectStatus { @@ -701,6 +926,9 @@ impl Project { renamed_session_names: HashMap::new(), marketplace_installs: Vec::new(), marketplace_disabled: Vec::new(), + host_id: None, + aws_placement: None, + aws: None, created_at: now.clone(), updated_at: now, } @@ -717,6 +945,11 @@ impl Project { }) } + /// Whether this project runs on an AWS host rather than local Docker. + pub fn is_aws(&self) -> bool { + self.host_id.is_some() + } + pub fn container_name(&self) -> String { format!("triple-c-{}", self.id) } @@ -1011,4 +1244,124 @@ mod tests { assert!(!obj.contains_key("auth_bridge_enabled")); assert!(!obj.contains_key("browser_view_enabled")); } + + // ── AWS host ──────────────────────────────────────────────────────────── + + fn cfg(cpu: u32, mem: u32) -> AwsWorkspaceConfig { + AwsWorkspaceConfig { cpu, memory_mib: mem, ..Default::default() } + } + + fn repo(url: &str, branch: Option<&str>, folder: &str) -> RepoSource { + RepoSource { url: url.into(), branch: branch.map(String::from), folder: folder.into() } + } + + #[test] + fn an_old_project_without_host_fields_loads_as_local() { + let p = Project::new("demo".to_string(), Vec::new()); + let mut v = serde_json::to_value(&p).unwrap(); + let o = v.as_object_mut().unwrap(); + o.remove("host_id"); + o.remove("aws_placement"); + o.remove("aws"); + let loaded: Project = serde_json::from_value(v).unwrap(); + assert_eq!(loaded.host_id, None); + assert!(loaded.aws.is_none() && loaded.aws_placement.is_none()); + assert!(!loaded.is_aws()); + } + + #[test] + fn an_empty_aws_object_gets_the_defaults() { + let c: AwsWorkspaceConfig = serde_json::from_str("{}").unwrap(); + assert!(c.wip_push_on_stop); + assert_eq!((c.cpu, c.memory_mib, c.ephemeral_gib), (1024, 4096, 21)); + assert_eq!(c.arch, Arch::X86_64); + assert_eq!(c.storage, StorageMode::Hybrid); + assert!(!c.spot && c.idle_stop_minutes.is_none() && c.repos.is_empty()); + assert!(validate_aws_workspace(&c).is_ok()); + let off: AwsWorkspaceConfig = serde_json::from_str(r#"{"wip_push_on_stop":false}"#).unwrap(); + assert!(!off.wip_push_on_stop); + } + + #[test] + fn enums_serialize_snake_case_and_is_aws_follows_host_id() { + assert_eq!(serde_json::to_string(&Arch::Arm64).unwrap(), "\"arm64\""); + assert_eq!(serde_json::to_string(&Arch::X86_64).unwrap(), "\"x86_64\""); + assert_eq!(serde_json::to_string(&StorageMode::Hybrid).unwrap(), "\"hybrid\""); + let mut p = Project::new("d".into(), Vec::new()); + p.host_id = Some("h1".into()); + assert!(p.is_aws()); + } + + #[test] + fn cpu_and_memory_must_be_a_fargate_pair() { + for (c, m) in [(256, 512), (256, 2048), (1024, 2048), (1024, 8192), (2048, 4096), (4096, 30720), (8192, 61440), (16384, 32768), (16384, 122880)] { + assert!(validate_aws_workspace(&cfg(c, m)).is_ok(), "{c}/{m} should be valid"); + } + for (c, m) in [(256, 4096), (1024, 1024), (1024, 9216), (1024, 2500), (300, 1024), (16384, 114688 + 1), (8192, 18432)] { + assert!(validate_aws_workspace(&cfg(c, m)).is_err(), "{c}/{m} should be rejected"); + } + } + + #[test] + fn ephemeral_storage_is_21_to_200() { + for (g, ok) in [(20, false), (21, true), (200, true), (201, false)] { + let c = AwsWorkspaceConfig { ephemeral_gib: g, ..Default::default() }; + assert_eq!(validate_aws_workspace(&c).is_ok(), ok, "{g}"); + } + } + + #[test] + fn idle_stop_is_5_to_1440_or_off() { + for (m, ok) in [(None, true), (Some(4), false), (Some(5), true), (Some(1440), true), (Some(1441), false)] { + let c = AwsWorkspaceConfig { idle_stop_minutes: m, ..Default::default() }; + assert_eq!(validate_aws_workspace(&c).is_ok(), ok, "{m:?}"); + } + } + + fn with_repos(repos: Vec) -> AwsWorkspaceConfig { + AwsWorkspaceConfig { repos, ..Default::default() } + } + + #[test] + fn repo_folders_follow_the_charset_and_are_unique() { + let ok = |f: &str| validate_aws_workspace(&with_repos(vec![repo("https://h/r.git", None, f)])).is_ok(); + assert!(ok("my-repo_1.x")); + assert!(ok(&"a".repeat(64))); + for bad in ["", ".", "..", "a/b", "a b", "ü", &"a".repeat(65)] { + assert!(!ok(bad), "{bad:?}"); + } + let dup = with_repos(vec![repo("https://h/a", None, "x"), repo("https://h/b", None, "x")]); + assert!(validate_aws_workspace(&dup).unwrap_err().contains("twice")); + let two = with_repos(vec![repo("https://h/a", None, "x"), repo("https://h/b", None, "y")]); + assert!(validate_aws_workspace(&two).is_ok()); + } + + #[test] + fn repo_urls_are_https_scp_or_ssh() { + let ok = |u: &str| validate_aws_workspace(&with_repos(vec![repo(u, None, "x")])).is_ok(); + for good in ["https://github.com/o/r.git", "git@github.com:o/r.git", "ssh://git@h/o/r.git"] { + assert!(ok(good), "{good}"); + } + for bad in ["", "http://h/r", "file:///etc", "/local/path", "--upload-pack=x", "github.com:o/r", "https://h/r x", "ext::sh -c id", "https://"] { + assert!(!ok(bad), "{bad:?}"); + } + } + + #[test] + fn branches_follow_check_ref_format() { + let ok = |b: Option<&str>| validate_aws_workspace(&with_repos(vec![repo("https://h/r", b, "x")])).is_ok(); + for good in [None, Some("main"), Some("feat/aws-1"), Some("v1.2.3"), Some("a@b")] { + assert!(ok(good), "{good:?}"); + } + for bad in ["", "-x", "a..b", "a b", "a~1", "a^", "a:b", "a?", "a*", "a[", "a\\b", "a.lock", "x/.hidden", "a/", "/a", "a//b", "a@{1}", "@", "a."] { + assert!(!ok(Some(bad)), "{bad:?}"); + } + } + + #[test] + fn at_most_20_repos() { + let mk = |n: usize| with_repos((0..n).map(|i| repo("https://h/r", None, &format!("r{i}"))).collect()); + assert!(validate_aws_workspace(&mk(20)).is_ok()); + assert!(validate_aws_workspace(&mk(21)).is_err()); + } } diff --git a/app/src-tauri/src/storage/projects_store.rs b/app/src-tauri/src/storage/projects_store.rs index 2f3b8ff..e141a3b 100644 --- a/app/src-tauri/src/storage/projects_store.rs +++ b/app/src-tauri/src/storage/projects_store.rs @@ -440,6 +440,32 @@ impl ProjectsStore { Ok(()) } + /// Granular setter for the AWS runtime handles of a project, store-owned + /// like `container_id`. Fails if the project is not an AWS project. + pub fn set_aws_runtime( + &self, + project_id: &str, + task_arn: Option, + access_point_id: Option, + task_definition_arn: Option, + ) -> Result<(), String> { + let mut projects = self.lock(); + let p = projects + .iter_mut() + .find(|p| p.id == project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + let aws = p + .aws + .as_mut() + .ok_or_else(|| format!("Project {} is not an AWS project", project_id))?; + aws.task_arn = task_arn; + aws.access_point_id = access_point_id; + aws.task_definition_arn = task_definition_arn; + p.updated_at = chrono::Utc::now().to_rfc3339(); + self.save(&projects)?; + Ok(()) + } + pub fn set_container_id(&self, project_id: &str, container_id: Option) -> Result<(), String> { let mut projects = self.lock(); if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) { @@ -847,4 +873,33 @@ mod tests { assert!(sight_and_mark(&store, &full[..12], |_| false).is_some()); fs::remove_dir_all(&dir).ok(); } + + #[test] + fn set_aws_runtime_writes_the_handles_and_only_for_aws_projects() { + let dir = temp_dir("aws-runtime"); + let mut aws = Project::new("a".to_string(), Vec::new()); + aws.host_id = Some("h".into()); + aws.aws = Some(Default::default()); + let local = Project::new("l".to_string(), Vec::new()); + let (aid, lid) = (aws.id.clone(), local.id.clone()); + let store = store_over(&dir, vec![aws, local]); + + store + .set_aws_runtime(&aid, Some("t".into()), Some("ap".into()), Some("td".into())) + .unwrap(); + let a = store.get(&aid).unwrap().aws.unwrap(); + assert_eq!( + (a.task_arn.as_deref(), a.access_point_id.as_deref(), a.task_definition_arn.as_deref()), + (Some("t"), Some("ap"), Some("td")) + ); + let on_disk: Vec = + serde_json::from_str(&fs::read_to_string(dir.join("projects.json")).unwrap()).unwrap(); + assert_eq!(on_disk[0].aws.as_ref().unwrap().task_arn.as_deref(), Some("t")); + + store.set_aws_runtime(&aid, None, None, None).unwrap(); + assert!(store.get(&aid).unwrap().aws.unwrap().task_arn.is_none()); + assert!(store.set_aws_runtime(&lid, None, None, None).is_err()); + assert!(store.set_aws_runtime("nope", None, None, None).is_err()); + fs::remove_dir_all(&dir).ok(); + } } diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index cbd4056..6dc0dd6 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -68,10 +68,45 @@ export interface Project { renamed_session_names: Record; marketplace_installs: MarketplaceInstall[]; marketplace_disabled: MarketplaceItemRef[]; + /** Host connection id this project runs on; null = local Docker. */ + host_id: string | null; + aws_placement: AwsPlacement | null; + aws: AwsWorkspaceConfig | null; created_at: string; updated_at: string; } +export interface AwsPlacement { + region: string; + environment: string; +} + +export type Arch = "x86_64" | "arm64"; +export type StorageMode = "ephemeral" | "hybrid"; + +export interface RepoSource { + url: string; + /** null = the remote's default branch. */ + branch: string | null; + folder: string; +} + +export interface AwsWorkspaceConfig { + cpu: number; + memory_mib: number; + arch: Arch; + storage: StorageMode; + ephemeral_gib: number; + spot: boolean; + idle_stop_minutes: number | null; + repos: RepoSource[]; + wip_push_on_stop: boolean; + /** Store-owned runtime handles; a save cannot change them. */ + task_arn: string | null; + access_point_id: string | null; + task_definition_arn: string | null; +} + export type ProjectStatus = | "stopped" | "starting" -- 2.52.0 From 01e7b2752a40a80a57a758c05e7f4d3774289ff1 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 08:22:13 -0800 Subject: [PATCH 03/43] refactor(docker): build container env in one host-neutral function workspace_env() builds every project-shaped KEY=VALUE pair (backends, CA vars, MANAGED_AUTH_KEYS blanking, custom env, instructions, Claude Code settings); create_container keeps COLORTERM, HOST_UID/GID and the host lookups (CA resolution, keychain token) and formats the pairs. Golden vectors recorded from the pre-refactor block pin the order and values. Co-Authored-By: Claude Sonnet 5.5 --- app/src-tauri/src/docker/container.rs | 373 ++-------- app/src-tauri/src/docker/mod.rs | 1 + app/src-tauri/src/docker/workspace_env.rs | 666 ++++++++++++++++++ .../workspace_env_golden/anthropic_plain.txt | 33 + .../anthropic_shared_token.txt | 33 + .../workspace_env_golden/bedrock_bearer.txt | 33 + .../bedrock_profile_global_fallbacks.txt | 33 + .../bedrock_static_keys.txt | 33 + .../claude_code_settings.txt | 33 + .../workspace_env_golden/corporate_ca.txt | 33 + .../workspace_env_golden/custom_env.txt | 37 + .../instructions_and_mission_control.txt | 34 + .../llamacpp_global_url.txt | 33 + .../docker/workspace_env_golden/ollama.txt | 33 + .../openai_compatible.txt | 33 + .../workspace_env_golden/vpn_git_timezone.txt | 37 + 16 files changed, 1151 insertions(+), 327 deletions(-) create mode 100644 app/src-tauri/src/docker/workspace_env.rs create mode 100644 app/src-tauri/src/docker/workspace_env_golden/anthropic_plain.txt create mode 100644 app/src-tauri/src/docker/workspace_env_golden/anthropic_shared_token.txt create mode 100644 app/src-tauri/src/docker/workspace_env_golden/bedrock_bearer.txt create mode 100644 app/src-tauri/src/docker/workspace_env_golden/bedrock_profile_global_fallbacks.txt create mode 100644 app/src-tauri/src/docker/workspace_env_golden/bedrock_static_keys.txt create mode 100644 app/src-tauri/src/docker/workspace_env_golden/claude_code_settings.txt create mode 100644 app/src-tauri/src/docker/workspace_env_golden/corporate_ca.txt create mode 100644 app/src-tauri/src/docker/workspace_env_golden/custom_env.txt create mode 100644 app/src-tauri/src/docker/workspace_env_golden/instructions_and_mission_control.txt create mode 100644 app/src-tauri/src/docker/workspace_env_golden/llamacpp_global_url.txt create mode 100644 app/src-tauri/src/docker/workspace_env_golden/ollama.txt create mode 100644 app/src-tauri/src/docker/workspace_env_golden/openai_compatible.txt create mode 100644 app/src-tauri/src/docker/workspace_env_golden/vpn_git_timezone.txt diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index ef39fbb..b506a23 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -9,6 +9,7 @@ use sha2::{Sha256, Digest}; use super::ca_certs; use super::client::get_docker; +use super::workspace_env::{workspace_env, WorkspaceEnvInputs}; use crate::models::AppSettings; use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalLlamaCppSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, PortMapping, Project, ProjectPath}; @@ -139,7 +140,7 @@ The user can click a file path in this terminal to open it in a viewer (while a /// instructions, appending port mapping docs, and appending scheduler docs. /// Used by both create_container() and container_needs_recreation() to ensure /// the same value is produced in both paths. -fn build_claude_instructions( +pub(super) fn build_claude_instructions( global_instructions: Option<&str>, project_instructions: Option<&str>, port_mappings: &[PortMapping], @@ -356,7 +357,7 @@ fn model_alias_fingerprint_part( } /// Whether `key` is an env var name Triple-C reserves for itself. -fn is_reserved_env_key(key: &str) -> bool { +pub(super) fn is_reserved_env_key(key: &str) -> bool { let upper = key.to_uppercase(); RESERVED_ENV_PREFIXES.iter().any(|p| upper.starts_with(p)) || RESERVED_ENV_EXACT.iter().any(|e| upper == *e) @@ -375,7 +376,7 @@ fn is_reserved_env_key(key: &str) -> bool { /// /// Empty stays empty rather than becoming the hash of the empty string: an empty /// label is how every other `triple-c.*` key says "nothing configured". -fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String { +pub(super) fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String { let mut parts: Vec = Vec::new(); for env_var in custom_env_vars { let key = env_var.key.trim(); @@ -432,7 +433,7 @@ fn claude_token_label(project: &Project) -> String { /// Merge global and per-project custom environment variables. /// Per-project variables override global variables with the same key. -fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec { +pub(super) fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec { let mut merged: std::collections::HashMap = std::collections::HashMap::new(); for ev in global { let key = ev.key.trim().to_string(); @@ -494,7 +495,7 @@ fn sha256_hex(input: &str) -> String { /// Resolve a per-project string value with a global fallback. Returns `None` /// when both are blank, otherwise the per-project value if set, else the global. -fn resolve_with_global<'a>(per_project: Option<&'a str>, global: Option<&'a str>) -> Option<&'a str> { +pub(super) fn resolve_with_global<'a>(per_project: Option<&'a str>, global: Option<&'a str>) -> Option<&'a str> { let project_val = per_project.map(str::trim).filter(|s| !s.is_empty()); if project_val.is_some() { return project_val; @@ -659,7 +660,7 @@ fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String { /// to the global set and never turn a globally-enabled setting off. `None` at /// project level means "inherit", which is now a state the project can /// actually be in rather than the only state an off switch could produce. -fn merge_claude_code_settings( +pub(super) fn merge_claude_code_settings( global: Option<&ClaudeCodeSettings>, project: Option<&ClaudeCodeSettings>, ) -> Option { @@ -759,7 +760,7 @@ fn compute_claude_code_settings_fingerprint( /// /// The other two are documented as "set to `1` to …" with no meaning attached /// to `0`, so `0` is unambiguously neutral and is stated outright. -fn claude_code_env_vars(settings: Option<&ClaudeCodeSettings>) -> Vec { +pub(super) fn claude_code_env_vars(settings: Option<&ClaudeCodeSettings>) -> Vec { let owned; let s = match settings { Some(s) => s, @@ -822,7 +823,7 @@ fn claude_code_env_vars(settings: Option<&ClaudeCodeSettings>) -> Vec { /// /// Returns a `String` rather than an `Option`: there is no longer any /// input for which this produces nothing to say. -fn build_claude_code_settings_json( +pub(super) fn build_claude_code_settings_json( settings: Option<&ClaudeCodeSettings>, sandbox_enabled: bool, ) -> String { @@ -1080,7 +1081,7 @@ type VpnHostConfigParts = ( /// `MISSION_CONTROL_ENABLED` twenty lines above shows the failure this avoids — /// it is pushed only when true, so a snapshot's baked `=1` survives the toggle /// going off. -fn vpn_env_var(enabled: bool) -> String { +pub(super) fn vpn_env_var(enabled: bool) -> String { format!("VPN_SUPPORT_ENABLED={}", u8::from(enabled)) } @@ -1266,212 +1267,24 @@ pub async fn create_container( log::debug!("Skipping HOST_UID/HOST_GID on Windows — Docker Desktop's Linux VM handles user mapping"); } - if let Some(ref token) = project.git_token { - env_vars.push(format!("GIT_TOKEN={}", token)); - } - // Per-project git user overrides global defaults - let effective_git_name = project.git_user_name.as_deref().or(default_git_user_name); - let effective_git_email = project.git_user_email.as_deref().or(default_git_user_email); - if let Some(name) = effective_git_name { - env_vars.push(format!("GIT_USER_NAME={}", name)); - } - if let Some(email) = effective_git_email { - env_vars.push(format!("GIT_USER_EMAIL={}", email)); - } - - // Bedrock configuration - if project.backend == Backend::Bedrock { - if let Some(ref bedrock) = project.bedrock_config { - env_vars.push("CLAUDE_CODE_USE_BEDROCK=1".to_string()); - - // AWS region: per-project overrides global - let region = if !bedrock.aws_region.is_empty() { - Some(bedrock.aws_region.clone()) - } else { - global_aws.aws_region.clone() - }; - if let Some(ref r) = region { - env_vars.push(format!("AWS_REGION={}", r)); - } - - match bedrock.auth_method { - BedrockAuthMethod::StaticCredentials => { - // Static/session credentials are NOT injected as env vars. - // They are written to ~/.aws/credentials by - // sync_bedrock_credentials() on every container - // start, so rotated/updated keys are picked up without a - // full container recreation (and never get baked into the - // snapshot image). The empty values set by the - // MANAGED_AUTH_KEYS neutralization pass below are ignored by - // the AWS SDK, which falls through to the credentials file. - } - BedrockAuthMethod::Profile => { - // Per-project profile overrides global - let profile = bedrock.aws_profile.as_ref() - .or(global_aws.aws_profile.as_ref()); - if let Some(p) = profile { - env_vars.push(format!("AWS_PROFILE={}", p)); - } - env_vars.push("AWS_SSO_AUTH_REFRESH_CMD=triple-c-sso-refresh".to_string()); - } - BedrockAuthMethod::BearerToken => { - if let Some(ref token) = bedrock.aws_bearer_token { - env_vars.push(format!("AWS_BEARER_TOKEN_BEDROCK={}", token)); - } - } - } - - if let Some(model) = resolve_with_global( - bedrock.model_id.as_deref(), - global_aws.default_model_id.as_deref(), - ) { - env_vars.push(format!("ANTHROPIC_MODEL={}", model)); - } - - if bedrock.disable_prompt_caching { - env_vars.push("DISABLE_PROMPT_CACHING=1".to_string()); - } - - if let Some(ref tier) = bedrock.service_tier { - let trimmed = tier.trim(); - if !trimmed.is_empty() { - env_vars.push(format!("ANTHROPIC_BEDROCK_SERVICE_TIER={}", trimmed)); - } - } - } - } - - // ── Custom-endpoint backends ───────────────────────────────────────────── - // Ollama, llama.cpp and the OpenAI-Compatible gateway all point Claude Code - // at a non-Anthropic server via ANTHROPIC_BASE_URL. Each resolves its model - // id here; the model-alias vars are emitted once below, from - // `alias_model` / `alias_haiku`, so the three backends cannot drift apart. - let mut alias_model: Option = None; - let mut alias_haiku: Option = None; - - // Ollama configuration - if project.backend == Backend::Ollama { - if let Some(ref ollama) = project.ollama_config { - if let Some(url) = resolve_with_global( - Some(&ollama.base_url), - global_ollama.base_url.as_deref(), - ) { - env_vars.push(format!("ANTHROPIC_BASE_URL={}", url)); - } - env_vars.push("ANTHROPIC_AUTH_TOKEN=ollama".to_string()); - if let Some(model) = resolve_with_global( - ollama.model_id.as_deref(), - global_ollama.default_model_id.as_deref(), - ) { - env_vars.push(format!("ANTHROPIC_MODEL={}", model)); - alias_model = Some(model.to_string()); - } - alias_haiku = resolve_with_global( - ollama.haiku_model_id.as_deref(), - global_ollama.default_haiku_model_id.as_deref(), - ) - .map(str::to_string); - } - } - - // llama.cpp (llama-server) configuration - if project.backend == Backend::LlamaCpp { - if let Some(ref cfg) = project.llamacpp_config { - if let Some(url) = resolve_with_global( - Some(&cfg.base_url), - global_llamacpp.base_url.as_deref(), - ) { - env_vars.push(format!("ANTHROPIC_BASE_URL={}", url)); - } - // llama-server only enforces an Authorization header when it was - // started with `--api-key` (default: none), so the value here is - // ignored in the common case. Claude Code still refuses to run - // against a custom base URL with no credential at all, so a - // placeholder is always sent — same trick as the Ollama branch - // above, which sends the literal "ollama". - env_vars.push("ANTHROPIC_AUTH_TOKEN=llama.cpp".to_string()); - if let Some(model) = resolve_with_global( - cfg.model_id.as_deref(), - global_llamacpp.default_model_id.as_deref(), - ) { - env_vars.push(format!("ANTHROPIC_MODEL={}", model)); - alias_model = Some(model.to_string()); - } - alias_haiku = resolve_with_global( - cfg.haiku_model_id.as_deref(), - global_llamacpp.default_haiku_model_id.as_deref(), - ) - .map(str::to_string); - } - } - - // OpenAI Compatible configuration - if project.backend == Backend::OpenAiCompatible { - if let Some(ref config) = project.openai_compatible_config { - if let Some(url) = resolve_with_global( - Some(&config.base_url), - global_openai_compatible.base_url.as_deref(), - ) { - env_vars.push(format!("ANTHROPIC_BASE_URL={}", url)); - } - if let Some(ref key) = config.api_key { - env_vars.push(format!("ANTHROPIC_AUTH_TOKEN={}", key)); - } - if let Some(model) = resolve_with_global( - config.model_id.as_deref(), - global_openai_compatible.default_model_id.as_deref(), - ) { - env_vars.push(format!("ANTHROPIC_MODEL={}", model)); - alias_model = Some(model.to_string()); - } - alias_haiku = resolve_with_global( - config.haiku_model_id.as_deref(), - global_openai_compatible.default_haiku_model_id.as_deref(), - ) - .map(str::to_string); - } - } - - // Model aliases — the fix for background Claude Code calls against a local - // server. Only for backends that talk to a custom endpoint: Anthropic and - // Bedrock reach servers that really do host the Anthropic model ids, so - // they keep Claude Code's own defaults. Anything not emitted here is - // blanked by the MANAGED_AUTH_KEYS pass below, so switching *away* from a - // custom endpoint clears the aliases out of the snapshot image too. - if project.backend.uses_custom_endpoint() { - for (key, value) in - compute_model_aliases(alias_model.as_deref(), alias_haiku.as_deref()) - { - env_vars.push(format!("{}={}", key, value)); - } - } - // Shared Claude Code OAuth token (Anthropic backend only, opt-out per - // project). Injected *before* the neutralization pass below so that pass - // sees it as already-set; when it is absent the pass actively blanks the - // variable instead of leaving a stale one baked into the snapshot image. + // project). Read here — it lives in the keychain — and handed to + // `workspace_env`, which injects it before the neutralization pass. let shared_claude = shared_claude_auth(project); - if let Some((ref token, _)) = shared_claude { - env_vars.push(format!("{}={}", CLAUDE_OAUTH_TOKEN_ENV, token)); + if shared_claude.is_some() { log::info!( "Injecting the shared Claude authentication token into the container for project {}", project.id ); } - // ── Corporate CA certificates ─────────────────────────────────────────── - // Resolved here (rather than down with the mounts) so the env vars land - // *before* the neutralization pass below and are seen as already-set. + // Corporate CA certificates, resolved here (not in `workspace_env`) because + // the mount below needs the same resolution. // // A bad path is a hard error, not a warning: behind a TLS-terminating // proxy a container without the CA fails every HTTPS call — npm, pip, git, // and Claude Code's own API requests — each in its own confusing way. One // message naming the path is far kinder. - // - // The values are set here rather than exported by the entrypoint because a - // terminal session is a `docker exec`, which sees the container's - // configured env and nothing the entrypoint exported. Same lesson as - // `$BROWSER` and the URL relay shim. let effective_ca_path = resolve_with_global(project.ca_cert_path.as_deref(), default_ca_cert_path); let resolved_ca = ca_certs::resolve(effective_ca_path)?; @@ -1483,110 +1296,40 @@ pub async fn create_container( project.id ); } - for (key, value) in ca_certs::ca_env_vars(resolved_ca.as_ref()) { - env_vars.push(format!("{}={}", key, value)); - } - // ── Neutralize stale backend auth env vars ────────────────────────────── - // When a project switches backends (e.g. Bedrock → Anthropic) the container - // is recreated *from a snapshot image* committed off the previous container, - // and `docker commit` copies that container's full ENV into the image. So - // any auth var set under the old backend (e.g. CLAUDE_CODE_USE_BEDROCK=1, - // AWS_PROFILE, a model alias) survives in the image ENV and stays active - // unless we explicitly override it at create time. - // - // This pass is about *staleness*, not secrecy. It fixes the container it is - // building and does nothing to the image, so it is not — and never was — - // a defence against a credential baked into a snapshot. That is - // `commit_container_snapshot`'s job, via SECRET_ENV_KEYS. - // Create-time env takes precedence over image ENV, so we set every managed - // auth key the *current* backend did NOT set to an empty value, clearing the - // stale baked-in one. - const MANAGED_AUTH_KEYS: &[&str] = &[ - "CLAUDE_CODE_USE_BEDROCK", - "AWS_REGION", - "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY", - "AWS_SESSION_TOKEN", - "AWS_PROFILE", - "AWS_BEARER_TOKEN_BEDROCK", - "AWS_SSO_AUTH_REFRESH_CMD", - "ANTHROPIC_BASE_URL", - "ANTHROPIC_AUTH_TOKEN", - "ANTHROPIC_MODEL", - "DISABLE_PROMPT_CACHING", - "ANTHROPIC_BEDROCK_SERVICE_TIER", - // Switching from a custom-endpoint backend to Anthropic or Bedrock must - // *clear* the aliases, not merely stop setting them: a stale - // ANTHROPIC_DEFAULT_HAIKU_MODEL baked into the snapshot image would - // keep pointing background calls at a model id the new backend has - // never heard of. - ANTHROPIC_DEFAULT_OPUS_MODEL, - ANTHROPIC_DEFAULT_SONNET_MODEL, - ANTHROPIC_DEFAULT_HAIKU_MODEL, - ANTHROPIC_DEFAULT_FABLE_MODEL, - // Revoking the shared token, opting a project out, or switching away - // from the Anthropic backend must *clear* this, not merely stop setting - // it — otherwise the value committed into the snapshot image keeps - // authenticating the container with a credential the user removed. - CLAUDE_OAUTH_TOKEN_ENV, - ]; - // Same reasoning for the CA vars — `ca_env_vars` already emits them empty - // when no CA is configured, so this list is belt-and-braces for a snapshot - // committed by a build that predates the feature. - let managed_keys: Vec<&str> = MANAGED_AUTH_KEYS - .iter() - .copied() - .chain(ca_certs::CA_ENV_KEYS.iter().copied()) - .collect(); - let already_set: std::collections::HashSet = env_vars - .iter() - .filter_map(|e| e.split('=').next().map(|k| k.to_string())) - .collect(); - for key in &managed_keys { - if !already_set.contains(*key) { - env_vars.push(format!("{}=", key)); - } - } + // Everything project-shaped (backend auth, CA vars, custom env, instructions, + // Claude Code settings, ...) comes from the one host-neutral builder, so the + // AWS host can put the same pairs in its secret. + env_vars.extend( + workspace_env( + project, + &WorkspaceEnvInputs { + global_aws, + global_ollama, + global_llamacpp, + global_openai_compatible, + global_claude_instructions, + global_custom_env_vars, + timezone, + global_claude_code_settings, + default_git_user_name, + default_git_user_email, + resolved_ca: resolved_ca.as_ref(), + shared_claude_token: shared_claude.as_ref().map(|(token, _)| token.as_str()), + }, + ) + .into_iter() + .map(|(key, value)| format!("{}={}", key, value)), + ); - // Custom environment variables (global + per-project, project overrides global for same key) - let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars); - for env_var in &merged_env { - let key = env_var.key.trim(); - if key.is_empty() { - continue; - } - if is_reserved_env_key(key) { - log::warn!("Skipping reserved env var: {}", key); - continue; - } - env_vars.push(format!("{}={}", key, env_var.value)); - } - let custom_env_fingerprint = compute_env_fingerprint(&merged_env); - env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint)); - - // Container timezone - if let Some(tz) = timezone { - if !tz.is_empty() { - env_vars.push(format!("TZ={}", tz)); - } - } - - // Mission Control env var - if project.mission_control_enabled { - env_vars.push("MISSION_CONTROL_ENABLED=1".to_string()); - } - - env_vars.push(vpn_env_var(project.vpn_support_enabled)); - - // Permission mode — read by triple-c-task-runner for scheduled (headless) - // Claude Code runs. Interactive terminals get the flags directly instead. - env_vars.push(format!( - "TRIPLE_C_PERMISSION_MODE={}", - project.effective_permission_mode().as_env_value() + // The labels below fingerprint the inputs that went into the env; derive + // them with the same helpers `workspace_env` used, so both always agree. + let effective_git_name = project.git_user_name.as_deref().or(default_git_user_name); + let effective_git_email = project.git_user_email.as_deref().or(default_git_user_email); + let custom_env_fingerprint = compute_env_fingerprint(&merge_custom_env_vars( + global_custom_env_vars, + &project.custom_env_vars, )); - - // Claude instructions (global + per-project, plus port mapping info + scheduler docs) let combined_instructions = build_claude_instructions( global_claude_instructions, project.claude_instructions.as_deref(), @@ -1594,34 +1337,10 @@ pub async fn create_container( project.mission_control_enabled, project.sandbox_mode_enabled, ); - - if let Some(ref instructions) = combined_instructions { - env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions)); - } - - // Claude Code settings (global + per-project merged) let merged_cc_settings = merge_claude_code_settings( global_claude_code_settings, project.claude_code_settings.as_ref(), ); - // Env-var-based settings, read directly by Claude Code. Extracted and unit - // tested for the `vpn_host_config` reason: a container is created once, by - // a very long function, and a variable emitted with the wrong value here is - // invisible until someone wonders why a switch does nothing. - env_vars.extend(claude_code_env_vars(merged_cc_settings.as_ref())); - - // settings.json-based settings (applied by the entrypoint). Always emitted, - // even with no `ClaudeCodeSettings` struct present: the payload asserts the - // *whole* managed key set, so "no settings" still has to be stated over a - // settings.json left behind on the config volume by a previous config. - // Split so the payload is safe under an older entrypoint that has never - // heard of the null-means-delete convention — see - // `split_claude_code_settings_payload`. - let (cc_settings_set, cc_settings_clear) = split_claude_code_settings_payload( - &build_claude_code_settings_json(merged_cc_settings.as_ref(), project.sandbox_mode_enabled), - ); - env_vars.push(format!("CLAUDE_CODE_SETTINGS_JSON={}", cc_settings_set)); - env_vars.push(format!("CLAUDE_CODE_SETTINGS_CLEAR={}", cc_settings_clear)); let mut mounts: Vec = Vec::new(); diff --git a/app/src-tauri/src/docker/mod.rs b/app/src-tauri/src/docker/mod.rs index 0a15d89..874a756 100644 --- a/app/src-tauri/src/docker/mod.rs +++ b/app/src-tauri/src/docker/mod.rs @@ -2,6 +2,7 @@ pub mod ca_certs; pub mod client; pub mod events; pub mod container; +pub mod workspace_env; pub mod image; pub mod exec; pub mod gateway; diff --git a/app/src-tauri/src/docker/workspace_env.rs b/app/src-tauri/src/docker/workspace_env.rs new file mode 100644 index 0000000..cd8f3d9 --- /dev/null +++ b/app/src-tauri/src/docker/workspace_env.rs @@ -0,0 +1,666 @@ +//! The container environment a project gets, built once for every host. +//! +//! Docker passes these pairs as the container's `Env`; the AWS host stores the +//! very same pairs in a per-project Secrets Manager secret. Both therefore call +//! [`workspace_env`], so a backend, a CA bundle or a custom variable cannot +//! reach one host and not the other. +//! +//! What is *not* here: `COLORTERM`, `HOST_UID` and `HOST_GID` (host-specific, +//! assembled by `create_container`), and anything that needs the host — the +//! corporate CA is resolved from disk and the shared Claude token read from +//! the keychain by the caller, then handed in through [`WorkspaceEnvInputs`]. +//! Order is part of the contract: it is the order `docker inspect` shows. + +use super::ca_certs::{self, ResolvedCa}; +use super::container::{ + build_claude_code_settings_json, build_claude_instructions, claude_code_env_vars, + compute_env_fingerprint, compute_model_aliases, is_reserved_env_key, merge_claude_code_settings, + merge_custom_env_vars, resolve_with_global, split_claude_code_settings_payload, vpn_env_var, + ANTHROPIC_DEFAULT_FABLE_MODEL, ANTHROPIC_DEFAULT_HAIKU_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL, + ANTHROPIC_DEFAULT_SONNET_MODEL, CLAUDE_OAUTH_TOKEN_ENV, +}; +use crate::models::{ + Backend, BedrockAuthMethod, ClaudeCodeSettings, EnvVar, GlobalAwsSettings, + GlobalLlamaCppSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, Project, +}; + +/// Everything besides the project that shapes its environment. +pub(crate) struct WorkspaceEnvInputs<'a> { + pub global_aws: &'a GlobalAwsSettings, + pub global_ollama: &'a GlobalOllamaSettings, + pub global_llamacpp: &'a GlobalLlamaCppSettings, + pub global_openai_compatible: &'a GlobalOpenAiCompatibleSettings, + pub global_claude_instructions: Option<&'a str>, + pub global_custom_env_vars: &'a [EnvVar], + pub timezone: Option<&'a str>, + pub global_claude_code_settings: Option<&'a ClaudeCodeSettings>, + pub default_git_user_name: Option<&'a str>, + pub default_git_user_email: Option<&'a str>, + /// The corporate CA, already resolved from disk (`ca_certs::resolve`). + pub resolved_ca: Option<&'a ResolvedCa>, + /// The shared Claude OAuth token, already read from the keychain and + /// already filtered by `shared_claude_auth` (backend, opt-out, non-blank). + pub shared_claude_token: Option<&'a str>, +} + +/// `"KEY=VALUE"` to a pair, splitting at the first `=` (values may contain `=`). +fn split_pair(entry: &str) -> (String, String) { + match entry.split_once('=') { + Some((k, v)) => (k.to_string(), v.to_string()), + None => (entry.to_string(), String::new()), + } +} + +/// The `KEY=VALUE` pairs a project's container gets, in order, excluding the +/// host-specific ones. See the module docs. +pub(crate) fn workspace_env( + project: &Project, + inputs: &WorkspaceEnvInputs, +) -> Vec<(String, String)> { + let WorkspaceEnvInputs { + global_aws, + global_ollama, + global_llamacpp, + global_openai_compatible, + global_claude_instructions, + global_custom_env_vars, + timezone, + global_claude_code_settings, + default_git_user_name, + default_git_user_email, + resolved_ca, + shared_claude_token, + } = *inputs; + let mut env: Vec<(String, String)> = Vec::new(); + + if let Some(ref token) = project.git_token { + env.push(("GIT_TOKEN".to_string(), token.to_string())); + } + // Per-project git user overrides global defaults + let effective_git_name = project.git_user_name.as_deref().or(default_git_user_name); + let effective_git_email = project.git_user_email.as_deref().or(default_git_user_email); + if let Some(name) = effective_git_name { + env.push(("GIT_USER_NAME".to_string(), name.to_string())); + } + if let Some(email) = effective_git_email { + env.push(("GIT_USER_EMAIL".to_string(), email.to_string())); + } + + // Bedrock configuration + if project.backend == Backend::Bedrock { + if let Some(ref bedrock) = project.bedrock_config { + env.push(("CLAUDE_CODE_USE_BEDROCK".to_string(), "1".to_string())); + + // AWS region: per-project overrides global + let region = if !bedrock.aws_region.is_empty() { + Some(bedrock.aws_region.clone()) + } else { + global_aws.aws_region.clone() + }; + if let Some(ref r) = region { + env.push(("AWS_REGION".to_string(), r.to_string())); + } + + match bedrock.auth_method { + BedrockAuthMethod::StaticCredentials => { + // Static/session credentials are NOT injected as env vars. + // They are written to ~/.aws/credentials by + // sync_bedrock_credentials() on every container + // start, so rotated/updated keys are picked up without a + // full container recreation (and never get baked into the + // snapshot image). The empty values set by the + // MANAGED_AUTH_KEYS neutralization pass below are ignored by + // the AWS SDK, which falls through to the credentials file. + } + BedrockAuthMethod::Profile => { + // Per-project profile overrides global + let profile = bedrock.aws_profile.as_ref() + .or(global_aws.aws_profile.as_ref()); + if let Some(p) = profile { + env.push(("AWS_PROFILE".to_string(), p.to_string())); + } + env.push(("AWS_SSO_AUTH_REFRESH_CMD".to_string(), "triple-c-sso-refresh".to_string())); + } + BedrockAuthMethod::BearerToken => { + if let Some(ref token) = bedrock.aws_bearer_token { + env.push(("AWS_BEARER_TOKEN_BEDROCK".to_string(), token.to_string())); + } + } + } + + if let Some(model) = resolve_with_global( + bedrock.model_id.as_deref(), + global_aws.default_model_id.as_deref(), + ) { + env.push(("ANTHROPIC_MODEL".to_string(), model.to_string())); + } + + if bedrock.disable_prompt_caching { + env.push(("DISABLE_PROMPT_CACHING".to_string(), "1".to_string())); + } + + if let Some(ref tier) = bedrock.service_tier { + let trimmed = tier.trim(); + if !trimmed.is_empty() { + env.push(("ANTHROPIC_BEDROCK_SERVICE_TIER".to_string(), trimmed.to_string())); + } + } + } + } + + // ── Custom-endpoint backends ───────────────────────────────────────────── + // Ollama, llama.cpp and the OpenAI-Compatible gateway all point Claude Code + // at a non-Anthropic server via ANTHROPIC_BASE_URL. Each resolves its model + // id here; the model-alias vars are emitted once below, from + // `alias_model` / `alias_haiku`, so the three backends cannot drift apart. + let mut alias_model: Option = None; + let mut alias_haiku: Option = None; + + // Ollama configuration + if project.backend == Backend::Ollama { + if let Some(ref ollama) = project.ollama_config { + if let Some(url) = resolve_with_global( + Some(&ollama.base_url), + global_ollama.base_url.as_deref(), + ) { + env.push(("ANTHROPIC_BASE_URL".to_string(), url.to_string())); + } + env.push(("ANTHROPIC_AUTH_TOKEN".to_string(), "ollama".to_string())); + if let Some(model) = resolve_with_global( + ollama.model_id.as_deref(), + global_ollama.default_model_id.as_deref(), + ) { + env.push(("ANTHROPIC_MODEL".to_string(), model.to_string())); + alias_model = Some(model.to_string()); + } + alias_haiku = resolve_with_global( + ollama.haiku_model_id.as_deref(), + global_ollama.default_haiku_model_id.as_deref(), + ) + .map(str::to_string); + } + } + + // llama.cpp (llama-server) configuration + if project.backend == Backend::LlamaCpp { + if let Some(ref cfg) = project.llamacpp_config { + if let Some(url) = resolve_with_global( + Some(&cfg.base_url), + global_llamacpp.base_url.as_deref(), + ) { + env.push(("ANTHROPIC_BASE_URL".to_string(), url.to_string())); + } + // llama-server only enforces an Authorization header when it was + // started with `--api-key` (default: none), so the value here is + // ignored in the common case. Claude Code still refuses to run + // against a custom base URL with no credential at all, so a + // placeholder is always sent — same trick as the Ollama branch + // above, which sends the literal "ollama". + env.push(("ANTHROPIC_AUTH_TOKEN".to_string(), "llama.cpp".to_string())); + if let Some(model) = resolve_with_global( + cfg.model_id.as_deref(), + global_llamacpp.default_model_id.as_deref(), + ) { + env.push(("ANTHROPIC_MODEL".to_string(), model.to_string())); + alias_model = Some(model.to_string()); + } + alias_haiku = resolve_with_global( + cfg.haiku_model_id.as_deref(), + global_llamacpp.default_haiku_model_id.as_deref(), + ) + .map(str::to_string); + } + } + + // OpenAI Compatible configuration + if project.backend == Backend::OpenAiCompatible { + if let Some(ref config) = project.openai_compatible_config { + if let Some(url) = resolve_with_global( + Some(&config.base_url), + global_openai_compatible.base_url.as_deref(), + ) { + env.push(("ANTHROPIC_BASE_URL".to_string(), url.to_string())); + } + if let Some(ref key) = config.api_key { + env.push(("ANTHROPIC_AUTH_TOKEN".to_string(), key.to_string())); + } + if let Some(model) = resolve_with_global( + config.model_id.as_deref(), + global_openai_compatible.default_model_id.as_deref(), + ) { + env.push(("ANTHROPIC_MODEL".to_string(), model.to_string())); + alias_model = Some(model.to_string()); + } + alias_haiku = resolve_with_global( + config.haiku_model_id.as_deref(), + global_openai_compatible.default_haiku_model_id.as_deref(), + ) + .map(str::to_string); + } + } + + // Model aliases — the fix for background Claude Code calls against a local + // server. Only for backends that talk to a custom endpoint: Anthropic and + // Bedrock reach servers that really do host the Anthropic model ids, so + // they keep Claude Code's own defaults. Anything not emitted here is + // blanked by the MANAGED_AUTH_KEYS pass below, so switching *away* from a + // custom endpoint clears the aliases out of the snapshot image too. + if project.backend.uses_custom_endpoint() { + for (key, value) in + compute_model_aliases(alias_model.as_deref(), alias_haiku.as_deref()) + { + env.push((key.to_string(), value)); + } + } + + // Shared Claude Code OAuth token (Anthropic backend only, opt-out per + // project). Injected *before* the neutralization pass below so that pass + // sees it as already-set; when it is absent the pass actively blanks the + // variable instead of leaving a stale one baked into the snapshot image. + if let Some(token) = shared_claude_token { + env.push((CLAUDE_OAUTH_TOKEN_ENV.to_string(), token.to_string())); + } + + // ── Corporate CA certificates ─────────────────────────────────────────── + // Resolved here (rather than down with the mounts) so the env vars land + // *before* the neutralization pass below and are seen as already-set. + // + // A bad path is a hard error, not a warning: behind a TLS-terminating + // proxy a container without the CA fails every HTTPS call — npm, pip, git, + // and Claude Code's own API requests — each in its own confusing way. One + // message naming the path is far kinder. + // + // The values are set here rather than exported by the entrypoint because a + // terminal session is a `docker exec`, which sees the container's + // configured env and nothing the entrypoint exported. Same lesson as + // `$BROWSER` and the URL relay shim. + for (key, value) in ca_certs::ca_env_vars(resolved_ca) { + env.push((key.to_string(), value)); + } + + // ── Neutralize stale backend auth env vars ────────────────────────────── + // When a project switches backends (e.g. Bedrock → Anthropic) the container + // is recreated *from a snapshot image* committed off the previous container, + // and `docker commit` copies that container's full ENV into the image. So + // any auth var set under the old backend (e.g. CLAUDE_CODE_USE_BEDROCK=1, + // AWS_PROFILE, a model alias) survives in the image ENV and stays active + // unless we explicitly override it at create time. + // + // This pass is about *staleness*, not secrecy. It fixes the container it is + // building and does nothing to the image, so it is not — and never was — + // a defence against a credential baked into a snapshot. That is + // `commit_container_snapshot`'s job, via SECRET_ENV_KEYS. + // Create-time env takes precedence over image ENV, so we set every managed + // auth key the *current* backend did NOT set to an empty value, clearing the + // stale baked-in one. + const MANAGED_AUTH_KEYS: &[&str] = &[ + "CLAUDE_CODE_USE_BEDROCK", + "AWS_REGION", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_PROFILE", + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_SSO_AUTH_REFRESH_CMD", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_MODEL", + "DISABLE_PROMPT_CACHING", + "ANTHROPIC_BEDROCK_SERVICE_TIER", + // Switching from a custom-endpoint backend to Anthropic or Bedrock must + // *clear* the aliases, not merely stop setting them: a stale + // ANTHROPIC_DEFAULT_HAIKU_MODEL baked into the snapshot image would + // keep pointing background calls at a model id the new backend has + // never heard of. + ANTHROPIC_DEFAULT_OPUS_MODEL, + ANTHROPIC_DEFAULT_SONNET_MODEL, + ANTHROPIC_DEFAULT_HAIKU_MODEL, + ANTHROPIC_DEFAULT_FABLE_MODEL, + // Revoking the shared token, opting a project out, or switching away + // from the Anthropic backend must *clear* this, not merely stop setting + // it — otherwise the value committed into the snapshot image keeps + // authenticating the container with a credential the user removed. + CLAUDE_OAUTH_TOKEN_ENV, + ]; + // Same reasoning for the CA vars — `ca_env` already emits them empty + // when no CA is configured, so this list is belt-and-braces for a snapshot + // committed by a build that predates the feature. + let managed_keys: Vec<&str> = MANAGED_AUTH_KEYS + .iter() + .copied() + .chain(ca_certs::CA_ENV_KEYS.iter().copied()) + .collect(); + let already_set: std::collections::HashSet = + env.iter().map(|(k, _)| k.clone()).collect(); + for key in &managed_keys { + if !already_set.contains(*key) { + env.push((key.to_string(), String::new())); + } + } + + // Custom environment variables (global + per-project, project overrides global for same key) + let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars); + for env_var in &merged_env { + let key = env_var.key.trim(); + if key.is_empty() { + continue; + } + if is_reserved_env_key(key) { + log::warn!("Skipping reserved env var: {}", key); + continue; + } + env.push((key.to_string(), env_var.value.clone())); + } + let custom_env_fingerprint = compute_env_fingerprint(&merged_env); + env.push(("TRIPLE_C_CUSTOM_ENV".to_string(), custom_env_fingerprint.to_string())); + + // Container timezone + if let Some(tz) = timezone { + if !tz.is_empty() { + env.push(("TZ".to_string(), tz.to_string())); + } + } + + // Mission Control env var + if project.mission_control_enabled { + env.push(("MISSION_CONTROL_ENABLED".to_string(), "1".to_string())); + } + + env.push(split_pair(&vpn_env_var(project.vpn_support_enabled))); + + // Permission mode — read by triple-c-task-runner for scheduled (headless) + // Claude Code runs. Interactive terminals get the flags directly instead. + env.push(( + "TRIPLE_C_PERMISSION_MODE".to_string(), + project.effective_permission_mode().as_env_value().to_string(), + )); + + // Claude instructions (global + per-project, plus port mapping info + scheduler docs) + let combined_instructions = build_claude_instructions( + global_claude_instructions, + project.claude_instructions.as_deref(), + &project.port_mappings, + project.mission_control_enabled, + project.sandbox_mode_enabled, + ); + + if let Some(ref instructions) = combined_instructions { + env.push(("CLAUDE_INSTRUCTIONS".to_string(), instructions.to_string())); + } + + // Claude Code settings (global + per-project merged) + let merged_cc_settings = merge_claude_code_settings( + global_claude_code_settings, + project.claude_code_settings.as_ref(), + ); + // Env-var-based settings, read directly by Claude Code. Extracted and unit + // tested for the `vpn_host_config` reason: a container is created once, by + // a very long function, and a variable emitted with the wrong value here is + // invisible until someone wonders why a switch does nothing. + env.extend( + claude_code_env_vars(merged_cc_settings.as_ref()) + .iter() + .map(|entry| split_pair(entry)), + ); + + // settings.json-based settings (applied by the entrypoint). Always emitted, + // even with no `ClaudeCodeSettings` struct present: the payload asserts the + // *whole* managed key set, so "no settings" still has to be stated over a + // settings.json left behind on the config volume by a previous config. + // Split so the payload is safe under an older entrypoint that has never + // heard of the null-means-delete convention — see + // `split_claude_code_settings_payload`. + let (cc_settings_set, cc_settings_clear) = split_claude_code_settings_payload( + &build_claude_code_settings_json(merged_cc_settings.as_ref(), project.sandbox_mode_enabled), + ); + env.push(("CLAUDE_CODE_SETTINGS_JSON".to_string(), cc_settings_set.to_string())); + env.push(("CLAUDE_CODE_SETTINGS_CLEAR".to_string(), cc_settings_clear.to_string())); + + env +} + +#[cfg(test)] +mod tests { + use super::super::ca_certs::ResolvedCa; + use crate::models::*; + use std::path::PathBuf; + + /// Every input the env block reads, owned, so a table row is one value. + pub(crate) struct Case { + pub name: &'static str, + pub project: Project, + pub global_aws: GlobalAwsSettings, + pub global_ollama: GlobalOllamaSettings, + pub global_llamacpp: GlobalLlamaCppSettings, + pub global_openai: GlobalOpenAiCompatibleSettings, + pub global_instructions: Option<&'static str>, + pub global_env: Vec, + pub timezone: Option<&'static str>, + pub global_cc: Option, + pub default_git_name: Option<&'static str>, + pub default_git_email: Option<&'static str>, + pub ca: Option, + pub shared_token: Option<(String, String)>, + } + + fn case(name: &'static str, project: Project) -> Case { + Case { + name, + project, + global_aws: GlobalAwsSettings::default(), + global_ollama: GlobalOllamaSettings::default(), + global_llamacpp: GlobalLlamaCppSettings::default(), + global_openai: GlobalOpenAiCompatibleSettings::default(), + global_instructions: None, + global_env: Vec::new(), + timezone: None, + global_cc: None, + default_git_name: None, + default_git_email: None, + ca: None, + shared_token: None, + } + } + + fn proj(backend: Backend) -> Project { + let mut p = Project::new("demo".to_string(), Vec::new()); + p.backend = backend; + p + } + + fn bedrock(method: BedrockAuthMethod) -> BedrockConfig { + BedrockConfig { + auth_method: method, + aws_region: "us-east-1".into(), + aws_access_key_id: Some("AKIAFAKE".into()), + aws_secret_access_key: Some("secretfake".into()), + aws_session_token: None, + aws_profile: Some("proj-profile".into()), + aws_bearer_token: Some("bearerfake".into()), + model_id: Some("anthropic.claude-x".into()), + disable_prompt_caching: true, + service_tier: Some(" priority ".into()), + } + } + + pub(crate) fn cases() -> Vec { + let mut v = Vec::new(); + + let mut c = case("anthropic_shared_token", proj(Backend::Anthropic)); + c.shared_token = Some(("sk-shared-token".into(), "v7".into())); + v.push(c); + + v.push(case("anthropic_plain", proj(Backend::Anthropic))); + + let mut p = proj(Backend::Bedrock); + p.bedrock_config = Some(bedrock(BedrockAuthMethod::StaticCredentials)); + let mut c = case("bedrock_static_keys", p); + c.global_aws.default_model_id = Some("global-model".into()); + v.push(c); + + let mut p = proj(Backend::Bedrock); + let mut b = bedrock(BedrockAuthMethod::Profile); + b.aws_region = String::new(); + b.aws_profile = None; + b.model_id = None; + b.disable_prompt_caching = false; + b.service_tier = Some(" ".into()); + p.bedrock_config = Some(b); + let mut c = case("bedrock_profile_global_fallbacks", p); + c.global_aws.aws_region = Some("eu-west-1".into()); + c.global_aws.aws_profile = Some("global-profile".into()); + c.global_aws.default_model_id = Some("global-model".into()); + v.push(c); + + let mut p = proj(Backend::Bedrock); + p.bedrock_config = Some(bedrock(BedrockAuthMethod::BearerToken)); + v.push(case("bedrock_bearer", p)); + + let mut p = proj(Backend::Ollama); + p.ollama_config = Some(OllamaConfig { + base_url: "http://ollama.local:11434".into(), + model_id: Some("qwen3".into()), + haiku_model_id: None, + }); + let mut c = case("ollama", p); + c.global_ollama.default_haiku_model_id = Some("tiny".into()); + v.push(c); + + let mut p = proj(Backend::LlamaCpp); + p.llamacpp_config = Some(LlamaCppConfig { + base_url: String::new(), + model_id: None, + haiku_model_id: Some("llama-small".into()), + }); + let mut c = case("llamacpp_global_url", p); + c.global_llamacpp.base_url = Some("http://llama:8080".into()); + c.global_llamacpp.default_model_id = Some("llama-big".into()); + v.push(c); + + let mut p = proj(Backend::OpenAiCompatible); + p.openai_compatible_config = Some(OpenAiCompatibleConfig { + base_url: "https://gw.example/v1".into(), + api_key: Some("sk-gateway".into()), + model_id: Some("gpt-x".into()), + haiku_model_id: Some("gpt-mini".into()), + }); + v.push(case("openai_compatible", p)); + + let mut p = proj(Backend::Anthropic); + p.custom_env_vars = vec![ + EnvVar { key: "FOO".into(), value: "project".into() }, + EnvVar { key: " SPACED ".into(), value: "x y".into() }, + EnvVar { key: "".into(), value: "dropped".into() }, + EnvVar { key: "HOME".into(), value: "reserved".into() }, + EnvVar { key: "CLAUDE_CODE_OAUTH_TOKEN".into(), value: "reserved".into() }, + ]; + let mut c = case("custom_env", p); + c.global_env = vec![ + EnvVar { key: "FOO".into(), value: "global".into() }, + EnvVar { key: "BAR".into(), value: "b".into() }, + ]; + v.push(c); + + let mut p = proj(Backend::Anthropic); + p.claude_instructions = Some("project rules".into()); + p.mission_control_enabled = true; + p.sandbox_mode_enabled = true; + p.port_mappings = vec![PortMapping { + host_port: 8080, + container_port: 3000, + protocol: "tcp".into(), + }]; + let mut c = case("instructions_and_mission_control", p); + c.global_instructions = Some("global rules\nline two"); + v.push(c); + + let mut p = proj(Backend::Anthropic); + p.claude_code_settings = Some(ClaudeCodeSettings { + effort: Some("high".into()), + focus_mode: Some(false), + ..Default::default() + }); + let mut c = case("claude_code_settings", p); + c.global_cc = Some(ClaudeCodeSettings { + tui_mode: Some("fullscreen".into()), + env_scrub: Some(true), + prompt_caching_1h: Some(true), + ..Default::default() + }); + v.push(c); + + let mut p = proj(Backend::Anthropic); + p.vpn_support_enabled = true; + p.git_token = Some("ghp_faketoken".into()); + p.git_user_name = Some("Project User".into()); + let mut c = case("vpn_git_timezone", p); + c.timezone = Some("Europe/Berlin"); + c.default_git_name = Some("Default Name"); + c.default_git_email = Some("default@example.com"); + v.push(c); + + let mut c = case("corporate_ca", proj(Backend::Anthropic)); + c.ca = Some(ResolvedCa { + host_path: "/certs".into(), + is_dir: true, + mount_target: "/tmp/.host-ca".into(), + cert_files: vec![PathBuf::from("/certs/a.crt")], + }); + v.push(c); + + v + } + + fn golden_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src/docker/workspace_env_golden") + .join(format!("{name}.txt")) + } + + /// `merge_custom_env_vars` collects a `HashMap`, so the custom variables + /// come out in a different order every run (before the extraction as + /// well). Sort that one run — between the last managed blank and the + /// fingerprint — so the rest of the order is still compared exactly. + fn normalise(mut env: Vec) -> Vec { + let end = env.iter().position(|e| e.starts_with("TRIPLE_C_CUSTOM_ENV=")).unwrap(); + let start = env.iter().position(|e| e.starts_with("CLAUDE_CODE_OAUTH_TOKEN=")).unwrap() + 1; + env[start..end].sort(); + env + } + + /// The pre-refactor block's output, recorded before the extraction (see + /// `workspace_env_golden/`), must be reproduced exactly, order included. + #[test] + fn workspace_env_matches_what_create_container_built_before_the_extraction() { + for c in cases() { + let inputs = super::WorkspaceEnvInputs { + global_aws: &c.global_aws, + global_ollama: &c.global_ollama, + global_llamacpp: &c.global_llamacpp, + global_openai_compatible: &c.global_openai, + global_claude_instructions: c.global_instructions, + global_custom_env_vars: &c.global_env, + timezone: c.timezone, + global_claude_code_settings: c.global_cc.as_ref(), + default_git_user_name: c.default_git_name, + default_git_user_email: c.default_git_email, + resolved_ca: c.ca.as_ref(), + shared_claude_token: c.shared_token.as_ref().map(|(t, _)| t.as_str()), + }; + let got: Vec = super::workspace_env(&c.project, &inputs) + .into_iter() + .map(|(k, v)| format!("{k}={v}")) + .collect(); + let want = std::fs::read_to_string(golden_path(c.name)).unwrap(); + let want: Vec = want + .lines() + .filter(|l| *l != "[" && *l != "]") + .map(|l| { + let l = l.trim().trim_end_matches(','); + serde_json::from_str::(l).unwrap_or_else(|_| panic!("{l}")) + }) + .collect(); + assert_eq!(normalise(got), normalise(want), "case {}", c.name); + } + } +} diff --git a/app/src-tauri/src/docker/workspace_env_golden/anthropic_plain.txt b/app/src-tauri/src/docker/workspace_env_golden/anthropic_plain.txt new file mode 100644 index 0000000..2aa4ec3 --- /dev/null +++ b/app/src-tauri/src/docker/workspace_env_golden/anthropic_plain.txt @@ -0,0 +1,33 @@ +[ + "NODE_EXTRA_CA_CERTS=", + "REQUESTS_CA_BUNDLE=", + "SSL_CERT_FILE=", + "CLAUDE_CODE_USE_BEDROCK=", + "AWS_REGION=", + "AWS_ACCESS_KEY_ID=", + "AWS_SECRET_ACCESS_KEY=", + "AWS_SESSION_TOKEN=", + "AWS_PROFILE=", + "AWS_BEARER_TOKEN_BEDROCK=", + "AWS_SSO_AUTH_REFRESH_CMD=", + "ANTHROPIC_BASE_URL=", + "ANTHROPIC_AUTH_TOKEN=", + "ANTHROPIC_MODEL=", + "DISABLE_PROMPT_CACHING=", + "ANTHROPIC_BEDROCK_SERVICE_TIER=", + "ANTHROPIC_DEFAULT_OPUS_MODEL=", + "ANTHROPIC_DEFAULT_SONNET_MODEL=", + "ANTHROPIC_DEFAULT_HAIKU_MODEL=", + "ANTHROPIC_DEFAULT_FABLE_MODEL=", + "CLAUDE_CODE_OAUTH_TOKEN=", + "TRIPLE_C_CUSTOM_ENV=", + "VPN_SUPPORT_ENABLED=0", + "TRIPLE_C_PERMISSION_MODE=default", + "CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.", + "CLAUDE_CODE_NO_FLICKER=", + "CLAUDE_CODE_ENABLE_AWAY_SUMMARY=", + "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0", + "ENABLE_PROMPT_CACHING_1H=0", + "CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}", + "CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]", +] diff --git a/app/src-tauri/src/docker/workspace_env_golden/anthropic_shared_token.txt b/app/src-tauri/src/docker/workspace_env_golden/anthropic_shared_token.txt new file mode 100644 index 0000000..7f039ad --- /dev/null +++ b/app/src-tauri/src/docker/workspace_env_golden/anthropic_shared_token.txt @@ -0,0 +1,33 @@ +[ + "CLAUDE_CODE_OAUTH_TOKEN=sk-shared-token", + "NODE_EXTRA_CA_CERTS=", + "REQUESTS_CA_BUNDLE=", + "SSL_CERT_FILE=", + "CLAUDE_CODE_USE_BEDROCK=", + "AWS_REGION=", + "AWS_ACCESS_KEY_ID=", + "AWS_SECRET_ACCESS_KEY=", + "AWS_SESSION_TOKEN=", + "AWS_PROFILE=", + "AWS_BEARER_TOKEN_BEDROCK=", + "AWS_SSO_AUTH_REFRESH_CMD=", + "ANTHROPIC_BASE_URL=", + "ANTHROPIC_AUTH_TOKEN=", + "ANTHROPIC_MODEL=", + "DISABLE_PROMPT_CACHING=", + "ANTHROPIC_BEDROCK_SERVICE_TIER=", + "ANTHROPIC_DEFAULT_OPUS_MODEL=", + "ANTHROPIC_DEFAULT_SONNET_MODEL=", + "ANTHROPIC_DEFAULT_HAIKU_MODEL=", + "ANTHROPIC_DEFAULT_FABLE_MODEL=", + "TRIPLE_C_CUSTOM_ENV=", + "VPN_SUPPORT_ENABLED=0", + "TRIPLE_C_PERMISSION_MODE=default", + "CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.", + "CLAUDE_CODE_NO_FLICKER=", + "CLAUDE_CODE_ENABLE_AWAY_SUMMARY=", + "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0", + "ENABLE_PROMPT_CACHING_1H=0", + "CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}", + "CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]", +] diff --git a/app/src-tauri/src/docker/workspace_env_golden/bedrock_bearer.txt b/app/src-tauri/src/docker/workspace_env_golden/bedrock_bearer.txt new file mode 100644 index 0000000..565840c --- /dev/null +++ b/app/src-tauri/src/docker/workspace_env_golden/bedrock_bearer.txt @@ -0,0 +1,33 @@ +[ + "CLAUDE_CODE_USE_BEDROCK=1", + "AWS_REGION=us-east-1", + "AWS_BEARER_TOKEN_BEDROCK=bearerfake", + "ANTHROPIC_MODEL=anthropic.claude-x", + "DISABLE_PROMPT_CACHING=1", + "ANTHROPIC_BEDROCK_SERVICE_TIER=priority", + "NODE_EXTRA_CA_CERTS=", + "REQUESTS_CA_BUNDLE=", + "SSL_CERT_FILE=", + "AWS_ACCESS_KEY_ID=", + "AWS_SECRET_ACCESS_KEY=", + "AWS_SESSION_TOKEN=", + "AWS_PROFILE=", + "AWS_SSO_AUTH_REFRESH_CMD=", + "ANTHROPIC_BASE_URL=", + "ANTHROPIC_AUTH_TOKEN=", + "ANTHROPIC_DEFAULT_OPUS_MODEL=", + "ANTHROPIC_DEFAULT_SONNET_MODEL=", + "ANTHROPIC_DEFAULT_HAIKU_MODEL=", + "ANTHROPIC_DEFAULT_FABLE_MODEL=", + "CLAUDE_CODE_OAUTH_TOKEN=", + "TRIPLE_C_CUSTOM_ENV=", + "VPN_SUPPORT_ENABLED=0", + "TRIPLE_C_PERMISSION_MODE=default", + "CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.", + "CLAUDE_CODE_NO_FLICKER=", + "CLAUDE_CODE_ENABLE_AWAY_SUMMARY=", + "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0", + "ENABLE_PROMPT_CACHING_1H=0", + "CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}", + "CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]", +] diff --git a/app/src-tauri/src/docker/workspace_env_golden/bedrock_profile_global_fallbacks.txt b/app/src-tauri/src/docker/workspace_env_golden/bedrock_profile_global_fallbacks.txt new file mode 100644 index 0000000..69d1db0 --- /dev/null +++ b/app/src-tauri/src/docker/workspace_env_golden/bedrock_profile_global_fallbacks.txt @@ -0,0 +1,33 @@ +[ + "CLAUDE_CODE_USE_BEDROCK=1", + "AWS_REGION=eu-west-1", + "AWS_PROFILE=global-profile", + "AWS_SSO_AUTH_REFRESH_CMD=triple-c-sso-refresh", + "ANTHROPIC_MODEL=global-model", + "NODE_EXTRA_CA_CERTS=", + "REQUESTS_CA_BUNDLE=", + "SSL_CERT_FILE=", + "AWS_ACCESS_KEY_ID=", + "AWS_SECRET_ACCESS_KEY=", + "AWS_SESSION_TOKEN=", + "AWS_BEARER_TOKEN_BEDROCK=", + "ANTHROPIC_BASE_URL=", + "ANTHROPIC_AUTH_TOKEN=", + "DISABLE_PROMPT_CACHING=", + "ANTHROPIC_BEDROCK_SERVICE_TIER=", + "ANTHROPIC_DEFAULT_OPUS_MODEL=", + "ANTHROPIC_DEFAULT_SONNET_MODEL=", + "ANTHROPIC_DEFAULT_HAIKU_MODEL=", + "ANTHROPIC_DEFAULT_FABLE_MODEL=", + "CLAUDE_CODE_OAUTH_TOKEN=", + "TRIPLE_C_CUSTOM_ENV=", + "VPN_SUPPORT_ENABLED=0", + "TRIPLE_C_PERMISSION_MODE=default", + "CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.", + "CLAUDE_CODE_NO_FLICKER=", + "CLAUDE_CODE_ENABLE_AWAY_SUMMARY=", + "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0", + "ENABLE_PROMPT_CACHING_1H=0", + "CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}", + "CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]", +] diff --git a/app/src-tauri/src/docker/workspace_env_golden/bedrock_static_keys.txt b/app/src-tauri/src/docker/workspace_env_golden/bedrock_static_keys.txt new file mode 100644 index 0000000..15556bb --- /dev/null +++ b/app/src-tauri/src/docker/workspace_env_golden/bedrock_static_keys.txt @@ -0,0 +1,33 @@ +[ + "CLAUDE_CODE_USE_BEDROCK=1", + "AWS_REGION=us-east-1", + "ANTHROPIC_MODEL=anthropic.claude-x", + "DISABLE_PROMPT_CACHING=1", + "ANTHROPIC_BEDROCK_SERVICE_TIER=priority", + "NODE_EXTRA_CA_CERTS=", + "REQUESTS_CA_BUNDLE=", + "SSL_CERT_FILE=", + "AWS_ACCESS_KEY_ID=", + "AWS_SECRET_ACCESS_KEY=", + "AWS_SESSION_TOKEN=", + "AWS_PROFILE=", + "AWS_BEARER_TOKEN_BEDROCK=", + "AWS_SSO_AUTH_REFRESH_CMD=", + "ANTHROPIC_BASE_URL=", + "ANTHROPIC_AUTH_TOKEN=", + "ANTHROPIC_DEFAULT_OPUS_MODEL=", + "ANTHROPIC_DEFAULT_SONNET_MODEL=", + "ANTHROPIC_DEFAULT_HAIKU_MODEL=", + "ANTHROPIC_DEFAULT_FABLE_MODEL=", + "CLAUDE_CODE_OAUTH_TOKEN=", + "TRIPLE_C_CUSTOM_ENV=", + "VPN_SUPPORT_ENABLED=0", + "TRIPLE_C_PERMISSION_MODE=default", + "CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.", + "CLAUDE_CODE_NO_FLICKER=", + "CLAUDE_CODE_ENABLE_AWAY_SUMMARY=", + "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0", + "ENABLE_PROMPT_CACHING_1H=0", + "CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}", + "CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]", +] diff --git a/app/src-tauri/src/docker/workspace_env_golden/claude_code_settings.txt b/app/src-tauri/src/docker/workspace_env_golden/claude_code_settings.txt new file mode 100644 index 0000000..9e8017a --- /dev/null +++ b/app/src-tauri/src/docker/workspace_env_golden/claude_code_settings.txt @@ -0,0 +1,33 @@ +[ + "NODE_EXTRA_CA_CERTS=", + "REQUESTS_CA_BUNDLE=", + "SSL_CERT_FILE=", + "CLAUDE_CODE_USE_BEDROCK=", + "AWS_REGION=", + "AWS_ACCESS_KEY_ID=", + "AWS_SECRET_ACCESS_KEY=", + "AWS_SESSION_TOKEN=", + "AWS_PROFILE=", + "AWS_BEARER_TOKEN_BEDROCK=", + "AWS_SSO_AUTH_REFRESH_CMD=", + "ANTHROPIC_BASE_URL=", + "ANTHROPIC_AUTH_TOKEN=", + "ANTHROPIC_MODEL=", + "DISABLE_PROMPT_CACHING=", + "ANTHROPIC_BEDROCK_SERVICE_TIER=", + "ANTHROPIC_DEFAULT_OPUS_MODEL=", + "ANTHROPIC_DEFAULT_SONNET_MODEL=", + "ANTHROPIC_DEFAULT_HAIKU_MODEL=", + "ANTHROPIC_DEFAULT_FABLE_MODEL=", + "CLAUDE_CODE_OAUTH_TOKEN=", + "TRIPLE_C_CUSTOM_ENV=", + "VPN_SUPPORT_ENABLED=0", + "TRIPLE_C_PERMISSION_MODE=default", + "CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.", + "CLAUDE_CODE_NO_FLICKER=1", + "CLAUDE_CODE_ENABLE_AWAY_SUMMARY=", + "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1", + "ENABLE_PROMPT_CACHING_1H=1", + "CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"effortLevel\":\"high\",\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false,\"tui\":\"fullscreen\"}", + "CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"viewMode\"]", +] diff --git a/app/src-tauri/src/docker/workspace_env_golden/corporate_ca.txt b/app/src-tauri/src/docker/workspace_env_golden/corporate_ca.txt new file mode 100644 index 0000000..52fb127 --- /dev/null +++ b/app/src-tauri/src/docker/workspace_env_golden/corporate_ca.txt @@ -0,0 +1,33 @@ +[ + "NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt", + "REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt", + "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt", + "CLAUDE_CODE_USE_BEDROCK=", + "AWS_REGION=", + "AWS_ACCESS_KEY_ID=", + "AWS_SECRET_ACCESS_KEY=", + "AWS_SESSION_TOKEN=", + "AWS_PROFILE=", + "AWS_BEARER_TOKEN_BEDROCK=", + "AWS_SSO_AUTH_REFRESH_CMD=", + "ANTHROPIC_BASE_URL=", + "ANTHROPIC_AUTH_TOKEN=", + "ANTHROPIC_MODEL=", + "DISABLE_PROMPT_CACHING=", + "ANTHROPIC_BEDROCK_SERVICE_TIER=", + "ANTHROPIC_DEFAULT_OPUS_MODEL=", + "ANTHROPIC_DEFAULT_SONNET_MODEL=", + "ANTHROPIC_DEFAULT_HAIKU_MODEL=", + "ANTHROPIC_DEFAULT_FABLE_MODEL=", + "CLAUDE_CODE_OAUTH_TOKEN=", + "TRIPLE_C_CUSTOM_ENV=", + "VPN_SUPPORT_ENABLED=0", + "TRIPLE_C_PERMISSION_MODE=default", + "CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.", + "CLAUDE_CODE_NO_FLICKER=", + "CLAUDE_CODE_ENABLE_AWAY_SUMMARY=", + "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0", + "ENABLE_PROMPT_CACHING_1H=0", + "CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}", + "CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]", +] diff --git a/app/src-tauri/src/docker/workspace_env_golden/custom_env.txt b/app/src-tauri/src/docker/workspace_env_golden/custom_env.txt new file mode 100644 index 0000000..186bdc4 --- /dev/null +++ b/app/src-tauri/src/docker/workspace_env_golden/custom_env.txt @@ -0,0 +1,37 @@ +[ + "NODE_EXTRA_CA_CERTS=", + "REQUESTS_CA_BUNDLE=", + "SSL_CERT_FILE=", + "CLAUDE_CODE_USE_BEDROCK=", + "AWS_REGION=", + "AWS_ACCESS_KEY_ID=", + "AWS_SECRET_ACCESS_KEY=", + "AWS_SESSION_TOKEN=", + "AWS_PROFILE=", + "AWS_BEARER_TOKEN_BEDROCK=", + "AWS_SSO_AUTH_REFRESH_CMD=", + "ANTHROPIC_BASE_URL=", + "ANTHROPIC_AUTH_TOKEN=", + "ANTHROPIC_MODEL=", + "DISABLE_PROMPT_CACHING=", + "ANTHROPIC_BEDROCK_SERVICE_TIER=", + "ANTHROPIC_DEFAULT_OPUS_MODEL=", + "ANTHROPIC_DEFAULT_SONNET_MODEL=", + "ANTHROPIC_DEFAULT_HAIKU_MODEL=", + "ANTHROPIC_DEFAULT_FABLE_MODEL=", + "CLAUDE_CODE_OAUTH_TOKEN=", + "BAR=b", + "FOO=project", + "SPACED=x y", + "HOME=reserved", + "TRIPLE_C_CUSTOM_ENV=63ce864a8c48a386cb039d4878e3885d5a823d4bb52047ba0e18cb87b9f3035b", + "VPN_SUPPORT_ENABLED=0", + "TRIPLE_C_PERMISSION_MODE=default", + "CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.", + "CLAUDE_CODE_NO_FLICKER=", + "CLAUDE_CODE_ENABLE_AWAY_SUMMARY=", + "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0", + "ENABLE_PROMPT_CACHING_1H=0", + "CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}", + "CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]", +] diff --git a/app/src-tauri/src/docker/workspace_env_golden/instructions_and_mission_control.txt b/app/src-tauri/src/docker/workspace_env_golden/instructions_and_mission_control.txt new file mode 100644 index 0000000..25b8020 --- /dev/null +++ b/app/src-tauri/src/docker/workspace_env_golden/instructions_and_mission_control.txt @@ -0,0 +1,34 @@ +[ + "NODE_EXTRA_CA_CERTS=", + "REQUESTS_CA_BUNDLE=", + "SSL_CERT_FILE=", + "CLAUDE_CODE_USE_BEDROCK=", + "AWS_REGION=", + "AWS_ACCESS_KEY_ID=", + "AWS_SECRET_ACCESS_KEY=", + "AWS_SESSION_TOKEN=", + "AWS_PROFILE=", + "AWS_BEARER_TOKEN_BEDROCK=", + "AWS_SSO_AUTH_REFRESH_CMD=", + "ANTHROPIC_BASE_URL=", + "ANTHROPIC_AUTH_TOKEN=", + "ANTHROPIC_MODEL=", + "DISABLE_PROMPT_CACHING=", + "ANTHROPIC_BEDROCK_SERVICE_TIER=", + "ANTHROPIC_DEFAULT_OPUS_MODEL=", + "ANTHROPIC_DEFAULT_SONNET_MODEL=", + "ANTHROPIC_DEFAULT_HAIKU_MODEL=", + "ANTHROPIC_DEFAULT_FABLE_MODEL=", + "CLAUDE_CODE_OAUTH_TOKEN=", + "TRIPLE_C_CUSTOM_ENV=", + "MISSION_CONTROL_ENABLED=1", + "VPN_SUPPORT_ENABLED=0", + "TRIPLE_C_PERMISSION_MODE=default", + "CLAUDE_INSTRUCTIONS=global rules\nline two\n\n## Mission Control\n\nThe `/workspace/mission-control/` directory contains **Flight Control** — an AI-first development methodology for structured project management. Use it for all project work.\n\n### How It Works\n\n- **Mission Control is a tool, not a project.** It provides skills and methodology for managing other projects.\n- All Flight Control skills are installed as personal skills in `~/.claude/skills/` and are automatically available as `/slash-commands`\n- The methodology docs and project registry live in `/workspace/mission-control/`\n\n### When to Use\n\nWhen working on any project that has a `.flightops/` directory, follow the Flight Control methodology:\n1. Read the project's `.flightops/ARTIFACTS.md` to understand artifact storage\n2. Read `.flightops/FLIGHT_OPERATIONS.md` for the implementation workflow\n3. Use Mission Control skills for planning and execution\n\n### Available Skills\n\n| Skill | When to Use |\n|-------|-------------|\n| `/init-project` | Setting up a new project for Flight Control |\n| `/mission` | Defining new work outcomes (days-to-weeks scope) |\n| `/flight` | Creating technical specs from missions (hours-to-days scope) |\n| `/leg` | Generating implementation steps from flights (minutes-to-hours scope) |\n| `/agentic-workflow` | Executing legs with multi-agent workflow (implement, review, commit) |\n| `/flight-debrief` | Post-flight analysis after a flight lands |\n| `/mission-debrief` | Post-mission retrospective after completion |\n| `/daily-briefing` | Cross-project status report |\n\n### Key Rules\n\n- **Planning skills produce artifacts only** — never modify source code directly\n- **Phase gates require human confirmation** — missions before flights, flights before legs\n- **Legs are immutable once in-flight** — create new ones instead of modifying\n- **`/agentic-workflow` orchestrates implementation** — it spawns separate Developer and Reviewer agents\n- **Artifacts live in the target project** — not in mission-control\n\nproject rules\n\n## Flight Operations\n\nThis project uses **Flight Control** (bundled with Triple-C) for structured development.\n\n**Before any mission/flight/leg work, read these files in order:**\n1. `.flightops/README.md` — What the flightops directory contains\n2. `.flightops/FLIGHT_OPERATIONS.md` — **The workflow you MUST follow**\n3. `.flightops/ARTIFACTS.md` — Where all artifacts are stored\n4. `.flightops/agent-crews/` — Project crew definitions for each phase (read the relevant crew file)\n\n## Available Port Mappings\nThe following ports are mapped from the host to this container. Use these container ports when starting services that need to be accessible from the host:\n- Host port 8080 -> Container port 3000 (tcp)\n\n## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.\n\n## Sandbox Mode\n\nThis container has Claude Code's bash sandbox enabled, managed by Triple-C\n(toggle it from the project's \"Sandbox mode\" switch in the Triple-C UI).\nBash commands run inside `bubblewrap` with filesystem and network isolation\n(`enableWeakerNestedSandbox` is on because we are inside Docker).\n\n### When a command fails because of sandbox restrictions\n\nTriple-C disables the `dangerouslyDisableSandbox` escape hatch\n(`allowUnsandboxedCommands: false`), so failing commands cannot bypass the\nsandbox at runtime. To make a blocked command work, edit\n`~/.claude/settings.json` and restart Claude Code:\n\n| Need | Setting |\n|---|---|\n| Write to a path outside the project (e.g. `~/.kube`) | Add to `sandbox.filesystem.allowWrite` |\n| Reach a new domain | Will prompt; or add permanently to `sandbox.allowedDomains` |\n| Run a specific tool entirely outside the sandbox | Add a glob (e.g. `\"docker *\"`) to `sandbox.excludedCommands` |\n\n### Docker commands\n\nThe `docker` CLI does not work inside the sandbox. If this project has\n\"Allow container spawning\" enabled in Triple-C and you need to run\n`docker` commands, add `\"docker *\"` to `sandbox.excludedCommands` in\n`~/.claude/settings.json`. Other tools known to be sandbox-incompatible\ninclude `watchman` — pass `--no-watchman` to `jest`.\n\n### Disabling sandbox mode\n\nDo not change `sandbox.enabled` in `settings.json` — Triple-C overwrites it\non every container start. To turn sandbox off, stop the container in\nTriple-C, flip the \"Sandbox mode\" switch off, then start the container.", + "CLAUDE_CODE_NO_FLICKER=", + "CLAUDE_CODE_ENABLE_AWAY_SUMMARY=", + "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0", + "ENABLE_PROMPT_CACHING_1H=0", + "CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"allowUnsandboxedCommands\":false,\"enableWeakerNestedSandbox\":true,\"enabled\":true},\"showThinkingSummaries\":false}", + "CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]", +] diff --git a/app/src-tauri/src/docker/workspace_env_golden/llamacpp_global_url.txt b/app/src-tauri/src/docker/workspace_env_golden/llamacpp_global_url.txt new file mode 100644 index 0000000..5f5636f --- /dev/null +++ b/app/src-tauri/src/docker/workspace_env_golden/llamacpp_global_url.txt @@ -0,0 +1,33 @@ +[ + "ANTHROPIC_BASE_URL=http://llama:8080", + "ANTHROPIC_AUTH_TOKEN=llama.cpp", + "ANTHROPIC_MODEL=llama-big", + "ANTHROPIC_DEFAULT_OPUS_MODEL=llama-big", + "ANTHROPIC_DEFAULT_SONNET_MODEL=llama-big", + "ANTHROPIC_DEFAULT_HAIKU_MODEL=llama-small", + "ANTHROPIC_DEFAULT_FABLE_MODEL=llama-big", + "NODE_EXTRA_CA_CERTS=", + "REQUESTS_CA_BUNDLE=", + "SSL_CERT_FILE=", + "CLAUDE_CODE_USE_BEDROCK=", + "AWS_REGION=", + "AWS_ACCESS_KEY_ID=", + "AWS_SECRET_ACCESS_KEY=", + "AWS_SESSION_TOKEN=", + "AWS_PROFILE=", + "AWS_BEARER_TOKEN_BEDROCK=", + "AWS_SSO_AUTH_REFRESH_CMD=", + "DISABLE_PROMPT_CACHING=", + "ANTHROPIC_BEDROCK_SERVICE_TIER=", + "CLAUDE_CODE_OAUTH_TOKEN=", + "TRIPLE_C_CUSTOM_ENV=", + "VPN_SUPPORT_ENABLED=0", + "TRIPLE_C_PERMISSION_MODE=default", + "CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.", + "CLAUDE_CODE_NO_FLICKER=", + "CLAUDE_CODE_ENABLE_AWAY_SUMMARY=", + "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0", + "ENABLE_PROMPT_CACHING_1H=0", + "CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}", + "CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]", +] diff --git a/app/src-tauri/src/docker/workspace_env_golden/ollama.txt b/app/src-tauri/src/docker/workspace_env_golden/ollama.txt new file mode 100644 index 0000000..054b40d --- /dev/null +++ b/app/src-tauri/src/docker/workspace_env_golden/ollama.txt @@ -0,0 +1,33 @@ +[ + "ANTHROPIC_BASE_URL=http://ollama.local:11434", + "ANTHROPIC_AUTH_TOKEN=ollama", + "ANTHROPIC_MODEL=qwen3", + "ANTHROPIC_DEFAULT_OPUS_MODEL=qwen3", + "ANTHROPIC_DEFAULT_SONNET_MODEL=qwen3", + "ANTHROPIC_DEFAULT_HAIKU_MODEL=tiny", + "ANTHROPIC_DEFAULT_FABLE_MODEL=qwen3", + "NODE_EXTRA_CA_CERTS=", + "REQUESTS_CA_BUNDLE=", + "SSL_CERT_FILE=", + "CLAUDE_CODE_USE_BEDROCK=", + "AWS_REGION=", + "AWS_ACCESS_KEY_ID=", + "AWS_SECRET_ACCESS_KEY=", + "AWS_SESSION_TOKEN=", + "AWS_PROFILE=", + "AWS_BEARER_TOKEN_BEDROCK=", + "AWS_SSO_AUTH_REFRESH_CMD=", + "DISABLE_PROMPT_CACHING=", + "ANTHROPIC_BEDROCK_SERVICE_TIER=", + "CLAUDE_CODE_OAUTH_TOKEN=", + "TRIPLE_C_CUSTOM_ENV=", + "VPN_SUPPORT_ENABLED=0", + "TRIPLE_C_PERMISSION_MODE=default", + "CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.", + "CLAUDE_CODE_NO_FLICKER=", + "CLAUDE_CODE_ENABLE_AWAY_SUMMARY=", + "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0", + "ENABLE_PROMPT_CACHING_1H=0", + "CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}", + "CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]", +] diff --git a/app/src-tauri/src/docker/workspace_env_golden/openai_compatible.txt b/app/src-tauri/src/docker/workspace_env_golden/openai_compatible.txt new file mode 100644 index 0000000..4dd2890 --- /dev/null +++ b/app/src-tauri/src/docker/workspace_env_golden/openai_compatible.txt @@ -0,0 +1,33 @@ +[ + "ANTHROPIC_BASE_URL=https://gw.example/v1", + "ANTHROPIC_AUTH_TOKEN=sk-gateway", + "ANTHROPIC_MODEL=gpt-x", + "ANTHROPIC_DEFAULT_OPUS_MODEL=gpt-x", + "ANTHROPIC_DEFAULT_SONNET_MODEL=gpt-x", + "ANTHROPIC_DEFAULT_HAIKU_MODEL=gpt-mini", + "ANTHROPIC_DEFAULT_FABLE_MODEL=gpt-x", + "NODE_EXTRA_CA_CERTS=", + "REQUESTS_CA_BUNDLE=", + "SSL_CERT_FILE=", + "CLAUDE_CODE_USE_BEDROCK=", + "AWS_REGION=", + "AWS_ACCESS_KEY_ID=", + "AWS_SECRET_ACCESS_KEY=", + "AWS_SESSION_TOKEN=", + "AWS_PROFILE=", + "AWS_BEARER_TOKEN_BEDROCK=", + "AWS_SSO_AUTH_REFRESH_CMD=", + "DISABLE_PROMPT_CACHING=", + "ANTHROPIC_BEDROCK_SERVICE_TIER=", + "CLAUDE_CODE_OAUTH_TOKEN=", + "TRIPLE_C_CUSTOM_ENV=", + "VPN_SUPPORT_ENABLED=0", + "TRIPLE_C_PERMISSION_MODE=default", + "CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.", + "CLAUDE_CODE_NO_FLICKER=", + "CLAUDE_CODE_ENABLE_AWAY_SUMMARY=", + "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0", + "ENABLE_PROMPT_CACHING_1H=0", + "CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}", + "CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]", +] diff --git a/app/src-tauri/src/docker/workspace_env_golden/vpn_git_timezone.txt b/app/src-tauri/src/docker/workspace_env_golden/vpn_git_timezone.txt new file mode 100644 index 0000000..627ecda --- /dev/null +++ b/app/src-tauri/src/docker/workspace_env_golden/vpn_git_timezone.txt @@ -0,0 +1,37 @@ +[ + "GIT_TOKEN=ghp_faketoken", + "GIT_USER_NAME=Project User", + "GIT_USER_EMAIL=default@example.com", + "NODE_EXTRA_CA_CERTS=", + "REQUESTS_CA_BUNDLE=", + "SSL_CERT_FILE=", + "CLAUDE_CODE_USE_BEDROCK=", + "AWS_REGION=", + "AWS_ACCESS_KEY_ID=", + "AWS_SECRET_ACCESS_KEY=", + "AWS_SESSION_TOKEN=", + "AWS_PROFILE=", + "AWS_BEARER_TOKEN_BEDROCK=", + "AWS_SSO_AUTH_REFRESH_CMD=", + "ANTHROPIC_BASE_URL=", + "ANTHROPIC_AUTH_TOKEN=", + "ANTHROPIC_MODEL=", + "DISABLE_PROMPT_CACHING=", + "ANTHROPIC_BEDROCK_SERVICE_TIER=", + "ANTHROPIC_DEFAULT_OPUS_MODEL=", + "ANTHROPIC_DEFAULT_SONNET_MODEL=", + "ANTHROPIC_DEFAULT_HAIKU_MODEL=", + "ANTHROPIC_DEFAULT_FABLE_MODEL=", + "CLAUDE_CODE_OAUTH_TOKEN=", + "TRIPLE_C_CUSTOM_ENV=", + "TZ=Europe/Berlin", + "VPN_SUPPORT_ENABLED=1", + "TRIPLE_C_PERMISSION_MODE=default", + "CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.", + "CLAUDE_CODE_NO_FLICKER=", + "CLAUDE_CODE_ENABLE_AWAY_SUMMARY=", + "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0", + "ENABLE_PROMPT_CACHING_1H=0", + "CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}", + "CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]", +] -- 2.52.0 From d1883310ddd95239f3d6eb68de7ae5ba5b39789e Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 08:26:08 -0800 Subject: [PATCH 04/43] feat(aws): workspace users register their own task definitions Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- app/src-tauri/src/aws/live_tests.rs | 20 ++ app/src-tauri/src/aws/policies.rs | 234 +++++++++++++++++++- app/src-tauri/src/commands/host_commands.rs | 7 +- aws/README.md | 48 +++- aws/policies/workspace-user-tasks.json | 96 ++++++++ 6 files changed, 393 insertions(+), 14 deletions(-) create mode 100644 aws/policies/workspace-user-tasks.json diff --git a/CLAUDE.md b/CLAUDE.md index f1d2409..a41b63d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -312,7 +312,7 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li `identity.rs` is `GetCallerIdentity` and the principal to simulate (an assumed role's ARN is recovered with `iam:GetRole`, because SSO roles have an `/aws-reserved/…` path the session ARN lacks); `error.rs` classifies SDK errors (SSO expired, access denied naming the action, …); - `policies.rs` embeds `aws/policies/*.json` (the tier policies — the admin tier spans several + `policies.rs` embeds `aws/policies/*.json` (the tier policies — both tiers span several files, `policy_files` lists them — and the permissions boundary) and the table of checks the simulator is asked; `permissions.rs` runs it (`SimulatePrincipalPolicy`, adaptive retries — it throttles — or read-only probes when the diff --git a/app/src-tauri/src/aws/live_tests.rs b/app/src-tauri/src/aws/live_tests.rs index e36e505..205c972 100644 --- a/app/src-tauri/src/aws/live_tests.rs +++ b/app/src-tauri/src/aws/live_tests.rs @@ -561,6 +561,7 @@ async fn a_role_with_no_policy_checks_as_insufficient_and_lists_every_action() { #[ignore] async fn a_role_without_simulate_permission_falls_back_to_probes() { e2e(|e| async move { + // `InspectOwnPrincipal` lives in the first file. let mut doc: serde_json::Value = serde_json::from_str(policy_files(Tier::WorkspaceUser)[0].1).unwrap(); doc["Statement"] @@ -570,6 +571,11 @@ async fn a_role_without_simulate_permission_falls_back_to_probes() { let role = e.role("nosim", None, &[]).await; let arn = e.managed_policy("nosim", &doc.to_string()).await; e.attach(&role, &arn).await; + // The tasks file is attached whole. + let tasks = e + .managed_policy("nosim-tasks", policy_files(Tier::WorkspaceUser)[1].1) + .await; + e.attach(&role, &tasks).await; let cfg = e.assume(&role).await; let me = caller_identity(&cfg).await.expect("identity"); assert_eq!(me.user_id, role.session_user_id()); @@ -790,6 +796,20 @@ async fn secrets_use_the_environment_key_and_only_their_owner_can_change_them() as_user.put_secret_value().secret_id(&own).secret_string("v2").send().await, ); + // `workspace-user-tasks.json`: the owner reads their own secret, never + // the environment's or someone else's. + let read = ok( + "get own", + as_user.get_secret_value().secret_id(&own).send().await, + ); + assert_eq!(read.secret_string(), Some("v2")); + for (what, arn) in [("environment", &environment), ("someone else's", &someone_elses)] { + denied( + &format!("get {what}"), + as_user.get_secret_value().secret_id(arn).send().await, + ); + } + // Any other key is refused. for (i, (what, k)) in [("no KmsKeyId", None), ("aws/secretsmanager", Some(aws_managed.as_str()))] .into_iter() diff --git a/app/src-tauri/src/aws/policies.rs b/app/src-tauri/src/aws/policies.rs index c1bfe33..f418465 100644 --- a/app/src-tauri/src/aws/policies.rs +++ b/app/src-tauri/src/aws/policies.rs @@ -35,10 +35,16 @@ pub fn policy_files(tier: Tier) -> &'static [(&'static str, &'static str)] { include_str!("../../../../aws/policies/environment-admin-network.json"), ), ], - Tier::WorkspaceUser => &[( - "workspace-user.json", - include_str!("../../../../aws/policies/workspace-user.json"), - )], + Tier::WorkspaceUser => &[ + ( + "workspace-user.json", + include_str!("../../../../aws/policies/workspace-user.json"), + ), + ( + "workspace-user-tasks.json", + include_str!("../../../../aws/policies/workspace-user-tasks.json"), + ), + ], } } @@ -240,6 +246,36 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[ ("ecs:CreateAction", Literal("RunTask")), ], ), + // `workspace-user-tasks.json`: a user registers, tags and retires their + // own task definitions, and reads their own secrets. + write( + "ecs:RegisterTaskDefinition", + PROBE_TASK_DEFINITION, + &[(OWNER_REQUEST_TAG, OwnerId)], + ), + write( + "ecs:TagResource", + PROBE_TASK_DEFINITION, + &[ + (OWNER_REQUEST_TAG, OwnerId), + ("ecs:CreateAction", Literal("RegisterTaskDefinition")), + ], + ), + write( + "ecs:DeregisterTaskDefinition", + PROBE_TASK_DEFINITION, + &[(OWNER_RESOURCE_TAG, OwnerId)], + ), + write( + "ecs:DeleteTaskDefinitions", + PROBE_TASK_DEFINITION, + &[(OWNER_RESOURCE_TAG, OwnerId)], + ), + read( + "secretsmanager:GetSecretValue", + PROBE_SECRET, + &[(OWNER_RESOURCE_TAG, OwnerId)], + ), write( "iam:PassRole", PROBE_ROLE, @@ -904,6 +940,27 @@ mod tests { ("ecs:RunTask", "task-definition/", OWNER_REQUEST_TAG), ("ecs:RunTask", "task-definition/", OWNER_RESOURCE_TAG), ("ecs:TagResource", "task/", OWNER_REQUEST_TAG), + ( + "ecs:RegisterTaskDefinition", + "task-definition/", + OWNER_REQUEST_TAG, + ), + ("ecs:TagResource", "task-definition/", OWNER_REQUEST_TAG), + ( + "ecs:DeregisterTaskDefinition", + "task-definition/", + OWNER_RESOURCE_TAG, + ), + ( + "ecs:DeleteTaskDefinitions", + "task-definition/", + OWNER_RESOURCE_TAG, + ), + ( + "secretsmanager:GetSecretValue", + "secret:", + OWNER_RESOURCE_TAG, + ), ("ecs:StopTask", "task/", OWNER_RESOURCE_TAG), ("ecs:ExecuteCommand", "task/", OWNER_RESOURCE_TAG), ("ssm:StartSession", "task/", OWNER_RESOURCE_TAG), @@ -1066,6 +1123,170 @@ mod tests { assert!(!allowed(Tier::WorkspaceUser, &other_cluster, ME, ME)); } + /// The three tag keys a workspace user may put on anything they create. + const OWN_TAG_KEYS: Context = &[ + ("aws:TagKeys", Literal("triple-c:owner")), + ("aws:TagKeys", Literal("triple-c:project")), + ("aws:TagKeys", Literal("triple-c:environment")), + ]; + + fn ws_check(action: &str, resource_part: &str) -> &'static Check { + checks(Tier::WorkspaceUser) + .iter() + .find(|c| c.action == action && c.resource.contains(resource_part)) + .unwrap_or_else(|| panic!("no workspace-user check for {action} on {resource_part}")) + } + + #[test] + fn registering_a_task_definition_needs_the_owner_tag_and_only_the_three_keys() { + let reg = ws_check("ecs:RegisterTaskDefinition", "task-definition/"); + let with_keys = |extra: Option<&'static str>| -> Check { + let ctx: Context = match extra { + None => &[ + (OWNER_REQUEST_TAG, OwnerId), + ("aws:TagKeys", Literal("triple-c:owner")), + ("aws:TagKeys", Literal("triple-c:project")), + ("aws:TagKeys", Literal("triple-c:environment")), + ], + Some(_) => &[ + (OWNER_REQUEST_TAG, OwnerId), + ("aws:TagKeys", Literal("triple-c:owner")), + ("aws:TagKeys", Literal("triple-c:project")), + ("aws:TagKeys", Literal("triple-c:environment")), + ("aws:TagKeys", Literal("extra")), + ], + }; + Check { context: ctx, ..*reg } + }; + assert!(allowed(Tier::WorkspaceUser, &with_keys(None), ME, ME)); + assert!( + !allowed(Tier::WorkspaceUser, &with_keys(Some("extra")), ME, ME), + "an extra tag key must be refused" + ); + let no_owner = Check { + context: OWN_TAG_KEYS, + ..*reg + }; + assert!( + !allowed(Tier::WorkspaceUser, &no_owner, ME, ME), + "no owner request tag" + ); + let someone_elses = Check { + context: &[(OWNER_REQUEST_TAG, Literal(SOMEONE_ELSE))], + ..*reg + }; + assert!(!allowed(Tier::WorkspaceUser, &someone_elses, ME, ME)); + // Only Triple-C's families. + let other_family = Check { + resource: "arn:aws:ecs:{region}:{account}:task-definition/production:1", + ..*reg + }; + assert!(!allowed(Tier::WorkspaceUser, &other_family, ME, ME)); + } + + #[test] + fn task_definitions_are_tagged_only_at_register_and_only_with_the_three_keys() { + let tag = ws_check("ecs:TagResource", "task-definition/"); + assert!(allowed(Tier::WorkspaceUser, tag, ME, ME)); + let retag = Check { + context: &[(OWNER_REQUEST_TAG, OwnerId)], + ..*tag + }; + assert!(!allowed(Tier::WorkspaceUser, &retag, ME, ME)); + let other_create = Check { + context: &[ + (OWNER_REQUEST_TAG, OwnerId), + ("ecs:CreateAction", Literal("RunTask")), + ], + ..*tag + }; + assert!(!allowed(Tier::WorkspaceUser, &other_create, ME, ME)); + let extra_key = Check { + context: &[ + (OWNER_REQUEST_TAG, OwnerId), + ("ecs:CreateAction", Literal("RegisterTaskDefinition")), + ("aws:TagKeys", Literal("triple-c:owner")), + ("aws:TagKeys", Literal("triple-c:component")), + ], + ..*tag + }; + assert!( + !allowed(Tier::WorkspaceUser, &extra_key, ME, ME), + "a task definition must not be tagged triple-c:component" + ); + } + + #[test] + fn only_the_owner_retires_a_task_definition() { + for action in ["ecs:DeregisterTaskDefinition", "ecs:DeleteTaskDefinitions"] { + let c = ws_check(action, "task-definition/"); + assert!(allowed(Tier::WorkspaceUser, c, ME, ME), "{action}"); + let theirs = Check { + context: &[(OWNER_RESOURCE_TAG, Literal(SOMEONE_ELSE))], + ..*c + }; + assert!(!allowed(Tier::WorkspaceUser, &theirs, ME, ME), "{action}"); + let other_family = Check { + resource: "arn:aws:ecs:{region}:{account}:task-definition/production:1", + ..*c + }; + assert!(!allowed(Tier::WorkspaceUser, &other_family, ME, ME), "{action}"); + } + } + + /// RegisterTaskDefinition with a task or execution role also authorizes + /// `iam:PassRole` on each; `PassOwnTaskRoles` (owner-tagged, to ECS + /// tasks, `triple-c-*` roles) must cover both and nothing else. + #[test] + fn register_task_definition_can_pass_the_users_task_and_execution_roles() { + let pass = ws_check("iam:PassRole", "role/"); + for role in [ + "arn:aws:iam::{account}:role/triple-c-prod-0123456789ab-task", + "arn:aws:iam::{account}:role/triple-c-prod-0123456789ab-exec", + ] { + let c = Check { resource: role, ..*pass }; + assert!(allowed(Tier::WorkspaceUser, &c, ME, ME), "{role}"); + assert!(!allowed(Tier::WorkspaceUser, &c, ME, SOMEONE_ELSE), "{role}"); + } + } + + #[test] + fn secrets_are_readable_only_by_their_owner_and_never_the_environments() { + let get = ws_check("secretsmanager:GetSecretValue", "secret:"); + assert!(allowed(Tier::WorkspaceUser, get, ME, ME)); + assert!(!allowed(Tier::WorkspaceUser, get, ME, SOMEONE_ELSE)); + // Even tagged with the caller's owner ID, a component-tagged secret + // (the environment's) is off limits: the Deny holds on its own. + let environments = Check { + context: &[ + (OWNER_RESOURCE_TAG, OwnerId), + (COMPONENT_RESOURCE_TAG, Literal("environment")), + ], + ..*get + }; + assert!(!allowed(Tier::WorkspaceUser, &environments, ME, ME)); + let component_only = Check { + context: &[(COMPONENT_RESOURCE_TAG, Literal("environment"))], + ..*get + }; + let req = Request { + check: &component_only, + caller: ME, + owner: ME, + }; + let stmts = denies(Tier::WorkspaceUser); + assert!( + matching(&stmts, &req).iter().any(|s| conditions_hold(s, &req)), + "missing a Deny of GetSecretValue on component-tagged secrets" + ); + // Not outside Triple-C's secrets. + let other = Check { + resource: "arn:aws:secretsmanager:{region}:{account}:secret:prod/db-AbCdEf", + ..*get + }; + assert!(!allowed(Tier::WorkspaceUser, &other, ME, ME)); + } + #[test] fn ecs_tag_resource_only_tags_at_run_task() { let tag = checks(Tier::WorkspaceUser) @@ -1617,7 +1838,10 @@ mod tests { names(Tier::EnvironmentAdmin), ["environment-admin.json", "environment-admin-network.json"] ); - assert_eq!(names(Tier::WorkspaceUser), ["workspace-user.json"]); + assert_eq!( + names(Tier::WorkspaceUser), + ["workspace-user.json", "workspace-user-tasks.json"] + ); } /// D8: CloudFormation leaves the policies, the checks and the probes. diff --git a/app/src-tauri/src/commands/host_commands.rs b/app/src-tauri/src/commands/host_commands.rs index 823a0d8..3c7bc66 100644 --- a/app/src-tauri/src/commands/host_commands.rs +++ b/app/src-tauri/src/commands/host_commands.rs @@ -547,8 +547,11 @@ mod tests { serde_json::from_str::(&f.json).expect("policy JSON"); } let ws = aws_policy_document(Tier::WorkspaceUser); - assert_eq!(ws.len(), 1); - assert_eq!(ws[0].name, "workspace-user.json"); + let names: Vec<&str> = ws.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["workspace-user.json", "workspace-user-tasks.json"]); + for f in &ws { + serde_json::from_str::(&f.json).expect("policy JSON"); + } assert_eq!( serde_json::to_value(&ws[0]).unwrap()["name"], "workspace-user.json" diff --git a/aws/README.md b/aws/README.md index e1696bc..46d9711 100644 --- a/aws/README.md +++ b/aws/README.md @@ -6,15 +6,16 @@ tier the signed-in identity has under **Settings → Hosts → Check**. | File | What it is | |---|---| -| `policies/workspace-user.json` | The **workspace user** tier: create, start, stop, connect to and delete **their own** workspaces in an existing environment | +| `policies/workspace-user.json` | The **workspace user** tier, part 1: create, start, stop, connect to and delete **their own** workspaces in an existing environment | +| `policies/workspace-user-tasks.json` | The **workspace user** tier, part 2: register, tag and retire **their own** ECS task definitions, and read **their own** secrets (`workspace-user.json` has no room left) | | `policies/environment-admin.json` | The **environment admin** tier, part 1: apply and destroy the environment and onboarding modules (`terraform/`) with OpenTofu, and keep their state | | `policies/environment-admin-network.json` | The **environment admin** tier, part 2: the network those modules create (VPC, subnets, gateways, routes, security groups) | | `policies/permissions-boundary.json` | The **permissions boundary** every Triple-C role must carry. It is never attached to a person | -The admin tier is **two** files, because one managed policy holds only 6,144 characters; both are -required, and the app's **Copy** lists every file of a tier. An environment admin attaches all three -tier files (the workspace user's and both admin files): the admin files add environment management -on top of everything a workspace user can do. Explicit Deny statements in any file apply to anyone +The workspace tier and the admin tier are each **two** files, because one managed policy holds only +6,144 characters; both files of a tier are required, and the app's **Copy** lists every file of a +tier. An environment admin attaches all four tier files (both workspace user files and both admin +files): the admin files add environment management on top of everything a workspace user can do. Explicit Deny statements in any file apply to anyone who has it attached. ## Workspace user @@ -26,6 +27,23 @@ following: - **Run tasks.** The task definition must be a `triple-c-*` one tagged with the user's own owner ID, and the cluster must be a `triple-c-*` one. The new task is tagged with the user's owner ID. The user can pass only `triple-c-*` roles tagged with their owner ID, and only to ECS tasks. +- **Register their own task definitions** (`workspace-user-tasks.json`). `ecs:RegisterTaskDefinition` + on `triple-c-*` families needs `triple-c:owner = ${aws:userid}` in the request and only the three + ordinary tag keys. `ecs:TagResource` on a task definition is allowed only at registration + (`ecs:CreateAction = RegisterTaskDefinition`, same owner and tag-key conditions), so a + definition can never be retagged. `ecs:DeregisterTaskDefinition` and `ecs:DeleteTaskDefinitions` + need the definition to carry the user's owner ID. A task definition that names a task or + execution role is also authorized with `iam:PassRole` on it, which `PassOwnTaskRoles` already + covers (owner-tagged `triple-c-*` roles, to ECS tasks only; a unit test checks both roles). + `RunOwnTasks` still requires the definition's owner tag, so a user can run only definitions they + registered. The statements use the revision-free and revision forms alike: the `*` in + `task-definition/triple-c-*` matches `family:revision`. +- **Read their own secrets.** `secretsmanager:GetSecretValue` on `triple-c/*` secrets tagged with + their owner ID, so a second machine can read the agent token and attach to a running task + (`ReadOwnSecrets`). An explicit Deny (`NeverReadEnvironmentSecrets`) refuses it on any secret + tagged `triple-c:component`, even one that also carries their owner tag. (`NeverTouchEnvironmentSecrets` + in `workspace-user.json` covers writes; it has no room for another action.) Reading also + needs `kms:Decrypt` through Secrets Manager on the environment key, which the first file grants. - **Work with their own tasks.** They can stop them, `ecs execute-command` into them, and open an SSM session to them. They can end or resume only sessions they started, and open data channels (`ssmmessages:OpenDataChannel`) only on those. @@ -341,7 +359,8 @@ Create each file as a **customer managed policy** and attach it. In the console, or role. Inline policies do not fit: a role's inline policies share 10,240 characters and an environment admin needs every tier file (about 14,500), and a user's share 2,048. A managed policy may hold 6,144 characters, not counting whitespace. Without whitespace, `workspace-user.json` is -about 6,040 (roughly 100 characters of headroom), `environment-admin.json` about 5,800 (340) +about 6,040 (roughly 100 characters of headroom), `workspace-user-tasks.json` about 1,460, +`environment-admin.json` about 5,800 (340) and `environment-admin-network.json` about 3,340; a unit test (`every_policy_fits_a_managed_policy`) fails before any file outgrows the limit. A tier that outgrows its files gains another file, which the app then lists with the others. For IAM Identity Center, add the files as inline policies on the permission set @@ -352,8 +371,12 @@ With the CLI: ```sh aws iam create-policy --policy-name triple-c-workspace-user \ --policy-document file://policies/workspace-user.json +aws iam create-policy --policy-name triple-c-workspace-user-tasks \ + --policy-document file://policies/workspace-user-tasks.json aws iam attach-user-policy --user-name \ --policy-arn arn:aws:iam:::policy/triple-c-workspace-user +aws iam attach-user-policy --user-name \ + --policy-arn arn:aws:iam:::policy/triple-c-workspace-user-tasks ``` ## Known gaps @@ -492,6 +515,19 @@ asserts that every role the admin creates carries the boundary. Still open: +- **`ecs:RegisterTaskDefinition` and resource-level permissions (phase 5, unverified).** The + statements in `workspace-user-tasks.json` scope the action to `task-definition/triple-c-*`, but + whether ECS authorizes a register call against that ARN (the family is known, the revision is + not yet) or only against `*` has not been checked on real IAM; the unit tests' evaluator only + proves the JSON means what it says. The `aws:RequestTag` and `aws:TagKeys` conditions are + documented for the action either way. Probe, as a role holding the workspace files: register a + `triple-c-e2e-*` definition tagged with the owner and the two other keys. If it is denied + while the policy simulator allows it, change `Resource` of `RegisterOwnTaskDefinitions` to + `"*"` (the request-tag conditions still confine it; the family name is then unenforced) and + record that here. Also confirm `ecs:TagResource` with `ecs:CreateAction = RegisterTaskDefinition` + tags at registration, that `DeregisterTaskDefinition`/`DeleteTaskDefinitions` honour + `aws:ResourceTag`, and that a user can read their own secret only (`live_tests.rs` checks the + secret read); - the `existing` network mode against a user's own VPC, and an onboarding by a workspace user from an admin bundle (the same module, applied with admin credentials); - `DisassociateGoneAddresses` cannot touch a **live** association. Probe: allocate an untagged diff --git a/aws/policies/workspace-user-tasks.json b/aws/policies/workspace-user-tasks.json new file mode 100644 index 0000000..6815ce6 --- /dev/null +++ b/aws/policies/workspace-user-tasks.json @@ -0,0 +1,96 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "RegisterOwnTaskDefinitions", + "Effect": "Allow", + "Action": [ + "ecs:RegisterTaskDefinition" + ], + "Resource": [ + "arn:aws:ecs:*:*:task-definition/triple-c-*" + ], + "Condition": { + "StringEquals": { + "aws:RequestTag/triple-c:owner": "${aws:userid}" + }, + "ForAllValues:StringEquals": { + "aws:TagKeys": [ + "triple-c:owner", + "triple-c:project", + "triple-c:environment" + ] + } + } + }, + { + "Sid": "TagOwnTaskDefinitionsAtRegister", + "Effect": "Allow", + "Action": [ + "ecs:TagResource" + ], + "Resource": [ + "arn:aws:ecs:*:*:task-definition/triple-c-*" + ], + "Condition": { + "StringEquals": { + "ecs:CreateAction": "RegisterTaskDefinition", + "aws:RequestTag/triple-c:owner": "${aws:userid}" + }, + "ForAllValues:StringEquals": { + "aws:TagKeys": [ + "triple-c:owner", + "triple-c:project", + "triple-c:environment" + ] + } + } + }, + { + "Sid": "RetireOwnTaskDefinitions", + "Effect": "Allow", + "Action": [ + "ecs:DeregisterTaskDefinition", + "ecs:DeleteTaskDefinitions" + ], + "Resource": [ + "arn:aws:ecs:*:*:task-definition/triple-c-*" + ], + "Condition": { + "StringEquals": { + "aws:ResourceTag/triple-c:owner": "${aws:userid}" + } + } + }, + { + "Sid": "ReadOwnSecrets", + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue" + ], + "Resource": [ + "arn:aws:secretsmanager:*:*:secret:triple-c/*" + ], + "Condition": { + "StringEquals": { + "aws:ResourceTag/triple-c:owner": "${aws:userid}" + } + } + }, + { + "Sid": "NeverReadEnvironmentSecrets", + "Effect": "Deny", + "Action": [ + "secretsmanager:GetSecretValue" + ], + "Resource": [ + "arn:aws:secretsmanager:*:*:secret:triple-c/*" + ], + "Condition": { + "Null": { + "aws:ResourceTag/triple-c:component": "false" + } + } + } + ] +} -- 2.52.0 From 7320aaa5d4b7538539d3c8c726cd4d4eaab33c52 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 08:27:48 -0800 Subject: [PATCH 05/43] fix(aws): document register-into-family residual risk, correct sizes Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/policies.rs | 42 ++++++++++++++++--------------- aws/README.md | 21 ++++++++++------ 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/app/src-tauri/src/aws/policies.rs b/app/src-tauri/src/aws/policies.rs index f418465..40aed77 100644 --- a/app/src-tauri/src/aws/policies.rs +++ b/app/src-tauri/src/aws/policies.rs @@ -248,6 +248,12 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[ ), // `workspace-user-tasks.json`: a user registers, tags and retires their // own task definitions, and reads their own secrets. + // Residual risk the policy cannot close: the family carries the owner + // key, which IAM can't condition on, so any user may register a revision + // into another owner's family. The app's mitigation: it runs only the + // exact `family:revision` ARN its own register call returned, and when it + // looks up an existing revision it accepts only one tagged with the + // caller's own owner ID (never "latest of the family"). write( "ecs:RegisterTaskDefinition", PROBE_TASK_DEFINITION, @@ -1140,27 +1146,23 @@ mod tests { #[test] fn registering_a_task_definition_needs_the_owner_tag_and_only_the_three_keys() { let reg = ws_check("ecs:RegisterTaskDefinition", "task-definition/"); - let with_keys = |extra: Option<&'static str>| -> Check { - let ctx: Context = match extra { - None => &[ - (OWNER_REQUEST_TAG, OwnerId), - ("aws:TagKeys", Literal("triple-c:owner")), - ("aws:TagKeys", Literal("triple-c:project")), - ("aws:TagKeys", Literal("triple-c:environment")), - ], - Some(_) => &[ - (OWNER_REQUEST_TAG, OwnerId), - ("aws:TagKeys", Literal("triple-c:owner")), - ("aws:TagKeys", Literal("triple-c:project")), - ("aws:TagKeys", Literal("triple-c:environment")), - ("aws:TagKeys", Literal("extra")), - ], - }; - Check { context: ctx, ..*reg } - }; - assert!(allowed(Tier::WorkspaceUser, &with_keys(None), ME, ME)); + const THREE_KEYS: Context = &[ + (OWNER_REQUEST_TAG, OwnerId), + ("aws:TagKeys", Literal("triple-c:owner")), + ("aws:TagKeys", Literal("triple-c:project")), + ("aws:TagKeys", Literal("triple-c:environment")), + ]; + const FOUR_KEYS: Context = &[ + (OWNER_REQUEST_TAG, OwnerId), + ("aws:TagKeys", Literal("triple-c:owner")), + ("aws:TagKeys", Literal("triple-c:project")), + ("aws:TagKeys", Literal("triple-c:environment")), + ("aws:TagKeys", Literal("extra")), + ]; + let with_keys = |context: Context| Check { context, ..*reg }; + assert!(allowed(Tier::WorkspaceUser, &with_keys(THREE_KEYS), ME, ME)); assert!( - !allowed(Tier::WorkspaceUser, &with_keys(Some("extra")), ME, ME), + !allowed(Tier::WorkspaceUser, &with_keys(FOUR_KEYS), ME, ME), "an extra tag key must be refused" ); let no_owner = Check { diff --git a/aws/README.md b/aws/README.md index 46d9711..d250679 100644 --- a/aws/README.md +++ b/aws/README.md @@ -12,10 +12,11 @@ tier the signed-in identity has under **Settings → Hosts → Check**. | `policies/environment-admin-network.json` | The **environment admin** tier, part 2: the network those modules create (VPC, subnets, gateways, routes, security groups) | | `policies/permissions-boundary.json` | The **permissions boundary** every Triple-C role must carry. It is never attached to a person | -The workspace tier and the admin tier are each **two** files, because one managed policy holds only -6,144 characters; both files of a tier are required, and the app's **Copy** lists every file of a -tier. An environment admin attaches all four tier files (both workspace user files and both admin -files): the admin files add environment management on top of everything a workspace user can do. Explicit Deny statements in any file apply to anyone +The workspace tier and the admin tier are each **two** files, because one managed policy holds +only 6,144 characters; both files of a tier are required, and the app's **Copy** lists every +file of a tier. An environment admin attaches all four tier files (both workspace user files and +both admin files): the admin files add environment management on top of everything a workspace +user can do. Explicit Deny statements in any file apply to anyone who has it attached. ## Workspace user @@ -36,8 +37,14 @@ following: execution role is also authorized with `iam:PassRole` on it, which `PassOwnTaskRoles` already covers (owner-tagged `triple-c-*` roles, to ECS tasks only; a unit test checks both roles). `RunOwnTasks` still requires the definition's owner tag, so a user can run only definitions they - registered. The statements use the revision-free and revision forms alike: the `*` in - `task-definition/triple-c-*` matches `family:revision`. + registered. The `*` in `task-definition/triple-c-*` matches `family:revision` too. + - **Residual risk: polluting another owner's family.** The family name carries the owner key, + which IAM cannot condition on, so any workspace user can register a *revision* into another + owner's `triple-c-*` family and make it the family's latest. It cannot run or steal anything + (`RunOwnTasks` needs the owner tag, and the new revision carries the attacker's), and the + policy cannot close this. The app's mitigation: it always runs the exact `family:revision` + ARN its own register call returned, and when it looks up an existing revision it accepts + only one tagged with the caller's own owner ID, never "latest of the family". - **Read their own secrets.** `secretsmanager:GetSecretValue` on `triple-c/*` secrets tagged with their owner ID, so a second machine can read the agent token and attach to a running task (`ReadOwnSecrets`). An explicit Deny (`NeverReadEnvironmentSecrets`) refuses it on any secret @@ -357,7 +364,7 @@ module creates the same policy, applied by an account administrator; the admin t Create each file as a **customer managed policy** and attach it. In the console, go to **IAM → Policies → Create policy → JSON**, paste the file, and attach the policy to a user, group or role. Inline policies do not fit: a role's inline policies share 10,240 characters and an -environment admin needs every tier file (about 14,500), and a user's share 2,048. A managed policy +environment admin needs every tier file (about 16,600), and a user's share 2,048. A managed policy may hold 6,144 characters, not counting whitespace. Without whitespace, `workspace-user.json` is about 6,040 (roughly 100 characters of headroom), `workspace-user-tasks.json` about 1,460, `environment-admin.json` about 5,800 (340) -- 2.52.0 From 64ff3baab23368c48bff6bdb5b83112db9840c44 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 08:39:58 -0800 Subject: [PATCH 06/43] feat(aws): per-project secret, access point and task definition aws::workspace prepares an AWS project before its task runs: one Secrets Manager secret holding every project-shaped value (P1; files as one TRIPLEC_FILES key per R1), the Hybrid EFS access point, and a task definition whose environment is only TRIPLEC_HOST and COLORTERM (R2), with every payload key as a valueFrom secret reference. A revision is reused only when it carries the caller's owner tag, this project's tag and the expected fingerprint label (other users can register into any triple-c-* family), always by its exact family:revision ARN. Payload keys and secrets entries are sorted so unchanged settings never register a new revision. Names (family, secret, access point root, log stream prefix, file system token) live in tofu_modules and use owner_key. Adds aws-sdk-efs and aws-sdk-cloudwatchlogs (released with the pinned aws-sdk-ecs; lock gains only their own entries). Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/Cargo.lock | 51 ++ app/src-tauri/Cargo.toml | 2 + app/src-tauri/src/aws/mod.rs | 3 + app/src-tauri/src/aws/tofu_modules.rs | 58 ++ app/src-tauri/src/aws/workspace.rs | 955 +++++++++++++++++++++ app/src-tauri/src/aws/workspace/tests.rs | 1002 ++++++++++++++++++++++ 6 files changed, 2071 insertions(+) create mode 100644 app/src-tauri/src/aws/workspace.rs create mode 100644 app/src-tauri/src/aws/workspace/tests.rs diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index f73bdd9..b38d3a9 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -367,6 +367,30 @@ dependencies = [ "uuid", ] +[[package]] +name = "aws-sdk-cloudwatchlogs" +version = "1.165.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581cf82c1682f1251b910a32af5b19f9f5faed98c9117825391c1fcdfde21855" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "fastrand", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-ecs" version = "1.159.0" @@ -390,6 +414,31 @@ dependencies = [ "tracing", ] +[[package]] +name = "aws-sdk-efs" +version = "1.125.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa82ea1472278e955c2f59c534a23681f630e6f215e29263eb10e25b0b8b281a" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-iam" version = "1.136.0" @@ -7392,7 +7441,9 @@ dependencies = [ "aws-config", "aws-credential-types", "aws-runtime", + "aws-sdk-cloudwatchlogs", "aws-sdk-ecs", + "aws-sdk-efs", "aws-sdk-iam", "aws-sdk-kms", "aws-sdk-s3", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index d6c7c0f..533e161 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -68,6 +68,8 @@ aws-sdk-ecs = { version = "1.159.0", default-features = false, features = ["defa aws-sdk-sts = { version = "1.126.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } aws-sdk-iam = { version = "1.136.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } aws-sdk-secretsmanager = { version = "1.128.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } +aws-sdk-efs = { version = "1.125.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } +aws-sdk-cloudwatchlogs = { version = "1.165.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } # Named by src/aws/connection.rs: the credentials-provider trait (its profile # wrapper) and the profile-file set (aws-config's alias for it is deprecated). # Both are the versions aws-config already resolves. diff --git a/app/src-tauri/src/aws/mod.rs b/app/src-tauri/src/aws/mod.rs index 1cdeb07..d04e826 100644 --- a/app/src-tauri/src/aws/mod.rs +++ b/app/src-tauri/src/aws/mod.rs @@ -15,6 +15,9 @@ pub mod policies; mod policy_eval; pub mod tofu; pub mod tofu_modules; +// Used by the AWS host (phase 5, Task 6); until then only by its tests. +#[allow(dead_code)] +pub mod workspace; pub use connection::{sdk_config, sdk_config_for_keys}; pub use error::{classify_sdk_error, AwsError}; diff --git a/app/src-tauri/src/aws/tofu_modules.rs b/app/src-tauri/src/aws/tofu_modules.rs index 9561ee4..38ad549 100644 --- a/app/src-tauri/src/aws/tofu_modules.rs +++ b/app/src-tauri/src/aws/tofu_modules.rs @@ -122,6 +122,64 @@ pub fn user_role_names(env: &str, owner_key: &str) -> (String, String) { ) } +/// A project id as a name component: `[A-Za-z0-9_-]` only (anything else +/// becomes `-`), at most 64 characters, never empty. Project ids are UUIDs, +/// which pass unchanged; this only keeps a hand-edited `projects.json` from +/// putting `/`, `..` or `:` into a secret name or an EFS path. +fn project_component(project_id: &str) -> String { + let s: String = project_id + .chars() + .take(64) + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + c + } else { + '-' + } + }) + .collect(); + if s.is_empty() { + "_".to_string() + } else { + s + } +} + +/// A project's ECS task definition family: +/// `triple-c---`. At most 55 +/// characters of `[A-Za-z0-9_-]`. Other users can register revisions into +/// any `triple-c-*` family, so a family is never trusted by name alone (see +/// `aws::workspace`). +pub fn task_family(env: &str, owner_key: &str, project_id: &str) -> String { + let id: String = project_component(project_id).chars().take(12).collect(); + format!("triple-c-{env}-{owner_key}-{id}") +} + +/// The project's Secrets Manager secret (P1): `triple-c///`. +pub fn project_secret_name(env: &str, owner_key: &str, project_id: &str) -> String { + format!( + "triple-c/{env}/{owner_key}/{}", + project_component(project_id) + ) +} + +/// The root of the project's EFS access point (P4), at most 90 characters. +pub fn access_point_root(owner_key: &str, project_id: &str) -> String { + format!("/triple-c/{owner_key}/{}", project_component(project_id)) +} + +/// The container's `awslogs-stream-prefix`; ECS names the stream +/// `/workspace/`. +pub fn log_stream_prefix(owner_key: &str, project_id: &str) -> String { + format!("{owner_key}/{}", project_component(project_id)) +} + +/// The `creation_token` of the user's file system (the onboarding module's +/// `local.name`). +pub fn user_file_system_token(env: &str, owner_key: &str) -> String { + format!("triple-c-{env}-{owner_key}") +} + /// The per-account, per-region state bucket. pub fn state_bucket_name(account: &str, region: &str) -> String { format!("triple-c-tfstate-{account}-{region}") diff --git a/app/src-tauri/src/aws/workspace.rs b/app/src-tauri/src/aws/workspace.rs new file mode 100644 index 0000000..ca51ac4 --- /dev/null +++ b/app/src-tauri/src/aws/workspace.rs @@ -0,0 +1,955 @@ +//! A project's AWS resources, made ready before its task runs: the project +//! secret, the EFS access point (Hybrid storage) and the task definition. +//! +//! **Everything project-shaped goes in the secret (P1).** Every workspace user +//! has `ecs:Describe*` on `*`, so a task definition's `environment`, tags and +//! labels are readable by everyone in the account. The task definition holds +//! only two constants in `environment`, the three ownership tags, a +//! fingerprint label (a hash), and one `secrets` entry per payload key. +//! +//! **A task definition family is not trusted by name.** Any workspace user +//! may register a revision into any `triple-c-*` family (the policy can't +//! scope RegisterTaskDefinition to an owner's family). So a revision is reused +//! only when it carries the caller's own owner tag (which only the caller can +//! set at register) and this project's tag and the expected fingerprint, and +//! it is always named by its exact `family:revision` ARN. +//! +//! **Determinism.** Custom env vars come out of `workspace_env` in HashMap +//! order, so the payload is a `BTreeMap` and the `secrets` entries follow its +//! key order: unchanged settings give the same fingerprint, and therefore no +//! new revision, on every start. The agent token rotates every start (P2) but +//! the key set does not. + +use std::collections::BTreeMap; +use std::future::Future; + +use aws_config::SdkConfig; +use aws_sdk_ecs::operation::register_task_definition::RegisterTaskDefinitionInput; +use aws_sdk_ecs::types::{ + Compatibility, ContainerDefinition, CpuArchitecture, EfsAuthorizationConfig, + EfsAuthorizationConfigIam, EfsTransitEncryption, EfsVolumeConfiguration, EphemeralStorage, + HealthCheck, KernelCapabilities, KeyValuePair, LinuxParameters, LogConfiguration, LogDriver, + MountPoint, NetworkMode, OsFamily, RuntimePlatform, Secret, SortOrder, Tag, + TaskDefinitionField, TaskDefinitionStatus, Volume, +}; +use base64::Engine; +use rand::RngCore; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use super::environments::EnvironmentInfo; +use super::error::{classify_sdk_error, AwsError}; +use super::identity::CallerIdentity; +use super::tofu_modules::{ + access_point_root, log_stream_prefix, owner_key, project_secret_name, task_family, + user_file_system_token, user_role_names, +}; +use crate::host::ssm::AGENT_PORT; +use crate::models::project::{validate_aws_workspace, Arch, RepoSource, StorageMode}; +use crate::models::Project; + +pub const TAG_OWNER: &str = "triple-c:owner"; +pub const TAG_PROJECT: &str = "triple-c:project"; +pub const TAG_ENVIRONMENT: &str = "triple-c:environment"; +/// The container's docker label carrying [`workspace_fingerprint`]. Tags +/// can't: the policy admits only the three tag keys above. +pub const FINGERPRINT_LABEL: &str = "triple-c.fingerprint"; +pub const CONTAINER_NAME: &str = "workspace"; +/// Where a Hybrid project's access point is mounted (P4). +pub const HOME_MOUNT_PATH: &str = "/mnt/triple-c-home"; +const HOME_VOLUME: &str = "home"; +/// Secrets Manager's `SecretString` limit. +pub const MAX_SECRET_BYTES: usize = 65_536; +/// At most this many of a family's newest revisions are described when +/// looking for a reusable one; past that a new one is registered. +pub const MAX_REVISIONS_CHECKED: usize = 10; +/// Part of the fingerprint. Bump it whenever [`task_definition_request`] +/// changes what it builds from the same plan, so existing projects pick up +/// the new shape. +const TASK_DEFINITION_TEMPLATE: u32 = 1; +/// The container user (`claude`), owner of the access point's root. +const WORKSPACE_UID: i64 = 1000; + +/// The payload's own keys. Custom env pairs of these names are dropped. +const KEY_AGENT_TOKEN: &str = "TRIPLEC_AGENT_TOKEN"; +const KEY_FILES: &str = "TRIPLEC_FILES"; +const KEY_REPOS: &str = "TRIPLEC_REPOS"; +const KEY_WIP_PUSH: &str = "TRIPLEC_WIP_PUSH"; +const KEY_IDLE: &str = "TRIPLEC_IDLE_STOP_MINUTES"; +const KEY_STORAGE: &str = "TRIPLEC_STORAGE"; +/// The task definition's whole `environment` (R2): constants, no project data. +const FIXED_ENVIRONMENT: [(&str, &str); 2] = [("TRIPLEC_HOST", "aws"), ("COLORTERM", "truecolor")]; + +fn other(message: impl Into) -> AwsError { + AwsError::Other { + message: message.into(), + } +} + +/// The three ownership tags, in key order, and nothing else. +pub fn workspace_tags(user_id: &str, project_id: &str, env: &str) -> Vec<(String, String)> { + vec![ + (TAG_ENVIRONMENT.to_string(), env.to_string()), + (TAG_OWNER.to_string(), user_id.to_string()), + (TAG_PROJECT.to_string(), project_id.to_string()), + ] +} + +/// A fresh agent token: 48 random bytes, URL-safe base64 without padding. +pub fn new_agent_token() -> String { + let mut bytes = [0u8; 48]; + rand::rng().fill_bytes(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +// ------------------------------------------------------------------ payload + +/// A name `valueFrom: :::` can carry and a shell can export. +fn is_env_name(key: &str) -> bool { + let b = key.as_bytes(); + !b.is_empty() + && (b[0].is_ascii_alphabetic() || b[0] == b'_') + && b.iter().all(|c| c.is_ascii_alphanumeric() || *c == b'_') +} + +/// `ssh/` or `ca/`, the file name non-empty, without `/`, and +/// not `.` or `..` (R1: the entrypoint writes it under `/tmp/.host-*/`). +fn validate_file_key(key: &str) -> Result<(), String> { + let file = key + .strip_prefix("ssh/") + .or_else(|| key.strip_prefix("ca/")) + .ok_or_else(|| format!("\"{}\" is not an ssh/ or ca/ file", key.escape_debug()))?; + if file.is_empty() || file.contains('/') || file.contains('\0') || file == "." || file == ".." { + return Err(format!( + "\"{}\" is not a valid file name", + key.escape_debug() + )); + } + Ok(()) +} + +fn payload_map( + env_pairs: &[(String, String)], + agent_token: &str, + files: &[(String, Vec)], + repos: &[RepoSource], + wip: bool, + idle: Option, + storage: StorageMode, +) -> Result, String> { + let own = [ + KEY_AGENT_TOKEN, + KEY_FILES, + KEY_REPOS, + KEY_WIP_PUSH, + KEY_IDLE, + KEY_STORAGE, + ]; + let mut m = BTreeMap::new(); + for (k, v) in env_pairs { + if !is_env_name(k) { + return Err(format!( + "The environment variable name \"{}\" can't be passed to an AWS task: use \ + letters, digits and _, not starting with a digit", + k.escape_debug() + )); + } + if own.contains(&k.as_str()) || FIXED_ENVIRONMENT.iter().any(|(f, _)| f == k) { + log::warn!("Ignoring the environment variable {k}: Triple-C sets it on AWS"); + continue; + } + m.insert(k.clone(), v.clone()); + } + let b64 = base64::engine::general_purpose::STANDARD; + let mut file_map = BTreeMap::new(); + for (k, bytes) in files { + validate_file_key(k)?; + file_map.insert(k.clone(), b64.encode(bytes)); + } + m.insert(KEY_AGENT_TOKEN.into(), agent_token.into()); + m.insert(KEY_FILES.into(), json(&file_map)); + m.insert(KEY_REPOS.into(), json(&repos)); + m.insert(KEY_WIP_PUSH.into(), if wip { "1" } else { "0" }.into()); + if let Some(minutes) = idle { + m.insert(KEY_IDLE.into(), minutes.to_string()); + } + m.insert( + KEY_STORAGE.into(), + match storage { + StorageMode::Ephemeral => "ephemeral", + StorageMode::Hybrid => "hybrid", + } + .into(), + ); + Ok(m) +} + +/// A nested JSON value (string keys, plain data: cannot fail). +fn json(v: &T) -> String { + serde_json::to_string(v).expect("plain data serialises") +} + +fn serialize_payload(m: &BTreeMap) -> Result { + let json = serde_json::to_string(m).expect("a string map serialises"); + if json.len() > MAX_SECRET_BYTES { + return Err(format!( + "The project's settings for AWS come to {} bytes, more than the 65,536 a Secrets \ + Manager secret holds. Shorten the instructions, Claude Code settings or custom \ + environment variables, or remove files from the SSH folder.", + json.len() + )); + } + Ok(json) +} + +/// The project secret's JSON (P1, R1): every env pair, then the payload's own +/// keys (`TRIPLEC_AGENT_TOKEN`, `TRIPLEC_FILES` = `{"ssh/"|"ca/": base64}`, +/// `TRIPLEC_REPOS`, `TRIPLEC_WIP_PUSH` = `1`/`0`, `TRIPLEC_IDLE_STOP_MINUTES` +/// when on, `TRIPLEC_STORAGE` = `ephemeral`/`hybrid`), keys sorted. Fails +/// when it would exceed 65,536 bytes. +pub fn project_secret_payload( + env_pairs: &[(String, String)], + agent_token: &str, + files: &[(String, Vec)], + repos: &[RepoSource], + wip: bool, + idle: Option, + storage: StorageMode, +) -> Result { + serialize_payload(&payload_map( + env_pairs, + agent_token, + files, + repos, + wip, + idle, + storage, + )?) +} + +// ---------------------------------------------------------- task definition + +/// Everything a project's task definition is built from. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct WorkspacePlan { + pub family: String, + pub cpu: u32, + pub memory: u32, + pub arch: Arch, + pub storage: StorageMode, + pub ephemeral_gib: u32, + pub image: String, + pub exec_role_arn: String, + pub task_role_arn: String, + pub log_group: String, + pub log_stream_prefix: String, + pub region: String, + pub secret_arn: String, + /// Sorted (the payload's key order). + pub secret_keys: Vec, + /// Both set for Hybrid, both `None` for Ephemeral. + pub file_system_id: Option, + pub access_point_id: Option, + pub tags: Vec<(String, String)>, +} + +/// sha256 (hex) over the plan and [`TASK_DEFINITION_TEMPLATE`]: the request +/// is a pure function of both, so this covers everything in it but the label +/// that carries it. Not project data: the plan holds names and ARNs only. +pub fn workspace_fingerprint(plan: &WorkspacePlan) -> String { + let bytes = + serde_json::to_vec(&(TASK_DEFINITION_TEMPLATE, plan)).expect("plain data serialises"); + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +fn health_check_command() -> String { + format!( + "curl -sf --max-time 3 -H \"Authorization: Bearer $(cat /dev/shm/triple-c/agent-token)\" \ + http://127.0.0.1:{AGENT_PORT}/v1/health" + ) +} + +/// The `RegisterTaskDefinition` request for `plan`, fingerprint label included. +pub fn task_definition_request(plan: &WorkspacePlan) -> RegisterTaskDefinitionInput { + const BUILT: &str = "every required field is set"; + let hybrid = match (&plan.storage, &plan.file_system_id, &plan.access_point_id) { + (StorageMode::Hybrid, Some(fs), Some(ap)) => Some((fs, ap)), + _ => None, + }; + + let mut container = ContainerDefinition::builder() + .name(CONTAINER_NAME) + .image(&plan.image) + .essential(true) + .set_environment(Some( + FIXED_ENVIRONMENT + .iter() + .map(|(k, v)| KeyValuePair::builder().name(*k).value(*v).build()) + .collect(), + )) + .set_secrets(Some( + plan.secret_keys + .iter() + .map(|k| { + Secret::builder() + .name(k) + .value_from(format!("{}:{k}::", plan.secret_arn)) + .build() + .expect(BUILT) + }) + .collect(), + )) + .health_check( + HealthCheck::builder() + .command("CMD-SHELL") + .command(health_check_command()) + .interval(30) + .retries(3) + .start_period(120) + .build() + .expect(BUILT), + ) + .linux_parameters( + LinuxParameters::builder() + .init_process_enabled(true) + .capabilities(KernelCapabilities::builder().add("SYS_PTRACE").build()) + .build(), + ) + .log_configuration( + LogConfiguration::builder() + .log_driver(LogDriver::Awslogs) + .options("awslogs-group", &plan.log_group) + .options("awslogs-region", &plan.region) + .options("awslogs-stream-prefix", &plan.log_stream_prefix) + .build() + .expect(BUILT), + ) + .docker_labels(FINGERPRINT_LABEL, workspace_fingerprint(plan)); + if hybrid.is_some() { + container = container.mount_points( + MountPoint::builder() + .source_volume(HOME_VOLUME) + .container_path(HOME_MOUNT_PATH) + .read_only(false) + .build(), + ); + } + + let mut req = RegisterTaskDefinitionInput::builder() + .family(&plan.family) + .task_role_arn(&plan.task_role_arn) + .execution_role_arn(&plan.exec_role_arn) + .network_mode(NetworkMode::Awsvpc) + .requires_compatibilities(Compatibility::Fargate) + .cpu(plan.cpu.to_string()) + .memory(plan.memory.to_string()) + .runtime_platform( + RuntimePlatform::builder() + .cpu_architecture(match plan.arch { + Arch::X86_64 => CpuArchitecture::X8664, + Arch::Arm64 => CpuArchitecture::Arm64, + }) + .operating_system_family(OsFamily::Linux) + .build(), + ) + .ephemeral_storage( + EphemeralStorage::builder() + .size_in_gib(plan.ephemeral_gib as i32) + .build(), + ) + .container_definitions(container.build()) + .set_tags(Some( + plan.tags + .iter() + .map(|(k, v)| Tag::builder().key(k).value(v).build()) + .collect(), + )); + if let Some((fs, ap)) = hybrid { + req = req.volumes( + Volume::builder() + .name(HOME_VOLUME) + .efs_volume_configuration( + EfsVolumeConfiguration::builder() + .file_system_id(fs) + .transit_encryption(EfsTransitEncryption::Enabled) + .authorization_config( + EfsAuthorizationConfig::builder() + .access_point_id(ap) + .iam(EfsAuthorizationConfigIam::Enabled) + .build(), + ) + .build() + .expect(BUILT), + ) + .build(), + ); + } + req.build().expect(BUILT) +} + +// ---------------------------------------------------------------------- API + +#[derive(Debug, Clone, PartialEq)] +pub struct FileSystemRef { + pub id: String, + pub arn: String, + /// Its `triple-c:owner` tag. + pub owner: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AccessPointRef { + pub id: String, + pub file_system_id: String, + pub root_path: Option, + pub owner: Option, + pub project: Option, + /// EFS `LifeCycleState` (`creating`, `available`, `updating`, ...). + pub state: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TaskDefinitionRef { + /// `...:task-definition/:`. + pub arn: String, + pub family: String, + pub owner: Option, + pub project: Option, + /// The `workspace` container's [`FINGERPRINT_LABEL`]. + pub fingerprint: Option, +} + +/// What preparing a workspace asks AWS. Like `DetectApi`, each method +/// returns a `Send` future; an impl may write them as `async fn`. The +/// decisions (which access point or revision may be reused) are made by the +/// callers here, so a fake exercises them. +pub trait WorkspaceApi: Send + Sync { + /// The file system whose creation token is `token`. + fn find_file_system( + &self, + token: &str, + ) -> impl Future, AwsError>> + Send; + /// The access points on `fs_id` (bounded). + fn access_points( + &self, + fs_id: &str, + ) -> impl Future, AwsError>> + Send; + /// A new access point rooted at `root` (uid/gid 1000, created 0700). + fn create_access_point( + &self, + fs_id: &str, + root: &str, + tags: &[(String, String)], + ) -> impl Future> + Send; + /// Create the secret (encrypted with `kms_key_arn`, tagged), or put a new + /// value into it when it exists. Returns its ARN. + fn put_project_secret( + &self, + name: &str, + kms_key_arn: &str, + json: &str, + tags: &[(String, String)], + ) -> impl Future> + Send; + /// The ACTIVE revisions of exactly `family`, newest first (bounded). + fn task_definition_revisions( + &self, + family: &str, + ) -> impl Future, AwsError>> + Send; + /// One revision with its tags; `None` when it is gone. + fn describe_task_definition( + &self, + arn: &str, + ) -> impl Future, AwsError>> + Send; + /// Register and return the new revision's ARN. + fn register_task_definition( + &self, + req: RegisterTaskDefinitionInput, + ) -> impl Future> + Send; +} + +/// `...:task-definition/:`. +fn is_revision_arn_of(arn: &str, family: &str) -> bool { + arn.starts_with("arn:") + && arn + .rsplit_once(":task-definition/") + .and_then(|(_, rest)| rest.rsplit_once(':')) + .is_some_and(|(fam, rev)| { + fam == family && !rev.is_empty() && rev.bytes().all(|c| c.is_ascii_digit()) + }) +} + +/// The newest of `family`'s revisions (among the first +/// [`MAX_REVISIONS_CHECKED`]) that the caller registered for this project +/// with this fingerprint, by its exact ARN. A newer revision someone else +/// registered into the family is skipped, never used. +pub async fn latest_task_definition( + api: &W, + family: &str, + owner_user_id: &str, + project_id: &str, + fingerprint: &str, +) -> Result, AwsError> { + let revisions = api.task_definition_revisions(family).await?; + for arn in revisions.iter().take(MAX_REVISIONS_CHECKED) { + if !is_revision_arn_of(arn, family) { + continue; + } + let Some(d) = api.describe_task_definition(arn).await? else { + continue; + }; + if d.arn == *arn + && d.family == family + && d.owner.as_deref() == Some(owner_user_id) + && d.project.as_deref() == Some(project_id) + && d.fingerprint.as_deref() == Some(fingerprint) + { + return Ok(Some(d.arn)); + } + } + Ok(None) +} + +/// The caller's access point for this project: owned by them, tagged with +/// the project, rooted where it should be, and not on its way out. +fn pick_access_point<'a>( + points: &'a [AccessPointRef], + fs_id: &str, + root: &str, + owner_user_id: &str, + project_id: &str, +) -> Option<&'a AccessPointRef> { + points.iter().find(|a| { + a.file_system_id == fs_id + && a.root_path.as_deref() == Some(root) + && a.owner.as_deref() == Some(owner_user_id) + && a.project.as_deref() == Some(project_id) + && matches!( + a.state.as_deref(), + Some("available" | "creating" | "updating") + ) + }) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PreparedWorkspace { + pub family: String, + /// Exact `family:revision`. + pub task_definition_arn: String, + pub access_point_id: Option, + /// This start's token, already in the secret. + pub agent_token: String, + pub secret_arn: String, +} + +/// Make the project's secret, access point (Hybrid) and task definition +/// ready. Everything that can be checked locally — placement, sizes, the +/// payload's size and names — is checked before the first write. +pub async fn prepare_workspace( + api: &W, + who: &CallerIdentity, + env: &EnvironmentInfo, + project: &Project, + env_pairs: &[(String, String)], + files: &[(String, Vec)], + image: &str, +) -> Result { + let (Some(placement), Some(cfg)) = (project.aws_placement.as_ref(), project.aws.as_ref()) + else { + return Err(other("The project is not set up to run on AWS")); + }; + if !project.is_aws() { + return Err(other("The project is not set up to run on AWS")); + } + if placement.environment != env.name { + return Err(other(format!( + "The project belongs to environment \"{}\", not \"{}\"", + placement.environment, env.name + ))); + } + let region = placement.region.as_str(); + let mut arn_parts = env.cluster_arn.split(':'); + let partition = arn_parts.nth(1).unwrap_or("aws"); + if arn_parts.nth(1) != Some(region) { + return Err(other(format!( + "The environment \"{}\" is not in the project's region {region}", + env.name + ))); + } + validate_aws_workspace(cfg).map_err(other)?; + if image.trim().is_empty() { + return Err(other("No container image is set for AWS")); + } + + let ok = owner_key(region, &who.user_id); + let tags = workspace_tags(&who.user_id, &project.id, &env.name); + let agent_token = new_agent_token(); + let payload = payload_map( + env_pairs, + &agent_token, + files, + &cfg.repos, + cfg.wip_push_on_stop, + cfg.idle_stop_minutes, + cfg.storage, + ) + .map_err(other)?; + let json = serialize_payload(&payload).map_err(other)?; + + // Hybrid: the user's own file system, from their onboarding. + let (file_system_id, access_point_id) = match cfg.storage { + StorageMode::Ephemeral => (None, None), + StorageMode::Hybrid => { + let token = user_file_system_token(&env.name, &ok); + let fs = api + .find_file_system(&token) + .await? + .filter(|fs| fs.owner.as_deref() == Some(who.user_id.as_str())) + .ok_or_else(|| { + other(format!( + "Your file system in environment \"{}\" was not found. Hybrid storage \ + needs it: ask an administrator to onboard you there, or use \ + Ephemeral storage.", + env.name + )) + })?; + let root = access_point_root(&ok, &project.id); + let points = api.access_points(&fs.id).await?; + let ap = match pick_access_point(&points, &fs.id, &root, &who.user_id, &project.id) { + Some(a) => a.id.clone(), + None => api.create_access_point(&fs.id, &root, &tags).await?, + }; + (Some(fs.id), Some(ap)) + } + }; + + let secret_name = project_secret_name(&env.name, &ok, &project.id); + let secret_arn = api + .put_project_secret(&secret_name, &env.kms_key_arn, &json, &tags) + .await?; + + let (task_role, exec_role) = user_role_names(&env.name, &ok); + let role_arn = |name: &str| format!("arn:{partition}:iam::{}:role/{name}", who.account); + let plan = WorkspacePlan { + family: task_family(&env.name, &ok, &project.id), + cpu: cfg.cpu, + memory: cfg.memory_mib, + arch: cfg.arch, + storage: cfg.storage, + ephemeral_gib: cfg.ephemeral_gib, + image: image.to_string(), + exec_role_arn: role_arn(&exec_role), + task_role_arn: role_arn(&task_role), + log_group: env.log_group.clone(), + log_stream_prefix: log_stream_prefix(&ok, &project.id), + region: region.to_string(), + secret_arn: secret_arn.clone(), + secret_keys: payload.keys().cloned().collect(), + file_system_id, + access_point_id: access_point_id.clone(), + tags, + }; + let fingerprint = workspace_fingerprint(&plan); + let task_definition_arn = + match latest_task_definition(api, &plan.family, &who.user_id, &project.id, &fingerprint) + .await? + { + Some(arn) => arn, + None => { + let arn = api + .register_task_definition(task_definition_request(&plan)) + .await?; + if !is_revision_arn_of(&arn, &plan.family) { + return Err(other( + "ecs:RegisterTaskDefinition returned an ARN that is not a revision of the \ + project's family", + )); + } + arn + } + }; + + Ok(PreparedWorkspace { + family: plan.family, + task_definition_arn, + access_point_id, + agent_token, + secret_arn, + }) +} + +// ---------------------------------------------------------------- real API + +/// Bounds on what one lookup lists. +const MAX_ACCESS_POINTS: usize = 1000; +const MAX_LISTED_REVISIONS: i32 = 100; + +/// The real thing, from a connection's `SdkConfig`. +pub struct SdkWorkspaceApi { + ecs: aws_sdk_ecs::Client, + efs: aws_sdk_efs::Client, + secrets: aws_sdk_secretsmanager::Client, +} + +impl SdkWorkspaceApi { + pub fn new(cfg: &SdkConfig) -> Self { + SdkWorkspaceApi { + ecs: aws_sdk_ecs::Client::new(cfg), + efs: aws_sdk_efs::Client::new(cfg), + secrets: aws_sdk_secretsmanager::Client::new(cfg), + } + } +} + +fn efs_tag(tags: &[aws_sdk_efs::types::Tag], key: &str) -> Option { + tags.iter() + .find(|t| t.key() == key) + .map(|t| t.value().to_string()) +} + +fn ecs_tag(tags: &[Tag], key: &str) -> Option { + tags.iter() + .find(|t| t.key() == Some(key)) + .and_then(|t| t.value()) + .map(str::to_string) +} + +impl WorkspaceApi for SdkWorkspaceApi { + async fn find_file_system(&self, token: &str) -> Result, AwsError> { + const ACTION: &str = "elasticfilesystem:DescribeFileSystems"; + let out = self + .efs + .describe_file_systems() + .creation_token(token) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + Ok(out.file_systems().first().map(|fs| FileSystemRef { + id: fs.file_system_id().to_string(), + arn: fs.file_system_arn().unwrap_or_default().to_string(), + owner: efs_tag(fs.tags(), TAG_OWNER), + })) + } + + async fn access_points(&self, fs_id: &str) -> Result, AwsError> { + const ACTION: &str = "elasticfilesystem:DescribeAccessPoints"; + let mut out = Vec::new(); + let mut token: Option = None; + loop { + let page = self + .efs + .describe_access_points() + .file_system_id(fs_id) + .set_next_token(token.take()) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + for a in page.access_points() { + out.push(AccessPointRef { + id: a.access_point_id().unwrap_or_default().to_string(), + file_system_id: a.file_system_id().unwrap_or_default().to_string(), + root_path: a + .root_directory() + .and_then(|r| r.path()) + .map(str::to_string), + owner: efs_tag(a.tags(), TAG_OWNER), + project: efs_tag(a.tags(), TAG_PROJECT), + state: a.life_cycle_state().map(|s| s.as_str().to_string()), + }); + } + match page.next_token() { + Some(t) if out.len() < MAX_ACCESS_POINTS => token = Some(t.to_string()), + _ => break, + } + } + Ok(out) + } + + async fn create_access_point( + &self, + fs_id: &str, + root: &str, + tags: &[(String, String)], + ) -> Result { + use aws_sdk_efs::types::{CreationInfo, PosixUser, RootDirectory, Tag as EfsTag}; + const ACTION: &str = "elasticfilesystem:CreateAccessPoint"; + const BUILT: &str = "every required field is set"; + let out = self + .efs + .create_access_point() + .file_system_id(fs_id) + .posix_user( + PosixUser::builder() + .uid(WORKSPACE_UID) + .gid(WORKSPACE_UID) + .build() + .expect(BUILT), + ) + .root_directory( + RootDirectory::builder() + .path(root) + .creation_info( + CreationInfo::builder() + .owner_uid(WORKSPACE_UID) + .owner_gid(WORKSPACE_UID) + .permissions("0700") + .build() + .expect(BUILT), + ) + .build(), + ) + .set_tags(Some( + tags.iter() + .map(|(k, v)| EfsTag::builder().key(k).value(v).build().expect(BUILT)) + .collect(), + )) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + out.access_point_id() + .map(str::to_string) + .ok_or_else(|| other(format!("{ACTION} returned no AccessPointId"))) + } + + async fn put_project_secret( + &self, + name: &str, + kms_key_arn: &str, + json: &str, + tags: &[(String, String)], + ) -> Result { + use aws_sdk_secretsmanager::types::Tag as SmTag; + const CREATE: &str = "secretsmanager:CreateSecret"; + const PUT: &str = "secretsmanager:PutSecretValue"; + let created = self + .secrets + .create_secret() + .name(name) + .kms_key_id(kms_key_arn) + .secret_string(json) + .set_tags(Some( + tags.iter() + .map(|(k, v)| SmTag::builder().key(k).value(v).build()) + .collect(), + )) + .send() + .await; + let arn = match created { + Ok(out) => out.arn().map(str::to_string), + Err(e) + if e.as_service_error() + .is_some_and(|s| s.is_resource_exists_exception()) => + { + // Only a secret tagged with the caller as owner accepts this + // (the policy), so another user's same-named secret fails + // here rather than receiving the payload. + self.secrets + .put_secret_value() + .secret_id(name) + .secret_string(json) + .send() + .await + .map_err(|e| classify_sdk_error(PUT, &e))? + .arn() + .map(str::to_string) + } + Err(e) => return Err(classify_sdk_error(CREATE, &e)), + }; + arn.ok_or_else(|| other(format!("{CREATE} returned no ARN"))) + } + + async fn task_definition_revisions(&self, family: &str) -> Result, AwsError> { + const ACTION: &str = "ecs:ListTaskDefinitions"; + let out = self + .ecs + .list_task_definitions() + .family_prefix(family) + .status(TaskDefinitionStatus::Active) + .sort(SortOrder::Desc) + .max_results(MAX_LISTED_REVISIONS) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + // `familyPrefix` is a prefix: keep exactly this family. + Ok(out + .task_definition_arns() + .iter() + .filter(|a| is_revision_arn_of(a, family)) + .cloned() + .collect()) + } + + async fn describe_task_definition( + &self, + arn: &str, + ) -> Result, AwsError> { + const ACTION: &str = "ecs:DescribeTaskDefinition"; + let out = self + .ecs + .describe_task_definition() + .task_definition(arn) + .include(TaskDefinitionField::Tags) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + let Some(td) = out.task_definition() else { + return Ok(None); + }; + let fingerprint = td + .container_definitions() + .iter() + .find(|c| c.name() == Some(CONTAINER_NAME)) + .and_then(|c| c.docker_labels()) + .and_then(|l| l.get(FINGERPRINT_LABEL)) + .cloned(); + Ok(Some(TaskDefinitionRef { + arn: td.task_definition_arn().unwrap_or_default().to_string(), + family: td.family().unwrap_or_default().to_string(), + owner: ecs_tag(out.tags(), TAG_OWNER), + project: ecs_tag(out.tags(), TAG_PROJECT), + fingerprint, + })) + } + + async fn register_task_definition( + &self, + req: RegisterTaskDefinitionInput, + ) -> Result { + const ACTION: &str = "ecs:RegisterTaskDefinition"; + // The fluent builder takes no whole input; copy every field the + // input has, so nothing `task_definition_request` sets is lost. + let out = self + .ecs + .register_task_definition() + .set_family(req.family) + .set_task_role_arn(req.task_role_arn) + .set_execution_role_arn(req.execution_role_arn) + .set_network_mode(req.network_mode) + .set_container_definitions(req.container_definitions) + .set_volumes(req.volumes) + .set_placement_constraints(req.placement_constraints) + .set_requires_compatibilities(req.requires_compatibilities) + .set_cpu(req.cpu) + .set_memory(req.memory) + .set_tags(req.tags) + .set_pid_mode(req.pid_mode) + .set_ipc_mode(req.ipc_mode) + .set_proxy_configuration(req.proxy_configuration) + .set_ephemeral_storage(req.ephemeral_storage) + .set_runtime_platform(req.runtime_platform) + .set_enable_fault_injection(req.enable_fault_injection) + .set_trusted_execution_configuration(req.trusted_execution_configuration) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + out.task_definition() + .and_then(|t| t.task_definition_arn()) + .map(str::to_string) + .ok_or_else(|| other(format!("{ACTION} returned no task definition ARN"))) + } +} + +#[cfg(test)] +mod tests; diff --git a/app/src-tauri/src/aws/workspace/tests.rs b/app/src-tauri/src/aws/workspace/tests.rs new file mode 100644 index 0000000..bc0e9a0 --- /dev/null +++ b/app/src-tauri/src/aws/workspace/tests.rs @@ -0,0 +1,1002 @@ +use std::collections::BTreeMap; +use std::sync::Mutex; + +use aws_sdk_ecs::operation::register_task_definition::RegisterTaskDefinitionInput; +use aws_sdk_ecs::types::{CpuArchitecture, EfsAuthorizationConfigIam, EfsTransitEncryption}; + +use super::*; +use crate::aws::environments::EnvironmentInfo; +use crate::aws::identity::CallerIdentity; +use crate::aws::tofu_modules::{ + access_point_root, log_stream_prefix, owner_key, project_secret_name, task_family, + user_file_system_token, +}; +use crate::models::project::{Arch, AwsPlacement, AwsWorkspaceConfig, RepoSource, StorageMode}; +use crate::models::Project; + +const ACCOUNT: &str = "111122223333"; +const REGION: &str = "eu-west-1"; +const USER: &str = "AROAEXAMPLEID:alice@example.com"; +const OTHER: &str = "AROAOTHERID:mallory"; +const PROJECT_ID: &str = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"; +const SECRET_ARN: &str = + "arn:aws:secretsmanager:eu-west-1:111122223333:secret:triple-c/dev/abc/p-AbCdEf"; + +fn who() -> CallerIdentity { + CallerIdentity { + account: ACCOUNT.into(), + arn: format!("arn:aws:sts::{ACCOUNT}:assumed-role/Dev/alice"), + user_id: USER.into(), + } +} + +fn env_info() -> EnvironmentInfo { + EnvironmentInfo { + schema: 1, + name: "dev".into(), + template_version: 1, + cluster_arn: format!("arn:aws:ecs:{REGION}:{ACCOUNT}:cluster/triple-c-dev"), + security_group_id: "sg-0123456789abcdef0".into(), + subnet_ids: vec!["subnet-0a".into(), "subnet-0b".into()], + assign_public_ip: true, + kms_key_arn: format!( + "arn:aws:kms:{REGION}:{ACCOUNT}:key/1234abcd-12ab-34cd-56ef-1234567890ab" + ), + log_group: "/triple-c/dev".into(), + network_mode: "public".into(), + tenancy: "strict".into(), + } +} + +fn project(storage: StorageMode) -> Project { + let mut p = Project::new("p".into(), vec![]); + p.id = PROJECT_ID.into(); + p.host_id = Some("conn-1".into()); + p.aws_placement = Some(AwsPlacement { + region: REGION.into(), + environment: "dev".into(), + }); + p.aws = Some(AwsWorkspaceConfig { + storage, + repos: vec![RepoSource { + url: "https://github.com/example/repo.git".into(), + branch: Some("main".into()), + folder: "repo".into(), + }], + ..Default::default() + }); + p +} + +fn pairs(kv: &[(&str, &str)]) -> Vec<(String, String)> { + kv.iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +fn sample_plan(storage: StorageMode) -> WorkspacePlan { + let ok = owner_key(REGION, USER); + WorkspacePlan { + family: task_family("dev", &ok, PROJECT_ID), + cpu: 1024, + memory: 4096, + arch: Arch::X86_64, + storage, + ephemeral_gib: 21, + image: "ghcr.io/example/triple-c:latest".into(), + exec_role_arn: format!("arn:aws:iam::{ACCOUNT}:role/triple-c-dev-{ok}-exec"), + task_role_arn: format!("arn:aws:iam::{ACCOUNT}:role/triple-c-dev-{ok}-task"), + log_group: "/triple-c/dev".into(), + log_stream_prefix: log_stream_prefix(&ok, PROJECT_ID), + region: REGION.into(), + secret_arn: SECRET_ARN.into(), + secret_keys: vec![ + "A_KEY".into(), + "GIT_TOKEN".into(), + "TRIPLEC_AGENT_TOKEN".into(), + ], + file_system_id: (storage == StorageMode::Hybrid).then(|| "fs-0123abcd".to_string()), + access_point_id: (storage == StorageMode::Hybrid).then(|| "fsap-0123abcd".to_string()), + tags: workspace_tags(USER, PROJECT_ID, "dev"), + } +} + +// ---------------------------------------------------------------- names + +#[test] +fn names_use_owner_key_and_never_contain_a_colon() { + let ok = owner_key(REGION, USER); + let names = [ + task_family("dev", &ok, PROJECT_ID), + project_secret_name("dev", &ok, PROJECT_ID), + access_point_root(&ok, PROJECT_ID), + log_stream_prefix(&ok, PROJECT_ID), + user_file_system_token("dev", &ok), + ]; + for n in &names { + assert!(n.contains(&ok), "{n} lacks the owner key"); + assert!(!n.contains(':'), "{n} contains a colon"); + assert!(!n.contains("AROA"), "{n} contains the raw user id"); + } + assert_eq!(names[0], format!("triple-c-dev-{ok}-0f1e2d3c-4b5")); + assert_eq!(names[1], format!("triple-c/dev/{ok}/{PROJECT_ID}")); + assert_eq!(names[2], format!("/triple-c/{ok}/{PROJECT_ID}")); + assert_eq!(names[3], format!("{ok}/{PROJECT_ID}")); + // The onboarding module's `creation_token` (local.name). + assert_eq!(names[4], format!("triple-c-dev-{ok}")); +} + +#[test] +fn names_sanitise_a_hostile_project_id() { + let ok = owner_key(REGION, USER); + for id in ["../../etc:x", "a/b", "..", ".", "", "x y*z"] { + let fam = task_family("dev", &ok, id); + assert!(fam.len() <= 255); + assert!( + fam.bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_'), + "{fam}" + ); + let root = access_point_root(&ok, id); + let tail = root.strip_prefix(&format!("/triple-c/{ok}/")).unwrap(); + assert!( + !tail.is_empty() && !tail.contains('/') && tail != "." && tail != "..", + "{root}" + ); + let secret = project_secret_name("dev", &ok, id); + assert!(!secret.contains(':') && !secret.contains(".."), "{secret}"); + assert_eq!(secret.matches('/').count(), 3, "{secret}"); + } + let long = "x".repeat(500); + assert!(task_family("abcdefghijklmnopqrst", &ok, &long).len() <= 255); + assert!(access_point_root(&ok, &long).len() <= 100); +} + +// ------------------------------------------------------- task definition + +fn env_names(req: &RegisterTaskDefinitionInput) -> BTreeMap { + req.container_definitions()[0] + .environment() + .iter() + .map(|kv| { + ( + kv.name().unwrap().to_string(), + kv.value().unwrap().to_string(), + ) + }) + .collect() +} + +#[test] +fn request_environment_is_only_the_constants_and_every_key_is_a_secret() { + let plan = sample_plan(StorageMode::Ephemeral); + let req = task_definition_request(&plan); + assert_eq!( + env_names(&req), + BTreeMap::from([ + ("COLORTERM".to_string(), "truecolor".to_string()), + ("TRIPLEC_HOST".to_string(), "aws".to_string()), + ]) + ); + let c = &req.container_definitions()[0]; + let secrets: Vec<(String, String)> = c + .secrets() + .iter() + .map(|s| (s.name().to_string(), s.value_from().to_string())) + .collect(); + let expected: Vec<(String, String)> = plan + .secret_keys + .iter() + .map(|k| (k.clone(), format!("{SECRET_ARN}:{k}::"))) + .collect(); + assert_eq!(secrets, expected); +} + +#[test] +fn request_shape_matches_the_global_constraints() { + let plan = sample_plan(StorageMode::Ephemeral); + let req = task_definition_request(&plan); + assert_eq!(req.family(), Some(plan.family.as_str())); + assert_eq!(req.cpu(), Some("1024")); + assert_eq!(req.memory(), Some("4096")); + assert_eq!( + req.network_mode(), + Some(&aws_sdk_ecs::types::NetworkMode::Awsvpc) + ); + assert_eq!( + req.requires_compatibilities(), + &[aws_sdk_ecs::types::Compatibility::Fargate] + ); + assert_eq!(req.task_role_arn(), Some(plan.task_role_arn.as_str())); + assert_eq!(req.execution_role_arn(), Some(plan.exec_role_arn.as_str())); + assert_eq!(req.ephemeral_storage().unwrap().size_in_gib(), 21); + let rp = req.runtime_platform().unwrap(); + assert_eq!(rp.cpu_architecture(), Some(&CpuArchitecture::X8664)); + assert_eq!( + rp.operating_system_family(), + Some(&aws_sdk_ecs::types::OsFamily::Linux) + ); + + let tags: BTreeMap<_, _> = req + .tags() + .iter() + .map(|t| (t.key().unwrap(), t.value().unwrap())) + .collect(); + assert_eq!( + tags, + BTreeMap::from([ + ("triple-c:environment", "dev"), + ("triple-c:owner", USER), + ("triple-c:project", PROJECT_ID), + ]) + ); + + assert_eq!(req.container_definitions().len(), 1); + let c = &req.container_definitions()[0]; + assert_eq!(c.name(), Some("workspace")); + assert_eq!(c.essential(), Some(true)); + assert_eq!(c.image(), Some(plan.image.as_str())); + let hc = c.health_check().unwrap(); + assert_eq!( + hc.command(), + &[ + "CMD-SHELL".to_string(), + "curl -sf --max-time 3 -H \"Authorization: Bearer $(cat /dev/shm/triple-c/agent-token)\" http://127.0.0.1:47900/v1/health".to_string(), + ] + ); + assert_eq!( + (hc.interval(), hc.retries(), hc.start_period()), + (Some(30), Some(3), Some(120)) + ); + let lp = c.linux_parameters().unwrap(); + assert_eq!(lp.init_process_enabled(), Some(true)); + assert_eq!( + lp.capabilities().unwrap().add(), + &["SYS_PTRACE".to_string()] + ); + assert!(lp.capabilities().unwrap().drop().is_empty()); + let log = c.log_configuration().unwrap(); + assert_eq!(log.log_driver(), &aws_sdk_ecs::types::LogDriver::Awslogs); + let opts = log.options().unwrap(); + assert_eq!( + opts.get("awslogs-group").map(String::as_str), + Some("/triple-c/dev") + ); + assert_eq!(opts.get("awslogs-region").map(String::as_str), Some(REGION)); + assert_eq!( + opts.get("awslogs-stream-prefix"), + Some(&plan.log_stream_prefix) + ); + assert_eq!(opts.len(), 3); + let labels = c.docker_labels().unwrap(); + assert_eq!(labels.len(), 1); + assert_eq!( + labels.get(FINGERPRINT_LABEL), + Some(&workspace_fingerprint(&plan)) + ); + assert!(c.port_mappings().is_empty()); +} + +#[test] +fn ephemeral_has_no_volume_or_mount() { + let req = task_definition_request(&sample_plan(StorageMode::Ephemeral)); + assert!(req.volumes().is_empty()); + assert!(req.container_definitions()[0].mount_points().is_empty()); +} + +#[test] +fn hybrid_mounts_the_access_point_with_iam_and_tls() { + let req = task_definition_request(&sample_plan(StorageMode::Hybrid)); + assert_eq!(req.volumes().len(), 1); + let v = &req.volumes()[0]; + assert_eq!(v.name(), Some("home")); + let efs = v.efs_volume_configuration().unwrap(); + assert_eq!(efs.file_system_id(), "fs-0123abcd"); + assert_eq!( + efs.transit_encryption(), + Some(&EfsTransitEncryption::Enabled) + ); + let auth = efs.authorization_config().unwrap(); + assert_eq!(auth.access_point_id(), Some("fsap-0123abcd")); + assert_eq!(auth.iam(), Some(&EfsAuthorizationConfigIam::Enabled)); + // An access point sets the root; the volume must not set its own. + assert!(efs.root_directory().is_none() || efs.root_directory() == Some("/")); + let mounts = req.container_definitions()[0].mount_points(); + assert_eq!(mounts.len(), 1); + assert_eq!(mounts[0].source_volume(), Some("home")); + assert_eq!(mounts[0].container_path(), Some("/mnt/triple-c-home")); + assert_eq!(mounts[0].read_only(), Some(false)); +} + +#[test] +fn arm64_sets_the_runtime_platform() { + let mut plan = sample_plan(StorageMode::Ephemeral); + plan.arch = Arch::Arm64; + let req = task_definition_request(&plan); + assert_eq!( + req.runtime_platform().unwrap().cpu_architecture(), + Some(&CpuArchitecture::Arm64) + ); +} + +#[test] +fn fingerprint_is_stable_and_covers_every_field() { + let base = sample_plan(StorageMode::Hybrid); + assert_eq!( + workspace_fingerprint(&base), + workspace_fingerprint(&base.clone()) + ); + let fp = workspace_fingerprint(&base); + assert_eq!(fp.len(), 64); + assert!(fp.bytes().all(|c| c.is_ascii_hexdigit())); + + type Change = Box; + let changes: Vec = vec![ + Box::new(|p| p.family.push('x')), + Box::new(|p| p.cpu = 2048), + Box::new(|p| p.memory = 8192), + Box::new(|p| p.arch = Arch::Arm64), + Box::new(|p| p.storage = StorageMode::Ephemeral), + Box::new(|p| p.ephemeral_gib = 30), + Box::new(|p| p.image.push('2')), + Box::new(|p| p.exec_role_arn.push('x')), + Box::new(|p| p.task_role_arn.push('x')), + Box::new(|p| p.log_group.push('x')), + Box::new(|p| p.log_stream_prefix.push('x')), + Box::new(|p| p.region = "us-east-1".into()), + Box::new(|p| p.secret_arn.push('x')), + Box::new(|p| p.secret_keys.push("NEW".into())), + Box::new(|p| p.file_system_id = Some("fs-other".into())), + Box::new(|p| p.access_point_id = Some("fsap-other".into())), + Box::new(|p| p.tags[0].1.push('x')), + ]; + for (i, change) in changes.iter().enumerate() { + let mut p = base.clone(); + change(&mut p); + assert_ne!( + workspace_fingerprint(&p), + fp, + "change {i} kept the fingerprint" + ); + } +} + +// --------------------------------------------------------- secret payload + +fn payload_map(json: &str) -> BTreeMap { + serde_json::from_str(json).unwrap() +} + +#[test] +fn payload_holds_all_project_data_with_sorted_keys() { + let env = pairs(&[("ZED", "1"), ("GIT_TOKEN", "ghp_secret"), ("ALPHA", "a")]); + let files = vec![ + ("ssh/id_ed25519".to_string(), b"KEY".to_vec()), + ("ca/corp.pem".to_string(), b"PEM".to_vec()), + ]; + let repos = project(StorageMode::Hybrid).aws.unwrap().repos; + let json = project_secret_payload( + &env, + "tok", + &files, + &repos, + true, + Some(30), + StorageMode::Hybrid, + ) + .unwrap(); + let m = payload_map(&json); + let keys: Vec<&str> = m.keys().map(String::as_str).collect(); + assert_eq!( + keys, + [ + "ALPHA", + "GIT_TOKEN", + "TRIPLEC_AGENT_TOKEN", + "TRIPLEC_FILES", + "TRIPLEC_IDLE_STOP_MINUTES", + "TRIPLEC_REPOS", + "TRIPLEC_STORAGE", + "TRIPLEC_WIP_PUSH", + "ZED", + ] + ); + // Serialised in key order, so the secret value does not churn. + let positions: Vec = m + .keys() + .map(|k| json.find(&format!("\"{k}\"")).unwrap()) + .collect(); + assert!(positions.windows(2).all(|w| w[0] < w[1])); + assert_eq!(m["TRIPLEC_AGENT_TOKEN"], "tok"); + assert_eq!(m["TRIPLEC_WIP_PUSH"], "1"); + assert_eq!(m["TRIPLEC_IDLE_STOP_MINUTES"], "30"); + assert_eq!(m["TRIPLEC_STORAGE"], "hybrid"); + let files: BTreeMap = serde_json::from_str(&m["TRIPLEC_FILES"]).unwrap(); + assert_eq!( + files, + BTreeMap::from([ + ("ca/corp.pem".to_string(), "UEVN".to_string()), + ("ssh/id_ed25519".to_string(), "S0VZ".to_string()), + ]) + ); + let repos: serde_json::Value = serde_json::from_str(&m["TRIPLEC_REPOS"]).unwrap(); + assert_eq!( + repos, + serde_json::json!([{"url": "https://github.com/example/repo.git", "branch": "main", "folder": "repo"}]) + ); + assert!(!m.keys().any(|k| k.starts_with("TRIPLEC_FILE_"))); + assert!(!m.contains_key("TRIPLEC_SECRET_KEYS")); +} + +#[test] +fn payload_off_values() { + let json = + project_secret_payload(&[], "t", &[], &[], false, None, StorageMode::Ephemeral).unwrap(); + let m = payload_map(&json); + assert_eq!(m["TRIPLEC_WIP_PUSH"], "0"); + assert_eq!(m["TRIPLEC_STORAGE"], "ephemeral"); + assert!(!m.contains_key("TRIPLEC_IDLE_STOP_MINUTES")); + assert_eq!(m["TRIPLEC_FILES"], "{}"); + assert_eq!(m["TRIPLEC_REPOS"], "[]"); +} + +#[test] +fn payload_is_independent_of_env_pair_order() { + let a = pairs(&[("B", "2"), ("A", "1"), ("C", "3")]); + let b = pairs(&[("C", "3"), ("A", "1"), ("B", "2")]); + let pa = project_secret_payload(&a, "t", &[], &[], true, None, StorageMode::Hybrid).unwrap(); + let pb = project_secret_payload(&b, "t", &[], &[], true, None, StorageMode::Hybrid).unwrap(); + assert_eq!(pa, pb); +} + +#[test] +fn payload_rejects_bad_file_names() { + for name in [ + "ssh/", "ssh/.", "ssh/..", "ssh/a/b", "other/x", "x", "ca/../k", "ssh/a:b", + ] { + let files = vec![(name.to_string(), b"x".to_vec())]; + let r = project_secret_payload(&[], "t", &files, &[], true, None, StorageMode::Hybrid); + if name == "ssh/a:b" { + // A colon is a fine file name; only the secret's *keys* are restricted. + assert!(r.is_ok()); + } else { + assert!(r.is_err(), "{name} accepted"); + } + } +} + +#[test] +fn payload_rejects_env_names_a_secret_reference_cannot_carry() { + for bad in ["A:B", "", "1ABC", "A-B", "A B"] { + let env = pairs(&[(bad, "v")]); + let r = project_secret_payload(&env, "t", &[], &[], true, None, StorageMode::Hybrid); + assert!(r.is_err(), "{bad:?} accepted"); + // The error never echoes a value. + assert!(!r.unwrap_err().contains("\"v\"")); + } +} + +#[test] +fn payload_keeps_its_own_keys_over_custom_env() { + let env = pairs(&[ + ("TRIPLEC_AGENT_TOKEN", "forged"), + ("TRIPLEC_HOST", "local"), + ("COLORTERM", "16"), + ("OK", "1"), + ]); + let m = payload_map( + &project_secret_payload(&env, "real", &[], &[], true, None, StorageMode::Hybrid).unwrap(), + ); + assert_eq!(m["TRIPLEC_AGENT_TOKEN"], "real"); + // The task definition's fixed environment owns these two names. + assert!(!m.contains_key("TRIPLEC_HOST")); + assert!(!m.contains_key("COLORTERM")); + assert_eq!(m["OK"], "1"); +} + +#[test] +fn payload_over_64_kib_fails() { + let env = pairs(&[("BIG", &"x".repeat(65_536))]); + let err = + project_secret_payload(&env, "t", &[], &[], true, None, StorageMode::Hybrid).unwrap_err(); + assert!(err.contains("65,536"), "{err}"); +} + +#[test] +fn agent_tokens_are_64_url_safe_characters_and_fresh() { + let a = new_agent_token(); + let b = new_agent_token(); + assert_ne!(a, b); + assert_eq!(a.len(), 64); + assert!(a + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')); +} + +// ------------------------------------------------------- prepare (fake API) + +type Tags = Vec<(String, String)>; + +#[derive(Default)] +struct Fake { + file_system: Option, + access_points: Mutex>, + created_access_points: Mutex>, + secrets: Mutex>, + /// Newest first, like `ListTaskDefinitions` sorted DESC. + revisions: Mutex>, + registered: Mutex>, + describes: Mutex, +} + +impl Fake { + fn onboarded() -> Self { + Fake { + file_system: Some(FileSystemRef { + id: "fs-0123abcd".into(), + arn: format!( + "arn:aws:elasticfilesystem:{REGION}:{ACCOUNT}:file-system/fs-0123abcd" + ), + owner: Some(USER.into()), + }), + ..Default::default() + } + } + fn writes(&self) -> usize { + self.created_access_points.lock().unwrap().len() + + self.secrets.lock().unwrap().len() + + self.registered.lock().unwrap().len() + } +} + +impl WorkspaceApi for Fake { + async fn find_file_system(&self, token: &str) -> Result, AwsError> { + let ok = owner_key(REGION, USER); + assert_eq!(token, user_file_system_token("dev", &ok)); + Ok(self.file_system.clone()) + } + async fn access_points(&self, fs_id: &str) -> Result, AwsError> { + Ok(self + .access_points + .lock() + .unwrap() + .iter() + .filter(|a| a.file_system_id == fs_id) + .cloned() + .collect()) + } + async fn create_access_point( + &self, + fs_id: &str, + root: &str, + tags: &[(String, String)], + ) -> Result { + let mut c = self.created_access_points.lock().unwrap(); + c.push((fs_id.into(), root.into(), tags.to_vec())); + Ok(format!("fsap-new{}", c.len())) + } + async fn put_project_secret( + &self, + name: &str, + kms_key_arn: &str, + json: &str, + tags: &[(String, String)], + ) -> Result { + self.secrets.lock().unwrap().push(( + name.into(), + kms_key_arn.into(), + json.into(), + tags.to_vec(), + )); + Ok(SECRET_ARN.into()) + } + async fn task_definition_revisions(&self, family: &str) -> Result, AwsError> { + Ok(self + .revisions + .lock() + .unwrap() + .iter() + .filter(|r| r.family == family) + .map(|r| r.arn.clone()) + .collect()) + } + async fn describe_task_definition( + &self, + arn: &str, + ) -> Result, AwsError> { + *self.describes.lock().unwrap() += 1; + Ok(self + .revisions + .lock() + .unwrap() + .iter() + .find(|r| r.arn == arn) + .cloned()) + } + async fn register_task_definition( + &self, + req: RegisterTaskDefinitionInput, + ) -> Result { + let family = req.family().unwrap().to_string(); + let mut revs = self.revisions.lock().unwrap(); + let n = revs.iter().filter(|r| r.family == family).count() + 1; + let arn = format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{family}:{n}"); + let tag = |k: &str| { + req.tags() + .iter() + .find(|t| t.key() == Some(k)) + .and_then(|t| t.value()) + .map(String::from) + }; + revs.insert( + 0, + TaskDefinitionRef { + arn: arn.clone(), + family: family.clone(), + owner: tag(TAG_OWNER), + project: tag(TAG_PROJECT), + fingerprint: req.container_definitions()[0] + .docker_labels() + .and_then(|l| l.get(FINGERPRINT_LABEL).cloned()), + }, + ); + self.registered.lock().unwrap().push(req); + Ok(arn) + } +} + +const IMAGE: &str = "ghcr.io/example/triple-c:latest"; + +async fn prepare( + fake: &Fake, + p: &Project, + env: &[(String, String)], +) -> Result { + prepare_workspace(fake, &who(), &env_info(), p, env, &[], IMAGE).await +} + +#[tokio::test] +async fn first_start_creates_everything_with_the_three_tags() { + let fake = Fake::onboarded(); + let p = project(StorageMode::Hybrid); + let env = pairs(&[ + ("GIT_TOKEN", "ghp_secret"), + ("CLAUDE_INSTRUCTIONS", "be nice"), + ]); + let out = prepare(&fake, &p, &env).await.unwrap(); + let ok = owner_key(REGION, USER); + let tags = workspace_tags(USER, PROJECT_ID, "dev"); + + let aps = fake.created_access_points.lock().unwrap().clone(); + assert_eq!( + aps, + vec![( + "fs-0123abcd".to_string(), + access_point_root(&ok, PROJECT_ID), + tags.clone() + )] + ); + assert_eq!(out.access_point_id.as_deref(), Some("fsap-new1")); + + let secrets = fake.secrets.lock().unwrap().clone(); + assert_eq!(secrets.len(), 1); + assert_eq!(secrets[0].0, project_secret_name("dev", &ok, PROJECT_ID)); + assert_eq!(secrets[0].1, env_info().kms_key_arn); + assert_eq!(secrets[0].3, tags); + let m = payload_map(&secrets[0].2); + assert_eq!(m["TRIPLEC_AGENT_TOKEN"], out.agent_token); + assert_eq!(m["GIT_TOKEN"], "ghp_secret"); + assert_eq!(out.secret_arn, SECRET_ARN); + + let reg = fake.registered.lock().unwrap(); + assert_eq!(reg.len(), 1); + let req = ®[0]; + assert_eq!( + req.family(), + Some(task_family("dev", &ok, PROJECT_ID).as_str()) + ); + assert_eq!( + req.execution_role_arn(), + Some(format!("arn:aws:iam::{ACCOUNT}:role/triple-c-dev-{ok}-exec").as_str()) + ); + assert_eq!( + req.task_role_arn(), + Some(format!("arn:aws:iam::{ACCOUNT}:role/triple-c-dev-{ok}-task").as_str()) + ); + // No project data outside `secrets`: every payload value is absent from + // the environment, labels and tags. + let rendered = format!( + "{:?}{:?}{:?}", + req.container_definitions()[0].environment(), + req.container_definitions()[0].docker_labels(), + req.tags() + ); + for v in [ + "ghp_secret", + "be nice", + out.agent_token.as_str(), + "github.com/example", + ] { + assert!(!rendered.contains(v), "{v} leaked outside secrets"); + } + // Every payload key, and only those, is a secret reference. + let secret_names: Vec<&str> = req.container_definitions()[0] + .secrets() + .iter() + .map(|s| s.name()) + .collect(); + assert_eq!( + secret_names, + m.keys().map(String::as_str).collect::>() + ); + assert!(out.task_definition_arn.ends_with(":1")); + assert_eq!(out.family, task_family("dev", &ok, PROJECT_ID)); +} + +#[tokio::test] +async fn unchanged_inputs_reuse_the_revision_and_a_changed_cpu_registers_one() { + let fake = Fake::onboarded(); + let mut p = project(StorageMode::Hybrid); + let env = pairs(&[("A", "1")]); + let first = prepare(&fake, &p, &env).await.unwrap(); + // The created access point now exists for the second start. + fake.access_points.lock().unwrap().push(AccessPointRef { + id: first.access_point_id.clone().unwrap(), + file_system_id: "fs-0123abcd".into(), + root_path: Some(access_point_root(&owner_key(REGION, USER), PROJECT_ID)), + owner: Some(USER.into()), + project: Some(PROJECT_ID.into()), + state: Some("available".into()), + }); + let second = prepare(&fake, &p, &env).await.unwrap(); + assert_eq!( + fake.registered.lock().unwrap().len(), + 1, + "unchanged inputs re-registered" + ); + assert_eq!(second.task_definition_arn, first.task_definition_arn); + assert_ne!( + second.agent_token, first.agent_token, + "the agent token rotates every start" + ); + assert_eq!( + fake.secrets.lock().unwrap().len(), + 2, + "the secret is rewritten every start" + ); + + p.aws.as_mut().unwrap().cpu = 2048; + p.aws.as_mut().unwrap().memory_mib = 8192; + let third = prepare(&fake, &p, &env).await.unwrap(); + assert_eq!(fake.registered.lock().unwrap().len(), 2); + assert!(third.task_definition_arn.ends_with(":2")); +} + +#[tokio::test] +async fn env_order_does_not_register_a_new_revision() { + let fake = Fake::onboarded(); + let p = project(StorageMode::Ephemeral); + prepare(&fake, &p, &pairs(&[("B", "2"), ("A", "1")])) + .await + .unwrap(); + prepare(&fake, &p, &pairs(&[("A", "1"), ("B", "2")])) + .await + .unwrap(); + assert_eq!(fake.registered.lock().unwrap().len(), 1); + let reg = fake.registered.lock().unwrap(); + let names: Vec<&str> = reg[0].container_definitions()[0] + .secrets() + .iter() + .map(|s| s.name()) + .collect(); + let mut sorted = names.clone(); + sorted.sort(); + assert_eq!(names, sorted); +} + +#[tokio::test] +async fn a_foreign_newest_revision_is_never_used() { + let fake = Fake::onboarded(); + let p = project(StorageMode::Ephemeral); + let first = prepare(&fake, &p, &[]).await.unwrap(); + let own = fake.revisions.lock().unwrap()[0].clone(); + let ok = owner_key(REGION, USER); + let family = task_family("dev", &ok, PROJECT_ID); + // Another user registers a newer revision into this family, copying the + // fingerprint label and claiming the project. + fake.revisions.lock().unwrap().insert( + 0, + TaskDefinitionRef { + arn: format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{family}:2"), + family: family.clone(), + owner: Some(OTHER.into()), + project: Some(PROJECT_ID.into()), + fingerprint: own.fingerprint.clone(), + }, + ); + let second = prepare(&fake, &p, &[]).await.unwrap(); + // The caller's own older revision, by its exact family:revision ARN. + assert_eq!(second.task_definition_arn, first.task_definition_arn); + assert!(second.task_definition_arn.ends_with(&format!("{family}:1"))); + assert_eq!(fake.registered.lock().unwrap().len(), 1); + + // With only foreign revisions, the caller registers its own. + let fake = Fake::onboarded(); + fake.revisions.lock().unwrap().push(TaskDefinitionRef { + owner: Some(OTHER.into()), + arn: format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{family}:1"), + ..own.clone() + }); + let out = prepare(&fake, &p, &[]).await.unwrap(); + assert_eq!(fake.registered.lock().unwrap().len(), 1); + assert!(out.task_definition_arn.ends_with(&format!("{family}:2"))); +} + +#[tokio::test] +async fn a_revision_for_another_project_or_family_is_not_reused() { + let fake = Fake::onboarded(); + let p = project(StorageMode::Ephemeral); + prepare(&fake, &p, &[]).await.unwrap(); + // Same owner and fingerprint label, but another project's tag. + fake.revisions.lock().unwrap()[0].project = Some("someone-else".into()); + prepare(&fake, &p, &[]).await.unwrap(); + assert_eq!(fake.registered.lock().unwrap().len(), 2); +} + +#[tokio::test] +async fn an_existing_access_point_is_reused() { + let fake = Fake::onboarded(); + let ok = owner_key(REGION, USER); + let root = access_point_root(&ok, PROJECT_ID); + let good = AccessPointRef { + id: "fsap-existing".into(), + file_system_id: "fs-0123abcd".into(), + root_path: Some(root.clone()), + owner: Some(USER.into()), + project: Some(PROJECT_ID.into()), + state: Some("available".into()), + }; + fake.access_points.lock().unwrap().extend([ + // Decoys: wrong owner, wrong root, being deleted. + AccessPointRef { + id: "fsap-foreign".into(), + owner: Some(OTHER.into()), + ..good.clone() + }, + AccessPointRef { + id: "fsap-elsewhere".into(), + root_path: Some("/".into()), + ..good.clone() + }, + AccessPointRef { + id: "fsap-deleting".into(), + state: Some("deleting".into()), + ..good.clone() + }, + good.clone(), + ]); + let out = prepare(&fake, &project(StorageMode::Hybrid), &[]) + .await + .unwrap(); + assert_eq!(out.access_point_id.as_deref(), Some("fsap-existing")); + assert!(fake.created_access_points.lock().unwrap().is_empty()); + { + let reg = fake.registered.lock().unwrap(); + let efs = reg[0].volumes()[0].efs_volume_configuration().unwrap(); + assert_eq!( + efs.authorization_config().unwrap().access_point_id(), + Some("fsap-existing") + ); + } + + // Only decoys: a new one is created. + let fake2 = Fake::onboarded(); + fake2.access_points.lock().unwrap().push(AccessPointRef { + id: "fsap-foreign".into(), + owner: Some(OTHER.into()), + ..good + }); + prepare(&fake2, &project(StorageMode::Hybrid), &[]) + .await + .unwrap(); + assert_eq!(fake2.created_access_points.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn ephemeral_touches_no_file_system() { + let fake = Fake::default(); // no file system at all + let out = prepare(&fake, &project(StorageMode::Ephemeral), &[]) + .await + .unwrap(); + assert!(out.access_point_id.is_none()); + assert!(fake.created_access_points.lock().unwrap().is_empty()); + assert!(fake.registered.lock().unwrap()[0].volumes().is_empty()); +} + +#[tokio::test] +async fn an_oversized_payload_fails_before_any_write() { + let fake = Fake::onboarded(); + let env = pairs(&[("BIG", &"x".repeat(70_000))]); + let err = prepare(&fake, &project(StorageMode::Hybrid), &env) + .await + .unwrap_err(); + assert!(err.to_string().contains("65,536"), "{err}"); + assert_eq!(fake.writes(), 0); +} + +#[tokio::test] +async fn hybrid_without_the_users_file_system_fails_before_any_write() { + let fake = Fake::default(); + let err = prepare(&fake, &project(StorageMode::Hybrid), &[]) + .await + .unwrap_err(); + assert!(err.to_string().contains("file system"), "{err}"); + assert_eq!(fake.writes(), 0); + + let mut foreign = Fake::onboarded(); + foreign.file_system.as_mut().unwrap().owner = Some(OTHER.into()); + let err = prepare(&foreign, &project(StorageMode::Hybrid), &[]) + .await + .unwrap_err(); + assert!(err.to_string().contains("file system"), "{err}"); + assert_eq!(foreign.writes(), 0); +} + +#[tokio::test] +async fn refuses_a_project_that_is_not_placed_in_this_environment() { + let fake = Fake::onboarded(); + let mut local = project(StorageMode::Hybrid); + local.host_id = None; + local.aws = None; + local.aws_placement = None; + assert!(prepare(&fake, &local, &[]).await.is_err()); + + let mut elsewhere = project(StorageMode::Hybrid); + elsewhere.aws_placement.as_mut().unwrap().environment = "prod".into(); + assert!(prepare(&fake, &elsewhere, &[]).await.is_err()); + + let mut region = project(StorageMode::Hybrid); + region.aws_placement.as_mut().unwrap().region = "us-east-1".into(); + assert!(prepare(&fake, ®ion, &[]).await.is_err()); + assert_eq!(fake.writes(), 0); +} + +#[tokio::test] +async fn files_travel_in_the_secret_only() { + let fake = Fake::onboarded(); + let files = vec![("ssh/id_ed25519".to_string(), b"PRIVATE KEY".to_vec())]; + prepare_workspace( + &fake, + &who(), + &env_info(), + &project(StorageMode::Hybrid), + &[], + &files, + IMAGE, + ) + .await + .unwrap(); + let m = payload_map(&fake.secrets.lock().unwrap()[0].2); + assert!(m["TRIPLEC_FILES"].contains("ssh/id_ed25519")); +} + +#[tokio::test] +async fn revision_lookup_is_bounded() { + let fake = Fake::onboarded(); + let ok = owner_key(REGION, USER); + let family = task_family("dev", &ok, PROJECT_ID); + for n in (1..=50).rev() { + fake.revisions.lock().unwrap().push(TaskDefinitionRef { + arn: format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{family}:{n}"), + family: family.clone(), + owner: Some(OTHER.into()), + project: Some(PROJECT_ID.into()), + fingerprint: None, + }); + } + prepare(&fake, &project(StorageMode::Ephemeral), &[]) + .await + .unwrap(); + assert!(*fake.describes.lock().unwrap() <= MAX_REVISIONS_CHECKED); + assert_eq!(fake.registered.lock().unwrap().len(), 1); +} -- 2.52.0 From 30d6b26a4246a8e1467d865fa9a9f3758cc8ce01 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 08:46:32 -0800 Subject: [PATCH 07/43] fix(aws): fingerprint the built task definition; harden workspace lookups - The fingerprint hashes a canonical rendering of the built request (Debug-redacted environment and HashMap log options rendered separately, labels excluded) instead of the plan plus a template constant, so a builder change can't silently keep old revisions. - Page ListTaskDefinitions so a longer family can't crowd out the caller's revisions. - Project ids that aren't [A-Za-z0-9-] get a hash suffix in names, so ids that sanitise alike can't share a secret. - Name a secret scheduled for deletion; only mount an available file system; drop the unused FileSystemRef.arn. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/tofu_modules.rs | 54 +++++--- app/src-tauri/src/aws/workspace.rs | 160 ++++++++++++++++++----- app/src-tauri/src/aws/workspace/tests.rs | 130 +++++++++++++++++- 3 files changed, 286 insertions(+), 58 deletions(-) diff --git a/app/src-tauri/src/aws/tofu_modules.rs b/app/src-tauri/src/aws/tofu_modules.rs index 38ad549..dfb08fd 100644 --- a/app/src-tauri/src/aws/tofu_modules.rs +++ b/app/src-tauri/src/aws/tofu_modules.rs @@ -122,36 +122,60 @@ pub fn user_role_names(env: &str, owner_key: &str) -> (String, String) { ) } -/// A project id as a name component: `[A-Za-z0-9_-]` only (anything else -/// becomes `-`), at most 64 characters, never empty. Project ids are UUIDs, -/// which pass unchanged; this only keeps a hand-edited `projects.json` from -/// putting `/`, `..` or `:` into a secret name or an EFS path. +/// Whether a project id is used in names as it is: 1 to 64 characters of +/// `[A-Za-z0-9-]`. Every UUID (how the app makes ids) is. +fn is_clean_project_id(id: &str) -> bool { + (1..=64).contains(&id.len()) && id.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-') +} + +/// 16 hex characters of `sha256(id)`. +fn project_id_hash(id: &str) -> String { + Sha256::digest(id.as_bytes()) + .iter() + .take(8) + .map(|b| format!("{b:02x}")) + .collect() +} + +/// A project id as a name component, safe in a secret name, an EFS path and +/// a log stream. A clean id (see [`is_clean_project_id`]) is used as is. +/// Anything else — only a hand-edited `projects.json` has one — becomes its +/// first 40 characters with everything outside `[A-Za-z0-9-]` replaced by +/// `-`, then `_` and a hash of the whole id. Clean ids never contain `_`, so +/// the two forms can't collide, and ids that sanitise alike (`a/b`, `a:b`) +/// still differ by the hash. fn project_component(project_id: &str) -> String { - let s: String = project_id + if is_clean_project_id(project_id) { + return project_id.to_string(); + } + let prefix: String = project_id .chars() - .take(64) + .take(40) .map(|c| { - if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' } }) .collect(); - if s.is_empty() { - "_".to_string() - } else { - s - } + format!("{prefix}_{}", project_id_hash(project_id)) } /// A project's ECS task definition family: -/// `triple-c---`. At most 55 +/// `triple-c---` (an id that +/// isn't clean contributes `_` and 11 hex of its hash instead). At most 55 /// characters of `[A-Za-z0-9_-]`. Other users can register revisions into /// any `triple-c-*` family, so a family is never trusted by name alone (see -/// `aws::workspace`). +/// `aws::workspace`), and two of a user's projects could share 12 leading +/// characters, so a family's tasks and revisions are told apart by their +/// `triple-c:project` tag. pub fn task_family(env: &str, owner_key: &str, project_id: &str) -> String { - let id: String = project_component(project_id).chars().take(12).collect(); + let id: String = if is_clean_project_id(project_id) { + project_id.chars().take(12).collect() + } else { + format!("_{}", &project_id_hash(project_id)[..11]) + }; format!("triple-c-{env}-{owner_key}-{id}") } diff --git a/app/src-tauri/src/aws/workspace.rs b/app/src-tauri/src/aws/workspace.rs index ca51ac4..e45a995 100644 --- a/app/src-tauri/src/aws/workspace.rs +++ b/app/src-tauri/src/aws/workspace.rs @@ -32,6 +32,7 @@ use aws_sdk_ecs::types::{ MountPoint, NetworkMode, OsFamily, RuntimePlatform, Secret, SortOrder, Tag, TaskDefinitionField, TaskDefinitionStatus, Volume, }; +use aws_sdk_secretsmanager::error::ProvideErrorMetadata; use base64::Engine; use rand::RngCore; use serde::Serialize; @@ -63,10 +64,6 @@ pub const MAX_SECRET_BYTES: usize = 65_536; /// At most this many of a family's newest revisions are described when /// looking for a reusable one; past that a new one is registered. pub const MAX_REVISIONS_CHECKED: usize = 10; -/// Part of the fingerprint. Bump it whenever [`task_definition_request`] -/// changes what it builds from the same plan, so existing projects pick up -/// the new shape. -const TASK_DEFINITION_TEMPLATE: u32 = 1; /// The container user (`claude`), owner of the access point's root. const WORKSPACE_UID: i64 = 1000; @@ -253,13 +250,47 @@ pub struct WorkspacePlan { pub tags: Vec<(String, String)>, } -/// sha256 (hex) over the plan and [`TASK_DEFINITION_TEMPLATE`]: the request -/// is a pure function of both, so this covers everything in it but the label -/// that carries it. Not project data: the plan holds names and ARNs only. +/// sha256 (hex) over the task definition `plan` produces, minus the label +/// that carries the fingerprint. Not project data: the request holds names, +/// ARNs and constants only. pub fn workspace_fingerprint(plan: &WorkspacePlan) -> String { - let bytes = - serde_json::to_vec(&(TASK_DEFINITION_TEMPLATE, plan)).expect("plain data serialises"); - Sha256::digest(bytes) + request_fingerprint(&build_request(plan)) +} + +/// sha256 (hex) over a canonical rendering of `req`: its `Debug` text, with +/// the parts `Debug` can't render faithfully taken out first and rendered +/// in order — `environment` and `repository_credentials` (which `Debug` +/// redacts, so a change to them would otherwise go unseen) and the log +/// options (a `HashMap`, whose `Debug` order is random). Docker labels are +/// dropped: the fingerprint label is the only one, and it can't cover +/// itself. Hashing the built request rather than its inputs means any +/// change to [`task_definition_request`] registers a new revision on the +/// next start, with nothing to remember to bump. (An SDK upgrade that +/// changes `Debug` output does the same, once.) +fn request_fingerprint(req: &RegisterTaskDefinitionInput) -> String { + let mut req = req.clone(); + let mut taken = Vec::new(); + for c in req.container_definitions.iter_mut().flatten() { + c.docker_labels = None; + let env: Vec<(Option, Option)> = c + .environment + .take() + .unwrap_or_default() + .iter() + .map(|kv| (kv.name().map(String::from), kv.value().map(String::from))) + .collect(); + let credentials = c + .repository_credentials + .take() + .map(|r| r.credentials_parameter); + let log_options: Option> = c + .log_configuration + .as_mut() + .and_then(|l| l.options.take()) + .map(|m| m.into_iter().collect()); + taken.push(format!("{env:?}|{credentials:?}|{log_options:?}")); + } + Sha256::digest(format!("{req:?}\n{taken:?}").as_bytes()) .iter() .map(|b| format!("{b:02x}")) .collect() @@ -274,6 +305,16 @@ fn health_check_command() -> String { /// The `RegisterTaskDefinition` request for `plan`, fingerprint label included. pub fn task_definition_request(plan: &WorkspacePlan) -> RegisterTaskDefinitionInput { + let mut req = build_request(plan); + let fingerprint = request_fingerprint(&req); + for c in req.container_definitions.iter_mut().flatten() { + c.docker_labels = Some([(FINGERPRINT_LABEL.to_string(), fingerprint.clone())].into()); + } + req +} + +/// The request without its fingerprint label. +fn build_request(plan: &WorkspacePlan) -> RegisterTaskDefinitionInput { const BUILT: &str = "every required field is set"; let hybrid = match (&plan.storage, &plan.file_system_id, &plan.access_point_id) { (StorageMode::Hybrid, Some(fs), Some(ap)) => Some((fs, ap)), @@ -326,8 +367,7 @@ pub fn task_definition_request(plan: &WorkspacePlan) -> RegisterTaskDefinitionIn .options("awslogs-stream-prefix", &plan.log_stream_prefix) .build() .expect(BUILT), - ) - .docker_labels(FINGERPRINT_LABEL, workspace_fingerprint(plan)); + ); if hybrid.is_some() { container = container.mount_points( MountPoint::builder() @@ -395,7 +435,6 @@ pub fn task_definition_request(plan: &WorkspacePlan) -> RegisterTaskDefinitionIn #[derive(Debug, Clone, PartialEq)] pub struct FileSystemRef { pub id: String, - pub arn: String, /// Its `triple-c:owner` tag. pub owner: Option, } @@ -685,6 +724,23 @@ pub async fn prepare_workspace( /// Bounds on what one lookup lists. const MAX_ACCESS_POINTS: usize = 1000; const MAX_LISTED_REVISIONS: i32 = 100; +/// `ListTaskDefinitions` pages read per lookup; past that, a new revision is +/// registered (what crowding a family out can cost the caller). +const MAX_REVISION_PAGES: usize = 20; + +/// Secrets Manager's `InvalidRequestException` for a name whose secret is +/// scheduled for deletion ("... already scheduled for deletion"). +fn is_pending_deletion(message: Option<&str>) -> bool { + message.is_some_and(|m| m.contains("scheduled for deletion")) +} + +fn pending_deletion_error(name: &str) -> AwsError { + other(format!( + "This project's secret ({name}) is scheduled for deletion, so it can't be written. \ + Restore it in the Secrets Manager console (or wait until it is deleted) and start \ + the project again." + )) +} /// The real thing, from a connection's `SdkConfig`. pub struct SdkWorkspaceApi { @@ -726,11 +782,16 @@ impl WorkspaceApi for SdkWorkspaceApi { .send() .await .map_err(|e| classify_sdk_error(ACTION, &e))?; - Ok(out.file_systems().first().map(|fs| FileSystemRef { - id: fs.file_system_id().to_string(), - arn: fs.file_system_arn().unwrap_or_default().to_string(), - owner: efs_tag(fs.tags(), TAG_OWNER), - })) + // One at most (a creation token is unique per account and region); + // one still being created or deleted can't be mounted. + Ok(out + .file_systems() + .iter() + .find(|fs| *fs.life_cycle_state() == aws_sdk_efs::types::LifeCycleState::Available) + .map(|fs| FileSystemRef { + id: fs.file_system_id().to_string(), + owner: efs_tag(fs.tags(), TAG_OWNER), + })) } async fn access_points(&self, fs_id: &str) -> Result, AwsError> { @@ -851,10 +912,25 @@ impl WorkspaceApi for SdkWorkspaceApi { .secret_string(json) .send() .await - .map_err(|e| classify_sdk_error(PUT, &e))? + .map_err(|e| match e.as_service_error() { + Some(s) + if s.is_invalid_request_exception() + && is_pending_deletion(s.message()) => + { + pending_deletion_error(name) + } + _ => classify_sdk_error(PUT, &e), + })? .arn() .map(str::to_string) } + Err(e) + if e.as_service_error().is_some_and(|s| { + s.is_invalid_request_exception() && is_pending_deletion(s.message()) + }) => + { + return Err(pending_deletion_error(name)); + } Err(e) => return Err(classify_sdk_error(CREATE, &e)), }; arn.ok_or_else(|| other(format!("{CREATE} returned no ARN"))) @@ -862,23 +938,35 @@ impl WorkspaceApi for SdkWorkspaceApi { async fn task_definition_revisions(&self, family: &str) -> Result, AwsError> { const ACTION: &str = "ecs:ListTaskDefinitions"; - let out = self - .ecs - .list_task_definitions() - .family_prefix(family) - .status(TaskDefinitionStatus::Active) - .sort(SortOrder::Desc) - .max_results(MAX_LISTED_REVISIONS) - .send() - .await - .map_err(|e| classify_sdk_error(ACTION, &e))?; - // `familyPrefix` is a prefix: keep exactly this family. - Ok(out - .task_definition_arns() - .iter() - .filter(|a| is_revision_arn_of(a, family)) - .cloned() - .collect()) + // `familyPrefix` is a prefix, and sorted descending a longer family + // (`z`, anyone can register one) comes first: page on until + // enough of exactly this family is found, or the pages run out. + let mut found = Vec::new(); + let mut token: Option = None; + for _ in 0..MAX_REVISION_PAGES { + let page = self + .ecs + .list_task_definitions() + .family_prefix(family) + .status(TaskDefinitionStatus::Active) + .sort(SortOrder::Desc) + .max_results(MAX_LISTED_REVISIONS) + .set_next_token(token.take()) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + found.extend( + page.task_definition_arns() + .iter() + .filter(|a| is_revision_arn_of(a, family)) + .cloned(), + ); + match page.next_token() { + Some(t) if found.len() < MAX_REVISIONS_CHECKED => token = Some(t.to_string()), + _ => break, + } + } + Ok(found) } async fn describe_task_definition( diff --git a/app/src-tauri/src/aws/workspace/tests.rs b/app/src-tauri/src/aws/workspace/tests.rs index bc0e9a0..94b9cc0 100644 --- a/app/src-tauri/src/aws/workspace/tests.rs +++ b/app/src-tauri/src/aws/workspace/tests.rs @@ -468,11 +468,11 @@ fn payload_rejects_bad_file_names() { #[test] fn payload_rejects_env_names_a_secret_reference_cannot_carry() { for bad in ["A:B", "", "1ABC", "A-B", "A B"] { - let env = pairs(&[(bad, "v")]); + let env = pairs(&[(bad, "SENTINEL-VALUE-7f3a")]); let r = project_secret_payload(&env, "t", &[], &[], true, None, StorageMode::Hybrid); assert!(r.is_err(), "{bad:?} accepted"); // The error never echoes a value. - assert!(!r.unwrap_err().contains("\"v\"")); + assert!(!r.unwrap_err().contains("SENTINEL-VALUE-7f3a")); } } @@ -534,9 +534,6 @@ impl Fake { Fake { file_system: Some(FileSystemRef { id: "fs-0123abcd".into(), - arn: format!( - "arn:aws:elasticfilesystem:{REGION}:{ACCOUNT}:file-system/fs-0123abcd" - ), owner: Some(USER.into()), }), ..Default::default() @@ -705,9 +702,14 @@ async fn first_start_creates_everything_with_the_three_tags() { ); // No project data outside `secrets`: every payload value is absent from // the environment, labels and tags. + // (`Debug` redacts `environment`, so its pairs are rendered by hand.) + let env_text: Vec = req.container_definitions()[0] + .environment() + .iter() + .map(|kv| format!("{}={}", kv.name().unwrap(), kv.value().unwrap())) + .collect(); let rendered = format!( - "{:?}{:?}{:?}", - req.container_definitions()[0].environment(), + "{env_text:?}{:?}{:?}", req.container_definitions()[0].docker_labels(), req.tags() ); @@ -1000,3 +1002,117 @@ async fn revision_lookup_is_bounded() { assert!(*fake.describes.lock().unwrap() <= MAX_REVISIONS_CHECKED); assert_eq!(fake.registered.lock().unwrap().len(), 1); } + +// ------------------------------------------------------- fix round 1 + +#[test] +fn ids_that_sanitise_alike_get_different_names() { + let ok = owner_key(REGION, USER); + for (a, b) in [("a/b", "a:b"), ("", "_"), ("x y", "x*y")] { + assert_ne!( + project_secret_name("dev", &ok, a), + project_secret_name("dev", &ok, b) + ); + assert_ne!(access_point_root(&ok, a), access_point_root(&ok, b)); + assert_ne!(log_stream_prefix(&ok, a), log_stream_prefix(&ok, b)); + assert_ne!(task_family("dev", &ok, a), task_family("dev", &ok, b)); + } + // A sanitised id can't take a clean id's name: clean ids have no `_`. + let hashed = project_secret_name("dev", &ok, "a/b"); + assert!(hashed.rsplit('/').next().unwrap().contains('_'), "{hashed}"); + // Ids that differ only past 64 characters still differ. + let long_a = format!("{}a", "x".repeat(70)); + let long_b = format!("{}b", "x".repeat(70)); + assert_ne!( + project_secret_name("dev", &ok, &long_a), + project_secret_name("dev", &ok, &long_b) + ); + assert!(task_family("dev", &ok, "a/b").len() <= 255); + // A UUID passes unchanged. + assert!(project_secret_name("dev", &ok, PROJECT_ID).ends_with(PROJECT_ID)); +} + +#[test] +fn the_fingerprint_follows_the_built_request_and_not_its_label() { + let plan = sample_plan(StorageMode::Hybrid); + let req = task_definition_request(&plan); + let label = req.container_definitions()[0] + .docker_labels() + .unwrap() + .get(FINGERPRINT_LABEL) + .unwrap() + .clone(); + // The label is the fingerprint of the request itself, label excluded. + assert_eq!(request_fingerprint(&req), label); + assert_eq!(workspace_fingerprint(&plan), label); + + // Any change the builder could make shows: the health check, a log + // option, the (Debug-redacted) environment, a mount. + type Edit = Box; + let edits: Vec = vec![ + Box::new(|r| { + let c = &mut r.container_definitions.as_mut().unwrap()[0]; + c.health_check.as_mut().unwrap().interval = Some(31); + }), + Box::new(|r| { + let c = &mut r.container_definitions.as_mut().unwrap()[0]; + let opts = c + .log_configuration + .as_mut() + .unwrap() + .options + .as_mut() + .unwrap(); + opts.insert("mode".into(), "non-blocking".into()); + }), + Box::new(|r| { + let c = &mut r.container_definitions.as_mut().unwrap()[0]; + c.environment = Some(vec![aws_sdk_ecs::types::KeyValuePair::builder() + .name("TRIPLEC_HOST") + .value("aws2") + .build()]); + }), + Box::new(|r| { + let c = &mut r.container_definitions.as_mut().unwrap()[0]; + c.mount_points.as_mut().unwrap()[0].read_only = Some(true); + }), + Box::new(|r| r.cpu = Some("2048".into())), + ]; + for (i, edit) in edits.iter().enumerate() { + let mut changed = req.clone(); + edit(&mut changed); + assert_ne!( + request_fingerprint(&changed), + label, + "edit {i} kept the fingerprint" + ); + } + // Another label value does not change it. + let mut relabelled = req.clone(); + relabelled.container_definitions.as_mut().unwrap()[0].docker_labels = + Some([(FINGERPRINT_LABEL.to_string(), "other".to_string())].into()); + assert_eq!(request_fingerprint(&relabelled), label); +} + +#[test] +fn the_fingerprint_is_stable_across_builds() { + // Each build makes fresh HashMaps (log options), each with its own + // random iteration order. + let plan = sample_plan(StorageMode::Hybrid); + let first = workspace_fingerprint(&plan); + for _ in 0..50 { + assert_eq!(workspace_fingerprint(&plan), first); + } +} + +#[test] +fn a_secret_pending_deletion_is_recognised() { + assert!(is_pending_deletion(Some( + "You can't create this secret because a secret with this name is already scheduled for deletion." + ))); + assert!(!is_pending_deletion(Some("Some other invalid request"))); + assert!(!is_pending_deletion(None)); + let msg = pending_deletion_error("triple-c/dev/abc/p").to_string(); + assert!(msg.contains("scheduled for deletion"), "{msg}"); + assert!(msg.contains("triple-c/dev/abc/p"), "{msg}"); +} -- 2.52.0 From e43ed26e20a9f1b814c998f43f0b0baf93d23b6a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 08:56:11 -0800 Subject: [PATCH 08/43] feat(aws): run, find, stop and watch workspace tasks aws/tasks.rs: TaskApi (+ SdkTaskApi) with RunTask, DescribeTasks (tags), ListTasks by family, StopTask and a GetLogEvents tail. find_or_run reuses the project's live task only when its tags name the caller and the project, and otherwise runs the exact family:revision with the three ownership tags (FARGATE, or the FARGATE_SPOT capacity provider for Spot), with a client token so SDK retries cannot start a second task. wait_ready polls with injectable timing and fails with the stop reason and the last 50 log lines. Quota refusals become AwsError::QuotaExceeded and are never retried. classify_stop maps stop codes per the spec. host/watch.rs: ExitSource::Aws polls an injected project-to-task lookup every 15 s, per connection+region+cluster, 100 tasks per call; a group that can't be asked is skipped. has_exited's Agent arm is unchanged (R3). container_watch::run/spawn take their ExitSource. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/error.rs | 9 +- app/src-tauri/src/aws/mod.rs | 3 + app/src-tauri/src/aws/tasks.rs | 686 ++++++++++++++++++++++++ app/src-tauri/src/aws/tasks/tests.rs | 721 ++++++++++++++++++++++++++ app/src-tauri/src/aws/tofu_modules.rs | 8 + app/src-tauri/src/container_watch.rs | 55 +- app/src-tauri/src/host/watch.rs | 288 +++++++++- app/src-tauri/src/lib.rs | 1 + app/src/lib/aws-error.test.ts | 7 +- app/src/lib/aws-error.ts | 1 + app/src/lib/types.ts | 1 + 11 files changed, 1767 insertions(+), 13 deletions(-) create mode 100644 app/src-tauri/src/aws/tasks.rs create mode 100644 app/src-tauri/src/aws/tasks/tests.rs diff --git a/app/src-tauri/src/aws/error.rs b/app/src-tauri/src/aws/error.rs index 4a0ce1c..32cb7fe 100644 --- a/app/src-tauri/src/aws/error.rs +++ b/app/src-tauri/src/aws/error.rs @@ -29,6 +29,11 @@ pub enum AwsError { StateBucketTaken { bucket: String, }, + /// An account limit (Fargate vCPUs, tasks, ...) refused the request. + /// Never retried; `message` says what to do. + QuotaExceeded { + message: String, + }, Other { message: String, }, @@ -63,7 +68,9 @@ impl std::fmt::Display for AwsError { "The state bucket name \"{bucket}\" is already taken by another AWS account, so \ Triple-C can't keep OpenTofu state there" ), - AwsError::Other { message } => f.write_str(message), + AwsError::QuotaExceeded { message } | AwsError::Other { message } => { + f.write_str(message) + } } } } diff --git a/app/src-tauri/src/aws/mod.rs b/app/src-tauri/src/aws/mod.rs index d04e826..90bbfe1 100644 --- a/app/src-tauri/src/aws/mod.rs +++ b/app/src-tauri/src/aws/mod.rs @@ -13,6 +13,9 @@ pub mod permissions; pub mod policies; #[cfg(test)] mod policy_eval; +// Used by the AWS host (phase 5, Task 6); until then only by its tests. +#[allow(dead_code)] +pub mod tasks; pub mod tofu; pub mod tofu_modules; // Used by the AWS host (phase 5, Task 6); until then only by its tests. diff --git a/app/src-tauri/src/aws/tasks.rs b/app/src-tauri/src/aws/tasks.rs new file mode 100644 index 0000000..5ac3dc9 --- /dev/null +++ b/app/src-tauri/src/aws/tasks.rs @@ -0,0 +1,686 @@ +//! A project's ECS task: find the one already running, or run one; wait +//! until it is ready; stop it; and read why it stopped. +//! +//! **A task is trusted by its tags, not its family.** Family names are built +//! from a 12-character id prefix and any workspace user may register into any +//! `triple-c-*` family, so a task listed under the project's family is reused +//! only when DescribeTasks shows the caller's own `triple-c:owner` (which only +//! the caller can set, at RunTask) and this project's `triple-c:project`. +//! +//! **RunTask names an exact revision.** Never a bare family: "the latest +//! revision" of a family is whatever anyone registered last. +//! +//! **A quota refusal is final.** Fargate's vCPU limit (and its kin) surface as +//! [`AwsError::QuotaExceeded`] with a message saying what to do; nothing here +//! retries a RunTask, and the SDK's own retries are made idempotent by a +//! client token. + +use std::future::Future; +use std::time::Duration; + +use aws_config::SdkConfig; +use aws_sdk_ecs::operation::run_task::RunTaskInput; +use aws_sdk_ecs::types::{ + AssignPublicIp, AwsVpcConfiguration, CapacityProviderStrategyItem, DesiredStatus, LaunchType, + ManagedAgentName, NetworkConfiguration, Tag, Task, TaskField, +}; + +use super::environments::EnvironmentInfo; +use super::error::{classify_sdk_error, AwsError}; +pub use super::tofu_modules::task_log_stream; +use super::workspace::{workspace_tags, CONTAINER_NAME, TAG_OWNER, TAG_PROJECT}; + +/// The `reason` StopTask records when the app stops a task. +pub const STOP_REASON: &str = "Stopped from Triple-C"; +/// Log lines shown when a task stops before it is ready. +pub const LOG_TAIL_LINES: i32 = 50; +const SPOT_PROVIDER: &str = "FARGATE_SPOT"; +/// DescribeTasks takes at most 100 tasks per call. +const DESCRIBE_BATCH: usize = 100; +/// ListTasks pages read per status; a project has one task, so this is slack. +const MAX_LIST_PAGES: usize = 5; + +fn other(message: impl Into) -> AwsError { + AwsError::Other { + message: message.into(), + } +} + +/// What the app needs to know about a task. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskInfo { + pub arn: String, + /// `PROVISIONING`, `PENDING`, `ACTIVATING`, `RUNNING`, `DEACTIVATING`, + /// `STOPPING`, `DEPROVISIONING`, `STOPPED`. + pub last_status: String, + pub desired_status: String, + /// `HEALTHY`, `UNHEALTHY`, `UNKNOWN`. + pub health: Option, + /// ECS `stopCode` (`EssentialContainerExited`, `SpotInterruption`, ...). + pub stop_code: Option, + pub stopped_reason: Option, + pub task_definition_arn: String, + /// The `workspace` container's runtime id (needed for an SSM target). + pub runtime_id: Option, + /// The `workspace` container's ExecuteCommandAgent is RUNNING. + pub exec_agent_running: bool, + /// Its `triple-c:owner` / `triple-c:project` tags (DescribeTasks with + /// TAGS; RunTask's answer carries the tags it was given). + pub owner: Option, + pub project: Option, +} + +impl TaskInfo { + fn is_stopping(&self) -> bool { + self.last_status == "STOPPED" || self.desired_status == "STOPPED" + } + /// On its way up or up, and meant to stay that way. + fn is_live(&self) -> bool { + self.desired_status == "RUNNING" + && matches!( + self.last_status.as_str(), + "PROVISIONING" | "PENDING" | "ACTIVATING" | "RUNNING" + ) + } + fn is_ready(&self) -> bool { + self.last_status == "RUNNING" && self.exec_agent_running && self.runtime_id.is_some() + } +} + +fn tag_value(tags: &[Tag], key: &str) -> Option { + tags.iter() + .find(|t| t.key() == Some(key)) + .and_then(|t| t.value()) + .map(str::to_string) +} + +/// An SDK task as a [`TaskInfo`]. The container facts come from the +/// `workspace` container only. +pub(crate) fn task_info(t: &Task) -> TaskInfo { + let workspace = t + .containers() + .iter() + .find(|c| c.name() == Some(CONTAINER_NAME)); + TaskInfo { + arn: t.task_arn().unwrap_or_default().to_string(), + last_status: t.last_status().unwrap_or_default().to_string(), + desired_status: t.desired_status().unwrap_or_default().to_string(), + health: t.health_status().map(|h| h.as_str().to_string()), + stop_code: t.stop_code().map(|c| c.as_str().to_string()), + stopped_reason: t.stopped_reason().map(str::to_string), + task_definition_arn: t.task_definition_arn().unwrap_or_default().to_string(), + runtime_id: workspace.and_then(|c| c.runtime_id()).map(str::to_string), + exec_agent_running: workspace.is_some_and(|c| { + c.managed_agents().iter().any(|a| { + a.name() == Some(&ManagedAgentName::ExecuteCommandAgent) + && a.last_status() == Some("RUNNING") + }) + }), + owner: tag_value(t.tags(), TAG_OWNER), + project: tag_value(t.tags(), TAG_PROJECT), + } +} + +/// What running and watching tasks asks AWS. Like `WorkspaceApi`, each +/// method returns a `Send` future and an impl may write them as `async fn`; +/// the decisions are made by the functions below, so a fake exercises them. +pub trait TaskApi: Send + Sync { + /// RunTask. A quota refusal is [`AwsError::QuotaExceeded`]. + fn run_task( + &self, + req: RunTaskInput, + ) -> impl Future> + Send; + /// DescribeTasks (with tags). Tasks ECS no longer knows are left out. + fn describe( + &self, + cluster: &str, + arns: &[String], + ) -> impl Future, AwsError>> + Send; + /// ListTasks of exactly `family`, desired status RUNNING and PENDING. + fn list_project_tasks( + &self, + cluster: &str, + family: &str, + ) -> impl Future, AwsError>> + Send; + fn stop_task( + &self, + cluster: &str, + arn: &str, + reason: &str, + ) -> impl Future> + Send; + /// The last `limit` lines of a log stream, oldest first. A stream (or + /// group) that does not exist is no lines, not an error. + fn log_tail( + &self, + group: &str, + stream: &str, + limit: i32, + ) -> impl Future, AwsError>> + Send; +} + +// ------------------------------------------------------------- run request + +/// The RunTask request: the exact revision, on the environment's cluster and +/// network, with ECS Exec on and exactly the `tags` given (no propagation, +/// no ECS-managed tags, no overrides — P1). Spot runs on the `FARGATE_SPOT` +/// capacity provider with no launch type (the two are exclusive). +pub fn run_task_request( + env: &EnvironmentInfo, + task_def_arn: &str, + spot: bool, + tags: &[(String, String)], +) -> RunTaskInput { + const BUILT: &str = "every required field is set"; + let mut req = RunTaskInput::builder() + .cluster(&env.cluster_arn) + .task_definition(task_def_arn) + .count(1) + .enable_execute_command(true) + .network_configuration( + NetworkConfiguration::builder() + .awsvpc_configuration( + AwsVpcConfiguration::builder() + .set_subnets(Some(env.subnet_ids.clone())) + .security_groups(&env.security_group_id) + .assign_public_ip(if env.assign_public_ip { + AssignPublicIp::Enabled + } else { + AssignPublicIp::Disabled + }) + .build() + .expect(BUILT), + ) + .build(), + ) + .set_tags(Some( + tags.iter() + .map(|(k, v)| Tag::builder().key(k).value(v).build()) + .collect(), + )) + // SDK retries of this call must not start a second task. + .client_token(uuid::Uuid::new_v4().to_string()); + req = if spot { + req.capacity_provider_strategy( + CapacityProviderStrategyItem::builder() + .capacity_provider(SPOT_PROVIDER) + .weight(1) + .build() + .expect(BUILT), + ) + } else { + req.launch_type(LaunchType::Fargate) + }; + req.build().expect(BUILT) +} + +/// The family of `arn:...:task-definition/:`, when the +/// revision is a number. `None` for a bare family or anything else. +pub fn revision_family(arn: &str) -> Option<&str> { + if !arn.starts_with("arn:") { + return None; + } + let (_, rest) = arn.rsplit_once(":task-definition/")?; + let (family, rev) = rest.rsplit_once(':')?; + (!family.is_empty() + && !family.contains('/') + && !rev.is_empty() + && rev.bytes().all(|c| c.is_ascii_digit())) + .then_some(family) +} + +// ------------------------------------------------------------- find or run + +/// The project's live task (provisioning through running, not being +/// stopped), owned by the caller and tagged with this project. A RUNNING +/// one is preferred. +pub async fn find_project_task( + api: &A, + cluster: &str, + family: &str, + owner_user_id: &str, + project_id: &str, +) -> Result, AwsError> { + let arns = api.list_project_tasks(cluster, family).await?; + if arns.is_empty() { + return Ok(None); + } + let mut mine: Vec = Vec::new(); + for chunk in arns.chunks(DESCRIBE_BATCH) { + mine.extend(api.describe(cluster, chunk).await?.into_iter().filter(|t| { + t.owner.as_deref() == Some(owner_user_id) + && t.project.as_deref() == Some(project_id) + && revision_family(&t.task_definition_arn) == Some(family) + && t.is_live() + })); + } + mine.sort_by_key(|t| t.last_status != "RUNNING"); + Ok(mine.into_iter().next()) +} + +/// What [`find_or_run`] did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskStart { + pub task: TaskInfo, + /// An existing task was found (nothing was run). Its agent token is the + /// one it started with — see [`find_project_task`] in the report for + /// why a caller should look before rotating the token. + pub reused: bool, +} + +/// The project's live task if there is one (Review Focus 2: never a second +/// task for a project), else RunTask with the exact `task_def_arn`. +pub async fn find_or_run( + api: &A, + env: &EnvironmentInfo, + owner_user_id: &str, + project_id: &str, + task_def_arn: &str, + spot: bool, +) -> Result { + let family = revision_family(task_def_arn).ok_or_else(|| { + other(format!( + "\"{task_def_arn}\" is not an exact task definition revision" + )) + })?; + if !family.starts_with("triple-c-") { + return Err(other(format!( + "\"{task_def_arn}\" is not a Triple-C task definition" + ))); + } + if let Some(task) = + find_project_task(api, &env.cluster_arn, family, owner_user_id, project_id).await? + { + return Ok(TaskStart { task, reused: true }); + } + let tags = workspace_tags(owner_user_id, project_id, &env.name); + let task = api + .run_task(run_task_request(env, task_def_arn, spot, &tags)) + .await?; + Ok(TaskStart { + task, + reused: false, + }) +} + +// ------------------------------------------------------------------ quotas + +/// Whether an AWS message says an account limit or quota was hit. +pub fn looks_like_quota(text: &str) -> bool { + let t = text.to_lowercase(); + [ + "you've reached the limit", + "you\u{2019}ve reached the limit", + "limitexceeded", + "limit exceeded", + "quota", + ] + .iter() + .any(|s| t.contains(s)) +} + +/// The error for a RunTask refused on a quota. +pub fn quota_error(detail: &str) -> AwsError { + AwsError::QuotaExceeded { + message: format!( + "AWS would not start the task because an account limit was reached: {detail}. \ + Stop another workspace, or ask an administrator to raise the Fargate quota \ + (Service Quotas, AWS Fargate) for this region. Triple-C does not retry." + ), + } +} + +/// `e` as a quota error when its message is one; otherwise unchanged. +pub fn quota_or(e: AwsError) -> AwsError { + match e { + AwsError::Other { ref message } if looks_like_quota(message) => quota_error(message), + e => e, + } +} + +pub fn is_quota_error(e: &AwsError) -> bool { + matches!(e, AwsError::QuotaExceeded { .. }) +} + +/// RunTask's answer: its one task, or an error from its `failures` +/// (`(reason, detail)`), a quota one when they say so. +pub fn run_task_outcome( + tasks: Vec, + failures: Vec<(Option, Option)>, +) -> Result { + if let Some(task) = tasks.into_iter().find(|t| !t.arn.is_empty()) { + return Ok(task); + } + let text = failures + .iter() + .map(|(r, d)| match (r, d) { + (Some(r), Some(d)) => format!("{r} ({d})"), + (Some(r), None) => r.clone(), + (None, Some(d)) => d.clone(), + (None, None) => "unknown failure".into(), + }) + .collect::>() + .join("; "); + if text.is_empty() { + return Err(other("ecs:RunTask returned no task and no failure")); + } + if looks_like_quota(&text) { + return Err(quota_error(&text)); + } + Err(other(format!("ECS could not start the task: {text}"))) +} + +// -------------------------------------------------------------------- wait + +/// How [`wait_ready`] polls. Injectable so tests don't wait. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WaitTiming { + pub interval: Duration, + pub timeout: Duration, +} + +impl Default for WaitTiming { + fn default() -> Self { + WaitTiming { + interval: Duration::from_secs(5), + timeout: Duration::from_secs(6 * 60), + } + } +} + +/// Where a project's task logs go: the environment's log group and the +/// project's `awslogs-stream-prefix` (Task 4's `log_stream_prefix`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskLogs { + pub group: String, + pub stream_prefix: String, +} + +fn describe_timeout(t: Duration) -> String { + let secs = t.as_secs(); + if secs >= 60 && secs % 60 == 0 { + let m = secs / 60; + format!("{m} minute{}", if m == 1 { "" } else { "s" }) + } else { + format!("{secs} seconds") + } +} + +/// Poll until the task is RUNNING with its exec agent up and a runtime id +/// (what an SSM session needs), calling `progress` with each look. A task +/// that stops (or is being stopped) fails with its reason and the last +/// [`LOG_TAIL_LINES`] lines of its log; one that never gets there fails +/// after `timing.timeout` with its last status. The task is left as it is +/// either way: stopping it is the caller's call. +pub async fn wait_ready( + api: &A, + cluster: &str, + arn: &str, + logs: &TaskLogs, + timing: WaitTiming, + progress: impl Fn(&TaskInfo), +) -> Result { + let deadline = tokio::time::Instant::now() + timing.timeout; + let wanted = [arn.to_string()]; + loop { + let Some(t) = api + .describe(cluster, &wanted) + .await? + .into_iter() + .find(|t| t.arn == arn) + else { + return Err(other(format!( + "The task {arn} is no longer known to ECS; start the project again" + ))); + }; + progress(&t); + if t.is_stopping() { + return Err(stopped_error(api, logs, &t).await); + } + if t.is_ready() { + return Ok(t); + } + if tokio::time::Instant::now() + timing.interval > deadline { + return Err(other(format!( + "The task did not become ready within {} (last status {}{}). It may still \ + be starting; check the ECS console, or stop the project and start it again.", + describe_timeout(timing.timeout), + t.last_status, + if t.last_status == "RUNNING" && !t.exec_agent_running { + ", ECS Exec agent not running yet" + } else { + "" + } + ))); + } + tokio::time::sleep(timing.interval).await; + } +} + +async fn stopped_error(api: &A, logs: &TaskLogs, t: &TaskInfo) -> AwsError { + let reason = t + .stopped_reason + .clone() + .unwrap_or_else(|| "no reason given".into()); + let code = t + .stop_code + .as_deref() + .map(|c| format!(" ({c})")) + .unwrap_or_default(); + let stream = task_log_stream(&logs.stream_prefix, &t.arn); + let tail = match api.log_tail(&logs.group, &stream, LOG_TAIL_LINES).await { + Ok(lines) if lines.is_empty() => "\n(The task wrote no log lines.)".to_string(), + Ok(lines) => format!("\nLast log lines:\n{}", lines.join("\n")), + Err(e) => format!("\n(Its log could not be read: {e})"), + }; + other(format!( + "The task stopped before it was ready: {reason}{code}.{tail}" + )) +} + +// ----------------------------------------------------------- stop / watch + +/// Why a task stopped, as the app reports it (spec, "Lifecycle and status"). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StopKind { + /// Exited on its own (the essential container ended, idle stop, or AWS + /// retired it). + Exited, + /// A Fargate Spot interruption (P8). + Spot, + /// Never got going: the project goes to Error with this reason. + FailedToStart(String), + /// Stopped by someone (the app, the console, the CLI). + UserInitiated, +} + +pub fn classify_stop(info: &TaskInfo) -> StopKind { + match info.stop_code.as_deref() { + Some("SpotInterruption") => StopKind::Spot, + Some("TaskFailedToStart") => StopKind::FailedToStart( + info.stopped_reason + .clone() + .filter(|r| !r.trim().is_empty()) + .unwrap_or_else(|| "The task failed to start".into()), + ), + Some("UserInitiated") => StopKind::UserInitiated, + _ => StopKind::Exited, + } +} + +/// Of `arns`, the ones that are STOPPED or that ECS no longer knows (it +/// forgets stopped tasks after about an hour). A task merely being stopped +/// is not yet reported. For the AWS death watch. +pub async fn stopped_among( + api: &A, + cluster: &str, + arns: &[String], +) -> Result, AwsError> { + let mut stopped = Vec::new(); + for chunk in arns.chunks(DESCRIBE_BATCH) { + let known = api.describe(cluster, chunk).await?; + for arn in chunk { + match known.iter().find(|t| &t.arn == arn) { + Some(t) if t.last_status != "STOPPED" => {} + _ => stopped.push(arn.clone()), + } + } + } + Ok(stopped) +} + +// ---------------------------------------------------------------- real API + +/// The real thing, from a connection's `SdkConfig`. +pub struct SdkTaskApi { + ecs: aws_sdk_ecs::Client, + logs: aws_sdk_cloudwatchlogs::Client, +} + +impl SdkTaskApi { + pub fn new(cfg: &SdkConfig) -> Self { + SdkTaskApi { + ecs: aws_sdk_ecs::Client::new(cfg), + logs: aws_sdk_cloudwatchlogs::Client::new(cfg), + } + } +} + +impl TaskApi for SdkTaskApi { + async fn run_task(&self, req: RunTaskInput) -> Result { + const ACTION: &str = "ecs:RunTask"; + // The fluent builder takes no whole input; copy every field. + let out = self + .ecs + .run_task() + .set_capacity_provider_strategy(req.capacity_provider_strategy) + .set_cluster(req.cluster) + .set_count(req.count) + .set_enable_ecs_managed_tags(req.enable_ecs_managed_tags) + .set_enable_execute_command(req.enable_execute_command) + .set_group(req.group) + .set_launch_type(req.launch_type) + .set_network_configuration(req.network_configuration) + .set_overrides(req.overrides) + .set_placement_constraints(req.placement_constraints) + .set_placement_strategy(req.placement_strategy) + .set_platform_version(req.platform_version) + .set_propagate_tags(req.propagate_tags) + .set_reference_id(req.reference_id) + .set_started_by(req.started_by) + .set_tags(req.tags) + .set_task_definition(req.task_definition) + .set_client_token(req.client_token) + .set_volume_configurations(req.volume_configurations) + .send() + .await + .map_err(|e| quota_or(classify_sdk_error(ACTION, &e)))?; + run_task_outcome( + out.tasks().iter().map(task_info).collect(), + out.failures() + .iter() + .map(|f| (f.reason().map(String::from), f.detail().map(String::from))) + .collect(), + ) + } + + async fn describe(&self, cluster: &str, arns: &[String]) -> Result, AwsError> { + const ACTION: &str = "ecs:DescribeTasks"; + let mut out = Vec::new(); + for chunk in arns.chunks(DESCRIBE_BATCH) { + let page = self + .ecs + .describe_tasks() + .cluster(cluster) + .set_tasks(Some(chunk.to_vec())) + .include(TaskField::Tags) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + out.extend(page.tasks().iter().map(task_info)); + } + Ok(out) + } + + async fn list_project_tasks( + &self, + cluster: &str, + family: &str, + ) -> Result, AwsError> { + const ACTION: &str = "ecs:ListTasks"; + let mut found: Vec = Vec::new(); + for status in [DesiredStatus::Running, DesiredStatus::Pending] { + let mut token: Option = None; + for _ in 0..MAX_LIST_PAGES { + let page = self + .ecs + .list_tasks() + .cluster(cluster) + .family(family) + .desired_status(status.clone()) + .set_next_token(token.take()) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + for a in page.task_arns() { + if !found.contains(a) { + found.push(a.clone()); + } + } + match page.next_token() { + Some(t) => token = Some(t.to_string()), + None => break, + } + } + } + Ok(found) + } + + async fn stop_task(&self, cluster: &str, arn: &str, reason: &str) -> Result<(), AwsError> { + const ACTION: &str = "ecs:StopTask"; + self.ecs + .stop_task() + .cluster(cluster) + .task(arn) + .reason(reason) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + Ok(()) + } + + async fn log_tail( + &self, + group: &str, + stream: &str, + limit: i32, + ) -> Result, AwsError> { + const ACTION: &str = "logs:GetLogEvents"; + let out = self + .logs + .get_log_events() + .log_group_name(group) + .log_stream_name(stream) + .start_from_head(false) + .limit(limit) + .send() + .await; + match out { + Ok(page) => Ok(page + .events() + .iter() + .filter_map(|e| e.message()) + .map(str::to_string) + .collect()), + Err(e) + if e.as_service_error() + .is_some_and(|s| s.is_resource_not_found_exception()) => + { + Ok(Vec::new()) + } + Err(e) => Err(classify_sdk_error(ACTION, &e)), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/app/src-tauri/src/aws/tasks/tests.rs b/app/src-tauri/src/aws/tasks/tests.rs new file mode 100644 index 0000000..340399a --- /dev/null +++ b/app/src-tauri/src/aws/tasks/tests.rs @@ -0,0 +1,721 @@ +use std::sync::Mutex; +use std::time::Duration; + +use aws_sdk_ecs::types::{ + AssignPublicIp, Container, LaunchType, ManagedAgent, ManagedAgentName, Tag, Task, TaskStopCode, +}; + +use super::*; +use crate::aws::environments::EnvironmentInfo; +use crate::aws::tofu_modules::{log_stream_prefix, owner_key}; + +const ACCOUNT: &str = "111122223333"; +const REGION: &str = "eu-west-1"; +const USER: &str = "AROAEXAMPLEID:alice@example.com"; +const OTHER: &str = "AROAOTHERID:mallory"; +const PROJECT_ID: &str = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"; +const FAMILY: &str = "triple-c-dev-abcdef012345-0f1e2d3c-4b5"; + +fn cluster() -> String { + format!("arn:aws:ecs:{REGION}:{ACCOUNT}:cluster/triple-c-dev") +} + +fn td_arn(rev: u32) -> String { + format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{FAMILY}:{rev}") +} + +fn task_arn(id: &str) -> String { + format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task/triple-c-dev/{id}") +} + +fn env_info() -> EnvironmentInfo { + EnvironmentInfo { + schema: 1, + name: "dev".into(), + template_version: 1, + cluster_arn: cluster(), + security_group_id: "sg-0123456789abcdef0".into(), + subnet_ids: vec!["subnet-0a".into(), "subnet-0b".into()], + assign_public_ip: true, + kms_key_arn: format!("arn:aws:kms:{REGION}:{ACCOUNT}:key/1234"), + log_group: "/triple-c/dev".into(), + network_mode: "public".into(), + tenancy: "strict".into(), + } +} + +fn info(id: &str, last: &str) -> TaskInfo { + TaskInfo { + arn: task_arn(id), + last_status: last.into(), + desired_status: if last == "STOPPED" { + "STOPPED" + } else { + "RUNNING" + } + .into(), + health: None, + stop_code: None, + stopped_reason: None, + task_definition_arn: td_arn(3), + runtime_id: None, + exec_agent_running: false, + owner: Some(USER.into()), + project: Some(PROJECT_ID.into()), + } +} + +fn ready(id: &str) -> TaskInfo { + TaskInfo { + runtime_id: Some(format!("{id}-123")), + exec_agent_running: true, + ..info(id, "RUNNING") + } +} + +/// A scripted `TaskApi`: `listed` answers ListTasks, `described` answers +/// every DescribeTasks in turn (the last one repeats), `run` answers RunTask. +#[derive(Default)] +struct Fake { + listed: Vec, + described: Mutex>>, + run: Mutex>>, + logs: Vec, + calls: Mutex>, + run_requests: Mutex>, + log_requests: Mutex>, +} + +impl Fake { + fn calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } + fn describing(self, answers: Vec>) -> Self { + *self.described.lock().unwrap() = answers; + self + } + fn running(self, r: Result) -> Self { + *self.run.lock().unwrap() = Some(r); + self + } +} + +impl TaskApi for Fake { + async fn run_task(&self, req: RunTaskInput) -> Result { + self.calls.lock().unwrap().push("run".into()); + self.run_requests.lock().unwrap().push(req); + self.run + .lock() + .unwrap() + .clone() + .unwrap_or_else(|| Ok(info("new", "PROVISIONING"))) + } + async fn describe(&self, cluster: &str, arns: &[String]) -> Result, AwsError> { + self.calls + .lock() + .unwrap() + .push(format!("describe {cluster} {}", arns.join(","))); + let mut d = self.described.lock().unwrap(); + let answer = if d.len() > 1 { + d.remove(0) + } else { + d.first().cloned().unwrap_or_default() + }; + Ok(answer + .into_iter() + .filter(|t| arns.contains(&t.arn)) + .collect()) + } + async fn list_project_tasks( + &self, + cluster: &str, + family: &str, + ) -> Result, AwsError> { + self.calls + .lock() + .unwrap() + .push(format!("list {cluster} {family}")); + Ok(self.listed.clone()) + } + async fn stop_task(&self, _cluster: &str, arn: &str, reason: &str) -> Result<(), AwsError> { + self.calls + .lock() + .unwrap() + .push(format!("stop {arn} {reason}")); + Ok(()) + } + async fn log_tail( + &self, + group: &str, + stream: &str, + limit: i32, + ) -> Result, AwsError> { + self.log_requests + .lock() + .unwrap() + .push((group.into(), stream.into(), limit)); + Ok(self.logs.clone()) + } +} + +fn tags() -> Vec<(String, String)> { + crate::aws::workspace::workspace_tags(USER, PROJECT_ID, "dev") +} + +// ------------------------------------------------------------ run request + +#[test] +fn on_demand_runs_fargate_on_the_environment_network_with_exec_and_the_three_tags() { + let req = run_task_request(&env_info(), &td_arn(3), false, &tags()); + assert_eq!(req.cluster(), Some(cluster().as_str())); + assert_eq!(req.task_definition(), Some(td_arn(3).as_str())); + assert_eq!(req.launch_type(), Some(&LaunchType::Fargate)); + assert!(req.capacity_provider_strategy().is_empty()); + assert_eq!(req.enable_execute_command(), Some(true)); + assert_eq!(req.count(), Some(1)); + let vpc = req + .network_configuration() + .and_then(|n| n.awsvpc_configuration()) + .expect("awsvpc"); + assert_eq!(vpc.subnets(), ["subnet-0a", "subnet-0b"]); + assert_eq!(vpc.security_groups(), ["sg-0123456789abcdef0"]); + assert_eq!(vpc.assign_public_ip(), Some(&AssignPublicIp::Enabled)); + let got: Vec<(String, String)> = req + .tags() + .iter() + .map(|t| (t.key().unwrap().to_string(), t.value().unwrap().to_string())) + .collect(); + assert_eq!(got, tags()); + // Nothing project-shaped rides along (P1), and tags are given, not propagated. + assert!(req.overrides().is_none()); + assert!(req.propagate_tags().is_none()); + assert!(req.enable_ecs_managed_tags().is_none()); +} + +#[test] +fn spot_uses_the_fargate_spot_capacity_provider_and_no_launch_type() { + let req = run_task_request(&env_info(), &td_arn(3), true, &tags()); + assert_eq!(req.launch_type(), None); + let strategy = req.capacity_provider_strategy(); + assert_eq!(strategy.len(), 1); + assert_eq!(strategy[0].capacity_provider(), "FARGATE_SPOT"); + assert_eq!(strategy[0].weight(), 1); +} + +#[test] +fn a_private_environment_gets_no_public_ip() { + let mut env = env_info(); + env.assign_public_ip = false; + let req = run_task_request(&env, &td_arn(3), false, &tags()); + let vpc = req + .network_configuration() + .unwrap() + .awsvpc_configuration() + .unwrap(); + assert_eq!(vpc.assign_public_ip(), Some(&AssignPublicIp::Disabled)); +} + +// ------------------------------------------------------------ find or run + +#[tokio::test] +async fn an_existing_running_task_is_reused_and_nothing_is_run() { + let api = Fake { + listed: vec![task_arn("t1")], + ..Default::default() + } + .describing(vec![vec![ready("t1")]]); + let got = find_or_run(&api, &env_info(), USER, PROJECT_ID, &td_arn(4), false) + .await + .unwrap(); + assert!(got.reused); + assert_eq!(got.task.arn, task_arn("t1")); + assert!(!api.calls().contains(&"run".to_string())); + assert_eq!(api.calls()[0], format!("list {} {FAMILY}", cluster())); +} + +#[tokio::test] +async fn a_pending_task_is_reused_too() { + let api = Fake { + listed: vec![task_arn("t1")], + ..Default::default() + } + .describing(vec![vec![info("t1", "PENDING")]]); + let got = find_or_run(&api, &env_info(), USER, PROJECT_ID, &td_arn(3), false) + .await + .unwrap(); + assert!(got.reused); + assert_eq!(got.task.last_status, "PENDING"); + assert!(!api.calls().contains(&"run".to_string())); +} + +#[tokio::test] +async fn a_task_of_another_owner_or_project_in_the_family_is_never_reused() { + let mut foreign = info("theirs", "RUNNING"); + foreign.owner = Some(OTHER.into()); + let mut other_project = info("other-project", "RUNNING"); + other_project.project = Some("ffffffff-0000-0000-0000-000000000000".into()); + let mut untagged = info("untagged", "RUNNING"); + untagged.owner = None; + untagged.project = None; + let api = Fake { + listed: vec![ + task_arn("theirs"), + task_arn("other-project"), + task_arn("untagged"), + ], + ..Default::default() + } + .describing(vec![vec![foreign, other_project, untagged]]); + let got = find_or_run(&api, &env_info(), USER, PROJECT_ID, &td_arn(3), false) + .await + .unwrap(); + assert!(!got.reused); + assert_eq!(got.task.arn, task_arn("new")); + assert_eq!(api.calls().last().unwrap(), "run"); +} + +#[tokio::test] +async fn a_task_already_stopping_is_not_reused() { + let mut stopping = info("t1", "RUNNING"); + stopping.desired_status = "STOPPED".into(); + let api = Fake { + listed: vec![task_arn("t1"), task_arn("t2")], + ..Default::default() + } + .describing(vec![vec![stopping, info("t2", "DEPROVISIONING")]]); + let got = find_or_run(&api, &env_info(), USER, PROJECT_ID, &td_arn(3), false) + .await + .unwrap(); + assert!(!got.reused); +} + +#[tokio::test] +async fn a_running_task_is_preferred_over_a_pending_one() { + let api = Fake { + listed: vec![task_arn("p"), task_arn("r")], + ..Default::default() + } + .describing(vec![vec![info("p", "PENDING"), ready("r")]]); + let got = find_project_task(&api, &cluster(), FAMILY, USER, PROJECT_ID) + .await + .unwrap() + .unwrap(); + assert_eq!(got.arn, task_arn("r")); +} + +#[tokio::test] +async fn nothing_listed_means_no_describe_and_a_run_with_the_exact_revision_and_tags() { + let api = Fake::default(); + let got = find_or_run(&api, &env_info(), USER, PROJECT_ID, &td_arn(7), true) + .await + .unwrap(); + assert!(!got.reused); + assert_eq!(api.calls().len(), 2, "{:?}", api.calls()); + let reqs = api.run_requests.lock().unwrap(); + assert_eq!(reqs[0].task_definition(), Some(td_arn(7).as_str())); + assert_eq!(reqs[0].launch_type(), None); + let keys: Vec<&str> = reqs[0].tags().iter().filter_map(|t| t.key()).collect(); + assert_eq!( + keys, + ["triple-c:environment", "triple-c:owner", "triple-c:project"] + ); + assert_eq!( + reqs[0] + .tags() + .iter() + .find(|t| t.key() == Some("triple-c:owner")) + .unwrap() + .value(), + Some(USER) + ); +} + +#[tokio::test] +async fn a_bare_family_or_foreign_arn_is_refused_before_any_call() { + let api = Fake::default(); + for bad in [ + FAMILY.to_string(), + format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{FAMILY}"), + format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{FAMILY}:latest"), + "arn:aws:ecs:eu-west-1:1:task-definition/someone-else:1".to_string(), + ] { + let err = find_or_run(&api, &env_info(), USER, PROJECT_ID, &bad, false) + .await + .unwrap_err(); + assert!(matches!(err, AwsError::Other { .. }), "{bad}: {err:?}"); + } + assert!(api.calls().is_empty()); +} + +#[tokio::test] +async fn a_quota_refusal_is_reported_once_without_retrying() { + let api = Fake::default().running(Err(quota_error( + "You've reached the limit on the number of vCPUs you can run concurrently", + ))); + let err = find_or_run(&api, &env_info(), USER, PROJECT_ID, &td_arn(3), false) + .await + .unwrap_err(); + assert!(is_quota_error(&err)); + let text = err.to_string(); + assert!(text.contains("limit"), "{text}"); + assert!(text.contains("quota"), "{text}"); + assert!(text.contains("vCPUs"), "{text}"); + assert_eq!( + api.calls().iter().filter(|c| *c == "run").count(), + 1, + "no retry" + ); +} + +// ------------------------------------------------------------ quota mapping + +#[test] +fn quota_texts_are_recognised_and_others_are_not() { + for q in [ + "You've reached the limit on the number of vCPUs you can run concurrently", + "You\u{2019}ve reached the limit on the number of tasks you can run concurrently", + "LimitExceededException: too many", + "Service quota exceeded for Fargate On-Demand vCPU", + "limit exceeded", + ] { + assert!(looks_like_quota(q), "{q}"); + } + for not in [ + "Capacity is unavailable at this time. Please try again later", + "AccessDeniedException: not authorized", + "CannotPullContainerError", + ] { + assert!(!looks_like_quota(not), "{not}"); + } +} + +#[test] +fn quota_mapping_turns_other_errors_into_quota_errors_and_leaves_the_rest() { + let q = quota_or(AwsError::Other { + message: "ClientException: You've reached the limit on the number of vCPUs".into(), + }); + assert!(is_quota_error(&q)); + // The frontend's `quota_exceeded` kind (lib/types.ts). + let v = serde_json::to_value(&q).unwrap(); + assert_eq!(v["kind"], "quota_exceeded"); + assert!(v["message"].as_str().unwrap().contains("vCPUs")); + let denied = AwsError::AccessDenied { + action: "ecs:RunTask".into(), + message: "limit".into(), + }; + assert_eq!(quota_or(denied.clone()), denied); + assert!(!is_quota_error(&AwsError::Other { + message: "x".into() + })); +} + +#[test] +fn run_task_failures_become_errors_and_quota_failures_quota_errors() { + let ok = run_task_outcome(vec![info("a", "PROVISIONING")], vec![]).unwrap(); + assert_eq!(ok.arn, task_arn("a")); + let err = run_task_outcome( + vec![], + vec![(Some("RESOURCE:ENI".into()), Some("no ENIs left".into()))], + ) + .unwrap_err(); + assert!(!is_quota_error(&err)); + assert!(err.to_string().contains("RESOURCE:ENI"), "{err}"); + assert!(err.to_string().contains("no ENIs left"), "{err}"); + let err = run_task_outcome( + vec![], + vec![( + Some("You've reached the limit on the number of tasks you can run concurrently".into()), + None, + )], + ) + .unwrap_err(); + assert!(is_quota_error(&err)); + let err = run_task_outcome(vec![], vec![]).unwrap_err(); + assert!(err.to_string().contains("no task"), "{err}"); +} + +// ------------------------------------------------------------ wait + +fn timing() -> WaitTiming { + WaitTiming { + interval: Duration::from_secs(5), + timeout: Duration::from_secs(360), + } +} + +fn logs() -> TaskLogs { + TaskLogs { + group: "/triple-c/dev".into(), + stream_prefix: log_stream_prefix(&owner_key(REGION, USER), PROJECT_ID), + } +} + +#[tokio::test(start_paused = true)] +async fn wait_polls_until_running_with_the_exec_agent_and_a_runtime_id() { + let mut half = info("t1", "RUNNING"); + half.runtime_id = Some("rt".into()); + let api = Fake::default().describing(vec![ + vec![info("t1", "PROVISIONING")], + vec![info("t1", "PENDING")], + vec![half], + vec![ready("t1")], + ]); + let seen = Mutex::new(Vec::new()); + let t0 = tokio::time::Instant::now(); + let got = wait_ready( + &api, + &cluster(), + &task_arn("t1"), + &logs(), + timing(), + |t: &TaskInfo| seen.lock().unwrap().push(t.last_status.clone()), + ) + .await + .unwrap(); + assert_eq!(got, ready("t1")); + assert_eq!( + *seen.lock().unwrap(), + ["PROVISIONING", "PENDING", "RUNNING", "RUNNING"] + ); + assert_eq!(t0.elapsed(), Duration::from_secs(15)); +} + +#[tokio::test(start_paused = true)] +async fn a_task_that_stops_while_waiting_reports_its_reason_and_last_log_lines() { + let mut stopped = info("t1", "STOPPED"); + stopped.stop_code = Some("EssentialContainerExited".into()); + stopped.stopped_reason = Some("Essential container in task exited".into()); + let api = Fake { + logs: vec!["Triple-C: starting".into(), "fatal: boom".into()], + ..Default::default() + } + .describing(vec![vec![info("t1", "PENDING")], vec![stopped]]); + let err = wait_ready( + &api, + &cluster(), + &task_arn("t1"), + &logs(), + timing(), + |_: &TaskInfo| {}, + ) + .await + .unwrap_err(); + let text = err.to_string(); + assert!( + text.contains("Essential container in task exited"), + "{text}" + ); + assert!(text.contains("Triple-C: starting\nfatal: boom"), "{text}"); + let reqs = api.log_requests.lock().unwrap(); + assert_eq!( + *reqs, + [( + "/triple-c/dev".to_string(), + format!("{}/workspace/t1", logs().stream_prefix), + 50 + )] + ); +} + +#[tokio::test(start_paused = true)] +async fn a_task_being_stopped_fails_the_wait_at_once() { + let mut stopping = info("t1", "RUNNING"); + stopping.desired_status = "STOPPED".into(); + stopping.stop_code = Some("SpotInterruption".into()); + stopping.stopped_reason = Some("Your Spot Task was interrupted.".into()); + let api = Fake::default().describing(vec![vec![stopping]]); + let err = wait_ready( + &api, + &cluster(), + &task_arn("t1"), + &logs(), + timing(), + |_: &TaskInfo| {}, + ) + .await + .unwrap_err(); + assert!( + err.to_string().contains("Your Spot Task was interrupted."), + "{err}" + ); +} + +#[tokio::test(start_paused = true)] +async fn a_task_that_never_gets_ready_times_out_with_its_last_status() { + let api = Fake::default().describing(vec![vec![info("t1", "PENDING")]]); + let t0 = tokio::time::Instant::now(); + let err = wait_ready( + &api, + &cluster(), + &task_arn("t1"), + &logs(), + timing(), + |_: &TaskInfo| {}, + ) + .await + .unwrap_err(); + assert!(t0.elapsed() >= Duration::from_secs(360)); + assert!(t0.elapsed() < Duration::from_secs(366)); + let text = err.to_string(); + assert!(text.contains("6 minutes"), "{text}"); + assert!(text.contains("PENDING"), "{text}"); +} + +#[tokio::test(start_paused = true)] +async fn a_task_that_vanishes_is_an_error_not_a_hang() { + let api = Fake::default().describing(vec![vec![]]); + let err = wait_ready( + &api, + &cluster(), + &task_arn("t1"), + &logs(), + timing(), + |_: &TaskInfo| {}, + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("no longer"), "{err}"); +} + +#[test] +fn the_default_wait_is_five_seconds_for_six_minutes() { + let t = WaitTiming::default(); + assert_eq!(t.interval, Duration::from_secs(5)); + assert_eq!(t.timeout, Duration::from_secs(360)); +} + +// ------------------------------------------------------------ stop / logs + +#[tokio::test] +async fn stop_passes_the_reason() { + let api = Fake::default(); + api.stop_task(&cluster(), &task_arn("t1"), STOP_REASON) + .await + .unwrap(); + assert_eq!( + api.calls(), + [format!("stop {} Stopped from Triple-C", task_arn("t1"))] + ); +} + +#[test] +fn the_log_stream_is_prefix_container_task_id() { + assert_eq!( + task_log_stream("abc/p", &task_arn("0123abcd")), + "abc/p/workspace/0123abcd" + ); +} + +#[tokio::test] +async fn stopped_among_reports_stopped_and_vanished_tasks_only() { + let api = Fake::default().describing(vec![vec![ + ready("up"), + info("down", "STOPPED"), + info("pending", "PENDING"), + ]]); + let arns = vec![ + task_arn("up"), + task_arn("down"), + task_arn("pending"), + task_arn("gone"), + ]; + let mut got = stopped_among(&api, &cluster(), &arns).await.unwrap(); + got.sort(); + assert_eq!(got, [task_arn("down"), task_arn("gone")]); +} + +// ------------------------------------------------------------ classify + +#[test] +fn classify_stop_follows_the_spec_table() { + let with = |code: Option<&str>, reason: Option<&str>| TaskInfo { + stop_code: code.map(String::from), + stopped_reason: reason.map(String::from), + ..info("t", "STOPPED") + }; + assert_eq!( + classify_stop(&with(Some("EssentialContainerExited"), None)), + StopKind::Exited + ); + assert_eq!( + classify_stop(&with(Some("SpotInterruption"), None)), + StopKind::Spot + ); + assert_eq!( + classify_stop(&with( + Some("TaskFailedToStart"), + Some("CannotPullContainerError: nope") + )), + StopKind::FailedToStart("CannotPullContainerError: nope".into()) + ); + assert!(matches!( + classify_stop(&with(Some("TaskFailedToStart"), None)), + StopKind::FailedToStart(r) if !r.is_empty() + )); + assert_eq!( + classify_stop(&with(Some("UserInitiated"), Some("Stopped from Triple-C"))), + StopKind::UserInitiated + ); + assert_eq!( + classify_stop(&with(Some("TerminationNotice"), None)), + StopKind::Exited + ); + assert_eq!( + classify_stop(&with(Some("ServiceSchedulerInitiated"), None)), + StopKind::Exited + ); + assert_eq!(classify_stop(&with(None, None)), StopKind::Exited); +} + +// ------------------------------------------------------------ SDK mapping + +#[test] +fn task_info_reads_the_workspace_container_and_its_exec_agent() { + let task = Task::builder() + .task_arn(task_arn("t1")) + .last_status("RUNNING") + .desired_status("RUNNING") + .health_status(aws_sdk_ecs::types::HealthStatus::Healthy) + .stop_code(TaskStopCode::SpotInterruption) + .stopped_reason("r") + .task_definition_arn(td_arn(3)) + .containers( + Container::builder() + .name("sidecar") + .runtime_id("wrong") + .build(), + ) + .containers( + Container::builder() + .name("workspace") + .runtime_id("rt-1") + .managed_agents( + ManagedAgent::builder() + .name(ManagedAgentName::ExecuteCommandAgent) + .last_status("RUNNING") + .build(), + ) + .build(), + ) + .tags(Tag::builder().key("triple-c:owner").value(USER).build()) + .tags( + Tag::builder() + .key("triple-c:project") + .value(PROJECT_ID) + .build(), + ) + .build(); + let i = task_info(&task); + assert_eq!(i.arn, task_arn("t1")); + assert_eq!(i.health.as_deref(), Some("HEALTHY")); + assert_eq!(i.stop_code.as_deref(), Some("SpotInterruption")); + assert_eq!(i.runtime_id.as_deref(), Some("rt-1")); + assert!(i.exec_agent_running); + assert_eq!(i.owner.as_deref(), Some(USER)); + assert_eq!(i.project.as_deref(), Some(PROJECT_ID)); + + let bare = task_info(&Task::builder().task_arn("x").last_status("PENDING").build()); + assert!(!bare.exec_agent_running); + assert_eq!(bare.runtime_id, None); + assert_eq!(bare.owner, None); +} diff --git a/app/src-tauri/src/aws/tofu_modules.rs b/app/src-tauri/src/aws/tofu_modules.rs index dfb08fd..2d665d0 100644 --- a/app/src-tauri/src/aws/tofu_modules.rs +++ b/app/src-tauri/src/aws/tofu_modules.rs @@ -198,6 +198,14 @@ pub fn log_stream_prefix(owner_key: &str, project_id: &str) -> String { format!("{owner_key}/{}", project_component(project_id)) } +/// The awslogs stream of a task's `workspace` container: +/// `//`, the task id being the last segment of +/// its ARN. +pub fn task_log_stream(stream_prefix: &str, task_arn: &str) -> String { + let task_id = task_arn.rsplit('/').next().unwrap_or(task_arn); + format!("{stream_prefix}/workspace/{task_id}") +} + /// The `creation_token` of the user's file system (the onboarding module's /// `local.name`). pub fn user_file_system_token(env: &str, owner_key: &str) -> String { diff --git a/app/src-tauri/src/container_watch.rs b/app/src-tauri/src/container_watch.rs index 9647681..3f3887c 100644 --- a/app/src-tauri/src/container_watch.rs +++ b/app/src-tauri/src/container_watch.rs @@ -143,11 +143,11 @@ async fn unless_cancelled( /// (see [`STABLE_AFTER`]). async fn watch_once( store: &ProjectsStore, + source: &ExitSource, cancel: &mut watch::Receiver, sweep_reason: ExitReason, on_change: &impl Fn(&Project, ExitReason), ) -> Option { - let source = ExitSource::Docker; if !unless_cancelled(cancel, source.reachable()).await? { return Some(false); } @@ -187,6 +187,7 @@ async fn watch_once( /// test can drive it without a Tauri app. pub(crate) async fn run( store: Arc, + source: ExitSource, mut cancel: watch::Receiver, on_change: impl Fn(&Project, ExitReason), ) { @@ -197,7 +198,7 @@ pub(crate) async fn run( let mut sweep_reason = ExitReason::FoundStopped; let mut failures: u32 = 0; loop { - let Some(healthy) = watch_once(&store, &mut cancel, sweep_reason, &on_change).await else { + let Some(healthy) = watch_once(&store, &source, &mut cancel, sweep_reason, &on_change).await else { return; }; sweep_reason = ExitReason::Exited; @@ -209,14 +210,16 @@ pub(crate) async fn run( } } -/// Start the watcher for the life of the app. +/// Start a watcher on `source` for the life of the app. One per host kind: +/// Docker's event stream, and (Task 6) the AWS task poll. pub fn spawn( app: AppHandle, store: Arc, + source: ExitSource, cancel: watch::Receiver, ) -> tauri::async_runtime::JoinHandle<()> { tauri::async_runtime::spawn(async move { - run(store, cancel, |p, reason| { + run(store, source, cancel, |p, reason| { log::warn!( "Project '{}' ({}): its container stopped without the app asking ({:?}) — marking Stopped", p.name, @@ -270,6 +273,48 @@ mod tests { assert_eq!(unless_cancelled(&mut rx, async { 1 }).await, None); } + /// The AWS source drives the same loop: it is "reachable" without a + /// ping, polls its lookup every interval, and gives way to cancellation. + /// No project is Running, so nothing is confirmed (and nothing reaches + /// Docker or AWS). + #[tokio::test(start_paused = true)] + async fn an_aws_source_polls_its_lookup_until_cancelled() { + use crate::host::watch::{AwsTaskLookup, AwsWatch, TaskGroup, WatchedTask}; + use futures_util::future::BoxFuture; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct Lookup(AtomicUsize); + impl AwsTaskLookup for Lookup { + fn running_tasks(&self) -> Vec { + self.0.fetch_add(1, Ordering::SeqCst); + Vec::new() + } + fn stopped<'a>( + &'a self, + _: &'a TaskGroup, + _: &'a [String], + ) -> BoxFuture<'a, Result, String>> { + Box::pin(async { Ok(Vec::new()) }) + } + } + + let dir = std::env::temp_dir().join(format!("triple-c-watch-aws-{}", uuid::Uuid::new_v4().simple())); + std::fs::create_dir_all(&dir).unwrap(); + let store = Arc::new(ProjectsStore::over_file_for_test(dir.join("projects.json"), Vec::new())); + let lookup = Arc::new(Lookup(AtomicUsize::new(0))); + let source = ExitSource::Aws(AwsWatch::every(lookup.clone(), Duration::from_secs(15))); + let (cancel_tx, cancel_rx) = watch::channel(false); + let watcher = tokio::spawn(run(store, source, cancel_rx, |_, _| panic!("nothing stopped"))); + tokio::time::sleep(Duration::from_secs(46)).await; + assert_eq!(lookup.0.load(Ordering::SeqCst), 3); + let _ = cancel_tx.send(true); + tokio::time::timeout(Duration::from_secs(1), watcher) + .await + .expect("stops at once") + .unwrap(); + std::fs::remove_dir_all(&dir).ok(); + } + /// End to end against a real daemon: a Running project whose container is /// killed from outside the app is flipped to Stopped and reported as a live /// exit; one found already stopped on the first look is reported as such; @@ -302,7 +347,7 @@ mod tests { let (cancel_tx, cancel_rx) = watch::channel(false); let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel::<(String, ExitReason)>(); - let watcher = tokio::spawn(run(store.clone(), cancel_rx, move |p, why| { + let watcher = tokio::spawn(run(store.clone(), ExitSource::Docker, cancel_rx, move |p, why| { let _ = seen_tx.send((p.id.clone(), why)); })); let first = tokio::time::timeout(Duration::from_secs(10), seen_rx.recv()).await; diff --git a/app/src-tauri/src/host/watch.rs b/app/src-tauri/src/host/watch.rs index 2c60254..837dfb1 100644 --- a/app/src-tauri/src/host/watch.rs +++ b/app/src-tauri/src/host/watch.rs @@ -3,21 +3,143 @@ //! reported dead (replayable from a point in time), and a positive-exit check //! used to confirm each report before anything changes. //! -//! Docker pushes `die` events. An AWS host will have no push stream from the -//! app's point of view; its [`deaths`] will be a `DescribeTasks` poll that -//! yields stopped tasks, which `container_watch` handles the same way because -//! every report is confirmed with [`ContainerTarget::has_exited`] anyway. +//! Docker pushes `die` events. AWS has no push stream from the app's point of +//! view; its [`ExitSource::deaths`] is a `DescribeTasks` poll (every 15 s) over +//! the tasks of Running AWS projects that yields the stopped ones, which +//! `container_watch` handles the same way because every report is confirmed +//! with [`ContainerTarget::has_exited`] anyway. +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; +use std::time::Duration; + +use futures_util::future::BoxFuture; use futures_util::{Stream, StreamExt}; use std::pin::Pin; use super::ContainerTarget; use crate::docker; -/// Which host's deaths to watch. One variant today. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// How often the AWS watch asks ECS (spec, "Lifecycle and status"). +pub const AWS_POLL_INTERVAL: Duration = Duration::from_secs(15); + +/// Which host's deaths to watch. +#[derive(Clone)] pub enum ExitSource { Docker, + // Constructed by the AWS host (phase 5, Task 6); until then only by tests. + #[allow(dead_code)] + Aws(AwsWatch), +} + +impl std::fmt::Debug for ExitSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ExitSource::Docker => f.write_str("Docker"), + ExitSource::Aws(w) => write!(f, "Aws(every {:?})", w.interval), + } + } +} + +/// Where a task's DescribeTasks goes: the connection and region its +/// credentials and endpoint come from, and the cluster. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct TaskGroup { + pub connection_id: String, + pub region: String, + pub cluster_arn: String, +} + +/// A Running AWS project's task, and the target a death is reported as. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WatchedTask { + pub target: ContainerTarget, + pub group: TaskGroup, + pub task_arn: String, +} + +/// The project-to-task lookup the AWS watch runs on, injected so it can be +/// faked (Task 6 backs it with the tunnel registry / project store and +/// `aws::tasks::stopped_among`). +pub trait AwsTaskLookup: Send + Sync { + /// The tasks of the projects that are Running now. Read on every poll. + fn running_tasks(&self) -> Vec; + /// Of `arns` (all in `group`, at most 100), the ones that are STOPPED or + /// gone. `Err` when ECS could not be asked. + fn stopped<'a>( + &'a self, + group: &'a TaskGroup, + arns: &'a [String], + ) -> BoxFuture<'a, Result, String>>; +} + +/// The AWS death poll: a lookup and how often to run it. +#[derive(Clone)] +pub struct AwsWatch { + lookup: Arc, + interval: Duration, +} + +#[allow(dead_code)] // Task 6 +impl AwsWatch { + pub fn new(lookup: Arc) -> Self { + Self::every(lookup, AWS_POLL_INTERVAL) + } + pub fn every(lookup: Arc, interval: Duration) -> Self { + AwsWatch { lookup, interval } + } +} + +/// DescribeTasks' batch limit. +const DESCRIBE_BATCH: usize = 100; + +/// One poll: the targets of Running projects whose task stopped. Tasks are +/// asked about per group (connection + region + cluster), 100 at a time. A +/// group that can't be asked (expired SSO, network) is logged and skipped — +/// it confirms nothing, and the other groups are still watched; the next +/// poll asks again. +pub async fn poll_stopped(lookup: &dyn AwsTaskLookup) -> Vec { + let mut groups: HashMap> = HashMap::new(); + for t in lookup.running_tasks() { + groups.entry(t.group.clone()).or_default().push(t); + } + let mut dead = Vec::new(); + for (group, tasks) in groups { + for chunk in tasks.chunks(DESCRIBE_BATCH) { + let arns: Vec = chunk.iter().map(|t| t.task_arn.clone()).collect(); + match lookup.stopped(&group, &arns).await { + Ok(stopped) => dead.extend( + chunk + .iter() + .filter(|t| stopped.contains(&t.task_arn)) + .map(|t| t.target.clone()), + ), + Err(e) => log::warn!( + "Container watch: could not check AWS tasks in {} ({}): {e}", + group.region, + group.cluster_arn + ), + } + } + } + dead +} + +/// Polls forever, `interval` apart (the first after one interval: the +/// watcher sweeps on connect), yielding each stopped task's target. +fn aws_deaths(watch: AwsWatch) -> DeathStream { + Box::pin(futures_util::stream::unfold( + (watch, VecDeque::new()), + |(watch, mut queue)| async move { + loop { + if let Some(t) = queue.pop_front() { + return Some((Ok(t), (watch, queue))); + } + tokio::time::sleep(watch.interval).await; + queue.extend(poll_stopped(&*watch.lookup).await); + } + }, + )) } pub type DeathStream = Pin> + Send>>; @@ -28,6 +150,9 @@ impl ExitSource { pub async fn reachable(&self) -> bool { match self { ExitSource::Docker => docker::events::ping().await, + // No one endpoint to ping: each poll asks every group, and a + // group that fails is skipped rather than ending the stream. + ExitSource::Aws(_) => true, } } @@ -38,6 +163,9 @@ impl ExitSource { ExitSource::Docker => Box::pin( docker::events::container_deaths(since).map(|r| r.map(ContainerTarget::docker)), ), + // ECS has no replay: a task that stopped while the stream was + // down is still STOPPED (or gone) on the next poll. + ExitSource::Aws(watch) => aws_deaths(watch.clone()), } } } @@ -58,3 +186,151 @@ impl ContainerTarget { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + fn group(conn: &str, region: &str) -> TaskGroup { + TaskGroup { + connection_id: conn.into(), + region: region.into(), + cluster_arn: format!("arn:aws:ecs:{region}:111122223333:cluster/triple-c-dev"), + } + } + + fn task(n: usize, g: &TaskGroup) -> WatchedTask { + WatchedTask { + target: ContainerTarget::docker(format!("target-{:012}", n)), + group: g.clone(), + task_arn: format!("arn:aws:ecs:{}:111122223333:task/triple-c-dev/t{n}", g.region), + } + } + + /// Running tasks are fixed; `dead` are the stopped ARNs; a group whose + /// region is `failing` errors. Every `stopped` call is recorded. + struct FakeLookup { + tasks: Mutex>, + dead: Vec, + failing: &'static str, + asked: Mutex>, + polls: Mutex, + } + + impl FakeLookup { + fn new(tasks: Vec, dead: Vec) -> Self { + FakeLookup { + tasks: Mutex::new(tasks), + dead, + failing: "", + asked: Mutex::new(Vec::new()), + polls: Mutex::new(0), + } + } + } + + impl AwsTaskLookup for FakeLookup { + fn running_tasks(&self) -> Vec { + *self.polls.lock().unwrap() += 1; + self.tasks.lock().unwrap().clone() + } + fn stopped<'a>( + &'a self, + group: &'a TaskGroup, + arns: &'a [String], + ) -> BoxFuture<'a, Result, String>> { + Box::pin(async move { + self.asked.lock().unwrap().push((group.clone(), arns.len())); + if group.region == self.failing { + return Err("SSO session expired".into()); + } + Ok(arns.iter().filter(|a| self.dead.contains(a)).cloned().collect()) + }) + } + } + + #[tokio::test] + async fn a_poll_asks_each_group_once_and_reports_only_stopped_tasks() { + let (a, b) = (group("c1", "eu-west-1"), group("c2", "us-east-1")); + let tasks = vec![task(1, &a), task(2, &b), task(3, &a)]; + let dead = vec![tasks[0].task_arn.clone(), tasks[1].task_arn.clone()]; + let lookup = FakeLookup::new(tasks.clone(), dead); + let mut got = poll_stopped(&lookup).await; + got.sort_by(|x, y| x.id().cmp(y.id())); + assert_eq!(got, vec![tasks[0].target.clone(), tasks[1].target.clone()]); + let mut asked = lookup.asked.lock().unwrap().clone(); + asked.sort_by(|x, y| x.0.region.cmp(&y.0.region)); + assert_eq!(asked, vec![(a, 2), (b, 1)]); + } + + #[tokio::test] + async fn a_group_that_cannot_be_asked_confirms_nothing_and_the_others_still_report() { + let (a, b) = (group("c1", "eu-west-1"), group("c2", "us-east-1")); + let tasks = vec![task(1, &a), task(2, &b)]; + let dead = tasks.iter().map(|t| t.task_arn.clone()).collect(); + let mut lookup = FakeLookup::new(tasks.clone(), dead); + lookup.failing = "eu-west-1"; + assert_eq!(poll_stopped(&lookup).await, vec![tasks[1].target.clone()]); + } + + #[tokio::test] + async fn tasks_are_described_a_hundred_at_a_time() { + let a = group("c1", "eu-west-1"); + let lookup = FakeLookup::new((0..250).map(|n| task(n, &a)).collect(), Vec::new()); + assert!(poll_stopped(&lookup).await.is_empty()); + let sizes: Vec = lookup.asked.lock().unwrap().iter().map(|(_, n)| *n).collect(); + assert_eq!(sizes, vec![100, 100, 50]); + } + + #[tokio::test] + async fn no_running_aws_projects_means_no_aws_calls() { + let lookup = FakeLookup::new(Vec::new(), Vec::new()); + assert!(poll_stopped(&lookup).await.is_empty()); + assert!(lookup.asked.lock().unwrap().is_empty()); + } + + #[tokio::test(start_paused = true)] + async fn the_aws_stream_polls_every_interval_and_yields_each_death() { + let a = group("c1", "eu-west-1"); + let tasks = vec![task(1, &a), task(2, &a)]; + let lookup = Arc::new(FakeLookup::new( + tasks.clone(), + vec![tasks[0].task_arn.clone(), tasks[1].task_arn.clone()], + )); + let source = ExitSource::Aws(AwsWatch::new(lookup.clone())); + assert!(source.reachable().await); + let mut deaths = source.deaths(0); + let t0 = tokio::time::Instant::now(); + let first = deaths.next().await.unwrap().unwrap(); + assert_eq!(t0.elapsed(), AWS_POLL_INTERVAL, "the first poll waits one interval"); + let second = deaths.next().await.unwrap().unwrap(); + assert_eq!(t0.elapsed(), AWS_POLL_INTERVAL, "one poll, both reported"); + let mut got = vec![first, second]; + got.sort_by(|x, y| x.id().cmp(y.id())); + assert_eq!(got, vec![tasks[0].target.clone(), tasks[1].target.clone()]); + assert_eq!(*lookup.polls.lock().unwrap(), 1); + + // Once the projects are no longer Running, polls go on and report nothing. + lookup.tasks.lock().unwrap().clear(); + let quiet = tokio::time::timeout(AWS_POLL_INTERVAL * 3 + Duration::from_secs(1), deaths.next()).await; + assert!(quiet.is_err(), "nothing to report"); + assert_eq!(*lookup.polls.lock().unwrap(), 4); + } + + #[test] + fn the_aws_poll_runs_every_fifteen_seconds() { + assert_eq!(AWS_POLL_INTERVAL, Duration::from_secs(15)); + } + + #[tokio::test] + async fn an_agent_target_never_confirms_an_exit_on_its_own() { + // R3: Task 6 wires the tunnel registry into this arm. + let ep = crate::host::agent::AgentEndpoint::new( + url::Url::parse("http://127.0.0.1:47901").unwrap(), + "fake-token", + ) + .unwrap(); + assert!(!ContainerTarget::Agent(ep).has_exited().await); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index b94e2b5..10569a0 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -329,6 +329,7 @@ pub fn run() { lifecycle_setup.track(container_watch::spawn( app.handle().clone(), projects_store_setup.clone(), + crate::host::watch::ExitSource::Docker, lifecycle_setup.cancellation(), )); diff --git a/app/src/lib/aws-error.test.ts b/app/src/lib/aws-error.test.ts index 3b65427..81dad15 100644 --- a/app/src/lib/aws-error.test.ts +++ b/app/src/lib/aws-error.test.ts @@ -1,11 +1,16 @@ import { describe, it, expect } from "vitest"; -import { toAwsError } from "./aws-error"; +import { awsErrorMessage, toAwsError } from "./aws-error"; describe("toAwsError", () => { it("passes a tagged AwsError through", () => { const e = { kind: "sso_expired", profile: "dev" }; expect(toAwsError(e)).toBe(e); }); + it("passes a quota error through with its message", () => { + const e = { kind: "quota_exceeded", message: "An account limit was reached" }; + expect(toAwsError(e)).toBe(e); + expect(awsErrorMessage(toAwsError(e))).toBe("An account limit was reached"); + }); it("wraps strings and Errors as other", () => { expect(toAwsError("boom")).toEqual({ kind: "other", message: "boom" }); expect(toAwsError(new Error("bad"))).toEqual({ kind: "other", message: "bad" }); diff --git a/app/src/lib/aws-error.ts b/app/src/lib/aws-error.ts index c55d627..a88c8ef 100644 --- a/app/src/lib/aws-error.ts +++ b/app/src/lib/aws-error.ts @@ -8,6 +8,7 @@ const KINDS: Record = { invalid_credentials: true, access_denied: true, state_bucket_taken: true, + quota_exceeded: true, other: true, }; diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 6dc0dd6..f07224c 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -1184,6 +1184,7 @@ export type AwsError = | { kind: "invalid_credentials"; message: string } | { kind: "access_denied"; action: string; message: string } | { kind: "state_bucket_taken"; bucket: string } + | { kind: "quota_exceeded"; message: string } | { kind: "other"; message: string }; export interface CallerIdentity { -- 2.52.0 From 7fd6fa6aaa522e7a782a70bdab5bfd922b7d6890 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 09:15:37 -0800 Subject: [PATCH 09/43] feat(aws): environment record, agent token, access point wait and resource removal RuntimeApi (GetParameter, GetRole, GetSecretValue, DescribeAccessPoints, task definition retirement, DeleteSecret with a 7-day window, DeleteAccessPoint) with the decisions in tested functions over it. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/environments.rs | 2 +- app/src-tauri/src/aws/runtime.rs | 498 +++++++++++++++++++++++++ app/src-tauri/src/aws/runtime/tests.rs | 341 +++++++++++++++++ 3 files changed, 840 insertions(+), 1 deletion(-) create mode 100644 app/src-tauri/src/aws/runtime.rs create mode 100644 app/src-tauri/src/aws/runtime/tests.rs diff --git a/app/src-tauri/src/aws/environments.rs b/app/src-tauri/src/aws/environments.rs index 48d1304..2320a75 100644 --- a/app/src-tauri/src/aws/environments.rs +++ b/app/src-tauri/src/aws/environments.rs @@ -754,7 +754,7 @@ fn error_parts( } /// Yes / No (missing) / Unknown (denied) from a lookup's result. -fn knowable( +pub(crate) fn knowable( action: &str, res: Result>, ) -> Result diff --git a/app/src-tauri/src/aws/runtime.rs b/app/src-tauri/src/aws/runtime.rs new file mode 100644 index 0000000..58c8cad --- /dev/null +++ b/app/src-tauri/src/aws/runtime.rs @@ -0,0 +1,498 @@ +//! What the AWS host asks AWS around a task, besides running it +//! (`tasks.rs`) and preparing its resources (`workspace.rs`): the +//! environment's record, whether the caller is onboarded, the agent token of +//! a running task (P2), waiting for a new access point, and removing a +//! project's resources. +//! +//! Like the other `aws/` modules, the decisions are made by the functions +//! here over a small trait ([`RuntimeApi`]) so a fake exercises them; the SDK +//! calls are in [`SdkRuntimeApi`]. + +use std::future::Future; +use std::time::Duration; + +use aws_config::SdkConfig; +use aws_sdk_secretsmanager::error::ProvideErrorMetadata; + +use super::environments::{knowable, parse_environment_record, EnvironmentInfo, Knowable}; +use super::error::{classify_sdk_error, AwsError}; +use super::tasks::{find_project_task, TaskApi, STOP_REASON}; +use super::tofu_modules::{ + access_point_root, environment_parameter_name, project_secret_name, task_family, + user_file_system_token, user_role_names, +}; +use super::workspace::WorkspaceApi; + +/// The secret key holding the agent token (Task 4's payload). +pub const AGENT_TOKEN_KEY: &str = "TRIPLEC_AGENT_TOKEN"; +/// Secrets Manager's recovery window when a project is removed. +pub const SECRET_RECOVERY_DAYS: i64 = 7; +/// A project secret is at most 64 KiB; anything bigger is not ours. +const MAX_SECRET_READ: usize = 70_000; + +fn other(message: impl Into) -> AwsError { + AwsError::Other { + message: message.into(), + } +} + +pub trait RuntimeApi: Send + Sync { + /// `ssm:GetParameter` of an environment's record; `None` when absent. + fn environment_record( + &self, + env: &str, + ) -> impl Future, AwsError>> + Send; + /// `iam:GetRole`. + fn role_exists(&self, name: &str) -> impl Future> + Send; + /// `secretsmanager:GetSecretValue`'s `SecretString`. + fn secret_string(&self, name: &str) -> impl Future> + Send; + /// The access point's `LifeCycleState`; `None` when it is gone. + fn access_point_state( + &self, + id: &str, + ) -> impl Future, AwsError>> + Send; + fn deregister_task_definition( + &self, + arn: &str, + ) -> impl Future> + Send; + fn delete_task_definitions( + &self, + arns: &[String], + ) -> impl Future> + Send; + /// Schedule deletion with a [`SECRET_RECOVERY_DAYS`] window. A secret + /// that does not exist is success. + fn delete_secret(&self, name: &str) -> impl Future> + Send; + /// A missing access point is success. + fn delete_access_point(&self, id: &str) -> impl Future> + Send; +} + +// ------------------------------------------------------------ environment + +/// The environment's record, read with one `GetParameter` and validated. +pub async fn read_environment( + rt: &R, + env: &str, + account: &str, + region: &str, +) -> Result { + let value = rt.environment_record(env).await?.ok_or_else(|| { + other(format!( + "The environment \"{env}\" was not found in {region}. It may have been removed; \ + check Settings → Hosts." + )) + })?; + parse_environment_record(env, &value, account, region).map_err(|e| { + other(format!( + "The record of environment \"{env}\" in {region} is not usable: {e}" + )) + }) +} + +/// Whether the caller's task role exists in the environment: the onboarding +/// signal a workspace user can read. A denied lookup is not an answer, so +/// it is let through (RunTask will say what is wrong). +pub async fn check_onboarded( + rt: &R, + env: &str, + owner_key: &str, +) -> Result<(), AwsError> { + let (task_role, _) = user_role_names(env, owner_key); + match rt.role_exists(&task_role).await? { + Knowable::No => Err(other(format!( + "You have not joined the environment \"{env}\" yet. Join it in Settings → Hosts \ + (or ask an administrator to onboard you), then start the project again." + ))), + Knowable::Yes | Knowable::Unknown => Ok(()), + } +} + +/// The agent token in a project secret's JSON. The error never echoes the +/// secret. +pub fn agent_token_from_secret(json: &str) -> Result { + if json.len() > MAX_SECRET_READ { + return Err("The project secret is larger than a project secret can be".into()); + } + let v: serde_json::Value = serde_json::from_str(json) + .map_err(|_| "The project secret is not a JSON object".to_string())?; + v.get(AGENT_TOKEN_KEY) + .and_then(|t| t.as_str()) + .filter(|t| !t.is_empty() && t.bytes().all(|b| b.is_ascii_graphic())) + .map(str::to_string) + .ok_or_else(|| "The project secret holds no agent token".to_string()) +} + +/// The token a running task of this project started with (P2: the caller +/// may read their own secret; `prepare_workspace` wrote it before RunTask). +pub async fn read_agent_token( + rt: &R, + env: &str, + owner_key: &str, + project_id: &str, +) -> Result { + let name = project_secret_name(env, owner_key, project_id); + let json = rt.secret_string(&name).await?; + agent_token_from_secret(&json).map_err(other) +} + +/// How long a fresh access point may take to become `available`. +#[derive(Debug, Clone, Copy)] +pub struct PollTiming { + pub interval: Duration, + pub timeout: Duration, +} + +impl Default for PollTiming { + fn default() -> Self { + PollTiming { + interval: Duration::from_secs(2), + timeout: Duration::from_secs(90), + } + } +} + +/// Wait until the access point is `available` (a task mounting one that is +/// still `creating` fails to start). +pub async fn wait_access_point( + rt: &R, + id: &str, + timing: PollTiming, +) -> Result<(), AwsError> { + let deadline = tokio::time::Instant::now() + timing.timeout; + loop { + match rt.access_point_state(id).await?.as_deref() { + Some("available") => return Ok(()), + Some("creating") | Some("updating") => {} + Some(s) => { + return Err(other(format!( + "The project's EFS access point {id} is {s}; start the project again" + ))) + } + None => { + return Err(other(format!( + "The project's EFS access point {id} disappeared; start the project again" + ))) + } + } + if tokio::time::Instant::now() + timing.interval > deadline { + return Err(other(format!( + "The project's EFS access point {id} did not become available in time; \ + start the project again in a minute" + ))); + } + tokio::time::sleep(timing.interval).await; + } +} + +// ------------------------------------------------------------------ ARNs + +/// The cluster *name* of `arn:...:task//` (ECS accepts a name +/// wherever it takes a cluster). +pub fn cluster_of_task(task_arn: &str) -> Option<&str> { + let (_, rest) = task_arn.split_once(":task/")?; + let (cluster, id) = rest.split_once('/')?; + (task_arn.starts_with("arn:") && !cluster.is_empty() && !id.is_empty() && !id.contains('/')) + .then_some(cluster) +} + +/// The cluster ARN of a task ARN (same partition, region and account). +pub fn cluster_arn_of_task(task_arn: &str) -> Option { + let cluster = cluster_of_task(task_arn)?; + let (prefix, _) = task_arn.split_once(":task/")?; + Some(format!("{prefix}:cluster/{cluster}")) +} + +// ---------------------------------------------------------------- removal + +/// What removing a project's AWS resources needs to know. +pub struct RemoveRequest<'a> { + pub env: &'a str, + pub region: &'a str, + pub owner_user_id: &'a str, + pub owner_key: &'a str, + pub project_id: &'a str, + /// The task ARN the store has, if any (stopped too, in case the listing + /// missed it). + pub stored_task_arn: Option<&'a str>, + pub stored_access_point_id: Option<&'a str>, + /// Delete the EFS access point. Its data stays on the file system until + /// an administrator removes it; the path is not reused. + pub discard_data: bool, +} + +/// Stop the project's task, retire its task definition revisions, schedule +/// its secret for deletion (7 days) and, with `discard_data`, delete its +/// access point. A task that can't be stopped stops the removal (nothing +/// billed may be left running); the rest is attempted in full and every +/// failure reported. +pub async fn remove_workspace_resources( + tasks: &T, + work: &W, + rt: &R, + req: &RemoveRequest<'_>, +) -> Result<(), AwsError> { + let cluster = format!("triple-c-{}", req.env); + let family = task_family(req.env, req.owner_key, req.project_id); + + // 1. The task. + let live = + find_project_task(tasks, &cluster, &family, req.owner_user_id, req.project_id).await?; + let mut to_stop: Vec = live.into_iter().map(|t| t.arn).collect(); + if let Some(arn) = req.stored_task_arn { + if cluster_of_task(arn) == Some(cluster.as_str()) && !to_stop.iter().any(|a| a == arn) { + to_stop.push(arn.to_string()); + } + } + for arn in &to_stop { + tasks.stop_task(&cluster, arn, STOP_REASON).await?; + } + + let mut failures: Vec = Vec::new(); + + // 2. Task definition revisions the caller registered for this project. + let mut retired = Vec::new(); + match work.task_definition_revisions(&family).await { + Ok(revisions) => { + for arn in revisions { + match work.describe_task_definition(&arn).await { + Ok(Some(d)) + if d.owner.as_deref() == Some(req.owner_user_id) + && d.project.as_deref() == Some(req.project_id) => + { + match rt.deregister_task_definition(&d.arn).await { + Ok(()) => retired.push(d.arn), + Err(e) => failures.push(format!("task definition {}: {e}", d.arn)), + } + } + Ok(_) => {} + Err(e) => failures.push(format!("task definition {arn}: {e}")), + } + } + } + Err(e) => failures.push(format!("task definitions: {e}")), + } + // DeleteTaskDefinitions takes at most 10 at a time. + for chunk in retired.chunks(10) { + if let Err(e) = rt.delete_task_definitions(chunk).await { + failures.push(format!("deleting retired task definitions: {e}")); + } + } + + // 3. The secret. + let secret = project_secret_name(req.env, req.owner_key, req.project_id); + if let Err(e) = rt.delete_secret(&secret).await { + failures.push(format!("secret {secret}: {e}")); + } + + // 4. The access point, only when asked. + if req.discard_data { + let mut ids: Vec = req.stored_access_point_id.map(String::from).into_iter().collect(); + let token = user_file_system_token(req.env, req.owner_key); + match work.find_file_system(&token).await { + Ok(Some(fs)) if fs.owner.as_deref() == Some(req.owner_user_id) => { + let root = access_point_root(req.owner_key, req.project_id); + match work.access_points(&fs.id).await { + Ok(points) => ids.extend( + points + .into_iter() + .filter(|a| { + a.file_system_id == fs.id + && a.root_path.as_deref() == Some(root.as_str()) + && a.owner.as_deref() == Some(req.owner_user_id) + && a.project.as_deref() == Some(req.project_id) + }) + .map(|a| a.id), + ), + Err(e) => failures.push(format!("access points: {e}")), + } + } + Ok(_) => {} + Err(e) => failures.push(format!("file system: {e}")), + } + ids.sort(); + ids.dedup(); + for id in ids { + if let Err(e) = rt.delete_access_point(&id).await { + failures.push(format!("access point {id}: {e}")); + } + } + } + + if failures.is_empty() { + Ok(()) + } else { + Err(other(format!( + "Some of the project's AWS resources in {} could not be removed: {}", + req.region, + failures.join("; ") + ))) + } +} + +// ---------------------------------------------------------------- real API + +pub struct SdkRuntimeApi { + ssm: aws_sdk_ssm::Client, + iam: aws_sdk_iam::Client, + secrets: aws_sdk_secretsmanager::Client, + efs: aws_sdk_efs::Client, + ecs: aws_sdk_ecs::Client, +} + +impl SdkRuntimeApi { + pub fn new(cfg: &SdkConfig) -> Self { + SdkRuntimeApi { + ssm: aws_sdk_ssm::Client::new(cfg), + iam: aws_sdk_iam::Client::new(cfg), + secrets: aws_sdk_secretsmanager::Client::new(cfg), + efs: aws_sdk_efs::Client::new(cfg), + ecs: aws_sdk_ecs::Client::new(cfg), + } + } +} + +impl RuntimeApi for SdkRuntimeApi { + async fn environment_record(&self, env: &str) -> Result, AwsError> { + const ACTION: &str = "ssm:GetParameter"; + match self + .ssm + .get_parameter() + .name(environment_parameter_name(env)) + .with_decryption(false) + .send() + .await + { + Ok(out) => Ok(out.parameter().and_then(|p| p.value()).map(str::to_string)), + Err(e) + if e.as_service_error() + .is_some_and(|s| s.is_parameter_not_found()) => + { + Ok(None) + } + Err(e) => Err(classify_sdk_error(ACTION, &e)), + } + } + + async fn role_exists(&self, name: &str) -> Result { + let res = self.iam.get_role().role_name(name).send().await; + knowable("iam:GetRole", res) + } + + async fn secret_string(&self, name: &str) -> Result { + const ACTION: &str = "secretsmanager:GetSecretValue"; + let out = self + .secrets + .get_secret_value() + .secret_id(name) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + out.secret_string() + .map(str::to_string) + .ok_or_else(|| other(format!("{ACTION} returned no SecretString"))) + } + + async fn access_point_state(&self, id: &str) -> Result, AwsError> { + const ACTION: &str = "elasticfilesystem:DescribeAccessPoints"; + match self.efs.describe_access_points().access_point_id(id).send().await { + Ok(out) => Ok(out + .access_points() + .iter() + .find(|a| a.access_point_id() == Some(id)) + .and_then(|a| a.life_cycle_state()) + .map(|s| s.as_str().to_string())), + Err(e) + if e.as_service_error() + .is_some_and(|s| s.is_access_point_not_found()) => + { + Ok(None) + } + Err(e) => Err(classify_sdk_error(ACTION, &e)), + } + } + + async fn deregister_task_definition(&self, arn: &str) -> Result<(), AwsError> { + const ACTION: &str = "ecs:DeregisterTaskDefinition"; + self.ecs + .deregister_task_definition() + .task_definition(arn) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + Ok(()) + } + + async fn delete_task_definitions(&self, arns: &[String]) -> Result<(), AwsError> { + const ACTION: &str = "ecs:DeleteTaskDefinitions"; + let out = self + .ecs + .delete_task_definitions() + .set_task_definitions(Some(arns.to_vec())) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + let failed: Vec = out + .failures() + .iter() + .map(|f| { + format!( + "{} ({})", + f.arn().unwrap_or("?"), + f.reason().unwrap_or("no reason given") + ) + }) + .collect(); + if failed.is_empty() { + Ok(()) + } else { + Err(other(format!("{ACTION} failed for {}", failed.join(", ")))) + } + } + + async fn delete_secret(&self, name: &str) -> Result<(), AwsError> { + const ACTION: &str = "secretsmanager:DeleteSecret"; + match self + .secrets + .delete_secret() + .secret_id(name) + .force_delete_without_recovery(false) + .recovery_window_in_days(SECRET_RECOVERY_DAYS) + .send() + .await + { + Ok(_) => Ok(()), + Err(e) + if e.as_service_error() + .is_some_and(|s| s.is_resource_not_found_exception()) => + { + Ok(()) + } + // Already scheduled: the goal. + Err(e) + if e.as_service_error().is_some_and(|s| { + s.is_invalid_request_exception() + && s.message().is_some_and(|m| m.contains("scheduled for deletion")) + }) => + { + Ok(()) + } + Err(e) => Err(classify_sdk_error(ACTION, &e)), + } + } + + async fn delete_access_point(&self, id: &str) -> Result<(), AwsError> { + const ACTION: &str = "elasticfilesystem:DeleteAccessPoint"; + match self.efs.delete_access_point().access_point_id(id).send().await { + Ok(_) => Ok(()), + Err(e) + if e.as_service_error() + .is_some_and(|s| s.is_access_point_not_found()) => + { + Ok(()) + } + Err(e) => Err(classify_sdk_error(ACTION, &e)), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/app/src-tauri/src/aws/runtime/tests.rs b/app/src-tauri/src/aws/runtime/tests.rs new file mode 100644 index 0000000..7c3334c --- /dev/null +++ b/app/src-tauri/src/aws/runtime/tests.rs @@ -0,0 +1,341 @@ +use std::sync::Mutex; + +use aws_sdk_ecs::operation::register_task_definition::RegisterTaskDefinitionInput; +use aws_sdk_ecs::operation::run_task::RunTaskInput; + +use super::*; +use crate::aws::tasks::TaskInfo; +use crate::aws::tofu_modules::owner_key; +use crate::aws::workspace::{AccessPointRef, FileSystemRef, TaskDefinitionRef}; + +const REGION: &str = "eu-west-1"; +const ACCOUNT: &str = "111122223333"; +const USER: &str = "AROAEXAMPLEID:alice@example.com"; +const PROJECT: &str = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"; + +fn ok() -> String { + owner_key(REGION, USER) +} + +fn family() -> String { + task_family("dev", &ok(), PROJECT) +} + +fn task_arn(id: &str) -> String { + format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task/triple-c-dev/{id}") +} + +#[derive(Default)] +struct Fake { + calls: Mutex>, + record: Option, + role: Option, + secret: Option, + ap_states: Mutex>>, + live_tasks: Vec, + revisions: Vec, + access_points: Vec, + fail_secret_delete: bool, +} + +impl Fake { + fn log(&self, s: String) { + self.calls.lock().unwrap().push(s); + } + fn calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } +} + +impl RuntimeApi for Fake { + async fn environment_record(&self, env: &str) -> Result, AwsError> { + self.log(format!("record {env}")); + Ok(self.record.clone()) + } + async fn role_exists(&self, name: &str) -> Result { + self.log(format!("role {name}")); + Ok(self.role.clone().unwrap_or(Knowable::Yes)) + } + async fn secret_string(&self, name: &str) -> Result { + self.log(format!("secret {name}")); + self.secret.clone().ok_or_else(|| other("no secret")) + } + async fn access_point_state(&self, id: &str) -> Result, AwsError> { + self.log(format!("ap {id}")); + let mut s = self.ap_states.lock().unwrap(); + Ok(if s.len() > 1 { s.remove(0) } else { s.first().cloned().flatten() }) + } + async fn deregister_task_definition(&self, arn: &str) -> Result<(), AwsError> { + self.log(format!("deregister {arn}")); + Ok(()) + } + async fn delete_task_definitions(&self, arns: &[String]) -> Result<(), AwsError> { + self.log(format!("delete-td {}", arns.join(","))); + Ok(()) + } + async fn delete_secret(&self, name: &str) -> Result<(), AwsError> { + self.log(format!("delete-secret {name}")); + if self.fail_secret_delete { + return Err(other("denied")); + } + Ok(()) + } + async fn delete_access_point(&self, id: &str) -> Result<(), AwsError> { + self.log(format!("delete-ap {id}")); + Ok(()) + } +} + +impl TaskApi for Fake { + async fn run_task(&self, _req: RunTaskInput) -> Result { + unreachable!("removal never runs a task") + } + async fn describe(&self, _c: &str, arns: &[String]) -> Result, AwsError> { + Ok(self.live_tasks.iter().filter(|t| arns.contains(&t.arn)).cloned().collect()) + } + async fn list_project_tasks(&self, _c: &str, _f: &str) -> Result, AwsError> { + Ok(self.live_tasks.iter().map(|t| t.arn.clone()).collect()) + } + async fn stop_task(&self, cluster: &str, arn: &str, reason: &str) -> Result<(), AwsError> { + self.log(format!("stop {cluster} {arn} {reason}")); + Ok(()) + } + async fn log_tail(&self, _g: &str, _s: &str, _l: i32) -> Result, AwsError> { + Ok(Vec::new()) + } +} + +impl WorkspaceApi for Fake { + async fn find_file_system(&self, _token: &str) -> Result, AwsError> { + Ok(Some(FileSystemRef { id: "fs-1".into(), owner: Some(USER.into()) })) + } + async fn access_points(&self, _fs: &str) -> Result, AwsError> { + Ok(self.access_points.clone()) + } + async fn create_access_point( + &self, + _fs: &str, + _root: &str, + _tags: &[(String, String)], + ) -> Result { + unreachable!() + } + async fn put_project_secret( + &self, + _n: &str, + _k: &str, + _j: &str, + _t: &[(String, String)], + ) -> Result { + unreachable!() + } + async fn task_definition_revisions(&self, _f: &str) -> Result, AwsError> { + Ok(self.revisions.iter().map(|r| r.arn.clone()).collect()) + } + async fn describe_task_definition( + &self, + arn: &str, + ) -> Result, AwsError> { + Ok(self.revisions.iter().find(|r| r.arn == arn).cloned()) + } + async fn register_task_definition( + &self, + _req: RegisterTaskDefinitionInput, + ) -> Result { + unreachable!() + } +} + +fn record() -> String { + serde_json::json!({ + "schema": 1, "name": "dev", "template_version": 1, + "cluster_arn": format!("arn:aws:ecs:{REGION}:{ACCOUNT}:cluster/triple-c-dev"), + "security_group_id": "sg-0123456789abcdef0", + "subnet_ids": ["subnet-0a", "subnet-0b"], + "assign_public_ip": true, + "kms_key_arn": format!("arn:aws:kms:{REGION}:{ACCOUNT}:key/1234-abcd"), + "log_group": "/triple-c/dev", "network_mode": "public", "tenancy": "strict" + }) + .to_string() +} + +#[tokio::test] +async fn the_environment_is_read_with_one_lookup_and_validated() { + let fake = Fake { record: Some(record()), ..Default::default() }; + let env = read_environment(&fake, "dev", ACCOUNT, REGION).await.unwrap(); + assert_eq!(env.log_group, "/triple-c/dev"); + assert_eq!(fake.calls(), vec!["record dev"]); + + let missing = Fake::default(); + let e = read_environment(&missing, "dev", ACCOUNT, REGION).await.unwrap_err(); + assert!(e.to_string().contains("was not found"), "{e}"); + + // A record from another account is refused, by field name only. + let foreign = Fake { record: Some(record()), ..Default::default() }; + let e = read_environment(&foreign, "dev", "999999999999", REGION).await.unwrap_err(); + assert!(e.to_string().contains("cluster_arn"), "{e}"); +} + +#[tokio::test] +async fn a_missing_task_role_means_not_joined_and_a_denied_lookup_lets_the_start_try() { + let no = Fake { role: Some(Knowable::No), ..Default::default() }; + let e = check_onboarded(&no, "dev", &ok()).await.unwrap_err(); + assert!(e.to_string().contains("not joined"), "{e}"); + assert_eq!(no.calls(), vec![format!("role triple-c-dev-{}-task", ok())]); + let unknown = Fake { role: Some(Knowable::Unknown), ..Default::default() }; + assert!(check_onboarded(&unknown, "dev", &ok()).await.is_ok()); +} + +#[test] +fn the_agent_token_is_read_from_the_secret_without_echoing_it() { + let json = r#"{"TRIPLEC_AGENT_TOKEN":"abc-DEF_123","OTHER":"SENTINEL-VALUE-7f3a"}"#; + assert_eq!(agent_token_from_secret(json).unwrap(), "abc-DEF_123"); + for bad in [ + r#"{"OTHER":"SENTINEL-VALUE-7f3a"}"#, + r#"{"TRIPLEC_AGENT_TOKEN":""}"#, + r#"{"TRIPLEC_AGENT_TOKEN":"has space"}"#, + "SENTINEL-VALUE-7f3a", + ] { + let e = agent_token_from_secret(bad).unwrap_err(); + assert!(!e.contains("SENTINEL") && !e.contains("space"), "{e}"); + } +} + +#[tokio::test] +async fn the_token_comes_from_the_projects_own_secret() { + let fake = Fake { + secret: Some(r#"{"TRIPLEC_AGENT_TOKEN":"tok"}"#.into()), + ..Default::default() + }; + assert_eq!(read_agent_token(&fake, "dev", &ok(), PROJECT).await.unwrap(), "tok"); + assert_eq!(fake.calls(), vec![format!("secret triple-c/dev/{}/{PROJECT}", ok())]); +} + +#[tokio::test(start_paused = true)] +async fn a_new_access_point_is_waited_for_until_available() { + let fake = Fake { + ap_states: Mutex::new(vec![Some("creating".into()), Some("creating".into()), Some("available".into())]), + ..Default::default() + }; + wait_access_point(&fake, "fsap-1", PollTiming::default()).await.unwrap(); + assert_eq!(fake.calls().len(), 3); + + let stuck = Fake { ap_states: Mutex::new(vec![Some("creating".into())]), ..Default::default() }; + let e = wait_access_point(&stuck, "fsap-1", PollTiming::default()).await.unwrap_err(); + assert!(e.to_string().contains("did not become available"), "{e}"); + + let gone = Fake { ap_states: Mutex::new(vec![Some("deleting".into())]), ..Default::default() }; + assert!(wait_access_point(&gone, "fsap-1", PollTiming::default()).await.is_err()); +} + +#[test] +fn task_arns_name_their_cluster() { + let arn = task_arn("abc123"); + assert_eq!(cluster_of_task(&arn), Some("triple-c-dev")); + assert_eq!( + cluster_arn_of_task(&arn).as_deref(), + Some(format!("arn:aws:ecs:{REGION}:{ACCOUNT}:cluster/triple-c-dev").as_str()) + ); + assert_eq!(cluster_of_task("arn:aws:ecs:r:a:task/abc"), None, "old format, no cluster"); + assert_eq!(cluster_of_task("task/triple-c-dev/abc"), None); + assert_eq!(cluster_of_task("arn:aws:ecs:r:a:task/c/a/b"), None); +} + +fn live(id: &str) -> TaskInfo { + TaskInfo { + arn: task_arn(id), + last_status: "RUNNING".into(), + desired_status: "RUNNING".into(), + health: None, + stop_code: None, + stopped_reason: None, + task_definition_arn: format!( + "arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{}:2", + family() + ), + runtime_id: None, + exec_agent_running: false, + owner: Some(USER.into()), + project: Some(PROJECT.into()), + } +} + +fn rev(n: u32, owner: &str) -> TaskDefinitionRef { + TaskDefinitionRef { + arn: format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{}:{n}", family()), + family: family(), + owner: Some(owner.into()), + project: Some(PROJECT.into()), + fingerprint: None, + } +} + +#[tokio::test] +async fn removal_stops_the_task_retires_own_revisions_and_schedules_the_secret() { + let fake = Fake { + live_tasks: vec![live("t1")], + revisions: vec![rev(2, USER), rev(3, "AROAOTHER:mallory"), rev(1, USER)], + access_points: vec![AccessPointRef { + id: "fsap-found".into(), + file_system_id: "fs-1".into(), + root_path: Some(access_point_root(&ok(), PROJECT)), + owner: Some(USER.into()), + project: Some(PROJECT.into()), + state: Some("available".into()), + }], + ..Default::default() + }; + let key = ok(); + let t0 = task_arn("t0"); + let mut r = RemoveRequest { + env: "dev", + region: REGION, + owner_user_id: USER, + owner_key: &key, + project_id: PROJECT, + stored_task_arn: Some(&t0), + stored_access_point_id: Some("fsap-stored"), + discard_data: false, + }; + remove_workspace_resources(&fake, &fake, &fake, &r).await.unwrap(); + let calls = fake.calls(); + let secret = project_secret_name("dev", &ok(), PROJECT); + assert_eq!( + calls, + vec![ + format!("stop triple-c-dev {} {STOP_REASON}", task_arn("t1")), + format!("stop triple-c-dev {} {STOP_REASON}", task_arn("t0")), + format!("deregister {}", rev(2, USER).arn), + format!("deregister {}", rev(1, USER).arn), + format!("delete-td {},{}", rev(2, USER).arn, rev(1, USER).arn), + format!("delete-secret {secret}"), + ], + "another user's revision is left alone and the access point is kept" + ); + + let keep = Fake { access_points: fake.access_points.clone(), ..Default::default() }; + r.discard_data = true; + remove_workspace_resources(&keep, &keep, &keep, &r).await.unwrap(); + let calls = keep.calls(); + assert!(calls.contains(&"delete-ap fsap-found".to_string()), "{calls:?}"); + assert!(calls.contains(&"delete-ap fsap-stored".to_string()), "{calls:?}"); +} + +#[tokio::test] +async fn a_failed_step_is_reported_but_the_rest_still_runs() { + let fake = Fake { fail_secret_delete: true, ..Default::default() }; + let key = ok(); + let r = RemoveRequest { + env: "dev", + region: REGION, + owner_user_id: USER, + owner_key: &key, + project_id: PROJECT, + stored_task_arn: None, + stored_access_point_id: Some("fsap-1"), + discard_data: true, + }; + let e = remove_workspace_resources(&fake, &fake, &fake, &r).await.unwrap_err(); + assert!(e.to_string().contains("secret"), "{e}"); + assert!(fake.calls().contains(&"delete-ap fsap-1".to_string())); +} -- 2.52.0 From e05f7e3cd8d38802bd3a0273de512efecfe709a8 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 09:15:37 -0800 Subject: [PATCH 10/43] feat(aws): start, attach and stop a project on its AWS environment ProjectHost::Aws and the tunnel registry: start looks for the project's live task before preparing (a running task is attached to with the token read back from its secret, never rotated), waits for a new access point, runs, waits, tunnels and checks the agent; a task the start ran is stopped on any later failure. Stop, remove (resources; data only on request), reconcile (background re-attach) and the AWS death watch, with exits confirmed through the registry. Docker-only Reset, migration, staleness and container info refuse or skip AWS projects; an AWS project with resources can't be moved off its host. Shutdown closes every tunnel. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 + app/src-tauri/src/aws/mod.rs | 5 +- app/src-tauri/src/aws/tasks.rs | 3 + app/src-tauri/src/aws/workspace.rs | 1 + app/src-tauri/src/commands/docker_commands.rs | 5 + .../src/commands/migration_commands.rs | 8 + .../src/commands/project_commands.rs | 165 +++- .../src/commands/terminal_commands.rs | 4 +- app/src-tauri/src/container_watch.rs | 17 +- app/src-tauri/src/docker/container.rs | 2 +- app/src-tauri/src/docker/workspace_env.rs | 33 + app/src-tauri/src/host/aws_host.rs | 833 ++++++++++++++++++ app/src-tauri/src/host/aws_host/tests.rs | 543 ++++++++++++ app/src-tauri/src/host/lifecycle.rs | 64 +- app/src-tauri/src/host/mod.rs | 24 +- app/src-tauri/src/host/ssm.rs | 9 + app/src-tauri/src/host/tunnels.rs | 301 +++++++ app/src-tauri/src/host/watch.rs | 10 +- app/src-tauri/src/lib.rs | 16 + app/src-tauri/src/web_terminal/ws_handler.rs | 3 +- 20 files changed, 2023 insertions(+), 29 deletions(-) create mode 100644 app/src-tauri/src/host/aws_host.rs create mode 100644 app/src-tauri/src/host/aws_host/tests.rs create mode 100644 app/src-tauri/src/host/tunnels.rs diff --git a/CLAUDE.md b/CLAUDE.md index a41b63d..649da53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -292,6 +292,12 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li when it drops, so an `AgentEndpoint` stays valid. The `StartSession` token goes to the plugin through its environment, never argv, and the plugin's environment is stripped of every inherited `AWS_*` variable before the session's own credentials are added. + `aws_host.rs` is `ProjectHost::Aws` (an ECS Fargate task per project; a running task is + attached to with the token read back from its secret, never rotated; a task a failed start ran + is stopped again) and `tunnels.rs` the registry of open tunnels by project — an AWS project's + `ContainerTarget::for_project` is its tunnel's endpoint, its stored `container_id` is that + endpoint's URL, and the AWS death watch confirms exits through the registry. Shutdown closes + every tunnel. AWS SDK calls stay in `aws/` (`runtime.rs`, `tasks.rs`, `workspace.rs`). `agent.rs` reuses `docker/exec.rs`'s `OneshotOutput`, host-file reader and uid probe so the two variants cannot drift; an `Agent` target's token never appears in `Debug` or error text. **Container-interior code must go through `ContainerTarget`, never `docker::exec`, bollard or diff --git a/app/src-tauri/src/aws/mod.rs b/app/src-tauri/src/aws/mod.rs index 90bbfe1..90b011a 100644 --- a/app/src-tauri/src/aws/mod.rs +++ b/app/src-tauri/src/aws/mod.rs @@ -13,13 +13,10 @@ pub mod permissions; pub mod policies; #[cfg(test)] mod policy_eval; -// Used by the AWS host (phase 5, Task 6); until then only by its tests. -#[allow(dead_code)] +pub mod runtime; pub mod tasks; pub mod tofu; pub mod tofu_modules; -// Used by the AWS host (phase 5, Task 6); until then only by its tests. -#[allow(dead_code)] pub mod workspace; pub use connection::{sdk_config, sdk_config_for_keys}; diff --git a/app/src-tauri/src/aws/tasks.rs b/app/src-tauri/src/aws/tasks.rs index 5ac3dc9..41b0e89 100644 --- a/app/src-tauri/src/aws/tasks.rs +++ b/app/src-tauri/src/aws/tasks.rs @@ -337,6 +337,7 @@ pub fn quota_or(e: AwsError) -> AwsError { } } +#[allow(dead_code)] // for the UI's start errors (Task 8) pub fn is_quota_error(e: &AwsError) -> bool { matches!(e, AwsError::QuotaExceeded { .. }) } @@ -480,6 +481,7 @@ async fn stopped_error(api: &A, logs: &TaskLogs, t: &TaskInfo) -> Aw // ----------------------------------------------------------- stop / watch /// Why a task stopped, as the app reports it (spec, "Lifecycle and status"). +#[allow(dead_code)] // the stop reasons the UI shows (Task 8) #[derive(Debug, Clone, PartialEq, Eq)] pub enum StopKind { /// Exited on its own (the essential container ended, idle stop, or AWS @@ -493,6 +495,7 @@ pub enum StopKind { UserInitiated, } +#[allow(dead_code)] // Task 8 pub fn classify_stop(info: &TaskInfo) -> StopKind { match info.stop_code.as_deref() { Some("SpotInterruption") => StopKind::Spot, diff --git a/app/src-tauri/src/aws/workspace.rs b/app/src-tauri/src/aws/workspace.rs index e45a995..2da99b5 100644 --- a/app/src-tauri/src/aws/workspace.rs +++ b/app/src-tauri/src/aws/workspace.rs @@ -204,6 +204,7 @@ fn serialize_payload(m: &BTreeMap) -> Result { /// `TRIPLEC_REPOS`, `TRIPLEC_WIP_PUSH` = `1`/`0`, `TRIPLEC_IDLE_STOP_MINUTES` /// when on, `TRIPLEC_STORAGE` = `ephemeral`/`hybrid`), keys sorted. Fails /// when it would exceed 65,536 bytes. +#[cfg_attr(not(test), allow(dead_code))] pub fn project_secret_payload( env_pairs: &[(String, String)], agent_token: &str, diff --git a/app/src-tauri/src/commands/docker_commands.rs b/app/src-tauri/src/commands/docker_commands.rs index 598a42a..e0a561b 100644 --- a/app/src-tauri/src/commands/docker_commands.rs +++ b/app/src-tauri/src/commands/docker_commands.rs @@ -34,6 +34,11 @@ pub async fn get_container_info( .projects_store .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; + // An AWS project has no Docker container (its own name could match a + // stale one from before it moved to AWS). + if project.is_aws() { + return Ok(None); + } docker::get_container_info(&project).await } diff --git a/app/src-tauri/src/commands/migration_commands.rs b/app/src-tauri/src/commands/migration_commands.rs index 6771e37..593ab98 100644 --- a/app/src-tauri/src/commands/migration_commands.rs +++ b/app/src-tauri/src/commands/migration_commands.rs @@ -432,6 +432,11 @@ pub async fn get_container_staleness( .projects_store .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; + // An AWS task has no local container or snapshot to compare: no claim + // either way ("unknown"), and nothing is probed. + if project.is_aws() { + return Ok(ContainerStaleness::default()); + } let settings = state.settings_store.get(); let base_image = crate::models::container_config::resolve_image_name( &settings.image_source, @@ -708,6 +713,9 @@ pub async fn migrate_project_to_base( app_handle: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { + if let Some(p) = state.projects_store.get(&project_id) { + crate::commands::project_commands::refuse_aws(&p, "Updating to the current base image")?; + } let _guard = match ActiveGuard::acquire(&project_id) { Ok(guard) => guard, Err(busy) => return Ok(MigrationReport::failed_preflight(&busy)), diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index 842c457..9264838 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -655,6 +655,7 @@ pub async fn add_project( #[tauri::command] pub async fn remove_project( project_id: String, + discard_data: Option, state: State<'_, AppState>, ) -> Result { // **H-2: the only writer of these three categories that held nothing.** @@ -673,6 +674,32 @@ pub async fn remove_project( let _guard = crate::project_lock::try_acquire(&project_id, crate::project_lock::ProjectOp::Destroy)?; + // An AWS project's task and resources go first, and a failure keeps the + // project: its record is the only handle on a billed task and its + // secret. Its data (the EFS access point) goes only when asked. + if let Some(project) = state.projects_store.get(&project_id).filter(|p| p.is_aws()) { + let settings = state.settings_store.get(); + if let Some(target) = ContainerTarget::for_project(&project) { + state.exec_manager.close_sessions_for_container(target.id()).await; + } + let connection_exists = project + .host_id + .as_deref() + .is_some_and(|id| settings.hosts.iter().any(|h| h.id == id)); + if connection_exists { + ProjectHost::for_project(&project) + .remove_aws_resources(&project, &settings, discard_data.unwrap_or(false)) + .await?; + } else { + log::warn!( + "Project {}: its AWS connection no longer exists, so its AWS resources can't be \ + removed from here", + project_id + ); + crate::host::tunnels::close(&project_id).await; + } + } + // Release any host loopback ports the auth bridge holds for this project // before the container (and the project record) go away. state.auth_bridge.stop(&project_id).await; @@ -1150,11 +1177,23 @@ fn validate_aws_update( ) } } - if (stored.host_id != project.host_id || stored.aws_placement != project.aws_placement) - && stored.status != ProjectStatus::Stopped - { + let moved = stored.host_id != project.host_id || stored.aws_placement != project.aws_placement; + if moved && stored.status != ProjectStatus::Stopped { return Err("Stop the project before changing its host or placement".into()); } + // The task, access point and task definition live in the stored + // connection's account and region; moving the project would orphan them. + if moved + && stored.aws.as_ref().is_some_and(|a| { + a.task_arn.is_some() || a.access_point_id.is_some() || a.task_definition_arn.is_some() + }) + { + return Err( + "This project still has AWS resources (a task, its data's access point or task \ + definitions) where it runs now; remove the workspace's AWS resources first" + .into(), + ); + } Ok(()) } @@ -1359,6 +1398,21 @@ async fn start_project_container_locked( // Update project with container info using granular methods (Issue 14: TOCTOU) state.projects_store.set_container_id(&project_id, Some(container_id.clone()))?; + // AWS: the task's handles, store-owned like the container id. The target + // id stored above is the tunnel's endpoint URL, which is what the death + // watch reports a stopped task as. + if project.is_aws() { + if let Some((task_arn, access_point_id, task_definition_arn)) = + crate::host::tunnels::runtime(&project_id) + { + state.projects_store.set_aws_runtime( + &project_id, + Some(task_arn), + access_point_id, + Some(task_definition_arn), + )?; + } + } state.projects_store.update_status(&project_id, ProjectStatus::Running)?; // Arm the auth bridge if this project opted in. Purely host-side, so it @@ -1404,7 +1458,30 @@ pub async fn stop_project_container( // Drop host listeners first: they only make sense while the container runs. state.auth_bridge.stop(&project_id).await; - if let Some(container) = ContainerTarget::for_project(&project) { + if project.is_aws() { + // A failed StopTask leaves a billed task running, so unlike Docker it + // is not papered over: the project keeps its status and the user + // sees why (expired SSO, typically), and can retry. + emit_progress(&app_handle, &project_id, "Stopping task..."); + if let Some(container) = ContainerTarget::for_project(&project) { + state.exec_manager.close_sessions_for_container(container.id()).await; + } + let settings = state.settings_store.get(); + if let Err(e) = ProjectHost::for_project(&project).stop_project(&project, &settings).await { + log::warn!("Stop failed for project {}: {}", project_id, e); + let _ = state.projects_store.update_status(&project_id, project.status.clone()); + return Err(format!("Could not stop the task: {e}")); + } + if let Some(a) = project.aws.as_ref() { + state.projects_store.set_aws_runtime( + &project_id, + None, + a.access_point_id.clone(), + a.task_definition_arn.clone(), + )?; + } + state.projects_store.set_container_id(&project_id, None)?; + } else if let Some(container) = ContainerTarget::for_project(&project) { // Close exec sessions for this project emit_progress(&app_handle, &project_id, "Stopping container..."); state.exec_manager.close_sessions_for_container(container.id()).await; @@ -1438,6 +1515,7 @@ pub async fn rebuild_project_container( .projects_store .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; + refuse_aws(&project, "Reset")?; // Reset supersedes any migration decision that was still pending: the // snapshot image and both volumes are about to go, so a surviving record @@ -1513,6 +1591,20 @@ pub async fn rebuild_project_container( /// [`crate::commands::migration_commands::reconcile_migration`]. The migration /// pass runs over *every* project, not just the Running ones, because a project /// whose container was removed mid-migration reports Stopped. +/// How long reconcile waits on AWS for one project. +const AWS_RECONCILE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); + +/// Refuse a Docker-only operation on an AWS project. +pub(crate) fn refuse_aws(project: &Project, what: &str) -> Result<(), String> { + if project.is_aws() { + return Err(format!( + "{what} is not available for a project that runs on AWS; it applies to local \ + Docker containers only" + )); + } + Ok(()) +} + #[tauri::command] pub async fn reconcile_project_statuses( app_handle: tauri::AppHandle, @@ -1520,7 +1612,7 @@ pub async fn reconcile_project_statuses( ) -> Result, String> { let projects = state.projects_store.list(); - for project in &projects { + for project in projects.iter().filter(|p| !p.is_aws()) { crate::commands::migration_commands::reconcile_migration(project, &app_handle).await; } @@ -1554,6 +1646,28 @@ pub async fn reconcile_project_statuses( continue; } + // AWS: ECS is the authority. One DescribeTasks per project, bounded + // so a slow or unreachable AWS can't hold the app's start; a live + // task's tunnel is re-opened in the background. + if project.is_aws() { + let settings = state.settings_store.get(); + let store = state.projects_store.clone(); + if tokio::time::timeout( + AWS_RECONCILE_TIMEOUT, + crate::host::aws_host::reconcile(project, &settings, store), + ) + .await + .is_err() + { + log::warn!( + "Project {}: AWS did not answer within {}s; status left as is", + project.id, + AWS_RECONCILE_TIMEOUT.as_secs() + ); + } + continue; + } + let is_running = if let Some(container) = ContainerTarget::for_project(project) { container.is_running().await.unwrap_or(false) } else { @@ -2279,4 +2393,45 @@ mod tests { let mut edited = running.clone(); edited.aws.as_mut().unwrap().spot = true; assert!(validate_aws_update(&running, &edited, &hosts).is_ok()); } + + #[test] + fn a_project_with_aws_resources_cannot_be_moved_off_its_host() { + let hosts = vec![host("h1"), host("h2")]; + let mut stored = aws_project(); + stored.aws.as_mut().unwrap().access_point_id = Some("fsap-1".into()); + let mut local = stored.clone(); + local.host_id = None; + local.aws_placement = None; + local.aws = None; + let e = validate_aws_update(&stored, &local, &hosts).unwrap_err(); + assert!(e.contains("remove the workspace's AWS resources first"), "{e}"); + let mut other = stored.clone(); + other.host_id = Some("h2".into()); + assert!(validate_aws_update(&stored, &other, &hosts).is_err()); + let mut moved = stored.clone(); + moved.aws_placement.as_mut().unwrap().environment = "prod".into(); + assert!(validate_aws_update(&stored, &moved, &hosts).is_err()); + for set in [ + |a: &mut crate::models::AwsWorkspaceConfig| a.task_arn = Some("t".into()), + |a: &mut crate::models::AwsWorkspaceConfig| a.task_definition_arn = Some("td".into()), + ] { + let mut st = aws_project(); + set(st.aws.as_mut().unwrap()); + assert!(validate_aws_update(&st, &local, &hosts).is_err()); + } + // No handles: free to move. Other edits are fine with handles. + assert!(validate_aws_update(&aws_project(), &local, &hosts).is_ok()); + let mut edited = stored.clone(); + edited.aws.as_mut().unwrap().cpu = 2048; + edited.aws.as_mut().unwrap().memory_mib = 8192; + assert!(validate_aws_update(&stored, &edited, &hosts).is_ok()); + } + + #[test] + fn docker_only_operations_refuse_aws_projects() { + assert!(refuse_aws(&aws_project(), "Reset").unwrap_err().contains("Reset is not available")); + let mut local = aws_project(); + local.host_id = None; + assert!(refuse_aws(&local, "Reset").is_ok()); + } } diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index a1ddfd7..9bbeb05 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -160,7 +160,9 @@ pub async fn open_terminal_session( .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; - let target = ContainerTarget::for_project(&project) + // Waits while an AWS project's tunnel is being re-opened after a restart. + let target = ContainerTarget::for_project_waiting(&project) + .await .ok_or_else(|| "Container not running".to_string())?; if let Some(id) = resume_session_id.as_deref() { diff --git a/app/src-tauri/src/container_watch.rs b/app/src-tauri/src/container_watch.rs index 3f3887c..aedefde 100644 --- a/app/src-tauri/src/container_watch.rs +++ b/app/src-tauri/src/container_watch.rs @@ -219,13 +219,28 @@ pub fn spawn( cancel: watch::Receiver, ) -> tauri::async_runtime::JoinHandle<()> { tauri::async_runtime::spawn(async move { - run(store, source, cancel, |p, reason| { + let store_for_aws = store.clone(); + run(store, source, cancel, move |p, reason| { log::warn!( "Project '{}' ({}): its container stopped without the app asking ({:?}) — marking Stopped", p.name, p.id, reason ); + // An AWS task that stopped: its tunnel leads nowhere now, and its + // task ARN is history (the data's access point stays). + if p.is_aws() { + if let Some(a) = p.aws.as_ref() { + let _ = store_for_aws.set_aws_runtime( + &p.id, + None, + a.access_point_id.clone(), + a.task_definition_arn.clone(), + ); + } + let id = p.id.clone(); + tauri::async_runtime::spawn(async move { crate::host::tunnels::close(&id).await }); + } let _ = app.emit(CONTAINER_EXITED_EVENT, ContainerExited { project: p, reason }); }) .await; diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index b506a23..701026c 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -402,7 +402,7 @@ pub(super) fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String { /// /// The returned token is never logged and never leaves this module except as /// the env var value handed to Docker. -fn shared_claude_auth(project: &Project) -> Option<(String, String)> { +pub(super) fn shared_claude_auth(project: &Project) -> Option<(String, String)> { if project.backend != Backend::Anthropic || !project.use_shared_auth_token { return None; } diff --git a/app/src-tauri/src/docker/workspace_env.rs b/app/src-tauri/src/docker/workspace_env.rs index cd8f3d9..046eb76 100644 --- a/app/src-tauri/src/docker/workspace_env.rs +++ b/app/src-tauri/src/docker/workspace_env.rs @@ -419,6 +419,39 @@ pub(crate) fn workspace_env( env } + +/// [`workspace_env`] with its inputs resolved from `settings` exactly as +/// `create_container` resolves them (the CA from disk, the shared Claude +/// token from the keychain), for a host that does not create a Docker +/// container. Also returns the resolved CA, whose files the caller ships. +pub(crate) fn workspace_env_from_settings( + project: &Project, + settings: &crate::models::AppSettings, +) -> Result { + let shared_claude = super::container::shared_claude_auth(project); + let effective_ca_path = + resolve_with_global(project.ca_cert_path.as_deref(), settings.ca_cert_path.as_deref()); + let resolved_ca = ca_certs::resolve(effective_ca_path)?; + let pairs = workspace_env( + project, + &WorkspaceEnvInputs { + global_aws: &settings.global_aws, + global_ollama: &settings.global_ollama, + global_llamacpp: &settings.global_llamacpp, + global_openai_compatible: &settings.global_openai_compatible, + global_claude_instructions: settings.global_claude_instructions.as_deref(), + global_custom_env_vars: &settings.global_custom_env_vars, + timezone: settings.timezone.as_deref(), + global_claude_code_settings: settings.global_claude_code_settings.as_ref(), + default_git_user_name: settings.default_git_user_name.as_deref(), + default_git_user_email: settings.default_git_user_email.as_deref(), + resolved_ca: resolved_ca.as_ref(), + shared_claude_token: shared_claude.as_ref().map(|(token, _)| token.as_str()), + }, + ); + Ok((pairs, resolved_ca)) +} + #[cfg(test)] mod tests { use super::super::ca_certs::ResolvedCa; diff --git a/app/src-tauri/src/host/aws_host.rs b/app/src-tauri/src/host/aws_host.rs new file mode 100644 index 0000000..c784d76 --- /dev/null +++ b/app/src-tauri/src/host/aws_host.rs @@ -0,0 +1,833 @@ +//! `ProjectHost::Aws`: a project's container is an ECS Fargate task in one +//! of the account's environments, reached through an SSM port-forward to +//! the in-task agent (`ssm.rs`, `agent.rs`). +//! +//! Start: connection → `SdkConfig` → caller identity → environment record → +//! onboarding → the project's live task, if there is one (its token is read +//! back from the project secret, P2 — **a running task's token is never +//! rotated**); otherwise `prepare_workspace` (which rotates the token) → +//! access point available → RunTask → ready → tunnel → agent health → +//! registry ([`super::tunnels`]). A task this start ran is stopped again on +//! any later failure, so nothing billed is left behind. +//! +//! The decisions are made by [`start_core`] over the `aws/` traits and a +//! [`Connector`], so fakes exercise them; AWS SDK calls stay in `aws/`. + +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use aws_config::SdkConfig; +use futures_util::future::BoxFuture; + +use super::agent::{self, AgentEndpoint}; +use super::ssm::{EcsTask, PortForward, AGENT_PORT}; +use super::tunnels::{self, LiveTunnel, TunnelHandle}; +use super::watch::{AwsTaskLookup, TaskGroup, WatchedTask}; +use super::ContainerTarget; +use crate::aws::environments::EnvironmentInfo; +use crate::aws::runtime::{ + check_onboarded, cluster_arn_of_task, cluster_of_task, read_agent_token, read_environment, + remove_workspace_resources, wait_access_point, PollTiming, RemoveRequest, RuntimeApi, + SdkRuntimeApi, +}; +use crate::aws::tasks::{ + find_or_run, find_project_task, stopped_among, task_log_stream, wait_ready, SdkTaskApi, + TaskApi, TaskInfo, TaskLogs, WaitTiming, LOG_TAIL_LINES, STOP_REASON, +}; +use crate::aws::tofu_modules::{log_stream_prefix, owner_key, task_family}; +use crate::aws::workspace::{prepare_workspace, SdkWorkspaceApi, WorkspaceApi}; +use crate::aws::{caller_identity, sdk_config, CallerIdentity}; +use crate::docker::ca_certs::{container_cert_name, ResolvedCa}; +use crate::models::app_settings::ImageSource; +use crate::models::container_config::REGISTRY_IMAGE; +use crate::models::{AppSettings, Project, ProjectStatus}; +use crate::storage::projects_store::ProjectsStore; +use crate::storage::settings_store::SettingsStore; + +// Progress strings the start view shows, in order (Task 8 matches on none +// of them; they are for the user). +pub const PROGRESS_READING: &str = "Reading environment…"; +pub const PROGRESS_PREPARING: &str = "Preparing workspace…"; +pub const PROGRESS_STARTING: &str = "Starting task…"; +pub const PROGRESS_WAITING: &str = "Waiting for the task (about 2 minutes)…"; +pub const PROGRESS_CONNECTING: &str = "Connecting…"; + +/// A concurrent start won the RunTask race: the secret now holds a token +/// that task never saw. +pub const START_RACE: &str = + "Another start of this workspace is in progress; try again in a minute"; + +/// Per-file cap on what is shipped in the project secret (R1). +pub const MAX_FILE_BYTES: u64 = 64 * 1024; +/// How long the agent gets to answer through a fresh tunnel. +const HEALTH_TIMEOUT: Duration = Duration::from_secs(30); +const HEALTH_INTERVAL: Duration = Duration::from_secs(1); +/// How long a terminal waits for a background re-attach. +pub const REATTACH_WAIT: Duration = Duration::from_secs(90); + +fn s(e: E) -> String { + e.to_string() +} + +// ─────────────────────────────────────────────────────────────── inputs + +/// The image an AWS task runs (P5): registry images only. +pub fn aws_image(settings: &AppSettings) -> Result { + match settings.image_source { + ImageSource::Registry => Ok(REGISTRY_IMAGE.to_string()), + ImageSource::LocalBuild => { + log::info!("AWS projects run the registry image; the local build exists only on this machine"); + Ok(REGISTRY_IMAGE.to_string()) + } + ImageSource::Custom => { + let name = settings.custom_image_name.as_deref().unwrap_or("").trim(); + if is_registry_reference(name) { + Ok(name.to_string()) + } else { + Err(format!( + "The custom image \"{}\" is not a registry reference, so AWS can't pull it. \ + Use a name like registry.example.com/team/image:tag (Settings → Docker).", + name.escape_debug() + )) + } + } + } +} + +/// `host/path[:tag]` or `user/repo[:tag]`: something Fargate can pull. A +/// bare name (a local tag) or a loopback registry is not. +fn is_registry_reference(name: &str) -> bool { + if name.is_empty() || name.len() > 512 || name.chars().any(|c| c.is_whitespace() || c.is_control()) { + return false; + } + let Some((first, rest)) = name.split_once('/') else { + return false; + }; + if first.is_empty() || rest.is_empty() || rest.starts_with('/') { + return false; + } + let host = first.split(':').next().unwrap_or(""); + !(host == "localhost" || host.starts_with("127.") || host == "[::1]") +} + +/// [`aws_env`]'s answer: the pairs, and the resolved CA whose files ship too. +pub type AwsEnv = (Vec<(String, String)>, Option); + +/// The environment pairs an AWS task gets: [`workspace_env`] with the same +/// inputs Docker resolves, and Claude Code's sandbox forced off (R4: Fargate +/// support for bubblewrap is unverified). Returns the resolved CA too. +/// +/// [`workspace_env`]: crate::docker::workspace_env::workspace_env +pub fn aws_env(project: &Project, settings: &AppSettings) -> Result { + let mut p = project.clone(); + if p.sandbox_mode_enabled { + log::info!( + "Project {}: Claude Code's sandbox is turned off on AWS (not yet supported on Fargate)", + project.id + ); + p.sandbox_mode_enabled = false; + } + crate::docker::workspace_env::workspace_env_from_settings(&p, settings) +} + +/// One file read for the secret, refusing anything over [`MAX_FILE_BYTES`]. +fn read_capped(path: &Path, shown: &str) -> Result, String> { + let len = std::fs::metadata(path) + .map_err(|e| format!("Could not read {shown}: {e}"))? + .len(); + if len > MAX_FILE_BYTES { + return Err(format!( + "{shown} is {len} bytes; files sent to an AWS workspace are limited to {} KiB each", + MAX_FILE_BYTES / 1024 + )); + } + std::fs::read(path).map_err(|e| format!("Could not read {shown}: {e}")) +} + +/// The regular files directly in `dir`: subdirectories skipped, and a +/// symlink only when it resolves to a file inside `dir`. +fn files_in(dir: &Path) -> Result, String> { + let root = std::fs::canonicalize(dir) + .map_err(|e| format!("Could not read {}: {e}", dir.display()))?; + let mut out = Vec::new(); + let entries = + std::fs::read_dir(&root).map_err(|e| format!("Could not read {}: {e}", dir.display()))?; + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = entry.file_name().to_str().map(str::to_string) else { + log::warn!("Skipping {} (not a UTF-8 name)", path.display()); + continue; + }; + let Ok(ft) = entry.file_type() else { continue }; + let real = if ft.is_file() { + path.clone() + } else if ft.is_symlink() { + match std::fs::canonicalize(&path) { + Ok(t) if t.starts_with(&root) && t.is_file() => t, + _ => { + log::warn!("Skipping {} (a link out of the folder, or not to a file)", path.display()); + continue; + } + } + } else { + continue; + }; + out.push((name, real)); + } + out.sort(); + Ok(out) +} + +/// The files the entrypoint writes to `/tmp/.host-ssh` and `/tmp/.host-ca` +/// (R1), as `ssh/` / `ca/` pairs: the project's effective SSH +/// key folder (or file) and the resolved CA, the same sources Docker mounts. +pub fn workspace_files( + ssh_path: Option<&str>, + ca: Option<&ResolvedCa>, +) -> Result)>, String> { + let mut out = Vec::new(); + if let Some(ssh) = ssh_path.map(str::trim).filter(|p| !p.is_empty()) { + crate::commands::project_commands::validate_mounted_host_path( + "the SSH key folder", + None, + Some(ssh), + )?; + let path = Path::new(ssh); + if path.is_dir() { + for (name, real) in files_in(path)? { + let bytes = read_capped(&real, &format!("SSH file {name}"))?; + out.push((format!("ssh/{name}"), bytes)); + } + } else if path.is_file() { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| format!("The SSH key path {ssh} has no usable file name"))?; + out.push((format!("ssh/{name}"), read_capped(path, &format!("SSH file {name}"))?)); + } else { + return Err(format!("The SSH key path {ssh} does not exist")); + } + } + if let Some(ca) = ca { + crate::commands::project_commands::validate_mounted_host_path( + "the CA certificate path", + None, + Some(&ca.host_path), + )?; + if ca.is_dir { + let allowed: Vec = ca.cert_files.clone(); + for (name, real) in files_in(Path::new(&ca.host_path))? { + // Only what `collect_cert_files` picked (by extension). + let picked = allowed.iter().any(|f| { + f.file_name().and_then(|n| n.to_str()) == Some(name.as_str()) + }); + if picked { + out.push((format!("ca/{name}"), read_capped(&real, &format!("certificate {name}"))?)); + } + } + } else { + let path = Path::new(&ca.host_path); + let name = container_cert_name( + &path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), + ); + out.push((format!("ca/{name}"), read_capped(path, &format!("certificate {name}"))?)); + } + } + Ok(out) +} + +// ──────────────────────────────────────────────────────────── connector + +/// Opens the tunnel to a ready task and checks its agent answers. +pub trait Connector: Send + Sync { + fn connect( + &self, + task: &TaskInfo, + token: &str, + ) -> impl Future, AgentEndpoint), String>> + Send; +} + +/// The real one: an SSM port-forward, then `/v1/health` through it. +pub struct SsmConnector { + pub sdk: SdkConfig, +} + +/// The SSM target of a ready task's `workspace` container. +pub fn ecs_task(task: &TaskInfo) -> Result { + let cluster = cluster_of_task(&task.arn) + .ok_or_else(|| format!("{} is not a task ARN with a cluster", task.arn))?; + let task_id = task.arn.rsplit('/').next().unwrap_or_default().to_string(); + let runtime_id = task + .runtime_id + .clone() + .ok_or_else(|| "The task's container has no runtime id yet".to_string())?; + Ok(EcsTask { cluster: cluster.to_string(), task_id, runtime_id }) +} + +/// Wait for the agent behind `ep` to answer. A refusal (wrong token) is +/// final; silence is retried until [`HEALTH_TIMEOUT`]. +async fn wait_healthy(ep: &AgentEndpoint) -> Result<(), String> { + let deadline = tokio::time::Instant::now() + HEALTH_TIMEOUT; + loop { + match agent::is_running(ep).await { + Ok(true) => return Ok(()), + Ok(false) => {} + Err(e) => return Err(format!("The workspace's agent refused the connection: {e}")), + } + if tokio::time::Instant::now() + HEALTH_INTERVAL > deadline { + return Err( + "The workspace's agent did not answer through the tunnel. Check that the image \ + includes triple-c-agent, then start the project again." + .into(), + ); + } + tokio::time::sleep(HEALTH_INTERVAL).await; + } +} + +impl Connector for SsmConnector { + async fn connect( + &self, + task: &TaskInfo, + token: &str, + ) -> Result<(Box, AgentEndpoint), String> { + let target = ecs_task(task)?; + let forward = PortForward::open(&self.sdk, &target, AGENT_PORT).await?; + let ep = forward.endpoint(token); + if let Err(e) = wait_healthy(&ep).await { + forward.close().await; + return Err(e); + } + Ok((Box::new(forward), ep)) + } +} + +// ─────────────────────────────────────────────────────────────── start + +/// What [`start_core`] needs besides the APIs. +pub struct StartInputs<'a> { + pub project: &'a Project, + pub who: &'a CallerIdentity, + pub env_pairs: &'a [(String, String)], + pub files: &'a [(String, Vec)], + pub image: &'a str, + pub wait: WaitTiming, + pub access_point_wait: PollTiming, +} + +/// A connected task. +pub struct Started { + pub env: EnvironmentInfo, + pub task: TaskInfo, + pub forward: Box, + pub endpoint: AgentEndpoint, + /// The access point the task mounts (Hybrid). `None` when an existing + /// task was attached to: the store's value stands. + pub access_point_id: Option, + /// This start ran the task (rather than attaching to a running one). + pub created: bool, +} + +/// Append the task's last log lines to a wait failure that lacks them. +async fn with_log_tail(api: &T, logs: &TaskLogs, arn: &str, err: String) -> String { + if err.contains("Last log lines") || err.contains("wrote no log lines") || err.contains("log could not be read") { + return err; + } + match api.log_tail(&logs.group, &task_log_stream(&logs.stream_prefix, arn), LOG_TAIL_LINES).await { + Ok(lines) if !lines.is_empty() => format!("{err}\nLast log lines:\n{}", lines.join("\n")), + _ => err, + } +} + +/// Stop a task this start ran, best-effort, so nothing billed is orphaned. +async fn stop_created(api: &T, cluster: &str, arn: &str) { + if let Err(e) = api.stop_task(cluster, arn, STOP_REASON).await { + log::warn!("Could not stop task {arn} after a failed start: {e}"); + } +} + +/// The start sequence; see the module docs. +pub async fn start_core( + tasks: &T, + work: &W, + rt: &R, + connector: &C, + inp: &StartInputs<'_>, + progress: &(dyn Fn(&str) + Send + Sync), +) -> Result { + let project = inp.project; + let (placement, cfg) = match (&project.aws_placement, &project.aws) { + (Some(p), Some(c)) if project.is_aws() => (p, c), + _ => return Err("The project is not set up to run on AWS".into()), + }; + let region = placement.region.as_str(); + let ok = owner_key(region, &inp.who.user_id); + + progress(PROGRESS_READING); + let env = read_environment(rt, &placement.environment, &inp.who.account, region) + .await + .map_err(s)?; + check_onboarded(rt, &env.name, &ok).await.map_err(s)?; + let family = task_family(&env.name, &ok, &project.id); + let logs = TaskLogs { + group: env.log_group.clone(), + stream_prefix: log_stream_prefix(&ok, &project.id), + }; + + let existing = find_project_task(tasks, &env.cluster_arn, &family, &inp.who.user_id, &project.id) + .await + .map_err(s)?; + + let (task, token, access_point_id, created) = if let Some(task) = existing { + // Attach: never rotate a running task's token (P2). + log::info!("Project {}: attaching to its running task {}", project.id, task.arn); + let token = read_agent_token(rt, &env.name, &ok, &project.id).await.map_err(s)?; + progress(PROGRESS_WAITING); + let task = match wait_ready(tasks, &env.cluster_arn, &task.arn, &logs, inp.wait, |_| {}).await { + Ok(t) => t, + Err(e) => return Err(with_log_tail(tasks, &logs, &task.arn, e.to_string()).await), + }; + (task, token, None, false) + } else { + progress(PROGRESS_PREPARING); + let prepared = prepare_workspace( + work, + inp.who, + &env, + project, + inp.env_pairs, + inp.files, + inp.image, + ) + .await + .map_err(s)?; + if let Some(ap) = &prepared.access_point_id { + wait_access_point(rt, ap, inp.access_point_wait).await.map_err(s)?; + } + progress(PROGRESS_STARTING); + let start = find_or_run( + tasks, + &env, + &inp.who.user_id, + &project.id, + &prepared.task_definition_arn, + cfg.spot, + ) + .await + .map_err(s)?; + if start.reused { + return Err(START_RACE.into()); + } + progress(PROGRESS_WAITING); + let arn = start.task.arn.clone(); + let task = match wait_ready(tasks, &env.cluster_arn, &arn, &logs, inp.wait, |_| {}).await { + Ok(t) => t, + Err(e) => { + let msg = with_log_tail(tasks, &logs, &arn, e.to_string()).await; + stop_created(tasks, &env.cluster_arn, &arn).await; + return Err(msg); + } + }; + (task, prepared.agent_token, prepared.access_point_id, true) + }; + + progress(PROGRESS_CONNECTING); + match connector.connect(&task, &token).await { + Ok((forward, endpoint)) => Ok(Started { + env, + task, + forward, + endpoint, + access_point_id, + created, + }), + Err(e) => { + if created { + stop_created(tasks, &env.cluster_arn, &task.arn).await; + } + Err(e) + } + } +} + +/// The connection, region and SDK config of an AWS project. +async fn project_sdk(project: &Project, settings: &AppSettings) -> Result<(String, String, SdkConfig), String> { + let host_id = project.host_id.as_deref().ok_or("The project has no host")?; + let region = project + .aws_placement + .as_ref() + .map(|p| p.region.clone()) + .ok_or("The project has no AWS placement")?; + let conn = crate::commands::host_commands::aws_conn(host_id, settings)?; + let sdk = sdk_config(&conn, ®ion).await.map_err(s)?; + Ok((host_id.to_string(), region, sdk)) +} + +/// `ProjectHost::Aws::ensure_started`. +pub async fn ensure_started( + project: &Project, + settings: &AppSettings, + progress: &(dyn Fn(&str) + Send + Sync), +) -> Result { + // Already connected and answering: nothing to do. + if let Some(ep) = tunnels::get_endpoint(&project.id) { + if matches!(agent::is_running(&ep).await, Ok(true)) { + return Ok(ContainerTarget::Agent(ep)); + } + tunnels::close(&project.id).await; + } + + // Everything local first: a bad image or file fails before any AWS call. + let image = aws_image(settings)?; + let (env_pairs, ca) = aws_env(project, settings)?; + let ssh = project + .ssh_key_path + .as_deref() + .or(settings.default_ssh_key_path.as_deref()); + let files = workspace_files(ssh, ca.as_ref())?; + + let (connection_id, region, sdk) = project_sdk(project, settings).await?; + let who = caller_identity(&sdk).await.map_err(s)?; + let started = start_core( + &SdkTaskApi::new(&sdk), + &SdkWorkspaceApi::new(&sdk), + &SdkRuntimeApi::new(&sdk), + &SsmConnector { sdk: sdk.clone() }, + &StartInputs { + project, + who: &who, + env_pairs: &env_pairs, + files: &files, + image: &image, + wait: WaitTiming::default(), + access_point_wait: PollTiming::default(), + }, + progress, + ) + .await?; + + log::info!( + "Project {}: {} task {}", + project.id, + if started.created { "started" } else { "attached to" }, + started.task.arn + ); + let endpoint = started.endpoint.clone(); + let access_point_id = started + .access_point_id + .or_else(|| project.aws.as_ref().and_then(|a| a.access_point_id.clone())); + let ecs = ecs_task(&started.task)?; + if let Some(old) = tunnels::register( + &project.id, + LiveTunnel { + task: ecs, + task_arn: started.task.arn.clone(), + cluster_arn: started.env.cluster_arn.clone(), + connection_id, + region, + sdk, + forward: started.forward, + endpoint: endpoint.clone(), + access_point_id, + task_definition_arn: started.task.task_definition_arn.clone(), + }, + ) { + old.forward.close().await; + } + Ok(ContainerTarget::Agent(endpoint)) +} + +// ──────────────────────────────────────────────────────────────── stop + +/// StopTask the task in `task_arn`'s cluster. +pub async fn stop_task_arn(api: &T, task_arn: &str) -> Result<(), String> { + let cluster = cluster_of_task(task_arn) + .ok_or_else(|| format!("{task_arn} is not a task ARN with a cluster"))?; + api.stop_task(cluster, task_arn, STOP_REASON).await.map_err(s) +} + +/// `ProjectHost::stop` for an agent target: the task behind a registered +/// tunnel. `None` when `ep` is not one of ours. +pub async fn stop_endpoint(ep: &AgentEndpoint) -> Option> { + let (_connection, _region, cluster, arn) = tunnels::task_of(ep)?; + let (project_id, sdk) = tunnels::owner_of(ep)?; + let res = SdkTaskApi::new(&sdk) + .stop_task(&cluster, &arn, STOP_REASON) + .await + .map_err(s); + if res.is_ok() { + tunnels::close(&project_id).await; + } + Some(res) +} + +/// Stop an AWS project's task: through its tunnel when there is one, else +/// the stored task ARN, else whatever live task the project has (a crash +/// between RunTask and the store write). Closes the tunnel. +pub async fn stop_project(project: &Project, settings: &AppSettings) -> Result<(), String> { + if let Some(ep) = tunnels::get_endpoint(&project.id) { + if let Some(res) = stop_endpoint(&ep).await { + return res; + } + } + let (_, region, sdk) = project_sdk(project, settings).await?; + let api = SdkTaskApi::new(&sdk); + let stored = project.aws.as_ref().and_then(|a| a.task_arn.clone()); + let arn = match stored { + Some(a) => Some(a), + None => { + let env = project.aws_placement.as_ref().map(|p| p.environment.clone()).unwrap_or_default(); + let who = caller_identity(&sdk).await.map_err(s)?; + let ok = owner_key(®ion, &who.user_id); + find_project_task(&api, &format!("triple-c-{env}"), &task_family(&env, &ok, &project.id), &who.user_id, &project.id) + .await + .map_err(s)? + .map(|t| t.arn) + } + }; + if let Some(arn) = arn { + stop_task_arn(&api, &arn).await?; + } + tunnels::close(&project.id).await; + Ok(()) +} + +// ────────────────────────────────────────────────────────────── remove + +/// Stop the task and remove the project's AWS resources (task definition +/// revisions, the secret with a 7-day window, and with `discard_data` the +/// access point). +pub async fn remove_resources( + project: &Project, + settings: &AppSettings, + discard_data: bool, +) -> Result<(), String> { + tunnels::close(&project.id).await; + let (_, region, sdk) = project_sdk(project, settings).await?; + let who = caller_identity(&sdk).await.map_err(s)?; + let ok = owner_key(®ion, &who.user_id); + let env = project.aws_placement.as_ref().map(|p| p.environment.clone()).unwrap_or_default(); + let aws = project.aws.as_ref(); + remove_workspace_resources( + &SdkTaskApi::new(&sdk), + &SdkWorkspaceApi::new(&sdk), + &SdkRuntimeApi::new(&sdk), + &RemoveRequest { + env: &env, + region: ®ion, + owner_user_id: &who.user_id, + owner_key: &ok, + project_id: &project.id, + stored_task_arn: aws.and_then(|a| a.task_arn.as_deref()), + stored_access_point_id: aws.and_then(|a| a.access_point_id.as_deref()), + discard_data, + }, + ) + .await + .map_err(s) +} + +// ─────────────────────────────────────────────────────────── reconcile + +/// What reconcile found for a Running AWS project. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Reconciled { + /// The task is up (or on its way): re-open the tunnel. + Live(Box), + /// The task stopped, or ECS no longer knows it: the project is Stopped. + Gone, +} + +/// Ask ECS about a project's stored task. No stored task is `Gone`. +pub async fn reconcile_task(api: &T, task_arn: Option<&str>) -> Result { + let Some(arn) = task_arn else { + return Ok(Reconciled::Gone); + }; + let Some(cluster) = cluster_of_task(arn) else { + return Ok(Reconciled::Gone); + }; + let found = api.describe(cluster, &[arn.to_string()]).await.map_err(s)?; + Ok(match found.into_iter().find(|t| t.arn == arn) { + Some(t) + if t.desired_status == "RUNNING" + && matches!(t.last_status.as_str(), "PROVISIONING" | "PENDING" | "ACTIVATING" | "RUNNING") => + { + Reconciled::Live(Box::new(t)) + } + _ => Reconciled::Gone, + }) +} + +/// Reconcile one AWS project the store has as Running (or Starting/ +/// Stopping/Error left by a crash): a gone task makes it Stopped (its task +/// ARN cleared); a live one keeps it Running. An ECS that can't be asked +/// changes nothing — marking Stopped would hide a billed task. +pub async fn reconcile_project( + api: &T, + store: &ProjectsStore, + project: &Project, +) -> Result { + let arn = project.aws.as_ref().and_then(|a| a.task_arn.clone()); + let r = reconcile_task(api, arn.as_deref()).await?; + match &r { + Reconciled::Gone => { + store.update_status(&project.id, ProjectStatus::Stopped)?; + if let Some(a) = project.aws.as_ref() { + store.set_aws_runtime(&project.id, None, a.access_point_id.clone(), a.task_definition_arn.clone())?; + } + } + Reconciled::Live(_) => { + if project.status != ProjectStatus::Running { + store.update_status(&project.id, ProjectStatus::Running)?; + } + } + } + Ok(r) +} + +/// `reconcile_project_statuses` for an AWS project: decide, and when the +/// task is live re-open its tunnel in the background (terminals wait for +/// it through [`tunnels::wait_endpoint`]). Never blocks on the tunnel. +pub async fn reconcile(project: &Project, settings: &AppSettings, store: Arc) { + let (connection_id, region, sdk) = match project_sdk(project, settings).await { + Ok(v) => v, + Err(e) => { + log::warn!("Project {}: could not reach AWS to reconcile ({e}); status left as is", project.id); + return; + } + }; + let api = SdkTaskApi::new(&sdk); + match reconcile_project(&api, &store, project).await { + Ok(Reconciled::Live(task)) => { + tunnels::set_pending(&project.id, true); + let project = project.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = reattach(&project, *task, connection_id, region, sdk, &store).await { + log::warn!("Project {}: could not re-open the tunnel to its task: {e}", project.id); + tunnels::set_pending(&project.id, false); + } + }); + } + Ok(Reconciled::Gone) => { + log::info!("Project '{}' ({}): its task is gone — set to Stopped", project.name, project.id) + } + Err(e) => log::warn!("Project {}: could not check its task ({e}); status left as is", project.id), + } +} + +/// Re-open the tunnel to a live task found at startup. +async fn reattach( + project: &Project, + task: TaskInfo, + connection_id: String, + region: String, + sdk: SdkConfig, + store: &ProjectsStore, +) -> Result<(), String> { + let env = project.aws_placement.as_ref().map(|p| p.environment.clone()).unwrap_or_default(); + let who = caller_identity(&sdk).await.map_err(s)?; + let ok = owner_key(®ion, &who.user_id); + let rt = SdkRuntimeApi::new(&sdk); + let api = SdkTaskApi::new(&sdk); + let token = read_agent_token(&rt, &env, &ok, &project.id).await.map_err(s)?; + let cluster_arn = cluster_arn_of_task(&task.arn).ok_or("bad task ARN")?; + let logs = TaskLogs { group: format!("/triple-c/{env}"), stream_prefix: log_stream_prefix(&ok, &project.id) }; + let task = wait_ready(&api, &cluster_arn, &task.arn, &logs, WaitTiming::default(), |_| {}) + .await + .map_err(s)?; + let (forward, endpoint) = SsmConnector { sdk: sdk.clone() }.connect(&task, &token).await?; + if let Some(old) = tunnels::register( + &project.id, + LiveTunnel { + task: ecs_task(&task)?, + task_arn: task.arn.clone(), + cluster_arn, + connection_id, + region, + sdk, + forward, + endpoint: endpoint.clone(), + access_point_id: project.aws.as_ref().and_then(|a| a.access_point_id.clone()), + task_definition_arn: task.task_definition_arn.clone(), + }, + ) { + old.forward.close().await; + } + // The local port is new, so the stored target id is too. + store.set_container_id(&project.id, Some(endpoint.base_url.to_string()))?; + log::info!("Project {}: tunnel to task {} re-opened", project.id, task.arn); + Ok(()) +} + +// ─────────────────────────────────────────────────────────────── watch + +/// The AWS death watch's lookup: Running AWS projects from the store, their +/// targets from the registry (or, while unattached, an endpoint with the +/// stored id so `container_watch::settle` still matches), and +/// `stopped_among` per connection/region/cluster. +pub struct StoreTaskLookup { + pub projects: Arc, + pub settings: Arc, +} + +/// A Running AWS project's watched task, if it has one. +pub fn watched_task(p: &Project) -> Option { + if p.status != ProjectStatus::Running || !p.is_aws() { + return None; + } + let arn = p.aws.as_ref()?.task_arn.clone()?; + let target = match tunnels::get_endpoint(&p.id) { + Some(ep) => ep, + None => AgentEndpoint::new(url::Url::parse(p.container_id.as_deref()?).ok()?, "unattached").ok()?, + }; + if Some(target.base_url.as_str()) != p.container_id.as_deref() { + return None; + } + Some(WatchedTask { + target: ContainerTarget::Agent(target), + group: TaskGroup { + connection_id: p.host_id.clone()?, + region: p.aws_placement.as_ref()?.region.clone(), + cluster_arn: cluster_arn_of_task(&arn)?, + }, + task_arn: arn, + }) +} + +impl AwsTaskLookup for StoreTaskLookup { + fn running_tasks(&self) -> Vec { + self.projects.list().iter().filter_map(watched_task).collect() + } + + fn stopped<'a>( + &'a self, + group: &'a TaskGroup, + arns: &'a [String], + ) -> BoxFuture<'a, Result, String>> { + Box::pin(async move { + let settings = self.settings.get(); + let conn = crate::commands::host_commands::aws_conn(&group.connection_id, &settings)?; + let sdk = sdk_config(&conn, &group.region).await.map_err(s)?; + let stopped = stopped_among(&SdkTaskApi::new(&sdk), &group.cluster_arn, arns) + .await + .map_err(s)?; + record_stopped(&self.projects.list(), &stopped); + Ok(stopped) + }) + } +} + +/// Tell the registry which targets' tasks ECS reports stopped, so +/// `has_exited` confirms them (R3). +pub fn record_stopped(projects: &[Project], stopped: &[String]) { + for w in projects.iter().filter_map(watched_task) { + if stopped.contains(&w.task_arn) { + tunnels::mark_stopped(w.target.id()); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/app/src-tauri/src/host/aws_host/tests.rs b/app/src-tauri/src/host/aws_host/tests.rs new file mode 100644 index 0000000..afa31b7 --- /dev/null +++ b/app/src-tauri/src/host/aws_host/tests.rs @@ -0,0 +1,543 @@ +use std::collections::HashMap; +use std::sync::atomic::AtomicUsize; +use std::sync::Mutex; + +use aws_sdk_ecs::operation::register_task_definition::RegisterTaskDefinitionInput; +use aws_sdk_ecs::operation::run_task::RunTaskInput; + +use super::*; +use crate::aws::environments::Knowable; +use crate::aws::tasks::TaskInfo; +use crate::aws::workspace::{AccessPointRef, FileSystemRef, TaskDefinitionRef}; +use crate::aws::AwsError; +use crate::host::lifecycle::ProjectHost; +use crate::host::tunnels::tests::{fake_tunnel, FakeTunnel}; +use crate::models::{AwsPlacement, AwsWorkspaceConfig, StorageMode}; + +const REGION: &str = "eu-west-1"; +const ACCOUNT: &str = "111122223333"; +const USER: &str = "AROAEXAMPLEID:alice@example.com"; + +fn who() -> CallerIdentity { + CallerIdentity { + account: ACCOUNT.into(), + arn: format!("arn:aws:sts::{ACCOUNT}:assumed-role/dev/alice"), + user_id: USER.into(), + } +} + +fn aws_project(storage: StorageMode) -> Project { + let mut p = Project::new("aws".into(), Vec::new()); + p.host_id = Some("c1".into()); + p.aws_placement = Some(AwsPlacement { region: REGION.into(), environment: "dev".into() }); + p.aws = Some(AwsWorkspaceConfig { storage, ..Default::default() }); + p +} + +fn family(p: &Project) -> String { + task_family("dev", &owner_key(REGION, USER), &p.id) +} + +fn task_arn(id: &str) -> String { + format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task/triple-c-dev/{id}") +} + +fn cluster_arn() -> String { + format!("arn:aws:ecs:{REGION}:{ACCOUNT}:cluster/triple-c-dev") +} + +fn task(p: &Project, id: &str, last: &str, ready: bool) -> TaskInfo { + TaskInfo { + arn: task_arn(id), + last_status: last.into(), + desired_status: if last == "STOPPED" { "STOPPED" } else { "RUNNING" }.into(), + health: None, + stop_code: (last == "STOPPED").then(|| "EssentialContainerExited".into()), + stopped_reason: (last == "STOPPED").then(|| "Essential container exited".into()), + task_definition_arn: format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{}:1", family(p)), + runtime_id: ready.then(|| "rt-1".into()), + exec_agent_running: ready, + owner: Some(USER.into()), + project: Some(p.id.clone()), + } +} + +fn record() -> String { + serde_json::json!({ + "schema": 1, "name": "dev", "template_version": 1, + "cluster_arn": cluster_arn(), + "security_group_id": "sg-0123456789abcdef0", + "subnet_ids": ["subnet-0a", "subnet-0b"], + "assign_public_ip": true, + "kms_key_arn": format!("arn:aws:kms:{REGION}:{ACCOUNT}:key/1234-abcd"), + "log_group": "/triple-c/dev", "network_mode": "public", "tenancy": "strict" + }) + .to_string() +} + +/// One fake for all three AWS traits and the connector, logging calls. +#[derive(Default)] +struct Fake { + calls: Mutex>, + /// ListTasks answers in turn; the last repeats. + listed: Mutex>>, + tasks: Mutex>, + /// What RunTask starts. + run: Option, + logs: Vec, + role: Option, + connect_fails: bool, + connect_tokens: Mutex>, + secret_json: Mutex>, +} + +impl Fake { + fn log(&self, s: impl Into) { + self.calls.lock().unwrap().push(s.into()); + } + fn calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } + fn called(&self, prefix: &str) -> bool { + self.calls().iter().any(|c| c.starts_with(prefix)) + } + fn with_task(self, t: TaskInfo) -> Self { + self.tasks.lock().unwrap().insert(t.arn.clone(), t); + self + } +} + +impl TaskApi for Fake { + async fn run_task(&self, _req: RunTaskInput) -> Result { + self.log("run"); + let t = self.run.clone().expect("a run was expected"); + self.tasks.lock().unwrap().insert(t.arn.clone(), t.clone()); + Ok(t) + } + async fn describe(&self, _c: &str, arns: &[String]) -> Result, AwsError> { + let t = self.tasks.lock().unwrap(); + Ok(arns.iter().filter_map(|a| t.get(a).cloned()).collect()) + } + async fn list_project_tasks(&self, _c: &str, _f: &str) -> Result, AwsError> { + self.log("list"); + let mut l = self.listed.lock().unwrap(); + Ok(if l.len() > 1 { l.remove(0) } else { l.first().cloned().unwrap_or_default() }) + } + async fn stop_task(&self, _c: &str, arn: &str, reason: &str) -> Result<(), AwsError> { + self.log(format!("stop {arn} {reason}")); + Ok(()) + } + async fn log_tail(&self, _g: &str, _s: &str, _l: i32) -> Result, AwsError> { + Ok(self.logs.clone()) + } +} + +impl WorkspaceApi for Fake { + async fn find_file_system(&self, _t: &str) -> Result, AwsError> { + Ok(Some(FileSystemRef { id: "fs-1".into(), owner: Some(USER.into()) })) + } + async fn access_points(&self, _fs: &str) -> Result, AwsError> { + Ok(Vec::new()) + } + async fn create_access_point( + &self, + _fs: &str, + _root: &str, + _tags: &[(String, String)], + ) -> Result { + self.log("create-ap"); + Ok("fsap-new".into()) + } + async fn put_project_secret( + &self, + _name: &str, + _kms: &str, + json: &str, + _tags: &[(String, String)], + ) -> Result { + self.log("put-secret"); + *self.secret_json.lock().unwrap() = Some(json.into()); + Ok(format!("arn:aws:secretsmanager:{REGION}:{ACCOUNT}:secret:triple-c/dev/x-AbCdEf")) + } + async fn task_definition_revisions(&self, _f: &str) -> Result, AwsError> { + Ok(Vec::new()) + } + async fn describe_task_definition( + &self, + _arn: &str, + ) -> Result, AwsError> { + Ok(None) + } + async fn register_task_definition( + &self, + req: RegisterTaskDefinitionInput, + ) -> Result { + self.log("register"); + Ok(format!( + "arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{}:1", + req.family().unwrap() + )) + } +} + +impl RuntimeApi for Fake { + async fn environment_record(&self, env: &str) -> Result, AwsError> { + self.log(format!("record {env}")); + Ok(Some(record())) + } + async fn role_exists(&self, _name: &str) -> Result { + self.log("role"); + Ok(self.role.clone().unwrap_or(Knowable::Yes)) + } + async fn secret_string(&self, _name: &str) -> Result { + self.log("get-secret"); + Ok(r#"{"TRIPLEC_AGENT_TOKEN":"token-of-the-running-task"}"#.into()) + } + async fn access_point_state(&self, id: &str) -> Result, AwsError> { + self.log(format!("ap-state {id}")); + Ok(Some("available".into())) + } + async fn deregister_task_definition(&self, _arn: &str) -> Result<(), AwsError> { + Ok(()) + } + async fn delete_task_definitions(&self, _arns: &[String]) -> Result<(), AwsError> { + Ok(()) + } + async fn delete_secret(&self, _name: &str) -> Result<(), AwsError> { + Ok(()) + } + async fn delete_access_point(&self, _id: &str) -> Result<(), AwsError> { + Ok(()) + } +} + +impl Connector for Fake { + async fn connect( + &self, + _task: &TaskInfo, + token: &str, + ) -> Result<(Box, AgentEndpoint), String> { + self.log("connect"); + self.connect_tokens.lock().unwrap().push(token.into()); + if self.connect_fails { + return Err("session-manager-plugin was not found".into()); + } + Ok(( + Box::new(FakeTunnel(std::sync::Arc::new(AtomicUsize::new(0)))), + AgentEndpoint::loopback(61100, token), + )) + } +} + +fn inputs<'a>(p: &'a Project, who: &'a CallerIdentity) -> StartInputs<'a> { + StartInputs { + project: p, + who, + env_pairs: &[], + files: &[], + image: REGISTRY_IMAGE, + wait: WaitTiming { + interval: Duration::from_millis(1), + timeout: Duration::from_millis(30), + }, + access_point_wait: PollTiming { + interval: Duration::from_millis(1), + timeout: Duration::from_millis(30), + }, + } +} + +async fn start(fake: &Fake, p: &Project) -> (Result, Vec) { + let w = who(); + let steps = Mutex::new(Vec::new()); + let progress = |m: &str| steps.lock().unwrap().push(m.to_string()); + let r = start_core(fake, fake, fake, fake, &inputs(p, &w), &progress).await; + (r, steps.into_inner().unwrap()) +} + +#[tokio::test] +async fn a_first_start_prepares_runs_waits_and_connects_in_that_order() { + let p = aws_project(StorageMode::Hybrid); + let fake = Fake { run: Some(task(&p, "new", "RUNNING", true)), ..Default::default() }; + let (r, steps) = start(&fake, &p).await; + let started = r.unwrap(); + assert!(started.created); + assert_eq!(started.access_point_id.as_deref(), Some("fsap-new")); + assert_eq!(started.task.arn, task_arn("new")); + assert_eq!( + fake.calls(), + vec![ + "record dev", "role", "list", "create-ap", "put-secret", "register", + "ap-state fsap-new", "list", "run", "connect" + ], + "look for a live task before rotating the token; the access point is available before RunTask" + ); + assert_eq!(steps, vec![PROGRESS_READING, PROGRESS_PREPARING, PROGRESS_STARTING, PROGRESS_WAITING, PROGRESS_CONNECTING]); + // The connector got the token the secret was just written with. + let secret: serde_json::Value = + serde_json::from_str(fake.secret_json.lock().unwrap().as_deref().unwrap()).unwrap(); + assert_eq!( + fake.connect_tokens.lock().unwrap()[0], + secret["TRIPLEC_AGENT_TOKEN"].as_str().unwrap() + ); +} + +#[tokio::test] +async fn a_running_task_is_attached_with_its_own_token_and_nothing_is_rewritten() { + let p = aws_project(StorageMode::Hybrid); + let live = task(&p, "live", "RUNNING", true); + let fake = Fake { + listed: Mutex::new(vec![vec![live.arn.clone()]]), + ..Default::default() + } + .with_task(live); + let (r, steps) = start(&fake, &p).await; + let started = r.unwrap(); + assert!(!started.created); + assert_eq!(started.access_point_id, None); + assert_eq!(fake.calls(), vec!["record dev", "role", "list", "get-secret", "connect"]); + assert_eq!(fake.connect_tokens.lock().unwrap()[0], "token-of-the-running-task"); + assert_eq!(steps, vec![PROGRESS_READING, PROGRESS_WAITING, PROGRESS_CONNECTING]); +} + +#[tokio::test] +async fn losing_the_run_race_is_a_clear_error_and_nothing_is_stopped() { + let p = aws_project(StorageMode::Ephemeral); + let other = task(&p, "other", "RUNNING", true); + let fake = Fake { + // Nothing at the first look; a concurrent start's task at the second. + listed: Mutex::new(vec![vec![], vec![other.arn.clone()]]), + ..Default::default() + } + .with_task(other); + let (r, _) = start(&fake, &p).await; + assert_eq!(r.err().as_deref(), Some(START_RACE)); + assert!(!fake.called("run") && !fake.called("stop") && !fake.called("connect")); +} + +#[tokio::test] +async fn a_task_that_stops_while_starting_is_reported_with_its_log() { + let p = aws_project(StorageMode::Ephemeral); + let fake = Fake { + run: Some(task(&p, "dies", "STOPPED", false)), + logs: vec!["entrypoint: cannot decode TRIPLEC_FILES".into()], + ..Default::default() + }; + let (r, _) = start(&fake, &p).await; + let e = r.err().unwrap(); + assert!(e.contains("Essential container exited") && e.contains("cannot decode"), "{e}"); + assert!(!fake.called("connect")); +} + +#[tokio::test] +async fn a_task_that_never_gets_ready_is_stopped_and_its_log_shown() { + let p = aws_project(StorageMode::Ephemeral); + let fake = Fake { + run: Some(task(&p, "slow", "PENDING", false)), + logs: vec!["pulling image".into()], + ..Default::default() + }; + let (r, _) = start(&fake, &p).await; + let e = r.err().unwrap(); + assert!(e.contains("did not become ready") && e.contains("pulling image"), "{e}"); + assert!(fake.calls().contains(&format!("stop {} {STOP_REASON}", task_arn("slow")))); +} + +#[tokio::test] +async fn a_tunnel_failure_stops_a_task_this_start_ran_but_never_an_attached_one() { + let p = aws_project(StorageMode::Ephemeral); + let fake = Fake { + run: Some(task(&p, "new", "RUNNING", true)), + connect_fails: true, + ..Default::default() + }; + let (r, _) = start(&fake, &p).await; + assert!(r.err().unwrap().contains("session-manager-plugin")); + assert!(fake.calls().contains(&format!("stop {} {STOP_REASON}", task_arn("new")))); + + let live = task(&p, "live", "RUNNING", true); + let attached = Fake { + listed: Mutex::new(vec![vec![live.arn.clone()]]), + connect_fails: true, + ..Default::default() + } + .with_task(live); + let (r, _) = start(&attached, &p).await; + assert!(r.is_err()); + assert!(!attached.called("stop")); +} + +#[tokio::test] +async fn a_user_who_has_not_joined_is_told_before_anything_is_written() { + let p = aws_project(StorageMode::Hybrid); + let fake = Fake { role: Some(Knowable::No), ..Default::default() }; + let (r, _) = start(&fake, &p).await; + assert!(r.err().unwrap().contains("not joined")); + assert_eq!(fake.calls(), vec!["record dev", "role"]); +} + +// ──────────────────────────────────────────────────────────────── inputs + +#[test] +fn aws_runs_registry_images_only() { + let mut s = AppSettings::default(); + s.image_source = ImageSource::LocalBuild; + assert_eq!(aws_image(&s).unwrap(), REGISTRY_IMAGE); + s.image_source = ImageSource::Registry; + assert_eq!(aws_image(&s).unwrap(), REGISTRY_IMAGE); + s.image_source = ImageSource::Custom; + for good in ["registry.example.com/team/img:1", "ghcr.io/x/y", "user/repo:tag", "reg:5000/a"] { + s.custom_image_name = Some(good.into()); + assert_eq!(aws_image(&s).unwrap(), good); + } + for bad in ["", "triple-c:latest", "ubuntu", "localhost/x", "127.0.0.1:5000/x", "a /b", "/x"] { + s.custom_image_name = Some(bad.into()); + assert!(aws_image(&s).is_err(), "{bad}"); + } +} + +#[test] +fn the_sandbox_is_forced_off_on_aws() { + let mut p = aws_project(StorageMode::Ephemeral); + p.sandbox_mode_enabled = true; + let (pairs, _) = aws_env(&p, &AppSettings::default()).unwrap(); + let settings = &pairs.iter().find(|(k, _)| k == "CLAUDE_CODE_SETTINGS_JSON").unwrap().1; + let v: serde_json::Value = serde_json::from_str(settings).unwrap(); + assert_eq!(v["sandbox"]["enabled"], false, "{settings}"); +} + +#[test] +fn files_for_the_secret_come_from_the_ssh_folder_without_leaving_it() { + let base = tempfile::tempdir().unwrap(); + let ssh = base.path().join("ssh"); + std::fs::create_dir(&ssh).unwrap(); + std::fs::write(ssh.join("id_ed25519"), b"KEY").unwrap(); + std::fs::write(ssh.join("config"), b"Host *").unwrap(); + std::fs::create_dir(ssh.join("sub")).unwrap(); + std::fs::write(ssh.join("sub/nested"), b"x").unwrap(); + std::fs::write(base.path().join("outside"), b"SECRET-OUTSIDE").unwrap(); + #[cfg(unix)] + { + std::os::unix::fs::symlink(base.path().join("outside"), ssh.join("escape")).unwrap(); + std::os::unix::fs::symlink(ssh.join("config"), ssh.join("alias")).unwrap(); + } + let got = workspace_files(Some(ssh.to_str().unwrap()), None).unwrap(); + let names: Vec<&str> = got.iter().map(|(k, _)| k.as_str()).collect(); + #[cfg(unix)] + assert_eq!(names, vec!["ssh/alias", "ssh/config", "ssh/id_ed25519"]); + #[cfg(not(unix))] + assert_eq!(names, vec!["ssh/config", "ssh/id_ed25519"]); + assert!(got.iter().all(|(_, v)| v != b"SECRET-OUTSIDE")); + + std::fs::write(ssh.join("huge"), vec![b'x'; MAX_FILE_BYTES as usize + 1]).unwrap(); + let e = workspace_files(Some(ssh.to_str().unwrap()), None).unwrap_err(); + assert!(e.contains("huge") && e.contains("64 KiB"), "{e}"); + + assert!(workspace_files(Some("/"), None).is_err(), "a filesystem root is refused"); + assert!(workspace_files(None, None).unwrap().is_empty()); +} + +#[test] +fn ca_files_are_what_docker_would_mount() { + let base = tempfile::tempdir().unwrap(); + let dir = base.path().join("ca"); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join("corp.pem"), b"PEM").unwrap(); + std::fs::write(dir.join("README"), b"not a cert").unwrap(); + let ca = crate::docker::ca_certs::resolve(Some(dir.to_str().unwrap())).unwrap().unwrap(); + let got = workspace_files(None, Some(&ca)).unwrap(); + assert_eq!(got, vec![("ca/corp.pem".to_string(), b"PEM".to_vec())]); + + let one = base.path().join("Root CA.pem"); + std::fs::write(&one, b"ONE").unwrap(); + let ca = crate::docker::ca_certs::resolve(Some(one.to_str().unwrap())).unwrap().unwrap(); + let got = workspace_files(None, Some(&ca)).unwrap(); + assert_eq!(got, vec![("ca/Root_CA.crt".to_string(), b"ONE".to_vec())]); +} + +// ───────────────────────────────────────────────── targets and reconcile + +#[tokio::test] +async fn an_aws_projects_target_is_its_registered_tunnel() { + let mut p = aws_project(StorageMode::Ephemeral); + p.container_id = Some("http://127.0.0.1:61200/".into()); + assert_eq!(ProjectHost::for_project(&p), ProjectHost::Aws); + assert_eq!(ContainerTarget::for_project(&p), None, "no tunnel, no target, whatever is stored"); + tunnels::register(&p.id, fake_tunnel(61200, std::sync::Arc::new(AtomicUsize::new(0)))); + assert_eq!( + ContainerTarget::for_project(&p), + Some(ContainerTarget::Agent(AgentEndpoint::loopback(61200, "tok"))) + ); + assert_eq!( + ContainerTarget::for_project_waiting(&p).await, + ContainerTarget::for_project(&p) + ); + tunnels::close(&p.id).await; + + let local = Project::new("local".into(), Vec::new()); + assert_eq!(ProjectHost::for_project(&local), ProjectHost::Docker); +} + +fn store_with(p: &Project) -> (tempfile::TempDir, ProjectsStore) { + let dir = tempfile::tempdir().unwrap(); + let store = ProjectsStore::over_file_for_test(dir.path().join("projects.json"), vec![p.clone()]); + (dir, store) +} + +#[tokio::test] +async fn reconcile_marks_a_project_whose_task_stopped_as_stopped() { + let mut p = aws_project(StorageMode::Hybrid); + p.status = ProjectStatus::Running; + { + let a = p.aws.as_mut().unwrap(); + a.task_arn = Some(task_arn("gone")); + a.access_point_id = Some("fsap-1".into()); + a.task_definition_arn = Some("arn:td:1".into()); + } + let fake = Fake::default().with_task(task(&p, "gone", "STOPPED", false)); + let (_d, store) = store_with(&p); + assert_eq!(reconcile_project(&fake, &store, &p).await.unwrap(), Reconciled::Gone); + let after = store.get(&p.id).unwrap(); + assert_eq!(after.status, ProjectStatus::Stopped); + let a = after.aws.unwrap(); + assert_eq!(a.task_arn, None, "a stopped task's ARN is cleared"); + assert_eq!(a.access_point_id.as_deref(), Some("fsap-1"), "the data's access point stays"); + + // ECS has forgotten it: also gone. + let forgotten = Fake::default(); + let (_d, store) = store_with(&p); + assert_eq!(reconcile_project(&forgotten, &store, &p).await.unwrap(), Reconciled::Gone); + assert_eq!(store.get(&p.id).unwrap().status, ProjectStatus::Stopped); +} + +#[tokio::test] +async fn reconcile_keeps_a_project_with_a_live_task_running() { + let mut p = aws_project(StorageMode::Hybrid); + p.status = ProjectStatus::Running; + p.aws.as_mut().unwrap().task_arn = Some(task_arn("up")); + let fake = Fake::default().with_task(task(&p, "up", "RUNNING", true)); + let (_d, store) = store_with(&p); + assert!(matches!(reconcile_project(&fake, &store, &p).await.unwrap(), Reconciled::Live(_))); + assert_eq!(store.get(&p.id).unwrap().status, ProjectStatus::Running); +} + +#[tokio::test] +async fn the_watch_reports_a_running_aws_project_as_its_stored_target_and_confirms_a_recorded_stop() { + let mut p = aws_project(StorageMode::Ephemeral); + p.status = ProjectStatus::Running; + p.container_id = Some("http://127.0.0.1:61300/".into()); + p.aws.as_mut().unwrap().task_arn = Some(task_arn("w1")); + let w = watched_task(&p).expect("watched"); + assert_eq!(w.target.id(), "http://127.0.0.1:61300/", "settle's running_on matches the stored id"); + assert_eq!(w.group.cluster_arn, cluster_arn()); + assert_eq!(w.group.connection_id, "c1"); + assert!(!w.target.has_exited().await, "nothing recorded yet"); + record_stopped(std::slice::from_ref(&p), &[task_arn("w1")]); + assert!(w.target.has_exited().await); + + let mut stopped = p.clone(); + stopped.status = ProjectStatus::Stopped; + assert!(watched_task(&stopped).is_none(), "a Stopped project is not polled again"); + let local = Project::new("l".into(), Vec::new()); + assert!(watched_task(&local).is_none()); +} diff --git a/app/src-tauri/src/host/lifecycle.rs b/app/src-tauri/src/host/lifecycle.rs index 0e63b0a..d38ee78 100644 --- a/app/src-tauri/src/host/lifecycle.rs +++ b/app/src-tauri/src/host/lifecycle.rs @@ -18,11 +18,13 @@ use crate::docker; use crate::models::{AppSettings, Project}; use crate::models::container_config; -/// The host a project runs on. One variant today; an AWS variant will carry -/// the connection, region and environment (see the spec's data model). +/// The host a project runs on: the local Docker daemon, or an ECS Fargate +/// task in an AWS environment (`aws_host.rs`; the connection, region and +/// environment come from the project). #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProjectHost { Docker, + Aws, } /// An agent target is a way *into* a container, not a handle on its @@ -41,8 +43,12 @@ pub struct DiscardLeftovers { } impl ProjectHost { - pub fn for_project(_project: &Project) -> Self { - ProjectHost::Docker + pub fn for_project(project: &Project) -> Self { + if project.is_aws() { + ProjectHost::Aws + } else { + ProjectHost::Docker + } } /// Make sure the project's container exists, matches the project's @@ -60,6 +66,7 @@ impl ProjectHost { ProjectHost::Docker => docker_ensure_started(project, settings, progress) .await .map(ContainerTarget::docker), + ProjectHost::Aws => super::aws_host::ensure_started(project, settings, progress).await, } } @@ -72,24 +79,66 @@ impl ProjectHost { ProjectHost::Docker => Ok(docker::find_existing_container(project) .await? .map(ContainerTarget::docker)), + // The tunnel registry: an AWS project's target exists while its + // tunnel is open. + ProjectHost::Aws => Ok(ContainerTarget::for_project(project)), } } + /// Stop the container behind `target`. For an agent target that is the + /// ECS task behind a registered tunnel (`StopTask`, reason "Stopped from + /// Triple-C"), and the tunnel is closed. pub async fn stop(&self, target: &ContainerTarget) -> Result<(), String> { match target { ContainerTarget::Docker { container_id } => docker::stop_container(container_id).await, - ContainerTarget::Agent(_) => Err(AGENT_LIFECYCLE.replace("{op}", "Stopping")), + ContainerTarget::Agent(ep) => super::aws_host::stop_endpoint(ep) + .await + .unwrap_or_else(|| Err(AGENT_LIFECYCLE.replace("{op}", "Stopping"))), + } + } + + /// Stop a project's container whether or not the app holds a target for + /// it. Docker: the stored container. AWS: the tunnel's task, else the + /// stored task ARN, else the project's live task — a project whose + /// tunnel could not be re-opened must still be stoppable. + pub async fn stop_project(&self, project: &Project, settings: &AppSettings) -> Result<(), String> { + match self { + ProjectHost::Docker => match ContainerTarget::for_project(project) { + Some(t) => self.stop(&t).await, + None => Ok(()), + }, + ProjectHost::Aws => super::aws_host::stop_project(project, settings).await, } } /// Stop (errors ignored — a stopped container is the goal) and remove. + /// An agent target is stopped; its resources go with + /// [`Self::remove_aws_resources`]. pub async fn remove(&self, target: &ContainerTarget) -> Result<(), String> { match target { ContainerTarget::Docker { container_id } => { let _ = docker::stop_container(container_id).await; docker::remove_container(container_id).await } - ContainerTarget::Agent(_) => Err(AGENT_LIFECYCLE.replace("{op}", "Removing")), + ContainerTarget::Agent(ep) => super::aws_host::stop_endpoint(ep) + .await + .unwrap_or_else(|| Err(AGENT_LIFECYCLE.replace("{op}", "Removing"))), + } + } + + /// AWS: stop the task and remove the project's resources — task + /// definition revisions, the secret (7-day recovery window) and, only + /// with `discard_data`, the EFS access point. Docker: nothing (its state + /// goes with [`Self::discard_project_state`]). + pub async fn remove_aws_resources( + &self, + project: &Project, + settings: &AppSettings, + discard_data: bool, + ) -> Result<(), String> { + match self { + ProjectHost::Docker => Ok(()), + ProjectHost::Aws => super::aws_host::remove_resources(project, settings, discard_data).await, } } @@ -107,6 +156,8 @@ impl ProjectHost { left.volumes = docker::remove_project_volumes(project).await; left } + // Removed by `remove_aws_resources`, which needs the settings. + ProjectHost::Aws => DiscardLeftovers::default(), } } @@ -118,6 +169,7 @@ impl ProjectHost { docker::remove_legacy_mcp_containers(&project.id).await; docker::remove_legacy_project_network(&project.id).await; } + ProjectHost::Aws => {} } } } diff --git a/app/src-tauri/src/host/mod.rs b/app/src-tauri/src/host/mod.rs index 2a1bf8f..b149f02 100644 --- a/app/src-tauri/src/host/mod.rs +++ b/app/src-tauri/src/host/mod.rs @@ -28,12 +28,11 @@ //! may still do so. pub mod agent; +pub mod aws_host; pub mod lifecycle; pub mod sessions; -// Nothing in the app opens an SSM tunnel until the AWS host lands (phase 5 of -// the AWS spec); its tests exercise it meanwhile. Remove this allow then. -#[allow(dead_code)] pub mod ssm; +pub mod tunnels; pub mod watch; pub use crate::docker::exec::{ @@ -154,11 +153,28 @@ impl ContainerTarget { } } - /// The target for a project's container, if it has one yet. + /// The target for a project's container, if it has one yet. An AWS + /// project's is its registered tunnel's agent endpoint (`tunnels.rs`): + /// none while no tunnel is open. pub fn for_project(project: &Project) -> Option { + if project.is_aws() { + return tunnels::get_endpoint(&project.id).map(ContainerTarget::Agent); + } project.container_id.as_deref().map(Self::docker) } + /// [`Self::for_project`], waiting (up to `aws_host::REATTACH_WAIT`) for an + /// AWS project whose tunnel is being re-opened in the background after a + /// restart. For a Docker project it is `for_project`. + pub async fn for_project_waiting(project: &Project) -> Option { + if project.is_aws() { + return tunnels::wait_endpoint(&project.id, aws_host::REATTACH_WAIT) + .await + .map(ContainerTarget::Agent); + } + Self::for_project(project) + } + /// The host-specific identifier (a Docker container id today). For logs /// and for keying per-container state; never parse it. pub fn id(&self) -> &str { diff --git a/app/src-tauri/src/host/ssm.rs b/app/src-tauri/src/host/ssm.rs index 43f35b8..a76e6d2 100644 --- a/app/src-tauri/src/host/ssm.rs +++ b/app/src-tauri/src/host/ssm.rs @@ -115,6 +115,10 @@ impl EcsTask { /// ARN), and check it can take a session now: the task is running and /// its ECS Exec agent is up. Fargate starts that agent a few seconds after /// the container, so "not ready yet" is a normal answer just after start. + /// + /// The AWS host builds its target from `aws::tasks::wait_ready`'s answer + /// instead (`aws_host::ecs_task`); this stays for the live tests. + #[allow(dead_code)] pub async fn resolve( ecs: &aws_sdk_ecs::Client, cluster: &str, @@ -175,6 +179,7 @@ impl EcsTask { /// The part of an ARN after the last `marker` (`.../cluster/name` → `name`), /// or the whole string when it is not an ARN. +#[allow(dead_code)] // `EcsTask::resolve` fn last_arn_segment<'a>(s: &'a str, marker: &str) -> &'a str { if !s.starts_with("arn:") { return s; @@ -606,6 +611,7 @@ pub enum TunnelState { pub struct PortForward { local_port: u16, state: watch::Receiver, + #[allow(dead_code)] // `plugin_pid()`, for diagnostics and the tests plugin_pid: watch::Receiver>, supervisor: Option>, _cancel: DropGuard, @@ -678,6 +684,7 @@ impl PortForward { } } + #[allow(dead_code)] // diagnostics and the tests (Task 8 may show the state) pub fn local_port(&self) -> u16 { self.local_port } @@ -688,11 +695,13 @@ impl PortForward { AgentEndpoint::loopback(self.local_port, token) } + #[allow(dead_code)] // diagnostics and the tests (Task 8 may show the state) pub fn state(&self) -> watch::Receiver { self.state.clone() } /// The running plugin's process id, if one is running. + #[allow(dead_code)] // diagnostics and the tests (Task 8 may show the state) pub fn plugin_pid(&self) -> Option { *self.plugin_pid.borrow() } diff --git a/app/src-tauri/src/host/tunnels.rs b/app/src-tauri/src/host/tunnels.rs new file mode 100644 index 0000000..3c71721 --- /dev/null +++ b/app/src-tauri/src/host/tunnels.rs @@ -0,0 +1,301 @@ +//! The live SSM tunnels to AWS projects' agents, by project id. +//! +//! An AWS project's [`ContainerTarget`](super::ContainerTarget) is an agent +//! endpoint at the loopback end of a [`PortForward`](super::ssm::PortForward); +//! this registry is where `ContainerTarget::for_project` finds it, where +//! `ProjectHost::stop` finds the task behind an endpoint, and what the +//! shutdown path closes (every forward runs a `session-manager-plugin` child +//! that would otherwise outlive the app — see `ssm.rs`). +//! +//! It also remembers which targets the AWS death watch found **stopped**, so +//! `ContainerTarget::has_exited` can answer for an agent target (R3): only +//! the host that runs the task can confirm a death, never the agent. + +use std::collections::{HashMap, HashSet}; +use std::sync::Mutex; +use std::time::Duration; + +use aws_config::SdkConfig; +use futures_util::future::BoxFuture; +use tokio::sync::watch; + +use super::agent::AgentEndpoint; +use super::ssm::{EcsTask, PortForward}; + +/// Something that owns a tunnel's resources: the real [`PortForward`], or a +/// test stand-in. +pub trait TunnelHandle: Send + Sync { + fn close(self: Box) -> BoxFuture<'static, ()>; +} + +impl TunnelHandle for PortForward { + fn close(self: Box) -> BoxFuture<'static, ()> { + Box::pin(async move { (*self).close().await }) + } +} + +/// One project's tunnel and the task it reaches. +pub struct LiveTunnel { + /// The SSM target (kept for diagnostics; the forward already uses it). + #[allow(dead_code)] + pub task: EcsTask, + pub task_arn: String, + pub cluster_arn: String, + pub connection_id: String, + pub region: String, + /// The connection's SDK config, so a stop needs no settings lookup. + pub sdk: SdkConfig, + pub forward: Box, + pub endpoint: AgentEndpoint, + /// Runtime handles for the store (`set_aws_runtime`). + pub access_point_id: Option, + pub task_definition_arn: String, +} + +/// Where an endpoint's task is: `(connection, region, cluster, task ARN)`. +pub type TaskOf = (String, String, String, String); + +#[derive(Default)] +struct Registry { + live: HashMap, + /// Target ids (endpoint base URLs) whose task the AWS watch saw STOPPED. + stopped: HashSet, + /// Projects whose tunnel is being re-opened in the background. + pending: HashSet, +} + +fn registry() -> &'static Mutex { + static R: std::sync::OnceLock> = std::sync::OnceLock::new(); + R.get_or_init(Default::default) +} + +fn lock() -> std::sync::MutexGuard<'static, Registry> { + registry().lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Wakes [`wait_endpoint`] callers when the registry changes. +fn changed() -> &'static watch::Sender { + static C: std::sync::OnceLock> = std::sync::OnceLock::new(); + C.get_or_init(|| watch::channel(0).0) +} + +fn notify() { + changed().send_modify(|n| *n = n.wrapping_add(1)); +} + +/// Register `tunnel` for `project_id`, returning the one it replaces (the +/// caller closes it). +pub fn register(project_id: &str, tunnel: LiveTunnel) -> Option { + let old = { + let mut r = lock(); + r.stopped.remove(tunnel.endpoint.base_url.as_str()); + r.pending.remove(project_id); + r.live.insert(project_id.to_string(), tunnel) + }; + notify(); + old +} + +pub fn get_endpoint(project_id: &str) -> Option { + lock().live.get(project_id).map(|t| t.endpoint.clone()) +} + +/// `(task ARN, access point id, task definition ARN)` of the project's tunnel. +pub fn runtime(project_id: &str) -> Option<(String, Option, String)> { + lock().live.get(project_id).map(|t| { + ( + t.task_arn.clone(), + t.access_point_id.clone(), + t.task_definition_arn.clone(), + ) + }) +} + +/// The project whose tunnel is `ep` and its task. +pub fn task_of(ep: &AgentEndpoint) -> Option { + lock().live.values().find(|t| t.endpoint.base_url == ep.base_url).map(|t| { + ( + t.connection_id.clone(), + t.region.clone(), + t.cluster_arn.clone(), + t.task_arn.clone(), + ) + }) +} + +/// The project id and SDK config behind `ep`. +pub(crate) fn owner_of(ep: &AgentEndpoint) -> Option<(String, SdkConfig)> { + lock() + .live + .iter() + .find(|(_, t)| t.endpoint.base_url == ep.base_url) + .map(|(id, t)| (id.clone(), t.sdk.clone())) +} + +/// Take the project's tunnel out and close it. +pub async fn close(project_id: &str) { + let taken = { + let mut r = lock(); + r.pending.remove(project_id); + let t = r.live.remove(project_id); + if let Some(ref t) = t { + r.stopped.remove(t.endpoint.base_url.as_str()); + } + t + }; + notify(); + if let Some(t) = taken { + t.forward.close().await; + } +} + +/// Close every tunnel, concurrently. The shutdown path's step. +pub async fn close_all() { + let all: Vec = { + let mut r = lock(); + r.pending.clear(); + r.stopped.clear(); + r.live.drain().map(|(_, t)| t).collect() + }; + notify(); + futures_util::future::join_all(all.into_iter().map(|t| t.forward.close())).await; +} + +/// The AWS watch saw the task behind target `target_id` STOPPED. +pub fn mark_stopped(target_id: &str) { + lock().stopped.insert(target_id.to_string()); +} + +/// Whether the AWS watch has confirmed the task behind `target_id` stopped. +pub fn is_stopped(target_id: &str) -> bool { + lock().stopped.contains(target_id) +} + +/// A background re-attach for this project has begun / ended without a tunnel. +pub fn set_pending(project_id: &str, pending: bool) { + { + let mut r = lock(); + if pending { + r.pending.insert(project_id.to_string()); + } else { + r.pending.remove(project_id); + } + } + notify(); +} + +pub fn is_pending(project_id: &str) -> bool { + lock().pending.contains(project_id) +} + +/// The project's endpoint, waiting up to `timeout` while a background +/// re-attach is still opening it. `None` when there is none. +pub async fn wait_endpoint(project_id: &str, timeout: Duration) -> Option { + let mut rx = changed().subscribe(); + let deadline = tokio::time::Instant::now() + timeout; + loop { + if let Some(ep) = get_endpoint(project_id) { + return Some(ep); + } + if !is_pending(project_id) { + return None; + } + if tokio::time::timeout_at(deadline, rx.changed()).await.is_err() { + return None; + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + /// A stand-in tunnel that counts its closes. + pub(crate) struct FakeTunnel(pub Arc); + impl TunnelHandle for FakeTunnel { + fn close(self: Box) -> BoxFuture<'static, ()> { + let n = self.0.clone(); + Box::pin(async move { + n.fetch_add(1, Ordering::SeqCst); + }) + } + } + + pub(crate) fn fake_tunnel(port: u16, closes: Arc) -> LiveTunnel { + LiveTunnel { + task: EcsTask { + cluster: "triple-c-dev".into(), + task_id: format!("t{port}"), + runtime_id: "r".into(), + }, + task_arn: format!("arn:aws:ecs:eu-west-1:111122223333:task/triple-c-dev/t{port}"), + cluster_arn: "arn:aws:ecs:eu-west-1:111122223333:cluster/triple-c-dev".into(), + connection_id: "c1".into(), + region: "eu-west-1".into(), + sdk: SdkConfig::builder().build(), + forward: Box::new(FakeTunnel(closes)), + endpoint: AgentEndpoint::loopback(port, "tok"), + access_point_id: Some("fsap-1".into()), + task_definition_arn: "arn:td:1".into(), + } + } + + fn pid(tag: &str) -> String { + format!("tunnels-test-{tag}-{}", uuid::Uuid::new_v4()) + } + + #[tokio::test] + async fn a_registered_tunnel_is_found_by_project_and_by_endpoint_and_closed_once() { + let closes = Arc::new(AtomicUsize::new(0)); + let p = pid("reg"); + assert!(get_endpoint(&p).is_none()); + assert!(register(&p, fake_tunnel(61001, closes.clone())).is_none()); + let ep = get_endpoint(&p).unwrap(); + assert_eq!(ep, AgentEndpoint::loopback(61001, "tok")); + let (conn, region, _, arn) = task_of(&ep).unwrap(); + assert_eq!((conn.as_str(), region.as_str()), ("c1", "eu-west-1")); + assert!(arn.ends_with("/t61001")); + assert_eq!(runtime(&p).unwrap().1.as_deref(), Some("fsap-1")); + close(&p).await; + close(&p).await; + assert_eq!(closes.load(Ordering::SeqCst), 1); + assert!(get_endpoint(&p).is_none() && task_of(&ep).is_none()); + } + + #[tokio::test] + async fn re_registering_hands_back_the_old_tunnel_and_clears_a_stale_stop() { + let closes = Arc::new(AtomicUsize::new(0)); + let p = pid("rereg"); + register(&p, fake_tunnel(61002, closes.clone())); + let id = AgentEndpoint::loopback(61003, "tok").base_url.to_string(); + mark_stopped(&id); + let old = register(&p, fake_tunnel(61003, closes.clone())).unwrap(); + assert_eq!(old.endpoint.base_url.port(), Some(61002)); + assert!(!is_stopped(&id), "a new task on a reused port is not stopped"); + old.forward.close().await; + close(&p).await; + assert_eq!(closes.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn waiting_for_an_endpoint_ends_when_it_is_registered_or_the_reattach_gives_up() { + let p = pid("wait"); + assert!(wait_endpoint(&p, Duration::from_secs(5)).await.is_none(), "nothing pending"); + set_pending(&p, true); + let p2 = p.clone(); + let waiter = tokio::spawn(async move { wait_endpoint(&p2, Duration::from_secs(5)).await }); + tokio::time::sleep(Duration::from_millis(20)).await; + register(&p, fake_tunnel(61004, Arc::new(AtomicUsize::new(0)))); + assert_eq!(waiter.await.unwrap().unwrap().base_url.port(), Some(61004)); + close(&p).await; + + set_pending(&p, true); + let p2 = p.clone(); + let waiter = tokio::spawn(async move { wait_endpoint(&p2, Duration::from_secs(5)).await }); + tokio::time::sleep(Duration::from_millis(20)).await; + set_pending(&p, false); + assert!(waiter.await.unwrap().is_none()); + } +} diff --git a/app/src-tauri/src/host/watch.rs b/app/src-tauri/src/host/watch.rs index 837dfb1..d269268 100644 --- a/app/src-tauri/src/host/watch.rs +++ b/app/src-tauri/src/host/watch.rs @@ -27,8 +27,6 @@ pub const AWS_POLL_INTERVAL: Duration = Duration::from_secs(15); #[derive(Clone)] pub enum ExitSource { Docker, - // Constructed by the AWS host (phase 5, Task 6); until then only by tests. - #[allow(dead_code)] Aws(AwsWatch), } @@ -80,7 +78,6 @@ pub struct AwsWatch { interval: Duration, } -#[allow(dead_code)] // Task 6 impl AwsWatch { pub fn new(lookup: Arc) -> Self { Self::every(lookup, AWS_POLL_INTERVAL) @@ -180,9 +177,10 @@ impl ContainerTarget { ContainerTarget::Docker { container_id } => docker::events::has_exited(container_id).await, // The agent can only say "I answer" or "I don't"; silence is not a // positive exit (an SSM blip looks the same). Only the host that - // runs the container can confirm a death — an AWS host answers - // from ECS — so through the agent alone this never confirms one. - ContainerTarget::Agent(_) => false, + // runs the container can confirm a death: the AWS watch records + // the targets whose task ECS reports STOPPED in the tunnel + // registry (R3), and nothing else ever confirms one. + ContainerTarget::Agent(ep) => super::tunnels::is_stopped(ep.base_url.as_str()), } } } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 10569a0..d199661 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -332,6 +332,19 @@ pub fn run() { crate::host::watch::ExitSource::Docker, lifecycle_setup.cancellation(), )); + // The same for AWS projects: a DescribeTasks poll every 15 s over + // the tasks of Running AWS projects (none: no AWS calls at all). + lifecycle_setup.track(container_watch::spawn( + app.handle().clone(), + projects_store_setup.clone(), + crate::host::watch::ExitSource::Aws(crate::host::watch::AwsWatch::new( + std::sync::Arc::new(crate::host::aws_host::StoreTaskLookup { + projects: projects_store_setup.clone(), + settings: settings_store_setup.clone(), + }), + )), + lifecycle_setup.cancellation(), + )); // Marketplaces: refresh each once at startup, in the background. // Failures are logged, not toasted — the Marketplace tab shows them. @@ -509,6 +522,9 @@ pub fn run() { exec_manager.close_all_sessions(), auth_bridge.stop_all(), browser_view::manager().stop_all(), + // Each AWS tunnel is a session-manager-plugin + // child that `exit` would not end. + crate::host::tunnels::close_all(), ); }; diff --git a/app/src-tauri/src/web_terminal/ws_handler.rs b/app/src-tauri/src/web_terminal/ws_handler.rs index 8d60578..df85e36 100644 --- a/app/src-tauri/src/web_terminal/ws_handler.rs +++ b/app/src-tauri/src/web_terminal/ws_handler.rs @@ -295,7 +295,8 @@ async fn handle_open( return Err(format!("Project '{}' is not running", project.name)); } - let container = crate::host::ContainerTarget::for_project(&project) + let container = crate::host::ContainerTarget::for_project_waiting(&project) + .await .ok_or_else(|| "Container not running".to_string())?; let cmd = match session_type { -- 2.52.0 From 37e94b1ee6d1cb0f8241493c81cec64250e17b0c Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 09:25:53 -0800 Subject: [PATCH 11/43] fix(aws): record a task as soon as it exists; repair a raced token; guard re-attach A start stores the task ARN when RunTask returns (or the task is found), reconcile finds an unrecorded live task, and a failed attach leaves the project in Error rather than hiding a running task. The winner of a start race puts its agent token back in the secret. A background re-attach registers only if the project still runs that task and was not stopped meanwhile. Removing a project whose connection is gone needs explicit confirmation. Names come from tofu_modules; files are read with a hard cap. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/environments.rs | 9 +- app/src-tauri/src/aws/runtime.rs | 32 +- app/src-tauri/src/aws/tofu_modules.rs | 10 + app/src-tauri/src/aws/tofu_modules/tests.rs | 6 + .../src/commands/project_commands.rs | 64 ++- app/src-tauri/src/container_watch.rs | 6 +- app/src-tauri/src/host/aws_host.rs | 366 ++++++++++++++---- app/src-tauri/src/host/aws_host/tests.rs | 148 ++++++- app/src-tauri/src/host/lifecycle.rs | 12 +- app/src-tauri/src/host/tunnels.rs | 83 +++- 10 files changed, 634 insertions(+), 102 deletions(-) diff --git a/app/src-tauri/src/aws/environments.rs b/app/src-tauri/src/aws/environments.rs index 2320a75..35e17d2 100644 --- a/app/src-tauri/src/aws/environments.rs +++ b/app/src-tauri/src/aws/environments.rs @@ -130,7 +130,12 @@ pub fn parse_environment_record( .ok_or_else(|| "template_version is not a version number".to_string())?; let cluster_arn = str_field(obj, "cluster_arn")?; - if cluster_arn != format!("arn:aws:ecs:{region}:{account}:cluster/triple-c-{name}") { + if cluster_arn + != format!( + "arn:aws:ecs:{region}:{account}:cluster/{}", + super::tofu_modules::environment_cluster_name(&name) + ) + { return Err( "cluster_arn is not this environment's cluster in this account and region".into(), ); @@ -171,7 +176,7 @@ pub fn parse_environment_record( return Err("kms_key_arn is not a KMS key in this account and region".into()); } let log_group = str_field(obj, "log_group")?; - if log_group != format!("/triple-c/{name}") { + if log_group != super::tofu_modules::environment_log_group(&name) { return Err("log_group is not this environment's log group".into()); } let network_mode = str_field(obj, "network_mode")?; diff --git a/app/src-tauri/src/aws/runtime.rs b/app/src-tauri/src/aws/runtime.rs index 58c8cad..66cf639 100644 --- a/app/src-tauri/src/aws/runtime.rs +++ b/app/src-tauri/src/aws/runtime.rs @@ -18,7 +18,7 @@ use super::environments::{knowable, parse_environment_record, EnvironmentInfo, K use super::error::{classify_sdk_error, AwsError}; use super::tasks::{find_project_task, TaskApi, STOP_REASON}; use super::tofu_modules::{ - access_point_root, environment_parameter_name, project_secret_name, task_family, + access_point_root, environment_cluster_name, environment_parameter_name, project_secret_name, task_family, user_file_system_token, user_role_names, }; use super::workspace::WorkspaceApi; @@ -134,6 +134,34 @@ pub async fn read_agent_token( agent_token_from_secret(&json).map_err(other) } +/// After a start this call ran is up and its agent answered with `ours`: +/// make sure the secret still holds `ours`. A start that lost the RunTask +/// race (another machine, the same moment) rotated the token in the secret +/// before it found this task, and every later attach would read a token the +/// task never saw. Returns whether the secret had to be put back. +pub async fn ensure_agent_token( + work: &W, + rt: &R, + env: &EnvironmentInfo, + owner_user_id: &str, + owner_key: &str, + project_id: &str, + ours: &str, +) -> Result { + let name = project_secret_name(&env.name, owner_key, project_id); + let json = rt.secret_string(&name).await?; + if agent_token_from_secret(&json).ok().as_deref() == Some(ours) { + return Ok(false); + } + let mut v: serde_json::Map = serde_json::from_str(&json) + .map_err(|_| other("The project secret is not a JSON object"))?; + v.insert(AGENT_TOKEN_KEY.to_string(), serde_json::Value::String(ours.to_string())); + let repaired = serde_json::to_string(&v).map_err(|e| other(e.to_string()))?; + let tags = super::workspace::workspace_tags(owner_user_id, project_id, &env.name); + work.put_project_secret(&name, &env.kms_key_arn, &repaired, &tags).await?; + Ok(true) +} + /// How long a fresh access point may take to become `available`. #[derive(Debug, Clone, Copy)] pub struct PollTiming { @@ -230,7 +258,7 @@ pub async fn remove_workspace_resources, ) -> Result<(), AwsError> { - let cluster = format!("triple-c-{}", req.env); + let cluster = environment_cluster_name(req.env); let family = task_family(req.env, req.owner_key, req.project_id); // 1. The task. diff --git a/app/src-tauri/src/aws/tofu_modules.rs b/app/src-tauri/src/aws/tofu_modules.rs index 2d665d0..28943c7 100644 --- a/app/src-tauri/src/aws/tofu_modules.rs +++ b/app/src-tauri/src/aws/tofu_modules.rs @@ -96,6 +96,16 @@ pub fn validate_environment_name(name: &str) -> Result { } } +/// An environment's ECS cluster name (the environment module's). +pub fn environment_cluster_name(env: &str) -> String { + format!("triple-c-{env}") +} + +/// An environment's CloudWatch log group (the environment module's). +pub fn environment_log_group(env: &str) -> String { + format!("/triple-c/{env}") +} + /// The SSM parameter holding an environment's record. pub fn environment_parameter_name(env: &str) -> String { format!("/triple-c/environments/{env}") diff --git a/app/src-tauri/src/aws/tofu_modules/tests.rs b/app/src-tauri/src/aws/tofu_modules/tests.rs index 9416ed1..1b4cf15 100644 --- a/app/src-tauri/src/aws/tofu_modules/tests.rs +++ b/app/src-tauri/src/aws/tofu_modules/tests.rs @@ -163,6 +163,12 @@ fn environment_names_are_validated() { ); } +#[test] +fn the_environment_cluster_and_log_group_names_match_the_module() { + assert_eq!(environment_cluster_name("dev"), "triple-c-dev"); + assert_eq!(environment_log_group("dev"), "/triple-c/dev"); +} + #[test] fn same_user_and_environment_in_two_regions_do_not_collide() { // IAM role names are account-global but environments are regional: the diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index 9264838..becc435 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -656,6 +656,7 @@ pub async fn add_project( pub async fn remove_project( project_id: String, discard_data: Option, + forget_aws_resources: Option, state: State<'_, AppState>, ) -> Result { // **H-2: the only writer of these three categories that held nothing.** @@ -691,9 +692,10 @@ pub async fn remove_project( .remove_aws_resources(&project, &settings, discard_data.unwrap_or(false)) .await?; } else { + missing_connection_removal(forget_aws_resources.unwrap_or(false))?; log::warn!( - "Project {}: its AWS connection no longer exists, so its AWS resources can't be \ - removed from here", + "Project {}: removed without its AWS connection; its task, secret and data (if \ + any) are left in AWS, as the user confirmed", project_id ); crate::host::tunnels::close(&project_id).await; @@ -1362,7 +1364,7 @@ async fn start_project_container_locked( let host = ProjectHost::for_project(&project); let result: Result = async { let target = host - .ensure_started(&project, &settings, &|msg: &str| { + .ensure_started(&project, &settings, &state.projects_store, &|msg: &str| { emit_progress(&app_handle, &project_id, msg) }) .await?; @@ -1387,10 +1389,24 @@ async fn start_project_container_locked( Ok(target) }.await; - // On failure, reset status to Stopped so the project doesn't get stuck. + // On failure, reset status to Stopped so the project doesn't get stuck — + // unless an AWS task is still running (an attach that failed, or a start + // that lost the race): Stopped would hide a billed task, so the project + // is left in Error with its task ARN, which Stop and Start both use. if let Err(ref e) = result { log::error!("Failed to start container for project {}: {}", project_id, e); - let _ = state.projects_store.update_status(&project_id, ProjectStatus::Stopped); + let task_left = state + .projects_store + .get(&project_id) + .is_some_and(|p| aws_task_left_after_failed_start(&p)); + let status = if task_left { ProjectStatus::Error } else { ProjectStatus::Stopped }; + let _ = state.projects_store.update_status(&project_id, status); + if task_left { + return Err(format!( + "{e}\n\nThe workspace's task is still running on AWS. Start again to connect to \ + it, or Stop to stop it." + )); + } } let target = result?; @@ -1591,6 +1607,26 @@ pub async fn rebuild_project_container( /// [`crate::commands::migration_commands::reconcile_migration`]. The migration /// pass runs over *every* project, not just the Running ones, because a project /// whose container was removed mid-migration reports Stopped. +/// Removing an AWS project whose connection was deleted can't reach AWS: +/// refused unless the user confirmed leaving its resources behind. +fn missing_connection_removal(forget_aws_resources: bool) -> Result<(), String> { + if forget_aws_resources { + return Ok(()); + } + Err("This project's AWS connection no longer exists, so its task (which may still be \ + running and billed), secret and data can't be removed from here. Add the connection \ + again in Settings → Hosts and remove the project, or confirm removing it while leaving \ + those resources in AWS." + .into()) +} + +/// After a failed start: an AWS project whose task ARN is still stored has +/// a task that may still be running (a start stops, and forgets, only a task +/// it ran itself). +fn aws_task_left_after_failed_start(project: &Project) -> bool { + project.is_aws() && project.aws.as_ref().is_some_and(|a| a.task_arn.is_some()) +} + /// How long reconcile waits on AWS for one project. const AWS_RECONCILE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); @@ -2434,4 +2470,22 @@ mod tests { local.host_id = None; assert!(refuse_aws(&local, "Reset").is_ok()); } + + #[test] + fn a_failed_start_with_a_task_left_running_is_not_stopped() { + let mut p = aws_project(); + assert!(!aws_task_left_after_failed_start(&p)); + p.aws.as_mut().unwrap().task_arn = Some("arn:aws:ecs:r:a:task/c/t".into()); + assert!(aws_task_left_after_failed_start(&p)); + let mut local = p.clone(); + local.host_id = None; + assert!(!aws_task_left_after_failed_start(&local)); + } + + #[test] + fn removing_without_the_connection_needs_explicit_confirmation() { + let e = missing_connection_removal(false).unwrap_err(); + assert!(e.contains("still be running") && e.contains("Settings → Hosts"), "{e}"); + assert!(missing_connection_removal(true).is_ok()); + } } diff --git a/app/src-tauri/src/container_watch.rs b/app/src-tauri/src/container_watch.rs index aedefde..296d1d7 100644 --- a/app/src-tauri/src/container_watch.rs +++ b/app/src-tauri/src/container_watch.rs @@ -239,7 +239,11 @@ pub fn spawn( ); } let id = p.id.clone(); - tauri::async_runtime::spawn(async move { crate::host::tunnels::close(&id).await }); + if let Some(arn) = p.aws.as_ref().and_then(|a| a.task_arn.clone()) { + tauri::async_runtime::spawn(async move { + crate::host::tunnels::close_if_task(&id, &arn).await + }); + } } let _ = app.emit(CONTAINER_EXITED_EVENT, ContainerExited { project: p, reason }); }) diff --git a/app/src-tauri/src/host/aws_host.rs b/app/src-tauri/src/host/aws_host.rs index c784d76..4a58e98 100644 --- a/app/src-tauri/src/host/aws_host.rs +++ b/app/src-tauri/src/host/aws_host.rs @@ -28,7 +28,8 @@ use super::watch::{AwsTaskLookup, TaskGroup, WatchedTask}; use super::ContainerTarget; use crate::aws::environments::EnvironmentInfo; use crate::aws::runtime::{ - check_onboarded, cluster_arn_of_task, cluster_of_task, read_agent_token, read_environment, + check_onboarded, cluster_arn_of_task, cluster_of_task, ensure_agent_token, read_agent_token, + read_environment, remove_workspace_resources, wait_access_point, PollTiming, RemoveRequest, RuntimeApi, SdkRuntimeApi, }; @@ -36,7 +37,9 @@ use crate::aws::tasks::{ find_or_run, find_project_task, stopped_among, task_log_stream, wait_ready, SdkTaskApi, TaskApi, TaskInfo, TaskLogs, WaitTiming, LOG_TAIL_LINES, STOP_REASON, }; -use crate::aws::tofu_modules::{log_stream_prefix, owner_key, task_family}; +use crate::aws::tofu_modules::{ + environment_cluster_name, log_stream_prefix, owner_key, task_family, +}; use crate::aws::workspace::{prepare_workspace, SdkWorkspaceApi, WorkspaceApi}; use crate::aws::{caller_identity, sdk_config, CallerIdentity}; use crate::docker::ca_certs::{container_cert_name, ResolvedCa}; @@ -57,7 +60,13 @@ pub const PROGRESS_CONNECTING: &str = "Connecting…"; /// A concurrent start won the RunTask race: the secret now holds a token /// that task never saw. pub const START_RACE: &str = - "Another start of this workspace is in progress; try again in a minute"; + "Another start of this workspace is in progress; try again in a minute (once it is up, \ + Start connects to it)"; + +/// The agent refused the token from the secret (a start elsewhere rotated +/// it, and the repair did not happen). +pub const TOKEN_MISMATCH: &str = + "The workspace's access token no longer matches; stop and start the workspace"; /// Per-file cap on what is shipped in the project secret (R1). pub const MAX_FILE_BYTES: u64 = 64 * 1024; @@ -132,18 +141,22 @@ pub fn aws_env(project: &Project, settings: &AppSettings) -> Result Result, String> { - let len = std::fs::metadata(path) - .map_err(|e| format!("Could not read {shown}: {e}"))? - .len(); - if len > MAX_FILE_BYTES { + use std::io::Read; + let file = std::fs::File::open(path).map_err(|e| format!("Could not read {shown}: {e}"))?; + let mut bytes = Vec::new(); + file.take(MAX_FILE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|e| format!("Could not read {shown}: {e}"))?; + if bytes.len() as u64 > MAX_FILE_BYTES { return Err(format!( - "{shown} is {len} bytes; files sent to an AWS workspace are limited to {} KiB each", + "{shown} is larger than {} KiB, the limit for each file sent to an AWS workspace", MAX_FILE_BYTES / 1024 )); } - std::fs::read(path).map_err(|e| format!("Could not read {shown}: {e}")) + Ok(bytes) } /// The regular files directly in `dir`: subdirectories skipped, and a @@ -154,13 +167,19 @@ fn files_in(dir: &Path) -> Result, String> { let mut out = Vec::new(); let entries = std::fs::read_dir(&root).map_err(|e| format!("Could not read {}: {e}", dir.display()))?; - for entry in entries.flatten() { + for entry in entries { + let entry = entry.map_err(|e| format!("Could not read {}: {e}", dir.display()))?; let path = entry.path(); - let Some(name) = entry.file_name().to_str().map(str::to_string) else { - log::warn!("Skipping {} (not a UTF-8 name)", path.display()); - continue; - }; - let Ok(ft) = entry.file_type() else { continue }; + let name = entry.file_name().to_str().map(str::to_string).ok_or_else(|| { + format!( + "{} has a name that is not UTF-8, so it can't be sent to an AWS workspace; \ + rename or move it", + path.display() + ) + })?; + let ft = entry + .file_type() + .map_err(|e| format!("Could not read {}: {e}", path.display()))?; let real = if ft.is_file() { path.clone() } else if ft.is_symlink() { @@ -266,6 +285,15 @@ pub fn ecs_task(task: &TaskInfo) -> Result { Ok(EcsTask { cluster: cluster.to_string(), task_id, runtime_id }) } +/// What the user is told when the agent answers with an error status. +fn refusal_message(e: &str) -> String { + if e.contains("(401)") { + TOKEN_MISMATCH.into() + } else { + format!("The workspace's agent refused the connection: {e}") + } +} + /// Wait for the agent behind `ep` to answer. A refusal (wrong token) is /// final; silence is retried until [`HEALTH_TIMEOUT`]. async fn wait_healthy(ep: &AgentEndpoint) -> Result<(), String> { @@ -274,7 +302,7 @@ async fn wait_healthy(ep: &AgentEndpoint) -> Result<(), String> { match agent::is_running(ep).await { Ok(true) => return Ok(()), Ok(false) => {} - Err(e) => return Err(format!("The workspace's agent refused the connection: {e}")), + Err(e) => return Err(refusal_message(&e)), } if tokio::time::Instant::now() + HEALTH_INTERVAL > deadline { return Err( @@ -315,6 +343,20 @@ pub struct StartInputs<'a> { pub image: &'a str, pub wait: WaitTiming, pub access_point_wait: PollTiming, + /// Called as soon as the project has a task (found, or RunTask + /// returned) so the store holds its ARN before the long wait — a crash + /// then still leaves the task findable — and with `None` when a task + /// this start ran was stopped again. + pub on_task: &'a (dyn Fn(Option<&TaskRecord>) + Send + Sync), +} + +/// A project's task handles, for the store's `set_aws_runtime`. `None` +/// fields keep what the store has. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskRecord { + pub task_arn: String, + pub access_point_id: Option, + pub task_definition_arn: Option, } /// A connected task. @@ -383,6 +425,11 @@ pub async fn start_core t, Err(e) => { let msg = with_log_tail(tasks, &logs, &arn, e.to_string()).await; stop_created(tasks, &env.cluster_arn, &arn).await; + (inp.on_task)(None); return Err(msg); } }; @@ -435,17 +495,36 @@ pub async fn start_core Ok(Started { - env, - task, - forward, - endpoint, - access_point_id, - created, - }), + Ok((forward, endpoint)) => { + if created { + // A start that lost the RunTask race rotated the secret's + // token after this one wrote it; put ours back so the next + // attach (another machine, a restart) reads the right one. + match ensure_agent_token(work, rt, &env, &inp.who.user_id, &ok, &project.id, &token).await { + Ok(true) => log::warn!( + "Project {}: the secret's agent token had been rotated by another start; restored", + project.id + ), + Ok(false) => {} + Err(e) => log::warn!( + "Project {}: could not check the secret's agent token ({e}); a later attach may be refused", + project.id + ), + } + } + Ok(Started { + env, + task, + forward, + endpoint, + access_point_id, + created, + }) + } Err(e) => { if created { stop_created(tasks, &env.cluster_arn, &task.arn).await; + (inp.on_task)(None); } Err(e) } @@ -469,8 +548,15 @@ async fn project_sdk(project: &Project, settings: &AppSettings) -> Result<(Strin pub async fn ensure_started( project: &Project, settings: &AppSettings, + store: &ProjectsStore, progress: &(dyn Fn(&str) + Send + Sync), ) -> Result { + // A background re-attach (after a restart) is opening the tunnel: wait + // for it rather than racing it with a second one. + if tunnels::is_pending(&project.id) { + progress(PROGRESS_CONNECTING); + tunnels::wait_endpoint(&project.id, REATTACH_WAIT).await; + } // Already connected and answering: nothing to do. if let Some(ep) = tunnels::get_endpoint(&project.id) { if matches!(agent::is_running(&ep).await, Ok(true)) { @@ -490,6 +576,7 @@ pub async fn ensure_started( let (connection_id, region, sdk) = project_sdk(project, settings).await?; let who = caller_identity(&sdk).await.map_err(s)?; + let on_task = |rec: Option<&TaskRecord>| record_task(store, &project.id, rec); let started = start_core( &SdkTaskApi::new(&sdk), &SdkWorkspaceApi::new(&sdk), @@ -503,6 +590,7 @@ pub async fn ensure_started( image: &image, wait: WaitTiming::default(), access_point_wait: PollTiming::default(), + on_task: &on_task, }, progress, ) @@ -539,6 +627,31 @@ pub async fn ensure_started( Ok(ContainerTarget::Agent(endpoint)) } +/// Write a start's task handles to the store as soon as they exist +/// ([`StartInputs::on_task`]); `None` clears the task ARN. +pub fn record_task(store: &ProjectsStore, project_id: &str, rec: Option<&TaskRecord>) { + let Some(stored) = store.get(project_id).and_then(|p| p.aws) else { + return; + }; + let res = match rec { + Some(r) => store.set_aws_runtime( + project_id, + Some(r.task_arn.clone()), + r.access_point_id.clone().or(stored.access_point_id), + r.task_definition_arn.clone().or(stored.task_definition_arn), + ), + None => store.set_aws_runtime( + project_id, + None, + stored.access_point_id, + stored.task_definition_arn, + ), + }; + if let Err(e) = res { + log::warn!("Project {project_id}: could not record its task: {e}"); + } +} + // ──────────────────────────────────────────────────────────────── stop /// StopTask the task in `task_arn`'s cluster. @@ -563,6 +676,49 @@ pub async fn stop_endpoint(ep: &AgentEndpoint) -> Option> { Some(res) } +/// Where to look for a project's live task when no ARN is stored (a crash +/// before the store write, or a store from an older build). +pub struct FindTask { + pub cluster: String, + pub family: String, + pub owner_user_id: String, + pub project_id: String, +} + +impl FindTask { + fn new(project: &Project, region: &str, owner_user_id: &str) -> Self { + let env = project + .aws_placement + .as_ref() + .map(|p| p.environment.clone()) + .unwrap_or_default(); + let ok = owner_key(region, owner_user_id); + FindTask { + cluster: environment_cluster_name(&env), + family: task_family(&env, &ok, &project.id), + owner_user_id: owner_user_id.to_string(), + project_id: project.id.clone(), + } + } +} + +/// The project's task ARN: the stored one, else its live task's. +async fn task_arn_or_find( + api: &T, + stored: Option<&str>, + find: &FindTask, +) -> Result, String> { + if let Some(a) = stored { + return Ok(Some(a.to_string())); + } + Ok( + find_project_task(api, &find.cluster, &find.family, &find.owner_user_id, &find.project_id) + .await + .map_err(s)? + .map(|t| t.arn), + ) +} + /// Stop an AWS project's task: through its tunnel when there is one, else /// the stored task ARN, else whatever live task the project has (a crash /// between RunTask and the store write). Closes the tunnel. @@ -578,13 +734,8 @@ pub async fn stop_project(project: &Project, settings: &AppSettings) -> Result<( let arn = match stored { Some(a) => Some(a), None => { - let env = project.aws_placement.as_ref().map(|p| p.environment.clone()).unwrap_or_default(); let who = caller_identity(&sdk).await.map_err(s)?; - let ok = owner_key(®ion, &who.user_id); - find_project_task(&api, &format!("triple-c-{env}"), &task_family(&env, &ok, &project.id), &who.user_id, &project.id) - .await - .map_err(s)? - .map(|t| t.arn) + task_arn_or_find(&api, None, &FindTask::new(project, ®ion, &who.user_id)).await? } }; if let Some(arn) = arn { @@ -598,13 +749,13 @@ pub async fn stop_project(project: &Project, settings: &AppSettings) -> Result<( /// Stop the task and remove the project's AWS resources (task definition /// revisions, the secret with a 7-day window, and with `discard_data` the -/// access point). +/// access point). The tunnel is closed once that succeeded; on failure the +/// project keeps it. pub async fn remove_resources( project: &Project, settings: &AppSettings, discard_data: bool, ) -> Result<(), String> { - tunnels::close(&project.id).await; let (_, region, sdk) = project_sdk(project, settings).await?; let who = caller_identity(&sdk).await.map_err(s)?; let ok = owner_key(®ion, &who.user_id); @@ -626,7 +777,9 @@ pub async fn remove_resources( }, ) .await - .map_err(s) + .map_err(s)?; + tunnels::close(&project.id).await; + Ok(()) } // ─────────────────────────────────────────────────────────── reconcile @@ -640,15 +793,20 @@ pub enum Reconciled { Gone, } -/// Ask ECS about a project's stored task. No stored task is `Gone`. -pub async fn reconcile_task(api: &T, task_arn: Option<&str>) -> Result { - let Some(arn) = task_arn else { +/// Ask ECS about a project's task: the stored ARN, else (none stored) its +/// live task found by family and tags. +pub async fn reconcile_task( + api: &T, + task_arn: Option<&str>, + find: &FindTask, +) -> Result { + let Some(arn) = task_arn_or_find(api, task_arn, find).await? else { return Ok(Reconciled::Gone); }; - let Some(cluster) = cluster_of_task(arn) else { + let Some(cluster) = cluster_of_task(&arn) else { return Ok(Reconciled::Gone); }; - let found = api.describe(cluster, &[arn.to_string()]).await.map_err(s)?; + let found = api.describe(cluster, std::slice::from_ref(&arn)).await.map_err(s)?; Ok(match found.into_iter().find(|t| t.arn == arn) { Some(t) if t.desired_status == "RUNNING" @@ -662,23 +820,34 @@ pub async fn reconcile_task(api: &T, task_arn: Option<&str>) -> Resu /// Reconcile one AWS project the store has as Running (or Starting/ /// Stopping/Error left by a crash): a gone task makes it Stopped (its task -/// ARN cleared); a live one keeps it Running. An ECS that can't be asked -/// changes nothing — marking Stopped would hide a billed task. +/// ARN cleared); a live one keeps it Running and its ARN stored. An ECS +/// that can't be asked changes nothing — marking Stopped would hide a +/// billed task. pub async fn reconcile_project( api: &T, store: &ProjectsStore, project: &Project, + find: &FindTask, ) -> Result { let arn = project.aws.as_ref().and_then(|a| a.task_arn.clone()); - let r = reconcile_task(api, arn.as_deref()).await?; + let r = reconcile_task(api, arn.as_deref(), find).await?; match &r { Reconciled::Gone => { store.update_status(&project.id, ProjectStatus::Stopped)?; - if let Some(a) = project.aws.as_ref() { - store.set_aws_runtime(&project.id, None, a.access_point_id.clone(), a.task_definition_arn.clone())?; - } + record_task(store, &project.id, None); } - Reconciled::Live(_) => { + Reconciled::Live(t) => { + if arn.as_deref() != Some(t.arn.as_str()) { + record_task( + store, + &project.id, + Some(&TaskRecord { + task_arn: t.arn.clone(), + access_point_id: None, + task_definition_arn: Some(t.task_definition_arn.clone()), + }), + ); + } if project.status != ProjectStatus::Running { store.update_status(&project.id, ProjectStatus::Running)?; } @@ -698,13 +867,22 @@ pub async fn reconcile(project: &Project, settings: &AppSettings, store: Arc w, + Err(e) => { + log::warn!("Project {}: could not reach AWS to reconcile ({e}); status left as is", project.id); + return; + } + }; let api = SdkTaskApi::new(&sdk); - match reconcile_project(&api, &store, project).await { + let find = FindTask::new(project, ®ion, &who.user_id); + match reconcile_project(&api, &store, project, &find).await { Ok(Reconciled::Live(task)) => { - tunnels::set_pending(&project.id, true); + let generation = tunnels::begin_reattach(&project.id); let project = project.clone(); tauri::async_runtime::spawn(async move { - if let Err(e) = reattach(&project, *task, connection_id, region, sdk, &store).await { + let ctx = Reattach { generation, connection_id, region, sdk, who }; + if let Err(e) = reattach(&project, *task, ctx, &store).await { log::warn!("Project {}: could not re-open the tunnel to its task: {e}", project.id); tunnels::set_pending(&project.id, false); } @@ -717,43 +895,73 @@ pub async fn reconcile(project: &Project, settings: &AppSettings, store: Arc bool { + store.get(project_id).is_some_and(|p| { + p.status == ProjectStatus::Running + && p.aws.as_ref().and_then(|a| a.task_arn.as_deref()) == Some(task_arn) + }) +} + /// Re-open the tunnel to a live task found at startup. async fn reattach( project: &Project, task: TaskInfo, - connection_id: String, - region: String, - sdk: SdkConfig, + ctx: Reattach, store: &ProjectsStore, ) -> Result<(), String> { - let env = project.aws_placement.as_ref().map(|p| p.environment.clone()).unwrap_or_default(); - let who = caller_identity(&sdk).await.map_err(s)?; - let ok = owner_key(®ion, &who.user_id); - let rt = SdkRuntimeApi::new(&sdk); - let api = SdkTaskApi::new(&sdk); - let token = read_agent_token(&rt, &env, &ok, &project.id).await.map_err(s)?; - let cluster_arn = cluster_arn_of_task(&task.arn).ok_or("bad task ARN")?; - let logs = TaskLogs { group: format!("/triple-c/{env}"), stream_prefix: log_stream_prefix(&ok, &project.id) }; - let task = wait_ready(&api, &cluster_arn, &task.arn, &logs, WaitTiming::default(), |_| {}) + let placement = project.aws_placement.as_ref().ok_or("no placement")?; + let ok = owner_key(&ctx.region, &ctx.who.user_id); + let rt = SdkRuntimeApi::new(&ctx.sdk); + let api = SdkTaskApi::new(&ctx.sdk); + let env = read_environment(&rt, &placement.environment, &ctx.who.account, &ctx.region) .await .map_err(s)?; - let (forward, endpoint) = SsmConnector { sdk: sdk.clone() }.connect(&task, &token).await?; - if let Some(old) = tunnels::register( - &project.id, - LiveTunnel { - task: ecs_task(&task)?, - task_arn: task.arn.clone(), - cluster_arn, - connection_id, - region, - sdk, - forward, - endpoint: endpoint.clone(), - access_point_id: project.aws.as_ref().and_then(|a| a.access_point_id.clone()), - task_definition_arn: task.task_definition_arn.clone(), - }, - ) { - old.forward.close().await; + let token = read_agent_token(&rt, &env.name, &ok, &project.id).await.map_err(s)?; + let logs = TaskLogs { group: env.log_group.clone(), stream_prefix: log_stream_prefix(&ok, &project.id) }; + let task = wait_ready(&api, &env.cluster_arn, &task.arn, &logs, WaitTiming::default(), |_| {}) + .await + .map_err(s)?; + let (forward, endpoint) = SsmConnector { sdk: ctx.sdk.clone() }.connect(&task, &token).await?; + let tunnel = LiveTunnel { + task: ecs_task(&task)?, + task_arn: task.arn.clone(), + cluster_arn: env.cluster_arn.clone(), + connection_id: ctx.connection_id, + region: ctx.region, + sdk: ctx.sdk, + forward, + endpoint: endpoint.clone(), + access_point_id: project.aws.as_ref().and_then(|a| a.access_point_id.clone()), + task_definition_arn: task.task_definition_arn.clone(), + }; + if !reattach_still_wanted(store, &project.id, &task.arn) { + tunnel.forward.close().await; + tunnels::set_pending(&project.id, false); + log::info!("Project {}: stopped or removed while re-attaching; tunnel closed", project.id); + return Ok(()); + } + match tunnels::register_if_current(&project.id, ctx.generation, tunnel) { + Ok(old) => { + if let Some(old) = old { + old.forward.close().await; + } + } + Err(tunnel) => { + tunnel.forward.close().await; + log::info!("Project {}: stopped or removed while re-attaching; tunnel closed", project.id); + return Ok(()); + } } // The local port is new, so the stored target id is too. store.set_container_id(&project.id, Some(endpoint.base_url.to_string()))?; diff --git a/app/src-tauri/src/host/aws_host/tests.rs b/app/src-tauri/src/host/aws_host/tests.rs index afa31b7..f72a169 100644 --- a/app/src-tauri/src/host/aws_host/tests.rs +++ b/app/src-tauri/src/host/aws_host/tests.rs @@ -89,6 +89,10 @@ struct Fake { connect_fails: bool, connect_tokens: Mutex>, secret_json: Mutex>, + /// A losing start rotates the secret's token while this one connects. + rotated_by_loser: bool, + /// What `on_task` was told, in order. + recorded: Mutex>>, } impl Fake { @@ -191,7 +195,12 @@ impl RuntimeApi for Fake { } async fn secret_string(&self, _name: &str) -> Result { self.log("get-secret"); - Ok(r#"{"TRIPLEC_AGENT_TOKEN":"token-of-the-running-task"}"#.into()) + Ok(self + .secret_json + .lock() + .unwrap() + .clone() + .unwrap_or_else(|| r#"{"TRIPLEC_AGENT_TOKEN":"token-of-the-running-task"}"#.into())) } async fn access_point_state(&self, id: &str) -> Result, AwsError> { self.log(format!("ap-state {id}")); @@ -219,6 +228,12 @@ impl Connector for Fake { ) -> Result<(Box, AgentEndpoint), String> { self.log("connect"); self.connect_tokens.lock().unwrap().push(token.into()); + if self.rotated_by_loser { + let mut j = self.secret_json.lock().unwrap(); + let mut v: serde_json::Value = serde_json::from_str(j.as_deref().unwrap()).unwrap(); + v["TRIPLEC_AGENT_TOKEN"] = "the-losers-token".into(); + *j = Some(v.to_string()); + } if self.connect_fails { return Err("session-manager-plugin was not found".into()); } @@ -229,8 +244,13 @@ impl Connector for Fake { } } -fn inputs<'a>(p: &'a Project, who: &'a CallerIdentity) -> StartInputs<'a> { +fn inputs<'a>( + p: &'a Project, + who: &'a CallerIdentity, + on_task: &'a (dyn Fn(Option<&TaskRecord>) + Send + Sync), +) -> StartInputs<'a> { StartInputs { + on_task, project: p, who, env_pairs: &[], @@ -251,7 +271,10 @@ async fn start(fake: &Fake, p: &Project) -> (Result, Vec| { + fake.recorded.lock().unwrap().push(r.map(|r| r.task_arn.clone())); + }; + let r = start_core(fake, fake, fake, fake, &inputs(p, &w, &on_task), &progress).await; (r, steps.into_inner().unwrap()) } @@ -268,11 +291,12 @@ async fn a_first_start_prepares_runs_waits_and_connects_in_that_order() { fake.calls(), vec![ "record dev", "role", "list", "create-ap", "put-secret", "register", - "ap-state fsap-new", "list", "run", "connect" + "ap-state fsap-new", "list", "run", "connect", "get-secret" ], "look for a live task before rotating the token; the access point is available before RunTask" ); assert_eq!(steps, vec![PROGRESS_READING, PROGRESS_PREPARING, PROGRESS_STARTING, PROGRESS_WAITING, PROGRESS_CONNECTING]); + assert_eq!(*fake.recorded.lock().unwrap(), vec![Some(task_arn("new"))], "recorded before the wait"); // The connector got the token the secret was just written with. let secret: serde_json::Value = serde_json::from_str(fake.secret_json.lock().unwrap().as_deref().unwrap()).unwrap(); @@ -312,6 +336,7 @@ async fn losing_the_run_race_is_a_clear_error_and_nothing_is_stopped() { .with_task(other); let (r, _) = start(&fake, &p).await; assert_eq!(r.err().as_deref(), Some(START_RACE)); + assert_eq!(*fake.recorded.lock().unwrap(), vec![Some(task_arn("other"))], "the live task is recorded"); assert!(!fake.called("run") && !fake.called("stop") && !fake.called("connect")); } @@ -341,6 +366,11 @@ async fn a_task_that_never_gets_ready_is_stopped_and_its_log_shown() { let e = r.err().unwrap(); assert!(e.contains("did not become ready") && e.contains("pulling image"), "{e}"); assert!(fake.calls().contains(&format!("stop {} {STOP_REASON}", task_arn("slow")))); + assert_eq!( + *fake.recorded.lock().unwrap(), + vec![Some(task_arn("slow")), None], + "the ARN is stored before the wait, and forgotten once the task is stopped" + ); } #[tokio::test] @@ -365,6 +395,11 @@ async fn a_tunnel_failure_stops_a_task_this_start_ran_but_never_an_attached_one( let (r, _) = start(&attached, &p).await; assert!(r.is_err()); assert!(!attached.called("stop")); + assert_eq!( + *attached.recorded.lock().unwrap(), + vec![Some(task_arn("live"))], + "an attached task stays recorded: it is still running" + ); } #[tokio::test] @@ -380,8 +415,7 @@ async fn a_user_who_has_not_joined_is_told_before_anything_is_written() { #[test] fn aws_runs_registry_images_only() { - let mut s = AppSettings::default(); - s.image_source = ImageSource::LocalBuild; + let mut s = AppSettings { image_source: ImageSource::LocalBuild, ..Default::default() }; assert_eq!(aws_image(&s).unwrap(), REGISTRY_IMAGE); s.image_source = ImageSource::Registry; assert_eq!(aws_image(&s).unwrap(), REGISTRY_IMAGE); @@ -478,6 +512,10 @@ async fn an_aws_projects_target_is_its_registered_tunnel() { assert_eq!(ProjectHost::for_project(&local), ProjectHost::Docker); } +fn find(p: &Project) -> FindTask { + FindTask::new(p, REGION, USER) +} + fn store_with(p: &Project) -> (tempfile::TempDir, ProjectsStore) { let dir = tempfile::tempdir().unwrap(); let store = ProjectsStore::over_file_for_test(dir.path().join("projects.json"), vec![p.clone()]); @@ -496,7 +534,7 @@ async fn reconcile_marks_a_project_whose_task_stopped_as_stopped() { } let fake = Fake::default().with_task(task(&p, "gone", "STOPPED", false)); let (_d, store) = store_with(&p); - assert_eq!(reconcile_project(&fake, &store, &p).await.unwrap(), Reconciled::Gone); + assert_eq!(reconcile_project(&fake, &store, &p, &find(&p)).await.unwrap(), Reconciled::Gone); let after = store.get(&p.id).unwrap(); assert_eq!(after.status, ProjectStatus::Stopped); let a = after.aws.unwrap(); @@ -506,7 +544,7 @@ async fn reconcile_marks_a_project_whose_task_stopped_as_stopped() { // ECS has forgotten it: also gone. let forgotten = Fake::default(); let (_d, store) = store_with(&p); - assert_eq!(reconcile_project(&forgotten, &store, &p).await.unwrap(), Reconciled::Gone); + assert_eq!(reconcile_project(&forgotten, &store, &p, &find(&p)).await.unwrap(), Reconciled::Gone); assert_eq!(store.get(&p.id).unwrap().status, ProjectStatus::Stopped); } @@ -517,7 +555,7 @@ async fn reconcile_keeps_a_project_with_a_live_task_running() { p.aws.as_mut().unwrap().task_arn = Some(task_arn("up")); let fake = Fake::default().with_task(task(&p, "up", "RUNNING", true)); let (_d, store) = store_with(&p); - assert!(matches!(reconcile_project(&fake, &store, &p).await.unwrap(), Reconciled::Live(_))); + assert!(matches!(reconcile_project(&fake, &store, &p, &find(&p)).await.unwrap(), Reconciled::Live(_))); assert_eq!(store.get(&p.id).unwrap().status, ProjectStatus::Running); } @@ -541,3 +579,95 @@ async fn the_watch_reports_a_running_aws_project_as_its_stored_target_and_confir let local = Project::new("l".into(), Vec::new()); assert!(watched_task(&local).is_none()); } + +#[tokio::test] +async fn the_winner_of_a_start_race_puts_its_token_back_in_the_secret() { + let p = aws_project(StorageMode::Ephemeral); + let fake = Fake { + run: Some(task(&p, "new", "RUNNING", true)), + rotated_by_loser: true, + ..Default::default() + }; + let (r, _) = start(&fake, &p).await; + r.unwrap(); + let ours = fake.connect_tokens.lock().unwrap()[0].clone(); + let calls = fake.calls(); + assert_eq!(&calls[calls.len() - 3..], ["connect", "get-secret", "put-secret"]); + let v: serde_json::Value = + serde_json::from_str(fake.secret_json.lock().unwrap().as_deref().unwrap()).unwrap(); + assert_eq!(v["TRIPLEC_AGENT_TOKEN"], ours.as_str()); + assert!(v.get("TRIPLEC_FILES").is_some(), "the rest of the payload is kept"); +} + +#[tokio::test] +async fn an_unchanged_secret_is_not_rewritten_after_a_start() { + let p = aws_project(StorageMode::Ephemeral); + let fake = Fake { run: Some(task(&p, "new", "RUNNING", true)), ..Default::default() }; + start(&fake, &p).await.0.unwrap(); + assert_eq!(fake.calls().iter().filter(|c| *c == "put-secret").count(), 1); +} + +#[test] +fn a_refused_token_says_to_restart_the_workspace() { + assert_eq!( + refusal_message("Agent health check failed: the agent rejected the credentials (401)"), + TOKEN_MISMATCH + ); + assert!(refusal_message("Agent health check failed: agent returned HTTP 500").contains("500")); +} + +#[tokio::test] +async fn reconcile_finds_a_task_whose_arn_was_never_stored() { + // The app quit during "Waiting for the task" in a build that stored the + // ARN only at the end: the task is found by family and tags. + let mut p = aws_project(StorageMode::Ephemeral); + p.status = ProjectStatus::Running; + let live = task(&p, "orphan", "RUNNING", true); + let fake = Fake { listed: Mutex::new(vec![vec![live.arn.clone()]]), ..Default::default() } + .with_task(live); + let (_d, store) = store_with(&p); + assert!(matches!( + reconcile_project(&fake, &store, &p, &find(&p)).await.unwrap(), + Reconciled::Live(_) + )); + let after = store.get(&p.id).unwrap(); + assert_eq!(after.status, ProjectStatus::Running); + assert_eq!(after.aws.unwrap().task_arn, Some(task_arn("orphan")), "now stored"); + + let nothing = Fake::default(); + let (_d, store) = store_with(&p); + assert_eq!(reconcile_project(¬hing, &store, &p, &find(&p)).await.unwrap(), Reconciled::Gone); + assert_eq!(store.get(&p.id).unwrap().status, ProjectStatus::Stopped); +} + +#[test] +fn a_reattach_registers_only_while_the_project_still_runs_that_task() { + let mut p = aws_project(StorageMode::Ephemeral); + p.status = ProjectStatus::Running; + p.aws.as_mut().unwrap().task_arn = Some(task_arn("t")); + let (_d, store) = store_with(&p); + assert!(reattach_still_wanted(&store, &p.id, &task_arn("t"))); + assert!(!reattach_still_wanted(&store, &p.id, &task_arn("other"))); + store.update_status(&p.id, ProjectStatus::Stopping).unwrap(); + assert!(!reattach_still_wanted(&store, &p.id, &task_arn("t"))); + store.remove(&p.id).unwrap(); + assert!(!reattach_still_wanted(&store, &p.id, &task_arn("t"))); +} + +#[test] +fn a_start_records_its_task_and_keeps_the_stored_handles_it_does_not_know() { + let mut p = aws_project(StorageMode::Hybrid); + p.aws.as_mut().unwrap().access_point_id = Some("fsap-old".into()); + let (_d, store) = store_with(&p); + record_task( + &store, + &p.id, + Some(&TaskRecord { task_arn: "arn:t".into(), access_point_id: None, task_definition_arn: Some("arn:td:2".into()) }), + ); + let a = store.get(&p.id).unwrap().aws.unwrap(); + assert_eq!((a.task_arn.as_deref(), a.access_point_id.as_deref(), a.task_definition_arn.as_deref()), + (Some("arn:t"), Some("fsap-old"), Some("arn:td:2"))); + record_task(&store, &p.id, None); + let a = store.get(&p.id).unwrap().aws.unwrap(); + assert_eq!((a.task_arn, a.access_point_id.as_deref()), (None, Some("fsap-old"))); +} diff --git a/app/src-tauri/src/host/lifecycle.rs b/app/src-tauri/src/host/lifecycle.rs index d38ee78..aaea367 100644 --- a/app/src-tauri/src/host/lifecycle.rs +++ b/app/src-tauri/src/host/lifecycle.rs @@ -17,6 +17,7 @@ use super::ContainerTarget; use crate::docker; use crate::models::{AppSettings, Project}; use crate::models::container_config; +use crate::storage::projects_store::ProjectsStore; /// The host a project runs on: the local Docker daemon, or an ECS Fargate /// task in an AWS environment (`aws_host.rs`; the connection, region and @@ -56,17 +57,20 @@ impl ProjectHost { /// /// `progress` receives the same human-readable steps the start view has /// always shown. Failure leaves the caller to reset the project status. + /// `store`: the AWS host records a task's ARN in it as soon as the task + /// exists, so a crash during the long wait leaves it findable. pub async fn ensure_started( &self, project: &Project, settings: &AppSettings, + store: &ProjectsStore, progress: &(dyn Fn(&str) + Send + Sync), ) -> Result { match self { ProjectHost::Docker => docker_ensure_started(project, settings, progress) .await .map(ContainerTarget::docker), - ProjectHost::Aws => super::aws_host::ensure_started(project, settings, progress).await, + ProjectHost::Aws => super::aws_host::ensure_started(project, settings, store, progress).await, } } @@ -329,6 +333,8 @@ mod tests { async fn a_project_container_round_trips_through_the_host() { let project = Project::new("lifecycle-live-test".to_string(), Vec::new()); let settings = AppSettings::default(); + let store_dir = tempfile::tempdir().expect("temp dir"); + let store = ProjectsStore::over_file_for_test(store_dir.path().join("projects.json"), Vec::new()); let host = ProjectHost::for_project(&project); let steps = std::sync::Mutex::new(Vec::::new()); let progress = |m: &str| steps.lock().unwrap().push(m.to_string()); @@ -341,7 +347,7 @@ mod tests { let outcome = async { check(host.find_current(&project).await?.is_none(), "no container before the first start")?; - let first = host.ensure_started(&project, &settings, &progress).await?; + let first = host.ensure_started(&project, &settings, &store, &progress).await?; check(first.is_running().await?, "running after start")?; let (out, code) = first .exec_oneshot_env_status(vec!["id".into(), "-un".into()], vec![]) @@ -353,7 +359,7 @@ mod tests { check(first.has_exited().await, "exited after stop")?; // Same config, so the existing container is started, not recreated. - let second = host.ensure_started(&project, &settings, &progress).await?; + let second = host.ensure_started(&project, &settings, &store, &progress).await?; check(second == first, "restart reuses the container")?; check(second.is_running().await?, "running after restart")?; diff --git a/app/src-tauri/src/host/tunnels.rs b/app/src-tauri/src/host/tunnels.rs index 3c71721..831dff2 100644 --- a/app/src-tauri/src/host/tunnels.rs +++ b/app/src-tauri/src/host/tunnels.rs @@ -62,6 +62,9 @@ struct Registry { stopped: HashSet, /// Projects whose tunnel is being re-opened in the background. pending: HashSet, + /// Bumped by every [`close`] (a stop or remove): a background re-attach + /// that began under an older generation must not register. + generation: HashMap, } fn registry() -> &'static Mutex { @@ -132,11 +135,55 @@ pub(crate) fn owner_of(ep: &AgentEndpoint) -> Option<(String, SdkConfig)> { .map(|(id, t)| (id.clone(), t.sdk.clone())) } -/// Take the project's tunnel out and close it. +/// A background re-attach of `project_id` begins: marks it pending and +/// returns the generation [`register_if_current`] must still see. +pub fn begin_reattach(project_id: &str) -> u64 { + let g = { + let mut r = lock(); + r.pending.insert(project_id.to_string()); + *r.generation.entry(project_id.to_string()).or_default() + }; + notify(); + g +} + +/// [`register`], unless the project was closed (stopped, removed) since +/// `generation` was taken: then the tunnel is handed back for the caller to +/// close, and nothing is registered. +pub fn register_if_current( + project_id: &str, + generation: u64, + tunnel: LiveTunnel, +) -> Result, Box> { + let old = { + let mut r = lock(); + if r.generation.get(project_id).copied().unwrap_or_default() != generation { + return Err(Box::new(tunnel)); + } + r.stopped.remove(tunnel.endpoint.base_url.as_str()); + r.pending.remove(project_id); + r.live.insert(project_id.to_string(), tunnel) + }; + notify(); + Ok(old) +} + +/// [`close`], but only when the registered tunnel leads to `task_arn` (a +/// death report about an older task must not close a newer one's tunnel). +pub async fn close_if_task(project_id: &str, task_arn: &str) { + let matches = lock().live.get(project_id).is_some_and(|t| t.task_arn == task_arn); + if matches { + close(project_id).await; + } +} + +/// Take the project's tunnel out and close it, and invalidate a background +/// re-attach in flight. pub async fn close(project_id: &str) { let taken = { let mut r = lock(); r.pending.remove(project_id); + *r.generation.entry(project_id.to_string()).or_default() += 1; let t = r.live.remove(project_id); if let Some(ref t) = t { r.stopped.remove(t.endpoint.base_url.as_str()); @@ -155,6 +202,9 @@ pub async fn close_all() { let mut r = lock(); r.pending.clear(); r.stopped.clear(); + for g in r.generation.values_mut() { + *g += 1; + } r.live.drain().map(|(_, t)| t).collect() }; notify(); @@ -279,6 +329,37 @@ pub(crate) mod tests { assert_eq!(closes.load(Ordering::SeqCst), 2); } + #[tokio::test] + async fn a_reattach_that_began_before_a_stop_does_not_register() { + let closes = Arc::new(AtomicUsize::new(0)); + let p = pid("gen"); + let g = begin_reattach(&p); + assert!(is_pending(&p)); + close(&p).await; // the user stopped the project meanwhile + let back = register_if_current(&p, g, fake_tunnel(61005, closes.clone())); + let t = back.err().expect("handed back, not registered"); + assert!(get_endpoint(&p).is_none() && !is_pending(&p)); + t.forward.close().await; + assert_eq!(closes.load(Ordering::SeqCst), 1); + + let g = begin_reattach(&p); + assert!(register_if_current(&p, g, fake_tunnel(61006, closes.clone())).is_ok()); + assert!(get_endpoint(&p).is_some()); + close(&p).await; + } + + #[tokio::test] + async fn a_death_report_closes_only_the_tunnel_of_the_task_that_died() { + let closes = Arc::new(AtomicUsize::new(0)); + let p = pid("died"); + register(&p, fake_tunnel(61007, closes.clone())); + close_if_task(&p, "arn:aws:ecs:eu-west-1:111122223333:task/triple-c-dev/older").await; + assert!(get_endpoint(&p).is_some(), "a newer task's tunnel stays"); + close_if_task(&p, "arn:aws:ecs:eu-west-1:111122223333:task/triple-c-dev/t61007").await; + assert!(get_endpoint(&p).is_none()); + assert_eq!(closes.load(Ordering::SeqCst), 1); + } + #[tokio::test] async fn waiting_for_an_endpoint_ends_when_it_is_registered_or_the_reattach_gives_up() { let p = pid("wait"); -- 2.52.0 From 8d45a93d630b50cfb352a8b790864412929b3032 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 09:30:17 -0800 Subject: [PATCH 12/43] fix(aws): a start cancels a pending re-attach; keep an unstoppable task recorded ensure_started cancels a background re-attach instead of waiting for it, and every register bumps the project's generation, so a late re-attach can never replace a newer tunnel. A task StopTask refused after a failed start keeps its ARN. A re-attach that gives up clears the pending flag only if still current; close_if_task checks and takes under one lock. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/host/aws_host.rs | 34 ++++--- app/src-tauri/src/host/aws_host/tests.rs | 27 ++++++ app/src-tauri/src/host/tunnels.rs | 113 ++++++++++++++++------- 3 files changed, 128 insertions(+), 46 deletions(-) diff --git a/app/src-tauri/src/host/aws_host.rs b/app/src-tauri/src/host/aws_host.rs index 4a58e98..a170cb6 100644 --- a/app/src-tauri/src/host/aws_host.rs +++ b/app/src-tauri/src/host/aws_host.rs @@ -384,9 +384,15 @@ async fn with_log_tail(api: &T, logs: &TaskLogs, arn: &str, err: Str } /// Stop a task this start ran, best-effort, so nothing billed is orphaned. -async fn stop_created(api: &T, cluster: &str, arn: &str) { - if let Err(e) = api.stop_task(cluster, arn, STOP_REASON).await { - log::warn!("Could not stop task {arn} after a failed start: {e}"); +/// `true` when StopTask succeeded; otherwise the task may still run and its +/// ARN must stay recorded. +async fn stop_created(api: &T, cluster: &str, arn: &str) -> bool { + match api.stop_task(cluster, arn, STOP_REASON).await { + Ok(()) => true, + Err(e) => { + log::warn!("Could not stop task {arn} after a failed start: {e}"); + false + } } } @@ -485,8 +491,9 @@ pub async fn start_core t, Err(e) => { let msg = with_log_tail(tasks, &logs, &arn, e.to_string()).await; - stop_created(tasks, &env.cluster_arn, &arn).await; - (inp.on_task)(None); + if stop_created(tasks, &env.cluster_arn, &arn).await { + (inp.on_task)(None); + } return Err(msg); } }; @@ -522,8 +529,7 @@ pub async fn start_core { - if created { - stop_created(tasks, &env.cluster_arn, &task.arn).await; + if created && stop_created(tasks, &env.cluster_arn, &task.arn).await { (inp.on_task)(None); } Err(e) @@ -551,12 +557,10 @@ pub async fn ensure_started( store: &ProjectsStore, progress: &(dyn Fn(&str) + Send + Sync), ) -> Result { - // A background re-attach (after a restart) is opening the tunnel: wait - // for it rather than racing it with a second one. - if tunnels::is_pending(&project.id) { - progress(PROGRESS_CONNECTING); - tunnels::wait_endpoint(&project.id, REATTACH_WAIT).await; - } + // A background re-attach (after a restart) may be opening a tunnel: + // cancel it, this start attaches itself. Its tunnel, if it gets one, is + // refused by the registry and closed. + tunnels::cancel_reattach(&project.id); // Already connected and answering: nothing to do. if let Some(ep) = tunnels::get_endpoint(&project.id) { if matches!(agent::is_running(&ep).await, Ok(true)) { @@ -884,7 +888,7 @@ pub async fn reconcile(project: &Project, settings: &AppSettings, store: Arc>, /// A losing start rotates the secret's token while this one connects. rotated_by_loser: bool, + /// StopTask is refused (expired credentials, say). + stop_fails: bool, /// What `on_task` was told, in order. recorded: Mutex>>, } @@ -129,6 +131,9 @@ impl TaskApi for Fake { } async fn stop_task(&self, _c: &str, arn: &str, reason: &str) -> Result<(), AwsError> { self.log(format!("stop {arn} {reason}")); + if self.stop_fails { + return Err(AwsError::Other { message: "ExpiredToken".into() }); + } Ok(()) } async fn log_tail(&self, _g: &str, _s: &str, _l: i32) -> Result, AwsError> { @@ -671,3 +676,25 @@ fn a_start_records_its_task_and_keeps_the_stored_handles_it_does_not_know() { let a = store.get(&p.id).unwrap().aws.unwrap(); assert_eq!((a.task_arn, a.access_point_id.as_deref()), (None, Some("fsap-old"))); } + +#[tokio::test] +async fn a_task_that_could_not_be_stopped_after_a_failed_start_stays_recorded() { + let p = aws_project(StorageMode::Ephemeral); + let fake = Fake { + run: Some(task(&p, "slow", "PENDING", false)), + stop_fails: true, + ..Default::default() + }; + assert!(start(&fake, &p).await.0.is_err()); + assert!(fake.called("stop")); + assert_eq!(*fake.recorded.lock().unwrap(), vec![Some(task_arn("slow"))], "still findable"); + + let fake = Fake { + run: Some(task(&p, "new", "RUNNING", true)), + connect_fails: true, + stop_fails: true, + ..Default::default() + }; + assert!(start(&fake, &p).await.0.is_err()); + assert_eq!(*fake.recorded.lock().unwrap(), vec![Some(task_arn("new"))]); +} diff --git a/app/src-tauri/src/host/tunnels.rs b/app/src-tauri/src/host/tunnels.rs index 831dff2..49c760e 100644 --- a/app/src-tauri/src/host/tunnels.rs +++ b/app/src-tauri/src/host/tunnels.rs @@ -62,8 +62,9 @@ struct Registry { stopped: HashSet, /// Projects whose tunnel is being re-opened in the background. pending: HashSet, - /// Bumped by every [`close`] (a stop or remove): a background re-attach - /// that began under an older generation must not register. + /// Bumped by every [`close`] (a stop or remove), every [`register`] (a + /// start's own tunnel) and [`cancel_reattach`]: a background re-attach + /// that began under an older generation must never register. generation: HashMap, } @@ -91,6 +92,7 @@ fn notify() { pub fn register(project_id: &str, tunnel: LiveTunnel) -> Option { let old = { let mut r = lock(); + *r.generation.entry(project_id.to_string()).or_default() += 1; r.stopped.remove(tunnel.endpoint.base_url.as_str()); r.pending.remove(project_id); r.live.insert(project_id.to_string(), tunnel) @@ -171,25 +173,57 @@ pub fn register_if_current( /// [`close`], but only when the registered tunnel leads to `task_arn` (a /// death report about an older task must not close a newer one's tunnel). pub async fn close_if_task(project_id: &str, task_arn: &str) { - let matches = lock().live.get(project_id).is_some_and(|t| t.task_arn == task_arn); - if matches { - close(project_id).await; + let taken = { + let mut r = lock(); + if !r.live.get(project_id).is_some_and(|t| t.task_arn == task_arn) { + return; + } + take(&mut r, project_id) + }; + notify(); + if let Some(t) = taken { + t.forward.close().await; } } +/// Under the lock: invalidate a re-attach in flight and take the tunnel out. +fn take(r: &mut Registry, project_id: &str) -> Option { + r.pending.remove(project_id); + *r.generation.entry(project_id.to_string()).or_default() += 1; + let t = r.live.remove(project_id); + if let Some(ref t) = t { + r.stopped.remove(t.endpoint.base_url.as_str()); + } + t +} + +/// A start is about to attach itself: any background re-attach in flight +/// is cancelled (it will hand its tunnel back and close it). +pub fn cancel_reattach(project_id: &str) { + { + let mut r = lock(); + r.pending.remove(project_id); + *r.generation.entry(project_id.to_string()).or_default() += 1; + } + notify(); +} + +/// A background re-attach that began at `generation` gave up: clear the +/// pending flag, unless something newer has taken over since. +pub fn end_reattach(project_id: &str, generation: u64) { + { + let mut r = lock(); + if r.generation.get(project_id).copied().unwrap_or_default() == generation { + r.pending.remove(project_id); + } + } + notify(); +} + /// Take the project's tunnel out and close it, and invalidate a background /// re-attach in flight. pub async fn close(project_id: &str) { - let taken = { - let mut r = lock(); - r.pending.remove(project_id); - *r.generation.entry(project_id.to_string()).or_default() += 1; - let t = r.live.remove(project_id); - if let Some(ref t) = t { - r.stopped.remove(t.endpoint.base_url.as_str()); - } - t - }; + let taken = take(&mut lock(), project_id); notify(); if let Some(t) = taken { t.forward.close().await; @@ -221,19 +255,6 @@ pub fn is_stopped(target_id: &str) -> bool { lock().stopped.contains(target_id) } -/// A background re-attach for this project has begun / ended without a tunnel. -pub fn set_pending(project_id: &str, pending: bool) { - { - let mut r = lock(); - if pending { - r.pending.insert(project_id.to_string()); - } else { - r.pending.remove(project_id); - } - } - notify(); -} - pub fn is_pending(project_id: &str) -> bool { lock().pending.contains(project_id) } @@ -348,6 +369,36 @@ pub(crate) mod tests { close(&p).await; } + #[tokio::test] + async fn a_starts_tunnel_survives_a_reattach_that_finishes_after_it() { + let closes = Arc::new(AtomicUsize::new(0)); + // Start cancels the pending re-attach, then registers its own. + let p = pid("start-wins"); + let g = begin_reattach(&p); + cancel_reattach(&p); + assert!(!is_pending(&p)); + register(&p, fake_tunnel(61010, closes.clone())); + let late = register_if_current(&p, g, fake_tunnel(61011, closes.clone())); + late.err().expect("the late re-attach is refused").forward.close().await; + assert_eq!(get_endpoint(&p).unwrap().base_url.port(), Some(61010), "Start's tunnel stays"); + assert_eq!(closes.load(Ordering::SeqCst), 1, "only the late tunnel was closed"); + // A stale end of that re-attach does not clear a newer one's pending flag. + let g2 = begin_reattach(&p); + end_reattach(&p, g); + assert!(is_pending(&p)); + end_reattach(&p, g2); + assert!(!is_pending(&p)); + close(&p).await; + + // Even without a cancel, a plain register makes an older re-attach stale. + let p = pid("register-bumps"); + let g = begin_reattach(&p); + register(&p, fake_tunnel(61012, closes.clone())); + assert!(register_if_current(&p, g, fake_tunnel(61013, closes.clone())).is_err()); + assert_eq!(get_endpoint(&p).unwrap().base_url.port(), Some(61012)); + close(&p).await; + } + #[tokio::test] async fn a_death_report_closes_only_the_tunnel_of_the_task_that_died() { let closes = Arc::new(AtomicUsize::new(0)); @@ -364,7 +415,7 @@ pub(crate) mod tests { async fn waiting_for_an_endpoint_ends_when_it_is_registered_or_the_reattach_gives_up() { let p = pid("wait"); assert!(wait_endpoint(&p, Duration::from_secs(5)).await.is_none(), "nothing pending"); - set_pending(&p, true); + begin_reattach(&p); let p2 = p.clone(); let waiter = tokio::spawn(async move { wait_endpoint(&p2, Duration::from_secs(5)).await }); tokio::time::sleep(Duration::from_millis(20)).await; @@ -372,11 +423,11 @@ pub(crate) mod tests { assert_eq!(waiter.await.unwrap().unwrap().base_url.port(), Some(61004)); close(&p).await; - set_pending(&p, true); + let g = begin_reattach(&p); let p2 = p.clone(); let waiter = tokio::spawn(async move { wait_endpoint(&p2, Duration::from_secs(5)).await }); tokio::time::sleep(Duration::from_millis(20)).await; - set_pending(&p, false); + end_reattach(&p, g); assert!(waiter.await.unwrap().is_none()); } } -- 2.52.0 From 51f71212d203a536225128e53ea93f4cac823447 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 09:46:03 -0800 Subject: [PATCH 13/43] =?UTF-8?q?feat(container):=20aws=20mode=20=E2=80=94?= =?UTF-8?q?=20secret=20files,=20repo=20clone,=20Hybrid=20home,=20WIP=20pus?= =?UTF-8?q?h,=20idle=20stop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With TRIPLEC_HOST=aws the entrypoint decodes TRIPLEC_FILES into /tmp/.host-ssh and /tmp/.host-ca and unsets it (and TRIPLEC_REPOS) before the agent's environment snapshot; links ~/.claude, ~/.claude.json and ~/.ssh into /mnt/triple-c-home for Hybrid storage; clones TRIPLEC_REPOS as claude after git credentials and ~/.ssh are set up; runs triple-c-wip-push from the TERM trap; and starts triple-c-idle-watch, which signals the entrypoint itself (PID 1 is docker-init under initProcessEnabled). Docker mode is unchanged: every new step is gated on TRIPLEC_HOST=aws. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/docker/aws_mode_tests.rs | 714 +++++++++++++++++++++ app/src-tauri/src/docker/image.rs | 4 + app/src-tauri/src/docker/mod.rs | 2 + container/Dockerfile | 5 + container/entrypoint.sh | 319 ++++++++- container/triple-c-idle-watch | 128 ++++ container/triple-c-wip-push | 146 +++++ 7 files changed, 1308 insertions(+), 10 deletions(-) create mode 100644 app/src-tauri/src/docker/aws_mode_tests.rs create mode 100755 container/triple-c-idle-watch create mode 100755 container/triple-c-wip-push diff --git a/app/src-tauri/src/docker/aws_mode_tests.rs b/app/src-tauri/src/docker/aws_mode_tests.rs new file mode 100644 index 0000000..83e9601 --- /dev/null +++ b/app/src-tauri/src/docker/aws_mode_tests.rs @@ -0,0 +1,714 @@ +//! The container side of an AWS workspace (`TRIPLEC_HOST=aws`): the marked +//! blocks of `entrypoint.sh` and the two scripts it drives, run under bash in +//! temp dirs — the `sync_host_ssh` pattern in `image.rs`. Root-only steps +//! (chown, `su claude`) are switched off by text substitution, so these run +//! the same as root (CI) and as a user. + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::{Command, Output}; + +const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh"); +const WIP_PUSH: &str = include_str!("../../../../container/triple-c-wip-push"); +const IDLE_WATCH: &str = include_str!("../../../../container/triple-c-idle-watch"); +const SCHEDULER: &str = include_str!("../../../../container/triple-c-scheduler"); + +fn block<'a>(text: &'a str, name: &str) -> &'a str { + let start = text + .find(&format!("# >>> {name}\n")) + .unwrap_or_else(|| panic!("start marker {name}")); + let end = text + .find(&format!("# <<< {name}\n")) + .unwrap_or_else(|| panic!("end marker {name}")); + &text[start..end] +} + +/// The aws blocks with the container user switched off: no `su claude`, no +/// chown to a user the test host may not have. +fn aws_blocks(names: &[&str]) -> String { + names + .iter() + .map(|n| block(ENTRYPOINT, n).replace("triplec_run_as=claude", "triplec_run_as=")) + .collect::>() + .join("\n") +} + +fn bash(script: &str, args: &[&Path], env: &[(&str, &str)], clear_env: &[&str]) -> Output { + let mut cmd = Command::new("bash"); + cmd.arg("-c").arg(script).arg("test"); + cmd.args(args); + for k in clear_env { + cmd.env_remove(k); + } + for (k, v) in env { + cmd.env(k, v); + } + cmd.output().expect("run bash") +} + +fn text(o: &Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + ) +} + +fn mode(p: &Path) -> u32 { + fs::metadata(p).unwrap().permissions().mode() & 0o777 +} + +fn b64(s: &str) -> String { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(s) +} + +fn git(dir: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .env("GIT_AUTHOR_NAME", "t") + .env("GIT_AUTHOR_EMAIL", "t@example.invalid") + .env("GIT_COMMITTER_NAME", "t") + .env("GIT_COMMITTER_EMAIL", "t@example.invalid") + .env("GIT_CONFIG_NOSYSTEM", "1") + .output() + .expect("git"); + assert!( + out.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +// ------------------------------------------------------------ secret files + +fn files_script(tmp: &Path) -> String { + let ssh = tmp.join("host-ssh"); + let ca = tmp.join("host-ca"); + let shm = tmp.join("shm"); + let blocks = aws_blocks(&["aws_env"]) + .replace("/tmp/.host-ssh", ssh.to_str().unwrap()) + .replace("/tmp/.host-ca", ca.to_str().unwrap()); + // The agent-token block too, so the snapshot it takes is checked; its + // token file goes to the temp dir rather than the real /dev/shm. + let agent = block(ENTRYPOINT, "agent_token").replace("/dev/shm/triple-c", shm.to_str().unwrap()); + format!( + "{blocks}\n{agent}\n\ + env -0 > \"$1/env\"\n\ + printf '%s\\0' \"${{agent_env[@]}}\" > \"$1/agent_env\"\n\ + printf '%s' \"${{triplec_repos_json:-}}\" > \"$1/repos\"\n" + ) +} + +#[test] +fn aws_files_decode_to_the_host_dirs_mode_600_and_leave_the_environment() { + let tmp = tempfile::tempdir().unwrap(); + let files = format!( + r#"{{"ssh/id_ed25519":"{}","ssh/config":"{}","ca/corp.crt":"{}","ssh/..":"{}","ssh/a/b":"{}","ssh/":"{}","home/x":"{}","ca/bad":"%%%"}}"#, + b64("PRIVATE-KEY-BODY"), + b64("Host *\n"), + b64("-----BEGIN CERTIFICATE-----"), + b64("x"), + b64("x"), + b64("x"), + b64("x"), + ); + let repos = r#"[{"url":"https://example.invalid/r.git","branch":null,"folder":"r"}]"#; + let out = bash( + &files_script(tmp.path()), + &[tmp.path()], + &[ + ("TRIPLEC_HOST", "aws"), + ("TRIPLEC_FILES", &files), + ("TRIPLEC_REPOS", repos), + ("TRIPLEC_AGENT_TOKEN", "test-token-not-real"), + ], + &[], + ); + let log = text(&out); + assert!(out.status.success(), "{log}"); + + let ssh = tmp.path().join("host-ssh"); + let ca = tmp.path().join("host-ca"); + assert_eq!(fs::read_to_string(ssh.join("id_ed25519")).unwrap(), "PRIVATE-KEY-BODY"); + assert_eq!(fs::read_to_string(ssh.join("config")).unwrap(), "Host *\n"); + assert_eq!(fs::read_to_string(ca.join("corp.crt")).unwrap(), "-----BEGIN CERTIFICATE-----"); + for f in [ssh.join("id_ed25519"), ssh.join("config"), ca.join("corp.crt")] { + assert_eq!(mode(&f), 0o600, "{}", f.display()); + } + assert_eq!(mode(&ssh), 0o700); + assert_eq!(mode(&ca), 0o700); + + // Exactly the three good files; every bad name refused out loud. + let mut names: Vec<_> = fs::read_dir(&ssh).unwrap().map(|e| e.unwrap().file_name()).collect(); + names.sort(); + assert_eq!(names, ["config", "id_ed25519"]); + assert_eq!(fs::read_dir(&ca).unwrap().count(), 1); + assert!(!tmp.path().join("a").exists() && !tmp.path().join("x").exists()); + for bad in ["ssh/..", "ssh/a/b", "\"ssh/\"", "home/x", "ca/bad"] { + assert!(log.contains(&format!("refusing")) && log.contains(bad.trim_matches('"')), "{bad}: {log}"); + } + + // Gone from the environment, and from the agent's snapshot of it. + let env = fs::read_to_string(tmp.path().join("env")).unwrap(); + assert!(!env.contains("TRIPLEC_FILES="), "{env}"); + assert!(!env.contains("TRIPLEC_REPOS=")); + assert!(!env.contains("PRIVATE-KEY-BODY")); + let agent_env = fs::read_to_string(tmp.path().join("agent_env")).unwrap(); + assert!(!agent_env.contains("TRIPLEC_FILES"), "{agent_env}"); + assert!(!agent_env.contains("TRIPLEC_REPOS")); + assert!(!agent_env.contains(&b64("PRIVATE-KEY-BODY"))); + // The repo list survives as an unexported shell variable for clone_repos. + assert_eq!(fs::read_to_string(tmp.path().join("repos")).unwrap(), repos); +} + +#[test] +fn outside_aws_mode_the_files_variable_is_left_alone() { + let tmp = tempfile::tempdir().unwrap(); + let files = format!(r#"{{"ssh/id":"{}"}}"#, b64("k")); + let out = bash( + &files_script(tmp.path()), + &[tmp.path()], + &[("TRIPLEC_FILES", &files)], + &["TRIPLEC_HOST", "TRIPLEC_AGENT_TOKEN"], + ); + assert!(out.status.success(), "{}", text(&out)); + assert!(!tmp.path().join("host-ssh").exists()); + let env = fs::read_to_string(tmp.path().join("env")).unwrap(); + assert!(env.contains("TRIPLEC_FILES=")); +} + +#[test] +fn malformed_files_json_writes_nothing_and_does_not_fail_the_start() { + let tmp = tempfile::tempdir().unwrap(); + let out = bash( + &files_script(tmp.path()), + &[tmp.path()], + &[("TRIPLEC_HOST", "aws"), ("TRIPLEC_FILES", "not json")], + &["TRIPLEC_AGENT_TOKEN"], + ); + assert!(out.status.success(), "{}", text(&out)); + assert!(text(&out).contains("TRIPLEC_FILES"), "{}", text(&out)); + assert!(!tmp.path().join("host-ssh").join("x").exists()); + let env = fs::read_to_string(tmp.path().join("env")).unwrap(); + assert!(!env.contains("TRIPLEC_FILES=")); +} + +// -------------------------------------------------------- Hybrid home links + +fn link_home(home: &Path, persist: &Path) -> Output { + let script = format!("{}\nlink_persistent_home \"$1\" \"$2\" \"\"\n", aws_blocks(&["aws_env", "aws_home"])); + bash(&script, &[home, persist], &[], &[]) +} + +#[test] +fn link_persistent_home_seeds_on_first_run_and_is_idempotent() { + let tmp = tempfile::tempdir().unwrap(); + let (home, persist) = (tmp.path().join("home"), tmp.path().join("mnt")); + fs::create_dir_all(home.join(".claude/bin")).unwrap(); + fs::write(home.join(".claude/bin/seed"), "from-image").unwrap(); + fs::create_dir_all(home.join(".ssh")).unwrap(); + fs::write(home.join(".ssh/known_hosts"), "image-hosts").unwrap(); + fs::create_dir_all(&persist).unwrap(); + + let out = link_home(&home, &persist); + assert!(out.status.success(), "{}", text(&out)); + for name in [".claude", ".claude.json", ".ssh"] { + let link = home.join(name); + assert!(fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "{name}"); + assert_eq!(fs::read_link(&link).unwrap(), persist.join(name)); + } + assert_eq!(fs::read_to_string(persist.join(".claude/bin/seed")).unwrap(), "from-image"); + assert_eq!(fs::read_to_string(persist.join(".ssh/known_hosts")).unwrap(), "image-hosts"); + assert_eq!(fs::read_to_string(persist.join(".claude.json")).unwrap().trim(), "{}"); + + // State written through the links, then a second run on the same home. + fs::write(home.join(".claude/session"), "kept").unwrap(); + fs::write(home.join(".claude.json"), "{\"login\":1}").unwrap(); + let out = link_home(&home, &persist); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(fs::read_to_string(persist.join(".claude/session")).unwrap(), "kept"); + assert_eq!(fs::read_to_string(persist.join(".claude.json")).unwrap(), "{\"login\":1}"); + assert_eq!(fs::read_link(home.join(".claude")).unwrap(), persist.join(".claude")); + + // The next task starts from the image again: its fresh copies are + // replaced by the links and the persisted state wins. + fs::remove_file(home.join(".claude")).unwrap(); + fs::remove_file(home.join(".claude.json")).unwrap(); + fs::create_dir_all(home.join(".claude")).unwrap(); + fs::write(home.join(".claude/session"), "image-copy").unwrap(); + let out = link_home(&home, &persist); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(fs::read_link(home.join(".claude")).unwrap(), persist.join(".claude")); + assert_eq!(fs::read_to_string(home.join(".claude/session")).unwrap(), "kept"); + assert_eq!(fs::read_to_string(home.join(".claude.json")).unwrap(), "{\"login\":1}"); +} + +#[test] +fn link_persistent_home_without_the_mount_changes_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("home"); + fs::create_dir_all(home.join(".claude")).unwrap(); + let out = link_home(&home, &tmp.path().join("absent")); + assert!(out.status.success()); + assert!(text(&out).contains("not mounted"), "{}", text(&out)); + assert!(fs::symlink_metadata(home.join(".claude")).unwrap().is_dir()); +} + +#[test] +fn write_claude_json_keeps_a_linked_claude_json_linked() { + let tmp = tempfile::tempdir().unwrap(); + let (home, persist) = (tmp.path().join("home"), tmp.path().join("mnt")); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&persist).unwrap(); + fs::write(persist.join(".claude.json"), "{}").unwrap(); + std::os::unix::fs::symlink(persist.join(".claude.json"), home.join(".claude.json")).unwrap(); + let f = block(ENTRYPOINT, "write_claude_json"); + // chown to a user the host may not have is noise here, not the point. + let script = format!("chown() {{ :; }}\n{f}\nCLAUDE_JSON=\"$1/.claude.json\"\nwrite_claude_json '{{\"a\":1}}'\n"); + let out = bash(&script, &[&home], &[], &[]); + assert!(out.status.success(), "{}", text(&out)); + assert!(fs::symlink_metadata(home.join(".claude.json")).unwrap().file_type().is_symlink()); + assert_eq!(fs::read_to_string(persist.join(".claude.json")).unwrap().trim(), "{\"a\":1}"); +} + +// --------------------------------------------------------------- clone_repos + +fn bare_remote(tmp: &Path, name: &str) -> std::path::PathBuf { + let work = tmp.join(format!("{name}-src")); + let bare = tmp.join(format!("{name}.git")); + fs::create_dir_all(&work).unwrap(); + git(&work, &["init", "-q", "-b", "main"]); + fs::write(work.join("README"), "hello").unwrap(); + git(&work, &["add", "README"]); + git(&work, &["commit", "-q", "-m", "init"]); + git(&work, &["branch", "dev"]); + git(tmp, &["clone", "-q", "--bare", work.to_str().unwrap(), bare.to_str().unwrap()]); + bare +} + +#[test] +fn clone_repos_clones_skips_existing_and_reports_failures_without_failing() { + let tmp = tempfile::tempdir().unwrap(); + let ws = tmp.path().join("workspace"); + fs::create_dir_all(&ws).unwrap(); + let remote = bare_remote(tmp.path(), "proj"); + // An existing repo whose recorded URL is bad: skipping means no attempt. + let existing = ws.join("existing"); + fs::create_dir_all(&existing).unwrap(); + git(&existing, &["init", "-q"]); + let json = format!( + r#"[{{"url":"{r}","branch":null,"folder":"main-copy"}}, + {{"url":"{r}","branch":"dev","folder":"dev-copy"}}, + {{"url":"/nonexistent/nope.git","branch":null,"folder":"existing"}}, + {{"url":"/nonexistent/nope.git","branch":null,"folder":"broken"}}, + {{"url":"{r}","branch":null,"folder":"../escape"}}]"#, + r = remote.display() + ); + let script = format!("{}\nclone_repos \"$JSON\" \"$1\"\necho \"rc=$?\"\n", aws_blocks(&["aws_env", "aws_repos"])); + let out = bash(&script, &[&ws], &[("JSON", &json)], &[]); + let log = text(&out); + assert!(out.status.success(), "{log}"); + assert!(log.contains("rc=0"), "{log}"); + + assert_eq!(fs::read_to_string(ws.join("main-copy/README")).unwrap(), "hello"); + assert_eq!(git(&ws.join("dev-copy"), &["rev-parse", "--abbrev-ref", "HEAD"]), "dev"); + assert!(log.contains("Triple-C: clone of broken failed:"), "{log}"); + assert!(!log.contains("clone of existing failed"), "{log}"); + assert!(log.contains("../escape"), "a bad folder is refused by name: {log}"); + assert!(!tmp.path().join("escape").exists()); +} + +#[test] +fn clone_repos_with_malformed_json_reports_and_returns_zero() { + let tmp = tempfile::tempdir().unwrap(); + let script = format!("{}\nclone_repos 'nope' \"$1\"; echo \"rc=$?\"\n", aws_blocks(&["aws_env", "aws_repos"])); + let out = bash(&script, &[tmp.path()], &[], &[]); + assert!(text(&out).contains("rc=0"), "{}", text(&out)); + assert!(text(&out).contains("TRIPLEC_REPOS"), "{}", text(&out)); +} + +// ------------------------------------------------------------ WIP push + +fn write_script(dir: &Path, name: &str, body: &str) -> std::path::PathBuf { + let p = dir.join(name); + fs::write(&p, body).unwrap(); + fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap(); + p +} + +fn wip_push(ws: &Path, args: &[&str]) -> Output { + let tmp = ws.parent().unwrap(); + let script = write_script(tmp, "triple-c-wip-push", WIP_PUSH); + Command::new(script) + .args(args) + .env("TRIPLEC_WORKSPACE_ROOT", ws) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("HOME", tmp) + .output() + .unwrap() +} + +/// A workspace holding one clone of a bare remote, with an identity set. +fn workspace_with_clone(tmp: &Path) -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) { + let remote = bare_remote(tmp, "origin"); + let ws = tmp.join("workspace"); + fs::create_dir_all(&ws).unwrap(); + git(&ws, &["clone", "-q", remote.to_str().unwrap(), "proj"]); + let repo = ws.join("proj"); + git(&repo, &["config", "user.name", "Dev"]); + git(&repo, &["config", "user.email", "dev@example.invalid"]); + (ws, repo, remote) +} + +fn wip_refs(remote: &Path) -> Vec { + let out = git(remote, &["for-each-ref", "--format=%(refname)", "refs/heads/triple-c/wip/"]); + out.lines().map(str::to_string).collect() +} + +#[test] +fn wip_push_pushes_the_whole_tree_and_leaves_the_repo_as_it_was() { + let tmp = tempfile::tempdir().unwrap(); + let (ws, repo, remote) = workspace_with_clone(tmp.path()); + // An unpushed commit, a staged change and an untracked file. + fs::write(repo.join("committed.txt"), "c").unwrap(); + git(&repo, &["add", "committed.txt"]); + git(&repo, &["commit", "-q", "-m", "local"]); + fs::write(repo.join("README"), "staged edit").unwrap(); + git(&repo, &["add", "README"]); + fs::write(repo.join("untracked.txt"), "u").unwrap(); + fs::write(repo.join(".gitignore"), "ignored.log\n").unwrap(); + fs::write(repo.join("ignored.log"), "noise").unwrap(); + + let status_before = git(&repo, &["status", "--porcelain"]); + let head_before = git(&repo, &["rev-parse", "HEAD"]); + let index_before = fs::read(repo.join(".git/index")).unwrap(); + + let out = wip_push(&ws, &["--reason", "stop"]); + assert!(out.status.success(), "{}", text(&out)); + + let refs = wip_refs(&remote); + assert_eq!(refs.len(), 1, "{refs:?}"); + let name = refs[0].trim_start_matches("refs/heads/triple-c/wip/"); + assert!( + name.len() == 15 && name.as_bytes()[8] == b'-' && name.chars().filter(|c| c.is_ascii_digit()).count() == 14, + "{name}" + ); + let r = &refs[0]; + assert_eq!(git(&remote, &["show", &format!("{r}:committed.txt")]), "c"); + assert_eq!(git(&remote, &["show", &format!("{r}:README")]), "staged edit"); + assert_eq!(git(&remote, &["show", &format!("{r}:untracked.txt")]), "u"); + assert!( + !git(&remote, &["ls-tree", "--name-only", r]).lines().any(|l| l == "ignored.log"), + "ignored files stay out" + ); + // The WIP commit sits on top of the user's HEAD. + assert_eq!(git(&remote, &["rev-parse", &format!("{r}^")]), head_before); + + assert_eq!(git(&repo, &["status", "--porcelain"]), status_before); + assert_eq!(git(&repo, &["rev-parse", "HEAD"]), head_before); + assert_eq!(fs::read(repo.join(".git/index")).unwrap(), index_before, "index untouched"); + assert_eq!(git(&repo, &["rev-parse", "--abbrev-ref", "HEAD"]), "main"); +} + +#[test] +fn wip_push_of_only_unpushed_commits_pushes_head() { + let tmp = tempfile::tempdir().unwrap(); + let (ws, repo, remote) = workspace_with_clone(tmp.path()); + fs::write(repo.join("x"), "x").unwrap(); + git(&repo, &["add", "x"]); + git(&repo, &["commit", "-q", "-m", "x"]); + let out = wip_push(&ws, &[]); + assert!(out.status.success(), "{}", text(&out)); + let refs = wip_refs(&remote); + assert_eq!(refs.len(), 1); + assert_eq!(git(&remote, &["rev-parse", &refs[0]]), git(&repo, &["rev-parse", "HEAD"])); +} + +#[test] +fn wip_push_dry_run_prints_one_json_line_per_repo_and_changes_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let (ws, repo, remote) = workspace_with_clone(tmp.path()); + git(&ws, &["clone", "-q", remote.to_str().unwrap(), "clean"]); + fs::create_dir_all(ws.join("not-a-repo")).unwrap(); + fs::write(repo.join("x"), "x").unwrap(); + git(&repo, &["add", "x"]); + git(&repo, &["commit", "-q", "-m", "x"]); + fs::write(repo.join("y"), "y").unwrap(); + + let out = wip_push(&ws, &["--dry-run"]); + assert!(out.status.success(), "{}", text(&out)); + let stdout = String::from_utf8_lossy(&out.stdout); + let lines: Vec = stdout + .lines() + .map(|l| serde_json::from_str(l).unwrap_or_else(|e| panic!("{l}: {e}"))) + .collect(); + assert_eq!(lines.len(), 2, "{stdout}"); + let by = |f: &str| lines.iter().find(|v| v["folder"] == f).unwrap().clone(); + assert_eq!( + by("proj"), + serde_json::json!({"folder": "proj", "dirty": true, "unpushed": 1, "branch": "main"}) + ); + assert_eq!( + by("clean"), + serde_json::json!({"folder": "clean", "dirty": false, "unpushed": 0, "branch": "main"}) + ); + assert!(wip_refs(&remote).is_empty()); +} + +#[test] +fn wip_push_of_clean_repos_pushes_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let (ws, _repo, remote) = workspace_with_clone(tmp.path()); + let out = wip_push(&ws, &["--reason", "idle"]); + assert!(out.status.success(), "{}", text(&out)); + assert!(wip_refs(&remote).is_empty()); +} + +#[test] +fn wip_push_fails_when_a_push_fails() { + let tmp = tempfile::tempdir().unwrap(); + let (ws, repo, _remote) = workspace_with_clone(tmp.path()); + git(&repo, &["remote", "set-url", "origin", "/nonexistent/gone.git"]); + fs::write(repo.join("y"), "y").unwrap(); + let out = wip_push(&ws, &[]); + assert!(!out.status.success(), "{}", text(&out)); + assert!(text(&out).contains("proj"), "{}", text(&out)); +} + +#[test] +fn wip_push_rejects_an_unknown_reason() { + let tmp = tempfile::tempdir().unwrap(); + let ws = tmp.path().join("workspace"); + fs::create_dir_all(&ws).unwrap(); + let out = wip_push(&ws, &["--reason", "whenever"]); + assert_eq!(out.status.code(), Some(2), "{}", text(&out)); +} + +// ------------------------------------------------------------ idle watch + +fn is_idle(args: &[&str]) -> bool { + let script = format!("{}\nis_idle \"$@\"\n", block(IDLE_WATCH, "is_idle")); + let mut cmd = Command::new("bash"); + cmd.arg("-c").arg(script).arg("t").args(args); + let out = cmd.output().unwrap(); + match out.status.code() { + Some(0) => true, + Some(1) => false, + _ => panic!("is_idle {args:?}: {}", text(&out)), + } +} + +#[test] +fn is_idle_needs_every_condition() { + // now last execs tunnels claude_running jobs_running minutes + assert!(is_idle(&["10000", "9400", "0", "0", "0", "0", "10"]), "exactly N minutes"); + assert!(is_idle(&["10000", "1000", "0", "0", "0", "0", "10"])); + assert!(!is_idle(&["10000", "9401", "0", "0", "0", "0", "10"]), "one second short"); + assert!(!is_idle(&["10000", "1000", "1", "0", "0", "0", "10"]), "an exec is open"); + assert!(!is_idle(&["10000", "1000", "0", "2", "0", "0", "10"]), "a tunnel is open"); + assert!(!is_idle(&["10000", "1000", "0", "0", "1", "0", "10"]), "claude is running"); + assert!(!is_idle(&["10000", "1000", "0", "0", "0", "1", "10"]), "a scheduler job runs"); + // Anything unreadable is "not idle". + assert!(!is_idle(&["10000", "", "0", "0", "0", "0", "10"])); + assert!(!is_idle(&["10000", "x", "0", "0", "0", "0", "10"])); + assert!(!is_idle(&["10000", "1000", "", "0", "0", "0", "10"])); + assert!(!is_idle(&["10000", "1000", "0", "0", "0", "0", "0"]), "0 minutes is off"); + assert!(!is_idle(&["10000", "1000", "0", "0", "0", "0", ""])); +} + +struct Watch { + tmp: tempfile::TempDir, +} + +impl Watch { + fn new() -> Self { + let tmp = tempfile::tempdir().unwrap(); + write_script(tmp.path(), "triple-c-idle-watch", IDLE_WATCH); + write_script(tmp.path(), "triple-c-scheduler", SCHEDULER); + fs::write(tmp.path().join("token"), "test-token-not-real").unwrap(); + fs::create_dir_all(tmp.path().join("home")).unwrap(); + Watch { tmp } + } + + fn cmd(&self, agent_url: &str, signal_pid: u32) -> Command { + let p = self.tmp.path(); + let mut c = Command::new(p.join("triple-c-idle-watch")); + c.env_clear() + .env("PATH", std::env::var("PATH").unwrap()) + .env("HOME", p.join("home")) + .env("TRIPLEC_IDLE_STOP_MINUTES", "5") + .env("TRIPLEC_AGENT_URL", agent_url) + .env("TRIPLEC_AGENT_TOKEN_FILE", p.join("token")) + .env("TRIPLEC_IDLE_INTERVAL", "1") + .env("TRIPLEC_IDLE_SIGNAL_PID", signal_pid.to_string()) + .env("TRIPLEC_STOP_REASON_FILE", p.join("reason")) + .env("TRIPLEC_SCHEDULER_BIN", p.join("triple-c-scheduler")) + .env("TRIPLEC_SCHEDULER_USER", "") + // The test host may well be running a `claude` of its own. + .env("TRIPLEC_IDLE_PROC_NAME", "triple-c-no-such-process"); + c + } +} + +/// A one-shot fake agent: answers `/v1/activity` with `body` when the bearer +/// token matches, 401 otherwise. Returns its URL and child. +fn fake_agent(dir: &Path, body: &str) -> (String, std::process::Child) { + let server = r#" +import http.server, sys, socketserver +body = sys.argv[1].encode() +token = open(sys.argv[2]).read() +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + ok = self.path == "/v1/activity" and self.headers.get("Authorization") == "Bearer " + token + self.send_response(200 if ok else 401) + self.end_headers() + if ok: self.wfile.write(body) + def log_message(self, *a): pass +s = socketserver.TCPServer(("127.0.0.1", 0), H) +print(s.server_address[1], flush=True) +s.serve_forever() +"#; + let mut child = Command::new("python3") + .arg("-I") + .arg("-c") + .arg(server) + .arg(body) + .arg(dir.join("token")) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let mut line = String::new(); + use std::io::BufRead; + std::io::BufReader::new(child.stdout.take().unwrap()).read_line(&mut line).unwrap(); + (format!("http://127.0.0.1:{}", line.trim()), child) +} + +#[test] +fn idle_watch_check_treats_an_unreachable_agent_as_busy() { + let w = Watch::new(); + let out = w.cmd("http://127.0.0.1:9", 0).arg("--check").output().unwrap(); + assert_eq!(out.status.code(), Some(1), "{}", text(&out)); +} + +#[test] +fn idle_watch_check_reads_activity_with_the_token() { + let w = Watch::new(); + let (url, mut agent) = fake_agent(w.tmp.path(), r#"{"last_activity_unix":1,"active_execs":0,"active_tunnels":0}"#); + let idle = w.cmd(&url, 0).arg("--check").output().unwrap(); + let (busy_url, mut busy_agent) = + fake_agent(w.tmp.path(), r#"{"last_activity_unix":1,"active_execs":1,"active_tunnels":0}"#); + let busy = w.cmd(&busy_url, 0).arg("--check").output().unwrap(); + fs::write(w.tmp.path().join("token"), "wrong").unwrap(); + let unauth = w.cmd(&url, 0).arg("--check").output().unwrap(); + agent.kill().ok(); + busy_agent.kill().ok(); + assert_eq!(idle.status.code(), Some(0), "{}", text(&idle)); + assert_eq!(busy.status.code(), Some(1), "{}", text(&busy)); + assert_eq!(unauth.status.code(), Some(1), "a 401 is not idle: {}", text(&unauth)); +} + +#[test] +fn idle_watch_signals_the_entrypoint_with_reason_idle() { + let w = Watch::new(); + let (url, mut agent) = fake_agent(w.tmp.path(), r#"{"last_activity_unix":1,"active_execs":0,"active_tunnels":0}"#); + // Stands in for the entrypoint: records the TERM it gets. + let target = Command::new("bash") + .arg("-c") + .arg("trap 'echo term > \"$1\"; exit 0' TERM; while :; do sleep 0.1; done") + .arg("t") + .arg(w.tmp.path().join("got")) + .spawn() + .unwrap(); + let mut target = target; + let out = w.cmd(&url, target.id()).output().unwrap(); + let status = target.wait().unwrap(); + agent.kill().ok(); + assert!(out.status.success(), "{}", text(&out)); + assert!(status.success()); + assert_eq!(fs::read_to_string(w.tmp.path().join("got")).unwrap().trim(), "term"); + assert_eq!(fs::read_to_string(w.tmp.path().join("reason")).unwrap().trim(), "idle"); +} + +// ------------------------------------------------------- stop (SIGTERM) path + +#[test] +fn aws_stop_runs_the_wip_push_with_the_recorded_reason_once() { + let tmp = tempfile::tempdir().unwrap(); + let log = tmp.path().join("calls"); + let fake = write_script( + tmp.path(), + "triple-c-wip-push", + &format!("#!/bin/bash\necho \"$*\" >> '{}'\n", log.display()), + ); + let reason = tmp.path().join("reason"); + let blocks = aws_blocks(&["aws_env", "aws_stop"]) + .replace("/usr/local/bin/triple-c-wip-push", fake.to_str().unwrap()) + .replace("/run/triple-c/stop-reason", reason.to_str().unwrap()); + let script = format!("{blocks}\naws_before_stop\naws_before_stop\n"); + + let out = bash(&script, &[], &[("TRIPLEC_HOST", "aws"), ("TRIPLEC_WIP_PUSH", "1")], &[]); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(fs::read_to_string(&log).unwrap(), "--reason sigterm\n"); + + fs::remove_file(&log).unwrap(); + fs::write(&reason, "idle\n").unwrap(); + let out = bash(&script, &[], &[("TRIPLEC_HOST", "aws"), ("TRIPLEC_WIP_PUSH", "1")], &[]); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(fs::read_to_string(&log).unwrap(), "--reason idle\n"); + + fs::remove_file(&log).unwrap(); + let out = bash(&script, &[], &[("TRIPLEC_HOST", "aws"), ("TRIPLEC_WIP_PUSH", "0")], &[]); + assert!(out.status.success(), "{}", text(&out)); + assert!(!log.exists(), "WIP push off"); +} + +#[test] +fn docker_mode_never_reaches_the_aws_blocks() { + // Every new step sits behind TRIPLEC_HOST=aws; the scripts are only + // started from those branches. + for name in ["aws_env", "aws_home", "aws_repos", "aws_stop"] { + let b = block(ENTRYPOINT, name); + assert!(b.contains("aws"), "{name}"); + } + let outside: String = { + let mut s = ENTRYPOINT.to_string(); + for name in ["aws_env", "aws_home", "aws_repos", "aws_stop"] { + s = s.replace(block(ENTRYPOINT, name), ""); + } + s + }; + let lines: Vec<&str> = outside.lines().collect(); + let calls = [ + "triple-c-wip-push", + "triple-c-idle-watch", + "clone_repos", + "link_persistent_home", + "aws_before_stop", + "start_idle_watch", + ]; + let guarded = |s: &str| s.contains("\"$triplec_aws\" = 1"); + let mut seen = 0; + for (i, line) in lines.iter().enumerate() { + let l = line.trim_start(); + if l.starts_with('#') || !calls.iter().any(|c| l.contains(c)) { + continue; + } + seen += 1; + // Guarded on the line itself or by the `if` right above it. + assert!( + guarded(l) || (i > 0 && lines[i - 1].trim_start().starts_with("if ") && guarded(lines[i - 1])), + "unguarded call outside the aws blocks: {line}" + ); + } + assert_eq!(seen, 4, "the four call sites: Hybrid link, clone, idle watch, stop"); + // And the snapshot-sensitive variables are gone before the agent's + // environment snapshot is taken. + assert!(ENTRYPOINT.find("unset TRIPLEC_FILES").unwrap() < ENTRYPOINT.find("# >>> agent_token").unwrap()); + assert!(ENTRYPOINT.find("unset TRIPLEC_REPOS").unwrap() < ENTRYPOINT.find("# >>> agent_token").unwrap()); +} diff --git a/app/src-tauri/src/docker/image.rs b/app/src-tauri/src/docker/image.rs index 9f5ddae..31f05be 100644 --- a/app/src-tauri/src/docker/image.rs +++ b/app/src-tauri/src/docker/image.rs @@ -17,6 +17,8 @@ const AUDIO_SHIM: &str = include_str!("../../../../container/audio-shim"); const SSO_REFRESH: &str = include_str!("../../../../container/triple-c-sso-refresh"); const TRIPLE_C_OPEN: &str = include_str!("../../../../container/triple-c-open"); const PLAYWRIGHT_HEAL: &str = include_str!("../../../../container/triple-c-playwright-heal"); +const WIP_PUSH: &str = include_str!("../../../../container/triple-c-wip-push"); +const IDLE_WATCH: &str = include_str!("../../../../container/triple-c-idle-watch"); static MISSION_CONTROL_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../container/mission-control"); static SKILLS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../container/skills"); @@ -245,6 +247,8 @@ fn create_build_context() -> Result, std::io::Error> { append_file_to_archive(&mut archive, "triple-c-sso-refresh", SSO_REFRESH.as_bytes(), 0o755)?; append_file_to_archive(&mut archive, "triple-c-open", TRIPLE_C_OPEN.as_bytes(), 0o755)?; append_file_to_archive(&mut archive, "triple-c-playwright-heal", PLAYWRIGHT_HEAL.as_bytes(), 0o755)?; + append_file_to_archive(&mut archive, "triple-c-wip-push", WIP_PUSH.as_bytes(), 0o755)?; + append_file_to_archive(&mut archive, "triple-c-idle-watch", IDLE_WATCH.as_bytes(), 0o755)?; append_embedded_dir(&mut archive, &MISSION_CONTROL_DIR, "mission-control")?; append_embedded_dir(&mut archive, &SKILLS_DIR, "skills")?; diff --git a/app/src-tauri/src/docker/mod.rs b/app/src-tauri/src/docker/mod.rs index 874a756..9486297 100644 --- a/app/src-tauri/src/docker/mod.rs +++ b/app/src-tauri/src/docker/mod.rs @@ -9,6 +9,8 @@ pub mod gateway; pub mod legacy_cleanup; pub mod migration; pub mod stt; +#[cfg(all(test, unix))] +mod aws_mode_tests; /// The container user `claude`'s home directory, as the image creates it. pub(crate) const CONTAINER_HOME: &str = "/home/claude"; diff --git a/container/Dockerfile b/container/Dockerfile index 84af9c7..a927a1e 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -488,6 +488,11 @@ COPY triple-c-playwright-heal /usr/local/bin/triple-c-playwright-heal RUN chmod +x /usr/local/bin/triple-c-playwright-heal COPY triple-c-task-runner /usr/local/bin/triple-c-task-runner RUN chmod +x /usr/local/bin/triple-c-task-runner +# AWS workspaces only (entrypoint.sh, TRIPLEC_HOST=aws): the WIP push run on +# stop and the idle auto-stop watcher. Inert in a Docker container. +COPY triple-c-wip-push /usr/local/bin/triple-c-wip-push +COPY triple-c-idle-watch /usr/local/bin/triple-c-idle-watch +RUN chmod +x /usr/local/bin/triple-c-wip-push /usr/local/bin/triple-c-idle-watch # Root-owned in /usr/local/bin: entrypoint.sh starts it as root (only when a # token is configured), and it rides the snapshot and migration like the other # shims here. diff --git a/container/entrypoint.sh b/container/entrypoint.sh index c24e0f2..cc71f91 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -13,6 +13,102 @@ # upgradable content in /usr/local/bin or /opt, or seed it from here. # See "Container Lifecycle" in the repo's CLAUDE.md. +# ── AWS workspace (TRIPLEC_HOST=aws): project data off the environment ───── +# On AWS (an ECS Fargate task) there are no bind mounts and no Docker API. +# Everything project-shaped arrives as env vars injected from the project's +# Secrets Manager secret (app/src-tauri/src/aws/workspace.rs): +# TRIPLEC_FILES {"ssh/": base64, "ca/": base64} — decoded here +# into /tmp/.host-ssh and /tmp/.host-ca, the paths the +# Docker bind mounts use, so the SSH merge and the CA install +# below run unchanged; +# TRIPLEC_REPOS [{"url","branch"|null,"folder"}] — cloned by clone_repos +# once git credentials and ~/.ssh are in place. +# Both are unset here, *before* the agent-token block below snapshots the +# environment for the agent: that snapshot is every terminal's environment on +# AWS, and the files hold private keys. The repo list is kept only as an +# unexported shell variable, which no child process inherits. +# +# Nothing in this block (or any other aws block) runs without TRIPLEC_HOST=aws; +# a Docker container sees the same script it always did. +# >>> aws_env +triplec_aws=0 +triplec_run_as=claude +triplec_repos_json= + +# stdin: the TRIPLEC_FILES JSON; $1 the ssh dir, $2 the CA dir. Names are +# validated (non-empty, no `/`, not `.`/`..`, no NUL) and anything else is +# refused by name. Files are mode 600 in 700 dirs, root-owned when run as root, +# as the read-only bind mounts would present them. +aws_decode_files() { + python3 -I -c ' +import base64, binascii, json, os, stat, sys +dirs = {"ssh": sys.argv[1], "ca": sys.argv[2]} +root = os.geteuid() == 0 +def say(msg): + print("entrypoint: " + msg, file=sys.stderr) +try: + files = json.load(sys.stdin) +except ValueError: + say("TRIPLEC_FILES is not valid JSON; no files written") + sys.exit(1) +if not isinstance(files, dict): + say("TRIPLEC_FILES is not a JSON object; no files written") + sys.exit(1) +bad = 0 +for key, value in files.items(): + kind, sep, name = key.partition("/") + if kind not in dirs or not sep or name in ("", ".", "..") or "/" in name or "\0" in name: + say("refusing file entry %r: not ssh/ or ca/" % key) + bad += 1 + continue + try: + data = base64.b64decode(value, validate=True) if isinstance(value, str) else None + except (binascii.Error, ValueError): + data = None + if data is None: + say("refusing file entry %r: value is not base64" % key) + bad += 1 + continue + d = dirs[kind] + os.makedirs(d, mode=0o700, exist_ok=True) + if not stat.S_ISDIR(os.lstat(d).st_mode): + say("refusing file entry %r: %s is not a directory" % (key, d)) + bad += 1 + continue + os.chmod(d, 0o700) + if root: + os.chown(d, 0, 0) + fd = os.open(os.path.join(d, name), os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "wb") as f: + os.fchmod(f.fileno(), 0o600) + if root: + os.fchown(f.fileno(), 0, 0) + f.write(data) +sys.exit(1 if bad else 0) +' "$1" "$2" +} + +# Runs a command as the container user (`claude`) when this script is root. +_triplec_as_user() { + if [ -n "${triplec_run_as:-}" ] && [ "$(id -u)" = 0 ]; then + su -s /bin/bash -c '"$@"' -- "$triplec_run_as" _ "$@" + else + "$@" + fi +} + +if [ "${TRIPLEC_HOST:-}" = aws ]; then + triplec_aws=1 + if [ -n "${TRIPLEC_FILES+x}" ]; then + printf '%s' "$TRIPLEC_FILES" | aws_decode_files /tmp/.host-ssh /tmp/.host-ca \ + || echo "entrypoint: warning — some TRIPLEC_FILES entries were not written (see above)" + unset TRIPLEC_FILES + fi + triplec_repos_json="${TRIPLEC_REPOS:-}" + unset TRIPLEC_REPOS +fi +# <<< aws_env + # ── triple-c-agent token: off the environment before anything else runs ─── # The agent (started at the very end, see start_agent) authenticates every # request with this token, and it runs as root, so the token is effectively @@ -97,7 +193,9 @@ remap_uid_gid() { fi } -remap_uid_gid +# On AWS nothing is bind-mounted and HOST_UID/HOST_GID are never set, so there +# is nothing to match; skipped outright rather than relying on that. +[ "$triplec_aws" = 1 ] || remap_uid_gid # Fix ownership of home directory after UID/GID change — only where it is # wrong. A blanket `chown -R` rewrites every inode, and where /home/claude is @@ -109,6 +207,58 @@ remap_uid_gid # ~/.claude volume. find /home/claude \( ! -user claude -o ! -group claude \) -exec chown -h claude:claude {} + +# ── AWS Hybrid storage: persisted home state (plan ruling P4) ─────────────── +# A Hybrid workspace mounts its EFS access point (POSIX 1000:1000) at +# /mnt/triple-c-home. Mounting it over /home/claude would hide the image's +# Claude Code, cargo and uv — EFS has no Docker-volume "copy the image's +# contents on first mount" — so only user state is persisted, by symlink: +# ~/.claude, ~/.claude.json and ~/.ssh. On first use each is seeded from the +# image's copy (or created empty); from then on the persisted copy wins and +# the fresh image copy every new task starts with is replaced by the link — +# what a Docker home volume does by masking. Runs before the SSH merge below, +# so host keys land on EFS, and before anything writes ~/.claude. +# >>> aws_home +link_persistent_home() { + local home="$1" persist="$2" owner="$3" name link target + if [ ! -d "$persist" ]; then + echo "entrypoint: warning — Hybrid storage, but $persist is not mounted; home state will not persist" + return 0 + fi + for name in .claude .claude.json .ssh; do + link="$home/$name" + target="$persist/$name" + if [ -L "$link" ] && [ "$(readlink "$link")" = "$target" ]; then + continue + fi + if [ ! -e "$target" ] && [ ! -L "$target" ]; then + if [ -e "$link" ] || [ -L "$link" ]; then + # Staged then renamed: a copy cut short must never look seeded. + rm -rf "$target.triple-c-seed" + if ! cp -a "$link" "$target.triple-c-seed" || ! mv "$target.triple-c-seed" "$target"; then + rm -rf "$target.triple-c-seed" + echo "entrypoint: warning — could not seed $target; ~/$name stays unpersisted" + continue + fi + elif [ "$name" = .claude.json ]; then + ( umask 077; printf '{}\n' > "$target" ) || { + echo "entrypoint: warning — could not create $target"; continue; } + else + mkdir -m 700 "$target" || { + echo "entrypoint: warning — could not create $target"; continue; } + fi + [ -z "$owner" ] || chown -R "$owner" "$target" 2>/dev/null + fi + rm -rf "$link" + ln -s "$target" "$link" || echo "entrypoint: warning — could not link ~/$name to $target" + [ -z "$owner" ] || chown -h "$owner" "$link" 2>/dev/null + done + echo "entrypoint: Hybrid storage — ~/.claude, ~/.claude.json and ~/.ssh persist on $persist" +} +# <<< aws_home +if [ "$triplec_aws" = 1 ] && [ "${TRIPLEC_STORAGE:-}" = hybrid ]; then + link_persistent_home /home/claude /mnt/triple-c-home claude:claude +fi + # ── Corporate CA certificates ─────────────────────────────────────────────── # The host's CA material is bind-mounted read-only at /tmp/.host-ca. Triple-C # mounts a *directory* as-is and a *single file* as /tmp/.host-ca/.crt, @@ -361,11 +511,13 @@ sync_host_ssh() { if [ -d /tmp/.host-ssh ]; then sync_host_ssh /tmp/.host-ssh /home/claude/.ssh - chown -R claude:claude /home/claude/.ssh + # Trailing slashes: on AWS Hybrid ~/.ssh is a symlink, and without them + # `chown -R` and `find` would stop at the link itself. + chown -R claude:claude /home/claude/.ssh/ chmod 700 /home/claude/.ssh - find /home/claude/.ssh -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \; - find /home/claude/.ssh -type f -name "*.triple-c-backup" -exec chmod 600 {} \; - find /home/claude/.ssh -type f -name "*.pub" -exec chmod 644 {} \; + find /home/claude/.ssh/ -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \; + find /home/claude/.ssh/ -type f -name "*.triple-c-backup" -exec chmod 600 {} \; + find /home/claude/.ssh/ -type f -name "*.pub" -exec chmod 644 {} \; if [ -f /home/claude/.ssh/known_hosts ]; then chmod 644 /home/claude/.ssh/known_hosts fi @@ -441,6 +593,79 @@ if [ -n "$GIT_USER_EMAIL" ]; then fi chown claude:claude /home/claude/.gitconfig 2>/dev/null || true +# ── AWS: clone the project's repositories ─────────────────────────────────── +# An AWS workspace has no project folder to mount; its repositories are cloned +# into /workspace/ on start. Here, after the credential helper, git +# identity and ~/.ssh are in place, as `claude`, with prompts off and a +# per-repo time limit. A folder that is already a repository (Hybrid keeps +# nothing under /workspace today, but a restarted container would) is left +# alone. Failures are reported and never stop the start: a workspace with one +# repo missing is still a workspace. +# >>> aws_repos +clone_repos() { + local json="$1" root="$2" folder url branch dest existed err rc + local limit="${triplec_clone_timeout:-600}" + [ -n "$json" ] || return 0 + while IFS= read -r -d '' folder && IFS= read -r -d '' url && IFS= read -r -d '' branch; do + dest="$root/$folder" + if [ -e "$dest/.git" ]; then + echo "entrypoint: /workspace/$folder is already a repository; not cloning" + continue + fi + existed=0 + [ -e "$dest" ] && existed=1 + local args=(clone --quiet) + [ -n "$branch" ] && args+=(--branch "$branch") + args+=(-- "$url" "$dest") + echo "entrypoint: cloning $folder..." + err=$(_triplec_as_user env GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="ssh -o BatchMode=yes" \ + timeout -k 10 "$limit" git "${args[@]}" 2>&1 >> write_claude_json write_claude_json() { - _wcj_tmp="${CLAUDE_JSON}.triple-c-tmp" + _wcj_dest="$CLAUDE_JSON" + if [ -L "$_wcj_dest" ]; then + _wcj_dest=$(readlink -f "$CLAUDE_JSON") + [ -n "$_wcj_dest" ] || _wcj_dest="$CLAUDE_JSON" + fi + _wcj_tmp="${_wcj_dest}.triple-c-tmp" if printf '%s\n' "$1" > "$_wcj_tmp" 2>/dev/null; then - mv -f "$_wcj_tmp" "$CLAUDE_JSON" 2>/dev/null || rm -f "$_wcj_tmp" + mv -f "$_wcj_tmp" "$_wcj_dest" 2>/dev/null || rm -f "$_wcj_tmp" else rm -f "$_wcj_tmp" echo "entrypoint: warning — could not write $CLAUDE_JSON (leaving it as it was)" return 1 fi # By name, after the rename, so these land on the new inode. - chown claude:claude "$CLAUDE_JSON" - chmod 600 "$CLAUDE_JSON" + chown claude:claude "$_wcj_dest" + chmod 600 "$_wcj_dest" } +# <<< write_claude_json CLAUDE_JSON="/home/claude/.claude.json" if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then @@ -662,7 +898,7 @@ else fi # ── Docker socket permissions ──────────────────────────────────────────────── -if [ -S /var/run/docker.sock ]; then +if [ "$triplec_aws" != 1 ] && [ -S /var/run/docker.sock ]; then DOCKER_GID=$(stat -c '%g' /var/run/docker.sock) if ! getent group "$DOCKER_GID" > /dev/null 2>&1; then groupadd -g "$DOCKER_GID" docker-host @@ -852,9 +1088,72 @@ start_agent() { # <<< start_agent start_agent +# ── AWS: WIP push on stop, idle auto-stop ─────────────────────────────────── +# aws_before_stop runs from the TERM trap below, before the keepalive goes: +# with TRIPLEC_WIP_PUSH=1 it pushes every repo's unsaved work to a +# triple-c/wip/ branch (as `claude`, bounded at 90 s). ECS gives a task +# `stopTimeout` seconds between SIGTERM and SIGKILL — 30 by default, 120 at +# most on Fargate — so the task definition must raise it for the push to +# finish. Once only: a second TERM during the push changes nothing. +# +# The idle watcher (TRIPLEC_IDLE_STOP_MINUTES set) runs as root under a +# supervisor loop like the agent's, so a claude-user `pkill` cannot switch +# auto-stop off. When the workspace is idle it records the reason "idle" and +# sends TERM to *this* script ($$) — not PID 1, which under ECS's +# initProcessEnabled is docker-init — so an idle stop and an ECS stop take +# this same trap and push at most once. Its environment is built from scratch: +# it needs none of the container's. +# >>> aws_stop +aws_before_stop() { + [ "${TRIPLEC_HOST:-}" = aws ] || return 0 + [ -z "${triplec_stopping:-}" ] || return 0 + triplec_stopping=1 + local reason=sigterm + if [ "$(cat /run/triple-c/stop-reason 2>/dev/null)" = idle ]; then + reason=idle + fi + if [ "${TRIPLEC_WIP_PUSH:-0}" = 1 ]; then + echo "entrypoint: stopping ($reason) — pushing work in progress..." + _triplec_as_user timeout -k 5 90 /usr/local/bin/triple-c-wip-push --reason "$reason" \ + || echo "entrypoint: warning — WIP push incomplete (see above)" + fi + return 0 +} + +start_idle_watch() { + local minutes="$1" signal_pid="$2" + mkdir -p /run/triple-c && chmod 700 /run/triple-c + rm -f /run/triple-c/stop-reason + if [ -z "${TRIPLEC_AGENT_TOKEN_FILE:-}" ]; then + echo "triple-c-idle-watch: no agent token, so no activity to read — idle stop will never fire" >&2 + fi + ( + trap '' HUP + while :; do + watch_started=$SECONDS + env -i PATH="$PATH" \ + TRIPLEC_IDLE_STOP_MINUTES="$minutes" \ + TRIPLEC_IDLE_SIGNAL_PID="$signal_pid" \ + TRIPLEC_AGENT_TOKEN_FILE="${TRIPLEC_AGENT_TOKEN_FILE:-/dev/shm/triple-c/agent-token}" \ + /usr/local/bin/triple-c-idle-watch + rc=$? + # 0: it signalled the stop. 2: misconfigured, and a restart would + # only say so again. + [ "$rc" -eq 0 ] || [ "$rc" -eq 2 ] && break + echo "triple-c-idle-watch exited ($rc) — restarting" >&2 + [ $((SECONDS - watch_started)) -ge 5 ] || sleep 5 + done + ) & +} +# <<< aws_stop +if [ "$triplec_aws" = 1 ] && [ -n "${TRIPLEC_IDLE_STOP_MINUTES:-}" ]; then + start_idle_watch "$TRIPLEC_IDLE_STOP_MINUTES" "$$" +fi + echo "Triple-C container ready." keepalive_pid="" stop_keepalive() { + [ "$triplec_aws" = 1 ] && aws_before_stop [ -n "$keepalive_pid" ] && kill "$keepalive_pid" 2>/dev/null exit 0 } diff --git a/container/triple-c-idle-watch b/container/triple-c-idle-watch new file mode 100755 index 0000000..589819c --- /dev/null +++ b/container/triple-c-idle-watch @@ -0,0 +1,128 @@ +#!/bin/bash +# triple-c-idle-watch [--check] +# +# Idle auto-stop for an AWS workspace (plan ruling P7). Started by +# entrypoint.sh, as root, only when TRIPLEC_HOST=aws and +# TRIPLEC_IDLE_STOP_MINUTES is set. Every interval it asks whether the +# workspace is idle: +# +# * the agent's /v1/activity (read with the agent's own token file) shows +# no authenticated activity for N minutes, no open exec, no open tunnel; +# * no `claude` process is running; +# * no scheduler job is running (`triple-c-scheduler status`, as claude). +# +# Anything it cannot read counts as NOT idle: a stop is the one thing here +# that cannot be undone, so every doubt keeps the workspace running. +# +# When idle it records "idle" as the stop reason and sends SIGTERM to the +# entrypoint (TRIPLEC_IDLE_SIGNAL_PID). The entrypoint's TERM trap does the +# WIP push (reason read from that file) and stops the keepalive, so a stop +# from here and a stop from ECS take the same single path — and a SIGTERM from +# ECS arriving mid-push cannot start a second, concurrent push. +# +# Why not `kill -TERM 1`: with initProcessEnabled ECS runs the container under +# docker-init, so PID 1 is the init, not the entrypoint. The init would +# forward the signal, but signalling the entrypoint directly does not depend on +# that and also works without an init. +# +# --check evaluates once and exits 0 (idle) or 1 (not idle); for tests and +# for looking at a live task. +# +# Configuration (environment; the entrypoint sets the first three, the rest +# exist for tests): +# TRIPLEC_IDLE_STOP_MINUTES N (required, > 0) +# TRIPLEC_IDLE_SIGNAL_PID the entrypoint's PID +# TRIPLEC_AGENT_TOKEN_FILE default /dev/shm/triple-c/agent-token +# TRIPLEC_AGENT_URL default http://127.0.0.1:47900 +# TRIPLEC_IDLE_INTERVAL seconds between checks, default 60 +# TRIPLEC_STOP_REASON_FILE default /run/triple-c/stop-reason +# TRIPLEC_SCHEDULER_BIN default /usr/local/bin/triple-c-scheduler +# TRIPLEC_SCHEDULER_USER default claude; empty runs it as this user +# TRIPLEC_IDLE_PROC_NAME default claude + +set -u + +minutes="${TRIPLEC_IDLE_STOP_MINUTES:-}" +signal_pid="${TRIPLEC_IDLE_SIGNAL_PID:-}" +token_file="${TRIPLEC_AGENT_TOKEN_FILE:-/dev/shm/triple-c/agent-token}" +agent_url="${TRIPLEC_AGENT_URL:-http://127.0.0.1:47900}" +interval="${TRIPLEC_IDLE_INTERVAL:-60}" +reason_file="${TRIPLEC_STOP_REASON_FILE:-/run/triple-c/stop-reason}" +scheduler="${TRIPLEC_SCHEDULER_BIN:-/usr/local/bin/triple-c-scheduler}" +scheduler_user="${TRIPLEC_SCHEDULER_USER-claude}" +proc_name="${TRIPLEC_IDLE_PROC_NAME:-claude}" + +# >>> is_idle +# is_idle now last_activity_unix active_execs active_tunnels claude_running jobs_running minutes +# Pure: exit 0 when idle, 1 otherwise. Every argument must be a plain +# non-negative integer; anything else (an empty or garbled read) is not idle. +is_idle() { + local a + [ $# -eq 7 ] || return 1 + for a in "$@"; do + case "$a" in ''|*[!0-9]*) return 1 ;; esac + done + local now=$1 last=$2 execs=$3 tunnels=$4 claude=$5 jobs=$6 mins=$7 + [ "$mins" -gt 0 ] || return 1 + [ "$execs" -eq 0 ] && [ "$tunnels" -eq 0 ] || return 1 + [ "$claude" -eq 0 ] && [ "$jobs" -eq 0 ] || return 1 + [ $((now - last)) -ge $((mins * 60)) ] +} +# <<< is_idle + +# Prints "last execs tunnels", or fails. +read_activity() { + local token body + token=$(cat "$token_file" 2>/dev/null) || return 1 + [ -n "$token" ] || return 1 + # The token goes in through a config on stdin, never argv: /proc//cmdline + # is readable by every user in the container. + body=$(printf 'header = "Authorization: Bearer %s"\n' "$token" \ + | curl -sf --max-time 5 --config - "$agent_url/v1/activity" 2>/dev/null) || return 1 + printf '%s' "$body" | jq -r '[.last_activity_unix, .active_execs, .active_tunnels] + | map(if type == "number" and . >= 0 and . == floor then tostring else "x" end) + | join(" ")' 2>/dev/null +} + +# 1 when a scheduler job is running *or* the question cannot be answered. +jobs_running() { + local out + if [ -n "$scheduler_user" ] && [ "$(id -u)" = 0 ]; then + out=$(su -s /bin/bash -c '"$1" status' -- "$scheduler_user" _ "$scheduler" 2>/dev/null) || { echo 1; return; } + else + out=$("$scheduler" status 2>/dev/null) || { echo 1; return; } + fi + if [ "$out" = "Nothing running." ]; then echo 0; else echo 1; fi +} + +check() { + local act last execs tunnels claude + act=$(read_activity) || return 1 + read -r last execs tunnels <<< "$act" + if pgrep -x "$proc_name" >/dev/null 2>&1; then claude=1; else claude=0; fi + is_idle "$(date +%s)" "${last:-}" "${execs:-}" "${tunnels:-}" "$claude" "$(jobs_running)" "$minutes" +} + +case "$minutes" in + ''|*[!0-9]*|0) echo "triple-c-idle-watch: TRIPLEC_IDLE_STOP_MINUTES must be a positive integer" >&2; exit 2 ;; +esac + +if [ "${1:-}" = "--check" ]; then + check + exit $? +fi + +case "$signal_pid" in + ''|*[!0-9]*|0|1) echo "triple-c-idle-watch: TRIPLEC_IDLE_SIGNAL_PID must be the entrypoint's PID" >&2; exit 2 ;; +esac + +while :; do + sleep "$interval" + if check; then + echo "triple-c-idle-watch: idle for ${minutes} minutes — stopping the workspace" + mkdir -p "$(dirname "$reason_file")" 2>/dev/null + printf 'idle\n' > "$reason_file" + kill -TERM "$signal_pid" + exit 0 + fi +done diff --git a/container/triple-c-wip-push b/container/triple-c-wip-push new file mode 100755 index 0000000..b83f5db --- /dev/null +++ b/container/triple-c-wip-push @@ -0,0 +1,146 @@ +#!/bin/bash +# triple-c-wip-push [--dry-run] [--reason idle|stop|sigterm] +# +# Save unpushed work before an AWS workspace stops. For every git repository +# directly under /workspace whose working tree is dirty or which holds commits +# no remote has, push a snapshot of the *whole* working tree (tracked changes, +# staged or not, and untracked files .gitignore does not exclude) to +# refs/heads/triple-c/wip/ on the repo's remote. +# +# It never touches the user's index, HEAD, branches or working tree: the +# snapshot is built in a temporary index (GIT_INDEX_FILE) seeded from a copy of +# the real one, written with write-tree and committed with commit-tree on top of +# HEAD. Only new objects land in .git/objects (unreferenced locally; gc takes +# them). Status reads run with GIT_OPTIONAL_LOCKS=0 so even the index's stat +# cache is left alone. +# +# Run as `claude` (the entrypoint uses su), so pushes use the user's own +# credential helper and ~/.ssh. Prompts are off: on a stopping task there is +# nobody to answer one. +# +# --dry-run prints one JSON line per repo +# {"folder":…, "dirty":bool, "unpushed":n, "branch":…} +# and changes nothing. +# +# Exit 0 when every needed push succeeded (or nothing needed pushing), 1 when +# any failed, 2 on bad usage. +# +# TRIPLEC_WORKSPACE_ROOT overrides /workspace (tests). + +set -u + +root="${TRIPLEC_WORKSPACE_ROOT:-/workspace}" +dry_run=0 +reason=stop + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) dry_run=1; shift ;; + --reason) + case "${2:-}" in + idle|stop|sigterm) reason="$2"; shift 2 ;; + *) echo "triple-c-wip-push: --reason must be idle, stop or sigterm" >&2; exit 2 ;; + esac ;; + *) echo "usage: triple-c-wip-push [--dry-run] [--reason idle|stop|sigterm]" >&2; exit 2 ;; + esac +done + +export GIT_TERMINAL_PROMPT=0 GIT_OPTIONAL_LOCKS=0 +# BatchMode: an unknown host key or a passphrase prompt fails at once instead +# of waiting for input nobody will give. Keeps any GIT_SSH_COMMAND the user set. +export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh} -o BatchMode=yes" + +stamp=$(date -u +%Y%m%d-%H%M%S) +failed=0 + +# The remote to push to: the branch's upstream remote, else origin, else the +# only remote. Empty when there is none. +pick_remote() { + local r + r=$(git config --get "branch.$1.remote" 2>/dev/null) + if [ -n "$r" ] && [ "$r" != "." ]; then printf '%s' "$r"; return; fi + if git remote | grep -qx origin; then printf origin; return; fi + if [ "$(git remote | wc -l)" -eq 1 ]; then git remote; fi +} + +for dir in "$root"/*/; do + dir="${dir%/}" + [ -d "$dir" ] || continue + # A repository rooted exactly here. A symlinked folder resolves elsewhere + # and is skipped: it is not the workspace's own. + top=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || continue + [ "$top" = "$(cd "$dir" && pwd -P)" ] || continue + folder=$(basename "$dir") + + ( + cd "$dir" || exit 1 + head=$(git rev-parse -q --verify HEAD 2>/dev/null) + branch=$(git symbolic-ref -q --short HEAD 2>/dev/null) || branch=HEAD + + dirty=false + [ -n "$(git status --porcelain 2>/dev/null | head -1)" ] && dirty=true + + # Commits no remote has: past the upstream when there is one, past + # every remote-tracking ref otherwise. + unpushed=0 + if [ -n "$head" ]; then + if git rev-parse -q --verify '@{u}' >/dev/null 2>&1; then + unpushed=$(git rev-list --count '@{u}..HEAD' 2>/dev/null || echo 0) + else + unpushed=$(git rev-list --count HEAD --not --remotes 2>/dev/null || echo 0) + fi + fi + + if [ "$dry_run" = 1 ]; then + jq -cn --arg folder "$folder" --argjson dirty "$dirty" \ + --argjson unpushed "$unpushed" --arg branch "$branch" \ + '{folder: $folder, dirty: $dirty, unpushed: $unpushed, branch: $branch}' + exit 0 + fi + + [ "$dirty" = true ] || [ "$unpushed" -gt 0 ] || exit 0 + + remote=$(pick_remote "$branch") + if [ -z "$remote" ]; then + echo "triple-c-wip-push: $folder: unsaved work but no remote to push it to" >&2 + exit 1 + fi + + commit="$head" + if [ "$dirty" = true ]; then + tmp_index=$(mktemp) || exit 1 + trap 'rm -f "$tmp_index"' EXIT + real_index=$(git rev-parse --git-path index) + # Seed from the real index (read only) so unchanged files keep + # their stat data and `add -A` hashes only what changed. + if [ -f "$real_index" ]; then + cp "$real_index" "$tmp_index" || exit 1 + else + rm -f "$tmp_index" + fi + export GIT_INDEX_FILE="$tmp_index" + # The user may have no identity configured; a WIP commit still needs one. + git config user.name >/dev/null 2>&1 || export GIT_AUTHOR_NAME=Triple-C GIT_COMMITTER_NAME=Triple-C + git config user.email >/dev/null 2>&1 || export GIT_AUTHOR_EMAIL=triple-c@localhost GIT_COMMITTER_EMAIL=triple-c@localhost + git add -A . >/dev/null 2>&1 || { echo "triple-c-wip-push: $folder: could not stage the working tree" >&2; exit 1; } + tree=$(git write-tree) || exit 1 + msg="Triple-C WIP ($reason) of $branch at $stamp UTC" + if [ -n "$head" ]; then + commit=$(git commit-tree "$tree" -p "$head" -m "$msg") || exit 1 + else + commit=$(git commit-tree "$tree" -m "$msg") || exit 1 + fi + unset GIT_INDEX_FILE + fi + + ref="refs/heads/triple-c/wip/$stamp" + if err=$(git push --no-verify -q "$remote" "$commit:$ref" 2>&1); then + echo "triple-c-wip-push: $folder: pushed to $remote $ref" + else + echo "triple-c-wip-push: $folder: push to $remote failed: $(printf '%s' "$err" | tail -n 1)" >&2 + exit 1 + fi + ) || failed=1 +done + +exit "$failed" -- 2.52.0 From b1ac78613c2a79ac85f9ec32a2a88c280ef1a56d Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 09:59:00 -0800 Subject: [PATCH 14/43] fix(container): aws mode review round 1 Clone repos in the background after the agent starts (progress in /run/triple-c-clone; an unfinished clone keeps the workspace from idling), give the task definition stopTimeout 120 for the SIGTERM WIP push, reserve the TRIPLEC_ env prefix on both hosts, and stop root writing through claude-controlled links (~/.claude.json written as claude only via the exact Hybrid link; ~/.ssh merged only through the exact Hybrid link, Docker paths unchanged). Keep the user's ssh command and add BatchMode, cover dot-folders, refuse undecodable or over-long file names by name, and upload LFS objects before the --no-verify push. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/workspace.rs | 15 +- app/src-tauri/src/aws/workspace/tests.rs | 7 + app/src-tauri/src/docker/aws_mode_tests.rs | 257 +++++++++++++++++++-- app/src-tauri/src/docker/container.rs | 22 +- container/entrypoint.sh | 183 +++++++++++---- container/triple-c-idle-watch | 17 +- container/triple-c-wip-push | 24 +- 7 files changed, 447 insertions(+), 78 deletions(-) diff --git a/app/src-tauri/src/aws/workspace.rs b/app/src-tauri/src/aws/workspace.rs index 2da99b5..0261175 100644 --- a/app/src-tauri/src/aws/workspace.rs +++ b/app/src-tauri/src/aws/workspace.rs @@ -75,6 +75,9 @@ const KEY_WIP_PUSH: &str = "TRIPLEC_WIP_PUSH"; const KEY_IDLE: &str = "TRIPLEC_IDLE_STOP_MINUTES"; const KEY_STORAGE: &str = "TRIPLEC_STORAGE"; /// The task definition's whole `environment` (R2): constants, no project data. +/// Seconds ECS waits after SIGTERM before SIGKILL (Fargate allows 2–120). +pub const STOP_TIMEOUT_SECS: i32 = 120; + const FIXED_ENVIRONMENT: [(&str, &str); 2] = [("TRIPLEC_HOST", "aws"), ("COLORTERM", "truecolor")]; fn other(message: impl Into) -> AwsError { @@ -151,7 +154,13 @@ fn payload_map( k.escape_debug() )); } - if own.contains(&k.as_str()) || FIXED_ENVIRONMENT.iter().any(|(f, _)| f == k) { + // The whole `TRIPLEC_` namespace is the entrypoint's (the Docker side + // reserves it too: `RESERVED_ENV_PREFIXES`), not just the keys this + // payload writes — `TRIPLEC_AGENT_ADDR`, say, would rebind the agent. + if own.contains(&k.as_str()) + || FIXED_ENVIRONMENT.iter().any(|(f, _)| f == k) + || k.to_ascii_uppercase().starts_with("TRIPLEC_") + { log::warn!("Ignoring the environment variable {k}: Triple-C sets it on AWS"); continue; } @@ -326,6 +335,10 @@ fn build_request(plan: &WorkspacePlan) -> RegisterTaskDefinitionInput { .name(CONTAINER_NAME) .image(&plan.image) .essential(true) + // SIGTERM → SIGKILL grace. The entrypoint's TERM trap pushes unsaved + // work (`triple-c-wip-push`, bounded at 90 s) before it exits; ECS's + // default 30 s would kill most pushes part-way. 120 is Fargate's max. + .stop_timeout(STOP_TIMEOUT_SECS) .set_environment(Some( FIXED_ENVIRONMENT .iter() diff --git a/app/src-tauri/src/aws/workspace/tests.rs b/app/src-tauri/src/aws/workspace/tests.rs index 94b9cc0..4b0bfc0 100644 --- a/app/src-tauri/src/aws/workspace/tests.rs +++ b/app/src-tauri/src/aws/workspace/tests.rs @@ -210,6 +210,8 @@ fn request_shape_matches_the_global_constraints() { assert_eq!(req.task_role_arn(), Some(plan.task_role_arn.as_str())); assert_eq!(req.execution_role_arn(), Some(plan.exec_role_arn.as_str())); assert_eq!(req.ephemeral_storage().unwrap().size_in_gib(), 21); + // Room for the TERM trap's WIP push (90 s) before SIGKILL. + assert_eq!(req.container_definitions()[0].stop_timeout(), Some(120)); let rp = req.runtime_platform().unwrap(); assert_eq!(rp.cpu_architecture(), Some(&CpuArchitecture::X8664)); assert_eq!( @@ -481,6 +483,8 @@ fn payload_keeps_its_own_keys_over_custom_env() { let env = pairs(&[ ("TRIPLEC_AGENT_TOKEN", "forged"), ("TRIPLEC_HOST", "local"), + ("TRIPLEC_AGENT_ADDR", "0.0.0.0:47900"), + ("triplec_other", "x"), ("COLORTERM", "16"), ("OK", "1"), ]); @@ -491,6 +495,9 @@ fn payload_keeps_its_own_keys_over_custom_env() { // The task definition's fixed environment owns these two names. assert!(!m.contains_key("TRIPLEC_HOST")); assert!(!m.contains_key("COLORTERM")); + // The rest of the TRIPLEC_ namespace is the entrypoint's too. + assert!(!m.contains_key("TRIPLEC_AGENT_ADDR")); + assert!(!m.contains_key("triplec_other")); assert_eq!(m["OK"], "1"); } diff --git a/app/src-tauri/src/docker/aws_mode_tests.rs b/app/src-tauri/src/docker/aws_mode_tests.rs index 83e9601..5a79efa 100644 --- a/app/src-tauri/src/docker/aws_mode_tests.rs +++ b/app/src-tauri/src/docker/aws_mode_tests.rs @@ -109,7 +109,7 @@ fn files_script(tmp: &Path) -> String { fn aws_files_decode_to_the_host_dirs_mode_600_and_leave_the_environment() { let tmp = tempfile::tempdir().unwrap(); let files = format!( - r#"{{"ssh/id_ed25519":"{}","ssh/config":"{}","ca/corp.crt":"{}","ssh/..":"{}","ssh/a/b":"{}","ssh/":"{}","home/x":"{}","ca/bad":"%%%"}}"#, + r#"{{"ssh/id_ed25519":"{}","ssh/config":"{}","ca/corp.crt":"{}","ssh/..":"{}","ssh/a/b":"{}","ssh/":"{}","home/x":"{}","ca/bad":"%%%","ssh/{long}":"{}","ssh/\ud800":"{}"}}"#, b64("PRIVATE-KEY-BODY"), b64("Host *\n"), b64("-----BEGIN CERTIFICATE-----"), @@ -117,6 +117,9 @@ fn aws_files_decode_to_the_host_dirs_mode_600_and_leave_the_environment() { b64("x"), b64("x"), b64("x"), + b64("x"), + b64("x"), + long = "a".repeat(300), ); let repos = r#"[{"url":"https://example.invalid/r.git","branch":null,"folder":"r"}]"#; let out = bash( @@ -150,8 +153,18 @@ fn aws_files_decode_to_the_host_dirs_mode_600_and_leave_the_environment() { assert_eq!(names, ["config", "id_ed25519"]); assert_eq!(fs::read_dir(&ca).unwrap().count(), 1); assert!(!tmp.path().join("a").exists() && !tmp.path().join("x").exists()); - for bad in ["ssh/..", "ssh/a/b", "\"ssh/\"", "home/x", "ca/bad"] { - assert!(log.contains(&format!("refusing")) && log.contains(bad.trim_matches('"')), "{bad}: {log}"); + let long_key = format!("ssh/{}", "a".repeat(300)); + for (key, why) in [ + ("'ssh/..'", "not ssh/ or ca/"), + ("'ssh/a/b'", "not ssh/ or ca/"), + ("'ssh/'", "not ssh/ or ca/"), + ("'home/x'", "not ssh/ or ca/"), + ("'ca/bad'", "value is not base64"), + (&format!("'{long_key}'"), "File name too long"), + ("'ssh/\\ud800'", "UnicodeEncodeError"), + ] { + let line = format!("entrypoint: refusing file entry {key}: {why}"); + assert!(log.lines().any(|l| l == line), "missing `{line}` in:\n{log}"); } // Gone from the environment, and from the agent's snapshot of it. @@ -260,21 +273,96 @@ fn link_persistent_home_without_the_mount_changes_nothing() { assert!(fs::symlink_metadata(home.join(".claude")).unwrap().is_dir()); } +/// `write_claude_json` with the Hybrid mount at `/mnt`, in aws mode or not. +fn write_claude_json(tmp: &Path, aws: bool) -> Output { + let mnt = tmp.join("mnt"); + let blocks = aws_blocks(&["aws_env", "write_claude_json"]) + .replace("triplec_home_mnt=/mnt/triple-c-home", &format!("triplec_home_mnt='{}'", mnt.display())); + // chown to a user the host may not have is noise here, not the point. + let script = format!( + "chown() {{ :; }}\n{blocks}\ntriplec_aws={}\nCLAUDE_JSON=\"$1/.claude.json\"\nwrite_claude_json '{{\"a\":1}}'\n", + if aws { 1 } else { 0 } + ); + bash(&script, &[&tmp.join("home")], &[], &[]) +} + +fn hybrid_dirs(tmp: &Path) -> (std::path::PathBuf, std::path::PathBuf) { + let (home, mnt) = (tmp.join("home"), tmp.join("mnt")); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&mnt).unwrap(); + (home, mnt) +} + #[test] fn write_claude_json_keeps_a_linked_claude_json_linked() { let tmp = tempfile::tempdir().unwrap(); - let (home, persist) = (tmp.path().join("home"), tmp.path().join("mnt")); - fs::create_dir_all(&home).unwrap(); - fs::create_dir_all(&persist).unwrap(); - fs::write(persist.join(".claude.json"), "{}").unwrap(); - std::os::unix::fs::symlink(persist.join(".claude.json"), home.join(".claude.json")).unwrap(); - let f = block(ENTRYPOINT, "write_claude_json"); - // chown to a user the host may not have is noise here, not the point. - let script = format!("chown() {{ :; }}\n{f}\nCLAUDE_JSON=\"$1/.claude.json\"\nwrite_claude_json '{{\"a\":1}}'\n"); - let out = bash(&script, &[&home], &[], &[]); + let (home, mnt) = hybrid_dirs(tmp.path()); + fs::write(mnt.join(".claude.json"), "{}").unwrap(); + std::os::unix::fs::symlink(mnt.join(".claude.json"), home.join(".claude.json")).unwrap(); + let out = write_claude_json(tmp.path(), true); assert!(out.status.success(), "{}", text(&out)); assert!(fs::symlink_metadata(home.join(".claude.json")).unwrap().file_type().is_symlink()); - assert_eq!(fs::read_to_string(persist.join(".claude.json")).unwrap().trim(), "{\"a\":1}"); + assert_eq!(fs::read_to_string(mnt.join(".claude.json")).unwrap().trim(), "{\"a\":1}"); + assert_eq!(mode(&mnt.join(".claude.json")), 0o600); + assert!(!mnt.join(".claude.json.triple-c-tmp").exists()); +} + +#[test] +fn write_claude_json_refuses_a_link_triple_c_did_not_make() { + let tmp = tempfile::tempdir().unwrap(); + let (home, mnt) = hybrid_dirs(tmp.path()); + // A link pointing somewhere else entirely. + let elsewhere = tmp.path().join("elsewhere"); + fs::write(&elsewhere, "untouched").unwrap(); + std::os::unix::fs::symlink(&elsewhere, home.join(".claude.json")).unwrap(); + let out = write_claude_json(tmp.path(), true); + assert!(!out.status.success()); + assert!(text(&out).contains("did not make"), "{}", text(&out)); + assert_eq!(fs::read_to_string(&elsewhere).unwrap(), "untouched"); + + // The right link, but its target swapped for a link of its own. + fs::remove_file(home.join(".claude.json")).unwrap(); + std::os::unix::fs::symlink(mnt.join(".claude.json"), home.join(".claude.json")).unwrap(); + std::os::unix::fs::symlink(&elsewhere, mnt.join(".claude.json")).unwrap(); + let out = write_claude_json(tmp.path(), true); + assert!(!out.status.success()); + assert_eq!(fs::read_to_string(&elsewhere).unwrap(), "untouched"); +} + +#[test] +fn write_claude_json_outside_aws_writes_a_plain_file_as_before() { + let tmp = tempfile::tempdir().unwrap(); + let (home, _mnt) = hybrid_dirs(tmp.path()); + let out = write_claude_json(tmp.path(), false); + assert!(out.status.success(), "{}", text(&out)); + assert!(fs::symlink_metadata(home.join(".claude.json")).unwrap().is_file()); + assert_eq!(fs::read_to_string(home.join(".claude.json")).unwrap().trim(), "{\"a\":1}"); +} + +#[test] +fn the_hybrid_ssh_dir_is_used_only_through_the_link_triple_c_made() { + let tmp = tempfile::tempdir().unwrap(); + let (home, mnt) = hybrid_dirs(tmp.path()); + let check = |home: &Path| { + let blocks = aws_blocks(&["aws_env", "aws_home"]) + .replace("triplec_home_mnt=/mnt/triple-c-home", &format!("triplec_home_mnt='{}'", mnt.display())); + bash(&format!("{blocks}\naws_hybrid_ssh_dir \"$1\""), &[home], &[], &[]) + }; + fs::create_dir_all(mnt.join(".ssh")).unwrap(); + std::os::unix::fs::symlink(mnt.join(".ssh"), home.join(".ssh")).unwrap(); + let out = check(&home); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(String::from_utf8_lossy(&out.stdout), mnt.join(".ssh").to_str().unwrap()); + + // Persisted .ssh replaced by a link (to /etc, say): refused. + fs::remove_dir(mnt.join(".ssh")).unwrap(); + std::os::unix::fs::symlink(tmp.path(), mnt.join(".ssh")).unwrap(); + assert!(!check(&home).status.success()); + + // ~/.ssh linked anywhere else: refused. + fs::remove_file(home.join(".ssh")).unwrap(); + std::os::unix::fs::symlink(tmp.path(), home.join(".ssh")).unwrap(); + assert!(!check(&home).status.success()); } // --------------------------------------------------------------- clone_repos @@ -307,11 +395,18 @@ fn clone_repos_clones_skips_existing_and_reports_failures_without_failing() { {{"url":"{r}","branch":"dev","folder":"dev-copy"}}, {{"url":"/nonexistent/nope.git","branch":null,"folder":"existing"}}, {{"url":"/nonexistent/nope.git","branch":null,"folder":"broken"}}, - {{"url":"{r}","branch":null,"folder":"../escape"}}]"#, + {{"url":"{r}","branch":null,"folder":"../escape"}}, + {{"url":"{r}","branch":null,"folder":"bad\ud800"}}, + {{"url":"{r}","branch":null,"folder":".dotted"}}]"#, r = remote.display() ); - let script = format!("{}\nclone_repos \"$JSON\" \"$1\"\necho \"rc=$?\"\n", aws_blocks(&["aws_env", "aws_repos"])); - let out = bash(&script, &[&ws], &[("JSON", &json)], &[]); + let status = tmp.path().join("status"); + fs::create_dir_all(&status).unwrap(); + let script = format!( + "{}\nclone_repos \"$JSON\" \"$1\" \"$2\"\necho \"rc=$?\"\n", + aws_blocks(&["aws_env", "aws_repos"]) + ); + let out = bash(&script, &[&ws, &status], &[("JSON", &json)], &[]); let log = text(&out); assert!(out.status.success(), "{log}"); assert!(log.contains("rc=0"), "{log}"); @@ -321,7 +416,42 @@ fn clone_repos_clones_skips_existing_and_reports_failures_without_failing() { assert!(log.contains("Triple-C: clone of broken failed:"), "{log}"); assert!(!log.contains("clone of existing failed"), "{log}"); assert!(log.contains("../escape"), "a bad folder is refused by name: {log}"); + assert!(log.contains("bad folder 'bad\\ud800'"), "{log}"); assert!(!tmp.path().join("escape").exists()); + assert_eq!(fs::read_to_string(ws.join(".dotted/README")).unwrap(), "hello"); + assert_eq!( + fs::read_to_string(status.join("repos")).unwrap(), + "main-copy\tcloned\ndev-copy\tcloned\nexisting\texists\nbroken\tfailed\n.dotted\tcloned\n" + ); +} + +/// A stand-in `ssh` that records how it was called and fails. +fn fake_ssh(dir: &Path) -> (std::path::PathBuf, std::path::PathBuf) { + let log = dir.join("ssh-calls"); + let ssh = write_script(dir, "fake-ssh", &format!("#!/bin/sh\necho \"$*\" >> '{}'\nexit 255\n", log.display())); + (ssh, log) +} + +#[test] +fn clone_repos_keeps_the_users_ssh_command_and_adds_batch_mode() { + let tmp = tempfile::tempdir().unwrap(); + let (ssh, calls) = fake_ssh(tmp.path()); + let home = tmp.path().join("home"); + fs::create_dir_all(&home).unwrap(); + fs::write(home.join(".gitconfig"), format!("[core]\n\tsshCommand = sh {} -o User=me\n", ssh.display())).unwrap(); + let ws = tmp.path().join("ws"); + fs::create_dir_all(&ws).unwrap(); + let json = r#"[{"url":"ssh://git@example.invalid/r.git","branch":null,"folder":"r"}]"#; + let script = format!("{}\nclone_repos \"$JSON\" \"$1\"\n", aws_blocks(&["aws_env", "aws_repos"])); + let out = bash( + &script, + &[&ws], + &[("JSON", json), ("HOME", home.to_str().unwrap()), ("GIT_CONFIG_NOSYSTEM", "1")], + &["GIT_SSH_COMMAND", "GIT_SSH"], + ); + assert!(text(&out).contains("clone of r failed"), "{}", text(&out)); + let calls = fs::read_to_string(&calls).unwrap(); + assert!(calls.contains("-o User=me -o BatchMode=yes"), "{calls}"); } #[test] @@ -345,7 +475,10 @@ fn write_script(dir: &Path, name: &str, body: &str) -> std::path::PathBuf { fn wip_push(ws: &Path, args: &[&str]) -> Output { let tmp = ws.parent().unwrap(); let script = write_script(tmp, "triple-c-wip-push", WIP_PUSH); - Command::new(script) + // Through bash rather than exec'd: a script written a moment ago can be + // ETXTBSY to exec while a parallel test's fork still holds it open. + Command::new("bash") + .arg(script) .args(args) .env("TRIPLEC_WORKSPACE_ROOT", ws) .env("GIT_CONFIG_NOSYSTEM", "1") @@ -481,6 +614,65 @@ fn wip_push_fails_when_a_push_fails() { assert!(text(&out).contains("proj"), "{}", text(&out)); } +#[test] +fn wip_push_covers_dot_folders() { + let tmp = tempfile::tempdir().unwrap(); + let (ws, _repo, remote) = workspace_with_clone(tmp.path()); + git(&ws, &["clone", "-q", remote.to_str().unwrap(), ".hidden"]); + fs::write(ws.join(".hidden/y"), "y").unwrap(); + let out = wip_push(&ws, &["--dry-run"]); + assert!(String::from_utf8_lossy(&out.stdout).contains(r#""folder":".hidden""#), "{}", text(&out)); + let out = wip_push(&ws, &[]); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(wip_refs(&remote).len(), 1); +} + +#[test] +fn wip_push_keeps_the_repos_ssh_command_and_adds_batch_mode() { + let tmp = tempfile::tempdir().unwrap(); + let (ws, repo, _remote) = workspace_with_clone(tmp.path()); + let (ssh, calls) = fake_ssh(tmp.path()); + git(&repo, &["config", "core.sshCommand", &format!("sh {} -o User=me", ssh.display())]); + git(&repo, &["remote", "set-url", "origin", "ssh://git@example.invalid/r.git"]); + fs::write(repo.join("y"), "y").unwrap(); + let out = wip_push(&ws, &[]); + assert!(!out.status.success(), "the fake ssh fails the push"); + let calls = fs::read_to_string(&calls).unwrap(); + assert!(calls.contains("-o User=me -o BatchMode=yes"), "{calls}"); +} + +#[test] +fn wip_push_uploads_lfs_objects_before_the_no_verify_push() { + let tmp = tempfile::tempdir().unwrap(); + let (ws, repo, remote) = workspace_with_clone(tmp.path()); + // A stand-in git-lfs: present, reports one LFS file, records the push. + let bin = tmp.path().join("bin"); + fs::create_dir_all(&bin).unwrap(); + let log = tmp.path().join("lfs-calls"); + write_script( + &bin, + "git-lfs", + &format!( + "#!/bin/sh\ncase \"$1\" in\n version) echo fake;;\n ls-files) echo big.bin;;\n push) echo \"$*\" >> '{}';;\nesac\n", + log.display() + ), + ); + fs::write(repo.join("y"), "y").unwrap(); + let script = write_script(tmp.path(), "triple-c-wip-push", WIP_PUSH); + let out = Command::new("bash") + .arg(script) + .env("TRIPLEC_WORKSPACE_ROOT", &ws) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("HOME", tmp.path()) + .env("PATH", format!("{}:{}", bin.display(), std::env::var("PATH").unwrap())) + .output() + .unwrap(); + assert!(out.status.success(), "{}", text(&out)); + let refs = wip_refs(&remote); + let pushed = git(&remote, &["rev-parse", &refs[0]]); + assert_eq!(fs::read_to_string(&log).unwrap().trim(), format!("push origin {pushed}")); +} + #[test] fn wip_push_rejects_an_unknown_reason() { let tmp = tempfile::tempdir().unwrap(); @@ -538,7 +730,8 @@ impl Watch { fn cmd(&self, agent_url: &str, signal_pid: u32) -> Command { let p = self.tmp.path(); - let mut c = Command::new(p.join("triple-c-idle-watch")); + let mut c = Command::new("bash"); + c.arg(p.join("triple-c-idle-watch")); c.env_clear() .env("PATH", std::env::var("PATH").unwrap()) .env("HOME", p.join("home")) @@ -589,6 +782,20 @@ s.serve_forever() (format!("http://127.0.0.1:{}", line.trim()), child) } +#[test] +fn idle_watch_check_counts_an_unfinished_clone_as_busy() { + let w = Watch::new(); + let (url, mut agent) = fake_agent(w.tmp.path(), r#"{"last_activity_unix":1,"active_execs":0,"active_tunnels":0}"#); + let clone = w.tmp.path().join("clone"); + fs::create_dir_all(&clone).unwrap(); + let busy = w.cmd(&url, 0).env("TRIPLEC_CLONE_STATUS_DIR", &clone).arg("--check").output().unwrap(); + fs::write(clone.join("done"), "").unwrap(); + let idle = w.cmd(&url, 0).env("TRIPLEC_CLONE_STATUS_DIR", &clone).arg("--check").output().unwrap(); + agent.kill().ok(); + assert_eq!(busy.status.code(), Some(1), "{}", text(&busy)); + assert_eq!(idle.status.code(), Some(0), "{}", text(&idle)); +} + #[test] fn idle_watch_check_treats_an_unreachable_agent_as_busy() { let w = Watch::new(); @@ -689,6 +896,7 @@ fn docker_mode_never_reaches_the_aws_blocks() { "triple-c-idle-watch", "clone_repos", "link_persistent_home", + "aws_hybrid_ssh_dir", "aws_before_stop", "start_idle_watch", ]; @@ -706,7 +914,18 @@ fn docker_mode_never_reaches_the_aws_blocks() { "unguarded call outside the aws blocks: {line}" ); } - assert_eq!(seen, 4, "the four call sites: Hybrid link, clone, idle watch, stop"); + assert_eq!(seen, 5, "the call sites: Hybrid link, Hybrid ~/.ssh, clone, idle watch, stop"); + // Clones start in the background *after* the agent: the app gives a + // starting task's agent only seconds to answer. + let pos = |needle: &str| { + lines + .iter() + .position(|l| l.trim_start().starts_with(needle)) + .unwrap_or_else(|| panic!("{needle}")) + }; + let agent_call = lines.iter().position(|l| l.trim() == "start_agent").expect("start_agent call"); + assert!(agent_call < pos("start_clone_repos"), "clones must start after the agent"); + assert!(block(ENTRYPOINT, "aws_repos").contains(") &"), "start_clone_repos backgrounds the clone"); // And the snapshot-sensitive variables are gone before the agent's // environment snapshot is taken. assert!(ENTRYPOINT.find("unset TRIPLEC_FILES").unwrap() < ENTRYPOINT.find("# >>> agent_token").unwrap()); diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 701026c..ffeb841 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -245,7 +245,11 @@ pub(crate) const LABEL_SCRUB: &str = "triple-c.scrub"; /// cannot travel onto a snapshot. See the `LABEL` block in the Dockerfile. pub(crate) const LABEL_BASE: &str = "triple-c.base"; -const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; +/// `TRIPLEC_` is the AWS workspace's own namespace (`TRIPLEC_HOST`, +/// `TRIPLEC_FILES`, `TRIPLEC_AGENT_TOKEN`, … — see `container/entrypoint.sh`): +/// a custom var there could switch a local container into AWS mode or forge +/// the inputs the entrypoint trusts. +const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_", "TRIPLEC_"]; /// Exact env var names Triple-C manages itself. Not covered by /// [`RESERVED_ENV_PREFIXES`] because they don't share those prefixes. @@ -4096,6 +4100,22 @@ mod tests { assert_eq!(vpn_env_var(false), "VPN_SUPPORT_ENABLED=0"); } + #[test] + fn the_aws_workspace_namespace_is_reserved_from_custom_env() { + for key in ["TRIPLEC_HOST", "TRIPLEC_FILES", "TRIPLEC_AGENT_TOKEN", "triplec_repos", "TRIPLEC_ANYTHING"] { + assert!(is_reserved_env_key(key), "{key}"); + } + assert_eq!( + compute_env_fingerprint(&[EnvVar { + key: "TRIPLEC_HOST".to_string(), + value: "aws".to_string(), + }]), + "", + "a reserved key never reaches the container" + ); + assert!(!is_reserved_env_key("TRIPLECX")); + } + #[test] fn the_vpn_skill_flag_is_reserved_from_custom_env() { // entrypoint.sh installs and removes the pia-vpn skill from this diff --git a/container/entrypoint.sh b/container/entrypoint.sh index cc71f91..91ad73c 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -34,6 +34,8 @@ triplec_aws=0 triplec_run_as=claude triplec_repos_json= +# Where Hybrid storage mounts the project's EFS access point. +triplec_home_mnt=/mnt/triple-c-home # stdin: the TRIPLEC_FILES JSON; $1 the ssh dir, $2 the CA dir. Names are # validated (non-empty, no `/`, not `.`/`..`, no NUL) and anything else is @@ -54,27 +56,21 @@ except ValueError: if not isinstance(files, dict): say("TRIPLEC_FILES is not a JSON object; no files written") sys.exit(1) -bad = 0 -for key, value in files.items(): +def write(key, value): kind, sep, name = key.partition("/") if kind not in dirs or not sep or name in ("", ".", "..") or "/" in name or "\0" in name: - say("refusing file entry %r: not ssh/ or ca/" % key) - bad += 1 - continue + return "not ssh/ or ca/" + name.encode("utf-8") # a lone surrogate cannot name a file try: data = base64.b64decode(value, validate=True) if isinstance(value, str) else None except (binascii.Error, ValueError): data = None if data is None: - say("refusing file entry %r: value is not base64" % key) - bad += 1 - continue + return "value is not base64" d = dirs[kind] os.makedirs(d, mode=0o700, exist_ok=True) if not stat.S_ISDIR(os.lstat(d).st_mode): - say("refusing file entry %r: %s is not a directory" % (key, d)) - bad += 1 - continue + return "%s is not a directory" % d os.chmod(d, 0o700) if root: os.chown(d, 0, 0) @@ -84,6 +80,16 @@ for key, value in files.items(): if root: os.fchown(f.fileno(), 0, 0) f.write(data) + return None +bad = 0 +for key, value in files.items(): + try: + why = write(key, value) + except (OSError, UnicodeError, ValueError) as e: + why = "%s" % (e.strerror if isinstance(e, OSError) and e.strerror else type(e).__name__) + if why: + say("refusing file entry %r: %s" % (key, why)) + bad += 1 sys.exit(1 if bad else 0) ' "$1" "$2" } @@ -220,10 +226,14 @@ find /home/claude \( ! -user claude -o ! -group claude \) -exec chown -h claude: # >>> aws_home link_persistent_home() { local home="$1" persist="$2" owner="$3" name link target - if [ ! -d "$persist" ]; then + if [ ! -d "$persist" ] || [ -L "$persist" ]; then echo "entrypoint: warning — Hybrid storage, but $persist is not mounted; home state will not persist" return 0 fi + # The access point already makes its root 1000:1000; said again here so + # claude (Claude Code's own atomic saves, write_claude_json) can always + # create files beside the persisted ones. + [ -z "$owner" ] || chown "$owner" "$persist" 2>/dev/null for name in .claude .claude.json .ssh; do link="$home/$name" target="$persist/$name" @@ -254,9 +264,21 @@ link_persistent_home() { done echo "entrypoint: Hybrid storage — ~/.claude, ~/.claude.json and ~/.ssh persist on $persist" } + +# The real directory behind a Hybrid ~/.ssh link, printed only when the link +# is exactly the one link_persistent_home makes and neither it nor the mount +# is itself a symlink. Root chowns and chmods under the result, and the +# persisted directory is claude-writable, so root must never be pointed +# anywhere else by it. +aws_hybrid_ssh_dir() { + local home="$1" want="$triplec_home_mnt/.ssh" + [ "$(readlink "$home/.ssh")" = "$want" ] || return 1 + [ ! -L "$triplec_home_mnt" ] && [ ! -L "$want" ] && [ -d "$want" ] || return 1 + printf '%s' "$want" +} # <<< aws_home if [ "$triplec_aws" = 1 ] && [ "${TRIPLEC_STORAGE:-}" = hybrid ]; then - link_persistent_home /home/claude /mnt/triple-c-home claude:claude + link_persistent_home /home/claude "$triplec_home_mnt" claude:claude fi # ── Corporate CA certificates ─────────────────────────────────────────────── @@ -509,20 +531,29 @@ sync_host_ssh() { } # <<< sync_host_ssh -if [ -d /tmp/.host-ssh ]; then - sync_host_ssh /tmp/.host-ssh /home/claude/.ssh - # Trailing slashes: on AWS Hybrid ~/.ssh is a symlink, and without them - # `chown -R` and `find` would stop at the link itself. - chown -R claude:claude /home/claude/.ssh/ - chmod 700 /home/claude/.ssh - find /home/claude/.ssh/ -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \; - find /home/claude/.ssh/ -type f -name "*.triple-c-backup" -exec chmod 600 {} \; - find /home/claude/.ssh/ -type f -name "*.pub" -exec chmod 644 {} \; - if [ -f /home/claude/.ssh/known_hosts ]; then - chmod 644 /home/claude/.ssh/known_hosts +# On AWS Hybrid ~/.ssh is a link into the EFS mount; the root steps below then +# work on the real directory behind it, and only after aws_hybrid_ssh_dir has +# confirmed the link is the one Triple-C made. Any other link: nothing is +# merged. Docker: always /home/claude/.ssh, as before. +ssh_home_dir=/home/claude/.ssh +if [ "$triplec_aws" = 1 ] && [ -L "$ssh_home_dir" ]; then + ssh_home_dir=$(aws_hybrid_ssh_dir /home/claude) || { + echo "entrypoint: warning — ~/.ssh is a symlink Triple-C did not make; host SSH files not merged" + ssh_home_dir="" + } +fi +if [ -n "$ssh_home_dir" ] && [ -d /tmp/.host-ssh ]; then + sync_host_ssh /tmp/.host-ssh "$ssh_home_dir" + chown -R claude:claude "$ssh_home_dir" + chmod 700 "$ssh_home_dir" + find "$ssh_home_dir" -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \; + find "$ssh_home_dir" -type f -name "*.triple-c-backup" -exec chmod 600 {} \; + find "$ssh_home_dir" -type f -name "*.pub" -exec chmod 644 {} \; + if [ -f "$ssh_home_dir/known_hosts" ]; then + chmod 644 "$ssh_home_dir/known_hosts" fi - if [ -f /home/claude/.ssh/config ]; then - chmod 600 /home/claude/.ssh/config + if [ -f "$ssh_home_dir/config" ]; then + chmod 600 "$ssh_home_dir/config" fi fi @@ -595,21 +626,30 @@ chown claude:claude /home/claude/.gitconfig 2>/dev/null || true # ── AWS: clone the project's repositories ─────────────────────────────────── # An AWS workspace has no project folder to mount; its repositories are cloned -# into /workspace/ on start. Here, after the credential helper, git -# identity and ~/.ssh are in place, as `claude`, with prompts off and a -# per-repo time limit. A folder that is already a repository (Hybrid keeps -# nothing under /workspace today, but a restarted container would) is left -# alone. Failures are reported and never stop the start: a workspace with one -# repo missing is still a workspace. +# into /workspace/ once the credential helper, git identity and ~/.ssh +# are in place — as `claude`, with prompts off and a per-repo time limit. A +# folder that is already a repository is left alone. Failures are reported and +# never stop the start: a workspace with one repo missing is still a workspace. +# +# In the background, started after the agent (start_clone_repos below): the +# app gives a starting task's agent only seconds to answer, so a clone in the +# way of the agent would fail every start with a large repository. Terminals +# work while the repos arrive. Progress, for the app to show later, is in +# /run/triple-c-clone (world-readable, root-written): `repos` gets one +# "\t" line per repo, and `done` appears when +# all are finished. The idle watcher counts an unfinished clone as activity. # >>> aws_repos +# clone_repos json root [status_dir] clone_repos() { - local json="$1" root="$2" folder url branch dest existed err rc + local json="$1" root="$2" status_dir="${3:-}" folder url branch dest existed err rc local limit="${triplec_clone_timeout:-600}" [ -n "$json" ] || return 0 + _clone_status() { [ -z "$status_dir" ] || printf '%s\t%s\n' "$1" "$2" >> "$status_dir/repos"; } while IFS= read -r -d '' folder && IFS= read -r -d '' url && IFS= read -r -d '' branch; do dest="$root/$folder" if [ -e "$dest/.git" ]; then echo "entrypoint: /workspace/$folder is already a repository; not cloning" + _clone_status "$folder" exists continue fi existed=0 @@ -618,17 +658,24 @@ clone_repos() { [ -n "$branch" ] && args+=(--branch "$branch") args+=(-- "$url" "$dest") echo "entrypoint: cloning $folder..." - err=$(_triplec_as_user env GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="ssh -o BatchMode=yes" \ - timeout -k 10 "$limit" git "${args[@]}" 2>&1 &1 "$status_dir/done" + ) & +} # <<< aws_repos -if [ "$triplec_aws" = 1 ]; then - clone_repos "$triplec_repos_json" /workspace -fi # ── Claude instructions ────────────────────────────────────────────────────── if [ -n "$CLAUDE_INSTRUCTIONS" ]; then @@ -826,27 +890,40 @@ fi # skips the write that would have repaired it. `triple-c-task-runner` has done # it this way all along. # -# A linked ~/.claude.json (AWS Hybrid storage) is written where the link -# points: renaming over the link itself would swap it for a plain file and the -# state would quietly stop persisting. +# On AWS Hybrid storage ~/.claude.json is a link into the EFS mount, and +# renaming over the link would swap it for a plain file and the state would +# quietly stop persisting. So the write goes to the link's target — but only +# when the link is exactly the one link_persistent_home makes and its target +# is not itself a link, and it is done *as claude*: that directory is +# claude-writable, so root must not write, rename or chown anything in it. +# Docker mode never takes that branch. # >>> write_claude_json write_claude_json() { - _wcj_dest="$CLAUDE_JSON" - if [ -L "$_wcj_dest" ]; then - _wcj_dest=$(readlink -f "$CLAUDE_JSON") - [ -n "$_wcj_dest" ] || _wcj_dest="$CLAUDE_JSON" + if [ "${triplec_aws:-0}" = 1 ] && [ -L "$CLAUDE_JSON" ]; then + _wcj_dest="$triplec_home_mnt/.claude.json" + if [ "$(readlink "$CLAUDE_JSON")" != "$_wcj_dest" ] || [ -L "$triplec_home_mnt" ] || [ -L "$_wcj_dest" ]; then + echo "entrypoint: warning — $CLAUDE_JSON is a symlink Triple-C did not make; not writing through it" + return 1 + fi + if printf '%s\n' "$1" | _triplec_as_user sh -c \ + 'umask 077; cat > "$1.triple-c-tmp" && mv -f "$1.triple-c-tmp" "$1"' _ "$_wcj_dest"; then + return 0 + fi + _triplec_as_user rm -f "$_wcj_dest.triple-c-tmp" + echo "entrypoint: warning — could not write $CLAUDE_JSON (leaving it as it was)" + return 1 fi - _wcj_tmp="${_wcj_dest}.triple-c-tmp" + _wcj_tmp="${CLAUDE_JSON}.triple-c-tmp" if printf '%s\n' "$1" > "$_wcj_tmp" 2>/dev/null; then - mv -f "$_wcj_tmp" "$_wcj_dest" 2>/dev/null || rm -f "$_wcj_tmp" + mv -f "$_wcj_tmp" "$CLAUDE_JSON" 2>/dev/null || rm -f "$_wcj_tmp" else rm -f "$_wcj_tmp" echo "entrypoint: warning — could not write $CLAUDE_JSON (leaving it as it was)" return 1 fi # By name, after the rename, so these land on the new inode. - chown claude:claude "$_wcj_dest" - chmod 600 "$_wcj_dest" + chown claude:claude "$CLAUDE_JSON" + chmod 600 "$CLAUDE_JSON" } # <<< write_claude_json @@ -1149,6 +1226,10 @@ start_idle_watch() { if [ "$triplec_aws" = 1 ] && [ -n "${TRIPLEC_IDLE_STOP_MINUTES:-}" ]; then start_idle_watch "$TRIPLEC_IDLE_STOP_MINUTES" "$$" fi +# Repos are cloned in the background from here on — see clone_repos. +if [ "$triplec_aws" = 1 ]; then + start_clone_repos "$triplec_repos_json" /run/triple-c-clone +fi echo "Triple-C container ready." keepalive_pid="" diff --git a/container/triple-c-idle-watch b/container/triple-c-idle-watch index 589819c..c50c5df 100755 --- a/container/triple-c-idle-watch +++ b/container/triple-c-idle-watch @@ -9,7 +9,9 @@ # * the agent's /v1/activity (read with the agent's own token file) shows # no authenticated activity for N minutes, no open exec, no open tunnel; # * no `claude` process is running; -# * no scheduler job is running (`triple-c-scheduler status`, as claude). +# * no scheduler job is running (`triple-c-scheduler status`, as claude); +# * the start's background repository clone has finished (entrypoint.sh, +# start_clone_repos: /run/triple-c-clone exists without `done`). # # Anything it cannot read counts as NOT idle: a stop is the one thing here # that cannot be undone, so every doubt keeps the workspace running. @@ -39,6 +41,7 @@ # TRIPLEC_SCHEDULER_BIN default /usr/local/bin/triple-c-scheduler # TRIPLEC_SCHEDULER_USER default claude; empty runs it as this user # TRIPLEC_IDLE_PROC_NAME default claude +# TRIPLEC_CLONE_STATUS_DIR default /run/triple-c-clone set -u @@ -51,6 +54,7 @@ reason_file="${TRIPLEC_STOP_REASON_FILE:-/run/triple-c/stop-reason}" scheduler="${TRIPLEC_SCHEDULER_BIN:-/usr/local/bin/triple-c-scheduler}" scheduler_user="${TRIPLEC_SCHEDULER_USER-claude}" proc_name="${TRIPLEC_IDLE_PROC_NAME:-claude}" +clone_dir="${TRIPLEC_CLONE_STATUS_DIR:-/run/triple-c-clone}" # >>> is_idle # is_idle now last_activity_unix active_execs active_tunnels claude_running jobs_running minutes @@ -95,12 +99,19 @@ jobs_running() { if [ "$out" = "Nothing running." ]; then echo 0; else echo 1; fi } +# A clone still under way counts as a running job. +clone_running() { + [ -d "$clone_dir" ] && [ ! -e "$clone_dir/done" ] +} + check() { - local act last execs tunnels claude + local act last execs tunnels claude jobs act=$(read_activity) || return 1 read -r last execs tunnels <<< "$act" if pgrep -x "$proc_name" >/dev/null 2>&1; then claude=1; else claude=0; fi - is_idle "$(date +%s)" "${last:-}" "${execs:-}" "${tunnels:-}" "$claude" "$(jobs_running)" "$minutes" + jobs=$(jobs_running) + clone_running && jobs=1 + is_idle "$(date +%s)" "${last:-}" "${execs:-}" "${tunnels:-}" "$claude" "$jobs" "$minutes" } case "$minutes" in diff --git a/container/triple-c-wip-push b/container/triple-c-wip-push index b83f5db..42489aa 100755 --- a/container/triple-c-wip-push +++ b/container/triple-c-wip-push @@ -46,9 +46,8 @@ while [ $# -gt 0 ]; do done export GIT_TERMINAL_PROMPT=0 GIT_OPTIONAL_LOCKS=0 -# BatchMode: an unknown host key or a passphrase prompt fails at once instead -# of waiting for input nobody will give. Keeps any GIT_SSH_COMMAND the user set. -export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh} -o BatchMode=yes" +# Dot-folders too: clone_repos accepts a folder named `.x`. +shopt -s dotglob nullglob stamp=$(date -u +%Y%m%d-%H%M%S) failed=0 @@ -63,6 +62,8 @@ pick_remote() { if [ "$(git remote | wc -l)" -eq 1 ]; then git remote; fi } +user_ssh_command="${GIT_SSH_COMMAND:-}" + for dir in "$root"/*/; do dir="${dir%/}" [ -d "$dir" ] || continue @@ -74,6 +75,12 @@ for dir in "$root"/*/; do ( cd "$dir" || exit 1 + # BatchMode: an unknown host key or a passphrase prompt fails at once + # instead of waiting for input nobody will give. Appended to the ssh + # command the user chose (GIT_SSH_COMMAND, else this repo's + # core.sshCommand), never instead of it. + ssh_cmd="${user_ssh_command:-$(git config --get core.sshCommand)}" + export GIT_SSH_COMMAND="${ssh_cmd:-ssh} -o BatchMode=yes" head=$(git rev-parse -q --verify HEAD 2>/dev/null) branch=$(git symbolic-ref -q --short HEAD 2>/dev/null) || branch=HEAD @@ -134,6 +141,17 @@ for dir in "$root"/*/; do fi ref="refs/heads/triple-c/wip/$stamp" + # --no-verify skips every pre-push hook, Git LFS's upload among them. + # So when git-lfs is here and the snapshot has LFS files, upload their + # objects first — best effort: the push still goes, with a warning. + if git lfs version >/dev/null 2>&1 \ + && [ -n "$(git lfs ls-files --name-only "$commit" 2>/dev/null | head -n 1)" ]; then + if lfs_err=$(git lfs push "$remote" "$commit" 2>&1); then + echo "triple-c-wip-push: $folder: uploaded LFS objects to $remote" + else + echo "triple-c-wip-push: $folder: warning — LFS upload failed, large files may be missing on the branch: $(printf '%s' "$lfs_err" | tail -n 1)" >&2 + fi + fi if err=$(git push --no-verify -q "$remote" "$commit:$ref" 2>&1); then echo "triple-c-wip-push: $folder: pushed to $remote $ref" else -- 2.52.0 From 51042f5d9e8940e8f63b45673581aff9fd849e38 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 10:15:14 -0800 Subject: [PATCH 15/43] feat(aws): stop guard, task log and AWS resource removal commands check_unsaved_work runs triple-c-wip-push --dry-run through the agent as claude (checked:false with the reason when the workspace can't be asked); stop_project_container takes wip: push runs the push first and a failure aborts the stop with its output. aws_task_log reads the current or last task's stream in the environment record's log group. remove_aws_resources removes a stopped project's AWS resources and clears its runtime handles, so it can move off AWS. add_project takes the host fields; an AWS project takes repositories, no folders, and no runtime handles from the caller. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/capabilities/default.json | 3 + app/src-tauri/gen/schemas/acl-manifests.json | 2 +- app/src-tauri/gen/schemas/capabilities.json | 2 +- app/src-tauri/gen/schemas/desktop-schema.json | 36 +++ app/src-tauri/gen/schemas/linux-schema.json | 36 +++ app/src-tauri/src/commands/mod.rs | 1 + .../src/commands/project_commands.rs | 178 ++++++++++++- app/src-tauri/src/commands/stop_guard.rs | 249 ++++++++++++++++++ app/src-tauri/src/host/aws_host.rs | 68 +++++ app/src-tauri/src/host/aws_host/tests.rs | 35 ++- app/src-tauri/src/lib.rs | 3 + app/src-tauri/src/models/project.rs | 29 ++ 12 files changed, 633 insertions(+), 9 deletions(-) create mode 100644 app/src-tauri/src/commands/stop_guard.rs diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 5005837..066ba49 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -18,6 +18,9 @@ "allow-update-project", "allow-start-project-container", "allow-stop-project-container", + "allow-check-unsaved-work", + "allow-aws-task-log", + "allow-remove-aws-resources", "allow-rebuild-project-container", "allow-reconcile-project-statuses", "allow-list-notes", diff --git a/app/src-tauri/gen/schemas/acl-manifests.json b/app/src-tauri/gen/schemas/acl-manifests.json index 8ca189d..2ea2ad7 100644 --- a/app/src-tauri/gen/schemas/acl-manifests.json +++ b/app/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-aws-keys-connection":{"identifier":"allow-add-aws-keys-connection","description":"Enables the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":["add_aws_keys_connection"],"deny":[]}},"allow-add-aws-profile-connection":{"identifier":"allow-add-aws-profile-connection","description":"Enables the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":["add_aws_profile_connection"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-connection-identity":{"identifier":"allow-aws-connection-identity","description":"Enables the aws_connection_identity command without any pre-configured scope.","commands":{"allow":["aws_connection_identity"],"deny":[]}},"allow-aws-connection-sso-login":{"identifier":"allow-aws-connection-sso-login","description":"Enables the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":["aws_connection_sso_login"],"deny":[]}},"allow-aws-policy-document":{"identifier":"allow-aws-policy-document","description":"Enables the aws_policy_document command without any pre-configured scope.","commands":{"allow":["aws_policy_document"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-aws-deployment":{"identifier":"allow-cancel-aws-deployment","description":"Enables the cancel_aws_deployment command without any pre-configured scope.","commands":{"allow":["cancel_aws_deployment"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-aws-permissions":{"identifier":"allow-check-aws-permissions","description":"Enables the check_aws_permissions command without any pre-configured scope.","commands":{"allow":["check_aws_permissions"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["clear_scheduler_notifications"],"deny":[]}},"allow-close-browser-view-popout":{"identifier":"allow-close-browser-view-popout","description":"Enables the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":["close_browser_view_popout"],"deny":[]}},"allow-close-container-page":{"identifier":"allow-close-container-page","description":"Enables the close_container_page command without any pre-configured scope.","commands":{"allow":["close_container_page"],"deny":[]}},"allow-close-terminal-session":{"identifier":"allow-close-terminal-session","description":"Enables the close_terminal_session command without any pre-configured scope.","commands":{"allow":["close_terminal_session"],"deny":[]}},"allow-confirm-migration":{"identifier":"allow-confirm-migration","description":"Enables the confirm_migration command without any pre-configured scope.","commands":{"allow":["confirm_migration"],"deny":[]}},"allow-create-container-directory":{"identifier":"allow-create-container-directory","description":"Enables the create_container_directory command without any pre-configured scope.","commands":{"allow":["create_container_directory"],"deny":[]}},"allow-delete-note":{"identifier":"allow-delete-note","description":"Enables the delete_note command without any pre-configured scope.","commands":{"allow":["delete_note"],"deny":[]}},"allow-deploy-aws-environment":{"identifier":"allow-deploy-aws-environment","description":"Enables the deploy_aws_environment command without any pre-configured scope.","commands":{"allow":["deploy_aws_environment"],"deny":[]}},"allow-deploy-aws-permissions-boundary":{"identifier":"allow-deploy-aws-permissions-boundary","description":"Enables the deploy_aws_permissions_boundary command without any pre-configured scope.","commands":{"allow":["deploy_aws_permissions_boundary"],"deny":[]}},"allow-destroy-incomplete-aws-environment":{"identifier":"allow-destroy-incomplete-aws-environment","description":"Enables the destroy_incomplete_aws_environment command without any pre-configured scope.","commands":{"allow":["destroy_incomplete_aws_environment"],"deny":[]}},"allow-detect-aws-config":{"identifier":"allow-detect-aws-config","description":"Enables the detect_aws_config command without any pre-configured scope.","commands":{"allow":["detect_aws_config"],"deny":[]}},"allow-detect-aws-environments":{"identifier":"allow-detect-aws-environments","description":"Enables the detect_aws_environments command without any pre-configured scope.","commands":{"allow":["detect_aws_environments"],"deny":[]}},"allow-detect-host-timezone":{"identifier":"allow-detect-host-timezone","description":"Enables the detect_host_timezone command without any pre-configured scope.","commands":{"allow":["detect_host_timezone"],"deny":[]}},"allow-detect-install-options":{"identifier":"allow-detect-install-options","description":"Enables the detect_install_options command without any pre-configured scope.","commands":{"allow":["detect_install_options"],"deny":[]}},"allow-download-container-backup":{"identifier":"allow-download-container-backup","description":"Enables the download_container_backup command without any pre-configured scope.","commands":{"allow":["download_container_backup"],"deny":[]}},"allow-download-container-file":{"identifier":"allow-download-container-file","description":"Enables the download_container_file command without any pre-configured scope.","commands":{"allow":["download_container_file"],"deny":[]}},"allow-export-settings":{"identifier":"allow-export-settings","description":"Enables the export_settings command without any pre-configured scope.","commands":{"allow":["export_settings"],"deny":[]}},"allow-follow-aws-deployment":{"identifier":"allow-follow-aws-deployment","description":"Enables the follow_aws_deployment command without any pre-configured scope.","commands":{"allow":["follow_aws_deployment"],"deny":[]}},"allow-forget-marketplace-installs":{"identifier":"allow-forget-marketplace-installs","description":"Enables the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":["forget_marketplace_installs"],"deny":[]}},"allow-get-app-version":{"identifier":"allow-get-app-version","description":"Enables the get_app_version command without any pre-configured scope.","commands":{"allow":["get_app_version"],"deny":[]}},"allow-get-auth-bridge-status":{"identifier":"allow-get-auth-bridge-status","description":"Enables the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":["get_auth_bridge_status"],"deny":[]}},"allow-get-browser-view-match-window":{"identifier":"allow-get-browser-view-match-window","description":"Enables the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["get_browser_view_match_window"],"deny":[]}},"allow-get-browser-view-popout-state":{"identifier":"allow-get-browser-view-popout-state","description":"Enables the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":["get_browser_view_popout_state"],"deny":[]}},"allow-get-browser-view-status":{"identifier":"allow-get-browser-view-status","description":"Enables the get_browser_view_status command without any pre-configured scope.","commands":{"allow":["get_browser_view_status"],"deny":[]}},"allow-get-container-info":{"identifier":"allow-get-container-info","description":"Enables the get_container_info command without any pre-configured scope.","commands":{"allow":["get_container_info"],"deny":[]}},"allow-get-container-page-state":{"identifier":"allow-get-container-page-state","description":"Enables the get_container_page_state command without any pre-configured scope.","commands":{"allow":["get_container_page_state"],"deny":[]}},"allow-get-container-staleness":{"identifier":"allow-get-container-staleness","description":"Enables the get_container_staleness command without any pre-configured scope.","commands":{"allow":["get_container_staleness"],"deny":[]}},"allow-get-gateway-auth-token":{"identifier":"allow-get-gateway-auth-token","description":"Enables the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["get_gateway_auth_token"],"deny":[]}},"allow-get-gateway-status":{"identifier":"allow-get-gateway-status","description":"Enables the get_gateway_status command without any pre-configured scope.","commands":{"allow":["get_gateway_status"],"deny":[]}},"allow-get-help-content":{"identifier":"allow-get-help-content","description":"Enables the get_help_content command without any pre-configured scope.","commands":{"allow":["get_help_content"],"deny":[]}},"allow-get-marketplace-sync-report":{"identifier":"allow-get-marketplace-sync-report","description":"Enables the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":["get_marketplace_sync_report"],"deny":[]}},"allow-get-migration-state":{"identifier":"allow-get-migration-state","description":"Enables the get_migration_state command without any pre-configured scope.","commands":{"allow":["get_migration_state"],"deny":[]}},"allow-get-scheduled-task-log":{"identifier":"allow-get-scheduled-task-log","description":"Enables the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":["get_scheduled_task_log"],"deny":[]}},"allow-get-scheduler-notifications":{"identifier":"allow-get-scheduler-notifications","description":"Enables the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["get_scheduler_notifications"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-get-stt-status":{"identifier":"allow-get-stt-status","description":"Enables the get_stt_status command without any pre-configured scope.","commands":{"allow":["get_stt_status"],"deny":[]}},"allow-get-web-terminal-status":{"identifier":"allow-get-web-terminal-status","description":"Enables the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":["get_web_terminal_status"],"deny":[]}},"allow-has-claude-token":{"identifier":"allow-has-claude-token","description":"Enables the has_claude_token command without any pre-configured scope.","commands":{"allow":["has_claude_token"],"deny":[]}},"allow-inspect-ca-cert-path":{"identifier":"allow-inspect-ca-cert-path","description":"Enables the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":["inspect_ca_cert_path"],"deny":[]}},"allow-install-browser-view-browser":{"identifier":"allow-install-browser-view-browser","description":"Enables the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":["install_browser_view_browser"],"deny":[]}},"allow-install-browser-view-support":{"identifier":"allow-install-browser-view-support","description":"Enables the install_browser_view_support command without any pre-configured scope.","commands":{"allow":["install_browser_view_support"],"deny":[]}},"allow-install-marketplace-item":{"identifier":"allow-install-marketplace-item","description":"Enables the install_marketplace_item command without any pre-configured scope.","commands":{"allow":["install_marketplace_item"],"deny":[]}},"allow-list-aws-profiles":{"identifier":"allow-list-aws-profiles","description":"Enables the list_aws_profiles command without any pre-configured scope.","commands":{"allow":["list_aws_profiles"],"deny":[]}},"allow-list-claude-sessions":{"identifier":"allow-list-claude-sessions","description":"Enables the list_claude_sessions command without any pre-configured scope.","commands":{"allow":["list_claude_sessions"],"deny":[]}},"allow-list-container-capabilities":{"identifier":"allow-list-container-capabilities","description":"Enables the list_container_capabilities command without any pre-configured scope.","commands":{"allow":["list_container_capabilities"],"deny":[]}},"allow-list-container-files":{"identifier":"allow-list-container-files","description":"Enables the list_container_files command without any pre-configured scope.","commands":{"allow":["list_container_files"],"deny":[]}},"allow-list-marketplace-snapshots":{"identifier":"allow-list-marketplace-snapshots","description":"Enables the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":["list_marketplace_snapshots"],"deny":[]}},"allow-list-marketplace-updates":{"identifier":"allow-list-marketplace-updates","description":"Enables the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":["list_marketplace_updates"],"deny":[]}},"allow-list-notes":{"identifier":"allow-list-notes","description":"Enables the list_notes command without any pre-configured scope.","commands":{"allow":["list_notes"],"deny":[]}},"allow-list-projects":{"identifier":"allow-list-projects","description":"Enables the list_projects command without any pre-configured scope.","commands":{"allow":["list_projects"],"deny":[]}},"allow-list-scheduled-tasks":{"identifier":"allow-list-scheduled-tasks","description":"Enables the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":["list_scheduled_tasks"],"deny":[]}},"allow-marketplace-gh-host-available":{"identifier":"allow-marketplace-gh-host-available","description":"Enables the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":["marketplace_gh_host_available"],"deny":[]}},"allow-marketplace-item-diff":{"identifier":"allow-marketplace-item-diff","description":"Enables the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":["marketplace_item_diff"],"deny":[]}},"allow-migrate-project-to-base":{"identifier":"allow-migrate-project-to-base","description":"Enables the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":["migrate_project_to_base"],"deny":[]}},"allow-onboard-aws-user":{"identifier":"allow-onboard-aws-user","description":"Enables the onboard_aws_user command without any pre-configured scope.","commands":{"allow":["onboard_aws_user"],"deny":[]}},"allow-open-browser-view-popout":{"identifier":"allow-open-browser-view-popout","description":"Enables the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":["open_browser_view_popout"],"deny":[]}},"allow-open-file-viewer":{"identifier":"allow-open-file-viewer","description":"Enables the open_file_viewer command without any pre-configured scope.","commands":{"allow":["open_file_viewer"],"deny":[]}},"allow-open-page-in-container-browser":{"identifier":"allow-open-page-in-container-browser","description":"Enables the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":["open_page_in_container_browser"],"deny":[]}},"allow-open-terminal-session":{"identifier":"allow-open-terminal-session","description":"Enables the open_terminal_session command without any pre-configured scope.","commands":{"allow":["open_terminal_session"],"deny":[]}},"allow-open-url-external":{"identifier":"allow-open-url-external","description":"Enables the open_url_external command without any pre-configured scope.","commands":{"allow":["open_url_external"],"deny":[]}},"allow-paste-image-to-terminal":{"identifier":"allow-paste-image-to-terminal","description":"Enables the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":["paste_image_to_terminal"],"deny":[]}},"allow-preview-settings-import":{"identifier":"allow-preview-settings-import","description":"Enables the preview_settings_import command without any pre-configured scope.","commands":{"allow":["preview_settings_import"],"deny":[]}},"allow-pull-gateway-image":{"identifier":"allow-pull-gateway-image","description":"Enables the pull_gateway_image command without any pre-configured scope.","commands":{"allow":["pull_gateway_image"],"deny":[]}},"allow-pull-image":{"identifier":"allow-pull-image","description":"Enables the pull_image command without any pre-configured scope.","commands":{"allow":["pull_image"],"deny":[]}},"allow-pull-stt-image":{"identifier":"allow-pull-stt-image","description":"Enables the pull_stt_image command without any pre-configured scope.","commands":{"allow":["pull_stt_image"],"deny":[]}},"allow-push-aws-saved-state":{"identifier":"allow-push-aws-saved-state","description":"Enables the push_aws_saved_state command without any pre-configured scope.","commands":{"allow":["push_aws_saved_state"],"deny":[]}},"allow-read-container-file":{"identifier":"allow-read-container-file","description":"Enables the read_container_file command without any pre-configured scope.","commands":{"allow":["read_container_file"],"deny":[]}},"allow-rebuild-project-container":{"identifier":"allow-rebuild-project-container","description":"Enables the rebuild_project_container command without any pre-configured scope.","commands":{"allow":["rebuild_project_container"],"deny":[]}},"allow-reconcile-project-statuses":{"identifier":"allow-reconcile-project-statuses","description":"Enables the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":["reconcile_project_statuses"],"deny":[]}},"allow-refresh-marketplaces":{"identifier":"allow-refresh-marketplaces","description":"Enables the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":["refresh_marketplaces"],"deny":[]}},"allow-regenerate-gateway-auth-token":{"identifier":"allow-regenerate-gateway-auth-token","description":"Enables the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["regenerate_gateway_auth_token"],"deny":[]}},"allow-regenerate-web-terminal-token":{"identifier":"allow-regenerate-web-terminal-token","description":"Enables the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":["regenerate_web_terminal_token"],"deny":[]}},"allow-remove-host-connection":{"identifier":"allow-remove-host-connection","description":"Enables the remove_host_connection command without any pre-configured scope.","commands":{"allow":["remove_host_connection"],"deny":[]}},"allow-remove-marketplace":{"identifier":"allow-remove-marketplace","description":"Enables the remove_marketplace command without any pre-configured scope.","commands":{"allow":["remove_marketplace"],"deny":[]}},"allow-remove-marketplace-account":{"identifier":"allow-remove-marketplace-account","description":"Enables the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":["remove_marketplace_account"],"deny":[]}},"allow-remove-project":{"identifier":"allow-remove-project","description":"Enables the remove_project command without any pre-configured scope.","commands":{"allow":["remove_project"],"deny":[]}},"allow-remove-scheduled-task":{"identifier":"allow-remove-scheduled-task","description":"Enables the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":["remove_scheduled_task"],"deny":[]}},"allow-rename-container-path":{"identifier":"allow-rename-container-path","description":"Enables the rename_container_path command without any pre-configured scope.","commands":{"allow":["rename_container_path"],"deny":[]}},"allow-repair-aws-environment":{"identifier":"allow-repair-aws-environment","description":"Enables the repair_aws_environment command without any pre-configured scope.","commands":{"allow":["repair_aws_environment"],"deny":[]}},"allow-rollback-migration":{"identifier":"allow-rollback-migration","description":"Enables the rollback_migration command without any pre-configured scope.","commands":{"allow":["rollback_migration"],"deny":[]}},"allow-run-docker-install":{"identifier":"allow-run-docker-install","description":"Enables the run_docker_install command without any pre-configured scope.","commands":{"allow":["run_docker_install"],"deny":[]}},"allow-run-scheduled-task-now":{"identifier":"allow-run-scheduled-task-now","description":"Enables the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":["run_scheduled_task_now"],"deny":[]}},"allow-save-aws-admin-bundle":{"identifier":"allow-save-aws-admin-bundle","description":"Enables the save_aws_admin_bundle command without any pre-configured scope.","commands":{"allow":["save_aws_admin_bundle"],"deny":[]}},"allow-save-note":{"identifier":"allow-save-note","description":"Enables the save_note command without any pre-configured scope.","commands":{"allow":["save_note"],"deny":[]}},"allow-send-audio-data":{"identifier":"allow-send-audio-data","description":"Enables the send_audio_data command without any pre-configured scope.","commands":{"allow":["send_audio_data"],"deny":[]}},"allow-set-auth-bridge-enabled":{"identifier":"allow-set-auth-bridge-enabled","description":"Enables the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":["set_auth_bridge_enabled"],"deny":[]}},"allow-set-browser-view-enabled":{"identifier":"allow-set-browser-view-enabled","description":"Enables the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":["set_browser_view_enabled"],"deny":[]}},"allow-set-browser-view-match-window":{"identifier":"allow-set-browser-view-match-window","description":"Enables the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["set_browser_view_match_window"],"deny":[]}},"allow-set-browser-view-popout-always-on-top":{"identifier":"allow-set-browser-view-popout-always-on-top","description":"Enables the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":["set_browser_view_popout_always_on_top"],"deny":[]}},"allow-set-container-page-viewport":{"identifier":"allow-set-container-page-viewport","description":"Enables the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":["set_container_page_viewport"],"deny":[]}},"allow-set-gateway-api-key":{"identifier":"allow-set-gateway-api-key","description":"Enables the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":["set_gateway_api_key"],"deny":[]}},"allow-set-global-item-disabled":{"identifier":"allow-set-global-item-disabled","description":"Enables the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":["set_global_item_disabled"],"deny":[]}},"allow-set-scheduled-task-enabled":{"identifier":"allow-set-scheduled-task-enabled","description":"Enables the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":["set_scheduled_task_enabled"],"deny":[]}},"allow-start-audio-bridge":{"identifier":"allow-start-audio-bridge","description":"Enables the start_audio_bridge command without any pre-configured scope.","commands":{"allow":["start_audio_bridge"],"deny":[]}},"allow-start-gateway":{"identifier":"allow-start-gateway","description":"Enables the start_gateway command without any pre-configured scope.","commands":{"allow":["start_gateway"],"deny":[]}},"allow-start-marketplace-gh-container-login":{"identifier":"allow-start-marketplace-gh-container-login","description":"Enables the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":["start_marketplace_gh_container_login"],"deny":[]}},"allow-start-project-container":{"identifier":"allow-start-project-container","description":"Enables the start_project_container command without any pre-configured scope.","commands":{"allow":["start_project_container"],"deny":[]}},"allow-start-stt":{"identifier":"allow-start-stt","description":"Enables the start_stt command without any pre-configured scope.","commands":{"allow":["start_stt"],"deny":[]}},"allow-start-web-terminal":{"identifier":"allow-start-web-terminal","description":"Enables the start_web_terminal command without any pre-configured scope.","commands":{"allow":["start_web_terminal"],"deny":[]}},"allow-stop-audio-bridge":{"identifier":"allow-stop-audio-bridge","description":"Enables the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":["stop_audio_bridge"],"deny":[]}},"allow-stop-gateway":{"identifier":"allow-stop-gateway","description":"Enables the stop_gateway command without any pre-configured scope.","commands":{"allow":["stop_gateway"],"deny":[]}},"allow-stop-project-container":{"identifier":"allow-stop-project-container","description":"Enables the stop_project_container command without any pre-configured scope.","commands":{"allow":["stop_project_container"],"deny":[]}},"allow-stop-stt":{"identifier":"allow-stop-stt","description":"Enables the stop_stt command without any pre-configured scope.","commands":{"allow":["stop_stt"],"deny":[]}},"allow-stop-web-terminal":{"identifier":"allow-stop-web-terminal","description":"Enables the stop_web_terminal command without any pre-configured scope.","commands":{"allow":["stop_web_terminal"],"deny":[]}},"allow-submit-claude-token-code":{"identifier":"allow-submit-claude-token-code","description":"Enables the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":["submit_claude_token_code"],"deny":[]}},"allow-sweep-claude-token-snapshots":{"identifier":"allow-sweep-claude-token-snapshots","description":"Enables the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":["sweep_claude_token_snapshots"],"deny":[]}},"allow-terminal-input":{"identifier":"allow-terminal-input","description":"Enables the terminal_input command without any pre-configured scope.","commands":{"allow":["terminal_input"],"deny":[]}},"allow-terminal-resize":{"identifier":"allow-terminal-resize","description":"Enables the terminal_resize command without any pre-configured scope.","commands":{"allow":["terminal_resize"],"deny":[]}},"allow-test-marketplace-account":{"identifier":"allow-test-marketplace-account","description":"Enables the test_marketplace_account command without any pre-configured scope.","commands":{"allow":["test_marketplace_account"],"deny":[]}},"allow-transcribe-audio":{"identifier":"allow-transcribe-audio","description":"Enables the transcribe_audio command without any pre-configured scope.","commands":{"allow":["transcribe_audio"],"deny":[]}},"allow-uninstall-marketplace-item":{"identifier":"allow-uninstall-marketplace-item","description":"Enables the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":["uninstall_marketplace_item"],"deny":[]}},"allow-unlock-aws-state":{"identifier":"allow-unlock-aws-state","description":"Enables the unlock_aws_state command without any pre-configured scope.","commands":{"allow":["unlock_aws_state"],"deny":[]}},"allow-update-aws-connection":{"identifier":"allow-update-aws-connection","description":"Enables the update_aws_connection command without any pre-configured scope.","commands":{"allow":["update_aws_connection"],"deny":[]}},"allow-update-aws-environment":{"identifier":"allow-update-aws-environment","description":"Enables the update_aws_environment command without any pre-configured scope.","commands":{"allow":["update_aws_environment"],"deny":[]}},"allow-update-marketplace":{"identifier":"allow-update-marketplace","description":"Enables the update_marketplace command without any pre-configured scope.","commands":{"allow":["update_marketplace"],"deny":[]}},"allow-update-marketplace-item":{"identifier":"allow-update-marketplace-item","description":"Enables the update_marketplace_item command without any pre-configured scope.","commands":{"allow":["update_marketplace_item"],"deny":[]}},"allow-update-project":{"identifier":"allow-update-project","description":"Enables the update_project command without any pre-configured scope.","commands":{"allow":["update_project"],"deny":[]}},"allow-update-scheduled-task":{"identifier":"allow-update-scheduled-task","description":"Enables the update_scheduled_task command without any pre-configured scope.","commands":{"allow":["update_scheduled_task"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-upload-files-to-container":{"identifier":"allow-upload-files-to-container","description":"Enables the upload_files_to_container command without any pre-configured scope.","commands":{"allow":["upload_files_to_container"],"deny":[]}},"allow-upload-host-file-to-terminal":{"identifier":"allow-upload-host-file-to-terminal","description":"Enables the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":["upload_host_file_to_terminal"],"deny":[]}},"allow-viewer-choose-file":{"identifier":"allow-viewer-choose-file","description":"Enables the viewer_choose_file command without any pre-configured scope.","commands":{"allow":["viewer_choose_file"],"deny":[]}},"allow-viewer-get-state":{"identifier":"allow-viewer-get-state","description":"Enables the viewer_get_state command without any pre-configured scope.","commands":{"allow":["viewer_get_state"],"deny":[]}},"allow-viewer-poll-file":{"identifier":"allow-viewer-poll-file","description":"Enables the viewer_poll_file command without any pre-configured scope.","commands":{"allow":["viewer_poll_file"],"deny":[]}},"allow-viewer-read-file":{"identifier":"allow-viewer-read-file","description":"Enables the viewer_read_file command without any pre-configured scope.","commands":{"allow":["viewer_read_file"],"deny":[]}},"allow-viewer-write-file":{"identifier":"allow-viewer-write-file","description":"Enables the viewer_write_file command without any pre-configured scope.","commands":{"allow":["viewer_write_file"],"deny":[]}},"deny-acquire-claude-token":{"identifier":"deny-acquire-claude-token","description":"Denies the acquire_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["acquire_claude_token"]}},"deny-add-aws-keys-connection":{"identifier":"deny-add-aws-keys-connection","description":"Denies the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["add_aws_keys_connection"]}},"deny-add-aws-profile-connection":{"identifier":"deny-add-aws-profile-connection","description":"Denies the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["add_aws_profile_connection"]}},"deny-add-marketplace":{"identifier":"deny-add-marketplace","description":"Denies the add_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace"]}},"deny-add-marketplace-gh-host-account":{"identifier":"deny-add-marketplace-gh-host-account","description":"Denies the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_gh_host_account"]}},"deny-add-marketplace-token-account":{"identifier":"deny-add-marketplace-token-account","description":"Denies the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_token_account"]}},"deny-add-project":{"identifier":"deny-add-project","description":"Denies the add_project command without any pre-configured scope.","commands":{"allow":[],"deny":["add_project"]}},"deny-add-scheduled-task":{"identifier":"deny-add-scheduled-task","description":"Denies the add_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["add_scheduled_task"]}},"deny-apply-marketplace-now":{"identifier":"deny-apply-marketplace-now","description":"Denies the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_marketplace_now"]}},"deny-apply-settings-import":{"identifier":"deny-apply-settings-import","description":"Denies the apply_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_settings_import"]}},"deny-aws-connection-identity":{"identifier":"deny-aws-connection-identity","description":"Denies the aws_connection_identity command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_connection_identity"]}},"deny-aws-connection-sso-login":{"identifier":"deny-aws-connection-sso-login","description":"Denies the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_connection_sso_login"]}},"deny-aws-policy-document":{"identifier":"deny-aws-policy-document","description":"Denies the aws_policy_document command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_policy_document"]}},"deny-aws-sso-refresh":{"identifier":"deny-aws-sso-refresh","description":"Denies the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_sso_refresh"]}},"deny-build-gateway-image":{"identifier":"deny-build-gateway-image","description":"Denies the build_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_gateway_image"]}},"deny-build-image":{"identifier":"deny-build-image","description":"Denies the build_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_image"]}},"deny-build-stt-image":{"identifier":"deny-build-stt-image","description":"Denies the build_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_stt_image"]}},"deny-cancel-aws-deployment":{"identifier":"deny-cancel-aws-deployment","description":"Denies the cancel_aws_deployment command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_aws_deployment"]}},"deny-cancel-claude-token":{"identifier":"deny-cancel-claude-token","description":"Denies the cancel_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_claude_token"]}},"deny-cancel-marketplace-gh-login":{"identifier":"deny-cancel-marketplace-gh-login","description":"Denies the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_marketplace_gh_login"]}},"deny-check-aws-permissions":{"identifier":"deny-check-aws-permissions","description":"Denies the check_aws_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_aws_permissions"]}},"deny-check-browser-view-support":{"identifier":"deny-check-browser-view-support","description":"Denies the check_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["check_browser_view_support"]}},"deny-check-docker":{"identifier":"deny-check-docker","description":"Denies the check_docker command without any pre-configured scope.","commands":{"allow":[],"deny":["check_docker"]}},"deny-check-for-updates":{"identifier":"deny-check-for-updates","description":"Denies the check_for_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["check_for_updates"]}},"deny-check-gateway-health":{"identifier":"deny-check-gateway-health","description":"Denies the check_gateway_health command without any pre-configured scope.","commands":{"allow":[],"deny":["check_gateway_health"]}},"deny-check-image-exists":{"identifier":"deny-check-image-exists","description":"Denies the check_image_exists command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_exists"]}},"deny-check-image-update":{"identifier":"deny-check-image-update","description":"Denies the check_image_update command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_update"]}},"deny-clear-claude-token":{"identifier":"deny-clear-claude-token","description":"Denies the clear_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_claude_token"]}},"deny-clear-gateway-api-key":{"identifier":"deny-clear-gateway-api-key","description":"Denies the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_gateway_api_key"]}},"deny-clear-scheduler-notifications":{"identifier":"deny-clear-scheduler-notifications","description":"Denies the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_scheduler_notifications"]}},"deny-close-browser-view-popout":{"identifier":"deny-close-browser-view-popout","description":"Denies the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["close_browser_view_popout"]}},"deny-close-container-page":{"identifier":"deny-close-container-page","description":"Denies the close_container_page command without any pre-configured scope.","commands":{"allow":[],"deny":["close_container_page"]}},"deny-close-terminal-session":{"identifier":"deny-close-terminal-session","description":"Denies the close_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["close_terminal_session"]}},"deny-confirm-migration":{"identifier":"deny-confirm-migration","description":"Denies the confirm_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm_migration"]}},"deny-create-container-directory":{"identifier":"deny-create-container-directory","description":"Denies the create_container_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["create_container_directory"]}},"deny-delete-note":{"identifier":"deny-delete-note","description":"Denies the delete_note command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_note"]}},"deny-deploy-aws-environment":{"identifier":"deny-deploy-aws-environment","description":"Denies the deploy_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["deploy_aws_environment"]}},"deny-deploy-aws-permissions-boundary":{"identifier":"deny-deploy-aws-permissions-boundary","description":"Denies the deploy_aws_permissions_boundary command without any pre-configured scope.","commands":{"allow":[],"deny":["deploy_aws_permissions_boundary"]}},"deny-destroy-incomplete-aws-environment":{"identifier":"deny-destroy-incomplete-aws-environment","description":"Denies the destroy_incomplete_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy_incomplete_aws_environment"]}},"deny-detect-aws-config":{"identifier":"deny-detect-aws-config","description":"Denies the detect_aws_config command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_aws_config"]}},"deny-detect-aws-environments":{"identifier":"deny-detect-aws-environments","description":"Denies the detect_aws_environments command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_aws_environments"]}},"deny-detect-host-timezone":{"identifier":"deny-detect-host-timezone","description":"Denies the detect_host_timezone command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_host_timezone"]}},"deny-detect-install-options":{"identifier":"deny-detect-install-options","description":"Denies the detect_install_options command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_install_options"]}},"deny-download-container-backup":{"identifier":"deny-download-container-backup","description":"Denies the download_container_backup command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_backup"]}},"deny-download-container-file":{"identifier":"deny-download-container-file","description":"Denies the download_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_file"]}},"deny-export-settings":{"identifier":"deny-export-settings","description":"Denies the export_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["export_settings"]}},"deny-follow-aws-deployment":{"identifier":"deny-follow-aws-deployment","description":"Denies the follow_aws_deployment command without any pre-configured scope.","commands":{"allow":[],"deny":["follow_aws_deployment"]}},"deny-forget-marketplace-installs":{"identifier":"deny-forget-marketplace-installs","description":"Denies the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":[],"deny":["forget_marketplace_installs"]}},"deny-get-app-version":{"identifier":"deny-get-app-version","description":"Denies the get_app_version command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_version"]}},"deny-get-auth-bridge-status":{"identifier":"deny-get-auth-bridge-status","description":"Denies the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_auth_bridge_status"]}},"deny-get-browser-view-match-window":{"identifier":"deny-get-browser-view-match-window","description":"Denies the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_match_window"]}},"deny-get-browser-view-popout-state":{"identifier":"deny-get-browser-view-popout-state","description":"Denies the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_popout_state"]}},"deny-get-browser-view-status":{"identifier":"deny-get-browser-view-status","description":"Denies the get_browser_view_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_status"]}},"deny-get-container-info":{"identifier":"deny-get-container-info","description":"Denies the get_container_info command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_info"]}},"deny-get-container-page-state":{"identifier":"deny-get-container-page-state","description":"Denies the get_container_page_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_page_state"]}},"deny-get-container-staleness":{"identifier":"deny-get-container-staleness","description":"Denies the get_container_staleness command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_staleness"]}},"deny-get-gateway-auth-token":{"identifier":"deny-get-gateway-auth-token","description":"Denies the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_auth_token"]}},"deny-get-gateway-status":{"identifier":"deny-get-gateway-status","description":"Denies the get_gateway_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_status"]}},"deny-get-help-content":{"identifier":"deny-get-help-content","description":"Denies the get_help_content command without any pre-configured scope.","commands":{"allow":[],"deny":["get_help_content"]}},"deny-get-marketplace-sync-report":{"identifier":"deny-get-marketplace-sync-report","description":"Denies the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":[],"deny":["get_marketplace_sync_report"]}},"deny-get-migration-state":{"identifier":"deny-get-migration-state","description":"Denies the get_migration_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_migration_state"]}},"deny-get-scheduled-task-log":{"identifier":"deny-get-scheduled-task-log","description":"Denies the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduled_task_log"]}},"deny-get-scheduler-notifications":{"identifier":"deny-get-scheduler-notifications","description":"Denies the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduler_notifications"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-get-stt-status":{"identifier":"deny-get-stt-status","description":"Denies the get_stt_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_stt_status"]}},"deny-get-web-terminal-status":{"identifier":"deny-get-web-terminal-status","description":"Denies the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_web_terminal_status"]}},"deny-has-claude-token":{"identifier":"deny-has-claude-token","description":"Denies the has_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["has_claude_token"]}},"deny-inspect-ca-cert-path":{"identifier":"deny-inspect-ca-cert-path","description":"Denies the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":[],"deny":["inspect_ca_cert_path"]}},"deny-install-browser-view-browser":{"identifier":"deny-install-browser-view-browser","description":"Denies the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_browser"]}},"deny-install-browser-view-support":{"identifier":"deny-install-browser-view-support","description":"Denies the install_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_support"]}},"deny-install-marketplace-item":{"identifier":"deny-install-marketplace-item","description":"Denies the install_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["install_marketplace_item"]}},"deny-list-aws-profiles":{"identifier":"deny-list-aws-profiles","description":"Denies the list_aws_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_aws_profiles"]}},"deny-list-claude-sessions":{"identifier":"deny-list-claude-sessions","description":"Denies the list_claude_sessions command without any pre-configured scope.","commands":{"allow":[],"deny":["list_claude_sessions"]}},"deny-list-container-capabilities":{"identifier":"deny-list-container-capabilities","description":"Denies the list_container_capabilities command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_capabilities"]}},"deny-list-container-files":{"identifier":"deny-list-container-files","description":"Denies the list_container_files command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_files"]}},"deny-list-marketplace-snapshots":{"identifier":"deny-list-marketplace-snapshots","description":"Denies the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_snapshots"]}},"deny-list-marketplace-updates":{"identifier":"deny-list-marketplace-updates","description":"Denies the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_updates"]}},"deny-list-notes":{"identifier":"deny-list-notes","description":"Denies the list_notes command without any pre-configured scope.","commands":{"allow":[],"deny":["list_notes"]}},"deny-list-projects":{"identifier":"deny-list-projects","description":"Denies the list_projects command without any pre-configured scope.","commands":{"allow":[],"deny":["list_projects"]}},"deny-list-scheduled-tasks":{"identifier":"deny-list-scheduled-tasks","description":"Denies the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":[],"deny":["list_scheduled_tasks"]}},"deny-marketplace-gh-host-available":{"identifier":"deny-marketplace-gh-host-available","description":"Denies the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_gh_host_available"]}},"deny-marketplace-item-diff":{"identifier":"deny-marketplace-item-diff","description":"Denies the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_item_diff"]}},"deny-migrate-project-to-base":{"identifier":"deny-migrate-project-to-base","description":"Denies the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":[],"deny":["migrate_project_to_base"]}},"deny-onboard-aws-user":{"identifier":"deny-onboard-aws-user","description":"Denies the onboard_aws_user command without any pre-configured scope.","commands":{"allow":[],"deny":["onboard_aws_user"]}},"deny-open-browser-view-popout":{"identifier":"deny-open-browser-view-popout","description":"Denies the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["open_browser_view_popout"]}},"deny-open-file-viewer":{"identifier":"deny-open-file-viewer","description":"Denies the open_file_viewer command without any pre-configured scope.","commands":{"allow":[],"deny":["open_file_viewer"]}},"deny-open-page-in-container-browser":{"identifier":"deny-open-page-in-container-browser","description":"Denies the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["open_page_in_container_browser"]}},"deny-open-terminal-session":{"identifier":"deny-open-terminal-session","description":"Denies the open_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["open_terminal_session"]}},"deny-open-url-external":{"identifier":"deny-open-url-external","description":"Denies the open_url_external command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url_external"]}},"deny-paste-image-to-terminal":{"identifier":"deny-paste-image-to-terminal","description":"Denies the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["paste_image_to_terminal"]}},"deny-preview-settings-import":{"identifier":"deny-preview-settings-import","description":"Denies the preview_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["preview_settings_import"]}},"deny-pull-gateway-image":{"identifier":"deny-pull-gateway-image","description":"Denies the pull_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_gateway_image"]}},"deny-pull-image":{"identifier":"deny-pull-image","description":"Denies the pull_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_image"]}},"deny-pull-stt-image":{"identifier":"deny-pull-stt-image","description":"Denies the pull_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_stt_image"]}},"deny-push-aws-saved-state":{"identifier":"deny-push-aws-saved-state","description":"Denies the push_aws_saved_state command without any pre-configured scope.","commands":{"allow":[],"deny":["push_aws_saved_state"]}},"deny-read-container-file":{"identifier":"deny-read-container-file","description":"Denies the read_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_container_file"]}},"deny-rebuild-project-container":{"identifier":"deny-rebuild-project-container","description":"Denies the rebuild_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["rebuild_project_container"]}},"deny-reconcile-project-statuses":{"identifier":"deny-reconcile-project-statuses","description":"Denies the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":[],"deny":["reconcile_project_statuses"]}},"deny-refresh-marketplaces":{"identifier":"deny-refresh-marketplaces","description":"Denies the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":[],"deny":["refresh_marketplaces"]}},"deny-regenerate-gateway-auth-token":{"identifier":"deny-regenerate-gateway-auth-token","description":"Denies the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_gateway_auth_token"]}},"deny-regenerate-web-terminal-token":{"identifier":"deny-regenerate-web-terminal-token","description":"Denies the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_web_terminal_token"]}},"deny-remove-host-connection":{"identifier":"deny-remove-host-connection","description":"Denies the remove_host_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_host_connection"]}},"deny-remove-marketplace":{"identifier":"deny-remove-marketplace","description":"Denies the remove_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace"]}},"deny-remove-marketplace-account":{"identifier":"deny-remove-marketplace-account","description":"Denies the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace_account"]}},"deny-remove-project":{"identifier":"deny-remove-project","description":"Denies the remove_project command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_project"]}},"deny-remove-scheduled-task":{"identifier":"deny-remove-scheduled-task","description":"Denies the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_scheduled_task"]}},"deny-rename-container-path":{"identifier":"deny-rename-container-path","description":"Denies the rename_container_path command without any pre-configured scope.","commands":{"allow":[],"deny":["rename_container_path"]}},"deny-repair-aws-environment":{"identifier":"deny-repair-aws-environment","description":"Denies the repair_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["repair_aws_environment"]}},"deny-rollback-migration":{"identifier":"deny-rollback-migration","description":"Denies the rollback_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["rollback_migration"]}},"deny-run-docker-install":{"identifier":"deny-run-docker-install","description":"Denies the run_docker_install command without any pre-configured scope.","commands":{"allow":[],"deny":["run_docker_install"]}},"deny-run-scheduled-task-now":{"identifier":"deny-run-scheduled-task-now","description":"Denies the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":[],"deny":["run_scheduled_task_now"]}},"deny-save-aws-admin-bundle":{"identifier":"deny-save-aws-admin-bundle","description":"Denies the save_aws_admin_bundle command without any pre-configured scope.","commands":{"allow":[],"deny":["save_aws_admin_bundle"]}},"deny-save-note":{"identifier":"deny-save-note","description":"Denies the save_note command without any pre-configured scope.","commands":{"allow":[],"deny":["save_note"]}},"deny-send-audio-data":{"identifier":"deny-send-audio-data","description":"Denies the send_audio_data command without any pre-configured scope.","commands":{"allow":[],"deny":["send_audio_data"]}},"deny-set-auth-bridge-enabled":{"identifier":"deny-set-auth-bridge-enabled","description":"Denies the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_auth_bridge_enabled"]}},"deny-set-browser-view-enabled":{"identifier":"deny-set-browser-view-enabled","description":"Denies the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_enabled"]}},"deny-set-browser-view-match-window":{"identifier":"deny-set-browser-view-match-window","description":"Denies the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_match_window"]}},"deny-set-browser-view-popout-always-on-top":{"identifier":"deny-set-browser-view-popout-always-on-top","description":"Denies the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_popout_always_on_top"]}},"deny-set-container-page-viewport":{"identifier":"deny-set-container-page-viewport","description":"Denies the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":[],"deny":["set_container_page_viewport"]}},"deny-set-gateway-api-key":{"identifier":"deny-set-gateway-api-key","description":"Denies the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["set_gateway_api_key"]}},"deny-set-global-item-disabled":{"identifier":"deny-set-global-item-disabled","description":"Denies the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_global_item_disabled"]}},"deny-set-scheduled-task-enabled":{"identifier":"deny-set-scheduled-task-enabled","description":"Denies the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_scheduled_task_enabled"]}},"deny-start-audio-bridge":{"identifier":"deny-start-audio-bridge","description":"Denies the start_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["start_audio_bridge"]}},"deny-start-gateway":{"identifier":"deny-start-gateway","description":"Denies the start_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["start_gateway"]}},"deny-start-marketplace-gh-container-login":{"identifier":"deny-start-marketplace-gh-container-login","description":"Denies the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":[],"deny":["start_marketplace_gh_container_login"]}},"deny-start-project-container":{"identifier":"deny-start-project-container","description":"Denies the start_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["start_project_container"]}},"deny-start-stt":{"identifier":"deny-start-stt","description":"Denies the start_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["start_stt"]}},"deny-start-web-terminal":{"identifier":"deny-start-web-terminal","description":"Denies the start_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["start_web_terminal"]}},"deny-stop-audio-bridge":{"identifier":"deny-stop-audio-bridge","description":"Denies the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_audio_bridge"]}},"deny-stop-gateway":{"identifier":"deny-stop-gateway","description":"Denies the stop_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_gateway"]}},"deny-stop-project-container":{"identifier":"deny-stop-project-container","description":"Denies the stop_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_project_container"]}},"deny-stop-stt":{"identifier":"deny-stop-stt","description":"Denies the stop_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_stt"]}},"deny-stop-web-terminal":{"identifier":"deny-stop-web-terminal","description":"Denies the stop_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_web_terminal"]}},"deny-submit-claude-token-code":{"identifier":"deny-submit-claude-token-code","description":"Denies the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":[],"deny":["submit_claude_token_code"]}},"deny-sweep-claude-token-snapshots":{"identifier":"deny-sweep-claude-token-snapshots","description":"Denies the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["sweep_claude_token_snapshots"]}},"deny-terminal-input":{"identifier":"deny-terminal-input","description":"Denies the terminal_input command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_input"]}},"deny-terminal-resize":{"identifier":"deny-terminal-resize","description":"Denies the terminal_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_resize"]}},"deny-test-marketplace-account":{"identifier":"deny-test-marketplace-account","description":"Denies the test_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["test_marketplace_account"]}},"deny-transcribe-audio":{"identifier":"deny-transcribe-audio","description":"Denies the transcribe_audio command without any pre-configured scope.","commands":{"allow":[],"deny":["transcribe_audio"]}},"deny-uninstall-marketplace-item":{"identifier":"deny-uninstall-marketplace-item","description":"Denies the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["uninstall_marketplace_item"]}},"deny-unlock-aws-state":{"identifier":"deny-unlock-aws-state","description":"Denies the unlock_aws_state command without any pre-configured scope.","commands":{"allow":[],"deny":["unlock_aws_state"]}},"deny-update-aws-connection":{"identifier":"deny-update-aws-connection","description":"Denies the update_aws_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["update_aws_connection"]}},"deny-update-aws-environment":{"identifier":"deny-update-aws-environment","description":"Denies the update_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["update_aws_environment"]}},"deny-update-marketplace":{"identifier":"deny-update-marketplace","description":"Denies the update_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace"]}},"deny-update-marketplace-item":{"identifier":"deny-update-marketplace-item","description":"Denies the update_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace_item"]}},"deny-update-project":{"identifier":"deny-update-project","description":"Denies the update_project command without any pre-configured scope.","commands":{"allow":[],"deny":["update_project"]}},"deny-update-scheduled-task":{"identifier":"deny-update-scheduled-task","description":"Denies the update_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["update_scheduled_task"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-upload-files-to-container":{"identifier":"deny-upload-files-to-container","description":"Denies the upload_files_to_container command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_files_to_container"]}},"deny-upload-host-file-to-terminal":{"identifier":"deny-upload-host-file-to-terminal","description":"Denies the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_host_file_to_terminal"]}},"deny-viewer-choose-file":{"identifier":"deny-viewer-choose-file","description":"Denies the viewer_choose_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_choose_file"]}},"deny-viewer-get-state":{"identifier":"deny-viewer-get-state","description":"Denies the viewer_get_state command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_get_state"]}},"deny-viewer-poll-file":{"identifier":"deny-viewer-poll-file","description":"Denies the viewer_poll_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_poll_file"]}},"deny-viewer-read-file":{"identifier":"deny-viewer-read-file","description":"Denies the viewer_read_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_read_file"]}},"deny-viewer-write-file":{"identifier":"deny-viewer-write-file","description":"Denies the viewer_write_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_write_file"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-aws-keys-connection":{"identifier":"allow-add-aws-keys-connection","description":"Enables the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":["add_aws_keys_connection"],"deny":[]}},"allow-add-aws-profile-connection":{"identifier":"allow-add-aws-profile-connection","description":"Enables the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":["add_aws_profile_connection"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-connection-identity":{"identifier":"allow-aws-connection-identity","description":"Enables the aws_connection_identity command without any pre-configured scope.","commands":{"allow":["aws_connection_identity"],"deny":[]}},"allow-aws-connection-sso-login":{"identifier":"allow-aws-connection-sso-login","description":"Enables the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":["aws_connection_sso_login"],"deny":[]}},"allow-aws-policy-document":{"identifier":"allow-aws-policy-document","description":"Enables the aws_policy_document command without any pre-configured scope.","commands":{"allow":["aws_policy_document"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-aws-task-log":{"identifier":"allow-aws-task-log","description":"Enables the aws_task_log command without any pre-configured scope.","commands":{"allow":["aws_task_log"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-aws-deployment":{"identifier":"allow-cancel-aws-deployment","description":"Enables the cancel_aws_deployment command without any pre-configured scope.","commands":{"allow":["cancel_aws_deployment"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-aws-permissions":{"identifier":"allow-check-aws-permissions","description":"Enables the check_aws_permissions command without any pre-configured scope.","commands":{"allow":["check_aws_permissions"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-check-unsaved-work":{"identifier":"allow-check-unsaved-work","description":"Enables the check_unsaved_work command without any pre-configured scope.","commands":{"allow":["check_unsaved_work"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["clear_scheduler_notifications"],"deny":[]}},"allow-close-browser-view-popout":{"identifier":"allow-close-browser-view-popout","description":"Enables the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":["close_browser_view_popout"],"deny":[]}},"allow-close-container-page":{"identifier":"allow-close-container-page","description":"Enables the close_container_page command without any pre-configured scope.","commands":{"allow":["close_container_page"],"deny":[]}},"allow-close-terminal-session":{"identifier":"allow-close-terminal-session","description":"Enables the close_terminal_session command without any pre-configured scope.","commands":{"allow":["close_terminal_session"],"deny":[]}},"allow-confirm-migration":{"identifier":"allow-confirm-migration","description":"Enables the confirm_migration command without any pre-configured scope.","commands":{"allow":["confirm_migration"],"deny":[]}},"allow-create-container-directory":{"identifier":"allow-create-container-directory","description":"Enables the create_container_directory command without any pre-configured scope.","commands":{"allow":["create_container_directory"],"deny":[]}},"allow-delete-note":{"identifier":"allow-delete-note","description":"Enables the delete_note command without any pre-configured scope.","commands":{"allow":["delete_note"],"deny":[]}},"allow-deploy-aws-environment":{"identifier":"allow-deploy-aws-environment","description":"Enables the deploy_aws_environment command without any pre-configured scope.","commands":{"allow":["deploy_aws_environment"],"deny":[]}},"allow-deploy-aws-permissions-boundary":{"identifier":"allow-deploy-aws-permissions-boundary","description":"Enables the deploy_aws_permissions_boundary command without any pre-configured scope.","commands":{"allow":["deploy_aws_permissions_boundary"],"deny":[]}},"allow-destroy-incomplete-aws-environment":{"identifier":"allow-destroy-incomplete-aws-environment","description":"Enables the destroy_incomplete_aws_environment command without any pre-configured scope.","commands":{"allow":["destroy_incomplete_aws_environment"],"deny":[]}},"allow-detect-aws-config":{"identifier":"allow-detect-aws-config","description":"Enables the detect_aws_config command without any pre-configured scope.","commands":{"allow":["detect_aws_config"],"deny":[]}},"allow-detect-aws-environments":{"identifier":"allow-detect-aws-environments","description":"Enables the detect_aws_environments command without any pre-configured scope.","commands":{"allow":["detect_aws_environments"],"deny":[]}},"allow-detect-host-timezone":{"identifier":"allow-detect-host-timezone","description":"Enables the detect_host_timezone command without any pre-configured scope.","commands":{"allow":["detect_host_timezone"],"deny":[]}},"allow-detect-install-options":{"identifier":"allow-detect-install-options","description":"Enables the detect_install_options command without any pre-configured scope.","commands":{"allow":["detect_install_options"],"deny":[]}},"allow-download-container-backup":{"identifier":"allow-download-container-backup","description":"Enables the download_container_backup command without any pre-configured scope.","commands":{"allow":["download_container_backup"],"deny":[]}},"allow-download-container-file":{"identifier":"allow-download-container-file","description":"Enables the download_container_file command without any pre-configured scope.","commands":{"allow":["download_container_file"],"deny":[]}},"allow-export-settings":{"identifier":"allow-export-settings","description":"Enables the export_settings command without any pre-configured scope.","commands":{"allow":["export_settings"],"deny":[]}},"allow-follow-aws-deployment":{"identifier":"allow-follow-aws-deployment","description":"Enables the follow_aws_deployment command without any pre-configured scope.","commands":{"allow":["follow_aws_deployment"],"deny":[]}},"allow-forget-marketplace-installs":{"identifier":"allow-forget-marketplace-installs","description":"Enables the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":["forget_marketplace_installs"],"deny":[]}},"allow-get-app-version":{"identifier":"allow-get-app-version","description":"Enables the get_app_version command without any pre-configured scope.","commands":{"allow":["get_app_version"],"deny":[]}},"allow-get-auth-bridge-status":{"identifier":"allow-get-auth-bridge-status","description":"Enables the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":["get_auth_bridge_status"],"deny":[]}},"allow-get-browser-view-match-window":{"identifier":"allow-get-browser-view-match-window","description":"Enables the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["get_browser_view_match_window"],"deny":[]}},"allow-get-browser-view-popout-state":{"identifier":"allow-get-browser-view-popout-state","description":"Enables the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":["get_browser_view_popout_state"],"deny":[]}},"allow-get-browser-view-status":{"identifier":"allow-get-browser-view-status","description":"Enables the get_browser_view_status command without any pre-configured scope.","commands":{"allow":["get_browser_view_status"],"deny":[]}},"allow-get-container-info":{"identifier":"allow-get-container-info","description":"Enables the get_container_info command without any pre-configured scope.","commands":{"allow":["get_container_info"],"deny":[]}},"allow-get-container-page-state":{"identifier":"allow-get-container-page-state","description":"Enables the get_container_page_state command without any pre-configured scope.","commands":{"allow":["get_container_page_state"],"deny":[]}},"allow-get-container-staleness":{"identifier":"allow-get-container-staleness","description":"Enables the get_container_staleness command without any pre-configured scope.","commands":{"allow":["get_container_staleness"],"deny":[]}},"allow-get-gateway-auth-token":{"identifier":"allow-get-gateway-auth-token","description":"Enables the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["get_gateway_auth_token"],"deny":[]}},"allow-get-gateway-status":{"identifier":"allow-get-gateway-status","description":"Enables the get_gateway_status command without any pre-configured scope.","commands":{"allow":["get_gateway_status"],"deny":[]}},"allow-get-help-content":{"identifier":"allow-get-help-content","description":"Enables the get_help_content command without any pre-configured scope.","commands":{"allow":["get_help_content"],"deny":[]}},"allow-get-marketplace-sync-report":{"identifier":"allow-get-marketplace-sync-report","description":"Enables the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":["get_marketplace_sync_report"],"deny":[]}},"allow-get-migration-state":{"identifier":"allow-get-migration-state","description":"Enables the get_migration_state command without any pre-configured scope.","commands":{"allow":["get_migration_state"],"deny":[]}},"allow-get-scheduled-task-log":{"identifier":"allow-get-scheduled-task-log","description":"Enables the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":["get_scheduled_task_log"],"deny":[]}},"allow-get-scheduler-notifications":{"identifier":"allow-get-scheduler-notifications","description":"Enables the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["get_scheduler_notifications"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-get-stt-status":{"identifier":"allow-get-stt-status","description":"Enables the get_stt_status command without any pre-configured scope.","commands":{"allow":["get_stt_status"],"deny":[]}},"allow-get-web-terminal-status":{"identifier":"allow-get-web-terminal-status","description":"Enables the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":["get_web_terminal_status"],"deny":[]}},"allow-has-claude-token":{"identifier":"allow-has-claude-token","description":"Enables the has_claude_token command without any pre-configured scope.","commands":{"allow":["has_claude_token"],"deny":[]}},"allow-inspect-ca-cert-path":{"identifier":"allow-inspect-ca-cert-path","description":"Enables the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":["inspect_ca_cert_path"],"deny":[]}},"allow-install-browser-view-browser":{"identifier":"allow-install-browser-view-browser","description":"Enables the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":["install_browser_view_browser"],"deny":[]}},"allow-install-browser-view-support":{"identifier":"allow-install-browser-view-support","description":"Enables the install_browser_view_support command without any pre-configured scope.","commands":{"allow":["install_browser_view_support"],"deny":[]}},"allow-install-marketplace-item":{"identifier":"allow-install-marketplace-item","description":"Enables the install_marketplace_item command without any pre-configured scope.","commands":{"allow":["install_marketplace_item"],"deny":[]}},"allow-list-aws-profiles":{"identifier":"allow-list-aws-profiles","description":"Enables the list_aws_profiles command without any pre-configured scope.","commands":{"allow":["list_aws_profiles"],"deny":[]}},"allow-list-claude-sessions":{"identifier":"allow-list-claude-sessions","description":"Enables the list_claude_sessions command without any pre-configured scope.","commands":{"allow":["list_claude_sessions"],"deny":[]}},"allow-list-container-capabilities":{"identifier":"allow-list-container-capabilities","description":"Enables the list_container_capabilities command without any pre-configured scope.","commands":{"allow":["list_container_capabilities"],"deny":[]}},"allow-list-container-files":{"identifier":"allow-list-container-files","description":"Enables the list_container_files command without any pre-configured scope.","commands":{"allow":["list_container_files"],"deny":[]}},"allow-list-marketplace-snapshots":{"identifier":"allow-list-marketplace-snapshots","description":"Enables the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":["list_marketplace_snapshots"],"deny":[]}},"allow-list-marketplace-updates":{"identifier":"allow-list-marketplace-updates","description":"Enables the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":["list_marketplace_updates"],"deny":[]}},"allow-list-notes":{"identifier":"allow-list-notes","description":"Enables the list_notes command without any pre-configured scope.","commands":{"allow":["list_notes"],"deny":[]}},"allow-list-projects":{"identifier":"allow-list-projects","description":"Enables the list_projects command without any pre-configured scope.","commands":{"allow":["list_projects"],"deny":[]}},"allow-list-scheduled-tasks":{"identifier":"allow-list-scheduled-tasks","description":"Enables the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":["list_scheduled_tasks"],"deny":[]}},"allow-marketplace-gh-host-available":{"identifier":"allow-marketplace-gh-host-available","description":"Enables the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":["marketplace_gh_host_available"],"deny":[]}},"allow-marketplace-item-diff":{"identifier":"allow-marketplace-item-diff","description":"Enables the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":["marketplace_item_diff"],"deny":[]}},"allow-migrate-project-to-base":{"identifier":"allow-migrate-project-to-base","description":"Enables the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":["migrate_project_to_base"],"deny":[]}},"allow-onboard-aws-user":{"identifier":"allow-onboard-aws-user","description":"Enables the onboard_aws_user command without any pre-configured scope.","commands":{"allow":["onboard_aws_user"],"deny":[]}},"allow-open-browser-view-popout":{"identifier":"allow-open-browser-view-popout","description":"Enables the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":["open_browser_view_popout"],"deny":[]}},"allow-open-file-viewer":{"identifier":"allow-open-file-viewer","description":"Enables the open_file_viewer command without any pre-configured scope.","commands":{"allow":["open_file_viewer"],"deny":[]}},"allow-open-page-in-container-browser":{"identifier":"allow-open-page-in-container-browser","description":"Enables the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":["open_page_in_container_browser"],"deny":[]}},"allow-open-terminal-session":{"identifier":"allow-open-terminal-session","description":"Enables the open_terminal_session command without any pre-configured scope.","commands":{"allow":["open_terminal_session"],"deny":[]}},"allow-open-url-external":{"identifier":"allow-open-url-external","description":"Enables the open_url_external command without any pre-configured scope.","commands":{"allow":["open_url_external"],"deny":[]}},"allow-paste-image-to-terminal":{"identifier":"allow-paste-image-to-terminal","description":"Enables the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":["paste_image_to_terminal"],"deny":[]}},"allow-preview-settings-import":{"identifier":"allow-preview-settings-import","description":"Enables the preview_settings_import command without any pre-configured scope.","commands":{"allow":["preview_settings_import"],"deny":[]}},"allow-pull-gateway-image":{"identifier":"allow-pull-gateway-image","description":"Enables the pull_gateway_image command without any pre-configured scope.","commands":{"allow":["pull_gateway_image"],"deny":[]}},"allow-pull-image":{"identifier":"allow-pull-image","description":"Enables the pull_image command without any pre-configured scope.","commands":{"allow":["pull_image"],"deny":[]}},"allow-pull-stt-image":{"identifier":"allow-pull-stt-image","description":"Enables the pull_stt_image command without any pre-configured scope.","commands":{"allow":["pull_stt_image"],"deny":[]}},"allow-push-aws-saved-state":{"identifier":"allow-push-aws-saved-state","description":"Enables the push_aws_saved_state command without any pre-configured scope.","commands":{"allow":["push_aws_saved_state"],"deny":[]}},"allow-read-container-file":{"identifier":"allow-read-container-file","description":"Enables the read_container_file command without any pre-configured scope.","commands":{"allow":["read_container_file"],"deny":[]}},"allow-rebuild-project-container":{"identifier":"allow-rebuild-project-container","description":"Enables the rebuild_project_container command without any pre-configured scope.","commands":{"allow":["rebuild_project_container"],"deny":[]}},"allow-reconcile-project-statuses":{"identifier":"allow-reconcile-project-statuses","description":"Enables the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":["reconcile_project_statuses"],"deny":[]}},"allow-refresh-marketplaces":{"identifier":"allow-refresh-marketplaces","description":"Enables the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":["refresh_marketplaces"],"deny":[]}},"allow-regenerate-gateway-auth-token":{"identifier":"allow-regenerate-gateway-auth-token","description":"Enables the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["regenerate_gateway_auth_token"],"deny":[]}},"allow-regenerate-web-terminal-token":{"identifier":"allow-regenerate-web-terminal-token","description":"Enables the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":["regenerate_web_terminal_token"],"deny":[]}},"allow-remove-aws-resources":{"identifier":"allow-remove-aws-resources","description":"Enables the remove_aws_resources command without any pre-configured scope.","commands":{"allow":["remove_aws_resources"],"deny":[]}},"allow-remove-host-connection":{"identifier":"allow-remove-host-connection","description":"Enables the remove_host_connection command without any pre-configured scope.","commands":{"allow":["remove_host_connection"],"deny":[]}},"allow-remove-marketplace":{"identifier":"allow-remove-marketplace","description":"Enables the remove_marketplace command without any pre-configured scope.","commands":{"allow":["remove_marketplace"],"deny":[]}},"allow-remove-marketplace-account":{"identifier":"allow-remove-marketplace-account","description":"Enables the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":["remove_marketplace_account"],"deny":[]}},"allow-remove-project":{"identifier":"allow-remove-project","description":"Enables the remove_project command without any pre-configured scope.","commands":{"allow":["remove_project"],"deny":[]}},"allow-remove-scheduled-task":{"identifier":"allow-remove-scheduled-task","description":"Enables the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":["remove_scheduled_task"],"deny":[]}},"allow-rename-container-path":{"identifier":"allow-rename-container-path","description":"Enables the rename_container_path command without any pre-configured scope.","commands":{"allow":["rename_container_path"],"deny":[]}},"allow-repair-aws-environment":{"identifier":"allow-repair-aws-environment","description":"Enables the repair_aws_environment command without any pre-configured scope.","commands":{"allow":["repair_aws_environment"],"deny":[]}},"allow-rollback-migration":{"identifier":"allow-rollback-migration","description":"Enables the rollback_migration command without any pre-configured scope.","commands":{"allow":["rollback_migration"],"deny":[]}},"allow-run-docker-install":{"identifier":"allow-run-docker-install","description":"Enables the run_docker_install command without any pre-configured scope.","commands":{"allow":["run_docker_install"],"deny":[]}},"allow-run-scheduled-task-now":{"identifier":"allow-run-scheduled-task-now","description":"Enables the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":["run_scheduled_task_now"],"deny":[]}},"allow-save-aws-admin-bundle":{"identifier":"allow-save-aws-admin-bundle","description":"Enables the save_aws_admin_bundle command without any pre-configured scope.","commands":{"allow":["save_aws_admin_bundle"],"deny":[]}},"allow-save-note":{"identifier":"allow-save-note","description":"Enables the save_note command without any pre-configured scope.","commands":{"allow":["save_note"],"deny":[]}},"allow-send-audio-data":{"identifier":"allow-send-audio-data","description":"Enables the send_audio_data command without any pre-configured scope.","commands":{"allow":["send_audio_data"],"deny":[]}},"allow-set-auth-bridge-enabled":{"identifier":"allow-set-auth-bridge-enabled","description":"Enables the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":["set_auth_bridge_enabled"],"deny":[]}},"allow-set-browser-view-enabled":{"identifier":"allow-set-browser-view-enabled","description":"Enables the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":["set_browser_view_enabled"],"deny":[]}},"allow-set-browser-view-match-window":{"identifier":"allow-set-browser-view-match-window","description":"Enables the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["set_browser_view_match_window"],"deny":[]}},"allow-set-browser-view-popout-always-on-top":{"identifier":"allow-set-browser-view-popout-always-on-top","description":"Enables the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":["set_browser_view_popout_always_on_top"],"deny":[]}},"allow-set-container-page-viewport":{"identifier":"allow-set-container-page-viewport","description":"Enables the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":["set_container_page_viewport"],"deny":[]}},"allow-set-gateway-api-key":{"identifier":"allow-set-gateway-api-key","description":"Enables the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":["set_gateway_api_key"],"deny":[]}},"allow-set-global-item-disabled":{"identifier":"allow-set-global-item-disabled","description":"Enables the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":["set_global_item_disabled"],"deny":[]}},"allow-set-scheduled-task-enabled":{"identifier":"allow-set-scheduled-task-enabled","description":"Enables the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":["set_scheduled_task_enabled"],"deny":[]}},"allow-start-audio-bridge":{"identifier":"allow-start-audio-bridge","description":"Enables the start_audio_bridge command without any pre-configured scope.","commands":{"allow":["start_audio_bridge"],"deny":[]}},"allow-start-gateway":{"identifier":"allow-start-gateway","description":"Enables the start_gateway command without any pre-configured scope.","commands":{"allow":["start_gateway"],"deny":[]}},"allow-start-marketplace-gh-container-login":{"identifier":"allow-start-marketplace-gh-container-login","description":"Enables the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":["start_marketplace_gh_container_login"],"deny":[]}},"allow-start-project-container":{"identifier":"allow-start-project-container","description":"Enables the start_project_container command without any pre-configured scope.","commands":{"allow":["start_project_container"],"deny":[]}},"allow-start-stt":{"identifier":"allow-start-stt","description":"Enables the start_stt command without any pre-configured scope.","commands":{"allow":["start_stt"],"deny":[]}},"allow-start-web-terminal":{"identifier":"allow-start-web-terminal","description":"Enables the start_web_terminal command without any pre-configured scope.","commands":{"allow":["start_web_terminal"],"deny":[]}},"allow-stop-audio-bridge":{"identifier":"allow-stop-audio-bridge","description":"Enables the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":["stop_audio_bridge"],"deny":[]}},"allow-stop-gateway":{"identifier":"allow-stop-gateway","description":"Enables the stop_gateway command without any pre-configured scope.","commands":{"allow":["stop_gateway"],"deny":[]}},"allow-stop-project-container":{"identifier":"allow-stop-project-container","description":"Enables the stop_project_container command without any pre-configured scope.","commands":{"allow":["stop_project_container"],"deny":[]}},"allow-stop-stt":{"identifier":"allow-stop-stt","description":"Enables the stop_stt command without any pre-configured scope.","commands":{"allow":["stop_stt"],"deny":[]}},"allow-stop-web-terminal":{"identifier":"allow-stop-web-terminal","description":"Enables the stop_web_terminal command without any pre-configured scope.","commands":{"allow":["stop_web_terminal"],"deny":[]}},"allow-submit-claude-token-code":{"identifier":"allow-submit-claude-token-code","description":"Enables the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":["submit_claude_token_code"],"deny":[]}},"allow-sweep-claude-token-snapshots":{"identifier":"allow-sweep-claude-token-snapshots","description":"Enables the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":["sweep_claude_token_snapshots"],"deny":[]}},"allow-terminal-input":{"identifier":"allow-terminal-input","description":"Enables the terminal_input command without any pre-configured scope.","commands":{"allow":["terminal_input"],"deny":[]}},"allow-terminal-resize":{"identifier":"allow-terminal-resize","description":"Enables the terminal_resize command without any pre-configured scope.","commands":{"allow":["terminal_resize"],"deny":[]}},"allow-test-marketplace-account":{"identifier":"allow-test-marketplace-account","description":"Enables the test_marketplace_account command without any pre-configured scope.","commands":{"allow":["test_marketplace_account"],"deny":[]}},"allow-transcribe-audio":{"identifier":"allow-transcribe-audio","description":"Enables the transcribe_audio command without any pre-configured scope.","commands":{"allow":["transcribe_audio"],"deny":[]}},"allow-uninstall-marketplace-item":{"identifier":"allow-uninstall-marketplace-item","description":"Enables the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":["uninstall_marketplace_item"],"deny":[]}},"allow-unlock-aws-state":{"identifier":"allow-unlock-aws-state","description":"Enables the unlock_aws_state command without any pre-configured scope.","commands":{"allow":["unlock_aws_state"],"deny":[]}},"allow-update-aws-connection":{"identifier":"allow-update-aws-connection","description":"Enables the update_aws_connection command without any pre-configured scope.","commands":{"allow":["update_aws_connection"],"deny":[]}},"allow-update-aws-environment":{"identifier":"allow-update-aws-environment","description":"Enables the update_aws_environment command without any pre-configured scope.","commands":{"allow":["update_aws_environment"],"deny":[]}},"allow-update-marketplace":{"identifier":"allow-update-marketplace","description":"Enables the update_marketplace command without any pre-configured scope.","commands":{"allow":["update_marketplace"],"deny":[]}},"allow-update-marketplace-item":{"identifier":"allow-update-marketplace-item","description":"Enables the update_marketplace_item command without any pre-configured scope.","commands":{"allow":["update_marketplace_item"],"deny":[]}},"allow-update-project":{"identifier":"allow-update-project","description":"Enables the update_project command without any pre-configured scope.","commands":{"allow":["update_project"],"deny":[]}},"allow-update-scheduled-task":{"identifier":"allow-update-scheduled-task","description":"Enables the update_scheduled_task command without any pre-configured scope.","commands":{"allow":["update_scheduled_task"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-upload-files-to-container":{"identifier":"allow-upload-files-to-container","description":"Enables the upload_files_to_container command without any pre-configured scope.","commands":{"allow":["upload_files_to_container"],"deny":[]}},"allow-upload-host-file-to-terminal":{"identifier":"allow-upload-host-file-to-terminal","description":"Enables the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":["upload_host_file_to_terminal"],"deny":[]}},"allow-viewer-choose-file":{"identifier":"allow-viewer-choose-file","description":"Enables the viewer_choose_file command without any pre-configured scope.","commands":{"allow":["viewer_choose_file"],"deny":[]}},"allow-viewer-get-state":{"identifier":"allow-viewer-get-state","description":"Enables the viewer_get_state command without any pre-configured scope.","commands":{"allow":["viewer_get_state"],"deny":[]}},"allow-viewer-poll-file":{"identifier":"allow-viewer-poll-file","description":"Enables the viewer_poll_file command without any pre-configured scope.","commands":{"allow":["viewer_poll_file"],"deny":[]}},"allow-viewer-read-file":{"identifier":"allow-viewer-read-file","description":"Enables the viewer_read_file command without any pre-configured scope.","commands":{"allow":["viewer_read_file"],"deny":[]}},"allow-viewer-write-file":{"identifier":"allow-viewer-write-file","description":"Enables the viewer_write_file command without any pre-configured scope.","commands":{"allow":["viewer_write_file"],"deny":[]}},"deny-acquire-claude-token":{"identifier":"deny-acquire-claude-token","description":"Denies the acquire_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["acquire_claude_token"]}},"deny-add-aws-keys-connection":{"identifier":"deny-add-aws-keys-connection","description":"Denies the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["add_aws_keys_connection"]}},"deny-add-aws-profile-connection":{"identifier":"deny-add-aws-profile-connection","description":"Denies the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["add_aws_profile_connection"]}},"deny-add-marketplace":{"identifier":"deny-add-marketplace","description":"Denies the add_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace"]}},"deny-add-marketplace-gh-host-account":{"identifier":"deny-add-marketplace-gh-host-account","description":"Denies the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_gh_host_account"]}},"deny-add-marketplace-token-account":{"identifier":"deny-add-marketplace-token-account","description":"Denies the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_token_account"]}},"deny-add-project":{"identifier":"deny-add-project","description":"Denies the add_project command without any pre-configured scope.","commands":{"allow":[],"deny":["add_project"]}},"deny-add-scheduled-task":{"identifier":"deny-add-scheduled-task","description":"Denies the add_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["add_scheduled_task"]}},"deny-apply-marketplace-now":{"identifier":"deny-apply-marketplace-now","description":"Denies the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_marketplace_now"]}},"deny-apply-settings-import":{"identifier":"deny-apply-settings-import","description":"Denies the apply_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_settings_import"]}},"deny-aws-connection-identity":{"identifier":"deny-aws-connection-identity","description":"Denies the aws_connection_identity command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_connection_identity"]}},"deny-aws-connection-sso-login":{"identifier":"deny-aws-connection-sso-login","description":"Denies the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_connection_sso_login"]}},"deny-aws-policy-document":{"identifier":"deny-aws-policy-document","description":"Denies the aws_policy_document command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_policy_document"]}},"deny-aws-sso-refresh":{"identifier":"deny-aws-sso-refresh","description":"Denies the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_sso_refresh"]}},"deny-aws-task-log":{"identifier":"deny-aws-task-log","description":"Denies the aws_task_log command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_task_log"]}},"deny-build-gateway-image":{"identifier":"deny-build-gateway-image","description":"Denies the build_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_gateway_image"]}},"deny-build-image":{"identifier":"deny-build-image","description":"Denies the build_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_image"]}},"deny-build-stt-image":{"identifier":"deny-build-stt-image","description":"Denies the build_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_stt_image"]}},"deny-cancel-aws-deployment":{"identifier":"deny-cancel-aws-deployment","description":"Denies the cancel_aws_deployment command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_aws_deployment"]}},"deny-cancel-claude-token":{"identifier":"deny-cancel-claude-token","description":"Denies the cancel_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_claude_token"]}},"deny-cancel-marketplace-gh-login":{"identifier":"deny-cancel-marketplace-gh-login","description":"Denies the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_marketplace_gh_login"]}},"deny-check-aws-permissions":{"identifier":"deny-check-aws-permissions","description":"Denies the check_aws_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_aws_permissions"]}},"deny-check-browser-view-support":{"identifier":"deny-check-browser-view-support","description":"Denies the check_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["check_browser_view_support"]}},"deny-check-docker":{"identifier":"deny-check-docker","description":"Denies the check_docker command without any pre-configured scope.","commands":{"allow":[],"deny":["check_docker"]}},"deny-check-for-updates":{"identifier":"deny-check-for-updates","description":"Denies the check_for_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["check_for_updates"]}},"deny-check-gateway-health":{"identifier":"deny-check-gateway-health","description":"Denies the check_gateway_health command without any pre-configured scope.","commands":{"allow":[],"deny":["check_gateway_health"]}},"deny-check-image-exists":{"identifier":"deny-check-image-exists","description":"Denies the check_image_exists command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_exists"]}},"deny-check-image-update":{"identifier":"deny-check-image-update","description":"Denies the check_image_update command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_update"]}},"deny-check-unsaved-work":{"identifier":"deny-check-unsaved-work","description":"Denies the check_unsaved_work command without any pre-configured scope.","commands":{"allow":[],"deny":["check_unsaved_work"]}},"deny-clear-claude-token":{"identifier":"deny-clear-claude-token","description":"Denies the clear_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_claude_token"]}},"deny-clear-gateway-api-key":{"identifier":"deny-clear-gateway-api-key","description":"Denies the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_gateway_api_key"]}},"deny-clear-scheduler-notifications":{"identifier":"deny-clear-scheduler-notifications","description":"Denies the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_scheduler_notifications"]}},"deny-close-browser-view-popout":{"identifier":"deny-close-browser-view-popout","description":"Denies the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["close_browser_view_popout"]}},"deny-close-container-page":{"identifier":"deny-close-container-page","description":"Denies the close_container_page command without any pre-configured scope.","commands":{"allow":[],"deny":["close_container_page"]}},"deny-close-terminal-session":{"identifier":"deny-close-terminal-session","description":"Denies the close_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["close_terminal_session"]}},"deny-confirm-migration":{"identifier":"deny-confirm-migration","description":"Denies the confirm_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm_migration"]}},"deny-create-container-directory":{"identifier":"deny-create-container-directory","description":"Denies the create_container_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["create_container_directory"]}},"deny-delete-note":{"identifier":"deny-delete-note","description":"Denies the delete_note command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_note"]}},"deny-deploy-aws-environment":{"identifier":"deny-deploy-aws-environment","description":"Denies the deploy_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["deploy_aws_environment"]}},"deny-deploy-aws-permissions-boundary":{"identifier":"deny-deploy-aws-permissions-boundary","description":"Denies the deploy_aws_permissions_boundary command without any pre-configured scope.","commands":{"allow":[],"deny":["deploy_aws_permissions_boundary"]}},"deny-destroy-incomplete-aws-environment":{"identifier":"deny-destroy-incomplete-aws-environment","description":"Denies the destroy_incomplete_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy_incomplete_aws_environment"]}},"deny-detect-aws-config":{"identifier":"deny-detect-aws-config","description":"Denies the detect_aws_config command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_aws_config"]}},"deny-detect-aws-environments":{"identifier":"deny-detect-aws-environments","description":"Denies the detect_aws_environments command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_aws_environments"]}},"deny-detect-host-timezone":{"identifier":"deny-detect-host-timezone","description":"Denies the detect_host_timezone command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_host_timezone"]}},"deny-detect-install-options":{"identifier":"deny-detect-install-options","description":"Denies the detect_install_options command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_install_options"]}},"deny-download-container-backup":{"identifier":"deny-download-container-backup","description":"Denies the download_container_backup command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_backup"]}},"deny-download-container-file":{"identifier":"deny-download-container-file","description":"Denies the download_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_file"]}},"deny-export-settings":{"identifier":"deny-export-settings","description":"Denies the export_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["export_settings"]}},"deny-follow-aws-deployment":{"identifier":"deny-follow-aws-deployment","description":"Denies the follow_aws_deployment command without any pre-configured scope.","commands":{"allow":[],"deny":["follow_aws_deployment"]}},"deny-forget-marketplace-installs":{"identifier":"deny-forget-marketplace-installs","description":"Denies the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":[],"deny":["forget_marketplace_installs"]}},"deny-get-app-version":{"identifier":"deny-get-app-version","description":"Denies the get_app_version command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_version"]}},"deny-get-auth-bridge-status":{"identifier":"deny-get-auth-bridge-status","description":"Denies the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_auth_bridge_status"]}},"deny-get-browser-view-match-window":{"identifier":"deny-get-browser-view-match-window","description":"Denies the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_match_window"]}},"deny-get-browser-view-popout-state":{"identifier":"deny-get-browser-view-popout-state","description":"Denies the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_popout_state"]}},"deny-get-browser-view-status":{"identifier":"deny-get-browser-view-status","description":"Denies the get_browser_view_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_status"]}},"deny-get-container-info":{"identifier":"deny-get-container-info","description":"Denies the get_container_info command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_info"]}},"deny-get-container-page-state":{"identifier":"deny-get-container-page-state","description":"Denies the get_container_page_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_page_state"]}},"deny-get-container-staleness":{"identifier":"deny-get-container-staleness","description":"Denies the get_container_staleness command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_staleness"]}},"deny-get-gateway-auth-token":{"identifier":"deny-get-gateway-auth-token","description":"Denies the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_auth_token"]}},"deny-get-gateway-status":{"identifier":"deny-get-gateway-status","description":"Denies the get_gateway_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_status"]}},"deny-get-help-content":{"identifier":"deny-get-help-content","description":"Denies the get_help_content command without any pre-configured scope.","commands":{"allow":[],"deny":["get_help_content"]}},"deny-get-marketplace-sync-report":{"identifier":"deny-get-marketplace-sync-report","description":"Denies the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":[],"deny":["get_marketplace_sync_report"]}},"deny-get-migration-state":{"identifier":"deny-get-migration-state","description":"Denies the get_migration_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_migration_state"]}},"deny-get-scheduled-task-log":{"identifier":"deny-get-scheduled-task-log","description":"Denies the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduled_task_log"]}},"deny-get-scheduler-notifications":{"identifier":"deny-get-scheduler-notifications","description":"Denies the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduler_notifications"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-get-stt-status":{"identifier":"deny-get-stt-status","description":"Denies the get_stt_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_stt_status"]}},"deny-get-web-terminal-status":{"identifier":"deny-get-web-terminal-status","description":"Denies the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_web_terminal_status"]}},"deny-has-claude-token":{"identifier":"deny-has-claude-token","description":"Denies the has_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["has_claude_token"]}},"deny-inspect-ca-cert-path":{"identifier":"deny-inspect-ca-cert-path","description":"Denies the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":[],"deny":["inspect_ca_cert_path"]}},"deny-install-browser-view-browser":{"identifier":"deny-install-browser-view-browser","description":"Denies the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_browser"]}},"deny-install-browser-view-support":{"identifier":"deny-install-browser-view-support","description":"Denies the install_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_support"]}},"deny-install-marketplace-item":{"identifier":"deny-install-marketplace-item","description":"Denies the install_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["install_marketplace_item"]}},"deny-list-aws-profiles":{"identifier":"deny-list-aws-profiles","description":"Denies the list_aws_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_aws_profiles"]}},"deny-list-claude-sessions":{"identifier":"deny-list-claude-sessions","description":"Denies the list_claude_sessions command without any pre-configured scope.","commands":{"allow":[],"deny":["list_claude_sessions"]}},"deny-list-container-capabilities":{"identifier":"deny-list-container-capabilities","description":"Denies the list_container_capabilities command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_capabilities"]}},"deny-list-container-files":{"identifier":"deny-list-container-files","description":"Denies the list_container_files command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_files"]}},"deny-list-marketplace-snapshots":{"identifier":"deny-list-marketplace-snapshots","description":"Denies the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_snapshots"]}},"deny-list-marketplace-updates":{"identifier":"deny-list-marketplace-updates","description":"Denies the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_updates"]}},"deny-list-notes":{"identifier":"deny-list-notes","description":"Denies the list_notes command without any pre-configured scope.","commands":{"allow":[],"deny":["list_notes"]}},"deny-list-projects":{"identifier":"deny-list-projects","description":"Denies the list_projects command without any pre-configured scope.","commands":{"allow":[],"deny":["list_projects"]}},"deny-list-scheduled-tasks":{"identifier":"deny-list-scheduled-tasks","description":"Denies the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":[],"deny":["list_scheduled_tasks"]}},"deny-marketplace-gh-host-available":{"identifier":"deny-marketplace-gh-host-available","description":"Denies the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_gh_host_available"]}},"deny-marketplace-item-diff":{"identifier":"deny-marketplace-item-diff","description":"Denies the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_item_diff"]}},"deny-migrate-project-to-base":{"identifier":"deny-migrate-project-to-base","description":"Denies the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":[],"deny":["migrate_project_to_base"]}},"deny-onboard-aws-user":{"identifier":"deny-onboard-aws-user","description":"Denies the onboard_aws_user command without any pre-configured scope.","commands":{"allow":[],"deny":["onboard_aws_user"]}},"deny-open-browser-view-popout":{"identifier":"deny-open-browser-view-popout","description":"Denies the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["open_browser_view_popout"]}},"deny-open-file-viewer":{"identifier":"deny-open-file-viewer","description":"Denies the open_file_viewer command without any pre-configured scope.","commands":{"allow":[],"deny":["open_file_viewer"]}},"deny-open-page-in-container-browser":{"identifier":"deny-open-page-in-container-browser","description":"Denies the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["open_page_in_container_browser"]}},"deny-open-terminal-session":{"identifier":"deny-open-terminal-session","description":"Denies the open_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["open_terminal_session"]}},"deny-open-url-external":{"identifier":"deny-open-url-external","description":"Denies the open_url_external command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url_external"]}},"deny-paste-image-to-terminal":{"identifier":"deny-paste-image-to-terminal","description":"Denies the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["paste_image_to_terminal"]}},"deny-preview-settings-import":{"identifier":"deny-preview-settings-import","description":"Denies the preview_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["preview_settings_import"]}},"deny-pull-gateway-image":{"identifier":"deny-pull-gateway-image","description":"Denies the pull_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_gateway_image"]}},"deny-pull-image":{"identifier":"deny-pull-image","description":"Denies the pull_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_image"]}},"deny-pull-stt-image":{"identifier":"deny-pull-stt-image","description":"Denies the pull_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_stt_image"]}},"deny-push-aws-saved-state":{"identifier":"deny-push-aws-saved-state","description":"Denies the push_aws_saved_state command without any pre-configured scope.","commands":{"allow":[],"deny":["push_aws_saved_state"]}},"deny-read-container-file":{"identifier":"deny-read-container-file","description":"Denies the read_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_container_file"]}},"deny-rebuild-project-container":{"identifier":"deny-rebuild-project-container","description":"Denies the rebuild_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["rebuild_project_container"]}},"deny-reconcile-project-statuses":{"identifier":"deny-reconcile-project-statuses","description":"Denies the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":[],"deny":["reconcile_project_statuses"]}},"deny-refresh-marketplaces":{"identifier":"deny-refresh-marketplaces","description":"Denies the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":[],"deny":["refresh_marketplaces"]}},"deny-regenerate-gateway-auth-token":{"identifier":"deny-regenerate-gateway-auth-token","description":"Denies the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_gateway_auth_token"]}},"deny-regenerate-web-terminal-token":{"identifier":"deny-regenerate-web-terminal-token","description":"Denies the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_web_terminal_token"]}},"deny-remove-aws-resources":{"identifier":"deny-remove-aws-resources","description":"Denies the remove_aws_resources command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_aws_resources"]}},"deny-remove-host-connection":{"identifier":"deny-remove-host-connection","description":"Denies the remove_host_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_host_connection"]}},"deny-remove-marketplace":{"identifier":"deny-remove-marketplace","description":"Denies the remove_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace"]}},"deny-remove-marketplace-account":{"identifier":"deny-remove-marketplace-account","description":"Denies the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace_account"]}},"deny-remove-project":{"identifier":"deny-remove-project","description":"Denies the remove_project command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_project"]}},"deny-remove-scheduled-task":{"identifier":"deny-remove-scheduled-task","description":"Denies the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_scheduled_task"]}},"deny-rename-container-path":{"identifier":"deny-rename-container-path","description":"Denies the rename_container_path command without any pre-configured scope.","commands":{"allow":[],"deny":["rename_container_path"]}},"deny-repair-aws-environment":{"identifier":"deny-repair-aws-environment","description":"Denies the repair_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["repair_aws_environment"]}},"deny-rollback-migration":{"identifier":"deny-rollback-migration","description":"Denies the rollback_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["rollback_migration"]}},"deny-run-docker-install":{"identifier":"deny-run-docker-install","description":"Denies the run_docker_install command without any pre-configured scope.","commands":{"allow":[],"deny":["run_docker_install"]}},"deny-run-scheduled-task-now":{"identifier":"deny-run-scheduled-task-now","description":"Denies the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":[],"deny":["run_scheduled_task_now"]}},"deny-save-aws-admin-bundle":{"identifier":"deny-save-aws-admin-bundle","description":"Denies the save_aws_admin_bundle command without any pre-configured scope.","commands":{"allow":[],"deny":["save_aws_admin_bundle"]}},"deny-save-note":{"identifier":"deny-save-note","description":"Denies the save_note command without any pre-configured scope.","commands":{"allow":[],"deny":["save_note"]}},"deny-send-audio-data":{"identifier":"deny-send-audio-data","description":"Denies the send_audio_data command without any pre-configured scope.","commands":{"allow":[],"deny":["send_audio_data"]}},"deny-set-auth-bridge-enabled":{"identifier":"deny-set-auth-bridge-enabled","description":"Denies the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_auth_bridge_enabled"]}},"deny-set-browser-view-enabled":{"identifier":"deny-set-browser-view-enabled","description":"Denies the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_enabled"]}},"deny-set-browser-view-match-window":{"identifier":"deny-set-browser-view-match-window","description":"Denies the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_match_window"]}},"deny-set-browser-view-popout-always-on-top":{"identifier":"deny-set-browser-view-popout-always-on-top","description":"Denies the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_popout_always_on_top"]}},"deny-set-container-page-viewport":{"identifier":"deny-set-container-page-viewport","description":"Denies the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":[],"deny":["set_container_page_viewport"]}},"deny-set-gateway-api-key":{"identifier":"deny-set-gateway-api-key","description":"Denies the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["set_gateway_api_key"]}},"deny-set-global-item-disabled":{"identifier":"deny-set-global-item-disabled","description":"Denies the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_global_item_disabled"]}},"deny-set-scheduled-task-enabled":{"identifier":"deny-set-scheduled-task-enabled","description":"Denies the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_scheduled_task_enabled"]}},"deny-start-audio-bridge":{"identifier":"deny-start-audio-bridge","description":"Denies the start_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["start_audio_bridge"]}},"deny-start-gateway":{"identifier":"deny-start-gateway","description":"Denies the start_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["start_gateway"]}},"deny-start-marketplace-gh-container-login":{"identifier":"deny-start-marketplace-gh-container-login","description":"Denies the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":[],"deny":["start_marketplace_gh_container_login"]}},"deny-start-project-container":{"identifier":"deny-start-project-container","description":"Denies the start_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["start_project_container"]}},"deny-start-stt":{"identifier":"deny-start-stt","description":"Denies the start_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["start_stt"]}},"deny-start-web-terminal":{"identifier":"deny-start-web-terminal","description":"Denies the start_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["start_web_terminal"]}},"deny-stop-audio-bridge":{"identifier":"deny-stop-audio-bridge","description":"Denies the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_audio_bridge"]}},"deny-stop-gateway":{"identifier":"deny-stop-gateway","description":"Denies the stop_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_gateway"]}},"deny-stop-project-container":{"identifier":"deny-stop-project-container","description":"Denies the stop_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_project_container"]}},"deny-stop-stt":{"identifier":"deny-stop-stt","description":"Denies the stop_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_stt"]}},"deny-stop-web-terminal":{"identifier":"deny-stop-web-terminal","description":"Denies the stop_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_web_terminal"]}},"deny-submit-claude-token-code":{"identifier":"deny-submit-claude-token-code","description":"Denies the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":[],"deny":["submit_claude_token_code"]}},"deny-sweep-claude-token-snapshots":{"identifier":"deny-sweep-claude-token-snapshots","description":"Denies the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["sweep_claude_token_snapshots"]}},"deny-terminal-input":{"identifier":"deny-terminal-input","description":"Denies the terminal_input command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_input"]}},"deny-terminal-resize":{"identifier":"deny-terminal-resize","description":"Denies the terminal_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_resize"]}},"deny-test-marketplace-account":{"identifier":"deny-test-marketplace-account","description":"Denies the test_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["test_marketplace_account"]}},"deny-transcribe-audio":{"identifier":"deny-transcribe-audio","description":"Denies the transcribe_audio command without any pre-configured scope.","commands":{"allow":[],"deny":["transcribe_audio"]}},"deny-uninstall-marketplace-item":{"identifier":"deny-uninstall-marketplace-item","description":"Denies the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["uninstall_marketplace_item"]}},"deny-unlock-aws-state":{"identifier":"deny-unlock-aws-state","description":"Denies the unlock_aws_state command without any pre-configured scope.","commands":{"allow":[],"deny":["unlock_aws_state"]}},"deny-update-aws-connection":{"identifier":"deny-update-aws-connection","description":"Denies the update_aws_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["update_aws_connection"]}},"deny-update-aws-environment":{"identifier":"deny-update-aws-environment","description":"Denies the update_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["update_aws_environment"]}},"deny-update-marketplace":{"identifier":"deny-update-marketplace","description":"Denies the update_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace"]}},"deny-update-marketplace-item":{"identifier":"deny-update-marketplace-item","description":"Denies the update_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace_item"]}},"deny-update-project":{"identifier":"deny-update-project","description":"Denies the update_project command without any pre-configured scope.","commands":{"allow":[],"deny":["update_project"]}},"deny-update-scheduled-task":{"identifier":"deny-update-scheduled-task","description":"Denies the update_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["update_scheduled_task"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-upload-files-to-container":{"identifier":"deny-upload-files-to-container","description":"Denies the upload_files_to_container command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_files_to_container"]}},"deny-upload-host-file-to-terminal":{"identifier":"deny-upload-host-file-to-terminal","description":"Denies the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_host_file_to_terminal"]}},"deny-viewer-choose-file":{"identifier":"deny-viewer-choose-file","description":"Denies the viewer_choose_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_choose_file"]}},"deny-viewer-get-state":{"identifier":"deny-viewer-get-state","description":"Denies the viewer_get_state command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_get_state"]}},"deny-viewer-poll-file":{"identifier":"deny-viewer-poll-file","description":"Denies the viewer_poll_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_poll_file"]}},"deny-viewer-read-file":{"identifier":"deny-viewer-read-file","description":"Denies the viewer_read_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_read_file"]}},"deny-viewer-write-file":{"identifier":"deny-viewer-write-file","description":"Denies the viewer_write_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_write_file"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}}} \ No newline at end of file diff --git a/app/src-tauri/gen/schemas/capabilities.json b/app/src-tauri/gen/schemas/capabilities.json index 951b16e..a9217c8 100644 --- a/app/src-tauri/gen/schemas/capabilities.json +++ b/app/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate field and Backup. Two commands still take a host path over IPC as a string — the terminal drop and `download_container_backup` — and for those `validate_host_path` is the boundary rather than defence in depth. This file is the reviewed threat model of record, so keep this census accurate: a stale reference here is worse than none. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). A second capability file, `file-viewer.json`, covers the `file-viewer-*` windows the terminal file viewer opens on `viewer.html`; it is the only other local-origin window, its grants are listed and justified there, and its one non-obvious grant (`core:window:allow-destroy`) exists because `onCloseRequested` cannot close a window without it. App commands are gated by this file too. `build.rs` declares a Tauri `AppManifest` listing every command in `generate_handler!`, which is what makes tauri 2.11.0 apply the ACL to app commands at all (`webview/mod.rs:1794` skips it when no app manifest exists), and the bare `allow-` entries below are the complete list of app commands the main window may call. `build.rs` refuses to build unless every registered command has exactly one such grant, in the file whose `windows` its name says it belongs to (`viewer_*` in `file-viewer.json`, everything else here), and unless every bare entry names a registered command — so a forgotten, misspelled, duplicated or misfiled grant is a failed `cargo check`, not a feature that dies at runtime with `not allowed by ACL`. `deny-*` is banned by the same check: in tauri 2.11.0 a deny matches regardless of window or origin, so a deny meant for the viewer would deny main too. Hand-written files under `permissions/` are refused for the same reason — they would be grants this census cannot see. Because the census can only vouch for what it reads, `build.rs` also refuses any capability it did not check: anything in `capabilities/` other than a top-level `*.json` file (tauri also loads `.toml`/`.json5` there, and subdirectories), a `webviews` or `remote` key in a capability file (either would extend grants beyond what `windows` says), `app.security.capabilities` declared inline in `tauri.conf.json`, any `tauri..conf.json`, or `TAURI_CONFIG`, and a tauri config in a format the census cannot parse (JSON5, TOML). OS/editor junk (`.DS_Store`, `Thumbs.db`, editor swap files) is recognised and skipped in `capabilities/` and `permissions/` rather than refused, since tauri never loads it either. Each failure names the check that failed (\"stray entry in capabilities/\", \"capabilities declared outside capabilities/\", \"hand-written permission\", …) rather than always reading as a grant/handler mismatch. Known gap: adding a new `tauri..conf.json` to a tree that has already been built once only takes effect on a clean build or in CI — cargo's incremental build has no reason to notice a file that did not exist on the previous build. The `*marketplace*` commands fetch user-configured https git repos on the host and push pinned files into containers; account tokens stay in the OS keychain and never cross IPC outward — the only inbound one is the token pasted into `add_marketplace_token_account`. The pop-out stays capability-less. The Rust label gates in `commands/file_viewer_commands.rs` remain, because the ACL says *which* window may call a command and the label says *whose* registry entry it acts on; they are not redundant. The rules live in `src/command_census.rs`, which is unit-tested, and `src/test/capabilities.test.ts` checks the other direction: that the code that runs in each window imports only the wrappers that window is granted. On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.","local":true,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:webview:allow-internal-toggle-devtools","dialog:allow-open","dialog:allow-save","allow-check-docker","allow-check-image-exists","allow-build-image","allow-get-container-info","allow-list-projects","allow-add-project","allow-remove-project","allow-update-project","allow-start-project-container","allow-stop-project-container","allow-rebuild-project-container","allow-reconcile-project-statuses","allow-list-notes","allow-save-note","allow-delete-note","allow-get-container-staleness","allow-migrate-project-to-base","allow-confirm-migration","allow-rollback-migration","allow-get-migration-state","allow-set-auth-bridge-enabled","allow-get-auth-bridge-status","allow-set-browser-view-enabled","allow-get-browser-view-status","allow-check-browser-view-support","allow-install-browser-view-support","allow-install-browser-view-browser","allow-open-browser-view-popout","allow-close-browser-view-popout","allow-get-browser-view-popout-state","allow-set-browser-view-popout-always-on-top","allow-open-page-in-container-browser","allow-set-container-page-viewport","allow-get-container-page-state","allow-close-container-page","allow-set-browser-view-match-window","allow-get-browser-view-match-window","allow-acquire-claude-token","allow-submit-claude-token-code","allow-cancel-claude-token","allow-has-claude-token","allow-clear-claude-token","allow-sweep-claude-token-snapshots","allow-get-settings","allow-update-settings","allow-pull-image","allow-detect-aws-config","allow-inspect-ca-cert-path","allow-list-aws-profiles","allow-add-aws-profile-connection","allow-add-aws-keys-connection","allow-update-aws-connection","allow-remove-host-connection","allow-aws-connection-identity","allow-aws-connection-sso-login","allow-check-aws-permissions","allow-aws-policy-document","allow-detect-aws-environments","allow-deploy-aws-environment","allow-update-aws-environment","allow-repair-aws-environment","allow-destroy-incomplete-aws-environment","allow-unlock-aws-state","allow-follow-aws-deployment","allow-cancel-aws-deployment","allow-push-aws-saved-state","allow-onboard-aws-user","allow-deploy-aws-permissions-boundary","allow-save-aws-admin-bundle","allow-detect-host-timezone","allow-export-settings","allow-preview-settings-import","allow-apply-settings-import","allow-open-terminal-session","allow-terminal-input","allow-terminal-resize","allow-close-terminal-session","allow-paste-image-to-terminal","allow-upload-host-file-to-terminal","allow-start-audio-bridge","allow-send-audio-data","allow-stop-audio-bridge","allow-list-container-files","allow-download-container-backup","allow-download-container-file","allow-upload-files-to-container","allow-read-container-file","allow-rename-container-path","allow-create-container-directory","allow-open-file-viewer","allow-aws-sso-refresh","allow-get-app-version","allow-check-for-updates","allow-check-image-update","allow-get-help-content","allow-open-url-external","allow-detect-install-options","allow-run-docker-install","allow-start-web-terminal","allow-stop-web-terminal","allow-get-web-terminal-status","allow-regenerate-web-terminal-token","allow-get-stt-status","allow-start-stt","allow-stop-stt","allow-build-stt-image","allow-pull-stt-image","allow-transcribe-audio","allow-get-gateway-status","allow-start-gateway","allow-stop-gateway","allow-check-gateway-health","allow-build-gateway-image","allow-pull-gateway-image","allow-set-gateway-api-key","allow-clear-gateway-api-key","allow-get-gateway-auth-token","allow-regenerate-gateway-auth-token","allow-list-claude-sessions","allow-list-container-capabilities","allow-list-scheduled-tasks","allow-add-scheduled-task","allow-update-scheduled-task","allow-get-scheduled-task-log","allow-set-scheduled-task-enabled","allow-run-scheduled-task-now","allow-remove-scheduled-task","allow-get-scheduler-notifications","allow-clear-scheduler-notifications","allow-list-marketplace-snapshots","allow-refresh-marketplaces","allow-add-marketplace","allow-update-marketplace","allow-remove-marketplace","allow-install-marketplace-item","allow-uninstall-marketplace-item","allow-set-global-item-disabled","allow-forget-marketplace-installs","allow-list-marketplace-updates","allow-marketplace-item-diff","allow-update-marketplace-item","allow-apply-marketplace-now","allow-get-marketplace-sync-report","allow-add-marketplace-token-account","allow-add-marketplace-gh-host-account","allow-start-marketplace-gh-container-login","allow-cancel-marketplace-gh-login","allow-test-marketplace-account","allow-remove-marketplace-account","allow-marketplace-gh-host-available"]},"file-viewer":{"identifier":"file-viewer","description":"The terminal file viewer windows (`file-viewer-`, opened by `open_file_viewer` on the app's own `viewer.html`). Same rules as `default.json`, including the layout checks: this file itself must stay a top-level `capabilities/*.json` with no `webviews` or `remote` key, or `build.rs` refuses the build rather than grant something the census cannot see. The five bare `allow-viewer-*` grants are the only app commands a viewer window can invoke: `build.rs` declares the AppManifest that makes tauri enforce that, and refuses any other bare grant in this file. The label gate inside `commands/file_viewer_commands.rs` is still what stops window A acting on window B's registry entry, because the ACL only decides which window may call. The rest of this file is the plugin-command surface a compromised viewer webview could reach, and it is the smallest one that lets the window work. `core:event:allow-listen`/`allow-unlisten` are for `file-viewer-goto` (Rust → this window; the viewer subscribes through `getCurrentWindow().listen`, because a bare `listen()` in *any* window receives an `emit_to`). `core:window:allow-destroy` is not optional: `getCurrentWindow().onCloseRequested` in @tauri-apps/api 2.11 makes Rust `prevent_close()` whenever a JS listener exists and then calls `destroy()` itself, so without this grant the window's X button does nothing once the unsaved-changes guard is installed. `allow-close` is deliberately absent — nothing calls it, and `destroy` is the only exit. No `set-title`/`set-focus`/`unminimize`: those are done from Rust when a second click targets an already-open file. `core:webview:allow-internal-toggle-devtools` is the same dev-only convenience `default.json` carries.","local":true,"windows":["file-viewer-*"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:window:allow-destroy","core:webview:allow-internal-toggle-devtools","allow-viewer-get-state","allow-viewer-choose-file","allow-viewer-read-file","allow-viewer-poll-file","allow-viewer-write-file"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate field and Backup. Two commands still take a host path over IPC as a string — the terminal drop and `download_container_backup` — and for those `validate_host_path` is the boundary rather than defence in depth. This file is the reviewed threat model of record, so keep this census accurate: a stale reference here is worse than none. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). A second capability file, `file-viewer.json`, covers the `file-viewer-*` windows the terminal file viewer opens on `viewer.html`; it is the only other local-origin window, its grants are listed and justified there, and its one non-obvious grant (`core:window:allow-destroy`) exists because `onCloseRequested` cannot close a window without it. App commands are gated by this file too. `build.rs` declares a Tauri `AppManifest` listing every command in `generate_handler!`, which is what makes tauri 2.11.0 apply the ACL to app commands at all (`webview/mod.rs:1794` skips it when no app manifest exists), and the bare `allow-` entries below are the complete list of app commands the main window may call. `build.rs` refuses to build unless every registered command has exactly one such grant, in the file whose `windows` its name says it belongs to (`viewer_*` in `file-viewer.json`, everything else here), and unless every bare entry names a registered command — so a forgotten, misspelled, duplicated or misfiled grant is a failed `cargo check`, not a feature that dies at runtime with `not allowed by ACL`. `deny-*` is banned by the same check: in tauri 2.11.0 a deny matches regardless of window or origin, so a deny meant for the viewer would deny main too. Hand-written files under `permissions/` are refused for the same reason — they would be grants this census cannot see. Because the census can only vouch for what it reads, `build.rs` also refuses any capability it did not check: anything in `capabilities/` other than a top-level `*.json` file (tauri also loads `.toml`/`.json5` there, and subdirectories), a `webviews` or `remote` key in a capability file (either would extend grants beyond what `windows` says), `app.security.capabilities` declared inline in `tauri.conf.json`, any `tauri..conf.json`, or `TAURI_CONFIG`, and a tauri config in a format the census cannot parse (JSON5, TOML). OS/editor junk (`.DS_Store`, `Thumbs.db`, editor swap files) is recognised and skipped in `capabilities/` and `permissions/` rather than refused, since tauri never loads it either. Each failure names the check that failed (\"stray entry in capabilities/\", \"capabilities declared outside capabilities/\", \"hand-written permission\", …) rather than always reading as a grant/handler mismatch. Known gap: adding a new `tauri..conf.json` to a tree that has already been built once only takes effect on a clean build or in CI — cargo's incremental build has no reason to notice a file that did not exist on the previous build. The `*marketplace*` commands fetch user-configured https git repos on the host and push pinned files into containers; account tokens stay in the OS keychain and never cross IPC outward — the only inbound one is the token pasted into `add_marketplace_token_account`. The pop-out stays capability-less. The Rust label gates in `commands/file_viewer_commands.rs` remain, because the ACL says *which* window may call a command and the label says *whose* registry entry it acts on; they are not redundant. The rules live in `src/command_census.rs`, which is unit-tested, and `src/test/capabilities.test.ts` checks the other direction: that the code that runs in each window imports only the wrappers that window is granted. On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.","local":true,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:webview:allow-internal-toggle-devtools","dialog:allow-open","dialog:allow-save","allow-check-docker","allow-check-image-exists","allow-build-image","allow-get-container-info","allow-list-projects","allow-add-project","allow-remove-project","allow-update-project","allow-start-project-container","allow-stop-project-container","allow-check-unsaved-work","allow-aws-task-log","allow-remove-aws-resources","allow-rebuild-project-container","allow-reconcile-project-statuses","allow-list-notes","allow-save-note","allow-delete-note","allow-get-container-staleness","allow-migrate-project-to-base","allow-confirm-migration","allow-rollback-migration","allow-get-migration-state","allow-set-auth-bridge-enabled","allow-get-auth-bridge-status","allow-set-browser-view-enabled","allow-get-browser-view-status","allow-check-browser-view-support","allow-install-browser-view-support","allow-install-browser-view-browser","allow-open-browser-view-popout","allow-close-browser-view-popout","allow-get-browser-view-popout-state","allow-set-browser-view-popout-always-on-top","allow-open-page-in-container-browser","allow-set-container-page-viewport","allow-get-container-page-state","allow-close-container-page","allow-set-browser-view-match-window","allow-get-browser-view-match-window","allow-acquire-claude-token","allow-submit-claude-token-code","allow-cancel-claude-token","allow-has-claude-token","allow-clear-claude-token","allow-sweep-claude-token-snapshots","allow-get-settings","allow-update-settings","allow-pull-image","allow-detect-aws-config","allow-inspect-ca-cert-path","allow-list-aws-profiles","allow-add-aws-profile-connection","allow-add-aws-keys-connection","allow-update-aws-connection","allow-remove-host-connection","allow-aws-connection-identity","allow-aws-connection-sso-login","allow-check-aws-permissions","allow-aws-policy-document","allow-detect-aws-environments","allow-deploy-aws-environment","allow-update-aws-environment","allow-repair-aws-environment","allow-destroy-incomplete-aws-environment","allow-unlock-aws-state","allow-follow-aws-deployment","allow-cancel-aws-deployment","allow-push-aws-saved-state","allow-onboard-aws-user","allow-deploy-aws-permissions-boundary","allow-save-aws-admin-bundle","allow-detect-host-timezone","allow-export-settings","allow-preview-settings-import","allow-apply-settings-import","allow-open-terminal-session","allow-terminal-input","allow-terminal-resize","allow-close-terminal-session","allow-paste-image-to-terminal","allow-upload-host-file-to-terminal","allow-start-audio-bridge","allow-send-audio-data","allow-stop-audio-bridge","allow-list-container-files","allow-download-container-backup","allow-download-container-file","allow-upload-files-to-container","allow-read-container-file","allow-rename-container-path","allow-create-container-directory","allow-open-file-viewer","allow-aws-sso-refresh","allow-get-app-version","allow-check-for-updates","allow-check-image-update","allow-get-help-content","allow-open-url-external","allow-detect-install-options","allow-run-docker-install","allow-start-web-terminal","allow-stop-web-terminal","allow-get-web-terminal-status","allow-regenerate-web-terminal-token","allow-get-stt-status","allow-start-stt","allow-stop-stt","allow-build-stt-image","allow-pull-stt-image","allow-transcribe-audio","allow-get-gateway-status","allow-start-gateway","allow-stop-gateway","allow-check-gateway-health","allow-build-gateway-image","allow-pull-gateway-image","allow-set-gateway-api-key","allow-clear-gateway-api-key","allow-get-gateway-auth-token","allow-regenerate-gateway-auth-token","allow-list-claude-sessions","allow-list-container-capabilities","allow-list-scheduled-tasks","allow-add-scheduled-task","allow-update-scheduled-task","allow-get-scheduled-task-log","allow-set-scheduled-task-enabled","allow-run-scheduled-task-now","allow-remove-scheduled-task","allow-get-scheduler-notifications","allow-clear-scheduler-notifications","allow-list-marketplace-snapshots","allow-refresh-marketplaces","allow-add-marketplace","allow-update-marketplace","allow-remove-marketplace","allow-install-marketplace-item","allow-uninstall-marketplace-item","allow-set-global-item-disabled","allow-forget-marketplace-installs","allow-list-marketplace-updates","allow-marketplace-item-diff","allow-update-marketplace-item","allow-apply-marketplace-now","allow-get-marketplace-sync-report","allow-add-marketplace-token-account","allow-add-marketplace-gh-host-account","allow-start-marketplace-gh-container-login","allow-cancel-marketplace-gh-login","allow-test-marketplace-account","allow-remove-marketplace-account","allow-marketplace-gh-host-available"]},"file-viewer":{"identifier":"file-viewer","description":"The terminal file viewer windows (`file-viewer-`, opened by `open_file_viewer` on the app's own `viewer.html`). Same rules as `default.json`, including the layout checks: this file itself must stay a top-level `capabilities/*.json` with no `webviews` or `remote` key, or `build.rs` refuses the build rather than grant something the census cannot see. The five bare `allow-viewer-*` grants are the only app commands a viewer window can invoke: `build.rs` declares the AppManifest that makes tauri enforce that, and refuses any other bare grant in this file. The label gate inside `commands/file_viewer_commands.rs` is still what stops window A acting on window B's registry entry, because the ACL only decides which window may call. The rest of this file is the plugin-command surface a compromised viewer webview could reach, and it is the smallest one that lets the window work. `core:event:allow-listen`/`allow-unlisten` are for `file-viewer-goto` (Rust → this window; the viewer subscribes through `getCurrentWindow().listen`, because a bare `listen()` in *any* window receives an `emit_to`). `core:window:allow-destroy` is not optional: `getCurrentWindow().onCloseRequested` in @tauri-apps/api 2.11 makes Rust `prevent_close()` whenever a JS listener exists and then calls `destroy()` itself, so without this grant the window's X button does nothing once the unsaved-changes guard is installed. `allow-close` is deliberately absent — nothing calls it, and `destroy` is the only exit. No `set-title`/`set-focus`/`unminimize`: those are done from Rust when a second click targets an already-open file. `core:webview:allow-internal-toggle-devtools` is the same dev-only convenience `default.json` carries.","local":true,"windows":["file-viewer-*"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:window:allow-destroy","core:webview:allow-internal-toggle-devtools","allow-viewer-get-state","allow-viewer-choose-file","allow-viewer-read-file","allow-viewer-poll-file","allow-viewer-write-file"]}} \ No newline at end of file diff --git a/app/src-tauri/gen/schemas/desktop-schema.json b/app/src-tauri/gen/schemas/desktop-schema.json index c957e8e..91d2ff6 100644 --- a/app/src-tauri/gen/schemas/desktop-schema.json +++ b/app/src-tauri/gen/schemas/desktop-schema.json @@ -428,6 +428,12 @@ "const": "allow-aws-sso-refresh", "markdownDescription": "Enables the aws_sso_refresh command without any pre-configured scope." }, + { + "description": "Enables the aws_task_log command without any pre-configured scope.", + "type": "string", + "const": "allow-aws-task-log", + "markdownDescription": "Enables the aws_task_log command without any pre-configured scope." + }, { "description": "Enables the build_gateway_image command without any pre-configured scope.", "type": "string", @@ -506,6 +512,12 @@ "const": "allow-check-image-update", "markdownDescription": "Enables the check_image_update command without any pre-configured scope." }, + { + "description": "Enables the check_unsaved_work command without any pre-configured scope.", + "type": "string", + "const": "allow-check-unsaved-work", + "markdownDescription": "Enables the check_unsaved_work command without any pre-configured scope." + }, { "description": "Enables the clear_claude_token command without any pre-configured scope.", "type": "string", @@ -950,6 +962,12 @@ "const": "allow-regenerate-web-terminal-token", "markdownDescription": "Enables the regenerate_web_terminal_token command without any pre-configured scope." }, + { + "description": "Enables the remove_aws_resources command without any pre-configured scope.", + "type": "string", + "const": "allow-remove-aws-resources", + "markdownDescription": "Enables the remove_aws_resources command without any pre-configured scope." + }, { "description": "Enables the remove_host_connection command without any pre-configured scope.", "type": "string", @@ -1358,6 +1376,12 @@ "const": "deny-aws-sso-refresh", "markdownDescription": "Denies the aws_sso_refresh command without any pre-configured scope." }, + { + "description": "Denies the aws_task_log command without any pre-configured scope.", + "type": "string", + "const": "deny-aws-task-log", + "markdownDescription": "Denies the aws_task_log command without any pre-configured scope." + }, { "description": "Denies the build_gateway_image command without any pre-configured scope.", "type": "string", @@ -1436,6 +1460,12 @@ "const": "deny-check-image-update", "markdownDescription": "Denies the check_image_update command without any pre-configured scope." }, + { + "description": "Denies the check_unsaved_work command without any pre-configured scope.", + "type": "string", + "const": "deny-check-unsaved-work", + "markdownDescription": "Denies the check_unsaved_work command without any pre-configured scope." + }, { "description": "Denies the clear_claude_token command without any pre-configured scope.", "type": "string", @@ -1880,6 +1910,12 @@ "const": "deny-regenerate-web-terminal-token", "markdownDescription": "Denies the regenerate_web_terminal_token command without any pre-configured scope." }, + { + "description": "Denies the remove_aws_resources command without any pre-configured scope.", + "type": "string", + "const": "deny-remove-aws-resources", + "markdownDescription": "Denies the remove_aws_resources command without any pre-configured scope." + }, { "description": "Denies the remove_host_connection command without any pre-configured scope.", "type": "string", diff --git a/app/src-tauri/gen/schemas/linux-schema.json b/app/src-tauri/gen/schemas/linux-schema.json index c957e8e..91d2ff6 100644 --- a/app/src-tauri/gen/schemas/linux-schema.json +++ b/app/src-tauri/gen/schemas/linux-schema.json @@ -428,6 +428,12 @@ "const": "allow-aws-sso-refresh", "markdownDescription": "Enables the aws_sso_refresh command without any pre-configured scope." }, + { + "description": "Enables the aws_task_log command without any pre-configured scope.", + "type": "string", + "const": "allow-aws-task-log", + "markdownDescription": "Enables the aws_task_log command without any pre-configured scope." + }, { "description": "Enables the build_gateway_image command without any pre-configured scope.", "type": "string", @@ -506,6 +512,12 @@ "const": "allow-check-image-update", "markdownDescription": "Enables the check_image_update command without any pre-configured scope." }, + { + "description": "Enables the check_unsaved_work command without any pre-configured scope.", + "type": "string", + "const": "allow-check-unsaved-work", + "markdownDescription": "Enables the check_unsaved_work command without any pre-configured scope." + }, { "description": "Enables the clear_claude_token command without any pre-configured scope.", "type": "string", @@ -950,6 +962,12 @@ "const": "allow-regenerate-web-terminal-token", "markdownDescription": "Enables the regenerate_web_terminal_token command without any pre-configured scope." }, + { + "description": "Enables the remove_aws_resources command without any pre-configured scope.", + "type": "string", + "const": "allow-remove-aws-resources", + "markdownDescription": "Enables the remove_aws_resources command without any pre-configured scope." + }, { "description": "Enables the remove_host_connection command without any pre-configured scope.", "type": "string", @@ -1358,6 +1376,12 @@ "const": "deny-aws-sso-refresh", "markdownDescription": "Denies the aws_sso_refresh command without any pre-configured scope." }, + { + "description": "Denies the aws_task_log command without any pre-configured scope.", + "type": "string", + "const": "deny-aws-task-log", + "markdownDescription": "Denies the aws_task_log command without any pre-configured scope." + }, { "description": "Denies the build_gateway_image command without any pre-configured scope.", "type": "string", @@ -1436,6 +1460,12 @@ "const": "deny-check-image-update", "markdownDescription": "Denies the check_image_update command without any pre-configured scope." }, + { + "description": "Denies the check_unsaved_work command without any pre-configured scope.", + "type": "string", + "const": "deny-check-unsaved-work", + "markdownDescription": "Denies the check_unsaved_work command without any pre-configured scope." + }, { "description": "Denies the clear_claude_token command without any pre-configured scope.", "type": "string", @@ -1880,6 +1910,12 @@ "const": "deny-regenerate-web-terminal-token", "markdownDescription": "Denies the regenerate_web_terminal_token command without any pre-configured scope." }, + { + "description": "Denies the remove_aws_resources command without any pre-configured scope.", + "type": "string", + "const": "deny-remove-aws-resources", + "markdownDescription": "Denies the remove_aws_resources command without any pre-configured scope." + }, { "description": "Denies the remove_host_connection command without any pre-configured scope.", "type": "string", diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index 44678b7..63c0763 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -15,6 +15,7 @@ pub mod migration_commands; pub mod notes_commands; pub mod project_commands; pub mod settings_commands; +pub mod stop_guard; pub mod settings_export_commands; pub mod stt_commands; pub mod terminal_commands; diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index becc435..0a79fe0 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -4,7 +4,10 @@ use crate::commands::aws_commands; use crate::docker; use crate::host::lifecycle::{DiscardLeftovers, ProjectHost}; use crate::host::ContainerTarget; -use crate::models::{Backend, BedrockAuthMethod, Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ProjectStatus}; +use crate::models::{ + AwsPlacement, AwsWorkspaceConfig, Backend, BedrockAuthMethod, Project, ProjectPath, + ProjectRemovalReport, ProjectResetOutcome, ProjectStatus, UnsavedWork, WipChoice, +}; use crate::storage::secure; use crate::AppState; @@ -633,9 +636,47 @@ pub async fn list_projects(state: State<'_, AppState>) -> Result, S pub async fn add_project( name: String, paths: Vec, + host_id: Option, + aws_placement: Option, + aws: Option, state: State<'_, AppState>, ) -> Result { - // Validate paths + let project = new_project(name, paths, host_id, aws_placement, aws, &state.settings_store.get().hosts)?; + // Nothing can have been blanked on a project that did not exist a line ago. + store_secrets_for_project(&project, &[])?; + state.projects_store.add(project) +} + +/// The project `add_project` creates. A local project needs a folder; an +/// AWS project (all three host fields) clones repositories instead and +/// takes no folders. The host fields are checked as a save would check them. +fn new_project( + name: String, + paths: Vec, + host_id: Option, + aws_placement: Option, + aws: Option, + hosts: &[crate::models::HostConnection], +) -> Result { + let on_host = host_id.is_some() || aws_placement.is_some() || aws.is_some(); + if on_host { + if paths.iter().any(|p| !p.host_path.is_empty() || !p.mount_name.is_empty()) { + return Err("A project on AWS clones repositories; it can't mount folders from this machine.".into()); + } + let blank = Project::new(name.clone(), Vec::new()); + let mut project = Project::new(name, Vec::new()); + project.host_id = host_id; + project.aws_placement = aws_placement; + // Runtime handles belong to the start path, never to the caller. + project.aws = aws.map(|a| AwsWorkspaceConfig { + task_arn: None, + access_point_id: None, + task_definition_arn: None, + ..a + }); + validate_aws_update(&blank, &project, hosts)?; + return Ok(project); + } // A new project needs a folder; the blank row `validate_project_paths` // tolerates is the Config tab's placeholder on an *existing* one. if paths.is_empty() @@ -646,10 +687,7 @@ pub async fn add_project( return Err("At least one folder path is required.".to_string()); } validate_project_paths(&paths)?; - let project = Project::new(name, paths); - // Nothing can have been blanked on a project that did not exist a line ago. - store_secrets_for_project(&project, &[])?; - state.projects_store.add(project) + Ok(Project::new(name, paths)) } #[tauri::command] @@ -1453,6 +1491,7 @@ async fn start_project_container_locked( #[tauri::command] pub async fn stop_project_container( project_id: String, + wip: Option, app_handle: tauri::AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { @@ -1475,6 +1514,19 @@ pub async fn stop_project_container( state.auth_bridge.stop(&project_id).await; if project.is_aws() { + // The stop guard's choice: a push that fails aborts the stop, and + // the project keeps its status (the push output says why). + if wip == Some(WipChoice::Push) { + emit_progress(&app_handle, &project_id, "Pushing unsaved work..."); + let target = ContainerTarget::for_project(&project); + if let Err(e) = + super::stop_guard::before_stop(target.as_ref(), wip, super::stop_guard::PUSH_TIMEOUT).await + { + log::warn!("Project {}: WIP push before stop failed: {}", project_id, e); + let _ = state.projects_store.update_status(&project_id, project.status.clone()); + return Err(e); + } + } // A failed StopTask leaves a billed task running, so unlike Docker it // is not papered over: the project keeps its status and the user // sees why (expired SSO, typically), and can retry. @@ -1511,6 +1563,80 @@ pub async fn stop_project_container( Ok(()) } +/// The stop guard's question: what would stopping this AWS workspace lose? +/// A local project (or a stopped one) has nothing to check. +#[tauri::command] +pub async fn check_unsaved_work( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let project = state + .projects_store + .get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + if !project.is_aws() || project.status != ProjectStatus::Running { + return Ok(UnsavedWork { checked: true, ..Default::default() }); + } + let target = ContainerTarget::for_project(&project); + Ok(super::stop_guard::unsaved_work(target.as_ref(), super::stop_guard::DRY_RUN_TIMEOUT).await) +} + +/// The last `limit` (1–200) lines of the AWS project's current or last task's +/// log. Empty when there is no task to read (or the project is local). +#[tauri::command] +pub async fn aws_task_log( + project_id: String, + limit: u32, + state: State<'_, AppState>, +) -> Result, String> { + let project = state + .projects_store + .get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + if !project.is_aws() { + return Ok(Vec::new()); + } + let settings = state.settings_store.get(); + crate::host::aws_host::task_log(&project, &settings, limit.clamp(1, 200) as i32).await +} + +/// Remove an AWS project's task definitions, secret and (with +/// `discard_data`) saved files, keeping the project, so it can move to +/// another host. Stopped projects only. +#[tauri::command] +pub async fn remove_aws_resources( + project_id: String, + discard_data: bool, + state: State<'_, AppState>, +) -> Result { + let _guard = + crate::project_lock::try_acquire(&project_id, crate::project_lock::ProjectOp::Destroy)?; + let project = state + .projects_store + .get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + check_aws_resources_removable(&project)?; + let settings = state.settings_store.get(); + ProjectHost::for_project(&project) + .remove_aws_resources(&project, &settings, discard_data) + .await?; + state.projects_store.set_aws_runtime(&project_id, None, None, None)?; + state + .projects_store + .get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id)) +} + +fn check_aws_resources_removable(project: &Project) -> Result<(), String> { + if !project.is_aws() { + return Err("This project does not run on AWS".into()); + } + if project.status != ProjectStatus::Stopped { + return Err("Stop the project before removing its AWS resources".into()); + } + Ok(()) +} + #[tauri::command] pub async fn rebuild_project_container( project_id: String, @@ -2488,4 +2614,44 @@ mod tests { assert!(e.contains("still be running") && e.contains("Settings → Hosts"), "{e}"); assert!(missing_connection_removal(true).is_ok()); } + + #[test] + fn a_new_aws_project_takes_repos_not_folders_and_no_runtime_handles() { + let hosts = vec![host("h1")]; + let place = || Some(AwsPlacement { region: "us-west-2".into(), environment: "dev".into() }); + let cfg = AwsWorkspaceConfig { + task_arn: Some("arn:forged".into()), + access_point_id: Some("fsap-forged".into()), + repos: vec![crate::models::RepoSource { url: "https://h/r.git".into(), branch: None, folder: "r".into() }], + ..Default::default() + }; + let p = new_project("a".into(), vec![], Some("h1".into()), place(), Some(cfg.clone()), &hosts).unwrap(); + assert!(p.is_aws() && p.paths.is_empty()); + let a = p.aws.unwrap(); + assert_eq!((a.task_arn, a.access_point_id, a.task_definition_arn), (None, None, None)); + assert_eq!(a.repos.len(), 1); + // folders are refused, so are half placements, unknown hosts and invalid configs + let folder = vec![ProjectPath { host_path: "/x".into(), mount_name: "x".into() }]; + assert!(new_project("a".into(), folder, Some("h1".into()), place(), Some(cfg.clone()), &hosts).is_err()); + assert!(new_project("a".into(), vec![], Some("h1".into()), None, Some(cfg.clone()), &hosts).is_err()); + assert!(new_project("a".into(), vec![], Some("nope".into()), place(), Some(cfg.clone()), &hosts).is_err()); + let bad = AwsWorkspaceConfig { cpu: 256, memory_mib: 8192, ..Default::default() }; + let e = new_project("a".into(), vec![], Some("h1".into()), place(), Some(bad), &hosts).unwrap_err(); + assert!(e.contains("not valid"), "{e}"); + // a local project still needs a folder + assert!(new_project("a".into(), vec![], None, None, None, &hosts).is_err()); + } + + #[test] + fn aws_resources_are_removable_only_from_a_stopped_aws_project() { + let mut p = aws_project(); + assert!(check_aws_resources_removable(&p).is_ok()); + p.status = ProjectStatus::Running; + assert!(check_aws_resources_removable(&p).unwrap_err().contains("Stop")); + p.status = ProjectStatus::Error; + assert!(check_aws_resources_removable(&p).is_err()); + let mut local = aws_project(); + local.host_id = None; + assert!(check_aws_resources_removable(&local).is_err()); + } } diff --git a/app/src-tauri/src/commands/stop_guard.rs b/app/src-tauri/src/commands/stop_guard.rs new file mode 100644 index 0000000..14725e6 --- /dev/null +++ b/app/src-tauri/src/commands/stop_guard.rs @@ -0,0 +1,249 @@ +//! The AWS stop guard: what `triple-c-wip-push` says about a workspace's +//! unsaved git work, and the push a Stop may run first. +//! +//! The decisions are made over [`WipRunner`] so a fake target exercises them; +//! [`ContainerTarget`] runs the script through the agent as `claude` (the +//! script needs the user's credential helper and `~/.ssh`). + +use std::future::Future; +use std::time::Duration; + +use crate::host::ContainerTarget; +use crate::models::{RepoState, UnsavedWork, WipChoice}; + +/// The script, baked into the image (`container/triple-c-wip-push`). +pub const WIP_PUSH: &str = "/usr/local/bin/triple-c-wip-push"; +/// A dry run only reads `git status`; it should be quick. +pub const DRY_RUN_TIMEOUT: Duration = Duration::from_secs(30); +/// A push goes over the network, once per dirty repo. +pub const PUSH_TIMEOUT: Duration = Duration::from_secs(300); +/// Cap on the push output carried into an error. +const MAX_OUTPUT: usize = 4000; + +/// Runs the WIP script in the workspace: `(combined output, exit code)`. +pub trait WipRunner: Sync { + fn run(&self, args: Vec) -> impl Future> + Send; +} + +impl WipRunner for ContainerTarget { + async fn run(&self, args: Vec) -> Result<(String, i64), String> { + let mut cmd = vec![WIP_PUSH.to_string()]; + cmd.extend(args); + self.exec_oneshot_as("claude", cmd, Vec::new()).await + } +} + +/// The repository lines of a dry run. Lines that are not JSON objects (a +/// warning on stderr) are skipped; a JSON line of the wrong shape is an error. +pub fn parse_dry_run(output: &str) -> Result, String> { + output + .lines() + .map(str::trim) + .filter(|l| l.starts_with('{')) + .map(|l| { + serde_json::from_str::(l) + .map_err(|e| format!("Unexpected output from the WIP check: {e}")) + }) + .collect() +} + +/// Whether anything in `work` would be lost by a stop (or could not be checked). +/// The frontend applies the same rule (`stopGuardNeeded`). +#[cfg(test)] +pub fn needs_guard(work: &UnsavedWork) -> bool { + !work.checked || work.repos.iter().any(|r| r.dirty || r.unpushed > 0) +} + +fn unchecked(reason: String) -> UnsavedWork { + UnsavedWork { checked: false, repos: Vec::new(), error: Some(reason) } +} + +/// Ask the workspace what a stop would lose. `runner` is `None` when the +/// workspace's agent is not reachable (no tunnel). +pub async fn unsaved_work(runner: Option<&R>, timeout: Duration) -> UnsavedWork { + let Some(runner) = runner else { + return unchecked( + "Triple-C is not connected to the workspace, so it can't check for unsaved work." + .into(), + ); + }; + let res = tokio::time::timeout(timeout, runner.run(vec!["--dry-run".into()])).await; + match res { + Err(_) => unchecked("The workspace did not answer the check for unsaved work in time.".into()), + Ok(Err(e)) => unchecked(format!("The workspace could not be asked about unsaved work: {e}")), + Ok(Ok((out, code))) if code != 0 => unchecked(format!( + "The check for unsaved work failed (exit {code}): {}", + cap(&out) + )), + Ok(Ok((out, _))) => match parse_dry_run(&out) { + Ok(repos) => UnsavedWork { checked: true, repos, error: None }, + Err(e) => unchecked(e), + }, + } +} + +fn cap(s: &str) -> String { + let t = s.trim(); + if t.len() <= MAX_OUTPUT { + return t.to_string(); + } + let mut end = t.len() - MAX_OUTPUT; + while !t.is_char_boundary(end) { + end += 1; + } + format!("…{}", &t[end..]) +} + +/// What Stop does before stopping, given the user's choice. `Push` runs the +/// push and fails (aborting the stop) with its output when it does not +/// succeed; `Discard` and no choice do nothing. +pub async fn before_stop( + runner: Option<&R>, + choice: Option, + timeout: Duration, +) -> Result<(), String> { + if choice != Some(WipChoice::Push) { + return Ok(()); + } + let runner = runner.ok_or_else(|| { + "Triple-C is not connected to the workspace, so it can't push unsaved work. The \ + workspace was not stopped." + .to_string() + })?; + let res = tokio::time::timeout(timeout, runner.run(vec!["--reason".into(), "stop".into()])).await; + match res { + Err(_) => Err("Pushing unsaved work did not finish in time. The workspace was not stopped.".into()), + Ok(Err(e)) => Err(format!("Could not push unsaved work: {e}. The workspace was not stopped.")), + Ok(Ok((out, 0))) => { + log::info!("WIP push before stop: {}", cap(&out)); + Ok(()) + } + Ok(Ok((out, code))) => Err(format!( + "Pushing unsaved work failed (exit {code}), so the workspace was not stopped:\n{}", + cap(&out) + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + struct Fake { + reply: Result<(String, i64), String>, + calls: Mutex>>, + hang: bool, + } + + impl Fake { + fn new(reply: Result<(String, i64), String>) -> Self { + Fake { reply, calls: Mutex::new(Vec::new()), hang: false } + } + fn calls(&self) -> Vec> { + self.calls.lock().unwrap().clone() + } + } + + impl WipRunner for Fake { + async fn run(&self, args: Vec) -> Result<(String, i64), String> { + self.calls.lock().unwrap().push(args); + if self.hang { + std::future::pending::<()>().await; + } + self.reply.clone() + } + } + + const T: Duration = Duration::from_secs(5); + + #[test] + fn a_dry_run_is_parsed_skipping_non_json_lines() { + let out = "warning: something\n{\"folder\":\"api\",\"dirty\":true,\"unpushed\":0,\"branch\":\"main\"}\n\ + {\"folder\":\"web\",\"dirty\":false,\"unpushed\":2,\"branch\":\"HEAD\"}\n"; + let repos = parse_dry_run(out).unwrap(); + assert_eq!(repos.len(), 2); + assert_eq!(repos[0], RepoState { folder: "api".into(), dirty: true, unpushed: 0, branch: "main".into() }); + assert_eq!(repos[1].unpushed, 2); + assert!(parse_dry_run("{\"folder\":1}").is_err()); + assert!(parse_dry_run("").unwrap().is_empty()); + } + + #[tokio::test] + async fn the_check_runs_a_dry_run_and_reports_the_repos() { + let fake = Fake::new(Ok(("{\"folder\":\"a\",\"dirty\":false,\"unpushed\":0,\"branch\":\"main\"}".into(), 0))); + let w = unsaved_work(Some(&fake), T).await; + assert!(w.checked && w.error.is_none()); + assert_eq!(w.repos.len(), 1); + assert!(!needs_guard(&w)); + assert_eq!(fake.calls(), vec![vec!["--dry-run".to_string()]]); + } + + #[tokio::test] + async fn an_unreachable_workspace_is_unchecked_with_a_reason() { + let w = unsaved_work::(None, T).await; + assert!(!w.checked); + assert!(w.error.as_deref().unwrap().contains("not connected")); + assert!(needs_guard(&w)); + + let failing = Fake::new(Err("connection refused".into())); + let w = unsaved_work(Some(&failing), T).await; + assert!(!w.checked && w.error.unwrap().contains("connection refused")); + + let nonzero = Fake::new(Ok(("boom".into(), 2))); + let w = unsaved_work(Some(&nonzero), T).await; + assert!(!w.checked && w.error.unwrap().contains("exit 2")); + } + + #[tokio::test(start_paused = true)] + async fn a_check_that_hangs_times_out_unchecked() { + let mut fake = Fake::new(Ok((String::new(), 0))); + fake.hang = true; + let w = unsaved_work(Some(&fake), Duration::from_secs(1)).await; + assert!(!w.checked && w.error.unwrap().contains("in time")); + } + + #[test] + fn dirty_or_unpushed_work_needs_the_guard() { + let repo = |dirty, unpushed| RepoState { folder: "x".into(), dirty, unpushed, branch: "b".into() }; + let w = |r| UnsavedWork { checked: true, repos: vec![r], error: None }; + assert!(needs_guard(&w(repo(true, 0)))); + assert!(needs_guard(&w(repo(false, 1)))); + assert!(!needs_guard(&w(repo(false, 0)))); + } + + #[tokio::test] + async fn push_runs_the_script_with_reason_stop() { + let fake = Fake::new(Ok(("pushed".into(), 0))); + before_stop(Some(&fake), Some(WipChoice::Push), T).await.unwrap(); + assert_eq!(fake.calls(), vec![vec!["--reason".to_string(), "stop".to_string()]]); + } + + #[tokio::test] + async fn a_failed_push_aborts_with_its_output() { + let fake = Fake::new(Ok(("triple-c-wip-push: api: push to origin failed: denied".into(), 1))); + let e = before_stop(Some(&fake), Some(WipChoice::Push), T).await.unwrap_err(); + assert!(e.contains("push to origin failed: denied"), "{e}"); + assert!(e.contains("not stopped")); + + let gone = Fake::new(Err("tunnel closed".into())); + assert!(before_stop(Some(&gone), Some(WipChoice::Push), T).await.unwrap_err().contains("tunnel closed")); + assert!(before_stop::(None, Some(WipChoice::Push), T).await.is_err()); + } + + #[tokio::test] + async fn discard_or_no_choice_runs_nothing() { + let fake = Fake::new(Err("must not run".into())); + before_stop(Some(&fake), Some(WipChoice::Discard), T).await.unwrap(); + before_stop(Some(&fake), None, T).await.unwrap(); + before_stop::(None, None, T).await.unwrap(); + assert!(fake.calls().is_empty()); + } + + #[test] + fn long_output_keeps_its_end() { + let s = format!("{}END", "x".repeat(10_000)); + let c = cap(&s); + assert!(c.ends_with("END") && c.len() < 4100); + } +} diff --git a/app/src-tauri/src/host/aws_host.rs b/app/src-tauri/src/host/aws_host.rs index a170cb6..7cca535 100644 --- a/app/src-tauri/src/host/aws_host.rs +++ b/app/src-tauri/src/host/aws_host.rs @@ -637,6 +637,9 @@ pub fn record_task(store: &ProjectsStore, project_id: &str, rec: Option<&TaskRec let Some(stored) = store.get(project_id).and_then(|p| p.aws) else { return; }; + if let Some(r) = rec { + remember_task(project_id, &r.task_arn); + } let res = match rec { Some(r) => store.set_aws_runtime( project_id, @@ -656,6 +659,71 @@ pub fn record_task(store: &ProjectsStore, project_id: &str, rec: Option<&TaskRec } } +// ──────────────────────────────────────────────────────────────── logs + +/// The last task each project's starts ran or found, kept for this run of +/// the app: a start that failed and stopped its own task clears the stored +/// ARN, and its log is what the user then wants to read. +static LAST_TASK: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +fn last_tasks() -> std::sync::MutexGuard<'static, std::collections::HashMap> { + LAST_TASK + .get_or_init(Default::default) + .lock() + .unwrap_or_else(|e| e.into_inner()) +} + +pub fn remember_task(project_id: &str, task_arn: &str) { + last_tasks().insert(project_id.to_string(), task_arn.to_string()); +} + +pub fn last_task_arn(project_id: &str) -> Option { + last_tasks().get(project_id).cloned() +} + +/// The task whose log `aws_task_log` reads: the stored (current) one, else +/// the tunnel's, else the last one a start of this run ran or found. +pub fn log_task_arn(project: &Project) -> Option { + project + .aws + .as_ref() + .and_then(|a| a.task_arn.clone()) + .or_else(|| tunnels::runtime(&project.id).map(|r| r.0)) + .or_else(|| last_task_arn(&project.id)) +} + +/// The last `limit` lines of `task_arn`'s log stream, in the environment +/// record's log group. +pub async fn task_log_lines( + api: &T, + rt: &R, + project: &Project, + who: &CallerIdentity, + region: &str, + task_arn: &str, + limit: i32, +) -> Result, String> { + let placement = project.aws_placement.as_ref().ok_or("The project has no AWS placement")?; + let env = read_environment(rt, &placement.environment, &who.account, region) + .await + .map_err(s)?; + let prefix = log_stream_prefix(&owner_key(region, &who.user_id), &project.id); + api.log_tail(&env.log_group, &task_log_stream(&prefix, task_arn), limit) + .await + .map_err(s) +} + +/// `aws_task_log`: the current or last task's log; empty with no task. +pub async fn task_log(project: &Project, settings: &AppSettings, limit: i32) -> Result, String> { + let Some(arn) = log_task_arn(project) else { + return Ok(Vec::new()); + }; + let (_, region, sdk) = project_sdk(project, settings).await?; + let who = caller_identity(&sdk).await.map_err(s)?; + task_log_lines(&SdkTaskApi::new(&sdk), &SdkRuntimeApi::new(&sdk), project, &who, ®ion, &arn, limit).await +} + // ──────────────────────────────────────────────────────────────── stop /// StopTask the task in `task_arn`'s cluster. diff --git a/app/src-tauri/src/host/aws_host/tests.rs b/app/src-tauri/src/host/aws_host/tests.rs index cce50b1..154d8de 100644 --- a/app/src-tauri/src/host/aws_host/tests.rs +++ b/app/src-tauri/src/host/aws_host/tests.rs @@ -95,6 +95,8 @@ struct Fake { stop_fails: bool, /// What `on_task` was told, in order. recorded: Mutex>>, + /// `group stream limit` of each log read. + log_reads: Mutex>, } impl Fake { @@ -136,7 +138,8 @@ impl TaskApi for Fake { } Ok(()) } - async fn log_tail(&self, _g: &str, _s: &str, _l: i32) -> Result, AwsError> { + async fn log_tail(&self, g: &str, st: &str, l: i32) -> Result, AwsError> { + self.log_reads.lock().unwrap().push(format!("{g} {st} {l}")); Ok(self.logs.clone()) } } @@ -698,3 +701,33 @@ async fn a_task_that_could_not_be_stopped_after_a_failed_start_stays_recorded() assert!(start(&fake, &p).await.0.is_err()); assert_eq!(*fake.recorded.lock().unwrap(), vec![Some(task_arn("new"))]); } + +#[tokio::test] +async fn the_task_log_reads_the_tasks_stream_in_the_records_log_group() { + let p = aws_project(StorageMode::Hybrid); + let fake = Fake { logs: vec!["line 1".into(), "line 2".into()], ..Default::default() }; + let lines = task_log_lines(&fake, &fake, &p, &who(), REGION, &task_arn("abc"), 20).await.unwrap(); + assert_eq!(lines, vec!["line 1", "line 2"]); + let prefix = log_stream_prefix(&owner_key(REGION, USER), &p.id); + assert_eq!( + *fake.log_reads.lock().unwrap(), + vec![format!("/triple-c/dev {prefix}/workspace/abc 20")] + ); +} + +#[test] +fn the_log_task_is_the_stored_one_else_the_last_one_a_start_saw() { + let mut p = aws_project(StorageMode::Hybrid); + p.id = "log-task-test-project".into(); + assert_eq!(log_task_arn(&p), None); + let (_d, store) = store_with(&p); + record_task( + &store, + &p.id, + Some(&TaskRecord { task_arn: "arn:first".into(), access_point_id: None, task_definition_arn: None }), + ); + record_task(&store, &p.id, None); + assert_eq!(log_task_arn(&p).as_deref(), Some("arn:first"), "a cleared ARN still has a log"); + p.aws.as_mut().unwrap().task_arn = Some("arn:current".into()); + assert_eq!(log_task_arn(&p).as_deref(), Some("arn:current")); +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index d199661..4ee3d56 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -551,6 +551,9 @@ pub fn run() { commands::project_commands::update_project, commands::project_commands::start_project_container, commands::project_commands::stop_project_container, + commands::project_commands::check_unsaved_work, + commands::project_commands::aws_task_log, + commands::project_commands::remove_aws_resources, commands::project_commands::rebuild_project_container, commands::project_commands::reconcile_project_statuses, // Notes diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index a9a6d34..0891f3e 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -681,6 +681,35 @@ pub fn validate_aws_workspace(cfg: &AwsWorkspaceConfig) -> Result<(), String> { Ok(()) } +/// What Stop does with an AWS workspace's unsaved git work (the stop guard). +/// A local project ignores it. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WipChoice { + /// Run `triple-c-wip-push --reason stop` first; a failure aborts the stop. + Push, + /// Stop without pushing from the app. + Discard, +} + +/// One repository line of `triple-c-wip-push --dry-run`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RepoState { + pub folder: String, + pub dirty: bool, + pub unpushed: u64, + pub branch: String, +} + +/// What `check_unsaved_work` found. `checked: false` (with `error`) when the +/// workspace could not be asked — the stop guard then cannot offer a push. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct UnsavedWork { + pub checked: bool, + pub repos: Vec, + pub error: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum ProjectStatus { -- 2.52.0 From eb7c6c7b21c65fd04f5484a89eca348c9424841e Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 10:15:14 -0800 Subject: [PATCH 16/43] feat(aws): AWS project settings and the stop guard Add Project picks Local or an AWS connection, a region and a ready, joined environment, with repositories instead of folders. The Config tab's Host section edits CPU/memory (valid pairs only), architecture, storage, ephemeral size, Spot, idle stop, WIP push and repositories, and can remove the AWS resources or move the project back to this machine. Docker access, VPN, port mappings and the gateway backend are disabled on AWS with the reason; the sandbox shows Off on AWS for now. Stop on an AWS workspace with unsaved work asks first (Push to WIP branch preselected); a failed start shows the task's last log lines; removing an AWS project can delete its saved files, and asks again when its connection is gone. Co-Authored-By: Claude Opus 5.5 --- .../projects/AddProjectDialog.test.tsx | 99 +++++ .../components/projects/AddProjectDialog.tsx | 309 ++++++++++--- .../projects/ConfirmRemoveModal.test.tsx | 37 ++ .../projects/ConfirmRemoveModal.tsx | 119 ++++- app/src/components/projects/ProjectRow.tsx | 21 +- .../components/projects/RepoListEditor.tsx | 133 ++++++ .../projects/StopGuardModal.test.tsx | 51 +++ .../components/projects/StopGuardModal.tsx | 102 +++++ .../components/projects/home/ConfigTab.tsx | 5 + .../components/projects/home/ProjectHome.tsx | 97 ++-- .../projects/home/config/AccessSection.tsx | 27 +- .../projects/home/config/AwsConfig.test.tsx | 132 ++++++ .../projects/home/config/HostSection.tsx | 419 ++++++++++++++++++ .../projects/home/config/ModelSection.tsx | 24 +- .../projects/home/config/RuntimeSection.tsx | 28 +- .../projects/home/config/WorkspaceSection.tsx | 159 ++++--- app/src/components/ui/Button.tsx | 4 +- app/src/hooks/useProjectActions.test.ts | 122 +++++ app/src/hooks/useProjectActions.ts | 76 +++- app/src/hooks/useProjects.ts | 14 +- app/src/lib/awsWorkspace.test.ts | 53 +++ app/src/lib/awsWorkspace.ts | 125 ++++++ app/src/lib/tauri-commands.ts | 31 +- app/src/lib/types.ts | 34 ++ app/src/test/awsFixtures.ts | 76 ++++ 25 files changed, 2065 insertions(+), 232 deletions(-) create mode 100644 app/src/components/projects/ConfirmRemoveModal.test.tsx create mode 100644 app/src/components/projects/RepoListEditor.tsx create mode 100644 app/src/components/projects/StopGuardModal.test.tsx create mode 100644 app/src/components/projects/StopGuardModal.tsx create mode 100644 app/src/components/projects/home/config/AwsConfig.test.tsx create mode 100644 app/src/components/projects/home/config/HostSection.tsx create mode 100644 app/src/hooks/useProjectActions.test.ts create mode 100644 app/src/lib/awsWorkspace.test.ts create mode 100644 app/src/lib/awsWorkspace.ts create mode 100644 app/src/test/awsFixtures.ts diff --git a/app/src/components/projects/AddProjectDialog.test.tsx b/app/src/components/projects/AddProjectDialog.test.tsx index 4179f43..b9ba61a 100644 --- a/app/src/components/projects/AddProjectDialog.test.tsx +++ b/app/src/components/projects/AddProjectDialog.test.tsx @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import AddProjectDialog from "./AddProjectDialog"; +import { useAppState } from "../../store/appState"; +import type { AppSettings, EnvironmentScan } from "../../lib/types"; +import { environment } from "../../test/awsFixtures"; const add = vi.fn(); @@ -12,6 +15,11 @@ vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn(async () => null), })); +const detectAwsEnvironments = vi.fn(); +vi.mock("../../lib/tauri-commands", () => ({ + detectAwsEnvironments: (c: string, r: string) => detectAwsEnvironments(c, r), +})); + /** A promise whose resolution this test controls, so `loading` can be held open. */ function deferred() { let resolve!: (v: unknown) => void; @@ -116,3 +124,94 @@ describe("AddProjectDialog", () => { expect(submitButton()).toHaveAccessibleDescription(""); }); }); + +describe("AddProjectDialog on AWS", () => { + const scan = (envs: EnvironmentScan["environments"]): EnvironmentScan => ({ + region: "eu-west-1", + account: "111122223333", + owner_id: "AROA:alice", + owner_key: "abc", + state_bucket: "yes", + boundary: "yes", + environments: envs, + }); + + beforeEach(() => { + vi.clearAllMocks(); + useAppState.setState({ + appSettings: { + hosts: [ + { + id: "c1", + name: "Work account", + kind: { type: "aws", auth: { method: "profile", name: "work" }, default_region: "eu-west-1" }, + }, + ], + } as unknown as AppSettings, + }); + }); + + function chooseAws() { + fireEvent.change(screen.getByLabelText("Runs on"), { target: { value: "c1" } }); + } + + it("sends the host, placement and workspace with repositories instead of folders", async () => { + detectAwsEnvironments.mockResolvedValue( + scan([environment("staging", "deploying", true), environment("dev", "ready", true)]), + ); + add.mockResolvedValue({ id: "p1" }); + const onClose = vi.fn(); + render(); + chooseAws(); + expect(screen.getByLabelText("Region")).toHaveValue("eu-west-1"); + await waitFor(() => expect(screen.getByLabelText("Environment")).toHaveValue("dev")); + expect(detectAwsEnvironments).toHaveBeenCalledWith("c1", "eu-west-1"); + expect( + (screen.getByRole("option", { name: /staging/ }) as HTMLOptionElement).disabled, + ).toBe(true); + expect(screen.queryByLabelText("Folder 1 host path")).toBeNull(); + + fireEvent.change(screen.getByLabelText("Project name"), { target: { value: "api" } }); + fireEvent.change(screen.getByLabelText("Repository 1 URL"), { + target: { value: "git@github.com:org/api.git" }, + }); + fireEvent.change(screen.getByLabelText("Repository 1 branch"), { target: { value: "develop" } }); + fireEvent.click(submitButton()); + + await waitFor(() => expect(add).toHaveBeenCalled()); + const [name, paths, host] = add.mock.calls[0]; + expect(name).toBe("api"); + expect(paths).toEqual([]); + expect(host.hostId).toBe("c1"); + expect(host.awsPlacement).toEqual({ region: "eu-west-1", environment: "dev" }); + expect(host.aws.repos).toEqual([ + { url: "git@github.com:org/api.git", branch: "develop", folder: "api" }, + ]); + expect(host.aws).toMatchObject({ cpu: 1024, memory_mib: 4096, wip_push_on_stop: true, task_arn: null }); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); + + it("points at Settings → Hosts when no environment is usable", async () => { + detectAwsEnvironments.mockResolvedValue(scan([environment("dev", "ready", false)])); + render(); + chooseAws(); + expect(await screen.findByText(/Settings → Hosts/)).toBeInTheDocument(); + expect(screen.queryByLabelText("Environment")).toBeNull(); + fireEvent.change(screen.getByLabelText("Project name"), { target: { value: "api" } }); + fireEvent.click(submitButton()); + expect(await screen.findByRole("alert")).toHaveTextContent(/Choose an environment/); + expect(add).not.toHaveBeenCalled(); + }); + + it("shows the backend's validation message when the add is refused", async () => { + detectAwsEnvironments.mockResolvedValue(scan([environment("dev", "update_available", true)])); + add.mockRejectedValue('Repository URL "ftp://x": use https://..., ssh://... or git@host:path'); + render(); + chooseAws(); + await waitFor(() => expect(screen.getByLabelText("Environment")).toHaveValue("dev")); + fireEvent.change(screen.getByLabelText("Project name"), { target: { value: "api" } }); + fireEvent.change(screen.getByLabelText("Repository 1 URL"), { target: { value: "ftp://x" } }); + fireEvent.click(submitButton()); + expect(await screen.findByRole("alert")).toHaveTextContent(/use https:\/\//); + }); +}); diff --git a/app/src/components/projects/AddProjectDialog.tsx b/app/src/components/projects/AddProjectDialog.tsx index 0187fd3..a306ac2 100644 --- a/app/src/components/projects/AddProjectDialog.tsx +++ b/app/src/components/projects/AddProjectDialog.tsx @@ -1,10 +1,15 @@ -import { useId, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { open } from "@tauri-apps/plugin-dialog"; import { useProjects } from "../../hooks/useProjects"; -import type { ProjectPath } from "../../lib/types"; +import type { DetectedEnvironment, HostConnection, ProjectPath, RepoSource } from "../../lib/types"; +import { detectAwsEnvironments } from "../../lib/tauri-commands"; +import { awsErrorMessage, toAwsError } from "../../lib/aws-error"; +import { cleanRepos, defaultAwsWorkspace, environmentSelectable } from "../../lib/awsWorkspace"; +import { useAppState } from "../../store/appState"; import Modal from "../ui/Modal"; import Button from "../ui/Button"; -import { inputClass, monoInputClass } from "../ui/Field"; +import { inputClass, monoInputClass, selectClass } from "../ui/Field"; +import { RepoRows, emptyRepo } from "./RepoListEditor"; interface Props { onClose: () => void; @@ -15,12 +20,36 @@ interface PathEntry { mount_name: string; } +/** Why an environment can't take a new project, for its disabled option. */ +function unavailableReason(env: DetectedEnvironment): string { + const st = env.state.state; + if (st !== "ready" && st !== "update_available") return st.replace(/_/g, " "); + return "you have not joined it"; +} + +type Scan = + | { status: "idle" } + | { status: "loading" } + | { status: "error"; message: string } + | { status: "done"; environments: DetectedEnvironment[] }; + function basenameFromPath(p: string): string { return p.replace(/[/\\]$/, "").split(/[/\\]/).pop() || ""; } +/** Stable, so the store selector does not return a new array each render. */ +const NO_HOSTS: HostConnection[] = []; + export default function AddProjectDialog({ onClose }: Props) { const { add } = useProjects(); + const hosts = useAppState((s) => s.appSettings?.hosts ?? NO_HOSTS); + const [hostId, setHostId] = useState(""); + const connection = hosts.find((h) => h.id === hostId) ?? null; + const [region, setRegion] = useState(""); + const [scanRegion, setScanRegion] = useState(""); + const [scan, setScan] = useState({ status: "idle" }); + const [environment, setEnvironment] = useState(""); + const [repos, setRepos] = useState([emptyRepo()]); const [name, setName] = useState(""); const [pathEntries, setPathEntries] = useState([ { host_path: "", mount_name: "" }, @@ -30,6 +59,39 @@ export default function AddProjectDialog({ onClose }: Props) { const nameInputRef = useRef(null); const formId = useId(); + // Look for environments when the connection or the region (on blur) changes. + useEffect(() => { + if (!connection || !scanRegion) { + setScan({ status: "idle" }); + return; + } + let cancelled = false; + setScan({ status: "loading" }); + detectAwsEnvironments(connection.id, scanRegion).then( + (found) => { + if (cancelled) return; + setScan({ status: "done", environments: found.environments }); + const first = found.environments.find(environmentSelectable); + setEnvironment(first?.name ?? ""); + }, + (e) => { + if (!cancelled) setScan({ status: "error", message: awsErrorMessage(toAwsError(e)) }); + }, + ); + return () => { + cancelled = true; + }; + }, [connection, scanRegion]); + + const chooseHost = (id: string) => { + setHostId(id); + setEnvironment(""); + const conn = hosts.find((h) => h.id === id); + const r = conn?.kind.default_region ?? ""; + setRegion(r); + setScanRegion(r); + }; + const handleBrowse = async (index: number) => { const selected = await open({ directory: true, multiple: false }); if (typeof selected === "string") { @@ -63,6 +125,33 @@ export default function AddProjectDialog({ onClose }: Props) { setError("Project name is required"); return; } + if (connection) { + if (!environment) { + setError("Choose an environment to run the project in"); + return; + } + const cleaned = cleanRepos(repos); + if (cleaned.some((r) => !r.url)) { + setError("Every repository needs a URL"); + return; + } + setLoading(true); + setError(null); + try { + await add(name.trim(), [], { + hostId: connection.id, + awsPlacement: { region: scanRegion, environment }, + aws: { ...defaultAwsWorkspace(), repos: cleaned }, + }); + onClose(); + } catch (err) { + // The backend's validation message (repository URL, folder, …). + setError(String(err)); + } finally { + setLoading(false); + } + return; + } const validPaths: ProjectPath[] = pathEntries .filter((p) => p.host_path.trim()) .map((p) => ({ @@ -137,62 +226,166 @@ export default function AddProjectDialog({ onClose }: Props) { /> -
- Folders -
- {pathEntries.map((entry, i) => ( -
-
- updateEntry(i, "host_path", e.target.value)} - placeholder="/path/to/folder" - aria-label={`Folder ${i + 1} host path`} - className={inputClass} - /> - - {pathEntries.length > 1 && ( - - )} -
-
- - /workspace/ - - updateEntry(i, "mount_name", e.target.value)} - placeholder="mount-name" - aria-label={`Folder ${i + 1} mount name`} - className={monoInputClass} - /> -
-
- ))} + {hosts.length > 0 && ( +
+ +
- -
+ )} + + {connection ? ( + <> +
+ + setRegion(e.target.value)} + onBlur={() => setScanRegion(region.trim())} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + setScanRegion(region.trim()); + } + }} + className={monoInputClass} + /> +
+
+ + {scan.status === "loading" && ( +

+ Looking for environments in {scanRegion}… +

+ )} + {scan.status === "error" && ( +

{scan.message}

+ )} + {scan.status === "done" && ( + <> + {scan.environments.some(environmentSelectable) ? ( + + ) : ( +

+ No environment in {scanRegion} is ready for you to use. Deploy or join + one in Settings → Hosts. +

+ )} + + )} +
+
+ Repositories +

+ Cloned into /workspace/<folder> when + the workspace starts. Memory, storage and the rest are in the project’s + Config tab. +

+ +
+ + ) : ( +
+ Folders +
+ {pathEntries.map((entry, i) => ( +
+
+ updateEntry(i, "host_path", e.target.value)} + placeholder="/path/to/folder" + aria-label={`Folder ${i + 1} host path`} + className={inputClass} + /> + + {pathEntries.length > 1 && ( + + )} +
+
+ + /workspace/ + + updateEntry(i, "mount_name", e.target.value)} + placeholder="mount-name" + aria-label={`Folder ${i + 1} mount name`} + className={monoInputClass} + /> +
+
+ ))} +
+ +
+ )} {error && (
{ + it("keeps an AWS workspace's saved files unless asked", () => { + const onConfirm = vi.fn(); + render(); + const box = screen.getByRole("checkbox", { name: /saved files \(EFS\)/ }); + expect(box).not.toBeChecked(); + fireEvent.click(screen.getByRole("button", { name: "Remove" })); + fireEvent.click(box); + fireEvent.click(screen.getByRole("button", { name: "Remove" })); + expect(onConfirm.mock.calls).toEqual([[{ discardData: false }], [{ discardData: true }]]); + }); + + it("offers no EFS choice for a local project", () => { + render(); + expect(screen.queryByRole("checkbox")).toBeNull(); + expect(screen.getByText(/mounted in, not copied/)).toBeInTheDocument(); + }); + + it("says what is left in AWS when the connection is gone", () => { + const onConfirm = vi.fn(); + render( + , + ); + expect(screen.getByText(/task definitions, its secret and its saved files/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Remove and leave them in AWS" })); + expect(onConfirm).toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/projects/ConfirmRemoveModal.tsx b/app/src/components/projects/ConfirmRemoveModal.tsx index 1fc53b4..f081606 100644 --- a/app/src/components/projects/ConfirmRemoveModal.tsx +++ b/app/src/components/projects/ConfirmRemoveModal.tsx @@ -1,13 +1,20 @@ +import { useState } from "react"; import Modal from "../ui/Modal"; import Button from "../ui/Button"; interface Props { projectName: string; - onConfirm: () => void; + /** An AWS project: its task, task definitions and secret go instead of Docker state. */ + aws?: boolean; + onConfirm: (opts: { discardData: boolean }) => void; onCancel: () => void; } -export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }: Props) { +const REMOVE_CLASS = + "bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"; + +export default function ConfirmRemoveModal({ projectName, aws = false, onConfirm, onCancel }: Props) { + const [discardData, setDiscardData] = useState(false); return ( Cancel - } > - {/* - Everything remove_project() destroys, named. It removes the container, - *both* named volumes (triple-c-home-{id} and triple-c-claude-config-{id}), - the triple-c-snapshot-{id} image and the project's keychain secrets — so - an accurate warning has to reach past "the config volume". The last - sentence is the reassuring half and matters just as much: project folders - are bind mounts from the host and nothing here touches them. - */} -

- Are you sure you want to remove{" "} - {projectName}? This deletes - its container, both of its volumes and its saved container image — so the home - directory, the Claude login and config, installed skills, session transcripts, - scheduled tasks and any stored credentials all go with it. -

+ {aws ? ( + <> +

+ Are you sure you want to remove{" "} + {projectName}? This stops its + workspace on AWS and removes its task definitions and its secret (the secret is + deleted after AWS’s 7-day recovery window). Work that was not pushed to a + remote is lost. +

+ + + ) : ( + <> + {/* + Everything remove_project() destroys, named. It removes the container, + *both* named volumes (triple-c-home-{id} and triple-c-claude-config-{id}), + the triple-c-snapshot-{id} image and the project's keychain secrets — so + an accurate warning has to reach past "the config volume". The last + sentence is the reassuring half and matters just as much: project folders + are bind mounts from the host and nothing here touches them. + */} +

+ Are you sure you want to remove{" "} + {projectName}? This deletes + its container, both of its volumes and its saved container image — so the home + directory, the Claude login and config, installed skills, session transcripts, + scheduled tasks and any stored credentials all go with it. +

+

+ Your project folders on this machine are mounted in, not copied, and are left + untouched. +

+ + )} +
+ ); +} + +/** The second confirm when an AWS project's connection is gone. */ +export function ConfirmForgetAwsModal({ + projectName, + reason, + onConfirm, + onCancel, +}: { + projectName: string; + reason: string; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( + + + + + } + > +

{reason}

- Your project folders on this machine are mounted in, not copied, and are left - untouched. + Removing {projectName} now + leaves its AWS resources in the account: the task (which may still be running and + billed), its task definitions, its secret and its saved files. You would have to + remove them in AWS yourself.

); diff --git a/app/src/components/projects/ProjectRow.tsx b/app/src/components/projects/ProjectRow.tsx index 87e981e..f6eef6e 100644 --- a/app/src/components/projects/ProjectRow.tsx +++ b/app/src/components/projects/ProjectRow.tsx @@ -4,6 +4,8 @@ import { useAppState, homeTabKey } from "../../store/appState"; import { useProjectActions } from "../../hooks/useProjectActions"; import { ProjectStatusIndicator } from "../ui/StatusIndicator"; import { useUnavailable } from "../ui/unavailable"; +import { isAwsProject } from "../../lib/awsWorkspace"; +import StopGuardModal from "./StopGuardModal"; interface Props { project: Project; @@ -23,8 +25,13 @@ export default function ProjectRow({ project }: Props) { progress: s.containerProgress[project.id], })), ); - const { busy, handleStart, handleStop, openClaudeTerminal } = + const { busy, handleStart, handleStop, openClaudeTerminal, stopGuard, resolveStopGuard } = useProjectActions(project); + const aws = isAwsProject(project); + // An AWS project mounts nothing; its clones are what /workspace holds. + const folders = aws + ? (project.aws?.repos ?? []).map((r) => r.folder) + : project.paths.map((pp) => pp.mount_name); const isSelected = activeTabKey === homeTabKey(project.id) || selectedProjectId === project.id; @@ -64,12 +71,17 @@ export default function ProjectRow({ project }: Props) {
- {project.paths.map((pp, i) => ( + {aws && ( +
+ AWS · {project.aws_placement?.environment ?? "?"} ({project.aws_placement?.region ?? "?"}) +
+ )} + {folders.map((folder, i) => (
- /workspace/{pp.mount_name} + /workspace/{folder}
))}
@@ -149,6 +161,9 @@ export default function ProjectRow({ project }: Props) { {terminal.reasonNode}
+ {stopGuard && ( + + )}
); } diff --git a/app/src/components/projects/RepoListEditor.tsx b/app/src/components/projects/RepoListEditor.tsx new file mode 100644 index 0000000..285feb5 --- /dev/null +++ b/app/src/components/projects/RepoListEditor.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from "react"; +import type { RepoSource } from "../../lib/types"; +import { cleanRepos, repoFolderFromUrl } from "../../lib/awsWorkspace"; +import Button from "../ui/Button"; +import { inputClass, monoInputClass } from "../ui/Field"; + +export interface RepoRowsProps { + rows: RepoSource[]; + onRowsChange: (rows: RepoSource[]) => void; + /** Called when focus leaves a field (save-on-blur editors). */ + onCommit?: () => void; + disabled?: boolean; +} + +export const emptyRepo = (): RepoSource => ({ url: "", branch: null, folder: "" }); + +/** Rows of URL / branch / folder. The folder follows the URL until it is edited. */ +export function RepoRows({ rows, onRowsChange, onCommit, disabled = false }: RepoRowsProps) { + const update = (i: number, patch: Partial) => { + const next = [...rows]; + next[i] = { ...next[i], ...patch }; + onRowsChange(next); + }; + return ( +
+ {rows.map((r, i) => ( +
+ { + const url = e.target.value; + const followed = !r.folder || r.folder === repoFolderFromUrl(r.url); + update(i, { url, ...(followed ? { folder: repoFolderFromUrl(url) } : {}) }); + }} + onBlur={onCommit} + className={monoInputClass} + /> +
+ update(i, { branch: e.target.value })} + onBlur={onCommit} + className={`w-40 ${inputClass}`} + /> + + /workspace/ + + update(i, { folder: e.target.value })} + onBlur={onCommit} + className={monoInputClass} + /> + +
+
+ ))} + +
+ ); +} + +function sameRepos(a: RepoSource[], b: RepoSource[]): boolean { + return ( + a.length === b.length && + a.every((r, i) => r.url === b[i].url && r.branch === b[i].branch && r.folder === b[i].folder) + ); +} + +/** + * The Config tab's save-on-blur repository list. Saves when every row that + * has anything in it has a URL; the backend's validation message (bad URL, + * folder used twice…) comes back through the save's toast. + */ +export default function RepoListEditor({ + repos, + onSave, + disabled = false, +}: { + repos: RepoSource[]; + onSave: (repos: RepoSource[]) => Promise; + disabled?: boolean; +}) { + const [rows, setRows] = useState(repos); + useEffect(() => setRows(repos), [repos]); + + // A commit bumps this; the effect then runs against the rendered rows. + const [pending, setPending] = useState(0); + useEffect(() => { + if (pending === 0) return; + const cleaned = cleanRepos(rows); + if (cleaned.some((r) => !r.url)) return; + if (sameRepos(cleaned, repos)) return; + void onSave(cleaned); + // Only a commit triggers this; `rows` changing alone does not save. + }, [pending]); + + return ( + setPending((n) => n + 1)} + disabled={disabled} + /> + ); +} diff --git a/app/src/components/projects/StopGuardModal.test.tsx b/app/src/components/projects/StopGuardModal.test.tsx new file mode 100644 index 0000000..9994328 --- /dev/null +++ b/app/src/components/projects/StopGuardModal.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import StopGuardModal from "./StopGuardModal"; +import type { UnsavedWork } from "../../lib/types"; + +const dirty: UnsavedWork = { + checked: true, + repos: [ + { folder: "api", branch: "main", dirty: true, unpushed: 2 }, + { folder: "clean", branch: "main", dirty: false, unpushed: 0 }, + ], + error: null, +}; + +describe("StopGuardModal", () => { + it("lists the repositories at risk and defaults to pushing", async () => { + const onChoose = vi.fn(); + render(); + expect(screen.getByText("/workspace/api")).toBeInTheDocument(); + expect(screen.getByText(/uncommitted changes, 2 unpushed commits/)).toBeInTheDocument(); + expect(screen.queryByText("/workspace/clean")).toBeNull(); + const push = screen.getByRole("button", { name: "Push to WIP branch and stop" }); + await waitFor(() => expect(push).toHaveFocus()); + fireEvent.click(push); + expect(onChoose).toHaveBeenCalledWith("push"); + }); + + it("offers Stop anyway and Cancel", () => { + const onChoose = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Stop anyway" })); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onChoose.mock.calls).toEqual([["discard"], [null]]); + }); + + it("hides Push and says why when the workspace could not be checked", async () => { + const onChoose = vi.fn(); + render( + , + ); + expect(screen.queryByRole("button", { name: /Push/ })).toBeNull(); + expect(screen.getByText("Triple-C is not connected to the workspace")).toBeInTheDocument(); + expect(screen.getByText(/could not check/)).toBeInTheDocument(); + const stop = screen.getByRole("button", { name: "Stop anyway" }); + await waitFor(() => expect(stop).toHaveFocus()); + }); +}); diff --git a/app/src/components/projects/StopGuardModal.tsx b/app/src/components/projects/StopGuardModal.tsx new file mode 100644 index 0000000..73dc7ea --- /dev/null +++ b/app/src/components/projects/StopGuardModal.tsx @@ -0,0 +1,102 @@ +import { useRef } from "react"; +import type { UnsavedWork, WipChoice } from "../../lib/types"; +import Modal from "../ui/Modal"; +import Button from "../ui/Button"; + +interface Props { + projectName: string; + work: UnsavedWork; + /** `null` = Cancel. */ + onChoose: (choice: WipChoice | null) => void; +} + +function describeRepo(dirty: boolean, unpushed: number): string { + const parts: string[] = []; + if (dirty) parts.push("uncommitted changes"); + if (unpushed > 0) parts.push(`${unpushed} unpushed commit${unpushed === 1 ? "" : "s"}`); + return parts.join(", "); +} + +/** + * Asked before an AWS workspace stops when its repositories hold work no + * remote has, or when Triple-C could not check. Pushing to a WIP branch is + * the default (it has focus); it is not offered when the check could not + * run, since the push goes the same way the check did. + */ +export default function StopGuardModal({ projectName, work, onChoose }: Props) { + const pushRef = useRef(null); + const stopRef = useRef(null); + const atRisk = work.repos.filter((r) => r.dirty || r.unpushed > 0); + + return ( + onChoose(null)} + widthClassName="w-[30rem]" + initialFocusRef={work.checked ? pushRef : stopRef} + footer={ + <> + + + {work.checked && ( + + )} + + } + > + {work.checked ? ( + <> +

+ Stopping {projectName} ends + its workspace. These repositories have work that no remote has yet: +

+
    + {atRisk.map((r) => ( +
  • + /workspace/{r.folder}{" "} + + ({r.branch}): {describeRepo(r.dirty, r.unpushed)} + +
  • + ))} +
+

+ Push to WIP branch saves a snapshot of each repository to a{" "} + triple-c/wip/… branch on its remote, without + touching your branches. If the push fails, the workspace keeps running. +

+ + ) : ( + <> +

+ Triple-C could not check{" "} + {projectName} for unsaved + work, so it can’t push it to a WIP branch first. +

+ {work.error && ( +

+ {work.error} +

+ )} +

+ Uncommitted or unpushed work in the workspace may be lost if you stop it now. +

+ + )} +
+ ); +} diff --git a/app/src/components/projects/home/ConfigTab.tsx b/app/src/components/projects/home/ConfigTab.tsx index 037f228..e478f58 100644 --- a/app/src/components/projects/home/ConfigTab.tsx +++ b/app/src/components/projects/home/ConfigTab.tsx @@ -6,6 +6,8 @@ import ModelSection from "./config/ModelSection"; import AccessSection from "./config/AccessSection"; import RuntimeSection from "./config/RuntimeSection"; import MarketplaceSection from "./config/MarketplaceSection"; +import HostSection from "./config/HostSection"; +import { isAwsProject } from "../../../lib/awsWorkspace"; interface Props { project: Project; @@ -40,6 +42,9 @@ export default function ConfigTab({ project, save, saveState }: Props) {
+ {isAwsProject(project) && ( + + )} (null); const [confirmReset, setConfirmReset] = useState(false); const [showMigration, setShowMigration] = useState(false); const { runningSince, progress } = useAppState( @@ -99,6 +103,7 @@ export default function ProjectHome({ projectId, active }: Props) { ); } + const aws = isAwsProject(project); const isRunning = project.status === "running"; const isTransitioning = project.status === "starting" || project.status === "stopping"; @@ -120,6 +125,41 @@ export default function ProjectHome({ projectId, active }: Props) { migration.probeSettled && !!project.container_id; + /** Remove the project; an AWS project whose connection is gone asks again. */ + const removeProject = async (opts: RemoveProjectOptions) => { + try { + const report = await remove(project.id, opts); + if (!projectRemovalIsClean(report)) { + const verb = leftoverVerb(report); + if (report.retry_scheduled) { + useAppState.getState().pushToast({ + kind: "info", + message: `“${project.name}” was removed, but Triple-C could not confirm all its Docker resources were removed`, + detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed. It will check again the next time it starts.`, + }); + } else { + // The pending-cleanup record itself failed to save — no + // retry will happen, so this must not promise one. + useAppState.getState().pushToast({ + kind: "error", + message: `“${project.name}” was removed, but Triple-C could not confirm its Docker resources were removed`, + detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed, and could not record this for a retry. You may need to remove ${leftoverPronoun(report)} manually (\`docker rm\` / \`docker rmi\` / \`docker volume rm\`).`, + }); + } + } + } catch (e) { + if (aws && !opts.forgetAwsResources && /connection no longer exists/i.test(String(e))) { + setForgetAws({ reason: String(e), discardData: !!opts.discardData }); + return; + } + useAppState.getState().pushToast({ + kind: "error", + message: `Could not remove “${project.name}”`, + detail: String(e), + }); + } + }; + return (
{/* Header */} @@ -188,12 +228,13 @@ export default function ProjectHome({ projectId, active }: Props) { { label: "Update container base…", onSelect: () => setShowMigration(true), - disabled: !canMigrate, + // Docker-only: the backend refuses both for an AWS project. + disabled: aws || !canMigrate, }, { label: "Reset container…", onSelect: () => setConfirmReset(true), - disabled: !isStopped || actions.busy, + disabled: aws || !isStopped || actions.busy, danger: true, }, { @@ -280,39 +321,33 @@ export default function ProjectHome({ projectId, active }: Props) { }} /> )} + {actions.stopGuard && ( + + )} + {forgetAws && ( + setForgetAws(null)} + onConfirm={() => { + const { discardData } = forgetAws; + setForgetAws(null); + void removeProject({ discardData, forgetAwsResources: true }); + }} + /> + )} {confirmRemove && ( setConfirmRemove(false)} - onConfirm={async () => { + onConfirm={({ discardData }) => { setConfirmRemove(false); - try { - const report = await remove(project.id); - if (!projectRemovalIsClean(report)) { - const verb = leftoverVerb(report); - if (report.retry_scheduled) { - useAppState.getState().pushToast({ - kind: "info", - message: `“${project.name}” was removed, but Triple-C could not confirm all its Docker resources were removed`, - detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed. It will check again the next time it starts.`, - }); - } else { - // The pending-cleanup record itself failed to save — no - // retry will happen, so this must not promise one. - useAppState.getState().pushToast({ - kind: "error", - message: `“${project.name}” was removed, but Triple-C could not confirm its Docker resources were removed`, - detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed, and could not record this for a retry. You may need to remove ${leftoverPronoun(report)} manually (\`docker rm\` / \`docker rmi\` / \`docker volume rm\`).`, - }); - } - } - } catch (e) { - useAppState.getState().pushToast({ - kind: "error", - message: `Could not remove “${project.name}”`, - detail: String(e), - }); - } + void removeProject({ discardData }); }} /> )} diff --git a/app/src/components/projects/home/config/AccessSection.tsx b/app/src/components/projects/home/config/AccessSection.tsx index 85c7f42..9676698 100644 --- a/app/src/components/projects/home/config/AccessSection.tsx +++ b/app/src/components/projects/home/config/AccessSection.tsx @@ -7,6 +7,7 @@ import Field, { ConfigGroup, inputClass } from "../../../ui/Field"; import CaCertPathInput from "../../../settings/CaCertPathInput"; import EnvVarsEditor from "../../EnvVarsEditor"; import PortMappingsEditor from "../../PortMappingsEditor"; +import { NOT_ON_AWS, isAwsProject } from "../../../../lib/awsWorkspace"; interface Props { project: Project; @@ -161,16 +162,22 @@ export default function AccessSection({ Port mappings -

- Expose container ports on the host so you can reach dev servers running inside - the sandbox. -

- save({ port_mappings: mappings })} - /> + {isAwsProject(project) ? ( +

{NOT_ON_AWS}

+ ) : ( + <> +

+ Expose container ports on the host so you can reach dev servers running inside + the sandbox. +

+ save({ port_mappings: mappings })} + /> + + )}
); diff --git a/app/src/components/projects/home/config/AwsConfig.test.tsx b/app/src/components/projects/home/config/AwsConfig.test.tsx new file mode 100644 index 0000000..f1f18ca --- /dev/null +++ b/app/src/components/projects/home/config/AwsConfig.test.tsx @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import RuntimeSection from "./RuntimeSection"; +import AccessSection from "./AccessSection"; +import WorkspaceSection from "./WorkspaceSection"; +import ModelSection from "./ModelSection"; +import HostSection from "./HostSection"; +import { awsProject, localProject } from "../../../../test/awsFixtures"; +import type { AuthBridgeStatus, Project } from "../../../../lib/types"; + +const OFF_BRIDGE: AuthBridgeStatus = { enabled: false, active_ports: [], conflicts: [] }; +const removeAwsResources = vi.fn(); + +vi.mock("../../../../lib/tauri-commands", () => ({ + getAuthBridgeStatus: vi.fn(async () => OFF_BRIDGE), + setAuthBridgeEnabled: vi.fn(), + inspectCaCertPath: vi.fn(async () => null), + removeAwsResources: (id: string, discard: boolean) => removeAwsResources(id, discard), +})); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => () => {}) })); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn(async () => null) })); + +const save = vi.fn().mockResolvedValue(true); + +beforeEach(() => vi.clearAllMocks()); + +function toggle(name: string) { + return screen.getByRole("switch", { name }); +} + +describe("AWS project config", () => { + it("locks Docker access, VPN and the sandbox on AWS, saying why", () => { + render(); + for (const name of ["Allow container spawning", "VPN support", "Sandbox mode"]) { + expect(toggle(name)).toBeDisabled(); + expect(toggle(name)).toHaveAttribute("aria-checked", "false"); + } + expect(screen.getAllByText("Not available on AWS (Fargate)")).toHaveLength(2); + expect(screen.getByText(/Off on AWS for now/)).toBeInTheDocument(); + }); + + it("leaves those options alone for a local project", () => { + render(); + expect(toggle("VPN support")).not.toBeDisabled(); + expect(toggle("Sandbox mode")).toHaveAttribute("aria-checked", "true"); + expect(screen.queryByText("Not available on AWS (Fargate)")).toBeNull(); + }); + + it("replaces port mappings and folders on AWS", () => { + render(); + expect(screen.getByText("Not available on AWS (Fargate)")).toBeInTheDocument(); + expect(screen.queryByText(/Expose container ports/)).toBeNull(); + + render(); + expect(screen.queryByText("Folders")).toBeNull(); + expect(screen.getByText(/mounts no folders/)).toBeInTheDocument(); + }); + + it("disables the gateway backend and warns about local model URLs on AWS", () => { + render(); + const gateway = screen.getByRole("option", { name: /OpenAI Compatible/ }) as HTMLOptionElement; + expect(gateway.disabled).toBe(true); + expect(gateway.textContent).toMatch(/Not available on AWS/); + expect(screen.getByText(/must be reachable from the AWS task/)).toBeInTheDocument(); + }); +}); + +describe("HostSection", () => { + const renderHost = (p: Project = awsProject(), disabled = false) => + render(); + + it("saves a CPU change with a memory size valid for it", async () => { + renderHost(); // 1024 CPU / 4096 MiB + fireEvent.change(screen.getByLabelText("CPU"), { target: { value: "256" } }); + await waitFor(() => expect(save).toHaveBeenCalled()); + const aws = save.mock.calls[0][0].aws; + expect([aws.cpu, aws.memory_mib]).toEqual([256, 2048]); + }); + + it("saves a valid memory size and refuses an invalid one", async () => { + renderHost(); + const mem = screen.getByLabelText("Memory (MiB)"); + fireEvent.change(mem, { target: { value: "5000" } }); + fireEvent.blur(mem); + expect(screen.getByRole("alert")).toHaveTextContent(/5000 MiB of memory is not valid with 1024/); + expect(save).not.toHaveBeenCalled(); + + fireEvent.change(mem, { target: { value: "8192" } }); + fireEvent.blur(mem); + await waitFor(() => expect(save).toHaveBeenCalledTimes(1)); + expect(save.mock.calls[0][0].aws).toMatchObject({ cpu: 1024, memory_mib: 8192 }); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("warns that Ephemeral storage loses the login and history", () => { + const p = awsProject(); + p.aws = { ...p.aws!, storage: "ephemeral" }; + renderHost(p); + expect(screen.getByText(/Claude login and history are lost when the workspace stops/)).toBeInTheDocument(); + expect(screen.getByText(/EFS .* coming later/)).toBeInTheDocument(); + }); + + it("saves repository edits on blur", async () => { + renderHost(); + fireEvent.click(screen.getByRole("button", { name: "+ Add repository" })); + const url = screen.getByLabelText("Repository 2 URL"); + fireEvent.change(url, { target: { value: "git@github.com:org/web.git" } }); + fireEvent.blur(url); + await waitFor(() => expect(save).toHaveBeenCalled()); + expect(save.mock.calls[0][0].aws.repos).toEqual([ + { url: "https://example.com/org/api.git", branch: null, folder: "api" }, + { url: "git@github.com:org/web.git", branch: null, folder: "web" }, + ]); + }); + + it("removes AWS resources after a confirm, and only then allows moving to this machine", async () => { + const p = awsProject(); + p.aws = { ...p.aws!, task_definition_arn: "arn:td:1" }; + removeAwsResources.mockResolvedValue({ ...p, aws: { ...p.aws, task_definition_arn: null } }); + renderHost(p); + const move = screen.getByRole("button", { name: "Run on this machine instead" }); + expect(move).toHaveAttribute("aria-disabled", "true"); + fireEvent.click(move); + expect(save).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Remove AWS resources…" })); + const dialog = screen.getByRole("dialog"); + fireEvent.click(within(dialog).getByRole("checkbox")); + fireEvent.click(within(dialog).getByRole("button", { name: "Remove" })); + await waitFor(() => expect(removeAwsResources).toHaveBeenCalledWith(p.id, true)); + }); +}); diff --git a/app/src/components/projects/home/config/HostSection.tsx b/app/src/components/projects/home/config/HostSection.tsx new file mode 100644 index 0000000..3c1fa4f --- /dev/null +++ b/app/src/components/projects/home/config/HostSection.tsx @@ -0,0 +1,419 @@ +import { useEffect, useState } from "react"; +import type { AwsWorkspaceConfig, HostConnection, Project, RepoSource } from "../../../../lib/types"; +import * as commands from "../../../../lib/tauri-commands"; +import { useAppState } from "../../../../store/appState"; +import { + EPHEMERAL_WARNING, + FARGATE_CPUS, + defaultAwsWorkspace, + formatCpu, + formatMemory, + memoryError, + memoryOptions, + nearestMemory, +} from "../../../../lib/awsWorkspace"; +import Button from "../../../ui/Button"; +import Modal from "../../../ui/Modal"; +import Toggle from "../../../ui/Toggle"; +import SegmentedControl from "../../../ui/SegmentedControl"; +import Field, { ConfigGroup, SwitchRow, inputClass, selectClass } from "../../../ui/Field"; +import RepoListEditor from "../../RepoListEditor"; + +interface Props { + project: Project; + save: (patch: Partial) => Promise; + disabled: boolean; +} + +const DEFAULT_IDLE_MINUTES = 60; + +/** + * Where an AWS project runs and what its Fargate task looks like. Only shown + * for AWS projects; a local project's host is chosen when it is added. + */ +/** Stable, so the store selector does not return a new array each render. */ +const NO_HOSTS: HostConnection[] = []; + +export default function HostSection({ project, save, disabled }: Props) { + const aws: AwsWorkspaceConfig = project.aws ?? defaultAwsWorkspace(); + const hosts = useAppState((s) => s.appSettings?.hosts ?? NO_HOSTS); + const connection = hosts.find((h) => h.id === project.host_id); + + const [memory, setMemory] = useState(String(aws.memory_mib)); + const [memoryProblem, setMemoryProblem] = useState(null); + const [ephemeral, setEphemeral] = useState(String(aws.ephemeral_gib)); + const [ephemeralProblem, setEphemeralProblem] = useState(null); + const [idle, setIdle] = useState(String(aws.idle_stop_minutes ?? DEFAULT_IDLE_MINUTES)); + const [idleProblem, setIdleProblem] = useState(null); + const [confirmRemoveResources, setConfirmRemoveResources] = useState(false); + + useEffect(() => { + setMemory(String(aws.memory_mib)); + setEphemeral(String(aws.ephemeral_gib)); + setIdle(String(aws.idle_stop_minutes ?? DEFAULT_IDLE_MINUTES)); + }, [aws.memory_mib, aws.ephemeral_gib, aws.idle_stop_minutes]); + + const saveAws = (patch: Partial) => save({ aws: { ...aws, ...patch } }); + + const hasResources = !!(aws.task_arn || aws.access_point_id || aws.task_definition_arn); + + const commitMemory = () => { + const value = Number(memory); + const problem = memoryError(aws.cpu, value); + setMemoryProblem(problem); + if (problem || value === aws.memory_mib) return; + void saveAws({ memory_mib: value }); + }; + + const commitEphemeral = () => { + const value = Number(ephemeral); + const ok = Number.isInteger(value) && value >= 21 && value <= 200; + setEphemeralProblem(ok ? null : "Ephemeral storage must be 21 to 200 GiB"); + if (ok && value !== aws.ephemeral_gib) void saveAws({ ephemeral_gib: value }); + }; + + const commitIdle = () => { + const value = Number(idle); + const ok = Number.isInteger(value) && value >= 5 && value <= 1440; + setIdleProblem(ok ? null : "Idle stop must be 5 to 1440 minutes"); + if (ok && value !== aws.idle_stop_minutes) void saveAws({ idle_stop_minutes: value }); + }; + + return ( + +
+
Connection
+
+ {connection?.name ?? ( + + Removed — add it again in Settings → Hosts to start this project + + )} +
+
Region
+
{project.aws_placement?.region}
+
Environment
+
+ {project.aws_placement?.environment} +
+
+ +
+ + {(id) => ( + + )} + + { + const opts = memoryOptions(aws.cpu); + return `${formatMemory(opts[0])} to ${formatMemory(opts[opts.length - 1])} with ${formatCpu(aws.cpu)}.`; + })()} + > + {(id) => ( + <> + setMemory(e.target.value)} + onBlur={commitMemory} + onKeyDown={(e) => e.key === "Enter" && commitMemory()} + className={`w-32 ${inputClass}`} + /> + + {memoryOptions(aws.cpu).map((m) => ( + + + )} + +
+ + Architecture + + void saveAws({ arch })} + segments={[ + { value: "x86_64", label: "x86_64" }, + { value: "arm64", label: "ARM64 (Graviton)" }, + ]} + /> +
+
+ {memoryProblem && ( +

+ {memoryProblem} +

+ )} + +
+ + Storage + +
+ void saveAws({ storage })} + segments={[ + { value: "ephemeral", label: "Ephemeral", caution: true }, + { value: "hybrid", label: "Hybrid" }, + ]} + /> + + EFS (everything persists) — coming later + +
+ {aws.storage === "ephemeral" ? ( +

+ {EPHEMERAL_WARNING} Push your work to a remote before stopping. +

+ ) : ( +

+ The Claude login, history and SSH keys are kept on EFS between starts; everything + else, repositories included, starts fresh. +

+ )} +
+ + + {(id) => ( + setEphemeral(e.target.value)} + onBlur={commitEphemeral} + className={`w-24 ${inputClass}`} + /> + )} + + {ephemeralProblem && ( +

+ {ephemeralProblem} +

+ )} + + void saveAws({ spot })} + /> + } + /> + + + void saveAws({ idle_stop_minutes: on ? Number(idle) || DEFAULT_IDLE_MINUTES : null }) + } + /> + } + /> + {aws.idle_stop_minutes !== null && ( + + {(id) => ( + setIdle(e.target.value)} + onBlur={commitIdle} + className={`w-24 ${inputClass}`} + /> + )} + + )} + {idleProblem && ( +

+ {idleProblem} +

+ )} + + void saveAws({ wip_push_on_stop })} + /> + } + /> + +
+ + Repositories + +

+ Cloned into /workspace/<folder> when the + workspace starts, with the project’s SSH key or Git token. +

+ saveAws({ repos })} + /> +
+ +
+ + +
+ + {confirmRemoveResources && ( + setConfirmRemoveResources(false)} + /> + )} +
+ ); +} + +function RemoveAwsResourcesModal({ project, onClose }: { project: Project; onClose: () => void }) { + const [discardData, setDiscardData] = useState(false); + const [running, setRunning] = useState(false); + const [error, setError] = useState(null); + + const confirm = async () => { + if (running) return; + setRunning(true); + setError(null); + try { + const updated = await commands.removeAwsResources(project.id, discardData); + useAppState.getState().updateProjectInList(updated); + onClose(); + } catch (e) { + setError(String(e)); + } finally { + setRunning(false); + } + }; + + return ( + + + + + } + > +

+ Removes {project.name}’s + task definitions and secret from AWS (the secret after a 7-day recovery window) and + keeps the project, so it can run somewhere else. Its next start on AWS sets them up + again. +

+ + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/app/src/components/projects/home/config/ModelSection.tsx b/app/src/components/projects/home/config/ModelSection.tsx index e43e6a1..302afe5 100644 --- a/app/src/components/projects/home/config/ModelSection.tsx +++ b/app/src/components/projects/home/config/ModelSection.tsx @@ -16,6 +16,11 @@ import Field, { selectClass, } from "../../../ui/Field"; import Toggle from "../../../ui/Toggle"; +import { NOT_ON_AWS, isAwsProject } from "../../../../lib/awsWorkspace"; + +/** P6: an AWS workspace can reach a local model only over the network. */ +const LOCAL_MODEL_ON_AWS = + "This project runs on AWS: the URL must be reachable from the AWS task, so host.docker.internal and this machine's LAN addresses will not work."; /** Bedrock fields held in the OS keychain, never serialized back to us. */ const BEDROCK_SECRET_KEYS = [ @@ -181,6 +186,8 @@ export default function ModelSection({ project, save, disabled }: Props) { // that predates it, should still pick the shared token up. const useSharedToken = project.use_shared_auth_token !== false; + const aws = isAwsProject(project); + const handleBackendChange = (mode: Backend) => { const patch: Partial = { backend: mode }; if (mode === "bedrock" && !project.bedrock_config) @@ -212,10 +219,25 @@ export default function ModelSection({ project, save, disabled }: Props) { - + )} + {aws && project.backend === "open_ai_compatible" && ( +

+ OpenAI Compatible (gateway): {NOT_ON_AWS}. Choose another backend. +

+ )} + {aws && (project.backend === "ollama" || project.backend === "llama_cpp") && ( +

+ {LOCAL_MODEL_ON_AWS} +

+ )} {/* Only Anthropic reads CLAUDE_CODE_OAUTH_TOKEN; the other backends authenticate through their own credentials entirely. */} diff --git a/app/src/components/projects/home/config/RuntimeSection.tsx b/app/src/components/projects/home/config/RuntimeSection.tsx index 7fe7a2e..099564b 100644 --- a/app/src/components/projects/home/config/RuntimeSection.tsx +++ b/app/src/components/projects/home/config/RuntimeSection.tsx @@ -5,6 +5,7 @@ import PermissionModeControl, { permissionModePatch } from "../../PermissionMode import ClaudeInstructionsEditor from "../../ClaudeInstructionsEditor"; import ClaudeCodeSettingsEditor from "../../ClaudeCodeSettingsEditor"; import AuthBridgeRow from "./AuthBridgeRow"; +import { NOT_ON_AWS, SANDBOX_OFF_ON_AWS, isAwsProject } from "../../../../lib/awsWorkspace"; interface Props { project: Project; @@ -19,6 +20,7 @@ export default function RuntimeSection({ disabled, disabledReason, }: Props) { + const aws = isAwsProject(project); return ( <> save({ sandbox_mode_enabled: v })} /> } @@ -47,12 +53,16 @@ export default function RuntimeSection({ save({ allow_docker_access: v })} /> } @@ -60,12 +70,12 @@ export default function RuntimeSection({ save({ vpn_support_enabled: v })} /> } diff --git a/app/src/components/projects/home/config/WorkspaceSection.tsx b/app/src/components/projects/home/config/WorkspaceSection.tsx index cc6790c..9390bfb 100644 --- a/app/src/components/projects/home/config/WorkspaceSection.tsx +++ b/app/src/components/projects/home/config/WorkspaceSection.tsx @@ -3,6 +3,7 @@ import { open } from "@tauri-apps/plugin-dialog"; import type { Project, ProjectPath } from "../../../../lib/types"; import Button from "../../../ui/Button"; import Field, { ConfigGroup, inputClass, monoInputClass } from "../../../ui/Field"; +import { isAwsProject } from "../../../../lib/awsWorkspace"; interface Props { project: Project; @@ -73,7 +74,11 @@ export default function WorkspaceSection({ project, save, disabled }: Props) { return ( -
- - Folders - -

- Each host folder is mounted at /workspace/<name>{" "} - inside the container. + {isAwsProject(project) ? ( +

+ This project runs on AWS, so it mounts no folders from this machine: its + repositories are cloned into /workspace. Edit them + under Host below.

+ ) : ( +
+ + Folders + +

+ Each host folder is mounted at /workspace/<name>{" "} + inside the container. +

-
- {paths.map((pp, i) => ( -
- { - const updated = [...paths]; - updated[i] = { ...updated[i], host_path: e.target.value }; - setPaths(updated); - }} - onBlur={() => saveIfComplete()} - placeholder="/path/to/folder" - disabled={disabled} - className={`flex-1 min-w-0 ${inputClass}`} - /> -
- - - /workspace/ - +
+ {paths.map((pp, i) => ( +
{ const updated = [...paths]; - updated[i] = { ...updated[i], mount_name: e.target.value }; + updated[i] = { ...updated[i], host_path: e.target.value }; setPaths(updated); }} onBlur={() => saveIfComplete()} - placeholder="name" + placeholder="/path/to/folder" disabled={disabled} - className={`w-40 ${monoInputClass}`} + className={`flex-1 min-w-0 ${inputClass}`} /> - {paths.length > 1 && ( +
- )} + + /workspace/ + + { + const updated = [...paths]; + updated[i] = { ...updated[i], mount_name: e.target.value }; + setPaths(updated); + }} + onBlur={() => saveIfComplete()} + placeholder="name" + disabled={disabled} + className={`w-40 ${monoInputClass}`} + /> + {paths.length > 1 && ( + + )} +
-
- ))} -
+ ))} +
- -
+ +
+ )} ); } diff --git a/app/src/components/ui/Button.tsx b/app/src/components/ui/Button.tsx index be41f79..0f10fb4 100644 --- a/app/src/components/ui/Button.tsx +++ b/app/src/components/ui/Button.tsx @@ -1,4 +1,4 @@ -import type { ButtonHTMLAttributes, ReactNode } from "react"; +import type { ButtonHTMLAttributes, ReactNode, Ref } from "react"; import { useUnavailable } from "./unavailable"; export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost"; @@ -19,6 +19,8 @@ interface Props extends ButtonHTMLAttributes { unavailable?: boolean; /** Why the button cannot be used. Required for `unavailable` to say anything. */ unavailableReason?: string; + /** React 19 passes `ref` as a prop; it lands on the ` - @@ -40,21 +52,23 @@ export default function ConfirmRemoveModal({ projectName, aws = false, onConfirm deleted after AWS’s 7-day recovery window). Work that was not pushed to a remote is lost.

- + + )} ) : ( <> diff --git a/app/src/components/projects/ProjectRow.tsx b/app/src/components/projects/ProjectRow.tsx index f6eef6e..7fc7a11 100644 --- a/app/src/components/projects/ProjectRow.tsx +++ b/app/src/components/projects/ProjectRow.tsx @@ -162,7 +162,12 @@ export default function ProjectRow({ project }: Props) { {terminal.reasonNode}
{stopGuard && ( - + )} ); diff --git a/app/src/components/projects/RepoListEditor.tsx b/app/src/components/projects/RepoListEditor.tsx index 285feb5..6e4fe1f 100644 --- a/app/src/components/projects/RepoListEditor.tsx +++ b/app/src/components/projects/RepoListEditor.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { RepoSource } from "../../lib/types"; import { cleanRepos, repoFolderFromUrl } from "../../lib/awsWorkspace"; import Button from "../ui/Button"; @@ -70,8 +70,8 @@ export function RepoRows({ rows, onRowsChange, onCommit, disabled = false }: Rep aria-label={`Remove repository ${i + 1}`} onClick={() => { onRowsChange(rows.filter((_, j) => j !== i)); - // A removal is a finished edit. The commit is applied after - // the rows update renders, so it sees the shorter list. + // A removal is a finished edit; `onRowsChange` has already + // handed the shorter list over. onCommit?.(); }} > @@ -98,6 +98,9 @@ function sameRepos(a: RepoSource[], b: RepoSource[]): boolean { * The Config tab's save-on-blur repository list. Saves when every row that * has anything in it has a URL; the backend's validation message (bad URL, * folder used twice…) comes back through the save's toast. + * + * Rows in progress survive a refresh of the project list: they are replaced + * from `repos` only when the stored value actually changed. */ export default function RepoListEditor({ repos, @@ -109,25 +112,28 @@ export default function RepoListEditor({ disabled?: boolean; }) { const [rows, setRows] = useState(repos); - useEffect(() => setRows(repos), [repos]); + // The latest rows, so a commit right after an edit (a removal) sees them. + const rowsRef = useRef(rows); + const storedRef = useRef(repos); - // A commit bumps this; the effect then runs against the rendered rows. - const [pending, setPending] = useState(0); useEffect(() => { - if (pending === 0) return; - const cleaned = cleanRepos(rows); - if (cleaned.some((r) => !r.url)) return; - if (sameRepos(cleaned, repos)) return; - void onSave(cleaned); - // Only a commit triggers this; `rows` changing alone does not save. - }, [pending]); + if (sameRepos(repos, storedRef.current)) return; + storedRef.current = repos; + rowsRef.current = repos; + setRows(repos); + }, [repos]); - return ( - setPending((n) => n + 1)} - disabled={disabled} - /> - ); + const change = (next: RepoSource[]) => { + rowsRef.current = next; + setRows(next); + }; + + const commit = () => { + const cleaned = cleanRepos(rowsRef.current); + if (cleaned.some((r) => !r.url)) return; + if (sameRepos(cleaned, storedRef.current)) return; + void onSave(cleaned); + }; + + return ; } diff --git a/app/src/components/projects/StopGuardModal.test.tsx b/app/src/components/projects/StopGuardModal.test.tsx index 9994328..1a638df 100644 --- a/app/src/components/projects/StopGuardModal.test.tsx +++ b/app/src/components/projects/StopGuardModal.test.tsx @@ -25,10 +25,10 @@ describe("StopGuardModal", () => { expect(onChoose).toHaveBeenCalledWith("push"); }); - it("offers Stop anyway and Cancel", () => { + it("offers Stop without pushing and Cancel", () => { const onChoose = vi.fn(); render(); - fireEvent.click(screen.getByRole("button", { name: "Stop anyway" })); + fireEvent.click(screen.getByRole("button", { name: "Stop without pushing" })); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(onChoose.mock.calls).toEqual([["discard"], [null]]); }); @@ -39,10 +39,12 @@ describe("StopGuardModal", () => { , ); expect(screen.queryByRole("button", { name: /Push/ })).toBeNull(); + expect(screen.getByText(/own push of work in progress still runs/)).toBeInTheDocument(); expect(screen.getByText("Triple-C is not connected to the workspace")).toBeInTheDocument(); expect(screen.getByText(/could not check/)).toBeInTheDocument(); const stop = screen.getByRole("button", { name: "Stop anyway" }); diff --git a/app/src/components/projects/StopGuardModal.tsx b/app/src/components/projects/StopGuardModal.tsx index 73dc7ea..bcb3826 100644 --- a/app/src/components/projects/StopGuardModal.tsx +++ b/app/src/components/projects/StopGuardModal.tsx @@ -6,6 +6,8 @@ import Button from "../ui/Button"; interface Props { projectName: string; work: UnsavedWork; + /** The project's "push work in progress when the workspace stops" setting. */ + wipPushOnStop?: boolean; /** `null` = Cancel. */ onChoose: (choice: WipChoice | null) => void; } @@ -23,7 +25,7 @@ function describeRepo(dirty: boolean, unpushed: number): string { * the default (it has focus); it is not offered when the check could not * run, since the push goes the same way the check did. */ -export default function StopGuardModal({ projectName, work, onChoose }: Props) { +export default function StopGuardModal({ projectName, work, wipPushOnStop = false, onChoose }: Props) { const pushRef = useRef(null); const stopRef = useRef(null); const atRisk = work.repos.filter((r) => r.dirty || r.unpushed > 0); @@ -45,7 +47,7 @@ export default function StopGuardModal({ projectName, work, onChoose }: Props) { variant={work.checked ? "danger" : "primary"} onClick={() => onChoose("discard")} > - Stop anyway + {work.checked ? "Stop without pushing" : "Stop anyway"} {work.checked && ( diff --git a/app/src/components/projects/home/config/HostSection.tsx b/app/src/components/projects/home/config/HostSection.tsx index 6092d17..b5b8a4f 100644 --- a/app/src/components/projects/home/config/HostSection.tsx +++ b/app/src/components/projects/home/config/HostSection.tsx @@ -367,7 +367,7 @@ function RemoveAwsResourcesModal({ project, onClose }: { project: Project; onClo setRunning(true); setError(null); try { - const updated = await commands.removeAwsResources(project.id, discardData); + const updated = await commands.removeAwsResources(project.id, hasSavedFiles(project) && discardData); useAppState.getState().updateProjectInList(updated); onClose(); } catch (e) { diff --git a/app/src/hooks/useProjectActions.test.ts b/app/src/hooks/useProjectActions.test.ts index f1f5fc8..457906b 100644 --- a/app/src/hooks/useProjectActions.test.ts +++ b/app/src/hooks/useProjectActions.test.ts @@ -76,6 +76,18 @@ describe("useProjectActions stop guard", () => { expect(stop).not.toHaveBeenCalled(); }); + it("stops an unchecked workspace without sending a choice", async () => { + checkUnsavedWork.mockResolvedValue({ checked: false, repos: [], error: "dry run timed out" }); + const { result } = renderHook(() => useProjectActions(awsProject({ status: "running" }))); + await act(async () => { + await result.current.handleStop(); + }); + await act(async () => { + await result.current.resolveStopGuard("unguarded"); + }); + expect(stop).toHaveBeenCalledWith("p1", undefined); + }); + it("toasts a failed push or StopTask and leaves the project as it was", async () => { checkUnsavedWork.mockResolvedValue(dirty); stop.mockRejectedValue("Pushing unsaved work failed (exit 1), so the workspace was not stopped:\npush denied"); diff --git a/app/src/hooks/useProjectActions.ts b/app/src/hooks/useProjectActions.ts index 6e43507..0642f3c 100644 --- a/app/src/hooks/useProjectActions.ts +++ b/app/src/hooks/useProjectActions.ts @@ -105,11 +105,13 @@ export function useProjectActions(project: Project) { }, [aws, project.status, project.id, run, stop]); const resolveStopGuard = useCallback( - (choice: WipChoice | null) => { + (choice: WipChoice | "unguarded" | null) => { setStopGuard(null); if (choice === null) return undefined; return run("Stop", async () => { - const note = await stop(project.id, choice); + // "unguarded": no choice is sent, so nothing is recorded in the + // workspace and its own shutdown push runs as configured. + const note = await stop(project.id, choice === "unguarded" ? undefined : choice); if (note) pushToast({ kind: "info", message: `“${project.name}” stopped`, detail: note }); return note; }); diff --git a/app/src/lib/awsWorkspace.ts b/app/src/lib/awsWorkspace.ts index a1b3faf..7998f47 100644 --- a/app/src/lib/awsWorkspace.ts +++ b/app/src/lib/awsWorkspace.ts @@ -107,7 +107,7 @@ export function cleanRepos(rows: RepoSource[]): RepoSource[] { })); } -/** Whether Stop must ask first (mirrors Rust `needs_guard`). */ +/** Whether Stop must ask first (the one place this rule lives). */ export function stopGuardNeeded(work: UnsavedWork): boolean { return !work.checked || work.repos.some((r) => r.dirty || r.unpushed > 0); } diff --git a/container/triple-c-idle-watch b/container/triple-c-idle-watch index c50c5df..17f934d 100755 --- a/container/triple-c-idle-watch +++ b/container/triple-c-idle-watch @@ -132,7 +132,12 @@ while :; do if check; then echo "triple-c-idle-watch: idle for ${minutes} minutes — stopping the workspace" mkdir -p "$(dirname "$reason_file")" 2>/dev/null - printf 'idle\n' > "$reason_file" + # A pushed/discard marker means the app's stop guard has decided and + # its stop is under way: keep that decision rather than overwrite it. + case "$(cat "$reason_file" 2>/dev/null)" in + pushed|discard) ;; + *) printf 'idle\n' > "$reason_file" ;; + esac kill -TERM "$signal_pid" exit 0 fi -- 2.52.0 From 074cb288486ec5038b977226f0bb3e44ea152e66 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 10:31:06 -0800 Subject: [PATCH 19/43] docs(aws): correct the 0.25 vCPU Fargate memory sizes in the phase 5 plan Co-Authored-By: Claude Opus 5.5 --- docs/superpowers/plans/2026-11-15-aws-fargate-workspaces.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-11-15-aws-fargate-workspaces.md b/docs/superpowers/plans/2026-11-15-aws-fargate-workspaces.md index 29c89b2..26f5fd4 100644 --- a/docs/superpowers/plans/2026-11-15-aws-fargate-workspaces.md +++ b/docs/superpowers/plans/2026-11-15-aws-fargate-workspaces.md @@ -30,7 +30,7 @@ - No project data in a task definition's `environment`, tags or `overrides` (P1). Secrets only by `valueFrom` ARN. - Container name `workspace`; agent port `host::ssm::AGENT_PORT` (47900); task runs with `enableExecuteCommand: true`, `initProcessEnabled: true`, cap add `SYS_PTRACE` only. - Health check exactly as the spec: `curl -sf --max-time 3 -H "Authorization: Bearer $(cat /dev/shm/triple-c/agent-token)" http://127.0.0.1:47900/v1/health`, interval 30, retries 3, startPeriod 120. -- Fargate CPU/memory pairs are AWS's valid table (256: 512–2048; 512: 1024–4096; 1024: 2048–8192; 2048: 4096–16384 in 1024 steps; 4096: 8192–30720 in 1024 steps; 8192: 16384–61440 in 4096 steps; 16384: 32768–122880 in 8192 steps). Ephemeral 21–200 GiB. +- Fargate CPU/memory pairs are AWS's valid table (256: 512, 1024 or 2048; 512: 1024–4096; 1024: 2048–8192; 2048: 4096–16384 in 1024 steps; 4096: 8192–30720 in 1024 steps; 8192: 16384–61440 in 4096 steps; 16384: 32768–122880 in 8192 steps). Ephemeral 21–200 GiB. - Boundary tests in `host/mod.rs` still pass: no bollard or Docker lifecycle outside `host/`/`docker/`. - **Change a policy JSON, its checks and `aws/README.md` together**; every policy ≤ 6,144 characters; re-run the live tests after a policy change. - New Tauri commands: `#[tauri::command]` + `generate_handler!` + `allow-*` in `capabilities/default.json`. -- 2.52.0 From 1eed994da73b0f0cecd8280a1a5aa71008140e72 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 10:46:59 -0800 Subject: [PATCH 20/43] fix(aws): tag access points at create against their file system EFS authorizes the elasticfilesystem:TagResource that a tagged CreateAccessPoint needs against the parent file system's ARN, not the new access point's: a workspace user's first Hybrid start was denied ("not authorized to perform: elasticfilesystem:TagResource on the specified resource"). Verified live with a probe role: access-point/* is denied; file-system/* is allowed, as is * with each of elasticfilesystem:CreateAction, aws:RequestTag and aws:TagKeys. TagOwnAccessPointsAtCreate now names file-system/* with the same conditions (CreateAction = CreateAccessPoint, owner request tag, the three tag keys); its check and owner-scoped test follow. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/policies.rs | 6 ++++-- aws/README.md | 5 ++++- aws/policies/workspace-user.json | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src-tauri/src/aws/policies.rs b/app/src-tauri/src/aws/policies.rs index 40aed77..d0ac553 100644 --- a/app/src-tauri/src/aws/policies.rs +++ b/app/src-tauri/src/aws/policies.rs @@ -362,9 +362,11 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[ (COMPONENT_RESOURCE_TAG, Literal("user-onboarding")), ], ), + // Authorized against the parent file system, not the new access point + // (verified live, phase 5). write( "elasticfilesystem:TagResource", - PROBE_ACCESS_POINT, + PROBE_FILE_SYSTEM, &[ (OWNER_REQUEST_TAG, OwnerId), ( @@ -993,7 +995,7 @@ mod tests { ), ( "elasticfilesystem:TagResource", - "access-point/", + "file-system/", OWNER_REQUEST_TAG, ), ( diff --git a/aws/README.md b/aws/README.md index d250679..f436609 100644 --- a/aws/README.md +++ b/aws/README.md @@ -81,7 +81,10 @@ following: their owner ID, and create them only on **their own** file system: one tagged `triple-c:component` (their onboarding module tags it `user-onboarding`) *and* `triple-c:owner = ${aws:userid}`. Each user has their own file system (strict tenancy), so - another user's file system is refused even though it is Triple-C's. + another user's file system is refused even though it is Triple-C's. The `TagResource` that a + tagged `CreateAccessPoint` needs is authorized against the **file system**, not the new access + point (verified live, phase 5), so `TagOwnAccessPointsAtCreate` names `file-system/*` and is + confined by `elasticfilesystem:CreateAction = CreateAccessPoint`. - **Set only the ordinary tag keys.** Every request that tags something may set only `triple-c:owner`, `triple-c:project` and `triple-c:environment` (`aws:TagKeys`). This applies to `RunTask`, the ECS and EFS tag-on-create calls, `CreateSecret`, and secret `TagResource`. A diff --git a/aws/policies/workspace-user.json b/aws/policies/workspace-user.json index 230c11d..9f1d9ac 100644 --- a/aws/policies/workspace-user.json +++ b/aws/policies/workspace-user.json @@ -254,7 +254,7 @@ "elasticfilesystem:TagResource" ], "Resource": [ - "arn:aws:elasticfilesystem:*:*:access-point/*" + "arn:aws:elasticfilesystem:*:*:file-system/*" ], "Condition": { "StringEquals": { -- 2.52.0 From e901a08ab6a55fc671ccaf60075f07ff97541b48 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 11:30:33 -0800 Subject: [PATCH 21/43] test(aws): live Fargate workspace lifecycle aws::live_tests::environment::a_fargate_workspace_round_trips (ignored): a public environment deployed by the environment admin, a role holding only the workspace-user files onboarded to it and driving ProjectHost::Aws through a profile connection: start (Hybrid, 1 vCPU / 2 GB, one public repo), exec, background clone, ~/.claude persistence across stop/start, attach without a second RunTask, the stop guard's dry run, the SIGTERM WIP path, idle stop at 5 minutes, bubblewrap, denials on another owner's revision and secret, and project removal. TRIPLEC_E2E_IMAGE names the image; a private ECR image gets a test-only pull policy on the exec role. A guard stops tasks, retires revisions, deletes access points and the secret before the fixture destroys the modules. aws/README.md gains the phase 5 results; the spec's To verify items are answered. Open: DeregisterTaskDefinition is authorized against * only, so the workspace tier cannot retire revisions (the test's last assertion fails until that is decided). Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/live_tests.rs | 595 +++++++++++++++++- aws/README.md | 54 +- .../specs/2026-11-13-aws-ecs-host-design.md | 16 +- 3 files changed, 647 insertions(+), 18 deletions(-) diff --git a/app/src-tauri/src/aws/live_tests.rs b/app/src-tauri/src/aws/live_tests.rs index 205c972..cb2ab3c 100644 --- a/app/src-tauri/src/aws/live_tests.rs +++ b/app/src-tauri/src/aws/live_tests.rs @@ -1017,7 +1017,8 @@ mod environment { use aws_config::SdkConfig; - use super::{e2e, ok, E2e, Role}; + use super::{classify_sdk_error, e2e, ok, E2e, Role}; + use futures_util::FutureExt; use crate::aws::environments::{ ensure_named_state_bucket, is_empty_state, scan_in_bucket, EnvironmentAction, EnvironmentState, Knowable, OnboardingState, SdkDetectApi, @@ -1715,4 +1716,596 @@ mod environment { }) .await; } + + // ─────────────────────────────────────────────── phase 5: a workspace + + /// What the phase-5 test made outside the modules, for its own guard + /// (which runs before the environment fixture's destroy). + #[derive(Default)] + struct Phase5 { + /// The onboarded execution role holding the test-only ECR policy. + exec_role: Option, + cluster: Option, + /// Task definition family prefixes to retire (`triple-c--`). + families: Vec, + /// The user's file system's creation token. + fs_token: Option, + secrets: Vec, + } + + const ECR_POLICY: &str = "triple-c-e2e-ecr-pull"; + + /// As the account admin: stop every task in the cluster and wait for + /// them, retire the test's task definitions, delete the user's access + /// points and the project secret (no recovery window), and take the + /// test-only ECR policy off the execution role, so the modules' + /// `tofu destroy` can remove the rest. Logs, never fails. + async fn phase5_cleanup(e: &E2e, p: &Phase5) { + let ecs = aws_sdk_ecs::Client::new(&e.cfg); + if let Some(cluster) = &p.cluster { + let mut arns = Vec::new(); + for status in [aws_sdk_ecs::types::DesiredStatus::Running, aws_sdk_ecs::types::DesiredStatus::Pending] { + if let Ok(out) = ecs.list_tasks().cluster(cluster).desired_status(status).send().await { + arns.extend(out.task_arns().iter().cloned()); + } + } + for arn in &arns { + eprintln!("cleanup: stopping task {arn}"); + if let Err(err) = ecs.stop_task().cluster(cluster).task(arn).reason("triple-c-e2e cleanup").send().await { + eprintln!("cleanup: stop {arn}: {err:?}"); + } + } + // Until none is left running (a stop takes up to stopTimeout). + for _ in 0..40 { + let mut live = 0; + if !arns.is_empty() { + if let Ok(out) = ecs.describe_tasks().cluster(cluster).set_tasks(Some(arns.clone())).send().await { + live = out.tasks().iter().filter(|t| t.last_status() != Some("STOPPED")).count(); + } + } + if live == 0 { + break; + } + tokio::time::sleep(Duration::from_secs(10)).await; + } + } + for prefix in &p.families { + let mut defs = Vec::new(); + for status in [aws_sdk_ecs::types::TaskDefinitionStatus::Active, aws_sdk_ecs::types::TaskDefinitionStatus::Inactive] { + let mut next = None; + loop { + let Ok(out) = ecs + .list_task_definitions() + .family_prefix(prefix) + .status(status.clone()) + .set_next_token(next.take()) + .send() + .await + else { + break; + }; + defs.extend(out.task_definition_arns().iter().cloned()); + next = out.next_token().map(str::to_string); + if next.is_none() { + break; + } + } + } + for arn in &defs { + let _ = ecs.deregister_task_definition().task_definition(arn).send().await; + } + for chunk in defs.chunks(10) { + if let Err(err) = ecs.delete_task_definitions().set_task_definitions(Some(chunk.to_vec())).send().await { + eprintln!("cleanup: delete task definitions: {err:?}"); + } + } + if !defs.is_empty() { + eprintln!("cleanup: retired {} task definition revision(s) of {prefix}*", defs.len()); + } + } + if let Some(token) = &p.fs_token { + let efs = aws_sdk_efs::Client::new(&e.cfg); + if let Ok(out) = efs.describe_file_systems().creation_token(token).send().await { + for fs in out.file_systems() { + if let Ok(aps) = efs.describe_access_points().file_system_id(fs.file_system_id()).send().await { + for ap in aps.access_points() { + let id = ap.access_point_id().unwrap_or_default(); + eprintln!("cleanup: deleting access point {id}"); + if let Err(err) = efs.delete_access_point().access_point_id(id).send().await { + eprintln!("cleanup: delete access point {id}: {err:?}"); + } + } + } + } + } + } + for name in &p.secrets { + match e.secrets.delete_secret().secret_id(name).force_delete_without_recovery(true).send().await { + Ok(_) => eprintln!("cleanup: deleted secret {name} (no recovery window)"), + Err(err) => { + let err = classify_sdk_error("secretsmanager:DeleteSecret", &err); + if !err.to_string().contains("ResourceNotFound") { + eprintln!("cleanup: delete secret {name}: {err}"); + } + } + } + } + if let Some(role) = &p.exec_role { + match e.iam.delete_role_policy().role_name(role).policy_name(ECR_POLICY).send().await { + Ok(_) => eprintln!("cleanup: removed {ECR_POLICY} from {role}"), + Err(err) => { + if !format!("{err:?}").contains("NoSuchEntity") { + eprintln!("cleanup: remove {ECR_POLICY} from {role}: {err:?}"); + } + } + } + } + } + + /// Poll `f` every `every` until it returns `Some`, or `None` after `within`. + async fn poll(within: Duration, every: Duration, mut f: F) -> Option + where + F: FnMut() -> Fut, + Fut: std::future::Future>, + { + let deadline = std::time::Instant::now() + within; + loop { + if let Some(v) = f().await { + return Some(v); + } + if std::time::Instant::now() + every > deadline { + return None; + } + tokio::time::sleep(every).await; + } + } + + /// `sh -c script` as `user`: (output, exit code). + async fn sh(t: &crate::host::ContainerTarget, user: &str, script: &str) -> (String, i64) { + t.exec_oneshot_as(user, vec!["sh".into(), "-c".into(), script.into()], Vec::new()) + .await + .unwrap_or_else(|err| panic!("exec `{script}`: {err}")) + } + + /// Phase 5: a Fargate workspace's whole life through `ProjectHost::Aws`, + /// as a role holding **only** the workspace-user files, in an + /// environment the environment admin deployed and onboarded it to. + /// + /// ```sh + /// cd app/src-tauri + /// cargo build + /// TRIPLEC_AWS_E2E_PROFILE=… TRIPLEC_AWS_E2E_REGION=us-west-2 \ + /// TRIPLEC_E2E_IMAGE=.dkr.ecr..amazonaws.com/triple-c-e2e-sandbox: \ + /// cargo test aws::live_tests::environment::a_fargate_workspace_round_trips \ + /// -- --ignored --test-threads=1 --nocapture + /// ``` + /// + /// `TRIPLEC_E2E_IMAGE` is the image the project names as its Custom + /// image: one built from this branch (`docker build --platform + /// linux/amd64 ./container`) and pushed to a registry Fargate can pull. + /// For a private ECR repository in the test account, the test gives the + /// onboarded execution role a **test-only** inline pull policy + /// (`triple-c-e2e-ecr-pull`; the boundary allows ECR pulls, the + /// onboarding module grants none) and removes it before the onboarding + /// is destroyed. Creating and deleting that repository is the runner's + /// job (`aws/README.md`, "Live verification"). + /// + /// The workspace user signs in the way a saved profile connection does: + /// a profile (`role_arn` + `source_profile` = the e2e profile, session + /// name `triple-c-e2e`) in a config file this test writes and points + /// `AWS_CONFIG_FILE` at. About 30 Fargate task-minutes at 1 vCPU / 2 GB. + #[tokio::test] + #[ignore] + async fn a_fargate_workspace_round_trips() { + use crate::aws::runtime::SdkRuntimeApi; + use crate::aws::tasks::{run_task_request, task_log_stream, SdkTaskApi, TaskApi}; + use crate::aws::tofu_modules::{ + environment_cluster_name, log_stream_prefix, project_secret_name, task_family, + user_file_system_token, + }; + use crate::aws::workspace::workspace_tags; + use crate::host::{lifecycle::ProjectHost, tunnels}; + use crate::models::app_settings::ImageSource; + use crate::models::project::{AwsPlacement, AwsWorkspaceConfig, RepoSource, StorageMode}; + use crate::models::{AppSettings, Project}; + use crate::storage::projects_store::ProjectsStore; + + let image = super::var("TRIPLEC_E2E_IMAGE"); + env_e2e(|t| async move { + let p5 = std::sync::Mutex::new(Phase5::default()); + let body = async { + let region = t.e.region.clone(); + let admin = t.admin().await; + let (keys, cfg, who) = t.session(&admin).await; + t.ensure_bucket(&cfg, &who).await; + t.ensure_boundary().await; + + // ── the environment, deployed by the environment admin ── + let name = format!("e2e{}", &uuid::Uuid::new_v4().simple().to_string()[..6]); + p5.lock().unwrap().cluster = Some(environment_cluster_name(&name)); + p5.lock().unwrap().families.push(format!("triple-c-{name}-")); + let vars = environment_vars(&name, ®ion, &NetworkChoice::Public).unwrap(); + let (_, outcome, denied) = t + .run(ModuleKind::Environment, Deployment::Environment(&name), &vars, TofuOp::Apply, t.keys_env(&keys)) + .await; + assert_ok("deploy", &outcome, &denied); + + // ── the workspace user: only the workspace files ── + let user = t.e.tier_role("wsuser", &[Tier::WorkspaceUser]).await; + let owner = validate_owner_id(&user.session_user_id()).expect("owner id"); + let okey = owner_key(®ion, &owner); + p5.lock().unwrap().fs_token = Some(user_file_system_token(&name, &okey)); + + // Onboarded by the admin (an onboarding from an admin bundle). + let (keys, _, _) = t.session(&admin).await; + let (_, outcome, denied) = t + .run( + ModuleKind::UserOnboard, + Deployment::User { env: &name, owner_key: &okey }, + &onboarding_vars(&name, ®ion, &owner), + TofuOp::Apply, + t.keys_env(&keys), + ) + .await; + assert_ok("onboard the workspace user", &outcome, &denied); + let (_, exec_role) = user_role_names(&name, &okey); + + // Test-only: pull from the throwaway private ECR repository. + if let Some((registry, rest)) = image.split_once('/') { + if registry.contains(".dkr.ecr.") { + let repo = rest.split([':', '@']).next().unwrap(); + let doc = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Action":"ecr:GetAuthorizationToken","Resource":"*"}},{{"Effect":"Allow","Action":["ecr:BatchGetImage","ecr:GetDownloadUrlForLayer","ecr:BatchCheckLayerAvailability"],"Resource":"arn:aws:ecr:{region}:{}:repository/{repo}"}}]}}"#, + t.e.account + ); + p5.lock().unwrap().exec_role = Some(exec_role.clone()); + ok( + "put the test-only ECR policy", + t.e.iam.put_role_policy().role_name(&exec_role).policy_name(ECR_POLICY).policy_document(doc).send().await, + ); + eprintln!("test-only: {ECR_POLICY} on {exec_role} for {repo}"); + } + } + + // The workspace user's profile connection. + let profile = format!("{}-wsuser", t.e.prefix); + let cfg_file = t.root.join(format!("{}-config", t.e.prefix)); + std::fs::write( + &cfg_file, + format!( + "[profile {profile}]\nrole_arn = {}\nsource_profile = {}\nrole_session_name = {}\n", + user.arn, + super::var("TRIPLEC_AWS_E2E_PROFILE"), + super::SESSION + ), + ) + .unwrap(); + std::env::set_var("AWS_CONFIG_FILE", &cfg_file); + let conn = HostConnection { + id: "e2e-wsuser".into(), + name: "e2e-wsuser".into(), + kind: HostKind::Aws { + auth: AwsAuth::Profile { name: profile.clone() }, + default_region: region.clone(), + }, + }; + let ws_cfg = crate::aws::sdk_config(&conn, ®ion).await.expect("workspace user config"); + let ws = caller_identity(&ws_cfg).await.expect("workspace user identity"); + assert_eq!(ws.user_id, owner, "the profile session is the onboarded owner"); + eprintln!("workspace user: {}", ws.arn); + + let mut settings = AppSettings::default(); + settings.hosts = vec![conn]; + settings.image_source = ImageSource::Custom; + settings.custom_image_name = Some(image.clone()); + + let mut project = Project::new("e2e-fargate".into(), Vec::new()); + project.host_id = Some("e2e-wsuser".into()); + project.aws_placement = Some(AwsPlacement { region: region.clone(), environment: name.clone() }); + project.aws = Some(AwsWorkspaceConfig { + cpu: 1024, + memory_mib: 2048, + storage: StorageMode::Hybrid, + repos: vec![RepoSource { + url: "https://github.com/octocat/Hello-World.git".into(), + branch: None, + folder: "Hello-World".into(), + }], + wip_push_on_stop: true, + // On from the first start: turning it on later adds a + // secret key, so the next start registers a new revision. + // The test's own execs keep task 1 busy until its stop. + idle_stop_minutes: Some(5), + ..Default::default() + }); + let id = project.id.clone(); + p5.lock().unwrap().secrets.push(project_secret_name(&name, &okey, &id)); + let store_dir = tempfile::tempdir().unwrap(); + let store = ProjectsStore::over_file_for_test(store_dir.path().join("projects.json"), vec![project.clone()]); + let host = ProjectHost::for_project(&project); + assert_eq!(host, ProjectHost::Aws); + let progress = |m: &str| eprintln!(" progress: {m}"); + let api = SdkTaskApi::new(&ws_cfg); + let env = crate::aws::runtime::read_environment(&SdkRuntimeApi::new(&ws_cfg), &name, &ws.account, ®ion) + .await + .expect("the environment record, as the workspace user"); + let family = task_family(&name, &okey, &id); + let stream_prefix = log_stream_prefix(&okey, &id); + let current = |store: &ProjectsStore| store.get(&id).unwrap(); + + // ── start 1 ── + let t0 = std::time::Instant::now(); + let target = host.ensure_started(¤t(&store), &settings, &store, &progress).await.expect("start 1"); + let first_start = t0.elapsed(); + let p = current(&store); + let aws = p.aws.clone().unwrap(); + let task1 = aws.task_arn.clone().expect("the task ARN is stored"); + let revision1 = aws.task_definition_arn.clone().expect("the task definition is stored"); + eprintln!("RESULT start 1 (cold: register, access point, RunTask, pull, agent, tunnel): {first_start:?}; task {task1}; {revision1}"); + assert!(revision1.contains(&format!(":task-definition/{family}:")), "{revision1}"); + assert!(aws.access_point_id.is_some(), "Hybrid has an access point"); + + // ECS's own health check (startPeriod 120 s, interval 30 s). + let health = poll(Duration::from_secs(300), Duration::from_secs(10), || async { + let d = api.describe(&env.cluster_arn, std::slice::from_ref(&task1)).await.ok()?; + let h = d.first()?.health.clone(); + (h.as_deref() == Some("HEALTHY")).then_some(()) + }) + .await; + eprintln!("RESULT ECS health HEALTHY after {:?} from RunTask's start ({health:?})", t0.elapsed()); + assert!(health.is_some(), "the task never became HEALTHY"); + + // Resource-level checks, as the account admin: the revision and + // the task carry exactly the three tags. + let ecs_admin = aws_sdk_ecs::Client::new(&t.e.cfg); + let td = ok( + "describe the revision", + ecs_admin.describe_task_definition().task_definition(&revision1).include(aws_sdk_ecs::types::TaskDefinitionField::Tags).send().await, + ); + let mut tags: Vec<(String, String)> = td + .tags() + .iter() + .map(|t| (t.key().unwrap_or_default().to_string(), t.value().unwrap_or_default().to_string())) + .collect(); + tags.sort(); + let mut want = workspace_tags(&owner, &id, &name); + want.sort(); + assert_eq!(tags, want, "RegisterTaskDefinition tagged at registration"); + eprintln!("RESULT RegisterTaskDefinition as the workspace user on task-definition/triple-c-*: allowed, tagged {tags:?}"); + eprintln!("RESULT RunTask (owner-tagged revision, iam:PassRole by tag on the user's own roles): allowed"); + + // ── through the agent ── + let (out, code) = sh(&target, "claude", "echo hello-from-fargate").await; + assert_eq!((out.trim(), code), ("hello-from-fargate", 0)); + let (id_out, _) = sh(&target, "claude", "id").await; + eprintln!("RESULT id: {}", id_out.trim()); + assert!(id_out.contains("uid=1000(claude)"), "{id_out}"); + + let cloned = poll(Duration::from_secs(300), Duration::from_secs(5), || async { + let (out, code) = sh(&target, "root", "test -f /run/triple-c-clone/done && cat /run/triple-c-clone/repos").await; + (code == 0).then_some(out) + }) + .await + .expect("the background clone never finished"); + eprintln!("RESULT clone status: {}", cloned.trim()); + let (head, code) = sh(&target, "claude", "git -C /workspace/Hello-World log -1 --format=%H").await; + assert_eq!(code, 0, "{head}"); + eprintln!("RESULT cloned Hello-World at {}", head.trim()); + + let (links, _) = sh( + &target, + "claude", + "for f in ~/.claude ~/.claude.json ~/.ssh; do printf '%s -> %s\n' \"$f\" \"$(readlink \"$f\")\"; done; \ + grep ' /mnt/triple-c-home ' /proc/mounts; stat -c '%U:%G %a' /mnt/triple-c-home", + ) + .await; + eprintln!("RESULT Hybrid home:\n{}", links.trim()); + assert!(links.contains(".claude -> /mnt/triple-c-home/"), "{links}"); + let marker = uuid::Uuid::new_v4().to_string(); + let (_, code) = sh(&target, "claude", &format!("printf %s {marker} > ~/.claude/marker")).await; + assert_eq!(code, 0); + + // Claude Code's own writes to ~/.claude.json (no login: it + // still starts, and fails at the API call). + let (claude, _) = sh( + &target, + "claude", + "cd /workspace && timeout 90 claude -p 'say ok' &1 | tail -3; \ + test -L ~/.claude.json && echo STILL-A-LINK || echo REPLACED; ls -la ~/.claude.json", + ) + .await; + eprintln!("RESULT claude run:\n{}", claude.trim()); + + // P9: bubblewrap's unprivileged user namespace on Fargate. + let (bwrap, code) = sh( + &target, + "claude", + "bwrap --ro-bind / / --unshare-user true 2>&1; echo \"unshare-user rc=$?\"; \ + bwrap --ro-bind / / --dev /dev --proc /proc --unshare-all --die-with-parent true 2>&1; echo \"unshare-all rc=$?\"; \ + cat /proc/sys/user/max_user_namespaces 2>&1; cat /proc/sys/kernel/unprivileged_userns_clone 2>&1; uname -r", + ) + .await; + eprintln!("RESULT P9 bwrap (exit {code}):\n{}", bwrap.trim()); + + // The stop guard's dry run through the agent, on a dirty repo. + let (_, code) = sh(&target, "claude", "echo e2e >> /workspace/Hello-World/README && touch /workspace/Hello-World/e2e-new").await; + assert_eq!(code, 0); + let (raw, code) = sh(&target, "claude", "/usr/local/bin/triple-c-wip-push --dry-run").await; + eprintln!("RESULT triple-c-wip-push --dry-run (exit {code}): {}", raw.trim()); + let unsaved = crate::commands::stop_guard::unsaved_work(Some(&target), crate::commands::stop_guard::DRY_RUN_TIMEOUT).await; + eprintln!("RESULT stop guard check: {unsaved:?}"); + assert!(unsaved.checked, "{unsaved:?}"); + assert!(unsaved.repos.iter().any(|r| r.folder == "Hello-World" && r.dirty), "{unsaved:?}"); + + // GetSecretValue: another owner's triple-c/* secret is denied. + let foreign_secret = format!("triple-c/{}/someone-else", t.e.prefix); + let created = ok( + "admin creates another owner's secret", + t.e.secrets + .create_secret() + .name(&foreign_secret) + .secret_string("e2e") + .tags(super::sm_tag("triple-c:owner", "AROAEXAMPLEOTHER:bob")) + .send() + .await, + ); + t.e.record_secret(created.arn().unwrap()); + let ws_secrets = aws_sdk_secretsmanager::Client::new(&ws_cfg); + super::denied( + "GetSecretValue on another owner's secret", + ws_secrets.get_secret_value().secret_id(created.arn().unwrap()).send().await, + ); + eprintln!("RESULT GetSecretValue on another owner's secret: denied"); + + // RunTask on a revision the user does not own is denied. + let foreign_family = format!("triple-c-{name}-e2e-foreign"); + let reg = ok( + "admin registers a foreign revision", + ecs_admin + .register_task_definition() + .family(&foreign_family) + .requires_compatibilities(aws_sdk_ecs::types::Compatibility::Fargate) + .network_mode(aws_sdk_ecs::types::NetworkMode::Awsvpc) + .cpu("256") + .memory("512") + .container_definitions( + aws_sdk_ecs::types::ContainerDefinition::builder() + .name("x") + .image("public.ecr.aws/docker/library/alpine:3") + .essential(true) + .build(), + ) + .tags(aws_sdk_ecs::types::Tag::builder().key("triple-c:owner").value("AROAEXAMPLEOTHER:bob").build()) + .send() + .await, + ); + let foreign_arn = reg.task_definition().unwrap().task_definition_arn().unwrap().to_string(); + let r = api.run_task(run_task_request(&env, &foreign_arn, false, &workspace_tags(&owner, &id, &name))).await; + match r { + Err(crate::aws::error::AwsError::AccessDenied { .. }) => { + eprintln!("RESULT RunTask on another owner's revision: denied") + } + other => panic!("RunTask on another owner's revision: expected AccessDenied, got {other:?}"), + } + + // A second start while running: an attach, no second RunTask. + // With the tunnel up it is reused outright; with it closed the + // start finds the live task and reads the token back (own + // GetSecretValue). + let again = host.ensure_started(¤t(&store), &settings, &store, &progress).await.expect("start while running (tunnel up)"); + assert_eq!(again.id(), target.id()); + tunnels::close(&id).await; + let t1 = std::time::Instant::now(); + let attached = host.ensure_started(¤t(&store), &settings, &store, &progress).await.expect("attach"); + eprintln!("RESULT attach to the running task (find, GetSecretValue own secret, tunnel): {:?}", t1.elapsed()); + assert_eq!(current(&store).aws.unwrap().task_arn.as_deref(), Some(task1.as_str())); + let live = api.list_project_tasks(&env.cluster_arn, &family).await.unwrap(); + assert_eq!(live, vec![task1.clone()], "exactly one task: no second RunTask"); + let (out, _) = sh(&attached, "claude", "cat ~/.claude/marker").await; + assert_eq!(out.trim(), marker); + + // ── stop 1: StopTask → SIGTERM → the entrypoint's WIP push ── + let t2 = std::time::Instant::now(); + host.stop_project(¤t(&store), &settings).await.expect("stop 1"); + let stopped = poll(Duration::from_secs(240), Duration::from_secs(5), || async { + let d = api.describe(&env.cluster_arn, std::slice::from_ref(&task1)).await.ok()?; + let t = d.into_iter().next()?; + (t.last_status == "STOPPED").then_some(t) + }) + .await + .expect("task 1 never stopped"); + eprintln!("RESULT stop 1: STOPPED after {:?}; code {:?}, reason {:?}", t2.elapsed(), stopped.stop_code, stopped.stopped_reason); + tokio::time::sleep(Duration::from_secs(15)).await; + let log1 = api.log_tail(&env.log_group, &task_log_stream(&stream_prefix, &task1), 60).await.unwrap(); + let tail1: Vec<&String> = log1.iter().filter(|l| l.contains("entrypoint: stopping") || l.contains("wip") || l.contains("WIP")).collect(); + eprintln!("RESULT task 1 stop log lines: {tail1:#?}"); + assert!(log1.iter().any(|l| l.contains("entrypoint: stopping (sigterm) — pushing work in progress")), "{log1:#?}"); + store.set_aws_runtime(&id, None, current(&store).aws.unwrap().access_point_id, Some(revision1.clone())).unwrap(); + + // ── start 2, unchanged; idle stop is at its minimum ── + let t3 = std::time::Instant::now(); + let target2 = host.ensure_started(¤t(&store), &settings, &store, &progress).await.expect("start 2"); + let aws2 = current(&store).aws.unwrap(); + let task2 = aws2.task_arn.clone().unwrap(); + eprintln!("RESULT start 2 (same revision): {:?}; task {task2}", t3.elapsed()); + assert_ne!(task2, task1, "a new task"); + assert_eq!(aws2.task_definition_arn.as_deref(), Some(revision1.as_str()), "same family revision"); + let (out, _) = sh(&target2, "claude", "cat ~/.claude/marker; test -L ~/.claude.json && echo STILL-A-LINK").await; + eprintln!("RESULT after restart: {}", out.trim()); + assert!(out.contains(&marker), "the ~/.claude marker persisted: {out}"); + poll(Duration::from_secs(300), Duration::from_secs(5), || async { + let (_, code) = sh(&target2, "root", "test -f /run/triple-c-clone/done").await; + (code == 0).then_some(()) + }) + .await + .expect("clone 2 never finished"); + let (check, _) = sh(&target2, "root", "TRIPLEC_IDLE_STOP_MINUTES=5 /usr/local/bin/triple-c-idle-watch --check; echo idle-check=$?").await; + eprintln!("RESULT idle --check right after use: {}", check.trim()); + + // Leave it alone: close the tunnel and watch ECS. + tunnels::close(&id).await; + let idle_from = std::time::Instant::now(); + let stopped2 = poll(Duration::from_secs(15 * 60), Duration::from_secs(20), || async { + let d = api.describe(&env.cluster_arn, std::slice::from_ref(&task2)).await.ok()?; + let t = d.into_iter().next()?; + (t.desired_status == "STOPPED" || t.last_status == "STOPPED").then_some(t) + }) + .await + .expect("the idle task did not stop by itself within 15 minutes"); + eprintln!( + "RESULT idle stop: desired STOPPED {:?} after the last use; code {:?}, reason {:?}", + idle_from.elapsed(), + stopped2.stop_code, + stopped2.stopped_reason + ); + poll(Duration::from_secs(240), Duration::from_secs(10), || async { + let d = api.describe(&env.cluster_arn, std::slice::from_ref(&task2)).await.ok()?; + (d.first()?.last_status == "STOPPED").then_some(()) + }) + .await; + tokio::time::sleep(Duration::from_secs(15)).await; + let log2 = api.log_tail(&env.log_group, &task_log_stream(&stream_prefix, &task2), 60).await.unwrap(); + let tail2: Vec<&String> = log2.iter().filter(|l| l.contains("stopping") || l.contains("idle")).collect(); + eprintln!("RESULT task 2 stop log lines: {tail2:#?}"); + assert!(log2.iter().any(|l| l.contains("entrypoint: stopping (idle)")), "{log2:#?}"); + + // ── remove: the project's resources, data discarded ── + store.set_aws_runtime(&id, None, aws2.access_point_id.clone(), aws2.task_definition_arn.clone()).unwrap(); + // Every step runs even when one fails (the failures are + // reported together), so check them all before the result. + let removed = host.remove_aws_resources(¤t(&store), &settings, true).await; + eprintln!("RESULT remove_aws_resources: {:?}", removed.as_ref().map_err(|e| e.chars().take(400).collect::())); + let sm = ok( + "describe the project secret", + t.e.secrets.describe_secret().secret_id(project_secret_name(&name, &okey, &id)).send().await, + ); + eprintln!("RESULT project secret after remove: deleted_date {:?}", sm.deleted_date()); + assert!(sm.deleted_date().is_some(), "the secret is scheduled for deletion"); + let defs = ok( + "list the family", + ecs_admin.list_task_definitions().family_prefix(&family).send().await, + ); + eprintln!("RESULT active revisions after remove: {:?}", defs.task_definition_arns()); + let td = ecs_admin.describe_task_definition().task_definition(&revision1).send().await; + eprintln!( + "RESULT revision after remove: {:?}", + td.map(|o| o.task_definition().and_then(|d| d.status().cloned())).map_err(|e| format!("{e:?}").chars().take(200).collect::()) + ); + let efs = aws_sdk_efs::Client::new(&t.e.cfg); + let gone = efs.describe_access_points().access_point_id(aws2.access_point_id.clone().unwrap()).send().await; + eprintln!("RESULT access point after remove: {:?}", gone.as_ref().map(|o| o.access_points().len()).map_err(|e| format!("{e:?}").chars().take(120).collect::())); + assert!(gone.is_err() || gone.unwrap().access_points().iter().all(|a| a.life_cycle_state() != Some(&aws_sdk_efs::types::LifeCycleState::Available))); + removed.expect("remove the project's resources"); + assert!(defs.task_definition_arns().is_empty(), "active revisions left: {:?}", defs.task_definition_arns()); + }; + let result = std::panic::AssertUnwindSafe(body).catch_unwind().await; + let guard = std::mem::take(&mut *p5.lock().unwrap()); + phase5_cleanup(&t.e, &guard).await; + if let Err(panic) = result { + std::panic::resume_unwind(panic); + } + }) + .await; + } } + diff --git a/aws/README.md b/aws/README.md index f436609..38d56dd 100644 --- a/aws/README.md +++ b/aws/README.md @@ -525,21 +525,49 @@ asserts that every role the admin creates carries the boundary. Still open: -- **`ecs:RegisterTaskDefinition` and resource-level permissions (phase 5, unverified).** The - statements in `workspace-user-tasks.json` scope the action to `task-definition/triple-c-*`, but - whether ECS authorizes a register call against that ARN (the family is known, the revision is - not yet) or only against `*` has not been checked on real IAM; the unit tests' evaluator only - proves the JSON means what it says. The `aws:RequestTag` and `aws:TagKeys` conditions are - documented for the action either way. Probe, as a role holding the workspace files: register a - `triple-c-e2e-*` definition tagged with the owner and the two other keys. If it is denied - while the policy simulator allows it, change `Resource` of `RegisterOwnTaskDefinitions` to - `"*"` (the request-tag conditions still confine it; the family name is then unenforced) and - record that here. Also confirm `ecs:TagResource` with `ecs:CreateAction = RegisterTaskDefinition` - tags at registration, that `DeregisterTaskDefinition`/`DeleteTaskDefinitions` honour - `aws:ResourceTag`, and that a user can read their own secret only (`live_tests.rs` checks the - secret read); +- **Deregistering task definitions (phase 5): see below; the workspace tier cannot do it yet.** - the `existing` network mode against a user's own VPC, and an onboarding by a workspace user from an admin bundle (the same module, applied with admin credentials); - `DisassociateGoneAddresses` cannot touch a **live** association. Probe: allocate an untagged Elastic IP, associate it with a test ENI, and call `ec2:DisassociateAddress` on that association as the environment admin; it must be denied (then release both). + +### Phase 5: a Fargate workspace (2026-11-15) + +```sh +cd app/src-tauri +cargo build +TRIPLEC_AWS_E2E_PROFILE= TRIPLEC_AWS_E2E_REGION=us-west-2 \ +TRIPLEC_E2E_IMAGE=.dkr.ecr..amazonaws.com/triple-c-e2e-sandbox: \ + cargo test aws::live_tests::environment::a_fargate_workspace_round_trips -- --ignored --test-threads=1 --nocapture +``` + +The image is this branch's `container/` (`docker build --platform linux/amd64`), pushed to a +throwaway private ECR repository `triple-c-e2e-sandbox` that the runner creates and deletes. +**Test only:** the onboarding grants the execution role no ECR permissions (the boundary allows +pulls), so the test puts an inline `triple-c-e2e-ecr-pull` policy on it and removes it before the +onboarding is destroyed. The test deploys a `public` environment as the environment admin, onboards +a role that holds **only** the workspace files, and drives `ProjectHost::Aws` as that role through +a profile connection (`role_arn` + `source_profile`): Hybrid, 1 vCPU / 2 GB, one public repo, idle +stop 5 minutes. Its guard stops every task, retires the test's revisions, deletes the access points +and the secret and removes the ECR policy; then the fixture destroys the modules. + +| Point | Result | +|---|---| +| `RegisterTaskDefinition` on `task-definition/triple-c-*` | ✓ allowed for the workspace user with the owner request tag and the three keys; the revision carries exactly those tags (`TagResource` with `ecs:CreateAction`) | +| `RunTask`: owner tag on the revision, `iam:PassRole` by `iam:ResourceTag` | ✓ allowed on the user's revision with their own roles; on a revision tagged with another owner, denied | +| `DeregisterTaskDefinition` | ✗ **open**: ECS authorizes it against `*` only. `task-definition/triple-c-*`, and `*` with `aws:ResourceTag/triple-c:owner`, are both denied; only an unconditional `*` works (probe). Removing a project therefore fails on its revisions (the secret and the access point are still removed) | +| `DeleteTaskDefinitions` | ✓ (probe) authorized against the revision's ARN with `aws:ResourceTag`: the owner's tag passes, another owner's is denied | +| `CreateAccessPoint`'s `TagResource` | ✗→fixed: authorized against the **file system**, not the access point (probe: `access-point/*` denied; `file-system/*` allowed; `CreateAction`, `aws:RequestTag` and `aws:TagKeys` all present) | +| `GetSecretValue` | ✓ own secret (an attach reads the token back); another owner's `triple-c/*` secret denied | +| EFS access point with the task role (IAM + TLS) | ✓ mounted at `/mnt/triple-c-home` (`127.0.0.1:/ … nfs4`, the TLS tunnel), `claude:claude 700`; `~/.claude`, `~/.claude.json` and `~/.ssh` link into it | +| Times | first start (register, access point, RunTask, pull of ~1.2 GB, agent, tunnel) 104 s; ECS `HEALTHY` 135 s after the start began; attach to a running task 2 s; StopTask to `STOPPED` 42 s; restart 86–92 s | +| Attach | ✓ a second start while running reuses the task (one task in the family, same ARN, no RunTask) | +| Restart | ✓ new task, same revision; the `~/.claude` marker survived | +| Repo clone | ✓ the background clone finished (`/run/triple-c-clone/repos`: `Hello-World cloned`) | +| Stop guard and WIP push | ✓ `triple-c-wip-push --dry-run` reports the dirty repo; after StopTask the log shows `entrypoint: stopping (sigterm) — pushing work in progress...` and the push failing for lack of GitHub credentials. No push with real credentials was tried | +| Idle stop (5 min) | ✓ the task stopped itself 365 s after the last use (`triple-c-idle-watch: idle for 5 minutes`, `entrypoint: stopping (idle)`); ECS stop code `EssentialContainerExited` | +| `~/.claude.json` after Claude Code runs | ✓ still a link (Claude Code started without a login: "Not logged in") | +| Bubblewrap (P9) | ✗ `bwrap --unshare-user` fails: "No permissions to create new namespace" (kernel 6.1, `max_user_namespaces` 16384, no `CAP_SYS_ADMIN` on Fargate). The sandbox stays off on AWS | +| A revision per configuration | turning idle stop on adds a secret key, so the next start registers a new revision (by design) | +| Cleanup | ✓ nothing `triple-c` left in us-west-2; environment KMS keys pending deletion | diff --git a/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md b/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md index 10a0ee7..57f1e1d 100644 --- a/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md +++ b/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md @@ -658,11 +658,19 @@ TLS only, tagged `triple-c:component=state`), one object per deployment (D9). ## To verify before or during implementation -- Whether Fargate allows bubblewrap's unprivileged user namespaces, and what Claude Code's sandbox - does there. +- ~~Whether Fargate allows bubblewrap's unprivileged user namespaces, and what Claude Code's + sandbox does there.~~ **Measured 2026-11-15:** it doesn't. `bwrap --unshare-user` fails with "No + permissions to create new namespace" (no `CAP_SYS_ADMIN`), so the AWS host keeps Claude Code's + sandbox off. - ABAC condition keys for each action: ECS `RunTask` tag-on-create, `ExecuteCommand`, and `ssm:StartSession` against ECS targets; EFS `CreateAccessPoint` with request tags; Secrets Manager name-prefix plus tag conditions with `${aws:userid}` for SSO sessions. + **Phase 5 (2026-11-15, `aws/README.md` "Phase 5"):** `RunTask` with the owner's revision and + roles, `RegisterTaskDefinition` on `task-definition/triple-c-*`, `CreateAccessPoint` with request + tags and `GetSecretValue` on one's own secret work for a role session; another owner's revision + or secret is denied. `CreateAccessPoint`'s `TagResource` is authorized against the file system + (policy fixed). `DeregisterTaskDefinition` supports only `*` with no tag condition, which the + workspace tier does not grant: open. SSO sessions themselves were not used. - ~~That `session-manager-plugin` port forwarding to an ECS target is stable for hours-long sessions, and how quickly it reconnects.~~ **Measured 2026-11-14** (phase 2b, us-west-2, plugin 1.2.896, Fargate 1 vCPU / 2 GB, `host::ssm::live_tests`): @@ -749,8 +757,8 @@ detached runner, detection, self-onboarding, an update ("No changes"), destroy; file-system policy accepted the just-created task role; conditional `DeleteObject` (`If-Match`) on the versioned bucket returns 412 on a stale ETag. -Still to verify: the `existing` network mode against a user's own VPC, and Fargate tasks in the -deployed environment (phase 5). +Still to verify: the `existing` network mode against a user's own VPC. Fargate tasks in the +deployed environment were run in phase 5 (`aws/README.md`). ## Connection model -- 2.52.0 From 1700cc798578c226607abef96788869ff119d9b1 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 11:34:52 -0800 Subject: [PATCH 22/43] fix(aws): leave task definitions to admins on removal Ruling R8. ECS authorizes ecs:DeregisterTaskDefinition against * only, with no tag condition (verified live), so the workspace tier's RetireOwnTaskDefinitions could never match, and allowing it on * would let any user deregister any revision in the account. DeleteTaskDefinitions is resource-level but deletes only INACTIVE revisions, so it goes too. Removing an AWS project now stops the task, schedules the secret and (with discard) deletes the access point, and leaves the task definition revisions registered, saying so in the log: they hold no secret values, cost nothing, and RunOwnTasks still needs the owner tag. aws/README.md tells admins how to deregister them. The policy statement, its checks and owner-scoped rows, the RuntimeApi methods, the fakes, the removal UI copy and the live test's final assertion follow. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/live_tests.rs | 7 +- app/src-tauri/src/aws/policies.rs | 56 +++++------ app/src-tauri/src/aws/runtime.rs | 93 +++---------------- app/src-tauri/src/aws/runtime/tests.rs | 15 +-- app/src-tauri/src/host/aws_host/tests.rs | 6 -- .../projects/ConfirmRemoveModal.tsx | 5 +- .../projects/home/config/HostSection.tsx | 6 +- aws/README.md | 30 ++++-- aws/policies/workspace-user-tasks.json | 16 ---- .../specs/2026-11-13-aws-ecs-host-design.md | 4 +- 10 files changed, 73 insertions(+), 165 deletions(-) diff --git a/app/src-tauri/src/aws/live_tests.rs b/app/src-tauri/src/aws/live_tests.rs index cb2ab3c..a04b23b 100644 --- a/app/src-tauri/src/aws/live_tests.rs +++ b/app/src-tauri/src/aws/live_tests.rs @@ -2269,7 +2269,8 @@ mod environment { eprintln!("RESULT task 2 stop log lines: {tail2:#?}"); assert!(log2.iter().any(|l| l.contains("entrypoint: stopping (idle)")), "{log2:#?}"); - // ── remove: the project's resources, data discarded ── + // ── remove: the task, the secret and (data discarded) the access + // point; the task definition is left (R8) ── store.set_aws_runtime(&id, None, aws2.access_point_id.clone(), aws2.task_definition_arn.clone()).unwrap(); // Every step runs even when one fails (the failures are // reported together), so check them all before the result. @@ -2296,7 +2297,9 @@ mod environment { eprintln!("RESULT access point after remove: {:?}", gone.as_ref().map(|o| o.access_points().len()).map_err(|e| format!("{e:?}").chars().take(120).collect::())); assert!(gone.is_err() || gone.unwrap().access_points().iter().all(|a| a.life_cycle_state() != Some(&aws_sdk_efs::types::LifeCycleState::Available))); removed.expect("remove the project's resources"); - assert!(defs.task_definition_arns().is_empty(), "active revisions left: {:?}", defs.task_definition_arns()); + // R8: the revisions stay registered for an admin to clear + // (the guard does, as the account admin). + assert_eq!(defs.task_definition_arns(), [revision1.clone()], "the project's revision is left ACTIVE"); }; let result = std::panic::AssertUnwindSafe(body).catch_unwind().await; let guard = std::mem::take(&mut *p5.lock().unwrap()); diff --git a/app/src-tauri/src/aws/policies.rs b/app/src-tauri/src/aws/policies.rs index d0ac553..10d1e64 100644 --- a/app/src-tauri/src/aws/policies.rs +++ b/app/src-tauri/src/aws/policies.rs @@ -246,8 +246,10 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[ ("ecs:CreateAction", Literal("RunTask")), ], ), - // `workspace-user-tasks.json`: a user registers, tags and retires their - // own task definitions, and reads their own secrets. + // `workspace-user-tasks.json`: a user registers and tags their own task + // definitions, and reads their own secrets. Retiring them is the admin's + // (R8): ECS authorizes `DeregisterTaskDefinition` against `*` only, with + // no tag condition, so it can't be confined to a user's own revisions. // Residual risk the policy cannot close: the family carries the owner // key, which IAM can't condition on, so any user may register a revision // into another owner's family. The app's mitigation: it runs only the @@ -267,16 +269,6 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[ ("ecs:CreateAction", Literal("RegisterTaskDefinition")), ], ), - write( - "ecs:DeregisterTaskDefinition", - PROBE_TASK_DEFINITION, - &[(OWNER_RESOURCE_TAG, OwnerId)], - ), - write( - "ecs:DeleteTaskDefinitions", - PROBE_TASK_DEFINITION, - &[(OWNER_RESOURCE_TAG, OwnerId)], - ), read( "secretsmanager:GetSecretValue", PROBE_SECRET, @@ -954,16 +946,6 @@ mod tests { OWNER_REQUEST_TAG, ), ("ecs:TagResource", "task-definition/", OWNER_REQUEST_TAG), - ( - "ecs:DeregisterTaskDefinition", - "task-definition/", - OWNER_RESOURCE_TAG, - ), - ( - "ecs:DeleteTaskDefinitions", - "task-definition/", - OWNER_RESOURCE_TAG, - ), ( "secretsmanager:GetSecretValue", "secret:", @@ -1220,21 +1202,25 @@ mod tests { ); } + /// R8: the workspace tier retires no task definition, not even its own + /// (`DeregisterTaskDefinition` is authorized against `*` only). #[test] - fn only_the_owner_retires_a_task_definition() { + fn the_workspace_tier_never_retires_a_task_definition() { + let register = ws_check("ecs:RegisterTaskDefinition", "task-definition/"); for action in ["ecs:DeregisterTaskDefinition", "ecs:DeleteTaskDefinitions"] { - let c = ws_check(action, "task-definition/"); - assert!(allowed(Tier::WorkspaceUser, c, ME, ME), "{action}"); - let theirs = Check { - context: &[(OWNER_RESOURCE_TAG, Literal(SOMEONE_ELSE))], - ..*c - }; - assert!(!allowed(Tier::WorkspaceUser, &theirs, ME, ME), "{action}"); - let other_family = Check { - resource: "arn:aws:ecs:{region}:{account}:task-definition/production:1", - ..*c - }; - assert!(!allowed(Tier::WorkspaceUser, &other_family, ME, ME), "{action}"); + assert!( + !checks(Tier::WorkspaceUser).iter().any(|c| c.action == action), + "{action} is checked for the workspace tier" + ); + for resource in ["*", register.resource] { + let own = Check { + action, + resource, + context: &[(OWNER_RESOURCE_TAG, OwnerId)], + ..*register + }; + assert!(!allowed(Tier::WorkspaceUser, &own, ME, ME), "{action} on {resource}"); + } } } diff --git a/app/src-tauri/src/aws/runtime.rs b/app/src-tauri/src/aws/runtime.rs index 66cf639..5cdadcb 100644 --- a/app/src-tauri/src/aws/runtime.rs +++ b/app/src-tauri/src/aws/runtime.rs @@ -51,14 +51,6 @@ pub trait RuntimeApi: Send + Sync { &self, id: &str, ) -> impl Future, AwsError>> + Send; - fn deregister_task_definition( - &self, - arn: &str, - ) -> impl Future> + Send; - fn delete_task_definitions( - &self, - arns: &[String], - ) -> impl Future> + Send; /// Schedule deletion with a [`SECRET_RECOVERY_DAYS`] window. A secret /// that does not exist is success. fn delete_secret(&self, name: &str) -> impl Future> + Send; @@ -247,9 +239,9 @@ pub struct RemoveRequest<'a> { pub discard_data: bool, } -/// Stop the project's task, retire its task definition revisions, schedule -/// its secret for deletion (7 days) and, with `discard_data`, delete its -/// access point. A task that can't be stopped stops the removal (nothing +/// Stop the project's task, schedule its secret for deletion (7 days) and, +/// with `discard_data`, delete its access point. Task definition revisions +/// are left registered (R8). A task that can't be stopped stops the removal (nothing /// billed may be left running); the rest is attempted in full and every /// failure reported. pub async fn remove_workspace_resources( @@ -276,34 +268,17 @@ pub async fn remove_workspace_resources = Vec::new(); - // 2. Task definition revisions the caller registered for this project. - let mut retired = Vec::new(); - match work.task_definition_revisions(&family).await { - Ok(revisions) => { - for arn in revisions { - match work.describe_task_definition(&arn).await { - Ok(Some(d)) - if d.owner.as_deref() == Some(req.owner_user_id) - && d.project.as_deref() == Some(req.project_id) => - { - match rt.deregister_task_definition(&d.arn).await { - Ok(()) => retired.push(d.arn), - Err(e) => failures.push(format!("task definition {}: {e}", d.arn)), - } - } - Ok(_) => {} - Err(e) => failures.push(format!("task definition {arn}: {e}")), - } - } - } - Err(e) => failures.push(format!("task definitions: {e}")), - } - // DeleteTaskDefinitions takes at most 10 at a time. - for chunk in retired.chunks(10) { - if let Err(e) = rt.delete_task_definitions(chunk).await { - failures.push(format!("deleting retired task definitions: {e}")); - } - } + // 2. Task definition revisions are left ACTIVE (ruling R8): ECS + // authorizes `DeregisterTaskDefinition` against `*` only, with no tag + // condition, so the workspace tier can't retire just its own. They are + // inert (no secret values, only the secret's ARN) and free, and + // `RunOwnTasks` still requires the owner tag, so nobody else can run + // them. An admin can deregister them (`aws/README.md`). + log::info!( + "Project {}: its task definition revisions ({family}:*) are left registered; an \ + administrator can deregister them", + req.project_id + ); // 3. The secret. let secret = project_secret_name(req.env, req.owner_key, req.project_id); @@ -363,7 +338,6 @@ pub struct SdkRuntimeApi { iam: aws_sdk_iam::Client, secrets: aws_sdk_secretsmanager::Client, efs: aws_sdk_efs::Client, - ecs: aws_sdk_ecs::Client, } impl SdkRuntimeApi { @@ -373,7 +347,6 @@ impl SdkRuntimeApi { iam: aws_sdk_iam::Client::new(cfg), secrets: aws_sdk_secretsmanager::Client::new(cfg), efs: aws_sdk_efs::Client::new(cfg), - ecs: aws_sdk_ecs::Client::new(cfg), } } } @@ -438,44 +411,6 @@ impl RuntimeApi for SdkRuntimeApi { } } - async fn deregister_task_definition(&self, arn: &str) -> Result<(), AwsError> { - const ACTION: &str = "ecs:DeregisterTaskDefinition"; - self.ecs - .deregister_task_definition() - .task_definition(arn) - .send() - .await - .map_err(|e| classify_sdk_error(ACTION, &e))?; - Ok(()) - } - - async fn delete_task_definitions(&self, arns: &[String]) -> Result<(), AwsError> { - const ACTION: &str = "ecs:DeleteTaskDefinitions"; - let out = self - .ecs - .delete_task_definitions() - .set_task_definitions(Some(arns.to_vec())) - .send() - .await - .map_err(|e| classify_sdk_error(ACTION, &e))?; - let failed: Vec = out - .failures() - .iter() - .map(|f| { - format!( - "{} ({})", - f.arn().unwrap_or("?"), - f.reason().unwrap_or("no reason given") - ) - }) - .collect(); - if failed.is_empty() { - Ok(()) - } else { - Err(other(format!("{ACTION} failed for {}", failed.join(", ")))) - } - } - async fn delete_secret(&self, name: &str) -> Result<(), AwsError> { const ACTION: &str = "secretsmanager:DeleteSecret"; match self diff --git a/app/src-tauri/src/aws/runtime/tests.rs b/app/src-tauri/src/aws/runtime/tests.rs index 7c3334c..0319993 100644 --- a/app/src-tauri/src/aws/runtime/tests.rs +++ b/app/src-tauri/src/aws/runtime/tests.rs @@ -65,14 +65,6 @@ impl RuntimeApi for Fake { let mut s = self.ap_states.lock().unwrap(); Ok(if s.len() > 1 { s.remove(0) } else { s.first().cloned().flatten() }) } - async fn deregister_task_definition(&self, arn: &str) -> Result<(), AwsError> { - self.log(format!("deregister {arn}")); - Ok(()) - } - async fn delete_task_definitions(&self, arns: &[String]) -> Result<(), AwsError> { - self.log(format!("delete-td {}", arns.join(","))); - Ok(()) - } async fn delete_secret(&self, name: &str) -> Result<(), AwsError> { self.log(format!("delete-secret {name}")); if self.fail_secret_delete { @@ -271,7 +263,7 @@ fn rev(n: u32, owner: &str) -> TaskDefinitionRef { } #[tokio::test] -async fn removal_stops_the_task_retires_own_revisions_and_schedules_the_secret() { +async fn removal_stops_the_task_leaves_the_revisions_and_schedules_the_secret() { let fake = Fake { live_tasks: vec![live("t1")], revisions: vec![rev(2, USER), rev(3, "AROAOTHER:mallory"), rev(1, USER)], @@ -305,12 +297,9 @@ async fn removal_stops_the_task_retires_own_revisions_and_schedules_the_secret() vec![ format!("stop triple-c-dev {} {STOP_REASON}", task_arn("t1")), format!("stop triple-c-dev {} {STOP_REASON}", task_arn("t0")), - format!("deregister {}", rev(2, USER).arn), - format!("deregister {}", rev(1, USER).arn), - format!("delete-td {},{}", rev(2, USER).arn, rev(1, USER).arn), format!("delete-secret {secret}"), ], - "another user's revision is left alone and the access point is kept" + "no task definition is touched (R8) and the access point is kept" ); let keep = Fake { access_points: fake.access_points.clone(), ..Default::default() }; diff --git a/app/src-tauri/src/host/aws_host/tests.rs b/app/src-tauri/src/host/aws_host/tests.rs index 154d8de..a4560cb 100644 --- a/app/src-tauri/src/host/aws_host/tests.rs +++ b/app/src-tauri/src/host/aws_host/tests.rs @@ -214,12 +214,6 @@ impl RuntimeApi for Fake { self.log(format!("ap-state {id}")); Ok(Some("available".into())) } - async fn deregister_task_definition(&self, _arn: &str) -> Result<(), AwsError> { - Ok(()) - } - async fn delete_task_definitions(&self, _arns: &[String]) -> Result<(), AwsError> { - Ok(()) - } async fn delete_secret(&self, _name: &str) -> Result<(), AwsError> { Ok(()) } diff --git a/app/src/components/projects/ConfirmRemoveModal.tsx b/app/src/components/projects/ConfirmRemoveModal.tsx index b8fe247..f8bed2f 100644 --- a/app/src/components/projects/ConfirmRemoveModal.tsx +++ b/app/src/components/projects/ConfirmRemoveModal.tsx @@ -48,8 +48,9 @@ export default function ConfirmRemoveModal({

Are you sure you want to remove{" "} {projectName}? This stops its - workspace on AWS and removes its task definitions and its secret (the secret is - deleted after AWS’s 7-day recovery window). Work that was not pushed to a + workspace on AWS and removes its secret (deleted after AWS’s 7-day recovery + window). Its task definitions stay registered (they hold no secrets and cost + nothing); an administrator can deregister them. Work that was not pushed to a remote is lost.

{savedFiles && ( diff --git a/app/src/components/projects/home/config/HostSection.tsx b/app/src/components/projects/home/config/HostSection.tsx index b5b8a4f..508a498 100644 --- a/app/src/components/projects/home/config/HostSection.tsx +++ b/app/src/components/projects/home/config/HostSection.tsx @@ -401,9 +401,9 @@ function RemoveAwsResourcesModal({ project, onClose }: { project: Project; onClo >

Removes {project.name}’s - task definitions and secret from AWS (the secret after a 7-day recovery window) and - keeps the project, so it can run somewhere else. Its next start on AWS sets them up - again. + secret from AWS (after a 7-day recovery window) and keeps the project, so it can run + somewhere else. Its task definitions stay registered for an administrator to + deregister. Its next start on AWS sets everything up again.

{hasSavedFiles(project) && ( diff --git a/app/src/components/projects/home/config/AwsConfig.test.tsx b/app/src/components/projects/home/config/AwsConfig.test.tsx index f46ff84..a3c7783 100644 --- a/app/src/components/projects/home/config/AwsConfig.test.tsx +++ b/app/src/components/projects/home/config/AwsConfig.test.tsx @@ -159,7 +159,7 @@ describe("HostSection", () => { fireEvent.click(screen.getByRole("button", { name: "Remove AWS resources…" })); const dialog = screen.getByRole("dialog"); - // Hybrid: the saved files can go too. + // Hybrid: the access point can go too (the files stay on the user's EFS). fireEvent.click(within(dialog).getByRole("checkbox")); fireEvent.click(within(dialog).getByRole("button", { name: "Remove" })); await waitFor(() => expect(removeAwsResources).toHaveBeenCalledWith(p.id, true)); diff --git a/app/src/components/projects/home/config/HostSection.tsx b/app/src/components/projects/home/config/HostSection.tsx index 508a498..d11b603 100644 --- a/app/src/components/projects/home/config/HostSection.tsx +++ b/app/src/components/projects/home/config/HostSection.tsx @@ -401,9 +401,9 @@ function RemoveAwsResourcesModal({ project, onClose }: { project: Project; onClo >

Removes {project.name}’s - secret from AWS (after a 7-day recovery window) and keeps the project, so it can run - somewhere else. Its task definitions stay registered for an administrator to - deregister. Its next start on AWS sets everything up again. + secret from AWS and keeps the project, so it can run somewhere else. Its task + definitions stay registered for an administrator to deregister. Its next start on AWS + sets everything up again.

{hasSavedFiles(project) && ( )} {error && ( diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 8b8c967..5f78c34 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -116,7 +116,8 @@ export interface NewProjectHost { } export interface RemoveProjectOptions { - /** AWS: also delete the workspace's saved files (its EFS access point). */ + /** AWS: also remove the workspace's EFS access point (the files stay on the + * user's own file system until offboarding). */ discardData?: boolean; /** AWS: the connection is gone; remove the project and leave its AWS resources. */ forgetAwsResources?: boolean; diff --git a/aws/README.md b/aws/README.md index f32b6ba..44b9f3f 100644 --- a/aws/README.md +++ b/aws/README.md @@ -89,6 +89,15 @@ following: tagged `CreateAccessPoint` needs is authorized against the **file system**, not the new access point (verified live, phase 5), so `TagOwnAccessPointsAtCreate` names `file-system/*` and is confined by `elasticfilesystem:CreateAction = CreateAccessPoint`. +- **What removing a project removes.** Removing a project (or only its AWS resources) stops its + task and deletes its secret **at once** (`ForceDeleteWithoutRecovery`, ruling R10: the app + rebuilds it from local data, and a secret left pending deletion would make the next start's + `CreateSecret` fail). "Also remove the workspace's file-system access point" deletes only the + **access point** (ruling R9): the files under it (the Claude login, history, SSH keys) stay on + the user's own EFS file system, which only their own task role can read, until the user is + offboarded (destroying their onboarding module removes the file system). Starting the same + project on AWS again creates a new access point onto the same directory. Task definition + revisions stay registered (R8). - **Set only the ordinary tag keys.** Every request that tags something may set only `triple-c:owner`, `triple-c:project` and `triple-c:environment` (`aws:TagKeys`). This applies to `RunTask`, the ECS and EFS tag-on-create calls, `CreateSecret`, and secret `TagResource`. A @@ -586,5 +595,5 @@ and the secret and removes the ECR policy; then the fixture destroys the modules | `~/.claude.json` after Claude Code runs | ✓ still a link (Claude Code started without a login: "Not logged in") | | Bubblewrap (P9) | ✗ `bwrap --unshare-user` fails: "No permissions to create new namespace" (kernel 6.1, `max_user_namespaces` 16384, no `CAP_SYS_ADMIN` on Fargate). The sandbox stays off on AWS | | A revision per configuration | turning idle stop on adds a secret key, so the next start registers a new revision (by design) | -| Removal (R8) | ✓ final run: `remove_aws_resources` succeeds; the secret is scheduled for deletion, the access point deleted, the revision left ACTIVE | +| Removal (R8) | ✓ final run: `remove_aws_resources` succeeds; the secret is scheduled for deletion, the access point deleted, the revision left ACTIVE. Since R10 the secret is deleted without a recovery window (the test now asserts it is gone) | | Cleanup | ✓ nothing `triple-c` left in us-west-2; environment KMS keys pending deletion | -- 2.52.0 From 95b68ed06885c10780e220a0eb7112f073014b44 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 12:31:19 -0800 Subject: [PATCH 27/43] fix(container): idle stop ignores silent sessions and never loses work - The agent marks requests with X-Triple-C-Background: 1 (and the attach/tunnel sessions they open) as not activity (R11). - triple-c-idle-watch no longer requires zero open execs/tunnels: idle is last_activity older than N minutes, no claude, no scheduler job, no running clone, and no unsaved git work. With the WIP push on, it pushes as claude before signalling and stays up (backing off) when the push fails; with it off, dirty repos keep the workspace up. - An idle stop exits 75 so ECS reports it distinctly from a crash; the TERM trap no longer pushes again after the watcher's push. - start_clone_repos writes done from an EXIT trap. Co-Authored-By: Claude Opus 5.5 --- container/entrypoint.sh | 25 ++- container/triple-c-agent/src/attach.rs | 19 ++- container/triple-c-agent/src/lib.rs | 33 +++- container/triple-c-agent/src/tunnel.rs | 14 +- container/triple-c-agent/tests/protocol.rs | 70 ++++++++ container/triple-c-idle-watch | 154 +++++++++++++----- .../2026-11-13-triple-c-agent-protocol.md | 14 +- 7 files changed, 266 insertions(+), 63 deletions(-) diff --git a/container/entrypoint.sh b/container/entrypoint.sh index 506a1d2..71173c9 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -724,8 +724,10 @@ start_clone_repos() { mkdir -m 755 "$status_dir" || status_dir="" ( trap '' HUP + # `done` however the clone ends (an error, a signal), or the idle + # watcher would count it as still running forever. + [ -z "$status_dir" ] || trap ': > "$status_dir/done"' EXIT clone_repos "$json" /workspace "$status_dir" - [ -z "$status_dir" ] || : > "$status_dir/done" ) & } # <<< aws_repos @@ -1170,7 +1172,9 @@ start_agent # with TRIPLEC_WIP_PUSH=1 it pushes every repo's unsaved work to a # triple-c/wip/ branch (as `claude`, bounded at 90 s), unless the app's # stop guard recorded `pushed` or `discard` in /run/triple-c/stop-reason -# (written as root through the agent just before StopTask). ECS gives a task +# (written as root through the agent just before StopTask), or the idle +# watcher recorded `idle` (it pushes *before* signalling and only signals once +# nothing unsaved is left, so an idle stop never loses work). ECS gives a task # `stopTimeout` seconds between SIGTERM and SIGKILL — 30 by default, 120 at # most on Fargate — so the task definition must raise it for the push to # finish. Once only: a second TERM during the push changes nothing. @@ -1180,9 +1184,13 @@ start_agent # auto-stop off. When the workspace is idle it records the reason "idle" and # sends TERM to *this* script ($$) — not PID 1, which under ECS's # initProcessEnabled is docker-init — so an idle stop and an ECS stop take -# this same trap and push at most once. Its environment is built from scratch: -# it needs none of the container's. +# this same trap and push at most once. An idle stop exits with status 75 +# (triplec_idle_exit; Rust `aws::tasks::IDLE_EXIT_CODE`), so ECS reports it as +# EssentialContainerExited with that exit code and the app can say "stopped +# after being idle" rather than "crashed". Its environment is built from +# scratch: it needs none of the container's but TRIPLEC_WIP_PUSH. # >>> aws_stop +triplec_idle_exit=75 aws_before_stop() { [ "${TRIPLEC_HOST:-}" = aws ] || return 0 [ -z "${triplec_stopping:-}" ] || return 0 @@ -1190,7 +1198,11 @@ aws_before_stop() { local reason=sigterm recorded recorded=$(cat /run/triple-c/stop-reason 2>/dev/null) case "$recorded" in - idle) reason=idle ;; + idle) + # The watcher already pushed (or found nothing to push). + triplec_exit_status=$triplec_idle_exit + echo "entrypoint: stopping (idle) — work in progress already handled by the idle watcher" + return 0 ;; # The app's stop guard already pushed, or the user chose to stop # without pushing: honour that instead of pushing again. pushed|discard) @@ -1220,6 +1232,7 @@ start_idle_watch() { TRIPLEC_IDLE_STOP_MINUTES="$minutes" \ TRIPLEC_IDLE_SIGNAL_PID="$signal_pid" \ TRIPLEC_AGENT_TOKEN_FILE="${TRIPLEC_AGENT_TOKEN_FILE:-/dev/shm/triple-c/agent-token}" \ + TRIPLEC_WIP_PUSH="${TRIPLEC_WIP_PUSH:-0}" \ /usr/local/bin/triple-c-idle-watch rc=$? # 0: it signalled the stop. 2: misconfigured, and a restart would @@ -1244,7 +1257,7 @@ keepalive_pid="" stop_keepalive() { [ "$triplec_aws" = 1 ] && aws_before_stop [ -n "$keepalive_pid" ] && kill "$keepalive_pid" 2>/dev/null - exit 0 + exit "${triplec_exit_status:-0}" } trap stop_keepalive TERM INT HUP throttle_fifo=/run/triple-c-keepalive.throttle diff --git a/container/triple-c-agent/src/attach.rs b/container/triple-c-agent/src/attach.rs index ffc2597..c4adfbd 100644 --- a/container/triple-c-agent/src/attach.rs +++ b/container/triple-c-agent/src/attach.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use std::time::Duration; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; -use axum::extract::State; +use axum::extract::{Extension, State}; use axum::response::Response; use bytes::Bytes; use serde::Deserialize; @@ -13,7 +13,7 @@ use tokio::sync::{mpsc, oneshot}; use crate::pty; use crate::spawn::{self, ExecSpec, Io, PrepareError}; -use crate::{AppState, CountGuard, MAX_WS_MESSAGE}; +use crate::{AppState, Background, CountGuard, MAX_WS_MESSAGE}; /// How long the client has to send its spec after the upgrade. const SPEC_TIMEOUT: Duration = Duration::from_secs(30); @@ -47,10 +47,15 @@ enum Input { Data(Bytes), } -pub async fn upgrade(State(st): State>, ws: WebSocketUpgrade) -> Response { +pub async fn upgrade( + State(st): State>, + bg: Option>, + ws: WebSocketUpgrade, +) -> Response { + let bg = bg.map(|Extension(b)| b).unwrap_or_default(); ws.max_message_size(MAX_WS_MESSAGE) .max_frame_size(MAX_WS_MESSAGE) - .on_upgrade(move |socket| session(socket, st)) + .on_upgrade(move |socket| session(socket, st, bg)) } fn text(v: serde_json::Value) -> Message { @@ -69,7 +74,7 @@ fn frame(channel: u8, data: &[u8]) -> Message { Message::Binary(v.into()) } -async fn session(mut socket: WebSocket, st: Arc) { +async fn session(mut socket: WebSocket, st: Arc, bg: Background) { // 1. The spec, as one text frame. let first = match tokio::time::timeout(SPEC_TIMEOUT, socket.recv()).await { Ok(Some(Ok(Message::Text(t)))) => t, @@ -166,7 +171,7 @@ async fn session(mut socket: WebSocket, st: Arc) { tokio::select! { ev = ev_rx.recv() => match ev { Some(Event::Out(ch, data)) => { - st.activity.touch(); + st.activity.touch_for(bg); if socket.send(frame(ch, &data)).await.is_err() { break; } @@ -180,7 +185,7 @@ async fn session(mut socket: WebSocket, st: Arc) { }, msg = socket.recv() => match msg { Some(Ok(Message::Binary(data))) => { - st.activity.touch(); + st.activity.touch_for(bg); if req.attach_stdin { if let Some(tx) = &in_tx { let _ = tx.send(Input::Data(data)); diff --git a/container/triple-c-agent/src/lib.rs b/container/triple-c-agent/src/lib.rs index 8ac0728..c612802 100644 --- a/container/triple-c-agent/src/lib.rs +++ b/container/triple-c-agent/src/lib.rs @@ -194,6 +194,30 @@ impl Activity { pub fn touch(&self) { self.last_unix.store(now_unix(), Ordering::Relaxed); } + /// Touch unless the session belongs to a background request. + pub fn touch_for(&self, bg: Background) { + if !bg.0 { + self.touch(); + } + } +} + +/// A request carrying this header with the value `1` is a background poll +/// (the app's auth bridge reading `/proc/net/tcp` every 2 s): it is served +/// like any other but never counts as activity, nor does the I/O of an +/// attach or tunnel it opens. Otherwise such polls would keep an idle +/// workspace up forever. +pub const BACKGROUND_HEADER: &str = "x-triple-c-background"; + +/// Whether the request was marked with [`BACKGROUND_HEADER`]; set by the +/// auth layer as a request extension for the handlers that keep a session. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct Background(pub bool); + +impl Background { + pub fn of(headers: &axum::http::HeaderMap) -> Background { + Background(headers.get(BACKGROUND_HEADER).is_some_and(|v| v.as_bytes() == b"1")) + } } /// Increments a counter for as long as it lives, so an exec or tunnel that @@ -326,7 +350,7 @@ pub async fn serve(listener: tokio::net::TcpListener, config: Config) -> std::io /// Bearer-token gate on every request. A failure is `401` with an **empty** /// body (spec): nothing about the API is disclosed to a caller without the /// token. -async fn auth(State(st): State>, req: Request, next: Next) -> Response { +async fn auth(State(st): State>, mut req: Request, next: Next) -> Response { let presented = req .headers() .get(header::AUTHORIZATION) @@ -336,11 +360,14 @@ async fn auth(State(st): State>, req: Request, next: Next) -> Resp return StatusCode::UNAUTHORIZED.into_response(); } // `health` and `activity` are the idle monitor's own probes; counting them - // would mean an idle container could never look idle. + // would mean an idle container could never look idle. Background polls + // (see [`BACKGROUND_HEADER`]) are exempt for the same reason. + let bg = Background::of(req.headers()); let path = req.uri().path(); if path != "/v1/health" && path != "/v1/activity" { - st.activity.touch(); + st.activity.touch_for(bg); } + req.extensions_mut().insert(bg); next.run(req).await } diff --git a/container/triple-c-agent/src/tunnel.rs b/container/triple-c-agent/src/tunnel.rs index 7c99ae6..20cd593 100644 --- a/container/triple-c-agent/src/tunnel.rs +++ b/container/triple-c-agent/src/tunnel.rs @@ -7,14 +7,14 @@ use std::time::Duration; use axum::extract::rejection::QueryRejection; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; -use axum::extract::{Query, State}; +use axum::extract::{Extension, Query, State}; use axum::http::StatusCode; use axum::response::Response; use serde::Deserialize; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; -use crate::{error, AppState, CountGuard, MAX_WS_MESSAGE}; +use crate::{error, AppState, Background, CountGuard, MAX_WS_MESSAGE}; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); @@ -25,9 +25,11 @@ pub struct TunnelQuery { pub async fn upgrade( State(st): State>, + bg: Option>, q: Result, QueryRejection>, ws: WebSocketUpgrade, ) -> Response { + let bg = bg.map(|Extension(b)| b).unwrap_or_default(); let port = match q { Ok(Query(q)) if q.port != 0 => q.port, Ok(_) => return error(StatusCode::BAD_REQUEST, "port must be 1-65535"), @@ -35,10 +37,10 @@ pub async fn upgrade( }; ws.max_message_size(MAX_WS_MESSAGE) .max_frame_size(MAX_WS_MESSAGE) - .on_upgrade(move |socket| session(socket, st, port)) + .on_upgrade(move |socket| session(socket, st, port, bg)) } -async fn session(mut socket: WebSocket, st: Arc, port: u16) { +async fn session(mut socket: WebSocket, st: Arc, port: u16, bg: Background) { // Loopback only, by construction: the tunnel reaches in-container // listeners, never the network the container can see. let tcp = match tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(("127.0.0.1", port))).await { @@ -56,7 +58,7 @@ async fn session(mut socket: WebSocket, st: Arc, port: u16) { tokio::select! { r = rd.read(&mut buf) => match r { Ok(n) if n > 0 => { - st.activity.touch(); + st.activity.touch_for(bg); if socket.send(Message::Binary(buf[..n].to_vec().into())).await.is_err() { return; } @@ -68,7 +70,7 @@ async fn session(mut socket: WebSocket, st: Arc, port: u16) { }, m = socket.recv() => match m { Some(Ok(Message::Binary(d))) => { - st.activity.touch(); + st.activity.touch_for(bg); if wr.write_all(&d).await.is_err() { let _ = socket.send(Message::Close(None)).await; return; diff --git a/container/triple-c-agent/tests/protocol.rs b/container/triple-c-agent/tests/protocol.rs index c640f5d..ec53eeb 100644 --- a/container/triple-c-agent/tests/protocol.rs +++ b/container/triple-c-agent/tests/protocol.rs @@ -862,6 +862,76 @@ async fn activity_counts_execs_and_tunnels_and_ignores_its_own_probes() { } } +#[tokio::test] +async fn background_requests_and_their_sessions_are_not_activity() { + let a = start().await; + let (_, v0) = a.get("/v1/activity").await; + let t0 = v0["last_activity_unix"].as_i64().unwrap(); + tokio::time::sleep(Duration::from_millis(1100)).await; + + // A one-shot exec marked as background. + let r = a + .http + .post(a.url("/v1/exec")) + .bearer_auth(TOKEN) + .header("X-Triple-C-Background", "1") + .json(&json!({ "cmd": ["echo", "hi"], "user": a.user })) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 200); + + // A background attach whose process talks. + let mut req = format!("ws://{}/v1/attach", a.addr).into_client_request().unwrap(); + req.headers_mut().insert("Authorization", format!("Bearer {TOKEN}").parse().unwrap()); + req.headers_mut().insert("X-Triple-C-Background", "1".parse().unwrap()); + let mut ws = tokio_tungstenite::connect_async(req).await.unwrap().0; + let spec = json!({ "cmd": ["sh", "-c", "echo out; cat"], "user": a.user, "attach_stdin": true }); + ws.send(Message::Text(spec.to_string().into())).await.unwrap(); + assert_eq!(next_text(&mut ws).await["type"], "started"); + ws.send(Message::Binary(b"typed\n".to_vec().into())).await.unwrap(); + let mut acc = Vec::new(); + read_until(&mut ws, &mut acc, "typed").await; + + let (_, v) = a.get("/v1/activity").await; + assert_eq!(v["last_activity_unix"].as_i64().unwrap(), t0, "background requests are not activity: {v}"); + + // Any other value of the header is an ordinary request. + a.http + .get(a.url("/v1/exec/nope")) + .bearer_auth(TOKEN) + .header("X-Triple-C-Background", "yes") + .send() + .await + .unwrap(); + let (_, v) = a.get("/v1/activity").await; + assert!(v["last_activity_unix"].as_i64().unwrap() > t0, "{v}"); +} + +#[tokio::test] +async fn an_open_but_silent_attach_stops_being_activity() { + let a = start().await; + let (mut ws, _) = a.attach(json!({ "cmd": ["cat"], "attach_stdin": true })).await; + ws.send(Message::Binary(b"x\n".to_vec().into())).await.unwrap(); + let mut acc = Vec::new(); + read_until(&mut ws, &mut acc, "x").await; + let (_, v) = a.get("/v1/activity").await; + let t1 = v["last_activity_unix"].as_i64().unwrap(); + assert_eq!(v["active_execs"], 1); + + // Still attached, nothing flowing: the timestamp stays put, so the + // idle watcher (which no longer looks at the counters) can see it age. + tokio::time::sleep(Duration::from_millis(2100)).await; + let (_, v) = a.get("/v1/activity").await; + assert_eq!(v["last_activity_unix"].as_i64().unwrap(), t1, "{v}"); + + // Input on the attach is activity again. + ws.send(Message::Binary(b"y\n".to_vec().into())).await.unwrap(); + read_until(&mut ws, &mut acc, "y").await; + let (_, v) = a.get("/v1/activity").await; + assert!(v["last_activity_unix"].as_i64().unwrap() > t1, "{v}"); +} + // ── connection limits ────────────────────────────────────────────────────── /// Wait for the server to close `s`: `Ok(elapsed)` on EOF or reset, diff --git a/container/triple-c-idle-watch b/container/triple-c-idle-watch index 17f934d..8cea036 100755 --- a/container/triple-c-idle-watch +++ b/container/triple-c-idle-watch @@ -1,45 +1,62 @@ #!/bin/bash # triple-c-idle-watch [--check] # -# Idle auto-stop for an AWS workspace (plan ruling P7). Started by +# Idle auto-stop for an AWS workspace (plan ruling P7, R11). Started by # entrypoint.sh, as root, only when TRIPLEC_HOST=aws and # TRIPLEC_IDLE_STOP_MINUTES is set. Every interval it asks whether the # workspace is idle: # # * the agent's /v1/activity (read with the agent's own token file) shows -# no authenticated activity for N minutes, no open exec, no open tunnel; +# no authenticated activity for N minutes. The agent counts requests and +# the bytes of attaches and tunnels, so an open but silent terminal tab +# ages like no tab at all, and the app's background polls (the auth +# bridge, marked X-Triple-C-Background) never count. The open exec and +# tunnel counters are *not* consulted (R11); # * no `claude` process is running; # * no scheduler job is running (`triple-c-scheduler status`, as claude); # * the start's background repository clone has finished (entrypoint.sh, -# start_clone_repos: /run/triple-c-clone exists without `done`). +# start_clone_repos: /run/triple-c-clone exists without `done`); +# * no work would be lost: `triple-c-wip-push --dry-run` (as claude) finds +# no dirty repository and no unpushed commit. With the WIP push on +# (TRIPLEC_WIP_PUSH=1) such work is pushed here, before the stop, and the +# workspace stops only once that push succeeded; a failed push keeps it +# running and is retried later (backing off). With it off, unsaved work +# simply keeps the workspace up. # # Anything it cannot read counts as NOT idle: a stop is the one thing here # that cannot be undone, so every doubt keeps the workspace running. # # When idle it records "idle" as the stop reason and sends SIGTERM to the -# entrypoint (TRIPLEC_IDLE_SIGNAL_PID). The entrypoint's TERM trap does the -# WIP push (reason read from that file) and stops the keepalive, so a stop -# from here and a stop from ECS take the same single path — and a SIGTERM from -# ECS arriving mid-push cannot start a second, concurrent push. +# entrypoint (TRIPLEC_IDLE_SIGNAL_PID). The entrypoint's TERM trap sees +# "idle", does not push again (this script already did), stops the keepalive +# and exits with the idle status (75) so the app can tell an idle stop from a +# crash. A stop from ECS (SIGTERM, no marker) still pushes in the trap, best +# effort; a SIGTERM from ECS arriving mid-way cannot start a second push. # # Why not `kill -TERM 1`: with initProcessEnabled ECS runs the container under # docker-init, so PID 1 is the init, not the entrypoint. The init would # forward the signal, but signalling the entrypoint directly does not depend on # that and also works without an init. # -# --check evaluates once and exits 0 (idle) or 1 (not idle); for tests and -# for looking at a live task. +# --check evaluates once and exits 0 (idle and nothing unsaved, or unsaved +# work the WIP push would take) or 1 (not idle); for tests and for looking at +# a live task. It never pushes. # -# Configuration (environment; the entrypoint sets the first three, the rest +# Configuration (environment; the entrypoint sets the first four, the rest # exist for tests): # TRIPLEC_IDLE_STOP_MINUTES N (required, > 0) # TRIPLEC_IDLE_SIGNAL_PID the entrypoint's PID # TRIPLEC_AGENT_TOKEN_FILE default /dev/shm/triple-c/agent-token +# TRIPLEC_WIP_PUSH 1: push unsaved work before an idle stop # TRIPLEC_AGENT_URL default http://127.0.0.1:47900 # TRIPLEC_IDLE_INTERVAL seconds between checks, default 60 # TRIPLEC_STOP_REASON_FILE default /run/triple-c/stop-reason # TRIPLEC_SCHEDULER_BIN default /usr/local/bin/triple-c-scheduler # TRIPLEC_SCHEDULER_USER default claude; empty runs it as this user +# (also the user the WIP push runs as) +# TRIPLEC_WIP_PUSH_BIN default /usr/local/bin/triple-c-wip-push +# TRIPLEC_WIP_TIMEOUT seconds for the push, default 90 +# TRIPLEC_WIP_BACKOFF_MAX longest wait between push retries, default 1800 # TRIPLEC_IDLE_PROC_NAME default claude # TRIPLEC_CLONE_STATUS_DIR default /run/triple-c-clone @@ -55,26 +72,31 @@ scheduler="${TRIPLEC_SCHEDULER_BIN:-/usr/local/bin/triple-c-scheduler}" scheduler_user="${TRIPLEC_SCHEDULER_USER-claude}" proc_name="${TRIPLEC_IDLE_PROC_NAME:-claude}" clone_dir="${TRIPLEC_CLONE_STATUS_DIR:-/run/triple-c-clone}" +wip_push="${TRIPLEC_WIP_PUSH:-0}" +wip_bin="${TRIPLEC_WIP_PUSH_BIN:-/usr/local/bin/triple-c-wip-push}" +wip_timeout="${TRIPLEC_WIP_TIMEOUT:-90}" +backoff_max="${TRIPLEC_WIP_BACKOFF_MAX:-1800}" # >>> is_idle -# is_idle now last_activity_unix active_execs active_tunnels claude_running jobs_running minutes +# is_idle now last_activity_unix claude_running jobs_running minutes # Pure: exit 0 when idle, 1 otherwise. Every argument must be a plain # non-negative integer; anything else (an empty or garbled read) is not idle. +# Open execs and tunnels are deliberately not an input (R11): their traffic +# already moves last_activity_unix, and an open but silent one is idle. is_idle() { local a - [ $# -eq 7 ] || return 1 + [ $# -eq 5 ] || return 1 for a in "$@"; do case "$a" in ''|*[!0-9]*) return 1 ;; esac done - local now=$1 last=$2 execs=$3 tunnels=$4 claude=$5 jobs=$6 mins=$7 + local now=$1 last=$2 claude=$3 jobs=$4 mins=$5 [ "$mins" -gt 0 ] || return 1 - [ "$execs" -eq 0 ] && [ "$tunnels" -eq 0 ] || return 1 [ "$claude" -eq 0 ] && [ "$jobs" -eq 0 ] || return 1 [ $((now - last)) -ge $((mins * 60)) ] } # <<< is_idle -# Prints "last execs tunnels", or fails. +# Prints last_activity_unix, or fails. read_activity() { local token body token=$(cat "$token_file" 2>/dev/null) || return 1 @@ -83,19 +105,23 @@ read_activity() { # is readable by every user in the container. body=$(printf 'header = "Authorization: Bearer %s"\n' "$token" \ | curl -sf --max-time 5 --config - "$agent_url/v1/activity" 2>/dev/null) || return 1 - printf '%s' "$body" | jq -r '[.last_activity_unix, .active_execs, .active_tunnels] - | map(if type == "number" and . >= 0 and . == floor then tostring else "x" end) - | join(" ")' 2>/dev/null + printf '%s' "$body" | jq -r '.last_activity_unix + | if type == "number" and . >= 0 and . == floor then tostring else "x" end' 2>/dev/null +} + +# Run "$@" as the scheduler user (claude) when we are root, else as is. +as_user() { + if [ -n "$scheduler_user" ] && [ "$(id -u)" = 0 ]; then + su -s /bin/bash -c '"$@"' -- "$scheduler_user" _ "$@" + else + "$@" + fi } # 1 when a scheduler job is running *or* the question cannot be answered. jobs_running() { local out - if [ -n "$scheduler_user" ] && [ "$(id -u)" = 0 ]; then - out=$(su -s /bin/bash -c '"$1" status' -- "$scheduler_user" _ "$scheduler" 2>/dev/null) || { echo 1; return; } - else - out=$("$scheduler" status 2>/dev/null) || { echo 1; return; } - fi + out=$(as_user "$scheduler" status 2>/dev/null) || { echo 1; return; } if [ "$out" = "Nothing running." ]; then echo 0; else echo 1; fi } @@ -104,14 +130,38 @@ clone_running() { [ -d "$clone_dir" ] && [ ! -e "$clone_dir/done" ] } -check() { - local act last execs tunnels claude jobs - act=$(read_activity) || return 1 - read -r last execs tunnels <<< "$act" +# The folders with unsaved work (dirty, or commits no remote has), one per +# line; nothing when there is none. Fails when it cannot tell. +unsaved_work() { + local out + out=$(as_user timeout -k 5 60 "$wip_bin" --dry-run 2>/dev/null) || return 1 + printf '%s\n' "$out" | jq -r 'select(.dirty == true or (.unpushed // 0) > 0) | .folder' 2>/dev/null +} + +# The activity, process, job and clone conditions. +quiet() { + local last claude jobs + last=$(read_activity) || return 1 if pgrep -x "$proc_name" >/dev/null 2>&1; then claude=1; else claude=0; fi jobs=$(jobs_running) clone_running && jobs=1 - is_idle "$(date +%s)" "${last:-}" "${execs:-}" "${tunnels:-}" "$claude" "$jobs" "$minutes" + is_idle "$(date +%s)" "${last:-}" "$claude" "$jobs" "$minutes" +} + +# Say something once per change of state, not every interval. +said="" +say_once() { + [ "$1" = "$said" ] && return 0 + said="$1" + echo "triple-c-idle-watch: $1" +} + +# --check: quiet, and no unsaved work unless the WIP push would take it. +check() { + local dirty + quiet || return 1 + dirty=$(unsaved_work) || return 1 + [ -z "$dirty" ] || [ "$wip_push" = 1 ] } case "$minutes" in @@ -127,18 +177,44 @@ case "$signal_pid" in ''|*[!0-9]*|0|1) echo "triple-c-idle-watch: TRIPLEC_IDLE_SIGNAL_PID must be the entrypoint's PID" >&2; exit 2 ;; esac +push_failures=0 +next_push_at=0 while :; do sleep "$interval" - if check; then - echo "triple-c-idle-watch: idle for ${minutes} minutes — stopping the workspace" - mkdir -p "$(dirname "$reason_file")" 2>/dev/null - # A pushed/discard marker means the app's stop guard has decided and - # its stop is under way: keep that decision rather than overwrite it. - case "$(cat "$reason_file" 2>/dev/null)" in - pushed|discard) ;; - *) printf 'idle\n' > "$reason_file" ;; - esac - kill -TERM "$signal_pid" - exit 0 + if ! quiet; then + said="" + continue fi + if ! dirty=$(unsaved_work); then + say_once "idle, but the repositories' state cannot be read — not stopping" + continue + fi + if [ -n "$dirty" ]; then + folders=$(printf '%s' "$dirty" | paste -sd, -) + if [ "$wip_push" != 1 ]; then + say_once "idle, but $folders has unsaved work and the WIP push is off — not stopping" + continue + fi + [ "$(date +%s)" -ge "$next_push_at" ] || continue + echo "triple-c-idle-watch: idle — pushing work in progress ($folders) before stopping..." + if ! as_user timeout -k 5 "$wip_timeout" "$wip_bin" --reason idle; then + push_failures=$((push_failures + 1)) + wait_s=$((interval << (push_failures < 10 ? push_failures : 10))) + [ "$wait_s" -le "$backoff_max" ] || wait_s=$backoff_max + next_push_at=$(( $(date +%s) + wait_s )) + said="" + echo "triple-c-idle-watch: the WIP push failed — staying up; retrying in ${wait_s}s" + continue + fi + fi + echo "triple-c-idle-watch: idle for ${minutes} minutes — stopping the workspace" + mkdir -p "$(dirname "$reason_file")" 2>/dev/null + # A pushed/discard marker means the app's stop guard has decided and + # its stop is under way: keep that decision rather than overwrite it. + case "$(cat "$reason_file" 2>/dev/null)" in + pushed|discard) ;; + *) printf 'idle\n' > "$reason_file" ;; + esac + kill -TERM "$signal_pid" + exit 0 done diff --git a/docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md b/docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md index 451aee8..e4886bb 100644 --- a/docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md +++ b/docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md @@ -37,6 +37,11 @@ has a quirk the app relies on, the agent reproduces it, and this document names - **Every request** needs `Authorization: Bearer `. A missing or wrong token gets `401` with an empty body. The comparison is constant-time. +- **Background requests** carry `X-Triple-C-Background: 1` (exactly `1`; any other value is an + ordinary request). They are served like any other, but neither the request nor the bytes of an + attach or tunnel it opens count as activity (see `GET /v1/activity`). The app sends it on polls + no person is behind — today the auth bridge's `/proc/net/tcp` read every 2 s + (`ContainerTarget::exec_oneshot_background`). - **Errors** are `4xx`/`5xx` with body `{"error": ""}`, Content-Type `application/json`. - **JSON** is UTF-8. Raw bytes inside JSON are standard base64 with padding. - **Users** are names resolved through `/etc/passwd` and `/etc/group` in the container. An exec @@ -121,14 +126,19 @@ When either side ends, the other is closed. ### `GET /v1/activity` `200 {"last_activity_unix": , "active_execs": , "active_tunnels": }`. -"Activity" means any request other than `health` and `activity`, or any byte through an attach or -tunnel. This is used by idle auto-stop later. +"Activity" means any request other than `health`, `activity` and background requests, or any byte +(either direction) through an attach or tunnel that was not opened by a background request. +`last_activity_unix` is the input to AWS idle auto-stop (`container/triple-c-idle-watch`), which +does **not** consult the two counters: an attach or tunnel that is open but silent ages like no +session at all, so an open terminal tab doesn't keep a workspace up by itself (ruling R11). The +counters stay for diagnostics. ## Client mapping (`host::ContainerTarget::Agent`) | ContainerTarget | Agent call | |---|---| | `exec_oneshot*` (all variants) | `POST /v1/exec`, with `limit` = the variant's limit (`MAX_ONESHOT_OUTPUT` or explicit) | +| `exec_oneshot_background` | `POST /v1/exec` with `X-Triple-C-Background: 1` (Docker: same as `exec_oneshot_limited`) | | `exec_detached` | `POST /v1/exec/detached` | | `attach` / `attach_exec` | `GET /v1/attach`; `input` is an `AsyncWrite` whose shutdown sends `close_stdin` | | `wait_exec_exit` | poll `GET /v1/exec/{id}` (same budget as the Docker version: 200 × 25 ms, shared constants in `docker/exec.rs`) | -- 2.52.0 From 151b7690df37b98123aa4b97eec0f9a902b1793c Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 12:31:28 -0800 Subject: [PATCH 28/43] fix(aws): the auth bridge's poll no longer keeps a workspace up The auth bridge reads /proc/net every 2 s; on the agent that now goes through ContainerTarget::exec_oneshot_background, which sends X-Triple-C-Background: 1 so the agent does not count it as activity. Docker behaviour is unchanged. The idle-stop hint says what idle means now (R11) and what happens to unsaved work. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/auth_bridge/mod.rs | 3 +- app/src-tauri/src/host/agent.rs | 43 +++++++++++++++---- app/src-tauri/src/host/agent/tests.rs | 21 ++++++++- app/src-tauri/src/host/mod.rs | 17 ++++++++ .../projects/home/config/HostSection.tsx | 6 ++- 5 files changed, 79 insertions(+), 11 deletions(-) diff --git a/app/src-tauri/src/auth_bridge/mod.rs b/app/src-tauri/src/auth_bridge/mod.rs index 43618af..9356bc4 100644 --- a/app/src-tauri/src/auth_bridge/mod.rs +++ b/app/src-tauri/src/auth_bridge/mod.rs @@ -326,7 +326,8 @@ async fn poll_loop( // bridge or stopping the container doesn't wait out an in-flight poll. let discovery = tokio::select! { _ = cancel.changed() => break, - res = container.exec_oneshot_limited("claude", cmd, PROC_NET_OUTPUT_LIMIT) => { + // Background: on AWS this poll must not keep an idle workspace up. + res = container.exec_oneshot_background("claude", cmd, PROC_NET_OUTPUT_LIMIT) => { res.map(|(out, _)| out) } }; diff --git a/app/src-tauri/src/host/agent.rs b/app/src-tauri/src/host/agent.rs index a8f7001..011838e 100644 --- a/app/src-tauri/src/host/agent.rs +++ b/app/src-tauri/src/host/agent.rs @@ -277,20 +277,35 @@ async fn exec_oneshot_raw( cmd: Vec, env: Vec, limit: usize, +) -> Result { + exec_oneshot_raw_marked(ep, user, cmd, env, limit, false).await +} + +/// The agent's background marker (protocol spec, "Activity"): a request that +/// carries it never counts as activity for idle stop. +pub const BACKGROUND_HEADER: &str = "X-Triple-C-Background"; + +async fn exec_oneshot_raw_marked( + ep: &AgentEndpoint, + user: &str, + cmd: Vec, + env: Vec, + limit: usize, + background: bool, ) -> Result { const CONTEXT: &str = "Failed to start exec"; let spec = ExecSpec::new(cmd).user(user).working_dir(None).env(env).tty(false); let mut body = spec_json(&spec); body.insert("limit".into(), serde_json::json!(limit)); - let resp = send( - CONTEXT, - http() - .post(ep.url("v1/exec")) - .header(reqwest::header::AUTHORIZATION, ep.auth()) - .json(&body), - ) - .await?; + let mut req = http() + .post(ep.url("v1/exec")) + .header(reqwest::header::AUTHORIZATION, ep.auth()) + .json(&body); + if background { + req = req.header(BACKGROUND_HEADER, "1"); + } + let resp = send(CONTEXT, req).await?; let status = resp.status(); if status == reqwest::StatusCode::PAYLOAD_TOO_LARGE { @@ -352,6 +367,18 @@ pub async fn exec_oneshot_limited( Ok((out.text(), out.exit_code)) } +/// [`exec_oneshot_limited`] marked as a background poll, so it never keeps +/// an idle workspace up (the auth bridge's `/proc/net` reads). +pub async fn exec_oneshot_background( + ep: &AgentEndpoint, + user: &str, + cmd: Vec, + limit: usize, +) -> Result<(String, i64), String> { + let out = exec_oneshot_raw_marked(ep, user, cmd, Vec::new(), limit, true).await?; + Ok((out.text(), out.exit_code)) +} + /// `exec_oneshot_as`: combined output under [`MAX_ONESHOT_OUTPUT`]. pub async fn exec_oneshot_as( ep: &AgentEndpoint, diff --git a/app/src-tauri/src/host/agent/tests.rs b/app/src-tauri/src/host/agent/tests.rs index 067a52d..c0ce2f0 100644 --- a/app/src-tauri/src/host/agent/tests.rs +++ b/app/src-tauri/src/host/agent/tests.rs @@ -22,6 +22,8 @@ const TOKEN: &str = "fake-agent-token-for-unit-tests-only-0000"; struct Seen { auth: Vec, exec_bodies: Vec, + /// The background marker of each `/v1/exec`, if any. + exec_background: Vec>, detached_bodies: Vec, status_polls: u32, resizes: Vec<(String, Value)>, @@ -62,7 +64,13 @@ async fn exec(State(f): St, h: HeaderMap, Json(body): Json) -> Response { if !f.authed(&h) { return unauthorized(); } - f.seen.lock().unwrap().exec_bodies.push(body.clone()); + { + let mut seen = f.seen.lock().unwrap(); + seen.exec_bodies.push(body.clone()); + seen.exec_background.push( + h.get("x-triple-c-background").and_then(|v| v.to_str().ok()).map(str::to_string), + ); + } match body["cmd"][0].as_str().unwrap_or("") { "sh" => Json(json!({"chunks": [{"s": "out", "d": b64(b"1000\n1001\n")}], "exit_code": 0})) .into_response(), @@ -304,6 +312,17 @@ async fn other_errors_carry_the_agents_message_and_no_token() { assert_eq!(e, "Failed to start exec: boom"); } +#[tokio::test] +async fn only_a_background_poll_carries_the_background_marker() { + let (ep, fake) = fake_agent().await; + let t = target(&ep); + t.exec_oneshot_limited("claude", cmd("sh"), 1000).await.unwrap(); + t.exec_oneshot_background("claude", cmd("sh"), 1000).await.unwrap(); + let seen = fake.seen.lock().unwrap(); + assert_eq!(seen.exec_background, [None, Some("1".to_string())]); + assert_eq!(seen.exec_bodies[0], seen.exec_bodies[1], "the request is otherwise the same"); +} + // ── one-shot execs ─────────────────────────────────────────────────────────── #[tokio::test] diff --git a/app/src-tauri/src/host/mod.rs b/app/src-tauri/src/host/mod.rs index b149f02..f4a3522 100644 --- a/app/src-tauri/src/host/mod.rs +++ b/app/src-tauri/src/host/mod.rs @@ -270,6 +270,23 @@ impl ContainerTarget { } } + /// [`Self::exec_oneshot_limited`] for a background poll: on the agent it + /// carries the background marker, so it never counts as activity for + /// idle stop. Docker has no idle stop; the call is the same there. + pub async fn exec_oneshot_background( + &self, + user: &str, + cmd: Vec, + limit: usize, + ) -> Result<(String, i64), String> { + match self { + ContainerTarget::Docker { container_id } => { + docker::exec::exec_oneshot_limited(container_id, user, cmd, limit).await + } + ContainerTarget::Agent(ep) => agent::exec_oneshot_background(ep, user, cmd, limit).await, + } + } + /// stdout and stderr kept apart: `(stdout, stderr, exit_code)`. pub async fn exec_oneshot_streams_as( &self, diff --git a/app/src/components/projects/home/config/HostSection.tsx b/app/src/components/projects/home/config/HostSection.tsx index d11b603..348ea29 100644 --- a/app/src/components/projects/home/config/HostSection.tsx +++ b/app/src/components/projects/home/config/HostSection.tsx @@ -257,7 +257,11 @@ export default function HostSection({ project, save, disabled }: Props) { Date: Sun, 15 Nov 2026 12:31:28 -0800 Subject: [PATCH 29/43] fix(aws): say why a task stopped, and bound every AWS call - classify_stop is wired into the death watch: stopped_among returns each task's StopKind, the tunnel registry keeps it, and ContainerExited carries a StopNote. Spot -> 'Spot interruption'; TaskFailedToStart -> Error with the reason; the entrypoint's idle exit (75, read from the workspace container's exit code) -> 'stopped after being idle'; a user stop is applied silently (P8). is_quota_error, dead, moved to tests. - The SDK config has a TimeoutConfig (30 s operation / 10 s attempt); the watch's per-group poll and StopTask also have tokio bounds. - Watcher tests cover silent sessions, unsaved work with the WIP push on and off, a failing push and the idle exit status; docs updated. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 + app/src-tauri/src/aws/connection.rs | 38 ++- app/src-tauri/src/aws/runtime/tests.rs | 1 + app/src-tauri/src/aws/tasks.rs | 36 +-- app/src-tauri/src/aws/tasks/tests.rs | 37 ++- app/src-tauri/src/container_watch.rs | 75 +++++- app/src-tauri/src/docker/aws_mode_tests.rs | 236 +++++++++++++++--- app/src-tauri/src/host/aws_host.rs | 52 ++-- app/src-tauri/src/host/aws_host/tests.rs | 4 +- app/src-tauri/src/host/tunnels.rs | 22 +- app/src/hooks/useProjectStatusEvents.test.ts | 29 +++ app/src/hooks/useProjectStatusEvents.ts | 64 ++++- .../specs/2026-11-13-aws-ecs-host-design.md | 13 +- 13 files changed, 511 insertions(+), 102 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2cf8bea..df6be51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -399,6 +399,12 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li user's index is untouched; needs the task definition's `stopTimeout` raised, 120 s) unless the stop marker says the app handled it. The idle watcher runs as root under a supervisor and signals the entrypoint's own PID (not PID 1, which is docker-init), so idle and ECS stops share one trap. + Idle = the agent's `last_activity_unix` older than N minutes (open-but-silent attaches/tunnels + don't count; requests with `X-Triple-C-Background: 1`, e.g. the auth bridge's poll, never do), + no `claude`, no scheduler job, no running clone, and no unsaved git work: with the WIP push on the + watcher pushes *before* signalling and stays up if that fails; with it off, dirty repos keep the + workspace up. An idle stop exits 75 (`aws::tasks::IDLE_EXIT_CODE`) so the death watch can tell it + from a crash (`classify_stop` → the `ContainerExited` toast). **The `TRIPLEC_` env prefix is reserved** on both hosts (`RESERVED_ENV_PREFIXES`; `workspace.rs` drops custom variables in it): the entrypoint's settings live there. - **Browser runtime libraries are baked in; browser *binaries* are not.** A layer runs diff --git a/app/src-tauri/src/aws/connection.rs b/app/src-tauri/src/aws/connection.rs index 1e14579..bd91b1c 100644 --- a/app/src-tauri/src/aws/connection.rs +++ b/app/src-tauri/src/aws/connection.rs @@ -5,6 +5,7 @@ use aws_config::identity::IdentityCache; use aws_config::profile::ProfileFileCredentialsProvider; use aws_config::provider_config::ProviderConfig; use aws_config::retry::RetryConfig; +use aws_config::timeout::TimeoutConfig; use aws_config::{BehaviorVersion, Region, SdkConfig}; use aws_credential_types::provider::error::CredentialsError; use aws_credential_types::provider::{future, ProvideCredentials, SharedCredentialsProvider}; @@ -81,9 +82,9 @@ fn keys_credentials(keys: &secure::AwsKeys) -> SharedCredentialsProvider { /// stray endpoint there would send every connection's signed requests (and /// the permission check) somewhere other than AWS. Connections talk to the /// real regional endpoints; a profile's own `endpoint_url` is ignored too. -/// The HTTP client, timeouts and stalled-stream protection are each service -/// client's defaults (the `default-https-client` stack every AWS crate here -/// is built with). +/// The HTTP client and stalled-stream protection are each service client's +/// defaults (the `default-https-client` stack every AWS crate here is built +/// with). Timeouts are ours: [`OPERATION_TIMEOUT`] / [`ATTEMPT_TIMEOUT`]. fn build_sdk_config(region: Region, credentials: SharedCredentialsProvider) -> SdkConfig { let mut builder = SdkConfig::builder() .behavior_version(BehaviorVersion::latest()) @@ -91,6 +92,7 @@ fn build_sdk_config(region: Region, credentials: SharedCredentialsProvider) -> S .credentials_provider(credentials) .identity_cache(IdentityCache::lazy().build()) .retry_config(RetryConfig::standard()) + .timeout_config(timeouts()) .time_source(SharedTimeSource::default()); if let Some(sleep) = default_async_sleep() { builder = builder.sleep_impl(sleep); @@ -98,6 +100,22 @@ fn build_sdk_config(region: Region, credentials: SharedCredentialsProvider) -> S builder.build() } +/// Every call the app makes through the SDK is a control-plane call with a +/// small answer (ECS, Secrets Manager, EFS, IAM, SSM, CloudWatch Logs, STS, +/// and S3 reads of state objects capped at 2 KiB); OpenTofu talks to AWS +/// itself, not through these clients. Without a bound a half-open +/// connection would hang a start, a stop or the death watch for minutes. +/// The operation bound covers every retry; the attempt bound is one try. +pub const OPERATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +pub const ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +fn timeouts() -> TimeoutConfig { + TimeoutConfig::builder() + .operation_timeout(OPERATION_TIMEOUT) + .operation_attempt_timeout(ATTEMPT_TIMEOUT) + .build() +} + /// Puts the profile's name into the source chain of every credentials error, /// so a failure deep inside the SDK can be reported against the profile. #[derive(Debug)] @@ -297,4 +315,18 @@ sts = assert_isolated(&cfg); assert!(cfg.credentials_provider().is_some()); } + + #[test] + fn every_sdk_call_is_bounded() { + let keys = crate::storage::secure::AwsKeys { + access_key_id: "AKIDTEST".into(), + secret_access_key: "s".into(), + session_token: None, + }; + let cfg = build_sdk_config(Region::new("us-west-2"), keys_credentials(&keys)); + let t = cfg.timeout_config().expect("a timeout config"); + assert_eq!(t.operation_timeout(), Some(OPERATION_TIMEOUT)); + assert_eq!(t.operation_attempt_timeout(), Some(ATTEMPT_TIMEOUT)); + assert!(ATTEMPT_TIMEOUT < OPERATION_TIMEOUT); + } } diff --git a/app/src-tauri/src/aws/runtime/tests.rs b/app/src-tauri/src/aws/runtime/tests.rs index 0319993..7c9111c 100644 --- a/app/src-tauri/src/aws/runtime/tests.rs +++ b/app/src-tauri/src/aws/runtime/tests.rs @@ -247,6 +247,7 @@ fn live(id: &str) -> TaskInfo { ), runtime_id: None, exec_agent_running: false, + exit_code: None, owner: Some(USER.into()), project: Some(PROJECT.into()), } diff --git a/app/src-tauri/src/aws/tasks.rs b/app/src-tauri/src/aws/tasks.rs index 41b0e89..b4167cb 100644 --- a/app/src-tauri/src/aws/tasks.rs +++ b/app/src-tauri/src/aws/tasks.rs @@ -64,6 +64,8 @@ pub struct TaskInfo { pub runtime_id: Option, /// The `workspace` container's ExecuteCommandAgent is RUNNING. pub exec_agent_running: bool, + /// The `workspace` container's exit code, once it has one. + pub exit_code: Option, /// Its `triple-c:owner` / `triple-c:project` tags (DescribeTasks with /// TAGS; RunTask's answer carries the tags it was given). pub owner: Option, @@ -116,6 +118,7 @@ pub(crate) fn task_info(t: &Task) -> TaskInfo { && a.last_status() == Some("RUNNING") }) }), + exit_code: workspace.and_then(|c| c.exit_code()), owner: tag_value(t.tags(), TAG_OWNER), project: tag_value(t.tags(), TAG_PROJECT), } @@ -337,11 +340,6 @@ pub fn quota_or(e: AwsError) -> AwsError { } } -#[allow(dead_code)] // for the UI's start errors (Task 8) -pub fn is_quota_error(e: &AwsError) -> bool { - matches!(e, AwsError::QuotaExceeded { .. }) -} - /// RunTask's answer: its one task, or an error from its `failures` /// (`(reason, detail)`), a quota one when they say so. pub fn run_task_outcome( @@ -480,13 +478,19 @@ async fn stopped_error(api: &A, logs: &TaskLogs, t: &TaskInfo) -> Aw // ----------------------------------------------------------- stop / watch +/// The entrypoint's exit status after an idle stop (`container/entrypoint.sh`, +/// `stop_keepalive`). A stop from ECS (SIGTERM) exits 0; this one is distinct +/// so an idle stop (`EssentialContainerExited`) is not mistaken for a crash. +pub const IDLE_EXIT_CODE: i32 = 75; + /// Why a task stopped, as the app reports it (spec, "Lifecycle and status"). -#[allow(dead_code)] // the stop reasons the UI shows (Task 8) #[derive(Debug, Clone, PartialEq, Eq)] pub enum StopKind { - /// Exited on its own (the essential container ended, idle stop, or AWS - /// retired it). - Exited, + /// Exited on its own (the essential container ended, or AWS retired it), + /// with ECS's stopped reason when it gave one. + Exited(Option), + /// The workspace's own idle stop ([`IDLE_EXIT_CODE`]). + Idle, /// A Fargate Spot interruption (P8). Spot, /// Never got going: the project goes to Error with this reason. @@ -495,7 +499,6 @@ pub enum StopKind { UserInitiated, } -#[allow(dead_code)] // Task 8 pub fn classify_stop(info: &TaskInfo) -> StopKind { match info.stop_code.as_deref() { Some("SpotInterruption") => StopKind::Spot, @@ -506,25 +509,28 @@ pub fn classify_stop(info: &TaskInfo) -> StopKind { .unwrap_or_else(|| "The task failed to start".into()), ), Some("UserInitiated") => StopKind::UserInitiated, - _ => StopKind::Exited, + Some("EssentialContainerExited") if info.exit_code == Some(IDLE_EXIT_CODE) => StopKind::Idle, + _ => StopKind::Exited(info.stopped_reason.clone().filter(|r| !r.trim().is_empty())), } } /// Of `arns`, the ones that are STOPPED or that ECS no longer knows (it -/// forgets stopped tasks after about an hour). A task merely being stopped -/// is not yet reported. For the AWS death watch. +/// forgets stopped tasks after about an hour), each with why it stopped (a +/// forgotten task: [`StopKind::Exited`] without a reason). A task merely +/// being stopped is not yet reported. For the AWS death watch. pub async fn stopped_among( api: &A, cluster: &str, arns: &[String], -) -> Result, AwsError> { +) -> Result, AwsError> { let mut stopped = Vec::new(); for chunk in arns.chunks(DESCRIBE_BATCH) { let known = api.describe(cluster, chunk).await?; for arn in chunk { match known.iter().find(|t| &t.arn == arn) { Some(t) if t.last_status != "STOPPED" => {} - _ => stopped.push(arn.clone()), + Some(t) => stopped.push((arn.clone(), classify_stop(t))), + None => stopped.push((arn.clone(), StopKind::Exited(None))), } } } diff --git a/app/src-tauri/src/aws/tasks/tests.rs b/app/src-tauri/src/aws/tasks/tests.rs index 340399a..fdd6eb2 100644 --- a/app/src-tauri/src/aws/tasks/tests.rs +++ b/app/src-tauri/src/aws/tasks/tests.rs @@ -44,6 +44,10 @@ fn env_info() -> EnvironmentInfo { } } +fn is_quota_error(e: &AwsError) -> bool { + matches!(e, AwsError::QuotaExceeded { .. }) +} + fn info(id: &str, last: &str) -> TaskInfo { TaskInfo { arn: task_arn(id), @@ -60,6 +64,7 @@ fn info(id: &str, last: &str) -> TaskInfo { task_definition_arn: td_arn(3), runtime_id: None, exec_agent_running: false, + exit_code: None, owner: Some(USER.into()), project: Some(PROJECT_ID.into()), } @@ -620,8 +625,14 @@ async fn stopped_among_reports_stopped_and_vanished_tasks_only() { task_arn("gone"), ]; let mut got = stopped_among(&api, &cluster(), &arns).await.unwrap(); - got.sort(); - assert_eq!(got, [task_arn("down"), task_arn("gone")]); + got.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!( + got, + [ + (task_arn("down"), StopKind::Exited(None)), + (task_arn("gone"), StopKind::Exited(None)), + ] + ); } // ------------------------------------------------------------ classify @@ -634,9 +645,17 @@ fn classify_stop_follows_the_spec_table() { ..info("t", "STOPPED") }; assert_eq!( - classify_stop(&with(Some("EssentialContainerExited"), None)), - StopKind::Exited + classify_stop(&with(Some("EssentialContainerExited"), Some("Essential container in task exited"))), + StopKind::Exited(Some("Essential container in task exited".into())) ); + // The entrypoint's idle stop exits with its own status. + let idle = TaskInfo { exit_code: Some(IDLE_EXIT_CODE), ..with(Some("EssentialContainerExited"), None) }; + assert_eq!(classify_stop(&idle), StopKind::Idle); + let crashed = TaskInfo { exit_code: Some(137), ..with(Some("EssentialContainerExited"), None) }; + assert_eq!(classify_stop(&crashed), StopKind::Exited(None)); + // Only with that stop code: a user stop of a task that exits 75 is a user stop. + let user = TaskInfo { exit_code: Some(IDLE_EXIT_CODE), ..with(Some("UserInitiated"), None) }; + assert_eq!(classify_stop(&user), StopKind::UserInitiated); assert_eq!( classify_stop(&with(Some("SpotInterruption"), None)), StopKind::Spot @@ -658,13 +677,13 @@ fn classify_stop_follows_the_spec_table() { ); assert_eq!( classify_stop(&with(Some("TerminationNotice"), None)), - StopKind::Exited + StopKind::Exited(None) ); assert_eq!( classify_stop(&with(Some("ServiceSchedulerInitiated"), None)), - StopKind::Exited + StopKind::Exited(None) ); - assert_eq!(classify_stop(&with(None, None)), StopKind::Exited); + assert_eq!(classify_stop(&with(None, Some(" "))), StopKind::Exited(None)); } // ------------------------------------------------------------ SDK mapping @@ -683,12 +702,14 @@ fn task_info_reads_the_workspace_container_and_its_exec_agent() { Container::builder() .name("sidecar") .runtime_id("wrong") + .exit_code(1) .build(), ) .containers( Container::builder() .name("workspace") .runtime_id("rt-1") + .exit_code(75) .managed_agents( ManagedAgent::builder() .name(ManagedAgentName::ExecuteCommandAgent) @@ -711,11 +732,13 @@ fn task_info_reads_the_workspace_container_and_its_exec_agent() { assert_eq!(i.stop_code.as_deref(), Some("SpotInterruption")); assert_eq!(i.runtime_id.as_deref(), Some("rt-1")); assert!(i.exec_agent_running); + assert_eq!(i.exit_code, Some(75), "the workspace container's, not the sidecar's"); assert_eq!(i.owner.as_deref(), Some(USER)); assert_eq!(i.project.as_deref(), Some(PROJECT_ID)); let bare = task_info(&Task::builder().task_arn("x").last_status("PENDING").build()); assert!(!bare.exec_agent_running); assert_eq!(bare.runtime_id, None); + assert_eq!(bare.exit_code, None); assert_eq!(bare.owner, None); } diff --git a/app/src-tauri/src/container_watch.rs b/app/src-tauri/src/container_watch.rs index 296d1d7..0ab8399 100644 --- a/app/src-tauri/src/container_watch.rs +++ b/app/src-tauri/src/container_watch.rs @@ -32,6 +32,7 @@ use serde::Serialize; use tauri::{AppHandle, Emitter}; use tokio::sync::watch; +use crate::aws::tasks::StopKind; use crate::host::watch::ExitSource; use crate::host::ContainerTarget; use crate::models::{Project, ProjectStatus}; @@ -68,6 +69,32 @@ pub enum ExitReason { pub struct ContainerExited<'a> { pub project: &'a Project, pub reason: ExitReason, + /// AWS: why ECS says the task stopped (P8). `None` on Docker, or when + /// the watch has no reason on record. + pub stop: Option, +} + +/// Why an AWS task stopped, for the UI's toast. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct StopNote { + /// `spot`, `idle`, `failed` (the project goes to Error), `user` (someone + /// stopped it: no toast) or `exited` (on its own). + pub kind: &'static str, + /// ECS's stopped reason, or a fixed text. + pub message: Option, +} + +impl StopNote { + pub fn of(kind: &StopKind) -> StopNote { + let (kind, message) = match kind { + StopKind::Spot => ("spot", Some("Spot interruption".to_string())), + StopKind::Idle => ("idle", None), + StopKind::FailedToStart(r) => ("failed", Some(r.clone())), + StopKind::UserInitiated => ("user", None), + StopKind::Exited(r) => ("exited", r.clone()), + }; + StopNote { kind, message } + } } fn project_busy(project_id: &str) -> bool { @@ -227,6 +254,19 @@ pub fn spawn( p.id, reason ); + // Why ECS stopped it, read before the tunnel (and with it the + // registry's record) goes. + let kind = p + .is_aws() + .then(|| p.container_id.as_deref().and_then(crate::host::tunnels::stop_kind)) + .flatten(); + let mut shown = p.clone(); + // A task that never got going is an error, not a stop (P8). + if let Some(StopKind::FailedToStart(_)) = kind { + if store_for_aws.update_status(&p.id, ProjectStatus::Error).is_ok() { + shown.status = ProjectStatus::Error; + } + } // An AWS task that stopped: its tunnel leads nowhere now, and its // task ARN is history (the data's access point stays). if p.is_aws() { @@ -245,7 +285,11 @@ pub fn spawn( }); } } - let _ = app.emit(CONTAINER_EXITED_EVENT, ContainerExited { project: p, reason }); + if let Some(store_p) = store_for_aws.get(&p.id) { + shown = store_p; + } + let stop = kind.as_ref().map(StopNote::of); + let _ = app.emit(CONTAINER_EXITED_EVENT, ContainerExited { project: &shown, reason, stop }); }) .await; }) @@ -266,15 +310,38 @@ mod tests { #[test] fn the_event_says_why_so_the_ui_can_tell_news_from_housekeeping() { let p = Project::new("demo".to_string(), Vec::new()); - let v = serde_json::to_value(ContainerExited { project: &p, reason: ExitReason::Exited }).unwrap(); + let v = serde_json::to_value(ContainerExited { project: &p, reason: ExitReason::Exited, stop: None }) + .unwrap(); assert_eq!(v["reason"], "exited"); assert_eq!(v["project"]["name"], "demo"); - let v = serde_json::to_value(ContainerExited { project: &p, reason: ExitReason::FoundStopped }) - .unwrap(); + assert!(v["stop"].is_null()); + let v = serde_json::to_value(ContainerExited { + project: &p, + reason: ExitReason::FoundStopped, + stop: Some(StopNote::of(&StopKind::Spot)), + }) + .unwrap(); assert_eq!(v["reason"], "found_stopped"); + assert_eq!(v["stop"], serde_json::json!({ "kind": "spot", "message": "Spot interruption" })); assert_eq!(CONTAINER_EXITED_EVENT, "project-container-exited"); } + #[test] + fn every_stop_kind_has_a_note_for_the_ui() { + let cases = [ + (StopKind::Spot, "spot", Some("Spot interruption")), + (StopKind::Idle, "idle", None), + (StopKind::FailedToStart("CannotPull".into()), "failed", Some("CannotPull")), + (StopKind::UserInitiated, "user", None), + (StopKind::Exited(Some("OOM".into())), "exited", Some("OOM")), + (StopKind::Exited(None), "exited", None), + ]; + for (k, kind, message) in cases { + let n = StopNote::of(&k); + assert_eq!((n.kind, n.message.as_deref()), (kind, message), "{k:?}"); + } + } + /// The shutdown path: a host call that never answers (a wedged daemon) /// must give way to cancellation at once, not hold teardown. #[tokio::test] diff --git a/app/src-tauri/src/docker/aws_mode_tests.rs b/app/src-tauri/src/docker/aws_mode_tests.rs index 1801cc2..3fe8964 100644 --- a/app/src-tauri/src/docker/aws_mode_tests.rs +++ b/app/src-tauri/src/docker/aws_mode_tests.rs @@ -698,20 +698,20 @@ fn is_idle(args: &[&str]) -> bool { #[test] fn is_idle_needs_every_condition() { - // now last execs tunnels claude_running jobs_running minutes - assert!(is_idle(&["10000", "9400", "0", "0", "0", "0", "10"]), "exactly N minutes"); - assert!(is_idle(&["10000", "1000", "0", "0", "0", "0", "10"])); - assert!(!is_idle(&["10000", "9401", "0", "0", "0", "0", "10"]), "one second short"); - assert!(!is_idle(&["10000", "1000", "1", "0", "0", "0", "10"]), "an exec is open"); - assert!(!is_idle(&["10000", "1000", "0", "2", "0", "0", "10"]), "a tunnel is open"); - assert!(!is_idle(&["10000", "1000", "0", "0", "1", "0", "10"]), "claude is running"); - assert!(!is_idle(&["10000", "1000", "0", "0", "0", "1", "10"]), "a scheduler job runs"); + // now, last activity, claude running, jobs running, minutes. + assert!(is_idle(&["10000", "9400", "0", "0", "10"]), "exactly N minutes"); + assert!(is_idle(&["10000", "1000", "0", "0", "10"])); + assert!(!is_idle(&["10000", "9401", "0", "0", "10"]), "one second short"); + assert!(!is_idle(&["10000", "1000", "1", "0", "10"]), "claude is running"); + assert!(!is_idle(&["10000", "1000", "0", "1", "10"]), "a scheduler job runs"); // Anything unreadable is "not idle". - assert!(!is_idle(&["10000", "", "0", "0", "0", "0", "10"])); - assert!(!is_idle(&["10000", "x", "0", "0", "0", "0", "10"])); - assert!(!is_idle(&["10000", "1000", "", "0", "0", "0", "10"])); - assert!(!is_idle(&["10000", "1000", "0", "0", "0", "0", "0"]), "0 minutes is off"); - assert!(!is_idle(&["10000", "1000", "0", "0", "0", "0", ""])); + assert!(!is_idle(&["10000", "", "0", "0", "10"])); + assert!(!is_idle(&["10000", "x", "0", "0", "10"])); + assert!(!is_idle(&["10000", "1000", "", "0", "10"])); + assert!(!is_idle(&["10000", "1000", "0", "0", "0"]), "0 minutes is off"); + assert!(!is_idle(&["10000", "1000", "0", "0", ""])); + // The old arity (with exec/tunnel counters) is refused, not misread. + assert!(!is_idle(&["10000", "1000", "0", "0", "0", "0", "10"])); } struct Watch { @@ -723,6 +723,18 @@ impl Watch { let tmp = tempfile::tempdir().unwrap(); write_script(tmp.path(), "triple-c-idle-watch", IDLE_WATCH); write_script(tmp.path(), "triple-c-scheduler", SCHEDULER); + // A stand-in WIP push: `--dry-run` prints the file `dry` (nothing: + // clean repos), a push records its arguments in `pushes` and exits + // with the number in `push_rc` (0 when absent). + let d = tmp.path().display(); + write_script( + tmp.path(), + "triple-c-wip-push", + &format!( + "#!/bin/bash\nif [ \"$1\" = --dry-run ]; then cat '{d}/dry' 2>/dev/null; exit 0; fi\n\ + echo \"$*\" >> '{d}/pushes'\nexit \"$(cat '{d}/push_rc' 2>/dev/null || echo 0)\"\n" + ), + ); fs::write(tmp.path().join("token"), "test-token-not-real").unwrap(); fs::create_dir_all(tmp.path().join("home")).unwrap(); Watch { tmp } @@ -743,10 +755,56 @@ impl Watch { .env("TRIPLEC_STOP_REASON_FILE", p.join("reason")) .env("TRIPLEC_SCHEDULER_BIN", p.join("triple-c-scheduler")) .env("TRIPLEC_SCHEDULER_USER", "") + .env("TRIPLEC_WIP_PUSH_BIN", p.join("triple-c-wip-push")) // The test host may well be running a `claude` of its own. .env("TRIPLEC_IDLE_PROC_NAME", "triple-c-no-such-process"); c } + + fn dirty(&self) { + fs::write( + self.tmp.path().join("dry"), + "{\"folder\":\"api\",\"dirty\":true,\"unpushed\":0,\"branch\":\"main\"}\n\ + {\"folder\":\"web\",\"dirty\":false,\"unpushed\":0,\"branch\":\"main\"}\n", + ) + .unwrap(); + } + + fn pushes(&self) -> String { + fs::read_to_string(self.tmp.path().join("pushes")).unwrap_or_default() + } +} + +/// Stands in for the entrypoint: records the TERM it gets in `got`. +fn fake_entrypoint(dir: &Path) -> std::process::Child { + Command::new("bash") + .arg("-c") + .arg("trap 'echo term > \"$1\"; exit 0' TERM; while :; do sleep 0.1; done") + .arg("t") + .arg(dir.join("got")) + .spawn() + .unwrap() +} + +/// Run the watcher loop for at most `secs`; `None` when it was still running +/// (it had not signalled) and was killed. Never hangs a test run. +fn run_watch_for(mut cmd: Command, secs: u64) -> Option { + let mut child = cmd + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs); + while std::time::Instant::now() < deadline { + if child.try_wait().unwrap().is_some() { + return Some(child.wait_with_output().unwrap()); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + child.kill().ok(); + let out = child.wait_with_output().unwrap(); + eprintln!("watcher (killed): {}", text(&out)); + None } /// A one-shot fake agent: answers `/v1/activity` with `body` when the bearer @@ -782,10 +840,12 @@ s.serve_forever() (format!("http://127.0.0.1:{}", line.trim()), child) } +const OLD_ACTIVITY: &str = r#"{"last_activity_unix":1,"active_execs":0,"active_tunnels":0}"#; + #[test] fn idle_watch_check_counts_an_unfinished_clone_as_busy() { let w = Watch::new(); - let (url, mut agent) = fake_agent(w.tmp.path(), r#"{"last_activity_unix":1,"active_execs":0,"active_tunnels":0}"#); + let (url, mut agent) = fake_agent(w.tmp.path(), OLD_ACTIVITY); let clone = w.tmp.path().join("clone"); fs::create_dir_all(&clone).unwrap(); let busy = w.cmd(&url, 0).env("TRIPLEC_CLONE_STATUS_DIR", &clone).arg("--check").output().unwrap(); @@ -806,40 +866,115 @@ fn idle_watch_check_treats_an_unreachable_agent_as_busy() { #[test] fn idle_watch_check_reads_activity_with_the_token() { let w = Watch::new(); - let (url, mut agent) = fake_agent(w.tmp.path(), r#"{"last_activity_unix":1,"active_execs":0,"active_tunnels":0}"#); + let (url, mut agent) = fake_agent(w.tmp.path(), OLD_ACTIVITY); let idle = w.cmd(&url, 0).arg("--check").output().unwrap(); - let (busy_url, mut busy_agent) = - fake_agent(w.tmp.path(), r#"{"last_activity_unix":1,"active_execs":1,"active_tunnels":0}"#); + let recent = format!( + r#"{{"last_activity_unix":{},"active_execs":0,"active_tunnels":0}}"#, + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + ); + let (busy_url, mut busy_agent) = fake_agent(w.tmp.path(), &recent); let busy = w.cmd(&busy_url, 0).arg("--check").output().unwrap(); fs::write(w.tmp.path().join("token"), "wrong").unwrap(); let unauth = w.cmd(&url, 0).arg("--check").output().unwrap(); agent.kill().ok(); busy_agent.kill().ok(); assert_eq!(idle.status.code(), Some(0), "{}", text(&idle)); - assert_eq!(busy.status.code(), Some(1), "{}", text(&busy)); + assert_eq!(busy.status.code(), Some(1), "recent activity: {}", text(&busy)); assert_eq!(unauth.status.code(), Some(1), "a 401 is not idle: {}", text(&unauth)); } +#[test] +fn idle_watch_ignores_open_but_silent_execs_and_tunnels() { + // R11: an open terminal tab (an attach) or a tunnel no longer keeps the + // workspace up; only their traffic moves the clock. + let w = Watch::new(); + let (url, mut agent) = + fake_agent(w.tmp.path(), r#"{"last_activity_unix":1,"active_execs":3,"active_tunnels":2}"#); + let out = w.cmd(&url, 0).arg("--check").output().unwrap(); + agent.kill().ok(); + assert_eq!(out.status.code(), Some(0), "{}", text(&out)); +} + +#[test] +fn idle_watch_check_with_unsaved_work_depends_on_the_wip_push() { + let w = Watch::new(); + w.dirty(); + let (url, mut agent) = fake_agent(w.tmp.path(), OLD_ACTIVITY); + let off = w.cmd(&url, 0).env("TRIPLEC_WIP_PUSH", "0").arg("--check").output().unwrap(); + let on = w.cmd(&url, 0).env("TRIPLEC_WIP_PUSH", "1").arg("--check").output().unwrap(); + // A dry run that fails is a doubt: not idle. + write_script(w.tmp.path(), "triple-c-wip-push", "#!/bin/bash\nexit 1\n"); + let unknown = w.cmd(&url, 0).env("TRIPLEC_WIP_PUSH", "1").arg("--check").output().unwrap(); + agent.kill().ok(); + assert_eq!(off.status.code(), Some(1), "unsaved work, WIP push off: {}", text(&off)); + assert_eq!(on.status.code(), Some(0), "the WIP push would take it: {}", text(&on)); + assert_eq!(unknown.status.code(), Some(1), "{}", text(&unknown)); + assert_eq!(w.pushes(), "", "--check never pushes"); +} + #[test] fn idle_watch_signals_the_entrypoint_with_reason_idle() { let w = Watch::new(); - let (url, mut agent) = fake_agent(w.tmp.path(), r#"{"last_activity_unix":1,"active_execs":0,"active_tunnels":0}"#); - // Stands in for the entrypoint: records the TERM it gets. - let target = Command::new("bash") - .arg("-c") - .arg("trap 'echo term > \"$1\"; exit 0' TERM; while :; do sleep 0.1; done") - .arg("t") - .arg(w.tmp.path().join("got")) - .spawn() - .unwrap(); - let mut target = target; - let out = w.cmd(&url, target.id()).output().unwrap(); + let (url, mut agent) = fake_agent(w.tmp.path(), OLD_ACTIVITY); + let mut target = fake_entrypoint(w.tmp.path()); + let out = run_watch_for(w.cmd(&url, target.id()), 20).expect("it signals"); let status = target.wait().unwrap(); agent.kill().ok(); assert!(out.status.success(), "{}", text(&out)); assert!(status.success()); assert_eq!(fs::read_to_string(w.tmp.path().join("got")).unwrap().trim(), "term"); assert_eq!(fs::read_to_string(w.tmp.path().join("reason")).unwrap().trim(), "idle"); + assert_eq!(w.pushes(), "", "nothing unsaved, nothing pushed"); +} + +#[test] +fn idle_watch_with_unsaved_work_and_the_wip_push_off_stays_up() { + let w = Watch::new(); + w.dirty(); + let (url, mut agent) = fake_agent(w.tmp.path(), OLD_ACTIVITY); + let mut target = fake_entrypoint(w.tmp.path()); + let mut cmd = w.cmd(&url, target.id()); + cmd.env("TRIPLEC_WIP_PUSH", "0"); + let ran = run_watch_for(cmd, 4); + target.kill().ok(); + target.wait().ok(); + agent.kill().ok(); + assert!(ran.is_none(), "it must not stop: {:?}", ran.map(|o| text(&o))); + assert!(!w.tmp.path().join("got").exists(), "no TERM"); + assert!(!w.tmp.path().join("reason").exists(), "no marker"); + assert_eq!(w.pushes(), ""); +} + +#[test] +fn idle_watch_pushes_before_an_idle_stop_and_stays_up_while_the_push_fails() { + // The push fails: the workspace stays up and retries, backing off. + let w = Watch::new(); + w.dirty(); + fs::write(w.tmp.path().join("push_rc"), "1").unwrap(); + let (url, mut agent) = fake_agent(w.tmp.path(), OLD_ACTIVITY); + let mut target = fake_entrypoint(w.tmp.path()); + let mut cmd = w.cmd(&url, target.id()); + cmd.env("TRIPLEC_WIP_PUSH", "1").env("TRIPLEC_WIP_BACKOFF_MAX", "2"); + let ran = run_watch_for(cmd, 6); + assert!(ran.is_none(), "a failed push must not stop the workspace"); + assert!(!w.tmp.path().join("got").exists(), "no TERM"); + assert!(!w.tmp.path().join("reason").exists(), "no marker"); + let tries = w.pushes().lines().count(); + assert!((1..=3).contains(&tries), "retried with a back-off, not every second: {tries}"); + assert!(w.pushes().lines().all(|l| l == "--reason idle"), "{}", w.pushes()); + + // Now the push works: it pushes once, then signals with the idle marker. + fs::write(w.tmp.path().join("push_rc"), "0").unwrap(); + fs::remove_file(w.tmp.path().join("pushes")).unwrap(); + let mut cmd = w.cmd(&url, target.id()); + cmd.env("TRIPLEC_WIP_PUSH", "1"); + let out = run_watch_for(cmd, 20).expect("it stops once the push succeeded"); + target.wait().unwrap(); + agent.kill().ok(); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(w.pushes(), "--reason idle\n"); + assert_eq!(fs::read_to_string(w.tmp.path().join("got")).unwrap().trim(), "term"); + assert_eq!(fs::read_to_string(w.tmp.path().join("reason")).unwrap().trim(), "idle"); } #[test] @@ -847,13 +982,13 @@ fn idle_watch_keeps_a_stop_guard_marker_already_recorded() { for marker in ["pushed", "discard"] { let w = Watch::new(); fs::write(w.tmp.path().join("reason"), format!("{marker}\n")).unwrap(); - let (url, mut agent) = fake_agent(w.tmp.path(), r#"{"last_activity_unix":1,"active_execs":0,"active_tunnels":0}"#); + let (url, mut agent) = fake_agent(w.tmp.path(), OLD_ACTIVITY); let mut target = Command::new("bash") .arg("-c") .arg("trap 'exit 0' TERM; while :; do sleep 0.1; done") .spawn() .unwrap(); - let out = w.cmd(&url, target.id()).output().unwrap(); + let out = run_watch_for(w.cmd(&url, target.id()), 20).expect("it signals"); target.wait().unwrap(); agent.kill().ok(); assert!(out.status.success(), "{}", text(&out)); @@ -864,7 +999,7 @@ fn idle_watch_keeps_a_stop_guard_marker_already_recorded() { // ------------------------------------------------------- stop (SIGTERM) path #[test] -fn aws_stop_runs_the_wip_push_with_the_recorded_reason_once() { +fn aws_stop_pushes_on_sigterm_once_and_leaves_an_idle_stop_to_the_watcher() { let tmp = tempfile::tempdir().unwrap(); let log = tmp.path().join("calls"); let fake = write_script( @@ -882,13 +1017,18 @@ fn aws_stop_runs_the_wip_push_with_the_recorded_reason_once() { assert!(out.status.success(), "{}", text(&out)); assert_eq!(fs::read_to_string(&log).unwrap(), "--reason sigterm\n"); + // An idle stop: the watcher already pushed, so the trap does not, and it + // exits with the idle status the app reads from ECS. fs::remove_file(&log).unwrap(); fs::write(&reason, "idle\n").unwrap(); - let out = bash(&script, &[], &[("TRIPLEC_HOST", "aws"), ("TRIPLEC_WIP_PUSH", "1")], &[]); + let idle_script = format!("{blocks}\naws_before_stop\necho \"status=${{triplec_exit_status:-0}}\"\n"); + let out = bash(&idle_script, &[], &[("TRIPLEC_HOST", "aws"), ("TRIPLEC_WIP_PUSH", "1")], &[]); assert!(out.status.success(), "{}", text(&out)); - assert_eq!(fs::read_to_string(&log).unwrap(), "--reason idle\n"); + assert!(!log.exists(), "no second push after the watcher's"); + assert!(text(&out).contains("entrypoint: stopping (idle)"), "{}", text(&out)); + assert!(text(&out).contains("status=75"), "{}", text(&out)); + fs::remove_file(&reason).unwrap(); - fs::remove_file(&log).unwrap(); let out = bash(&script, &[], &[("TRIPLEC_HOST", "aws"), ("TRIPLEC_WIP_PUSH", "0")], &[]); assert!(out.status.success(), "{}", text(&out)); assert!(!log.exists(), "WIP push off"); @@ -922,6 +1062,32 @@ fn aws_stop_skips_its_push_when_the_app_already_pushed_or_the_user_declined() { assert_eq!(fs::read_to_string(&log).unwrap(), "--reason sigterm\n"); } +#[test] +fn the_idle_exit_status_is_the_one_the_app_reads() { + assert!( + block(ENTRYPOINT, "aws_stop") + .contains(&format!("triplec_idle_exit={}\n", crate::aws::tasks::IDLE_EXIT_CODE)), + "entrypoint.sh and aws::tasks::IDLE_EXIT_CODE disagree" + ); + assert!(ENTRYPOINT.contains("exit \"${triplec_exit_status:-0}\""), "stop_keepalive exits with it"); + // The watcher gets the WIP setting through its scrubbed environment. + assert!(block(ENTRYPOINT, "aws_stop").contains("TRIPLEC_WIP_PUSH=\"${TRIPLEC_WIP_PUSH:-0}\"")); +} + +#[test] +fn start_clone_repos_marks_done_however_the_clone_ends() { + let tmp = tempfile::tempdir().unwrap(); + let status = tmp.path().join("status"); + // clone_repos replaced by one that fails half-way. + let script = format!( + "{}\nclone_repos() {{ exit 3; }}\nstart_clone_repos '[x]' \"$1\"\nwait\n", + aws_blocks(&["aws_env", "aws_repos"]) + ); + let out = bash(&script, &[&status], &[], &[]); + assert!(out.status.success(), "{}", text(&out)); + assert!(status.join("done").exists(), "a failed clone still ends: {}", text(&out)); +} + #[test] fn docker_mode_never_reaches_the_aws_blocks() { // Every new step sits behind TRIPLEC_HOST=aws; the scripts are only diff --git a/app/src-tauri/src/host/aws_host.rs b/app/src-tauri/src/host/aws_host.rs index a7ba3c0..5cb827f 100644 --- a/app/src-tauri/src/host/aws_host.rs +++ b/app/src-tauri/src/host/aws_host.rs @@ -35,7 +35,7 @@ use crate::aws::runtime::{ }; use crate::aws::tasks::{ find_or_run, find_project_task, stopped_among, task_log_stream, wait_ready, SdkTaskApi, - TaskApi, TaskInfo, TaskLogs, WaitTiming, LOG_TAIL_LINES, STOP_REASON, + StopKind, TaskApi, TaskInfo, TaskLogs, WaitTiming, LOG_TAIL_LINES, STOP_REASON, }; use crate::aws::tofu_modules::{ environment_cluster_name, log_stream_prefix, owner_key, task_family, @@ -75,6 +75,11 @@ const HEALTH_TIMEOUT: Duration = Duration::from_secs(30); const HEALTH_INTERVAL: Duration = Duration::from_secs(1); /// How long a terminal waits for a background re-attach. pub const REATTACH_WAIT: Duration = Duration::from_secs(90); +/// The most one AWS death-watch poll of a group may take. +const WATCH_CALL_TIMEOUT: Duration = Duration::from_secs(60); +/// The most a StopTask (credentials included) may take before the stop is +/// reported as failed; the SDK's own bound is per call. +const STOP_TIMEOUT: Duration = Duration::from_secs(45); fn s(e: E) -> String { e.to_string() @@ -730,7 +735,18 @@ pub async fn task_log(project: &Project, settings: &AppSettings, limit: i32) -> pub async fn stop_task_arn(api: &T, task_arn: &str) -> Result<(), String> { let cluster = cluster_of_task(task_arn) .ok_or_else(|| format!("{task_arn} is not a task ARN with a cluster"))?; - api.stop_task(cluster, task_arn, STOP_REASON).await.map_err(s) + bounded_stop(api.stop_task(cluster, task_arn, STOP_REASON)).await +} + +/// A StopTask, bounded by [`STOP_TIMEOUT`]: a stop that cannot reach AWS +/// fails (and the project keeps its task ARN) instead of hanging the UI. +async fn bounded_stop( + call: impl std::future::Future>, +) -> Result<(), String> { + tokio::time::timeout(STOP_TIMEOUT, call) + .await + .map_err(|_| format!("StopTask did not answer within {STOP_TIMEOUT:?}; try again"))? + .map_err(s) } /// `ProjectHost::stop` for an agent target: the task behind a registered @@ -738,10 +754,7 @@ pub async fn stop_task_arn(api: &T, task_arn: &str) -> Result<(), St pub async fn stop_endpoint(ep: &AgentEndpoint) -> Option> { let (_connection, _region, cluster, arn) = tunnels::task_of(ep)?; let (project_id, sdk) = tunnels::owner_of(ep)?; - let res = SdkTaskApi::new(&sdk) - .stop_task(&cluster, &arn, STOP_REASON) - .await - .map_err(s); + let res = bounded_stop(SdkTaskApi::new(&sdk).stop_task(&cluster, &arn, STOP_REASON)).await; if res.is_ok() { tunnels::close(&project_id).await; } @@ -1089,22 +1102,29 @@ impl AwsTaskLookup for StoreTaskLookup { Box::pin(async move { let settings = self.settings.get(); let conn = crate::commands::host_commands::aws_conn(&group.connection_id, &settings)?; - let sdk = sdk_config(&conn, &group.region).await.map_err(s)?; - let stopped = stopped_among(&SdkTaskApi::new(&sdk), &group.cluster_arn, arns) - .await - .map_err(s)?; + // The SDK bounds each call; this bounds the whole poll of a group + // (credentials included, which an SSO refresh can stall), so one + // stuck group never stalls the watch of every other. + let stopped = tokio::time::timeout(WATCH_CALL_TIMEOUT, async { + let sdk = sdk_config(&conn, &group.region).await.map_err(s)?; + stopped_among(&SdkTaskApi::new(&sdk), &group.cluster_arn, arns) + .await + .map_err(s) + }) + .await + .map_err(|_| format!("ECS did not answer within {WATCH_CALL_TIMEOUT:?}"))??; record_stopped(&self.projects.list(), &stopped); - Ok(stopped) + Ok(stopped.into_iter().map(|(arn, _)| arn).collect()) }) } } -/// Tell the registry which targets' tasks ECS reports stopped, so -/// `has_exited` confirms them (R3). -pub fn record_stopped(projects: &[Project], stopped: &[String]) { +/// Tell the registry which targets' tasks ECS reports stopped, and why, so +/// `has_exited` confirms them (R3) and the death report can say why (P8). +pub fn record_stopped(projects: &[Project], stopped: &[(String, StopKind)]) { for w in projects.iter().filter_map(watched_task) { - if stopped.contains(&w.task_arn) { - tunnels::mark_stopped(w.target.id()); + if let Some((_, why)) = stopped.iter().find(|(arn, _)| *arn == w.task_arn) { + tunnels::mark_stopped(w.target.id(), why.clone()); } } } diff --git a/app/src-tauri/src/host/aws_host/tests.rs b/app/src-tauri/src/host/aws_host/tests.rs index a4560cb..ecfeb00 100644 --- a/app/src-tauri/src/host/aws_host/tests.rs +++ b/app/src-tauri/src/host/aws_host/tests.rs @@ -57,6 +57,7 @@ fn task(p: &Project, id: &str, last: &str, ready: bool) -> TaskInfo { task_definition_arn: format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{}:1", family(p)), runtime_id: ready.then(|| "rt-1".into()), exec_agent_running: ready, + exit_code: None, owner: Some(USER.into()), project: Some(p.id.clone()), } @@ -572,8 +573,9 @@ async fn the_watch_reports_a_running_aws_project_as_its_stored_target_and_confir assert_eq!(w.group.cluster_arn, cluster_arn()); assert_eq!(w.group.connection_id, "c1"); assert!(!w.target.has_exited().await, "nothing recorded yet"); - record_stopped(std::slice::from_ref(&p), &[task_arn("w1")]); + record_stopped(std::slice::from_ref(&p), &[(task_arn("w1"), StopKind::Idle)]); assert!(w.target.has_exited().await); + assert_eq!(crate::host::tunnels::stop_kind(w.target.id()), Some(StopKind::Idle)); let mut stopped = p.clone(); stopped.status = ProjectStatus::Stopped; diff --git a/app/src-tauri/src/host/tunnels.rs b/app/src-tauri/src/host/tunnels.rs index 49c760e..0232bde 100644 --- a/app/src-tauri/src/host/tunnels.rs +++ b/app/src-tauri/src/host/tunnels.rs @@ -20,6 +20,7 @@ use futures_util::future::BoxFuture; use tokio::sync::watch; use super::agent::AgentEndpoint; +use crate::aws::tasks::StopKind; use super::ssm::{EcsTask, PortForward}; /// Something that owns a tunnel's resources: the real [`PortForward`], or a @@ -58,8 +59,9 @@ pub type TaskOf = (String, String, String, String); #[derive(Default)] struct Registry { live: HashMap, - /// Target ids (endpoint base URLs) whose task the AWS watch saw STOPPED. - stopped: HashSet, + /// Target ids (endpoint base URLs) whose task the AWS watch saw STOPPED, + /// and why. + stopped: HashMap, /// Projects whose tunnel is being re-opened in the background. pending: HashSet, /// Bumped by every [`close`] (a stop or remove), every [`register`] (a @@ -245,14 +247,19 @@ pub async fn close_all() { futures_util::future::join_all(all.into_iter().map(|t| t.forward.close())).await; } -/// The AWS watch saw the task behind target `target_id` STOPPED. -pub fn mark_stopped(target_id: &str) { - lock().stopped.insert(target_id.to_string()); +/// The AWS watch saw the task behind target `target_id` STOPPED, for `why`. +pub fn mark_stopped(target_id: &str, why: StopKind) { + lock().stopped.insert(target_id.to_string(), why); } /// Whether the AWS watch has confirmed the task behind `target_id` stopped. pub fn is_stopped(target_id: &str) -> bool { - lock().stopped.contains(target_id) + lock().stopped.contains_key(target_id) +} + +/// Why the task behind `target_id` stopped, once the watch has seen it. +pub fn stop_kind(target_id: &str) -> Option { + lock().stopped.get(target_id).cloned() } pub fn is_pending(project_id: &str) -> bool { @@ -341,7 +348,8 @@ pub(crate) mod tests { let p = pid("rereg"); register(&p, fake_tunnel(61002, closes.clone())); let id = AgentEndpoint::loopback(61003, "tok").base_url.to_string(); - mark_stopped(&id); + mark_stopped(&id, StopKind::Spot); + assert_eq!(stop_kind(&id), Some(StopKind::Spot)); let old = register(&p, fake_tunnel(61003, closes.clone())).unwrap(); assert_eq!(old.endpoint.base_url.port(), Some(61002)); assert!(!is_stopped(&id), "a new task on a reused port is not stopped"); diff --git a/app/src/hooks/useProjectStatusEvents.test.ts b/app/src/hooks/useProjectStatusEvents.test.ts index dee0635..128242b 100644 --- a/app/src/hooks/useProjectStatusEvents.test.ts +++ b/app/src/hooks/useProjectStatusEvents.test.ts @@ -54,6 +54,35 @@ describe("useProjectStatusEvents", () => { expect(toasts).toHaveLength(0); }); + it("says why an AWS task stopped, and keeps a user's stop quiet", async () => { + renderHook(() => useProjectStatusEvents()); + await vi.waitFor(() => expect(handler).not.toBeNull()); + const fire = (stop: unknown, reason = "exited") => { + useAppState.setState({ toasts: [] }); + handler!({ payload: { project: project({ status: "stopped" }), reason, stop } }); + return useAppState.getState().toasts; + }; + + let t = fire({ kind: "spot", message: "Spot interruption" }); + expect(t[0].kind).toBe("info"); + expect(t[0].message).toContain("Spot interruption"); + + t = fire({ kind: "idle", message: null }); + expect(t[0].kind).toBe("info"); + expect(t[0].message).toContain("idle"); + + expect(fire({ kind: "user", message: null })).toHaveLength(0); + + // A task that never started is news even when found at startup. + t = fire({ kind: "failed", message: "CannotPullContainerError: denied" }, "found_stopped"); + expect(t[0].kind).toBe("error"); + expect(t[0].detail).toContain("CannotPullContainerError"); + + t = fire({ kind: "exited", message: "OutOfMemoryError" }); + expect(t[0].kind).toBe("error"); + expect(t[0].detail).toContain("OutOfMemoryError"); + }); + it("stops listening on unmount", async () => { const { unmount } = renderHook(() => useProjectStatusEvents()); await vi.waitFor(() => expect(handler).not.toBeNull()); diff --git a/app/src/hooks/useProjectStatusEvents.ts b/app/src/hooks/useProjectStatusEvents.ts index 87a66f8..02c94f6 100644 --- a/app/src/hooks/useProjectStatusEvents.ts +++ b/app/src/hooks/useProjectStatusEvents.ts @@ -12,6 +12,56 @@ export interface ContainerExitedEvent { * while the app was closed, e.g. a host reboot); nothing was interrupted. */ reason: "exited" | "found_stopped"; + /** AWS: why ECS says the task stopped (`StopNote`); absent on Docker. */ + stop?: StopNote | null; +} + +/** `container_watch.rs`'s `StopNote`. */ +export interface StopNote { + /** `user`: someone stopped it (console, CLI, another machine) — no toast. */ + kind: "spot" | "idle" | "failed" | "user" | "exited"; + message: string | null; +} + +/** The toast for an exit, or null for none. */ +export function exitToast( + name: string, + reason: ContainerExitedEvent["reason"], + stop: StopNote | null | undefined, +): { kind: "error" | "info"; message: string; detail?: string } | null { + if (stop?.kind === "user") return null; + if (stop?.kind === "failed") { + return { + kind: "error", + message: `The AWS workspace for “${name}” failed to start`, + detail: stop.message ?? undefined, + }; + } + if (reason !== "exited") return null; + switch (stop?.kind) { + case "spot": + return { + kind: "info", + message: `“${name}” stopped: Spot interruption`, + detail: + "AWS reclaimed the Spot capacity it ran on. Start the project again to get a new workspace.", + }; + case "idle": + return { + kind: "info", + message: `“${name}” stopped after being idle`, + detail: "Its AWS workspace stopped itself to save cost. Start the project again to continue.", + }; + default: + return { + kind: "error", + message: `The container for “${name}” stopped`, + detail: + "It exited without Triple-C stopping it — open sessions were disconnected. " + + "Start the project again to reconnect." + + (stop?.message ? ` (${stop.message})` : ""), + }; + } } /** @@ -25,18 +75,12 @@ export function useProjectStatusEvents() { let cancelled = false; let unlisten: UnlistenFn | null = null; listen("project-container-exited", (event) => { - const { project, reason } = event.payload; + const { project, reason, stop } = event.payload; const state = useAppState.getState(); state.updateProjectInList(project); - if (reason !== "exited") return; - state.pushToast({ - kind: "error", - message: `The container for “${project.name}” stopped`, - detail: - "It exited without Triple-C stopping it — open sessions were disconnected. " + - "Start the project again to reconnect.", - dedupeKey: `container-exited-${project.id}`, - }); + const toast = exitToast(project.name, reason, stop); + if (!toast) return; + state.pushToast({ ...toast, dedupeKey: `container-exited-${project.id}` }); }) .then((fn) => { if (cancelled) fn(); diff --git a/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md b/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md index 60edfcc..9bfcb0b 100644 --- a/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md +++ b/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md @@ -349,7 +349,9 @@ updates use the existing "new image available" flow: the next start uses the new `git status --porcelain` and any unpushed commits in each repo. If anything would be lost, it offers: **Push to a `triple-c/wip/` branch** (the preselected default), **Stop anyway**, or **Cancel**. The same check runs before idle auto-stop. If anything is dirty, idle auto-stop - pushes a WIP branch instead of throwing work away. + pushes a WIP branch instead of throwing work away, and stops only once that push succeeded (a + failed push keeps the workspace up and is retried, backing off). With the WIP push off, unsaved + work keeps the workspace up. - **Fargate task retirement and Spot interruption** send `SIGTERM` with a short deadline (2 minutes for Spot). The entrypoint's `SIGTERM` trap runs the same WIP push, best-effort, when the project has it enabled. @@ -497,9 +499,12 @@ way. - user-initiated → Stopped. EventBridge → SQS for push notifications is a later improvement. -- **Idle auto-stop** runs **inside** the task: the agent tracks activity (terminal I/O, agent - calls, running `claude` processes and scheduler jobs) and stops the task by exiting the - entrypoint. It doesn't depend on the app being open. Because a running scheduler job counts as +- **Idle auto-stop** runs **inside** the task: the agent tracks activity (terminal I/O and agent + calls; an open but silent terminal or tunnel is not activity, and the app's background polls + are marked `X-Triple-C-Background` and never count — ruling R11), the watcher adds running + `claude` processes, scheduler jobs and unsaved git work, and stops the task by exiting the + entrypoint with status 75, which the app reads back from `DescribeTasks` to say "stopped after + being idle". It doesn't depend on the app being open. Because a running scheduler job counts as activity, scheduled tasks keep a workspace alive. The admin's maximum lifetime is a hard cap either way. - **Health:** the task definition carries an ECS container health check against the agent, run -- 2.52.0 From 277264769019bae76dae76dfa8c89dba6fc51db6 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 12:33:22 -0800 Subject: [PATCH 30/43] fix(aws): reconcile AWS projects at startup without waiting for Docker - New reconcile_aws_projects command (handler, capability, TS wrapper) that App runs on mount regardless of Docker; reconcile_project_statuses now skips AWS projects. Projects reconcile concurrently, 4 at a time, each still bounded at 15 s. - reconcile_task falls back to find_project_task when the stored task is gone or stopped, before calling the project Gone (m3). - An AWS project with no registered tunnel gets a placeholder target id (http://unattached.invalid//) instead of the last run's loopback URL, so a reused port can't match the wrong project; the death watch still reports it by that id until a re-attach registers (m9). Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/capabilities/default.json | 1 + app/src-tauri/gen/schemas/acl-manifests.json | 2 +- app/src-tauri/gen/schemas/capabilities.json | 2 +- app/src-tauri/gen/schemas/desktop-schema.json | 12 +++ app/src-tauri/gen/schemas/linux-schema.json | 12 +++ .../src/commands/project_commands.rs | 73 ++++++++++++++----- app/src-tauri/src/host/aws_host.rs | 53 +++++++++++--- app/src-tauri/src/host/aws_host/tests.rs | 48 ++++++++++++ app/src-tauri/src/lib.rs | 1 + app/src/App.tsx | 7 +- app/src/lib/tauri-commands.ts | 2 + 11 files changed, 180 insertions(+), 33 deletions(-) diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 066ba49..2bbe2cd 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -23,6 +23,7 @@ "allow-remove-aws-resources", "allow-rebuild-project-container", "allow-reconcile-project-statuses", + "allow-reconcile-aws-projects", "allow-list-notes", "allow-save-note", "allow-delete-note", diff --git a/app/src-tauri/gen/schemas/acl-manifests.json b/app/src-tauri/gen/schemas/acl-manifests.json index 2ea2ad7..39ed73a 100644 --- a/app/src-tauri/gen/schemas/acl-manifests.json +++ b/app/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-aws-keys-connection":{"identifier":"allow-add-aws-keys-connection","description":"Enables the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":["add_aws_keys_connection"],"deny":[]}},"allow-add-aws-profile-connection":{"identifier":"allow-add-aws-profile-connection","description":"Enables the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":["add_aws_profile_connection"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-connection-identity":{"identifier":"allow-aws-connection-identity","description":"Enables the aws_connection_identity command without any pre-configured scope.","commands":{"allow":["aws_connection_identity"],"deny":[]}},"allow-aws-connection-sso-login":{"identifier":"allow-aws-connection-sso-login","description":"Enables the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":["aws_connection_sso_login"],"deny":[]}},"allow-aws-policy-document":{"identifier":"allow-aws-policy-document","description":"Enables the aws_policy_document command without any pre-configured scope.","commands":{"allow":["aws_policy_document"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-aws-task-log":{"identifier":"allow-aws-task-log","description":"Enables the aws_task_log command without any pre-configured scope.","commands":{"allow":["aws_task_log"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-aws-deployment":{"identifier":"allow-cancel-aws-deployment","description":"Enables the cancel_aws_deployment command without any pre-configured scope.","commands":{"allow":["cancel_aws_deployment"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-aws-permissions":{"identifier":"allow-check-aws-permissions","description":"Enables the check_aws_permissions command without any pre-configured scope.","commands":{"allow":["check_aws_permissions"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-check-unsaved-work":{"identifier":"allow-check-unsaved-work","description":"Enables the check_unsaved_work command without any pre-configured scope.","commands":{"allow":["check_unsaved_work"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["clear_scheduler_notifications"],"deny":[]}},"allow-close-browser-view-popout":{"identifier":"allow-close-browser-view-popout","description":"Enables the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":["close_browser_view_popout"],"deny":[]}},"allow-close-container-page":{"identifier":"allow-close-container-page","description":"Enables the close_container_page command without any pre-configured scope.","commands":{"allow":["close_container_page"],"deny":[]}},"allow-close-terminal-session":{"identifier":"allow-close-terminal-session","description":"Enables the close_terminal_session command without any pre-configured scope.","commands":{"allow":["close_terminal_session"],"deny":[]}},"allow-confirm-migration":{"identifier":"allow-confirm-migration","description":"Enables the confirm_migration command without any pre-configured scope.","commands":{"allow":["confirm_migration"],"deny":[]}},"allow-create-container-directory":{"identifier":"allow-create-container-directory","description":"Enables the create_container_directory command without any pre-configured scope.","commands":{"allow":["create_container_directory"],"deny":[]}},"allow-delete-note":{"identifier":"allow-delete-note","description":"Enables the delete_note command without any pre-configured scope.","commands":{"allow":["delete_note"],"deny":[]}},"allow-deploy-aws-environment":{"identifier":"allow-deploy-aws-environment","description":"Enables the deploy_aws_environment command without any pre-configured scope.","commands":{"allow":["deploy_aws_environment"],"deny":[]}},"allow-deploy-aws-permissions-boundary":{"identifier":"allow-deploy-aws-permissions-boundary","description":"Enables the deploy_aws_permissions_boundary command without any pre-configured scope.","commands":{"allow":["deploy_aws_permissions_boundary"],"deny":[]}},"allow-destroy-incomplete-aws-environment":{"identifier":"allow-destroy-incomplete-aws-environment","description":"Enables the destroy_incomplete_aws_environment command without any pre-configured scope.","commands":{"allow":["destroy_incomplete_aws_environment"],"deny":[]}},"allow-detect-aws-config":{"identifier":"allow-detect-aws-config","description":"Enables the detect_aws_config command without any pre-configured scope.","commands":{"allow":["detect_aws_config"],"deny":[]}},"allow-detect-aws-environments":{"identifier":"allow-detect-aws-environments","description":"Enables the detect_aws_environments command without any pre-configured scope.","commands":{"allow":["detect_aws_environments"],"deny":[]}},"allow-detect-host-timezone":{"identifier":"allow-detect-host-timezone","description":"Enables the detect_host_timezone command without any pre-configured scope.","commands":{"allow":["detect_host_timezone"],"deny":[]}},"allow-detect-install-options":{"identifier":"allow-detect-install-options","description":"Enables the detect_install_options command without any pre-configured scope.","commands":{"allow":["detect_install_options"],"deny":[]}},"allow-download-container-backup":{"identifier":"allow-download-container-backup","description":"Enables the download_container_backup command without any pre-configured scope.","commands":{"allow":["download_container_backup"],"deny":[]}},"allow-download-container-file":{"identifier":"allow-download-container-file","description":"Enables the download_container_file command without any pre-configured scope.","commands":{"allow":["download_container_file"],"deny":[]}},"allow-export-settings":{"identifier":"allow-export-settings","description":"Enables the export_settings command without any pre-configured scope.","commands":{"allow":["export_settings"],"deny":[]}},"allow-follow-aws-deployment":{"identifier":"allow-follow-aws-deployment","description":"Enables the follow_aws_deployment command without any pre-configured scope.","commands":{"allow":["follow_aws_deployment"],"deny":[]}},"allow-forget-marketplace-installs":{"identifier":"allow-forget-marketplace-installs","description":"Enables the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":["forget_marketplace_installs"],"deny":[]}},"allow-get-app-version":{"identifier":"allow-get-app-version","description":"Enables the get_app_version command without any pre-configured scope.","commands":{"allow":["get_app_version"],"deny":[]}},"allow-get-auth-bridge-status":{"identifier":"allow-get-auth-bridge-status","description":"Enables the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":["get_auth_bridge_status"],"deny":[]}},"allow-get-browser-view-match-window":{"identifier":"allow-get-browser-view-match-window","description":"Enables the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["get_browser_view_match_window"],"deny":[]}},"allow-get-browser-view-popout-state":{"identifier":"allow-get-browser-view-popout-state","description":"Enables the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":["get_browser_view_popout_state"],"deny":[]}},"allow-get-browser-view-status":{"identifier":"allow-get-browser-view-status","description":"Enables the get_browser_view_status command without any pre-configured scope.","commands":{"allow":["get_browser_view_status"],"deny":[]}},"allow-get-container-info":{"identifier":"allow-get-container-info","description":"Enables the get_container_info command without any pre-configured scope.","commands":{"allow":["get_container_info"],"deny":[]}},"allow-get-container-page-state":{"identifier":"allow-get-container-page-state","description":"Enables the get_container_page_state command without any pre-configured scope.","commands":{"allow":["get_container_page_state"],"deny":[]}},"allow-get-container-staleness":{"identifier":"allow-get-container-staleness","description":"Enables the get_container_staleness command without any pre-configured scope.","commands":{"allow":["get_container_staleness"],"deny":[]}},"allow-get-gateway-auth-token":{"identifier":"allow-get-gateway-auth-token","description":"Enables the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["get_gateway_auth_token"],"deny":[]}},"allow-get-gateway-status":{"identifier":"allow-get-gateway-status","description":"Enables the get_gateway_status command without any pre-configured scope.","commands":{"allow":["get_gateway_status"],"deny":[]}},"allow-get-help-content":{"identifier":"allow-get-help-content","description":"Enables the get_help_content command without any pre-configured scope.","commands":{"allow":["get_help_content"],"deny":[]}},"allow-get-marketplace-sync-report":{"identifier":"allow-get-marketplace-sync-report","description":"Enables the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":["get_marketplace_sync_report"],"deny":[]}},"allow-get-migration-state":{"identifier":"allow-get-migration-state","description":"Enables the get_migration_state command without any pre-configured scope.","commands":{"allow":["get_migration_state"],"deny":[]}},"allow-get-scheduled-task-log":{"identifier":"allow-get-scheduled-task-log","description":"Enables the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":["get_scheduled_task_log"],"deny":[]}},"allow-get-scheduler-notifications":{"identifier":"allow-get-scheduler-notifications","description":"Enables the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["get_scheduler_notifications"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-get-stt-status":{"identifier":"allow-get-stt-status","description":"Enables the get_stt_status command without any pre-configured scope.","commands":{"allow":["get_stt_status"],"deny":[]}},"allow-get-web-terminal-status":{"identifier":"allow-get-web-terminal-status","description":"Enables the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":["get_web_terminal_status"],"deny":[]}},"allow-has-claude-token":{"identifier":"allow-has-claude-token","description":"Enables the has_claude_token command without any pre-configured scope.","commands":{"allow":["has_claude_token"],"deny":[]}},"allow-inspect-ca-cert-path":{"identifier":"allow-inspect-ca-cert-path","description":"Enables the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":["inspect_ca_cert_path"],"deny":[]}},"allow-install-browser-view-browser":{"identifier":"allow-install-browser-view-browser","description":"Enables the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":["install_browser_view_browser"],"deny":[]}},"allow-install-browser-view-support":{"identifier":"allow-install-browser-view-support","description":"Enables the install_browser_view_support command without any pre-configured scope.","commands":{"allow":["install_browser_view_support"],"deny":[]}},"allow-install-marketplace-item":{"identifier":"allow-install-marketplace-item","description":"Enables the install_marketplace_item command without any pre-configured scope.","commands":{"allow":["install_marketplace_item"],"deny":[]}},"allow-list-aws-profiles":{"identifier":"allow-list-aws-profiles","description":"Enables the list_aws_profiles command without any pre-configured scope.","commands":{"allow":["list_aws_profiles"],"deny":[]}},"allow-list-claude-sessions":{"identifier":"allow-list-claude-sessions","description":"Enables the list_claude_sessions command without any pre-configured scope.","commands":{"allow":["list_claude_sessions"],"deny":[]}},"allow-list-container-capabilities":{"identifier":"allow-list-container-capabilities","description":"Enables the list_container_capabilities command without any pre-configured scope.","commands":{"allow":["list_container_capabilities"],"deny":[]}},"allow-list-container-files":{"identifier":"allow-list-container-files","description":"Enables the list_container_files command without any pre-configured scope.","commands":{"allow":["list_container_files"],"deny":[]}},"allow-list-marketplace-snapshots":{"identifier":"allow-list-marketplace-snapshots","description":"Enables the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":["list_marketplace_snapshots"],"deny":[]}},"allow-list-marketplace-updates":{"identifier":"allow-list-marketplace-updates","description":"Enables the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":["list_marketplace_updates"],"deny":[]}},"allow-list-notes":{"identifier":"allow-list-notes","description":"Enables the list_notes command without any pre-configured scope.","commands":{"allow":["list_notes"],"deny":[]}},"allow-list-projects":{"identifier":"allow-list-projects","description":"Enables the list_projects command without any pre-configured scope.","commands":{"allow":["list_projects"],"deny":[]}},"allow-list-scheduled-tasks":{"identifier":"allow-list-scheduled-tasks","description":"Enables the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":["list_scheduled_tasks"],"deny":[]}},"allow-marketplace-gh-host-available":{"identifier":"allow-marketplace-gh-host-available","description":"Enables the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":["marketplace_gh_host_available"],"deny":[]}},"allow-marketplace-item-diff":{"identifier":"allow-marketplace-item-diff","description":"Enables the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":["marketplace_item_diff"],"deny":[]}},"allow-migrate-project-to-base":{"identifier":"allow-migrate-project-to-base","description":"Enables the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":["migrate_project_to_base"],"deny":[]}},"allow-onboard-aws-user":{"identifier":"allow-onboard-aws-user","description":"Enables the onboard_aws_user command without any pre-configured scope.","commands":{"allow":["onboard_aws_user"],"deny":[]}},"allow-open-browser-view-popout":{"identifier":"allow-open-browser-view-popout","description":"Enables the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":["open_browser_view_popout"],"deny":[]}},"allow-open-file-viewer":{"identifier":"allow-open-file-viewer","description":"Enables the open_file_viewer command without any pre-configured scope.","commands":{"allow":["open_file_viewer"],"deny":[]}},"allow-open-page-in-container-browser":{"identifier":"allow-open-page-in-container-browser","description":"Enables the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":["open_page_in_container_browser"],"deny":[]}},"allow-open-terminal-session":{"identifier":"allow-open-terminal-session","description":"Enables the open_terminal_session command without any pre-configured scope.","commands":{"allow":["open_terminal_session"],"deny":[]}},"allow-open-url-external":{"identifier":"allow-open-url-external","description":"Enables the open_url_external command without any pre-configured scope.","commands":{"allow":["open_url_external"],"deny":[]}},"allow-paste-image-to-terminal":{"identifier":"allow-paste-image-to-terminal","description":"Enables the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":["paste_image_to_terminal"],"deny":[]}},"allow-preview-settings-import":{"identifier":"allow-preview-settings-import","description":"Enables the preview_settings_import command without any pre-configured scope.","commands":{"allow":["preview_settings_import"],"deny":[]}},"allow-pull-gateway-image":{"identifier":"allow-pull-gateway-image","description":"Enables the pull_gateway_image command without any pre-configured scope.","commands":{"allow":["pull_gateway_image"],"deny":[]}},"allow-pull-image":{"identifier":"allow-pull-image","description":"Enables the pull_image command without any pre-configured scope.","commands":{"allow":["pull_image"],"deny":[]}},"allow-pull-stt-image":{"identifier":"allow-pull-stt-image","description":"Enables the pull_stt_image command without any pre-configured scope.","commands":{"allow":["pull_stt_image"],"deny":[]}},"allow-push-aws-saved-state":{"identifier":"allow-push-aws-saved-state","description":"Enables the push_aws_saved_state command without any pre-configured scope.","commands":{"allow":["push_aws_saved_state"],"deny":[]}},"allow-read-container-file":{"identifier":"allow-read-container-file","description":"Enables the read_container_file command without any pre-configured scope.","commands":{"allow":["read_container_file"],"deny":[]}},"allow-rebuild-project-container":{"identifier":"allow-rebuild-project-container","description":"Enables the rebuild_project_container command without any pre-configured scope.","commands":{"allow":["rebuild_project_container"],"deny":[]}},"allow-reconcile-project-statuses":{"identifier":"allow-reconcile-project-statuses","description":"Enables the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":["reconcile_project_statuses"],"deny":[]}},"allow-refresh-marketplaces":{"identifier":"allow-refresh-marketplaces","description":"Enables the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":["refresh_marketplaces"],"deny":[]}},"allow-regenerate-gateway-auth-token":{"identifier":"allow-regenerate-gateway-auth-token","description":"Enables the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["regenerate_gateway_auth_token"],"deny":[]}},"allow-regenerate-web-terminal-token":{"identifier":"allow-regenerate-web-terminal-token","description":"Enables the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":["regenerate_web_terminal_token"],"deny":[]}},"allow-remove-aws-resources":{"identifier":"allow-remove-aws-resources","description":"Enables the remove_aws_resources command without any pre-configured scope.","commands":{"allow":["remove_aws_resources"],"deny":[]}},"allow-remove-host-connection":{"identifier":"allow-remove-host-connection","description":"Enables the remove_host_connection command without any pre-configured scope.","commands":{"allow":["remove_host_connection"],"deny":[]}},"allow-remove-marketplace":{"identifier":"allow-remove-marketplace","description":"Enables the remove_marketplace command without any pre-configured scope.","commands":{"allow":["remove_marketplace"],"deny":[]}},"allow-remove-marketplace-account":{"identifier":"allow-remove-marketplace-account","description":"Enables the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":["remove_marketplace_account"],"deny":[]}},"allow-remove-project":{"identifier":"allow-remove-project","description":"Enables the remove_project command without any pre-configured scope.","commands":{"allow":["remove_project"],"deny":[]}},"allow-remove-scheduled-task":{"identifier":"allow-remove-scheduled-task","description":"Enables the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":["remove_scheduled_task"],"deny":[]}},"allow-rename-container-path":{"identifier":"allow-rename-container-path","description":"Enables the rename_container_path command without any pre-configured scope.","commands":{"allow":["rename_container_path"],"deny":[]}},"allow-repair-aws-environment":{"identifier":"allow-repair-aws-environment","description":"Enables the repair_aws_environment command without any pre-configured scope.","commands":{"allow":["repair_aws_environment"],"deny":[]}},"allow-rollback-migration":{"identifier":"allow-rollback-migration","description":"Enables the rollback_migration command without any pre-configured scope.","commands":{"allow":["rollback_migration"],"deny":[]}},"allow-run-docker-install":{"identifier":"allow-run-docker-install","description":"Enables the run_docker_install command without any pre-configured scope.","commands":{"allow":["run_docker_install"],"deny":[]}},"allow-run-scheduled-task-now":{"identifier":"allow-run-scheduled-task-now","description":"Enables the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":["run_scheduled_task_now"],"deny":[]}},"allow-save-aws-admin-bundle":{"identifier":"allow-save-aws-admin-bundle","description":"Enables the save_aws_admin_bundle command without any pre-configured scope.","commands":{"allow":["save_aws_admin_bundle"],"deny":[]}},"allow-save-note":{"identifier":"allow-save-note","description":"Enables the save_note command without any pre-configured scope.","commands":{"allow":["save_note"],"deny":[]}},"allow-send-audio-data":{"identifier":"allow-send-audio-data","description":"Enables the send_audio_data command without any pre-configured scope.","commands":{"allow":["send_audio_data"],"deny":[]}},"allow-set-auth-bridge-enabled":{"identifier":"allow-set-auth-bridge-enabled","description":"Enables the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":["set_auth_bridge_enabled"],"deny":[]}},"allow-set-browser-view-enabled":{"identifier":"allow-set-browser-view-enabled","description":"Enables the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":["set_browser_view_enabled"],"deny":[]}},"allow-set-browser-view-match-window":{"identifier":"allow-set-browser-view-match-window","description":"Enables the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["set_browser_view_match_window"],"deny":[]}},"allow-set-browser-view-popout-always-on-top":{"identifier":"allow-set-browser-view-popout-always-on-top","description":"Enables the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":["set_browser_view_popout_always_on_top"],"deny":[]}},"allow-set-container-page-viewport":{"identifier":"allow-set-container-page-viewport","description":"Enables the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":["set_container_page_viewport"],"deny":[]}},"allow-set-gateway-api-key":{"identifier":"allow-set-gateway-api-key","description":"Enables the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":["set_gateway_api_key"],"deny":[]}},"allow-set-global-item-disabled":{"identifier":"allow-set-global-item-disabled","description":"Enables the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":["set_global_item_disabled"],"deny":[]}},"allow-set-scheduled-task-enabled":{"identifier":"allow-set-scheduled-task-enabled","description":"Enables the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":["set_scheduled_task_enabled"],"deny":[]}},"allow-start-audio-bridge":{"identifier":"allow-start-audio-bridge","description":"Enables the start_audio_bridge command without any pre-configured scope.","commands":{"allow":["start_audio_bridge"],"deny":[]}},"allow-start-gateway":{"identifier":"allow-start-gateway","description":"Enables the start_gateway command without any pre-configured scope.","commands":{"allow":["start_gateway"],"deny":[]}},"allow-start-marketplace-gh-container-login":{"identifier":"allow-start-marketplace-gh-container-login","description":"Enables the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":["start_marketplace_gh_container_login"],"deny":[]}},"allow-start-project-container":{"identifier":"allow-start-project-container","description":"Enables the start_project_container command without any pre-configured scope.","commands":{"allow":["start_project_container"],"deny":[]}},"allow-start-stt":{"identifier":"allow-start-stt","description":"Enables the start_stt command without any pre-configured scope.","commands":{"allow":["start_stt"],"deny":[]}},"allow-start-web-terminal":{"identifier":"allow-start-web-terminal","description":"Enables the start_web_terminal command without any pre-configured scope.","commands":{"allow":["start_web_terminal"],"deny":[]}},"allow-stop-audio-bridge":{"identifier":"allow-stop-audio-bridge","description":"Enables the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":["stop_audio_bridge"],"deny":[]}},"allow-stop-gateway":{"identifier":"allow-stop-gateway","description":"Enables the stop_gateway command without any pre-configured scope.","commands":{"allow":["stop_gateway"],"deny":[]}},"allow-stop-project-container":{"identifier":"allow-stop-project-container","description":"Enables the stop_project_container command without any pre-configured scope.","commands":{"allow":["stop_project_container"],"deny":[]}},"allow-stop-stt":{"identifier":"allow-stop-stt","description":"Enables the stop_stt command without any pre-configured scope.","commands":{"allow":["stop_stt"],"deny":[]}},"allow-stop-web-terminal":{"identifier":"allow-stop-web-terminal","description":"Enables the stop_web_terminal command without any pre-configured scope.","commands":{"allow":["stop_web_terminal"],"deny":[]}},"allow-submit-claude-token-code":{"identifier":"allow-submit-claude-token-code","description":"Enables the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":["submit_claude_token_code"],"deny":[]}},"allow-sweep-claude-token-snapshots":{"identifier":"allow-sweep-claude-token-snapshots","description":"Enables the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":["sweep_claude_token_snapshots"],"deny":[]}},"allow-terminal-input":{"identifier":"allow-terminal-input","description":"Enables the terminal_input command without any pre-configured scope.","commands":{"allow":["terminal_input"],"deny":[]}},"allow-terminal-resize":{"identifier":"allow-terminal-resize","description":"Enables the terminal_resize command without any pre-configured scope.","commands":{"allow":["terminal_resize"],"deny":[]}},"allow-test-marketplace-account":{"identifier":"allow-test-marketplace-account","description":"Enables the test_marketplace_account command without any pre-configured scope.","commands":{"allow":["test_marketplace_account"],"deny":[]}},"allow-transcribe-audio":{"identifier":"allow-transcribe-audio","description":"Enables the transcribe_audio command without any pre-configured scope.","commands":{"allow":["transcribe_audio"],"deny":[]}},"allow-uninstall-marketplace-item":{"identifier":"allow-uninstall-marketplace-item","description":"Enables the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":["uninstall_marketplace_item"],"deny":[]}},"allow-unlock-aws-state":{"identifier":"allow-unlock-aws-state","description":"Enables the unlock_aws_state command without any pre-configured scope.","commands":{"allow":["unlock_aws_state"],"deny":[]}},"allow-update-aws-connection":{"identifier":"allow-update-aws-connection","description":"Enables the update_aws_connection command without any pre-configured scope.","commands":{"allow":["update_aws_connection"],"deny":[]}},"allow-update-aws-environment":{"identifier":"allow-update-aws-environment","description":"Enables the update_aws_environment command without any pre-configured scope.","commands":{"allow":["update_aws_environment"],"deny":[]}},"allow-update-marketplace":{"identifier":"allow-update-marketplace","description":"Enables the update_marketplace command without any pre-configured scope.","commands":{"allow":["update_marketplace"],"deny":[]}},"allow-update-marketplace-item":{"identifier":"allow-update-marketplace-item","description":"Enables the update_marketplace_item command without any pre-configured scope.","commands":{"allow":["update_marketplace_item"],"deny":[]}},"allow-update-project":{"identifier":"allow-update-project","description":"Enables the update_project command without any pre-configured scope.","commands":{"allow":["update_project"],"deny":[]}},"allow-update-scheduled-task":{"identifier":"allow-update-scheduled-task","description":"Enables the update_scheduled_task command without any pre-configured scope.","commands":{"allow":["update_scheduled_task"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-upload-files-to-container":{"identifier":"allow-upload-files-to-container","description":"Enables the upload_files_to_container command without any pre-configured scope.","commands":{"allow":["upload_files_to_container"],"deny":[]}},"allow-upload-host-file-to-terminal":{"identifier":"allow-upload-host-file-to-terminal","description":"Enables the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":["upload_host_file_to_terminal"],"deny":[]}},"allow-viewer-choose-file":{"identifier":"allow-viewer-choose-file","description":"Enables the viewer_choose_file command without any pre-configured scope.","commands":{"allow":["viewer_choose_file"],"deny":[]}},"allow-viewer-get-state":{"identifier":"allow-viewer-get-state","description":"Enables the viewer_get_state command without any pre-configured scope.","commands":{"allow":["viewer_get_state"],"deny":[]}},"allow-viewer-poll-file":{"identifier":"allow-viewer-poll-file","description":"Enables the viewer_poll_file command without any pre-configured scope.","commands":{"allow":["viewer_poll_file"],"deny":[]}},"allow-viewer-read-file":{"identifier":"allow-viewer-read-file","description":"Enables the viewer_read_file command without any pre-configured scope.","commands":{"allow":["viewer_read_file"],"deny":[]}},"allow-viewer-write-file":{"identifier":"allow-viewer-write-file","description":"Enables the viewer_write_file command without any pre-configured scope.","commands":{"allow":["viewer_write_file"],"deny":[]}},"deny-acquire-claude-token":{"identifier":"deny-acquire-claude-token","description":"Denies the acquire_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["acquire_claude_token"]}},"deny-add-aws-keys-connection":{"identifier":"deny-add-aws-keys-connection","description":"Denies the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["add_aws_keys_connection"]}},"deny-add-aws-profile-connection":{"identifier":"deny-add-aws-profile-connection","description":"Denies the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["add_aws_profile_connection"]}},"deny-add-marketplace":{"identifier":"deny-add-marketplace","description":"Denies the add_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace"]}},"deny-add-marketplace-gh-host-account":{"identifier":"deny-add-marketplace-gh-host-account","description":"Denies the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_gh_host_account"]}},"deny-add-marketplace-token-account":{"identifier":"deny-add-marketplace-token-account","description":"Denies the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_token_account"]}},"deny-add-project":{"identifier":"deny-add-project","description":"Denies the add_project command without any pre-configured scope.","commands":{"allow":[],"deny":["add_project"]}},"deny-add-scheduled-task":{"identifier":"deny-add-scheduled-task","description":"Denies the add_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["add_scheduled_task"]}},"deny-apply-marketplace-now":{"identifier":"deny-apply-marketplace-now","description":"Denies the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_marketplace_now"]}},"deny-apply-settings-import":{"identifier":"deny-apply-settings-import","description":"Denies the apply_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_settings_import"]}},"deny-aws-connection-identity":{"identifier":"deny-aws-connection-identity","description":"Denies the aws_connection_identity command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_connection_identity"]}},"deny-aws-connection-sso-login":{"identifier":"deny-aws-connection-sso-login","description":"Denies the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_connection_sso_login"]}},"deny-aws-policy-document":{"identifier":"deny-aws-policy-document","description":"Denies the aws_policy_document command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_policy_document"]}},"deny-aws-sso-refresh":{"identifier":"deny-aws-sso-refresh","description":"Denies the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_sso_refresh"]}},"deny-aws-task-log":{"identifier":"deny-aws-task-log","description":"Denies the aws_task_log command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_task_log"]}},"deny-build-gateway-image":{"identifier":"deny-build-gateway-image","description":"Denies the build_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_gateway_image"]}},"deny-build-image":{"identifier":"deny-build-image","description":"Denies the build_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_image"]}},"deny-build-stt-image":{"identifier":"deny-build-stt-image","description":"Denies the build_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_stt_image"]}},"deny-cancel-aws-deployment":{"identifier":"deny-cancel-aws-deployment","description":"Denies the cancel_aws_deployment command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_aws_deployment"]}},"deny-cancel-claude-token":{"identifier":"deny-cancel-claude-token","description":"Denies the cancel_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_claude_token"]}},"deny-cancel-marketplace-gh-login":{"identifier":"deny-cancel-marketplace-gh-login","description":"Denies the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_marketplace_gh_login"]}},"deny-check-aws-permissions":{"identifier":"deny-check-aws-permissions","description":"Denies the check_aws_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_aws_permissions"]}},"deny-check-browser-view-support":{"identifier":"deny-check-browser-view-support","description":"Denies the check_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["check_browser_view_support"]}},"deny-check-docker":{"identifier":"deny-check-docker","description":"Denies the check_docker command without any pre-configured scope.","commands":{"allow":[],"deny":["check_docker"]}},"deny-check-for-updates":{"identifier":"deny-check-for-updates","description":"Denies the check_for_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["check_for_updates"]}},"deny-check-gateway-health":{"identifier":"deny-check-gateway-health","description":"Denies the check_gateway_health command without any pre-configured scope.","commands":{"allow":[],"deny":["check_gateway_health"]}},"deny-check-image-exists":{"identifier":"deny-check-image-exists","description":"Denies the check_image_exists command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_exists"]}},"deny-check-image-update":{"identifier":"deny-check-image-update","description":"Denies the check_image_update command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_update"]}},"deny-check-unsaved-work":{"identifier":"deny-check-unsaved-work","description":"Denies the check_unsaved_work command without any pre-configured scope.","commands":{"allow":[],"deny":["check_unsaved_work"]}},"deny-clear-claude-token":{"identifier":"deny-clear-claude-token","description":"Denies the clear_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_claude_token"]}},"deny-clear-gateway-api-key":{"identifier":"deny-clear-gateway-api-key","description":"Denies the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_gateway_api_key"]}},"deny-clear-scheduler-notifications":{"identifier":"deny-clear-scheduler-notifications","description":"Denies the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_scheduler_notifications"]}},"deny-close-browser-view-popout":{"identifier":"deny-close-browser-view-popout","description":"Denies the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["close_browser_view_popout"]}},"deny-close-container-page":{"identifier":"deny-close-container-page","description":"Denies the close_container_page command without any pre-configured scope.","commands":{"allow":[],"deny":["close_container_page"]}},"deny-close-terminal-session":{"identifier":"deny-close-terminal-session","description":"Denies the close_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["close_terminal_session"]}},"deny-confirm-migration":{"identifier":"deny-confirm-migration","description":"Denies the confirm_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm_migration"]}},"deny-create-container-directory":{"identifier":"deny-create-container-directory","description":"Denies the create_container_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["create_container_directory"]}},"deny-delete-note":{"identifier":"deny-delete-note","description":"Denies the delete_note command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_note"]}},"deny-deploy-aws-environment":{"identifier":"deny-deploy-aws-environment","description":"Denies the deploy_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["deploy_aws_environment"]}},"deny-deploy-aws-permissions-boundary":{"identifier":"deny-deploy-aws-permissions-boundary","description":"Denies the deploy_aws_permissions_boundary command without any pre-configured scope.","commands":{"allow":[],"deny":["deploy_aws_permissions_boundary"]}},"deny-destroy-incomplete-aws-environment":{"identifier":"deny-destroy-incomplete-aws-environment","description":"Denies the destroy_incomplete_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy_incomplete_aws_environment"]}},"deny-detect-aws-config":{"identifier":"deny-detect-aws-config","description":"Denies the detect_aws_config command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_aws_config"]}},"deny-detect-aws-environments":{"identifier":"deny-detect-aws-environments","description":"Denies the detect_aws_environments command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_aws_environments"]}},"deny-detect-host-timezone":{"identifier":"deny-detect-host-timezone","description":"Denies the detect_host_timezone command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_host_timezone"]}},"deny-detect-install-options":{"identifier":"deny-detect-install-options","description":"Denies the detect_install_options command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_install_options"]}},"deny-download-container-backup":{"identifier":"deny-download-container-backup","description":"Denies the download_container_backup command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_backup"]}},"deny-download-container-file":{"identifier":"deny-download-container-file","description":"Denies the download_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_file"]}},"deny-export-settings":{"identifier":"deny-export-settings","description":"Denies the export_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["export_settings"]}},"deny-follow-aws-deployment":{"identifier":"deny-follow-aws-deployment","description":"Denies the follow_aws_deployment command without any pre-configured scope.","commands":{"allow":[],"deny":["follow_aws_deployment"]}},"deny-forget-marketplace-installs":{"identifier":"deny-forget-marketplace-installs","description":"Denies the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":[],"deny":["forget_marketplace_installs"]}},"deny-get-app-version":{"identifier":"deny-get-app-version","description":"Denies the get_app_version command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_version"]}},"deny-get-auth-bridge-status":{"identifier":"deny-get-auth-bridge-status","description":"Denies the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_auth_bridge_status"]}},"deny-get-browser-view-match-window":{"identifier":"deny-get-browser-view-match-window","description":"Denies the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_match_window"]}},"deny-get-browser-view-popout-state":{"identifier":"deny-get-browser-view-popout-state","description":"Denies the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_popout_state"]}},"deny-get-browser-view-status":{"identifier":"deny-get-browser-view-status","description":"Denies the get_browser_view_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_status"]}},"deny-get-container-info":{"identifier":"deny-get-container-info","description":"Denies the get_container_info command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_info"]}},"deny-get-container-page-state":{"identifier":"deny-get-container-page-state","description":"Denies the get_container_page_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_page_state"]}},"deny-get-container-staleness":{"identifier":"deny-get-container-staleness","description":"Denies the get_container_staleness command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_staleness"]}},"deny-get-gateway-auth-token":{"identifier":"deny-get-gateway-auth-token","description":"Denies the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_auth_token"]}},"deny-get-gateway-status":{"identifier":"deny-get-gateway-status","description":"Denies the get_gateway_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_status"]}},"deny-get-help-content":{"identifier":"deny-get-help-content","description":"Denies the get_help_content command without any pre-configured scope.","commands":{"allow":[],"deny":["get_help_content"]}},"deny-get-marketplace-sync-report":{"identifier":"deny-get-marketplace-sync-report","description":"Denies the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":[],"deny":["get_marketplace_sync_report"]}},"deny-get-migration-state":{"identifier":"deny-get-migration-state","description":"Denies the get_migration_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_migration_state"]}},"deny-get-scheduled-task-log":{"identifier":"deny-get-scheduled-task-log","description":"Denies the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduled_task_log"]}},"deny-get-scheduler-notifications":{"identifier":"deny-get-scheduler-notifications","description":"Denies the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduler_notifications"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-get-stt-status":{"identifier":"deny-get-stt-status","description":"Denies the get_stt_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_stt_status"]}},"deny-get-web-terminal-status":{"identifier":"deny-get-web-terminal-status","description":"Denies the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_web_terminal_status"]}},"deny-has-claude-token":{"identifier":"deny-has-claude-token","description":"Denies the has_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["has_claude_token"]}},"deny-inspect-ca-cert-path":{"identifier":"deny-inspect-ca-cert-path","description":"Denies the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":[],"deny":["inspect_ca_cert_path"]}},"deny-install-browser-view-browser":{"identifier":"deny-install-browser-view-browser","description":"Denies the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_browser"]}},"deny-install-browser-view-support":{"identifier":"deny-install-browser-view-support","description":"Denies the install_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_support"]}},"deny-install-marketplace-item":{"identifier":"deny-install-marketplace-item","description":"Denies the install_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["install_marketplace_item"]}},"deny-list-aws-profiles":{"identifier":"deny-list-aws-profiles","description":"Denies the list_aws_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_aws_profiles"]}},"deny-list-claude-sessions":{"identifier":"deny-list-claude-sessions","description":"Denies the list_claude_sessions command without any pre-configured scope.","commands":{"allow":[],"deny":["list_claude_sessions"]}},"deny-list-container-capabilities":{"identifier":"deny-list-container-capabilities","description":"Denies the list_container_capabilities command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_capabilities"]}},"deny-list-container-files":{"identifier":"deny-list-container-files","description":"Denies the list_container_files command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_files"]}},"deny-list-marketplace-snapshots":{"identifier":"deny-list-marketplace-snapshots","description":"Denies the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_snapshots"]}},"deny-list-marketplace-updates":{"identifier":"deny-list-marketplace-updates","description":"Denies the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_updates"]}},"deny-list-notes":{"identifier":"deny-list-notes","description":"Denies the list_notes command without any pre-configured scope.","commands":{"allow":[],"deny":["list_notes"]}},"deny-list-projects":{"identifier":"deny-list-projects","description":"Denies the list_projects command without any pre-configured scope.","commands":{"allow":[],"deny":["list_projects"]}},"deny-list-scheduled-tasks":{"identifier":"deny-list-scheduled-tasks","description":"Denies the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":[],"deny":["list_scheduled_tasks"]}},"deny-marketplace-gh-host-available":{"identifier":"deny-marketplace-gh-host-available","description":"Denies the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_gh_host_available"]}},"deny-marketplace-item-diff":{"identifier":"deny-marketplace-item-diff","description":"Denies the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_item_diff"]}},"deny-migrate-project-to-base":{"identifier":"deny-migrate-project-to-base","description":"Denies the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":[],"deny":["migrate_project_to_base"]}},"deny-onboard-aws-user":{"identifier":"deny-onboard-aws-user","description":"Denies the onboard_aws_user command without any pre-configured scope.","commands":{"allow":[],"deny":["onboard_aws_user"]}},"deny-open-browser-view-popout":{"identifier":"deny-open-browser-view-popout","description":"Denies the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["open_browser_view_popout"]}},"deny-open-file-viewer":{"identifier":"deny-open-file-viewer","description":"Denies the open_file_viewer command without any pre-configured scope.","commands":{"allow":[],"deny":["open_file_viewer"]}},"deny-open-page-in-container-browser":{"identifier":"deny-open-page-in-container-browser","description":"Denies the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["open_page_in_container_browser"]}},"deny-open-terminal-session":{"identifier":"deny-open-terminal-session","description":"Denies the open_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["open_terminal_session"]}},"deny-open-url-external":{"identifier":"deny-open-url-external","description":"Denies the open_url_external command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url_external"]}},"deny-paste-image-to-terminal":{"identifier":"deny-paste-image-to-terminal","description":"Denies the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["paste_image_to_terminal"]}},"deny-preview-settings-import":{"identifier":"deny-preview-settings-import","description":"Denies the preview_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["preview_settings_import"]}},"deny-pull-gateway-image":{"identifier":"deny-pull-gateway-image","description":"Denies the pull_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_gateway_image"]}},"deny-pull-image":{"identifier":"deny-pull-image","description":"Denies the pull_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_image"]}},"deny-pull-stt-image":{"identifier":"deny-pull-stt-image","description":"Denies the pull_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_stt_image"]}},"deny-push-aws-saved-state":{"identifier":"deny-push-aws-saved-state","description":"Denies the push_aws_saved_state command without any pre-configured scope.","commands":{"allow":[],"deny":["push_aws_saved_state"]}},"deny-read-container-file":{"identifier":"deny-read-container-file","description":"Denies the read_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_container_file"]}},"deny-rebuild-project-container":{"identifier":"deny-rebuild-project-container","description":"Denies the rebuild_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["rebuild_project_container"]}},"deny-reconcile-project-statuses":{"identifier":"deny-reconcile-project-statuses","description":"Denies the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":[],"deny":["reconcile_project_statuses"]}},"deny-refresh-marketplaces":{"identifier":"deny-refresh-marketplaces","description":"Denies the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":[],"deny":["refresh_marketplaces"]}},"deny-regenerate-gateway-auth-token":{"identifier":"deny-regenerate-gateway-auth-token","description":"Denies the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_gateway_auth_token"]}},"deny-regenerate-web-terminal-token":{"identifier":"deny-regenerate-web-terminal-token","description":"Denies the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_web_terminal_token"]}},"deny-remove-aws-resources":{"identifier":"deny-remove-aws-resources","description":"Denies the remove_aws_resources command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_aws_resources"]}},"deny-remove-host-connection":{"identifier":"deny-remove-host-connection","description":"Denies the remove_host_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_host_connection"]}},"deny-remove-marketplace":{"identifier":"deny-remove-marketplace","description":"Denies the remove_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace"]}},"deny-remove-marketplace-account":{"identifier":"deny-remove-marketplace-account","description":"Denies the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace_account"]}},"deny-remove-project":{"identifier":"deny-remove-project","description":"Denies the remove_project command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_project"]}},"deny-remove-scheduled-task":{"identifier":"deny-remove-scheduled-task","description":"Denies the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_scheduled_task"]}},"deny-rename-container-path":{"identifier":"deny-rename-container-path","description":"Denies the rename_container_path command without any pre-configured scope.","commands":{"allow":[],"deny":["rename_container_path"]}},"deny-repair-aws-environment":{"identifier":"deny-repair-aws-environment","description":"Denies the repair_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["repair_aws_environment"]}},"deny-rollback-migration":{"identifier":"deny-rollback-migration","description":"Denies the rollback_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["rollback_migration"]}},"deny-run-docker-install":{"identifier":"deny-run-docker-install","description":"Denies the run_docker_install command without any pre-configured scope.","commands":{"allow":[],"deny":["run_docker_install"]}},"deny-run-scheduled-task-now":{"identifier":"deny-run-scheduled-task-now","description":"Denies the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":[],"deny":["run_scheduled_task_now"]}},"deny-save-aws-admin-bundle":{"identifier":"deny-save-aws-admin-bundle","description":"Denies the save_aws_admin_bundle command without any pre-configured scope.","commands":{"allow":[],"deny":["save_aws_admin_bundle"]}},"deny-save-note":{"identifier":"deny-save-note","description":"Denies the save_note command without any pre-configured scope.","commands":{"allow":[],"deny":["save_note"]}},"deny-send-audio-data":{"identifier":"deny-send-audio-data","description":"Denies the send_audio_data command without any pre-configured scope.","commands":{"allow":[],"deny":["send_audio_data"]}},"deny-set-auth-bridge-enabled":{"identifier":"deny-set-auth-bridge-enabled","description":"Denies the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_auth_bridge_enabled"]}},"deny-set-browser-view-enabled":{"identifier":"deny-set-browser-view-enabled","description":"Denies the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_enabled"]}},"deny-set-browser-view-match-window":{"identifier":"deny-set-browser-view-match-window","description":"Denies the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_match_window"]}},"deny-set-browser-view-popout-always-on-top":{"identifier":"deny-set-browser-view-popout-always-on-top","description":"Denies the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_popout_always_on_top"]}},"deny-set-container-page-viewport":{"identifier":"deny-set-container-page-viewport","description":"Denies the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":[],"deny":["set_container_page_viewport"]}},"deny-set-gateway-api-key":{"identifier":"deny-set-gateway-api-key","description":"Denies the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["set_gateway_api_key"]}},"deny-set-global-item-disabled":{"identifier":"deny-set-global-item-disabled","description":"Denies the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_global_item_disabled"]}},"deny-set-scheduled-task-enabled":{"identifier":"deny-set-scheduled-task-enabled","description":"Denies the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_scheduled_task_enabled"]}},"deny-start-audio-bridge":{"identifier":"deny-start-audio-bridge","description":"Denies the start_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["start_audio_bridge"]}},"deny-start-gateway":{"identifier":"deny-start-gateway","description":"Denies the start_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["start_gateway"]}},"deny-start-marketplace-gh-container-login":{"identifier":"deny-start-marketplace-gh-container-login","description":"Denies the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":[],"deny":["start_marketplace_gh_container_login"]}},"deny-start-project-container":{"identifier":"deny-start-project-container","description":"Denies the start_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["start_project_container"]}},"deny-start-stt":{"identifier":"deny-start-stt","description":"Denies the start_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["start_stt"]}},"deny-start-web-terminal":{"identifier":"deny-start-web-terminal","description":"Denies the start_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["start_web_terminal"]}},"deny-stop-audio-bridge":{"identifier":"deny-stop-audio-bridge","description":"Denies the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_audio_bridge"]}},"deny-stop-gateway":{"identifier":"deny-stop-gateway","description":"Denies the stop_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_gateway"]}},"deny-stop-project-container":{"identifier":"deny-stop-project-container","description":"Denies the stop_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_project_container"]}},"deny-stop-stt":{"identifier":"deny-stop-stt","description":"Denies the stop_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_stt"]}},"deny-stop-web-terminal":{"identifier":"deny-stop-web-terminal","description":"Denies the stop_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_web_terminal"]}},"deny-submit-claude-token-code":{"identifier":"deny-submit-claude-token-code","description":"Denies the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":[],"deny":["submit_claude_token_code"]}},"deny-sweep-claude-token-snapshots":{"identifier":"deny-sweep-claude-token-snapshots","description":"Denies the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["sweep_claude_token_snapshots"]}},"deny-terminal-input":{"identifier":"deny-terminal-input","description":"Denies the terminal_input command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_input"]}},"deny-terminal-resize":{"identifier":"deny-terminal-resize","description":"Denies the terminal_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_resize"]}},"deny-test-marketplace-account":{"identifier":"deny-test-marketplace-account","description":"Denies the test_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["test_marketplace_account"]}},"deny-transcribe-audio":{"identifier":"deny-transcribe-audio","description":"Denies the transcribe_audio command without any pre-configured scope.","commands":{"allow":[],"deny":["transcribe_audio"]}},"deny-uninstall-marketplace-item":{"identifier":"deny-uninstall-marketplace-item","description":"Denies the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["uninstall_marketplace_item"]}},"deny-unlock-aws-state":{"identifier":"deny-unlock-aws-state","description":"Denies the unlock_aws_state command without any pre-configured scope.","commands":{"allow":[],"deny":["unlock_aws_state"]}},"deny-update-aws-connection":{"identifier":"deny-update-aws-connection","description":"Denies the update_aws_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["update_aws_connection"]}},"deny-update-aws-environment":{"identifier":"deny-update-aws-environment","description":"Denies the update_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["update_aws_environment"]}},"deny-update-marketplace":{"identifier":"deny-update-marketplace","description":"Denies the update_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace"]}},"deny-update-marketplace-item":{"identifier":"deny-update-marketplace-item","description":"Denies the update_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace_item"]}},"deny-update-project":{"identifier":"deny-update-project","description":"Denies the update_project command without any pre-configured scope.","commands":{"allow":[],"deny":["update_project"]}},"deny-update-scheduled-task":{"identifier":"deny-update-scheduled-task","description":"Denies the update_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["update_scheduled_task"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-upload-files-to-container":{"identifier":"deny-upload-files-to-container","description":"Denies the upload_files_to_container command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_files_to_container"]}},"deny-upload-host-file-to-terminal":{"identifier":"deny-upload-host-file-to-terminal","description":"Denies the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_host_file_to_terminal"]}},"deny-viewer-choose-file":{"identifier":"deny-viewer-choose-file","description":"Denies the viewer_choose_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_choose_file"]}},"deny-viewer-get-state":{"identifier":"deny-viewer-get-state","description":"Denies the viewer_get_state command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_get_state"]}},"deny-viewer-poll-file":{"identifier":"deny-viewer-poll-file","description":"Denies the viewer_poll_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_poll_file"]}},"deny-viewer-read-file":{"identifier":"deny-viewer-read-file","description":"Denies the viewer_read_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_read_file"]}},"deny-viewer-write-file":{"identifier":"deny-viewer-write-file","description":"Denies the viewer_write_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_write_file"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-aws-keys-connection":{"identifier":"allow-add-aws-keys-connection","description":"Enables the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":["add_aws_keys_connection"],"deny":[]}},"allow-add-aws-profile-connection":{"identifier":"allow-add-aws-profile-connection","description":"Enables the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":["add_aws_profile_connection"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-connection-identity":{"identifier":"allow-aws-connection-identity","description":"Enables the aws_connection_identity command without any pre-configured scope.","commands":{"allow":["aws_connection_identity"],"deny":[]}},"allow-aws-connection-sso-login":{"identifier":"allow-aws-connection-sso-login","description":"Enables the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":["aws_connection_sso_login"],"deny":[]}},"allow-aws-policy-document":{"identifier":"allow-aws-policy-document","description":"Enables the aws_policy_document command without any pre-configured scope.","commands":{"allow":["aws_policy_document"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-aws-task-log":{"identifier":"allow-aws-task-log","description":"Enables the aws_task_log command without any pre-configured scope.","commands":{"allow":["aws_task_log"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-aws-deployment":{"identifier":"allow-cancel-aws-deployment","description":"Enables the cancel_aws_deployment command without any pre-configured scope.","commands":{"allow":["cancel_aws_deployment"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-aws-permissions":{"identifier":"allow-check-aws-permissions","description":"Enables the check_aws_permissions command without any pre-configured scope.","commands":{"allow":["check_aws_permissions"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-check-unsaved-work":{"identifier":"allow-check-unsaved-work","description":"Enables the check_unsaved_work command without any pre-configured scope.","commands":{"allow":["check_unsaved_work"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["clear_scheduler_notifications"],"deny":[]}},"allow-close-browser-view-popout":{"identifier":"allow-close-browser-view-popout","description":"Enables the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":["close_browser_view_popout"],"deny":[]}},"allow-close-container-page":{"identifier":"allow-close-container-page","description":"Enables the close_container_page command without any pre-configured scope.","commands":{"allow":["close_container_page"],"deny":[]}},"allow-close-terminal-session":{"identifier":"allow-close-terminal-session","description":"Enables the close_terminal_session command without any pre-configured scope.","commands":{"allow":["close_terminal_session"],"deny":[]}},"allow-confirm-migration":{"identifier":"allow-confirm-migration","description":"Enables the confirm_migration command without any pre-configured scope.","commands":{"allow":["confirm_migration"],"deny":[]}},"allow-create-container-directory":{"identifier":"allow-create-container-directory","description":"Enables the create_container_directory command without any pre-configured scope.","commands":{"allow":["create_container_directory"],"deny":[]}},"allow-delete-note":{"identifier":"allow-delete-note","description":"Enables the delete_note command without any pre-configured scope.","commands":{"allow":["delete_note"],"deny":[]}},"allow-deploy-aws-environment":{"identifier":"allow-deploy-aws-environment","description":"Enables the deploy_aws_environment command without any pre-configured scope.","commands":{"allow":["deploy_aws_environment"],"deny":[]}},"allow-deploy-aws-permissions-boundary":{"identifier":"allow-deploy-aws-permissions-boundary","description":"Enables the deploy_aws_permissions_boundary command without any pre-configured scope.","commands":{"allow":["deploy_aws_permissions_boundary"],"deny":[]}},"allow-destroy-incomplete-aws-environment":{"identifier":"allow-destroy-incomplete-aws-environment","description":"Enables the destroy_incomplete_aws_environment command without any pre-configured scope.","commands":{"allow":["destroy_incomplete_aws_environment"],"deny":[]}},"allow-detect-aws-config":{"identifier":"allow-detect-aws-config","description":"Enables the detect_aws_config command without any pre-configured scope.","commands":{"allow":["detect_aws_config"],"deny":[]}},"allow-detect-aws-environments":{"identifier":"allow-detect-aws-environments","description":"Enables the detect_aws_environments command without any pre-configured scope.","commands":{"allow":["detect_aws_environments"],"deny":[]}},"allow-detect-host-timezone":{"identifier":"allow-detect-host-timezone","description":"Enables the detect_host_timezone command without any pre-configured scope.","commands":{"allow":["detect_host_timezone"],"deny":[]}},"allow-detect-install-options":{"identifier":"allow-detect-install-options","description":"Enables the detect_install_options command without any pre-configured scope.","commands":{"allow":["detect_install_options"],"deny":[]}},"allow-download-container-backup":{"identifier":"allow-download-container-backup","description":"Enables the download_container_backup command without any pre-configured scope.","commands":{"allow":["download_container_backup"],"deny":[]}},"allow-download-container-file":{"identifier":"allow-download-container-file","description":"Enables the download_container_file command without any pre-configured scope.","commands":{"allow":["download_container_file"],"deny":[]}},"allow-export-settings":{"identifier":"allow-export-settings","description":"Enables the export_settings command without any pre-configured scope.","commands":{"allow":["export_settings"],"deny":[]}},"allow-follow-aws-deployment":{"identifier":"allow-follow-aws-deployment","description":"Enables the follow_aws_deployment command without any pre-configured scope.","commands":{"allow":["follow_aws_deployment"],"deny":[]}},"allow-forget-marketplace-installs":{"identifier":"allow-forget-marketplace-installs","description":"Enables the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":["forget_marketplace_installs"],"deny":[]}},"allow-get-app-version":{"identifier":"allow-get-app-version","description":"Enables the get_app_version command without any pre-configured scope.","commands":{"allow":["get_app_version"],"deny":[]}},"allow-get-auth-bridge-status":{"identifier":"allow-get-auth-bridge-status","description":"Enables the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":["get_auth_bridge_status"],"deny":[]}},"allow-get-browser-view-match-window":{"identifier":"allow-get-browser-view-match-window","description":"Enables the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["get_browser_view_match_window"],"deny":[]}},"allow-get-browser-view-popout-state":{"identifier":"allow-get-browser-view-popout-state","description":"Enables the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":["get_browser_view_popout_state"],"deny":[]}},"allow-get-browser-view-status":{"identifier":"allow-get-browser-view-status","description":"Enables the get_browser_view_status command without any pre-configured scope.","commands":{"allow":["get_browser_view_status"],"deny":[]}},"allow-get-container-info":{"identifier":"allow-get-container-info","description":"Enables the get_container_info command without any pre-configured scope.","commands":{"allow":["get_container_info"],"deny":[]}},"allow-get-container-page-state":{"identifier":"allow-get-container-page-state","description":"Enables the get_container_page_state command without any pre-configured scope.","commands":{"allow":["get_container_page_state"],"deny":[]}},"allow-get-container-staleness":{"identifier":"allow-get-container-staleness","description":"Enables the get_container_staleness command without any pre-configured scope.","commands":{"allow":["get_container_staleness"],"deny":[]}},"allow-get-gateway-auth-token":{"identifier":"allow-get-gateway-auth-token","description":"Enables the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["get_gateway_auth_token"],"deny":[]}},"allow-get-gateway-status":{"identifier":"allow-get-gateway-status","description":"Enables the get_gateway_status command without any pre-configured scope.","commands":{"allow":["get_gateway_status"],"deny":[]}},"allow-get-help-content":{"identifier":"allow-get-help-content","description":"Enables the get_help_content command without any pre-configured scope.","commands":{"allow":["get_help_content"],"deny":[]}},"allow-get-marketplace-sync-report":{"identifier":"allow-get-marketplace-sync-report","description":"Enables the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":["get_marketplace_sync_report"],"deny":[]}},"allow-get-migration-state":{"identifier":"allow-get-migration-state","description":"Enables the get_migration_state command without any pre-configured scope.","commands":{"allow":["get_migration_state"],"deny":[]}},"allow-get-scheduled-task-log":{"identifier":"allow-get-scheduled-task-log","description":"Enables the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":["get_scheduled_task_log"],"deny":[]}},"allow-get-scheduler-notifications":{"identifier":"allow-get-scheduler-notifications","description":"Enables the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["get_scheduler_notifications"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-get-stt-status":{"identifier":"allow-get-stt-status","description":"Enables the get_stt_status command without any pre-configured scope.","commands":{"allow":["get_stt_status"],"deny":[]}},"allow-get-web-terminal-status":{"identifier":"allow-get-web-terminal-status","description":"Enables the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":["get_web_terminal_status"],"deny":[]}},"allow-has-claude-token":{"identifier":"allow-has-claude-token","description":"Enables the has_claude_token command without any pre-configured scope.","commands":{"allow":["has_claude_token"],"deny":[]}},"allow-inspect-ca-cert-path":{"identifier":"allow-inspect-ca-cert-path","description":"Enables the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":["inspect_ca_cert_path"],"deny":[]}},"allow-install-browser-view-browser":{"identifier":"allow-install-browser-view-browser","description":"Enables the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":["install_browser_view_browser"],"deny":[]}},"allow-install-browser-view-support":{"identifier":"allow-install-browser-view-support","description":"Enables the install_browser_view_support command without any pre-configured scope.","commands":{"allow":["install_browser_view_support"],"deny":[]}},"allow-install-marketplace-item":{"identifier":"allow-install-marketplace-item","description":"Enables the install_marketplace_item command without any pre-configured scope.","commands":{"allow":["install_marketplace_item"],"deny":[]}},"allow-list-aws-profiles":{"identifier":"allow-list-aws-profiles","description":"Enables the list_aws_profiles command without any pre-configured scope.","commands":{"allow":["list_aws_profiles"],"deny":[]}},"allow-list-claude-sessions":{"identifier":"allow-list-claude-sessions","description":"Enables the list_claude_sessions command without any pre-configured scope.","commands":{"allow":["list_claude_sessions"],"deny":[]}},"allow-list-container-capabilities":{"identifier":"allow-list-container-capabilities","description":"Enables the list_container_capabilities command without any pre-configured scope.","commands":{"allow":["list_container_capabilities"],"deny":[]}},"allow-list-container-files":{"identifier":"allow-list-container-files","description":"Enables the list_container_files command without any pre-configured scope.","commands":{"allow":["list_container_files"],"deny":[]}},"allow-list-marketplace-snapshots":{"identifier":"allow-list-marketplace-snapshots","description":"Enables the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":["list_marketplace_snapshots"],"deny":[]}},"allow-list-marketplace-updates":{"identifier":"allow-list-marketplace-updates","description":"Enables the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":["list_marketplace_updates"],"deny":[]}},"allow-list-notes":{"identifier":"allow-list-notes","description":"Enables the list_notes command without any pre-configured scope.","commands":{"allow":["list_notes"],"deny":[]}},"allow-list-projects":{"identifier":"allow-list-projects","description":"Enables the list_projects command without any pre-configured scope.","commands":{"allow":["list_projects"],"deny":[]}},"allow-list-scheduled-tasks":{"identifier":"allow-list-scheduled-tasks","description":"Enables the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":["list_scheduled_tasks"],"deny":[]}},"allow-marketplace-gh-host-available":{"identifier":"allow-marketplace-gh-host-available","description":"Enables the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":["marketplace_gh_host_available"],"deny":[]}},"allow-marketplace-item-diff":{"identifier":"allow-marketplace-item-diff","description":"Enables the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":["marketplace_item_diff"],"deny":[]}},"allow-migrate-project-to-base":{"identifier":"allow-migrate-project-to-base","description":"Enables the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":["migrate_project_to_base"],"deny":[]}},"allow-onboard-aws-user":{"identifier":"allow-onboard-aws-user","description":"Enables the onboard_aws_user command without any pre-configured scope.","commands":{"allow":["onboard_aws_user"],"deny":[]}},"allow-open-browser-view-popout":{"identifier":"allow-open-browser-view-popout","description":"Enables the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":["open_browser_view_popout"],"deny":[]}},"allow-open-file-viewer":{"identifier":"allow-open-file-viewer","description":"Enables the open_file_viewer command without any pre-configured scope.","commands":{"allow":["open_file_viewer"],"deny":[]}},"allow-open-page-in-container-browser":{"identifier":"allow-open-page-in-container-browser","description":"Enables the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":["open_page_in_container_browser"],"deny":[]}},"allow-open-terminal-session":{"identifier":"allow-open-terminal-session","description":"Enables the open_terminal_session command without any pre-configured scope.","commands":{"allow":["open_terminal_session"],"deny":[]}},"allow-open-url-external":{"identifier":"allow-open-url-external","description":"Enables the open_url_external command without any pre-configured scope.","commands":{"allow":["open_url_external"],"deny":[]}},"allow-paste-image-to-terminal":{"identifier":"allow-paste-image-to-terminal","description":"Enables the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":["paste_image_to_terminal"],"deny":[]}},"allow-preview-settings-import":{"identifier":"allow-preview-settings-import","description":"Enables the preview_settings_import command without any pre-configured scope.","commands":{"allow":["preview_settings_import"],"deny":[]}},"allow-pull-gateway-image":{"identifier":"allow-pull-gateway-image","description":"Enables the pull_gateway_image command without any pre-configured scope.","commands":{"allow":["pull_gateway_image"],"deny":[]}},"allow-pull-image":{"identifier":"allow-pull-image","description":"Enables the pull_image command without any pre-configured scope.","commands":{"allow":["pull_image"],"deny":[]}},"allow-pull-stt-image":{"identifier":"allow-pull-stt-image","description":"Enables the pull_stt_image command without any pre-configured scope.","commands":{"allow":["pull_stt_image"],"deny":[]}},"allow-push-aws-saved-state":{"identifier":"allow-push-aws-saved-state","description":"Enables the push_aws_saved_state command without any pre-configured scope.","commands":{"allow":["push_aws_saved_state"],"deny":[]}},"allow-read-container-file":{"identifier":"allow-read-container-file","description":"Enables the read_container_file command without any pre-configured scope.","commands":{"allow":["read_container_file"],"deny":[]}},"allow-rebuild-project-container":{"identifier":"allow-rebuild-project-container","description":"Enables the rebuild_project_container command without any pre-configured scope.","commands":{"allow":["rebuild_project_container"],"deny":[]}},"allow-reconcile-aws-projects":{"identifier":"allow-reconcile-aws-projects","description":"Enables the reconcile_aws_projects command without any pre-configured scope.","commands":{"allow":["reconcile_aws_projects"],"deny":[]}},"allow-reconcile-project-statuses":{"identifier":"allow-reconcile-project-statuses","description":"Enables the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":["reconcile_project_statuses"],"deny":[]}},"allow-refresh-marketplaces":{"identifier":"allow-refresh-marketplaces","description":"Enables the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":["refresh_marketplaces"],"deny":[]}},"allow-regenerate-gateway-auth-token":{"identifier":"allow-regenerate-gateway-auth-token","description":"Enables the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["regenerate_gateway_auth_token"],"deny":[]}},"allow-regenerate-web-terminal-token":{"identifier":"allow-regenerate-web-terminal-token","description":"Enables the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":["regenerate_web_terminal_token"],"deny":[]}},"allow-remove-aws-resources":{"identifier":"allow-remove-aws-resources","description":"Enables the remove_aws_resources command without any pre-configured scope.","commands":{"allow":["remove_aws_resources"],"deny":[]}},"allow-remove-host-connection":{"identifier":"allow-remove-host-connection","description":"Enables the remove_host_connection command without any pre-configured scope.","commands":{"allow":["remove_host_connection"],"deny":[]}},"allow-remove-marketplace":{"identifier":"allow-remove-marketplace","description":"Enables the remove_marketplace command without any pre-configured scope.","commands":{"allow":["remove_marketplace"],"deny":[]}},"allow-remove-marketplace-account":{"identifier":"allow-remove-marketplace-account","description":"Enables the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":["remove_marketplace_account"],"deny":[]}},"allow-remove-project":{"identifier":"allow-remove-project","description":"Enables the remove_project command without any pre-configured scope.","commands":{"allow":["remove_project"],"deny":[]}},"allow-remove-scheduled-task":{"identifier":"allow-remove-scheduled-task","description":"Enables the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":["remove_scheduled_task"],"deny":[]}},"allow-rename-container-path":{"identifier":"allow-rename-container-path","description":"Enables the rename_container_path command without any pre-configured scope.","commands":{"allow":["rename_container_path"],"deny":[]}},"allow-repair-aws-environment":{"identifier":"allow-repair-aws-environment","description":"Enables the repair_aws_environment command without any pre-configured scope.","commands":{"allow":["repair_aws_environment"],"deny":[]}},"allow-rollback-migration":{"identifier":"allow-rollback-migration","description":"Enables the rollback_migration command without any pre-configured scope.","commands":{"allow":["rollback_migration"],"deny":[]}},"allow-run-docker-install":{"identifier":"allow-run-docker-install","description":"Enables the run_docker_install command without any pre-configured scope.","commands":{"allow":["run_docker_install"],"deny":[]}},"allow-run-scheduled-task-now":{"identifier":"allow-run-scheduled-task-now","description":"Enables the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":["run_scheduled_task_now"],"deny":[]}},"allow-save-aws-admin-bundle":{"identifier":"allow-save-aws-admin-bundle","description":"Enables the save_aws_admin_bundle command without any pre-configured scope.","commands":{"allow":["save_aws_admin_bundle"],"deny":[]}},"allow-save-note":{"identifier":"allow-save-note","description":"Enables the save_note command without any pre-configured scope.","commands":{"allow":["save_note"],"deny":[]}},"allow-send-audio-data":{"identifier":"allow-send-audio-data","description":"Enables the send_audio_data command without any pre-configured scope.","commands":{"allow":["send_audio_data"],"deny":[]}},"allow-set-auth-bridge-enabled":{"identifier":"allow-set-auth-bridge-enabled","description":"Enables the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":["set_auth_bridge_enabled"],"deny":[]}},"allow-set-browser-view-enabled":{"identifier":"allow-set-browser-view-enabled","description":"Enables the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":["set_browser_view_enabled"],"deny":[]}},"allow-set-browser-view-match-window":{"identifier":"allow-set-browser-view-match-window","description":"Enables the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["set_browser_view_match_window"],"deny":[]}},"allow-set-browser-view-popout-always-on-top":{"identifier":"allow-set-browser-view-popout-always-on-top","description":"Enables the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":["set_browser_view_popout_always_on_top"],"deny":[]}},"allow-set-container-page-viewport":{"identifier":"allow-set-container-page-viewport","description":"Enables the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":["set_container_page_viewport"],"deny":[]}},"allow-set-gateway-api-key":{"identifier":"allow-set-gateway-api-key","description":"Enables the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":["set_gateway_api_key"],"deny":[]}},"allow-set-global-item-disabled":{"identifier":"allow-set-global-item-disabled","description":"Enables the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":["set_global_item_disabled"],"deny":[]}},"allow-set-scheduled-task-enabled":{"identifier":"allow-set-scheduled-task-enabled","description":"Enables the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":["set_scheduled_task_enabled"],"deny":[]}},"allow-start-audio-bridge":{"identifier":"allow-start-audio-bridge","description":"Enables the start_audio_bridge command without any pre-configured scope.","commands":{"allow":["start_audio_bridge"],"deny":[]}},"allow-start-gateway":{"identifier":"allow-start-gateway","description":"Enables the start_gateway command without any pre-configured scope.","commands":{"allow":["start_gateway"],"deny":[]}},"allow-start-marketplace-gh-container-login":{"identifier":"allow-start-marketplace-gh-container-login","description":"Enables the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":["start_marketplace_gh_container_login"],"deny":[]}},"allow-start-project-container":{"identifier":"allow-start-project-container","description":"Enables the start_project_container command without any pre-configured scope.","commands":{"allow":["start_project_container"],"deny":[]}},"allow-start-stt":{"identifier":"allow-start-stt","description":"Enables the start_stt command without any pre-configured scope.","commands":{"allow":["start_stt"],"deny":[]}},"allow-start-web-terminal":{"identifier":"allow-start-web-terminal","description":"Enables the start_web_terminal command without any pre-configured scope.","commands":{"allow":["start_web_terminal"],"deny":[]}},"allow-stop-audio-bridge":{"identifier":"allow-stop-audio-bridge","description":"Enables the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":["stop_audio_bridge"],"deny":[]}},"allow-stop-gateway":{"identifier":"allow-stop-gateway","description":"Enables the stop_gateway command without any pre-configured scope.","commands":{"allow":["stop_gateway"],"deny":[]}},"allow-stop-project-container":{"identifier":"allow-stop-project-container","description":"Enables the stop_project_container command without any pre-configured scope.","commands":{"allow":["stop_project_container"],"deny":[]}},"allow-stop-stt":{"identifier":"allow-stop-stt","description":"Enables the stop_stt command without any pre-configured scope.","commands":{"allow":["stop_stt"],"deny":[]}},"allow-stop-web-terminal":{"identifier":"allow-stop-web-terminal","description":"Enables the stop_web_terminal command without any pre-configured scope.","commands":{"allow":["stop_web_terminal"],"deny":[]}},"allow-submit-claude-token-code":{"identifier":"allow-submit-claude-token-code","description":"Enables the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":["submit_claude_token_code"],"deny":[]}},"allow-sweep-claude-token-snapshots":{"identifier":"allow-sweep-claude-token-snapshots","description":"Enables the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":["sweep_claude_token_snapshots"],"deny":[]}},"allow-terminal-input":{"identifier":"allow-terminal-input","description":"Enables the terminal_input command without any pre-configured scope.","commands":{"allow":["terminal_input"],"deny":[]}},"allow-terminal-resize":{"identifier":"allow-terminal-resize","description":"Enables the terminal_resize command without any pre-configured scope.","commands":{"allow":["terminal_resize"],"deny":[]}},"allow-test-marketplace-account":{"identifier":"allow-test-marketplace-account","description":"Enables the test_marketplace_account command without any pre-configured scope.","commands":{"allow":["test_marketplace_account"],"deny":[]}},"allow-transcribe-audio":{"identifier":"allow-transcribe-audio","description":"Enables the transcribe_audio command without any pre-configured scope.","commands":{"allow":["transcribe_audio"],"deny":[]}},"allow-uninstall-marketplace-item":{"identifier":"allow-uninstall-marketplace-item","description":"Enables the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":["uninstall_marketplace_item"],"deny":[]}},"allow-unlock-aws-state":{"identifier":"allow-unlock-aws-state","description":"Enables the unlock_aws_state command without any pre-configured scope.","commands":{"allow":["unlock_aws_state"],"deny":[]}},"allow-update-aws-connection":{"identifier":"allow-update-aws-connection","description":"Enables the update_aws_connection command without any pre-configured scope.","commands":{"allow":["update_aws_connection"],"deny":[]}},"allow-update-aws-environment":{"identifier":"allow-update-aws-environment","description":"Enables the update_aws_environment command without any pre-configured scope.","commands":{"allow":["update_aws_environment"],"deny":[]}},"allow-update-marketplace":{"identifier":"allow-update-marketplace","description":"Enables the update_marketplace command without any pre-configured scope.","commands":{"allow":["update_marketplace"],"deny":[]}},"allow-update-marketplace-item":{"identifier":"allow-update-marketplace-item","description":"Enables the update_marketplace_item command without any pre-configured scope.","commands":{"allow":["update_marketplace_item"],"deny":[]}},"allow-update-project":{"identifier":"allow-update-project","description":"Enables the update_project command without any pre-configured scope.","commands":{"allow":["update_project"],"deny":[]}},"allow-update-scheduled-task":{"identifier":"allow-update-scheduled-task","description":"Enables the update_scheduled_task command without any pre-configured scope.","commands":{"allow":["update_scheduled_task"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-upload-files-to-container":{"identifier":"allow-upload-files-to-container","description":"Enables the upload_files_to_container command without any pre-configured scope.","commands":{"allow":["upload_files_to_container"],"deny":[]}},"allow-upload-host-file-to-terminal":{"identifier":"allow-upload-host-file-to-terminal","description":"Enables the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":["upload_host_file_to_terminal"],"deny":[]}},"allow-viewer-choose-file":{"identifier":"allow-viewer-choose-file","description":"Enables the viewer_choose_file command without any pre-configured scope.","commands":{"allow":["viewer_choose_file"],"deny":[]}},"allow-viewer-get-state":{"identifier":"allow-viewer-get-state","description":"Enables the viewer_get_state command without any pre-configured scope.","commands":{"allow":["viewer_get_state"],"deny":[]}},"allow-viewer-poll-file":{"identifier":"allow-viewer-poll-file","description":"Enables the viewer_poll_file command without any pre-configured scope.","commands":{"allow":["viewer_poll_file"],"deny":[]}},"allow-viewer-read-file":{"identifier":"allow-viewer-read-file","description":"Enables the viewer_read_file command without any pre-configured scope.","commands":{"allow":["viewer_read_file"],"deny":[]}},"allow-viewer-write-file":{"identifier":"allow-viewer-write-file","description":"Enables the viewer_write_file command without any pre-configured scope.","commands":{"allow":["viewer_write_file"],"deny":[]}},"deny-acquire-claude-token":{"identifier":"deny-acquire-claude-token","description":"Denies the acquire_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["acquire_claude_token"]}},"deny-add-aws-keys-connection":{"identifier":"deny-add-aws-keys-connection","description":"Denies the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["add_aws_keys_connection"]}},"deny-add-aws-profile-connection":{"identifier":"deny-add-aws-profile-connection","description":"Denies the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["add_aws_profile_connection"]}},"deny-add-marketplace":{"identifier":"deny-add-marketplace","description":"Denies the add_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace"]}},"deny-add-marketplace-gh-host-account":{"identifier":"deny-add-marketplace-gh-host-account","description":"Denies the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_gh_host_account"]}},"deny-add-marketplace-token-account":{"identifier":"deny-add-marketplace-token-account","description":"Denies the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_token_account"]}},"deny-add-project":{"identifier":"deny-add-project","description":"Denies the add_project command without any pre-configured scope.","commands":{"allow":[],"deny":["add_project"]}},"deny-add-scheduled-task":{"identifier":"deny-add-scheduled-task","description":"Denies the add_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["add_scheduled_task"]}},"deny-apply-marketplace-now":{"identifier":"deny-apply-marketplace-now","description":"Denies the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_marketplace_now"]}},"deny-apply-settings-import":{"identifier":"deny-apply-settings-import","description":"Denies the apply_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_settings_import"]}},"deny-aws-connection-identity":{"identifier":"deny-aws-connection-identity","description":"Denies the aws_connection_identity command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_connection_identity"]}},"deny-aws-connection-sso-login":{"identifier":"deny-aws-connection-sso-login","description":"Denies the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_connection_sso_login"]}},"deny-aws-policy-document":{"identifier":"deny-aws-policy-document","description":"Denies the aws_policy_document command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_policy_document"]}},"deny-aws-sso-refresh":{"identifier":"deny-aws-sso-refresh","description":"Denies the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_sso_refresh"]}},"deny-aws-task-log":{"identifier":"deny-aws-task-log","description":"Denies the aws_task_log command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_task_log"]}},"deny-build-gateway-image":{"identifier":"deny-build-gateway-image","description":"Denies the build_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_gateway_image"]}},"deny-build-image":{"identifier":"deny-build-image","description":"Denies the build_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_image"]}},"deny-build-stt-image":{"identifier":"deny-build-stt-image","description":"Denies the build_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_stt_image"]}},"deny-cancel-aws-deployment":{"identifier":"deny-cancel-aws-deployment","description":"Denies the cancel_aws_deployment command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_aws_deployment"]}},"deny-cancel-claude-token":{"identifier":"deny-cancel-claude-token","description":"Denies the cancel_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_claude_token"]}},"deny-cancel-marketplace-gh-login":{"identifier":"deny-cancel-marketplace-gh-login","description":"Denies the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_marketplace_gh_login"]}},"deny-check-aws-permissions":{"identifier":"deny-check-aws-permissions","description":"Denies the check_aws_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_aws_permissions"]}},"deny-check-browser-view-support":{"identifier":"deny-check-browser-view-support","description":"Denies the check_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["check_browser_view_support"]}},"deny-check-docker":{"identifier":"deny-check-docker","description":"Denies the check_docker command without any pre-configured scope.","commands":{"allow":[],"deny":["check_docker"]}},"deny-check-for-updates":{"identifier":"deny-check-for-updates","description":"Denies the check_for_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["check_for_updates"]}},"deny-check-gateway-health":{"identifier":"deny-check-gateway-health","description":"Denies the check_gateway_health command without any pre-configured scope.","commands":{"allow":[],"deny":["check_gateway_health"]}},"deny-check-image-exists":{"identifier":"deny-check-image-exists","description":"Denies the check_image_exists command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_exists"]}},"deny-check-image-update":{"identifier":"deny-check-image-update","description":"Denies the check_image_update command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_update"]}},"deny-check-unsaved-work":{"identifier":"deny-check-unsaved-work","description":"Denies the check_unsaved_work command without any pre-configured scope.","commands":{"allow":[],"deny":["check_unsaved_work"]}},"deny-clear-claude-token":{"identifier":"deny-clear-claude-token","description":"Denies the clear_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_claude_token"]}},"deny-clear-gateway-api-key":{"identifier":"deny-clear-gateway-api-key","description":"Denies the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_gateway_api_key"]}},"deny-clear-scheduler-notifications":{"identifier":"deny-clear-scheduler-notifications","description":"Denies the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_scheduler_notifications"]}},"deny-close-browser-view-popout":{"identifier":"deny-close-browser-view-popout","description":"Denies the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["close_browser_view_popout"]}},"deny-close-container-page":{"identifier":"deny-close-container-page","description":"Denies the close_container_page command without any pre-configured scope.","commands":{"allow":[],"deny":["close_container_page"]}},"deny-close-terminal-session":{"identifier":"deny-close-terminal-session","description":"Denies the close_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["close_terminal_session"]}},"deny-confirm-migration":{"identifier":"deny-confirm-migration","description":"Denies the confirm_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm_migration"]}},"deny-create-container-directory":{"identifier":"deny-create-container-directory","description":"Denies the create_container_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["create_container_directory"]}},"deny-delete-note":{"identifier":"deny-delete-note","description":"Denies the delete_note command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_note"]}},"deny-deploy-aws-environment":{"identifier":"deny-deploy-aws-environment","description":"Denies the deploy_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["deploy_aws_environment"]}},"deny-deploy-aws-permissions-boundary":{"identifier":"deny-deploy-aws-permissions-boundary","description":"Denies the deploy_aws_permissions_boundary command without any pre-configured scope.","commands":{"allow":[],"deny":["deploy_aws_permissions_boundary"]}},"deny-destroy-incomplete-aws-environment":{"identifier":"deny-destroy-incomplete-aws-environment","description":"Denies the destroy_incomplete_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy_incomplete_aws_environment"]}},"deny-detect-aws-config":{"identifier":"deny-detect-aws-config","description":"Denies the detect_aws_config command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_aws_config"]}},"deny-detect-aws-environments":{"identifier":"deny-detect-aws-environments","description":"Denies the detect_aws_environments command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_aws_environments"]}},"deny-detect-host-timezone":{"identifier":"deny-detect-host-timezone","description":"Denies the detect_host_timezone command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_host_timezone"]}},"deny-detect-install-options":{"identifier":"deny-detect-install-options","description":"Denies the detect_install_options command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_install_options"]}},"deny-download-container-backup":{"identifier":"deny-download-container-backup","description":"Denies the download_container_backup command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_backup"]}},"deny-download-container-file":{"identifier":"deny-download-container-file","description":"Denies the download_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_file"]}},"deny-export-settings":{"identifier":"deny-export-settings","description":"Denies the export_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["export_settings"]}},"deny-follow-aws-deployment":{"identifier":"deny-follow-aws-deployment","description":"Denies the follow_aws_deployment command without any pre-configured scope.","commands":{"allow":[],"deny":["follow_aws_deployment"]}},"deny-forget-marketplace-installs":{"identifier":"deny-forget-marketplace-installs","description":"Denies the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":[],"deny":["forget_marketplace_installs"]}},"deny-get-app-version":{"identifier":"deny-get-app-version","description":"Denies the get_app_version command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_version"]}},"deny-get-auth-bridge-status":{"identifier":"deny-get-auth-bridge-status","description":"Denies the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_auth_bridge_status"]}},"deny-get-browser-view-match-window":{"identifier":"deny-get-browser-view-match-window","description":"Denies the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_match_window"]}},"deny-get-browser-view-popout-state":{"identifier":"deny-get-browser-view-popout-state","description":"Denies the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_popout_state"]}},"deny-get-browser-view-status":{"identifier":"deny-get-browser-view-status","description":"Denies the get_browser_view_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_status"]}},"deny-get-container-info":{"identifier":"deny-get-container-info","description":"Denies the get_container_info command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_info"]}},"deny-get-container-page-state":{"identifier":"deny-get-container-page-state","description":"Denies the get_container_page_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_page_state"]}},"deny-get-container-staleness":{"identifier":"deny-get-container-staleness","description":"Denies the get_container_staleness command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_staleness"]}},"deny-get-gateway-auth-token":{"identifier":"deny-get-gateway-auth-token","description":"Denies the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_auth_token"]}},"deny-get-gateway-status":{"identifier":"deny-get-gateway-status","description":"Denies the get_gateway_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_status"]}},"deny-get-help-content":{"identifier":"deny-get-help-content","description":"Denies the get_help_content command without any pre-configured scope.","commands":{"allow":[],"deny":["get_help_content"]}},"deny-get-marketplace-sync-report":{"identifier":"deny-get-marketplace-sync-report","description":"Denies the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":[],"deny":["get_marketplace_sync_report"]}},"deny-get-migration-state":{"identifier":"deny-get-migration-state","description":"Denies the get_migration_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_migration_state"]}},"deny-get-scheduled-task-log":{"identifier":"deny-get-scheduled-task-log","description":"Denies the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduled_task_log"]}},"deny-get-scheduler-notifications":{"identifier":"deny-get-scheduler-notifications","description":"Denies the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduler_notifications"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-get-stt-status":{"identifier":"deny-get-stt-status","description":"Denies the get_stt_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_stt_status"]}},"deny-get-web-terminal-status":{"identifier":"deny-get-web-terminal-status","description":"Denies the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_web_terminal_status"]}},"deny-has-claude-token":{"identifier":"deny-has-claude-token","description":"Denies the has_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["has_claude_token"]}},"deny-inspect-ca-cert-path":{"identifier":"deny-inspect-ca-cert-path","description":"Denies the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":[],"deny":["inspect_ca_cert_path"]}},"deny-install-browser-view-browser":{"identifier":"deny-install-browser-view-browser","description":"Denies the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_browser"]}},"deny-install-browser-view-support":{"identifier":"deny-install-browser-view-support","description":"Denies the install_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_support"]}},"deny-install-marketplace-item":{"identifier":"deny-install-marketplace-item","description":"Denies the install_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["install_marketplace_item"]}},"deny-list-aws-profiles":{"identifier":"deny-list-aws-profiles","description":"Denies the list_aws_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_aws_profiles"]}},"deny-list-claude-sessions":{"identifier":"deny-list-claude-sessions","description":"Denies the list_claude_sessions command without any pre-configured scope.","commands":{"allow":[],"deny":["list_claude_sessions"]}},"deny-list-container-capabilities":{"identifier":"deny-list-container-capabilities","description":"Denies the list_container_capabilities command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_capabilities"]}},"deny-list-container-files":{"identifier":"deny-list-container-files","description":"Denies the list_container_files command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_files"]}},"deny-list-marketplace-snapshots":{"identifier":"deny-list-marketplace-snapshots","description":"Denies the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_snapshots"]}},"deny-list-marketplace-updates":{"identifier":"deny-list-marketplace-updates","description":"Denies the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_updates"]}},"deny-list-notes":{"identifier":"deny-list-notes","description":"Denies the list_notes command without any pre-configured scope.","commands":{"allow":[],"deny":["list_notes"]}},"deny-list-projects":{"identifier":"deny-list-projects","description":"Denies the list_projects command without any pre-configured scope.","commands":{"allow":[],"deny":["list_projects"]}},"deny-list-scheduled-tasks":{"identifier":"deny-list-scheduled-tasks","description":"Denies the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":[],"deny":["list_scheduled_tasks"]}},"deny-marketplace-gh-host-available":{"identifier":"deny-marketplace-gh-host-available","description":"Denies the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_gh_host_available"]}},"deny-marketplace-item-diff":{"identifier":"deny-marketplace-item-diff","description":"Denies the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_item_diff"]}},"deny-migrate-project-to-base":{"identifier":"deny-migrate-project-to-base","description":"Denies the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":[],"deny":["migrate_project_to_base"]}},"deny-onboard-aws-user":{"identifier":"deny-onboard-aws-user","description":"Denies the onboard_aws_user command without any pre-configured scope.","commands":{"allow":[],"deny":["onboard_aws_user"]}},"deny-open-browser-view-popout":{"identifier":"deny-open-browser-view-popout","description":"Denies the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["open_browser_view_popout"]}},"deny-open-file-viewer":{"identifier":"deny-open-file-viewer","description":"Denies the open_file_viewer command without any pre-configured scope.","commands":{"allow":[],"deny":["open_file_viewer"]}},"deny-open-page-in-container-browser":{"identifier":"deny-open-page-in-container-browser","description":"Denies the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["open_page_in_container_browser"]}},"deny-open-terminal-session":{"identifier":"deny-open-terminal-session","description":"Denies the open_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["open_terminal_session"]}},"deny-open-url-external":{"identifier":"deny-open-url-external","description":"Denies the open_url_external command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url_external"]}},"deny-paste-image-to-terminal":{"identifier":"deny-paste-image-to-terminal","description":"Denies the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["paste_image_to_terminal"]}},"deny-preview-settings-import":{"identifier":"deny-preview-settings-import","description":"Denies the preview_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["preview_settings_import"]}},"deny-pull-gateway-image":{"identifier":"deny-pull-gateway-image","description":"Denies the pull_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_gateway_image"]}},"deny-pull-image":{"identifier":"deny-pull-image","description":"Denies the pull_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_image"]}},"deny-pull-stt-image":{"identifier":"deny-pull-stt-image","description":"Denies the pull_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_stt_image"]}},"deny-push-aws-saved-state":{"identifier":"deny-push-aws-saved-state","description":"Denies the push_aws_saved_state command without any pre-configured scope.","commands":{"allow":[],"deny":["push_aws_saved_state"]}},"deny-read-container-file":{"identifier":"deny-read-container-file","description":"Denies the read_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_container_file"]}},"deny-rebuild-project-container":{"identifier":"deny-rebuild-project-container","description":"Denies the rebuild_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["rebuild_project_container"]}},"deny-reconcile-aws-projects":{"identifier":"deny-reconcile-aws-projects","description":"Denies the reconcile_aws_projects command without any pre-configured scope.","commands":{"allow":[],"deny":["reconcile_aws_projects"]}},"deny-reconcile-project-statuses":{"identifier":"deny-reconcile-project-statuses","description":"Denies the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":[],"deny":["reconcile_project_statuses"]}},"deny-refresh-marketplaces":{"identifier":"deny-refresh-marketplaces","description":"Denies the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":[],"deny":["refresh_marketplaces"]}},"deny-regenerate-gateway-auth-token":{"identifier":"deny-regenerate-gateway-auth-token","description":"Denies the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_gateway_auth_token"]}},"deny-regenerate-web-terminal-token":{"identifier":"deny-regenerate-web-terminal-token","description":"Denies the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_web_terminal_token"]}},"deny-remove-aws-resources":{"identifier":"deny-remove-aws-resources","description":"Denies the remove_aws_resources command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_aws_resources"]}},"deny-remove-host-connection":{"identifier":"deny-remove-host-connection","description":"Denies the remove_host_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_host_connection"]}},"deny-remove-marketplace":{"identifier":"deny-remove-marketplace","description":"Denies the remove_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace"]}},"deny-remove-marketplace-account":{"identifier":"deny-remove-marketplace-account","description":"Denies the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace_account"]}},"deny-remove-project":{"identifier":"deny-remove-project","description":"Denies the remove_project command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_project"]}},"deny-remove-scheduled-task":{"identifier":"deny-remove-scheduled-task","description":"Denies the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_scheduled_task"]}},"deny-rename-container-path":{"identifier":"deny-rename-container-path","description":"Denies the rename_container_path command without any pre-configured scope.","commands":{"allow":[],"deny":["rename_container_path"]}},"deny-repair-aws-environment":{"identifier":"deny-repair-aws-environment","description":"Denies the repair_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["repair_aws_environment"]}},"deny-rollback-migration":{"identifier":"deny-rollback-migration","description":"Denies the rollback_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["rollback_migration"]}},"deny-run-docker-install":{"identifier":"deny-run-docker-install","description":"Denies the run_docker_install command without any pre-configured scope.","commands":{"allow":[],"deny":["run_docker_install"]}},"deny-run-scheduled-task-now":{"identifier":"deny-run-scheduled-task-now","description":"Denies the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":[],"deny":["run_scheduled_task_now"]}},"deny-save-aws-admin-bundle":{"identifier":"deny-save-aws-admin-bundle","description":"Denies the save_aws_admin_bundle command without any pre-configured scope.","commands":{"allow":[],"deny":["save_aws_admin_bundle"]}},"deny-save-note":{"identifier":"deny-save-note","description":"Denies the save_note command without any pre-configured scope.","commands":{"allow":[],"deny":["save_note"]}},"deny-send-audio-data":{"identifier":"deny-send-audio-data","description":"Denies the send_audio_data command without any pre-configured scope.","commands":{"allow":[],"deny":["send_audio_data"]}},"deny-set-auth-bridge-enabled":{"identifier":"deny-set-auth-bridge-enabled","description":"Denies the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_auth_bridge_enabled"]}},"deny-set-browser-view-enabled":{"identifier":"deny-set-browser-view-enabled","description":"Denies the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_enabled"]}},"deny-set-browser-view-match-window":{"identifier":"deny-set-browser-view-match-window","description":"Denies the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_match_window"]}},"deny-set-browser-view-popout-always-on-top":{"identifier":"deny-set-browser-view-popout-always-on-top","description":"Denies the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_popout_always_on_top"]}},"deny-set-container-page-viewport":{"identifier":"deny-set-container-page-viewport","description":"Denies the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":[],"deny":["set_container_page_viewport"]}},"deny-set-gateway-api-key":{"identifier":"deny-set-gateway-api-key","description":"Denies the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["set_gateway_api_key"]}},"deny-set-global-item-disabled":{"identifier":"deny-set-global-item-disabled","description":"Denies the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_global_item_disabled"]}},"deny-set-scheduled-task-enabled":{"identifier":"deny-set-scheduled-task-enabled","description":"Denies the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_scheduled_task_enabled"]}},"deny-start-audio-bridge":{"identifier":"deny-start-audio-bridge","description":"Denies the start_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["start_audio_bridge"]}},"deny-start-gateway":{"identifier":"deny-start-gateway","description":"Denies the start_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["start_gateway"]}},"deny-start-marketplace-gh-container-login":{"identifier":"deny-start-marketplace-gh-container-login","description":"Denies the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":[],"deny":["start_marketplace_gh_container_login"]}},"deny-start-project-container":{"identifier":"deny-start-project-container","description":"Denies the start_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["start_project_container"]}},"deny-start-stt":{"identifier":"deny-start-stt","description":"Denies the start_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["start_stt"]}},"deny-start-web-terminal":{"identifier":"deny-start-web-terminal","description":"Denies the start_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["start_web_terminal"]}},"deny-stop-audio-bridge":{"identifier":"deny-stop-audio-bridge","description":"Denies the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_audio_bridge"]}},"deny-stop-gateway":{"identifier":"deny-stop-gateway","description":"Denies the stop_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_gateway"]}},"deny-stop-project-container":{"identifier":"deny-stop-project-container","description":"Denies the stop_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_project_container"]}},"deny-stop-stt":{"identifier":"deny-stop-stt","description":"Denies the stop_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_stt"]}},"deny-stop-web-terminal":{"identifier":"deny-stop-web-terminal","description":"Denies the stop_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_web_terminal"]}},"deny-submit-claude-token-code":{"identifier":"deny-submit-claude-token-code","description":"Denies the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":[],"deny":["submit_claude_token_code"]}},"deny-sweep-claude-token-snapshots":{"identifier":"deny-sweep-claude-token-snapshots","description":"Denies the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["sweep_claude_token_snapshots"]}},"deny-terminal-input":{"identifier":"deny-terminal-input","description":"Denies the terminal_input command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_input"]}},"deny-terminal-resize":{"identifier":"deny-terminal-resize","description":"Denies the terminal_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_resize"]}},"deny-test-marketplace-account":{"identifier":"deny-test-marketplace-account","description":"Denies the test_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["test_marketplace_account"]}},"deny-transcribe-audio":{"identifier":"deny-transcribe-audio","description":"Denies the transcribe_audio command without any pre-configured scope.","commands":{"allow":[],"deny":["transcribe_audio"]}},"deny-uninstall-marketplace-item":{"identifier":"deny-uninstall-marketplace-item","description":"Denies the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["uninstall_marketplace_item"]}},"deny-unlock-aws-state":{"identifier":"deny-unlock-aws-state","description":"Denies the unlock_aws_state command without any pre-configured scope.","commands":{"allow":[],"deny":["unlock_aws_state"]}},"deny-update-aws-connection":{"identifier":"deny-update-aws-connection","description":"Denies the update_aws_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["update_aws_connection"]}},"deny-update-aws-environment":{"identifier":"deny-update-aws-environment","description":"Denies the update_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["update_aws_environment"]}},"deny-update-marketplace":{"identifier":"deny-update-marketplace","description":"Denies the update_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace"]}},"deny-update-marketplace-item":{"identifier":"deny-update-marketplace-item","description":"Denies the update_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace_item"]}},"deny-update-project":{"identifier":"deny-update-project","description":"Denies the update_project command without any pre-configured scope.","commands":{"allow":[],"deny":["update_project"]}},"deny-update-scheduled-task":{"identifier":"deny-update-scheduled-task","description":"Denies the update_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["update_scheduled_task"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-upload-files-to-container":{"identifier":"deny-upload-files-to-container","description":"Denies the upload_files_to_container command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_files_to_container"]}},"deny-upload-host-file-to-terminal":{"identifier":"deny-upload-host-file-to-terminal","description":"Denies the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_host_file_to_terminal"]}},"deny-viewer-choose-file":{"identifier":"deny-viewer-choose-file","description":"Denies the viewer_choose_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_choose_file"]}},"deny-viewer-get-state":{"identifier":"deny-viewer-get-state","description":"Denies the viewer_get_state command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_get_state"]}},"deny-viewer-poll-file":{"identifier":"deny-viewer-poll-file","description":"Denies the viewer_poll_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_poll_file"]}},"deny-viewer-read-file":{"identifier":"deny-viewer-read-file","description":"Denies the viewer_read_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_read_file"]}},"deny-viewer-write-file":{"identifier":"deny-viewer-write-file","description":"Denies the viewer_write_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_write_file"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}}} \ No newline at end of file diff --git a/app/src-tauri/gen/schemas/capabilities.json b/app/src-tauri/gen/schemas/capabilities.json index a9217c8..23dbf94 100644 --- a/app/src-tauri/gen/schemas/capabilities.json +++ b/app/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate field and Backup. Two commands still take a host path over IPC as a string — the terminal drop and `download_container_backup` — and for those `validate_host_path` is the boundary rather than defence in depth. This file is the reviewed threat model of record, so keep this census accurate: a stale reference here is worse than none. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). A second capability file, `file-viewer.json`, covers the `file-viewer-*` windows the terminal file viewer opens on `viewer.html`; it is the only other local-origin window, its grants are listed and justified there, and its one non-obvious grant (`core:window:allow-destroy`) exists because `onCloseRequested` cannot close a window without it. App commands are gated by this file too. `build.rs` declares a Tauri `AppManifest` listing every command in `generate_handler!`, which is what makes tauri 2.11.0 apply the ACL to app commands at all (`webview/mod.rs:1794` skips it when no app manifest exists), and the bare `allow-` entries below are the complete list of app commands the main window may call. `build.rs` refuses to build unless every registered command has exactly one such grant, in the file whose `windows` its name says it belongs to (`viewer_*` in `file-viewer.json`, everything else here), and unless every bare entry names a registered command — so a forgotten, misspelled, duplicated or misfiled grant is a failed `cargo check`, not a feature that dies at runtime with `not allowed by ACL`. `deny-*` is banned by the same check: in tauri 2.11.0 a deny matches regardless of window or origin, so a deny meant for the viewer would deny main too. Hand-written files under `permissions/` are refused for the same reason — they would be grants this census cannot see. Because the census can only vouch for what it reads, `build.rs` also refuses any capability it did not check: anything in `capabilities/` other than a top-level `*.json` file (tauri also loads `.toml`/`.json5` there, and subdirectories), a `webviews` or `remote` key in a capability file (either would extend grants beyond what `windows` says), `app.security.capabilities` declared inline in `tauri.conf.json`, any `tauri..conf.json`, or `TAURI_CONFIG`, and a tauri config in a format the census cannot parse (JSON5, TOML). OS/editor junk (`.DS_Store`, `Thumbs.db`, editor swap files) is recognised and skipped in `capabilities/` and `permissions/` rather than refused, since tauri never loads it either. Each failure names the check that failed (\"stray entry in capabilities/\", \"capabilities declared outside capabilities/\", \"hand-written permission\", …) rather than always reading as a grant/handler mismatch. Known gap: adding a new `tauri..conf.json` to a tree that has already been built once only takes effect on a clean build or in CI — cargo's incremental build has no reason to notice a file that did not exist on the previous build. The `*marketplace*` commands fetch user-configured https git repos on the host and push pinned files into containers; account tokens stay in the OS keychain and never cross IPC outward — the only inbound one is the token pasted into `add_marketplace_token_account`. The pop-out stays capability-less. The Rust label gates in `commands/file_viewer_commands.rs` remain, because the ACL says *which* window may call a command and the label says *whose* registry entry it acts on; they are not redundant. The rules live in `src/command_census.rs`, which is unit-tested, and `src/test/capabilities.test.ts` checks the other direction: that the code that runs in each window imports only the wrappers that window is granted. On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.","local":true,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:webview:allow-internal-toggle-devtools","dialog:allow-open","dialog:allow-save","allow-check-docker","allow-check-image-exists","allow-build-image","allow-get-container-info","allow-list-projects","allow-add-project","allow-remove-project","allow-update-project","allow-start-project-container","allow-stop-project-container","allow-check-unsaved-work","allow-aws-task-log","allow-remove-aws-resources","allow-rebuild-project-container","allow-reconcile-project-statuses","allow-list-notes","allow-save-note","allow-delete-note","allow-get-container-staleness","allow-migrate-project-to-base","allow-confirm-migration","allow-rollback-migration","allow-get-migration-state","allow-set-auth-bridge-enabled","allow-get-auth-bridge-status","allow-set-browser-view-enabled","allow-get-browser-view-status","allow-check-browser-view-support","allow-install-browser-view-support","allow-install-browser-view-browser","allow-open-browser-view-popout","allow-close-browser-view-popout","allow-get-browser-view-popout-state","allow-set-browser-view-popout-always-on-top","allow-open-page-in-container-browser","allow-set-container-page-viewport","allow-get-container-page-state","allow-close-container-page","allow-set-browser-view-match-window","allow-get-browser-view-match-window","allow-acquire-claude-token","allow-submit-claude-token-code","allow-cancel-claude-token","allow-has-claude-token","allow-clear-claude-token","allow-sweep-claude-token-snapshots","allow-get-settings","allow-update-settings","allow-pull-image","allow-detect-aws-config","allow-inspect-ca-cert-path","allow-list-aws-profiles","allow-add-aws-profile-connection","allow-add-aws-keys-connection","allow-update-aws-connection","allow-remove-host-connection","allow-aws-connection-identity","allow-aws-connection-sso-login","allow-check-aws-permissions","allow-aws-policy-document","allow-detect-aws-environments","allow-deploy-aws-environment","allow-update-aws-environment","allow-repair-aws-environment","allow-destroy-incomplete-aws-environment","allow-unlock-aws-state","allow-follow-aws-deployment","allow-cancel-aws-deployment","allow-push-aws-saved-state","allow-onboard-aws-user","allow-deploy-aws-permissions-boundary","allow-save-aws-admin-bundle","allow-detect-host-timezone","allow-export-settings","allow-preview-settings-import","allow-apply-settings-import","allow-open-terminal-session","allow-terminal-input","allow-terminal-resize","allow-close-terminal-session","allow-paste-image-to-terminal","allow-upload-host-file-to-terminal","allow-start-audio-bridge","allow-send-audio-data","allow-stop-audio-bridge","allow-list-container-files","allow-download-container-backup","allow-download-container-file","allow-upload-files-to-container","allow-read-container-file","allow-rename-container-path","allow-create-container-directory","allow-open-file-viewer","allow-aws-sso-refresh","allow-get-app-version","allow-check-for-updates","allow-check-image-update","allow-get-help-content","allow-open-url-external","allow-detect-install-options","allow-run-docker-install","allow-start-web-terminal","allow-stop-web-terminal","allow-get-web-terminal-status","allow-regenerate-web-terminal-token","allow-get-stt-status","allow-start-stt","allow-stop-stt","allow-build-stt-image","allow-pull-stt-image","allow-transcribe-audio","allow-get-gateway-status","allow-start-gateway","allow-stop-gateway","allow-check-gateway-health","allow-build-gateway-image","allow-pull-gateway-image","allow-set-gateway-api-key","allow-clear-gateway-api-key","allow-get-gateway-auth-token","allow-regenerate-gateway-auth-token","allow-list-claude-sessions","allow-list-container-capabilities","allow-list-scheduled-tasks","allow-add-scheduled-task","allow-update-scheduled-task","allow-get-scheduled-task-log","allow-set-scheduled-task-enabled","allow-run-scheduled-task-now","allow-remove-scheduled-task","allow-get-scheduler-notifications","allow-clear-scheduler-notifications","allow-list-marketplace-snapshots","allow-refresh-marketplaces","allow-add-marketplace","allow-update-marketplace","allow-remove-marketplace","allow-install-marketplace-item","allow-uninstall-marketplace-item","allow-set-global-item-disabled","allow-forget-marketplace-installs","allow-list-marketplace-updates","allow-marketplace-item-diff","allow-update-marketplace-item","allow-apply-marketplace-now","allow-get-marketplace-sync-report","allow-add-marketplace-token-account","allow-add-marketplace-gh-host-account","allow-start-marketplace-gh-container-login","allow-cancel-marketplace-gh-login","allow-test-marketplace-account","allow-remove-marketplace-account","allow-marketplace-gh-host-available"]},"file-viewer":{"identifier":"file-viewer","description":"The terminal file viewer windows (`file-viewer-`, opened by `open_file_viewer` on the app's own `viewer.html`). Same rules as `default.json`, including the layout checks: this file itself must stay a top-level `capabilities/*.json` with no `webviews` or `remote` key, or `build.rs` refuses the build rather than grant something the census cannot see. The five bare `allow-viewer-*` grants are the only app commands a viewer window can invoke: `build.rs` declares the AppManifest that makes tauri enforce that, and refuses any other bare grant in this file. The label gate inside `commands/file_viewer_commands.rs` is still what stops window A acting on window B's registry entry, because the ACL only decides which window may call. The rest of this file is the plugin-command surface a compromised viewer webview could reach, and it is the smallest one that lets the window work. `core:event:allow-listen`/`allow-unlisten` are for `file-viewer-goto` (Rust → this window; the viewer subscribes through `getCurrentWindow().listen`, because a bare `listen()` in *any* window receives an `emit_to`). `core:window:allow-destroy` is not optional: `getCurrentWindow().onCloseRequested` in @tauri-apps/api 2.11 makes Rust `prevent_close()` whenever a JS listener exists and then calls `destroy()` itself, so without this grant the window's X button does nothing once the unsaved-changes guard is installed. `allow-close` is deliberately absent — nothing calls it, and `destroy` is the only exit. No `set-title`/`set-focus`/`unminimize`: those are done from Rust when a second click targets an already-open file. `core:webview:allow-internal-toggle-devtools` is the same dev-only convenience `default.json` carries.","local":true,"windows":["file-viewer-*"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:window:allow-destroy","core:webview:allow-internal-toggle-devtools","allow-viewer-get-state","allow-viewer-choose-file","allow-viewer-read-file","allow-viewer-poll-file","allow-viewer-write-file"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate field and Backup. Two commands still take a host path over IPC as a string — the terminal drop and `download_container_backup` — and for those `validate_host_path` is the boundary rather than defence in depth. This file is the reviewed threat model of record, so keep this census accurate: a stale reference here is worse than none. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). A second capability file, `file-viewer.json`, covers the `file-viewer-*` windows the terminal file viewer opens on `viewer.html`; it is the only other local-origin window, its grants are listed and justified there, and its one non-obvious grant (`core:window:allow-destroy`) exists because `onCloseRequested` cannot close a window without it. App commands are gated by this file too. `build.rs` declares a Tauri `AppManifest` listing every command in `generate_handler!`, which is what makes tauri 2.11.0 apply the ACL to app commands at all (`webview/mod.rs:1794` skips it when no app manifest exists), and the bare `allow-` entries below are the complete list of app commands the main window may call. `build.rs` refuses to build unless every registered command has exactly one such grant, in the file whose `windows` its name says it belongs to (`viewer_*` in `file-viewer.json`, everything else here), and unless every bare entry names a registered command — so a forgotten, misspelled, duplicated or misfiled grant is a failed `cargo check`, not a feature that dies at runtime with `not allowed by ACL`. `deny-*` is banned by the same check: in tauri 2.11.0 a deny matches regardless of window or origin, so a deny meant for the viewer would deny main too. Hand-written files under `permissions/` are refused for the same reason — they would be grants this census cannot see. Because the census can only vouch for what it reads, `build.rs` also refuses any capability it did not check: anything in `capabilities/` other than a top-level `*.json` file (tauri also loads `.toml`/`.json5` there, and subdirectories), a `webviews` or `remote` key in a capability file (either would extend grants beyond what `windows` says), `app.security.capabilities` declared inline in `tauri.conf.json`, any `tauri..conf.json`, or `TAURI_CONFIG`, and a tauri config in a format the census cannot parse (JSON5, TOML). OS/editor junk (`.DS_Store`, `Thumbs.db`, editor swap files) is recognised and skipped in `capabilities/` and `permissions/` rather than refused, since tauri never loads it either. Each failure names the check that failed (\"stray entry in capabilities/\", \"capabilities declared outside capabilities/\", \"hand-written permission\", …) rather than always reading as a grant/handler mismatch. Known gap: adding a new `tauri..conf.json` to a tree that has already been built once only takes effect on a clean build or in CI — cargo's incremental build has no reason to notice a file that did not exist on the previous build. The `*marketplace*` commands fetch user-configured https git repos on the host and push pinned files into containers; account tokens stay in the OS keychain and never cross IPC outward — the only inbound one is the token pasted into `add_marketplace_token_account`. The pop-out stays capability-less. The Rust label gates in `commands/file_viewer_commands.rs` remain, because the ACL says *which* window may call a command and the label says *whose* registry entry it acts on; they are not redundant. The rules live in `src/command_census.rs`, which is unit-tested, and `src/test/capabilities.test.ts` checks the other direction: that the code that runs in each window imports only the wrappers that window is granted. On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.","local":true,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:webview:allow-internal-toggle-devtools","dialog:allow-open","dialog:allow-save","allow-check-docker","allow-check-image-exists","allow-build-image","allow-get-container-info","allow-list-projects","allow-add-project","allow-remove-project","allow-update-project","allow-start-project-container","allow-stop-project-container","allow-check-unsaved-work","allow-aws-task-log","allow-remove-aws-resources","allow-rebuild-project-container","allow-reconcile-project-statuses","allow-reconcile-aws-projects","allow-list-notes","allow-save-note","allow-delete-note","allow-get-container-staleness","allow-migrate-project-to-base","allow-confirm-migration","allow-rollback-migration","allow-get-migration-state","allow-set-auth-bridge-enabled","allow-get-auth-bridge-status","allow-set-browser-view-enabled","allow-get-browser-view-status","allow-check-browser-view-support","allow-install-browser-view-support","allow-install-browser-view-browser","allow-open-browser-view-popout","allow-close-browser-view-popout","allow-get-browser-view-popout-state","allow-set-browser-view-popout-always-on-top","allow-open-page-in-container-browser","allow-set-container-page-viewport","allow-get-container-page-state","allow-close-container-page","allow-set-browser-view-match-window","allow-get-browser-view-match-window","allow-acquire-claude-token","allow-submit-claude-token-code","allow-cancel-claude-token","allow-has-claude-token","allow-clear-claude-token","allow-sweep-claude-token-snapshots","allow-get-settings","allow-update-settings","allow-pull-image","allow-detect-aws-config","allow-inspect-ca-cert-path","allow-list-aws-profiles","allow-add-aws-profile-connection","allow-add-aws-keys-connection","allow-update-aws-connection","allow-remove-host-connection","allow-aws-connection-identity","allow-aws-connection-sso-login","allow-check-aws-permissions","allow-aws-policy-document","allow-detect-aws-environments","allow-deploy-aws-environment","allow-update-aws-environment","allow-repair-aws-environment","allow-destroy-incomplete-aws-environment","allow-unlock-aws-state","allow-follow-aws-deployment","allow-cancel-aws-deployment","allow-push-aws-saved-state","allow-onboard-aws-user","allow-deploy-aws-permissions-boundary","allow-save-aws-admin-bundle","allow-detect-host-timezone","allow-export-settings","allow-preview-settings-import","allow-apply-settings-import","allow-open-terminal-session","allow-terminal-input","allow-terminal-resize","allow-close-terminal-session","allow-paste-image-to-terminal","allow-upload-host-file-to-terminal","allow-start-audio-bridge","allow-send-audio-data","allow-stop-audio-bridge","allow-list-container-files","allow-download-container-backup","allow-download-container-file","allow-upload-files-to-container","allow-read-container-file","allow-rename-container-path","allow-create-container-directory","allow-open-file-viewer","allow-aws-sso-refresh","allow-get-app-version","allow-check-for-updates","allow-check-image-update","allow-get-help-content","allow-open-url-external","allow-detect-install-options","allow-run-docker-install","allow-start-web-terminal","allow-stop-web-terminal","allow-get-web-terminal-status","allow-regenerate-web-terminal-token","allow-get-stt-status","allow-start-stt","allow-stop-stt","allow-build-stt-image","allow-pull-stt-image","allow-transcribe-audio","allow-get-gateway-status","allow-start-gateway","allow-stop-gateway","allow-check-gateway-health","allow-build-gateway-image","allow-pull-gateway-image","allow-set-gateway-api-key","allow-clear-gateway-api-key","allow-get-gateway-auth-token","allow-regenerate-gateway-auth-token","allow-list-claude-sessions","allow-list-container-capabilities","allow-list-scheduled-tasks","allow-add-scheduled-task","allow-update-scheduled-task","allow-get-scheduled-task-log","allow-set-scheduled-task-enabled","allow-run-scheduled-task-now","allow-remove-scheduled-task","allow-get-scheduler-notifications","allow-clear-scheduler-notifications","allow-list-marketplace-snapshots","allow-refresh-marketplaces","allow-add-marketplace","allow-update-marketplace","allow-remove-marketplace","allow-install-marketplace-item","allow-uninstall-marketplace-item","allow-set-global-item-disabled","allow-forget-marketplace-installs","allow-list-marketplace-updates","allow-marketplace-item-diff","allow-update-marketplace-item","allow-apply-marketplace-now","allow-get-marketplace-sync-report","allow-add-marketplace-token-account","allow-add-marketplace-gh-host-account","allow-start-marketplace-gh-container-login","allow-cancel-marketplace-gh-login","allow-test-marketplace-account","allow-remove-marketplace-account","allow-marketplace-gh-host-available"]},"file-viewer":{"identifier":"file-viewer","description":"The terminal file viewer windows (`file-viewer-`, opened by `open_file_viewer` on the app's own `viewer.html`). Same rules as `default.json`, including the layout checks: this file itself must stay a top-level `capabilities/*.json` with no `webviews` or `remote` key, or `build.rs` refuses the build rather than grant something the census cannot see. The five bare `allow-viewer-*` grants are the only app commands a viewer window can invoke: `build.rs` declares the AppManifest that makes tauri enforce that, and refuses any other bare grant in this file. The label gate inside `commands/file_viewer_commands.rs` is still what stops window A acting on window B's registry entry, because the ACL only decides which window may call. The rest of this file is the plugin-command surface a compromised viewer webview could reach, and it is the smallest one that lets the window work. `core:event:allow-listen`/`allow-unlisten` are for `file-viewer-goto` (Rust → this window; the viewer subscribes through `getCurrentWindow().listen`, because a bare `listen()` in *any* window receives an `emit_to`). `core:window:allow-destroy` is not optional: `getCurrentWindow().onCloseRequested` in @tauri-apps/api 2.11 makes Rust `prevent_close()` whenever a JS listener exists and then calls `destroy()` itself, so without this grant the window's X button does nothing once the unsaved-changes guard is installed. `allow-close` is deliberately absent — nothing calls it, and `destroy` is the only exit. No `set-title`/`set-focus`/`unminimize`: those are done from Rust when a second click targets an already-open file. `core:webview:allow-internal-toggle-devtools` is the same dev-only convenience `default.json` carries.","local":true,"windows":["file-viewer-*"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:window:allow-destroy","core:webview:allow-internal-toggle-devtools","allow-viewer-get-state","allow-viewer-choose-file","allow-viewer-read-file","allow-viewer-poll-file","allow-viewer-write-file"]}} \ No newline at end of file diff --git a/app/src-tauri/gen/schemas/desktop-schema.json b/app/src-tauri/gen/schemas/desktop-schema.json index 91d2ff6..6dbe82f 100644 --- a/app/src-tauri/gen/schemas/desktop-schema.json +++ b/app/src-tauri/gen/schemas/desktop-schema.json @@ -938,6 +938,12 @@ "const": "allow-rebuild-project-container", "markdownDescription": "Enables the rebuild_project_container command without any pre-configured scope." }, + { + "description": "Enables the reconcile_aws_projects command without any pre-configured scope.", + "type": "string", + "const": "allow-reconcile-aws-projects", + "markdownDescription": "Enables the reconcile_aws_projects command without any pre-configured scope." + }, { "description": "Enables the reconcile_project_statuses command without any pre-configured scope.", "type": "string", @@ -1886,6 +1892,12 @@ "const": "deny-rebuild-project-container", "markdownDescription": "Denies the rebuild_project_container command without any pre-configured scope." }, + { + "description": "Denies the reconcile_aws_projects command without any pre-configured scope.", + "type": "string", + "const": "deny-reconcile-aws-projects", + "markdownDescription": "Denies the reconcile_aws_projects command without any pre-configured scope." + }, { "description": "Denies the reconcile_project_statuses command without any pre-configured scope.", "type": "string", diff --git a/app/src-tauri/gen/schemas/linux-schema.json b/app/src-tauri/gen/schemas/linux-schema.json index 91d2ff6..6dbe82f 100644 --- a/app/src-tauri/gen/schemas/linux-schema.json +++ b/app/src-tauri/gen/schemas/linux-schema.json @@ -938,6 +938,12 @@ "const": "allow-rebuild-project-container", "markdownDescription": "Enables the rebuild_project_container command without any pre-configured scope." }, + { + "description": "Enables the reconcile_aws_projects command without any pre-configured scope.", + "type": "string", + "const": "allow-reconcile-aws-projects", + "markdownDescription": "Enables the reconcile_aws_projects command without any pre-configured scope." + }, { "description": "Enables the reconcile_project_statuses command without any pre-configured scope.", "type": "string", @@ -1886,6 +1892,12 @@ "const": "deny-rebuild-project-container", "markdownDescription": "Denies the rebuild_project_container command without any pre-configured scope." }, + { + "description": "Denies the reconcile_aws_projects command without any pre-configured scope.", + "type": "string", + "const": "deny-reconcile-aws-projects", + "markdownDescription": "Denies the reconcile_aws_projects command without any pre-configured scope." + }, { "description": "Denies the reconcile_project_statuses command without any pre-configured scope.", "type": "string", diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index 914b03c..4c826a7 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -1847,25 +1847,9 @@ pub async fn reconcile_project_statuses( continue; } - // AWS: ECS is the authority. One DescribeTasks per project, bounded - // so a slow or unreachable AWS can't hold the app's start; a live - // task's tunnel is re-opened in the background. + // AWS projects are reconciled by `reconcile_aws_projects`, which the + // UI runs at startup whether or not Docker answers. if project.is_aws() { - let settings = state.settings_store.get(); - let store = state.projects_store.clone(); - if tokio::time::timeout( - AWS_RECONCILE_TIMEOUT, - crate::host::aws_host::reconcile(project, &settings, store), - ) - .await - .is_err() - { - log::warn!( - "Project {}: AWS did not answer within {}s; status left as is", - project.id, - AWS_RECONCILE_TIMEOUT.as_secs() - ); - } continue; } @@ -1911,6 +1895,59 @@ pub async fn reconcile_project_statuses( } +/// How many AWS projects reconcile at once. +const AWS_RECONCILE_CONCURRENCY: usize = 4; + +/// Reconcile every AWS project against ECS (the authority), independently of +/// Docker: an app started without Docker still learns which tasks run and +/// re-opens their tunnels. One bounded DescribeTasks per project, a few at a +/// time; a live task's tunnel is re-opened in the background. +#[tauri::command] +pub async fn reconcile_aws_projects(state: State<'_, AppState>) -> Result, String> { + use futures_util::StreamExt; + + let projects: Vec = state.projects_store.list().into_iter().filter(|p| p.is_aws()).collect(); + // A target id left from the last run is a loopback URL whose port a new + // tunnel (any project's) may now own; until a re-attach registers, the + // project gets an id no tunnel can have (m9). + for p in &projects { + if let Some(id) = crate::host::aws_host::unattached_container_id(p) { + let _ = state.projects_store.set_container_id(&p.id, Some(id)); + } + } + let settings = state.settings_store.get(); + let store = state.projects_store.clone(); + futures_util::stream::iter(projects.into_iter().filter(|p| { + matches!( + p.status, + ProjectStatus::Running | ProjectStatus::Error | ProjectStatus::Starting | ProjectStatus::Stopping + ) && crate::project_lock::held(&p.id).is_none() + })) + .for_each_concurrent(AWS_RECONCILE_CONCURRENCY, |project| { + let settings = settings.clone(); + let store = store.clone(); + async move { + // The store's current copy: the id above may have changed. + let project = store.get(&project.id).unwrap_or(project); + if tokio::time::timeout( + AWS_RECONCILE_TIMEOUT, + crate::host::aws_host::reconcile(&project, &settings, store), + ) + .await + .is_err() + { + log::warn!( + "Project {}: AWS did not answer within {}s; status left as is", + project.id, + AWS_RECONCILE_TIMEOUT.as_secs() + ); + } + } + }) + .await; + Ok(state.projects_store.list()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/app/src-tauri/src/host/aws_host.rs b/app/src-tauri/src/host/aws_host.rs index 5cb827f..6726468 100644 --- a/app/src-tauri/src/host/aws_host.rs +++ b/app/src-tauri/src/host/aws_host.rs @@ -888,19 +888,48 @@ pub async fn reconcile_task( let Some(arn) = task_arn_or_find(api, task_arn, find).await? else { return Ok(Reconciled::Gone); }; - let Some(cluster) = cluster_of_task(&arn) else { - return Ok(Reconciled::Gone); - }; - let found = api.describe(cluster, std::slice::from_ref(&arn)).await.map_err(s)?; - Ok(match found.into_iter().find(|t| t.arn == arn) { - Some(t) - if t.desired_status == "RUNNING" - && matches!(t.last_status.as_str(), "PROVISIONING" | "PENDING" | "ACTIVATING" | "RUNNING") => - { - Reconciled::Live(Box::new(t)) + if let Some(cluster) = cluster_of_task(&arn) { + let found = api.describe(cluster, std::slice::from_ref(&arn)).await.map_err(s)?; + if let Some(t) = found.into_iter().find(|t| t.arn == arn).filter(is_live) { + return Ok(Reconciled::Live(Box::new(t))); } - _ => Reconciled::Gone, - }) + } + // The stored task is gone or stopping. Another start (another machine, + // or a crash before the store write) may have left the project a newer + // one: look before calling it Gone (m3). + if task_arn.is_some() { + if let Some(t) = + find_project_task(api, &find.cluster, &find.family, &find.owner_user_id, &find.project_id) + .await + .map_err(s)? + .filter(|t| t.arn != arn && is_live(t)) + { + return Ok(Reconciled::Live(Box::new(t))); + } + } + Ok(Reconciled::Gone) +} + +fn is_live(t: &TaskInfo) -> bool { + t.desired_status == "RUNNING" + && matches!(t.last_status.as_str(), "PROVISIONING" | "PENDING" | "ACTIVATING" | "RUNNING") +} + +/// The target id an AWS project gets while it has no registered tunnel, or +/// `None` when its stored one is fine (a registered tunnel's, already this +/// placeholder, or none). Never a loopback URL, so a port a new tunnel +/// reuses can't make the death watch match the wrong project (m9). A valid +/// agent URL, so the watch can still report the project by it. +pub fn unattached_container_id(p: &Project) -> Option { + if !p.is_aws() { + return None; + } + let stored = p.container_id.as_deref()?; + if tunnels::get_endpoint(&p.id).is_some_and(|ep| ep.base_url.as_str() == stored) { + return None; + } + let placeholder = format!("http://unattached.invalid/{}/", p.id); + (stored != placeholder).then_some(placeholder) } /// Reconcile one AWS project the store has as Running (or Starting/ diff --git a/app/src-tauri/src/host/aws_host/tests.rs b/app/src-tauri/src/host/aws_host/tests.rs index ecfeb00..b2f350e 100644 --- a/app/src-tauri/src/host/aws_host/tests.rs +++ b/app/src-tauri/src/host/aws_host/tests.rs @@ -727,3 +727,51 @@ fn the_log_task_is_the_stored_one_else_the_last_one_a_start_saw() { p.aws.as_mut().unwrap().task_arn = Some("arn:current".into()); assert_eq!(log_task_arn(&p).as_deref(), Some("arn:current")); } + +#[tokio::test] +async fn reconcile_falls_back_to_a_newer_live_task_when_the_stored_one_stopped() { + // m3: another machine restarted the project; the stored ARN is history. + let mut p = aws_project(StorageMode::Ephemeral); + p.status = ProjectStatus::Running; + p.aws.as_mut().unwrap().task_arn = Some(task_arn("old")); + let newer = task(&p, "newer", "RUNNING", true); + let fake = Fake { listed: Mutex::new(vec![vec![newer.arn.clone()]]), ..Default::default() } + .with_task(task(&p, "old", "STOPPED", false)) + .with_task(newer); + let (_d, store) = store_with(&p); + assert!(matches!( + reconcile_project(&fake, &store, &p, &find(&p)).await.unwrap(), + Reconciled::Live(t) if t.arn == task_arn("newer") + )); + let after = store.get(&p.id).unwrap(); + assert_eq!(after.status, ProjectStatus::Running); + assert_eq!(after.aws.unwrap().task_arn, Some(task_arn("newer"))); + + // ECS forgot the stored one and nothing else runs: Gone, after looking. + let fake = Fake::default(); + let (_d, store) = store_with(&p); + assert_eq!(reconcile_project(&fake, &store, &p, &find(&p)).await.unwrap(), Reconciled::Gone); + assert!(fake.called("list"), "it looked for another task first"); +} + +#[tokio::test] +async fn an_unattached_aws_project_gets_a_target_id_no_tunnel_can_have() { + // m9: the last run's loopback port may belong to another project's + // tunnel now. + let mut p = aws_project(StorageMode::Ephemeral); + p.status = ProjectStatus::Running; + p.container_id = Some("http://127.0.0.1:61400/".into()); + p.aws.as_mut().unwrap().task_arn = Some(task_arn("u1")); + let id = unattached_container_id(&p).expect("replaced"); + assert_eq!(id, format!("http://unattached.invalid/{}/", p.id)); + p.container_id = Some(id.clone()); + assert_eq!(unattached_container_id(&p), None, "idempotent"); + // The watch still reports it, by that id, and a registered tunnel on the + // old port can't be confused with it. + let w = watched_task(&p).expect("still watched"); + assert_eq!(w.target.id(), id); + p.container_id = None; + assert_eq!(unattached_container_id(&p), None, "nothing stored, nothing to replace"); + let local = Project::new("l".into(), Vec::new()); + assert_eq!(unattached_container_id(&local), None); +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 4ee3d56..452e96a 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -556,6 +556,7 @@ pub fn run() { commands::project_commands::remove_aws_resources, commands::project_commands::rebuild_project_container, commands::project_commands::reconcile_project_statuses, + commands::project_commands::reconcile_aws_projects, // Notes commands::notes_commands::list_notes, commands::notes_commands::save_note, diff --git a/app/src/App.tsx b/app/src/App.tsx index 8454652..2d0aea7 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -25,7 +25,7 @@ import { useMarketplaceSyncToasts } from "./hooks/useMarketplace"; import { useProjectStatusEvents } from "./hooks/useProjectStatusEvents"; import MarketplaceView from "./components/marketplace/MarketplaceView"; import { useAppState, isHomeTab, tabKeyId, homeTabKey, MARKETPLACE_TAB_KEY } from "./store/appState"; -import { reconcileProjectStatuses } from "./lib/tauri-commands"; +import { reconcileAwsProjects, reconcileProjectStatuses } from "./lib/tauri-commands"; export default function App() { const { checkDocker, checkImage, startDockerPolling } = useDocker(); @@ -81,6 +81,11 @@ export default function App() { // Initialize on mount useEffect(() => { loadSettings(); + // AWS projects don't need Docker: ECS says which tasks run, and their + // tunnels re-open in the background. + reconcileAwsProjects() + .then(setProjects) + .catch((e) => console.error("Failed to reconcile AWS projects:", e)); let stopPolling: (() => void) | undefined; checkDocker().then((available) => { if (available) { diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 2869886..273fe3e 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -43,6 +43,8 @@ export const rebuildProjectContainer = (projectId: string) => invoke("rebuild_project_container", { projectId }); export const reconcileProjectStatuses = () => invoke("reconcile_project_statuses"); +/** AWS projects against ECS; run at startup whether or not Docker answers. */ +export const reconcileAwsProjects = () => invoke("reconcile_aws_projects"); // Notes — per-project, host-side, readable with the container stopped. export const listNotes = (projectId: string) => -- 2.52.0 From 7f113f58acf84d5a9a08e92829e89a64ab2ce0cc Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 12:34:03 -0800 Subject: [PATCH 31/43] fix(aws): refuse credentials in repo URLs and HEAD as a branch - validate_repo_url rejects https URLs with userinfo (user:pass@ or token@), pointing at the project's Git HTTPS token setting (m4). - validate_branch rejects HEAD (m5). - The FIXED_ENVIRONMENT doc comment sits on FIXED_ENVIRONMENT again (m6). Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/workspace.rs | 2 +- app/src-tauri/src/models/project.rs | 33 ++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/app/src-tauri/src/aws/workspace.rs b/app/src-tauri/src/aws/workspace.rs index 0261175..482b7ce 100644 --- a/app/src-tauri/src/aws/workspace.rs +++ b/app/src-tauri/src/aws/workspace.rs @@ -74,10 +74,10 @@ const KEY_REPOS: &str = "TRIPLEC_REPOS"; const KEY_WIP_PUSH: &str = "TRIPLEC_WIP_PUSH"; const KEY_IDLE: &str = "TRIPLEC_IDLE_STOP_MINUTES"; const KEY_STORAGE: &str = "TRIPLEC_STORAGE"; -/// The task definition's whole `environment` (R2): constants, no project data. /// Seconds ECS waits after SIGTERM before SIGKILL (Fargate allows 2–120). pub const STOP_TIMEOUT_SECS: i32 = 120; +/// The task definition's whole `environment` (R2): constants, no project data. const FIXED_ENVIRONMENT: [(&str, &str); 2] = [("TRIPLEC_HOST", "aws"), ("COLORTERM", "truecolor")]; fn other(message: impl Into) -> AwsError { diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index a1f015a..032dcd4 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -585,6 +585,15 @@ fn validate_repo_url(url: &str) -> Result<(), String> { return bad("must not start with '-'"); } let ok = if let Some(rest) = url.strip_prefix("https://") { + // Credentials in the URL would sit in the project secret and in + // `.git/config` in the clear, and leak into every clone's remote. + let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); + if authority.contains('@') { + return bad( + "must not carry credentials (user:password@ or token@); put the token in the \ + project's Git HTTPS token setting (Access) instead", + ); + } !rest.is_empty() } else if let Some(rest) = url.strip_prefix("ssh://") { !rest.is_empty() @@ -610,6 +619,11 @@ fn validate_branch(branch: &str) -> Result<(), String> { if branch.starts_with('-') || branch.starts_with('/') || branch.ends_with('/') { return bad("must not start with '-' or start/end with '/'"); } + // `HEAD` passes check-ref-format but `git clone --branch HEAD` fails + // (and a branch named HEAD is refused by `git branch`). + if branch == "HEAD" { + return bad("HEAD is not a branch; leave the branch empty for the default branch"); + } if branch == "@" || branch.ends_with('.') || branch.contains("..") || branch.contains("@{") || branch.contains("//") { return bad("is not a valid branch name"); } @@ -1405,13 +1419,30 @@ mod tests { } } + #[test] + fn an_https_url_with_credentials_is_refused_pointing_at_the_token_setting() { + let check = |u: &str| validate_aws_workspace(&with_repos(vec![repo(u, None, "x")])); + for bad in [ + "https://user:pass@github.com/o/r.git", + "https://ghp_abc@github.com/o/r.git", + "https://oauth2:tok@gitlab.com/o/r", + ] { + let e = check(bad).unwrap_err(); + assert!(e.contains("Git HTTPS token"), "{e}"); + } + // An @ in the path is not userinfo; ssh and scp forms keep their user. + for good in ["https://h/o/r@v2.git", "ssh://git@h/o/r.git", "git@github.com:o/r.git"] { + assert!(check(good).is_ok(), "{good}"); + } + } + #[test] fn branches_follow_check_ref_format() { let ok = |b: Option<&str>| validate_aws_workspace(&with_repos(vec![repo("https://h/r", b, "x")])).is_ok(); for good in [None, Some("main"), Some("feat/aws-1"), Some("v1.2.3"), Some("a@b")] { assert!(ok(good), "{good:?}"); } - for bad in ["", "-x", "a..b", "a b", "a~1", "a^", "a:b", "a?", "a*", "a[", "a\\b", "a.lock", "x/.hidden", "a/", "/a", "a//b", "a@{1}", "@", "a."] { + for bad in ["", "-x", "a..b", "a b", "a~1", "a^", "a:b", "a?", "a*", "a[", "a\\b", "a.lock", "x/.hidden", "a/", "/a", "a//b", "a@{1}", "@", "a.", "HEAD"] { assert!(!ok(Some(bad)), "{bad:?}"); } } -- 2.52.0 From 24a82bd4c8866d74c9192776fd566be5070b0563 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 12:34:34 -0800 Subject: [PATCH 32/43] fix(aws): warn that Bedrock profile auth can't work in an AWS workspace The task has no ~/.aws from this machine and SSO can't refresh there; the note points at static keys or a bearer token (m10). The auth bridge stays enabled on AWS (its poll is now a background request). Co-Authored-By: Claude Opus 5.5 --- .../home/config/ModelSection.test.tsx | 32 +++++++++++++++++++ .../projects/home/config/ModelSection.tsx | 12 +++++++ 2 files changed, 44 insertions(+) diff --git a/app/src/components/projects/home/config/ModelSection.test.tsx b/app/src/components/projects/home/config/ModelSection.test.tsx index f61c236..630f81f 100644 --- a/app/src/components/projects/home/config/ModelSection.test.tsx +++ b/app/src/components/projects/home/config/ModelSection.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import ModelSection, { + BEDROCK_PROFILE_ON_AWS, DEFAULT_LLAMACPP_CONFIG, DEFAULT_OLLAMA_CONFIG, } from "./ModelSection"; @@ -215,3 +216,34 @@ describe("ModelSection — background (haiku) model override", () => { expect(screen.getByText(/background work/i)).toBeInTheDocument(); }); }); + +describe("ModelSection — Bedrock on AWS", () => { + const bedrock = (auth_method: "profile" | "static_credentials" | "bearer_token") => ({ + backend: "bedrock" as const, + bedrock_config: { + auth_method, + aws_region: "us-east-1", + aws_access_key_id: null, + aws_secret_access_key: null, + aws_session_token: null, + aws_profile: "dev", + aws_bearer_token: null, + model_id: null, + service_tier: null, + disable_prompt_caching: false, + } as unknown as Project["bedrock_config"], + }); + + it("warns that a named profile can't work in an AWS workspace", () => { + renderSection({ ...bedrock("profile"), host_id: "c1" } as Partial); + expect(screen.getByText(BEDROCK_PROFILE_ON_AWS)).toBeInTheDocument(); + }); + + it("says nothing for a local project or another auth method", () => { + const { unmount } = renderSection(bedrock("profile")); + expect(screen.queryByText(BEDROCK_PROFILE_ON_AWS)).toBeNull(); + unmount(); + renderSection({ ...bedrock("static_credentials"), host_id: "c1" } as Partial); + expect(screen.queryByText(BEDROCK_PROFILE_ON_AWS)).toBeNull(); + }); +}); diff --git a/app/src/components/projects/home/config/ModelSection.tsx b/app/src/components/projects/home/config/ModelSection.tsx index 04ebb16..832fdba 100644 --- a/app/src/components/projects/home/config/ModelSection.tsx +++ b/app/src/components/projects/home/config/ModelSection.tsx @@ -24,6 +24,9 @@ import { useAppState } from "../../../../store/appState"; const LOCAL_MODEL_ON_AWS = "This project runs on AWS: the URL must be reachable from the AWS task, so host.docker.internal, localhost and this machine's LAN addresses will not work."; +export const BEDROCK_PROFILE_ON_AWS = + "This project runs on AWS: a named profile is read from ~/.aws on this machine, which the AWS workspace does not have, and an SSO login cannot be refreshed there. Use static keys or a bearer token instead."; + /** Bedrock fields held in the OS keychain, never serialized back to us. */ const BEDROCK_SECRET_KEYS = [ "aws_access_key_id", @@ -359,6 +362,15 @@ export default function ModelSection({ project, save, disabled }: Props) { )} + {bedrock.auth_method === "profile" && aws && ( +

+ {BEDROCK_PROFILE_ON_AWS} +

+ )} + {bedrock.auth_method === "profile" && ( Date: Sun, 15 Nov 2026 12:35:50 -0800 Subject: [PATCH 33/43] fix(aws): the e2e script uses only the named profile and always cleans up It unsets every caller AWS_* variable (keeping only where the profile's files are, now documented), sets AWS_PROFILE explicitly, attempts the ECR delete in the EXIT trap unconditionally (RepositoryNotFound tolerated, bounded at 120 s), and exits 130/143 on INT/TERM (M21). Co-Authored-By: Claude Opus 5.5 --- aws/README.md | 10 ++++++--- scripts/aws-e2e-workspace.sh | 43 +++++++++++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/aws/README.md b/aws/README.md index 44b9f3f..a847478 100644 --- a/aws/README.md +++ b/aws/README.md @@ -563,11 +563,15 @@ Still open: TRIPLEC_AWS_E2E_PROFILE= TRIPLEC_AWS_E2E_REGION=us-west-2 scripts/aws-e2e-workspace.sh ``` -The script builds this checkout's `container/` (`--platform linux/amd64`), creates a throwaway +The script first unsets every `AWS_*` variable of the caller, so only the named profile's +credentials are used; `TRIPLEC_AWS_E2E_CREDENTIALS_FILE` / `TRIPLEC_AWS_E2E_CONFIG_FILE` (default: +the caller's `AWS_SHARED_CREDENTIALS_FILE` / `AWS_CONFIG_FILE`, else `~/.aws`) say where that +profile lives. It builds this checkout's `container/` (`--platform linux/amd64`), creates a throwaway private ECR repository `triple-c-e2e-sandbox`, pushes, runs the test (`aws::live_tests::environment::a_fargate_workspace_round_trips`, with `TRIPLEC_E2E_IMAGE` and -`TRIPLEC_E2E_ECR_REPO`) and deletes the repository in an exit trap; the test's guard deletes it -as well. The test body runs under a 45-minute timeout, every exec under its own. +`TRIPLEC_E2E_ECR_REPO`) and deletes the repository in an exit trap — always attempted, a +`RepositoryNotFoundException` is fine; Ctrl-C exits 130 and a TERM 143 after the same cleanup. The +test's guard deletes it as well. The test body runs under a 45-minute timeout, every exec under its own. **Test only:** the onboarding grants the execution role no ECR permissions (the boundary allows pulls), so the test puts an inline `triple-c-e2e-ecr-pull` policy on it and removes it before the onboarding is destroyed. The test deploys a `public` environment as the environment admin, onboards diff --git a/scripts/aws-e2e-workspace.sh b/scripts/aws-e2e-workspace.sh index 2376fc4..1d314f7 100755 --- a/scripts/aws-e2e-workspace.sh +++ b/scripts/aws-e2e-workspace.sh @@ -11,28 +11,59 @@ # TRIPLEC_AWS_E2E_PROFILE= TRIPLEC_AWS_E2E_REGION=us-west-2 \ # scripts/aws-e2e-workspace.sh # +# Credentials come ONLY from that profile. Every other AWS_* variable of the +# caller (AWS_PROFILE, AWS_ACCESS_KEY_ID, AWS_SESSION_TOKEN, AWS_REGION, +# AWS_ENDPOINT_URL*, ...) is unset first, so ambient credentials can never +# end up running the test. Where the profile is read from: +# +# TRIPLEC_AWS_E2E_CREDENTIALS_FILE the shared credentials file (default: +# the caller's AWS_SHARED_CREDENTIALS_FILE, +# else ~/.aws/credentials) +# TRIPLEC_AWS_E2E_CONFIG_FILE the config file (default: the caller's +# AWS_CONFIG_FILE, else ~/.aws/config) +# +# These two are re-exported as AWS_SHARED_CREDENTIALS_FILE / AWS_CONFIG_FILE, +# with AWS_PROFILE set to the profile; nothing else AWS_* is passed on. +# # Costs a few cents (about 40 Fargate task-minutes at 1 vCPU / 2 GB). set -euo pipefail : "${TRIPLEC_AWS_E2E_PROFILE:?set TRIPLEC_AWS_E2E_PROFILE (see aws/README.md, Live verification)}" : "${TRIPLEC_AWS_E2E_REGION:?set TRIPLEC_AWS_E2E_REGION}" +credentials_file="${TRIPLEC_AWS_E2E_CREDENTIALS_FILE:-${AWS_SHARED_CREDENTIALS_FILE:-}}" +config_file="${TRIPLEC_AWS_E2E_CONFIG_FILE:-${AWS_CONFIG_FILE:-}}" +while IFS= read -r v; do + unset "$v" +done < <(compgen -e | grep '^AWS_' || true) +[ -z "$credentials_file" ] || export AWS_SHARED_CREDENTIALS_FILE="$credentials_file" +[ -z "$config_file" ] || export AWS_CONFIG_FILE="$config_file" +export AWS_PROFILE="$TRIPLEC_AWS_E2E_PROFILE" AWS_PAGER="" + root=$(cd "$(dirname "$0")/.." && pwd) repo=triple-c-e2e-sandbox tag="e2e-$(date -u +%Y%m%d%H%M%S)" aws_() { aws --profile "$TRIPLEC_AWS_E2E_PROFILE" --region "$TRIPLEC_AWS_E2E_REGION" "$@"; } docker_config=$(mktemp -d) +# The repository is deleted whatever happened before, even if this script +# never got as far as creating it: a "not found" is the only answer that is +# not worth a warning. cleanup() { - status=$? - if aws_ ecr describe-repositories --repository-names "$repo" >/dev/null 2>&1; then - aws_ ecr delete-repository --repository-name "$repo" --force >/dev/null \ - && echo "aws-e2e-workspace: deleted ECR repository $repo" \ - || echo "aws-e2e-workspace: WARNING: could not delete ECR repository $repo" >&2 + local status=$? out + trap - EXIT INT TERM + if out=$(timeout 120 aws ecr delete-repository --profile "$TRIPLEC_AWS_E2E_PROFILE" \ + --region "$TRIPLEC_AWS_E2E_REGION" --repository-name "$repo" --force 2>&1 >/dev/null); then + echo "aws-e2e-workspace: deleted ECR repository $repo" + elif [[ "$out" != *RepositoryNotFoundException* ]]; then + echo "aws-e2e-workspace: WARNING: could not delete ECR repository $repo: $out" >&2 fi rm -rf "$docker_config" exit "$status" } -trap cleanup EXIT INT TERM +trap cleanup EXIT +# Ctrl-C / kill: the usual shell statuses, and the EXIT trap still cleans up. +trap 'exit 130' INT +trap 'exit 143' TERM account=$(aws_ sts get-caller-identity --query Account --output text) registry="$account.dkr.ecr.$TRIPLEC_AWS_E2E_REGION.amazonaws.com" -- 2.52.0 From 8a3c5529582c8b56b8a5ee491aafc84e61a96c71 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 12:36:23 -0800 Subject: [PATCH 34/43] test(aws): the live idle stop holds a terminal open and polls in the background The idle wait now keeps an attach open (silent) and runs the auth bridge's background exec every 2 s; the task must still stop itself and ECS must report it as an idle stop (exit 75 -> StopKind::Idle). Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/live_tests.rs | 45 +++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/app/src-tauri/src/aws/live_tests.rs b/app/src-tauri/src/aws/live_tests.rs index b6de3f7..cb29d5b 100644 --- a/app/src-tauri/src/aws/live_tests.rs +++ b/app/src-tauri/src/aws/live_tests.rs @@ -2326,8 +2326,29 @@ mod environment { let (check, _) = sh(&target2, "root", "TRIPLEC_IDLE_STOP_MINUTES=5 /usr/local/bin/triple-c-idle-watch --check; echo idle-check=$?").await; eprintln!("RESULT idle --check right after use: {}", check.trim()); - // Leave it alone: close the tunnel and watch ECS. - tunnels::close(&id).await; + // Leave it alone, the way a user does (R11): a terminal tab stays + // open but silent (an attach held through the tunnel), and the + // auth bridge's background poll keeps running every 2 s. Neither + // may keep the workspace up. + let held = target2 + .attach(crate::host::ExecSpec::new(vec!["cat".into()]).tty(true)) + .await + .expect("hold a terminal open"); + let poller = { + let t = target2.clone(); + tokio::spawn(async move { + loop { + let _ = t + .exec_oneshot_background( + "claude", + vec!["/usr/bin/cat".into(), "/proc/net/tcp".into()], + crate::host::PROC_NET_OUTPUT_LIMIT, + ) + .await; + tokio::time::sleep(Duration::from_secs(2)).await; + } + }) + }; let idle_from = std::time::Instant::now(); let stopped2 = poll(Duration::from_secs(15 * 60), Duration::from_secs(20), || async { let d = api.describe(&env.cluster_arn, std::slice::from_ref(&task2)).await.ok()?; @@ -2335,18 +2356,30 @@ mod environment { (t.desired_status == "STOPPED" || t.last_status == "STOPPED").then_some(t) }) .await - .expect("the idle task did not stop by itself within 15 minutes"); + .expect("the idle task did not stop by itself within 15 minutes (open tab + background poll)"); + poller.abort(); + drop(held); + tunnels::close(&id).await; eprintln!( "RESULT idle stop: desired STOPPED {:?} after the last use; code {:?}, reason {:?}", idle_from.elapsed(), stopped2.stop_code, stopped2.stopped_reason ); - poll(Duration::from_secs(240), Duration::from_secs(10), || async { + let final2 = poll(Duration::from_secs(240), Duration::from_secs(10), || async { let d = api.describe(&env.cluster_arn, std::slice::from_ref(&task2)).await.ok()?; - (d.first()?.last_status == "STOPPED").then_some(()) + let t = d.into_iter().next()?; + (t.last_status == "STOPPED").then_some(t) }) - .await; + .await + .expect("task 2 never reached STOPPED"); + eprintln!( + "RESULT idle stop as ECS reports it: code {:?}, exit {:?}, kind {:?}", + final2.stop_code, + final2.exit_code, + crate::aws::tasks::classify_stop(&final2) + ); + assert_eq!(crate::aws::tasks::classify_stop(&final2), crate::aws::tasks::StopKind::Idle); tokio::time::sleep(Duration::from_secs(15)).await; let log2 = api.log_tail(&env.log_group, &task_log_stream(&stream_prefix, &task2), 60).await.unwrap(); let tail2: Vec<&String> = log2.iter().filter(|l| l.contains("stopping") || l.contains("idle")).collect(); -- 2.52.0 From 3c1c27676948522097000b1a7302d7cc44678a3d Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 12:37:58 -0800 Subject: [PATCH 35/43] docs(aws): note the idle-stop changes are not yet re-verified live Co-Authored-By: Claude Opus 5.5 --- aws/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws/README.md b/aws/README.md index a847478..975cab4 100644 --- a/aws/README.md +++ b/aws/README.md @@ -595,7 +595,7 @@ and the secret and removes the ECR policy; then the fixture destroys the modules | Restart | ✓ new task, same revision; the `~/.claude` marker survived | | Repo clone | ✓ the background clone finished (`/run/triple-c-clone/repos`: `Hello-World cloned`) | | Stop guard and WIP push | ✓ `triple-c-wip-push --dry-run` reports the dirty repo; after StopTask the log shows `entrypoint: stopping (sigterm) — pushing work in progress...` and the push failing for lack of GitHub credentials. No push with real credentials was tried | -| Idle stop (5 min) | ✓ the task stopped itself 365 s after the last use (`triple-c-idle-watch: idle for 5 minutes`, `entrypoint: stopping (idle)`); ECS stop code `EssentialContainerExited` | +| Idle stop (5 min) | ✓ the task stopped itself 365 s after the last use (`triple-c-idle-watch: idle for 5 minutes`, `entrypoint: stopping (idle)`); ECS stop code `EssentialContainerExited`. Since the final fixes (R11) the idle stop exits 75 so the app shows it as an idle stop, open-but-silent terminals and the auth bridge's poll no longer count, and unsaved work blocks or is pushed first; the extended live test (attach held open, background poll) is written but **not yet re-run** (the test credentials had expired) | | `~/.claude.json` after Claude Code runs | ✓ still a link (Claude Code started without a login: "Not logged in") | | Bubblewrap (P9) | ✗ `bwrap --unshare-user` fails: "No permissions to create new namespace" (kernel 6.1, `max_user_namespaces` 16384, no `CAP_SYS_ADMIN` on Fargate). The sandbox stays off on AWS | | A revision per configuration | turning idle stop on adds a secret key, so the next start registers a new revision (by design) | -- 2.52.0 From 276f5378a4118024f831d3c480a448ce1cc894e1 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 12:54:34 -0800 Subject: [PATCH 36/43] fix(aws): give the permission check room for throttled retries and say it can be slow The connection-wide 30 s operation limit counts retry backoff, so a throttled SimulatePrincipalPolicy got about six of its ten adaptive attempts. The IAM client used by the check now has a 120 s operation limit, and Settings tells the user a check can take a few minutes. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/permissions.rs | 16 ++++++++++++++++ app/src-tauri/src/aws/permissions/tests.rs | 10 ++++++++++ .../components/settings/HostsSettings.test.tsx | 17 ++++++++++++++++- app/src/components/settings/HostsSettings.tsx | 13 ++++++++++++- 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/app/src-tauri/src/aws/permissions.rs b/app/src-tauri/src/aws/permissions.rs index 6f6c314..cf51d1b 100644 --- a/app/src-tauri/src/aws/permissions.rs +++ b/app/src-tauri/src/aws/permissions.rs @@ -135,8 +135,13 @@ impl SdkPermissionApi { // hundred simulations) failed a third of its rows with "Rate // exceeded" under standard retries (verified live). Adaptive retry // backs off client-side once throttled, with room to retry. + // + // The connection's 30 s operation limit counts the retries' backoff + // too, which would cut those ten attempts to about six; the check + // gets its own, longer limit, and the UI says it can take a while. let iam = aws_sdk_iam::config::Builder::from(cfg) .retry_config(aws_config::retry::RetryConfig::adaptive().with_max_attempts(10)) + .timeout_config(iam_check_timeouts()) .build(); SdkPermissionApi { iam: aws_sdk_iam::Client::from_conf(iam), @@ -147,6 +152,17 @@ impl SdkPermissionApi { } } +/// The whole of one simulation, retries and backoff included. Ten adaptive +/// attempts with backoff capped at 20 s fit in two minutes. +pub const IAM_CHECK_OPERATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); + +fn iam_check_timeouts() -> aws_config::timeout::TimeoutConfig { + aws_config::timeout::TimeoutConfig::builder() + .operation_timeout(IAM_CHECK_OPERATION_TIMEOUT) + .operation_attempt_timeout(super::connection::ATTEMPT_TIMEOUT) + .build() +} + impl PermissionApi for SdkPermissionApi { async fn resolve_principal(&self, principal: PrincipalArn) -> Result { identity::resolve_principal(&self.iam, principal).await diff --git a/app/src-tauri/src/aws/permissions/tests.rs b/app/src-tauri/src/aws/permissions/tests.rs index 8a883da..ca2217e 100644 --- a/app/src-tauri/src/aws/permissions/tests.rs +++ b/app/src-tauri/src/aws/permissions/tests.rs @@ -628,3 +628,13 @@ fn no_probe_is_cloudformation() { } assert_eq!(Probe::for_action("cloudformation:DescribeStacks"), None); } + +#[test] +fn the_iam_check_gets_room_for_its_throttled_retries() { + let t = iam_check_timeouts(); + assert_eq!(t.operation_timeout(), Some(IAM_CHECK_OPERATION_TIMEOUT)); + assert!(IAM_CHECK_OPERATION_TIMEOUT > crate::aws::connection::OPERATION_TIMEOUT); + // Ten adaptive attempts, backoff capped at 20 s, fit inside it. + assert!(IAM_CHECK_OPERATION_TIMEOUT >= std::time::Duration::from_secs(9 * 20 / 2 + 20)); + assert_eq!(t.operation_attempt_timeout(), Some(crate::aws::connection::ATTEMPT_TIMEOUT)); +} diff --git a/app/src/components/settings/HostsSettings.test.tsx b/app/src/components/settings/HostsSettings.test.tsx index 7a1c0fa..f6c98e5 100644 --- a/app/src/components/settings/HostsSettings.test.tsx +++ b/app/src/components/settings/HostsSettings.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import HostsSettings from "./HostsSettings"; +import HostsSettings, { CHECK_IN_PROGRESS } from "./HostsSettings"; import { useAppState } from "../../store/appState"; import type { AppSettings, HostConnection, PermissionReport } from "../../lib/types"; @@ -140,6 +140,21 @@ describe("HostsSettings", () => { expect(screen.getByLabelText("Secret access key")).toHaveValue(""); }); + it("tells the user a permission check can take a few minutes while it runs", async () => { + let finish: (r: PermissionReport) => void = () => {}; + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_aws_profiles") return []; + if (cmd === "check_aws_permissions") return new Promise((res) => (finish = res)); + throw new Error(`unexpected ${cmd}`); + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Check Prod" })); + expect(await screen.findByRole("status")).toHaveTextContent(CHECK_IN_PROGRESS); + finish(report({})); + expect(await screen.findByText("Insufficient")).toBeInTheDocument(); + expect(screen.queryByText(CHECK_IN_PROGRESS)).not.toBeInTheDocument(); + }); + it("check shows Insufficient with the denied actions and every policy file to copy", async () => { const adminFiles = [ { name: "environment-admin.json", json: '{"Statement":"admin"}' }, diff --git a/app/src/components/settings/HostsSettings.tsx b/app/src/components/settings/HostsSettings.tsx index 1a6360d..b1653d9 100644 --- a/app/src/components/settings/HostsSettings.tsx +++ b/app/src/components/settings/HostsSettings.tsx @@ -26,6 +26,10 @@ import SegmentedControl from "../ui/SegmentedControl"; import Field, { inputClass, selectClass } from "../ui/Field"; +/** AWS throttles the permission simulator; a check retries patiently instead of failing. */ +export const CHECK_IN_PROGRESS = + "Checking permissions… AWS limits how fast this can run, so it can take up to a few minutes."; + const OWN_PRINCIPAL_ACTIONS = ["iam:GetRole", "iam:SimulatePrincipalPolicy"]; const SUMMARY_LABEL: Record = { @@ -184,6 +188,7 @@ function ConnectionRow({ host }: { host: HostConnection }) { const [region, setRegion] = useState(host.kind.default_region); const [confirmRemove, setConfirmRemove] = useState(false); const [busy, setBusy] = useState(false); + const [checking, setChecking] = useState(false); const [signingIn, setSigningIn] = useState(false); const [report, setReport] = useState(null); const [failure, setFailure] = useState(null); @@ -201,6 +206,7 @@ function ConnectionRow({ host }: { host: HostConnection }) { const runCheck = async () => { setBusy(true); + setChecking(true); setFailure(null); try { setReport(await checkAwsPermissions(host.id)); @@ -208,6 +214,7 @@ function ConnectionRow({ host }: { host: HostConnection }) { setReport(null); setFailure({ error: toAwsError(e), retry: runCheck }); } finally { + setChecking(false); setBusy(false); } }; @@ -311,7 +318,11 @@ function ConnectionRow({ host }: { host: HostConnection }) { )} - {busy && !editing && !confirmRemove &&

Working…

} + {busy && !editing && !confirmRemove && ( +

+ {checking ? CHECK_IN_PROGRESS : "Working…"} +

+ )} {failure && } {report && } -- 2.52.0 From 170ba1e94d4f9c5f734ef2a4945a0372e9339243 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 13:07:53 -0800 Subject: [PATCH 37/43] fix(container): skip repos still cloning, re-check idleness after the idle push, trap TERM before background work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clone_repos records `\tcloning` as each clone begins; triple-c-wip-push skips a folder whose last state is `cloning` while the clone is not done (dry run reports "cloning": true; RepoState/TS carry it, the stop guard lists it as "still cloning — nothing to push" and never asks because of it). - The idle watcher re-checks quiet() after a successful push and goes back to watching if someone came back during it. - The entrypoint installs its stop trap before the idle watcher and the background clone start, so a TERM in between still runs aws_before_stop. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/commands/stop_guard.rs | 2 +- app/src-tauri/src/docker/aws_mode_tests.rs | 92 ++++++++++++++++++- app/src-tauri/src/models/project.rs | 3 + .../projects/StopGuardModal.test.tsx | 11 +++ .../components/projects/StopGuardModal.tsx | 11 ++- app/src/lib/awsWorkspace.test.ts | 2 + app/src/lib/awsWorkspace.ts | 2 +- app/src/lib/types.ts | 2 + container/entrypoint.sh | 28 ++++-- container/triple-c-idle-watch | 15 ++- container/triple-c-wip-push | 28 +++++- 11 files changed, 178 insertions(+), 18 deletions(-) diff --git a/app/src-tauri/src/commands/stop_guard.rs b/app/src-tauri/src/commands/stop_guard.rs index 05c003d..90c9bd6 100644 --- a/app/src-tauri/src/commands/stop_guard.rs +++ b/app/src-tauri/src/commands/stop_guard.rs @@ -280,7 +280,7 @@ pub(crate) mod tests { {\"folder\":\"web\",\"dirty\":false,\"unpushed\":2,\"branch\":\"HEAD\"}\n"; let repos = parse_dry_run(out).unwrap(); assert_eq!(repos.len(), 2); - assert_eq!(repos[0], RepoState { folder: "api".into(), dirty: true, unpushed: 0, branch: "main".into() }); + assert_eq!(repos[0], RepoState { folder: "api".into(), dirty: true, unpushed: 0, branch: "main".into(), cloning: false }); assert_eq!(repos[1].unpushed, 2); assert!(parse_dry_run("{\"folder\":1}").is_err()); assert!(parse_dry_run("").unwrap().is_empty()); diff --git a/app/src-tauri/src/docker/aws_mode_tests.rs b/app/src-tauri/src/docker/aws_mode_tests.rs index 3fe8964..192f733 100644 --- a/app/src-tauri/src/docker/aws_mode_tests.rs +++ b/app/src-tauri/src/docker/aws_mode_tests.rs @@ -421,7 +421,8 @@ fn clone_repos_clones_skips_existing_and_reports_failures_without_failing() { assert_eq!(fs::read_to_string(ws.join(".dotted/README")).unwrap(), "hello"); assert_eq!( fs::read_to_string(status.join("repos")).unwrap(), - "main-copy\tcloned\ndev-copy\tcloned\nexisting\texists\nbroken\tfailed\n.dotted\tcloned\n" + "main-copy\tcloning\nmain-copy\tcloned\ndev-copy\tcloning\ndev-copy\tcloned\n\ + existing\texists\nbroken\tcloning\nbroken\tfailed\n.dotted\tcloning\n.dotted\tcloned\n" ); } @@ -473,6 +474,11 @@ fn write_script(dir: &Path, name: &str, body: &str) -> std::path::PathBuf { } fn wip_push(ws: &Path, args: &[&str]) -> Output { + let tmp = ws.parent().unwrap(); + wip_push_with_clone_dir(ws, args, &tmp.join("no-clone-status")) +} + +fn wip_push_with_clone_dir(ws: &Path, args: &[&str], clone_dir: &Path) -> Output { let tmp = ws.parent().unwrap(); let script = write_script(tmp, "triple-c-wip-push", WIP_PUSH); // Through bash rather than exec'd: a script written a moment ago can be @@ -481,6 +487,7 @@ fn wip_push(ws: &Path, args: &[&str]) -> Output { .arg(script) .args(args) .env("TRIPLEC_WORKSPACE_ROOT", ws) + .env("TRIPLEC_CLONE_STATUS_DIR", clone_dir) .env("GIT_CONFIG_NOSYSTEM", "1") .env("HOME", tmp) .output() @@ -673,6 +680,44 @@ fn wip_push_uploads_lfs_objects_before_the_no_verify_push() { assert_eq!(fs::read_to_string(&log).unwrap().trim(), format!("push origin {pushed}")); } +#[test] +fn wip_push_skips_a_repo_the_background_clone_is_still_writing() { + let tmp = tempfile::tempdir().unwrap(); + let (ws, repo, remote) = workspace_with_clone(tmp.path()); + // `half` is mid-clone: a checkout in progress reads as a dirty tree. + git(&ws, &["clone", "-q", remote.to_str().unwrap(), "half"]); + fs::remove_file(ws.join("half/README")).unwrap(); + fs::write(repo.join("y"), "y").unwrap(); + let clone = tmp.path().join("clone"); + fs::create_dir_all(&clone).unwrap(); + fs::write(clone.join("repos"), "proj\tcloning\nproj\tcloned\nhalf\tcloning\n").unwrap(); + + let out = wip_push_with_clone_dir(&ws, &["--dry-run"], &clone); + assert!(out.status.success(), "{}", text(&out)); + let stdout = String::from_utf8_lossy(&out.stdout); + let lines: Vec = stdout.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + let by = |f: &str| lines.iter().find(|v| v["folder"] == f).unwrap().clone(); + assert_eq!( + by("half"), + serde_json::json!({"folder": "half", "dirty": false, "unpushed": 0, "branch": "", "cloning": true}) + ); + assert_eq!(by("proj")["dirty"], true, "a finished clone is checked as usual"); + // The Rust side parses that line. + let parsed = crate::commands::stop_guard::parse_dry_run(&stdout).unwrap(); + assert!(parsed.iter().any(|r| r.folder == "half" && r.cloning)); + + let out = wip_push_with_clone_dir(&ws, &[], &clone); + assert!(out.status.success(), "{}", text(&out)); + assert!(text(&out).contains("half: still cloning"), "{}", text(&out)); + assert_eq!(wip_refs(&remote).len(), 1, "only proj was pushed"); + + // Once the clone is done, the folder is an ordinary repository again. + fs::write(clone.join("done"), "").unwrap(); + let out = wip_push_with_clone_dir(&ws, &["--dry-run"], &clone); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains(r#"{"folder":"half","dirty":true"#), "{stdout}"); +} + #[test] fn wip_push_rejects_an_unknown_reason() { let tmp = tempfile::tempdir().unwrap(); @@ -977,6 +1022,45 @@ fn idle_watch_pushes_before_an_idle_stop_and_stays_up_while_the_push_fails() { assert_eq!(fs::read_to_string(w.tmp.path().join("reason")).unwrap().trim(), "idle"); } +#[test] +fn idle_watch_goes_back_to_watching_when_someone_comes_back_during_the_push() { + let w = Watch::new(); + w.dirty(); + let d = w.tmp.path(); + // The push starts a process the watcher counts as `claude` (a renamed + // `sleep`, so `pgrep -x` matches it), as a user returning mid-push would. + let busy_name = format!("tcbusy{}", std::process::id() % 100_000); + let sleep = Command::new("sh").arg("-c").arg("command -v sleep").output().unwrap(); + let sleep = String::from_utf8_lossy(&sleep.stdout).trim().to_string(); + fs::copy(&sleep, d.join(&busy_name)).unwrap(); + fs::set_permissions(d.join(&busy_name), fs::Permissions::from_mode(0o755)).unwrap(); + write_script( + d, + "triple-c-wip-push", + &format!( + "#!/bin/bash\nif [ \"$1\" = --dry-run ]; then cat '{p}/dry'; exit 0; fi\n\ + echo \"$*\" >> '{p}/pushes'\n'{p}/{busy_name}' 30 /dev/null 2>&1 &\n\ + echo $! > '{p}/busy.pid'\nexit 0\n", + p = d.display() + ), + ); + let (url, mut agent) = fake_agent(d, OLD_ACTIVITY); + let mut target = fake_entrypoint(d); + let mut cmd = w.cmd(&url, target.id()); + cmd.env("TRIPLEC_WIP_PUSH", "1").env("TRIPLEC_IDLE_PROC_NAME", &busy_name); + let ran = run_watch_for(cmd, 5); + if let Ok(pid) = fs::read_to_string(d.join("busy.pid")) { + let _ = Command::new("kill").arg(pid.trim()).status(); + } + target.kill().ok(); + target.wait().ok(); + agent.kill().ok(); + assert!(ran.is_none(), "it must not stop: {:?}", ran.map(|o| text(&o))); + assert_eq!(w.pushes(), "--reason idle\n", "it pushed once, then saw the activity"); + assert!(!d.join("got").exists(), "no TERM"); + assert!(!d.join("reason").exists(), "no idle marker"); +} + #[test] fn idle_watch_keeps_a_stop_guard_marker_already_recorded() { for marker in ["pushed", "discard"] { @@ -1138,6 +1222,12 @@ fn docker_mode_never_reaches_the_aws_blocks() { }; let agent_call = lines.iter().position(|l| l.trim() == "start_agent").expect("start_agent call"); assert!(agent_call < pos("start_clone_repos"), "clones must start after the agent"); + // The stop trap is in place before any AWS background work starts, so a + // TERM in between still runs aws_before_stop. + let trap = pos("trap stop_keepalive TERM INT HUP"); + assert!(trap < pos("start_clone_repos"), "the trap must precede the clone"); + assert!(trap < pos("start_idle_watch"), "the trap must precede the idle watcher"); + assert!(pos("stop_keepalive()") < trap); assert!(block(ENTRYPOINT, "aws_repos").contains(") &"), "start_clone_repos backgrounds the clone"); // And the snapshot-sensitive variables are gone before the agent's // environment snapshot is taken. diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index 032dcd4..9835397 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -727,6 +727,9 @@ pub struct RepoState { pub dirty: bool, pub unpushed: u64, pub branch: String, + /// The start's background clone is still writing it: nothing to push. + #[serde(default)] + pub cloning: bool, } /// What `check_unsaved_work` found. `checked: false` (with `error`) when the diff --git a/app/src/components/projects/StopGuardModal.test.tsx b/app/src/components/projects/StopGuardModal.test.tsx index 48bb677..8fbe070 100644 --- a/app/src/components/projects/StopGuardModal.test.tsx +++ b/app/src/components/projects/StopGuardModal.test.tsx @@ -25,6 +25,17 @@ describe("StopGuardModal", () => { expect(onChoose).toHaveBeenCalledWith("push"); }); + it("lists a repository still cloning as having nothing to push", () => { + const work: UnsavedWork = { + ...dirty, + repos: [...dirty.repos, { folder: "big", branch: "", dirty: false, unpushed: 0, cloning: true }], + }; + render(); + expect(screen.getByText("/workspace/big")).toBeInTheDocument(); + expect(screen.getByText("still cloning — nothing to push")).toBeInTheDocument(); + expect(screen.getByText("/workspace/api")).toBeInTheDocument(); + }); + it("offers Stop without pushing and Cancel", () => { const onChoose = vi.fn(); render(); diff --git a/app/src/components/projects/StopGuardModal.tsx b/app/src/components/projects/StopGuardModal.tsx index 390d32c..37e89cf 100644 --- a/app/src/components/projects/StopGuardModal.tsx +++ b/app/src/components/projects/StopGuardModal.tsx @@ -31,7 +31,8 @@ function describeRepo(dirty: boolean, unpushed: number): string { export default function StopGuardModal({ projectName, work, wipPushOnStop = false, onChoose }: Props) { const pushRef = useRef(null); const stopRef = useRef(null); - const atRisk = work.repos.filter((r) => r.dirty || r.unpushed > 0); + const atRisk = work.repos.filter((r) => !r.cloning && (r.dirty || r.unpushed > 0)); + const cloning = work.repos.filter((r) => r.cloning); return ( ))} + {cloning.map((r) => ( +
  • + /workspace/{r.folder}{" "} + + still cloning — nothing to push + +
  • + ))}

    Push to WIP branch saves a snapshot of each repository to a{" "} diff --git a/app/src/lib/awsWorkspace.test.ts b/app/src/lib/awsWorkspace.test.ts index 981d823..ea0fe0f 100644 --- a/app/src/lib/awsWorkspace.test.ts +++ b/app/src/lib/awsWorkspace.test.ts @@ -48,6 +48,8 @@ describe("awsWorkspace", () => { expect(stopGuardNeeded({ checked: true, repos: [{ ...repo, dirty: true }], error: null })).toBe(true); expect(stopGuardNeeded({ checked: true, repos: [{ ...repo, unpushed: 1 }], error: null })).toBe(true); expect(stopGuardNeeded({ checked: false, repos: [], error: "x" })).toBe(true); + // A repository still cloning has nothing to push. + expect(stopGuardNeeded({ checked: true, repos: [{ ...repo, cloning: true }], error: null })).toBe(false); }); it("selects only ready environments the user joined", () => { diff --git a/app/src/lib/awsWorkspace.ts b/app/src/lib/awsWorkspace.ts index 7998f47..3087a7a 100644 --- a/app/src/lib/awsWorkspace.ts +++ b/app/src/lib/awsWorkspace.ts @@ -109,7 +109,7 @@ export function cleanRepos(rows: RepoSource[]): RepoSource[] { /** Whether Stop must ask first (the one place this rule lives). */ export function stopGuardNeeded(work: UnsavedWork): boolean { - return !work.checked || work.repos.some((r) => r.dirty || r.unpushed > 0); + return !work.checked || work.repos.some((r) => !r.cloning && (r.dirty || r.unpushed > 0)); } /** An environment a project can be placed in: ready (or merely behind) and joined. */ diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 5f78c34..e95633f 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -136,6 +136,8 @@ export interface RepoState { dirty: boolean; unpushed: number; branch: string; + /** The start's background clone is still writing it: nothing to push. */ + cloning?: boolean; } /** What `checkUnsavedWork` found (Rust `UnsavedWork`). `checked: false` (with diff --git a/container/entrypoint.sh b/container/entrypoint.sh index 71173c9..0063525 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -635,9 +635,12 @@ chown claude:claude /home/claude/.gitconfig 2>/dev/null || true # app gives a starting task's agent only seconds to answer, so a clone in the # way of the agent would fail every start with a large repository. Terminals # work while the repos arrive. Progress, for the app to show later, is in -# /run/triple-c-clone (world-readable, root-written): `repos` gets one -# "\t" line per repo, and `done` appears when -# all are finished. The idle watcher counts an unfinished clone as activity. +# /run/triple-c-clone (world-readable, root-written): `repos` gets a +# "\tcloning" line as a clone begins and a +# "\t" line when it ends (the last line for a +# folder is its state), and `done` appears when all are finished. The idle +# watcher counts an unfinished clone as activity; triple-c-wip-push skips a +# folder still `cloning`. # >>> aws_repos # clone_repos json root [status_dir] clone_repos() { @@ -658,6 +661,7 @@ clone_repos() { [ -n "$branch" ] && args+=(--branch "$branch") args+=(-- "$url" "$dest") echo "entrypoint: cloning $folder..." + _clone_status "$folder" cloning # BatchMode is appended to whatever ssh command the user's git config # names (core.sshCommand), read as the user, rather than replacing it. err=$(_triplec_as_user sh -c ' @@ -1244,6 +1248,17 @@ start_idle_watch() { ) & } # <<< aws_stop +# The stop trap goes in before any background work starts (the idle watcher +# can signal this script; a clone is work a stop must account for), so a TERM +# from here on always runs aws_before_stop. Defined after aws_stop, which +# holds the function it calls. +keepalive_pid="" +stop_keepalive() { + [ "$triplec_aws" = 1 ] && aws_before_stop + [ -n "$keepalive_pid" ] && kill "$keepalive_pid" 2>/dev/null + exit "${triplec_exit_status:-0}" +} +trap stop_keepalive TERM INT HUP if [ "$triplec_aws" = 1 ] && [ -n "${TRIPLEC_IDLE_STOP_MINUTES:-}" ]; then start_idle_watch "$TRIPLEC_IDLE_STOP_MINUTES" "$$" fi @@ -1253,13 +1268,6 @@ if [ "$triplec_aws" = 1 ]; then fi echo "Triple-C container ready." -keepalive_pid="" -stop_keepalive() { - [ "$triplec_aws" = 1 ] && aws_before_stop - [ -n "$keepalive_pid" ] && kill "$keepalive_pid" 2>/dev/null - exit "${triplec_exit_status:-0}" -} -trap stop_keepalive TERM INT HUP throttle_fifo=/run/triple-c-keepalive.throttle rm -f "$throttle_fifo" mkfifo -m 600 "$throttle_fifo" 2>/dev/null && exec 3<>"$throttle_fifo" diff --git a/container/triple-c-idle-watch b/container/triple-c-idle-watch index 8cea036..a7b52ff 100755 --- a/container/triple-c-idle-watch +++ b/container/triple-c-idle-watch @@ -19,9 +19,10 @@ # * no work would be lost: `triple-c-wip-push --dry-run` (as claude) finds # no dirty repository and no unpushed commit. With the WIP push on # (TRIPLEC_WIP_PUSH=1) such work is pushed here, before the stop, and the -# workspace stops only once that push succeeded; a failed push keeps it -# running and is retried later (backing off). With it off, unsaved work -# simply keeps the workspace up. +# workspace stops only once that push succeeded and the workspace is +# still quiet afterwards (activity during the push sends it back to +# watching); a failed push keeps it running and is retried later (backing +# off). With it off, unsaved work simply keeps the workspace up. # # Anything it cannot read counts as NOT idle: a stop is the one thing here # that cannot be undone, so every doubt keeps the workspace running. @@ -206,6 +207,14 @@ while :; do echo "triple-c-idle-watch: the WIP push failed — staying up; retrying in ${wait_s}s" continue fi + # The push took a while: someone may have come back meanwhile (a + # terminal, a `claude`, a scheduler job). Their new work would be + # lost, since the stop hook does not push again after an idle push. + if ! quiet; then + echo "triple-c-idle-watch: activity during the WIP push — not stopping" + said="" + continue + fi fi echo "triple-c-idle-watch: idle for ${minutes} minutes — stopping the workspace" mkdir -p "$(dirname "$reason_file")" 2>/dev/null diff --git a/container/triple-c-wip-push b/container/triple-c-wip-push index 42489aa..3da361b 100755 --- a/container/triple-c-wip-push +++ b/container/triple-c-wip-push @@ -22,14 +22,22 @@ # {"folder":…, "dirty":bool, "unpushed":n, "branch":…} # and changes nothing. # +# A repository the start's background clone is still writing (the entrypoint's +# /run/triple-c-clone: its `repos` file's last line for the folder says +# `cloning` and `done` is absent) is skipped: a half-made checkout is not the +# user's work. The dry run reports it as +# {"folder":…, "dirty":false, "unpushed":0, "branch":"", "cloning":true} +# # Exit 0 when every needed push succeeded (or nothing needed pushing), 1 when # any failed, 2 on bad usage. # -# TRIPLEC_WORKSPACE_ROOT overrides /workspace (tests). +# TRIPLEC_WORKSPACE_ROOT overrides /workspace, TRIPLEC_CLONE_STATUS_DIR +# /run/triple-c-clone (tests). set -u root="${TRIPLEC_WORKSPACE_ROOT:-/workspace}" +clone_dir="${TRIPLEC_CLONE_STATUS_DIR:-/run/triple-c-clone}" dry_run=0 reason=stop @@ -62,6 +70,14 @@ pick_remote() { if [ "$(git remote | wc -l)" -eq 1 ]; then git remote; fi } +# Whether the background clone is still writing folder $1: the clone is not +# `done` and the last state it recorded for the folder is `cloning`. +still_cloning() { + [ -d "$clone_dir" ] && [ ! -e "$clone_dir/done" ] || return 1 + # ENVIRON, not -v: -v would read backslashes in a folder name as escapes. + [ "$(F="$1" awk -F '\t' '$1 == ENVIRON["F"] { s = $2 } END { print s }' "$clone_dir/repos" 2>/dev/null)" = cloning ] +} + user_ssh_command="${GIT_SSH_COMMAND:-}" for dir in "$root"/*/; do @@ -73,6 +89,16 @@ for dir in "$root"/*/; do [ "$top" = "$(cd "$dir" && pwd -P)" ] || continue folder=$(basename "$dir") + if still_cloning "$folder"; then + if [ "$dry_run" = 1 ]; then + jq -cn --arg folder "$folder" \ + '{folder: $folder, dirty: false, unpushed: 0, branch: "", cloning: true}' + else + echo "triple-c-wip-push: $folder: still cloning — nothing to push" + fi + continue + fi + ( cd "$dir" || exit 1 # BatchMode: an unknown host key or a passphrase prompt fails at once -- 2.52.0 From 884a665e7db18220b61658783c0bd8ffe5b26932 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 13:07:54 -0800 Subject: [PATCH 38/43] fix(aws): close terminals before the stop guard's push and bound it in the container The guard pushed and recorded `pushed` while terminals (and Claude in them) were still open; the TERM hook then skipped its push, so later edits were lost. Stop now closes the project's exec sessions and auth bridge first, then pushes, records the marker and stops the task. A failed push still aborts the stop with the status restored; the auth bridge is re-armed and the error says the terminals were closed. The script also runs under `/usr/bin/timeout -k 5` ending 10 s before the client's timeout: the agent does not kill an exec its client gave up on. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/project_commands.rs | 103 ++++++++++++++---- app/src-tauri/src/commands/stop_guard.rs | 64 +++++++++-- 2 files changed, 137 insertions(+), 30 deletions(-) diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index 4c826a7..3490c5c 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -1517,36 +1517,58 @@ pub async fn stop_project_container( state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?; - // The stop guard's choice, before anything else is torn down: a push that - // fails aborts the stop with the project as it was (auth bridge included). + // AWS: the terminals and the auth bridge close *before* the stop guard's + // push, so nothing written after its snapshot is silently dropped (the + // entrypoint's hook skips its own push once `pushed` is recorded). A push + // that fails aborts the stop with the status as it was; the auth bridge + // is re-armed, and the error says the terminals were closed. let mut note = None; let guard_target = if project.is_aws() { ContainerTarget::for_project(&project) } else { None }; if project.is_aws() { - if wip == Some(WipChoice::Push) { - emit_progress(&app_handle, &project_id, "Pushing unsaved work..."); - } - note = aws_guard_before_stop( + let close_first = async { + state.auth_bridge.stop(&project_id).await; + if let Some(container) = guard_target.as_ref() { + state.exec_manager.close_sessions_for_container(container.id()).await; + } + if wip == Some(WipChoice::Push) { + emit_progress(&app_handle, &project_id, "Pushing unsaved work..."); + } + }; + let guarded = aws_guard_before_stop( &state.projects_store, &project_id, project.status.clone(), guard_target.as_ref(), wip, super::stop_guard::PUSH_TIMEOUT, + close_first, ) - .await?; + .await; + match guarded { + Ok(n) => note = n, + Err(e) => { + if project.auth_bridge_enabled { + if let Some(target) = guard_target.clone() { + state + .auth_bridge + .start(project_id.clone(), target, app_handle.clone(), state.projects_store.clone()) + .await; + } + } + return Err(e); + } + } + } else { + // Drop host listeners first: they only make sense while the container runs. + state.auth_bridge.stop(&project_id).await; } - // Drop host listeners first: they only make sense while the container runs. - state.auth_bridge.stop(&project_id).await; - if project.is_aws() { // A failed StopTask leaves a billed task running, so unlike Docker it // is not papered over: the project keeps its status and the user - // sees why (expired SSO, typically), and can retry. + // sees why (expired SSO, typically), and can retry. Its terminals + // and auth bridge were closed before the stop guard's step above. emit_progress(&app_handle, &project_id, "Stopping task..."); - if let Some(container) = ContainerTarget::for_project(&project) { - state.exec_manager.close_sessions_for_container(container.id()).await; - } let settings = state.settings_store.get(); if let Err(e) = ProjectHost::for_project(&project).stop_project(&project, &settings).await { log::warn!("Stop failed for project {}: {}", project_id, e); @@ -1578,22 +1600,36 @@ pub async fn stop_project_container( Ok(note) } -/// The AWS stop guard's step before StopTask ([`super::stop_guard::prepare_stop`]). -/// A failed push puts `previous` back, so the project is as it was. -async fn aws_guard_before_stop( +/// Appended to a failed stop-guard push: its terminals were closed first. +const TERMINALS_CLOSED_FOR_PUSH: &str = + "Its terminals were closed before the push, so nothing written after it could be lost; \ + open them again to keep working."; + +/// The AWS stop guard's step before StopTask ([`super::stop_guard::prepare_stop`]), +/// after `close_first` (the project's terminals and auth bridge): whatever +/// they write after the push's snapshot would otherwise be lost, since the +/// entrypoint's hook does not push again after `pushed`. A failed push puts +/// `previous` back, so the project's status is as it was. +async fn aws_guard_before_stop( store: &crate::storage::projects_store::ProjectsStore, project_id: &str, previous: ProjectStatus, runner: Option<&R>, wip: Option, push_timeout: std::time::Duration, -) -> Result, String> { + close_first: F, +) -> Result, String> +where + R: super::stop_guard::WipRunner, + F: std::future::Future, +{ + close_first.await; match super::stop_guard::prepare_stop(runner, wip, push_timeout).await { Ok(note) => Ok(note), Err(e) => { log::warn!("Project {}: WIP push before stop failed: {}", project_id, e); let _ = store.update_status(project_id, previous); - Err(e) + Err(format!("{e}\n\n{TERMINALS_CLOSED_FOR_PUSH}")) } } } @@ -2715,10 +2751,11 @@ mod tests { let (_d, store, id) = stop_store(ProjectStatus::Running); store.update_status(&id, ProjectStatus::Stopping).unwrap(); let fake = Fake::new(Ok(("push to origin failed: denied".into(), 1))); - let e = aws_guard_before_stop(&store, &id, ProjectStatus::Running, Some(&fake), Some(WipChoice::Push), std::time::Duration::from_secs(5)) + let e = aws_guard_before_stop(&store, &id, ProjectStatus::Running, Some(&fake), Some(WipChoice::Push), std::time::Duration::from_secs(5), async {}) .await .unwrap_err(); assert!(e.contains("denied"), "{e}"); + assert!(e.contains("terminals were closed"), "{e}"); assert_eq!(store.get(&id).unwrap().status, ProjectStatus::Running); assert!(fake.markers.lock().unwrap().is_empty()); } @@ -2729,7 +2766,7 @@ mod tests { let (_d, store, id) = stop_store(ProjectStatus::Running); store.update_status(&id, ProjectStatus::Stopping).unwrap(); let fake = Fake::new(Ok(("pushed".into(), 0))); - let note = aws_guard_before_stop(&store, &id, ProjectStatus::Running, Some(&fake), Some(WipChoice::Push), std::time::Duration::from_secs(5)) + let note = aws_guard_before_stop(&store, &id, ProjectStatus::Running, Some(&fake), Some(WipChoice::Push), std::time::Duration::from_secs(5), async {}) .await .unwrap(); assert_eq!(note, None); @@ -2738,6 +2775,30 @@ mod tests { assert_eq!(fake.calls.lock().unwrap().len(), 1); } + #[tokio::test] + async fn the_terminals_close_before_the_stop_guard_pushes_or_records_anything() { + use crate::commands::stop_guard::tests::Fake; + for wip in [Some(WipChoice::Push), Some(WipChoice::Discard), None] { + let (_d, store, id) = stop_store(ProjectStatus::Running); + let fake = Fake::new(Ok(("pushed".into(), 0))); + let seen_at_close = std::sync::Mutex::new(None); + aws_guard_before_stop(&store, &id, ProjectStatus::Running, Some(&fake), wip, std::time::Duration::from_secs(5), async { + *seen_at_close.lock().unwrap() = + Some((fake.calls.lock().unwrap().len(), fake.markers.lock().unwrap().len())); + }) + .await + .unwrap(); + assert_eq!(*seen_at_close.lock().unwrap(), Some((0, 0)), "{wip:?}: closed first"); + } + // Stop's own code closes them through that step, not after it. + let src = include_str!("project_commands.rs"); + let stop = &src[src.find("pub async fn stop_project_container").unwrap()..]; + let stop = &stop[..stop.find("\n}\n").unwrap()]; + let close = stop.find("close_sessions_for_container").unwrap(); + assert!(close < stop.find("aws_guard_before_stop(").unwrap()); + assert!(stop.find("auth_bridge.stop").unwrap() < stop.find("aws_guard_before_stop(").unwrap()); + } + #[test] fn a_new_aws_project_takes_repos_not_folders_and_no_runtime_handles() { let hosts = vec![host("h1")]; diff --git a/app/src-tauri/src/commands/stop_guard.rs b/app/src-tauri/src/commands/stop_guard.rs index 90c9bd6..a6efb1b 100644 --- a/app/src-tauri/src/commands/stop_guard.rs +++ b/app/src-tauri/src/commands/stop_guard.rs @@ -19,6 +19,10 @@ pub const DRY_RUN_TIMEOUT: Duration = Duration::from_secs(30); pub const PUSH_TIMEOUT: Duration = Duration::from_secs(300); /// Cap on the push output carried into an error. const MAX_OUTPUT: usize = 4000; +/// How much sooner than the client's timeout the in-container `timeout` +/// ends the script (its TERM, then 5 s to the KILL): an exec the client gives +/// up on is not killed by the agent, so the container bounds it itself. +const IN_CONTAINER_MARGIN: Duration = Duration::from_secs(10); /// Where the entrypoint's stop hook looks for the app's decision (Task 7's /// file, which the idle watcher also writes `idle` to). @@ -30,8 +34,13 @@ pub const MARKER_DISCARD: &str = "discard"; /// The workspace, as the stop guard uses it. pub trait WipRunner: Sync { - /// Run the WIP script as `claude`: `(combined output, exit code)`. - fn run(&self, args: Vec) -> impl Future> + Send; + /// Run the WIP script as `claude`: `(combined output, exit code)`. The + /// caller waits at most `limit`; the run must not outlive it. + fn run( + &self, + args: Vec, + limit: Duration, + ) -> impl Future> + Send; /// Record `marker` in [`STOP_REASON_FILE`] (as root: the directory is root's). fn write_stop_reason(&self, marker: &str) -> impl Future> + Send; /// Remove [`STOP_REASON_FILE`] (as root), so the hook pushes as configured again. @@ -39,10 +48,8 @@ pub trait WipRunner: Sync { } impl WipRunner for ContainerTarget { - async fn run(&self, args: Vec) -> Result<(String, i64), String> { - let mut cmd = vec![WIP_PUSH.to_string()]; - cmd.extend(args); - self.exec_oneshot_as("claude", cmd, Vec::new()).await + async fn run(&self, args: Vec, limit: Duration) -> Result<(String, i64), String> { + self.exec_oneshot_as("claude", wip_command(args, limit), Vec::new()).await } async fn write_stop_reason(&self, marker: &str) -> Result<(), String> { @@ -68,6 +75,22 @@ impl WipRunner for ContainerTarget { } } +/// The script's argv under coreutils `timeout`, ending it +/// [`IN_CONTAINER_MARGIN`] before the client's `limit` (KILL 5 s after the +/// TERM), so a push the app gave up on is not left running in the workspace. +pub fn wip_command(args: Vec, limit: Duration) -> Vec { + let secs = limit.saturating_sub(IN_CONTAINER_MARGIN).as_secs().max(1); + let mut cmd = vec![ + "/usr/bin/timeout".to_string(), + "-k".to_string(), + "5".to_string(), + secs.to_string(), + WIP_PUSH.to_string(), + ]; + cmd.extend(args); + cmd +} + /// The root command that forgets a recorded marker (a fixed argv). pub fn clear_stop_reason_command() -> Vec { vec!["rm".into(), "-f".into(), STOP_REASON_FILE.into()] @@ -132,7 +155,7 @@ pub async fn unsaved_work(runner: Option<&R>, timeout: Duration) - .into(), ); }; - let res = tokio::time::timeout(timeout, runner.run(vec!["--dry-run".into()])).await; + let res = tokio::time::timeout(timeout, runner.run(vec!["--dry-run".into()], timeout)).await; match res { Err(_) => unchecked("The workspace did not answer the check for unsaved work in time.".into()), Ok(Err(e)) => unchecked(format!("The workspace could not be asked about unsaved work: {e}")), @@ -175,7 +198,8 @@ pub async fn before_stop( workspace was not stopped." .to_string() })?; - let res = tokio::time::timeout(timeout, runner.run(vec!["--reason".into(), "stop".into()])).await; + let res = + tokio::time::timeout(timeout, runner.run(vec!["--reason".into(), "stop".into()], timeout)).await; match res { Err(_) => Err("Pushing unsaved work did not finish in time. The workspace was not stopped.".into()), Ok(Err(e)) => Err(format!("Could not push unsaved work: {e}. The workspace was not stopped.")), @@ -252,7 +276,7 @@ pub(crate) mod tests { } impl WipRunner for Fake { - async fn run(&self, args: Vec) -> Result<(String, i64), String> { + async fn run(&self, args: Vec, _limit: Duration) -> Result<(String, i64), String> { self.calls.lock().unwrap().push(args); if self.hang { std::future::pending::<()>().await; @@ -346,6 +370,28 @@ pub(crate) mod tests { assert!(fake.calls().is_empty()); } + #[test] + fn the_script_is_bounded_inside_the_workspace_before_the_client_gives_up() { + let cmd = wip_command(vec!["--reason".into(), "stop".into()], PUSH_TIMEOUT); + assert_eq!(cmd, vec!["/usr/bin/timeout", "-k", "5", "290", WIP_PUSH, "--reason", "stop"]); + let secs: u64 = cmd[3].parse().unwrap(); + assert!(secs + 5 < PUSH_TIMEOUT.as_secs(), "TERM and KILL land before the client's timeout"); + let dry = wip_command(vec!["--dry-run".into()], DRY_RUN_TIMEOUT); + assert_eq!(&dry[3..], &["20", WIP_PUSH, "--dry-run"]); + assert_eq!(wip_command(Vec::new(), Duration::from_secs(3))[3], "1", "never 0 (no limit)"); + // The image has coreutils' timeout where the argv says. + let df = include_str!("../../../../container/Dockerfile"); + assert!(df.contains("ubuntu:24.04") || df.contains("ubuntu:noble"), "coreutils at /usr/bin"); + } + + #[test] + fn a_cloning_repo_parses_with_its_flag_and_older_lines_without_it() { + let out = "{\"folder\":\"half\",\"dirty\":false,\"unpushed\":0,\"branch\":\"\",\"cloning\":true}\n\ + {\"folder\":\"a\",\"dirty\":true,\"unpushed\":0,\"branch\":\"main\"}"; + let repos = parse_dry_run(out).unwrap(); + assert!(repos[0].cloning && !repos[1].cloning); + } + #[test] fn long_output_keeps_its_end() { let s = format!("{}END", "x".repeat(10_000)); -- 2.52.0 From 33eb63d907d868b20f0aeb12faa2d6c5b580e88c Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 13:08:05 -0800 Subject: [PATCH 39/43] fix(aws): forget a stale stopped mark on a project's unattached target id The death watch's stopped marks are keyed by target id, and an unattached AWS project's id (http://unattached.invalid//) is reused by every later unattached spell, but only an endpoint URL's mark was ever removed. A stale mark could make the watch report a live, billed task as stopped. Registering a tunnel now clears the project's placeholder mark, and reconcile forgets it before asking ECS. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/project_commands.rs | 5 ++- app/src-tauri/src/host/aws_host.rs | 15 ++++++- app/src-tauri/src/host/aws_host/tests.rs | 20 ++++++++++ app/src-tauri/src/host/tunnels.rs | 39 +++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index 3490c5c..fccb468 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -1946,8 +1946,11 @@ pub async fn reconcile_aws_projects(state: State<'_, AppState>) -> Result Option { if tunnels::get_endpoint(&p.id).is_some_and(|ep| ep.base_url.as_str() == stored) { return None; } - let placeholder = format!("http://unattached.invalid/{}/", p.id); + let placeholder = tunnels::unattached_id(&p.id); (stored != placeholder).then_some(placeholder) } +/// Before reconcile asks ECS about `p`: a project with no registered tunnel +/// is (or is about to be) known by its [`tunnels::unattached_id`], which a +/// past unattached spell may have left marked stopped. Reconcile is the +/// authority about the task now, so that mark goes; the watch re-marks it if +/// the task really is stopped. Returns the placeholder to store, if any. +pub fn prepare_unattached(p: &Project) -> Option { + if !p.is_aws() || tunnels::get_endpoint(&p.id).is_some() { + return None; + } + tunnels::forget_stopped(&tunnels::unattached_id(&p.id)); + unattached_container_id(p) +} + /// Reconcile one AWS project the store has as Running (or Starting/ /// Stopping/Error left by a crash): a gone task makes it Stopped (its task /// ARN cleared); a live one keeps it Running and its ARN stored. An ECS diff --git a/app/src-tauri/src/host/aws_host/tests.rs b/app/src-tauri/src/host/aws_host/tests.rs index b2f350e..220934e 100644 --- a/app/src-tauri/src/host/aws_host/tests.rs +++ b/app/src-tauri/src/host/aws_host/tests.rs @@ -754,6 +754,26 @@ async fn reconcile_falls_back_to_a_newer_live_task_when_the_stored_one_stopped() assert!(fake.called("list"), "it looked for another task first"); } +#[tokio::test] +async fn reconcile_forgets_a_stale_stop_on_the_unattached_id_before_reusing_it() { + use crate::host::tunnels; + let mut p = aws_project(StorageMode::Ephemeral); + p.status = ProjectStatus::Running; + p.aws.as_mut().unwrap().task_arn = Some(task_arn("u2")); + let placeholder = tunnels::unattached_id(&p.id); + // A past unattached spell's task was seen stopped under this id. + tunnels::mark_stopped(&placeholder, StopKind::Spot); + p.container_id = Some("http://127.0.0.1:61400/".into()); + assert_eq!(prepare_unattached(&p), Some(placeholder.clone())); + assert!(!tunnels::is_stopped(&placeholder), "the live task must not read as stopped"); + // Already on the placeholder: nothing to store, the mark still goes. + p.container_id = Some(placeholder.clone()); + tunnels::mark_stopped(&placeholder, StopKind::Spot); + assert_eq!(prepare_unattached(&p), None); + assert!(!tunnels::is_stopped(&placeholder)); + assert_eq!(prepare_unattached(&Project::new("l".into(), Vec::new())), None); +} + #[tokio::test] async fn an_unattached_aws_project_gets_a_target_id_no_tunnel_can_have() { // m9: the last run's loopback port may belong to another project's diff --git a/app/src-tauri/src/host/tunnels.rs b/app/src-tauri/src/host/tunnels.rs index 0232bde..99d4106 100644 --- a/app/src-tauri/src/host/tunnels.rs +++ b/app/src-tauri/src/host/tunnels.rs @@ -89,6 +89,21 @@ fn notify() { changed().send_modify(|n| *n = n.wrapping_add(1)); } +/// The target id an AWS project has while no tunnel is registered for it +/// (see `aws_host::unattached_container_id`): never a loopback URL, so a +/// port a new tunnel reuses can't be confused with it. +pub fn unattached_id(project_id: &str) -> String { + format!("http://unattached.invalid/{project_id}/") +} + +/// Forget that the watch saw the task behind `target_id` stopped. For a +/// project's [`unattached_id`], which (unlike an endpoint) is reused by +/// every later unattached spell of the same project: a stale entry would +/// make a later live task read as stopped. +pub fn forget_stopped(target_id: &str) { + lock().stopped.remove(target_id); +} + /// Register `tunnel` for `project_id`, returning the one it replaces (the /// caller closes it). pub fn register(project_id: &str, tunnel: LiveTunnel) -> Option { @@ -96,6 +111,7 @@ pub fn register(project_id: &str, tunnel: LiveTunnel) -> Option { let mut r = lock(); *r.generation.entry(project_id.to_string()).or_default() += 1; r.stopped.remove(tunnel.endpoint.base_url.as_str()); + r.stopped.remove(&unattached_id(project_id)); r.pending.remove(project_id); r.live.insert(project_id.to_string(), tunnel) }; @@ -165,6 +181,7 @@ pub fn register_if_current( return Err(Box::new(tunnel)); } r.stopped.remove(tunnel.endpoint.base_url.as_str()); + r.stopped.remove(&unattached_id(project_id)); r.pending.remove(project_id); r.live.insert(project_id.to_string(), tunnel) }; @@ -358,6 +375,28 @@ pub(crate) mod tests { assert_eq!(closes.load(Ordering::SeqCst), 2); } + #[tokio::test] + async fn a_registering_tunnel_clears_a_stop_seen_under_the_projects_unattached_id() { + let p = pid("unattached-stale"); + let placeholder = unattached_id(&p); + mark_stopped(&placeholder, StopKind::Spot); + let closes = Arc::new(AtomicUsize::new(0)); + register(&p, fake_tunnel(61301, closes.clone())); + assert!(!is_stopped(&placeholder), "a later unattached spell must not read as stopped"); + close(&p).await; + + // A re-attach registering clears it too; and it can be forgotten directly. + let q = pid("unattached-stale-reattach"); + mark_stopped(&unattached_id(&q), StopKind::Spot); + let g = begin_reattach(&q); + assert!(register_if_current(&q, g, fake_tunnel(61302, closes.clone())).is_ok()); + assert!(!is_stopped(&unattached_id(&q))); + close(&q).await; + mark_stopped(&unattached_id(&q), StopKind::Idle); + forget_stopped(&unattached_id(&q)); + assert!(!is_stopped(&unattached_id(&q))); + } + #[tokio::test] async fn a_reattach_that_began_before_a_stop_does_not_register() { let closes = Arc::new(AtomicUsize::new(0)); -- 2.52.0 From be2825d344210fc47a0d187d80b5f0ceb0d2b9c6 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 13:08:05 -0800 Subject: [PATCH 40/43] fix(aws): the file viewer's change poll no longer counts as workspace activity Its 2 s sha256sum poll now carries X-Triple-C-Background, so an open viewer does not keep an idle AWS workspace up. Opening and saving a file stay foreground; Docker is unchanged. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/file_viewer/poll.rs | 4 +++- app/src-tauri/src/host/agent.rs | 11 +++++++++++ app/src-tauri/src/host/agent/tests.rs | 16 ++++++++++++++++ app/src-tauri/src/host/mod.rs | 16 ++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/app/src-tauri/src/file_viewer/poll.rs b/app/src-tauri/src/file_viewer/poll.rs index 905e172..165e297 100644 --- a/app/src-tauri/src/file_viewer/poll.rs +++ b/app/src-tauri/src/file_viewer/poll.rs @@ -51,8 +51,10 @@ pub async fn poll_file( "poll".to_string(), container_path.to_string(), ]; + // A background poll: an open viewer must not keep an idle AWS workspace + // up. Opening and saving a file are the user's own actions and count. let (stdout, stderr, code) = container - .exec_oneshot_streams_as("claude", cmd, Vec::new()) + .exec_oneshot_streams_background("claude", cmd) .await?; if code != 0 && code != 4 { return Err(format!( diff --git a/app/src-tauri/src/host/agent.rs b/app/src-tauri/src/host/agent.rs index 011838e..ad5f4a5 100644 --- a/app/src-tauri/src/host/agent.rs +++ b/app/src-tauri/src/host/agent.rs @@ -400,6 +400,17 @@ pub async fn exec_oneshot_streams_as( Ok((out.stdout(), out.stderr(), out.exit_code)) } +/// [`exec_oneshot_streams_as`] marked as a background poll (the file +/// viewer's change check), so it never keeps an idle workspace up. +pub async fn exec_oneshot_streams_background( + ep: &AgentEndpoint, + user: &str, + cmd: Vec, +) -> Result<(String, String, i64), String> { + let out = exec_oneshot_raw_marked(ep, user, cmd, Vec::new(), MAX_ONESHOT_OUTPUT, true).await?; + Ok((out.stdout(), out.stderr(), out.exit_code)) +} + // ── detached execs, status, resize ─────────────────────────────────────────── #[derive(Deserialize)] diff --git a/app/src-tauri/src/host/agent/tests.rs b/app/src-tauri/src/host/agent/tests.rs index c0ce2f0..a72517d 100644 --- a/app/src-tauri/src/host/agent/tests.rs +++ b/app/src-tauri/src/host/agent/tests.rs @@ -323,6 +323,22 @@ async fn only_a_background_poll_carries_the_background_marker() { assert_eq!(seen.exec_bodies[0], seen.exec_bodies[1], "the request is otherwise the same"); } +#[tokio::test] +async fn the_file_viewers_poll_is_a_background_request_and_its_open_and_save_are_not() { + let (ep, fake) = fake_agent().await; + let t = target(&ep); + // The 2 s change check, as the viewer runs it. + let _ = crate::file_viewer::poll::poll_file(&t, "/workspace/x").await; + t.exec_oneshot_streams_background("claude", cmd("sh")).await.unwrap(); + // What opening or saving goes through: user actions, which count. + t.exec_oneshot_streams_as("claude", cmd("sh"), Vec::new()).await.unwrap(); + let seen = fake.seen.lock().unwrap(); + assert_eq!(seen.exec_background, [Some("1".to_string()), Some("1".to_string()), None]); + let src = |s: &str| s.contains("exec_oneshot_streams_as("); + assert!(!src(include_str!("../../file_viewer/poll.rs")), "the poll stays a background request"); + assert!(src(include_str!("../../file_viewer/resolve.rs")) && src(include_str!("../../file_viewer/write.rs"))); +} + // ── one-shot execs ─────────────────────────────────────────────────────────── #[tokio::test] diff --git a/app/src-tauri/src/host/mod.rs b/app/src-tauri/src/host/mod.rs index f4a3522..6882030 100644 --- a/app/src-tauri/src/host/mod.rs +++ b/app/src-tauri/src/host/mod.rs @@ -302,6 +302,22 @@ impl ContainerTarget { } } + /// [`Self::exec_oneshot_streams_as`] for a background poll (the file + /// viewer's change check): on the agent it carries the background marker, + /// so it never counts as activity for idle stop. The same call on Docker. + pub async fn exec_oneshot_streams_background( + &self, + user: &str, + cmd: Vec, + ) -> Result<(String, String, i64), String> { + match self { + ContainerTarget::Docker { container_id } => { + docker::exec::exec_oneshot_streams_as(container_id, user, cmd, Vec::new()).await + } + ContainerTarget::Agent(ep) => agent::exec_oneshot_streams_background(ep, user, cmd).await, + } + } + /// Start a command and return without waiting or reading its output. /// Returns the exec id. pub async fn exec_detached(&self, spec: ExecSpec) -> Result { -- 2.52.0 From c467308513ee73ee2b9a132a389e494de7c8a488 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 13:08:05 -0800 Subject: [PATCH 41/43] fix(aws): keep the access point id when a removal keeps the saved files remove_aws_resources cleared access_point_id even without discard_data, though the access point stays; the project could then move hosts and orphan it. The id is now kept, so a later start reuses it, a later removal with "Also remove the access point" deletes it, and moving the project is refused until then (the message and the Host section say so). Co-Authored-By: Claude Opus 5.5 --- .../src/commands/project_commands.rs | 45 +++++++++++++++++-- .../projects/home/config/AwsConfig.test.tsx | 15 +++++++ .../projects/home/config/HostSection.tsx | 10 ++++- 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index fccb468..f54fc5d 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -1230,8 +1230,10 @@ fn validate_aws_update( }) { return Err( - "This project still has AWS resources (a task, its data's access point or task \ - definitions) where it runs now; remove the workspace's AWS resources first" + "This project still has AWS resources (a task, its saved files' access point or task \ + definitions) where it runs now; remove the workspace's AWS resources first — \ + including the access point (\"Also remove the workspace's file-system access \ + point\"), which stays when the saved files are kept" .into(), ); } @@ -1691,13 +1693,26 @@ pub async fn remove_aws_resources( ProjectHost::for_project(&project) .remove_aws_resources(&project, &settings, discard_data) .await?; - state.projects_store.set_aws_runtime(&project_id, None, None, None)?; + state + .projects_store + .set_aws_runtime(&project_id, None, access_point_after_removal(&project, discard_data), None)?; state .projects_store .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id)) } +/// The access point a removal leaves stored: kept when the saved files were +/// kept (it is still there, a later start reuses it and a later removal +/// with `discard_data` deletes it; and while it is stored the project can't +/// move away from it — [`validate_aws_update`]), gone when they were not. +fn access_point_after_removal(project: &Project, discard_data: bool) -> Option { + if discard_data { + return None; + } + project.aws.as_ref().and_then(|a| a.access_point_id.clone()) +} + fn check_aws_resources_removable(project: &Project) -> Result<(), String> { if !project.is_aws() { return Err("This project does not run on AWS".into()); @@ -2671,6 +2686,30 @@ mod tests { assert!(validate_aws_update(&running, &edited, &hosts).is_ok()); } + #[test] + fn a_removal_that_keeps_the_saved_files_keeps_their_access_point_and_the_project_in_place() { + let hosts = vec![host("h1")]; + let mut stored = aws_project(); + stored.aws.as_mut().unwrap().access_point_id = Some("fsap-1".into()); + assert_eq!(access_point_after_removal(&stored, false), Some("fsap-1".into())); + assert_eq!(access_point_after_removal(&stored, true), None); + // What the store holds after a keep-the-files removal still blocks a move. + let mut after = stored.clone(); + let a = after.aws.as_mut().unwrap(); + a.task_arn = None; + a.task_definition_arn = None; + a.access_point_id = access_point_after_removal(&stored, false); + let mut local = after.clone(); + local.host_id = None; + local.aws_placement = None; + local.aws = None; + let e = validate_aws_update(&after, &local, &hosts).unwrap_err(); + assert!(e.contains("access point"), "{e}"); + // After a discarding removal it can move. + after.aws.as_mut().unwrap().access_point_id = access_point_after_removal(&stored, true); + assert!(validate_aws_update(&after, &local, &hosts).is_ok()); + } + #[test] fn a_project_with_aws_resources_cannot_be_moved_off_its_host() { let hosts = vec![host("h1"), host("h2")]; diff --git a/app/src/components/projects/home/config/AwsConfig.test.tsx b/app/src/components/projects/home/config/AwsConfig.test.tsx index a3c7783..bdcca36 100644 --- a/app/src/components/projects/home/config/AwsConfig.test.tsx +++ b/app/src/components/projects/home/config/AwsConfig.test.tsx @@ -165,6 +165,21 @@ describe("HostSection", () => { await waitFor(() => expect(removeAwsResources).toHaveBeenCalledWith(p.id, true)); }); + it("keeps a project whose kept files' access point remains on AWS until it is removed", () => { + const p = awsProject(); + p.aws = { ...p.aws!, access_point_id: "fsap-1", task_arn: null, task_definition_arn: null }; + renderHost(p); + const move = screen.getByRole("button", { name: "Run on this machine instead" }); + expect(move).toHaveAttribute("aria-disabled", "true"); + expect(move).toHaveAccessibleDescription(/access point is still on AWS/); + expect(screen.getByRole("button", { name: "Remove AWS resources…" })).not.toHaveAttribute( + "aria-disabled", + "true", + ); + fireEvent.click(screen.getByRole("button", { name: "Remove AWS resources…" })); + expect(within(screen.getByRole("dialog")).getByText(/access point\s+stays too/)).toBeInTheDocument(); + }); + it("keeps the AWS resource buttons locked while the project is in Error (its task may live)", () => { const p = awsProject({ status: "error" }); p.aws = { ...p.aws!, task_arn: "arn:task" }; diff --git a/app/src/components/projects/home/config/HostSection.tsx b/app/src/components/projects/home/config/HostSection.tsx index 348ea29..45b530e 100644 --- a/app/src/components/projects/home/config/HostSection.tsx +++ b/app/src/components/projects/home/config/HostSection.tsx @@ -58,6 +58,8 @@ export default function HostSection({ project, save, disabled }: Props) { const saveAws = (patch: Partial) => save({ aws: { ...aws, ...patch } }); const hasResources = !!(aws.task_arn || aws.access_point_id || aws.task_definition_arn); + // A removal that kept the saved files keeps their access point stored. + const onlyAccessPoint = !!aws.access_point_id && !aws.task_arn && !aws.task_definition_arn; // Not `disabled` (which also allows Error): on AWS, Error can mean a live task. const stopped = project.status === "stopped"; @@ -343,7 +345,9 @@ export default function HostSection({ project, save, disabled }: Props) { unavailableReason={ !stopped ? STILL_RUNNING - : "Remove the workspace's AWS resources first, so nothing is left running or billed." + : onlyAccessPoint + ? "The saved files' access point is still on AWS. Remove the AWS resources with \u201cAlso remove the workspace\u2019s file-system access point\u201d first." + : "Remove the workspace's AWS resources first, so nothing is left running or billed." } onClick={() => void save({ host_id: null, aws_placement: null, aws: null })} > @@ -421,7 +425,9 @@ function RemoveAwsResourcesModal({ project, onClose }: { project: Project; onClo Also remove the workspace’s file-system access point The files stay on your own EFS file system (only your workspace role can read - them) until an administrator offboards you. + them) until an administrator offboards you. Left unchecked, the access point + stays too: the next start here reuses it, and the project can’t move to + another host until it is removed. -- 2.52.0 From d73a19a9e756054990764297b0971b2e79e4993a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 13:08:05 -0800 Subject: [PATCH 42/43] docs: note the stop guard order, background polls, clone skip and trap order Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index df6be51..a201f86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -352,7 +352,9 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li (`aws:userid` contains `:`). Workspace users can't deregister or delete task definitions (`DeregisterTaskDefinition` is `*`-only in IAM): removal leaves revisions ACTIVE for an admin (`aws/README.md`). Stop: the stop guard (`commands/stop_guard.rs`) dry-runs `triple-c-wip-push` - through the agent, may push first, then writes `pushed` or `discard` to `/run/triple-c/stop-reason` + through the agent, closes the project's terminals and auth bridge (so nothing is written after the + push's snapshot), may push first (under an in-container `timeout`, since the agent does not kill an + exec the client gave up on), then writes `pushed` or `discard` to `/run/triple-c/stop-reason` (root-owned; the entrypoint's TERM hook reads it, and the idle watcher writes `idle`) so the hook doesn't push twice; the marker is cleared if StopTask fails. Claude Code's sandbox is forced off in the generated settings (bubblewrap can't create namespaces on Fargate, verified live). @@ -400,10 +402,13 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li marker says the app handled it. The idle watcher runs as root under a supervisor and signals the entrypoint's own PID (not PID 1, which is docker-init), so idle and ECS stops share one trap. Idle = the agent's `last_activity_unix` older than N minutes (open-but-silent attaches/tunnels - don't count; requests with `X-Triple-C-Background: 1`, e.g. the auth bridge's poll, never do), + don't count; requests with `X-Triple-C-Background: 1` — the auth bridge's and the file viewer's polls — never do), no `claude`, no scheduler job, no running clone, and no unsaved git work: with the WIP push on the watcher pushes *before* signalling and stays up if that fails; with it off, dirty repos keep the - workspace up. An idle stop exits 75 (`aws::tasks::IDLE_EXIT_CODE`) so the death watch can tell it + workspace up; activity during the watcher's push sends it back to watching. A repo the background + clone is still writing (`/run/triple-c-clone/repos` last says `cloning`, no `done`) is skipped by + `triple-c-wip-push` (dry run: `"cloning": true`). The stop trap is set before any background work + starts. An idle stop exits 75 (`aws::tasks::IDLE_EXIT_CODE`) so the death watch can tell it from a crash (`classify_stop` → the `ContainerExited` toast). **The `TRIPLEC_` env prefix is reserved** on both hosts (`RESERVED_ENV_PREFIXES`; `workspace.rs` drops custom variables in it): the entrypoint's settings live there. -- 2.52.0 From b80ca0681ecb16c0196ab3d1328d737220907958 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 13:28:39 -0800 Subject: [PATCH 43/43] docs(aws): record the live re-run of the final idle-stop path Co-Authored-By: Claude Opus 5.5 --- aws/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws/README.md b/aws/README.md index 975cab4..76d65a6 100644 --- a/aws/README.md +++ b/aws/README.md @@ -595,7 +595,7 @@ and the secret and removes the ECR policy; then the fixture destroys the modules | Restart | ✓ new task, same revision; the `~/.claude` marker survived | | Repo clone | ✓ the background clone finished (`/run/triple-c-clone/repos`: `Hello-World cloned`) | | Stop guard and WIP push | ✓ `triple-c-wip-push --dry-run` reports the dirty repo; after StopTask the log shows `entrypoint: stopping (sigterm) — pushing work in progress...` and the push failing for lack of GitHub credentials. No push with real credentials was tried | -| Idle stop (5 min) | ✓ the task stopped itself 365 s after the last use (`triple-c-idle-watch: idle for 5 minutes`, `entrypoint: stopping (idle)`); ECS stop code `EssentialContainerExited`. Since the final fixes (R11) the idle stop exits 75 so the app shows it as an idle stop, open-but-silent terminals and the auth bridge's poll no longer count, and unsaved work blocks or is pushed first; the extended live test (attach held open, background poll) is written but **not yet re-run** (the test credentials had expired) | +| Idle stop (5 min) | ✓ with a terminal held open and the auth bridge's background poll running, the task stopped itself 365 s after the last use (`triple-c-idle-watch: idle for 5 minutes`, `entrypoint: stopping (idle) — work in progress already handled by the idle watcher`); ECS reports `EssentialContainerExited` with exit code 75, which the app classifies as an idle stop (re-run 2026-11-15 after the PR #82 fixes) | | `~/.claude.json` after Claude Code runs | ✓ still a link (Claude Code started without a login: "Not logged in") | | Bubblewrap (P9) | ✗ `bwrap --unshare-user` fails: "No permissions to create new namespace" (kernel 6.1, `max_user_namespaces` 16384, no `CAP_SYS_ADMIN` on Fargate). The sandbox stays off on AWS | | A revision per configuration | turning idle stop on adds a secret key, so the next start registers a new revision (by design) | -- 2.52.0