@v587d/capital-generation 2.11.1 → 2.13.0
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 +25 -0
- package/README.md +14 -14
- package/automation/builtins.js +147 -0
- package/automation/calendar.js +66 -0
- package/automation/client.js +68 -0
- package/automation/client.src.cjs +777 -0
- package/automation/defaults.js +71 -0
- package/automation/index.js +183 -0
- package/automation/owner.js +222 -0
- package/automation/package.json +50 -0
- package/automation/policy.js +105 -0
- package/automation/redact.js +25 -0
- package/automation/route.js +241 -0
- package/automation/runner.js +858 -0
- package/automation/scheduler.js +495 -0
- package/automation/schema.js +359 -0
- package/automation/service.js +338 -0
- package/automation/settings.js +48 -0
- package/automation/store.js +707 -0
- package/automation/time.js +220 -0
- package/automation/workspaces.js +154 -0
- package/chart-ui/package.json +1 -1
- package/config/client.js +4 -2
- package/config/client.src.cjs +90 -1
- package/config/index.js +15 -0
- package/config/package.json +1 -1
- package/cordis.patch.yml +8 -0
- package/lib/index.d.ts +38 -0
- package/lib/index.js +16 -0
- package/lib/web-retriever/local-fetch.d.ts +1 -1
- package/lib/web-retriever/local-fetch.js +1 -1
- package/lib/web-retriever/wind-client.d.ts +1 -1
- package/lib/web-retriever/wind-client.js +1 -1
- package/package.json +9 -7
- package/preset/capital-generation/README.md +1 -1
- package/watchlist/package.json +1 -1
|
@@ -0,0 +1,71 @@
|
|
|
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))
|
|
@@ -0,0 +1,183 @@
|
|
|
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 { createTradingCalendar } from './calendar.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 calendar = options.calendar ?? createTradingCalendar()
|
|
42
|
+
const getSettings = typeof settings === 'function' ? settings : () => resolveAutomationSettings(ctx, settings)
|
|
43
|
+
const rawProfileName = String(profile.name)
|
|
44
|
+
const displayName = rawProfileName.slice(0, 200)
|
|
45
|
+
const home = profile.home ?? profile.dir ?? process.cwd()
|
|
46
|
+
const runDir = profile.runDir ?? join(home, 'automation-workspace', safeSegment(displayName))
|
|
47
|
+
const ownerDir = profile.ownerDir ?? join(home, 'storages', 'capital-automation-locks')
|
|
48
|
+
const policyContext = options.policyContext ?? ctx
|
|
49
|
+
const workspaces = createWorkspaceCatalog({ ctx, displayName, runDir, logger })
|
|
50
|
+
const store = createAutomationStore({
|
|
51
|
+
openDomain: () => storageDomain.open(domainSpec),
|
|
52
|
+
profile: { name: rawProfileName },
|
|
53
|
+
now,
|
|
54
|
+
calendar,
|
|
55
|
+
})
|
|
56
|
+
const runner = createRunRunner({
|
|
57
|
+
ctx,
|
|
58
|
+
store,
|
|
59
|
+
settings: getSettings,
|
|
60
|
+
profile: { displayName, runDir },
|
|
61
|
+
workspaces,
|
|
62
|
+
now,
|
|
63
|
+
logger,
|
|
64
|
+
})
|
|
65
|
+
const scheduler = createScheduler({
|
|
66
|
+
store,
|
|
67
|
+
runner,
|
|
68
|
+
calendar,
|
|
69
|
+
profile: { ownerDir },
|
|
70
|
+
getSettings,
|
|
71
|
+
now,
|
|
72
|
+
logger,
|
|
73
|
+
})
|
|
74
|
+
const service = createAutomationService({
|
|
75
|
+
store,
|
|
76
|
+
scheduler,
|
|
77
|
+
runner,
|
|
78
|
+
calendar,
|
|
79
|
+
profile: { displayName },
|
|
80
|
+
workspaces,
|
|
81
|
+
version,
|
|
82
|
+
settings: getSettings,
|
|
83
|
+
now,
|
|
84
|
+
})
|
|
85
|
+
runner.installListeners()
|
|
86
|
+
if (typeof policyContext?.inject === 'function') {
|
|
87
|
+
policyContext.inject(['tools'], (toolsCtx) => {
|
|
88
|
+
installAutomationPolicy({ ctx: toolsCtx, runner, logger })
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
return { store, runner, scheduler, service, calendar, workspaces, profile: { displayName, runDir, ownerDir } }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function authorizeFactory(ctx) {
|
|
95
|
+
return (req) => {
|
|
96
|
+
let connection
|
|
97
|
+
try {
|
|
98
|
+
connection = ctx.get('connection')
|
|
99
|
+
} catch {
|
|
100
|
+
return 401
|
|
101
|
+
}
|
|
102
|
+
if (connection === undefined || connection === null) return 401
|
|
103
|
+
if (typeof connection.requestRejection !== 'function') return 401
|
|
104
|
+
return connection.requestRejection(req)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function unavailable(code, message) {
|
|
109
|
+
return Promise.reject(new AutomationError(code, message, 503))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Route proxy: the page exists before the profile/storage fibers settle, and says so. */
|
|
113
|
+
function lazyService(getService) {
|
|
114
|
+
return new Proxy({}, {
|
|
115
|
+
get(_target, property) {
|
|
116
|
+
return async (...args) => {
|
|
117
|
+
let service
|
|
118
|
+
try {
|
|
119
|
+
service = await getService()
|
|
120
|
+
} catch {
|
|
121
|
+
return unavailable('automation_unavailable', '投研自动化服务尚未就绪。')
|
|
122
|
+
}
|
|
123
|
+
const method = service?.[property]
|
|
124
|
+
if (typeof method !== 'function') return unavailable('automation_unavailable', '投研自动化服务尚未就绪。')
|
|
125
|
+
return method(...args)
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function apply(ctx) {
|
|
132
|
+
const logger = ctx.logger
|
|
133
|
+
let activeService
|
|
134
|
+
|
|
135
|
+
function currentService() {
|
|
136
|
+
return activeService
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
ctx.inject(['profileContext', 'storageDomain'], (appCtx) => {
|
|
140
|
+
let created
|
|
141
|
+
try {
|
|
142
|
+
const profileContext = appCtx.profileContext
|
|
143
|
+
created = createAutomationRuntime({
|
|
144
|
+
ctx,
|
|
145
|
+
profile: profileContext,
|
|
146
|
+
storageDomain: appCtx.storageDomain,
|
|
147
|
+
policyContext: appCtx,
|
|
148
|
+
logger,
|
|
149
|
+
})
|
|
150
|
+
activeService = created.service
|
|
151
|
+
created.scheduler.start()
|
|
152
|
+
.then(() => logger?.info?.(`capital-automation: scheduler ${created.scheduler.status().state}`))
|
|
153
|
+
.catch((error) => logger?.error?.(`capital-automation: scheduler 启动失败:${String(error)}`))
|
|
154
|
+
appCtx.effect(() => async () => {
|
|
155
|
+
if (activeService === created.service) {
|
|
156
|
+
activeService = undefined
|
|
157
|
+
}
|
|
158
|
+
await created.scheduler.stop().catch(() => {})
|
|
159
|
+
await created.runner.dispose?.()
|
|
160
|
+
await created.store.close().catch(() => {})
|
|
161
|
+
}, 'capital-automation: runtime')
|
|
162
|
+
} catch (error) {
|
|
163
|
+
logger?.error?.(`capital-automation: 运行时组装失败:${sanitizeMessage(String(error))}`)
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const service = lazyService(currentService)
|
|
168
|
+
const handler = createAutomationRouteHandler(service, {
|
|
169
|
+
authorize: authorizeFactory(ctx),
|
|
170
|
+
logger,
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
174
|
+
webCtx.effect(
|
|
175
|
+
() => webCtx.webServer.register({ kind: 'prefix', path: ROUTE_PATH, handler }),
|
|
176
|
+
'capital-automation: route',
|
|
177
|
+
)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
ctx.logger?.info?.('capital-automation: host row active (page + /capital-automation)')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
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,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@v587d/capital-automation",
|
|
3
|
+
"version": "2.13.0",
|
|
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
|
+
"owner.js",
|
|
38
|
+
"store.js",
|
|
39
|
+
"service.js",
|
|
40
|
+
"runner.js",
|
|
41
|
+
"scheduler.js",
|
|
42
|
+
"workspaces.js",
|
|
43
|
+
"policy.js",
|
|
44
|
+
"route.js",
|
|
45
|
+
"settings.js",
|
|
46
|
+
"redact.js",
|
|
47
|
+
"client.js",
|
|
48
|
+
"package.json"
|
|
49
|
+
]
|
|
50
|
+
}
|
|
@@ -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
|
+
}
|