@cjhyy/code-shell-server 0.9.46 → 0.9.47
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/dist/mobile-remote/codex-room-agent.d.ts +0 -6
- package/dist/mobile-remote/codex-room-agent.js +30 -32
- package/dist/mobile-remote/mobile-client-event-validator.js +8 -0
- 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 +25 -22
- package/dist/mobile-remote/room-manager.js +4 -2
- package/dist/mobile-remote/room-process-stop.d.ts +3 -0
- package/dist/mobile-remote/room-process-stop.js +28 -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 +19 -1
- package/dist/panels/execution-gate.js +33 -5
- package/dist/panels/hub-automations.js +1 -0
- 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/package.json +6 -6
|
@@ -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 { 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,11 @@ export class CodexRoomAgent {
|
|
|
114
109
|
stdio: ["pipe", "pipe", "pipe"],
|
|
115
110
|
});
|
|
116
111
|
this.child = child;
|
|
117
|
-
// Feed the user's prompt over stdin (codex exec reads it because argv ends `-`).
|
|
118
|
-
child.stdin?.end(text);
|
|
119
112
|
if (child.stdout) {
|
|
120
113
|
const rl = createInterface({ input: child.stdout });
|
|
121
114
|
rl.on("line", (line) => {
|
|
115
|
+
if (this.child !== child)
|
|
116
|
+
return;
|
|
122
117
|
// Capture the thread id (parseCodexJsonLine intentionally drops
|
|
123
118
|
// thread.started — it's resume-control info, not a render event) so we
|
|
124
119
|
// can resume on the next turn and persist it across restarts.
|
|
@@ -131,7 +126,7 @@ export class CodexRoomAgent {
|
|
|
131
126
|
// turn_end / error are the reducer's sealing events — once one fires
|
|
132
127
|
// for this turn, the exit handler need not synthesize a fallback.
|
|
133
128
|
if (ev.type === "turn_end" || ev.type === "error")
|
|
134
|
-
|
|
129
|
+
turnSealed = true;
|
|
135
130
|
this.opts.onEvent(ev);
|
|
136
131
|
}
|
|
137
132
|
});
|
|
@@ -146,17 +141,32 @@ export class CodexRoomAgent {
|
|
|
146
141
|
errRl.on("line", (line) => stderrLines.push(line));
|
|
147
142
|
}
|
|
148
143
|
child.on("error", (err) => {
|
|
144
|
+
if (this.child !== child)
|
|
145
|
+
return;
|
|
146
|
+
this.child = undefined;
|
|
149
147
|
const isMissing = err.code === "ENOENT";
|
|
150
|
-
|
|
148
|
+
turnSealed = true; // this error seals the run in the reducer
|
|
151
149
|
this.opts.onEvent({
|
|
152
150
|
type: "error",
|
|
153
151
|
error: isMissing
|
|
154
152
|
? `未找到命令 "${this.opts.command}"。请先安装 Codex CLI 并确保它在 PATH 中。`
|
|
155
153
|
: err.message,
|
|
156
154
|
});
|
|
155
|
+
});
|
|
156
|
+
child.stdin?.on("error", (error) => {
|
|
157
|
+
if (this.child !== child)
|
|
158
|
+
return;
|
|
157
159
|
this.child = undefined;
|
|
160
|
+
stopRoomProcess(child);
|
|
161
|
+
this.opts.onEvent({
|
|
162
|
+
type: "error",
|
|
163
|
+
error: `Codex input pipe closed${error.code ? ` (${error.code})` : ""}`,
|
|
164
|
+
});
|
|
158
165
|
});
|
|
159
|
-
child.on("
|
|
166
|
+
child.on("close", (code, signal) => {
|
|
167
|
+
if (this.child !== child)
|
|
168
|
+
return;
|
|
169
|
+
this.child = undefined;
|
|
160
170
|
// The turn's process is done; the room stays "running" (ready for the next
|
|
161
171
|
// turn). We do NOT emit `exit` here — that would tear the room down in the
|
|
162
172
|
// UI after every turn. turn.completed already produced a `turn_end`.
|
|
@@ -164,7 +174,7 @@ export class CodexRoomAgent {
|
|
|
164
174
|
// is surfaced now so the room shows *why* it failed.
|
|
165
175
|
const stderrErr = codexStderrError(stderrLines, code);
|
|
166
176
|
if (stderrErr) {
|
|
167
|
-
|
|
177
|
+
turnSealed = true;
|
|
168
178
|
this.opts.onEvent({ type: "error", error: stderrErr });
|
|
169
179
|
}
|
|
170
180
|
// FALLBACK SEAL (#6): if the process died without ever sealing the turn
|
|
@@ -174,14 +184,16 @@ export class CodexRoomAgent {
|
|
|
174
184
|
const seal = sealEventOnExit({
|
|
175
185
|
code,
|
|
176
186
|
signal,
|
|
177
|
-
turnSealed
|
|
178
|
-
stopping:
|
|
187
|
+
turnSealed,
|
|
188
|
+
stopping: false,
|
|
179
189
|
hasStderrError: stderrErr != null,
|
|
180
190
|
});
|
|
181
191
|
if (seal)
|
|
182
192
|
this.opts.onEvent(seal);
|
|
183
|
-
this.child = undefined;
|
|
184
193
|
});
|
|
194
|
+
// Register stdin's error owner before writing: early CLI exits fail writes
|
|
195
|
+
// asynchronously even when end() itself returns successfully.
|
|
196
|
+
child.stdin?.end(text);
|
|
185
197
|
return true;
|
|
186
198
|
}
|
|
187
199
|
/** The room is "running" once started — independent of whether a turn's
|
|
@@ -191,23 +203,9 @@ export class CodexRoomAgent {
|
|
|
191
203
|
}
|
|
192
204
|
stop() {
|
|
193
205
|
this.started = false;
|
|
194
|
-
this.stopping = true; // the upcoming SIGTERM exit is intentional, not a crash
|
|
195
206
|
const child = this.child;
|
|
196
207
|
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
|
-
}
|
|
208
|
+
if (child)
|
|
209
|
+
stopRoomProcess(child);
|
|
212
210
|
}
|
|
213
211
|
}
|
|
@@ -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) &&
|
|
@@ -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 { stopRoomProcess } from "./room-process-stop.js";
|
|
2
3
|
import { createInterface } from "node:readline";
|
|
3
4
|
import { pathWithCommonBins } from "./path-bins.js";
|
|
4
5
|
/**
|
|
@@ -151,11 +152,15 @@ export class ResidentAgentProcess {
|
|
|
151
152
|
if (child.stdout) {
|
|
152
153
|
const rl = createInterface({ input: child.stdout });
|
|
153
154
|
rl.on("line", (line) => {
|
|
155
|
+
if (this.child !== child)
|
|
156
|
+
return;
|
|
154
157
|
for (const ev of parseStreamJsonLine(line))
|
|
155
158
|
this.opts.onEvent(ev);
|
|
156
159
|
});
|
|
157
160
|
}
|
|
158
161
|
child.stderr?.on("data", (chunk) => {
|
|
162
|
+
if (this.child !== child)
|
|
163
|
+
return;
|
|
159
164
|
// claude prints non-fatal warnings to stderr; surface as error events
|
|
160
165
|
// only if they look like real errors (cheap heuristic).
|
|
161
166
|
const text = String(chunk);
|
|
@@ -167,11 +172,14 @@ export class ResidentAgentProcess {
|
|
|
167
172
|
if (exited)
|
|
168
173
|
return;
|
|
169
174
|
exited = true;
|
|
170
|
-
if (this.child
|
|
171
|
-
|
|
175
|
+
if (this.child !== child)
|
|
176
|
+
return;
|
|
177
|
+
this.child = undefined;
|
|
172
178
|
this.opts.onEvent({ type: "exit", code, signal });
|
|
173
179
|
};
|
|
174
180
|
child.on("error", (err) => {
|
|
181
|
+
if (this.child !== child)
|
|
182
|
+
return;
|
|
175
183
|
const isMissing = err.code === "ENOENT";
|
|
176
184
|
this.opts.onEvent({
|
|
177
185
|
type: "error",
|
|
@@ -184,7 +192,19 @@ export class ResidentAgentProcess {
|
|
|
184
192
|
if (child.pid === undefined)
|
|
185
193
|
finish(null, null);
|
|
186
194
|
});
|
|
187
|
-
child.on("
|
|
195
|
+
child.stdin?.on("error", (error) => {
|
|
196
|
+
if (this.child !== child)
|
|
197
|
+
return;
|
|
198
|
+
// Pipe errors are emitted by stdin, separately from ChildProcess.error.
|
|
199
|
+
// Retire this room immediately; its eventual exit must not close a retry.
|
|
200
|
+
stopRoomProcess(child);
|
|
201
|
+
this.opts.onEvent({
|
|
202
|
+
type: "error",
|
|
203
|
+
error: `Claude Code input pipe closed${error.code ? ` (${error.code})` : ""}`,
|
|
204
|
+
});
|
|
205
|
+
finish(null, null);
|
|
206
|
+
});
|
|
207
|
+
child.on("close", (code, signal) => finish(code, signal));
|
|
188
208
|
}
|
|
189
209
|
/** Feed one user turn. Returns false if the process isn't running. */
|
|
190
210
|
send(text) {
|
|
@@ -206,24 +226,7 @@ export class ResidentAgentProcess {
|
|
|
206
226
|
stop() {
|
|
207
227
|
const child = this.child;
|
|
208
228
|
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
|
-
}
|
|
229
|
+
if (child)
|
|
230
|
+
stopRoomProcess(child);
|
|
228
231
|
}
|
|
229
232
|
}
|
|
@@ -890,8 +890,10 @@ export class RoomManager {
|
|
|
890
890
|
catch {
|
|
891
891
|
throw new Error("linked session transcript is unavailable");
|
|
892
892
|
}
|
|
893
|
-
if (!resolvedTarget
|
|
894
|
-
|
|
893
|
+
if (!resolvedTarget) {
|
|
894
|
+
throw new Error("linked session transcript was not found for the requested session");
|
|
895
|
+
}
|
|
896
|
+
if (typeof resolvedTarget.externalSessionId !== "string" ||
|
|
895
897
|
typeof resolvedTarget.cwd !== "string" ||
|
|
896
898
|
resolvedTarget.externalSessionId !== target.externalSessionId ||
|
|
897
899
|
resolvedTarget.kind !== target.kind ||
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Retire an unusable room transport without leaving an unresponsive CLI alive. */
|
|
2
|
+
export function stopRoomProcess(child) {
|
|
3
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
4
|
+
return;
|
|
5
|
+
const signal = (value) => {
|
|
6
|
+
if (child.pid && child.pid > 1 && process.platform !== "win32") {
|
|
7
|
+
try {
|
|
8
|
+
process.kill(-child.pid, value);
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
/* A failed spawn or an exited group falls back to the exact child. */
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
child.kill(value);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* already gone */
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
signal("SIGTERM");
|
|
23
|
+
if (!child.pid)
|
|
24
|
+
return;
|
|
25
|
+
const timer = setTimeout(() => signal("SIGKILL"), 1_000);
|
|
26
|
+
timer.unref();
|
|
27
|
+
child.once("exit", () => clearTimeout(timer));
|
|
28
|
+
}
|
|
@@ -96,7 +96,8 @@ export declare class PanelAppAgentTaskService {
|
|
|
96
96
|
get(owner: PanelAgentTaskOwner, id: unknown): PanelAgentTaskView;
|
|
97
97
|
cancel(owner: PanelAgentTaskOwner, id: unknown): Promise<PanelAgentTaskView>;
|
|
98
98
|
rebind(owner: PanelAgentTaskOwner): void;
|
|
99
|
-
|
|
99
|
+
/** `projects` limits cancellation to tasks bound to those (canonical) projects. */
|
|
100
|
+
cancelApp(appId: string, projects?: ReadonlySet<string>): void;
|
|
100
101
|
private execute;
|
|
101
102
|
private scopedTask;
|
|
102
103
|
private emit;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { panelExecutionGate } from "./execution-gate.js";
|
|
1
|
+
import { panelExecutionGate, panelExecutionProject } from "./execution-gate.js";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
const MAX_TASKS = 200;
|
|
4
4
|
const MAX_PROMPT_CHARS = 20_000;
|
|
@@ -131,7 +131,11 @@ export class PanelAppAgentTaskService {
|
|
|
131
131
|
cancelRequested: false,
|
|
132
132
|
activity: [],
|
|
133
133
|
};
|
|
134
|
-
const release = panelExecutionGate.enter(
|
|
134
|
+
const release = panelExecutionGate.enter({
|
|
135
|
+
appId: owner.appId,
|
|
136
|
+
projectPath: owner.projectPath,
|
|
137
|
+
holder: { kind: "agent-task", guestId: owner.guestId },
|
|
138
|
+
});
|
|
135
139
|
this.tasks.set(task.id, task);
|
|
136
140
|
this.trimHistory();
|
|
137
141
|
this.emit(task);
|
|
@@ -174,10 +178,13 @@ export class PanelAppAgentTaskService {
|
|
|
174
178
|
this.runtime.rebind(task.sessionId, task.owner);
|
|
175
179
|
}
|
|
176
180
|
}
|
|
177
|
-
|
|
181
|
+
/** `projects` limits cancellation to tasks bound to those (canonical) projects. */
|
|
182
|
+
cancelApp(appId, projects) {
|
|
178
183
|
for (const task of this.tasks.values()) {
|
|
179
184
|
if (task.owner.appId !== appId || TERMINAL_STATUSES.has(task.status))
|
|
180
185
|
continue;
|
|
186
|
+
if (projects && !projects.has(panelExecutionProject(task.owner.projectPath)))
|
|
187
|
+
continue;
|
|
181
188
|
task.cancelRequested = true;
|
|
182
189
|
task.status = "cancelling";
|
|
183
190
|
task.updatedAt = this.now();
|
|
@@ -1,13 +1,29 @@
|
|
|
1
|
+
/** What holds an execution, so a refused change can name it. Host-assigned only. */
|
|
2
|
+
export interface PanelExecutionHolder {
|
|
3
|
+
kind: "panel-call" | "agent-tool" | "process" | "agent-task" | "tool-job" | "automation";
|
|
4
|
+
/** Desktop WebContents id of the Panel instance that owns this execution. */
|
|
5
|
+
guestId?: number;
|
|
6
|
+
/** Trusted Host description, such as a native tool entry name. */
|
|
7
|
+
label?: string;
|
|
8
|
+
}
|
|
1
9
|
export interface PanelExecutionScope {
|
|
2
10
|
appId: string;
|
|
3
11
|
/** Host-verified binding project, never a directory supplied by the guest. */
|
|
4
12
|
projectPath: string;
|
|
13
|
+
holder?: PanelExecutionHolder;
|
|
14
|
+
}
|
|
15
|
+
export interface PanelExecutionBlocker extends PanelExecutionScope {
|
|
16
|
+
count: number;
|
|
5
17
|
}
|
|
6
18
|
/** Existing path aliases must not create separate admission domains. */
|
|
7
19
|
export declare function panelExecutionProject(path: string): string;
|
|
8
20
|
export declare class PanelExecutionBusyError extends Error {
|
|
21
|
+
/** Present when executions block a change; empty while another change runs. */
|
|
22
|
+
readonly blockers: PanelExecutionBlocker[];
|
|
9
23
|
readonly status = 409;
|
|
10
|
-
constructor(message: string
|
|
24
|
+
constructor(message: string,
|
|
25
|
+
/** Present when executions block a change; empty while another change runs. */
|
|
26
|
+
blockers?: PanelExecutionBlocker[]);
|
|
11
27
|
}
|
|
12
28
|
/** One process owns admission for every Panel runtime sharing its installed catalog.
|
|
13
29
|
* Acquire synchronously before authorization/IO; release after real execution cleanup,
|
|
@@ -21,6 +37,8 @@ export declare class PanelExecutionGate {
|
|
|
21
37
|
run<T>(scope: PanelExecutionScope, work: () => Promise<T>): Promise<T>;
|
|
22
38
|
/** Durable queues remain occupied even while paused and between process attempts. */
|
|
23
39
|
register(source: () => Iterable<PanelExecutionScope>): () => void;
|
|
40
|
+
/** Executions that a change matching `matches` would wait for, grouped by holder. */
|
|
41
|
+
blockers(matches: (scope: PanelExecutionScope) => boolean): PanelExecutionBlocker[];
|
|
24
42
|
mutate<T>(matches: (scope: PanelExecutionScope) => boolean, work: () => Promise<T>): Promise<T>;
|
|
25
43
|
}
|
|
26
44
|
export declare const panelExecutionGate: PanelExecutionGate;
|
|
@@ -10,9 +10,13 @@ export function panelExecutionProject(path) {
|
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
export class PanelExecutionBusyError extends Error {
|
|
13
|
+
blockers;
|
|
13
14
|
status = 409;
|
|
14
|
-
constructor(message
|
|
15
|
+
constructor(message,
|
|
16
|
+
/** Present when executions block a change; empty while another change runs. */
|
|
17
|
+
blockers = []) {
|
|
15
18
|
super(message);
|
|
19
|
+
this.blockers = blockers;
|
|
16
20
|
this.name = "PanelExecutionBusyError";
|
|
17
21
|
}
|
|
18
22
|
}
|
|
@@ -25,7 +29,11 @@ export class PanelExecutionGate {
|
|
|
25
29
|
sources = new Set();
|
|
26
30
|
mutations = new Set();
|
|
27
31
|
enter(scope) {
|
|
28
|
-
const held = {
|
|
32
|
+
const held = {
|
|
33
|
+
appId: scope.appId,
|
|
34
|
+
projectPath: panelExecutionProject(scope.projectPath),
|
|
35
|
+
...(scope.holder ? { holder: { ...scope.holder } } : {}),
|
|
36
|
+
};
|
|
29
37
|
if ([...this.mutations].some((matches) => matches(held)))
|
|
30
38
|
throw new PanelExecutionBusyError("面板正在更新,请完成后重试。");
|
|
31
39
|
this.executions.add(held);
|
|
@@ -49,13 +57,33 @@ export class PanelExecutionGate {
|
|
|
49
57
|
this.sources.delete(source);
|
|
50
58
|
};
|
|
51
59
|
}
|
|
60
|
+
/** Executions that a change matching `matches` would wait for, grouped by holder. */
|
|
61
|
+
blockers(matches) {
|
|
62
|
+
const grouped = new Map();
|
|
63
|
+
const scopes = [...this.executions, ...[...this.sources].flatMap((source) => [...source()])];
|
|
64
|
+
for (const scope of scopes) {
|
|
65
|
+
if (!matches(scope))
|
|
66
|
+
continue;
|
|
67
|
+
const blocker = {
|
|
68
|
+
appId: scope.appId,
|
|
69
|
+
projectPath: scope.projectPath,
|
|
70
|
+
...(scope.holder ? { holder: { ...scope.holder } } : {}),
|
|
71
|
+
};
|
|
72
|
+
const key = JSON.stringify(blocker), existing = grouped.get(key);
|
|
73
|
+
if (existing)
|
|
74
|
+
existing.count++;
|
|
75
|
+
else
|
|
76
|
+
grouped.set(key, { ...blocker, count: 1 });
|
|
77
|
+
}
|
|
78
|
+
return [...grouped.values()];
|
|
79
|
+
}
|
|
52
80
|
async mutate(matches, work) {
|
|
53
81
|
if (this.mutations.size)
|
|
54
82
|
throw new PanelExecutionBusyError("另一个面板修改正在进行,请稍后重试。");
|
|
55
83
|
// No await between the snapshot and installing the admission barrier.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
throw new PanelExecutionBusyError("面板还有正在提交、排队或运行的任务,请结束后再修改。");
|
|
84
|
+
const blockers = this.blockers(matches);
|
|
85
|
+
if (blockers.length)
|
|
86
|
+
throw new PanelExecutionBusyError("面板还有正在提交、排队或运行的任务,请结束后再修改。", blockers);
|
|
59
87
|
this.mutations.add(matches);
|
|
60
88
|
try {
|
|
61
89
|
return await work();
|
|
@@ -214,12 +214,21 @@ export class ResourceUploadIngest {
|
|
|
214
214
|
}
|
|
215
215
|
async cleanupExpired() {
|
|
216
216
|
await this.initialize();
|
|
217
|
-
for (const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
217
|
+
for (const key of [...this.records.keys()]) {
|
|
218
|
+
// Another upload may have renewed while cleanup waited on an earlier
|
|
219
|
+
// entry. Only the current expired record can cancel a running finish.
|
|
220
|
+
const record = this.records.get(key);
|
|
221
|
+
if (!record || record.expiresAt > this.options.now())
|
|
222
|
+
continue;
|
|
223
|
+
this.finishes.get(key)?.abort();
|
|
224
|
+
await this.locked(key, async () => {
|
|
225
|
+
// A write already holding this lock may acknowledge a chunk and renew
|
|
226
|
+
// the lease before cleanup enters. Never delete that renewed upload.
|
|
227
|
+
const current = this.records.get(key);
|
|
228
|
+
if (current && current.expiresAt <= this.options.now())
|
|
229
|
+
await this.remove(current);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
223
232
|
}
|
|
224
233
|
async begin(scope, raw) {
|
|
225
234
|
await this.authorize(scope);
|
package/dist/panels/runtime.js
CHANGED
|
@@ -553,6 +553,7 @@ export function createPanelRuntime(options) {
|
|
|
553
553
|
acquireExecution: (owner) => panelExecutionGate.enter({
|
|
554
554
|
appId: owner.appId,
|
|
555
555
|
projectPath: options.bindingCwd ?? options.cwd,
|
|
556
|
+
holder: { kind: "process" },
|
|
556
557
|
}),
|
|
557
558
|
approvalScope: "guest",
|
|
558
559
|
resolvePackageEntry: async (owner, name) => {
|
|
@@ -941,7 +942,11 @@ export function createPanelRuntime(options) {
|
|
|
941
942
|
};
|
|
942
943
|
}
|
|
943
944
|
async function onPanelAction(scope, input) {
|
|
944
|
-
return panelExecutionGate.run({
|
|
945
|
+
return panelExecutionGate.run({
|
|
946
|
+
appId: scope.appId,
|
|
947
|
+
projectPath: options.bindingCwd ?? options.cwd,
|
|
948
|
+
holder: { kind: "panel-call" },
|
|
949
|
+
}, () => onPanelActionAdmitted(scope, input));
|
|
945
950
|
}
|
|
946
951
|
async function onPanelActionAdmitted(scope, input) {
|
|
947
952
|
const grant = grants.get(scope.instanceId);
|
|
@@ -980,6 +985,7 @@ export function createPanelRuntime(options) {
|
|
|
980
985
|
const releaseExecution = panelExecutionGate.enter({
|
|
981
986
|
appId: grant.app.id,
|
|
982
987
|
projectPath: options.bindingCwd ?? options.cwd,
|
|
988
|
+
holder: { kind: "agent-tool" },
|
|
983
989
|
});
|
|
984
990
|
const requestId = randomBytes(18).toString("base64url");
|
|
985
991
|
const value = await new Promise((done) => {
|
|
@@ -1309,7 +1315,11 @@ export function createPanelRuntime(options) {
|
|
|
1309
1315
|
}
|
|
1310
1316
|
}
|
|
1311
1317
|
async function call(grant, input) {
|
|
1312
|
-
return panelExecutionGate.run({
|
|
1318
|
+
return panelExecutionGate.run({
|
|
1319
|
+
appId: grant.app.id,
|
|
1320
|
+
projectPath: options.bindingCwd ?? options.cwd,
|
|
1321
|
+
holder: { kind: "panel-call" },
|
|
1322
|
+
}, () => callAdmitted(grant, input));
|
|
1313
1323
|
}
|
|
1314
1324
|
async function callAdmitted(grant, input) {
|
|
1315
1325
|
if (!(await authorized(grant)))
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { chmod, lstat, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { isAbsolute, join } from "node:path";
|
|
4
|
-
import { formatNetscapeCookies, parseCookieJar, } from "@cjhyy/code-shell-core";
|
|
4
|
+
import { formatNetscapeCookies, normalizeCookieDomain, cookieAppliesToHost, parseCookieJar, } from "@cjhyy/code-shell-core";
|
|
5
5
|
function targetUrl(raw) {
|
|
6
6
|
if (typeof raw !== "string" || raw.length > 2048)
|
|
7
7
|
throw new Error("Cookie selection requires an HTTPS URL");
|
|
@@ -16,16 +16,6 @@ function targetUrl(raw) {
|
|
|
16
16
|
throw new Error("Cookie selection requires an HTTPS URL");
|
|
17
17
|
return url;
|
|
18
18
|
}
|
|
19
|
-
function domain(raw) {
|
|
20
|
-
if (typeof raw !== "string")
|
|
21
|
-
return;
|
|
22
|
-
const value = raw.toLowerCase().replace(/^\./, "");
|
|
23
|
-
if (value.length > 253 ||
|
|
24
|
-
!value.includes(".") ||
|
|
25
|
-
!value.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)))
|
|
26
|
-
return;
|
|
27
|
-
return value;
|
|
28
|
-
}
|
|
29
19
|
function beneath(host, root) {
|
|
30
20
|
return host === root || host.endsWith(`.${root}`);
|
|
31
21
|
}
|
|
@@ -86,7 +76,7 @@ export class PanelTaskCookieService {
|
|
|
86
76
|
]))
|
|
87
77
|
.digest("hex");
|
|
88
78
|
}
|
|
89
|
-
cookies(credential,
|
|
79
|
+
cookies(credential, host) {
|
|
90
80
|
const now = (this.options.now?.() ?? Date.now()) / 1000;
|
|
91
81
|
if (typeof credential.secret !== "string" ||
|
|
92
82
|
Buffer.byteLength(credential.secret) > 4 * 1024 * 1024)
|
|
@@ -106,8 +96,10 @@ export class PanelTaskCookieService {
|
|
|
106
96
|
!cookie.path.startsWith("/") ||
|
|
107
97
|
/[\x00-\x1f\x7f]/.test(cookie.path))))
|
|
108
98
|
return false;
|
|
109
|
-
|
|
110
|
-
|
|
99
|
+
// Account eligibility and actual Cookie scope are different fences:
|
|
100
|
+
// a login captured on www.example.com can carry .example.com cookies,
|
|
101
|
+
// but must not export host-only cookies from a sibling login host.
|
|
102
|
+
if (!cookieAppliesToHost(cookie, host))
|
|
111
103
|
return false;
|
|
112
104
|
if (cookie.expirationDate !== undefined &&
|
|
113
105
|
(typeof cookie.expirationDate !== "number" ||
|
|
@@ -120,10 +112,10 @@ export class PanelTaskCookieService {
|
|
|
120
112
|
async snapshot(scope, url) {
|
|
121
113
|
await this.options.authorize(scope);
|
|
122
114
|
const result = (await this.options.credentials(scope)).flatMap((credential) => {
|
|
123
|
-
const root =
|
|
115
|
+
const root = normalizeCookieDomain(credential.meta?.domain);
|
|
124
116
|
if (credential.type !== "cookie" || !root || !beneath(url.hostname.toLowerCase(), root))
|
|
125
117
|
return [];
|
|
126
|
-
const cookies = this.cookies(credential,
|
|
118
|
+
const cookies = this.cookies(credential, url.hostname);
|
|
127
119
|
if (!cookies.length)
|
|
128
120
|
return [];
|
|
129
121
|
return [
|
package/dist/panels/tool-jobs.js
CHANGED
|
@@ -179,11 +179,16 @@ export class PanelToolJobService {
|
|
|
179
179
|
};
|
|
180
180
|
}
|
|
181
181
|
executionScopes() {
|
|
182
|
+
const held = (scope, label) => ({
|
|
183
|
+
appId: scope.appId,
|
|
184
|
+
projectPath: scope.projectPath,
|
|
185
|
+
holder: { kind: "tool-job", ...(label ? { label } : {}) },
|
|
186
|
+
});
|
|
182
187
|
return [
|
|
183
|
-
...[...this.preparing.values()].map((item) => item.scope),
|
|
188
|
+
...[...this.preparing.values()].map((item) => held(item.scope)),
|
|
184
189
|
...[...this.jobs.values()]
|
|
185
190
|
.filter((job) => !TERMINAL.has(job.status) || this.active.has(job.id))
|
|
186
|
-
.map((job) => job.scope),
|
|
191
|
+
.map((job) => held(job.scope, job.entry.name)),
|
|
187
192
|
];
|
|
188
193
|
}
|
|
189
194
|
activeCount(projectPath) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cjhyy/code-shell-server",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.47",
|
|
4
4
|
"description": "CodeShell server transport layer — mobile remote HTTP/WS host, pairing, passcode gate, tunnel, rooms, uploads, and session/attachment services. Electron-free; precursor of the auth gateway.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -62,14 +62,14 @@
|
|
|
62
62
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@cjhyy/code-shell-capability-coding": "0.9.
|
|
66
|
-
"@cjhyy/code-shell-core": "0.9.
|
|
67
|
-
"@cjhyy/code-shell-web": "0.9.
|
|
65
|
+
"@cjhyy/code-shell-capability-coding": "0.9.47",
|
|
66
|
+
"@cjhyy/code-shell-core": "0.9.47",
|
|
67
|
+
"@cjhyy/code-shell-web": "0.9.47",
|
|
68
68
|
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
69
69
|
"@types/ws": "^8.18.1",
|
|
70
70
|
"ws": "^8.21.0",
|
|
71
|
-
"@cjhyy/code-shell-link": "0.9.
|
|
72
|
-
"@cjhyy/code-shell-host": "0.9.
|
|
71
|
+
"@cjhyy/code-shell-link": "0.9.47",
|
|
72
|
+
"@cjhyy/code-shell-host": "0.9.47"
|
|
73
73
|
},
|
|
74
74
|
"engines": {
|
|
75
75
|
"node": ">=20.10"
|