@cjhyy/code-shell-core 0.6.0-rc.16 → 0.6.0-rc.18

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 (64) hide show
  1. package/THIRD_PARTY_NOTICES.md +206 -0
  2. package/dist/automation/scheduler.d.ts +13 -7
  3. package/dist/automation/scheduler.js +116 -37
  4. package/dist/cc-orchestrator/agent-adapter.d.ts +2 -0
  5. package/dist/cc-orchestrator/agent-adapter.js +7 -1
  6. package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -0
  7. package/dist/cc-orchestrator/external-agent-driver.js +102 -51
  8. package/dist/cli/agent-server-stdio.js +2 -0
  9. package/dist/credentials/access.d.ts +56 -0
  10. package/dist/credentials/access.js +183 -0
  11. package/dist/credentials/index.d.ts +1 -0
  12. package/dist/credentials/index.js +1 -0
  13. package/dist/credentials/inject-credential-tool.js +5 -5
  14. package/dist/credentials/use-credential-tool.d.ts +8 -1
  15. package/dist/credentials/use-credential-tool.js +55 -45
  16. package/dist/engine/engine.d.ts +3 -0
  17. package/dist/engine/engine.js +40 -13
  18. package/dist/engine/image-policy.d.ts +6 -0
  19. package/dist/engine/image-policy.js +17 -6
  20. package/dist/engine/input-attachments.d.ts +13 -0
  21. package/dist/engine/input-attachments.js +255 -0
  22. package/dist/engine/model-facade.d.ts +5 -2
  23. package/dist/engine/model-facade.js +4 -4
  24. package/dist/engine/parse-task.d.ts +10 -0
  25. package/dist/engine/parse-task.js +5 -0
  26. package/dist/engine/streaming-tool-queue.d.ts +11 -7
  27. package/dist/engine/streaming-tool-queue.js +11 -7
  28. package/dist/engine/turn-loop.d.ts +4 -0
  29. package/dist/engine/turn-loop.js +106 -25
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.js +2 -2
  32. package/dist/logging/sanitize-messages.d.ts +10 -2
  33. package/dist/logging/sanitize-messages.js +21 -6
  34. package/dist/preset/index.js +10 -9
  35. package/dist/protocol/chat-session-manager.d.ts +1 -0
  36. package/dist/protocol/chat-session-manager.js +18 -2
  37. package/dist/protocol/chat-session.d.ts +3 -0
  38. package/dist/protocol/chat-session.js +1 -0
  39. package/dist/protocol/client.d.ts +9 -4
  40. package/dist/protocol/client.js +18 -1
  41. package/dist/protocol/server.d.ts +12 -0
  42. package/dist/protocol/server.js +116 -38
  43. package/dist/protocol/types.d.ts +37 -0
  44. package/dist/runtime/spawn-common.js +10 -0
  45. package/dist/tool-system/builtin/drive-claude-code.d.ts +1 -0
  46. package/dist/tool-system/builtin/drive-claude-code.js +137 -18
  47. package/dist/tool-system/builtin/index.d.ts +8 -3
  48. package/dist/tool-system/builtin/index.js +15 -13
  49. package/dist/tool-system/builtin/powershell.d.ts +5 -2
  50. package/dist/tool-system/builtin/powershell.js +11 -7
  51. package/dist/tool-system/builtin/read.js +114 -5
  52. package/dist/tool-system/builtin/view-image.js +9 -0
  53. package/dist/tool-system/executor.d.ts +1 -5
  54. package/dist/tool-system/executor.js +94 -115
  55. package/dist/tool-system/mcp-manager.d.ts +2 -0
  56. package/dist/tool-system/mcp-manager.js +23 -8
  57. package/dist/tool-system/path-policy.js +13 -0
  58. package/dist/tool-system/permission.d.ts +28 -7
  59. package/dist/tool-system/permission.js +130 -49
  60. package/dist/tool-system/registry.js +11 -4
  61. package/dist/tool-system/tool-result-redaction.d.ts +7 -0
  62. package/dist/tool-system/tool-result-redaction.js +48 -0
  63. package/dist/types.d.ts +23 -4
  64. package/package.json +4 -3
@@ -7,22 +7,17 @@
7
7
  * - token/link → `{ kind: "value", value }`
8
8
  * - cookie → 就地写临时 cookies.txt(0600),返回 `{ kind: "cookie", cookiesFile, count }`
9
9
  *
10
- * 取值全部 core 直读 CredentialStore(cookie 值第二期已进库),无跨进程。
10
+ * desktop 下通过 host credential access IPC 按需解析 secret;headless/SDK
11
+ * 仍可使用本地 CredentialStore。
11
12
  * 集中在 core/src/credentials/ 下,只经 ToolDefinition 注册 + ToolContext.askUser 耦合 core,
12
13
  * 满足设计稿 §1「可整块外移」约束。
13
14
  */
