coding-agent-wrapper 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
caw/auth/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # caw auth — Credential Management for Docker Containers
2
+
3
+ Coding agents store OAuth credentials in home directory files (e.g., `~/.claude/.credentials.json`). When running agents inside Docker containers, token refresh creates new tokens (OAuth rotation), invalidating the host's tokens.
4
+
5
+ `caw auth` solves this by making `~/.caw/auth/` the canonical location for all credential files, mounting it read-write into containers, and using symlinks so writes propagate in real-time.
6
+
7
+ ## How it works
8
+
9
+ ```
10
+ HOST: CONTAINER:
11
+
12
+ ~/.claude/.credentials.json /home/playground/.claude/.credentials.json
13
+ ↓ symlink ↑ copy + inotify sync
14
+ ~/.caw/auth/claude/credentials.json ←—RW mount—→ /tmp/caw_auth/claude/credentials.json
15
+ ```
16
+
17
+ On the host, credential files are replaced with symlinks into `~/.caw/auth/`. Inside the container, credentials are copied from the bind mount and kept in sync bidirectionally via an inotify-based guard.
18
+
19
+ ## Usage
20
+
21
+ ```bash
22
+ caw auth setup # collect + symlink all detected agents
23
+ caw auth setup --agents claude codex # specific agents only
24
+ caw auth setup --force # overwrite existing auth dir and backups
25
+
26
+ caw auth status # symlink state, token expiry, last modified
27
+
28
+ docker run $(caw auth docker-flags) -v ./project:/work my-image
29
+
30
+ caw auth teardown # restore original files from backups
31
+ caw auth teardown --dry-run # preview what would be restored
32
+ ```
33
+
34
+ ## Commands
35
+
36
+ ### `caw auth setup`
37
+
38
+ Reads credentials from `~/.claude/`, `~/.codex/`, `~/.gemini/`, `~/.config/cursor/`. Writes canonical files to `~/.caw/auth/`, generates `manifest.json` and `setup-container.sh`, then replaces host credential files with symlinks.
39
+
40
+ - Credential files (tokens, OAuth) are marked `strategy: symlink` — shared read-write
41
+ - Config files (.claude.json, config.toml) are marked `strategy: copy` — cleaned/stripped for containers
42
+
43
+ ### `caw auth teardown`
44
+
45
+ Restores original credential files from `~/.caw/auth/.backups/`.
46
+
47
+ ### `caw auth status`
48
+
49
+ Shows a table with symlink state, token expiry, and last modified time for all managed files.
50
+
51
+ ### `caw auth docker-flags`
52
+
53
+ Outputs the `-v` flag for Docker:
54
+
55
+ ```bash
56
+ $ caw auth docker-flags
57
+ -v /home/user/.caw/auth:/tmp/caw_auth:rw
58
+ ```
59
+
60
+ ## Container setup
61
+
62
+ The generated `setup-container.sh` runs inside the container (called from your entrypoint). It reads `manifest.json`, copies credentials, and starts a bidirectional inotify guard for credential sync.
63
+
64
+ ```bash
65
+ # In your entrypoint.sh:
66
+ if [ -f /tmp/caw_auth/setup-container.sh ]; then
67
+ /tmp/caw_auth/setup-container.sh /tmp/caw_auth /home/playground playground
68
+ fi
69
+ ```
70
+
71
+ Requires `jq` in the container image. `inotify-tools` is installed automatically if not present.
72
+
73
+ ## Directory structure
74
+
75
+ ```
76
+ ~/.caw/auth/
77
+ ├── manifest.json # file map + metadata
78
+ ├── setup-container.sh # POSIX script for container setup
79
+ ├── .backups/ # originals before symlinking
80
+ ├── claude/
81
+ │ ├── credentials.json # credential (symlinked)
82
+ │ └── config.json # cleaned .claude.json (copied)
83
+ ├── codex/
84
+ │ ├── auth.json # credential (symlinked)
85
+ │ └── config.toml # cleaned config (copied)
86
+ ├── gemini/
87
+ │ ├── oauth_creds.json # credential (symlinked)
88
+ │ └── ... # config files (copied)
89
+ └── cursor/
90
+ ├── auth.json # credential (symlinked)
91
+ └── cli-config.json # cleaned config (copied)
92
+ ```
93
+
94
+ ## Supported agents
95
+
96
+ | Agent | Credential files | Config files |
97
+ |-------|-----------------|--------------|
98
+ | Claude Code | `.claude/.credentials.json` | `.claude.json` (stripped to essential keys) |
99
+ | Codex | `.codex/auth.json` | `.codex/config.toml` (local trust removed) |
100
+ | Gemini CLI | `.gemini/oauth_creds.json` | `google_accounts.json`, `settings.json`, `installation_id` |
101
+ | Cursor | `.config/cursor/auth.json` | `.cursor/cli-config.json` (stripped) |
102
+
103
+ ## Programmatic API
104
+
105
+ ```python
106
+ from caw.auth import setup, teardown, get_status, get_docker_flags
107
+
108
+ setup(agents=["claude"])
109
+ statuses = get_status()
110
+ flags = get_docker_flags()
111
+ teardown()
112
+ ```
113
+
114
+ See [`examples/auth.py`](../../examples/auth.py) for a full example.
115
+
116
+ ## Known limitation
117
+
118
+ OAuth token rotation means a refresh returns a new refresh token, invalidating the old one. If two processes refresh simultaneously, one gets an invalid token. Don't run the same agent identity in two places at once.
caw/auth/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """caw.auth — Credential management for Docker containers."""
2
+
3
+ from .collector import AUTH_DIR, setup
4
+ from .linker import teardown
5
+ from .manifest import AgentManifest, Manifest, ManifestFile
6
+ from .providers import PROVIDERS, AgentAuthProvider, CollectedFile
7
+ from .status import AuthFileStatus, get_docker_flags, get_status, status
8
+
9
+ __all__ = [
10
+ "AUTH_DIR",
11
+ "AgentAuthProvider",
12
+ "AgentManifest",
13
+ "AuthFileStatus",
14
+ "CollectedFile",
15
+ "Manifest",
16
+ "ManifestFile",
17
+ "PROVIDERS",
18
+ "get_docker_flags",
19
+ "get_status",
20
+ "setup",
21
+ "status",
22
+ "teardown",
23
+ ]
caw/auth/cli.py ADDED
@@ -0,0 +1,68 @@
1
+ """CLI subcommands for `caw auth`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated, Optional
7
+
8
+ import typer
9
+
10
+
11
+ app = typer.Typer(help="Manage credentials for Docker containers.")
12
+
13
+
14
+ @app.command()
15
+ def setup(
16
+ agents: Annotated[
17
+ Optional[list[str]],
18
+ typer.Option("--agents", "-a", help="Agents to include (claude, codex, gemini, cursor, or all)"),
19
+ ] = None,
20
+ source_home: Annotated[
21
+ str,
22
+ typer.Option("--source-home", help="Source home directory to read credentials from"),
23
+ ] = str(Path.home()),
24
+ force: Annotated[bool, typer.Option("--force", "-f", help="Overwrite existing auth dir and backups")] = False,
25
+ ):
26
+ """Collect credentials from host into ~/.caw/auth/ and symlink them."""
27
+ from .collector import setup as do_setup
28
+
29
+ do_setup(agents=agents or ["all"], source_home=source_home, force=force)
30
+
31
+
32
+ @app.command()
33
+ def teardown(
34
+ agents: Annotated[
35
+ Optional[list[str]],
36
+ typer.Option("--agents", "-a", help="Agents to restore"),
37
+ ] = None,
38
+ dry_run: Annotated[bool, typer.Option("--dry-run", "-n", help="Show what would be done")] = False,
39
+ ):
40
+ """Restore original credential files from backups."""
41
+ from .linker import teardown as do_teardown
42
+
43
+ do_teardown(agents=agents, dry_run=dry_run)
44
+
45
+
46
+ @app.command("status")
47
+ def status_cmd(
48
+ agents: Annotated[
49
+ Optional[list[str]],
50
+ typer.Option("--agents", "-a", help="Agents to show"),
51
+ ] = None,
52
+ ):
53
+ """Show symlink state, token expiry, and last modified time."""
54
+ from .status import status as do_status
55
+
56
+ do_status(agents=agents)
57
+
58
+
59
+ @app.command("docker-flags")
60
+ def docker_flags():
61
+ """Output the -v flag string for docker."""
62
+ from .status import get_docker_flags as do_get_docker_flags
63
+
64
+ try:
65
+ typer.echo(do_get_docker_flags())
66
+ except FileNotFoundError:
67
+ typer.echo("Error: manifest.json not found. Run `caw auth setup` first.", err=True)
68
+ raise typer.Exit(1)
caw/auth/collector.py ADDED
@@ -0,0 +1,324 @@
1
+ """Set up credentials from host into ~/.caw/auth/ and generate manifest + setup script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import textwrap
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+
10
+ from .manifest import AgentManifest, Manifest
11
+ from .providers import PROVIDERS, AgentAuthProvider, CollectedFile
12
+
13
+ console = Console()
14
+
15
+ # Canonical auth directory
16
+ AUTH_DIR = Path.home() / ".caw" / "auth"
17
+
18
+
19
+ def _resolve_providers(agent_names: list[str]) -> list[AgentAuthProvider]:
20
+ """Resolve provider names to provider objects."""
21
+ if "all" in agent_names:
22
+ return list(PROVIDERS.values())
23
+ result = []
24
+ for name in agent_names:
25
+ if name not in PROVIDERS:
26
+ available = ", ".join(PROVIDERS.keys())
27
+ raise ValueError(f"Unknown agent '{name}'. Available: {available}")
28
+ result.append(PROVIDERS[name])
29
+ return result
30
+
31
+
32
+ def _validate_providers(
33
+ providers: list[AgentAuthProvider], src_home: Path
34
+ ) -> tuple[list[AgentAuthProvider], list[tuple[AgentAuthProvider, list[str]]]]:
35
+ """Validate providers, returning (valid, skipped) lists."""
36
+ valid = []
37
+ skipped = []
38
+ for provider in providers:
39
+ missing = provider.validate(src_home)
40
+ if missing:
41
+ skipped.append((provider, missing))
42
+ else:
43
+ valid.append(provider)
44
+ return valid, skipped
45
+
46
+
47
+ def _generate_setup_container_sh(manifest: Manifest) -> str:
48
+ """Generate a standalone POSIX shell script for container-side setup."""
49
+ return textwrap.dedent("""\
50
+ #!/bin/sh
51
+ # setup-container.sh — generated by `caw auth setup`
52
+ # Sets up credential copies, config copies, and an inotify-based
53
+ # bidirectional credential sync guard inside a container.
54
+ # Usage: setup-container.sh <mount_point> <container_home> <username>
55
+ set -e
56
+
57
+ MOUNT_POINT="${1:-/tmp/caw_auth}"
58
+ CONTAINER_HOME="${2:-/home/playground}"
59
+ USERNAME="${3:-playground}"
60
+
61
+ MANIFEST="$MOUNT_POINT/manifest.json"
62
+ GUARD_PAIRS="/tmp/_caw_guard_pairs"
63
+ GUARD_PID_FILE="/tmp/caw_credential_guard.pid"
64
+
65
+ if [ ! -f "$MANIFEST" ]; then
66
+ echo "[setup-container] ERROR: manifest.json not found at $MANIFEST"
67
+ exit 1
68
+ fi
69
+
70
+ echo "[setup-container] Setting up auth files from $MOUNT_POINT..."
71
+
72
+ # Clean up guard pairs file from any previous run
73
+ rm -f "$GUARD_PAIRS"
74
+
75
+ # Parse manifest with jq and process each file
76
+ jq -r '
77
+ .agents | to_entries[] | .value.files[] |
78
+ [.src, .container_target, .strategy, .mode] | @tsv
79
+ ' "$MANIFEST" | while IFS=$(printf '\\t') read -r src target strategy mode; do
80
+ target_path="$CONTAINER_HOME/$target"
81
+ source_path="$MOUNT_POINT/$src"
82
+
83
+ # Create parent directory
84
+ target_dir=$(dirname "$target_path")
85
+ mkdir -p "$target_dir"
86
+
87
+ if [ "$strategy" = "symlink" ]; then
88
+ # Credential files: copy (not symlink) so container user can read 0600 files.
89
+ # The bind-mounted source is owned by the host UID and is 0600, but the
90
+ # setup script runs as root so it can read it. We copy into the container
91
+ # user's home and record the pair for the inotify guard.
92
+ rm -f "$target_path"
93
+ cp "$source_path" "$target_path"
94
+ chmod "$mode" "$target_path"
95
+ chown "$USERNAME:$USERNAME" "$target_dir" 2>/dev/null || true
96
+ chown "$USERNAME:$USERNAME" "$target_path" 2>/dev/null || true
97
+ echo "[setup-container] copy (credential): $target (mode $mode)"
98
+ # Record source:local pair for the guard
99
+ echo "$source_path|$target_path|$mode" >> "$GUARD_PAIRS"
100
+ elif [ "$strategy" = "copy" ]; then
101
+ cp "$source_path" "$target_path"
102
+ chmod "$mode" "$target_path"
103
+ chown "$USERNAME:$USERNAME" "$target_dir" 2>/dev/null || true
104
+ chown "$USERNAME:$USERNAME" "$target_path" 2>/dev/null || true
105
+ echo "[setup-container] copy (config): $target (mode $mode)"
106
+ fi
107
+ done
108
+
109
+ # -------------------------------------------------------------------
110
+ # Credential guard — bidirectional sync via inotify
111
+ # -------------------------------------------------------------------
112
+
113
+ _caw_credential_guard() {
114
+ PAIRS_FILE="$1"
115
+ OWNER="$2"
116
+
117
+ # Build inotifywait watch list: unique directories from both sides
118
+ WATCH_DIRS=""
119
+ while IFS='|' read -r src local _mode; do
120
+ src_dir=$(dirname "$src")
121
+ local_dir=$(dirname "$local")
122
+ # Deduplicate by checking if already present
123
+ case "$WATCH_DIRS" in
124
+ *"$src_dir"*) ;;
125
+ *) WATCH_DIRS="$WATCH_DIRS $src_dir" ;;
126
+ esac
127
+ case "$WATCH_DIRS" in
128
+ *"$local_dir"*) ;;
129
+ *) WATCH_DIRS="$WATCH_DIRS $local_dir" ;;
130
+ esac
131
+ done < "$PAIRS_FILE"
132
+
133
+ echo "[credential-guard] Watching directories:$WATCH_DIRS"
134
+
135
+ while true; do
136
+ # shellcheck disable=SC2086
137
+ inotifywait -m -q -e close_write $WATCH_DIRS 2>/dev/null | while read -r dir event filename; do
138
+ # dir has a trailing slash from inotifywait
139
+ changed_path="${dir}${filename}"
140
+
141
+ while IFS='|' read -r src local mode; do
142
+ if [ "$changed_path" = "$src" ]; then
143
+ # Source (bind mount) changed — sync to local copy
144
+ src_hash=$(md5sum "$src" 2>/dev/null | cut -d' ' -f1)
145
+ local_hash=$(md5sum "$local" 2>/dev/null | cut -d' ' -f1)
146
+ if [ "$src_hash" != "$local_hash" ]; then
147
+ cp "$src" "$local"
148
+ chmod "$mode" "$local"
149
+ chown "$OWNER:$OWNER" "$local" 2>/dev/null || true
150
+ echo "[credential-guard] synced $src -> $local"
151
+ fi
152
+ elif [ "$changed_path" = "$local" ]; then
153
+ # Local copy changed — sync back to bind mount
154
+ src_hash=$(md5sum "$src" 2>/dev/null | cut -d' ' -f1)
155
+ local_hash=$(md5sum "$local" 2>/dev/null | cut -d' ' -f1)
156
+ if [ "$src_hash" != "$local_hash" ]; then
157
+ cp "$local" "$src"
158
+ echo "[credential-guard] synced $local -> $src"
159
+ fi
160
+ fi
161
+ done < "$PAIRS_FILE"
162
+ done
163
+ echo "[credential-guard] inotifywait exited, restarting in 2s..."
164
+ sleep 2
165
+ done
166
+ }
167
+
168
+ # Start the guard if there are credential pairs to watch
169
+ # Skip credential guard when using Bedrock (no credential sync needed)
170
+ if [ "${CLAUDE_CODE_USE_BEDROCK:-0}" = "1" ]; then
171
+ echo "[setup-container] Bedrock mode — skipping credential guard."
172
+ elif [ -f "$GUARD_PAIRS" ] && [ -s "$GUARD_PAIRS" ]; then
173
+ # Install inotify-tools if not present
174
+ if ! command -v inotifywait >/dev/null 2>&1; then
175
+ echo "[setup-container] Installing inotify-tools..."
176
+ if apt-get update -qq >/dev/null 2>&1 && apt-get install -y -qq inotify-tools >/dev/null 2>&1; then
177
+ echo "[setup-container] inotify-tools installed."
178
+ else
179
+ echo "[setup-container] WARNING: Failed to install inotify-tools. Credential guard will not run."
180
+ echo "[setup-container] Auth setup complete (without credential guard)."
181
+ exit 0
182
+ fi
183
+ fi
184
+
185
+ # Kill any previous guard
186
+ if [ -f "$GUARD_PID_FILE" ]; then
187
+ old_pid=$(cat "$GUARD_PID_FILE" 2>/dev/null)
188
+ if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
189
+ kill "$old_pid" 2>/dev/null || true
190
+ echo "[setup-container] Stopped previous credential guard (PID $old_pid)."
191
+ fi
192
+ rm -f "$GUARD_PID_FILE"
193
+ fi
194
+
195
+ _caw_credential_guard "$GUARD_PAIRS" "$USERNAME" &
196
+ echo $! > "$GUARD_PID_FILE"
197
+ echo "[setup-container] Credential guard started (PID $(cat "$GUARD_PID_FILE"))."
198
+ fi
199
+
200
+ echo "[setup-container] Auth setup complete."
201
+ """)
202
+
203
+
204
+ def setup(
205
+ agents: list[str] | None = None,
206
+ source_home: str | None = None,
207
+ force: bool = False,
208
+ dest_dir: str | Path | None = None,
209
+ ) -> Path:
210
+ """Collect credentials from host into an auth directory and link them.
211
+
212
+ Args:
213
+ agents: List of agent names, or None / ["all"] for all agents.
214
+ source_home: Home directory to read credentials from.
215
+ force: Overwrite existing auth dir without prompting.
216
+ dest_dir: Custom destination directory. Defaults to ~/.caw/auth/.
217
+
218
+ Returns:
219
+ Path to the auth directory.
220
+ """
221
+ src_home = Path(source_home) if source_home else Path.home()
222
+ auth_dir = Path(dest_dir) if dest_dir else AUTH_DIR
223
+
224
+ console.print(f"[bold]Collecting credentials into {auth_dir}/[/bold]\n")
225
+
226
+ # Resolve providers
227
+ selected = _resolve_providers(agents or ["all"])
228
+
229
+ # Validate
230
+ valid_providers, skipped_providers = _validate_providers(selected, src_home)
231
+
232
+ if not valid_providers:
233
+ console.print("[red]Error: No valid agent credentials found.[/red]")
234
+ for provider, missing in skipped_providers:
235
+ console.print(f" [dim]{provider.name}:[/dim] missing {', '.join(missing)}")
236
+ raise SystemExit(1)
237
+
238
+ # Warn about missing agents
239
+ if skipped_providers:
240
+ for provider, missing in skipped_providers:
241
+ console.print(f"[yellow]Warning: {provider.name} credentials not found:[/yellow] {', '.join(missing)}")
242
+ console.print()
243
+
244
+ # Show what we'll process
245
+ names = [p.name for p in valid_providers]
246
+ console.print(f"[dim]Agents:[/dim] {', '.join(names)}\n")
247
+
248
+ # Describe each provider
249
+ for provider in valid_providers:
250
+ desc = provider.describe(src_home)
251
+ console.print(f"[bold]{provider.name}:[/bold] {desc}")
252
+ console.print()
253
+
254
+ # Collect files from all providers
255
+ all_files: list[CollectedFile] = []
256
+ manifest = Manifest.create(host_home=str(src_home))
257
+
258
+ for provider in valid_providers:
259
+ console.print(f"[bold]Collecting {provider.name} files...[/bold]")
260
+ collected = provider.collect(src_home)
261
+ all_files.extend(collected)
262
+
263
+ # Build manifest entry
264
+ agent_manifest = AgentManifest(files=[cf.manifest_file for cf in collected])
265
+ manifest.agents[provider.name] = agent_manifest
266
+
267
+ # Write everything
268
+ if auth_dir.exists():
269
+ import shutil
270
+
271
+ # Preserve .backups directory across re-collections
272
+ backups = auth_dir / ".backups"
273
+ backup_tmp = None
274
+ if backups.exists():
275
+ backup_tmp = auth_dir.parent / ".backups_tmp"
276
+ if backup_tmp.exists():
277
+ shutil.rmtree(backup_tmp)
278
+ backups.rename(backup_tmp)
279
+
280
+ shutil.rmtree(auth_dir)
281
+ auth_dir.mkdir(parents=True)
282
+
283
+ if backup_tmp and backup_tmp.exists():
284
+ backup_tmp.rename(auth_dir / ".backups")
285
+ else:
286
+ auth_dir.mkdir(parents=True)
287
+
288
+ # Write collected files
289
+ for cf in all_files:
290
+ out_path = auth_dir / cf.manifest_file.src
291
+ out_path.parent.mkdir(parents=True, exist_ok=True)
292
+ out_path.write_bytes(cf.content)
293
+ out_path.chmod(int(cf.manifest_file.mode, 8))
294
+
295
+ # Write manifest
296
+ manifest.save(auth_dir / "manifest.json")
297
+
298
+ # Write setup-container.sh
299
+ setup_script = auth_dir / "setup-container.sh"
300
+ setup_script.write_text(_generate_setup_container_sh(manifest))
301
+ setup_script.chmod(0o755)
302
+
303
+ # Set directory permissions
304
+ for d in auth_dir.rglob("*"):
305
+ if d.is_dir():
306
+ d.chmod(0o755)
307
+
308
+ console.print("\n[bold green]Done![/bold green]")
309
+ console.print(f"Auth files written to: {auth_dir}")
310
+ console.print("\n[dim]Contents:[/dim]")
311
+ for cf in all_files:
312
+ strategy = cf.manifest_file.strategy
313
+ ftype = cf.manifest_file.type
314
+ console.print(f" [dim]{cf.manifest_file.src}[/dim] ({ftype}, {strategy})")
315
+ console.print(" [dim]manifest.json[/dim]")
316
+ console.print(" [dim]setup-container.sh[/dim]")
317
+
318
+ # Link credential files
319
+ console.print()
320
+ from .linker import link as do_link
321
+
322
+ do_link(agents=agents, force=force, auth_dir=auth_dir)
323
+
324
+ return auth_dir