@stigmer/cli 3.1.7 → 3.1.8
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/commands/share.d.ts +3 -0
- package/commands/share.d.ts.map +1 -0
- package/commands/share.js +75 -0
- package/commands/share.js.map +1 -0
- package/config/index.d.ts +1 -1
- package/config/index.d.ts.map +1 -1
- package/config/index.js +1 -1
- package/config/index.js.map +1 -1
- package/config/resolve.d.ts +19 -2
- package/config/resolve.d.ts.map +1 -1
- package/config/resolve.js +40 -2
- package/config/resolve.js.map +1 -1
- package/local/runtime/node.d.ts +9 -2
- package/local/runtime/node.d.ts.map +1 -1
- package/local/runtime/node.js +38 -23
- package/local/runtime/node.js.map +1 -1
- package/package.json +6 -6
- package/program.d.ts.map +1 -1
- package/program.js +2 -0
- package/program.js.map +1 -1
- package/resources/connect/oauth.d.ts +0 -9
- package/resources/connect/oauth.d.ts.map +1 -1
- package/resources/connect/oauth.js +1 -17
- package/resources/connect/oauth.js.map +1 -1
- package/resources/share.d.ts +37 -0
- package/resources/share.d.ts.map +1 -0
- package/resources/share.js +144 -0
- package/resources/share.js.map +1 -0
- package/resources/stream/convert.d.ts +6 -2
- package/resources/stream/convert.d.ts.map +1 -1
- package/resources/stream/convert.js +8 -2
- package/resources/stream/convert.js.map +1 -1
- package/resources/stream/events.d.ts +14 -2
- package/resources/stream/events.d.ts.map +1 -1
- package/resources/stream/events.js.map +1 -1
- package/resources/stream/render-ndjson.js +2 -0
- package/resources/stream/render-ndjson.js.map +1 -1
- package/resources/stream/render-plaintext.d.ts.map +1 -1
- package/resources/stream/render-plaintext.js +3 -0
- package/resources/stream/render-plaintext.js.map +1 -1
- package/resources/stream/snapshot.js +6 -1
- package/resources/stream/snapshot.js.map +1 -1
- package/resources/stream/tool-state.d.ts +1 -1
- package/resources/stream/tool-state.d.ts.map +1 -1
- package/resources/stream/tool-state.js +13 -4
- package/resources/stream/tool-state.js.map +1 -1
- package/src/commands/connect.test.ts +26 -4
- package/src/commands/share.ts +99 -0
- package/src/config/index.ts +2 -0
- package/src/config/resolve.test.ts +38 -0
- package/src/config/resolve.ts +42 -3
- package/src/local/runtime/node.ts +43 -25
- package/src/local/runtime/runtime.test.ts +5 -4
- package/src/program.test.ts +7 -0
- package/src/program.ts +2 -0
- package/src/resources/connect/oauth.test.ts +1 -24
- package/src/resources/connect/oauth.ts +1 -17
- package/src/resources/read-verbs.integration.test.ts +1 -1
- package/src/resources/share.test.ts +389 -0
- package/src/resources/share.ts +227 -0
- package/src/resources/stream/convert.ts +17 -3
- package/src/resources/stream/diff.test.ts +22 -0
- package/src/resources/stream/events.ts +15 -1
- package/src/resources/stream/render-ndjson.test.ts +10 -0
- package/src/resources/stream/render-ndjson.ts +2 -0
- package/src/resources/stream/render-plaintext.test.ts +8 -0
- package/src/resources/stream/render-plaintext.ts +3 -0
- package/src/resources/stream/snapshot.test.ts +14 -0
- package/src/resources/stream/snapshot.ts +5 -1
- package/src/resources/stream/tool-state.ts +13 -4
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// Unit tests for the share-agent resource layer. The critical contract is
|
|
2
|
+
// merge-preservation: `updateSharing` replaces `spec.sharing` wholesale, so a
|
|
3
|
+
// CLI toggle must never wipe console-configured origins or visitor messages.
|
|
4
|
+
|
|
5
|
+
import { clone, create } from "@bufbuild/protobuf";
|
|
6
|
+
import { AgentSchema, type Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
7
|
+
import { AgentStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/status_pb";
|
|
8
|
+
import type { UpdateAgentSharingInput } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/io_pb";
|
|
9
|
+
import { AgentSharingAudience } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
|
|
10
|
+
import { buildEmbedSnippet, type Stigmer } from "@stigmer/sdk";
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import { classify, ExitCode, UsageError } from "../errors/index.js";
|
|
13
|
+
import { shareAgent } from "./share.js";
|
|
14
|
+
|
|
15
|
+
const CLOUD = { appOrigin: "https://app.stigmer.ai", isLocal: false };
|
|
16
|
+
const LOCAL = { appOrigin: "http://localhost:8234", isLocal: true };
|
|
17
|
+
|
|
18
|
+
function makeAgent(
|
|
19
|
+
sharing?: {
|
|
20
|
+
enabled?: boolean;
|
|
21
|
+
audience?: AgentSharingAudience;
|
|
22
|
+
allowedOrigins?: string[];
|
|
23
|
+
messages?: { rateLimited?: string; unavailable?: string; conversationEnded?: string };
|
|
24
|
+
},
|
|
25
|
+
shareLinkToken?: string,
|
|
26
|
+
): Agent {
|
|
27
|
+
return create(AgentSchema, {
|
|
28
|
+
metadata: { id: "agt_01ARZ3NDEKTSV4RRFFQ69G5FAV", org: "acme", slug: "support-agent", name: "Support Agent" },
|
|
29
|
+
spec: sharing !== undefined ? { sharing } : {},
|
|
30
|
+
...(shareLinkToken !== undefined ? { status: { shareLinkToken } } : {}),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A minimal fake of the SDK surface shareAgent touches: agent.getByReference /
|
|
35
|
+
// agent.get + call-recording agent.updateSharing / agent.rotateShareLink
|
|
36
|
+
// (skill.test.ts convention). Rotation answers with the agent re-stamped
|
|
37
|
+
// with `rotatedToken`, mirroring the server's fresh-entropy response.
|
|
38
|
+
function fakeClient(
|
|
39
|
+
agent: Agent,
|
|
40
|
+
opts: { failWith?: Error; rotatedToken?: string } = {},
|
|
41
|
+
) {
|
|
42
|
+
const calls: UpdateAgentSharingInput[] = [];
|
|
43
|
+
let rotations = 0;
|
|
44
|
+
const client = {
|
|
45
|
+
agent: {
|
|
46
|
+
async get() {
|
|
47
|
+
return agent;
|
|
48
|
+
},
|
|
49
|
+
async getByReference() {
|
|
50
|
+
return agent;
|
|
51
|
+
},
|
|
52
|
+
async updateSharing(input: UpdateAgentSharingInput) {
|
|
53
|
+
if (opts.failWith) throw opts.failWith;
|
|
54
|
+
calls.push(input);
|
|
55
|
+
return agent;
|
|
56
|
+
},
|
|
57
|
+
async rotateShareLink() {
|
|
58
|
+
rotations += 1;
|
|
59
|
+
const rotated = clone(AgentSchema, agent);
|
|
60
|
+
rotated.status = create(AgentStatusSchema, {
|
|
61
|
+
shareLinkToken: opts.rotatedToken ?? "fresh-token",
|
|
62
|
+
});
|
|
63
|
+
return rotated;
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
} as unknown as Stigmer;
|
|
67
|
+
return { client, calls, rotationCount: () => rotations };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("shareAgent merge-preservation (fails closed)", () => {
|
|
71
|
+
it("enabling preserves console-configured origins and messages verbatim", async () => {
|
|
72
|
+
const agent = makeAgent({
|
|
73
|
+
enabled: false,
|
|
74
|
+
allowedOrigins: ["https://example.com", "https://docs.example.com"],
|
|
75
|
+
messages: { rateLimited: "Slow down!", unavailable: "Back soon.", conversationEnded: "Bye!" },
|
|
76
|
+
});
|
|
77
|
+
const { client, calls } = fakeClient(agent);
|
|
78
|
+
|
|
79
|
+
await shareAgent(client, "acme/support-agent", "acme", { enabled: true, ...CLOUD });
|
|
80
|
+
|
|
81
|
+
expect(calls).toHaveLength(1);
|
|
82
|
+
expect(calls[0].resourceId).toBe("agt_01ARZ3NDEKTSV4RRFFQ69G5FAV");
|
|
83
|
+
expect(calls[0].sharing?.enabled).toBe(true);
|
|
84
|
+
expect(calls[0].sharing?.allowedOrigins).toEqual(["https://example.com", "https://docs.example.com"]);
|
|
85
|
+
expect(calls[0].sharing?.messages?.rateLimited).toBe("Slow down!");
|
|
86
|
+
expect(calls[0].sharing?.messages?.unavailable).toBe("Back soon.");
|
|
87
|
+
expect(calls[0].sharing?.messages?.conversationEnded).toBe("Bye!");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("disabling preserves origins and messages, flipping only enabled", async () => {
|
|
91
|
+
const agent = makeAgent({
|
|
92
|
+
enabled: true,
|
|
93
|
+
allowedOrigins: ["https://example.com"],
|
|
94
|
+
messages: { rateLimited: "Slow down!" },
|
|
95
|
+
});
|
|
96
|
+
const { client, calls } = fakeClient(agent);
|
|
97
|
+
|
|
98
|
+
const result = await shareAgent(client, "acme/support-agent", "acme", { enabled: false, ...CLOUD });
|
|
99
|
+
|
|
100
|
+
expect(calls).toHaveLength(1);
|
|
101
|
+
expect(calls[0].sharing?.enabled).toBe(false);
|
|
102
|
+
expect(calls[0].sharing?.allowedOrigins).toEqual(["https://example.com"]);
|
|
103
|
+
expect(calls[0].sharing?.messages?.rateLimited).toBe("Slow down!");
|
|
104
|
+
expect(result.message).toContain("Sharing disabled");
|
|
105
|
+
// No link/snippet on disable — the URL no longer works.
|
|
106
|
+
expect(result.sections).toHaveLength(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("enables cleanly for an agent that has never been shared (no spec.sharing)", async () => {
|
|
110
|
+
const { client, calls } = fakeClient(makeAgent(undefined));
|
|
111
|
+
|
|
112
|
+
await shareAgent(client, "acme/support-agent", "acme", { enabled: true, ...CLOUD });
|
|
113
|
+
|
|
114
|
+
expect(calls).toHaveLength(1);
|
|
115
|
+
expect(calls[0].sharing?.enabled).toBe(true);
|
|
116
|
+
expect(calls[0].sharing?.allowedOrigins).toEqual([]);
|
|
117
|
+
expect(calls[0].sharing?.messages?.rateLimited).toBe("");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("a plain toggle preserves an org-members-only audience (never reverts to public)", async () => {
|
|
121
|
+
const agent = makeAgent({
|
|
122
|
+
enabled: false,
|
|
123
|
+
audience: AgentSharingAudience.org,
|
|
124
|
+
allowedOrigins: [],
|
|
125
|
+
});
|
|
126
|
+
const { client, calls } = fakeClient(agent);
|
|
127
|
+
|
|
128
|
+
await shareAgent(client, "acme/support-agent", "acme", { enabled: true, ...CLOUD });
|
|
129
|
+
|
|
130
|
+
expect(calls).toHaveLength(1);
|
|
131
|
+
// A re-enable without --audience must keep the org restriction: writing
|
|
132
|
+
// public here would silently expose an internal agent to the internet.
|
|
133
|
+
expect(calls[0].sharing?.audience).toBe(AgentSharingAudience.org);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("shareAgent audience", () => {
|
|
138
|
+
it("--audience org sets the audience and writes the full block", async () => {
|
|
139
|
+
const agent = makeAgent({
|
|
140
|
+
enabled: true,
|
|
141
|
+
allowedOrigins: ["https://example.com"],
|
|
142
|
+
messages: { rateLimited: "Slow down!" },
|
|
143
|
+
});
|
|
144
|
+
const { client, calls } = fakeClient(agent);
|
|
145
|
+
|
|
146
|
+
const result = await shareAgent(client, "acme/support-agent", "acme", {
|
|
147
|
+
enabled: true,
|
|
148
|
+
audience: "org",
|
|
149
|
+
...CLOUD,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(calls).toHaveLength(1);
|
|
153
|
+
expect(calls[0].sharing?.audience).toBe(AgentSharingAudience.org);
|
|
154
|
+
expect(calls[0].sharing?.enabled).toBe(true);
|
|
155
|
+
expect(calls[0].sharing?.allowedOrigins).toEqual(["https://example.com"]);
|
|
156
|
+
expect(calls[0].sharing?.messages?.rateLimited).toBe("Slow down!");
|
|
157
|
+
|
|
158
|
+
// Org shares print the member link and NO embed snippet (embeds serve
|
|
159
|
+
// anonymous guests, which org shares refuse).
|
|
160
|
+
const link = result.sections.find((s) => s.title === "Member chat link");
|
|
161
|
+
expect(link?.items).toEqual(["https://app.stigmer.ai/chat/acme/support-agent"]);
|
|
162
|
+
expect(result.sections.find((s) => s.title === "Embed on your site")).toBeUndefined();
|
|
163
|
+
expect(result.hints.some((h) => h.includes("signed-in members"))).toBe(true);
|
|
164
|
+
expect(result.hints.some((h) => h.includes("search engines"))).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("--audience public converts an org share back to an explicit public one", async () => {
|
|
168
|
+
const agent = makeAgent({ enabled: true, audience: AgentSharingAudience.org });
|
|
169
|
+
const { client, calls } = fakeClient(agent);
|
|
170
|
+
|
|
171
|
+
await shareAgent(client, "acme/support-agent", "acme", {
|
|
172
|
+
enabled: true,
|
|
173
|
+
audience: "public",
|
|
174
|
+
...CLOUD,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(calls).toHaveLength(1);
|
|
178
|
+
// Written as the explicit enum value, never left unspecified.
|
|
179
|
+
expect(calls[0].sharing?.audience).toBe(AgentSharingAudience.public);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("an audience change alone triggers a write even when enabled matches", async () => {
|
|
183
|
+
const agent = makeAgent({ enabled: true });
|
|
184
|
+
const { client, calls } = fakeClient(agent);
|
|
185
|
+
|
|
186
|
+
const result = await shareAgent(client, "acme/support-agent", "acme", {
|
|
187
|
+
enabled: true,
|
|
188
|
+
audience: "org",
|
|
189
|
+
...CLOUD,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(calls).toHaveLength(1);
|
|
193
|
+
expect(result.message).toContain("Sharing enabled");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("matching enabled AND audience stays idempotent (no write)", async () => {
|
|
197
|
+
const agent = makeAgent({ enabled: true, audience: AgentSharingAudience.org });
|
|
198
|
+
const { client, calls } = fakeClient(agent);
|
|
199
|
+
|
|
200
|
+
const result = await shareAgent(client, "acme/support-agent", "acme", {
|
|
201
|
+
enabled: true,
|
|
202
|
+
audience: "org",
|
|
203
|
+
...CLOUD,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(calls).toHaveLength(0);
|
|
207
|
+
expect(result.message).toContain("already on");
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("shareAgent idempotency", () => {
|
|
212
|
+
it("skips the write when sharing is already enabled, still reporting the link", async () => {
|
|
213
|
+
const { client, calls } = fakeClient(makeAgent({ enabled: true }));
|
|
214
|
+
|
|
215
|
+
const result = await shareAgent(client, "acme/support-agent", "acme", { enabled: true, ...CLOUD });
|
|
216
|
+
|
|
217
|
+
expect(calls).toHaveLength(0);
|
|
218
|
+
expect(result.message).toContain("already on");
|
|
219
|
+
const link = result.sections.find((s) => s.title === "Public chat link");
|
|
220
|
+
expect(link?.items).toEqual(["https://app.stigmer.ai/chat/acme/support-agent"]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("skips the write when sharing is already disabled", async () => {
|
|
224
|
+
const { client, calls } = fakeClient(makeAgent({ enabled: false }));
|
|
225
|
+
|
|
226
|
+
const result = await shareAgent(client, "acme/support-agent", "acme", { enabled: false, ...CLOUD });
|
|
227
|
+
|
|
228
|
+
expect(calls).toHaveLength(0);
|
|
229
|
+
expect(result.message).toContain("already off");
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("shareAgent output", () => {
|
|
234
|
+
it("builds the link and snippet from the RESOLVED agent's org/slug (ID refs included)", async () => {
|
|
235
|
+
const { client } = fakeClient(makeAgent({ enabled: false }));
|
|
236
|
+
|
|
237
|
+
const result = await shareAgent(client, "agt_01ARZ3NDEKTSV4RRFFQ69G5FAV", "", { enabled: true, ...CLOUD });
|
|
238
|
+
|
|
239
|
+
const link = result.sections.find((s) => s.title === "Public chat link");
|
|
240
|
+
expect(link?.items).toEqual(["https://app.stigmer.ai/chat/acme/support-agent"]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("emits the exact embed snippet the web share dialog emits", async () => {
|
|
244
|
+
const { client } = fakeClient(makeAgent({ enabled: false }));
|
|
245
|
+
|
|
246
|
+
const result = await shareAgent(client, "acme/support-agent", "acme", { enabled: true, ...CLOUD });
|
|
247
|
+
|
|
248
|
+
const embed = result.sections.find((s) => s.title === "Embed on your site");
|
|
249
|
+
expect(embed?.items.join("\n")).toBe(buildEmbedSnippet("https://app.stigmer.ai", "acme", "support-agent"));
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("includes the who-pays and indexability hints", async () => {
|
|
253
|
+
const { client } = fakeClient(makeAgent({ enabled: false }));
|
|
254
|
+
|
|
255
|
+
const result = await shareAgent(client, "acme/support-agent", "acme", { enabled: true, ...CLOUD });
|
|
256
|
+
|
|
257
|
+
expect(result.hints.some((h) => h.includes("acme's credits"))).toBe(true);
|
|
258
|
+
expect(result.hints.some((h) => h.includes("search engines"))).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("warns that a local link won't serve visitors (guest chat is Cloud-only)", async () => {
|
|
262
|
+
const { client } = fakeClient(makeAgent({ enabled: false }));
|
|
263
|
+
|
|
264
|
+
const result = await shareAgent(client, "acme/support-agent", "acme", { enabled: true, ...LOCAL });
|
|
265
|
+
|
|
266
|
+
const link = result.sections.find((s) => s.title === "Public chat link");
|
|
267
|
+
expect(link?.items).toEqual(["http://localhost:8234/chat/acme/support-agent"]);
|
|
268
|
+
expect(result.hints.some((h) => h.includes("Stigmer Cloud"))).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("omits the local warning on a cloud backend", async () => {
|
|
272
|
+
const { client } = fakeClient(makeAgent({ enabled: false }));
|
|
273
|
+
|
|
274
|
+
const result = await shareAgent(client, "acme/support-agent", "acme", { enabled: true, ...CLOUD });
|
|
275
|
+
|
|
276
|
+
expect(result.hints.some((h) => h.includes("Stigmer Cloud"))).toBe(false);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("shareAgent --reset-link (rotatable share token)", () => {
|
|
281
|
+
it("rotates and prints the fresh tokened link and snippet", async () => {
|
|
282
|
+
const { client, calls, rotationCount } = fakeClient(
|
|
283
|
+
makeAgent({ enabled: true }),
|
|
284
|
+
{ rotatedToken: "fresh-token" },
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const result = await shareAgent(client, "acme/support-agent", "acme", {
|
|
288
|
+
enabled: true,
|
|
289
|
+
resetLink: true,
|
|
290
|
+
...CLOUD,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Already enabled: no sharing write, exactly one rotation.
|
|
294
|
+
expect(calls).toHaveLength(0);
|
|
295
|
+
expect(rotationCount()).toBe(1);
|
|
296
|
+
expect(result.message).toContain("Share link reset");
|
|
297
|
+
|
|
298
|
+
const link = result.sections.find((s) => s.title === "Public chat link");
|
|
299
|
+
expect(link?.items).toEqual([
|
|
300
|
+
"https://app.stigmer.ai/chat/acme/support-agent?k=fresh-token",
|
|
301
|
+
]);
|
|
302
|
+
const embed = result.sections.find((s) => s.title === "Embed on your site");
|
|
303
|
+
expect(embed?.items.join("\n")).toBe(
|
|
304
|
+
buildEmbedSnippet("https://app.stigmer.ai", "acme", "support-agent", "fresh-token"),
|
|
305
|
+
);
|
|
306
|
+
expect(result.hints.some((h) => h.includes("Re-share the new link"))).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("a plain toggle on a locked link keeps printing the existing tokened URL", async () => {
|
|
310
|
+
const { client, rotationCount } = fakeClient(
|
|
311
|
+
makeAgent({ enabled: true }, "existing-token"),
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
const result = await shareAgent(client, "acme/support-agent", "acme", {
|
|
315
|
+
enabled: true,
|
|
316
|
+
...CLOUD,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// No rotation was requested: the current token must ride the link
|
|
320
|
+
// verbatim, or the printed URL would be dead.
|
|
321
|
+
expect(rotationCount()).toBe(0);
|
|
322
|
+
const link = result.sections.find((s) => s.title === "Public chat link");
|
|
323
|
+
expect(link?.items).toEqual([
|
|
324
|
+
"https://app.stigmer.ai/chat/acme/support-agent?k=existing-token",
|
|
325
|
+
]);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("refuses --reset-link for an org-members-only share with actionable guidance", async () => {
|
|
329
|
+
const { client, rotationCount } = fakeClient(
|
|
330
|
+
makeAgent({ enabled: true, audience: AgentSharingAudience.org }),
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
const err = await shareAgent(client, "acme/support-agent", "acme", {
|
|
334
|
+
enabled: true,
|
|
335
|
+
resetLink: true,
|
|
336
|
+
...CLOUD,
|
|
337
|
+
}).catch((e: unknown) => e);
|
|
338
|
+
|
|
339
|
+
// Org access is gated by live membership — the token is never consulted,
|
|
340
|
+
// so rotating it would be a silent no-op the user did not get.
|
|
341
|
+
expect(err).toBeInstanceOf(UsageError);
|
|
342
|
+
expect((err as Error).message).toContain("public links");
|
|
343
|
+
expect(rotationCount()).toBe(0);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("the member link never carries a token, even when one exists in status", async () => {
|
|
347
|
+
const { client } = fakeClient(
|
|
348
|
+
makeAgent({ enabled: true, audience: AgentSharingAudience.org }, "existing-token"),
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const result = await shareAgent(client, "acme/support-agent", "acme", {
|
|
352
|
+
enabled: true,
|
|
353
|
+
...CLOUD,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const link = result.sections.find((s) => s.title === "Member chat link");
|
|
357
|
+
expect(link?.items).toEqual(["https://app.stigmer.ai/chat/acme/support-agent"]);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe("shareAgent errors", () => {
|
|
362
|
+
it("fails fast with org guidance for a bare slug and no org", async () => {
|
|
363
|
+
const { client, calls } = fakeClient(makeAgent());
|
|
364
|
+
|
|
365
|
+
const err = await shareAgent(client, "support-agent", "", { enabled: true, ...CLOUD }).catch((e: unknown) => e);
|
|
366
|
+
|
|
367
|
+
expect(err).toBeInstanceOf(UsageError);
|
|
368
|
+
expect((err as Error).message).toContain("organization not set");
|
|
369
|
+
expect(classify(err)?.exitCode).toBe(ExitCode.Usage);
|
|
370
|
+
expect(calls).toHaveLength(0);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("does not apply the org guard to an ID reference", async () => {
|
|
374
|
+
const { client, calls } = fakeClient(makeAgent({ enabled: false }));
|
|
375
|
+
|
|
376
|
+
await shareAgent(client, "agt_01ARZ3NDEKTSV4RRFFQ69G5FAV", "", { enabled: true, ...CLOUD });
|
|
377
|
+
|
|
378
|
+
expect(calls).toHaveLength(1);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("propagates server rejections (permission denied) unchanged", async () => {
|
|
382
|
+
const denied = new Error("permission denied: can_edit required");
|
|
383
|
+
const { client } = fakeClient(makeAgent({ enabled: false }), { failWith: denied });
|
|
384
|
+
|
|
385
|
+
await expect(shareAgent(client, "acme/support-agent", "acme", { enabled: true, ...CLOUD })).rejects.toThrow(
|
|
386
|
+
/permission denied/,
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// `share agent` dispatch: enable or disable public sharing for an agent and
|
|
2
|
+
// report the hosted chat link + embed snippet.
|
|
3
|
+
//
|
|
4
|
+
// The one correctness invariant here: the `updateSharing` RPC replaces
|
|
5
|
+
// `spec.sharing` WHOLESALE. A naive "set enabled" would silently wipe any
|
|
6
|
+
// `allowed_origins` or visitor messages the owner configured in the console.
|
|
7
|
+
// So this module always reads the current state first and sends the complete
|
|
8
|
+
// block back with only `enabled` flipped — the same merge-preserve discipline
|
|
9
|
+
// as the web dialog's draftFromAgent (sdk/react ShareAgentDialog.tsx).
|
|
10
|
+
//
|
|
11
|
+
// URL and snippet shapes come from @stigmer/sdk's sharing helpers — the single
|
|
12
|
+
// source of truth shared with the web console — so every surface emits
|
|
13
|
+
// byte-identical output. The caller supplies the app origin (resolved from the
|
|
14
|
+
// backend type by the command layer) to keep this module pure and env-free.
|
|
15
|
+
|
|
16
|
+
import { create } from "@bufbuild/protobuf";
|
|
17
|
+
import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
18
|
+
import {
|
|
19
|
+
RotateShareLinkInputSchema,
|
|
20
|
+
UpdateAgentSharingInputSchema,
|
|
21
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agent/v1/io_pb";
|
|
22
|
+
import {
|
|
23
|
+
AgentSharingAudience,
|
|
24
|
+
AgentSharingMessagesSchema,
|
|
25
|
+
AgentSharingSchema,
|
|
26
|
+
type AgentSharing,
|
|
27
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
|
|
28
|
+
import { buildChatUrl, buildEmbedSnippet, type Stigmer } from "@stigmer/sdk";
|
|
29
|
+
import { UsageError } from "../errors/index.js";
|
|
30
|
+
import { CommandResult } from "../output/index.js";
|
|
31
|
+
import { isAgentId } from "./reference.js";
|
|
32
|
+
import { resolveAgentRef } from "./run/resolve.js";
|
|
33
|
+
|
|
34
|
+
/** Who can chat over the shared link. Mirrors the SDK's SharingAudience. */
|
|
35
|
+
export type ShareAudience = "public" | "org";
|
|
36
|
+
|
|
37
|
+
/** Inputs for {@link shareAgent} beyond the reference itself. */
|
|
38
|
+
export interface ShareAgentOptions {
|
|
39
|
+
/** Desired sharing state: `true` to enable, `false` to disable. */
|
|
40
|
+
readonly enabled: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Desired audience. Omitted means "keep the agent's current audience" —
|
|
43
|
+
* a plain toggle must never flip an org-members-only share back to
|
|
44
|
+
* public (or vice versa).
|
|
45
|
+
*/
|
|
46
|
+
readonly audience?: ShareAudience;
|
|
47
|
+
/**
|
|
48
|
+
* Rotate the share-link token: the server generates a fresh `?k=`
|
|
49
|
+
* secret and the current link (tokened or plain) stops working
|
|
50
|
+
* immediately. The token is server-owned status — never part of the
|
|
51
|
+
* sharing block this module merges.
|
|
52
|
+
*/
|
|
53
|
+
readonly resetLink?: boolean;
|
|
54
|
+
/** The app origin serving the hosted chat page and `embed.js`. */
|
|
55
|
+
readonly appOrigin: string;
|
|
56
|
+
/**
|
|
57
|
+
* Whether the CLI points at a local backend. Guest chat (the token mint
|
|
58
|
+
* behind the hosted page) is a Stigmer Cloud capability, so a local link
|
|
59
|
+
* gets a "won't serve visitors" warning alongside the result.
|
|
60
|
+
*/
|
|
61
|
+
readonly isLocal: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Enable or disable sharing for the referenced agent and describe the
|
|
66
|
+
* outcome. Idempotent: when the agent is already in the desired state, no
|
|
67
|
+
* write is issued (the link is still reported when sharing is on).
|
|
68
|
+
*/
|
|
69
|
+
export async function shareAgent(
|
|
70
|
+
client: Stigmer,
|
|
71
|
+
ref: string,
|
|
72
|
+
org: string,
|
|
73
|
+
options: ShareAgentOptions,
|
|
74
|
+
): Promise<CommandResult> {
|
|
75
|
+
// Fail fast with actionable guidance instead of the backend's cryptic
|
|
76
|
+
// "org value length must be at least 1" — mirrors the connect org guard.
|
|
77
|
+
if (org === "" && !isAgentId(ref) && !ref.includes("/")) {
|
|
78
|
+
throw new UsageError(
|
|
79
|
+
"organization not set\n\n" +
|
|
80
|
+
"Set it with:\n" +
|
|
81
|
+
" stigmer config context set --org <org>\n" +
|
|
82
|
+
` stigmer share agent ${ref} --org <org>`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const agent = await resolveAgentRef(client, ref, org);
|
|
87
|
+
const current = agent.spec?.sharing;
|
|
88
|
+
|
|
89
|
+
const currentAudience = audienceFromProto(current?.audience);
|
|
90
|
+
const targetAudience = options.audience ?? currentAudience;
|
|
91
|
+
|
|
92
|
+
// Resetting the link is meaningless for org-members-only shares: their
|
|
93
|
+
// access is gated by live membership, and the member link never carries
|
|
94
|
+
// the token. Refuse rather than silently rotating an unused secret —
|
|
95
|
+
// the share dialog hides its Reset control for org audience for the
|
|
96
|
+
// same reason.
|
|
97
|
+
if (options.resetLink === true && targetAudience === "org") {
|
|
98
|
+
throw new UsageError(
|
|
99
|
+
"--reset-link only applies to public links\n\n" +
|
|
100
|
+
"This share is restricted to organization members, whose access is\n" +
|
|
101
|
+
"checked on every message. To cut someone off, remove them from the\n" +
|
|
102
|
+
"organization.",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const alreadyInState =
|
|
107
|
+
(current?.enabled ?? false) === options.enabled &&
|
|
108
|
+
currentAudience === targetAudience;
|
|
109
|
+
|
|
110
|
+
if (!alreadyInState) {
|
|
111
|
+
// Send the COMPLETE sharing block with only the requested fields changed,
|
|
112
|
+
// so a CLI toggle can never wipe console-configured origins, visitor
|
|
113
|
+
// messages, or an org-members-only audience.
|
|
114
|
+
await client.agent.updateSharing(
|
|
115
|
+
create(UpdateAgentSharingInputSchema, {
|
|
116
|
+
resourceId: agent.metadata?.id ?? "",
|
|
117
|
+
sharing: preservingSharing(current, options.enabled, targetAudience),
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The rotation is a separate targeted RPC (the token is server-owned
|
|
123
|
+
// status, not part of the sharing block). The returned agent carries the
|
|
124
|
+
// fresh token, so the link printed below is the new one.
|
|
125
|
+
let linkToken = agent.status?.shareLinkToken ?? "";
|
|
126
|
+
if (options.resetLink === true) {
|
|
127
|
+
const rotated = await client.agent.rotateShareLink(
|
|
128
|
+
create(RotateShareLinkInputSchema, {
|
|
129
|
+
resourceId: agent.metadata?.id ?? "",
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
linkToken = rotated.status?.shareLinkToken ?? "";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return describeOutcome(agent, options, targetAudience, linkToken, alreadyInState && options.resetLink !== true);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Unspecified means public by contract (pre-audience shares keep their
|
|
139
|
+
// anyone-with-link behavior).
|
|
140
|
+
function audienceFromProto(audience: AgentSharingAudience | undefined): ShareAudience {
|
|
141
|
+
return audience === AgentSharingAudience.org ? "org" : "public";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The full sharing config with the desired `enabled` and audience,
|
|
145
|
+
// preserving origins and messages (empty defaults when the agent was never
|
|
146
|
+
// shared before). The audience is written explicitly — never left
|
|
147
|
+
// unspecified — so a console-managed org share can't drift back to public.
|
|
148
|
+
function preservingSharing(
|
|
149
|
+
current: AgentSharing | undefined,
|
|
150
|
+
enabled: boolean,
|
|
151
|
+
audience: ShareAudience,
|
|
152
|
+
): AgentSharing {
|
|
153
|
+
return create(AgentSharingSchema, {
|
|
154
|
+
enabled,
|
|
155
|
+
audience:
|
|
156
|
+
audience === "org"
|
|
157
|
+
? AgentSharingAudience.org
|
|
158
|
+
: AgentSharingAudience.public,
|
|
159
|
+
allowedOrigins: [...(current?.allowedOrigins ?? [])],
|
|
160
|
+
messages: create(AgentSharingMessagesSchema, {
|
|
161
|
+
rateLimited: current?.messages?.rateLimited ?? "",
|
|
162
|
+
unavailable: current?.messages?.unavailable ?? "",
|
|
163
|
+
conversationEnded: current?.messages?.conversationEnded ?? "",
|
|
164
|
+
}),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Build the user-facing result. Org/slug come from the RESOLVED agent's
|
|
169
|
+
// metadata (authoritative even when the user passed an ID), matching how the
|
|
170
|
+
// web share dialog derives them. The link token comes from the agent's
|
|
171
|
+
// status (post-rotation when --reset-link ran) and rides the printed URL
|
|
172
|
+
// and snippet — public audience only (org access is gated by membership).
|
|
173
|
+
function describeOutcome(
|
|
174
|
+
agent: Agent,
|
|
175
|
+
options: ShareAgentOptions,
|
|
176
|
+
audience: ShareAudience,
|
|
177
|
+
linkToken: string,
|
|
178
|
+
alreadyInState: boolean,
|
|
179
|
+
): CommandResult {
|
|
180
|
+
const org = agent.metadata?.org ?? "";
|
|
181
|
+
const slug = agent.metadata?.slug ?? "";
|
|
182
|
+
const name = agent.metadata?.name || slug;
|
|
183
|
+
|
|
184
|
+
if (!options.enabled) {
|
|
185
|
+
const result = CommandResult.success(
|
|
186
|
+
alreadyInState ? `Sharing is already off for '${name}'` : `Sharing disabled for '${name}'`,
|
|
187
|
+
);
|
|
188
|
+
result.hint("The share link no longer works. Re-enable with:");
|
|
189
|
+
result.hint(` stigmer share agent ${org}/${slug}`);
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const result = CommandResult.success(
|
|
194
|
+
options.resetLink === true
|
|
195
|
+
? `Share link reset for '${name}' — the old link no longer works`
|
|
196
|
+
: alreadyInState
|
|
197
|
+
? `Sharing is already on for '${name}'`
|
|
198
|
+
: `Sharing enabled for '${name}'`,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const publicLinkToken = audience === "org" ? undefined : linkToken || undefined;
|
|
202
|
+
result
|
|
203
|
+
.addSection(audience === "org" ? "Member chat link" : "Public chat link")
|
|
204
|
+
.item(buildChatUrl(options.appOrigin, org, slug, publicLinkToken));
|
|
205
|
+
|
|
206
|
+
if (audience === "org") {
|
|
207
|
+
// Embeds serve anonymous guests, which org-members-only shares refuse
|
|
208
|
+
// by design — no snippet to print.
|
|
209
|
+
result.hint(`Only signed-in members of ${org} can chat. Access ends when they leave the organization.`);
|
|
210
|
+
result.hint(`Members chat on ${org}'s credits.`);
|
|
211
|
+
} else {
|
|
212
|
+
const snippetSection = result.addSection("Embed on your site");
|
|
213
|
+
for (const line of buildEmbedSnippet(options.appOrigin, org, slug, publicLinkToken).split("\n")) {
|
|
214
|
+
snippetSection.item(line);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
result.hint(`Visitors chat on ${org}'s credits.`);
|
|
218
|
+
result.hint("Public links can be forwarded and indexed by search engines.");
|
|
219
|
+
if (options.resetLink === true) {
|
|
220
|
+
result.hint("Re-share the new link with the people who should keep access.");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (options.isLocal) {
|
|
224
|
+
result.hint("Guest chat is a Stigmer Cloud capability — this local link won't serve visitors.");
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
@@ -18,7 +18,15 @@ import {
|
|
|
18
18
|
import type { ToolCallInfo, TodoItemView } from "./events.js";
|
|
19
19
|
|
|
20
20
|
/** Mapped tool-call status strings. */
|
|
21
|
-
export type ToolStatus =
|
|
21
|
+
export type ToolStatus =
|
|
22
|
+
| "pending"
|
|
23
|
+
| "running"
|
|
24
|
+
| "completed"
|
|
25
|
+
| "failed"
|
|
26
|
+
| "waiting_approval"
|
|
27
|
+
| "skipped"
|
|
28
|
+
| "interrupted"
|
|
29
|
+
| "unknown";
|
|
22
30
|
|
|
23
31
|
/** Convert a proto ToolCall to render info. Mirrors Go's convertToolCall. */
|
|
24
32
|
export function convertToolCall(tc: ToolCall): ToolCallInfo {
|
|
@@ -110,6 +118,8 @@ export function mapToolCallStatus(status: ToolCallStatus): ToolStatus {
|
|
|
110
118
|
return "waiting_approval";
|
|
111
119
|
case ToolCallStatus.TOOL_CALL_SKIPPED:
|
|
112
120
|
return "skipped";
|
|
121
|
+
case ToolCallStatus.TOOL_CALL_INTERRUPTED:
|
|
122
|
+
return "interrupted";
|
|
113
123
|
default:
|
|
114
124
|
return "unknown";
|
|
115
125
|
}
|
|
@@ -148,9 +158,13 @@ export function subAgentStatusName(status: SubAgentStatus): string {
|
|
|
148
158
|
return SubAgentStatus[status] ?? "SUB_AGENT_STATUS_UNSPECIFIED";
|
|
149
159
|
}
|
|
150
160
|
|
|
151
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* True for tool statuses that mean the tool is no longer executing.
|
|
163
|
+
* "interrupted" (platform-settled at terminalization, issue #207) is terminal:
|
|
164
|
+
* without it, a settled call replays as a live running block forever.
|
|
165
|
+
*/
|
|
152
166
|
export function isTerminalToolStatus(status: string): boolean {
|
|
153
|
-
return status === "completed" || status === "failed" || status === "skipped";
|
|
167
|
+
return status === "completed" || status === "failed" || status === "skipped" || status === "interrupted";
|
|
154
168
|
}
|
|
155
169
|
|
|
156
170
|
/** True for terminal agent phases. Mirrors Go's isTerminalAgentPhase. */
|
|
@@ -190,6 +190,28 @@ describe("tool call interleaving", () => {
|
|
|
190
190
|
expect(kindsFor(seq)).toContain("toolCompleted");
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
+
it("running → interrupted closes the block with toolInterrupted, not toolCompleted", () => {
|
|
194
|
+
// Issue #207: the platform settles an in-flight call to INTERRUPTED when
|
|
195
|
+
// the execution terminalizes; the live differ must close its running block
|
|
196
|
+
// with the honest event (a checkmark would claim the tool finished).
|
|
197
|
+
const seq = [
|
|
198
|
+
snapshot({ messages: [aiMsg("calling", { toolCalls: [tool("t1", "shell", ToolCallStatus.TOOL_CALL_RUNNING)] })] }),
|
|
199
|
+
snapshot({ messages: [aiMsg("calling", { toolCalls: [tool("t1", "shell", ToolCallStatus.TOOL_CALL_INTERRUPTED)] })] }),
|
|
200
|
+
];
|
|
201
|
+
const kinds = kindsFor(seq);
|
|
202
|
+
expect(kinds).toContain("toolInterrupted");
|
|
203
|
+
expect(kinds).not.toContain("toolCompleted");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("first-sight interrupted tool (re-attach) synthesizes toolInterrupted", () => {
|
|
207
|
+
const seq = [
|
|
208
|
+
snapshot({
|
|
209
|
+
messages: [toolMsg("", [tool("t1", "read", ToolCallStatus.TOOL_CALL_INTERRUPTED)])],
|
|
210
|
+
}),
|
|
211
|
+
];
|
|
212
|
+
expect(kindsFor(seq)).toContain("toolInterrupted");
|
|
213
|
+
});
|
|
214
|
+
|
|
193
215
|
it("emits a streaming delta when a running tool's result grows", () => {
|
|
194
216
|
const seq = [
|
|
195
217
|
snapshot({
|