machine-bridge-mcp 2.0.0 → 3.0.0-beta.9
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 +101 -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 +80 -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
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
1
|
import { existsSync } from "node:fs";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
import { BridgeError } from "./errors.mjs";
|
|
@@ -7,12 +6,8 @@ import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
|
7
6
|
import { withOperationStateLock } from "./operation-state-lock.mjs";
|
|
8
7
|
import { ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
9
8
|
|
|
10
|
-
const SCHEMA_VERSION =
|
|
9
|
+
const SCHEMA_VERSION = 2;
|
|
11
10
|
const MAX_STATE_BYTES = 256 * 1024;
|
|
12
|
-
const MAX_PENDING = 100;
|
|
13
|
-
const MAX_LEASES = 256;
|
|
14
|
-
const PENDING_TTL_SECONDS = 10 * 60;
|
|
15
|
-
const DEFAULT_LEASE_SECONDS = 60 * 60;
|
|
16
11
|
const MAX_LEASE_SECONDS = 12 * 60 * 60;
|
|
17
12
|
const FULL_MAX_LEASE_SECONDS = 8 * 60 * 60;
|
|
18
13
|
|
|
@@ -26,131 +21,93 @@ export class OperationAuthorizer {
|
|
|
26
21
|
this.root = options.root ? path.resolve(String(options.root)) : "";
|
|
27
22
|
this.resolveExistingPath = options.resolveExistingPath;
|
|
28
23
|
this.resolveWritePath = options.resolveWritePath;
|
|
24
|
+
this.protectedRoots = Array.isArray(options.protectedRoots) ? options.protectedRoots.filter(Boolean).map((value) => path.resolve(String(value))) : [];
|
|
29
25
|
this.now = typeof options.now === "function" ? options.now : Date.now;
|
|
30
|
-
this.queue = Promise.resolve();
|
|
31
26
|
}
|
|
32
27
|
|
|
33
28
|
async authorize(operation) {
|
|
34
|
-
if (operation?.context?.origin !== "relay")
|
|
35
|
-
|
|
36
|
-
const accountId = String(authorization?.account_id || "");
|
|
37
|
-
const clientId = String(authorization?.client_id || "");
|
|
38
|
-
const role = String(authorization?.role || "").trim().toLowerCase();
|
|
39
|
-
if (!/^acct_[A-Za-z0-9_-]{20,96}$/.test(accountId) || !/^mcp_client_[A-Za-z0-9_-]{43}$/.test(clientId)) {
|
|
40
|
-
throw new BridgeError("authorization_denied", "relay operation is missing authenticated client identity");
|
|
29
|
+
if (operation?.context?.origin !== "relay") {
|
|
30
|
+
return { allowed: true, source: "local", category: "local operation", scopes: [], targetHash: "" };
|
|
41
31
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
accountId,
|
|
47
|
-
clientId,
|
|
48
|
-
};
|
|
32
|
+
const authority = operation.context?.authority;
|
|
33
|
+
const principal = authority?.principal;
|
|
34
|
+
if (!authority || principal?.kind !== "account") {
|
|
35
|
+
throw new BridgeError("authorization_denied", "relay operation is missing effective authority context");
|
|
49
36
|
}
|
|
50
37
|
const requirement = await classifyOperation(operation.tool, operation.args, {
|
|
51
38
|
workspace: this.workspace,
|
|
52
|
-
resolveExistingPath: this.resolveExistingPath,
|
|
53
|
-
resolveWritePath: this.resolveWritePath,
|
|
39
|
+
resolveExistingPath: (value) => this.resolveExistingPath(value, operation.context),
|
|
40
|
+
resolveWritePath: (value) => this.resolveWritePath(value, operation.context),
|
|
54
41
|
});
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (!missingScopes.length) {
|
|
62
|
-
const leaseIds = [...new Set(matches.leases.map((lease) => lease.id))];
|
|
42
|
+
if (requirement && requirement.canonicalTargets?.some((target) => this.protectedRoots.some((root) => isWithinRoot(root, target)))) {
|
|
43
|
+
throw new BridgeError("authorization_denied", "Machine Bridge control-plane state cannot be accessed through generic path-based remote tools", {
|
|
44
|
+
details: { reason: "control_plane_state_protected", tool: operation.tool },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (!requirement) {
|
|
63
48
|
return {
|
|
64
49
|
allowed: true,
|
|
65
|
-
source: "
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
scopes: requiredScopes,
|
|
50
|
+
source: authority.owner ? "trusted-owner" : "role-ceiling",
|
|
51
|
+
category: "ordinary operation",
|
|
52
|
+
scopes: [],
|
|
53
|
+
targetHash: "",
|
|
70
54
|
};
|
|
71
55
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
tool: operation.tool,
|
|
77
|
-
scopes: missingScopes,
|
|
56
|
+
assertRequirementWithinAuthority(requirement, authority, operation.tool);
|
|
57
|
+
return {
|
|
58
|
+
allowed: true,
|
|
59
|
+
source: authority.owner ? "trusted-owner" : "role-ceiling",
|
|
78
60
|
category: requirement.category,
|
|
61
|
+
scopes: normalizeScopes(requirement.scopes || [requirement.scope]),
|
|
79
62
|
targetHash: requirement.targetHash,
|
|
80
|
-
}
|
|
81
|
-
const scopeText = missingScopes.join(", ");
|
|
82
|
-
throw new BridgeError(
|
|
83
|
-
"authorization_denied",
|
|
84
|
-
`local approval required for ${requirement.category} (${scopeText}); approve the missing scopes with: machine-mcp approval approve ${pending.id} --duration 1h; or open an explicit temporary full window with: machine-mcp approval approve ${pending.id} --full`,
|
|
85
|
-
{
|
|
86
|
-
retryable: true,
|
|
87
|
-
details: {
|
|
88
|
-
reason: "local_approval_required",
|
|
89
|
-
approval_id: pending.id,
|
|
90
|
-
scope: missingScopes[0],
|
|
91
|
-
scopes: missingScopes,
|
|
92
|
-
required_scopes: requiredScopes,
|
|
93
|
-
expires_at: pending.expires_at,
|
|
94
|
-
approve_command: `machine-mcp approval approve ${pending.id} --duration 1h`,
|
|
95
|
-
full_command: `machine-mcp approval approve ${pending.id} --full`,
|
|
96
|
-
},
|
|
97
|
-
},
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
enqueue(callback) {
|
|
102
|
-
const result = this.queue.then(callback, callback);
|
|
103
|
-
this.queue = result.then(() => undefined, () => undefined);
|
|
104
|
-
return result;
|
|
63
|
+
};
|
|
105
64
|
}
|
|
106
65
|
}
|
|
107
66
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
pending: currentPending(root, now),
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export async function approvePendingOperation(root, pendingId, duration = "1h", now = Date.now(), scopeOverride = "") {
|
|
116
|
-
if (scopeOverride && scopeOverride !== "full") throw new Error("pending approval scopes may only be elevated to full");
|
|
117
|
-
return withOperationStateLock(root, async () => {
|
|
118
|
-
const pendingState = readPendingState(root);
|
|
119
|
-
const leaseState = readLeaseState(root);
|
|
120
|
-
const current = epochSeconds(now);
|
|
121
|
-
const pending = pendingState.pending.find((entry) => entry.id === String(pendingId || "") && entry.expires_at > current);
|
|
122
|
-
if (!pending) throw new Error("pending approval was not found or has expired");
|
|
123
|
-
const scopes = scopeOverride ? [scopeOverride] : pending.scopes;
|
|
124
|
-
let lease = leaseState.leases.find((entry) => entry.source_approval_id === pending.id && entry.expires_at > current);
|
|
125
|
-
if (!lease) {
|
|
126
|
-
lease = appendLease(leaseState, {
|
|
127
|
-
accountId: pending.account_id,
|
|
128
|
-
clientId: pending.client_id,
|
|
129
|
-
scopes,
|
|
130
|
-
duration,
|
|
131
|
-
sourceApprovalId: pending.id,
|
|
132
|
-
}, now);
|
|
133
|
-
writeJson(leasePath(root), leaseState);
|
|
134
|
-
}
|
|
135
|
-
pendingState.pending = pendingState.pending.filter((entry) => entry.id !== pending.id && entry.expires_at > current);
|
|
136
|
-
writeJson(pendingPath(root), pendingState);
|
|
137
|
-
return lease;
|
|
138
|
-
});
|
|
67
|
+
function isWithinRoot(root, target) {
|
|
68
|
+
const relative = path.relative(path.resolve(root), path.resolve(target));
|
|
69
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
139
70
|
}
|
|
140
71
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
72
|
+
function assertRequirementWithinAuthority(requirement, authority, tool) {
|
|
73
|
+
const scopes = normalizeScopes(requirement.scopes || [requirement.scope]);
|
|
74
|
+
const policy = authority.effectivePolicy;
|
|
75
|
+
const role = authority.principal.role;
|
|
76
|
+
if (scopes.includes("full") && !authority.owner) {
|
|
77
|
+
throw new BridgeError("authorization_denied", "full authority is reserved for the owner account");
|
|
144
78
|
}
|
|
145
|
-
if (
|
|
146
|
-
throw new
|
|
79
|
+
if ((scopes.includes("external-read") || scopes.includes("external-write")) && !policy.unrestrictedPaths) {
|
|
80
|
+
throw new BridgeError("authorization_denied", `account role ${role} is confined to the selected workspace`, {
|
|
81
|
+
details: { reason: "account_role_path_ceiling", tool, role },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if ((scopes.includes("sensitive-read") || scopes.includes("sensitive-write") || scopes.includes("credential-operation")) && !authority.owner) {
|
|
85
|
+
throw new BridgeError("authorization_denied", `account role ${role} cannot access credential or persistence-sensitive targets`, {
|
|
86
|
+
details: { reason: "account_role_sensitive_ceiling", tool, role },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (!authority.owner && (tool === "stage_job" || tool === "start_job")) {
|
|
90
|
+
throw new BridgeError("authorization_denied", `account role ${role} cannot create persistent execution plans`, {
|
|
91
|
+
details: { reason: "account_role_persistent_execution_ceiling", tool, role },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (scopes.includes("shell") && !policy.allowExec) {
|
|
95
|
+
throw new BridgeError("authorization_denied", `account role ${role} cannot execute local processes`, {
|
|
96
|
+
details: { reason: "account_role_execution_ceiling", tool, role },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if ((scopes.includes("browser-session") || scopes.includes("application-control") || scopes.includes("data-export")) && !authority.owner) {
|
|
100
|
+
throw new BridgeError("authorization_denied", `account role ${role} cannot control the owner browser or desktop session`, {
|
|
101
|
+
details: { reason: "account_role_interactive_ceiling", tool, role },
|
|
102
|
+
});
|
|
147
103
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Legacy lease administration is retained only for migration and incident-response
|
|
107
|
+
// tooling. Runtime authorization no longer consumes leases and never asks a user to
|
|
108
|
+
// copy an approval ID into a terminal.
|
|
109
|
+
export function listOperationApprovals(root, now = Date.now()) {
|
|
110
|
+
return { leases: currentLeases(root, now), pending: [] };
|
|
154
111
|
}
|
|
155
112
|
|
|
156
113
|
export async function revokeOperationLease(root, leaseId, now = Date.now()) {
|
|
@@ -172,155 +129,39 @@ export async function clearOperationLeases(root) {
|
|
|
172
129
|
});
|
|
173
130
|
}
|
|
174
131
|
|
|
175
|
-
export function parseApprovalDuration(value, scopes = "") {
|
|
176
|
-
const text = String(value || "1h").trim().toLowerCase();
|
|
177
|
-
const match = /^(\d+)(m|h)$/.exec(text);
|
|
178
|
-
if (!match) throw new Error("approval duration must use minutes or hours, for example 30m or 2h");
|
|
179
|
-
const seconds = Number(match[1]) * (match[2] === "h" ? 3600 : 60);
|
|
180
|
-
const normalizedScopes = normalizeScopes(Array.isArray(scopes) ? scopes : [scopes]);
|
|
181
|
-
const maximum = normalizedScopes.includes("full") ? FULL_MAX_LEASE_SECONDS : MAX_LEASE_SECONDS;
|
|
182
|
-
if (!Number.isSafeInteger(seconds) || seconds < 60 || seconds > maximum) {
|
|
183
|
-
throw new Error(`approval duration must be between 1m and ${maximum / 3600}h`);
|
|
184
|
-
}
|
|
185
|
-
return seconds;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
async function recordPendingApproval(root, input, now) {
|
|
189
|
-
return withOperationStateLock(root, async () => {
|
|
190
|
-
const state = readPendingState(root);
|
|
191
|
-
const current = epochSeconds(now);
|
|
192
|
-
const scopes = normalizeScopes(input.scopes);
|
|
193
|
-
state.pending = state.pending.filter((entry) => entry.expires_at > current);
|
|
194
|
-
const existing = state.pending.find((entry) => (
|
|
195
|
-
entry.account_id === input.accountId
|
|
196
|
-
&& entry.client_id === input.clientId
|
|
197
|
-
&& sameScopes(entry.scopes, scopes)
|
|
198
|
-
&& entry.target_hash === input.targetHash
|
|
199
|
-
));
|
|
200
|
-
if (existing) return existing;
|
|
201
|
-
const pending = {
|
|
202
|
-
id: `approval_${randomBytes(18).toString("base64url")}`,
|
|
203
|
-
account_id: input.accountId,
|
|
204
|
-
client_id: input.clientId,
|
|
205
|
-
tool: String(input.tool).slice(0, 128),
|
|
206
|
-
scopes,
|
|
207
|
-
category: String(input.category).slice(0, 160),
|
|
208
|
-
target_hash: input.targetHash,
|
|
209
|
-
created_at: current,
|
|
210
|
-
expires_at: current + PENDING_TTL_SECONDS,
|
|
211
|
-
};
|
|
212
|
-
state.pending.push(pending);
|
|
213
|
-
state.pending = state.pending.slice(-MAX_PENDING);
|
|
214
|
-
writeJson(pendingPath(root), state);
|
|
215
|
-
return pending;
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function appendLease(state, input, now) {
|
|
220
|
-
const scopes = normalizeScopes(input.scopes);
|
|
221
|
-
if (!scopes.length) throw new Error("capability lease requires at least one known scope");
|
|
222
|
-
if (scopes.includes("full") && scopes.length !== 1) throw new Error("full capability lease cannot be combined with narrower scopes");
|
|
223
|
-
const seconds = parseApprovalDuration(input.duration || `${DEFAULT_LEASE_SECONDS / 3600}h`, scopes);
|
|
224
|
-
const current = epochSeconds(now);
|
|
225
|
-
state.leases = state.leases.filter((lease) => lease.expires_at > current);
|
|
226
|
-
if (state.leases.length >= MAX_LEASES) throw new Error(`operation capability leases exceed the maximum of ${MAX_LEASES}`);
|
|
227
|
-
const lease = {
|
|
228
|
-
id: `lease_${randomBytes(18).toString("base64url")}`,
|
|
229
|
-
account_id: String(input.accountId || "*"),
|
|
230
|
-
client_id: String(input.clientId || ""),
|
|
231
|
-
scopes,
|
|
232
|
-
created_at: current,
|
|
233
|
-
expires_at: current + seconds,
|
|
234
|
-
...(input.sourceApprovalId ? { source_approval_id: String(input.sourceApprovalId) } : {}),
|
|
235
|
-
};
|
|
236
|
-
state.leases.push(lease);
|
|
237
|
-
return lease;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function matchingLeases(root, input, now) {
|
|
241
|
-
const current = epochSeconds(now);
|
|
242
|
-
const candidates = currentLeases(root, now).filter((lease) => (
|
|
243
|
-
(lease.account_id === "*" || lease.account_id === input.accountId)
|
|
244
|
-
&& (lease.client_id === "*" || lease.client_id === input.clientId)
|
|
245
|
-
&& lease.expires_at > current
|
|
246
|
-
));
|
|
247
|
-
const byScope = new Map();
|
|
248
|
-
for (const scope of input.scopes) {
|
|
249
|
-
const lease = candidates.find((entry) => entry.scopes.includes("full") || entry.scopes.includes(scope));
|
|
250
|
-
if (lease) byScope.set(scope, lease);
|
|
251
|
-
}
|
|
252
|
-
return { byScope, leases: [...byScope.values()] };
|
|
253
|
-
}
|
|
254
|
-
|
|
255
132
|
function currentLeases(root, now) {
|
|
256
133
|
const current = epochSeconds(now);
|
|
257
134
|
return readLeaseState(root).leases.filter((lease) => lease.expires_at > current);
|
|
258
135
|
}
|
|
259
136
|
|
|
260
|
-
function currentPending(root, now) {
|
|
261
|
-
const current = epochSeconds(now);
|
|
262
|
-
return readPendingState(root).pending.filter((entry) => entry.expires_at > current);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
137
|
function readLeaseState(root) {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
function readPendingState(root) {
|
|
270
|
-
return readState(pendingPath(root), emptyPendingState, "pending", validPending);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function readState(file, empty, arrayKey, validEntry) {
|
|
274
|
-
if (!existsSync(file)) return empty();
|
|
138
|
+
const file = leasePath(root);
|
|
139
|
+
if (!existsSync(file)) return emptyLeaseState();
|
|
275
140
|
const raw = readBoundedRegularFileSync(file, MAX_STATE_BYTES, "operation approval state");
|
|
276
141
|
let parsed;
|
|
277
142
|
try { parsed = JSON.parse(raw.toString("utf8")); } catch (error) {
|
|
278
143
|
throw new Error("operation approval state is not valid JSON", { cause: error });
|
|
279
144
|
}
|
|
280
|
-
if (
|
|
281
|
-
|
|
282
|
-
|| typeof parsed !== "object"
|
|
283
|
-
|| Array.isArray(parsed)
|
|
284
|
-
|| parsed.schemaVersion !== SCHEMA_VERSION
|
|
285
|
-
|| !Array.isArray(parsed[arrayKey])
|
|
286
|
-
|| !parsed[arrayKey].every(validEntry)
|
|
287
|
-
) {
|
|
145
|
+
if (parsed?.schemaVersion === 1 && Array.isArray(parsed.leases)) return emptyLeaseState();
|
|
146
|
+
if (!plainRecord(parsed) || parsed.schemaVersion !== SCHEMA_VERSION || !Array.isArray(parsed.leases) || !parsed.leases.every(validLease)) {
|
|
288
147
|
throw new Error("operation approval state schema is invalid");
|
|
289
148
|
}
|
|
290
149
|
return parsed;
|
|
291
150
|
}
|
|
292
151
|
|
|
293
152
|
function validLease(value) {
|
|
294
|
-
if (!plainRecord(value)) return false;
|
|
295
|
-
if (!/^lease_[A-Za-z0-9_-]{24}$/.test(value.id)) return false;
|
|
153
|
+
if (!plainRecord(value) || !/^lease_[A-Za-z0-9_-]{24}$/.test(value.id)) return false;
|
|
296
154
|
if (!validAccountBinding(value.account_id) || !validClientBinding(value.client_id)) return false;
|
|
155
|
+
if (value.account_id === "*" ? value.account_version !== 0 : !Number.isSafeInteger(value.account_version) || value.account_version <= 0) return false;
|
|
297
156
|
if (!validScopes(value.scopes) || !validTimestampRange(value.created_at, value.expires_at)) return false;
|
|
298
157
|
const maximum = value.scopes.includes("full") ? FULL_MAX_LEASE_SECONDS : MAX_LEASE_SECONDS;
|
|
299
|
-
|
|
300
|
-
return value.source_approval_id === undefined || /^approval_[A-Za-z0-9_-]{24}$/.test(value.source_approval_id);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function validPending(value) {
|
|
304
|
-
if (!plainRecord(value)) return false;
|
|
305
|
-
return /^approval_[A-Za-z0-9_-]{24}$/.test(value.id)
|
|
306
|
-
&& validAccountBinding(value.account_id, false)
|
|
307
|
-
&& validClientBinding(value.client_id, false)
|
|
308
|
-
&& /^[a-z][a-z0-9_]{0,127}$/.test(value.tool)
|
|
309
|
-
&& validScopes(value.scopes, false)
|
|
310
|
-
&& typeof value.category === "string"
|
|
311
|
-
&& value.category.length > 0
|
|
312
|
-
&& value.category.length <= 160
|
|
313
|
-
&& !/[\u0000-\u001f\u007f]/.test(value.category)
|
|
314
|
-
&& /^[a-f0-9]{64}$/.test(value.target_hash)
|
|
315
|
-
&& validTimestampRange(value.created_at, value.expires_at)
|
|
316
|
-
&& value.expires_at - value.created_at === PENDING_TTL_SECONDS;
|
|
158
|
+
return value.expires_at - value.created_at <= maximum;
|
|
317
159
|
}
|
|
318
160
|
|
|
319
|
-
function validScopes(value
|
|
161
|
+
function validScopes(value) {
|
|
320
162
|
if (!Array.isArray(value) || value.length < 1 || value.length > OPERATION_APPROVAL_SCOPES.length) return false;
|
|
321
163
|
if (value.some((scope) => !SCOPE_SET.has(scope))) return false;
|
|
322
164
|
if (!sameScopes(value, normalizeScopes(value))) return false;
|
|
323
|
-
if (!allowFull && value.includes("full")) return false;
|
|
324
165
|
return !value.includes("full") || value.length === 1;
|
|
325
166
|
}
|
|
326
167
|
|
|
@@ -342,10 +183,7 @@ function validClientBinding(value, wildcard = true) {
|
|
|
342
183
|
}
|
|
343
184
|
|
|
344
185
|
function validTimestampRange(createdAt, expiresAt) {
|
|
345
|
-
return Number.isSafeInteger(createdAt)
|
|
346
|
-
&& Number.isSafeInteger(expiresAt)
|
|
347
|
-
&& createdAt > 0
|
|
348
|
-
&& expiresAt > createdAt;
|
|
186
|
+
return Number.isSafeInteger(createdAt) && Number.isSafeInteger(expiresAt) && createdAt > 0 && expiresAt > createdAt;
|
|
349
187
|
}
|
|
350
188
|
|
|
351
189
|
function plainRecord(value) {
|
|
@@ -360,7 +198,5 @@ function writeJson(file, value) {
|
|
|
360
198
|
}
|
|
361
199
|
|
|
362
200
|
function leasePath(root) { return path.join(path.resolve(root), "operation-leases.json"); }
|
|
363
|
-
function pendingPath(root) { return path.join(path.resolve(root), "operation-pending.json"); }
|
|
364
201
|
function emptyLeaseState() { return { schemaVersion: SCHEMA_VERSION, leases: [] }; }
|
|
365
|
-
function emptyPendingState() { return { schemaVersion: SCHEMA_VERSION, pending: [] }; }
|
|
366
202
|
function epochSeconds(value) { return Math.floor(Number(value) / 1000); }
|
|
@@ -55,7 +55,11 @@ export function reviewedOperationToolNames() {
|
|
|
55
55
|
|
|
56
56
|
export async function classifyOperation(tool, args = {}, options = {}) {
|
|
57
57
|
const name = String(tool || "");
|
|
58
|
+
if (name === "list_local_resources") return requirement("sensitive-read", "protected local resource inventory", name, {});
|
|
58
59
|
if (SHELL_TOOLS.has(name)) return requirement("shell", "remote shell or process control", name, args);
|
|
60
|
+
if ((name === "stage_job" || name === "start_job") && jobUsesProtectedResources(args)) {
|
|
61
|
+
return requirement(["persistent-job", "sensitive-read"], "managed job using protected local resources", name, protectedJobProjection(args));
|
|
62
|
+
}
|
|
59
63
|
if (PERSISTENT_TOOLS.has(name)) return requirement("persistent-job", "persistent managed job", name, args);
|
|
60
64
|
if (name === "operate_local_application" && args.value_resource) {
|
|
61
65
|
return requirement(["application-control", "data-export"], "desktop application control using protected local data", name, {
|
|
@@ -113,6 +117,19 @@ export async function classifyOperation(tool, args = {}, options = {}) {
|
|
|
113
117
|
return null;
|
|
114
118
|
}
|
|
115
119
|
|
|
120
|
+
function jobUsesProtectedResources(args) {
|
|
121
|
+
const steps = [...(Array.isArray(args?.steps) ? args.steps : []), ...(Array.isArray(args?.finally_steps) ? args.finally_steps : [])];
|
|
122
|
+
return steps.some((step) => Boolean(step?.stdin_resource) || (step?.env_resources && Object.keys(step.env_resources).length > 0));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function protectedJobProjection(args) {
|
|
126
|
+
const steps = [...(Array.isArray(args?.steps) ? args.steps : []), ...(Array.isArray(args?.finally_steps) ? args.finally_steps : [])];
|
|
127
|
+
return {
|
|
128
|
+
step_count: steps.length,
|
|
129
|
+
resource_reference_count: steps.reduce((count, step) => count + (step?.stdin_resource ? 1 : 0) + Object.keys(step?.env_resources || {}).length, 0),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
116
133
|
function requirement(scopes, category, tool, target) {
|
|
117
134
|
const normalized = normalizeScopes(Array.isArray(scopes) ? scopes : [scopes]);
|
|
118
135
|
if (!normalized.length) throw new Error("operation approval requirement is missing a scope");
|
|
@@ -121,9 +138,20 @@ function requirement(scopes, category, tool, target) {
|
|
|
121
138
|
scopes: normalized,
|
|
122
139
|
category,
|
|
123
140
|
targetHash: createHash("sha256").update(JSON.stringify({ tool, target: redactTarget(target) })).digest("hex"),
|
|
141
|
+
canonicalTargets: canonicalTargetPaths(target),
|
|
124
142
|
};
|
|
125
143
|
}
|
|
126
144
|
|
|
145
|
+
function canonicalTargetPaths(target) {
|
|
146
|
+
if (!target || typeof target !== "object") return [];
|
|
147
|
+
const values = [];
|
|
148
|
+
if (typeof target.path === "string" && path.isAbsolute(target.path)) values.push(path.resolve(target.path));
|
|
149
|
+
if (Array.isArray(target.paths)) {
|
|
150
|
+
for (const value of target.paths) if (typeof value === "string" && path.isAbsolute(value)) values.push(path.resolve(value));
|
|
151
|
+
}
|
|
152
|
+
return [...new Set(values)];
|
|
153
|
+
}
|
|
154
|
+
|
|
127
155
|
function normalizeScopes(scopes) {
|
|
128
156
|
const requested = new Set(scopes.map((scope) => String(scope || "")));
|
|
129
157
|
return OPERATION_APPROVAL_SCOPES.filter((scope) => requested.has(scope));
|
|
@@ -1,92 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { createExclusiveFileSync, removeOwnedJsonFileSync } from "./exclusive-file.mjs";
|
|
5
|
-
import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
|
|
6
|
-
import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
|
|
7
|
-
import { ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
1
|
+
import { withOwnerStateLock } from "./owner-state-lock.mjs";
|
|
8
2
|
|
|
9
3
|
const LOCK_PURPOSE = "operation-authorization";
|
|
10
4
|
const LOCK_FILE = "operation-authorization.lock";
|
|
11
|
-
const MAX_LOCK_BYTES = 8 * 1024;
|
|
12
|
-
const DEFAULT_WAIT_MS = 5_000;
|
|
13
|
-
const DEFAULT_POLL_MS = 25;
|
|
14
5
|
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (!requestedRoot) throw new Error("operation-state lock root is missing");
|
|
19
|
-
const directory = path.resolve(requestedRoot);
|
|
20
|
-
ensureOwnerOnlyDirectorySync(directory);
|
|
21
|
-
const lockPath = path.join(directory, LOCK_FILE);
|
|
22
|
-
const timeoutMs = positiveInteger(options.timeoutMs, DEFAULT_WAIT_MS);
|
|
23
|
-
const pollMs = positiveInteger(options.pollMs, DEFAULT_POLL_MS);
|
|
24
|
-
const deadline = createMonotonicDeadline(timeoutMs);
|
|
25
|
-
const owner = lockOwner();
|
|
26
|
-
|
|
27
|
-
while (true) {
|
|
28
|
-
try {
|
|
29
|
-
createExclusiveFileSync(lockPath, `${JSON.stringify(owner, null, 2)}\n`, { mode: 0o600 });
|
|
30
|
-
break;
|
|
31
|
-
} catch (error) {
|
|
32
|
-
if (error?.code !== "EEXIST") throw error;
|
|
33
|
-
const existing = readLockOwner(lockPath);
|
|
34
|
-
if (!existing) throw new Error("operation authorization lock is malformed; inspect the owner-only state directory");
|
|
35
|
-
if (existing.purpose !== LOCK_PURPOSE) throw new Error("operation authorization lock contains mismatched purpose metadata");
|
|
36
|
-
const identity = inspectProcessInstance(existing, { maxAgeMs: Number.POSITIVE_INFINITY });
|
|
37
|
-
if (identity.reclaimable) {
|
|
38
|
-
if (removeOwnedJsonFileSync(lockPath, { token: existing.token, purpose: LOCK_PURPOSE }, { maxBytes: MAX_LOCK_BYTES })) continue;
|
|
39
|
-
}
|
|
40
|
-
if (deadline.expired()) {
|
|
41
|
-
const pid = Number.isInteger(existing.pid) ? `pid ${existing.pid}` : "unknown process";
|
|
42
|
-
throw new Error(`operation authorization state is busy (${pid}); retry after the current approval operation finishes`);
|
|
43
|
-
}
|
|
44
|
-
await delay(Math.min(pollMs, Math.max(1, deadline.remainingMs())));
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
let result;
|
|
49
|
-
let callbackError;
|
|
50
|
-
try {
|
|
51
|
-
result = await callback();
|
|
52
|
-
} catch (error) {
|
|
53
|
-
callbackError = error;
|
|
54
|
-
}
|
|
55
|
-
const released = removeOwnedJsonFileSync(lockPath, { token: owner.token, purpose: LOCK_PURPOSE }, { maxBytes: MAX_LOCK_BYTES });
|
|
56
|
-
if (!released) throw new Error("operation authorization lock changed before release; approval state may require inspection");
|
|
57
|
-
if (callbackError) throw callbackError;
|
|
58
|
-
return result;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function lockOwner() {
|
|
62
|
-
return {
|
|
63
|
-
pid: process.pid,
|
|
64
|
-
token: randomBytes(16).toString("hex"),
|
|
6
|
+
export function withOperationStateLock(root, callback, options = {}) {
|
|
7
|
+
return withOwnerStateLock(root, callback, {
|
|
8
|
+
...options,
|
|
65
9
|
purpose: LOCK_PURPOSE,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function readLockOwner(file) {
|
|
73
|
-
if (!existsSync(file)) return null;
|
|
74
|
-
let parsed;
|
|
75
|
-
try { parsed = JSON.parse(readBoundedRegularFileSync(file, MAX_LOCK_BYTES, "operation authorization lock").toString("utf8")); } catch { return null; }
|
|
76
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
77
|
-
if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
|
|
78
|
-
if (!/^[a-f0-9]{32}$/.test(String(parsed.token || ""))) return null;
|
|
79
|
-
if (parsed.purpose !== LOCK_PURPOSE) return null;
|
|
80
|
-
if (!Number.isFinite(Date.parse(String(parsed.startedAt || "")))) return null;
|
|
81
|
-
if (!Number.isFinite(Date.parse(String(parsed.processStartedAt || "")))) return null;
|
|
82
|
-
return parsed;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function positiveInteger(value, fallback) {
|
|
86
|
-
const number = Number(value);
|
|
87
|
-
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function delay(milliseconds) {
|
|
91
|
-
return new Promise((resolvePromise) => { setTimeout(resolvePromise, milliseconds); });
|
|
10
|
+
fileName: LOCK_FILE,
|
|
11
|
+
label: "operation authorization",
|
|
12
|
+
});
|
|
92
13
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createExclusiveFileSync, removeOwnedJsonFileSync } from "./exclusive-file.mjs";
|
|
5
|
+
import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
|
|
6
|
+
import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
|
|
7
|
+
import { ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
8
|
+
|
|
9
|
+
const MAX_LOCK_BYTES = 8 * 1024;
|
|
10
|
+
const DEFAULT_WAIT_MS = 5_000;
|
|
11
|
+
const DEFAULT_POLL_MS = 25;
|
|
12
|
+
|
|
13
|
+
export async function withOwnerStateLock(root, callback, options = {}) {
|
|
14
|
+
if (typeof callback !== "function") throw new TypeError("owner-state lock requires a callback");
|
|
15
|
+
const requestedRoot = String(root || "").trim();
|
|
16
|
+
if (!requestedRoot) throw new Error("owner-state lock root is missing");
|
|
17
|
+
const purpose = boundedIdentifier(options.purpose, "state");
|
|
18
|
+
const fileName = boundedFileName(options.fileName, `${purpose}.lock`);
|
|
19
|
+
const label = String(options.label || purpose).replace(/[\r\n\t\u0000-\u001f\u007f]/g, " ").trim().slice(0, 80) || purpose;
|
|
20
|
+
const directory = path.resolve(requestedRoot);
|
|
21
|
+
ensureOwnerOnlyDirectorySync(directory);
|
|
22
|
+
const lockPath = path.join(directory, fileName);
|
|
23
|
+
const timeoutMs = positiveInteger(options.timeoutMs, DEFAULT_WAIT_MS);
|
|
24
|
+
const pollMs = positiveInteger(options.pollMs, DEFAULT_POLL_MS);
|
|
25
|
+
const maxAgeMs = Number.isFinite(Number(options.maxAgeMs)) ? Math.max(1, Number(options.maxAgeMs)) : Number.POSITIVE_INFINITY;
|
|
26
|
+
const deadline = createMonotonicDeadline(timeoutMs);
|
|
27
|
+
const owner = lockOwner(purpose);
|
|
28
|
+
|
|
29
|
+
while (true) {
|
|
30
|
+
try {
|
|
31
|
+
createExclusiveFileSync(lockPath, `${JSON.stringify(owner, null, 2)}\n`, { mode: 0o600 });
|
|
32
|
+
break;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error?.code !== "EEXIST") throw error;
|
|
35
|
+
const existing = readLockOwner(lockPath, purpose);
|
|
36
|
+
if (!existing) throw new Error(`${label} lock is malformed; inspect the owner-only state directory`);
|
|
37
|
+
const identity = inspectProcessInstance(existing, { maxAgeMs });
|
|
38
|
+
if (identity.reclaimable) {
|
|
39
|
+
if (removeOwnedJsonFileSync(lockPath, { token: existing.token, purpose }, { maxBytes: MAX_LOCK_BYTES })) continue;
|
|
40
|
+
}
|
|
41
|
+
if (deadline.expired()) {
|
|
42
|
+
const pid = Number.isInteger(existing.pid) ? `pid ${existing.pid}` : "unknown process";
|
|
43
|
+
throw new Error(`${label} state is busy (${pid}); retry after the current operation finishes`);
|
|
44
|
+
}
|
|
45
|
+
await delay(Math.min(pollMs, Math.max(1, deadline.remainingMs())));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let result;
|
|
50
|
+
let callbackError;
|
|
51
|
+
try {
|
|
52
|
+
result = await callback();
|
|
53
|
+
} catch (error) {
|
|
54
|
+
callbackError = error;
|
|
55
|
+
}
|
|
56
|
+
const released = removeOwnedJsonFileSync(lockPath, { token: owner.token, purpose }, { maxBytes: MAX_LOCK_BYTES });
|
|
57
|
+
if (!released) throw new Error(`${label} lock changed before release; state may require inspection`);
|
|
58
|
+
if (callbackError) throw callbackError;
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function lockOwner(purpose) {
|
|
63
|
+
return {
|
|
64
|
+
pid: process.pid,
|
|
65
|
+
token: randomBytes(16).toString("hex"),
|
|
66
|
+
purpose,
|
|
67
|
+
startedAt: new Date().toISOString(),
|
|
68
|
+
processStartedAt: new Date(currentProcessStartTimeMs()).toISOString(),
|
|
69
|
+
entryScript: String(process.argv[1] || "").slice(0, 4096),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readLockOwner(file, purpose) {
|
|
74
|
+
if (!existsSync(file)) return null;
|
|
75
|
+
let parsed;
|
|
76
|
+
try { parsed = JSON.parse(readBoundedRegularFileSync(file, MAX_LOCK_BYTES, "owner-state lock").toString("utf8")); } catch { return null; }
|
|
77
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
78
|
+
if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
|
|
79
|
+
if (!/^[a-f0-9]{32}$/.test(String(parsed.token || ""))) return null;
|
|
80
|
+
if (parsed.purpose !== purpose) return null;
|
|
81
|
+
if (!Number.isFinite(Date.parse(String(parsed.startedAt || "")))) return null;
|
|
82
|
+
if (!Number.isFinite(Date.parse(String(parsed.processStartedAt || "")))) return null;
|
|
83
|
+
return parsed;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function boundedIdentifier(value, fallback) {
|
|
87
|
+
const text = String(value || fallback).toLowerCase().replace(/[^a-z0-9._-]/g, "-").slice(0, 64);
|
|
88
|
+
return text || fallback;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function boundedFileName(value, fallback) {
|
|
92
|
+
const name = path.basename(String(value || fallback));
|
|
93
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,95}\.lock$/.test(name)) throw new Error("owner-state lock filename is invalid");
|
|
94
|
+
return name;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function positiveInteger(value, fallback) {
|
|
98
|
+
const number = Number(value);
|
|
99
|
+
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function delay(milliseconds) {
|
|
103
|
+
return new Promise((resolvePromise) => { setTimeout(resolvePromise, milliseconds); });
|
|
104
|
+
}
|