Files
cc-connect/docs/usage.md
T
4cf879b2b9 feat: add run_as_user for per-project OS-user isolation (#560)
* feat: add run_as_user for per-project OS-user isolation

Implements #496. Adds an optional `run_as_user` field at the project
level that, when set, spawns the project's agent command under a
different Unix user via `sudo -n -iu <user> --`. This provides OS-level
file-system isolation from the supervisor user that runs cc-connect
itself: a misbehaving agent can no longer reach files outside the
target user's permission scope, even via python -c / shell redirection
/ other non-hooked code paths.

## What lands in this PR

### Config (config/)

- new fields `run_as_user *string` and `run_as_env []string` on each
  `[[projects]]` entry
- validation: rejects root, rejects invalid usernames, rejects use on
  Windows, enforces POSIX username charset to close a sudo argv
  injection vector

### Core primitives (core/runas*.go)

POSIX-only (Windows stubs provided for API compatibility).

- `BuildSpawnCommand(ctx, opts, name, args...)` — wraps exec.Cmd with
  `sudo -n -iu <user> --preserve-env=<allowlist> -- name args...` when
  opts.RunAsUser is set; passthrough otherwise
- `FilterEnvForSpawn(env, opts)` — strips supervisor env to a minimal
  allowlist (PATH, LANG, LC_*, TERM) plus any project-level extensions
- `VerifyRunAsUserCheap` — the two cheap sudo probes, suitable for
  per-spawn re-verification (catches sudoers edits between startup and
  spawn)
- `PreflightRunAsUser` — the three go/no-go startup gates from #496:
  (1) passwordless sudo to target works,
  (2) target cannot passwordless sudo (if escalation succeeds, captures
      `sudo -n -l` output in the error message), and
  (3) target can read AND write work_dir root. Plus a non-fatal
  descendant scan warning on unreadable/unwritable descendants with
  prune-paths, 10s timeout, and a 50-entry cap.
- `RunIsolationProbe` + embedded runas_probe.sh (//go:embed) — the
  leak-audit probe codex asked for: spawns a fixed shell script as the
  target user and parses its output into a structured IsolationReport.
  Reports effective id/groups/umask/pwd, target-home config presence,
  cross-user denial attempts for every other configured run_as_user,
  and supervisor-denial attempts. Never reads file contents, only
  existence and access.

All of these are testable without a real second user via an injectable
`SudoRunner` interface; production uses ExecSudoRunner.

### Failure policy

Per-spec: all checks are hard go/no-go, not warnings.

- Any preflight fatal in any project: cc-connect refuses to start
  entirely; no half-spawned processes.
- Any audit-level `CROSS_LEAKED` (target reads another project user's
  secrets) or `SUPERVISOR_LEAKED` (target reads the supervisor's
  secrets) or `WORKDIR_WRITABLE=no`: cc-connect refuses to start.
- Descendant-scan findings are warnings (inaccessibility inside a
  project tree is sometimes intentional).

### Startup wiring (cmd/cc-connect/)

- `runRunAsUserStartupChecks` runs preflight + isolation probe for
  every project with run_as_user set, in parallel, BEFORE any agent
  or engine is constructed. Logs every outcome via slog. On any fatal,
  prints the full list and `os.Exit(1)`.
- injects project-level run_as_user / run_as_env into the agent's
  options map before calling `core.CreateAgent`, so agents can opt in
  without new top-level plumbing.

### Doctor subcommand (cmd/cc-connect/doctor_runas.go)

New `cc-connect doctor user-isolation [--project NAME] [--out PATH]`
subcommand. Runs preflight + isolation probe for one or all
`run_as_user` projects, writes a JSON report per project, and prints a
human-readable summary. Default output is
`~/.cc-connect/audits/<timestamp>-<project>.json`.

Also supports `--print-script` to dump the embedded probe source for
inspection (and for running shellcheck against it).

### Claude Code wiring (agent/claudecode/)

- `Agent` struct gains `runAsUser` and `runAsEnv` fields
- `New()` parses them from the opts map and skips the
  `exec.LookPath("claude")` check when run_as_user is set (the target
  user's PATH is what matters, not the supervisor's)
- `newClaudeSession` takes `core.SpawnOptions`, runs the per-spawn
  cheap re-check immediately before building the command, uses
  `core.BuildSpawnCommand` instead of `exec.CommandContext`, and
  filters env via `core.FilterEnvForSpawn`

### Docs

- new "Running agents as a different Unix user" section in
  `docs/usage.md` with setup steps, sudoers example, ACL example,
  environment-propagation migration checklist (PGSSL*, ~/.claude/
  settings.json, ~/.pgpass, ~/keys/, ~/.ssh/, MCP binaries, shell
  profile, language toolchains), opt-out instructions, and
  failure-mode debug guide
- README section with a short configuration example and a pointer to
  the full docs
- `config.example.toml` commented example with setup recipe

### Tests

- config: parse run_as_user/run_as_env, reject root, reject invalid
  chars, Windows rejection, username validator valid/invalid cases
- core: BuildSpawnCommand legacy vs. sudo form, FilterEnvForSpawn
  filtering, VerifyRunAsUserCheap success/no-sudo/escalation-possible/
  empty-user, PreflightRunAsUser all-pass/no-sudo/escalation-fatal/
  workdir-fatal/descendant-warnings/descendant-cap, probe parser
  clean/cross-leak-fatal/supervisor-leak-fatal/workdir-not-writable-
  fatal, shell quoting, other-users filtering, embedded probe script
  sanity
- agent/claudecode: New() parses run_as_user/run_as_env, run_as_user
  skips LookPath
- cmd/cc-connect: default audit dir suffix, printHumanReport doesn't
  panic on populated report

Uses stubbed SudoRunners throughout — no tests require a real second
Unix user or real sudo binary.

## Scope

Only Claude Code is wired up in this PR. Each non-claudecode agent has
its own session/spawn lifecycle (codex re-spawns per turn, cursor has
its own state model, etc.) so the sweep needs per-agent reasoning and
is deferred to a follow-up. The core primitives land here so each
future agent migration is a small, self-contained PR.

Follow-up issue tracks: codex, cursor, gemini, iflow, opencode, qoder,
acp, pi.

Refs: #496
Credit: issue framing via user + codex adversarial review
        (go/no-go gates, leak-audit probe, env propagation docs,
        sudo -iu rationale pinning)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(run_as_user): simplify after code review

Addresses findings from a parallel code-reuse / code-quality / efficiency
review of #498. Behavior unchanged; tests still pass.

Highlights:

- cache VerifyRunAsUserCheap results for 30s keyed by runAsUser, so
  rapid-fire messages from the same Slack user don't pay the ~100ms
  per-spawn sudo tax. Cache evicts on failure so the next spawn
  re-verifies fresh. Adds ResetVerifyCache for tests + operators.
- doctor user-isolation now fans out across projects in parallel
  (mirroring runas_startup.go) instead of iterating serially. Per-project
  output is buffered and printed in order so the report stays grouped.
- collapse Agent.runAsUser + Agent.runAsEnv into a single
  core.SpawnOptions field — eliminates the reassemble-at-call-site
  pattern.
- fold core/runas_username.go into core/runas.go (one 17-line file →
  one helper near its only caller).
- IsolationReport.RawOutput is now only populated when the audit had a
  fatal — clean reports are smaller on disk.
- introduce typed string constants for the probe wire format
  (tagBegin / tagWorkDirReadable / tagCrossLeaked / ...) so the parser
  and runas_probe.sh share a discoverable contract.
- itoa() in tests → strconv.Itoa
- contains() helper in tests → slices.Contains
- mass comment trim across runas.go / runas_check.go / runas_audit.go:
  delete WHAT-restates-name comments (~70 lines) while keeping the
  load-bearing WHY blocks (sudo -n -iu rationale, env propagation
  reasoning, find expression format note, scan timeout justification).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(runas): drop PATH from default env allowlist

Preserving PATH across the sudo boundary defeats the whole point of
-i: it overrides the target user's login-profile PATH with the
supervisor's, and leaks supervisor work directories (e.g. the
cc-connect source tree on the supervisor's PATH) into the isolated
session.

sudo -i now rebuilds PATH from /etc/profile + the target user's
~/.profile. Binaries the isolated agent needs (like the claude CLI)
should be installed system-wide (e.g. /usr/local/bin symlinks) or put
on the target user's own shell profile.

Caught by Q6/Q8 daemon-spawn verification in data-worklog Phase 0a:
partseeker-coder sessions were running with leigh's PATH intact.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(engine): propagate run_as_user to per-workspace agents

In multi-workspace mode, getOrCreateWorkspaceAgent constructs a fresh
opts map for each per-workspace agent instead of inheriting the
project-level opts that main.go decorated at startup. As a result,
run_as_user (and run_as_env) silently dropped out of the per-workspace
spawn path — the project-level config said partseeker-coder but every
spawn actually ran as the supervisor.

Fix: expose GetRunAsUser/GetRunAsEnv accessors on *claudecode.Agent
and copy them across in getOrCreateWorkspaceAgent via interface
assertion, mirroring the existing GetModel/GetMode pattern. Two
regression tests cover the propagation and the no-propagation case
so multi-workspace deployments that don't use run_as_user are
unaffected.

Caught by Q6/Q8 daemon-spawn verification in data-worklog Phase 0a.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add OAuth credential sharing guidance for run_as_user setups

OAuth access tokens expire after a few hours and only get refreshed by
active Claude CLI sessions. When the target user has no active session
between cc-connect spawns, its token expires and auth fails with 401.

Document the symlink + ACL pattern: target user's credentials.json
symlinks to the supervisor's copy so both share one auto-refreshed token.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: graceful session shutdown — close stdin before signals

The old Close() called context.Cancel() which sends SIGKILL immediately
via Go's exec.CommandContext, giving Claude Code zero time to run Stop
hooks. This broke claude-mem session summaries (120s timeout) and any
other cleanup that depends on a clean exit.

New three-phase shutdown:
1. Close stdin (EOF) — Claude Code exits cleanly, running Stop hooks
2. SIGTERM after 30s grace period if still alive
3. SIGKILL after 5s more as last resort

Also increases engine-level close timeout from 10s to 45s to accommodate
the longer graceful window.

Tested: all agent/claudecode and core tests pass. Verified Claude Code
exits cleanly on stdin EOF (exit code 0).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 120s graceful close + hold message during session transition

Increases the default graceful stop timeout from 30s to 120s to match
claude-mem's Stop hook timeout. The wait ends as soon as the process
exits — typical shutdowns take seconds, not the full 120s.

When a user messages during an idle-reset that triggers a graceful
close, they now see "Wrapping up your previous session (usually a few
seconds, up to 2 minutes). Your new session will start automatically."
instead of a silent delay. The hold message is sent before the blocking
close, and the new session starts immediately when the old one finishes.

Engine-level close timeout raised to 130s (120s grace + 5s SIGTERM +
5s buffer).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Leigh Stillard <leigh@stillard.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:45:08 +08:00

31 KiB

Usage Guide

Complete guide to using cc-connect features.

Table of Contents


Session Management

Each user gets an independent session with full conversation context. Manage sessions via slash commands:

Command Description
/new [name] Start a new session
/list List all agent sessions for this project
/switch <id> Switch to a different session
/current Show current session info
/history [n] Show last n messages (default 10)
/usage Show account/model quota usage (if supported)
/provider [...] Manage API providers
/model [switch <alias>] List available models or switch by alias
/dir [path] Show or switch the agent work directory
/allow <tool> Pre-allow a tool (next session)
/reasoning [level] View or switch reasoning effort (Codex)
/mode [name] View or switch permission mode
/stop Stop current execution
/help Show available commands

During a session, the agent may request tool permissions. Reply allow / deny / allow all.

You can also configure automatic session rotation after inactivity:

[[projects]]
name = "demo"
reset_on_idle_mins = 60

When enabled, the next normal message after a long idle period starts in a fresh session automatically, without deleting the old session from /list.


Permission Modes

All agents support permission modes switchable at runtime via /mode.

Claude Code Modes

Mode Config Value Behavior
Default default Every tool call requires approval
Accept Edits acceptEdits / edit File edits auto-approved
Auto auto Claude decides when to ask for permission
Plan Mode plan Claude only plans, no execution
YOLO bypassPermissions / yolo All tools auto-approved

Codex Modes

Mode Config Value Behavior
Suggest suggest Only trusted commands run without approval
Auto Edit auto-edit Model decides when to ask
Full Auto full-auto Auto-approve with sandbox
YOLO yolo Bypass all approvals and sandbox

Cursor Agent Modes

Mode Config Value Behavior
Default default Trust workspace, ask before tools
Force (YOLO) force / yolo Auto-approve all
Plan plan Read-only analysis
Ask ask Q&A style, read-only

Gemini CLI Modes

Mode Config Value Behavior
Default default Prompt for approval
Auto Edit auto_edit / edit Auto-approve edits
YOLO yolo Auto-approve all
Plan plan Read-only plan mode

Qoder CLI / OpenCode / iFlow CLI

Mode Config Value Behavior
Default default Standard permissions
YOLO yolo Skip all checks

Configuration

[projects.agent.options]
mode = "default"
# allowed_tools = ["Read", "Grep", "Glob"]

Switch at runtime:

/mode          # show current and available modes
/mode yolo     # switch to YOLO mode
/mode default  # switch back

API Provider Management

Switch between API providers at runtime without restart.

Configure Providers

[projects.agent.options]
work_dir = "/path/to/project"
provider = "anthropic"   # active provider

[[projects.agent.providers]]
name = "anthropic"
api_key = "sk-ant-xxx"

[[projects.agent.providers]]
name = "relay"
api_key = "sk-xxx"
base_url = "https://api.relay-service.com"
model = "claude-sonnet-4-20250514"

[[projects.agent.providers.models]]
model = "claude-sonnet-4-20250514"
alias = "sonnet"

[[projects.agent.providers.models]]
model = "claude-opus-4-20250514"
alias = "opus"

[[projects.agent.providers.models]]
model = "claude-haiku-3-5-20241022"
alias = "haiku"

# MiniMax — OpenAI-compatible, 1M context
[[projects.agent.providers]]
name = "minimax"
api_key = "your-minimax-api-key"
base_url = "https://api.minimax.io/v1"
model = "MiniMax-M2.7"

# For Bedrock, Vertex, etc.
[[projects.agent.providers]]
name = "bedrock"
env = { CLAUDE_CODE_USE_BEDROCK = "1", AWS_PROFILE = "bedrock" }

CLI Commands

cc-connect provider add --project my-backend --name relay --api-key sk-xxx --base-url https://api.relay.com
cc-connect provider list --project my-backend
cc-connect provider remove --project my-backend --name relay
cc-connect provider import --project my-backend  # from cc-switch

Chat Commands

/provider                   Show current provider
/provider list              List all providers
/provider add <name> <key> [url] [model]
/provider remove <name>
/provider switch <name>
/provider <name>            Shortcut for switch

Env Var Mapping

Agent api_key → base_url →
Claude Code ANTHROPIC_API_KEY ANTHROPIC_BASE_URL
Codex OPENAI_API_KEY OPENAI_BASE_URL
Gemini CLI GEMINI_API_KEY use env map
OpenCode ANTHROPIC_API_KEY use env map
iFlow CLI IFLOW_API_KEY IFLOW_BASE_URL

Model Selection

Pre-configure a list of selectable models per provider using [[providers.models]]. Each entry has a model identifier and an optional alias (short name shown in /model).

Configure Models

[[projects.agent.providers]]
name = "openai"
api_key = "sk-xxx"

[[projects.agent.providers.models]]
model = "gpt-5.3-codex"
alias = "codex"

[[projects.agent.providers.models]]
model = "gpt-5.4"
alias = "gpt"

[[projects.agent.providers.models]]
model = "gpt-5.3-codex-spark"
alias = "spark"

Chat Commands

/model              List available models (format: alias - model)
/model switch <alias>      Switch to the model matching the alias
/model switch <name>       Switch to the model by its full name
/model <alias>             Legacy syntax, still supported

When models is configured, /model shows exactly that list without making an API round-trip. When omitted, models are fetched from the provider API or fall back to a built-in list.


Work Directory Switching (/dir, /cd)

Switch where the next agent session starts, directly from chat.

Chat Commands

/dir                    Show current work directory and recent history
/dir <path>             Switch to a path (relative or absolute)
/dir <number>           Switch to a directory from history
/dir -                  Switch back to previous directory
/dir help               Show command usage
/cd <path>              Backward-compatible alias of /dir <path>

Behavior Notes

  • Directory changes apply to the next session in the current project.
  • Relative paths are resolved from the current agent work directory.
  • Directory history is project-scoped and can be switched by index.
  • /cd is kept for compatibility, but /dir is the primary command.

Examples:

/dir ../another-repo
/dir 2
/dir -

Running agents as a different Unix user (run_as_user)

Platform support: Linux and macOS. Not supported on Windows. Agent support: Claude Code today. Other agents fall back to the supervisor user; see the tracking issue for migration status.

What this is

By default, every agent session cc-connect spawns runs as the same Unix user that runs cc-connect itself. If an agent misbehaves — reads a secret, overwrites a sibling repo, trashes ~/.ssh/ — it has the supervisor user's full file-system reach.

run_as_user sets a per-project target Unix user. When it is set, cc-connect spawns that project's agent command via

sudo -n -iu <target-user> -- claude ...

The target user is a real, unprivileged Unix account that you create. The agent runs under that account's uid/gid, with its own home directory, shell profile, PATH, and tool credentials. File-system isolation is enforced by the kernel, not by hooks or allowlists.

Security guarantee and non-guarantee

This provides OS-user isolation from any file or process the target user cannot reach. An agent can no longer read or clobber the supervisor's ~/.ssh/, another project user's ~/.pgpass, or a repo whose UNIX permissions don't grant access to the target user.

This does not automatically isolate projects from each other if they share the same run_as_user. If you want per-project isolation, create a separate Unix user per project.

This is not a sandbox in the sense of Linux namespaces, seccomp, or container isolation. It is strictly file-system scoping by uid.

Setup

1. Create the target user and install their tooling

The target user needs its own copy of everything the agent touches, because sudo -i loads the target user's login environment — not the supervisor's.

sudo useradd -m -s /bin/bash partseeker-coder
sudo -iu partseeker-coder

# Install the agent CLI under the target user's PATH
#   (for Claude Code, follow the normal install instructions)

# Set up the target user's ~/.claude/
mkdir -p ~/.claude
# Copy or re-create:
#   ~/.claude/settings.json     (MCP servers, hooks, model settings)
#   ~/.claude.json              (Claude Code auth)
#   ~/.claude/plugins/          (claude-mem and any other plugin state)

exit

2. Grant the supervisor passwordless sudo to the target

Add a scoped sudoers rule. Do not use NOPASSWD: ALL for the supervisor — that grants the supervisor root, which is irrelevant here and dangerous.

# /etc/sudoers.d/cc-connect (install with `sudo visudo -f ...`)
partseeker-orchestrator ALL=(partseeker-coder) NOPASSWD: ALL

Adjust the usernames for your setup. The rule says: "the supervisor user may run any command as this specific target user, without a password."

3. Verify the target user cannot sudo

The whole point of stepping down into a target user is that the target cannot immediately escalate back. Verify:

sudo -n -iu partseeker-coder -- sudo -n true
# must FAIL with "a password is required" or similar

If that command succeeds, cc-connect will refuse to start. Remove any NOPASSWD sudo grants for the target user first.

4. Make the project's work_dir accessible to the target user

The target user needs read AND write on the project's work_dir. If the directory is owned by the supervisor, either chown it to the target, add group ownership the target is in, or apply a POSIX ACL:

sudo setfacl -R -m u:partseeker-coder:rwX /home/leigh/workspace/sandboxed-repo
sudo setfacl -R -dm u:partseeker-coder:rwX /home/leigh/workspace/sandboxed-repo

cc-connect refuses to start if the target user cannot read+write the work_dir root, and warns (non-fatal) for descendant paths that look inaccessible.

5. Audit the setup before starting cc-connect

cc-connect doctor user-isolation

This runs the full preflight (the three go/no-go gates from #496) and an isolation probe: it spawns a fixed shell script as the target user and reports what the target can read, what it's denied, and any cross-user leaks. Output goes to stdout plus a JSON report in ~/.cc-connect/audits/<timestamp>-<project>.json.

Exit code 0 = clean. Exit code 1 = at least one fatal problem.

You can inspect the probe script itself with:

cc-connect doctor user-isolation --print-script

Configuration

[[projects]]
name = "claude-sandboxed"
run_as_user = "partseeker-coder"

# Optional: extend the default env var allowlist that crosses the sudo
# boundary. The defaults (PATH, LANG, LC_*, TERM) are always included.
# Only list vars the target user cannot reasonably set in their own
# shell profile. Secrets belong in the target user's ~/.claude/settings.json
# env block, NOT here.
run_as_env = ["PGSSLROOTCERT", "PGSSLMODE"]

[projects.agent]
type = "claudecode"

[projects.agent.options]
mode = "default"
model = "sonnet"
work_dir = "/home/leigh/workspace/sandboxed-repo"

Environment propagation: what moves into the target user's home

This is the 2am-debugging section. When you switch a project to run_as_user, the supervisor's environment is not forwarded across the sudo boundary — that's the whole point. Everything the agent needs has to live in the target user's home.

Migration checklist:

  • Agent config — ~/.claude/settings.json (MCP servers, hooks, model settings), ~/.claude.json (auth). Copy from the supervisor or re-create from scratch.

  • Plugin state — ~/.claude/plugins/ — claude-mem, any other Claude Code plugins.

  • MCP server binaries — must be on the target user's PATH, not just the supervisor's. Either install under the target user or reference full paths in settings.json.

  • Postgres TLS — PGSSLROOTCERT, PGSSLCERT, PGSSLKEY belong in the target user's ~/.claude/settings.json env block. Their referenced cert files must be readable by the target user.

  • Claude OAuth credentials — if you authenticate via claude.ai (OAuth), the token lives in ~/.claude/.credentials.json. OAuth access tokens expire after a few hours and are refreshed automatically by whichever Claude CLI session is running. The target user's token will not be refreshed unless the target user has an active session — which it often doesn't between cc-connect spawns. The recommended fix is to symlink the target user's credentials to the supervisor's file so both share one token that stays fresh:

    ```bash
    # Grant target user read access via ACL (keeps 600 for everyone else)
    setfacl -m u:<target-user>:rx ~/.claude/
    setfacl -m u:<target-user>:r  ~/.claude/.credentials.json
    
    # Replace the target user's credentials with a symlink
    sudo -iu <target-user> bash -c \
      'rm -f ~/.claude/.credentials.json && \
       ln -s /home/<supervisor>/.claude/.credentials.json \
             ~/.claude/.credentials.json'
    ```
    
    **If you use an API key** (`ANTHROPIC_API_KEY`) instead of OAuth,
    this is not an issue — set the key in the target user's
    `~/.claude/settings.json` `env` block and it won't expire.
    
  • Credential files — ~/.pgpass, ~/.gitconfig, ~/.netrc, ~/.aws/, ~/.config/gh/, ~/.kube/ — whichever the agent actually uses. Each needs its own copy or a group-readable shared copy.

  • SSH keys — ~/.ssh/id_ed25519 etc., if the agent runs git push over SSH. Same story: copy or group-share.

  • Key material under ~/keys/ — custom directories the supervisor uses need an equivalent under the target user's home or a group-readable shared copy.

  • Language toolchains — if the agent uses asdf, mise, nvm, rustup, etc., those live in ~. The target user needs either its own install or a system-wide install that both users can run.

  • Shell profile — ~/.profile / ~/.bashrc on the target user needs to set PATH and any tool init the agent depends on. Test with sudo -iu partseeker-coder before wiring cc-connect.

After migration, run cc-connect doctor user-isolation again. The target home section reports which expected paths are present and which are missing — missing isn't necessarily wrong, but it's your checklist.

Opting out

Remove run_as_user from the project entry, or set it to "". Legacy behavior (spawn as supervisor) returns on the next restart.

Failure modes and error messages

  • "passwordless sudo to user X is not configured" — step 2 of setup is missing or the sudoers rule is scoped to the wrong supervisor. Fix the rule, run visudo -c to validate syntax, then restart cc-connect.
  • "target user X can run passwordless sudo" — step 3 failed. The error includes the output of sudo -l from the target context; find the offending rule and remove it.
  • "target user X cannot read AND write work_dir Y" — step 4 failed. chown the directory or add an ACL as shown above.
  • "CROSS_LEAKED" or "SUPERVISOR_LEAKED" in the audit — the target user can read another user's secrets. Tighten the offending file's permissions (usually chmod 600 file; chown user:user file) and re-audit.
  • "descendant scan timed out" — non-fatal. The work_dir is large enough that the permission walk exceeded its timeout. Run cc-connect doctor user-isolation manually if you want the full walk, or narrow the project's work_dir.

Feishu Setup CLI

Use CLI to create or bind Feishu/Lark bot credentials and write them back to config.toml.

# Recommended: unified entry
cc-connect feishu setup --project my-project
cc-connect feishu setup --project my-project --app cli_xxx:sec_xxx

# Force modes (usually unnecessary)
cc-connect feishu new --project my-project
cc-connect feishu bind --project my-project --app cli_xxx:sec_xxx

Differences:

  • setup: unified entry. No credentials => behaves like new; with --app => behaves like bind.
  • new: force QR onboarding flow; rejects --app.
  • bind: force credential binding flow; requires credentials.

Behavior:

  • setup uses QR onboarding by default, or bind mode when --app is provided.
  • If --project does not exist, it is created automatically.
  • If project exists but has no feishu/lark platform, one is added automatically.
  • The command writes credentials (app_id, app_secret); in QR onboarding flow, Feishu usually pre-configures permissions and event subscriptions.
  • Still verify app publish status and availability scope in Feishu Open Platform.
  • Runtime platform config also supports an optional domain override for Feishu/Lark API endpoints; this does not change setup/onboarding URLs.

Weixin (personal) Setup CLI

Weixin personal chat uses the ilink bot HTTP API (long polling + sendMessage, same family as OpenClaw openclaw-weixin). Use the CLI to scan a QR code or bind an existing Bearer token and write config.toml.

Full walkthrough (Chinese): docs/weixin.md.

cc-connect weixin setup --project my-project
cc-connect weixin bind --project my-project --token '<token>'
cc-connect weixin new --project my-project

Notes:

  • setup without --token runs QR login; with --token behaves like bind.
  • Auto-creates the project and/or a weixin platform block when missing.
  • After login, send a message from WeChat once so context_token is cached.
  • See cc-connect weixin help for flags (--api-url, --cdn-url, --route-tag, etc.).

Claude Code Router Integration

Claude Code Router routes requests to different model providers.

Setup

  1. Install: npm install -g @musistudio/claude-code-router

  2. Configure ~/.claude-code-router/config.json:

{
  "APIKEY": "your-secret-key",
  "Providers": [
    {
      "name": "deepseek",
      "api_base_url": "https://api.deepseek.com/chat/completions",
      "api_key": "sk-xxx",
      "models": ["deepseek-chat", "deepseek-reasoner"],
      "transformer": { "use": ["deepseek"] }
    }
  ],
  "Router": {
    "default": "deepseek,deepseek-chat",
    "think": "deepseek,deepseek-reasoner"
  }
}
  1. Start: ccr start

  2. Configure cc-connect:

[projects.agent.options]
router_url = "http://127.0.0.1:3456"
router_api_key = "your-secret-key"  # optional

Voice Messages (Speech-to-Text)

Send voice messages — cc-connect transcribes them automatically.

Supported: Feishu, WeChat Work, Telegram, LINE, Discord, Slack

Requirements: OpenAI/Groq API key, ffmpeg

Configure

[speech]
enabled = true
provider = "openai"    # or "groq"
language = ""          # "zh", "en", or auto-detect

[speech.openai]
api_key = "sk-xxx"
# base_url = ""
# model = "whisper-1"

# [speech.groq]
# api_key = "gsk_xxx"
# model = "whisper-large-v3-turbo"

Install ffmpeg

# Ubuntu/Debian
sudo apt install ffmpeg

# macOS
brew install ffmpeg

Voice Reply (Text-to-Speech)

Synthesize AI replies into voice messages.

Supported: Feishu (Lark)

Configure

[tts]
enabled = true
provider = "qwen"        # or "openai"
voice = "Cherry"
tts_mode = "voice_only"  # "voice_only" | "always"
max_text_len = 0         # 0 = no limit

[tts.qwen]
api_key = "sk-xxx"
# model = "qwen3-tts-flash"

TTS Modes

Mode Behavior
voice_only Reply with voice only when user sends voice
always Always send voice reply

Switch: /tts always or /tts voice_only


Image and File Send-Back

When an agent generates a local image, PDF, report, bundle, or other file and needs to deliver it directly to the current chat, use attachment mode in cc-connect send.

Currently supported platforms:

  • Feishu
  • Telegram

When to run setup first

If the current agent does not natively inject the system prompt, run this once in chat after upgrading:

/bind setup

or:

/cron setup

These two commands write the same cc-connect instructions. Either one is enough. After that, the agent knows:

  • normal text replies should be returned normally
  • generated attachments should be sent back with cc-connect send --image/--file

If you have run setup before, run it again after upgrading so the instructions are refreshed to the latest version.

Config switch

Add this to config.toml if you want to disable agent-driven attachment send-back:

attachment_send = "off"

The default is on. This switch is independent from the agent's /mode and only affects cc-connect send --image/--file.

CLI examples

cc-connect send --image /absolute/path/to/chart.png
cc-connect send --file /absolute/path/to/report.pdf
cc-connect send --file /absolute/path/to/report.pdf --image /absolute/path/to/chart.png

Notes:

  • --image is for image attachments.
  • --file is for any file attachment.
  • --message is optional and sends a text note before the attachments.
  • --image and --file can both be repeated.
  • Absolute paths are recommended so the command does not depend on the agent's current working directory.
  • With attachment_send = "off", image/file send-back is blocked but ordinary text replies still work.

Typical use cases

  1. The agent generates a screenshot or chart and should send it directly to the user.
  2. The agent generates a PDF, Markdown export, log bundle, or patch file that should be delivered as an attachment.
  3. The agent wants to send a short status message together with one or more generated files.

Important notes

  • This command is for attachment delivery, not ordinary text replies.
  • The files must exist on the local machine where the agent runs.
  • There must be an active session; otherwise the command fails because cc-connect has no chat context to deliver to.
  • Platform-specific file size and file type limits still apply.

Scheduled Tasks (Cron)

Create scheduled tasks that run automatically.

Chat Commands

/cron                                          List all jobs
/cron add <min> <hour> <day> <mon> <wk> <prompt>   Create job
/cron del <id>                                 Delete job
/cron enable <id>                              Enable job
/cron disable <id>                             Disable job

Example:

/cron add 0 6 * * * Summarize GitHub trending repos

CLI Commands

cc-connect cron add --cron "0 6 * * *" --prompt "Summarize GitHub trending" --desc "Daily Trending"
cc-connect cron list
cc-connect cron del <job-id>

Optional: --session-mode new-per-run starts a fresh agent session on each run (default is reuse, same as before). --timeout-mins N sets how long the scheduler waits per run (0 = no limit; omit = 30 minutes).

Natural Language (Claude Code)

"Every day at 6am, summarize GitHub trending"

Claude Code auto-creates the cron job. For other agents that rely on memory files, run /cron setup or /bind setup once first; both write the same instructions.


Multi-Bot Relay

Cross-platform bot communication in group chats.

Group Chat Binding

/bind              Show bindings
/bind claudecode   Add claudecode project
/bind gemini       Add gemini project
/bind -claudecode  Remove claudecode

Bot-to-Bot Communication

cc-connect relay send --to gemini "What do you think about this architecture?"

Daemon Mode

Run as background service.

cc-connect daemon install --config ~/.cc-connect/config.toml
cc-connect daemon start
cc-connect daemon stop
cc-connect daemon restart
cc-connect daemon status
cc-connect daemon logs [-f]
cc-connect daemon uninstall

Multi-Workspace Mode

One bot serving multiple workspaces per channel.

Configure

[[projects]]
name = "my-project"
mode = "multi-workspace"
base_dir = "~/workspaces"

[projects.agent]
type = "claudecode"

Commands

/workspace                    Show current binding
/workspace bind <name>        Bind local folder
/workspace init <git-url>     Clone and bind repo
/workspace unbind             Remove binding
/workspace list               List all bindings

How It Works

  • Channel name #project-a → auto-binds to base_dir/project-a/
  • Each channel has isolated sessions and agent state

Web Admin Dashboard (Beta)

Status: Beta. This feature is available since v1.2.2-beta.5. The UI and API may change in future releases.

A full-featured management UI embedded in the binary — project CRUD, session management, cron job editor, global settings, chat interface, and i18n support.

Quick Setup (Chat Command)

The easiest way to enable web admin:

/web setup

This automatically enables both the Management API and the Bridge in config.toml, generates tokens, and prints the access URL. You may need to run /restart for changes to take effect.

After setup, open the URL shown (default http://localhost:9820) and log in with the token.

Check Status

/web           # or /web status — show current web admin URL and status

Manual Configuration

Add the following to config.toml:

[management]
enabled = true
port = 9820                     # Management UI & API listen port
token = "your-secret-token"     # Login token; /web setup generates one automatically
cors_origins = ["*"]            # Allowed CORS origins; empty = no CORS headers

Then restart cc-connect.

Build Options

Web assets are compiled into the binary by default. To exclude them (saves ~1MB):

make build-noweb
# or
go build -tags 'no_web' ./cmd/cc-connect

When built with no_web, the /web command will report that web admin is not available.

Management API

The Management API is served on the same port as the UI. Base URL: http://<host>:<port>/api/v1

All API requests require the Authorization: Bearer <token> header.

Key endpoints:

Method Path Description
GET /api/v1/status System status (version, uptime, platforms)
POST /api/v1/restart Restart cc-connect
POST /api/v1/reload Reload configuration
GET /api/v1/projects List projects
GET /api/v1/sessions?project=<name> List sessions for a project
GET /api/v1/cron List cron jobs
GET /api/v1/settings Get global settings
PATCH /api/v1/settings Update global settings

Full API reference: management-api.md


Bridge — External Adapter Access (Beta)

Status: Beta. This feature is available since v1.2.2-beta.5. The protocol may change in future releases.

The Bridge exposes a WebSocket + REST server so external adapters (custom UIs, bots, scripts) can interact with cc-connect sessions — send messages, receive events, manage sessions.

Enable via Chat

The /web setup command enables Bridge automatically alongside the Management API.

Manual Configuration

Add the following to config.toml:

[bridge]
enabled = true
port = 9810                     # Bridge listen port (separate from management)
token = "your-bridge-secret"    # Auth token for WebSocket and REST
path = "/bridge/ws"             # WebSocket endpoint path
cors_origins = ["*"]            # Allowed CORS origins; empty = no CORS

Then restart cc-connect.

Authentication

All Bridge connections require a token. Supported methods:

  • Query parameter: ?token=<bridge-token>
  • Header: Authorization: Bearer <bridge-token>
  • Header: X-Bridge-Token: <bridge-token>

WebSocket

Connect to:

ws://<host>:<bridge-port>/bridge/ws?token=<bridge-token>

The WebSocket supports bidirectional messaging — send user messages to the agent and receive agent events (text, tool calls, permission requests, etc.) in real time.

REST API

Served on the same port as the WebSocket.

Method Path Description
GET /bridge/sessions?session_key=...&project=... List sessions
POST /bridge/sessions Create a new session
GET /bridge/sessions/{id}?session_key=...&project=... Get session detail + history
DELETE /bridge/sessions/{id}?session_key=...&project=... Delete a session
POST /bridge/sessions/switch Switch active session

Full protocol reference: bridge-protocol.md

Port Summary

Service Default Port Config Block
Management (Web UI + API) 9820 [management]
Bridge (WebSocket + REST) 9810 [bridge]

Configuration Reference

See config.example.toml for full examples.

Project Structure

[[projects]]
name = "my-project"

[projects.agent]
type = "claudecode"  # or codex, cursor, gemini, qoder, opencode, iflow

[projects.agent.options]
work_dir = "/path/to/project"
mode = "default"
provider = "anthropic"

[[projects.platforms]]
type = "feishu"  # or dingtalk, telegram, slack, discord, wecom, weixin, line, qq, qqbot

[projects.platforms.options]
# platform-specific options