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.
Files changed (60) hide show
  1. package/CONTEXT.md +123 -0
  2. package/LICENSE +21 -0
  3. package/README.md +135 -2
  4. package/client.js +167 -0
  5. package/cordis.patch.yml +26 -0
  6. package/docs/adr/0001-fleet-enrollment-over-account-auto-discovery.md +17 -0
  7. package/docs/adr/0002-shared-room-as-coordination-unit.md +17 -0
  8. package/docs/adr/0003-direct-call-and-room-as-two-channels.md +22 -0
  9. package/docs/adr/0004-direct-call-policy-owned-by-target-device.md +23 -0
  10. package/docs/adr/0005-upgrade-session-to-room-host.md +22 -0
  11. package/docs/adr/0006-addressed-messages-and-any-device-speaks.md +16 -0
  12. package/docs/adr/0007-user-speech-decoupled-from-device.md +21 -0
  13. package/docs/adr/0008-extend-global-task-list-as-room-board.md +21 -0
  14. package/docs/adr/0009-room-policy-configured-at-creation.md +21 -0
  15. package/docs/adr/0010-room-policy-v1-fields-and-custom-room-types.md +24 -0
  16. package/docs/adr/0011-room-board-lifecycle-and-hooks.md +27 -0
  17. package/docs/adr/0012-lazy-wake-and-directed-speech.md +22 -0
  18. package/docs/adr/0013-direct-call-as-device-agnostic-functions.md +26 -0
  19. package/docs/adr/0014-fleet-call-unified-entry-with-typed-shortcuts.md +22 -0
  20. package/docs/adr/0015-room-plus-local-teams-and-delegation.md +22 -0
  21. package/docs/adr/0016-delegation-upgrade-reuses-session.md +20 -0
  22. package/docs/adr/0017-network-phases-direct-and-relay.md +21 -0
  23. package/docs/adr/0018-offline-fail-fast-and-durable-room-log.md +21 -0
  24. package/docs/adr/0019-execution-context-configured-locally.md +24 -0
  25. package/docs/adr/0020-approval-inbox-with-scoped-grants.md +22 -0
  26. package/docs/adr/0021-room-visibility-user-full-agents-scoped.md +26 -0
  27. package/docs/adr/0022-tool-adoption-mechanisms.md +23 -0
  28. package/docs/adr/0023-reuse-session-trajectory-for-call-details.md +22 -0
  29. package/docs/adr/0024-strict-version-match-with-reserved-capabilities.md +22 -0
  30. package/docs/notes/approval-inbox-phase.md +31 -0
  31. package/docs/notes/design-decisions-summary.md +49 -0
  32. package/docs/notes/dev-release.md +67 -0
  33. package/docs/notes/dsh-global-task-list-evaluation.md +54 -0
  34. package/docs/notes/network-v1-v2-implementation.md +71 -0
  35. package/docs/notes/pairing-session-2026-11-09.md +30 -0
  36. package/docs/notes/remote-tests-2026-11-09.md +33 -0
  37. package/docs/notes/room-logic-phase.md +31 -0
  38. package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
  39. package/package.json +51 -3
  40. package/src/host/approval-inbox.js +135 -0
  41. package/src/host/approval-web.js +95 -0
  42. package/src/host/delegation.js +127 -0
  43. package/src/host/direct-call.js +209 -0
  44. package/src/host/execution-slot.js +96 -0
  45. package/src/host/index.js +10 -0
  46. package/src/host/room-service.js +324 -0
  47. package/src/host/store.js +156 -0
  48. package/src/host/tool-policy.js +151 -0
  49. package/src/host/tools.js +590 -0
  50. package/src/index.js +3 -0
  51. package/src/net/direct.js +69 -0
  52. package/src/net/discovery.js +105 -0
  53. package/src/net/fleet-node.js +509 -0
  54. package/src/net/identity.js +167 -0
  55. package/src/net/index.js +9 -0
  56. package/src/net/protocol.js +73 -0
  57. package/src/net/relay-client.js +258 -0
  58. package/src/net/relay-server.js +366 -0
  59. package/src/net/secure-channel.js +538 -0
  60. 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
+ }