@foxden-app/foxclaw 0.3.3 → 0.3.5

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/.env.example CHANGED
@@ -29,6 +29,13 @@ TELEGRAM_PREVIEW_THROTTLE_MS=800
29
29
  THREAD_LIST_LIMIT=10
30
30
  CODEX_CLI_BIN=/absolute/path/to/codex
31
31
 
32
+ # Optional: proxy for ChatGPT/Codex backend requests when FoxClaw runs as a service.
33
+ # Put these in the same env file that `foxclaw start` installs into systemd/launchd.
34
+ # HTTP_PROXY=http://127.0.0.1:7890
35
+ # HTTPS_PROXY=http://127.0.0.1:7890
36
+ # ALL_PROXY=socks5://127.0.0.1:7891
37
+ # NO_PROXY=127.0.0.1,localhost
38
+
32
39
  # Weixin (iLink): run `foxclaw weixin-login` once, then enable:
33
40
  # WX_ENABLED=true
34
41
  # Comma-separated iLink user ids allowed to chat (see account JSON linkedIlinkUserId)
package/dist/config.d.ts CHANGED
@@ -8,6 +8,8 @@ export declare const DEFAULT_LOCK_PATH: string;
8
8
  export declare const DEFAULT_CODEX_APP_SERVER_STATE_PATH: string;
9
9
  export declare const DEFAULT_CODEX_APP_SERVER_LOG_PATH: string;
10
10
  export declare const DEFAULT_ENV_PATH: string;
11
+ export declare function resolveEnvPath(): string;
12
+ export declare function getLoadedEnvPath(): string | null;
11
13
  export declare function loadEnv(): void;