14
- import { writeFileSync, existsSync, readdirSync, statSync, rmSync } from "node:fs";
15
- import { join } from "node:path";
16
- import { tmpdir } from "node:os";
17
- import { randomUUID } from "node:crypto";
18
- import { CredentialStore } from "./store.js";
19
- import { formatNetscapeCookies, parseCookieJar } from "./cookie-jar.js";
15
+ import { SENSITIVE_TOOL_RESULT_PLACEHOLDER } from "../tool-system/tool-result-redaction.js";
20
16
  import { credentialUseGate, } from "./use-gate.js";
21
17
  import { SettingsManager } from "../settings/manager.js";
22
18
  import { logger } from "../logging/logger.js";
19
+ import { credentialAccessScope, getCredentialAccess, materializeCookieSecret, sweepStaleCredentialCookieFiles, } from "./access.js";
23
20
  const TOOL_NAME = "UseCredential";
24
- const COOKIE_FILE_PREFIX = "codeshell-cred-cookie-";
25
- const COOKIE_FILE_MAX_AGE_MS = 30 * 60 * 1000; // 30min 启动 sweep 上限
26
21
  const BASE_DESCRIPTION = "Use a stored credential (token / API key / login cookie) to run a command. " +
27
22
  "Call with NO arguments first to list available credentials (id + label + type); " +
28
23
  "then call again with `id` to fetch one. Token/link credentials return their secret " +
