Compare commits
22 Commits
37f73b124b
...
claude/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
042e3bf05a | ||
|
|
663bf5dfa4 | ||
|
|
3f9c7f8dd6 | ||
|
|
55a07c8ba5 | ||
|
|
89d6952c46 | ||
|
|
58c3fd61b8 | ||
|
|
f10a566b4c | ||
|
|
0ead28a7bf | ||
|
|
5f9ecb7b05 | ||
|
|
99a55c2109 | ||
|
|
b2ada7e4a7 | ||
|
|
2e0db91c2b | ||
|
|
16931942b9 | ||
|
|
3b67bef373 | ||
|
|
278eeb931b | ||
|
|
83aa854206 | ||
|
|
885b055768 | ||
|
|
79fb5a93ef | ||
|
|
b1f238a024 | ||
|
|
a2a3583a1d | ||
|
|
041356e68b | ||
|
|
85bc9c723d |
BIN
.github/stargazer_map.png
vendored
|
Before Width: | Height: | Size: 330 KiB After Width: | Height: | Size: 58 KiB |
BIN
.github/stats.png
vendored
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.8 KiB |
BIN
.github/stats_addons.png
vendored
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 3.6 KiB |
6
.github/workflows/archived_lint-checks.yaml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
container: ghcr.io/hadolint/hadolint:latest-alpine
|
||||
steps:
|
||||
- name: ↩️ Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
|
||||
- name: Get changed files
|
||||
id: changed_files
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
container: koalaman/shellcheck-alpine:latest
|
||||
steps:
|
||||
- name: ↩️ Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
|
||||
- name: Get changed files
|
||||
id: changed_files
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ↩️ Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
# Full git history is needed to get a proper list of changed files within `super-linter`
|
||||
fetch-depth: 0
|
||||
|
||||
2
.github/workflows/daily_README.yaml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
|
||||
- name: Install jq + yq (v4)
|
||||
run: |
|
||||
|
||||
18
.github/workflows/daily_ai_fix.yaml
vendored
@@ -50,7 +50,6 @@ permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
id-token: write # claude-code-action fetches a GitHub OIDC token to auth the OAuth flow
|
||||
|
||||
concurrency:
|
||||
group: ai-fix-sweep
|
||||
@@ -66,7 +65,7 @@ jobs:
|
||||
environment: CR_PAT
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.AI_PR_TOKEN }}
|
||||
@@ -126,12 +125,19 @@ jobs:
|
||||
|
||||
- name: Analyse and fix
|
||||
if: steps.batch.outputs.count != '0'
|
||||
uses: anthropics/claude-code-action@44423bdec74b97d67543eb16c110546762c110b2 # v1
|
||||
uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
# One sticky, auto-updating status comment per run instead of the
|
||||
# model narrating its own progress in scattered comments.
|
||||
track_progress: true
|
||||
# Skip the OIDC -> Claude App token exchange. The scheduled path
|
||||
# happens to pass it (github.actor is the maintainer), but
|
||||
# workflow_dispatch by anyone else would 401. AI_PR_TOKEN, not
|
||||
# GITHUB_TOKEN, so a PR Claude opens triggers CI.
|
||||
github_token: ${{ secrets.AI_PR_TOKEN }}
|
||||
# No track_progress here. It needs an issue or PR to hang its sticky
|
||||
# comment on, and the action hard-fails validation without one; this
|
||||
# workflow only ever runs on schedule/workflow_dispatch. Per-issue
|
||||
# progress still gets reported — issue-fix.md has Claude comment on
|
||||
# each issue directly via gh.
|
||||
prompt: |
|
||||
The batch of issues to work through is /tmp/ai-fix/batch.json.
|
||||
Follow .github/prompts/issue-fix.md exactly. Do not deviate from
|
||||
|
||||
2
.github/workflows/generate_stargazer_map.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v7
|
||||
|
||||
2
.github/workflows/helper_stats_graphs.yaml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
- name: Install apps
|
||||
run: |
|
||||
git pull --rebase origin master
|
||||
|
||||
4
.github/workflows/lint.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
4
.github/workflows/on_claude_mention.yml
vendored
@@ -59,12 +59,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
uses: anthropics/claude-code-action@44423bdec74b97d67543eb16c110546762c110b2 # v1
|
||||
uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
# AI_PR_TOKEN, not GITHUB_TOKEN, so a PR Claude opens triggers CI.
|
||||
|
||||
14
.github/workflows/on_issue_approved.yaml
vendored
@@ -32,7 +32,6 @@ permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
id-token: write # claude-code-action fetches a GitHub OIDC token to auth the OAuth flow
|
||||
|
||||
concurrency:
|
||||
group: ai-approve-${{ github.event.issue.number || inputs.issue }}
|
||||
@@ -76,7 +75,7 @@ jobs:
|
||||
environment: CR_PAT
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.AI_PR_TOKEN }}
|
||||
@@ -136,10 +135,17 @@ jobs:
|
||||
|
||||
- name: Execute the plan
|
||||
if: steps.bundle.outputs.has_plan == 'true'
|
||||
uses: anthropics/claude-code-action@44423bdec74b97d67543eb16c110546762c110b2 # v1
|
||||
uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
track_progress: true
|
||||
# Skip the OIDC -> Claude App token exchange, which 401s whenever
|
||||
# github.actor lacks write access. AI_PR_TOKEN, not GITHUB_TOKEN, so
|
||||
# a PR Claude opens triggers CI.
|
||||
github_token: ${{ secrets.AI_PR_TOKEN }}
|
||||
# Only the `issues` path has a comment thread to track progress in.
|
||||
# On workflow_dispatch there is none, and passing true there fails
|
||||
# the action's input validation outright.
|
||||
track_progress: ${{ github.event_name == 'issues' }}
|
||||
prompt: |
|
||||
The approved plan is /tmp/ai-exec/plan.md and the issue it belongs
|
||||
to is /tmp/ai-exec/issue.json. Follow .github/prompts/issue-execute-plan.md
|
||||
|
||||
2
.github/workflows/on_issues.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
- name: Assign issues
|
||||
run: |
|
||||
# Init
|
||||
|
||||
11
.github/workflows/on_issues_ai_triage.yaml
vendored
@@ -41,7 +41,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
id-token: write # claude-code-action fetches a GitHub OIDC token to auth the OAuth flow
|
||||
|
||||
concurrency:
|
||||
group: ai-triage-${{ github.event.issue.number || inputs.issue || github.run_id }}
|
||||
@@ -124,7 +123,7 @@ jobs:
|
||||
# (issues.opened, dispatch) never set claim, so they always proceed.
|
||||
- name: Checkout tooling
|
||||
if: github.event_name != 'issue_comment' || steps.claim.outputs.go == 'true'
|
||||
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -145,9 +144,15 @@ jobs:
|
||||
id: classify
|
||||
if: github.event_name != 'issue_comment' || steps.claim.outputs.go == 'true'
|
||||
continue-on-error: true
|
||||
uses: anthropics/claude-code-action@44423bdec74b97d67543eb16c110546762c110b2 # v1
|
||||
uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
# Without this the action falls back to the OIDC -> Claude App token
|
||||
# exchange, which 401s ("User does not have write access on this
|
||||
# repository") whenever github.actor is the outside reporter who
|
||||
# opened the issue or replied to a needs-info request. Same token the
|
||||
# step already exports as GH_TOKEN; classify only reads.
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
show_full_output: true
|
||||
prompt: |
|
||||
Read /tmp/ai-triage/context.md, then follow the instructions in
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
|
||||
- name: Ping mapped submitter when add-on is mentioned
|
||||
env:
|
||||
|
||||
10
.github/workflows/on_pr_coderabbit.yml
vendored
@@ -25,7 +25,6 @@ permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write # claude-code-action fetches a GitHub OIDC token to auth the OAuth flow
|
||||
|
||||
concurrency:
|
||||
group: ai-coderabbit-${{ github.event.pull_request.number }}
|
||||
@@ -66,7 +65,7 @@ jobs:
|
||||
|
||||
- name: Checkout PR branch
|
||||
if: steps.claim.outputs.go == 'true'
|
||||
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
fetch-depth: 0
|
||||
@@ -80,9 +79,14 @@ jobs:
|
||||
|
||||
- name: Address CodeRabbit comments
|
||||
if: steps.claim.outputs.go == 'true'
|
||||
uses: anthropics/claude-code-action@44423bdec74b97d67543eb16c110546762c110b2 # v1
|
||||
uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
# Skip the OIDC -> Claude App token exchange, which 401s whenever
|
||||
# github.actor lacks write access — here github.actor is
|
||||
# coderabbitai[bot], the review submitter. AI_PR_TOKEN, not
|
||||
# GITHUB_TOKEN, so the pushed fixes re-trigger CI on the PR.
|
||||
github_token: ${{ secrets.AI_PR_TOKEN }}
|
||||
prompt: |
|
||||
CodeRabbit has reviewed pull request #${{ github.event.pull_request.number }}
|
||||
on ${{ github.repository }}. You are on that PR's branch. Follow
|
||||
|
||||
6
.github/workflows/onpr_check-pr.yaml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
changedChangelogFiles: ${{ steps.changed-files.outputs.changelogs_files }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
# Need the merge commit's parents resolvable (HEAD^1 below): a depth-1 shallow
|
||||
# checkout truncates parent refs entirely at the boundary commit.
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
addon: ${{ fromJSON(needs.check-addon-changes.outputs.changedAddons) }}
|
||||
steps:
|
||||
- name: ↩️ Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
|
||||
- name: 🔎 Run Home Assistant Add-on Lint
|
||||
uses: frenck/action-addon-linter@v2
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
addon: ${{ fromJSON(needs.check-addon-changes.outputs.changedAddons) }}
|
||||
steps:
|
||||
- name: ↩️ Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
|
||||
- name: Copy templates into addon build context
|
||||
env:
|
||||
|
||||
12
.github/workflows/onpush_builder.yaml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
changedAddons: ${{ steps.find_addons.outputs.changed_addons }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
sanitizeCommitted: ${{ steps.sanitize_commit.outputs.committed }}
|
||||
sanitizeCommitSha: ${{ steps.sanitize_commit.outputs.commit_long_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
matrix:
|
||||
addon: ${{ fromJSON(needs.detect-changed-addons.outputs.changedAddons) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- name: Run Home Assistant Add-on Lint
|
||||
uses: frenck/action-addon-linter@v2
|
||||
with:
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
- arch: aarch64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -335,7 +335,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -406,7 +406,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
4
.github/workflows/weekly_crlftolf.yaml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository contents
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
|
||||
- name: Use action to check for CRLF endings
|
||||
uses: erclu/check-crlf@v1
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
runs-on: ubuntu-latest # Use a Linux runner
|
||||
steps:
|
||||
- name: Checkout repository contents
|
||||
uses: actions/checkout@v7 # Use the checkout action
|
||||
uses: actions/checkout@v7.0.1 # Use the checkout action
|
||||
- name: Find files with CRLF endings
|
||||
uses: erclu/check-crlf@v1.2.0 # Use the check-crlf action
|
||||
id: check-crlf # Assign an id to this step
|
||||
|
||||
2
.github/workflows/weekly_reduceimagesize.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
|
||||
- name: Compress Images
|
||||
id: calibre
|
||||
|
||||
2
.github/workflows/weekly_stats.yaml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
- name: Create stats
|
||||
run: |
|
||||
echo "Starting"
|
||||
|
||||
@@ -3,6 +3,160 @@
|
||||
|
||||
set -e
|
||||
|
||||
################################################################################
|
||||
# Block markers, temp helper, quoting and export-block builders
|
||||
#
|
||||
# Everything here is free of side effects and defined before the Supervisor
|
||||
# guard, so that the self-test below can exercise the whole value path outside a
|
||||
# container, where bashio is not available.
|
||||
################################################################################
|
||||
BLOCK_BEGIN="# --- BEGIN ADDON ENV (generated) ---"
|
||||
BLOCK_END="# --- END ADDON ENV (generated) ---"
|
||||
|
||||
mktemp_safe() {
|
||||
local tmpdir="${TMPDIR:-/tmp}"
|
||||
mkdir -p "$tmpdir"
|
||||
mktemp "$tmpdir/tmp.XXXXXXXXXX"
|
||||
}
|
||||
|
||||
dotenv_quote() {
|
||||
# For /.env and /etc/environment: double quotes + minimal escaping.
|
||||
#
|
||||
# These files are read back by sourcing them from a shell, so every
|
||||
# character that is still special inside double quotes has to be escaped.
|
||||
# $ and ` used to be left alone, which meant a value was expanded instead of
|
||||
# being read literally: a password like pa$$w0rd came back with the shell
|
||||
# PID spliced into it, and a value containing backticks ran as a command.
|
||||
#
|
||||
# Backslash must be doubled first, so that the backslashes added below are
|
||||
# not doubled in turn.
|
||||
local v="$1"
|
||||
v="${v//\\/\\\\}"
|
||||
v="${v//\"/\\\"}"
|
||||
v="${v//\$/\\\$}"
|
||||
v="${v//\`/\\\`}"
|
||||
v="${v//$'\n'/\\n}"
|
||||
v="${v//$'\r'/\\r}"
|
||||
printf '"%s"' "$v"
|
||||
}
|
||||
|
||||
shell_quote() {
|
||||
# Single-quote for safe injection into shell code.
|
||||
#
|
||||
# Inside single quotes every character is literal, so the only thing a value
|
||||
# needs escaping for is the quote character itself: close the quote, emit an
|
||||
# escaped quote, reopen it. Backslashes must be left untouched.
|
||||
#
|
||||
# This used to double every backslash and to replace ' with '"'"' followed by
|
||||
# a stray space. The stray space corrupted every value containing a quote
|
||||
# (O'Brien pass arrived as O' Brien pass); the doubling was undone further
|
||||
# down the path by the "awk -v" in append_export, so backslashes survived by
|
||||
# accident. Both halves are fixed together -- see the note in append_export.
|
||||
local s="$1"
|
||||
printf "'%s'" "${s//\'/\'\\\'\'}"
|
||||
}
|
||||
|
||||
append_export() {
|
||||
# Plain append, deliberately not awk: "awk -v q=$value" runs the value
|
||||
# through awk's escape processing, which turns \t into a tab, \b into a
|
||||
# backspace and \\ into a single backslash. That used to be cancelled out by
|
||||
# shell_quote doubling every backslash, so the two bugs hid each other --
|
||||
# fixing only one of them corrupts the value.
|
||||
printf 'export %s=%s\n' "$1" "$(shell_quote "$2")" >> "$EXPORT_BODY"
|
||||
}
|
||||
|
||||
compose_export_block() {
|
||||
{
|
||||
echo "$BLOCK_BEGIN"
|
||||
echo "# Generated from $JSONSOURCE"
|
||||
cat "$EXPORT_BODY"
|
||||
echo "$BLOCK_END"
|
||||
} > "$EXPORT_BLOCK"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Self-test: bash .templates/00-global_var.sh --self-test
|
||||
#
|
||||
# Builds a real export block and sources it, which is exactly what happens once
|
||||
# the block is injected at the top of a service run script, then checks that
|
||||
# every value came back byte for byte. Testing the whole path matters: the two
|
||||
# defects this guards against (shell_quote doubling backslashes and append_export
|
||||
# passing values through "awk -v") cancelled each other out, so a test of either
|
||||
# helper alone reported success while the pair was wrong.
|
||||
#
|
||||
# Runs before the Supervisor guard and exits, so it never affects startup.
|
||||
################################################################################
|
||||
if [[ "${1:-}" == "--self-test" ]]; then
|
||||
# Literal test data: the single quotes and metacharacters are the point.
|
||||
# shellcheck disable=SC2016
|
||||
self_test_values=(
|
||||
'plain.host'
|
||||
'next\.duckdns\.org' # regex, dots escaped once
|
||||
'next\\.duckdns\\.org' # regex, dots escaped twice by the user
|
||||
'C:\Users\bob\share' # windows path, \U and \b are awk escapes
|
||||
'\\server\share' # UNC path
|
||||
'col\tsep' # \t is an awk escape
|
||||
"O'Brien pass" # embedded quote
|
||||
"it's a 'quoted' word" # several embedded quotes
|
||||
"'leading"
|
||||
"trailing'"
|
||||
'a$b`c"d' # shell metacharacters
|
||||
'*.example.com|^foo\d+$'
|
||||
$'sp ace\ttab'
|
||||
''
|
||||
)
|
||||
|
||||
JSONSOURCE="self-test"
|
||||
EXPORT_BODY="$(mktemp_safe)"
|
||||
EXPORT_BLOCK="$(mktemp_safe)"
|
||||
self_test_env="$(mktemp_safe)"
|
||||
trap 'rm -f "$EXPORT_BODY" "$EXPORT_BLOCK" "$self_test_env"' EXIT
|
||||
self_test_rc=0
|
||||
|
||||
self_test_check() {
|
||||
# $1 name of the variable that was read back, $2 expected value, $3 how
|
||||
local self_test_got="${!1}"
|
||||
[[ "$self_test_got" == "$2" ]] && return 0
|
||||
printf 'FAIL (%s): <%s> came back as <%s>\n' "$3" "$2" "$self_test_got"
|
||||
self_test_rc=1
|
||||
}
|
||||
|
||||
# 1. The export block, sourced the way an injected run script would
|
||||
for self_test_i in "${!self_test_values[@]}"; do
|
||||
append_export "SELFTEST_${self_test_i}" "${self_test_values[$self_test_i]}"
|
||||
done
|
||||
compose_export_block
|
||||
# shellcheck source=/dev/null
|
||||
. "$EXPORT_BLOCK"
|
||||
for self_test_i in "${!self_test_values[@]}"; do
|
||||
self_test_check "SELFTEST_${self_test_i}" "${self_test_values[$self_test_i]}" "export block"
|
||||
done
|
||||
|
||||
# 2. /.env, sourced the way browserless_chrome and wger read it back.
|
||||
# Values holding a newline are out of scope: dotenv_quote writes them as a
|
||||
# literal \n, which a dotenv parser unescapes but a shell does not.
|
||||
for self_test_i in "${!self_test_values[@]}"; do
|
||||
printf 'DOTENVTEST_%s=%s\n' \
|
||||
"$self_test_i" "$(dotenv_quote "${self_test_values[$self_test_i]}")"
|
||||
done > "$self_test_env"
|
||||
if ! bash -n "$self_test_env"; then
|
||||
# An unescaped backtick or quote leaves the file unparseable, which would
|
||||
# abort the sourcing shell instead of just yielding a wrong value.
|
||||
echo "FAIL (dotenv): generated env file is not valid shell"
|
||||
self_test_rc=1
|
||||
else
|
||||
# shellcheck source=/dev/null
|
||||
. "$self_test_env"
|
||||
for self_test_i in "${!self_test_values[@]}"; do
|
||||
self_test_check "DOTENVTEST_${self_test_i}" "${self_test_values[$self_test_i]}" "dotenv"
|
||||
done
|
||||
fi
|
||||
|
||||
[[ "$self_test_rc" -eq 0 ]] &&
|
||||
echo "${#self_test_values[@]} values round-tripped unchanged (export block + dotenv)"
|
||||
exit "$self_test_rc"
|
||||
fi
|
||||
|
||||
################################################################################
|
||||
# Guard: only run inside Supervisor-managed add-ons
|
||||
################################################################################
|
||||
@@ -30,15 +184,6 @@ command -v jq >/dev/null || bashio::exit.nok "jq is required"
|
||||
mkdir -p /etc
|
||||
touch "$ETC_ENV_FILE"
|
||||
|
||||
################################################################################
|
||||
# Temp helper
|
||||
################################################################################
|
||||
mktemp_safe() {
|
||||
local tmpdir="${TMPDIR:-/tmp}"
|
||||
mkdir -p "$tmpdir"
|
||||
mktemp "$tmpdir/tmp.XXXXXXXXXX"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Secrets support
|
||||
################################################################################
|
||||
@@ -68,54 +213,13 @@ resolve_secret() {
|
||||
printf '%s' "$line"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Quoting
|
||||
################################################################################
|
||||
dotenv_quote() {
|
||||
# For /.env and /etc/environment: double quotes + minimal escaping
|
||||
local v="$1"
|
||||
v="${v//\\/\\\\}"
|
||||
v="${v//\"/\\\"}"
|
||||
v="${v//$'\n'/\\n}"
|
||||
v="${v//$'\r'/\\r}"
|
||||
printf '"%s"' "$v"
|
||||
}
|
||||
|
||||
shell_quote() {
|
||||
# Single-quote for safe injection in shell code
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\'/\'\"\'\"\' }"
|
||||
s="${s% }"
|
||||
printf "'%s'" "$s"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# S6 + script injection block
|
||||
################################################################################
|
||||
BLOCK_BEGIN="# --- BEGIN ADDON ENV (generated) ---"
|
||||
BLOCK_END="# --- END ADDON ENV (generated) ---"
|
||||
|
||||
EXPORT_BLOCK="$(mktemp_safe)"
|
||||
EXPORT_BODY="$(mktemp_safe)"
|
||||
KV_FILE="$(mktemp_safe)"
|
||||
trap 'rm -f "$EXPORT_BLOCK" "$KV_FILE"' EXIT
|
||||
|
||||
{
|
||||
echo "$BLOCK_BEGIN"
|
||||
echo "# Generated from $JSONSOURCE"
|
||||
echo "$BLOCK_END"
|
||||
} > "$EXPORT_BLOCK"
|
||||
|
||||
append_export() {
|
||||
local k="$1" v="$2" q
|
||||
q="$(shell_quote "$v")"
|
||||
|
||||
awk -v k="$k" -v q="$q" -v e="$BLOCK_END" '
|
||||
$0==e { print "export " k "=" q }
|
||||
{ print }
|
||||
' "$EXPORT_BLOCK" > "$EXPORT_BLOCK.tmp"
|
||||
mv "$EXPORT_BLOCK.tmp" "$EXPORT_BLOCK"
|
||||
}
|
||||
trap 'rm -f "$EXPORT_BLOCK" "$EXPORT_BODY" "$KV_FILE"' EXIT
|
||||
|
||||
inject_block() {
|
||||
local f="$1" tmp
|
||||
@@ -235,6 +339,8 @@ cp "$ENV_FILE" "$ETC_ENV_FILE"
|
||||
################################################################################
|
||||
# Inject into scripts and shells (best-effort)
|
||||
################################################################################
|
||||
compose_export_block
|
||||
|
||||
for f in /etc/services.d/*/run /etc/s6-overlay/s6-rc.d/*/run /etc/cont-init.d/*.sh /entrypoint.sh /etc/bash.bashrc "${GLOBAL_VAR_FILES:-}"; do
|
||||
[[ -f "$f" ]] && inject_block "$f"
|
||||
done
|
||||
|
||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.2 KiB |
BIN
aurral/stats.png
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.4 KiB |
BIN
baikal/stats.png
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -1,4 +1,13 @@
|
||||
|
||||
## 1.6.0.2 (2026-07-27)
|
||||
|
||||
- Fix base_url sed patterns rewriting *every* `base_url` key in Bazarr's config.yaml (radarr.base_url, sonarr.base_url, and any other configured integration), instead of only Bazarr's own under `general:`. This silently broke the Radarr/Sonarr connections inside Bazarr on every addon restart when ingress was enabled
|
||||
|
||||
## 1.6.0.1 (2026-07-27)
|
||||
|
||||
- Fix ingress: nginx rewrote Bazarr's redirects into an absolute `http://<host>:8099/...` URL, which the browser blocked as mixed content when Home Assistant is served over HTTPS. Redirects now stay relative and point at the ingress path
|
||||
- Fix fallback base_url in the nginx service script missing its leading `/`, which crashed Bazarr on startup
|
||||
|
||||
## 1.6.0 (2026-07-08)
|
||||
|
||||
- Update to latest version from linuxserver/docker-bazarr (changelog : https://github.com/linuxserver/docker-bazarr/releases)
|
||||
|
||||
@@ -112,4 +112,4 @@ schema:
|
||||
slug: bazarr_nas
|
||||
udev: true
|
||||
url: https://github.com/alexbelgium/hassio-addons/tree/master/bazarr
|
||||
version: "1.6.0"
|
||||
version: "1.6.0.2"
|
||||
|
||||
@@ -35,16 +35,20 @@ if [ -f "$CONFIG_LOCATION" ]; then
|
||||
ingress_noauth)
|
||||
bashio::log.green "Ingress is enabled, authentication is disabled"
|
||||
bashio::log.yellow "WARNING : Make sure that the port is not exposed externally by your router to avoid a security risk !"
|
||||
# Set base_url (must start with / for Flask blueprint registration)
|
||||
sed -i "s| base_url:.*| base_url: /$slug|" "$CONFIG_LOCATION"
|
||||
# Set base_url (must start with / for Flask blueprint registration).
|
||||
# Scoped to the general: block only -- config.yaml also carries a
|
||||
# base_url under each configured *arr integration (radarr.base_url,
|
||||
# sonarr.base_url, ...) and those must not be touched.
|
||||
sed -i "/^general:/,/^[^ ]/{ s| base_url:.*| base_url: /$slug|; }" "$CONFIG_LOCATION"
|
||||
# Disable auth
|
||||
sed -i '/^auth:/,/^[^ ]/{ s/ type:.*/ type: null/ }' "$CONFIG_LOCATION"
|
||||
;;
|
||||
# Ingress mode, with authentication
|
||||
ingress_auth)
|
||||
bashio::log.green "Ingress is enabled, and external authentication is enabled"
|
||||
# Set base_url (must start with / for Flask blueprint registration)
|
||||
sed -i "s| base_url:.*| base_url: /$slug|" "$CONFIG_LOCATION"
|
||||
# Set base_url (must start with / for Flask blueprint registration).
|
||||
# Scoped to the general: block only -- see note above.
|
||||
sed -i "/^general:/,/^[^ ]/{ s| base_url:.*| base_url: /$slug|; }" "$CONFIG_LOCATION"
|
||||
# Enable Bazarr auth when leaving ingress_noauth
|
||||
sed -i '/^auth:/,/^[^ ]/{ s/ type:.*/ type: form/ }' "$CONFIG_LOCATION"
|
||||
;;
|
||||
@@ -52,7 +56,8 @@ if [ -f "$CONFIG_LOCATION" ]; then
|
||||
noingress_auth)
|
||||
bashio::log.green "Disabling ingress and enabling authentication"
|
||||
bashio::log.yellow "WARNING : Ingress is disabled so the app won't be available from HA itself !"
|
||||
sed -i "s/ base_url:.*/ base_url: ''/" "$CONFIG_LOCATION"
|
||||
# Scoped to the general: block only -- see note above.
|
||||
sed -i "/^general:/,/^[^ ]/{ s/ base_url:.*/ base_url: ''/; }" "$CONFIG_LOCATION"
|
||||
# Enable Bazarr auth when leaving ingress_noauth
|
||||
sed -i '/^auth:/,/^[^ ]/{ s/ type:.*/ type: form/ }' "$CONFIG_LOCATION"
|
||||
;;
|
||||
|
||||
@@ -19,6 +19,19 @@ server {
|
||||
proxy_set_header Connection $http_connection;
|
||||
#auth_basic off;
|
||||
|
||||
# Adjust Location headers in backend redirects
|
||||
# Bazarr is Flask-based and answers /bazarr with a redirect to /bazarr/,
|
||||
# made absolute against the Host nginx sends upstream, so it reads
|
||||
# http://127.0.0.1:6767/bazarr/. proxy_redirect strips that prefix, and
|
||||
# nginx then re-absolutises the result as $scheme://$host:$server_port/...
|
||||
# i.e. http://<ha_host>:8099/bazarr/ -- blocked by the browser as mixed
|
||||
# content inside the ingress iframe. absolute_redirect off keeps it
|
||||
# relative; the proxy_redirect rules re-prefix it with the ingress entry
|
||||
# (the second rule also covers a redirect that was relative already).
|
||||
absolute_redirect off; # Do not add port to redirect
|
||||
proxy_redirect http://127.0.0.1:6767/ %%ingress_entry%%/;
|
||||
proxy_redirect / %%ingress_entry%%/;
|
||||
|
||||
# Correct base_url
|
||||
proxy_set_header Accept-Encoding "";
|
||||
sub_filter_once off;
|
||||
|
||||
@@ -15,9 +15,13 @@ bashio::net.wait_for "$port" localhost 900
|
||||
if [ -f "$CONFIG_LOCATION" ]; then
|
||||
if ! bashio::config.true "ingress_disabled"; then
|
||||
if ! bashio::config.has_value "connection_mode" || [ "$(bashio::config 'connection_mode')" != "noingress_auth" ]; then
|
||||
if ! grep -q "base_url.*$slug" "$CONFIG_LOCATION"; then
|
||||
if ! sed -n "/^general:/,/^[^ ]/ { /^ base_url: \/$slug$/p; }" "$CONFIG_LOCATION" | grep -q .; then
|
||||
bashio::log.warning "BaseUrl not set properly, restarting"
|
||||
sed -i "s/ base_url:.*/ base_url: $slug/" "$CONFIG_LOCATION"
|
||||
# Must start with / for Flask blueprint registration. Scoped to
|
||||
# the general: block only -- config.yaml also carries a base_url
|
||||
# under each configured *arr integration (radarr.base_url,
|
||||
# sonarr.base_url, ...) and those must not be touched.
|
||||
sed -i "/^general:/,/^[^ ]/{ s| base_url:.*| base_url: /$slug|; }" "$CONFIG_LOCATION"
|
||||
bashio::addon.restart
|
||||
fi
|
||||
fi
|
||||
|
||||
BIN
bazarr/stats.png
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -1,4 +1,19 @@
|
||||
## 1.36.2 (28-07-2026)
|
||||
- Minor bugs fixed
|
||||
|
||||
## 1.36.1 (27-07-2026)
|
||||
|
||||
- Fix the Codex CLI install failing on every boot with `Verified Codex <version> installation failed; Codex is unavailable this boot`, leaving `install_codex_cli` permanently non-functional. The download, its SHA-256 verification, and the extraction all succeeded; the chain broke at the final step, which validates the candidate binary by running `--version` as the `abc` runtime user. `mktemp -d` creates its directory `0700 root:root`, and `abc` cannot traverse a root-only directory, so executing the staged binary failed with `unable to exec: Permission denied` (exit 126) before it could be moved into place. Reproduced and fixed by making the staging directory traversable (`chmod 0755`) immediately after `mktemp`; verified on a live add-on container, where the same probe goes from exit 126 to success once the mode is widened. Nothing secret is staged there — the public release archive and the extracted binary, both world-readable upstream artifacts — and the existing `cleanup()` trap still removes the directory on exit. The validation deliberately keeps running as `abc` rather than root, so the binary is exercised as the identity that will actually run it.
|
||||
|
||||
## 1.36 (27-07-2026)
|
||||
|
||||
- Add optional OpenAI Codex CLI support, so a Claude session in this add-on can delegate work to ChatGPT Codex. Three parts: an `install_codex_cli` switch, a browserless way to activate a ChatGPT subscription on it, and an MCP registration that makes Codex callable as a tool from Claude.
|
||||
- **Install (`install_codex_cli`, default off).** Codex is deliberately *not* baked into the image: the Linux release binary is ~310 MB extracted, which is not worth carrying in every installation for an off-by-default option, and updating it would then require an add-on rebuild. The new `81-codex_cli.sh` cont-init script downloads the pinned static-musl release (`ARG`/`ENV CODEX_VERSION`, currently `0.145.0`) into `/data/codex/bin` and symlinks it onto `PATH`. `/data` is the add-on's own persistent volume — independent of the configurable `data_location` — so the ~113 MB download happens once, not per boot and not again after an add-on update unless the pinned release changes. The install prefix is outside `$HOME` on purpose: the managed-MCP merge in `82-claude_tools.sh` treats any server `command` under `$HOME` as user-installed and refuses to manage it, so an add-on-owned binary living there could never be updated or removed by the add-on. The download fails open like the Claude Desktop update check — an offline boot, a GitHub outage or a bad pin logs a warning and leaves any existing binary in place instead of aborting startup, and the new binary is validated by actually running `--version` from the staging directory before it replaces the current one, so a truncated download can never overwrite a working install. Staging deliberately happens under `/data` rather than the default `/tmp`: `/tmp` in this image is a RAM-backed tmpfs, so holding the 110 MB archive plus the 310 MB binary there during boot is a real risk on a small Home Assistant host, and it is mounted `noexec`, which would make the validation step impossible anyway. Staging on the destination filesystem also turns the final move into an atomic rename instead of a second 310 MB copy. Disabling the option is non-destructive: the binary and the completed sign-in are kept, so a disable/re-enable cycle needs neither another download nor another login.
|
||||
- **Subscription activation (`codex-login`).** Codex's default sign-in serves an OAuth callback on `localhost:1455` and expects a local browser, which cannot work here — the image ships no browser, and the add-on is reached through Home Assistant ingress. The new `/usr/local/bin/codex-login` helper runs `codex login --device-auth` instead, the flow OpenAI documents for headless machines: it prints a verification URL and a short-lived one-time code to approve on any other device, then polls until done. It can be run from the desktop's xterm (openbox right-click menu), from a Claude Code session, or from the container console, and it drops to the `abc` runtime user first so `~/.codex/auth.json` is not created root-owned — the same failure mode `83-github_cli.sh` documents for `~/.gitconfig`. Output is line-buffered so the URL and code appear immediately even without a TTY. If a session is already authenticated the helper reports the account and exits rather than starting a second flow. Credentials live in the persistent home, so the sign-in survives restarts and updates.
|
||||
- **MCP bridge.** `codex mcp-server` exposes Codex itself as a stdio MCP server (tools `codex` and `codex-reply`, verified by driving the 0.145.0 binary over stdio), which is registered through the *existing* managed-MCP merge in `82-claude_tools.sh` rather than a second copy of that logic — so it inherits the merge's idempotence, its refusal to clobber a user-customized entry, its removal-when-disabled behaviour, and the `0600` re-tightening of the token-bearing config files. Registration covers both Claude Code (`~/.claude.json`) and Claude Desktop, so every session type gets the tool. A managed `CLAUDE.md` block tells Claude what Codex is and when a second agent is worth the round-trip, since registering a server without guidance rarely gets it used — the same gap the Headroom block exists to close.
|
||||
- **Sandbox (`codex_sandbox_mode`, default `danger-full-access`).** Codex normally confines itself with a Linux OS sandbox (Landlock, or its bundled bubblewrap), which is unreliable inside a Home Assistant add-on container — and the container is already the security boundary. The default therefore bypasses Codex's own sandbox; `read-only` and `workspace-write` remain selectable. The chosen mode is applied in two places that are kept in sync: as root-level `-c` config overrides in the MCP registration (Codex forwards those to the MCP server, and they must precede the subcommand), and as a managed block at the top of `~/.codex/config.toml` so plain terminal `codex`/`codex exec` runs behave the same. That block is written at position 0 rather than appended, because a bare TOML key placed after a `[table]` header would silently become a key *of that table*. `approval_policy` is always `never` — an MCP- or cron-driven run has nobody to answer a prompt. Claude Code's own permission prompts still gate every `mcp__codex__*` call unless `permission_mode` is `bypass`.
|
||||
- `84-claude_runtime_ownership.sh` now also reconciles `~/.codex`, and `claude-tools-doctor.sh` reports the Codex binary, the installed-versus-pinned version, the two new switches, and the login status (which prints the auth mode, never the token).
|
||||
|
||||
## 1.35 (23-07-2026)
|
||||
|
||||
- Fix recurring "For your security, sign in again to keep using Claude." and the Claude app's dispatch tab showing this desktop as offline when opened from mobile first. Root cause (confirmed from `~/.config/Claude/logs/main.log` on a live install): the app launches with `--password-store=gnome-libsecret`, forcing Electron's libsecret/Secret-Service backend, but `gnome-keyring` was removed from the image in a previous commit because it prompted for a keyring password on first boot and blocked the app from launching. With the flag still forcing libsecret and no keyring daemon running, `safeStorage.isEncryptionAvailable()` is `false` — logs showed `session will not persist; app secrets fall back to plaintext` and `cannot store allowlist cache`. The un-persisted session then goes stale, failing the elevated-access OAuth check (`session_stale_relogin`) that the cowork/dispatch bridge needs, so the bridge is "parked until re-login" — which is what the Claude app surfaces as the desktop being offline, until a fresh sign-in (only completable from a computer, see `SIGN_IN.md` Problem A) un-parks it. `rootfs/defaults/autostart` now launches with `--password-store=basic` instead: Electron's built-in fixed-key store needs no daemon and never prompts, and persists under `$HOME/.config/Claude` (`/data/data`, persistent), so the session survives restarts and dispatch stays online regardless of which device connects first. A passwordless keyring was considered and rejected — it would live in the same persistent volume as the ciphertext, adding no real protection in this single-user self-hosted setup. `Dockerfile`'s stale comment (still describing gnome-keyring as installed) is corrected; the package stays removed.
|
||||
|
||||
@@ -94,7 +94,7 @@ RUN curl -fsSL --retry 3 --retry-delay 2 \
|
||||
# cannot alter executables elsewhere in the image.
|
||||
COPY rootfs/ /
|
||||
RUN find /etc/cont-init.d /etc/s6-overlay /defaults /usr/local/bin -type f \
|
||||
\( -name "*.sh" -o -name "run" -o -name "finish" -o -name "ha-cli" \) -print -exec chmod +x {} \; && \
|
||||
\( -name "*.sh" -o -name "run" -o -name "finish" -o -name "ha-cli" -o -name "codex-login" \) -print -exec chmod +x {} \; && \
|
||||
chmod +x /usr/local/bin/claude
|
||||
|
||||
# Uses /bin for compatibility purposes
|
||||
|
||||
@@ -78,6 +78,8 @@ Git synchronization hooks. A repository is indexed only when it is listed in
|
||||
- Custom script support through the repository standard `claude_desktop.sh`.
|
||||
- Bundled optimization tools: Headroom, RTK, and TokenSave; Caveman remains
|
||||
available as an opt-in plugin.
|
||||
- Optional OpenAI Codex CLI, authenticated exclusively with a ChatGPT
|
||||
subscription and reachable from Claude through the native Codex MCP server.
|
||||
- Optional Home Assistant MCP bridge so Claude can query and control Home
|
||||
Assistant.
|
||||
- Independent hourly savings reports for Headroom, RTK, and TokenSave.
|
||||
@@ -105,6 +107,8 @@ Git synchronization hooks. A repository is indexed only when it is listed in
|
||||
| `install_tokensave` | `true` | Install TokenSave's complete global Claude integration. |
|
||||
| `tokensave_project_paths` | `[]` | Explicit absolute Git repository paths to initialize or sync at startup. |
|
||||
| `install_caveman` | `false` | Install the third-party Caveman Claude Code plugin at startup. |
|
||||
| `install_codex_cli` | `false` | Install the latest stable OpenAI Codex CLI at startup and register its native MCP server so Claude can delegate work to ChatGPT Codex. |
|
||||
| `codex_sandbox_mode` | `workspace-write` | Filesystem scope Codex runs with: `read-only`, `workspace-write`, or `danger-full-access`. |
|
||||
| `enable_tools_health_report` | `true` | Write independent Headroom, RTK, and TokenSave gains to the add-on log hourly. |
|
||||
| `install_github_cli` | `true` | Enable setup checks for the baked-in `git` and `gh` commands. |
|
||||
| `github_token` | | Optional GitHub token used to authenticate `gh` and Git operations. |
|
||||
@@ -195,6 +199,77 @@ The dashboard is disabled externally by default. To expose it:
|
||||
The dashboard is unauthenticated. Do not publish this port to the public
|
||||
internet.
|
||||
|
||||
## Codex CLI
|
||||
|
||||
Setting `install_codex_cli: true` adds OpenAI's Codex CLI alongside Claude and
|
||||
registers `codex mcp-server` in both Claude Code and Claude Desktop. A Claude
|
||||
session can therefore delegate a task to ChatGPT Codex and read its result back
|
||||
through MCP.
|
||||
|
||||
Codex is not baked into the image because its Linux binary is large and the
|
||||
feature is off by default. At each startup, the add-on resolves the latest
|
||||
stable upstream release. It downloads the architecture-specific binary into
|
||||
persistent `/data/codex/bin` only when the installed release is missing or
|
||||
outdated, verifies the GitHub-published SHA-256 digest before extraction or
|
||||
execution, validates the staged binary with `--version`, and replaces the
|
||||
existing binary atomically. If release metadata or the download is unavailable,
|
||||
startup continues and a previously working installation is retained.
|
||||
|
||||
### Signing in with a ChatGPT subscription
|
||||
|
||||
The add-on has no browser, so use the bundled device-code helper:
|
||||
|
||||
```bash
|
||||
codex-login
|
||||
```
|
||||
|
||||
Run it from the desktop's xterm, a Claude Code session, or the container
|
||||
console. It prints a verification URL and one-time code that you approve on
|
||||
another device. Credentials are stored in the runtime user's persistent
|
||||
`~/.codex/auth.json`, so the sign-in survives restarts and add-on updates.
|
||||
|
||||
This integration is deliberately **subscription-only**. The managed launcher
|
||||
removes any inherited `OPENAI_API_KEY` and starts every Codex command—including
|
||||
`codex mcp-server`—with:
|
||||
|
||||
```toml
|
||||
forced_login_method = "chatgpt"
|
||||
cli_auth_credentials_store = "file"
|
||||
```
|
||||
|
||||
The launcher also removes caller-provided overrides for those two keys before
|
||||
starting Codex. The same values are maintained in `~/.codex/config.toml`.
|
||||
Consequently, the MCP server uses the ChatGPT Codex entitlement and cannot
|
||||
silently fall back to usage-based OpenAI API-key billing.
|
||||
|
||||
### Using Codex from Claude
|
||||
|
||||
Claude receives two native MCP tools:
|
||||
|
||||
- `mcp__codex__codex` starts a task. Pass a self-contained `prompt` and set
|
||||
`cwd` to the repository Codex should inspect. The result includes a
|
||||
`threadId`.
|
||||
- `mcp__codex__codex-reply` continues the same Codex thread with its
|
||||
`threadId`.
|
||||
|
||||
The add-on also installs managed Claude guidance recommending Codex for
|
||||
independent review, a second diagnosis, or a competing implementation rather
|
||||
than routine lookups. Codex consumption counts against the signed-in ChatGPT
|
||||
plan's Codex allowance.
|
||||
|
||||
### Sandbox scope
|
||||
|
||||
`codex_sandbox_mode` defaults to `workspace-write`, allowing implementation
|
||||
inside the supplied repository without granting unrestricted access to every
|
||||
mounted path. Select `read-only` for review-only delegation. Use
|
||||
`danger-full-access` only as an explicit fallback when Codex's nested Linux
|
||||
sandbox is unavailable in the Home Assistant add-on container and the mounted
|
||||
paths are trusted.
|
||||
|
||||
`approval_policy` is always `never`, because an MCP-driven Codex process has no
|
||||
interactive operator to answer a prompt. Claude Code's own permissions still
|
||||
gate the `mcp__codex__*` call unless `permission_mode` is `bypass`.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Run the following inside the add-on through a custom script or container console:
|
||||
@@ -206,8 +281,8 @@ claude-tools-doctor.sh
|
||||
The report checks the tool binaries, configuration switches, configured and
|
||||
effective runtime identities, redacted MCP registrations, Claude hooks,
|
||||
permission mode, Headroom health, TokenSave indexes, routing, and recorded
|
||||
savings. It never prints MCP environment values because the Home Assistant MCP
|
||||
entry can contain a long-lived token.
|
||||
savings. It never prints MCP environment values or raw Codex authentication
|
||||
status because either can contain credentials or masked credential fragments.
|
||||
|
||||
The hourly report can also be invoked manually:
|
||||
|
||||
@@ -288,6 +363,8 @@ Persistent state is stored in the configured `data_location` (default
|
||||
shared home
|
||||
- TokenSave repository indexes: `.tokensave/` inside each explicitly configured
|
||||
project
|
||||
- Codex authentication and configuration: `~/.codex`; the verified executable
|
||||
and subscription-only launcher live in persistent `/data/codex/bin`
|
||||
|
||||
Volatile cache data is redirected to `/tmp/cache` through `$XDG_CACHE_HOME` and
|
||||
`$HOME/.cache`.
|
||||
|
||||
@@ -59,6 +59,8 @@ options:
|
||||
headroom_auto_compress: true
|
||||
headroom_wrap_claude_code: true
|
||||
install_caveman: false
|
||||
install_codex_cli: false
|
||||
codex_sandbox_mode: workspace-write
|
||||
install_github_cli: true
|
||||
install_headroom: true
|
||||
install_rtk: true
|
||||
@@ -107,6 +109,8 @@ schema:
|
||||
headroom_auto_compress: bool?
|
||||
headroom_wrap_claude_code: bool
|
||||
install_caveman: bool
|
||||
install_codex_cli: bool
|
||||
codex_sandbox_mode: list(read-only|workspace-write|danger-full-access)
|
||||
install_github_cli: bool
|
||||
install_headroom: bool
|
||||
install_rtk: bool
|
||||
@@ -118,5 +122,5 @@ slug: claude_desktop
|
||||
tmpfs: true
|
||||
udev: true
|
||||
url: https://github.com/alexbelgium/hassio-addons
|
||||
version: "1.35"
|
||||
version: "1.36.2"
|
||||
video: true
|
||||
|
||||
328
claude_desktop/rootfs/etc/cont-init.d/81-codex_cli.sh
Executable file
@@ -0,0 +1,328 @@
|
||||
#!/usr/bin/with-contenv bashio
|
||||
# shellcheck shell=bash
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
# OpenAI Codex CLI, installed on demand rather than baked into the image: the Linux release
|
||||
# binary is large and the option is off by default. Runs before 82-claude_tools.sh so the binary
|
||||
# exists when that script registers the `codex` MCP server.
|
||||
#
|
||||
# The install prefix is /data/codex, NOT $HOME/.codex/bin: /data is persistent regardless of the
|
||||
# configurable data_location, and the managed MCP merge treats commands under $HOME as
|
||||
# user-installed. Codex state (auth.json, config.toml) remains in the runtime user's home.
|
||||
CODEX_ROOT="/data/codex"
|
||||
CODEX_PREFIX="${CODEX_ROOT}/bin"
|
||||
CODEX_BIN="${CODEX_PREFIX}/codex"
|
||||
CODEX_REAL="${CODEX_PREFIX}/codex-real"
|
||||
CODEX_STAMP="${CODEX_PREFIX}/.version"
|
||||
CODEX_LINK="/usr/local/bin/codex"
|
||||
CODEX_RELEASE_API="https://api.github.com/repos/openai/codex/releases/latest"
|
||||
|
||||
RUNTIME_HOME="$(getent passwd abc | cut -d: -f6)"
|
||||
if [ -z "$RUNTIME_HOME" ]; then
|
||||
bashio::log.warning "Unable to resolve the abc runtime home; using /data/data"
|
||||
RUNTIME_HOME="/data/data"
|
||||
fi
|
||||
|
||||
run_as_runtime_user() {
|
||||
s6-setuidgid abc env HOME="$RUNTIME_HOME" CODEX_HOME="$RUNTIME_HOME/.codex" "$@"
|
||||
}
|
||||
|
||||
if ! bashio::config.true 'install_codex_cli'; then
|
||||
# Non-destructive: preserve the binary and completed ChatGPT sign-in for a later re-enable.
|
||||
# 82-claude_tools.sh removes only the MCP registration and managed guidance.
|
||||
bashio::log.info "Codex CLI disabled"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$(uname -m)" in
|
||||
x86_64) CODEX_TARGET="x86_64-unknown-linux-musl" ;;
|
||||
aarch64 | arm64) CODEX_TARGET="aarch64-unknown-linux-musl" ;;
|
||||
*)
|
||||
bashio::log.warning "Codex CLI has no release binary for $(uname -m); skipping"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
CODEX_ASSET="codex-${CODEX_TARGET}.tar.gz"
|
||||
mkdir -p "$CODEX_PREFIX"
|
||||
|
||||
# Migrate the PR's earlier direct-binary layout to the enforced wrapper layout without another
|
||||
# download. The real binary is kept separately; `codex` becomes a small launcher that always
|
||||
# forces ChatGPT subscription authentication and removes any inherited API key.
|
||||
if [ ! -x "$CODEX_REAL" ] \
|
||||
&& [ -x "$CODEX_BIN" ] \
|
||||
&& run_as_runtime_user "$CODEX_BIN" --version > /dev/null 2>&1; then
|
||||
mv -f "$CODEX_BIN" "$CODEX_REAL"
|
||||
fi
|
||||
|
||||
# Resolve the latest stable release and its GitHub-published SHA-256 digest on every boot. This
|
||||
# follows upstream updates without pinning a version, while downloading the large asset only when
|
||||
# the installed version changes. A metadata outage never replaces or removes a working binary.
|
||||
codex_tmp="$(mktemp -d -p "$CODEX_ROOT")"
|
||||
# mktemp always creates 0700 root:root here, but the candidate binary is validated by running it
|
||||
# as the abc runtime user, which cannot traverse a root-only directory — that made every install
|
||||
# fail at the --version step with "unable to exec: Permission denied" (exit 126) and report
|
||||
# "Codex is unavailable this boot". Make the staging directory traversable. Nothing secret is
|
||||
# staged here: it holds the public release archive and the extracted binary, both of which are
|
||||
# world-readable upstream artifacts, and cleanup() removes the directory on exit.
|
||||
chmod 0755 "$codex_tmp"
|
||||
cleanup() {
|
||||
rm -rf "$codex_tmp"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
release_metadata="${codex_tmp}/release.json"
|
||||
release_info=""
|
||||
if curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 10 --max-time 30 \
|
||||
-o "$release_metadata" "$CODEX_RELEASE_API"; then
|
||||
release_info="$(
|
||||
CODEX_ASSET="$CODEX_ASSET" python3 - "$release_metadata" <<'PY' 2> /dev/null || true
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
metadata = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
tag = metadata.get("tag_name", "")
|
||||
if not isinstance(tag, str) or not tag.startswith("rust-v"):
|
||||
raise SystemExit("unexpected release tag")
|
||||
version = tag.removeprefix("rust-v")
|
||||
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-(?:alpha|beta)(?:\.[0-9]+){0,2})?", version):
|
||||
raise SystemExit("unexpected release version")
|
||||
|
||||
asset_name = os.environ["CODEX_ASSET"]
|
||||
asset = next(
|
||||
(
|
||||
item
|
||||
for item in metadata.get("assets", [])
|
||||
if isinstance(item, dict) and item.get("name") == asset_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
if asset is None:
|
||||
raise SystemExit("release asset missing")
|
||||
digest = asset.get("digest", "")
|
||||
if not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", digest):
|
||||
raise SystemExit("release asset has no valid SHA-256 digest")
|
||||
url = asset.get("browser_download_url", "")
|
||||
if not isinstance(url, str) or not url.startswith("https://github.com/openai/codex/releases/download/"):
|
||||
raise SystemExit("unexpected release asset URL")
|
||||
|
||||
print(f"{version}\t{digest.removeprefix('sha256:').lower()}\t{url}")
|
||||
PY
|
||||
)"
|
||||
fi
|
||||
|
||||
if [ -z "$release_info" ]; then
|
||||
if [ -x "$CODEX_REAL" ] && run_as_runtime_user "$CODEX_REAL" --version > /dev/null 2>&1; then
|
||||
bashio::log.warning "Unable to resolve the latest verified Codex release; keeping the existing install"
|
||||
else
|
||||
bashio::log.warning "Unable to resolve the latest verified Codex release; Codex is unavailable this boot"
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
IFS=$'\t' read -r CODEX_WANTED CODEX_SHA256 CODEX_URL <<< "$release_info"
|
||||
|
||||
if [ -x "$CODEX_REAL" ] \
|
||||
&& [ "$(cat "$CODEX_STAMP" 2> /dev/null || true)" = "$CODEX_WANTED" ] \
|
||||
&& run_as_runtime_user "$CODEX_REAL" --version > /dev/null 2>&1; then
|
||||
bashio::log.info "Codex CLI ${CODEX_WANTED} already installed (latest stable)"
|
||||
else
|
||||
bashio::log.info "Installing latest stable Codex CLI ${CODEX_WANTED} (${CODEX_TARGET}); this is a large one-time download"
|
||||
archive="${codex_tmp}/${CODEX_ASSET}"
|
||||
extracted="${codex_tmp}/codex-${CODEX_TARGET}"
|
||||
|
||||
# Fail open for add-on startup but fail closed for the candidate binary: its official
|
||||
# release digest must match before extraction or execution, and replacement happens only
|
||||
# after the staged binary successfully runs.
|
||||
if curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 10 --max-time 600 \
|
||||
-o "$archive" "$CODEX_URL" \
|
||||
&& printf '%s %s\n' "$CODEX_SHA256" "$archive" | sha256sum -c - > /dev/null \
|
||||
&& tar -xzf "$archive" -C "$codex_tmp" \
|
||||
&& [ -f "$extracted" ] \
|
||||
&& chmod 0755 "$extracted" \
|
||||
&& run_as_runtime_user "$extracted" --version > /dev/null 2>&1 \
|
||||
&& mv -f "$extracted" "$CODEX_REAL"; then
|
||||
printf '%s' "$CODEX_WANTED" > "$CODEX_STAMP"
|
||||
bashio::log.info "Codex CLI installed: $("$CODEX_REAL" --version 2> /dev/null || echo unknown)"
|
||||
elif [ -x "$CODEX_REAL" ]; then
|
||||
bashio::log.warning "Verified Codex ${CODEX_WANTED} installation failed; keeping the existing install"
|
||||
else
|
||||
bashio::log.warning "Verified Codex ${CODEX_WANTED} installation failed; Codex is unavailable this boot"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -x "$CODEX_REAL" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Every Codex entry point, including the MCP server launched by Claude, goes through this wrapper.
|
||||
# This is an execution-time guarantee in addition to the managed config below: API-key billing
|
||||
# cannot be selected even if an API key is present in the surrounding environment. Caller-provided
|
||||
# overrides for the two authentication guards are stripped before the forced root-level overrides
|
||||
# are inserted; root-level -c flags must precede Codex subcommands such as `mcp-server`.
|
||||
{
|
||||
printf '#!/usr/bin/env bash\n'
|
||||
printf 'CODEX_REAL=%q\n' "$CODEX_REAL"
|
||||
cat <<'SH'
|
||||
RUNTIME_HOME="$(getent passwd abc | cut -d: -f6)"
|
||||
if [ -z "$RUNTIME_HOME" ]; then
|
||||
echo "codex: unable to resolve the abc runtime home" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
is_managed_override() {
|
||||
local assignment="$1"
|
||||
local key="${assignment%%=*}"
|
||||
key="${key//[[:space:]]/}"
|
||||
case "$key" in
|
||||
forced_login_method | cli_auth_credentials_store) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
filtered_args=()
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-c | --config)
|
||||
if [ "$#" -lt 2 ]; then
|
||||
filtered_args+=("$1")
|
||||
shift
|
||||
continue
|
||||
fi
|
||||
if is_managed_override "$2"; then
|
||||
shift 2
|
||||
continue
|
||||
fi
|
||||
filtered_args+=("$1" "$2")
|
||||
shift 2
|
||||
;;
|
||||
--config=*)
|
||||
assignment="${1#--config=}"
|
||||
if ! is_managed_override "$assignment"; then
|
||||
filtered_args+=("$1")
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
-c*)
|
||||
assignment="${1#-c}"
|
||||
if ! is_managed_override "$assignment"; then
|
||||
filtered_args+=("$1")
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
filtered_args+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
forced_args=(
|
||||
-c 'forced_login_method="chatgpt"'
|
||||
-c 'cli_auth_credentials_store="file"'
|
||||
)
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
exec s6-setuidgid abc env -u OPENAI_API_KEY \
|
||||
HOME="$RUNTIME_HOME" CODEX_HOME="$RUNTIME_HOME/.codex" \
|
||||
"$CODEX_REAL" "${forced_args[@]}" "${filtered_args[@]}"
|
||||
fi
|
||||
|
||||
unset OPENAI_API_KEY
|
||||
export HOME="$RUNTIME_HOME"
|
||||
export CODEX_HOME="$RUNTIME_HOME/.codex"
|
||||
exec "$CODEX_REAL" "${forced_args[@]}" "${filtered_args[@]}"
|
||||
SH
|
||||
} > "$CODEX_BIN"
|
||||
chmod 0755 "$CODEX_BIN"
|
||||
|
||||
chown -R -- "$(id -u abc):$(id -g abc)" "$CODEX_ROOT" \
|
||||
|| bashio::log.warning "Unable to set ownership on ${CODEX_ROOT}"
|
||||
ln -sfn "$CODEX_BIN" "$CODEX_LINK"
|
||||
|
||||
# Manage the root-level defaults used by terminal Codex and by `codex mcp-server`.
|
||||
#
|
||||
# `forced_login_method = "chatgpt"` makes subscription authentication the only permitted login
|
||||
# method, so an inherited OPENAI_API_KEY cannot silently switch this integration to API billing.
|
||||
# File storage is explicit because the container has no supported OS keyring.
|
||||
#
|
||||
# The managed block must be first: a bare TOML key after a [table] header belongs to that table.
|
||||
# Existing top-level definitions of the managed keys are removed before insertion; retaining them
|
||||
# would create duplicate keys and make the entire Codex configuration invalid.
|
||||
CODEX_SANDBOX_MODE="$(bashio::config 'codex_sandbox_mode' 'danger-full-access')"
|
||||
run_as_runtime_user mkdir -p "$RUNTIME_HOME/.codex"
|
||||
CODEX_SANDBOX_MODE="$CODEX_SANDBOX_MODE" RUNTIME_HOME="$RUNTIME_HOME" \
|
||||
run_as_runtime_user python3 - <<'PY' \
|
||||
|| bashio::log.warning "Unable to update the managed Codex configuration block"
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
BEGIN = "# BEGIN managed by claude_desktop addon"
|
||||
END = "# END managed by claude_desktop addon"
|
||||
MANAGED_KEYS = {
|
||||
"sandbox_mode",
|
||||
"approval_policy",
|
||||
"forced_login_method",
|
||||
"cli_auth_credentials_store",
|
||||
}
|
||||
|
||||
block = "\n".join(
|
||||
[
|
||||
BEGIN,
|
||||
"# Managed defaults for terminal and MCP-driven Codex runs.",
|
||||
f'sandbox_mode = "{os.environ["CODEX_SANDBOX_MODE"]}"',
|
||||
'approval_policy = "never"',
|
||||
"# Require ChatGPT subscription OAuth; do not fall back to API-key billing.",
|
||||
'forced_login_method = "chatgpt"',
|
||||
"# This container has no supported OS keyring; keep OAuth credentials in auth.json.",
|
||||
'cli_auth_credentials_store = "file"',
|
||||
END,
|
||||
]
|
||||
)
|
||||
|
||||
path = Path(os.environ["RUNTIME_HOME"]) / ".codex" / "config.toml"
|
||||
original = path.read_text(encoding="utf-8") if path.exists() else ""
|
||||
rest = re.sub(
|
||||
rf"{re.escape(BEGIN)}.*?{re.escape(END)}\n?",
|
||||
"",
|
||||
original,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
table_header = re.compile(r"^\s*\[\[?.+?\]\]?\s*(?:#.*)?$")
|
||||
assignment = re.compile(
|
||||
r'''^\s*(?P<key>[A-Za-z0-9_-]+|"[^"]+"|'[^']+')\s*='''
|
||||
)
|
||||
kept = []
|
||||
at_top_level = True
|
||||
for line in rest.splitlines(keepends=True):
|
||||
if at_top_level and table_header.match(line):
|
||||
at_top_level = False
|
||||
match = assignment.match(line) if at_top_level else None
|
||||
if match:
|
||||
key = match.group("key")
|
||||
if key[:1] in {'"', "'"}:
|
||||
key = key[1:-1]
|
||||
if key in MANAGED_KEYS:
|
||||
continue
|
||||
kept.append(line)
|
||||
|
||||
remainder = "".join(kept).lstrip("\n")
|
||||
new = block + "\n" + (("\n" + remainder) if remainder else "")
|
||||
tomllib.loads(new)
|
||||
if new != original:
|
||||
path.write_text(new, encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
PY
|
||||
|
||||
if [ -f "$RUNTIME_HOME/.codex/auth.json" ]; then
|
||||
bashio::log.info "Codex CLI is signed in with stored ChatGPT credentials"
|
||||
else
|
||||
bashio::log.info "Codex CLI is not signed in yet; run 'codex-login' to activate your ChatGPT subscription"
|
||||
fi
|
||||
@@ -293,6 +293,20 @@ if $TOKENSAVE_ENABLED; then
|
||||
done <<< "$TOKENSAVE_PROJECT_PATHS"
|
||||
fi
|
||||
|
||||
# Codex CLI is installed by 81-codex_cli.sh into /data/codex/bin — deliberately outside $HOME,
|
||||
# because is_managed() below treats any command under $HOME as user-installed.
|
||||
CODEX_BIN="/data/codex/bin/codex"
|
||||
CODEX_ENABLED=false
|
||||
CODEX_SANDBOX_MODE="$(bashio::config 'codex_sandbox_mode' 'workspace-write')"
|
||||
if bashio::config.true 'install_codex_cli'; then
|
||||
if [ -x "$CODEX_BIN" ]; then
|
||||
CODEX_ENABLED=true
|
||||
bashio::log.info "codex $("$CODEX_BIN" --version 2> /dev/null || true) available; registering the codex MCP server (sandbox: ${CODEX_SANDBOX_MODE})"
|
||||
else
|
||||
bashio::log.warning "codex is not available"
|
||||
fi
|
||||
fi
|
||||
|
||||
HA_MCP_ENABLED=false
|
||||
HA_MCP_URL=""
|
||||
HA_MCP_TOKEN=""
|
||||
@@ -314,6 +328,7 @@ fi
|
||||
HEADROOM_ENABLED="$HEADROOM_ENABLED" HEADROOM_BIN="$(command -v headroom || echo headroom)" \
|
||||
HEADROOM_HF_HOME="${HOME}/.headroom/hf" \
|
||||
TOKENSAVE_ENABLED="$TOKENSAVE_ENABLED" TOKENSAVE_BIN="$(command -v tokensave || echo tokensave)" \
|
||||
CODEX_ENABLED="$CODEX_ENABLED" CODEX_BIN="$CODEX_BIN" CODEX_SANDBOX_MODE="$CODEX_SANDBOX_MODE" \
|
||||
HA_MCP_ENABLED="$HA_MCP_ENABLED" HA_MCP_URL="$HA_MCP_URL" HA_MCP_TOKEN="$HA_MCP_TOKEN" \
|
||||
MCP_PROXY_BIN="$(command -v mcp-proxy || echo mcp-proxy)" \
|
||||
CLAUDE_DESKTOP_CONFIG="$CLAUDE_DESKTOP_CONFIG" CLAUDE_CODE_CONFIG="$CLAUDE_CODE_CONFIG" \
|
||||
@@ -326,6 +341,7 @@ MANAGED_BASENAMES = {
|
||||
"headroom": "headroom",
|
||||
"tokensave": "tokensave",
|
||||
"homeassistant": "mcp-proxy",
|
||||
"codex": "codex",
|
||||
}
|
||||
|
||||
desired = {}
|
||||
@@ -341,6 +357,25 @@ if os.environ["HEADROOM_ENABLED"] == "true":
|
||||
}
|
||||
if os.environ["TOKENSAVE_ENABLED"] == "true":
|
||||
desired["tokensave"] = {"command": os.environ["TOKENSAVE_BIN"], "args": ["serve"]}
|
||||
if os.environ["CODEX_ENABLED"] == "true":
|
||||
# `codex mcp-server` exposes Codex itself as an stdio MCP server (tools: codex, codex-reply),
|
||||
# which is what lets a Claude session hand a task to ChatGPT Codex. The sandbox/approval
|
||||
# policy is pinned with root-level `-c` overrides, which Codex forwards to the MCP server;
|
||||
# they must precede the subcommand. approval_policy is always "never" because an MCP-driven
|
||||
# run has nobody to answer a prompt. The sandbox defaults to workspace-write; users can opt
|
||||
# into danger-full-access explicitly if the nested sandbox is unavailable in their container.
|
||||
# 81-codex_cli.sh writes the same values into ~/.codex/config.toml so plain terminal `codex`
|
||||
# runs behave identically.
|
||||
desired["codex"] = {
|
||||
"command": os.environ["CODEX_BIN"],
|
||||
"args": [
|
||||
"-c",
|
||||
f'sandbox_mode="{os.environ["CODEX_SANDBOX_MODE"]}"',
|
||||
"-c",
|
||||
'approval_policy="never"',
|
||||
"mcp-server",
|
||||
],
|
||||
}
|
||||
if os.environ["HA_MCP_ENABLED"] == "true":
|
||||
# Home Assistant's MCP Server integration speaks stateless Streamable HTTP on /api/mcp;
|
||||
# mcp-proxy defaults to SSE, so the transport flags are required.
|
||||
@@ -531,6 +566,32 @@ else
|
||||
manage_claude_md_block ha-api-helper remove
|
||||
fi
|
||||
|
||||
# Registering the MCP server is not enough on its own: without guidance the model rarely reaches
|
||||
# for a second agent, the same gap the Headroom block above exists to close.
|
||||
if $CODEX_ENABLED; then
|
||||
manage_claude_md_block codex add <<'MD'
|
||||
## Delegating to ChatGPT Codex
|
||||
|
||||
The `codex` MCP server runs OpenAI's Codex agent locally, signed in with the user's ChatGPT
|
||||
subscription. It is a genuinely independent second agent — a different model family, reading the
|
||||
files itself — not a search tool. It is slow and costs the user's ChatGPT quota, so use it when a
|
||||
second opinion is worth minutes, not for routine lookups.
|
||||
|
||||
Good uses: an independent review of a design or a risky change before it lands; a second
|
||||
diagnosis of a bug you have a theory about but cannot confirm; a competing implementation of a
|
||||
self-contained piece you can then compare against your own.
|
||||
|
||||
Call `mcp__codex__codex` with `prompt` and always set `cwd` to the repository being discussed —
|
||||
Codex reads the files itself, so it needs the right working directory and enough context in the
|
||||
prompt to act without seeing this conversation. Continue an exchange with
|
||||
`mcp__codex__codex-reply` (note the hyphen) using the `threadId` it returned, rather than
|
||||
starting a fresh `codex` call. Treat its answers as a peer's opinion: verify claims about this
|
||||
codebase before acting on them.
|
||||
MD
|
||||
else
|
||||
manage_claude_md_block codex remove
|
||||
fi
|
||||
|
||||
if bashio::config.true 'install_rtk'; then
|
||||
if command -v rtk &> /dev/null; then
|
||||
bashio::log.info "Configuring rtk Claude Code integration"
|
||||
|
||||
@@ -2,14 +2,22 @@
|
||||
# shellcheck shell=bash
|
||||
set -e
|
||||
|
||||
# Earlier configuration scripts intentionally run as root. 20-folders.sh remapped abc to the
|
||||
# effective runtime identity (never root in bypass mode, where Claude Code refuses to run as
|
||||
# root). Reconcile ownership with that identity after all Claude configuration writes are
|
||||
# complete, as a safety net in case any intermediate step re-owned a managed path.
|
||||
# Earlier configuration scripts intentionally run as root. Reconcile the paths written by those
|
||||
# scripts with the final abc runtime identity and its configured persistent home.
|
||||
RUNTIME_UID="$(id -u abc)"
|
||||
RUNTIME_GID="$(id -g abc)"
|
||||
RUNTIME_HOME="$(getent passwd abc | cut -d: -f6)"
|
||||
|
||||
for managed_path in "$HOME/.claude" "$HOME/.claude.json" "$HOME/.config/Claude"; do
|
||||
if [ -z "$RUNTIME_HOME" ]; then
|
||||
bashio::log.warning "Unable to resolve the abc runtime home; skipping runtime ownership reconciliation"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for managed_path in \
|
||||
"$RUNTIME_HOME/.claude" \
|
||||
"$RUNTIME_HOME/.claude.json" \
|
||||
"$RUNTIME_HOME/.config/Claude" \
|
||||
"$RUNTIME_HOME/.codex"; do
|
||||
if [ -e "$managed_path" ]; then
|
||||
chown -R -- "${RUNTIME_UID}:${RUNTIME_GID}" "$managed_path" \
|
||||
|| bashio::log.warning "Unable to set effective runtime ownership on $managed_path"
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
#!/usr/bin/with-contenv bashio
|
||||
# Diagnose installation, registration, routing, indexing, permissions, and recorded savings without
|
||||
# printing MCP environment values (which may contain the Home Assistant access token).
|
||||
# printing MCP environment values or authentication material.
|
||||
# shellcheck shell=bash
|
||||
set +e
|
||||
set -o pipefail
|
||||
export NO_COLOR=1
|
||||
export PATH="/lsiopy/bin:/usr/local/bin:/usr/bin:/bin:${PATH}"
|
||||
|
||||
RUNTIME_HOME="$(getent passwd abc | cut -d: -f6)"
|
||||
if [ -z "$RUNTIME_HOME" ]; then
|
||||
RUNTIME_HOME="/data/data"
|
||||
fi
|
||||
|
||||
section() {
|
||||
printf '\n=== %s ===\n' "$1"
|
||||
}
|
||||
|
||||
section "Installed binaries"
|
||||
for tool in claude claude-desktop headroom rtk tokensave git gh rg jq shellcheck yamllint hadolint actionlint; do
|
||||
for tool in claude claude-desktop headroom rtk tokensave codex git gh rg jq shellcheck yamllint hadolint actionlint; do
|
||||
resolved="$(command -v "$tool" 2> /dev/null || true)"
|
||||
if [ -n "$resolved" ]; then
|
||||
printf '%-16s %s\n' "$tool" "$resolved"
|
||||
@@ -22,13 +27,14 @@ for tool in claude claude-desktop headroom rtk tokensave git gh rg jq shellcheck
|
||||
done
|
||||
|
||||
section "Configured switches"
|
||||
for option in permission_mode install_headroom headroom_wrap_claude_code expose_headroom_dashboard install_rtk install_tokensave install_caveman enable_tools_health_report; do
|
||||
for option in permission_mode install_headroom headroom_wrap_claude_code expose_headroom_dashboard install_rtk install_tokensave install_codex_cli codex_sandbox_mode install_caveman enable_tools_health_report; do
|
||||
printf '%-30s %s\n' "$option" "$(bashio::config "$option")"
|
||||
done
|
||||
|
||||
section "Runtime identity"
|
||||
printf '%-30s %s\n' "configured PUID:PGID" "$(bashio::config 'PUID'):$(bashio::config 'PGID')"
|
||||
printf '%-30s %s\n' "effective abc UID:GID" "$(id -u abc):$(id -g abc)"
|
||||
printf '%-30s %s\n' "abc runtime home" "$RUNTIME_HOME"
|
||||
printf '%-30s %s\n' "current process UID:GID" "$(id -u):$(id -g)"
|
||||
if [ "$(bashio::config 'permission_mode')" = "bypass" ]; then
|
||||
if [ "$(id -u abc)" -eq 0 ]; then
|
||||
@@ -39,11 +45,12 @@ if [ "$(bashio::config 'permission_mode')" = "bypass" ]; then
|
||||
fi
|
||||
|
||||
section "Claude Code permission state"
|
||||
python3 - <<'PY'
|
||||
RUNTIME_HOME="$RUNTIME_HOME" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
path = Path.home() / ".claude/settings.json"
|
||||
path = Path(os.environ["RUNTIME_HOME"]) / ".claude/settings.json"
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except FileNotFoundError:
|
||||
@@ -59,13 +66,15 @@ else:
|
||||
PY
|
||||
|
||||
section "MCP registrations (environment values redacted)"
|
||||
python3 - <<'PY'
|
||||
RUNTIME_HOME="$RUNTIME_HOME" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
home = Path(os.environ["RUNTIME_HOME"])
|
||||
paths = [
|
||||
Path.home() / ".claude.json",
|
||||
Path.home() / ".config/Claude/claude_desktop_config.json",
|
||||
home / ".claude.json",
|
||||
home / ".config/Claude/claude_desktop_config.json",
|
||||
]
|
||||
for path in paths:
|
||||
print(path)
|
||||
@@ -95,11 +104,12 @@ for path in paths:
|
||||
PY
|
||||
|
||||
section "Claude Code hooks"
|
||||
python3 - <<'PY'
|
||||
RUNTIME_HOME="$RUNTIME_HOME" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
path = Path.home() / ".claude/settings.json"
|
||||
path = Path(os.environ["RUNTIME_HOME"]) / ".claude/settings.json"
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except FileNotFoundError:
|
||||
@@ -148,18 +158,17 @@ section "TokenSave"
|
||||
if bashio::config.true 'install_tokensave'; then
|
||||
tokensave doctor --agent claude || true
|
||||
tokensave gain --all --range 30d || true
|
||||
# Capture before looping — see the matching comment in 82-claude_tools.sh: feeding the
|
||||
# loop straight from `< <(bashio::config ...)` yields an empty list under errexit.
|
||||
# Capture before looping — see the matching comment in 82-claude_tools.sh.
|
||||
TOKENSAVE_PROJECT_PATHS="$(bashio::config 'tokensave_project_paths')"
|
||||
while IFS= read -r configured_path || [ -n "$configured_path" ]; do
|
||||
if [ -z "$configured_path" ] || [ "$configured_path" = "null" ]; then
|
||||
continue
|
||||
fi
|
||||
repo_root="$(s6-setuidgid abc env HOME="$HOME" git -c safe.directory='*' -C "$configured_path" rev-parse --show-toplevel 2> /dev/null || true)"
|
||||
repo_root="$(s6-setuidgid abc env HOME="$RUNTIME_HOME" git -c safe.directory='*' -C "$configured_path" rev-parse --show-toplevel 2> /dev/null || true)"
|
||||
if [ -z "$repo_root" ]; then
|
||||
echo "${configured_path}: not a Git repository"
|
||||
elif [ -f "$repo_root/.tokensave/tokensave.db" ]; then
|
||||
s6-setuidgid abc env HOME="$HOME" tokensave status "$repo_root" --short || true
|
||||
s6-setuidgid abc env HOME="$RUNTIME_HOME" tokensave status "$repo_root" --short || true
|
||||
else
|
||||
echo "${repo_root}: NOT INITIALIZED"
|
||||
fi
|
||||
@@ -168,6 +177,45 @@ else
|
||||
echo "disabled"
|
||||
fi
|
||||
|
||||
section "Codex"
|
||||
if bashio::config.true 'install_codex_cli'; then
|
||||
codex_bin="/data/codex/bin/codex"
|
||||
if [ -x "$codex_bin" ]; then
|
||||
printf '%-30s %s\n' "installed" "$("$codex_bin" --version 2> /dev/null || echo 'FAILED TO RUN')"
|
||||
printf '%-30s %s\n' "installed version stamp" "$(cat /data/codex/bin/.version 2> /dev/null || echo 'MISSING')"
|
||||
printf '%-30s %s\n' "release policy" "latest stable, SHA-256 verified"
|
||||
printf '%-30s %s\n' "authentication policy" "ChatGPT subscription only"
|
||||
|
||||
# Never forward raw `login status` output: non-ChatGPT modes can include masked secret
|
||||
# fragments. Only print explicitly allow-listed states.
|
||||
codex_status="$(
|
||||
s6-setuidgid abc env -u OPENAI_API_KEY \
|
||||
HOME="$RUNTIME_HOME" CODEX_HOME="$RUNTIME_HOME/.codex" \
|
||||
"$codex_bin" login status 2>&1
|
||||
)"
|
||||
codex_status_rc=$?
|
||||
case "$codex_status" in
|
||||
*"Logged in using ChatGPT"*)
|
||||
echo "Logged in using ChatGPT"
|
||||
;;
|
||||
*"Not logged in"*)
|
||||
echo "Not logged in; run 'codex-login' to activate a ChatGPT subscription"
|
||||
;;
|
||||
*)
|
||||
if [ "$codex_status_rc" -eq 0 ]; then
|
||||
echo "Authenticated with a non-ChatGPT method; run 'codex-login' to enforce subscription authentication"
|
||||
else
|
||||
echo "Unable to determine Codex login status safely; run 'codex-login'"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
else
|
||||
echo "enabled but ${codex_bin} is MISSING (download failed or add-on not yet restarted)"
|
||||
fi
|
||||
else
|
||||
echo "disabled"
|
||||
fi
|
||||
|
||||
section "Claude routing"
|
||||
printf 'PATH claude: %s\n' "$(command -v claude 2> /dev/null || true)"
|
||||
printf 'real claude: %s\n' "$([ -x /usr/bin/claude ] && echo /usr/bin/claude || echo MISSING)"
|
||||
|
||||
48
claude_desktop/rootfs/usr/local/bin/codex-login
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/with-contenv bashio
|
||||
# Activate a ChatGPT subscription on the Codex CLI from inside the add-on.
|
||||
#
|
||||
# The add-on ships no browser, so the normal `codex login` flow cannot complete here.
|
||||
# `codex login --device-auth` prints a verification URL and one-time code that can be
|
||||
# approved on another device. Credentials persist in the abc runtime user's home.
|
||||
# shellcheck shell=bash
|
||||
set -o pipefail
|
||||
|
||||
CODEX_BIN="/data/codex/bin/codex"
|
||||
RUNTIME_HOME="$(getent passwd abc | cut -d: -f6)"
|
||||
if [ -z "$RUNTIME_HOME" ]; then
|
||||
echo "codex-login: unable to resolve the abc runtime home" >&2
|
||||
exit 1
|
||||
fi
|
||||
export HOME="$RUNTIME_HOME"
|
||||
export CODEX_HOME="$RUNTIME_HOME/.codex"
|
||||
|
||||
if ! bashio::config.true 'install_codex_cli'; then
|
||||
echo "codex-login: the install_codex_cli add-on option is disabled" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -x "$CODEX_BIN" ]; then
|
||||
echo "codex-login: ${CODEX_BIN} is not installed; enable install_codex_cli and restart the add-on" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Everything runs as abc so auth.json is readable by the same account that launches the MCP
|
||||
# server. Explicit HOME handling also makes container-console calls safe when the caller is root.
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
exec s6-setuidgid abc env -u OPENAI_API_KEY HOME="$RUNTIME_HOME" CODEX_HOME="$RUNTIME_HOME/.codex" "$0" "$@"
|
||||
fi
|
||||
|
||||
# The managed Codex config forces `chatgpt` authentication and file credential storage. An
|
||||
# inherited API key is removed as defense in depth so this helper cannot activate API billing.
|
||||
unset OPENAI_API_KEY
|
||||
|
||||
# Branch on the exit code, not the text: "Not logged in" also contains "logged in".
|
||||
if "$CODEX_BIN" login status; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "codex-login: starting ChatGPT subscription device-code sign-in."
|
||||
echo "codex-login: open the URL below on any device and enter the displayed code."
|
||||
|
||||
# Line-buffered so the URL and code appear while Codex is still polling in non-TTY callers.
|
||||
exec stdbuf -oL -eL "$CODEX_BIN" login --device-auth "$@"
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.4 KiB |
BIN
codex/stats.png
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 1.7 KiB |
BIN
emby/stats.png
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 1.7 KiB |
BIN
ente/stats.png
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.6 KiB |
BIN
gitea/stats.png
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.4 KiB |
BIN
grav/stats.png
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.4 KiB |
BIN
immich/stats.png
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.4 KiB |
BIN
inadyn/stats.png
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.3 KiB |
BIN
joal/stats.png
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.6 KiB |
BIN
joplin/stats.png
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 1.6 KiB |
BIN
kometa/stats.png
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.4 KiB |
BIN
lidarr/stats.png
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.3 KiB |
BIN
monica/stats.png
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.4 KiB |
BIN
mylar3/stats.png
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.2 KiB |