@appforge-ci/core 0.2.3 → 0.3.1

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.
Files changed (125) hide show
  1. package/dist/agent-readiness.d.ts +240 -1
  2. package/dist/agent-readiness.d.ts.map +1 -1
  3. package/dist/agent-readiness.js +304 -1
  4. package/dist/agent-readiness.js.map +1 -1
  5. package/dist/agent-readiness.test.js +277 -19
  6. package/dist/agent-readiness.test.js.map +1 -1
  7. package/dist/api-client.d.ts +80 -3
  8. package/dist/api-client.d.ts.map +1 -1
  9. package/dist/api-client.js +79 -0
  10. package/dist/api-client.js.map +1 -1
  11. package/dist/api-client.test.js +22 -0
  12. package/dist/api-client.test.js.map +1 -1
  13. package/dist/appforge-verify.test.d.ts +2 -0
  14. package/dist/appforge-verify.test.d.ts.map +1 -0
  15. package/dist/appforge-verify.test.js +958 -0
  16. package/dist/appforge-verify.test.js.map +1 -0
  17. package/dist/attestation-keys.d.ts +20 -0
  18. package/dist/attestation-keys.d.ts.map +1 -0
  19. package/dist/attestation-keys.js +27 -0
  20. package/dist/attestation-keys.js.map +1 -0
  21. package/dist/attestation-keys.test.d.ts +2 -0
  22. package/dist/attestation-keys.test.d.ts.map +1 -0
  23. package/dist/attestation-keys.test.js +45 -0
  24. package/dist/attestation-keys.test.js.map +1 -0
  25. package/dist/attestation-signing.d.ts +226 -0
  26. package/dist/attestation-signing.d.ts.map +1 -0
  27. package/dist/attestation-signing.js +276 -0
  28. package/dist/attestation-signing.js.map +1 -0
  29. package/dist/attestation-signing.test.d.ts +2 -0
  30. package/dist/attestation-signing.test.d.ts.map +1 -0
  31. package/dist/attestation-signing.test.js +250 -0
  32. package/dist/attestation-signing.test.js.map +1 -0
  33. package/dist/attestation-workerd.test.d.ts +2 -0
  34. package/dist/attestation-workerd.test.d.ts.map +1 -0
  35. package/dist/attestation-workerd.test.js +183 -0
  36. package/dist/attestation-workerd.test.js.map +1 -0
  37. package/dist/attestation.d.ts +412 -0
  38. package/dist/attestation.d.ts.map +1 -0
  39. package/dist/attestation.js +353 -0
  40. package/dist/attestation.js.map +1 -0
  41. package/dist/attestation.test.d.ts +2 -0
  42. package/dist/attestation.test.d.ts.map +1 -0
  43. package/dist/attestation.test.js +218 -0
  44. package/dist/attestation.test.js.map +1 -0
  45. package/dist/build-vpn.d.ts +20 -0
  46. package/dist/build-vpn.d.ts.map +1 -0
  47. package/dist/build-vpn.js +50 -0
  48. package/dist/build-vpn.js.map +1 -0
  49. package/dist/fleet-vpn.d.ts +64 -0
  50. package/dist/fleet-vpn.d.ts.map +1 -0
  51. package/dist/fleet-vpn.js +88 -0
  52. package/dist/fleet-vpn.js.map +1 -0
  53. package/dist/fleet-vpn.test.d.ts +2 -0
  54. package/dist/fleet-vpn.test.d.ts.map +1 -0
  55. package/dist/fleet-vpn.test.js +69 -0
  56. package/dist/fleet-vpn.test.js.map +1 -0
  57. package/dist/index.d.ts +11 -0
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +11 -0
  60. package/dist/index.js.map +1 -1
  61. package/dist/schemas-repo-url.test.d.ts +2 -0
  62. package/dist/schemas-repo-url.test.d.ts.map +1 -0
  63. package/dist/schemas-repo-url.test.js +67 -0
  64. package/dist/schemas-repo-url.test.js.map +1 -0
  65. package/dist/schemas.d.ts +360 -7
  66. package/dist/schemas.d.ts.map +1 -1
  67. package/dist/schemas.js +148 -12
  68. package/dist/schemas.js.map +1 -1
  69. package/dist/ssh-deploy-key.d.ts +59 -0
  70. package/dist/ssh-deploy-key.d.ts.map +1 -0
  71. package/dist/ssh-deploy-key.js +124 -0
  72. package/dist/ssh-deploy-key.js.map +1 -0
  73. package/dist/ssh-deploy-key.test.d.ts +2 -0
  74. package/dist/ssh-deploy-key.test.d.ts.map +1 -0
  75. package/dist/ssh-deploy-key.test.js +117 -0
  76. package/dist/ssh-deploy-key.test.js.map +1 -0
  77. package/dist/types.d.ts +204 -0
  78. package/dist/types.d.ts.map +1 -1
  79. package/dist/types.js +0 -5
  80. package/dist/types.js.map +1 -1
  81. package/dist/vpn-attestation-workerd.test.d.ts +2 -0
  82. package/dist/vpn-attestation-workerd.test.d.ts.map +1 -0
  83. package/dist/vpn-attestation-workerd.test.js +184 -0
  84. package/dist/vpn-attestation-workerd.test.js.map +1 -0
  85. package/dist/vpn-attestation.d.ts +1015 -0
  86. package/dist/vpn-attestation.d.ts.map +1 -0
  87. package/dist/vpn-attestation.js +963 -0
  88. package/dist/vpn-attestation.js.map +1 -0
  89. package/dist/vpn-attestation.test.d.ts +2 -0
  90. package/dist/vpn-attestation.test.d.ts.map +1 -0
  91. package/dist/vpn-attestation.test.js +613 -0
  92. package/dist/vpn-attestation.test.js.map +1 -0
  93. package/dist/vpn-egress-presets.d.ts +142 -0
  94. package/dist/vpn-egress-presets.d.ts.map +1 -0
  95. package/dist/vpn-egress-presets.js +318 -0
  96. package/dist/vpn-egress-presets.js.map +1 -0
  97. package/dist/vpn-egress-presets.test.d.ts +2 -0
  98. package/dist/vpn-egress-presets.test.d.ts.map +1 -0
  99. package/dist/vpn-egress-presets.test.js +210 -0
  100. package/dist/vpn-egress-presets.test.js.map +1 -0
  101. package/dist/vpn-egress.d.ts +57 -0
  102. package/dist/vpn-egress.d.ts.map +1 -0
  103. package/dist/vpn-egress.js +126 -0
  104. package/dist/vpn-egress.js.map +1 -0
  105. package/dist/vpn-egress.test.d.ts +2 -0
  106. package/dist/vpn-egress.test.d.ts.map +1 -0
  107. package/dist/vpn-egress.test.js +133 -0
  108. package/dist/vpn-egress.test.js.map +1 -0
  109. package/dist/vpn-sandbox.d.ts +67 -0
  110. package/dist/vpn-sandbox.d.ts.map +1 -0
  111. package/dist/vpn-sandbox.js +22 -0
  112. package/dist/vpn-sandbox.js.map +1 -0
  113. package/dist/vpn-sandbox.test.d.ts +2 -0
  114. package/dist/vpn-sandbox.test.d.ts.map +1 -0
  115. package/dist/vpn-sandbox.test.js +34 -0
  116. package/dist/vpn-sandbox.test.js.map +1 -0
  117. package/dist/vpn-ssh.d.ts +138 -0
  118. package/dist/vpn-ssh.d.ts.map +1 -0
  119. package/dist/vpn-ssh.js +320 -0
  120. package/dist/vpn-ssh.js.map +1 -0
  121. package/dist/vpn-ssh.test.d.ts +2 -0
  122. package/dist/vpn-ssh.test.d.ts.map +1 -0
  123. package/dist/vpn-ssh.test.js +274 -0
  124. package/dist/vpn-ssh.test.js.map +1 -0
  125. package/package.json +1 -1
