shennian 0.2.56 → 0.2.58

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.
@@ -0,0 +1,21 @@
1
+ export type FsPathFlavor = 'win32' | 'posix';
2
+ export type AuthorizedFsRoot = {
3
+ raw: string;
4
+ normalized: string;
5
+ boundary: string;
6
+ real: string | null;
7
+ flavor: FsPathFlavor;
8
+ file: boolean;
9
+ };
10
+ export type ResolveAuthorizedPathResult = {
11
+ ok: true;
12
+ path: string;
13
+ root: AuthorizedFsRoot;
14
+ } | {
15
+ ok: false;
16
+ error: string;
17
+ };
18
+ export declare function resolveSessionWorkDir(input: string): string;
19
+ export declare function createAuthorizedFsRoot(rawRoot: string): AuthorizedFsRoot;
20
+ export declare function resolveAuthorizedPath(rawPath: string, root: AuthorizedFsRoot): ResolveAuthorizedPathResult;
21
+ export declare function isAuthorizedRootPath(pathValue: string, root: AuthorizedFsRoot): boolean;
@@ -0,0 +1,121 @@
1
+ // @arch docs/architecture/cli/daemon.md#文件系统权限边界
2
+ // @test src/__tests__/fs-boundary.test.ts
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ const WINDOWS_ABSOLUTE_RE = /^[A-Za-z]:([\\/]|$)/;
7
+ const WINDOWS_UNC_RE = /^\\\\[^\\]+\\[^\\]+/;
8
+ const WINDOWS_DRIVE_RELATIVE_RE = /^[A-Za-z]:(?![\\/])/;
9
+ function isWindowsAbsolutePath(input) {
10
+ return WINDOWS_ABSOLUTE_RE.test(input) || WINDOWS_UNC_RE.test(input);
11
+ }
12
+ function hasWindowsDriveRelativePrefix(input) {
13
+ return WINDOWS_DRIVE_RELATIVE_RE.test(input);
14
+ }
15
+ function normalizeWindowsAbsolutePath(input) {
16
+ return input.replace(/^[/\\]([A-Za-z]:[\\/].*)$/, '$1');
17
+ }
18
+ function pathFlavor(input) {
19
+ return isWindowsAbsolutePath(normalizeWindowsAbsolutePath(input)) ? 'win32' : 'posix';
20
+ }
21
+ function pathApi(flavor) {
22
+ return flavor === 'win32' ? path.win32 : path.posix;
23
+ }
24
+ function normalizeForCompare(input, flavor) {
25
+ const api = pathApi(flavor);
26
+ const normalized = api.normalize(input);
27
+ const trimmed = normalized.length > api.parse(normalized).root.length
28
+ ? normalized.replace(/[\\/]+$/, '')
29
+ : normalized;
30
+ return flavor === 'win32' ? trimmed.toLowerCase() : trimmed;
31
+ }
32
+ function isWithinOrEqual(candidate, root, flavor) {
33
+ const api = pathApi(flavor);
34
+ const normalizedCandidate = normalizeForCompare(candidate, flavor);
35
+ const normalizedRoot = normalizeForCompare(root, flavor);
36
+ if (normalizedCandidate === normalizedRoot)
37
+ return true;
38
+ const relative = api.relative(normalizedRoot, normalizedCandidate);
39
+ return Boolean(relative) && !relative.startsWith('..') && !api.isAbsolute(relative);
40
+ }
41
+ function safeRealpath(pathValue) {
42
+ try {
43
+ return fs.realpathSync.native(pathValue);
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ export function resolveSessionWorkDir(input) {
50
+ if (!input)
51
+ return os.homedir();
52
+ const normalizedInput = normalizeWindowsAbsolutePath(input);
53
+ const flavor = pathFlavor(normalizedInput);
54
+ const api = pathApi(flavor);
55
+ if (normalizedInput.startsWith('~')) {
56
+ return path.join(os.homedir(), normalizedInput.slice(1).replace(/^[/\\]+/, ''));
57
+ }
58
+ if (process.platform === 'win32') {
59
+ const normalized = normalizedInput.replace(/\\/g, '/');
60
+ if (normalized === '/tmp' || normalized.startsWith('/tmp/')) {
61
+ const suffix = normalized.slice('/tmp'.length).replace(/^\/+/, '');
62
+ return suffix ? path.win32.join(os.tmpdir(), ...suffix.split('/')) : os.tmpdir();
63
+ }
64
+ }
65
+ if (isWindowsAbsolutePath(normalizedInput)) {
66
+ return path.win32.resolve(normalizedInput);
67
+ }
68
+ return api.resolve(normalizedInput);
69
+ }
70
+ export function createAuthorizedFsRoot(rawRoot) {
71
+ const normalized = resolveSessionWorkDir(rawRoot);
72
+ const flavor = pathFlavor(normalized);
73
+ const stat = safeStat(normalized);
74
+ const file = typeof stat?.isFile === 'function' ? stat.isFile() : false;
75
+ const boundary = file ? pathApi(flavor).dirname(normalized) : normalized;
76
+ return {
77
+ raw: rawRoot,
78
+ normalized,
79
+ boundary,
80
+ real: safeRealpath(boundary),
81
+ flavor,
82
+ file,
83
+ };
84
+ }
85
+ export function resolveAuthorizedPath(rawPath, root) {
86
+ const input = String(rawPath || '').trim() || root.normalized;
87
+ const normalizedInput = normalizeWindowsAbsolutePath(input);
88
+ const api = pathApi(root.flavor);
89
+ if (hasWindowsDriveRelativePrefix(normalizedInput)) {
90
+ return { ok: false, error: `Access denied: ${rawPath}` };
91
+ }
92
+ const candidate = api.isAbsolute(normalizedInput)
93
+ ? api.resolve(normalizedInput)
94
+ : api.resolve(root.boundary, normalizedInput);
95
+ if (!isWithinOrEqual(candidate, root.boundary, root.flavor)) {
96
+ return { ok: false, error: `Access denied: ${rawPath}` };
97
+ }
98
+ if (root.file && normalizeForCompare(candidate, root.flavor) !== normalizeForCompare(root.normalized, root.flavor)) {
99
+ return { ok: false, error: `Access denied: ${rawPath}` };
100
+ }
101
+ const realCandidate = safeRealpath(candidate);
102
+ if (realCandidate) {
103
+ const realRoot = root.real ?? safeRealpath(root.boundary);
104
+ if (realRoot && !isWithinOrEqual(realCandidate, realRoot, pathFlavor(realRoot))) {
105
+ return { ok: false, error: `Access denied: ${rawPath}` };
106
+ }
107
+ }
108
+ return { ok: true, path: candidate, root };
109
+ }
110
+ export function isAuthorizedRootPath(pathValue, root) {
111
+ return isWithinOrEqual(pathValue, root.boundary, root.flavor) &&
112
+ normalizeForCompare(pathValue, root.flavor) === normalizeForCompare(root.boundary, root.flavor);
113
+ }
114
+ function safeStat(pathValue) {
115
+ try {
116
+ return fs.statSync(pathValue);
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
@@ -7,38 +7,19 @@ const FILE_SYSTEM_ROOTS_PATH = '__roots__';
7
7
  function isWindowsAbsolutePath(pathValue) {
8
8
  return /^[A-Za-z]:([\\/]|$)/.test(pathValue) || /^\\\\[^\\]+\\[^\\]+/.test(pathValue);
9
9
  }
10
- function listFileSystemRoots() {
11
- if (os.platform() === 'win32') {
12
- const entries = [];
13
- for (let code = 65; code <= 90; code += 1) {
14
- const drive = `${String.fromCharCode(code)}:\\`;
15
- if (!fs.existsSync(drive))
16
- continue;
17
- entries.push({
18
- name: drive,
19
- path: drive,
20
- isDir: true,
21
- });
22
- }
23
- return { path: FILE_SYSTEM_ROOTS_PATH, entries };
24
- }
25
- return {
26
- path: FILE_SYSTEM_ROOTS_PATH,
27
- entries: [{ name: '/', path: '/', isDir: true }],
28
- };
29
- }
30
10
  export async function handleFsLs(runtime, req) {
31
11
  const requestedPath = req.params.path || os.homedir();
12
+ const rootPath = req.params.rootPath || requestedPath;
32
13
  if (requestedPath === FILE_SYSTEM_ROOTS_PATH) {
33
- runtime.client.sendRes({
34
- type: 'res',
35
- id: req.id,
36
- ok: true,
37
- payload: listFileSystemRoots(),
38
- });
14
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'Access denied: filesystem roots are outside the authorized directory' });
39
15
  return;
40
16
  }
41
- const dirPath = runtime.resolvePath(requestedPath);
17
+ const resolved = runtime.resolveAuthorizedPath(requestedPath, rootPath);
18
+ if (!resolved.ok) {
19
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: resolved.error });
20
+ return;
21
+ }
22
+ const dirPath = resolved.path;
42
23
  try {
43
24
  const raw = fs.readdirSync(dirPath, { withFileTypes: true });
44
25
  const joinPath = isWindowsAbsolutePath(dirPath) ? path.win32.join : path.join;
@@ -65,7 +46,14 @@ export async function handleFsLs(runtime, req) {
65
46
  }
66
47
  }
67
48
  export async function handleFsRead(runtime, req) {
68
- const filePath = runtime.resolvePath(req.params.path);
49
+ const requestedPath = req.params.path;
50
+ const rootPath = req.params.rootPath || requestedPath;
51
+ const resolved = runtime.resolveAuthorizedPath(requestedPath, rootPath);
52
+ if (!resolved.ok) {
53
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: resolved.error });
54
+ return;
55
+ }
56
+ const filePath = resolved.path;
69
57
  const encoding = req.params.encoding || 'utf8';
