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,448 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { evidence } from '../core/evidence.js';
|
|
6
|
+
import { OperatorError } from '../core/errors.js';
|
|
7
|
+
import { resolveTrustedExecutable } from '../core/trusted-executable.js';
|
|
8
|
+
import { PathScope } from './path-scope.js';
|
|
9
|
+
|
|
10
|
+
const SCORE = {
|
|
11
|
+
reliability: 0.98,
|
|
12
|
+
latency: 0.9,
|
|
13
|
+
determinism: 0.98,
|
|
14
|
+
security: 0.96,
|
|
15
|
+
reversibility: 0.72,
|
|
16
|
+
informationQuality: 0.99,
|
|
17
|
+
interactionCost: 0.01
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
21
|
+
const MAX_CONTAINERS = 100;
|
|
22
|
+
const MAX_SERVICES = 50;
|
|
23
|
+
const INSPECT_FORMAT = '{{json .Id}}\t{{json .Name}}\t{{json .Config.Image}}\t{{json .State.Status}}\t{{json (index .Config.Labels "com.docker.compose.project")}}\t{{json (index .Config.Labels "com.docker.compose.service")}}\t{{json (index .Config.Labels "com.docker.compose.project.working_dir")}}';
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
export class DockerProvider {
|
|
45
|
+
name = 'docker.local.semantic';
|
|
46
|
+
#scope ;
|
|
47
|
+
#dockerExecutable ;
|
|
48
|
+
#dockerArgsPrefix ;
|
|
49
|
+
|
|
50
|
+
constructor(options ) {
|
|
51
|
+
this.#scope = new PathScope(options.allowedRoots);
|
|
52
|
+
this.#dockerExecutable = options.dockerExecutable ?? 'docker';
|
|
53
|
+
this.#dockerArgsPrefix = options.dockerArgsPrefix ?? [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
supports(action ) {
|
|
57
|
+
return action.capability === 'docker.inspect' || action.capability === 'docker.manage';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
score() { return SCORE; }
|
|
61
|
+
|
|
62
|
+
async execute(action , executionContext = {}) {
|
|
63
|
+
const started = performance.now();
|
|
64
|
+
try {
|
|
65
|
+
if (action.capability === 'docker.inspect') {
|
|
66
|
+
const rootInput = String(action.input.path ?? '').trim();
|
|
67
|
+
if (rootInput) {
|
|
68
|
+
const state = await this.#inspectProject(rootInput, executionContext.signal);
|
|
69
|
+
return success(action, started, {
|
|
70
|
+
scope: 'project',
|
|
71
|
+
root: state.root,
|
|
72
|
+
context: { ...state.context, local: true },
|
|
73
|
+
containers: state.containers.map(publicContainer),
|
|
74
|
+
services: summarizeServices(state.containers),
|
|
75
|
+
fingerprint: state.fingerprint
|
|
76
|
+
}, [
|
|
77
|
+
evidence('docker_context', 'pass', 'Docker context resolves to a local-only endpoint.', state.context),
|
|
78
|
+
evidence('docker_project', 'pass', 'Compose containers were matched from Docker-owned labels without parsing repository Compose configuration.', {
|
|
79
|
+
root: state.root,
|
|
80
|
+
containerCount: state.containers.length,
|
|
81
|
+
serviceCount: new Set(state.containers.map((item) => item.service)).size
|
|
82
|
+
})
|
|
83
|
+
]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const context = await this.#localContext(executionContext.signal);
|
|
87
|
+
const serverVersion = await this.#serverVersion(context, executionContext.signal);
|
|
88
|
+
const containers = await this.#listDaemonContainers(context, executionContext.signal);
|
|
89
|
+
return success(action, started, {
|
|
90
|
+
scope: 'daemon',
|
|
91
|
+
context: { ...context, local: true },
|
|
92
|
+
serverVersion,
|
|
93
|
+
containers: containers.items,
|
|
94
|
+
truncated: containers.truncated
|
|
95
|
+
}, [
|
|
96
|
+
evidence('docker_context', 'pass', 'Docker context resolves to a local-only endpoint.', context),
|
|
97
|
+
evidence('docker_daemon', 'pass', 'Read bounded Docker daemon/container state without returning commands, environment variables, mounts, or arbitrary labels.', {
|
|
98
|
+
containerCount: containers.items.length,
|
|
99
|
+
truncated: containers.truncated
|
|
100
|
+
})
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const operation = String(action.input.operation ?? '');
|
|
105
|
+
if (!['start', 'stop', 'restart'].includes(operation)) {
|
|
106
|
+
throw new OperatorError('INVALID_DOCKER_OPERATION', 'Docker manage operation must be start, stop, or restart.');
|
|
107
|
+
}
|
|
108
|
+
const services = validateServices(action.input.services);
|
|
109
|
+
const expectedCurrentFingerprint = String(action.input.expectedCurrentFingerprint ?? '');
|
|
110
|
+
if (!/^[0-9a-f]{64}$/i.test(expectedCurrentFingerprint)) {
|
|
111
|
+
throw new OperatorError('DOCKER_FINGERPRINT_REQUIRED', 'A fresh expectedCurrentFingerprint from docker.inspect is required.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const before = await this.#inspectProject(String(action.input.path ?? ''), executionContext.signal);
|
|
115
|
+
if (before.fingerprint !== expectedCurrentFingerprint) {
|
|
116
|
+
throw new OperatorError('DOCKER_STATE_CHANGED', 'Docker project state changed after the supplied precondition was captured.', {
|
|
117
|
+
retryable: true,
|
|
118
|
+
details: { expectedCurrentFingerprint, actualCurrentFingerprint: before.fingerprint }
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const selected = before.containers.filter((container) => services.includes(container.service));
|
|
123
|
+
const missing = services.filter((service) => !selected.some((container) => container.service === service));
|
|
124
|
+
if (missing.length > 0) {
|
|
125
|
+
throw new OperatorError('DOCKER_SERVICE_NOT_CREATED', 'Every requested service must already have at least one Compose-created container.', {
|
|
126
|
+
details: { missing }
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const ids = [...new Set(selected.map((container) => container.id))].sort();
|
|
130
|
+
const timeoutMs = boundedInteger(action.input.timeoutMs, 60_000, 1_000, 5 * 60_000);
|
|
131
|
+
await this.#run(before.context, [operation, ...ids], timeoutMs, executionContext.signal);
|
|
132
|
+
|
|
133
|
+
const after = await this.#inspectProject(before.root, executionContext.signal);
|
|
134
|
+
verifyLifecyclePostcondition(operation, services, after.containers);
|
|
135
|
+
return success(action, started, {
|
|
136
|
+
operation,
|
|
137
|
+
root: before.root,
|
|
138
|
+
services,
|
|
139
|
+
containerIds: ids.map((id) => id.slice(0, 12)),
|
|
140
|
+
beforeFingerprint: before.fingerprint,
|
|
141
|
+
afterFingerprint: after.fingerprint,
|
|
142
|
+
states: summarizeServices(after.containers.filter((container) => services.includes(container.service)))
|
|
143
|
+
}, [
|
|
144
|
+
evidence('docker_lifecycle', 'pass', `Docker ${operation} completed for already-created Compose service containers.`, {
|
|
145
|
+
services,
|
|
146
|
+
containerCount: ids.length
|
|
147
|
+
}),
|
|
148
|
+
evidence('postcondition', 'pass', 'Docker container states were re-inspected after lifecycle management.', {
|
|
149
|
+
beforeFingerprint: before.fingerprint,
|
|
150
|
+
afterFingerprint: after.fingerprint
|
|
151
|
+
})
|
|
152
|
+
]);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const op = error instanceof OperatorError
|
|
155
|
+
? error
|
|
156
|
+
: new OperatorError('DOCKER_ERROR', error instanceof Error ? error.message : String(error), { retryable: true });
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
capability: action.capability,
|
|
160
|
+
provider: this.name,
|
|
161
|
+
evidence: [evidence('docker', 'fail', op.message, { code: op.code })],
|
|
162
|
+
error: { code: op.code, message: op.message, retryable: op.retryable },
|
|
163
|
+
durationMs: Math.round(performance.now() - started)
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async #localContext(signal ) {
|
|
169
|
+
const shown = await this.#runRaw(['context', 'show'], 15_000, signal);
|
|
170
|
+
const name = shown.stdout.trim();
|
|
171
|
+
if (!name || name.length > 256 || /[\r\n\0]/.test(name)) {
|
|
172
|
+
throw new OperatorError('DOCKER_CONTEXT_INVALID', 'Docker returned an invalid active context name.');
|
|
173
|
+
}
|
|
174
|
+
const inspected = await this.#runRaw(['context', 'inspect', name, '--format', '{{json .Endpoints.docker.Host}}'], 15_000, signal);
|
|
175
|
+
let host ;
|
|
176
|
+
try { host = JSON.parse(inspected.stdout.trim()) ; } catch {
|
|
177
|
+
throw new OperatorError('DOCKER_CONTEXT_INVALID', 'Docker context endpoint could not be parsed.');
|
|
178
|
+
}
|
|
179
|
+
if (typeof host !== 'string' || !isLocalDockerHost(host)) {
|
|
180
|
+
throw new OperatorError('REMOTE_DOCKER_CONTEXT_DENIED', 'Operator Docker adapter permits only local Unix sockets, Windows named pipes, or loopback TCP endpoints by default.', {
|
|
181
|
+
details: { context: name, host }
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return { name, host };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async #serverVersion(context , signal ) {
|
|
188
|
+
const result = await this.#run(context, ['version', '--format', '{{json .Server.Version}}'], 15_000, signal);
|
|
189
|
+
try {
|
|
190
|
+
const version = JSON.parse(result.stdout.trim());
|
|
191
|
+
return typeof version === 'string' ? version.slice(0, 128) : '';
|
|
192
|
+
} catch { return result.stdout.trim().slice(0, 128); }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async #listDaemonContainers(context , signal ) {
|
|
196
|
+
const result = await this.#run(context, ['ps', '--all', '--format', '{{json .}}'], 20_000, signal);
|
|
197
|
+
const lines = result.stdout.split(/\r?\n/).filter(Boolean);
|
|
198
|
+
const items = [];
|
|
199
|
+
for (const line of lines.slice(0, MAX_CONTAINERS)) {
|
|
200
|
+
try {
|
|
201
|
+
const raw = JSON.parse(line) ;
|
|
202
|
+
items.push({
|
|
203
|
+
id: String(raw.ID ?? '').slice(0, 12),
|
|
204
|
+
name: String(raw.Names ?? '').slice(0, 256),
|
|
205
|
+
image: String(raw.Image ?? '').slice(0, 512),
|
|
206
|
+
state: String(raw.State ?? '').slice(0, 64),
|
|
207
|
+
status: String(raw.Status ?? '').slice(0, 256),
|
|
208
|
+
ports: String(raw.Ports ?? '').slice(0, 1024)
|
|
209
|
+
});
|
|
210
|
+
} catch {
|
|
211
|
+
throw new OperatorError('DOCKER_OUTPUT_INVALID', 'Docker container JSON output could not be parsed.');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { items, truncated: lines.length > MAX_CONTAINERS || result.truncated };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async #inspectProject(inputPath , signal ) {
|
|
218
|
+
const root = await this.#scope.resolveExisting(inputPath);
|
|
219
|
+
const context = await this.#localContext(signal);
|
|
220
|
+
const listed = await this.#run(context, ['ps', '--all', '--filter', 'label=com.docker.compose.project', '--format', '{{json .ID}}'], 20_000, signal);
|
|
221
|
+
const ids = listed.stdout.split(/\r?\n/).filter(Boolean).slice(0, MAX_CONTAINERS).map((line) => {
|
|
222
|
+
try { return String(JSON.parse(line)); } catch { throw new OperatorError('DOCKER_OUTPUT_INVALID', 'Docker container id output could not be parsed.'); }
|
|
223
|
+
}).filter((id) => /^[0-9a-f]{12,64}$/i.test(id));
|
|
224
|
+
|
|
225
|
+
const containers = ids.length === 0 ? [] : await this.#inspectComposeContainers(context, ids, signal);
|
|
226
|
+
const matched = containers
|
|
227
|
+
.filter((container) => sameLocalPath(container.workingDir, root))
|
|
228
|
+
.sort((a, b) => `${a.service}\0${a.id}`.localeCompare(`${b.service}\0${b.id}`));
|
|
229
|
+
return {
|
|
230
|
+
root,
|
|
231
|
+
context,
|
|
232
|
+
containers: matched,
|
|
233
|
+
fingerprint: fingerprintProject(context, matched)
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async #inspectComposeContainers(context , ids , signal ) {
|
|
238
|
+
const result = await this.#run(context, ['inspect', '--format', INSPECT_FORMAT, ...ids], 30_000, signal);
|
|
239
|
+
const containers = [];
|
|
240
|
+
for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) {
|
|
241
|
+
const fields = line.split('\t');
|
|
242
|
+
if (fields.length !== 7) throw new OperatorError('DOCKER_OUTPUT_INVALID', 'Docker inspect output had an unexpected shape.');
|
|
243
|
+
const parsed = fields.map((field) => {
|
|
244
|
+
try { return JSON.parse(field) ; } catch { throw new OperatorError('DOCKER_OUTPUT_INVALID', 'Docker inspect field was not valid JSON.'); }
|
|
245
|
+
});
|
|
246
|
+
const [id, rawName, image, state, project, service, workingDir] = parsed.map((value) => typeof value === 'string' ? value : '');
|
|
247
|
+
if (!/^[0-9a-f]{12,64}$/i.test(id) || !service || !workingDir) continue;
|
|
248
|
+
containers.push({
|
|
249
|
+
id,
|
|
250
|
+
name: rawName.replace(/^\//, '').slice(0, 256),
|
|
251
|
+
image: image.slice(0, 512),
|
|
252
|
+
state: state.toLowerCase().slice(0, 64),
|
|
253
|
+
project: project.slice(0, 256),
|
|
254
|
+
service: service.slice(0, 256),
|
|
255
|
+
workingDir
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return containers;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async #run(context , args , timeoutMs , signal ) {
|
|
262
|
+
return this.#runRaw(['--context', context.name, ...args], timeoutMs, signal);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async #runRaw(args , timeoutMs , signal ) {
|
|
266
|
+
return await runDocker(this.#dockerExecutable, [...this.#dockerArgsPrefix, ...args], timeoutMs, signal);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function boundedInteger(input , fallback , min , max ) {
|
|
271
|
+
const value = Number(input ?? fallback);
|
|
272
|
+
if (!Number.isFinite(value)) return fallback;
|
|
273
|
+
return Math.min(Math.max(Math.trunc(value), min), max);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function validateServices(input ) {
|
|
277
|
+
if (!Array.isArray(input) || input.length === 0 || input.length > MAX_SERVICES) {
|
|
278
|
+
throw new OperatorError('DOCKER_SERVICES_REQUIRED', `services must contain 1-${MAX_SERVICES} Compose service names.`);
|
|
279
|
+
}
|
|
280
|
+
const services = [...new Set(input.map(String))];
|
|
281
|
+
for (const service of services) {
|
|
282
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(service)) {
|
|
283
|
+
throw new OperatorError('INVALID_DOCKER_SERVICE', 'Docker service names must use bounded alphanumeric/._- characters.');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return services;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function publicContainer(container ) {
|
|
290
|
+
return {
|
|
291
|
+
id: container.id.slice(0, 12),
|
|
292
|
+
name: container.name,
|
|
293
|
+
image: container.image,
|
|
294
|
+
state: container.state,
|
|
295
|
+
project: container.project,
|
|
296
|
+
service: container.service
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function summarizeServices(containers ) {
|
|
301
|
+
const grouped = new Map ();
|
|
302
|
+
for (const container of containers) grouped.set(container.service, [...(grouped.get(container.service) ?? []), container]);
|
|
303
|
+
return [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([service, items]) => ({
|
|
304
|
+
service,
|
|
305
|
+
containers: items.length,
|
|
306
|
+
states: [...new Set(items.map((item) => item.state))].sort()
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function verifyLifecyclePostcondition(operation , services , containers ) {
|
|
311
|
+
for (const service of services) {
|
|
312
|
+
const states = containers.filter((container) => container.service === service).map((container) => container.state);
|
|
313
|
+
if (states.length === 0) throw new OperatorError('DOCKER_POSTCONDITION_FAILED', `Service ${service} disappeared after Docker ${operation}.`);
|
|
314
|
+
const valid = operation === 'stop'
|
|
315
|
+
? states.every((state) => state === 'exited')
|
|
316
|
+
: states.every((state) => state === 'running');
|
|
317
|
+
if (!valid) {
|
|
318
|
+
throw new OperatorError('DOCKER_POSTCONDITION_FAILED', `Service ${service} did not reach the expected state after Docker ${operation}.`, {
|
|
319
|
+
details: { service, operation, states }
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function fingerprintProject(context , containers ) {
|
|
326
|
+
const payload = JSON.stringify({
|
|
327
|
+
context,
|
|
328
|
+
containers: containers.map(({ id, name, image, state, project, service }) => ({ id, name, image, state, project, service }))
|
|
329
|
+
});
|
|
330
|
+
return crypto.createHash('sha256').update(payload).digest('hex');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function sameLocalPath(candidate , root ) {
|
|
334
|
+
if (!path.isAbsolute(candidate)) return false;
|
|
335
|
+
const left = path.normalize(path.resolve(candidate));
|
|
336
|
+
const right = path.normalize(path.resolve(root));
|
|
337
|
+
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function isLocalDockerHost(host ) {
|
|
341
|
+
if (/^unix:\/\/\//i.test(host)) return path.posix.isAbsolute(host.slice('unix://'.length));
|
|
342
|
+
if (/^npipe:\/\//i.test(host)) {
|
|
343
|
+
const pipePath = host.slice('npipe://'.length).replace(/\\/g, '/');
|
|
344
|
+
return /^(?:\/\/)?\.\/pipe\/[A-Za-z0-9_.-]+$/i.test(pipePath);
|
|
345
|
+
}
|
|
346
|
+
if (/^tcp:\/\//i.test(host)) {
|
|
347
|
+
try {
|
|
348
|
+
const url = new URL(`http://${host.slice('tcp://'.length)}`);
|
|
349
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
350
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
|
351
|
+
} catch { return false; }
|
|
352
|
+
}
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function dockerEnvironment() {
|
|
357
|
+
const env = { COMPOSE_DISABLE_ENV_FILE: '1' };
|
|
358
|
+
for (const key of ['PATH', 'Path', 'HOME', 'USERPROFILE', 'LOCALAPPDATA', 'APPDATA', 'TMP', 'TEMP', 'SYSTEMROOT', 'WINDIR']) {
|
|
359
|
+
if (process.env[key] !== undefined) env[key] = process.env[key];
|
|
360
|
+
}
|
|
361
|
+
return env;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function runDocker(executable , args , timeoutMs , signal ) {
|
|
365
|
+
return await new Promise((resolve, reject) => {
|
|
366
|
+
if (signal?.aborted) {
|
|
367
|
+
reject(new OperatorError('EXECUTION_ABORTED', 'Docker execution was cancelled.', { retryable: false }));
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const environment = dockerEnvironment();
|
|
371
|
+
const dockerExecutable = resolveTrustedExecutable(executable, environment);
|
|
372
|
+
const child = spawn(dockerExecutable, args, {
|
|
373
|
+
shell: false,
|
|
374
|
+
windowsHide: true,
|
|
375
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
376
|
+
env: environment
|
|
377
|
+
});
|
|
378
|
+
const stdout = [];
|
|
379
|
+
const stderr = [];
|
|
380
|
+
let bytes = 0;
|
|
381
|
+
let truncated = false;
|
|
382
|
+
let timedOut = false;
|
|
383
|
+
let aborted = false;
|
|
384
|
+
let forceKillTimer ;
|
|
385
|
+
const capture = (bucket ) => (chunk ) => {
|
|
386
|
+
if (bytes >= MAX_OUTPUT_BYTES) { truncated = true; return; }
|
|
387
|
+
const sliced = chunk.subarray(0, MAX_OUTPUT_BYTES - bytes);
|
|
388
|
+
bucket.push(sliced);
|
|
389
|
+
bytes += sliced.byteLength;
|
|
390
|
+
if (sliced.byteLength < chunk.byteLength) truncated = true;
|
|
391
|
+
};
|
|
392
|
+
child.stdout.on('data', capture(stdout));
|
|
393
|
+
child.stderr.on('data', capture(stderr));
|
|
394
|
+
child.once('error', reject);
|
|
395
|
+
const terminate = () => {
|
|
396
|
+
try { child.kill('SIGTERM'); } catch { /* child may already be gone */ }
|
|
397
|
+
if (!forceKillTimer) {
|
|
398
|
+
forceKillTimer = setTimeout(() => {
|
|
399
|
+
try { child.kill('SIGKILL'); } catch { /* child may already be gone */ }
|
|
400
|
+
}, 1000);
|
|
401
|
+
forceKillTimer.unref();
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
const onAbort = () => { aborted = true; terminate(); };
|
|
405
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
406
|
+
if (signal?.aborted) onAbort();
|
|
407
|
+
const timer = setTimeout(() => {
|
|
408
|
+
timedOut = true;
|
|
409
|
+
terminate();
|
|
410
|
+
}, timeoutMs);
|
|
411
|
+
timer.unref();
|
|
412
|
+
child.once('close', (code) => {
|
|
413
|
+
clearTimeout(timer);
|
|
414
|
+
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
415
|
+
signal?.removeEventListener('abort', onAbort);
|
|
416
|
+
if (aborted) {
|
|
417
|
+
reject(new OperatorError('EXECUTION_ABORTED', 'Docker execution was cancelled.', { retryable: false }));
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (timedOut) {
|
|
421
|
+
reject(new OperatorError('DOCKER_TIMEOUT', `Docker command exceeded ${timeoutMs}ms timeout.`, { retryable: true }));
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const output = {
|
|
425
|
+
code: code ?? -1,
|
|
426
|
+
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
427
|
+
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
428
|
+
truncated
|
|
429
|
+
};
|
|
430
|
+
if (output.code !== 0) {
|
|
431
|
+
reject(new OperatorError('DOCKER_COMMAND_FAILED', output.stderr.trim().slice(0, 1200) || `Docker exited with code ${output.code}.`));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
resolve(output);
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function success(action , started , output , extraEvidence ) {
|
|
440
|
+
return {
|
|
441
|
+
ok: true,
|
|
442
|
+
capability: action.capability,
|
|
443
|
+
provider: 'docker.local.semantic',
|
|
444
|
+
output,
|
|
445
|
+
evidence: extraEvidence,
|
|
446
|
+
durationMs: Math.round(performance.now() - started)
|
|
447
|
+
};
|
|
448
|
+
}
|