chatccc 0.2.290 → 0.2.292
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.
|
@@ -25,6 +25,12 @@ const SEARCH_TIMEOUT_MS = 15_000;
|
|
|
25
25
|
const MAX_COMMAND_OUTPUT_BYTES = 256 * 1024;
|
|
26
26
|
const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
|
|
27
27
|
const MAX_COMMAND_TIMEOUT_MS = 900_000;
|
|
28
|
+
/** 让位注入的轮询间隔:run_command 执行期以此频率检查是否有待注入的用户消息 */
|
|
29
|
+
const YIELD_POLL_INTERVAL_MS = 250;
|
|
30
|
+
/** command_output 单次等待上限,避免代理用等待把 step 又堵死 */
|
|
31
|
+
const MAX_COMMAND_OUTPUT_WAIT_MS = 30_000;
|
|
32
|
+
/** 后台命令句柄上限;超出时优先回收已结束的句柄 */
|
|
33
|
+
const MAX_BACKGROUND_COMMANDS = 32;
|
|
28
34
|
/** task 子代理工具:子任务结果回传主会话前的最大字符数(防止子代理长输出撑爆主上下文) */
|
|
29
35
|
export const MAX_TASK_OUTPUT_CHARS = 32_000;
|
|
30
36
|
const requireFromHere = createRequire(import.meta.url);
|
|
@@ -722,7 +728,41 @@ export async function searchCodeForTool(cwd, input, signal, runtimeOptions = {})
|
|
|
722
728
|
: fallback.truncated,
|
|
723
729
|
};
|
|
724
730
|
}
|
|
725
|
-
|
|
731
|
+
const backgroundCommands = new Map();
|
|
732
|
+
let backgroundCommandSeq = 0;
|
|
733
|
+
function normalizeCommandOutputWaitMs(value) {
|
|
734
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
|
|
735
|
+
return 0;
|
|
736
|
+
return Math.min(Math.trunc(value), MAX_COMMAND_OUTPUT_WAIT_MS);
|
|
737
|
+
}
|
|
738
|
+
function snapshotBackgroundCommand(entry) {
|
|
739
|
+
return {
|
|
740
|
+
taskId: entry.taskId,
|
|
741
|
+
command: entry.command,
|
|
742
|
+
cwd: entry.cwd,
|
|
743
|
+
running: !entry.done,
|
|
744
|
+
exitCode: entry.exitCode,
|
|
745
|
+
signal: entry.signal,
|
|
746
|
+
stdout: entry.stdout.chunks.join(""),
|
|
747
|
+
stderr: entry.stderr.chunks.join(""),
|
|
748
|
+
timedOut: entry.timedOut,
|
|
749
|
+
truncated: entry.stdout.truncated || entry.stderr.truncated,
|
|
750
|
+
durationMs: Date.now() - entry.startedAt,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
/** 句柄超限时优先回收已结束的条目(运行中的条目保留,避免破坏 agent 的跟踪)。 */
|
|
754
|
+
function pruneBackgroundCommands() {
|
|
755
|
+
if (backgroundCommands.size <= MAX_BACKGROUND_COMMANDS)
|
|
756
|
+
return;
|
|
757
|
+
for (const [id, entry] of backgroundCommands) {
|
|
758
|
+
if (!entry.done)
|
|
759
|
+
continue;
|
|
760
|
+
backgroundCommands.delete(id);
|
|
761
|
+
if (backgroundCommands.size <= MAX_BACKGROUND_COMMANDS)
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
export async function runCommandForTool(cwd, input, abortSignal, coAuthor, shouldYield) {
|
|
726
766
|
const command = withGitCoAuthor(input.command?.trim(), coAuthor);
|
|
727
767
|
if (!command)
|
|
728
768
|
throw new Error("command is required");
|
|
@@ -740,6 +780,8 @@ export async function runCommandForTool(cwd, input, abortSignal, coAuthor) {
|
|
|
740
780
|
let timedOut = false;
|
|
741
781
|
let timeout;
|
|
742
782
|
let fallbackTimer;
|
|
783
|
+
let yieldPoll;
|
|
784
|
+
let backgroundEntry;
|
|
743
785
|
const child = spawn(command, {
|
|
744
786
|
cwd: commandCwd,
|
|
745
787
|
shell: true,
|
|
@@ -751,6 +793,8 @@ export async function runCommandForTool(cwd, input, abortSignal, coAuthor) {
|
|
|
751
793
|
clearTimeout(timeout);
|
|
752
794
|
if (fallbackTimer)
|
|
753
795
|
clearTimeout(fallbackTimer);
|
|
796
|
+
if (yieldPoll)
|
|
797
|
+
clearInterval(yieldPoll);
|
|
754
798
|
abortSignal?.removeEventListener("abort", abort);
|
|
755
799
|
};
|
|
756
800
|
const finish = (exitCode, signal) => {
|
|
@@ -770,12 +814,32 @@ export async function runCommandForTool(cwd, input, abortSignal, coAuthor) {
|
|
|
770
814
|
durationMs: Date.now() - startedAt,
|
|
771
815
|
});
|
|
772
816
|
};
|
|
817
|
+
/**
|
|
818
|
+
* 统一收敛点:已转入后台则更新句柄并唤醒等待方,否则按前台语义 resolve。
|
|
819
|
+
* 让位后 timeout 与 abort 的既有定时器/监听继续生效,因此后台任务仍受
|
|
820
|
+
* timeoutMs 约束,也仍能被 /stop 终止。
|
|
821
|
+
*/
|
|
822
|
+
const settle = (exitCode, signal) => {
|
|
823
|
+
if (!backgroundEntry) {
|
|
824
|
+
finish(exitCode, signal);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
backgroundEntry.done = true;
|
|
828
|
+
backgroundEntry.exitCode = exitCode;
|
|
829
|
+
backgroundEntry.signal = signal;
|
|
830
|
+
backgroundEntry.timedOut = timedOut;
|
|
831
|
+
cleanup();
|
|
832
|
+
const waiters = backgroundEntry.waiters;
|
|
833
|
+
backgroundEntry.waiters = [];
|
|
834
|
+
for (const wake of waiters)
|
|
835
|
+
wake();
|
|
836
|
+
};
|
|
773
837
|
const requestKill = (reason) => {
|
|
774
838
|
void killProcessTree(child.pid);
|
|
775
839
|
fallbackTimer = setTimeout(() => {
|
|
776
840
|
child.stdout?.destroy();
|
|
777
841
|
child.stderr?.destroy();
|
|
778
|
-
|
|
842
|
+
settle(null, reason === "timeout" ? "SIGTERM" : reason);
|
|
779
843
|
}, 5_000);
|
|
780
844
|
fallbackTimer.unref?.();
|
|
781
845
|
};
|
|
@@ -787,6 +851,70 @@ export async function runCommandForTool(cwd, input, abortSignal, coAuthor) {
|
|
|
787
851
|
requestKill("abort");
|
|
788
852
|
};
|
|
789
853
|
abortSignal?.addEventListener("abort", abort, { once: true });
|
|
854
|
+
/**
|
|
855
|
+
* 让位:命令既不杀也不等,转为后台句柄后立即返回。这样当前 step 尽快结束,
|
|
856
|
+
* 下一个 prepareStep 边界即可调用 drainInput 把用户新消息注入当前 turn。
|
|
857
|
+
*/
|
|
858
|
+
const detachToBackground = () => {
|
|
859
|
+
if (settled || backgroundEntry)
|
|
860
|
+
return;
|
|
861
|
+
// close/error 重新挂到 settle:settle 会按 backgroundEntry 分流到后台句柄
|
|
862
|
+
child.removeAllListeners("close");
|
|
863
|
+
child.removeAllListeners("error");
|
|
864
|
+
child.once("close", (code, signal) => settle(code, signal));
|
|
865
|
+
child.once("error", () => settle(null, "error"));
|
|
866
|
+
const taskId = `cmd-${++backgroundCommandSeq}-${Math.random().toString(36).slice(2, 6)}`;
|
|
867
|
+
backgroundEntry = {
|
|
868
|
+
taskId,
|
|
869
|
+
command,
|
|
870
|
+
cwd: commandCwd,
|
|
871
|
+
startedAt,
|
|
872
|
+
child,
|
|
873
|
+
stdout,
|
|
874
|
+
stderr,
|
|
875
|
+
done: false,
|
|
876
|
+
exitCode: null,
|
|
877
|
+
signal: null,
|
|
878
|
+
timedOut: false,
|
|
879
|
+
waiters: [],
|
|
880
|
+
};
|
|
881
|
+
backgroundCommands.set(taskId, backgroundEntry);
|
|
882
|
+
pruneBackgroundCommands();
|
|
883
|
+
if (yieldPoll)
|
|
884
|
+
clearInterval(yieldPoll);
|
|
885
|
+
yieldPoll = undefined;
|
|
886
|
+
// 刻意不调 cleanup:超时定时器与 abort 监听继续为后台任务服务
|
|
887
|
+
settled = true;
|
|
888
|
+
resolvePromise({
|
|
889
|
+
command,
|
|
890
|
+
cwd: commandCwd,
|
|
891
|
+
exitCode: null,
|
|
892
|
+
signal: null,
|
|
893
|
+
stdout: stdout.chunks.join(""),
|
|
894
|
+
stderr: stderr.chunks.join(""),
|
|
895
|
+
timedOut: false,
|
|
896
|
+
truncated: stdout.truncated || stderr.truncated,
|
|
897
|
+
durationMs: Date.now() - startedAt,
|
|
898
|
+
backgrounded: true,
|
|
899
|
+
taskId,
|
|
900
|
+
});
|
|
901
|
+
};
|
|
902
|
+
if (shouldYield) {
|
|
903
|
+
yieldPoll = setInterval(() => {
|
|
904
|
+
if (settled || backgroundEntry)
|
|
905
|
+
return;
|
|
906
|
+
let yieldNow = false;
|
|
907
|
+
try {
|
|
908
|
+
yieldNow = shouldYield() === true;
|
|
909
|
+
}
|
|
910
|
+
catch {
|
|
911
|
+
yieldNow = false;
|
|
912
|
+
}
|
|
913
|
+
if (yieldNow)
|
|
914
|
+
detachToBackground();
|
|
915
|
+
}, YIELD_POLL_INTERVAL_MS);
|
|
916
|
+
yieldPoll.unref?.();
|
|
917
|
+
}
|
|
790
918
|
child.stdout?.on("data", (chunk) => {
|
|
791
919
|
appendLimitedOutput(stdout, chunk);
|
|
792
920
|
});
|
|
@@ -794,6 +922,10 @@ export async function runCommandForTool(cwd, input, abortSignal, coAuthor) {
|
|
|
794
922
|
appendLimitedOutput(stderr, chunk);
|
|
795
923
|
});
|
|
796
924
|
child.once("error", (err) => {
|
|
925
|
+
if (backgroundEntry) {
|
|
926
|
+
settle(null, "error");
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
797
929
|
if (settled)
|
|
798
930
|
return;
|
|
799
931
|
settled = true;
|
|
@@ -801,10 +933,75 @@ export async function runCommandForTool(cwd, input, abortSignal, coAuthor) {
|
|
|
801
933
|
reject(err);
|
|
802
934
|
});
|
|
803
935
|
child.once("close", (code, signal) => {
|
|
804
|
-
|
|
936
|
+
settle(code, signal);
|
|
805
937
|
});
|
|
806
938
|
});
|
|
807
939
|
}
|
|
940
|
+
/** 读取后台命令的最新输出;waitMs > 0 时最多等待该时长(等待期间仍可让位)。 */
|
|
941
|
+
export async function commandOutputForTool(taskId, input, shouldYield) {
|
|
942
|
+
const entry = backgroundCommands.get(taskId);
|
|
943
|
+
if (!entry)
|
|
944
|
+
throw new Error(`未知的后台任务:${taskId}`);
|
|
945
|
+
const waitMs = normalizeCommandOutputWaitMs(input?.waitMs);
|
|
946
|
+
if (waitMs > 0 && !entry.done) {
|
|
947
|
+
await waitForBackgroundCommand(entry, waitMs, shouldYield);
|
|
948
|
+
}
|
|
949
|
+
return snapshotBackgroundCommand(entry);
|
|
950
|
+
}
|
|
951
|
+
/** 等待后台命令结束(或等待预算耗尽、或让位信号到来)。 */
|
|
952
|
+
function waitForBackgroundCommand(entry, waitMs, shouldYield) {
|
|
953
|
+
return new Promise((resolve) => {
|
|
954
|
+
let finished = false;
|
|
955
|
+
let poll;
|
|
956
|
+
const wake = () => {
|
|
957
|
+
if (finished)
|
|
958
|
+
return;
|
|
959
|
+
finished = true;
|
|
960
|
+
clearTimeout(timer);
|
|
961
|
+
if (poll)
|
|
962
|
+
clearInterval(poll);
|
|
963
|
+
const index = entry.waiters.indexOf(wake);
|
|
964
|
+
if (index >= 0)
|
|
965
|
+
entry.waiters.splice(index, 1);
|
|
966
|
+
resolve();
|
|
967
|
+
};
|
|
968
|
+
const timer = setTimeout(wake, waitMs);
|
|
969
|
+
timer.unref?.();
|
|
970
|
+
entry.waiters.push(wake);
|
|
971
|
+
if (shouldYield) {
|
|
972
|
+
poll = setInterval(() => {
|
|
973
|
+
let yieldNow = false;
|
|
974
|
+
try {
|
|
975
|
+
yieldNow = shouldYield() === true;
|
|
976
|
+
}
|
|
977
|
+
catch {
|
|
978
|
+
yieldNow = false;
|
|
979
|
+
}
|
|
980
|
+
if (yieldNow)
|
|
981
|
+
wake();
|
|
982
|
+
}, YIELD_POLL_INTERVAL_MS);
|
|
983
|
+
poll.unref?.();
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
/** 终止一个仍在运行的后台命令。 */
|
|
988
|
+
export async function commandKillForTool(taskId) {
|
|
989
|
+
const entry = backgroundCommands.get(taskId);
|
|
990
|
+
if (!entry)
|
|
991
|
+
throw new Error(`未知的后台任务:${taskId}`);
|
|
992
|
+
if (entry.done)
|
|
993
|
+
return { taskId, killed: false };
|
|
994
|
+
await killProcessTree(entry.child.pid);
|
|
995
|
+
return { taskId, killed: true };
|
|
996
|
+
}
|
|
997
|
+
/** 终止并清空全部后台命令句柄(进程退出与测试收尾),避免留下孤儿进程。 */
|
|
998
|
+
export function killAllBackgroundCommands() {
|
|
999
|
+
for (const entry of backgroundCommands.values()) {
|
|
1000
|
+
if (!entry.done)
|
|
1001
|
+
void killProcessTree(entry.child.pid);
|
|
1002
|
+
}
|
|
1003
|
+
backgroundCommands.clear();
|
|
1004
|
+
}
|
|
808
1005
|
export async function editFileForTool(cwd, input) {
|
|
809
1006
|
if (!Array.isArray(input.edits) || input.edits.length === 0) {
|
|
810
1007
|
throw new Error("edits must contain at least one replacement");
|
|
@@ -1090,7 +1287,7 @@ export function createBuiltinFileTools(cwd, options = {}) {
|
|
|
1090
1287
|
execute: (input) => presentFileForTool(cwd, input),
|
|
1091
1288
|
}),
|
|
1092
1289
|
run_command: tool({
|
|
1093
|
-
description: "在本地工作区运行非交互式 shell 命令。用于测试、git 和包脚本。返回 stdout/stderr 和 exitCode
|
|
1290
|
+
description: "在本地工作区运行非交互式 shell 命令。用于测试、git 和包脚本。返回 stdout/stderr 和 exitCode;非零退出码不是工具错误。命令运行期间若用户发来新消息,会立即转入后台并返回 taskId(backgrounded:true),命令继续运行,用 command_output 取后续输出。",
|
|
1094
1291
|
inputSchema: jsonSchema({
|
|
1095
1292
|
type: "object",
|
|
1096
1293
|
additionalProperties: false,
|
|
@@ -1108,9 +1305,34 @@ export function createBuiltinFileTools(cwd, options = {}) {
|
|
|
1108
1305
|
reason: isDangerousCommand(input.command) ? "high-risk" : "rule",
|
|
1109
1306
|
detail: `运行命令: ${input.command}`,
|
|
1110
1307
|
});
|
|
1111
|
-
return runCommandForTool(cwd, input, execOptions.abortSignal, options.gitCoAuthor);
|
|
1308
|
+
return runCommandForTool(cwd, input, execOptions.abortSignal, options.gitCoAuthor, options.shouldYieldToInjection);
|
|
1112
1309
|
},
|
|
1113
1310
|
}),
|
|
1311
|
+
command_output: tool({
|
|
1312
|
+
description: "读取被让位到后台的命令的最新输出。立即返回当前状态;waitMs 可最多等待 30 秒。任务结束后可反复读取完整 stdout/stderr。",
|
|
1313
|
+
inputSchema: jsonSchema({
|
|
1314
|
+
type: "object",
|
|
1315
|
+
additionalProperties: false,
|
|
1316
|
+
properties: {
|
|
1317
|
+
taskId: { type: "string", description: "run_command 返回的 taskId(backgrounded:true 时)。" },
|
|
1318
|
+
waitMs: { type: "number", description: `可选等待毫秒数;0 或省略立即返回,上限 ${MAX_COMMAND_OUTPUT_WAIT_MS}。` },
|
|
1319
|
+
},
|
|
1320
|
+
required: ["taskId"],
|
|
1321
|
+
}),
|
|
1322
|
+
execute: (input) => commandOutputForTool(input.taskId, input, options.shouldYieldToInjection),
|
|
1323
|
+
}),
|
|
1324
|
+
command_kill: tool({
|
|
1325
|
+
description: "终止一个仍在后台运行的命令(run_command 让位后转入后台的)。",
|
|
1326
|
+
inputSchema: jsonSchema({
|
|
1327
|
+
type: "object",
|
|
1328
|
+
additionalProperties: false,
|
|
1329
|
+
properties: {
|
|
1330
|
+
taskId: { type: "string", description: "run_command 返回的 taskId。" },
|
|
1331
|
+
},
|
|
1332
|
+
required: ["taskId"],
|
|
1333
|
+
}),
|
|
1334
|
+
execute: (input) => commandKillForTool(input.taskId),
|
|
1335
|
+
}),
|
|
1114
1336
|
task: tool({
|
|
1115
1337
|
description: "把独立子任务委派给子代理执行:子代理使用子模型、拥有独立上下文,不污染主对话上下文。适合边界清晰、可独立交付的调研/代码生成子任务(如扫描整个仓库、阅读长文档、生成独立模块)。子代理不能再次委派任务(禁止嵌套),结果会截断回传。",
|
|
1116
1338
|
inputSchema: jsonSchema({
|
|
@@ -270,6 +270,8 @@ export class ChatSession {
|
|
|
270
270
|
context;
|
|
271
271
|
compactionTimeoutMs;
|
|
272
272
|
maxSteps;
|
|
273
|
+
/** 透传给 run_command 的让位注入判定(见 ChatSessionOptions.shouldYieldToInjection)。 */
|
|
274
|
+
shouldYieldToInjection;
|
|
273
275
|
effort;
|
|
274
276
|
maxOutputTokens;
|
|
275
277
|
streaming;
|
|
@@ -359,6 +361,7 @@ export class ChatSession {
|
|
|
359
361
|
this.subModel = this.subModelId ? provider(this.subModelId) : this.model;
|
|
360
362
|
this.cwd = options.cwd ?? process.cwd();
|
|
361
363
|
this.maxSteps = normalizeMaxSteps(options.maxSteps);
|
|
364
|
+
this.shouldYieldToInjection = options.shouldYieldToInjection;
|
|
362
365
|
this.compactionTimeoutMs = Math.max(1, options.compactionTimeoutMs ?? DEFAULT_COMPACTION_TIMEOUT_MS);
|
|
363
366
|
this.customSystemPrompt = options.systemPrompt ?? "";
|
|
364
367
|
this.permissionMode = options.permissionMode ?? "ask";
|
|
@@ -493,6 +496,7 @@ export class ChatSession {
|
|
|
493
496
|
tools: createBuiltinFileTools(this.cwd, {
|
|
494
497
|
permissionGate: this.permissionGate,
|
|
495
498
|
runTask: this.runTask,
|
|
499
|
+
shouldYieldToInjection: this.shouldYieldToInjection,
|
|
496
500
|
gitCoAuthor: {
|
|
497
501
|
...appConfig.git.coAuthor,
|
|
498
502
|
enabled: this.gitCoAuthorEnabled,
|
|
@@ -19,6 +19,9 @@ function toChatSessionOptions(sessionId, cwd, options) {
|
|
|
19
19
|
// 高危命令不询问,全部放行(与独立 deepccc CLI 的 ask 模式不同)
|
|
20
20
|
permissionMode: "bypass",
|
|
21
21
|
...(options.gitCoAuthor !== undefined ? { gitCoAuthor: options.gitCoAuthor } : {}),
|
|
22
|
+
...(options.hasPendingInjection
|
|
23
|
+
? { shouldYieldToInjection: () => options.hasPendingInjection(sessionId) === true }
|
|
24
|
+
: {}),
|
|
22
25
|
};
|
|
23
26
|
}
|
|
24
27
|
export function createCccAdapter(options = {}) {
|
|
@@ -1,26 +1,109 @@
|
|
|
1
1
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* 会话级能力凭据(capability grant)。
|
|
7
|
+
*
|
|
8
|
+
* 内存 Map 是本进程的权威视图;磁盘只用于跨进程重启恢复。
|
|
9
|
+
*
|
|
10
|
+
* 两条必须遵守的约束:
|
|
11
|
+
* 1. 签发后要落盘、校验时先内存后磁盘。否则 ChatCCC 重启会让所有定时发送
|
|
12
|
+
* (周期报告、后台任务)在重启后立刻收到 403,而工作负载本身仍在运行 ——
|
|
13
|
+
* 故障是静默的。
|
|
14
|
+
* 2. clearAgentCapabilityGrants() 只清内存缓存,**绝不**删盘。它在进程首次启动
|
|
15
|
+
* 的 resetState() 里被调用;若此处删盘,每次重启都会重新随机发码,本修复
|
|
16
|
+
* 直接失效。需要真正作废授权请用 revokeAgentCapabilityGrant()。
|
|
17
|
+
*/
|
|
2
18
|
const grantsBySession = new Map();
|
|
19
|
+
/**
|
|
20
|
+
* 与 config.ts 的 USER_DATA_DIR 同源。这里刻意不 import config.ts:该模块在顶层
|
|
21
|
+
* 有副作用(初始化文件日志、加载配置),被测试间接导入会带来多余 I/O 与循环
|
|
22
|
+
* 依赖风险;重复一个路径构造比引入副作用更安全。
|
|
23
|
+
*/
|
|
24
|
+
const DEFAULT_GRANTS_DIR = join(homedir(), ".chatccc", "state", "agent-grants");
|
|
25
|
+
/** 覆盖点(测试/运维);在每次调用时读取,便于测试导入之后再设置。 */
|
|
26
|
+
export function agentGrantDir() {
|
|
27
|
+
const override = process.env.CHATCCC_AGENT_GRANT_DIR;
|
|
28
|
+
return override !== undefined && override.trim() !== "" ? override : DEFAULT_GRANTS_DIR;
|
|
29
|
+
}
|
|
30
|
+
function grantFilePath(sessionId) {
|
|
31
|
+
// sessionId 会进入文件名:只保留安全字符,避免路径穿越。
|
|
32
|
+
const safe = sessionId.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
33
|
+
return join(agentGrantDir(), `${safe}.grant`);
|
|
34
|
+
}
|
|
35
|
+
function readPersistedGrant(sessionId) {
|
|
36
|
+
try {
|
|
37
|
+
const value = readFileSync(grantFilePath(sessionId), "utf8").trim();
|
|
38
|
+
return value === "" ? undefined : value;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined; // 不存在 / 不可读都视作“没有持久化授权”
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function persistGrant(sessionId, grant) {
|
|
45
|
+
try {
|
|
46
|
+
mkdirSync(agentGrantDir(), { recursive: true });
|
|
47
|
+
const target = grantFilePath(sessionId);
|
|
48
|
+
const tmp = `${target}.tmp`;
|
|
49
|
+
// Windows 会忽略 mode,实际保护来自用户目录 ACL;POSIX 下为 0600。
|
|
50
|
+
writeFileSync(tmp, `${grant}\n`, { encoding: "utf8", mode: 0o600 });
|
|
51
|
+
renameSync(tmp, target); // 原子替换,避免读到半写文件
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// 落盘失败不能让签发失败:内存里的授权码在本次进程生命周期内仍然可用。
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function removePersistedGrant(sessionId) {
|
|
58
|
+
try {
|
|
59
|
+
rmSync(grantFilePath(sessionId), { force: true });
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* 删除失败不影响内存态 */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** 内存优先;内存没有则从磁盘恢复,避免重启后重新随机发码。 */
|
|
66
|
+
function expectedGrant(sessionId) {
|
|
67
|
+
const inMemory = grantsBySession.get(sessionId);
|
|
68
|
+
if (inMemory !== undefined)
|
|
69
|
+
return inMemory;
|
|
70
|
+
const persisted = readPersistedGrant(sessionId);
|
|
71
|
+
if (persisted !== undefined)
|
|
72
|
+
grantsBySession.set(sessionId, persisted);
|
|
73
|
+
return persisted;
|
|
74
|
+
}
|
|
3
75
|
export function issueAgentCapabilityGrant(sessionId) {
|
|
4
76
|
if (!sessionId)
|
|
5
77
|
throw new Error("sessionId is required for an agent capability grant");
|
|
6
|
-
const existing =
|
|
78
|
+
const existing = expectedGrant(sessionId);
|
|
7
79
|
if (existing !== undefined)
|
|
8
80
|
return existing;
|
|
9
81
|
const grant = randomBytes(32).toString("base64url");
|
|
10
82
|
grantsBySession.set(sessionId, grant);
|
|
83
|
+
persistGrant(sessionId, grant);
|
|
11
84
|
return grant;
|
|
12
85
|
}
|
|
13
86
|
export function validateAgentCapabilityGrant(sessionId, candidate) {
|
|
14
87
|
if (!sessionId || typeof candidate !== "string" || candidate.length === 0)
|
|
15
88
|
return false;
|
|
16
|
-
const expected =
|
|
89
|
+
const expected = expectedGrant(sessionId);
|
|
17
90
|
if (expected === undefined)
|
|
18
|
-
return false;
|
|
91
|
+
return false; // 查不到一律拒绝(fail closed)
|
|
19
92
|
const expectedBytes = Buffer.from(expected, "utf8");
|
|
20
93
|
const candidateBytes = Buffer.from(candidate, "utf8");
|
|
21
94
|
return expectedBytes.length === candidateBytes.length
|
|
22
95
|
&& timingSafeEqual(expectedBytes, candidateBytes);
|
|
23
96
|
}
|
|
97
|
+
/** 真正作废某个会话的授权(内存 + 磁盘)。 */
|
|
98
|
+
export function revokeAgentCapabilityGrant(sessionId) {
|
|
99
|
+
grantsBySession.delete(sessionId);
|
|
100
|
+
removePersistedGrant(sessionId);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 只清内存缓存,**不**删磁盘授权。
|
|
104
|
+
* 调用点:resetState()(进程首次启动)与测试清理。
|
|
105
|
+
* 若在这里同时删盘,则每次重启都会换码 —— 那正是本模块要修掉的故障。
|
|
106
|
+
*/
|
|
24
107
|
export function clearAgentCapabilityGrants() {
|
|
25
108
|
grantsBySession.clear();
|
|
26
109
|
}
|
package/dist/src/session.js
CHANGED
|
@@ -29,7 +29,7 @@ function compressWechatDisplayText(text) {
|
|
|
29
29
|
}
|
|
30
30
|
import { readStreamState, writeStreamState, createEmptyStreamState, isFinalReplySentForTurn, markFinalReplySent, } from "./stream-state.js";
|
|
31
31
|
import { addCardToTurn, finalizeTurnCards, markCardDone } from "./turn-cards.js";
|
|
32
|
-
import { bindChatToSession, unbindChatFromSession, getChatsForSession, activePrompts, displayCards, unifiedDisplayLoopHandle, setUnifiedDisplayLoopHandle, rebuildSessionChatsFromRegistry, recordLastActiveChat, getLastActiveChat, pickDisplayChat, dequeueMessage, consumeQueuedMessage, cancelQueuedMessage, shiftInjection, unshiftInjection, drainRemainingInjections, clearInjections, setQueuePreservedChat, consumeQueuePreservedChat, markSessionFinalizing, clearSessionFinalizing, reserveAutoRecovery, consumeAutoRecoveryReservation, cancelAutoRecoveryReservation, hasAutoRecoveryReservation, } from "./session-chat-binding.js";
|
|
32
|
+
import { bindChatToSession, unbindChatFromSession, getChatsForSession, activePrompts, displayCards, unifiedDisplayLoopHandle, setUnifiedDisplayLoopHandle, rebuildSessionChatsFromRegistry, recordLastActiveChat, getLastActiveChat, pickDisplayChat, dequeueMessage, consumeQueuedMessage, cancelQueuedMessage, shiftInjection, unshiftInjection, drainRemainingInjections, clearInjections, setQueuePreservedChat, consumeQueuePreservedChat, markSessionFinalizing, clearSessionFinalizing, reserveAutoRecovery, consumeAutoRecoveryReservation, cancelAutoRecoveryReservation, hasAutoRecoveryReservation, hasPendingInjection, } from "./session-chat-binding.js";
|
|
33
33
|
async function sendFinalReplyTextOnce(platform, chatId, sessionId, turnCount, text) {
|
|
34
34
|
const sent = await platform.sendText(chatId, text).then((ok) => ok !== false).catch(() => false);
|
|
35
35
|
if (sent)
|
|
@@ -550,6 +550,7 @@ export function getAdapterForTool(tool, sessionId) {
|
|
|
550
550
|
...(config.ccc.provider ? { provider: config.ccc.provider } : {}),
|
|
551
551
|
...(config.ccc.subModel ? { subModel: config.ccc.subModel } : {}),
|
|
552
552
|
...(config.ccc.gitCoAuthor !== null ? { gitCoAuthor: config.ccc.gitCoAuthor } : {}),
|
|
553
|
+
hasPendingInjection,
|
|
553
554
|
});
|
|
554
555
|
}
|
|
555
556
|
else if (tool === "dsh") {
|