@cjhyy/code-shell-core 0.8.8 → 0.8.10

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.
Files changed (86) hide show
  1. package/dist/automation/scheduler.js +49 -0
  2. package/dist/automation/store.d.ts +1 -1
  3. package/dist/automation/store.js +184 -10
  4. package/dist/cli/agent-server-stdio.js +7 -0
  5. package/dist/credentials/store.d.ts +14 -0
  6. package/dist/credentials/store.js +245 -42
  7. package/dist/engine/engine.js +81 -27
  8. package/dist/engine/file-history-hook.js +24 -5
  9. package/dist/engine/run-finalize.js +8 -6
  10. package/dist/engine/run-session-open.js +1 -1
  11. package/dist/engine/run-setup.d.ts +4 -0
  12. package/dist/engine/run-setup.js +5 -1
  13. package/dist/engine/run-tooling.js +7 -1
  14. package/dist/engine/run-types.d.ts +33 -0
  15. package/dist/engine/run-types.js +21 -0
  16. package/dist/engine/run-workspace.js +11 -5
  17. package/dist/engine/turn-loop.js +9 -8
  18. package/dist/goal/lifecycle.d.ts +2 -0
  19. package/dist/goal/lifecycle.js +56 -33
  20. package/dist/index.d.ts +2 -3
  21. package/dist/index.internal.d.ts +1 -0
  22. package/dist/index.internal.js +1 -0
  23. package/dist/index.js +2 -2
  24. package/dist/links/cli.d.ts +2 -0
  25. package/dist/links/cli.js +11 -4
  26. package/dist/model-catalog/index.js +19 -4
  27. package/dist/model-catalog/save-entry.js +122 -61
  28. package/dist/model-catalog/types.js +27 -23
  29. package/dist/panel-apps/installer.js +27 -14
  30. package/dist/panel-apps/manifest.d.ts +11 -11
  31. package/dist/panel-apps/manifest.js +3 -1
  32. package/dist/panel-apps/registry.js +60 -12
  33. package/dist/plugins/installedPlugins.d.ts +4 -0
  34. package/dist/plugins/installedPlugins.js +70 -30
  35. package/dist/plugins/installer/types.d.ts +12 -12
  36. package/dist/plugins/installer/update.js +37 -38
  37. package/dist/plugins/knownMarketplaces.d.ts +7 -3
  38. package/dist/plugins/knownMarketplaces.js +127 -23
  39. package/dist/plugins/pluginCatalog.js +18 -4
  40. package/dist/plugins/pluginHookApproval.js +56 -60
  41. package/dist/plugins/pluginMcpApproval.js +50 -52
  42. package/dist/profile/catalog-store.js +39 -4
  43. package/dist/profile/catalog.js +55 -15
  44. package/dist/profile/store.js +51 -21
  45. package/dist/prompt/composer.d.ts +8 -0
  46. package/dist/prompt/composer.js +8 -0
  47. package/dist/protocol/chat-session-manager.d.ts +9 -0
  48. package/dist/protocol/chat-session-manager.js +22 -0
  49. package/dist/protocol/chat-session.d.ts +5 -0
  50. package/dist/protocol/chat-session.js +1 -0
  51. package/dist/protocol/server.d.ts +2 -0
  52. package/dist/protocol/server.js +112 -29
  53. package/dist/protocol/types.d.ts +20 -0
  54. package/dist/run/FileRunStore.d.ts +2 -0
  55. package/dist/run/FileRunStore.js +153 -18
  56. package/dist/run/Heartbeat.js +63 -4
  57. package/dist/services/auto-dream.js +39 -17
  58. package/dist/services/session-memory.js +107 -8
  59. package/dist/session/file-history.d.ts +63 -2
  60. package/dist/session/file-history.js +593 -86
  61. package/dist/session/session-manager.d.ts +4 -1
  62. package/dist/session/session-manager.js +94 -47
  63. package/dist/session/transcript.js +33 -3
  64. package/dist/session/undo-target.d.ts +15 -6
  65. package/dist/session/undo-target.js +26 -9
  66. package/dist/settings/manager.d.ts +22 -3
  67. package/dist/settings/manager.js +185 -50
  68. package/dist/settings/schema.d.ts +3 -3
  69. package/dist/sources/adapters/local-files.js +49 -4
  70. package/dist/sources/catalog.js +64 -18
  71. package/dist/sources/types.d.ts +3 -3
  72. package/dist/sources/types.js +7 -4
  73. package/dist/themes/installer.js +192 -28
  74. package/dist/tool-system/builtin/add-marketplace.js +21 -1
  75. package/dist/tool-system/builtin/cron.d.ts +2 -1
  76. package/dist/tool-system/builtin/cron.js +20 -6
  77. package/dist/tool-system/builtin/index.js +48 -1
  78. package/dist/tool-system/builtin/install-capability.d.ts +52 -0
  79. package/dist/tool-system/builtin/install-capability.js +1057 -0
  80. package/dist/tool-system/builtin/skill.js +3 -1
  81. package/dist/tool-system/executor.js +1 -0
  82. package/dist/tool-system/path-policy.js +25 -33
  83. package/dist/tool-system/registry.js +5 -0
  84. package/dist/utils/file-mutex.d.ts +2 -0
  85. package/dist/utils/file-mutex.js +29 -4
  86. package/package.json +2 -1