@@ -98,6 +98,183 @@ export declare function agentFitsPlatform(readiness: AgentReadiness, platform: P
98
98
  * getting bound somewhere it can't actually run.
99
99
  */
100
100
  export declare function agentCanBuildPlatform(agent: Parameters<typeof computeAgentReadiness>[0], platform: Platform, now?: Date): boolean;
101
+ /**
102
+ * Build-isolation level an agent reports (`Agent.enforcementLevel`,
103
+ * migrations/0043). Ordered: a higher level satisfies a lower requirement.
104
+ * - `none` no kernel-enforced isolation of build processes (today).
105
+ * - `sandbox` builds run under a kernel sandbox profile (Seatbelt).
106
+ * - `sandbox_uid` ... and as a dedicated unprivileged user.
107
+ * Anything unrecognised ranks as `none`: an unknown level never satisfies a
108
+ * requirement (fail closed).
109
+ */
110
+ export declare const ENFORCEMENT_LEVELS: readonly ["none", "sandbox", "sandbox_uid"];
111
+ export type EnforcementLevel = (typeof ENFORCEMENT_LEVELS)[number];
112
+ export declare function parseEnforcementLevel(raw: unknown): EnforcementLevel;
113
+ /** 0 (`none`) .. 2 (`sandbox_uid`); unknown input is 0. */
114
+ export declare function enforcementLevelRank(level: unknown): number;
115
+ /** What a feature id may look like (`Agent.vpnFeatures` entries, tunnel
116
+ * `requires` entries): lowercase, short, no whitespace or separators that
117
+ * could be abused in a log line or SQL JSON. Ids carry their own version
118
+ * suffix (`ssh-v1`) so a breaking change is a NEW id, never a redefinition. */
119
+ export declare const VPN_FEATURE_ID_PATTERN: RegExp;
120
+ /** An agent reports at most this many features; the rest are dropped. */
121
+ export declare const MAX_VPN_FEATURES = 32;
122
+ /**
123
+ * Feature ids the regulated-VPN program will introduce. Informational: the
124
+ * scheduler's superset check works on whatever ids a tunnel requires and an
125
+ * agent reports, so a new id needs no change here. Only `egress-policy-v1` is
126
+ * required (by a strict-egress tunnel) and reported (by an agent with a new
127
+ * enough verified helper) so far; the others land in later PRs.
128
+ */
129
+ export declare const KNOWN_VPN_FEATURES: readonly ["egress-policy-v1", "ssh-v1", "sandbox-v1", "evidence-v1"];
130
+ /** Prefix of a requirement token that asks for a minimum enforcement level
131
+ * (`enforcement:sandbox`) rather than a feature id. */
132
+ export declare const ENFORCEMENT_REQUIREMENT_PREFIX = "enforcement:";
133
+ /**
134
+ * The canonical form of a reported feature list: only well-formed ids,
135
+ * de-duplicated, sorted, capped at `MAX_VPN_FEATURES`. Anything else is
136
+ * dropped, never repaired. This is the ONLY form stored in
137
+ * `Agent.vpnFeatures`, so the scheduler's SQL superset check can compare
138
+ * strings exactly.
139
+ */
140
+ export declare function canonicalVpnFeatures(input: readonly unknown[] | null | undefined): string[];
141
+ /** Parses the `Agent.vpnFeatures` JSON column. Malformed, non-array or null
142
+ * input is `[]` (no features), never an exception. */
143
+ export declare function parseVpnFeatures(raw: string | null | undefined): string[];
144
+ /** What a tunnel needs from the Mac that serves its builds. */
145
+ export interface VpnRequirements {
146
+ /** Feature ids the agent must report (a superset check). */
147
+ features: readonly string[];
148
+ /** Minimum `enforcementLevel`. */
149
+ minEnforcementLevel: EnforcementLevel;
150
+ }
151
+ export declare const NO_VPN_REQUIREMENTS: VpnRequirements;
152
+ /** The feature id of the strict egress policy: the helper enforces a job's
153
+ * `egress` allow-list and echoes its hash, and the agent verifies the echo. A
154
+ * tunnel with `strictEgress` requires it (see `vpnTunnelRequirements`). */
155
+ export declare const EGRESS_POLICY_FEATURE = "egress-policy-v1";
156
+ /**
157
+ * The slice of a tunnel `vpnTunnelRequirements` reads: the status, plus the two
158
+ * settings that make a tunnel demand something of the Mac (migrations/0045).
159
+ * The columns are required (not optional) so a caller that builds this from a
160
+ * query cannot forget them: a forgotten setting would silently mean "nothing
161
+ * required".
162
+ */
163
+ export interface VpnTunnelRequirementSource {
164
+ status: "pending" | "ready" | "degraded" | "disabled" | null;
165
+ /** `VpnTunnel.strictEgress` (0/1). */
166
+ strictEgress: number | boolean | null;
167
+ /** `VpnTunnel.complianceMode` ('standard' | 'regulated'). */
168
+ complianceMode: string | null;
169
+ /** The build's app uses an ssh git remote (`isSshGitRemote(App.repoUrl)`, vpn-ssh.ts, migrations/0048).
170
+ * Required, like the two settings above, so a caller cannot forget it: the clone would then run on a Mac
171
+ * that cannot do ssh. Only an exact `true` means "ssh"; the callers compute it from the app row. */
172
+ sshRepo: boolean;
173
+ }
174
+ /**
175
+ * What the given tunnel requires of an agent. This is the single place a tunnel
176
+ * setting makes a requirement; the scheduler, the guarded claim SQL, the lease
177
+ * response (`job.requires`) and the agent's abort check all consume this one
178
+ * answer.
179
+ *
180
+ * - `strictEgress`: the Mac must report `egress-policy-v1`, because an agent or
181
+ * helper that predates the policy ignores the `egress` block and would run
182
+ * the build with no allow-list. Only an exact `0`/`false` means "not strict":
183
+ * a missing, null or unrecognised value requires it (fail closed).
184
+ * - `complianceMode` other than exactly `standard` (regulated, or a value this
185
+ * code does not know): strict egress as above, and at least `sandbox`
186
+ * enforcement. No Mac reports a sandbox yet, so a regulated tunnel's builds
187
+ * wait for one rather than run unconfined.
188
+ *
189
+ * - `sshRepo` (the app's repository is an ssh remote, PR-G): the Mac must report
190
+ * `ssh-v1`, because an agent or helper that predates it cannot clone through
191
+ * the tunnel's pinned-host-key ssh path and would ignore the ssh fields.
192
+ *
193
+ * A standard-mode tunnel with strict egress off and an https repository requires
194
+ * nothing, exactly as before, so the server can ship ahead of any agent.
195
+ */
196
+ export declare function vpnTunnelRequirements(tunnel: VpnTunnelRequirementSource): VpnRequirements;
197
+ /** The wire form of requirements, carried in the lease response as
198
+ * `job.requires`: feature ids plus `enforcement:<level>` for a level above
199
+ * `none`. An agent aborts a job with any token it cannot satisfy. */
200
+ export declare function vpnRequirementTokens(req: VpnRequirements): string[];
201
+ /**
202
+ * Which of `tokens` the capabilities do NOT satisfy. A token is satisfied only
203
+ * if it is a feature id the capabilities list, or `enforcement:<level>` with a
204
+ * KNOWN level the capabilities reach. An unknown token, an unknown level, or a
205
+ * non-string entry is unmet: this is what makes the check fail closed against a
206
+ * requirement introduced by a newer control plane than the agent. A non-array
207
+ * `tokens` is itself unmet (reported as one entry).
208
+ *
209
+ * Used by both the scheduler (`agentSatisfiesVpnRequirements`) and the agent
210
+ * (before it starts a leased VPN job), so they cannot disagree.
211
+ */
212
+ export declare function unmetRequirements(tokens: unknown, capabilities: {
213
+ features: readonly string[];
214
+ enforcementLevel: unknown;
215
+ }): string[];
216
+ /**
217
+ * Operator taint (`Agent.dedicatedOrgId`): a Mac dedicated to an org takes only
218
+ * that org's builds, VPN or not. `null` = shared. An empty string is NOT shared:
219
+ * only an explicit null is (a mangled value must not widen who may build here).
220
+ */
221
+ export declare function agentAllowsOrg(dedicatedOrgId: string | null, buildOrgId: string): boolean;
222
+ /**
223
+ * Where a tunnel's builds may run (`VpnTunnel.hostPolicy`, migrations/0047).
224
+ * - `shared` any approved, VPN-capable Mac, under the exclusive-host lock
225
+ * (today's behaviour, and the default).
226
+ * - `pinned` only Macs the operator pinned to the tunnel (node affinity).
227
+ * - `dedicated` pinned, AND the Mac is dedicated to this org
228
+ * (`Agent.dedicatedOrgId`, so no other org's work is leased to
229
+ * it), AND it reports no co-hosted GitHub Actions runner.
230
+ * Set only by a platform admin; org admins can read it, never write it.
231
+ */
232
+ export declare const VPN_HOST_POLICIES: readonly ["shared", "pinned", "dedicated"];
233
+ export type VpnHostPolicy = (typeof VPN_HOST_POLICIES)[number];
234
+ /**
235
+ * What a customer sees for their tunnel's host policy (read-only; the operator
236
+ * sets it). Deliberately plain about the limits: "dedicated" is a scheduling
237
+ * guarantee about which machines AppForge runs this org's VPN builds on, not a
238
+ * claim of hardware isolation or of any compliance certification.
239
+ */
240
+ export declare function describeVpnHostPolicy(policy: VpnHostPolicy | string, pinnedHostCount?: number): string;
241
+ /** How long a queued VPN build of a `pinned`/`dedicated` tunnel waits for a ready
242
+ * pinned Mac before the control plane's sweep fails it as an infra failure with a
243
+ * refund. The wait is measured as CONTINUOUS host unavailability, not build age:
244
+ * the sweep records when the tunnel's pinned Macs stopped being ready
245
+ * (`VpnTunnelHostWait`) and fails a build only once they have all been unready
246
+ * for this long AND the build itself has been queued this long. Any tick that
247
+ * finds a ready Mac (busy counts as ready) restarts the clock, so a Mac that is
248
+ * briefly away (a reboot or self-update cycle, a short drain) does not fail a
249
+ * build that has merely been waiting in line. Longer than the 120 s
250
+ * lock-reservation window. The sweep runs every 5 minutes, so the effective
251
+ * wait is this plus up to one cron interval. */
252
+ export declare const VPN_HOST_WAIT_SECONDS = 600;
253
+ /** If the sweep's last observation of a tunnel's pinned hosts is older than this
254
+ * (three cron ticks), it knows nothing about the gap, so the next "unready"
255
+ * observation starts a fresh unavailability stretch instead of extending an old one. */
256
+ export declare const VPN_HOST_OBSERVATION_GAP_SECONDS = 900;
257
+ /**
258
+ * Parses a stored policy. An UNKNOWN value is `dedicated`, the strictest, not
259
+ * `shared`: a mangled policy must tighten placement, never loosen it (the
260
+ * column's CHECK makes this unreachable today; it is here so a future value
261
+ * read by an older Worker fails closed).
262
+ */
263
+ export declare function parseVpnHostPolicy(raw: unknown): VpnHostPolicy;
264
+ /**
265
+ * Whether an agent may serve a VPN build under the tunnel's host policy.
266
+ * - `hostPinned` is "this Mac is in the tunnel's pin set".
267
+ * - `dedicatedOrgId` is the Mac's operator taint.
268
+ * - `coHostedRunner` is what its last (fresh) heartbeat reported: only an
269
+ * exact 0 means "no GitHub Actions runner on the machine". 1, null
270
+ * (unreported) and anything else all refuse for `dedicated`.
271
+ * `shared` always passes (and is the only case that needs no pin).
272
+ */
273
+ export declare function agentSatisfiesHostPolicy(policy: VpnHostPolicy | string, orgId: string, agent: {
274
+ hostPinned: boolean;
275
+ dedicatedOrgId: string | null;
276
+ coHostedRunner: number | null;
277
+ }): boolean;
101
278
  /**
102
279
  * The VPN-related slice of an agent row the scheduler needs — kept as its
103
280
  * own structural type (not added to `AgentInfo`) because it comes straight
@@ -112,6 +289,21 @@ export interface VpnSchedulingAgent {
112
289
  vpnApproved: number;
113
290
  /** The org this Mac is exclusively locked to, or null when unlocked. */
114
291
  vpnLockOrgId: string | null;
292
+ /** Features the agent proved it can enforce — the caller passes `[]` unless
293
+ * the heartbeat that reported them is fresh (see `agentProvesVpnEnforcement`). */
294
+ vpnFeatures: readonly string[];
295
+ /** Same provenance and freshness rule as `vpnFeatures`. */
296
+ enforcementLevel: EnforcementLevel;
297
+ /** Operator-set taint (migrations/0043): the one org this Mac may serve, or
298
+ * null for a shared Mac. Required (not optional) so a caller can never
299
+ * forget it: forgetting it would silently un-taint a dedicated Mac. */
300
+ dedicatedOrgId: string | null;
301
+ /** What the last heartbeat reported about a co-hosted GitHub Actions runner
302
+ * (migrations/0047): 1 present, 0 absent, null unknown. As with
303
+ * `vpnFeatures`, the caller passes null unless that heartbeat is fresh.
304
+ * Required so a caller cannot forget it (forgetting would let a dedicated
305
+ * policy pass on a stale value). */
306
+ coHostedRunner: number | null;
115
307
  }
