@stigmer/cli 3.1.6 → 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.
Files changed (93) hide show
  1. package/commands/share.d.ts +3 -0
  2. package/commands/share.d.ts.map +1 -0
  3. package/commands/share.js +75 -0
  4. package/commands/share.js.map +1 -0
  5. package/config/index.d.ts +1 -1
  6. package/config/index.d.ts.map +1 -1
  7. package/config/index.js +1 -1
  8. package/config/index.js.map +1 -1
  9. package/config/resolve.d.ts +19 -2
  10. package/config/resolve.d.ts.map +1 -1
  11. package/config/resolve.js +40 -2
  12. package/config/resolve.js.map +1 -1
  13. package/local/runtime/node.d.ts +9 -2
  14. package/local/runtime/node.d.ts.map +1 -1
  15. package/local/runtime/node.js +38 -23
  16. package/local/runtime/node.js.map +1 -1
  17. package/package.json +6 -6
  18. package/program.d.ts.map +1 -1
  19. package/program.js +2 -0
  20. package/program.js.map +1 -1
  21. package/resources/connect/connect.d.ts.map +1 -1
  22. package/resources/connect/connect.js +76 -8
  23. package/resources/connect/connect.js.map +1 -1
  24. package/resources/connect/discover.d.ts.map +1 -1
  25. package/resources/connect/discover.js +12 -4
  26. package/resources/connect/discover.js.map +1 -1
  27. package/resources/connect/oauth.d.ts +0 -9
  28. package/resources/connect/oauth.d.ts.map +1 -1
  29. package/resources/connect/oauth.js +1 -17
  30. package/resources/connect/oauth.js.map +1 -1
  31. package/resources/mcp/placeholder-resolver.d.ts +19 -0
  32. package/resources/mcp/placeholder-resolver.d.ts.map +1 -0
  33. package/resources/mcp/placeholder-resolver.js +52 -0
  34. package/resources/mcp/placeholder-resolver.js.map +1 -0
  35. package/resources/mcp/runtime-env.d.ts +16 -0
  36. package/resources/mcp/runtime-env.d.ts.map +1 -1
  37. package/resources/mcp/runtime-env.js +29 -8
  38. package/resources/mcp/runtime-env.js.map +1 -1
  39. package/resources/share.d.ts +37 -0
  40. package/resources/share.d.ts.map +1 -0
  41. package/resources/share.js +144 -0
  42. package/resources/share.js.map +1 -0
  43. package/resources/stream/convert.d.ts +6 -2
  44. package/resources/stream/convert.d.ts.map +1 -1
  45. package/resources/stream/convert.js +8 -2
  46. package/resources/stream/convert.js.map +1 -1
  47. package/resources/stream/events.d.ts +14 -2
  48. package/resources/stream/events.d.ts.map +1 -1
  49. package/resources/stream/events.js.map +1 -1
  50. package/resources/stream/render-ndjson.js +2 -0
  51. package/resources/stream/render-ndjson.js.map +1 -1
  52. package/resources/stream/render-plaintext.d.ts.map +1 -1
  53. package/resources/stream/render-plaintext.js +3 -0
  54. package/resources/stream/render-plaintext.js.map +1 -1
  55. package/resources/stream/snapshot.js +6 -1
  56. package/resources/stream/snapshot.js.map +1 -1
  57. package/resources/stream/tool-state.d.ts +1 -1
  58. package/resources/stream/tool-state.d.ts.map +1 -1
  59. package/resources/stream/tool-state.js +13 -4
  60. package/resources/stream/tool-state.js.map +1 -1
  61. package/src/commands/connect.test.ts +26 -4
  62. package/src/commands/share.ts +99 -0
  63. package/src/config/index.ts +2 -0
  64. package/src/config/resolve.test.ts +38 -0
  65. package/src/config/resolve.ts +42 -3
  66. package/src/local/runtime/node.ts +43 -25
  67. package/src/local/runtime/runtime.test.ts +5 -4
  68. package/src/program.test.ts +7 -0
  69. package/src/program.ts +2 -0
  70. package/src/resources/connect/__fixtures__/stdio-server.mjs +8 -3
  71. package/src/resources/connect/connect.integration.test.ts +91 -0
  72. package/src/resources/connect/connect.ts +86 -8
  73. package/src/resources/connect/discover.test.ts +40 -1
  74. package/src/resources/connect/discover.ts +16 -4
  75. package/src/resources/connect/oauth.test.ts +1 -24
  76. package/src/resources/connect/oauth.ts +1 -17
  77. package/src/resources/mcp/placeholder-resolver.test.ts +71 -0
  78. package/src/resources/mcp/placeholder-resolver.ts +63 -0
  79. package/src/resources/mcp/runtime-env.test.ts +35 -1
  80. package/src/resources/mcp/runtime-env.ts +35 -9
  81. package/src/resources/read-verbs.integration.test.ts +1 -1
  82. package/src/resources/share.test.ts +389 -0
  83. package/src/resources/share.ts +227 -0
  84. package/src/resources/stream/convert.ts +17 -3
  85. package/src/resources/stream/diff.test.ts +22 -0
  86. package/src/resources/stream/events.ts +15 -1
  87. package/src/resources/stream/render-ndjson.test.ts +10 -0
  88. package/src/resources/stream/render-ndjson.ts +2 -0
  89. package/src/resources/stream/render-plaintext.test.ts +8 -0
  90. package/src/resources/stream/render-plaintext.ts +3 -0
  91. package/src/resources/stream/snapshot.test.ts +14 -0
  92. package/src/resources/stream/snapshot.ts +5 -1
  93. package/src/resources/stream/tool-state.ts +13 -4
