diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a14fb98..8f25b19 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -34,6 +34,7 @@ jobs: - common - gh - ssh-agent + - cloud-folder - az - kube-state baseImage: @@ -73,6 +74,7 @@ jobs: - common - gh - ssh-agent + - cloud-folder - az - kube-state steps: diff --git a/README.md b/README.md index 8993cb2..15fd8a0 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ installing Node.js, and a one-line bootstrap that wires both into any repo. | [`git`](src/git) | git augmented for the container: a worktree-aware wrapper that scopes `GIT_DIR` to the checkout and maps a `.git` pointer under the host's `.git-dirs` onto one shared mount, `safe.directory` and `core.hooksPath` at post-create, the global config in a volume it owns. | | [`gh`](src/gh) | [GitHub CLI](https://cli.github.com) from GitHub releases, checksum-verified, unless the official Feature installed it; its login (`GH_CONFIG_DIR`) in a volume it owns, git's GitHub credentials taken from it. | | [`ssh-agent`](src/ssh-agent) | `SSH_AUTH_SOCK` at a fixed path in every process, so git commits can be signed with an SSH key: the launcher mounts the host's agent socket (linux, macOS, a WSL distro), the private key never entering the container; the Windows engines get a plain explanation and an opt-in, temporary key mode; `ssh-agent:*` tasks for keys, dedicated-key warnings, rotation and retirement (`key:rotate`, `key:retire`, `key:age`) and git signing setup. | +| [`cloud-folder`](src/cloud-folder) | One fixed path (`/mnt/cloud-folder`, the `mountPoint` option) for a shared document folder that must never go in git (SharePoint, OneDrive): today host mode, the launcher bind-mounting the host folder a sync client keeps (`DCF_HOST_CLOUD_FOLDER`) at `/mnt/cloud-folder-host`, linked from `mountPoint`; `cloud-folder:status` says whether it is mounted, empty and writable and fails when it is not, so a start-up check can stop. rclone sync and mount come later. | | [`az`](src/az) | Augments the official `azure-cli` Feature: its login (`AZURE_CONFIG_DIR`) in a volume it owns, `az bicep` using the Bicep on PATH. | | [`kube-state`](src/kube-state) | Owns the kubeconfig (`KUBECONFIG`) the Kubernetes tools share, in a volume it owns, so velero and kubectl stop needing a hand-mounted `~/.kube`; installs no binary. | | [`common`](src/common) | The shared helper behind every Feature's post-create hook (`dcf-persist`: make a state directory usable and say what backs it), for a repo's own scripts too. | @@ -231,6 +232,7 @@ run is configured: | Ordered, comma-separated host types `auto` tries (default per OS: Windows `windows-docker,windows-wsl-docker,windows-wslc`, inside WSL `windows-wsl-docker`, `linux`, `macos`) | `HOST_TYPE_PRIORITY` | `HOST_TYPE_PRIORITY=` var | | `false` (or `0`, `no`): the launcher adds no host mounts to `up`. By default it adds the host home's `.git-dirs` at `/git-dirs` and, where the host type has one, the SSH agent socket at the `ssh-agent` Feature's fixed path (each skipped when the repository's `devcontainer.json` already mounts that target), so a repository declares no mount line; `task devcontainer:host` lists them. | `HOST_MOUNTS` | `HOST_MOUNTS=` var | | `key`: opt into a signing key kept in the container (the `ssh-agent` Feature's key mode, which breaks "the private key never enters the container" for you): `DCF_HOST_SSH_AGENT=key` and no agent socket mounted. Unset: the host type decides (socket on linux, macos, a WSL distro; none on the Windows engines). | `SSH_AGENT_MODE` | `SSH_AGENT_MODE=` var | +| A host folder a sync client keeps (SharePoint, OneDrive) that `up` bind-mounts at `/mnt/cloud-folder-host`, where the `cloud-folder` Feature's `mountPoint` links; passed on unchanged for `containerEnv`. Never created: a path missing on this host is skipped with a warning. Unset: no folder. | `DCF_HOST_CLOUD_FOLDER` | `DCF_HOST_CLOUD_FOLDER=` var | | Portable Node.js version | `DEVCONTAINER_NODE_VERSION` | - | | `@devcontainers/cli` version (default `latest`) | `DEVCONTAINER_CLI_VERSION` | - | | Root of the host-side tool-state folders (`devcontainer:homes`; default `~/.devcontainer-homes`) | `DEVCONTAINER_HOMES` | - | @@ -292,6 +294,14 @@ signing key in the container's volume and so breaks "the key never enters the co `task ssh-agent:key:new` makes one inside the container). Then `task ssh-agent:git:signing` and `task ssh-agent:git:verify`; `task ssh-agent:key:rotate` replaces the key in four steps and `key:retire` / `key:age` retire it and show its age. +**A shared document folder outside git (`cloud-folder` 1.0.0, devcontainer-cli 1.12.0):** add +`ghcr.io/nabil-becker/devcontainer-features/cloud-folder:1` to the `features`, map `"DCF_HOST_CLOUD_FOLDER": +"${localEnv:DCF_HOST_CLOUD_FOLDER}"` with `containerEnv` (the bootstrap's template does), and set +`DCF_HOST_CLOUD_FOLDER=` in `.devcontainer/.env`. `up` then mounts it at +`/mnt/cloud-folder-host`, and the Feature's `mountPoint` (default `/mnt/cloud-folder`) links there: the path in the +container is named once, in that option. Drop a hand-written bind mount of the folder from `devcontainer.json`. Put +`task cloud-folder:status` in your start-up check: it fails when the folder is missing or unwritable. + All of them also answer to `dc:` (the include's alias in the generated Taskfile). Each task dispatches per platform (Task's per-command `platforms`): diff --git a/TODO.md b/TODO.md index f456bc1..5273996 100644 --- a/TODO.md +++ b/TODO.md @@ -57,6 +57,7 @@ stays. Ids are short descriptive slugs (two to four kebab-case words naming the - [ ] **gh-ssh-key-format-check Check the real output of gh ssh-key list** (engineers). `REVOKE=1` finds the old key's id by parsing `gh ssh-key list`, which only the stubs have produced; run one real rotation on a throwaway key, fix the parse if the format differs, and file what it shows. Added 2026-10-24. - [x] **fleet-reconcile-live-sessions The fleet waits for restored sessions, reports and stops duplicates** (host). Requested by a consuming repository running four agents in one container (2026-10-24): `claude-fleet` waits until the restored session list is stable before reconciling (`--no-wait` skips it), reads ids from `.devcontainer/.env` before the environment, reports every agent with more than one live session and, only with `--stop-duplicates`, stops the extras with `claude stop` (never rm); `claude-host`'s refusal names `claude attach `. claude-code 1.6.0, devcontainer-cli 1.11.0. Added 2026-10-24. Done 2026-10-24 (branch `feature/fleet-reconcile-live-sessions/1`). - [x] **release-claude-code-1-6-1 Release claude-code 1.6.1 after the pin-hook and attach fixes merge** (the Operator). 1.6.1 carries the pin-hook and attach-advice fixes found on the workstation; the host reviews the con's changes, the Operator says "commit" and opens the pull request, and merges it once validate, lint, tests and DCO pass. The host does not start the release; it says when one is due. Added 2026-10-24. Done 2026-10-24. +- [ ] **cloud-folder-build Build the cloud-folder Feature** (host). A central SharePoint folder in the container through rclone, decided 2026-10-27 (`docs/proposals/cloud-folder.md`, section 7): slice 1 (`host` mode, `status`) is in progress; slice 2 (`sync`, sign-in, the shutdown check) and slice 3 (`mount`, FUSE granted per machine) remain. The office can adopt after slice 1. Added 2026-10-27. ### Devhost and the way of working @@ -262,7 +263,15 @@ stays. Ids are short descriptive slugs (two to four kebab-case words naming the - [x] **pin-hook-env-visibility The pin hook only looks in `.devcontainer/.env`** (host). Found 2026-10-24 when a `/clear` left `HOST_SESSION` at the id from before the clear: this repository keeps the line in the root `.env` (the Taskfile's `dotenv: ['.env', '.devcontainer/.env']`), and `claude-host-pin` (hook and `--replace`) searches only `.devcontainer/.env`, so it wrote nothing ("no .devcontainer/.env above …"). Search the Taskfile's dotenv files in its order, in the hook, `--replace` and `claude-fleet`, with tests. The check that the pin follows a `/clear` is `pin-hook-first-clear`. Added 2026-10-24. Done 2026-10-24 (branch `fix/claude-host-attach-kind/1`). - [ ] **fleet-attach-wording claude-fleet gives the right attach advice** (host). `claude-fleet` still prints `claude attach ` for every running session, interactive ones included, which fails for a session a terminal holds. Follow `claude-host`'s advice by session kind (`claude-host-attach-kind`), in the fleet's output and the claude-code README. Added 2026-10-24. - [ ] **shutdown-check-records-note The shutdown check notes uncommitted records instead of refusing** (host). The check refuses every rebuild on the backs' own uncommitted ledger, backlog and cost changes and on their waiting stretches, which a rebuild keeps, so the Operator types `FORCE=1` each time and the check protects nothing. Make those a note; block only work a rebuild would lose: a back mid-write, a test run, unpushed commits, a night shift halfway through. Stale runner notes stay with `runner-notes-block-stop`. Added 2026-10-24. -- [ ] **fleet-repo-check Run the fleet on the workstation in this repository** (the Operator). The root `Taskfile.yml` now sets `SESSIONS` (uncommitted), so `task dc:claude:fleet` passes its precondition; only a dry run has checked that. Run it here with this session up (it should start nothing and open the agent view) and with none running (it should start one), and file the output if either fails; the engineer's "fleet still doesnt work here" waits on it. Added 2026-10-24. +- [x] **fleet-repo-check Run the fleet on the workstation in this repository** (the Operator). The root `Taskfile.yml` sets `SESSIONS` (PR #80), so `task dc:claude:fleet` passes its precondition; only a dry run has checked that. Run it here with this session up (it should start nothing and open the agent view) and with none running (it should start one), and file the output if either fails; the engineer's "fleet still doesnt work here" waits on it. Added 2026-10-24. Done 2026-10-26: run on the workstation with this session up, it started nothing and opened the agent view. +- [x] **release-ssh-agent-1-1-1 Release ssh-agent 1.1.1 after the git:verify fix merges** (the Operator). The fix on `fix/ssh-agent-git-verify/1` (not yet committed) ends `git:verify`'s `tmp: unbound variable` under `set -u` and the temporary folder it leaves in `/tmp`; on Windows hosts `git:signing` and key rotation now default to `SCOPE=global`. Commit and merge it, then run the release; the host does not start the release. Added 2026-10-26. Done 2026-10-26. +- [ ] **worktree-empty-folders EnterWorktree leaves folders the container cannot delete** (host). A worktree made with EnterWorktree inside the OneDrive checkout leaves empty folders the container cannot remove (`rm -rf` and `rmdir` fail with "Not a directory"); the leftover `.claude/worktrees/fix-ssh-agent-verify` is one, and the Operator deletes it from Explorer on the workstation. Make worktrees with `git worktree add` in the scratchpad, as CLAUDE.md says, and note the EnterWorktree trap there or in the subagent brief. Related: `devhost-worktree-isolation`. Added 2026-10-26. +- [ ] **duplicate-warning-gap Learn what the session-start duplicate warning does and close its gap** (host). agentic-operating-framework still shows a session-start duplicate warning from its own script, a workaround since its ssh-agent 1.1.1 report (2026-10-27). Read that script in the consuming repository, then check whether `claude-fleet`'s duplicate report (`fleet-reconcile-live-sessions`) or `claude-host`'s refusal already covers it, and close what is left here. Added 2026-10-27. +- [ ] **backs-paste-truncation Find out whether the backs truncate pasted requests** (host). The secretary reported two complete pasted requests as cut off (2026-10-27), and the host found both complete, so the back likely sees a shortened copy of pasted text. Find where the paste is cut (the job's input or the condensed stretch), then fix it there or have the back stop flagging a paste as cut off, with a test. Added 2026-10-27. +- [ ] **psscriptanalyzer-tracked-files The PowerShell lint checks tracked files only** (host). `task lint` fails on a workstation checkout because the PSScriptAnalyzer step scans the git-ignored `material.ignore/` folder and reports 32 problems in files git never sees; the earlier `lint-ignore-local-folders` fix covered yamllint only. Build the analyzer's file list from tracked and non-ignored files, as the YAML check does, with a test. Added 2026-10-27. +- [ ] **cloud-folder-ci-check Read the first CI run of the cloud-folder tests** (engineers). The Feature's container tests (`test/cloud-folder`) and the collection-wide test have not run with Docker anywhere but CI; read the first CI run and file what it shows. Added 2026-10-27. +- [ ] **cloud-folder-workstation-up Run cloud-folder once on the workstation** (the Operator). One real `up` with a cloud folder whose path has spaces, on Docker Desktop and on wslc, to check the launcher's mount and `cloud-folder:status` on real hosts. Added 2026-10-27. +- [ ] **release-cloud-folder Release cloud-folder 1.0.0 and devcontainer-cli 1.12.0 after the step 1 pull request merges** (the Operator). The host does not start the release. Until it runs, consumers cannot pull cloud-folder or name it in `installsAfter`. Added 2026-10-27. ## Next @@ -279,6 +288,8 @@ stays. Ids are short descriptive slugs (two to four kebab-case words naming the - [ ] **kube-state-installs-after kube-state integrations and test count derivation** (host). After release (release-collection): add kube-state to `installsAfter` in velero, kustomize, argocd and kubeconform Features; derive Feature count in test/_global from src/ listing instead of hard-coded value. (was TD-17) Added 2026-10-16. - [x] **merge-features-pr Merge the Features PR #12** (the Operator). devhost 1.2.3 and git 1.1.0 (the src and test commits plus the sign-off hook fix), on `features/devhost-and-git-fixes`; the records PR #13 stacks on it. Merge with the admin bypass once CI is green, then run the release. Added 2026-10-16. Done 2026-10-19. - [x] **new-feature-checklist One checklist for adding a Feature** (host). What a new Feature needs is spread over README, CONTRIBUTING, the CI matrices and `test/_global` (a hard-coded Feature count), and scenarios that call `task` need `go-task` listed; three cons rediscovered it. One list in CONTRIBUTING.md. Ledger review 2026-10-16, group 5. Added 2026-10-16. Done 2026-10-16. +- [ ] **claude-host-join-running claude-host joins a running session with CLAUDE_HOST_JOIN=1** (host). Requested by agentic-operating-framework (its ADR 0015, 2026-10-27): with `CLAUDE_HOST_JOIN=1`, claude-host attaches to a live background session in the folder (the pinned id if live, else the newest by `startedAt`) instead of refusing, with a one-line note on stderr and a duplicates line when more than one is live; nothing live keeps today's `--resume`/`--session-id`; an interactive one prints its session id, pid and terminal (`ps -o tty=`), says to switch to that terminal or Remote Control, and exits 1 starting nothing; `devcontainer:claude` passes the variable through. claude-code 1.7.0 and a devcontainer-cli minor bump. Their `scripts/test-join.sh` (fake `claude`; cases: nothing live as an ended entry, a dead pid or another folder; one background session; two live with the pinned one older; two live with the pin in neither; the duplicates line; an interactive session; an unreadable list) fits `task test:claude-unit`; they drop `scripts/join.sh` once the release ships. Linked to `fleet-attach-wording` (the same attach advice, same release) and `session-concept-drop` (the host sees no conflict; decide it first). Added 2026-10-27. +- [ ] **cloud-folder-feature A cloud folder in the container through rclone** (host). Requested by the client engagement office (2026-10-27): one fixed path in the container (`/mnt/cloud-folder`, option `mountPoint`) that agents read and write as a directory, filled from SharePoint or OneDrive through rclone, so it no longer depends on one Windows host's sync client and works on a Kubernetes cluster. The rclone binary is on containers.dev; no Feature configures a remote, signs in, mounts or syncs. Modes: `auto` (default: `mount`, else `sync`), `mount` (`rclone mount`, needs FUSE), `sync` (`rclone bisync` to a volume, on a timer, unprivileged), `host` (a host bind mount, no rclone); tasks `login`, `status`, `sync`, `unmount`. The Operator said yes (2026-10-27): a proposal in `docs/proposals/` first, then three slices (`host` plus `status`; `sync` plus `login`; `mount` with the launcher granting FUSE). Added 2026-10-27. ### Devhost and the way of working @@ -393,3 +404,4 @@ stays. Ids are short descriptive slugs (two to four kebab-case words naming the - [ ] **fleet-waiting-line Print the fleet's waiting line only while the list changes** (the Operator). The fleet now prints "waiting for restored sessions…" on every run and waits at least `FLEET_SETTLE_INTERVAL` (3 seconds) before starting anything. Recommendation: print it only while the session list is changing, a few lines of change; the host can fold that in before #76 merges. Added 2026-10-24. - [ ] **session-concept-drop Drop the session from the domain?** (the Operator). Should agents stop being identified by session id, so the domain keeps only a name and a folder, with background agents that start with the container and interactive attach for humans? The pins, the pin hook and the stale-pin warnings would go with it. No recommendation was recorded; the host's shape is untested against Claude Code (`fleet-design-rethink`). Added 2026-10-24. - [x] **commit-sessions-setting Commit the fleet's SESSIONS setting on a topic branch** (the Operator). The `SESSIONS` line in the root `Taskfile.yml` is uncommitted on a detached HEAD beside the backs' records changes; committing needs a topic branch first (`task devhost:branch`). Commit it on a branch, or leave it uncommitted? Added 2026-10-24. Done 2026-10-24: "yes", committed on a topic branch. +- [ ] **cloud-folder-commit Commit, push and open the pull request for cloud-folder step 1** (the Operator). Step 1 (`host` mode), the proposal, ADR 0031 and the records are in the working tree, nothing committed; the host recommends one commit, with cloud-folder at 1.0.0 and devcontainer-cli at 1.12.0, once the Operator has checked the rework. Added 2026-10-27. diff --git a/Taskfile.yml b/Taskfile.yml index f28dacd..0e717b8 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -80,7 +80,7 @@ tasks: echo "generated READMEs are up to date" lint: - desc: 'shellcheck every shell script, yamllint every YAML file (.yamllint), PSScriptAnalyzer every PowerShell file, the vendored copies of src/common/*.sh (in src/ and local-features/) identical to the canonical ones, and the Docker-free unit tests (devhost tooling, the git Feature''s .git-dirs mapping, the ssh-agent Feature''s setup script, the claude-code Feature''s live-session rule and claude-fleet, the host launcher) (CI runs exactly this inside .devcontainer/ci; the tools must be installed - this repo''s devcontainer has them, at the pinned versions).' + desc: 'shellcheck every shell script, yamllint every YAML file (.yamllint), PSScriptAnalyzer every PowerShell file, the vendored copies of src/common/*.sh (in src/ and local-features/) identical to the canonical ones, and the Docker-free unit tests (devhost tooling, the git Feature''s .git-dirs mapping, the ssh-agent Feature''s setup script, the cloud-folder Feature''s status, the claude-code Feature''s live-session rule and claude-fleet, the host launcher) (CI runs exactly this inside .devcontainer/ci; the tools must be installed - this repo''s devcontainer has them, at the pinned versions).' cmds: - shellcheck -x src/*/*.sh local-features/*/*.sh local-features/*/share/bin/*.sh local-features/*/share/jobs/*/*.sh test/*/*.sh test/*/unit/*.sh test/bootstrap/*.sh bootstrap/*.sh bootstrap/host/*.sh .githooks/* .devcontainer/*.sh - yamllint --strict . @@ -97,6 +97,7 @@ tasks: - task: test:host-mounts - task: test:ssh-key-import - task: test:ssh-agent-unit + - task: test:cloud-folder-unit - task: test:claude-unit - task: test:claude-fleet-task @@ -131,10 +132,15 @@ tasks: - bash test/bootstrap/host_type_test.sh test:host-mounts: - desc: 'Docker-free bash tests of the host mounts the bash twin (src/devcontainer-cli/devcontainer-cli.sh) adds to `up`: the --mount argument per host type (linux, macos, windows-wsl-docker), the source folder created, a mount skipped when the repository''s devcontainer.json already declares its target (JSONC comments, trailing commas, object form, --config), HOST_MOUNTS=false, none for build/exec/read-configuration, and the SSH agent socket mount per host type and SSH_AGENT_MODE=key, and what `host` lists; a stub devcontainer records its arguments; part of lint. The PowerShell twin is in test:bootstrap.' + desc: 'Docker-free bash tests of the host mounts the bash twin (src/devcontainer-cli/devcontainer-cli.sh) adds to `up`: the --mount argument per host type (linux, macos, windows-wsl-docker), the source folder created, a mount skipped when the repository''s devcontainer.json already declares its target (JSONC comments, trailing commas, object form, --config), HOST_MOUNTS=false, none for build/exec/read-configuration, the SSH agent socket mount per host type and SSH_AGENT_MODE=key, the cloud folder (DCF_HOST_CLOUD_FOLDER at the fixed /mnt/cloud-folder-host, a Windows path passed as is, a missing folder skipped and never created), and what `host` lists; a stub devcontainer records its arguments; part of lint. The PowerShell twin is in test:bootstrap.' cmds: - bash test/bootstrap/host_mounts_test.sh + test:cloud-folder-unit: + desc: 'Docker-free tests of the cloud-folder Feature''s cloud-folder-setup over a faked mount point (a mount table file, no mount, no privilege): the build-time link from mountPoint to the host mount (made, idempotent, none when they are equal, an empty folder replaced, a folder with content or a file refused), status through the link, no source, mounted and not empty, mounted and empty, not writable (skipped as root), a bare folder that is not mounted, mode host with nothing mounted, modes this version does not know, a path with a space, a mounted file, --json, the post-start report never failing, nothing written into the folder, and the Feature Taskfile handing CLOUD_FOLDER_* and CLI_ARGS to the script; part of lint. Needs python3 (to read the JSON) and task.' + cmds: + - bash test/cloud-folder/unit/status_test.sh + test:ssh-agent-unit: desc: 'Docker-free tests of the ssh-agent Feature''s ssh-agent-setup against temp folders with the real OpenSSH tools and git: the verdict per DCF_HOST_SSH_AGENT (socket, none, key, unknown), key mode (an agent started on the fixed path and reused, a stale socket replaced, a 0600 key loaded and a 0644 one refused, key-new, key-add, key-stdin, key-remove), key hygiene (dedicated default name, default-name and age warnings, key-age), rotation and retirement against a stubbed gh (the steps in order, the old key retired only after git:verify passes, the allowed_signers history, REVOKE, the resume path, --plan changing nothing), the refusal when key mode is off, git-signing (KEY by fingerprint or .pub, SCOPE and its default) and a real sign and verify round trip in git-verify; only throwaway keys; part of lint. Needs openssh-client and git.' cmds: diff --git a/bootstrap/devcontainer.json b/bootstrap/devcontainer.json index 7e4b14f..810e059 100644 --- a/bootstrap/devcontainer.json +++ b/bootstrap/devcontainer.json @@ -36,11 +36,16 @@ // The host user's home: %USERPROFILE% on Windows, $HOME elsewhere "DCF_HOST_HOME": "${localEnv:DCF_HOST_HOME}", // socket, key or none (none for now) - "DCF_HOST_SSH_AGENT": "${localEnv:DCF_HOST_SSH_AGENT}" + "DCF_HOST_SSH_AGENT": "${localEnv:DCF_HOST_SSH_AGENT}", + // The host folder (DCF_HOST_CLOUD_FOLDER in .devcontainer/.env) the launcher mounts for the + // cloud-folder Feature; empty when none. The Feature's status names it. + "DCF_HOST_CLOUD_FOLDER": "${localEnv:DCF_HOST_CLOUD_FOLDER}" }, // Host mounts: the launcher (task devcontainer:up / rebuild) adds the mounts the host type // needs with `devcontainer up --mount`, today the host home's .git-dirs at /git-dirs (the git - // Feature's one mount), so nothing here declares it. HOST_MOUNTS=false turns that off. If you + // Feature's one mount), so nothing here declares it; also, when DCF_HOST_CLOUD_FOLDER is set, + // that host folder at /mnt/cloud-folder-host, where the cloud-folder Feature's mountPoint + // option links. HOST_MOUNTS=false turns that off. If you // keep or add the line below, the launcher sees the target and skips its own, so it is harmless. // Only when you also open the folder straight in VS Code (Reopen in Container), which skips the // launcher and leaves DCF_HOST_HOME empty, add the line yourself, with the home variable VS Code diff --git a/bootstrap/devcontainer.yml b/bootstrap/devcontainer.yml index 4285145..3871e81 100644 --- a/bootstrap/devcontainer.yml +++ b/bootstrap/devcontainer.yml @@ -62,6 +62,12 @@ # the agent socket is mounted on linux, macos and # windows-wsl-docker from a distro, none on the Windows # engines (no host process may be left running). +# DCF_HOST_CLOUD_FOLDER a host folder a sync client keeps (a SharePoint or OneDrive +# folder): `up` bind-mounts it at /mnt/cloud-folder-host, the +# cloud-folder Feature's host mount (its mountPoint option links +# there), and passes the variable on, for containerEnv. Unset = +# no folder. Never created: a path missing on this host is skipped +# with a warning (the Feature's status then fails). # DEVCONTAINER_NODE_VERSION portable Node.js version (default in the scripts). # DEVCONTAINER_CLI_VERSION @devcontainers/cli version (default: latest). # DEVCONTAINER_HOMES host folder for bind-mounted tool state @@ -127,13 +133,15 @@ vars: HOST_TYPE_PRIORITY: '{{.HOST_TYPE_PRIORITY | default ""}}' HOST_MOUNTS: '{{.HOST_MOUNTS | default ""}}' SSH_AGENT_MODE: '{{.SSH_AGENT_MODE | default ""}}' + DCF_HOST_CLOUD_FOLDER: '{{.DCF_HOST_CLOUD_FOLDER | default ""}}' _PS_SETTINGS: -DockerPath '{{.DEVCONTAINER_DOCKER_PATH}}' -NodeVersion '{{.DEVCONTAINER_NODE_VERSION}}' -CliVersion '{{.DEVCONTAINER_CLI_VERSION}}' # Only the tasks that run the CLI (and `host`) take the host type and the host mounts switch. - _PS_HOST_SETTINGS: -HostType '{{.HOST_TYPE}}' -HostTypePriority '{{.HOST_TYPE_PRIORITY}}' -HostMounts '{{.HOST_MOUNTS}}' -SshAgentMode '{{.SSH_AGENT_MODE}}' + # A folder path may hold spaces and quotes (a synced library's name), hence shellQuote. + _PS_HOST_SETTINGS: -HostType '{{.HOST_TYPE}}' -HostTypePriority '{{.HOST_TYPE_PRIORITY}}' -HostMounts '{{.HOST_MOUNTS}}' -SshAgentMode '{{.SSH_AGENT_MODE}}' -CloudFolder {{shellQuote .DCF_HOST_CLOUD_FOLDER}} # Linux/macOS: the bash script reads the environment. The resolved values # are prefixed to the command rather than set through `env:` (see above). _SH_SETTINGS: >- - {{if .DEVCONTAINER_DOCKER_PATH}}DEVCONTAINER_DOCKER_PATH='{{.DEVCONTAINER_DOCKER_PATH}}' {{end}}{{if .DEVCONTAINER_NODE_VERSION}}DEVCONTAINER_NODE_VERSION='{{.DEVCONTAINER_NODE_VERSION}}' {{end}}{{if .DEVCONTAINER_CLI_VERSION}}DEVCONTAINER_CLI_VERSION='{{.DEVCONTAINER_CLI_VERSION}}' {{end}}{{if .HOST_TYPE}}HOST_TYPE='{{.HOST_TYPE}}' {{end}}{{if .HOST_TYPE_PRIORITY}}HOST_TYPE_PRIORITY='{{.HOST_TYPE_PRIORITY}}' {{end}}{{if .HOST_MOUNTS}}HOST_MOUNTS='{{.HOST_MOUNTS}}' {{end}}{{if .SSH_AGENT_MODE}}SSH_AGENT_MODE='{{.SSH_AGENT_MODE}}' {{end}} + {{if .DEVCONTAINER_DOCKER_PATH}}DEVCONTAINER_DOCKER_PATH='{{.DEVCONTAINER_DOCKER_PATH}}' {{end}}{{if .DEVCONTAINER_NODE_VERSION}}DEVCONTAINER_NODE_VERSION='{{.DEVCONTAINER_NODE_VERSION}}' {{end}}{{if .DEVCONTAINER_CLI_VERSION}}DEVCONTAINER_CLI_VERSION='{{.DEVCONTAINER_CLI_VERSION}}' {{end}}{{if .HOST_TYPE}}HOST_TYPE='{{.HOST_TYPE}}' {{end}}{{if .HOST_TYPE_PRIORITY}}HOST_TYPE_PRIORITY='{{.HOST_TYPE_PRIORITY}}' {{end}}{{if .HOST_MOUNTS}}HOST_MOUNTS='{{.HOST_MOUNTS}}' {{end}}{{if .SSH_AGENT_MODE}}SSH_AGENT_MODE='{{.SSH_AGENT_MODE}}' {{end}}{{if .DCF_HOST_CLOUD_FOLDER}}DCF_HOST_CLOUD_FOLDER={{shellQuote .DCF_HOST_CLOUD_FOLDER}} {{end}} # The shutdown check when STOP_CHECK is unset, run by `bash -lc` inside the # container from the workspace folder (the CLI's exec starts there): the # repository's `task shutdown:check` if its Taskfile defines it, else the @@ -171,7 +179,7 @@ tasks: platforms: [linux, darwin] host: - desc: 'Show the host profile without starting anything: HOST_TYPE as resolved and why, the HOST_TYPE_PRIORITY list used, the engine command, the DCF_HOST_TYPE, DCF_HOST_HOME and DCF_HOST_SSH_AGENT values the launcher sets before it runs the CLI, and the host mounts `up` would add (and those it would skip because devcontainer.json already mounts the target; HOST_MOUNTS=false turns them off). HOST_TYPE= or HOST_TYPE_PRIORITY= on the command line try other choices.' + desc: 'Show the host profile without starting anything: HOST_TYPE as resolved and why, the HOST_TYPE_PRIORITY list used, the engine command, the DCF_HOST_TYPE, DCF_HOST_HOME, DCF_HOST_SSH_AGENT and DCF_HOST_CLOUD_FOLDER values the launcher sets before it runs the CLI, and the host mounts `up` would add (and those it would skip because devcontainer.json already mounts the target; HOST_MOUNTS=false turns them off). HOST_TYPE= or HOST_TYPE_PRIORITY= on the command line try other choices.' cmds: - task: cli vars: diff --git a/bootstrap/env.example b/bootstrap/env.example index ae63c06..121b7ce 100644 --- a/bootstrap/env.example +++ b/bootstrap/env.example @@ -25,6 +25,15 @@ # Linux linux; macOS macos. An organisation that wants Docker in WSL first: #HOST_TYPE_PRIORITY=windows-wsl-docker,windows-docker,windows-wslc +# A host folder a sync client keeps (a SharePoint or OneDrive folder) that +# `task devcontainer:up` bind-mounts at /mnt/cloud-folder-host, for material that +# must never go in git; the cloud-folder Feature links its mountPoint option +# there, so the path in the container is named once, in that option. Unset = no +# folder. The launcher never creates it: a path missing on this machine is +# skipped with a warning. Map it with containerEnv (the bootstrap's +# devcontainer.json does). +#DCF_HOST_CLOUD_FOLDER=C:\Users\\\ + # Portable Node.js and @devcontainers/cli versions used by the host-side # tasks (cached per user; nothing installed globally). #DEVCONTAINER_NODE_VERSION=24.21.0 diff --git a/bootstrap/host/DevcontainerCli.psm1 b/bootstrap/host/DevcontainerCli.psm1 index 743bf3c..574f089 100644 --- a/bootstrap/host/DevcontainerCli.psm1 +++ b/bootstrap/host/DevcontainerCli.psm1 @@ -25,20 +25,24 @@ windows-wsl-docker, windows-wslc, linux, macos - and HOST_TYPE_PRIORITY (ordered comma list) says which `auto` tries first. The engine follows the host type unless DEVCONTAINER_DOCKER_PATH is set, which always wins. Before - the CLI runs, DCF_HOST_TYPE, DCF_HOST_HOME and DCF_HOST_SSH_AGENT are set in - this process (see Resolve-HostProfile; `host` prints them). + the CLI runs, DCF_HOST_TYPE, DCF_HOST_HOME, DCF_HOST_SSH_AGENT and DCF_HOST_CLOUD_FOLDER are + set in this process (see Resolve-HostProfile; `host` prints them). Every setting here is read from an environment variable so a .env file (loaded by Task's dotenv: or by bootstrap.ps1) can drive it: DEVCONTAINER_DOCKER_PATH, DEVCONTAINER_NODE_VERSION, - DEVCONTAINER_CLI_VERSION, HOST_TYPE, HOST_TYPE_PRIORITY, HOST_MOUNTS, SSH_AGENT_MODE. Explicit parameters win over the environment. + DEVCONTAINER_CLI_VERSION, HOST_TYPE, HOST_TYPE_PRIORITY, HOST_MOUNTS, SSH_AGENT_MODE, + DCF_HOST_CLOUD_FOLDER. Explicit parameters win over the environment. Host mounts: `up` (so `rebuild`) adds the mounts the host type needs with `--mount type=bind,source=...,target=...` (Get-HostMount; slice 3: the `.git-dirs` folder of the host home at /git-dirs; slice 4: the SSH agent socket at /var/lib/devcontainer-features/ssh-agent/agent.sock where the host type has one), so a repository declares none. DCF_HOST_SSH_AGENT is socket, none or key - (SSH_AGENT_MODE=key, the opt-in to a key in the container, also suppresses the mount). A mount whose target the repository's devcontainer.json already mounts is + (SSH_AGENT_MODE=key, the opt-in to a key in the container, also suppresses the mount). The + host folder DCF_HOST_CLOUD_FOLDER, when set, goes to the cloud-folder Feature's fixed host mount + /mnt/cloud-folder-host (the Feature links its mountPoint there); it is never created, and + skipped with a warning when it does not exist on this host. A mount whose target the repository's devcontainer.json already mounts is skipped, with one line saying so. HOST_MOUNTS=false turns the launcher's mounts off. `build` mounts nothing and `exec` runs in an existing container: neither takes them. @@ -409,8 +413,9 @@ function Resolve-HostProfile { .SYNOPSIS Everything the launcher knows about the host: Type, Why, Priority, Engine (the command shown), EnginePath ($null = docker on PATH), and the three - DCF_HOST_* values: Home, SshAgent (socket, none or key), and SshSocket (the host - socket the agent mount takes, or empty). + DCF_HOST_* values: Home, SshAgent (socket, none or key), SshSocket (the host + socket the agent mount takes, or empty) and CloudFolder (DCF_HOST_CLOUD_FOLDER as given, or + empty). #> [CmdletBinding()] param([string]$DockerPath) @@ -431,34 +436,41 @@ function Resolve-HostProfile { Type = $t.Type; Why = $t.Why; Priority = $t.Priority Engine = $engine; EnginePath = $enginePath Home = $hostHome; SshAgent = $agent.Mode; SshSocket = $agent.Socket + CloudFolder = (Get-SettingOrEnv '' 'DCF_HOST_CLOUD_FOLDER') } } function Initialize-HostEnvironment { - <# .SYNOPSIS Sets DCF_HOST_TYPE, DCF_HOST_HOME and DCF_HOST_SSH_AGENT in this process, so the devcontainer CLI (and ${localEnv:...} in a repo's devcontainer.json) sees them. Returns the profile. #> + <# .SYNOPSIS Sets DCF_HOST_TYPE, DCF_HOST_HOME, DCF_HOST_SSH_AGENT and DCF_HOST_CLOUD_FOLDER in this process, so the devcontainer CLI (and ${localEnv:...} in a repo's devcontainer.json) sees them. Returns the profile. #> [CmdletBinding()] param([string]$DockerPath) $p = Resolve-HostProfile -DockerPath $DockerPath [Environment]::SetEnvironmentVariable('DCF_HOST_TYPE', $p.Type) [Environment]::SetEnvironmentVariable('DCF_HOST_HOME', $p.Home) [Environment]::SetEnvironmentVariable('DCF_HOST_SSH_AGENT', $p.SshAgent) + # Passed on unchanged (the user's value), so containerEnv can map it; empty removes it. + [Environment]::SetEnvironmentVariable('DCF_HOST_CLOUD_FOLDER', $p.CloudFolder) return $p } # ---------------------------------------------------------------- host mounts ---- # The mounts the launcher adds to `devcontainer up` (--mount), per host type: the host home's -# .git-dirs (slice 3) and the SSH agent socket at the ssh-agent Feature's fixed path (slice 4). -# Add a case to the switch, or an entry to the array of a case: nothing else changes. +# .git-dirs (slice 3), the SSH agent socket at the ssh-agent Feature's fixed path (slice 4), and +# the host folder DCF_HOST_CLOUD_FOLDER at the cloud-folder Feature's fixed host mount (cloud-folder +# proposal, slice 1; the same constant is cloud_folder_target in devcontainer-cli.sh and HOST_MOUNT +# in the Feature's install.sh). Add a case to the switch, or an entry to the array of a case: nothing else changes. $script:SshAgentTarget = '/var/lib/devcontainer-features/ssh-agent/agent.sock' +$script:CloudFolderTarget = '/mnt/cloud-folder-host' function Get-HostMount { <# .SYNOPSIS The host mounts for a host type: objects with Name, Source, Target and Type. git-dirs is empty when the home is unknown; ssh-agent is present only with - -SshAgent socket and a -SshSocket source. + -SshAgent socket and a -SshSocket source; cloud-folder only with a -CloudFolder source, + always at /mnt/cloud-folder-host. #> [CmdletBinding()] - param([Parameter(Mandatory)][string]$Type, [string]$HostHome, [string]$SshAgent = 'none', [string]$SshSocket) + param([Parameter(Mandatory)][string]$Type, [string]$HostHome, [string]$SshAgent = 'none', [string]$SshSocket, [string]$CloudFolder) $mounts = @() if ($HostHome) { switch ($Type) { @@ -472,6 +484,9 @@ function Get-HostMount { if ($SshAgent -eq 'socket' -and $SshSocket) { $mounts += [pscustomobject]@{ Name = 'ssh-agent'; Source = $SshSocket; Target = $script:SshAgentTarget; Type = 'bind' } } + if ($CloudFolder) { + $mounts += [pscustomobject]@{ Name = 'cloud-folder'; Source = $CloudFolder; Target = $script:CloudFolderTarget; Type = 'bind' } + } return $mounts } @@ -561,7 +576,7 @@ function Resolve-HostMount { $p = Resolve-HostProfile -DockerPath $DockerPath $configured = @(Get-ConfiguredMountTarget -WorkspaceFolder $WorkspaceFolder -Arguments $Arguments) $add = @(); $skip = @() - foreach ($m in @(Get-HostMount -Type $p.Type -HostHome $p.Home -SshAgent $p.SshAgent -SshSocket $p.SshSocket)) { + foreach ($m in @(Get-HostMount -Type $p.Type -HostHome $p.Home -SshAgent $p.SshAgent -SshSocket $p.SshSocket -CloudFolder $p.CloudFolder)) { if ($configured -contains $m.Target) { $skip += $m } else { $add += $m } } return [pscustomobject]@{ Enabled = $true; Add = $add; Skip = $skip } @@ -571,14 +586,33 @@ function Initialize-HostMountSource { <# .SYNOPSIS Creates the host folder of a bind mount when it is a path this launcher can reach (idempotent), so a start never fails on a missing source. A failure is a warning, never fatal. #> [CmdletBinding()] param([Parameter(Mandatory)]$Mount, [Parameter(Mandatory)][string]$Type) - # A socket is not ours to create. - if ($Mount.Name -eq 'ssh-agent') { return } + # A socket is not ours to create, nor is the user's cloud folder (an empty one would hide a wrong path). + if ($Mount.Name -in 'ssh-agent', 'cloud-folder') { return } # windows-wsl-docker driven from Windows has a distro path (/home/...) that Windows cannot create. if ($Type -eq 'windows-wsl-docker' -and (Get-HostPlatform) -eq 'windows') { return } try { if (-not (Test-Path -LiteralPath $Mount.Source)) { [void](New-Item -ItemType Directory -Path $Mount.Source -Force) } } catch { Write-Warning "host mount: could not create $($Mount.Source): $($_.Exception.Message)" } } +function Test-CloudFolderMount { + <# + .SYNOPSIS + $true when the cloud-folder mount can be passed: a source that is a folder on this host. A source this launcher cannot check (a Windows path from Linux or + macOS, a distro path from Windows) goes to the engine as is. A refusal is one warning; + the container then starts without the folder and its status says so. + #> + [CmdletBinding()] + [OutputType([bool])] + param([Parameter(Mandatory)]$Mount) + $windowsPath = $Mount.Source -match '^([A-Za-z]:[\\/]|\\\\)' + $checkable = if ((Get-HostPlatform) -eq 'windows') { $windowsPath } else { $Mount.Source.StartsWith('/') } + if ($checkable -and -not (Test-Path -LiteralPath $Mount.Source -PathType Container)) { + Write-Warning "host mount cloud-folder skipped: DCF_HOST_CLOUD_FOLDER '$($Mount.Source)' is not a folder on this host; the container starts without it (task cloud-folder:status says so)." + return $false + } + return $true +} + function Get-HostMountArgument { <# .SYNOPSIS @@ -593,6 +627,7 @@ function Get-HostMountArgument { foreach ($m in $r.Skip) { Write-Host "host mount skipped: $($m.Target) is already mounted by this repository's devcontainer.json" } foreach ($m in $r.Add) { if ($m.Source -match ',') { Write-Warning "host mount $($m.Name) skipped: the source '$($m.Source)' contains a comma, which --mount cannot carry."; continue } + if ($m.Name -eq 'cloud-folder' -and -not (Test-CloudFolderMount -Mount $m)) { continue } Initialize-HostMountSource -Mount $m -Type $type $out += @('--mount', (ConvertTo-MountArgument -Mount $m)) } @@ -611,6 +646,7 @@ function Show-HostProfile { Write-Host "DCF_HOST_TYPE : $($p.Type)" Write-Host "DCF_HOST_HOME : $($p.Home)" Write-Host "DCF_HOST_SSH_AGENT : $($p.SshAgent)" + Write-Host "DCF_HOST_CLOUD_FOLDER: $(if ($p.CloudFolder) { $p.CloudFolder } else { '(unset)' })" if (-not $WorkspaceFolder) { $WorkspaceFolder = (Get-Location).Path } $r = Resolve-HostMount -WorkspaceFolder $WorkspaceFolder -Arguments $Arguments -DockerPath $DockerPath if (-not $r.Enabled) { Write-Host 'host mounts : off (HOST_MOUNTS=false)'; return } @@ -1055,4 +1091,4 @@ function Invoke-DevcontainerCli { exit $rc } -Export-ModuleMember -Function Get-HostTypeId, Get-DefaultHostTypePriority, Resolve-HostType, Resolve-SshAgent, Resolve-HostProfile, Initialize-HostEnvironment, Get-HostMount, ConvertTo-MountArgument, ConvertTo-JsonWithoutComment, Get-ConfiguredMountTarget, Test-HostMountsEnabled, Resolve-HostMount, Initialize-HostMountSource, Get-HostMountArgument, Show-HostProfile, Import-SshKey, Clear-HostProfileCache, Get-SettingOrEnv, ConvertFrom-TaskCliArgString, Get-PortableNodeDir, Get-DevcontainersCliEntrypoint, Remove-DevcontainersCli, Resolve-DockerPath, Get-WorkspaceFolderVariant, Get-RunningDevcontainerId, Stop-Devcontainer, Test-BuildSubcommand, Enter-DevcontainerBuildLock, Exit-DevcontainerBuildLock, Invoke-DevcontainerCliProcess, Invoke-DevcontainerCli +Export-ModuleMember -Function Get-HostTypeId, Get-DefaultHostTypePriority, Resolve-HostType, Resolve-SshAgent, Resolve-HostProfile, Initialize-HostEnvironment, Get-HostMount, Test-CloudFolderMount, ConvertTo-MountArgument, ConvertTo-JsonWithoutComment, Get-ConfiguredMountTarget, Test-HostMountsEnabled, Resolve-HostMount, Initialize-HostMountSource, Get-HostMountArgument, Show-HostProfile, Import-SshKey, Clear-HostProfileCache, Get-SettingOrEnv, ConvertFrom-TaskCliArgString, Get-PortableNodeDir, Get-DevcontainersCliEntrypoint, Remove-DevcontainersCli, Resolve-DockerPath, Get-WorkspaceFolderVariant, Get-RunningDevcontainerId, Stop-Devcontainer, Test-BuildSubcommand, Enter-DevcontainerBuildLock, Exit-DevcontainerBuildLock, Invoke-DevcontainerCliProcess, Invoke-DevcontainerCli diff --git a/bootstrap/host/Invoke-DevcontainerCli.ps1 b/bootstrap/host/Invoke-DevcontainerCli.ps1 index 6cd571c..d6c5529 100644 --- a/bootstrap/host/Invoke-DevcontainerCli.ps1 +++ b/bootstrap/host/Invoke-DevcontainerCli.ps1 @@ -48,6 +48,11 @@ socket is mounted, DCF_HOST_SSH_AGENT=key). Empty = $env:SSH_AGENT_MODE, else the host type decides. +.PARAMETER CloudFolder + DCF_HOST_CLOUD_FOLDER: a host folder (one a sync client keeps) that `up` bind-mounts at the + cloud-folder Feature's host mount, /mnt/cloud-folder-host. Empty = $env:DCF_HOST_CLOUD_FOLDER, + else no folder. + .PARAMETER NodeVersion Portable Node.js version (Windows). Empty = $env:DEVCONTAINER_NODE_VERSION or the module default. @@ -67,6 +72,7 @@ param( [string]$HostTypePriority, [string]$HostMounts, [string]$SshAgentMode, + [string]$CloudFolder, [string]$NodeVersion, [string]$CliVersion, [switch]$Force @@ -79,6 +85,7 @@ if ($HostType) { $env:HOST_TYPE = $HostType } if ($HostTypePriority) { $env:HOST_TYPE_PRIORITY = $HostTypePriority } if ($HostMounts) { $env:HOST_MOUNTS = $HostMounts } if ($SshAgentMode) { $env:SSH_AGENT_MODE = $SshAgentMode } +if ($CloudFolder) { $env:DCF_HOST_CLOUD_FOLDER = $CloudFolder } $params = @{ Subcommand = $Subcommand diff --git a/docs/adr/0031-the-launcher-grants-fuse-on-request-and-the-cloud-folder-mode-is-chosen-in-the-container.md b/docs/adr/0031-the-launcher-grants-fuse-on-request-and-the-cloud-folder-mode-is-chosen-in-the-container.md new file mode 100644 index 0000000..267e69a --- /dev/null +++ b/docs/adr/0031-the-launcher-grants-fuse-on-request-and-the-cloud-folder-mode-is-chosen-in-the-container.md @@ -0,0 +1,93 @@ +# ADR-0031: The launcher grants FUSE on request, and the cloud-folder mode is chosen in the container + +| | | +|---|---| +| **Status** | accepted | +| **Date** | 2026-10-27 | +| **Deciders** | the Operator; proposed by the host | + +## Context + +The `cloud-folder` Feature ([the proposal](../proposals/cloud-folder.md)) gives a container one +fixed path filled from a SharePoint or OneDrive folder, by one of three modes: `host` (a bind +mount of a folder the host syncs), `mount` (`rclone mount`, needs FUSE) and `sync` (`rclone +bisync` on a timer, needs nothing privileged). A Feature's `capAdd`, `securityOpt` and `devices` +are static JSON and would apply to every consumer whatever the `mode` option says, so a Feature +cannot ask for FUSE only when it is wanted. [ADR 0024](0024-the-host-is-an-adapter-owned-by-devcontainer-cli.md) +made the launcher the one place that knows the host and had it pass `DCF_HOST_*` variables and +`--mount` arguments, but so far it has only added a mount and an environment variable; it has +never granted a container a privilege. The proposal also needs a process that runs inside the +container (the sync timer), of which no Feature has an example yet, and a default for the +rclone backend. The Operator accepted all six recommendations of the proposal on 2026-10-27. + +## Decision + +1. **The launcher grants FUSE per machine, only on request.** When the project sets a launcher + variable (for example `DCF_HOST_FUSE=1`), the devcontainer-cli launcher adds the runtime + arguments `mount` mode needs. It never grants them by default. This extends ADR 0024: the + launcher may now grant a privilege as well as pass a path or a variable, and a Feature never + declares one for this purpose. +2. **`host` mode is a launcher bind mount** of `DCF_HOST_CLOUD_FOLDER`, carried into the container + through the repository's `containerEnv` like the other `DCF_HOST_*` variables. No rclone is + involved. The launcher always mounts it at one fixed path, `/mnt/cloud-folder-host`, because it cannot + read the Feature's options; the Feature links its `mountPoint` to that path at build time, so a + consumer sets the path once (decided by the Operator 2026-10-27, after the first build). +3. **`auto` tries `host`, then `mount`, then `sync`**, inside the container, and says which it + chose. `mount` is chosen only if `/dev/fuse` is usable, which is all the Feature detects. +4. **`remoteType` defaults to `sharepoint`**, not `onedrive`, because the client-credentials flow + the cluster will need works only with the `sharepoint` backend. +5. **The sync timer is one supervised loop started from the Feature's `postStartCommand`**, with a + PID file in the Feature's volume, following the ssh-agent Feature's key mode. No `entrypoint` + is declared. The repository's shutdown check refuses a stop while a sync runs or uploads are + pending. +6. **Slices:** `host` mode and `status` first (the office adopts then), `sync` and the timer + second, `mount`, `auto` and the FUSE grant third. In `sync` mode bisync's defaults stand + (newer wins, the loser is renamed, the 50% delete guard stays). + +## Options considered + +### Option 1: the launcher grants FUSE on request; the Feature detects what it was given (chosen) + +Keeps the privilege out of every consumer that does not ask, keeps the Feature host-blind, and +follows the pattern of ADR 0024. `auto` needs only to detect that FUSE is present. + +### Option 2: the Feature declares `capAdd: SYS_ADMIN` and `/dev/fuse` in its JSON + +Static: every consumer would get the privilege whether or not it uses `mount`, including those +that choose `sync` or `host`. Rejected. + +### Option 3: grant FUSE always in the launcher + +Widens every container started through the launcher for the sake of one Feature. Rejected in +favour of asking. + +### Option 4: supervise the timer as a container `entrypoint` + +It would run as PID 1 for every consumer and collide with a repository's own entrypoint; no +Feature declares one today. Rejected; the `postStartCommand` loop is the established precedent. + +### Option 5: `onedrive` as the default `remoteType` + +What the original request asked for, but client credentials do not work with it, so the cluster +case would need a different remote type. Rejected. + +## Consequences + +- The launcher and devcontainer-cli change together for slice 3, with a version bump, and ADR + 0024's host contract now includes granting a privilege on request; the notes for the launcher + must say so and that the grant is opt-in per machine. +- A project that does not ask for FUSE gets `sync` (or `host`), so `auto` degrades instead of + failing; whether the grant works on Docker Desktop and wslc, and whether a cluster can grant it + at all, is still unverified (proposal section 11). +- The first in-container background process of the collection is introduced; a hard kill or a + stop that skips the shutdown check can leave a stale bisync lock and a listing that needs + `--resync`, and the next start says so. +- The proposal's open questions (client credentials with the `sharepoint` remote, bisync with + SharePoint hashes, the meaning of the pending-upload counters) remain to be answered during + slices 2 and 3. + +## Change log + +| Date | Change | +|---|---| +| 2026-10-27 | Accepted: the Operator accepted all six recommendations of the cloud-folder proposal. | diff --git a/docs/adr/README.md b/docs/adr/README.md index b2f56b2..ccb6aa0 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -79,3 +79,4 @@ numbering and a row below. | [0028](0028-claude-host-refuses-a-second-live-session-and-a-hook-writes-the-pin-back.md) | claude-host refuses a second live session, and an opt-in hook writes the pin back | accepted | 2026-10-24 | | [0029](0029-key-rotation-is-one-command-and-keeps-the-old-key-registered-unless-revoked.md) | Key rotation is one command, and it keeps the old key registered unless revoked | accepted | 2026-10-24 | | [0030](0030-claude-fleet-waits-for-restored-sessions-and-stops-extras-but-never-removes-them.md) | claude-fleet waits for restored sessions, and stops extras but never removes them | accepted | 2026-10-24 | +| [0031](0031-the-launcher-grants-fuse-on-request-and-the-cloud-folder-mode-is-chosen-in-the-container.md) | The launcher grants FUSE on request, and the cloud-folder mode is chosen in the container | accepted | 2026-10-27 | diff --git a/docs/ledger/2026-10.md b/docs/ledger/2026-10.md index a3925fe..a5f1911 100644 --- a/docs/ledger/2026-10.md +++ b/docs/ledger/2026-10.md @@ -4700,3 +4700,138 @@ - **For the repository:** none - **Decisions needed:** none - **Measures:** $0.29 (claude-opus-5-5 $0.29; 6 replies, 399,985 tokens in, 1,856 out); list-price estimate (this stretch; model haiku wrote the entry) + +### 2026-10-26 Fleet SESSIONS setting merged as PR #80, records committed + +- **Session:** host (4f9a03e8-d3db-4e7e-b939-4de63ac232d1) +- **Engineer:** operator +- **Branch:** unknown +- **Asked:** Commit the fleet SESSIONS setting in Taskfile.yml with the backs' records on a topic branch, push it, open a PR and merge it. +- **Skill:** none +- **Came out:** Commit 164f107 on feature/fleet-repo-check/1 holds the SESSIONS setting in Taskfile.yml and the records since #79. Pushed, PR #80 opened and merged. Main is at the merged state and the topic branch is pruned. Two cost-report files changed after the commit and were left out. +- **Friction:** [process] The first PR body was written without the repository's PR template and had to be rewritten to follow it. +- **Learned:** Cost-report files keep changing after a commit, so they miss it and wait for the next records commit. +- **Decisions:** "commit it on a topic branch" (yes); "push" (push the branch and open the PR); "merged" (PR #80 merged). +- **For the repository:** none +- **Decisions needed:** none +- **Measures:** $1.12 (claude-opus-5-5 $1.12; 11 replies, 852,590 tokens in, 3,658 out); list-price estimate (this stretch; model haiku wrote the entry) + +### 2026-10-26 Devhost rebuild status answered; fleet check still open + +- **Session:** host (d7621154-59a4-4a54-89e4-8ccaa6012504) +- **Engineer:** operator +- **Branch:** unknown +- **Asked:** Did the devhost rebuild work? +- **Skill:** none +- **Came out:** The rebuild is confirmed from inside the container. It started at 13:15 UTC, HEAD d7b1ba2 matches origin/main (PR #80), the checkout and /git-dirs mounts are in place, and Claude Code, git and gh read their settings from the volumes. No files changed. The fleet sessions check stays open. +- **Friction:** [tooling] From inside the container the fleet launch cannot be seen, so the fleet check cannot be confirmed here. [process] git is on a detached HEAD with no branch checked out. +- **Learned:** The rebuild was checked from the container's own state (start time, HEAD, mounts), not from the build log alone. +- **Decisions:** none +- **For the repository:** [tooling] The fleet check needs a way to report its result back to the container, or it stays an open item. +- **Decisions needed:** Did the workstation run of task dc:claude:fleet wait for this session and report no duplicates? Only the Operator can confirm this. +- **Measures:** $0.45 (claude-opus-5-5 $0.45; 5 replies, 264,350 tokens in, 2,025 out); list-price estimate (this stretch; model haiku wrote the entry) + +### 2026-10-26 ssh-agent git:verify fix merged as PR #81, release started + +- **Session:** host (d7621154-59a4-4a54-89e4-8ccaa6012504) +- **Engineer:** operator +- **Branch:** fix/signing-config-scope/1 +- **Asked:** Commit, push and open a PR for the ssh-agent git:verify fix, then merge it and run the ssh-agent release. +- **Skill:** none +- **Came out:** Commit f96f78f (amended) on fix/signing-config-scope/1, renamed from fix/ssh-agent-git-verify/1. PR #81 merged as 7f98444. Release run 40333297379 started for ssh-agent 1.1.1; the registry check is still open. +- **Friction:** [tooling] The pre-push guard refused fix/ssh-agent-git-verify/1 because the name is not a backlog item; the branch was renamed to the matching item and the commit message amended. [docs] The backlog item for the release still names the old branch and says "not yet committed". +- **Learned:** The push guard matches the branch name to a TODO item id, so a fix can take the id of the item it carries out. +- **Decisions:** "merged and kicked off the release" (PR #81 merged, release started). +- **For the repository:** [docs] Correct the signing-config-scope wording in TODO.md (old branch name, "not yet committed"). [tooling] The push guard should name the backlog id it expects. +- **Decisions needed:** Default for git:signing on Linux and macOS hosts without the git Feature volume (signing-config-scope); it stays repo until decided. +- **Measures:** $2.42 (claude-opus-5-5 $2.42; 38 replies, 3,139,895 tokens in, 17,275 out); list-price estimate (this stretch; model haiku wrote the entry) + +### 2026-10-26 ssh-agent 1.1.1 released and confirmed, merged branch cleaned up + +- **Session:** host (d7621154-59a4-4a54-89e4-8ccaa6012504) +- **Engineer:** operator +- **Branch:** unknown +- **Asked:** Confirm the ssh-agent 1.1.1 release on the registry and clean up the merged branch, carried from the earlier merge step. +- **Skill:** none +- **Came out:** Release run 40333297379 succeeded. The registry lists 1.1.1, and the 1 and 1.1 tags moved with it. Local fix/signing-config-scope/1 was pruned, and its remote was deleted on merge. feature/session-state-contract/1 was kept because it has unmerged work. The empty folder .claude/worktrees/fix-ssh-agent-verify is no longer a git worktree but is still on disk. No tracked files changed in this stretch. +- **Friction:** [tooling] The leftover worktree folder could not be removed from the container (rm -rf and rmdir both fail with "Not a directory"). It was left for deletion from the workstation. +- **Learned:** Checking the registry tag list confirmed 1.1.1 and the moved tags, not just the release run result. Git had already dropped the worktree records, and the folder's contents were on main through PR #81, so removing the orphaned copy was safe. +- **Decisions:** none +- **For the repository:** [tooling] Worktrees made with EnterWorktree inside the OneDrive checkout leave folders the container cannot delete (raised as a stake for the secretary). [docs] The ssh-agent 1.1.1 release item in the backlog still needs ticking (left for the secretary). +- **Decisions needed:** Default for git:signing on Linux and macOS hosts without the git Feature volume (signing-config-scope). It stays repo until decided. +- **Measures:** $0.58 (claude-opus-5-5 $0.58; 9 replies, 930,382 tokens in, 3,277 out); list-price estimate (this stretch; model haiku wrote the entry) + +### 2026-10-26 Release notice drafted for ssh-agent 1.1.1 for other projects + +- **Session:** host (d7621154-59a4-4a54-89e4-8ccaa6012504) +- **Engineer:** operator +- **Branch:** unknown +- **Asked:** A release prompt for ssh-agent 1.1.1 that can be pasted into the office or DSNP sessions. +- **Skill:** none +- **Came out:** A paste-ready release notice in the host session, citing ssh-agent 1.1.1 (released 2026-10-26, PR #81). It covers the git:verify fix, the git:signing default and warning for Windows hosts, and the new "Signing and GitHub" section in NOTES. It also tells each receiving project how to rebuild to pick up 1.1.1 and which workarounds to remove. No files changed. +- **Friction:** not reported +- **Learned:** A release notice works best when each item names what changed and what the receiving project should do about it. +- **Decisions:** none +- **For the repository:** none +- **Decisions needed:** none +- **Measures:** counted in the first entry of this stretch, above + +### 2026-10-27 ssh-agent 1.1.1 confirmed on a consumer after rebuild, join item detailed + +- **Session:** host (57f13518-09e9-42b2-8c54-eea9caea9f5f) +- **Engineer:** operator +- **Branch:** unknown +- **Asked:** Record the agentic-operating-framework report-back on ssh-agent 1.1.1 after a rebuild. +- **Skill:** none +- **Came out:** Report-back recorded in the host session. ssh-agent 1.1.1 passes git:verify on the consumer, the dedicated signing key survived the rebuild, and signing works with SCOPE=global. No ssh-agent workarounds remain there. /workspaces/devcontainer-features/TODO.md edited: the join item now has the interactive-session output, the stderr note and seven test cases. The session-start duplicate warning still has no item. +- **Friction:** [process] The secretary's notice said the earlier paste was cut off; it was not, so the host added the missing join details itself. [docs] The consumer's session-start duplicate warning has no backlog item here. +- **Learned:** A consumer's report-back confirms the fix outside the host, using the verify output and the surviving key. Open question: what the consumer's duplicate warning does; the item cannot say what to fix until its script is seen. +- **Decisions:** none +- **For the repository:** [docs] Add a backlog item for the session-start duplicate warning in agentic-operating-framework once its script is seen (stake for the secretary). +- **Decisions needed:** signing-config-scope: default for git:signing on Linux and macOS hosts without the git Feature volume; the host suggests accepting the container's global config. Also the go-ahead to branch for the claude-host join item. +- **Measures:** $0.55 (claude-opus-5-5 $0.55; 5 replies, 290,627 tokens in, 2,609 out); list-price estimate (this stretch; model haiku wrote the entry) + +### 2026-10-27 Cloud-folder proposal written and decided + +- **Session:** host (57f13518-09e9-42b2-8c54-eea9caea9f5f) +- **Engineer:** operator +- **Branch:** feature/cloud-folder-feature/1 +- **Asked:** File the cloud-folder feature request as a proposal, check its decisions and open questions against rclone.org, and mark it decided. +- **Skill:** none +- **Came out:** docs/proposals/cloud-folder.md written, status "decided". Both tracking files marked decided. Repository checks pass. Nothing committed. +- **Friction:** not reported +- **Learned:** The rclone docs changed the request. OneDrive has no device-code sign-in, so the proposal defaults remoteType to sharepoint, the only type that supports client-credential sign-in. The launcher, not a hostPathEnv setting, supplies the host path. +- **Decisions:** "yes to all" (accept the recommendations on the proposal's decisions and open questions). +- **For the repository:** none +- **Decisions needed:** none +- **Measures:** $1.45 (claude-opus-5-5 $1.45; 14 replies, 1,123,263 tokens in, 11,876 out); cons $0.87 (general-purpose on claude-opus-5-5 $0.24, general-purpose on claude-sonnet-5-5 $0.63); list-price estimate (this stretch; model haiku wrote the entry) + +### 2026-10-27 cloud-folder slice 1 built on the feature branch, not committed + +- **Session:** con general-purpose (a22bed471af04fbc0) +- **Engineer:** operator +- **Branch:** feature/cloud-folder-feature/1 +- **Asked:** Build slice 1 of docs/proposals/cloud-folder.md: the cloud-folder Feature in host mode only, the launcher mount from DCF_HOST_CLOUD_FOLDER, and registration in the collection lists. Do not commit or push. +- **Skill:** none +- **Came out:** New src/cloud-folder/ (Feature 1.0.0: devcontainer-feature.json, install.sh, cloud-folder-setup.sh, taskfile.yml, NOTES.md, README.md) and test/cloud-folder/ (test.sh, scenarios.json, unit/status_test.sh). Changed src/devcontainer-cli/ (1.11.0 to 1.12.0, bash and PowerShell launchers, tests, NOTES, README), bootstrap/ (host module, launcher entry, Taskfile, devcontainer.json template, env.example), test/bootstrap/, README.md, .github/workflows/test.yaml, test/_global/, and Taskfile.yml. Nothing committed. task docs:check, task devhost:verify and the Docker-free tests pass. task lint fails only in the PowerShell analyzer step, which scans the git-ignored material.ignore/ folder. The four Docker scenarios were not run because there is no Docker daemon. +- **Friction:** [tooling] The PowerShell analyzer step scans git-ignored material.ignore/ (32 findings in files outside this repo). [tooling] A shellcheck SC2012 finding was fixed by swapping ls for find. [tooling] Fixture and stub quoting broke test output (echo ate a backslash, sh echo turned \t into a tab, a Pester Test-Path mock hid other calls); each was fixed in the test. [plan] The proposal's "empty by accident" rule is ambiguous; the build reads it as a missing or unmounted folder only. +- **Learned:** Feature "auto" can be decided by a real mount target in /proc/self/mountinfo, not by a folder check. The bash count check had to be relative to the run without the variable, since macOS also gets the agent socket mount. +- **Decisions:** none +- **For the repository:** [tooling] Base the lint analyzer's file list on git ls-files plus untracked, non-ignored files, or exclude *.ignore. [tooling] Run task test FEATURE=cloud-folder and task test:global in CI or on a Docker host; the mounted and mount_point scenarios are unproven. [docs] The manual Windows check from the proposal (a sync client and a path with spaces, on Docker Desktop and wslc) is still to do. +- **Decisions needed:** (1) Should a mounted but empty folder fail the status, perhaps with an opt-out? It passes with a warning now. (2) The mount target is set in two places (host setting CLOUD_FOLDER_MOUNT_POINT and the Feature's mountPoint); keep it or pass it another way? (3) A missing host folder is skipped with a warning, not created; confirm. (4) Release cloud-folder 1.0.0 and devcontainer-cli 1.12.0 after merge. The consumer can adopt only after both are published. +- **Measures:** $9.52 (claude-opus-5-5 $9.52; 73 replies, 12,350,591 tokens in, 77,598 out); list-price estimate (this stretch; model haiku wrote the entry) + +### 2026-10-27 cloud-folder mount target reworked to one setting, not committed + +- **Session:** host (57f13518-09e9-42b2-8c54-eea9caea9f5f) +- **Engineer:** operator +- **Branch:** feature/cloud-folder-feature/1 +- **Asked:** Set the mount path once: the launcher mounts DCF_HOST_CLOUD_FOLDER at a fixed path, and the Feature's mountPoint links to it. Settled on the Operator's answers to the open mount-target questions. +- **Skill:** none +- **Came out:** Working tree changes in src/cloud-folder/ (status follows the link to the real mount; build fails if mountPoint already holds files), the src/devcontainer-cli/ bash and PowerShell launchers (fixed mount path, second setting removed), bootstrap and tests, and ADR 0031 with the fixed-path decision. Feature unit 85 passed, bash launcher 92 passed, PowerShell launcher 122 passed, repository checks passing. Container tests not run (no Docker). Nothing committed. +- **Friction:** [process] The secretary reported the decisions list as cut off again; it was not. [docs] ADR 0031 had not caught up with the Operator's last decision; the host added it. +- **Learned:** A fixed mount path in the launcher leaves one place to set the path. The Feature's mountPoint link is the only setting a consumer touches. +- **Decisions:** The Operator's answers on the mount target, now recorded in ADR 0031 and docs/proposals/cloud-folder.md; the wording is in those files. +- **For the repository:** [docs] Record each Operator decision in the ADR when it is made; ADR 0031 lagged one decision. +- **Decisions needed:** Go-ahead to commit, push and open the pull request on feature/cloud-folder-feature/1. Still open unless the earlier answers covered them: whether a mounted but empty folder should fail the status, and the releases after merge. +- **Measures:** $0.78 (claude-opus-5-5 $0.78; 10 replies, 1,007,985 tokens in, 5,490 out); cons $12.94 (general-purpose on claude-opus-5-5 $12.94); list-price estimate (this stretch; model haiku wrote the entry) diff --git a/docs/ledger/costs-2026-10-operator.jsonl b/docs/ledger/costs-2026-10-operator.jsonl index 922baee..9d73721 100644 --- a/docs/ledger/costs-2026-10-operator.jsonl +++ b/docs/ledger/costs-2026-10-operator.jsonl @@ -2001,6 +2001,90 @@ {"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "96e9df0f-5f6c-43a6-9856-37908bcc4ba2", "ts": "2026-10-24T14:15:10.401Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 566, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 566, "cache_read_input_tokens": 131076, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 566, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 566, "cache_read_input_tokens": 131076, "input_tokens": 2, "output_tokens": 175, "type": "message"}], "output_tokens": 175, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} {"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "96e9df0f-5f6c-43a6-9856-37908bcc4ba2", "ts": "2026-10-24T14:15:12.421Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 218, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 218, "cache_read_input_tokens": 131642, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 218, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 218, "cache_read_input_tokens": 131642, "input_tokens": 2, "output_tokens": 126, "type": "message"}], "output_tokens": 126, "output_tokens_details": {"thinking_tokens": 16}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} {"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "96e9df0f-5f6c-43a6-9856-37908bcc4ba2", "ts": "2026-10-24T14:15:22.299Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 304, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 304, "cache_read_input_tokens": 131860, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 304, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 304, "cache_read_input_tokens": 131860, "input_tokens": 2, "output_tokens": 251, "type": "message"}], "output_tokens": 251, "output_tokens_details": {"thinking_tokens": 62}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-24T14:23:57.253Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 25355, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 25355, "cache_read_input_tokens": 28005, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 25355, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 25355, "cache_read_input_tokens": 28005, "input_tokens": 2, "output_tokens": 1038, "type": "message"}], "output_tokens": 1038, "output_tokens_details": {"thinking_tokens": 795}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-24T14:24:02.490Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 1143, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1143, "cache_read_input_tokens": 53360, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 1143, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1143, "cache_read_input_tokens": 53360, "input_tokens": 2, "output_tokens": 109, "type": "message"}], "output_tokens": 109, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-24T14:24:14.825Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 242, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 242, "cache_read_input_tokens": 54503, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 242, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 242, "cache_read_input_tokens": 54503, "input_tokens": 2, "output_tokens": 869, "type": "message"}], "output_tokens": 869, "output_tokens_details": {"thinking_tokens": 289}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-24T14:26:02.153Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 909, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 909, "cache_read_input_tokens": 54745, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 909, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 909, "cache_read_input_tokens": 54745, "input_tokens": 2, "output_tokens": 243, "type": "message"}], "output_tokens": 243, "output_tokens_details": {"thinking_tokens": 78}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-24T14:26:08.291Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 868, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 868, "cache_read_input_tokens": 55654, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 868, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 868, "cache_read_input_tokens": 55654, "input_tokens": 2, "output_tokens": 275, "type": "message"}], "output_tokens": 275, "output_tokens_details": {"thinking_tokens": 81}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-24T14:26:13.734Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 645, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 645, "cache_read_input_tokens": 56522, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 645, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 645, "cache_read_input_tokens": 56522, "input_tokens": 2, "output_tokens": 352, "type": "message"}], "output_tokens": 352, "output_tokens_details": {"thinking_tokens": 155}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-24T14:26:19.272Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 711, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 711, "cache_read_input_tokens": 57167, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 711, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 711, "cache_read_input_tokens": 57167, "input_tokens": 2, "output_tokens": 304, "type": "message"}], "output_tokens": 304, "output_tokens_details": {"thinking_tokens": 136}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-24T14:26:35.547Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 5006, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 5006, "cache_read_input_tokens": 57878, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 5006, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 5006, "cache_read_input_tokens": 57878, "input_tokens": 2, "output_tokens": 2146, "type": "message"}], "output_tokens": 2146, "output_tokens_details": {"thinking_tokens": 296}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-24T14:34:48.595Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 2876, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 2876, "cache_read_input_tokens": 62884, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 2876, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 2876, "cache_read_input_tokens": 62884, "input_tokens": 2, "output_tokens": 235, "type": "message"}], "output_tokens": 235, "output_tokens_details": {"thinking_tokens": 39}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-24T14:34:52.597Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 293, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 293, "cache_read_input_tokens": 65760, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 293, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 293, "cache_read_input_tokens": 65760, "input_tokens": 2, "output_tokens": 141, "type": "message"}], "output_tokens": 141, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-24T14:34:56.247Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 252, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 252, "cache_read_input_tokens": 66053, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 252, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 252, "cache_read_input_tokens": 66053, "input_tokens": 2, "output_tokens": 147, "type": "message"}], "output_tokens": 147, "output_tokens_details": {"thinking_tokens": 24}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-24T14:35:04.358Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 423, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 423, "cache_read_input_tokens": 66305, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 423, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 423, "cache_read_input_tokens": 66305, "input_tokens": 2, "output_tokens": 670, "type": "message"}], "output_tokens": 670, "output_tokens_details": {"thinking_tokens": 407}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-24T14:35:07.050Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 731, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 731, "cache_read_input_tokens": 66728, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 731, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 731, "cache_read_input_tokens": 66728, "input_tokens": 2, "output_tokens": 125, "type": "message"}], "output_tokens": 125, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-24T14:35:14.251Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 209, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 209, "cache_read_input_tokens": 67459, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 209, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 209, "cache_read_input_tokens": 67459, "input_tokens": 2, "output_tokens": 538, "type": "message"}], "output_tokens": 538, "output_tokens_details": {"thinking_tokens": 72}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-24T14:35:46.348Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 874, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 874, "cache_read_input_tokens": 67668, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 874, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 874, "cache_read_input_tokens": 67668, "input_tokens": 2, "output_tokens": 252, "type": "message"}], "output_tokens": 252, "output_tokens_details": {"thinking_tokens": 113}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-24T14:35:53.100Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 7536, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 7536, "cache_read_input_tokens": 68542, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 7536, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 7536, "cache_read_input_tokens": 68542, "input_tokens": 2, "output_tokens": 487, "type": "message"}], "output_tokens": 487, "output_tokens_details": {"thinking_tokens": 180}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-24T14:36:00.167Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 578, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 578, "cache_read_input_tokens": 76078, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 578, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 578, "cache_read_input_tokens": 76078, "input_tokens": 2, "output_tokens": 480, "type": "message"}], "output_tokens": 480, "output_tokens_details": {"thinking_tokens": 140}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-24T14:36:22.105Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 557, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 557, "cache_read_input_tokens": 76656, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 557, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 557, "cache_read_input_tokens": 76656, "input_tokens": 2, "output_tokens": 242, "type": "message"}], "output_tokens": 242, "output_tokens_details": {"thinking_tokens": 29}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-26T12:58:36.025Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 51297, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 51297, "cache_read_input_tokens": 26226, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 51297, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 51297, "cache_read_input_tokens": 26226, "input_tokens": 2, "output_tokens": 158, "type": "message"}], "output_tokens": 158, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-26T12:58:45.872Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 416, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 416, "cache_read_input_tokens": 77523, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 416, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 416, "cache_read_input_tokens": 77523, "input_tokens": 2, "output_tokens": 633, "type": "message"}], "output_tokens": 633, "output_tokens_details": {"thinking_tokens": 40}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-26T12:58:56.331Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 750, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 750, "cache_read_input_tokens": 77939, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 750, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 750, "cache_read_input_tokens": 77939, "input_tokens": 2, "output_tokens": 116, "type": "message"}], "output_tokens": 116, "output_tokens_details": {"thinking_tokens": 16}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-26T12:58:58.439Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 578, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 578, "cache_read_input_tokens": 78689, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 578, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 578, "cache_read_input_tokens": 78689, "input_tokens": 2, "output_tokens": 600, "type": "message"}], "output_tokens": 600, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-26T12:59:09.522Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 631, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 631, "cache_read_input_tokens": 79267, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 631, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 631, "cache_read_input_tokens": 79267, "input_tokens": 2, "output_tokens": 274, "type": "message"}], "output_tokens": 274, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-26T13:03:50.074Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 29, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 29, "cache_read_input_tokens": 80172, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 29, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 29, "cache_read_input_tokens": 80172, "input_tokens": 2, "output_tokens": 161, "type": "message"}], "output_tokens": 161, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-26T13:04:03.303Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 361, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 361, "cache_read_input_tokens": 80201, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 361, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 361, "cache_read_input_tokens": 80201, "input_tokens": 2, "output_tokens": 255, "type": "message"}], "output_tokens": 255, "output_tokens_details": {"thinking_tokens": 35}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-26T13:06:12.090Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 534, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 534, "cache_read_input_tokens": 80562, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 534, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 534, "cache_read_input_tokens": 80562, "input_tokens": 2, "output_tokens": 364, "type": "message"}], "output_tokens": 364, "output_tokens_details": {"thinking_tokens": 90}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "4f9a03e8-d3db-4e7e-b939-4de63ac232d1", "ts": "2026-10-26T13:06:17.992Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 478, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 478, "cache_read_input_tokens": 81096, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 478, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 478, "cache_read_input_tokens": 81096, "input_tokens": 2, "output_tokens": 196, "type": "message"}], "output_tokens": 196, "output_tokens_details": {"thinking_tokens": 51}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:22:18.260Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 24495, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 24495, "cache_read_input_tokens": 26960, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 24495, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 24495, "cache_read_input_tokens": 26960, "input_tokens": 2, "output_tokens": 360, "type": "message"}], "output_tokens": 360, "output_tokens_details": {"thinking_tokens": 59}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:22:30.880Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 395, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 395, "cache_read_input_tokens": 51455, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 395, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 395, "cache_read_input_tokens": 51455, "input_tokens": 2, "output_tokens": 344, "type": "message"}], "output_tokens": 344, "output_tokens_details": {"thinking_tokens": 67}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:22:37.428Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 490, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 490, "cache_read_input_tokens": 51850, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 490, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 490, "cache_read_input_tokens": 51850, "input_tokens": 2, "output_tokens": 315, "type": "message"}], "output_tokens": 315, "output_tokens_details": {"thinking_tokens": 46}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:22:44.849Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 1389, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1389, "cache_read_input_tokens": 52340, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 1389, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1389, "cache_read_input_tokens": 52340, "input_tokens": 2, "output_tokens": 386, "type": "message"}], "output_tokens": 386, "output_tokens_details": {"thinking_tokens": 152}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:22:54.877Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 1237, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1237, "cache_read_input_tokens": 53729, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 1237, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1237, "cache_read_input_tokens": 53729, "input_tokens": 2, "output_tokens": 620, "type": "message"}], "output_tokens": 620, "output_tokens_details": {"thinking_tokens": 201}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:25:23.661Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 902, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 902, "cache_read_input_tokens": 54966, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 902, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 902, "cache_read_input_tokens": 54966, "input_tokens": 2, "output_tokens": 232, "type": "message"}], "output_tokens": 232, "output_tokens_details": {"thinking_tokens": 92}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:26:24.907Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 999, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 999, "cache_read_input_tokens": 55868, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 999, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 999, "cache_read_input_tokens": 55868, "input_tokens": 2, "output_tokens": 446, "type": "message"}], "output_tokens": 446, "output_tokens_details": {"thinking_tokens": 227}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:26:30.208Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 1037, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1037, "cache_read_input_tokens": 56867, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 1037, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1037, "cache_read_input_tokens": 56867, "input_tokens": 2, "output_tokens": 202, "type": "message"}], "output_tokens": 202, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:26:33.533Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 1941, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1941, "cache_read_input_tokens": 57904, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 1941, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1941, "cache_read_input_tokens": 57904, "input_tokens": 2, "output_tokens": 182, "type": "message"}], "output_tokens": 182, "output_tokens_details": {"thinking_tokens": 119}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:26:44.599Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 628, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 628, "cache_read_input_tokens": 59845, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 628, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 628, "cache_read_input_tokens": 59845, "input_tokens": 2, "output_tokens": 304, "type": "message"}], "output_tokens": 304, "output_tokens_details": {"thinking_tokens": 83}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:26:54.558Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 2460, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 2460, "cache_read_input_tokens": 60473, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 2460, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 2460, "cache_read_input_tokens": 60473, "input_tokens": 2, "output_tokens": 240, "type": "message"}], "output_tokens": 240, "output_tokens_details": {"thinking_tokens": 45}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:27:03.900Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 4123, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 4123, "cache_read_input_tokens": 62933, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 4123, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 4123, "cache_read_input_tokens": 62933, "input_tokens": 2, "output_tokens": 570, "type": "message"}], "output_tokens": 570, "output_tokens_details": {"thinking_tokens": 197}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:27:12.959Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 2933, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 2933, "cache_read_input_tokens": 67056, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 2933, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 2933, "cache_read_input_tokens": 67056, "input_tokens": 2, "output_tokens": 258, "type": "message"}], "output_tokens": 258, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:27:27.422Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 4895, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 4895, "cache_read_input_tokens": 69989, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 4895, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 4895, "cache_read_input_tokens": 69989, "input_tokens": 2, "output_tokens": 713, "type": "message"}], "output_tokens": 713, "output_tokens_details": {"thinking_tokens": 483}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:27:33.051Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 1511, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1511, "cache_read_input_tokens": 74884, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 1511, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1511, "cache_read_input_tokens": 74884, "input_tokens": 2, "output_tokens": 766, "type": "message"}], "output_tokens": 766, "output_tokens_details": {"thinking_tokens": 30}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:28:05.867Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 1188, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1188, "cache_read_input_tokens": 76395, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 1188, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1188, "cache_read_input_tokens": 76395, "input_tokens": 2, "output_tokens": 862, "type": "message"}], "output_tokens": 862, "output_tokens_details": {"thinking_tokens": 274}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:28:14.889Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 1050, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1050, "cache_read_input_tokens": 77583, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 1050, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1050, "cache_read_input_tokens": 77583, "input_tokens": 2, "output_tokens": 645, "type": "message"}], "output_tokens": 645, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:28:22.086Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 983, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 983, "cache_read_input_tokens": 78633, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 983, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 983, "cache_read_input_tokens": 78633, "input_tokens": 2, "output_tokens": 980, "type": "message"}], "output_tokens": 980, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:28:34.895Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 1286, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1286, "cache_read_input_tokens": 79616, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 1286, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1286, "cache_read_input_tokens": 79616, "input_tokens": 2, "output_tokens": 771, "type": "message"}], "output_tokens": 771, "output_tokens_details": {"thinking_tokens": 66}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:29:01.972Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 926, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 926, "cache_read_input_tokens": 80902, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 926, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 926, "cache_read_input_tokens": 80902, "input_tokens": 2, "output_tokens": 352, "type": "message"}], "output_tokens": 352, "output_tokens_details": {"thinking_tokens": 128}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:29:07.429Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 429, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 429, "cache_read_input_tokens": 81828, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 429, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 429, "cache_read_input_tokens": 81828, "input_tokens": 2, "output_tokens": 178, "type": "message"}], "output_tokens": 178, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:29:32.521Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 383, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 383, "cache_read_input_tokens": 82257, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 383, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 383, "cache_read_input_tokens": 82257, "input_tokens": 2, "output_tokens": 154, "type": "message"}], "output_tokens": 154, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:29:36.377Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 244, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 244, "cache_read_input_tokens": 82640, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 244, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 244, "cache_read_input_tokens": 82640, "input_tokens": 2, "output_tokens": 1755, "type": "message"}], "output_tokens": 1755, "output_tokens_details": {"thinking_tokens": 24}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:30:03.202Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 2325, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 2325, "cache_read_input_tokens": 82884, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 2325, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 2325, "cache_read_input_tokens": 82884, "input_tokens": 2, "output_tokens": 324, "type": "message"}], "output_tokens": 324, "output_tokens_details": {"thinking_tokens": 85}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:35:36.972Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 805, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 805, "cache_read_input_tokens": 85209, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 805, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 805, "cache_read_input_tokens": 85209, "input_tokens": 2, "output_tokens": 874, "type": "message"}], "output_tokens": 874, "output_tokens_details": {"thinking_tokens": 185}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:38:30.069Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 1076, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1076, "cache_read_input_tokens": 86014, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 1076, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1076, "cache_read_input_tokens": 86014, "input_tokens": 2, "output_tokens": 284, "type": "message"}], "output_tokens": 284, "output_tokens_details": {"thinking_tokens": 95}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:38:51.867Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 973, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 973, "cache_read_input_tokens": 87090, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 973, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 973, "cache_read_input_tokens": 87090, "input_tokens": 2, "output_tokens": 581, "type": "message"}], "output_tokens": 581, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:41:27.398Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 810, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 810, "cache_read_input_tokens": 88063, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 810, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 810, "cache_read_input_tokens": 88063, "input_tokens": 2, "output_tokens": 277, "type": "message"}], "output_tokens": 277, "output_tokens_details": {"thinking_tokens": 36}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:41:34.981Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 1218, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1218, "cache_read_input_tokens": 88873, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 1218, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1218, "cache_read_input_tokens": 88873, "input_tokens": 2, "output_tokens": 204, "type": "message"}], "output_tokens": 204, "output_tokens_details": {"thinking_tokens": 48}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:41:38.313Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 387, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 387, "cache_read_input_tokens": 90091, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 387, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 387, "cache_read_input_tokens": 90091, "input_tokens": 2, "output_tokens": 15, "type": "message"}], "output_tokens": 15, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:41:43.216Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 379, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 379, "cache_read_input_tokens": 90478, "fallback_credit": null, "inference_geo": "global", "input_tokens": 4, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 379, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 379, "cache_read_input_tokens": 90478, "input_tokens": 4, "output_tokens": 27, "type": "message"}], "output_tokens": 27, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:41:54.444Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 398, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 398, "cache_read_input_tokens": 90857, "fallback_credit": null, "inference_geo": "global", "input_tokens": 4, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 398, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 398, "cache_read_input_tokens": 90857, "input_tokens": 4, "output_tokens": 189, "type": "message"}], "output_tokens": 189, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:42:03.314Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 450, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 450, "cache_read_input_tokens": 91255, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 450, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 450, "cache_read_input_tokens": 91255, "input_tokens": 2, "output_tokens": 454, "type": "message"}], "output_tokens": 454, "output_tokens_details": {"thinking_tokens": 250}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:42:11.159Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 983, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 983, "cache_read_input_tokens": 91705, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 983, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 983, "cache_read_input_tokens": 91705, "input_tokens": 2, "output_tokens": 832, "type": "message"}], "output_tokens": 832, "output_tokens_details": {"thinking_tokens": 367}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:42:19.097Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 1147, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1147, "cache_read_input_tokens": 92688, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 1147, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1147, "cache_read_input_tokens": 92688, "input_tokens": 2, "output_tokens": 652, "type": "message"}], "output_tokens": 652, "output_tokens_details": {"thinking_tokens": 16}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:42:27.318Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 801, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 801, "cache_read_input_tokens": 93835, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 801, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 801, "cache_read_input_tokens": 93835, "input_tokens": 2, "output_tokens": 219, "type": "message"}], "output_tokens": 219, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:43:01.902Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 353, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 353, "cache_read_input_tokens": 94636, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 353, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 353, "cache_read_input_tokens": 94636, "input_tokens": 2, "output_tokens": 1201, "type": "message"}], "output_tokens": 1201, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:43:20.886Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 1365, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1365, "cache_read_input_tokens": 94989, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 1365, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1365, "cache_read_input_tokens": 94989, "input_tokens": 2, "output_tokens": 514, "type": "message"}], "output_tokens": 514, "output_tokens_details": {"thinking_tokens": 98}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:45:09.998Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 550, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 550, "cache_read_input_tokens": 96354, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 550, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 550, "cache_read_input_tokens": 96354, "input_tokens": 2, "output_tokens": 298, "type": "message"}], "output_tokens": 298, "output_tokens_details": {"thinking_tokens": 59}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:45:16.497Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 456, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 456, "cache_read_input_tokens": 96904, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 456, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 456, "cache_read_input_tokens": 96904, "input_tokens": 2, "output_tokens": 316, "type": "message"}], "output_tokens": 316, "output_tokens_details": {"thinking_tokens": 21}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:45:21.691Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 652, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 652, "cache_read_input_tokens": 97360, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 652, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 652, "cache_read_input_tokens": 97360, "input_tokens": 2, "output_tokens": 151, "type": "message"}], "output_tokens": 151, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:45:26.515Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 184, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 184, "cache_read_input_tokens": 98012, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 184, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 184, "cache_read_input_tokens": 98012, "input_tokens": 2, "output_tokens": 199, "type": "message"}], "output_tokens": 199, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:45:30.174Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 453, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 453, "cache_read_input_tokens": 98196, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 453, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 453, "cache_read_input_tokens": 98196, "input_tokens": 2, "output_tokens": 54, "type": "message"}], "output_tokens": 54, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:45:35.850Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 420, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 420, "cache_read_input_tokens": 98649, "fallback_credit": null, "inference_geo": "global", "input_tokens": 4, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 420, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 420, "cache_read_input_tokens": 98649, "input_tokens": 4, "output_tokens": 221, "type": "message"}], "output_tokens": 221, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:45:40.737Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 262, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 262, "cache_read_input_tokens": 99069, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 262, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 262, "cache_read_input_tokens": 99069, "input_tokens": 2, "output_tokens": 252, "type": "message"}], "output_tokens": 252, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:45:46.188Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 314, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 314, "cache_read_input_tokens": 99331, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 314, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 314, "cache_read_input_tokens": 99331, "input_tokens": 2, "output_tokens": 228, "type": "message"}], "output_tokens": 228, "output_tokens_details": {"thinking_tokens": 101}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:45:49.802Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 1191, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1191, "cache_read_input_tokens": 99645, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 1191, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 1191, "cache_read_input_tokens": 99645, "input_tokens": 2, "output_tokens": 135, "type": "message"}], "output_tokens": 135, "output_tokens_details": {"thinking_tokens": 82}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:45:56.541Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 285, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 285, "cache_read_input_tokens": 100836, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 285, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 285, "cache_read_input_tokens": 100836, "input_tokens": 2, "output_tokens": 254, "type": "message"}], "output_tokens": 254, "output_tokens_details": {"thinking_tokens": 130}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:46:03.695Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 711, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 711, "cache_read_input_tokens": 101121, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 711, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 711, "cache_read_input_tokens": 101121, "input_tokens": 2, "output_tokens": 168, "type": "message"}], "output_tokens": 168, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:46:22.784Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 372, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 372, "cache_read_input_tokens": 101832, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 372, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 372, "cache_read_input_tokens": 101832, "input_tokens": 2, "output_tokens": 151, "type": "message"}], "output_tokens": 151, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:46:27.809Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 294, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 294, "cache_read_input_tokens": 102204, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 294, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 294, "cache_read_input_tokens": 102204, "input_tokens": 2, "output_tokens": 276, "type": "message"}], "output_tokens": 276, "output_tokens_details": {"thinking_tokens": 80}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:46:33.176Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 460, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 460, "cache_read_input_tokens": 102498, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 460, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 460, "cache_read_input_tokens": 102498, "input_tokens": 2, "output_tokens": 239, "type": "message"}], "output_tokens": 239, "output_tokens_details": {"thinking_tokens": 46}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:46:39.250Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 318, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 318, "cache_read_input_tokens": 102958, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 318, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 318, "cache_read_input_tokens": 102958, "input_tokens": 2, "output_tokens": 167, "type": "message"}], "output_tokens": 167, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:46:45.084Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 527, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 527, "cache_read_input_tokens": 103276, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 527, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 527, "cache_read_input_tokens": 103276, "input_tokens": 2, "output_tokens": 233, "type": "message"}], "output_tokens": 233, "output_tokens_details": {"thinking_tokens": 82}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:46:49.917Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 285, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 285, "cache_read_input_tokens": 103803, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 285, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 285, "cache_read_input_tokens": 103803, "input_tokens": 2, "output_tokens": 235, "type": "message"}], "output_tokens": 235, "output_tokens_details": {"thinking_tokens": 55}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:46:59.896Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 397, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 397, "cache_read_input_tokens": 104088, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 397, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 397, "cache_read_input_tokens": 104088, "input_tokens": 2, "output_tokens": 592, "type": "message"}], "output_tokens": 592, "output_tokens_details": {"thinking_tokens": 171}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} +{"reply": {"agent_id": "", "agent_type": "", "kind": "inter", "model": "claude-opus-5-5", "sid": "d7621154-59a4-4a54-89e4-8ccaa6012504", "ts": "2026-10-26T13:51:44.157Z", "usage": {"cache_creation": {"ephemeral_1h_input_tokens": 143, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 143, "cache_read_input_tokens": 105077, "fallback_credit": null, "inference_geo": "global", "input_tokens": 2, "iterations": [{"cache_creation": {"ephemeral_1h_input_tokens": 143, "ephemeral_5m_input_tokens": 0}, "cache_creation_input_tokens": 143, "cache_read_input_tokens": 105077, "input_tokens": 2, "output_tokens": 1216, "type": "message"}], "output_tokens": 1216, "output_tokens_details": {"thinking_tokens": 214}, "server_tool_use": {"web_fetch_requests": 0, "web_search_requests": 0}, "service_tier": "standard", "speed": "standard"}}} {"run": {"cost_usd": 0, "duration_s": 0.2, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 1382, "ts": "2026-10-20T16:50:54Z", "undone": 0}} {"run": {"cost_usd": 0, "duration_s": 2.5, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "changed", "stretch_chars": 214, "ts": "2026-10-20T16:52:41Z", "undone": 0}} {"run": {"cost_usd": 0.00294, "duration_s": 9.6, "job": "secretary", "model": "haiku", "output_tokens": 817, "stakes": 0, "status": "nothing", "stretch_chars": 1597, "ts": "2026-10-20T16:52:48Z", "undone": 0}} @@ -2616,3 +2700,42 @@ {"run": {"cost_usd": 0.01693, "duration_s": 22.6, "job": "secretary", "model": "haiku", "output_tokens": 3519, "stakes": 1, "status": "changed", "stretch_chars": 2166, "ts": "2026-10-24T14:14:12Z", "undone": 0}} {"run": {"cost_usd": 0, "duration_s": 0.3, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 1959, "ts": "2026-10-24T14:15:10Z", "undone": 0}} {"run": {"cost_usd": 0, "duration_s": 0.2, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 713, "ts": "2026-10-24T14:15:22Z", "undone": 0}} +{"run": {"cost_usd": 0.00384, "duration_s": 8.2, "job": "scribe", "model": "haiku", "output_tokens": 665, "stakes": 0, "status": "nothing", "stretch_chars": 1621, "ts": "2026-10-24T14:24:23Z", "undone": 0}} +{"run": {"cost_usd": 0, "duration_s": 10.5, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 1621, "ts": "2026-10-24T14:24:25Z", "undone": 0}} +{"run": {"cost_usd": 0.00991, "duration_s": 16.4, "job": "secretary", "model": "haiku", "output_tokens": 436, "stakes": 0, "status": "nothing", "stretch_chars": 1621, "ts": "2026-10-24T14:24:31Z", "undone": 0}} +{"run": {"cost_usd": 0, "duration_s": 0.3, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 2083, "ts": "2026-10-24T14:26:44Z", "undone": 0}} +{"run": {"cost_usd": 0.00472, "duration_s": 14.6, "job": "scribe", "model": "haiku", "output_tokens": 2398, "stakes": 1, "status": "changed", "stretch_chars": 2083, "ts": "2026-10-24T14:26:58Z", "undone": 0}} +{"run": {"cost_usd": 0.01586, "duration_s": 28.7, "job": "secretary", "model": "haiku", "output_tokens": 4400, "stakes": 0, "status": "changed", "stretch_chars": 2083, "ts": "2026-10-24T14:27:12Z", "undone": 0}} +{"run": {"cost_usd": 0, "duration_s": 0.5, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 3930, "ts": "2026-10-24T14:35:15Z", "undone": 0}} +{"run": {"cost_usd": 0.00611, "duration_s": 18.3, "job": "scribe", "model": "haiku", "output_tokens": 2655, "stakes": 0, "status": "changed", "stretch_chars": 3930, "ts": "2026-10-24T14:35:32Z", "undone": 0}} +{"run": {"cost_usd": 0.01544, "duration_s": 23.2, "job": "secretary", "model": "haiku", "output_tokens": 3467, "stakes": 0, "status": "changed", "stretch_chars": 3930, "ts": "2026-10-24T14:35:37Z", "undone": 0}} +{"run": {"cost_usd": 0, "duration_s": 0.3, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 319, "ts": "2026-10-24T14:36:22Z", "undone": 0}} +{"run": {"cost_usd": 0, "duration_s": 0.9, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 858, "ts": "2026-10-26T12:59:10Z", "undone": 0}} +{"run": {"cost_usd": 0.12135, "duration_s": 11.5, "job": "librarian", "model": "sonnet", "output_tokens": 434, "stakes": 0, "status": "nothing", "stretch_chars": 8815, "ts": "2026-10-26T12:59:21Z", "undone": 0}} +{"run": {"cost_usd": 0, "duration_s": 0.6, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 855, "ts": "2026-10-26T13:04:04Z", "undone": 0}} +{"run": {"cost_usd": 0.00628, "duration_s": 19.1, "job": "scribe", "model": "haiku", "output_tokens": 3082, "stakes": 0, "status": "changed", "stretch_chars": 2034, "ts": "2026-10-26T13:04:22Z", "undone": 0}} +{"run": {"cost_usd": 0.01189, "duration_s": 21.5, "job": "secretary", "model": "haiku", "output_tokens": 1552, "stakes": 0, "status": "pending", "stretch_chars": 2034, "ts": "2026-10-26T13:04:25Z", "undone": 0}} +{"run": {"cost_usd": 0, "duration_s": 0.6, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 680, "ts": "2026-10-26T13:06:18Z", "undone": 0}} +{"run": {"cost_usd": 0.01481, "duration_s": 18.3, "job": "secretary", "model": "haiku", "output_tokens": 1894, "stakes": 0, "status": "nothing", "stretch_chars": 2715, "ts": "2026-10-26T13:06:36Z", "undone": 0}} +{"run": {"cost_usd": 0, "duration_s": 0.3, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 884, "ts": "2026-10-26T13:22:24Z", "undone": 0}} +{"run": {"cost_usd": 0, "duration_s": 0.6, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 1431, "ts": "2026-10-26T13:22:58Z", "undone": 0}} +{"run": {"cost_usd": 0.00563, "duration_s": 14.9, "job": "scribe", "model": "haiku", "output_tokens": 1968, "stakes": 0, "status": "changed", "stretch_chars": 2316, "ts": "2026-10-26T13:23:12Z", "undone": 0}} +{"run": {"cost_usd": 0.01569, "duration_s": 20.4, "job": "secretary", "model": "haiku", "output_tokens": 2528, "stakes": 0, "status": "nothing", "stretch_chars": 2316, "ts": "2026-10-26T13:23:18Z", "undone": 0}} +{"run": {"cost_usd": 0, "duration_s": 0.6, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 665, "ts": "2026-10-26T13:25:28Z", "undone": 0}} +{"run": {"cost_usd": 0.01376, "duration_s": 25.4, "job": "secretary", "model": "haiku", "output_tokens": 2093, "stakes": 1, "status": "nothing", "stretch_chars": 665, "ts": "2026-10-26T13:25:53Z", "undone": 0}} +{"run": {"cost_usd": 0, "duration_s": 3.3, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 5829, "ts": "2026-10-26T13:35:44Z", "undone": 0}} +{"run": {"cost_usd": 0.09342, "duration_s": 12.3, "job": "librarian", "model": "sonnet", "output_tokens": 324, "stakes": 0, "status": "nothing", "stretch_chars": 8812, "ts": "2026-10-26T13:35:53Z", "undone": 0}} +{"run": {"cost_usd": 0.00569, "duration_s": 14.6, "job": "scribe", "model": "haiku", "output_tokens": 1559, "stakes": 0, "status": "nothing", "stretch_chars": 4982, "ts": "2026-10-26T13:35:55Z", "undone": 0}} +{"run": {"cost_usd": 0.01721, "duration_s": 30.5, "job": "secretary", "model": "haiku", "output_tokens": 4427, "stakes": 0, "status": "changed", "stretch_chars": 5829, "ts": "2026-10-26T13:36:11Z", "undone": 0}} +{"run": {"cost_usd": 0, "duration_s": 1.6, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 417, "ts": "2026-10-26T13:41:42Z", "undone": 0}} +{"run": {"cost_usd": 0, "duration_s": 1.2, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 82, "ts": "2026-10-26T13:41:46Z", "undone": 0}} +{"run": {"cost_usd": 0.00477, "duration_s": 13.8, "job": "scribe", "model": "haiku", "output_tokens": 1158, "stakes": 0, "status": "nothing", "stretch_chars": 1930, "ts": "2026-10-26T13:41:54Z", "undone": 0}} +{"run": {"cost_usd": 0, "duration_s": 0.7, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 2029, "ts": "2026-10-26T13:43:22Z", "undone": 0}} +{"run": {"cost_usd": 0.01117, "duration_s": 14.6, "job": "secretary", "model": "haiku", "output_tokens": 1401, "stakes": 0, "status": "nothing", "stretch_chars": 2530, "ts": "2026-10-26T13:43:36Z", "undone": 0}} +{"run": {"cost_usd": 0, "duration_s": 1.4, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 544, "ts": "2026-10-26T13:45:33Z", "undone": 0}} +{"run": {"cost_usd": 0.00591, "duration_s": 26.4, "job": "scribe", "model": "haiku", "output_tokens": 3752, "stakes": 0, "status": "changed", "stretch_chars": 2657, "ts": "2026-10-26T13:45:58Z", "undone": 0}} +{"run": {"cost_usd": 0, "duration_s": 0.5, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 2370, "ts": "2026-10-26T13:47:03Z", "undone": 0}} +{"run": {"cost_usd": 0.01599, "duration_s": 27.9, "job": "secretary", "model": "haiku", "output_tokens": 4124, "stakes": 1, "status": "changed", "stretch_chars": 2915, "ts": "2026-10-26T13:47:30Z", "undone": 0}} +{"run": {"cost_usd": 0, "duration_s": 1.2, "job": "tester", "model": "none", "output_tokens": 0, "stakes": 0, "status": "nothing", "stretch_chars": 1566, "ts": "2026-10-26T13:51:57Z", "undone": 0}} +{"run": {"cost_usd": 0.01196, "duration_s": 17.9, "job": "secretary", "model": "haiku", "output_tokens": 1495, "stakes": 0, "status": "nothing", "stretch_chars": 1566, "ts": "2026-10-26T13:52:14Z", "undone": 0}} +{"run": {"cost_usd": 0.007, "duration_s": 24.2, "job": "scribe", "model": "haiku", "output_tokens": 4251, "stakes": 0, "status": "changed", "stretch_chars": 3937, "ts": "2026-10-26T13:52:20Z", "undone": 0}} diff --git a/docs/ledger/costs-2026-10-operator.md b/docs/ledger/costs-2026-10-operator.md index a3f3711..26b7b76 100644 --- a/docs/ledger/costs-2026-10-operator.md +++ b/docs/ledger/costs-2026-10-operator.md @@ -6,20 +6,20 @@ so a rebuild loses nothing. Every figure is a list-price estimate (Opus $5/$25, Numbers and ids only. Read first by the ledger review: see "What to look for". Regenerated, never edited by hand. -Total: $425.83. Inter sessions: $341.09 in 3 sessions. Cons: $60.09 in 42 runs. Backs: $24.65 in 800 runs, 5 undone by guards. Ledger corrections: 17. +Total: $432.21. Inter sessions: $347.05 in 5 sessions. Cons: $60.09 in 42 runs. Backs: $25.06 in 839 runs, 5 undone by guards. Ledger corrections: 17. -Entry rate: 297 entries on 8 days with entries, 37.1 per day, 148.5 per week (over 2 weeks with entries). A review every 75 entries (DEVHOST_REVIEW_EVERY) comes every 2.0 days at this rate. +Entry rate: 304 entries on 9 days with entries, 33.8 per day, 101.3 per week (over 3 weeks with entries). A review every 75 entries (DEVHOST_REVIEW_EVERY) comes every 2.2 days at this rate. -Spend $425.83 of the $200 target (213%), a tracking point, not a cap. +Spend $432.21 of the $200 target (216%), a tracking point, not a cap. ## By model | Model | Inters | Cons | Backs | Total | Replies or runs | Tokens out | |---|---|---|---|---|---|---| -| haiku | $0.00 | $0.00 | $8.93 | $8.93 | 393 | 1,698,790 | -| opus | $341.09 | $5.03 | $0.00 | $346.13 | 1470 | 944,786 | -| other | $0.00 | $0.00 | $0.00 | $0.00 | 293 | 0 | -| sonnet | $0.00 | $55.05 | $15.70 | $70.76 | 1019 | 756,957 | +| haiku | $0.00 | $0.00 | $9.14 | $9.14 | 413 | 1,748,095 | +| opus | $347.05 | $5.03 | $0.00 | $352.09 | 1554 | 979,863 | +| other | $0.00 | $0.00 | $0.00 | $0.00 | 310 | 0 | +| sonnet | $0.00 | $55.05 | $15.92 | $70.97 | 1021 | 757,715 | ## Inter sessions @@ -28,6 +28,8 @@ Spend $425.83 of the $200 target (213%), a tracking point, not a cap. | d91c4e06-06f3-4fea-ad00-57cd6502eb6e | claude-opus-5-5 | 1 | $0.21 | 2026-10-19T14:23 | 2026-10-19T14:23 | | d7e03a48-93bd-44ef-a1d2-045ad84afabf | claude-opus-5-5 | 1335 | $335.44 | 2026-10-19T14:25 | 2026-10-24T13:20 | | 96e9df0f-5f6c-43a6-9856-37908bcc4ba2 | claude-opus-5-5 | 76 | $5.45 | 2026-10-24T13:33 | 2026-10-24T14:15 | +| 4f9a03e8-d3db-4e7e-b939-4de63ac232d1 | claude-opus-5-5 | 27 | $2.21 | 2026-10-24T14:23 | 2026-10-26T13:06 | +| d7621154-59a4-4a54-89e4-8ccaa6012504 | claude-opus-5-5 | 57 | $3.75 | 2026-10-26T13:22 | 2026-10-26T13:51 | ## Cons (subagents) @@ -40,10 +42,10 @@ Spend $425.83 of the $200 target (213%), a tracking point, not a cap. | Back | Model | Runs | Changed | Nothing | Pending | Error | Undone | Stakes | Cost | Per run | Per change | Avg s | Avg chars in | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| librarian | sonnet | 114 | 24 | 59 | 31 | 0 | 1 | 3 | $15.70 | $0.14 | $0.65 | 22 | 11277 | -| scribe | haiku | 217 | 152 | 65 | 0 | 0 | 0 | 0 | $3.41 | $0.02 | $0.02 | 27 | 5068 | -| secretary | haiku | 176 | 137 | 33 | 6 | 0 | 4 | 8 | $5.53 | $0.03 | $0.04 | 35 | 3526 | -| tester | none | 293 | 3 | 290 | 0 | 0 | 0 | 0 | $0.00 | $0.00 | $0.00 | 1 | 2115 | +| librarian | sonnet | 116 | 24 | 61 | 31 | 0 | 1 | 3 | $15.92 | $0.14 | $0.66 | 21 | 11235 | +| scribe | haiku | 226 | 158 | 68 | 0 | 0 | 0 | 1 | $3.46 | $0.02 | $0.02 | 27 | 4979 | +| secretary | haiku | 187 | 141 | 39 | 7 | 0 | 4 | 10 | $5.69 | $0.03 | $0.04 | 35 | 3469 | +| tester | none | 310 | 3 | 307 | 0 | 0 | 0 | 0 | $0.00 | $0.00 | $0.00 | 1 | 2084 | ## By day @@ -54,7 +56,8 @@ Spend $425.83 of the $200 target (213%), a tracking point, not a cap. | 2026-10-21 | $77.88 | $12.64 | $4.35 | $94.87 | | 2026-10-22 | $36.23 | $2.21 | $0.91 | $39.34 | | 2026-10-23 | $51.33 | $11.99 | $2.79 | $66.11 | -| 2026-10-24 | $73.86 | $17.82 | $3.77 | $95.45 | +| 2026-10-24 | $75.12 | $17.82 | $3.83 | $96.77 | +| 2026-10-26 | $4.70 | $0.00 | $0.36 | $5.06 | ## What to look for diff --git a/docs/proposals/README.md b/docs/proposals/README.md index 304ae10..4652e60 100644 --- a/docs/proposals/README.md +++ b/docs/proposals/README.md @@ -11,3 +11,4 @@ Drafts the Operator decides on. A proposal is never in force until the Operator | [Agent principles](agent-principles.md) | accepted 2026-10-19; superseded 2026-10-20 by the five generic principles in `docs/principles/agents.md` (ADR 0013) | | [Iteration package](iteration-package.md) | accepted (2026-10-16): the flow and rule (papers carry over; the foundation is merged, never nested; it holds what the iteration finds foundational, not the OS tooling); no true-up: this repository exports the first package, and its paper records what came over | | [Host profile](host-profile.md) | decided 2026-10-23: the host is an adapter owned by devcontainer-cli (ADR 0024); `HOST_TYPE` with `HOST_TYPE_PRIORITY`, `DCF_HOST_*` variables, launcher mounts, the bootstrap in `.devcontainer/cli_bootstrap.ignore/`; build not started | +| [Cloud folder](cloud-folder.md) | decided 2026-10-27: a Feature giving the container one fixed folder filled from SharePoint or OneDrive through rclone (`host`, `sync`, `mount`; `auto` in that order); `remoteType` `sharepoint`; FUSE granted by the launcher on request; three slices, build not started | diff --git a/docs/proposals/cloud-folder.md b/docs/proposals/cloud-folder.md new file mode 100644 index 0000000..9262770 --- /dev/null +++ b/docs/proposals/cloud-folder.md @@ -0,0 +1,141 @@ +# Proposal: a cloud folder in the container (SharePoint or OneDrive through rclone) + +Status: decided 2026-10-27 by the Operator: every recommendation in section 10 accepted. The build is not started (slice 1 next). Source of the need: the request of a consuming repository (the client engagement office), summarised in section 1. The design points in section 3 were agreed with the Operator before this draft; the rest was decided by the Operator on 2026-10-27 (section 10). + +## 1. The need + +The office keeps its engagement record in git. Some material must never go in git: meeting recordings, raw transcripts, unsummarised documents. That material lives in one central folder of the organisation's SharePoint document library. Agents in the container must read and write it as a normal directory. + +Today the only way is a host bind mount of the folder that OneDrive syncs on the Windows host. That ties the container to one PC's sync client and path. It cannot work where there is no Windows host: the office is moving to a Kubernetes cluster, where the container is the whole machine. + +Who this serves: the maintainer of a consuming repository who adopts the Feature, and the engineers who build it. + +Prior art (checked 2026-10-27 on containers.dev, by the request's author): two Features install the rclone binary only. None configures a remote, signs in, mounts or syncs; nothing covers OneDrive, SharePoint or FUSE. NFR-9 asks us to say this in the Feature's NOTES. + +## 2. What the Feature is + +An augmentation in the sense of CONTRIBUTING: a Feature `cloud-folder` that installs rclone only when absent (pinned, checksum-verified, NFR-3), owns the config and cache in its own volume, and gives one fixed path (option `mountPoint`, default `/mnt/cloud-folder`) filled by one of three modes. + +| Mode | How | Needs | Fits | +|---|---|---|---| +| `mount` | `rclone mount` of the remote with a VFS cache | `/dev/fuse` and `fuse3` in the container, and FUSE permission granted by the launcher | Docker hosts and clusters that allow it; live view | +| `sync` | `rclone bisync` between the remote and a folder on a named volume at the mount point, on demand and on a timer | nothing privileged | anywhere, including clusters that refuse FUSE | +| `host` | a launcher bind mount of a host path; no rclone | `DCF_HOST_CLOUD_FOLDER` set by the launcher | a Windows host with OneDrive sync, as the fallback | + +`auto` (default) uses `host` when the launcher passed a host path, else `mount` when `/dev/fuse` is usable, else `sync`, and says which it chose. + +## 3. Agreed design points + +1. **FUSE is granted per machine by the launcher, not by the Feature.** A Feature's `capAdd`, `securityOpt` and `devices` are static and would hit every consumer. So the devcontainer-cli launcher adds the runtime arguments for `mount` mode on the machines where the project asks for it, as it does for the ssh-agent socket. The Feature uses FUSE only when it is present; that is all `auto` detects. +2. **`host` mode is a launcher bind mount** driven by a `DCF_HOST_*` variable (`DCF_HOST_CLOUD_FOLDER`), passed through the repository's `containerEnv` like the others (ADR 0024). No rclone is involved. Unset means "no host folder". +3. **`sync` on a timer is a process inside the container**, which ADR 0024 allows. It starts with the container and stops cleanly. The repository's shutdown check (`shutdown:check`, see `bootstrap/SHUTDOWN.md`) refuses a stop while a sync runs or uploads are pending. The check may wait briefly, as the contract allows. +4. **Acceptance 5 is restated honestly.** `status` reports pending uploads (the VFS cache queue in `mount`, bisync state in `sync`), and the stop is refused while any are pending. We do not claim "loses nothing" on a hard kill. +5. **Token and config live in the Feature's own volume** (NFR-1), mode 600, never on a command line, never in logs. +6. **Slices** are in section 7. The office can adopt after slice 1. + +## 4. The picture + +``` + HOST (launcher: devcontainer-cli) DEVCONTAINER + ---- ------------ + task devcontainer:up + | exports DCF_HOST_CLOUD_FOLDER (host mode) + | adds --mount (host) or FUSE args (mount), per machine + v + devcontainer up ----------------------------> /mnt/cloud-folder (fixed path) + ^ + host : bind mount -------------+ + mount : rclone mount (VFS cache in volume) --+ + sync : rclone bisync timer <-> volume folder +--> SharePoint + | + volume cloud-folder-: rclone.conf (600), VFS cache, bisync state + | + task cloud-folder:status <--- shutdown:check refuses stop while pending +``` + +## 5. Options, tasks and secrets + +Options (static metadata; NFR-6: also plain variables `CLOUD_FOLDER_*`): + +| Option | Default | Meaning | +|---|---|---| +| `mode` | `auto` | `auto`, `mount`, `sync` or `host` | +| `mountPoint` | `/mnt/cloud-folder` | where the folder appears; the only place a consumer names the path. In `host` mode the launcher mounts `DCF_HOST_CLOUD_FOLDER` at the fixed `/mnt/cloud-folder-host` and the install links `mountPoint` to it (decided 2026-10-27, slice 1); `sync` and `mount` fill `mountPoint` directly, without the link | +| `remoteType` | `sharepoint` | the rclone backend (see finding 2: `onedrive` cannot use client credentials) | +| `driveType` | `documentLibrary` | `personal`, `business` or `documentLibrary` | +| `siteUrl`, `remotePath` | empty | the SharePoint site and the folder inside the library | +| `syncInterval` | `15m` | for `sync`; `0` means on demand only | +| `rcloneVersion` | pinned | the rclone version; `latest` says what it resolves to | +| `installTaskfile` | `true` | as in every Feature | + +The old request had an option `hostPathEnv`. This draft drops it: the host path comes from `DCF_HOST_CLOUD_FOLDER`, as point 2 says. + +Tasks (NFR-2, first is `version`; non-interactive except `login`, NFR-7): + +- `cloud-folder:version`, `cloud-folder:login` (interactive), `cloud-folder:status`, `cloud-folder:sync` (one bisync; the first run needs `--resync`), `cloud-folder:unmount`. +- `status` prints: mode chosen and why, remote reachable, mount point present and writable, last sync, pending uploads, no token. It exits non-zero when the mount point is empty by accident, so the office's start-up check can stop rather than let agents write into a bare directory. It also offers `--json` for agents (NFR-7). + +Secrets: + +- Per person (now): `login` runs the browser flow. Headless, the person runs `rclone authorize "onedrive"` on a machine with a browser and pastes the result (finding 1: no device-code flow is documented). +- Cluster (later): an Entra app registration limited to one site (`Sites.Selected`) with client credentials; the secret is supplied by the platform as a file or variable, never baked into an image. +- The config is `RCLONE_CONFIG=/var/lib/devcontainer-features/cloud-folder/rclone.conf`, set through `containerEnv`; a `cloud-folder-setup` post-start hook calls `dcf-persist` and `chmod 600`. Secrets are never passed with `--password` or similar flags (they show in the process list); rclone reads them from the file or from `RCLONE_*` variables in its own process. + +## 6. Findings checked against the sources (2026-10-27) + +1. **Device-code sign-in: not documented.** The OneDrive page describes the browser flow on `127.0.0.1:53682` and points headless users to the remote setup page (`rclone authorize` on a machine with a browser). A forum search found no feature request and no workaround other than pasting a token. Sources: https://rclone.org/onedrive/ , https://rclone.org/remote_setup/ . The token paste has failed for some users on version mismatches (https://forum.rclone.org/t/couldnt-decode-response-when-pasting-in-code-for-headless-authorisation/27242), so `login` should tell the person to use the same rclone version on both machines. +2. **Client credentials: supported, version not confirmed.** The OneDrive page documents an advanced option `client_credentials` ("Use client credentials OAuth flow"), set with the tenant ID, needing Application Permissions, and it lists `Files.Read.All`, `Sites.Read.All` and/or `Sites.Selected`. It says the `onedrive` type does not work with it: use `sharepoint`, or give `drive_id` by hand. Source: https://rclone.org/onedrive/ . A mirror of the upstream shows a commit "onedrive: add support for OAuth client credential flow - fixes #6197" dated December 2024 (https://git.frostfs.info/KurlesHS/rclone/commits/branch/pr-6343-onedrive-oauth-flow); the page's "added in v1.24" label is about the backend, not this option. The release that first shipped it is under "Still open". The current release is v1.75.2 (2026-10-09), https://rclone.org/changelog/ , which is far past any plausible answer, so pinning to it is safe either way. A third-party guide says app-only access needs explicit `drive_id` values because `/me/drive` does not exist without a user (https://fast.io/resources/rclone-onedrive-business/; secondary source, to verify). +3. **Bisync is not beta, but is an "advanced command".** The page says "`bisync` is considered an advanced command, so use with care"; it arrived in v1.58 and the early beta restriction (both sides need modtimes) was lifted in v1.66. Source: https://rclone.org/bisync/ . Behaviour that matters here: modtime and size are compared by default; changing `--compare` needs `--resync`; a critical error renames the listing files to `.lst-err` and blocks later runs until `--resync` (exit code 7); a lock file stops overlapping runs and by default never expires, so we set `--max-lock` (minimum 2m); a run that would delete more than 50% of files on a side aborts (`--max-delete`). Listings live in `~/.cache/rclone/bisync`, which we move into the volume. `--check-sync=only` checks listing integrity without syncing. +4. **OneDrive hashes and modtimes.** QuickXorHash is the default for all OneDrive backends since 1.62; modtimes are accurate to 1 second; setting a modtime creates a new file version and uses quota, which is why `upload_cutoff` is off by default. Source: https://rclone.org/onedrive/ . The page said nothing on bisync and OneDrive together; I read only part of the bisync page (see "Still open"). +5. **Pending uploads from `rclone mount`.** With `--rc` and a VFS cache mode above `off`, `rclone rc vfs/stats` returns `diskCache.uploadsInProgress` and `diskCache.uploadsQueued` (counters, undefined further), plus `erroredFiles`. `vfs/queue` lists the queue (name, size, tries, `uploading`). Neither call needs authentication. With cache mode `off` there is no queue. Source: https://rclone.org/rc/ . So `mount` must run with a cache mode of at least `writes` and `--rc` bound to localhost with no remote access, and `status` calls `vfs/stats`. +6. **Config write-back.** rclone must be able to write the config file, because it updates the OAuth token inside it on refresh; it writes a temp file, renames, and tries to keep the original's permissions; a symlink is resolved to its target. A config path of `""`, `notfound` or `/dev/null` keeps the config in memory only. The docs do not say what happens with a read-only file. Source: https://rclone.org/docs/ . Consequence: a token-based remote needs a writable config, so the volume is writable. For a read-only secret mount on a cluster, client credentials are the fit (no refresh token to store: the app asks for a new token); the Feature then copies the mounted file into the volume at start, or runs with the in-memory config. This is untested, so it sits under "Still open". + +## 7. Slices + +| Slice | Content | CI without a host | Manual | +|---|---|---|---| +| 1 | `host` mode, `status`, `cloud-folder-setup`, taskfile, NOTES; launcher passes `DCF_HOST_CLOUD_FOLDER` and the bind mount at the fixed `/mnt/cloud-folder-host`, `mountPoint` linked to it | `status` over a faked mount point (present, empty, unwritable); launcher argument list; Feature test with no rclone sign-in | one `up` on a Windows host with OneDrive sync | +| 2 | rclone install (pinned, verified), `login`, `sync`, the in-container timer, the `shutdown:check` hook | install test with `pinned_version`; bisync between two local folders (a local remote); stop refused while a sync runs; no token in logs or `ps` | a real sign-in, a file each way, a stop mid-sync | +| 3 | `mount`, `auto`, the launcher granting FUSE per machine, `vfs/stats` in `status` | detection of `/dev/fuse` as a pure function; `status` parser against a recorded `vfs/stats` reply | Docker Desktop and wslc: does the grant work; a cluster that refuses FUSE falls to `sync` | + +The office can adopt after slice 1: its need today is the host folder, and it moves the path to `/mnt/office-shared` with its Restricted layout pointing there. + +## 8. How the Feature meets the non-functional requirements + +| NFR | How | +|---|---| +| 1 owns-its-state | volume `cloud-folder-${devcontainerId}` at `/var/lib/devcontainer-features/cloud-folder` holds `rclone.conf`, the VFS cache and the bisync state; `containerEnv` sets `RCLONE_CONFIG`, `cloud-folder-setup` uses `dcf-persist`. The synced folder itself is a second volume at the mount point in `sync` mode | +| 2 registered-taskfile | `taskfile.yml` with the verbatim install block, `version` first, a `desc` on every task | +| 3 verified-and-pinned | rclone downloaded from the publisher's release, checked against its `SHA256SUMS`, `rcloneVersion` option, NOTES says what `latest` resolves to | +| 4 augments-not-replaces | `installsAfter` the two community rclone Features (so theirs installs first if a consumer uses it), never `dependsOn`; this Feature installs rclone only when absent | +| 5 idempotent-and-user-neutral | `install.sh` twice gives the same result; setup is idempotent and never fatal; runs without assuming `remoteUser` | +| 6 one-way-to-configure | options and `CLOUD_FOLDER_*` variables resolved CLI, then `.env`, then environment | +| 7 agent-ready | every task but `login` is non-interactive; `status --json`; `CLI_ARGS` accepted | +| 8 documented-to-the-standard | NOTES covers the ten items; the limits section carries findings 1, 2, 5 and 6 | +| 9 says-why-it-exists | one paragraph: the two community Features install the binary only | +| 10 tested-on-base-images | `test.sh`, a `pinned_version` scenario, a scenario per option, a place in the global scenario; the `mount` scenarios need `/dev/fuse` and run only where CI has it (to confirm) | + +## 9. Background process: the precedent, plainly + +There is no precedent for an entrypoint. No Feature in `src/` or `local-features/` declares `entrypoint` in its JSON. The nearest precedent is the ssh-agent Feature in key mode: its `postStartCommand` hook starts `ssh-agent -a `, which forks itself. The devhost Feature's `share/bin/entrypoints` is a directory of commands installed onto the PATH, not a container entrypoint. `common` has none either. + +So the timer follows the ssh-agent pattern: `cloud-folder-setup` runs as the `postStartCommand` and starts one supervised loop (`cloud-folder-setup run`) detached, with a PID file in the volume, so a second start does not double it. An `entrypoint` would run as PID 1 for every consumer and would collide with a repository's own; I recommend against it. The loop stops on `SIGTERM` (finishing or aborting the current bisync, which leaves the lock file, hence `--max-lock`), and `cloud-folder:status` and the shutdown check read the PID file and the bisync lock. A stop that skips the check (`FORCE=1`) or a hard kill can leave a stale lock and a listing that needs `--resync`; the next start says so. + +## 10. Decisions for the Operator + +1. Default `remoteType` `sharepoint` rather than `onedrive`, because client credentials only work with `sharepoint`. Recommended: yes. +2. `auto` order: `host`, then `mount`, then `sync`. Recommended: yes. +3. Supervise the timer from `postStartCommand`, not an `entrypoint`. Recommended: yes. +4. The launcher grants FUSE only when the project asks for it (a launcher variable, for example `DCF_HOST_FUSE=1`), never by default. Recommended: yes. +5. Slice order, with the office adopting after slice 1. Recommended: as written. +6. In `sync` mode, which side wins a conflict, and whether deletions propagate. Recommended: keep bisync's defaults (newer wins, the loser is renamed) and keep the 50% delete guard. + +## 11. Still open (unverified facts, not decisions) + +1. The first rclone release with `client_credentials` for the OneDrive and SharePoint backend. The evidence is a December 2024 commit in a mirror; the upstream merge and release tag are unconfirmed. +2. Whether `Sites.Selected` works end to end with the `sharepoint` remote (the page lists it, but the one-line mention gives no steps), and which `drive_id` is needed. +3. Bisync with SharePoint: how it treats QuickXorHash and 1-second modtimes (does `--compare size,modtime` produce false conflicts, does `--resync` need `--refresh-times`). I read the bisync page only in part; the changelog tail was not read. +4. The exact meaning of `uploadsInProgress` and `uploadsQueued` (the page gives counters only), and whether a file being written but not yet closed counts. +5. rclone's behaviour with a read-only config file during token refresh (not documented). +6. Whether `--cap-add SYS_ADMIN`, `--device /dev/fuse` and AppArmor changes are all needed on Docker Desktop and wslc, or whether `--security-opt` alone is enough; and whether a cluster's policy can grant them at all. +7. Whether CI can run a `mount` test (needs `/dev/fuse` in the test container). diff --git a/src/cloud-folder/NOTES.md b/src/cloud-folder/NOTES.md new file mode 100644 index 0000000..ee5bd42 --- /dev/null +++ b/src/cloud-folder/NOTES.md @@ -0,0 +1,157 @@ +## What it gives you + +One fixed path in the container, `/mnt/cloud-folder` by default (option `mountPoint`), for a +shared document folder that must never go in git: recordings, raw transcripts, unsummarised +documents kept in a SharePoint or OneDrive folder. Agents and people read and write it as a +normal directory, and `task cloud-folder:status` says whether it is really there before anyone +writes into it. + +This version (1.0.0) knows one source, **host mode**: the folder a sync client (OneDrive) keeps +on the host is bind-mounted by the launcher (the `devcontainer-cli` Feature and its bootstrap) +at one fixed path, the host mount `/mnt/cloud-folder-host`, and the mount point links to it. +There is no rclone, no sign-in and no token in this version. + +It installs no binary and downloads nothing, so there is nothing to verify. It adds +`cloud-folder-setup` (the `postStartCommand`, also behind the tasks) in `/usr/local/bin`, its +option defaults in `/usr/local/share/cloud-folder-feature/options.env`, the Feature Taskfile, +and, when `mountPoint` is not `/mnt/cloud-folder-host` itself, a symbolic link from `mountPoint` +to it. No folder is created at either path: when nothing is mounted the link leads nowhere and +the status fails, so no one writes into a bare folder of the container by mistake. + +## How the folder gets there (host mode) + +``` + HOST DEVCONTAINER + .devcontainer/.env: + DCF_HOST_CLOUD_FOLDER= + task devcontainer:up + launcher: devcontainer up --mount + type=bind,source=, ----> /mnt/cloud-folder-host (fixed, the host mount) + target=/mnt/cloud-folder-host ^ + and DCF_HOST_CLOUD_FOLDER in its env | symbolic link made at build + (containerEnv passes it in) /mnt/cloud-folder (mountPoint option) + ^ cloud-folder-setup (every start) + | task cloud-folder:status +``` + +1. On the host, set `DCF_HOST_CLOUD_FOLDER` to the folder's path, in `.devcontainer/.env` (or + the environment, or `task devcontainer:up DCF_HOST_CLOUD_FOLDER=...`): for example + `C:\Users\\\` on Windows. Unset means "no host folder", and the + launcher mounts nothing. +2. `task devcontainer:up` (devcontainer-cli 1.12.0 or later) adds the bind mount at + `/mnt/cloud-folder-host`, always: the path in the container is named once, in the Feature's + `mountPoint` option, which links there. The launcher never creates the host folder: when the path does not exist on this host it skips + the mount and says so, and the container starts without it (the status then fails). A path + with a comma cannot be passed to `--mount` and is skipped the same way. `HOST_MOUNTS=false` + turns this mount off with the others. `task devcontainer:host` shows what `up` would add. A + repository that bind-mounted the folder by hand drops that line from its `devcontainer.json`. +3. Map the variable with `containerEnv` so the report can name the source (the bootstrap's + `devcontainer.json` template does): `"DCF_HOST_CLOUD_FOLDER": "${localEnv:DCF_HOST_CLOUD_FOLDER}"`. + +The Feature decides nothing from the variable: "mounted" means the path the mount point leads to +(through the link, so the host mount) is the target of a mount in the container's mount table. +The variable only names the source in the report. + +## Options + +- `mode` (default `auto`): `host` expects a host folder mounted at the mount point. `auto` uses + `host` when something is mounted there; otherwise it reports "no source" and fails the status, + and never pretends. Override without a rebuild with `CLOUD_FOLDER_MODE` in `containerEnv`, or + for one call with `task cloud-folder:status CLOUD_FOLDER_MODE=host`. A value this version does + not know (`sync`, `mount`) is reported and fails the status. +- `mountPoint` (default `/mnt/cloud-folder`): where the folder appears, an absolute path, and the + only place you name it. The install makes it a symbolic link to the host mount + `/mnt/cloud-folder-host` (no link when `mountPoint` is that path). It refuses, failing the + build with the reason, when `mountPoint` already exists in the image as a folder with content + or as a file; an empty folder or an old link is replaced. `CLOUD_FOLDER_MOUNT_POINT` in + `containerEnv` makes the status look at another path without a rebuild (no link is made then). + In the `sync` and `mount` modes of a later version the folder is filled at `mountPoint` + directly and no link is made. +- `installTaskfile` (default `true`): install the Taskfile into the go-task registry. + +The options of the decided design that belong to later versions (`remoteType`, `driveType`, +`siteUrl`, `remotePath`, `syncInterval`, `rcloneVersion`) are left out of this version rather than +accepted and ignored; the `sync` and `mount` modes come with them. + +```json +"features": { + "ghcr.io/nabil-becker/devcontainer-features/go-task:1": {}, + "ghcr.io/nabil-becker/devcontainer-features/cloud-folder:1": { + "mountPoint": "/mnt/cloud-folder" + } +}, +"containerEnv": { + "DCF_HOST_CLOUD_FOLDER": "${localEnv:DCF_HOST_CLOUD_FOLDER}" +} +``` + +## State + +None. This version keeps no state of its own: the folder's content lives on the host (and in +the cloud behind the sync client), and nothing else is stored. The config volume of the design +(`rclone.conf`, the cache, the sync state) comes with rclone in a later version. + +## Check it works + +```sh +task cloud-folder:status # cloud-folder: ok: the folder is mounted and writable +task cloud-folder:status -- --json # one JSON object: mode, mount_point, real_path, linked, mounted, empty, writable, verdict, ok, hint +``` + +`status` prints the mode in use and why, whether the mount point is mounted, present but not +mounted, or missing, whether it is empty and writable, and what to do when it is not. It exits: + +- `0` when the folder is mounted and writable; an empty folder passes with a warning (right for + a new folder, a sign of a wrong path or an unfinished sync otherwise); +- `1` when nothing is mounted at the host mount (no source, or mode `host` with no mount), when the mount point + is a bare folder of the container, not a folder, not writable by this user, or the mode is not + one this version knows; so a repository's start-up check (`task cloud-folder:status` in its + own check) can stop before agents write into the wrong place; +- `2` on an unknown argument. + +It writes nothing into the folder: writability is the access check (`test -w`), so a status +never leaves a file for the sync client to upload. + +## Pairs with + +`devcontainer-cli` (the launcher that adds the bind mount from `DCF_HOST_CLOUD_FOLDER`; its +bootstrap's `devcontainer.json` template maps the variable with `containerEnv`) and `go-task` +(for the tasks). It needs nothing else; `installsAfter` orders it behind `go-task` and +`common-utils`. + +## Tasks + +`task cloud-folder:version`, `status` and `setup` (all in `cloud-folder`). `CLOUD_FOLDER_MODE` +and `CLOUD_FOLDER_MOUNT_POINT` on the command line, in a `.env` file or in the environment (in +that order) override the options for one call; `status` takes `-- --json`. + +## Limits and troubleshooting + +- **Host mode needs a host with a sync client.** It ties the container to one machine's synced + folder and path, and there is no host on a cluster; the `sync` and `mount` modes (rclone) of a + later version are the answer there. +- **"no source" or "not-mounted"** with `DCF_HOST_CLOUD_FOLDER` set: the host folder did not + exist when `up` ran (the launcher said so and skipped it), `HOST_MOUNTS=false`, or the + container was started without the launcher (opened straight in VS Code). When the host mount + is mounted but `mountPoint` does not lead there, the report says so. Fix the cause and `task devcontainer:rebuild`: + mounts are fixed when the container is created. +- **"unwritable"**: the host folder's permissions, or the engine maps the mount read-only. +- **Files on demand.** A sync client that keeps files online-only shows them in the folder but + may fetch them only when opened, which is slow through a bind mount; mark the folder "always + keep on this device" on the host. +- Not tried yet on a real Windows host with a sync client; the tests fake the mount point. + +## Why it exists + +The two Features for rclone published on containers.dev (checked 2026-10-27) install the +binary only: none configures a remote, signs in, mounts or syncs, and none covers OneDrive, +SharePoint or a host folder. This Feature gives the folder one fixed path and a status a +start-up check can trust, whatever fills it; rclone comes in a later version, installed only when +absent and ordered after those Features. + +## Version and upgrade + +`1.0.0`: the first release, host mode only (`mode` `auto` or `host`, `mountPoint` linked to the +host mount `/mnt/cloud-folder-host`, the `status`, `setup` and `version` tasks). The host side +lives in `devcontainer-cli` 1.12.0 or later (`DCF_HOST_CLOUD_FOLDER`). Pin the Feature with `:1`. A later minor +version adds the rclone modes and their options; `host` and the mount point stay as they are. diff --git a/src/cloud-folder/README.md b/src/cloud-folder/README.md new file mode 100644 index 0000000..9ce67ec --- /dev/null +++ b/src/cloud-folder/README.md @@ -0,0 +1,183 @@ + +# Cloud folder (cloud-folder) + +One fixed path in the devcontainer (mountPoint, default /mnt/cloud-folder) for a shared document folder that must never go in git, such as a SharePoint or OneDrive folder. This version knows host mode only: the devcontainer-cli launcher bind-mounts the host folder named by DCF_HOST_CLOUD_FOLDER (a folder a sync client keeps on the host) at /mnt/cloud-folder-host, and mountPoint links to it; no rclone, no sign-in. A post-start hook (cloud-folder-setup) reports on every start which mode is in use and whether the folder is mounted, empty and writable, and cloud-folder:status exits non-zero when it is missing or unwritable, so a start-up check can stop. Plus the Feature Taskfile (version, status, setup). + +## Example Usage + +```json +"features": { + "ghcr.io/nabil-becker/devcontainer-features/cloud-folder:1": {} +} +``` + +## Options + +| Options Id | Description | Type | Default Value | +|-----|-----|-----|-----| +| installTaskfile | Also install this Feature's Taskfile into /usr/local/share/go-task/includes.d so its tasks appear under the : namespace of a root Taskfile that includes /usr/local/share/go-task/features.yml (see the go-task Feature). | boolean | true | +| mode | Where the folder comes from. host: a bind mount the launcher adds from DCF_HOST_CLOUD_FOLDER (no rclone). auto: host when something is mounted at the mount point, else it reports that there is no source and never pretends. The rclone modes (sync, mount) come in a later version. Override per container with CLOUD_FOLDER_MODE in containerEnv, or per call on the task. | string | auto | +| mountPoint | Absolute path where the folder appears in the container. The launcher always mounts the host folder at /mnt/cloud-folder-host; when mountPoint is another path, the install makes it a symbolic link to that one, so this option is the only place to name the path. The install refuses a mountPoint that already exists in the image as a folder with content or as a file. | string | /mnt/cloud-folder | + +## What it gives you + +One fixed path in the container, `/mnt/cloud-folder` by default (option `mountPoint`), for a +shared document folder that must never go in git: recordings, raw transcripts, unsummarised +documents kept in a SharePoint or OneDrive folder. Agents and people read and write it as a +normal directory, and `task cloud-folder:status` says whether it is really there before anyone +writes into it. + +This version (1.0.0) knows one source, **host mode**: the folder a sync client (OneDrive) keeps +on the host is bind-mounted by the launcher (the `devcontainer-cli` Feature and its bootstrap) +at one fixed path, the host mount `/mnt/cloud-folder-host`, and the mount point links to it. +There is no rclone, no sign-in and no token in this version. + +It installs no binary and downloads nothing, so there is nothing to verify. It adds +`cloud-folder-setup` (the `postStartCommand`, also behind the tasks) in `/usr/local/bin`, its +option defaults in `/usr/local/share/cloud-folder-feature/options.env`, the Feature Taskfile, +and, when `mountPoint` is not `/mnt/cloud-folder-host` itself, a symbolic link from `mountPoint` +to it. No folder is created at either path: when nothing is mounted the link leads nowhere and +the status fails, so no one writes into a bare folder of the container by mistake. + +## How the folder gets there (host mode) + +``` + HOST DEVCONTAINER + .devcontainer/.env: + DCF_HOST_CLOUD_FOLDER= + task devcontainer:up + launcher: devcontainer up --mount + type=bind,source=, ----> /mnt/cloud-folder-host (fixed, the host mount) + target=/mnt/cloud-folder-host ^ + and DCF_HOST_CLOUD_FOLDER in its env | symbolic link made at build + (containerEnv passes it in) /mnt/cloud-folder (mountPoint option) + ^ cloud-folder-setup (every start) + | task cloud-folder:status +``` + +1. On the host, set `DCF_HOST_CLOUD_FOLDER` to the folder's path, in `.devcontainer/.env` (or + the environment, or `task devcontainer:up DCF_HOST_CLOUD_FOLDER=...`): for example + `C:\Users\\\` on Windows. Unset means "no host folder", and the + launcher mounts nothing. +2. `task devcontainer:up` (devcontainer-cli 1.12.0 or later) adds the bind mount at + `/mnt/cloud-folder-host`, always: the path in the container is named once, in the Feature's + `mountPoint` option, which links there. The launcher never creates the host folder: when the path does not exist on this host it skips + the mount and says so, and the container starts without it (the status then fails). A path + with a comma cannot be passed to `--mount` and is skipped the same way. `HOST_MOUNTS=false` + turns this mount off with the others. `task devcontainer:host` shows what `up` would add. A + repository that bind-mounted the folder by hand drops that line from its `devcontainer.json`. +3. Map the variable with `containerEnv` so the report can name the source (the bootstrap's + `devcontainer.json` template does): `"DCF_HOST_CLOUD_FOLDER": "${localEnv:DCF_HOST_CLOUD_FOLDER}"`. + +The Feature decides nothing from the variable: "mounted" means the path the mount point leads to +(through the link, so the host mount) is the target of a mount in the container's mount table. +The variable only names the source in the report. + +## Options + +- `mode` (default `auto`): `host` expects a host folder mounted at the mount point. `auto` uses + `host` when something is mounted there; otherwise it reports "no source" and fails the status, + and never pretends. Override without a rebuild with `CLOUD_FOLDER_MODE` in `containerEnv`, or + for one call with `task cloud-folder:status CLOUD_FOLDER_MODE=host`. A value this version does + not know (`sync`, `mount`) is reported and fails the status. +- `mountPoint` (default `/mnt/cloud-folder`): where the folder appears, an absolute path, and the + only place you name it. The install makes it a symbolic link to the host mount + `/mnt/cloud-folder-host` (no link when `mountPoint` is that path). It refuses, failing the + build with the reason, when `mountPoint` already exists in the image as a folder with content + or as a file; an empty folder or an old link is replaced. `CLOUD_FOLDER_MOUNT_POINT` in + `containerEnv` makes the status look at another path without a rebuild (no link is made then). + In the `sync` and `mount` modes of a later version the folder is filled at `mountPoint` + directly and no link is made. +- `installTaskfile` (default `true`): install the Taskfile into the go-task registry. + +The options of the decided design that belong to later versions (`remoteType`, `driveType`, +`siteUrl`, `remotePath`, `syncInterval`, `rcloneVersion`) are left out of this version rather than +accepted and ignored; the `sync` and `mount` modes come with them. + +```json +"features": { + "ghcr.io/nabil-becker/devcontainer-features/go-task:1": {}, + "ghcr.io/nabil-becker/devcontainer-features/cloud-folder:1": { + "mountPoint": "/mnt/cloud-folder" + } +}, +"containerEnv": { + "DCF_HOST_CLOUD_FOLDER": "${localEnv:DCF_HOST_CLOUD_FOLDER}" +} +``` + +## State + +None. This version keeps no state of its own: the folder's content lives on the host (and in +the cloud behind the sync client), and nothing else is stored. The config volume of the design +(`rclone.conf`, the cache, the sync state) comes with rclone in a later version. + +## Check it works + +```sh +task cloud-folder:status # cloud-folder: ok: the folder is mounted and writable +task cloud-folder:status -- --json # one JSON object: mode, mount_point, real_path, linked, mounted, empty, writable, verdict, ok, hint +``` + +`status` prints the mode in use and why, whether the mount point is mounted, present but not +mounted, or missing, whether it is empty and writable, and what to do when it is not. It exits: + +- `0` when the folder is mounted and writable; an empty folder passes with a warning (right for + a new folder, a sign of a wrong path or an unfinished sync otherwise); +- `1` when nothing is mounted at the host mount (no source, or mode `host` with no mount), when the mount point + is a bare folder of the container, not a folder, not writable by this user, or the mode is not + one this version knows; so a repository's start-up check (`task cloud-folder:status` in its + own check) can stop before agents write into the wrong place; +- `2` on an unknown argument. + +It writes nothing into the folder: writability is the access check (`test -w`), so a status +never leaves a file for the sync client to upload. + +## Pairs with + +`devcontainer-cli` (the launcher that adds the bind mount from `DCF_HOST_CLOUD_FOLDER`; its +bootstrap's `devcontainer.json` template maps the variable with `containerEnv`) and `go-task` +(for the tasks). It needs nothing else; `installsAfter` orders it behind `go-task` and +`common-utils`. + +## Tasks + +`task cloud-folder:version`, `status` and `setup` (all in `cloud-folder`). `CLOUD_FOLDER_MODE` +and `CLOUD_FOLDER_MOUNT_POINT` on the command line, in a `.env` file or in the environment (in +that order) override the options for one call; `status` takes `-- --json`. + +## Limits and troubleshooting + +- **Host mode needs a host with a sync client.** It ties the container to one machine's synced + folder and path, and there is no host on a cluster; the `sync` and `mount` modes (rclone) of a + later version are the answer there. +- **"no source" or "not-mounted"** with `DCF_HOST_CLOUD_FOLDER` set: the host folder did not + exist when `up` ran (the launcher said so and skipped it), `HOST_MOUNTS=false`, or the + container was started without the launcher (opened straight in VS Code). When the host mount + is mounted but `mountPoint` does not lead there, the report says so. Fix the cause and `task devcontainer:rebuild`: + mounts are fixed when the container is created. +- **"unwritable"**: the host folder's permissions, or the engine maps the mount read-only. +- **Files on demand.** A sync client that keeps files online-only shows them in the folder but + may fetch them only when opened, which is slow through a bind mount; mark the folder "always + keep on this device" on the host. +- Not tried yet on a real Windows host with a sync client; the tests fake the mount point. + +## Why it exists + +The two Features for rclone published on containers.dev (checked 2026-10-27) install the +binary only: none configures a remote, signs in, mounts or syncs, and none covers OneDrive, +SharePoint or a host folder. This Feature gives the folder one fixed path and a status a +start-up check can trust, whatever fills it; rclone comes in a later version, installed only when +absent and ordered after those Features. + +## Version and upgrade + +`1.0.0`: the first release, host mode only (`mode` `auto` or `host`, `mountPoint` linked to the +host mount `/mnt/cloud-folder-host`, the `status`, `setup` and `version` tasks). The host side +lives in `devcontainer-cli` 1.12.0 or later (`DCF_HOST_CLOUD_FOLDER`). Pin the Feature with `:1`. A later minor +version adds the rclone modes and their options; `host` and the mount point stay as they are. + + +--- + +_Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/nabil-becker/devcontainer-features/blob/main/src/cloud-folder/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/src/cloud-folder/cloud-folder-setup.sh b/src/cloud-folder/cloud-folder-setup.sh new file mode 100644 index 0000000..2ef500b --- /dev/null +++ b/src/cloud-folder/cloud-folder-setup.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# cloud-folder-setup: the cloud-folder Feature's postStartCommand (a host folder is mounted, or +# not, per start), also behind `task cloud-folder:setup`, `task cloud-folder:status` and +# `task cloud-folder:version`. +# +# cloud-folder-setup report on the folder (never fatal: exit 0) +# cloud-folder-setup status [--json] the same report; exit 0 when the folder is mounted and +# writable (empty or not), 1 when it is missing, not +# mounted, not writable, or the mode is not one this +# version knows. --json prints one JSON object instead. +# cloud-folder-setup version the Feature version and the mode and mount point in use +# cloud-folder-setup link MOUNT_POINT HOST_MOUNT +# install.sh, as root: make MOUNT_POINT a symbolic link to +# HOST_MOUNT (nothing when they are the same path) +# +# This version knows host mode only: the devcontainer-cli launcher bind-mounts the host folder +# named by DCF_HOST_CLOUD_FOLDER at one fixed path, the host mount (/mnt/cloud-folder-host), and +# install.sh links the option mountPoint to it when the two differ, so a consumer names the path +# once. "Mounted" means the path the mount point leads to (through the link) is the target of a +# mount in /proc/self/mountinfo. A folder that exists but is not mounted is a bare folder of the +# container: writing there would put files where no one else sees them, so the status fails +# rather than call it a cloud folder. +# +# Nothing is written into the folder: writability is the access check (test -w), so a status +# never leaves a file behind for a sync client to upload. +# Test hooks (not options): DCF_CLOUD_FOLDER_MOUNTINFO names the mount table to read (default +# /proc/self/mountinfo); DCF_CLOUD_FOLDER_HOST_MOUNT replaces the fixed host mount path. +set -u + +# Defaults baked by install.sh from the Feature options; CLOUD_FOLDER_* in the environment +# override them without a rebuild. +# shellcheck disable=SC1091 # written by install.sh at image build +[ -f /usr/local/share/cloud-folder-feature/options.env ] && source /usr/local/share/cloud-folder-feature/options.env +mode_option="${CLOUD_FOLDER_MODE:-${MODE:-auto}}" +mount_point="${CLOUD_FOLDER_MOUNT_POINT:-${MOUNTPOINT:-/mnt/cloud-folder}}" +feature_version="${FEATURE_VERSION:-unknown}" +host_mount="${DCF_CLOUD_FOLDER_HOST_MOUNT:-${HOST_MOUNT:-/mnt/cloud-folder-host}}" +mountinfo="${DCF_CLOUD_FOLDER_MOUNTINFO:-/proc/self/mountinfo}" +host_source="${DCF_HOST_CLOUD_FOLDER:-}" + +# A trailing slash would never match the mount table. +while [ "${#mount_point}" -gt 1 ] && [ "${mount_point%/}" != "$mount_point" ]; do mount_point="${mount_point%/}"; done + +say() { echo "cloud-folder: $*"; } + +# is_mounted : the path is the target of a mount. The mount table escapes space, tab, +# newline and backslash as octal (\040, \011, \012, \134), so the path is encoded the same way. +is_mounted() { + local enc="$1" + [ -r "$mountinfo" ] || return 1 + enc="${enc//\\/\\134}" + enc="${enc// /\\040}" + enc="${enc//$'\t'/\\011}" + enc="${enc//$'\n'/\\012}" + CF_TARGET="$enc" awk '$5 == ENVIRON["CF_TARGET"] { found = 1 } END { exit !found }' "$mountinfo" 2>/dev/null +} + +# real_path : where the path leads, links followed (the last part need not exist). +real_path() { readlink -m -- "$1" 2>/dev/null || readlink -f -- "$1" 2>/dev/null || printf '%s\n' "$1"; } + +# link_mount_point : the build-time link (install.sh runs it as root). +# Idempotent: an existing link is replaced, an empty folder gives way; a folder with content or +# any other file is refused, since replacing it would lose what is there. +link_mount_point() { + local mp="$1" hm="$2" + case "$mp" in /?*) ;; *) echo "cloud-folder: mountPoint '$mp' must be an absolute path." >&2; return 1 ;; esac + while [ "${#mp}" -gt 1 ] && [ "${mp%/}" != "$mp" ]; do mp="${mp%/}"; done + if [ "$mp" = "$hm" ]; then say "mountPoint is the host mount $hm itself: no link"; return 0; fi + if [ -L "$mp" ]; then + rm -f -- "$mp" || return 1 + elif [ -d "$mp" ]; then + if [ -n "$(find "$mp" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]; then + echo "cloud-folder: mountPoint $mp is a folder with content in the image; it cannot become the link to $hm. Choose another mountPoint, or empty that folder first." >&2 + return 1 + fi + rmdir -- "$mp" || return 1 + elif [ -e "$mp" ]; then + echo "cloud-folder: mountPoint $mp exists in the image and is not a folder or a link; it cannot become the link to $hm." >&2 + return 1 + fi + mkdir -p -- "$(dirname -- "$mp")" || return 1 + ln -s -- "$hm" "$mp" || return 1 + say "mountPoint $mp links to the host mount $hm" +} + +# assess: sets MODE_USED (host | none), WHY, REAL (where the mount point leads), LINKED, PRESENT, +# MOUNTED, IS_DIR, EMPTY (yes | no | unknown), WRITABLE, VERDICT (ok | empty | not-mounted | +# no-source | not-a-folder | unwritable | unknown-mode) and HINT. +assess() { + PRESENT=no; MOUNTED=no; IS_DIR=no; EMPTY=unknown; WRITABLE=no; HINT=''; LINKED=no + REAL="$(real_path "$mount_point")" + [ -L "$mount_point" ] && LINKED=yes + [ -e "$mount_point" ] && PRESENT=yes + [ -d "$mount_point" ] && IS_DIR=yes + is_mounted "$REAL" && MOUNTED=yes + if [ "$IS_DIR" = yes ] && [ -r "$mount_point" ] && [ -x "$mount_point" ]; then + if [ -n "$(find -H "$mount_point" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]; then EMPTY=no; else EMPTY=yes; fi + fi + [ "$IS_DIR" = yes ] && [ -w "$mount_point" ] && [ -x "$mount_point" ] && WRITABLE=yes + + case "$mode_option" in + host) + MODE_USED=host; WHY='set by the option or CLOUD_FOLDER_MODE' ;; + auto) + if [ "$MOUNTED" = yes ]; then MODE_USED=host; WHY="auto: something is mounted at $mount_point" + else MODE_USED=none; WHY="auto: nothing is mounted at $mount_point, and this version has no other source"; fi ;; + sync | mount) + MODE_USED=none; WHY="mode $mode_option is not in this version of the Feature ($feature_version), which knows auto and host; the rclone modes come later" + VERDICT=unknown-mode; return 0 ;; + *) + MODE_USED=none; WHY="mode '$mode_option' is unknown (auto or host)" + VERDICT=unknown-mode; return 0 ;; + esac + + if [ "$MOUNTED" = no ]; then + if [ "$MODE_USED" = none ]; then VERDICT=no-source; else VERDICT=not-mounted; fi + if [ -n "$host_source" ]; then + HINT="the launcher was given DCF_HOST_CLOUD_FOLDER=$host_source, but nothing is mounted here: the host folder may not exist (the launcher then skips the mount and says so), HOST_MOUNTS=false may be set, or the container was started without the launcher (task devcontainer:up, devcontainer-cli 1.12.0 or later)." + else + HINT="no host folder: set DCF_HOST_CLOUD_FOLDER= in .devcontainer/.env on the host and start with task devcontainer:up; the launcher then mounts it here (map DCF_HOST_CLOUD_FOLDER with containerEnv so this report can name it)." + fi + if [ "$REAL" != "$host_mount" ] && is_mounted "$host_mount"; then + HINT="$HINT The host mount $host_mount is mounted, but $mount_point does not lead to it (CLOUD_FOLDER_MOUNT_POINT names another path than the option mountPoint, or the image predates the link): use $host_mount, or rebuild." + fi + if [ "$PRESENT" = yes ]; then + HINT="$HINT $mount_point exists but is a bare folder of the container: do not write there." + fi + return 0 + fi + if [ "$IS_DIR" = no ]; then VERDICT=not-a-folder; HINT="$mount_point is mounted but is not a folder"; return 0; fi + if [ "$WRITABLE" = no ]; then + VERDICT=unwritable + HINT="$(id -un 2>/dev/null || id -u) cannot write to $mount_point: check the host folder's permissions (or the mount is read-only)" + return 0 + fi + if [ "$EMPTY" = yes ]; then + VERDICT=empty + HINT="the folder is mounted and writable but empty: right for a new folder; otherwise check that DCF_HOST_CLOUD_FOLDER names the synced folder and that the sync client has finished" + return 0 + fi + VERDICT=ok +} + +json_str() { # : a JSON string literal + local s="$1" + s="${s//\\/\\\\}"; s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}"; s="${s//$'\r'/\\r}"; s="${s//$'\t'/\\t}" + printf '"%s"' "$s" +} +json_bool() { if [ "$1" = yes ]; then printf true; else printf false; fi; } + +print_json() { + local empty + case "$EMPTY" in yes) empty=true ;; no) empty=false ;; *) empty=null ;; esac + printf '{"mode_option":%s,"mode":%s,"why":%s,"mount_point":%s,"real_path":%s,"linked":%s,"host_source":%s,' \ + "$(json_str "$mode_option")" "$(json_str "$MODE_USED")" "$(json_str "$WHY")" "$(json_str "$mount_point")" \ + "$(json_str "$REAL")" "$(json_bool "$LINKED")" "$(json_str "$host_source")" + printf '"present":%s,"mounted":%s,"folder":%s,"empty":%s,"writable":%s,"verdict":%s,"ok":%s,"hint":%s}\n' \ + "$(json_bool "$PRESENT")" "$(json_bool "$MOUNTED")" "$(json_bool "$IS_DIR")" "$empty" "$(json_bool "$WRITABLE")" \ + "$(json_str "$VERDICT")" "$(if verdict_ok; then printf true; else printf false; fi)" "$(json_str "$HINT")" +} + +verdict_ok() { [ "$VERDICT" = ok ] || [ "$VERDICT" = empty ]; } + +print_report() { + local state + say "mode $MODE_USED ($WHY)" + if [ "$MOUNTED" = yes ]; then state=mounted; elif [ "$PRESENT" = yes ]; then state='present, not mounted'; else state=missing; fi + if [ "$LINKED" = yes ]; then say "mount point $mount_point -> $REAL: $state" + else say "mount point $mount_point: $state"; fi + [ -n "$host_source" ] && say "host folder (DCF_HOST_CLOUD_FOLDER): $host_source" + if [ "$MOUNTED" = yes ] && [ "$IS_DIR" = yes ]; then + case "$EMPTY" in yes) state=empty ;; no) state='not empty' ;; *) state='cannot be listed' ;; esac + say "contents: $state; writable: $WRITABLE" + fi + case "$VERDICT" in + ok) say "ok: the folder is mounted and writable" ;; + empty) say "ok, but empty: ${HINT#the folder is mounted and writable but empty: }" ;; + *) say "not usable ($VERDICT): ${HINT:-$WHY}" ;; + esac +} + +cmd="${1:-report}" +[ $# -gt 0 ] && shift +case "$cmd" in + report) + assess + print_report + exit 0 ;; + status) + json=no + for a in "$@"; do + case "$a" in + --json) json=yes ;; + *) echo "cloud-folder: status: unknown argument '$a' (only --json)" >&2; exit 2 ;; + esac + done + assess + if [ "$json" = yes ]; then print_json; else print_report; fi + verdict_ok && exit 0 + exit 1 ;; + version) + say "Feature $feature_version; mode option $mode_option; mount point $mount_point; host mount $host_mount" + if command -v rclone >/dev/null 2>&1; then say "rclone on PATH (not used by this version): $(rclone version 2>/dev/null | head -n 1)" + else say "rclone: not installed (this version needs none)"; fi + exit 0 ;; + link) + [ $# -eq 2 ] || { echo "usage: cloud-folder-setup link MOUNT_POINT HOST_MOUNT" >&2; exit 2; } + link_mount_point "$1" "$2" + exit $? ;; + *) + echo "usage: cloud-folder-setup [status [--json] | version | link MOUNT_POINT HOST_MOUNT]" >&2 + exit 2 ;; +esac diff --git a/src/cloud-folder/devcontainer-feature.json b/src/cloud-folder/devcontainer-feature.json new file mode 100644 index 0000000..a1e716c --- /dev/null +++ b/src/cloud-folder/devcontainer-feature.json @@ -0,0 +1,34 @@ +{ + "id": "cloud-folder", + "version": "1.0.0", + "name": "Cloud folder", + "description": "One fixed path in the devcontainer (mountPoint, default /mnt/cloud-folder) for a shared document folder that must never go in git, such as a SharePoint or OneDrive folder. This version knows host mode only: the devcontainer-cli launcher bind-mounts the host folder named by DCF_HOST_CLOUD_FOLDER (a folder a sync client keeps on the host) at /mnt/cloud-folder-host, and mountPoint links to it; no rclone, no sign-in. A post-start hook (cloud-folder-setup) reports on every start which mode is in use and whether the folder is mounted, empty and writable, and cloud-folder:status exits non-zero when it is missing or unwritable, so a start-up check can stop. Plus the Feature Taskfile (version, status, setup).", + "documentationURL": "https://github.com/nabil-becker/devcontainer-features/tree/main/src/cloud-folder", + "licenseURL": "https://github.com/nabil-becker/devcontainer-features/blob/main/LICENSE", + "options": { + "installTaskfile": { + "type": "boolean", + "default": true, + "description": "Also install this Feature's Taskfile into /usr/local/share/go-task/includes.d so its tasks appear under the : namespace of a root Taskfile that includes /usr/local/share/go-task/features.yml (see the go-task Feature)." + }, + "mode": { + "type": "string", + "enum": [ + "auto", + "host" + ], + "default": "auto", + "description": "Where the folder comes from. host: a bind mount the launcher adds from DCF_HOST_CLOUD_FOLDER (no rclone). auto: host when something is mounted at the mount point, else it reports that there is no source and never pretends. The rclone modes (sync, mount) come in a later version. Override per container with CLOUD_FOLDER_MODE in containerEnv, or per call on the task." + }, + "mountPoint": { + "type": "string", + "default": "/mnt/cloud-folder", + "description": "Absolute path where the folder appears in the container. The launcher always mounts the host folder at /mnt/cloud-folder-host; when mountPoint is another path, the install makes it a symbolic link to that one, so this option is the only place to name the path. The install refuses a mountPoint that already exists in the image as a folder with content or as a file." + } + }, + "postStartCommand": "cloud-folder-setup", + "installsAfter": [ + "ghcr.io/nabil-becker/devcontainer-features/go-task", + "ghcr.io/devcontainers/features/common-utils" + ] +} diff --git a/src/cloud-folder/install.sh b/src/cloud-folder/install.sh new file mode 100644 index 0000000..8f093d7 --- /dev/null +++ b/src/cloud-folder/install.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Installs the cloud-folder Feature's pieces: cloud-folder-setup (the +# postStartCommand, also behind `task cloud-folder:status`) and the Feature +# Taskfile. Feature options arrive as upper-cased environment variables (see +# devcontainer-feature.json): MODE, MOUNTPOINT. +# +# This version knows host mode only (a bind mount the launcher adds), so +# nothing is downloaded and there is nothing to verify. rclone, its config +# volume and the sync and mount modes come in a later version. +# +# The launcher bind-mounts the host folder at one fixed path, the host mount +# (HOST_MOUNT below, the same constant as in both launcher twins). When the +# option mountPoint is another path, it becomes a symbolic link to the host +# mount here, so a consumer names the path once (the option). Neither path is +# created as a folder: when nothing is mounted the link dangles and the folder +# is absent, so no one writes into a bare folder of the image by mistake; +# Docker creates the host mount when the launcher mounts onto it. The rclone +# modes of a later version fill mountPoint themselves and make no link. +set -euo pipefail + +HOST_MOUNT=/mnt/cloud-folder-host + +MODE="${MODE:-auto}" +MOUNTPOINT="${MOUNTPOINT:-/mnt/cloud-folder}" + +if [ "$(id -u)" -ne 0 ]; then + echo "cloud-folder: install.sh must run as root (the devcontainer Feature lifecycle does this)." >&2 + exit 1 +fi + +case "$MODE" in + auto | host) ;; + *) echo "cloud-folder: option mode '$MODE' is not known to this version (auto or host)." >&2; exit 1 ;; +esac +case "$MOUNTPOINT" in + /?*) ;; + *) echo "cloud-folder: option mountPoint '$MOUNTPOINT' must be an absolute path." >&2; exit 1 ;; +esac + +feature_dir="$(cd "$(dirname "$0")" && pwd)" +version="$(sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$feature_dir/devcontainer-feature.json" | head -n 1)" + +# cloud-folder-setup reads its option defaults from options.env, so the hook +# and the tasks agree; CLOUD_FOLDER_* in the environment override them. +share=/usr/local/share/cloud-folder-feature +mkdir -p "$share" +{ + echo "# Written by the cloud-folder Feature's install.sh from its options; sourced by cloud-folder-setup." + printf 'MODE=%q\n' "$MODE" + printf 'MOUNTPOINT=%q\n' "$MOUNTPOINT" + printf 'FEATURE_VERSION=%q\n' "${version:-unknown}" + printf 'HOST_MOUNT=%q\n' "$HOST_MOUNT" +} > "$share/options.env" +chmod 0644 "$share/options.env" +install -m 0755 "$feature_dir/cloud-folder-setup.sh" /usr/local/bin/cloud-folder-setup + +# Host mode (all this version knows): mountPoint leads to the host mount. A refusal (a folder with +# content, or a file, already at mountPoint) fails the build with the reason. +/usr/local/bin/cloud-folder-setup link "$MOUNTPOINT" "$HOST_MOUNT" + +echo "cloud-folder: Feature ${version:-unknown}, mode $MODE, mount point $MOUNTPOINT, host mount $HOST_MOUNT" + +# ---------------------------------------------------- Feature Taskfile ---- +# Drop this Feature's taskfile.yml where the go-task Feature's registry picks +# it up (namespace = Feature id), and refresh the registry if go-task is +# already installed; otherwise go-task refreshes it when it installs. See +# the go-task Feature README for the one-line root Taskfile include. +if [ "${INSTALLTASKFILE:-true}" = "true" ]; then + includes_d=/usr/local/share/go-task/includes.d + mkdir -p "$includes_d" + install -m 0644 "$(cd "$(dirname "$0")" && pwd)/taskfile.yml" "$includes_d/cloud-folder.yml" + if command -v task-features-registry >/dev/null 2>&1; then task-features-registry; fi +fi diff --git a/src/cloud-folder/taskfile.yml b/src/cloud-folder/taskfile.yml new file mode 100644 index 0000000..5052696 --- /dev/null +++ b/src/cloud-folder/taskfile.yml @@ -0,0 +1,23 @@ +# Tasks contributed by the cloud-folder Feature (namespace: cloud-folder). +# CLOUD_FOLDER_MODE and CLOUD_FOLDER_MOUNT_POINT, from the command line, a .env file or the +# environment (in that order, as Task resolves them), override the Feature options for one call. +version: '3' + +tasks: + version: + dir: '{{.USER_WORKING_DIR}}' + desc: 'Show the cloud-folder Feature version, the mode option and the mount point in use, and whether rclone is on PATH (this version uses none).' + cmds: + - '{{if .CLOUD_FOLDER_MODE}}CLOUD_FOLDER_MODE={{shellQuote .CLOUD_FOLDER_MODE}} {{end}}{{if .CLOUD_FOLDER_MOUNT_POINT}}CLOUD_FOLDER_MOUNT_POINT={{shellQuote .CLOUD_FOLDER_MOUNT_POINT}} {{end}}cloud-folder-setup version' + + status: + dir: '{{.USER_WORKING_DIR}}' + desc: 'Report on the cloud folder: the mode in use and why, whether the mount point is mounted, empty and writable, and what to do when it is not. Exit 0 when it is mounted and writable (an empty folder is reported, not failed), 1 when it is missing, not mounted, not writable or the mode is unknown, so a start-up check can stop. `-- --json` prints one JSON object for agents. Writes nothing into the folder.' + cmds: + - '{{if .CLOUD_FOLDER_MODE}}CLOUD_FOLDER_MODE={{shellQuote .CLOUD_FOLDER_MODE}} {{end}}{{if .CLOUD_FOLDER_MOUNT_POINT}}CLOUD_FOLDER_MOUNT_POINT={{shellQuote .CLOUD_FOLDER_MOUNT_POINT}} {{end}}cloud-folder-setup status {{.CLI_ARGS}}' + + setup: + dir: '{{.USER_WORKING_DIR}}' + desc: 'Re-run the post-start hook: the same report as status, but never fatal (exit 0).' + cmds: + - '{{if .CLOUD_FOLDER_MODE}}CLOUD_FOLDER_MODE={{shellQuote .CLOUD_FOLDER_MODE}} {{end}}{{if .CLOUD_FOLDER_MOUNT_POINT}}CLOUD_FOLDER_MOUNT_POINT={{shellQuote .CLOUD_FOLDER_MOUNT_POINT}} {{end}}cloud-folder-setup' diff --git a/src/devcontainer-cli/NOTES.md b/src/devcontainer-cli/NOTES.md index 0e4887f..752c138 100644 --- a/src/devcontainer-cli/NOTES.md +++ b/src/devcontainer-cli/NOTES.md @@ -243,13 +243,14 @@ knows what kind of host it runs on, so a repository writes nothing host-specific the priority list that fits the OS is taken, because the engine is given. Unset, the engine follows the host type, so on Windows `docker` that is on PATH but whose daemon is down no longer wins over an installed `wslc.exe`. - Before it runs the CLI (`up`, `build`, `exec` and the other subcommands that take `--docker-path`) the launcher sets - `DCF_HOST_TYPE` (the resolved id), `DCF_HOST_HOME` (the host user's home) and `DCF_HOST_SSH_AGENT` - (`socket`, `none` or `key`, see "The SSH agent" below) in its own environment. A repository maps them into the + `DCF_HOST_TYPE` (the resolved id), `DCF_HOST_HOME` (the host user's home), `DCF_HOST_SSH_AGENT` + (`socket`, `none` or `key`, see "The SSH agent" below) and `DCF_HOST_CLOUD_FOLDER` (the user's own value, passed on + unchanged, see "The cloud folder" below) in its own environment. A repository maps them into the container itself with `containerEnv`, `"DCF_HOST_TYPE": "${localEnv:DCF_HOST_TYPE}"` and so on (the bootstrap's `devcontainer.json` template does). It declares no host mount: the launcher adds those (next section). Opening the folder straight in VS Code skips the launcher: the variables are then empty. - `task devcontainer:host` prints the result without starting anything: `HOST_TYPE` as resolved and why, the priority list - used, the engine command, the three `DCF_HOST_*` values and the host mounts `up` would add or skip (next section). + used, the engine command, the four `DCF_HOST_*` values and the host mounts `up` would add or skip (next section). `HOST_TYPE=` or `HOST_TYPE_PRIORITY=` on the command line try other choices. Not verified on real hosts: `wsl -e docker info` answering for the default distro; the WSL markers on every WSL version @@ -296,6 +297,20 @@ the host type needs; ADR 0024 decides it, `docs/proposals/host-profile.md` is th general-purpose and should not live in a container volume (it prints fingerprints, never key material). Both are one-shot commands; nothing stays running on the host. Not verified on a real host: `devcontainer exec` forwarding its stdin on every platform (the tests use a stub). +- **The cloud folder (1.12.0).** When `DCF_HOST_CLOUD_FOLDER` names a host folder (one a sync client keeps, such as a + SharePoint or OneDrive folder), `up` bind-mounts it at one fixed path, `/mnt/cloud-folder-host`, the `cloud-folder` + Feature's host mount (a constant in both twins and in the Feature). The Feature links its `mountPoint` option to that + path at build time, so a consumer names the path in the container once, in the option, and the launcher needs no + target setting. The variable is a setting like the others: a Task var, `.devcontainer/.env` or the environment + (PowerShell: `-CloudFolder`); the path is shell-quoted on its way, so spaces and quotes in a library's name survive. + Unset means no folder and no mount. The + folder is the user's, so it is never created: a path this launcher can check (a Windows path from Windows, an absolute + path elsewhere) that is not a folder is skipped with one warning and the container starts without it (the Feature's + `task cloud-folder:status` then fails); another form, such as a Windows path seen from Linux, goes to the engine as is. + A source with a comma is skipped the same way. `HOST_MOUNTS=false` and a + repository line for the same target suppress it like the others. `DCF_HOST_CLOUD_FOLDER` itself is passed on to the + CLI unchanged, so `containerEnv` can map it (the bootstrap's template does) and the Feature's report can name the + source. Not verified on a real host: Docker Desktop and wslc taking a Windows path with spaces from `--mount`. - **Fleet waits, reports and stops duplicates (1.11.0).** `task devcontainer:claude:fleet -- --no-wait|--stop-duplicates` reach `claude-fleet` (claude-code 1.6.0); the task description says so. - **Several Claude sessions (1.9.0).** `task devcontainer:claude:fleet` passes `SESSIONS` (set by the including Taskfile) and its arguments to the claude-code Feature's `claude-fleet` in the container (through `exec`, or directly inside); see diff --git a/src/devcontainer-cli/README.md b/src/devcontainer-cli/README.md index 4c58066..e98246f 100644 --- a/src/devcontainer-cli/README.md +++ b/src/devcontainer-cli/README.md @@ -264,13 +264,14 @@ knows what kind of host it runs on, so a repository writes nothing host-specific the priority list that fits the OS is taken, because the engine is given. Unset, the engine follows the host type, so on Windows `docker` that is on PATH but whose daemon is down no longer wins over an installed `wslc.exe`. - Before it runs the CLI (`up`, `build`, `exec` and the other subcommands that take `--docker-path`) the launcher sets - `DCF_HOST_TYPE` (the resolved id), `DCF_HOST_HOME` (the host user's home) and `DCF_HOST_SSH_AGENT` - (`socket`, `none` or `key`, see "The SSH agent" below) in its own environment. A repository maps them into the + `DCF_HOST_TYPE` (the resolved id), `DCF_HOST_HOME` (the host user's home), `DCF_HOST_SSH_AGENT` + (`socket`, `none` or `key`, see "The SSH agent" below) and `DCF_HOST_CLOUD_FOLDER` (the user's own value, passed on + unchanged, see "The cloud folder" below) in its own environment. A repository maps them into the container itself with `containerEnv`, `"DCF_HOST_TYPE": "${localEnv:DCF_HOST_TYPE}"` and so on (the bootstrap's `devcontainer.json` template does). It declares no host mount: the launcher adds those (next section). Opening the folder straight in VS Code skips the launcher: the variables are then empty. - `task devcontainer:host` prints the result without starting anything: `HOST_TYPE` as resolved and why, the priority list - used, the engine command, the three `DCF_HOST_*` values and the host mounts `up` would add or skip (next section). + used, the engine command, the four `DCF_HOST_*` values and the host mounts `up` would add or skip (next section). `HOST_TYPE=` or `HOST_TYPE_PRIORITY=` on the command line try other choices. Not verified on real hosts: `wsl -e docker info` answering for the default distro; the WSL markers on every WSL version @@ -317,6 +318,20 @@ the host type needs; ADR 0024 decides it, `docs/proposals/host-profile.md` is th general-purpose and should not live in a container volume (it prints fingerprints, never key material). Both are one-shot commands; nothing stays running on the host. Not verified on a real host: `devcontainer exec` forwarding its stdin on every platform (the tests use a stub). +- **The cloud folder (1.12.0).** When `DCF_HOST_CLOUD_FOLDER` names a host folder (one a sync client keeps, such as a + SharePoint or OneDrive folder), `up` bind-mounts it at one fixed path, `/mnt/cloud-folder-host`, the `cloud-folder` + Feature's host mount (a constant in both twins and in the Feature). The Feature links its `mountPoint` option to that + path at build time, so a consumer names the path in the container once, in the option, and the launcher needs no + target setting. The variable is a setting like the others: a Task var, `.devcontainer/.env` or the environment + (PowerShell: `-CloudFolder`); the path is shell-quoted on its way, so spaces and quotes in a library's name survive. + Unset means no folder and no mount. The + folder is the user's, so it is never created: a path this launcher can check (a Windows path from Windows, an absolute + path elsewhere) that is not a folder is skipped with one warning and the container starts without it (the Feature's + `task cloud-folder:status` then fails); another form, such as a Windows path seen from Linux, goes to the engine as is. + A source with a comma is skipped the same way. `HOST_MOUNTS=false` and a + repository line for the same target suppress it like the others. `DCF_HOST_CLOUD_FOLDER` itself is passed on to the + CLI unchanged, so `containerEnv` can map it (the bootstrap's template does) and the Feature's report can name the + source. Not verified on a real host: Docker Desktop and wslc taking a Windows path with spaces from `--mount`. - **Fleet waits, reports and stops duplicates (1.11.0).** `task devcontainer:claude:fleet -- --no-wait|--stop-duplicates` reach `claude-fleet` (claude-code 1.6.0); the task description says so. - **Several Claude sessions (1.9.0).** `task devcontainer:claude:fleet` passes `SESSIONS` (set by the including Taskfile) and its arguments to the claude-code Feature's `claude-fleet` in the container (through `exec`, or directly inside); see diff --git a/src/devcontainer-cli/devcontainer-cli.sh b/src/devcontainer-cli/devcontainer-cli.sh index c6e89ea..64432b9 100755 --- a/src/devcontainer-cli/devcontainer-cli.sh +++ b/src/devcontainer-cli/devcontainer-cli.sh @@ -44,6 +44,10 @@ # SSH_AGENT_MODE key = the user opts into a signing key kept in the container # (the ssh-agent Feature's key mode): DCF_HOST_SSH_AGENT=key and # no agent socket is mounted. Unset = the host type decides. +# DCF_HOST_CLOUD_FOLDER a host folder (one a sync client keeps) that `up` bind-mounts at +# /mnt/cloud-folder-host, the cloud-folder Feature's host mount (its +# mountPoint links there); passed on to the CLI as is. +# Unset = no host folder, no mount. # DEVCONTAINER_BUILD_LOCK 0 = do not serialise image builds (below). # DEVCONTAINER_BUILD_LOCK_FEATURES_TEST true = `features test` takes the # lock too (default false). @@ -313,31 +317,46 @@ resolve_ssh_agent() { esac } -# resolve_host_profile: HT_* plus HT_HOME and the SSH agent (HT_SSH_AGENT, HT_SSH_SOCKET). +# The cloud folder (docs/proposals/cloud-folder.md, slice 1): DCF_HOST_CLOUD_FOLDER names a host +# folder that `up` bind-mounts at one fixed path, the cloud-folder Feature's host mount; the +# Feature links its mountPoint option there, so the consumer names the path once (the option). +# The user sets the variable; the launcher passes it on unchanged, for a repository's containerEnv. +# The same constant is $script:CloudFolderTarget in DevcontainerCli.psm1 and HOST_MOUNT in the +# Feature's install.sh. +cloud_folder_target=/mnt/cloud-folder-host + +# resolve_cloud_folder: sets HT_CLOUD_FOLDER (the host path, or empty). +resolve_cloud_folder() { HT_CLOUD_FOLDER="${DCF_HOST_CLOUD_FOLDER:-}"; } + +# resolve_host_profile: HT_* plus HT_HOME, the SSH agent (HT_SSH_AGENT, HT_SSH_SOCKET) and the +# cloud folder (HT_CLOUD_FOLDER). resolve_host_profile() { resolve_host_type HT_HOME="${HOME:-}" resolve_ssh_agent + resolve_cloud_folder } # export_host_env: DCF_HOST_* into this process's environment, which the CLI inherits; # a repository maps them into the container with containerEnv. export_host_env() { resolve_host_profile - export DCF_HOST_TYPE="$HT_TYPE" DCF_HOST_HOME="$HT_HOME" DCF_HOST_SSH_AGENT="$HT_SSH_AGENT" + export DCF_HOST_TYPE="$HT_TYPE" DCF_HOST_HOME="$HT_HOME" DCF_HOST_SSH_AGENT="$HT_SSH_AGENT" DCF_HOST_CLOUD_FOLDER="$HT_CLOUD_FOLDER" } # ---------------------------------------------------------- host mounts ---- # The mounts the launcher adds to `devcontainer up` (--mount type=bind,source=..,target=..), # per host type, so a repository declares none (ADR 0024; the PowerShell twin is -# Get-HostMount). Two entries: the host home's .git-dirs at /git-dirs (slice 3) and the SSH agent -# socket at the ssh-agent Feature's fixed path where the host type has one (slice 4). +# Get-HostMount). Three entries: the host home's .git-dirs at /git-dirs (slice 3), the SSH agent +# socket at the ssh-agent Feature's fixed path where the host type has one (slice 4), and the +# host folder DCF_HOST_CLOUD_FOLDER at the cloud-folder Feature's host mount when it is set. # `up` (and so rebuild) only: `build` mounts nothing, `exec` runs in an existing container. # A mount whose target the repository's own devcontainer.json already declares is skipped # (one line says so); HOST_MOUNTS=false turns all of them off. -# host_mount_specs: prints "name|source|target" lines for HT_TYPE, HT_HOME and HT_SSH_SOCKET. -# The ssh-agent source is a socket (or Docker Desktop's path), never a folder to create. +# host_mount_specs: prints "name|source|target" lines for HT_TYPE, HT_HOME, HT_SSH_SOCKET and +# HT_CLOUD_FOLDER. The ssh-agent source is a socket (or Docker Desktop's path) and the +# cloud-folder source is the user's folder: neither is ours to create. host_mount_specs() { if [ -n "${HT_HOME:-}" ]; then case "$HT_TYPE" in @@ -348,6 +367,9 @@ host_mount_specs() { if [ "${HT_SSH_AGENT:-none}" = socket ] && [ -n "${HT_SSH_SOCKET:-}" ]; then printf 'ssh-agent|%s|%s\n' "$HT_SSH_SOCKET" "$ssh_agent_target" fi + if [ -n "${HT_CLOUD_FOLDER:-}" ]; then + printf 'cloud-folder|%s|%s\n' "$HT_CLOUD_FOLDER" "$cloud_folder_target" + fi } host_mounts_enabled() { case "${HOST_MOUNTS:-true}" in false | 0 | no) return 1 ;; esac; } @@ -429,9 +451,18 @@ host_mount_args() { case "$source" in *,*) echo "host mount $name skipped: the source '$source' contains a comma, which --mount cannot carry." >&2; continue ;; esac - if [ "$name" != ssh-agent ]; then # a socket is not ours to create - mkdir -p "$source" 2>/dev/null || echo "host mount: could not create $source" >&2 - fi + case "$name" in + ssh-agent) ;; # a socket is not ours to create + cloud-folder) # the user's folder: never created (an empty one would hide a wrong path) + # Only a path of this host can be checked; another form (a Windows path) goes to the engine as is. + case "$source" in + /*) if [ ! -d "$source" ]; then + echo "host mount cloud-folder skipped: DCF_HOST_CLOUD_FOLDER '$source' is not a folder on this host; the container starts without it (task cloud-folder:status says so)." >&2 + continue + fi ;; + esac ;; + *) mkdir -p "$source" 2>/dev/null || echo "host mount: could not create $source" >&2 ;; + esac HM_ARGS+=(--mount "type=bind,source=$source,target=$target") done } @@ -449,6 +480,7 @@ mode_host() { # [workspace] [cli args...] echo "DCF_HOST_TYPE : $HT_TYPE" echo "DCF_HOST_HOME : $HT_HOME" echo "DCF_HOST_SSH_AGENT : $HT_SSH_AGENT" + echo "DCF_HOST_CLOUD_FOLDER: ${HT_CLOUD_FOLDER:-(unset)}" resolve_host_mounts "$ws" "$@" if [ "$HM_ENABLED" = false ]; then echo 'host mounts : off (HOST_MOUNTS=false)'; return 0; fi if [ $(( ${#HM_ADD[@]} + ${#HM_SKIP[@]} )) -eq 0 ]; then echo 'host mounts : none'; return 0; fi diff --git a/src/devcontainer-cli/devcontainer-feature.json b/src/devcontainer-cli/devcontainer-feature.json index e6f8416..6f85787 100644 --- a/src/devcontainer-cli/devcontainer-feature.json +++ b/src/devcontainer-cli/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "devcontainer-cli", - "version": "1.11.0", + "version": "1.12.0", "name": "Dev Container CLI", "description": "Installs @devcontainers/cli as /usr/local/bin/devcontainer, backed by a private, sha256-verified Node.js under /opt/devcontainer-cli that is NOT added to PATH - so `devcontainer up/build/exec/features test` work inside a container without a system-wide Node.js. Pair with docker-in-docker (needed for features test and nested `up` on Windows/macOS hosts) or docker-outside-of-docker (Linux hosts, same paths).", "documentationURL": "https://github.com/nabil-becker/devcontainer-features/tree/main/src/devcontainer-cli", diff --git a/test/_global/all_features.sh b/test/_global/all_features.sh index f69052d..ab38ccc 100755 --- a/test/_global/all_features.sh +++ b/test/_global/all_features.sh @@ -22,6 +22,8 @@ check "git wrapper" bash -c "[ \"\$(command -v git)\" = /usr/local/bin/git ] && check "gh" gh --version check "ssh-agent-setup" command -v ssh-agent-setup check "ssh-add" command -v ssh-add +check "cloud-folder-setup" command -v cloud-folder-setup +check "cloud-folder status fails with nothing mounted" bash -c "! cloud-folder-setup status" # shellcheck disable=SC2016 # expansion is meant to happen inside the inner bash check "SSH_AUTH_SOCK is the Feature's fixed path" bash -c '[ "$SSH_AUTH_SOCK" = /var/lib/devcontainer-features/ssh-agent/agent.sock ]' check "az" bash -c "az version -o tsv | cut -f1 | grep -E '^[0-9]'" @@ -47,8 +49,8 @@ check "ssh-agent key volume present" test -d /var/lib/devcontainer-features/ssh- registry=/usr/local/share/go-task/features.yml # shellcheck disable=SC2016 # expansion is meant to happen inside the inner bash -check "registry lists every Feature" bash -c 'for f in common go-task claude-code devcontainer-cli coder-cli openbao velero kustomize kubeconform argocd dsc git gh ssh-agent az kube-state; do grep -q "^ $f:" /usr/local/share/go-task/features.yml || { echo "missing $f"; exit 1; }; done' -check "feature tasks resolve through the registry" bash -c "task -t $registry --list-all | grep -c -E '^\\* (dsc:get|velero:backups|kustomize:build:all|argocd:apps|openbao:status|coder-cli:workspaces|claude-code:host|devcontainer-cli:version|kubeconform:validate:kustomize|go-task:registered|git:setup|gh:status|ssh-agent:check|az:account|kube-state:contexts|common:persist):' | grep -qx 16" +check "registry lists every Feature" bash -c 'for f in common go-task claude-code devcontainer-cli coder-cli openbao velero kustomize kubeconform argocd dsc git gh ssh-agent cloud-folder az kube-state; do grep -q "^ $f:" /usr/local/share/go-task/features.yml || { echo "missing $f"; exit 1; }; done' +check "feature tasks resolve through the registry" bash -c "task -t $registry --list-all | grep -c -E '^\\* (dsc:get|velero:backups|kustomize:build:all|argocd:apps|openbao:status|coder-cli:workspaces|claude-code:host|devcontainer-cli:version|kubeconform:validate:kustomize|go-task:registered|git:setup|gh:status|ssh-agent:check|cloud-folder:status|az:account|kube-state:contexts|common:persist):' | grep -qx 17" check "feature task runs" task -t "$registry" dsc:version check "registered list task" bash -c "task -t $registry go-task:registered | grep -qx dsc" diff --git a/test/_global/scenarios.json b/test/_global/scenarios.json index 71180ce..89125ed 100644 --- a/test/_global/scenarios.json +++ b/test/_global/scenarios.json @@ -20,6 +20,7 @@ "git": {}, "gh": {}, "ssh-agent": {}, + "cloud-folder": {}, "az": {}, "kube-state": {} }, diff --git a/test/bootstrap/DevcontainerCli.Tests.ps1 b/test/bootstrap/DevcontainerCli.Tests.ps1 index e636322..7bacec7 100644 --- a/test/bootstrap/DevcontainerCli.Tests.ps1 +++ b/test/bootstrap/DevcontainerCli.Tests.ps1 @@ -437,7 +437,7 @@ Describe 'Host type (HOST_TYPE, HOST_TYPE_PRIORITY, DCF_HOST_*)' { Describe 'Host mounts (HOST_MOUNTS, --mount on up)' { # Same evidence as the host type tests: the OS, the engine, mocked inside the module. BeforeAll { - $script:EnvNames = 'HOST_TYPE', 'HOST_TYPE_PRIORITY', 'HOST_MOUNTS', 'DEVCONTAINER_DOCKER_PATH', 'DCF_HOST_TYPE', 'DCF_HOST_HOME', 'DCF_HOST_SSH_AGENT', 'SSH_AGENT_MODE', 'SSH_AUTH_SOCK', 'USERPROFILE', 'HOME' + $script:EnvNames = 'HOST_TYPE', 'HOST_TYPE_PRIORITY', 'HOST_MOUNTS', 'DEVCONTAINER_DOCKER_PATH', 'DCF_HOST_TYPE', 'DCF_HOST_HOME', 'DCF_HOST_SSH_AGENT', 'SSH_AGENT_MODE', 'SSH_AUTH_SOCK', 'USERPROFILE', 'HOME', 'DCF_HOST_CLOUD_FOLDER', 'CLOUD_FOLDER_MOUNT_POINT' $script:SavedEnv = @{} foreach ($n in $script:EnvNames) { $script:SavedEnv[$n] = [Environment]::GetEnvironmentVariable($n) } $script:SavedPath = $env:PATH @@ -557,6 +557,81 @@ Describe 'Host mounts (HOST_MOUNTS, --mount on up)' { Test-Path $src | Should -BeFalse } + It 'lists the cloud-folder mount only with a -CloudFolder source, always at the fixed host mount' { + $m = @(Get-HostMount -Type 'windows-docker' -HostHome 'C:\Users\test' -CloudFolder 'C:\Users\test\Synced - Docs\Shared') + $m.Count | Should -Be 2 + $m[1].Name | Should -Be 'cloud-folder' + $m[1].Target | Should -Be '/mnt/cloud-folder-host' + ConvertTo-MountArgument -Mount $m[1] | Should -Be 'type=bind,source=C:\Users\test\Synced - Docs\Shared,target=/mnt/cloud-folder-host' + (@(Get-HostMount -Type 'linux' -HostHome '' -CloudFolder '/srv/docs'))[0].Target | Should -Be '/mnt/cloud-folder-host' + @(Get-HostMount -Type 'windows-docker' -HostHome 'C:\Users\test' -CloudFolder '').Count | Should -Be 1 + } + + It 'passes the cloud-folder --mount for () with : ' -ForEach @( + @{ Os = 'windows'; Id = 'windows-docker'; Source = 'C:\Users\test\Synced - Docs\Shared'; Exists = $true; Expect = 'mounted' } + @{ Os = 'windows'; Id = 'windows-wslc'; Source = 'C:\Users\test\Synced - Docs\Shared'; Exists = $true; Expect = 'mounted' } + @{ Os = 'windows'; Id = 'windows-docker'; Source = 'C:\Users\test\Missing'; Exists = $false; Expect = 'skipped' } + @{ Os = 'windows'; Id = 'windows-wsl-docker'; Source = '/home/wsluser/docs'; Exists = $false; Expect = 'mounted' } + @{ Os = 'linux'; Id = 'linux'; Source = 'C:\Users\test\Synced - Docs\Shared'; Exists = $false; Expect = 'mounted' } + @{ Os = 'linux'; Id = 'linux'; Source = '/srv/missing-docs'; Exists = $false; Expect = 'skipped' } + ) { + Initialize-MountEvidence -Os $Os -Wslc 'C:\wslc.exe' + $script:CloudExists = $Exists + Mock -ModuleName DevcontainerCli Test-Path { $script:CloudExists } -ParameterFilter { $PathType -eq 'Container' } + Mock -ModuleName DevcontainerCli Test-Path { Microsoft.PowerShell.Management\Test-Path @PesterBoundParameters } -ParameterFilter { $PathType -ne 'Container' } + $env:HOST_TYPE = $Id + $env:DCF_HOST_CLOUD_FOLDER = $Source + $ws = Get-MountWorkspace '{ "name": "plain" }' + $out = @(Get-HostMountArgument -WorkspaceFolder $ws 3>&1) + $cloud = @($out | Where-Object { $_ -is [string] -and $_ -like '*target=/mnt/cloud-folder-host' }) + $warnings = @($out | Where-Object { $_ -is [System.Management.Automation.WarningRecord] } | ForEach-Object { $_.Message }) + if ($Expect -eq 'mounted') { + $cloud | Should -HaveCount 1 + $cloud[0] | Should -Be "type=bind,source=$Source,target=/mnt/cloud-folder-host" + $warnings | Should -HaveCount 0 + } + else { + $cloud | Should -HaveCount 0 + $warnings | Should -Contain "host mount cloud-folder skipped: DCF_HOST_CLOUD_FOLDER '$Source' is not a folder on this host; the container starts without it (task cloud-folder:status says so)." + } + } + + It 'the cloud-folder target is fixed (CLOUD_FOLDER_MOUNT_POINT is no launcher setting); HOST_MOUNTS=false and a repository mount of the target suppress it' { + Initialize-MountEvidence -Os linux + $env:HOST_TYPE = 'linux' + $env:DCF_HOST_CLOUD_FOLDER = 'C:\Users\test\Shared' + $ws = Get-MountWorkspace '{ }' + $env:CLOUD_FOLDER_MOUNT_POINT = '/mnt/office-shared' + @(Get-HostMountArgument -WorkspaceFolder $ws) | Should -Contain 'type=bind,source=C:\Users\test\Shared,target=/mnt/cloud-folder-host' + $env:CLOUD_FOLDER_MOUNT_POINT = $null + $env:HOST_MOUNTS = 'false' + @(Get-HostMountArgument -WorkspaceFolder $ws) | Should -HaveCount 0 + $env:HOST_MOUNTS = $null + $own = Get-MountWorkspace '{ "mounts": ["source=C:/x,target=/mnt/cloud-folder-host,type=bind"] }' + @(Get-HostMountArgument -WorkspaceFolder $own 6>$null) | Should -Not -Contain 'type=bind,source=C:\Users\test\Shared,target=/mnt/cloud-folder-host' + } + + It 'sets DCF_HOST_CLOUD_FOLDER unchanged for the CLI, and host prints it' { + Initialize-MountEvidence -Os windows -Wslc 'C:\wslc.exe' + $env:HOST_TYPE = 'windows-wslc' + $env:DCF_HOST_CLOUD_FOLDER = 'C:\Users\test\Synced - Docs\Shared' + (Initialize-HostEnvironment).CloudFolder | Should -Be 'C:\Users\test\Synced - Docs\Shared' + $env:DCF_HOST_CLOUD_FOLDER | Should -Be 'C:\Users\test\Synced - Docs\Shared' + $text = (Show-HostProfile -WorkspaceFolder (Get-MountWorkspace '{ }') 6>&1 3>$null | ForEach-Object { $_.MessageData.Message }) -join "`n" + $text | Should -Match 'DCF_HOST_CLOUD_FOLDER: C:\\Users\\test\\Synced - Docs\\Shared' + $env:DCF_HOST_CLOUD_FOLDER = $null + Clear-HostProfileCache + $text = (Show-HostProfile -WorkspaceFolder (Get-MountWorkspace '{ }') 6>&1 | ForEach-Object { $_.MessageData.Message }) -join "`n" + $text | Should -Match 'DCF_HOST_CLOUD_FOLDER: \(unset\)' + } + + It 'Initialize-HostMountSource leaves a cloud-folder source alone (the user''s folder is never created)' { + $src = Join-Path $TestDrive 'cloud-folder-source' + $m = [pscustomobject]@{ Name = 'cloud-folder'; Source = $src; Target = '/mnt/cloud-folder-host'; Type = 'bind' } + Initialize-HostMountSource -Mount $m -Type linux + Test-Path $src | Should -BeFalse + } + It 'passes --mount for with the home of that host type' -ForEach @( @{ Os = 'windows'; Id = 'windows-docker'; Source = 'C:\Users\test/.git-dirs' } @{ Os = 'windows'; Id = 'windows-wslc'; Source = 'C:\Users\test/.git-dirs' } @@ -639,7 +714,7 @@ Describe 'Host mounts (HOST_MOUNTS, --mount on up)' { # arguments (the Windows branch runs the portable node.exe instead, so these run on Linux/macOS). Describe 'The up argument list carries the host mounts' -Skip:$IsWindows { BeforeAll { - $script:EnvNames = 'HOST_TYPE', 'HOST_MOUNTS', 'DEVCONTAINER_DOCKER_PATH', 'HOME', 'SSH_AUTH_SOCK', 'SSH_AGENT_MODE', 'DCF_HOST_TYPE', 'DCF_HOST_HOME', 'DCF_HOST_SSH_AGENT' + $script:EnvNames = 'HOST_TYPE', 'HOST_MOUNTS', 'DEVCONTAINER_DOCKER_PATH', 'HOME', 'SSH_AUTH_SOCK', 'SSH_AGENT_MODE', 'DCF_HOST_TYPE', 'DCF_HOST_HOME', 'DCF_HOST_SSH_AGENT', 'DCF_HOST_CLOUD_FOLDER', 'CLOUD_FOLDER_MOUNT_POINT' $script:SavedEnv = @{} foreach ($n in $script:EnvNames) { $script:SavedEnv[$n] = [Environment]::GetEnvironmentVariable($n) } $script:SavedPath = $env:PATH @@ -694,6 +769,20 @@ Describe 'The up argument list carries the host mounts' -Skip:$IsWindows { $a[0] | Should -Be 'up' } + It 'up gets --mount for DCF_HOST_CLOUD_FOLDER at the Feature host mount, and none without it' { + (Invoke-Recorded -Subcommand up) | Should -Not -Contain 'type=bind,source=,target=/mnt/cloud-folder-host' + @((Invoke-Recorded -Subcommand up) | Where-Object { $_ -like '*target=/mnt/cloud-folder-host' }) | Should -HaveCount 0 + $docs = Join-Path $TestDrive 'Synced Docs' + New-Item -ItemType Directory -Path $docs -Force | Out-Null + $env:DCF_HOST_CLOUD_FOLDER = $docs + $a = Invoke-Recorded -Subcommand up + $a | Should -Contain "type=bind,source=$docs,target=/mnt/cloud-folder-host" + $a[[array]::IndexOf($a, "type=bind,source=$docs,target=/mnt/cloud-folder-host") - 1] | Should -Be '--mount' + # The stub records with sh's echo, which reads \t and the like: the path has none of them. + $env:DCF_HOST_CLOUD_FOLDER = 'C:\Users\Synced - Docs\Shared' + (Invoke-Recorded -Subcommand up) | Should -Contain 'type=bind,source=C:\Users\Synced - Docs\Shared,target=/mnt/cloud-folder-host' + } + It 'up passes no --mount with HOST_MOUNTS=false' { $env:HOST_MOUNTS = 'false' (Invoke-Recorded -Subcommand up) | Should -Not -Contain '--mount' diff --git a/test/bootstrap/host_mounts_test.sh b/test/bootstrap/host_mounts_test.sh index 44b87ec..c14468a 100644 --- a/test/bootstrap/host_mounts_test.sh +++ b/test/bootstrap/host_mounts_test.sh @@ -4,8 +4,9 @@ # tests 'Host mounts' in DevcontainerCli.Tests.ps1: the --mount argument per host type (linux, # macos, windows-wsl-docker), the source folder created, a mount skipped when the repository's # devcontainer.json already declares its target (JSONC: comments, trailing commas, objects, -# --config), HOST_MOUNTS=false, no mount for build/exec/read-configuration, and what `host` -# lists. A stub `devcontainer` first on PATH records its arguments, one per line. +# --config), HOST_MOUNTS=false, no mount for build/exec/read-configuration, what `host` +# lists, the SSH agent socket mount, and the cloud folder (DCF_HOST_CLOUD_FOLDER, a Windows path, +# a missing folder, the fixed target). A stub `devcontainer` first on PATH records its arguments, one per line. # Part of `task lint` (`task test:host-mounts`). CLI_SH points the tests at another copy of the # script (used to show a test fails when the code it covers is broken). set -u @@ -165,6 +166,47 @@ ws="$(new_ws "$plain")"; run linux "$ws" -- host check 'host shows DCF_HOST_SSH_AGENT: none without a socket' 'DCF_HOST_SSH_AGENT : none' "$(grep '^DCF_HOST_SSH_AGENT' <<<"$out")" ws="$(new_ws "$plain")"; run linux "$ws" SSH_AUTH_SOCK="$sock" SSH_AGENT_MODE=key -- host check 'host shows DCF_HOST_SSH_AGENT: key' 'DCF_HOST_SSH_AGENT : key' "$(grep '^DCF_HOST_SSH_AGENT' <<<"$out")" +# --- the cloud folder (cloud-folder proposal, slice 1): DCF_HOST_CLOUD_FOLDER at the Feature's fixed host mount +cf_target=/mnt/cloud-folder-host +cf_mounts() { grep -c "target=$cf_target\$" <<<"$args"; } +mkdir -p "$T/Shared Docs" +cf="$T/Shared Docs" +for os in linux macos wsl; do + ws="$(new_ws "$plain")"; run "$os" "$ws" -- up + check "$os: no DCF_HOST_CLOUD_FOLDER, no cloud-folder mount" 0 "$(cf_mounts)" + n0="$(grep -c -x -- '--mount' <<<"$args")" + ws="$(new_ws "$plain")"; run "$os" "$ws" DCF_HOST_CLOUD_FOLDER="$cf" -- up + check "$os: DCF_HOST_CLOUD_FOLDER is mounted at the Feature's host mount" "type=bind,source=$cf,target=$cf_target" "$(grep -x -- "type=bind,source=$cf,target=$cf_target" <<<"$args")" + check "$os: the cloud folder is one more --mount, the others kept" $((n0 + 1)) "$(grep -c -x -- '--mount' <<<"$args")" +done +win='C:\Users\someone\Synced Library - Docs\Shared' +ws="$(new_ws "$plain")"; run linux "$ws" DCF_HOST_CLOUD_FOLDER="$win" -- up +check 'a Windows path goes to the engine as is (backslashes and spaces kept, one argument)' "type=bind,source=$win,target=$cf_target" "$(grep -x -F -- "type=bind,source=$win,target=$cf_target" <<<"$args")" +check 'and is not created on this host' 0 "$(grep -c 'could not create' <<<"$out")" +ws="$(new_ws "$plain")"; run linux "$ws" DCF_HOST_CLOUD_FOLDER="$T/no-such-folder" -- up +check 'a host path that does not exist: no mount' 0 "$(cf_mounts)" +check 'and one line says so' 1 "$(grep -c "host mount cloud-folder skipped: DCF_HOST_CLOUD_FOLDER '$T/no-such-folder' is not a folder on this host" <<<"$out")" +check 'and the folder is not created' no "$([ -e "$T/no-such-folder" ] && echo yes || echo no)" +check 'and up still runs' up "$(head -n1 <<<"$args")" +ws="$(new_ws "$plain")"; run linux "$ws" DCF_HOST_CLOUD_FOLDER="$cf" CLOUD_FOLDER_MOUNT_POINT=/mnt/office-shared -- up +check 'the target is fixed: CLOUD_FOLDER_MOUNT_POINT is not a launcher setting' "type=bind,source=$cf,target=$cf_target" "$(grep -x -- "type=bind,source=$cf,target=$cf_target" <<<"$args")" +ws="$(new_ws "$plain")"; run linux "$ws" DCF_HOST_CLOUD_FOLDER='C:\a,b' -- up +check 'a source with a comma: no mount' 0 "$(cf_mounts)" +ws="$(new_ws "$plain")"; run linux "$ws" DCF_HOST_CLOUD_FOLDER="$cf" HOST_MOUNTS=false -- up +check 'HOST_MOUNTS=false suppresses the cloud-folder mount too' 0 "$(grep -c -x -- '--mount' <<<"$args")" +ws="$(new_ws '{ "mounts": ["source=C:/x,target=/mnt/cloud-folder-host,type=bind"] }')"; run linux "$ws" DCF_HOST_CLOUD_FOLDER="$cf" -- up +check 'a repository that mounts the target itself keeps its own line' 0 "$(cf_mounts)" +check 'and one line says the launcher skipped its own' "host mount skipped: $cf_target is already mounted by this repository's devcontainer.json" "$(grep 'host mount skipped' <<<"$out")" +for sub in build exec; do + ws="$(new_ws "$plain")"; run linux "$ws" DCF_HOST_CLOUD_FOLDER="$cf" -- "$sub" + check "$sub: no cloud-folder mount" 0 "$(cf_mounts)" +done +ws="$(new_ws "$plain")"; run linux "$ws" DCF_HOST_CLOUD_FOLDER="$cf" -- host +check 'host lists the cloud-folder mount' "host mount (add) : cloud-folder: type=bind,source=$cf,target=$cf_target" "$(grep 'host mount (add) : cloud-folder' <<<"$out")" +check 'host shows DCF_HOST_CLOUD_FOLDER' "DCF_HOST_CLOUD_FOLDER: $cf" "$(grep '^DCF_HOST_CLOUD_FOLDER' <<<"$out")" +ws="$(new_ws "$plain")"; run linux "$ws" -- host +check 'host shows DCF_HOST_CLOUD_FOLDER unset' 'DCF_HOST_CLOUD_FOLDER: (unset)' "$(grep '^DCF_HOST_CLOUD_FOLDER' <<<"$out")" + # what the CLI is handed in its environment # shellcheck disable=SC2016 # the stub's own variable printf '#!/bin/sh\nenv | grep "^DCF_HOST_SSH_AGENT=" > "$DCF_TEST_RECORD"\n' > "$bin/devcontainer" @@ -176,6 +218,12 @@ ws="$(new_ws "$plain")"; run linux "$ws" SSH_AGENT_MODE=key SSH_AUTH_SOCK="$sock check 'the CLI sees DCF_HOST_SSH_AGENT=key' DCF_HOST_SSH_AGENT=key "$args" ws="$(new_ws "$plain")"; run linux "$ws" -- up check 'the CLI sees DCF_HOST_SSH_AGENT=none' DCF_HOST_SSH_AGENT=none "$args" +# shellcheck disable=SC2016 # the stub's own variable +printf '#!/bin/sh\nenv | grep "^DCF_HOST_CLOUD_FOLDER=" > "$DCF_TEST_RECORD"\n' > "$bin/devcontainer" +ws="$(new_ws "$plain")"; run linux "$ws" DCF_HOST_CLOUD_FOLDER="$win" -- up +check 'the CLI sees DCF_HOST_CLOUD_FOLDER unchanged (containerEnv maps it)' "DCF_HOST_CLOUD_FOLDER=$win" "$args" +ws="$(new_ws "$plain")"; run linux "$ws" -- up +check 'the CLI sees DCF_HOST_CLOUD_FOLDER empty when unset' 'DCF_HOST_CLOUD_FOLDER=' "$args" echo echo "$pass passed, $fail failed" diff --git a/test/cloud-folder/fixed_path.sh b/test/cloud-folder/fixed_path.sh new file mode 100644 index 0000000..b50b388 --- /dev/null +++ b/test/cloud-folder/fixed_path.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Scenario 'fixed_path' (see scenarios.json): mountPoint is the host mount itself +# (/mnt/cloud-folder-host), so the install makes no link and creates nothing. +set -e +# shellcheck disable=SC1091 # provided by the devcontainer CLI at test time +source dev-container-features-test-lib + +check "no link at the host mount" bash -c "! test -L /mnt/cloud-folder-host" +check "nothing created at the host mount" bash -c "! test -e /mnt/cloud-folder-host" +check "no link at the default path either" bash -c "! test -e /mnt/cloud-folder && ! test -L /mnt/cloud-folder" +check "status names the host mount, no arrow" bash -c "cloud-folder-setup status | grep -F 'mount point /mnt/cloud-folder-host: missing'" + +reportResults diff --git a/test/cloud-folder/mount_point.sh b/test/cloud-folder/mount_point.sh new file mode 100644 index 0000000..cb55a2a --- /dev/null +++ b/test/cloud-folder/mount_point.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Scenario 'mount_point' (see scenarios.json): mode host and mountPoint /mnt/shared, as vscode. +# /mnt/shared links to the host mount. Nothing is mounted, so status fails as not-mounted; then a +# mount table faked through the test hook DCF_CLOUD_FOLDER_MOUNTINFO lists a root-owned folder at +# the host mount: present through the link, but not writable. +set -e +# shellcheck disable=SC1091 # provided by the devcontainer CLI at test time +source dev-container-features-test-lib + +check "options baked" bash -c "grep -qx 'MODE=host' /usr/local/share/cloud-folder-feature/options.env && grep -qx 'MOUNTPOINT=/mnt/shared' /usr/local/share/cloud-folder-feature/options.env" +check "mode host with nothing mounted fails" bash -c "! cloud-folder-setup status" +check "mountPoint links to the host mount" bash -c "[ \"\$(readlink /mnt/shared)\" = /mnt/cloud-folder-host ]" +check "and says not-mounted at the option's path" bash -c "cloud-folder-setup status | grep -F 'mount point /mnt/shared -> /mnt/cloud-folder-host: missing' && cloud-folder-setup status | grep -F 'not usable (not-mounted)'" + +sudo mkdir -p /mnt/cloud-folder-host +sudo chmod 0755 /mnt/cloud-folder-host +fake="$(mktemp)" +echo '30 22 0:50 /shared /mnt/cloud-folder-host rw,relatime - fakefs host rw' > "$fake" +check "a mounted folder vscode cannot write fails" bash -c "! DCF_CLOUD_FOLDER_MOUNTINFO=$fake cloud-folder-setup status" +check "and says unwritable" bash -c "DCF_CLOUD_FOLDER_MOUNTINFO=$fake cloud-folder-setup status | grep -F 'not usable (unwritable)'" +check "the post-start report still exits 0" bash -c "DCF_CLOUD_FOLDER_MOUNTINFO=$fake cloud-folder-setup >/dev/null" +check "CLOUD_FOLDER_MOUNT_POINT overrides the option" bash -c "CLOUD_FOLDER_MOUNT_POINT=/elsewhere cloud-folder-setup status | grep -F 'mount point /elsewhere: missing'" +check "task status fails too (a start-up check can stop)" bash -c "! task -t /usr/local/share/go-task/includes.d/cloud-folder.yml status" + +reportResults diff --git a/test/cloud-folder/mounted.sh b/test/cloud-folder/mounted.sh new file mode 100644 index 0000000..16c845c --- /dev/null +++ b/test/cloud-folder/mounted.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Scenario 'mounted' (see scenarios.json): a real mount (a named volume, as root) at the host mount +# /mnt/cloud-folder-host stands in for the launcher's bind mount of the host folder; the default +# mountPoint /mnt/cloud-folder links to it. auto must choose host; the folder is reported empty and +# then not empty, through the link; status passes. +set -e +# shellcheck disable=SC1091 # provided by the devcontainer CLI at test time +source dev-container-features-test-lib + +check "the host mount is a mount" bash -c "awk '\$5 == \"/mnt/cloud-folder-host\"' /proc/self/mountinfo | grep -q ." +check "the mount point links to it" bash -c "[ \"\$(readlink /mnt/cloud-folder)\" = /mnt/cloud-folder-host ]" +check "auto chooses host" bash -c "cloud-folder-setup status | grep -F 'mode host (auto: something is mounted at /mnt/cloud-folder)'" +check "an empty mounted folder passes with a warning" bash -c "cloud-folder-setup status | grep -F 'ok, but empty'" +check "status passes when mounted and writable" cloud-folder-setup status +check "status wrote nothing" bash -c "[ -z \"\$(ls -A /mnt/cloud-folder)\" ]" +echo note > /mnt/cloud-folder/minutes.txt +check "a file written through the link is in the host mount" grep -qx note /mnt/cloud-folder-host/minutes.txt +check "a mounted folder with a file is ok" bash -c "cloud-folder-setup status | grep -F 'ok: the folder is mounted and writable'" +check "status --json says ok" bash -c "cloud-folder-setup status --json | grep -F '\"ok\":true'" +check "task cloud-folder:status passes" task -t /usr/local/share/go-task/includes.d/cloud-folder.yml status +rm -f /mnt/cloud-folder/minutes.txt + +reportResults diff --git a/test/cloud-folder/no_taskfile.sh b/test/cloud-folder/no_taskfile.sh new file mode 100644 index 0000000..80a0af6 --- /dev/null +++ b/test/cloud-folder/no_taskfile.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Scenario 'no_taskfile' (see scenarios.json): installTaskfile=false leaves the Taskfile out. +set -e +# shellcheck disable=SC1091 # provided by the devcontainer CLI at test time +source dev-container-features-test-lib + +check "taskfile not registered" bash -c "! test -e /usr/local/share/go-task/includes.d/cloud-folder.yml" +check "cloud-folder-setup still installed" command -v cloud-folder-setup + +reportResults diff --git a/test/cloud-folder/scenarios.json b/test/cloud-folder/scenarios.json new file mode 100644 index 0000000..ba47e8e --- /dev/null +++ b/test/cloud-folder/scenarios.json @@ -0,0 +1,46 @@ +{ + "mounted": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "go-task": {}, + "cloud-folder": {} + }, + "mounts": [ + { + "source": "cloud-folder-test-mounted", + "target": "/mnt/cloud-folder-host", + "type": "volume" + } + ], + "remoteUser": "root" + }, + "mount_point": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "go-task": {}, + "cloud-folder": { + "mode": "host", + "mountPoint": "/mnt/shared" + } + }, + "remoteUser": "vscode" + }, + "fixed_path": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "cloud-folder": { + "mountPoint": "/mnt/cloud-folder-host" + } + }, + "remoteUser": "vscode" + }, + "no_taskfile": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "cloud-folder": { + "installTaskfile": false + } + }, + "remoteUser": "vscode" + } +} diff --git a/test/cloud-folder/test.sh b/test/cloud-folder/test.sh new file mode 100644 index 0000000..f907c43 --- /dev/null +++ b/test/cloud-folder/test.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Auto-generated scenario: the cloud-folder Feature with default options (mode auto, mountPoint +# /mnt/cloud-folder) and nothing mounted: the status must say there is no source and fail. Run with: +# devcontainer features test -f cloud-folder --skip-scenarios . +set -e +# shellcheck disable=SC1091 # provided by the devcontainer CLI at test time +source dev-container-features-test-lib + +check "cloud-folder-setup on PATH" command -v cloud-folder-setup +check "cloud-folder-setup is root-owned" bash -c "[ \"\$(stat -c %U \"\$(command -v cloud-folder-setup)\")\" = root ]" +check "options baked for cloud-folder-setup" bash -c "grep -qx 'MODE=auto' /usr/local/share/cloud-folder-feature/options.env && grep -qx 'MOUNTPOINT=/mnt/cloud-folder' /usr/local/share/cloud-folder-feature/options.env" +check "the Feature version is baked" grep -qx 'FEATURE_VERSION=1.0.0' /usr/local/share/cloud-folder-feature/options.env +check "the host mount path is baked" grep -qx 'HOST_MOUNT=/mnt/cloud-folder-host' /usr/local/share/cloud-folder-feature/options.env +check "the mount point is a link to the host mount" bash -c "[ \"\$(readlink /mnt/cloud-folder)\" = /mnt/cloud-folder-host ]" +check "the host mount is not created by the image" bash -c "! test -e /mnt/cloud-folder-host" +check "version names the mount point" bash -c "cloud-folder-setup version | grep -F 'mount point /mnt/cloud-folder'" +check "the post-start report exits 0 and reports" bash -c "cloud-folder-setup | grep -E '^cloud-folder: '" +check "status fails with no source" bash -c "! cloud-folder-setup status" +check "status says no source" bash -c "cloud-folder-setup status | grep -F 'not usable (no-source)'" +check "status shows the link" bash -c "cloud-folder-setup status | grep -F 'mount point /mnt/cloud-folder -> /mnt/cloud-folder-host: missing'" +check "status --json is JSON with verdict no-source" bash -c "cloud-folder-setup status --json | grep -F '\"verdict\":\"no-source\"'" +check "CLOUD_FOLDER_MODE=sync is refused by this version" bash -c "CLOUD_FOLDER_MODE=sync cloud-folder-setup status | grep -F 'not in this version'" +check "rclone is not installed by this version" bash -c "! command -v rclone" + +check "taskfile registered" test -f /usr/local/share/go-task/includes.d/cloud-folder.yml + +reportResults diff --git a/test/cloud-folder/unit/status_test.sh b/test/cloud-folder/unit/status_test.sh new file mode 100644 index 0000000..f645313 --- /dev/null +++ b/test/cloud-folder/unit/status_test.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +# Docker-free tests of cloud-folder-setup (src/cloud-folder/cloud-folder-setup.sh) over a faked +# mount point: the mount table is a file (DCF_CLOUD_FOLDER_MOUNTINFO) that lists, or does not +# list, a temp folder as a mount target, and DCF_CLOUD_FOLDER_HOST_MOUNT moves the fixed host +# mount into the temp folder, so no mount and no privilege is needed. Covered: the build-time +# link from mountPoint to the host mount (`link`) and status through it; no +# source (auto, nothing mounted), mounted and not empty, mounted and empty, mounted and not +# writable (skipped as root, which may write anywhere), a bare folder that exists but is not +# mounted, mode host with nothing mounted (and the DCF_HOST_CLOUD_FOLDER hint), modes this +# version does not know, a path with a space and a trailing slash, a mounted file, --json, the +# post-start report never failing, nothing written into the folder, and the Feature Taskfile +# handing CLOUD_FOLDER_* and CLI_ARGS to the script. Part of `task lint` +# (`task test:cloud-folder-unit`). SETUP_SH points the tests at another copy of the script. +set -u +exec /dev/null; rm -rf "$T"' EXIT + +pass=0 fail=0 +check() { # + if [ "$2" = "$3" ]; then pass=$((pass + 1)); echo "ok - $1" + else fail=$((fail + 1)); echo "FAIL - $1 (expected '$2', got '$3')"; fi +} +has() { # + if grep -qF -- "$2" <<<"$3"; then pass=$((pass + 1)); echo "ok - $1" + else fail=$((fail + 1)); echo "FAIL - $1 (no '$2' in: $3)"; fi +} + +# mount_table : a mount table listing each path as a mount target, escaped as the +# kernel escapes it. +mount_table() { + local p e + : > "$T/mountinfo" + echo '22 1 8:1 / / rw,relatime - ext4 /dev/sda1 rw' >> "$T/mountinfo" + for p in "$@"; do + e="${p//\\/\\134}"; e="${e// /\\040}" + printf '30 22 0:50 /shared %s rw,relatime - fakefs host rw\n' "$e" >> "$T/mountinfo" + done +} + +# run [VAR=value ...] --