dsh-device-fleet 0.0.0-stage → 0.1.0-dev.2
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/CONTEXT.md +123 -0
- package/LICENSE +21 -0
- package/README.md +133 -2
- package/cordis.patch.yml +26 -0
- package/docs/adr/0001-fleet-enrollment-over-account-auto-discovery.md +17 -0
- package/docs/adr/0002-shared-room-as-coordination-unit.md +17 -0
- package/docs/adr/0003-direct-call-and-room-as-two-channels.md +22 -0
- package/docs/adr/0004-direct-call-policy-owned-by-target-device.md +23 -0
- package/docs/adr/0005-upgrade-session-to-room-host.md +22 -0
- package/docs/adr/0006-addressed-messages-and-any-device-speaks.md +16 -0
- package/docs/adr/0007-user-speech-decoupled-from-device.md +21 -0
- package/docs/adr/0008-extend-global-task-list-as-room-board.md +21 -0
- package/docs/adr/0009-room-policy-configured-at-creation.md +21 -0
- package/docs/adr/0010-room-policy-v1-fields-and-custom-room-types.md +24 -0
- package/docs/adr/0011-room-board-lifecycle-and-hooks.md +27 -0
- package/docs/adr/0012-lazy-wake-and-directed-speech.md +22 -0
- package/docs/adr/0013-direct-call-as-device-agnostic-functions.md +26 -0
- package/docs/adr/0014-fleet-call-unified-entry-with-typed-shortcuts.md +22 -0
- package/docs/adr/0015-room-plus-local-teams-and-delegation.md +22 -0
- package/docs/adr/0016-delegation-upgrade-reuses-session.md +20 -0
- package/docs/adr/0017-network-phases-direct-and-relay.md +21 -0
- package/docs/adr/0018-offline-fail-fast-and-durable-room-log.md +21 -0
- package/docs/adr/0019-execution-context-configured-locally.md +24 -0
- package/docs/adr/0020-approval-inbox-with-scoped-grants.md +22 -0
- package/docs/adr/0021-room-visibility-user-full-agents-scoped.md +26 -0
- package/docs/adr/0022-tool-adoption-mechanisms.md +23 -0
- package/docs/adr/0023-reuse-session-trajectory-for-call-details.md +22 -0
- package/docs/adr/0024-strict-version-match-with-reserved-capabilities.md +22 -0
- package/docs/notes/design-decisions-summary.md +49 -0
- package/docs/notes/dev-release.md +28 -0
- package/docs/notes/dsh-global-task-list-evaluation.md +54 -0
- package/docs/notes/network-v1-v2-implementation.md +71 -0
- package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
- package/package.json +42 -3
- package/src/host/direct-call.js +190 -0
- package/src/host/execution-slot.js +82 -0
- package/src/host/index.js +6 -0
- package/src/host/store.js +128 -0
- package/src/host/tool-policy.js +150 -0
- package/src/host/tools.js +215 -0
- package/src/index.js +3 -0
- package/src/net/direct.js +69 -0
- package/src/net/discovery.js +105 -0
- package/src/net/fleet-node.js +414 -0
- package/src/net/identity.js +156 -0
- package/src/net/index.js +9 -0
- package/src/net/protocol.js +73 -0
- package/src/net/relay-client.js +258 -0
- package/src/net/relay-server.js +366 -0
- package/src/net/secure-channel.js +538 -0
- package/src/plugin/index.js +285 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Direct-call host/client over the fleet RPC layer (ADR-0003, ADR-0004, ADR-0014).
|
|
2
|
+
//
|
|
3
|
+
// Host side runs on the resource-owner device: it applies Tool Policy, may ask
|
|
4
|
+
// for local approval, then executes through the Execution Slot (or a direct fs
|
|
5
|
+
// read for list/fetch). Client side runs on the caller device and backs the
|
|
6
|
+
// `fleet_*` model tools.
|
|
7
|
+
import { DEFAULT_SHELL_TOOL } from './execution-slot.js'
|
|
8
|
+
|
|
9
|
+
const DEFAULT_POLICY_TIMEOUT_MS = 60_000
|
|
10
|
+
|
|
11
|
+
function toolText(result) {
|
|
12
|
+
if (!result) return ''
|
|
13
|
+
if (result.isError) return `Error: ${result.text || 'tool failed'}`
|
|
14
|
+
return result.text ?? ''
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class DirectCallHost {
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} options
|
|
20
|
+
* @param {object} options.slot ExecutionSlot
|
|
21
|
+
* @param {import('./tool-policy.js').ToolPolicy} options.policy
|
|
22
|
+
* @param {{resolve: Function, listDir: Function, stat?: Function, readText?: Function}} [options.fs]
|
|
23
|
+
* @param {(request: object) => Promise<boolean>} [options.requestApproval]
|
|
24
|
+
* @param {string} options.deviceId
|
|
25
|
+
* @param {string} [options.name]
|
|
26
|
+
* @param {string} [options.pluginVersion]
|
|
27
|
+
* @param {string} [options.dshVersion]
|
|
28
|
+
* @param {string[]} [options.capabilities]
|
|
29
|
+
* @param {string} [options.shellTool]
|
|
30
|
+
*/
|
|
31
|
+
constructor({
|
|
32
|
+
slot,
|
|
33
|
+
policy,
|
|
34
|
+
fs,
|
|
35
|
+
requestApproval,
|
|
36
|
+
deviceId,
|
|
37
|
+
name = 'device',
|
|
38
|
+
pluginVersion = '0.0.0',
|
|
39
|
+
dshVersion = 'unknown',
|
|
40
|
+
capabilities = [],
|
|
41
|
+
shellTool = DEFAULT_SHELL_TOOL,
|
|
42
|
+
approvalTimeoutMs = DEFAULT_POLICY_TIMEOUT_MS,
|
|
43
|
+
}) {
|
|
44
|
+
this.slot = slot
|
|
45
|
+
this.policy = policy
|
|
46
|
+
this.fs = fs
|
|
47
|
+
this.requestApproval = requestApproval
|
|
48
|
+
this.deviceId = deviceId
|
|
49
|
+
this.name = name
|
|
50
|
+
this.pluginVersion = pluginVersion
|
|
51
|
+
this.dshVersion = dshVersion
|
|
52
|
+
this.capabilities = capabilities
|
|
53
|
+
this.shellTool = shellTool
|
|
54
|
+
this.approvalTimeoutMs = approvalTimeoutMs
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
info() {
|
|
58
|
+
return {
|
|
59
|
+
deviceId: this.deviceId,
|
|
60
|
+
name: this.name,
|
|
61
|
+
pluginVersion: this.pluginVersion,
|
|
62
|
+
dshVersion: this.dshVersion,
|
|
63
|
+
capabilities: this.capabilities,
|
|
64
|
+
policy: this.policy.summarize(),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async handleRequest({ deviceId, method, params }) {
|
|
69
|
+
switch (method) {
|
|
70
|
+
case 'fleet.ping':
|
|
71
|
+
return { pong: true, deviceId: this.deviceId }
|
|
72
|
+
case 'fleet.info':
|
|
73
|
+
return this.info()
|
|
74
|
+
case 'fleet.tools':
|
|
75
|
+
return { deviceId: this.deviceId, tools: await this.slot.listTools(), policy: this.policy.summarize() }
|
|
76
|
+
case 'fleet.call':
|
|
77
|
+
return this.handleCall({ fromDeviceId: deviceId, request: params ?? {} })
|
|
78
|
+
default:
|
|
79
|
+
throw Object.assign(new Error(`unknown fleet method: ${String(method)}`), { code: 'unknown-method' })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async handleCall({ fromDeviceId, request }) {
|
|
84
|
+
const capability = request.capability ?? 'tool'
|
|
85
|
+
const tool = request.tool ?? null
|
|
86
|
+
const args = request.args ?? {}
|
|
87
|
+
const decision = this.policy.decide({ capability, tool, args })
|
|
88
|
+
if (decision.decision === 'deny') {
|
|
89
|
+
throw Object.assign(new Error(decision.reason), { code: 'denied', data: { capability, tool } })
|
|
90
|
+
}
|
|
91
|
+
if (decision.decision === 'ask') {
|
|
92
|
+
const approved = await this.askApproval({ fromDeviceId, capability, tool, args, reason: decision.reason })
|
|
93
|
+
if (!approved) throw Object.assign(new Error('local approval was not granted'), { code: 'approval_denied' })
|
|
94
|
+
this.policy.grant(decision.key, { scope: 'once' })
|
|
95
|
+
}
|
|
96
|
+
const startedAt = Date.now()
|
|
97
|
+
const result = await this.execute({ capability, tool, args })
|
|
98
|
+
return {
|
|
99
|
+
...result,
|
|
100
|
+
meta: {
|
|
101
|
+
...(result.meta ?? {}),
|
|
102
|
+
capability,
|
|
103
|
+
tool,
|
|
104
|
+
deviceId: this.deviceId,
|
|
105
|
+
durationMs: Date.now() - startedAt,
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async askApproval({ fromDeviceId, capability, tool, args, reason }) {
|
|
111
|
+
if (typeof this.requestApproval !== 'function') return false
|
|
112
|
+
try {
|
|
113
|
+
const approved = await this.requestApproval({ fromDeviceId, capability, tool, args, reason })
|
|
114
|
+
return approved === true
|
|
115
|
+
} catch {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async execute({ capability, tool, args }) {
|
|
121
|
+
switch (capability) {
|
|
122
|
+
case 'read':
|
|
123
|
+
case 'grep':
|
|
124
|
+
case 'run':
|
|
125
|
+
case 'tool': {
|
|
126
|
+
const name = capability === 'run' ? this.shellTool : tool
|
|
127
|
+
if (typeof name !== 'string' || name === '') {
|
|
128
|
+
throw Object.assign(new Error(`capability ${capability} needs a tool name`), { code: 'bad-request' })
|
|
129
|
+
}
|
|
130
|
+
return this.slot.executeTool({ name, args })
|
|
131
|
+
}
|
|
132
|
+
case 'list':
|
|
133
|
+
return this.readDirectory(args)
|
|
134
|
+
case 'fetch':
|
|
135
|
+
return this.fetchFile(args)
|
|
136
|
+
default:
|
|
137
|
+
throw Object.assign(new Error(`unsupported capability: ${String(capability)}`), { code: 'unsupported' })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async readDirectory(args) {
|
|
142
|
+
if (!this.fs) throw Object.assign(new Error('filesystem is unavailable'), { code: 'unsupported' })
|
|
143
|
+
const path = String(args?.path ?? args?.dir ?? '')
|
|
144
|
+
if (!path) throw Object.assign(new Error('list needs path'), { code: 'bad-request' })
|
|
145
|
+
const target = await this.fs.resolve(path)
|
|
146
|
+
const entries = await this.fs.listDir(target)
|
|
147
|
+
const lines = entries.map((entry) => `${entry.type}\t${entry.name}${entry.size === undefined ? '' : `\t${entry.size}`}`)
|
|
148
|
+
return { isError: false, text: lines.join('\n') || '(empty directory)', content: [], meta: { path } }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async fetchFile(args) {
|
|
152
|
+
if (!this.fs) throw Object.assign(new Error('filesystem is unavailable'), { code: 'unsupported' })
|
|
153
|
+
const path = String(args?.path ?? '')
|
|
154
|
+
if (!path) throw Object.assign(new Error('fetch needs path'), { code: 'bad-request' })
|
|
155
|
+
const target = await this.fs.resolve(path)
|
|
156
|
+
if (typeof this.fs.readText !== 'function') {
|
|
157
|
+
throw Object.assign(new Error('fetch is unavailable on this device'), { code: 'unsupported' })
|
|
158
|
+
}
|
|
159
|
+
const text = await this.fs.readText(target)
|
|
160
|
+
return { isError: false, text, content: [], meta: { path, bytes: Buffer.byteLength(text) } }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export class DirectCallClient {
|
|
165
|
+
constructor({ node }) {
|
|
166
|
+
this.node = node
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async info(deviceId) {
|
|
170
|
+
return this.node.request(deviceId, 'fleet.info', {})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async listTools(deviceId) {
|
|
174
|
+
return this.node.request(deviceId, 'fleet.tools', {})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async call(deviceId, { capability, tool = null, args = {} }, options = {}) {
|
|
178
|
+
return this.node.request(deviceId, 'fleet.call', { capability, tool, args }, options)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Format a remote result for a model-facing text tool. */
|
|
182
|
+
render(result) {
|
|
183
|
+
if (result?.isError) return `Error: ${result.text || 'remote tool failed'}`
|
|
184
|
+
const meta = result?.meta ?? {}
|
|
185
|
+
const footer = meta.durationMs !== undefined ? `\n[${String(meta.capability ?? 'call')} on ${String(meta.deviceId ?? 'peer')} · ${String(meta.durationMs)}ms]` : ''
|
|
186
|
+
return `${result?.text ?? ''}${footer}`.trim()
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export { toolText }
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Execution Slot: a hidden DSH session/agent that gives remote direct calls a
|
|
2
|
+
// legal tool-execution context (cwd, sandbox policy, approval routing) without
|
|
3
|
+
// running any model turn. See ADR-0003 and ADR-0019.
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_SHELL_TOOL = process.platform === 'win32' ? 'pwsh' : 'bash'
|
|
7
|
+
|
|
8
|
+
export class ExecutionSlot {
|
|
9
|
+
/**
|
|
10
|
+
* @param {{sessionController: object, tools: object, config?: {cwd?: string, agentPreset?: string}}} options
|
|
11
|
+
*/
|
|
12
|
+
constructor({ sessionController, tools, config = {} }) {
|
|
13
|
+
this.sessionController = sessionController
|
|
14
|
+
this.tools = tools
|
|
15
|
+
this.config = config
|
|
16
|
+
this.agent = null
|
|
17
|
+
this.sessionId = null
|
|
18
|
+
this.initializing = null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async ensureAgent() {
|
|
22
|
+
if (this.agent) return this.agent
|
|
23
|
+
if (this.initializing) return this.initializing
|
|
24
|
+
this.initializing = (async () => {
|
|
25
|
+
const created = await this.sessionController.create({
|
|
26
|
+
cwd: this.config.cwd,
|
|
27
|
+
agentPreset: this.config.agentPreset,
|
|
28
|
+
})
|
|
29
|
+
const sessionId = created?.sessionId
|
|
30
|
+
if (!sessionId) throw Object.assign(new Error('execution slot could not create a session'), { code: 'slot-unavailable' })
|
|
31
|
+
const resolved = await this.sessionController.resolveAgent(sessionId)
|
|
32
|
+
if (!resolved?.agent) {
|
|
33
|
+
const reason = resolved?.error?.code ?? 'session/agent-busy'
|
|
34
|
+
throw Object.assign(new Error(`execution slot agent unavailable: ${String(reason)}`), { code: 'slot-unavailable' })
|
|
35
|
+
}
|
|
36
|
+
this.sessionId = sessionId
|
|
37
|
+
this.agent = resolved.agent
|
|
38
|
+
return this.agent
|
|
39
|
+
})().finally(() => {
|
|
40
|
+
this.initializing = null
|
|
41
|
+
})
|
|
42
|
+
return this.initializing
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Model-facing tool schemas visible to the execution slot agent. */
|
|
46
|
+
async listTools() {
|
|
47
|
+
const agent = await this.ensureAgent()
|
|
48
|
+
const schemas = this.tools.schemas?.(agent) ?? this.tools.schemas?.() ?? []
|
|
49
|
+
return schemas
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Execute one tool inside the slot context and normalize the result.
|
|
54
|
+
* @param {{name: string, args: object, signal?: AbortSignal}} call
|
|
55
|
+
*/
|
|
56
|
+
async executeTool({ name, args = {}, signal }) {
|
|
57
|
+
const agent = await this.ensureAgent()
|
|
58
|
+
const batch = await this.tools.execute({
|
|
59
|
+
calls: [{ id: randomUUID(), name, arguments: JSON.stringify(args ?? {}) }],
|
|
60
|
+
agent,
|
|
61
|
+
signal,
|
|
62
|
+
})
|
|
63
|
+
const first = batch?.results?.[0]?.result
|
|
64
|
+
if (!first) throw Object.assign(new Error(`tool ${name} returned no result`), { code: 'tool-no-result' })
|
|
65
|
+
const text = (first.content ?? [])
|
|
66
|
+
.map((block) => (block?.type === 'text' ? block.text : `[${String(block?.type ?? 'block')}]`))
|
|
67
|
+
.join('\n')
|
|
68
|
+
return {
|
|
69
|
+
isError: first.isError === true,
|
|
70
|
+
text,
|
|
71
|
+
content: first.content ?? [],
|
|
72
|
+
error: first.error ?? null,
|
|
73
|
+
meta: first.meta ?? null,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Tool names currently visible to the slot agent. */
|
|
78
|
+
async toolNames() {
|
|
79
|
+
const tools = await this.listTools()
|
|
80
|
+
return tools.map((tool) => tool.name).filter((name) => typeof name === 'string')
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Persistent device state for the fleet plugin: identity, fleet, roster, config.
|
|
2
|
+
// Everything lives under `$DSH_HOME/device-fleet/` (or DEVICE_FLEET_DATA_DIR).
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { createFleet, decodeInvite, encodeInvite, generateIdentity } from '../net/identity.js'
|
|
6
|
+
|
|
7
|
+
export function resolveDataDir({ dshHome, override } = {}) {
|
|
8
|
+
if (override) return path.resolve(override)
|
|
9
|
+
if (process.env.DEVICE_FLEET_DATA_DIR) return path.resolve(process.env.DEVICE_FLEET_DATA_DIR)
|
|
10
|
+
const home = dshHome ?? process.env.DSH_HOME ?? process.cwd()
|
|
11
|
+
return path.join(home, 'device-fleet')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readJson(file, fallback) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
17
|
+
} catch {
|
|
18
|
+
return fallback
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeJsonAtomic(file, value) {
|
|
23
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
24
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
|
|
25
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2))
|
|
26
|
+
fs.renameSync(tmp, file)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Load or create the persistent device state.
|
|
31
|
+
* @returns {{dataDir: string, identity: object, fleet: object, roster: object[], config: object, enrollment: object|null, createdFleet: boolean}}
|
|
32
|
+
*/
|
|
33
|
+
export function loadState({ dshHome, dataDir, name = 'device' } = {}) {
|
|
34
|
+
const dir = resolveDataDir({ dshHome, override: dataDir })
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
36
|
+
|
|
37
|
+
let identity = readJson(path.join(dir, 'identity.json'), null)
|
|
38
|
+
if (!identity) {
|
|
39
|
+
identity = generateIdentity({ name })
|
|
40
|
+
writeJsonAtomic(path.join(dir, 'identity.json'), identity)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let createdFleet = false
|
|
44
|
+
let fleet = readJson(path.join(dir, 'fleet.json'), null)
|
|
45
|
+
if (!fleet) {
|
|
46
|
+
fleet = createFleet({ name: `${name} fleet` })
|
|
47
|
+
writeJsonAtomic(path.join(dir, 'fleet.json'), fleet)
|
|
48
|
+
createdFleet = true
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const roster = readJson(path.join(dir, 'roster.json'), [])
|
|
52
|
+
const config = readJson(path.join(dir, 'config.json'), {})
|
|
53
|
+
let enrollment = readJson(path.join(dir, 'enrollment.json'), null)
|
|
54
|
+
let pendingEnrollment = readJson(path.join(dir, 'enrollment.pending.json'), null)
|
|
55
|
+
|
|
56
|
+
// A pending invite written by `fleet_join` takes effect on the next load:
|
|
57
|
+
// enrolling while the network node is already running would leave the two
|
|
58
|
+
// sides on different fleets.
|
|
59
|
+
if (pendingEnrollment?.code) {
|
|
60
|
+
try {
|
|
61
|
+
const decoded = decodeInvite(pendingEnrollment.code)
|
|
62
|
+
fleet = decoded.fleet
|
|
63
|
+
enrollment = {
|
|
64
|
+
joinedAt: Date.now(),
|
|
65
|
+
accountFingerprint: decoded.accountFingerprint ?? null,
|
|
66
|
+
relay: decoded.relay ?? null,
|
|
67
|
+
}
|
|
68
|
+
writeJsonAtomic(path.join(dir, 'fleet.json'), fleet)
|
|
69
|
+
writeJsonAtomic(path.join(dir, 'enrollment.json'), enrollment)
|
|
70
|
+
fs.unlinkSync(path.join(dir, 'enrollment.pending.json'))
|
|
71
|
+
pendingEnrollment = null
|
|
72
|
+
} catch {
|
|
73
|
+
// Keep the pending file; the user can fix the invite and restart again.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { dataDir: dir, identity, fleet, roster: Array.isArray(roster) ? roster : [], config, enrollment, pendingEnrollment, createdFleet }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Queue an invite for the next plugin load (the running node keeps its fleet). */
|
|
81
|
+
export function writePendingEnrollment(state, code) {
|
|
82
|
+
decodeInvite(code) // validate before writing
|
|
83
|
+
writeJsonAtomic(path.join(state.dataDir, 'enrollment.pending.json'), { code, queuedAt: Date.now() })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function saveIdentity(state) {
|
|
87
|
+
writeJsonAtomic(path.join(state.dataDir, 'identity.json'), state.identity)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function saveFleet(state) {
|
|
91
|
+
writeJsonAtomic(path.join(state.dataDir, 'fleet.json'), state.fleet)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function saveRoster(state, roster) {
|
|
95
|
+
state.roster = roster
|
|
96
|
+
writeJsonAtomic(path.join(state.dataDir, 'roster.json'), roster)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function saveConfig(state, config) {
|
|
100
|
+
state.config = config
|
|
101
|
+
writeJsonAtomic(path.join(state.dataDir, 'config.json'), config)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Create an invite for this device's fleet. */
|
|
105
|
+
export function createInvite(state, { accountFingerprint = null, relay = null } = {}) {
|
|
106
|
+
return encodeInvite({ fleet: state.fleet, accountFingerprint, relay })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Join a fleet from an invite code (overwrites fleet/enrollment, keeps identity). */
|
|
110
|
+
export function enrollFromInvite(state, code) {
|
|
111
|
+
const decoded = decodeInvite(code)
|
|
112
|
+
state.fleet = decoded.fleet
|
|
113
|
+
state.enrollment = {
|
|
114
|
+
joinedAt: Date.now(),
|
|
115
|
+
accountFingerprint: decoded.accountFingerprint ?? null,
|
|
116
|
+
relay: decoded.relay ?? null,
|
|
117
|
+
}
|
|
118
|
+
saveFleet(state)
|
|
119
|
+
writeJsonAtomic(path.join(state.dataDir, 'enrollment.json'), state.enrollment)
|
|
120
|
+
return state.enrollment
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Check a locally observed account fingerprint against the invite's binding. */
|
|
124
|
+
export function accountMatches(state, fingerprint) {
|
|
125
|
+
const expected = state.enrollment?.accountFingerprint ?? null
|
|
126
|
+
if (!expected) return true // unbound invite: accept the first account and caller may bind
|
|
127
|
+
return fingerprint !== null && fingerprint === expected
|
|
128
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Target-device tool policy (ADR-0004, ADR-0019, ADR-0020).
|
|
2
|
+
// The policy owns: allowed roots, per-capability mode (auto/ask/deny),
|
|
3
|
+
// the raw-tool allowlist, and short-lived grants created by approvals.
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
export const CAPABILITIES = ['read', 'list', 'grep', 'fetch', 'run', 'tool']
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_POLICY = Object.freeze({
|
|
9
|
+
allowedRoots: [],
|
|
10
|
+
capabilities: {
|
|
11
|
+
read: 'auto',
|
|
12
|
+
list: 'auto',
|
|
13
|
+
grep: 'auto',
|
|
14
|
+
fetch: 'auto',
|
|
15
|
+
run: 'ask',
|
|
16
|
+
tool: 'deny',
|
|
17
|
+
},
|
|
18
|
+
allowedTools: [],
|
|
19
|
+
approvalTtlMs: 15 * 60 * 1000,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
/** True when `candidate` resolves inside one of `roots`. */
|
|
23
|
+
export function pathWithinRoots(roots, candidate) {
|
|
24
|
+
const resolved = path.resolve(String(candidate))
|
|
25
|
+
return roots.some((root) => {
|
|
26
|
+
const base = path.resolve(String(root))
|
|
27
|
+
const relative = path.relative(base, resolved)
|
|
28
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class ToolPolicy {
|
|
33
|
+
constructor(config = {}) {
|
|
34
|
+
this.config = normalizeConfig(config)
|
|
35
|
+
/** @type {Map<string, {expiresAt: number, remaining: number}>} */
|
|
36
|
+
this.grants = new Map()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get roots() {
|
|
40
|
+
return this.config.allowedRoots
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
summarize() {
|
|
44
|
+
return {
|
|
45
|
+
allowedRoots: this.roots,
|
|
46
|
+
capabilities: { ...this.config.capabilities },
|
|
47
|
+
allowedTools: [...this.config.allowedTools],
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Decide one call: { decision: 'allow' | 'ask' | 'deny', reason, key } */
|
|
52
|
+
decide({ capability, tool = null, args = {} } = {}) {
|
|
53
|
+
const capabilityName = CAPABILITIES.includes(capability) ? capability : 'tool'
|
|
54
|
+
const mode = this.config.capabilities[capabilityName] ?? 'deny'
|
|
55
|
+
const grantKey = `${capabilityName}:${capabilityName === 'tool' ? String(tool) : '*'}`
|
|
56
|
+
if (this.consumeGrant(grantKey)) return { decision: 'allow', reason: 'approved grant', key: grantKey, capability: capabilityName }
|
|
57
|
+
|
|
58
|
+
const paths = collectPaths(args)
|
|
59
|
+
const needsRoots = ['read', 'list', 'grep', 'fetch'].includes(capabilityName)
|
|
60
|
+
if (needsRoots) {
|
|
61
|
+
if (this.roots.length === 0) {
|
|
62
|
+
return { decision: 'deny', reason: 'no allowed roots configured on this device', key: grantKey, capability: capabilityName }
|
|
63
|
+
}
|
|
64
|
+
for (const candidate of paths) {
|
|
65
|
+
if (!pathWithinRoots(this.roots, candidate)) {
|
|
66
|
+
return {
|
|
67
|
+
decision: 'deny',
|
|
68
|
+
reason: `path is outside the allowed roots: ${String(candidate)}`,
|
|
69
|
+
key: grantKey,
|
|
70
|
+
capability: capabilityName,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (capabilityName === 'run' && this.config.capabilities.run === 'allow') {
|
|
76
|
+
return { decision: 'allow', reason: 'run is allowed by policy', key: grantKey, capability: capabilityName }
|
|
77
|
+
}
|
|
78
|
+
if (capabilityName === 'tool') {
|
|
79
|
+
if (!this.config.allowedTools.includes(String(tool))) {
|
|
80
|
+
return {
|
|
81
|
+
decision: 'deny',
|
|
82
|
+
reason: `tool ${String(tool)} is not in the allowed tools list`,
|
|
83
|
+
key: grantKey,
|
|
84
|
+
capability: capabilityName,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { decision: 'allow', reason: 'tool is allowlisted', key: grantKey, capability: capabilityName }
|
|
88
|
+
}
|
|
89
|
+
if (mode === 'allow') return { decision: 'allow', reason: 'capability is allowed by policy', key: grantKey, capability: capabilityName }
|
|
90
|
+
if (mode === 'auto') return { decision: 'allow', reason: 'capability is auto-approved', key: grantKey, capability: capabilityName }
|
|
91
|
+
if (mode === 'ask') return { decision: 'ask', reason: 'capability requires local approval', key: grantKey, capability: capabilityName }
|
|
92
|
+
return { decision: 'deny', reason: 'capability is disabled by policy', key: grantKey, capability: capabilityName }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Remember an approval as a expiring grant (TTL scope) or allow one call (once scope). */
|
|
96
|
+
grant(key, { scope = 'once', ttlMs = this.config.approvalTtlMs, now = Date.now() } = {}) {
|
|
97
|
+
if (typeof key !== 'string' || key === '') return
|
|
98
|
+
const current = this.grants.get(key)
|
|
99
|
+
if (scope === 'once') {
|
|
100
|
+
this.grants.set(key, { expiresAt: now + ttlMs, remaining: (current?.remaining ?? 0) + 1 })
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
this.grants.set(key, { expiresAt: now + ttlMs, remaining: Infinity })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
consumeGrant(key) {
|
|
107
|
+
const grant = this.grants.get(key)
|
|
108
|
+
if (!grant) return false
|
|
109
|
+
if (grant.expiresAt <= Date.now()) {
|
|
110
|
+
this.grants.delete(key)
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
if (Number.isFinite(grant.remaining)) {
|
|
114
|
+
grant.remaining -= 1
|
|
115
|
+
if (grant.remaining <= 0) this.grants.delete(key)
|
|
116
|
+
}
|
|
117
|
+
return true
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
revokeAll() {
|
|
121
|
+
this.grants.clear()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizeConfig(config) {
|
|
126
|
+
const capabilities = { ...DEFAULT_POLICY.capabilities }
|
|
127
|
+
for (const [key, value] of Object.entries(config.capabilities ?? {})) {
|
|
128
|
+
if (CAPABILITIES.includes(key) && ['auto', 'ask', 'deny', 'allow'].includes(value)) capabilities[key] = value
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
allowedRoots: Array.isArray(config.allowedRoots) ? config.allowedRoots.map(String) : [],
|
|
132
|
+
capabilities,
|
|
133
|
+
allowedTools: Array.isArray(config.allowedTools) ? config.allowedTools.map(String) : [],
|
|
134
|
+
approvalTtlMs:
|
|
135
|
+
Number.isFinite(config.approvalTtlMs) && config.approvalTtlMs > 0
|
|
136
|
+
? config.approvalTtlMs
|
|
137
|
+
: DEFAULT_POLICY.approvalTtlMs,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function collectPaths(args) {
|
|
142
|
+
const paths = []
|
|
143
|
+
for (const key of ['path', 'file_path', 'cwd', 'dir', 'directory']) {
|
|
144
|
+
if (typeof args?.[key] === 'string' && args[key] !== '') paths.push(args[key])
|
|
145
|
+
}
|
|
146
|
+
if (Array.isArray(args?.paths)) {
|
|
147
|
+
for (const value of args.paths) if (typeof value === 'string' && value !== '') paths.push(value)
|
|
148
|
+
}
|
|
149
|
+
return paths
|
|
150
|
+
}
|