116
308
  /** The slice of a queued-build candidate (`@appforge/db`'s
117
309
  * `QueuedBuildCandidate`) the VPN predicate reads. */
@@ -120,17 +312,34 @@ export interface VpnSchedulingBuild {
120
312
  useVpn: boolean;
121
313
  /** The build's org's tunnel status, or null when it has no tunnel. */
122
314
  vpnTunnelStatus: "pending" | "ready" | "degraded" | "disabled" | null;
315
+ /** What the build's tunnel requires of the Mac (`vpnTunnelRequirements`).
316
+ * Ignored for a build that doesn't use the VPN. */
317
+ requirements: VpnRequirements;
318
+ /** The build's tunnel's host policy (`VpnTunnel.hostPolicy`, migrations/0047).
319
+ * Ignored for a build that doesn't use the VPN. */
320
+ hostPolicy: VpnHostPolicy;
321
+ /** Whether the asking agent is pinned to the build's tunnel
322
+ * (`VpnTunnelHostPin`). Required: forgetting it must read as "not pinned". */
323
+ agentPinned: boolean;
123
324
  }
124
325
  /**
125
326
  * Scheduler predicate for the site-to-site VPN's exclusive-host lock —
126
327
  * the "taint/toleration" check, ANDed alongside `agentCanBuildPlatform` in
127
328
  * `/agents/lease-job` before any `claimBuildById` attempt:
128
329
  *
330
+ * - A Mac dedicated to an org (`dedicatedOrgId`) refuses every other org's
331
+ * builds, VPN or not.
129
332
  * - A build that doesn't use the VPN is never offered to a Mac that is
130
333
  * locked to an org (the lock means "only that org's VPN builds here").
131
334
  * - A VPN build needs a VPN-capable AND admin-approved Mac (issue #643), a tunnel that exists and isn't
132
335
  * `disabled`, and a Mac that is either unlocked or already locked to
133
336
  * this same org (same-org VPN builds share the lock).
337
+ * - A VPN build also needs a Mac that satisfies the tunnel's requirements:
338
+ * it reports every required feature and a high enough enforcement level
339
+ * (capability negotiation, migrations/0043). Nothing is required today.
340
+ * - A VPN build whose tunnel is `pinned` or `dedicated` (migrations/0047) only
341
+ * goes to a Mac pinned to that tunnel, and `dedicated` additionally needs the
342
+ * Mac dedicated to the org with no co-hosted GitHub Actions runner.
134
343
  *
135
344
  * This is an optimization, not the safety net: it filters on the agent
136
345
  * row loaded at the start of the request, which can be stale by the time
@@ -141,6 +350,36 @@ export interface VpnSchedulingBuild {
141
350
  * proves it works.
142
351
  */
