mecord-connect 1.0.0
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/DISCLOSURE +47 -0
- package/LICENSE +64 -0
- package/README.md +44 -0
- package/bin/operator.mjs +10 -0
- package/package.json +58 -0
- package/runtime/app/apps/local-agent/src/approval-store.js +342 -0
- package/runtime/app/apps/local-agent/src/bootstrap-config.js +255 -0
- package/runtime/app/apps/local-agent/src/cli.js +237 -0
- package/runtime/app/apps/local-agent/src/device-reset.js +260 -0
- package/runtime/app/apps/local-agent/src/emergency-stop.js +141 -0
- package/runtime/app/apps/local-agent/src/main.js +311 -0
- package/runtime/app/apps/local-agent/src/privacy-data.js +129 -0
- package/runtime/app/apps/local-agent/src/relay-agent.js +333 -0
- package/runtime/app/apps/local-agent/src/relay-enrollment.js +156 -0
- package/runtime/app/apps/local-agent/src/relay-session-credentials.js +436 -0
- package/runtime/app/apps/local-agent/src/remote.js +130 -0
- package/runtime/app/apps/local-agent/src/runtime-factory.js +66 -0
- package/runtime/app/apps/local-agent/src/server.js +578 -0
- package/runtime/app/package.json +4 -0
- package/runtime/app/src/capabilities/browser-cdp-connection.js +199 -0
- package/runtime/app/src/capabilities/browser-cdp-frames.js +288 -0
- package/runtime/app/src/capabilities/browser-cdp-page.js +430 -0
- package/runtime/app/src/capabilities/browser-cdp.js +384 -0
- package/runtime/app/src/capabilities/browser-managed.js +345 -0
- package/runtime/app/src/capabilities/docker.js +448 -0
- package/runtime/app/src/capabilities/filesystem.js +264 -0
- package/runtime/app/src/capabilities/git-checkpoint.js +415 -0
- package/runtime/app/src/capabilities/git-write.js +321 -0
- package/runtime/app/src/capabilities/git.js +252 -0
- package/runtime/app/src/capabilities/index.js +16 -0
- package/runtime/app/src/capabilities/path-scope.js +86 -0
- package/runtime/app/src/capabilities/postgres.js +566 -0
- package/runtime/app/src/capabilities/process.js +258 -0
- package/runtime/app/src/capabilities/project-command.js +599 -0
- package/runtime/app/src/capabilities/project-transaction.js +215 -0
- package/runtime/app/src/capabilities/project.js +67 -0
- package/runtime/app/src/capabilities/system.js +40 -0
- package/runtime/app/src/capabilities/vscode.js +312 -0
- package/runtime/app/src/capabilities/windows-uia.js +331 -0
- package/runtime/app/src/core/account-device-registry.js +604 -0
- package/runtime/app/src/core/action-identity.js +44 -0
- package/runtime/app/src/core/audit.js +295 -0
- package/runtime/app/src/core/capability-policy.js +58 -0
- package/runtime/app/src/core/child-environment.js +29 -0
- package/runtime/app/src/core/device-enrollment.js +417 -0
- package/runtime/app/src/core/device-identity.js +436 -0
- package/runtime/app/src/core/device-registry.js +477 -0
- package/runtime/app/src/core/device-reset.js +178 -0
- package/runtime/app/src/core/device-routing.js +305 -0
- package/runtime/app/src/core/durable-state.js +306 -0
- package/runtime/app/src/core/errors.js +20 -0
- package/runtime/app/src/core/evidence.js +9 -0
- package/runtime/app/src/core/index.js +17 -0
- package/runtime/app/src/core/machine-state.js +160 -0
- package/runtime/app/src/core/network-authority.js +20 -0
- package/runtime/app/src/core/policy.js +67 -0
- package/runtime/app/src/core/product-identity.js +7 -0
- package/runtime/app/src/core/provenance.js +18 -0
- package/runtime/app/src/core/provider-learning.js +274 -0
- package/runtime/app/src/core/public-plugin-surface.js +25 -0
- package/runtime/app/src/core/public-restricted-data.js +105 -0
- package/runtime/app/src/core/rate-limit.js +118 -0
- package/runtime/app/src/core/relay-client.js +568 -0
- package/runtime/app/src/core/relay-delivery-store.js +512 -0
- package/runtime/app/src/core/relay-result-store.js +321 -0
- package/runtime/app/src/core/release-update.js +253 -0
- package/runtime/app/src/core/router.js +96 -0
- package/runtime/app/src/core/runtime.js +136 -0
- package/runtime/app/src/core/scoped-path-syntax.js +16 -0
- package/runtime/app/src/core/session-token.js +498 -0
- package/runtime/app/src/core/task-orchestrator.js +1148 -0
- package/runtime/app/src/core/task-store.js +517 -0
- package/runtime/app/src/core/task.js +142 -0
- package/runtime/app/src/core/trusted-executable.js +124 -0
- package/runtime/app/src/core/types.js +83 -0
- package/runtime/app/src/core/windows-path-lease.js +138 -0
- package/runtime/native/operator-windows-dpapi.exe +0 -0
- package/runtime/native/operator-windows-path-lease.exe +0 -0
- package/runtime/native/operator-windows-uia.exe +0 -0
- package/runtime/runtime-manifest.json +380 -0
- package/src/cli.mjs +345 -0
- package/src/publish-check.mjs +22 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { AuditLog } from '../../../src/core/audit.js';
|
|
4
|
+
import { DeviceIdentityStore } from '../../../src/core/device-identity.js';
|
|
5
|
+
import { DeviceRegistryStore } from '../../../src/core/device-registry.js';
|
|
6
|
+
import { TaskStore } from '../../../src/core/task-store.js';
|
|
7
|
+
import { createRuntime } from './runtime-factory.js';
|
|
8
|
+
import { createLocalAgentServer } from './server.js';
|
|
9
|
+
import { EmergencyStopStore } from './emergency-stop.js';
|
|
10
|
+
import { ApprovalStore } from './approval-store.js';
|
|
11
|
+
import { LocalPrivacyDataStore } from './privacy-data.js';
|
|
12
|
+
import { LocalAgentRelayRunner } from './relay-agent.js';
|
|
13
|
+
import { windowsBootstrapProtector } from './bootstrap-config.js';
|
|
14
|
+
import { RelaySessionCredentialManager, deriveRelayDeviceResetUrl, deriveRelaySessionRotateUrl } from './relay-session-credentials.js';
|
|
15
|
+
import { LocalDeviceResetCoordinator } from './device-reset.js';
|
|
16
|
+
import { OperatorError } from '../../../src/core/errors.js';
|
|
17
|
+
import { RelayEnrollmentClient } from './relay-enrollment.js';
|
|
18
|
+
import { PUBLIC_PLUGIN_CAPABILITIES } from '../../../src/core/public-plugin-surface.js';
|
|
19
|
+
import { TaskOrchestrator } from '../../../src/core/task-orchestrator.js';
|
|
20
|
+
import { evidence } from '../../../src/core/evidence.js';
|
|
21
|
+
|
|
22
|
+
const allowedRoots = (process.env.OPERATOR_ALLOWED_ROOTS ?? process.cwd())
|
|
23
|
+
.split(path.delimiter)
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.map((root) => path.resolve(root));
|
|
26
|
+
|
|
27
|
+
// This allowlist is used only by commands explicitly declared in the trusted
|
|
28
|
+
// Operator project-command registry outside project roots. Generic terminal
|
|
29
|
+
// execution has a separate, empty-by-default allowlist below.
|
|
30
|
+
const allowedExecutables = (process.env.OPERATOR_ALLOWED_EXECUTABLES ?? 'git,node,npm,npx,pnpm,python,python3')
|
|
31
|
+
.split(',')
|
|
32
|
+
.map((item) => item.trim())
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
|
|
35
|
+
const terminalAllowedExecutables = (process.env.OPERATOR_TERMINAL_ALLOWED_EXECUTABLES ?? '')
|
|
36
|
+
.split(',')
|
|
37
|
+
.map((item) => item.trim())
|
|
38
|
+
.filter(Boolean);
|
|
39
|
+
|
|
40
|
+
const token = process.env.OPERATOR_AGENT_TOKEN;
|
|
41
|
+
if (!token || token.length < 32) {
|
|
42
|
+
console.error('[operator] OPERATOR_AGENT_TOKEN must be set to a secret of at least 32 characters.');
|
|
43
|
+
process.exit(2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const recoveryToken = process.env.OPERATOR_RECOVERY_TOKEN;
|
|
47
|
+
if (recoveryToken !== undefined && recoveryToken.length < 32) {
|
|
48
|
+
console.error('[operator] OPERATOR_RECOVERY_TOKEN must be at least 32 characters when set.');
|
|
49
|
+
process.exit(2);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const stateDir = path.resolve(process.env.OPERATOR_STATE_DIR ?? path.join(os.homedir(), '.operator'));
|
|
53
|
+
const emergencyStop = new EmergencyStopStore(stateDir);
|
|
54
|
+
const approvals = new ApprovalStore(stateDir);
|
|
55
|
+
const audit = new AuditLog(stateDir);
|
|
56
|
+
const tasks = new TaskStore(stateDir);
|
|
57
|
+
const deviceIdentity = new DeviceIdentityStore(stateDir);
|
|
58
|
+
const deviceRegistry = new DeviceRegistryStore(stateDir);
|
|
59
|
+
const privacy = new LocalPrivacyDataStore(stateDir);
|
|
60
|
+
const browserAutoLaunch = process.env.OPERATOR_BROWSER_AUTO_LAUNCH !== '0';
|
|
61
|
+
const relayUrl = process.env.OPERATOR_RELAY_URL?.trim();
|
|
62
|
+
const relayResultUrl = process.env.OPERATOR_RELAY_RESULT_URL?.trim();
|
|
63
|
+
const relayTokenFile = path.resolve(process.env.OPERATOR_RELAY_SESSION_TOKEN_FILE?.trim() || path.join(stateDir, 'relay-session.token'));
|
|
64
|
+
const relayAllowInsecureLoopback = process.env.OPERATOR_RELAY_ALLOW_INSECURE_LOOPBACK === '1';
|
|
65
|
+
const relayRequired = process.env.OPERATOR_RELAY_REQUIRED === '1';
|
|
66
|
+
const pairUrlBase = process.env.OPERATOR_PAIR_URL_BASE?.trim();
|
|
67
|
+
let pairUrl ;
|
|
68
|
+
if (pairUrlBase) {
|
|
69
|
+
pairUrl = new URL(pairUrlBase);
|
|
70
|
+
if (pairUrl.protocol !== 'https:' || pairUrl.username || pairUrl.password || pairUrl.hash || pairUrl.search || pairUrl.pathname !== '/pair') {
|
|
71
|
+
throw new OperatorError('PAIR_URL_INVALID', 'Device pairing URL must be credential-free HTTPS ending exactly at /pair.');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (relayRequired && !relayUrl) {
|
|
75
|
+
throw new OperatorError('RELAY_REQUIRED_CONFIGURATION_MISSING', 'Relay-only mode requires an explicit relay URL.');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const runtime = createRuntime({
|
|
79
|
+
stateDir,
|
|
80
|
+
allowedRoots,
|
|
81
|
+
allowedExecutables,
|
|
82
|
+
terminalAllowedExecutables,
|
|
83
|
+
projectCommandRegistryPath: process.env.OPERATOR_PROJECT_COMMAND_REGISTRY,
|
|
84
|
+
dockerExecutable: process.env.OPERATOR_DOCKER_PATH,
|
|
85
|
+
postgresProfileRegistryPath: process.env.OPERATOR_POSTGRES_PROFILE_REGISTRY,
|
|
86
|
+
psqlExecutable: process.env.OPERATOR_PSQL_PATH,
|
|
87
|
+
vscodeExecutable: process.env.OPERATOR_VSCODE_PATH,
|
|
88
|
+
vscodeDataDir: process.env.OPERATOR_VSCODE_DATA_DIR,
|
|
89
|
+
cdpEndpoint: process.env.OPERATOR_CDP_ENDPOINT,
|
|
90
|
+
browserAutoLaunch,
|
|
91
|
+
browserPath: process.env.OPERATOR_BROWSER_PATH,
|
|
92
|
+
browserDataDir: process.env.OPERATOR_BROWSER_DATA_DIR,
|
|
93
|
+
windowsUiaPath: process.env.OPERATOR_WINDOWS_UIA_PATH,
|
|
94
|
+
windowsPathLeasePath: process.env.OPERATOR_WINDOWS_PATH_LEASE_PATH
|
|
95
|
+
});
|
|
96
|
+
let relayRunner = null;
|
|
97
|
+
let relayRun = null;
|
|
98
|
+
let relaySessionCredentials = null;
|
|
99
|
+
let localAgentBaseUrl = '';
|
|
100
|
+
let shuttingDown = false;
|
|
101
|
+
|
|
102
|
+
function stopRelay() {
|
|
103
|
+
relayRunner?.stop();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function failRequiredRelay(error ) {
|
|
107
|
+
if (!relayRequired || shuttingDown) return;
|
|
108
|
+
shuttingDown = true;
|
|
109
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
110
|
+
console.error(`[operator] required relay failed: ${message}`);
|
|
111
|
+
stopRelay();
|
|
112
|
+
await Promise.allSettled([agent.close(), runtime.close()]);
|
|
113
|
+
process.exitCode = 1;
|
|
114
|
+
setImmediate(() => process.exit(1));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function startRelay() {
|
|
118
|
+
if (!relayUrl || shuttingDown || relayRun) return;
|
|
119
|
+
const enrollment = new RelayEnrollmentClient({
|
|
120
|
+
relayUrl, resultUrl: relayResultUrl, identity: deviceIdentity,
|
|
121
|
+
allowLoopbackInsecure: relayAllowInsecureLoopback,
|
|
122
|
+
onUserCode: ({ userCode, expiresAt }) => {
|
|
123
|
+
console.error(`[operator] device pairing code: ${userCode} (expires ${expiresAt})`);
|
|
124
|
+
if (pairUrl) {
|
|
125
|
+
const url = new URL(pairUrl);
|
|
126
|
+
url.searchParams.set('code', userCode);
|
|
127
|
+
console.error(`[operator] pair this device: ${url.toString()}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
const sessionCredentials = new RelaySessionCredentialManager({
|
|
132
|
+
stateDir,
|
|
133
|
+
legacyTokenFile: relayTokenFile,
|
|
134
|
+
rotateUrl: deriveRelaySessionRotateUrl(relayUrl, relayResultUrl, relayAllowInsecureLoopback),
|
|
135
|
+
protector: windowsBootstrapProtector(),
|
|
136
|
+
allowLoopbackInsecure: relayAllowInsecureLoopback,
|
|
137
|
+
enrollment,
|
|
138
|
+
onBackgroundRefreshFailure: () => relayRunner?.reconnect()
|
|
139
|
+
});
|
|
140
|
+
relaySessionCredentials = sessionCredentials;
|
|
141
|
+
relayRunner = new LocalAgentRelayRunner({
|
|
142
|
+
stateDir,
|
|
143
|
+
relayUrl,
|
|
144
|
+
resultUrl: relayResultUrl,
|
|
145
|
+
sessionTokenFile: relayTokenFile,
|
|
146
|
+
sessionCredentials,
|
|
147
|
+
identity: deviceIdentity,
|
|
148
|
+
localAgentBaseUrl,
|
|
149
|
+
agentToken: token,
|
|
150
|
+
getSupportedCapabilities: () => runtime.supportedCapabilities(PUBLIC_PLUGIN_CAPABILITIES),
|
|
151
|
+
allowLoopbackInsecure: relayAllowInsecureLoopback
|
|
152
|
+
});
|
|
153
|
+
const runner = relayRunner;
|
|
154
|
+
relayRun = runner.run()
|
|
155
|
+
.then(async () => {
|
|
156
|
+
if (relayRequired && !shuttingDown) {
|
|
157
|
+
await failRequiredRelay(new OperatorError('RELAY_REQUIRED_STOPPED', 'The required relay connection stopped.'));
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
.catch(async (error) => {
|
|
161
|
+
console.error(`[operator] relay connection stopped: ${error instanceof Error ? error.message : String(error)}`);
|
|
162
|
+
await failRequiredRelay(error);
|
|
163
|
+
})
|
|
164
|
+
.finally(() => {
|
|
165
|
+
runner.stop();
|
|
166
|
+
if (relayRunner === runner) relayRunner = null;
|
|
167
|
+
if (relaySessionCredentials === sessionCredentials) relaySessionCredentials = null;
|
|
168
|
+
relayRun = null;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function resetLocalDevice() {
|
|
173
|
+
const coordinator = new LocalDeviceResetCoordinator({
|
|
174
|
+
stateDir,
|
|
175
|
+
identity: deviceIdentity,
|
|
176
|
+
resetUrl: relayUrl ? deriveRelayDeviceResetUrl(relayUrl, relayResultUrl, relayAllowInsecureLoopback) : undefined,
|
|
177
|
+
getResetToken: relayUrl ? async () => {
|
|
178
|
+
const credentials = relaySessionCredentials;
|
|
179
|
+
if (!credentials) throw new OperatorError('DEVICE_RESET_RELAY_UNAVAILABLE', 'Relay session credentials are not active; start Operator and retry device reset.');
|
|
180
|
+
return await credentials.forReset();
|
|
181
|
+
} : undefined,
|
|
182
|
+
stopRelay: async () => {
|
|
183
|
+
stopRelay();
|
|
184
|
+
const activeRun = relayRun;
|
|
185
|
+
if (activeRun) await activeRun;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
return await coordinator.reset();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const permissions = {
|
|
192
|
+
allowedCapabilities: ['computer.inspect', 'project.inspect', 'project.command.*', 'project.transaction.*', 'docker.*', 'postgres.*', 'vscode.*', 'file.*', 'git.*', 'terminal.execute', 'browser.inspect', 'browser.navigate', 'browser.interact', 'app.inspect', 'app.operate'],
|
|
193
|
+
allowedRoots,
|
|
194
|
+
allowExternalWrites: false,
|
|
195
|
+
allowSystemChanges: false,
|
|
196
|
+
allowDestructive: false
|
|
197
|
+
};
|
|
198
|
+
const taskOrchestrator = new TaskOrchestrator({
|
|
199
|
+
runtime,
|
|
200
|
+
store: tasks,
|
|
201
|
+
permissions,
|
|
202
|
+
executeAction: async (action, actionPermissions) => {
|
|
203
|
+
if ((await emergencyStop.status()).engaged) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
capability: action.capability,
|
|
207
|
+
provider: 'policy',
|
|
208
|
+
evidence: [evidence('emergency_stop', 'fail', 'Operator execution is disabled by the local emergency stop.')],
|
|
209
|
+
error: { code: 'EMERGENCY_STOPPED', message: 'Operator execution is disabled by the local emergency stop.', retryable: false },
|
|
210
|
+
durationMs: 0
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const result = await runtime.execute(action, actionPermissions);
|
|
214
|
+
await audit.append({
|
|
215
|
+
taskId: action.taskId,
|
|
216
|
+
capability: action.capability,
|
|
217
|
+
target: action.target,
|
|
218
|
+
result: result.ok ? 'success' : result.provider === 'policy' ? 'blocked' : 'failure',
|
|
219
|
+
risk: action.risk,
|
|
220
|
+
details: { actionId: action.id, provider: result.provider, durationMs: result.durationMs, errorCode: result.error?.code }
|
|
221
|
+
});
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const agent = createLocalAgentServer({
|
|
227
|
+
runtime,
|
|
228
|
+
token,
|
|
229
|
+
recoveryToken,
|
|
230
|
+
emergencyStop,
|
|
231
|
+
approvals,
|
|
232
|
+
audit,
|
|
233
|
+
tasks,
|
|
234
|
+
taskOrchestrator,
|
|
235
|
+
deviceIdentity,
|
|
236
|
+
deviceRegistry,
|
|
237
|
+
privacy,
|
|
238
|
+
deviceReset: resetLocalDevice,
|
|
239
|
+
onEmergencyStop: () => stopRelay(),
|
|
240
|
+
onEmergencyClear: () => {
|
|
241
|
+
try { startRelay(); }
|
|
242
|
+
catch (error) { console.error(`[operator] relay reconnect after emergency recovery failed: ${error instanceof Error ? error.message : String(error)}`); }
|
|
243
|
+
},
|
|
244
|
+
settings: {
|
|
245
|
+
recoveryConfigured: Boolean(recoveryToken),
|
|
246
|
+
browserAutoLaunch,
|
|
247
|
+
cdpEndpointConfigured: Boolean(process.env.OPERATOR_CDP_ENDPOINT),
|
|
248
|
+
browserPathConfigured: Boolean(process.env.OPERATOR_BROWSER_PATH),
|
|
249
|
+
projectCommandRegistryConfigured: Boolean(process.env.OPERATOR_PROJECT_COMMAND_REGISTRY),
|
|
250
|
+
dockerConfigured: Boolean(process.env.OPERATOR_DOCKER_PATH),
|
|
251
|
+
postgresConfigured: Boolean(process.env.OPERATOR_POSTGRES_PROFILE_REGISTRY),
|
|
252
|
+
vscodeConfigured: Boolean(process.env.OPERATOR_VSCODE_PATH),
|
|
253
|
+
windowsUiaConfigured: Boolean(process.env.OPERATOR_WINDOWS_UIA_PATH),
|
|
254
|
+
relayConfigured: Boolean(relayUrl),
|
|
255
|
+
relayResultConfigured: Boolean(relayResultUrl),
|
|
256
|
+
relayTokenFileConfigured: Boolean(relayUrl),
|
|
257
|
+
authorizedRootCount: allowedRoots.length,
|
|
258
|
+
projectExecutableAllowlistCount: allowedExecutables.length,
|
|
259
|
+
terminalExecutableAllowlistCount: terminalAllowedExecutables.length
|
|
260
|
+
},
|
|
261
|
+
permissions
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const host = process.env.OPERATOR_AGENT_HOST ?? '127.0.0.1';
|
|
265
|
+
const port = Number(process.env.OPERATOR_AGENT_PORT ?? 47100);
|
|
266
|
+
const bound = await agent.listen(host, port);
|
|
267
|
+
localAgentBaseUrl = relayUrl ? `http://${loopbackAddressForBoundHost(bound.host)}:${bound.port}` : '';
|
|
268
|
+
console.error(`[operator] local agent listening on http://${bound.host}:${bound.port}`);
|
|
269
|
+
console.error(`[operator] authorized roots: ${allowedRoots.join(', ')}`);
|
|
270
|
+
console.error(`[operator] protected state directory: ${stateDir}`);
|
|
271
|
+
console.error(`[operator] recovery API: ${recoveryToken ? 'configured' : 'disabled until OPERATOR_RECOVERY_TOKEN is set'}`);
|
|
272
|
+
console.error(`[operator] generic terminal: ${terminalAllowedExecutables.length ? 'explicit allowlist configured' : 'disabled by default'}`);
|
|
273
|
+
console.error(`[operator] relay: ${relayUrl ? 'configured' : 'disabled'}`);
|
|
274
|
+
if (relayUrl) {
|
|
275
|
+
const relayCapabilities = await runtime.supportedCapabilities(PUBLIC_PLUGIN_CAPABILITIES);
|
|
276
|
+
console.error(`[operator] relay capabilities: ${relayCapabilities.join(', ') || 'none'}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const emergencyStatus = await emergencyStop.status();
|
|
280
|
+
if (relayUrl && emergencyStatus.engaged) {
|
|
281
|
+
if (relayRequired) {
|
|
282
|
+
await failRequiredRelay(new OperatorError(
|
|
283
|
+
'RELAY_REQUIRED_EMERGENCY_STOP',
|
|
284
|
+
'The required relay cannot start while the local emergency stop is engaged.'
|
|
285
|
+
));
|
|
286
|
+
}
|
|
287
|
+
} else if (relayUrl) {
|
|
288
|
+
try { startRelay(); }
|
|
289
|
+
catch (error) {
|
|
290
|
+
console.error(`[operator] relay startup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
291
|
+
await failRequiredRelay(error);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (const signal of ['SIGINT', 'SIGTERM'] ) {
|
|
296
|
+
process.on(signal, async () => {
|
|
297
|
+
if (shuttingDown) return;
|
|
298
|
+
shuttingDown = true;
|
|
299
|
+
stopRelay();
|
|
300
|
+
await Promise.allSettled([relayRun, agent.close(), runtime.close()].filter(Boolean) );
|
|
301
|
+
process.exit(0);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function loopbackAddressForBoundHost(hostInput ) {
|
|
306
|
+
const host = hostInput.toLowerCase().replace(/^\[|\]$/g, '');
|
|
307
|
+
if (host === '::1' || host === '::') return '[::1]';
|
|
308
|
+
if (host === '0.0.0.0') return '127.0.0.1';
|
|
309
|
+
if (host === 'localhost' || host === '127.0.0.1') return host;
|
|
310
|
+
throw new Error('Relay integration requires the local agent to bind to loopback or a wildcard interface so it can re-enter through the local policy boundary.');
|
|
311
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
const MAX_COUNTED_ENTRIES = 10_000;
|
|
15
|
+
|
|
16
|
+
export class LocalPrivacyDataStore {
|
|
17
|
+
#stateDir ;
|
|
18
|
+
|
|
19
|
+
constructor(stateDir ) {
|
|
20
|
+
this.#stateDir = path.resolve(stateDir);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async inventory() {
|
|
24
|
+
return [
|
|
25
|
+
await this.#status('activity', ['audit.ndjson', 'audit-head.json', 'provider-learning.json'], true),
|
|
26
|
+
await this.#status('tasks', ['tasks', 'task-leases'], true),
|
|
27
|
+
await this.#status('session-state', ['relay-client.json', 'device-sessions.json', 'relay-session.token'], true),
|
|
28
|
+
await this.#status('device-identity', ['device-identity.json'], false),
|
|
29
|
+
await this.#status('pairing-state', ['device-registry.json', 'device-routing.json'], false)
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async purge(category ) {
|
|
34
|
+
const before = (await this.inventory()).find((item) => item.category === category);
|
|
35
|
+
if (!before) throw new Error('Unknown privacy category.');
|
|
36
|
+
const targets = category === 'activity'
|
|
37
|
+
? ['audit.ndjson', 'audit-head.json', 'provider-learning.json']
|
|
38
|
+
: category === 'tasks'
|
|
39
|
+
? ['tasks', 'task-leases']
|
|
40
|
+
: ['relay-client.json', 'device-sessions.json', 'relay-session.token'];
|
|
41
|
+
|
|
42
|
+
for (const relative of targets) {
|
|
43
|
+
const target = this.#target(relative);
|
|
44
|
+
await this.#assertSafeKnownTarget(target);
|
|
45
|
+
}
|
|
46
|
+
for (const relative of targets) {
|
|
47
|
+
const target = this.#target(relative);
|
|
48
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
return { category, removedBytes: before.bytes, removedEntries: before.entries };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async #status(category , relatives , deletable ) {
|
|
54
|
+
let bytes = 0;
|
|
55
|
+
let entries = 0;
|
|
56
|
+
for (const relative of relatives) {
|
|
57
|
+
const counted = await countKnownPath(this.#target(relative));
|
|
58
|
+
bytes += counted.bytes;
|
|
59
|
+
entries += counted.entries;
|
|
60
|
+
if (entries > MAX_COUNTED_ENTRIES) throw new Error('Privacy inventory exceeds the bounded entry count.');
|
|
61
|
+
}
|
|
62
|
+
return { category, bytes, entries, present: entries > 0, deletable };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#target(relative ) {
|
|
66
|
+
const target = path.resolve(this.#stateDir, relative);
|
|
67
|
+
const rel = path.relative(this.#stateDir, target);
|
|
68
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) throw new Error('Privacy data target escaped the Operator state directory.');
|
|
69
|
+
return target;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async #assertSafeKnownTarget(target ) {
|
|
73
|
+
try {
|
|
74
|
+
const stat = await fs.lstat(target);
|
|
75
|
+
if (stat.isSymbolicLink()) throw new Error('Privacy data target must not be a symbolic link.');
|
|
76
|
+
if (stat.isDirectory()) await assertTreeHasNoSymlinks(target, 0);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if ((error ).code === 'ENOENT') return;
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function countKnownPath(target ) {
|
|
85
|
+
try {
|
|
86
|
+
const stat = await fs.lstat(target);
|
|
87
|
+
if (stat.isSymbolicLink()) throw new Error('Privacy inventory refuses symbolic links.');
|
|
88
|
+
if (stat.isFile()) return { bytes: stat.size, entries: 1 };
|
|
89
|
+
if (!stat.isDirectory()) return { bytes: 0, entries: 1 };
|
|
90
|
+
return await countDirectory(target, 0);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if ((error ).code === 'ENOENT') return { bytes: 0, entries: 0 };
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function countDirectory(dir , seen ) {
|
|
98
|
+
let bytes = 0;
|
|
99
|
+
let entries = 1;
|
|
100
|
+
const names = await fs.readdir(dir);
|
|
101
|
+
for (const name of names) {
|
|
102
|
+
if (seen + entries > MAX_COUNTED_ENTRIES) throw new Error('Privacy inventory exceeds the bounded entry count.');
|
|
103
|
+
const child = path.join(dir, name);
|
|
104
|
+
const stat = await fs.lstat(child);
|
|
105
|
+
if (stat.isSymbolicLink()) throw new Error('Privacy inventory refuses symbolic links.');
|
|
106
|
+
if (stat.isDirectory()) {
|
|
107
|
+
const nested = await countDirectory(child, seen + entries);
|
|
108
|
+
bytes += nested.bytes;
|
|
109
|
+
entries += nested.entries;
|
|
110
|
+
} else {
|
|
111
|
+
bytes += stat.size;
|
|
112
|
+
entries += 1;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { bytes, entries };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function assertTreeHasNoSymlinks(dir , seen ) {
|
|
119
|
+
let count = seen;
|
|
120
|
+
for (const name of await fs.readdir(dir)) {
|
|
121
|
+
count += 1;
|
|
122
|
+
if (count > MAX_COUNTED_ENTRIES) throw new Error('Privacy deletion exceeds the bounded entry count.');
|
|
123
|
+
const child = path.join(dir, name);
|
|
124
|
+
const stat = await fs.lstat(child);
|
|
125
|
+
if (stat.isSymbolicLink()) throw new Error('Privacy deletion refuses symbolic links.');
|
|
126
|
+
if (stat.isDirectory()) count = await assertTreeHasNoSymlinks(child, count);
|
|
127
|
+
}
|
|
128
|
+
return count;
|
|
129
|
+
}
|