pi-harness-runtime 1.1.387 → 1.1.388

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 (127) hide show
  1. package/AGENTS.md +9 -4
  2. package/index.ts +20 -18
  3. package/package.json +1 -1
  4. package/packages/a2a-adapter/package.json +1 -1
  5. package/packages/agent-policy/package.json +1 -1
  6. package/packages/architecture-generator/package.json +1 -1
  7. package/packages/auth/package.json +1 -1
  8. package/packages/autonomous-refactor/package.json +1 -1
  9. package/packages/autonomous-runtime/package.json +1 -1
  10. package/packages/cache-strategy/package.json +1 -1
  11. package/packages/capability-registry/package.json +1 -1
  12. package/packages/checkpoint/package.json +1 -1
  13. package/packages/cli-plugin-sdk/package.json +1 -1
  14. package/packages/clipboard/package.json +1 -1
  15. package/packages/clipboard-plugin/package.json +1 -1
  16. package/packages/code-generation/package.json +1 -1
  17. package/packages/code-review/package.json +1 -1
  18. package/packages/codex-adapter/package.json +1 -1
  19. package/packages/codex-watcher/package.json +1 -1
  20. package/packages/config-capture/package.json +1 -1
  21. package/packages/connection-runtime/package.json +1 -1
  22. package/packages/context-compiler/package.json +1 -1
  23. package/packages/context-discovery/package.json +1 -1
  24. package/packages/context-manager/package.json +1 -1
  25. package/packages/contracts/dist/workspace-env-format.d.ts +71 -0
  26. package/packages/contracts/dist/workspace-env-format.js +120 -0
  27. package/packages/contracts/dist/workspace-env.d.ts +7 -30
  28. package/packages/contracts/dist/workspace-env.js +18 -57
  29. package/packages/contracts/package.json +1 -1
  30. package/packages/contracts/src/workspace-env-format.ts +165 -0
  31. package/packages/contracts/src/workspace-env.ts +27 -99
  32. package/packages/contracts/test/workspace-env.test.ts +144 -0
  33. package/packages/cookie-sanitizer/package.json +1 -1
  34. package/packages/cost-optimizer/package.json +1 -1
  35. package/packages/dependency-analyzer/package.json +1 -1
  36. package/packages/django-plugin/package.json +1 -1
  37. package/packages/doc-generator/package.json +1 -1
  38. package/packages/durable-review/package.json +1 -1
  39. package/packages/e2e-tester/package.json +1 -1
  40. package/packages/evaluation-engine/package.json +1 -1
  41. package/packages/evaluation-runner/package.json +1 -1
  42. package/packages/event-bus/package.json +1 -1
  43. package/packages/event-sink/package.json +1 -1
  44. package/packages/event-store/package.json +1 -1
  45. package/packages/experience-replay/package.json +1 -1
  46. package/packages/feedback-collector/package.json +1 -1
  47. package/packages/file-copy-helper/package.json +1 -1
  48. package/packages/framework-detector/package.json +1 -1
  49. package/packages/framework-plugin-sdk/package.json +1 -1
  50. package/packages/frappe-plugin/package.json +1 -1
  51. package/packages/generic-web-plugin/package.json +1 -1
  52. package/packages/health-monitor/package.json +1 -1
  53. package/packages/intent-analyzer/package.json +1 -1
  54. package/packages/jev-judge/package.json +1 -1
  55. package/packages/knowledge-graph/package.json +1 -1
  56. package/packages/knowledge-retrieval/package.json +1 -1
  57. package/packages/laravel-plugin/package.json +1 -1
  58. package/packages/learning-engine/package.json +1 -1
  59. package/packages/mcp-adapter/package.json +1 -1
  60. package/packages/memory-engine/package.json +1 -1
  61. package/packages/milestone-manager/package.json +1 -1
  62. package/packages/model-registry/package.json +1 -1
  63. package/packages/nextjs-plugin/package.json +1 -1
  64. package/packages/notification/package.json +1 -1
  65. package/packages/observability/package.json +1 -1
  66. package/packages/okf-indexer/package.json +1 -1
  67. package/packages/paperclip-integration/package.json +1 -1
  68. package/packages/performance-optimizer/package.json +1 -1
  69. package/packages/permission-mode/package.json +1 -1
  70. package/packages/privilege-broker/package.json +1 -1
  71. package/packages/project-analyzer/package.json +1 -1
  72. package/packages/project-bootstrap/package.json +1 -1
  73. package/packages/projection-engine/package.json +1 -1
  74. package/packages/prompt-compiler/package.json +1 -1
  75. package/packages/prompt-versioning/package.json +1 -1
  76. package/packages/provider-adapter-sdk/package.json +1 -1
  77. package/packages/provider-router/package.json +1 -1
  78. package/packages/provider-selector/package.json +1 -1
  79. package/packages/providers/package.json +1 -1
  80. package/packages/qdrant-skills/package.json +1 -1
  81. package/packages/quota-manager/package.json +1 -1
  82. package/packages/rate-limiter/package.json +1 -1
  83. package/packages/react-vite-plugin/package.json +1 -1
  84. package/packages/relay/dist/index.d.ts +3 -0
  85. package/packages/relay/dist/index.js +3 -0
  86. package/packages/relay/dist/relay-endpoint.d.ts +94 -0
  87. package/packages/relay/dist/relay-endpoint.js +239 -0
  88. package/packages/relay/package.json +1 -1
  89. package/packages/relay/src/index.ts +17 -0
  90. package/packages/relay/src/relay-endpoint.ts +304 -0
  91. package/packages/relay/test/relay-endpoint.test.ts +278 -0
  92. package/packages/release-manager/package.json +1 -1
  93. package/packages/requirement-compiler/package.json +1 -1
  94. package/packages/runtime/package.json +1 -1
  95. package/packages/scheduler/package.json +1 -1
  96. package/packages/scheduler-adapter/package.json +1 -1
  97. package/packages/session/package.json +1 -1
  98. package/packages/session-api/package.json +1 -1
  99. package/packages/session-export/package.json +1 -1
  100. package/packages/shared-context/package.json +1 -1
  101. package/packages/skill-mcp-client/package.json +1 -1
  102. package/packages/skill-registry/package.json +1 -1
  103. package/packages/skills/package.json +1 -1
  104. package/packages/sprint-planner/package.json +1 -1
  105. package/packages/subscription-engine/package.json +1 -1
  106. package/packages/task-compiler/package.json +1 -1
  107. package/packages/telegram/package.json +1 -1
  108. package/packages/tencentdb-memory/package.json +1 -1
  109. package/packages/tencentdb-sync/package.json +1 -1
  110. package/packages/test-data-generator/package.json +1 -1
  111. package/packages/test-generator/package.json +1 -1
  112. package/packages/todo-bd-sync/package.json +1 -1
  113. package/packages/token-estimation/package.json +1 -1
  114. package/packages/token-optimizer/package.json +1 -1
  115. package/packages/trajectory/package.json +1 -1
  116. package/packages/tui/package.json +1 -1
  117. package/packages/tui/test/harness-footer.test.ts +1 -1
  118. package/packages/types/package.json +1 -1
  119. package/packages/voice-api/package.json +1 -1
  120. package/packages/webmcp-tester/package.json +1 -1
  121. package/packages/wiki/package.json +1 -1
  122. package/packages/workflow-events/package.json +1 -1
  123. package/packages/workspace-scanner/package.json +1 -1
  124. package/packages/worktree/package.json +1 -1
  125. package/packages/write-review/package.json +1 -1
  126. package/scripts/relay-client.ts +24 -4
  127. package/scripts/unified-webhook-server.ts +29 -22
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Relay endpoint resolution.
3
+ *
4
+ * Resolves the relay WebSocket URL and the auth token from the SAME sources
5
+ * the relay server uses (see scripts/unified-webhook-server.ts), so a client
6
+ * and the server always agree:
7
+ *
8
+ * URL: RELAY_WS_URL env -> keys/relay-ws-url.txt -> derived from keys/telegram-webhook-url.txt
9
+ * Token: RELAY_AUTH_TOKEN env -> keys/relay-auth-token.txt -> keys/telegram-webhook-secret.txt
10
+ *
11
+ * The token value is never logged: only the NAME of the source it came from.
12
+ */
13
+
14
+ import { readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+
17
+ /** Environment variables consulted by the resolver. */
18
+ export interface RelayEndpointEnv {
19
+ RELAY_WS_URL?: string;
20
+ RELAY_AUTH_TOKEN?: string;
21
+ }
22
+
23
+ /** Result of a relay endpoint resolution. */
24
+ export interface RelayEndpointResolution {
25
+ /** WebSocket URL, or null when it could not be resolved. */
26
+ url: string | null;
27
+ /** Where the URL came from (e.g. "env RELAY_WS_URL"). Never contains a secret. */
28
+ urlSource: string | null;
29
+ /** Auth token, or null when it could not be resolved. */
30
+ token: string | null;
31
+ /** Where the token came from (e.g. "keys/telegram-webhook-secret.txt"). Never contains the token. */
32
+ tokenSource: string | null;
33
+ /** Human readable description of what is missing (never contains a token). */
34
+ missing: string[];
35
+ /** Human readable caveats about the resolved endpoint (never contains a token). */
36
+ notes: string[];
37
+ }
38
+
39
+ /** Options for {@link resolveRelayEndpoint}. */
40
+ export interface ResolveRelayEndpointOptions {
41
+ /** Environment to read (defaults to process.env). */
42
+ env?: RelayEndpointEnv;
43
+ /** Directory holding the key files (e.g. ~/.pi-harness-runtime/keys). */
44
+ keysDir: string;
45
+ /** Optional file reader override (used by the tests). */
46
+ readFile?: (path: string) => string | undefined;
47
+ }
48
+
49
+ /** Source names, in the order they are consulted. */
50
+ export const RELAY_URL_SOURCES = [
51
+ "env RELAY_WS_URL",
52
+ "keys/relay-ws-url.txt",
53
+ "keys/telegram-webhook-url.txt (derived)",
54
+ ] as const;
55
+
56
+ export const RELAY_TOKEN_SOURCES = [
57
+ "env RELAY_AUTH_TOKEN",
58
+ "keys/relay-auth-token.txt",
59
+ "keys/telegram-webhook-secret.txt",
60
+ ] as const;
61
+
62
+ /**
63
+ * The token sources are the SAME chain the relay server uses, so the client
64
+ * and the server can never disagree about the credential.
65
+ *
66
+ * Note: `keys/telegram-webhook-secret.txt` is also the secret the Telegram
67
+ * webhook expects in its `x-telegram-bot-api-secret-token` header. Sharing it
68
+ * with the WebSocket relay widens the blast radius of that secret - put a
69
+ * dedicated token in `keys/relay-auth-token.txt` (it is preferred) when the
70
+ * relay and the webhook do not have to share one.
71
+ *
72
+ * The Telegram webhook secret itself may also come from the environment
73
+ * variable TELEGRAM_WEBHOOK_SECRET; that variable belongs to the HTTP
74
+ * webhook, not to the relay, and is therefore not part of this chain.
75
+ */
76
+
77
+ /**
78
+ * Derive the relay WebSocket URL from the Telegram webhook URL.
79
+ *
80
+ * The derivation keeps the host (and port) of the webhook URL and replaces the
81
+ * path with /ws, because the relay is exposed on the same origin as the
82
+ * webhook:
83
+ *
84
+ * https://webhook.bunchee.online/telegram -> wss://webhook.bunchee.online/ws
85
+ *
86
+ * That only works when the origin serves /ws, i.e. behind the reverse proxy
87
+ * that maps /ws to the relay port (nginx: location /ws -> 127.0.0.1:8444).
88
+ * When the webhook URL addresses the application DIRECTLY, the relay listens
89
+ * on WS_PORT (default 8444), not on the HTTP port - use RELAY_WS_URL or
90
+ * keys/relay-ws-url.txt for that setup.
91
+ */
92
+ export function deriveRelayWsUrl(webhookUrl: string): string | null {
93
+ const trimmed = webhookUrl?.trim();
94
+ if (!trimmed) return null;
95
+ try {
96
+ const parsed = new URL(trimmed);
97
+ if (!parsed.host) return null;
98
+ const secure = parsed.protocol === "https:" || parsed.protocol === "wss:";
99
+ const scheme = secure ? "wss:" : "ws:";
100
+ return `${scheme}//${parsed.host}/ws`;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * True when the value looks like a WebSocket URL.
108
+ *
109
+ * Credentials ("wss://user:pass@host") and query strings are rejected: the
110
+ * resolved URL is written to the log, and neither belongs there.
111
+ */
112
+ export function isRelayWsUrl(value: string): boolean {
113
+ const trimmed = value?.trim() ?? "";
114
+ if (!/^wss?:\/\//i.test(trimmed)) return false;
115
+ if (trimmed.includes("@") || trimmed.includes("?")) return false;
116
+ try {
117
+ const parsed = new URL(trimmed);
118
+ if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") return false;
119
+ return parsed.host.length > 0;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ function defaultReadFile(path: string): string | undefined {
126
+ try {
127
+ return readFileSync(path, "utf-8");
128
+ } catch {
129
+ return undefined;
130
+ }
131
+ }
132
+
133
+ /** Host of a URL, or an empty string when it cannot be parsed. */
134
+ function hostOf(url: string): string {
135
+ try {
136
+ return new URL(url).host;
137
+ } catch {
138
+ return "";
139
+ }
140
+ }
141
+
142
+ /** True for hosts that address the machine itself. */
143
+ function isLoopbackHost(host: string): boolean {
144
+ const name = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "").toLowerCase();
145
+ return name === "127.0.0.1" || name === "localhost" || name === "::1" || name.startsWith("127.");
146
+ }
147
+
148
+ interface UrlResolution {
149
+ url: string | null;
150
+ source: string | null;
151
+ rejected: string[];
152
+ notes: string[];
153
+ }
154
+
155
+ interface TokenResolution {
156
+ token: string | null;
157
+ source: string | null;
158
+ }
159
+
160
+ /**
161
+ * Transport warnings for a resolved URL.
162
+ *
163
+ * A plaintext ws:// URL to a remote host sends the auth token in cleartext.
164
+ */
165
+ function transportNotes(url: string): string[] {
166
+ if (!url.toLowerCase().startsWith("ws://")) return [];
167
+ if (isLoopbackHost(hostOf(url))) return [];
168
+ return [
169
+ "the relay url is plaintext (ws://) for a remote host - the auth token is sent unencrypted, use wss://",
170
+ ];
171
+ }
172
+
173
+ /**
174
+ * Resolve the WebSocket URL: env -> dedicated key file -> derived from the
175
+ * telegram webhook url. The first usable source wins; a source that holds a
176
+ * rejected value is reported so the operator can see why it was skipped.
177
+ */
178
+ function resolveUrl(
179
+ env: RelayEndpointEnv,
180
+ readKey: (name: string) => string | undefined,
181
+ ): UrlResolution {
182
+ const rejected: string[] = [];
183
+ const notes: string[] = [];
184
+
185
+ const envUrl = env.RELAY_WS_URL?.trim();
186
+ if (envUrl) {
187
+ if (isRelayWsUrl(envUrl)) {
188
+ notes.push(...transportNotes(envUrl));
189
+ return { url: envUrl, source: "env RELAY_WS_URL", rejected, notes };
190
+ }
191
+ rejected.push("env RELAY_WS_URL (not a ws:// or wss:// URL)");
192
+ }
193
+
194
+ const fileUrl = readKey("relay-ws-url.txt");
195
+ if (fileUrl) {
196
+ if (isRelayWsUrl(fileUrl)) {
197
+ notes.push(...transportNotes(fileUrl));
198
+ return { url: fileUrl, source: "keys/relay-ws-url.txt", rejected, notes };
199
+ }
200
+ rejected.push("keys/relay-ws-url.txt (not a ws:// or wss:// URL)");
201
+ }
202
+
203
+ const webhookUrl = readKey("telegram-webhook-url.txt");
204
+ const derived = webhookUrl ? deriveRelayWsUrl(webhookUrl) : null;
205
+ if (derived) {
206
+ if (isLoopbackHost(hostOf(derived))) {
207
+ notes.push(
208
+ "derived url points at a loopback host: the relay listens on WS_PORT (default 8444), not on the webhook port - set RELAY_WS_URL or keys/relay-ws-url.txt",
209
+ );
210
+ }
211
+ notes.push(...transportNotes(derived));
212
+ return { url: derived, source: "keys/telegram-webhook-url.txt (derived)", rejected, notes };
213
+ }
214
+ if (webhookUrl) {
215
+ rejected.push("keys/telegram-webhook-url.txt (no ws:// or wss:// URL could be derived)");
216
+ }
217
+
218
+ return { url: null, source: null, rejected, notes };
219
+ }
220
+
221
+ /** Resolve the auth token: env -> dedicated key file -> webhook secret. */
222
+ function resolveToken(
223
+ env: RelayEndpointEnv,
224
+ readKey: (name: string) => string | undefined,
225
+ ): TokenResolution {
226
+ const envToken = env.RELAY_AUTH_TOKEN?.trim();
227
+ if (envToken) {
228
+ return { token: envToken, source: "env RELAY_AUTH_TOKEN" };
229
+ }
230
+
231
+ const fileToken = readKey("relay-auth-token.txt");
232
+ if (fileToken) {
233
+ return { token: fileToken, source: "keys/relay-auth-token.txt" };
234
+ }
235
+
236
+ const secret = readKey("telegram-webhook-secret.txt");
237
+ if (secret) {
238
+ return { token: secret, source: "keys/telegram-webhook-secret.txt" };
239
+ }
240
+
241
+ return { token: null, source: null };
242
+ }
243
+
244
+ /**
245
+ * Resolve the relay WebSocket URL and auth token.
246
+ *
247
+ * The first non-empty source wins. A blank file counts as missing (an empty
248
+ * value is a configuration mistake, not a valid endpoint). A source that holds
249
+ * a value which was REJECTED is reported in `missing` so the operator can see
250
+ * why the resolver moved on.
251
+ */
252
+ export function resolveRelayEndpoint(options: ResolveRelayEndpointOptions): RelayEndpointResolution {
253
+ const env = options.env ?? (process.env as RelayEndpointEnv);
254
+ const read = options.readFile ?? defaultReadFile;
255
+
256
+ const readKey = (name: string): string | undefined => {
257
+ try {
258
+ const raw = read(join(options.keysDir, name));
259
+ const value = raw?.trim();
260
+ return value ? value : undefined;
261
+ } catch {
262
+ // An unreadable key file is treated as absent - the source list in
263
+ // `missing` tells the operator where to look.
264
+ return undefined;
265
+ }
266
+ };
267
+
268
+ const urlResolution = resolveUrl(env, readKey);
269
+ const tokenResolution = resolveToken(env, readKey);
270
+
271
+ const missing: string[] = [];
272
+ if (!urlResolution.url) {
273
+ missing.push(`url not resolved (looked at ${RELAY_URL_SOURCES.join(", ")})`);
274
+ }
275
+ if (!tokenResolution.token) {
276
+ missing.push(`token not resolved (looked at ${RELAY_TOKEN_SOURCES.join(", ")})`);
277
+ }
278
+ for (const reason of urlResolution.rejected) {
279
+ missing.push(`rejected source: ${reason}`);
280
+ }
281
+
282
+ return {
283
+ url: urlResolution.url,
284
+ urlSource: urlResolution.source,
285
+ token: tokenResolution.token,
286
+ tokenSource: tokenResolution.source,
287
+ missing,
288
+ notes: urlResolution.notes,
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Log-safe description of a resolution (contains no token value).
294
+ */
295
+ export function describeRelayEndpoint(resolution: RelayEndpointResolution): string {
296
+ const parts: string[] = [];
297
+ parts.push(resolution.url ? `url from ${resolution.urlSource}` : "url NOT resolved");
298
+ parts.push(resolution.token ? `token from ${resolution.tokenSource}` : "token NOT resolved");
299
+ let description = parts.join(", ");
300
+ if (resolution.notes.length > 0) {
301
+ description += ` (${resolution.notes.join("; ")})`;
302
+ }
303
+ return description;
304
+ }
@@ -0,0 +1,278 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ deriveRelayWsUrl,
4
+ describeRelayEndpoint,
5
+ isRelayWsUrl,
6
+ resolveRelayEndpoint,
7
+ } from "../src/relay-endpoint.js";
8
+
9
+ /** Build a fake key directory reader. */
10
+ function makeKeys(files: Record<string, string>) {
11
+ return (path: string): string | undefined => {
12
+ const name = path.split("/").pop() ?? path;
13
+ return Object.prototype.hasOwnProperty.call(files, name) ? files[name] : undefined;
14
+ };
15
+ }
16
+
17
+ describe("deriveRelayWsUrl", () => {
18
+ test("maps an https webhook url to the relay websocket url", () => {
19
+ expect(deriveRelayWsUrl("https://webhook.bunchee.online/telegram")).toBe(
20
+ "wss://webhook.bunchee.online/ws",
21
+ );
22
+ });
23
+
24
+ test("documents the direct-app case: the relay listens on WS_PORT, not on the webhook port", () => {
25
+ // Behind a proxy (nginx `location /ws -> 127.0.0.1:8444`) the derived
26
+ // URL is correct. When the webhook URL addresses the app DIRECTLY the
27
+ // relay listens on WS_PORT (default 8444), so the derived URL is only
28
+ // a starting point - resolveRelayEndpoint() adds a note about it.
29
+ expect(deriveRelayWsUrl("http://127.0.0.1:8443/telegram")).toBe("ws://127.0.0.1:8443/ws");
30
+ });
31
+
32
+ test("keeps a wss url on the same host", () => {
33
+ expect(deriveRelayWsUrl("wss://webhook.bunchee.online/ws")).toBe("wss://webhook.bunchee.online/ws");
34
+ });
35
+
36
+ test("returns null for garbage", () => {
37
+ expect(deriveRelayWsUrl("not a url")).toBeNull();
38
+ expect(deriveRelayWsUrl("")).toBeNull();
39
+ });
40
+ });
41
+
42
+ describe("isRelayWsUrl", () => {
43
+ test("accepts ws and wss urls only", () => {
44
+ expect(isRelayWsUrl("ws://127.0.0.1:8444/ws")).toBe(true);
45
+ expect(isRelayWsUrl("wss://host/ws")).toBe(true);
46
+ expect(isRelayWsUrl("https://host/telegram")).toBe(false);
47
+ expect(isRelayWsUrl("")).toBe(false);
48
+ });
49
+
50
+ test("rejects urls that carry credentials or a query string", () => {
51
+ expect(isRelayWsUrl("wss://user:pass@host/ws")).toBe(false);
52
+ expect(isRelayWsUrl("wss://host/ws?token=secret")).toBe(false);
53
+ });
54
+ });
55
+
56
+ describe("resolveRelayEndpoint", () => {
57
+ test("prefers the environment over the key files", () => {
58
+ const resolution = resolveRelayEndpoint({
59
+ env: { RELAY_WS_URL: "wss://from-env/ws", RELAY_AUTH_TOKEN: "env-token" },
60
+ keysDir: "/keys",
61
+ readFile: makeKeys({
62
+ "relay-ws-url.txt": "wss://from-file/ws",
63
+ "relay-auth-token.txt": "file-token",
64
+ }),
65
+ });
66
+
67
+ expect(resolution.url).toBe("wss://from-env/ws");
68
+ expect(resolution.urlSource).toBe("env RELAY_WS_URL");
69
+ expect(resolution.token).toBe("env-token");
70
+ expect(resolution.tokenSource).toBe("env RELAY_AUTH_TOKEN");
71
+ expect(resolution.missing).toEqual([]);
72
+ });
73
+
74
+ test("falls back to the dedicated key files", () => {
75
+ const resolution = resolveRelayEndpoint({
76
+ env: {},
77
+ keysDir: "/keys",
78
+ readFile: makeKeys({
79
+ "relay-ws-url.txt": "wss://from-file/ws",
80
+ "relay-auth-token.txt": "file-token",
81
+ }),
82
+ });
83
+
84
+ expect(resolution.url).toBe("wss://from-file/ws");
85
+ expect(resolution.urlSource).toBe("keys/relay-ws-url.txt");
86
+ expect(resolution.token).toBe("file-token");
87
+ expect(resolution.tokenSource).toBe("keys/relay-auth-token.txt");
88
+ expect(resolution.missing).toEqual([]);
89
+ });
90
+
91
+ test("derives the url from the telegram webhook url and the token from the webhook secret", () => {
92
+ const resolution = resolveRelayEndpoint({
93
+ env: {},
94
+ keysDir: "/keys",
95
+ readFile: makeKeys({
96
+ "telegram-webhook-url.txt": "https://webhook.bunchee.online/telegram",
97
+ "telegram-webhook-secret.txt": "shared-secret",
98
+ }),
99
+ });
100
+
101
+ expect(resolution.url).toBe("wss://webhook.bunchee.online/ws");
102
+ expect(resolution.urlSource).toBe("keys/telegram-webhook-url.txt (derived)");
103
+ expect(resolution.token).toBe("shared-secret");
104
+ expect(resolution.tokenSource).toBe("keys/telegram-webhook-secret.txt");
105
+ expect(resolution.missing).toEqual([]);
106
+ expect(resolution.notes).toEqual([]);
107
+ });
108
+
109
+ test("reports what is missing when nothing is configured", () => {
110
+ const resolution = resolveRelayEndpoint({
111
+ env: {},
112
+ keysDir: "/keys",
113
+ readFile: makeKeys({}),
114
+ });
115
+
116
+ expect(resolution.url).toBeNull();
117
+ expect(resolution.token).toBeNull();
118
+ expect(resolution.missing).toHaveLength(2);
119
+ expect(resolution.missing[0]).toContain("url");
120
+ expect(resolution.missing[1]).toContain("token");
121
+ });
122
+
123
+ test("treats a blank key file as missing", () => {
124
+ const resolution = resolveRelayEndpoint({
125
+ env: {},
126
+ keysDir: "/keys",
127
+ readFile: makeKeys({
128
+ "relay-ws-url.txt": " \n",
129
+ "relay-auth-token.txt": "",
130
+ }),
131
+ });
132
+
133
+ expect(resolution.url).toBeNull();
134
+ expect(resolution.token).toBeNull();
135
+ expect(resolution.missing).toHaveLength(2);
136
+ });
137
+
138
+ test("ignores a key file that does not hold a websocket url", () => {
139
+ const resolution = resolveRelayEndpoint({
140
+ env: {},
141
+ keysDir: "/keys",
142
+ readFile: makeKeys({
143
+ "relay-ws-url.txt": "https://webhook.bunchee.online/telegram",
144
+ "telegram-webhook-url.txt": "https://webhook.bunchee.online/telegram",
145
+ }),
146
+ });
147
+
148
+ expect(resolution.url).toBe("wss://webhook.bunchee.online/ws");
149
+ expect(resolution.urlSource).toBe("keys/telegram-webhook-url.txt (derived)");
150
+ expect(resolution.missing.join(" ")).toContain(
151
+ "rejected source: keys/relay-ws-url.txt (not a ws:// or wss:// URL)",
152
+ );
153
+ });
154
+
155
+ test("rejects an env url that carries credentials", () => {
156
+ const resolution = resolveRelayEndpoint({
157
+ env: { RELAY_WS_URL: "wss://user:pass@host/ws" },
158
+ keysDir: "/keys",
159
+ readFile: makeKeys({ "relay-ws-url.txt": "wss://from-file/ws" }),
160
+ });
161
+
162
+ expect(resolution.url).toBe("wss://from-file/ws");
163
+ expect(resolution.missing.join(" ")).toContain("rejected source: env RELAY_WS_URL");
164
+ // The rejected value must not be echoed back - it may hold a secret.
165
+ expect(resolution.missing.join(" ")).not.toContain("user:pass");
166
+ expect(describeRelayEndpoint(resolution)).not.toContain("user:pass");
167
+ });
168
+
169
+ test("warns when a remote host would be used over plaintext ws", () => {
170
+ const resolution = resolveRelayEndpoint({
171
+ env: { RELAY_WS_URL: "ws://relay.example.com/ws" },
172
+ keysDir: "/keys",
173
+ readFile: makeKeys({}),
174
+ });
175
+
176
+ expect(resolution.url).toBe("ws://relay.example.com/ws");
177
+ expect(resolution.notes.join(" ")).toContain("plaintext");
178
+ });
179
+
180
+ test("does not warn about a plaintext ws url on the loopback interface", () => {
181
+ const resolution = resolveRelayEndpoint({
182
+ env: { RELAY_WS_URL: "ws://127.0.0.1:8444/ws" },
183
+ keysDir: "/keys",
184
+ readFile: makeKeys({}),
185
+ });
186
+
187
+ expect(resolution.notes.join(" ")).not.toContain("plaintext");
188
+ });
189
+
190
+ test("never leaks the token in the description", () => {
191
+ const resolution = resolveRelayEndpoint({
192
+ env: {},
193
+ keysDir: "/keys",
194
+ readFile: makeKeys({
195
+ "relay-ws-url.txt": "wss://host/ws",
196
+ "relay-auth-token.txt": "super-secret-token",
197
+ }),
198
+ });
199
+
200
+ const description = describeRelayEndpoint(resolution);
201
+ expect(description).toContain("url from keys/relay-ws-url.txt");
202
+ expect(description).toContain("token from keys/relay-auth-token.txt");
203
+ expect(description).not.toContain("super-secret-token");
204
+ });
205
+
206
+ test("describes a missing endpoint without a secret", () => {
207
+ const resolution = resolveRelayEndpoint({ env: {}, keysDir: "/keys", readFile: makeKeys({}) });
208
+ const description = describeRelayEndpoint(resolution);
209
+ expect(description).toContain("url NOT resolved");
210
+ expect(description).toContain("token NOT resolved");
211
+ // The source list lives in `missing`; the description must not repeat it.
212
+ expect(description).not.toContain("looked at");
213
+ });
214
+
215
+ test("records a rejected env url so the operator can see why it was skipped", () => {
216
+ const resolution = resolveRelayEndpoint({
217
+ env: { RELAY_WS_URL: "https://host/telegram" },
218
+ keysDir: "/keys",
219
+ readFile: makeKeys({ "relay-ws-url.txt": "wss://from-file/ws" }),
220
+ });
221
+
222
+ expect(resolution.url).toBe("wss://from-file/ws");
223
+ expect(resolution.urlSource).toBe("keys/relay-ws-url.txt");
224
+ expect(resolution.missing.join(" ")).toContain("rejected source: env RELAY_WS_URL");
225
+ });
226
+
227
+ test("treats an unreadable key file as absent", () => {
228
+ const resolution = resolveRelayEndpoint({
229
+ env: {},
230
+ keysDir: "/keys",
231
+ readFile: () => {
232
+ throw new Error("EACCES");
233
+ },
234
+ });
235
+
236
+ expect(resolution.url).toBeNull();
237
+ expect(resolution.token).toBeNull();
238
+ expect(resolution.missing).toHaveLength(2);
239
+ });
240
+
241
+ test("resolves nothing when the keys directory does not exist", () => {
242
+ const resolution = resolveRelayEndpoint({
243
+ env: {},
244
+ keysDir: "/definitely/not/a/keys/dir",
245
+ });
246
+
247
+ expect(resolution.url).toBeNull();
248
+ expect(resolution.token).toBeNull();
249
+ expect(resolution.missing).toHaveLength(2);
250
+ });
251
+
252
+ test("trims a token that ends with CRLF", () => {
253
+ const resolution = resolveRelayEndpoint({
254
+ env: {},
255
+ keysDir: "/keys",
256
+ readFile: makeKeys({
257
+ "relay-ws-url.txt": "wss://host/ws",
258
+ "relay-auth-token.txt": "token-with-crlf\r\n",
259
+ }),
260
+ });
261
+
262
+ expect(resolution.token).toBe("token-with-crlf");
263
+ });
264
+
265
+ test("warns when the derived url points at a loopback host", () => {
266
+ const resolution = resolveRelayEndpoint({
267
+ env: {},
268
+ keysDir: "/keys",
269
+ readFile: makeKeys({
270
+ "telegram-webhook-url.txt": "http://127.0.0.1:8443/telegram",
271
+ }),
272
+ });
273
+
274
+ expect(resolution.url).toBe("ws://127.0.0.1:8443/ws");
275
+ expect(resolution.notes.join(" ")).toContain("WS_PORT");
276
+ expect(describeRelayEndpoint(resolution)).toContain("WS_PORT");
277
+ });
278
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi/release-manager",
3
- "version": "1.1.387",
3
+ "version": "1.1.388",
4
4
  "type": "module",
5
5
  "description": "Release management for pi-harness",
6
6
  "main": "dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-harness/requirement-compiler",
3
- "version": "1.1.387",
3
+ "version": "1.1.388",
4
4
  "description": "Compiles raw requirements into validated CompiledRequirement with ambiguity detection and risk tagging",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-harness/runtime",
3
- "version": "1.1.387",
3
+ "version": "1.1.388",
4
4
  "description": "pi-harness-runtime — runtime",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-harness/scheduler",
3
- "version": "1.1.387",
3
+ "version": "1.1.388",
4
4
  "description": "pi-harness-runtime — scheduler",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi/scheduler-adapter",
3
- "version": "1.1.387",
3
+ "version": "1.1.388",
4
4
  "description": "Scheduler Adapter — cron, systemd, launchd, internal adapters for autonomous runtime (RFC-0101 §6)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi/session",
3
- "version": "1.1.387",
3
+ "version": "1.1.388",
4
4
  "description": "Session management system for pi-harness-runtime",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi/session-api",
3
- "version": "1.1.387",
3
+ "version": "1.1.388",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi/session-export",
3
- "version": "1.1.387",
3
+ "version": "1.1.388",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-harness/shared-context",
3
- "version": "1.1.387",
3
+ "version": "1.1.388",
4
4
  "description": "pi-harness-runtime — shared-context",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi/skill-mcp-client",
3
- "version": "1.1.387",
3
+ "version": "1.1.388",
4
4
  "description": "MCP client for Skills SaaS backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-harness/skill-registry",
3
- "version": "1.1.387",
3
+ "version": "1.1.388",
4
4
  "description": "Skill Registry for pi-harness-runtime — dynamic skill discovery and invocation",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",