@offerpilot/axiomruntime 0.0.1 → 0.0.2

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.
package/README.md CHANGED
@@ -26,33 +26,34 @@ AI 与维护者使用的英文权威文档位于 `AGENTS.md`、`docs/AI_RUNTIME_
26
26
 
27
27
  ## 通过 npm 安装
28
28
 
29
- 运行环境需要 Node.js `22.19.0` 或更高版本。全局安装后,npm 会把本包注册为 `ai` 命令:
29
+ 运行环境需要 Node.js `22.19.0` 或更高版本。先确认版本,再执行全局安装;npm 会把本包注册为 `ai` 命令:
30
30
 
31
31
  ```bash
32
+ node --version
32
33
  npm install --global @offerpilot/axiomruntime
33
34
  ai --version
34
35
  ai setup
35
36
  ```
36
37
 
38
+ 如果 npm 显示 `EBADENGINE`,说明当前 Node.js 版本不受支持。请先升级到 `22.19.0+`,不要继续使用警告状态下安装的 CLI。
39
+
37
40
  `ai --version` 用于验证 npm 包和命令安装成功;`ai setup` 初始化 Runtime 配置,并可在确认后安装缺失的 Claude Code/Codex。之后可直接运行 `ai`。
38
41
 
42
+ 可用 root 完成全局安装,但日常执行 `ai` 和常驻服务应使用专用非 root 用户。若仍以 root 启动 Claude,Runtime 会把 `auto`/`bypassPermissions` 安全约束为默认权限、输出告警,并且绝不会传递 Claude Code 禁止 root 使用的 `--dangerously-skip-permissions`。
43
+
39
44
  ## 通过 Homebrew 安装
40
45
 
41
- 发布流程会在 npm 发布成功后把 `Formula/axiomruntime.rb` 同步到 `offerpilot/homebrew-tap`。Tap 建立后可使用:
46
+ 维护者发布新版本时可直接双击仓库根目录的 `发布.command`,交互选择 npm、Homebrew 或发布条件检查,无需记发布命令。npm 发布成功后,再选择 Homebrew;助手会读取 npm Registry 的真实 tarball 与 SHA256,并把 `Formula/axiomruntime.rb` 同步到 GitLab 项目 `linlangli/axiom`。
47
+
48
+ 用户首次安装需要显式提供 GitLab 地址:
42
49
 
43
50
  ```bash
44
- brew tap offerpilot/tap
45
- brew install axiomruntime
51
+ brew tap linlangli/axiom https://gitlab.com/linlangli/axiom.git
52
+ brew install linlangli/axiom/axiomruntime
46
53
  ai --version
47
54
  ai setup