@@ -95,6 +95,15 @@ export declare class ChatSessionManager {
95
95
  * the values so a callback that closes a session can't perturb the walk.
96
96
  */
97
97
  forEachSession(fn: (s: ChatSession) => void): void;
98
+ /** First live Engine for legacy fallbacks that genuinely accept any owner. */
99
+ getAnyLiveEngine(): Engine | undefined;
100
+ /**
101
+ * Build a detached Engine for global model/provider/settings queries. Keeping
102
+ * this seam on the manager avoids protocol code reaching into its private
103
+ * factory and, importantly, avoids borrowing project-local state from an
104
+ * arbitrary live session.
105
+ */
106
+ createDetachedEngine(slice?: Partial<EngineConfigSlice>): Engine;
98
107
  /** Snapshot-safe, read-only source shared by query("sessions") and Pet projection. */
99
108
  getLiveSessionSnapshot(): LiveChatSessionSnapshot;
100
109
  close(sessionId: string): Promise<void>;
@@ -164,10 +164,26 @@ export class ChatSessionManager {
164
164
  for (const s of [...this.sessions.values()])
165
165
  fn(s);
166
166
  }
167
+ /** First live Engine for legacy fallbacks that genuinely accept any owner. */
168
+ getAnyLiveEngine() {
169
+ return this.sessions.values().next().value?.engine;
170
+ }
171
+ /**
172
+ * Build a detached Engine for global model/provider/settings queries. Keeping
173
+ * this seam on the manager avoids protocol code reaching into its private
174
+ * factory and, importantly, avoids borrowing project-local state from an
175
+ * arbitrary live session.
176
+ */
177
+ createDetachedEngine(slice = {}) {
178
+ return this.factory(slice);
179
+ }
167
180
  /** Snapshot-safe, read-only source shared by query("sessions") and Pet projection. */
168
181
  getLiveSessionSnapshot() {
169
182
  const sessions = [];
170
183
  this.forEachSession((session) => {
184
+ const sessionManager = this.engineSessionManager(session.engine);
185
+ if (sessionManager?.isEphemeralSession?.(session.id))
186
+ return;
171
187
  sessions.push({
172
188
  sessionId: session.id,
173
189
  busy: session.isBusy(),
@@ -280,6 +296,12 @@ export class ChatSessionManager {
280
296
  sweepIdle() {
281
297
  const cutoff = Date.now() - this.idleTtlMs;
282
298
  for (const [id, s] of [...this.sessions]) {
299
+ // Quick Chat transcripts are process-local by design. Closing one from
300
+ // the generic idle sweeper destroys its inherited context while the
301
+ // renderer still owns (and displays) the panel. Its explicit claim/
302
+ // panel lifecycle is the sole authority that may close it.
303
+ if (id.startsWith("qchat-"))
304
+ continue;
283
305
  if (s.lastActivityAt >= cutoff)
284
306
  continue;
285
307
  if (s.isBusy())
@@ -33,6 +33,11 @@ export interface TurnOpts {
33
33
  injected?: boolean;
34
34
  /** Stable id for this user-intent; forwarded to Engine.run for idempotency. */
35
35
  clientMessageId?: string;
36
+ /** Archive history before this turn at the engine's pre-model safe point. */
37
+ archiveBeforeCurrentTurn?: {
38
+ fromClientMessageId?: string;
39
+ segmentId?: string;
40
+ };
36
41
  /** Structured input attachments for this turn. */
37
42
  attachments?: InputAttachmentMeta[];
38
43
  /** Permission mode snapshot for this queued turn only. */
@@ -290,6 +290,7 @@ export class ChatSession {
290
290
  goal: next.opts.goal,
291
291
  injected: next.opts.injected,
292
292
  clientMessageId: next.opts.clientMessageId,
293
+ archiveBeforeCurrentTurn: next.opts.archiveBeforeCurrentTurn,
293
294
  attachments: next.opts.attachments,
294
295
  permissionMode: next.opts.permissionMode,
295
296
  planMode: next.opts.planMode,
@@ -375,6 +375,8 @@ export declare class AgentServer {
375
375
  * selector) can query models/providers without requiring a chat session first.
376
376
  */
377
377
  private anyEngine;
378
+ /** Neutral engine for truly global queries; never inherits a live project's cwd or policy. */
379
+ private detachedQueryEngine;
378
380
  close(): void;
379
381
  /** Release only this transport's approval ownership. Safe on socket close
380
382
  * even when the ChatSessionManager is shared by other TCP connections. */
@@ -58,10 +58,60 @@ function runInputError(params) {
58
58
  if (params.injected !== undefined && typeof params.injected !== "boolean") {
59
59
  return "injected must be a boolean";
60
60
  }
61
+ if (params.quickChatClaimId !== undefined &&
62
+ (typeof params.quickChatClaimId !== "string" || params.quickChatClaimId.length === 0)) {
63
+ return "quickChatClaimId must be a non-empty string";
64
+ }
65
+ if (params.archiveBeforeCurrentTurn !== undefined) {
66
+ const archive = params.archiveBeforeCurrentTurn;
67
+ if (!archive || typeof archive !== "object" || Array.isArray(archive)) {
68
+ return "archiveBeforeCurrentTurn must be an object";
69
+ }
70
+ if (typeof params.clientMessageId !== "string" || params.clientMessageId.length === 0) {
71
+ return "archiveBeforeCurrentTurn requires clientMessageId";
72
+ }
73
+ for (const [field, value] of [
74
+ ["fromClientMessageId", archive.fromClientMessageId],
75
+ ["segmentId", archive.segmentId],
76
+ ]) {
77
+ if (value !== undefined &&
78
+ (typeof value !== "string" ||
79
+ value.length === 0 ||
80
+ value.length > 256 ||
81
+ /[\r\n]/.test(value))) {
82
+ return `archiveBeforeCurrentTurn.${field} must be a bounded non-empty string`;
83
+ }
84
+ }
85
+ }
61
86
  if (params.behaviorMode !== undefined &&
62
87
  (typeof params.behaviorMode !== "string" || params.behaviorMode.length === 0)) {
63
88
  return `invalid behavior mode: ${String(params.behaviorMode)}`;
64
89
  }
90
+ for (const [field, value, maxItems] of [
91
+ ["toolAllowlist", params.toolAllowlist, 32],
92
+ ["skillAllowlist", params.skillAllowlist, 8],
93
+ ]) {
94
+ if (value === undefined)
95
+ continue;
96
+ if (!Array.isArray(value) ||
97
+ value.length > maxItems ||
98
+ value.some((name) => typeof name !== "string" || name.length === 0 || name.length > 128 || /[\r\n]/.test(name))) {
99
+ return `${field} must be an array of at most ${maxItems} bounded names`;
100
+ }
101
+ }
102
+ if (params.ephemeral !== undefined && typeof params.ephemeral !== "boolean") {
103
+ return "ephemeral must be a boolean";
104
+ }
105
+ if (params.maxTurns !== undefined &&
106
+ (!Number.isInteger(params.maxTurns) || params.maxTurns < 1 || params.maxTurns > 30)) {
107
+ return "maxTurns must be an integer from 1 to 30";
108
+ }
109
+ if (params.maxContextTokens !== undefined &&
110
+ (!Number.isInteger(params.maxContextTokens) ||
111
+ params.maxContextTokens < 4_096 ||
112
+ params.maxContextTokens > 131_072)) {
113
+ return "maxContextTokens must be an integer from 4096 to 131072";
114
+ }
65
115
  if (params.kind !== undefined && (typeof params.kind !== "string" || params.kind.length === 0)) {
66
116
  return `invalid session kind: ${String(params.kind)}`;
67
117
  }
@@ -75,6 +125,20 @@ function runInputError(params) {
75
125
  // extension-owned param shapes) lives in ExtensionModule.validateRunParams.
76
126
  return null;
77
127
  }
128
+ function goalExtensionInputError(params) {
129
+ const fields = ["addTurns", "addTokenBudget", "addTimeBudgetMs", "addStopBlocks"];
130
+ let supplied = false;
131
+ for (const field of fields) {
132
+ const value = params[field];
133
+ if (value === undefined)
134
+ continue;
135
+ supplied = true;
136
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
137
+ return `${field} must be a positive safe integer`;
138
+ }
139
+ }
140
+ return supplied ? null : "at least one goal extension is required";
141
+ }
78
142
  const COMPACT_STREAM_STRATEGIES = new Set([
79
143
  "micro",
80
144
  "summary",
@@ -1080,6 +1144,8 @@ export class AgentServer {
1080
1144
  const sessionConfig = {
1081
1145
  cwd: params.cwd,
1082
1146
  projectTrusted: params.projectTrusted,
1147
+ maxTurns: params.maxTurns,
1148
+ maxContextTokens: params.maxContextTokens,
1083
1149
  goal: typeof params.goal === "string" || (params.goal != null && typeof params.goal === "object")
1084
1150
  ? params.goal
1085
1151
  : undefined,
@@ -1160,9 +1226,13 @@ export class AgentServer {
1160
1226
  this.notify(Methods.StreamEvent, { sessionId: sid, event });
1161
1227
  },
1162
1228
  clientMessageId: typeof params.clientMessageId === "string" ? params.clientMessageId : undefined,
1229
+ archiveBeforeCurrentTurn: params.archiveBeforeCurrentTurn,
1163
1230
  permissionMode: params.permissionMode,
1164
1231
  planMode: params.planMode,
1165
1232
  behaviorMode: params.behaviorMode,
1233
+ toolAllowlist: params.toolAllowlist,
1234
+ skillAllowlist: params.skillAllowlist,
1235
+ ephemeral: params.ephemeral,
1166
1236
  profileParams: params.profileParams,
1167
1237
  workspaceProfile: params.workspaceProfile,
1168
1238
  sessionBrief: params.sessionBrief,
@@ -1290,6 +1360,9 @@ export class AgentServer {
1290
1360
  permissionMode: params.permissionMode,
1291
1361
  planMode: params.planMode,
1292
1362
  behaviorMode: params.behaviorMode,
1363
+ toolAllowlist: params.toolAllowlist,
1364
+ skillAllowlist: params.skillAllowlist,
1365
+ ephemeral: params.ephemeral,
1293
1366
  profileParams: params.profileParams,
1294
1367
  workspaceProfile: params.workspaceProfile,
1295
1368
  sessionBrief: params.sessionBrief,
@@ -1435,7 +1508,17 @@ export class AgentServer {
1435
1508
  * limits, or an error if no session / no active run.
1436
1509
  */
1437
1510
  handleGoalExtend(req) {
1438
- const params = (req.params ?? {});
1511
+ const rawParams = req.params;
1512
+ if (!rawParams || typeof rawParams !== "object" || Array.isArray(rawParams)) {
1513
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "goal extension params are required"));
1514
+ return;
1515
+ }
1516
+ const inputError = goalExtensionInputError(rawParams);
1517
+ if (inputError) {
1518
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, inputError));
1519
+ return;
1520
+ }
1521
+ const params = rawParams;
1439
1522
  const ext = {
1440
1523
  addTurns: params.addTurns,
1441
1524
  addTokenBudget: params.addTokenBudget,
@@ -2099,7 +2182,7 @@ export class AgentServer {
2099
2182
  resolved = session.engine;
2100
2183
  }
2101
2184
  else {
2102
- resolved = this.anyEngine();
2185
+ resolved ??= this.anyEngine();
2103
2186
  }
2104
2187
  }
2105
2188
  if (!resolved) {
@@ -2110,16 +2193,16 @@ export class AgentServer {
2110
2193
  }
2111
2194
  async handleQuery(req) {
2112
2195
  const params = (req.params ?? {});
2113
- // For query operations we prefer the legacyEngine; if absent borrow any
2114
- // session engine from the manager (model pool, settings are shared).
2115
- const engine = this.legacyEngine ?? this.anyEngine();
2196
+ // Global queries use a detached manager-owned engine. Session-scoped cases
2197
+ // below resolve the exact owner from params.sessionId instead of borrowing
2198
+ // whichever live project's engine happens to be first in a Map.
2199
+ const engine = this.legacyEngine ?? this.detachedQueryEngine();
2116
2200
  switch (params.type) {
2117
2201
  case "tools": {
2118
- if (!engine) {
2119
- this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, "No engine available for tools query"));
2202
+ const toolsEngine = await this.resolveEngineForSessionQuery(req, params.sessionId, engine, "tools");
2203
+ if (!toolsEngine)
2120
2204
  return;
2121
- }
2122
- const registry = engine.getToolRegistry();
2205
+ const registry = toolsEngine.getToolRegistry();
2123
2206
  const tools = typeof registry.listToolsDetailed === "function"
2124
2207
  ? registry
2125
2208
  .listToolsDetailed()
@@ -2146,16 +2229,15 @@ export class AgentServer {
2146
2229
  break;
2147
2230
  }
2148
2231
  case "config": {
2149
- if (!engine) {
2150
- this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, "No engine available for config query"));
2232
+ const configEngine = await this.resolveEngineForSessionQuery(req, params.sessionId, engine, "config");
2233
+ if (!configEngine)
2151
2234
  return;
2152
- }
2153
- const config = engine.getConfig();
2235
+ const config = configEngine.getConfig();
2154
2236
  this.transport.send(createResponse(req.id, {
2155
2237
  type: "config",
2156
2238
  data: {
2157
2239
  permissionMode: config.permissionMode ?? "default",
2158
- planMode: engine.planMode ?? false,
2240
+ planMode: configEngine.planMode ?? false,
2159
2241
  preset: config.preset,
2160
2242
  model: config.llm.model,
2161
2243
  cwd: config.cwd,
@@ -2165,21 +2247,20 @@ export class AgentServer {
2165
2247
  llm: redactLlmConfig(config.llm),
2166
2248
  // Resolved feature flags (defaults merged with settings overlay)
2167
2249
  // so the /features command can list current state.
2168
- featureFlags: engine.getFeatureFlags(),
2250
+ featureFlags: configEngine.getFeatureFlags(),
2169
2251
  // Effective permission rules so /permissions can list them (TODO 5.1).
2170
- permissionRules: engine.getPermissionRules(),
2252
+ permissionRules: configEngine.getPermissionRules(),
2171
2253
  },
2172
2254
  }));
2173
2255
  break;
2174
2256
  }
2175
2257
  case "session_detail": {
2176
- if (!engine) {
2177
- this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, "No engine available for session_detail query"));
2178
- return;
2179
- }
2180
2258
  if (params.sessionId) {
2259
+ const detailEngine = await this.resolveEngineForSessionQuery(req, params.sessionId, engine, "session_detail");
2260
+ if (!detailEngine)
2261
+ return;
2181
2262
  try {
2182
- const bundle = engine.getSessionManager().resume(params.sessionId);
2263
+ const bundle = detailEngine.getSessionManager().resume(params.sessionId);
2183
2264
  const data = {
2184
2265
  state: bundle.state,
2185
2266
  transcript: bundle.transcript.getEvents(),
@@ -3127,16 +3208,18 @@ export class AgentServer {
3127
3208
  anyEngine() {
3128
3209
  if (!this.chatManager)
3129
3210
  return null;
3130
- const sessions = this.chatManager.sessions;
3131
- const first = sessions.values().next().value;
3132
- if (first)
3133
- return first.engine;
3211
+ const live = this.chatManager.getAnyLiveEngine();
3212
+ if (live)
3213
+ return live;
3214
+ return this.detachedQueryEngine();
3215
+ }
3216
+ /** Neutral engine for truly global queries; never inherits a live project's cwd or policy. */
3217
+ detachedQueryEngine() {
3218
+ if (!this.chatManager)
3219
+ return null;
3134
3220
  if (this.globalQueryEngine)
3135
3221
  return this.globalQueryEngine;
3136
- const factory = this.chatManager.factory;
3137
- if (typeof factory !== "function")
3138
- return null;
3139
- this.globalQueryEngine = factory({});
3222
+ this.globalQueryEngine = this.chatManager.createDetachedEngine();
3140
3223
  return this.globalQueryEngine;
3141
3224
  }
3142
3225
  // ─── Lifecycle ──────────────────────────────────────────────────
@@ -80,6 +80,16 @@ export interface RunParams {
80
80
  attachments?: InputAttachmentMeta[];
81
81
  /** Stable id for the user's submit intent; duplicate ids are idempotent. */
82
82
  clientMessageId?: string;
83
+ /**
84
+ * Archive the preceding history after this message is durably appended and
85
+ * before its first model call. `clientMessageId` is the exclusive end anchor.
86
+ */
87
+ archiveBeforeCurrentTurn?: {
88
+ fromClientMessageId?: string;
89
+ segmentId?: string;
90
+ };
91
+ /** Desktop host ownership generation for process-local Quick Chat runs. */
92
+ quickChatClaimId?: string;
83
93
  /**
84
94
  * Host-generated system reminder. Persisted with an injected marker so disk
85
95
  * transcript readers hide it as a user bubble while retaining the assistant reply.
@@ -116,6 +126,16 @@ export interface RunParams {
116
126
  planMode?: boolean;
117
127
  /** Named behavior profile for a product-specific per-run interaction mode. */
118
128
  behaviorMode?: RunBehaviorMode;
129
+ /** Host-authorized per-run hard tool allowlist. Empty means no tools. */
130
+ toolAllowlist?: string[];
131
+ /** Host-authorized per-run hard Skill allowlist. Empty means no Skills. */
132
+ skillAllowlist?: string[];
133
+ /** Keep a fresh run Session process-local and out of normal Session pickers. */
134
+ ephemeral?: boolean;
135
+ /** Optional fresh-session turn ceiling used by bounded host Tasks. */
136
+ maxTurns?: number;
137
+ /** Optional fresh-session context ceiling used by bounded host Tasks. */
138
+ maxContextTokens?: number;
119
139
  /**
120
140
  * Generic per-run parameters consumed by the active behavior profile
121
141
  * (delivered as non-durable run context; never appended to the task or
@@ -18,6 +18,8 @@ export declare class FileRunStore implements RunStore {
18
18
  private ensureRunDir;
19
19
  private writeJson;
20
20
  private readJson;
21
+ private assertRealDirectory;
22
+ private assertSafeFileTarget;
21
23
  /** Serializes concurrent JSONL appends per file path. */
22
24
  private readonly appendLocks;
23
25
  private appendJsonl;
@@ -9,34 +9,54 @@
9
9
  * approvals/<id>.json — approval records
10
10
  * artifacts/refs.jsonl — artifact reference log
11
11
  */
12
- import { mkdirSync, existsSync, readFileSync, writeFileSync, appendFileSync, readdirSync, renameSync, rmSync, } from "node:fs";
13
- import { join } from "node:path";
12
+ import { mkdirSync, existsSync, readFileSync, writeFileSync, appendFileSync, closeSync, chmodSync, constants, fchmodSync, readdirSync, fstatSync, openSync, readSync, renameSync, rmSync, lstatSync, } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
14
  import { homedir } from "node:os";
15
15
  import { randomUUID } from "node:crypto";
16
16
  import { assertSafeRunFileId, assertSafeRunId } from "./ids.js";
17
+ const MAX_RUN_JSON_BYTES = 8 * 1024 * 1024;
18
+ const MAX_RUN_JSONL_BYTES = 128 * 1024 * 1024;
19
+ const MAX_RUN_JSONL_RECORD_BYTES = 2 * 1024 * 1024;
20
+ const MAX_RUN_DIRECTORIES = 100_000;
21
+ const MAX_RUN_CHILD_FILES = 100_000;
17
22
  export class FileRunStore {
18
23
  runsDir;
19
24
  constructor(storageDir) {
20
25
  this.runsDir = storageDir ?? join(homedir(), ".code-shell", "runs");
21
- mkdirSync(this.runsDir, { recursive: true });
26
+ mkdirSync(this.runsDir, { recursive: true, mode: 0o700 });
27
+ this.assertRealDirectory(this.runsDir);
28
+ if (process.platform !== "win32")
29
+ chmodSync(this.runsDir, 0o700);
22
30
  }
23
31
  // ─── Helpers ───────────────────────────────────────────────────
24
32
  runDir(runId) {
25
33
  assertSafeRunId(runId);
26
- return join(this.runsDir, runId);
34
+ const dir = join(this.runsDir, runId);
35
+ if (existsSync(dir))
36
+ this.assertRealDirectory(dir);
37
+ return dir;
27
38
  }
28
39
  ensureRunDir(runId) {
29
40
  const dir = this.runDir(runId);
30
- mkdirSync(dir, { recursive: true });
31
- mkdirSync(join(dir, "checkpoints"), { recursive: true });
32
- mkdirSync(join(dir, "approvals"), { recursive: true });
33
- mkdirSync(join(dir, "artifacts"), { recursive: true });
41
+ const directories = [dir, join(dir, "checkpoints"), join(dir, "approvals"), join(dir, "artifacts")];
42
+ for (const directory of directories) {
43
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
44
+ this.assertRealDirectory(directory);
45
+ if (process.platform !== "win32")
46
+ chmodSync(directory, 0o700);
47
+ }
34
48
  return dir;
35
49
  }
36
50
  writeJson(filePath, data) {
51
+ this.assertRealDirectory(dirname(filePath));
52
+ this.assertSafeFileTarget(filePath);
53
+ const serialized = JSON.stringify(data, null, 2);
54
+ if (Buffer.byteLength(serialized, "utf8") > MAX_RUN_JSON_BYTES) {
55
+ throw new Error(`Run JSON exceeds ${MAX_RUN_JSON_BYTES} bytes`);
56
+ }
37
57
  const tmp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
38
58
  try {
39
- writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
59
+ writeFileSync(tmp, serialized, { encoding: "utf-8", mode: 0o600, flag: "wx" });
40
60
  // Atomic rename — prevents partial writes on crash
41
61
  renameSync(tmp, filePath);
42
62
  }
@@ -49,7 +69,42 @@ export class FileRunStore {
49
69
  readJson(filePath) {
50
70
  if (!existsSync(filePath))
51
71
  return null;
52
- return JSON.parse(readFileSync(filePath, "utf-8"));
72
+ this.assertRealDirectory(dirname(filePath));
73
+ let fd;
74
+ try {
75
+ const entry = lstatSync(filePath);
76
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > MAX_RUN_JSON_BYTES) {
77
+ throw new Error(`Run JSON is not a bounded regular file: ${filePath}`);
78
+ }
79
+ fd = openSync(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
80
+ const opened = fstatSync(fd);
81
+ if (!opened.isFile() || opened.size > MAX_RUN_JSON_BYTES) {
82
+ throw new Error(`Run JSON is not a bounded regular file: ${filePath}`);
83
+ }
84
+ return JSON.parse(readFileSync(fd, "utf-8"));
85
+ }
86
+ finally {
87
+ if (fd !== undefined)
88
+ closeSync(fd);
89
+ }
90
+ }
91
+ assertRealDirectory(directory) {
92
+ const info = lstatSync(directory);
93
+ if (info.isSymbolicLink() || !info.isDirectory()) {
94
+ throw new Error(`Run storage path is not a real directory: ${directory}`);
95
+ }
96
+ }
97
+ assertSafeFileTarget(filePath) {
98
+ try {
99
+ const info = lstatSync(filePath);
100
+ if (info.isSymbolicLink() || !info.isFile()) {
101
+ throw new Error(`Run storage target is not a regular file: ${filePath}`);
102
+ }
103
+ }
104
+ catch (error) {
105
+ if (error.code !== "ENOENT")
106
+ throw error;
107
+ }
53
108
  }
54
109
  /** Serializes concurrent JSONL appends per file path. */
55
110
  appendLocks = new Map();
@@ -68,7 +123,39 @@ export class FileRunStore {
68
123
  this.appendLocks.set(filePath, lock);
69
124
  try {
70
125
  await prev.catch(() => { });
71
- appendFileSync(filePath, JSON.stringify(data) + "\n", "utf-8");
126
+ this.assertRealDirectory(dirname(filePath));
127
+ this.assertSafeFileTarget(filePath);
128
+ const record = JSON.stringify(data);
129
+ if (Buffer.byteLength(record, "utf8") > MAX_RUN_JSONL_RECORD_BYTES) {
130
+ throw new Error(`Run JSONL record exceeds ${MAX_RUN_JSONL_RECORD_BYTES} bytes`);
131
+ }
132
+ // A process crash can leave a partial final JSONL record. Without a
133
+ // separator, the first append after restart would concatenate a valid
134
+ // record onto that fragment and lose both records. Inspect and repair
135
+ // the boundary through the same append-mode descriptor used to write.
136
+ const fd = openSync(filePath, constants.O_APPEND |
137
+ constants.O_CREAT |
138
+ constants.O_RDWR |
139
+ (constants.O_NOFOLLOW ?? 0), 0o600);
140
+ try {
141
+ if (process.platform !== "win32")
142
+ fchmodSync(fd, 0o600);
143
+ const size = fstatSync(fd).size;
144
+ if (size + Buffer.byteLength(record, "utf8") + 2 > MAX_RUN_JSONL_BYTES) {
145
+ throw new Error(`Run JSONL exceeds ${MAX_RUN_JSONL_BYTES} bytes`);
146
+ }
147
+ let prefix = "";
148
+ if (size > 0) {
149
+ const lastByte = Buffer.allocUnsafe(1);
150
+ readSync(fd, lastByte, 0, 1, size - 1);
151
+ if (lastByte[0] !== 0x0a)
152
+ prefix = "\n";
153
+ }
154
+ appendFileSync(fd, prefix + record + "\n", "utf-8");
155
+ }
156
+ finally {
157
+ closeSync(fd);
158
+ }
72
159
  }
73
160
  finally {
74
161
  // Release the lock for the next writer regardless of success/failure,
@@ -82,13 +169,41 @@ export class FileRunStore {
82
169
  readJsonl(filePath) {
83
170
  if (!existsSync(filePath))
84
171
  return [];
85
- const content = readFileSync(filePath, "utf-8");
172
+ this.assertRealDirectory(dirname(filePath));
173
+ let fd;
174
+ let content;
175
+ try {
176
+ const entry = lstatSync(filePath);
177
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > MAX_RUN_JSONL_BYTES) {
178
+ throw new Error(`Run JSONL is not a bounded regular file: ${filePath}`);
179
+ }
180
+ fd = openSync(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
181
+ const opened = fstatSync(fd);
182
+ if (!opened.isFile() || opened.size > MAX_RUN_JSONL_BYTES) {
183
+ throw new Error(`Run JSONL is not a bounded regular file: ${filePath}`);
184
+ }
185
+ content = readFileSync(fd, "utf-8");
186
+ }
187
+ finally {
188
+ if (fd !== undefined)
189
+ closeSync(fd);
190
+ }
86
191
  if (!content)
87
192
  return [];
88
- return content
89
- .split("\n")
90
- .filter((line) => line.trim())
91
- .map((line) => JSON.parse(line));
193
+ const records = [];
194
+ for (const line of content.split("\n")) {
195
+ if (!line.trim())
196
+ continue;
197
+ try {
198
+ records.push(JSON.parse(line));
199
+ }
200
+ catch {
201
+ // Append-only logs must remain readable after a torn final write. A
202
+ // malformed record is isolated to its line; later valid records still
203
+ // carry useful recovery state (same policy as Transcript.loadFromFile).
204
+ }
205
+ }
206
+ return records;
92
207
  }
93
208
  // ─── Snapshot ──────────────────────────────────────────────────
94
209
  async create(snapshot) {
@@ -109,11 +224,24 @@ export class FileRunStore {
109
224
  if (!existsSync(this.runsDir))
110
225
  return [];
111
226
  const entries = readdirSync(this.runsDir, { withFileTypes: true });
227
+ if (entries.length > MAX_RUN_DIRECTORIES)
228
+ throw new Error("Run registry has too many entries");
112
229
  const snapshots = [];
113
230
  for (const entry of entries) {
114
231
  if (!entry.isDirectory())
115
232
  continue;
116
- const snapshot = this.readJson(join(this.runsDir, entry.name, "run.json"));
233
+ let snapshot;
234
+ try {
235
+ snapshot = this.readJson(join(this.runsDir, entry.name, "run.json"));
236
+ }
237
+ catch (error) {
238
+ // One manually damaged/legacy partial snapshot must not make every
239
+ // healthy run disappear from the history list. Preserve real I/O
240
+ // failures, but isolate JSON corruption to the affected directory.
241
+ if (error instanceof SyntaxError)
242
+ continue;
243
+ throw error;
244
+ }
117
245
  if (!snapshot)
118
246
  continue;
119
247
  // Filter by status
@@ -123,8 +251,9 @@ export class FileRunStore {
123
251
  continue;
124
252
  }
125
253
  // Filter by tag
126
- if (query?.tag && !snapshot.tags.includes(query.tag))
254
+ if (query?.tag && (!Array.isArray(snapshot.tags) || !snapshot.tags.includes(query.tag))) {
127
255
  continue;
256
+ }
128
257
  snapshots.push(snapshot);
129
258
  }
130
259
  // Sort by createdAt descending (newest first)
@@ -165,7 +294,10 @@ export class FileRunStore {
165
294
  const cpDir = join(this.runDir(runId), "checkpoints");
166
295
  if (!existsSync(cpDir))
167
296
  return null;
297
+ this.assertRealDirectory(cpDir);
168
298
  const files = readdirSync(cpDir).filter((f) => f.endsWith(".json"));
299
+ if (files.length > MAX_RUN_CHILD_FILES)
300
+ throw new Error("Run has too many checkpoints");
169
301
  if (files.length === 0)
170
302
  return null;
171
303
  // Find the latest by createdAt
@@ -194,7 +326,10 @@ export class FileRunStore {
194
326
  const approvalDir = join(this.runDir(runId), "approvals");
195
327
  if (!existsSync(approvalDir))
196
328
  return null;
329
+ this.assertRealDirectory(approvalDir);
197
330
  const files = readdirSync(approvalDir).filter((f) => f.endsWith(".json"));
331
+ if (files.length > MAX_RUN_CHILD_FILES)
332
+ throw new Error("Run has too many approvals");
198
333
  for (const file of files) {
199
334
  const approval = this.readJson(join(approvalDir, file));
200
335
  if (approval?.status === "pending")