@agent-team-foundation/first-tree-hub 0.9.8 → 0.9.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{bootstrap-DWifXj9b.mjs → bootstrap-hh_PkTu6.mjs} +16 -0
- package/dist/cli/index.mjs +15 -5
- package/dist/{core-DKA6g1lL.mjs → core-B2YUTpgg.mjs} +1057 -57
- package/dist/drizzle/0023_clients_org_scoping.sql +40 -0
- package/dist/drizzle/meta/_journal.json +7 -0
- package/dist/{feishu-CRNUI05I.mjs → feishu-B1Kiq7S6.mjs} +0 -1
- package/dist/index.mjs +4 -4
- package/dist/web/assets/{index-Cp24nDIg.js → index-DkzjED0c.js} +1 -1
- package/dist/web/index.html +15 -1
- package/package.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { m as __toESM } from "./esm-CYu4tXXn.mjs";
|
|
2
2
|
import { _ as withSpan, a as endWsConnectionSpan, b as require_pino, c as messageAttrs, d as rootLogger$1, g as startWsConnectionSpan, i as currentTraceId, n as applyLoggerConfig, o as getFastifyOtelPlugin, p as setWsConnectionAttrs, r as createLogger$1, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-DV_fQKqV-CuLWzBxQ.mjs";
|
|
3
3
|
import { s as formatPrettyEntry$1, t as LOG_LEVELS$1, u as parseLogLevel$1 } from "./logger-core-BTmvdflj-DhdipBkV.mjs";
|
|
4
|
-
import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, d as DEFAULT_HOME_DIR$1, f as agentConfigSchema, g as initConfig, i as loadCredentials, l as DEFAULT_CONFIG_DIR, m as collectMissingPrompts, n as ensureFreshAccessToken, o as resolveServerUrl, p as clientConfigSchema, s as saveAgentConfig, u as DEFAULT_DATA_DIR$1, v as migrateLegacyHome, w as setConfigValue } from "./bootstrap-
|
|
5
|
-
import { $ as sessionStateMessageSchema, A as createMemberSchema, B as loginSchema, C as agentTypeSchema$1, D as createAdapterMappingSchema, E as createAdapterConfigSchema, F as extractMentions, G as runtimeStateMessageSchema, H as notificationQuerySchema, I as imageInlineContentSchema, J as sendToAgentSchema, K as selfServiceFeishuBotSchema, L as inboxPollQuerySchema, M as createTaskSchema, N as delegateFeishuUserSchema, O as createAgentSchema, P as dryRunAgentRuntimeConfigSchema, Q as sessionReconcileRequestSchema, R as isRedactedEnvValue, S as agentRuntimeConfigPayloadSchema$1, T as connectTokenExchangeSchema, U as paginationQuerySchema, V as messageSourceSchema$1, W as refreshTokenSchema, X as sessionEventMessageSchema, Y as sessionCompletionMessageSchema, Z as sessionEventSchema$1, _ as addParticipantSchema, a as AGENT_SELECTOR_HEADER$1, at as updateMemberSchema, b as agentBindRequestSchema, c as AGENT_TYPES, ct as updateTaskStatusSchema, d as SYSTEM_CONFIG_DEFAULTS, et as taskListQuerySchema, f as TASK_CREATOR_TYPES, g as WS_AUTH_FRAME_TIMEOUT_MS, h as TASK_TERMINAL_STATUSES, i as AGENT_BIND_REJECT_REASONS, it as updateChatSchema, j as createOrganizationSchema, k as createChatSchema, l as AGENT_VISIBILITY, lt as wsAuthFrameSchema, m as TASK_STATUSES, nt as updateAgentRuntimeConfigSchema, o as AGENT_SOURCES, ot as updateOrganizationSchema, p as TASK_HEALTH_SIGNALS, q as sendMessageSchema, rt as updateAgentSchema, s as AGENT_STATUSES, st as updateSystemConfigSchema, tt as updateAdapterConfigSchema, u as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, v as adminCreateTaskSchema, w as clientRegisterSchema, x as agentPinnedMessageSchema$1, y as adminUpdateTaskSchema, z as linkTaskChatSchema } from "./feishu-
|
|
4
|
+
import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, d as DEFAULT_HOME_DIR$1, f as agentConfigSchema, g as initConfig, i as loadCredentials, l as DEFAULT_CONFIG_DIR, m as collectMissingPrompts, n as ensureFreshAccessToken, o as resolveServerUrl, p as clientConfigSchema, s as saveAgentConfig, u as DEFAULT_DATA_DIR$1, v as migrateLegacyHome, w as setConfigValue } from "./bootstrap-hh_PkTu6.mjs";
|
|
5
|
+
import { $ as sessionStateMessageSchema, A as createMemberSchema, B as loginSchema, C as agentTypeSchema$1, D as createAdapterMappingSchema, E as createAdapterConfigSchema, F as extractMentions, G as runtimeStateMessageSchema, H as notificationQuerySchema, I as imageInlineContentSchema, J as sendToAgentSchema, K as selfServiceFeishuBotSchema, L as inboxPollQuerySchema, M as createTaskSchema, N as delegateFeishuUserSchema, O as createAgentSchema, P as dryRunAgentRuntimeConfigSchema, Q as sessionReconcileRequestSchema, R as isRedactedEnvValue, S as agentRuntimeConfigPayloadSchema$1, T as connectTokenExchangeSchema, U as paginationQuerySchema, V as messageSourceSchema$1, W as refreshTokenSchema, X as sessionEventMessageSchema, Y as sessionCompletionMessageSchema, Z as sessionEventSchema$1, _ as addParticipantSchema, a as AGENT_SELECTOR_HEADER$1, at as updateMemberSchema, b as agentBindRequestSchema, c as AGENT_TYPES, ct as updateTaskStatusSchema, d as SYSTEM_CONFIG_DEFAULTS, et as taskListQuerySchema, f as TASK_CREATOR_TYPES, g as WS_AUTH_FRAME_TIMEOUT_MS, h as TASK_TERMINAL_STATUSES, i as AGENT_BIND_REJECT_REASONS, it as updateChatSchema, j as createOrganizationSchema, k as createChatSchema, l as AGENT_VISIBILITY, lt as wsAuthFrameSchema, m as TASK_STATUSES, nt as updateAgentRuntimeConfigSchema, o as AGENT_SOURCES, ot as updateOrganizationSchema, p as TASK_HEALTH_SIGNALS, q as sendMessageSchema, rt as updateAgentSchema, s as AGENT_STATUSES, st as updateSystemConfigSchema, tt as updateAdapterConfigSchema, u as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, v as adminCreateTaskSchema, w as clientRegisterSchema, x as agentPinnedMessageSchema$1, y as adminUpdateTaskSchema, z as linkTaskChatSchema } from "./feishu-B1Kiq7S6.mjs";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import { ZodError, z } from "zod";
|
|
8
8
|
import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
|
@@ -13,7 +13,7 @@ import { closeSync, copyFileSync, createReadStream, existsSync, mkdirSync, openS
|
|
|
13
13
|
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
14
14
|
import WebSocket from "ws";
|
|
15
15
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
16
|
-
import { stringify } from "yaml";
|
|
16
|
+
import { parse, stringify } from "yaml";
|
|
17
17
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
18
18
|
import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
|
|
19
19
|
import * as semver from "semver";
|
|
@@ -21,9 +21,9 @@ import bcrypt from "bcrypt";
|
|
|
21
21
|
import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
|
|
22
22
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
23
23
|
import postgres from "postgres";
|
|
24
|
+
import { confirm, input, password, select } from "@inquirer/prompts";
|
|
24
25
|
import { fileURLToPath } from "node:url";
|
|
25
26
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
|
26
|
-
import { confirm, input, password, select } from "@inquirer/prompts";
|
|
27
27
|
import cors from "@fastify/cors";
|
|
28
28
|
import rateLimit from "@fastify/rate-limit";
|
|
29
29
|
import fastifyStatic from "@fastify/static";
|
|
@@ -461,7 +461,6 @@ z.object({
|
|
|
461
461
|
inboxId: z.string(),
|
|
462
462
|
status: z.string(),
|
|
463
463
|
source: z.string().nullable().optional(),
|
|
464
|
-
cloudUserId: z.string().nullable().optional(),
|
|
465
464
|
visibility: agentVisibilitySchema,
|
|
466
465
|
metadata: z.record(z.string(), z.unknown()),
|
|
467
466
|
managerId: z.string().nullable(),
|
|
@@ -1329,6 +1328,22 @@ defineConfig({
|
|
|
1329
1328
|
}),
|
|
1330
1329
|
hubPublicUrl: field(z.string(), { env: "FIRST_TREE_HUB_PUBLIC_URL" })
|
|
1331
1330
|
}),
|
|
1331
|
+
feedback: optional({
|
|
1332
|
+
repo: field(z.string(), { env: "HEARBACK_FEEDBACK_REPO" }),
|
|
1333
|
+
githubToken: field(z.string(), {
|
|
1334
|
+
env: "HEARBACK_GITHUB_TOKEN",
|
|
1335
|
+
secret: true
|
|
1336
|
+
}),
|
|
1337
|
+
llm: optional({
|
|
1338
|
+
apiKey: field(z.string(), {
|
|
1339
|
+
env: "LLM_API_KEY",
|
|
1340
|
+
secret: true
|
|
1341
|
+
}),
|
|
1342
|
+
baseUrl: field(z.string().optional(), { env: "LLM_BASE_URL" }),
|
|
1343
|
+
model: field(z.string().optional(), { env: "LLM_MODEL" })
|
|
1344
|
+
}),
|
|
1345
|
+
trustProxyHeaders: field(z.boolean().default(false), { env: "HEARBACK_TRUST_PROXY_HEADERS" })
|
|
1346
|
+
}),
|
|
1332
1347
|
observability: {
|
|
1333
1348
|
logging: {
|
|
1334
1349
|
level: field(logLevelSchema.default("info"), { env: "FIRST_TREE_HUB_LOG_LEVEL" }),
|
|
@@ -1511,6 +1526,19 @@ var SdkError = class extends Error {
|
|
|
1511
1526
|
this.name = "SdkError";
|
|
1512
1527
|
}
|
|
1513
1528
|
};
|
|
1529
|
+
/**
|
|
1530
|
+
* Thrown (emitted on `error` and rejected from `connect()`) when the server
|
|
1531
|
+
* refuses a `client:register` because the local clientId is bound to a
|
|
1532
|
+
* different organization. The CLI layer detects this via `instanceof` and
|
|
1533
|
+
* prompts the user before rotating the local clientId.
|
|
1534
|
+
*/
|
|
1535
|
+
var ClientOrgMismatchError$1 = class extends Error {
|
|
1536
|
+
code = "CLIENT_ORG_MISMATCH";
|
|
1537
|
+
constructor(message = "Client belongs to a different organization") {
|
|
1538
|
+
super(message);
|
|
1539
|
+
this.name = "ClientOrgMismatchError";
|
|
1540
|
+
}
|
|
1541
|
+
};
|
|
1514
1542
|
const RECONNECT_BASE_MS = 1e3;
|
|
1515
1543
|
const RECONNECT_MAX_MS = 3e4;
|
|
1516
1544
|
const WS_CONNECT_TIMEOUT_MS = 1e4;
|
|
@@ -1566,6 +1594,12 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1566
1594
|
registered = false;
|
|
1567
1595
|
/** Count of `server:welcome` frames received; drives `isReconnect` flag. */
|
|
1568
1596
|
welcomeFramesReceived = 0;
|
|
1597
|
+
/**
|
|
1598
|
+
* Last handshake error, stashed for the `close` handler to surface a typed
|
|
1599
|
+
* reason (e.g. {@link ClientOrgMismatchError}) instead of a generic
|
|
1600
|
+
* "closed before ready" when `connect()` is pending.
|
|
1601
|
+
*/
|
|
1602
|
+
lastHandshakeError = null;
|
|
1569
1603
|
wsLogger;
|
|
1570
1604
|
authLogger;
|
|
1571
1605
|
boundAgents = /* @__PURE__ */ new Map();
|
|
@@ -1750,7 +1784,9 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1750
1784
|
this.rejectAllPendingBinds("WebSocket closed");
|
|
1751
1785
|
if (!settled) {
|
|
1752
1786
|
this.wsLogger.warn({ code }, "closed before ready");
|
|
1753
|
-
|
|
1787
|
+
const typedErr = this.lastHandshakeError;
|
|
1788
|
+
this.lastHandshakeError = null;
|
|
1789
|
+
settle(reject, typedErr ?? /* @__PURE__ */ new Error(`WebSocket closed before ready (code ${code})`));
|
|
1754
1790
|
return;
|
|
1755
1791
|
}
|
|
1756
1792
|
this.wsLogger.warn({
|
|
@@ -1803,8 +1839,15 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1803
1839
|
return;
|
|
1804
1840
|
}
|
|
1805
1841
|
if (type === "client:register:rejected") {
|
|
1806
|
-
const
|
|
1807
|
-
|
|
1842
|
+
const code = typeof msg.code === "string" ? msg.code : void 0;
|
|
1843
|
+
const message = typeof msg.message === "string" ? msg.message : "unknown";
|
|
1844
|
+
this.closing = true;
|
|
1845
|
+
const err = code === "CLIENT_ORG_MISMATCH" ? new ClientOrgMismatchError$1(message) : /* @__PURE__ */ new Error(`client:register rejected: ${message}`);
|
|
1846
|
+
this.lastHandshakeError = err;
|
|
1847
|
+
this.wsLogger.error({
|
|
1848
|
+
code,
|
|
1849
|
+
message
|
|
1850
|
+
}, "client register rejected");
|
|
1808
1851
|
this.emit("error", err);
|
|
1809
1852
|
this.ws?.close(4403, "register rejected");
|
|
1810
1853
|
return;
|
|
@@ -4838,6 +4881,96 @@ function makeUuidV7() {
|
|
|
4838
4881
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
4839
4882
|
}
|
|
4840
4883
|
//#endregion
|
|
4884
|
+
//#region src/core/client-reidentify.ts
|
|
4885
|
+
/**
|
|
4886
|
+
* Handle a `CLIENT_ORG_MISMATCH` from the server by rotating the local
|
|
4887
|
+
* `client.id` in `client.yaml`. The server binds every client to one org for
|
|
4888
|
+
* its lifetime; when the user's credentials move to a different org, the old
|
|
4889
|
+
* clientId becomes unusable and a new one must be issued locally. The old
|
|
4890
|
+
* yaml is preserved as `client.yaml.bak` so the operator can recover or
|
|
4891
|
+
* audit the previous identity.
|
|
4892
|
+
*
|
|
4893
|
+
* Returns the generated clientId. The caller is expected to reset the config
|
|
4894
|
+
* singleton and re-run its initialization so the new id takes effect.
|
|
4895
|
+
*/
|
|
4896
|
+
function rotateClientIdWithBackup(configDir) {
|
|
4897
|
+
const yamlPath = join(configDir, "client.yaml");
|
|
4898
|
+
const backupPath = join(configDir, "client.yaml.bak");
|
|
4899
|
+
if (!existsSync(yamlPath)) throw new Error(`Cannot rotate client id — ${yamlPath} does not exist.`);
|
|
4900
|
+
const raw = readFileSync(yamlPath, "utf-8");
|
|
4901
|
+
copyFileSync(yamlPath, backupPath);
|
|
4902
|
+
const parsed = parse(raw);
|
|
4903
|
+
const current = typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
4904
|
+
const clientSection = typeof current.client === "object" && current.client !== null ? current.client : {};
|
|
4905
|
+
const oldId = typeof clientSection.id === "string" ? clientSection.id : null;
|
|
4906
|
+
const newId = `client_${randomBytes(4).toString("hex")}`;
|
|
4907
|
+
writeFileSync(yamlPath, stringify({
|
|
4908
|
+
...current,
|
|
4909
|
+
client: {
|
|
4910
|
+
...clientSection,
|
|
4911
|
+
id: newId
|
|
4912
|
+
}
|
|
4913
|
+
}), { mode: 384 });
|
|
4914
|
+
return {
|
|
4915
|
+
oldId,
|
|
4916
|
+
newId,
|
|
4917
|
+
backupPath,
|
|
4918
|
+
yamlPath
|
|
4919
|
+
};
|
|
4920
|
+
}
|
|
4921
|
+
/**
|
|
4922
|
+
* Shared handler for `CLIENT_ORG_MISMATCH` across CLI entry points
|
|
4923
|
+
* (`client start` and `client connect --no-service`). Prompts interactively,
|
|
4924
|
+
* rotates the local clientId, and always exits the current process — the
|
|
4925
|
+
* runtime is already poisoned (wrong clientId in memory), so continuing
|
|
4926
|
+
* in-band is not safe. Service-supervised (managed) runs skip the prompt and
|
|
4927
|
+
* leave an audit trail in pino so operators can trace `.bak` files later.
|
|
4928
|
+
*
|
|
4929
|
+
* Exits with:
|
|
4930
|
+
* - 0 after a successful rotate (operator is told how to re-run).
|
|
4931
|
+
* - 1 if the user declines or rotation itself fails.
|
|
4932
|
+
*/
|
|
4933
|
+
async function handleClientOrgMismatch(err, opts) {
|
|
4934
|
+
print.blank();
|
|
4935
|
+
print.line(" ⚠️ This machine is registered as a client in a different organization.\n");
|
|
4936
|
+
print.line(` Server message: ${err.message}\n`);
|
|
4937
|
+
print.blank();
|
|
4938
|
+
if (!(opts.managed ? true : await confirm({
|
|
4939
|
+
message: "Rotate the local client identity and register fresh?",
|
|
4940
|
+
default: true
|
|
4941
|
+
}).catch(() => false))) {
|
|
4942
|
+
print.line(" Aborted — no changes made.\n");
|
|
4943
|
+
process.exit(1);
|
|
4944
|
+
}
|
|
4945
|
+
try {
|
|
4946
|
+
const { oldId, newId, backupPath } = rotateClientIdWithBackup(opts.configDir);
|
|
4947
|
+
if (opts.managed) createLogger("client").warn({
|
|
4948
|
+
oldId,
|
|
4949
|
+
newId,
|
|
4950
|
+
backupPath
|
|
4951
|
+
}, "client identity rotated on CLIENT_ORG_MISMATCH (managed mode)");
|
|
4952
|
+
print.blank();
|
|
4953
|
+
print.line(` ✓ Rotated local client identity.\n`);
|
|
4954
|
+
print.line(` old clientId: ${oldId ?? "(unset)"}\n`);
|
|
4955
|
+
print.line(` new clientId: ${newId}\n`);
|
|
4956
|
+
print.line(` previous yaml backed up to: ${backupPath}\n`);
|
|
4957
|
+
print.blank();
|
|
4958
|
+
print.line(" Note: the old client remains in the previous org. That org's admin\n");
|
|
4959
|
+
print.line(" can remove it if cleanup is needed.\n");
|
|
4960
|
+
print.blank();
|
|
4961
|
+
if (opts.managed) print.line(" The background service will pick up the new identity on its next restart.\n\n");
|
|
4962
|
+
else {
|
|
4963
|
+
print.line(" To reconnect with the new identity, run:\n\n");
|
|
4964
|
+
print.line(` ${opts.rerunCommand}\n\n`);
|
|
4965
|
+
}
|
|
4966
|
+
process.exit(0);
|
|
4967
|
+
} catch (rotateErr) {
|
|
4968
|
+
const rmsg = rotateErr instanceof Error ? rotateErr.message : String(rotateErr);
|
|
4969
|
+
print.line(` Failed to rotate client identity: ${rmsg}\n`);
|
|
4970
|
+
process.exit(1);
|
|
4971
|
+
}
|
|
4972
|
+
}
|
|
4973
|
+
//#endregion
|
|
4841
4974
|
//#region src/core/client-runtime.ts
|
|
4842
4975
|
/**
|
|
4843
4976
|
* Client runtime — one shared ClientConnection, multiple agents multiplexed.
|
|
@@ -6146,7 +6279,7 @@ async function onboardCreate(args) {
|
|
|
6146
6279
|
}
|
|
6147
6280
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
6148
6281
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
6149
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
6282
|
+
const { bindFeishuBot } = await import("./feishu-B1Kiq7S6.mjs").then((n) => n.r);
|
|
6150
6283
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
6151
6284
|
if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
6152
6285
|
else {
|
|
@@ -6287,7 +6420,764 @@ function setNestedByDot(obj, dotPath, value) {
|
|
|
6287
6420
|
if (lastKey !== void 0) current[lastKey] = value;
|
|
6288
6421
|
}
|
|
6289
6422
|
//#endregion
|
|
6290
|
-
//#region
|
|
6423
|
+
//#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/schema.js
|
|
6424
|
+
const FeedbackType = z.enum(["bug", "feature"]);
|
|
6425
|
+
const BrowserContext = z.object({
|
|
6426
|
+
url: z.string().optional(),
|
|
6427
|
+
userAgent: z.string().optional(),
|
|
6428
|
+
browser: z.string().optional(),
|
|
6429
|
+
os: z.string().optional(),
|
|
6430
|
+
viewport: z.string().optional()
|
|
6431
|
+
});
|
|
6432
|
+
const ConsoleError = z.object({
|
|
6433
|
+
message: z.string(),
|
|
6434
|
+
stack: z.string().optional(),
|
|
6435
|
+
timestamp: z.string().optional()
|
|
6436
|
+
});
|
|
6437
|
+
const FailedRequest = z.object({
|
|
6438
|
+
method: z.string(),
|
|
6439
|
+
url: z.string(),
|
|
6440
|
+
status: z.number().optional(),
|
|
6441
|
+
statusText: z.string().optional()
|
|
6442
|
+
});
|
|
6443
|
+
const AgentContext = z.object({
|
|
6444
|
+
toolCalls: z.array(z.object({
|
|
6445
|
+
name: z.string(),
|
|
6446
|
+
error: z.string().optional()
|
|
6447
|
+
})).optional(),
|
|
6448
|
+
errorTraces: z.array(z.string()).optional(),
|
|
6449
|
+
environment: z.record(z.string()).optional()
|
|
6450
|
+
});
|
|
6451
|
+
const FeedbackContext = z.object({
|
|
6452
|
+
source: z.enum(["web-plugin", "agent-skill"]),
|
|
6453
|
+
browser: BrowserContext.optional(),
|
|
6454
|
+
consoleErrors: z.array(ConsoleError).optional(),
|
|
6455
|
+
failedRequests: z.array(FailedRequest).optional(),
|
|
6456
|
+
agent: AgentContext.optional(),
|
|
6457
|
+
screenshotUrl: z.string().optional(),
|
|
6458
|
+
timestamp: z.string().optional(),
|
|
6459
|
+
extra: z.record(z.string()).optional()
|
|
6460
|
+
});
|
|
6461
|
+
const ReporterInfo = z.object({ email: z.string().email().optional() });
|
|
6462
|
+
const FeedbackReport = z.object({
|
|
6463
|
+
type: FeedbackType,
|
|
6464
|
+
title: z.string().min(5, "Title must be at least 5 characters"),
|
|
6465
|
+
description: z.string(),
|
|
6466
|
+
context: FeedbackContext,
|
|
6467
|
+
reporter: ReporterInfo.optional()
|
|
6468
|
+
});
|
|
6469
|
+
//#endregion
|
|
6470
|
+
//#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/errors.js
|
|
6471
|
+
var HearbackError = class extends Error {
|
|
6472
|
+
code;
|
|
6473
|
+
constructor(message, code) {
|
|
6474
|
+
super(message);
|
|
6475
|
+
this.code = code;
|
|
6476
|
+
this.name = "HearbackError";
|
|
6477
|
+
}
|
|
6478
|
+
};
|
|
6479
|
+
var GitHubAuthError = class extends HearbackError {
|
|
6480
|
+
constructor(status, message, url = "") {
|
|
6481
|
+
let hint;
|
|
6482
|
+
if (status === 401) hint = "GitHub token is missing or expired.";
|
|
6483
|
+
else if (status === 403) if (url.includes("/contents/") || url.includes("/git/refs")) hint = "Token lacks \"contents:write\" scope. Needed for screenshot uploads and saving subscribers to the hearback-data branch.";
|
|
6484
|
+
else if (url.includes("/issues") || url.includes("/labels")) hint = "Token lacks \"issues:write\" scope. Needed to create issues, labels, and reactions.";
|
|
6485
|
+
else hint = `GitHub returned 403 (forbidden) for ${url || "API call"}. Check token permissions.`;
|
|
6486
|
+
else hint = `GitHub API returned ${status}: ${message}`;
|
|
6487
|
+
super(hint, "GITHUB_AUTH_ERROR");
|
|
6488
|
+
}
|
|
6489
|
+
};
|
|
6490
|
+
var GitHubRateLimitError = class extends HearbackError {
|
|
6491
|
+
resetAt;
|
|
6492
|
+
constructor(resetAt) {
|
|
6493
|
+
super(`GitHub API rate limit exceeded. Resets at ${resetAt.toISOString()}`, "GITHUB_RATE_LIMIT");
|
|
6494
|
+
this.resetAt = resetAt;
|
|
6495
|
+
}
|
|
6496
|
+
};
|
|
6497
|
+
//#endregion
|
|
6498
|
+
//#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/github.js
|
|
6499
|
+
function parseRepo(repo) {
|
|
6500
|
+
const parts = repo.split("/");
|
|
6501
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) throw new HearbackError(`Invalid repo format: "${repo}". Expected "owner/name".`, "INVALID_REPO");
|
|
6502
|
+
return {
|
|
6503
|
+
owner: parts[0],
|
|
6504
|
+
name: parts[1]
|
|
6505
|
+
};
|
|
6506
|
+
}
|
|
6507
|
+
async function githubFetch(config, path, options = {}) {
|
|
6508
|
+
const base = config.apiBase ?? "https://api.github.com";
|
|
6509
|
+
const url = path.startsWith("http") ? path : `${base}${path}`;
|
|
6510
|
+
const res = await fetch(url, {
|
|
6511
|
+
...options,
|
|
6512
|
+
headers: {
|
|
6513
|
+
Accept: "application/vnd.github.v3+json",
|
|
6514
|
+
Authorization: `Bearer ${config.token}`,
|
|
6515
|
+
"User-Agent": "hearback",
|
|
6516
|
+
...options.headers
|
|
6517
|
+
}
|
|
6518
|
+
});
|
|
6519
|
+
if (res.status === 401 || res.status === 403) throw new GitHubAuthError(res.status, await res.text(), path);
|
|
6520
|
+
if (res.status === 429) {
|
|
6521
|
+
const resetHeader = res.headers.get("x-ratelimit-reset");
|
|
6522
|
+
throw new GitHubRateLimitError(resetHeader ? /* @__PURE__ */ new Date(Number(resetHeader) * 1e3) : /* @__PURE__ */ new Date());
|
|
6523
|
+
}
|
|
6524
|
+
return res;
|
|
6525
|
+
}
|
|
6526
|
+
async function createIssue(config, params) {
|
|
6527
|
+
const { owner, name } = parseRepo(config.repo);
|
|
6528
|
+
const res = await githubFetch(config, `/repos/${owner}/${name}/issues`, {
|
|
6529
|
+
method: "POST",
|
|
6530
|
+
body: JSON.stringify(params),
|
|
6531
|
+
headers: { "Content-Type": "application/json" }
|
|
6532
|
+
});
|
|
6533
|
+
if (!res.ok) throw new HearbackError(`Failed to create issue: ${res.status} ${await res.text()}`, "GITHUB_CREATE_ISSUE_FAILED");
|
|
6534
|
+
return res.json();
|
|
6535
|
+
}
|
|
6536
|
+
async function searchIssues(config, query) {
|
|
6537
|
+
const { owner, name } = parseRepo(config.repo);
|
|
6538
|
+
const res = await githubFetch(config, `/search/issues?q=${encodeURIComponent(`${query} repo:${owner}/${name} is:issue label:hearback`)}&per_page=5`);
|
|
6539
|
+
if (!res.ok) return {
|
|
6540
|
+
total_count: 0,
|
|
6541
|
+
items: []
|
|
6542
|
+
};
|
|
6543
|
+
return res.json();
|
|
6544
|
+
}
|
|
6545
|
+
async function addReaction(config, issueNumber, reaction = "+1") {
|
|
6546
|
+
const { owner, name } = parseRepo(config.repo);
|
|
6547
|
+
const res = await githubFetch(config, `/repos/${owner}/${name}/issues/${issueNumber}/reactions`, {
|
|
6548
|
+
method: "POST",
|
|
6549
|
+
body: JSON.stringify({ content: reaction }),
|
|
6550
|
+
headers: { "Content-Type": "application/json" }
|
|
6551
|
+
});
|
|
6552
|
+
if (!res.ok && res.status !== 422) throw new HearbackError(`Failed to add reaction: ${res.status}`, "GITHUB_REACTION_FAILED");
|
|
6553
|
+
}
|
|
6554
|
+
/** Read a file from a repo branch. Returns null if not found. */
|
|
6555
|
+
async function getRepoFile(config, path, branch) {
|
|
6556
|
+
const { owner, name } = parseRepo(config.repo);
|
|
6557
|
+
const res = await githubFetch(config, `/repos/${owner}/${name}/contents/${path}${branch ? `?ref=${branch}` : ""}`);
|
|
6558
|
+
if (res.status === 404) return null;
|
|
6559
|
+
if (!res.ok) throw new HearbackError(`Failed to read file ${path}: ${res.status}`, "GITHUB_READ_FILE_FAILED");
|
|
6560
|
+
const data = await res.json();
|
|
6561
|
+
return {
|
|
6562
|
+
content: data.encoding === "base64" ? Buffer.from(data.content, "base64").toString("utf-8") : data.content,
|
|
6563
|
+
sha: data.sha
|
|
6564
|
+
};
|
|
6565
|
+
}
|
|
6566
|
+
/** Write (create or update) a file on a repo branch. Requires sha for updates. */
|
|
6567
|
+
async function putRepoFile(config, params) {
|
|
6568
|
+
const { owner, name } = parseRepo(config.repo);
|
|
6569
|
+
const base64 = Buffer.isBuffer(params.content) ? params.content.toString("base64") : Buffer.from(params.content, "utf-8").toString("base64");
|
|
6570
|
+
const body = {
|
|
6571
|
+
message: params.message,
|
|
6572
|
+
content: base64
|
|
6573
|
+
};
|
|
6574
|
+
if (params.branch) body.branch = params.branch;
|
|
6575
|
+
if (params.sha) body.sha = params.sha;
|
|
6576
|
+
const res = await githubFetch(config, `/repos/${owner}/${name}/contents/${params.path}`, {
|
|
6577
|
+
method: "PUT",
|
|
6578
|
+
body: JSON.stringify(body),
|
|
6579
|
+
headers: { "Content-Type": "application/json" }
|
|
6580
|
+
});
|
|
6581
|
+
if (!res.ok) throw new HearbackError(`Failed to write file ${params.path}: ${res.status}`, "GITHUB_WRITE_FILE_FAILED");
|
|
6582
|
+
const data = await res.json();
|
|
6583
|
+
return {
|
|
6584
|
+
downloadUrl: data.content.download_url,
|
|
6585
|
+
htmlUrl: data.content.html_url,
|
|
6586
|
+
sha: data.content.sha
|
|
6587
|
+
};
|
|
6588
|
+
}
|
|
6589
|
+
/** Ensure a branch exists; creates it from default branch (main/master) if missing. */
|
|
6590
|
+
async function ensureBranch(config, branchName) {
|
|
6591
|
+
const { owner, name } = parseRepo(config.repo);
|
|
6592
|
+
const checkRes = await githubFetch(config, `/repos/${owner}/${name}/git/refs/heads/${branchName}`);
|
|
6593
|
+
if (checkRes.ok) return;
|
|
6594
|
+
if (checkRes.status !== 404) throw new HearbackError(`Failed to check branch: ${checkRes.status}`, "GITHUB_CHECK_BRANCH_FAILED");
|
|
6595
|
+
const repoRes = await githubFetch(config, `/repos/${owner}/${name}`);
|
|
6596
|
+
if (!repoRes.ok) throw new HearbackError(`Failed to read repo: ${repoRes.status}`, "GITHUB_READ_REPO_FAILED");
|
|
6597
|
+
const defaultRefRes = await githubFetch(config, `/repos/${owner}/${name}/git/refs/heads/${(await repoRes.json()).default_branch}`);
|
|
6598
|
+
if (!defaultRefRes.ok) throw new HearbackError(`Failed to read default branch: ${defaultRefRes.status}`, "GITHUB_READ_DEFAULT_BRANCH_FAILED");
|
|
6599
|
+
const defaultRef = await defaultRefRes.json();
|
|
6600
|
+
const createRes = await githubFetch(config, `/repos/${owner}/${name}/git/refs`, {
|
|
6601
|
+
method: "POST",
|
|
6602
|
+
body: JSON.stringify({
|
|
6603
|
+
ref: `refs/heads/${branchName}`,
|
|
6604
|
+
sha: defaultRef.object.sha
|
|
6605
|
+
}),
|
|
6606
|
+
headers: { "Content-Type": "application/json" }
|
|
6607
|
+
});
|
|
6608
|
+
if (!createRes.ok && createRes.status !== 422) throw new HearbackError(`Failed to create branch: ${createRes.status}`, "GITHUB_CREATE_BRANCH_FAILED");
|
|
6609
|
+
}
|
|
6610
|
+
async function uploadImage(config, imageData, filename) {
|
|
6611
|
+
const { downloadUrl } = await putRepoFile(config, {
|
|
6612
|
+
path: `_hearback-uploads/${Date.now()}-${filename}`,
|
|
6613
|
+
content: imageData,
|
|
6614
|
+
message: `[hearback] upload screenshot: ${filename}`
|
|
6615
|
+
});
|
|
6616
|
+
const tokenIdx = downloadUrl.indexOf("?token=");
|
|
6617
|
+
return tokenIdx === -1 ? downloadUrl : downloadUrl.slice(0, tokenIdx);
|
|
6618
|
+
}
|
|
6619
|
+
async function ensureLabels(config, labels) {
|
|
6620
|
+
const { owner, name } = parseRepo(config.repo);
|
|
6621
|
+
for (const label of labels) {
|
|
6622
|
+
const color = getLabelColor(label);
|
|
6623
|
+
const res = await githubFetch(config, `/repos/${owner}/${name}/labels`, {
|
|
6624
|
+
method: "POST",
|
|
6625
|
+
body: JSON.stringify({
|
|
6626
|
+
name: label,
|
|
6627
|
+
color
|
|
6628
|
+
}),
|
|
6629
|
+
headers: { "Content-Type": "application/json" }
|
|
6630
|
+
});
|
|
6631
|
+
if (!res.ok && res.status !== 422) throw new HearbackError(`Failed to create label "${label}": ${res.status}`, "GITHUB_LABEL_FAILED");
|
|
6632
|
+
}
|
|
6633
|
+
}
|
|
6634
|
+
function getLabelColor(label) {
|
|
6635
|
+
return label === "hearback" ? "F59E0B" : "CCCCCC";
|
|
6636
|
+
}
|
|
6637
|
+
//#endregion
|
|
6638
|
+
//#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/formatter.js
|
|
6639
|
+
function formatIssueBody(report) {
|
|
6640
|
+
const sections = [];
|
|
6641
|
+
const heading = report.type === "bug" ? "Bug Report" : "Feature Request";
|
|
6642
|
+
sections.push(`## ${heading}`);
|
|
6643
|
+
sections.push(`**描述**: ${report.description}`);
|
|
6644
|
+
if (report.context.screenshotUrl) sections.push(`\n**截图**:\n`);
|
|
6645
|
+
sections.push(formatContextTable(report));
|
|
6646
|
+
if (report.context.consoleErrors?.length) sections.push(formatConsoleErrors(report.context.consoleErrors));
|
|
6647
|
+
if (report.context.failedRequests?.length) sections.push(formatFailedRequests(report.context.failedRequests));
|
|
6648
|
+
if (report.context.agent?.toolCalls?.length) sections.push(formatToolCalls(report.context.agent.toolCalls));
|
|
6649
|
+
if (report.context.agent?.errorTraces?.length) sections.push(formatErrorTraces(report.context.agent.errorTraces));
|
|
6650
|
+
return sections.join("\n\n");
|
|
6651
|
+
}
|
|
6652
|
+
function formatContextTable(report) {
|
|
6653
|
+
const rows = [];
|
|
6654
|
+
rows.push(["来源", report.context.source]);
|
|
6655
|
+
if (report.context.browser?.url) rows.push(["页面", report.context.browser.url]);
|
|
6656
|
+
if (report.context.browser?.browser && report.context.browser?.os) rows.push(["浏览器", `${report.context.browser.browser} / ${report.context.browser.os}`]);
|
|
6657
|
+
else if (report.context.browser?.userAgent) rows.push(["User Agent", report.context.browser.userAgent]);
|
|
6658
|
+
if (report.context.timestamp) rows.push(["时间", report.context.timestamp]);
|
|
6659
|
+
if (report.context.extra) for (const [key, value] of Object.entries(report.context.extra)) rows.push([key, value]);
|
|
6660
|
+
if (rows.length === 0) return "";
|
|
6661
|
+
return [
|
|
6662
|
+
"### 自动收集的上下文",
|
|
6663
|
+
"",
|
|
6664
|
+
"| 字段 | 值 |",
|
|
6665
|
+
"|------|-----|",
|
|
6666
|
+
...rows.map(([k, v]) => `| ${k} | ${v} |`)
|
|
6667
|
+
].join("\n");
|
|
6668
|
+
}
|
|
6669
|
+
function formatConsoleErrors(errors) {
|
|
6670
|
+
return [
|
|
6671
|
+
"<details>",
|
|
6672
|
+
"<summary>Console Errors</summary>",
|
|
6673
|
+
"",
|
|
6674
|
+
...errors.map((e) => {
|
|
6675
|
+
let entry = e.message;
|
|
6676
|
+
if (e.stack) entry += `\n ${e.stack}`;
|
|
6677
|
+
return entry;
|
|
6678
|
+
}),
|
|
6679
|
+
"",
|
|
6680
|
+
"</details>"
|
|
6681
|
+
].join("\n");
|
|
6682
|
+
}
|
|
6683
|
+
function formatFailedRequests(requests) {
|
|
6684
|
+
return [
|
|
6685
|
+
"<details>",
|
|
6686
|
+
"<summary>Failed Network Requests</summary>",
|
|
6687
|
+
"",
|
|
6688
|
+
...requests.map((r) => {
|
|
6689
|
+
let entry = `${r.method} ${r.url}`;
|
|
6690
|
+
if (r.status) entry += ` → ${r.status}`;
|
|
6691
|
+
if (r.statusText) entry += ` ${r.statusText}`;
|
|
6692
|
+
return entry;
|
|
6693
|
+
}),
|
|
6694
|
+
"",
|
|
6695
|
+
"</details>"
|
|
6696
|
+
].join("\n");
|
|
6697
|
+
}
|
|
6698
|
+
function formatToolCalls(calls) {
|
|
6699
|
+
return [
|
|
6700
|
+
"<details>",
|
|
6701
|
+
"<summary>Tool Calls</summary>",
|
|
6702
|
+
"",
|
|
6703
|
+
...calls.map((c) => {
|
|
6704
|
+
let entry = `- \`${c.name}\``;
|
|
6705
|
+
if (c.error) entry += ` — error: ${c.error}`;
|
|
6706
|
+
return entry;
|
|
6707
|
+
}),
|
|
6708
|
+
"",
|
|
6709
|
+
"</details>"
|
|
6710
|
+
].join("\n");
|
|
6711
|
+
}
|
|
6712
|
+
function formatErrorTraces(traces) {
|
|
6713
|
+
return [
|
|
6714
|
+
"<details>",
|
|
6715
|
+
"<summary>Error Traces</summary>",
|
|
6716
|
+
"",
|
|
6717
|
+
...traces.map((t) => `\`\`\`\n${t}\n\`\`\``),
|
|
6718
|
+
"",
|
|
6719
|
+
"</details>"
|
|
6720
|
+
].join("\n");
|
|
6721
|
+
}
|
|
6722
|
+
const SUBSCRIBERS_PATH = "subscribers.json";
|
|
6723
|
+
function normalize(email) {
|
|
6724
|
+
return email.toLowerCase().trim();
|
|
6725
|
+
}
|
|
6726
|
+
async function readSubscribers(config, branch) {
|
|
6727
|
+
const file = await getRepoFile(config, SUBSCRIBERS_PATH, branch);
|
|
6728
|
+
if (!file) return { data: {} };
|
|
6729
|
+
try {
|
|
6730
|
+
return {
|
|
6731
|
+
data: JSON.parse(file.content),
|
|
6732
|
+
sha: file.sha
|
|
6733
|
+
};
|
|
6734
|
+
} catch {
|
|
6735
|
+
return {
|
|
6736
|
+
data: {},
|
|
6737
|
+
sha: file.sha
|
|
6738
|
+
};
|
|
6739
|
+
}
|
|
6740
|
+
}
|
|
6741
|
+
async function writeSubscribers(config, branch, data, sha, message) {
|
|
6742
|
+
await putRepoFile(config, {
|
|
6743
|
+
path: SUBSCRIBERS_PATH,
|
|
6744
|
+
content: JSON.stringify(data, null, 2),
|
|
6745
|
+
message,
|
|
6746
|
+
branch,
|
|
6747
|
+
sha
|
|
6748
|
+
});
|
|
6749
|
+
}
|
|
6750
|
+
/**
|
|
6751
|
+
* Add an email to an issue's subscriber list. Creates the data branch and file
|
|
6752
|
+
* if missing. Dedupes. Retries once on sha conflict (concurrent writes).
|
|
6753
|
+
*/
|
|
6754
|
+
async function addSubscriber(config, issueNumber, email, options = {}) {
|
|
6755
|
+
const branch = options.branch ?? "hearback-data";
|
|
6756
|
+
const normalized = normalize(email);
|
|
6757
|
+
const key = String(issueNumber);
|
|
6758
|
+
await ensureBranch(config, branch);
|
|
6759
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
6760
|
+
const { data, sha } = await readSubscribers(config, branch);
|
|
6761
|
+
const existing = data[key] ?? [];
|
|
6762
|
+
if (existing.includes(normalized)) return;
|
|
6763
|
+
const updated = {
|
|
6764
|
+
...data,
|
|
6765
|
+
[key]: [...existing, normalized]
|
|
6766
|
+
};
|
|
6767
|
+
try {
|
|
6768
|
+
await writeSubscribers(config, branch, updated, sha, `[hearback] add subscriber to #${issueNumber}`);
|
|
6769
|
+
return;
|
|
6770
|
+
} catch (err) {
|
|
6771
|
+
if (err instanceof HearbackError && err.code === "GITHUB_WRITE_FILE_FAILED" && attempt === 0) continue;
|
|
6772
|
+
throw err;
|
|
6773
|
+
}
|
|
6774
|
+
}
|
|
6775
|
+
}
|
|
6776
|
+
//#endregion
|
|
6777
|
+
//#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/duplicates.js
|
|
6778
|
+
/**
|
|
6779
|
+
* Sanitize title for GitHub search. Removes characters that would break
|
|
6780
|
+
* search query syntax (quotes, colons, slashes used as operators) and
|
|
6781
|
+
* caps length. GitHub search handles stopwords and tokenization internally,
|
|
6782
|
+
* so we pass the cleaned title through directly.
|
|
6783
|
+
*/
|
|
6784
|
+
function sanitizeTitle(title) {
|
|
6785
|
+
return title.replace(/["':/]/g, " ").replace(/\s+/g, " ").trim().slice(0, 100);
|
|
6786
|
+
}
|
|
6787
|
+
async function findDuplicates(config, title) {
|
|
6788
|
+
const query = sanitizeTitle(title);
|
|
6789
|
+
if (!query) return [];
|
|
6790
|
+
return (await searchIssues(config, query)).items.map((issue) => ({
|
|
6791
|
+
issueNumber: issue.number,
|
|
6792
|
+
title: issue.title,
|
|
6793
|
+
state: issue.state,
|
|
6794
|
+
url: issue.html_url
|
|
6795
|
+
}));
|
|
6796
|
+
}
|
|
6797
|
+
//#endregion
|
|
6798
|
+
//#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/submit.js
|
|
6799
|
+
async function checkForDuplicates(config, title) {
|
|
6800
|
+
return findDuplicates(config, title);
|
|
6801
|
+
}
|
|
6802
|
+
async function subscribeToDuplicate(config, issueNumber, email) {
|
|
6803
|
+
await addReaction(config, issueNumber);
|
|
6804
|
+
await addSubscriber(config, issueNumber, email);
|
|
6805
|
+
}
|
|
6806
|
+
async function submitFeedback(options) {
|
|
6807
|
+
const { github, report } = options;
|
|
6808
|
+
FeedbackReport.parse(report);
|
|
6809
|
+
const labels = ["hearback"];
|
|
6810
|
+
if (report.type === "bug") labels.push("bug");
|
|
6811
|
+
if (report.type === "feature") labels.push("enhancement");
|
|
6812
|
+
await ensureLabels(github, labels);
|
|
6813
|
+
const body = formatIssueBody(report);
|
|
6814
|
+
const issue = await createIssue(github, {
|
|
6815
|
+
title: report.title,
|
|
6816
|
+
body,
|
|
6817
|
+
labels
|
|
6818
|
+
});
|
|
6819
|
+
let subscribeWarning;
|
|
6820
|
+
if (report.reporter?.email) try {
|
|
6821
|
+
await addSubscriber(github, issue.number, report.reporter.email);
|
|
6822
|
+
} catch (err) {
|
|
6823
|
+
subscribeWarning = `Issue created, but reporter email could not be saved (${err instanceof Error ? err.message : String(err)}). Hint: GitHub token needs "contents:write" scope to write the hearback-data branch for email notifications.`;
|
|
6824
|
+
console.warn(`[hearback] ${subscribeWarning}`);
|
|
6825
|
+
}
|
|
6826
|
+
return {
|
|
6827
|
+
issue,
|
|
6828
|
+
isDuplicate: false,
|
|
6829
|
+
subscribeWarning
|
|
6830
|
+
};
|
|
6831
|
+
}
|
|
6832
|
+
//#endregion
|
|
6833
|
+
//#region ../../node_modules/.pnpm/hearback-server@0.1.6/node_modules/hearback-server/dist/rate-limit.js
|
|
6834
|
+
var RateLimiter = class {
|
|
6835
|
+
submissions = /* @__PURE__ */ new Map();
|
|
6836
|
+
chatMessages = /* @__PURE__ */ new Map();
|
|
6837
|
+
config;
|
|
6838
|
+
constructor(config) {
|
|
6839
|
+
this.config = {
|
|
6840
|
+
maxSubmissions: config?.maxSubmissions ?? 10,
|
|
6841
|
+
maxChatMessages: config?.maxChatMessages ?? 30,
|
|
6842
|
+
windowMs: config?.windowMs ?? 36e5
|
|
6843
|
+
};
|
|
6844
|
+
}
|
|
6845
|
+
checkSubmission(ip) {
|
|
6846
|
+
return this.check(this.submissions, ip, this.config.maxSubmissions);
|
|
6847
|
+
}
|
|
6848
|
+
checkChatMessage(ip) {
|
|
6849
|
+
return this.check(this.chatMessages, ip, this.config.maxChatMessages);
|
|
6850
|
+
}
|
|
6851
|
+
check(bucket, ip, max) {
|
|
6852
|
+
const now = Date.now();
|
|
6853
|
+
const entry = bucket.get(ip);
|
|
6854
|
+
if (!entry || now >= entry.resetAt) {
|
|
6855
|
+
bucket.set(ip, {
|
|
6856
|
+
count: 1,
|
|
6857
|
+
resetAt: now + this.config.windowMs
|
|
6858
|
+
});
|
|
6859
|
+
return { allowed: true };
|
|
6860
|
+
}
|
|
6861
|
+
if (entry.count >= max) return {
|
|
6862
|
+
allowed: false,
|
|
6863
|
+
retryAfterMs: entry.resetAt - now
|
|
6864
|
+
};
|
|
6865
|
+
entry.count++;
|
|
6866
|
+
return { allowed: true };
|
|
6867
|
+
}
|
|
6868
|
+
};
|
|
6869
|
+
//#endregion
|
|
6870
|
+
//#region ../../node_modules/.pnpm/hearback-server@0.1.6/node_modules/hearback-server/dist/chat-prompt.js
|
|
6871
|
+
const FEEDBACK_CHAT_SYSTEM_PROMPT = `You are a feedback assistant. Help users report bugs or suggest features. Reply in the user's language.
|
|
6872
|
+
|
|
6873
|
+
## Don't make users repeat themselves
|
|
6874
|
+
Use details from earlier messages in this conversation. Don't ask for things the user has already told you.
|
|
6875
|
+
|
|
6876
|
+
## Default behavior
|
|
6877
|
+
- One sentence per reply. One question per turn. Never stack questions.
|
|
6878
|
+
- If the user's first message already contains symptom + specifics, SKIP questioning and go straight to the summary.
|
|
6879
|
+
|
|
6880
|
+
## Bugs — ask ONLY what's missing, in this priority
|
|
6881
|
+
1. Symptom — what did you see?
|
|
6882
|
+
2. Trigger — always, or specific steps?
|
|
6883
|
+
3. Expected — only if not obvious from the symptom.
|
|
6884
|
+
|
|
6885
|
+
## Features — ask ONLY what's missing
|
|
6886
|
+
1. Use case — what were you trying to do?
|
|
6887
|
+
2. Workaround — how are you handling it today? (only if not stated)
|
|
6888
|
+
|
|
6889
|
+
## Stop criterion
|
|
6890
|
+
Stop when you can write a specific title (NOT "bug found") and a description a developer can act on. Usually 1–3 exchanges.
|
|
6891
|
+
|
|
6892
|
+
## Visual context — ask for a screenshot when it would help
|
|
6893
|
+
A picture is faster than a paragraph when the problem is visual. After the user's initial description, decide whether a screenshot would materially help a developer act on it. Ask once when:
|
|
6894
|
+
- The description mentions anything visual — layout, color, wrong element, "looks weird", position, styling, icon missing.
|
|
6895
|
+
- The description names a specific UI element but is vague about what the user is seeing ("the button didn't work" — a screenshot beats another round of questions).
|
|
6896
|
+
- The description is very short and a picture would fill in the gaps.
|
|
6897
|
+
|
|
6898
|
+
Phrase it as a concrete call to action, not a vague "do you have a screenshot":
|
|
6899
|
+
> "A screenshot would help here. Take one with your OS (Cmd+Shift+Ctrl+4 on Mac / Win+Shift+S on Windows), then paste it in or drag the file into this window."
|
|
6900
|
+
|
|
6901
|
+
Skip the screenshot ask entirely for purely behavioral / backend bugs — API errors, permissions, data loading, login failures. Those don't need a picture.
|
|
6902
|
+
|
|
6903
|
+
Ask at most once. If the user declines, says they can't, or just replies with text, move on with what you have. Don't ask again later in the same conversation.
|
|
6904
|
+
|
|
6905
|
+
## Attachments already provided
|
|
6906
|
+
When a user message ends with \`[attachment: <name>]\` (or contains that pattern), the user has already attached an image. In that case:
|
|
6907
|
+
- Skip the "ask for a screenshot" step entirely — don't ask for one, don't re-ask even if the description sounds visual.
|
|
6908
|
+
- Treat the attachment as additional context confirming what the user described; the image itself isn't visible to you, but its presence means a developer will see it in the final issue.
|
|
6909
|
+
- Don't mention the marker back to the user.
|
|
6910
|
+
|
|
6911
|
+
## Optional — email for fix notifications
|
|
6912
|
+
After you have title + summary but BEFORE the summary/confirmation turn, ask once:
|
|
6913
|
+
"Optional: want an email when this is fixed? (Skip to continue anonymously.)"
|
|
6914
|
+
- User provides an address → include it verbatim as \`email\` in the final JSON.
|
|
6915
|
+
- User declines / says "no" / "skip" / "anonymous" / silence → OMIT the \`email\` field entirely (don't emit "" or null).
|
|
6916
|
+
- Never ask twice. If the answer is unclear, treat it as skip.
|
|
6917
|
+
- Don't guess or fabricate an email.
|
|
6918
|
+
|
|
6919
|
+
## Summary format (before confirmation)
|
|
6920
|
+
> **Title:** …
|
|
6921
|
+
> **Summary:** …
|
|
6922
|
+
> **Email:** … *(only if the user provided one; omit the line otherwise)*
|
|
6923
|
+
>
|
|
6924
|
+
> Submit this?
|
|
6925
|
+
|
|
6926
|
+
## Confirmation — judge intent, don't match keywords
|
|
6927
|
+
"yes" / "go" / "就这样" / "可以了" / "submit" / "提交" / 👍 — all confirm.
|
|
6928
|
+
"wait" / "先别" / "let me add" — don't. Ambiguous → ask once more.
|
|
6929
|
+
|
|
6930
|
+
## After confirmation — output ONLY this JSON, nothing else:
|
|
6931
|
+
|
|
6932
|
+
\`\`\`json
|
|
6933
|
+
{"ready":true,"type":"bug","title":"under 60 chars, specific","description":"markdown"}
|
|
6934
|
+
\`\`\`
|
|
6935
|
+
|
|
6936
|
+
\`type\` must be exactly "bug" or "feature" (never "bug_or_feature").
|
|
6937
|
+
|
|
6938
|
+
If (and only if) the user provided an email, add it as \`email\`:
|
|
6939
|
+
|
|
6940
|
+
\`\`\`json
|
|
6941
|
+
{"ready":true,"type":"bug","title":"…","description":"…","email":"user@example.com"}
|
|
6942
|
+
\`\`\`
|
|
6943
|
+
|
|
6944
|
+
Omit the \`email\` key entirely when the user skipped. Don't emit \`"email":""\` or \`"email":null\`.
|
|
6945
|
+
|
|
6946
|
+
Description format (suggested, not strict):
|
|
6947
|
+
- Bug: symptom / repro / expected
|
|
6948
|
+
- Feature: use case / proposed / workaround
|
|
6949
|
+
|
|
6950
|
+
Include the user's own words where they matter.
|
|
6951
|
+
|
|
6952
|
+
## Hard rules
|
|
6953
|
+
- Never output JSON before confirmation. If unsure, ask once more.
|
|
6954
|
+
- If the user pastes a token / password / API key / secret, warn them and OMIT it from the report.
|
|
6955
|
+
- "Nevermind" / "算了" / "forget it" → stop cleanly.
|
|
6956
|
+
`;
|
|
6957
|
+
//#endregion
|
|
6958
|
+
//#region ../../node_modules/.pnpm/hearback-server@0.1.6/node_modules/hearback-server/dist/handler.js
|
|
6959
|
+
function createFeedbackHandler(config) {
|
|
6960
|
+
const rateLimiter = new RateLimiter(config.rateLimit);
|
|
6961
|
+
const ghConfig = {
|
|
6962
|
+
repo: config.repo,
|
|
6963
|
+
token: config.githubToken,
|
|
6964
|
+
apiBase: config.githubApiBase
|
|
6965
|
+
};
|
|
6966
|
+
const corsHeaders = {
|
|
6967
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
6968
|
+
"Access-Control-Allow-Headers": "Content-Type"
|
|
6969
|
+
};
|
|
6970
|
+
const origins = config.corsOrigins ?? ["*"];
|
|
6971
|
+
if (origins.includes("*")) corsHeaders["Access-Control-Allow-Origin"] = "*";
|
|
6972
|
+
function getCorsHeaders(requestOrigin) {
|
|
6973
|
+
if (origins.includes("*")) return corsHeaders;
|
|
6974
|
+
if (requestOrigin && origins.includes(requestOrigin)) return {
|
|
6975
|
+
...corsHeaders,
|
|
6976
|
+
"Access-Control-Allow-Origin": requestOrigin
|
|
6977
|
+
};
|
|
6978
|
+
return corsHeaders;
|
|
6979
|
+
}
|
|
6980
|
+
async function handle(req) {
|
|
6981
|
+
const cors = getCorsHeaders(req.headers["origin"]);
|
|
6982
|
+
if (req.method === "OPTIONS") return {
|
|
6983
|
+
status: 204,
|
|
6984
|
+
headers: cors,
|
|
6985
|
+
body: null
|
|
6986
|
+
};
|
|
6987
|
+
if (req.method !== "POST") return {
|
|
6988
|
+
status: 405,
|
|
6989
|
+
headers: cors,
|
|
6990
|
+
body: { error: "Method not allowed" }
|
|
6991
|
+
};
|
|
6992
|
+
const route = req.path.replace(/\/$/, "");
|
|
6993
|
+
try {
|
|
6994
|
+
if (route.endsWith("/chat")) return await handleChat(req, cors);
|
|
6995
|
+
if (route.endsWith("/submit")) return await handleSubmit(req, cors);
|
|
6996
|
+
if (route.endsWith("/subscribe")) return await handleSubscribe(req, cors);
|
|
6997
|
+
if (route.endsWith("/upload")) return await handleUpload(req, cors);
|
|
6998
|
+
return {
|
|
6999
|
+
status: 404,
|
|
7000
|
+
headers: cors,
|
|
7001
|
+
body: { error: "Not found" }
|
|
7002
|
+
};
|
|
7003
|
+
} catch (err) {
|
|
7004
|
+
return {
|
|
7005
|
+
status: 500,
|
|
7006
|
+
headers: cors,
|
|
7007
|
+
body: { error: err instanceof Error ? err.message : "Internal error" }
|
|
7008
|
+
};
|
|
7009
|
+
}
|
|
7010
|
+
}
|
|
7011
|
+
async function handleChat(req, cors) {
|
|
7012
|
+
if (!config.llm) return {
|
|
7013
|
+
status: 501,
|
|
7014
|
+
headers: cors,
|
|
7015
|
+
body: { error: "LLM not configured. Use form mode." }
|
|
7016
|
+
};
|
|
7017
|
+
const rateCheck = rateLimiter.checkChatMessage(req.ip);
|
|
7018
|
+
if (!rateCheck.allowed) return {
|
|
7019
|
+
status: 429,
|
|
7020
|
+
headers: {
|
|
7021
|
+
...cors,
|
|
7022
|
+
"Retry-After": String(Math.ceil((rateCheck.retryAfterMs ?? 36e5) / 1e3))
|
|
7023
|
+
},
|
|
7024
|
+
body: { error: "反馈过多,一小时后再试" }
|
|
7025
|
+
};
|
|
7026
|
+
const body = req.body;
|
|
7027
|
+
if (!body.messages || !Array.isArray(body.messages)) return {
|
|
7028
|
+
status: 400,
|
|
7029
|
+
headers: cors,
|
|
7030
|
+
body: { error: "Missing messages array" }
|
|
7031
|
+
};
|
|
7032
|
+
const messages = body.messages.slice(-10);
|
|
7033
|
+
const baseUrl = config.llm.baseUrl ?? "https://api.openai.com/v1";
|
|
7034
|
+
const model = config.llm.model ?? "gpt-4o-mini";
|
|
7035
|
+
const llmRes = await fetch(`${baseUrl}/chat/completions`, {
|
|
7036
|
+
method: "POST",
|
|
7037
|
+
headers: {
|
|
7038
|
+
"Content-Type": "application/json",
|
|
7039
|
+
Authorization: `Bearer ${config.llm.apiKey}`
|
|
7040
|
+
},
|
|
7041
|
+
body: JSON.stringify({
|
|
7042
|
+
model,
|
|
7043
|
+
messages: [{
|
|
7044
|
+
role: "system",
|
|
7045
|
+
content: FEEDBACK_CHAT_SYSTEM_PROMPT
|
|
7046
|
+
}, ...messages],
|
|
7047
|
+
stream: true
|
|
7048
|
+
})
|
|
7049
|
+
});
|
|
7050
|
+
if (!llmRes.ok) {
|
|
7051
|
+
const text = await llmRes.text();
|
|
7052
|
+
return {
|
|
7053
|
+
status: 502,
|
|
7054
|
+
headers: cors,
|
|
7055
|
+
body: {
|
|
7056
|
+
error: `LLM error: ${llmRes.status}`,
|
|
7057
|
+
detail: text
|
|
7058
|
+
}
|
|
7059
|
+
};
|
|
7060
|
+
}
|
|
7061
|
+
return {
|
|
7062
|
+
status: 200,
|
|
7063
|
+
headers: {
|
|
7064
|
+
...cors,
|
|
7065
|
+
"Content-Type": "text/event-stream",
|
|
7066
|
+
"Cache-Control": "no-cache",
|
|
7067
|
+
Connection: "keep-alive"
|
|
7068
|
+
},
|
|
7069
|
+
body: llmRes.body
|
|
7070
|
+
};
|
|
7071
|
+
}
|
|
7072
|
+
async function handleSubmit(req, cors) {
|
|
7073
|
+
const rateCheck = rateLimiter.checkSubmission(req.ip);
|
|
7074
|
+
if (!rateCheck.allowed) return {
|
|
7075
|
+
status: 429,
|
|
7076
|
+
headers: {
|
|
7077
|
+
...cors,
|
|
7078
|
+
"Retry-After": String(Math.ceil((rateCheck.retryAfterMs ?? 36e5) / 1e3))
|
|
7079
|
+
},
|
|
7080
|
+
body: { error: "反馈过多,一小时后再试" }
|
|
7081
|
+
};
|
|
7082
|
+
const body = req.body;
|
|
7083
|
+
if (!body.title || !body.description || !body.type) return {
|
|
7084
|
+
status: 400,
|
|
7085
|
+
headers: cors,
|
|
7086
|
+
body: { error: "Missing required fields: type, title, description" }
|
|
7087
|
+
};
|
|
7088
|
+
if (!body.skipDuplicateCheck) {
|
|
7089
|
+
const duplicates = await checkForDuplicates(ghConfig, body.title);
|
|
7090
|
+
if (duplicates.length > 0) return {
|
|
7091
|
+
status: 200,
|
|
7092
|
+
headers: cors,
|
|
7093
|
+
body: {
|
|
7094
|
+
isDuplicate: true,
|
|
7095
|
+
candidates: duplicates.slice(0, 3)
|
|
7096
|
+
}
|
|
7097
|
+
};
|
|
7098
|
+
}
|
|
7099
|
+
const result = await submitFeedback({
|
|
7100
|
+
github: ghConfig,
|
|
7101
|
+
report: FeedbackReport.parse({
|
|
7102
|
+
type: body.type,
|
|
7103
|
+
title: body.title,
|
|
7104
|
+
description: body.description,
|
|
7105
|
+
context: body.context ?? { source: "web-plugin" },
|
|
7106
|
+
reporter: body.reporterEmail ? { email: body.reporterEmail } : void 0
|
|
7107
|
+
})
|
|
7108
|
+
});
|
|
7109
|
+
return {
|
|
7110
|
+
status: 201,
|
|
7111
|
+
headers: cors,
|
|
7112
|
+
body: {
|
|
7113
|
+
isDuplicate: false,
|
|
7114
|
+
issueNumber: result.issue.number,
|
|
7115
|
+
issueUrl: result.issue.html_url,
|
|
7116
|
+
...result.subscribeWarning ? { warning: result.subscribeWarning } : {}
|
|
7117
|
+
}
|
|
7118
|
+
};
|
|
7119
|
+
}
|
|
7120
|
+
async function handleSubscribe(req, cors) {
|
|
7121
|
+
const body = req.body;
|
|
7122
|
+
if (!body.issueNumber || !body.email) return {
|
|
7123
|
+
status: 400,
|
|
7124
|
+
headers: cors,
|
|
7125
|
+
body: { error: "Missing issueNumber and email" }
|
|
7126
|
+
};
|
|
7127
|
+
try {
|
|
7128
|
+
await subscribeToDuplicate(ghConfig, body.issueNumber, body.email);
|
|
7129
|
+
} catch (err) {
|
|
7130
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
7131
|
+
const hint = msg.includes("contents") || msg.includes("403") ? "GitHub token needs \"contents:write\" scope to save subscribers. See docs for token setup." : msg;
|
|
7132
|
+
return {
|
|
7133
|
+
status: 502,
|
|
7134
|
+
headers: cors,
|
|
7135
|
+
body: {
|
|
7136
|
+
subscribed: false,
|
|
7137
|
+
issueNumber: body.issueNumber,
|
|
7138
|
+
error: hint
|
|
7139
|
+
}
|
|
7140
|
+
};
|
|
7141
|
+
}
|
|
7142
|
+
return {
|
|
7143
|
+
status: 200,
|
|
7144
|
+
headers: cors,
|
|
7145
|
+
body: {
|
|
7146
|
+
subscribed: true,
|
|
7147
|
+
issueNumber: body.issueNumber
|
|
7148
|
+
}
|
|
7149
|
+
};
|
|
7150
|
+
}
|
|
7151
|
+
async function handleUpload(req, cors) {
|
|
7152
|
+
if (!req.rawBody || req.rawBody.length === 0) return {
|
|
7153
|
+
status: 400,
|
|
7154
|
+
headers: cors,
|
|
7155
|
+
body: { error: "No file data received" }
|
|
7156
|
+
};
|
|
7157
|
+
if (req.rawBody.length > 10 * 1024 * 1024) return {
|
|
7158
|
+
status: 413,
|
|
7159
|
+
headers: cors,
|
|
7160
|
+
body: { error: "Screenshot exceeds 10MB limit" }
|
|
7161
|
+
};
|
|
7162
|
+
try {
|
|
7163
|
+
const filename = `screenshot-${Date.now()}.jpg`;
|
|
7164
|
+
return {
|
|
7165
|
+
status: 200,
|
|
7166
|
+
headers: cors,
|
|
7167
|
+
body: { url: await uploadImage(ghConfig, req.rawBody, filename) }
|
|
7168
|
+
};
|
|
7169
|
+
} catch (err) {
|
|
7170
|
+
return {
|
|
7171
|
+
status: 500,
|
|
7172
|
+
headers: cors,
|
|
7173
|
+
body: { error: err instanceof Error ? err.message : "Upload failed" }
|
|
7174
|
+
};
|
|
7175
|
+
}
|
|
7176
|
+
}
|
|
7177
|
+
return { handle };
|
|
7178
|
+
}
|
|
7179
|
+
//#endregion
|
|
7180
|
+
//#region ../server/dist/app-RWQiJW6_.mjs
|
|
6291
7181
|
var __defProp = Object.defineProperty;
|
|
6292
7182
|
var __exportAll = (all, no_symbols) => {
|
|
6293
7183
|
let target = {};
|
|
@@ -6298,6 +7188,17 @@ var __exportAll = (all, no_symbols) => {
|
|
|
6298
7188
|
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
6299
7189
|
return target;
|
|
6300
7190
|
};
|
|
7191
|
+
/** Organization entity. Agents and chats belong to exactly one organization. */
|
|
7192
|
+
const organizations = pgTable("organizations", {
|
|
7193
|
+
id: text("id").primaryKey(),
|
|
7194
|
+
name: text("name").unique().notNull(),
|
|
7195
|
+
displayName: text("display_name").notNull(),
|
|
7196
|
+
maxAgents: integer("max_agents").notNull().default(0),
|
|
7197
|
+
maxMessagesPerMinute: integer("max_messages_per_minute").notNull().default(0),
|
|
7198
|
+
features: jsonb("features").$type().notNull().default({}),
|
|
7199
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
7200
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
7201
|
+
});
|
|
6301
7202
|
/** User accounts. Passwords are stored as bcrypt hashes. */
|
|
6302
7203
|
const users = pgTable("users", {
|
|
6303
7204
|
id: text("id").primaryKey(),
|
|
@@ -6316,10 +7217,18 @@ const users = pgTable("users", {
|
|
|
6316
7217
|
* every `agent:bind` request. `user_id` is nullable only to accommodate legacy
|
|
6317
7218
|
* rows created before JWT-on-handshake; the WS handshake claims the row on
|
|
6318
7219
|
* first re-register under an authenticated JWT (see `client:register` M13).
|
|
7220
|
+
*
|
|
7221
|
+
* A client is also bound to exactly one organization for its lifetime. The
|
|
7222
|
+
* `organization_id` column is populated on first registration from the
|
|
7223
|
+
* authenticated JWT's org claim and never changes thereafter. Re-registering
|
|
7224
|
+
* the same clientId under a JWT for a different org is rejected with
|
|
7225
|
+
* `CLIENT_ORG_MISMATCH` — the CLI responds by abandoning the local clientId
|
|
7226
|
+
* and registering a new one instead (see docs/multi-tenancy-hardening-design.md).
|
|
6319
7227
|
*/
|
|
6320
7228
|
const clients = pgTable("clients", {
|
|
6321
7229
|
id: text("id").primaryKey(),
|
|
6322
7230
|
userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
|
|
7231
|
+
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
6323
7232
|
status: text("status").notNull().default("disconnected"),
|
|
6324
7233
|
sdkVersion: text("sdk_version"),
|
|
6325
7234
|
hostname: text("hostname"),
|
|
@@ -6328,18 +7237,7 @@ const clients = pgTable("clients", {
|
|
|
6328
7237
|
connectedAt: timestamp("connected_at", { withTimezone: true }),
|
|
6329
7238
|
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(),
|
|
6330
7239
|
metadata: jsonb("metadata").$type()
|
|
6331
|
-
}, (table) => [index("idx_clients_user").on(table.userId)]);
|
|
6332
|
-
/** Organization entity. Agents and chats belong to exactly one organization. */
|
|
6333
|
-
const organizations = pgTable("organizations", {
|
|
6334
|
-
id: text("id").primaryKey(),
|
|
6335
|
-
name: text("name").unique().notNull(),
|
|
6336
|
-
displayName: text("display_name").notNull(),
|
|
6337
|
-
maxAgents: integer("max_agents").notNull().default(0),
|
|
6338
|
-
maxMessagesPerMinute: integer("max_messages_per_minute").notNull().default(0),
|
|
6339
|
-
features: jsonb("features").$type().notNull().default({}),
|
|
6340
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
6341
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
6342
|
-
});
|
|
7240
|
+
}, (table) => [index("idx_clients_user").on(table.userId), index("idx_clients_org").on(table.organizationId)]);
|
|
6343
7241
|
/** Agent registration. Each agent owns a unique inboxId for message delivery. */
|
|
6344
7242
|
const agents = pgTable("agents", {
|
|
6345
7243
|
uuid: text("uuid").primaryKey(),
|
|
@@ -6351,7 +7249,6 @@ const agents = pgTable("agents", {
|
|
|
6351
7249
|
inboxId: text("inbox_id").unique().notNull(),
|
|
6352
7250
|
status: text("status").notNull().default("active"),
|
|
6353
7251
|
source: text("source"),
|
|
6354
|
-
cloudUserId: text("cloud_user_id"),
|
|
6355
7252
|
visibility: text("visibility").notNull().default("private"),
|
|
6356
7253
|
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
6357
7254
|
managerId: text("manager_id").notNull(),
|
|
@@ -6413,6 +7310,20 @@ var BadRequestError = class extends AppError {
|
|
|
6413
7310
|
this.name = "BadRequestError";
|
|
6414
7311
|
}
|
|
6415
7312
|
};
|
|
7313
|
+
/**
|
|
7314
|
+
* Thrown when an operation targets a client whose organization does not match
|
|
7315
|
+
* the caller's authenticated organization. A client is bound to exactly one
|
|
7316
|
+
* org for its lifetime; re-registering or operating under a different org's
|
|
7317
|
+
* credentials is refused. CLI consumers recognize the `code` field and
|
|
7318
|
+
* respond by abandoning the local clientId to register a fresh one.
|
|
7319
|
+
*/
|
|
7320
|
+
var ClientOrgMismatchError = class extends AppError {
|
|
7321
|
+
code = "CLIENT_ORG_MISMATCH";
|
|
7322
|
+
constructor(message = "Client belongs to a different organization") {
|
|
7323
|
+
super(403, message);
|
|
7324
|
+
this.name = "ClientOrgMismatchError";
|
|
7325
|
+
}
|
|
7326
|
+
};
|
|
6416
7327
|
/** Communication container. All messages between agents flow within a Chat. */
|
|
6417
7328
|
const chats = pgTable("chats", {
|
|
6418
7329
|
id: text("id").primaryKey(),
|
|
@@ -7266,14 +8177,19 @@ async function resolveAgentClient(db, data) {
|
|
|
7266
8177
|
return null;
|
|
7267
8178
|
}
|
|
7268
8179
|
if (!data.clientId) return null;
|
|
7269
|
-
const [manager] = await db.select({
|
|
8180
|
+
const [manager] = await db.select({
|
|
8181
|
+
userId: members.userId,
|
|
8182
|
+
organizationId: members.organizationId
|
|
8183
|
+
}).from(members).where(eq(members.id, data.managerId)).limit(1);
|
|
7270
8184
|
if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
|
|
7271
8185
|
const [client] = await db.select({
|
|
7272
8186
|
id: clients.id,
|
|
7273
|
-
userId: clients.userId
|
|
8187
|
+
userId: clients.userId,
|
|
8188
|
+
organizationId: clients.organizationId
|
|
7274
8189
|
}).from(clients).where(eq(clients.id, data.clientId)).limit(1);
|
|
7275
8190
|
if (!client) throw new BadRequestError(`Client "${data.clientId}" not found`);
|
|
7276
8191
|
if (!client.userId) throw new BadRequestError(`Client "${data.clientId}" has not been claimed by a user yet. Have the operator run \`first-tree-hub client connect\` on that machine before pinning an agent to it.`);
|
|
8192
|
+
if (client.organizationId !== manager.organizationId) throw new ForbiddenError(`Client "${data.clientId}" belongs to a different organization — pick a client registered in the manager's org.`);
|
|
7277
8193
|
if (client.userId !== manager.userId) throw new ForbiddenError(`Client "${data.clientId}" is not owned by the manager's user — pick a client belonging to that user.`);
|
|
7278
8194
|
return client.id;
|
|
7279
8195
|
}
|
|
@@ -7372,7 +8288,6 @@ async function listAgentsForAdmin(db, scope, limit, cursor) {
|
|
|
7372
8288
|
delegateMention: agents.delegateMention,
|
|
7373
8289
|
inboxId: agents.inboxId,
|
|
7374
8290
|
status: agents.status,
|
|
7375
|
-
cloudUserId: agents.cloudUserId,
|
|
7376
8291
|
visibility: agents.visibility,
|
|
7377
8292
|
metadata: agents.metadata,
|
|
7378
8293
|
managerId: agents.managerId,
|
|
@@ -7410,7 +8325,6 @@ async function listAgentsForMember(db, scope, limit, cursor, type) {
|
|
|
7410
8325
|
delegateMention: agents.delegateMention,
|
|
7411
8326
|
inboxId: agents.inboxId,
|
|
7412
8327
|
status: agents.status,
|
|
7413
|
-
cloudUserId: agents.cloudUserId,
|
|
7414
8328
|
visibility: agents.visibility,
|
|
7415
8329
|
metadata: agents.metadata,
|
|
7416
8330
|
managerId: agents.managerId,
|
|
@@ -7940,22 +8854,23 @@ async function cleanupStalePresence(db, staleSeconds = 60) {
|
|
|
7940
8854
|
* Assert the caller can act on this client. Throws 404 for both "not found"
|
|
7941
8855
|
* and "not yours" to prevent UUID enumeration across org/user boundaries.
|
|
7942
8856
|
*
|
|
7943
|
-
*
|
|
7944
|
-
*
|
|
8857
|
+
* A client is bound to exactly one organization (`clients.organization_id`).
|
|
8858
|
+
* Access is granted when:
|
|
8859
|
+
* - member: row.user_id == scope.userId AND row.organization_id == scope.organizationId.
|
|
8860
|
+
* - admin: row.organization_id == scope.organizationId AND the owner is a
|
|
8861
|
+
* member of that same org (defense in depth).
|
|
7945
8862
|
*
|
|
7946
|
-
*
|
|
7947
|
-
*
|
|
7948
|
-
* cross-tenant admin can't operate on another org's orphan rows. These
|
|
7949
|
-
* orphans are surfaced for self-service re-registration only; the owning
|
|
7950
|
-
* operator must claim the row via `first-tree-hub connect` before any
|
|
7951
|
-
* admin action becomes available.
|
|
8863
|
+
* Same user across two orgs has two distinct client rows; operating on one
|
|
8864
|
+
* while logged into the other is refused by the org filter.
|
|
7952
8865
|
*/
|
|
7953
8866
|
async function assertClientOwner(db, clientId, scope) {
|
|
7954
8867
|
const [row] = await db.select({
|
|
7955
8868
|
id: clients.id,
|
|
7956
|
-
userId: clients.userId
|
|
8869
|
+
userId: clients.userId,
|
|
8870
|
+
organizationId: clients.organizationId
|
|
7957
8871
|
}).from(clients).where(eq(clients.id, clientId)).limit(1);
|
|
7958
8872
|
if (!row) throw new NotFoundError(`Client "${clientId}" not found`);
|
|
8873
|
+
if (row.organizationId !== scope.organizationId) throw new NotFoundError(`Client "${clientId}" not found`);
|
|
7959
8874
|
if (row.userId === scope.userId) return;
|
|
7960
8875
|
if (scope.role === "admin" && row.userId !== null) {
|
|
7961
8876
|
const [sibling] = await db.select({ id: members.id }).from(members).where(and(eq(members.userId, row.userId), eq(members.organizationId, scope.organizationId))).limit(1);
|
|
@@ -7966,24 +8881,29 @@ async function assertClientOwner(db, clientId, scope) {
|
|
|
7966
8881
|
/**
|
|
7967
8882
|
* Upsert the clients row for a given `client_id` under an authenticated user.
|
|
7968
8883
|
*
|
|
7969
|
-
* Claim semantics (see proposal M13):
|
|
7970
|
-
* - New client_id → INSERT with the authenticated user_id.
|
|
7971
|
-
* - Existing row with
|
|
7972
|
-
* - Existing row
|
|
7973
|
-
*
|
|
7974
|
-
*
|
|
7975
|
-
*
|
|
8884
|
+
* Claim semantics (see proposal M13 + multi-tenancy hardening):
|
|
8885
|
+
* - New client_id → INSERT with the authenticated user_id and org_id.
|
|
8886
|
+
* - Existing row with the same user_id + org_id → refresh runtime columns.
|
|
8887
|
+
* - Existing row in a different org → {@link ClientOrgMismatchError}. A
|
|
8888
|
+
* client is bound to one org for its lifetime; the CLI reacts by
|
|
8889
|
+
* abandoning the local clientId and registering a new one.
|
|
8890
|
+
* - Existing row with a different user_id (same org) → {@link ForbiddenError};
|
|
8891
|
+
* the operator must pick a different clientId. Hard conflict because
|
|
8892
|
+
* pinned agents under that client belong to the original owner.
|
|
7976
8893
|
*/
|
|
7977
8894
|
async function registerClient(db, data) {
|
|
7978
8895
|
const now = /* @__PURE__ */ new Date();
|
|
7979
8896
|
const [existing] = await db.select({
|
|
7980
8897
|
id: clients.id,
|
|
7981
|
-
userId: clients.userId
|
|
8898
|
+
userId: clients.userId,
|
|
8899
|
+
organizationId: clients.organizationId
|
|
7982
8900
|
}).from(clients).where(eq(clients.id, data.clientId)).limit(1);
|
|
8901
|
+
if (existing && existing.organizationId !== data.organizationId) throw new ClientOrgMismatchError(`Client "${data.clientId}" is bound to a different organization. Re-register as a new client under the current org.`);
|
|
7983
8902
|
if (existing?.userId && existing.userId !== data.userId) throw new ForbiddenError(`Client "${data.clientId}" is already claimed by a different user. Pick a unique client_id.`);
|
|
7984
8903
|
await db.insert(clients).values({
|
|
7985
8904
|
id: data.clientId,
|
|
7986
8905
|
userId: data.userId,
|
|
8906
|
+
organizationId: data.organizationId,
|
|
7987
8907
|
status: "connected",
|
|
7988
8908
|
instanceId: data.instanceId,
|
|
7989
8909
|
hostname: data.hostname ?? null,
|
|
@@ -8044,18 +8964,13 @@ async function listActiveAgentsPinnedToClient(db, clientId) {
|
|
|
8044
8964
|
/**
|
|
8045
8965
|
* Scope-aware client listing.
|
|
8046
8966
|
*
|
|
8047
|
-
* - member:
|
|
8048
|
-
*
|
|
8049
|
-
*
|
|
8050
|
-
*
|
|
8051
|
-
* Legacy unclaimed rows (`user_id IS NULL`) are intentionally hidden from
|
|
8052
|
-
* both roles — the `clients` table has no org column, so we cannot verify
|
|
8053
|
-
* which org an orphan belongs to. Exposing them to admin would leak
|
|
8054
|
-
* orphans across tenants. The owning operator reclaims the row via
|
|
8055
|
-
* `first-tree-hub connect`, after which it appears in their list.
|
|
8967
|
+
* - member: rows where `user_id = scope.userId` AND `organization_id = scope.organizationId`
|
|
8968
|
+
* — protects against a user listing their own clients registered under a
|
|
8969
|
+
* different org when they're logged into this one.
|
|
8970
|
+
* - admin: every row in `scope.organizationId`, regardless of owner.
|
|
8056
8971
|
*/
|
|
8057
8972
|
async function listClients(db, scope) {
|
|
8058
|
-
const rows = scope.role === "admin" ? await db.
|
|
8973
|
+
const rows = scope.role === "admin" ? await db.select({
|
|
8059
8974
|
id: clients.id,
|
|
8060
8975
|
userId: clients.userId,
|
|
8061
8976
|
status: clients.status,
|
|
@@ -8066,7 +8981,7 @@ async function listClients(db, scope) {
|
|
|
8066
8981
|
connectedAt: clients.connectedAt,
|
|
8067
8982
|
lastSeenAt: clients.lastSeenAt,
|
|
8068
8983
|
metadata: clients.metadata
|
|
8069
|
-
}).from(clients).
|
|
8984
|
+
}).from(clients).where(eq(clients.organizationId, scope.organizationId)) : await db.select().from(clients).where(and(eq(clients.userId, scope.userId), eq(clients.organizationId, scope.organizationId)));
|
|
8070
8985
|
const counts = await db.select({
|
|
8071
8986
|
clientId: agents.clientId,
|
|
8072
8987
|
count: sql`count(*)::int`
|
|
@@ -8104,6 +9019,14 @@ async function retireClient(db, clientId) {
|
|
|
8104
9019
|
await tx.delete(clients).where(eq(clients.id, clientId));
|
|
8105
9020
|
});
|
|
8106
9021
|
}
|
|
9022
|
+
/**
|
|
9023
|
+
* System-scope sweep: mark clients as disconnected when their last-seen
|
|
9024
|
+
* server instance stopped sending heartbeats. Runs globally across all orgs
|
|
9025
|
+
* by design — it is invoked only by internal timers, never from a
|
|
9026
|
+
* user-scoped request, so the per-org filter the read paths enforce does not
|
|
9027
|
+
* apply. Org isolation on the data these clients belong to is still
|
|
9028
|
+
* enforced at the read paths (see `assertClientOwner` / `listClients`).
|
|
9029
|
+
*/
|
|
8107
9030
|
async function cleanupStaleClients(db, staleSeconds = 60) {
|
|
8108
9031
|
const result = await db.execute(sql`
|
|
8109
9032
|
UPDATE clients SET status = 'disconnected'
|
|
@@ -11367,6 +12290,7 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
11367
12290
|
await registerClient(app.db, {
|
|
11368
12291
|
clientId: data.clientId,
|
|
11369
12292
|
userId: session.userId,
|
|
12293
|
+
organizationId: session.organizationId,
|
|
11370
12294
|
instanceId,
|
|
11371
12295
|
hostname: data.hostname,
|
|
11372
12296
|
os: data.os,
|
|
@@ -11374,9 +12298,11 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
11374
12298
|
});
|
|
11375
12299
|
} catch (err) {
|
|
11376
12300
|
const message = err instanceof Error ? err.message : "client register failed";
|
|
12301
|
+
const code = err instanceof ClientOrgMismatchError ? err.code : void 0;
|
|
11377
12302
|
socket.send(JSON.stringify({
|
|
11378
12303
|
type: "client:register:rejected",
|
|
11379
|
-
message
|
|
12304
|
+
message,
|
|
12305
|
+
...code ? { code } : {}
|
|
11380
12306
|
}));
|
|
11381
12307
|
socket.close(4403, "client register rejected");
|
|
11382
12308
|
return;
|
|
@@ -11806,6 +12732,65 @@ async function contextTreeInfoRoutes(app) {
|
|
|
11806
12732
|
};
|
|
11807
12733
|
});
|
|
11808
12734
|
}
|
|
12735
|
+
/**
|
|
12736
|
+
* Resolve the client IP for rate-limit attribution.
|
|
12737
|
+
*
|
|
12738
|
+
* Headers like `x-forwarded-for` are client-controllable, so we only trust
|
|
12739
|
+
* them when the operator explicitly opts in via `HEARBACK_TRUST_PROXY_HEADERS=true`.
|
|
12740
|
+
* Otherwise fall back to Fastify's `req.ip` (socket-level) — degrades to one
|
|
12741
|
+
* bucket per upstream proxy, which is correct when no proxy metadata is
|
|
12742
|
+
* trustworthy.
|
|
12743
|
+
*/
|
|
12744
|
+
function resolveClientIp(req, trustProxyHeaders) {
|
|
12745
|
+
if (trustProxyHeaders) {
|
|
12746
|
+
const xff = req.headers["x-forwarded-for"];
|
|
12747
|
+
const first = Array.isArray(xff) ? xff[0] : typeof xff === "string" ? xff.split(",")[0] : void 0;
|
|
12748
|
+
if (first && first.trim().length > 0) return first.trim();
|
|
12749
|
+
}
|
|
12750
|
+
return req.ip ?? "";
|
|
12751
|
+
}
|
|
12752
|
+
/**
|
|
12753
|
+
* Mount the hearback-server handler onto Fastify. Register with
|
|
12754
|
+
* `{ prefix: "/feedback" }` so the widget's default `data-endpoint="/feedback"`
|
|
12755
|
+
* resolves to `/feedback/chat`, `/feedback/submit`, `/feedback/upload`, etc.
|
|
12756
|
+
*
|
|
12757
|
+
* We don't use hearback's prebuilt `feedbackPlugin` because it expects the
|
|
12758
|
+
* `fastify-raw-body` plugin. This adapter uses `addContentTypeParser` with
|
|
12759
|
+
* `parseAs: "buffer"` on an encapsulated child instance so upload bytes reach
|
|
12760
|
+
* the handler as a Buffer without pulling in another dependency.
|
|
12761
|
+
*/
|
|
12762
|
+
async function feedbackRoutes(app, config) {
|
|
12763
|
+
const { trustProxyHeaders, ...handlerConfig } = config;
|
|
12764
|
+
const handler = createFeedbackHandler(handlerConfig);
|
|
12765
|
+
app.addContentTypeParser(/^image\//, { parseAs: "buffer" }, (_req, body, done) => {
|
|
12766
|
+
done(null, body);
|
|
12767
|
+
});
|
|
12768
|
+
app.all("/*", async (req, reply) => {
|
|
12769
|
+
const path = req.url.replace(/^.*\/feedback/, "").split("?")[0] || "/";
|
|
12770
|
+
const ip = resolveClientIp(req, trustProxyHeaders);
|
|
12771
|
+
const headers = {};
|
|
12772
|
+
for (const [k, v] of Object.entries(req.headers)) headers[k] = Array.isArray(v) ? v[0] : v;
|
|
12773
|
+
const isUpload = path.replace(/\/$/, "").endsWith("/upload");
|
|
12774
|
+
const rawBody = isUpload && Buffer.isBuffer(req.body) ? req.body : void 0;
|
|
12775
|
+
const result = await handler.handle({
|
|
12776
|
+
method: req.method,
|
|
12777
|
+
path,
|
|
12778
|
+
body: isUpload ? {} : req.body,
|
|
12779
|
+
ip,
|
|
12780
|
+
headers,
|
|
12781
|
+
rawBody
|
|
12782
|
+
});
|
|
12783
|
+
reply.status(result.status).headers(result.headers);
|
|
12784
|
+
if (result.body && typeof result.body === "object" && Symbol.asyncIterator in result.body) {
|
|
12785
|
+
const stream = result.body;
|
|
12786
|
+
for await (const chunk of stream) reply.raw.write(chunk);
|
|
12787
|
+
reply.raw.end();
|
|
12788
|
+
return;
|
|
12789
|
+
}
|
|
12790
|
+
if (result.body === null) reply.send();
|
|
12791
|
+
else reply.send(result.body);
|
|
12792
|
+
});
|
|
12793
|
+
}
|
|
11809
12794
|
async function healthRoutes(app) {
|
|
11810
12795
|
app.get("/health", async () => {
|
|
11811
12796
|
try {
|
|
@@ -13868,6 +14853,21 @@ async function buildApp(config) {
|
|
|
13868
14853
|
await api.register(clientWsRoutes(notifier, config.instanceId), { prefix: "/agent/ws" });
|
|
13869
14854
|
await api.register(adminWsRoutes(notifier, config.secrets.jwtSecret), { prefix: "/ws" });
|
|
13870
14855
|
}, { prefix: "/api/v1" });
|
|
14856
|
+
if (config.feedback) {
|
|
14857
|
+
const feedbackConfig = config.feedback;
|
|
14858
|
+
await app.register(async (scope) => {
|
|
14859
|
+
await scope.register(feedbackRoutes, {
|
|
14860
|
+
repo: feedbackConfig.repo,
|
|
14861
|
+
githubToken: feedbackConfig.githubToken,
|
|
14862
|
+
llm: feedbackConfig.llm ? {
|
|
14863
|
+
apiKey: feedbackConfig.llm.apiKey,
|
|
14864
|
+
baseUrl: feedbackConfig.llm.baseUrl,
|
|
14865
|
+
model: feedbackConfig.llm.model
|
|
14866
|
+
} : void 0,
|
|
14867
|
+
trustProxyHeaders: feedbackConfig.trustProxyHeaders
|
|
14868
|
+
});
|
|
14869
|
+
}, { prefix: "/feedback" });
|
|
14870
|
+
}
|
|
13871
14871
|
const webDistPath = config.webDistPath;
|
|
13872
14872
|
if (webDistPath) {
|
|
13873
14873
|
const webRoot = resolve(webDistPath);
|
|
@@ -14426,4 +15426,4 @@ function createExecuteUpdate({ managed }) {
|
|
|
14426
15426
|
};
|
|
14427
15427
|
}
|
|
14428
15428
|
//#endregion
|
|
14429
|
-
export { checkServerHealth as A,
|
|
15429
|
+
export { configureClientLoggerForService as $, checkServerHealth as A, createOwner as B, runMigrations as C, checkDocker as D, checkDatabase as E, isDockerAvailable as F, setJsonMode as G, resolveReplyToFromEnv as H, stopPostgres as I, FirstTreeHubSDK as J, status as K, ClientRuntime as L, checkWebSocket as M, printResults as N, checkNodeVersion as O, ensurePostgres as P, applyClientLoggerConfig as Q, handleClientOrgMismatch as R, uninstallClientService as S, checkClientConfig as T, blank as U, hasUser as V, print as W, SessionRegistry as X, SdkError as Y, cleanWorkspaces as Z, runHomeMigration as _, showServiceLogs as a, isServiceSupported as b, COMMAND_VERSION as c, promptMissingFields as d, formatCheckReport as f, saveOnboardState as g, onboardCreate as h, parseDuration as i, checkServerReachable as j, checkServerConfig as k, isInteractive as l, onboardCheck as m, declineUpdate as n, validateLevel as o, loadOnboardState as p, ClientOrgMismatchError$1 as q, promptUpdate as r, startServer as s, createExecuteUpdate as t, promptAddAgent as u, getClientServiceStatus as v, checkAgentConfigs as w, resolveCliInvocation as x, installClientService as y, rotateClientIdWithBackup as z };
|