Do not treat an inconclusive local bridge probe as a dead process.

If the process is still there, doctor will not start a second bridge or ask ChatGPT to delete the connector. Idea from AtlaxTech in #43.

Co-authored-by: AtlaxTech <105478113+AtlaxTech@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XiaoDuoYa
2026-08-31 21:17:35 +08:00
co-authored by AtlaxTech Cursor
parent 5131cea191
commit d6d0dd4e86
8 changed files with 192 additions and 15 deletions
+1 -1
View File
@@ -201,7 +201,7 @@ Full threat model: [docs/security.md](docs/security.md)
```bash
pnpm install
pnpm build # -> dist/, exposes the `c2c` bin
pnpm test # vitest: 142 tests (path security, OAuth, pairing, MCP e2e)
pnpm test # vitest: 146 tests (path security, OAuth, pairing, MCP e2e)
c2c setup # bridge + tunnel + pairing code, all in one
c2c sandbox-allow # whitelist the settings dir in Codex (macOS + Windows)
+1 -1
View File
@@ -131,7 +131,7 @@ Ready.
```bash
pnpm install
pnpm build # 产出 dist/,暴露 c2c 命令
pnpm test # vitest142 个测试(路径安全、OAuth、配对、MCP 端到端)
pnpm test # vitest146 个测试(路径安全、OAuth、配对、MCP 端到端)
c2c setup # 一条命令:Bridge + 隧道 + 配对码
c2c sandbox-allow # 把本地设置目录加入 Codex 沙箱白名单(macOS / Windows
+4
View File
@@ -15,6 +15,10 @@ can (restarts the bridge, restarts the tunnel) without asking.
`c2c start` (or let doctor do it). Bridge logs:
`c2c logs`, or verbose: `c2c logs --verbose`.
If doctor says the bridge state is **uncertain** (无法确认), do not start a
second bridge and do not Delete the ChatGPT connector. Wait and run doctor
again. The local process may still be running.
### Everything was quit and ChatGPT can no longer connect
Quitting Codex / the terminal stops the public address. The next `c2c doctor`
starts a new address and sets `chatgptRepair.needed`. The Skill should tell the
+3
View File
@@ -88,6 +88,9 @@ whatever data it needs by itself.
- `chatgptRepair.needed` is true (fix the connector first, then doctor again)
- `namedRepair.needed` is true (user must login to Cloudflare, then doctor again.
Do not Delete the ChatGPT connector — the address did not change)
- `report.bridge` says 状态无法确认: the local bridge may still be running.
Do not `c2c start`, do not Delete the connector, do not treat it as
`chatgptRepair`. Wait and run doctor again.
A ChatGPT-side 401 after a sent message is different: repair then, do not
treat it as permission to skip this gate next time.
+38 -5
View File
@@ -66,12 +66,45 @@ export async function probeBridge(
}
}
export type BridgeObservation =
| { state: "healthy"; runtime: RuntimeState }
| { state: "stopped"; runtime: RuntimeState | null; reason: "runtime_missing" | "pid_missing" }
| { state: "unknown"; runtime: RuntimeState | null; reason: "probe_failed" | "pid_unknown" | "workspace_mismatch" };
function observePid(pid: number): "present" | "missing" | "unknown" {
if (!Number.isInteger(pid) || pid <= 0) return "unknown";
try {
process.kill(pid, 0);
return "present";
} catch (error) {
return (error as NodeJS.ErrnoException).code === "ESRCH" ? "missing" : "unknown";
}
}
/**
* Distinguish a dead bridge from a probe that simply failed.
* Read-only: never starts, stops, or clears runtime.
*/
export async function findBridgeObservation(workspaceId: string): Promise<BridgeObservation> {
const runtime = readRuntimeState(workspaceId);
if (!runtime) return { state: "stopped", runtime: null, reason: "runtime_missing" };
const health = await probeBridge(runtime.port);
if (health && health.workspaceId === workspaceId) {
return { state: "healthy", runtime };
}
if (health) {
return { state: "unknown", runtime, reason: "workspace_mismatch" };
}
const pid = observePid(runtime.pid);
if (pid === "missing") return { state: "stopped", runtime, reason: "pid_missing" };
return { state: "unknown", runtime, reason: pid === "unknown" ? "pid_unknown" : "probe_failed" };
}
export async function findLiveBridge(workspaceId: string): Promise<RuntimeState | null> {
const state = readRuntimeState(workspaceId);
if (!state) return null;
const health = await probeBridge(state.port);
if (health && health.workspaceId === workspaceId) return state;
return null;
const observation = await findBridgeObservation(workspaceId);
return observation.state === "healthy" ? observation.runtime : null;
}
export { SERVICE_NAME, VERSION };
+22 -5
View File
@@ -4,7 +4,7 @@ import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { startBridge } from "../bridge/server.js";
import { findLiveBridge, probeBridge, readRuntimeState, type RuntimeState } from "../bridge/runtime.js";
import { findBridgeObservation, findLiveBridge, type RuntimeState } from "../bridge/runtime.js";
import { adminFetch, ensureBridge, stopBridge } from "../process/daemon.js";
import { Workspace } from "../workspace/manager.js";
import { AuthStore } from "../auth/store.js";
@@ -354,12 +354,21 @@ program
.action(async (opts: { workspace?: string; json: boolean }) => {
const root = resolveWorkspace(opts.workspace);
const workspace = new Workspace(root);
const runtime = await findLiveBridge(workspace.id);
if (!runtime) {
const observation = await findBridgeObservation(workspace.id);
if (observation.state === "unknown") {
if (opts.json) {
say(JSON.stringify({ ok: false, running: null, state: "unknown", reason: observation.reason }));
} else {
cross(`Bridge 状态无法确认(${observation.reason}),未将其视为未运行。`);
}
return;
}
if (observation.state === "stopped") {
if (opts.json) say(JSON.stringify({ ok: false, running: false }));
else say("Bridge 未运行。使用 `c2c start` 启动。");
return;
}
const runtime = observation.runtime;
const info = await adminFetch<AdminInfo>(runtime, "GET", "/admin/info");
if (opts.json) {
say(JSON.stringify({ ok: true, running: true, ...info }));
@@ -422,9 +431,15 @@ program
// Bridge
let runtime: RuntimeState | null = null;
let bridgeUnknown = false;
if (workspace) {
runtime = await findLiveBridge(workspace.id);
if (!runtime && opts.fix) {
const observation = await findBridgeObservation(workspace.id);
if (observation.state === "healthy") {
runtime = observation.runtime;
} else if (observation.state === "unknown") {
bridgeUnknown = true;
report.bridge = { ok: false, detail: `状态无法确认(${observation.reason}),未自动修复` };
} else if (opts.fix) {
try {
runtime = (await ensureBridge(root)).runtime;
results.push("已自动启动 Bridge");
@@ -594,6 +609,8 @@ program
} else {
report.tunnel = { ok: false, detail: "公网地址无法访问" };
}
} else if (bridgeUnknown) {
report.tunnel = report.tunnel ?? { ok: false, detail: "Bridge 状态无法确认,未执行连接器修复" };
} else if (namedReady) {
report.tunnel = { ok: false, detail: "NAMED_TUNNEL_DOWN" };
namedRepair = { needed: true, userMessage: NAMED_REPAIR_MESSAGE };
+8 -3
View File
@@ -3,7 +3,7 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { ensureDir, getStateDir } from "../config/paths.js";
import { findLiveBridge, probeBridge, readRuntimeState, type RuntimeState } from "../bridge/runtime.js";
import { findBridgeObservation, findLiveBridge, probeBridge, readRuntimeState, type RuntimeState } from "../bridge/runtime.js";
import { Workspace } from "../workspace/manager.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -31,8 +31,13 @@ export interface EnsureBridgeResult {
*/
export async function ensureBridge(workspaceRoot: string, opts: { port?: number } = {}): Promise<EnsureBridgeResult> {
const workspace = new Workspace(workspaceRoot);
const live = await findLiveBridge(workspace.id);
if (live) return { runtime: live, spawned: false };
const observation = await findBridgeObservation(workspace.id);
if (observation.state === "healthy") return { runtime: observation.runtime, spawned: false };
if (observation.state === "unknown") {
throw new Error(
`Bridge state is uncertain (${observation.reason}); refusing to start another bridge.`
);
}
const logDir = ensureDir(path.join(getStateDir(), "logs"));
const logFile = path.join(logDir, `bridge-${workspace.id}.out.log`);
+115
View File
@@ -0,0 +1,115 @@
import { afterEach, describe, expect, it } from "vitest";
import { spawn } from "node:child_process";
import path from "node:path";
import { startBridge } from "../src/bridge/server.js";
import {
findBridgeObservation,
findLiveBridge,
writeRuntimeState,
type RuntimeState,
} from "../src/bridge/runtime.js";
import { ensureBridge } from "../src/process/daemon.js";
import { SERVICE_NAME, VERSION } from "../src/version.js";
import { Workspace } from "../src/workspace/manager.js";
import { cleanup, isolateStateDir, makeTmpDir, write } from "./helpers.js";
function stubRuntime(workspaceId: string, workspaceRoot: string, pid: number, port: number): RuntimeState {
return {
service: SERVICE_NAME,
version: VERSION,
workspaceId,
workspaceRoot,
pid,
port,
adminToken: "test-token",
publicUrl: null,
startedAt: new Date().toISOString(),
};
}
describe("findBridgeObservation", () => {
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs) cleanup(dir);
dirs.length = 0;
delete process.env.C2C_STATE_DIR;
});
it("treats a missing runtime file as stopped", async () => {
dirs.push(isolateStateDir());
const root = makeTmpDir("obs-missing");
dirs.push(root);
write(root, "a.txt", "a");
const workspace = new Workspace(root);
const observation = await findBridgeObservation(workspace.id);
expect(observation.state).toBe("stopped");
if (observation.state === "stopped") expect(observation.reason).toBe("runtime_missing");
expect(await findLiveBridge(workspace.id)).toBeNull();
});
it("treats a dead pid plus a failed probe as stopped", async () => {
dirs.push(isolateStateDir());
const root = makeTmpDir("obs-dead");
dirs.push(root);
write(root, "a.txt", "a");
const workspace = new Workspace(root);
writeRuntimeState(stubRuntime(workspace.id, workspace.root, 999_999_999, 1));
const observation = await findBridgeObservation(workspace.id);
expect(observation.state).toBe("stopped");
if (observation.state === "stopped") expect(observation.reason).toBe("pid_missing");
expect(await findLiveBridge(workspace.id)).toBeNull();
});
it("does not treat a live pid plus a failed probe as stopped", async () => {
dirs.push(isolateStateDir());
const root = makeTmpDir("obs-unknown");
dirs.push(root);
write(root, "a.txt", "a");
const workspace = new Workspace(root);
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
stdio: "ignore",
detached: true,
});
child.unref();
try {
if (!child.pid) throw new Error("failed to spawn helper");
writeRuntimeState(stubRuntime(workspace.id, workspace.root, child.pid, 1));
const observation = await findBridgeObservation(workspace.id);
expect(observation.state).toBe("unknown");
if (observation.state === "unknown") expect(observation.reason).toBe("probe_failed");
expect(await findLiveBridge(workspace.id)).toBeNull();
await expect(ensureBridge(root)).rejects.toThrow(/uncertain/);
} finally {
if (child.pid) {
try {
process.kill(child.pid, "SIGKILL");
} catch {
/* ignore */
}
}
}
});
it("reports healthy when the local bridge answers", async () => {
dirs.push(isolateStateDir());
const root = makeTmpDir("obs-live");
dirs.push(root);
write(root, "a.txt", "a");
const auth = path.join(makeTmpDir("obs-auth"), "store.json");
dirs.push(path.dirname(auth));
const bridge = await startBridge({
workspaceRoot: root,
port: 0,
persistRuntime: true,
authStoreFile: auth,
});
try {
const observation = await findBridgeObservation(bridge.workspace.id);
expect(observation.state).toBe("healthy");
expect(await findLiveBridge(bridge.workspace.id)).not.toBeNull();
} finally {
await bridge.close();
}
});
});