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
71 changes: 67 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ codeoid start
├── Bun.serve() — HTTP + WebSocket server
├── ShutdownManager — cleanup registry, signal handlers, 30s grace period
├── SessionManager — rate limiting, session resume, scope enforcement
│ ├── ProviderRegistry — pluggable backends (claude default; openai, gemini, codex, pi, gemini-cli)
│ ├── ProviderRegistry — pluggable backends (claude default; openai, gemini, codex, pi, gemini-cli, qwen)
│ └── Session × N — each drives one provider backend (Claude Agent SDK by default)
│ ├── ScrollbackBuffer — circular ring, replayed on device handoff
│ ├── TranscriptStore — JSONL persistence, survives daemon restart
Expand Down Expand Up @@ -55,7 +55,7 @@ src/
│ ├── providers/ # Pluggable agent backends behind one SessionProvider interface
│ │ ├── registry.ts # ProviderRegistry + createDefaultProviderRegistry()
│ │ ├── interface.ts # SessionProvider contract
│ │ └── claude|openai|gemini|codex|pi|acp/ # one dir per backend (acp = Gemini CLI)
│ │ └── claude|openai|gemini|codex|pi|acp|qwen/ # one dir per backend (acp = Gemini CLI)
│ ├── store.ts # bun:sqlite — sessions + audit_log tables
│ ├── auth.ts # ZeroID JWT verification via @highflame/sdk
│ ├── agent-identity.ts # ZeroID identities for coding agents + sub-agents
Expand All @@ -81,7 +81,7 @@ src/
## Tech Stack

- **Runtime**: Bun (native WebSocket, bun:sqlite, Bun.serve())
- **Agent backends**: pluggable via `ProviderRegistry` — Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`, default), plus OpenAI, Gemini, Codex, pi, and the Gemini CLI
- **Agent backends**: pluggable via `ProviderRegistry` — Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`, default), plus OpenAI, Gemini, Codex, pi, the Gemini CLI, and Qwen Code (`@qwen-code/sdk`)
- **Auth**: ZeroID via `@highflame/sdk` (local JWKS verification)
- **Telegram**: Grammy
- **CLI**: Commander
Expand All @@ -93,7 +93,7 @@ No native addon dependencies. Single `bun build` produces a 1.1MB bundle.

### Sessions
A session = one agent backend (the Claude Agent SDK by default; also Codex,
Gemini, OpenAI, pi, or the Gemini CLI) working in one directory.
Gemini, OpenAI, pi, the Gemini CLI, or Qwen Code) working in one directory.
Sessions are named, persistent, and daemon-owned. Multiple clients can
attach/detach simultaneously from any frontend.

