2026-06-09 08:45:33 +00:00
|
|
|
#!/bin/bash
|
|
|
|
|
# Per-user Claude Code Workstation launcher (devvm). Lands the user in their OWN
|
|
|
|
|
# ~/code clone (NOT a hardcoded /home/wizard/code) and names the Claude session
|
|
|
|
|
# after the tmux session so /resume, the prompt box, and the terminal title line
|
|
|
|
|
# up. Deployed via /etc/skel by setup-devvm.sh, so new accounts get it on
|
|
|
|
|
# `useradd -m`. Existing users are repointed to this during their migration.
|
|
|
|
|
echo ""
|
|
|
|
|
echo " Welcome, $(id -un)! 🚀"
|
|
|
|
|
echo ""
|
|
|
|
|
echo " Starting Claude Code in $HOME/code ..."
|
|
|
|
|
echo " (Right-click for tmux menu, or Ctrl+B then | or - to split)"
|
|
|
|
|
echo ""
|
|
|
|
|
|
2026-06-15 17:20:03 +00:00
|
|
|
# The native claude install lives in ~/.local/bin. This launcher runs in tmux's non-login
|
|
|
|
|
# env, which does NOT source the user's shell rc (where the native installer added it to
|
|
|
|
|
# PATH) — so `claude` would appear missing here. Put it on PATH ourselves; guarded/idempotent.
|
|
|
|
|
case ":$PATH:" in
|
|
|
|
|
*":$HOME/.local/bin:"*) ;;
|
|
|
|
|
*) export PATH="$HOME/.local/bin:$PATH" ;;
|
|
|
|
|
esac
|
|
|
|
|
|
2026-06-09 08:45:33 +00:00
|
|
|
name_args=()
|
|
|
|
|
if [ -n "${TMUX:-}" ]; then
|
|
|
|
|
sess="$(tmux display-message -p '#{session_name}' 2>/dev/null)"
|
|
|
|
|
[ -n "$sess" ] && name_args=(--name "$sess")
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
cd "$HOME/code" 2>/dev/null || cd "$HOME"
|
|
|
|
|
|
workstation: per-user code_layout — workspace puts project repos under ~/code (ancamilea + tripit)
Viktor asked to restructure Anca's setup: her ~/code WAS the infra clone
itself; he wants ~/code to be the directory where all her project repos
(tripit etc.) live side by side, with infra moved to a subdirectory.
- roster.yaml gains per-user 'code_layout: single|workspace' + 'repos',
validated + derived by roster_engine.py (12 new tests, 40 total).
- t3-provision-users reconcile: auto-migrates a single-layout ~/code to
~/code/infra (running processes follow the moved inode), hoists nested
project clones to the workspace root, clones roster repos from Forgejo
AS the user (their PAT makes private repos work), and wires the
documented forgejo remote + forgejo/master upstream into clones that
predate that contract.
- Fixed a latent TSV bug: empty jq @tsv fields collapse under tab-IFS
read, shifting later fields left (groups was only safe by being the
last field) — emit '-' sentinels instead.
- start-claude.sh session freshen is layout-aware (freshens each repo
under ~/code for workspace users).
- managed claudeMd + AGENTS.md non-admin recipe + multi-tenancy.md
updated in the same change.
Applied live: ancamilea = workspace (infra at ~/code/infra, her existing
tripit clone hoisted to ~/code/tripit, master upstream switched to
forgejo/master); emo stays single layout, untouched. [ci skip]
2026-06-10 18:05:31 +00:00
|
|
|
# Freshen the user's clone(s) at session start so they begin on current upstream
|
|
|
|
|
# state (the hourly t3-provision-users reconcile does the same in the background).
|
|
|
|
|
# Single layout freshens ~/code itself; workspace layout freshens each repo under
|
|
|
|
|
# ~/code. Fast-forward only, and only when safe (on master + clean tree); hard
|
|
|
|
|
# 10s fetch cap per repo so an offline remote never stalls the launch.
|
|
|
|
|
freshen_repo() {
|
|
|
|
|
GIT_TERMINAL_PROMPT=0 timeout 10 git -C "$1" fetch --all --prune --quiet 2>/dev/null || true
|
2026-06-10 18:20:59 +00:00
|
|
|
# ff whatever branch is checked out (master, main, ...) when that is provably
|
|
|
|
|
# safe: on a branch, clean tree, upstream configured. Never rebases/merges.
|
|
|
|
|
if [ -n "$(git -C "$1" symbolic-ref --short -q HEAD)" ] \
|
workstation: per-user code_layout — workspace puts project repos under ~/code (ancamilea + tripit)
Viktor asked to restructure Anca's setup: her ~/code WAS the infra clone
itself; he wants ~/code to be the directory where all her project repos
(tripit etc.) live side by side, with infra moved to a subdirectory.
- roster.yaml gains per-user 'code_layout: single|workspace' + 'repos',
validated + derived by roster_engine.py (12 new tests, 40 total).
- t3-provision-users reconcile: auto-migrates a single-layout ~/code to
~/code/infra (running processes follow the moved inode), hoists nested
project clones to the workspace root, clones roster repos from Forgejo
AS the user (their PAT makes private repos work), and wires the
documented forgejo remote + forgejo/master upstream into clones that
predate that contract.
- Fixed a latent TSV bug: empty jq @tsv fields collapse under tab-IFS
read, shifting later fields left (groups was only safe by being the
last field) — emit '-' sentinels instead.
- start-claude.sh session freshen is layout-aware (freshens each repo
under ~/code for workspace users).
- managed claudeMd + AGENTS.md non-admin recipe + multi-tenancy.md
updated in the same change.
Applied live: ancamilea = workspace (infra at ~/code/infra, her existing
tripit clone hoisted to ~/code/tripit, master upstream switched to
forgejo/master); emo stays single layout, untouched. [ci skip]
2026-06-10 18:05:31 +00:00
|
|
|
&& [ -z "$(git -C "$1" status --porcelain 2>/dev/null)" ] \
|
2026-06-10 18:20:59 +00:00
|
|
|
&& git -C "$1" rev-parse --verify -q '@{upstream}' >/dev/null 2>&1; then
|
|
|
|
|
git -C "$1" merge --ff-only '@{upstream}' >/dev/null 2>&1 || true
|
2026-06-10 09:41:38 +00:00
|
|
|
fi
|
workstation: per-user code_layout — workspace puts project repos under ~/code (ancamilea + tripit)
Viktor asked to restructure Anca's setup: her ~/code WAS the infra clone
itself; he wants ~/code to be the directory where all her project repos
(tripit etc.) live side by side, with infra moved to a subdirectory.
- roster.yaml gains per-user 'code_layout: single|workspace' + 'repos',
validated + derived by roster_engine.py (12 new tests, 40 total).
- t3-provision-users reconcile: auto-migrates a single-layout ~/code to
~/code/infra (running processes follow the moved inode), hoists nested
project clones to the workspace root, clones roster repos from Forgejo
AS the user (their PAT makes private repos work), and wires the
documented forgejo remote + forgejo/master upstream into clones that
predate that contract.
- Fixed a latent TSV bug: empty jq @tsv fields collapse under tab-IFS
read, shifting later fields left (groups was only safe by being the
last field) — emit '-' sentinels instead.
- start-claude.sh session freshen is layout-aware (freshens each repo
under ~/code for workspace users).
- managed claudeMd + AGENTS.md non-admin recipe + multi-tenancy.md
updated in the same change.
Applied live: ancamilea = workspace (infra at ~/code/infra, her existing
tripit clone hoisted to ~/code/tripit, master upstream switched to
forgejo/master); emo stays single layout, untouched. [ci skip]
2026-06-10 18:05:31 +00:00
|
|
|
}
|
|
|
|
|
if [ -d "$HOME/code/.git" ]; then
|
|
|
|
|
freshen_repo "$HOME/code"
|
|
|
|
|
else
|
|
|
|
|
for repo_git in "$HOME"/code/*/.git; do
|
|
|
|
|
[ -d "$repo_git" ] && freshen_repo "${repo_git%/.git}"
|
|
|
|
|
done
|
2026-06-10 09:41:38 +00:00
|
|
|
fi
|
|
|
|
|
|
2026-06-15 17:12:05 +00:00
|
|
|
# Run the NATIVE `claude` (the recommended install: ~/.local/bin/claude, self-updating).
|
|
|
|
|
# No npm/npx. If the native binary is missing (a fresh account before the hourly reconcile
|
|
|
|
|
# has provisioned it), bootstrap it with the official native installer, then run it.
|
2026-06-09 08:45:33 +00:00
|
|
|
launch() {
|
2026-06-15 17:12:05 +00:00
|
|
|
if ! command -v claude >/dev/null 2>&1; then
|
|
|
|
|
echo " Installing Claude Code (native) for $(id -un) …"
|
|
|
|
|
curl -fsSL https://claude.ai/install.sh | bash || return 127
|
|
|
|
|
export PATH="$HOME/.local/bin:$PATH"
|
2026-06-09 08:45:33 +00:00
|
|
|
fi
|
2026-06-15 17:12:05 +00:00
|
|
|
claude "$@"
|
2026-06-09 08:45:33 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-15 14:37:59 +00:00
|
|
|
# Re-assert Claude Code's first-run onboarding flag before launch. ~/.claude.json is a
|
|
|
|
|
# SINGLE file that ALL of a user's concurrent claude processes (this terminal, their
|
|
|
|
|
# t3-serve instance, agent/SDK sessions) read-modify-write; a stale writer periodically
|
|
|
|
|
# drops top-level keys — including hasCompletedOnboarding — which throws the next
|
|
|
|
|
# interactive session back to the "Choose the text style" wizard even though the user is
|
|
|
|
|
# fully logged in (credentials live in the SEPARATE ~/.claude/.credentials.json, which is
|
|
|
|
|
# never affected). Idempotent, runs as the user right before launch, never clobbers other
|
|
|
|
|
# keys. Best-effort: no-op if jq is missing or the file is empty/corrupt (claude self-heals).
|
|
|
|
|
ensure_onboarding() {
|
|
|
|
|
command -v jq >/dev/null 2>&1 || return 0
|
|
|
|
|
local cfg="$HOME/.claude.json" ver tmp
|
|
|
|
|
ver="$(claude --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)"
|
|
|
|
|
if [ -s "$cfg" ]; then
|
|
|
|
|
jq -e . "$cfg" >/dev/null 2>&1 || return 0 # corrupt -> leave for claude
|
|
|
|
|
[ "$(jq -r '.hasCompletedOnboarding // false' "$cfg")" = "true" ] && return 0 # already set -> no write
|
|
|
|
|
elif [ -e "$cfg" ]; then
|
|
|
|
|
return 0 # empty (mid-write?) -> leave it
|
|
|
|
|
fi
|
|
|
|
|
tmp="$(mktemp "${cfg}.XXXXXX")" || return 0
|
|
|
|
|
if [ -f "$cfg" ]; then
|
|
|
|
|
jq --arg v "$ver" '.hasCompletedOnboarding = true
|
|
|
|
|
| (if $v != "" then .lastOnboardingVersion = $v else . end)' "$cfg" > "$tmp" 2>/dev/null \
|
|
|
|
|
&& chmod 600 "$tmp" && mv "$tmp" "$cfg" || rm -f "$tmp"
|
|
|
|
|
else
|
|
|
|
|
jq -n --arg v "$ver" '{hasCompletedOnboarding: true}
|
|
|
|
|
+ (if $v != "" then {lastOnboardingVersion: $v} else {} end)' > "$tmp" 2>/dev/null \
|
|
|
|
|
&& chmod 600 "$tmp" && mv "$tmp" "$cfg" || rm -f "$tmp"
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
ensure_onboarding
|
|
|
|
|
|
workstation: per-user long-lived Claude token to end concurrent-refresh logout
A heavy user (emo) runs 8+ always-on `claude` agents + their t3-serve instance,
all sharing one ~/.claude/.credentials.json. When the shared access token expires
the processes refresh simultaneously; OAuth refresh-token rotation makes the
losing writer persist an EMPTY refresh token, logging the user out roughly every
access-token lifetime (~8h). Re-issuing the credential never sticks — the race
recurs (this is why emo's "standalone token" fix kept regressing).
Fix: an opt-in, per-user, non-rotating setup-token (sk-ant-oat01, ~1y, scope
user:inference) kept in the user's OWN Vault path (field `setup_token`).
claude-auth-sync materializes it to a user-owned
~/.config/claude-auth-sync/claude-oauth.env and, while it is present, SKIPS the
rotating-credential validate/backup/restore (so no false
WorkstationClaudeAuthInvalid). start-claude.sh and t3-serve@.service load it as
CLAUDE_CODE_OAUTH_TOKEN, so every session of that user uses the non-rotating
token and there is nothing to race on.
Fail-safe + opt-in: with no `setup_token` in Vault, every path is a no-op, so
users on the normal per-user Enterprise-SSO flow are unaffected. This is each
user's OWN identity, never the forbidden shared CLAUDE_CODE_OAUTH_TOKEN. Runbook
documents enable/disable/rotate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 08:07:43 +00:00
|
|
|
# Load a per-user long-lived CLAUDE_CODE_OAUTH_TOKEN if claude-auth-sync has
|
|
|
|
|
# materialized one from this user's own Vault path. A non-rotating setup-token
|
|
|
|
|
# sidesteps the shared ~/.claude/.credentials.json OAuth refresh-token race that
|
|
|
|
|
# logs out users running many concurrent agents (interactive + t3 + always-on).
|
|
|
|
|
# Absent file -> no-op (normal per-user Enterprise-SSO flow). The user's OWN
|
|
|
|
|
# token; never shared between OS users.
|
|
|
|
|
_oauth_env="$HOME/.config/claude-auth-sync/claude-oauth.env"
|
|
|
|
|
if [ -r "$_oauth_env" ]; then set -a; . "$_oauth_env"; set +a; fi
|
|
|
|
|
|
2026-06-09 08:45:33 +00:00
|
|
|
# Deliberately not `exec` so we can branch on the exit code: clean quit ends the
|
|
|
|
|
# pane (ttyd closes the terminal); a crash drops to a shell so the tmux session
|
|
|
|
|
# isn't destroyed-and-recreated in a ttyd auto-reconnect loop.
|
2026-06-09 19:35:29 +00:00
|
|
|
# No --model flag: inherit the org-wide default from /etc/claude-code/managed-settings.json
|
|
|
|
|
# (an explicit --model would override that managed default for every launched session).
|
|
|
|
|
launch --dangerously-skip-permissions "${name_args[@]}"
|
2026-06-09 08:45:33 +00:00
|
|
|
code=$?
|
|
|
|
|
[ "$code" -eq 0 ] && exit 0
|
|
|
|
|
|
|
|
|
|
echo ""
|
|
|
|
|
echo " claude exited abnormally (status $code). Dropping to a shell — your tmux session is preserved."
|
|
|
|
|
echo " Re-launch any time with: ~/start-claude.sh"
|
|
|
|
|
echo ""
|
|
|
|
|
exec "${SHELL:-/bin/bash}" -l
|