Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

79 changes: 79 additions & 0 deletions home-manager/services/cliproxyapi/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ let
)
);

keychainSyncScript = pkgs.replaceVars ./scripts/keychain-sync.sh {
email = "shunkakinoki@gmail.com";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The email for keychainSyncScript is hardcoded. It would be more flexible and maintainable to make this configurable, perhaps by deriving it from config.home.username or allowing it to be passed as an argument, especially if this configuration is intended for multiple users or environments.

keychain_account = "shunkakinoki";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The keychain_account is hardcoded. Similar to the email, this should ideally be configurable to support different user accounts or to avoid exposing sensitive details directly in the configuration.

jq = "${pkgs.jq}/bin/jq";
};

wrapperScript = pkgs.replaceVars ./scripts/wrapper.sh {
common = commonScript;
};
Expand Down Expand Up @@ -105,6 +111,53 @@ in
};
};

# Keychain sync - extract Claude/Codex OAuth from local stores into auth dir
launchd.agents.cliproxyapi-keychain-sync = lib.mkIf pkgs.stdenv.isDarwin {
enable = true;
config = {
ProgramArguments = [
"${pkgs.bash}/bin/bash"
"${keychainSyncScript}"
];
Environment = {
PATH = "${
lib.makeBinPath [
pkgs.bash
pkgs.coreutils
pkgs.jq
]
}:/usr/bin";
};
StartInterval = 300;
RunAtLoad = true;
StandardOutPath = "/tmp/cliproxyapi-keychain-sync.log";
StandardErrorPath = "/tmp/cliproxyapi-keychain-sync.error.log";
Comment on lines +133 to +134

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Logging to /tmp/ is generally not persistent across reboots and can be cleared by the system. For important service logs, consider using a more permanent location, such as a subdirectory within the user's home directory (e.g., ~/.local/state/cliproxyapi/logs) or a system-wide log directory if appropriate permissions are managed.

};
};

# Periodic sync - pull auth files from S3 every 5 minutes
launchd.agents.cliproxyapi-sync = lib.mkIf pkgs.stdenv.isDarwin {
enable = true;
config = {
ProgramArguments = [
"${pkgs.bash}/bin/bash"
"${hydrateScript}"
];
Environment = {
PATH = "${
lib.makeBinPath [
pkgs.bash
pkgs.coreutils
pkgs.awscli2
]
}:/opt/homebrew/bin:/usr/local/bin:/usr/bin";
};
StartInterval = 300;
StandardOutPath = "/tmp/cliproxyapi-sync.log";
StandardErrorPath = "/tmp/cliproxyapi-sync.error.log";
Comment on lines +156 to +157

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the cliproxyapi-keychain-sync agent, logging to /tmp/ for cliproxyapi-sync is not ideal for persistence. Please consider a more permanent logging location.

};
};

# Linux systemd
systemd.user.services.cliproxyapi = lib.mkIf pkgs.stdenv.isLinux {
Unit = {
Expand Down Expand Up @@ -160,4 +213,30 @@ in
}";
};
};

# Periodic sync - pull auth files from S3 every 5 minutes
systemd.user.timers.cliproxyapi-sync = lib.mkIf pkgs.stdenv.isLinux {
Unit.Description = "Periodically sync auth files from S3";
Timer = {
OnBootSec = "1min";
OnUnitActiveSec = "5min";
Unit = "cliproxyapi-sync.service";
};
Install.WantedBy = [ "timers.target" ];
};

systemd.user.services.cliproxyapi-sync = lib.mkIf pkgs.stdenv.isLinux {
Unit.Description = "CLIProxyAPI auth sync from S3";
Service = {
Type = "oneshot";
ExecStart = "${pkgs.bash}/bin/bash ${hydrateScript}";
Environment = "PATH=${
lib.makeBinPath [
pkgs.bash
pkgs.awscli2
pkgs.coreutils
]
}";
};
};
}
143 changes: 143 additions & 0 deletions home-manager/services/cliproxyapi/scripts/keychain-sync.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# Sync OAuth tokens from local credential stores into cliproxyapi auth dir.
#
# Sources:
# - Claude Code: macOS Keychain (Claude Code-credentials / <username>)
# - Codex CLI: ~/.codex/auth.json
#
# On first run, macOS will prompt to allow keychain access — click "Always Allow".
# shellcheck source=/dev/null
set -euo pipefail

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The script uses set -euo pipefail. According to the general rules, "Do not use set -e in PAM execution scripts. These scripts must handle failures gracefully (e.g., during credential decryption or in retry loops) to avoid blocking the user's login." While this script isn't strictly a PAM script, its role in syncing authentication tokens suggests that failures should be handled gracefully rather than causing an immediate exit, which could impact user experience or automated processes. Consider replacing set -e with explicit error handling for critical commands.