Expand Down Expand Up @@ -177,6 +177,69 @@ TELEGRAM_ALLOWED_USER_IDS=123,456 # Required with bot token
# Falls back to ANTHROPIC_API_KEY env var if not logged in.
```

### Qwen backend (Alibaba)

Qwen Code runs in-process via `@qwen-code/sdk`, which bundles the CLI it drives
— nothing to install. Both credential paths work; `providers.qwen.authType`
picks one, and omitting it auto-detects (a `~/.qwen` login wins over the key).

**Subscription** — run `qwen` once to log in with a qwen.ai account. Tokens
live in `~/.qwen/oauth_creds.json` and never transit codeoid.

**API key** — set `OPENAI_API_KEY` in `~/.codeoid/.env` and point
`providers.qwen.baseUrl` at the right gateway:

| Preset | Use for |
| --- | --- |
| `dashscope-intl` | Model Studio pay-as-you-go keys (international) |
| `dashscope-cn` | Model Studio pay-as-you-go keys (China) |
| `bailian-plan-intl` | **Plan-specific keys (`sk-sp-…`)** from the Bailian token-plan |

The last one matters: a `sk-sp-` plan key is rejected by the standard DashScope
hosts with `invalid_api_key`, which is a confusing failure to debug. Plan keys
also expose a broader catalog than Qwen alone (`qwen3.8-max`, `glm-5.2`,
`deepseek-v4-pro`, …) — set `providers.qwen.model` to pick one.

```json
{
"providers": {
"qwen": {
"authType": "openai",
"baseUrl": "bailian-plan-intl",
"model": "qwen3.8-max"
}
}
}
```

codeoid forces `permissionMode: "default"` so every write routes through the
approval gate — qwen-code's own `auto` mode would silently auto-approve edits
and shell commands.

**Known gap — read-only shell commands bypass codeoid's gate.** qwen-code's
`resolveDefaultPermission` runs `isShellCommandReadOnlyAST(command)` and
auto-allows anything it classifies read-only, without ever raising a
permission request. Measured on this backend: `rm -f …` gates, but
`echo hello`, `cat /etc/hostname`, and `cat ~/.codeoid-decoy-secret.txt` all
executed **ungated** — including reads outside the workdir. So on the qwen
backend an agent can read any file the daemon user can (`~/.codeoid/config.json`,
`~/.ssh/…`) without `canUseTool` firing.

This is a property of the backend, not of codeoid's gate: codeoid cannot
intercept a tool call the CLI never asks about. The classifier layer is
documented in qwen-code as not overridable by permission mode, so the only
blunt mitigation available today is `excludeTools: ["run_shell_command"]`,
which removes shell capability entirely. `buildQwenEnv()` closes the adjacent
env-var vector, but NOT this one.

Audited against `gemini-cli` 0.50.0 (qwen-code is a fork of it): the gap looks
**qwen-specific**. Its bundle has no `isShellCommandReadOnlyAST` /
`resolveDefaultPermission`, and its shell `shouldConfirmExecute` has no
content-based read-only bypass — the only auto-allow paths are the policy
engine (`getMessageBusDecision`), YOLO mode, and sandbox policy, none of which
codeoid enables. That check is STATIC only; the live ACP confirmation was
blocked on expired `~/.gemini` OAuth (re-run `gemini` interactively to redo it).

Onboarding: `codeoid login [key] [--zeroid <preset|url>]` verifies the key via a
token exchange and writes `apiKey` (+ `zeroidUrl` if `--zeroid` given) to the
config file. The shipped default issuer is the Highflame SaaS, so a hosted user
Expand Down
5 changes: 5 additions & 0 deletions bun.lock

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"@highflame/codeoid-core": "^0.4.0",
"@highflame/codeoid-protocol": "^0.4.0",
"@highflame/sdk": "^0.3.18",
"@qwen-code/sdk": "0.1.8",
"@xenova/transformers": "^2.17.2",
"commander": "^13.0.0",
"grammy": "^1.35.0",
Expand Down
56 changes: 56 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,13 +629,62 @@ const ProvidersSchema = z
command: z.string().default("gemini"),
})
.default({ enabled: true, command: "gemini" }),
/** Alibaba Qwen Code, driven in-process via `@qwen-code/sdk`. */
qwen: z
.object({
enabled: z.boolean().default(true),
/**
* Credential path. `openai` = the OpenAI-compatible API-key path
* (`OPENAI_API_KEY` + `baseUrl`); `qwen-oauth` = a qwen.ai
* subscription already logged in under `~/.qwen`. Omit to
* auto-detect: OAuth creds on disk win, else the key.
*/
authType: z.enum(["openai", "qwen-oauth"]).optional(),
/**
* OpenAI-compatible gateway. Accepts a {@link QWEN_BASE_URL_PRESETS}
* name or a full URL. Omit to let the CLI use its own default (or
* `OPENAI_BASE_URL` from the environment).
*/
baseUrl: z.string().optional(),
/** Default model when the session doesn't pick one (e.g. `qwen3.8-max`). */
model: z.string().optional(),
/**
* Override the CLI the SDK drives. Omit to use the CLI bundled inside
* `@qwen-code/sdk`, which is the version this provider was tested
* against — the same posture as the pinned gemini-cli dependency.
*/
command: z.string().optional(),
})
.default({ enabled: true }),
})
.default({
pi: { enabled: true, command: "pi" },
codex: { enabled: true, command: "codex" },
geminiCli: { enabled: true, command: "gemini" },
qwen: { enabled: true },
});

/**
* Named Qwen gateways, so an operator never has to discover these by hand.
*
* `bailian-plan-*` is the endpoint behind a Model Studio *plan-specific* key
* (`sk-sp-…`, issued by the Bailian token-plan installer). It is NOT the
* standard DashScope host — plan keys are rejected there with
* `invalid_api_key`, which is a confusing failure to debug from scratch.
*/
export const QWEN_BASE_URL_PRESETS: Record<string, string> = {
"dashscope-intl": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
"dashscope-cn": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"bailian-plan-intl":
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
};

/** Resolve a `providers.qwen.baseUrl` preset name (or pass a URL through). */
export function resolveQwenBaseUrl(value: string | undefined): string | undefined {
if (!value) return undefined;
return QWEN_BASE_URL_PRESETS[value] ?? value;
}

/**
* A single MCP server in the canonical registry (see
* docs/provider-mcp-registry-design.md). Declared once here; codeoid mounts it
Expand Down Expand Up @@ -1030,6 +1079,13 @@ export interface CodeoidConfig {
enabled: boolean;
command: string;
};
qwen: {
enabled: boolean;
authType?: "openai" | "qwen-oauth";
baseUrl?: string;
model?: string;
command?: string;
};
};
/**
* Canonical MCP server registry — declared once, mounted on every backend
Expand Down
41 changes: 41 additions & 0 deletions src/daemon/providers/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,44 @@ export function buildGeminiCliEnv(
base,
);
}

/** The qwen-code allowlist policy — shared by the builder and its tests. */
const QWEN_ENV_POLICY: SubprocessEnvPolicy = {
// The CLI's own namespaces: OPENAI_* is its OpenAI-compatible auth path
// (key + base URL + model), QWEN_* its own config/runtime namespace,
// DASHSCOPE_* the Alibaba Model Studio key. Plus POSIX locale categories.
prefixes: ["OPENAI_", "QWEN_", "DASHSCOPE_", "LC_"],
suffixes: ["_API_KEY"],
};

/**
* Environment for the qwen-code CLI the `@qwen-code/sdk` spawns.
*
* qwen-code's subscription credential store is `~/.qwen/oauth_creds.json`
* (HOME is in the shared basics — qwen.ai OAuth never transits codeoid),
* with env fallbacks for the API-key path (`OPENAI_API_KEY` +
* `OPENAI_BASE_URL` + `OPENAI_MODEL`, or `DASHSCOPE_API_KEY`).
*
* SECURITY — why this returns MORE than an allowlist. Unlike the Claude
* Agent SDK (which replaces the child env outright), `@qwen-code/sdk` spawns
* with `{ ...process.env, ...options.env }`. A plain allowlist would
* therefore be a no-op: everything we left out is still inherited, including
* the secrets `loadDotEnv` puts in the daemon's env (`CODEOID_API_KEY` = the
* root ZeroID key, `TELEGRAM_BOT_TOKEN`, provider keys). Since a merge can
* only add or override keys — never delete them — we explicitly map every
* non-allowlisted name in `base` to the empty string. After the SDK's merge
* the child sees exactly the allowlist, and every daemon secret reads as
* empty rather than leaking to the agent's Bash tool or stdio MCP servers.
*
* Pure + exported for unit testing.
*/
export function buildQwenEnv(
base: Record<string, string | undefined> = process.env,
): Record<string, string> {
const allowed = buildSubprocessEnv(QWEN_ENV_POLICY, base);
const out: Record<string, string> = { ...allowed };
for (const name of Object.keys(base)) {
if (!(name in allowed)) out[name] = "";
}
return out;
}
Loading
Loading