@stigmer/server 3.16.1-dev.20260917224426 → 3.18.0-dev.20260918103812
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/dist/boot/compose.d.ts +0 -12
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +6 -2
- package/dist/boot/compose.js.map +1 -1
- package/dist/domain/agentexecution/lifecycle.js +1 -1
- package/dist/domain/agentexecution/lifecycle.js.map +1 -1
- package/dist/domain/platform/controller.d.ts +24 -11
- package/dist/domain/platform/controller.d.ts.map +1 -1
- package/dist/domain/platform/controller.js +47 -3
- package/dist/domain/platform/controller.js.map +1 -1
- package/dist/domain/workflow/registry/data/task-kind-registry.json +4 -1
- package/dist/domain/workflowexecution/lifecycle.js +1 -1
- package/dist/domain/workflowexecution/lifecycle.js.map +1 -1
- package/dist/extensions/drivers.d.ts +11 -0
- package/dist/extensions/drivers.d.ts.map +1 -1
- package/dist/extensions/license-status.d.ts +64 -0
- package/dist/extensions/license-status.d.ts.map +1 -0
- package/dist/extensions/license-status.js +9 -0
- package/dist/extensions/license-status.js.map +1 -0
- package/dist/extensions/registry.d.ts +6 -0
- package/dist/extensions/registry.d.ts.map +1 -1
- package/dist/extensions/registry.js +10 -0
- package/dist/extensions/registry.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/sandbox/kubernetes.d.ts.map +1 -1
- package/dist/sandbox/kubernetes.js +8 -1
- package/dist/sandbox/kubernetes.js.map +1 -1
- package/dist/sandbox/provisioner.d.ts +20 -5
- package/dist/sandbox/provisioner.d.ts.map +1 -1
- package/dist/sandbox/provisioner.js.map +1 -1
- package/dist/sandbox/steps.d.ts +22 -8
- package/dist/sandbox/steps.d.ts.map +1 -1
- package/dist/sandbox/steps.js +17 -12
- package/dist/sandbox/steps.js.map +1 -1
- package/package.json +5 -5
- package/src/boot/compose.ts +6 -14
- package/src/domain/agentexecution/lifecycle.ts +1 -1
- package/src/domain/platform/__tests__/platform.test.ts +147 -46
- package/src/domain/platform/controller.ts +77 -13
- package/src/domain/workflow/registry/data/task-kind-registry.json +4 -1
- package/src/domain/workflowexecution/lifecycle.ts +1 -1
- package/src/extensions/__tests__/license-status-points.test.ts +50 -0
- package/src/extensions/drivers.ts +11 -0
- package/src/extensions/license-status.ts +71 -0
- package/src/extensions/registry.ts +19 -0
- package/src/index.ts +8 -0
- package/src/pipeline/__tests__/billing-contract-rules.test.ts +260 -0
- package/src/sandbox/__tests__/docker.integration.test.ts +2 -0
- package/src/sandbox/__tests__/kubernetes.test.ts +10 -2
- package/src/sandbox/__tests__/local-process.integration.test.ts +23 -6
- package/src/sandbox/__tests__/steps.test.ts +72 -19
- package/src/sandbox/kubernetes.ts +8 -1
- package/src/sandbox/provisioner.ts +20 -5
- package/src/sandbox/steps.ts +29 -12
|
@@ -53,6 +53,7 @@ import type { SecretCodec } from "../encryption/codec.js";
|
|
|
53
53
|
import { V1_VERSION } from "../encryption/v1-codec.js";
|
|
54
54
|
import type { AuthorizationQueryEngine } from "./authorization-queries.js";
|
|
55
55
|
import type { IdentityFederation } from "./identity-federation.js";
|
|
56
|
+
import type { LicenseStatusProvider } from "./license-status.js";
|
|
56
57
|
import type { ListReadScope } from "./list-read-scope.js";
|
|
57
58
|
import type { PolicyGrantScope } from "./policy-grant-scope.js";
|
|
58
59
|
import type { ModelCatalogProvider } from "../domain/workflow/registry/model-catalog-provider.js";
|
|
@@ -272,6 +273,11 @@ export interface ResolvedExtensionDrivers {
|
|
|
272
273
|
* tuple-half RPC arms refuse UNIMPLEMENTED with the edition reason.
|
|
273
274
|
*/
|
|
274
275
|
readonly authorizationQueries: AuthorizationQueryEngine | undefined;
|
|
276
|
+
/**
|
|
277
|
+
* The license-status provider — undefined = compose.ts installs the
|
|
278
|
+
* built-in `absent` answer, so every edition serves getLicenseStatus.
|
|
279
|
+
*/
|
|
280
|
+
readonly licenseStatus: LicenseStatusProvider | undefined;
|
|
275
281
|
}
|
|
276
282
|
|
|
277
283
|
/**
|
|
@@ -325,6 +331,8 @@ export function resolveExtensions(
|
|
|
325
331
|
let policyGrantScopeDeclaredBy: string | undefined;
|
|
326
332
|
let authorizationQueries: AuthorizationQueryEngine | undefined;
|
|
327
333
|
let authorizationQueriesDeclaredBy: string | undefined;
|
|
334
|
+
let licenseStatus: LicenseStatusProvider | undefined;
|
|
335
|
+
let licenseStatusDeclaredBy: string | undefined;
|
|
328
336
|
const artifactStorageDrivers = new Map<
|
|
329
337
|
string,
|
|
330
338
|
ArtifactStorageDriverFactory
|
|
@@ -516,6 +524,16 @@ export function resolveExtensions(
|
|
|
516
524
|
authorizationQueriesDeclaredBy = unit.name;
|
|
517
525
|
}
|
|
518
526
|
|
|
527
|
+
if (unit.drivers?.licenseStatus !== undefined) {
|
|
528
|
+
if (licenseStatusDeclaredBy !== undefined) {
|
|
529
|
+
throw new Error(
|
|
530
|
+
`extension '${unit.name}' registers a LicenseStatusProvider, but '${licenseStatusDeclaredBy}' already did — exactly one may be composed`,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
licenseStatus = unit.drivers.licenseStatus;
|
|
534
|
+
licenseStatusDeclaredBy = unit.name;
|
|
535
|
+
}
|
|
536
|
+
|
|
519
537
|
if (unit.drivers?.artifactStorageDrivers !== undefined) {
|
|
520
538
|
for (const [name, factory] of unit.drivers.artifactStorageDrivers) {
|
|
521
539
|
if ((BUILT_IN_STORAGE_TYPES as ReadonlyArray<string>).includes(name)) {
|
|
@@ -641,6 +659,7 @@ export function resolveExtensions(
|
|
|
641
659
|
iamPolicyStore,
|
|
642
660
|
policyGrantScope,
|
|
643
661
|
authorizationQueries,
|
|
662
|
+
licenseStatus,
|
|
644
663
|
},
|
|
645
664
|
services,
|
|
646
665
|
workers,
|
package/src/index.ts
CHANGED
|
@@ -111,6 +111,14 @@ export type {
|
|
|
111
111
|
} from "./extensions/resource-authorization.js";
|
|
112
112
|
export type { OrganizationDirectory } from "./extensions/organization-directory.js";
|
|
113
113
|
export { ALL_ORGANIZATIONS } from "./extensions/organization-directory.js";
|
|
114
|
+
// The license-status seam (drivers.licenseStatus): the provider an
|
|
115
|
+
// Enterprise composition registers so getLicenseStatus answers from its
|
|
116
|
+
// configured ticket. The built-in `absent` default is the composition
|
|
117
|
+
// root's and is not exported.
|
|
118
|
+
export type {
|
|
119
|
+
LicenseStatusProvider,
|
|
120
|
+
LicenseStatusReport,
|
|
121
|
+
} from "./extensions/license-status.js";
|
|
114
122
|
// The 20260911.11 identity-account seams (Q-IA-9): the store PORT a
|
|
115
123
|
// composition drives the domain through (drivers.identityAccountStore;
|
|
116
124
|
// a driver throws DuplicateAccountError for a held id), the federation
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the message-level rules of the plan, subscription and license
|
|
3
|
+
* contract through the shared validator every edition runs
|
|
4
|
+
* (steps/validation.ts, the same instance the transport interceptor uses).
|
|
5
|
+
*
|
|
6
|
+
* Why here and not in a conformance suite: every billing kind is cloud_only,
|
|
7
|
+
* so the open-source server never serves a Plan or a License and the
|
|
8
|
+
* cross-edition suites, where field rules are normally proven against a
|
|
9
|
+
* running server, cannot reach them. The vocabulary those kinds share with
|
|
10
|
+
* the Enterprise edition (Entitlements, LicenseClaims) is verified offline
|
|
11
|
+
* by a server that holds no store for it at all. The one place in this
|
|
12
|
+
* repository where a rule on these messages can be proven red-then-green is
|
|
13
|
+
* the validator itself, so this file drives it directly, one row per rule,
|
|
14
|
+
* a passing shape beside every refused one.
|
|
15
|
+
*
|
|
16
|
+
* What the rows pin: the instrument decides which PlanTerms a plan may carry;
|
|
17
|
+
* a license's timestamps are ordered (issue, expiry, grace); a limit is
|
|
18
|
+
* present or absent, never zero; a feature is named once. A row that
|
|
19
|
+
* stops matching means the contract moved, and the proto is where it moved.
|
|
20
|
+
*/
|
|
21
|
+
import { create } from "@bufbuild/protobuf";
|
|
22
|
+
import type { DescMessage, MessageInitShape } from "@bufbuild/protobuf";
|
|
23
|
+
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
|
|
24
|
+
import {
|
|
25
|
+
PlanInstrument,
|
|
26
|
+
PlanSpecSchema,
|
|
27
|
+
} from "@stigmer/protos/ai/stigmer/billing/plan/v1/spec_pb";
|
|
28
|
+
import { LicenseSpecSchema } from "@stigmer/protos/ai/stigmer/billing/license/v1/spec_pb";
|
|
29
|
+
import {
|
|
30
|
+
EntitlementLimitsSchema,
|
|
31
|
+
EntitlementsSchema,
|
|
32
|
+
Feature,
|
|
33
|
+
} from "@stigmer/protos/ai/stigmer/platform/v1/entitlement_pb";
|
|
34
|
+
import {
|
|
35
|
+
LicenseClaimsSchema,
|
|
36
|
+
LicenseTerm,
|
|
37
|
+
} from "@stigmer/protos/ai/stigmer/platform/v1/license_pb";
|
|
38
|
+
import { describe, expect, it } from "vitest";
|
|
39
|
+
|
|
40
|
+
import { validator } from "../steps/validation.js";
|
|
41
|
+
|
|
42
|
+
const t = (iso: string) => timestampFromDate(new Date(iso));
|
|
43
|
+
|
|
44
|
+
const customer = {
|
|
45
|
+
id: "cus_acme",
|
|
46
|
+
displayName: "Acme Corp",
|
|
47
|
+
contactEmail: "billing@acme.example",
|
|
48
|
+
};
|
|
49
|
+
const entitlements = { features: [Feature.channels] };
|
|
50
|
+
const orderedTerm = {
|
|
51
|
+
issuedAt: t("2026-09-01T00:00:00Z"),
|
|
52
|
+
expiresAt: t("2027-09-01T00:00:00Z"),
|
|
53
|
+
graceUntil: t("2027-10-01T00:00:00Z"),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** One row: a message, its init, and the rule id it must trip, or none. */
|
|
57
|
+
interface Row<Desc extends DescMessage> {
|
|
58
|
+
readonly name: string;
|
|
59
|
+
readonly schema: Desc;
|
|
60
|
+
readonly init: MessageInitShape<Desc>;
|
|
61
|
+
readonly refusedBy?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function row<Desc extends DescMessage>(r: Row<Desc>): Row<Desc> {
|
|
65
|
+
return r;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const rows: ReadonlyArray<Row<DescMessage>> = [
|
|
69
|
+
row({
|
|
70
|
+
name: "a subscription plan with monthly terms",
|
|
71
|
+
schema: PlanSpecSchema,
|
|
72
|
+
init: {
|
|
73
|
+
instrument: PlanInstrument.subscription,
|
|
74
|
+
entitlements,
|
|
75
|
+
terms: { monthlyMinimumMicros: 1n, usageShareBasisPoints: 100 },
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
row({
|
|
79
|
+
name: "a license plan with an annual price",
|
|
80
|
+
schema: PlanSpecSchema,
|
|
81
|
+
init: {
|
|
82
|
+
instrument: PlanInstrument.license,
|
|
83
|
+
entitlements,
|
|
84
|
+
terms: { annualPriceMicros: 1n },
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
row({
|
|
88
|
+
name: "a plan with no terms at all",
|
|
89
|
+
schema: PlanSpecSchema,
|
|
90
|
+
init: { instrument: PlanInstrument.subscription, entitlements },
|
|
91
|
+
}),
|
|
92
|
+
row({
|
|
93
|
+
name: "a license plan carrying a monthly minimum",
|
|
94
|
+
schema: PlanSpecSchema,
|
|
95
|
+
init: {
|
|
96
|
+
instrument: PlanInstrument.license,
|
|
97
|
+
entitlements,
|
|
98
|
+
terms: { monthlyMinimumMicros: 1n },
|
|
99
|
+
},
|
|
100
|
+
refusedBy: "plan_spec.license_has_no_monthly_terms",
|
|
101
|
+
}),
|
|
102
|
+
row({
|
|
103
|
+
name: "a license plan carrying a usage share",
|
|
104
|
+
schema: PlanSpecSchema,
|
|
105
|
+
init: {
|
|
106
|
+
instrument: PlanInstrument.license,
|
|
107
|
+
entitlements,
|
|
108
|
+
terms: { usageShareBasisPoints: 100 },
|
|
109
|
+
},
|
|
110
|
+
refusedBy: "plan_spec.license_has_no_monthly_terms",
|
|
111
|
+
}),
|
|
112
|
+
row({
|
|
113
|
+
name: "a subscription plan carrying an annual price",
|
|
114
|
+
schema: PlanSpecSchema,
|
|
115
|
+
init: {
|
|
116
|
+
instrument: PlanInstrument.subscription,
|
|
117
|
+
entitlements,
|
|
118
|
+
terms: { annualPriceMicros: 1n },
|
|
119
|
+
},
|
|
120
|
+
refusedBy: "plan_spec.subscription_has_no_annual_price",
|
|
121
|
+
}),
|
|
122
|
+
row({
|
|
123
|
+
name: "a usage share above one hundred percent",
|
|
124
|
+
schema: PlanSpecSchema,
|
|
125
|
+
init: {
|
|
126
|
+
instrument: PlanInstrument.subscription,
|
|
127
|
+
entitlements,
|
|
128
|
+
terms: { usageShareBasisPoints: 10001 },
|
|
129
|
+
},
|
|
130
|
+
refusedBy: "int32.gte_lte",
|
|
131
|
+
}),
|
|
132
|
+
row({
|
|
133
|
+
name: "ordered license claims",
|
|
134
|
+
schema: LicenseClaimsSchema,
|
|
135
|
+
init: {
|
|
136
|
+
licenseId: "lic_1",
|
|
137
|
+
customer,
|
|
138
|
+
term: LicenseTerm.paid,
|
|
139
|
+
entitlements,
|
|
140
|
+
...orderedTerm,
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
row({
|
|
144
|
+
name: "claims expiring before they were issued",
|
|
145
|
+
schema: LicenseClaimsSchema,
|
|
146
|
+
init: {
|
|
147
|
+
licenseId: "lic_1",
|
|
148
|
+
customer,
|
|
149
|
+
term: LicenseTerm.paid,
|
|
150
|
+
entitlements,
|
|
151
|
+
...orderedTerm,
|
|
152
|
+
expiresAt: t("2026-08-01T00:00:00Z"),
|
|
153
|
+
graceUntil: t("2026-08-02T00:00:00Z"),
|
|
154
|
+
},
|
|
155
|
+
refusedBy: "license_claims.expires_after_issue",
|
|
156
|
+
}),
|
|
157
|
+
row({
|
|
158
|
+
name: "claims whose grace ends before expiry",
|
|
159
|
+
schema: LicenseClaimsSchema,
|
|
160
|
+
init: {
|
|
161
|
+
licenseId: "lic_1",
|
|
162
|
+
customer,
|
|
163
|
+
term: LicenseTerm.paid,
|
|
164
|
+
entitlements,
|
|
165
|
+
...orderedTerm,
|
|
166
|
+
graceUntil: t("2027-08-01T00:00:00Z"),
|
|
167
|
+
},
|
|
168
|
+
refusedBy: "license_claims.grace_not_before_expiry",
|
|
169
|
+
}),
|
|
170
|
+
row({
|
|
171
|
+
name: "a customer with no contact address",
|
|
172
|
+
schema: LicenseClaimsSchema,
|
|
173
|
+
init: {
|
|
174
|
+
licenseId: "lic_1",
|
|
175
|
+
customer: { id: "cus_acme", displayName: "Acme Corp" },
|
|
176
|
+
term: LicenseTerm.paid,
|
|
177
|
+
entitlements,
|
|
178
|
+
...orderedTerm,
|
|
179
|
+
},
|
|
180
|
+
refusedBy: "required",
|
|
181
|
+
}),
|
|
182
|
+
row({
|
|
183
|
+
name: "a customer whose contact address is not an email",
|
|
184
|
+
schema: LicenseClaimsSchema,
|
|
185
|
+
init: {
|
|
186
|
+
licenseId: "lic_1",
|
|
187
|
+
customer: { ...customer, contactEmail: "acme" },
|
|
188
|
+
term: LicenseTerm.paid,
|
|
189
|
+
entitlements,
|
|
190
|
+
...orderedTerm,
|
|
191
|
+
},
|
|
192
|
+
refusedBy: "string.email",
|
|
193
|
+
}),
|
|
194
|
+
row({
|
|
195
|
+
name: "a license spec whose grace ends before expiry",
|
|
196
|
+
schema: LicenseSpecSchema,
|
|
197
|
+
init: {
|
|
198
|
+
customer,
|
|
199
|
+
entitlements,
|
|
200
|
+
term: LicenseTerm.trial,
|
|
201
|
+
expiresAt: orderedTerm.expiresAt,
|
|
202
|
+
graceUntil: t("2027-08-01T00:00:00Z"),
|
|
203
|
+
},
|
|
204
|
+
refusedBy: "license_spec.grace_not_before_expiry",
|
|
205
|
+
}),
|
|
206
|
+
row({
|
|
207
|
+
name: "a license spec with grace on the expiry instant",
|
|
208
|
+
schema: LicenseSpecSchema,
|
|
209
|
+
init: {
|
|
210
|
+
customer,
|
|
211
|
+
entitlements,
|
|
212
|
+
term: LicenseTerm.trial,
|
|
213
|
+
expiresAt: orderedTerm.expiresAt,
|
|
214
|
+
graceUntil: orderedTerm.expiresAt,
|
|
215
|
+
},
|
|
216
|
+
}),
|
|
217
|
+
row({
|
|
218
|
+
name: "a limit of zero, which is never what an operator meant",
|
|
219
|
+
schema: EntitlementLimitsSchema,
|
|
220
|
+
init: { maxOrganizations: 0 },
|
|
221
|
+
refusedBy: "int32.gt",
|
|
222
|
+
}),
|
|
223
|
+
row({
|
|
224
|
+
name: "an absent limit, which is no limit",
|
|
225
|
+
schema: EntitlementLimitsSchema,
|
|
226
|
+
init: {},
|
|
227
|
+
}),
|
|
228
|
+
row({
|
|
229
|
+
name: "a feature named twice",
|
|
230
|
+
schema: EntitlementsSchema,
|
|
231
|
+
init: { features: [Feature.channels, Feature.channels] },
|
|
232
|
+
refusedBy: "repeated.unique",
|
|
233
|
+
}),
|
|
234
|
+
row({
|
|
235
|
+
name: "the unspecified feature",
|
|
236
|
+
schema: EntitlementsSchema,
|
|
237
|
+
init: { features: [Feature.feature_unspecified] },
|
|
238
|
+
refusedBy: "enum.not_in",
|
|
239
|
+
}),
|
|
240
|
+
];
|
|
241
|
+
|
|
242
|
+
describe("the billing contract's rules, through the shared validator", () => {
|
|
243
|
+
for (const r of rows) {
|
|
244
|
+
const verdict =
|
|
245
|
+
r.refusedBy === undefined ? "passes" : `is refused by ${r.refusedBy}`;
|
|
246
|
+
it(`${r.name} ${verdict}`, () => {
|
|
247
|
+
const result = validator().validate(r.schema, create(r.schema, r.init));
|
|
248
|
+
if (r.refusedBy === undefined) {
|
|
249
|
+
expect(result.kind).toBe("valid");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (result.kind !== "invalid") {
|
|
253
|
+
throw new Error(`expected a refusal, got ${result.kind}`);
|
|
254
|
+
}
|
|
255
|
+
expect(result.error.violations.map((v) => v.ruleId)).toContain(
|
|
256
|
+
r.refusedBy,
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
});
|
|
@@ -68,6 +68,7 @@ describe.skipIf(!optedIn || !dockerAnswers())(
|
|
|
68
68
|
await driver.ensureSessionSandbox(sessionId, {
|
|
69
69
|
taskQueue: `session:${sessionId}`,
|
|
70
70
|
stigmerToken: "tok-docker-smoke",
|
|
71
|
+
callerClass: "user",
|
|
71
72
|
});
|
|
72
73
|
expect(await driver.probe("session", sessionId)).toBe("running");
|
|
73
74
|
|
|
@@ -91,6 +92,7 @@ describe.skipIf(!optedIn || !dockerAnswers())(
|
|
|
91
92
|
await driver.ensureSessionSandbox(sessionId, {
|
|
92
93
|
taskQueue: `session:${sessionId}`,
|
|
93
94
|
stigmerToken: "tok-docker-smoke",
|
|
95
|
+
callerClass: "user",
|
|
94
96
|
});
|
|
95
97
|
expect(await driver.probe("session", sessionId)).toBe("running");
|
|
96
98
|
|
|
@@ -89,7 +89,11 @@ function fakeGateway(
|
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
const env = {
|
|
92
|
+
const env = {
|
|
93
|
+
taskQueue: "session:ses_1",
|
|
94
|
+
stigmerToken: "tok-1",
|
|
95
|
+
callerClass: "user",
|
|
96
|
+
};
|
|
93
97
|
|
|
94
98
|
describe("the ensure state machine", () => {
|
|
95
99
|
it("absent → Secret, PVC, Deployment — in that order, no readiness wait", async () => {
|
|
@@ -211,7 +215,11 @@ describe("the manifest shapes (the Java SandboxManifestFactory pins)", () => {
|
|
|
211
215
|
});
|
|
212
216
|
|
|
213
217
|
it("a token-less sandbox omits the token env but keeps the (empty) Secret", () => {
|
|
214
|
-
const tokenless = {
|
|
218
|
+
const tokenless = {
|
|
219
|
+
taskQueue: "session:ses_1",
|
|
220
|
+
stigmerToken: "",
|
|
221
|
+
callerClass: "user",
|
|
222
|
+
};
|
|
215
223
|
const deployment = buildSandboxDeployment(
|
|
216
224
|
"session",
|
|
217
225
|
"ses_1",
|
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
* can read it, so no real runner install is needed.
|
|
7
7
|
*
|
|
8
8
|
* Unix-only like the runner itself; deterministic (poll with timeout,
|
|
9
|
-
* never sleep).
|
|
9
|
+
* never sleep). Every poll gets an explicit budget: vitest's default
|
|
10
|
+
* second is enough on an idle laptop and not under the full suite's
|
|
11
|
+
* load, where a /bin/sh child can take longer than that to start and
|
|
12
|
+
* write its first line. Polling returns the moment the condition holds,
|
|
13
|
+
* so the larger budget costs nothing on a quiet host.
|
|
10
14
|
*/
|
|
11
15
|
import {
|
|
12
16
|
chmodSync,
|
|
@@ -25,6 +29,9 @@ import { createLogger } from "../../boot/logger.js";
|
|
|
25
29
|
import { newLocalProcessSandboxProvisioner } from "../local-process.js";
|
|
26
30
|
import type { SandboxDriverConfig } from "../provisioner.js";
|
|
27
31
|
|
|
32
|
+
/** How long a spawned child is given to start, write, or die under load. */
|
|
33
|
+
const CHILD_SETTLE_TIMEOUT_MS = 10_000;
|
|
34
|
+
|
|
28
35
|
const silentLogger = createLogger({
|
|
29
36
|
level: "error",
|
|
30
37
|
pretty: false,
|
|
@@ -74,14 +81,18 @@ describe.skipIf(process.platform === "win32")(
|
|
|
74
81
|
await driver.ensureSessionSandbox("ses_smoke", {
|
|
75
82
|
taskQueue: "session:ses_smoke",
|
|
76
83
|
stigmerToken: "tok-smoke",
|
|
84
|
+
callerClass: "user",
|
|
85
|
+
});
|
|
86
|
+
await vi.waitFor(() => expect(spawnCount()).toBe(1), {
|
|
87
|
+
timeout: CHILD_SETTLE_TIMEOUT_MS,
|
|
77
88
|
});
|
|
78
|
-
await vi.waitFor(() => expect(spawnCount()).toBe(1));
|
|
79
89
|
expect(await driver.probe("session", "ses_smoke")).toBe("running");
|
|
80
90
|
|
|
81
91
|
// Fast path: a second ensure must not spawn a second process.
|
|
82
92
|
await driver.ensureSessionSandbox("ses_smoke", {
|
|
83
93
|
taskQueue: "session:ses_smoke",
|
|
84
94
|
stigmerToken: "tok-smoke",
|
|
95
|
+
callerClass: "user",
|
|
85
96
|
});
|
|
86
97
|
expect(spawnCount()).toBe(1);
|
|
87
98
|
|
|
@@ -104,16 +115,22 @@ describe.skipIf(process.platform === "win32")(
|
|
|
104
115
|
|
|
105
116
|
it("deprovision kills the child; probe reports absent; re-ensure respawns", async () => {
|
|
106
117
|
await driver.deprovisionSessionSandbox("ses_smoke");
|
|
107
|
-
await vi.waitFor(
|
|
108
|
-
|
|
109
|
-
|
|
118
|
+
await vi.waitFor(
|
|
119
|
+
async () => {
|
|
120
|
+
expect(await driver.probe("session", "ses_smoke")).toBe("absent");
|
|
121
|
+
},
|
|
122
|
+
{ timeout: CHILD_SETTLE_TIMEOUT_MS },
|
|
123
|
+
);
|
|
110
124
|
|
|
111
125
|
// The repair arm without stored state: a fresh ensure respawns.
|
|
112
126
|
await driver.ensureSessionSandbox("ses_smoke", {
|
|
113
127
|
taskQueue: "session:ses_smoke",
|
|
114
128
|
stigmerToken: "",
|
|
129
|
+
callerClass: "user",
|
|
130
|
+
});
|
|
131
|
+
await vi.waitFor(() => expect(spawnCount()).toBe(2), {
|
|
132
|
+
timeout: CHILD_SETTLE_TIMEOUT_MS,
|
|
115
133
|
});
|
|
116
|
-
await vi.waitFor(() => expect(spawnCount()).toBe(2));
|
|
117
134
|
expect(await driver.probe("session", "ses_smoke")).toBe("running");
|
|
118
135
|
});
|
|
119
136
|
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
* - the terminal observer deprovisions exactly on transitions INTO a
|
|
12
12
|
* terminal phase, fire-and-forget, and swallows (logs) teardown
|
|
13
13
|
* failures;
|
|
14
|
-
* - a disabled mint lane launches token-less rather than failing
|
|
14
|
+
* - a disabled mint lane launches token-less rather than failing;
|
|
15
|
+
* - the caller splits two ways on both lanes: the identity id reaches
|
|
16
|
+
* the credential mint, the caller class reaches the driver's
|
|
17
|
+
* environment unchanged (a composed lane such as `guest` included).
|
|
15
18
|
*/
|
|
16
19
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
17
20
|
import { tmpdir } from "node:os";
|
|
@@ -53,14 +56,25 @@ import {
|
|
|
53
56
|
ensureWorkflowSandboxForExecution,
|
|
54
57
|
newWorkflowSandboxTerminalObserver,
|
|
55
58
|
SANDBOX_PROVISIONING_FAILED_PREFIX,
|
|
59
|
+
type SandboxCaller,
|
|
56
60
|
} from "../steps.js";
|
|
57
61
|
|
|
58
62
|
/**
|
|
59
|
-
* The caller
|
|
60
|
-
* (C4
|
|
61
|
-
*
|
|
63
|
+
* The caller the ensure bodies split two ways: the identity id into the
|
|
64
|
+
* credential mint (C4 — the OSS execution-scoped mint ignores it, so the
|
|
65
|
+
* token assertions stay binding-shaped) and the class onto the driver's
|
|
66
|
+
* environment. `user` is the plain lane; the class-propagation cases hand
|
|
67
|
+
* in a composed lane's word to prove the body copies, never normalises.
|
|
62
68
|
*/
|
|
63
69
|
const TEST_CALLER_IDENTITY_ID = "ida_test_caller";
|
|
70
|
+
const TEST_CALLER: SandboxCaller = {
|
|
71
|
+
identityId: TEST_CALLER_IDENTITY_ID,
|
|
72
|
+
callerClass: "user",
|
|
73
|
+
};
|
|
74
|
+
const GUEST_CALLER: SandboxCaller = {
|
|
75
|
+
identityId: "ida_guest_caller",
|
|
76
|
+
callerClass: "guest",
|
|
77
|
+
};
|
|
64
78
|
|
|
65
79
|
const silentLogger = createLogger({
|
|
66
80
|
level: "error",
|
|
@@ -235,7 +249,7 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
|
|
|
235
249
|
temporalConfig: sessionRoutingCloudDefault,
|
|
236
250
|
},
|
|
237
251
|
execution,
|
|
238
|
-
|
|
252
|
+
TEST_CALLER,
|
|
239
253
|
);
|
|
240
254
|
expect(provisioner.ensured).toEqual([
|
|
241
255
|
{
|
|
@@ -244,11 +258,32 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
|
|
|
244
258
|
env: {
|
|
245
259
|
taskQueue: `session:${sessionId}`,
|
|
246
260
|
stigmerToken: `tok-${execution.metadata?.id ?? ""}`,
|
|
261
|
+
callerClass: "user",
|
|
247
262
|
},
|
|
248
263
|
},
|
|
249
264
|
]);
|
|
250
265
|
});
|
|
251
266
|
|
|
267
|
+
it("hands the caller's class to the driver unchanged and the caller's id to the mint", async () => {
|
|
268
|
+
const provisioner = fakeProvisioner();
|
|
269
|
+
const credentials = capabilityCredentials();
|
|
270
|
+
const { execution } = await seed(ExecutionTarget.CLOUD);
|
|
271
|
+
await ensureSessionSandboxForExecution(
|
|
272
|
+
{
|
|
273
|
+
store,
|
|
274
|
+
logger: silentLogger,
|
|
275
|
+
lane: lane(provisioner, credentials),
|
|
276
|
+
temporalConfig: sessionRoutingCloudDefault,
|
|
277
|
+
},
|
|
278
|
+
execution,
|
|
279
|
+
GUEST_CALLER,
|
|
280
|
+
);
|
|
281
|
+
expect(provisioner.ensured[0]?.env.callerClass).toBe("guest");
|
|
282
|
+
expect(credentials.minted[0]?.callerIdentityId).toBe(
|
|
283
|
+
GUEST_CALLER.identityId,
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
252
287
|
it("delegates the mint to the capability provider with the full provisioning context (C4)", async () => {
|
|
253
288
|
const provisioner = fakeProvisioner();
|
|
254
289
|
const credentials = capabilityCredentials();
|
|
@@ -277,7 +312,7 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
|
|
|
277
312
|
temporalConfig: sessionRoutingCloudDefault,
|
|
278
313
|
},
|
|
279
314
|
execution,
|
|
280
|
-
|
|
315
|
+
TEST_CALLER,
|
|
281
316
|
);
|
|
282
317
|
|
|
283
318
|
expect(credentials.minted).toEqual([
|
|
@@ -303,7 +338,7 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
|
|
|
303
338
|
temporalConfig: sessionRoutingCloudDefault,
|
|
304
339
|
},
|
|
305
340
|
execution,
|
|
306
|
-
|
|
341
|
+
TEST_CALLER,
|
|
307
342
|
);
|
|
308
343
|
expect(provisioner.ensured).toEqual([]);
|
|
309
344
|
});
|
|
@@ -327,7 +362,7 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
|
|
|
327
362
|
temporalConfig: sessionRoutingCloudDefault,
|
|
328
363
|
},
|
|
329
364
|
execution,
|
|
330
|
-
|
|
365
|
+
TEST_CALLER,
|
|
331
366
|
);
|
|
332
367
|
});
|
|
333
368
|
|
|
@@ -345,7 +380,7 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
|
|
|
345
380
|
temporalConfig: sessionRoutingCloudDefault,
|
|
346
381
|
},
|
|
347
382
|
execution,
|
|
348
|
-
|
|
383
|
+
TEST_CALLER,
|
|
349
384
|
);
|
|
350
385
|
expect(provisioner.ensured).toEqual([]);
|
|
351
386
|
});
|
|
@@ -366,7 +401,7 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
|
|
|
366
401
|
),
|
|
367
402
|
},
|
|
368
403
|
execution,
|
|
369
|
-
|
|
404
|
+
TEST_CALLER,
|
|
370
405
|
);
|
|
371
406
|
expect(provisioner.ensured).toEqual([]);
|
|
372
407
|
});
|
|
@@ -382,7 +417,7 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
|
|
|
382
417
|
temporalConfig: sessionRoutingCloudDefault,
|
|
383
418
|
},
|
|
384
419
|
execution,
|
|
385
|
-
|
|
420
|
+
TEST_CALLER,
|
|
386
421
|
);
|
|
387
422
|
expect(provisioner.ensured[0]?.env.stigmerToken).toBe("");
|
|
388
423
|
});
|
|
@@ -401,7 +436,7 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
|
|
|
401
436
|
temporalConfig: sessionRoutingCloudDefault,
|
|
402
437
|
},
|
|
403
438
|
execution,
|
|
404
|
-
|
|
439
|
+
TEST_CALLER,
|
|
405
440
|
);
|
|
406
441
|
const stamped = await store.getResource(
|
|
407
442
|
ApiResourceKind.agent_execution,
|
|
@@ -439,7 +474,7 @@ describe("the session lane (ensureSessionSandboxForExecution)", () => {
|
|
|
439
474
|
temporalConfig: sessionRoutingCloudDefault,
|
|
440
475
|
},
|
|
441
476
|
execution,
|
|
442
|
-
|
|
477
|
+
TEST_CALLER,
|
|
443
478
|
);
|
|
444
479
|
const after = await store.getResource(
|
|
445
480
|
ApiResourceKind.agent_execution,
|
|
@@ -467,17 +502,35 @@ describe("the workflow lane (ensureWorkflowSandboxForExecution)", () => {
|
|
|
467
502
|
temporalConfig: executionRoutingConfig,
|
|
468
503
|
},
|
|
469
504
|
workflowExecution(ExecutionTarget.CLOUD),
|
|
470
|
-
|
|
505
|
+
TEST_CALLER,
|
|
471
506
|
);
|
|
472
507
|
expect(provisioner.ensured).toEqual([
|
|
473
508
|
{
|
|
474
509
|
scope: "workflow",
|
|
475
510
|
id: "wfx_sbx_1",
|
|
476
|
-
env: {
|
|
511
|
+
env: {
|
|
512
|
+
taskQueue: "wfexec:wfx_sbx_1",
|
|
513
|
+
stigmerToken: "tok-wfx_sbx_1",
|
|
514
|
+
callerClass: "user",
|
|
515
|
+
},
|
|
477
516
|
},
|
|
478
517
|
]);
|
|
479
518
|
});
|
|
480
519
|
|
|
520
|
+
it("hands the caller's class to the driver unchanged on the workflow lane", async () => {
|
|
521
|
+
const provisioner = fakeProvisioner();
|
|
522
|
+
await ensureWorkflowSandboxForExecution(
|
|
523
|
+
{
|
|
524
|
+
logger: silentLogger,
|
|
525
|
+
lane: lane(provisioner),
|
|
526
|
+
temporalConfig: executionRoutingConfig,
|
|
527
|
+
},
|
|
528
|
+
workflowExecution(ExecutionTarget.CLOUD),
|
|
529
|
+
GUEST_CALLER,
|
|
530
|
+
);
|
|
531
|
+
expect(provisioner.ensured[0]?.env.callerClass).toBe("guest");
|
|
532
|
+
});
|
|
533
|
+
|
|
481
534
|
it("delegates the mint to the capability provider on the workflow scope (C4)", async () => {
|
|
482
535
|
const provisioner = fakeProvisioner();
|
|
483
536
|
const credentials = capabilityCredentials();
|
|
@@ -491,7 +544,7 @@ describe("the workflow lane (ensureWorkflowSandboxForExecution)", () => {
|
|
|
491
544
|
metadata: { id: "wfx_sbx_cap", name: "wfx_sbx_cap", org: "org-test" },
|
|
492
545
|
spec: { executionTarget: ExecutionTarget.CLOUD },
|
|
493
546
|
}),
|
|
494
|
-
|
|
547
|
+
TEST_CALLER,
|
|
495
548
|
);
|
|
496
549
|
expect(credentials.minted).toEqual([
|
|
497
550
|
{
|
|
@@ -514,7 +567,7 @@ describe("the workflow lane (ensureWorkflowSandboxForExecution)", () => {
|
|
|
514
567
|
temporalConfig: executionRoutingConfig,
|
|
515
568
|
},
|
|
516
569
|
workflowExecution(ExecutionTarget.LOCAL),
|
|
517
|
-
|
|
570
|
+
TEST_CALLER,
|
|
518
571
|
);
|
|
519
572
|
await ensureWorkflowSandboxForExecution(
|
|
520
573
|
{
|
|
@@ -528,7 +581,7 @@ describe("the workflow lane (ensureWorkflowSandboxForExecution)", () => {
|
|
|
528
581
|
),
|
|
529
582
|
},
|
|
530
583
|
workflowExecution(ExecutionTarget.CLOUD),
|
|
531
|
-
|
|
584
|
+
TEST_CALLER,
|
|
532
585
|
);
|
|
533
586
|
expect(provisioner.ensured).toEqual([]);
|
|
534
587
|
});
|
|
@@ -545,7 +598,7 @@ describe("the workflow lane (ensureWorkflowSandboxForExecution)", () => {
|
|
|
545
598
|
temporalConfig: executionRoutingConfig,
|
|
546
599
|
},
|
|
547
600
|
workflowExecution(ExecutionTarget.CLOUD),
|
|
548
|
-
|
|
601
|
+
TEST_CALLER,
|
|
549
602
|
);
|
|
550
603
|
expect.unreachable("the workflow lane must throw on failure");
|
|
551
604
|
} catch (error) {
|
|
@@ -85,7 +85,14 @@ const RUNNER_RESOURCES = {
|
|
|
85
85
|
limits: { cpu: "2", memory: "2Gi" },
|
|
86
86
|
} as const;
|
|
87
87
|
|
|
88
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* The persistent-workspace scopes (SandboxScope.persistentWorkspace).
|
|
90
|
+
* Durability here is decided by SCOPE alone: this driver reads neither the
|
|
91
|
+
* environment's caller class nor any per-session policy, so every session
|
|
92
|
+
* keeps its PVC whoever asked. A driver that gives some caller classes a
|
|
93
|
+
* workspace that dies with the conversation is a composition's choice
|
|
94
|
+
* built on SandboxEnvironment.callerClass, not a built-in behaviour.
|
|
95
|
+
*/
|
|
89
96
|
const PERSISTENT_SCOPES: ReadonlySet<SandboxScope> = new Set([
|
|
90
97
|
"session",
|
|
91
98
|
"workflow",
|