@livedesk/hub 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -1,15 +1,15 @@
1
- # @livedesk/hub
2
-
3
- Local LiveDesk Hub runtime.
4
-
5
- This package is used by the `livedesk` launcher package. Most users should run:
6
-
7
- ```powershell
8
- npx -y livedesk
9
- ```
10
-
11
- Mode 4 runs its Atlas compositor in a separate worker process. It consumes
12
- latest-only Mode 2 frames and exposes one H.264 wall stream at
13
- `/api/remote/atlas/ws`. The grid uses native 320x180 cells until the configured
14
- 1920x1080 bound requires smaller cells; focused control continues to use direct
15
- Mode 3.
1
+ # @livedesk/hub
2
+
3
+ Local LiveDesk Hub runtime.
4
+
5
+ This package is used by the `livedesk` launcher package. Most users should run:
6
+
7
+ ```powershell
8
+ npx -y --prefer-online livedesk@latest
9
+ ```
10
+
11
+ Mode 4 runs its Atlas compositor in a separate worker process. It consumes
12
+ latest-only Mode 2 frames and exposes one H.264 wall stream at
13
+ `/api/remote/atlas/ws`. The grid uses native 320x180 cells until the configured
14
+ 1920x1080 bound requires smaller cells; focused control continues to use direct
15
+ Mode 3.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "LiveDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -15,6 +15,7 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@ffmpeg-installer/ffmpeg": "^1.1.0",
18
+ "@openai/codex-sdk": "0.145.0",
18
19
  "cors": "^2.8.5",
19
20
  "express": "^4.21.2",
20
21
  "ffmpeg-static": "^5.3.0",