48
55
  ```
49
56
 
50
- 如果 Tap 不托管在 GitHub,需要在首次添加时显式提供公开 Git 地址:
51
-
52
- ```bash
53
- brew tap offerpilot/tap <homebrew-tap-public-git-url>
54
- ```
55
-
56
57
  ## 安装开发依赖
57
58
 
58
59
  运行和开发需要 Node.js `22.19.0` 或更高版本。该下限与锁定依赖的运行时要求一致;Node.js 20 不受支持。
@@ -103,6 +104,8 @@ AI_GATEWAY_SERVER_TOKEN='replace-me' npm run server
103
104
 
104
105
  默认分支提交通过测试后,GitLab CI 会构建以 commit SHA 标记的镜像,并由锁定当前项目的受保护 Runner(标签 `axiomruntime-prod`)部署到 `154.201.73.41:/opt/AxiomRuntime`。部署只操作 `axiomruntime` Compose 项目;健康检查失败时恢复上一镜像,不清理服务器上的其他容器或镜像。
105
106
 
107
+ 镜像构建会在一次性 build stage 中编译 `better-sqlite3` 原生 addon,再把裁掉开发依赖后的 production dependency tree 复制到相同 Node.js 版本的最终镜像;最终镜像不包含编译器。
108
+
106
109
  首次部署前,服务器必须已有 `/opt/AxiomRuntime/config/providers.json` 和 `/opt/AxiomRuntime/secrets/server-token`,且容器用户 `10001:10001` 可以读写配置目录。CI 只校验这些文件,不生成或覆盖凭据。
107
110
 
108
111
  Telegram 容器默认不启动,以免与现有 long polling 进程争抢消息。完成迁移并停止旧 Bot 后,在服务器 `/opt/AxiomRuntime/.env` 中设置:
@@ -130,6 +133,10 @@ ai provider add
130
133
  ai provider list
131
134
  ai provider edit
132
135
  ai provider delete
136
+ ai set codex chatgpt
137
+ ai set codex provider 51talk-cq gpt-5.6-sol
138
+ ai set codex provider 51talk-cq
139
+ ai set codex proxy 127.0.0.1:7890
133
140
  ai telegram
134
141
  ai telegram list
135
142
  ai telegram start --bot ops
@@ -0,0 +1,121 @@
1
+ import { writeLog } from "../../core/logs/log-service.js";
2
+ import { readCache } from "../../core/config/cache-store.js";
3
+ import { listAvailableCodexGptModels, resolveCodexProviderModel, setCodexProvider, setCodexProxy } from "../../core/integrations/codex-provider-config.js";
4
+ import { listProviders } from "../../core/providers/provider-service.js";
5
+ import { promptSelect, promptText } from "../prompts/prompt.js";
6
+ const SET_CODEX_USAGE = "Usage: ai set codex [chatgpt | provider [name] [model-id] | proxy <host:port|url|off>]";
7
+ export async function runSetCommand(args) {
8
+ const [targetArg, modeArg, providerNameArg, modelArg, ...extra] = args;
9
+ const target = targetArg ?? "codex";
10
+ if (target !== "codex") {
11
+ throw new Error(SET_CODEX_USAGE);
12
+ }
13
+ if (extra.length) {
14
+ throw new Error(SET_CODEX_USAGE);
15
+ }
16
+ const mode = await getMode(modeArg);
17
+ if (mode === "proxy") {
18
+ if (modelArg)
19
+ throw new Error(SET_CODEX_USAGE);
20
+ const proxyValue = providerNameArg ?? await promptText("Codex proxy (host:port, URL, or off)");
21
+ const result = await setCodexProxy(proxyValue);
22
+ console.log(result.proxyUrl ? `Codex proxy set to: ${result.proxyUrl}` : "Codex proxy cleared.");
23
+ console.log(`Updated: ${result.configPath}`);
24
+ if (!result.environment.applied && result.environment.reason) {
25
+ console.log(result.environment.reason);
26
+ }
27
+ printRestartResult(result.restart);
28
+ await writeLog({
29
+ category: "provider",
30
+ action: "codex_proxy_set",
31
+ message: result.proxyUrl ? "Codex proxy enabled." : "Codex proxy cleared.",
32
+ metadata: {
33
+ enabled: Boolean(result.proxyUrl),
34
+ desktopEnvironmentApplied: result.environment.applied,
35
+ restartScheduled: result.restart.scheduled
36
+ }
37
+ });
38
+ return;
39
+ }
40
+ if (mode === "chatgpt" && (providerNameArg || modelArg)) {
41
+ throw new Error("Usage: ai set codex chatgpt");
42
+ }
43
+ const selection = mode === "chatgpt"
44
+ ? { type: "chatgpt" }
45
+ : { type: "provider", ...await getProviderAndModel(providerNameArg, modelArg) };
46
+ if (mode === "chatgpt") {
47
+ console.log("Opening ChatGPT login in your browser...");
48
+ }
49
+ const result = await setCodexProvider(selection);
50
+ console.log(`Codex provider set to: ${result.providerName}`);
51
+ if (result.model)
52
+ console.log(`Codex model set to: ${result.model}`);
53
+ console.log(`Updated: ${result.configPath}`);
54
+ console.log(`Updated: ${result.authPath}`);
55
+ printRestartResult(result.restart);
56
+ await writeLog({
57
+ category: "provider",
58
+ action: "codex_provider_set",
59
+ message: `Codex provider set to ${result.providerName}.`,
60
+ metadata: {
61
+ selection: result.selection,
62
+ provider: result.providerName,
63
+ providerId: result.providerId,
64
+ model: result.model,
65
+ restartScheduled: result.restart.scheduled
66
+ }
67
+ });
68
+ }
69
+ async function getMode(value) {
70
+ if (value === "chatgpt" || value === "provider" || value === "proxy")
71
+ return value;
72
+ if (value) {
73
+ throw new Error(`Unknown Codex setting: ${value}`);
74
+ }
75
+ return promptSelect("Set Codex", [
76
+ { label: "ChatGPT account", value: "chatgpt" },
77
+ { label: "Configured provider", value: "provider" },
78
+ { label: "Proxy", value: "proxy" }
79
+ ]);
80
+ }
81
+ function printRestartResult(restart) {
82
+ if (restart.scheduled) {
83
+ console.log(`Restarting ${restart.appPath ?? "Codex app"}...`);
84
+ }
85
+ else {
86
+ console.log(`Restart Codex app manually. ${restart.reason ?? ""}`.trim());
87
+ }
88
+ }
89
+ async function getProviderAndModel(name, requestedModel) {
90
+ const [providers, cache] = await Promise.all([listProviders(), readCache()]);
91
+ return selectCodexProviderAndModel(providers, cache.providers, name, requestedModel);
92
+ }
93
+ export async function selectCodexProviderAndModel(providers, providerCache, name, requestedModel, select = promptSelect) {
94
+ if (!providers.length) {
95
+ throw new Error("No providers configured. Run `ai provider add` first.");
96
+ }
97
+ if (!name && !providers.some((provider) => providerGptModels(provider.name, providerCache).length)) {
98
+ throw new Error("No configured provider has available GPT models. Run `ai status` to refresh model lists.");
99
+ }
100
+ const providerName = name ?? await select("Select provider for Codex", providers.map((provider) => ({
101
+ label: `${provider.name} / ${providerGptModels(provider.name, providerCache).length} GPT models`,
102
+ value: provider.name
103
+ })));
104
+ const provider = providers.find((item) => item.name === providerName);
105
+ if (!provider) {
106
+ throw new Error(`Provider not found: ${providerName}`);
107
+ }
108
+ const availableModels = providerGptModels(provider.name, providerCache);
109
+ if (!availableModels.length) {
110
+ resolveCodexProviderModel(provider.name, availableModels);
111
+ }
112
+ const model = requestedModel
113
+ ? resolveCodexProviderModel(provider.name, availableModels, requestedModel)
114
+ : name
115
+ ? resolveCodexProviderModel(provider.name, availableModels)
116
+ : await select(`Select GPT model for ${provider.name}`, availableModels.map((availableModel) => ({ label: availableModel, value: availableModel })));
117
+ return { provider, model };
118
+ }
119
+ function providerGptModels(providerName, providerCache) {
120
+ return listAvailableCodexGptModels(providerCache[providerName]?.models ?? []);
121
+ }
package/dist/cli/index.js CHANGED
@@ -10,6 +10,7 @@ import { runMemoryCommand } from "./commands/memory.js";
10
10
  import { runReportCommand } from "./commands/report.js";
11
11
  import { runLastCommand, runUseCommand } from "./commands/use.js";
12
12
  import { runSessionCommand } from "./commands/session.js";
13
+ import { runSetCommand } from "./commands/set.js";
13
14
  import { runSetupCommand } from "./commands/setup.js";
14
15
  import { runStatusCommand } from "./commands/status.js";
15
16
  import { runTelegramCommand } from "./commands/telegram.js";
@@ -17,6 +18,7 @@ import { runVersionCommand } from "./commands/version.js";
17
18
  import { writeLog } from "../core/logs/log-service.js";
18
19
  import { promptSelect } from "./prompts/prompt.js";
19
20
  import { getCliCommand, registerCliCommand } from "./registry.js";
21
+ import { redactCodexProxyCommandArgs } from "./sanitize-args.js";
20
22
  registerBuiltInCommands();
21
23
  async function main() {
22
24
  const [, , command, ...args] = process.argv;
@@ -52,6 +54,9 @@ async function main() {
52
54
  process.exitCode = 1;
53
55
  }
54
56
  function sanitizeArgs(command, args) {
57
+ const redactedProxyArgs = redactCodexProxyCommandArgs(command, args);
58
+ if (redactedProxyArgs)
59
+ return redactedProxyArgs;
55
60
  if (command === "add" && args.length >= 3) {
56
61
  return [args[0], args[1], maskSecretArg(args[2])];
57
62
  }
@@ -131,7 +136,7 @@ function maskSecretArg(value) {
131
136
  return `${value.slice(0, 4)}****${value.slice(-4)}`;
132
137
  }
133
138
  async function runInteractive() {
134
- const interactiveCommandNames = ["setup", "use", "add", "memory", "telegram", "status", "session", "report", "doctor", "log", "context", "help", "last"];
139
+ const interactiveCommandNames = ["setup", "use", "set", "add", "memory", "telegram", "status", "session", "report", "doctor", "log", "context", "help", "last"];
135
140
  const command = await promptSelect("AI Gateway", [
136
141
  ...interactiveCommandNames
137
142
  .flatMap((name) => {
@@ -250,6 +255,11 @@ function registerBuiltInCommands() {
250
255
  description: "use",
251
256
  run: (args) => runUseCommand(args)
252
257
  });
258
+ registerCliCommand({
259
+ name: "set",
260
+ description: "set Codex provider or proxy",
261
+ run: (args) => runSetCommand(args)
262
+ });
253
263
  registerCliCommand({
254
264
  name: "last",
255
265
  description: "last",
@@ -0,0 +1,6 @@
1
+ export function redactCodexProxyCommandArgs(command, args) {
2
+ if (command !== "set" || args[0] !== "codex" || args[1] !== "proxy" || args.length < 3) {
3
+ return null;
4
+ }
5
+ return [args[0], args[1], "[proxy-redacted]", ...args.slice(3)];
6
+ }
@@ -0,0 +1,541 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import lockfile from "proper-lockfile";
7
+ import { resolveToolCommand } from "../runner/command-resolver.js";
8
+ const LOCK_OPTIONS = {
9
+ realpath: false,
10
+ stale: 10_000,
11
+ update: 5_000,
12
+ retries: {
13
+ retries: 100,
14
+ factor: 1.2,
15
+ minTimeout: 25,
16
+ maxTimeout: 500
17
+ }
18
+ };
19
+ const RESERVED_PROVIDER_IDS = new Set(["openai", "ollama", "lmstudio"]);
20
+ const CODEX_PROXY_ENV_KEYS = [
21
+ "HTTP_PROXY",
22
+ "HTTPS_PROXY",
23
+ "ALL_PROXY",
24
+ "http_proxy",
25
+ "https_proxy",
26
+ "all_proxy"
27
+ ];
28
+ export async function setCodexProvider(selection, options = {}) {
29
+ const codexHome = resolveCodexHome(options.codexHome);
30
+ const configPath = path.join(codexHome, "config.toml");
31
+ const authPath = path.join(codexHome, "auth.json");
32
+ await fs.mkdir(codexHome, { recursive: true, mode: 0o700 });
33
+ const release = await lockfile.lock(configPath, LOCK_OPTIONS);
34
+ let providerName;
35
+ let providerId;
36
+ let model;
37
+ try {
38
+ if (selection.type === "chatgpt") {
39
+ providerName = "ChatGPT";
40
+ providerId = "openai";
41
+ model = null;
42
+ await configureChatGpt(configPath, authPath, codexHome, options.runChatGptLogin);
43
+ }
44
+ else {
45
+ providerName = selection.provider.name;
46
+ providerId = providerIdForName(selection.provider.name);
47
+ model = assertCodexGptModel(selection.model);
48
+ await configureCustomProvider(configPath, authPath, selection.provider, providerId, model);
49
+ }
50
+ }
51
+ finally {
52
+ await release();
53
+ }
54
+ const restart = await (options.scheduleRestart ?? scheduleCodexAppRestart)();
55
+ return {
56
+ selection: selection.type,
57
+ providerName,
58
+ providerId,
59
+ model,
60
+ configPath,
61
+ authPath,
62
+ restart
63
+ };
64
+ }
65
+ export async function setCodexProxy(value, options = {}) {
66
+ const proxyUrl = normalizeCodexProxy(value);
67
+ const codexHome = resolveCodexHome(options.codexHome);
68
+ const configPath = path.join(codexHome, "config.toml");
69
+ await fs.mkdir(codexHome, { recursive: true, mode: 0o700 });
70
+ const release = await lockfile.lock(configPath, LOCK_OPTIONS);
71
+ let environment;
72
+ try {
73
+ const snapshot = await snapshotFile(configPath);
74
+ const current = snapshot?.content.toString("utf8") ?? "";
75
+ try {
76
+ if (snapshot || proxyUrl !== null) {
77
+ await atomicWriteFile(configPath, buildCodexProxyConfig(current, proxyUrl), snapshot?.mode ?? 0o600);
78
+ }
79
+ environment = await (options.applyProxyEnvironment
80
+ ?? ((nextProxyUrl) => applyCodexProxyEnvironment(nextProxyUrl, options.platform)))(proxyUrl);
81
+ }
82
+ catch (error) {
83
+ await restoreFile(configPath, snapshot).catch(() => undefined);
84
+ throw error;
85
+ }
86
+ }
87
+ finally {
88
+ await release();
89
+ }
90
+ const restart = await (options.scheduleRestart ?? scheduleCodexAppRestart)();
91
+ return { proxyUrl, configPath, environment, restart };
92
+ }
93
+ export function providerIdForName(name) {
94
+ const trimmed = name.trim();
95
+ if (/^[A-Za-z0-9_-]+$/.test(trimmed) && !RESERVED_PROVIDER_IDS.has(trimmed.toLowerCase())) {
96
+ return trimmed;
97
+ }
98
+ const slug = trimmed
99
+ .normalize("NFKD")
100
+ .replace(/[^A-Za-z0-9_-]+/g, "-")
101
+ .replace(/^-+|-+$/g, "")
102
+ .toLowerCase()
103
+ .slice(0, 40) || "provider";
104
+ const digest = createHash("sha256").update(trimmed).digest("hex").slice(0, 8);
105
+ return `ai-${slug}-${digest}`;
106
+ }
107
+ export function listAvailableCodexGptModels(models) {
108
+ return [...new Set(models.map((model) => model.trim()).filter(isCodexGptModel))]
109
+ .sort(compareCodexGptModelsNewestFirst);
110
+ }
111
+ export function resolveCodexProviderModel(providerName, models, requestedModel) {
112
+ const available = listAvailableCodexGptModels(models);
113
+ if (!available.length) {
114
+ throw new Error(`Provider ${providerName} has no available GPT models. Run \`ai status\` to refresh its model list.`);
115
+ }
116
+ if (!requestedModel)
117
+ return available[0];
118
+ const requested = assertCodexGptModel(requestedModel);
119
+ const selected = available.find((model) => model.toLowerCase() === requested.toLowerCase());
120
+ if (!selected) {
121
+ throw new Error(`GPT model ${requested} is not available for provider ${providerName}. Available: ${available.join(", ")}`);
122
+ }
123
+ return selected;
124
+ }
125
+ export function buildCodexProviderConfig(current, provider, providerId, model = provider.model) {
126
+ if (!provider.baseUrl.trim())
127
+ throw new Error(`Provider ${provider.name} has no base URL.`);
128
+ if (!provider.apiKey.trim())
129
+ throw new Error(`Provider ${provider.name} has no API key.`);
130
+ const selectedModel = assertCodexGptModel(model);
131
+ const eol = current.includes("\r\n") ? "\r\n" : "\n";
132
+ let next = removeProviderTables(current, providerId);
133
+ next = setTopLevelTomlString(next, "cli_auth_credentials_store", "file");
134
+ next = setTopLevelTomlString(next, "model_provider", providerId);
135
+ next = setTopLevelTomlString(next, "model", selectedModel);
136
+ const table = [
137
+ `[model_providers.${providerId}]`,
138
+ `name = ${tomlString(provider.name)}`,
139
+ `base_url = ${tomlString(provider.baseUrl.trim().replace(/\/+$/, ""))}`,
140
+ `env_key = ${tomlString("OPENAI_API_KEY")}`,
141
+ "requires_openai_auth = true",
142
+ `wire_api = ${tomlString("responses")}`
143
+ ].join(eol);
144
+ return `${next.trimEnd()}${eol}${eol}${table}${eol}`;
145
+ }
146
+ export function buildCodexChatGptConfig(current) {
147
+ let next = setTopLevelTomlString(current, "cli_auth_credentials_store", "file");
148
+ next = setTopLevelTomlString(next, "model_provider", "openai");
149
+ return ensureTrailingNewline(next);
150
+ }
151
+ export function normalizeCodexProxy(value) {
152
+ const trimmed = value.trim();
153
+ if (!trimmed) {
154
+ throw new Error("Proxy address is required. Example: 127.0.0.1:7890");
155
+ }
156
+ if (["off", "none", "clear", "disable"].includes(trimmed.toLowerCase()))
157
+ return null;
158
+ const candidate = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(trimmed)
159
+ ? trimmed
160
+ : `http://${trimmed}`;
161
+ let parsed;
162
+ try {
163
+ parsed = new URL(candidate);
164
+ }
165
+ catch {
166
+ throw new Error(`Invalid proxy address: ${value}`);
167
+ }
168
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
169
+ throw new Error("Codex proxy must use http:// or https://.");
170
+ }
171
+ if (!parsed.hostname)
172
+ throw new Error(`Invalid proxy address: ${value}`);
173
+ if (parsed.username || parsed.password) {
174
+ throw new Error("Proxy credentials are not accepted on the command line; use a local unauthenticated proxy endpoint.");
175
+ }
176
+ if ((parsed.pathname && parsed.pathname !== "/") || parsed.search || parsed.hash) {
177
+ throw new Error("Codex proxy must be an origin only, without a path, query, or fragment.");
178
+ }
179
+ return `${parsed.protocol}//${parsed.host}`;
180
+ }
181
+ export function buildCodexProxyConfig(current, proxyUrl) {
182
+ return updateTomlTableStrings(current, "shell_environment_policy.set", Object.fromEntries(CODEX_PROXY_ENV_KEYS.map((key) => [key, proxyUrl])));
183
+ }
184
+ export async function applyCodexProxyEnvironment(proxyUrl, platform = process.platform) {
185
+ if (platform !== "darwin") {
186
+ return {
187
+ applied: false,
188
+ reason: "Automatic desktop proxy environment updates are currently supported on macOS only."
189
+ };
190
+ }
191
+ const snapshots = new Map();
192
+ for (const key of CODEX_PROXY_ENV_KEYS) {
193
+ const result = await runProcess("/bin/launchctl", ["getenv", key]);
194
+ const previous = result.code === 0 ? result.stdout.replace(/\r?\n$/, "") : "";
195
+ snapshots.set(key, previous || null);
196
+ }
197
+ const changed = [];
198
+ try {
199
+ for (const key of CODEX_PROXY_ENV_KEYS) {
200
+ const args = proxyUrl === null ? ["unsetenv", key] : ["setenv", key, proxyUrl];
201
+ const result = await runProcess("/bin/launchctl", args);
202
+ if (result.code !== 0) {
203
+ throw new Error(`launchctl ${args[0]} failed for ${key}: ${result.stderr.trim() || `exit ${result.code}`}`);
204
+ }
205
+ changed.push(key);
206
+ }
207
+ }
208
+ catch (error) {
209
+ for (const key of changed.reverse()) {
210
+ const previous = snapshots.get(key);
211
+ const args = previous === null || previous === undefined
212
+ ? ["unsetenv", key]
213
+ : ["setenv", key, previous];
214
+ await runProcess("/bin/launchctl", args).catch(() => undefined);
215
+ }
216
+ throw error;
217
+ }
218
+ return { applied: true };
219
+ }
220
+ export async function scheduleCodexAppRestart(options = {}) {
221
+ const platform = options.platform ?? process.platform;
222
+ if (platform !== "darwin") {
223
+ return { scheduled: false, reason: "Automatic Codex app restart is currently supported on macOS only." };
224
+ }
225
+ const homeDir = options.homeDir ?? os.homedir();
226
+ const candidates = options.appCandidates ?? [
227
+ "/Applications/Codex.app",
228
+ "/Applications/ChatGPT.app",
229
+ path.join(homeDir, "Applications/Codex.app"),
230
+ path.join(homeDir, "Applications/ChatGPT.app")
231
+ ];
232
+ const pathExists = options.pathExists ?? fileExists;
233
+ let appPath;
234
+ for (const candidate of candidates) {
235
+ if (await pathExists(candidate)) {
236
+ appPath = candidate;
237
+ break;
238
+ }
239
+ }
240
+ if (!appPath) {
241
+ return { scheduled: false, reason: "Codex.app or ChatGPT.app was not found." };
242
+ }
243
+ const appName = path.basename(appPath, ".app");
244
+ if (!/^[A-Za-z0-9 ._-]+$/.test(appName)) {
245
+ return { scheduled: false, reason: `Unsupported application name: ${appName}` };
246
+ }
247
+ const appleScriptName = appName.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
248
+ const helper = [
249
+ "const { spawnSync } = require('node:child_process');",
250
+ `const appPath = ${JSON.stringify(appPath)};`,
251
+ `const quitScript = ${JSON.stringify(`tell application "${appleScriptName}" to quit`)};`,
252
+ "setTimeout(() => {",
253
+ " spawnSync('/usr/bin/osascript', ['-e', quitScript], { stdio: 'ignore' });",
254
+ " setTimeout(() => spawnSync('/usr/bin/open', [appPath], { stdio: 'ignore' }), 1000);",
255
+ "}, 750);"
256
+ ].join("\n");
257
+ (options.spawnDetached ?? defaultSpawnDetached)(process.execPath, ["-e", helper]);
258
+ return { scheduled: true, appPath };
259
+ }
260
+ async function configureChatGpt(configPath, authPath, codexHome, runner) {
261
+ const [configSnapshot, authSnapshot] = await Promise.all([
262
+ snapshotFile(configPath),
263
+ snapshotFile(authPath)
264
+ ]);
265
+ try {
266
+ const exitCode = await (runner ?? runCodexChatGptLogin)({ codexHome });
267
+ if (exitCode !== 0) {
268
+ throw new Error(`ChatGPT login failed with exit code ${exitCode}.`);
269
+ }
270
+ await validateChatGptAuth(authPath);
271
+ await fs.chmod(authPath, 0o600);
272
+ const currentConfig = configSnapshot?.content.toString("utf8") ?? "";
273
+ await atomicWriteFile(configPath, buildCodexChatGptConfig(currentConfig), configSnapshot?.mode ?? 0o600);
274
+ }
275
+ catch (error) {
276
+ await Promise.allSettled([
277
+ restoreFile(configPath, configSnapshot),
278
+ restoreFile(authPath, authSnapshot)
279
+ ]);
280
+ throw error;
281
+ }
282
+ }
283
+ async function configureCustomProvider(configPath, authPath, provider, providerId, model) {
284
+ const [configSnapshot, authSnapshot] = await Promise.all([
285
+ snapshotFile(configPath),
286
+ snapshotFile(authPath)
287
+ ]);
288
+ const currentConfig = configSnapshot?.content.toString("utf8") ?? "";
289
+ const auth = `${JSON.stringify({ auth_mode: "apikey", OPENAI_API_KEY: provider.apiKey.trim() }, null, 2)}\n`;
290
+ try {
291
+ await atomicWriteFile(authPath, auth, 0o600);
292
+ await atomicWriteFile(configPath, buildCodexProviderConfig(currentConfig, provider, providerId, model), configSnapshot?.mode ?? 0o600);
293
+ }
294
+ catch (error) {
295
+ await Promise.allSettled([
296
+ restoreFile(configPath, configSnapshot),
297
+ restoreFile(authPath, authSnapshot)
298
+ ]);
299
+ throw error;
300
+ }
301
+ }
302
+ async function runCodexChatGptLogin({ codexHome }) {
303
+ const command = resolveToolCommand("codex");
304
+ if (!command) {
305
+ throw new Error("Codex CLI not found. Run `ai setup --install-tools` first.");
306
+ }
307
+ return new Promise((resolve, reject) => {
308
+ const child = spawn(command, ["-c", "cli_auth_credentials_store=\"file\"", "login"], {
309
+ env: { ...process.env, CODEX_HOME: codexHome },
310
+ stdio: "inherit"
311
+ });
312
+ child.once("error", reject);
313
+ child.once("close", (code, signal) => {
314
+ if (signal) {
315
+ reject(new Error(`ChatGPT login was interrupted by ${signal}.`));
316
+ return;
317
+ }
318
+ resolve(code ?? 1);
319
+ });
320
+ });
321
+ }
322
+ async function validateChatGptAuth(authPath) {
323
+ let auth;
324
+ try {
325
+ auth = JSON.parse(await fs.readFile(authPath, "utf8"));
326
+ }
327
+ catch {
328
+ throw new Error("ChatGPT login completed without a readable Codex auth.json.");
329
+ }
330
+ if (!isRecord(auth) || auth.auth_mode !== "chatgpt" || !isRecord(auth.tokens)) {
331
+ throw new Error("ChatGPT login did not produce valid Codex credentials.");
332
+ }
333
+ if (typeof auth.tokens.access_token !== "string" || typeof auth.tokens.refresh_token !== "string") {
334
+ throw new Error("ChatGPT login credentials are incomplete.");
335
+ }
336
+ }
337
+ function setTopLevelTomlString(current, key, value) {
338
+ const eol = current.includes("\r\n") ? "\r\n" : "\n";
339
+ const lines = current.split(/\r?\n/);
340
+ const firstTable = lines.findIndex((line) => /^\s*\[\[?/.test(line));
341
+ const end = firstTable < 0 ? lines.length : firstTable;
342
+ const assignment = `${key} = ${tomlString(value)}`;
343
+ const pattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
344
+ let replaced = false;
345
+ const top = lines.slice(0, end).filter((line) => {
346
+ if (!pattern.test(line))
347
+ return true;
348
+ if (replaced)
349
+ return false;
350
+ replaced = true;
351
+ return true;
352
+ });
353
+ if (replaced) {
354
+ const index = top.findIndex((line) => pattern.test(line));
355
+ top[index] = assignment;
356
+ }
357
+ else {
358
+ while (top.length && top[top.length - 1] === "")
359
+ top.pop();
360
+ top.push(assignment, "");
361
+ }
362
+ return [...top, ...lines.slice(end)].join(eol);
363
+ }
364
+ function updateTomlTableStrings(current, tableName, values) {
365
+ const eol = current.includes("\r\n") ? "\r\n" : "\n";
366
+ const lines = current.split(/\r?\n/);
367
+ const tablePattern = new RegExp(`^\\s*\\[${escapeRegExp(tableName)}\\]\\s*(?:#.*)?$`);
368
+ const tableIndex = lines.findIndex((line) => tablePattern.test(line));
369
+ const assignments = Object.entries(values)
370
+ .filter((entry) => entry[1] !== null)
371
+ .map(([key, value]) => `${key} = ${tomlString(value)}`);
372
+ if (tableIndex < 0) {
373
+ if (!assignments.length)
374
+ return ensureTrailingNewline(current);
375
+ const prefix = current.trimEnd();
376
+ const table = [`[${tableName}]`, ...assignments].join(eol);
377
+ return `${prefix ? `${prefix}${eol}${eol}` : ""}${table}${eol}`;
378
+ }
379
+ let tableEnd = lines.length;
380
+ for (let index = tableIndex + 1; index < lines.length; index += 1) {
381
+ if (/^\s*\[\[?/.test(lines[index])) {
382
+ tableEnd = index;
383
+ break;
384
+ }
385
+ }
386
+ const managedPatterns = Object.keys(values).map((key) => new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`));
387
+ const body = lines
388
+ .slice(tableIndex + 1, tableEnd)
389
+ .filter((line) => !managedPatterns.some((pattern) => pattern.test(line)));
390
+ while (body.length && body[body.length - 1] === "")
391
+ body.pop();
392
+ if (body.length && assignments.length)
393
+ body.push("");
394
+ body.push(...assignments);
395
+ return ensureTrailingNewline([
396
+ ...lines.slice(0, tableIndex + 1),
397
+ ...body,
398
+ ...lines.slice(tableEnd)
399
+ ].join(eol));
400
+ }
401
+ function removeProviderTables(current, providerId) {
402
+ const lines = current.split(/\r?\n/);
403
+ const prefix = `model_providers.${providerId}`;
404
+ let skip = false;
405
+ const kept = [];
406
+ for (const line of lines) {
407
+ const header = /^\s*\[([^\]]+)\]\s*(?:#.*)?$/.exec(line)?.[1];
408
+ if (header) {
409
+ skip = header === prefix || header.startsWith(`${prefix}.`);
410
+ }
411
+ if (!skip)
412
+ kept.push(line);
413
+ }
414
+ return kept.join(current.includes("\r\n") ? "\r\n" : "\n");
415
+ }
416
+ async function snapshotFile(filePath) {
417
+ try {
418
+ const [content, stat] = await Promise.all([fs.readFile(filePath), fs.stat(filePath)]);
419
+ return { content, mode: stat.mode & 0o777 };
420
+ }
421
+ catch (error) {
422
+ if (error.code === "ENOENT")
423
+ return null;
424
+ throw error;
425
+ }
426
+ }
427
+ async function restoreFile(filePath, snapshot) {
428
+ if (!snapshot) {
429
+ await fs.rm(filePath, { force: true });
430
+ return;
431
+ }
432
+ await atomicWriteFile(filePath, snapshot.content, snapshot.mode);
433
+ }
434
+ async function atomicWriteFile(filePath, content, mode) {
435
+ await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
436
+ const temporaryPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
437
+ try {
438
+ await fs.writeFile(temporaryPath, content, { mode });
439
+ await fs.chmod(temporaryPath, mode);
440
+ await fs.rename(temporaryPath, filePath);
441
+ }
442
+ catch (error) {
443
+ await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
444
+ throw error;
445
+ }
446
+ }
447
+ function resolveCodexHome(explicit) {
448
+ const value = explicit ?? process.env.CODEX_HOME;
449
+ return value ? path.resolve(value) : path.join(os.homedir(), ".codex");
450
+ }
451
+ function ensureTrailingNewline(value) {
452
+ return `${value.trimEnd()}\n`;
453
+ }
454
+ function tomlString(value) {
455
+ return JSON.stringify(value);
456
+ }
457
+ function escapeRegExp(value) {
458
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
459
+ }
460
+ function assertCodexGptModel(model) {
461
+ const value = model.trim();
462
+ if (!isCodexGptModel(value)) {
463
+ throw new Error(`Codex provider model must be a GPT model id starting with \"gpt-\": ${model || "(empty)"}`);
464
+ }
465
+ return value;
466
+ }
467
+ function isCodexGptModel(model) {
468
+ return /^gpt-/i.test(model.trim());
469
+ }
470
+ function compareCodexGptModelsNewestFirst(left, right) {
471
+ const leftKey = codexGptModelSortKey(left);
472
+ const rightKey = codexGptModelSortKey(right);
473
+ const versionLength = Math.max(leftKey.version.length, rightKey.version.length);
474
+ for (let index = 0; index < versionLength; index += 1) {
475
+ const difference = (rightKey.version[index] ?? -1) - (leftKey.version[index] ?? -1);
476
+ if (difference)
477
+ return difference;
478
+ }
479
+ if (leftKey.tier !== rightKey.tier)
480
+ return rightKey.tier - leftKey.tier;
481
+ if (leftKey.alias !== rightKey.alias)
482
+ return rightKey.alias - leftKey.alias;
483
+ if (leftKey.snapshot !== rightKey.snapshot)
484
+ return rightKey.snapshot - leftKey.snapshot;
485
+ return left.localeCompare(right);
486
+ }
487
+ function codexGptModelSortKey(model) {
488
+ const value = model.toLowerCase();
489
+ const version = /^gpt-(\d+(?:\.\d+)*)/.exec(value)?.[1]
490
+ ?.split(".")
491
+ .map(Number) ?? [];
492
+ const snapshotMatch = /(?:^|-)(\d{4})-(\d{2})-(\d{2})(?:$|-)/.exec(value);
493
+ const snapshot = snapshotMatch
494
+ ? Number(`${snapshotMatch[1]}${snapshotMatch[2]}${snapshotMatch[3]}`)
495
+ : 0;
496
+ const tier = /(?:^|-)sol(?:-|$)/.test(value) ? 60
497
+ : /(?:^|-)pro(?:-|$)/.test(value) ? 50
498
+ : /^gpt-\d+(?:\.\d+)*(?:-\d{4}-\d{2}-\d{2})?$/.test(value) ? 45
499
+ : /(?:^|-)codex(?:-|$)/.test(value) ? 40
500
+ : /(?:^|-)terra(?:-|$)/.test(value) ? 30
501
+ : /(?:^|-)mini(?:-|$)/.test(value) ? 20
502
+ : /(?:^|-)luna(?:-|$)/.test(value) ? 10
503
+ : /(?:^|-)nano(?:-|$)/.test(value) ? 5
504
+ : 0;
505
+ return { version, tier, alias: snapshot ? 0 : 1, snapshot };
506
+ }
507
+ function isRecord(value) {
508
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
509
+ }
510
+ async function fileExists(filePath) {
511
+ try {
512
+ await fs.access(filePath);
513
+ return true;
514
+ }
515
+ catch {
516
+ return false;
517
+ }
518
+ }
519
+ function defaultSpawnDetached(command, args) {
520
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
521
+ child.unref();
522
+ }
523
+ function runProcess(command, args) {
524
+ return new Promise((resolve, reject) => {
525
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
526
+ let stdout = "";
527
+ let stderr = "";
528
+ child.stdout.setEncoding("utf8");
529
+ child.stderr.setEncoding("utf8");
530
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
531
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
532
+ child.once("error", reject);
533
+ child.once("close", (code, signal) => {
534
+ if (signal) {
535
+ reject(new Error(`${command} was interrupted by ${signal}.`));
536
+ return;
537
+ }
538
+ resolve({ code: code ?? 1, stdout, stderr });
539
+ });
540
+ });
541
+ }
@@ -0,0 +1,19 @@
1
+ export const CLAUDE_ROOT_PERMISSION_WARNING = "Claude bypass permissions is unavailable while Axiom Runtime runs as root; using default permissions. Run `ai` as a non-root user to enable auto mode.";
2
+ export function getProcessUid() {
3
+ return typeof process.getuid === "function" ? process.getuid() : null;
4
+ }
5
+ export function isPrivilegedProcess(options = {}) {
6
+ const uid = options.uid === undefined ? getProcessUid() : options.uid;
7
+ return uid === 0;
8
+ }
9
+ export function resolveClaudePermissionMode(requestedMode, options = {}) {
10
+ const privileged = isPrivilegedProcess(options);
11
+ const constrained = privileged && requestedMode === "bypassPermissions";
12
+ return {
13
+ requestedMode,
14
+ effectiveMode: constrained ? "default" : requestedMode,
15
+ privileged,
16
+ constrained,
17
+ reason: constrained ? "root_bypass_permissions_unsupported" : null
18
+ };
19
+ }
@@ -1,3 +1,4 @@
1
+ import { resolveClaudePermissionMode } from "./claude-permission-policy.js";
1
2
  const runnerEngines = new Map();
2
3
  export function registerRunnerEngine(descriptor) {
3
4
  runnerEngines.set(descriptor.name, descriptor);
@@ -30,12 +31,13 @@ registerRunnerEngine({
30
31
  ANTHROPIC_MODEL: model
31
32
  };
32
33
  },
33
- buildArgs: ({ provider, model, settingsPath }) => {
34
+ buildArgs: ({ provider, model, settingsPath, uid }) => {
34
35
  const args = ["--model", model];
35
36
  if (settingsPath) {
36
37
  args.push("--settings", settingsPath);
37
38
  }
38
- if (provider.mode === "auto") {
39
+ const permission = resolveClaudePermissionMode(provider.mode === "auto" ? "bypassPermissions" : "default", { uid });
40
+ if (permission.effectiveMode === "bypassPermissions") {
39
41
  args.push("--permission-mode", "bypassPermissions");
40
42
  args.push("--dangerously-skip-permissions");
41
43
  }
@@ -7,6 +7,7 @@ import { writeLog } from "../logs/log-service.js";
7
7
  import { isRecord } from "../utils/is-record.js";
8
8
  import { resolveToolCommand } from "./command-resolver.js";
9
9
  import { getRunnerEngine } from "./engine-registry.js";
10
+ import { CLAUDE_ROOT_PERMISSION_WARNING, resolveClaudePermissionMode } from "./claude-permission-policy.js";
10
11
  import { finishSession, startSession } from "../sessions/session-service.js";
11
12
  export async function runTool(tool, provider, model, options = {}) {
12
13
  await saveLastSelection(tool, provider.name, model);
@@ -15,13 +16,20 @@ export async function runTool(tool, provider, model, options = {}) {
15
16
  throw new Error(`Failed to find ${tool} CLI. Run \`ai doctor\` to check local CLI installation.`);
16
17
  }
17
18
  const engine = getRunnerEngine(tool);
18
- const env = buildRunnerEnv(tool, provider, model);
19
+ const launchPolicy = resolveRunnerLaunchPolicy(tool, provider);
20
+ if (launchPolicy.warning) {
21
+ console.warn(`Warning: ${launchPolicy.warning}`);
22
+ }
23
+ const effectiveProvider = launchPolicy.provider;
24
+ const env = buildRunnerEnv(tool, effectiveProvider, model);
19
25
  const temporaryFiles = [];
20
- const claudeSettingsPath = engine.requiresSettingsFile ? writeTemporaryClaudeSettings(provider) : null;
26
+ const claudeSettingsPath = engine.requiresSettingsFile
27
+ ? writeTemporaryClaudeSettings(effectiveProvider, { permissionMode: launchPolicy.claudeSettingsPermissionMode })
28
+ : null;
21
29
  if (claudeSettingsPath)
22
30
  temporaryFiles.push(claudeSettingsPath);
23
- const args = buildRunnerArgs(tool, provider, model, claudeSettingsPath);
24
- const effectiveBaseUrl = getToolBaseUrl(tool, provider);
31
+ const args = buildRunnerArgs(tool, effectiveProvider, model, claudeSettingsPath);
32
+ const effectiveBaseUrl = getToolBaseUrl(tool, effectiveProvider);
25
33
  await writeLog({
26
34
  category: "usage",
27
35
  action: "tool_spawn",
@@ -30,7 +38,9 @@ export async function runTool(tool, provider, model, options = {}) {
30
38
  tool,
31
39
  provider: provider.name,
32
40
  model,
33
- mode: provider.mode,
41
+ mode: effectiveProvider.mode,
42
+ configuredMode: provider.mode,
43
+ permissionConstraint: launchPolicy.reason,
34
44
  baseUrl: effectiveBaseUrl,
35
45
  configuredBaseUrl: provider.baseUrl,
36
46
  apiKeyFingerprint: getSecretFingerprint(provider.apiKey),
@@ -41,7 +51,7 @@ export async function runTool(tool, provider, model, options = {}) {
41
51
  });
42
52
  const session = await startSession({
43
53
  tool,
44
- provider,
54
+ provider: effectiveProvider,
45
55
  model,
46
56
  projectPath: process.cwd(),
47
57
  fallbackFrom: options.fallbackFrom ?? null
@@ -70,8 +80,27 @@ export function getSecretFingerprint(value) {
70
80
  return `len=${value.length}`;
71
81
  return `${value.slice(0, 8)}...${value.slice(-6)} len=${value.length}`;
72
82
  }
73
- export function buildRunnerArgs(tool, provider, model, claudeSettingsPath) {
74
- return getRunnerEngine(tool).buildArgs({ provider, model, settingsPath: claudeSettingsPath });
83
+ export function buildRunnerArgs(tool, provider, model, claudeSettingsPath, identity = {}) {
84
+ const launchPolicy = resolveRunnerLaunchPolicy(tool, provider, identity);
85
+ return getRunnerEngine(tool).buildArgs({
86
+ provider: launchPolicy.provider,
87
+ model,
88
+ settingsPath: claudeSettingsPath,
89
+ uid: identity.uid
90
+ });
91
+ }
92
+ export function resolveRunnerLaunchPolicy(tool, provider, identity = {}) {
93
+ if (tool !== "claude") {
94
+ return { provider, claudeSettingsPermissionMode: "provider", warning: null, reason: null };
95
+ }
96
+ const requestedMode = provider.mode === "auto" ? "bypassPermissions" : "default";
97
+ const resolution = resolveClaudePermissionMode(requestedMode, identity);
98
+ return {
99
+ provider: resolution.constrained ? { ...provider, mode: "ask" } : provider,
100
+ claudeSettingsPermissionMode: resolution.privileged ? "safeDefault" : "provider",
101
+ warning: resolution.constrained ? CLAUDE_ROOT_PERMISSION_WARNING : null,
102
+ reason: resolution.reason
103
+ };
75
104
  }
76
105
  const AUTO_PERMISSIONS = {
77
106
  defaultMode: "bypassPermissions",
@@ -94,6 +123,11 @@ const READ_ONLY_PERMISSIONS = {
94
123
  allow: ["Read", "Glob", "Grep", "WebFetch", "WebSearch"],
95
124
  deny: ["Bash", "Edit", "Write", "MultiEdit", "NotebookEdit"]
96
125
  };
126
+ const SAFE_DEFAULT_PERMISSIONS = {
127
+ defaultMode: "default",
128
+ allow: [],
129
+ deny: []
130
+ };
97
131
  export function writeTemporaryClaudeSettings(provider, options = {}) {
98
132
  const userSettingsPath = path.join(os.homedir(), ".claude", "settings.json");
99
133
  const settings = readClaudeSettings(userSettingsPath);
@@ -104,7 +138,10 @@ export function writeTemporaryClaudeSettings(provider, options = {}) {
104
138
  ANTHROPIC_API_KEY: provider.apiKey,
105
139
  ANTHROPIC_BASE_URL: baseUrl
106
140
  };
107
- if (options.permissionMode === "readOnly") {
141
+ if (options.permissionMode === "safeDefault") {
142
+ settings.permissions = { ...SAFE_DEFAULT_PERMISSIONS };
143
+ }
144
+ else if (options.permissionMode === "readOnly") {
108
145
  settings.permissions = { ...READ_ONLY_PERMISSIONS };
109
146
  }
110
147
  else if (provider.mode === "auto") {
@@ -8,6 +8,7 @@ import { readProviders } from "../config/providers-store.js";
8
8
  import { checkProvider, runDeepCheck } from "../models/model-discovery.js";
9
9
  import { writeLog } from "../logs/log-service.js";
10
10
  import { getKnownCandidates, resolveToolCommand } from "../runner/command-resolver.js";
11
+ import { isPrivilegedProcess } from "../runner/claude-permission-policy.js";
11
12
  import { resolveProviderCandidatesForTool } from "../runner/fallback.js";
12
13
  import { buildUsageEvent } from "../usage/usage-service.js";
13
14
  const MIN_NODE_VERSION = "22.19.0";
@@ -16,6 +17,7 @@ export async function runDoctor(options = {}) {
16
17
  const fix = options.fix ?? false;
17
18
  const checkProviders = options.checkProviders ?? true;
18
19
  checkNodeVersion(items);
20
+ checkExecutionUser(items);
19
21
  if (options.checkNativeDependencies ?? true) {
20
22
  await checkNativeDependencies(items);
21
23
  }
@@ -26,6 +28,21 @@ export async function runDoctor(options = {}) {
26
28
  checkCommand("Local CLIs", "codex", items);
27
29
  return items;
28
30
  }
31
+ function checkExecutionUser(items) {
32
+ const item = getExecutionUserDoctorItem();
33
+ if (item)
34
+ items.push(item);
35
+ }
36
+ export function getExecutionUserDoctorItem(identity = {}) {
37
+ if (!isPrivilegedProcess(identity))
38
+ return null;
39
+ return {
40
+ group: "Execution User",
41
+ status: "warn",
42
+ message: "Axiom Runtime is running as root; Claude auto/bypass permission mode is unavailable and will be constrained to default permissions.",
43
+ suggestion: "Install globally as root if required, but run `ai` and long-lived services with a dedicated non-root account."
44
+ };
45
+ }
29
46
  export async function runDeepDoctor(tool = "codex", options = {}) {
30
47
  const items = await runDoctor(options);
31
48
  try {
@@ -3,6 +3,7 @@ import fsPromises from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { getConfigRoot, getTelegramBotConfigPath, getTelegramBotDbPath, getTelegramBotDir, getTelegramBotLogPath, getTelegramBotPidPath, getTelegramConfigPath, getTelegramDbPath } from "../core/config/paths.js";
5
5
  const BOT_NAME_RE = /^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/;
6
+ const LEGACY_MIGRATION_MARKER = ".telegram-legacy-migrated";
6
7
  export function isValidBotName(name) {
7
8
  return BOT_NAME_RE.test(name) && name.length <= 32;
8
9
  }
@@ -42,13 +43,19 @@ export async function migrateLegacyConfig() {
42
43
  const legacyPath = getTelegramConfigPath();
43
44
  if (!fs.existsSync(legacyPath))
44
45
  return;
45
- if (botExists("default"))
46
+ if (fs.existsSync(getLegacyMigrationMarkerPath()))
46
47
  return;
47
48
  const dir = getTelegramBotDir();
48
49
  await fsPromises.mkdir(dir, { recursive: true });
49
- await fsPromises.copyFile(legacyPath, getTelegramBotConfigPath("default"));
50
+ if (!botExists("default")) {
51
+ await fsPromises.copyFile(legacyPath, getTelegramBotConfigPath("default"));
52
+ }
53
+ await markLegacyMigrationComplete();
50
54
  }
51
55
  export async function removeBotFiles(name, configuredDbPath) {
56
+ if (name === "default") {
57
+ await markLegacyMigrationComplete();
58
+ }
52
59
  const managedConfiguredDbPath = configuredDbPath && isManagedConfigPath(configuredDbPath)
53
60
  ? path.resolve(configuredDbPath)
54
61
  : undefined;
@@ -61,8 +68,7 @@ export async function removeBotFiles(name, configuredDbPath) {
61
68
  getTelegramBotConfigPath(name),
62
69
  getTelegramBotPidPath(name),
63
70
  getTelegramBotLogPath(name),
64
- ...[...dbPaths].flatMap((dbPath) => [dbPath, `${dbPath}-shm`, `${dbPath}-wal`]),
65
- ...(name === "default" ? [getTelegramConfigPath()] : [])
71
+ ...[...dbPaths].flatMap((dbPath) => [dbPath, `${dbPath}-shm`, `${dbPath}-wal`])
66
72
  ];
67
73
  for (const p of paths) {
68
74
  try {
@@ -78,3 +84,10 @@ function isManagedConfigPath(filePath) {
78
84
  const relative = path.relative(getConfigRoot(), path.resolve(filePath));
79
85
  return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
80
86
  }
87
+ function getLegacyMigrationMarkerPath() {
88
+ return path.join(getConfigRoot(), LEGACY_MIGRATION_MARKER);
89
+ }
90
+ async function markLegacyMigrationComplete() {
91
+ await fsPromises.mkdir(getConfigRoot(), { recursive: true });
92
+ await fsPromises.writeFile(getLegacyMigrationMarkerPath(), "completed\n", { mode: 0o600 });
93
+ }
@@ -3,13 +3,15 @@ import { createJsonLineAccumulator, extractJsonLines, spawnCapture, stripAnsi, t
3
3
  import { requiresDangerousConfirmation } from "../interaction/approval.js";
4
4
  import { writeTelegramLog } from "../log.js";
5
5
  import { isRecord } from "../../core/utils/is-record.js";
6
+ import { CLAUDE_ROOT_PERMISSION_WARNING, resolveClaudePermissionMode } from "../../core/runner/claude-permission-policy.js";
6
7
  export class ClaudeEngine {
7
8
  name = "claude";
8
9
  processes = new Map();
9
10
  controllers = new Map();
10
11
  async execute(params) {
11
- const permissionMode = params.permissionMode ?? "default";
12
- const runtime = await prepareEngineRuntime("claude", params.providerName, params.modelName, permissionMode);
12
+ const requestedPermissionMode = params.permissionMode ?? "default";
13
+ const runtime = await prepareEngineRuntime("claude", params.providerName, params.modelName, requestedPermissionMode);
14
+ const permissionMode = runtime.claudePermission?.effectiveMode ?? requestedPermissionMode;
13
15
  const controller = new AbortController();
14
16
  this.controllers.set(params.chatId, controller);
15
17
  const signal = params.signal ?? controller.signal;
@@ -29,11 +31,25 @@ export class ClaudeEngine {
29
31
  resume: params.resume,
30
32
  allowedTools: params.allowedTools
31
33
  });
32
- await params.onProgress?.({ phase: "thinking", message: "Claude started" });
34
+ const permissionWarning = runtime.claudePermission?.constrained ? CLAUDE_ROOT_PERMISSION_WARNING : null;
35
+ await params.onProgress?.({ phase: "thinking", message: permissionWarning ?? "Claude started" });
36
+ if (permissionWarning) {
37
+ await writeTelegramLog({
38
+ level: "warn",
39
+ action: "claude_permission_constrained",
40
+ message: permissionWarning,
41
+ metadata: { requestedPermissionMode, effectivePermissionMode: permissionMode, reason: runtime.claudePermission?.reason }
42
+ });
43
+ }
33
44
  await writeTelegramLog({
34
45
  action: "claude_start",
35
46
  message: `Starting Claude for Telegram chat ${params.chatId}`,
36
- metadata: { ...summarizeRuntime(runtime.provider, runtime.model, "claude"), cwd: params.cwd }
47
+ metadata: {
48
+ ...summarizeRuntime(runtime.provider, runtime.model, "claude"),
49
+ cwd: params.cwd,
50
+ requestedPermissionMode,
51
+ effectivePermissionMode: permissionMode
52
+ }
37
53
  });
38
54
  try {
39
55
  const progressParser = createJsonLineAccumulator((event) => handleClaudeProgressEvent(event, params.onProgress));
@@ -89,7 +105,8 @@ export class ClaudeEngine {
89
105
  }
90
106
  }
91
107
  export function buildClaudeArgs(options) {
92
- const permissionMode = options.permissionMode === "readOnly" ? "default" : options.permissionMode;
108
+ const resolution = resolveClaudePermissionMode(options.permissionMode, { uid: options.uid });
109
+ const permissionMode = resolution.effectiveMode === "readOnly" ? "default" : resolution.effectiveMode;
93
110
  const args = [
94
111
  "-p",
95
112
  options.prompt,
@@ -111,7 +128,7 @@ export function buildClaudeArgs(options) {
111
128
  if (options.allowedTools?.length) {
112
129
  args.push("--allowedTools", options.allowedTools.join(","));
113
130
  }
114
- if (options.permissionMode === "bypassPermissions") {
131
+ if (resolution.effectiveMode === "bypassPermissions") {
115
132
  args.push("--dangerously-skip-permissions");
116
133
  }
117
134
  if (options.permissionMode === "readOnly") {
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { resolveExactProviderForTool, resolveProviderForTool } from "../../core/runner/fallback.js";
3
3
  import { buildRunnerEnv, cleanupTemporaryFiles, getToolBaseUrl, writeTemporaryClaudeSettings } from "../../core/runner/tool-runner.js";
4
+ import { resolveClaudePermissionMode } from "../../core/runner/claude-permission-policy.js";
4
5
  import { resolveToolCommand } from "../../core/runner/command-resolver.js";
5
6
  export async function prepareEngineRuntime(tool, providerName, modelName, permissionMode) {
6
7
  // Telegram resolves and orders fallback candidates before invoking an
@@ -13,17 +14,27 @@ export async function prepareEngineRuntime(tool, providerName, modelName, permis
13
14
  if (!command) {
14
15
  throw new Error(`Failed to find ${tool} CLI. Run \`ai doctor\` to check local CLI installation.`);
15
16
  }
17
+ const claudePermission = tool === "claude"
18
+ ? resolveClaudePermissionMode(permissionMode ?? "default")
19
+ : null;
20
+ const effectiveProvider = claudePermission?.constrained ? { ...provider, mode: "ask" } : provider;
16
21
  const env = {
17
- ...buildRunnerEnv(tool, provider, model),
22
+ ...buildRunnerEnv(tool, effectiveProvider, model),
18
23
  AI_GATEWAY_SOURCE: "telegram"
19
24
  };
20
25
  const temporaryFiles = [];
21
26
  const claudeSettingsPath = tool === "claude"
22
- ? writeTemporaryClaudeSettings(provider, { permissionMode: permissionMode === "readOnly" ? "readOnly" : "provider" })
27
+ ? writeTemporaryClaudeSettings(provider, {
28
+ permissionMode: permissionMode === "readOnly"
29
+ ? "readOnly"
30
+ : claudePermission?.privileged
31
+ ? "safeDefault"
32
+ : "provider"
33
+ })
23
34
  : null;
24
35
  if (claudeSettingsPath)
25
36
  temporaryFiles.push(claudeSettingsPath);
26
- return { provider, model, env, command, temporaryFiles, claudeSettingsPath };
37
+ return { provider, model, env, command, temporaryFiles, claudeSettingsPath, claudePermission };
27
38
  }
28
39
  export function cleanupRuntime(runtime) {
29
40
  cleanupTemporaryFiles(runtime.temporaryFiles);
package/docs/USAGE.html CHANGED
@@ -458,13 +458,16 @@
458
458
  <section id="install" class="panel">
459
459
  <h2>通过 npm 安装</h2>
460
460
  <div class="detail-body">
461
- <pre>npm install --global @offerpilot/axiomruntime
461
+ <pre>node --version
462
+ npm install --global @offerpilot/axiomruntime
462
463
  ai --version
463
464
  ai setup</pre>
464
465
  <p>npm 包名是 <code>@offerpilot/axiomruntime</code>,安装后注册的可执行命令是 <code>ai</code>。需要 Node.js <code>22.19.0+</code>。</p>
466
+ <p>如果 npm 显示 <code>EBADENGINE</code>,请先升级 Node.js 再重新安装,不要继续使用警告状态下安装的 CLI。</p>
465
467
  <p><code>ai --version</code> 无副作用地验证安装;<code>ai setup</code> 初始化 Runtime,并可在确认后安装缺失的 Claude Code/Codex。</p>
466
- <pre>brew tap offerpilot/tap
467
- brew install axiomruntime
468
+ <p>全局安装可以由 root 完成,但日常执行和常驻服务应使用专用非 root 用户。root 下请求 Claude <code>auto</code>/<code>bypassPermissions</code> 时,Runtime 会安全约束为默认权限并告警,不会传递 <code>--dangerously-skip-permissions</code>。</p>
469
+ <pre>brew tap linlangli/axiom https://gitlab.com/linlangli/axiom.git
470
+ brew install linlangli/axiom/axiomruntime
468
471
  ai --version
469
472
  ai setup</pre>
470
473
  <p>Homebrew Formula 复用同版本 npm tarball并校验 SHA256,固定使用 Homebrew <code>node@22</code>。非 GitHub Tap 首次添加时需提供公开 Git URL。</p>
@@ -534,6 +537,12 @@ ai setup</pre>
534
537
  <td>指定 provider 和模型启动工具。</td>
535
538
  <td>provider 不可用时按 level 降级。</td>
536
539
  </tr>
540
+ <tr data-search="set codex chatgpt login oauth provider proxy config auth restart app">
541
+ <td><span class="group-label">使用</span></td>
542
+ <td><code class="cmd">ai set codex chatgpt</code><br /><code class="cmd">ai set codex provider 51talk-cq gpt-5.6-sol</code><br /><code class="cmd">ai set codex provider 51talk-cq</code><br /><code class="cmd">ai set codex provider</code><br /><code class="cmd">ai set codex proxy 127.0.0.1:7890</code></td>
543
+ <td>持久切换 Codex App 的 ChatGPT 账号、已添加 provider 或 HTTP proxy。</td>
544
+ <td>一致更新配置与必要环境,成功后重启 app。</td>
545
+ </tr>
537
546
  <tr data-search="last resume previous selection">
538
547
  <td><span class="group-label">使用</span></td>
539
548
  <td><code class="cmd">ai last</code></td>
@@ -556,7 +565,7 @@ ai setup</pre>
556
565
  <td><span class="group-label">Provider</span></td>
557
566
  <td><code class="cmd">ai provider edit</code><br /><code class="cmd">ai provider edit 51talk</code></td>
558
567
  <td>修改 name、baseUrl、凭证、mode、level、model。</td>
559
- <td>凭证写入 SecretStore,配置只保存 apiKeyRef;编辑时不回显原值。</td>
568
+ <td>当前 apiKey 以明文保存在 providers.json,编辑时会回显当前值。SecretStore + apiKeyRef 是目标态,尚未实现。</td>
560
569
  </tr>
561
570
  <tr data-search="provider delete remove fallback">
562
571
  <td><span class="group-label">Provider</span></td>
@@ -628,13 +637,35 @@ ai use codex 51talk gpt-5.5</pre>
628
637
  <ul>
629
638
  <li>provider 不可用时按 <code>level</code> 自动降级。</li>
630
639
  <li>Claude Code 使用临时 <code>--settings</code> 注入环境变量,不修改用户 settings。</li>
631
- <li>Codex 使用本次子进程的 <code>-c model_provider=...</code> 固定所选 provider,并通过 <code>env_key = "OPENAI_API_KEY"</code> 使用 AI Gateway 注入的 Key;不会修改 Codex 全局认证或配置。</li>
632
- <li>provider 的 <code>mode</code> 为 <code>auto</code> 时,Claude Code 会追加 <code>--dangerously-skip-permissions</code>;<code>ask</code> 模式不会追加,并会在启动前确认。</li>
640
+ <li><code>ai use codex</code> 使用本次子进程的 <code>-c model_provider=...</code> 固定所选 provider,并通过 <code>env_key = "OPENAI_API_KEY"</code> 使用 AI Gateway 注入的 Key;不会修改 Codex 全局认证或配置。</li>
641
+ <li>provider 的 <code>mode</code> 为 <code>auto</code> 时,非 root 执行 Claude Code 会追加 <code>--dangerously-skip-permissions</code>;<code>ask</code> 模式不会追加,并会在启动前确认。root 下请求 <code>auto</code> 会被安全约束为默认权限,临时 settings 中继承的 bypass 配置也会被清除。</li>
633
642
  <li>OpenAI 兼容地址用于 Codex 时会使用 <code>/v1</code>,用于 Claude 时会去掉 <code>/v1</code>。</li>
634
643
  <li>日志只记录 Key 指纹,不记录完整 API Key。</li>
635
644
  </ul>
636
645
  </div>
637
646
  </div>
647
+ <details open>
648
+ <summary>持久设置 Codex App provider</summary>
649
+ <div class="detail-body">
650
+ <pre>ai set codex chatgpt
651
+ ai set codex provider 51talk-cq gpt-5.6-sol
652
+ ai set codex provider 51talk-cq
653
+ ai set codex provider
654
+ ai set codex proxy 127.0.0.1:7890
655
+ ai set codex proxy off</pre>
656
+ <ul>
657
+ <li><code>chatgpt</code> 调用 <code>codex login</code> 打开浏览器,并在成功后选择内建 <code>model_provider = "openai"</code>。</li>
658
+ <li><code>provider name model-id</code> 显式选择该 provider 已探测的 <code>gpt-*</code> 模型;只给 provider 时自动选最新 GPT。</li>
659
+ <li><code>provider</code> 不带名称时依次交互选择已添加的 provider 和 GPT 模型;没有可用 GPT 时提示运行 <code>ai status</code>,不修改配置。</li>
660
+ <li>自动选择先比较 GPT 数字版本,同版本优先 <code>sol</code>、<code>pro</code>、alias、<code>codex</code>、<code>terra</code>、<code>mini</code>、<code>luna</code>、<code>nano</code>,并优先滚动 alias。API Key 只写入 mode <code>0600</code> 的 <code>auth.json</code>。</li>
661
+ <li>原有 project、MCP、plugin、feature 设置会保留;任一步失败会恢复原 <code>config.toml</code> 和 <code>auth.json</code>。</li>
662
+ <li>切换自定义 provider 会替换当前 ChatGPT token;切回 ChatGPT 时需重新登录。</li>
663
+ <li>成功后 macOS 会重启 <code>Codex.app</code> 或当前名称的 <code>ChatGPT.app</code>;无法自动重启时按提示手动操作。</li>
664
+ <li><code>proxy</code> 把裸 <code>host:port</code> 规范化为 HTTP origin,只更新 <code>shell_environment_policy.set</code> 的大小写 HTTP(S)/ALL proxy keys;<code>off</code> 清除这些 managed keys,不修改 <code>auth.json</code>。</li>
665
+ <li>Proxy URL 禁止包含账号密码、path、query 或 fragment,command usage log 会整体脱敏。macOS 会同步当前登录会话的 user launchd environment;失败时恢复原 config 且不重启,注销或重启 macOS 后需重新运行本命令。</li>
666
+ </ul>
667
+ </div>
668
+ </details>
638
669
  </section>
639
670
 
640
671
  <section id="memory" class="panel">
@@ -712,7 +743,7 @@ ai telegram status --all</pre>
712
743
  <li><code>--bot=&lt;name&gt;</code> 与 <code>--bot &lt;name&gt;</code> 等价。旧位置参数写法继续兼容;新脚本推荐显式指定 <code>--bot</code> 或 <code>--all</code>。</li>
713
744
  <li>裸 <code>ai telegram</code> 会先选操作再列出 Bot;生命周期菜单可选 <code>all bots</code>,删除菜单会二次确认。带 <code>--bot</code> 进入交互菜单时会跳过 Bot 选择。</li>
714
745
  <li><code>edit</code> 交互式编辑指定 Bot;<code>delete</code> 先停止目标 Bot,再删除该 Bot 的 config、PID、log 和 SQLite 文件。旧 <code>remove</code> 是兼容别名。</li>
715
- <li>删除迁移得到的 <code>default</code> Bot 时,同时清理 legacy <code>telegram.json</code> 与数据库,避免后续迁移重新创建绑定。</li>
746
+ <li>legacy <code>telegram.json</code> 首次会自动迁移为命名 Bot <code>default</code>;删除 <code>default</code> 后,列表、生命周期命令和 Bot 服务不会再从 legacy 配置重建它。无目标 <code>ai telegram config</code> 仍可管理 legacy global config。</li>
716
747
  <li><code>config set ... --bot &lt;name&gt;</code> 只修改指定 Bot,不影响全局或其他 Bot;token 不会出现在命令日志或终端输出中。</li>
717
748
  <li>命名 Bot 的配置、PID、日志和会话保存在 <code>~/.ai-gateway/telegram/bots/&lt;name&gt;.json|.pid|.log|.sqlite</code>。</li>
718
749
  <li>为保持兼容,存在命名 Bot 时,无目标 <code>start</code>、<code>stop</code>、<code>status</code> 仍作用于全部命名 Bot。</li>
@@ -809,7 +840,8 @@ ai session clear</pre>
809
840
  <pre>~/.ai-gateway/providers.json
810
841
  ~/.ai-gateway/memory.json
811
842
  ~/.ai-gateway/model-pricing.json</pre>
812
- <p><code>providers.json</code> 保存聊天 provider 与 <code>apiKeyRef</code>;<code>memory.json</code> 保存记忆检索 embedding 模型与 secret ref;实际凭证进入 SecretStore。<code>model-pricing.json</code> 保存美元 / 百万 token 的模型价格。</p>
843
+ <p><code>providers.json</code> 保存聊天 provider;<code>memory.json</code> 保存记忆检索 embedding 模型。<code>model-pricing.json</code> 保存美元 / 百万 token 的模型价格。</p>
844
+ <p><strong>注意:</strong>这两个文件当前都以明文保存 <code>apiKey</code>。请限制文件权限并且不要提交到仓库。凭证改由 SecretStore 管理、配置只留 <code>apiKeyRef</code> 是目标架构,尚未实现。</p>
813
845
  </div>
814
846
  </details>
815
847
  <details>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@offerpilot/axiomruntime",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "CLI-first foundation for an enterprise AI Runtime with provider routing and governed execution.",
5
5
  "homepage": "https://gitlab.com/linlangli/agent-router",
6
6
  "repository": {
@@ -22,10 +22,11 @@
22
22
  "telegram:service": "node --enable-source-maps dist/telegram/supervisor.js",
23
23
  "test": "npm run build && node --test test/*.test.js",
24
24
  "release": "node scripts/release.js",
25
- "release:current": "node scripts/release.js --current",
26
- "release:check": "npm test && npm run homebrew:check",
25
+ "release:current": "node scripts/release.js --npm",
26
+ "release:check": "npm test && npm pack --dry-run",
27
27
  "homebrew:update": "node scripts/update-homebrew-formula.js --write",
28
28
  "homebrew:check": "node scripts/update-homebrew-formula.js",
29
+ "homebrew:publish": "node scripts/release.js --homebrew",
29
30
  "prepublishOnly": "npm run release:check"
30
31
  },
31
32
  "files": [
@@ -47,11 +48,11 @@
47
48
  "typescript": "^5.8.0"
48
49
  },
49
50
  "dependencies": {
50
- "better-sqlite3": "^12.11.1",
51
+ "better-sqlite3": "^13.0.2",
51
52
  "gpt-tokenizer": "^3.4.0",
52
53
  "grammy": "^1.44.0",
53
54
  "https-proxy-agent": "^9.1.0",
54
55
  "proper-lockfile": "^4.1.2",
55
- "undici": "^8.7.0"
56
+ "undici": "^8.10.0"
56
57
  }
57
58
  }