@cjhyy/code-shell-server 0.9.46 → 0.9.48
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 +4 -0
- package/dist/hub/auth-store.d.ts +2 -0
- package/dist/hub/auth-store.js +26 -7
- package/dist/mobile-remote/codex-room-agent.d.ts +0 -6
- package/dist/mobile-remote/codex-room-agent.js +32 -32
- package/dist/mobile-remote/mobile-chat-turn.d.ts +6 -0
- package/dist/mobile-remote/mobile-chat-turn.js +15 -2
- package/dist/mobile-remote/mobile-client-event-validator.js +8 -0
- package/dist/mobile-remote/mobile-run-dispatch.d.ts +1 -0
- package/dist/mobile-remote/mobile-run-dispatch.js +13 -5
- package/dist/mobile-remote/mobile-static.d.ts +5 -6
- package/dist/mobile-remote/mobile-static.js +13 -14
- package/dist/mobile-remote/remote-host-manager.d.ts +2 -0
- package/dist/mobile-remote/remote-host-manager.js +27 -5
- package/dist/mobile-remote/resident-agent.js +27 -22
- package/dist/mobile-remote/room-manager.d.ts +4 -0
- package/dist/mobile-remote/room-manager.js +9 -2
- package/dist/mobile-remote/room-process-stop.d.ts +11 -0
- package/dist/mobile-remote/room-process-stop.js +46 -0
- package/dist/panels/agent-task-service.d.ts +2 -1
- package/dist/panels/agent-task-service.js +10 -3
- package/dist/panels/execution-gate.d.ts +22 -2
- package/dist/panels/execution-gate.js +42 -6
- package/dist/panels/http.d.ts +1 -0
- package/dist/panels/hub-automations.js +1 -0
- package/dist/panels/management-http.d.ts +1 -0
- package/dist/panels/management.d.ts +3 -0
- package/dist/panels/management.js +63 -15
- package/dist/panels/package-mutation.d.ts +1 -1
- package/dist/panels/package-mutation.js +3 -10
- package/dist/panels/resources/uploads.js +15 -6
- package/dist/panels/runtime.js +12 -2
- package/dist/panels/task-cookies.js +8 -16
- package/dist/panels/tool-jobs.js +7 -2
- package/dist/serve/headless-server.js +113 -79
- package/dist/static-files.d.ts +7 -0
- package/dist/static-files.js +114 -0
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -83,6 +83,10 @@ Desktop Web 和 Hub 在注销或显式撤销会话时,会关闭该会话尚未
|
|
|
83
83
|
响应,包括正在下载的文件,并停止源文件读取、释放句柄。其他会话的下载不受影响;
|
|
84
84
|
已经返回接受结果的后台任务仍由原来的任务生命周期管理。注销请求本身仍返回成功。
|
|
85
85
|
|
|
86
|
+
Hub 认证每次读取磁盘上的当前会话;只有需要更新活动时间时才获取写锁,并在锁内
|
|
87
|
+
重新验证撤销与过期状态。Hub 与 Mobile 静态文件按打开时的大小流式发送,`HEAD`
|
|
88
|
+
只返回元数据;客户端断开会停止读取。首页和 SPA 回退也遵守静态目录边界。
|
|
89
|
+
|
|
86
90
|
## code-shell-serve — 个人 Hub Web host
|
|
87
91
|
|
|
88
92
|
在任意机器上把一个 workspace 变成浏览器可访问的 CodeShell:
|
package/dist/hub/auth-store.d.ts
CHANGED
|
@@ -40,6 +40,7 @@ export declare class HubAuthStore {
|
|
|
40
40
|
}): Promise<HubSessionGrant>;
|
|
41
41
|
login(input: HubLoginInput, replaceToken?: string): Promise<HubSessionGrant>;
|
|
42
42
|
authenticate(token: string | undefined): HubSession | null;
|
|
43
|
+
private findSession;
|
|
43
44
|
listSessions(): HubSession[];
|
|
44
45
|
/** Offline restore must not resurrect sessions revoked after the snapshot. */
|
|
45
46
|
revokeAllSessions(): void;
|
|
@@ -49,6 +50,7 @@ export declare class HubAuthStore {
|
|
|
49
50
|
private toPublic;
|
|
50
51
|
private mutate;
|
|
51
52
|
private ensureDirectory;
|
|
53
|
+
private assertDirectory;
|
|
52
54
|
private read;
|
|
53
55
|
private write;
|
|
54
56
|
}
|
package/dist/hub/auth-store.js
CHANGED
|
@@ -93,20 +93,36 @@ export class HubAuthStore {
|
|
|
93
93
|
authenticate(token) {
|
|
94
94
|
if (!token || !TOKEN_PATTERN.test(token))
|
|
95
95
|
return null;
|
|
96
|
+
// Writers publish by atomic rename. Read the committed record on every
|
|
97
|
+
// request, but only contend for the writer lock when activity needs saving.
|
|
98
|
+
this.assertDirectory();
|
|
99
|
+
const tokenHash = hashToken(token);
|
|
100
|
+
const record = this.read();
|
|
101
|
+
const now = this.now();
|
|
102
|
+
const session = this.findSession(record, tokenHash, now);
|
|
103
|
+
if (!session)
|
|
104
|
+
return null;
|
|
105
|
+
if (now - session.lastSeenAt < 60_000)
|
|
106
|
+
return this.toPublic(record, session);
|
|
96
107
|
return this.mutate((record) => {
|
|
97
108
|
const now = this.now();
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
109
|
+
// Recheck after taking the lock: a peer may have revoked, replaced or
|
|
110
|
+
// refreshed the session since the unlocked read. Never write that snapshot.
|
|
111
|
+
const session = this.findSession(record, tokenHash, now);
|
|
112
|
+
if (!session)
|
|
101
113
|
return { result: null, changed: false };
|
|
102
|
-
}
|
|
103
|
-
// Persist activity at most once per minute; validation always reads disk.
|
|
104
114
|
const changed = now - session.lastSeenAt >= 60_000;
|
|
105
115
|
if (changed)
|
|
106
116
|
session.lastSeenAt = now;
|
|
107
117
|
return { result: this.toPublic(record, session), changed };
|
|
108
118
|
});
|
|
109
119
|
}
|
|
120
|
+
findSession(record, tokenHash, now) {
|
|
121
|
+
if (!record.account)
|
|
122
|
+
return null;
|
|
123
|
+
const session = record.sessions.find((row) => safeHashEqual(row.tokenHash, tokenHash));
|
|
124
|
+
return session && session.expiresAt > now && session.createdAt <= now ? session : null;
|
|
125
|
+
}
|
|
110
126
|
listSessions() {
|
|
111
127
|
const record = this.read();
|
|
112
128
|
const now = this.now();
|
|
@@ -194,10 +210,13 @@ export class HubAuthStore {
|
|
|
194
210
|
ensureDirectory() {
|
|
195
211
|
const parent = dirname(this.filePath);
|
|
196
212
|
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
197
|
-
|
|
213
|
+
this.assertDirectory();
|
|
214
|
+
chmodSync(parent, 0o700);
|
|
215
|
+
}
|
|
216
|
+
assertDirectory() {
|
|
217
|
+
const metadata = lstatSync(dirname(this.filePath));
|
|
198
218
|
if (!metadata.isDirectory() || metadata.isSymbolicLink())
|
|
199
219
|
throw new Error("Invalid Hub auth directory");
|
|
200
|
-
chmodSync(parent, 0o700);
|
|
201
220
|
}
|
|
202
221
|
read(allowMissing = false) {
|
|
203
222
|
let descriptor;
|
|
@@ -76,12 +76,6 @@ export declare class CodexRoomAgent implements RoomAgent {
|
|
|
76
76
|
private child?;
|
|
77
77
|
private threadId?;
|
|
78
78
|
private started;
|
|
79
|
-
/** True once the CURRENT turn emitted a sealing event (turn_end via
|
|
80
|
-
* turn.completed, or an error). Reset at the start of each send(). */
|
|
81
|
-
private turnSealed;
|
|
82
|
-
/** True while stop() is tearing the room down, so the exit handler doesn't
|
|
83
|
-
* mistake an intentional SIGTERM for a crash. */
|
|
84
|
-
private stopping;
|
|
85
79
|
constructor(opts: CodexRoomAgentOptions);
|
|
86
80
|
/** No persistent process to launch; a turn's process is spawned on send(). */
|
|
87
81
|
start(): void;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { releaseStdioAfterExit, stopRoomProcess } from "./room-process-stop.js";
|
|
2
3
|
import { createInterface } from "node:readline";
|
|
3
4
|
import { pathWithCommonBins } from "./path-bins.js";
|
|
4
5
|
import { parseCodexJsonLine, extractThreadId } from "./codex-parse.js";
|
|
@@ -86,12 +87,6 @@ export class CodexRoomAgent {
|
|
|
86
87
|
child;
|
|
87
88
|
threadId;
|
|
88
89
|
started = false;
|
|
89
|
-
/** True once the CURRENT turn emitted a sealing event (turn_end via
|
|
90
|
-
* turn.completed, or an error). Reset at the start of each send(). */
|
|
91
|
-
turnSealed = false;
|
|
92
|
-
/** True while stop() is tearing the room down, so the exit handler doesn't
|
|
93
|
-
* mistake an intentional SIGTERM for a crash. */
|
|
94
|
-
stopping = false;
|
|
95
90
|
constructor(opts) {
|
|
96
91
|
this.opts = opts;
|
|
97
92
|
this.threadId = opts.resumeThreadId;
|
|
@@ -105,7 +100,7 @@ export class CodexRoomAgent {
|
|
|
105
100
|
return false;
|
|
106
101
|
if (this.child)
|
|
107
102
|
return false; // a turn is already running; one turn at a time
|
|
108
|
-
|
|
103
|
+
let turnSealed = false; // This child owns only its own turn and callbacks.
|
|
109
104
|
const args = codexArgsForTurn({ mode: this.opts.permissionMode, threadId: this.threadId });
|
|
110
105
|
const child = spawn(this.opts.command, args, {
|
|
111
106
|
cwd: this.opts.cwd,
|
|
@@ -114,11 +109,13 @@ export class CodexRoomAgent {
|
|
|
114
109
|
stdio: ["pipe", "pipe", "pipe"],
|
|
115
110
|
});
|
|
116
111
|
this.child = child;
|
|
117
|
-
//
|
|
118
|
-
child
|
|
112
|
+
// Settle on `close` so trailing output is read, without waiting on grandchildren.
|
|
113
|
+
releaseStdioAfterExit(child);
|
|
119
114
|
if (child.stdout) {
|
|
120
115
|
const rl = createInterface({ input: child.stdout });
|
|
121
116
|
rl.on("line", (line) => {
|
|
117
|
+
if (this.child !== child)
|
|
118
|
+
return;
|
|
122
119
|
// Capture the thread id (parseCodexJsonLine intentionally drops
|
|
123
120
|
// thread.started — it's resume-control info, not a render event) so we
|
|
124
121
|
// can resume on the next turn and persist it across restarts.
|
|
@@ -131,7 +128,7 @@ export class CodexRoomAgent {
|
|
|
131
128
|
// turn_end / error are the reducer's sealing events — once one fires
|
|
132
129
|
// for this turn, the exit handler need not synthesize a fallback.
|
|
133
130
|
if (ev.type === "turn_end" || ev.type === "error")
|
|
134
|
-
|
|
131
|
+
turnSealed = true;
|
|
135
132
|
this.opts.onEvent(ev);
|
|
136
133
|
}
|
|
137
134
|
});
|
|
@@ -146,17 +143,32 @@ export class CodexRoomAgent {
|
|
|
146
143
|
errRl.on("line", (line) => stderrLines.push(line));
|
|
147
144
|
}
|
|
148
145
|
child.on("error", (err) => {
|
|
146
|
+
if (this.child !== child)
|
|
147
|
+
return;
|
|
148
|
+
this.child = undefined;
|
|
149
149
|
const isMissing = err.code === "ENOENT";
|
|
150
|
-
|
|
150
|
+
turnSealed = true; // this error seals the run in the reducer
|
|
151
151
|
this.opts.onEvent({
|
|
152
152
|
type: "error",
|
|
153
153
|
error: isMissing
|
|
154
154
|
? `未找到命令 "${this.opts.command}"。请先安装 Codex CLI 并确保它在 PATH 中。`
|
|
155
155
|
: err.message,
|
|
156
156
|
});
|
|
157
|
+
});
|
|
158
|
+
child.stdin?.on("error", (error) => {
|
|
159
|
+
if (this.child !== child)
|
|
160
|
+
return;
|
|
157
161
|
this.child = undefined;
|
|
162
|
+
stopRoomProcess(child);
|
|
163
|
+
this.opts.onEvent({
|
|
164
|
+
type: "error",
|
|
165
|
+
error: `Codex input pipe closed${error.code ? ` (${error.code})` : ""}`,
|
|
166
|
+
});
|
|
158
167
|
});
|
|
159
|
-
child.on("
|
|
168
|
+
child.on("close", (code, signal) => {
|
|
169
|
+
if (this.child !== child)
|
|
170
|
+
return;
|
|
171
|
+
this.child = undefined;
|
|
160
172
|
// The turn's process is done; the room stays "running" (ready for the next
|
|
161
173
|
// turn). We do NOT emit `exit` here — that would tear the room down in the
|
|
162
174
|
// UI after every turn. turn.completed already produced a `turn_end`.
|
|
@@ -164,7 +176,7 @@ export class CodexRoomAgent {
|
|
|
164
176
|
// is surfaced now so the room shows *why* it failed.
|
|
165
177
|
const stderrErr = codexStderrError(stderrLines, code);
|
|
166
178
|
if (stderrErr) {
|
|
167
|
-
|
|
179
|
+
turnSealed = true;
|
|
168
180
|
this.opts.onEvent({ type: "error", error: stderrErr });
|
|
169
181
|
}
|
|
170
182
|
// FALLBACK SEAL (#6): if the process died without ever sealing the turn
|
|
@@ -174,14 +186,16 @@ export class CodexRoomAgent {
|
|
|
174
186
|
const seal = sealEventOnExit({
|
|
175
187
|
code,
|
|
176
188
|
signal,
|
|
177
|
-
turnSealed
|
|
178
|
-
stopping:
|
|
189
|
+
turnSealed,
|
|
190
|
+
stopping: false,
|
|
179
191
|
hasStderrError: stderrErr != null,
|
|
180
192
|
});
|
|
181
193
|
if (seal)
|
|
182
194
|
this.opts.onEvent(seal);
|
|
183
|
-
this.child = undefined;
|
|
184
195
|
});
|
|
196
|
+
// Register stdin's error owner before writing: early CLI exits fail writes
|
|
197
|
+
// asynchronously even when end() itself returns successfully.
|
|
198
|
+
child.stdin?.end(text);
|
|
185
199
|
return true;
|
|
186
200
|
}
|
|
187
201
|
/** The room is "running" once started — independent of whether a turn's
|
|
@@ -191,23 +205,9 @@ export class CodexRoomAgent {
|
|
|
191
205
|
}
|
|
192
206
|
stop() {
|
|
193
207
|
this.started = false;
|
|
194
|
-
this.stopping = true; // the upcoming SIGTERM exit is intentional, not a crash
|
|
195
208
|
const child = this.child;
|
|
196
209
|
this.child = undefined;
|
|
197
|
-
if (
|
|
198
|
-
child
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
try {
|
|
202
|
-
process.kill(-child.pid, "SIGTERM");
|
|
203
|
-
}
|
|
204
|
-
catch {
|
|
205
|
-
try {
|
|
206
|
-
child.kill("SIGTERM");
|
|
207
|
-
}
|
|
208
|
-
catch {
|
|
209
|
-
/* already gone */
|
|
210
|
-
}
|
|
211
|
-
}
|
|
210
|
+
if (child)
|
|
211
|
+
stopRoomProcess(child);
|
|
212
212
|
}
|
|
213
213
|
}
|
|
@@ -33,6 +33,12 @@ export type DispatchMobileChatTurnResult = {
|
|
|
33
33
|
} | {
|
|
34
34
|
ok: false;
|
|
35
35
|
message: string;
|
|
36
|
+
uncertain?: false;
|
|
37
|
+
} | {
|
|
38
|
+
ok: false;
|
|
39
|
+
message: string;
|
|
40
|
+
uncertain: true;
|
|
41
|
+
clientMessageId: string;
|
|
36
42
|
};
|
|
37
43
|
/** Transaction boundary for mobile chat: workspace → canonical stage → worker ack → finalize. */
|
|
38
44
|
export declare function dispatchMobileChatTurn(input: DispatchMobileChatTurnInput): Promise<DispatchMobileChatTurnResult>;
|
|
@@ -63,11 +63,24 @@ export async function dispatchMobileChatTurn(input) {
|
|
|
63
63
|
: await injectMobileRunAndAwaitAcceptance(input.bridge, request, input.meta);
|
|
64
64
|
}
|
|
65
65
|
catch {
|
|
66
|
-
acceptance = {
|
|
66
|
+
acceptance = {
|
|
67
|
+
ok: false,
|
|
68
|
+
uncertain: true,
|
|
69
|
+
message: "The selected runtime did not confirm the submission",
|
|
70
|
+
};
|
|
67
71
|
}
|
|
68
72
|
if (!acceptance.ok) {
|
|
73
|
+
// Worker input refers to the canonical staged files, not these temporary
|
|
74
|
+
// upload spools. Keep the staged files for a possibly accepted run and
|
|
75
|
+
// release only the claims so the same intent can retry its attachments.
|
|
76
|
+
// A late acceptance may never be observed, so uncertain files also need
|
|
77
|
+
// sent retention rather than expiring as abandoned drafts after one day.
|
|
78
|
+
if (acceptance.uncertain)
|
|
79
|
+
await (input.markSent ?? markAttachmentsSent)(cwd, input.sessionId, materialized.metas).catch(() => undefined);
|
|
69
80
|
await settleClaims(input.uploads, input.deviceId, materialized.claims, "release");
|
|
70
|
-
return
|
|
81
|
+
return acceptance.uncertain
|
|
82
|
+
? { ok: false, message: acceptance.message, uncertain: true, clientMessageId }
|
|
83
|
+
: { ok: false, message: acceptance.message };
|
|
71
84
|
}
|
|
72
85
|
await (input.markSent ?? markAttachmentsSent)(cwd, input.sessionId, materialized.metas).catch(() => undefined);
|
|
73
86
|
await settleClaims(input.uploads, input.deviceId, materialized.claims, "finalize");
|
|
@@ -52,6 +52,14 @@ export function parseMobileClientEvent(value) {
|
|
|
52
52
|
case "session.recovery.cancel":
|
|
53
53
|
valid = true;
|
|
54
54
|
break;
|
|
55
|
+
case "session.legacyBase":
|
|
56
|
+
valid =
|
|
57
|
+
boundedString(event.sessionId, 128) &&
|
|
58
|
+
boundedString(event.recoveryId, 128) &&
|
|
59
|
+
boundedString(event.requestId, 128) &&
|
|
60
|
+
((boundedString(event.throughEventId, 512) && event.cursor === undefined) ||
|
|
61
|
+
(boundedString(event.cursor, 2048) && event.throughEventId === undefined));
|
|
62
|
+
break;
|
|
55
63
|
case "session.outputJournal":
|
|
56
64
|
valid =
|
|
57
65
|
boundedString(event.sessionId, 128) &&
|
|
@@ -13,6 +13,7 @@ export type MobileRunAcceptance = {
|
|
|
13
13
|
ok: false;
|
|
14
14
|
message: string;
|
|
15
15
|
code?: number;
|
|
16
|
+
uncertain?: true;
|
|
16
17
|
};
|
|
17
18
|
/** Inject agent/run and wait for its request-correlated validation/queue acknowledgement. */
|
|
18
19
|
export declare function injectMobileRunAndAwaitAcceptance(bridge: MobileRunBridge, request: MobileRunRequest, meta: WorkerFrameMeta, timeoutMs?: number): Promise<MobileRunAcceptance>;
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
export function injectMobileRunAndAwaitAcceptance(bridge, request, meta, timeoutMs = 5_000) {
|
|
3
3
|
return new Promise((resolve) => {
|
|
4
4
|
let settled = false;
|
|
5
|
-
let timer;
|
|
6
5
|
const finish = (result) => {
|
|
7
6
|
if (settled)
|
|
8
7
|
return;
|
|
@@ -40,17 +39,26 @@ export function injectMobileRunAndAwaitAcceptance(bridge, request, meta, timeout
|
|
|
40
39
|
// Not JSON or not part of this request.
|
|
41
40
|
}
|
|
42
41
|
});
|
|
43
|
-
timer = setTimeout(() => finish({ ok: false, message: "worker did not acknowledge the run" }), timeoutMs);
|
|
42
|
+
const timer = setTimeout(() => finish({ ok: false, uncertain: true, message: "worker did not acknowledge the run" }), timeoutMs);
|
|
43
|
+
let injecting = false;
|
|
44
44
|
try {
|
|
45
|
-
|
|
45
|
+
const line = JSON.stringify({
|
|
46
46
|
jsonrpc: "2.0",
|
|
47
47
|
id: request.id,
|
|
48
48
|
method: "agent/run",
|
|
49
49
|
params: request.params,
|
|
50
|
-
})
|
|
50
|
+
});
|
|
51
|
+
injecting = true;
|
|
52
|
+
bridge.injectWorkerMessage(line, meta);
|
|
51
53
|
}
|
|
52
54
|
catch (error) {
|
|
53
|
-
|
|
55
|
+
// An injection can fail after queuing bytes. Only serialization failure
|
|
56
|
+
// proves that this request never reached the transport.
|
|
57
|
+
finish({
|
|
58
|
+
ok: false,
|
|
59
|
+
message: error instanceof Error ? error.message : String(error),
|
|
60
|
+
...(injecting ? { uncertain: true } : {}),
|
|
61
|
+
});
|
|
54
62
|
}
|
|
55
63
|
});
|
|
56
64
|
}
|
|
@@ -38,12 +38,11 @@ export declare function mobileEntryRedirect(reqUrl: string): string | null;
|
|
|
38
38
|
* can see through it. Guarding that would need inode/device policy, and anyone
|
|
39
39
|
* able to hardlink into the served root can already write files there.
|
|
40
40
|
*
|
|
41
|
-
* The returned path is the REQUESTED one, not the realpath:
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* which is out of scope for this fix. */
|
|
41
|
+
* The returned path is the REQUESTED one, not the realpath: the resolved-name
|
|
42
|
+
* contract is relied on elsewhere (on macOS the realpath of a /var root is
|
|
43
|
+
* /private/var, which would change every result). This is only a routing check;
|
|
44
|
+
* both HTTP callers open and revalidate the selected file through
|
|
45
|
+
* streamStaticFile before returning bytes. */
|
|
47
46
|
export declare function resolveSafe(root: string, subPath: string): string | null;
|
|
48
47
|
export interface MobileStaticOptions {
|
|
49
48
|
/** Absolute path to the built mobile app (out/mobile). */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { isAbsolute, join, normalize, relative, resolve, sep, extname } from "node:path";
|
|
3
3
|
import { request as httpRequest } from "node:http";
|
|
4
|
-
import { contentTypeFor } from "../static-files.js";
|
|
4
|
+
import { contentTypeFor, streamStaticFile } from "../static-files.js";
|
|
5
5
|
/**
|
|
6
6
|
* Serves the built mobile web app (out/mobile) as static assets for the
|
|
7
7
|
* `/mobile` route family, replacing the old inline `mobileRemoteHtml()` string.
|
|
@@ -61,12 +61,11 @@ function isContained(root, target) {
|
|
|
61
61
|
* can see through it. Guarding that would need inode/device policy, and anyone
|
|
62
62
|
* able to hardlink into the served root can already write files there.
|
|
63
63
|
*
|
|
64
|
-
* The returned path is the REQUESTED one, not the realpath:
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* which is out of scope for this fix. */
|
|
64
|
+
* The returned path is the REQUESTED one, not the realpath: the resolved-name
|
|
65
|
+
* contract is relied on elsewhere (on macOS the realpath of a /var root is
|
|
66
|
+
* /private/var, which would change every result). This is only a routing check;
|
|
67
|
+
* both HTTP callers open and revalidate the selected file through
|
|
68
|
+
* streamStaticFile before returning bytes. */
|
|
70
69
|
export function resolveSafe(root, subPath) {
|
|
71
70
|
const rootResolved = resolve(root);
|
|
72
71
|
const rel = normalize(subPath || "index.html");
|
|
@@ -77,11 +76,11 @@ export function resolveSafe(root, subPath) {
|
|
|
77
76
|
const full = resolve(join(rootResolved, rel));
|
|
78
77
|
if (!isContained(rootResolved, full))
|
|
79
78
|
return null;
|
|
80
|
-
if (!existsSync(full) || !statSync(full).isFile())
|
|
81
|
-
return null;
|
|
82
79
|
// realpathSync throws on a dangling link or a race-deleted file; a missing
|
|
83
80
|
// target is a 404 through the normal contract, never a 500.
|
|
84
81
|
try {
|
|
82
|
+
if (!existsSync(full) || !statSync(full).isFile())
|
|
83
|
+
return null;
|
|
85
84
|
if (!isContained(realpathSync(rootResolved), realpathSync(full)))
|
|
86
85
|
return null;
|
|
87
86
|
}
|
|
@@ -143,15 +142,15 @@ export function serveMobile(req, res, opts) {
|
|
|
143
142
|
// stale index.html would reference deleted assets → 404 → blank page until a
|
|
144
143
|
// hard refresh.
|
|
145
144
|
const isHtml = type.startsWith("text/html");
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
145
|
+
void streamStaticFile(req, res, {
|
|
146
|
+
rootDir: opts.rootDir,
|
|
147
|
+
filePath: file,
|
|
148
|
+
cacheControl: isHtml
|
|
149
149
|
? "no-store"
|
|
150
150
|
: sub.startsWith("assets/")
|
|
151
151
|
? "public, max-age=31536000, immutable"
|
|
152
152
|
: "no-cache",
|
|
153
153
|
});
|
|
154
|
-
createReadStream(file).pipe(res);
|
|
155
154
|
}
|
|
156
155
|
/** Dev-only: forward the request to the mobile vite dev server. The mobile vite
|
|
157
156
|
* config sets base "/mobile/", so vite serves its HMR client, module graph, and
|
|
@@ -37,6 +37,8 @@ export interface RemoteHostStarted {
|
|
|
37
37
|
export interface RemoteHostManagerOptions {
|
|
38
38
|
devices: TrustedDeviceStore;
|
|
39
39
|
outputJournal?: boolean;
|
|
40
|
+
/** Enable only when the attached selected-session host implements legacy base pages. */
|
|
41
|
+
legacyBasePages?: boolean;
|
|
40
42
|
onClientEvent: (event: unknown, ws: WebSocket) => void | Promise<void>;
|
|
41
43
|
/**
|
|
42
44
|
* Absolute path to the built mobile app (out/mobile). Defaults to the
|
|
@@ -126,6 +126,16 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
126
126
|
const gate = this.passcode;
|
|
127
127
|
this.opts.webApi?.start();
|
|
128
128
|
const server = createServer((req, res) => {
|
|
129
|
+
let pathname;
|
|
130
|
+
try {
|
|
131
|
+
pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
|
|
135
|
+
res.end("invalid request target");
|
|
136
|
+
req.resume();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
129
139
|
// Tunnel mode: a passcode gate sits in front of EVERY route. The gate
|
|
130
140
|
// either allows (returns true) or writes its own 401/403 challenge.
|
|
131
141
|
if (gate && !gate.gate(req, res))
|
|
@@ -158,9 +168,7 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
158
168
|
});
|
|
159
169
|
return;
|
|
160
170
|
}
|
|
161
|
-
const uploadPath =
|
|
162
|
-
? new URL(req.url, "http://localhost").pathname.match(/^\/api\/mobile\/uploads\/([A-Za-z0-9_-]+)$/)
|
|
163
|
-
: null;
|
|
171
|
+
const uploadPath = pathname.match(/^\/api\/mobile\/uploads\/([A-Za-z0-9_-]+)$/);
|
|
164
172
|
if (uploadPath && this.opts.uploads) {
|
|
165
173
|
void this.opts.uploads.acceptPut(uploadPath[1], req, res);
|
|
166
174
|
return;
|
|
@@ -411,7 +419,14 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
411
419
|
return {
|
|
412
420
|
type: "pair.ok",
|
|
413
421
|
device,
|
|
414
|
-
...(this.opts.outputJournal
|
|
422
|
+
...(this.opts.outputJournal
|
|
423
|
+
? {
|
|
424
|
+
capabilities: {
|
|
425
|
+
outputJournal: 1,
|
|
426
|
+
...(this.opts.legacyBasePages ? { legacyBasePages: 1 } : {}),
|
|
427
|
+
},
|
|
428
|
+
}
|
|
429
|
+
: {}),
|
|
415
430
|
};
|
|
416
431
|
}
|
|
417
432
|
if (event.type === "auth.device") {
|
|
@@ -427,7 +442,14 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
427
442
|
return {
|
|
428
443
|
type: "auth.ok",
|
|
429
444
|
device,
|
|
430
|
-
...(this.opts.outputJournal
|
|
445
|
+
...(this.opts.outputJournal
|
|
446
|
+
? {
|
|
447
|
+
capabilities: {
|
|
448
|
+
outputJournal: 1,
|
|
449
|
+
...(this.opts.legacyBasePages ? { legacyBasePages: 1 } : {}),
|
|
450
|
+
},
|
|
451
|
+
}
|
|
452
|
+
: {}),
|
|
431
453
|
};
|
|
432
454
|
}
|
|
433
455
|
return undefined;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { releaseStdioAfterExit, stopRoomProcess } from "./room-process-stop.js";
|
|
2
3
|
import { createInterface } from "node:readline";
|
|
3
4
|
import { pathWithCommonBins } from "./path-bins.js";
|
|
4
5
|
/**
|
|
@@ -148,14 +149,20 @@ export class ResidentAgentProcess {
|
|
|
148
149
|
stdio: ["pipe", "pipe", "pipe"],
|
|
149
150
|
});
|
|
150
151
|
this.child = child;
|
|
152
|
+
// Settle on `close` so trailing output is read, without waiting on grandchildren.
|
|
153
|
+
releaseStdioAfterExit(child);
|
|
151
154
|
if (child.stdout) {
|
|
152
155
|
const rl = createInterface({ input: child.stdout });
|
|
153
156
|
rl.on("line", (line) => {
|
|
157
|
+
if (this.child !== child)
|
|
158
|
+
return;
|
|
154
159
|
for (const ev of parseStreamJsonLine(line))
|
|
155
160
|
this.opts.onEvent(ev);
|
|
156
161
|
});
|
|
157
162
|
}
|
|
158
163
|
child.stderr?.on("data", (chunk) => {
|
|
164
|
+
if (this.child !== child)
|
|
165
|
+
return;
|
|
159
166
|
// claude prints non-fatal warnings to stderr; surface as error events
|
|
160
167
|
// only if they look like real errors (cheap heuristic).
|
|
161
168
|
const text = String(chunk);
|
|
@@ -167,11 +174,14 @@ export class ResidentAgentProcess {
|
|
|
167
174
|
if (exited)
|
|
168
175
|
return;
|
|
169
176
|
exited = true;
|
|
170
|
-
if (this.child
|
|
171
|
-
|
|
177
|
+
if (this.child !== child)
|
|
178
|
+
return;
|
|
179
|
+
this.child = undefined;
|
|
172
180
|
this.opts.onEvent({ type: "exit", code, signal });
|
|
173
181
|
};
|
|
174
182
|
child.on("error", (err) => {
|
|
183
|
+
if (this.child !== child)
|
|
184
|
+
return;
|
|
175
185
|
const isMissing = err.code === "ENOENT";
|
|
176
186
|
this.opts.onEvent({
|
|
177
187
|
type: "error",
|
|
@@ -184,7 +194,19 @@ export class ResidentAgentProcess {
|
|
|
184
194
|
if (child.pid === undefined)
|
|
185
195
|
finish(null, null);
|
|
186
196
|
});
|
|
187
|
-
child.on("
|
|
197
|
+
child.stdin?.on("error", (error) => {
|
|
198
|
+
if (this.child !== child)
|
|
199
|
+
return;
|
|
200
|
+
// Pipe errors are emitted by stdin, separately from ChildProcess.error.
|
|
201
|
+
// Retire this room immediately; its eventual exit must not close a retry.
|
|
202
|
+
stopRoomProcess(child);
|
|
203
|
+
this.opts.onEvent({
|
|
204
|
+
type: "error",
|
|
205
|
+
error: `Claude Code input pipe closed${error.code ? ` (${error.code})` : ""}`,
|
|
206
|
+
});
|
|
207
|
+
finish(null, null);
|
|
208
|
+
});
|
|
209
|
+
child.on("close", (code, signal) => finish(code, signal));
|
|
188
210
|
}
|
|
189
211
|
/** Feed one user turn. Returns false if the process isn't running. */
|
|
190
212
|
send(text) {
|
|
@@ -206,24 +228,7 @@ export class ResidentAgentProcess {
|
|
|
206
228
|
stop() {
|
|
207
229
|
const child = this.child;
|
|
208
230
|
this.child = undefined;
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
// undefined/0; the `> 1` also rules out the (theoretical) pid-1 case so this
|
|
212
|
-
// negative-pid group kill is consistent with killProcessGroup's guard.
|
|
213
|
-
if (!child?.pid || child.pid <= 1) {
|
|
214
|
-
child?.kill("SIGTERM");
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
try {
|
|
218
|
-
process.kill(-child.pid, "SIGTERM");
|
|
219
|
-
}
|
|
220
|
-
catch {
|
|
221
|
-
try {
|
|
222
|
-
child.kill("SIGTERM");
|
|
223
|
-
}
|
|
224
|
-
catch {
|
|
225
|
-
/* already gone */
|
|
226
|
-
}
|
|
227
|
-
}
|
|
231
|
+
if (child)
|
|
232
|
+
stopRoomProcess(child);
|
|
228
233
|
}
|
|
229
234
|
}
|
|
@@ -119,6 +119,10 @@ export interface RoomManagerOptions {
|
|
|
119
119
|
* Returning null means the transcript is absent. The returned identity is
|
|
120
120
|
* checked again here so a stale or mismatched locator fails closed. */
|
|
121
121
|
resolveLinkedSession?: (target: Readonly<LinkedSessionTarget>) => LinkedSessionTarget | null | undefined;
|
|
122
|
+
/** Throws while a background task (DriveAgent) may still be writing this
|
|
123
|
+
* external session. Checked before any resident agent can start on it, so
|
|
124
|
+
* desktop and phone entry points share one ownership gate. */
|
|
125
|
+
assertExternalSessionAvailable?: (externalSessionId: string, kind: RoomKind) => void;
|
|
122
126
|
/** Called whenever a room gains a new persisted message (push to phone). */
|
|
123
127
|
onMessage: (roomId: string, msg: RoomMessage) => void;
|
|
124
128
|
/** Called when a room's resident agent requests tool-use approval. For
|
|
@@ -777,6 +777,8 @@ export class RoomManager {
|
|
|
777
777
|
* so a given claude session maps to exactly one room. Returns the room id.
|
|
778
778
|
*/
|
|
779
779
|
openForSession(claudeSessionId, cwd, mode, kind = "claude-code") {
|
|
780
|
+
if (claudeSessionId)
|
|
781
|
+
this.opts.assertExternalSessionAvailable?.(claudeSessionId, kind);
|
|
780
782
|
// Reuse must match BOTH id and kind: a codex thread id and a claude session
|
|
781
783
|
// id live in the same `claudeSessionId` field, so a bare id match could
|
|
782
784
|
// otherwise hand back a claude room when a codex room was asked for.
|
|
@@ -851,6 +853,7 @@ export class RoomManager {
|
|
|
851
853
|
* silently start a different session. */
|
|
852
854
|
takeOverLinkedSession(roomId, externalSessionId, cwd, kind) {
|
|
853
855
|
const target = this.requireLinkedSessionTarget({ externalSessionId, cwd, kind });
|
|
856
|
+
this.opts.assertExternalSessionAvailable?.(target.externalSessionId, target.kind);
|
|
854
857
|
const meta = this.getRoom(roomId);
|
|
855
858
|
if (!meta ||
|
|
856
859
|
meta.claudeSessionId !== target.externalSessionId ||
|
|
@@ -890,8 +893,10 @@ export class RoomManager {
|
|
|
890
893
|
catch {
|
|
891
894
|
throw new Error("linked session transcript is unavailable");
|
|
892
895
|
}
|
|
893
|
-
if (!resolvedTarget
|
|
894
|
-
|
|
896
|
+
if (!resolvedTarget) {
|
|
897
|
+
throw new Error("linked session transcript was not found for the requested session");
|
|
898
|
+
}
|
|
899
|
+
if (typeof resolvedTarget.externalSessionId !== "string" ||
|
|
895
900
|
typeof resolvedTarget.cwd !== "string" ||
|
|
896
901
|
resolvedTarget.externalSessionId !== target.externalSessionId ||
|
|
897
902
|
resolvedTarget.kind !== target.kind ||
|
|
@@ -967,6 +972,8 @@ export class RoomManager {
|
|
|
967
972
|
if (meta.linkedSessionMode === "observe-only")
|
|
968
973
|
return { status: "observing" };
|
|
969
974
|
if (!this.agents.has(id)) {
|
|
975
|
+
if (meta.claudeSessionId)
|
|
976
|
+
this.opts.assertExternalSessionAvailable?.(meta.claudeSessionId, meta.kind);
|
|
970
977
|
let agent = undefined;
|
|
971
978
|
agent = this.opts.createAgent(meta, (event) => {
|
|
972
979
|
// stop() sends a signal; an old process can still drain output or emit
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
/** Retire an unusable room transport without leaving an unresponsive CLI alive. */
|
|
3
|
+
export declare function stopRoomProcess(child: ChildProcess): void;
|
|
4
|
+
/**
|
|
5
|
+
* `close` waits for every holder of the child's stdout/stderr. A grandchild the
|
|
6
|
+
* CLI backgrounded with inherited stdio (a dev server, say) keeps them open
|
|
7
|
+
* after the CLI exits, so the room would never settle. Once the CLI has exited
|
|
8
|
+
* and its buffered output had a grace period to drain, release the pipes;
|
|
9
|
+
* the grandchild itself is left running.
|
|
10
|
+
*/
|
|
11
|
+
export declare function releaseStdioAfterExit(child: ChildProcess, graceMs?: number): void;
|