@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.
- package/README.md +3 -1
- package/dist/handlers/hubAgentService.js +93 -33
- package/dist/handlers/hubConfigService.js +8 -3
- package/dist/handlers/hubCostService.js +20 -11
- package/dist/handlers/hubCostService.test.js +263 -0
- package/dist/handlers/hubHeartbeatService.js +65 -19
- package/dist/handlers/hubHostService.js +3 -0
- package/dist/handlers/hubLogService.js +13 -5
- package/dist/handlers/hubRedactionService.js +93 -0
- package/dist/handlers/hubRedactionService.test.js +116 -0
- package/dist/handlers/hubRunService.js +39 -10
- package/dist/handlers/hubRuntimeKeyService.js +27 -0
- package/dist/handlers/hubSendMailService.js +9 -5
- package/dist/handlers/hubVariablePatchService.js +54 -0
- package/dist/handlers/hubVariablePatchService.test.js +85 -0
- package/dist/naisysHub.js +29 -12
- package/dist/services/naisysServer.js +7 -2
- package/npm-shrinkwrap.json +23 -23
- package/package.json +9 -7
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
//
|
|
68
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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:
|
|
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
|
|
62
|
+
runId,
|
|
39
63
|
sessionId: newSessionId,
|
|
40
64
|
});
|
|
41
65
|
pushSessionToSupervisors({
|
|
42
66
|
userId: parsed.userId,
|
|
43
|
-
runId
|
|
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
|
-
|
|
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
|
|
28
|
-
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
|
|
118
|
-
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
|