@stigmer/server 3.36.0 → 3.37.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.
- package/dist/domain/agentexecution/artifacts.d.ts.map +1 -1
- package/dist/domain/agentexecution/artifacts.js +15 -6
- package/dist/domain/agentexecution/artifacts.js.map +1 -1
- package/dist/domain/agentexecution/constants.d.ts +0 -10
- package/dist/domain/agentexecution/constants.d.ts.map +1 -1
- package/dist/domain/agentexecution/constants.js +0 -12
- package/dist/domain/agentexecution/constants.js.map +1 -1
- package/dist/domain/agentexecution/lifecycle.d.ts.map +1 -1
- package/dist/domain/agentexecution/lifecycle.js +5 -3
- package/dist/domain/agentexecution/lifecycle.js.map +1 -1
- package/dist/domain/agentexecution/platform-rows.d.ts +63 -0
- package/dist/domain/agentexecution/platform-rows.d.ts.map +1 -0
- package/dist/domain/agentexecution/platform-rows.js +106 -0
- package/dist/domain/agentexecution/platform-rows.js.map +1 -0
- package/dist/domain/agentexecution/subscribe.d.ts.map +1 -1
- package/dist/domain/agentexecution/subscribe.js +7 -4
- package/dist/domain/agentexecution/subscribe.js.map +1 -1
- package/dist/domain/agentexecution/update-status.d.ts.map +1 -1
- package/dist/domain/agentexecution/update-status.js +48 -17
- package/dist/domain/agentexecution/update-status.js.map +1 -1
- package/dist/domain/agentexecution/usage.d.ts +0 -2
- package/dist/domain/agentexecution/usage.d.ts.map +1 -1
- package/dist/domain/agentexecution/usage.js +12 -10
- package/dist/domain/agentexecution/usage.js.map +1 -1
- package/dist/domain/organization/controller.d.ts +4 -1
- package/dist/domain/organization/controller.d.ts.map +1 -1
- package/dist/domain/organization/controller.js +30 -8
- package/dist/domain/organization/controller.js.map +1 -1
- package/dist/domain/workflow/controller.js +2 -1
- package/dist/domain/workflow/controller.js.map +1 -1
- package/dist/domain/workflow/steps.d.ts +7 -4
- package/dist/domain/workflow/steps.d.ts.map +1 -1
- package/dist/domain/workflow/steps.js +29 -17
- package/dist/domain/workflow/steps.js.map +1 -1
- package/dist/domain/workflowexecution/create-steps.d.ts.map +1 -1
- package/dist/domain/workflowexecution/create-steps.js +6 -2
- package/dist/domain/workflowexecution/create-steps.js.map +1 -1
- package/dist/domain/workflowexecution/lifecycle.d.ts.map +1 -1
- package/dist/domain/workflowexecution/lifecycle.js +5 -2
- package/dist/domain/workflowexecution/lifecycle.js.map +1 -1
- package/dist/domain/workflowexecution/send-signal.d.ts.map +1 -1
- package/dist/domain/workflowexecution/send-signal.js +6 -3
- package/dist/domain/workflowexecution/send-signal.js.map +1 -1
- package/dist/domain/workflowexecution/submit-approval.d.ts.map +1 -1
- package/dist/domain/workflowexecution/submit-approval.js +6 -2
- package/dist/domain/workflowexecution/submit-approval.js.map +1 -1
- package/dist/domain/workflowexecution/submit-file-decision.d.ts.map +1 -1
- package/dist/domain/workflowexecution/submit-file-decision.js +6 -2
- package/dist/domain/workflowexecution/submit-file-decision.js.map +1 -1
- package/dist/domain/workflowexecution/submit-workflow-task-approval.d.ts.map +1 -1
- package/dist/domain/workflowexecution/submit-workflow-task-approval.js +6 -2
- package/dist/domain/workflowexecution/submit-workflow-task-approval.js.map +1 -1
- package/dist/domain/workflowexecution/subscribe-events.d.ts.map +1 -1
- package/dist/domain/workflowexecution/subscribe-events.js +6 -2
- package/dist/domain/workflowexecution/subscribe-events.js.map +1 -1
- package/dist/domain/workflowexecution/subscribe.d.ts.map +1 -1
- package/dist/domain/workflowexecution/subscribe.js +7 -4
- package/dist/domain/workflowexecution/subscribe.js.map +1 -1
- package/dist/extensions/organization-directory.d.ts +29 -6
- package/dist/extensions/organization-directory.d.ts.map +1 -1
- package/dist/extensions/organization-directory.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/pipeline/steps/guard-memory-capture.d.ts +9 -4
- package/dist/pipeline/steps/guard-memory-capture.d.ts.map +1 -1
- package/dist/pipeline/steps/guard-memory-capture.js +10 -12
- package/dist/pipeline/steps/guard-memory-capture.js.map +1 -1
- package/dist/temporal/agentexecution/runner-result.d.ts +12 -0
- package/dist/temporal/agentexecution/runner-result.d.ts.map +1 -1
- package/dist/temporal/agentexecution/runner-result.js +15 -0
- package/dist/temporal/agentexecution/runner-result.js.map +1 -1
- package/dist/temporal/agentexecution/workflows/invoke-agent-execution.d.ts.map +1 -1
- package/dist/temporal/agentexecution/workflows/invoke-agent-execution.js +44 -22
- package/dist/temporal/agentexecution/workflows/invoke-agent-execution.js.map +1 -1
- package/package.json +6 -6
- package/src/domain/agentexecution/__tests__/agentexecution.test.ts +2 -8
- package/src/domain/agentexecution/__tests__/store-faults.test.ts +315 -0
- package/src/domain/agentexecution/__tests__/update-status.test.ts +217 -0
- package/src/domain/agentexecution/artifacts.ts +21 -12
- package/src/domain/agentexecution/constants.ts +0 -15
- package/src/domain/agentexecution/lifecycle.ts +5 -3
- package/src/domain/agentexecution/platform-rows.ts +133 -0
- package/src/domain/agentexecution/subscribe.ts +7 -3
- package/src/domain/agentexecution/update-status.ts +70 -19
- package/src/domain/agentexecution/usage.ts +16 -13
- package/src/domain/organization/controller.ts +47 -10
- package/src/domain/workflow/__tests__/version-audit-degradation.test.ts +192 -0
- package/src/domain/workflow/controller.ts +2 -1
- package/src/domain/workflow/steps.ts +30 -18
- package/src/domain/workflowexecution/__tests__/store-faults.test.ts +418 -0
- package/src/domain/workflowexecution/create-steps.ts +6 -2
- package/src/domain/workflowexecution/lifecycle.ts +5 -2
- package/src/domain/workflowexecution/send-signal.ts +11 -4
- package/src/domain/workflowexecution/submit-approval.ts +6 -2
- package/src/domain/workflowexecution/submit-file-decision.ts +6 -2
- package/src/domain/workflowexecution/submit-workflow-task-approval.ts +6 -2
- package/src/domain/workflowexecution/subscribe-events.ts +6 -2
- package/src/domain/workflowexecution/subscribe.ts +11 -4
- package/src/extensions/__tests__/extension-composition.test.ts +90 -18
- package/src/extensions/organization-directory.ts +33 -7
- package/src/index.ts +5 -1
- package/src/pipeline/__tests__/blind-not-found.test.ts +297 -0
- package/src/pipeline/steps/__tests__/guard-memory-capture.test.ts +67 -5
- package/src/pipeline/steps/guard-memory-capture.ts +24 -19
- package/src/temporal/agentexecution/__tests__/invoke-workflow.test.ts +51 -1
- package/src/temporal/agentexecution/__tests__/own-behalf-status-writes.test.ts +149 -9
- package/src/temporal/agentexecution/__tests__/replay-histories/runner-failed.json +757 -0
- package/src/temporal/agentexecution/__tests__/replay-histories/user-cancel.json +755 -0
- package/src/temporal/agentexecution/__tests__/replay.test.ts +4 -2
- package/src/temporal/agentexecution/runner-result.ts +16 -0
- package/src/temporal/agentexecution/workflows/invoke-agent-execution.ts +49 -22
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
* services), and the registry-declared edition answers on getServerInfo.
|
|
9
9
|
* Later entries add their own composed arms below: caller guards
|
|
10
10
|
* (20260902.02), the require-authentication posture (20260904.02), the
|
|
11
|
-
* O5 drivers, the O4 slots and hooks, the C2 tuple lifecycle
|
|
11
|
+
* O5 drivers, the O4 slots and hooks, the C2 tuple lifecycle, and the
|
|
12
|
+
* organization directory's external-id lookup: scoped to the identity
|
|
13
|
+
* provider the request names, and answered only to a caller who may view
|
|
14
|
+
* that provider, with one NotFound for every miss.
|
|
12
15
|
*
|
|
13
16
|
* The empty-set arm — no extensions composed, wire behavior byte-identical
|
|
14
17
|
* to before the parameter existed — is pinned where it belongs: the
|
|
@@ -43,6 +46,7 @@ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/
|
|
|
43
46
|
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
44
47
|
import { ApiKeyCommandController } from "@stigmer/protos/ai/stigmer/iam/apikey/v1/command_pb";
|
|
45
48
|
import { ApiKeyQueryController } from "@stigmer/protos/ai/stigmer/iam/apikey/v1/query_pb";
|
|
49
|
+
import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
46
50
|
import {
|
|
47
51
|
PlatformQueryController,
|
|
48
52
|
ServerEdition,
|
|
@@ -62,6 +66,7 @@ import type { ComposedServer } from "../../boot/compose.js";
|
|
|
62
66
|
import { createLogger } from "../../boot/logger.js";
|
|
63
67
|
import { AUTHENTICATION_TOKEN_MISSING_MESSAGE } from "../../pipeline/interceptors/auth.js";
|
|
64
68
|
import type { PipelineStep } from "../../pipeline/pipeline.js";
|
|
69
|
+
import type { AuthzCheck } from "../authorizer.js";
|
|
65
70
|
import type { GateSlotName } from "../gate-slots.js";
|
|
66
71
|
import type { OrganizationDirectory } from "../organization-directory.js";
|
|
67
72
|
import type {
|
|
@@ -73,6 +78,16 @@ import type {
|
|
|
73
78
|
import type { AgentExecutionStatusTransition } from "../status-hooks.js";
|
|
74
79
|
import type { ServerExtension } from "../registry.js";
|
|
75
80
|
|
|
81
|
+
/** The refusal a call answered; a call that succeeds fails the case. */
|
|
82
|
+
async function refusalOf(work: Promise<unknown>): Promise<ConnectError> {
|
|
83
|
+
try {
|
|
84
|
+
await work;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return ConnectError.from(error);
|
|
87
|
+
}
|
|
88
|
+
throw new Error("the call was admitted");
|
|
89
|
+
}
|
|
90
|
+
|
|
76
91
|
const BILLING_PROCEDURE =
|
|
77
92
|
"/ai.stigmer.billing.v1.BillingQueryController/getBillingAccount";
|
|
78
93
|
|
|
@@ -1019,16 +1034,46 @@ describe("extension composition (C2 tuple lifecycle + organization directory)",
|
|
|
1019
1034
|
// The directory answers are test-mutable so each case can stage its
|
|
1020
1035
|
// own world without a second composed server.
|
|
1021
1036
|
const myOrgIds: string[] = [];
|
|
1037
|
+
// Providers by `org/slug` → id, and mappings by `providerId:externalId`:
|
|
1038
|
+
// the directory scopes every lookup to the provider the request names.
|
|
1039
|
+
const providers = new Map<string, string>([
|
|
1040
|
+
["c2seededorg/test-idp", "idp_viewable"],
|
|
1041
|
+
["c2seededorg/other-idp", "idp_other"],
|
|
1042
|
+
["c2seededorg/hidden-idp", "idp_hidden"],
|
|
1043
|
+
]);
|
|
1022
1044
|
const externalOrgMap = new Map<string, string>();
|
|
1023
1045
|
const fakeDirectory: OrganizationDirectory = {
|
|
1024
1046
|
refusesEnumeration: true,
|
|
1025
1047
|
listMyOrganizationIds: async () => [...myOrgIds],
|
|
1026
|
-
|
|
1027
|
-
|
|
1048
|
+
lookupExternalOrganization: async (ref, externalOrgId) => {
|
|
1049
|
+
const providerId = providers.get(`${ref.org}/${ref.slug}`);
|
|
1050
|
+
return providerId === undefined
|
|
1051
|
+
? { kind: "no-identity-provider" }
|
|
1052
|
+
: {
|
|
1053
|
+
kind: "resolved",
|
|
1054
|
+
identityProviderId: providerId,
|
|
1055
|
+
organizationId: externalOrgMap.get(`${providerId}:${externalOrgId}`),
|
|
1056
|
+
};
|
|
1057
|
+
},
|
|
1028
1058
|
};
|
|
1059
|
+
// Every check is allowed except viewing the providers named here, so the
|
|
1060
|
+
// lookup's own authorization is the one refusal the suite can see.
|
|
1061
|
+
const deniedProviders = new Set<string>(["idp_hidden"]);
|
|
1062
|
+
const lookupChecks: AuthzCheck[] = [];
|
|
1029
1063
|
|
|
1030
1064
|
const iamExtension: ServerExtension = {
|
|
1031
1065
|
name: "fake-iam",
|
|
1066
|
+
authorizer: {
|
|
1067
|
+
authorize: (_caller, check) => {
|
|
1068
|
+
if (check.resourceKind === ApiResourceKind.identity_provider) {
|
|
1069
|
+
lookupChecks.push(check);
|
|
1070
|
+
if (deniedProviders.has(check.resourceId)) {
|
|
1071
|
+
return Promise.resolve({ kind: "deny", reason: "" });
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
return Promise.resolve({ kind: "allow" });
|
|
1075
|
+
},
|
|
1076
|
+
},
|
|
1032
1077
|
drivers: {
|
|
1033
1078
|
resourceAuthorizationLifecycle: fakeLifecycle,
|
|
1034
1079
|
organizationDirectory: fakeDirectory,
|
|
@@ -1077,7 +1122,8 @@ describe("extension composition (C2 tuple lifecycle + organization directory)",
|
|
|
1077
1122
|
expect(event.parentLinks).toEqual([]);
|
|
1078
1123
|
expect(event.caller.identityId).not.toBe("");
|
|
1079
1124
|
myOrgIds.push(org.metadata?.id ?? "");
|
|
1080
|
-
externalOrgMap.set("ext-org-42", org.metadata?.id ?? "");
|
|
1125
|
+
externalOrgMap.set("idp_viewable:ext-org-42", org.metadata?.id ?? "");
|
|
1126
|
+
externalOrgMap.set("idp_hidden:ext-org-42", org.metadata?.id ?? "");
|
|
1081
1127
|
});
|
|
1082
1128
|
|
|
1083
1129
|
it("agent create fires creation events for the agent AND its in-process default instance", async () => {
|
|
@@ -1236,26 +1282,52 @@ describe("extension composition (C2 tuple lifecycle + organization directory)",
|
|
|
1236
1282
|
expect(mine.entries.map((org) => org.metadata?.id)).toEqual(myOrgIds);
|
|
1237
1283
|
});
|
|
1238
1284
|
|
|
1239
|
-
it("getByExternalOrgId
|
|
1285
|
+
it("getByExternalOrgId resolves a mapping under the named provider for a caller who may view it", async () => {
|
|
1240
1286
|
const query = createClient(OrganizationQueryController, portTransport);
|
|
1241
|
-
|
|
1242
|
-
// directory's resolution key is the (globally unique) external id.
|
|
1243
|
-
const idpRef = { org: "c2seededorg", slug: "test-idp" };
|
|
1287
|
+
lookupChecks.length = 0;
|
|
1244
1288
|
const org = await query.getByExternalOrgId({
|
|
1245
|
-
identityProviderRef:
|
|
1289
|
+
identityProviderRef: { org: "c2seededorg", slug: "test-idp" },
|
|
1246
1290
|
externalOrgId: "ext-org-42",
|
|
1247
1291
|
});
|
|
1248
1292
|
expect(org.metadata?.id).toBe(myOrgIds[0]);
|
|
1293
|
+
expect(lookupChecks).toEqual([
|
|
1294
|
+
{
|
|
1295
|
+
permission: IamPermission.can_view,
|
|
1296
|
+
resourceKind: ApiResourceKind.identity_provider,
|
|
1297
|
+
resourceId: "idp_viewable",
|
|
1298
|
+
},
|
|
1299
|
+
]);
|
|
1300
|
+
});
|
|
1249
1301
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1302
|
+
it("getByExternalOrgId refuses a caller who may not view the named provider, before saying whether a mapping exists", async () => {
|
|
1303
|
+
const query = createClient(OrganizationQueryController, portTransport);
|
|
1304
|
+
for (const externalOrgId of ["ext-org-42", "ext-org-unknown"]) {
|
|
1305
|
+
const refused = await refusalOf(
|
|
1306
|
+
query.getByExternalOrgId({
|
|
1307
|
+
identityProviderRef: { org: "c2seededorg", slug: "hidden-idp" },
|
|
1308
|
+
externalOrgId,
|
|
1309
|
+
}),
|
|
1310
|
+
);
|
|
1311
|
+
expect(refused.code).toBe(Code.PermissionDenied);
|
|
1312
|
+
expect(refused.rawMessage).toBe("unauthorized to view identity provider");
|
|
1313
|
+
}
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
it("getByExternalOrgId answers one NotFound for an unknown provider, an unmapped id, and another provider's mapping", async () => {
|
|
1317
|
+
const query = createClient(OrganizationQueryController, portTransport);
|
|
1318
|
+
for (const [slug, externalOrgId] of [
|
|
1319
|
+
["no-such-idp", "ext-org-42"],
|
|
1320
|
+
["test-idp", "ext-org-unknown"],
|
|
1321
|
+
["other-idp", "ext-org-42"],
|
|
1322
|
+
] as const) {
|
|
1323
|
+
const missing = await refusalOf(
|
|
1324
|
+
query.getByExternalOrgId({
|
|
1325
|
+
identityProviderRef: { org: "c2seededorg", slug },
|
|
1326
|
+
externalOrgId,
|
|
1327
|
+
}),
|
|
1328
|
+
);
|
|
1329
|
+
expect(missing.code).toBe(Code.NotFound);
|
|
1330
|
+
expect(missing.rawMessage).toBe(`Organization not found: ${externalOrgId}`);
|
|
1258
1331
|
}
|
|
1259
|
-
expect(missing?.code).toBe(Code.NotFound);
|
|
1260
1332
|
});
|
|
1261
1333
|
});
|
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
* and the built-in directory filter to what the caller may view.
|
|
16
16
|
* - `getByExternalOrgId` (capability `externalOrgLookup`): deliberately
|
|
17
17
|
* absent from the OSS partial registration (UNIMPLEMENTED by
|
|
18
|
-
* construction); the cloud resolves external IdP org ids.
|
|
18
|
+
* construction); the cloud resolves external IdP org ids. The lookup
|
|
19
|
+
* is scoped to the identity provider the request names: the directory
|
|
20
|
+
* resolves that provider and the external id within it, and the
|
|
21
|
+
* controller answers only a caller who may view the provider, so one
|
|
22
|
+
* integrator's tenant ids never answer another's organizations.
|
|
19
23
|
*
|
|
20
24
|
* No directory composed = the trusted-local behavior, byte-identical; the
|
|
21
25
|
* built-in directory (authorization/organization-directory.ts) composes
|
|
@@ -47,12 +51,34 @@ export interface OrganizationDirectory {
|
|
|
47
51
|
caller: CallerIdentity,
|
|
48
52
|
): Promise<ReadonlyArray<string> | typeof ALL_ORGANIZATIONS>;
|
|
49
53
|
/**
|
|
50
|
-
* Resolves
|
|
51
|
-
*
|
|
52
|
-
* registers `getByExternalOrgId` (its chain
|
|
53
|
-
*
|
|
54
|
+
* Resolves the identity provider a lookup names, and the external IdP
|
|
55
|
+
* organization id within that provider alone. When this method is
|
|
56
|
+
* present, the controller registers `getByExternalOrgId` (its chain,
|
|
57
|
+
* the provider's authorization included, stays controller-owned); when
|
|
58
|
+
* absent, the RPC stays unregistered and answers UNIMPLEMENTED.
|
|
54
59
|
*/
|
|
55
|
-
|
|
60
|
+
lookupExternalOrganization?(
|
|
61
|
+
identityProviderRef: IdentityProviderRef,
|
|
56
62
|
externalOrgId: string,
|
|
57
|
-
): Promise<
|
|
63
|
+
): Promise<ExternalOrganizationLookup>;
|
|
58
64
|
}
|
|
65
|
+
|
|
66
|
+
/** The identity provider a lookup names: its organization and its slug. */
|
|
67
|
+
export interface IdentityProviderRef {
|
|
68
|
+
readonly org: string;
|
|
69
|
+
readonly slug: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What a directory found for a lookup: no such identity provider, or the
|
|
74
|
+
* provider's id with the organization its external id maps to under it
|
|
75
|
+
* (undefined = no mapping). The controller authorizes on the provider's
|
|
76
|
+
* id before it reveals whether a mapping exists.
|
|
77
|
+
*/
|
|
78
|
+
export type ExternalOrganizationLookup =
|
|
79
|
+
| { readonly kind: "no-identity-provider" }
|
|
80
|
+
| {
|
|
81
|
+
readonly kind: "resolved";
|
|
82
|
+
readonly identityProviderId: string;
|
|
83
|
+
readonly organizationId: string | undefined;
|
|
84
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -116,7 +116,11 @@ export type {
|
|
|
116
116
|
VisibilityTupleShape,
|
|
117
117
|
ResolvedParentLink,
|
|
118
118
|
} from "./extensions/resource-authorization.js";
|
|
119
|
-
export type {
|
|
119
|
+
export type {
|
|
120
|
+
ExternalOrganizationLookup,
|
|
121
|
+
IdentityProviderRef,
|
|
122
|
+
OrganizationDirectory,
|
|
123
|
+
} from "./extensions/organization-directory.js";
|
|
120
124
|
export { ALL_ORGANIZATIONS } from "./extensions/organization-directory.js";
|
|
121
125
|
// The license-status seam (drivers.licenseStatus): the provider an
|
|
122
126
|
// Enterprise composition registers so getLicenseStatus answers from its
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Holds the error contract's store-fault rule structurally: no error handler
|
|
3
|
+
* in the server answers NotFound without looking at the error it caught.
|
|
4
|
+
* Only a typed not-found (`ResourceNotFoundError` and its kin) means the
|
|
5
|
+
* record is missing; any other failure is an infrastructure fault answered
|
|
6
|
+
* through `internalError` (`src/pipeline/errors.ts`), because a client told
|
|
7
|
+
* "not found" may discard state that still exists
|
|
8
|
+
* (`.agents/skills/ts-server-dev-guidelines/SKILL.md`, "Errors").
|
|
9
|
+
*
|
|
10
|
+
* A *blind NotFound* is a `throw` of a NotFound that is a direct statement of
|
|
11
|
+
* a `catch` block or of a `.catch(...)` callback — not nested under any
|
|
12
|
+
* condition. That is the defect's exact shape: a discriminating handler throws
|
|
13
|
+
* its NotFound under `if (error instanceof ...)` or a code check, so it passes
|
|
14
|
+
* whatever error class it tests, and the guard needs no list of them. A throw
|
|
15
|
+
* constructs a NotFound when it is `new ConnectError(..., Code.NotFound)` or
|
|
16
|
+
* a call to a NotFound constructor; the constructors are derived from the
|
|
17
|
+
* source (`notFoundError`, plus every function whose body is a single
|
|
18
|
+
* `return` of a NotFound construction, to a fixed point), so a domain's own
|
|
19
|
+
* wrapper cannot hide a blind site, and a discriminating mapper is not one.
|
|
20
|
+
*
|
|
21
|
+
* The scan parses each module with the TypeScript compiler API rather than a
|
|
22
|
+
* regular expression, because a catch block's extent (nested braces, a log
|
|
23
|
+
* line before the throw) is a syntax question. `PENDING` is the list of known
|
|
24
|
+
* blind sites still to be fixed; the scan must equal it exactly, so a new
|
|
25
|
+
* blind site fails here, and so does a fixed site still listed.
|
|
26
|
+
*/
|
|
27
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
|
|
31
|
+
import ts from "typescript";
|
|
32
|
+
import { describe, expect, it } from "vitest";
|
|
33
|
+
|
|
34
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const SRC = path.resolve(HERE, "../..");
|
|
36
|
+
|
|
37
|
+
/** Known blind sites by module (relative to `src`), with their count. */
|
|
38
|
+
const PENDING: ReadonlyMap<string, number> = new Map([
|
|
39
|
+
// Store loads, stigmer/stigmer#1345 (fixed domain by domain).
|
|
40
|
+
["domain/agent/controller.ts", 1],
|
|
41
|
+
["domain/agentchannel/controller.ts", 1],
|
|
42
|
+
["domain/agentinstance/controller.ts", 1],
|
|
43
|
+
["domain/agentshare/controller.ts", 1],
|
|
44
|
+
["domain/artifact/controller.ts", 1],
|
|
45
|
+
["domain/environment/controller.ts", 1],
|
|
46
|
+
["domain/environment/steps.ts", 1],
|
|
47
|
+
["domain/mcpserver/complete-oauth-connect.ts", 1],
|
|
48
|
+
["domain/mcpserver/connect.ts", 1],
|
|
49
|
+
["domain/mcpserver/controller.ts", 1],
|
|
50
|
+
["domain/mcpserver/initiate-oauth-connect.ts", 1],
|
|
51
|
+
["domain/mcpserver/start-connect.ts", 2],
|
|
52
|
+
["domain/plugin/controller.ts", 1],
|
|
53
|
+
["domain/skill/controller.ts", 1],
|
|
54
|
+
["domain/workflow/controller.ts", 1],
|
|
55
|
+
["domain/workflowinstance/controller.ts", 1],
|
|
56
|
+
// Parent loads through the in-process chain that fold every RPC error,
|
|
57
|
+
// validation included, stigmer/stigmer#1351.
|
|
58
|
+
["domain/agentinstance/steps.ts", 1],
|
|
59
|
+
["domain/workflowinstance/steps.ts", 1],
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
interface BlindSite {
|
|
63
|
+
readonly module: string;
|
|
64
|
+
readonly line: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isCodeNotFound(node: ts.Expression): boolean {
|
|
68
|
+
return (
|
|
69
|
+
ts.isPropertyAccessExpression(node) &&
|
|
70
|
+
ts.isIdentifier(node.expression) &&
|
|
71
|
+
node.expression.text === "Code" &&
|
|
72
|
+
node.name.text === "NotFound"
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isNotFoundConstruction(
|
|
77
|
+
node: ts.Expression | undefined,
|
|
78
|
+
constructors: ReadonlySet<string>,
|
|
79
|
+
): boolean {
|
|
80
|
+
if (node === undefined) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
if (ts.isCallExpression(node)) {
|
|
84
|
+
return (
|
|
85
|
+
ts.isIdentifier(node.expression) && constructors.has(node.expression.text)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (ts.isNewExpression(node)) {
|
|
89
|
+
return (
|
|
90
|
+
ts.isIdentifier(node.expression) &&
|
|
91
|
+
node.expression.text === "ConnectError" &&
|
|
92
|
+
(node.arguments ?? []).some(isCodeNotFound)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function forEachNode(node: ts.Node, visit: (node: ts.Node) => void): void {
|
|
99
|
+
visit(node);
|
|
100
|
+
ts.forEachChild(node, (child) => forEachNode(child, visit));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `notFoundError` plus every single-`return` wrapper of a NotFound. */
|
|
104
|
+
function notFoundConstructors(
|
|
105
|
+
sources: readonly ts.SourceFile[],
|
|
106
|
+
): ReadonlySet<string> {
|
|
107
|
+
const constructors = new Set(["notFoundError"]);
|
|
108
|
+
let grew = true;
|
|
109
|
+
while (grew) {
|
|
110
|
+
grew = false;
|
|
111
|
+
for (const source of sources) {
|
|
112
|
+
forEachNode(source, (node) => {
|
|
113
|
+
if (
|
|
114
|
+
!ts.isFunctionDeclaration(node) ||
|
|
115
|
+
node.name === undefined ||
|
|
116
|
+
node.body === undefined ||
|
|
117
|
+
node.body.statements.length !== 1 ||
|
|
118
|
+
constructors.has(node.name.text)
|
|
119
|
+
) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const only = node.body.statements[0];
|
|
123
|
+
if (
|
|
124
|
+
only !== undefined &&
|
|
125
|
+
ts.isReturnStatement(only) &&
|
|
126
|
+
isNotFoundConstruction(only.expression, constructors)
|
|
127
|
+
) {
|
|
128
|
+
constructors.add(node.name.text);
|
|
129
|
+
grew = true;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return constructors;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The handler block of a `.catch(fn)` call, when `fn` has a block body. */
|
|
138
|
+
function catchCallbackBlock(node: ts.Node): ts.Block | undefined {
|
|
139
|
+
if (
|
|
140
|
+
!ts.isCallExpression(node) ||
|
|
141
|
+
!ts.isPropertyAccessExpression(node.expression) ||
|
|
142
|
+
node.expression.name.text !== "catch"
|
|
143
|
+
) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
const handler = node.arguments[0];
|
|
147
|
+
if (
|
|
148
|
+
handler !== undefined &&
|
|
149
|
+
(ts.isArrowFunction(handler) || ts.isFunctionExpression(handler)) &&
|
|
150
|
+
ts.isBlock(handler.body)
|
|
151
|
+
) {
|
|
152
|
+
return handler.body;
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function blindSites(
|
|
158
|
+
source: ts.SourceFile,
|
|
159
|
+
module: string,
|
|
160
|
+
constructors: ReadonlySet<string>,
|
|
161
|
+
): BlindSite[] {
|
|
162
|
+
const sites: BlindSite[] = [];
|
|
163
|
+
forEachNode(source, (node) => {
|
|
164
|
+
const block = ts.isCatchClause(node)
|
|
165
|
+
? node.block
|
|
166
|
+
: catchCallbackBlock(node);
|
|
167
|
+
if (block === undefined) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
for (const statement of block.statements) {
|
|
171
|
+
if (
|
|
172
|
+
ts.isThrowStatement(statement) &&
|
|
173
|
+
isNotFoundConstruction(statement.expression, constructors)
|
|
174
|
+
) {
|
|
175
|
+
const { line } = source.getLineAndCharacterOfPosition(
|
|
176
|
+
statement.getStart(source),
|
|
177
|
+
);
|
|
178
|
+
sites.push({ module, line: line + 1 });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
return sites;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parse(fileName: string, text: string): ts.SourceFile {
|
|
186
|
+
return ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Every non-test module under `src`, parsed. */
|
|
190
|
+
function serverSources(): ts.SourceFile[] {
|
|
191
|
+
const sources: ts.SourceFile[] = [];
|
|
192
|
+
const walk = (dir: string): void => {
|
|
193
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
194
|
+
const full = path.join(dir, entry.name);
|
|
195
|
+
if (entry.isDirectory()) {
|
|
196
|
+
if (entry.name !== "__tests__") {
|
|
197
|
+
walk(full);
|
|
198
|
+
}
|
|
199
|
+
} else if (
|
|
200
|
+
entry.name.endsWith(".ts") &&
|
|
201
|
+
!entry.name.endsWith(".d.ts") &&
|
|
202
|
+
!entry.name.endsWith(".test.ts")
|
|
203
|
+
) {
|
|
204
|
+
sources.push(parse(full, readFileSync(full, "utf8")));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
walk(SRC);
|
|
209
|
+
return sources;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The rule applied to one inline module, for the rule's own cases. */
|
|
213
|
+
function blindLinesOf(text: string): number[] {
|
|
214
|
+
const source = parse("fixture.ts", text);
|
|
215
|
+
return blindSites(source, "fixture.ts", notFoundConstructors([source])).map(
|
|
216
|
+
(site) => site.line,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
describe("the blind-NotFound rule", () => {
|
|
221
|
+
it("flags an unconditional NotFound in a catch block, bound or not", () => {
|
|
222
|
+
expect(
|
|
223
|
+
blindLinesOf(`
|
|
224
|
+
try { load(); } catch { throw notFoundError("agent", id); }
|
|
225
|
+
try { load(); } catch (error) {
|
|
226
|
+
log(error);
|
|
227
|
+
throw new ConnectError(copy(id), Code.NotFound);
|
|
228
|
+
}
|
|
229
|
+
`),
|
|
230
|
+
).toEqual([2, 5]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("flags a NotFound built by a derived wrapper and one in a .catch callback", () => {
|
|
234
|
+
expect(
|
|
235
|
+
blindLinesOf(`
|
|
236
|
+
function missing(id: string) { return notFoundError("agent", id); }
|
|
237
|
+
function missingAgain(id: string) { return missing(id); }
|
|
238
|
+
try { load(); } catch { throw missingAgain(id); }
|
|
239
|
+
load().catch(() => { throw missing(id); });
|
|
240
|
+
`),
|
|
241
|
+
).toEqual([4, 5]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("passes a NotFound thrown only under a test of the error", () => {
|
|
245
|
+
expect(
|
|
246
|
+
blindLinesOf(`
|
|
247
|
+
try { load(); } catch (error) {
|
|
248
|
+
if (error instanceof ResourceNotFoundError) {
|
|
249
|
+
throw notFoundError("agent", id);
|
|
250
|
+
}
|
|
251
|
+
throw internalError(error, "failed to load agent");
|
|
252
|
+
}
|
|
253
|
+
`),
|
|
254
|
+
).toEqual([]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("does not treat a discriminating mapper as a constructor", () => {
|
|
258
|
+
expect(
|
|
259
|
+
blindLinesOf(`
|
|
260
|
+
function mapNotFound(error: unknown, id: string): unknown {
|
|
261
|
+
if (error instanceof WorkflowNotFoundError) {
|
|
262
|
+
return notFoundError("workflow", id);
|
|
263
|
+
}
|
|
264
|
+
return error;
|
|
265
|
+
}
|
|
266
|
+
try { load(); } catch (error) { throw mapNotFound(error, id); }
|
|
267
|
+
`),
|
|
268
|
+
).toEqual([]);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("the server source", () => {
|
|
273
|
+
it("has no blind NotFound beyond the pending list, and the list names only live sites", () => {
|
|
274
|
+
const sources = serverSources();
|
|
275
|
+
const constructors = notFoundConstructors(sources);
|
|
276
|
+
const found = new Map<string, number[]>();
|
|
277
|
+
for (const source of sources) {
|
|
278
|
+
const module = path
|
|
279
|
+
.relative(SRC, source.fileName)
|
|
280
|
+
.split(path.sep)
|
|
281
|
+
.join("/");
|
|
282
|
+
for (const site of blindSites(source, module, constructors)) {
|
|
283
|
+
found.set(module, [...(found.get(module) ?? []), site.line]);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const counts = Object.fromEntries(
|
|
288
|
+
[...found].map(([module, lines]) => [module, lines.length]),
|
|
289
|
+
);
|
|
290
|
+
const where = [...found]
|
|
291
|
+
.flatMap(([module, lines]) => lines.map((line) => `${module}:${line}`))
|
|
292
|
+
.join("\n");
|
|
293
|
+
expect(counts, `blind NotFound sites found:\n${where}`).toEqual(
|
|
294
|
+
Object.fromEntries(PENDING),
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -7,8 +7,13 @@
|
|
|
7
7
|
* the capability, the org-mismatch throw propagating untouched, and the
|
|
8
8
|
* absent-capability posture staying byte-identical to the pre-seam gate
|
|
9
9
|
* (trusted-local and OIDC callers admit; machine and
|
|
10
|
-
* platform_client_id-carrying callers refuse).
|
|
10
|
+
* platform_client_id-carrying callers refuse). The claim counts only on a
|
|
11
|
+
* platform token (stigmer#1312): the refusing cases carry tokens signed by
|
|
12
|
+
* the envelope every Stigmer lane mints through, and another issuer's
|
|
13
|
+
* token carrying the same claim is admitted.
|
|
11
14
|
*/
|
|
15
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
16
|
+
|
|
12
17
|
import { describe, expect, it } from "vitest";
|
|
13
18
|
import { Code, ConnectError } from "@connectrpc/connect";
|
|
14
19
|
import { create } from "@bufbuild/protobuf";
|
|
@@ -17,6 +22,9 @@ import { MemorySchema } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/api_p
|
|
|
17
22
|
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
18
23
|
|
|
19
24
|
import type { CallerIdentity } from "../../../extensions/identity.js";
|
|
25
|
+
import { signPlatformToken } from "../../../platformtoken/envelope.js";
|
|
26
|
+
import { platformTokenKeyRingFromPem } from "../../../platformtoken/key-ring.js";
|
|
27
|
+
import type { SigningPlatformTokenKeyRing } from "../../../platformtoken/key-ring.js";
|
|
20
28
|
import type {
|
|
21
29
|
MemoryCaptureDecision,
|
|
22
30
|
RunnerCredentialProvider,
|
|
@@ -77,11 +85,40 @@ function providerWith(
|
|
|
77
85
|
};
|
|
78
86
|
}
|
|
79
87
|
|
|
88
|
+
const { privateKey, publicKey } = generateKeyPairSync("rsa", {
|
|
89
|
+
modulusLength: 2048,
|
|
90
|
+
});
|
|
91
|
+
const built = platformTokenKeyRingFromPem({
|
|
92
|
+
privateKeyPem: privateKey.export({ format: "pem", type: "pkcs8" }).toString(),
|
|
93
|
+
publicKeyPems: [publicKey.export({ format: "pem", type: "spki" }).toString()],
|
|
94
|
+
});
|
|
95
|
+
if (built.signer === undefined) throw new Error("fixture ring must sign");
|
|
96
|
+
const ring: SigningPlatformTokenKeyRing = { ...built, signer: built.signer };
|
|
97
|
+
|
|
98
|
+
/** A platform token, signed as every Stigmer-minted lane signs it. */
|
|
99
|
+
function mintedToken(claims: Record<string, string>): string {
|
|
100
|
+
return signPlatformToken(ring, { sub: "ida_minted", ...claims }).token;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Another issuer's JWT: the claims as given, never signed by the ring. */
|
|
80
104
|
function jwtWith(payload: Record<string, unknown>): string {
|
|
81
|
-
const
|
|
82
|
-
|
|
105
|
+
const segment = (value: object) =>
|
|
106
|
+
Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
107
|
+
return `${segment({ alg: "RS256", typ: "JWT" })}.${segment(payload)}.signature`;
|
|
83
108
|
}
|
|
84
109
|
|
|
110
|
+
/** An OIDC login whose issuer happens to stamp a platform_client_id claim. */
|
|
111
|
+
const FOREIGN_CLAIM_CALLER: CallerIdentity = {
|
|
112
|
+
identityId: "ida_oidc",
|
|
113
|
+
callerClass: "user",
|
|
114
|
+
issuer: "https://issuer.example",
|
|
115
|
+
rawToken: jwtWith({
|
|
116
|
+
iss: "https://issuer.example",
|
|
117
|
+
sub: "ida_oidc",
|
|
118
|
+
platform_client_id: "pc_1",
|
|
119
|
+
}),
|
|
120
|
+
};
|
|
121
|
+
|
|
85
122
|
async function rejectionOf(run: void | Promise<void>): Promise<unknown> {
|
|
86
123
|
return Promise.resolve(run).then(
|
|
87
124
|
() => {
|
|
@@ -172,13 +209,22 @@ describe("GuardMemoryCapture with the capability composed", () => {
|
|
|
172
209
|
identityId: "ida_guest",
|
|
173
210
|
callerClass: "guest",
|
|
174
211
|
issuer: "stigmer",
|
|
175
|
-
rawToken:
|
|
212
|
+
rawToken: mintedToken({ platform_client_id: "pc_1" }),
|
|
176
213
|
};
|
|
177
214
|
await expect(step.execute(memoryCtx(minted))).rejects.toThrowError(
|
|
178
215
|
MEMORY_CAPTURE_CALLER_MESSAGE,
|
|
179
216
|
);
|
|
180
217
|
});
|
|
181
218
|
|
|
219
|
+
it("no-opinion falls through: another issuer's platform_client_id claim is admitted", async () => {
|
|
220
|
+
// Both editions' capabilities answer no-opinion for a person, so this
|
|
221
|
+
// is the path an OIDC login takes in production.
|
|
222
|
+
const step = newGuardMemoryCaptureStep<typeof MemorySchema>(
|
|
223
|
+
providerWith(() => ({ verdict: "no-opinion" })),
|
|
224
|
+
);
|
|
225
|
+
await step.execute(memoryCtx(FOREIGN_CLAIM_CALLER));
|
|
226
|
+
});
|
|
227
|
+
|
|
182
228
|
it("internal and in-process callers never consult the capability", async () => {
|
|
183
229
|
const step = newGuardMemoryCaptureStep<typeof MemorySchema>(
|
|
184
230
|
providerWith(() => {
|
|
@@ -220,12 +266,28 @@ describe("GuardMemoryCapture without the capability (the pre-seam gate)", () =>
|
|
|
220
266
|
step.execute(
|
|
221
267
|
memoryCtx({
|
|
222
268
|
...LOCAL_USER,
|
|
223
|
-
rawToken:
|
|
269
|
+
rawToken: mintedToken({ platform_client_id: "pc_1" }),
|
|
224
270
|
}),
|
|
225
271
|
),
|
|
226
272
|
).rejects.toThrowError(MEMORY_CAPTURE_CALLER_MESSAGE);
|
|
227
273
|
});
|
|
228
274
|
|
|
275
|
+
it("admits a platform_client_id claim on a token Stigmer did not issue", async () => {
|
|
276
|
+
const step = newGuardMemoryCaptureStep<typeof MemorySchema>(providerWith());
|
|
277
|
+
await step.execute(memoryCtx(FOREIGN_CLAIM_CALLER));
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("admits a platform token whose platform_client_id is empty", async () => {
|
|
281
|
+
const step = newGuardMemoryCaptureStep<typeof MemorySchema>(providerWith());
|
|
282
|
+
await step.execute(
|
|
283
|
+
memoryCtx({
|
|
284
|
+
...LOCAL_USER,
|
|
285
|
+
issuer: "stigmer",
|
|
286
|
+
rawToken: mintedToken({ platform_client_id: "" }),
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
229
291
|
it("admits a runner-shaped caller — the recorded pre-fix posture", async () => {
|
|
230
292
|
// With no capability composed the gate cannot classify runner
|
|
231
293
|
// credentials — the single-user OSS posture admits them (the #564
|