neoagent 2.4.1-beta.28 → 2.4.1-beta.29
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/flutter_app/lib/main_controller.dart +102 -0
- package/flutter_app/lib/main_devices.dart +305 -1
- package/flutter_app/lib/main_integrations.dart +254 -7
- package/flutter_app/lib/src/backend_client.dart +35 -0
- package/package.json +1 -1
- package/server/http/routes.js +1 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +76175 -75500
- package/server/routes/workspace.js +86 -0
- package/server/services/integrations/home_assistant/constants.js +88 -0
- package/server/services/integrations/home_assistant/network.js +207 -0
- package/server/services/integrations/home_assistant/provider.js +249 -0
- package/server/services/integrations/home_assistant/snapshot.js +98 -0
- package/server/services/integrations/home_assistant/tools.js +101 -0
- package/server/services/integrations/registry.js +2 -0
- package/server/services/workspace/manager.js +116 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const { requireAuth } = require('../middleware/auth');
|
|
5
|
+
const { sanitizeError } = require('../utils/security');
|
|
6
|
+
|
|
7
|
+
const router = express.Router();
|
|
8
|
+
|
|
9
|
+
const MAX_PATH_LENGTH = 2048;
|
|
10
|
+
const MAX_EDIT_BYTES = 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
router.use(requireAuth);
|
|
13
|
+
|
|
14
|
+
function badRequest(message) {
|
|
15
|
+
const error = new Error(message);
|
|
16
|
+
error.status = 400;
|
|
17
|
+
return error;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseWorkspacePath(value, fallback = '.') {
|
|
21
|
+
const normalized = String(value ?? fallback).trim();
|
|
22
|
+
if (normalized.length > MAX_PATH_LENGTH) {
|
|
23
|
+
throw badRequest('path is too long.');
|
|
24
|
+
}
|
|
25
|
+
return normalized || fallback;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getWorkspace(req) {
|
|
29
|
+
const workspace = req.app?.locals?.workspaceManager;
|
|
30
|
+
if (!workspace) {
|
|
31
|
+
const error = new Error('Workspace service is unavailable.');
|
|
32
|
+
error.status = 503;
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
return workspace;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function handleWorkspaceAction(req, res, action) {
|
|
39
|
+
try {
|
|
40
|
+
const result = action(getWorkspace(req));
|
|
41
|
+
return res.json(result);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return res.status(err.status || 500).json({ error: sanitizeError(err), code: err.code || null });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
router.get('/files', (req, res) => handleWorkspaceAction(req, res, (workspace) =>
|
|
48
|
+
workspace.listExplorerDirectory(req.session.userId, {
|
|
49
|
+
path: parseWorkspacePath(req.query?.path, '.'),
|
|
50
|
+
})));
|
|
51
|
+
|
|
52
|
+
router.get('/files/content', (req, res) => handleWorkspaceAction(req, res, (workspace) =>
|
|
53
|
+
workspace.readExplorerFile(req.session.userId, {
|
|
54
|
+
path: parseWorkspacePath(req.query?.path, ''),
|
|
55
|
+
maxBytes: MAX_EDIT_BYTES,
|
|
56
|
+
})));
|
|
57
|
+
|
|
58
|
+
router.put('/files/content', (req, res) => handleWorkspaceAction(req, res, (workspace) => {
|
|
59
|
+
const content = String(req.body?.content ?? '');
|
|
60
|
+
if (Buffer.byteLength(content, 'utf8') > MAX_EDIT_BYTES) {
|
|
61
|
+
throw badRequest('content is too large.');
|
|
62
|
+
}
|
|
63
|
+
return workspace.writeExplorerFile(req.session.userId, {
|
|
64
|
+
path: parseWorkspacePath(req.body?.path, ''),
|
|
65
|
+
content,
|
|
66
|
+
});
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
router.get('/files/download', (req, res) => {
|
|
70
|
+
try {
|
|
71
|
+
const workspace = getWorkspace(req);
|
|
72
|
+
const file = workspace.getExplorerDownload(req.session.userId, {
|
|
73
|
+
path: parseWorkspacePath(req.query?.path, ''),
|
|
74
|
+
});
|
|
75
|
+
res.setHeader('Cache-Control', 'private, no-store');
|
|
76
|
+
return res.download(file.absolutePath, file.filename, (err) => {
|
|
77
|
+
if (!err || res.headersSent) return;
|
|
78
|
+
const status = err.code === 'ENOENT' || err.code === 'EACCES' ? 404 : 500;
|
|
79
|
+
res.status(status).json({ error: status === 404 ? 'File not found' : 'Failed to download file' });
|
|
80
|
+
});
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return res.status(err.status || 500).json({ error: sanitizeError(err), code: err.code || null });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
module.exports = router;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const HOME_ASSISTANT_PROVIDER_KEY = 'home_assistant';
|
|
4
|
+
|
|
5
|
+
const HOME_ASSISTANT_APP = {
|
|
6
|
+
id: 'home_assistant',
|
|
7
|
+
label: 'Home Assistant',
|
|
8
|
+
description: 'Connect one Home Assistant instance for states and service calls.',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const HOME_ASSISTANT_TOOL_DEFINITIONS = [
|
|
12
|
+
{
|
|
13
|
+
appId: HOME_ASSISTANT_APP.id,
|
|
14
|
+
name: 'home_assistant_get_config',
|
|
15
|
+
access: 'read',
|
|
16
|
+
description: 'Get basic configuration for the connected Home Assistant instance.',
|
|
17
|
+
parameters: { type: 'object', properties: {} },
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
appId: HOME_ASSISTANT_APP.id,
|
|
21
|
+
name: 'home_assistant_list_states',
|
|
22
|
+
access: 'read',
|
|
23
|
+
description: 'List Home Assistant entity states. Use entity_domain to narrow results such as light, sensor, switch, climate, or automation.',
|
|
24
|
+
parameters: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
entity_domain: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
description: 'Optional entity domain prefix before the dot, for example light or sensor.',
|
|
30
|
+
},
|
|
31
|
+
limit: { type: 'number', description: 'Maximum number of states to return, default 100.' },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
appId: HOME_ASSISTANT_APP.id,
|
|
37
|
+
name: 'home_assistant_get_state',
|
|
38
|
+
access: 'read',
|
|
39
|
+
description: 'Get one Home Assistant entity state by entity_id.',
|
|
40
|
+
parameters: {
|
|
41
|
+
type: 'object',
|
|
42
|
+
properties: {
|
|
43
|
+
entity_id: { type: 'string', description: 'Home Assistant entity ID, for example light.kitchen.' },
|
|
44
|
+
},
|
|
45
|
+
required: ['entity_id'],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
appId: HOME_ASSISTANT_APP.id,
|
|
50
|
+
name: 'home_assistant_call_service',
|
|
51
|
+
access: 'write',
|
|
52
|
+
description: 'Call a Home Assistant service such as light.turn_on or script.turn_on.',
|
|
53
|
+
parameters: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
domain: { type: 'string', description: 'Service domain, for example light, switch, climate, script, or automation.' },
|
|
57
|
+
service: { type: 'string', description: 'Service name, for example turn_on, turn_off, toggle, set_temperature, or reload.' },
|
|
58
|
+
service_data: { type: 'object', description: 'Optional Home Assistant service payload.' },
|
|
59
|
+
},
|
|
60
|
+
required: ['domain', 'service'],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
appId: HOME_ASSISTANT_APP.id,
|
|
65
|
+
name: 'home_assistant_api_request',
|
|
66
|
+
access: 'dynamic_http_method',
|
|
67
|
+
description: 'Make an authenticated Home Assistant REST API request under /api for advanced operations.',
|
|
68
|
+
parameters: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
method: { type: 'string', description: 'HTTP method: GET, POST, PUT, PATCH, or DELETE.' },
|
|
72
|
+
path: { type: 'string', description: 'Path under the same Home Assistant origin. Must start with /api/.' },
|
|
73
|
+
query: { type: 'object', description: 'Optional query parameters.' },
|
|
74
|
+
body: { type: 'object', description: 'Optional JSON request body.' },
|
|
75
|
+
},
|
|
76
|
+
required: ['method', 'path'],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const toolAppMap = new Map(HOME_ASSISTANT_TOOL_DEFINITIONS.map((tool) => [tool.name, tool.appId]));
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
HOME_ASSISTANT_APP,
|
|
85
|
+
HOME_ASSISTANT_PROVIDER_KEY,
|
|
86
|
+
HOME_ASSISTANT_TOOL_DEFINITIONS,
|
|
87
|
+
toolAppMap,
|
|
88
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const dns = require('dns').promises;
|
|
4
|
+
const net = require('net');
|
|
5
|
+
|
|
6
|
+
const HTTP_TIMEOUT_MS = 15000;
|
|
7
|
+
const ALLOWED_PORTS = new Set(['', '443', '8123']);
|
|
8
|
+
|
|
9
|
+
function trimText(value) {
|
|
10
|
+
return String(value || '').trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function requireText(value, label) {
|
|
14
|
+
const text = trimText(value);
|
|
15
|
+
if (!text) throw new Error(`${label} is required.`);
|
|
16
|
+
return text;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeHomeAssistantBaseUrl(value) {
|
|
20
|
+
const raw = requireText(value, 'Home Assistant URL');
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = new URL(raw);
|
|
24
|
+
} catch {
|
|
25
|
+
throw new Error('Home Assistant URL must be a valid absolute HTTPS URL.');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (parsed.protocol !== 'https:') {
|
|
29
|
+
throw new Error('Home Assistant URL must use HTTPS.');
|
|
30
|
+
}
|
|
31
|
+
if (parsed.username || parsed.password) {
|
|
32
|
+
throw new Error('Home Assistant URL must not include credentials.');
|
|
33
|
+
}
|
|
34
|
+
if (!ALLOWED_PORTS.has(parsed.port)) {
|
|
35
|
+
throw new Error('Home Assistant URL must use port 443 or 8123.');
|
|
36
|
+
}
|
|
37
|
+
parsed.hash = '';
|
|
38
|
+
parsed.search = '';
|
|
39
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/, '');
|
|
40
|
+
return parsed.toString().replace(/\/$/, '');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function ipv4ToNumber(address) {
|
|
44
|
+
const parts = String(address || '').split('.').map((part) => Number(part));
|
|
45
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return parts.reduce((acc, part) => ((acc << 8) + part) >>> 0, 0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ipv4InCidr(address, cidr, bits) {
|
|
52
|
+
const value = ipv4ToNumber(address);
|
|
53
|
+
const base = ipv4ToNumber(cidr);
|
|
54
|
+
if (value === null || base === null) return false;
|
|
55
|
+
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
|
56
|
+
return (value & mask) === (base & mask);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isBlockedIpv4(address) {
|
|
60
|
+
return [
|
|
61
|
+
['0.0.0.0', 8],
|
|
62
|
+
['10.0.0.0', 8],
|
|
63
|
+
['100.64.0.0', 10],
|
|
64
|
+
['127.0.0.0', 8],
|
|
65
|
+
['169.254.0.0', 16],
|
|
66
|
+
['172.16.0.0', 12],
|
|
67
|
+
['192.0.0.0', 24],
|
|
68
|
+
['192.0.2.0', 24],
|
|
69
|
+
['192.168.0.0', 16],
|
|
70
|
+
['198.18.0.0', 15],
|
|
71
|
+
['198.51.100.0', 24],
|
|
72
|
+
['203.0.113.0', 24],
|
|
73
|
+
['224.0.0.0', 4],
|
|
74
|
+
['240.0.0.0', 4],
|
|
75
|
+
].some(([cidr, bits]) => ipv4InCidr(address, cidr, bits));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isBlockedIpv6(address) {
|
|
79
|
+
const normalized = String(address || '').trim().toLowerCase();
|
|
80
|
+
if (normalized.startsWith('::ffff:')) {
|
|
81
|
+
return isBlockedIpv4(normalized.slice('::ffff:'.length));
|
|
82
|
+
}
|
|
83
|
+
return normalized === '::' ||
|
|
84
|
+
normalized === '::1' ||
|
|
85
|
+
normalized.startsWith('fc') ||
|
|
86
|
+
normalized.startsWith('fd') ||
|
|
87
|
+
normalized.startsWith('fe8') ||
|
|
88
|
+
normalized.startsWith('fe9') ||
|
|
89
|
+
normalized.startsWith('fea') ||
|
|
90
|
+
normalized.startsWith('feb') ||
|
|
91
|
+
normalized.startsWith('ff') ||
|
|
92
|
+
normalized.startsWith('2001:db8:');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isBlockedIpAddress(address) {
|
|
96
|
+
const normalized = String(address || '').trim().replace(/^\[|\]$/g, '');
|
|
97
|
+
const family = net.isIP(normalized);
|
|
98
|
+
if (family === 4) return isBlockedIpv4(normalized);
|
|
99
|
+
if (family === 6) return isBlockedIpv6(normalized);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function assertPublicHomeAssistantEndpoint(baseUrl) {
|
|
104
|
+
const url = new URL(normalizeHomeAssistantBaseUrl(baseUrl));
|
|
105
|
+
const hostname = String(url.hostname || '').replace(/^\[|\]$/g, '');
|
|
106
|
+
if (net.isIP(hostname) && isBlockedIpAddress(hostname)) {
|
|
107
|
+
throw new Error('Home Assistant URL must not point to a private, loopback, or link-local address.');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let addresses;
|
|
111
|
+
try {
|
|
112
|
+
addresses = await dns.lookup(hostname, { all: true, verbatim: true });
|
|
113
|
+
} catch (error) {
|
|
114
|
+
throw new Error(`Could not resolve Home Assistant host: ${error?.message || 'DNS lookup failed'}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!Array.isArray(addresses) || addresses.length === 0) {
|
|
118
|
+
throw new Error('Could not resolve Home Assistant host.');
|
|
119
|
+
}
|
|
120
|
+
if (addresses.some((entry) => isBlockedIpAddress(entry.address))) {
|
|
121
|
+
throw new Error('Home Assistant URL resolves to a private, loopback, or reserved address.');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function buildHomeAssistantUrl(baseUrl, path, query = {}) {
|
|
126
|
+
const base = new URL(normalizeHomeAssistantBaseUrl(baseUrl));
|
|
127
|
+
const url = new URL(requireText(path, 'path'), base);
|
|
128
|
+
if (url.origin !== base.origin || !url.pathname.startsWith('/api/')) {
|
|
129
|
+
throw new Error('Home Assistant API path must stay on the connected origin and start with /api/.');
|
|
130
|
+
}
|
|
131
|
+
for (const [key, value] of Object.entries(query || {})) {
|
|
132
|
+
if (value === undefined || value === null) continue;
|
|
133
|
+
const text = String(value).trim();
|
|
134
|
+
if (!text) continue;
|
|
135
|
+
url.searchParams.set(key, text);
|
|
136
|
+
}
|
|
137
|
+
return url.toString();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function homeAssistantRequest(credentials, options = {}) {
|
|
141
|
+
const baseUrl = normalizeHomeAssistantBaseUrl(credentials.baseUrl);
|
|
142
|
+
const token = requireText(credentials.token, 'Home Assistant token');
|
|
143
|
+
await assertPublicHomeAssistantEndpoint(baseUrl);
|
|
144
|
+
|
|
145
|
+
const method = String(options.method || 'GET').trim().toUpperCase();
|
|
146
|
+
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
147
|
+
throw new Error('Unsupported Home Assistant API method.');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const headers = { Accept: 'application/json', Authorization: `Bearer ${token}` };
|
|
151
|
+
let body;
|
|
152
|
+
if (options.body !== undefined && options.body !== null) {
|
|
153
|
+
headers['Content-Type'] = 'application/json';
|
|
154
|
+
body = JSON.stringify(options.body);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const controller = new AbortController();
|
|
158
|
+
const timer = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
|
|
159
|
+
let response;
|
|
160
|
+
try {
|
|
161
|
+
response = await fetch(buildHomeAssistantUrl(baseUrl, options.path, options.query), {
|
|
162
|
+
method,
|
|
163
|
+
headers,
|
|
164
|
+
body,
|
|
165
|
+
redirect: 'manual',
|
|
166
|
+
signal: controller.signal,
|
|
167
|
+
});
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error?.name === 'AbortError') {
|
|
170
|
+
throw new Error(`Home Assistant request timed out after ${HTTP_TIMEOUT_MS}ms.`);
|
|
171
|
+
}
|
|
172
|
+
throw error;
|
|
173
|
+
} finally {
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (response.status >= 300 && response.status < 400) {
|
|
178
|
+
throw new Error('Home Assistant redirected the API request; redirects are not followed.');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const text = await response.text();
|
|
182
|
+
let data = null;
|
|
183
|
+
try {
|
|
184
|
+
data = text ? JSON.parse(text) : null;
|
|
185
|
+
} catch {
|
|
186
|
+
data = text;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!response.ok) {
|
|
190
|
+
const message = data && typeof data === 'object'
|
|
191
|
+
? data.message || data.error || `${response.status} ${response.statusText}`
|
|
192
|
+
: text || `${response.status} ${response.statusText}`;
|
|
193
|
+
throw new Error(`Home Assistant request failed: ${String(message).trim()}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return data;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = {
|
|
200
|
+
assertPublicHomeAssistantEndpoint,
|
|
201
|
+
buildHomeAssistantUrl,
|
|
202
|
+
homeAssistantRequest,
|
|
203
|
+
isBlockedIpAddress,
|
|
204
|
+
normalizeHomeAssistantBaseUrl,
|
|
205
|
+
requireText,
|
|
206
|
+
trimText,
|
|
207
|
+
};
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const db = require('../../../db/database');
|
|
4
|
+
const { resolveAgentId } = require('../../agents/manager');
|
|
5
|
+
const {
|
|
6
|
+
deleteProviderConfig,
|
|
7
|
+
getProviderConfig,
|
|
8
|
+
setProviderConfig,
|
|
9
|
+
} = require('../provider_config_store');
|
|
10
|
+
const { getConnectionAccessMode } = require('../access');
|
|
11
|
+
const { encryptValue } = require('../secrets');
|
|
12
|
+
const {
|
|
13
|
+
HOME_ASSISTANT_APP,
|
|
14
|
+
HOME_ASSISTANT_PROVIDER_KEY,
|
|
15
|
+
HOME_ASSISTANT_TOOL_DEFINITIONS,
|
|
16
|
+
toolAppMap,
|
|
17
|
+
} = require('./constants');
|
|
18
|
+
const {
|
|
19
|
+
assertPublicHomeAssistantEndpoint,
|
|
20
|
+
buildHomeAssistantUrl,
|
|
21
|
+
isBlockedIpAddress,
|
|
22
|
+
normalizeHomeAssistantBaseUrl,
|
|
23
|
+
trimText,
|
|
24
|
+
} = require('./network');
|
|
25
|
+
const { buildHomeAssistantSnapshot } = require('./snapshot');
|
|
26
|
+
const {
|
|
27
|
+
executeHomeAssistantTool,
|
|
28
|
+
fetchHomeAssistantConfig,
|
|
29
|
+
parseCredentials,
|
|
30
|
+
} = require('./tools');
|
|
31
|
+
|
|
32
|
+
function parseJsonObject(value) {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(String(value || '{}'));
|
|
35
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseConfigInput(rawConfig, existingConfig = {}) {
|
|
42
|
+
const source = rawConfig && typeof rawConfig === 'object' ? rawConfig : {};
|
|
43
|
+
return {
|
|
44
|
+
baseUrl: trimText(source.baseUrl) || trimText(existingConfig.baseUrl),
|
|
45
|
+
token: trimText(source.token) || trimText(existingConfig.token),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function accountEmailForConfig(baseUrl, config = {}) {
|
|
50
|
+
const host = new URL(normalizeHomeAssistantBaseUrl(baseUrl)).host;
|
|
51
|
+
const location = trimText(config.location_name);
|
|
52
|
+
return `home_assistant:${location || host}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveHomeAssistantEnvStatus(userId, agentId = null) {
|
|
56
|
+
const normalizedUserId = Number(userId);
|
|
57
|
+
const config = Number.isInteger(normalizedUserId) && normalizedUserId > 0
|
|
58
|
+
? parseConfigInput(getProviderConfig(normalizedUserId, HOME_ASSISTANT_PROVIDER_KEY, agentId))
|
|
59
|
+
: { baseUrl: '', token: '' };
|
|
60
|
+
const missing = [];
|
|
61
|
+
if (!config.baseUrl) missing.push('baseUrl');
|
|
62
|
+
if (!config.token) missing.push('token');
|
|
63
|
+
return {
|
|
64
|
+
configured: missing.length === 0,
|
|
65
|
+
missing,
|
|
66
|
+
summary: missing.length === 0
|
|
67
|
+
? 'Home Assistant is ready for account connections.'
|
|
68
|
+
: 'Add your Home Assistant HTTPS URL and a Long-Lived Access Token in Official Integrations.',
|
|
69
|
+
setupMode: 'user',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sanitizeHomeAssistantConfigForClient(rawConfig) {
|
|
74
|
+
const config = parseConfigInput(rawConfig);
|
|
75
|
+
return {
|
|
76
|
+
baseUrl: config.baseUrl,
|
|
77
|
+
hasToken: Boolean(config.token),
|
|
78
|
+
configured: Boolean(config.baseUrl && config.token),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function loadExistingConnection(userId, agentId) {
|
|
83
|
+
return db
|
|
84
|
+
.prepare(
|
|
85
|
+
`SELECT *
|
|
86
|
+
FROM integration_connections
|
|
87
|
+
WHERE user_id = ? AND agent_id = ? AND provider_key = ?
|
|
88
|
+
ORDER BY updated_at DESC, id DESC
|
|
89
|
+
LIMIT 1`,
|
|
90
|
+
)
|
|
91
|
+
.get(userId, agentId, HOME_ASSISTANT_PROVIDER_KEY);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function upsertHomeAssistantConnection(userId, agentId, baseUrl, token, config) {
|
|
95
|
+
const existing = loadExistingConnection(userId, agentId);
|
|
96
|
+
const accessMode = getConnectionAccessMode(existing || null);
|
|
97
|
+
const metadata = {
|
|
98
|
+
...parseJsonObject(existing?.metadata_json || '{}'),
|
|
99
|
+
access_mode: accessMode,
|
|
100
|
+
locationName: config?.location_name || null,
|
|
101
|
+
version: config?.version || null,
|
|
102
|
+
timeZone: config?.time_zone || null,
|
|
103
|
+
};
|
|
104
|
+
const accountEmail = accountEmailForConfig(baseUrl, config);
|
|
105
|
+
|
|
106
|
+
db.prepare(
|
|
107
|
+
`INSERT INTO integration_connections (
|
|
108
|
+
user_id, agent_id, provider_key, app_key, status, account_email,
|
|
109
|
+
scopes_json, credentials_json, metadata_json, last_connected_at, updated_at
|
|
110
|
+
) VALUES (?, ?, ?, ?, 'connected', ?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
111
|
+
ON CONFLICT(user_id, agent_id, provider_key, app_key, account_email) DO UPDATE SET
|
|
112
|
+
status = excluded.status,
|
|
113
|
+
scopes_json = excluded.scopes_json,
|
|
114
|
+
credentials_json = excluded.credentials_json,
|
|
115
|
+
metadata_json = excluded.metadata_json,
|
|
116
|
+
last_connected_at = excluded.last_connected_at,
|
|
117
|
+
updated_at = excluded.updated_at`,
|
|
118
|
+
).run(
|
|
119
|
+
userId,
|
|
120
|
+
agentId,
|
|
121
|
+
HOME_ASSISTANT_PROVIDER_KEY,
|
|
122
|
+
HOME_ASSISTANT_APP.id,
|
|
123
|
+
accountEmail,
|
|
124
|
+
JSON.stringify(['home_assistant:api']),
|
|
125
|
+
encryptValue(JSON.stringify({ baseUrl, token })),
|
|
126
|
+
JSON.stringify(metadata),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
if (existing && existing.account_email !== accountEmail) {
|
|
130
|
+
db.prepare('DELETE FROM integration_connections WHERE id = ? AND user_id = ? AND agent_id = ?')
|
|
131
|
+
.run(existing.id, userId, agentId);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return loadExistingConnection(userId, agentId);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function createHomeAssistantProvider() {
|
|
138
|
+
return {
|
|
139
|
+
key: HOME_ASSISTANT_PROVIDER_KEY,
|
|
140
|
+
label: 'Home Assistant',
|
|
141
|
+
description: 'Official Home Assistant integration for one user-managed HTTPS instance with state reads and service calls.',
|
|
142
|
+
icon: 'home_assistant',
|
|
143
|
+
apps: [HOME_ASSISTANT_APP],
|
|
144
|
+
connectPrompt: 'Save your Home Assistant HTTPS URL and a Long-Lived Access Token. Private and loopback network targets are blocked server-side.',
|
|
145
|
+
supportsMultipleAccounts: false,
|
|
146
|
+
connectionMethod: 'user_config',
|
|
147
|
+
getApp(appId) {
|
|
148
|
+
return String(appId || '').trim() === HOME_ASSISTANT_APP.id ? HOME_ASSISTANT_APP : null;
|
|
149
|
+
},
|
|
150
|
+
getToolAppId(toolName) {
|
|
151
|
+
return toolAppMap.get(String(toolName || '').trim()) || null;
|
|
152
|
+
},
|
|
153
|
+
getEnvStatus(context = {}) {
|
|
154
|
+
return resolveHomeAssistantEnvStatus(context.userId, context.agentId);
|
|
155
|
+
},
|
|
156
|
+
getToolDefinitions(options = {}) {
|
|
157
|
+
const connectedAppIds = new Set(options.connectedAppIds || []);
|
|
158
|
+
return connectedAppIds.has(HOME_ASSISTANT_APP.id) ? HOME_ASSISTANT_TOOL_DEFINITIONS.slice() : [];
|
|
159
|
+
},
|
|
160
|
+
supportsTool(toolName) {
|
|
161
|
+
return toolAppMap.has(String(toolName || '').trim());
|
|
162
|
+
},
|
|
163
|
+
buildSnapshot(connectionRows, context = {}) {
|
|
164
|
+
return buildHomeAssistantSnapshot(this, connectionRows, context);
|
|
165
|
+
},
|
|
166
|
+
summarizeForModel(snapshot) {
|
|
167
|
+
if (!snapshot?.env?.configured) {
|
|
168
|
+
return 'Home Assistant: setup is not complete for this user yet. Tell them to finish Home Assistant setup in Official Integrations first.';
|
|
169
|
+
}
|
|
170
|
+
if (!snapshot.connection?.connected) {
|
|
171
|
+
return 'Home Assistant: setup is ready, but no Home Assistant instance is connected yet. Tell the user to open Official Integrations and connect it.';
|
|
172
|
+
}
|
|
173
|
+
return 'Home Assistant: native Home Assistant access is connected in this run with tools for config, states, entity state, service calls, and /api requests.';
|
|
174
|
+
},
|
|
175
|
+
async executeTool(toolName, args, connection) {
|
|
176
|
+
return executeHomeAssistantTool(toolName, args, connection);
|
|
177
|
+
},
|
|
178
|
+
getUserConfig({ userId, agentId }) {
|
|
179
|
+
const normalizedUserId = Number(userId);
|
|
180
|
+
const scopedAgentId = resolveAgentId(normalizedUserId, agentId || null);
|
|
181
|
+
const storedConfig = getProviderConfig(normalizedUserId, HOME_ASSISTANT_PROVIDER_KEY, scopedAgentId);
|
|
182
|
+
const connection = loadExistingConnection(normalizedUserId, scopedAgentId);
|
|
183
|
+
return {
|
|
184
|
+
...sanitizeHomeAssistantConfigForClient({ ...storedConfig, ...parseCredentials(connection) }),
|
|
185
|
+
accountCount: connection?.status === 'connected' ? 1 : 0,
|
|
186
|
+
hasConnectedAccount: connection?.status === 'connected',
|
|
187
|
+
};
|
|
188
|
+
},
|
|
189
|
+
async saveUserConfig({ userId, agentId, config }) {
|
|
190
|
+
const normalizedUserId = Number(userId);
|
|
191
|
+
if (!Number.isInteger(normalizedUserId) || normalizedUserId <= 0) {
|
|
192
|
+
throw new Error('A valid user is required to save Home Assistant configuration.');
|
|
193
|
+
}
|
|
194
|
+
const scopedAgentId = resolveAgentId(normalizedUserId, agentId || null);
|
|
195
|
+
const existingConnection = loadExistingConnection(normalizedUserId, scopedAgentId);
|
|
196
|
+
const existingConfig = parseConfigInput({
|
|
197
|
+
...getProviderConfig(normalizedUserId, HOME_ASSISTANT_PROVIDER_KEY, scopedAgentId),
|
|
198
|
+
...parseCredentials(existingConnection),
|
|
199
|
+
});
|
|
200
|
+
const parsedConfig = parseConfigInput(config, existingConfig);
|
|
201
|
+
const baseUrl = normalizeHomeAssistantBaseUrl(parsedConfig.baseUrl);
|
|
202
|
+
const token = parsedConfig.token;
|
|
203
|
+
if (!token) throw new Error('Home Assistant Long-Lived Access Token is required.');
|
|
204
|
+
|
|
205
|
+
const credentials = { baseUrl, token };
|
|
206
|
+
await assertPublicHomeAssistantEndpoint(baseUrl);
|
|
207
|
+
let haConfig;
|
|
208
|
+
try {
|
|
209
|
+
haConfig = await fetchHomeAssistantConfig(credentials);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
const message = String(error?.message || '').toLowerCase();
|
|
212
|
+
if (message.includes('401') || message.includes('unauthorized')) {
|
|
213
|
+
throw new Error('Home Assistant rejected the token. Create a new Long-Lived Access Token and try again.');
|
|
214
|
+
}
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
setProviderConfig(normalizedUserId, HOME_ASSISTANT_PROVIDER_KEY, { baseUrl, token }, scopedAgentId);
|
|
219
|
+
const connection = upsertHomeAssistantConnection(normalizedUserId, scopedAgentId, baseUrl, token, haConfig || {});
|
|
220
|
+
return {
|
|
221
|
+
...sanitizeHomeAssistantConfigForClient({ baseUrl, token }),
|
|
222
|
+
accountCount: connection?.status === 'connected' ? 1 : 0,
|
|
223
|
+
hasConnectedAccount: connection?.status === 'connected',
|
|
224
|
+
accountEmail: connection?.account_email || null,
|
|
225
|
+
locationName: haConfig?.location_name || null,
|
|
226
|
+
version: haConfig?.version || null,
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
clearUserConfig({ userId, agentId }) {
|
|
230
|
+
const normalizedUserId = Number(userId);
|
|
231
|
+
if (!Number.isInteger(normalizedUserId) || normalizedUserId <= 0) {
|
|
232
|
+
throw new Error('A valid user is required to clear Home Assistant configuration.');
|
|
233
|
+
}
|
|
234
|
+
const scopedAgentId = resolveAgentId(normalizedUserId, agentId || null);
|
|
235
|
+
deleteProviderConfig(normalizedUserId, HOME_ASSISTANT_PROVIDER_KEY, scopedAgentId);
|
|
236
|
+
db.prepare('DELETE FROM integration_connections WHERE user_id = ? AND agent_id = ? AND provider_key = ?')
|
|
237
|
+
.run(normalizedUserId, scopedAgentId, HOME_ASSISTANT_PROVIDER_KEY);
|
|
238
|
+
return { cleared: true };
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
module.exports = {
|
|
244
|
+
assertPublicHomeAssistantEndpoint,
|
|
245
|
+
buildHomeAssistantUrl,
|
|
246
|
+
createHomeAssistantProvider,
|
|
247
|
+
isBlockedIpAddress,
|
|
248
|
+
normalizeHomeAssistantBaseUrl,
|
|
249
|
+
};
|