dsh-device-fleet 0.0.0-stage → 0.1.0-dev.10
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 +135 -2
- package/client.js +167 -0
- 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/approval-inbox-phase.md +31 -0
- package/docs/notes/design-decisions-summary.md +49 -0
- package/docs/notes/dev-release.md +67 -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/pairing-session-2026-11-09.md +30 -0
- package/docs/notes/remote-tests-2026-11-09.md +33 -0
- package/docs/notes/room-logic-phase.md +31 -0
- package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
- package/package.json +51 -3
- package/src/host/approval-inbox.js +135 -0
- package/src/host/approval-web.js +95 -0
- package/src/host/delegation.js +127 -0
- package/src/host/direct-call.js +209 -0
- package/src/host/execution-slot.js +96 -0
- package/src/host/index.js +10 -0
- package/src/host/room-service.js +324 -0
- package/src/host/store.js +156 -0
- package/src/host/tool-policy.js +151 -0
- package/src/host/tools.js +590 -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 +509 -0
- package/src/net/identity.js +167 -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 +380 -0
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
const peerHints = readJson(path.join(dir, 'peer-hints.json'), [])
|
|
54
|
+
let enrollment = readJson(path.join(dir, 'enrollment.json'), null)
|
|
55
|
+
let pendingEnrollment = readJson(path.join(dir, 'enrollment.pending.json'), null)
|
|
56
|
+
|
|
57
|
+
// A pending invite written by `fleet_join` takes effect on the next load:
|
|
58
|
+
// enrolling while the network node is already running would leave the two
|
|
59
|
+
// sides on different fleets.
|
|
60
|
+
if (pendingEnrollment?.code) {
|
|
61
|
+
try {
|
|
62
|
+
const decoded = decodeInvite(pendingEnrollment.code)
|
|
63
|
+
fleet = decoded.fleet
|
|
64
|
+
enrollment = {
|
|
65
|
+
joinedAt: Date.now(),
|
|
66
|
+
accountFingerprint: decoded.accountFingerprint ?? null,
|
|
67
|
+
relay: decoded.relay ?? null,
|
|
68
|
+
endpoints: decoded.endpoints ?? [],
|
|
69
|
+
}
|
|
70
|
+
writeJsonAtomic(path.join(dir, 'fleet.json'), fleet)
|
|
71
|
+
writeJsonAtomic(path.join(dir, 'enrollment.json'), enrollment)
|
|
72
|
+
fs.unlinkSync(path.join(dir, 'enrollment.pending.json'))
|
|
73
|
+
pendingEnrollment = null
|
|
74
|
+
} catch {
|
|
75
|
+
// Keep the pending file; the user can fix the invite and restart again.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
dataDir: dir,
|
|
81
|
+
identity,
|
|
82
|
+
fleet,
|
|
83
|
+
roster: Array.isArray(roster) ? roster : [],
|
|
84
|
+
config,
|
|
85
|
+
enrollment,
|
|
86
|
+
pendingEnrollment,
|
|
87
|
+
peerHints: Array.isArray(peerHints) ? peerHints : [],
|
|
88
|
+
createdFleet,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Persist one manually supplied peer address (from fleet_connect) for reconnects. */
|
|
93
|
+
export function savePeerHint(state, hint) {
|
|
94
|
+
if (!hint || typeof hint.host !== 'string' || !Number.isInteger(hint.port) || hint.port <= 0) return
|
|
95
|
+
const entry = {
|
|
96
|
+
host: hint.host,
|
|
97
|
+
port: hint.port,
|
|
98
|
+
...(typeof hint.deviceId === 'string' ? { deviceId: hint.deviceId } : {}),
|
|
99
|
+
}
|
|
100
|
+
const hints = Array.isArray(state.peerHints) ? state.peerHints : []
|
|
101
|
+
const exists = hints.some((item) => item.host === entry.host && item.port === entry.port && item.deviceId === entry.deviceId)
|
|
102
|
+
if (!exists) hints.push(entry)
|
|
103
|
+
state.peerHints = hints
|
|
104
|
+
writeJsonAtomic(path.join(state.dataDir, 'peer-hints.json'), hints)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Queue an invite for the next plugin load (the running node keeps its fleet). */
|
|
108
|
+
export function writePendingEnrollment(state, code) {
|
|
109
|
+
decodeInvite(code) // validate before writing
|
|
110
|
+
writeJsonAtomic(path.join(state.dataDir, 'enrollment.pending.json'), { code, queuedAt: Date.now() })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function saveIdentity(state) {
|
|
114
|
+
writeJsonAtomic(path.join(state.dataDir, 'identity.json'), state.identity)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function saveFleet(state) {
|
|
118
|
+
writeJsonAtomic(path.join(state.dataDir, 'fleet.json'), state.fleet)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function saveRoster(state, roster) {
|
|
122
|
+
state.roster = roster
|
|
123
|
+
writeJsonAtomic(path.join(state.dataDir, 'roster.json'), roster)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function saveConfig(state, config) {
|
|
127
|
+
state.config = config
|
|
128
|
+
writeJsonAtomic(path.join(state.dataDir, 'config.json'), config)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Create an invite for this device's fleet. */
|
|
132
|
+
export function createInvite(state, { accountFingerprint = null, relay = null, endpoints = [] } = {}) {
|
|
133
|
+
return encodeInvite({ fleet: state.fleet, accountFingerprint, relay, endpoints })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Join a fleet from an invite code (overwrites fleet/enrollment, keeps identity). */
|
|
137
|
+
export function enrollFromInvite(state, code) {
|
|
138
|
+
const decoded = decodeInvite(code)
|
|
139
|
+
state.fleet = decoded.fleet
|
|
140
|
+
state.enrollment = {
|
|
141
|
+
joinedAt: Date.now(),
|
|
142
|
+
accountFingerprint: decoded.accountFingerprint ?? null,
|
|
143
|
+
relay: decoded.relay ?? null,
|
|
144
|
+
endpoints: decoded.endpoints ?? [],
|
|
145
|
+
}
|
|
146
|
+
saveFleet(state)
|
|
147
|
+
writeJsonAtomic(path.join(state.dataDir, 'enrollment.json'), state.enrollment)
|
|
148
|
+
return state.enrollment
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Check a locally observed account fingerprint against the invite's binding. */
|
|
152
|
+
export function accountMatches(state, fingerprint) {
|
|
153
|
+
const expected = state.enrollment?.accountFingerprint ?? null
|
|
154
|
+
if (!expected) return true // unbound invite: accept the first account and caller may bind
|
|
155
|
+
return fingerprint !== null && fingerprint === expected
|
|
156
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
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', 'delegate']
|
|
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
|
+
delegate: 'ask',
|
|
18
|
+
},
|
|
19
|
+
allowedTools: [],
|
|
20
|
+
approvalTtlMs: 15 * 60 * 1000,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
/** True when `candidate` resolves inside one of `roots`. */
|
|
24
|
+
export function pathWithinRoots(roots, candidate) {
|
|
25
|
+
const resolved = path.resolve(String(candidate))
|
|
26
|
+
return roots.some((root) => {
|
|
27
|
+
const base = path.resolve(String(root))
|
|
28
|
+
const relative = path.relative(base, resolved)
|
|
29
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class ToolPolicy {
|
|
34
|
+
constructor(config = {}) {
|
|
35
|
+
this.config = normalizeConfig(config)
|
|
36
|
+
/** @type {Map<string, {expiresAt: number, remaining: number}>} */
|
|
37
|
+
this.grants = new Map()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get roots() {
|
|
41
|
+
return this.config.allowedRoots
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
summarize() {
|
|
45
|
+
return {
|
|
46
|
+
allowedRoots: this.roots,
|
|
47
|
+
capabilities: { ...this.config.capabilities },
|
|
48
|
+
allowedTools: [...this.config.allowedTools],
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Decide one call: { decision: 'allow' | 'ask' | 'deny', reason, key } */
|
|
53
|
+
decide({ capability, tool = null, args = {} } = {}) {
|
|
54
|
+
const capabilityName = CAPABILITIES.includes(capability) ? capability : 'tool'
|
|
55
|
+
const mode = this.config.capabilities[capabilityName] ?? 'deny'
|
|
56
|
+
const grantKey = `${capabilityName}:${capabilityName === 'tool' ? String(tool) : '*'}`
|
|
57
|
+
if (this.consumeGrant(grantKey)) return { decision: 'allow', reason: 'approved grant', key: grantKey, capability: capabilityName }
|
|
58
|
+
|
|
59
|
+
const paths = collectPaths(args)
|
|
60
|
+
const needsRoots = ['read', 'list', 'grep', 'fetch'].includes(capabilityName)
|
|
61
|
+
if (needsRoots) {
|
|
62
|
+
if (this.roots.length === 0) {
|
|
63
|
+
return { decision: 'deny', reason: 'no allowed roots configured on this device', key: grantKey, capability: capabilityName }
|
|
64
|
+
}
|
|
65
|
+
for (const candidate of paths) {
|
|
66
|
+
if (!pathWithinRoots(this.roots, candidate)) {
|
|
67
|
+
return {
|
|
68
|
+
decision: 'deny',
|
|
69
|
+
reason: `path is outside the allowed roots: ${String(candidate)}`,
|
|
70
|
+
key: grantKey,
|
|
71
|
+
capability: capabilityName,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (capabilityName === 'run' && this.config.capabilities.run === 'allow') {
|
|
77
|
+
return { decision: 'allow', reason: 'run is allowed by policy', key: grantKey, capability: capabilityName }
|
|
78
|
+
}
|
|
79
|
+
if (capabilityName === 'tool') {
|
|
80
|
+
if (!this.config.allowedTools.includes(String(tool))) {
|
|
81
|
+
return {
|
|
82
|
+
decision: 'deny',
|
|
83
|
+
reason: `tool ${String(tool)} is not in the allowed tools list`,
|
|
84
|
+
key: grantKey,
|
|
85
|
+
capability: capabilityName,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { decision: 'allow', reason: 'tool is allowlisted', key: grantKey, capability: capabilityName }
|
|
89
|
+
}
|
|
90
|
+
if (mode === 'allow') return { decision: 'allow', reason: 'capability is allowed by policy', key: grantKey, capability: capabilityName }
|
|
91
|
+
if (mode === 'auto') return { decision: 'allow', reason: 'capability is auto-approved', key: grantKey, capability: capabilityName }
|
|
92
|
+
if (mode === 'ask') return { decision: 'ask', reason: 'capability requires local approval', key: grantKey, capability: capabilityName }
|
|
93
|
+
return { decision: 'deny', reason: 'capability is disabled by policy', key: grantKey, capability: capabilityName }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Remember an approval as a expiring grant (TTL scope) or allow one call (once scope). */
|
|
97
|
+
grant(key, { scope = 'once', ttlMs = this.config.approvalTtlMs, now = Date.now() } = {}) {
|
|
98
|
+
if (typeof key !== 'string' || key === '') return
|
|
99
|
+
const current = this.grants.get(key)
|
|
100
|
+
if (scope === 'once') {
|
|
101
|
+
this.grants.set(key, { expiresAt: now + ttlMs, remaining: (current?.remaining ?? 0) + 1 })
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
this.grants.set(key, { expiresAt: now + ttlMs, remaining: Infinity })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
consumeGrant(key) {
|
|
108
|
+
const grant = this.grants.get(key)
|
|
109
|
+
if (!grant) return false
|
|
110
|
+
if (grant.expiresAt <= Date.now()) {
|
|
111
|
+
this.grants.delete(key)
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
if (Number.isFinite(grant.remaining)) {
|
|
115
|
+
grant.remaining -= 1
|
|
116
|
+
if (grant.remaining <= 0) this.grants.delete(key)
|
|
117
|
+
}
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
revokeAll() {
|
|
122
|
+
this.grants.clear()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeConfig(config) {
|
|
127
|
+
const capabilities = { ...DEFAULT_POLICY.capabilities }
|
|
128
|
+
for (const [key, value] of Object.entries(config.capabilities ?? {})) {
|
|
129
|
+
if (CAPABILITIES.includes(key) && ['auto', 'ask', 'deny', 'allow'].includes(value)) capabilities[key] = value
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
allowedRoots: Array.isArray(config.allowedRoots) ? config.allowedRoots.map(String) : [],
|
|
133
|
+
capabilities,
|
|
134
|
+
allowedTools: Array.isArray(config.allowedTools) ? config.allowedTools.map(String) : [],
|
|
135
|
+
approvalTtlMs:
|
|
136
|
+
Number.isFinite(config.approvalTtlMs) && config.approvalTtlMs > 0
|
|
137
|
+
? config.approvalTtlMs
|
|
138
|
+
: DEFAULT_POLICY.approvalTtlMs,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function collectPaths(args) {
|
|
143
|
+
const paths = []
|
|
144
|
+
for (const key of ['path', 'file_path', 'cwd', 'dir', 'directory']) {
|
|
145
|
+
if (typeof args?.[key] === 'string' && args[key] !== '') paths.push(args[key])
|
|
146
|
+
}
|
|
147
|
+
if (Array.isArray(args?.paths)) {
|
|
148
|
+
for (const value of args.paths) if (typeof value === 'string' && value !== '') paths.push(value)
|
|
149
|
+
}
|
|
150
|
+
return paths
|
|
151
|
+
}
|