143
352
  export declare function agentSatisfiesVpnRequirements(agent: VpnSchedulingAgent, build: VpnSchedulingBuild): boolean;
353
+ /** The first agent release that can enforce a VPN job (it ships the signed
354
+ * WireGuard helper and honours `job.vpn`). Anything older IGNORES `job.vpn`
355
+ * and would run the build with no tunnel, so it must never be leased one,
356
+ * whatever its (possibly stale) `Agent.vpnCapable` row says. */
357
+ export declare const MIN_VPN_AGENT_VERSION = "0.2.0";
358
+ /** How recent `Agent.lastSeenAt` must be for `vpnCapable` to count: the flag
359
+ * is only as good as the heartbeat that last set it. Three missed 15s beats
360
+ * plus slack — and well under the 60s-ish scheduler cadence's tolerance. */
361
+ export declare const VPN_CAPABILITY_MAX_AGE_SECONDS = 120;
362
+ /**
363
+ * Numeric `major.minor.patch` comparison (a `-prerelease`/`+build` suffix is
364
+ * ignored). Returns <0, 0, >0, or `null` when either side isn't a plain
365
+ * semver — callers treat `null` as "unknown", never as "new enough".
366
+ */
367
+ export declare function compareSemver(a: string, b: string): number | null;
368
+ /** True only for a known version >= `MIN_VPN_AGENT_VERSION`. A missing or
369
+ * unparseable version is NOT capable (fail closed). */
370
+ export declare function agentVersionSupportsVpn(agentVersion: string | null | undefined): boolean;
371
+ /**
372
+ * Server-side proof that the leasing agent can actually enforce a VPN job,
373
+ * independent of the sticky-ish `vpnCapable` bit: the agent's reported
374
+ * version must be >= `MIN_VPN_AGENT_VERSION` and its last heartbeat (which is
375
+ * what (re-)asserts `vpnCapable`) must be recent. ANDed with
376
+ * `agentSatisfiesVpnRequirements` in `/agents/lease-job`; the guarded SQL in
377
+ * `claimBuildById`/`acquireVpnLock` re-checks freshness and pins the version.
378
+ */
379
+ export declare function agentProvesVpnEnforcement(agent: {
380
+ agentVersion: string | null;
381
+ lastSeenAt: string;
382
+ }, now?: Date): boolean;
144
383
  /**
145
384
  * The currently published packages/agent build — bump this in lockstep
146
385
  * with packages/agent/package.json's own "version" field (and
@@ -154,7 +393,7 @@ export declare function agentSatisfiesVpnRequirements(agent: VpnSchedulingAgent,
154
393
  * version against this same constant to decide whether to auto-issue an
155
394
  * `UPDATE_AGENT` command (Phase 2's remote command, reused unchanged).
156
395
  */
