multica_agent_host: install the 1Password CLI from 1Password's signed repo (WDWL-37)
Agents get `op` so op:// references resolve at command time and secret values stay out of prompts and transcripts. Installed per 1Password's apt instructions (signed-by keyring, debsig policy + keyring, 1password-cli), with the key fingerprint-checked before it is trusted; EL via their yum repo. multica_op_version pins, "" tracks the repo. Tag: multica_op. Optional, default-off hook writes OP_SERVICE_ACCOUNT_TOKEN from the playbook (ansible-vault) to a 0600 file owned by the daemon user. It is deliberately not wired into the daemon environment: the user unit belongs to the multica CLI, and the delivery design is pending. Rolled out with --tags multica_op to haunted-mansion and spaceship-earth; second run changed=0, op 2.41.1 as multica, debsig verifies the .deb. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
01e6ad90f0
commit
be7d4a4378
6 files changed
+326
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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/<arch> 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 -- <cmd>
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
@@ -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 <codesign@1password.com>. 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)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user