@stigmer/runner 3.5.3 → 3.7.0
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/.build-fingerprint +1 -1
- package/dist/activities/call-agent.js +85 -10
- package/dist/activities/call-agent.js.map +1 -1
- package/dist/activities/discover-mcp-server.js +9 -1
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +5 -0
- package/dist/activities/execute-cursor/index.js +88 -14
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/model-pricing.d.ts +9 -0
- package/dist/activities/execute-cursor/model-pricing.js +19 -0
- package/dist/activities/execute-cursor/model-pricing.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +7 -0
- package/dist/activities/execute-cursor/prompt-builder.js +9 -0
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/service-tier.d.ts +68 -0
- package/dist/activities/execute-cursor/service-tier.js +187 -0
- package/dist/activities/execute-cursor/service-tier.js.map +1 -0
- package/dist/activities/execute-cursor/session-lifecycle.d.ts +16 -1
- package/dist/activities/execute-cursor/session-lifecycle.js +12 -4
- package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
- package/dist/activities/execute-cursor/usage-accumulator.d.ts +21 -1
- package/dist/activities/execute-cursor/usage-accumulator.js +23 -3
- package/dist/activities/execute-cursor/usage-accumulator.js.map +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.d.ts +6 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js +3 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +45 -9
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +32 -1
- package/dist/client/stigmer-client.js +42 -2
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/main.js +18 -0
- package/dist/main.js.map +1 -1
- package/dist/runner.js +48 -0
- package/dist/runner.js.map +1 -1
- package/dist/sandbox-token-renewal.d.ts +65 -0
- package/dist/sandbox-token-renewal.js +169 -0
- package/dist/sandbox-token-renewal.js.map +1 -0
- package/dist/shared/artifact-storage.d.ts +17 -3
- package/dist/shared/artifact-storage.js +22 -4
- package/dist/shared/artifact-storage.js.map +1 -1
- package/dist/shared/caller-identity.d.ts +89 -0
- package/dist/shared/caller-identity.js +124 -0
- package/dist/shared/caller-identity.js.map +1 -0
- package/dist/shared/channel-attachment.d.ts +85 -0
- package/dist/shared/channel-attachment.js +203 -0
- package/dist/shared/channel-attachment.js.map +1 -0
- package/dist/shared/datastore-attachment.d.ts +2 -25
- package/dist/shared/datastore-attachment.js +1 -28
- package/dist/shared/datastore-attachment.js.map +1 -1
- package/dist/shared/synthesized-attachment.d.ts +51 -0
- package/dist/shared/synthesized-attachment.js +45 -0
- package/dist/shared/synthesized-attachment.js.map +1 -0
- package/dist/workflow-engine/loader.js +99 -2
- package/dist/workflow-engine/loader.js.map +1 -1
- package/dist/workflow-engine/tasks/call-agent.d.ts +0 -2
- package/dist/workflow-engine/tasks/call-agent.js +0 -2
- package/dist/workflow-engine/tasks/call-agent.js.map +1 -1
- package/dist/workflow-engine/types.d.ts +39 -7
- package/dist/workflow-engine/types.js.map +1 -1
- package/dist/workflows/call-agent-orchestrator.d.ts +3 -2
- package/dist/workflows/call-agent-orchestrator.js +8 -2
- package/dist/workflows/call-agent-orchestrator.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-utils__/mock-client.ts +4 -0
- package/src/__tests__/sandbox-token-renewal.test.ts +174 -0
- package/src/activities/__tests__/call-agent-contracts.test.ts +4 -4
- package/src/activities/__tests__/call-agent.test.ts +219 -4
- package/src/activities/__tests__/discover-mcp-server.test.ts +49 -0
- package/src/activities/call-agent.ts +94 -10
- package/src/activities/discover-mcp-server.ts +13 -1
- package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +20 -0
- package/src/activities/execute-cursor/__tests__/service-tier.test.ts +170 -0
- package/src/activities/execute-cursor/__tests__/usage-accumulator.test.ts +87 -1
- package/src/activities/execute-cursor/index.ts +121 -20
- package/src/activities/execute-cursor/model-pricing.ts +23 -0
- package/src/activities/execute-cursor/prompt-builder.ts +19 -0
- package/src/activities/execute-cursor/service-tier.ts +244 -0
- package/src/activities/execute-cursor/session-lifecycle.ts +33 -5
- package/src/activities/execute-cursor/usage-accumulator.ts +35 -3
- package/src/activities/execute-deep-agent/prompt-builder.ts +10 -0
- package/src/activities/execute-deep-agent/setup.ts +66 -10
- package/src/client/stigmer-client.ts +57 -4
- package/src/main.ts +20 -0
- package/src/runner.ts +62 -0
- package/src/sandbox-token-renewal.ts +212 -0
- package/src/shared/__tests__/caller-identity.test.ts +159 -0
- package/src/shared/__tests__/channel-attachment.test.ts +276 -0
- package/src/shared/__tests__/datastore-attachment.test.ts +4 -4
- package/src/shared/artifact-storage.ts +32 -7
- package/src/shared/caller-identity.ts +161 -0
- package/src/shared/channel-attachment.ts +237 -0
- package/src/shared/datastore-attachment.ts +2 -54
- package/src/shared/synthesized-attachment.ts +77 -0
- package/src/workflow-engine/__tests__/golden-execution.test.ts +8 -8
- package/src/workflow-engine/__tests__/loader.test.ts +192 -7
- package/src/workflow-engine/__tests__/tasks/call-agent.test.ts +9 -9
- package/src/workflow-engine/loader.ts +113 -2
- package/src/workflow-engine/tasks/call-agent.ts +0 -2
- package/src/workflow-engine/types.ts +40 -7
- package/src/workflows/call-agent-orchestrator.ts +8 -2
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-renewal of a cloud sandbox's control-plane credential (STIGMER_TOKEN).
|
|
3
|
+
*
|
|
4
|
+
* A sandbox token is minted with a fixed TTL, but the sandbox it serves has
|
|
5
|
+
* no fixed lifetime: an active conversation extends a session sandbox
|
|
6
|
+
* indefinitely, and a long workflow run can outlast any TTL chosen at
|
|
7
|
+
* provisioning. Before this module, the only refresh path was the control
|
|
8
|
+
* plane's Secret-rewrite + pod restart — which the 2026-08-05 incident
|
|
9
|
+
* showed both sacrifices the turn that trips it (the old pod picks up the
|
|
10
|
+
* work holding the dead env token) and wipes an ephemeral sandbox's
|
|
11
|
+
* workspace. Renewal decouples credential lifetime from sandbox lifetime:
|
|
12
|
+
* the runner re-mints in-process before expiry via the getRunnerScopedToken
|
|
13
|
+
* `renewal` arm, and the server bounds renewability by the live sandbox
|
|
14
|
+
* record (a reaped sandbox's credential dies with it).
|
|
15
|
+
*
|
|
16
|
+
* Relationship to {@link createRunnerTokenCoordinator}
|
|
17
|
+
* (runner-token-coordinator.ts): the coordinator owns the PROXY credential
|
|
18
|
+
* (x-stigmer-auth) and re-mints it using the control-plane token; this
|
|
19
|
+
* module keeps that control-plane token itself fresh, so the two form one
|
|
20
|
+
* chain with no expiring root. They stay separate modules because their
|
|
21
|
+
* hosts differ — every proxy-mode runner has a coordinator, but only
|
|
22
|
+
* in-cluster sandbox runners (static cloud sandboxes and pool members) hold
|
|
23
|
+
* a renewable credential; the desktop's control-plane token is the user's
|
|
24
|
+
* own Auth0 token, refreshed by the host app.
|
|
25
|
+
*
|
|
26
|
+
* The loop re-reads the CURRENT token every cycle rather than binding to
|
|
27
|
+
* the boot credential, because the credential's class can change under it:
|
|
28
|
+
* a pool member boots with a non-renewable pool_sandbox token and swaps to
|
|
29
|
+
* a renewable session token at claim time (attach-session.ts). A
|
|
30
|
+
* non-renewable token parks the loop on a slow recheck instead of stopping
|
|
31
|
+
* it, so the swap is picked up without any coupling to the claim path.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Renew once the token has lived this fraction of its issued lifetime
|
|
36
|
+
* (anchored on iat/exp, the coordinator's 0.8 convention). Anchoring on the
|
|
37
|
+
* ISSUED lifetime matters: a fraction of the *remaining* lifetime re-arms at
|
|
38
|
+
* 80% of an ever-shrinking remainder and never reaches the renewal point.
|
|
39
|
+
*/
|
|
40
|
+
const RENEW_AT_LIFETIME_FRACTION = 0.8;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Fallback safety margin before expiry for a token without an iat claim:
|
|
44
|
+
* renew this early, leaving room for two retry cycles.
|
|
45
|
+
*/
|
|
46
|
+
const FALLBACK_RENEW_MARGIN_MS = 10 * 60_000;
|
|
47
|
+
|
|
48
|
+
/** Retry delay after a failed renewal — short enough to recover well before expiry. */
|
|
49
|
+
const RETRY_DELAY_MS = 60_000;
|
|
50
|
+
|
|
51
|
+
/** Recheck cadence while the current token is not a renewable class (pre-claim pool member). */
|
|
52
|
+
const RECHECK_DELAY_MS = 60_000;
|
|
53
|
+
|
|
54
|
+
/** Floor for any scheduled delay so a malformed expiry cannot spin a hot loop. */
|
|
55
|
+
const MIN_DELAY_MS = 5_000;
|
|
56
|
+
|
|
57
|
+
/** The token_type claims the renewal arm accepts (mirrors the server's gate). */
|
|
58
|
+
const RENEWABLE_TOKEN_TYPES = new Set(["sandbox", "workflow_sandbox"]);
|
|
59
|
+
|
|
60
|
+
export interface SandboxTokenRenewalOptions {
|
|
61
|
+
/** Reads the credential currently in effect (re-read every cycle; see module doc). */
|
|
62
|
+
readonly getToken: () => string | null | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Calls the getRunnerScopedToken `renewal` arm authenticated with
|
|
65
|
+
* {@code currentToken}. Returns undefined when the server minted nothing;
|
|
66
|
+
* the loop retries rather than crashing the runner.
|
|
67
|
+
*/
|
|
68
|
+
readonly renew: (currentToken: string) => Promise<
|
|
69
|
+
{ token: string; expiresInSeconds?: number } | undefined
|
|
70
|
+
>;
|
|
71
|
+
/** Applies a freshly minted credential to every sink the host wires (ref, env, interceptors). */
|
|
72
|
+
readonly applyToken: (token: string) => void;
|
|
73
|
+
/** Optional structured logger; defaults to console. */
|
|
74
|
+
readonly log?: Pick<typeof console, "log" | "warn">;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SandboxTokenRenewal {
|
|
78
|
+
/** Stop the renewal timer. Idempotent. */
|
|
79
|
+
stop(): void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether a token's decoded claims mark it as renewable through the
|
|
84
|
+
* `renewal` arm. Exported so a static-mode host can decide not to start
|
|
85
|
+
* the loop at all for a credential whose class can never change.
|
|
86
|
+
*/
|
|
87
|
+
export function isRenewableSandboxToken(token: string | null | undefined): boolean {
|
|
88
|
+
const claims = decodeClaims(token);
|
|
89
|
+
return claims !== null
|
|
90
|
+
&& RENEWABLE_TOKEN_TYPES.has(claims.tokenType ?? "")
|
|
91
|
+
&& claims.expMs !== null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Start the renewal loop. The first cycle runs immediately (it only reads
|
|
96
|
+
* the token and schedules; no network call unless the credential is already
|
|
97
|
+
* past its renewal point).
|
|
98
|
+
*/
|
|
99
|
+
export function startSandboxTokenRenewal(
|
|
100
|
+
options: SandboxTokenRenewalOptions,
|
|
101
|
+
): SandboxTokenRenewal {
|
|
102
|
+
const log = options.log ?? console;
|
|
103
|
+
let stopped = false;
|
|
104
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
105
|
+
|
|
106
|
+
const schedule = (delayMs: number): void => {
|
|
107
|
+
if (stopped) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
timer = setTimeout(() => {
|
|
111
|
+
void cycle();
|
|
112
|
+
}, Math.max(MIN_DELAY_MS, delayMs));
|
|
113
|
+
// Never keep the process alive solely for a token renewal.
|
|
114
|
+
timer.unref?.();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const cycle = async (): Promise<void> => {
|
|
118
|
+
if (stopped) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const token = options.getToken() ?? null;
|
|
122
|
+
const claims = decodeClaims(token);
|
|
123
|
+
|
|
124
|
+
if (token === null || claims === null
|
|
125
|
+
|| !RENEWABLE_TOKEN_TYPES.has(claims.tokenType ?? "")) {
|
|
126
|
+
// Not (yet) a renewable credential — a pre-claim pool member, or a
|
|
127
|
+
// credential class this loop does not own. Park and look again.
|
|
128
|
+
schedule(RECHECK_DELAY_MS);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (claims.expMs === null) {
|
|
133
|
+
// A renewable-class token without an expiry is a mint-side anomaly;
|
|
134
|
+
// renewing "before expiry" is undefined, so park rather than spin.
|
|
135
|
+
log.warn("[sandbox-token-renewal] Credential carries no exp claim; parking");
|
|
136
|
+
schedule(RECHECK_DELAY_MS);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const renewAtMs = claims.iatMs !== null
|
|
141
|
+
? claims.iatMs + (claims.expMs - claims.iatMs) * RENEW_AT_LIFETIME_FRACTION
|
|
142
|
+
: claims.expMs - FALLBACK_RENEW_MARGIN_MS;
|
|
143
|
+
const delayMs = renewAtMs - Date.now();
|
|
144
|
+
if (delayMs > MIN_DELAY_MS) {
|
|
145
|
+
schedule(delayMs);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// At or past the renewal point (possibly past expiry after a long pod
|
|
150
|
+
// pause — the attempt is still correct: the server decides).
|
|
151
|
+
try {
|
|
152
|
+
const renewed = await options.renew(token);
|
|
153
|
+
if (renewed) {
|
|
154
|
+
options.applyToken(renewed.token);
|
|
155
|
+
log.log("[sandbox-token-renewal] Sandbox credential renewed");
|
|
156
|
+
// Recompute from the fresh token (its own exp is authoritative).
|
|
157
|
+
schedule(MIN_DELAY_MS);
|
|
158
|
+
} else {
|
|
159
|
+
log.warn("[sandbox-token-renewal] Server minted no renewal; will retry");
|
|
160
|
+
schedule(RETRY_DELAY_MS);
|
|
161
|
+
}
|
|
162
|
+
} catch (err) {
|
|
163
|
+
log.warn(
|
|
164
|
+
"[sandbox-token-renewal] Renewal failed; will retry: " +
|
|
165
|
+
`${err instanceof Error ? err.message : err}`,
|
|
166
|
+
);
|
|
167
|
+
schedule(RETRY_DELAY_MS);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
void cycle();
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
stop(): void {
|
|
175
|
+
stopped = true;
|
|
176
|
+
if (timer) {
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
timer = null;
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Decode the claims this module reads (token_type, iat, exp) from an
|
|
186
|
+
* unverified JWT. Decode-only on purpose: this is the runner's OWN
|
|
187
|
+
* credential, minted by the server it will present it back to —
|
|
188
|
+
* verification happens there.
|
|
189
|
+
*/
|
|
190
|
+
function decodeClaims(
|
|
191
|
+
token: string | null | undefined,
|
|
192
|
+
): { tokenType: string | undefined; iatMs: number | null; expMs: number | null } | null {
|
|
193
|
+
if (!token) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const parts = token.split(".");
|
|
197
|
+
if (parts.length !== 3) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const payload = JSON.parse(
|
|
202
|
+
Buffer.from(parts[1], "base64url").toString("utf-8"),
|
|
203
|
+
) as { token_type?: string; iat?: number; exp?: number };
|
|
204
|
+
return {
|
|
205
|
+
tokenType: payload.token_type,
|
|
206
|
+
iatMs: typeof payload.iat === "number" ? payload.iat * 1000 : null,
|
|
207
|
+
expMs: typeof payload.exp === "number" ? payload.exp * 1000 : null,
|
|
208
|
+
};
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the caller identity: the reserved env keys, the
|
|
3
|
+
* precedence chain, the authoritative injection, and the discovery
|
|
4
|
+
* sentinel that keeps identity-templating servers discoverable.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
ANONYMOUS_KIND,
|
|
10
|
+
CALLER_IDENTITY_KIND_ENV_KEY,
|
|
11
|
+
CALLER_IDENTITY_VALUE_ENV_KEY,
|
|
12
|
+
SESSION_ID_ENV_KEY,
|
|
13
|
+
STIGMER_USER_KIND,
|
|
14
|
+
anonymousCallerIdentity,
|
|
15
|
+
injectAnonymousCallerIdentityForDiscovery,
|
|
16
|
+
injectCallerIdentityEnv,
|
|
17
|
+
resolveCallerIdentity,
|
|
18
|
+
} from "../caller-identity.js";
|
|
19
|
+
import {
|
|
20
|
+
SENDER_IDENTITY_METADATA_KEY,
|
|
21
|
+
SENDER_KIND_METADATA_KEY,
|
|
22
|
+
} from "../sender-identity.js";
|
|
23
|
+
import { resolveHeaders } from "../placeholder-resolver.js";
|
|
24
|
+
|
|
25
|
+
describe("reserved env keys", () => {
|
|
26
|
+
it("are pinned verbatim — MCP server specs template these names (contract guard)", () => {
|
|
27
|
+
// These names appear in user-authored McpServer YAML (spec.env
|
|
28
|
+
// declarations and ${...} header templates). Renaming them breaks
|
|
29
|
+
// every deployed server that consumes caller identity.
|
|
30
|
+
expect(CALLER_IDENTITY_KIND_ENV_KEY).toBe("STIGMER_CALLER_IDENTITY_KIND");
|
|
31
|
+
expect(CALLER_IDENTITY_VALUE_ENV_KEY).toBe("STIGMER_CALLER_IDENTITY_VALUE");
|
|
32
|
+
expect(SESSION_ID_ENV_KEY).toBe("STIGMER_SESSION_ID");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("resolveCallerIdentity precedence", () => {
|
|
37
|
+
const channelMetadata = {
|
|
38
|
+
[SENDER_IDENTITY_METADATA_KEY]: "919800000001",
|
|
39
|
+
[SENDER_KIND_METADATA_KEY]: "whatsapp_phone",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
it("channel sender wins, kind passed through VERBATIM from the broker", () => {
|
|
43
|
+
expect(
|
|
44
|
+
resolveCallerIdentity(channelMetadata, { id: "idacc_1", email: "owner@example.com" }),
|
|
45
|
+
).toEqual({ kind: "whatsapp_phone", value: "919800000001" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("falls to the session creator (stigmer_user) when no channel sender exists", () => {
|
|
49
|
+
expect(
|
|
50
|
+
resolveCallerIdentity({}, { id: "idacc_1", email: "owner@example.com" }),
|
|
51
|
+
).toEqual({ kind: STIGMER_USER_KIND, value: "owner@example.com" });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("prefers the creator's email over the historically-mixed id field", () => {
|
|
55
|
+
// The audit actor's `id` is sometimes an identity-account id and
|
|
56
|
+
// sometimes an email (per the proto's own @internal note) — email is
|
|
57
|
+
// what humans bind against, so it wins when present.
|
|
58
|
+
expect(resolveCallerIdentity(undefined, { id: "idacc_1", email: "a@b.c" }).value).toBe("a@b.c");
|
|
59
|
+
expect(resolveCallerIdentity(undefined, { id: "idacc_1" }).value).toBe("idacc_1");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("falls to anonymous when neither source exists (OSS sparse audit, no metadata)", () => {
|
|
63
|
+
expect(resolveCallerIdentity(undefined, undefined)).toEqual({
|
|
64
|
+
kind: ANONYMOUS_KIND,
|
|
65
|
+
value: "",
|
|
66
|
+
});
|
|
67
|
+
expect(resolveCallerIdentity({}, { id: " ", email: "" })).toEqual({
|
|
68
|
+
kind: ANONYMOUS_KIND,
|
|
69
|
+
value: "",
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("a half-present channel identity (value without kind) is NOT a channel sender", () => {
|
|
74
|
+
// readSenderIdentity requires value AND kind; a half-stamped session
|
|
75
|
+
// falls through to the creator rather than fabricating a kind.
|
|
76
|
+
expect(
|
|
77
|
+
resolveCallerIdentity(
|
|
78
|
+
{ [SENDER_IDENTITY_METADATA_KEY]: "919800000001" },
|
|
79
|
+
{ email: "owner@example.com" },
|
|
80
|
+
),
|
|
81
|
+
).toEqual({ kind: STIGMER_USER_KIND, value: "owner@example.com" });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("injectCallerIdentityEnv", () => {
|
|
86
|
+
const identity = { kind: "whatsapp_phone", value: "919800000001" };
|
|
87
|
+
|
|
88
|
+
it("returns a NEW map with the reserved keys set — input never mutated", () => {
|
|
89
|
+
const input = { GRIST_API_KEY: "secret" };
|
|
90
|
+
const result = injectCallerIdentityEnv(input, identity, "sess_1");
|
|
91
|
+
|
|
92
|
+
expect(result).toEqual({
|
|
93
|
+
GRIST_API_KEY: "secret",
|
|
94
|
+
[CALLER_IDENTITY_KIND_ENV_KEY]: "whatsapp_phone",
|
|
95
|
+
[CALLER_IDENTITY_VALUE_ENV_KEY]: "919800000001",
|
|
96
|
+
[SESSION_ID_ENV_KEY]: "sess_1",
|
|
97
|
+
});
|
|
98
|
+
expect(input).toEqual({ GRIST_API_KEY: "secret" });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("platform values are authoritative — a user env var cannot impersonate a caller", () => {
|
|
102
|
+
const result = injectCallerIdentityEnv(
|
|
103
|
+
{ [CALLER_IDENTITY_VALUE_ENV_KEY]: "999999999999" },
|
|
104
|
+
identity,
|
|
105
|
+
"sess_1",
|
|
106
|
+
);
|
|
107
|
+
expect(result[CALLER_IDENTITY_VALUE_ENV_KEY]).toBe("919800000001");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("the reserved keys can never hit the unresolved-placeholder silent-skip path", () => {
|
|
111
|
+
// At runtime PlaceholderResolutionError is caught and the server
|
|
112
|
+
// silently dropped from the execution — always-present injection makes
|
|
113
|
+
// that unreachable for these keys, whatever the identity resolved to.
|
|
114
|
+
const env = injectCallerIdentityEnv({}, anonymousCallerIdentity(), "");
|
|
115
|
+
const headers = resolveHeaders(
|
|
116
|
+
{
|
|
117
|
+
"X-Stigmer-Caller-Kind": `\${${CALLER_IDENTITY_KIND_ENV_KEY}}`,
|
|
118
|
+
"X-Stigmer-Caller-Value": `\${${CALLER_IDENTITY_VALUE_ENV_KEY}}`,
|
|
119
|
+
},
|
|
120
|
+
env,
|
|
121
|
+
);
|
|
122
|
+
expect(headers).toEqual({
|
|
123
|
+
"X-Stigmer-Caller-Kind": ANONYMOUS_KIND,
|
|
124
|
+
"X-Stigmer-Caller-Value": "",
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("injectAnonymousCallerIdentityForDiscovery", () => {
|
|
130
|
+
it("resolves declared identity placeholders with the anonymous sentinel", () => {
|
|
131
|
+
// Discovery has no session: without the sentinel, a server templating
|
|
132
|
+
// ${STIGMER_CALLER_IDENTITY_VALUE} would throw
|
|
133
|
+
// PlaceholderResolutionError and never get its tools classified.
|
|
134
|
+
const declared = new Set([
|
|
135
|
+
"GRIST_API_KEY",
|
|
136
|
+
CALLER_IDENTITY_KIND_ENV_KEY,
|
|
137
|
+
CALLER_IDENTITY_VALUE_ENV_KEY,
|
|
138
|
+
]);
|
|
139
|
+
const env = injectAnonymousCallerIdentityForDiscovery(declared, {
|
|
140
|
+
GRIST_API_KEY: "secret",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(
|
|
144
|
+
resolveHeaders(
|
|
145
|
+
{ "X-Stigmer-Caller-Kind": `\${${CALLER_IDENTITY_KIND_ENV_KEY}}` },
|
|
146
|
+
env,
|
|
147
|
+
),
|
|
148
|
+
).toEqual({ "X-Stigmer-Caller-Kind": ANONYMOUS_KIND });
|
|
149
|
+
expect(env[CALLER_IDENTITY_VALUE_ENV_KEY]).toBe("");
|
|
150
|
+
expect(env.GRIST_API_KEY).toBe("secret");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("is declaration-gated: servers that never declared the keys get an untouched map", () => {
|
|
154
|
+
const input = { OTHER: "x" };
|
|
155
|
+
const result = injectAnonymousCallerIdentityForDiscovery(new Set(["OTHER"]), input);
|
|
156
|
+
expect(result).toBe(input);
|
|
157
|
+
expect(result).not.toHaveProperty(CALLER_IDENTITY_KIND_ENV_KEY);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The channel messaging attachment (proactive-messaging DD-006 D7/D8):
|
|
3
|
+
* discovery with the never-throw failure posture, both connection
|
|
4
|
+
* shapes, the structural approval-freedom the datastore attachment
|
|
5
|
+
* pinned before it, and the prompt section's filter/order/cap rules
|
|
6
|
+
* (DD-006 D6). The cross-repo pinned strings (slug, route, roster) are
|
|
7
|
+
* guarded here and in the mcp-server integration test — the
|
|
8
|
+
* TOOL_CALL_LIMIT precedent.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it, vi } from "vitest";
|
|
12
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
13
|
+
import type {
|
|
14
|
+
ChannelTemplate,
|
|
15
|
+
MessagingChannel,
|
|
16
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
|
|
17
|
+
|
|
18
|
+
import { mockStigmerClient } from "../../__test-utils__/mock-client.js";
|
|
19
|
+
import { mergeApprovalPolicies, type ActiveLeases } from "../approval-policy.js";
|
|
20
|
+
import { needsBackfill } from "../connect-backfill.js";
|
|
21
|
+
import {
|
|
22
|
+
CHANNEL_ATTACHMENT_SLUG,
|
|
23
|
+
CHANNELS_ROUTE,
|
|
24
|
+
TEMPLATE_SECTION_CAP,
|
|
25
|
+
discoverChannelMessaging,
|
|
26
|
+
formatChannelTemplatesSection,
|
|
27
|
+
synthesizeChannelAttachment,
|
|
28
|
+
type ChannelMessagingInfo,
|
|
29
|
+
} from "../channel-attachment.js";
|
|
30
|
+
import type { ResolvedMcpServer } from "../mcp-resolver.js";
|
|
31
|
+
|
|
32
|
+
function channel(slug: string): MessagingChannel {
|
|
33
|
+
return { channel: slug, provider: "whatsapp" } as MessagingChannel;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function template(overrides: Partial<ChannelTemplate>): ChannelTemplate {
|
|
37
|
+
return {
|
|
38
|
+
name: "fee_reminder",
|
|
39
|
+
language: "en",
|
|
40
|
+
category: "UTILITY",
|
|
41
|
+
status: "APPROVED",
|
|
42
|
+
parameterFormat: "POSITIONAL",
|
|
43
|
+
parameterNames: ["1", "2"],
|
|
44
|
+
bodyText: "Hi {{1}}, your fee of {{2}} is due.",
|
|
45
|
+
headerFormat: "",
|
|
46
|
+
rejectionReason: "",
|
|
47
|
+
unsupportedReason: "",
|
|
48
|
+
...overrides,
|
|
49
|
+
} as ChannelTemplate;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function info(slug: string, templates: ChannelTemplate[]): ChannelMessagingInfo {
|
|
53
|
+
return { channel: channel(slug), templates };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const noLeases: ActiveLeases = {
|
|
57
|
+
global: false,
|
|
58
|
+
categories: new Set(),
|
|
59
|
+
servers: new Set(),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe("discoverChannelMessaging (the DD-006 D4 failure posture)", () => {
|
|
63
|
+
it("returns channels with their templates, threading the scoped credential", async () => {
|
|
64
|
+
const client = mockStigmerClient({
|
|
65
|
+
listMessagingChannels: vi.fn().mockResolvedValue([channel("isc-whatsapp")]),
|
|
66
|
+
listChannelTemplates: vi.fn().mockResolvedValue([template({})]),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const result = await discoverChannelMessaging(client, "scoped-tok");
|
|
70
|
+
|
|
71
|
+
expect(result).toHaveLength(1);
|
|
72
|
+
expect(result[0].channel.channel).toBe("isc-whatsapp");
|
|
73
|
+
expect(result[0].templates).toHaveLength(1);
|
|
74
|
+
expect(client.listMessagingChannels).toHaveBeenCalledWith("scoped-tok");
|
|
75
|
+
expect(client.listChannelTemplates).toHaveBeenCalledWith("isc-whatsapp", "scoped-tok");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("answers empty for the everyday no-channel agent without fetching templates", async () => {
|
|
79
|
+
const client = mockStigmerClient();
|
|
80
|
+
|
|
81
|
+
expect(await discoverChannelMessaging(client, undefined)).toEqual([]);
|
|
82
|
+
expect(client.listChannelTemplates).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it.each([
|
|
86
|
+
["UNIMPLEMENTED (a control plane predating 3a)", Code.Unimplemented],
|
|
87
|
+
["FAILED_PRECONDITION (the OSS posture on older servers)", Code.FailedPrecondition],
|
|
88
|
+
["PERMISSION_DENIED (an unexpected reach refusal)", Code.PermissionDenied],
|
|
89
|
+
])("degrades %s to honest absence, never a throw", async (_label, code) => {
|
|
90
|
+
const client = mockStigmerClient({
|
|
91
|
+
listMessagingChannels: vi.fn().mockRejectedValue(new ConnectError("nope", code)),
|
|
92
|
+
});
|
|
93
|
+
const quiet = vi.spyOn(console, "debug").mockImplementation(() => {});
|
|
94
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
95
|
+
|
|
96
|
+
expect(await discoverChannelMessaging(client, undefined)).toEqual([]);
|
|
97
|
+
|
|
98
|
+
quiet.mockRestore();
|
|
99
|
+
warn.mockRestore();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("a template-read failure degrades PER CHANNEL — the tool survives, the section entry does not", async () => {
|
|
103
|
+
const client = mockStigmerClient({
|
|
104
|
+
listMessagingChannels: vi.fn().mockResolvedValue([channel("wa-a"), channel("wa-b")]),
|
|
105
|
+
listChannelTemplates: vi.fn()
|
|
106
|
+
.mockImplementation(async (slug: string) => {
|
|
107
|
+
if (slug === "wa-a") throw new ConnectError("registry down", Code.Unavailable);
|
|
108
|
+
return [template({})];
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
const quiet = vi.spyOn(console, "debug").mockImplementation(() => {});
|
|
112
|
+
|
|
113
|
+
const result = await discoverChannelMessaging(client, undefined);
|
|
114
|
+
|
|
115
|
+
expect(result.map((r) => [r.channel.channel, r.templates.length])).toEqual([
|
|
116
|
+
["wa-a", 0],
|
|
117
|
+
["wa-b", 1],
|
|
118
|
+
]);
|
|
119
|
+
quiet.mockRestore();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("synthesizeChannelAttachment", () => {
|
|
124
|
+
const options = {
|
|
125
|
+
bridgeEndpoint: "https://mcp.stigmer.ai/",
|
|
126
|
+
credential: "sandbox-token",
|
|
127
|
+
backendEndpoint: "http://localhost:7234",
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
it("returns undefined when the agent serves no proactive channel", () => {
|
|
131
|
+
expect(synthesizeChannelAttachment([], options)).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("builds the HTTP shape against the bridge /channels route with the credential", () => {
|
|
135
|
+
const attachment = synthesizeChannelAttachment([info("isc-whatsapp", [])], options);
|
|
136
|
+
expect(attachment).toMatchObject({
|
|
137
|
+
slug: CHANNEL_ATTACHMENT_SLUG,
|
|
138
|
+
connectionType: "http",
|
|
139
|
+
url: "https://mcp.stigmer.ai/channels",
|
|
140
|
+
headers: { Authorization: "Bearer sandbox-token" },
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("builds the OSS stdio shape: the CLI-embedded bridge with the channels roster", () => {
|
|
145
|
+
const attachment = synthesizeChannelAttachment([info("isc-whatsapp", [])], {
|
|
146
|
+
bridgeEndpoint: null,
|
|
147
|
+
credential: null,
|
|
148
|
+
backendEndpoint: "http://localhost:7234",
|
|
149
|
+
});
|
|
150
|
+
expect(attachment).toMatchObject({
|
|
151
|
+
connectionType: "stdio",
|
|
152
|
+
command: "stigmer",
|
|
153
|
+
args: ["mcp-server"],
|
|
154
|
+
env: {
|
|
155
|
+
STIGMER_MCP_ROSTER: "channels",
|
|
156
|
+
STIGMER_SERVER_ADDRESS: "localhost:7234",
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("pins the cross-repo strings the mcp-server side guards too", () => {
|
|
162
|
+
expect(CHANNEL_ATTACHMENT_SLUG).toBe("stigmer-channels");
|
|
163
|
+
expect(CHANNELS_ROUTE).toBe("/channels");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("is approval-free by construction: zero entries in the merged approval map", () => {
|
|
167
|
+
const attachment = synthesizeChannelAttachment([info("isc-whatsapp", [])], options)!;
|
|
168
|
+
// Forced, not convenient (DD-002 D6): both calling surfaces run
|
|
169
|
+
// UNATTENDED mode, where a gated tool resolves as skip-and-adapt —
|
|
170
|
+
// a gated send tool means reminders never send.
|
|
171
|
+
const merged = mergeApprovalPolicies(
|
|
172
|
+
[attachment as ResolvedMcpServer], [], noLeases,
|
|
173
|
+
);
|
|
174
|
+
expect(merged.size).toBe(0);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("is structurally immune to the connect backfill (destructiveHint tightener)", () => {
|
|
178
|
+
const attachment = synthesizeChannelAttachment([info("isc-whatsapp", [])], options)!;
|
|
179
|
+
expect(attachment.discoveredCapabilitiesEmpty).toBe(false);
|
|
180
|
+
expect(needsBackfill(attachment)).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("formatChannelTemplatesSection (DD-006 D6)", () => {
|
|
185
|
+
it("renders sendable templates with body text, parameters, and the image-header requirement", () => {
|
|
186
|
+
const section = formatChannelTemplatesSection([
|
|
187
|
+
info("isc-whatsapp", [
|
|
188
|
+
template({}),
|
|
189
|
+
template({
|
|
190
|
+
name: "invoice_qr",
|
|
191
|
+
parameterFormat: "NAMED",
|
|
192
|
+
parameterNames: ["member_name", "amount"],
|
|
193
|
+
bodyText: "Hello {{member_name}}, pay {{amount}}.",
|
|
194
|
+
headerFormat: "IMAGE",
|
|
195
|
+
}),
|
|
196
|
+
]),
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
expect(section).toContain("<available_channel_templates>");
|
|
200
|
+
expect(section).toContain("channel: isc-whatsapp (whatsapp)");
|
|
201
|
+
expect(section).toContain("- fee_reminder (en) [UTILITY], parameters: 1, 2");
|
|
202
|
+
expect(section).toContain('"Hi {{1}}, your fee of {{2}} is due."');
|
|
203
|
+
expect(section).toContain(
|
|
204
|
+
"- invoice_qr (en) [UTILITY], parameters: member_name, amount"
|
|
205
|
+
+ " (requires header_image_link: a public HTTPS image URL)",
|
|
206
|
+
);
|
|
207
|
+
expect(section).toContain("send_channel_message");
|
|
208
|
+
expect(section).toContain("</available_channel_templates>");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("filters unsendable templates out entirely — the console is the diagnosis surface", () => {
|
|
212
|
+
const section = formatChannelTemplatesSection([
|
|
213
|
+
info("isc-whatsapp", [
|
|
214
|
+
template({}),
|
|
215
|
+
template({
|
|
216
|
+
name: "dynamic_promo",
|
|
217
|
+
unsupportedReason: "this template has a dynamic-URL button, which this platform"
|
|
218
|
+
+ " version cannot supply parameters for",
|
|
219
|
+
}),
|
|
220
|
+
]),
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
expect(section).toContain("fee_reminder");
|
|
224
|
+
expect(section).not.toContain("dynamic_promo");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("returns \"\" when nothing is sendable — the tool alone still serves text sends", () => {
|
|
228
|
+
expect(formatChannelTemplatesSection([info("isc-whatsapp", [])])).toBe("");
|
|
229
|
+
expect(formatChannelTemplatesSection([
|
|
230
|
+
info("isc-whatsapp", [template({ unsupportedReason: "unsupported" })]),
|
|
231
|
+
])).toBe("");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("orders deterministically by (name, language) — never registry order", () => {
|
|
235
|
+
const section = formatChannelTemplatesSection([
|
|
236
|
+
info("isc-whatsapp", [
|
|
237
|
+
template({ name: "b_second", language: "en" }),
|
|
238
|
+
template({ name: "a_first", language: "hi" }),
|
|
239
|
+
template({ name: "a_first", language: "en" }),
|
|
240
|
+
]),
|
|
241
|
+
]);
|
|
242
|
+
|
|
243
|
+
const first = section.indexOf("a_first (en)");
|
|
244
|
+
const second = section.indexOf("a_first (hi)");
|
|
245
|
+
const third = section.indexOf("b_second (en)");
|
|
246
|
+
expect(first).toBeGreaterThan(-1);
|
|
247
|
+
expect(second).toBeGreaterThan(first);
|
|
248
|
+
expect(third).toBeGreaterThan(second);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("caps the section and names how many were withheld", () => {
|
|
252
|
+
const many = Array.from({ length: TEMPLATE_SECTION_CAP + 5 }, (_, i) =>
|
|
253
|
+
template({ name: `t_${String(i).padStart(3, "0")}` }));
|
|
254
|
+
|
|
255
|
+
const section = formatChannelTemplatesSection([info("isc-whatsapp", many)]);
|
|
256
|
+
|
|
257
|
+
expect(section).toContain(`t_${String(TEMPLATE_SECTION_CAP - 1).padStart(3, "0")}`);
|
|
258
|
+
expect(section).not.toContain(`t_${String(TEMPLATE_SECTION_CAP).padStart(3, "0")}`);
|
|
259
|
+
expect(section).toContain("(5 more approved templates not shown)");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("the cap spans channels; a later channel with no budget left is omitted whole", () => {
|
|
263
|
+
const fill = Array.from({ length: TEMPLATE_SECTION_CAP }, (_, i) =>
|
|
264
|
+
template({ name: `t_${String(i).padStart(3, "0")}` }));
|
|
265
|
+
|
|
266
|
+
const section = formatChannelTemplatesSection([
|
|
267
|
+
info("wa-a", fill),
|
|
268
|
+
info("wa-b", [template({ name: "starved" })]),
|
|
269
|
+
]);
|
|
270
|
+
|
|
271
|
+
expect(section).toContain("channel: wa-a (whatsapp)");
|
|
272
|
+
expect(section).not.toContain("channel: wa-b (whatsapp)");
|
|
273
|
+
expect(section).not.toContain("starved");
|
|
274
|
+
expect(section).toContain("(1 more approved template not shown)");
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -14,9 +14,9 @@ import { needsBackfill } from "../connect-backfill.js";
|
|
|
14
14
|
import {
|
|
15
15
|
DATASTORE_ATTACHMENT_SLUG,
|
|
16
16
|
formatDatastoresSection,
|
|
17
|
-
injectDatastoreAttachment,
|
|
18
17
|
synthesizeDatastoreAttachment,
|
|
19
18
|
} from "../datastore-attachment.js";
|
|
19
|
+
import { injectSynthesizedAttachment } from "../synthesized-attachment.js";
|
|
20
20
|
import type { ResolvedMcpServer } from "../mcp-resolver.js";
|
|
21
21
|
|
|
22
22
|
function usage(slug: string) {
|
|
@@ -113,7 +113,7 @@ describe("synthesizeDatastoreAttachment", () => {
|
|
|
113
113
|
});
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
-
describe("
|
|
116
|
+
describe("injectSynthesizedAttachment (the shared injection path)", () => {
|
|
117
117
|
const attachment = synthesizeDatastoreAttachment([usage("clinic")], {
|
|
118
118
|
bridgeEndpoint: "https://mcp.stigmer.ai",
|
|
119
119
|
credential: "tok",
|
|
@@ -129,7 +129,7 @@ describe("injectDatastoreAttachment", () => {
|
|
|
129
129
|
pinnedToolApprovals: [],
|
|
130
130
|
discoveredCapabilitiesEmpty: false,
|
|
131
131
|
};
|
|
132
|
-
const result =
|
|
132
|
+
const result = injectSynthesizedAttachment([other], attachment, "datastore records");
|
|
133
133
|
expect(result.map((s) => s.slug)).toEqual(["github", DATASTORE_ATTACHMENT_SLUG]);
|
|
134
134
|
});
|
|
135
135
|
|
|
@@ -144,7 +144,7 @@ describe("injectDatastoreAttachment", () => {
|
|
|
144
144
|
discoveredCapabilitiesEmpty: false,
|
|
145
145
|
};
|
|
146
146
|
|
|
147
|
-
const result =
|
|
147
|
+
const result = injectSynthesizedAttachment([impostor], attachment, "datastore records");
|
|
148
148
|
|
|
149
149
|
expect(result).toHaveLength(1);
|
|
150
150
|
expect(result[0].url).toBe("https://mcp.stigmer.ai/records");
|