157
- export declare const CURRENT_AGENT_VERSION = "0.2.3";
396
+ export declare const CURRENT_AGENT_VERSION = "0.3.1";
158
397
  /**
159
398
  * Whether a fleet member is running the latest known agent build.
160
399
  * `null` — deliberately not "outdated" — for a row with no reported
@@ -1 +1 @@
1
- {"version":3,"file":"agent-readiness.d.ts","sourceRoot":"","sources":["../src/agent-readiness.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEtD;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,cAAc;IAC7B,YAAY,EAAE,OAAO,CAAC;IACtB,QAAQ,EAAE,OAAO,CAAC;IAClB;;;sBAGkB;IAClB,YAAY,EAAE,OAAO,CAAC;IACtB;;;oCAGgC;IAChC,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,oBAAoB,QAAS,CAAC;AAE3C;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAEhD;AAED,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,GAAE,IAAiB,GAAG,OAAO,CAEhF;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,IAAI,CAAC,SAAS,EAAE,QAAQ,GAAG,YAAY,CAAC,EAC/C,GAAG,GAAE,IAAiB,GACrB,SAAS,CAAC,QAAQ,CAAC,CAErB;AAED,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,IAAI,CACT,SAAS,EACT,cAAc,GAAG,aAAa,GAAG,mBAAmB,GAAG,kBAAkB,GAAG,gBAAgB,GAAG,QAAQ,GAAG,YAAY,CACvH,EACD,GAAG,GAAE,IAAiB,GACrB,cAAc,CAUhB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,cAAc,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAYxF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,UAAU,CAAC,OAAO,qBAAqB,CAAC,CAAC,CAAC,CAAC,EAClD,QAAQ,EAAE,QAAQ,EAClB,GAAG,GAAE,IAAiB,GACrB,OAAO,CAET;AAED;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IACjC,uDAAuD;IACvD,UAAU,EAAE,MAAM,CAAC;IACnB;+EAC2E;IAC3E,WAAW,EAAE,MAAM,CAAC;IACpB,wEAAwE;IACxE,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED;uDACuD;AACvD,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,sEAAsE;IACtE,eAAe,EAAE,SAAS,GAAG,OAAO,GAAG,UAAU,GAAG,UAAU,GAAG,IAAI,CAAC;CACvE;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAK3G;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,UAAU,CAAC;AAE7C;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,GAAG,IAAI,CAGjF;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,qBAAqB,CAAC,qBAAqB,EAAE,MAAM,GAAG,IAAI,EAAE,sBAAsB,EAAE,OAAO,GAAG,OAAO,CAEpH;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAOzD"}
1
+ {"version":3,"file":"agent-readiness.d.ts","sourceRoot":"","sources":["../src/agent-readiness.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,4 @@
1
+ import { SSH_FEATURE } from "./vpn-ssh.js";
1
2
  /**
2
3
  * A mini's `status` column is only ever written as a side effect of an
3
4
  * inbound heartbeat (see updateAgentHeartbeat in packages/db/src/agents.ts)
@@ -100,16 +101,256 @@ export function agentFitsPlatform(readiness, platform) {
100
101
  export function agentCanBuildPlatform(agent, platform, now = new Date()) {
101
102
  return agentFitsPlatform(computeAgentReadiness(agent, now), platform);
102
103
  }
104
+ /**
105
+ * Build-isolation level an agent reports (`Agent.enforcementLevel`,
106
+ * migrations/0043). Ordered: a higher level satisfies a lower requirement.
107
+ * - `none` no kernel-enforced isolation of build processes (today).
108
+ * - `sandbox` builds run under a kernel sandbox profile (Seatbelt).
109
+ * - `sandbox_uid` ... and as a dedicated unprivileged user.
110
+ * Anything unrecognised ranks as `none`: an unknown level never satisfies a
111
+ * requirement (fail closed).
112
+ */
113
+ export const ENFORCEMENT_LEVELS = ["none", "sandbox", "sandbox_uid"];
114
+ export function parseEnforcementLevel(raw) {
115
+ return typeof raw === "string" && ENFORCEMENT_LEVELS.includes(raw) ? raw : "none";
116
+ }
117
+ /** 0 (`none`) .. 2 (`sandbox_uid`); unknown input is 0. */
118
+ export function enforcementLevelRank(level) {
119
+ return ENFORCEMENT_LEVELS.indexOf(parseEnforcementLevel(level));
120
+ }
121
+ /** What a feature id may look like (`Agent.vpnFeatures` entries, tunnel
122
+ * `requires` entries): lowercase, short, no whitespace or separators that
123
+ * could be abused in a log line or SQL JSON. Ids carry their own version
124
+ * suffix (`ssh-v1`) so a breaking change is a NEW id, never a redefinition. */
125
+ export const VPN_FEATURE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
126
+ /** An agent reports at most this many features; the rest are dropped. */
127
+ export const MAX_VPN_FEATURES = 32;
128
+ /**
129
+ * Feature ids the regulated-VPN program will introduce. Informational: the
130
+ * scheduler's superset check works on whatever ids a tunnel requires and an
131
+ * agent reports, so a new id needs no change here. Only `egress-policy-v1` is
132
+ * required (by a strict-egress tunnel) and reported (by an agent with a new
133
+ * enough verified helper) so far; the others land in later PRs.
134
+ */
135
+ export const KNOWN_VPN_FEATURES = ["egress-policy-v1", "ssh-v1", "sandbox-v1", "evidence-v1"];
136
+ /** Prefix of a requirement token that asks for a minimum enforcement level
137
+ * (`enforcement:sandbox`) rather than a feature id. */
138
+ export const ENFORCEMENT_REQUIREMENT_PREFIX = "enforcement:";
139
+ /**
140
+ * The canonical form of a reported feature list: only well-formed ids,
141
+ * de-duplicated, sorted, capped at `MAX_VPN_FEATURES`. Anything else is
142
+ * dropped, never repaired. This is the ONLY form stored in
143
+ * `Agent.vpnFeatures`, so the scheduler's SQL superset check can compare
144
+ * strings exactly.
145
+ */
146
+ export function canonicalVpnFeatures(input) {
147
+ if (!Array.isArray(input))
148
+ return [];
149
+ const ok = new Set();
150
+ for (const v of input)
151
+ if (typeof v === "string" && VPN_FEATURE_ID_PATTERN.test(v))
152
+ ok.add(v);
153
+ return [...ok].sort().slice(0, MAX_VPN_FEATURES);
154
+ }
155
+ /** Parses the `Agent.vpnFeatures` JSON column. Malformed, non-array or null
156
+ * input is `[]` (no features), never an exception. */
157
+ export function parseVpnFeatures(raw) {
158
+ if (!raw)
159
+ return [];
160
+ try {
161
+ return canonicalVpnFeatures(JSON.parse(raw));
162
+ }
163
+ catch {
164
+ return [];
165
+ }
166
+ }
167
+ export const NO_VPN_REQUIREMENTS = { features: [], minEnforcementLevel: "none" };
168
+ /** The feature id of the strict egress policy: the helper enforces a job's
169
+ * `egress` allow-list and echoes its hash, and the agent verifies the echo. A
170
+ * tunnel with `strictEgress` requires it (see `vpnTunnelRequirements`). */
171
+ export const EGRESS_POLICY_FEATURE = "egress-policy-v1";
172
+ /**
173
+ * What the given tunnel requires of an agent. This is the single place a tunnel
174
+ * setting makes a requirement; the scheduler, the guarded claim SQL, the lease
175
+ * response (`job.requires`) and the agent's abort check all consume this one
176
+ * answer.
177
+ *
178
+ * - `strictEgress`: the Mac must report `egress-policy-v1`, because an agent or
179
+ * helper that predates the policy ignores the `egress` block and would run
180
+ * the build with no allow-list. Only an exact `0`/`false` means "not strict":
181
+ * a missing, null or unrecognised value requires it (fail closed).
182
+ * - `complianceMode` other than exactly `standard` (regulated, or a value this
183
+ * code does not know): strict egress as above, and at least `sandbox`
184
+ * enforcement. No Mac reports a sandbox yet, so a regulated tunnel's builds
185
+ * wait for one rather than run unconfined.
186
+ *
187
+ * - `sshRepo` (the app's repository is an ssh remote, PR-G): the Mac must report
188
+ * `ssh-v1`, because an agent or helper that predates it cannot clone through
189
+ * the tunnel's pinned-host-key ssh path and would ignore the ssh fields.
190
+ *
191
+ * A standard-mode tunnel with strict egress off and an https repository requires
192
+ * nothing, exactly as before, so the server can ship ahead of any agent.
193
+ */
194
+ export function vpnTunnelRequirements(tunnel) {
195
+ const regulated = tunnel.complianceMode !== "standard";
196
+ const strict = regulated || (tunnel.strictEgress !== 0 && tunnel.strictEgress !== false);
197
+ const features = [];
198
+ if (strict)
199
+ features.push(EGRESS_POLICY_FEATURE);
200
+ if (tunnel.sshRepo === true)
201
+ features.push(SSH_FEATURE);
202
+ if (features.length === 0)
203
+ return NO_VPN_REQUIREMENTS;
204
+ return { features, minEnforcementLevel: regulated ? "sandbox" : "none" };
205
+ }
206
+ /** The wire form of requirements, carried in the lease response as
207
+ * `job.requires`: feature ids plus `enforcement:<level>` for a level above
208
+ * `none`. An agent aborts a job with any token it cannot satisfy. */
209
+ export function vpnRequirementTokens(req) {
210
+ // Verbatim, deliberately NOT `canonicalVpnFeatures`: that drops malformed ids
211
+ // and truncates at MAX_VPN_FEATURES, which is right for untrusted agent-reported
212
+ // input but would silently remove a REQUIRED feature here (fail open). A
213
+ // malformed or surplus required id must survive so `unmetRequirements` flags it
214
+ // as unmet — matching the guarded SQL, which binds the raw list.
215
+ const tokens = [...new Set(req.features)].sort();
216
+ if (enforcementLevelRank(req.minEnforcementLevel) > 0)
217
+ tokens.push(`${ENFORCEMENT_REQUIREMENT_PREFIX}${req.minEnforcementLevel}`);
218
+ return tokens;
219
+ }
220
+ /** Printable, bounded rendering of an untrusted token for logs and errors. */
221
+ function describeToken(token) {
222
+ if (typeof token !== "string")
223
+ return "<non-string>";
224
+ return token.replace(/[^\x21-\x7e]/g, "?").slice(0, 80);
225
+ }
226
+ /**
227
+ * Which of `tokens` the capabilities do NOT satisfy. A token is satisfied only
228
+ * if it is a feature id the capabilities list, or `enforcement:<level>` with a
229
+ * KNOWN level the capabilities reach. An unknown token, an unknown level, or a
230
+ * non-string entry is unmet: this is what makes the check fail closed against a
231
+ * requirement introduced by a newer control plane than the agent. A non-array
232
+ * `tokens` is itself unmet (reported as one entry).
233
+ *
234
+ * Used by both the scheduler (`agentSatisfiesVpnRequirements`) and the agent
235
+ * (before it starts a leased VPN job), so they cannot disagree.
236
+ */
237
+ export function unmetRequirements(tokens, capabilities) {
238
+ if (!Array.isArray(tokens))
239
+ return ["<malformed requires>"];
240
+ const have = new Set(capabilities.features);
241
+ const haveRank = enforcementLevelRank(capabilities.enforcementLevel);
242
+ const unmet = [];
243
+ for (const token of tokens) {
244
+ if (typeof token === "string" && token.startsWith(ENFORCEMENT_REQUIREMENT_PREFIX)) {
245
+ const level = token.slice(ENFORCEMENT_REQUIREMENT_PREFIX.length);
246
+ const known = ENFORCEMENT_LEVELS.includes(level);
247
+ if (!known || haveRank < enforcementLevelRank(level))
248
+ unmet.push(describeToken(token));
249
+ }
250
+ else if (typeof token !== "string" || !VPN_FEATURE_ID_PATTERN.test(token) || !have.has(token)) {
251
+ unmet.push(describeToken(token));
252
+ }
253
+ }
254
+ return unmet;
255
+ }
256
+ /**
257
+ * Operator taint (`Agent.dedicatedOrgId`): a Mac dedicated to an org takes only
258
+ * that org's builds, VPN or not. `null` = shared. An empty string is NOT shared:
259
+ * only an explicit null is (a mangled value must not widen who may build here).
260
+ */
261
+ export function agentAllowsOrg(dedicatedOrgId, buildOrgId) {
262
+ return dedicatedOrgId === null || dedicatedOrgId === buildOrgId;
263
+ }
264
+ /**
265
+ * Where a tunnel's builds may run (`VpnTunnel.hostPolicy`, migrations/0047).
266
+ * - `shared` any approved, VPN-capable Mac, under the exclusive-host lock
267
+ * (today's behaviour, and the default).
268
+ * - `pinned` only Macs the operator pinned to the tunnel (node affinity).
269
+ * - `dedicated` pinned, AND the Mac is dedicated to this org
270
+ * (`Agent.dedicatedOrgId`, so no other org's work is leased to
271
+ * it), AND it reports no co-hosted GitHub Actions runner.
272
+ * Set only by a platform admin; org admins can read it, never write it.
273
+ */
274
+ export const VPN_HOST_POLICIES = ["shared", "pinned", "dedicated"];
275
+ /**
276
+ * What a customer sees for their tunnel's host policy (read-only; the operator
277
+ * sets it). Deliberately plain about the limits: "dedicated" is a scheduling
278
+ * guarantee about which machines AppForge runs this org's VPN builds on, not a
279
+ * claim of hardware isolation or of any compliance certification.
280
+ */
281
+ export function describeVpnHostPolicy(policy, pinnedHostCount = 0) {
282
+ const n = `${pinnedHostCount} build machine${pinnedHostCount === 1 ? "" : "s"}`;
283
+ switch (parseVpnHostPolicy(policy)) {
284
+ case "shared":
285
+ return "shared: builds may run on any approved AppForge build machine, one organization's VPN tunnel at a time";
286
+ case "pinned":
287
+ return `pinned: VPN builds run only on the ${n} AppForge assigned to your tunnel`;
288
+ case "dedicated":
289
+ return `dedicated: VPN builds run only on the ${n} AppForge assigned to your tunnel, which take no other organization's builds. This is a scheduling arrangement, not a compliance certification`;
290
+ }
291
+ }
292
+ /** How long a queued VPN build of a `pinned`/`dedicated` tunnel waits for a ready
293
+ * pinned Mac before the control plane's sweep fails it as an infra failure with a
294
+ * refund. The wait is measured as CONTINUOUS host unavailability, not build age:
295
+ * the sweep records when the tunnel's pinned Macs stopped being ready
296
+ * (`VpnTunnelHostWait`) and fails a build only once they have all been unready
297
+ * for this long AND the build itself has been queued this long. Any tick that
298
+ * finds a ready Mac (busy counts as ready) restarts the clock, so a Mac that is
299
+ * briefly away (a reboot or self-update cycle, a short drain) does not fail a
300
+ * build that has merely been waiting in line. Longer than the 120 s
301
+ * lock-reservation window. The sweep runs every 5 minutes, so the effective
302
+ * wait is this plus up to one cron interval. */
303
+ export const VPN_HOST_WAIT_SECONDS = 600;
304
+ /** If the sweep's last observation of a tunnel's pinned hosts is older than this
305
+ * (three cron ticks), it knows nothing about the gap, so the next "unready"
306
+ * observation starts a fresh unavailability stretch instead of extending an old one. */
307
+ export const VPN_HOST_OBSERVATION_GAP_SECONDS = 900;
308
+ /**
309
+ * Parses a stored policy. An UNKNOWN value is `dedicated`, the strictest, not
310
+ * `shared`: a mangled policy must tighten placement, never loosen it (the
311
+ * column's CHECK makes this unreachable today; it is here so a future value
312
+ * read by an older Worker fails closed).
313
+ */
314
+ export function parseVpnHostPolicy(raw) {
315
+ return typeof raw === "string" && VPN_HOST_POLICIES.includes(raw) ? raw : "dedicated";
316
+ }
317
+ /**
318
+ * Whether an agent may serve a VPN build under the tunnel's host policy.
319
+ * - `hostPinned` is "this Mac is in the tunnel's pin set".
320
+ * - `dedicatedOrgId` is the Mac's operator taint.
321
+ * - `coHostedRunner` is what its last (fresh) heartbeat reported: only an
322
+ * exact 0 means "no GitHub Actions runner on the machine". 1, null
323
+ * (unreported) and anything else all refuse for `dedicated`.
324
+ * `shared` always passes (and is the only case that needs no pin).
325
+ */
326
+ export function agentSatisfiesHostPolicy(policy, orgId, agent) {
327
+ const p = parseVpnHostPolicy(policy);
328
+ if (p === "shared")
329
+ return true;
330
+ if (!agent.hostPinned)
331
+ return false;
332
+ if (p === "pinned")
333
+ return true;
334
+ return agent.dedicatedOrgId === orgId && agent.coHostedRunner === 0;
335
+ }
103
336
  /**
104
337
  * Scheduler predicate for the site-to-site VPN's exclusive-host lock —
105
338
  * the "taint/toleration" check, ANDed alongside `agentCanBuildPlatform` in
106
339
  * `/agents/lease-job` before any `claimBuildById` attempt:
107
340
  *
341
+ * - A Mac dedicated to an org (`dedicatedOrgId`) refuses every other org's
342
+ * builds, VPN or not.
108
343
  * - A build that doesn't use the VPN is never offered to a Mac that is
109
344
  * locked to an org (the lock means "only that org's VPN builds here").
110
345
  * - A VPN build needs a VPN-capable AND admin-approved Mac (issue #643), a tunnel that exists and isn't
111
346
  * `disabled`, and a Mac that is either unlocked or already locked to
112
347
  * this same org (same-org VPN builds share the lock).
348
+ * - A VPN build also needs a Mac that satisfies the tunnel's requirements:
349
+ * it reports every required feature and a high enough enforcement level
350
+ * (capability negotiation, migrations/0043). Nothing is required today.
351
+ * - A VPN build whose tunnel is `pinned` or `dedicated` (migrations/0047) only
352
+ * goes to a Mac pinned to that tunnel, and `dedicated` additionally needs the
353
+ * Mac dedicated to the org with no co-hosted GitHub Actions runner.
113
354
  *
114
355
  * This is an optimization, not the safety net: it filters on the agent
115
356
  * row loaded at the start of the request, which can be stale by the time
@@ -120,14 +361,76 @@ export function agentCanBuildPlatform(agent, platform, now = new Date()) {
120
361
  * proves it works.
121
362
  */
