@agent-team-foundation/first-tree-hub 0.10.1 → 0.10.3
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-CtVqQA8a.mjs → bootstrap-CBAVWQUT.mjs} +9 -1
- package/dist/cli/index.mjs +63 -20
- package/dist/{feishu-DEmwoNn_.mjs → dist-DUCelK3Z.mjs} +202 -62
- package/dist/drizzle/0026_saas_onboarding.sql +153 -0
- package/dist/drizzle/0027_runtime_provider.sql +10 -0
- package/dist/drizzle/0028_auth_identity_user_github_unique.sql +12 -0
- package/dist/drizzle/meta/_journal.json +21 -0
- package/dist/feishu-Boy3n8CT.mjs +52 -0
- package/dist/{getMachineId-bsd-BB-fnFLA.mjs → getMachineId-bsd-D0w3uAZa.mjs} +1 -1
- package/dist/{getMachineId-darwin-DAYWNsYK.mjs → getMachineId-darwin-DOoYFb2_.mjs} +1 -1
- package/dist/{getMachineId-win-H5RT49ov.mjs → getMachineId-win-B6hY8edq.mjs} +1 -1
- package/dist/index.mjs +7 -5
- package/dist/invitation-BTlGMy0o-Coj07kYi.mjs +3 -0
- package/dist/invitation-C_zAhB8x-8Khychlu.mjs +258 -0
- package/dist/{observability-DDkJwSKv.mjs → observability-C08jUFsJ.mjs} +1 -1
- package/dist/{observability-DV_fQKqV-oxfXX6Z2.mjs → observability-DPyf745N-BSc8QNcR.mjs} +6 -6
- package/dist/{core-BgiFGT7Y.mjs → saas-connect-3p-vBkuY.mjs} +2459 -430
- package/dist/web/assets/index-CHoaSIzI.js +21 -0
- package/dist/web/assets/index-CP8uLPyO.css +1 -0
- package/dist/web/assets/index-D7OzKrI2.js +387 -0
- package/dist/web/index.html +2 -2
- package/package.json +3 -2
- package/dist/web/assets/index-Cd290Lq6.css +0 -1
- package/dist/web/assets/index-xi7JmCtW.js +0 -361
- /package/dist/{execAsync-CP8iWV5b.mjs → execAsync-XMc-nFn-.mjs} +0 -0
- /package/dist/{getMachineId-linux-BU7Fi6S0.mjs → getMachineId-linux-MlY63Zsw.mjs} +0 -0
- /package/dist/{getMachineId-unsupported-BhWCxKBo.mjs → getMachineId-unsupported-BS652RIy.mjs} +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { m as __toESM } from "./esm-CYu4tXXn.mjs";
|
|
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-
|
|
3
|
-
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-
|
|
4
|
-
import { $ as
|
|
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-DPyf745N-BSc8QNcR.mjs";
|
|
3
|
+
import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, b as resetConfig, c as saveCredentials, 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, x as resetConfigMeta } from "./bootstrap-CBAVWQUT.mjs";
|
|
4
|
+
import { $ as safeRedirectPath, A as createOrgFromMeSchema, B as imageInlineContentSchema, C as clientRegisterSchema, D as createAgentSchema, E as createAdapterMappingSchema, F as dryRunAgentRuntimeConfigSchema, G as linkTaskChatSchema, H as isRedactedEnvValue, I as extractMentions, J as notificationQuerySchema, K as loginSchema, L as githubCallbackQuerySchema, M as createTaskSchema, N as defaultRuntimeConfigPayload, O as createChatSchema, P as delegateFeishuUserSchema, Q as runtimeStateMessageSchema, R as githubDevCallbackQuerySchema, S as clientCapabilitiesSchema$1, T as createAdapterConfigSchema, U as isReservedAgentName$1, V as inboxPollQuerySchema, W as joinByInvitationSchema, X as rebindAgentSchema, Y as paginationQuerySchema, Z as refreshTokenSchema, _ as adminUpdateTaskSchema, _t as updateOrganizationSchema, a as AGENT_STATUSES, at as sessionEventMessageSchema, b as agentRuntimeConfigPayloadSchema$1, bt as wsAuthFrameSchema, ct as sessionStateMessageSchema, d as TASK_HEALTH_SIGNALS, dt as updateAdapterConfigSchema, et as scanMentionTokens, f as TASK_STATUSES, ft as updateAgentRuntimeConfigSchema, g as adminCreateTaskSchema, gt as updateMemberSchema, h as addParticipantSchema, ht as updateClientCapabilitiesSchema, i as AGENT_SOURCES, it as sessionCompletionMessageSchema, j as createOrganizationSchema, k as createMemberSchema, l as SYSTEM_CONFIG_DEFAULTS, lt as switchOrgSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, mt as updateChatSchema, n as AGENT_NAME_REGEX$1, nt as sendMessageSchema, o as AGENT_TYPES, ot as sessionEventSchema$1, p as TASK_TERMINAL_STATUSES, pt as updateAgentSchema, q as messageSourceSchema$1, r as AGENT_SELECTOR_HEADER$1, rt as sendToAgentSchema, s as AGENT_VISIBILITY, st as sessionReconcileRequestSchema, t as AGENT_BIND_REJECT_REASONS, tt as selfServiceFeishuBotSchema, u as TASK_CREATOR_TYPES, ut as taskListQuerySchema, v as agentBindRequestSchema, vt as updateSystemConfigSchema, w as connectTokenExchangeSchema, x as agentTypeSchema$1, y as agentPinnedMessageSchema$1, yt as updateTaskStatusSchema, z as githubStartQuerySchema } from "./dist-DUCelK3Z.mjs";
|
|
5
|
+
import { a as ForbiddenError, c as buildInviteUrl, d as getActiveInvitation, f as invitationRedemptions, g as recordRedemption, i as ConflictError, l as ensureActiveInvitation, m as organizations, n as BadRequestError, o as NotFoundError, p as invitations, r as ClientOrgMismatchError$1, s as UnauthorizedError, t as AppError, u as findActiveByToken, v as users, y as uuidv7 } from "./invitation-C_zAhB8x-8Khychlu.mjs";
|
|
5
6
|
import { createRequire } from "node:module";
|
|
6
7
|
import { ZodError, z } from "zod";
|
|
7
8
|
import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
|
@@ -15,20 +16,21 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|
|
15
16
|
import { parse, stringify } from "yaml";
|
|
16
17
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
17
18
|
import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
|
|
19
|
+
import { Codex } from "@openai/codex-sdk";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
18
21
|
import * as semver from "semver";
|
|
19
22
|
import bcrypt from "bcrypt";
|
|
20
23
|
import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
|
|
21
24
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
22
25
|
import postgres from "postgres";
|
|
23
26
|
import { confirm, input, password, select } from "@inquirer/prompts";
|
|
24
|
-
import { fileURLToPath } from "node:url";
|
|
25
27
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
|
28
|
+
import { bigserial, boolean, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique, uniqueIndex } from "drizzle-orm/pg-core";
|
|
26
29
|
import cors from "@fastify/cors";
|
|
27
30
|
import rateLimit from "@fastify/rate-limit";
|
|
28
31
|
import fastifyStatic from "@fastify/static";
|
|
29
32
|
import websocket from "@fastify/websocket";
|
|
30
33
|
import Fastify from "fastify";
|
|
31
|
-
import { bigserial, boolean, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique, uniqueIndex } from "drizzle-orm/pg-core";
|
|
32
34
|
import { SignJWT, jwtVerify } from "jose";
|
|
33
35
|
import { Client, EventDispatcher, LoggerLevel, WSClient } from "@larksuiteoapi/node-sdk";
|
|
34
36
|
//#region ../client/dist/observability-B4kO005X.mjs
|
|
@@ -392,7 +394,8 @@ z.enum([
|
|
|
392
394
|
"not_owned",
|
|
393
395
|
"agent_suspended",
|
|
394
396
|
"wrong_org",
|
|
395
|
-
"unknown_agent"
|
|
397
|
+
"unknown_agent",
|
|
398
|
+
"runtime_provider_mismatch"
|
|
396
399
|
]);
|
|
397
400
|
/** Header used on agent-scoped HTTP calls to select which managed agent the JWT acts as. */
|
|
398
401
|
const AGENT_SELECTOR_HEADER = "x-agent-id";
|
|
@@ -420,6 +423,7 @@ z.object({
|
|
|
420
423
|
}),
|
|
421
424
|
clients: z.number().int()
|
|
422
425
|
});
|
|
426
|
+
const runtimeProviderSchema = z.enum(["claude-code", "codex"]);
|
|
423
427
|
const agentTypeSchema = z.enum([
|
|
424
428
|
"human",
|
|
425
429
|
"personal_assistant",
|
|
@@ -461,7 +465,8 @@ z.object({
|
|
|
461
465
|
visibility: agentVisibilitySchema.optional(),
|
|
462
466
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
463
467
|
managerId: z.string().optional(),
|
|
464
|
-
clientId: z.string().min(1).max(100).optional()
|
|
468
|
+
clientId: z.string().min(1).max(100).optional(),
|
|
469
|
+
runtimeProvider: runtimeProviderSchema.optional()
|
|
465
470
|
});
|
|
466
471
|
z.object({
|
|
467
472
|
type: agentTypeSchema.optional(),
|
|
@@ -472,6 +477,11 @@ z.object({
|
|
|
472
477
|
managerId: z.string().nullable().optional(),
|
|
473
478
|
clientId: z.string().min(1).max(100).nullable().optional()
|
|
474
479
|
});
|
|
480
|
+
z.object({
|
|
481
|
+
clientId: z.string().min(1).max(100),
|
|
482
|
+
runtimeProvider: runtimeProviderSchema,
|
|
483
|
+
force: z.boolean().optional()
|
|
484
|
+
});
|
|
475
485
|
z.object({
|
|
476
486
|
uuid: z.string(),
|
|
477
487
|
name: z.string().nullable(),
|
|
@@ -486,6 +496,7 @@ z.object({
|
|
|
486
496
|
metadata: z.record(z.string(), z.unknown()),
|
|
487
497
|
managerId: z.string().nullable(),
|
|
488
498
|
clientId: z.string().nullable(),
|
|
499
|
+
runtimeProvider: runtimeProviderSchema,
|
|
489
500
|
presenceStatus: presenceStatusSchema.optional(),
|
|
490
501
|
createdAt: z.string(),
|
|
491
502
|
updatedAt: z.string()
|
|
@@ -505,14 +516,16 @@ const agentPinnedMessageSchema = z.object({
|
|
|
505
516
|
agentId: z.string(),
|
|
506
517
|
name: z.string().nullable(),
|
|
507
518
|
displayName: z.string(),
|
|
508
|
-
agentType: agentTypeSchema
|
|
519
|
+
agentType: agentTypeSchema,
|
|
520
|
+
runtimeProvider: runtimeProviderSchema
|
|
509
521
|
});
|
|
510
522
|
/**
|
|
511
|
-
* Agent runtime configuration
|
|
523
|
+
* Agent runtime configuration.
|
|
512
524
|
*
|
|
513
525
|
* Defines the 5 user-tunable field groups that the Hub centrally manages
|
|
514
526
|
* and pushes down to the client runtime: prompt append, model, MCP servers,
|
|
515
|
-
* env vars, and Git repos.
|
|
527
|
+
* env vars, and Git repos. Tagged by `kind` (a runtime provider) so future
|
|
528
|
+
* provider-specific fields can land on a dedicated variant.
|
|
516
529
|
*
|
|
517
530
|
* NOTE: do not co-locate with `packages/shared/src/config/` — that namespace
|
|
518
531
|
* is reserved for the local YAML config (`agent.yaml` / server / client) and
|
|
@@ -556,9 +569,11 @@ const gitRepoSchema = z.object({
|
|
|
556
569
|
localPath: z.string().min(1).optional()
|
|
557
570
|
});
|
|
558
571
|
/**
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
572
|
+
* Untagged base shape — 5 user-tunable fields, no `kind` discriminator.
|
|
573
|
+
* Used for `.partial()` derivations on the PATCH side, where `kind` is
|
|
574
|
+
* pinned to `agents.runtime_provider` and never changes via config PATCH.
|
|
575
|
+
* Zod 4 forbids `.partial()` on a refined object, so we keep refinements
|
|
576
|
+
* on the tagged schema below.
|
|
562
577
|
*/
|
|
563
578
|
const agentRuntimeConfigPayloadShape = z.object({
|
|
564
579
|
prompt: promptConfigSchema.default({ append: "" }),
|
|
@@ -567,6 +582,17 @@ const agentRuntimeConfigPayloadShape = z.object({
|
|
|
567
582
|
env: z.array(envEntrySchema).default([]),
|
|
568
583
|
gitRepos: z.array(gitRepoSchema).default([])
|
|
569
584
|
});
|
|
585
|
+
/**
|
|
586
|
+
* Tagged variants — read-side, full payload including `kind`. Adding a new
|
|
587
|
+
* provider means adding a variant here, plus a handler factory and a
|
|
588
|
+
* capability probe module on the client side.
|
|
589
|
+
*
|
|
590
|
+
* Provider-specific fields (e.g. codex `sandboxMode`) belong on the
|
|
591
|
+
* matching variant, not on the base shape.
|
|
592
|
+
*/
|
|
593
|
+
const claudeRuntimeConfigPayloadShape = agentRuntimeConfigPayloadShape.extend({ kind: z.literal("claude-code") });
|
|
594
|
+
const codexRuntimeConfigPayloadShape = agentRuntimeConfigPayloadShape.extend({ kind: z.literal("codex") });
|
|
595
|
+
const taggedPayloadUnion = z.discriminatedUnion("kind", [claudeRuntimeConfigPayloadShape, codexRuntimeConfigPayloadShape]);
|
|
570
596
|
const payloadDuplicatesRefinement = (payload, ctx) => {
|
|
571
597
|
const seenMcp = /* @__PURE__ */ new Set();
|
|
572
598
|
payload.mcpServers.forEach((server, idx) => {
|
|
@@ -611,7 +637,21 @@ const payloadDuplicatesRefinement = (payload, ctx) => {
|
|
|
611
637
|
seenPaths.add(path);
|
|
612
638
|
});
|
|
613
639
|
};
|
|
614
|
-
|
|
640
|
+
/**
|
|
641
|
+
* Read-side full payload schema. Rows persisted before 0026 do not carry
|
|
642
|
+
* `kind`; `z.preprocess` injects `"claude-code"` so they parse cleanly into
|
|
643
|
+
* the claude variant. The service layer separately enforces
|
|
644
|
+
* `payload.kind === agents.runtime_provider` on writes.
|
|
645
|
+
*/
|
|
646
|
+
const agentRuntimeConfigPayloadSchema = z.preprocess((input) => {
|
|
647
|
+
if (input && typeof input === "object" && !Array.isArray(input) && !("kind" in input)) return {
|
|
648
|
+
...input,
|
|
649
|
+
kind: "claude-code"
|
|
650
|
+
};
|
|
651
|
+
return input;
|
|
652
|
+
}, taggedPayloadUnion).superRefine((payload, ctx) => {
|
|
653
|
+
payloadDuplicatesRefinement(payload, ctx);
|
|
654
|
+
});
|
|
615
655
|
const agentRuntimeConfigSchema = z.object({
|
|
616
656
|
agentId: z.string(),
|
|
617
657
|
version: z.number().int().positive(),
|
|
@@ -720,6 +760,37 @@ z.object({
|
|
|
720
760
|
os: z.string().max(50).optional(),
|
|
721
761
|
sdkVersion: z.string().max(50).optional()
|
|
722
762
|
});
|
|
763
|
+
const capabilityStateSchema = z.enum([
|
|
764
|
+
"ok",
|
|
765
|
+
"missing",
|
|
766
|
+
"unauthenticated",
|
|
767
|
+
"error"
|
|
768
|
+
]);
|
|
769
|
+
const capabilityAuthMethodSchema = z.enum([
|
|
770
|
+
"api_key",
|
|
771
|
+
"oauth",
|
|
772
|
+
"auth_json",
|
|
773
|
+
"none"
|
|
774
|
+
]);
|
|
775
|
+
const capabilityEntrySchema = z.object({
|
|
776
|
+
state: capabilityStateSchema,
|
|
777
|
+
available: z.boolean(),
|
|
778
|
+
authenticated: z.boolean(),
|
|
779
|
+
sdkVersion: z.string().nullable().optional(),
|
|
780
|
+
authMethod: capabilityAuthMethodSchema,
|
|
781
|
+
error: z.string().nullable().optional(),
|
|
782
|
+
detectedAt: z.string()
|
|
783
|
+
});
|
|
784
|
+
/**
|
|
785
|
+
* Capabilities snapshot keyed by runtime provider name. Recorded as a plain
|
|
786
|
+
* `Record<string, CapabilityEntry>` — every entry is optional (a client may
|
|
787
|
+
* report only the runtimes it actually probed) and the key set evolves
|
|
788
|
+
* naturally as new providers ship without a schema migration. Service-layer
|
|
789
|
+
* lookups (`agents.runtime_provider ∈ keys(capabilities)`) treat the keys
|
|
790
|
+
* as `RuntimeProvider` strings.
|
|
791
|
+
*/
|
|
792
|
+
const clientCapabilitiesSchema = z.record(z.string(), capabilityEntrySchema);
|
|
793
|
+
z.object({ capabilities: clientCapabilitiesSchema });
|
|
723
794
|
z.object({
|
|
724
795
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
725
796
|
cursor: z.string().optional()
|
|
@@ -897,6 +968,39 @@ z.object({
|
|
|
897
968
|
ackedAt: z.string().nullable()
|
|
898
969
|
}).extend({ message: clientMessageSchema });
|
|
899
970
|
z.object({ limit: z.coerce.number().int().min(1).max(50).default(10) });
|
|
971
|
+
z.object({
|
|
972
|
+
organizationId: z.string(),
|
|
973
|
+
organizationName: z.string(),
|
|
974
|
+
organizationDisplayName: z.string(),
|
|
975
|
+
role: z.string()
|
|
976
|
+
});
|
|
977
|
+
z.object({
|
|
978
|
+
id: z.string(),
|
|
979
|
+
organizationId: z.string(),
|
|
980
|
+
token: z.string(),
|
|
981
|
+
inviteUrl: z.string(),
|
|
982
|
+
role: z.string(),
|
|
983
|
+
createdAt: z.string(),
|
|
984
|
+
expiresAt: z.string().nullable()
|
|
985
|
+
});
|
|
986
|
+
z.object({ token: z.string().min(1) });
|
|
987
|
+
z.object({}).optional();
|
|
988
|
+
z.enum([
|
|
989
|
+
"connect",
|
|
990
|
+
"create_agent",
|
|
991
|
+
"completed"
|
|
992
|
+
]);
|
|
993
|
+
z.object({
|
|
994
|
+
id: z.string(),
|
|
995
|
+
name: z.string(),
|
|
996
|
+
displayName: z.string(),
|
|
997
|
+
role: z.enum(["admin", "member"])
|
|
998
|
+
});
|
|
999
|
+
z.object({
|
|
1000
|
+
name: z.string().min(2).max(50).regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
1001
|
+
displayName: z.string().min(1).max(200)
|
|
1002
|
+
});
|
|
1003
|
+
z.object({ organizationId: z.string().min(1) });
|
|
900
1004
|
const memberRoleSchema = z.enum(["admin", "member"]);
|
|
901
1005
|
const memberSchema = z.object({
|
|
902
1006
|
id: z.string(),
|
|
@@ -953,6 +1057,18 @@ z.object({
|
|
|
953
1057
|
read: z.enum(["true", "false"]).transform((v) => v === "true").optional(),
|
|
954
1058
|
agentId: z.string().optional()
|
|
955
1059
|
});
|
|
1060
|
+
z.object({ next: z.string().max(256).optional() });
|
|
1061
|
+
z.object({
|
|
1062
|
+
code: z.string().min(1),
|
|
1063
|
+
state: z.string().min(1)
|
|
1064
|
+
});
|
|
1065
|
+
z.object({
|
|
1066
|
+
githubId: z.string().min(1),
|
|
1067
|
+
login: z.string().min(1),
|
|
1068
|
+
email: z.string().email().optional(),
|
|
1069
|
+
displayName: z.string().optional(),
|
|
1070
|
+
next: z.string().max(256).optional()
|
|
1071
|
+
});
|
|
956
1072
|
const UUID_PATTERN$1 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
957
1073
|
z.object({
|
|
958
1074
|
name: z.string().min(2).max(50).regex(/^[a-z0-9][a-z0-9-]*$/, "Must start with a letter or digit and contain only lowercase alphanumeric and hyphens").refine((v) => !UUID_PATTERN$1.test(v), "Name must not be a UUID format"),
|
|
@@ -1330,7 +1446,8 @@ defineConfig({
|
|
|
1330
1446
|
},
|
|
1331
1447
|
server: {
|
|
1332
1448
|
port: field(z.number().default(8e3), { env: "FIRST_TREE_HUB_PORT" }),
|
|
1333
|
-
host: field(z.string().default("127.0.0.1"), { env: "FIRST_TREE_HUB_HOST" })
|
|
1449
|
+
host: field(z.string().default("127.0.0.1"), { env: "FIRST_TREE_HUB_HOST" }),
|
|
1450
|
+
publicUrl: field(z.string().optional(), { env: "FIRST_TREE_HUB_PUBLIC_URL" })
|
|
1334
1451
|
},
|
|
1335
1452
|
secrets: {
|
|
1336
1453
|
jwtSecret: field(z.string(), {
|
|
@@ -1358,6 +1475,13 @@ defineConfig({
|
|
|
1358
1475
|
}),
|
|
1359
1476
|
allowedOrg: field(z.string().optional(), { env: "FIRST_TREE_HUB_GITHUB_ALLOWED_ORG" })
|
|
1360
1477
|
},
|
|
1478
|
+
oauth: optional({ github: optional({
|
|
1479
|
+
clientId: field(z.string(), { env: "FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_ID" }),
|
|
1480
|
+
clientSecret: field(z.string(), {
|
|
1481
|
+
env: "FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_SECRET",
|
|
1482
|
+
secret: true
|
|
1483
|
+
})
|
|
1484
|
+
}) }),
|
|
1361
1485
|
cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_HUB_CORS_ORIGIN" }) }),
|
|
1362
1486
|
rateLimit: optional({
|
|
1363
1487
|
max: field(z.number().default(100), { env: "FIRST_TREE_HUB_RATE_LIMIT_MAX" }),
|
|
@@ -1481,6 +1605,25 @@ var FirstTreeHubSDK = class {
|
|
|
1481
1605
|
async fetchAgentConfig() {
|
|
1482
1606
|
return this.requestJson("/api/v1/agent/config");
|
|
1483
1607
|
}
|
|
1608
|
+
/**
|
|
1609
|
+
* Member-scoped: report this client's runtime-provider capabilities. The
|
|
1610
|
+
* server stores them under `clients.metadata.capabilities` after checking
|
|
1611
|
+
* that the connected member owns the client.
|
|
1612
|
+
*/
|
|
1613
|
+
async updateCapabilities(clientId, capabilities) {
|
|
1614
|
+
await this.requestVoid(`/api/v1/clients/${encodeURIComponent(clientId)}/capabilities`, {
|
|
1615
|
+
method: "PATCH",
|
|
1616
|
+
body: JSON.stringify({ capabilities })
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* Member-scoped: every agent pinned to a client owned by the calling user.
|
|
1621
|
+
* Used by client startup to reconcile the local `agent.yaml::runtime` with
|
|
1622
|
+
* the authoritative `agents.runtime_provider` before spawning handlers.
|
|
1623
|
+
*/
|
|
1624
|
+
async listMyAgents() {
|
|
1625
|
+
return this.requestJson("/api/v1/clients/me/agents");
|
|
1626
|
+
}
|
|
1484
1627
|
async isHubReachable(timeoutMs = 3e3) {
|
|
1485
1628
|
try {
|
|
1486
1629
|
const url = `${this._baseUrl}/api/v1/health`;
|
|
@@ -1576,7 +1719,7 @@ var SdkError = class extends Error {
|
|
|
1576
1719
|
* different organization. The CLI layer detects this via `instanceof` and
|
|
1577
1720
|
* prompts the user before rotating the local clientId.
|
|
1578
1721
|
*/
|
|
1579
|
-
var ClientOrgMismatchError
|
|
1722
|
+
var ClientOrgMismatchError = class extends Error {
|
|
1580
1723
|
code = "CLIENT_ORG_MISMATCH";
|
|
1581
1724
|
constructor(message = "Client belongs to a different organization") {
|
|
1582
1725
|
super(message);
|
|
@@ -1886,7 +2029,7 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1886
2029
|
const code = typeof msg.code === "string" ? msg.code : void 0;
|
|
1887
2030
|
const message = typeof msg.message === "string" ? msg.message : "unknown";
|
|
1888
2031
|
this.closing = true;
|
|
1889
|
-
const err = code === "CLIENT_ORG_MISMATCH" ? new ClientOrgMismatchError
|
|
2032
|
+
const err = code === "CLIENT_ORG_MISMATCH" ? new ClientOrgMismatchError(message) : /* @__PURE__ */ new Error(`client:register rejected: ${message}`);
|
|
1890
2033
|
this.lastHandshakeError = err;
|
|
1891
2034
|
this.wsLogger.error({
|
|
1892
2035
|
code,
|
|
@@ -2120,13 +2263,23 @@ function getHandlerFactory(type) {
|
|
|
2120
2263
|
}
|
|
2121
2264
|
join(DEFAULT_DATA_DIR, "context-tree");
|
|
2122
2265
|
/**
|
|
2123
|
-
*
|
|
2266
|
+
* Marker file written into every workspace so the Codex CLI's project-root
|
|
2267
|
+
* detection (configured via `project_root_markers: ["first-tree-workspace"]`)
|
|
2268
|
+
* stops at the workspace boundary instead of walking up the filesystem and
|
|
2269
|
+
* loading an unintended `AGENTS.md` from the operator's home or repo root.
|
|
2270
|
+
*/
|
|
2271
|
+
const FIRST_TREE_WORKSPACE_MARKER = ".first-tree-workspace";
|
|
2272
|
+
/**
|
|
2273
|
+
* Bootstrap a workspace with `.agent/` directory files plus the workspace
|
|
2274
|
+
* root marker (and an optional provider-specific briefing).
|
|
2124
2275
|
*
|
|
2125
|
-
* Writes identity.json, context/
|
|
2126
|
-
*
|
|
2276
|
+
* Writes identity.json, context/agent-instructions.md (if context tree
|
|
2277
|
+
* available), tools.md, the `.first-tree-workspace` marker, and — for
|
|
2278
|
+
* Codex — `AGENTS.md`. Idempotent: safe to call on every handler start()
|
|
2279
|
+
* and on resume().
|
|
2127
2280
|
*/
|
|
2128
2281
|
function bootstrapWorkspace(options) {
|
|
2129
|
-
const { workspacePath, identity, contextTreePath, serverUrl, chatId } = options;
|
|
2282
|
+
const { workspacePath, identity, contextTreePath, serverUrl, chatId, briefing } = options;
|
|
2130
2283
|
const agentDir = join(workspacePath, ".agent");
|
|
2131
2284
|
const contextDir = join(agentDir, "context");
|
|
2132
2285
|
if (existsSync(contextDir)) rmSync(contextDir, {
|
|
@@ -2152,6 +2305,8 @@ function bootstrapWorkspace(options) {
|
|
|
2152
2305
|
if (existsSync(rootNodePath)) copyFileSync(rootNodePath, join(contextDir, "domain-map.md"));
|
|
2153
2306
|
}
|
|
2154
2307
|
writeFileSync(join(agentDir, "tools.md"), generateToolsDoc(), "utf-8");
|
|
2308
|
+
writeFileSync(join(workspacePath, FIRST_TREE_WORKSPACE_MARKER), "", "utf-8");
|
|
2309
|
+
if (briefing?.format === "agents-md") writeFileSync(join(workspacePath, "AGENTS.md"), briefing.content, "utf-8");
|
|
2155
2310
|
}
|
|
2156
2311
|
function defaultInstallExec(command, args, options) {
|
|
2157
2312
|
execFileSync(command, args, {
|
|
@@ -2877,7 +3032,7 @@ function splitPathExt(pathext) {
|
|
|
2877
3032
|
}
|
|
2878
3033
|
const MAX_RETRIES = 2;
|
|
2879
3034
|
const TOOL_RESULT_PREVIEW_LIMIT = 400;
|
|
2880
|
-
const ASSISTANT_TEXT_EVENT_LIMIT = 8e3;
|
|
3035
|
+
const ASSISTANT_TEXT_EVENT_LIMIT$1 = 8e3;
|
|
2881
3036
|
const SUPPORTED_IMAGE_MIMES = new Set(SUPPORTED_IMAGE_MIMES$1);
|
|
2882
3037
|
const MIME_TO_EXT = {
|
|
2883
3038
|
"image/png": "png",
|
|
@@ -3003,7 +3158,7 @@ function createToolCallProcessor(emit) {
|
|
|
3003
3158
|
if (text.length === 0) continue;
|
|
3004
3159
|
emit({
|
|
3005
3160
|
kind: "assistant_text",
|
|
3006
|
-
payload: { text: text.slice(0, ASSISTANT_TEXT_EVENT_LIMIT) }
|
|
3161
|
+
payload: { text: text.slice(0, ASSISTANT_TEXT_EVENT_LIMIT$1) }
|
|
3007
3162
|
});
|
|
3008
3163
|
} else if (isThinkingBlock(block)) emit({
|
|
3009
3164
|
kind: "thinking",
|
|
@@ -3547,6 +3702,457 @@ function generateClaudeMd(workspacePath, identity, contextTreePath) {
|
|
|
3547
3702
|
}
|
|
3548
3703
|
writeFileSync(join(workspacePath, "CLAUDE.md"), sections.join("\n"), "utf-8");
|
|
3549
3704
|
}
|
|
3705
|
+
const ASSISTANT_TEXT_EVENT_LIMIT = 8e3;
|
|
3706
|
+
const RESULT_PREVIEW_LIMIT = 400;
|
|
3707
|
+
/**
|
|
3708
|
+
* Build the per-turn `ThreadOptions` Codex consumes. Exported so unit tests
|
|
3709
|
+
* can lock the auth-mode-friendly defaults (notably `model` only set when
|
|
3710
|
+
* the operator chose one).
|
|
3711
|
+
*/
|
|
3712
|
+
function buildCodexThreadOptions(payload, workspaceCwd) {
|
|
3713
|
+
const additionalDirectories = [];
|
|
3714
|
+
for (const repo of payload.gitRepos) {
|
|
3715
|
+
const localPath = repo.localPath ?? deriveRepoLocalPath(repo.url);
|
|
3716
|
+
if (!localPath) continue;
|
|
3717
|
+
additionalDirectories.push(join(workspaceCwd, localPath));
|
|
3718
|
+
}
|
|
3719
|
+
const opts = {
|
|
3720
|
+
workingDirectory: workspaceCwd,
|
|
3721
|
+
skipGitRepoCheck: true,
|
|
3722
|
+
sandboxMode: "workspace-write",
|
|
3723
|
+
approvalPolicy: "never",
|
|
3724
|
+
modelReasoningEffort: "high",
|
|
3725
|
+
webSearchEnabled: false,
|
|
3726
|
+
additionalDirectories
|
|
3727
|
+
};
|
|
3728
|
+
if (payload.model) opts.model = payload.model;
|
|
3729
|
+
return opts;
|
|
3730
|
+
}
|
|
3731
|
+
/**
|
|
3732
|
+
* Codex Handler — session-oriented handler using `@openai/codex-sdk`.
|
|
3733
|
+
*
|
|
3734
|
+
* Each instance owns one Thread for one chat. Each turn is a fresh
|
|
3735
|
+
* `runStreamed()` call (Codex CLI is run-to-completion per turn). Inject
|
|
3736
|
+
* during an active turn buffers messages and runs them as a follow-up turn
|
|
3737
|
+
* the moment the current one completes.
|
|
3738
|
+
*
|
|
3739
|
+
* Key footguns observed end-to-end (private plan §10.7):
|
|
3740
|
+
* - F1: providing `env` to Codex SDK does NOT inherit `process.env`; we
|
|
3741
|
+
* explicitly merge.
|
|
3742
|
+
* - F2: `resumeThread(id)` does NOT inherit `ThreadOptions`; we re-pass
|
|
3743
|
+
* them every time.
|
|
3744
|
+
* - F3: `modelReasoningEffort: "minimal"` is incompatible with default
|
|
3745
|
+
* tools; we default to `"high"` with `webSearchEnabled: false`.
|
|
3746
|
+
* - F6: `Thread` has no close/dispose — shutdown is exclusively
|
|
3747
|
+
* `AbortController.abort()`.
|
|
3748
|
+
*/
|
|
3749
|
+
const createCodexHandler = (config) => {
|
|
3750
|
+
const workspaceRoot = config.workspaceRoot;
|
|
3751
|
+
const agentConfigCache = config.agentConfigCache ?? null;
|
|
3752
|
+
const gitMirrorManager = config.gitMirrorManager ?? null;
|
|
3753
|
+
const contextTreePath = config.contextTreePath ?? null;
|
|
3754
|
+
let cwd = null;
|
|
3755
|
+
let codex = null;
|
|
3756
|
+
let thread = null;
|
|
3757
|
+
let threadId = null;
|
|
3758
|
+
let currentAbort = null;
|
|
3759
|
+
let currentTurnPromise = null;
|
|
3760
|
+
let ctx = null;
|
|
3761
|
+
let drainScheduled = false;
|
|
3762
|
+
const queuedMessages = [];
|
|
3763
|
+
const ownedWorktrees = [];
|
|
3764
|
+
function buildEnv(sessionCtx) {
|
|
3765
|
+
const env = {};
|
|
3766
|
+
for (const [k, v] of Object.entries(process.env)) if (typeof v === "string") env[k] = v;
|
|
3767
|
+
const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
|
|
3768
|
+
if (payload) for (const e of payload.env) env[e.key] = e.value;
|
|
3769
|
+
const merged = sessionCtx.buildAgentEnv(env);
|
|
3770
|
+
const out = {};
|
|
3771
|
+
for (const [k, v] of Object.entries(merged)) if (typeof v === "string") out[k] = v;
|
|
3772
|
+
return out;
|
|
3773
|
+
}
|
|
3774
|
+
function buildCodexConfig(payload) {
|
|
3775
|
+
const cfg = { project_root_markers: [FIRST_TREE_WORKSPACE_MARKER] };
|
|
3776
|
+
if (payload.mcpServers.length === 0) return cfg;
|
|
3777
|
+
const mcpServers = {};
|
|
3778
|
+
for (const m of payload.mcpServers) if (m.transport === "stdio") mcpServers[m.name] = {
|
|
3779
|
+
command: m.command,
|
|
3780
|
+
args: m.args ?? []
|
|
3781
|
+
};
|
|
3782
|
+
else {
|
|
3783
|
+
const entry = { url: m.url };
|
|
3784
|
+
if (m.headers) entry.headers = m.headers;
|
|
3785
|
+
mcpServers[m.name] = entry;
|
|
3786
|
+
}
|
|
3787
|
+
cfg.mcp_servers = mcpServers;
|
|
3788
|
+
return cfg;
|
|
3789
|
+
}
|
|
3790
|
+
function buildAgentBriefing(payload) {
|
|
3791
|
+
const lines = [];
|
|
3792
|
+
lines.push("# Agent Briefing");
|
|
3793
|
+
lines.push("");
|
|
3794
|
+
if (payload.prompt.append.trim()) {
|
|
3795
|
+
lines.push(payload.prompt.append.trim());
|
|
3796
|
+
lines.push("");
|
|
3797
|
+
}
|
|
3798
|
+
lines.push("Refer to `.agent/identity.json` for your agent identity, `.agent/tools.md` for the");
|
|
3799
|
+
lines.push("first-tree-hub SDK reference, and `.agent/context/` for organisational context");
|
|
3800
|
+
lines.push("(when configured).");
|
|
3801
|
+
return lines.join("\n").concat("\n");
|
|
3802
|
+
}
|
|
3803
|
+
function toCodexInput(message, sessionCtx) {
|
|
3804
|
+
return sessionCtx.formatInboundContent(message).then((text) => text);
|
|
3805
|
+
}
|
|
3806
|
+
async function prepareGitWorktrees(payload, workspaceCwd, chatId) {
|
|
3807
|
+
if (!gitMirrorManager) return;
|
|
3808
|
+
for (const repo of payload.gitRepos) {
|
|
3809
|
+
const localPath = repo.localPath ?? deriveRepoLocalPath(repo.url);
|
|
3810
|
+
if (!localPath) continue;
|
|
3811
|
+
const targetPath = join(workspaceCwd, localPath);
|
|
3812
|
+
if (existsSync(targetPath)) continue;
|
|
3813
|
+
try {
|
|
3814
|
+
await gitMirrorManager.ensureMirror(repo.url);
|
|
3815
|
+
await gitMirrorManager.fetchMirror(repo.url);
|
|
3816
|
+
const result = await gitMirrorManager.createWorktree({
|
|
3817
|
+
url: repo.url,
|
|
3818
|
+
ref: repo.ref,
|
|
3819
|
+
targetPath,
|
|
3820
|
+
sessionKey: chatId
|
|
3821
|
+
});
|
|
3822
|
+
ownedWorktrees.push({
|
|
3823
|
+
url: repo.url,
|
|
3824
|
+
path: targetPath,
|
|
3825
|
+
branchName: result.branchName
|
|
3826
|
+
});
|
|
3827
|
+
} catch (err) {
|
|
3828
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3829
|
+
ctx?.log(`codex git materialisation skipped (${repo.url}): ${msg}`);
|
|
3830
|
+
}
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
function emitToolCall(sessionCtx, payload) {
|
|
3834
|
+
const event = {
|
|
3835
|
+
kind: "tool_call",
|
|
3836
|
+
payload: {
|
|
3837
|
+
toolUseId: payload.toolUseId,
|
|
3838
|
+
name: payload.name,
|
|
3839
|
+
args: payload.args,
|
|
3840
|
+
status: payload.status,
|
|
3841
|
+
...payload.resultPreview ? { resultPreview: payload.resultPreview.slice(0, RESULT_PREVIEW_LIMIT) } : {}
|
|
3842
|
+
}
|
|
3843
|
+
};
|
|
3844
|
+
sessionCtx.emitEvent(event);
|
|
3845
|
+
}
|
|
3846
|
+
/**
|
|
3847
|
+
* Translate one terminal `item.completed` payload into the runtime's event
|
|
3848
|
+
* stream and, when the item is the assistant's final message, return the
|
|
3849
|
+
* raw text so `runTurn` can stitch the per-turn reply together.
|
|
3850
|
+
*/
|
|
3851
|
+
function processItem(item, sessionCtx) {
|
|
3852
|
+
switch (item.type) {
|
|
3853
|
+
case "agent_message":
|
|
3854
|
+
if (!item.text.trim()) return "";
|
|
3855
|
+
sessionCtx.emitEvent({
|
|
3856
|
+
kind: "assistant_text",
|
|
3857
|
+
payload: { text: item.text.slice(0, ASSISTANT_TEXT_EVENT_LIMIT) }
|
|
3858
|
+
});
|
|
3859
|
+
return item.text;
|
|
3860
|
+
case "command_execution": {
|
|
3861
|
+
const status = item.status === "completed" ? "ok" : item.status === "failed" ? "error" : "pending";
|
|
3862
|
+
emitToolCall(sessionCtx, {
|
|
3863
|
+
toolUseId: item.id,
|
|
3864
|
+
name: "command",
|
|
3865
|
+
args: { command: item.command },
|
|
3866
|
+
status,
|
|
3867
|
+
resultPreview: item.aggregated_output
|
|
3868
|
+
});
|
|
3869
|
+
return "";
|
|
3870
|
+
}
|
|
3871
|
+
case "file_change": {
|
|
3872
|
+
const status = item.status === "completed" ? "ok" : "error";
|
|
3873
|
+
emitToolCall(sessionCtx, {
|
|
3874
|
+
toolUseId: item.id,
|
|
3875
|
+
name: "file_change",
|
|
3876
|
+
args: { changes: item.changes },
|
|
3877
|
+
status
|
|
3878
|
+
});
|
|
3879
|
+
return "";
|
|
3880
|
+
}
|
|
3881
|
+
case "mcp_tool_call": {
|
|
3882
|
+
const status = item.status === "completed" ? "ok" : item.status === "failed" ? "error" : "pending";
|
|
3883
|
+
const resultPreview = item.error ? `error: ${item.error.message}` : item.result ? JSON.stringify(item.result.structured_content ?? item.result.content) : void 0;
|
|
3884
|
+
emitToolCall(sessionCtx, {
|
|
3885
|
+
toolUseId: item.id,
|
|
3886
|
+
name: `mcp:${item.server}/${item.tool}`,
|
|
3887
|
+
args: item.arguments,
|
|
3888
|
+
status,
|
|
3889
|
+
resultPreview
|
|
3890
|
+
});
|
|
3891
|
+
return "";
|
|
3892
|
+
}
|
|
3893
|
+
case "web_search":
|
|
3894
|
+
emitToolCall(sessionCtx, {
|
|
3895
|
+
toolUseId: item.id,
|
|
3896
|
+
name: "web_search",
|
|
3897
|
+
args: { query: item.query },
|
|
3898
|
+
status: "ok"
|
|
3899
|
+
});
|
|
3900
|
+
return "";
|
|
3901
|
+
case "todo_list":
|
|
3902
|
+
emitToolCall(sessionCtx, {
|
|
3903
|
+
toolUseId: item.id,
|
|
3904
|
+
name: "todo_list",
|
|
3905
|
+
args: { items: item.items },
|
|
3906
|
+
status: "ok"
|
|
3907
|
+
});
|
|
3908
|
+
return "";
|
|
3909
|
+
case "reasoning":
|
|
3910
|
+
sessionCtx.emitEvent({
|
|
3911
|
+
kind: "thinking",
|
|
3912
|
+
payload: {}
|
|
3913
|
+
});
|
|
3914
|
+
return "";
|
|
3915
|
+
case "error":
|
|
3916
|
+
sessionCtx.emitEvent({
|
|
3917
|
+
kind: "error",
|
|
3918
|
+
payload: {
|
|
3919
|
+
source: "tool",
|
|
3920
|
+
message: item.message
|
|
3921
|
+
}
|
|
3922
|
+
});
|
|
3923
|
+
return "";
|
|
3924
|
+
default: return "";
|
|
3925
|
+
}
|
|
3926
|
+
}
|
|
3927
|
+
async function runTurn(input, sessionCtx) {
|
|
3928
|
+
const activeThread = thread;
|
|
3929
|
+
if (!activeThread) return;
|
|
3930
|
+
const abort = new AbortController();
|
|
3931
|
+
currentAbort = abort;
|
|
3932
|
+
sessionCtx.setRuntimeState("working");
|
|
3933
|
+
const assistantTexts = [];
|
|
3934
|
+
let turnFailed = false;
|
|
3935
|
+
const promise = (async () => {
|
|
3936
|
+
try {
|
|
3937
|
+
const streamed = await activeThread.runStreamed(input, { signal: abort.signal });
|
|
3938
|
+
for await (const event of streamed.events) {
|
|
3939
|
+
if (abort.signal.aborted) break;
|
|
3940
|
+
sessionCtx.touch();
|
|
3941
|
+
if (event.type === "thread.started") threadId = event.thread_id;
|
|
3942
|
+
else if (event.type === "turn.started") {} else if (event.type === "item.completed") {
|
|
3943
|
+
const text = processItem(event.item, sessionCtx);
|
|
3944
|
+
if (text) assistantTexts.push(text);
|
|
3945
|
+
} else if (event.type === "item.started" || event.type === "item.updated") {} else if (event.type === "turn.completed") {} else if (event.type === "turn.failed") {
|
|
3946
|
+
turnFailed = true;
|
|
3947
|
+
sessionCtx.emitEvent({
|
|
3948
|
+
kind: "error",
|
|
3949
|
+
payload: {
|
|
3950
|
+
source: "sdk",
|
|
3951
|
+
message: event.error.message
|
|
3952
|
+
}
|
|
3953
|
+
});
|
|
3954
|
+
} else if (event.type === "error") sessionCtx.emitEvent({
|
|
3955
|
+
kind: "error",
|
|
3956
|
+
payload: {
|
|
3957
|
+
source: "sdk",
|
|
3958
|
+
message: event.message
|
|
3959
|
+
}
|
|
3960
|
+
});
|
|
3961
|
+
}
|
|
3962
|
+
} catch (err) {
|
|
3963
|
+
if (abort.signal.aborted) return;
|
|
3964
|
+
turnFailed = true;
|
|
3965
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3966
|
+
sessionCtx.emitEvent({
|
|
3967
|
+
kind: "error",
|
|
3968
|
+
payload: {
|
|
3969
|
+
source: "sdk",
|
|
3970
|
+
message: msg
|
|
3971
|
+
}
|
|
3972
|
+
});
|
|
3973
|
+
}
|
|
3974
|
+
})();
|
|
3975
|
+
currentTurnPromise = promise;
|
|
3976
|
+
try {
|
|
3977
|
+
await promise;
|
|
3978
|
+
} finally {
|
|
3979
|
+
currentAbort = null;
|
|
3980
|
+
currentTurnPromise = null;
|
|
3981
|
+
}
|
|
3982
|
+
if (abort.signal.aborted) return;
|
|
3983
|
+
const accumulated = assistantTexts.join("\n\n");
|
|
3984
|
+
let forwardFailed = false;
|
|
3985
|
+
if (accumulated.trim()) try {
|
|
3986
|
+
await sessionCtx.forwardResult(accumulated);
|
|
3987
|
+
} catch (err) {
|
|
3988
|
+
forwardFailed = true;
|
|
3989
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3990
|
+
sessionCtx.emitEvent({
|
|
3991
|
+
kind: "error",
|
|
3992
|
+
payload: {
|
|
3993
|
+
source: "runtime",
|
|
3994
|
+
message: `forwardResult failed: ${msg}`
|
|
3995
|
+
}
|
|
3996
|
+
});
|
|
3997
|
+
}
|
|
3998
|
+
const succeeded = !turnFailed && !forwardFailed;
|
|
3999
|
+
sessionCtx.emitEvent({
|
|
4000
|
+
kind: "turn_end",
|
|
4001
|
+
payload: { status: succeeded ? "success" : "error" }
|
|
4002
|
+
});
|
|
4003
|
+
if (succeeded && accumulated.trim()) sessionCtx.reportSessionCompletion();
|
|
4004
|
+
sessionCtx.setRuntimeState("idle");
|
|
4005
|
+
if (queuedMessages.length > 0 && !drainScheduled) {
|
|
4006
|
+
drainScheduled = true;
|
|
4007
|
+
setImmediate(() => {
|
|
4008
|
+
drainScheduled = false;
|
|
4009
|
+
const drained = queuedMessages.splice(0);
|
|
4010
|
+
if (drained.length === 0 || !ctx || !thread) return;
|
|
4011
|
+
mergeAndRun(drained, ctx);
|
|
4012
|
+
});
|
|
4013
|
+
}
|
|
4014
|
+
}
|
|
4015
|
+
async function mergeAndRun(drained, sessionCtx) {
|
|
4016
|
+
const inputs = [];
|
|
4017
|
+
for (const m of drained) try {
|
|
4018
|
+
inputs.push(await sessionCtx.formatInboundContent(m));
|
|
4019
|
+
} catch (err) {
|
|
4020
|
+
sessionCtx.log(`codex inject formatInboundContent failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4021
|
+
}
|
|
4022
|
+
if (inputs.length === 0) return;
|
|
4023
|
+
await runTurn(inputs.join("\n\n"), sessionCtx);
|
|
4024
|
+
}
|
|
4025
|
+
return {
|
|
4026
|
+
async start(message, sessionCtx) {
|
|
4027
|
+
ctx = sessionCtx;
|
|
4028
|
+
cwd = await acquireWorkspace(workspaceRoot, sessionCtx.chatId);
|
|
4029
|
+
let payload = null;
|
|
4030
|
+
if (agentConfigCache) payload = (await agentConfigCache.refresh(sessionCtx.agent.agentId)).payload;
|
|
4031
|
+
if (!payload) payload = {
|
|
4032
|
+
kind: "codex",
|
|
4033
|
+
prompt: { append: "" },
|
|
4034
|
+
model: "",
|
|
4035
|
+
mcpServers: [],
|
|
4036
|
+
env: [],
|
|
4037
|
+
gitRepos: []
|
|
4038
|
+
};
|
|
4039
|
+
bootstrapWorkspace({
|
|
4040
|
+
workspacePath: cwd,
|
|
4041
|
+
identity: sessionCtx.agent,
|
|
4042
|
+
contextTreePath,
|
|
4043
|
+
serverUrl: sessionCtx.sdk.serverUrl,
|
|
4044
|
+
chatId: sessionCtx.chatId,
|
|
4045
|
+
briefing: {
|
|
4046
|
+
format: "agents-md",
|
|
4047
|
+
content: buildAgentBriefing(payload)
|
|
4048
|
+
}
|
|
4049
|
+
});
|
|
4050
|
+
await prepareGitWorktrees(payload, cwd, sessionCtx.chatId);
|
|
4051
|
+
codex = new Codex({
|
|
4052
|
+
env: buildEnv(sessionCtx),
|
|
4053
|
+
config: buildCodexConfig(payload)
|
|
4054
|
+
});
|
|
4055
|
+
thread = codex.startThread(buildCodexThreadOptions(payload, cwd));
|
|
4056
|
+
await runTurn(await toCodexInput(message, sessionCtx), sessionCtx);
|
|
4057
|
+
if (!threadId) threadId = thread.id ?? null;
|
|
4058
|
+
if (!threadId) throw new Error("codex did not assign a thread id during the first turn");
|
|
4059
|
+
return threadId;
|
|
4060
|
+
},
|
|
4061
|
+
async resume(message, sessionId, sessionCtx) {
|
|
4062
|
+
ctx = sessionCtx;
|
|
4063
|
+
cwd = await acquireWorkspace(workspaceRoot, sessionCtx.chatId);
|
|
4064
|
+
let payload = null;
|
|
4065
|
+
if (agentConfigCache) payload = (await agentConfigCache.refresh(sessionCtx.agent.agentId)).payload;
|
|
4066
|
+
if (!payload) payload = {
|
|
4067
|
+
kind: "codex",
|
|
4068
|
+
prompt: { append: "" },
|
|
4069
|
+
model: "",
|
|
4070
|
+
mcpServers: [],
|
|
4071
|
+
env: [],
|
|
4072
|
+
gitRepos: []
|
|
4073
|
+
};
|
|
4074
|
+
bootstrapWorkspace({
|
|
4075
|
+
workspacePath: cwd,
|
|
4076
|
+
identity: sessionCtx.agent,
|
|
4077
|
+
contextTreePath,
|
|
4078
|
+
serverUrl: sessionCtx.sdk.serverUrl,
|
|
4079
|
+
chatId: sessionCtx.chatId,
|
|
4080
|
+
briefing: {
|
|
4081
|
+
format: "agents-md",
|
|
4082
|
+
content: buildAgentBriefing(payload)
|
|
4083
|
+
}
|
|
4084
|
+
});
|
|
4085
|
+
await prepareGitWorktrees(payload, cwd, sessionCtx.chatId);
|
|
4086
|
+
codex = new Codex({
|
|
4087
|
+
env: buildEnv(sessionCtx),
|
|
4088
|
+
config: buildCodexConfig(payload)
|
|
4089
|
+
});
|
|
4090
|
+
thread = codex.resumeThread(sessionId, buildCodexThreadOptions(payload, cwd));
|
|
4091
|
+
threadId = sessionId;
|
|
4092
|
+
if (message) await runTurn(await toCodexInput(message, sessionCtx), sessionCtx);
|
|
4093
|
+
return sessionId;
|
|
4094
|
+
},
|
|
4095
|
+
inject(message) {
|
|
4096
|
+
if (currentTurnPromise) {
|
|
4097
|
+
queuedMessages.push(message);
|
|
4098
|
+
return;
|
|
4099
|
+
}
|
|
4100
|
+
const sessionCtx = ctx;
|
|
4101
|
+
if (!sessionCtx || !thread) return;
|
|
4102
|
+
(async () => {
|
|
4103
|
+
try {
|
|
4104
|
+
await runTurn(await toCodexInput(message, sessionCtx), sessionCtx);
|
|
4105
|
+
} catch (err) {
|
|
4106
|
+
sessionCtx.log(`codex inject failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4107
|
+
}
|
|
4108
|
+
})();
|
|
4109
|
+
},
|
|
4110
|
+
async suspend() {
|
|
4111
|
+
currentAbort?.abort();
|
|
4112
|
+
try {
|
|
4113
|
+
await currentTurnPromise;
|
|
4114
|
+
} catch {}
|
|
4115
|
+
currentAbort = null;
|
|
4116
|
+
currentTurnPromise = null;
|
|
4117
|
+
thread = null;
|
|
4118
|
+
codex = null;
|
|
4119
|
+
},
|
|
4120
|
+
async shutdown() {
|
|
4121
|
+
currentAbort?.abort();
|
|
4122
|
+
try {
|
|
4123
|
+
await currentTurnPromise;
|
|
4124
|
+
} catch {}
|
|
4125
|
+
currentAbort = null;
|
|
4126
|
+
currentTurnPromise = null;
|
|
4127
|
+
thread = null;
|
|
4128
|
+
codex = null;
|
|
4129
|
+
if (gitMirrorManager) {
|
|
4130
|
+
for (const wt of ownedWorktrees) try {
|
|
4131
|
+
await gitMirrorManager.removeWorktree({
|
|
4132
|
+
url: wt.url,
|
|
4133
|
+
path: wt.path,
|
|
4134
|
+
branchName: wt.branchName
|
|
4135
|
+
});
|
|
4136
|
+
} catch (err) {
|
|
4137
|
+
ctx?.log(`codex worktree cleanup failed (${wt.path}): ${err instanceof Error ? err.message : String(err)}`);
|
|
4138
|
+
}
|
|
4139
|
+
ownedWorktrees.length = 0;
|
|
4140
|
+
}
|
|
4141
|
+
if (cwd && existsSync(cwd)) try {
|
|
4142
|
+
rmSync(cwd, {
|
|
4143
|
+
recursive: true,
|
|
4144
|
+
force: true
|
|
4145
|
+
});
|
|
4146
|
+
} catch (err) {
|
|
4147
|
+
ctx?.log(`codex workspace cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4148
|
+
}
|
|
4149
|
+
cwd = null;
|
|
4150
|
+
threadId = null;
|
|
4151
|
+
ctx = null;
|
|
4152
|
+
queuedMessages.length = 0;
|
|
4153
|
+
}
|
|
4154
|
+
};
|
|
4155
|
+
};
|
|
3550
4156
|
/** Register all built-in handlers. Call once at startup. */
|
|
3551
4157
|
function registerBuiltinHandlers() {
|
|
3552
4158
|
const resolution = resolveClaudeCodeExecutable();
|
|
@@ -3556,6 +4162,7 @@ function registerBuiltinHandlers() {
|
|
|
3556
4162
|
...config,
|
|
3557
4163
|
claudeCodeExecutable: resolution.path
|
|
3558
4164
|
}));
|
|
4165
|
+
registerHandler("codex", (config) => createCodexHandler(config));
|
|
3559
4166
|
}
|
|
3560
4167
|
function createAgentConfigCache(opts) {
|
|
3561
4168
|
const { sdk } = opts;
|
|
@@ -3590,6 +4197,7 @@ function createAgentConfigCache(opts) {
|
|
|
3590
4197
|
agentId,
|
|
3591
4198
|
version: 0,
|
|
3592
4199
|
payload: {
|
|
4200
|
+
kind: "claude-code",
|
|
3593
4201
|
prompt: { append: "" },
|
|
3594
4202
|
model: "",
|
|
3595
4203
|
mcpServers: [],
|
|
@@ -4607,50 +5215,270 @@ var AgentSlot = class {
|
|
|
4607
5215
|
}
|
|
4608
5216
|
};
|
|
4609
5217
|
/**
|
|
4610
|
-
*
|
|
4611
|
-
*
|
|
4612
|
-
*
|
|
4613
|
-
*
|
|
4614
|
-
*
|
|
4615
|
-
*
|
|
4616
|
-
* Picked deliberately wide for M1 internal use; revisit when scale shows
|
|
4617
|
-
* a real bottleneck.
|
|
5218
|
+
* Top-level marker file Claude Code writes after a successful OAuth login.
|
|
5219
|
+
* Path is platform-agnostic (`~/.claude.json`); the access token itself lives
|
|
5220
|
+
* in the platform credential store (macOS Keychain entry "Claude Code-
|
|
5221
|
+
* credentials", or libsecret on Linux), so we treat the presence of an
|
|
5222
|
+
* `oauthAccount.accountUuid` field as the canonical "logged in" signal.
|
|
4618
5223
|
*/
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
const
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
}
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
}
|
|
5224
|
+
const CLAUDE_PROFILE_PATH = () => join(homedir(), ".claude.json");
|
|
5225
|
+
function hasClaudeOAuthAccount() {
|
|
5226
|
+
try {
|
|
5227
|
+
const path = CLAUDE_PROFILE_PATH();
|
|
5228
|
+
if (!existsSync(path)) return false;
|
|
5229
|
+
const raw = readFileSync(path, "utf-8");
|
|
5230
|
+
const obj = JSON.parse(raw);
|
|
5231
|
+
return typeof obj.oauthAccount?.accountUuid === "string" && obj.oauthAccount.accountUuid.length > 0;
|
|
5232
|
+
} catch {
|
|
5233
|
+
return false;
|
|
5234
|
+
}
|
|
5235
|
+
}
|
|
5236
|
+
async function readSdkVersion$1() {
|
|
5237
|
+
try {
|
|
5238
|
+
let dir = dirname(fileURLToPath(await import.meta.resolve("@anthropic-ai/claude-agent-sdk")));
|
|
5239
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
5240
|
+
const candidate = join(dir, "package.json");
|
|
5241
|
+
if (existsSync(candidate)) {
|
|
5242
|
+
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
|
|
5243
|
+
if (pkg.name === "@anthropic-ai/claude-agent-sdk" && typeof pkg.version === "string") return pkg.version;
|
|
5244
|
+
}
|
|
5245
|
+
const parent = dirname(dir);
|
|
5246
|
+
if (parent === dir) break;
|
|
5247
|
+
dir = parent;
|
|
5248
|
+
}
|
|
5249
|
+
} catch {}
|
|
5250
|
+
return null;
|
|
5251
|
+
}
|
|
5252
|
+
function detectAuth$1() {
|
|
5253
|
+
if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.length > 0) return {
|
|
5254
|
+
authenticated: true,
|
|
5255
|
+
method: "api_key"
|
|
5256
|
+
};
|
|
5257
|
+
if (hasClaudeOAuthAccount()) return {
|
|
5258
|
+
authenticated: true,
|
|
5259
|
+
method: "oauth"
|
|
5260
|
+
};
|
|
5261
|
+
return {
|
|
5262
|
+
authenticated: false,
|
|
5263
|
+
method: "none"
|
|
5264
|
+
};
|
|
5265
|
+
}
|
|
4640
5266
|
/**
|
|
4641
|
-
*
|
|
4642
|
-
*
|
|
4643
|
-
*
|
|
5267
|
+
* Probe whether the Claude Code runtime is usable on this machine.
|
|
5268
|
+
*
|
|
5269
|
+
* `state` is the authoritative field; `available` and `authenticated` are
|
|
5270
|
+
* derived booleans kept around for simple consumers (e.g. capability lookup
|
|
5271
|
+
* in service-layer guards).
|
|
4644
5272
|
*/
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
5273
|
+
async function probeClaudeCodeCapability() {
|
|
5274
|
+
const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5275
|
+
try {
|
|
5276
|
+
let sdkPresent = false;
|
|
5277
|
+
try {
|
|
5278
|
+
await import("@anthropic-ai/claude-agent-sdk");
|
|
5279
|
+
sdkPresent = true;
|
|
5280
|
+
} catch {
|
|
5281
|
+
sdkPresent = false;
|
|
5282
|
+
}
|
|
5283
|
+
if (!sdkPresent) return {
|
|
5284
|
+
state: "missing",
|
|
5285
|
+
available: false,
|
|
5286
|
+
authenticated: false,
|
|
5287
|
+
sdkVersion: null,
|
|
5288
|
+
authMethod: "none",
|
|
5289
|
+
detectedAt
|
|
5290
|
+
};
|
|
5291
|
+
const sdkVersion = await readSdkVersion$1();
|
|
5292
|
+
const auth = detectAuth$1();
|
|
5293
|
+
if (!auth.authenticated) return {
|
|
5294
|
+
state: "unauthenticated",
|
|
5295
|
+
available: true,
|
|
5296
|
+
authenticated: false,
|
|
5297
|
+
sdkVersion,
|
|
5298
|
+
authMethod: "none",
|
|
5299
|
+
detectedAt
|
|
5300
|
+
};
|
|
5301
|
+
return {
|
|
5302
|
+
state: "ok",
|
|
5303
|
+
available: true,
|
|
5304
|
+
authenticated: true,
|
|
5305
|
+
sdkVersion,
|
|
5306
|
+
authMethod: auth.method,
|
|
5307
|
+
detectedAt
|
|
5308
|
+
};
|
|
5309
|
+
} catch (err) {
|
|
5310
|
+
return {
|
|
5311
|
+
state: "error",
|
|
5312
|
+
available: false,
|
|
5313
|
+
authenticated: false,
|
|
5314
|
+
sdkVersion: null,
|
|
5315
|
+
authMethod: "none",
|
|
5316
|
+
error: err instanceof Error ? err.message : String(err),
|
|
5317
|
+
detectedAt
|
|
5318
|
+
};
|
|
5319
|
+
}
|
|
5320
|
+
}
|
|
5321
|
+
function codexAuthPath() {
|
|
5322
|
+
return join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "auth.json");
|
|
5323
|
+
}
|
|
5324
|
+
async function readSdkVersion() {
|
|
5325
|
+
try {
|
|
5326
|
+
let dir = dirname(fileURLToPath(await import.meta.resolve("@openai/codex-sdk")));
|
|
5327
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
5328
|
+
const candidate = join(dir, "package.json");
|
|
5329
|
+
if (existsSync(candidate)) {
|
|
5330
|
+
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
|
|
5331
|
+
if (pkg.name === "@openai/codex-sdk" && typeof pkg.version === "string") return pkg.version;
|
|
5332
|
+
}
|
|
5333
|
+
const parent = dirname(dir);
|
|
5334
|
+
if (parent === dir) break;
|
|
5335
|
+
dir = parent;
|
|
5336
|
+
}
|
|
5337
|
+
} catch {}
|
|
5338
|
+
return null;
|
|
5339
|
+
}
|
|
5340
|
+
function detectAuth() {
|
|
5341
|
+
if (process.env.CODEX_API_KEY && process.env.CODEX_API_KEY.length > 0) return {
|
|
5342
|
+
authenticated: true,
|
|
5343
|
+
method: "api_key"
|
|
5344
|
+
};
|
|
5345
|
+
if (existsSync(codexAuthPath())) return {
|
|
5346
|
+
authenticated: true,
|
|
5347
|
+
method: "auth_json"
|
|
5348
|
+
};
|
|
5349
|
+
return {
|
|
5350
|
+
authenticated: false,
|
|
5351
|
+
method: "none"
|
|
5352
|
+
};
|
|
5353
|
+
}
|
|
5354
|
+
/**
|
|
5355
|
+
* Probe whether the OpenAI Codex runtime is usable on this machine.
|
|
5356
|
+
* Treats `~/.codex/auth.json` (set by `codex login`) as the canonical local
|
|
5357
|
+
* auth source; CODEX_API_KEY env shortcuts that for ephemeral use.
|
|
5358
|
+
*/
|
|
5359
|
+
async function probeCodexCapability() {
|
|
5360
|
+
const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5361
|
+
try {
|
|
5362
|
+
let sdkPresent = false;
|
|
5363
|
+
try {
|
|
5364
|
+
await import("@openai/codex-sdk");
|
|
5365
|
+
sdkPresent = true;
|
|
5366
|
+
} catch {
|
|
5367
|
+
sdkPresent = false;
|
|
5368
|
+
}
|
|
5369
|
+
if (!sdkPresent) return {
|
|
5370
|
+
state: "missing",
|
|
5371
|
+
available: false,
|
|
5372
|
+
authenticated: false,
|
|
5373
|
+
sdkVersion: null,
|
|
5374
|
+
authMethod: "none",
|
|
5375
|
+
detectedAt
|
|
5376
|
+
};
|
|
5377
|
+
const sdkVersion = await readSdkVersion();
|
|
5378
|
+
const auth = detectAuth();
|
|
5379
|
+
if (!auth.authenticated) return {
|
|
5380
|
+
state: "unauthenticated",
|
|
5381
|
+
available: true,
|
|
5382
|
+
authenticated: false,
|
|
5383
|
+
sdkVersion,
|
|
5384
|
+
authMethod: "none",
|
|
5385
|
+
detectedAt
|
|
5386
|
+
};
|
|
5387
|
+
return {
|
|
5388
|
+
state: "ok",
|
|
5389
|
+
available: true,
|
|
5390
|
+
authenticated: true,
|
|
5391
|
+
sdkVersion,
|
|
5392
|
+
authMethod: auth.method,
|
|
5393
|
+
detectedAt
|
|
5394
|
+
};
|
|
5395
|
+
} catch (err) {
|
|
5396
|
+
return {
|
|
5397
|
+
state: "error",
|
|
5398
|
+
available: false,
|
|
5399
|
+
authenticated: false,
|
|
5400
|
+
sdkVersion: null,
|
|
5401
|
+
authMethod: "none",
|
|
5402
|
+
error: err instanceof Error ? err.message : String(err),
|
|
5403
|
+
detectedAt
|
|
5404
|
+
};
|
|
5405
|
+
}
|
|
5406
|
+
}
|
|
5407
|
+
/**
|
|
5408
|
+
* Run every built-in capability probe and aggregate the results.
|
|
5409
|
+
*
|
|
5410
|
+
* Each provider gets its own module under this directory; the orchestrator
|
|
5411
|
+
* is intentionally simple — adding a new provider means importing the new
|
|
5412
|
+
* probe here and registering its key. The probe modules themselves are
|
|
5413
|
+
* deliberately not part of the `HandlerFactory` interface so capability
|
|
5414
|
+
* detection stays decoupled from runtime instantiation (so we can probe
|
|
5415
|
+
* whether a runtime is usable before spawning anything).
|
|
5416
|
+
*/
|
|
5417
|
+
async function probeCapabilities() {
|
|
5418
|
+
const probes = [["claude-code", probeClaudeCodeCapability()], ["codex", probeCodexCapability()]];
|
|
5419
|
+
const out = {};
|
|
5420
|
+
await Promise.all(probes.map(async ([provider, p]) => {
|
|
5421
|
+
try {
|
|
5422
|
+
out[provider] = await p;
|
|
5423
|
+
} catch (err) {
|
|
5424
|
+
out[provider] = {
|
|
5425
|
+
state: "error",
|
|
5426
|
+
available: false,
|
|
5427
|
+
authenticated: false,
|
|
5428
|
+
sdkVersion: null,
|
|
5429
|
+
authMethod: "none",
|
|
5430
|
+
error: err instanceof Error ? err.message : String(err),
|
|
5431
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5432
|
+
};
|
|
5433
|
+
}
|
|
5434
|
+
}));
|
|
5435
|
+
return out;
|
|
5436
|
+
}
|
|
5437
|
+
/**
|
|
5438
|
+
* Runtime-wide constants (Step 7 + Step 11).
|
|
5439
|
+
*
|
|
5440
|
+
* After Step 11 these values are fixed at code level — the previously
|
|
5441
|
+
* exposed `session.idle_timeout` / `session.max_sessions` / `concurrency`
|
|
5442
|
+
* fields in `agent.yaml` are dropped per PRD §D1 / §D15.
|
|
5443
|
+
*
|
|
5444
|
+
* Picked deliberately wide for M1 internal use; revisit when scale shows
|
|
5445
|
+
* a real bottleneck.
|
|
5446
|
+
*/
|
|
5447
|
+
/** 8 hours — covers "leave it on overnight" usage without holding worktrees forever. */
|
|
5448
|
+
const IDLE_TIMEOUT_MS = 480 * 60 * 1e3;
|
|
5449
|
+
z.object({
|
|
5450
|
+
idle_timeout: z.number().int().positive().optional(),
|
|
5451
|
+
max_sessions: z.number().int().positive().optional()
|
|
5452
|
+
}).passthrough();
|
|
5453
|
+
const sessionConfigSchema = z.object({
|
|
5454
|
+
idle_timeout: z.number().int().positive().default(IDLE_TIMEOUT_MS / 1e3),
|
|
5455
|
+
max_sessions: z.number().int().positive().default(50),
|
|
5456
|
+
reconcile_interval_seconds: z.number().int().min(30).max(3600).default(300)
|
|
5457
|
+
}).passthrough();
|
|
5458
|
+
const agentSlotConfigSchema = z.object({
|
|
5459
|
+
agentId: z.string().min(1),
|
|
5460
|
+
type: z.string().min(1),
|
|
5461
|
+
session: sessionConfigSchema.prefault({}),
|
|
5462
|
+
concurrency: z.number().int().positive().default(16)
|
|
5463
|
+
}).passthrough();
|
|
5464
|
+
z.object({
|
|
5465
|
+
server: z.url().default("http://localhost:8000"),
|
|
5466
|
+
agents: z.record(z.string(), agentSlotConfigSchema).refine((agents) => Object.keys(agents).length > 0, "At least one agent must be defined")
|
|
5467
|
+
});
|
|
5468
|
+
/**
|
|
5469
|
+
* Version-drift decision flow. Install, prompt, and exit are delegated to
|
|
5470
|
+
* command-layer callbacks so the Client package stays free of CLI /
|
|
5471
|
+
* filesystem knowledge.
|
|
5472
|
+
*/
|
|
5473
|
+
var UpdateManager = class UpdateManager {
|
|
5474
|
+
options;
|
|
5475
|
+
connection;
|
|
5476
|
+
welcomeListener;
|
|
5477
|
+
updateInFlight = false;
|
|
5478
|
+
quietGateTimer = null;
|
|
5479
|
+
disposed = false;
|
|
5480
|
+
/**
|
|
5481
|
+
* Set when a standalone (unmanaged) executeUpdate reports `installed: true`
|
|
4654
5482
|
* without exiting. The new bits are on disk; subsequent welcome frames must
|
|
4655
5483
|
* not re-invoke npm since a restart is the only way to pick them up.
|
|
4656
5484
|
*/
|
|
@@ -4802,7 +5630,7 @@ function result(data) {
|
|
|
4802
5630
|
data
|
|
4803
5631
|
})}\n`);
|
|
4804
5632
|
}
|
|
4805
|
-
function fail(code, message, exitCode = 1) {
|
|
5633
|
+
function fail$1(code, message, exitCode = 1) {
|
|
4806
5634
|
process.stderr.write(`${JSON.stringify({
|
|
4807
5635
|
ok: false,
|
|
4808
5636
|
error: {
|
|
@@ -4837,13 +5665,26 @@ function line(text) {
|
|
|
4837
5665
|
}
|
|
4838
5666
|
const print = {
|
|
4839
5667
|
result,
|
|
4840
|
-
fail,
|
|
5668
|
+
fail: fail$1,
|
|
4841
5669
|
status,
|
|
4842
5670
|
check,
|
|
4843
5671
|
blank,
|
|
4844
5672
|
line
|
|
4845
5673
|
};
|
|
4846
5674
|
//#endregion
|
|
5675
|
+
//#region src/cli/output.ts
|
|
5676
|
+
/**
|
|
5677
|
+
* CLI output re-exports. The underlying implementation lives in
|
|
5678
|
+
* `core/output.ts` (the Print layer). Keep these thin wrappers so callers that
|
|
5679
|
+
* only depend on `cli/output.ts` keep working during the migration.
|
|
5680
|
+
*/
|
|
5681
|
+
function success(data) {
|
|
5682
|
+
print.result(data);
|
|
5683
|
+
}
|
|
5684
|
+
function fail(code, message, exitCode = 1) {
|
|
5685
|
+
return print.fail(code, message, exitCode);
|
|
5686
|
+
}
|
|
5687
|
+
//#endregion
|
|
4847
5688
|
//#region src/core/agent-messaging.ts
|
|
4848
5689
|
/**
|
|
4849
5690
|
* Resolve `replyTo` envelope fields for `agent send`. When the CLI is invoked
|
|
@@ -5217,7 +6058,7 @@ var ClientRuntime = class {
|
|
|
5217
6058
|
});
|
|
5218
6059
|
const yaml = stringify({
|
|
5219
6060
|
agentId: message.agentId,
|
|
5220
|
-
runtime:
|
|
6061
|
+
runtime: message.runtimeProvider
|
|
5221
6062
|
});
|
|
5222
6063
|
writeFileSync(join(agentDir, "agent.yaml"), yaml, { mode: 384 });
|
|
5223
6064
|
print.check(true, `auto-added agent "${localName}"`, `${message.agentId} (from server push)`);
|
|
@@ -6530,7 +7371,7 @@ async function onboardCreate(args) {
|
|
|
6530
7371
|
}
|
|
6531
7372
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
6532
7373
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
6533
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
7374
|
+
const { bindFeishuBot } = await import("./feishu-Boy3n8CT.mjs").then((n) => n.r);
|
|
6534
7375
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
6535
7376
|
if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
6536
7377
|
else {
|
|
@@ -6693,6 +7534,66 @@ function setNestedByDot(obj, dotPath, value) {
|
|
|
6693
7534
|
if (lastKey !== void 0) current[lastKey] = value;
|
|
6694
7535
|
}
|
|
6695
7536
|
//#endregion
|
|
7537
|
+
//#region src/core/runtime-provider-reconcile.ts
|
|
7538
|
+
/**
|
|
7539
|
+
* Pre-flight reconciliation called before the agents loop spawns. Pulls
|
|
7540
|
+
* authoritative `runtime_provider` for every agent the calling user owns and
|
|
7541
|
+
* rewrites any local `agent.yaml` whose `runtime` field disagrees. Best-
|
|
7542
|
+
* effort: a transient hub failure logs and falls back to the local YAML
|
|
7543
|
+
* value (the in-band repair path catches any remaining drift on first bind).
|
|
7544
|
+
*/
|
|
7545
|
+
async function reconcileLocalRuntimeProviders(opts) {
|
|
7546
|
+
const res = await fetch(`${opts.serverUrl}/api/v1/clients/me/agents`, { headers: { Authorization: `Bearer ${opts.accessToken}` } });
|
|
7547
|
+
if (!res.ok) throw new Error(`hub returned ${res.status} on /clients/me/agents`);
|
|
7548
|
+
const items = await res.json();
|
|
7549
|
+
const byAgentId = new Map(items.map((it) => [it.agentId, it]));
|
|
7550
|
+
if (!existsSync(opts.agentsDir)) return;
|
|
7551
|
+
const subdirs = readdirSync(opts.agentsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
7552
|
+
for (const subdir of subdirs) {
|
|
7553
|
+
const yamlPath = join(opts.agentsDir, subdir.name, "agent.yaml");
|
|
7554
|
+
if (!existsSync(yamlPath)) continue;
|
|
7555
|
+
let parsed;
|
|
7556
|
+
try {
|
|
7557
|
+
parsed = parse(readFileSync(yamlPath, "utf-8")) ?? {};
|
|
7558
|
+
} catch (err) {
|
|
7559
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
7560
|
+
opts.log?.("warn", `agent ${subdir.name}: cannot parse yaml — ${msg}`);
|
|
7561
|
+
continue;
|
|
7562
|
+
}
|
|
7563
|
+
if (!parsed.agentId) continue;
|
|
7564
|
+
const auth = byAgentId.get(parsed.agentId);
|
|
7565
|
+
if (!auth) continue;
|
|
7566
|
+
if (parsed.runtime === auth.runtimeProvider) continue;
|
|
7567
|
+
const next = {
|
|
7568
|
+
...parsed,
|
|
7569
|
+
runtime: auth.runtimeProvider
|
|
7570
|
+
};
|
|
7571
|
+
try {
|
|
7572
|
+
writeFileSync(yamlPath, stringify(next), { mode: 384 });
|
|
7573
|
+
opts.log?.("info", `agent ${parsed.agentId}: yaml runtime "${parsed.runtime ?? "(unset)"}" → "${auth.runtimeProvider}" (hub authoritative)`);
|
|
7574
|
+
} catch (err) {
|
|
7575
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
7576
|
+
opts.log?.("warn", `agent ${parsed.agentId}: failed to rewrite yaml — ${msg}`);
|
|
7577
|
+
}
|
|
7578
|
+
}
|
|
7579
|
+
}
|
|
7580
|
+
/**
|
|
7581
|
+
* Member-scoped capabilities upload. Server stores the snapshot under
|
|
7582
|
+
* `clients.metadata.capabilities`. Best-effort: failure does not block
|
|
7583
|
+
* client startup since capabilities only matter for UI / admin checks.
|
|
7584
|
+
*/
|
|
7585
|
+
async function uploadClientCapabilities(opts) {
|
|
7586
|
+
const res = await fetch(`${opts.serverUrl}/api/v1/clients/${encodeURIComponent(opts.clientId)}/capabilities`, {
|
|
7587
|
+
method: "PATCH",
|
|
7588
|
+
headers: {
|
|
7589
|
+
Authorization: `Bearer ${opts.accessToken}`,
|
|
7590
|
+
"Content-Type": "application/json"
|
|
7591
|
+
},
|
|
7592
|
+
body: JSON.stringify({ capabilities: opts.capabilities })
|
|
7593
|
+
});
|
|
7594
|
+
if (!res.ok) throw new Error(`hub returned ${res.status} on PATCH /clients/${opts.clientId}/capabilities`);
|
|
7595
|
+
}
|
|
7596
|
+
//#endregion
|
|
6696
7597
|
//#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/schema.js
|
|
6697
7598
|
const FeedbackType = z.enum(["bug", "feature"]);
|
|
6698
7599
|
const BrowserContext = z.object({
|
|
@@ -7450,7 +8351,7 @@ function createFeedbackHandler(config) {
|
|
|
7450
8351
|
return { handle };
|
|
7451
8352
|
}
|
|
7452
8353
|
//#endregion
|
|
7453
|
-
//#region ../server/dist/app-
|
|
8354
|
+
//#region ../server/dist/app-D-aIvdiQ.mjs
|
|
7454
8355
|
var __defProp = Object.defineProperty;
|
|
7455
8356
|
var __exportAll = (all, no_symbols) => {
|
|
7456
8357
|
let target = {};
|
|
@@ -7461,28 +8362,6 @@ var __exportAll = (all, no_symbols) => {
|
|
|
7461
8362
|
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
7462
8363
|
return target;
|
|
7463
8364
|
};
|
|
7464
|
-
/** Organization entity. Agents and chats belong to exactly one organization. */
|
|
7465
|
-
const organizations = pgTable("organizations", {
|
|
7466
|
-
id: text("id").primaryKey(),
|
|
7467
|
-
name: text("name").unique().notNull(),
|
|
7468
|
-
displayName: text("display_name").notNull(),
|
|
7469
|
-
maxAgents: integer("max_agents").notNull().default(0),
|
|
7470
|
-
maxMessagesPerMinute: integer("max_messages_per_minute").notNull().default(0),
|
|
7471
|
-
features: jsonb("features").$type().notNull().default({}),
|
|
7472
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
7473
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
7474
|
-
});
|
|
7475
|
-
/** User accounts. Passwords are stored as bcrypt hashes. */
|
|
7476
|
-
const users = pgTable("users", {
|
|
7477
|
-
id: text("id").primaryKey(),
|
|
7478
|
-
username: text("username").unique().notNull(),
|
|
7479
|
-
passwordHash: text("password_hash").notNull(),
|
|
7480
|
-
displayName: text("display_name").notNull(),
|
|
7481
|
-
avatarUrl: text("avatar_url"),
|
|
7482
|
-
status: text("status").notNull().default("active"),
|
|
7483
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
7484
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
7485
|
-
});
|
|
7486
8365
|
/**
|
|
7487
8366
|
* Client connections. A client is a single SDK process (AgentRuntime) that may
|
|
7488
8367
|
* host multiple agents. From the unified-user-token milestone on, a client is
|
|
@@ -7526,6 +8405,7 @@ const agents = pgTable("agents", {
|
|
|
7526
8405
|
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
7527
8406
|
managerId: text("manager_id").notNull(),
|
|
7528
8407
|
clientId: text("client_id").references(() => clients.id, { onDelete: "restrict" }),
|
|
8408
|
+
runtimeProvider: text("runtime_provider").notNull().default("claude-code"),
|
|
7529
8409
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
7530
8410
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
7531
8411
|
}, (table) => [
|
|
@@ -7546,57 +8426,6 @@ const adapterAgentMappings = pgTable("adapter_agent_mappings", {
|
|
|
7546
8426
|
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
7547
8427
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
7548
8428
|
}, (table) => [unique("uq_adapter_agent_mapping").on(table.platform, table.externalUserId)]);
|
|
7549
|
-
var AppError = class extends Error {
|
|
7550
|
-
constructor(statusCode, message) {
|
|
7551
|
-
super(message);
|
|
7552
|
-
this.statusCode = statusCode;
|
|
7553
|
-
this.name = "AppError";
|
|
7554
|
-
}
|
|
7555
|
-
};
|
|
7556
|
-
var NotFoundError = class extends AppError {
|
|
7557
|
-
constructor(message = "Not found") {
|
|
7558
|
-
super(404, message);
|
|
7559
|
-
this.name = "NotFoundError";
|
|
7560
|
-
}
|
|
7561
|
-
};
|
|
7562
|
-
var UnauthorizedError = class extends AppError {
|
|
7563
|
-
constructor(message = "Unauthorized") {
|
|
7564
|
-
super(401, message);
|
|
7565
|
-
this.name = "UnauthorizedError";
|
|
7566
|
-
}
|
|
7567
|
-
};
|
|
7568
|
-
var ForbiddenError = class extends AppError {
|
|
7569
|
-
constructor(message = "Forbidden") {
|
|
7570
|
-
super(403, message);
|
|
7571
|
-
this.name = "ForbiddenError";
|
|
7572
|
-
}
|
|
7573
|
-
};
|
|
7574
|
-
var ConflictError = class extends AppError {
|
|
7575
|
-
constructor(message = "Conflict") {
|
|
7576
|
-
super(409, message);
|
|
7577
|
-
this.name = "ConflictError";
|
|
7578
|
-
}
|
|
7579
|
-
};
|
|
7580
|
-
var BadRequestError = class extends AppError {
|
|
7581
|
-
constructor(message = "Bad request") {
|
|
7582
|
-
super(400, message);
|
|
7583
|
-
this.name = "BadRequestError";
|
|
7584
|
-
}
|
|
7585
|
-
};
|
|
7586
|
-
/**
|
|
7587
|
-
* Thrown when an operation targets a client whose organization does not match
|
|
7588
|
-
* the caller's authenticated organization. A client is bound to exactly one
|
|
7589
|
-
* org for its lifetime; re-registering or operating under a different org's
|
|
7590
|
-
* credentials is refused. CLI consumers recognize the `code` field and
|
|
7591
|
-
* respond by abandoning the local clientId to register a fresh one.
|
|
7592
|
-
*/
|
|
7593
|
-
var ClientOrgMismatchError = class extends AppError {
|
|
7594
|
-
code = "CLIENT_ORG_MISMATCH";
|
|
7595
|
-
constructor(message = "Client belongs to a different organization") {
|
|
7596
|
-
super(403, message);
|
|
7597
|
-
this.name = "ClientOrgMismatchError";
|
|
7598
|
-
}
|
|
7599
|
-
};
|
|
7600
8429
|
/** Communication container. All messages between agents flow within a Chat. */
|
|
7601
8430
|
const chats = pgTable("chats", {
|
|
7602
8431
|
id: text("id").primaryKey(),
|
|
@@ -7747,26 +8576,6 @@ const adapterMessageReferences = pgTable("adapter_message_references", {
|
|
|
7747
8576
|
externalChannelId: text("external_channel_id").notNull(),
|
|
7748
8577
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
7749
8578
|
}, (table) => [unique("uq_adapter_message_ref").on(table.messageId, table.platform)]);
|
|
7750
|
-
/** Generate a UUID v7 (time-ordered). No external dependency. */
|
|
7751
|
-
function uuidv7() {
|
|
7752
|
-
const now = BigInt(Date.now());
|
|
7753
|
-
const bytes = new Uint8Array(16);
|
|
7754
|
-
bytes[0] = Number(now >> 40n & 255n);
|
|
7755
|
-
bytes[1] = Number(now >> 32n & 255n);
|
|
7756
|
-
bytes[2] = Number(now >> 24n & 255n);
|
|
7757
|
-
bytes[3] = Number(now >> 16n & 255n);
|
|
7758
|
-
bytes[4] = Number(now >> 8n & 255n);
|
|
7759
|
-
bytes[5] = Number(now & 255n);
|
|
7760
|
-
const rand = randomBytes(10);
|
|
7761
|
-
for (let i = 0; i < 10; i++) {
|
|
7762
|
-
const b = rand[i];
|
|
7763
|
-
if (b !== void 0) bytes[6 + i] = b;
|
|
7764
|
-
}
|
|
7765
|
-
bytes[6] = (bytes[6] ?? 0) & 15 | 112;
|
|
7766
|
-
bytes[8] = (bytes[8] ?? 0) & 63 | 128;
|
|
7767
|
-
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
7768
|
-
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
7769
|
-
}
|
|
7770
8579
|
/** UUID v7 regex pattern for distinguishing UUIDs from name slugs. */
|
|
7771
8580
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
7772
8581
|
/**
|
|
@@ -8266,7 +9075,7 @@ async function deleteAdapterConfig(db, id) {
|
|
|
8266
9075
|
const [row] = await db.delete(adapterConfigs).where(eq(adapterConfigs.id, id)).returning();
|
|
8267
9076
|
if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
|
|
8268
9077
|
}
|
|
8269
|
-
const log$
|
|
9078
|
+
const log$5 = createLogger$1("AdminAdapters");
|
|
8270
9079
|
function parseId(raw) {
|
|
8271
9080
|
const id = Number(raw);
|
|
8272
9081
|
if (!Number.isInteger(id) || id <= 0) throw new BadRequestError(`Invalid adapter ID: "${raw}"`);
|
|
@@ -8286,7 +9095,7 @@ async function adminAdapterRoutes(app) {
|
|
|
8286
9095
|
const scope = memberScope(request);
|
|
8287
9096
|
await assertCanManage(app.db, scope, body.agentId);
|
|
8288
9097
|
const config = await createAdapterConfig(app.db, body, app.config.secrets.encryptionKey);
|
|
8289
|
-
app.adapterManager.reload().catch((err) => log$
|
|
9098
|
+
app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after create"));
|
|
8290
9099
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
8291
9100
|
return reply.status(201).send({
|
|
8292
9101
|
...config,
|
|
@@ -8310,7 +9119,7 @@ async function adminAdapterRoutes(app) {
|
|
|
8310
9119
|
const existing = await getAdapterConfig(app.db, id);
|
|
8311
9120
|
await assertCanManage(app.db, scope, existing.agentId);
|
|
8312
9121
|
const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
|
|
8313
|
-
app.adapterManager.reload().catch((err) => log$
|
|
9122
|
+
app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after update"));
|
|
8314
9123
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
8315
9124
|
return {
|
|
8316
9125
|
...config,
|
|
@@ -8324,7 +9133,7 @@ async function adminAdapterRoutes(app) {
|
|
|
8324
9133
|
const existing = await getAdapterConfig(app.db, id);
|
|
8325
9134
|
await assertCanManage(app.db, scope, existing.agentId);
|
|
8326
9135
|
await deleteAdapterConfig(app.db, id);
|
|
8327
|
-
app.adapterManager.reload().catch((err) => log$
|
|
9136
|
+
app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after delete"));
|
|
8328
9137
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
8329
9138
|
return reply.status(204).send();
|
|
8330
9139
|
});
|
|
@@ -8411,6 +9220,7 @@ const members = pgTable("members", {
|
|
|
8411
9220
|
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
8412
9221
|
agentId: text("agent_id").unique().notNull().references(() => agents.uuid),
|
|
8413
9222
|
role: text("role").notNull(),
|
|
9223
|
+
status: text("status").notNull().default("active"),
|
|
8414
9224
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
8415
9225
|
}, (table) => [
|
|
8416
9226
|
unique("uq_members_user_org").on(table.userId, table.organizationId),
|
|
@@ -8424,6 +9234,38 @@ const members = pgTable("members", {
|
|
|
8424
9234
|
* real account.
|
|
8425
9235
|
*/
|
|
8426
9236
|
const RESERVED_AGENT_NAME_PREFIX = "__";
|
|
9237
|
+
/**
|
|
9238
|
+
* True iff `clients.metadata.capabilities` is a non-empty object — i.e. the
|
|
9239
|
+
* client has reported at least one runtime probe result. Used to distinguish
|
|
9240
|
+
* "we don't know what's installed yet" (empty / never reported) from
|
|
9241
|
+
* "client explicitly reports this provider is missing".
|
|
9242
|
+
*/
|
|
9243
|
+
function clientCapabilitiesReported(metadata) {
|
|
9244
|
+
if (!metadata || typeof metadata !== "object") return false;
|
|
9245
|
+
const caps = metadata.capabilities;
|
|
9246
|
+
if (!caps || typeof caps !== "object") return false;
|
|
9247
|
+
return Object.keys(caps).length > 0;
|
|
9248
|
+
}
|
|
9249
|
+
/**
|
|
9250
|
+
* Inspect a `clients.metadata.capabilities` blob (jsonb) for a specific
|
|
9251
|
+
* runtime provider entry. Capabilities live under the `metadata.capabilities`
|
|
9252
|
+
* subkey (Option C); the column is unstructured at the DB layer, so we
|
|
9253
|
+
* defensively narrow before key access.
|
|
9254
|
+
*
|
|
9255
|
+
* "Supports" requires the entry's SDK to be **available** — `state: "ok"` or
|
|
9256
|
+
* `state: "unauthenticated"`. A `missing` or `error` entry is *reported* but
|
|
9257
|
+
* not usable, so we explicitly reject those rather than treating mere key
|
|
9258
|
+
* presence as support. Auth state is left to the user to fix at runtime
|
|
9259
|
+
* (the re-bind dialog surfaces an `unauthenticated` hint).
|
|
9260
|
+
*/
|
|
9261
|
+
function clientSupportsRuntimeProvider(metadata, provider) {
|
|
9262
|
+
if (!metadata || typeof metadata !== "object") return false;
|
|
9263
|
+
const caps = metadata.capabilities;
|
|
9264
|
+
if (!caps || typeof caps !== "object") return false;
|
|
9265
|
+
const entry = caps[provider];
|
|
9266
|
+
if (!entry || typeof entry !== "object") return false;
|
|
9267
|
+
return entry.available === true;
|
|
9268
|
+
}
|
|
8427
9269
|
/** Default visibility per agent type. */
|
|
8428
9270
|
function defaultVisibility(type) {
|
|
8429
9271
|
switch (type) {
|
|
@@ -8444,6 +9286,32 @@ function defaultVisibility(type) {
|
|
|
8444
9286
|
* - When a non-human agent IS created with a `clientId`, the pinned client
|
|
8445
9287
|
* must already be owned by the manager's user (Rule R-RUN).
|
|
8446
9288
|
*/
|
|
9289
|
+
/**
|
|
9290
|
+
* Check that a client's reported capabilities show the given runtime provider
|
|
9291
|
+
* as **available** (SDK installed, regardless of auth state).
|
|
9292
|
+
*
|
|
9293
|
+
* Tri-state semantics by `clients.metadata.capabilities` shape:
|
|
9294
|
+
* - empty / absent — client hasn't probed yet (newly registered or pre-P2
|
|
9295
|
+
* install). Treat as "unknown" and allow; the in-band repair path
|
|
9296
|
+
* (RUNTIME_PROVIDER_MISMATCH on bind) catches actual incompatibility.
|
|
9297
|
+
* - reported, entry shows `state: ok | unauthenticated` (i.e. `available:
|
|
9298
|
+
* true`) — allow.
|
|
9299
|
+
* - reported, entry missing OR `state: missing | error` — block unless
|
|
9300
|
+
* `force` is set. We deliberately do NOT treat mere key presence as
|
|
9301
|
+
* support: probeCapabilities() always emits an entry per built-in
|
|
9302
|
+
* provider, including `{ state: "missing" }` for absent SDKs.
|
|
9303
|
+
*
|
|
9304
|
+
* Skipped entirely for human agents (no clientId) and when `force` is set
|
|
9305
|
+
* (e.g. operator overrides for an offline client).
|
|
9306
|
+
*/
|
|
9307
|
+
async function ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, options = {}) {
|
|
9308
|
+
if (clientId === null) return;
|
|
9309
|
+
if (options.force) return;
|
|
9310
|
+
const [client] = await db.select({ metadata: clients.metadata }).from(clients).where(eq(clients.id, clientId)).limit(1);
|
|
9311
|
+
if (!client) return;
|
|
9312
|
+
if (!clientCapabilitiesReported(client.metadata)) return;
|
|
9313
|
+
if (!clientSupportsRuntimeProvider(client.metadata, runtimeProvider)) throw new BadRequestError(`Client "${clientId}" does not have runtime provider "${runtimeProvider}" available. Install the matching SDK on that machine and re-run capability detection, or retry with \`force: true\` if the client is offline / capabilities are stale.`);
|
|
9314
|
+
}
|
|
8447
9315
|
async function resolveAgentClient(db, data) {
|
|
8448
9316
|
if (data.type === "human") {
|
|
8449
9317
|
if (data.clientId) throw new BadRequestError("Human agents cannot be pinned to a client");
|
|
@@ -8476,9 +9344,10 @@ async function resolveFallbackManagerId(db, orgId) {
|
|
|
8476
9344
|
if (!row) throw new BadRequestError(`Cannot create agent in organization "${orgId}" — no admin member exists. Create an admin member first (see \`first-tree-hub onboard\`).`);
|
|
8477
9345
|
return row.id;
|
|
8478
9346
|
}
|
|
8479
|
-
async function createAgent(db, data) {
|
|
9347
|
+
async function createAgent(db, data, options = {}) {
|
|
8480
9348
|
const uuid = uuidv7();
|
|
8481
9349
|
const name = data.name ?? null;
|
|
9350
|
+
const runtimeProvider = data.runtimeProvider ?? "claude-code";
|
|
8482
9351
|
if (name?.startsWith(RESERVED_AGENT_NAME_PREFIX)) throw new BadRequestError(`Agent name "${name}" is reserved — names starting with "${RESERVED_AGENT_NAME_PREFIX}" are Hub-internal`);
|
|
8483
9352
|
if (name && isReservedAgentName$1(name)) throw new BadRequestError(`Agent name "${name}" is reserved — pick a different one.`);
|
|
8484
9353
|
const inboxId = `inbox_${uuid}`;
|
|
@@ -8504,6 +9373,7 @@ async function createAgent(db, data) {
|
|
|
8504
9373
|
managerId,
|
|
8505
9374
|
type: data.type
|
|
8506
9375
|
});
|
|
9376
|
+
await ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, { force: options.force });
|
|
8507
9377
|
const [org] = await db.select({ maxAgents: organizations.maxAgents }).from(organizations).where(eq(organizations.id, orgId)).limit(1);
|
|
8508
9378
|
if (org && org.maxAgents > 0) {
|
|
8509
9379
|
if (((await db.select({ value: count() }).from(agents).where(and(eq(agents.organizationId, orgId), ne(agents.status, AGENT_STATUSES.DELETED))))[0]?.value ?? 0) >= org.maxAgents) throw new ForbiddenError(`Organization "${orgId}" has reached its agent limit (${org.maxAgents}). Upgrade your plan or delete unused agents.`);
|
|
@@ -8522,13 +9392,14 @@ async function createAgent(db, data) {
|
|
|
8522
9392
|
visibility: data.visibility ?? defaultVisibility(data.type),
|
|
8523
9393
|
metadata: data.metadata ?? {},
|
|
8524
9394
|
managerId,
|
|
8525
|
-
clientId
|
|
9395
|
+
clientId,
|
|
9396
|
+
runtimeProvider
|
|
8526
9397
|
}).returning();
|
|
8527
9398
|
if (!agent) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
8528
9399
|
await db.insert(agentConfigs).values({
|
|
8529
9400
|
agentId: agent.uuid,
|
|
8530
9401
|
version: 1,
|
|
8531
|
-
payload:
|
|
9402
|
+
payload: defaultRuntimeConfigPayload(runtimeProvider),
|
|
8532
9403
|
updatedBy: "system"
|
|
8533
9404
|
}).onConflictDoNothing();
|
|
8534
9405
|
return agent;
|
|
@@ -8582,6 +9453,7 @@ async function listAgentsForAdmin(db, scope, limit, cursor) {
|
|
|
8582
9453
|
metadata: agents.metadata,
|
|
8583
9454
|
managerId: agents.managerId,
|
|
8584
9455
|
clientId: agents.clientId,
|
|
9456
|
+
runtimeProvider: agents.runtimeProvider,
|
|
8585
9457
|
createdAt: agents.createdAt,
|
|
8586
9458
|
updatedAt: agents.updatedAt,
|
|
8587
9459
|
presenceStatus: agentPresence.status,
|
|
@@ -8619,6 +9491,7 @@ async function listAgentsForMember(db, scope, limit, cursor, type) {
|
|
|
8619
9491
|
metadata: agents.metadata,
|
|
8620
9492
|
managerId: agents.managerId,
|
|
8621
9493
|
clientId: agents.clientId,
|
|
9494
|
+
runtimeProvider: agents.runtimeProvider,
|
|
8622
9495
|
createdAt: agents.createdAt,
|
|
8623
9496
|
updatedAt: agents.updatedAt,
|
|
8624
9497
|
presenceStatus: agentPresence.status,
|
|
@@ -8638,7 +9511,7 @@ async function updateAgent(db, uuid, data) {
|
|
|
8638
9511
|
const agent = await getAgent(db, uuid);
|
|
8639
9512
|
if (data.clientId !== void 0) {
|
|
8640
9513
|
if (data.clientId === null) throw new BadRequestError("clientId cannot be cleared — once bound, an agent stays bound to its client");
|
|
8641
|
-
if (agent.clientId !== null && agent.clientId !== data.clientId) throw new BadRequestError("clientId is immutable
|
|
9514
|
+
if (agent.clientId !== null && agent.clientId !== data.clientId) throw new BadRequestError("clientId is immutable through this entry — cross-client moves go through rebindAgent (PATCH /admin/agents/:agentId/rebind), which runs owner / org / capability checks atomically.");
|
|
8642
9515
|
}
|
|
8643
9516
|
const updates = { updatedAt: /* @__PURE__ */ new Date() };
|
|
8644
9517
|
if (data.type !== void 0) updates.type = data.type;
|
|
@@ -8669,6 +9542,39 @@ async function updateAgent(db, uuid, data) {
|
|
|
8669
9542
|
return updated;
|
|
8670
9543
|
}
|
|
8671
9544
|
/**
|
|
9545
|
+
* Atomically re-bind an agent to a new client and/or runtime provider.
|
|
9546
|
+
*
|
|
9547
|
+
* Validations: agent must exist and not be human; new client must belong to
|
|
9548
|
+
* the same owner (manager.userId) and same organization; client must report
|
|
9549
|
+
* the requested runtime provider in its capabilities (skipped under `force`).
|
|
9550
|
+
*
|
|
9551
|
+
* Intended caller: PATCH /admin/agents/:agentId/rebind. The Web "Re-bind"
|
|
9552
|
+
* dialog routes both same-client runtime-only switches and cross-client
|
|
9553
|
+
* moves through this single entry.
|
|
9554
|
+
*
|
|
9555
|
+
* NOTE: active sessions on the previous client are not auto-suspended in P1.
|
|
9556
|
+
* P3 will wire in cross-service coordination (inbox + presence + session)
|
|
9557
|
+
* so the destination client can resume cleanly.
|
|
9558
|
+
*/
|
|
9559
|
+
async function rebindAgent(db, uuid, data) {
|
|
9560
|
+
const agent = await getAgent(db, uuid);
|
|
9561
|
+
if (agent.type === "human") throw new BadRequestError("Human agents have no runtime — they cannot be re-bound to a client.");
|
|
9562
|
+
const newClientId = await resolveAgentClient(db, {
|
|
9563
|
+
clientId: data.clientId,
|
|
9564
|
+
managerId: agent.managerId,
|
|
9565
|
+
type: agent.type
|
|
9566
|
+
});
|
|
9567
|
+
if (newClientId === null) throw new BadRequestError("Rebind requires a non-null clientId.");
|
|
9568
|
+
await ensureClientSupportsRuntimeProvider(db, newClientId, data.runtimeProvider, { force: data.force });
|
|
9569
|
+
const [updated] = await db.update(agents).set({
|
|
9570
|
+
clientId: newClientId,
|
|
9571
|
+
runtimeProvider: data.runtimeProvider,
|
|
9572
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
9573
|
+
}).where(eq(agents.uuid, uuid)).returning();
|
|
9574
|
+
if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
9575
|
+
return updated;
|
|
9576
|
+
}
|
|
9577
|
+
/**
|
|
8672
9578
|
* Reactivate a suspended agent.
|
|
8673
9579
|
*/
|
|
8674
9580
|
async function reactivateAgent(db, uuid) {
|
|
@@ -9188,7 +10094,7 @@ async function registerClient(db, data) {
|
|
|
9188
10094
|
userId: clients.userId,
|
|
9189
10095
|
organizationId: clients.organizationId
|
|
9190
10096
|
}).from(clients).where(eq(clients.id, data.clientId)).limit(1);
|
|
9191
|
-
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.`);
|
|
10097
|
+
if (existing && existing.organizationId !== data.organizationId) throw new ClientOrgMismatchError$1(`Client "${data.clientId}" is bound to a different organization. Re-register as a new client under the current org.`);
|
|
9192
10098
|
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.`);
|
|
9193
10099
|
await db.insert(clients).values({
|
|
9194
10100
|
id: data.clientId,
|
|
@@ -9248,10 +10154,45 @@ async function listActiveAgentsPinnedToClient(db, clientId) {
|
|
|
9248
10154
|
uuid: agents.uuid,
|
|
9249
10155
|
name: agents.name,
|
|
9250
10156
|
displayName: agents.displayName,
|
|
9251
|
-
type: agents.type
|
|
10157
|
+
type: agents.type,
|
|
10158
|
+
runtimeProvider: agents.runtimeProvider
|
|
9252
10159
|
}).from(agents).where(and(eq(agents.clientId, clientId), ne(agents.status, "deleted")));
|
|
9253
10160
|
}
|
|
9254
10161
|
/**
|
|
10162
|
+
* Member-scoped: every active agent pinned to a client owned by this user
|
|
10163
|
+
* within the given organization. Used by client startup to reconcile its
|
|
10164
|
+
* local YAML against the authoritative `agents.runtime_provider`.
|
|
10165
|
+
*/
|
|
10166
|
+
async function listMyPinnedAgents(db, scope) {
|
|
10167
|
+
return (await db.select({
|
|
10168
|
+
agentId: agents.uuid,
|
|
10169
|
+
clientId: agents.clientId,
|
|
10170
|
+
runtimeProvider: agents.runtimeProvider
|
|
10171
|
+
}).from(agents).innerJoin(clients, eq(agents.clientId, clients.id)).where(and(eq(clients.userId, scope.userId), eq(clients.organizationId, scope.organizationId), ne(agents.status, "deleted")))).filter((r) => r.clientId !== null).map((r) => ({
|
|
10172
|
+
agentId: r.agentId,
|
|
10173
|
+
clientId: r.clientId,
|
|
10174
|
+
runtimeProvider: r.runtimeProvider
|
|
10175
|
+
}));
|
|
10176
|
+
}
|
|
10177
|
+
/**
|
|
10178
|
+
* Replace this client's capabilities snapshot. Capabilities live under
|
|
10179
|
+
* `clients.metadata.capabilities` (Option C — no dedicated column); other
|
|
10180
|
+
* `metadata` subkeys are preserved on merge.
|
|
10181
|
+
*
|
|
10182
|
+
* Caller is expected to have already passed `assertClientOwner`.
|
|
10183
|
+
*/
|
|
10184
|
+
async function updateClientCapabilities(db, clientId, capabilities) {
|
|
10185
|
+
const parsed = clientCapabilitiesSchema$1.safeParse(capabilities);
|
|
10186
|
+
if (!parsed.success) throw new BadRequestError(`Invalid capabilities payload: ${parsed.error.message}`);
|
|
10187
|
+
const [client] = await db.select({ metadata: clients.metadata }).from(clients).where(eq(clients.id, clientId)).limit(1);
|
|
10188
|
+
if (!client) throw new NotFoundError(`Client "${clientId}" not found`);
|
|
10189
|
+
const merged = {
|
|
10190
|
+
...client.metadata ?? {},
|
|
10191
|
+
capabilities: parsed.data
|
|
10192
|
+
};
|
|
10193
|
+
await db.update(clients).set({ metadata: merged }).where(eq(clients.id, clientId));
|
|
10194
|
+
}
|
|
10195
|
+
/**
|
|
9255
10196
|
* Scope-aware client listing.
|
|
9256
10197
|
*
|
|
9257
10198
|
* - member: rows where `user_id = scope.userId` AND `organization_id = scope.organizationId`
|
|
@@ -9464,51 +10405,178 @@ const inboxEntries = pgTable("inbox_entries", {
|
|
|
9464
10405
|
index("idx_inbox_pending_notify").on(table.inboxId, table.createdAt).where(sql`status = 'pending' AND notify = true`),
|
|
9465
10406
|
index("idx_inbox_chat_silent").on(table.inboxId, table.chatId, table.notify, table.status)
|
|
9466
10407
|
]);
|
|
9467
|
-
|
|
9468
|
-
|
|
9469
|
-
|
|
9470
|
-
|
|
9471
|
-
|
|
9472
|
-
|
|
9473
|
-
}
|
|
9474
|
-
|
|
9475
|
-
|
|
9476
|
-
|
|
9477
|
-
|
|
9478
|
-
|
|
9479
|
-
|
|
9480
|
-
|
|
9481
|
-
|
|
9482
|
-
|
|
9483
|
-
|
|
9484
|
-
|
|
9485
|
-
|
|
9486
|
-
|
|
9487
|
-
|
|
9488
|
-
|
|
9489
|
-
|
|
9490
|
-
|
|
9491
|
-
|
|
9492
|
-
|
|
9493
|
-
|
|
9494
|
-
|
|
9495
|
-
|
|
9496
|
-
|
|
9497
|
-
|
|
9498
|
-
|
|
9499
|
-
|
|
9500
|
-
|
|
9501
|
-
|
|
9502
|
-
|
|
9503
|
-
|
|
9504
|
-
|
|
9505
|
-
|
|
9506
|
-
|
|
9507
|
-
|
|
9508
|
-
|
|
9509
|
-
|
|
9510
|
-
}
|
|
9511
|
-
|
|
10408
|
+
/** Per-session state snapshot. One row per (agent, chat) pair, upserted on each session:state message. */
|
|
10409
|
+
const agentChatSessions = pgTable("agent_chat_sessions", {
|
|
10410
|
+
agentId: text("agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
|
|
10411
|
+
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
10412
|
+
state: text("state").notNull(),
|
|
10413
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
10414
|
+
}, (table) => [primaryKey({ columns: [table.agentId, table.chatId] })]);
|
|
10415
|
+
/**
|
|
10416
|
+
* Upsert session state + refresh presence aggregates + NOTIFY.
|
|
10417
|
+
*
|
|
10418
|
+
* `agent_chat_sessions.(agent_id, chat_id)` is a single-row "current session
|
|
10419
|
+
* state" cache, not a session history log. A new runtime session starting on
|
|
10420
|
+
* the same (agent, chat) pair MUST overwrite whatever ended before — including
|
|
10421
|
+
* an `evicted` row left by a previous terminate. The previous "revival
|
|
10422
|
+
* defense" conflated two concerns: "this runtime session ended" (which is
|
|
10423
|
+
* what `evicted` actually means) and "this chat is permanently archived for
|
|
10424
|
+
* this agent" (a chat-level decision that should live on `chats`, not here).
|
|
10425
|
+
* See proposals/hub-agent-messaging-reply-and-mentions §M2-session-lifecycle.
|
|
10426
|
+
*
|
|
10427
|
+
* Presence row contract: this function tolerates a missing `agent_presence`
|
|
10428
|
+
* row by using `INSERT ... ON CONFLICT DO UPDATE`. The predictive-write path
|
|
10429
|
+
* (sendMessage on first message) may target an agent whose client has never
|
|
10430
|
+
* bound, so a prior `update agent_presence ... where agentId` would silently
|
|
10431
|
+
* drop the activeSessions/totalSessions refresh. See PR #198 review §2.
|
|
10432
|
+
*/
|
|
10433
|
+
async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier, options) {
|
|
10434
|
+
const now = /* @__PURE__ */ new Date();
|
|
10435
|
+
let wrote = false;
|
|
10436
|
+
await db.transaction(async (tx) => {
|
|
10437
|
+
await tx.insert(agentChatSessions).values({
|
|
10438
|
+
agentId,
|
|
10439
|
+
chatId,
|
|
10440
|
+
state,
|
|
10441
|
+
updatedAt: now
|
|
10442
|
+
}).onConflictDoUpdate({
|
|
10443
|
+
target: [agentChatSessions.agentId, agentChatSessions.chatId],
|
|
10444
|
+
set: {
|
|
10445
|
+
state,
|
|
10446
|
+
updatedAt: now
|
|
10447
|
+
},
|
|
10448
|
+
setWhere: ne(agentChatSessions.state, state)
|
|
10449
|
+
});
|
|
10450
|
+
const [counts] = await tx.select({
|
|
10451
|
+
active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
|
|
10452
|
+
total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
|
|
10453
|
+
}).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
|
|
10454
|
+
const activeSessions = counts?.active ?? 0;
|
|
10455
|
+
const totalSessions = counts?.total ?? 0;
|
|
10456
|
+
const presenceSet = options?.touchPresenceLastSeen ?? true ? {
|
|
10457
|
+
activeSessions,
|
|
10458
|
+
totalSessions,
|
|
10459
|
+
lastSeenAt: now
|
|
10460
|
+
} : {
|
|
10461
|
+
activeSessions,
|
|
10462
|
+
totalSessions
|
|
10463
|
+
};
|
|
10464
|
+
await tx.insert(agentPresence).values({
|
|
10465
|
+
agentId,
|
|
10466
|
+
activeSessions,
|
|
10467
|
+
totalSessions
|
|
10468
|
+
}).onConflictDoUpdate({
|
|
10469
|
+
target: [agentPresence.agentId],
|
|
10470
|
+
set: presenceSet
|
|
10471
|
+
});
|
|
10472
|
+
wrote = true;
|
|
10473
|
+
});
|
|
10474
|
+
if (wrote && notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
|
|
10475
|
+
}
|
|
10476
|
+
async function resetActivity(db, agentId) {
|
|
10477
|
+
const now = /* @__PURE__ */ new Date();
|
|
10478
|
+
await db.update(agentPresence).set({
|
|
10479
|
+
runtimeState: "idle",
|
|
10480
|
+
runtimeUpdatedAt: now
|
|
10481
|
+
}).where(eq(agentPresence.agentId, agentId));
|
|
10482
|
+
}
|
|
10483
|
+
async function getActivityOverview(db) {
|
|
10484
|
+
const [agentCounts] = await db.select({
|
|
10485
|
+
total: sql`count(*)::int`,
|
|
10486
|
+
running: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} IS NOT NULL)::int`,
|
|
10487
|
+
idle: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'idle')::int`,
|
|
10488
|
+
working: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'working')::int`,
|
|
10489
|
+
blocked: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'blocked')::int`,
|
|
10490
|
+
error: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'error')::int`
|
|
10491
|
+
}).from(agentPresence);
|
|
10492
|
+
const [clientCounts] = await db.select({ count: sql`count(*)::int` }).from(clients).where(eq(clients.status, "connected"));
|
|
10493
|
+
return {
|
|
10494
|
+
total: agentCounts?.total ?? 0,
|
|
10495
|
+
running: agentCounts?.running ?? 0,
|
|
10496
|
+
byState: {
|
|
10497
|
+
idle: agentCounts?.idle ?? 0,
|
|
10498
|
+
working: agentCounts?.working ?? 0,
|
|
10499
|
+
blocked: agentCounts?.blocked ?? 0,
|
|
10500
|
+
error: agentCounts?.error ?? 0
|
|
10501
|
+
},
|
|
10502
|
+
clients: clientCounts?.count ?? 0
|
|
10503
|
+
};
|
|
10504
|
+
}
|
|
10505
|
+
/**
|
|
10506
|
+
* List agents with active runtime state.
|
|
10507
|
+
* When scope is provided, filters to agents visible to the member.
|
|
10508
|
+
*/
|
|
10509
|
+
async function listAgentsWithRuntime(db, scope) {
|
|
10510
|
+
if (!scope) return db.select().from(agentPresence).where(isNotNull(agentPresence.runtimeState));
|
|
10511
|
+
return db.select({
|
|
10512
|
+
agentId: agentPresence.agentId,
|
|
10513
|
+
status: agentPresence.status,
|
|
10514
|
+
instanceId: agentPresence.instanceId,
|
|
10515
|
+
connectedAt: agentPresence.connectedAt,
|
|
10516
|
+
lastSeenAt: agentPresence.lastSeenAt,
|
|
10517
|
+
clientId: agentPresence.clientId,
|
|
10518
|
+
runtimeType: agentPresence.runtimeType,
|
|
10519
|
+
runtimeVersion: agentPresence.runtimeVersion,
|
|
10520
|
+
runtimeState: agentPresence.runtimeState,
|
|
10521
|
+
activeSessions: agentPresence.activeSessions,
|
|
10522
|
+
totalSessions: agentPresence.totalSessions,
|
|
10523
|
+
runtimeUpdatedAt: agentPresence.runtimeUpdatedAt,
|
|
10524
|
+
type: agents.type
|
|
10525
|
+
}).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope)));
|
|
10526
|
+
}
|
|
10527
|
+
const log$4 = createLogger$1("message");
|
|
10528
|
+
async function sendMessage(db, chatId, senderId, data, options = {}) {
|
|
10529
|
+
return withSpan("inbox.enqueue", messageAttrs({
|
|
10530
|
+
chatId,
|
|
10531
|
+
senderAgentId: senderId,
|
|
10532
|
+
source: data.source ?? void 0
|
|
10533
|
+
}), () => sendMessageInner(db, chatId, senderId, data, options));
|
|
10534
|
+
}
|
|
10535
|
+
async function sendMessageInner(db, chatId, senderId, data, options) {
|
|
10536
|
+
const txResult = await db.transaction(async (tx) => {
|
|
10537
|
+
const [participants, [chatRow], [senderRow]] = await Promise.all([
|
|
10538
|
+
tx.select({
|
|
10539
|
+
agentId: chatParticipants.agentId,
|
|
10540
|
+
inboxId: agents.inboxId,
|
|
10541
|
+
mode: chatParticipants.mode,
|
|
10542
|
+
name: agents.name
|
|
10543
|
+
}).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId)),
|
|
10544
|
+
tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1),
|
|
10545
|
+
tx.select({
|
|
10546
|
+
inboxId: agents.inboxId,
|
|
10547
|
+
organizationId: agents.organizationId
|
|
10548
|
+
}).from(agents).where(eq(agents.uuid, senderId)).limit(1)
|
|
10549
|
+
]);
|
|
10550
|
+
const chatType = chatRow?.type ?? null;
|
|
10551
|
+
if (!senderRow) throw new NotFoundError(`Sender agent "${senderId}" not found`);
|
|
10552
|
+
if (data.replyToInbox !== void 0 && data.replyToInbox !== null) {
|
|
10553
|
+
if (senderRow.inboxId !== data.replyToInbox) throw new BadRequestError("replyToInbox must reference the sender's own inbox");
|
|
10554
|
+
}
|
|
10555
|
+
const incomingMeta = data.metadata ?? {};
|
|
10556
|
+
const explicitMentionsRaw = incomingMeta.mentions;
|
|
10557
|
+
const explicitMentions = Array.isArray(explicitMentionsRaw) ? explicitMentionsRaw.filter((m) => typeof m === "string") : [];
|
|
10558
|
+
const contentText = typeof data.content === "string" ? data.content : "";
|
|
10559
|
+
const resolved = contentText ? extractMentions(contentText, participants) : [];
|
|
10560
|
+
const mergedMentions = [...new Set([...explicitMentions, ...resolved])];
|
|
10561
|
+
const metadataToStore = mergedMentions.length > 0 ? {
|
|
10562
|
+
...incomingMeta,
|
|
10563
|
+
mentions: mergedMentions
|
|
10564
|
+
} : incomingMeta;
|
|
10565
|
+
if (options.enforceGroupMention && chatType === "group") {
|
|
10566
|
+
if (mergedMentions.filter((id) => id !== senderId).length === 0) throw new BadRequestError("Sending to a group chat requires an explicit @mention. Use `agent send <name>` to message a single agent, or @<name> in the content to address one or more group members.");
|
|
10567
|
+
}
|
|
10568
|
+
let outboundContent = data.content;
|
|
10569
|
+
if (options.normalizeMentionsInContent && typeof outboundContent === "string") {
|
|
10570
|
+
const present = new Set(scanMentionTokens(outboundContent));
|
|
10571
|
+
const missingNames = [];
|
|
10572
|
+
for (const id of mergedMentions) {
|
|
10573
|
+
if (id === senderId) continue;
|
|
10574
|
+
const p = participants.find((q) => q.agentId === id);
|
|
10575
|
+
if (!p?.name) continue;
|
|
10576
|
+
if (present.has(p.name.toLowerCase())) continue;
|
|
10577
|
+
missingNames.push(p.name);
|
|
10578
|
+
}
|
|
10579
|
+
if (missingNames.length > 0) {
|
|
9512
10580
|
const prefix = missingNames.map((n) => `@${n}`).join(" ");
|
|
9513
10581
|
outboundContent = outboundContent.length > 0 ? `${prefix} ${outboundContent}` : prefix;
|
|
9514
10582
|
}
|
|
@@ -9527,14 +10595,20 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
|
|
|
9527
10595
|
source: data.source ?? null
|
|
9528
10596
|
}).returning();
|
|
9529
10597
|
const mentionSet = new Set(mergedMentions);
|
|
9530
|
-
const
|
|
10598
|
+
const fanout = participants.filter((p) => p.agentId !== senderId).map((p) => ({
|
|
10599
|
+
agentId: p.agentId,
|
|
9531
10600
|
inboxId: p.inboxId,
|
|
9532
|
-
messageId,
|
|
9533
|
-
chatId,
|
|
9534
10601
|
notify: p.mode !== "mention_only" || mentionSet.has(p.agentId)
|
|
9535
10602
|
}));
|
|
9536
|
-
if (
|
|
9537
|
-
|
|
10603
|
+
if (fanout.length > 0) await tx.insert(inboxEntries).values(fanout.map((f) => ({
|
|
10604
|
+
inboxId: f.inboxId,
|
|
10605
|
+
messageId,
|
|
10606
|
+
chatId,
|
|
10607
|
+
notify: f.notify
|
|
10608
|
+
})));
|
|
10609
|
+
const notified = fanout.filter((f) => f.notify);
|
|
10610
|
+
const recipients = notified.map((f) => f.inboxId);
|
|
10611
|
+
const recipientAgentIds = notified.map((f) => f.agentId);
|
|
9538
10612
|
if (data.inReplyTo) {
|
|
9539
10613
|
const [original] = await tx.select({
|
|
9540
10614
|
replyToInbox: messages.replyToInbox,
|
|
@@ -9553,9 +10627,24 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
|
|
|
9553
10627
|
if (!msg) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
9554
10628
|
return {
|
|
9555
10629
|
message: msg,
|
|
9556
|
-
recipients
|
|
10630
|
+
recipients,
|
|
10631
|
+
recipientAgentIds,
|
|
10632
|
+
organizationId: senderRow.organizationId
|
|
9557
10633
|
};
|
|
9558
10634
|
});
|
|
10635
|
+
const settled = await Promise.allSettled(txResult.recipientAgentIds.map((agentId) => upsertSessionState(db, agentId, chatId, "active", txResult.organizationId, void 0, { touchPresenceLastSeen: false })));
|
|
10636
|
+
for (let i = 0; i < settled.length; i++) {
|
|
10637
|
+
const r = settled[i];
|
|
10638
|
+
if (r?.status === "rejected") log$4.error({
|
|
10639
|
+
err: r.reason,
|
|
10640
|
+
chatId,
|
|
10641
|
+
agentId: txResult.recipientAgentIds[i]
|
|
10642
|
+
}, "predictive session activation failed");
|
|
10643
|
+
}
|
|
10644
|
+
return {
|
|
10645
|
+
message: txResult.message,
|
|
10646
|
+
recipients: txResult.recipients
|
|
10647
|
+
};
|
|
9559
10648
|
}
|
|
9560
10649
|
async function sendToAgent(db, senderUuid, targetName, data) {
|
|
9561
10650
|
const [sender] = await db.select({
|
|
@@ -9782,7 +10871,8 @@ async function adminAgentRoutes(app) {
|
|
|
9782
10871
|
agentId: agent.uuid,
|
|
9783
10872
|
name: agent.name,
|
|
9784
10873
|
displayName: agent.displayName,
|
|
9785
|
-
agentType: agent.type
|
|
10874
|
+
agentType: agent.type,
|
|
10875
|
+
runtimeProvider: agent.runtimeProvider
|
|
9786
10876
|
});
|
|
9787
10877
|
if (!parsed.success) {
|
|
9788
10878
|
app.log.warn({
|
|
@@ -9885,6 +10975,23 @@ async function adminAgentRoutes(app) {
|
|
|
9885
10975
|
updatedAt: agent.updatedAt.toISOString()
|
|
9886
10976
|
};
|
|
9887
10977
|
});
|
|
10978
|
+
/**
|
|
10979
|
+
* Rebind an agent to a new client and/or runtime provider. Re-runs owner /
|
|
10980
|
+
* org / capability checks atomically. Capability mismatch can be overridden
|
|
10981
|
+
* with `force: true` (e.g. client offline, capabilities stale).
|
|
10982
|
+
*/
|
|
10983
|
+
app.patch("/:uuid/rebind", async (request) => {
|
|
10984
|
+
const scope = memberScope(request);
|
|
10985
|
+
await assertCanManage(app.db, scope, request.params.uuid);
|
|
10986
|
+
const body = rebindAgentSchema.parse(request.body);
|
|
10987
|
+
const agent = await rebindAgent(app.db, request.params.uuid, body);
|
|
10988
|
+
notifyClientAgentPinned(agent);
|
|
10989
|
+
return {
|
|
10990
|
+
...agent,
|
|
10991
|
+
createdAt: agent.createdAt.toISOString(),
|
|
10992
|
+
updatedAt: agent.updatedAt.toISOString()
|
|
10993
|
+
};
|
|
10994
|
+
});
|
|
9888
10995
|
app.get("/:uuid", async (request) => {
|
|
9889
10996
|
const scope = memberScope(request);
|
|
9890
10997
|
await assertAgentVisible(app.db, scope, request.params.uuid);
|
|
@@ -10059,9 +11166,10 @@ function memberAuthHook(db, jwtSecret) {
|
|
|
10059
11166
|
id: members.id,
|
|
10060
11167
|
organizationId: members.organizationId,
|
|
10061
11168
|
role: members.role,
|
|
10062
|
-
agentId: members.agentId
|
|
11169
|
+
agentId: members.agentId,
|
|
11170
|
+
status: members.status
|
|
10063
11171
|
}).from(members).where(eq(members.id, payload.memberId)).limit(1);
|
|
10064
|
-
if (!member) throw new UnauthorizedError("Membership not found");
|
|
11172
|
+
if (!member || member.status !== "active") throw new UnauthorizedError("Membership not found");
|
|
10065
11173
|
request.member = {
|
|
10066
11174
|
userId: user.id,
|
|
10067
11175
|
memberId: member.id,
|
|
@@ -10338,107 +11446,6 @@ async function adminChatRoutes(app) {
|
|
|
10338
11446
|
});
|
|
10339
11447
|
});
|
|
10340
11448
|
}
|
|
10341
|
-
/** Per-session state snapshot. One row per (agent, chat) pair, upserted on each session:state message. */
|
|
10342
|
-
const agentChatSessions = pgTable("agent_chat_sessions", {
|
|
10343
|
-
agentId: text("agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
|
|
10344
|
-
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
10345
|
-
state: text("state").notNull(),
|
|
10346
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
10347
|
-
}, (table) => [primaryKey({ columns: [table.agentId, table.chatId] })]);
|
|
10348
|
-
/**
|
|
10349
|
-
* Upsert session state + refresh presence aggregates + NOTIFY.
|
|
10350
|
-
*
|
|
10351
|
-
* `agent_chat_sessions.(agent_id, chat_id)` is a single-row "current session
|
|
10352
|
-
* state" cache, not a session history log. A new runtime session starting on
|
|
10353
|
-
* the same (agent, chat) pair MUST overwrite whatever ended before — including
|
|
10354
|
-
* an `evicted` row left by a previous terminate. The previous "revival
|
|
10355
|
-
* defense" conflated two concerns: "this runtime session ended" (which is
|
|
10356
|
-
* what `evicted` actually means) and "this chat is permanently archived for
|
|
10357
|
-
* this agent" (a chat-level decision that should live on `chats`, not here).
|
|
10358
|
-
* See proposals/hub-agent-messaging-reply-and-mentions §M2-session-lifecycle.
|
|
10359
|
-
*/
|
|
10360
|
-
async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier) {
|
|
10361
|
-
const now = /* @__PURE__ */ new Date();
|
|
10362
|
-
let wrote = false;
|
|
10363
|
-
await db.transaction(async (tx) => {
|
|
10364
|
-
await tx.insert(agentChatSessions).values({
|
|
10365
|
-
agentId,
|
|
10366
|
-
chatId,
|
|
10367
|
-
state,
|
|
10368
|
-
updatedAt: now
|
|
10369
|
-
}).onConflictDoUpdate({
|
|
10370
|
-
target: [agentChatSessions.agentId, agentChatSessions.chatId],
|
|
10371
|
-
set: {
|
|
10372
|
-
state,
|
|
10373
|
-
updatedAt: now
|
|
10374
|
-
}
|
|
10375
|
-
});
|
|
10376
|
-
const [counts] = await tx.select({
|
|
10377
|
-
active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
|
|
10378
|
-
total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
|
|
10379
|
-
}).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
|
|
10380
|
-
const activeSessions = counts?.active ?? 0;
|
|
10381
|
-
const totalSessions = counts?.total ?? 0;
|
|
10382
|
-
await tx.update(agentPresence).set({
|
|
10383
|
-
activeSessions,
|
|
10384
|
-
totalSessions,
|
|
10385
|
-
lastSeenAt: now
|
|
10386
|
-
}).where(eq(agentPresence.agentId, agentId));
|
|
10387
|
-
wrote = true;
|
|
10388
|
-
});
|
|
10389
|
-
if (wrote && notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
|
|
10390
|
-
}
|
|
10391
|
-
async function resetActivity(db, agentId) {
|
|
10392
|
-
const now = /* @__PURE__ */ new Date();
|
|
10393
|
-
await db.update(agentPresence).set({
|
|
10394
|
-
runtimeState: "idle",
|
|
10395
|
-
runtimeUpdatedAt: now
|
|
10396
|
-
}).where(eq(agentPresence.agentId, agentId));
|
|
10397
|
-
}
|
|
10398
|
-
async function getActivityOverview(db) {
|
|
10399
|
-
const [agentCounts] = await db.select({
|
|
10400
|
-
total: sql`count(*)::int`,
|
|
10401
|
-
running: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} IS NOT NULL)::int`,
|
|
10402
|
-
idle: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'idle')::int`,
|
|
10403
|
-
working: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'working')::int`,
|
|
10404
|
-
blocked: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'blocked')::int`,
|
|
10405
|
-
error: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'error')::int`
|
|
10406
|
-
}).from(agentPresence);
|
|
10407
|
-
const [clientCounts] = await db.select({ count: sql`count(*)::int` }).from(clients).where(eq(clients.status, "connected"));
|
|
10408
|
-
return {
|
|
10409
|
-
total: agentCounts?.total ?? 0,
|
|
10410
|
-
running: agentCounts?.running ?? 0,
|
|
10411
|
-
byState: {
|
|
10412
|
-
idle: agentCounts?.idle ?? 0,
|
|
10413
|
-
working: agentCounts?.working ?? 0,
|
|
10414
|
-
blocked: agentCounts?.blocked ?? 0,
|
|
10415
|
-
error: agentCounts?.error ?? 0
|
|
10416
|
-
},
|
|
10417
|
-
clients: clientCounts?.count ?? 0
|
|
10418
|
-
};
|
|
10419
|
-
}
|
|
10420
|
-
/**
|
|
10421
|
-
* List agents with active runtime state.
|
|
10422
|
-
* When scope is provided, filters to agents visible to the member.
|
|
10423
|
-
*/
|
|
10424
|
-
async function listAgentsWithRuntime(db, scope) {
|
|
10425
|
-
if (!scope) return db.select().from(agentPresence).where(isNotNull(agentPresence.runtimeState));
|
|
10426
|
-
return db.select({
|
|
10427
|
-
agentId: agentPresence.agentId,
|
|
10428
|
-
status: agentPresence.status,
|
|
10429
|
-
instanceId: agentPresence.instanceId,
|
|
10430
|
-
connectedAt: agentPresence.connectedAt,
|
|
10431
|
-
lastSeenAt: agentPresence.lastSeenAt,
|
|
10432
|
-
clientId: agentPresence.clientId,
|
|
10433
|
-
runtimeType: agentPresence.runtimeType,
|
|
10434
|
-
runtimeVersion: agentPresence.runtimeVersion,
|
|
10435
|
-
runtimeState: agentPresence.runtimeState,
|
|
10436
|
-
activeSessions: agentPresence.activeSessions,
|
|
10437
|
-
totalSessions: agentPresence.totalSessions,
|
|
10438
|
-
runtimeUpdatedAt: agentPresence.runtimeUpdatedAt,
|
|
10439
|
-
type: agents.type
|
|
10440
|
-
}).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope)));
|
|
10441
|
-
}
|
|
10442
11449
|
/** Serialize a Date to ISO string, or null. */
|
|
10443
11450
|
function serializeDate(d) {
|
|
10444
11451
|
return d ? d.toISOString() : null;
|
|
@@ -10462,11 +11469,27 @@ async function adminClientRoutes(app) {
|
|
|
10462
11469
|
lastSeenAt: c.lastSeenAt.toISOString()
|
|
10463
11470
|
}));
|
|
10464
11471
|
});
|
|
11472
|
+
app.get("/me/agents", async (request) => {
|
|
11473
|
+
const scope = memberScope(request);
|
|
11474
|
+
return await listMyPinnedAgents(app.db, {
|
|
11475
|
+
userId: scope.userId,
|
|
11476
|
+
organizationId: scope.organizationId
|
|
11477
|
+
});
|
|
11478
|
+
});
|
|
11479
|
+
app.patch("/:clientId/capabilities", async (request, reply) => {
|
|
11480
|
+
const scope = memberScope(request);
|
|
11481
|
+
await assertClientOwner(app.db, request.params.clientId, scope);
|
|
11482
|
+
const body = updateClientCapabilitiesSchema.parse(request.body);
|
|
11483
|
+
await updateClientCapabilities(app.db, request.params.clientId, body.capabilities);
|
|
11484
|
+
return reply.status(204).send();
|
|
11485
|
+
});
|
|
10465
11486
|
app.get("/:clientId", async (request) => {
|
|
10466
11487
|
const scope = memberScope(request);
|
|
10467
11488
|
await assertClientOwner(app.db, request.params.clientId, scope);
|
|
10468
11489
|
const client = await getClient(app.db, request.params.clientId);
|
|
10469
11490
|
if (!client) throw new Error("unreachable: client missing after owner check");
|
|
11491
|
+
const metadata = client.metadata ?? {};
|
|
11492
|
+
const capabilities = metadata.capabilities && typeof metadata.capabilities === "object" ? metadata.capabilities : {};
|
|
10470
11493
|
return {
|
|
10471
11494
|
id: client.id,
|
|
10472
11495
|
userId: client.userId,
|
|
@@ -10475,7 +11498,8 @@ async function adminClientRoutes(app) {
|
|
|
10475
11498
|
hostname: client.hostname,
|
|
10476
11499
|
os: client.os,
|
|
10477
11500
|
connectedAt: serializeDate(client.connectedAt),
|
|
10478
|
-
lastSeenAt: client.lastSeenAt.toISOString()
|
|
11501
|
+
lastSeenAt: client.lastSeenAt.toISOString(),
|
|
11502
|
+
capabilities
|
|
10479
11503
|
};
|
|
10480
11504
|
});
|
|
10481
11505
|
app.post("/:clientId/disconnect", async (request) => {
|
|
@@ -12750,7 +13774,7 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
12750
13774
|
});
|
|
12751
13775
|
} catch (err) {
|
|
12752
13776
|
const message = err instanceof Error ? err.message : "client register failed";
|
|
12753
|
-
const code = err instanceof ClientOrgMismatchError ? err.code : void 0;
|
|
13777
|
+
const code = err instanceof ClientOrgMismatchError$1 ? err.code : void 0;
|
|
12754
13778
|
socket.send(JSON.stringify({
|
|
12755
13779
|
type: "client:register:rejected",
|
|
12756
13780
|
message,
|
|
@@ -12774,7 +13798,8 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
12774
13798
|
agentId: agent.uuid,
|
|
12775
13799
|
name: agent.name,
|
|
12776
13800
|
displayName: agent.displayName,
|
|
12777
|
-
agentType: agent.type
|
|
13801
|
+
agentType: agent.type,
|
|
13802
|
+
runtimeProvider: agent.runtimeProvider
|
|
12778
13803
|
});
|
|
12779
13804
|
if (!parsed.success) {
|
|
12780
13805
|
app.log.warn({
|
|
@@ -12810,6 +13835,7 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
12810
13835
|
inboxId: agents.inboxId,
|
|
12811
13836
|
status: agents.status,
|
|
12812
13837
|
clientId: agents.clientId,
|
|
13838
|
+
runtimeProvider: agents.runtimeProvider,
|
|
12813
13839
|
clientUserId: clients.userId,
|
|
12814
13840
|
managerUserId: members.userId
|
|
12815
13841
|
}).from(agents).leftJoin(clients, eq(agents.clientId, clients.id)).leftJoin(members, eq(agents.managerId, members.id)).where(and(eq(agents.uuid, bindRequest.agentId))).limit(1);
|
|
@@ -12844,6 +13870,10 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
12844
13870
|
sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.NOT_OWNED);
|
|
12845
13871
|
return;
|
|
12846
13872
|
}
|
|
13873
|
+
if (bindRequest.runtimeType !== agent.runtimeProvider) {
|
|
13874
|
+
sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.RUNTIME_PROVIDER_MISMATCH);
|
|
13875
|
+
return;
|
|
13876
|
+
}
|
|
12847
13877
|
await bindAgent(app.db, agent.id, {
|
|
12848
13878
|
clientId,
|
|
12849
13879
|
instanceId,
|
|
@@ -13025,122 +14055,722 @@ const CONNECT_JTI_TTL_MS = 6e5;
|
|
|
13025
14055
|
async function signToken(secret, payload, expiry) {
|
|
13026
14056
|
return new SignJWT({ ...payload }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(expiry).sign(secret);
|
|
13027
14057
|
}
|
|
14058
|
+
/**
|
|
14059
|
+
* Sign an `(access, refresh)` pair for the given member. Used by both the
|
|
14060
|
+
* legacy username/password login path and the SaaS GitHub OAuth callback,
|
|
14061
|
+
* so the issuance shape stays in one place.
|
|
14062
|
+
*/
|
|
14063
|
+
async function signTokensForMember(jwtSecretKey, member) {
|
|
14064
|
+
const secret = new TextEncoder().encode(jwtSecretKey);
|
|
14065
|
+
const tokenBase = {
|
|
14066
|
+
sub: member.userId,
|
|
14067
|
+
memberId: member.memberId,
|
|
14068
|
+
organizationId: member.organizationId,
|
|
14069
|
+
role: member.role
|
|
14070
|
+
};
|
|
14071
|
+
return {
|
|
14072
|
+
accessToken: await signToken(secret, {
|
|
14073
|
+
...tokenBase,
|
|
14074
|
+
type: "access"
|
|
14075
|
+
}, ACCESS_TOKEN_EXPIRY),
|
|
14076
|
+
refreshToken: await signToken(secret, {
|
|
14077
|
+
...tokenBase,
|
|
14078
|
+
type: "refresh"
|
|
14079
|
+
}, REFRESH_TOKEN_EXPIRY)
|
|
14080
|
+
};
|
|
14081
|
+
}
|
|
13028
14082
|
async function login(db, username, password, jwtSecretKey) {
|
|
13029
14083
|
const [user] = await db.select().from(users).where(eq(users.username, username)).limit(1);
|
|
13030
14084
|
if (!user || user.status !== "active") throw new UnauthorizedError("Invalid username or password");
|
|
13031
14085
|
if (!await bcrypt.compare(password, user.passwordHash)) throw new UnauthorizedError("Invalid username or password");
|
|
13032
|
-
const [member] = await db.select().from(members).where(eq(members.userId, user.id)).limit(1);
|
|
14086
|
+
const [member] = await db.select().from(members).where(and(eq(members.userId, user.id), eq(members.status, "active"))).limit(1);
|
|
13033
14087
|
if (!member) throw new UnauthorizedError("No organization membership found");
|
|
14088
|
+
const tokens = await signTokensForMember(jwtSecretKey, {
|
|
14089
|
+
userId: user.id,
|
|
14090
|
+
memberId: member.id,
|
|
14091
|
+
organizationId: member.organizationId,
|
|
14092
|
+
role: member.role
|
|
14093
|
+
});
|
|
14094
|
+
await db.update(users).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(users.id, user.id));
|
|
14095
|
+
return tokens;
|
|
14096
|
+
}
|
|
14097
|
+
async function refreshAccessToken(db, refreshToken, jwtSecretKey) {
|
|
13034
14098
|
const secret = new TextEncoder().encode(jwtSecretKey);
|
|
13035
|
-
|
|
14099
|
+
let payload;
|
|
14100
|
+
try {
|
|
14101
|
+
const { payload: p } = await jwtVerify(refreshToken, secret);
|
|
14102
|
+
payload = p;
|
|
14103
|
+
} catch {
|
|
14104
|
+
throw new UnauthorizedError("Invalid or expired refresh token");
|
|
14105
|
+
}
|
|
14106
|
+
if (payload.type !== "refresh" || !payload.sub) throw new UnauthorizedError("Invalid token type");
|
|
14107
|
+
const [user] = await db.select({
|
|
14108
|
+
id: users.id,
|
|
14109
|
+
status: users.status
|
|
14110
|
+
}).from(users).where(eq(users.id, payload.sub)).limit(1);
|
|
14111
|
+
if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
|
|
14112
|
+
const [member] = await db.select().from(members).where(and(eq(members.id, payload.memberId), eq(members.status, "active"))).limit(1);
|
|
14113
|
+
if (!member) throw new UnauthorizedError("Membership not found");
|
|
14114
|
+
return { accessToken: await signToken(secret, {
|
|
13036
14115
|
sub: user.id,
|
|
13037
14116
|
memberId: member.id,
|
|
13038
14117
|
organizationId: member.organizationId,
|
|
14118
|
+
role: member.role,
|
|
14119
|
+
type: "access"
|
|
14120
|
+
}, ACCESS_TOKEN_EXPIRY) };
|
|
14121
|
+
}
|
|
14122
|
+
/**
|
|
14123
|
+
* Generate a short-lived connect token for CLI authentication.
|
|
14124
|
+
* The connect token carries the member's identity and can be exchanged
|
|
14125
|
+
* for full access+refresh tokens via exchangeConnectToken().
|
|
14126
|
+
*
|
|
14127
|
+
* `iss` (when supplied) is stamped into the JWT so the CLI can derive
|
|
14128
|
+
* the hub URL with no additional argument. Production servers must
|
|
14129
|
+
* always pass it; dev callers may omit and the CLI will require an
|
|
14130
|
+
* explicit `--server-url` (legacy form).
|
|
14131
|
+
*/
|
|
14132
|
+
async function generateConnectToken(member, jwtSecretKey, iss) {
|
|
14133
|
+
const secret = new TextEncoder().encode(jwtSecretKey);
|
|
14134
|
+
const jti = randomUUID();
|
|
14135
|
+
const builder = new SignJWT({
|
|
14136
|
+
sub: member.userId,
|
|
14137
|
+
memberId: member.memberId,
|
|
14138
|
+
organizationId: member.organizationId,
|
|
14139
|
+
role: member.role,
|
|
14140
|
+
type: "connect"
|
|
14141
|
+
}).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setJti(jti).setExpirationTime(CONNECT_TOKEN_EXPIRY);
|
|
14142
|
+
if (iss) builder.setIssuer(iss);
|
|
14143
|
+
return {
|
|
14144
|
+
token: await builder.sign(secret),
|
|
14145
|
+
expiresIn: 600
|
|
14146
|
+
};
|
|
14147
|
+
}
|
|
14148
|
+
/**
|
|
14149
|
+
* Exchange a connect token for full access+refresh tokens.
|
|
14150
|
+
* Validates the connect token, verifies the user is still active,
|
|
14151
|
+
* and issues a fresh token pair.
|
|
14152
|
+
*/
|
|
14153
|
+
async function exchangeConnectToken(db, connectToken, jwtSecretKey) {
|
|
14154
|
+
const secret = new TextEncoder().encode(jwtSecretKey);
|
|
14155
|
+
let payload;
|
|
14156
|
+
try {
|
|
14157
|
+
const { payload: p } = await jwtVerify(connectToken, secret);
|
|
14158
|
+
payload = p;
|
|
14159
|
+
} catch {
|
|
14160
|
+
throw new UnauthorizedError("Invalid or expired connect token");
|
|
14161
|
+
}
|
|
14162
|
+
if (payload.type !== "connect" || !payload.sub || !payload.memberId) throw new UnauthorizedError("Invalid token type — expected connect token");
|
|
14163
|
+
const jti = payload.jti;
|
|
14164
|
+
if (jti) {
|
|
14165
|
+
if (consumedConnectJtis.has(jti)) throw new UnauthorizedError("Connect token has already been used");
|
|
14166
|
+
consumedConnectJtis.set(jti, Date.now());
|
|
14167
|
+
const cutoff = Date.now() - CONNECT_JTI_TTL_MS;
|
|
14168
|
+
for (const [k, ts] of consumedConnectJtis) if (ts < cutoff) consumedConnectJtis.delete(k);
|
|
14169
|
+
}
|
|
14170
|
+
const [user] = await db.select({
|
|
14171
|
+
id: users.id,
|
|
14172
|
+
status: users.status
|
|
14173
|
+
}).from(users).where(eq(users.id, payload.sub)).limit(1);
|
|
14174
|
+
if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
|
|
14175
|
+
const [member] = await db.select().from(members).where(and(eq(members.id, payload.memberId), eq(members.status, "active"))).limit(1);
|
|
14176
|
+
if (!member) throw new UnauthorizedError("Membership not found");
|
|
14177
|
+
return signTokensForMember(jwtSecretKey, {
|
|
14178
|
+
userId: user.id,
|
|
14179
|
+
memberId: member.id,
|
|
14180
|
+
organizationId: member.organizationId,
|
|
13039
14181
|
role: member.role
|
|
14182
|
+
});
|
|
14183
|
+
}
|
|
14184
|
+
/**
|
|
14185
|
+
* Third-party / local auth identities for a user. Models "how does this user
|
|
14186
|
+
* prove they are who they say they are". A single user MAY have multiple
|
|
14187
|
+
* identities (e.g. GitHub login + future email/password) but each
|
|
14188
|
+
* (provider, identifier) tuple maps to exactly one user.
|
|
14189
|
+
*
|
|
14190
|
+
* v1 supported shapes:
|
|
14191
|
+
* - GitHub OAuth: provider='github', identifier=<github numeric id>,
|
|
14192
|
+
* email=<primary>, credential_type=null
|
|
14193
|
+
* - Future Email + password: provider='email', identifier=<email>,
|
|
14194
|
+
* credential_type='password', credential_payload={ hash }
|
|
14195
|
+
* - Future Email + magic link: provider='email', identifier=<email>,
|
|
14196
|
+
* credential_type=null
|
|
14197
|
+
* - Future Webauthn / passkey: credential_type='webauthn',
|
|
14198
|
+
* credential_payload={ pubkey, counter }
|
|
14199
|
+
*
|
|
14200
|
+
* v1 explicitly does NOT support multi-factor on the same identity — the
|
|
14201
|
+
* (provider, identifier) UNIQUE constraint precludes two credential rows for
|
|
14202
|
+
* the same identifier. v2 splits credential_type / credential_payload into
|
|
14203
|
+
* a separate auth_credentials table; the migration is recorded in the
|
|
14204
|
+
* proposal so the upgrade path is unambiguous.
|
|
14205
|
+
*
|
|
14206
|
+
* The legacy `users.password_hash` column is preserved for backwards-compat
|
|
14207
|
+
* with self-host installs created before this milestone; new SaaS users get
|
|
14208
|
+
* a non-functional placeholder there and a real `auth_identities` row.
|
|
14209
|
+
*/
|
|
14210
|
+
const authIdentities = pgTable("auth_identities", {
|
|
14211
|
+
id: text("id").primaryKey(),
|
|
14212
|
+
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
14213
|
+
provider: text("provider").notNull(),
|
|
14214
|
+
identifier: text("identifier").notNull(),
|
|
14215
|
+
email: text("email"),
|
|
14216
|
+
verifiedAt: timestamp("verified_at", { withTimezone: true }),
|
|
14217
|
+
credentialType: text("credential_type"),
|
|
14218
|
+
credentialPayload: jsonb("credential_payload").$type(),
|
|
14219
|
+
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
14220
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
14221
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
14222
|
+
}, (table) => [
|
|
14223
|
+
unique("uq_auth_identities_provider_identifier").on(table.provider, table.identifier),
|
|
14224
|
+
index("idx_auth_identities_user").on(table.userId),
|
|
14225
|
+
index("idx_auth_identities_email").on(table.email),
|
|
14226
|
+
uniqueIndex("uq_auth_identities_user_github").on(table.userId).where(sql`provider = 'github'`)
|
|
14227
|
+
]);
|
|
14228
|
+
/**
|
|
14229
|
+
* Find or create the user backing a GitHub OAuth identity. Idempotent —
|
|
14230
|
+
* subsequent logins by the same `githubId` reuse the prior `user_id` row.
|
|
14231
|
+
*
|
|
14232
|
+
* SaaS users have no password. The legacy `users.password_hash` column is
|
|
14233
|
+
* NOT NULL (preserved for self-host), so we fill it with a non-functional
|
|
14234
|
+
* 32-byte random string. The bcrypt comparison in `authService.login`
|
|
14235
|
+
* treats it as a plain string and rejects every password — that's the
|
|
14236
|
+
* intended behaviour: SaaS users cannot fall back to password login.
|
|
14237
|
+
*/
|
|
14238
|
+
async function findOrCreateUserFromGithub(db, profile) {
|
|
14239
|
+
const [existing] = await db.select({ userId: authIdentities.userId }).from(authIdentities).where(and(eq(authIdentities.provider, "github"), eq(authIdentities.identifier, profile.githubId))).limit(1);
|
|
14240
|
+
if (existing) {
|
|
14241
|
+
if (profile.email) await db.update(authIdentities).set({
|
|
14242
|
+
email: profile.email,
|
|
14243
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
14244
|
+
}).where(and(eq(authIdentities.provider, "github"), eq(authIdentities.identifier, profile.githubId)));
|
|
14245
|
+
return { userId: existing.userId };
|
|
14246
|
+
}
|
|
14247
|
+
const userId = uuidv7();
|
|
14248
|
+
const baseUsername = profile.login.toLowerCase();
|
|
14249
|
+
const placeholderHash = `oauth:${randomBytes(32).toString("base64url")}`;
|
|
14250
|
+
await insertWithUsernameRetry(db, baseUsername, async (tx, username) => {
|
|
14251
|
+
await tx.insert(users).values({
|
|
14252
|
+
id: userId,
|
|
14253
|
+
username,
|
|
14254
|
+
passwordHash: placeholderHash,
|
|
14255
|
+
displayName: profile.displayName?.trim() || profile.login,
|
|
14256
|
+
avatarUrl: profile.avatarUrl ?? null
|
|
14257
|
+
});
|
|
14258
|
+
await tx.insert(authIdentities).values({
|
|
14259
|
+
id: uuidv7(),
|
|
14260
|
+
userId,
|
|
14261
|
+
provider: "github",
|
|
14262
|
+
identifier: profile.githubId,
|
|
14263
|
+
email: profile.email,
|
|
14264
|
+
verifiedAt: /* @__PURE__ */ new Date(),
|
|
14265
|
+
metadata: { login: profile.login }
|
|
14266
|
+
});
|
|
14267
|
+
});
|
|
14268
|
+
return { userId };
|
|
14269
|
+
}
|
|
14270
|
+
/** Postgres `unique_violation` SQLSTATE — emitted when a UNIQUE constraint trips. */
|
|
14271
|
+
const PG_UNIQUE_VIOLATION$1 = "23505";
|
|
14272
|
+
/**
|
|
14273
|
+
* Pick a candidate username, attempt the caller's INSERT in a transaction,
|
|
14274
|
+
* and retry under a fresh disambiguator if the UNIQUE(users.username)
|
|
14275
|
+
* constraint trips. Two concurrent OAuth sign-ins for the same GitHub
|
|
14276
|
+
* `login` would otherwise let one INSERT win and the other 500 — the
|
|
14277
|
+
* race window between the pre-check `SELECT` and the `INSERT` is small but
|
|
14278
|
+
* non-zero in production. Retry budget is small; pathological storms fall
|
|
14279
|
+
* back to a fully-random suffix.
|
|
14280
|
+
*/
|
|
14281
|
+
async function insertWithUsernameRetry(db, base, insert) {
|
|
14282
|
+
const [hit] = await db.select({ id: users.id }).from(users).where(eq(users.username, base)).limit(1);
|
|
14283
|
+
let candidate = hit ? `${base}-${randomBytes(2).toString("hex")}` : base;
|
|
14284
|
+
for (let attempt = 0; attempt < 4; attempt += 1) try {
|
|
14285
|
+
await db.transaction(async (tx) => {
|
|
14286
|
+
await insert(tx, candidate);
|
|
14287
|
+
});
|
|
14288
|
+
return;
|
|
14289
|
+
} catch (err) {
|
|
14290
|
+
if ((err?.code ?? err?.cause?.code) !== PG_UNIQUE_VIOLATION$1) throw err;
|
|
14291
|
+
candidate = `${base}-${randomBytes(2).toString("hex")}`;
|
|
14292
|
+
}
|
|
14293
|
+
candidate = `${base}-${uuidv7().slice(0, 12)}`;
|
|
14294
|
+
await db.transaction(async (tx) => {
|
|
14295
|
+
await insert(tx, candidate);
|
|
14296
|
+
});
|
|
14297
|
+
}
|
|
14298
|
+
const TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
14299
|
+
const USER_URL = "https://api.github.com/user";
|
|
14300
|
+
const USER_EMAILS_URL = "https://api.github.com/user/emails";
|
|
14301
|
+
/**
|
|
14302
|
+
* Exchange an OAuth code for an access token + fetch the user profile.
|
|
14303
|
+
*
|
|
14304
|
+
* The default `fetch` is overridable via `opts.fetcher` so tests can mock
|
|
14305
|
+
* the GitHub round-trip without standing up a fake server. The contract
|
|
14306
|
+
* the test fake must honor:
|
|
14307
|
+
* - First call: POST `${TOKEN_URL}` → returns `{ access_token: string }`
|
|
14308
|
+
* - Then GET `${USER_URL}` with `Authorization: Bearer …`
|
|
14309
|
+
* - Then GET `${USER_EMAILS_URL}` (only if `/user` returned no email)
|
|
14310
|
+
*/
|
|
14311
|
+
async function exchangeCodeForProfile(config, code, redirectUri, opts = {}) {
|
|
14312
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
14313
|
+
const tokenRes = await fetcher(TOKEN_URL, {
|
|
14314
|
+
method: "POST",
|
|
14315
|
+
headers: {
|
|
14316
|
+
Accept: "application/json",
|
|
14317
|
+
"Content-Type": "application/json"
|
|
14318
|
+
},
|
|
14319
|
+
body: JSON.stringify({
|
|
14320
|
+
client_id: config.clientId,
|
|
14321
|
+
client_secret: config.clientSecret,
|
|
14322
|
+
code,
|
|
14323
|
+
redirect_uri: redirectUri
|
|
14324
|
+
})
|
|
14325
|
+
});
|
|
14326
|
+
if (!tokenRes.ok) throw new Error(`GitHub token exchange failed (${tokenRes.status})`);
|
|
14327
|
+
const tokenJson = await tokenRes.json();
|
|
14328
|
+
if (!tokenJson.access_token) throw new Error(tokenJson.error ?? "GitHub token exchange returned no access_token");
|
|
14329
|
+
const userRes = await fetcher(USER_URL, { headers: {
|
|
14330
|
+
Authorization: `Bearer ${tokenJson.access_token}`,
|
|
14331
|
+
Accept: "application/vnd.github+json"
|
|
14332
|
+
} });
|
|
14333
|
+
if (!userRes.ok) throw new Error(`GitHub user fetch failed (${userRes.status})`);
|
|
14334
|
+
const user = await userRes.json();
|
|
14335
|
+
let email = user.email ?? null;
|
|
14336
|
+
if (!email) {
|
|
14337
|
+
const emailsRes = await fetcher(USER_EMAILS_URL, { headers: {
|
|
14338
|
+
Authorization: `Bearer ${tokenJson.access_token}`,
|
|
14339
|
+
Accept: "application/vnd.github+json"
|
|
14340
|
+
} });
|
|
14341
|
+
if (emailsRes.ok) {
|
|
14342
|
+
const emails = await emailsRes.json();
|
|
14343
|
+
email = (emails.find((e) => e.primary && e.verified) ?? emails.find((e) => e.verified))?.email ?? null;
|
|
14344
|
+
}
|
|
14345
|
+
}
|
|
14346
|
+
return {
|
|
14347
|
+
githubId: String(user.id),
|
|
14348
|
+
login: user.login,
|
|
14349
|
+
email,
|
|
14350
|
+
displayName: user.name ?? null,
|
|
14351
|
+
avatarUrl: user.avatar_url ?? null
|
|
13040
14352
|
};
|
|
13041
|
-
|
|
13042
|
-
|
|
13043
|
-
|
|
13044
|
-
|
|
13045
|
-
|
|
13046
|
-
|
|
13047
|
-
|
|
13048
|
-
|
|
13049
|
-
|
|
14353
|
+
}
|
|
14354
|
+
/** Insert (or reactivate) a `members` row for `userId` in `organizationId`. */
|
|
14355
|
+
async function ensureMembership(db, data) {
|
|
14356
|
+
const [existing] = await db.select().from(members).where(and(eq(members.userId, data.userId), eq(members.organizationId, data.organizationId))).limit(1);
|
|
14357
|
+
if (existing) {
|
|
14358
|
+
if (existing.status === "left") {
|
|
14359
|
+
await db.update(members).set({ status: "active" }).where(eq(members.id, existing.id));
|
|
14360
|
+
return {
|
|
14361
|
+
...existing,
|
|
14362
|
+
status: "active"
|
|
14363
|
+
};
|
|
14364
|
+
}
|
|
14365
|
+
return existing;
|
|
14366
|
+
}
|
|
14367
|
+
return db.transaction(async (tx) => {
|
|
14368
|
+
const memberId = uuidv7();
|
|
14369
|
+
const agentName = sanitizeAgentName(data.username);
|
|
14370
|
+
const inboxId = `inbox_${uuidv7()}`;
|
|
14371
|
+
const agentUuid = uuidv7();
|
|
14372
|
+
await tx.insert(agents).values({
|
|
14373
|
+
uuid: agentUuid,
|
|
14374
|
+
name: agentName,
|
|
14375
|
+
organizationId: data.organizationId,
|
|
14376
|
+
type: "human",
|
|
14377
|
+
displayName: data.displayName,
|
|
14378
|
+
inboxId,
|
|
14379
|
+
source: "oauth",
|
|
14380
|
+
visibility: "organization",
|
|
14381
|
+
managerId: memberId
|
|
14382
|
+
});
|
|
14383
|
+
const [row] = await tx.insert(members).values({
|
|
14384
|
+
id: memberId,
|
|
14385
|
+
userId: data.userId,
|
|
14386
|
+
organizationId: data.organizationId,
|
|
14387
|
+
agentId: agentUuid,
|
|
14388
|
+
role: data.role,
|
|
14389
|
+
status: "active"
|
|
14390
|
+
}).returning();
|
|
14391
|
+
if (!row) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
14392
|
+
return row;
|
|
14393
|
+
});
|
|
14394
|
+
}
|
|
14395
|
+
function sanitizeAgentName(login) {
|
|
14396
|
+
return login.toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "user";
|
|
14397
|
+
}
|
|
14398
|
+
/**
|
|
14399
|
+
* Create a fresh "personal team" org for a brand-new user, plus the
|
|
14400
|
+
* matching admin membership + 1:1 human agent. Slug strategy:
|
|
14401
|
+
*
|
|
14402
|
+
* - First try: `${login}-personal`
|
|
14403
|
+
* - On collision: append a 4-char hex disambiguator
|
|
14404
|
+
*
|
|
14405
|
+
* The display name is `{user}'s Personal Team` so it reads sensibly in the
|
|
14406
|
+
* UI; the user can rename via Settings later (proposal §"Personal team
|
|
14407
|
+
* visual降级").
|
|
14408
|
+
*/
|
|
14409
|
+
async function createPersonalTeam(db, input) {
|
|
14410
|
+
const baseSlug = sanitizeOrgSlug(`${input.loginSeed}-personal`);
|
|
14411
|
+
const displayName = `${input.userDisplayName}'s Personal Team`;
|
|
14412
|
+
const orgId = uuidv7();
|
|
14413
|
+
return {
|
|
14414
|
+
organizationId: orgId,
|
|
14415
|
+
slug: await insertOrgWithSlugRetry(db, orgId, baseSlug, displayName),
|
|
14416
|
+
displayName,
|
|
14417
|
+
memberId: (await ensureMembership(db, {
|
|
14418
|
+
userId: input.userId,
|
|
14419
|
+
organizationId: orgId,
|
|
14420
|
+
role: "admin",
|
|
14421
|
+
displayName: input.userDisplayName,
|
|
14422
|
+
username: input.loginSeed
|
|
14423
|
+
})).id
|
|
14424
|
+
};
|
|
14425
|
+
}
|
|
14426
|
+
function sanitizeOrgSlug(raw) {
|
|
14427
|
+
return raw.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "team";
|
|
14428
|
+
}
|
|
14429
|
+
/** Postgres `unique_violation` SQLSTATE — `organizations.name` UNIQUE tripping. */
|
|
14430
|
+
const PG_UNIQUE_VIOLATION = "23505";
|
|
14431
|
+
/**
|
|
14432
|
+
* Attempt INSERT into `organizations` with `base` slug, retrying with a
|
|
14433
|
+
* disambiguator on UNIQUE constraint violations. Two concurrent OAuth
|
|
14434
|
+
* sign-ins for the same GitHub `login` would race here without retry —
|
|
14435
|
+
* pre-check `SELECT` followed by `INSERT` has a TOCTOU window the unique
|
|
14436
|
+
* constraint catches but the catch path needs to exist. Returns the slug
|
|
14437
|
+
* actually used.
|
|
14438
|
+
*/
|
|
14439
|
+
async function insertOrgWithSlugRetry(db, orgId, base, displayName) {
|
|
14440
|
+
const [existing] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.name, base)).limit(1);
|
|
14441
|
+
let candidate = existing ? `${base}-${randomBytes(2).toString("hex")}` : base;
|
|
14442
|
+
for (let attempt = 0; attempt < 4; attempt += 1) try {
|
|
14443
|
+
await db.insert(organizations).values({
|
|
14444
|
+
id: orgId,
|
|
14445
|
+
name: candidate,
|
|
14446
|
+
displayName
|
|
14447
|
+
});
|
|
14448
|
+
return candidate;
|
|
14449
|
+
} catch (err) {
|
|
14450
|
+
if ((err?.code ?? err?.cause?.code) !== PG_UNIQUE_VIOLATION) throw err;
|
|
14451
|
+
candidate = `${base}-${randomBytes(2).toString("hex")}`;
|
|
14452
|
+
}
|
|
14453
|
+
candidate = `${base}-${uuidv7().slice(0, 12)}`;
|
|
14454
|
+
await db.insert(organizations).values({
|
|
14455
|
+
id: orgId,
|
|
14456
|
+
name: candidate,
|
|
14457
|
+
displayName
|
|
14458
|
+
});
|
|
14459
|
+
return candidate;
|
|
14460
|
+
}
|
|
14461
|
+
/** List ACTIVE memberships (omit soft-deleted "left") for a user. */
|
|
14462
|
+
async function listActiveMemberships(db, userId) {
|
|
14463
|
+
return await db.select({
|
|
14464
|
+
memberId: members.id,
|
|
14465
|
+
organizationId: members.organizationId,
|
|
14466
|
+
role: members.role,
|
|
14467
|
+
orgName: organizations.name,
|
|
14468
|
+
orgDisplayName: organizations.displayName,
|
|
14469
|
+
createdAt: members.createdAt
|
|
14470
|
+
}).from(members).innerJoin(organizations, eq(members.organizationId, organizations.id)).where(and(eq(members.userId, userId), eq(members.status, "active"))).orderBy(desc(members.createdAt));
|
|
14471
|
+
}
|
|
14472
|
+
/**
|
|
14473
|
+
* Pick the most recently joined active membership — used after OAuth login
|
|
14474
|
+
* when the user already has at least one team but no `next` was specified.
|
|
14475
|
+
*/
|
|
14476
|
+
async function pickPrimaryMembership(db, userId) {
|
|
14477
|
+
return (await listActiveMemberships(db, userId))[0] ?? null;
|
|
14478
|
+
}
|
|
14479
|
+
/**
|
|
14480
|
+
* Mark `members.status='left'` for the given member. v1 simplification:
|
|
14481
|
+
* no "must transfer admin" check — the proposal accepts the trade-off
|
|
14482
|
+
* (last admin allowed to leave, leaves an orphan team) and the cleanup is
|
|
14483
|
+
* a v2 sweep job.
|
|
14484
|
+
*/
|
|
14485
|
+
async function leaveOrganization(db, memberId) {
|
|
14486
|
+
const [existing] = await db.select().from(members).where(eq(members.id, memberId)).limit(1);
|
|
14487
|
+
if (!existing) throw new NotFoundError(`Membership "${memberId}" not found`);
|
|
14488
|
+
if (existing.status === "left") return existing;
|
|
14489
|
+
await db.update(members).set({ status: "left" }).where(eq(members.id, memberId));
|
|
14490
|
+
return {
|
|
14491
|
+
...existing,
|
|
14492
|
+
status: "left"
|
|
14493
|
+
};
|
|
14494
|
+
}
|
|
14495
|
+
/**
|
|
14496
|
+
* Self-service "create another team" (operator clicks "Create team" in the
|
|
14497
|
+
* org switcher). Caller is the new team's admin. Slug uniqueness is
|
|
14498
|
+
* enforced by the underlying organizations.name UNIQUE constraint.
|
|
14499
|
+
*/
|
|
14500
|
+
async function selfCreateOrganization(db, data) {
|
|
14501
|
+
const [collision] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.name, data.name)).limit(1);
|
|
14502
|
+
if (collision) throw new ConflictError(`Organization "${data.name}" already exists`);
|
|
14503
|
+
if (data.name === "default") throw new BadRequestError("\"default\" is a reserved organization name");
|
|
14504
|
+
const orgId = uuidv7();
|
|
14505
|
+
await db.insert(organizations).values({
|
|
14506
|
+
id: orgId,
|
|
14507
|
+
name: data.name,
|
|
14508
|
+
displayName: data.displayName
|
|
14509
|
+
});
|
|
13050
14510
|
return {
|
|
13051
|
-
|
|
13052
|
-
|
|
14511
|
+
organizationId: orgId,
|
|
14512
|
+
memberId: (await ensureMembership(db, {
|
|
14513
|
+
userId: data.userId,
|
|
14514
|
+
organizationId: orgId,
|
|
14515
|
+
role: "admin",
|
|
14516
|
+
displayName: data.userDisplayName,
|
|
14517
|
+
username: data.username
|
|
14518
|
+
})).id,
|
|
14519
|
+
name: data.name,
|
|
14520
|
+
displayName: data.displayName
|
|
13053
14521
|
};
|
|
13054
14522
|
}
|
|
13055
|
-
async function refreshAccessToken(db, refreshToken, jwtSecretKey) {
|
|
13056
|
-
const secret = new TextEncoder().encode(jwtSecretKey);
|
|
13057
|
-
let payload;
|
|
13058
|
-
try {
|
|
13059
|
-
const { payload: p } = await jwtVerify(refreshToken, secret);
|
|
13060
|
-
payload = p;
|
|
13061
|
-
} catch {
|
|
13062
|
-
throw new UnauthorizedError("Invalid or expired refresh token");
|
|
13063
|
-
}
|
|
13064
|
-
if (payload.type !== "refresh" || !payload.sub) throw new UnauthorizedError("Invalid token type");
|
|
13065
|
-
const [user] = await db.select({
|
|
13066
|
-
id: users.id,
|
|
13067
|
-
status: users.status
|
|
13068
|
-
}).from(users).where(eq(users.id, payload.sub)).limit(1);
|
|
13069
|
-
if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
|
|
13070
|
-
const [member] = await db.select().from(members).where(eq(members.id, payload.memberId)).limit(1);
|
|
13071
|
-
if (!member) throw new UnauthorizedError("Membership not found");
|
|
13072
|
-
return { accessToken: await signToken(secret, {
|
|
13073
|
-
sub: user.id,
|
|
13074
|
-
memberId: member.id,
|
|
13075
|
-
organizationId: member.organizationId,
|
|
13076
|
-
role: member.role,
|
|
13077
|
-
type: "access"
|
|
13078
|
-
}, ACCESS_TOKEN_EXPIRY) };
|
|
13079
|
-
}
|
|
13080
14523
|
/**
|
|
13081
|
-
*
|
|
13082
|
-
*
|
|
13083
|
-
*
|
|
14524
|
+
* State-token signing for the GitHub OAuth dance.
|
|
14525
|
+
*
|
|
14526
|
+
* Flow:
|
|
14527
|
+
* 1. `/auth/github/start` mints a `state` JWT *and* an HttpOnly cookie
|
|
14528
|
+
* holding the same nonce. Both ride for ~10 minutes.
|
|
14529
|
+
* 2. GitHub redirects back to `/auth/github/callback?code=…&state=<jwt>`.
|
|
14530
|
+
* 3. Callback verifies the JWT (signature + expiry) AND that the cookie
|
|
14531
|
+
* nonce matches `payload.nonce`. The double check defeats the
|
|
14532
|
+
* classic login-CSRF where an attacker pre-signs a `start` with their
|
|
14533
|
+
* own GitHub account and tricks a victim's browser into completing
|
|
14534
|
+
* the callback under that identity.
|
|
14535
|
+
*
|
|
14536
|
+
* `next` rides inside the JWT so the caller's intended landing path can't
|
|
14537
|
+
* be tampered with mid-flight.
|
|
13084
14538
|
*/
|
|
13085
|
-
|
|
13086
|
-
|
|
13087
|
-
|
|
14539
|
+
const STATE_EXPIRY = "10m";
|
|
14540
|
+
const NONCE_BYTES = 24;
|
|
14541
|
+
const OAUTH_STATE_COOKIE = "oauth_state_nonce";
|
|
14542
|
+
/**
|
|
14543
|
+
* Sign a fresh state token + return the matching cookie nonce. Caller is
|
|
14544
|
+
* responsible for setting the cookie (HttpOnly + Secure in prod).
|
|
14545
|
+
*/
|
|
14546
|
+
async function signOAuthState(jwtSecret, next) {
|
|
14547
|
+
const nonce = randomBytes(NONCE_BYTES).toString("base64url");
|
|
14548
|
+
const secret = new TextEncoder().encode(jwtSecret);
|
|
13088
14549
|
return {
|
|
13089
14550
|
token: await new SignJWT({
|
|
13090
|
-
|
|
13091
|
-
|
|
13092
|
-
|
|
13093
|
-
|
|
13094
|
-
type: "connect"
|
|
13095
|
-
}).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setJti(jti).setExpirationTime(CONNECT_TOKEN_EXPIRY).sign(secret),
|
|
13096
|
-
expiresIn: 600
|
|
14551
|
+
nonce,
|
|
14552
|
+
next
|
|
14553
|
+
}).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(STATE_EXPIRY).sign(secret),
|
|
14554
|
+
nonce
|
|
13097
14555
|
};
|
|
13098
14556
|
}
|
|
13099
14557
|
/**
|
|
13100
|
-
*
|
|
13101
|
-
*
|
|
13102
|
-
*
|
|
14558
|
+
* Verify a state token. Returns the carried `next` on success. Throws
|
|
14559
|
+
* `Error` with the verification failure mode on rejection so the route
|
|
14560
|
+
* layer can map to 401.
|
|
14561
|
+
*
|
|
14562
|
+
* Cookie/nonce double-submit is mandatory — this is the CSRF defense.
|
|
14563
|
+
* `/dev-callback` does NOT call this function; it bypasses state entirely
|
|
14564
|
+
* (see `api/auth/github.ts`) because the dev shortcut also bypasses the
|
|
14565
|
+
* github.com round-trip that would have set a state cookie.
|
|
13103
14566
|
*/
|
|
13104
|
-
async function
|
|
13105
|
-
const secret = new TextEncoder().encode(
|
|
14567
|
+
async function verifyOAuthState(jwtSecret, token, cookieNonce) {
|
|
14568
|
+
const secret = new TextEncoder().encode(jwtSecret);
|
|
13106
14569
|
let payload;
|
|
13107
14570
|
try {
|
|
13108
|
-
const { payload: p } = await jwtVerify(
|
|
14571
|
+
const { payload: p } = await jwtVerify(token, secret);
|
|
13109
14572
|
payload = p;
|
|
13110
14573
|
} catch {
|
|
13111
|
-
throw new
|
|
14574
|
+
throw new Error("Invalid or expired OAuth state");
|
|
13112
14575
|
}
|
|
13113
|
-
if (payload.
|
|
13114
|
-
|
|
13115
|
-
|
|
13116
|
-
|
|
13117
|
-
|
|
13118
|
-
|
|
13119
|
-
|
|
14576
|
+
if (typeof payload.nonce !== "string" || typeof payload.next !== "string") throw new Error("OAuth state payload malformed");
|
|
14577
|
+
if (!cookieNonce || cookieNonce !== payload.nonce) throw new Error("OAuth state nonce / cookie mismatch");
|
|
14578
|
+
return { next: payload.next };
|
|
14579
|
+
}
|
|
14580
|
+
/**
|
|
14581
|
+
* Resolve the hub's public-facing base URL.
|
|
14582
|
+
*
|
|
14583
|
+
* Precedence:
|
|
14584
|
+
* 1. `app.config.server.publicUrl` — explicit configuration. Required in
|
|
14585
|
+
* production (the boot check enforces it).
|
|
14586
|
+
* 2. The request's `Host` header (with `X-Forwarded-Proto` honored) —
|
|
14587
|
+
* dev fallback so local quickstart works without extra config.
|
|
14588
|
+
*
|
|
14589
|
+
* Result is normalized to drop trailing slashes so callers can append
|
|
14590
|
+
* paths with a single leading `/`.
|
|
14591
|
+
*/
|
|
14592
|
+
function resolvePublicUrl(app, request) {
|
|
14593
|
+
const configured = app.config.server.publicUrl;
|
|
14594
|
+
if (configured && configured.length > 0) return configured.replace(/\/+$/, "");
|
|
14595
|
+
return `${pickHeader(request.headers["x-forwarded-proto"]) ?? request.protocol}://${pickHeader(request.headers["x-forwarded-host"]) ?? pickHeader(request.headers.host) ?? request.hostname}`.replace(/\/+$/, "");
|
|
14596
|
+
}
|
|
14597
|
+
function pickHeader(value) {
|
|
14598
|
+
if (Array.isArray(value)) return value[0];
|
|
14599
|
+
return value;
|
|
14600
|
+
}
|
|
14601
|
+
/**
|
|
14602
|
+
* Manual cookie helpers — we don't pull in `@fastify/cookie` because the
|
|
14603
|
+
* SaaS onboarding flow needs exactly one cookie (the OAuth state nonce).
|
|
14604
|
+
* Parser tolerates the standard `name=value; name2=value2` format.
|
|
14605
|
+
*/
|
|
14606
|
+
function parseCookieHeader(header, name) {
|
|
14607
|
+
if (!header) return null;
|
|
14608
|
+
const raw = Array.isArray(header) ? header.join("; ") : header;
|
|
14609
|
+
for (const entry of raw.split(/;\s*/)) {
|
|
14610
|
+
const eq = entry.indexOf("=");
|
|
14611
|
+
if (eq < 0) continue;
|
|
14612
|
+
if (entry.slice(0, eq).trim() === name) return decodeURIComponent(entry.slice(eq + 1));
|
|
13120
14613
|
}
|
|
13121
|
-
|
|
13122
|
-
|
|
13123
|
-
|
|
13124
|
-
|
|
13125
|
-
|
|
13126
|
-
|
|
13127
|
-
|
|
13128
|
-
|
|
13129
|
-
|
|
13130
|
-
|
|
13131
|
-
|
|
13132
|
-
|
|
13133
|
-
|
|
13134
|
-
return
|
|
13135
|
-
|
|
13136
|
-
|
|
13137
|
-
|
|
13138
|
-
|
|
13139
|
-
|
|
13140
|
-
|
|
13141
|
-
|
|
13142
|
-
|
|
13143
|
-
|
|
14614
|
+
return null;
|
|
14615
|
+
}
|
|
14616
|
+
function buildCookie(opts) {
|
|
14617
|
+
const sameSite = opts.sameSite ?? "Lax";
|
|
14618
|
+
const parts = [
|
|
14619
|
+
`${opts.name}=${encodeURIComponent(opts.value)}`,
|
|
14620
|
+
"Path=/",
|
|
14621
|
+
"HttpOnly",
|
|
14622
|
+
`SameSite=${sameSite}`,
|
|
14623
|
+
`Max-Age=${opts.maxAge}`
|
|
14624
|
+
];
|
|
14625
|
+
if (opts.secure) parts.push("Secure");
|
|
14626
|
+
if (opts.maxAge <= 0) parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT");
|
|
14627
|
+
return parts.join("; ");
|
|
14628
|
+
}
|
|
14629
|
+
/**
|
|
14630
|
+
* GitHub OAuth surface. All routes are public (no member JWT required).
|
|
14631
|
+
*
|
|
14632
|
+
* Routes:
|
|
14633
|
+
* - GET /auth/github/start — sign state JWT + cookie + 302 to GitHub
|
|
14634
|
+
* - GET /auth/github/callback — verify state + exchange code → fragment
|
|
14635
|
+
* - GET /auth/github/dev-callback — dev-only stub (no GitHub round-trip)
|
|
14636
|
+
*/
|
|
14637
|
+
async function githubOauthRoutes(app) {
|
|
14638
|
+
const oauthCfg = app.config.oauth?.github;
|
|
14639
|
+
if (!oauthCfg) app.log.info("GitHub OAuth not configured — /auth/github/start will return 503. Set FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_ID/_SECRET to enable.");
|
|
14640
|
+
app.get("/start", { config: { rateLimit: {
|
|
14641
|
+
max: 20,
|
|
14642
|
+
timeWindow: "1 minute"
|
|
14643
|
+
} } }, async (request, reply) => {
|
|
14644
|
+
const { next } = githubStartQuerySchema.parse(request.query);
|
|
14645
|
+
const safeNext = safeRedirectPath(next ?? null);
|
|
14646
|
+
if (!oauthCfg) return reply.status(503).send({ error: "GitHub OAuth is not configured on this hub" });
|
|
14647
|
+
const { token, nonce } = await signOAuthState(app.config.secrets.jwtSecret, safeNext);
|
|
14648
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
14649
|
+
reply.header("Set-Cookie", buildCookie({
|
|
14650
|
+
name: OAUTH_STATE_COOKIE,
|
|
14651
|
+
value: nonce,
|
|
14652
|
+
maxAge: 600,
|
|
14653
|
+
secure: isProd
|
|
14654
|
+
}));
|
|
14655
|
+
const redirectUri = `${resolvePublicUrl(app, request)}/api/v1/auth/github/callback`;
|
|
14656
|
+
const params = new URLSearchParams({
|
|
14657
|
+
client_id: oauthCfg.clientId,
|
|
14658
|
+
redirect_uri: redirectUri,
|
|
14659
|
+
state: token,
|
|
14660
|
+
scope: "read:user user:email",
|
|
14661
|
+
allow_signup: "true"
|
|
14662
|
+
});
|
|
14663
|
+
return reply.redirect(`https://github.com/login/oauth/authorize?${params}`, 302);
|
|
14664
|
+
});
|
|
14665
|
+
app.get("/callback", async (request, reply) => {
|
|
14666
|
+
if (!oauthCfg) return reply.status(503).send({ error: "GitHub OAuth is not configured on this hub" });
|
|
14667
|
+
const { code, state } = githubCallbackQuerySchema.parse(request.query);
|
|
14668
|
+
const cookieNonce = parseCookieHeader(request.headers.cookie, OAUTH_STATE_COOKIE);
|
|
14669
|
+
let next;
|
|
14670
|
+
try {
|
|
14671
|
+
next = (await verifyOAuthState(app.config.secrets.jwtSecret, state, cookieNonce)).next;
|
|
14672
|
+
} catch (err) {
|
|
14673
|
+
const msg = err instanceof Error ? err.message : "OAuth state rejected";
|
|
14674
|
+
return reply.status(401).send({ error: msg });
|
|
14675
|
+
}
|
|
14676
|
+
reply.header("Set-Cookie", buildCookie({
|
|
14677
|
+
name: OAUTH_STATE_COOKIE,
|
|
14678
|
+
value: "",
|
|
14679
|
+
maxAge: 0,
|
|
14680
|
+
secure: process.env.NODE_ENV === "production"
|
|
14681
|
+
}));
|
|
14682
|
+
const redirectUri = `${resolvePublicUrl(app, request)}/api/v1/auth/github/callback`;
|
|
14683
|
+
let profile;
|
|
14684
|
+
try {
|
|
14685
|
+
profile = await exchangeCodeForProfile({
|
|
14686
|
+
clientId: oauthCfg.clientId,
|
|
14687
|
+
clientSecret: oauthCfg.clientSecret
|
|
14688
|
+
}, code, redirectUri);
|
|
14689
|
+
} catch (err) {
|
|
14690
|
+
const msg = err instanceof Error ? err.message : "GitHub exchange failed";
|
|
14691
|
+
app.log.warn({ err }, "github oauth code exchange failed");
|
|
14692
|
+
return reply.status(401).send({ error: msg });
|
|
14693
|
+
}
|
|
14694
|
+
return completeOauthFlow(app, request, reply, profile, next);
|
|
14695
|
+
});
|
|
14696
|
+
app.get("/dev-callback", async (request, reply) => {
|
|
14697
|
+
if (process.env.NODE_ENV === "production") return reply.status(404).send({ error: "Not found" });
|
|
14698
|
+
const params = githubDevCallbackQuerySchema.parse(request.query);
|
|
14699
|
+
const next = safeRedirectPath(params.next ?? null);
|
|
14700
|
+
return completeOauthFlow(app, request, reply, {
|
|
14701
|
+
githubId: params.githubId,
|
|
14702
|
+
login: params.login,
|
|
14703
|
+
email: params.email ?? null,
|
|
14704
|
+
displayName: params.displayName ?? params.login,
|
|
14705
|
+
avatarUrl: null
|
|
14706
|
+
}, next);
|
|
14707
|
+
});
|
|
14708
|
+
}
|
|
14709
|
+
async function completeOauthFlow(app, request, reply, profile, next) {
|
|
14710
|
+
const { userId } = await findOrCreateUserFromGithub(app.db, profile);
|
|
14711
|
+
let joinPath = "returning";
|
|
14712
|
+
const inviteMatch = /^\/invite\/([^/?#]+)/.exec(next);
|
|
14713
|
+
let memberInfo = null;
|
|
14714
|
+
if (inviteMatch?.[1]) {
|
|
14715
|
+
const token = inviteMatch[1];
|
|
14716
|
+
const inv = await findActiveByToken(app.db, token);
|
|
14717
|
+
if (!inv) return reply.status(404).send({ error: "Invitation not found or no longer valid" });
|
|
14718
|
+
const member = await ensureMembership(app.db, {
|
|
14719
|
+
userId,
|
|
14720
|
+
organizationId: inv.organizationId,
|
|
14721
|
+
role: inv.role === "admin" ? "admin" : "member",
|
|
14722
|
+
displayName: profile.displayName?.trim() || profile.login,
|
|
14723
|
+
username: profile.login
|
|
14724
|
+
});
|
|
14725
|
+
await recordRedemption(app.db, {
|
|
14726
|
+
invitationId: inv.id,
|
|
14727
|
+
userId,
|
|
14728
|
+
ip: request.ip,
|
|
14729
|
+
userAgent: request.headers["user-agent"] ?? null
|
|
14730
|
+
});
|
|
14731
|
+
memberInfo = {
|
|
14732
|
+
memberId: member.id,
|
|
14733
|
+
organizationId: member.organizationId,
|
|
14734
|
+
role: member.role === "admin" ? "admin" : "member"
|
|
14735
|
+
};
|
|
14736
|
+
joinPath = "invite";
|
|
14737
|
+
next = "/";
|
|
14738
|
+
} else {
|
|
14739
|
+
const primary = await pickPrimaryMembership(app.db, userId);
|
|
14740
|
+
if (primary) memberInfo = {
|
|
14741
|
+
memberId: primary.memberId,
|
|
14742
|
+
organizationId: primary.organizationId,
|
|
14743
|
+
role: primary.role === "admin" ? "admin" : "member"
|
|
14744
|
+
};
|
|
14745
|
+
else {
|
|
14746
|
+
const personal = await createPersonalTeam(app.db, {
|
|
14747
|
+
userId,
|
|
14748
|
+
loginSeed: profile.login,
|
|
14749
|
+
userDisplayName: profile.displayName?.trim() || profile.login
|
|
14750
|
+
});
|
|
14751
|
+
memberInfo = {
|
|
14752
|
+
memberId: personal.memberId,
|
|
14753
|
+
organizationId: personal.organizationId,
|
|
14754
|
+
role: "admin"
|
|
14755
|
+
};
|
|
14756
|
+
joinPath = "solo";
|
|
14757
|
+
next = "/";
|
|
14758
|
+
}
|
|
14759
|
+
}
|
|
14760
|
+
if (!memberInfo) return reply.status(500).send({ error: "Failed to resolve membership" });
|
|
14761
|
+
const tokens = await signTokensForMember(app.config.secrets.jwtSecret, {
|
|
14762
|
+
userId,
|
|
14763
|
+
memberId: memberInfo.memberId,
|
|
14764
|
+
organizationId: memberInfo.organizationId,
|
|
14765
|
+
role: memberInfo.role
|
|
14766
|
+
});
|
|
14767
|
+
const fragment = new URLSearchParams({
|
|
14768
|
+
access: tokens.accessToken,
|
|
14769
|
+
refresh: tokens.refreshToken,
|
|
14770
|
+
next,
|
|
14771
|
+
joinPath
|
|
14772
|
+
}).toString();
|
|
14773
|
+
return reply.redirect(`/auth/github/complete#${fragment}`, 302);
|
|
13144
14774
|
}
|
|
13145
14775
|
async function authRoutes(app) {
|
|
13146
14776
|
const loginMax = app.config.rateLimit?.loginMax ?? 5;
|
|
@@ -13277,7 +14907,12 @@ async function healthzRoutes(app) {
|
|
|
13277
14907
|
}
|
|
13278
14908
|
});
|
|
13279
14909
|
}
|
|
13280
|
-
/**
|
|
14910
|
+
/**
|
|
14911
|
+
* `/me` and self-service organization routes (mounted under the member
|
|
14912
|
+
* auth hook). The legacy `GET /me` shape is preserved + extended with
|
|
14913
|
+
* `wizard` and `inviteUrl` (admin only) so the web SPA can derive its
|
|
14914
|
+
* landing UI without an extra round-trip.
|
|
14915
|
+
*/
|
|
13281
14916
|
async function meRoutes(app) {
|
|
13282
14917
|
app.get("/me", async (request) => {
|
|
13283
14918
|
const m = requireMember(request);
|
|
@@ -13293,6 +14928,12 @@ async function meRoutes(app) {
|
|
|
13293
14928
|
displayName: agents.displayName,
|
|
13294
14929
|
inboxId: agents.inboxId
|
|
13295
14930
|
}).from(agents).where(eq(agents.uuid, m.agentId)).limit(1);
|
|
14931
|
+
const wizardStep = await inferWizardStep(app, m);
|
|
14932
|
+
let inviteUrl = null;
|
|
14933
|
+
if (m.role === "admin") {
|
|
14934
|
+
const inv = await getActiveInvitation(app.db, m.organizationId);
|
|
14935
|
+
if (inv) inviteUrl = buildInviteUrl(resolvePublicUrl(app, request), inv.token);
|
|
14936
|
+
}
|
|
13296
14937
|
return {
|
|
13297
14938
|
user: user ?? null,
|
|
13298
14939
|
member: {
|
|
@@ -13301,25 +14942,202 @@ async function meRoutes(app) {
|
|
|
13301
14942
|
role: m.role,
|
|
13302
14943
|
agentId: m.agentId
|
|
13303
14944
|
},
|
|
13304
|
-
agent: agent ?? null
|
|
14945
|
+
agent: agent ?? null,
|
|
14946
|
+
wizard: { step: wizardStep },
|
|
14947
|
+
inviteUrl
|
|
13305
14948
|
};
|
|
13306
14949
|
});
|
|
13307
14950
|
/**
|
|
13308
14951
|
* POST /connect-tokens — generate a short-lived connect token for CLI authentication.
|
|
13309
|
-
*
|
|
14952
|
+
* Stamped with `iss = server.publicUrl` (or the request host as a dev fallback)
|
|
14953
|
+
* so the CLI's `connect <token>` form can derive the hub URL with no extra arg.
|
|
14954
|
+
*
|
|
14955
|
+
* Rate-limited per-route at the same level as `/auth/login`: a "Copy
|
|
14956
|
+
* commands" double-click in the wizard mustn't burn through token slots,
|
|
14957
|
+
* but neither should a stolen access token mint unlimited connect tokens.
|
|
13310
14958
|
*/
|
|
13311
|
-
app.
|
|
14959
|
+
const loginMax = app.config.rateLimit?.loginMax ?? 5;
|
|
14960
|
+
app.post("/connect-tokens", { config: { rateLimit: {
|
|
14961
|
+
max: loginMax,
|
|
14962
|
+
timeWindow: "1 minute"
|
|
14963
|
+
} } }, async (request) => {
|
|
13312
14964
|
const m = requireMember(request);
|
|
14965
|
+
const issuer = resolvePublicUrl(app, request);
|
|
13313
14966
|
const { token, expiresIn } = await generateConnectToken({
|
|
13314
14967
|
userId: m.userId,
|
|
13315
14968
|
memberId: m.memberId,
|
|
13316
14969
|
organizationId: m.organizationId,
|
|
13317
14970
|
role: m.role
|
|
13318
|
-
}, app.config.secrets.jwtSecret);
|
|
14971
|
+
}, app.config.secrets.jwtSecret, issuer);
|
|
13319
14972
|
return {
|
|
13320
14973
|
token,
|
|
13321
14974
|
expiresIn,
|
|
13322
|
-
command: `first-tree-hub
|
|
14975
|
+
command: `first-tree-hub connect ${token}`
|
|
14976
|
+
};
|
|
14977
|
+
});
|
|
14978
|
+
app.get("/me/organizations", async (request) => {
|
|
14979
|
+
const m = requireMember(request);
|
|
14980
|
+
return (await listActiveMemberships(app.db, m.userId)).map((r) => ({
|
|
14981
|
+
id: r.organizationId,
|
|
14982
|
+
name: r.orgName,
|
|
14983
|
+
displayName: r.orgDisplayName,
|
|
14984
|
+
role: r.role
|
|
14985
|
+
}));
|
|
14986
|
+
});
|
|
14987
|
+
app.post("/me/organizations", async (request, reply) => {
|
|
14988
|
+
const m = requireMember(request);
|
|
14989
|
+
const body = createOrgFromMeSchema.parse(request.body);
|
|
14990
|
+
const [u] = await app.db.select({
|
|
14991
|
+
username: users.username,
|
|
14992
|
+
displayName: users.displayName
|
|
14993
|
+
}).from(users).where(eq(users.id, m.userId)).limit(1);
|
|
14994
|
+
if (!u) throw new NotFoundError("User not found");
|
|
14995
|
+
const created = await selfCreateOrganization(app.db, {
|
|
14996
|
+
userId: m.userId,
|
|
14997
|
+
userDisplayName: u.displayName,
|
|
14998
|
+
username: u.username,
|
|
14999
|
+
name: body.name,
|
|
15000
|
+
displayName: body.displayName
|
|
15001
|
+
});
|
|
15002
|
+
const tokens = await signTokensForMember(app.config.secrets.jwtSecret, {
|
|
15003
|
+
userId: m.userId,
|
|
15004
|
+
memberId: created.memberId,
|
|
15005
|
+
organizationId: created.organizationId,
|
|
15006
|
+
role: "admin"
|
|
15007
|
+
});
|
|
15008
|
+
return reply.status(201).send({
|
|
15009
|
+
organization: {
|
|
15010
|
+
id: created.organizationId,
|
|
15011
|
+
name: created.name,
|
|
15012
|
+
displayName: created.displayName,
|
|
15013
|
+
role: "admin"
|
|
15014
|
+
},
|
|
15015
|
+
tokens
|
|
15016
|
+
});
|
|
15017
|
+
});
|
|
15018
|
+
app.post("/me/organizations/join", { config: { rateLimit: {
|
|
15019
|
+
max: loginMax,
|
|
15020
|
+
timeWindow: "1 minute"
|
|
15021
|
+
} } }, async (request, reply) => {
|
|
15022
|
+
const m = requireMember(request);
|
|
15023
|
+
const body = joinByInvitationSchema.parse(request.body);
|
|
15024
|
+
const inv = await findActiveByToken(app.db, body.token);
|
|
15025
|
+
if (!inv) return reply.status(404).send({ error: "Invitation not found or no longer valid" });
|
|
15026
|
+
const [u] = await app.db.select({
|
|
15027
|
+
username: users.username,
|
|
15028
|
+
displayName: users.displayName
|
|
15029
|
+
}).from(users).where(eq(users.id, m.userId)).limit(1);
|
|
15030
|
+
if (!u) throw new NotFoundError("User not found");
|
|
15031
|
+
const member = await ensureMembership(app.db, {
|
|
15032
|
+
userId: m.userId,
|
|
15033
|
+
organizationId: inv.organizationId,
|
|
15034
|
+
role: inv.role === "admin" ? "admin" : "member",
|
|
15035
|
+
displayName: u.displayName,
|
|
15036
|
+
username: u.username
|
|
15037
|
+
});
|
|
15038
|
+
await recordRedemption(app.db, {
|
|
15039
|
+
invitationId: inv.id,
|
|
15040
|
+
userId: m.userId,
|
|
15041
|
+
ip: request.ip,
|
|
15042
|
+
userAgent: request.headers["user-agent"] ?? null
|
|
15043
|
+
});
|
|
15044
|
+
const tokens = await signTokensForMember(app.config.secrets.jwtSecret, {
|
|
15045
|
+
userId: m.userId,
|
|
15046
|
+
memberId: member.id,
|
|
15047
|
+
organizationId: member.organizationId,
|
|
15048
|
+
role: member.role
|
|
15049
|
+
});
|
|
15050
|
+
return reply.status(200).send({
|
|
15051
|
+
organizationId: member.organizationId,
|
|
15052
|
+
memberId: member.id,
|
|
15053
|
+
role: member.role,
|
|
15054
|
+
tokens
|
|
15055
|
+
});
|
|
15056
|
+
});
|
|
15057
|
+
app.post("/me/organizations/leave", async (request, reply) => {
|
|
15058
|
+
const m = requireMember(request);
|
|
15059
|
+
await leaveOrganization(app.db, m.memberId);
|
|
15060
|
+
return reply.status(204).send();
|
|
15061
|
+
});
|
|
15062
|
+
app.post("/auth/switch-org", async (request, reply) => {
|
|
15063
|
+
const m = requireMember(request);
|
|
15064
|
+
const body = switchOrgSchema.parse(request.body);
|
|
15065
|
+
const [target] = await app.db.select().from(members).where(and(eq(members.userId, m.userId), eq(members.organizationId, body.organizationId), eq(members.status, "active"))).limit(1);
|
|
15066
|
+
if (!target) throw new ForbiddenError("You do not belong to that organization");
|
|
15067
|
+
const tokens = await signTokensForMember(app.config.secrets.jwtSecret, {
|
|
15068
|
+
userId: m.userId,
|
|
15069
|
+
memberId: target.id,
|
|
15070
|
+
organizationId: target.organizationId,
|
|
15071
|
+
role: target.role
|
|
15072
|
+
});
|
|
15073
|
+
return reply.send(tokens);
|
|
15074
|
+
});
|
|
15075
|
+
}
|
|
15076
|
+
/**
|
|
15077
|
+
* Infer the wizard step from observable runtime state. Refer to
|
|
15078
|
+
* proposal §"Onboarding 状态推断" for the rationale.
|
|
15079
|
+
*
|
|
15080
|
+
* Note: we deliberately do NOT filter by `clients.status='connected'`
|
|
15081
|
+
* here. The original "fact-is-state" reading would have flapped between
|
|
15082
|
+
* `completed` and `connect` every time the user's client briefly went
|
|
15083
|
+
* offline — UX disaster (the onboarding modal would re-pop). "Ever
|
|
15084
|
+
* connected" (= a clients row exists at all for this user/org) is still
|
|
15085
|
+
* fact-derived: deleting the row really does rewind the wizard, and
|
|
15086
|
+
* that's the explicit reset path.
|
|
15087
|
+
*/
|
|
15088
|
+
async function inferWizardStep(app, m) {
|
|
15089
|
+
const [hasClient] = await app.db.select({ id: clients.id }).from(clients).where(and(eq(clients.userId, m.userId), eq(clients.organizationId, m.organizationId))).limit(1);
|
|
15090
|
+
if (!hasClient) return "connect";
|
|
15091
|
+
const [hasAgent] = await app.db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.managerId, m.memberId), ne(agents.type, "human"), eq(agents.status, "active"))).limit(1);
|
|
15092
|
+
if (!hasAgent) return "create_agent";
|
|
15093
|
+
return "completed";
|
|
15094
|
+
}
|
|
15095
|
+
/**
|
|
15096
|
+
* Public route exported separately so it mounts BEFORE the member auth hook.
|
|
15097
|
+
* Just exposes the org's display name & slug for the unauthenticated `/invite/:token`
|
|
15098
|
+
* landing page.
|
|
15099
|
+
*/
|
|
15100
|
+
async function publicInvitePreviewRoute(app) {
|
|
15101
|
+
const { previewInvitation } = await import("./invitation-BTlGMy0o-Coj07kYi.mjs");
|
|
15102
|
+
app.get("/:token/preview", async (request, reply) => {
|
|
15103
|
+
if (!request.params.token) throw new UnauthorizedError("Token required");
|
|
15104
|
+
const preview = await previewInvitation(app.db, request.params.token);
|
|
15105
|
+
return reply.send(preview);
|
|
15106
|
+
});
|
|
15107
|
+
}
|
|
15108
|
+
/**
|
|
15109
|
+
* Admin-only invitation routes — mounted under `/admin/organizations/:id/invitations`.
|
|
15110
|
+
*/
|
|
15111
|
+
async function adminInvitationRoutes(app) {
|
|
15112
|
+
app.get("/", async (request) => {
|
|
15113
|
+
const m = requireMember(request);
|
|
15114
|
+
if (m.role !== "admin") throw new ForbiddenError("Admin role required");
|
|
15115
|
+
if (request.params.id !== m.organizationId) throw new ForbiddenError("Cannot inspect invitations for another organization");
|
|
15116
|
+
const inv = await ensureActiveInvitation(app.db, m.organizationId, m.userId);
|
|
15117
|
+
return {
|
|
15118
|
+
id: inv.id,
|
|
15119
|
+
organizationId: inv.organizationId,
|
|
15120
|
+
token: inv.token,
|
|
15121
|
+
inviteUrl: buildInviteUrl(resolvePublicUrl(app, request), inv.token),
|
|
15122
|
+
role: inv.role,
|
|
15123
|
+
createdAt: inv.createdAt.toISOString(),
|
|
15124
|
+
expiresAt: inv.expiresAt ? inv.expiresAt.toISOString() : null
|
|
15125
|
+
};
|
|
15126
|
+
});
|
|
15127
|
+
app.post("/rotate", async (request) => {
|
|
15128
|
+
const m = requireMember(request);
|
|
15129
|
+
if (m.role !== "admin") throw new ForbiddenError("Admin role required");
|
|
15130
|
+
if (request.params.id !== m.organizationId) throw new ForbiddenError("Cannot rotate invitations for another organization");
|
|
15131
|
+
const { rotateInvitation } = await import("./invitation-BTlGMy0o-Coj07kYi.mjs");
|
|
15132
|
+
const inv = await rotateInvitation(app.db, m.organizationId, m.userId);
|
|
15133
|
+
return {
|
|
15134
|
+
id: inv.id,
|
|
15135
|
+
organizationId: inv.organizationId,
|
|
15136
|
+
token: inv.token,
|
|
15137
|
+
inviteUrl: buildInviteUrl(resolvePublicUrl(app, request), inv.token),
|
|
15138
|
+
role: inv.role,
|
|
15139
|
+
createdAt: inv.createdAt.toISOString(),
|
|
15140
|
+
expiresAt: inv.expiresAt ? inv.expiresAt.toISOString() : null
|
|
13323
15141
|
};
|
|
13324
15142
|
});
|
|
13325
15143
|
}
|
|
@@ -13959,10 +15777,13 @@ var schema_exports = /* @__PURE__ */ __exportAll({
|
|
|
13959
15777
|
agentConfigs: () => agentConfigs,
|
|
13960
15778
|
agentPresence: () => agentPresence,
|
|
13961
15779
|
agents: () => agents,
|
|
15780
|
+
authIdentities: () => authIdentities,
|
|
13962
15781
|
chatParticipants: () => chatParticipants,
|
|
13963
15782
|
chats: () => chats,
|
|
13964
15783
|
clients: () => clients,
|
|
13965
15784
|
inboxEntries: () => inboxEntries,
|
|
15785
|
+
invitationRedemptions: () => invitationRedemptions,
|
|
15786
|
+
invitations: () => invitations,
|
|
13966
15787
|
members: () => members,
|
|
13967
15788
|
messages: () => messages,
|
|
13968
15789
|
notifications: () => notifications,
|
|
@@ -14674,6 +16495,7 @@ function createConfigService(opts) {
|
|
|
14674
16495
|
*/
|
|
14675
16496
|
function applyPatch(current, patch) {
|
|
14676
16497
|
return {
|
|
16498
|
+
kind: current.kind,
|
|
14677
16499
|
prompt: patch.prompt ?? current.prompt,
|
|
14678
16500
|
model: patch.model ?? current.model,
|
|
14679
16501
|
mcpServers: patch.mcpServers ?? current.mcpServers,
|
|
@@ -14699,13 +16521,26 @@ function createConfigService(opts) {
|
|
|
14699
16521
|
async function readRow(agentId) {
|
|
14700
16522
|
const [row] = await db.select().from(agentConfigs).where(eq(agentConfigs.agentId, agentId)).limit(1);
|
|
14701
16523
|
if (!row) throw new NotFoundError(`Agent config "${agentId}" not found`);
|
|
14702
|
-
|
|
16524
|
+
const payload = agentRuntimeConfigPayloadSchema$1.parse(row.payload);
|
|
16525
|
+
return {
|
|
16526
|
+
...row,
|
|
16527
|
+
payload
|
|
16528
|
+
};
|
|
16529
|
+
}
|
|
16530
|
+
async function readRuntimeProviderFor(agentId) {
|
|
16531
|
+
const [row] = await db.select({ runtimeProvider: agents.runtimeProvider }).from(agents).where(eq(agents.uuid, agentId)).limit(1);
|
|
16532
|
+
if (!row) throw new NotFoundError(`Agent "${agentId}" not found`);
|
|
16533
|
+
return row.runtimeProvider;
|
|
14703
16534
|
}
|
|
14704
16535
|
async function commitWrite(agentId, patch, expectedVersion, updatedBy) {
|
|
14705
16536
|
const current = await readRow(agentId);
|
|
14706
16537
|
if (current.version !== expectedVersion) throw new ConflictError(`Agent config "${agentId}" version mismatch: expected ${expectedVersion}, got ${current.version}`);
|
|
14707
|
-
const
|
|
14708
|
-
const
|
|
16538
|
+
const provider = await readRuntimeProviderFor(agentId);
|
|
16539
|
+
const synced = {
|
|
16540
|
+
...applyPatch(current.payload, patch),
|
|
16541
|
+
kind: provider
|
|
16542
|
+
};
|
|
16543
|
+
const validated = agentRuntimeConfigPayloadSchema$1.parse(synced);
|
|
14709
16544
|
const [updated] = await db.update(agentConfigs).set({
|
|
14710
16545
|
version: sql`${agentConfigs.version} + 1`,
|
|
14711
16546
|
payload: validated,
|
|
@@ -14812,7 +16647,12 @@ function createConfigService(opts) {
|
|
|
14812
16647
|
},
|
|
14813
16648
|
async dryRun(agentId, patch) {
|
|
14814
16649
|
const row = await readRow(agentId);
|
|
14815
|
-
const
|
|
16650
|
+
const provider = await readRuntimeProviderFor(agentId);
|
|
16651
|
+
const synced = {
|
|
16652
|
+
...applyPatch(row.payload, patch),
|
|
16653
|
+
kind: provider
|
|
16654
|
+
};
|
|
16655
|
+
const next = agentRuntimeConfigPayloadSchema$1.parse(synced);
|
|
14816
16656
|
const diff = computeDiff(row.payload, next);
|
|
14817
16657
|
return {
|
|
14818
16658
|
current: {
|
|
@@ -15217,6 +17057,8 @@ async function buildApp(config) {
|
|
|
15217
17057
|
await api.register(healthRoutes);
|
|
15218
17058
|
await api.register(githubWebhookRoutes, { prefix: "/webhooks" });
|
|
15219
17059
|
await api.register(authRoutes, { prefix: "/auth" });
|
|
17060
|
+
await api.register(githubOauthRoutes, { prefix: "/auth/github" });
|
|
17061
|
+
await api.register(publicInvitePreviewRoute, { prefix: "/invite" });
|
|
15220
17062
|
await api.register(contextTreeInfoRoutes, { prefix: "/context-tree" });
|
|
15221
17063
|
await api.register(bootstrapConfigRoutes, { prefix: "/bootstrap" });
|
|
15222
17064
|
await api.register(async (adminApp) => {
|
|
@@ -15277,6 +17119,10 @@ async function buildApp(config) {
|
|
|
15277
17119
|
adminApp.addHook("onRequest", adminOnly);
|
|
15278
17120
|
await adminApp.register(adminOrganizationRoutes);
|
|
15279
17121
|
}, { prefix: "/admin/organizations" });
|
|
17122
|
+
await api.register(async (adminApp) => {
|
|
17123
|
+
adminApp.addHook("onRequest", memberAuth);
|
|
17124
|
+
await adminApp.register(adminInvitationRoutes);
|
|
17125
|
+
}, { prefix: "/admin/organizations/:id/invitations" });
|
|
15280
17126
|
await api.register(async (adminApp) => {
|
|
15281
17127
|
adminApp.addHook("onRequest", memberAuth);
|
|
15282
17128
|
adminApp.addHook("onRequest", adminOnly);
|
|
@@ -15488,7 +17334,7 @@ async function startServer(options) {
|
|
|
15488
17334
|
instanceId: `srv_${randomUUID().slice(0, 8)}`,
|
|
15489
17335
|
commandVersion: COMMAND_VERSION
|
|
15490
17336
|
};
|
|
15491
|
-
const { initTelemetry, shutdownTelemetry } = await import("./observability-
|
|
17337
|
+
const { initTelemetry, shutdownTelemetry } = await import("./observability-C08jUFsJ.mjs");
|
|
15492
17338
|
await initTelemetry(serverConfig.observability.tracing, config.instanceId);
|
|
15493
17339
|
const app = await buildApp(config);
|
|
15494
17340
|
const shutdown = async () => {
|
|
@@ -15714,4 +17560,187 @@ function createExecuteUpdate({ managed }) {
|
|
|
15714
17560
|
};
|
|
15715
17561
|
}
|
|
15716
17562
|
//#endregion
|
|
15717
|
-
|
|
17563
|
+
//#region src/commands/saas-connect.ts
|
|
17564
|
+
/**
|
|
17565
|
+
* @internal
|
|
17566
|
+
* Decode a JWT payload without verifying its signature. Used only by the
|
|
17567
|
+
* CLI's account-switch prompt and the URL-derivation helper below. Not
|
|
17568
|
+
* re-exported from `packages/command/src/index.ts` — external consumers
|
|
17569
|
+
* should call `deriveHubUrlFromToken` instead.
|
|
17570
|
+
*/
|
|
17571
|
+
function decodeJwtPayload(token) {
|
|
17572
|
+
try {
|
|
17573
|
+
const parts = token.split(".");
|
|
17574
|
+
if (parts.length !== 3 || !parts[1]) return null;
|
|
17575
|
+
const raw = Buffer.from(parts[1], "base64url").toString();
|
|
17576
|
+
const obj = JSON.parse(raw);
|
|
17577
|
+
if (typeof obj !== "object" || obj === null) return null;
|
|
17578
|
+
return obj;
|
|
17579
|
+
} catch {
|
|
17580
|
+
return null;
|
|
17581
|
+
}
|
|
17582
|
+
}
|
|
17583
|
+
var HubUrlDerivationError = class extends Error {
|
|
17584
|
+
constructor(code, message) {
|
|
17585
|
+
super(message);
|
|
17586
|
+
this.code = code;
|
|
17587
|
+
this.name = "HubUrlDerivationError";
|
|
17588
|
+
}
|
|
17589
|
+
};
|
|
17590
|
+
/**
|
|
17591
|
+
* Derive the hub URL from a connect token's `iss` claim. Throws
|
|
17592
|
+
* `HubUrlDerivationError` when the claim is missing or malformed — we
|
|
17593
|
+
* *never* fall back to a default URL because that would let a stale
|
|
17594
|
+
* connect token from one environment silently re-target another (prod →
|
|
17595
|
+
* staging foot-gun).
|
|
17596
|
+
*
|
|
17597
|
+
* The action handler maps the thrown error to a `fail()` exit so this
|
|
17598
|
+
* function stays unit-testable without spawning a subprocess.
|
|
17599
|
+
*/
|
|
17600
|
+
function deriveHubUrlFromToken(token) {
|
|
17601
|
+
const payload = decodeJwtPayload(token);
|
|
17602
|
+
if (!payload) throw new HubUrlDerivationError("INVALID_TOKEN", "Connect token is not a valid JWT. Generate a new one from your Hub web console.");
|
|
17603
|
+
const iss = payload.iss;
|
|
17604
|
+
if (typeof iss !== "string" || iss.length === 0) throw new HubUrlDerivationError("TOKEN_MISSING_ISS", "Connect token does not carry an issuer (`iss` claim). Generate a new token from a Hub running v0.10+.");
|
|
17605
|
+
if (!/^https?:\/\//i.test(iss)) throw new HubUrlDerivationError("TOKEN_BAD_ISS", `Connect token issuer "${iss}" is not an http(s) URL. Generate a new token.`);
|
|
17606
|
+
return iss.replace(/\/+$/, "");
|
|
17607
|
+
}
|
|
17608
|
+
async function promptReplaceOrCancel(newMemberId) {
|
|
17609
|
+
const existing = loadCredentials();
|
|
17610
|
+
if (!existing) return "proceed";
|
|
17611
|
+
const existingPayload = decodeJwtPayload(existing.accessToken);
|
|
17612
|
+
const existingMemberId = typeof existingPayload?.memberId === "string" ? existingPayload.memberId : null;
|
|
17613
|
+
if (existingMemberId && existingMemberId === newMemberId) return "proceed";
|
|
17614
|
+
const existingMember = existingMemberId ? `member ${existingMemberId.slice(0, 8)}` : "unknown account";
|
|
17615
|
+
const serviceStatus = getClientServiceStatus();
|
|
17616
|
+
const serviceLine = serviceStatus.state === "active" ? `running (${serviceStatus.detail ?? "live"})` : serviceStatus.state === "inactive" ? `installed but not running${serviceStatus.detail ? ` — ${serviceStatus.detail}` : ""}` : "not installed";
|
|
17617
|
+
print.line("\n ⚠️ This computer is already connected under another account.\n\n");
|
|
17618
|
+
print.line(` Existing account: ${existingMember}\n`);
|
|
17619
|
+
print.line(` Server: ${existing.serverUrl}\n`);
|
|
17620
|
+
print.line(` Background service: ${serviceLine}\n\n`);
|
|
17621
|
+
print.line(" Replacing only affects THIS computer. Server-side data is untouched.\n\n");
|
|
17622
|
+
return await select({
|
|
17623
|
+
message: "How would you like to continue?",
|
|
17624
|
+
choices: [{
|
|
17625
|
+
name: "Replace — log out the other account and set up this one",
|
|
17626
|
+
value: "replace"
|
|
17627
|
+
}, {
|
|
17628
|
+
name: "Cancel — keep the existing setup",
|
|
17629
|
+
value: "cancel"
|
|
17630
|
+
}]
|
|
17631
|
+
}) === "replace" ? "proceed" : "cancel";
|
|
17632
|
+
}
|
|
17633
|
+
async function exchangeToken(url, token) {
|
|
17634
|
+
const res = await fetch(`${url}/api/v1/auth/connect-token`, {
|
|
17635
|
+
method: "POST",
|
|
17636
|
+
headers: { "Content-Type": "application/json" },
|
|
17637
|
+
body: JSON.stringify({ token }),
|
|
17638
|
+
signal: AbortSignal.timeout(1e4)
|
|
17639
|
+
});
|
|
17640
|
+
if (!res.ok) fail("AUTH_ERROR", (await res.json().catch(() => ({}))).error ?? `Token exchange failed (HTTP ${res.status})`, 1);
|
|
17641
|
+
return await res.json();
|
|
17642
|
+
}
|
|
17643
|
+
/**
|
|
17644
|
+
* Top-level `first-tree-hub connect <token>`. Single positional, no flags,
|
|
17645
|
+
* no env-var override — the connect token's `iss` claim carries the hub
|
|
17646
|
+
* URL so prod / staging / local environments are tagged at issuance and
|
|
17647
|
+
* the operator can never accidentally cross-target.
|
|
17648
|
+
*/
|
|
17649
|
+
function registerSaaSConnectCommand(program) {
|
|
17650
|
+
program.command("connect <token>").description("Connect this computer to the Hub using a token from the web console").option("--no-service", "Skip background service install (runs inline until Ctrl+C)").action(async (token, options) => {
|
|
17651
|
+
try {
|
|
17652
|
+
let url;
|
|
17653
|
+
try {
|
|
17654
|
+
url = deriveHubUrlFromToken(token);
|
|
17655
|
+
} catch (err) {
|
|
17656
|
+
if (err instanceof HubUrlDerivationError) fail(err.code, err.message, 1);
|
|
17657
|
+
throw err;
|
|
17658
|
+
}
|
|
17659
|
+
const payload = decodeJwtPayload(token);
|
|
17660
|
+
const newMemberId = typeof payload?.memberId === "string" ? payload.memberId : null;
|
|
17661
|
+
if (newMemberId) {
|
|
17662
|
+
if (await promptReplaceOrCancel(newMemberId) === "cancel") {
|
|
17663
|
+
print.line("\n Cancelled. Existing setup untouched.\n");
|
|
17664
|
+
return;
|
|
17665
|
+
}
|
|
17666
|
+
}
|
|
17667
|
+
const tokens = await exchangeToken(url, token);
|
|
17668
|
+
setConfigValue(join(DEFAULT_CONFIG_DIR, "client.yaml"), "server.url", url);
|
|
17669
|
+
print.line(`\n ✓ Hub: ${url}\n`);
|
|
17670
|
+
saveCredentials({
|
|
17671
|
+
...tokens,
|
|
17672
|
+
serverUrl: url
|
|
17673
|
+
});
|
|
17674
|
+
print.line(" ✓ Authenticated\n");
|
|
17675
|
+
resetConfig();
|
|
17676
|
+
resetConfigMeta();
|
|
17677
|
+
const config = await initConfig({
|
|
17678
|
+
schema: clientConfigSchema,
|
|
17679
|
+
role: "client"
|
|
17680
|
+
});
|
|
17681
|
+
print.line(` ✓ Computer registered (id: ${config.client.id})\n`);
|
|
17682
|
+
if (options.service !== false && isServiceSupported()) {
|
|
17683
|
+
const info = installClientService();
|
|
17684
|
+
print.line(` ✓ Background service installed (${info.platform}) — you may close this terminal.\n`);
|
|
17685
|
+
print.line(` Logs: ${info.logDir}\n\n`);
|
|
17686
|
+
return;
|
|
17687
|
+
}
|
|
17688
|
+
if (options.service === false) print.line(" (--no-service) running inline — Ctrl+C to stop\n");
|
|
17689
|
+
else print.line(` Background service not supported on ${process.platform}; running inline.\n`);
|
|
17690
|
+
const agentsDir = join(DEFAULT_CONFIG_DIR, "agents");
|
|
17691
|
+
try {
|
|
17692
|
+
await migrateLocalAgentDirs({
|
|
17693
|
+
agentsDir,
|
|
17694
|
+
workspacesDir: join(DEFAULT_DATA_DIR$1, "workspaces"),
|
|
17695
|
+
sessionsDir: join(DEFAULT_DATA_DIR$1, "sessions"),
|
|
17696
|
+
resolver: createApiNameResolver(config.server.url, () => ensureFreshAccessToken())
|
|
17697
|
+
});
|
|
17698
|
+
} catch (err) {
|
|
17699
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
17700
|
+
print.status("⚠️", `agent-dir migration skipped: ${msg}`);
|
|
17701
|
+
}
|
|
17702
|
+
const agents = loadAgents({
|
|
17703
|
+
schema: agentConfigSchema,
|
|
17704
|
+
agentsDir
|
|
17705
|
+
});
|
|
17706
|
+
const runtime = new ClientRuntime(config.server.url, config.client.id, {
|
|
17707
|
+
currentVersion: COMMAND_VERSION,
|
|
17708
|
+
update: {
|
|
17709
|
+
updateConfig: config.update,
|
|
17710
|
+
prompt: promptUpdate,
|
|
17711
|
+
executeUpdate: createExecuteUpdate({ managed: false })
|
|
17712
|
+
}
|
|
17713
|
+
});
|
|
17714
|
+
for (const [name, agentConfig] of agents) runtime.addAgent(name, agentConfig);
|
|
17715
|
+
await runtime.start();
|
|
17716
|
+
runtime.watchAgentsDir(agentsDir);
|
|
17717
|
+
const shutdown = async () => {
|
|
17718
|
+
print.line("\n Shutting down...\n");
|
|
17719
|
+
runtime.unwatchAgentsDir();
|
|
17720
|
+
await runtime.stop();
|
|
17721
|
+
process.exit(0);
|
|
17722
|
+
};
|
|
17723
|
+
process.on("SIGINT", () => void shutdown());
|
|
17724
|
+
process.on("SIGTERM", () => void shutdown());
|
|
17725
|
+
await new Promise(() => {});
|
|
17726
|
+
} catch (error) {
|
|
17727
|
+
if (error.name === "ExitPromptError") {
|
|
17728
|
+
print.line("\n Cancelled.\n");
|
|
17729
|
+
return;
|
|
17730
|
+
}
|
|
17731
|
+
if (error instanceof ClientOrgMismatchError) await handleClientOrgMismatch(error, {
|
|
17732
|
+
managed: false,
|
|
17733
|
+
configDir: DEFAULT_CONFIG_DIR,
|
|
17734
|
+
rerunCommand: "first-tree-hub connect <token>"
|
|
17735
|
+
});
|
|
17736
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
17737
|
+
print.line(` Error: ${msg}\n`);
|
|
17738
|
+
process.exit(1);
|
|
17739
|
+
} finally {
|
|
17740
|
+
resetConfig();
|
|
17741
|
+
resetConfigMeta();
|
|
17742
|
+
}
|
|
17743
|
+
});
|
|
17744
|
+
}
|
|
17745
|
+
//#endregion
|
|
17746
|
+
export { status as $, checkServerHealth as A, isDockerAvailable as B, checkAgentConfigs as C, checkDocker as D, checkDatabase as E, installClientService as F, createOwner as G, ClientRuntime as H, isServiceSupported as I, fail as J, hasUser as K, resolveCliInvocation as L, checkWebSocket as M, printResults as N, checkNodeVersion as O, getClientServiceStatus as P, setJsonMode as Q, uninstallClientService as R, runMigrations as S, checkClientConfig as T, handleClientOrgMismatch as U, stopPostgres as V, rotateClientIdWithBackup as W, blank as X, success as Y, print as Z, onboardCreate as _, declineUpdate as a, probeCapabilities as at, createApiNameResolver as b, COMMAND_VERSION as c, isInteractive as d, ClientOrgMismatchError as et, promptAddAgent as f, onboardCheck as g, loadOnboardState as h, createExecuteUpdate as i, cleanWorkspaces as it, checkServerReachable as j, checkServerConfig as k, reconcileLocalRuntimeProviders as l, formatCheckReport as m, deriveHubUrlFromToken as n, SdkError as nt, promptUpdate as o, applyClientLoggerConfig as ot, promptMissingFields as p, resolveReplyToFromEnv as q, registerSaaSConnectCommand as r, SessionRegistry as rt, startServer as s, configureClientLoggerForService as st, HubUrlDerivationError as t, FirstTreeHubSDK as tt, uploadClientCapabilities as u, saveOnboardState as v, checkBackgroundService as w, migrateLocalAgentDirs as x, runHomeMigration as y, ensurePostgres as z };
|