@steerable/agent-shell 0.6.74 → 0.7.1
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/dist/fence-path.d.ts +16 -0
- package/dist/fence-path.js +54 -0
- package/dist/host/runtime.d.ts +0 -13
- package/dist/host/runtime.js +26 -0
- package/dist/host-tools.d.ts +1 -0
- package/dist/host-tools.js +1 -0
- package/dist/i18n.d.ts +2 -0
- package/dist/i18n.js +6 -1
- package/dist/json-store.d.ts +1 -0
- package/dist/json-store.js +50 -4
- package/dist/local-backend/coreloop-stream.d.ts +29 -5
- package/dist/local-backend/coreloop-stream.js +122 -1
- package/dist/local-backend/fork-copy.d.ts +9 -0
- package/dist/local-backend/fork-copy.js +75 -0
- package/dist/local-backend/history-edit.d.ts +12 -2
- package/dist/local-backend/history-edit.js +98 -4
- package/dist/local-backend/router.d.ts +1 -0
- package/dist/local-backend/router.js +189 -53
- package/dist/local-backend/task-process.js +5 -0
- package/dist/local-backend/task-service.js +6 -7
- package/dist/local-backend/turn-files.js +3 -1
- package/dist/local-executor.d.ts +1 -3
- package/dist/local-executor.js +10 -13
- package/dist/product-config.d.ts +7 -1
- package/dist/project-registry.d.ts +33 -0
- package/dist/project-registry.js +206 -78
- package/dist/project-rules.d.ts +2 -0
- package/dist/project-rules.js +17 -13
- package/dist/scenario/pack.d.ts +1 -1
- package/dist/sidecar/approval-label.d.ts +55 -0
- package/dist/sidecar/approval-label.js +102 -0
- package/dist/sidecar/reverse-approval.d.ts +26 -0
- package/dist/sidecar/reverse-approval.js +15 -4
- package/dist/sidecar/types.d.ts +14 -0
- package/dist/storage/index.d.ts +8 -1
- package/dist/storage/index.js +7 -2
- package/dist/storage/process-locks.d.ts +6 -0
- package/dist/storage/process-locks.js +6 -0
- package/dist/storage/scoped-store.d.ts +1 -1
- package/dist/storage/sqlite-driver.js +6 -5
- package/dist/tool-router.d.ts +22 -9
- package/dist/tool-router.js +43 -29
- package/package.json +4 -4
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { canonicalFencePath, canonicalPathInside } from '../fence-path.js';
|
|
4
|
+
const COMMAND_TOOLS = new Set(['local_exec_shell', 'local_run_snippet']);
|
|
5
|
+
// Only the shell starts an explicit cwd outside the writable roots. A snippet
|
|
6
|
+
// is rejected before it runs, so its card must not say the command still runs.
|
|
7
|
+
const OUTSIDE_CWD_TOOLS = new Set(['local_exec_shell']);
|
|
8
|
+
const BUILTIN_CATEGORY_LABEL = {
|
|
9
|
+
network_egress: 'Internet access',
|
|
10
|
+
sandbox_escalation: 'Sandbox',
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Display name for an approval category.
|
|
14
|
+
* Pack declarations win. Built-in categories and shell tools use the shell dictionary.
|
|
15
|
+
*
|
|
16
|
+
* @param input Locale, category, and the tool's declared labels.
|
|
17
|
+
* @returns A label for the card, or undefined when the category is just the tool name.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveApprovalCategoryLabel(input) {
|
|
20
|
+
const declared = declaredLabel(input.declaredLabel, input.locale);
|
|
21
|
+
if (declared)
|
|
22
|
+
return declared;
|
|
23
|
+
const builtin = BUILTIN_CATEGORY_LABEL[input.category];
|
|
24
|
+
if (builtin)
|
|
25
|
+
return input.translate(builtin);
|
|
26
|
+
if (COMMAND_TOOLS.has(input.toolName) || COMMAND_TOOLS.has(input.category))
|
|
27
|
+
return input.translate('Command');
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
/** English source string for the approval card. The web view translates it. */
|
|
31
|
+
export function hostApprovalCategoryLabel(input) {
|
|
32
|
+
return resolveApprovalCategoryLabel({
|
|
33
|
+
locale: 'en',
|
|
34
|
+
category: input.category,
|
|
35
|
+
toolName: input.toolName,
|
|
36
|
+
...(input.declaredLabel ? { declaredLabel: input.declaredLabel } : {}),
|
|
37
|
+
translate: (message) => message,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Which approval sentence to show when `local_exec_shell` names a cwd
|
|
42
|
+
* outside the writable root. A bound project uses its folder and source
|
|
43
|
+
* folders. A chat without a project uses the conversation workspace.
|
|
44
|
+
*
|
|
45
|
+
* @param input Tool name, cwd, and the chat's project or workspace root.
|
|
46
|
+
* @returns `false` when the card should not mention an outside directory.
|
|
47
|
+
*/
|
|
48
|
+
export function approvalCwdFence(input) {
|
|
49
|
+
const bound = Boolean(input.projectRoot);
|
|
50
|
+
const outside = shellCwdOutsideProject({
|
|
51
|
+
toolName: input.toolName,
|
|
52
|
+
cwd: input.cwd,
|
|
53
|
+
projectRoot: input.projectRoot ?? input.workspaceRoot,
|
|
54
|
+
sourceFolders: bound ? input.sourceFolders : [],
|
|
55
|
+
...(input.home ? { home: input.home } : {}),
|
|
56
|
+
});
|
|
57
|
+
if (!outside)
|
|
58
|
+
return false;
|
|
59
|
+
return bound ? 'project' : 'workspace';
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* True when `local_exec_shell` names a cwd outside the given root and its source folders.
|
|
63
|
+
*
|
|
64
|
+
* The root is the project folder, or the conversation workspace when the chat
|
|
65
|
+
* has no project. `home` defaults to the process home directory, the same
|
|
66
|
+
* directory the shell fence uses when it expands `~`.
|
|
67
|
+
*
|
|
68
|
+
* @param input Tool name, cwd argument, and the chat's writable folders.
|
|
69
|
+
* @returns Whether that cwd is outside those folders.
|
|
70
|
+
*/
|
|
71
|
+
export function shellCwdOutsideProject(input) {
|
|
72
|
+
if (!OUTSIDE_CWD_TOOLS.has(input.toolName))
|
|
73
|
+
return false;
|
|
74
|
+
if (!input.projectRoot || typeof input.cwd !== 'string' || !input.cwd.trim())
|
|
75
|
+
return false;
|
|
76
|
+
const target = resolveCwd(input.cwd.trim(), input.projectRoot, input.home ?? homedir());
|
|
77
|
+
const roots = [input.projectRoot, ...input.sourceFolders];
|
|
78
|
+
return !roots.some((root) => canonicalPathInside(root, target));
|
|
79
|
+
}
|
|
80
|
+
function declaredLabel(label, locale) {
|
|
81
|
+
if (!label)
|
|
82
|
+
return undefined;
|
|
83
|
+
const exact = label[locale];
|
|
84
|
+
if (exact)
|
|
85
|
+
return exact;
|
|
86
|
+
const normalized = locale.trim().toLowerCase().replace(/_/g, '-');
|
|
87
|
+
if (normalized === 'zh' || normalized.startsWith('zh-')) {
|
|
88
|
+
return label['zh-CN'] || label.zh || label['zh-Hans'];
|
|
89
|
+
}
|
|
90
|
+
if (normalized === 'en' || normalized.startsWith('en-'))
|
|
91
|
+
return label.en;
|
|
92
|
+
return label.en;
|
|
93
|
+
}
|
|
94
|
+
function resolveCwd(cwd, projectRoot, home) {
|
|
95
|
+
// Same `~` rule as ToolRouter.applyProjectCwdSandbox: slice(1) keeps the
|
|
96
|
+
// separator, and path.join folds it into the home directory. The fence
|
|
97
|
+
// comparison uses the real path, so `/tmp` and `/private/tmp` match.
|
|
98
|
+
const expanded = cwd.startsWith('~') ? path.join(home, cwd.slice(1)) : cwd;
|
|
99
|
+
const root = canonicalFencePath(path.resolve(projectRoot));
|
|
100
|
+
const resolved = path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(root, expanded);
|
|
101
|
+
return canonicalFencePath(resolved);
|
|
102
|
+
}
|
|
@@ -31,6 +31,20 @@ export interface ApprovalPromptRequest {
|
|
|
31
31
|
round: number;
|
|
32
32
|
/** Chat that asked. The renderer only shows the card in that composer. */
|
|
33
33
|
chatId?: string;
|
|
34
|
+
/** Localized category name. Absent when the category is the tool name. */
|
|
35
|
+
categoryLabel?: string;
|
|
36
|
+
/** Shell cwd is outside the chat project's writable folders. */
|
|
37
|
+
outsideWorkspace?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* `workspace` when the chat has no project and the cwd is outside the
|
|
40
|
+
* conversation workspace. Absent means the project sentence.
|
|
41
|
+
*/
|
|
42
|
+
outsideWorkspaceKind?: 'workspace';
|
|
43
|
+
/**
|
|
44
|
+
* Live command sandbox. `false` means the card must not say writes are
|
|
45
|
+
* blocked. Absent keeps that sentence for an older sidecar.
|
|
46
|
+
*/
|
|
47
|
+
sandboxEnforced?: boolean;
|
|
34
48
|
}
|
|
35
49
|
export interface ApprovalBridgeDeps {
|
|
36
50
|
/**
|
|
@@ -42,6 +56,18 @@ export interface ApprovalBridgeDeps {
|
|
|
42
56
|
/** True when at least one renderer window can receive the prompt. */
|
|
43
57
|
hasWindow: () => boolean;
|
|
44
58
|
onLog?: (line: string) => void;
|
|
59
|
+
/** Localized category name from the tool declaration or the shell dictionary. */
|
|
60
|
+
categoryLabel?: (toolName: string, category: string) => string | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* Whether a shell cwd sits outside the writable root.
|
|
63
|
+
* `true` and `'project'` use the project sentence. `'workspace'` uses the
|
|
64
|
+
* conversation-workspace sentence. `false` adds no outside-cwd sentence.
|
|
65
|
+
*/
|
|
66
|
+
outsideWorkspace?: (input: {
|
|
67
|
+
toolName: string;
|
|
68
|
+
arguments: Record<string, unknown>;
|
|
69
|
+
chatId?: string;
|
|
70
|
+
}) => boolean | 'project' | 'workspace' | Promise<boolean | 'project' | 'workspace'>;
|
|
45
71
|
}
|
|
46
72
|
export interface ApprovalBridge {
|
|
47
73
|
/** Reverse-channel handler for the sidecar's `approval.request`. */
|
|
@@ -44,16 +44,27 @@ export function createApprovalBridge(deps) {
|
|
|
44
44
|
}
|
|
45
45
|
const requestId = randomUUID();
|
|
46
46
|
const chatId = typeof p.chatId === 'string' && p.chatId ? p.chatId : undefined;
|
|
47
|
+
const args = p.arguments && typeof p.arguments === 'object'
|
|
48
|
+
? p.arguments
|
|
49
|
+
: {};
|
|
50
|
+
const category = typeof p.category === 'string' ? p.category : toolName;
|
|
51
|
+
const categoryLabel = deps.categoryLabel?.(toolName, category);
|
|
52
|
+
const fence = deps.outsideWorkspace
|
|
53
|
+
? await deps.outsideWorkspace({ toolName, arguments: args, ...(chatId ? { chatId } : {}) })
|
|
54
|
+
: false;
|
|
55
|
+
const outsideWorkspace = fence === true || fence === 'project' || fence === 'workspace';
|
|
47
56
|
const prompt = {
|
|
48
57
|
requestId,
|
|
49
58
|
toolName,
|
|
50
|
-
arguments:
|
|
51
|
-
? p.arguments
|
|
52
|
-
: {},
|
|
59
|
+
arguments: args,
|
|
53
60
|
mode: typeof p.mode === 'string' ? p.mode : 'other',
|
|
54
|
-
category
|
|
61
|
+
category,
|
|
55
62
|
round: typeof p.round === 'number' ? p.round : 0,
|
|
56
63
|
...(chatId ? { chatId } : {}),
|
|
64
|
+
...(categoryLabel ? { categoryLabel } : {}),
|
|
65
|
+
...(outsideWorkspace ? { outsideWorkspace: true } : {}),
|
|
66
|
+
...(fence === 'workspace' ? { outsideWorkspaceKind: 'workspace' } : {}),
|
|
67
|
+
...(typeof p.sandboxEnforced === 'boolean' ? { sandboxEnforced: p.sandboxEnforced } : {}),
|
|
57
68
|
};
|
|
58
69
|
deps.onLog?.(`approval: prompting for ${toolName} (${prompt.mode}/${prompt.category})`);
|
|
59
70
|
return await new Promise((resolve) => {
|
package/dist/sidecar/types.d.ts
CHANGED
|
@@ -470,6 +470,13 @@ export interface SidecarChatStreamRequest {
|
|
|
470
470
|
* host prompt. Independent of whether the OS exec sandbox is on.
|
|
471
471
|
*/
|
|
472
472
|
writableRoots?: string[];
|
|
473
|
+
/**
|
|
474
|
+
* Directory relative tool paths are joined to before the inside-root
|
|
475
|
+
* check. This is the project home, or the conversation workspace when
|
|
476
|
+
* the chat has no project. Absent, a relative path is inside unless it
|
|
477
|
+
* starts with `..`.
|
|
478
|
+
*/
|
|
479
|
+
pathBase?: string;
|
|
473
480
|
};
|
|
474
481
|
}
|
|
475
482
|
/**
|
|
@@ -524,6 +531,13 @@ export interface SidecarStreamChunk {
|
|
|
524
531
|
backend?: string;
|
|
525
532
|
enforcement: string;
|
|
526
533
|
};
|
|
534
|
+
/** Session-allow reuse. `cached` means this call did not show a card. */
|
|
535
|
+
approval?: {
|
|
536
|
+
kind?: string;
|
|
537
|
+
category?: string;
|
|
538
|
+
cached?: boolean;
|
|
539
|
+
waitMs?: number;
|
|
540
|
+
};
|
|
527
541
|
};
|
|
528
542
|
/** CoreLoop notices: soft_timeout / budget_exhausted / round_end / hook_action. */
|
|
529
543
|
notice?: {
|
package/dist/storage/index.d.ts
CHANGED
|
@@ -19,6 +19,13 @@ export interface ChatSessionRecord {
|
|
|
19
19
|
isPinned: boolean;
|
|
20
20
|
systemPrompt: string | null;
|
|
21
21
|
pinnedRefs: unknown[] | null;
|
|
22
|
+
/**
|
|
23
|
+
* 标题从哪来。`user` 表示用户改过名,自动标题不再覆盖(即使用户把标题改回「新对话」)。
|
|
24
|
+
* `auto` 表示上次由标题生成写入。缺省是旧库,仍按标题文本是否还是「新对话」判断。
|
|
25
|
+
*/
|
|
26
|
+
titleSource?: 'user' | 'auto' | null;
|
|
27
|
+
/** 分叉来源会话 id。原会话已删除时仍保留 id,读取接口再决定是否展示。 */
|
|
28
|
+
forkedFrom?: string | null;
|
|
22
29
|
}
|
|
23
30
|
export interface ChatMessageRecord {
|
|
24
31
|
id: string;
|
|
@@ -183,7 +190,7 @@ export declare class SqliteScopedStore implements ScopedStore {
|
|
|
183
190
|
/** 删除项目时调用:把该项目下所有会话降级为无项目对话(不删会话)。 */
|
|
184
191
|
clearProjectAssignment(projectId: string): Promise<number>;
|
|
185
192
|
getChat(chatId: string): Promise<ChatSessionRecord | null>;
|
|
186
|
-
updateChat(chatId: string, updates: Partial<Pick<ChatSessionRecord, 'title' | 'systemPrompt' | 'pinnedRefs' | 'isPinned' | 'projectId'>>): Promise<ChatSessionRecord | null>;
|
|
193
|
+
updateChat(chatId: string, updates: Partial<Pick<ChatSessionRecord, 'title' | 'systemPrompt' | 'pinnedRefs' | 'isPinned' | 'projectId' | 'titleSource' | 'forkedFrom'>>): Promise<ChatSessionRecord | null>;
|
|
187
194
|
deleteChat(chatId: string): Promise<boolean>;
|
|
188
195
|
chatHasMessages(chatId: string): Promise<boolean>;
|
|
189
196
|
/**
|
package/dist/storage/index.js
CHANGED
|
@@ -322,6 +322,9 @@ export class SqliteScopedStore {
|
|
|
322
322
|
// 项目记录本身存在 json-store(agent-projects.json),这里只存外键;
|
|
323
323
|
// 删项目时由路由层把本列置 NULL(会话降级为无项目对话)。
|
|
324
324
|
this.ensureColumn('chat_sessions', 'project_id', 'TEXT');
|
|
325
|
+
// 用户改名后自动标题不再覆盖。NULL 保持旧规则:标题不是「新对话」就跳过。
|
|
326
|
+
this.ensureColumn('chat_sessions', 'title_source', 'TEXT');
|
|
327
|
+
this.ensureColumn('chat_sessions', 'forked_from', 'TEXT');
|
|
325
328
|
// 任务编排(增量迁移):depends_on 存 JSON string[]——本任务等待的
|
|
326
329
|
// 依赖 taskId 列表;NULL = 无依赖。依赖必须指向建任务时已存在的
|
|
327
330
|
// 任务,所以依赖图天然无环。
|
|
@@ -725,9 +728,9 @@ export class SqliteScopedStore {
|
|
|
725
728
|
};
|
|
726
729
|
this.db.prepare(`
|
|
727
730
|
UPDATE chat_sessions
|
|
728
|
-
SET title = ?, system_prompt = ?, pinned_refs = ?, is_pinned = ?, project_id = ?, updated_at = ?
|
|
731
|
+
SET title = ?, system_prompt = ?, pinned_refs = ?, is_pinned = ?, project_id = ?, title_source = ?, forked_from = ?, updated_at = ?
|
|
729
732
|
WHERE tenant_id = ? AND user_id = ? AND id = ?
|
|
730
|
-
`).run(next.title, next.systemPrompt, JSON.stringify(next.pinnedRefs ?? null), next.isPinned ? 1 : 0, next.projectId ?? null, next.updatedAt, this.scope.tenantId, this.scope.userId, chatId);
|
|
733
|
+
`).run(next.title, next.systemPrompt, JSON.stringify(next.pinnedRefs ?? null), next.isPinned ? 1 : 0, next.projectId ?? null, next.titleSource ?? null, next.forkedFrom ?? null, next.updatedAt, this.scope.tenantId, this.scope.userId, chatId);
|
|
731
734
|
emitChatActivity({ chatId, updatedAt: next.updatedAt });
|
|
732
735
|
return await this.getChat(chatId);
|
|
733
736
|
}
|
|
@@ -1363,6 +1366,8 @@ export class SqliteScopedStore {
|
|
|
1363
1366
|
isPinned: Number(row.is_pinned ?? 0) === 1,
|
|
1364
1367
|
systemPrompt: row.system_prompt ? String(row.system_prompt) : null,
|
|
1365
1368
|
pinnedRefs: row.pinned_refs ? this.safeJson(row.pinned_refs) : null,
|
|
1369
|
+
titleSource: row.title_source === 'user' || row.title_source === 'auto' ? row.title_source : null,
|
|
1370
|
+
forkedFrom: row.forked_from ? String(row.forked_from) : null,
|
|
1366
1371
|
};
|
|
1367
1372
|
}
|
|
1368
1373
|
mapChatMessage(row) {
|
|
@@ -2,6 +2,12 @@ import { type HeldWriteLease } from './write-lease.js';
|
|
|
2
2
|
export declare const HOST_SCHEMA_VERSION = 1;
|
|
3
3
|
/** How long a starting host waits for another host to finish migrating. */
|
|
4
4
|
export declare const OPEN_LOCK_WAIT_MS = 15000;
|
|
5
|
+
/**
|
|
6
|
+
* How long a new process waits while probing the old exclusive database lock.
|
|
7
|
+
* A current process releases that lock immediately. An older build holds it
|
|
8
|
+
* until it exits, so the wait still fails closed.
|
|
9
|
+
*/
|
|
10
|
+
export declare const LEGACY_LOCK_PROBE_MS = 2000;
|
|
5
11
|
export declare class ChatBusyError extends Error {
|
|
6
12
|
readonly chatId: string;
|
|
7
13
|
readonly code = "chat_busy";
|
|
@@ -10,6 +10,12 @@ import { acquireWriteLease, StoreAlreadyOwnedError, } from './write-lease.js';
|
|
|
10
10
|
export const HOST_SCHEMA_VERSION = 1;
|
|
11
11
|
/** How long a starting host waits for another host to finish migrating. */
|
|
12
12
|
export const OPEN_LOCK_WAIT_MS = 15_000;
|
|
13
|
+
/**
|
|
14
|
+
* How long a new process waits while probing the old exclusive database lock.
|
|
15
|
+
* A current process releases that lock immediately. An older build holds it
|
|
16
|
+
* until it exits, so the wait still fails closed.
|
|
17
|
+
*/
|
|
18
|
+
export const LEGACY_LOCK_PROBE_MS = 2_000;
|
|
13
19
|
export class ChatBusyError extends Error {
|
|
14
20
|
chatId;
|
|
15
21
|
code = 'chat_busy';
|
|
@@ -16,7 +16,7 @@ export interface ScopedStore {
|
|
|
16
16
|
createChatWithId(chatId: string, title?: string, agentId?: string | null, projectId?: string | null): Promise<ChatSessionRecord>;
|
|
17
17
|
clearProjectAssignment(projectId: string): Promise<number>;
|
|
18
18
|
getChat(chatId: string): Promise<ChatSessionRecord | null>;
|
|
19
|
-
updateChat(chatId: string, updates: Partial<Pick<ChatSessionRecord, 'title' | 'systemPrompt' | 'pinnedRefs' | 'isPinned' | 'projectId'>>): Promise<ChatSessionRecord | null>;
|
|
19
|
+
updateChat(chatId: string, updates: Partial<Pick<ChatSessionRecord, 'title' | 'systemPrompt' | 'pinnedRefs' | 'isPinned' | 'projectId' | 'titleSource' | 'forkedFrom'>>): Promise<ChatSessionRecord | null>;
|
|
20
20
|
deleteChat(chatId: string): Promise<boolean>;
|
|
21
21
|
chatHasMessages(chatId: string): Promise<boolean>;
|
|
22
22
|
deleteChatIfEmpty(chatId: string): Promise<boolean>;
|
|
@@ -5,7 +5,7 @@ import { getProductConfig } from '../product-config.js';
|
|
|
5
5
|
import { getUserDataDir } from '../runtime.js';
|
|
6
6
|
import { LOCAL_SCOPE } from './driver.js';
|
|
7
7
|
import { SqliteScopedStore } from './index.js';
|
|
8
|
-
import { HOST_SCHEMA_VERSION, OPEN_LOCK_WAIT_MS, StorageUpgradeBlockedError, openLockPath, } from './process-locks.js';
|
|
8
|
+
import { HOST_SCHEMA_VERSION, LEGACY_LOCK_PROBE_MS, OPEN_LOCK_WAIT_MS, StorageUpgradeBlockedError, openLockPath, } from './process-locks.js';
|
|
9
9
|
import { acquireSharedLease, acquireWriteLease, lockPathForDb, StoreAlreadyOwnedError, } from './write-lease.js';
|
|
10
10
|
import { acquireWriteLeaseOrExit } from './write-lease-error.js';
|
|
11
11
|
const MAIN_DB_BUSY_TIMEOUT_MS = 5_000;
|
|
@@ -101,11 +101,12 @@ export class SqliteStorageDriver {
|
|
|
101
101
|
if (this.db)
|
|
102
102
|
return;
|
|
103
103
|
const dbPath = path.join(getUserDataDir(), getProductConfig().dbFileName ?? 'agent-shell.db');
|
|
104
|
-
// A previous build held this lock for the whole process.
|
|
105
|
-
//
|
|
106
|
-
//
|
|
104
|
+
// A previous build held this lock for the whole process. A current
|
|
105
|
+
// process only holds it for this probe, so wait briefly for a peer
|
|
106
|
+
// probe to finish. If the lock is still held, that process cannot
|
|
107
|
+
// share the database.
|
|
107
108
|
acquireWriteLeaseOrExit(() => {
|
|
108
|
-
const legacy = acquireWriteLease(lockPathForDb(dbPath));
|
|
109
|
+
const legacy = acquireWriteLease(lockPathForDb(dbPath), LEGACY_LOCK_PROBE_MS);
|
|
109
110
|
legacy.release();
|
|
110
111
|
});
|
|
111
112
|
const gate = openLockPath(dbPath);
|
package/dist/tool-router.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { McpServerRegistry } from './mcp-server-registry.js';
|
|
|
4
4
|
import type { ProjectRegistry } from './project-registry.js';
|
|
5
5
|
import type { TaskService } from './local-backend/task-service.js';
|
|
6
6
|
import type { WorktreeService } from './local-backend/worktree-service.js';
|
|
7
|
-
import type { ToolContribution, ToolMode, ToolExposure } from './scenario/pack.js';
|
|
7
|
+
import type { ToolApprovalSpec, ToolContribution, ToolMode, ToolExposure } from './scenario/pack.js';
|
|
8
8
|
export type { ToolMode, ToolExposure } from './scenario/pack.js';
|
|
9
9
|
import { type AgentToolPolicy } from './local-backend/agent-capability.js';
|
|
10
10
|
import { GoalStore } from './goal-store.js';
|
|
@@ -31,7 +31,9 @@ export interface ToolCall {
|
|
|
31
31
|
* `projectRoot` 是项目模式的硬沙箱根:非空时
|
|
32
32
|
* - 读路径必须落在根内,或落在 additionalReadRoots 内(含各自子目录)
|
|
33
33
|
* - 写路径必须落在根内,或落在 additionalWriteRoots 内(含各自子目录)
|
|
34
|
-
* - local_exec_shell 的 cwd 默认改为项目根;显式 cwd
|
|
34
|
+
* - local_exec_shell 的 cwd 默认改为项目根;显式 cwd 可以落在可写根外,
|
|
35
|
+
* 命令仍启动。命令沙箱开着时,写入由 exec 沙箱拒绝;沙箱关掉时这次写入不会被沙箱拦住。
|
|
36
|
+
* pwsh 与 local_run_snippet 的 cwd 仍必须落在可写根内
|
|
35
37
|
* 无项目对话(null/缺省)行为与之前完全一致。
|
|
36
38
|
*/
|
|
37
39
|
export interface ToolExecContext {
|
|
@@ -67,7 +69,16 @@ export interface ToolSchema {
|
|
|
67
69
|
mode: ToolMode;
|
|
68
70
|
/** 曝光层;缺省 `direct`。MCP 动态工具默认 `deferred`。 */
|
|
69
71
|
exposure?: ToolExposure;
|
|
72
|
+
/** 审批分组。缺省时 sidecar 用工具名做类别。 */
|
|
73
|
+
approval?: ToolApprovalSpec;
|
|
70
74
|
}
|
|
75
|
+
/** 审批类别:小写开头,最长 64,且不占用内置类别。 */
|
|
76
|
+
export declare const APPROVAL_CATEGORY_PATTERN: RegExp;
|
|
77
|
+
export declare const RESERVED_APPROVAL_CATEGORIES: ReadonlySet<string>;
|
|
78
|
+
/**
|
|
79
|
+
* 装配期校验包工具的审批类别。非法或与内置类别同名时抛错,宿主不装上该包。
|
|
80
|
+
*/
|
|
81
|
+
export declare function assertToolApproval(contribution: Pick<ToolContribution, 'name' | 'approval'>): void;
|
|
71
82
|
type ShellExecutor = (request: LocalExecRequest) => Promise<LocalExecResult>;
|
|
72
83
|
/**
|
|
73
84
|
* web_search / web_fetch 的执行缝(W5-2):唯一实现在 sidecar 的
|
|
@@ -237,15 +248,17 @@ export declare class ToolRouter {
|
|
|
237
248
|
*/
|
|
238
249
|
private autoInstallSkillOnSuccess;
|
|
239
250
|
/**
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
* 相对 cwd 按家目录解析(而不是进程 cwd)。
|
|
251
|
+
* 项目模式的 cwd 解析:有可写根时,缺省 cwd 收到项目家目录。
|
|
252
|
+
* 相对 cwd 按家目录解析(而不是进程 cwd)。`~` 与命令围栏同一规则。
|
|
243
253
|
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
254
|
+
* `allowOutside` 为真时,落在可写根外的 cwd 仍原样交给执行器。只有
|
|
255
|
+
* `local_exec_shell` 走这条路。命令沙箱开着时,审批卡说明写入该目录会被拒绝;
|
|
256
|
+
* 沙箱关掉时卡片不说这句话,这次写入也不会被沙箱拦住。
|
|
257
|
+
* `pwsh` 与代码片段不走这条路,越界 cwd 继续返回路径越界。
|
|
247
258
|
*
|
|
248
|
-
*
|
|
259
|
+
* 命令文本里内嵌的绝对路径无法可靠解析。文件读写工具另有路径围栏,
|
|
260
|
+
* 不随命令沙箱开关放行。命令沙箱开着时,shell 的实际写盘还受 exec 沙箱的
|
|
261
|
+
* writableRoots 约束,那些根含其子目录。
|
|
249
262
|
*/
|
|
250
263
|
private applyProjectCwdSandbox;
|
|
251
264
|
private executeShell;
|
package/dist/tool-router.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { buildProjectRootViolation, expandFencePath, isPathWithinRoot, writableFenceRoots, writableFenceViolation, } from './local-executor.js';
|
|
3
|
+
import { buildProjectRootViolation, canonicalFencePath, expandFencePath, resolveFencePath, isPathWithinRoot, writableFenceRoots, writableFenceViolation, } from './local-executor.js';
|
|
4
4
|
import { writeShellStdin } from './shell-session.js';
|
|
5
5
|
import { mcpExecutor } from './mcp-executor.js';
|
|
6
6
|
import { rankTools, resolveMaxResults, TOOL_SEARCH_DEFAULT_MAX_RESULTS, } from './tool-search-rank.js';
|
|
@@ -24,6 +24,26 @@ export const MCP_DYNAMIC_TOOL_PREFIX = 'mcp__';
|
|
|
24
24
|
*/
|
|
25
25
|
/** deferred 层的发现缝:一个 direct 层搜索工具,关键词命中即调。 */
|
|
26
26
|
export const TOOL_SEARCH_TOOL_NAME = 'tool_search';
|
|
27
|
+
/** 审批类别:小写开头,最长 64,且不占用内置类别。 */
|
|
28
|
+
export const APPROVAL_CATEGORY_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
|
|
29
|
+
export const RESERVED_APPROVAL_CATEGORIES = new Set([
|
|
30
|
+
'network_egress',
|
|
31
|
+
'sandbox_escalation',
|
|
32
|
+
]);
|
|
33
|
+
/**
|
|
34
|
+
* 装配期校验包工具的审批类别。非法或与内置类别同名时抛错,宿主不装上该包。
|
|
35
|
+
*/
|
|
36
|
+
export function assertToolApproval(contribution) {
|
|
37
|
+
const approval = contribution.approval;
|
|
38
|
+
if (approval == null)
|
|
39
|
+
return;
|
|
40
|
+
const category = approval.category;
|
|
41
|
+
if (typeof category !== 'string'
|
|
42
|
+
|| !APPROVAL_CATEGORY_PATTERN.test(category)
|
|
43
|
+
|| RESERVED_APPROVAL_CATEGORIES.has(category)) {
|
|
44
|
+
throw new Error(`[tool-router] tool ${contribution.name} has invalid approval category '${String(category)}'`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
27
47
|
/** 前转 RPC 超时:sidecar 侧单次请求上限默认 30s(可配到 600s),这里给足余量。 */
|
|
28
48
|
export const WEB_TOOL_RPC_TIMEOUT_MS = 130_000;
|
|
29
49
|
const PLUGIN_TOOL_MODES = new Set(['read', 'safe_write', 'destructive']);
|
|
@@ -70,6 +90,7 @@ export class ToolRouter {
|
|
|
70
90
|
if (existing.has(contribution.name)) {
|
|
71
91
|
throw new Error(`[tool-router] duplicate tool name from contribution: ${contribution.name}`);
|
|
72
92
|
}
|
|
93
|
+
assertToolApproval(contribution);
|
|
73
94
|
this.contributedTools.set(contribution.name, contribution);
|
|
74
95
|
existing.add(contribution.name);
|
|
75
96
|
}
|
|
@@ -489,6 +510,7 @@ export class ToolRouter {
|
|
|
489
510
|
inputSchema: c.inputSchema,
|
|
490
511
|
mode: c.mode,
|
|
491
512
|
...(c.exposure ? { exposure: c.exposure } : {}),
|
|
513
|
+
...(c.approval ? { approval: c.approval } : {}),
|
|
492
514
|
})),
|
|
493
515
|
];
|
|
494
516
|
}
|
|
@@ -983,7 +1005,7 @@ export class ToolRouter {
|
|
|
983
1005
|
timeout: typeof args.timeout === 'number' ? args.timeout : undefined,
|
|
984
1006
|
pty: args.pty !== false,
|
|
985
1007
|
yieldMs: typeof args.yieldMs === 'number' ? args.yieldMs : undefined,
|
|
986
|
-
}, projectRoot, context?.additionalWriteRoots ?? null);
|
|
1008
|
+
}, projectRoot, context?.additionalWriteRoots ?? null, { allowOutside: true });
|
|
987
1009
|
case 'write_stdin':
|
|
988
1010
|
return await writeShellStdin({
|
|
989
1011
|
sessionId: typeof args.sessionId === 'string' ? args.sessionId : '',
|
|
@@ -1055,7 +1077,7 @@ export class ToolRouter {
|
|
|
1055
1077
|
target: String(args.target || ''),
|
|
1056
1078
|
});
|
|
1057
1079
|
case 'local_run_snippet': {
|
|
1058
|
-
//
|
|
1080
|
+
// cwd 默认项目根。代码片段不走 exec 沙箱,显式越界 cwd 直接拒绝。
|
|
1059
1081
|
const sandboxed = this.applyProjectCwdSandbox({ cwd: typeof args.cwd === 'string' ? args.cwd : undefined }, projectRoot, context?.additionalWriteRoots ?? null);
|
|
1060
1082
|
if ('error' in sandboxed)
|
|
1061
1083
|
return sandboxed.error;
|
|
@@ -1329,17 +1351,19 @@ export class ToolRouter {
|
|
|
1329
1351
|
}
|
|
1330
1352
|
}
|
|
1331
1353
|
/**
|
|
1332
|
-
*
|
|
1333
|
-
*
|
|
1334
|
-
* 相对 cwd 按家目录解析(而不是进程 cwd)。
|
|
1354
|
+
* 项目模式的 cwd 解析:有可写根时,缺省 cwd 收到项目家目录。
|
|
1355
|
+
* 相对 cwd 按家目录解析(而不是进程 cwd)。`~` 与命令围栏同一规则。
|
|
1335
1356
|
*
|
|
1336
|
-
*
|
|
1337
|
-
*
|
|
1338
|
-
*
|
|
1357
|
+
* `allowOutside` 为真时,落在可写根外的 cwd 仍原样交给执行器。只有
|
|
1358
|
+
* `local_exec_shell` 走这条路。命令沙箱开着时,审批卡说明写入该目录会被拒绝;
|
|
1359
|
+
* 沙箱关掉时卡片不说这句话,这次写入也不会被沙箱拦住。
|
|
1360
|
+
* `pwsh` 与代码片段不走这条路,越界 cwd 继续返回路径越界。
|
|
1339
1361
|
*
|
|
1340
|
-
*
|
|
1362
|
+
* 命令文本里内嵌的绝对路径无法可靠解析。文件读写工具另有路径围栏,
|
|
1363
|
+
* 不随命令沙箱开关放行。命令沙箱开着时,shell 的实际写盘还受 exec 沙箱的
|
|
1364
|
+
* writableRoots 约束,那些根含其子目录。
|
|
1341
1365
|
*/
|
|
1342
|
-
applyProjectCwdSandbox(request, projectRoot, additionalWriteRoots) {
|
|
1366
|
+
applyProjectCwdSandbox(request, projectRoot, additionalWriteRoots, options) {
|
|
1343
1367
|
const roots = writableFenceRoots(projectRoot, additionalWriteRoots);
|
|
1344
1368
|
if (roots.length === 0)
|
|
1345
1369
|
return { request };
|
|
@@ -1348,11 +1372,11 @@ export class ToolRouter {
|
|
|
1348
1372
|
const expanded = request.cwd.startsWith('~')
|
|
1349
1373
|
? path.join(os.homedir(), request.cwd.slice(1))
|
|
1350
1374
|
: request.cwd;
|
|
1351
|
-
const resolvedCwd = path.isAbsolute(expanded)
|
|
1375
|
+
const resolvedCwd = canonicalFencePath(path.isAbsolute(expanded)
|
|
1352
1376
|
? path.resolve(expanded)
|
|
1353
|
-
: path.resolve(home, expanded);
|
|
1377
|
+
: path.resolve(home, expanded));
|
|
1354
1378
|
const violation = writableFenceViolation(resolvedCwd, roots);
|
|
1355
|
-
if (violation) {
|
|
1379
|
+
if (violation && !options?.allowOutside) {
|
|
1356
1380
|
return {
|
|
1357
1381
|
error: {
|
|
1358
1382
|
success: false,
|
|
@@ -1365,8 +1389,8 @@ export class ToolRouter {
|
|
|
1365
1389
|
}
|
|
1366
1390
|
return { request: { ...request, cwd: home } };
|
|
1367
1391
|
}
|
|
1368
|
-
async executeShell(request, projectRoot, additionalWriteRoots) {
|
|
1369
|
-
const sandboxed = this.applyProjectCwdSandbox(request, projectRoot, additionalWriteRoots);
|
|
1392
|
+
async executeShell(request, projectRoot, additionalWriteRoots, options) {
|
|
1393
|
+
const sandboxed = this.applyProjectCwdSandbox(request, projectRoot, additionalWriteRoots, options);
|
|
1370
1394
|
if ('error' in sandboxed)
|
|
1371
1395
|
return sandboxed.error;
|
|
1372
1396
|
if (this.shellExecutor) {
|
|
@@ -1377,21 +1401,11 @@ export class ToolRouter {
|
|
|
1377
1401
|
resolveReadablePath(inputPath, projectRoot, additionalReadRoots) {
|
|
1378
1402
|
if (!inputPath.trim())
|
|
1379
1403
|
return { error: 'path 不能为空' };
|
|
1380
|
-
const
|
|
1381
|
-
? path.join(os.homedir(), inputPath.slice(1))
|
|
1382
|
-
: inputPath;
|
|
1383
|
-
const resolved = path.resolve(expanded);
|
|
1404
|
+
const resolved = resolveFencePath(inputPath, projectRoot);
|
|
1384
1405
|
if (!projectRoot)
|
|
1385
1406
|
return { path: resolved };
|
|
1386
|
-
const root =
|
|
1387
|
-
|
|
1388
|
-
: projectRoot);
|
|
1389
|
-
const inAdditionalRoot = (additionalReadRoots ?? []).some((candidate) => {
|
|
1390
|
-
const expandedCandidate = candidate.startsWith('~')
|
|
1391
|
-
? path.join(os.homedir(), candidate.slice(1))
|
|
1392
|
-
: candidate;
|
|
1393
|
-
return isPathWithinRoot(resolved, path.resolve(expandedCandidate));
|
|
1394
|
-
});
|
|
1407
|
+
const root = expandFencePath(projectRoot);
|
|
1408
|
+
const inAdditionalRoot = (additionalReadRoots ?? []).some((candidate) => isPathWithinRoot(resolved, expandFencePath(candidate)));
|
|
1395
1409
|
if (!isPathWithinRoot(resolved, root) && !inAdditionalRoot) {
|
|
1396
1410
|
return { error: buildProjectRootViolation(resolved, root) };
|
|
1397
1411
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steerable/agent-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Steerable framework — product-neutral Node HostRuntime and headless HTTP server shared by Tauri desktop and browser deployments; scenario packs extend it through @steerable/pack-sdk.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://steerableframework.com/",
|
|
@@ -53,9 +53,9 @@
|
|
|
53
53
|
"jpeg-js": "^0.4.4",
|
|
54
54
|
"node-pty": "^1.1.0",
|
|
55
55
|
"pngjs": "^7.0.0",
|
|
56
|
-
"@steerable/agent-harness": "0.
|
|
57
|
-
"@steerable/
|
|
58
|
-
"@steerable/
|
|
56
|
+
"@steerable/agent-harness": "0.7.1",
|
|
57
|
+
"@steerable/pack-sdk": "0.7.1",
|
|
58
|
+
"@steerable/agent-protocol": "0.7.1"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/better-sqlite3": "^7.6.13",
|