@naisys/hub 3.0.0-beta.38 → 3.0.0-beta.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,11 @@
1
+ import { hashToken } from "@naisys/common-node";
1
2
  import { HeartbeatSchema, HUB_HEARTBEAT_INTERVAL_MS, HubEvents, } from "@naisys/hub-protocol";
2
3
  /** Tracks NAISYS instance heartbeats and pushes aggregate active user status to all instances */
3
- export function createHubHeartbeatService(naisysServer, { hubDb }, logService) {
4
- // Track active agent user IDs per host from heartbeat data
4
+ export function createHubHeartbeatService(naisysServer, { hubDb }, logService, redactionService, runtimeKeyService) {
5
+ // Active agent user ids per host. Subagents ride under the parent's userId.
5
6
  const hostActiveAgents = new Map();
6
- // Track each active agent's current run session and its last heartbeat time.
7
- // Keyed by hostId so we can drop sessions when a host disconnects.
8
7
  const hostActiveSessions = new Map();
8
+ const sessionKey = (userId, subagentId) => `${userId}:${subagentId ?? 0}`;
9
9
  // Track per-agent notification IDs (latestLogId, latestMailId)
10
10
  const agentNotifications = new Map();
11
11
  /** Update a single notification field for an agent */
@@ -20,7 +20,10 @@ export function createHubHeartbeatService(naisysServer, { hubDb }, logService) {
20
20
  // Handle heartbeat from NAISYS instances
21
21
  naisysServer.registerEvent(HubEvents.HEARTBEAT, async (hostId, data) => {
22
22
  const parsed = HeartbeatSchema.parse(data);
23
- const activeUserIds = parsed.activeSessions.map((s) => s.userId);
23
+ // Dedup: a parent and its subagents share a userId.
24
+ const activeUserIds = [
25
+ ...new Set(parsed.activeSessions.map((s) => s.userId)),
26
+ ];
24
27
  // Update in-memory per-host active agent IDs
25
28
  hostActiveAgents.set(hostId, activeUserIds);
26
29
  try {
@@ -42,15 +45,10 @@ export function createHubHeartbeatService(naisysServer, { hubDb }, logService) {
42
45
  // aggregate SESSION_HEARTBEAT broadcast runs on its own interval below.
43
46
  const sessionMap = new Map();
44
47
  for (const session of parsed.activeSessions) {
45
- await hubDb.run_session.updateMany({
46
- where: {
47
- user_id: session.userId,
48
- run_id: session.runId,
49
- session_id: session.sessionId,
50
- },
51
- data: { last_active: now },
52
- });
53
- sessionMap.set(session.userId, {
48
+ const subagentId = session.subagentId ?? 0;
49
+ sessionMap.set(sessionKey(session.userId, subagentId), {
50
+ userId: session.userId,
51
+ subagentId: subagentId === 0 ? null : subagentId,
54
52
  runId: session.runId,
55
53
  sessionId: session.sessionId,
56
54
  lastActive: now,
@@ -58,15 +56,62 @@ export function createHubHeartbeatService(naisysServer, { hubDb }, logService) {
58
56
  state: session.state,
59
57
  });
60
58
  }
59
+ if (parsed.activeSessions.length > 0) {
60
+ await hubDb.run_session.updateMany({
61
+ where: {
62
+ OR: parsed.activeSessions.map((session) => ({
63
+ user_id: session.userId,
64
+ run_id: session.runId,
65
+ subagent_id: session.subagentId ?? 0,
66
+ session_id: session.sessionId,
67
+ })),
68
+ },
69
+ data: { last_active: now },
70
+ });
71
+ }
61
72
  hostActiveSessions.set(hostId, sessionMap);
73
+ // Self-heal: register each plaintext with the redactor (idempotent),
74
+ // then mint + push a fresh key if the hash is missing or mismatched.
75
+ // Old plaintexts accumulate per user so leaks during the rotate
76
+ // transition window still get scrubbed; AGENT_STOP clears the set.
77
+ if (parsed.runtimeApiKeys?.length) {
78
+ const userIds = parsed.runtimeApiKeys.map((k) => k.userId);
79
+ const users = await hubDb.users.findMany({
80
+ where: { id: { in: userIds } },
81
+ select: { id: true, api_key_hash: true, enabled: true, archived: true },
82
+ });
83
+ const userMap = new Map(users.map((u) => [u.id, u]));
84
+ for (const claim of parsed.runtimeApiKeys) {
85
+ if (claim.runtimeApiKey) {
86
+ redactionService.registerRuntimeApiKey(claim.userId, claim.runtimeApiKey);
87
+ }
88
+ const user = userMap.get(claim.userId);
89
+ if (!user || !user.enabled || user.archived)
90
+ continue;
91
+ if (claim.runtimeApiKey &&
92
+ user.api_key_hash &&
93
+ hashToken(claim.runtimeApiKey) === user.api_key_hash) {
94
+ continue; // hub already knows this key — nothing to do
95
+ }
96
+ try {
97
+ const runtimeApiKey = await runtimeKeyService.issueRuntimeApiKey(claim.userId);
98
+ naisysServer.sendMessage(hostId, HubEvents.RUNTIME_KEY_REISSUE, {
99
+ userId: claim.userId,
100
+ runtimeApiKey,
101
+ });
102
+ }
103
+ catch (err) {
104
+ logService.error(`[Hub:Heartbeat] Failed to reissue runtime key for user ${claim.userId}: ${err}`);
105
+ }
106
+ }
107
+ }
62
108
  }
63
109
  catch (error) {
64
110
  logService.error(`[Hub:Heartbeat] Error updating heartbeat for host ${hostId}: ${error}`);
65
111
  }
66
112
  });
67
- // Clean up tracking when a host disconnects. Runtime API keys are not
68
- // touched here they're rotated on the next AGENT_START and revoked
69
- // explicitly on user disable/archive/delete.
113
+ // Runtime keys aren't touched on disconnect the DB hash stays valid;
114
+ // if the agent reconnects, heartbeat re-registers the plaintext.
70
115
  naisysServer.registerEvent(HubEvents.CLIENT_DISCONNECTED, (hostId) => {
71
116
  hostActiveAgents.delete(hostId);
72
117
  hostActiveSessions.delete(hostId);
@@ -99,10 +144,11 @@ export function createHubHeartbeatService(naisysServer, { hubDb }, logService) {
99
144
  function pushSessionHeartbeat() {
100
145
  const updates = [];
101
146
  for (const sessions of hostActiveSessions.values()) {
102
- for (const [userId, info] of sessions) {
147
+ for (const info of sessions.values()) {
103
148
  updates.push({
104
- userId,
149
+ userId: info.userId,
105
150
  runId: info.runId,
151
+ subagentId: info.subagentId ?? undefined,
106
152
  sessionId: info.sessionId,
107
153
  lastActive: info.lastActive,
108
154
  paused: info.paused,
@@ -37,6 +37,9 @@ export function createHubHostService(naisysServer, hostRegistrar, logService) {
37
37
  naisysServer.registerEvent(HubEvents.CLIENT_CONNECTED, (_hostId, connection) => {
38
38
  broadcastHostList(connection);
39
39
  });
40
+ naisysServer.registerEvent(HubEvents.CLIENT_DISCONNECTED, () => {
41
+ broadcastHostList();
42
+ });
40
43
  naisysServer.registerEvent(HubEvents.HOSTS_CHANGED, async () => {
41
44
  logService.log("[Hub:Hosts] Received HOSTS_CHANGED, refreshing cache...");
42
45
  await hostRegistrar.refreshHosts();
@@ -1,6 +1,6 @@
1
1
  import { HubEvents, LogWriteRequestSchema, } from "@naisys/hub-protocol";
2
2
  /** Handles log_write events from NAISYS instances (fire-and-forget) */
3
- export function createHubLogService(naisysServer, { hubDb }, logService, heartbeatService) {
3
+ export function createHubLogService(naisysServer, { hubDb }, logService, heartbeatService, redactionService) {
4
4
  // Track last pushed log ID per session for gap detection
5
5
  const lastPushedLogId = new Map();
6
6
  naisysServer.registerEvent(HubEvents.LOG_WRITE, async (hostId, data) => {
@@ -11,17 +11,22 @@ export function createHubLogService(naisysServer, { hubDb }, logService, heartbe
11
11
  const sessionUpdates = new Map();
12
12
  for (const entry of parsed.entries) {
13
13
  const now = new Date().toISOString();
14
- const lineCount = entry.message.split("\n").length;
14
+ const message = redactionService.redact(entry.message);
15
+ const lineCount = message.split("\n").length;
16
+ const subagentId = entry.subagentId ?? 0;
17
+ // Wire format: undefined for parent (subagent_id 0 in DB), number otherwise
18
+ const wireSubagentId = subagentId === 0 ? undefined : subagentId;
15
19
  const log = await hubDb.context_log.create({
16
20
  data: {
17
21
  user_id: entry.userId,
18
22
  run_id: entry.runId,
23
+ subagent_id: subagentId,
19
24
  session_id: entry.sessionId,
20
25
  host_id: hostId,
21
26
  role: entry.role,
22
27
  source: entry.source ?? null,
23
28
  type: entry.type ?? null,
24
- message: entry.message,
29
+ message,
25
30
  created_at: entry.createdAt,
26
31
  attachment_id: entry.attachmentId ?? null,
27
32
  },
@@ -31,6 +36,7 @@ export function createHubLogService(naisysServer, { hubDb }, logService, heartbe
31
36
  where: {
32
37
  user_id: entry.userId,
33
38
  run_id: entry.runId,
39
+ subagent_id: subagentId,
34
40
  session_id: entry.sessionId,
35
41
  },
36
42
  data: {
@@ -69,18 +75,19 @@ export function createHubLogService(naisysServer, { hubDb }, logService, heartbe
69
75
  }
70
76
  }
71
77
  // Collect push entry with DB-assigned ID
72
- const sessionKey = `${entry.userId}-${entry.runId}-${entry.sessionId}`;
78
+ const sessionKey = `${entry.userId}-${entry.runId}-${subagentId}-${entry.sessionId}`;
73
79
  const previousId = lastPushedLogId.get(sessionKey) ?? null;
74
80
  pushEntries.push({
75
81
  id: log.id,
76
82
  previousId,
77
83
  userId: entry.userId,
78
84
  runId: entry.runId,
85
+ subagentId: wireSubagentId,
79
86
  sessionId: entry.sessionId,
80
87
  role: entry.role,
81
88
  source: entry.source,
82
89
  type: entry.type,
83
- message: entry.message,
90
+ message,
84
91
  createdAt: entry.createdAt,
85
92
  attachmentId: attachmentPublicId,
86
93
  attachmentFilename,
@@ -98,6 +105,7 @@ export function createHubLogService(naisysServer, { hubDb }, logService, heartbe
98
105
  sessionUpdates.set(sessionKey, {
99
106
  userId: entry.userId,
100
107
  runId: entry.runId,
108
+ subagentId: wireSubagentId,
101
109
  sessionId: entry.sessionId,
102
110
  lastActive: now,
103
111
  latestLogId: log.id,
@@ -0,0 +1,93 @@
1
+ import { HubEvents } from "@naisys/hub-protocol";
2
+ const MIN_SECRET_LENGTH = 6;
3
+ const PATTERN_REPLACEMENTS = [
4
+ {
5
+ pattern: /Authorization:\s*(Bearer|Basic)\s+\S+/gi,
6
+ replacement: "Authorization: $1 [REDACTED]",
7
+ },
8
+ {
9
+ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----/g,
10
+ replacement: "[REDACTED:PRIVATE_KEY]",
11
+ },
12
+ {
13
+ pattern: /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
14
+ replacement: "[REDACTED:JWT]",
15
+ },
16
+ {
17
+ pattern: /AKIA[0-9A-Z]{16}/g,
18
+ replacement: "[REDACTED:AWS_KEY]",
19
+ },
20
+ ];
21
+ /**
22
+ * Strips sensitive values before they hit the DB or get rebroadcast. Two
23
+ * sources:
24
+ * - DB variables flagged sensitive
25
+ * - Runtime NAISYS_API_KEY plaintexts, registered by issueRuntimeApiKey
26
+ * and accumulated per-user as a Set. Heartbeat-driven re-registration
27
+ * self-heals across hub restarts; AGENT_STOP clears the Set.
28
+ */
29
+ export async function createHubRedactionService(naisysServer, { hubDb }, logService) {
30
+ // Sorted longest-first so a secret that is a prefix of another doesn't
31
+ // get partially replaced before the longer match runs.
32
+ let dbSecrets = [];
33
+ const runtimeApiKeys = new Map();
34
+ async function rebuildDbSecrets() {
35
+ const rows = await hubDb.variables.findMany({ where: { sensitive: true } });
36
+ dbSecrets = rows
37
+ .filter((r) => r.value && r.value.length >= MIN_SECRET_LENGTH)
38
+ .map((r) => ({ value: r.value, key: r.key }))
39
+ .sort((a, b) => b.value.length - a.value.length);
40
+ }
41
+ function redact(text) {
42
+ if (!text)
43
+ return text ?? "";
44
+ let out = text;
45
+ for (const { value, key } of dbSecrets) {
46
+ if (out.includes(value)) {
47
+ out = out.split(value).join(`[REDACTED:${key}]`);
48
+ }
49
+ }
50
+ for (const [userId, plaintexts] of runtimeApiKeys) {
51
+ for (const plaintext of plaintexts) {
52
+ if (out.includes(plaintext)) {
53
+ out = out
54
+ .split(plaintext)
55
+ .join(`[REDACTED:NAISYS_API_KEY:${userId}]`);
56
+ }
57
+ }
58
+ }
59
+ for (const { pattern, replacement } of PATTERN_REPLACEMENTS) {
60
+ out = out.replace(pattern, replacement);
61
+ }
62
+ return out;
63
+ }
64
+ function registerRuntimeApiKey(userId, plaintext) {
65
+ if (!plaintext || plaintext.length < MIN_SECRET_LENGTH)
66
+ return;
67
+ let set = runtimeApiKeys.get(userId);
68
+ if (!set) {
69
+ set = new Set();
70
+ runtimeApiKeys.set(userId, set);
71
+ }
72
+ set.add(plaintext);
73
+ }
74
+ function revokeRuntimeApiKey(userId) {
75
+ runtimeApiKeys.delete(userId);
76
+ }
77
+ naisysServer.registerEvent(HubEvents.VARIABLES_CHANGED, async () => {
78
+ try {
79
+ await rebuildDbSecrets();
80
+ }
81
+ catch (error) {
82
+ logService.error(`[Hub:Redaction] Failed to rebuild secrets after VARIABLES_CHANGED: ${error}`);
83
+ }
84
+ });
85
+ await rebuildDbSecrets();
86
+ return {
87
+ redact,
88
+ registerRuntimeApiKey,
89
+ revokeRuntimeApiKey,
90
+ rebuildDbSecrets,
91
+ };
92
+ }
93
+ //# sourceMappingURL=hubRedactionService.js.map
@@ -0,0 +1,116 @@
1
+ import { HubEvents } from "@naisys/hub-protocol";
2
+ import { beforeEach, describe, expect, test, vi } from "vitest";
3
+ import { createHubRedactionService } from "./hubRedactionService.js";
4
+ function createServerHarness() {
5
+ const handlers = new Map();
6
+ const server = {
7
+ registerEvent: vi.fn((event, handler) => {
8
+ handlers.set(event, handler);
9
+ }),
10
+ };
11
+ async function emit(event, ...args) {
12
+ const handler = handlers.get(event);
13
+ if (!handler)
14
+ throw new Error(`No handler for ${event}`);
15
+ await handler(...args);
16
+ }
17
+ return { server, emit };
18
+ }
19
+ function createHubDb(initialRows) {
20
+ let rows = [...initialRows];
21
+ const hubDb = {
22
+ variables: {
23
+ findMany: vi.fn(({ where }) => Promise.resolve(rows.filter((r) => r.sensitive === where.sensitive))),
24
+ },
25
+ };
26
+ return {
27
+ hubDb,
28
+ setRows: (next) => {
29
+ rows = [...next];
30
+ },
31
+ };
32
+ }
33
+ function createLogger() {
34
+ return {
35
+ log: vi.fn(),
36
+ error: vi.fn(),
37
+ disableConsole: vi.fn(),
38
+ };
39
+ }
40
+ describe("hubRedactionService", () => {
41
+ let server;
42
+ let emit;
43
+ beforeEach(() => {
44
+ const harness = createServerHarness();
45
+ server = harness.server;
46
+ emit = harness.emit;
47
+ });
48
+ test("redacts sensitive variable values", async () => {
49
+ const { hubDb } = createHubDb([
50
+ { key: "OPENAI_API_KEY", value: "sk-abc123def456", sensitive: true },
51
+ { key: "PUBLIC_VAR", value: "not-a-secret", sensitive: false },
52
+ ]);
53
+ const svc = await createHubRedactionService(server, { hubDb }, createLogger());
54
+ expect(svc.redact("call with sk-abc123def456 here")).toBe("call with [REDACTED:OPENAI_API_KEY] here");
55
+ expect(svc.redact("not-a-secret stays")).toBe("not-a-secret stays");
56
+ });
57
+ test("skips sensitive values shorter than 6 chars", async () => {
58
+ const { hubDb } = createHubDb([
59
+ { key: "SHORT", value: "abc", sensitive: true },
60
+ { key: "LONG_ENOUGH", value: "123456", sensitive: true },
61
+ ]);
62
+ const svc = await createHubRedactionService(server, { hubDb }, createLogger());
63
+ expect(svc.redact("abc and 123456")).toBe("abc and [REDACTED:LONG_ENOUGH]");
64
+ });
65
+ test("longest-first ordering avoids partial replacement", async () => {
66
+ const { hubDb } = createHubDb([
67
+ { key: "SHORT_KEY", value: "abc123", sensitive: true },
68
+ { key: "LONG_KEY", value: "abc1234567", sensitive: true },
69
+ ]);
70
+ const svc = await createHubRedactionService(server, { hubDb }, createLogger());
71
+ expect(svc.redact("token=abc1234567")).toBe("token=[REDACTED:LONG_KEY]");
72
+ });
73
+ test("registers and revokes runtime API keys per user", async () => {
74
+ const { hubDb } = createHubDb([]);
75
+ const svc = await createHubRedactionService(server, { hubDb }, createLogger());
76
+ svc.registerRuntimeApiKey(7, "deadbeefcafebabe");
77
+ expect(svc.redact("auth=deadbeefcafebabe")).toBe("auth=[REDACTED:NAISYS_API_KEY:7]");
78
+ svc.revokeRuntimeApiKey(7);
79
+ expect(svc.redact("auth=deadbeefcafebabe")).toBe("auth=deadbeefcafebabe");
80
+ });
81
+ test("accumulates plaintexts per user so old keys stay redactable mid-rotation", async () => {
82
+ const { hubDb } = createHubDb([]);
83
+ const svc = await createHubRedactionService(server, { hubDb }, createLogger());
84
+ svc.registerRuntimeApiKey(7, "oldkey1234567");
85
+ svc.registerRuntimeApiKey(7, "newkey7654321");
86
+ expect(svc.redact("oldkey1234567 and newkey7654321")).toBe("[REDACTED:NAISYS_API_KEY:7] and [REDACTED:NAISYS_API_KEY:7]");
87
+ svc.revokeRuntimeApiKey(7);
88
+ expect(svc.redact("oldkey1234567 and newkey7654321")).toBe("oldkey1234567 and newkey7654321");
89
+ });
90
+ test("pattern fallbacks catch shapes not in the secret list", async () => {
91
+ const { hubDb } = createHubDb([]);
92
+ const svc = await createHubRedactionService(server, { hubDb }, createLogger());
93
+ expect(svc.redact("Authorization: Bearer xyz.token.here")).toBe("Authorization: Bearer [REDACTED]");
94
+ expect(svc.redact("AKIAIOSFODNN7EXAMPLE")).toBe("[REDACTED:AWS_KEY]");
95
+ expect(svc.redact("header eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c trail")).toBe("header [REDACTED:JWT] trail");
96
+ });
97
+ test("redact returns empty string for null/undefined", async () => {
98
+ const { hubDb } = createHubDb([]);
99
+ const svc = await createHubRedactionService(server, { hubDb }, createLogger());
100
+ expect(svc.redact(null)).toBe("");
101
+ expect(svc.redact(undefined)).toBe("");
102
+ expect(svc.redact("")).toBe("");
103
+ });
104
+ test("rebuilds dbSecrets on VARIABLES_CHANGED", async () => {
105
+ const { hubDb, setRows } = createHubDb([
106
+ { key: "OLD_KEY", value: "old-secret-value", sensitive: true },
107
+ ]);
108
+ const svc = await createHubRedactionService(server, { hubDb }, createLogger());
109
+ expect(svc.redact("old-secret-value")).toBe("[REDACTED:OLD_KEY]");
110
+ setRows([{ key: "NEW_KEY", value: "new-secret-value", sensitive: true }]);
111
+ await emit(HubEvents.VARIABLES_CHANGED, 1);
112
+ expect(svc.redact("old-secret-value")).toBe("old-secret-value");
113
+ expect(svc.redact("new-secret-value")).toBe("[REDACTED:NEW_KEY]");
114
+ });
115
+ });
116
+ //# sourceMappingURL=hubRedactionService.test.js.map
@@ -14,18 +14,42 @@ export function createHubRunService(naisysServer, { hubDb }, logService) {
14
14
  naisysServer.registerEvent(HubEvents.SESSION_CREATE, async (hostId, data, ack) => {
15
15
  try {
16
16
  const parsed = SessionCreateRequestSchema.parse(data);
17
- // Get the last run_id across all sessions
18
- const lastRun = await hubDb.run_session.findFirst({
19
- select: { run_id: true },
20
- orderBy: { run_id: "desc" },
21
- });
22
- const newRunId = lastRun ? lastRun.run_id + 1 : 1;
17
+ // Subagent path: inherit the parent's runId rather than allocating a new one.
18
+ let runId;
19
+ if (parsed.subagentId !== undefined) {
20
+ if (parsed.parentRunId === undefined) {
21
+ throw new Error("parentRunId is required when subagentId is set");
22
+ }
23
+ // Confirm the parent's run row exists before materializing a child
24
+ // under it — otherwise we'd silently create an orphan subagent row.
25
+ const parent = await hubDb.run_session.findFirst({
26
+ where: {
27
+ user_id: parsed.userId,
28
+ run_id: parsed.parentRunId,
29
+ subagent_id: 0,
30
+ },
31
+ select: { run_id: true },
32
+ });
33
+ if (!parent) {
34
+ throw new Error(`parent run ${parsed.parentRunId} not found for user ${parsed.userId}`);
35
+ }
36
+ runId = parsed.parentRunId;
37
+ }
38
+ else {
39
+ const lastRun = await hubDb.run_session.findFirst({
40
+ select: { run_id: true },
41
+ orderBy: { run_id: "desc" },
42
+ });
43
+ runId = lastRun ? lastRun.run_id + 1 : 1;
44
+ }
45
+ const subagentId = parsed.subagentId ?? 0;
23
46
  const newSessionId = 1;
24
47
  const now = new Date().toISOString();
25
48
  await hubDb.run_session.create({
26
49
  data: {
27
50
  user_id: parsed.userId,
28
- run_id: newRunId,
51
+ run_id: runId,
52
+ subagent_id: subagentId,
29
53
  session_id: newSessionId,
30
54
  host_id: hostId,
31
55
  model_name: parsed.modelName,
@@ -35,12 +59,13 @@ export function createHubRunService(naisysServer, { hubDb }, logService) {
35
59
  });
36
60
  ack({
37
61
  success: true,
38
- runId: newRunId,
62
+ runId,
39
63
  sessionId: newSessionId,
40
64
  });
41
65
  pushSessionToSupervisors({
42
66
  userId: parsed.userId,
43
- runId: newRunId,
67
+ runId,
68
+ subagentId: parsed.subagentId,
44
69
  sessionId: newSessionId,
45
70
  modelName: parsed.modelName,
46
71
  createdAt: now,
@@ -55,12 +80,14 @@ export function createHubRunService(naisysServer, { hubDb }, logService) {
55
80
  naisysServer.registerEvent(HubEvents.SESSION_INCREMENT, async (hostId, data, ack) => {
56
81
  try {
57
82
  const parsed = SessionIncrementRequestSchema.parse(data);
58
- // Get the max session_id for this user + run
83
+ const subagentId = parsed.subagentId ?? 0;
84
+ // Get the max session_id for this user + run + subagent
59
85
  const lastSession = await hubDb.run_session.findFirst({
60
86
  select: { session_id: true },
61
87
  where: {
62
88
  user_id: parsed.userId,
63
89
  run_id: parsed.runId,
90
+ subagent_id: subagentId,
64
91
  },
65
92
  orderBy: { session_id: "desc" },
66
93
  });
@@ -70,6 +97,7 @@ export function createHubRunService(naisysServer, { hubDb }, logService) {
70
97
  data: {
71
98
  user_id: parsed.userId,
72
99
  run_id: parsed.runId,
100
+ subagent_id: subagentId,
73
101
  session_id: newSessionId,
74
102
  host_id: hostId,
75
103
  model_name: parsed.modelName,
@@ -81,6 +109,7 @@ export function createHubRunService(naisysServer, { hubDb }, logService) {
81
109
  pushSessionToSupervisors({
82
110
  userId: parsed.userId,
83
111
  runId: parsed.runId,
112
+ subagentId: parsed.subagentId,
84
113
  sessionId: newSessionId,
85
114
  modelName: parsed.modelName,
86
115
  createdAt: now,
@@ -0,0 +1,27 @@
1
+ import { hashToken } from "@naisys/common-node";
2
+ import { randomBytes } from "crypto";
3
+ /**
4
+ * Mints and revokes per-user runtime API keys. 32 random bytes; only the
5
+ * hash is persisted. Plaintext is registered with the redactor on issue so
6
+ * leaks get scrubbed; the user's Set is cleared on revoke.
7
+ */
8
+ export function createHubRuntimeKeyService({ hubDb }, redactionService) {
9
+ async function issueRuntimeApiKey(userId) {
10
+ const token = randomBytes(32).toString("hex");
11
+ await hubDb.users.update({
12
+ where: { id: userId },
13
+ data: { api_key_hash: hashToken(token) },
14
+ });
15
+ redactionService.registerRuntimeApiKey(userId, token);
16
+ return token;
17
+ }
18
+ async function revokeRuntimeApiKey(userId) {
19
+ await hubDb.users.update({
20
+ where: { id: userId },
21
+ data: { api_key_hash: null },
22
+ });
23
+ redactionService.revokeRuntimeApiKey(userId);
24
+ }
25
+ return { issueRuntimeApiKey, revokeRuntimeApiKey };
26
+ }
27
+ //# sourceMappingURL=hubRuntimeKeyService.js.map
@@ -1,9 +1,13 @@
1
1
  import { HubEvents, } from "@naisys/hub-protocol";
2
2
  /** Pure send-mail service with no auto-start logic, breaking the circular dependency */
3
- export function createHubSendMailService(naisysServer, { hubDb }, heartbeatService) {
3
+ export function createHubSendMailService(naisysServer, { hubDb }, heartbeatService, redactionService) {
4
4
  /** Send a mail message directly by user IDs */
5
5
  async function sendMail(params) {
6
6
  const now = new Date();
7
+ // Redact once at entry — DB row, supervisor push, and any future fan-out
8
+ // all read from these locals.
9
+ const subject = redactionService.redact(params.subject);
10
+ const body = redactionService.redact(params.body);
7
11
  // Build participants string from usernames (sorted alphabetically)
8
12
  const allUserIds = [
9
13
  ...new Set([params.fromUserId, ...params.recipientUserIds]),
@@ -24,8 +28,8 @@ export function createHubSendMailService(naisysServer, { hubDb }, heartbeatServi
24
28
  host_id: params.hostId,
25
29
  kind: params.kind,
26
30
  participants,
27
- subject: params.subject,
28
- body: params.body,
31
+ subject,
32
+ body,
29
33
  created_at: now,
30
34
  },
31
35
  });
@@ -114,8 +118,8 @@ export function createHubSendMailService(naisysServer, { hubDb }, heartbeatServi
114
118
  fromUserId: params.fromUserId,
115
119
  kind: params.kind,
116
120
  messageId: message.id,
117
- subject: params.subject,
118
- body: params.body,
121
+ subject,
122
+ body,
119
123
  createdAt: now.toISOString(),
120
124
  participants,
121
125
  attachments,
@@ -0,0 +1,54 @@
1
+ import { OPENAI_CODEX_ACCESS_TOKEN_VAR, OPENAI_CODEX_EXPIRES_AT_VAR, OPENAI_CODEX_REFRESH_TOKEN_VAR, } from "@naisys/common";
2
+ import { HubEvents, VariablePatchRequestSchema, } from "@naisys/hub-protocol";
3
+ // Allowlist + per-key storage policy. Without this, a compromised host could
4
+ // rotate any API key and have the hub broadcast it to every other host.
5
+ const clientPatchableVariables = new Map([
6
+ [OPENAI_CODEX_ACCESS_TOKEN_VAR, { sensitive: true, exportToShell: false }],
7
+ [OPENAI_CODEX_REFRESH_TOKEN_VAR, { sensitive: true, exportToShell: false }],
8
+ [OPENAI_CODEX_EXPIRES_AT_VAR, { sensitive: false, exportToShell: false }],
9
+ ]);
10
+ export function createHubVariablePatchService(naisysServer, { hubDb }, configService, redactionService, logService) {
11
+ async function patchVariables(hostId, request) {
12
+ const denied = request.updates.find(({ key }) => !clientPatchableVariables.has(key));
13
+ if (denied) {
14
+ logService.error(`[Hub:Variables] Rejected patch from host ${hostId}: variable '${denied.key}' is not patchable by NAISYS clients.`);
15
+ return;
16
+ }
17
+ await hubDb.$transaction(async (tx) => {
18
+ for (const update of request.updates) {
19
+ const policy = clientPatchableVariables.get(update.key);
20
+ await tx.variables.upsert({
21
+ where: { key: update.key },
22
+ update: {
23
+ value: update.value,
24
+ export_to_shell: policy.exportToShell,
25
+ sensitive: policy.sensitive,
26
+ updated_by: `host:${hostId}`,
27
+ },
28
+ create: {
29
+ key: update.key,
30
+ value: update.value,
31
+ export_to_shell: policy.exportToShell,
32
+ sensitive: policy.sensitive,
33
+ created_by: `host:${hostId}`,
34
+ updated_by: `host:${hostId}`,
35
+ },
36
+ });
37
+ }
38
+ });
39
+ // Inline rather than firing VARIABLES_CHANGED so the redaction set is
40
+ // rebuilt before any subsequent log lines ingest.
41
+ await redactionService.rebuildDbSecrets();
42
+ await configService.broadcastConfig();
43
+ }
44
+ naisysServer.registerEvent(HubEvents.VARIABLE_PATCH, async (hostId, request) => {
45
+ try {
46
+ await patchVariables(hostId, request);
47
+ }
48
+ catch (error) {
49
+ logService.error(`[Hub:Variables] Failed to apply variable patch from host ${hostId}: ${error}`);
50
+ }
51
+ }, VariablePatchRequestSchema);
52
+ return { patchVariables };
53
+ }
54
+ //# sourceMappingURL=hubVariablePatchService.js.map