70
58
  const offset = req.params.offset;
71
59
  const length = req.params.length;
@@ -148,7 +136,13 @@ export async function handleFsTransfer(runtime, req) {
148
136
  return;
149
137
  }
150
138
  try {
151
- const baseDir = runtime.resolvePath(targetPath || os.homedir());
139
+ const rootPath = req.params.rootPath || targetPath || os.homedir();
140
+ const resolved = runtime.resolveAuthorizedPath(targetPath || rootPath, rootPath);
141
+ if (!resolved.ok) {
142
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: resolved.error });
143
+ return;
144
+ }
145
+ const baseDir = resolved.path;
152
146
  const uploadDir = direct ? baseDir : path.join(baseDir, '.uploads');
153
147
  if (!direct)
154
148
  fs.mkdirSync(uploadDir, { recursive: true });
@@ -168,7 +162,13 @@ export async function handleFsTransferStart(runtime, req) {
168
162
  return;
169
163
  }
170
164
  try {
171
- const baseDir = runtime.resolvePath(targetPath || os.homedir());
165
+ const rootPath = req.params.rootPath || targetPath || os.homedir();
166
+ const resolved = runtime.resolveAuthorizedPath(targetPath || rootPath, rootPath);
167
+ if (!resolved.ok) {
168
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: resolved.error });
169
+ return;
170
+ }
171
+ const baseDir = resolved.path;
172
172
  const destinationDir = direct ? baseDir : path.join(baseDir, '.uploads');