12
14
  export interface AppConfig {
13
15
  tgBotToken: string;
package/dist/config.js CHANGED
@@ -12,21 +12,28 @@ export const DEFAULT_CODEX_APP_SERVER_STATE_PATH = path.join(APP_HOME, 'runtime'
12
12
  export const DEFAULT_CODEX_APP_SERVER_LOG_PATH = path.join(APP_HOME, 'logs', 'codex-app-server.log');
13
13
  export const DEFAULT_ENV_PATH = path.join(APP_HOME, '.env');
14
14
  let envLoaded = false;
15
- export function loadEnv() {
16
- if (envLoaded)
17
- return;
18
- envLoaded = true;
15
+ let loadedEnvPath = null;
16
+ export function resolveEnvPath() {
19
17
  const explicitPath = process.env.FOXCLAW_ENV?.trim();
20
18
  if (explicitPath) {
21
- dotenv.config({ path: explicitPath, override: true });
22
- return;
19
+ return path.resolve(explicitPath);
23
20
  }
24
21
  const cwdEnvPath = path.join(process.cwd(), '.env');
25
22
  if (fs.existsSync(cwdEnvPath)) {
26
- dotenv.config({ path: cwdEnvPath });
27
- return;
23
+ return cwdEnvPath;
28
24
  }
29
- dotenv.config({ path: DEFAULT_ENV_PATH });
25
+ return DEFAULT_ENV_PATH;
26
+ }
27
+ export function getLoadedEnvPath() {
28
+ return loadedEnvPath;
29
+ }
30
+ export function loadEnv() {
31
+ if (envLoaded)
32
+ return;
33
+ envLoaded = true;
34
+ const envPath = resolveEnvPath();
35
+ loadedEnvPath = envPath;
36
+ dotenv.config({ path: envPath, override: Boolean(process.env.FOXCLAW_ENV?.trim()) });
30
37
  }
31
38
  export function loadConfig() {
32
39
  loadEnv();
@@ -7065,6 +7065,9 @@ function cloneAuthRetryContext(context) {
7065
7065
  };
7066
7066
  }
7067
7067
  function isCodexAuthRotationError(params) {
7068
+ if (isChatGptBackendAccessBlocked(collectCodexErrorText(params))) {
7069
+ return false;
7070
+ }
7068
7071
  const code = stringOrNull(params?.error?.codexErrorInfo) ?? stringOrNull(params?.error?.code);
7069
7072
  if (code && /usageLimitExceeded|auth|unauthorized|forbidden|login/i.test(code)) {
7070
7073
  return true;
@@ -7073,15 +7076,77 @@ function isCodexAuthRotationError(params) {
7073
7076
  return /(usage limit|rate limit|not authenticated|unauthorized|forbidden|sign in|log in|login|auth)/i.test(message);
7074
7077
  }
7075
7078
  function formatCodexNotificationError(params) {
7079
+ const collected = collectCodexErrorText(params);
7080
+ const known = formatKnownCodexAccessError(collected);
7081
+ if (known) {
7082
+ return known;
7083
+ }
7076
7084
  const message = stringOrNull(params?.error?.message);
7077
7085
  if (message) {
7078
- return message;
7086
+ return clipUserFacingError(cleanUserFacingError(message));
7079
7087
  }
7080
7088
  const code = stringOrNull(params?.error?.codexErrorInfo) ?? stringOrNull(params?.error?.code);
7081
7089
  if (code) {
7082
- return code;
7090
+ return clipUserFacingError(cleanUserFacingError(code));
7091
+ }
7092
+ return clipUserFacingError(cleanUserFacingError(JSON.stringify(params?.error ?? params ?? {})));
7093
+ }
7094
+ function collectCodexErrorText(params) {
7095
+ const parts = [
7096
+ stringOrNull(params?.error?.message),
7097
+ stringOrNull(params?.error?.additionalDetails),
7098
+ stringOrNull(params?.additionalDetails),
7099
+ stringOrNull(params?.error?.codexErrorInfo),
7100
+ stringOrNull(params?.error?.code),
7101
+ ].filter((part) => part !== null);
7102
+ if (parts.length > 0) {
7103
+ return parts.join(' ');
7104
+ }
7105
+ try {
7106
+ return JSON.stringify(params?.error ?? params ?? {});
7107
+ }
7108
+ catch {
7109
+ return String(params?.error ?? params ?? '');
7083
7110
  }
7084
- return JSON.stringify(params?.error ?? params ?? {});
7111
+ }
7112
+ function formatKnownCodexAccessError(raw) {
7113
+ if (!isChatGptBackendAccessBlocked(raw)) {
7114
+ return null;
7115
+ }
7116
+ const ray = raw.match(/\bcf-ray:\s*([A-Za-z0-9-]+)/i)?.[1]
7117
+ ?? raw.match(/\bRay ID:\s*([A-Za-z0-9-]+)/i)?.[1]
7118
+ ?? null;
7119
+ return `ChatGPT backend 403 Forbidden: service network/proxy/IP is blocked, not necessarily auth.json invalid. Check HTTP_PROXY/HTTPS_PROXY in the FoxClaw env file and restart FoxClaw${ray ? ` (cf-ray: ${ray})` : ''}.`;
7120
+ }
7121
+ function isChatGptBackendAccessBlocked(raw) {
7122
+ const value = raw.toLowerCase();
7123
+ const targetsChatGptBackend = value.includes('chatgpt.com/backend-api')
7124
+ || value.includes('wss://chatgpt.com/backend-api');
7125
+ const isForbidden = value.includes('403 forbidden')
7126
+ || value.includes('status 403')
7127
+ || value.includes('httpstatuscode":403')
7128
+ || value.includes('httpstatuscode:403');
7129
+ const looksLikeHtmlBlock = value.includes('<html')
7130
+ || value.includes('text/html')
7131
+ || value.includes('cf-ray')
7132
+ || value.includes('unable to load site')
7133
+ || value.includes('if you are using a vpn');
7134
+ return targetsChatGptBackend && isForbidden && looksLikeHtmlBlock;
7135
+ }
7136
+ function cleanUserFacingError(raw) {
7137
+ return raw
7138
+ .replace(/<script\b[\s\S]*?<\/script>/gi, ' ')
7139
+ .replace(/<style\b[\s\S]*?<\/style>/gi, ' ')
7140
+ .replace(/<[^>]+>/g, ' ')
7141
+ .replace(/\s+/g, ' ')
7142
+ .trim();
7143
+ }
7144
+ function clipUserFacingError(raw, limit = 900) {
7145
+ const cleaned = raw.trim();
7146
+ if (cleaned.length <= limit) {
7147
+ return cleaned;
7148
+ }
7149
+ return `${cleaned.slice(0, limit - 3)}...`;
7085
7150
  }
7086
7151
  function parseUserInputQuestions(params) {
7087
7152
  const rawQuestions = Array.isArray(params?.questions)
@@ -7484,7 +7549,12 @@ function formatLocalTimestamp(seconds) {
7484
7549
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
7485
7550
  }
7486
7551
  function formatShortStatusError(error) {
7487
- const message = formatUserError(error).replace(/\s+/g, ' ').trim();
7552
+ const raw = formatUserError(error);
7553
+ const known = formatKnownCodexAccessError(raw);
7554
+ if (known) {
7555
+ return known.length > 120 ? `${known.slice(0, 117)}...` : known;
7556
+ }
7557
+ const message = cleanUserFacingError(raw);
7488
7558
  return message.length > 120 ? `${message.slice(0, 117)}...` : message;
7489
7559
  }
7490
7560
  function isThreadNotFoundError(error) {
package/dist/main.js CHANGED
@@ -5,7 +5,7 @@ import process from 'node:process';
5
5
  import { createInterface } from 'node:readline/promises';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { APP_HOME, DEFAULT_ENV_PATH, DEFAULT_LOG_PATH, DEFAULT_STATUS_PATH, loadConfig, loadEnv, } from './config.js';
8
+ import { APP_HOME, DEFAULT_ENV_PATH, DEFAULT_LOG_PATH, DEFAULT_STATUS_PATH, getLoadedEnvPath, loadConfig, loadEnv, } from './config.js';
9
9
  import { acquireProcessLock, LockHeldError } from './lock.js';
10
10
  import { readRuntimeStatus, writeRuntimeStatus } from './runtime.js';
11
11
  const command = process.argv[2] || 'serve';
@@ -130,6 +130,9 @@ async function runServeCli() {
130
130
  updatedAt: new Date().toISOString(),
131
131
  channels: { telegram: false, weixin: false },
132
132
  });
133
+ await app.stop({ terminateServer: true }).catch((error) => {
134
+ logger.warn('codex.app-server.stop_failed', { error: serializeError(error) });
135
+ });
133
136
  store?.close();
134
137
  processLock.release();
135
138
  process.exit(0);
@@ -383,16 +386,14 @@ function installSystemd() {
383
386
  const unitName = 'foxclaw.service';
384
387
  const userSystemdDir = path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || '', '.config'), 'systemd', 'user');
385
388
  const unitPath = path.join(userSystemdDir, unitName);
386
- const configDir = path.dirname(process.env.FOXCLAW_ENV?.trim() || DEFAULT_ENV_PATH);
389
+ const envPath = serviceEnvPath();
390
+ const configDir = path.dirname(envPath);
387
391
  const nodeBin = process.execPath;
388
392
  const nodeDir = path.dirname(nodeBin);
389
393
  const pathValue = buildServicePath(nodeDir);
390
394
  fs.mkdirSync(userSystemdDir, { recursive: true });
391
395
  fs.mkdirSync(configDir, { recursive: true });
392
396
  fs.mkdirSync(path.join(APP_HOME, 'logs'), { recursive: true });
393
- const foxclawEnvLine = process.env.FOXCLAW_ENV?.trim()
394
- ? `Environment=FOXCLAW_ENV=${systemdEscape(process.env.FOXCLAW_ENV.trim())}\n`
395
- : '';
396
397
  fs.writeFileSync(unitPath, `[Unit]
397
398
  Description=FoxClaw local Codex execution bridge
398
399
  Documentation=https://github.com/foxden-app/foxclaw
@@ -408,7 +409,8 @@ Environment=HOME=${systemdEscape(process.env.HOME || '')}
408
409
  Environment=USER=${systemdEscape(process.env.USER || '')}
409
410
  Environment=LOGNAME=${systemdEscape(process.env.LOGNAME || process.env.USER || '')}
410
411
  Environment=PATH=${systemdEscape(pathValue)}
411
- ${foxclawEnvLine}ExecStart=${systemdEscape(nodeBin)} ${systemdEscape(entryPoint)} serve
412
+ Environment=FOXCLAW_ENV=${systemdEscape(envPath)}
413
+ ExecStart=${systemdEscape(nodeBin)} ${systemdEscape(entryPoint)} serve
412
414
  Restart=always
413
415
  RestartSec=10
414
416
  TimeoutStopSec=45
@@ -456,15 +458,11 @@ function installLaunchd() {
456
458
  }
457
459
  const home = process.env.HOME || '';
458
460
  const plist = path.join(home, 'Library', 'LaunchAgents', 'app.foxden.foxclaw.plist');
459
- const configDir = path.dirname(process.env.FOXCLAW_ENV?.trim() || DEFAULT_ENV_PATH);
461
+ const envPath = serviceEnvPath();
462
+ const configDir = path.dirname(envPath);
460
463
  fs.mkdirSync(path.dirname(plist), { recursive: true });
461
464
  fs.mkdirSync(configDir, { recursive: true });
462
465
  fs.mkdirSync(path.join(APP_HOME, 'logs'), { recursive: true });
463
- const foxclawEnvXml = process.env.FOXCLAW_ENV?.trim()
464
- ? ` <key>FOXCLAW_ENV</key>
465
- <string>${xmlEscape(process.env.FOXCLAW_ENV.trim())}</string>
466
- `
467
- : '';
468
466
  fs.writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
469
467
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
470
468
  <plist version="1.0">
@@ -489,7 +487,8 @@ function installLaunchd() {
489
487
  <string>${xmlEscape(process.env.USER || '')}</string>
490
488
  <key>LOGNAME</key>
491
489
  <string>${xmlEscape(process.env.LOGNAME || process.env.USER || '')}</string>
492
- ${foxclawEnvXml}
490
+ <key>FOXCLAW_ENV</key>
491
+ <string>${xmlEscape(envPath)}</string>
493
492
  </dict>
494
493
  <key>RunAtLoad</key>
495
494
  <true/>
@@ -532,6 +531,9 @@ function buildServicePath(nodeDir) {
532
531
  ];
533
532
  return parts.filter((part, index) => part && parts.indexOf(part) === index).join(':');
534
533
  }
534
+ function serviceEnvPath() {
535
+ return path.resolve(process.env.FOXCLAW_ENV?.trim() || getLoadedEnvPath() || DEFAULT_ENV_PATH);
536
+ }
535
537
  function spawnChecked(commandName, args) {
536
538
  const result = spawnSync(commandName, args, { stdio: 'inherit' });
537
539
  if (result.status !== 0) {
@@ -182,6 +182,31 @@ Bridge logs are stored here:
182
182
  tail -f ~/.foxclaw/logs/service.log
183
183
  ```
184
184
 
185
+ ## ChatGPT Backend 403 Or Unable To Load Site
186
+
187
+ If Telegram shows `ChatGPT backend 403 Forbidden`, or the app-server log contains `Unable to load site`, `cf-ray`, or `chatgpt.com/backend-api`, the auth file is not necessarily broken. The service process is usually reaching ChatGPT with the wrong network/proxy/IP.
188
+
189
+ A common cause is that your shell or project `.env` has proxy variables, while the systemd/launchd service reads a different env file. Check the env file installed into the service:
190
+
191
+ ```bash
192
+ systemctl --user cat foxclaw.service
193
+ ```
194
+
195
+ Make sure the file referenced by `Environment=FOXCLAW_ENV=...` contains your proxy variables, for example:
196
+
197
+ ```dotenv
198
+ HTTP_PROXY=http://127.0.0.1:20171
199
+ HTTPS_PROXY=http://127.0.0.1:20171
200
+ ALL_PROXY=socks5://127.0.0.1:20170
201
+ NO_PROXY=127.0.0.1,localhost
202
+ ```
203
+
204
+ Restart FoxClaw after editing. The restart also restarts the managed Codex app-server so the new proxy environment takes effect:
205
+
206
+ ```bash
207
+ foxclaw restart
208
+ ```
209
+
185
210
  ## Service Starts With The Wrong Node Version
186
211
 
187
212
  The systemd installer captures the `node` binary from your current PATH. If you installed the service from a shell using Node 22 or older, reinstall it from a Node 24 shell:
@@ -183,6 +183,31 @@ Bridge 日志默认在:
183
183
  tail -f ~/.foxclaw/logs/service.log
184
184
  ```
185
185
 
186
+ ## ChatGPT 后端 403 或 Unable to load site
187
+
188
+ 如果 Telegram 里看到 `ChatGPT backend 403 Forbidden`,或者 app-server 日志里出现 `Unable to load site`、`cf-ray`、`chatgpt.com/backend-api`,通常不是 `auth.json` 文件坏了,而是服务进程访问 ChatGPT 后端时没有走正确网络。
189
+
190
+ 常见原因是:你在 shell 里配置了代理,或者项目 `.env` 里有代理,但 systemd/launchd 服务实际读的是另一个 env 文件。先看服务用的是哪个 env:
191
+
192
+ ```bash
193
+ systemctl --user cat foxclaw.service
194
+ ```
195
+
196
+ 确认 `Environment=FOXCLAW_ENV=...` 指向的文件里有你的代理配置,例如:
197
+
198
+ ```dotenv
199
+ HTTP_PROXY=http://127.0.0.1:20171
200
+ HTTPS_PROXY=http://127.0.0.1:20171
201
+ ALL_PROXY=socks5://127.0.0.1:20170
202
+ NO_PROXY=127.0.0.1,localhost
203
+ ```
204
+
205
+ 改完后重启 FoxClaw。重启会同时重启托管的 Codex app-server,让新代理生效:
206
+
207
+ ```bash
208
+ foxclaw restart
209
+ ```
210
+
186
211
  ## 服务用了错误的 Node 版本
187
212
 
188
213
  systemd 安装脚本会记录当时 PATH 里的 `node`。如果你从 Node 22 或更旧版本的 shell 里安装过服务,请从 Node 24 的 shell 重新安装:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -5,7 +5,7 @@ description: Publish npm packages safely, especially packages that require npm 2
5
5
 
6
6
  # NPM Publish
7
7
 
8
- Use this skill to publish an npm package from a repo while preserving the exact web-auth flow that works in this environment.
8
+ Use this skill to publish an npm package from a repo. Prefer the normal no-2FA publish path first. Fall back to npm web-auth only when npm explicitly prompts for it.
9
9
 
10
10
  ## Release Checklist
11
11
 
@@ -41,32 +41,44 @@ Use this skill to publish an npm package from a repo while preserving the exact
41
41
  git push
42
42
  ```
43
43
 
44
- ## 2FA Web Auth Publish Flow
44
+ ## Publish Flow
45
45
 
46
- Use this exact flow when npm returns `EOTP` or when the account uses web auth for publish:
47
-
48
- 1. Start publish in a TTY and disable local browser opening:
46
+ 1. Start publish in a TTY and disable local browser opening. This works both when npm publishes directly and when it asks for web auth:
49
47
  ```bash
50
48
  BROWSER=true npm publish
51
49
  ```
52
50
 
53
- 2. Wait for npm to print:
51
+ 2. If npm publishes directly, it should finish with:
54
52
  ```text
55
- Authenticate your account at:
56
- https://www.npmjs.com/auth/cli/<auth-id>
57
- Press ENTER to open in the browser...
53
+ + <package-name>@<version>
58
54
  ```
55
+ Then verify:
56
+ ```bash
57
+ npm view <package-name> version
58
+ git status --short --branch
59
+ ```
60
+
61
+ 3. If npm instead prints a web-auth prompt, use the fallback flow below.
62
+
63
+ ## Web Auth Fallback
64
+
65
+ Use this only when npm prints:
66
+ ```text
67
+ Authenticate your account at:
68
+ https://www.npmjs.com/auth/cli/<auth-id>
69
+ Press ENTER to open in the browser...
70
+ ```
59
71
 
60
- 3. Send the URL to the user as a bare URL, not inside backticks or a code block. Bare URLs are easier to tap.
72
+ 1. Send the URL to the user as a bare URL, not inside backticks or a code block. Bare URLs are easier to tap.
61
73
 
62
- 4. Do not press Enter immediately. Wait until the user says they clicked/confirmed the npm page.
74
+ 2. Do not press Enter immediately. Wait until the user says they clicked/confirmed the npm page.
63
75
 
64
- 5. After the user confirms, send Enter to the still-running TTY process. npm should retrieve the temporary token and finish:
76
+ 3. After the user confirms, send Enter to the still-running TTY process. npm should retrieve the temporary token and finish:
65
77
  ```text
66
78
  + <package-name>@<version>
67
79
  ```
68
80
 
69
- 6. Verify the published version:
81
+ 4. Verify the published version:
70
82
  ```bash
71
83
  npm view <package-name> version
72
84
  git status --short --branch
@@ -74,7 +86,7 @@ Use this exact flow when npm returns `EOTP` or when the account uses web auth fo
74
86
 
75
87
  ## Failure Modes
76
88
 
77
- - If `npm publish` is run without a TTY, npm may print an `EOTP` error with the auth URL redacted as `***`. Stop that attempt and rerun with a TTY.
89
+ - If `npm publish` is run without a TTY, npm may print an `EOTP` error with the auth URL redacted as `***`. Stop that attempt and rerun with `BROWSER=true npm publish` in a TTY.
78
90
  - If `xdg-open` fails because the environment has no browser, rerun with `BROWSER=true npm publish`.
79
91
  - If the auth link expires or the publish process exits, rerun `BROWSER=true npm publish` to generate a new link.
80
92
  - If the user provides a classic authenticator OTP instead of using the web link, publish can be retried with `npm publish --otp <code>`, but prefer web auth when the user asks for a clickable confirmation link.