evolcore 0.0.12 → 0.0.13
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/CHANGELOG.md +22 -0
- package/bin/ec-safe-output.js +26 -7
- package/dist/agents/claude-runner.js +53 -7
- package/dist/agents/codex-app-server-client.js +53 -5
- package/dist/agents/codex-runner.js +104 -59
- package/dist/agents/ecagent-runner.js +4 -1
- package/dist/aun/aid/control-aid.js +36 -13
- package/dist/aun/aid/store.js +13 -7
- package/dist/aun/group-fs-download.js +56 -0
- package/dist/aun/group-identity.js +1 -2
- package/dist/aun/msg/group.js +11 -33
- package/dist/aun/msg/index.js +1 -2
- package/dist/aun/msg/managed-operation.js +116 -55
- package/dist/aun/msg/upload.js +16 -1
- package/dist/aun/outbox.js +31 -2
- package/dist/aun/rpc/client.js +66 -0
- package/dist/aun/rpc/index.js +1 -2
- package/dist/aun/storage/{upload.js → client.js} +11 -2
- package/dist/aun/storage/index.js +1 -2
- package/dist/channels/aun.js +371 -136
- package/dist/cli/agent.js +9 -96
- package/dist/cli/cli-argv.js +10 -0
- package/dist/cli/config.js +9 -17
- package/dist/cli/ctl-command.js +1 -1
- package/dist/cli/daemon-commands.js +39 -24
- package/dist/cli/fs-command.js +19 -10
- package/dist/cli/init.js +77 -8
- package/dist/cli/queue-command.js +42 -0
- package/dist/cli/restart-monitor.js +3 -14
- package/dist/cli/trigger-command.js +7 -0
- package/dist/config/access-policy.js +1 -2
- package/dist/config/builtin-roles.js +5 -0
- package/dist/config/config-manager.js +21 -1
- package/dist/config/peer-role-resolver.js +6 -2
- package/dist/config/role-migration-startup.js +53 -0
- package/dist/config/role-store.js +1 -1
- package/dist/core/auth/agent-delegation.js +1 -1
- package/dist/core/auth/auth-gateway.js +29 -1
- package/dist/core/auth/authorization-audit.js +15 -0
- package/dist/core/auth/operation-authorizer.js +1 -0
- package/dist/core/auth/operation-catalog.js +9 -0
- package/dist/core/bootstrap-messages.js +9 -2
- package/dist/core/bootstrap-service.js +1 -0
- package/dist/core/command/command-handler.js +198 -51
- package/dist/core/command/menu-catalog.js +4 -0
- package/dist/core/command/menu-handler.js +85 -18
- package/dist/core/command/menu-protocol.js +23 -2
- package/dist/core/command/slash-handler.js +58 -10
- package/dist/core/event-catalog.js +6 -0
- package/dist/core/handoff/runtime.js +12 -1
- package/dist/core/handoff/store.js +5 -1
- package/dist/core/interaction-router.js +10 -0
- package/dist/core/message/im-renderer.js +19 -0
- package/dist/core/message/message-bridge.js +63 -11
- package/dist/core/message/message-log.js +2 -0
- package/dist/core/message/message-queue.js +267 -39
- package/dist/core/message/message-utils.js +81 -4
- package/dist/core/message/response-engine.js +107 -41
- package/dist/core/message/stream-debouncer.js +10 -2
- package/dist/core/permission/approval-gateway.js +1 -0
- package/dist/core/permission/ec-command-parser.js +93 -14
- package/dist/core/permission/index.js +1 -1
- package/dist/core/permission/mode.js +15 -0
- package/dist/core/permission/readonly-shell-query.js +148 -8
- package/dist/core/permission/sandbox-runtime.js +21 -0
- package/dist/core/permission/tool-policy.js +249 -27
- package/dist/core/protected-paths.js +107 -2
- package/dist/core/session/session-manager.js +21 -2
- package/dist/core/session/session-mapper.js +20 -1
- package/dist/eck/detect.js +18 -1
- package/dist/eck/group-rules-sync.js +15 -44
- package/dist/index.js +161 -86
- package/dist/ipc.js +16 -3
- package/dist/paths.js +3 -0
- package/dist/trigger/feedback.js +64 -11
- package/dist/trigger/manager.js +21 -1
- package/dist/trigger/parser.js +14 -2
- package/dist/trigger/scheduler.js +1 -0
- package/dist/trigger/validation.js +67 -6
- package/dist/utils/ecweb-supervisor.js +332 -0
- package/dist/utils/error-utils.js +10 -0
- package/kits/docs/evolcore/trigger.md +17 -1
- package/kits/migrations/migrate-contact-book-v2.mjs +1 -2
- package/kits/schemas/role-config.schema.1.json +1 -0
- package/kits/templates/roles/admin.json +5 -0
- package/kits/templates/roles/member.json +7 -1
- package/kits/templates/roles/visitor.json +2 -0
- package/package.json +1 -1
- package/dist/aun/msg/payload-type.js +0 -27
- package/dist/aun/rpc/caller.js +0 -42
- package/dist/aun/rpc/connection.js +0 -25
- package/dist/aun/storage/manage.js +0 -10
- package/dist/config/access-policy-domain.js +0 -18
- package/dist/config/config-batch-get.js +0 -11
- package/dist/config/owner-policy.js +0 -4
- package/dist/config/role-config-v4-startup.js +0 -32
- package/dist/config/role-config-v5-startup.js +0 -27
- package/dist/core/auth/trigger-authorization.js +0 -15
- package/dist/core/interaction-registration.js +0 -10
- package/dist/core/permission/execution-sandbox.js +0 -16
- package/dist/core/relation/peer-key.js +0 -1
- package/dist/core/session/session-key.js +0 -24
- package/dist/eck/baseagent-caps.js +0 -18
- package/dist/eck/rules-loader.js +0 -28
|
@@ -3,9 +3,94 @@ import path from 'path';
|
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
4
|
import { logger } from '../../utils/logger.js';
|
|
5
5
|
import { resolveRoot } from '../../paths.js';
|
|
6
|
-
import { containsHClassReference, containsLClassReference, getExistingHClassMaskTargets, hClassGrantIncludesProtectedPath, isHClassPath, isSameOrDescendant, isLClassPath, lClassGrantIncludesProtectedPath, resolveProtectedCandidate, } from '../protected-paths.js';
|
|
6
|
+
import { containsHClassReference, containsLClassReference, checkProtectedPathAccess, getExistingHClassMaskTargets, hClassGrantIncludesProtectedPath, isHClassPath, isSameOrDescendant, isLClassPath, lClassGrantIncludesProtectedPath, resolveProtectedCandidate, resolveProtectedCandidateWithoutFinalSymlink, } from '../protected-paths.js';
|
|
7
7
|
import { classifyEvolcoreShellCommand, parseCodexToolCommandArgv, parseBoundedOutputShellCommand, parseLiteralShellArgv, unwrapCodexShellCommandArgv, } from './ec-command-parser.js';
|
|
8
8
|
import { analyzeReadonlyShellQuery, } from './readonly-shell-query.js';
|
|
9
|
+
/** Resolve the session-owned temporary root supplied by the execution host. */
|
|
10
|
+
export function resolveManagedTempDir(env = process.env) {
|
|
11
|
+
const raw = typeof env.TMPDIR === 'string' ? env.TMPDIR.trim() : '';
|
|
12
|
+
if (!raw || !path.isAbsolute(raw))
|
|
13
|
+
return undefined;
|
|
14
|
+
const resolved = resolveProtectedCandidate(raw);
|
|
15
|
+
// A process-wide shared temporary root is not a session capability. The
|
|
16
|
+
// runner must register a private subdirectory before `$TMPDIR` is trusted.
|
|
17
|
+
if (['/tmp', '/var/tmp', '/dev/shm'].includes(resolved))
|
|
18
|
+
return undefined;
|
|
19
|
+
return resolved;
|
|
20
|
+
}
|
|
21
|
+
function substituteTmpDir(value, root) {
|
|
22
|
+
if (!root)
|
|
23
|
+
return value;
|
|
24
|
+
return value
|
|
25
|
+
.replace(/\$\{TMPDIR\}/g, root)
|
|
26
|
+
.replace(/\$TMPDIR\b/g, root);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A temp path is valid only when its canonical existing ancestor remains below
|
|
30
|
+
* the current session's TMPDIR. This also closes symlink and `..` escapes.
|
|
31
|
+
*/
|
|
32
|
+
export function isManagedTempPath(value, options = {}) {
|
|
33
|
+
const root = resolveManagedTempDir(options.env);
|
|
34
|
+
if (!root || typeof value !== 'string' || !value.trim())
|
|
35
|
+
return false;
|
|
36
|
+
const candidate = resolveProtectedCandidate(substituteTmpDir(value, root), options.cwd);
|
|
37
|
+
return isSameOrDescendant(candidate, root);
|
|
38
|
+
}
|
|
39
|
+
export function expandManagedTempPath(value, options = {}) {
|
|
40
|
+
return substituteTmpDir(value, resolveManagedTempDir(options.env));
|
|
41
|
+
}
|
|
42
|
+
const WRITE_INTENT_RE = /(?:^|[\s;&|])(?:mkdir|mktemp|touch|tee|cp|mv|install|chmod|chown|ln|rm)(?:[\s;&|]|$)|(?:^|[\s])(?:\d>>?|>>?)\s*/i;
|
|
43
|
+
const TEMP_TOKEN_RE = /(?:\$TMPDIR|\$\{TMPDIR\}|\/tmp(?:\/|$)|\/var\/tmp(?:\/|$)|\/dev\/shm(?:\/|$))[^\s'";&|<>)]*/g;
|
|
44
|
+
/**
|
|
45
|
+
* Inspect shell write targets that look like temporary paths. Ordinary
|
|
46
|
+
* project writes are left to the existing workspace/H-class policy; any
|
|
47
|
+
* explicit /tmp-style target must be contained by TMPDIR.
|
|
48
|
+
*/
|
|
49
|
+
export function checkManagedTempShellCommand(command, options = {}) {
|
|
50
|
+
if (!WRITE_INTENT_RE.test(command))
|
|
51
|
+
return { kind: 'none' };
|
|
52
|
+
const root = resolveManagedTempDir(options.env);
|
|
53
|
+
const normalized = substituteTmpDir(command, root);
|
|
54
|
+
const candidates = [...normalized.matchAll(TEMP_TOKEN_RE)].map(match => match[0].replace(/[.,:]+$/, ''));
|
|
55
|
+
if (candidates.length === 0)
|
|
56
|
+
return { kind: 'none' };
|
|
57
|
+
if (!root) {
|
|
58
|
+
return { kind: 'deny', paths: candidates, reason: 'TMPDIR is unset or not an absolute managed path' };
|
|
59
|
+
}
|
|
60
|
+
const outside = candidates.filter(candidate => !isManagedTempPath(candidate, {
|
|
61
|
+
...options,
|
|
62
|
+
env: { ...(options.env ?? process.env), TMPDIR: root },
|
|
63
|
+
}));
|
|
64
|
+
if (outside.length > 0) {
|
|
65
|
+
return { kind: 'deny', paths: outside, reason: 'temporary write target is outside the session-managed TMPDIR' };
|
|
66
|
+
}
|
|
67
|
+
return { kind: 'allow', paths: candidates };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Replace only already-validated temporary path operands before a second
|
|
71
|
+
* policy pass. This prevents a harmless `$TMPDIR/CA/root` name from looking
|
|
72
|
+
* like the real EvolCore CA directory while preserving any unrelated H-class
|
|
73
|
+
* operand in the same command.
|
|
74
|
+
*/
|
|
75
|
+
export function maskManagedTempShellPaths(command, options = {}) {
|
|
76
|
+
const check = checkManagedTempShellCommand(command, options);
|
|
77
|
+
if (check.kind !== 'allow')
|
|
78
|
+
return command;
|
|
79
|
+
const root = resolveManagedTempDir(options.env);
|
|
80
|
+
const normalized = substituteTmpDir(command, root);
|
|
81
|
+
let index = 0;
|
|
82
|
+
return normalized.replace(TEMP_TOKEN_RE, token => {
|
|
83
|
+
const candidate = token.replace(/[.,:]+$/, '');
|
|
84
|
+
if (!check.paths.includes(candidate) || !isManagedTempPath(candidate, {
|
|
85
|
+
...options,
|
|
86
|
+
env: { ...(options.env ?? process.env), TMPDIR: root },
|
|
87
|
+
})) {
|
|
88
|
+
return token;
|
|
89
|
+
}
|
|
90
|
+
index += 1;
|
|
91
|
+
return `__managed_tmp_${index}__`;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
9
94
|
// 绝对禁止命令(所有角色、所有权限模式下都禁止,不可授权)
|
|
10
95
|
// 这些是系统级破坏操作,任何情况下都不应该允许
|
|
11
96
|
const ABSOLUTE_FORBIDDEN = [
|
|
@@ -320,9 +405,50 @@ function containsNetStopCommand(command, depth = 0) {
|
|
|
320
405
|
});
|
|
321
406
|
}
|
|
322
407
|
function queryPathOperands(analysis) {
|
|
323
|
-
|
|
408
|
+
if (analysis.kind !== 'proven-readonly')
|
|
409
|
+
return [];
|
|
410
|
+
return analysis.ir.pipelines.flatMap(pipeline => pipeline.flatMap(command => command.pathOperands.map(operand => ({
|
|
411
|
+
operand,
|
|
412
|
+
accessKind: command.accessKind ?? (operand.access === 'recursive' ? 'enumerate' : 'content'),
|
|
413
|
+
}))));
|
|
324
414
|
}
|
|
325
|
-
function shellOperandMatchesClass(operand, className, options) {
|
|
415
|
+
function shellOperandMatchesClass(operand, className, options, accessKind = operand.access === 'recursive' ? 'enumerate' : 'content') {
|
|
416
|
+
// `$TMPDIR` is a private session namespace, so names such as `CA` or
|
|
417
|
+
// `config.json` inside it are ordinary temporary data rather than paths in
|
|
418
|
+
// the EvolCore root. Resolve the candidate first so a symlink escape does
|
|
419
|
+
// not inherit this exception.
|
|
420
|
+
if (options.managedTempDir) {
|
|
421
|
+
const managedTemp = resolveProtectedCandidate(options.managedTempDir);
|
|
422
|
+
const candidate = accessKind === 'metadata' && operand.access === 'exact'
|
|
423
|
+
? resolveProtectedCandidateWithoutFinalSymlink(operand.value, options.cwd)
|
|
424
|
+
: resolveProtectedCandidate(operand.value, options.cwd);
|
|
425
|
+
const workspace = options.cwd ? resolveProtectedCandidate(options.cwd) : undefined;
|
|
426
|
+
if (isSameOrDescendant(candidate, managedTemp)
|
|
427
|
+
&& (!workspace || !isSameOrDescendant(candidate, workspace))) {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (className === 'h' && accessKind === 'metadata' && operand.access === 'exact') {
|
|
432
|
+
// Filesystem sandboxes currently expose only path-wide H-class deny
|
|
433
|
+
// rules. A runner that cannot provide an action-aware metadata channel
|
|
434
|
+
// must keep the policy decision aligned with that lower-level boundary,
|
|
435
|
+
// but ordinary workspace metadata remains safe and should stay usable.
|
|
436
|
+
const metadata = checkProtectedPathAccess(operand.value, 'metadata', {
|
|
437
|
+
...options,
|
|
438
|
+
// stat/lstat must inspect the final symlink itself, never its target.
|
|
439
|
+
followFinalSymlink: false,
|
|
440
|
+
});
|
|
441
|
+
if (options.allowProtectedMetadata === false) {
|
|
442
|
+
return metadata.pathClass === 'h-file' || metadata.pathClass === 'h-directory';
|
|
443
|
+
}
|
|
444
|
+
// The no-follow decision is authoritative for metadata. In particular,
|
|
445
|
+
// an ordinary symlink whose target is H-class must remain inspectable as a
|
|
446
|
+
// link; falling through to isHClassPath() would follow it and reject the
|
|
447
|
+
// otherwise safe lstat/stat operation.
|
|
448
|
+
if (metadata.pathClass !== 'unknown' && metadata.allowed) {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
326
452
|
const reference = className === 'h' ? containsHClassReference(operand.value) : containsLClassReference(operand.value);
|
|
327
453
|
if (reference)
|
|
328
454
|
return true;
|
|
@@ -352,7 +478,7 @@ function hClassReadScopeIncludesProtectedPath(value, options) {
|
|
|
352
478
|
return getExistingHClassMaskTargets(root).some(target => isSameOrDescendant(target.path, scope));
|
|
353
479
|
}
|
|
354
480
|
function analyzeShellProtectedOperands(command, options) {
|
|
355
|
-
const analysis = analyzeReadonlyShellQuery(command);
|
|
481
|
+
const analysis = analyzeReadonlyShellQuery(command, { managedTempDir: options.managedTempDir });
|
|
356
482
|
if (analysis.kind === 'unproven') {
|
|
357
483
|
return {
|
|
358
484
|
analysis,
|
|
@@ -363,8 +489,8 @@ function analyzeShellProtectedOperands(command, options) {
|
|
|
363
489
|
const operands = queryPathOperands(analysis);
|
|
364
490
|
return {
|
|
365
491
|
analysis,
|
|
366
|
-
hClass: operands.some(operand => shellOperandMatchesClass(operand, 'h', options)),
|
|
367
|
-
lClass: operands.some(operand => shellOperandMatchesClass(operand, 'l', options)),
|
|
492
|
+
hClass: operands.some(({ operand, accessKind }) => shellOperandMatchesClass(operand, 'h', options, accessKind)),
|
|
493
|
+
lClass: operands.some(({ operand, accessKind }) => shellOperandMatchesClass(operand, 'l', options, accessKind)),
|
|
368
494
|
};
|
|
369
495
|
}
|
|
370
496
|
function protectedReadToolPaths(toolName, input) {
|
|
@@ -444,11 +570,21 @@ function protectedReadToolStaysInWorkspace(toolName, input, projectPath) {
|
|
|
444
570
|
}
|
|
445
571
|
return true;
|
|
446
572
|
}
|
|
447
|
-
function readonlyShellStaysInWorkspace(analysis, projectPath) {
|
|
573
|
+
function readonlyShellStaysInWorkspace(analysis, projectPath, managedTempDir) {
|
|
448
574
|
if (analysis.kind !== 'proven-readonly')
|
|
449
575
|
return false;
|
|
450
576
|
const workspace = resolveProtectedCandidate(projectPath);
|
|
451
|
-
|
|
577
|
+
const managedTemp = managedTempDir ? resolveProtectedCandidate(managedTempDir) : undefined;
|
|
578
|
+
return analysis.ir.pipelines.flatMap(pipeline => pipeline.flatMap(command => command.pathOperands.map(operand => ({
|
|
579
|
+
operand,
|
|
580
|
+
accessKind: command.accessKind ?? (operand.access === 'recursive' ? 'enumerate' : 'content'),
|
|
581
|
+
})))).every(({ operand, accessKind }) => (() => {
|
|
582
|
+
const candidate = accessKind === 'metadata' && operand.access === 'exact'
|
|
583
|
+
? resolveProtectedCandidateWithoutFinalSymlink(operand.value, projectPath)
|
|
584
|
+
: resolveProtectedCandidate(operand.value, projectPath);
|
|
585
|
+
return isSameOrDescendant(candidate, workspace)
|
|
586
|
+
|| (!!managedTemp && isSameOrDescendant(candidate, managedTemp));
|
|
587
|
+
})());
|
|
452
588
|
}
|
|
453
589
|
/**
|
|
454
590
|
* 只读模式检查(用于 PreToolUse hook 和 canUseTool callback)。普通
|
|
@@ -463,50 +599,61 @@ export function checkReadonly(toolName, input, projectPath, context) {
|
|
|
463
599
|
const paths = protectedReadToolPaths(toolName, input);
|
|
464
600
|
const pathOptions = { cwd: projectPath, root: context?.root };
|
|
465
601
|
if (!protectedReadToolStaysInWorkspace(toolName, input, projectPath)) {
|
|
466
|
-
return { behavior: 'deny', message: '🔒 只读模式:文件读取范围不能越出当前项目目录' };
|
|
602
|
+
return { behavior: 'deny', message: '🔒 只读模式:文件读取范围不能越出当前项目目录', policyCode: 'readonly_workspace_escape' };
|
|
467
603
|
}
|
|
468
604
|
if (paths.some(filePath => containsHClassReference(filePath) || isHClassPath(filePath, pathOptions))) {
|
|
469
|
-
return { behavior: 'deny', message: '🔒 只读模式:不允许读取 H 类受保护路径' };
|
|
605
|
+
return { behavior: 'deny', message: '🔒 只读模式:不允许读取 H 类受保护路径', policyCode: 'readonly_h_class_read' };
|
|
470
606
|
}
|
|
471
607
|
if (protectedReadToolMatchesHClass(toolName, input, pathOptions)) {
|
|
472
|
-
return { behavior: 'deny', message: '🔒 只读模式:不允许读取 H 类受保护路径' };
|
|
608
|
+
return { behavior: 'deny', message: '🔒 只读模式:不允许读取 H 类受保护路径', policyCode: 'readonly_h_class_read' };
|
|
473
609
|
}
|
|
474
610
|
if (protectedReadToolMatchesLClass(toolName, input, pathOptions)) {
|
|
475
|
-
return { behavior: 'deny', message: '🔒 只读模式:visitor 最低权限不允许读取 L-class 路径' };
|
|
611
|
+
return { behavior: 'deny', message: '🔒 只读模式:visitor 最低权限不允许读取 L-class 路径', policyCode: 'readonly_l_class_read' };
|
|
476
612
|
}
|
|
477
613
|
return { behavior: 'allow' };
|
|
478
614
|
}
|
|
479
615
|
if (toolName === 'Write' || toolName === 'Edit' || toolName === 'NotebookEdit') {
|
|
480
616
|
const filePath = (input.file_path || input.notebook_path);
|
|
481
617
|
logger.warn(`[ReadonlyCheck] 🔒 File write blocked: tool=${toolName} path=${filePath} project=${projectPath} session=${context?.sessionId} channel=${context?.channel} peer=${context?.peerId} role=${context?.role}`);
|
|
482
|
-
return { behavior: 'deny', message: '🔒 只读模式:禁止写入或修改文件' };
|
|
618
|
+
return { behavior: 'deny', message: '🔒 只读模式:禁止写入或修改文件', policyCode: 'readonly_file_write' };
|
|
483
619
|
}
|
|
484
620
|
if (toolName === 'Bash') {
|
|
485
621
|
const cmd = input.command || '';
|
|
486
|
-
|
|
622
|
+
// A managed session must provide its runner-registered temp root. For
|
|
623
|
+
// direct policy callers without a session, the process TMPDIR remains a
|
|
624
|
+
// useful compatibility fallback after the shared-root check above.
|
|
625
|
+
const managedTempDir = context?.sessionId
|
|
626
|
+
? context.managedTempDir
|
|
627
|
+
: context?.managedTempDir ?? resolveManagedTempDir();
|
|
628
|
+
const protectedOperands = analyzeShellProtectedOperands(cmd, {
|
|
629
|
+
cwd: projectPath,
|
|
630
|
+
root: context?.root,
|
|
631
|
+
managedTempDir,
|
|
632
|
+
allowProtectedMetadata: context?.allowProtectedMetadata,
|
|
633
|
+
});
|
|
487
634
|
if (protectedOperands.analysis.kind === 'proven-readonly'
|
|
488
635
|
&& !protectedOperands.hClass
|
|
489
636
|
&& !protectedOperands.lClass
|
|
490
|
-
&& readonlyShellStaysInWorkspace(protectedOperands.analysis, projectPath)) {
|
|
637
|
+
&& readonlyShellStaysInWorkspace(protectedOperands.analysis, projectPath, managedTempDir)) {
|
|
491
638
|
return { behavior: 'allow' };
|
|
492
639
|
}
|
|
493
640
|
if (protectedOperands.hClass) {
|
|
494
641
|
logger.warn(`[ReadonlyCheck] 🔒 H-class path blocked in readonly shell: cmd="${cmd}" session=${context?.sessionId} channel=${context?.channel} peer=${context?.peerId} role=${context?.role}`);
|
|
495
|
-
return { behavior: 'deny', message: '🔒 只读模式:不允许读取 H 类受保护路径' };
|
|
642
|
+
return { behavior: 'deny', message: '🔒 只读模式:不允许读取 H 类受保护路径', policyCode: 'readonly_h_class_read' };
|
|
496
643
|
}
|
|
497
644
|
if (protectedOperands.lClass) {
|
|
498
645
|
logger.warn(`[ReadonlyCheck] 🔒 L-class path blocked in readonly shell: cmd="${cmd}" session=${context?.sessionId} channel=${context?.channel} peer=${context?.peerId} role=${context?.role}`);
|
|
499
|
-
return { behavior: 'deny', message: '🔒 只读模式:visitor 最低权限不允许读取 L-class 路径' };
|
|
646
|
+
return { behavior: 'deny', message: '🔒 只读模式:visitor 最低权限不允许读取 L-class 路径', policyCode: 'readonly_l_class_read' };
|
|
500
647
|
}
|
|
501
648
|
if (protectedOperands.analysis.kind === 'proven-readonly') {
|
|
502
|
-
return { behavior: 'deny', message: '🔒 只读模式:Shell 查询范围不能越出当前项目目录' };
|
|
649
|
+
return { behavior: 'deny', message: '🔒 只读模式:Shell 查询范围不能越出当前项目目录', policyCode: 'readonly_workspace_escape' };
|
|
503
650
|
}
|
|
504
651
|
const cmdPreview = cmd.length > 80 ? cmd.substring(0, 80) + '...' : cmd;
|
|
505
652
|
logger.warn(`[ReadonlyCheck] 🔒 Bash blocked by default: cmd="${cmdPreview}" session=${context?.sessionId} channel=${context?.channel} peer=${context?.peerId} role=${context?.role}`);
|
|
506
|
-
return { behavior: 'deny', message: '🔒 只读模式:Shell 仅允许受限、可证明只读的普通路径查询' };
|
|
653
|
+
return { behavior: 'deny', message: '🔒 只读模式:Shell 仅允许受限、可证明只读的普通路径查询', policyCode: 'readonly_shell_unproven' };
|
|
507
654
|
}
|
|
508
655
|
logger.warn(`[ReadonlyCheck] 🔒 Unknown tool blocked: tool=${toolName} session=${context?.sessionId} channel=${context?.channel} peer=${context?.peerId} role=${context?.role}`);
|
|
509
|
-
return { behavior: 'deny', message: `🔒 只读模式:未声明为只读的工具 ${toolName}
|
|
656
|
+
return { behavior: 'deny', message: `🔒 只读模式:未声明为只读的工具 ${toolName} 已拒绝执行`, policyCode: 'readonly_tool_unavailable' };
|
|
510
657
|
}
|
|
511
658
|
const NON_FILESYSTEM_METADATA_TOOLS = new Set([
|
|
512
659
|
'Agent',
|
|
@@ -752,9 +899,35 @@ export function checkHClassWrite(toolName, input, context) {
|
|
|
752
899
|
}
|
|
753
900
|
else if (toolName === 'Bash') {
|
|
754
901
|
const command = typeof input.command === 'string' ? input.command : '';
|
|
755
|
-
|
|
902
|
+
// Keep this H-class pass aligned with the shared preflight pass. A
|
|
903
|
+
// managed session must not fall back to the daemon's process-wide TMPDIR;
|
|
904
|
+
// without a registered root, explicit temporary paths fail closed.
|
|
905
|
+
const tempOptions = {
|
|
906
|
+
cwd: context?.projectPath,
|
|
907
|
+
env: context?.sessionId
|
|
908
|
+
? { ...process.env, TMPDIR: context.managedTempDir ?? '' }
|
|
909
|
+
: context?.managedTempDir !== undefined
|
|
910
|
+
? { ...process.env, TMPDIR: context.managedTempDir }
|
|
911
|
+
: process.env,
|
|
912
|
+
};
|
|
913
|
+
const tempCheck = checkManagedTempShellCommand(command, tempOptions);
|
|
914
|
+
if (tempCheck.kind === 'deny') {
|
|
915
|
+
return {
|
|
916
|
+
behavior: 'deny',
|
|
917
|
+
message: '🔒 临时文件必须位于当前会话的 $TMPDIR 内',
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
// A managed temp write is intentionally allowed to contain names such as
|
|
921
|
+
// CA/root, but only that validated operand is masked. Any other protected
|
|
922
|
+
// operand in a mixed command must still be rejected.
|
|
923
|
+
const policyCommand = tempCheck.kind === 'allow'
|
|
924
|
+
? maskManagedTempShellPaths(command, tempOptions)
|
|
925
|
+
: command;
|
|
926
|
+
const protectedOperands = analyzeShellProtectedOperands(policyCommand, {
|
|
756
927
|
cwd: context?.projectPath,
|
|
757
928
|
root: context?.root,
|
|
929
|
+
managedTempDir: context?.managedTempDir,
|
|
930
|
+
allowProtectedMetadata: context?.allowProtectedMetadata,
|
|
758
931
|
});
|
|
759
932
|
if (protectedOperands.hClass) {
|
|
760
933
|
logger.warn(`[H-Class Protection] 🔒 Protected filesystem operand in shell command: tool=${toolName} ` +
|
|
@@ -905,7 +1078,10 @@ export function checkLClassWrite(toolName, input, context) {
|
|
|
905
1078
|
else if (toolName === 'Bash') {
|
|
906
1079
|
const command = typeof input.command === 'string' ? input.command : '';
|
|
907
1080
|
const pathOptions = { cwd: context?.projectPath, root: context?.root };
|
|
908
|
-
const protectedOperands = analyzeShellProtectedOperands(command,
|
|
1081
|
+
const protectedOperands = analyzeShellProtectedOperands(command, {
|
|
1082
|
+
...pathOptions,
|
|
1083
|
+
managedTempDir: context?.managedTempDir,
|
|
1084
|
+
});
|
|
909
1085
|
const lClassDenied = protectedOperands.lClass
|
|
910
1086
|
&& (context?.permissionMode === 'readonly' || protectedOperands.analysis.kind === 'unproven');
|
|
911
1087
|
if (lClassDenied) {
|
|
@@ -992,14 +1168,46 @@ export function checkBlacklist(toolName, input) {
|
|
|
992
1168
|
// 默认允许
|
|
993
1169
|
return { behavior: 'allow', updatedInput: input };
|
|
994
1170
|
}
|
|
1171
|
+
function managedTempOptions(context) {
|
|
1172
|
+
return {
|
|
1173
|
+
cwd: context.projectPath,
|
|
1174
|
+
// A managed session must never inherit the daemon's unrelated TMPDIR.
|
|
1175
|
+
env: context.sessionId
|
|
1176
|
+
? { ...process.env, TMPDIR: context.managedTempDir ?? '' }
|
|
1177
|
+
: context.managedTempDir !== undefined
|
|
1178
|
+
? { ...process.env, TMPDIR: context.managedTempDir }
|
|
1179
|
+
: process.env,
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
995
1182
|
function validateBoundedOutputPath(command, context) {
|
|
996
1183
|
const outputPath = command.outputPath;
|
|
997
1184
|
if (!outputPath)
|
|
998
1185
|
return undefined;
|
|
999
1186
|
if (context.permissionMode === 'readonly')
|
|
1000
1187
|
return '🔒 只读模式:禁止将命令输出写入文件';
|
|
1001
|
-
|
|
1002
|
-
|
|
1188
|
+
const tempOptions = managedTempOptions(context);
|
|
1189
|
+
const expandedOutputPath = expandManagedTempPath(outputPath, tempOptions);
|
|
1190
|
+
if (path.isAbsolute(expandedOutputPath)) {
|
|
1191
|
+
// A literal `/tmp/...` path is not proof that the caller is using this
|
|
1192
|
+
// session's managed workspace. Only an explicit `$TMPDIR` reference may
|
|
1193
|
+
// opt into the absolute temporary-path branch; the runner owns the value
|
|
1194
|
+
// of TMPDIR and validates its containment below.
|
|
1195
|
+
const explicitManagedTemp = /^\$(?:\{TMPDIR\}|TMPDIR)(?:[\\/]|$)/.test(outputPath);
|
|
1196
|
+
if (!explicitManagedTemp || !isManagedTempPath(outputPath, tempOptions)) {
|
|
1197
|
+
return '🔒 输出文件必须使用项目目录内的相对路径,或当前会话的 $TMPDIR';
|
|
1198
|
+
}
|
|
1199
|
+
const tempTarget = resolveProtectedCandidate(expandedOutputPath);
|
|
1200
|
+
if (fs.existsSync(tempTarget))
|
|
1201
|
+
return '🔒 临时安全输出只允许创建新文件,不能覆盖或追加现有文件';
|
|
1202
|
+
try {
|
|
1203
|
+
if (!fs.statSync(path.dirname(tempTarget)).isDirectory())
|
|
1204
|
+
return '🔒 临时输出文件的父目录无效';
|
|
1205
|
+
}
|
|
1206
|
+
catch {
|
|
1207
|
+
return '🔒 临时输出文件的父目录必须已经存在';
|
|
1208
|
+
}
|
|
1209
|
+
return undefined;
|
|
1210
|
+
}
|
|
1003
1211
|
const workspace = resolveProtectedCandidate(context.workspacePath ?? context.projectPath);
|
|
1004
1212
|
const cwd = resolveProtectedCandidate(context.projectPath);
|
|
1005
1213
|
if (!isSameOrDescendant(cwd, workspace))
|
|
@@ -1033,7 +1241,7 @@ function quotePosixShellArg(value) {
|
|
|
1033
1241
|
function prepareBoundedOutputInput(input, command, kind, context) {
|
|
1034
1242
|
const validationError = validateBoundedOutputPath(command, context);
|
|
1035
1243
|
if (validationError)
|
|
1036
|
-
return { behavior: 'deny', input, message: validationError };
|
|
1244
|
+
return { behavior: 'deny', input, message: validationError, policyCode: 'bounded_output_invalid' };
|
|
1037
1245
|
const helperPath = context.safeOutputHelperPath;
|
|
1038
1246
|
if (!helperPath) {
|
|
1039
1247
|
return {
|
|
@@ -1047,7 +1255,7 @@ function prepareBoundedOutputInput(input, command, kind, context) {
|
|
|
1047
1255
|
throw new Error('not a file');
|
|
1048
1256
|
}
|
|
1049
1257
|
catch {
|
|
1050
|
-
return { behavior: 'deny', input, message: '🔒 安全输出 helper 不可用,命令已拒绝' };
|
|
1258
|
+
return { behavior: 'deny', input, message: '🔒 安全输出 helper 不可用,命令已拒绝', policyCode: 'bounded_output_helper_unavailable' };
|
|
1051
1259
|
}
|
|
1052
1260
|
const payload = Buffer.from(JSON.stringify({
|
|
1053
1261
|
v: 1,
|
|
@@ -1058,7 +1266,9 @@ function prepareBoundedOutputInput(input, command, kind, context) {
|
|
|
1058
1266
|
argv: command.argv,
|
|
1059
1267
|
mergeStderr: command.mergeStderr,
|
|
1060
1268
|
...(command.headArgs ? { headArgs: command.headArgs } : {}),
|
|
1061
|
-
...(command.outputPath
|
|
1269
|
+
...(command.outputPath
|
|
1270
|
+
? { outputPath: expandManagedTempPath(command.outputPath, managedTempOptions(context)) }
|
|
1271
|
+
: {}),
|
|
1062
1272
|
}), 'utf8').toString('base64url');
|
|
1063
1273
|
const rewrittenCommand = `${quotePosixShellArg(process.execPath)} ${quotePosixShellArg(helperPath)} ${quotePosixShellArg(payload)}`;
|
|
1064
1274
|
return {
|
|
@@ -1071,6 +1281,15 @@ function prepareBoundedOutputInput(input, command, kind, context) {
|
|
|
1071
1281
|
export function evaluateToolPreflight(toolName, input, context) {
|
|
1072
1282
|
if (toolName === 'Bash') {
|
|
1073
1283
|
const command = typeof input.command === 'string' ? input.command : '';
|
|
1284
|
+
const tempCheck = checkManagedTempShellCommand(command, managedTempOptions(context));
|
|
1285
|
+
if (tempCheck.kind === 'deny') {
|
|
1286
|
+
return {
|
|
1287
|
+
behavior: 'deny',
|
|
1288
|
+
input,
|
|
1289
|
+
message: `🔒 临时文件必须位于当前会话的 $TMPDIR 内:${tempCheck.reason}`,
|
|
1290
|
+
policyCode: 'temporary_path_outside_tmpdir',
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1074
1293
|
const explicitCommandArgv = Array.isArray(input.commandArgv)
|
|
1075
1294
|
&& input.commandArgv.every(value => typeof value === 'string')
|
|
1076
1295
|
? input.commandArgv
|
|
@@ -1190,6 +1409,8 @@ export function evaluateToolPreflight(toolName, input, context) {
|
|
|
1190
1409
|
projectPath: context.projectPath,
|
|
1191
1410
|
workspacePath: context.workspacePath,
|
|
1192
1411
|
root: context.root,
|
|
1412
|
+
managedTempDir: context.managedTempDir,
|
|
1413
|
+
allowProtectedMetadata: context.allowProtectedMetadata,
|
|
1193
1414
|
});
|
|
1194
1415
|
if (hClass.behavior === 'deny') {
|
|
1195
1416
|
return { behavior: 'deny', input: checkedInput, message: hClass.message, policyCode: 'h_class_protection' };
|
|
@@ -1203,6 +1424,7 @@ export function evaluateToolPreflight(toolName, input, context) {
|
|
|
1203
1424
|
projectPath: context.projectPath,
|
|
1204
1425
|
workspacePath: context.workspacePath,
|
|
1205
1426
|
root: context.root,
|
|
1427
|
+
managedTempDir: context.managedTempDir,
|
|
1206
1428
|
});
|
|
1207
1429
|
if (lClass.behavior === 'deny') {
|
|
1208
1430
|
return { behavior: 'deny', input: checkedInput, message: lClass.message, policyCode: 'l_class_protection' };
|
|
@@ -38,6 +38,20 @@ const H_CLASS_REFERENCE_PATTERNS = [
|
|
|
38
38
|
/(?:^|[/\\\s"'=<>])data[\\/]triggers(?:[\\/]|$|[\s"';&|<>)},])/,
|
|
39
39
|
/(?:^|[/\\\s"'=<>])(?:[^/\\\s"']+\.json_|[^/\\\s"']+\.json\.migrated|defaults_\d+\.json)(?=$|[\s"';&|<>)},])/,
|
|
40
40
|
];
|
|
41
|
+
// H-class directory roots are kept separately from the full path matcher so
|
|
42
|
+
// an exact metadata query can distinguish a protected directory from a
|
|
43
|
+
// protected file. Descendants inherit the directory classification unless an
|
|
44
|
+
// existing regular file gives us a more precise answer.
|
|
45
|
+
const H_CLASS_DIRECTORY_RELATIVE_PATTERNS = [
|
|
46
|
+
/^backups\/config(?:\/|$)/,
|
|
47
|
+
/^\.snapshots(?:\/|$)/,
|
|
48
|
+
/^CA(?:\/|$)/,
|
|
49
|
+
/^(?:AIDs|aids)\/[^/]+\/(?:cert|keys|private)(?:\/|$)/,
|
|
50
|
+
/^data\/(?:triggers|sessions|outbox|handoff|channel-state)(?:\/|$)/,
|
|
51
|
+
/^data\/daemon\/(?:control|migrations)(?:\/|$)/,
|
|
52
|
+
/^agents\/[^/]+\/sessions(?:\/|$)/,
|
|
53
|
+
/^agents\/[^/]+\/data\/(?:channels|handoff)(?:\/|$)/,
|
|
54
|
+
];
|
|
41
55
|
const L_CLASS_RELATIVE_PATTERNS = [
|
|
42
56
|
/^agents\/[^/]+\/triggers(?:\/|$)/,
|
|
43
57
|
/^agents\/[^/]+\/roles(?:\/|$)/,
|
|
@@ -68,6 +82,22 @@ function resolveThroughExistingAncestor(value) {
|
|
|
68
82
|
return absolute;
|
|
69
83
|
}
|
|
70
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* H-class file names are normally leaves, but treating their descendants as
|
|
87
|
+
* protected as well is important when a malformed or externally-created
|
|
88
|
+
* directory uses a reserved file name (for example `config.json/child`).
|
|
89
|
+
* Such a path cannot expose content from the expected file, and fail-closed
|
|
90
|
+
* classification avoids turning that unusual filesystem state into a bypass.
|
|
91
|
+
*/
|
|
92
|
+
function matchesHClassRelative(relative) {
|
|
93
|
+
const segments = relative.split('/').filter(Boolean);
|
|
94
|
+
for (let end = segments.length; end > 0; end--) {
|
|
95
|
+
if (H_CLASS_RELATIVE_PATTERNS.some(pattern => pattern.test(segments.slice(0, end).join('/')))) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
71
101
|
export function isSameOrDescendant(candidate, parent) {
|
|
72
102
|
const normalizedCandidate = normalizeForComparison(candidate);
|
|
73
103
|
const normalizedParent = normalizeForComparison(parent);
|
|
@@ -85,14 +115,89 @@ export function isHClassPath(value, options = {}) {
|
|
|
85
115
|
: path.resolve(options.cwd ?? process.cwd(), value);
|
|
86
116
|
if (isSameOrDescendant(lexicalCandidate, root)) {
|
|
87
117
|
const lexicalRelative = path.relative(root, lexicalCandidate).split(path.sep).join('/');
|
|
88
|
-
if (
|
|
118
|
+
if (matchesHClassRelative(lexicalRelative))
|
|
89
119
|
return true;
|
|
90
120
|
}
|
|
91
121
|
const candidate = resolveProtectedCandidate(value, options.cwd);
|
|
92
122
|
if (!isSameOrDescendant(candidate, root))
|
|
93
123
|
return false;
|
|
94
124
|
const relative = path.relative(root, candidate).split(path.sep).join('/');
|
|
95
|
-
return
|
|
125
|
+
return matchesHClassRelative(relative);
|
|
126
|
+
}
|
|
127
|
+
function hasPathSyntax(value) {
|
|
128
|
+
return /[\0\r\n$`*?\[\]{}<>|;&]/.test(value);
|
|
129
|
+
}
|
|
130
|
+
function lexicalCandidate(value, cwd = process.cwd()) {
|
|
131
|
+
return path.isAbsolute(value) ? path.resolve(value) : path.resolve(cwd, value);
|
|
132
|
+
}
|
|
133
|
+
function resolveWithoutFinalSymlink(value, cwd = process.cwd()) {
|
|
134
|
+
const absolute = lexicalCandidate(value, cwd);
|
|
135
|
+
const basename = path.basename(absolute);
|
|
136
|
+
const parent = resolveThroughExistingAncestor(path.dirname(absolute));
|
|
137
|
+
return basename ? path.join(parent, basename) : parent;
|
|
138
|
+
}
|
|
139
|
+
/** Resolve path ancestors while preserving the final symlink as a literal node. */
|
|
140
|
+
export function resolveProtectedCandidateWithoutFinalSymlink(value, cwd = process.cwd()) {
|
|
141
|
+
return resolveWithoutFinalSymlink(value, cwd);
|
|
142
|
+
}
|
|
143
|
+
function relativeWithinRoot(value, root) {
|
|
144
|
+
if (!isSameOrDescendant(value, root))
|
|
145
|
+
return undefined;
|
|
146
|
+
return path.relative(root, value).split(path.sep).join('/');
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Classify one literal filesystem path without interpreting shell syntax.
|
|
150
|
+
* `followFinalSymlink=false` is used by stat/lstat policy so metadata queries
|
|
151
|
+
* describe the link itself rather than exposing the protected target.
|
|
152
|
+
*/
|
|
153
|
+
export function classifyProtectedPath(value, options = {}) {
|
|
154
|
+
if (typeof value !== 'string' || !value.trim() || hasPathSyntax(value))
|
|
155
|
+
return 'unknown';
|
|
156
|
+
const root = resolveThroughExistingAncestor(options.root ?? resolveRoot());
|
|
157
|
+
const lexical = resolveWithoutFinalSymlink(value, options.cwd);
|
|
158
|
+
const followFinalSymlink = options.followFinalSymlink !== false;
|
|
159
|
+
const candidate = followFinalSymlink ? resolveProtectedCandidate(value, options.cwd) : lexical;
|
|
160
|
+
const relative = relativeWithinRoot(candidate, root);
|
|
161
|
+
if (relative === undefined)
|
|
162
|
+
return 'ordinary';
|
|
163
|
+
const hMatch = matchesHClassRelative(relative);
|
|
164
|
+
if (hMatch) {
|
|
165
|
+
let directory = H_CLASS_DIRECTORY_RELATIVE_PATTERNS.some(pattern => pattern.test(relative));
|
|
166
|
+
try {
|
|
167
|
+
const statTarget = followFinalSymlink ? candidate : lexical;
|
|
168
|
+
directory = fs.lstatSync(statTarget).isDirectory();
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Missing paths below a protected directory inherit that directory's
|
|
172
|
+
// class; missing exact H-class filenames remain H-class files.
|
|
173
|
+
}
|
|
174
|
+
return directory ? 'h-directory' : 'h-file';
|
|
175
|
+
}
|
|
176
|
+
const lMatch = L_CLASS_RELATIVE_PATTERNS.some(pattern => pattern.test(relative));
|
|
177
|
+
if (lMatch)
|
|
178
|
+
return 'l-path';
|
|
179
|
+
return 'ordinary';
|
|
180
|
+
}
|
|
181
|
+
/** Apply the H/L path-class × access-action matrix to one literal path. */
|
|
182
|
+
export function checkProtectedPathAccess(value, accessKind, options = {}) {
|
|
183
|
+
const pathClass = classifyProtectedPath(value, options);
|
|
184
|
+
if (pathClass === 'unknown')
|
|
185
|
+
return { pathClass, accessKind, allowed: false };
|
|
186
|
+
if (pathClass === 'h-file' || pathClass === 'h-directory') {
|
|
187
|
+
return { pathClass, accessKind, allowed: accessKind === 'metadata' };
|
|
188
|
+
}
|
|
189
|
+
if (pathClass === 'l-path') {
|
|
190
|
+
return {
|
|
191
|
+
pathClass,
|
|
192
|
+
accessKind,
|
|
193
|
+
allowed: accessKind !== 'write' && accessKind !== 'grant',
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
pathClass,
|
|
198
|
+
accessKind,
|
|
199
|
+
allowed: accessKind !== 'write' && accessKind !== 'grant',
|
|
200
|
+
};
|
|
96
201
|
}
|
|
97
202
|
export function containsHClassReference(value) {
|
|
98
203
|
const normalized = value.replace(/\\/g, '/');
|
|
@@ -2,8 +2,7 @@ import { ensureDir } from '../../utils/atomic-write.js';
|
|
|
2
2
|
import { logger } from '../../utils/logger.js';
|
|
3
3
|
import { encodePath } from '../../utils/cross-platform.js';
|
|
4
4
|
import { chatDirPath, generateSessionId, formatTimestamp, atomicWriteJson, atomicWriteText, appendJsonl, readJsonFile, readLastJsonlLine, readAllJsonlLines, scanChatDirs, scanMetaFiles, ensureChatDir, readThreadIndex, writeThreadIndex, } from './session-fs-store.js';
|
|
5
|
-
import { sessionToFile, fileToSession } from './session-mapper.js';
|
|
6
|
-
import { formatSessionKey, DEFAULT_THREAD_ID } from './session-key.js';
|
|
5
|
+
import { sessionToFile, fileToSession, formatSessionKey, DEFAULT_THREAD_ID } from './session-mapper.js';
|
|
7
6
|
import { tryParseChannelKey } from '../channel-loader.js';
|
|
8
7
|
import { isSystemControlChannel } from '../system-channels.js';
|
|
9
8
|
import path from 'path';
|
|
@@ -597,6 +596,14 @@ export class SessionManager {
|
|
|
597
596
|
mutated = true;
|
|
598
597
|
}
|
|
599
598
|
}
|
|
599
|
+
if (chatType === 'group' && metadata?.groupId) {
|
|
600
|
+
if (!session.metadata)
|
|
601
|
+
session.metadata = {};
|
|
602
|
+
if (session.metadata.groupId !== metadata.groupId) {
|
|
603
|
+
session.metadata.groupId = metadata.groupId;
|
|
604
|
+
mutated = true;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
600
607
|
if (metadata?.peerType && session.metadata?.peerType !== metadata.peerType) {
|
|
601
608
|
if (!session.metadata)
|
|
602
609
|
session.metadata = {};
|
|
@@ -636,6 +643,9 @@ export class SessionManager {
|
|
|
636
643
|
if (metadata?.peerType) {
|
|
637
644
|
session.metadata.peerType = metadata.peerType;
|
|
638
645
|
}
|
|
646
|
+
if (chatType === 'group' && metadata?.groupId) {
|
|
647
|
+
session.metadata.groupId = metadata.groupId;
|
|
648
|
+
}
|
|
639
649
|
this.persistSession(session, 'sync');
|
|
640
650
|
return session;
|
|
641
651
|
}
|
|
@@ -702,11 +712,20 @@ export class SessionManager {
|
|
|
702
712
|
const existing = this.readMetaLatest(metaPath);
|
|
703
713
|
if (existing) {
|
|
704
714
|
const validSessionId = this.validateSessionFile(existing);
|
|
715
|
+
let mutated = false;
|
|
705
716
|
if (metadata) {
|
|
706
717
|
const creatorPeerId = existing.metadata?.peerId;
|
|
707
718
|
existing.metadata = { ...(existing.metadata || {}), ...metadata };
|
|
708
719
|
if (creatorPeerId && existing.metadata)
|
|
709
720
|
existing.metadata.peerId = creatorPeerId;
|
|
721
|
+
mutated = true;
|
|
722
|
+
}
|
|
723
|
+
if (chatType && existing.chatType !== chatType) {
|
|
724
|
+
logger.info(`[SessionManager] Updating thread chatType for session ${existing.id}: ${existing.chatType} -> ${chatType}`);
|
|
725
|
+
existing.chatType = chatType;
|
|
726
|
+
mutated = true;
|
|
727
|
+
}
|
|
728
|
+
if (mutated) {
|
|
710
729
|
this.persistSession(existing, 'none');
|
|
711
730
|
}
|
|
712
731
|
return { ...existing, agentSessionId: validSessionId };
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import { formatTimestamp } from './session-fs-store.js';
|
|
2
|
-
|
|
2
|
+
/** Default topic component used by the stable session routing key. */
|
|
3
|
+
export const DEFAULT_THREAD_ID = 'main';
|
|
4
|
+
export function formatSessionKey(channelType, channelId, threadId) {
|
|
5
|
+
const tid = threadId || DEFAULT_THREAD_ID;
|
|
6
|
+
return `${channelType}#${encodeURIComponent(channelId)}#${encodeURIComponent(tid)}`;
|
|
7
|
+
}
|
|
8
|
+
export function parseSessionKey(key) {
|
|
9
|
+
const first = key.indexOf('#');
|
|
10
|
+
if (first <= 0)
|
|
11
|
+
throw new Error(`Invalid session key: ${key}`);
|
|
12
|
+
const rest = key.slice(first + 1);
|
|
13
|
+
const second = rest.indexOf('#');
|
|
14
|
+
if (second <= 0)
|
|
15
|
+
throw new Error(`Invalid session key (missing threadId): ${key}`);
|
|
16
|
+
return {
|
|
17
|
+
channelType: key.slice(0, first),
|
|
18
|
+
channelId: decodeURIComponent(rest.slice(0, second)),
|
|
19
|
+
threadId: decodeURIComponent(rest.slice(second + 1)),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
3
22
|
export function sessionToFile(session) {
|
|
4
23
|
const metadata = {};
|
|
5
24
|
if (session.metadata) {
|