machine-bridge-mcp 2.0.0 → 3.0.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/CONTRIBUTING.md +20 -25
- package/README.md +21 -14
- package/SECURITY.md +147 -123
- package/browser-extension/manifest.json +3 -2
- package/browser-extension/service-worker.js +31 -12
- package/docs/ARCHITECTURE.md +27 -13
- package/docs/AUDIT.md +85 -2
- package/docs/CLIENTS.md +2 -2
- package/docs/ENGINEERING.md +9 -3
- package/docs/GETTING_STARTED.md +10 -9
- package/docs/LOCAL_AUTHORIZATION.md +115 -72
- package/docs/LOGGING.md +12 -3
- package/docs/MANAGED_JOBS.md +24 -29
- package/docs/MULTI_ACCOUNT.md +98 -37
- package/docs/OPERATIONS.md +33 -22
- package/docs/OVERVIEW.md +7 -7
- package/docs/PROJECT_STANDARDS.md +9 -5
- package/docs/RELEASING.md +171 -64
- package/docs/TESTING.md +19 -7
- package/docs/THREAT_MODEL.md +138 -67
- package/docs/TOOL_REFERENCE.md +2 -2
- package/docs/UPGRADING.md +102 -18
- package/native/macos/MachineBridgeTrustBroker.swift +233 -0
- package/package.json +36 -5
- package/scripts/candidate-runtime-store.mjs +38 -0
- package/scripts/check-plan.mjs +19 -0
- package/scripts/check-runner.mjs +4 -1
- package/scripts/commit-message-check.mjs +5 -2
- package/scripts/coverage-check.mjs +30 -2
- package/scripts/github-push.mjs +11 -0
- package/scripts/github-release.mjs +38 -7
- package/scripts/install-published-prerelease.mjs +167 -0
- package/scripts/local-release-acceptance.mjs +23 -12
- package/scripts/npm-publication-policy.mjs +16 -0
- package/scripts/prerelease-activation.mjs +79 -0
- package/scripts/privacy-check.mjs +7 -5
- package/scripts/promotion-digest.mjs +121 -0
- package/scripts/publish-npm.mjs +51 -0
- package/scripts/published-release.mjs +87 -0
- package/scripts/release-acceptance.mjs +30 -1
- package/scripts/release-candidate-manifest.mjs +40 -0
- package/scripts/release-channel.mjs +106 -0
- package/scripts/release-impact-check.mjs +12 -22
- package/scripts/release-soak.mjs +237 -0
- package/scripts/start-release-candidate.mjs +113 -7
- package/src/local/account-access.mjs +6 -0
- package/src/local/account-admin.mjs +34 -16
- package/src/local/agent-context.mjs +45 -42
- package/src/local/app-automation.mjs +9 -4
- package/src/local/authority-context.mjs +106 -0
- package/src/local/browser-bridge-http.mjs +4 -1
- package/src/local/browser-bridge.mjs +14 -9
- package/src/local/browser-broker-routes.mjs +4 -0
- package/src/local/browser-broker-server.mjs +5 -4
- package/src/local/browser-extension-identity.mjs +50 -0
- package/src/local/browser-extension-protocol.mjs +9 -4
- package/src/local/browser-operation-service.mjs +7 -0
- package/src/local/browser-pairing-http.mjs +36 -0
- package/src/local/browser-pairing-store.mjs +56 -47
- package/src/local/cli-account-admin.mjs +41 -7
- package/src/local/cli-activate.mjs +90 -0
- package/src/local/cli-approval.mjs +14 -58
- package/src/local/cli-local-admin.mjs +5 -15
- package/src/local/cli-options.mjs +11 -7
- package/src/local/cli-service.mjs +35 -5
- package/src/local/cli.mjs +112 -58
- package/src/local/daemon-process.mjs +6 -0
- package/src/local/delegated-process-sandbox.mjs +147 -0
- package/src/local/device-identity.mjs +157 -48
- package/src/local/device-root-provider.mjs +87 -0
- package/src/local/errors.mjs +25 -3
- package/src/local/git-service.mjs +16 -15
- package/src/local/job-runner.mjs +35 -27
- package/src/local/log.mjs +14 -1
- package/src/local/macos-trust-broker.mjs +320 -0
- package/src/local/managed-job-lock.mjs +18 -0
- package/src/local/managed-job-projection.mjs +9 -1
- package/src/local/managed-job-runner.mjs +13 -1
- package/src/local/managed-job-storage.mjs +2 -1
- package/src/local/managed-job-terminal.mjs +138 -0
- package/src/local/managed-jobs.mjs +88 -32
- package/src/local/operation-authorization.mjs +75 -239
- package/src/local/operation-risk.mjs +28 -0
- package/src/local/operation-state-lock.mjs +7 -86
- package/src/local/owner-state-lock.mjs +104 -0
- package/src/local/policy.mjs +19 -0
- package/src/local/process-execution.mjs +21 -9
- package/src/local/process-sessions.mjs +27 -17
- package/src/local/process-tracker.mjs +30 -6
- package/src/local/process-tree-ownership.mjs +59 -0
- package/src/local/process-tree.mjs +22 -18
- package/src/local/relay-connection.mjs +47 -35
- package/src/local/relay-diagnostics.mjs +65 -0
- package/src/local/remote-configuration.mjs +48 -0
- package/src/local/runtime-activation.mjs +141 -0
- package/src/local/runtime-diagnostics.mjs +21 -0
- package/src/local/runtime-relay.mjs +9 -8
- package/src/local/runtime-reporting.mjs +8 -3
- package/src/local/runtime-tool-handlers.mjs +8 -8
- package/src/local/runtime.mjs +73 -39
- package/src/local/secure-file.mjs +5 -1
- package/src/local/security-audit-log.mjs +204 -0
- package/src/local/service-convergence.mjs +17 -1
- package/src/local/service-ownership.mjs +17 -0
- package/src/local/service-restart-handoff.mjs +50 -0
- package/src/local/service-restart-scheduler.mjs +49 -0
- package/src/local/service-status.mjs +47 -0
- package/src/local/service.mjs +142 -43
- package/src/local/state-inventory.mjs +21 -3
- package/src/local/state.mjs +155 -17
- package/src/local/stdio.mjs +5 -0
- package/src/local/tool-executor.mjs +39 -6
- package/src/local/tool-result-boundary.mjs +51 -0
- package/src/local/tools.mjs +1 -0
- package/src/local/trusted-executable.mjs +53 -0
- package/src/local/trusted-git-executable.mjs +34 -0
- package/src/local/trusted-github-cli.mjs +24 -0
- package/src/local/windows-service.mjs +20 -0
- package/src/local/worker-deployment.mjs +3 -4
- package/src/local/worker-secret-file.mjs +2 -2
- package/src/local/workspace-file-service.mjs +34 -31
- package/src/shared/admin-auth.d.mts +2 -1
- package/src/shared/admin-auth.mjs +3 -3
- package/src/shared/device-session-auth.d.mts +20 -0
- package/src/shared/device-session-auth.mjs +51 -0
- package/src/shared/server-metadata.json +1 -1
- package/src/shared/tool-catalog.json +2 -2
- package/src/worker/account-admin.ts +23 -22
- package/src/worker/authority.ts +9 -2
- package/src/worker/daemon-auth.ts +45 -67
- package/src/worker/daemon-sockets.ts +12 -2
- package/src/worker/device-session-verifier.ts +129 -0
- package/src/worker/dpop.ts +151 -0
- package/src/worker/http.ts +47 -16
- package/src/worker/index.ts +53 -16
- package/src/worker/nonce-store.ts +1 -5
- package/src/worker/oauth-client-admin.ts +45 -0
- package/src/worker/oauth-controller.ts +55 -6
- package/src/worker/oauth-refresh-families.ts +21 -7
- package/src/worker/oauth-state.ts +6 -0
- package/src/worker/oauth-tokens.ts +28 -8
- package/src/worker/websocket-protocol.ts +8 -2
package/src/local/runtime.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { isRelayReadyContext } from "./relay-connection.mjs";
|
|
|
6
6
|
import { ProcessSessionManager } from "./process-sessions.mjs";
|
|
7
7
|
import { MAX_CONCURRENT_TOOL_CALLS } from "./execution-limits.mjs";
|
|
8
8
|
export { MAX_COMMAND_BYTES } from "./process-contract.mjs";
|
|
9
|
-
import { normalizePolicy, PolicyGate } from "./tools.mjs";
|
|
9
|
+
import { normalizePolicy, PolicyGate, toolNamesForPolicy } from "./tools.mjs";
|
|
10
10
|
import { publicError } from "./errors.mjs";
|
|
11
11
|
import { ProcessTracker } from "./process-tracker.mjs";
|
|
12
12
|
import { CallRegistry } from "./call-registry.mjs";
|
|
@@ -14,6 +14,7 @@ import { RuntimeObservability } from "./observability.mjs";
|
|
|
14
14
|
import { ToolExecutor } from "./tool-executor.mjs";
|
|
15
15
|
import { boundedErrorMessage, ProcessExecutionService } from "./process-execution.mjs";
|
|
16
16
|
import { GitService } from "./git-service.mjs";
|
|
17
|
+
import { createTrustedGitResolver } from "./trusted-git-executable.mjs";
|
|
17
18
|
import { LifecycleController } from "./lifecycle.mjs";
|
|
18
19
|
import { MAX_WRITE_BYTES, sha256, WorkspaceFileService } from "./workspace-file-service.mjs";
|
|
19
20
|
export { MAX_WRITE_BYTES, sha256 } from "./workspace-file-service.mjs";
|
|
@@ -32,6 +33,9 @@ import { buildProjectOverview, buildRuntimeInfo } from "./runtime-reporting.mjs"
|
|
|
32
33
|
import { diagnoseRuntime as runRuntimeDiagnostics } from "./runtime-diagnostics.mjs";
|
|
33
34
|
import { bindRuntimeToolHandlers, runtimeToolHandlerNames as registeredRuntimeToolHandlerNames } from "./runtime-tool-handlers.mjs";
|
|
34
35
|
import { OperationAuthorizer } from "./operation-authorization.mjs";
|
|
36
|
+
import { SecurityAuditLog } from "./security-audit-log.mjs";
|
|
37
|
+
import { delegatedProcessIsolationStatus } from "./delegated-process-sandbox.mjs";
|
|
38
|
+
import { policyForContext } from "./authority-context.mjs";
|
|
35
39
|
import { createRuntimeRelayConnection, normalizeRelayResumeCalls, normalizeRelayToolCall } from "./runtime-relay.mjs";
|
|
36
40
|
import { RelayCallRecovery } from "./relay-call-recovery.mjs";
|
|
37
41
|
import { assertContainedPath, createRuntimeDir, redactRuntimeErrorMessage, stateRootFromProfileStatePath } from "./runtime-paths.mjs";
|
|
@@ -47,7 +51,7 @@ export function runtimeToolHandlerNames() {
|
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
export class LocalRuntime {
|
|
50
|
-
constructor({ workerUrl = "", deviceIdentity = null, expectedRelayVersion = "", workspace, policy, logger = console, onSuperseded = null, onFatal = null, jobRoot = "", approvalRoot = "", resources = {}, resourceStatePath = "", browserStateRoot = "", agentHome = process.env.HOME || process.env.USERPROFILE || "", codexHome = process.env.CODEX_HOME || "", recoverJobs = true, applicationAutomation = {} }) {
|
|
54
|
+
constructor({ workerUrl = "", deviceIdentity = null, expectedRelayVersion = "", workspace, policy, logger = console, onSuperseded = null, onFatal = null, jobRoot = "", approvalRoot = "", resources = {}, resourceStatePath = "", browserStateRoot = "", agentHome = process.env.HOME || process.env.USERPROFILE || "", codexHome = process.env.CODEX_HOME || "", recoverJobs = true, applicationAutomation = {}, deviceRootStatus = null, resolveGitExecutable = null }) {
|
|
51
55
|
const remoteWorkerUrl = workerUrl ? String(workerUrl) : "";
|
|
52
56
|
this.workspaceInput = resolve(workspace || process.cwd());
|
|
53
57
|
this.workspace = realpathSync.native ? realpathSync.native(this.workspaceInput) : realpathSync(this.workspaceInput);
|
|
@@ -58,6 +62,7 @@ export class LocalRuntime {
|
|
|
58
62
|
this.logger = logger;
|
|
59
63
|
this.onSuperseded = typeof onSuperseded === "function" ? onSuperseded : null;
|
|
60
64
|
this.resourceStatePath = resourceStatePath ? resolve(resourceStatePath) : "";
|
|
65
|
+
this.deviceRootStatus = deviceRootStatus && typeof deviceRootStatus === "object" ? Object.freeze({ ...deviceRootStatus }) : null;
|
|
61
66
|
this.processTracker = new ProcessTracker();
|
|
62
67
|
this.lifecycle = new LifecycleController("local runtime");
|
|
63
68
|
this.observability = new RuntimeObservability();
|
|
@@ -80,13 +85,15 @@ export class LocalRuntime {
|
|
|
80
85
|
this.mutationQueue = Promise.resolve();
|
|
81
86
|
this.capabilityObserver = new CapabilityObserver();
|
|
82
87
|
this.runtimeDir = createRuntimeDir();
|
|
88
|
+
this.resolveGitExecutable = createTrustedGitResolver({ resolve: resolveGitExecutable, workspace: this.workspace, stateRoot: browserStateRoot, runtimeDir: this.runtimeDir, home: agentHome });
|
|
83
89
|
this.workspaceFileService = new WorkspaceFileService({
|
|
84
90
|
workspace: this.workspace,
|
|
85
91
|
policy: this.policy,
|
|
86
92
|
policyGate: this.policyGate,
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
policyForContext: (context) => this.effectivePolicy(context),
|
|
94
|
+
resolveExistingPath: (value, context) => this.resolveExistingPath(value, context),
|
|
95
|
+
resolveWritePath: (value, context) => this.resolveWritePath(value, context),
|
|
96
|
+
displayPath: (value, context) => this.displayPath(value, context),
|
|
90
97
|
throwIfCancelled: (context) => this.throwIfCancelled(context),
|
|
91
98
|
withMutationLock: (operation) => this.withMutationLock(operation),
|
|
92
99
|
});
|
|
@@ -96,6 +103,7 @@ export class LocalRuntime {
|
|
|
96
103
|
workspace: this.workspace,
|
|
97
104
|
policy: this.policy,
|
|
98
105
|
authorizeTool: (tool) => this.policyGate.assert(tool),
|
|
106
|
+
policyForContext: (context) => this.effectivePolicy(context),
|
|
99
107
|
resources,
|
|
100
108
|
resourceStatePath,
|
|
101
109
|
stateRoot: browserStateRoot,
|
|
@@ -106,21 +114,23 @@ export class LocalRuntime {
|
|
|
106
114
|
workspace: this.workspace,
|
|
107
115
|
policy: this.policy,
|
|
108
116
|
authorizeTool: (tool) => this.policyGate.assert(tool),
|
|
117
|
+
policyForContext: (context) => this.effectivePolicy(context),
|
|
109
118
|
runtimeDir: this.runtimeDir,
|
|
110
119
|
processTracker: this.processTracker,
|
|
111
|
-
resolveCwd: async (input) => {
|
|
112
|
-
const cwd = await this.resolveExistingPath(input);
|
|
120
|
+
resolveCwd: async (input, context) => {
|
|
121
|
+
const cwd = await this.resolveExistingPath(input, context);
|
|
113
122
|
if (!(await stat(cwd)).isDirectory()) throw new Error("cwd is not a directory");
|
|
114
123
|
return cwd;
|
|
115
124
|
},
|
|
116
|
-
displayPath: (value) => this.displayPath(value),
|
|
125
|
+
displayPath: (value, context) => this.displayPath(value, context),
|
|
117
126
|
throwIfCancelled: (context) => this.throwIfCancelled(context),
|
|
118
127
|
});
|
|
119
128
|
this.agentContextManager = new AgentContextManager({
|
|
120
129
|
workspace: this.workspace,
|
|
121
130
|
policy: this.policy,
|
|
122
|
-
|
|
123
|
-
|
|
131
|
+
policyForContext: (context) => this.effectivePolicy(context),
|
|
132
|
+
displayPath: (value, context) => this.displayPath(value, context),
|
|
133
|
+
resolveExistingPath: (value, context) => this.resolveExistingPath(value, context),
|
|
124
134
|
throwIfCancelled: (context) => this.throwIfCancelled(context),
|
|
125
135
|
home: agentHome,
|
|
126
136
|
codexHome,
|
|
@@ -129,18 +139,20 @@ export class LocalRuntime {
|
|
|
129
139
|
workspace: this.workspace,
|
|
130
140
|
policy: this.policy,
|
|
131
141
|
policyGate: this.policyGate,
|
|
142
|
+
policyForContext: (context) => this.effectivePolicy(context),
|
|
132
143
|
runtimeDir: this.runtimeDir,
|
|
133
144
|
processTracker: this.processTracker,
|
|
134
|
-
resolveExistingPath: (value) => this.resolveExistingPath(value),
|
|
145
|
+
resolveExistingPath: (value, context) => this.resolveExistingPath(value, context),
|
|
135
146
|
resolveLocalCommand: (args, context) => this.agentContextManager.resolveLocalCommand(args, context),
|
|
136
|
-
displayPath: (value) => this.displayPath(value),
|
|
147
|
+
displayPath: (value, context) => this.displayPath(value, context),
|
|
137
148
|
throwIfCancelled: (context) => this.throwIfCancelled(context),
|
|
138
|
-
retainCompletedOutput: (value) => this.processSessionManager.retainCompletedOutput(value),
|
|
149
|
+
retainCompletedOutput: (value, context) => this.processSessionManager.retainCompletedOutput(value, context),
|
|
139
150
|
});
|
|
140
151
|
this.gitService = new GitService({
|
|
141
|
-
resolveExistingPath: (value) => this.resolveExistingPath(value),
|
|
142
|
-
displayPath: (value) => this.displayPath(value),
|
|
143
|
-
|
|
152
|
+
resolveExistingPath: (value, context) => this.resolveExistingPath(value, context),
|
|
153
|
+
displayPath: (value, context) => this.displayPath(value, context),
|
|
154
|
+
runInternalProcess: (...args) => this.processExecutionService.runFixedInternal(...args),
|
|
155
|
+
gitExecutable: () => this.resolveGitExecutable(),
|
|
144
156
|
maximumBytes: MAX_WRITE_BYTES,
|
|
145
157
|
});
|
|
146
158
|
const runProcess = (cmd, argv, timeoutMs, allowFailure, maxOutputBytes, context, cwd, stdin) => this.runProcess(cmd, argv, timeoutMs, allowFailure, maxOutputBytes, context, cwd, stdin);
|
|
@@ -150,16 +162,18 @@ export class LocalRuntime {
|
|
|
150
162
|
...applicationAutomation,
|
|
151
163
|
policy: this.policy,
|
|
152
164
|
authorizeTool: (tool) => this.policyGate.assert(tool),
|
|
153
|
-
displayPath: (value) => this.displayPath(value),
|
|
165
|
+
displayPath: (value, context) => this.displayPath(value, context),
|
|
154
166
|
runProcess,
|
|
155
167
|
readResourceText,
|
|
156
168
|
throwIfCancelled: (context) => this.throwIfCancelled(context),
|
|
157
169
|
});
|
|
170
|
+
this.securityAudit = new SecurityAuditLog({ root: approvalRoot });
|
|
158
171
|
this.operationAuthorizer = new OperationAuthorizer({
|
|
159
172
|
workspace: this.workspace,
|
|
160
173
|
root: approvalRoot,
|
|
161
|
-
resolveExistingPath: (value) => this.resolveExistingPath(value),
|
|
162
|
-
resolveWritePath: (value) => this.resolveWritePath(value),
|
|
174
|
+
resolveExistingPath: (value, context) => this.resolveExistingPath(value, context),
|
|
175
|
+
resolveWritePath: (value, context) => this.resolveWritePath(value, context),
|
|
176
|
+
protectedRoots: [approvalRoot, browserStateRoot],
|
|
163
177
|
});
|
|
164
178
|
this.browserBridgeManager = new BrowserBridgeManager({
|
|
165
179
|
policy: this.policy,
|
|
@@ -178,8 +192,9 @@ export class LocalRuntime {
|
|
|
178
192
|
operationAuthorizer: this.operationAuthorizer,
|
|
179
193
|
callRegistry: this.callRegistry,
|
|
180
194
|
observability: this.observability,
|
|
195
|
+
securityAudit: this.securityAudit,
|
|
181
196
|
logger: this.logger,
|
|
182
|
-
safeMessage: (error, args) => this.safeErrorMessage(error, args),
|
|
197
|
+
safeMessage: (error, args, context) => this.safeErrorMessage(error, args, context),
|
|
183
198
|
slowMs: SLOW_TOOL_CALL_MS,
|
|
184
199
|
});
|
|
185
200
|
this.relay = createRuntimeRelayConnection(this, {
|
|
@@ -198,11 +213,11 @@ export class LocalRuntime {
|
|
|
198
213
|
|
|
199
214
|
tools() { return this.policyGate.names().filter((name) => name !== "server_info"); }
|
|
200
215
|
|
|
201
|
-
runtimeInfo() {
|
|
202
|
-
|
|
216
|
+
runtimeInfo(context = {}) {
|
|
217
|
+
const info = buildRuntimeInfo({
|
|
203
218
|
workspace: this.workspace,
|
|
204
|
-
displayPath: (value) => this.displayPath(value),
|
|
205
|
-
policy: this.
|
|
219
|
+
displayPath: (value) => this.displayPath(value, context),
|
|
220
|
+
policy: this.effectivePolicy(context),
|
|
206
221
|
toolNames: this.tools(),
|
|
207
222
|
capabilityObserver: this.capabilityObserver,
|
|
208
223
|
observability: this.observability,
|
|
@@ -214,6 +229,16 @@ export class LocalRuntime {
|
|
|
214
229
|
processSessionManager: this.processSessionManager,
|
|
215
230
|
managedJobManager: this.managedJobManager,
|
|
216
231
|
});
|
|
232
|
+
return {
|
|
233
|
+
...info,
|
|
234
|
+
security_audit: this.securityAudit.snapshot(),
|
|
235
|
+
trust: {
|
|
236
|
+
device_root: this.deviceRootStatus,
|
|
237
|
+
daemon_session: { ephemeral: true, certificate_lifetime_seconds: 86400, reconnect_prompts: false },
|
|
238
|
+
delegated_process_isolation: delegatedProcessIsolationStatus(),
|
|
239
|
+
routine_operation_prompts: false,
|
|
240
|
+
},
|
|
241
|
+
};
|
|
217
242
|
}
|
|
218
243
|
|
|
219
244
|
async start() {
|
|
@@ -427,20 +452,24 @@ export class LocalRuntime {
|
|
|
427
452
|
}
|
|
428
453
|
|
|
429
454
|
async projectOverview(context = {}) {
|
|
455
|
+
const effectivePolicy = this.effectivePolicy(context);
|
|
430
456
|
return buildProjectOverview({
|
|
431
457
|
workspace: this.workspace,
|
|
432
|
-
displayPath: (value) => this.displayPath(value),
|
|
433
|
-
policy:
|
|
434
|
-
toolNames:
|
|
458
|
+
displayPath: (value) => this.displayPath(value, context),
|
|
459
|
+
policy: effectivePolicy,
|
|
460
|
+
toolNames: toolNamesForPolicy(effectivePolicy).filter((name) => name !== "server_info"),
|
|
461
|
+
daemonPolicy: this.policy,
|
|
462
|
+
daemonToolNames: this.tools(),
|
|
435
463
|
capabilityObserver: this.capabilityObserver,
|
|
436
464
|
listTopLevel: (callContext) => this.listDir(".", callContext),
|
|
437
|
-
|
|
438
|
-
|
|
465
|
+
runInternalProcess: (...args) => this.processExecutionService.runFixedInternal(...args),
|
|
466
|
+
gitExecutable: () => this.resolveGitExecutable(),
|
|
467
|
+
safeErrorMessage: (error) => this.safeErrorMessage(error, {}, context),
|
|
439
468
|
throwIfCancelled: (callContext) => this.throwIfCancelled(callContext),
|
|
440
469
|
}, context);
|
|
441
470
|
}
|
|
442
471
|
|
|
443
|
-
listRoots() { return this.workspaceFileService.listRoots(); }
|
|
472
|
+
listRoots(context = {}) { return this.workspaceFileService.listRoots(context); }
|
|
444
473
|
|
|
445
474
|
listDir(pathValue, context = {}) { return this.workspaceFileService.listDir(pathValue, context); }
|
|
446
475
|
|
|
@@ -482,6 +511,7 @@ export class LocalRuntime {
|
|
|
482
511
|
runProcess: (...args) => this.runProcess(...args),
|
|
483
512
|
probeShell: (callContext) => this.processExecutionService.probeShell(callContext),
|
|
484
513
|
managedJobManager: this.managedJobManager,
|
|
514
|
+
relayStatus: () => this.relay?.status?.() || null,
|
|
485
515
|
throwIfCancelled: (callContext) => this.throwIfCancelled(callContext),
|
|
486
516
|
}, context);
|
|
487
517
|
}
|
|
@@ -577,6 +607,10 @@ export class LocalRuntime {
|
|
|
577
607
|
return this.processExecutionService.run(cmd, args, timeoutMs, allowFailure, maxOutputBytes, context, cwd, stdin);
|
|
578
608
|
}
|
|
579
609
|
|
|
610
|
+
effectivePolicy(context = {}) {
|
|
611
|
+
return policyForContext(context, this.policy);
|
|
612
|
+
}
|
|
613
|
+
|
|
580
614
|
resolvePath(inputPath = ".") {
|
|
581
615
|
const raw = String(inputPath || ".");
|
|
582
616
|
if (raw.includes("\0")) throw new Error("path contains a NUL byte");
|
|
@@ -599,14 +633,14 @@ export class LocalRuntime {
|
|
|
599
633
|
return this.workspaceCanonicalPromise;
|
|
600
634
|
}
|
|
601
635
|
|
|
602
|
-
async resolveExistingPath(inputPath = ".") {
|
|
636
|
+
async resolveExistingPath(inputPath = ".", context = {}) {
|
|
603
637
|
const candidate = this.resolvePath(inputPath);
|
|
604
638
|
const [workspace, canonical] = await Promise.all([this.canonicalWorkspace(), realpath(candidate)]);
|
|
605
|
-
if (!this.
|
|
639
|
+
if (!this.effectivePolicy(context).unrestrictedPaths) assertContainedPath(workspace, canonical);
|
|
606
640
|
return canonical;
|
|
607
641
|
}
|
|
608
642
|
|
|
609
|
-
async resolveWritePath(inputPath = ".") {
|
|
643
|
+
async resolveWritePath(inputPath = ".", context = {}) {
|
|
610
644
|
const candidate = this.resolvePath(inputPath);
|
|
611
645
|
const candidateInfo = await lstat(candidate).catch(() => null);
|
|
612
646
|
if (candidateInfo?.isSymbolicLink()) throw new Error("refusing to overwrite a symbolic link");
|
|
@@ -617,23 +651,23 @@ export class LocalRuntime {
|
|
|
617
651
|
ancestor = parent;
|
|
618
652
|
}
|
|
619
653
|
const [workspace, canonicalAncestor] = await Promise.all([this.canonicalWorkspace(), realpath(ancestor)]);
|
|
620
|
-
if (!this.
|
|
654
|
+
if (!this.effectivePolicy(context).unrestrictedPaths) assertContainedPath(workspace, canonicalAncestor);
|
|
621
655
|
const suffix = relative(ancestor, candidate);
|
|
622
656
|
return suffix ? resolve(canonicalAncestor, suffix) : canonicalAncestor;
|
|
623
657
|
}
|
|
624
658
|
|
|
625
|
-
displayPath(fullPath) {
|
|
659
|
+
displayPath(fullPath, context = {}) {
|
|
626
660
|
const absolute = resolve(fullPath);
|
|
627
|
-
if (this.
|
|
661
|
+
if (this.effectivePolicy(context).exposeAbsolutePaths) return absolute;
|
|
628
662
|
const shown = relative(this.workspace, absolute);
|
|
629
663
|
const insideWorkspace = shown === "" || (!shown.startsWith(`..${sep}`) && shown !== ".." && !isAbsolute(shown));
|
|
630
664
|
if (insideWorkspace) return shown ? shown.split(sep).join("/") : ".";
|
|
631
665
|
return `<external-path:${sha256(absolute).slice(0, 12)}>`;
|
|
632
666
|
}
|
|
633
667
|
|
|
634
|
-
safeErrorMessage(error, toolArgs = {}) {
|
|
668
|
+
safeErrorMessage(error, toolArgs = {}, context = {}) {
|
|
635
669
|
const message = boundedErrorMessage(error);
|
|
636
|
-
if (this.
|
|
670
|
+
if (this.effectivePolicy(context).exposeAbsolutePaths) return message;
|
|
637
671
|
return redactRuntimeErrorMessage(message, {
|
|
638
672
|
error,
|
|
639
673
|
toolArgs,
|
|
@@ -641,7 +675,7 @@ export class LocalRuntime {
|
|
|
641
675
|
workspaceInput: this.workspaceInput,
|
|
642
676
|
runtimeDir: this.runtimeDir,
|
|
643
677
|
home: process.env.HOME || process.env.USERPROFILE || "",
|
|
644
|
-
displayPath: (value) => this.displayPath(value),
|
|
678
|
+
displayPath: (value) => this.displayPath(value, context),
|
|
645
679
|
});
|
|
646
680
|
}
|
|
647
681
|
|
|
@@ -16,6 +16,9 @@ export function openRegularFileSync(file, flags, options = {}) {
|
|
|
16
16
|
try {
|
|
17
17
|
const info = fstatSync(fd);
|
|
18
18
|
if (!info.isFile()) throw new Error(`${label} is not a regular file`);
|
|
19
|
+
if (options.rejectMultipleLinks === true && Number(info.nlink) > 1) {
|
|
20
|
+
throw new Error(`${label} must not have multiple hard links`);
|
|
21
|
+
}
|
|
19
22
|
if (options.verifyPathIdentity === true) {
|
|
20
23
|
const pathInfo = lstatSync(file);
|
|
21
24
|
if (pathInfo.isSymbolicLink() || !pathInfo.isFile()) throw new Error(`${label} must be a regular file and not a symbolic link`);
|
|
@@ -39,7 +42,7 @@ function withRegularFileSync(file, flags, options, callback) {
|
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
export function chmodRegularFileSync(file, mode, label = "path") {
|
|
42
|
-
return withRegularFileSync(file, fsConstants.O_RDONLY, { label, chmod: mode }, () => undefined);
|
|
45
|
+
return withRegularFileSync(file, fsConstants.O_RDONLY, { label, chmod: mode, rejectMultipleLinks: true }, () => undefined);
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
export function readBoundedRegularFileSync(file, maxBytes, label = "path", options = {}) {
|
|
@@ -52,6 +55,7 @@ export function readBoundedRegularFileWithInfoSync(file, maxBytes, label = "path
|
|
|
52
55
|
return withRegularFileSync(file, fsConstants.O_RDONLY, {
|
|
53
56
|
label,
|
|
54
57
|
verifyPathIdentity: options.verifyPathIdentity === true,
|
|
58
|
+
rejectMultipleLinks: options.rejectMultipleLinks === true,
|
|
55
59
|
}, (fd, info) => {
|
|
56
60
|
if (info.size > limit) throw new Error(`file exceeds ${limit} bytes`);
|
|
57
61
|
options.afterOpen?.({ fd, info });
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
5
|
+
import { ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
6
|
+
import { withOwnerStateLock } from "./owner-state-lock.mjs";
|
|
7
|
+
|
|
8
|
+
const SCHEMA_VERSION = 1;
|
|
9
|
+
const MAX_EVENTS = 4096;
|
|
10
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
11
|
+
const HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
12
|
+
|
|
13
|
+
export class SecurityAuditLog {
|
|
14
|
+
constructor({ root = "", now = Date.now } = {}) {
|
|
15
|
+
this.root = root ? path.resolve(root) : "";
|
|
16
|
+
this.file = this.root ? path.join(this.root, "security-audit.json") : "";
|
|
17
|
+
this.now = typeof now === "function" ? now : Date.now;
|
|
18
|
+
this.queue = Promise.resolve();
|
|
19
|
+
this.lastError = "";
|
|
20
|
+
if (this.file && existsSync(this.file)) {
|
|
21
|
+
try { this.readVerifiedState(); } catch (error) { this.lastError = errorClass(error); }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
record(input = {}) {
|
|
26
|
+
if (!this.file) return Promise.resolve(false);
|
|
27
|
+
const operation = async () => {
|
|
28
|
+
try {
|
|
29
|
+
return await withOwnerStateLock(this.root, async () => {
|
|
30
|
+
const state = this.readVerifiedState();
|
|
31
|
+
const event = buildEvent(input, state, this.now());
|
|
32
|
+
state.events.push(event);
|
|
33
|
+
while (state.events.length > MAX_EVENTS) {
|
|
34
|
+
const removed = state.events.shift();
|
|
35
|
+
state.anchor = removed.hash;
|
|
36
|
+
}
|
|
37
|
+
writeState(this.file, state);
|
|
38
|
+
this.lastError = "";
|
|
39
|
+
return true;
|
|
40
|
+
}, {
|
|
41
|
+
purpose: "security-audit", fileName: "security-audit.lock", label: "security audit",
|
|
42
|
+
});
|
|
43
|
+
} catch (error) {
|
|
44
|
+
this.lastError = errorClass(error);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const result = this.queue.then(operation, operation);
|
|
49
|
+
this.queue = result.then(() => undefined, () => undefined);
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
snapshot() {
|
|
54
|
+
if (!this.file) return { enabled: false, healthy: true, retained: 0, maximum: MAX_EVENTS };
|
|
55
|
+
try {
|
|
56
|
+
const state = this.readVerifiedState();
|
|
57
|
+
return {
|
|
58
|
+
enabled: true,
|
|
59
|
+
healthy: !this.lastError,
|
|
60
|
+
retained: state.events.length,
|
|
61
|
+
maximum: MAX_EVENTS,
|
|
62
|
+
last_event_at: state.events.at(-1)?.timestamp || null,
|
|
63
|
+
last_error_class: this.lastError || null,
|
|
64
|
+
content_logged: false,
|
|
65
|
+
chain_verified: true,
|
|
66
|
+
};
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return {
|
|
69
|
+
enabled: true,
|
|
70
|
+
healthy: false,
|
|
71
|
+
retained: 0,
|
|
72
|
+
maximum: MAX_EVENTS,
|
|
73
|
+
last_event_at: null,
|
|
74
|
+
last_error_class: errorClass(error),
|
|
75
|
+
content_logged: false,
|
|
76
|
+
chain_verified: false,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
readVerifiedState() {
|
|
82
|
+
if (!existsSync(this.file)) return emptyState();
|
|
83
|
+
const raw = readBoundedRegularFileSync(this.file, MAX_BYTES, "security audit state");
|
|
84
|
+
let state;
|
|
85
|
+
try { state = JSON.parse(raw.toString("utf8")); } catch (error) {
|
|
86
|
+
throw new Error("security audit state is not valid JSON", { cause: error });
|
|
87
|
+
}
|
|
88
|
+
validateState(state);
|
|
89
|
+
let previous = state.anchor;
|
|
90
|
+
for (const event of state.events) {
|
|
91
|
+
if (event.previous_hash !== previous || event.hash !== eventHash(event)) {
|
|
92
|
+
throw new Error("security audit hash chain verification failed");
|
|
93
|
+
}
|
|
94
|
+
previous = event.hash;
|
|
95
|
+
}
|
|
96
|
+
return state;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildEvent(input, state, nowMs) {
|
|
101
|
+
const timestamp = new Date(Number(nowMs)).toISOString();
|
|
102
|
+
if (!Number.isFinite(Date.parse(timestamp))) throw new Error("security audit timestamp is invalid");
|
|
103
|
+
const principal = input.principal && typeof input.principal === "object" ? input.principal : {};
|
|
104
|
+
const event = {
|
|
105
|
+
sequence: state.next_sequence,
|
|
106
|
+
timestamp,
|
|
107
|
+
outcome: boundedToken(input.outcome, "unknown"),
|
|
108
|
+
tool: boundedToken(input.tool, "unknown"),
|
|
109
|
+
risk_category: boundedText(input.riskCategory, "ordinary operation", 160),
|
|
110
|
+
target_hash: HASH_PATTERN.test(String(input.targetHash || "")) ? String(input.targetHash) : null,
|
|
111
|
+
account_ref: principal.accountId ? privateReference(state.identity_salt, principal.accountId) : null,
|
|
112
|
+
client_ref: principal.clientId ? privateReference(state.identity_salt, principal.clientId) : null,
|
|
113
|
+
family_ref: principal.familyId ? privateReference(state.identity_salt, principal.familyId) : null,
|
|
114
|
+
account_version: Number.isSafeInteger(principal.accountVersion) ? principal.accountVersion : null,
|
|
115
|
+
role: boundedToken(principal.role, principal.kind === "local" ? "local" : "unknown"),
|
|
116
|
+
duration_ms: boundedNumber(input.durationMs),
|
|
117
|
+
input_bytes: boundedNumber(input.inputBytes),
|
|
118
|
+
output_bytes: boundedNumber(input.outputBytes),
|
|
119
|
+
error_code: input.errorCode ? boundedToken(input.errorCode, "unknown") : null,
|
|
120
|
+
previous_hash: state.events.at(-1)?.hash || state.anchor,
|
|
121
|
+
};
|
|
122
|
+
const completed = { ...event, hash: eventHash(event) };
|
|
123
|
+
state.next_sequence += 1;
|
|
124
|
+
return completed;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function emptyState() {
|
|
128
|
+
return {
|
|
129
|
+
schemaVersion: SCHEMA_VERSION,
|
|
130
|
+
identity_salt: randomBytes(32).toString("hex"),
|
|
131
|
+
anchor: randomBytes(32).toString("hex"),
|
|
132
|
+
next_sequence: 1,
|
|
133
|
+
events: [],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validateState(state) {
|
|
138
|
+
if (!plainRecord(state) || state.schemaVersion !== SCHEMA_VERSION) throw new Error("security audit state schema is invalid");
|
|
139
|
+
if (!HASH_PATTERN.test(state.identity_salt) || !HASH_PATTERN.test(state.anchor)) throw new Error("security audit state identity is invalid");
|
|
140
|
+
if (!Number.isSafeInteger(state.next_sequence) || state.next_sequence < 1) throw new Error("security audit sequence is invalid");
|
|
141
|
+
if (!Array.isArray(state.events) || state.events.length > MAX_EVENTS || !state.events.every(validEvent)) {
|
|
142
|
+
throw new Error("security audit events are invalid");
|
|
143
|
+
}
|
|
144
|
+
if (state.events.length && state.next_sequence <= state.events.at(-1).sequence) throw new Error("security audit sequence did not advance");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function validEvent(event) {
|
|
148
|
+
return plainRecord(event)
|
|
149
|
+
&& Number.isSafeInteger(event.sequence)
|
|
150
|
+
&& event.sequence > 0
|
|
151
|
+
&& Number.isFinite(Date.parse(String(event.timestamp || "")))
|
|
152
|
+
&& typeof event.outcome === "string"
|
|
153
|
+
&& typeof event.tool === "string"
|
|
154
|
+
&& typeof event.risk_category === "string"
|
|
155
|
+
&& (event.target_hash === null || HASH_PATTERN.test(event.target_hash))
|
|
156
|
+
&& ["account_ref", "client_ref", "family_ref"].every((key) => event[key] === null || HASH_PATTERN.test(event[key]))
|
|
157
|
+
&& (event.account_version === null || Number.isSafeInteger(event.account_version))
|
|
158
|
+
&& typeof event.role === "string"
|
|
159
|
+
&& Number.isFinite(event.duration_ms)
|
|
160
|
+
&& Number.isFinite(event.input_bytes)
|
|
161
|
+
&& Number.isFinite(event.output_bytes)
|
|
162
|
+
&& (event.error_code === null || typeof event.error_code === "string")
|
|
163
|
+
&& HASH_PATTERN.test(event.previous_hash)
|
|
164
|
+
&& HASH_PATTERN.test(event.hash);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function eventHash(event) {
|
|
168
|
+
const value = { ...event };
|
|
169
|
+
delete value.hash;
|
|
170
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function privateReference(salt, value) {
|
|
174
|
+
return createHash("sha256").update(salt).update("\0").update(String(value)).digest("hex");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function writeState(file, state) {
|
|
178
|
+
ensureOwnerOnlyDirectorySync(path.dirname(file));
|
|
179
|
+
const content = `${JSON.stringify(state)}\n`;
|
|
180
|
+
if (Buffer.byteLength(content) > MAX_BYTES) throw new Error("security audit state exceeds its size limit");
|
|
181
|
+
replaceFileAtomicallySync(file, content, { mode: 0o600 });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function boundedToken(value, fallback) {
|
|
185
|
+
const text = String(value || fallback).replace(/[^A-Za-z0-9._:-]/g, "_").slice(0, 128);
|
|
186
|
+
return text || fallback;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function boundedText(value, fallback, maximum) {
|
|
190
|
+
return String(value || fallback).replace(/[\r\n\t\u0000-\u001f\u007f]/g, " ").trim().slice(0, maximum) || fallback;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function boundedNumber(value) {
|
|
194
|
+
const number = Number(value);
|
|
195
|
+
return Number.isFinite(number) && number >= 0 ? Math.min(Math.floor(number), Number.MAX_SAFE_INTEGER) : 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function errorClass(error) {
|
|
199
|
+
return String(error?.code || error?.name || "audit_error").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function plainRecord(value) {
|
|
203
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
204
|
+
}
|
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
const DEFAULT_ATTEMPTS = 20;
|
|
2
2
|
const DEFAULT_DELAY_MS = 100;
|
|
3
3
|
|
|
4
|
+
export async function waitForActiveStatus(
|
|
5
|
+
readStatus,
|
|
6
|
+
{ attempts = DEFAULT_ATTEMPTS, delayMs = DEFAULT_DELAY_MS, sleep = delay } = {},
|
|
7
|
+
) {
|
|
8
|
+
return waitForStatus(readStatus, (status) => status?.active === true, { attempts, delayMs, sleep });
|
|
9
|
+
}
|
|
10
|
+
|
|
4
11
|
export async function waitForInactiveStatus(
|
|
5
12
|
readStatus,
|
|
6
13
|
{ attempts = DEFAULT_ATTEMPTS, delayMs = DEFAULT_DELAY_MS, sleep = delay } = {},
|
|
14
|
+
) {
|
|
15
|
+
return waitForStatus(readStatus, (status) => status?.active !== true, { attempts, delayMs, sleep });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function waitForStatus(
|
|
19
|
+
readStatus,
|
|
20
|
+
predicate,
|
|
21
|
+
{ attempts = DEFAULT_ATTEMPTS, delayMs = DEFAULT_DELAY_MS, sleep = delay } = {},
|
|
7
22
|
) {
|
|
8
23
|
if (typeof readStatus !== "function") throw new TypeError("readStatus must be a function");
|
|
24
|
+
if (typeof predicate !== "function") throw new TypeError("predicate must be a function");
|
|
9
25
|
const maximum = Number.isInteger(attempts) && attempts > 0 ? attempts : DEFAULT_ATTEMPTS;
|
|
10
26
|
let status = await readStatus();
|
|
11
|
-
for (let attempt = 1; status
|
|
27
|
+
for (let attempt = 1; !predicate(status) && attempt < maximum; attempt += 1) {
|
|
12
28
|
await sleep(delayMs);
|
|
13
29
|
status = await readStatus();
|
|
14
30
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export async function stopOwnedPlatformService({
|
|
2
|
+
state,
|
|
3
|
+
inspectWorkspaceDaemon,
|
|
4
|
+
ownsPlatformAutostart,
|
|
5
|
+
stopPlatformService,
|
|
6
|
+
} = {}) {
|
|
7
|
+
if (!state) throw new TypeError("service ownership check requires state");
|
|
8
|
+
if (typeof inspectWorkspaceDaemon !== "function") throw new TypeError("service ownership check requires inspectWorkspaceDaemon");
|
|
9
|
+
if (typeof ownsPlatformAutostart !== "function") throw new TypeError("service ownership check requires ownsPlatformAutostart");
|
|
10
|
+
if (typeof stopPlatformService !== "function") throw new TypeError("service ownership check requires stopPlatformService");
|
|
11
|
+
const daemon = inspectWorkspaceDaemon(state);
|
|
12
|
+
if (!ownsPlatformAutostart(daemon)) {
|
|
13
|
+
return { owned: false, stopped: false, daemon, provider: null };
|
|
14
|
+
}
|
|
15
|
+
const provider = await stopPlatformService();
|
|
16
|
+
return { owned: true, stopped: provider?.ok !== false, daemon, provider };
|
|
17
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { restartAutostart } from "./service.mjs";
|
|
4
|
+
import { createLogger } from "./log.mjs";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_DELAY_MS = 300;
|
|
7
|
+
const MAX_DELAY_MS = 5_000;
|
|
8
|
+
|
|
9
|
+
export async function runServiceRestartHandoff(options = {}) {
|
|
10
|
+
const delayMs = boundedDelay(options.delayMs ?? process.argv[2]);
|
|
11
|
+
const restart = typeof options.restartAutostart === "function" ? options.restartAutostart : restartAutostart;
|
|
12
|
+
const sleep = typeof options.sleep === "function" ? options.sleep : delay;
|
|
13
|
+
const logger = options.logger || createLogger({ component: "service-restart", level: "warn", format: "json", stderrOnly: true });
|
|
14
|
+
await sleep(delayMs);
|
|
15
|
+
const result = await restart({ logger });
|
|
16
|
+
if (result?.ok !== true) {
|
|
17
|
+
const error = new Error(`service restart handoff failed (${result?.reason || result?.provider || "unknown"})`);
|
|
18
|
+
error.result = result;
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function serviceRestartHandoffMain(options = {}) {
|
|
25
|
+
const run = typeof options.run === "function"
|
|
26
|
+
? options.run
|
|
27
|
+
: () => runServiceRestartHandoff(options.handoffOptions);
|
|
28
|
+
const logger = options.logger || createLogger({ component: "service-restart", level: "error", format: "json", stderrOnly: true });
|
|
29
|
+
try {
|
|
30
|
+
await run();
|
|
31
|
+
return 0;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
logger.error("service restart handoff failed", { error_class: error?.code || error?.name || "execution_failed" });
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function boundedDelay(value) {
|
|
39
|
+
const parsed = Number(value);
|
|
40
|
+
if (!Number.isFinite(parsed)) return DEFAULT_DELAY_MS;
|
|
41
|
+
return Math.min(MAX_DELAY_MS, Math.max(50, Math.floor(parsed)));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function delay(milliseconds) {
|
|
45
|
+
return new Promise((resolvePromise) => { setTimeout(resolvePromise, milliseconds); });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
|
49
|
+
process.exitCode = await serviceRestartHandoffMain();
|
|
50
|
+
}
|