@v587d/capital-generation 2.12.0 → 2.13.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.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Automation deployment defaults. Read once at the call site (`settings.js`), never frozen into
3
+ * the scheduler at mount time: the settings card may change between ticks.
4
+ */
5
+ export const DEFAULT_TICK_MS = 60_000
6
+ export const MIN_TICK_MS = 30_000
7
+ export const MAX_TICK_MS = 3_600_000
8
+
9
+ export const DEFAULT_RUN_TIMEOUT_MINUTES = 30
10
+ export const MIN_RUN_TIMEOUT_MINUTES = 5
11
+ export const MAX_RUN_TIMEOUT_MINUTES = 120
12
+
13
+ export const DEFAULT_IDLE_TIMEOUT_MINUTES = 5
14
+ export const MIN_IDLE_TIMEOUT_MINUTES = 1
15
+ export const MAX_IDLE_TIMEOUT_MINUTES = 30
16
+
17
+ // Real multi-agent research runs use hundreds of tool calls (subagent calls included). These
18
+ // limits bound runaway loops, not normal work; wall-clock and idle remain the primary guards.
19
+ export const DEFAULT_MAX_STEPS = 300
20
+ export const MIN_MAX_STEPS = 10
21
+ export const MAX_MAX_STEPS = 2_000
22
+
23
+ export const DEFAULT_MAX_TOOL_CALLS = 1_200
24
+ export const MIN_MAX_TOOL_CALLS = 10
25
+ export const MAX_MAX_TOOL_CALLS = 6_000
26
+
27
+ export const DEFAULT_OWNER_LEASE_MS = 90_000
28
+ export const MIN_OWNER_LEASE_MS = 30_000
29
+ export const MAX_OWNER_LEASE_MS = 600_000
30
+
31
+ export const DEFAULT_CATCH_UP_MS = 10 * 60_000
32
+ export const CANCEL_GRACE_MS = 15_000
33
+ export const QUIET_SETTLE_MS = 750
34
+
35
+ export const MAX_RUNS_PER_TASK = 200
36
+ export const MAX_RUNS_PER_PROFILE = 2_000
37
+ export const MAX_NOTIFICATIONS_PER_PROFILE = 200
38
+ export const MAX_ERRORS_PER_RUN = 20
39
+
40
+ export const MAX_TITLE_CHARS = 120
41
+ export const MAX_DESCRIPTION_CHARS = 500
42
+ export const MAX_INSTRUCTION_CHARS = 12_000
43
+ export const MAX_ERROR_MESSAGE_CHARS = 500
44
+ export const MAX_REQUEST_BODY_BYTES = 64 * 1024
45
+
46
+ export const DEFAULT_AUTOMATION_SETTINGS = {
47
+ tickMs: DEFAULT_TICK_MS,
48
+ runTimeoutMinutes: DEFAULT_RUN_TIMEOUT_MINUTES,
49
+ idleTimeoutMinutes: DEFAULT_IDLE_TIMEOUT_MINUTES,
50
+ maxSteps: DEFAULT_MAX_STEPS,
51
+ maxToolCalls: DEFAULT_MAX_TOOL_CALLS,
52
+ inAppNotifications: true,
53
+ }
54
+
55
+ export const RUN_STATUSES = [
56
+ 'queued',
57
+ 'starting',
58
+ 'running',
59
+ 'succeeded',
60
+ 'failed',
61
+ 'canceled',
62
+ 'timed_out',
63
+ 'blocked_requires_user',
64
+ 'unknown_after_restart',
65
+ 'skipped_overlap',
66
+ 'skipped_calendar_unavailable',
67
+ 'missed',
68
+ ]
69
+
70
+ export const ACTIVE_RUN_STATUSES = ['queued', 'starting', 'running']
71
+ export const TERMINAL_RUN_STATUSES = RUN_STATUSES.filter((status) => !ACTIVE_RUN_STATUSES.includes(status))
72
+
73
+ // A finished turn can be settled from its durable checkpoint; `unknown_after_restart`
74
+ // stays the fallback for runs with no provable turn boundary.
75
+ export const SETTLED_RUN_STATUSES = ['succeeded', 'failed', 'canceled', 'timed_out', 'blocked_requires_user']
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Host row `capital-automation`: fixed domain, profile-filtered API, one cross-process
3
+ * scheduler lease, and the browser page mounted on the host plane. The official
4
+ * `ui-schedule` row and its `schedules` identities are untouched.
5
+ */
6
+ import { readFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+
9
+ import { createRunRunner, buildRunPrompt, sessionTitleOf } from './runner.js'
10
+ import { createScheduler } from './scheduler.js'
11
+ import { createAutomationService, AutomationError } from './service.js'
12
+ import { createAutomationStore, domainSpec, ROUTE_PATH } from './store.js'
13
+ import { createAutomationRouteHandler } from './route.js'
14
+ import { resolveAutomationSettings } from './settings.js'
15
+ import { installAutomationPolicy } from './policy.js'
16
+ import { createCalendarSource } from './calendar-source.js'
17
+ import { createWorkspaceCatalog } from './workspaces.js'
18
+ import { sanitizeMessage } from './redact.js'
19
+
20
+ export const name = 'capital-automation'
21
+ export { ROUTE_PATH, domainSpec, buildRunPrompt, sessionTitleOf }
22
+
23
+ function versionOf() {
24
+ try {
25
+ return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version ?? '0.0.0'
26
+ } catch {
27
+ return '0.0.0'
28
+ }
29
+ }
30
+
31
+ function safeSegment(value) {
32
+ return String(value).replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 120)
33
+ }
34
+
35
+ /**
36
+ * Build the runtime without Cordis services. Exported for tests and for the desktop carrier
37
+ * that supplies its own HTTP/auth plane.
38
+ */
39
+ export function createAutomationRuntime(options) {
40
+ const { ctx, profile, storageDomain, settings, now = () => Date.now(), logger, version = versionOf() } = options
41
+ const getSettings = typeof settings === 'function' ? settings : () => resolveAutomationSettings(ctx, settings)
42
+ const rawProfileName = String(profile.name)
43
+ const displayName = rawProfileName.slice(0, 200)
44
+ const home = profile.home ?? profile.dir ?? process.cwd()
45
+ const runDir = profile.runDir ?? join(home, 'automation-workspace', safeSegment(displayName))
46
+ const ownerDir = profile.ownerDir ?? join(home, 'storages', 'capital-automation-locks')
47
+ const calendar = options.calendar ?? createCalendarSource({
48
+ cacheDir: join(home, 'storages', 'capital-automation-calendar'),
49
+ logger,
50
+ now,
51
+ })
52
+ const policyContext = options.policyContext ?? ctx
53
+ const workspaces = createWorkspaceCatalog({ ctx, displayName, runDir, logger })
54
+ const store = createAutomationStore({
55
+ openDomain: () => storageDomain.open(domainSpec),
56
+ profile: { name: rawProfileName },
57
+ now,
58
+ calendar,
59
+ })
60
+ const runner = createRunRunner({
61
+ ctx,
62
+ store,
63
+ settings: getSettings,
64
+ profile: { displayName, runDir },
65
+ workspaces,
66
+ now,
67
+ logger,
68
+ })
69
+ const scheduler = createScheduler({
70
+ store,
71
+ runner,
72
+ calendar,
73
+ profile: { ownerDir },
74
+ getSettings,
75
+ now,
76
+ logger,
77
+ })
78
+ const service = createAutomationService({
79
+ store,
80
+ scheduler,
81
+ runner,
82
+ calendar,
83
+ profile: { displayName },
84
+ workspaces,
85
+ version,
86
+ settings: getSettings,
87
+ now,
88
+ })
89
+ runner.installListeners()
90
+ if (typeof policyContext?.inject === 'function') {
91
+ policyContext.inject(['tools'], (toolsCtx) => {
92
+ installAutomationPolicy({ ctx: toolsCtx, runner, logger })
93
+ })
94
+ }
95
+ return { store, runner, scheduler, service, calendar, workspaces, profile: { displayName, runDir, ownerDir } }
96
+ }
97
+
98
+ function authorizeFactory(ctx) {
99
+ return (req) => {
100
+ let connection
101
+ try {
102
+ connection = ctx.get('connection')
103
+ } catch {
104
+ return 401
105
+ }
106
+ if (connection === undefined || connection === null) return 401
107
+ if (typeof connection.requestRejection !== 'function') return 401
108
+ return connection.requestRejection(req)
109
+ }
110
+ }
111
+
112
+ function unavailable(code, message) {
113
+ return Promise.reject(new AutomationError(code, message, 503))
114
+ }
115
+
116
+ /** Route proxy: the page exists before the profile/storage fibers settle, and says so. */
117
+ function lazyService(getService) {
118
+ return new Proxy({}, {
119
+ get(_target, property) {
120
+ return async (...args) => {
121
+ let service
122
+ try {
123
+ service = await getService()
124
+ } catch {
125
+ return unavailable('automation_unavailable', '投研自动化服务尚未就绪。')
126
+ }
127
+ const method = service?.[property]
128
+ if (typeof method !== 'function') return unavailable('automation_unavailable', '投研自动化服务尚未就绪。')
129
+ return method(...args)
130
+ }
131
+ },
132
+ })
133
+ }
134
+
135
+ export function apply(ctx) {
136
+ const logger = ctx.logger
137
+ let activeService
138
+
139
+ function currentService() {
140
+ return activeService
141
+ }
142
+
143
+ ctx.inject(['profileContext', 'storageDomain'], (appCtx) => {
144
+ let created
145
+ try {
146
+ const profileContext = appCtx.profileContext
147
+ created = createAutomationRuntime({
148
+ ctx,
149
+ profile: profileContext,
150
+ storageDomain: appCtx.storageDomain,
151
+ policyContext: appCtx,
152
+ logger,
153
+ })
154
+ activeService = created.service
155
+ created.scheduler.start()
156
+ .then(() => logger?.info?.(`capital-automation: scheduler ${created.scheduler.status().state}`))
157
+ .catch((error) => logger?.error?.(`capital-automation: scheduler 启动失败:${String(error)}`))
158
+ appCtx.effect(() => async () => {
159
+ if (activeService === created.service) {
160
+ activeService = undefined
161
+ }
162
+ await created.scheduler.stop().catch(() => {})
163
+ created.calendar.dispose?.()
164
+ await created.runner.dispose?.()
165
+ await created.store.close().catch(() => {})
166
+ }, 'capital-automation: runtime')
167
+ } catch (error) {
168
+ logger?.error?.(`capital-automation: 运行时组装失败:${sanitizeMessage(String(error))}`)
169
+ }
170
+ })
171
+
172
+ const service = lazyService(currentService)
173
+ const handler = createAutomationRouteHandler(service, {
174
+ authorize: authorizeFactory(ctx),
175
+ logger,
176
+ })
177
+
178
+ ctx.inject(['webServer'], (webCtx) => {
179
+ webCtx.effect(
180
+ () => webCtx.webServer.register({ kind: 'prefix', path: ROUTE_PATH, handler }),
181
+ 'capital-automation: route',
182
+ )
183
+ })
184
+
185
+ ctx.logger?.info?.('capital-automation: host row active (page + /capital-automation)')
186
+ }
187
+
188
+ export { resolveAutomationSettings }
@@ -0,0 +1,222 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { mkdir, open, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+
5
+ function safeSegment(value) {
6
+ return String(value).replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 120)
7
+ }
8
+
9
+ function ownerFile(ownerDir) {
10
+ return join(ownerDir, 'owner.json')
11
+ }
12
+
13
+ async function readOwnerFile(ownerDir, expectedProfileKey) {
14
+ try {
15
+ const text = await readFile(ownerFile(ownerDir), 'utf8')
16
+ const value = JSON.parse(text)
17
+ if (value === null || typeof value !== 'object') return undefined
18
+ if (typeof value.ownerId !== 'string' || typeof value.expiresAt !== 'number') return undefined
19
+ if (expectedProfileKey !== undefined && value.profileKey !== expectedProfileKey) return undefined
20
+ return value
21
+ } catch {
22
+ return undefined
23
+ }
24
+ }
25
+
26
+ /** Read the active owner without following a renamed old root. */
27
+ async function readOwner(lockDir, expectedProfileKey) {
28
+ try {
29
+ const entries = await readdir(lockDir, { withFileTypes: true })
30
+ const owners = []
31
+ for (const entry of entries) {
32
+ if (!entry.isDirectory()) continue
33
+ const value = await readOwnerFile(join(lockDir, entry.name), expectedProfileKey)
34
+ if (value !== undefined) owners.push(value)
35
+ }
36
+ // Read old-format records during the one-release migration window.
37
+ const legacy = await readOwnerFile(lockDir, expectedProfileKey)
38
+ if (legacy !== undefined) owners.push(legacy)
39
+ owners.sort((left, right) => (right.expiresAt ?? 0) - (left.expiresAt ?? 0))
40
+ return owners[0]
41
+ } catch {
42
+ return undefined
43
+ }
44
+ }
45
+
46
+ async function atomicWrite(file, value) {
47
+ const temp = `${file}.${process.pid}.${randomUUID()}.tmp`
48
+ await writeFile(temp, `${JSON.stringify(value)}\n`, { mode: 0o600 })
49
+ await rename(temp, file)
50
+ }
51
+
52
+ export function createOwnerLease(options) {
53
+ const {
54
+ directory,
55
+ profileKey,
56
+ ownerId,
57
+ leaseMs,
58
+ now = () => Date.now(),
59
+ random = () => randomUUID(),
60
+ } = options
61
+ // The root is only the arbitration point. Each lease gets a private child directory, so a
62
+ // stale process keeps addressing the renamed old root and can never write into its successor.
63
+ const lockDir = join(directory, `capital_automation.${safeSegment(profileKey)}.lock`)
64
+ let ownedDir
65
+ let held = false
66
+ let token
67
+
68
+ async function ensureDirectory() {
69
+ await mkdir(directory, { recursive: true, mode: 0o700 })
70
+ }
71
+
72
+ async function writeClaim() {
73
+ if (ownedDir === undefined || token === undefined) throw new Error('owner lease is not claimed')
74
+ const at = now()
75
+ await atomicWrite(ownerFile(ownedDir), {
76
+ ownerId,
77
+ fencingToken: token,
78
+ profileKey,
79
+ acquiredAt: at,
80
+ heartbeatAt: at,
81
+ expiresAt: at + leaseMs,
82
+ })
83
+ }
84
+
85
+ async function claimEmptyRoot() {
86
+ token = `${Date.now().toString(36)}-${String(random()).slice(0, 8)}`
87
+ const candidate = join(lockDir, `lease-${safeSegment(ownerId)}-${safeSegment(token)}`)
88
+ await mkdir(candidate, { mode: 0o700 })
89
+ ownedDir = candidate
90
+ try {
91
+ await writeClaim()
92
+ held = true
93
+ return true
94
+ } catch (error) {
95
+ ownedDir = undefined
96
+ token = undefined
97
+ await rm(candidate, { recursive: true, force: true }).catch(() => {})
98
+ throw error
99
+ }
100
+ }
101
+
102
+ async function acquire() {
103
+ if (held) return true
104
+ await ensureDirectory()
105
+ let createdRoot = false
106
+ try {
107
+ await mkdir(lockDir, { mode: 0o700 })
108
+ createdRoot = true
109
+ } catch (error) {
110
+ if (error?.code !== 'EEXIST') throw error
111
+ }
112
+ if (createdRoot) return claimEmptyRoot()
113
+
114
+ const incumbent = await readOwner(lockDir, profileKey)
115
+ if (incumbent !== undefined && Number.isFinite(incumbent.expiresAt) && incumbent.expiresAt > now()) {
116
+ return false
117
+ }
118
+ // Only the contender that wins this rename may install a replacement root. Any old owner
119
+ // still holding the previous child path can write there, but that path is now stale.
120
+ const stale = `${lockDir}.stale-${process.pid}-${String(random()).slice(0, 8)}`
121
+ try {
122
+ await rename(lockDir, stale)
123
+ } catch {
124
+ return false
125
+ }
126
+ try {
127
+ await mkdir(lockDir, { mode: 0o700 })
128
+ return await claimEmptyRoot()
129
+ } catch (error) {
130
+ if (error?.code !== 'EEXIST') throw error
131
+ return false
132
+ } finally {
133
+ await rm(stale, { recursive: true, force: true }).catch(() => {})
134
+ }
135
+ }
136
+
137
+ async function renew() {
138
+ if (!held || ownedDir === undefined || token === undefined) return false
139
+ const current = await readOwnerFile(ownedDir, profileKey)
140
+ const active = await readOwner(lockDir, profileKey)
141
+ if (current?.ownerId !== ownerId || current?.fencingToken !== token
142
+ || active?.ownerId !== ownerId || active?.fencingToken !== token) {
143
+ held = false
144
+ return false
145
+ }
146
+ if (!Number.isFinite(current.expiresAt) || current.expiresAt <= now()
147
+ || !Number.isFinite(active.expiresAt) || active.expiresAt <= now()) {
148
+ held = false
149
+ return false
150
+ }
151
+ try {
152
+ await writeClaim()
153
+ const afterWrite = await readOwner(lockDir, profileKey)
154
+ if (afterWrite?.ownerId !== ownerId || afterWrite?.fencingToken !== token) {
155
+ held = false
156
+ return false
157
+ }
158
+ return true
159
+ } catch {
160
+ held = false
161
+ return false
162
+ }
163
+ }
164
+
165
+ async function release() {
166
+ if (!held) return
167
+ held = false
168
+ const previousDir = ownedDir
169
+ const previousToken = token
170
+ ownedDir = undefined
171
+ token = undefined
172
+ const current = await readOwnerFile(previousDir, profileKey)
173
+ if (current?.ownerId !== ownerId || current?.fencingToken !== previousToken) return
174
+ // Do not remove the root: deleting it races a successor. An empty root is safely renamed by
175
+ // the next contender after it observes that no live child lease remains.
176
+ await rm(previousDir, { recursive: true, force: true }).catch(() => {})
177
+ }
178
+
179
+ return {
180
+ lockDir,
181
+ get held() {
182
+ return held
183
+ },
184
+ get fencingToken() {
185
+ return token
186
+ },
187
+ acquire,
188
+ renew,
189
+ release,
190
+ read: () => readOwner(lockDir, profileKey),
191
+ async isCurrent() {
192
+ if (!held || ownedDir === undefined || token === undefined) return false
193
+ const current = await readOwnerFile(ownedDir, profileKey)
194
+ const active = await readOwner(lockDir, profileKey)
195
+ return current?.ownerId === ownerId
196
+ && current?.fencingToken === token
197
+ && active?.ownerId === ownerId
198
+ && active?.fencingToken === token
199
+ && Number.isFinite(current.expiresAt)
200
+ && current.expiresAt > now()
201
+ && Number.isFinite(active.expiresAt)
202
+ && active.expiresAt > now()
203
+ },
204
+ }
205
+ }
206
+
207
+ /** Test hook: create a foreign lease without racing the public acquire path. */
208
+ export async function writeForeignLease(directory, profileKey, owner) {
209
+ const lockDir = join(directory, `capital_automation.${safeSegment(profileKey)}.lock`)
210
+ await mkdir(lockDir, { recursive: true, mode: 0o700 })
211
+ const child = join(lockDir, `lease-${safeSegment(owner.ownerId ?? 'foreign')}-${safeSegment(owner.fencingToken ?? randomUUID())}`)
212
+ await mkdir(child, { mode: 0o700 })
213
+ await atomicWrite(ownerFile(child), owner)
214
+ return lockDir
215
+ }
216
+
217
+ /** Touch an empty marker file so callers can prove the directory exists before opening a domain. */
218
+ export async function ensureOwnerDirectory(directory) {
219
+ await mkdir(directory, { recursive: true, mode: 0o700 })
220
+ const marker = await open(join(directory, '.keep'), 'a', 0o600)
221
+ await marker.close()
222
+ }
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@v587d/capital-automation",
3
+ "version": "2.13.1",
4
+ "description": "CapGen 投研自动化:独立 host bundle(固定 capital_automation 域、单 scheduler owner、/capital-automation 路由与浏览器页面)。",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "default": "./index.js"
10
+ },
11
+ "./client": {
12
+ "default": "./client.js"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "dsh": {
17
+ "client": {
18
+ "platform": "web",
19
+ "immediately": true,
20
+ "inject": [
21
+ "@deepseek-ai/dsh-client-locale",
22
+ "@deepseek-ai/dsh-client-ui-primitives",
23
+ "@deepseek-ai/dsh-client-ui-workspace"
24
+ ]
25
+ }
26
+ },
27
+ "dependencies": {
28
+ "zod": "^4.5.4"
29
+ },
30
+ "files": [
31
+ "index.js",
32
+ "defaults.js",
33
+ "schema.js",
34
+ "builtins.js",
35
+ "time.js",
36
+ "calendar.js",
37
+ "calendar-source.js",
38
+ "owner.js",
39
+ "store.js",
40
+ "service.js",
41
+ "runner.js",
42
+ "scheduler.js",
43
+ "workspaces.js",
44
+ "policy.js",
45
+ "route.js",
46
+ "settings.js",
47
+ "redact.js",
48
+ "client.js",
49
+ "package.json"
50
+ ]
51
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Hard call-level policy for unattended runs. Identity is the session id registered by the
3
+ * runner: a tool call is constrained only when its exact Agent session belongs to a live run.
4
+ * Role composition already closes each child's tool surface; this layer removes the two
5
+ * interactions that composition cannot express (asking the user and writing files/privilege).
6
+ */
7
+ export const BLOCKED_TOOL_NAMES = ['ask_user_question', 'write', 'edit']
8
+ export const SHELL_TOOL_NAMES = ['bash', 'pwsh', 'run_code']
9
+
10
+ const INSTALLED = new WeakMap()
11
+
12
+ function runnerOf(value) {
13
+ try {
14
+ return typeof value === 'function' ? value() : value
15
+ } catch {
16
+ return undefined
17
+ }
18
+ }
19
+
20
+ function isRecord(value) {
21
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
22
+ }
23
+
24
+ export function automationGuardReason(exec, runner) {
25
+ const name = exec?.name
26
+ if (typeof name !== 'string') return undefined
27
+ const activeRunner = runnerOf(runner)
28
+ const sessionId = exec?.agent?.session?.id
29
+ if (typeof sessionId !== 'string' || typeof activeRunner?.hasAutomationSession !== 'function' || !activeRunner.hasAutomationSession(sessionId)) return undefined
30
+ if (BLOCKED_TOOL_NAMES.includes(name)) {
31
+ const reason = name === 'ask_user_question'
32
+ ? '无人值守运行不能向用户提问:请基于现有信息采用保守口径继续,并在最终答复开头写明「需要用户确认」的缺口。'
33
+ : `无人值守运行不能写文件(${name}):请把结论写在最终答复中,不要修改工作区。`
34
+ activeRunner.noteGuardDenied(sessionId, name, reason)
35
+ return reason
36
+ }
37
+ if (SHELL_TOOL_NAMES.includes(name)) {
38
+ const args = isRecord(exec.arguments) ? exec.arguments : undefined
39
+ if (args?.sandbox_permissions !== undefined || args?.justification !== undefined) {
40
+ const reason = '无人值守运行不能申请沙箱提权:请只读执行,缺文件或权限时如实说明。'
41
+ activeRunner.noteGuardDenied(sessionId, name, reason)
42
+ return reason
43
+ }
44
+ }
45
+ return undefined
46
+ }
47
+
48
+ export function installAutomationPolicy({ ctx, runner, logger }) {
49
+ const root = ctx.root ?? ctx
50
+ if (root === null || (typeof root !== 'object' && typeof root !== 'function')) return
51
+ const existing = INSTALLED.get(root)
52
+ if (existing !== undefined) {
53
+ existing.runner = runner
54
+ existing.generation += 1
55
+ const generation = existing.generation
56
+ const cleanup = () => {
57
+ if (INSTALLED.get(root) !== existing || existing.generation !== generation) return
58
+ try {
59
+ if (typeof existing.disposeGuard === 'function') existing.disposeGuard()
60
+ } finally {
61
+ INSTALLED.delete(root)
62
+ }
63
+ }
64
+ if (typeof ctx.effect === 'function') {
65
+ ctx.effect(() => cleanup, 'capital-automation: unattended policy')
66
+ return
67
+ }
68
+ return cleanup
69
+ }
70
+ let tools
71
+ try {
72
+ tools = ctx.get?.('tools') ?? ctx.tools
73
+ } catch {
74
+ tools = undefined
75
+ }
76
+ if (tools === undefined || typeof tools.guard !== 'function') {
77
+ logger?.warn?.('capital-automation: tools.guard 不可用,无人值守工具闸门未安装')
78
+ return
79
+ }
80
+ const record = { runner, generation: 1, disposeGuard: undefined }
81
+ const install = () => {
82
+ try {
83
+ record.disposeGuard = tools.guard((exec) => automationGuardReason(exec, record.runner))
84
+ INSTALLED.set(root, record)
85
+ logger?.info?.('capital-automation: 无人值守工具闸门已安装(ask_user_question / write / edit / shell 提权)')
86
+ } catch (error) {
87
+ logger?.warn?.(`capital-automation: 无人值守工具闸门安装失败:${String(error)}`)
88
+ return undefined
89
+ }
90
+ const generation = record.generation
91
+ return () => {
92
+ if (INSTALLED.get(root) !== record || record.generation !== generation) return
93
+ try {
94
+ if (typeof record.disposeGuard === 'function') record.disposeGuard()
95
+ } finally {
96
+ INSTALLED.delete(root)
97
+ }
98
+ }
99
+ }
100
+ if (typeof ctx.effect === 'function') {
101
+ ctx.effect(install, 'capital-automation: unattended policy')
102
+ return
103
+ }
104
+ return install()
105
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Error text crossing the storage/wire boundary. Keep the user-readable cause, remove credentials,
3
+ * control bytes and host paths; model/tool payloads never enter this function.
4
+ */
5
+ const TOKEN_PATTERNS = [
6
+ /\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi,
7
+ /\bsk-[A-Za-z0-9_-]{8,}/g,
8
+ /\b[A-Za-z0-9_-]{32,}\b/g,
9
+ ]
10
+
11
+ const PATH_PATTERNS = [
12
+ /(?:[A-Za-z]:\\|\/)(?:[^\s"'`]+[\\/])+[^\s"'`]*/g,
13
+ /~\/[^\s"'`]+/g,
14
+ ]
15
+
16
+ export function sanitizeMessage(value) {
17
+ let text = typeof value === 'string' ? value : String(value ?? '')
18
+ // eslint-disable-next-line no-control-regex
19
+ text = text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
20
+ text = text.replace(/\r\n?/g, '\n')
21
+ for (const pattern of TOKEN_PATTERNS) text = text.replace(pattern, '[redacted]')
22
+ for (const pattern of PATH_PATTERNS) text = text.replace(pattern, '[path]')
23
+ const trimmed = text.trim()
24
+ return trimmed.length > 500 ? `${trimmed.slice(0, 497)}...` : trimmed
25
+ }