@@ -0,0 +1,71 @@
1
+ // Unit tests for the strict ${VAR} placeholder resolver.
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { PlaceholderResolutionError, resolveHeaders, resolvePlaceholders } from "./placeholder-resolver.js";
5
+
6
+ describe("resolvePlaceholders", () => {
7
+ it("returns a string with no placeholders unchanged", () => {
8
+ expect(resolvePlaceholders("/tmp/data.sqlite", {})).toBe("/tmp/data.sqlite");
9
+ });
10
+
11
+ it("resolves a single placeholder from the env map", () => {
12
+ expect(resolvePlaceholders("${DIR}", { DIR: "/home/me" })).toBe("/home/me");
13
+ });
14
+
15
+ it("resolves a placeholder embedded in surrounding text", () => {
16
+ expect(resolvePlaceholders("${DB_PATH}/data.sqlite", { DB_PATH: "/var/lib" })).toBe("/var/lib/data.sqlite");
17
+ });
18
+
19
+ it("resolves multiple and adjacent placeholders", () => {
20
+ expect(resolvePlaceholders("${A}${B}-${A}", { A: "x", B: "y" })).toBe("xy-x");
21
+ });
22
+
23
+ it("resolves to an empty string when the value is empty", () => {
24
+ // An empty-but-present value is a resolved value, not a missing variable.
25
+ expect(resolvePlaceholders("[${X}]", { X: "" })).toBe("[]");
26
+ });
27
+
28
+ it("ignores $VAR without braces (only ${VAR} is a placeholder)", () => {
29
+ expect(resolvePlaceholders("$HOME/bin", { HOME: "/root" })).toBe("$HOME/bin");
30
+ });
31
+
32
+ it("throws PlaceholderResolutionError for an unresolved variable, carrying name + context", () => {
33
+ let caught: unknown;
34
+ try {
35
+ resolvePlaceholders("${MISSING}", { OTHER: "1" }, "stdio arg[2]");
36
+ } catch (err) {
37
+ caught = err;
38
+ }
39
+ expect(caught).toBeInstanceOf(PlaceholderResolutionError);
40
+ const e = caught as PlaceholderResolutionError;
41
+ expect(e.variableName).toBe("MISSING");
42
+ expect(e.context).toBe("stdio arg[2]");
43
+ expect(e.message).toContain("${MISSING}");
44
+ expect(e.message).toContain("stdio arg[2]");
45
+ });
46
+
47
+ it("throws on the first unresolved variable even when others resolve", () => {
48
+ expect(() => resolvePlaceholders("${A}-${B}", { A: "ok" })).toThrow(PlaceholderResolutionError);
49
+ });
50
+ });
51
+
52
+ describe("resolveHeaders", () => {
53
+ it("resolves placeholders in every header value", () => {
54
+ const resolved = resolveHeaders(
55
+ { Authorization: "Bearer ${TOKEN}", "X-Static": "v1" },
56
+ { TOKEN: "abc123" },
57
+ );
58
+ expect(resolved).toEqual({ Authorization: "Bearer abc123", "X-Static": "v1" });
59
+ });
60
+
61
+ it("throws with the offending header name in the context", () => {
62
+ let caught: unknown;
63
+ try {
64
+ resolveHeaders({ Authorization: "Bearer ${API_KEY}" }, {});
65
+ } catch (err) {
66
+ caught = err;
67
+ }
68
+ expect(caught).toBeInstanceOf(PlaceholderResolutionError);
69
+ expect((caught as PlaceholderResolutionError).message).toContain('header "Authorization"');
70
+ });
71
+ });
@@ -0,0 +1,63 @@
1
+ // Strict placeholder resolver for ${VAR_NAME} syntax in MCP server configs.
2
+ //
3
+ // The MCP server spec (StdioServerConfig.args, HttpServerConfig.headers) allows
4
+ // values to reference declared env vars with ${VAR_NAME}. The proto contract is
5
+ // explicit that resolution is strict: a missing variable must produce a clear
6
+ // error rather than passing a literal "${VAR}" to the subprocess/server.
7
+ //
8
+ // This mirrors the runner's shared resolver (backend/services/runner/src/shared/
9
+ // placeholder-resolver.ts) so `connect --dry-run` (which discovers locally)
10
+ // behaves identically to real connect (which resolves on the runner). Kept
11
+ // intentionally dependency-free and semantically identical so the two can be
12
+ // collapsed into one shared package later without any behavior change.
13
+
14
+ const PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
15
+
16
+ export class PlaceholderResolutionError extends Error {
17
+ constructor(
18
+ public readonly variableName: string,
19
+ public readonly context?: string,
20
+ ) {
21
+ const where = context ? ` in ${context}` : "";
22
+ super(
23
+ `Unresolved placeholder \${${variableName}}${where}: ` +
24
+ `variable is not present in the execution environment`,
25
+ );
26
+ this.name = "PlaceholderResolutionError";
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Resolve all ${VAR_NAME} placeholders in a template string.
32
+ *
33
+ * @throws PlaceholderResolutionError if any placeholder cannot be resolved.
34
+ */
35
+ export function resolvePlaceholders(
36
+ template: string,
37
+ envVars: Record<string, string>,
38
+ context?: string,
39
+ ): string {
40
+ return template.replace(PLACEHOLDER_RE, (_match, varName: string) => {
41
+ if (varName in envVars) {
42
+ return envVars[varName];
43
+ }
44
+ throw new PlaceholderResolutionError(varName, context);
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Resolve placeholders in every value of a headers map.
50
+ *
51
+ * @throws PlaceholderResolutionError if any header value contains an
52
+ * unresolvable placeholder.
53
+ */
54
+ export function resolveHeaders(
55
+ headers: Record<string, string>,
56
+ envVars: Record<string, string>,
57
+ ): Record<string, string> {
58
+ const resolved: Record<string, string> = {};
59
+ for (const [key, value] of Object.entries(headers)) {
60
+ resolved[key] = resolvePlaceholders(value, envVars, `header "${key}"`);
61
+ }
62
+ return resolved;
63
+ }
@@ -4,12 +4,18 @@ import { create } from "@bufbuild/protobuf";
4
4
  import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
5
5
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
6
  import { UsageError } from "../../errors/index.js";
7
- import { buildRuntimeEnv, mergeProcessEnv, parseEnvOverrides } from "./runtime-env.js";
7
+ import { buildRuntimeEnv, mergeProcessEnv, parseEnvOverrides, resolveDeclaredEnvValues } from "./runtime-env.js";
8
8
 
9
9
  function serverWithEnv(env: Record<string, { isSecret: boolean }>) {
10
10
  return create(McpServerSchema, { metadata: { id: "mcp_1" }, spec: { env } });
11
11
  }
12
12
 
13
+ // resolveDeclaredEnvValues takes the raw declarations map; the tests only rely on
14
+ // keys being present, so a minimal declaration object is sufficient.
15
+ function decls(...keys: string[]) {
16
+ return Object.fromEntries(keys.map((k) => [k, { isSecret: false }]));
17
+ }
18
+
13
19
  describe("parseEnvOverrides", () => {
14
20
  it("parses KEY=VALUE pairs, preserving '=' in the value", () => {
15
21
  expect(parseEnvOverrides(["A=1", "B=x=y"])).toEqual({ A: "1", B: "x=y" });
@@ -66,3 +72,31 @@ describe("mergeProcessEnv", () => {
66
72
  expect(merged.NEW).toBe("1");
67
73
  });
68
74
  });
75
+
76
+ describe("resolveDeclaredEnvValues", () => {
77
+ const original = process.env;
78
+ beforeEach(() => {
79
+ process.env = { ...original, ALLOWED_DIR: "/from-os", EMPTY_VAR: "" };
80
+ });
81
+ afterEach(() => {
82
+ process.env = original;
83
+ });
84
+
85
+ it("pulls declared keys from the OS environment", () => {
86
+ expect(resolveDeclaredEnvValues(decls("ALLOWED_DIR"))).toEqual({ ALLOWED_DIR: "/from-os" });
87
+ });
88
+
89
+ it("skips declared keys that are unset or empty in the environment", () => {
90
+ expect(resolveDeclaredEnvValues(decls("EMPTY_VAR", "MISSING"))).toEqual({});
91
+ });
92
+
93
+ it("lets --env overrides win over the OS environment", () => {
94
+ expect(resolveDeclaredEnvValues(decls("ALLOWED_DIR"), ["ALLOWED_DIR=/override"])).toEqual({
95
+ ALLOWED_DIR: "/override",
96
+ });
97
+ });
98
+
99
+ it("includes an --env override even when the key is undeclared (matches buildRuntimeEnv)", () => {
100
+ expect(resolveDeclaredEnvValues(decls(), ["EXTRA=1"])).toEqual({ EXTRA: "1" });
101
+ });
102
+ });
@@ -36,6 +36,39 @@ export function parseEnvOverrides(overrides: readonly string[]): Record<string,
36
36
  return result;
37
37
  }
38
38
 
39
+ /**
40
+ * Resolve the values behind a server's declared env vars: declared keys pulled
41
+ * from the OS environment (non-empty only), with `--env` overrides layered on
42
+ * top (override wins; an undeclared override is still included).
43
+ *
44
+ * This is the single source of truth for "which env value is in play". It backs
45
+ * both the runtime_env sent to the backend (buildRuntimeEnv) and the ${VAR}
46
+ * placeholder resolution done locally for `--dry-run` (discover.ts), so the two
47
+ * can never drift: dry-run resolves against the exact same map the backend hands
48
+ * the runner (minus platform-injected infra vars, which a local machine cannot
49
+ * know).
50
+ *
51
+ * `declarations` is the server's `spec.env` map; only its keys (the declared
52
+ * variable names) are read, so any map keyed by those names is accepted.
53
+ */
54
+ export function resolveDeclaredEnvValues(
55
+ declarations: Record<string, unknown>,
56
+ envOverrides: readonly string[] = [],
57
+ ): Record<string, string> {
58
+ const values: Record<string, string> = {};
59
+
60
+ for (const key of Object.keys(declarations)) {
61
+ const value = process.env[key];
62
+ if (value !== undefined && value !== "") values[key] = value;
63
+ }
64
+
65
+ for (const [key, value] of Object.entries(parseEnvOverrides(envOverrides))) {
66
+ values[key] = value;
67
+ }
68
+
69
+ return values;
70
+ }
71
+
39
72
  /**
40
73
  * Build the runtime_env map for the Connect RPC from the OS environment and the
41
74
  * supplied --env overrides. Only keys with a non-empty value are emitted.
@@ -45,17 +78,10 @@ export function buildRuntimeEnv(
45
78
  envOverrides: readonly string[] = [],
46
79
  ): Record<string, ExecutionValue> {
47
80
  const declarations = server.spec?.env ?? {};
48
- const overrides = parseEnvOverrides(envOverrides);
81
+ const values = resolveDeclaredEnvValues(declarations, envOverrides);
49
82
  const runtime: Record<string, ExecutionValue> = {};
50
83
 
51
- for (const [key, decl] of Object.entries(declarations)) {
52
- const value = process.env[key];
53
- if (value !== undefined && value !== "") {
54
- runtime[key] = create(ExecutionValueSchema, { value, isSecret: decl.isSecret });
55
- }
56
- }
57
-
58
- for (const [key, value] of Object.entries(overrides)) {
84
+ for (const [key, value] of Object.entries(values)) {
59
85
  runtime[key] = create(ExecutionValueSchema, { value, isSecret: declarations[key]?.isSecret ?? false });
60
86
  }
61
87
 
@@ -38,7 +38,7 @@ const knownAgent = create(AgentSchema, {
38
38
  const knownOrg = create(OrganizationSchema, {
39
39
  apiVersion: "tenancy.stigmer.ai/v1",
40
40
  kind: "Organization",
41
- metadata: { name: "Acme", slug: "acme", org: "acme", id: "org_1" },
41
+ metadata: { name: "Acme", slug: "acme", org: "acme", id: "acme" },
42
42
  });
43
43
 
44
44
  const knownApiKey = create(ApiKeySchema, {
@@ -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
+ });