@cjhyy/code-shell-core 0.6.0-rc.17 → 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.
- package/THIRD_PARTY_NOTICES.md +206 -0
- package/dist/automation/scheduler.d.ts +13 -7
- package/dist/automation/scheduler.js +116 -37
- package/dist/cc-orchestrator/agent-adapter.d.ts +2 -0
- package/dist/cc-orchestrator/agent-adapter.js +7 -1
- package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -0
- package/dist/cc-orchestrator/external-agent-driver.js +102 -51
- package/dist/cli/agent-server-stdio.js +2 -0
- package/dist/credentials/access.d.ts +56 -0
- package/dist/credentials/access.js +183 -0
- package/dist/credentials/index.d.ts +1 -0
- package/dist/credentials/index.js +1 -0
- package/dist/credentials/inject-credential-tool.js +5 -5
- package/dist/credentials/use-credential-tool.d.ts +8 -1
- package/dist/credentials/use-credential-tool.js +55 -45
- package/dist/engine/engine.d.ts +3 -0
- package/dist/engine/engine.js +40 -13
- package/dist/engine/image-policy.d.ts +6 -0
- package/dist/engine/image-policy.js +17 -6
- package/dist/engine/input-attachments.d.ts +13 -0
- package/dist/engine/input-attachments.js +255 -0
- package/dist/engine/model-facade.d.ts +5 -2
- package/dist/engine/model-facade.js +4 -4
- package/dist/engine/parse-task.d.ts +10 -0
- package/dist/engine/parse-task.js +5 -0
- package/dist/engine/streaming-tool-queue.d.ts +11 -7
- package/dist/engine/streaming-tool-queue.js +11 -7
- package/dist/engine/turn-loop.d.ts +4 -0
- package/dist/engine/turn-loop.js +106 -25
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/logging/sanitize-messages.d.ts +10 -2
- package/dist/logging/sanitize-messages.js +21 -6
- package/dist/preset/index.js +10 -9
- package/dist/protocol/chat-session-manager.d.ts +1 -0
- package/dist/protocol/chat-session-manager.js +18 -2
- package/dist/protocol/chat-session.d.ts +3 -0
- package/dist/protocol/chat-session.js +1 -0
- package/dist/protocol/client.d.ts +9 -4
- package/dist/protocol/client.js +18 -1
- package/dist/protocol/server.d.ts +12 -0
- package/dist/protocol/server.js +116 -38
- package/dist/protocol/types.d.ts +37 -0
- package/dist/runtime/spawn-common.js +10 -0
- package/dist/tool-system/builtin/drive-claude-code.d.ts +1 -0
- package/dist/tool-system/builtin/drive-claude-code.js +137 -18
- package/dist/tool-system/builtin/index.d.ts +8 -3
- package/dist/tool-system/builtin/index.js +15 -13
- package/dist/tool-system/builtin/powershell.d.ts +5 -2
- package/dist/tool-system/builtin/powershell.js +11 -7
- package/dist/tool-system/builtin/read.js +114 -5
- package/dist/tool-system/builtin/view-image.js +9 -0
- package/dist/tool-system/executor.d.ts +1 -5
- package/dist/tool-system/executor.js +94 -115
- package/dist/tool-system/mcp-manager.d.ts +2 -0
- package/dist/tool-system/mcp-manager.js +23 -8
- package/dist/tool-system/path-policy.js +13 -0
- package/dist/tool-system/permission.d.ts +28 -7
- package/dist/tool-system/permission.js +130 -49
- package/dist/tool-system/registry.js +11 -4
- package/dist/tool-system/tool-result-redaction.d.ts +7 -0
- package/dist/tool-system/tool-result-redaction.js +48 -0
- package/dist/types.d.ts +23 -4
- 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
|
-
*
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
150
|
+
}
|
|
168
151
|
}
|
|
169
152
|
// cookie → 就地写临时 cookies.txt
|
|
170
153
|
if (cred.type === "cookie") {
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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();
|
package/dist/engine/engine.d.ts
CHANGED
|
@@ -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
|
package/dist/engine/engine.js
CHANGED
|
@@ -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 {
|
|
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.
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
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
|
|
2661
|
-
//
|
|
2662
|
-
//
|
|
2663
|
-
//
|
|
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(
|
|
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
|
|
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 +
|
|
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 =
|
|
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, "&").replace(/"/g, """).replace(/</g, "<");
|
|
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. */
|