@copilotkit/react-ui 1.59.1 → 1.59.2

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.
@@ -1,11 +1,13 @@
1
1
  import requireCpkPrefix from "./require-cpk-prefix.mjs";
2
2
  import noSingleArgZodRecord from "./no-single-arg-zod-record.mjs";
3
+ import noPublicEnvShellRead from "./no-public-env-shell-read.mjs";
3
4
 
4
5
  const plugin = {
5
6
  meta: { name: "copilotkit" },
6
7
  rules: {
7
8
  "require-cpk-prefix": requireCpkPrefix,
8
9
  "no-single-arg-zod-record": noSingleArgZodRecord,
10
+ "no-public-env-shell-read": noPublicEnvShellRead,
9
11
  },
10
12
  };
11
13
 
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Oxlint rule: no-public-env-shell-read
3
+ *
4
+ * Bans direct READS of a specific, enumerated set of `process.env.NEXT_PUBLIC_*`
5
+ * URL/analytics keys (see `BANNED_KEYS` below) in showcase shell client/server
6
+ * code. Those values are now served at runtime via `getRuntimeConfig()` (see
7
+ * workstream B / Option-B migration); a stray `process.env.NEXT_PUBLIC_<key>`
8
+ * read would silently re-freeze the value at build time and regress the
9
+ * migration.
10
+ *
11
+ * IMPORTANT: this rule does NOT ban every `NEXT_PUBLIC_*` read — only the
12
+ * explicit banned-key set. Build-stamp keys (NEXT_PUBLIC_COMMIT_SHA,
13
+ * NEXT_PUBLIC_BRANCH) and the local-dev computed key
14
+ * (NEXT_PUBLIC_LOCAL_BACKENDS) are intentionally NOT banned.
15
+ *
16
+ * Allowed (intentionally NOT in the banned set):
17
+ * - `NEXT_PUBLIC_COMMIT_SHA` — build-stamped artifact identifier
18
+ * - `NEXT_PUBLIC_BRANCH` — build-stamped artifact identifier
19
+ * - `NEXT_PUBLIC_LOCAL_BACKENDS` — local-dev only, computed from
20
+ * `shared/local-ports.json` at build time (not a real env var)
21
+ *
22
+ * Forms detected as READS (each will be flagged):
23
+ * - dotted member: process.env.NEXT_PUBLIC_SHELL_URL
24
+ * - string-bracket member: process.env["NEXT_PUBLIC_SHELL_URL"]
25
+ * - no-expression template: process.env[`NEXT_PUBLIC_SHELL_URL`]
26
+ * - optional chaining: process.env?.NEXT_PUBLIC_SHELL_URL
27
+ * process.env?.["NEXT_PUBLIC_SHELL_URL"]
28
+ * - destructuring read: const { NEXT_PUBLIC_SHELL_URL } = process.env
29
+ * const { NEXT_PUBLIC_SHELL_URL: aliased } = process.env
30
+ *
31
+ * Forms intentionally NOT flagged (writes/deletes are not reads):
32
+ * - assignment LHS: process.env.NEXT_PUBLIC_X = "..."
33
+ * - delete: delete process.env.NEXT_PUBLIC_X
34
+ *
35
+ * Out of scope (deliberately NOT covered — documented here so the gaps are
36
+ * auditable rather than implicit):
37
+ * - aliasing: const e = process.env; e.NEXT_PUBLIC_X
38
+ * (requires scope/flow tracking)
39
+ * - bulk-iteration reads: Object.keys(process.env), Object.values(process.env),
40
+ * Object.entries(process.env), for-in over process.env,
41
+ * spread `{...process.env}` — these read the whole
42
+ * env object without naming a key statically
43
+ * - rest-pattern destructure: `const { ...rest } = process.env` — same shape
44
+ * as the iteration case (no static key in source)
45
+ * - compound-assignment LHS: `process.env.X += "..."` — currently treated as
46
+ * an AssignmentExpression with operator !== "="
47
+ * (i.e. NOT flagged); fine in practice because
48
+ * showcase code does not append to env vars
49
+ * - update operators: `process.env.X++` / `process.env.X--` —
50
+ * defensively bailed; not flagged
51
+ *
52
+ * Scope is enforced via `overrides[].files` in `.oxlintrc.json` (shell
53
+ * source trees only; `.mdx` content, runtime-config implementation files,
54
+ * and tests are excluded by a follow-up override that turns this rule
55
+ * back off).
56
+ *
57
+ * Note: plan-B's original spec called for oxlint's `eslint/no-restricted-syntax`
58
+ * with an AST selector regex. oxlint 1.x does not implement that rule
59
+ * (only `no-restricted-globals` / `no-restricted-imports`), so we
60
+ * implement the equivalent guard as a focused custom rule in the existing
61
+ * copilotkit oxlint plugin instead.
62
+ */
63
+
64
+ // Exported so the table-driven test in
65
+ // `showcase/scripts/__tests__/lint-rule-no-public-env.test.ts` can iterate
66
+ // the rule's own banned set rather than hand-mirroring it (any drift would
67
+ // silently weaken coverage). The exported value is the same Set the rule
68
+ // uses internally — they cannot diverge.
69
+ export const BANNED_KEYS = new Set([
70
+ "NEXT_PUBLIC_POCKETBASE_URL",
71
+ "NEXT_PUBLIC_SHELL_URL",
72
+ "NEXT_PUBLIC_BASE_URL",
73
+ "NEXT_PUBLIC_OPS_BASE_URL",
74
+ "NEXT_PUBLIC_INTELLIGENCE_SIGNUP_URL",
75
+ "NEXT_PUBLIC_POSTHOG_KEY",
76
+ "NEXT_PUBLIC_POSTHOG_HOST",
77
+ "NEXT_PUBLIC_SCARF_PIXEL_ID",
78
+ "NEXT_PUBLIC_GOOGLE_ANALYTICS_TRACKING_ID",
79
+ "NEXT_PUBLIC_REB2B_KEY",
80
+ "NEXT_PUBLIC_REO_KEY",
81
+ ]);
82
+
83
+ /**
84
+ * True iff `node` (after unwrapping a wrapping `ChainExpression`) is the
85
+ * `process.env` member expression. All read forms hang off this anchor:
86
+ * - bare: process.env.X
87
+ * - optional chain: process?.env.X / process.env?.X / process?.env?.X
88
+ * Some parser flavors wrap the whole optional chain in a `ChainExpression`
89
+ * whose `.expression` is the MemberExpression we want to match; others
90
+ * surface the MemberExpression directly with `optional: true`. We accept
91
+ * both shapes by stripping the wrapper first.
92
+ */
93
+ function isProcessEnv(node) {
94
+ if (node && node.type === "ChainExpression") node = node.expression;
95
+ if (!node || node.type !== "MemberExpression") return false;
96
+ if (node.computed) return false;
97
+ if (!node.object || node.object.type !== "Identifier") return false;
98
+ if (node.object.name !== "process") return false;
99
+ if (!node.property || node.property.type !== "Identifier") return false;
100
+ return node.property.name === "env";
101
+ }
102
+
103
+ /**
104
+ * Given a `node.property` from a MemberExpression read off `process.env`,
105
+ * return the static key name if we can determine one — or null if the key
106
+ * is dynamic (and therefore out of scope for a static lint).
107
+ *
108
+ * Handled key forms:
109
+ * - Identifier (dotted: process.env.FOO)
110
+ * - Literal string (bracket: process.env["FOO"])
111
+ * - TemplateLiteral with no expressions (process.env[`FOO`])
112
+ */
113
+ function staticKeyName(property, computed) {
114
+ if (!property) return null;
115
+ if (!computed && property.type === "Identifier") {
116
+ return property.name;
117
+ }
118
+ if (
119
+ computed &&
120
+ property.type === "Literal" &&
121
+ typeof property.value === "string"
122
+ ) {
123
+ return property.value;
124
+ }
125
+ if (
126
+ computed &&
127
+ property.type === "TemplateLiteral" &&
128
+ Array.isArray(property.expressions) &&
129
+ property.expressions.length === 0 &&
130
+ Array.isArray(property.quasis) &&
131
+ property.quasis.length === 1
132
+ ) {
133
+ const cooked = property.quasis[0]?.value?.cooked;
134
+ return typeof cooked === "string" ? cooked : null;
135
+ }
136
+ return null;
137
+ }
138
+
139
+ const rule = {
140
+ meta: {
141
+ type: "problem",
142
+ docs: {
143
+ description:
144
+ "Disallow shell-side reads of a banned set of NEXT_PUBLIC_* URL/analytics keys (process.env.<key>, process.env['<key>'], destructuring, optional chaining) — use getRuntimeConfig() instead",
145
+ },
146
+ schema: [],
147
+ messages: {
148
+ forbiddenRead:
149
+ "Do not read process.env.NEXT_PUBLIC_* directly in shell code. Use getRuntimeConfig() from @/lib/runtime-config.client (client) or @/lib/runtime-config (server). See workstream B.",
150
+ },
151
+ },
152
+
153
+ create(context) {
154
+ return {
155
+ // Member-expression reads: dotted, bracket-string, bracket-template,
156
+ // and optional-chained equivalents. We skip writes (assignment LHS)
157
+ // and `delete` targets — those are not reads of the value.
158
+ MemberExpression(node) {
159
+ if (!isProcessEnv(node.object)) return;
160
+
161
+ // Write target: `process.env.X = ...`. The MemberExpression is the
162
+ // LHS of an AssignmentExpression — not a read.
163
+ const parent = node.parent;
164
+ if (
165
+ parent &&
166
+ parent.type === "AssignmentExpression" &&
167
+ parent.left === node
168
+ ) {
169
+ return;
170
+ }
171
+ // `delete process.env.X` — not a read either.
172
+ if (
173
+ parent &&
174
+ parent.type === "UnaryExpression" &&
175
+ parent.operator === "delete"
176
+ ) {
177
+ return;
178
+ }
179
+ // `update` operators (++/--): also a write, but extremely unlikely
180
+ // on a string env var. Defensive bail anyway.
181
+ if (parent && parent.type === "UpdateExpression") {
182
+ return;
183
+ }
184
+
185
+ const keyName = staticKeyName(node.property, node.computed);
186
+ if (!keyName) return;
187
+ if (!BANNED_KEYS.has(keyName)) return;
188
+
189
+ context.report({ node, messageId: "forbiddenRead" });
190
+ },
191
+
192
+ // Destructuring reads: `const { NEXT_PUBLIC_X } = process.env` and the
193
+ // aliased form `const { NEXT_PUBLIC_X: y } = process.env`. We catch
194
+ // this at the VariableDeclarator level so we can confirm the init is
195
+ // exactly `process.env` (not some other object with same-named props).
196
+ //
197
+ // Note: this does NOT cover the aliasing case
198
+ // `const e = process.env; e.NEXT_PUBLIC_X` — that requires scope
199
+ // tracking and is intentionally out of scope; see file header.
200
+ VariableDeclarator(node) {
201
+ if (!node.init || !isProcessEnv(node.init)) return;
202
+ if (!node.id || node.id.type !== "ObjectPattern") return;
203
+ for (const prop of node.id.properties) {
204
+ if (!prop || prop.type !== "Property") continue;
205
+ // The `key` is the source name on process.env; that's what we
206
+ // test against BANNED_KEYS regardless of any local alias. We
207
+ // route through staticKeyName() so the computed-string-key form
208
+ // `{ ["NEXT_PUBLIC_X"]: y } = process.env` and the no-expression
209
+ // template form `{ [`NEXT_PUBLIC_X`]: y } = process.env` are
210
+ // caught with the same parity as the bracket-member read.
211
+ const keyName = staticKeyName(prop.key, prop.computed);
212
+ if (!keyName) continue;
213
+ if (!BANNED_KEYS.has(keyName)) continue;
214
+ context.report({ node: prop, messageId: "forbiddenRead" });
215
+ }
216
+ },
217
+ };
218
+ },
219
+ };
220
+
221
+ export default rule;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@copilotkit/react-ui",
3
- "version": "1.59.1",
3
+ "version": "1.59.2",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "ai",
@@ -48,9 +48,9 @@
48
48
  "rehype-raw": "^7.0.0",