173
173
  if (!direct)
174
174
  fs.mkdirSync(destinationDir, { recursive: true });
@@ -0,0 +1,3 @@
1
+ import type { ReqFrame } from '@shennian/wire';
2
+ import type { SessionManagerRuntime } from '../types.js';
3
+ export declare function handleSessionRefresh(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
@@ -0,0 +1,35 @@
1
+ // @arch docs/architecture/cli/native-session-fusion.md
2
+ // @test src/__tests__/session-manager.test.ts
3
+ export async function handleSessionRefresh(runtime, req) {
4
+ const params = req.params;
5
+ if (!params.sessionId) {
6
+ runtime.client.sendRes({
7
+ type: 'res',
8
+ id: req.id,
9
+ ok: false,
10
+ error: 'sessionId is required',
11
+ });
12
+ return;
13
+ }
14
+ if (!runtime.nativeFusion) {
15
+ runtime.client.sendRes({
16
+ type: 'res',
17
+ id: req.id,
18
+ ok: true,
19
+ payload: { scanned: false },
20
+ });
21
+ return;
22
+ }
23
+ await runtime.nativeFusion.scanNow();
24
+ runtime.client.sendRes({
25
+ type: 'res',
26
+ id: req.id,
27
+ ok: true,
28
+ payload: {
29
+ scanned: true,
30
+ sessionId: params.sessionId,
31
+ agentType: params.agentType ?? null,
32
+ agentSessionId: params.agentSessionId ?? null,
33
+ },
34
+ });
35
+ }
@@ -8,7 +8,7 @@ import '../agents/cursor.js';
8
8
  import '../agents/opencode.js';
9
9
  import '../agents/pi.js';
10
10
  import '../agents/manager.js';
11
- export declare function resolveSessionWorkDir(input: string): string;
11
+ export { resolveSessionWorkDir } from '../fs/boundary.js';
12
12
  export declare class SessionManager {
13
13
  private client;
14
14
  private nativeFusion;
@@ -28,5 +28,6 @@ export declare class SessionManager {
28
28
  handleReq(req: ReqFrame): Promise<void>;
29
29
  private evictIdleSessions;
30
30
  private resolvePath;
31
+ private resolveAuthorizedPath;
31
32
  cleanup(): void;
32
33
  }
@@ -1,18 +1,18 @@
1
1
  // @arch docs/architecture/cli/daemon.md#会话管理
2
2
  // @test src/__tests__/session-store.test.ts
3
3
  // @test src/__tests__/model-switching.test.ts
4
- import path from 'node:path';
5
- import os from 'node:os';
6
4
  import { getRegisteredAgents, unregisterAgent } from '../agents/adapter.js';
7
5
  import { loadConfig } from '../config/index.js';
8
6
  import { handleUpgradeStart, handleUpgradeStatus } from '../commands/upgrade.js';
9
7
  import { handleAgentsRefresh, handleModelsRefresh } from './handlers/agents.js';
10
8
  import { handleAgentConfigClear, handleAgentConfigGet, handleAgentConfigTest, handleAgentConfigUpsert, } from './handlers/agent-config.js';
11
9
  import { handleChatAbort, handleChatSend } from './handlers/chat.js';
10
+ import { handleSessionRefresh } from './handlers/session-refresh.js';
12
11
  import { cleanupPendingTransfers, handleFsLs, handleFsRead, handleFsTransfer, handleFsTransferAbort, handleFsTransferChunk, handleFsTransferFinish, handleFsTransferStart, } from './handlers/fs.js';
13
12
  import { handleRegionProbe, handleRegionSwitch, handleUpgradeSetPolicy } from './handlers/control.js';
14
13
  import { ManagerRuntimeService, setManagerRuntimeService } from '../manager/runtime.js';
15
14
  import { ChatQueueManager } from './queue.js';
15
+ import { createAuthorizedFsRoot, resolveAuthorizedPath, resolveSessionWorkDir } from '../fs/boundary.js';
16
16
  // Side-effect imports to register built-in agent adapters.
17
17
  import '../agents/claude.js';
18
18
  import '../agents/codex.js';
@@ -25,32 +25,7 @@ import '../agents/pi.js';
25
25
  import '../agents/manager.js';
26
26
  import { registerCustomAgent } from '../agents/custom.js';
27
27
  const MAX_SESSIONS = 50;
28
- function isWindowsAbsolutePath(input) {
29
- return /^[A-Za-z]:([\\/]|$)/.test(input) || /^\\\\[^\\]+\\[^\\]+/.test(input);
30
- }
31
- function normalizeWindowsAbsolutePath(input) {
32
- return input.replace(/^[/\\]([A-Za-z]:[\\/].*)$/, '$1');
33
- }
34
- export function resolveSessionWorkDir(input) {
35
- if (!input)
36
- return os.homedir();
37
- const normalizedInput = normalizeWindowsAbsolutePath(input);
38
- const joinPath = process.platform === 'win32' ? path.win32.join : path.join;
39
- if (normalizedInput.startsWith('~')) {
40
- return joinPath(os.homedir(), normalizedInput.slice(1).replace(/^[/\\]+/, ''));
41
- }
42
- if (process.platform === 'win32') {
43
- const normalized = normalizedInput.replace(/\\/g, '/');
44
- if (normalized === '/tmp' || normalized.startsWith('/tmp/')) {
45
- const suffix = normalized.slice('/tmp'.length).replace(/^\/+/, '');
46
- return suffix ? path.win32.join(os.tmpdir(), ...suffix.split('/')) : os.tmpdir();
47
- }
48
- }
49
- if (isWindowsAbsolutePath(normalizedInput)) {
50
- return path.win32.resolve(normalizedInput);
51
- }
52
- return path.resolve(normalizedInput);
53
- }
28
+ export { resolveSessionWorkDir } from '../fs/boundary.js';
54
29
  export class SessionManager {
55
30
  client;
56
31
  nativeFusion;
@@ -87,6 +62,7 @@ export class SessionManager {
87
62
  processedReqIds: this.processedReqIds,
88
63
  reloadCustomAgents: () => this.reloadCustomAgents(),
89
64
  resolvePath: (pathValue) => this.resolvePath(pathValue),
65
+ resolveAuthorizedPath: (pathValue, rootPath) => this.resolveAuthorizedPath(pathValue, rootPath),
90
66
  runTextAcc: this.runTextAcc,
91
67
  sessions: this.sessions,
92
68
  evictIdleSessions: () => this.evictIdleSessions(),
@@ -128,6 +104,9 @@ export class SessionManager {
128
104
  case 'chat.abort':
129
105
  await handleChatAbort(runtime, req);
130
106
  break;
107
+ case 'session.refresh':
108
+ await handleSessionRefresh(runtime, req);
109
+ break;
131
110
  case 'fs.ls':
132
111
  await handleFsLs(runtime, req);
133
112
  break;
@@ -219,6 +198,9 @@ export class SessionManager {
219
198
  resolvePath(p) {
220
199
  return resolveSessionWorkDir(p);
221
200
  }
201
+ resolveAuthorizedPath(p, rootPath) {
202
+ return resolveAuthorizedPath(p, createAuthorizedFsRoot(rootPath));
203
+ }
222
204
  cleanup() {
223
205
  for (const [, session] of this.sessions) {
224
206
  session.adapter.stop().catch(() => { });
@@ -3,6 +3,7 @@ import type { AgentAdapter } from '../agents/adapter.js';
3
3
  import type { CliRelayClient } from '../relay/client.js';
4
4
  import type { NativeSessionFusionService } from '../native-fusion/service.js';
5
5
  import type { ManagerRuntimeService } from '../manager/runtime.js';
6
+ import type { ResolveAuthorizedPathResult } from '../fs/boundary.js';
6
7
  export type ActiveSession = {
7
8
  adapter: AgentAdapter;
8
9
  workDir: string;
@@ -39,6 +40,7 @@ export type SessionManagerRuntime = {
39
40
  processedReqIds: Set<string>;
40
41
  reloadCustomAgents: () => void;
41
42
  resolvePath: (pathValue: string) => string;
43
+ resolveAuthorizedPath: (pathValue: string, rootPath: string) => ResolveAuthorizedPathResult;
42
44
  runTextAcc: Map<string, string>;
43
45
  sessions: Map<string, ActiveSession>;
44
46
  evictIdleSessions: () => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shennian",
3
- "version": "0.2.56",
3
+ "version": "0.2.58",
4
4
  "description": "Shennian — AI Agent Control Plane CLI",
5
5
  "type": "module",
6
6
  "bin": {