diff --git a/ansible/playbooks/provision-agent-host.yml b/ansible/playbooks/provision-agent-host.yml index 8206d91..c6c59d2 100644 --- a/ansible/playbooks/provision-agent-host.yml +++ b/ansible/playbooks/provision-agent-host.yml @@ -56,6 +56,12 @@ # Environment variable on the control node: # multica_pat: "{{ lookup('env', 'MULTICA_PAT') }}" + # 1Password service account token for `op`. Off until the service + # account exists and the delivery design is agreed (WDWL-37). Same shape as + # the PAT: a per-host dictionary in group_vars/all/vault. + # multica_op_deliver_token: true + # multica_op_service_account_token: "{{ vault_op_service_account_tokens[inventory_hostname_short] }}" + # The role's own PAT assert is no_log, so a missing dictionary entry would # surface there only as "censored". This checks key presence, never the # value, so its failure can name the host. diff --git a/ansible/roles/multica_agent_host/README.md b/ansible/roles/multica_agent_host/README.md index 7ec6937..06f20f6 100644 --- a/ansible/roles/multica_agent_host/README.md +++ b/ansible/roles/multica_agent_host/README.md @@ -37,6 +37,8 @@ otherwise clean at the `production` profile. | Node.js, pinned, from nodejs.org with SHASUMS256 verification | yes, when an npm-installed agent CLI is selected | | `multica` CLI, pinned, from the GitHub release with `checksums.txt` verification | yes | | Agent CLIs (`claude`, `hermes`, …) | yes | +| 1Password CLI (`op`), from 1Password's signed apt/yum repository | yes (`multica_op_install`) | +| 1Password service account token, as a 0600 file for the daemon user | opt-in (`multica_op_deliver_token`); not exported to the daemon | | `workspaces_root`, on the intended mount, writable by the daemon user | yes | | Multica authentication (`multica login --token`) | yes | | CLI config (`server_url`, `app_url`, `workspaces_root`, `device_name`, …) | yes | @@ -236,6 +238,9 @@ See `defaults/main.yml` for the annotated list. The ones you will actually set: | `multica_user` | `multica` | Daemon service account. | | `multica_profile` | `""` | `--profile`, to isolate config/daemon state/workspaces. | | `multica_allow_remote_install_scripts` | `false` | Required for `hermes`; see below. | +| `multica_op_install` | `true` | 1Password CLI from 1Password's apt/yum repo. | +| `multica_op_version` | `""` | `""` tracks the repo (upgrades on every run); set e.g. `2.41.1` to pin. | +| `multica_op_deliver_token` | `false` | Write `multica_op_service_account_token` to `multica_op_token_file`. | ### Adding an agent CLI @@ -366,6 +371,58 @@ ssh -t scratch-vm 'sudo -iu multica claude' That step only becomes automatable if the host moves from a subscription to an API key. +## 1Password CLI + +`op` lets an agent resolve `op://vault/item/field` references at the moment +it runs a command (`op run -- cmd`, `op inject -i tpl -o out`, `op read`), so +the secret value never has to appear in a prompt, an issue or a transcript. +The 1Password desktop-app integration (and the Claude Code plugin built on it) +needs a GUI session, so headless agent hosts use a **service account** instead. + +Install, Debian/Ubuntu, exactly as 1Password documents it, with one addition: + +- the key at `multica_op_key_url` is fingerprint-checked against + `multica_op_key_fingerprint` before it is dearmored, so apt never trusts a + substituted key; +- dearmored to `/usr/share/keyrings/1password-archive-keyring.gpg` (apt, + `signed-by=`) and to `/usr/share/debsig/keyrings/AC2D62742012EA22/debsig.gpg` + with the policy in `/etc/debsig/policies/AC2D62742012EA22/` (debsig-verify); +- `1password-cli` from `downloads.1password.com/linux/debian/ stable`. + +EL uses 1Password's yum repository with `gpgcheck` and `repo_gpgcheck`. +The play then runs `op --version` as the daemon user, in the daemon user's +environment. + +### The service account token + +The role can deliver the token, but by default does not: + +```yaml +multica_op_deliver_token: true +multica_op_service_account_token: "{{ vault_op_service_account_tokens[inventory_hostname_short] }}" +``` + +writes it to `multica_op_token_file` +(`~multica/.config/op-service-account/token`, directory 0700, file 0600, +owned by the daemon user). That is all it does. **The role does not put the +token into the daemon's environment**, for two reasons: + +1. The daemon's systemd user unit is written and owned by the multica CLI + (`multica daemon autostart`). The role must not template or edit it, or it + silently freezes the CLI's `ExecStart`, environment and restart semantics. +2. Anything in the daemon's environment is inherited by every agent process + it spawns, where a casual `env` or `printenv` puts it in a transcript. + +How the token reaches `op` is a separate decision, not yet made. Until then +an agent can use it explicitly: + +```bash +OP_SERVICE_ACCOUNT_TOKEN="$(cat ~/.config/op-service-account/token)" op run -- +``` + +`op` reads the token only from `OP_SERVICE_ACCOUNT_TOKEN`; it has no +token-file option. + ## Known gaps - **Provider CLI login.** Unavoidable for subscription auth. @@ -387,6 +444,7 @@ API key. | `multica_auth` | `multica login` | | `multica_daemon` | autostart + start | | `multica_verify` | the assertions | +| `multica_op` | 1Password CLI, and the token file when enabled | | `multica` | all of the above | Preflight and the service-account step are tagged `always`: they build diff --git a/ansible/roles/multica_agent_host/defaults/main.yml b/ansible/roles/multica_agent_host/defaults/main.yml index bf3f0b3..3cfdd77 100644 --- a/ansible/roles/multica_agent_host/defaults/main.yml +++ b/ansible/roles/multica_agent_host/defaults/main.yml @@ -138,6 +138,34 @@ multica_agent_cli_catalog: # Setting env.DISABLE_AUTOUPDATER: "1" here is what makes the pin hold. multica_claude_settings: {} +# ----------------------------------------------------------------------------- +# 1Password CLI (op) +# ----------------------------------------------------------------------------- +# Lets agents resolve op:// references with `op run` / `op inject` / `op read`, +# so secret values never pass through a prompt or a transcript. Installed from +# 1Password's signed apt/yum repository. +multica_op_install: true +# "" = whatever the repository serves, upgraded on every run. Set e.g. "2.41.1" +# to pin. 1Password's apt index carries only the newest build, so a pin older +# than that cannot be installed on a fresh host. +multica_op_version: "" +multica_op_key_url: "https://downloads.1password.com/linux/keys/1password.asc" +# Code signing for 1Password . The play refuses a key +# at multica_op_key_url with any other fingerprint. +multica_op_key_fingerprint: "3FEF9748469ADBE15DA7CA80AC2D62742012EA22" +multica_op_debsig_policy_url: "https://downloads.1password.com/linux/debian/debsig/1password.pol" +multica_op_apt_base_url: "https://downloads.1password.com/linux/debian" +multica_op_rpm_base_url: "https://downloads.1password.com/linux/rpm/stable" + +# Service account token delivery. Off by default. When on, the token is written +# to multica_op_token_file (0600, owned by the daemon user) and nowhere else. +# The role does NOT export it into the daemon environment; see the README. +# Supply the VALUE from the playbook, like multica_pat, e.g. +# multica_op_service_account_token: "{{ vault_op_service_account_tokens[inventory_hostname_short] }}" +multica_op_deliver_token: false +multica_op_service_account_token: "" +multica_op_token_file: "{{ multica_user_home }}/.config/op-service-account/token" + # ----------------------------------------------------------------------------- # Node.js (installed only when a selected agent CLI needs npm) # ----------------------------------------------------------------------------- diff --git a/ansible/roles/multica_agent_host/tasks/main.yml b/ansible/roles/multica_agent_host/tasks/main.yml index d53edff..64b859b 100644 --- a/ansible/roles/multica_agent_host/tasks/main.yml +++ b/ansible/roles/multica_agent_host/tasks/main.yml @@ -34,6 +34,16 @@ - multica_claude_settings | length > 0 tags: [multica, multica_agents, multica_claude_settings] +- name: 1Password CLI + ansible.builtin.import_tasks: op_cli.yml + when: multica_op_install | bool + tags: [multica, multica_op] + +- name: 1Password service account token + ansible.builtin.import_tasks: op_token.yml + when: multica_op_deliver_token | bool + tags: [multica, multica_op] + - name: CLI configuration ansible.builtin.import_tasks: config.yml tags: [multica, multica_config] diff --git a/ansible/roles/multica_agent_host/tasks/op_cli.yml b/ansible/roles/multica_agent_host/tasks/op_cli.yml new file mode 100644 index 0000000..41d805c --- /dev/null +++ b/ansible/roles/multica_agent_host/tasks/op_cli.yml @@ -0,0 +1,187 @@ +--- +# 1Password CLI from 1Password's own signed package repository, never a +# curl-piped binary. Mirrors https://developer.1password.com/docs/cli/get-started +# (Linux, APT / YUM), with the signing key's fingerprint checked before apt or +# dnf is allowed to trust it. + +- name: 1Password CLI | Require a supported OS family + ansible.builtin.assert: + that: + - ansible_facts['os_family'] in ['Debian', 'RedHat'] + fail_msg: >- + The 1Password CLI install supports Debian- and RedHat-family hosts only + (found {{ ansible_facts['os_family'] }}). Set multica_op_install: false. + quiet: true + +# --- Debian / Ubuntu --------------------------------------------------------- + +- name: 1Password CLI | Install gnupg and debsig-verify + ansible.builtin.apt: + name: [gnupg, debsig-verify] + state: present + become: true + when: ansible_facts['os_family'] == 'Debian' + +- name: 1Password CLI | Fetch the signing key + ansible.builtin.get_url: + url: "{{ multica_op_key_url }}" + dest: /usr/share/keyrings/1password-archive-keyring.asc + owner: root + group: root + mode: "0644" + register: _multica_op_key_asc + become: true + when: ansible_facts['os_family'] == 'Debian' + +- name: 1Password CLI | Read the signing key's fingerprint + ansible.builtin.command: + argv: [gpg, --show-keys, --with-colons, /usr/share/keyrings/1password-archive-keyring.asc] + register: _multica_op_key_info + changed_when: false + check_mode: false + become: true + when: + - ansible_facts['os_family'] == 'Debian' + - not (ansible_check_mode and _multica_op_key_asc is changed) + +# Under --check on a fresh host the key was only pretend-downloaded, so there is +# nothing to read; the real run checks it. +# A key that does not match is never dearmored, so apt never trusts it. +- name: 1Password CLI | Require the expected signing key + ansible.builtin.assert: + that: + - multica_op_key_fingerprint in (_multica_op_key_info.stdout_lines + | select('match', '^fpr:') | map('split', ':') | map(attribute=9) | list) + fail_msg: >- + {{ multica_op_key_url }} is not the key with fingerprint + {{ multica_op_key_fingerprint }}. Refusing to trust it. + quiet: true + when: + - ansible_facts['os_family'] == 'Debian' + - _multica_op_key_info is not skipped + +- name: 1Password CLI | Look for the dearmored keyrings + ansible.builtin.stat: + path: "{{ item }}" + loop: + - /usr/share/keyrings/1password-archive-keyring.gpg + - /usr/share/debsig/keyrings/AC2D62742012EA22/debsig.gpg + register: _multica_op_keyrings + become: true + when: ansible_facts['os_family'] == 'Debian' + +- name: 1Password CLI | Create the debsig policy and keyring directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: root + group: root + mode: "0755" + loop: + - /etc/debsig/policies/AC2D62742012EA22 + - /usr/share/debsig/keyrings/AC2D62742012EA22 + register: _multica_op_debsig_dirs + become: true + when: ansible_facts['os_family'] == 'Debian' + +# Same key, two consumers: apt (repository metadata) and debsig-verify (the +# signature on each .deb). +- name: 1Password CLI | Dearmor the key for apt and debsig + ansible.builtin.command: + argv: [gpg, --batch, --yes, --dearmor, --output, "{{ item.item }}", /usr/share/keyrings/1password-archive-keyring.asc] + loop: "{{ _multica_op_keyrings.results }}" + loop_control: + label: "{{ item.item }}" + when: + - ansible_facts['os_family'] == 'Debian' + - _multica_op_key_asc is changed or not item.stat.exists + changed_when: true + become: true + +- name: 1Password CLI | Install the debsig policy + ansible.builtin.get_url: + url: "{{ multica_op_debsig_policy_url }}" + dest: /etc/debsig/policies/AC2D62742012EA22/1password.pol + owner: root + group: root + mode: "0644" + become: true + when: + - ansible_facts['os_family'] == 'Debian' + - not (ansible_check_mode and _multica_op_debsig_dirs is changed) + +- name: 1Password CLI | Add the apt repository + ansible.builtin.apt_repository: + repo: >- + deb [arch={{ _multica_arch }} signed-by=/usr/share/keyrings/1password-archive-keyring.gpg] + {{ multica_op_apt_base_url }}/{{ _multica_arch }} stable main + filename: 1password + state: present + update_cache: true + register: _multica_op_repo_deb + become: true + when: ansible_facts['os_family'] == 'Debian' + +- name: 1Password CLI | Install the package + ansible.builtin.apt: + name: >- + {{ '1password-cli=' ~ multica_op_version ~ '*' + if multica_op_version | length > 0 else '1password-cli' }} + state: "{{ 'present' if multica_op_version | length > 0 else 'latest' }}" + allow_downgrade: "{{ multica_op_version | length > 0 }}" + register: _multica_op_pkg_deb + become: true + when: + - ansible_facts['os_family'] == 'Debian' + - not (ansible_check_mode and _multica_op_repo_deb is changed) + +# --- EL ---------------------------------------------------------------------- + +- name: 1Password CLI | Add the yum repository + ansible.builtin.yum_repository: + name: 1password + description: 1Password Stable Channel + baseurl: "{{ multica_op_rpm_base_url }}/$basearch" + gpgkey: "{{ multica_op_key_url }}" + gpgcheck: true + repo_gpgcheck: true + enabled: true + register: _multica_op_repo_rpm + become: true + when: ansible_facts['os_family'] == 'RedHat' + +- name: 1Password CLI | Install the package + ansible.builtin.dnf: + name: >- + {{ '1password-cli-' ~ multica_op_version + if multica_op_version | length > 0 else '1password-cli' }} + state: "{{ 'present' if multica_op_version | length > 0 else 'latest' }}" + allow_downgrade: "{{ multica_op_version | length > 0 }}" + register: _multica_op_pkg_rpm + become: true + when: + - ansible_facts['os_family'] == 'RedHat' + - not (ansible_check_mode and _multica_op_repo_rpm is changed) + +# --- Verify ------------------------------------------------------------------ + +# As the daemon user, through its own environment: that is the context agents +# call `op` from. +- name: 1Password CLI | Check the installed version as the daemon user + ansible.builtin.command: + argv: [op, --version] + become: true + become_user: "{{ multica_user }}" + environment: "{{ _multica_user_env }}" + register: _multica_op_version_out + changed_when: false + check_mode: false + failed_when: >- + _multica_op_version_out.rc != 0 + or (multica_op_version | length > 0 + and _multica_op_version_out.stdout | trim != multica_op_version) + # Under --check, only when the binary is already there to ask. + when: >- + not ansible_check_mode + or (_multica_op_pkg_deb is not skipped and _multica_op_pkg_deb is not changed) + or (_multica_op_pkg_rpm is not skipped and _multica_op_pkg_rpm is not changed) diff --git a/ansible/roles/multica_agent_host/tasks/op_token.yml b/ansible/roles/multica_agent_host/tasks/op_token.yml new file mode 100644 index 0000000..2839430 --- /dev/null +++ b/ansible/roles/multica_agent_host/tasks/op_token.yml @@ -0,0 +1,37 @@ +--- +# Delivers the 1Password service account token to a file only the daemon user +# can read. It deliberately stops there: nothing here puts the token into the +# daemon's environment. The daemon's systemd user unit belongs to the multica +# CLI, so the role must not edit it, and exporting the token to the daemon +# would hand it to every agent process too. See the README. + +# no_log: a failed assert would otherwise render the token it inspected. +- name: 1Password token | Require a service account token + ansible.builtin.assert: + that: + - multica_op_service_account_token | length > 0 + - multica_op_service_account_token is match('^ops_') + fail_msg: >- + multica_op_deliver_token is true but multica_op_service_account_token is + not a 1Password service account token (ops_...). + quiet: true + no_log: true + +- name: 1Password token | Create the token directory + ansible.builtin.file: + path: "{{ multica_op_token_file | dirname }}" + state: directory + owner: "{{ multica_user }}" + group: "{{ multica_group }}" + mode: "0700" + become: true + +- name: 1Password token | Write the token file + ansible.builtin.copy: + dest: "{{ multica_op_token_file }}" + content: "{{ multica_op_service_account_token }}\n" + owner: "{{ multica_user }}" + group: "{{ multica_group }}" + mode: "0600" + no_log: true + become: true