49
49
  "remark-gfm": "^4.0.1",
50
50
  "remark-math": "^6.0.0",
51
- "@copilotkit/react-core": "1.59.1",
52
- "@copilotkit/runtime-client-gql": "1.59.1",
53
- "@copilotkit/shared": "1.59.1"
51
+ "@copilotkit/react-core": "1.59.2",
52
+ "@copilotkit/shared": "1.59.2",
53
+ "@copilotkit/runtime-client-gql": "1.59.2"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "^19.1.0",
@@ -71,6 +71,8 @@
71
71
  "scripts": {
72
72
  "build:css": "node -e \"const fs=require('fs');fs.mkdirSync('./dist/v2',{recursive:true});fs.cpSync('./src/v2/styles.css','./dist/v2/index.css')\"",
73
73
  "build": "tsdown && pnpm run build:css",
74
+ "size": "size-limit",
75
+ "compat-check": "es-check es2022 --module 'dist/**/!(*.umd).{mjs,cjs,js}' && es-check es2018 'dist/**/*.umd.js'",
74
76
  "dev": "tsdown --watch",
75
77
  "test": "vitest run",
76
78
  "test:watch": "vitest",
@@ -1,12 +1,10 @@
1
1
  import React, { useEffect, useMemo, useRef } from "react";
2
- import { MessagesProps } from "./props";
2
+ import type { MessagesProps } from "./props";
3
3
  import { useChatContext } from "./ChatContext";
4
- import { Message } from "@copilotkit/shared";
4
+ import type { Message } from "@copilotkit/shared";
5
5
  import { useCopilotChatInternal } from "@copilotkit/react-core";
6
- import {
7
- LegacyRenderMessage,
8
- LegacyRenderProps,
9
- } from "./messages/LegacyRenderMessage";
6
+ import type { LegacyRenderProps } from "./messages/LegacyRenderMessage";
7
+ import { LegacyRenderMessage } from "./messages/LegacyRenderMessage";
10
8
 
11
9
  export const Messages = ({
12
10
  inProgress,
@@ -85,7 +83,9 @@ export const Messages = ({
85
83
  )
86
84
  : RenderMessage;
87
85
 
88
- const LoadingIcon = () => <span>{icons.activityIcon}</span>;
86
+ const LoadingIcon = () => (
87
+ <span data-testid="copilot-loading-cursor">{icons.activityIcon}</span>
88
+ );
89
89
 
90
90
  return (
91
91
  <div className="copilotKitMessages" ref={messagesContainerRef}>
@@ -1,4 +1,4 @@
1
- import { AssistantMessageProps } from "../props";
1
+ import type { AssistantMessageProps } from "../props";
2
2
  import { useChatContext } from "../ChatContext";
3
3
  import { Markdown } from "../Markdown";
4
4
  import { useState } from "react";
@@ -48,7 +48,9 @@ export const AssistantMessage = (props: AssistantMessageProps) => {
48
48
  }
49
49
  };
50
50
 
51
- const LoadingIcon = () => <span>{icons.activityIcon}</span>;
51
+ const LoadingIcon = () => (
52
+ <span data-testid="copilot-loading-cursor">{icons.activityIcon}</span>
53
+ );
52
54
  const content = message?.content || "";
53
55
  const subComponent = message?.generativeUI?.() ?? props.subComponent;
54
56
  const subComponentPosition = message?.generativeUIPosition ?? "after";
@@ -1,4 +1,4 @@
1
- import { ErrorMessageProps } from "../props";
1
+ import type { ErrorMessageProps } from "../props";
2
2
  import { useChatContext } from "../ChatContext";
3
3
  import { Markdown } from "../Markdown";
4
4
  import { useState } from "react";
@@ -28,7 +28,10 @@ export const ErrorMessage = (props: ErrorMessageProps) => {
28
28
  console.log(error);
29
29
 
30
30
  return (
31
- <div className="copilotKitMessage copilotKitAssistantMessage">
31
+ <div
32
+ data-testid="copilot-error-banner"
33
+ className="copilotKitMessage copilotKitAssistantMessage"
34
+ >
32
35
  <Markdown content={error.message} />
33
36
 
34
37
  <div
@@ -0,0 +1,31 @@
1
+ import { readFileSync } from "fs";
2
+ import { resolve } from "path";
3
+
4
+ /**
5
+ * Verifies stable `data-testid` markers exist on the error banner and loading
6
+ * indicator surfaces so e2e tests can deterministically distinguish "errored
7
+ * out" vs "still loading" states. Without these, e2e probes hit ~30-60s
8
+ * timeouts instead of failing fast.
9
+ */
10
+
11
+ const errorMessagePath = resolve(__dirname, "ErrorMessage.tsx");
12
+ const assistantMessagePath = resolve(__dirname, "AssistantMessage.tsx");
13
+ const messagesPath = resolve(__dirname, "../Messages.tsx");
14
+
15
+ const errorMessageSrc = readFileSync(errorMessagePath, "utf-8");
16
+ const assistantMessageSrc = readFileSync(assistantMessagePath, "utf-8");
17
+ const messagesSrc = readFileSync(messagesPath, "utf-8");
18
+
19
+ describe("react-ui stable testids", () => {
20
+ it("ErrorMessage renders the copilot-error-banner testid on its root", () => {
21
+ expect(errorMessageSrc).toMatch(/data-testid="copilot-error-banner"/);
22
+ });
23
+
24
+ it("Messages LoadingIcon renders the copilot-loading-cursor testid", () => {
25
+ expect(messagesSrc).toMatch(/data-testid="copilot-loading-cursor"/);
26
+ });
27
+
28
+ it("AssistantMessage LoadingIcon renders the copilot-loading-cursor testid", () => {
29
+ expect(assistantMessageSrc).toMatch(/data-testid="copilot-loading-cursor"/);
30
+ });
31
+ });