@stigmer/server 3.38.1 → 3.38.3
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/controller.js +7 -3
- package/dist/domain/agentexecution/controller.js.map +1 -1
- package/dist/domain/agentexecution/update-status.d.ts +9 -2
- package/dist/domain/agentexecution/update-status.d.ts.map +1 -1
- package/dist/domain/agentexecution/update-status.js +28 -5
- package/dist/domain/agentexecution/update-status.js.map +1 -1
- package/dist/domain/agentexecution/validate-thinking-mode.d.ts +31 -21
- package/dist/domain/agentexecution/validate-thinking-mode.d.ts.map +1 -1
- package/dist/domain/agentexecution/validate-thinking-mode.js +61 -18
- package/dist/domain/agentexecution/validate-thinking-mode.js.map +1 -1
- package/dist/domain/artifact/controller.d.ts.map +1 -1
- package/dist/domain/artifact/controller.js +9 -6
- package/dist/domain/artifact/controller.js.map +1 -1
- package/dist/domain/environment/controller.d.ts +2 -2
- package/dist/domain/environment/controller.js +7 -3
- package/dist/domain/environment/controller.js.map +1 -1
- package/dist/domain/environment/steps.d.ts +2 -1
- package/dist/domain/environment/steps.d.ts.map +1 -1
- package/dist/domain/environment/steps.js +10 -7
- package/dist/domain/environment/steps.js.map +1 -1
- package/dist/domain/workflow/registry/data/model-registry.json +23 -14
- package/dist/domain/workflow/registry/model-registry-store.d.ts +14 -3
- package/dist/domain/workflow/registry/model-registry-store.d.ts.map +1 -1
- package/dist/domain/workflow/registry/model-registry-store.js +14 -3
- package/dist/domain/workflow/registry/model-registry-store.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/pipeline/errors.d.ts +12 -13
- package/dist/pipeline/errors.d.ts.map +1 -1
- package/dist/pipeline/errors.js +29 -3
- package/dist/pipeline/errors.js.map +1 -1
- package/package.json +6 -6
- package/src/domain/agentexecution/__tests__/run-gate.test.ts +42 -1
- package/src/domain/agentexecution/__tests__/update-status.test.ts +58 -2
- package/src/domain/agentexecution/__tests__/validate-thinking-mode.test.ts +209 -61
- package/src/domain/agentexecution/controller.ts +7 -3
- package/src/domain/agentexecution/update-status.ts +43 -3
- package/src/domain/agentexecution/validate-thinking-mode.ts +102 -47
- package/src/domain/artifact/__tests__/store-faults.test.ts +117 -0
- package/src/domain/artifact/controller.ts +12 -6
- package/src/domain/environment/__tests__/store-faults.test.ts +150 -0
- package/src/domain/environment/controller.ts +8 -5
- package/src/domain/environment/steps.ts +10 -7
- package/src/domain/workflow/registry/data/model-registry.json +23 -14
- package/src/domain/workflow/registry/model-registry-store.ts +16 -3
- package/src/index.ts +2 -0
- package/src/pipeline/__tests__/blind-not-found.test.ts +0 -3
- package/src/pipeline/__tests__/errors.test.ts +72 -0
- package/src/pipeline/errors.ts +42 -3
|
@@ -1,88 +1,143 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ValidateThinkingMode —
|
|
3
|
-
*
|
|
4
|
-
* (stigmer/stigmer#772)
|
|
5
|
-
*
|
|
2
|
+
* ValidateThinkingMode — fail-closed validation of
|
|
3
|
+
* ExecutionConfig.thinking_mode against the model registry
|
|
4
|
+
* (stigmer/stigmer#772), the sibling of ValidateServiceTier for the second
|
|
5
|
+
* variant dimension, judged on the harness the execution will run on
|
|
6
|
+
* (stigmer/stigmer#1280).
|
|
6
7
|
*
|
|
7
8
|
* Unlike the fast tier, thinking is capability-gated, not pricing-gated:
|
|
8
|
-
* thinking variants bill at base per-token rates (ledger-verified), so
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
9
|
+
* thinking variants bill at base per-token rates (ledger-verified), so the
|
|
10
|
+
* registry fact that makes ENABLED selectable is a thinking capability on
|
|
11
|
+
* the model's entry for the execution's harness — `thinking` (a fixed
|
|
12
|
+
* budget) or `adaptiveThinking` (depth adapts). Both harnesses translate
|
|
13
|
+
* the mode: Cursor as its thinking variant, native as Anthropic's
|
|
14
|
+
* `thinking` parameter in the form the entry declares. The harness scoping
|
|
15
|
+
* is what keeps a selection honest: an id such as claude-sonnet-5 has an
|
|
16
|
+
* entry on each harness, and only the entry the execution runs on says
|
|
17
|
+
* what its model does.
|
|
16
18
|
*
|
|
17
|
-
* - UNSPECIFIED
|
|
18
|
-
*
|
|
19
|
-
*
|
|
19
|
+
* - UNSPECIFIED: always valid — it resolves to DISABLED in the runner,
|
|
20
|
+
* and a model that requires thinking thinks under it.
|
|
21
|
+
* - DISABLED: valid, except on a model whose entry declares
|
|
22
|
+
* `thinkingRequired` — it always thinks and the provider refuses a
|
|
23
|
+
* request to turn thinking off.
|
|
20
24
|
* - ENABLED: requires model_name to be set (Auto has no variant
|
|
21
|
-
* dimensions) and that model's
|
|
22
|
-
*
|
|
25
|
+
* dimensions) and that model's entry on the harness to declare a
|
|
26
|
+
* thinking capability.
|
|
23
27
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
28
|
+
* The harness is the one CreateSessionIfNeeded and dispatch will use: the
|
|
29
|
+
* stored session's for a turn on an existing session, else the bootstrap
|
|
30
|
+
* session_spec's, UNSPECIFIED read as native (harnessName; the Cloud
|
|
31
|
+
* billing gate resolves the same way). Reading a stored session is why the
|
|
32
|
+
* step runs after the run gate: the caller is authorized to add a turn to
|
|
33
|
+
* that session before anything about it is read, so a refusal never tells
|
|
34
|
+
* an unauthorized caller which harness a session uses. It is still a pure
|
|
35
|
+
* step, before any side effect.
|
|
27
36
|
*/
|
|
28
|
-
import type { AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
37
|
+
import type { AgentExecution, AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
29
38
|
import { ThinkingMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
39
|
+
import { SessionSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
|
|
40
|
+
import { Harness } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
|
|
41
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
30
42
|
|
|
31
|
-
import { invalidArgumentError } from "../../pipeline/errors.js";
|
|
43
|
+
import { internalError, invalidArgumentError } from "../../pipeline/errors.js";
|
|
32
44
|
import type { PipelineStep } from "../../pipeline/pipeline.js";
|
|
45
|
+
import { ResourceNotFoundError, type Store } from "../../store/interface.js";
|
|
33
46
|
import type { ModelCatalogProvider } from "../workflow/registry/model-catalog-provider.js";
|
|
34
|
-
import {
|
|
35
|
-
|
|
47
|
+
import {
|
|
48
|
+
ADAPTIVE_THINKING_CAPABILITY_KEY,
|
|
49
|
+
THINKING_CAPABILITY_KEY,
|
|
50
|
+
THINKING_REQUIRED_CAPABILITY_KEY,
|
|
51
|
+
} from "../workflow/registry/model-registry-store.js";
|
|
52
|
+
import { harnessName } from "../workflow/registry/pin-validation.js";
|
|
36
53
|
|
|
37
54
|
export function newValidateThinkingModeStep(
|
|
38
55
|
registry: ModelCatalogProvider,
|
|
56
|
+
store: Store,
|
|
39
57
|
): PipelineStep<typeof AgentExecutionSchema> {
|
|
40
58
|
return {
|
|
41
59
|
name: "ValidateThinkingMode",
|
|
42
|
-
execute(ctx) {
|
|
60
|
+
async execute(ctx) {
|
|
43
61
|
const config = ctx.newState.spec?.executionConfig;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
62
|
+
const mode = config?.thinkingMode ?? ThinkingMode.UNSPECIFIED;
|
|
63
|
+
const modelName = (config?.modelName ?? "").trim();
|
|
64
|
+
// UNSPECIFIED is always valid, and so is DISABLED on Auto (no model
|
|
65
|
+
// to require thinking). Unknown enum numbers were already refused by
|
|
66
|
+
// proto field validation (defined_only).
|
|
67
|
+
if (mode === ThinkingMode.UNSPECIFIED || (mode === ThinkingMode.DISABLED && modelName === "")) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const harness = await executionHarness(ctx.newState, store);
|
|
72
|
+
|
|
73
|
+
if (mode === ThinkingMode.DISABLED) {
|
|
74
|
+
if (registry.hasCapabilityForHarness(harness, modelName, THINKING_REQUIRED_CAPABILITY_KEY)) {
|
|
75
|
+
throw invalidArgumentError(
|
|
76
|
+
`thinking_mode 'disabled' is not available for model '${modelName}': the model always ` +
|
|
77
|
+
`thinks on the ${harness} harness and refuses a request to turn thinking off. ` +
|
|
78
|
+
"Leave thinking_mode unset, or set it to 'enabled'.",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
47
81
|
return;
|
|
48
82
|
}
|
|
49
83
|
|
|
50
|
-
const modelName = (config.modelName ?? "").trim();
|
|
51
84
|
if (modelName === "") {
|
|
52
85
|
throw invalidArgumentError(
|
|
53
86
|
"thinking_mode 'enabled' requires execution_config.model_name: thinking is a " +
|
|
54
87
|
"per-model capability, and Auto (no pinned model) has no variant dimensions. " +
|
|
55
|
-
`Pin a model that supports it${thinkingCapableSuffix(registry)}.`,
|
|
88
|
+
`Pin a model that supports it${thinkingCapableSuffix(registry, harness)}.`,
|
|
56
89
|
);
|
|
57
90
|
}
|
|
58
91
|
|
|
59
|
-
if (
|
|
60
|
-
!registry.hasCapabilityForHarness(
|
|
61
|
-
HARNESS_NAME_CURSOR,
|
|
62
|
-
modelName,
|
|
63
|
-
THINKING_CAPABILITY_KEY,
|
|
64
|
-
)
|
|
65
|
-
) {
|
|
92
|
+
if (!canThink(registry, harness, modelName)) {
|
|
66
93
|
throw invalidArgumentError(
|
|
67
94
|
`thinking_mode 'enabled' is not available for model '${modelName}': the model ` +
|
|
68
|
-
|
|
69
|
-
`harness${thinkingCapableSuffix(registry)}.`,
|
|
95
|
+
`registry declares no thinking capability for it on the ${harness} ` +
|
|
96
|
+
`harness${thinkingCapableSuffix(registry, harness)}.`,
|
|
70
97
|
);
|
|
71
98
|
}
|
|
72
99
|
},
|
|
73
100
|
};
|
|
74
101
|
}
|
|
75
102
|
|
|
103
|
+
/** The registry section name of the harness this execution will run on. */
|
|
104
|
+
async function executionHarness(execution: AgentExecution, store: Store): Promise<string> {
|
|
105
|
+
const sessionId = execution.spec?.sessionId ?? "";
|
|
106
|
+
if (sessionId === "") {
|
|
107
|
+
return harnessName(execution.spec?.sessionSpec?.harness ?? Harness.UNSPECIFIED);
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const session = await store.getResource(ApiResourceKind.session, sessionId, SessionSchema);
|
|
111
|
+
return harnessName(session.spec?.harness ?? Harness.UNSPECIFIED);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
// A dangling session id is the loading steps' refusal to make, with
|
|
114
|
+
// its own NotFound; judge it on the default harness, as dispatch does.
|
|
115
|
+
// Any other failure is a store fault, never a reading of the session.
|
|
116
|
+
if (error instanceof ResourceNotFoundError) {
|
|
117
|
+
return harnessName(Harness.UNSPECIFIED);
|
|
118
|
+
}
|
|
119
|
+
throw internalError(error, "failed to load session for thinking-mode validation");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function canThink(registry: ModelCatalogProvider, harness: string, modelName: string): boolean {
|
|
124
|
+
return (
|
|
125
|
+
registry.hasCapabilityForHarness(harness, modelName, THINKING_CAPABILITY_KEY) ||
|
|
126
|
+
registry.hasCapabilityForHarness(harness, modelName, ADAPTIVE_THINKING_CAPABILITY_KEY)
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
76
130
|
/**
|
|
77
|
-
* "; models with a thinking mode: a, b, c" — actionable refusal detail
|
|
78
|
-
*
|
|
79
|
-
* declares none.
|
|
131
|
+
* "; models with a thinking mode: a, b, c" — actionable refusal detail for
|
|
132
|
+
* the harness, sorted, empty when the registry declares none there.
|
|
80
133
|
*/
|
|
81
|
-
function thinkingCapableSuffix(registry: ModelCatalogProvider): string {
|
|
82
|
-
const capable =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
134
|
+
function thinkingCapableSuffix(registry: ModelCatalogProvider, harness: string): string {
|
|
135
|
+
const capable = [
|
|
136
|
+
...new Set([
|
|
137
|
+
...registry.canonicalModelsWithCapabilityForHarness(harness, THINKING_CAPABILITY_KEY),
|
|
138
|
+
...registry.canonicalModelsWithCapabilityForHarness(harness, ADAPTIVE_THINKING_CAPABILITY_KEY),
|
|
139
|
+
]),
|
|
140
|
+
].sort();
|
|
86
141
|
if (capable.length === 0) {
|
|
87
142
|
return "";
|
|
88
143
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the store-fault contract the artifact load by id shares: a typed
|
|
3
|
+
* ResourceNotFoundError answers NotFound with the domain's pinned Go copy
|
|
4
|
+
* (`Artifact not found: <id>`), and any other store failure is an
|
|
5
|
+
* infrastructure fault answered as a sanitized Internal, never a NotFound
|
|
6
|
+
* that tells a client a run's output does not exist (stigmer/stigmer#1345).
|
|
7
|
+
*
|
|
8
|
+
* The surfaces: the three direct handlers that load through
|
|
9
|
+
* loadArtifactOrNotFound, delete, getDownloadUrl and getContent, each
|
|
10
|
+
* reached through the registered handler on an in-process router. All three
|
|
11
|
+
* load before they authorize (stigmer#224), and a fault answered as Internal
|
|
12
|
+
* says nothing about whether the artifact exists. The composed suites reach
|
|
13
|
+
* only a real store, which cannot fail selectively, so each surface runs here
|
|
14
|
+
* against a store whose read throws. Artifact storage is untouchable: a load
|
|
15
|
+
* that fails must stop the call before any blob read or URL mint.
|
|
16
|
+
*
|
|
17
|
+
* Out of scope: the NotFound copy itself (wire contract, pinned by the
|
|
18
|
+
* artifact conformance suite) and create's best-effort org derivation
|
|
19
|
+
* (stigmer/stigmer#1369).
|
|
20
|
+
*/
|
|
21
|
+
import type { Client, ConnectError } from "@connectrpc/connect";
|
|
22
|
+
import { Code, createClient, createRouterTransport } from "@connectrpc/connect";
|
|
23
|
+
import { describe, expect, it } from "vitest";
|
|
24
|
+
|
|
25
|
+
import { ArtifactCommandController } from "@stigmer/protos/ai/stigmer/agentic/artifact/v1/command_pb";
|
|
26
|
+
import { ArtifactQueryController } from "@stigmer/protos/ai/stigmer/agentic/artifact/v1/query_pb";
|
|
27
|
+
|
|
28
|
+
import { createLogger } from "../../../boot/logger.js";
|
|
29
|
+
import { createVerifierChainInterceptor } from "../../../pipeline/interceptors/auth.js";
|
|
30
|
+
import {
|
|
31
|
+
errorOf,
|
|
32
|
+
failingStore,
|
|
33
|
+
untouchable,
|
|
34
|
+
} from "../../../pipeline/__tests__/support.js";
|
|
35
|
+
import { newPermissiveSingleTeamAuthorizer } from "../../../pipeline/steps/authorize.js";
|
|
36
|
+
import { ResourceNotFoundError } from "../../../store/interface.js";
|
|
37
|
+
import type { Store } from "../../../store/interface.js";
|
|
38
|
+
|
|
39
|
+
import { registerArtifactServices } from "../controller.js";
|
|
40
|
+
|
|
41
|
+
const silentLogger = createLogger({
|
|
42
|
+
level: "error",
|
|
43
|
+
pretty: false,
|
|
44
|
+
write: () => {},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const ARTIFACT_ID = "art_storefault";
|
|
48
|
+
|
|
49
|
+
const NOT_FOUND_COPY = `Artifact not found: ${ARTIFACT_ID}`;
|
|
50
|
+
const LOAD_FAULT_COPY = "failed to load artifact";
|
|
51
|
+
|
|
52
|
+
const MISSING = (): Error =>
|
|
53
|
+
new ResourceNotFoundError(`artifact/${ARTIFACT_ID}`);
|
|
54
|
+
const LOCKED = (): Error => new Error("SQLITE_BUSY: database is locked");
|
|
55
|
+
|
|
56
|
+
interface ArtifactClients {
|
|
57
|
+
readonly command: Client<typeof ArtifactCommandController>;
|
|
58
|
+
readonly query: Client<typeof ArtifactQueryController>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The registered handlers on an in-process router (`artifact.test.ts`'s
|
|
63
|
+
* harness): the verifier chain stamps the trusted-local caller, each handler
|
|
64
|
+
* checks its id, and the load is the first read of the store.
|
|
65
|
+
*/
|
|
66
|
+
function artifactClients(store: Store): ArtifactClients {
|
|
67
|
+
const transport = createRouterTransport(
|
|
68
|
+
(router) => {
|
|
69
|
+
registerArtifactServices(router, {
|
|
70
|
+
store,
|
|
71
|
+
artifactStorage: untouchable("artifactStorage"),
|
|
72
|
+
logger: silentLogger,
|
|
73
|
+
authorizer: newPermissiveSingleTeamAuthorizer(),
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
router: {
|
|
78
|
+
interceptors: [createVerifierChainInterceptor([], [], silentLogger)],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
return {
|
|
83
|
+
command: createClient(ArtifactCommandController, transport),
|
|
84
|
+
query: createClient(ArtifactQueryController, transport),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const SURFACES: ReadonlyArray<
|
|
89
|
+
readonly [string, (clients: ArtifactClients) => Promise<unknown>]
|
|
90
|
+
> = [
|
|
91
|
+
["delete", ({ command }) => command.delete({ value: ARTIFACT_ID })],
|
|
92
|
+
[
|
|
93
|
+
"getDownloadUrl",
|
|
94
|
+
({ query }) => query.getDownloadUrl({ value: ARTIFACT_ID }),
|
|
95
|
+
],
|
|
96
|
+
["getContent", ({ query }) => query.getContent({ artifactId: ARTIFACT_ID })],
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
describe.each(SURFACES)("%s — loadArtifactOrNotFound", (_name, call) => {
|
|
100
|
+
function surfaceError(store: Store): Promise<ConnectError> {
|
|
101
|
+
return errorOf(() => call(artifactClients(store)));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
it("a missing artifact answers NotFound with the domain's copy", async () => {
|
|
105
|
+
const error = await surfaceError(failingStore(MISSING()));
|
|
106
|
+
|
|
107
|
+
expect(error.code).toBe(Code.NotFound);
|
|
108
|
+
expect(error.rawMessage).toBe(NOT_FOUND_COPY);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("any other store failure answers a sanitized Internal", async () => {
|
|
112
|
+
const error = await surfaceError(failingStore(LOCKED()));
|
|
113
|
+
|
|
114
|
+
expect(error.code).toBe(Code.Internal);
|
|
115
|
+
expect(error.rawMessage).toBe(LOAD_FAULT_COPY);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* storage_state transition rather than the hard-delete pipeline; only
|
|
10
10
|
* get/listByExecution ride pipelines. Proven by
|
|
11
11
|
* artifact.conformance.test.ts (CONFORMANCE_TARGET=local, incl. the
|
|
12
|
-
* file-server lane)
|
|
12
|
+
* file-server lane), __tests__/artifact.test.ts and
|
|
13
|
+
* __tests__/store-faults.test.ts.
|
|
13
14
|
*
|
|
14
15
|
* Go tolerates a nil ArtifactStorage (two-phase wiring) and answers
|
|
15
16
|
* Internal "artifact storage not configured"; the TS composition root
|
|
@@ -79,6 +80,7 @@ import {
|
|
|
79
80
|
newLoadTargetStep,
|
|
80
81
|
} from "../../pipeline/steps/load-target.js";
|
|
81
82
|
import { newValidateProtoStep } from "../../pipeline/steps/validation.js";
|
|
83
|
+
import { ResourceNotFoundError } from "../../store/interface.js";
|
|
82
84
|
import type { Store } from "../../store/interface.js";
|
|
83
85
|
import type { ListIndexRow } from "../../store/list-index.js";
|
|
84
86
|
import { artifactListIndex } from "./list-index.js";
|
|
@@ -649,7 +651,7 @@ async function getContent(
|
|
|
649
651
|
});
|
|
650
652
|
}
|
|
651
653
|
|
|
652
|
-
/** Load by id
|
|
654
|
+
/** Load by id: a missing artifact answers the byte-pinned Go NotFound copy, a store fault Internal. */
|
|
653
655
|
async function loadArtifactOrNotFound(
|
|
654
656
|
deps: ArtifactControllerDeps,
|
|
655
657
|
artifactId: string,
|
|
@@ -660,10 +662,14 @@ async function loadArtifactOrNotFound(
|
|
|
660
662
|
artifactId,
|
|
661
663
|
ArtifactSchema,
|
|
662
664
|
);
|
|
663
|
-
} catch {
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
665
|
+
} catch (error) {
|
|
666
|
+
if (error instanceof ResourceNotFoundError) {
|
|
667
|
+
throw new ConnectError(
|
|
668
|
+
artifactNotFoundMessage(artifactId),
|
|
669
|
+
Code.NotFound,
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
throw internalError(error, "failed to load artifact");
|
|
667
673
|
}
|
|
668
674
|
}
|
|
669
675
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the store-fault contract every environment load by id shares: a typed
|
|
3
|
+
* ResourceNotFoundError answers NotFound with the domain's pinned copy
|
|
4
|
+
* (`environment not found: <id>`), and any other store failure is an
|
|
5
|
+
* infrastructure fault answered as a sanitized Internal, never a NotFound
|
|
6
|
+
* that tells a client the environment does not exist
|
|
7
|
+
* (stigmer/stigmer#1345).
|
|
8
|
+
*
|
|
9
|
+
* The surfaces: updateVisibility (its own load step) and the three RPCs that
|
|
10
|
+
* share LoadEnvironmentByID, updateVariables, removeVariables and
|
|
11
|
+
* getSecretValue, each reached through the registered handler on an
|
|
12
|
+
* in-process router. The composed suites reach only a real store, which
|
|
13
|
+
* cannot fail selectively, so each surface runs here against a store whose
|
|
14
|
+
* read throws. The secret service is untouchable: a load that fails must stop
|
|
15
|
+
* the call before any decrypt or encrypt.
|
|
16
|
+
*
|
|
17
|
+
* Out of scope: the NotFound copy itself (wire contract, pinned by
|
|
18
|
+
* environment.test.ts and the environment conformance suite) and the
|
|
19
|
+
* personal-environment resolver's fold of a failed secret read into a
|
|
20
|
+
* missing key (stigmer/stigmer#1360).
|
|
21
|
+
*/
|
|
22
|
+
import type { Client, ConnectError } from "@connectrpc/connect";
|
|
23
|
+
import { Code, createClient, createRouterTransport } from "@connectrpc/connect";
|
|
24
|
+
import { describe, expect, it } from "vitest";
|
|
25
|
+
|
|
26
|
+
import { EnvironmentCommandController } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/command_pb";
|
|
27
|
+
import { EnvironmentQueryController } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/query_pb";
|
|
28
|
+
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
29
|
+
|
|
30
|
+
import { createLogger } from "../../../boot/logger.js";
|
|
31
|
+
import { createApiResourceInterceptor } from "../../../pipeline/interceptors/apiresource.js";
|
|
32
|
+
import { createVerifierChainInterceptor } from "../../../pipeline/interceptors/auth.js";
|
|
33
|
+
import {
|
|
34
|
+
errorOf,
|
|
35
|
+
failingStore,
|
|
36
|
+
untouchable,
|
|
37
|
+
} from "../../../pipeline/__tests__/support.js";
|
|
38
|
+
import { newPermissiveSingleTeamAuthorizer } from "../../../pipeline/steps/authorize.js";
|
|
39
|
+
import { ResourceNotFoundError } from "../../../store/interface.js";
|
|
40
|
+
import type { Store } from "../../../store/interface.js";
|
|
41
|
+
|
|
42
|
+
import { registerEnvironmentServices } from "../controller.js";
|
|
43
|
+
|
|
44
|
+
const silentLogger = createLogger({
|
|
45
|
+
level: "error",
|
|
46
|
+
pretty: false,
|
|
47
|
+
write: () => {},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const ENVIRONMENT_ID = "env_storefault";
|
|
51
|
+
|
|
52
|
+
const NOT_FOUND_COPY = `environment not found: ${ENVIRONMENT_ID}`;
|
|
53
|
+
const LOAD_FAULT_COPY = "failed to load environment";
|
|
54
|
+
|
|
55
|
+
const MISSING = (): Error =>
|
|
56
|
+
new ResourceNotFoundError(`environment/${ENVIRONMENT_ID}`);
|
|
57
|
+
const LOCKED = (): Error => new Error("SQLITE_BUSY: database is locked");
|
|
58
|
+
|
|
59
|
+
interface EnvironmentClients {
|
|
60
|
+
readonly command: Client<typeof EnvironmentCommandController>;
|
|
61
|
+
readonly query: Client<typeof EnvironmentQueryController>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The registered handlers on an in-process router: the verifier chain stamps
|
|
66
|
+
* the trusted-local caller and the apiresource interceptor the kind both
|
|
67
|
+
* loads read, then Authorize and ValidateProto run and the load is the first
|
|
68
|
+
* step that reads the store.
|
|
69
|
+
*/
|
|
70
|
+
function environmentClients(store: Store): EnvironmentClients {
|
|
71
|
+
const transport = createRouterTransport(
|
|
72
|
+
(router) => {
|
|
73
|
+
registerEnvironmentServices(router, {
|
|
74
|
+
store,
|
|
75
|
+
logger: silentLogger,
|
|
76
|
+
authorizer: newPermissiveSingleTeamAuthorizer(),
|
|
77
|
+
authorizationLifecycle: undefined,
|
|
78
|
+
secretService: untouchable("secretService"),
|
|
79
|
+
listReadScope: undefined,
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
router: {
|
|
84
|
+
interceptors: [
|
|
85
|
+
createVerifierChainInterceptor([], [], silentLogger),
|
|
86
|
+
createApiResourceInterceptor(),
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
return {
|
|
92
|
+
command: createClient(EnvironmentCommandController, transport),
|
|
93
|
+
query: createClient(EnvironmentQueryController, transport),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Each surface with an input valid enough to pass ValidateProto. */
|
|
98
|
+
const SURFACES: ReadonlyArray<
|
|
99
|
+
readonly [string, (clients: EnvironmentClients) => Promise<unknown>]
|
|
100
|
+
> = [
|
|
101
|
+
[
|
|
102
|
+
"updateVisibility — LoadEnvironmentForVisibilityUpdate",
|
|
103
|
+
({ command }) =>
|
|
104
|
+
command.updateVisibility({
|
|
105
|
+
resourceId: ENVIRONMENT_ID,
|
|
106
|
+
visibility: ApiResourceVisibility.visibility_private,
|
|
107
|
+
}),
|
|
108
|
+
],
|
|
109
|
+
[
|
|
110
|
+
"updateVariables — LoadEnvironmentByID",
|
|
111
|
+
({ command }) =>
|
|
112
|
+
command.updateVariables({
|
|
113
|
+
environmentId: ENVIRONMENT_ID,
|
|
114
|
+
variables: { API_KEY: { value: "v", isSecret: false } },
|
|
115
|
+
}),
|
|
116
|
+
],
|
|
117
|
+
[
|
|
118
|
+
"removeVariables — LoadEnvironmentByID",
|
|
119
|
+
({ command }) =>
|
|
120
|
+
command.removeVariables({
|
|
121
|
+
environmentId: ENVIRONMENT_ID,
|
|
122
|
+
keys: ["API_KEY"],
|
|
123
|
+
}),
|
|
124
|
+
],
|
|
125
|
+
[
|
|
126
|
+
"getSecretValue — LoadEnvironmentByID",
|
|
127
|
+
({ query }) =>
|
|
128
|
+
query.getSecretValue({ environmentId: ENVIRONMENT_ID, key: "API_KEY" }),
|
|
129
|
+
],
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
describe.each(SURFACES)("%s", (_name, call) => {
|
|
133
|
+
function surfaceError(store: Store): Promise<ConnectError> {
|
|
134
|
+
return errorOf(() => call(environmentClients(store)));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
it("a missing environment answers NotFound with the domain's copy", async () => {
|
|
138
|
+
const error = await surfaceError(failingStore(MISSING()));
|
|
139
|
+
|
|
140
|
+
expect(error.code).toBe(Code.NotFound);
|
|
141
|
+
expect(error.rawMessage).toBe(NOT_FOUND_COPY);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("any other store failure answers a sanitized Internal", async () => {
|
|
145
|
+
const error = await surfaceError(failingStore(LOCKED()));
|
|
146
|
+
|
|
147
|
+
expect(error.code).toBe(Code.Internal);
|
|
148
|
+
expect(error.rawMessage).toBe(LOAD_FAULT_COPY);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* design: limited blast radius, per-key audit, the industry "reveal" UX).
|
|
9
9
|
*
|
|
10
10
|
* Pipeline per RPC mirrors the Go step chains character-for-character.
|
|
11
|
-
* Proven by environment.conformance.test.ts (CONFORMANCE_TARGET=local)
|
|
12
|
-
* and __tests__/
|
|
11
|
+
* Proven by environment.conformance.test.ts (CONFORMANCE_TARGET=local),
|
|
12
|
+
* __tests__/environment.test.ts and __tests__/store-faults.test.ts.
|
|
13
13
|
*
|
|
14
14
|
* Versus Stigmer Cloud, OSS excludes the Authorize, CreateIamPolicies,
|
|
15
15
|
* FGA-tuple, and Publish steps (no multi-tenant auth, IAM/FGA, or event
|
|
@@ -421,7 +421,7 @@ async function updateVisibility(
|
|
|
421
421
|
return env;
|
|
422
422
|
}
|
|
423
423
|
|
|
424
|
-
/** Loads the environment by resource_id;
|
|
424
|
+
/** Loads the environment by resource_id; a missing one answers NotFound, a store fault Internal. */
|
|
425
425
|
function newLoadEnvironmentForVisibilityUpdateStep(
|
|
426
426
|
store: Store,
|
|
427
427
|
): PipelineStep<UpdateVisibilityDesc> {
|
|
@@ -436,8 +436,11 @@ function newLoadEnvironmentForVisibilityUpdateStep(
|
|
|
436
436
|
input.resourceId,
|
|
437
437
|
EnvironmentSchema,
|
|
438
438
|
);
|
|
439
|
-
} catch {
|
|
440
|
-
|
|
439
|
+
} catch (error) {
|
|
440
|
+
if (error instanceof ResourceNotFoundError) {
|
|
441
|
+
throw notFoundError("environment", input.resourceId);
|
|
442
|
+
}
|
|
443
|
+
throw internalError(error, "failed to load environment");
|
|
441
444
|
}
|
|
442
445
|
ctx.set(UPDATE_VISIBILITY_ENVIRONMENT_KEY, env);
|
|
443
446
|
},
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* by design and must not hit the forgery rejection.
|
|
16
16
|
* 3. Redaction (redact.ts) runs AFTER Persist, outside the pipeline.
|
|
17
17
|
*
|
|
18
|
-
* Proven by environment.conformance.test.ts (CONFORMANCE_TARGET=local)
|
|
19
|
-
* and __tests__/
|
|
18
|
+
* Proven by environment.conformance.test.ts (CONFORMANCE_TARGET=local),
|
|
19
|
+
* __tests__/environment.test.ts and __tests__/store-faults.test.ts.
|
|
20
20
|
*/
|
|
21
21
|
import { create } from "@bufbuild/protobuf";
|
|
22
22
|
import type { DescMessage } from "@bufbuild/protobuf";
|
|
@@ -51,6 +51,7 @@ import { findResourceByLabelAndOrg } from "../../pipeline/steps/helpers.js";
|
|
|
51
51
|
import { EXISTING_RESOURCE_KEY } from "../../pipeline/steps/load-existing.js";
|
|
52
52
|
import { TARGET_RESOURCE_KEY } from "../../pipeline/steps/load-target.js";
|
|
53
53
|
import { destroySecretBackingState } from "../../pipeline/steps/secret-cleanup.js";
|
|
54
|
+
import { ResourceNotFoundError } from "../../store/interface.js";
|
|
54
55
|
import type { Store } from "../../store/interface.js";
|
|
55
56
|
import {
|
|
56
57
|
PERSONAL_LABEL_KEY,
|
|
@@ -273,7 +274,8 @@ export function newEnforcePersonalUniquenessStep(
|
|
|
273
274
|
* environment_id field (the standard LoadTarget expects a `value` id
|
|
274
275
|
* field, but EnvironmentSecretValueInput / Update-/RemoveEnvironment-
|
|
275
276
|
* VariablesRequest carry `environment_id`). Stores under
|
|
276
|
-
* TARGET_RESOURCE_KEY.
|
|
277
|
+
* TARGET_RESOURCE_KEY. A missing environment answers NotFound, a store
|
|
278
|
+
* fault Internal.
|
|
277
279
|
*/
|
|
278
280
|
export function newLoadEnvironmentByIdStep<Desc extends DescMessage>(
|
|
279
281
|
store: Store,
|
|
@@ -294,10 +296,11 @@ export function newLoadEnvironmentByIdStep<Desc extends DescMessage>(
|
|
|
294
296
|
environmentId,
|
|
295
297
|
EnvironmentSchema,
|
|
296
298
|
);
|
|
297
|
-
} catch {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (error instanceof ResourceNotFoundError) {
|
|
301
|
+
throw notFoundError("environment", environmentId);
|
|
302
|
+
}
|
|
303
|
+
throw internalError(error, "failed to load environment");
|
|
301
304
|
}
|
|
302
305
|
|
|
303
306
|
ctx.set(TARGET_RESOURCE_KEY, env);
|