@@ -52,7 +47,7 @@ export const useCredentialToolDef = {
52
47
  */
53
48
  export function useCredentialToolDefFor(cwd) {
54
49
  try {
55
- const list = new CredentialStore(cwd).listMasked();
50
+ const list = getCredentialAccess().listMasked(cwd, "full");
56
51
  if (list.length === 0)
57
52
  return useCredentialToolDef;
58
53
  const names = list.map((c) => `${c.id} (${c.type})`).join(", ");
@@ -70,26 +65,7 @@ export function useCredentialToolDefFor(cwd) {
70
65
  * 不引入 lease 对象/定时器 —— 文件用完靠进程退出 + 这个轻量 sweep。
71
66
  */
72
67
  export function sweepStaleCredentialCookies(now = Date.now()) {
73
- const dir = tmpdir();
74
- try {
75
- if (!existsSync(dir))
76
- return;
77
- for (const f of readdirSync(dir)) {
78
- if (!f.startsWith(COOKIE_FILE_PREFIX))
79
- continue;
80
- const p = join(dir, f);
81
- try {
82
- if (now - statSync(p).mtimeMs > COOKIE_FILE_MAX_AGE_MS)
83
- rmSync(p, { force: true });
84
- }
85
- catch {
86
- /* skip */
87
- }
88
- }
89
- }
90
- catch {
91
- /* best-effort */
92
- }
68
+ sweepStaleCredentialCookieFiles(now);
93
69
  }
94
70
  /** 内存会话 allow 集:每个 Engine 一份(从 ctx.sessionId 键)。纯内存,关进程即忘。 */
95
71
  const sessionAllowByEngine = new Map();
@@ -112,7 +88,7 @@ function sessionAllowFor(ctx) {
112
88
  // (project-only). An "isolated" engine is at least as restrictive as project,
113
89
  // so it maps to "project" — it must never read the host user's ~/.code-shell.
114
90
  function credentialScope(scope) {
115
- return scope === "full" || scope === undefined ? "full" : "project";
91
+ return credentialAccessScope(scope);
116
92
  }
117
93
  function readAutoApprove(cwd, scope) {
118
94
  try {
@@ -128,19 +104,19 @@ function readAutoApprove(cwd, scope) {
128
104
  }
129
105
  export async function useCredentialTool(args, ctx) {
130
106
  const cwd = ctx?.cwd ?? process.cwd();
131
- const store = new CredentialStore(cwd);
107
+ const access = getCredentialAccess();
132
108
  const scope = credentialScope(ctx?.settingsScope);
133
109
  const id = typeof args.id === "string" ? args.id.trim() : "";
134
110
  const purpose = typeof args.purpose === "string" ? args.purpose : undefined;
135
111
  // 无 id → 清单(脱敏,权威实时源)。按 engine scope 过滤:project/isolated
136
112
  // 引擎不得列出宿主 user 层凭证。
137
113
  if (!id) {
138
- const credentials = store
139
- .listMasked(scope)
114
+ const credentials = access
115
+ .listMasked(cwd, scope)
140
116
  .map((c) => ({ id: c.id, label: c.label, type: c.type }));
141
117
  return json({ kind: "list", credentials });
142
118
  }
143
- const cred = store.resolve(id, scope);
119
+ const cred = access.resolveMeta(cwd, id, scope);
144
120
  if (!cred) {
145
121
  return json({ kind: "error", error: `凭证不存在: "${id}"。调用本工具(无参)可列出可用凭证。` });
146
122
  }
@@ -162,14 +138,20 @@ export async function useCredentialTool(args, ctx) {
162
138
  }
163
139
  // token/link → 直接返回值
164
140
  if (cred.type === "token" || cred.type === "link") {
165
- if (!cred.secret)
141
+ if (!cred.hasSecret || !access.resolveValue) {
142
+ return json({ kind: "error", error: `凭证「${cred.label}」没有可用的值。` });
143
+ }
144
+ try {
145
+ const value = await access.resolveValue({ cwd, id: cred.id, scope, purpose: "use" });
146
+ return json({ kind: "value", value });
147
+ }
148
+ catch {
166
149
  return json({ kind: "error", error: `凭证「${cred.label}」没有可用的值。` });
167
- return json({ kind: "value", value: cred.secret });
150
+ }
168
151
  }
169
152
  // cookie → 就地写临时 cookies.txt
170
153
  if (cred.type === "cookie") {
171
- const jar = parseCookieJar(cred.secret);
172
- if (jar.length === 0) {
154
+ if (!cred.hasSecret) {
173
155
  return json({
174
156
  kind: "error",
175
157
  error: `凭证「${cred.label}」的 cookie 为空或已失效,请在凭证页对该账号点「重拓」(重新登录后重新拓取)。`,
@@ -180,25 +162,53 @@ export async function useCredentialTool(args, ctx) {
180
162
  // same process — the second write would clobber the first and a caller could
181
163
  // read another account's cookies. A random component makes each file distinct.
182
164
  // Prefix is unchanged so the 30-min startup sweep still matches & cleans them.
183
- const file = join(tmpdir(), `${COOKIE_FILE_PREFIX}${safe(cred.id)}-${process.pid}-${randomUUID()}.txt`);
184
165
  try {
185
- writeFileSync(file, formatNetscapeCookies(jar), { mode: 0o600 });
166
+ const materialized = access.materializeCookie
167
+ ? await access.materializeCookie({ cwd, id: cred.id, scope })
168
+ : materializeCookieSecret(cred.id, await access.resolveValue({ cwd, id: cred.id, scope, purpose: "use" }));
169
+ return json({
170
+ kind: "cookie",
171
+ cookiesFile: materialized.cookiesFile,
172
+ count: materialized.count,
173
+ });
186
174
  }
187
175
  catch (e) {
176
+ if (String(e).includes("cookie jar is empty or invalid")) {
177
+ return json({
178
+ kind: "error",
179
+ error: `凭证「${cred.label}」的 cookie 为空或已失效,请在凭证页对该账号点「重拓」(重新登录后重新拓取)。`,
180
+ });
181
+ }
188
182
  logger.warn(`UseCredential: failed to write cookies.txt: ${String(e)}`);
189
183
  return json({ kind: "error", error: `写临时 cookie 文件失败: ${String(e)}` });
190
184
  }
191
- return json({ kind: "cookie", cookiesFile: file, count: jar.length });
192
185
  }
193
186
  return json({ kind: "error", error: `未知凭证类型: ${cred.type}` });
194
187
  }
195
- /** 文件名安全化(凭证 id 用了 `__`,这里只挡路径分隔符/控制字符) */
196
- function safe(s) {
197
- return s.replace(/[^a-zA-Z0-9_.-]/g, "_");
188
+ export async function useCredentialBuiltinTool(args, ctx) {
189
+ const result = await useCredentialTool(args, ctx);
190
+ if (!isValueResult(result))
191
+ return result;
192
+ const redacted = json({ kind: "value", value: SENSITIVE_TOOL_RESULT_PLACEHOLDER });
193
+ return {
194
+ result,
195
+ sensitive: true,
196
+ displayResult: redacted,
197
+ transcriptResult: redacted,
198
+ };
198
199
  }
199
200
  function json(r) {
200
201
  return JSON.stringify(r);
201
202
  }
203
+ function isValueResult(result) {
204
+ try {
205
+ const parsed = JSON.parse(result);
206
+ return parsed.kind === "value" && typeof parsed.value === "string";
207
+ }
208
+ catch {
209
+ return false;
210
+ }
211
+ }
202
212
  /** 测试钩子:清空会话 allow 集(避免跨用例污染)。 */
203
213
  export function __resetCredentialSessionAllowForTests() {
204
214
  sessionAllowByEngine.clear();
@@ -16,6 +16,7 @@ import { type SandboxBackend } from "../tool-system/sandbox/index.js";
16
16
  import { ModelPool, type ModelEntry } from "../llm/model-pool.js";
17
17
  import { AgentDefinitionRegistry } from "../agent/agent-definition-registry.js";
18
18
  import { EngineRuntime } from "./runtime.js";
19
+ import type { InputAttachmentMeta } from "../protocol/types.js";
19
20
  /**
20
21
  * Build ScanOptions.compatFileNames from the user's instruction compat toggles.
21
22
  * Primary file name stays hard-wired to CODESHELL.md (not exposed). Turning a
@@ -328,6 +329,8 @@ export declare class Engine {
328
329
  injected?: boolean;
329
330
  /** Stable id for this user-intent, used to make duplicate submits idempotent. */
330
331
  clientMessageId?: string;
332
+ /** Structured input attachments. Legacy `<codeshell-image>` blocks remain supported. */
333
+ attachments?: InputAttachmentMeta[];
331
334
  }): Promise<EngineResult>;
332
335
  /**
333
336
  * Run the end-of-session memory pipeline as a fire-and-forget background
@@ -40,7 +40,7 @@ import { sanitizeContent, sanitizeTaskString } from "../logging/sanitize-message
40
40
  import { TurnLoop } from "./turn-loop.js";
41
41
  import { MCPManager } from "../tool-system/mcp-manager.js";
42
42
  import { SettingsManager, userHome } from "../settings/manager.js";
43
- import { CredentialStore } from "../credentials/store.js";
43
+ import { getCredentialAccess } from "../credentials/access.js";
44
44
  import { isFeatureEnabled, resolveFeatureFlags, } from "../settings/feature-flags.js";
45
45
  import { effectiveDisabledList, effectiveBuiltinLists } from "../capability-control/overlay.js";
46
46
  import { computeEffectiveDisabledLists } from "../capability-control/disabled-lists.js";
@@ -54,6 +54,7 @@ import { defaultCacheDir } from "../llm/model-cache.js";
54
54
  import { detectProviderFromApiKey, buildModelPool } from "../onboarding.js";
55
55
  import { detectPastedNoise } from "../utils/task-sanitizer.js";
56
56
  import { parseTaskWithImages } from "./parse-task.js";
57
+ import { buildInputAttachmentContext } from "./input-attachments.js";
57
58
  import { enforceImagePolicy, byteLengthFromBase64, dropOversizedImages, collectAttachedImagePaths, } from "./image-policy.js";
58
59
  import { tryCompressImages } from "./image-compression.js";
59
60
  import { buildSessionTitle } from "./session-title.js";
@@ -831,8 +832,32 @@ export class Engine {
831
832
  usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
832
833
  };
833
834
  }
835
+ const cap = capabilitiesFor((this.config.llm.providerKind ?? this.config.llm.provider), this.config.llm.model);
836
+ const attachmentContext = await buildInputAttachmentContext(options?.attachments, cwd, {
837
+ includeImageBytes: cap.supportsVision,
838
+ expectedSessionId: options?.sessionId,
839
+ });
840
+ if (attachmentContext.errors.length > 0) {
841
+ const detail = attachmentContext.errors.join("; ");
842
+ logger.warn("engine.run.input_attachment_failed", { error: detail });
843
+ return {
844
+ text: `ERROR: input attachment could not be read (${detail}). Re-attach it or choose a path inside the workspace.`,
845
+ reason: "image_error",
846
+ sessionId: options?.sessionId ?? "input-attachment-failed",
847
+ turnCount: 0,
848
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
849
+ };
850
+ }
851
+ if (attachmentContext.text || attachmentContext.hasStructuredImageAttachments) {
852
+ parsedTask = {
853
+ text: [parsedTask.text, attachmentContext.text].filter(Boolean).join("\n\n"),
854
+ images: [...parsedTask.images, ...attachmentContext.images],
855
+ hasImages: parsedTask.hasImages ||
856
+ attachmentContext.images.length > 0 ||
857
+ attachmentContext.hasStructuredImageAttachments,
858
+ };
859
+ }
834
860
  if (parsedTask.hasImages) {
835
- const cap = capabilitiesFor((this.config.llm.providerKind ?? this.config.llm.provider), this.config.llm.model);
836
861
  if (!cap.supportsVision) {
837
862
  logger.warn("engine.run.vision_not_supported", {
838
863
  provider: this.config.llm.provider,
@@ -1397,12 +1422,14 @@ export class Engine {
1397
1422
  // in this same session don't re-prompt. Headless/auto backends skip
1398
1423
  // this — they don't prompt, so there are no project rules to persist.
1399
1424
  if (approvalBackend instanceof InteractiveApprovalBackend) {
1400
- approvalBackend.setCwd(cwd);
1401
- approvalBackend.setOnProjectRules((rules) => {
1402
- // Prepend the *full* accumulated list of session-saved project rules
1403
- // so user approvals win over defaults and earlier approvals aren't
1404
- // dropped when later ones come in.
1405
- permission.reconfigure(mode, approvalBackend, [...rules, ...defaultRules]);
1425
+ approvalBackend.setSessionContext(session.state.sessionId, {
1426
+ cwd,
1427
+ onProjectRules: (rules) => {
1428
+ // Prepend the *full* accumulated list of session-saved project rules
1429
+ // so user approvals win over defaults and earlier approvals aren't
1430
+ // dropped when later ones come in.
1431
+ permission.reconfigure(mode, approvalBackend, [...rules, ...defaultRules]);
1432
+ },
1406
1433
  });
1407
1434
  }
1408
1435
  const toolExecutor = new ToolExecutor(this.toolRegistry, permission, this.hooks);
@@ -2657,10 +2684,10 @@ export class Engine {
2657
2684
  buildPermissionConfig(mode, cwd) {
2658
2685
  const rules = [...this.preset.defaultPermissionRules];
2659
2686
  // Memory tools: dream scope is the LLM's own workspace, so save/delete
2660
- // there go through without prompting. user-scope save/delete fall through
2661
- // to the tool's permissionDefault ("ask"), forcing the user to confirm
2662
- // any modification of memories they own. Read tools are listed in the
2663
- // tool definition as permissionDefault: "allow" no rule needed here.
2687
+ // there go through without prompting. user-scope save/delete have no
2688
+ // explicit allow rule here, so default-mode classifier fallback asks the
2689
+ // user to confirm modifications. RegisteredTool.permissionDefault is only
2690
+ // UI/metadata and is not read by the classifier.
2664
2691
  rules.push({
2665
2692
  tool: "MemorySave",
2666
2693
  argsPattern: { scope: "^dream$" },
@@ -2995,7 +3022,7 @@ export class Engine {
2995
3022
  // the host user's credentials (same isolation contract as top-level env).
2996
3023
  // Placed below settings.env so an explicit `env` entry can still override.
2997
3024
  const credScope = (this.config.settingsScope ?? "project") === "full" ? "full" : "project";
2998
- layer(new CredentialStore(cwd).envExposures(credScope));
3025
+ layer(getCredentialAccess().envExposures(cwd, credScope));
2999
3026
  layer(settings.env); // top-level env (global ⊕ project) wins
3000
3027
  }
3001
3028
  catch {
@@ -149,6 +149,12 @@ export interface DropOversizedResult {
149
149
  droppedCount: number;
150
150
  }
151
151
  export declare function dropOversizedImages(images: readonly ParsedImage[]): DropOversizedResult;
152
+ export interface ImagePolicyByteInput {
153
+ name: string;
154
+ mime: string;
155
+ bytes: number;
156
+ }
157
+ export declare function enforceImageBytePolicy(images: readonly ImagePolicyByteInput[]): ImagePolicyVerdict;
152
158
  export declare function enforceImagePolicy(images: readonly ParsedImage[]): ImagePolicyVerdict;
153
159
  /**
154
160
  * Collect the workspace file paths of any attached images that came from a
@@ -107,15 +107,13 @@ export function dropOversizedImages(images) {
107
107
  if (dropped.length === 0) {
108
108
  return { kept, placeholder: "", droppedCount: 0 };
109
109
  }
110
- const list = dropped
111
- .map((d) => `「${d.name}」(~${fmtMB(d.bytes)})`)
112
- .join("、");
110
+ const list = dropped.map((d) => `「${d.name}」(~${fmtMB(d.bytes)})`).join("、");
113
111
  const placeholder = `[已自动跳过 ${dropped.length} 张超大图片:${list};` +
114
112
  `单图上限 ${fmtMB(IMAGE_LIMITS.maxBytesPerImage)}。` +
115
113
  `图片未进入对话历史,本轮其余内容继续。]`;
116
114
  return { kept, placeholder, droppedCount: dropped.length };
117
115
  }
118
- export function enforceImagePolicy(images) {
116
+ export function enforceImageBytePolicy(images) {
119
117
  if (images.length === 0)
120
118
  return { ok: true };
121
119
  if (images.length > IMAGE_LIMITS.maxImagesPerTurn) {
@@ -126,13 +124,13 @@ export function enforceImagePolicy(images) {
126
124
  `本次有 ${images.length} 张。请删掉一些再发。`,
127
125
  totals: {
128
126
  imageCount: images.length,
129
- totalBytes: images.reduce((s, i) => s + byteLengthFromBase64(i.base64), 0),
127
+ totalBytes: images.reduce((s, i) => s + i.bytes, 0),
130
128
  },
131
129
  };
132
130
  }
133
131
  let totalBytes = 0;
134
132
  for (const img of images) {
135
- const bytes = byteLengthFromBase64(img.base64);
133
+ const bytes = img.bytes;
136
134
  if (bytes > IMAGE_LIMITS.maxBytesPerImage) {
137
135
  return {
138
136
  ok: false,
@@ -163,6 +161,13 @@ export function enforceImagePolicy(images) {
163
161
  }
164
162
  return { ok: true };
165
163
  }
164
+ export function enforceImagePolicy(images) {
165
+ return enforceImageBytePolicy(images.map((img) => ({
166
+ name: img.name,
167
+ mime: img.mime,
168
+ bytes: byteLengthFromBase64(img.base64),
169
+ })));
170
+ }
166
171
  /**
167
172
  * Collect the workspace file paths of any attached images that came from a
168
173
  * real file (the desktop composer's path-attach flow sets ParsedImage.name to
@@ -178,6 +183,12 @@ export function enforceImagePolicy(images) {
178
183
  export function collectAttachedImagePaths(images, resolve, exists) {
179
184
  const out = [];
180
185
  for (const img of images) {
186
+ const path = img.path?.trim();
187
+ if (path) {
188
+ if (exists(resolve(path)))
189
+ out.push(path);
190
+ continue;
191
+ }
181
192
  const name = img.name?.trim();
182
193
  if (!name)
183
194
  continue;
@@ -0,0 +1,13 @@
1
+ import type { InputAttachmentMeta } from "../protocol/types.js";
2
+ import type { ParsedImage } from "./parse-task.js";
3
+ export interface InputAttachmentContext {
4
+ text: string;
5
+ images: ParsedImage[];
6
+ errors: string[];
7
+ hasStructuredImageAttachments: boolean;
8
+ }
9
+ export interface BuildInputAttachmentContextOptions {
10
+ includeImageBytes?: boolean;
11
+ expectedSessionId?: string;
12
+ }
13
+ export declare function buildInputAttachmentContext(attachments: readonly InputAttachmentMeta[] | undefined, cwd: string, options?: BuildInputAttachmentContextOptions): Promise<InputAttachmentContext>;
@@ -0,0 +1,255 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
3
+ import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { classifyPath } from "../tool-system/path-policy.js";
5
+ import { enforceImageBytePolicy } from "./image-policy.js";
6
+ const IMAGE_MIME_BY_EXT = {
7
+ ".png": "image/png",
8
+ ".jpg": "image/jpeg",
9
+ ".jpeg": "image/jpeg",
10
+ ".gif": "image/gif",
11
+ ".webp": "image/webp",
12
+ };
13
+ const MAX_DIRECTORY_TREE_ENTRIES = 200;
14
+ const MAX_DIRECTORY_TREE_DEPTH = 2;
15
+ export async function buildInputAttachmentContext(attachments, cwd, options = {}) {
16
+ if (!attachments || attachments.length === 0) {
17
+ return { text: "", images: [], errors: [], hasStructuredImageAttachments: false };
18
+ }
19
+ const cwdReal = await realpath(cwd);
20
+ const includeImageBytes = options.includeImageBytes ?? true;
21
+ const expectedSessionId = options.expectedSessionId;
22
+ const textBlocks = [];
23
+ const images = [];
24
+ const errors = [];
25
+ const pendingImages = [];
26
+ let hasStructuredImageAttachments = false;
27
+ if (!expectedSessionId) {
28
+ return {
29
+ text: "",
30
+ images: [],
31
+ errors: ["input attachments require an expected sessionId"],
32
+ hasStructuredImageAttachments: false,
33
+ };
34
+ }
35
+ if (!isSafeSessionPathSegment(expectedSessionId)) {
36
+ return {
37
+ text: "",
38
+ images: [],
39
+ errors: [`expected sessionId "${expectedSessionId}" is not a safe path segment`],
40
+ hasStructuredImageAttachments: false,
41
+ };
42
+ }
43
+ for (const attachment of attachments) {
44
+ if (!attachment || typeof attachment !== "object")
45
+ continue;
46
+ const displayPath = attachment.path || attachment.relPath || attachment.absPath;
47
+ if (!displayPath) {
48
+ errors.push(`attachment ${attachment.id || "(unknown)"} has no path`);
49
+ continue;
50
+ }
51
+ if (attachment.sessionId !== expectedSessionId) {
52
+ errors.push(`attachment ${attachment.id || displayPath} session mismatch: expected ${expectedSessionId}, got ${attachment.sessionId}`);
53
+ continue;
54
+ }
55
+ const resolved = resolveAttachmentPath(attachment, cwd);
56
+ let info;
57
+ try {
58
+ info = await stat(resolved);
59
+ }
60
+ catch (err) {
61
+ errors.push(`attachment ${attachment.id || displayPath} stat failed: ${err.message}`);
62
+ continue;
63
+ }
64
+ const policy = classifyPath(resolved, { workspaceRoot: cwdReal, operation: "read" });
65
+ if (policy.decision !== "allow") {
66
+ errors.push(`attachment ${attachment.id || displayPath} blocked by path policy: ${policy.reason}`);
67
+ continue;
68
+ }
69
+ const realPath = policy.resolvedPath;
70
+ const stagedPathError = await validateStagedAttachmentPath(attachment, realPath, cwdReal, expectedSessionId, displayPath);
71
+ if (stagedPathError) {
72
+ errors.push(stagedPathError);
73
+ continue;
74
+ }
75
+ if (attachment.kind === "directory" || info.isDirectory()) {
76
+ const tree = await directoryTree(realPath, cwdReal).catch((err) => ({
77
+ lines: [`(directory tree unavailable: ${err.message})`],
78
+ truncated: true,
79
+ entryCount: 0,
80
+ }));
81
+ textBlocks.push([
82
+ `<attached-directory path="${escapeText(displayPath)}" entries="${tree.entryCount}" truncated="${tree.truncated}">`,
83
+ ...tree.lines,
84
+ `</attached-directory>`,
85
+ ].join("\n"));
86
+ continue;
87
+ }
88
+ if (!info.isFile()) {
89
+ errors.push(`attachment ${attachment.id || displayPath} is not a regular file or directory`);
90
+ continue;
91
+ }
92
+ const mime = attachment.mime || IMAGE_MIME_BY_EXT[extname(realPath).toLowerCase()];
93
+ if (attachment.kind === "image") {
94
+ hasStructuredImageAttachments = true;
95
+ if (!includeImageBytes || attachment.vision?.include === false) {
96
+ textBlocks.push(formatFileMetadata(attachment, displayPath, realPath, info.size, mime));
97
+ continue;
98
+ }
99
+ if (!mime ||
100
+ !mime.startsWith("image/") ||
101
+ !IMAGE_MIME_BY_EXT[extname(realPath).toLowerCase()]) {
102
+ errors.push(`image attachment ${attachment.id || displayPath} has unsupported image type`);
103
+ continue;
104
+ }
105
+ pendingImages.push({
106
+ attachment,
107
+ displayPath,
108
+ realPath,
109
+ mime,
110
+ size: info.size,
111
+ });
112
+ continue;
113
+ }
114
+ textBlocks.push(formatFileMetadata(attachment, displayPath, realPath, info.size, mime));
115
+ }
116
+ if (errors.length === 0 && includeImageBytes && pendingImages.length > 0) {
117
+ const verdict = enforceImageBytePolicy(pendingImages.map((image) => ({
118
+ name: image.attachment.originalName || image.displayPath,
119
+ mime: image.mime,
120
+ bytes: image.size,
121
+ })));
122
+ if (!verdict.ok) {
123
+ errors.push(`image attachment size policy failed: ${verdict.message}`);
124
+ }
125
+ else {
126
+ for (const image of pendingImages) {
127
+ let bytes;
128
+ try {
129
+ bytes = await readFile(image.realPath);
130
+ }
131
+ catch (err) {
132
+ errors.push(`image attachment ${image.attachment.id || image.displayPath} read failed: ${err.message}`);
133
+ continue;
134
+ }
135
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
136
+ const base64 = bytes.toString("base64");
137
+ images.push({
138
+ mime: image.mime,
139
+ name: image.attachment.originalName || image.displayPath,
140
+ dataUrl: `data:${image.mime};base64,${base64}`,
141
+ base64,
142
+ path: image.displayPath,
143
+ hash: `sha256:${sha256}`,
144
+ size: image.size,
145
+ origin: image.attachment.origin,
146
+ sessionId: image.attachment.sessionId,
147
+ });
148
+ }
149
+ }
150
+ }
151
+ return { text: textBlocks.join("\n\n"), images, errors, hasStructuredImageAttachments };
152
+ }
153
+ function resolveAttachmentPath(attachment, cwd) {
154
+ const candidate = attachment.vision?.mediaPath || attachment.absPath || attachment.relPath || attachment.path;
155
+ if (isAbsolute(candidate))
156
+ return candidate;
157
+ return resolve(cwd, candidate);
158
+ }
159
+ async function validateStagedAttachmentPath(attachment, realPath, cwdReal, expectedSessionId, displayPath) {
160
+ if (!isStagedAttachmentReference(attachment, realPath, cwdReal))
161
+ return undefined;
162
+ const expectedDir = resolve(cwdReal, ".code-shell", "attachments", expectedSessionId);
163
+ let expectedDirReal;
164
+ try {
165
+ expectedDirReal = await realpath(expectedDir);
166
+ }
167
+ catch (err) {
168
+ return `attachment ${attachment.id || displayPath} staged session directory is unavailable: ${err.message}`;
169
+ }
170
+ if (isPathInside(realPath, expectedDirReal))
171
+ return undefined;
172
+ return `attachment ${attachment.id || displayPath} staged path is outside .code-shell/attachments/${expectedSessionId}`;
173
+ }
174
+ function isStagedAttachmentReference(attachment, realPath, cwdReal) {
175
+ const fields = [
176
+ attachment.path,
177
+ attachment.absPath,
178
+ attachment.relPath,
179
+ attachment.vision?.mediaPath,
180
+ ].filter((value) => typeof value === "string" && value.length > 0);
181
+ if (fields.some((value) => looksLikeStagedAttachmentPath(value)))
182
+ return true;
183
+ return isPathInside(realPath, resolve(cwdReal, ".code-shell", "attachments"));
184
+ }
185
+ function looksLikeStagedAttachmentPath(value) {
186
+ const normalized = normalizePath(value);
187
+ return (normalized === ".code-shell/attachments" ||
188
+ normalized.startsWith(".code-shell/attachments/") ||
189
+ normalized.includes("/.code-shell/attachments/"));
190
+ }
191
+ function isPathInside(child, parent) {
192
+ const rel = relative(parent, child);
193
+ return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
194
+ }
195
+ function isSafeSessionPathSegment(sessionId) {
196
+ if (!sessionId)
197
+ return false;
198
+ return (!sessionId.includes("/") && !sessionId.includes("\\") && sessionId !== "." && sessionId !== "..");
199
+ }
200
+ function normalizePath(value) {
201
+ return value.replace(/\\/g, "/");
202
+ }
203
+ function formatFileMetadata(attachment, displayPath, realPath, size, mime) {
204
+ const lines = [
205
+ `<attached-file path="${escapeText(displayPath)}">`,
206
+ `absolutePath: ${realPath}`,
207
+ ...(mime ? [`mime: ${mime}`] : []),
208
+ `size: ${size}`,
209
+ `sha256: ${attachment.sha256}`,
210
+ `origin: ${attachment.origin}`,
211
+ `</attached-file>`,
212
+ ];
213
+ return lines.join("\n");
214
+ }
215
+ async function directoryTree(dir, cwdReal) {
216
+ const lines = [];
217
+ let entryCount = 0;
218
+ let truncated = false;
219
+ async function walk(current, depth) {
220
+ if (entryCount >= MAX_DIRECTORY_TREE_ENTRIES) {
221
+ truncated = true;
222
+ return;
223
+ }
224
+ const entries = await readdir(current, { withFileTypes: true });
225
+ entries.sort((a, b) => a.name.localeCompare(b.name));
226
+ for (const entry of entries) {
227
+ if (entryCount >= MAX_DIRECTORY_TREE_ENTRIES) {
228
+ truncated = true;
229
+ return;
230
+ }
231
+ if (entry.name === ".git" || entry.name === "node_modules")
232
+ continue;
233
+ const abs = join(current, entry.name);
234
+ const real = await realpath(abs).catch(() => null);
235
+ if (!real)
236
+ continue;
237
+ const rel = relative(cwdReal, real);
238
+ if (rel === ".." || rel.startsWith(`..${sep}`))
239
+ continue;
240
+ entryCount += 1;
241
+ lines.push(`${" ".repeat(depth)}${entry.isDirectory() ? "dir " : "file "}${slash(rel)}`);
242
+ if (entry.isDirectory() && depth + 1 < MAX_DIRECTORY_TREE_DEPTH) {
243
+ await walk(real, depth + 1);
244
+ }
245
+ }
246
+ }
247
+ await walk(dir, 0);
248
+ return { lines, truncated, entryCount };
249
+ }
250
+ function escapeText(value) {
251
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
252
+ }
253
+ function slash(value) {
254
+ return sep === "\\" ? value.replace(/\\/g, "/") : value;
255
+ }
@@ -4,6 +4,9 @@
4
4
  import type { LLMClientBase } from "../llm/client-base.js";
5
5
  import type { Message, ToolDefinition, LLMResponse, StreamCallback } from "../types.js";
6
6
  import { Transcript } from "../session/transcript.js";
7
+ export interface ModelCallRecordingOptions {
8
+ sensitiveToolResultRedactions?: ReadonlyMap<string, string>;
9
+ }
7
10
  /**
8
11
  * Prompt-cache hit rate for one request, as CC computes it:
9
12
  * cacheRead / (cacheRead + cacheCreation + uncachedInput)
@@ -18,11 +21,11 @@ export declare class ModelFacade {
18
21
  private readonly client;
19
22
  private readonly transcript;
20
23
  constructor(client: LLMClientBase, transcript: Transcript);
21
- call(systemPrompt: string, messages: Message[], tools: ToolDefinition[], onStream?: StreamCallback, signal?: AbortSignal): Promise<LLMResponse>;
24
+ call(systemPrompt: string, messages: Message[], tools: ToolDefinition[], onStream?: StreamCallback, signal?: AbortSignal, recordingOptions?: ModelCallRecordingOptions): Promise<LLMResponse>;
22
25
  /**
23
26
  * Call without streaming — used as fallback when streaming fails.
24
27
  */
25
- callWithoutStreaming(systemPrompt: string, messages: Message[], tools: ToolDefinition[], signal?: AbortSignal): Promise<LLMResponse>;
28
+ callWithoutStreaming(systemPrompt: string, messages: Message[], tools: ToolDefinition[], signal?: AbortSignal, recordingOptions?: ModelCallRecordingOptions): Promise<LLMResponse>;
26
29
  /** Record API timing and token usage to bootstrap state. */
27
30
  private recordUsage;
28
31
  /** Optional summarize function for tool use summaries. */