References
  1. Do not use set -e in PAM execution scripts. These scripts must handle failures gracefully (e.g., during credential decryption or in retry loops) to avoid blocking the user's login.


AUTH_DIR="${HOME}/.cli-proxy-api/objectstore/auths"
EMAIL="@email@"
KEYCHAIN_ACCOUNT="@keychain_account@"
JQ="@jq@"

SECURITY="${SECURITY:-/usr/bin/security}"

mkdir -p "$AUTH_DIR"

changed=0

# --- Claude Code (keychain) ---
sync_claude() {
local raw
raw=$("$SECURITY" find-generic-password \
-s "Claude Code-credentials" \
-a "$KEYCHAIN_ACCOUNT" \
-w 2>/dev/null) || return 0

if [ -z "$raw" ]; then
echo "[$(date)] Claude: keychain entry empty, skipping" >&2
return 0
fi

# Claude Code stores JSON: { claudeAiOauth: { accessToken, refreshToken, expiresAt (epoch ms), ... } }
local access_token refresh_token expires_at
access_token=$($JQ -r '.claudeAiOauth.accessToken // empty' <<<"$raw" 2>/dev/null) || true
refresh_token=$($JQ -r '.claudeAiOauth.refreshToken // empty' <<<"$raw" 2>/dev/null) || true
expires_at=$($JQ -r '.claudeAiOauth.expiresAt // empty' <<<"$raw" 2>/dev/null) || true

# Convert epoch ms to ISO 8601
if [ -n "$expires_at" ] && [ "$expires_at" != "null" ]; then
expires_at=$(date -u -r "$((expires_at / 1000))" +%Y-%m-%dT%H:%M:%S+00:00 2>/dev/null) || expires_at=""

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expiresAt conversion uses date -u -r <epoch> which is BSD-date syntax, but this agent’s PATH includes pkgs.coreutils (GNU date), where -r means “reference file” and the conversion will fail (leaving expired empty). Consider either using a GNU-compatible conversion (date -d "@...") or explicitly calling the system /bin/date (and avoiding GNU date shadowing) so the field is populated reliably.

Suggested change
expires_at=$(date -u -r "$((expires_at / 1000))" +%Y-%m-%dT%H:%M:%S+00:00 2>/dev/null) || expires_at=""
expires_at=$(/bin/date -u -r "$((expires_at / 1000))" +%Y-%m-%dT%H:%M:%S+00:00 2>/dev/null) || expires_at=""

Copilot uses AI. Check for mistakes.
fi

if [ -z "$access_token" ]; then
echo "[$(date)] Claude: no access_token in keychain data, skipping" >&2
return 0
fi

local dest="$AUTH_DIR/claude-${EMAIL}.json"
local new_json
# shellcheck disable=SC2016
new_json=$($JQ -n \
--arg at "$access_token" \
--arg rt "${refresh_token:-}" \
--arg email "$EMAIL" \
--arg expired "${expires_at:-}" \
--arg last_refresh "$(date -u +%Y-%m-%dT%H:%M:%S+00:00)" \
'{
access_token: $at,
disabled: false,
email: $email,
expired: $expired,
id_token: "",
last_refresh: $last_refresh,
refresh_token: $rt,
type: "claude"
}')

# Only write if token changed
local existing_at=""
if [ -f "$dest" ]; then
existing_at=$($JQ -r '.access_token // empty' "$dest" 2>/dev/null) || true
fi

if [ "$access_token" != "$existing_at" ]; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Writing directly to $dest using printf '%s' "$new_json" >"$dest" is not atomic. If the script is interrupted during the write operation, the destination file could be corrupted or left in an incomplete state. For sensitive data like authentication tokens, it's safer to write to a temporary file first and then atomically move it to the final destination.

Suggested change
if [ "$access_token" != "$existing_at" ]; then
printf '%s' "$new_json" >"${dest}.tmp" && mv "${dest}.tmp" "$dest"

printf '%s' "$new_json" >"$dest"

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auth JSON files containing access tokens are written with the process umask and via direct redirection. On many systems this can create world-readable files (e.g. mode 0644) and it’s also non-atomic (a watcher could read a partially-written file). Consider setting a restrictive umask / chmod to 0600 and writing via a temp file + atomic rename (mv) to avoid token exposure and partial reads.

Suggested change
printf '%s' "$new_json" >"$dest"
# Write via a temp file with restrictive permissions, then atomically rename
local tmp_dest
tmp_dest=$(mktemp "${dest}.tmp.XXXXXX")
printf '%s' "$new_json" >"$tmp_dest"
chmod 600 "$tmp_dest"
mv -f "$tmp_dest" "$dest"

Copilot uses AI. Check for mistakes.
echo "[$(date)] Claude: synced new token to $dest" >&2
changed=1
fi
}