122
363
  export function agentSatisfiesVpnRequirements(agent, build) {
364
+ if (!agentAllowsOrg(agent.dedicatedOrgId, build.orgId))
365
+ return false;
123
366
  if (!build.useVpn)
124
367
  return agent.vpnLockOrgId === null;
125
368
  if (agent.vpnCapable !== 1 || agent.vpnApproved !== 1)
126
369
  return false;
127
370
  if (build.vpnTunnelStatus === null || build.vpnTunnelStatus === "disabled")
128
371
  return false;
372
+ if (unmetRequirements(vpnRequirementTokens(build.requirements), { features: agent.vpnFeatures, enforcementLevel: agent.enforcementLevel }).length > 0)
373
+ return false;
374
+ if (!agentSatisfiesHostPolicy(build.hostPolicy, build.orgId, {
375
+ hostPinned: build.agentPinned,
376
+ dedicatedOrgId: agent.dedicatedOrgId,
377
+ coHostedRunner: agent.coHostedRunner,
378
+ })) {
379
+ return false;
380
+ }
129
381
  return agent.vpnLockOrgId === null || agent.vpnLockOrgId === build.orgId;
130
382
  }
383
+ /** The first agent release that can enforce a VPN job (it ships the signed
384
+ * WireGuard helper and honours `job.vpn`). Anything older IGNORES `job.vpn`
385
+ * and would run the build with no tunnel, so it must never be leased one,
386
+ * whatever its (possibly stale) `Agent.vpnCapable` row says. */
387
+ export const MIN_VPN_AGENT_VERSION = "0.2.0";
388
+ /** How recent `Agent.lastSeenAt` must be for `vpnCapable` to count: the flag
389
+ * is only as good as the heartbeat that last set it. Three missed 15s beats
390
+ * plus slack — and well under the 60s-ish scheduler cadence's tolerance. */
391
+ export const VPN_CAPABILITY_MAX_AGE_SECONDS = 120;
392
+ /**
393
+ * Numeric `major.minor.patch` comparison (a `-prerelease`/`+build` suffix is
394
+ * ignored). Returns <0, 0, >0, or `null` when either side isn't a plain
395
+ * semver — callers treat `null` as "unknown", never as "new enough".
396
+ */
397
+ export function compareSemver(a, b) {
398
+ const parse = (v) => {
399
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(v.trim());
400
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
401
+ };
402
+ const pa = parse(a);
403
+ const pb = parse(b);
404
+ if (!pa || !pb)
405
+ return null;
406
+ for (let i = 0; i < 3; i++) {
407
+ if (pa[i] !== pb[i])
408
+ return pa[i] - pb[i];
409
+ }
410
+ return 0;
411
+ }
412
+ /** True only for a known version >= `MIN_VPN_AGENT_VERSION`. A missing or
413
+ * unparseable version is NOT capable (fail closed). */
414
+ export function agentVersionSupportsVpn(agentVersion) {
415
+ if (!agentVersion)
416
+ return false;
417
+ const cmp = compareSemver(agentVersion, MIN_VPN_AGENT_VERSION);
418
+ return cmp !== null && cmp >= 0;
419
+ }
420
+ /**
421
+ * Server-side proof that the leasing agent can actually enforce a VPN job,
422
+ * independent of the sticky-ish `vpnCapable` bit: the agent's reported
423
+ * version must be >= `MIN_VPN_AGENT_VERSION` and its last heartbeat (which is
424
+ * what (re-)asserts `vpnCapable`) must be recent. ANDed with
425
+ * `agentSatisfiesVpnRequirements` in `/agents/lease-job`; the guarded SQL in
426
+ * `claimBuildById`/`acquireVpnLock` re-checks freshness and pins the version.
427
+ */
428
+ export function agentProvesVpnEnforcement(agent, now = new Date()) {
429
+ if (!agentVersionSupportsVpn(agent.agentVersion))
430
+ return false;
431
+ const ageMs = now.getTime() - parseUtc(agent.lastSeenAt).getTime();
432
+ return Number.isFinite(ageMs) && ageMs <= VPN_CAPABILITY_MAX_AGE_SECONDS * 1000;
433
+ }
131
434
  /**
132
435
  * The currently published packages/agent build — bump this in lockstep
133
436
  * with packages/agent/package.json's own "version" field (and
@@ -141,7 +444,7 @@ export function agentSatisfiesVpnRequirements(agent, build) {
141
444
  * version against this same constant to decide whether to auto-issue an
142
445
  * `UPDATE_AGENT` command (Phase 2's remote command, reused unchanged).
143
446
  */
144
- export const CURRENT_AGENT_VERSION = "0.2.3";
447
+ export const CURRENT_AGENT_VERSION = "0.3.1";
145
448
  /**
146
449
  * Whether a fleet member is running the latest known agent build.
147
450
  * `null` — deliberately not "outdated" — for a row with no reported