@botbuddy/cli 1.63.12 → 1.63.14
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/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +1 -1
- package/src/stack-identified-admission.mjs +17 -0
- package/src/stack-isolation.mjs +57 -3
- package/src/stack-requester-origin-config.mjs +21 -0
- package/src/stack-requester-origin.mjs +62 -0
- package/src/stack.mjs +263 -10
- package/src/vendor/smol-toml/LICENSE +24 -0
- package/src/vendor/smol-toml/date.js +156 -0
- package/src/vendor/smol-toml/error.js +67 -0
- package/src/vendor/smol-toml/extract.js +293 -0
- package/src/vendor/smol-toml/index.js +34 -0
- package/src/vendor/smol-toml/parse.js +164 -0
- package/src/vendor/smol-toml/primitive.js +151 -0
- package/src/vendor/smol-toml/stringify.js +219 -0
- package/src/vendor/smol-toml/struct.js +156 -0
- package/src/vendor/smol-toml/util.js +57 -0
- package/src/vendor/smol-toml/vendored.json +20 -0
package/src/stack.mjs
CHANGED
|
@@ -33,7 +33,7 @@ import { existsSync, lstatSync, readFileSync, realpathSync } from "fs";
|
|
|
33
33
|
import { open, unlink, readFile } from "fs/promises";
|
|
34
34
|
import { homedir, tmpdir } from "os";
|
|
35
35
|
import { basename, dirname, join, relative, isAbsolute } from "path";
|
|
36
|
-
import { randomUUID } from "crypto";
|
|
36
|
+
import { randomUUID, createHash } from "crypto";
|
|
37
37
|
import { fileURLToPath } from "url";
|
|
38
38
|
import { callToolJson, CALL_AUTH_ERROR_CODES } from "./api.mjs";
|
|
39
39
|
import { deepLinkNotice, safeWebUrl } from "./deep-link.mjs";
|
|
@@ -43,7 +43,10 @@ import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
|
|
|
43
43
|
import { resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
|
|
44
44
|
import { runDockerCommand, runDockerWorkflow, ADMITTED_DOCKER_VALIDATIONS } from "./docker-hygiene.mjs";
|
|
45
45
|
import { acquireStackLock, lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
|
|
46
|
-
import { LEASED_STACK_IDLE_RETENTION_SECS, bindLeasedStackClaim, recoverLeasedStackClaim, hasLocalLeaseClaim, leasedClaimContext, assertLeasedStackIsolated, leasedStackClaimHeld, markLeasedStackReleased, stableRunStackReusable, leasedStackIdentity, materializeRunStackDir, portMapInConfig, refreshLeasedStackRetention, releaseLeasedStackClaim, reservePortBlocks } from "./stack-isolation.mjs";
|
|
46
|
+
import { LEASED_STACK_IDLE_RETENTION_SECS, assertFreshRunStackAvailable, recordAdmissionRequest, bindLeasedStackClaim, recoverLeasedStackClaim, hasLocalLeaseClaim, leasedClaimContext, assertLeasedStackIsolated, leasedStackClaimHeld, markLeasedStackReleased, stableRunStackReusable, leasedStackIdentity, materializeRunStackDir, portMapInConfig, refreshLeasedStackRetention, releaseLeasedStackClaim, reservePortBlocks } from "./stack-isolation.mjs";
|
|
47
|
+
import { reserveRequesterOrigin } from "./stack-requester-origin.mjs";
|
|
48
|
+
import { identifiedAdmissionAck, identifiedAdmissionDelivery, identifiedAdmissionRecovery } from "./stack-identified-admission.mjs";
|
|
49
|
+
import { patchRequesterAuthOrigin } from "./stack-requester-origin-config.mjs";
|
|
47
50
|
import { measureStackUsage } from "./stack-usage.mjs";
|
|
48
51
|
import { checkReadoptedStackMounts } from "./stack-mount-health.mjs";
|
|
49
52
|
import { machineUuid } from "./machine-id.mjs";
|
|
@@ -57,6 +60,20 @@ import { reportStackRunOutcome } from "./stack-run-outcome.mjs";
|
|
|
57
60
|
|
|
58
61
|
export const STACK_SCHEMA_VERSION = 1;
|
|
59
62
|
|
|
63
|
+
/**
|
|
64
|
+
* BOT-2780: only `stack run --remote --execution-ring beta --reap` reserves a requester
|
|
65
|
+
* origin and allocates through BOT-2499's identified admission. The origin cannot outlive
|
|
66
|
+
* its CLI, so the invocation must own cleanup; stable/auto/local and idle runs are unchanged.
|
|
67
|
+
*/
|
|
68
|
+
export const requesterOriginEnabled = (opts) => opts.execution === "remote" && opts.executionRing === "beta" && opts.reap === true;
|
|
69
|
+
// Exact replays of one identified request after an uncertain answer (lost response, 5xx,
|
|
70
|
+
// ADMISSION_UNCERTAIN, pending delivery). The same request_id can never create a second lease.
|
|
71
|
+
const ADMISSION_REPLAY_DELAYS_MS = [1000, 2000, 4000];
|
|
72
|
+
const ADMISSION_REPLAYABLE = new Set(["ADMISSION_UNCERTAIN", "RETRY_SAME_REQUEST", "HOST_ELIGIBILITY_BUSY"]);
|
|
73
|
+
// The key already has a ledger row (or had one): never replay it blindly or treat it as unallocated.
|
|
74
|
+
const ADMISSION_COMMITTED_UNCERTAIN = new Set(["REQUEST_IDENTITY_MISMATCH", "REQUEST_CLOSED", "ORIGINAL_LEASE_UNAVAILABLE"]);
|
|
75
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
76
|
+
|
|
60
77
|
/**
|
|
61
78
|
* Codex #1077 P1 / BOT-2184: an sshd with `GatewayPorts yes` binds a `-R` forward on every
|
|
62
79
|
* interface. A remote lease with --callback-port therefore runs the pre-forward GatewayPorts
|
|
@@ -119,6 +136,8 @@ export const STACK_HELP = `${bold("botbuddy stack")} — one command for a batch
|
|
|
119
136
|
|
|
120
137
|
${bold("USAGE")}
|
|
121
138
|
botbuddy stack up [options] Request a lease; park if the host is full; hold it active
|
|
139
|
+
botbuddy stack status --admission-request-id <uuid>
|
|
140
|
+
Read an identified admission after a lost response (BOT-2780)
|
|
122
141
|
botbuddy stack status <lease_id> Show a lease's state + connection; within a live remote run,
|
|
123
142
|
include the owner's attested local/remote forwards
|
|
124
143
|
botbuddy stack touch <lease_id> Bump the idle clock (and report measured use) so the reaper doesn't stop it
|
|
@@ -233,6 +252,12 @@ ${bold("run OPTIONS")}
|
|
|
233
252
|
else a remote host with capacity; the bundle digest is sent up front.
|
|
234
253
|
--execution-ring R Capacity rollout ring: stable (default) excludes beta daemon hosts.
|
|
235
254
|
beta is explicit opt-in and requires \`stack run --remote\`.
|
|
255
|
+
With --remote --execution-ring beta --reap (BOT-2780), run reserves a
|
|
256
|
+
loopback requester origin first and writes it as the private stack copy's
|
|
257
|
+
[auth] external_url, so generated auth links (/auth/v1/verify…) reach the
|
|
258
|
+
stack unchanged even when the SSH API forward is remapped; the child gets
|
|
259
|
+
it as connection.requester_origin. The lease is requested through the
|
|
260
|
+
identified admission (exact replay after a lost response, no fallback).
|
|
236
261
|
--callback-port N The caller runs a listener the stack calls back through
|
|
237
262
|
host.docker.internal:N (e.g. 4600): sent as callback_port. On a REMOTE
|
|
238
263
|
lease run first probes the host with a throwaway reverse forward and opens
|
|
@@ -421,6 +446,7 @@ export function parseStackArgs(argv) {
|
|
|
421
446
|
for (let i = 0; i < rest.length; i++) {
|
|
422
447
|
const a = rest[i];
|
|
423
448
|
switch (a) {
|
|
449
|
+
case "--admission-request-id": if (need(a, rest[i + 1])) opts.admissionRequestId = String(rest[++i]).toLowerCase(); break;
|
|
424
450
|
case "--slot": if (need(a, rest[i + 1])) opts.slot = rest[++i]; break;
|
|
425
451
|
case "--host": if (need(a, rest[i + 1])) opts.host = rest[++i]; break;
|
|
426
452
|
case "--repo": if (need(a, rest[i + 1])) opts.repo = rest[++i]; break;
|
|
@@ -588,7 +614,11 @@ export function parseStackArgs(argv) {
|
|
|
588
614
|
errors.push("--stack-path must be a non-empty relative path without '..'");
|
|
589
615
|
}
|
|
590
616
|
const leaseId = positionals[0] ?? null;
|
|
591
|
-
if (
|
|
617
|
+
if (opts.admissionRequestId != null && (!UUID_RE.test(opts.admissionRequestId) ||
|
|
618
|
+
!((command === "status" && !leaseId) || (command === "done" && leaseId && opts.reap && opts.releasePortClaim)))) {
|
|
619
|
+
errors.push("--admission-request-id takes a request UUID with `status` (no <lease_id>) or `done <lease_id> --reap --release-port-claim`");
|
|
620
|
+
}
|
|
621
|
+
if (command && ["status", "touch", "done"].includes(command) && !leaseId && !(command === "status" && opts.admissionRequestId)) {
|
|
592
622
|
errors.push(`${command} needs a <lease_id>`);
|
|
593
623
|
}
|
|
594
624
|
if (["up", "done"].includes(command) && opts.localExec && Boolean(opts.dockerContext) === Boolean(opts.dockerEndpoint)) {
|
|
@@ -865,6 +895,16 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
|
|
|
865
895
|
}
|
|
866
896
|
return execution;
|
|
867
897
|
};
|
|
898
|
+
// BOT-2780: the requester origin is only for a fresh CLI-managed stack context. A
|
|
899
|
+
// prepared path, pinned host or fixed nonce could resume a stack whose generated links
|
|
900
|
+
// already name another invocation's listener.
|
|
901
|
+
const originRun = requesterOriginEnabled(opts);
|
|
902
|
+
if (originRun && (opts.host != null || opts.stackNonce != null || (opts.stackPath && opts.stackPath !== ".") ||
|
|
903
|
+
!UUID_RE.test(opts.admissionRequestId ?? "") || !opts.requesterMachineId)) {
|
|
904
|
+
const error = new Error("beta requester origins require a fresh CLI-managed stack context (no --host, --stack-path or fixed nonce)");
|
|
905
|
+
error.code = "REQUESTER_ORIGIN_CONTEXT_REFUSED";
|
|
906
|
+
throw error;
|
|
907
|
+
}
|
|
868
908
|
// An explicitly prepared directory is the caller's to manage, but it is NOT
|
|
869
909
|
// exempt from the isolation contract (BOT-1798, Codex #928 R8 P1): a copied root
|
|
870
910
|
// config — same project_id, or merely the same ports — would make the Helper
|
|
@@ -930,15 +970,30 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
|
|
|
930
970
|
const nonce = opts.stackNonce ?? (stableReusable(worktreeRoot, stableDir) ? null : randomUUID());
|
|
931
971
|
const { projectId } = identity({ worktreeRoot, slot: opts.slot, ticket: opts.ticket, nonce });
|
|
932
972
|
const held = await claimProjectLock(projectId, lock, `this batch's isolated stack (${projectId})`);
|
|
973
|
+
let requesterOrigin = null;
|
|
974
|
+
let originContext = null;
|
|
933
975
|
try {
|
|
976
|
+
let configText;
|
|
977
|
+
if (originRun) {
|
|
978
|
+
// BOT-2780: refuse a retained slot context, then reserve the listener BEFORE the
|
|
979
|
+
// private config (and so the bundle digest) is derived. The source config is never
|
|
980
|
+
// changed; only the private materialized copy names the reserved origin.
|
|
981
|
+
assertFreshRunStackAvailable(worktreeRoot, opts.slot);
|
|
982
|
+
originContext = Object.freeze({ request_id: opts.admissionRequestId, worktree: worktreeRoot, slot: opts.slot, machine: opts.requesterMachineId });
|
|
983
|
+
requesterOrigin = await reserveRequesterOrigin({ context: originContext });
|
|
984
|
+
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- canonical realpath of the current worktree; fixed own source config, never a request-supplied path.
|
|
985
|
+
configText = await patchRequesterAuthOrigin(readFileSync(join(worktreeRoot, "supabase", "config.toml"), "utf8"), requesterOrigin.origin);
|
|
986
|
+
}
|
|
934
987
|
const execution = refuseRoot(resolveStackPath(cwd, materialize(
|
|
935
988
|
cwd,
|
|
936
989
|
{ slot: opts.slot, repo: opts.repo, ticket: opts.ticket, nonce, idleTtlSecs: opts.idleTtl ?? undefined },
|
|
937
990
|
// BOT-2403: a functions directory another writer replaced under this stack is reported.
|
|
938
|
-
{ ...(portRegistryDir ? { portRegistryDir } : {}), warn },
|
|
991
|
+
{ ...(portRegistryDir ? { portRegistryDir } : {}), warn, ...(configText !== undefined ? { configText } : {}) },
|
|
939
992
|
)));
|
|
940
993
|
return {
|
|
941
994
|
...execution,
|
|
995
|
+
...(requesterOrigin ? { requesterOrigin, originContext, admissionRequestId: opts.admissionRequestId,
|
|
996
|
+
recordAdmission: (requestId) => recordAdmissionRequest(stackDirFor(execution), requestId, portRegistryDir ? { registryDir: portRegistryDir } : undefined) } : {}),
|
|
942
997
|
bindLease: (leaseId) => bindLeasedStackClaim(stackDirFor(execution), leaseId, portRegistryDir ? { registryDir: portRegistryDir } : undefined),
|
|
943
998
|
// Release is called once the batch's signed reap is done, so hand the host
|
|
944
999
|
// its port block back immediately rather than holding it for the retention
|
|
@@ -951,6 +1006,7 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
|
|
|
951
1006
|
// BOT-2237 (Codex #1168 P2): returns the VERIFIED disposition — whether this stack's
|
|
952
1007
|
// claim is gone afterwards — so a failed unlink is never reported as released.
|
|
953
1008
|
release: ({ reaped = false } = {}) => {
|
|
1009
|
+
void requesterOrigin?.close();
|
|
954
1010
|
const registry = portRegistryDir ? { registryDir: portRegistryDir } : undefined;
|
|
955
1011
|
let released = false;
|
|
956
1012
|
if (reaped) {
|
|
@@ -964,6 +1020,7 @@ export async function resolveRunExecution(cwd, opts, { materialize = materialize
|
|
|
964
1020
|
},
|
|
965
1021
|
};
|
|
966
1022
|
} catch (error) {
|
|
1023
|
+
await requesterOrigin?.close();
|
|
967
1024
|
held.release();
|
|
968
1025
|
throw error;
|
|
969
1026
|
}
|
|
@@ -2999,6 +3056,21 @@ export async function cmdStatus(leaseId, opts, {
|
|
|
2999
3056
|
} = {}) {
|
|
3000
3057
|
const auth = await rpc.init();
|
|
3001
3058
|
if (!auth) return emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
3059
|
+
if (opts.admissionRequestId) {
|
|
3060
|
+
// BOT-2780: the owner-scoped identified-admission read. It never returns a connection or
|
|
3061
|
+
// capability, and an unknown key is not proof that nothing was admitted.
|
|
3062
|
+
const read = await rpc.call("get_stack_lease_admission", { request_id: opts.admissionRequestId });
|
|
3063
|
+
const base = { command: "status", request_id: opts.admissionRequestId };
|
|
3064
|
+
if (!read.ok) return emitResult(buildReceipt({ ...base, outcome: "error", error: read.error }), opts, read.auth ? EXIT.AUTH : EXIT.BACKEND);
|
|
3065
|
+
const a = read.data ?? {};
|
|
3066
|
+
if (a.success !== true) return emitResult(buildReceipt({ ...base, outcome: "error", code: a.code ?? null, error: a.code ?? "admission unreadable" }), opts, EXIT.BACKEND);
|
|
3067
|
+
return emitResult(buildReceipt({
|
|
3068
|
+
...base, outcome: a.lease_status ?? "unknown", lease_id: a.lease_id ?? null, admission_kind: a.admission_kind ?? null,
|
|
3069
|
+
job_binding: a.job_binding ?? null, provision_job_id: a.provision_job_id ?? null, lease_status: a.lease_status ?? null,
|
|
3070
|
+
closed: a.closed === true, superseded: a.superseded === true, close_reason: a.close_reason ?? null,
|
|
3071
|
+
cleanup_status: a.cleanup_status ?? null, cleanup_evidence: a.cleanup_evidence ?? null,
|
|
3072
|
+
}), opts, 0);
|
|
3073
|
+
}
|
|
3002
3074
|
const got = await rpc.call("get_stack_lease", { lease_id: leaseId });
|
|
3003
3075
|
if (!got.ok) return got.auth
|
|
3004
3076
|
? emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.AUTH)
|
|
@@ -3345,9 +3417,19 @@ export async function cmdDone(leaseId, opts, {
|
|
|
3345
3417
|
// provable (cleanup_verified strictly false), ends at once; a raw frame keeps waiting.
|
|
3346
3418
|
(s, p) => (s === "reap_failed" && p?.cleanup_verified === true) || (s === "reaped" && p?.cleanup_verified === false),
|
|
3347
3419
|
{ timeoutSec: opts.reapTimeout ?? 300, auth: rpc.auth });
|
|
3348
|
-
|
|
3420
|
+
// BOT-2780: a claim bound only to an identified admission request is released only when
|
|
3421
|
+
// the owner-scoped admission read proves that request named this exact lease.
|
|
3422
|
+
let requestBinding = null;
|
|
3423
|
+
if (opts.admissionRequestId) {
|
|
3424
|
+
const admission = await call("get_stack_lease_admission", { request_id: opts.admissionRequestId });
|
|
3425
|
+
requestBinding = admission?.ok && admission.data?.success === true && admission.data.superseded !== true && admission.data.lease_id === leaseId
|
|
3426
|
+
? { admission_request_id: opts.admissionRequestId } : false;
|
|
3427
|
+
}
|
|
3428
|
+
if (requestBinding === false) {
|
|
3429
|
+
portBlock = { released: false, reason: `admission ${opts.admissionRequestId} does not name lease ${leaseId}; local claim retained` };
|
|
3430
|
+
} else if (verified.woke && verified.cleanupEvidence) {
|
|
3349
3431
|
state = "reaped";
|
|
3350
|
-
portBlock = await recoverClaim({ ...current.data, lease_id: leaseId }, verified.cleanupEvidence);
|
|
3432
|
+
portBlock = await recoverClaim({ ...current.data, lease_id: leaseId }, { ...verified.cleanupEvidence, ...(requestBinding ?? {}) });
|
|
3351
3433
|
} else portBlock = { released: false, reason: "accepted cleanup proof is unavailable; local claim retained" };
|
|
3352
3434
|
}
|
|
3353
3435
|
} catch (error) {
|
|
@@ -3583,6 +3665,9 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3583
3665
|
incidentBox.outcomeChannel = { get auth() { return rpc.auth; }, callTool: adapters.callTool ?? callToolJson };
|
|
3584
3666
|
const api = adapters.api ?? {
|
|
3585
3667
|
request: (args) => rpc.call("request_stack_lease", args),
|
|
3668
|
+
// BOT-2780 (beta requester origin): BOT-2499's identified admission, never a fallback.
|
|
3669
|
+
requestIdentified: (args) => rpc.call("request_stack_lease_identified", args),
|
|
3670
|
+
getAdmission: (args) => rpc.call("get_stack_lease_admission", args),
|
|
3586
3671
|
get: (leaseId) => rpc.call("get_stack_lease", { lease_id: leaseId }),
|
|
3587
3672
|
// BOT-1803: a heartbeat carries the measured client use when there is one.
|
|
3588
3673
|
touch: (leaseId, usage) => rpc.call("touch_stack_lease", { lease_id: leaseId, ...(usage ? { usage } : {}) }),
|
|
@@ -3675,6 +3760,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3675
3760
|
let tunnel = null;
|
|
3676
3761
|
let forwardAttestation = null;
|
|
3677
3762
|
let tunnelLost = null;
|
|
3763
|
+
// BOT-2780: the requester origin closed under a live run (SSH loss or forward failure).
|
|
3764
|
+
let originLost = null;
|
|
3678
3765
|
const placement = { mode: null, hostKey: null, requesterHost: null, transport: "none", forwards: null, bundle: null, placementReason: null, considered: null };
|
|
3679
3766
|
incidentBox.execution = placement;
|
|
3680
3767
|
// BOT-1803: idle-lease bookkeeping for the receipt.
|
|
@@ -3685,7 +3772,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3685
3772
|
let retainBlock = false;
|
|
3686
3773
|
// BOT-1803: a heartbeat touch found the lease preempted under the running child.
|
|
3687
3774
|
let preempted = null;
|
|
3775
|
+
// BOT-2780: the requester listener never outlives the run, its tunnel or a reap request.
|
|
3776
|
+
const closeOrigin = async () => { try { await execution?.requesterOrigin?.close(); } catch { /* closed either way */ } };
|
|
3688
3777
|
const closeTunnel = async () => {
|
|
3778
|
+
await closeOrigin();
|
|
3689
3779
|
try {
|
|
3690
3780
|
if (forwardAttestation) {
|
|
3691
3781
|
const owner = forwardAttestation;
|
|
@@ -3711,6 +3801,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3711
3801
|
if (!leaseId) return Promise.resolve({ ok: true, state: null });
|
|
3712
3802
|
if (cleanupPromise) return cleanupPromise;
|
|
3713
3803
|
cleanupPromise = (async () => {
|
|
3804
|
+
await closeOrigin();
|
|
3714
3805
|
if (!leaseId) return cleanupResult = { ok: true, state: null };
|
|
3715
3806
|
const cleanupAbort = new AbortController();
|
|
3716
3807
|
const cleanupTimer = clock.setTimeout(() => cleanupAbort.abort("reap-timeout"), opts.reapTimeout * 1000);
|
|
@@ -3799,6 +3890,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3799
3890
|
};
|
|
3800
3891
|
|
|
3801
3892
|
const stopChild = (signal = "SIGTERM", forcedCode = null) => {
|
|
3893
|
+
void closeOrigin();
|
|
3802
3894
|
if (!child || childResult) return;
|
|
3803
3895
|
forcedStopCode ??= forcedCode;
|
|
3804
3896
|
childGroupSignal(child, signal);
|
|
@@ -3834,14 +3926,21 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3834
3926
|
const slot = deriveSlot(opts);
|
|
3835
3927
|
// BOT-1798: never "." — materialize/validate an ISOLATED stack dir first.
|
|
3836
3928
|
try {
|
|
3837
|
-
execution = await resolveExecution({ ...opts, slot
|
|
3929
|
+
execution = await resolveExecution({ ...opts, slot,
|
|
3930
|
+
// BOT-2780: one identified admission key per invocation, held through replays.
|
|
3931
|
+
...(requesterOriginEnabled(opts) ? { admissionRequestId: randomUUID(), requesterMachineId: hardwareUuid } : {}),
|
|
3932
|
+
});
|
|
3933
|
+
if (requesterOriginEnabled(opts) && (!execution.requesterOrigin || !execution.admissionRequestId || !execution.originContext)) {
|
|
3934
|
+
await closeOrigin();
|
|
3935
|
+
const error = new Error("requester origin context unavailable"); error.code = "REQUESTER_ORIGIN_CONTEXT_REFUSED"; throw error;
|
|
3936
|
+
}
|
|
3838
3937
|
} catch (error) {
|
|
3839
3938
|
// A slot whose stack another live batch owns is a coordination refusal (the
|
|
3840
3939
|
// same class as the server's duplicate-lease rejection), not bad arguments.
|
|
3841
3940
|
const exitCode = error?.code === "stack_slot_busy" ? EXIT.BACKEND : EXIT.INVALID;
|
|
3842
3941
|
// BOT-2490: retain listener/cooldown refusals in the existing public code field;
|
|
3843
3942
|
// keep the exit mapping and unrelated local-error semantics unchanged.
|
|
3844
|
-
const code = ["stack_port_census_failed", "stack_port_cooldown_failed"].includes(error?.code) ? error.code : null;
|
|
3943
|
+
const code = ["stack_port_census_failed", "stack_port_cooldown_failed"].includes(error?.code) || String(error?.code ?? "").startsWith("REQUESTER_ORIGIN_") ? error.code : null;
|
|
3845
3944
|
return { exitCode, outcome: "error", ...(code ? { code } : {}), error: String(error?.message ?? error) };
|
|
3846
3945
|
}
|
|
3847
3946
|
// BOT-1798 (Codex #928 R13 P2): the queue and provisioning waits can outlast the
|
|
@@ -3936,6 +4035,83 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3936
4035
|
...(bundle?.included?.length ? { bundle_included: bundle.included } : {}),
|
|
3937
4036
|
...(opts.callbackPort != null ? { callback_port: opts.callbackPort } : {}),
|
|
3938
4037
|
};
|
|
4038
|
+
// BOT-2780: the identified admission key and a digest of the immutable origin context.
|
|
4039
|
+
// The intent (including the bundle digest that names the reserved origin) is fingerprinted
|
|
4040
|
+
// server-side; replays of this exact request can never create a second lease.
|
|
4041
|
+
let identifiedVerdict = null;
|
|
4042
|
+
let requestIdentified = null;
|
|
4043
|
+
if (execution.admissionRequestId) {
|
|
4044
|
+
if (!stackDigest || !bundle?.sha256) {
|
|
4045
|
+
return { exitCode: EXIT.INTERNAL, outcome: "error", code: "REQUESTER_ORIGIN_DIGEST_UNAVAILABLE", error: "the requester-origin bundle digest could not be computed; no lease was requested" };
|
|
4046
|
+
}
|
|
4047
|
+
leaseArgs.request_id = execution.admissionRequestId;
|
|
4048
|
+
// Durable request-to-context association before any request can commit (recovery key).
|
|
4049
|
+
try { execution.recordAdmission?.(leaseArgs.request_id); } catch (error) {
|
|
4050
|
+
return { exitCode: EXIT.INTERNAL, outcome: "error", code: "REQUESTER_ORIGIN_CONTEXT_REFUSED", error: `could not record the admission request on this stack's claim (${error?.message ?? error}); no lease was requested` };
|
|
4051
|
+
}
|
|
4052
|
+
leaseArgs.origin_identity_sha256 = createHash("sha256")
|
|
4053
|
+
.update(JSON.stringify({ context: execution.originContext, origin: execution.requesterOrigin.origin })).digest("hex");
|
|
4054
|
+
const replayDelays = adapters.admissionReplayDelaysMs ?? ADMISSION_REPLAY_DELAYS_MS;
|
|
4055
|
+
let replays = 0;
|
|
4056
|
+
let mayHaveCommitted = false;
|
|
4057
|
+
let ownedAck = null;
|
|
4058
|
+
const settle = (code) => {
|
|
4059
|
+
identifiedVerdict = ownedAck ? { kind: "owned", code, ack: ownedAck } : { kind: "uncertain", code, ack: null };
|
|
4060
|
+
};
|
|
4061
|
+
// Returns a legacy-shaped answer: the decorated delivery for a ready ACK, a definite
|
|
4062
|
+
// refusal, or (with identifiedVerdict set) the original answer for the caller to retain.
|
|
4063
|
+
requestIdentified = async () => {
|
|
4064
|
+
for (;;) {
|
|
4065
|
+
let res;
|
|
4066
|
+
try { res = await api.requestIdentified(leaseArgs); } catch (error) { res = { ok: false, status: null, error: String(error?.message ?? error) }; }
|
|
4067
|
+
let replayCode = null;
|
|
4068
|
+
if (!res?.ok) {
|
|
4069
|
+
// A rejected credential, or a readable JSON-RPC protocol error over HTTP 200, was refused
|
|
4070
|
+
// before the tool handler ran: a definite refusal on a first answer. An unreadable body
|
|
4071
|
+
// (`invalid_json`) may follow a commit, so it is replayed like a lost response.
|
|
4072
|
+
if (!mayHaveCommitted && (res?.auth || (res?.status === 200 && res.error !== "invalid_json"))) return res;
|
|
4073
|
+
replayCode = "ADMISSION_UNCERTAIN";
|
|
4074
|
+
} else if (!mayHaveCommitted && typeof res.data?.raw === "string" && /^Unknown tool: request_stack_lease_identified\b/.test(res.data.raw)) {
|
|
4075
|
+
// An older backend without BOT-2499's tool: nothing was admitted, and no fallback.
|
|
4076
|
+
identifiedVerdict = { kind: "unsupported" };
|
|
4077
|
+
return res;
|
|
4078
|
+
} else if (res.data?.success !== true) {
|
|
4079
|
+
const code = typeof res.data?.code === "string" ? res.data.code : "ADMISSION_UNCERTAIN";
|
|
4080
|
+
if (ADMISSION_COMMITTED_UNCERTAIN.has(code)) { settle(code); return res; }
|
|
4081
|
+
if (ADMISSION_REPLAYABLE.has(code)) replayCode = code;
|
|
4082
|
+
// After an answer that may have committed, a later refusal is not proof of absence.
|
|
4083
|
+
else if (mayHaveCommitted) { settle("ADMISSION_UNCERTAIN"); return res; }
|
|
4084
|
+
// A definite, effect-free refusal never carries cleanup authority.
|
|
4085
|
+
else return { ...res, data: { success: false, code, ...(res.data?.message ? { message: res.data.message } : {}),
|
|
4086
|
+
...(res.data?.phase ? { phase: res.data.phase } : {}), ...(res.data?.wait ? { wait: res.data.wait } : {}) } };
|
|
4087
|
+
} else {
|
|
4088
|
+
const ack = identifiedAdmissionAck(res.data, leaseArgs.request_id);
|
|
4089
|
+
// A malformed or foreign ACK is untrusted: never cleanup authority, never adopted.
|
|
4090
|
+
if (!ack || ack.admission_kind !== "created" || (ownedAck && ack.lease_id !== ownedAck.lease_id)) {
|
|
4091
|
+
mayHaveCommitted = true;
|
|
4092
|
+
if (ack && ownedAck && ack.lease_id !== ownedAck.lease_id) ownedAck = null; // two leases for one key: trust neither
|
|
4093
|
+
settle(!ack || ack.admission_kind === "created" ? "ADMISSION_UNCERTAIN" : "ADMISSION_READOPTION_UNSUPPORTED");
|
|
4094
|
+
return res;
|
|
4095
|
+
}
|
|
4096
|
+
const delivery = identifiedAdmissionDelivery(res.data, ack);
|
|
4097
|
+
if (delivery) return { ...res, data: delivery };
|
|
4098
|
+
if (ack.delivery_status === "ready") { mayHaveCommitted = true; settle("ADMISSION_UNCERTAIN"); return res; }
|
|
4099
|
+
ownedAck = ack;
|
|
4100
|
+
replayCode = "ADMISSION_DELIVERY_PENDING";
|
|
4101
|
+
}
|
|
4102
|
+
mayHaveCommitted = mayHaveCommitted || replayCode !== "HOST_ELIGIBILITY_BUSY";
|
|
4103
|
+
if (replays >= replayDelays.length || receivedSignal) {
|
|
4104
|
+
// Only effect-free busy answers so far: still a definite refusal, nothing to retain.
|
|
4105
|
+
if (!mayHaveCommitted) return { ...res, data: { success: false, code: replayCode } };
|
|
4106
|
+
settle(replayCode);
|
|
4107
|
+
return res;
|
|
4108
|
+
}
|
|
4109
|
+
const delay = replayDelays[replays++];
|
|
4110
|
+
stderrLine(`stack: identified admission ${leaseArgs.request_id} is ${replayCode} — replaying the same request in ${delay}ms`);
|
|
4111
|
+
if (delay > 0) await new Promise((resolve) => { clock.setTimeout(resolve, delay); });
|
|
4112
|
+
}
|
|
4113
|
+
};
|
|
4114
|
+
}
|
|
3939
4115
|
// The retrying caller (api → rpc.call) transparently renews+retries the
|
|
3940
4116
|
// FIRST auth rejection among any of request/get/touch/release.
|
|
3941
4117
|
// BOT-2024: a stale-capacity refusal prints phase-aware guidance; with the opt-in
|
|
@@ -3948,11 +4124,51 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3948
4124
|
let idleRetried = false;
|
|
3949
4125
|
for (let attempt = 0; ; attempt++) {
|
|
3950
4126
|
activeWaitAbort = new AbortController();
|
|
3951
|
-
const admission = await requestWithCapacityWait(() => api.request(leaseArgs), opts, {
|
|
4127
|
+
const admission = await requestWithCapacityWait(() => requestIdentified ? requestIdentified() : api.request(leaseArgs), opts, {
|
|
3952
4128
|
waitCapacity: adapters.waitCapacity ?? defaultWaitCapacity, log: stderrLine, signal: activeWaitAbort.signal,
|
|
3953
4129
|
});
|
|
3954
4130
|
activeWaitAbort = null;
|
|
3955
4131
|
request = admission.req;
|
|
4132
|
+
if (identifiedVerdict) {
|
|
4133
|
+
const verdict = identifiedVerdict;
|
|
4134
|
+
if (verdict.kind === "unsupported") {
|
|
4135
|
+
return { exitCode: EXIT.BACKEND, outcome: "error", code: "IDENTIFIED_ADMISSION_UNSUPPORTED",
|
|
4136
|
+
error: "this backend has no identified stack-lease admission (request_stack_lease_identified); a beta requester-origin run never falls back to the legacy allocator" };
|
|
4137
|
+
}
|
|
4138
|
+
if (verdict.kind === "owned") {
|
|
4139
|
+
// The exact ACK proves this lease is ours, but it never became deliverable: reap it.
|
|
4140
|
+
leaseId = verdict.ack.lease_id;
|
|
4141
|
+
leaseProvisioned = true;
|
|
4142
|
+
execution.bindLease?.(leaseId);
|
|
4143
|
+
const cleanup = await requestCleanup();
|
|
4144
|
+
return { exitCode: receivedSignal ? SIGNAL_EXIT[receivedSignal] : cleanup.ok ? EXIT.BACKEND : EXIT.CLEANUP_FAILED,
|
|
4145
|
+
outcome: receivedSignal ? "interrupted" : "error", code: verdict.code, leaseId, cleanup,
|
|
4146
|
+
error: `identified admission ${leaseArgs.request_id} committed lease ${leaseId} but its delivery never became ready; it was released` };
|
|
4147
|
+
}
|
|
4148
|
+
// Possibly committed with no trustworthy ACK. One owner-scoped admission read can still
|
|
4149
|
+
// prove which lease this exact request created: then it is ours to reap normally.
|
|
4150
|
+
let observed = null;
|
|
4151
|
+
try { observed = await api.getAdmission?.({ request_id: leaseArgs.request_id }); } catch { observed = null; }
|
|
4152
|
+
const named = observed?.ok && observed.data?.success === true && observed.data.superseded !== true &&
|
|
4153
|
+
typeof observed.data.lease_id === "string" && UUID_RE.test(observed.data.lease_id) ? observed.data.lease_id : null;
|
|
4154
|
+
if (named) {
|
|
4155
|
+
leaseId = named;
|
|
4156
|
+
leaseProvisioned = true;
|
|
4157
|
+
execution.bindLease?.(leaseId);
|
|
4158
|
+
const cleanup = await requestCleanup();
|
|
4159
|
+
return { exitCode: receivedSignal ? SIGNAL_EXIT[receivedSignal] : cleanup.ok ? EXIT.BACKEND : EXIT.CLEANUP_FAILED,
|
|
4160
|
+
outcome: receivedSignal ? "interrupted" : "error", code: verdict.code, leaseId, cleanup,
|
|
4161
|
+
error: `identified admission ${leaseArgs.request_id} was uncertain; its owner-scoped read names lease ${leaseId}, which was released` };
|
|
4162
|
+
}
|
|
4163
|
+
// Still unknown: keep the port claim (bound to this request) and hand back the key.
|
|
4164
|
+
retainBlock = true;
|
|
4165
|
+
leaseProvisioned = true;
|
|
4166
|
+
return { exitCode: receivedSignal ? SIGNAL_EXIT[receivedSignal] : EXIT.BACKEND, outcome: receivedSignal ? "interrupted" : "error", code: verdict.code,
|
|
4167
|
+
allocationRecovery: identifiedAdmissionRecovery(leaseArgs, null, verdict.ack),
|
|
4168
|
+
error: `identified admission ${leaseArgs.request_id} may have committed; its port claim is retained. Read it with ` +
|
|
4169
|
+
`\`bb stack status --admission-request-id ${leaseArgs.request_id}\`, then release any lease it names with ` +
|
|
4170
|
+
`\`bb stack done <lease-id> --reap --release-port-claim --admission-request-id ${leaseArgs.request_id}\`` };
|
|
4171
|
+
}
|
|
3956
4172
|
|
|
3957
4173
|
// Interrupted DURING the capacity park: the refused request created no lease, so
|
|
3958
4174
|
// there is nothing to release. (A signal during a request that DID admit falls
|
|
@@ -4267,7 +4483,9 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4267
4483
|
// BOT-2400 (item 1): the park deadline never gives the queue place up. Hold the lease
|
|
4268
4484
|
// for the owner's next run (which re-attaches to it) and exit PARK_DEADLINE; only
|
|
4269
4485
|
// `stack cancel` / `stack done` or the owner's death releases it.
|
|
4270
|
-
|
|
4486
|
+
// BOT-2780: a requester-origin run cannot be resumed by a later invocation (its stack
|
|
4487
|
+
// names this CLI's listener, which closes now), so it releases instead of holding a place.
|
|
4488
|
+
const queueHold = holdPlace && !execution.requesterOrigin ? await holdPlace(leaseId, HOLD_ON_TIMEOUT_MAX_SEC) : null;
|
|
4271
4489
|
if (queueHold) {
|
|
4272
4490
|
// A refused/lost hold reply is not an owner cancellation: it may have
|
|
4273
4491
|
// committed before the reply was lost, or the queued lease may have
|
|
@@ -4425,6 +4643,28 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4425
4643
|
leaseId, sshTarget, forwards: placement.forwards,
|
|
4426
4644
|
isLive: () => Boolean(tunnel && !tunnelLost && !receivedSignal && tunnel.isLive()),
|
|
4427
4645
|
});
|
|
4646
|
+
if (execution.requesterOrigin) {
|
|
4647
|
+
// BOT-2780: bind the reserved listener to exactly this lease's API forward. It is not
|
|
4648
|
+
// another SSH forward: connection.api_url/ports/attestation stay physical-only.
|
|
4649
|
+
try {
|
|
4650
|
+
execution.requesterOrigin.onLost((loss) => { originLost = loss; if (!receivedSignal && !childResult) stopChild("SIGTERM", EXIT.LEASE_FAILED); });
|
|
4651
|
+
execution.requesterOrigin.activate({
|
|
4652
|
+
context: execution.originContext, leaseId, projectId: current.data.connection.project_id, sshTarget,
|
|
4653
|
+
remoteConnection: current.data.connection, forwardedConnection: tunnel.connection, forwards: placement.forwards,
|
|
4654
|
+
isLive: () => Boolean(tunnel && forwardAttestation && !tunnelLost && !receivedSignal && tunnel.isLive()),
|
|
4655
|
+
watchLoss: (callback) => tunnel.onExit(callback),
|
|
4656
|
+
});
|
|
4657
|
+
} catch (error) {
|
|
4658
|
+
await closeTunnel();
|
|
4659
|
+
const cleanup = await requestCleanup();
|
|
4660
|
+
return { exitCode: cleanup.ok ? EXIT.BACKEND : EXIT.CLEANUP_FAILED, outcome: "error", code: error?.code ?? "REQUESTER_ORIGIN_FORWARD_REFUSED", leaseId, error: String(error?.message ?? error), cleanup };
|
|
4661
|
+
}
|
|
4662
|
+
placement.requesterOrigin = execution.requesterOrigin.origin;
|
|
4663
|
+
stderrLine(`stack: requester origin ${execution.requesterOrigin.origin} → ${placement.hostKey ?? "remote"} API (generated auth links use it unchanged)`);
|
|
4664
|
+
childConnection = { ...childConnection, requester_origin: execution.requesterOrigin.origin };
|
|
4665
|
+
}
|
|
4666
|
+
} else if (execution.requesterOrigin) {
|
|
4667
|
+
await closeOrigin();
|
|
4428
4668
|
}
|
|
4429
4669
|
connectionFile = await writeConnection(safeConnectionPath(opts.connectionFile, leaseId), childConnection);
|
|
4430
4670
|
if (receivedSignal) {
|
|
@@ -4461,6 +4701,12 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4461
4701
|
// there would fail on a missing package.json AFTER the stack was provisioned.
|
|
4462
4702
|
// The child therefore always runs in the (canonicalised) worktree root.
|
|
4463
4703
|
const childCwd = execution.worktreeRoot;
|
|
4704
|
+
if (!tunnelLost && execution.requesterOrigin && servedRemote && (originLost || !execution.requesterOrigin.isLive())) {
|
|
4705
|
+
await closeTunnel();
|
|
4706
|
+
const cleanup = await requestCleanup();
|
|
4707
|
+
return { exitCode: cleanup.ok ? EXIT.LEASE_FAILED : EXIT.CLEANUP_FAILED, outcome: "error", code: originLost?.code ?? "REQUESTER_ORIGIN_TUNNEL_LOST", leaseId, cleanup,
|
|
4708
|
+
error: "the requester origin closed before the child started; the lease was released" };
|
|
4709
|
+
}
|
|
4464
4710
|
if (tunnelLost) {
|
|
4465
4711
|
// The forwards died between being verified and the child starting.
|
|
4466
4712
|
await closeTunnel();
|
|
@@ -4521,6 +4767,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
4521
4767
|
try { execution.renew?.({ idleTtlSecs: LEASED_STACK_IDLE_RETENTION_SECS, idleRetained: true }); } catch { /* advisory */ }
|
|
4522
4768
|
stderrLine(`stack: lease ${leaseId} is idle${cleanup.idle_since ? ` since ${cleanup.idle_since}` : ""} — its stack keeps running for the next run on this slot (\`bb stack done ${leaseId} --reap\` tears it down)`);
|
|
4523
4769
|
}
|
|
4770
|
+
if (originLost && !tunnelLost && !receivedSignal) {
|
|
4771
|
+
return { exitCode: cleanup.ok ? EXIT.LEASE_FAILED : EXIT.CLEANUP_FAILED, outcome: "failed", code: originLost.code, leaseId, childExitCode: childResult.code, cleanup, fenced: true,
|
|
4772
|
+
error: "the requester origin closed while the child was running, so the child was stopped and the lease released" };
|
|
4773
|
+
}
|
|
4524
4774
|
if (tunnelLost && !receivedSignal) {
|
|
4525
4775
|
return tunnelLostResult(tunnelLost, { leaseId, cleanup, childExitCode: childResult.code, fenced: fencing, ...(parkIncident ? { incident: parkIncident } : {}) });
|
|
4526
4776
|
}
|
|
@@ -4617,6 +4867,9 @@ export function runReceiptFields(result) {
|
|
|
4617
4867
|
execution_host_key: result.execution?.hostKey ?? null,
|
|
4618
4868
|
requester_host: result.execution?.requesterHost ?? null,
|
|
4619
4869
|
transport: result.execution?.transport ?? "none",
|
|
4870
|
+
// BOT-2780: the reserved requester origin generated auth links name (never a credential).
|
|
4871
|
+
...(result.execution?.requesterOrigin ? { requester_origin: result.execution.requesterOrigin } : {}),
|
|
4872
|
+
...(result.allocationRecovery ? { allocation_recovery: result.allocationRecovery } : {}),
|
|
4620
4873
|
...(result.execution?.forwards ? { forwards: result.execution.forwards } : {}),
|
|
4621
4874
|
...(result.execution?.bundle ? { bundle: result.execution.bundle } : {}),
|
|
4622
4875
|
// BOT-2269: the opted-in files that shipped in that bundle — paths, sha256, key names.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Copyright (c) Squirrel Chat et al., All rights reserved.
|
|
2
|
+
|
|
3
|
+
Redistribution and use in source and binary forms, with or without
|
|
4
|
+
modification, are permitted provided that the following conditions are met:
|
|
5
|
+
|
|
6
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
7
|
+
list of conditions and the following disclaimer.
|
|
8
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
9
|
+
this list of conditions and the following disclaimer in the
|
|
10
|
+
documentation and/or other materials provided with the distribution.
|
|
11
|
+
3. Neither the name of the copyright holder nor the names of its contributors
|
|
12
|
+
may be used to endorse or promote products derived from this software without
|
|
13
|
+
specific prior written permission.
|
|
14
|
+
|
|
15
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
16
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
17
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
18
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
19
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
20
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
21
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
22
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
23
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
24
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) Squirrel Chat et al., All rights reserved.
|
|
3
|
+
* SPDX-License-Identifier: BSD-3-Clause
|
|
4
|
+
*
|
|
5
|
+
* Redistribution and use in source and binary forms, with or without
|
|
6
|
+
* modification, are permitted provided that the following conditions are met:
|
|
7
|
+
*
|
|
8
|
+
* 1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
* list of conditions and the following disclaimer.
|
|
10
|
+
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
11
|
+
* this list of conditions and the following disclaimer in the
|
|
12
|
+
* documentation and/or other materials provided with the distribution.
|
|
13
|
+
* 3. Neither the name of the copyright holder nor the names of its contributors
|
|
14
|
+
* may be used to endorse or promote products derived from this software without
|
|
15
|
+
* specific prior written permission.
|
|
16
|
+
*
|
|
17
|
+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
18
|
+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
19
|
+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
20
|
+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
21
|
+
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
22
|
+
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
23
|
+
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
24
|
+
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
25
|
+
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
26
|
+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
27
|
+
*/
|
|
28
|
+
let DATE_TIME_RE = /^(\d{4}-\d{2}-\d{2})?[Tt ]?(?:(\d{2}):\d{2}(?::\d{2}(?:\.\d+)?)?)?(Z|z|[-+]\d{2}:\d{2})?$/i;
|
|
29
|
+
export class TomlDate extends Date {
|
|
30
|
+
#hasDate = false;
|
|
31
|
+
#hasTime = false;
|
|
32
|
+
#offset = null;
|
|
33
|
+
constructor(date, fasttype, unsafeDelim) {
|
|
34
|
+
let hasDate = true;
|
|
35
|
+
let hasTime = true;
|
|
36
|
+
let offset = 'Z';
|
|
37
|
+
let c;
|
|
38
|
+
if (typeof date === 'string') {
|
|
39
|
+
if (fasttype)
|
|
40
|
+
prep: {
|
|
41
|
+
// Date-time
|
|
42
|
+
if (fasttype < 3) {
|
|
43
|
+
if (+date.slice(11, 13) > 23) {
|
|
44
|
+
date = '';
|
|
45
|
+
break prep;
|
|
46
|
+
}
|
|
47
|
+
// Local
|
|
48
|
+
if (fasttype === 2) {
|
|
49
|
+
offset = null;
|
|
50
|
+
date += 'Z';
|
|
51
|
+
}
|
|
52
|
+
// Offset; keep track of offset if not Z
|
|
53
|
+
else if ((c = date.charCodeAt(date.length - 1)) !== 0x5a /* Z */ && c !== 0x7a /* z */) {
|
|
54
|
+
offset = date.slice(date.length - 6);
|
|
55
|
+
}
|
|
56
|
+
if (unsafeDelim)
|
|
57
|
+
date = date.slice(0, 10) + 'T' + date.slice(11);
|
|
58
|
+
}
|
|
59
|
+
// Time
|
|
60
|
+
else if (fasttype === 4) {
|
|
61
|
+
date = +date.slice(0, 2) > 23 ? '' : `0000-01-01T${date}Z`;
|
|
62
|
+
}
|
|
63
|
+
hasDate = fasttype !== 4;
|
|
64
|
+
hasTime = fasttype !== 3;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
let match = date.match(DATE_TIME_RE);
|
|
68
|
+
if (match) {
|
|
69
|
+
if (!match[1]) {
|
|
70
|
+
hasDate = false;
|
|
71
|
+
date = `0000-01-01T${date}`;
|
|
72
|
+
}
|
|
73
|
+
hasTime = !!match[2];
|
|
74
|
+
// Make sure to use T instead of a space. Breaks in case of extreme values otherwise.
|
|
75
|
+
hasTime && date[10] === ' ' && (date = date.replace(' ', 'T'));
|
|
76
|
+
// Do not allow rollover hours.
|
|
77
|
+
if (match[2] && +match[2] > 23) {
|
|
78
|
+
date = '';
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
offset = match[3] || null;
|
|
82
|
+
if (!offset && hasTime)
|
|
83
|
+
date += 'Z';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
date = '';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
super(date);
|
|
92
|
+
if (!isNaN(this.getTime())) {
|
|
93
|
+
this.#hasDate = hasDate;
|
|
94
|
+
this.#hasTime = hasTime;
|
|
95
|
+
this.#offset = offset;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
isDateTime() {
|
|
99
|
+
return this.#hasDate && this.#hasTime;
|
|
100
|
+
}
|
|
101
|
+
isLocal() {
|
|
102
|
+
return !this.#hasDate || !this.#hasTime || !this.#offset;
|
|
103
|
+
}
|
|
104
|
+
isDate() {
|
|
105
|
+
return this.#hasDate && !this.#hasTime;
|
|
106
|
+
}
|
|
107
|
+
isTime() {
|
|
108
|
+
return this.#hasTime && !this.#hasDate;
|
|
109
|
+
}
|
|
110
|
+
isValid() {
|
|
111
|
+
return this.#hasDate || this.#hasTime;
|
|
112
|
+
}
|
|
113
|
+
toISOString() {
|
|
114
|
+
let iso = super.toISOString();
|
|
115
|
+
// Local Date
|
|
116
|
+
if (this.isDate())
|
|
117
|
+
return iso.slice(0, 10);
|
|
118
|
+
// Local Time
|
|
119
|
+
if (this.isTime())
|
|
120
|
+
return iso.slice(11, 23);
|
|
121
|
+
// Local DateTime
|
|
122
|
+
if (this.#offset === null)
|
|
123
|
+
return iso.slice(0, -1);
|
|
124
|
+
// Offset DateTime
|
|
125
|
+
if (this.#offset === 'Z' || this.#offset === 'z')
|
|
126
|
+
return iso;
|
|
127
|
+
// This part is quite annoying: JS strips the original timezone from the ISO string representation
|
|
128
|
+
// Instead of using a "modified" date and "Z", we restore the representation "as authored"
|
|
129
|
+
let offset = (+(this.#offset.slice(1, 3)) * 60) + +(this.#offset.slice(4, 6));
|
|
130
|
+
offset = this.#offset[0] === '-' ? offset : -offset;
|
|
131
|
+
let offsetDate = new Date(this.getTime() - (offset * 60e3));
|
|
132
|
+
return offsetDate.toISOString().slice(0, -1) + this.#offset;
|
|
133
|
+
}
|
|
134
|
+
static wrapAsOffsetDateTime(jsDate, offset = 'Z') {
|
|
135
|
+
let date = new TomlDate(jsDate);
|
|
136
|
+
date.#offset = offset;
|
|
137
|
+
return date;
|
|
138
|
+
}
|
|
139
|
+
static wrapAsLocalDateTime(jsDate) {
|
|
140
|
+
let date = new TomlDate(jsDate);
|
|
141
|
+
date.#offset = null;
|
|
142
|
+
return date;
|
|
143
|
+
}
|
|
144
|
+
static wrapAsLocalDate(jsDate) {
|
|
145
|
+
let date = new TomlDate(jsDate);
|
|
146
|
+
date.#hasTime = false;
|
|
147
|
+
date.#offset = null;
|
|
148
|
+
return date;
|
|
149
|
+
}
|
|
150
|
+
static wrapAsLocalTime(jsDate) {
|
|
151
|
+
let date = new TomlDate(jsDate);
|
|
152
|
+
date.#hasDate = false;
|
|
153
|
+
date.#offset = null;
|
|
154
|
+
return date;
|
|
155
|
+
}
|
|
156
|
+
}
|