@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.
@@ -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
- this.turnSealed = false; // fresh turn — not yet sealed
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
- this.turnSealed = true;
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
- this.turnSealed = true; // this error seals the run in the reducer
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("exit", (code, signal) => {
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
- this.turnSealed = true;
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: this.turnSealed,
178
- stopping: this.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 (!child?.pid || child.pid <= 1) {
198
- child?.kill("SIGTERM");
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 = req.url
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 ? { capabilities: { outputJournal: 1 } } : {}),
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 ? { capabilities: { outputJournal: 1 } } : {}),
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 === child)
171
- this.child = undefined;
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("exit", (code, signal) => finish(code, signal));
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
- // Guard pid > 1: process.kill(-1) would SIGTERM every process the user can
210
- // reach, and -0 the caller's own group. `!child?.pid` already catches
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
- typeof resolvedTarget.externalSessionId !== "string" ||
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,3 @@
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;
@@ -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
- cancelApp(appId: string): void;
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(owner);
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
- cancelApp(appId) {
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 = { appId: scope.appId, projectPath: panelExecutionProject(scope.projectPath) };
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
- if ([...this.executions].some(matches) ||
57
- [...this.sources].some((source) => [...source()].some(matches)))
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();
@@ -98,6 +98,7 @@ export function createHubPanelAutomationHost(options) {
98
98
  releaseExecution = panelExecutionGate.enter({
99
99
  appId: job.panelSource.appId,
100
100
  projectPath: options.bindingCwd ?? cwd,
101
+ holder: { kind: "automation" },
101
102
  });
102
103
  }
103
104
  try {
@@ -214,12 +214,21 @@ export class ResourceUploadIngest {
214
214
  }
215
215
  async cleanupExpired() {
216
216
  await this.initialize();
217
- for (const record of [...this.records.values()])
218
- if (record.expiresAt <= this.options.now()) {
219
- const key = this.key(record.scope, record.sessionId);
220
- this.finishes.get(key)?.abort();
221
- await this.locked(key, () => this.remove(record));
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);
@@ -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({ appId: scope.appId, projectPath: options.bindingCwd ?? options.cwd }, () => onPanelActionAdmitted(scope, input));
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({ appId: grant.app.id, projectPath: options.bindingCwd ?? options.cwd }, () => callAdmitted(grant, input));
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, root) {
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
- const cookieDomain = domain(cookie.domain);
110
- if (!cookieDomain || !beneath(cookieDomain, root))
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 = domain(credential.meta?.domain);
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, root);
118
+ const cookies = this.cookies(credential, url.hostname);
127
119
  if (!cookies.length)
128
120
  return [];
129
121
  return [
@@ -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.46",
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.46",
66
- "@cjhyy/code-shell-core": "0.9.46",
67
- "@cjhyy/code-shell-web": "0.9.46",
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.46",
72
- "@cjhyy/code-shell-host": "0.9.46"
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"