# --- Codex CLI (file) ---
sync_codex() {
local auth_file="${HOME}/.codex/auth.json"
if [ ! -f "$auth_file" ]; then
return 0
fi

local access_token refresh_token account_id
access_token=$($JQ -r '.tokens.access_token // .OPENAI_API_KEY // empty' "$auth_file" 2>/dev/null) || true
refresh_token=$($JQ -r '.tokens.refresh_token // empty' "$auth_file" 2>/dev/null) || true
account_id=$($JQ -r '.account_id // empty' "$auth_file" 2>/dev/null) || true

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex account_id is extracted from .account_id, but the Codex auth format used elsewhere (and in this repo’s spec fixtures) nests it under .tokens.account_id. This will always write an empty account_id in the generated cliproxyapi auth JSON. Update the jq path to read the correct field (optionally supporting both layouts for compatibility).

Suggested change
account_id=$($JQ -r '.account_id // empty' "$auth_file" 2>/dev/null) || true
account_id=$($JQ -r '.tokens.account_id // .account_id // empty' "$auth_file" 2>/dev/null) || true

Copilot uses AI. Check for mistakes.

if [ -z "$access_token" ]; then
echo "[$(date)] Codex: no token in auth file, skipping" >&2
return 0
fi

local dest="$AUTH_DIR/codex-${EMAIL}.json"
local last_refresh
last_refresh=$($JQ -r '.last_refresh // empty' "$auth_file" 2>/dev/null) || true

local new_json
# shellcheck disable=SC2016
new_json=$($JQ -n \
--arg at "$access_token" \
--arg rt "${refresh_token:-}" \
--arg email "$EMAIL" \
--arg account_id "${account_id:-}" \
--arg last_refresh "${last_refresh:-$(date -u +%Y-%m-%dT%H:%M:%S+00:00)}" \
'{
access_token: $at,
account_id: $account_id,
disabled: false,
email: $email,
expired: "",
id_token: "",
last_refresh: $last_refresh,
refresh_token: $rt,
type: "codex"
}')

local existing_at=""
if [ -f "$dest" ]; then
existing_at=$($JQ -r '.access_token // empty' "$dest" 2>/dev/null) || true
fi

if [ "$access_token" != "$existing_at" ]; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the Claude sync function, this write operation is not atomic. Consider using a temporary file and then atomically moving it to prevent data corruption in case of interruption.

Suggested change
if [ "$access_token" != "$existing_at" ]; then
printf '%s' "$new_json" >"${dest}.tmp" && mv "${dest}.tmp" "$dest"

printf '%s' "$new_json" >"$dest"

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above for the Codex auth file: writing secrets via direct redirection can create overly-permissive permissions and is not atomic, which can race with the backup watcher reading/syncing. Prefer restrictive permissions (0600) and an atomic write pattern (temp file + rename).

Suggested change
printf '%s' "$new_json" >"$dest"
umask 077
local tmp
tmp="$(mktemp "${dest}.tmp.XXXXXX")"
printf '%s' "$new_json" >"$tmp"
chmod 600 "$tmp" 2>/dev/null || true
mv "$tmp" "$dest"

Copilot uses AI. Check for mistakes.
echo "[$(date)] Codex: synced new token to $dest" >&2
changed=1
fi
}

sync_claude
sync_codex

if [ "$changed" -eq 1 ]; then
echo "[$(date)] Auth files updated — backup watcher will push to S3" >&2
fi
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"dependencies": {
"@anthropic-ai/claude-code": "^2.1.81",
"@augmentcode/auggie": "^0.20.1",
"@beads/bd": "^0.62.0",
"@biomejs/biome": "^2.4.8",
"@ccusage/amp": "^18.0.10",
"@ccusage/codex": "^18.0.10",
Expand Down Expand Up @@ -66,6 +67,7 @@
"trustedDependencies": [
"@anthropic-ai/claude-code",
"@augmentcode/auggie",
"@beads/bd",
"@biomejs/biome",
"@ccusage/amp",
"@ccusage/codex",
Expand Down
Loading
Loading