@@ -0,0 +1,93 @@
1
+ import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import crypto from 'node:crypto';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ const MAX_AUDIT_RECORDS = 2000;
7
+ const MAX_AUDIT_FILE_RECORDS = 2250;
8
+ const MAX_AUDIT_FIELD = 4000;
9
+
10
+ function safeText(value, max = MAX_AUDIT_FIELD) {
11
+ return String(value ?? '').replace(/[\0\r\n]/g, ' ').trim().slice(0, max);
12
+ }
13
+
14
+ function redact(value, depth = 0) {
15
+ if (depth > 4) return '[truncated]';
16
+ if (Array.isArray(value)) return value.slice(0, 100).map(item => redact(item, depth + 1));
17
+ if (!value || typeof value !== 'object') return typeof value === 'string' ? safeText(value) : value;
18
+ return Object.fromEntries(Object.entries(value).slice(0, 100).map(([key, child]) => [
19
+ key,
20
+ /content|command|script|token|secret|password|credential|api[-_]?key|authorization/i.test(key) ? '[redacted]' : redact(child, depth + 1)
21
+ ]));
22
+ }
23
+
24
+ export function createAgentAuditStore({ dataDir = path.join(os.homedir(), '.livedesk') } = {}) {
25
+ const filePath = path.join(dataDir, 'agent-audit.jsonl');
26
+ let records = null;
27
+ let rewriteNeeded = false;
28
+ let fileRecordCount = 0;
29
+ let writeQueue = Promise.resolve();
30
+
31
+ async function load() {
32
+ if (records) return records;
33
+ try {
34
+ const allLines = (await readFile(filePath, 'utf8')).split(/\r?\n/).filter(Boolean);
35
+ fileRecordCount = allLines.length;
36
+ rewriteNeeded = allLines.length > MAX_AUDIT_FILE_RECORDS;
37
+ records = allLines.slice(-MAX_AUDIT_RECORDS).map(line => JSON.parse(line)).filter(item => item && typeof item === 'object');
38
+ } catch {
39
+ records = [];
40
+ }
41
+ return records;
42
+ }
43
+
44
+ function normalize(event = {}) {
45
+ return {
46
+ auditId: safeText(event.auditId || crypto.randomUUID(), 100),
47
+ timestamp: new Date().toISOString(),
48
+ runId: safeText(event.runId, 100),
49
+ deviceIds: Array.isArray(event.deviceIds) ? [...new Set(event.deviceIds.map(item => safeText(item, 128)).filter(Boolean))].slice(0, 500) : [],
50
+ event: safeText(event.event, 100),
51
+ toolName: safeText(event.toolName, 120),
52
+ category: safeText(event.category, 80),
53
+ decision: safeText(event.decision, 40),
54
+ status: safeText(event.status, 40),
55
+ permissionMode: safeText(event.permissionMode, 40),
56
+ policyHash: safeText(event.policyHash, 100),
57
+ argumentsHash: safeText(event.argumentsHash, 100),
58
+ details: redact(event.details || {})
59
+ };
60
+ }
61
+
62
+ return {
63
+ async record(event) {
64
+ const record = normalize(event);
65
+ writeQueue = writeQueue.then(async () => {
66
+ const current = await load();
67
+ current.push(record);
68
+ fileRecordCount += 1;
69
+ if (current.length > MAX_AUDIT_RECORDS) {
70
+ current.splice(0, current.length - MAX_AUDIT_RECORDS);
71
+ }
72
+ if (fileRecordCount > MAX_AUDIT_FILE_RECORDS) rewriteNeeded = true;
73
+ await mkdir(path.dirname(filePath), { recursive: true });
74
+ if (rewriteNeeded) {
75
+ const tempPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
76
+ await writeFile(tempPath, `${current.map(item => JSON.stringify(item)).join('\n')}\n`, { encoding: 'utf8', mode: 0o600 });
77
+ await rename(tempPath, filePath);
78
+ fileRecordCount = current.length;
79
+ rewriteNeeded = false;
80
+ } else {
81
+ await appendFile(filePath, `${JSON.stringify(record)}\n`, { encoding: 'utf8', mode: 0o600 });
82
+ }
83
+ });
84
+ await writeQueue;
85
+ return record;
86
+ },
87
+ async list({ runId = '', limit = 200 } = {}) {
88
+ const current = await load();
89
+ const normalizedLimit = Math.max(1, Math.min(500, Number(limit) || 200));
90
+ return current.filter(item => !runId || item.runId === String(runId)).slice(-normalizedLimit).reverse();
91
+ }
92
+ };
93
+ }
@@ -0,0 +1,29 @@
1
+ function normalizeIds(value) {
2
+ const source = Array.isArray(value) ? value : [];
3
+ return [...new Set(source.map(item => String(item || '').trim()).filter(Boolean))].slice(0, 500);
4
+ }
5
+
6
+ export function createAgentDeviceScope(allowedDeviceIds, fallbackDeviceIds = []) {
7
+ const requested = normalizeIds(allowedDeviceIds);
8
+ const fallback = normalizeIds(fallbackDeviceIds);
9
+ return new Set(requested.length > 0 ? requested : fallback);
10
+ }
11
+
12
+ export function selectAgentDeviceIds({ allowedDeviceIds, requestedDeviceIds, connectedDeviceIds }) {
13
+ const allowed = allowedDeviceIds instanceof Set ? allowedDeviceIds : new Set(normalizeIds(allowedDeviceIds));
14
+ const requested = normalizeIds(requestedDeviceIds);
15
+ const connected = new Set(normalizeIds(connectedDeviceIds));
16
+ const candidates = requested.length > 0 ? requested : [...allowed];
17
+ return candidates.filter(deviceId => allowed.has(deviceId) && connected.has(deviceId));
18
+ }
19
+
20
+ export function resolveAgentTargetIds({ allowedDeviceIds, requestedDeviceIds, connectedDeviceIds }) {
21
+ const requested = normalizeIds(requestedDeviceIds);
22
+ const allowed = allowedDeviceIds instanceof Set ? allowedDeviceIds : new Set(normalizeIds(allowedDeviceIds));
23
+ const deviceIds = selectAgentDeviceIds({ allowedDeviceIds: allowed, requestedDeviceIds: requested, connectedDeviceIds });
24
+ if (deviceIds.length > 0) return { deviceIds, error: '' };
25
+ if (requested.length > 0 && requested.every(deviceId => !allowed.has(deviceId))) {
26
+ return { deviceIds: [], error: 'agent-target-outside-selection' };
27
+ }
28
+ return { deviceIds: [], error: 'agent-no-target-devices' };
29
+ }
@@ -0,0 +1,175 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { AgentSettingsStore, AGENT_PROVIDER_CODEX, AGENT_PROVIDER_OPENCODE_GO } from './agent-settings.js';
4
+ import { createOsSecretStore } from './secret-store.js';
5
+ import { createOpenCodeGoProvider } from './opencode-go-provider.js';
6
+ import { AgentProviderError } from './provider-errors.js';
7
+ import { createCodexAgentRuntime } from './codex-agent-runtime.js';
8
+
9
+ function maskApiKey(value) {
10
+ const key = String(value || '');
11
+ return key.length >= 4 ? `••••${key.slice(-4)}` : '';
12
+ }
13
+
14
+ function publicCodexStatus(status = {}) {
15
+ return {
16
+ installed: status.installed === true,
17
+ authenticated: ['signed-in', 'not-signed-in'].includes(status.authenticated) ? status.authenticated : 'unknown',
18
+ status: String(status.status || 'unknown').slice(0, 40),
19
+ codexPath: String(status.codexPath || '').slice(0, 500),
20
+ detail: String(status.detail || '').slice(0, 300)
21
+ };
22
+ }
23
+
24
+ export function createAgentManager({
25
+ dataDir = path.join(os.homedir(), '.livedesk'),
26
+ settingsStore,
27
+ secretStore,
28
+ provider,
29
+ codexRuntime
30
+ } = {}) {
31
+ const settings = settingsStore || new AgentSettingsStore({ dataDir });
32
+ const secrets = secretStore || createOsSecretStore({ dataDir });
33
+ const legacyProvider = provider || createOpenCodeGoProvider();
34
+ const runtime = codexRuntime;
35
+ let statusCache = { expiresAt: 0, value: null };
36
+
37
+ async function getCodexStatus() {
38
+ if (!runtime) return { installed: false, authenticated: 'unknown', status: 'unavailable', codexPath: '' };
39
+ if (statusCache.value && statusCache.expiresAt > Date.now()) return statusCache.value;
40
+ let value;
41
+ try {
42
+ value = publicCodexStatus(await runtime.getStatus());
43
+ } catch (error) {
44
+ value = publicCodexStatus({ installed: true, authenticated: 'unknown', status: error?.code || 'security-unavailable', detail: error?.message || 'Codex security isolation is unavailable.' });
45
+ }
46
+ statusCache = { value, expiresAt: Date.now() + 10000 };
47
+ return value;
48
+ }
49
+
50
+ async function publicSettings() {
51
+ const current = await settings.get();
52
+ const { codexMaxTurns: _codexMaxTurns, ...exposedSettings } = current;
53
+ let hasApiKey = false;
54
+ let apiKeyPreview = '';
55
+ let secretStoreAvailable = true;
56
+ if (current.provider === AGENT_PROVIDER_OPENCODE_GO) {
57
+ try {
58
+ const key = await secrets.getApiKey(AGENT_PROVIDER_OPENCODE_GO);
59
+ hasApiKey = Boolean(key);
60
+ apiKeyPreview = maskApiKey(key);
61
+ } catch {
62
+ secretStoreAvailable = false;
63
+ }
64
+ }
65
+ const codex = await getCodexStatus();
66
+ const securityStatus = runtime?.getSecurityStatus?.() || {
67
+ isolatedCodexHome: false,
68
+ restrictedEnvironment: false,
69
+ livedeskMcpOnly: false,
70
+ selectedDeviceScopeEnforced: false,
71
+ failClosed: true,
72
+ verified: false,
73
+ state: 'unavailable'
74
+ };
75
+ return {
76
+ ...exposedSettings,
77
+ hasApiKey,
78
+ apiKeyPreview,
79
+ secretStoreAvailable,
80
+ codexInstallation: codex.installed ? 'installed' : 'not-installed',
81
+ codexAuth: codex.authenticated,
82
+ codexStatus: codex.status,
83
+ codexPath: codex.codexPath,
84
+ agentWorkspacePath: current.codexWorkingDirectory,
85
+ securityStatus
86
+ };
87
+ }
88
+
89
+ async function legacyApiKey() {
90
+ const key = await secrets.getApiKey(AGENT_PROVIDER_OPENCODE_GO);
91
+ if (!key) throw new AgentProviderError('agent-api-key-missing', 'OpenCode Go API key is not configured.', { status: 401 });
92
+ return key;
93
+ }
94
+
95
+ async function currentProvider() {
96
+ const current = await settings.get();
97
+ return { current, active: current.provider === AGENT_PROVIDER_CODEX ? runtime : legacyProvider };
98
+ }
99
+
100
+ return {
101
+ getSettings: publicSettings,
102
+ async updateSettings(patch = {}) {
103
+ const nextPatch = { ...patch };
104
+ const apiKey = typeof nextPatch.apiKey === 'string' ? nextPatch.apiKey.trim() : '';
105
+ delete nextPatch.apiKey;
106
+ const current = await settings.get();
107
+ const requestedProvider = String(nextPatch.provider || current.provider);
108
+ if (apiKey) {
109
+ if (requestedProvider !== AGENT_PROVIDER_OPENCODE_GO) {
110
+ throw new AgentProviderError('agent-codex-does-not-use-api-key', 'Codex authentication is owned by the installed Codex CLI.', { status: 400 });
111
+ }
112
+ await secrets.setApiKey(AGENT_PROVIDER_OPENCODE_GO, apiKey);
113
+ }
114
+ await settings.update(nextPatch);
115
+ statusCache = { expiresAt: 0, value: null };
116
+ return publicSettings();
117
+ },
118
+ async resetSettings() {
119
+ await settings.reset();
120
+ statusCache = { expiresAt: 0, value: null };
121
+ return publicSettings();
122
+ },
123
+ async deleteApiKey() {
124
+ await secrets.deleteApiKey(AGENT_PROVIDER_OPENCODE_GO);
125
+ return publicSettings();
126
+ },
127
+ async getModels() {
128
+ const { current, active } = await currentProvider();
129
+ if (current.provider === AGENT_PROVIDER_CODEX) {
130
+ return [{ id: '', name: 'Codex default', protocol: 'codex-sdk', available: true, capabilities: { supportsReasoningLevel: true, supportsStreaming: true, supportsTools: true } }];
131
+ }
132
+ return active.getModels(current, await legacyApiKey());
133
+ },
134
+ async testConnection() {
135
+ const { current, active } = await currentProvider();
136
+ if (current.provider === AGENT_PROVIDER_CODEX) {
137
+ if (!active) throw new AgentProviderError('codex-sdk-not-installed', 'Codex SDK is not installed.', { status: 503 });
138
+ statusCache = { expiresAt: 0, value: null };
139
+ return active.testConnection();
140
+ }
141
+ return active.testConnection(current, await legacyApiKey());
142
+ },
143
+ async createPlan(input) {
144
+ const { current, active } = await currentProvider();
145
+ if (!current.enabled) throw new AgentProviderError('agent-ai-disabled', 'Agent AI is disabled in Settings.', { status: 409 });
146
+ if (current.provider === AGENT_PROVIDER_CODEX) {
147
+ throw new AgentProviderError('agent-codex-run-required', 'Codex runs use the LiveDesk tool loop.', { status: 409 });
148
+ }
149
+ return active.createPlan(input, current, await legacyApiKey());
150
+ },
151
+ async createSummary(input) {
152
+ const { current, active } = await currentProvider();
153
+ if (!current.enabled || !current.generateSummary) throw new AgentProviderError('agent-summary-disabled', 'Agent summaries are disabled in Settings.', { status: 409 });
154
+ if (current.provider === AGENT_PROVIDER_CODEX) {
155
+ const source = input && typeof input === 'object' ? input : {};
156
+ const results = Array.isArray(source.results) ? source.results : [];
157
+ return { summary: String(results.length ? `${source.completed || 0} completed, ${source.failed || 0} failed.` : 'LiveDesk Agent run completed.').slice(0, 1200), modelId: current.codexModelId || 'Codex default' };
158
+ }
159
+ const source = input && typeof input === 'object' ? input : {};
160
+ const results = Array.isArray(source.results) ? source.results : [];
161
+ const safeResults = current.includeRawDeviceDetails
162
+ ? results.map(result => ({ deviceName: String(result?.deviceName || '').slice(0, 120), status: String(result?.status || '').slice(0, 40), result: String(result?.result || '').slice(0, 1200), error: String(result?.error || '').slice(0, 500) }))
163
+ : results.map(result => ({ deviceName: String(result?.deviceName || '').slice(0, 120), status: String(result?.status || '').slice(0, 40), hasResult: Boolean(result?.result), hasError: Boolean(result?.error) }));
164
+ return active.createSummary({ instruction: String(source.instruction || '').slice(0, 500), operation: String(source.operation || '').slice(0, 80), summary: { total: Number(source.total || results.length), completed: Number(source.completed || 0), failed: Number(source.failed || 0), results: safeResults } }, current, await legacyApiKey());
165
+ },
166
+ async startRun(input) {
167
+ const current = await settings.get();
168
+ if (current.provider !== AGENT_PROVIDER_CODEX || !runtime) throw new AgentProviderError('agent-codex-not-active', 'Codex SDK is not the active Agent provider.', { status: 409 });
169
+ return runtime.start(input);
170
+ },
171
+ getRun(runId) { return runtime?.get(runId) || null; },
172
+ cancelRun(runId) { return runtime?.cancel(runId) || null; },
173
+ cancelAllRuns() { return runtime?.cancelAll?.() || 0; }
174
+ };
175
+ }
@@ -0,0 +1,78 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { DEFAULT_CUSTOM_AGENT_PERMISSION_CATEGORIES, normalizeAgentPermissionCategories } from './agent-permissions.js';
6
+
7
+ function safePolicyId(value) {
8
+ const id = String(value || '').trim().replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 80);
9
+ return id || 'custom-default';
10
+ }
11
+
12
+ function publicPolicy(policy) {
13
+ return {
14
+ id: policy.id,
15
+ name: policy.name,
16
+ categories: { ...policy.categories },
17
+ createdAt: policy.createdAt,
18
+ updatedAt: policy.updatedAt
19
+ };
20
+ }
21
+
22
+ export function createAgentPermissionStore({ dataDir = path.join(os.homedir(), '.livedesk') } = {}) {
23
+ const filePath = path.join(dataDir, 'agent-permission-policies.json');
24
+ let policies;
25
+
26
+ async function load() {
27
+ if (policies) return policies;
28
+ try {
29
+ const parsed = JSON.parse(await readFile(filePath, 'utf8'));
30
+ policies = new Map(Object.entries(parsed && typeof parsed === 'object' ? parsed : {}).map(([id, value]) => [safePolicyId(id), {
31
+ id: safePolicyId(id),
32
+ name: String(value?.name || id).slice(0, 120),
33
+ categories: normalizeAgentPermissionCategories(value?.categories, DEFAULT_CUSTOM_AGENT_PERMISSION_CATEGORIES),
34
+ createdAt: String(value?.createdAt || new Date().toISOString()),
35
+ updatedAt: String(value?.updatedAt || new Date().toISOString())
36
+ }]));
37
+ } catch {
38
+ policies = new Map();
39
+ }
40
+ if (!policies.has('custom-default')) {
41
+ const now = new Date().toISOString();
42
+ policies.set('custom-default', { id: 'custom-default', name: 'Custom default', categories: { ...DEFAULT_CUSTOM_AGENT_PERMISSION_CATEGORIES }, createdAt: now, updatedAt: now });
43
+ }
44
+ return policies;
45
+ }
46
+
47
+ async function save() {
48
+ const current = await load();
49
+ const value = Object.fromEntries([...current].map(([id, policy]) => [id, policy]));
50
+ await mkdir(path.dirname(filePath), { recursive: true });
51
+ const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
52
+ await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
53
+ await rename(temporaryPath, filePath);
54
+ }
55
+
56
+ return {
57
+ async list() { return [...await load()].map(([, policy]) => publicPolicy(policy)); },
58
+ async get(id = 'custom-default') {
59
+ const current = await load();
60
+ return publicPolicy(current.get(safePolicyId(id)) || current.get('custom-default'));
61
+ },
62
+ async upsert({ id = 'custom-default', name = 'Custom default', categories = {} } = {}) {
63
+ const current = await load();
64
+ const policyId = safePolicyId(id);
65
+ const previous = current.get(policyId);
66
+ const now = new Date().toISOString();
67
+ current.set(policyId, {
68
+ id: policyId,
69
+ name: String(name || policyId).slice(0, 120),
70
+ categories: normalizeAgentPermissionCategories(categories, previous?.categories || DEFAULT_CUSTOM_AGENT_PERMISSION_CATEGORIES),
71
+ createdAt: previous?.createdAt || now,
72
+ updatedAt: now
73
+ });
74
+ await save();
75
+ return publicPolicy(current.get(policyId));
76
+ }
77
+ };
78
+ }
@@ -0,0 +1,209 @@
1
+ import crypto from 'node:crypto';
2
+ import { getAgentToolDefinition } from './agent-tool-registry.js';
3
+
4
+ export const AGENT_PERMISSION_MODES = Object.freeze(['ask', 'safe-auto', 'full-access', 'custom']);
5
+ export const AGENT_PERMISSION_DECISIONS = Object.freeze(['allow', 'ask', 'deny']);
6
+ export const AGENT_PERMISSION_CATEGORIES = Object.freeze([
7
+ 'read',
8
+ 'processControl',
9
+ 'serviceControl',
10
+ 'applicationControl',
11
+ 'fileRead',
12
+ 'fileWrite',
13
+ 'fileDelete',
14
+ 'shell',
15
+ 'script',
16
+ 'softwareInstall',
17
+ 'network',
18
+ 'systemPower',
19
+ 'systemConfiguration',
20
+ 'userAccount'
21
+ ]);
22
+
23
+ const DEFAULT_CUSTOM_CATEGORIES = Object.freeze({
24
+ read: 'allow',
25
+ processControl: 'ask',
26
+ serviceControl: 'ask',
27
+ applicationControl: 'ask',
28
+ fileRead: 'ask',
29
+ fileWrite: 'ask',
30
+ fileDelete: 'ask',
31
+ shell: 'ask',
32
+ script: 'ask',
33
+ softwareInstall: 'ask',
34
+ network: 'ask',
35
+ systemPower: 'ask',
36
+ systemConfiguration: 'ask',
37
+ userAccount: 'deny'
38
+ });
39
+
40
+ const PRESET_CATEGORIES = Object.freeze({
41
+ ask: {
42
+ ...DEFAULT_CUSTOM_CATEGORIES,
43
+ userAccount: 'ask'
44
+ },
45
+ 'safe-auto': {
46
+ read: 'allow',
47
+ processControl: 'allow',
48
+ serviceControl: 'allow',
49
+ // launch_application accepts an executable path, so allowing it would
50
+ // provide a shell-equivalent escape hatch (for example powershell.exe).
51
+ applicationControl: 'ask',
52
+ fileRead: 'allow',
53
+ fileWrite: 'allow',
54
+ fileDelete: 'ask',
55
+ shell: 'ask',
56
+ // A writable script directory must never become an auto-execution path.
57
+ script: 'ask',
58
+ softwareInstall: 'ask',
59
+ network: 'allow',
60
+ systemPower: 'ask',
61
+ systemConfiguration: 'ask',
62
+ userAccount: 'deny'
63
+ },
64
+ 'full-access': {
65
+ read: 'allow',
66
+ processControl: 'allow',
67
+ serviceControl: 'allow',
68
+ applicationControl: 'allow',
69
+ fileRead: 'allow',
70
+ fileWrite: 'allow',
71
+ fileDelete: 'allow',
72
+ shell: 'allow',
73
+ script: 'allow',
74
+ softwareInstall: 'allow',
75
+ network: 'allow',
76
+ systemPower: 'allow',
77
+ systemConfiguration: 'allow',
78
+ userAccount: 'ask'
79
+ }
80
+ });
81
+
82
+ const TOOL_CATEGORY_ALIASES = Object.freeze({
83
+ 'process.list': 'read',
84
+ 'service.status': 'read',
85
+ 'system.health': 'read',
86
+ 'gpu.status': 'read',
87
+ 'disk.status': 'read',
88
+ 'diagnostics.collect': 'read'
89
+ });
90
+
91
+ function normalizeIds(value) {
92
+ return [...new Set((Array.isArray(value) ? value : [])
93
+ .map(item => String(item || '').trim())
94
+ .filter(Boolean))].sort().slice(0, 500);
95
+ }
96
+
97
+ function normalizeDecision(value, fallback = 'ask') {
98
+ return AGENT_PERMISSION_DECISIONS.includes(String(value || '').toLowerCase())
99
+ ? String(value).toLowerCase()
100
+ : fallback;
101
+ }
102
+
103
+ export function normalizeAgentPermissionCategories(value, fallback = DEFAULT_CUSTOM_CATEGORIES) {
104
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
105
+ return Object.fromEntries(AGENT_PERMISSION_CATEGORIES.map(category => [
106
+ category,
107
+ normalizeDecision(source[category], fallback[category] || 'ask')
108
+ ]));
109
+ }
110
+
111
+ export function getPresetAgentPermissionCategories(mode, customCategories) {
112
+ const normalizedMode = AGENT_PERMISSION_MODES.includes(mode) ? mode : 'ask';
113
+ if (normalizedMode === 'custom') return normalizeAgentPermissionCategories(customCategories);
114
+ return normalizeAgentPermissionCategories(PRESET_CATEGORIES[normalizedMode] || PRESET_CATEGORIES.ask);
115
+ }
116
+
117
+ export function createAgentPermissionPolicy({ mode = 'ask', deviceIds = [], customCategories, maxToolCalls, now = new Date() } = {}) {
118
+ const normalizedMode = AGENT_PERMISSION_MODES.includes(String(mode)) ? String(mode) : 'ask';
119
+ const categories = getPresetAgentPermissionCategories(normalizedMode, customCategories);
120
+ const defaultMax = normalizedMode === 'ask' ? 30 : normalizedMode === 'safe-auto' ? 50 : normalizedMode === 'full-access' ? 100 : 50;
121
+ const requestedMax = Number(maxToolCalls);
122
+ const boundedMax = Number.isFinite(requestedMax) ? Math.max(1, Math.min(100, Math.floor(requestedMax))) : defaultMax;
123
+ const createdAt = new Date(now).toISOString();
124
+ const expiresAt = new Date(new Date(now).getTime() + 60 * 60 * 1000).toISOString();
125
+ return {
126
+ policyVersion: 1,
127
+ mode: normalizedMode,
128
+ deviceIds: normalizeIds(deviceIds),
129
+ categories,
130
+ maxToolCalls: boundedMax,
131
+ expiresAt,
132
+ createdAt
133
+ };
134
+ }
135
+
136
+ export function hashAgentPermissionPolicy(policy) {
137
+ const canonical = JSON.stringify({
138
+ policyVersion: policy?.policyVersion,
139
+ mode: policy?.mode,
140
+ deviceIds: normalizeIds(policy?.deviceIds),
141
+ categories: normalizeAgentPermissionCategories(policy?.categories),
142
+ allowedAppIds: normalizeIds(policy?.allowedAppIds),
143
+ allowedServiceNames: normalizeIds(policy?.allowedServiceNames),
144
+ allowedFileRoots: normalizeIds(policy?.allowedFileRoots),
145
+ allowedDomains: normalizeIds(policy?.allowedDomains),
146
+ allowedExecutables: normalizeIds(policy?.allowedExecutables),
147
+ maxToolCalls: Number(policy?.maxToolCalls) || 0,
148
+ expiresAt: String(policy?.expiresAt || '')
149
+ });
150
+ return crypto.createHash('sha256').update(canonical).digest('hex');
151
+ }
152
+
153
+ function targetIdsFromArgs(args, fallback = []) {
154
+ return normalizeIds(args?.deviceIds?.length ? args.deviceIds : fallback);
155
+ }
156
+
157
+ function isSafeAutoRelativePath(value) {
158
+ const text = String(value || '').trim();
159
+ if (!text || text.length > 600 || text.startsWith('/') || /^[A-Za-z]:[\\/]/.test(text) || text.startsWith('\\\\')) return false;
160
+ const segments = text.replaceAll('\\', '/').split('/').filter(Boolean);
161
+ return segments.length > 0 && !segments.includes('..');
162
+ }
163
+
164
+ function safeAutoConstraint(tool, args, mode) {
165
+ if (mode !== 'safe-auto') return '';
166
+ if (tool.category === 'fileWrite' && !isSafeAutoRelativePath(args?.path)) {
167
+ return 'Safe Auto file writes are limited to relative paths inside the LiveDesk files directory.';
168
+ }
169
+ if (tool.category === 'fileWrite') {
170
+ const filePath = String(args?.path || '').trim().replaceAll('\\', '/').toLowerCase();
171
+ if (filePath === 'scripts' || filePath.startsWith('scripts/')) {
172
+ return 'Safe Auto cannot write files inside the script directory.';
173
+ }
174
+ }
175
+ if (tool.category === 'script') {
176
+ const scriptPath = String(args?.path || '').trim().toLowerCase();
177
+ if (!isSafeAutoRelativePath(scriptPath) || !scriptPath.replaceAll('\\', '/').startsWith('scripts/') || !/\.(ps1|psm1|sh|bash|py|js|mjs|cmd|bat)$/.test(scriptPath)) {
178
+ return 'Safe Auto scripts must be registered relative script files inside the LiveDesk files directory.';
179
+ }
180
+ }
181
+ return '';
182
+ }
183
+
184
+ export function evaluateAgentToolPermission({ policy, toolName, arguments: args = {}, deviceIds = [] } = {}) {
185
+ const tool = getAgentToolDefinition(toolName);
186
+ if (!tool) return { decision: 'deny', category: 'unknown', risk: 'critical', reason: 'Unknown or unregistered Agent tool.' };
187
+ const allowed = new Set(normalizeIds(policy?.deviceIds));
188
+ const requested = targetIdsFromArgs(args, deviceIds);
189
+ if (requested.some(deviceId => !allowed.has(deviceId))) {
190
+ return { decision: 'deny', category: tool.category, risk: tool.risk, reason: 'The tool requested a device outside the selected Agent scope.' };
191
+ }
192
+ if (policy?.expiresAt && Date.parse(policy.expiresAt) <= Date.now()) {
193
+ return { decision: 'deny', category: tool.category, risk: 'high', reason: 'The Agent permission policy has expired.' };
194
+ }
195
+ const category = TOOL_CATEGORY_ALIASES[tool.name.replace(/^livedesk\./, '')] || tool.category;
196
+ const constraintError = safeAutoConstraint(tool, args, policy?.mode);
197
+ if (constraintError) {
198
+ return { decision: 'deny', category, risk: tool.risk, reason: constraintError };
199
+ }
200
+ const decision = normalizeDecision(policy?.categories?.[category], 'deny');
201
+ return {
202
+ decision,
203
+ category,
204
+ risk: tool.risk,
205
+ reason: decision === 'allow' ? `Allowed by ${policy?.mode || 'current'} policy.` : decision === 'ask' ? 'This operation requires approval in the current policy.' : 'This operation is denied by the current policy.'
206
+ };
207
+ }
208
+
209
+ export const DEFAULT_CUSTOM_AGENT_PERMISSION_CATEGORIES = Object.freeze({ ...DEFAULT_CUSTOM_CATEGORIES });