@danielblomma/cortex-mcp 2.2.5 → 2.4.1
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/CHANGELOG.md +201 -0
- package/README.md +73 -3
- package/bin/cortex.mjs +339 -242
- package/bin/daemon-control.mjs +162 -0
- package/package.json +7 -3
- package/scaffold/mcp/dist/.cortex-build-hash +1 -0
- package/scaffold/mcp/dist/cli/enterprise-setup.js +134 -0
- package/scaffold/mcp/dist/cli/govern.js +937 -0
- package/scaffold/mcp/dist/cli/query.js +409 -0
- package/scaffold/mcp/dist/cli/run.js +295 -0
- package/scaffold/mcp/dist/cli/stage.js +308 -0
- package/scaffold/mcp/dist/cli/telemetry-test.js +141 -0
- package/scaffold/mcp/dist/cli/ungoverned-detector.js +133 -0
- package/scaffold/mcp/dist/contextEntities.js +282 -0
- package/scaffold/mcp/dist/core/audit/query.js +72 -0
- package/scaffold/mcp/dist/core/audit/writer.js +28 -0
- package/scaffold/mcp/dist/core/config.js +235 -0
- package/scaffold/mcp/dist/core/enterprise-host-identity.js +193 -0
- package/scaffold/mcp/dist/core/enterprise-identity.js +18 -0
- package/scaffold/mcp/dist/core/enterprise-rotation.js +158 -0
- package/scaffold/mcp/dist/core/govern-paths.js +21 -0
- package/scaffold/mcp/dist/core/index.js +14 -0
- package/scaffold/mcp/dist/core/license.js +278 -0
- package/scaffold/mcp/dist/core/policy/enforce.js +66 -0
- package/scaffold/mcp/dist/core/policy/injection.js +172 -0
- package/scaffold/mcp/dist/core/policy/store.js +179 -0
- package/scaffold/mcp/dist/core/rbac/check.js +30 -0
- package/scaffold/mcp/dist/core/secure-endpoint.js +25 -0
- package/scaffold/mcp/dist/core/telemetry/collector.js +285 -0
- package/scaffold/mcp/dist/core/telemetry/state-dir.js +31 -0
- package/scaffold/mcp/dist/core/validators/builtins.js +632 -0
- package/scaffold/mcp/dist/core/validators/config.js +44 -0
- package/scaffold/mcp/dist/core/validators/engine.js +120 -0
- package/scaffold/mcp/dist/core/validators/evaluators/code_comments.js +236 -0
- package/scaffold/mcp/dist/core/validators/evaluators/regex.js +116 -0
- package/scaffold/mcp/dist/core/workflow/artifact-io.js +103 -0
- package/scaffold/mcp/dist/core/workflow/capabilities.js +88 -0
- package/scaffold/mcp/dist/core/workflow/default-workflows.js +102 -0
- package/scaffold/mcp/dist/core/workflow/enforcement.js +164 -0
- package/scaffold/mcp/dist/core/workflow/envelope.js +132 -0
- package/scaffold/mcp/dist/core/workflow/index.js +11 -0
- package/scaffold/mcp/dist/core/workflow/mcp-tools.js +175 -0
- package/scaffold/mcp/dist/core/workflow/resolution.js +69 -0
- package/scaffold/mcp/dist/core/workflow/run-lifecycle.js +123 -0
- package/scaffold/mcp/dist/core/workflow/schemas.js +141 -0
- package/scaffold/mcp/dist/core/workflow/synced-capability-registry.js +60 -0
- package/scaffold/mcp/dist/core/workflow/synced-registry.js +60 -0
- package/scaffold/mcp/dist/daemon/capability-sync-checker.js +242 -0
- package/scaffold/mcp/dist/daemon/client.js +130 -0
- package/scaffold/mcp/dist/daemon/egress-proxy.js +288 -0
- package/scaffold/mcp/dist/daemon/global-host-events.js +222 -0
- package/scaffold/mcp/dist/daemon/heartbeat-pusher.js +116 -0
- package/scaffold/mcp/dist/daemon/heartbeat-tracker.js +165 -0
- package/scaffold/mcp/dist/daemon/host-events-pusher.js +249 -0
- package/scaffold/mcp/dist/daemon/main.js +449 -0
- package/scaffold/mcp/dist/daemon/paths.js +36 -0
- package/scaffold/mcp/dist/daemon/project-service-registry.js +78 -0
- package/scaffold/mcp/dist/daemon/protocol.js +8 -0
- package/scaffold/mcp/dist/daemon/server.js +180 -0
- package/scaffold/mcp/dist/daemon/skill-sync-checker.js +557 -0
- package/scaffold/mcp/dist/daemon/sync-checker.js +165 -0
- package/scaffold/mcp/dist/daemon/ungoverned-scanner.js +136 -0
- package/scaffold/mcp/dist/daemon/workflow-sync-checker.js +249 -0
- package/scaffold/mcp/dist/defaults.js +6 -0
- package/scaffold/mcp/dist/embed.js +627 -0
- package/scaffold/mcp/dist/embedScheduler.js +479 -0
- package/scaffold/mcp/dist/embeddings.js +167 -0
- package/scaffold/mcp/dist/enterprise/audit/push.js +66 -0
- package/scaffold/mcp/dist/enterprise/index.js +327 -0
- package/scaffold/mcp/dist/enterprise/model/deploy.js +27 -0
- package/scaffold/mcp/dist/enterprise/policy/sync.js +129 -0
- package/scaffold/mcp/dist/enterprise/privacy/boundary.js +184 -0
- package/scaffold/mcp/dist/enterprise/reviews/changed-files.js +33 -0
- package/scaffold/mcp/dist/enterprise/reviews/pattern-context.js +202 -0
- package/scaffold/mcp/dist/enterprise/reviews/policy-selection.js +46 -0
- package/scaffold/mcp/dist/enterprise/reviews/push.js +102 -0
- package/scaffold/mcp/dist/enterprise/reviews/trust-state.js +186 -0
- package/scaffold/mcp/dist/enterprise/telemetry/sync.js +57 -0
- package/scaffold/mcp/dist/enterprise/tools/enterprise.js +826 -0
- package/scaffold/mcp/dist/enterprise/tools/harness.js +40 -0
- package/scaffold/mcp/dist/enterprise/tools/walk.js +73 -0
- package/scaffold/mcp/dist/enterprise/violations/push.js +77 -0
- package/scaffold/mcp/dist/enterprise/workflow/push.js +44 -0
- package/scaffold/mcp/dist/enterprise/workflow/state.js +329 -0
- package/scaffold/mcp/dist/frontmatter.js +33 -0
- package/scaffold/mcp/dist/graph.js +769 -0
- package/scaffold/mcp/dist/graphCsv.js +55 -0
- package/scaffold/mcp/dist/graphMetrics.js +8 -0
- package/scaffold/mcp/dist/hooks/permission-request.js +89 -0
- package/scaffold/mcp/dist/hooks/post-tool-use.js +105 -0
- package/scaffold/mcp/dist/hooks/pre-compact.js +29 -0
- package/scaffold/mcp/dist/hooks/pre-tool-use.js +78 -0
- package/scaffold/mcp/dist/hooks/session-end.js +43 -0
- package/scaffold/mcp/dist/hooks/session-start.js +41 -0
- package/scaffold/mcp/dist/hooks/shared.js +194 -0
- package/scaffold/mcp/dist/hooks/stop.js +28 -0
- package/scaffold/mcp/dist/hooks/user-prompt-submit.js +33 -0
- package/scaffold/mcp/dist/impactPresentation.js +137 -0
- package/scaffold/mcp/dist/impactRanking.js +191 -0
- package/scaffold/mcp/dist/impactResponse.js +30 -0
- package/scaffold/mcp/dist/impactResults.js +105 -0
- package/scaffold/mcp/dist/impactSeed.js +20 -0
- package/scaffold/mcp/dist/impactTraversal.js +64 -0
- package/scaffold/mcp/dist/jsonl.js +77 -0
- package/scaffold/mcp/dist/loadGraph.js +759 -0
- package/scaffold/mcp/dist/lruCache.js +38 -0
- package/scaffold/mcp/dist/paths.js +97 -0
- package/scaffold/mcp/dist/patternEvidence.js +272 -0
- package/scaffold/mcp/dist/plugin.js +81 -0
- package/scaffold/mcp/dist/presets.js +78 -0
- package/scaffold/mcp/dist/relatedResponse.js +18 -0
- package/scaffold/mcp/dist/relatedTraversal.js +78 -0
- package/scaffold/mcp/dist/rules.js +23 -0
- package/scaffold/mcp/dist/search.js +212 -0
- package/scaffold/mcp/dist/searchCore.js +457 -0
- package/scaffold/mcp/dist/searchResults.js +230 -0
- package/scaffold/mcp/dist/server.js +317 -0
- package/scaffold/mcp/dist/types.js +1 -0
- package/scaffold/mcp/package-lock.json +336 -212
- package/scaffold/mcp/package.json +15 -6
- package/scaffold/mcp/src/cli/enterprise-setup.ts +82 -8
- package/scaffold/mcp/src/cli/govern.ts +137 -52
- package/scaffold/mcp/src/cli/query.ts +36 -2
- package/scaffold/mcp/src/cli/run.ts +53 -19
- package/scaffold/mcp/src/cli/stage.ts +8 -3
- package/scaffold/mcp/src/core/config.ts +9 -2
- package/scaffold/mcp/src/core/enterprise-host-identity.ts +259 -0
- package/scaffold/mcp/src/core/enterprise-identity.ts +20 -0
- package/scaffold/mcp/src/core/enterprise-rotation.ts +185 -0
- package/scaffold/mcp/src/core/govern-paths.ts +30 -0
- package/scaffold/mcp/src/core/license.ts +186 -17
- package/scaffold/mcp/src/core/secure-endpoint.ts +23 -0
- package/scaffold/mcp/src/core/workflow/enforcement.ts +8 -1
- package/scaffold/mcp/src/core/workflow/mcp-tools.ts +3 -0
- package/scaffold/mcp/src/core/workflow/resolution.ts +9 -1
- package/scaffold/mcp/src/core/workflow/synced-capability-registry.ts +15 -0
- package/scaffold/mcp/src/core/workflow/synced-registry.ts +15 -0
- package/scaffold/mcp/src/daemon/capability-sync-checker.ts +38 -2
- package/scaffold/mcp/src/daemon/egress-proxy.ts +14 -8
- package/scaffold/mcp/src/daemon/global-host-events.ts +288 -0
- package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +4 -0
- package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +2 -0
- package/scaffold/mcp/src/daemon/host-events-pusher.ts +5 -0
- package/scaffold/mcp/src/daemon/main.ts +99 -25
- package/scaffold/mcp/src/daemon/project-service-registry.ts +107 -0
- package/scaffold/mcp/src/daemon/skill-sync-checker.ts +368 -31
- package/scaffold/mcp/src/daemon/sync-checker.ts +4 -0
- package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +57 -25
- package/scaffold/mcp/src/daemon/workflow-sync-checker.ts +41 -2
- package/scaffold/mcp/src/embed.ts +211 -10
- package/scaffold/mcp/src/enterprise/audit/push.ts +8 -0
- package/scaffold/mcp/src/enterprise/index.ts +1 -1
- package/scaffold/mcp/src/enterprise/policy/sync.ts +12 -0
- package/scaffold/mcp/src/enterprise/reviews/changed-files.ts +34 -0
- package/scaffold/mcp/src/enterprise/reviews/pattern-context.ts +231 -0
- package/scaffold/mcp/src/enterprise/reviews/push.ts +9 -0
- package/scaffold/mcp/src/enterprise/reviews/trust-state.ts +3 -1
- package/scaffold/mcp/src/enterprise/telemetry/sync.ts +9 -0
- package/scaffold/mcp/src/enterprise/tools/enterprise.ts +49 -36
- package/scaffold/mcp/src/enterprise/violations/push.ts +9 -0
- package/scaffold/mcp/src/enterprise/workflow/push.ts +7 -0
- package/scaffold/mcp/src/paths.ts +3 -5
- package/scaffold/mcp/src/patternEvidence.ts +347 -0
- package/scaffold/mcp/src/plugin.ts +20 -0
- package/scaffold/mcp/src/search.ts +35 -9
- package/scaffold/mcp/src/searchCore.ts +254 -12
- package/scaffold/mcp/src/searchResults.ts +96 -9
- package/scaffold/mcp/src/types.ts +7 -0
- package/scaffold/mcp/tests/changed-files.test.mjs +41 -0
- package/scaffold/mcp/tests/copilot-shim.test.mjs +19 -1
- package/scaffold/mcp/tests/egress-proxy.test.mjs +37 -0
- package/scaffold/mcp/tests/embed-entities.test.mjs +109 -1
- package/scaffold/mcp/tests/enterprise-identity-sync.test.mjs +401 -0
- package/scaffold/mcp/tests/enterprise-pattern-context.test.mjs +322 -0
- package/scaffold/mcp/tests/enterprise-setup.test.mjs +189 -0
- package/scaffold/mcp/tests/global-host-events.test.mjs +81 -0
- package/scaffold/mcp/tests/govern-install.test.mjs +95 -2
- package/scaffold/mcp/tests/govern-repair.test.mjs +20 -3
- package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +26 -0
- package/scaffold/mcp/tests/license.test.mjs +367 -0
- package/scaffold/mcp/tests/paths.test.mjs +11 -3
- package/scaffold/mcp/tests/pattern-evidence.test.mjs +321 -0
- package/scaffold/mcp/tests/project-service-registry.test.mjs +172 -0
- package/scaffold/mcp/tests/query-cli.test.mjs +73 -1
- package/scaffold/mcp/tests/review-trust-contract.test.mjs +18 -0
- package/scaffold/mcp/tests/search-graph-score.test.mjs +167 -0
- package/scaffold/mcp/tests/secure-enterprise-endpoint.test.mjs +46 -0
- package/scaffold/mcp/tests/skill-sync-checker.test.mjs +446 -2
- package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +104 -22
- package/scaffold/mcp/tests/workflow-cli.test.mjs +14 -1
- package/scaffold/mcp/tests/workflow-synced-capabilities.test.mjs +37 -0
- package/scaffold/mcp/tests/workflow-synced-registry.test.mjs +40 -1
- package/scaffold/scripts/dashboard.mjs +29 -18
- package/scaffold/scripts/doctor.sh +26 -8
- package/scaffold/scripts/ingest.mjs +37 -4
- package/scaffold/scripts/status.sh +13 -6
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
closeSync,
|
|
4
|
+
constants,
|
|
5
|
+
existsSync,
|
|
6
|
+
fchmodSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
realpathSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
unlinkSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
writeSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { loadEnterpriseConfig } from "../core/config.js";
|
|
22
|
+
import {
|
|
23
|
+
enterpriseCredentialId,
|
|
24
|
+
isAllowedLicenseEndpoint,
|
|
25
|
+
} from "../core/license.js";
|
|
26
|
+
import { matchesEnterpriseHostIdentity } from "../core/enterprise-host-identity.js";
|
|
27
|
+
|
|
28
|
+
type GlobalHostEvent = Record<string, unknown> & {
|
|
29
|
+
event_type: "ungoverned_ai_session_detected";
|
|
30
|
+
timestamp: string;
|
|
31
|
+
credential_id: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type GlobalCursor = {
|
|
35
|
+
version: 1;
|
|
36
|
+
credential_id: string;
|
|
37
|
+
ungoverned_last_key?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const EVENT_FILE_RE = /^ungoverned-\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
41
|
+
const CURSOR_FILENAME = "global-host-events-cursor.json";
|
|
42
|
+
|
|
43
|
+
function configuredHome(): string {
|
|
44
|
+
return process.env.HOME?.trim() || homedir();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function globalEventDir(create: boolean): string | null {
|
|
48
|
+
try {
|
|
49
|
+
const home = realpathSync(configuredHome());
|
|
50
|
+
const cortexDir = join(home, ".cortex");
|
|
51
|
+
if (!existsSync(cortexDir)) return null;
|
|
52
|
+
const cortexStat = lstatSync(cortexDir);
|
|
53
|
+
if (!cortexStat.isDirectory() || cortexStat.isSymbolicLink()) return null;
|
|
54
|
+
if (realpathSync(cortexDir) !== cortexDir) return null;
|
|
55
|
+
|
|
56
|
+
const dir = join(cortexDir, "host-events");
|
|
57
|
+
if (existsSync(dir)) {
|
|
58
|
+
const stat = lstatSync(dir);
|
|
59
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
|
|
60
|
+
if (realpathSync(dir) !== dir) return null;
|
|
61
|
+
chmodSync(dir, 0o700);
|
|
62
|
+
return dir;
|
|
63
|
+
}
|
|
64
|
+
if (!create) return null;
|
|
65
|
+
mkdirSync(dir, { mode: 0o700 });
|
|
66
|
+
chmodSync(dir, 0o700);
|
|
67
|
+
return dir;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function eventKey(event: GlobalHostEvent): string {
|
|
74
|
+
const pid =
|
|
75
|
+
typeof event.pid === "number" || typeof event.pid === "string"
|
|
76
|
+
? String(event.pid)
|
|
77
|
+
: "0";
|
|
78
|
+
return `${event.timestamp}#${pid}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function writeGlobalUngovernedEvent(
|
|
82
|
+
credentialId: string,
|
|
83
|
+
event: Omit<GlobalHostEvent, "credential_id">,
|
|
84
|
+
): boolean {
|
|
85
|
+
if (!matchesEnterpriseHostIdentity(credentialId)) return false;
|
|
86
|
+
const dir = globalEventDir(true);
|
|
87
|
+
if (!dir) return false;
|
|
88
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
89
|
+
const path = join(dir, `ungoverned-${date}.jsonl`);
|
|
90
|
+
let fd: number | null = null;
|
|
91
|
+
try {
|
|
92
|
+
fd = openSync(
|
|
93
|
+
path,
|
|
94
|
+
constants.O_WRONLY |
|
|
95
|
+
constants.O_APPEND |
|
|
96
|
+
constants.O_CREAT |
|
|
97
|
+
constants.O_NOFOLLOW,
|
|
98
|
+
0o600,
|
|
99
|
+
);
|
|
100
|
+
fchmodSync(fd, 0o600);
|
|
101
|
+
writeSync(
|
|
102
|
+
fd,
|
|
103
|
+
JSON.stringify({ ...event, credential_id: credentialId }) + "\n",
|
|
104
|
+
undefined,
|
|
105
|
+
"utf8",
|
|
106
|
+
);
|
|
107
|
+
closeSync(fd);
|
|
108
|
+
return true;
|
|
109
|
+
} catch {
|
|
110
|
+
if (fd !== null) {
|
|
111
|
+
try {
|
|
112
|
+
closeSync(fd);
|
|
113
|
+
} catch {
|
|
114
|
+
// Best effort.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readCursor(dir: string, credentialId: string): GlobalCursor {
|
|
122
|
+
const path = join(dir, CURSOR_FILENAME);
|
|
123
|
+
try {
|
|
124
|
+
const stat = lstatSync(path);
|
|
125
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
126
|
+
return { version: 1, credential_id: credentialId };
|
|
127
|
+
}
|
|
128
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<GlobalCursor>;
|
|
129
|
+
if (
|
|
130
|
+
parsed.version !== 1 ||
|
|
131
|
+
parsed.credential_id !== credentialId ||
|
|
132
|
+
(parsed.ungoverned_last_key !== undefined &&
|
|
133
|
+
typeof parsed.ungoverned_last_key !== "string")
|
|
134
|
+
) {
|
|
135
|
+
return { version: 1, credential_id: credentialId };
|
|
136
|
+
}
|
|
137
|
+
return parsed as GlobalCursor;
|
|
138
|
+
} catch {
|
|
139
|
+
return { version: 1, credential_id: credentialId };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function writeCursor(dir: string, cursor: GlobalCursor): void {
|
|
144
|
+
const path = join(dir, CURSOR_FILENAME);
|
|
145
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
146
|
+
try {
|
|
147
|
+
writeFileSync(
|
|
148
|
+
temporaryPath,
|
|
149
|
+
JSON.stringify(cursor, null, 2) + "\n",
|
|
150
|
+
{ encoding: "utf8", mode: 0o600, flag: "wx" },
|
|
151
|
+
);
|
|
152
|
+
chmodSync(temporaryPath, 0o600);
|
|
153
|
+
renameSync(temporaryPath, path);
|
|
154
|
+
chmodSync(path, 0o600);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
try {
|
|
157
|
+
unlinkSync(temporaryPath);
|
|
158
|
+
} catch {
|
|
159
|
+
// Best effort.
|
|
160
|
+
}
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readEvents(dir: string, credentialId: string): GlobalHostEvent[] {
|
|
166
|
+
const events: GlobalHostEvent[] = [];
|
|
167
|
+
for (const name of readdirSync(dir).filter((entry) => EVENT_FILE_RE.test(entry)).sort()) {
|
|
168
|
+
const path = join(dir, name);
|
|
169
|
+
try {
|
|
170
|
+
const stat = lstatSync(path);
|
|
171
|
+
if (!stat.isFile() || stat.isSymbolicLink()) continue;
|
|
172
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
173
|
+
if (!line.trim()) continue;
|
|
174
|
+
const parsed = JSON.parse(line) as Partial<GlobalHostEvent>;
|
|
175
|
+
if (
|
|
176
|
+
parsed.event_type !== "ungoverned_ai_session_detected" ||
|
|
177
|
+
parsed.credential_id !== credentialId ||
|
|
178
|
+
typeof parsed.timestamp !== "string"
|
|
179
|
+
) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
events.push(parsed as GlobalHostEvent);
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
// Skip malformed or concurrently changing files.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return events.sort((a, b) => eventKey(a).localeCompare(eventKey(b)));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function pushGlobalUngovernedEvents(
|
|
192
|
+
cwd: string,
|
|
193
|
+
credentialId: string,
|
|
194
|
+
): Promise<{ pushed: number; error?: string }> {
|
|
195
|
+
if (!matchesEnterpriseHostIdentity(credentialId)) {
|
|
196
|
+
return { pushed: 0, error: "enterprise identity conflict" };
|
|
197
|
+
}
|
|
198
|
+
const config = loadEnterpriseConfig(join(cwd, ".context"));
|
|
199
|
+
const apiKey = config.enterprise.api_key.trim();
|
|
200
|
+
const baseUrl = (
|
|
201
|
+
config.enterprise.base_url || config.enterprise.endpoint
|
|
202
|
+
).trim();
|
|
203
|
+
if (
|
|
204
|
+
!apiKey ||
|
|
205
|
+
!baseUrl ||
|
|
206
|
+
!isAllowedLicenseEndpoint(baseUrl) ||
|
|
207
|
+
enterpriseCredentialId(baseUrl, apiKey) !== credentialId
|
|
208
|
+
) {
|
|
209
|
+
return { pushed: 0, error: "enterprise identity conflict" };
|
|
210
|
+
}
|
|
211
|
+
const dir = globalEventDir(false);
|
|
212
|
+
if (!dir) return { pushed: 0 };
|
|
213
|
+
const cursor = readCursor(dir, credentialId);
|
|
214
|
+
const events = readEvents(dir, credentialId).filter(
|
|
215
|
+
(event) =>
|
|
216
|
+
!cursor.ungoverned_last_key ||
|
|
217
|
+
eventKey(event) > cursor.ungoverned_last_key,
|
|
218
|
+
);
|
|
219
|
+
if (events.length === 0) return { pushed: 0 };
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const response = await fetch(
|
|
223
|
+
`${baseUrl.replace(/\/$/, "")}/api/v1/govern/ungoverned`,
|
|
224
|
+
{
|
|
225
|
+
method: "POST",
|
|
226
|
+
headers: {
|
|
227
|
+
"Content-Type": "application/json",
|
|
228
|
+
Authorization: `Bearer ${apiKey}`,
|
|
229
|
+
},
|
|
230
|
+
body: JSON.stringify({
|
|
231
|
+
events: events.map((event) => ({
|
|
232
|
+
detected_at: event.timestamp,
|
|
233
|
+
host_id: event.host_id,
|
|
234
|
+
cli: event.cli,
|
|
235
|
+
binary_path: event.binary,
|
|
236
|
+
args: event.args,
|
|
237
|
+
sys_user: event.user,
|
|
238
|
+
parent_pid: event.ppid,
|
|
239
|
+
pid: event.pid,
|
|
240
|
+
action_taken: event.action ?? "logged",
|
|
241
|
+
})),
|
|
242
|
+
}),
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
if (!response.ok) {
|
|
246
|
+
return {
|
|
247
|
+
pushed: 0,
|
|
248
|
+
error: `HTTP ${response.status} ${response.statusText}`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
cursor.ungoverned_last_key = eventKey(events[events.length - 1]);
|
|
252
|
+
writeCursor(dir, cursor);
|
|
253
|
+
return { pushed: events.length };
|
|
254
|
+
} catch (err) {
|
|
255
|
+
return {
|
|
256
|
+
pushed: 0,
|
|
257
|
+
error: err instanceof Error ? err.message : String(err),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export type GlobalHostEventsPusherHandle = {
|
|
263
|
+
stop(): void;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
export function startGlobalUngovernedEventsPusher(
|
|
267
|
+
cwd: string,
|
|
268
|
+
credentialId: string,
|
|
269
|
+
intervalMs: number,
|
|
270
|
+
): GlobalHostEventsPusherHandle {
|
|
271
|
+
const tick = () => {
|
|
272
|
+
void pushGlobalUngovernedEvents(cwd, credentialId).then((outcome) => {
|
|
273
|
+
if (outcome.error) {
|
|
274
|
+
process.stderr.write(
|
|
275
|
+
`[cortex-daemon] global host-events push failed: ${outcome.error}\n`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
void Promise.resolve().then(tick);
|
|
281
|
+
const handle = setInterval(tick, intervalMs);
|
|
282
|
+
if (typeof handle.unref === "function") handle.unref();
|
|
283
|
+
return {
|
|
284
|
+
stop() {
|
|
285
|
+
clearInterval(handle);
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { hostname, platform, release } from "node:os";
|
|
4
4
|
import { loadEnterpriseConfig } from "../core/config.js";
|
|
5
|
+
import { isAllowedEnterpriseEndpoint } from "../core/secure-endpoint.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Govern host heartbeat — fills the host_enrollment table on cortex-web.
|
|
@@ -107,6 +108,9 @@ export async function pushHeartbeat(cwd: string): Promise<HeartbeatPushOutcome>
|
|
|
107
108
|
if (!apiKey || !baseUrl) {
|
|
108
109
|
return { ok: false, error: "enterprise not configured" };
|
|
109
110
|
}
|
|
111
|
+
if (!isAllowedEnterpriseEndpoint(baseUrl)) {
|
|
112
|
+
return { ok: false, error: "insecure or invalid enterprise endpoint" };
|
|
113
|
+
}
|
|
110
114
|
const payload = buildHeartbeatPayload(cwd);
|
|
111
115
|
try {
|
|
112
116
|
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/v1/govern/heartbeat`, {
|
|
@@ -130,6 +130,7 @@ export class HeartbeatTracker {
|
|
|
130
130
|
const now = options.now ?? new Date();
|
|
131
131
|
const thresholdMs = options.missingThresholdSeconds * 1000;
|
|
132
132
|
const flagged: TamperLockEntry[] = [];
|
|
133
|
+
const allowedCwds = new Set(options.cwds);
|
|
133
134
|
|
|
134
135
|
for (const [sessionId, state] of this.sessions) {
|
|
135
136
|
// Cleanup sessions that have been silent forever — they crashed.
|
|
@@ -140,6 +141,7 @@ export class HeartbeatTracker {
|
|
|
140
141
|
}
|
|
141
142
|
|
|
142
143
|
if (state.ended) continue;
|
|
144
|
+
if (!allowedCwds.has(state.cwd)) continue;
|
|
143
145
|
// Need at least 2 heartbeats for the "had-activity-then-silence"
|
|
144
146
|
// signal — a single SessionStart followed by silence may just be a
|
|
145
147
|
// user opening Claude and walking away.
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "node:fs";
|
|
9
9
|
import { join, dirname } from "node:path";
|
|
10
10
|
import { loadEnterpriseConfig } from "../core/config.js";
|
|
11
|
+
import { isAllowedEnterpriseEndpoint } from "../core/secure-endpoint.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Phase 7 sync flow — host-events pusher.
|
|
@@ -150,6 +151,10 @@ export async function pushHostEvents(cwd: string): Promise<PushOutcome> {
|
|
|
150
151
|
outcome.errors.push("enterprise not configured");
|
|
151
152
|
return outcome;
|
|
152
153
|
}
|
|
154
|
+
if (!isAllowedEnterpriseEndpoint(baseUrl)) {
|
|
155
|
+
outcome.errors.push("insecure or invalid enterprise endpoint");
|
|
156
|
+
return outcome;
|
|
157
|
+
}
|
|
153
158
|
|
|
154
159
|
const cursor = readCursor(cwd);
|
|
155
160
|
const ungovernedCursor = normalizeCursor(cursor.ungoverned_last_ts);
|
|
@@ -11,6 +11,9 @@ import type {
|
|
|
11
11
|
AuditLogResult,
|
|
12
12
|
} from "./protocol.js";
|
|
13
13
|
import { loadEnterpriseConfig, resolveEnterpriseActivation } from "../core/config.js";
|
|
14
|
+
import { configuredEnterpriseCredentialId } from "../core/enterprise-identity.js";
|
|
15
|
+
import { matchesEnterpriseHostIdentity } from "../core/enterprise-host-identity.js";
|
|
16
|
+
import { startGlobalUngovernedEventsPusher } from "./global-host-events.js";
|
|
14
17
|
import { pushMetrics } from "../enterprise/telemetry/sync.js";
|
|
15
18
|
import { TelemetryCollector, type TelemetryMetrics } from "../core/telemetry/collector.js";
|
|
16
19
|
import { resolveTelemetryStateDir, telemetryStatePath } from "../core/telemetry/state-dir.js";
|
|
@@ -34,6 +37,10 @@ import { startCapabilitySyncTimer } from "./capability-sync-checker.js";
|
|
|
34
37
|
import { startHostEventsPusher } from "./host-events-pusher.js";
|
|
35
38
|
import { startEgressProxy } from "./egress-proxy.js";
|
|
36
39
|
import { startHeartbeatPusher } from "./heartbeat-pusher.js";
|
|
40
|
+
import {
|
|
41
|
+
normalizeProjectCwd,
|
|
42
|
+
ProjectServiceRegistry,
|
|
43
|
+
} from "./project-service-registry.js";
|
|
37
44
|
import type { HeartbeatPayload, HeartbeatResult } from "./protocol.js";
|
|
38
45
|
|
|
39
46
|
/**
|
|
@@ -302,8 +309,37 @@ async function auditLog(payload: AuditLogPayload): Promise<AuditLogResult> {
|
|
|
302
309
|
async function main(): Promise<void> {
|
|
303
310
|
// Phase 6: hook heartbeat tracker (per-session activity record + tamper detect).
|
|
304
311
|
const tracker = new HeartbeatTracker();
|
|
312
|
+
let projectServices: ProjectServiceRegistry | null = null;
|
|
313
|
+
function registerEnterpriseProject(cwd: string): boolean {
|
|
314
|
+
const config = loadEnterpriseConfig(join(cwd, ".context"));
|
|
315
|
+
if (!resolveEnterpriseActivation(config).active) return true;
|
|
316
|
+
if (projectServices?.has(cwd)) return true;
|
|
317
|
+
const credentialId = configuredEnterpriseCredentialId(cwd);
|
|
318
|
+
if (!credentialId || !matchesEnterpriseHostIdentity(credentialId)) {
|
|
319
|
+
process.stderr.write(
|
|
320
|
+
`[cortex-daemon] rejected Enterprise project ${cwd}: ` +
|
|
321
|
+
"this user profile is enrolled to another endpoint or API key\n",
|
|
322
|
+
);
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
if (!projectServices?.register(cwd)) {
|
|
326
|
+
process.stderr.write(
|
|
327
|
+
`[cortex-daemon] rejected Enterprise project ${cwd}: ` +
|
|
328
|
+
"this user daemon is already bound to another endpoint or API key\n",
|
|
329
|
+
);
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
305
334
|
async function heartbeat(payload: HeartbeatPayload): Promise<HeartbeatResult> {
|
|
306
|
-
|
|
335
|
+
const cwd = normalizeProjectCwd(payload.cwd);
|
|
336
|
+
if (!cwd) {
|
|
337
|
+
return { recorded: false, tamper_lock_active: false };
|
|
338
|
+
}
|
|
339
|
+
if (!registerEnterpriseProject(cwd)) {
|
|
340
|
+
return { recorded: false, tamper_lock_active: false };
|
|
341
|
+
}
|
|
342
|
+
return tracker.recordHeartbeat({ ...payload, cwd });
|
|
307
343
|
}
|
|
308
344
|
|
|
309
345
|
const daemon = new CortexDaemon({
|
|
@@ -312,15 +348,11 @@ async function main(): Promise<void> {
|
|
|
312
348
|
onAuditLog: auditLog,
|
|
313
349
|
onHeartbeat: heartbeat,
|
|
314
350
|
});
|
|
315
|
-
await daemon.start();
|
|
316
351
|
|
|
317
352
|
// Phase 5: Tier 3 ungoverned-session detection. Periodic process scan, audit
|
|
318
353
|
// emit per finding, optional SIGTERM in enforced mode (same-user only).
|
|
319
354
|
const scanInterval = parseInt(process.env.CORTEX_UNGOVERNED_SCAN_MS ?? "", 10);
|
|
320
355
|
const intervalMs = Number.isFinite(scanInterval) && scanInterval > 0 ? scanInterval : 60_000;
|
|
321
|
-
if (process.env.CORTEX_DISABLE_UNGOVERNED_SCAN !== "1") {
|
|
322
|
-
startUngovernedScanner({ cwd: process.cwd(), intervalMs });
|
|
323
|
-
}
|
|
324
356
|
|
|
325
357
|
// Phase 6: periodic tamper-checker. For each active session that had at
|
|
326
358
|
// least one tool-fired hook then went silent past missing_threshold_seconds,
|
|
@@ -343,12 +375,7 @@ async function main(): Promise<void> {
|
|
|
343
375
|
const pushIntervalRaw = parseInt(process.env.CORTEX_HOST_EVENTS_PUSH_MS ?? "", 10);
|
|
344
376
|
const pushIntervalMs =
|
|
345
377
|
Number.isFinite(pushIntervalRaw) && pushIntervalRaw > 0 ? pushIntervalRaw : 5 * 60 * 1000;
|
|
346
|
-
|
|
347
|
-
startSyncTimer(process.cwd(), syncIntervalMs);
|
|
348
|
-
}
|
|
349
|
-
if (process.env.CORTEX_DISABLE_HOST_EVENTS_PUSH !== "1") {
|
|
350
|
-
startHostEventsPusher(process.cwd(), pushIntervalMs);
|
|
351
|
-
}
|
|
378
|
+
|
|
352
379
|
// Skills v3: poll cortex-web for org-authored skills, write SKILL.md
|
|
353
380
|
// files into per-CLI user-scope directories. Runs at the same cadence
|
|
354
381
|
// as the govern-config sync check by default but is independently
|
|
@@ -356,9 +383,6 @@ async function main(): Promise<void> {
|
|
|
356
383
|
const skillSyncRaw = parseInt(process.env.CORTEX_SKILL_SYNC_MS ?? "", 10);
|
|
357
384
|
const skillSyncMs =
|
|
358
385
|
Number.isFinite(skillSyncRaw) && skillSyncRaw > 0 ? skillSyncRaw : syncIntervalMs;
|
|
359
|
-
if (process.env.CORTEX_DISABLE_SKILL_SYNC !== "1") {
|
|
360
|
-
startSkillSyncTimer(process.cwd(), skillSyncMs);
|
|
361
|
-
}
|
|
362
386
|
|
|
363
387
|
// Harness Phase 2: poll cortex-web for org-authored workflows, cache
|
|
364
388
|
// their definitions locally so cortex.workflow.start can resolve
|
|
@@ -370,9 +394,6 @@ async function main(): Promise<void> {
|
|
|
370
394
|
Number.isFinite(workflowSyncRaw) && workflowSyncRaw > 0
|
|
371
395
|
? workflowSyncRaw
|
|
372
396
|
: skillSyncMs;
|
|
373
|
-
if (process.env.CORTEX_DISABLE_WORKFLOW_SYNC !== "1") {
|
|
374
|
-
startWorkflowSyncTimer(process.cwd(), workflowSyncMs);
|
|
375
|
-
}
|
|
376
397
|
|
|
377
398
|
// Harness Phase 2: poll cortex-web for org-authored capabilities and
|
|
378
399
|
// cache definitions locally so evaluateToolCall can merge them over
|
|
@@ -384,18 +405,68 @@ async function main(): Promise<void> {
|
|
|
384
405
|
Number.isFinite(capabilitySyncRaw) && capabilitySyncRaw > 0
|
|
385
406
|
? capabilitySyncRaw
|
|
386
407
|
: workflowSyncMs;
|
|
387
|
-
if (process.env.CORTEX_DISABLE_CAPABILITY_SYNC !== "1") {
|
|
388
|
-
startCapabilitySyncTimer(process.cwd(), capabilitySyncMs);
|
|
389
|
-
}
|
|
390
408
|
|
|
391
409
|
// Govern host heartbeat — fills host_enrollment on cortex-web so the
|
|
392
410
|
// dashboard at /dashboard/govern actually shows this host.
|
|
393
411
|
const heartbeatRaw = parseInt(process.env.CORTEX_HEARTBEAT_PUSH_MS ?? "", 10);
|
|
394
412
|
const heartbeatMs =
|
|
395
413
|
Number.isFinite(heartbeatRaw) && heartbeatRaw > 0 ? heartbeatRaw : 5 * 60 * 1000;
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
414
|
+
|
|
415
|
+
projectServices = new ProjectServiceRegistry((cwd) => {
|
|
416
|
+
const handles = [];
|
|
417
|
+
if (process.env.CORTEX_DISABLE_SYNC_CHECK !== "1") {
|
|
418
|
+
handles.push(startSyncTimer(cwd, syncIntervalMs));
|
|
419
|
+
}
|
|
420
|
+
if (process.env.CORTEX_DISABLE_HOST_EVENTS_PUSH !== "1") {
|
|
421
|
+
handles.push(startHostEventsPusher(cwd, pushIntervalMs));
|
|
422
|
+
}
|
|
423
|
+
return handles;
|
|
424
|
+
}, {
|
|
425
|
+
credentialIdForCwd: configuredEnterpriseCredentialId,
|
|
426
|
+
hostFactory: (cwd, credentialId) => {
|
|
427
|
+
const handles = [];
|
|
428
|
+
// These services operate on user-global process or artifact state.
|
|
429
|
+
// One explicit host governance project owns them; projects carrying
|
|
430
|
+
// another Enterprise identity are rejected by the registry.
|
|
431
|
+
if (process.env.CORTEX_DISABLE_UNGOVERNED_SCAN !== "1") {
|
|
432
|
+
// Process snapshots do not carry a trustworthy project cwd. Keep the
|
|
433
|
+
// single host-wide scanner detection-only so an enforced project can
|
|
434
|
+
// never terminate a process belonging to an advisory project.
|
|
435
|
+
handles.push(startUngovernedScanner({
|
|
436
|
+
cwd,
|
|
437
|
+
credentialId,
|
|
438
|
+
intervalMs,
|
|
439
|
+
mode: "advisory",
|
|
440
|
+
}));
|
|
441
|
+
}
|
|
442
|
+
if (process.env.CORTEX_DISABLE_HOST_EVENTS_PUSH !== "1") {
|
|
443
|
+
handles.push(
|
|
444
|
+
startGlobalUngovernedEventsPusher(
|
|
445
|
+
cwd,
|
|
446
|
+
credentialId,
|
|
447
|
+
pushIntervalMs,
|
|
448
|
+
),
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
if (process.env.CORTEX_DISABLE_SKILL_SYNC !== "1") {
|
|
452
|
+
handles.push(startSkillSyncTimer(cwd, skillSyncMs));
|
|
453
|
+
}
|
|
454
|
+
if (process.env.CORTEX_DISABLE_WORKFLOW_SYNC !== "1") {
|
|
455
|
+
handles.push(startWorkflowSyncTimer(cwd, workflowSyncMs));
|
|
456
|
+
}
|
|
457
|
+
if (process.env.CORTEX_DISABLE_CAPABILITY_SYNC !== "1") {
|
|
458
|
+
handles.push(startCapabilitySyncTimer(cwd, capabilitySyncMs));
|
|
459
|
+
}
|
|
460
|
+
if (process.env.CORTEX_DISABLE_HEARTBEAT_PUSH !== "1") {
|
|
461
|
+
handles.push(startHeartbeatPusher(cwd, heartbeatMs));
|
|
462
|
+
}
|
|
463
|
+
return handles;
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
await daemon.start();
|
|
468
|
+
const startupCwd = normalizeProjectCwd(process.cwd());
|
|
469
|
+
if (startupCwd) registerEnterpriseProject(startupCwd);
|
|
399
470
|
|
|
400
471
|
// Phase 4 task 19: cortex egress proxy. Logs SNI + destination per
|
|
401
472
|
// outbound connection (no TLS termination). cortex run sets
|
|
@@ -404,7 +475,10 @@ async function main(): Promise<void> {
|
|
|
404
475
|
const proxyPortRaw = parseInt(process.env.CORTEX_EGRESS_PROXY_PORT ?? "", 10);
|
|
405
476
|
const proxyPort = Number.isFinite(proxyPortRaw) && proxyPortRaw > 0 ? proxyPortRaw : 18888;
|
|
406
477
|
if (process.env.CORTEX_DISABLE_EGRESS_PROXY !== "1") {
|
|
407
|
-
|
|
478
|
+
// A shared proxy connection has no trustworthy project identity. Keep
|
|
479
|
+
// transport support, but suppress project audit emission until callers
|
|
480
|
+
// use an attributable per-project channel.
|
|
481
|
+
startEgressProxy({ port: proxyPort })
|
|
408
482
|
.then((handle) => {
|
|
409
483
|
process.stderr.write(
|
|
410
484
|
`[cortex-daemon] egress proxy listening on 127.0.0.1:${handle.port}\n`,
|
|
@@ -447,7 +521,7 @@ async function main(): Promise<void> {
|
|
|
447
521
|
if (process.env.CORTEX_DISABLE_TAMPER_CHECK !== "1") {
|
|
448
522
|
const checkTimer = setInterval(() => {
|
|
449
523
|
const detected = tracker.detectTamper({
|
|
450
|
-
cwds:
|
|
524
|
+
cwds: projectServices?.projectCwds() ?? [],
|
|
451
525
|
missingThresholdSeconds,
|
|
452
526
|
});
|
|
453
527
|
for (const entry of detected) {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type ProjectServiceHandle = {
|
|
5
|
+
stop(): void | Promise<void>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type ProjectServiceFactory = (
|
|
9
|
+
cwd: string,
|
|
10
|
+
) => ReadonlyArray<ProjectServiceHandle>;
|
|
11
|
+
|
|
12
|
+
export type HostServiceFactory = (
|
|
13
|
+
cwd: string,
|
|
14
|
+
credentialId: string,
|
|
15
|
+
) => ReadonlyArray<ProjectServiceHandle>;
|
|
16
|
+
|
|
17
|
+
export type ProjectServiceRegistryOptions = {
|
|
18
|
+
credentialIdForCwd?: (cwd: string) => string | null;
|
|
19
|
+
hostFactory?: HostServiceFactory;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function normalizeProjectCwd(cwd: string): string | null {
|
|
23
|
+
if (!cwd.trim()) return null;
|
|
24
|
+
const candidate = resolve(cwd);
|
|
25
|
+
try {
|
|
26
|
+
if (!existsSync(candidate) || !statSync(candidate).isDirectory()) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
if (!existsSync(join(candidate, ".context"))) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return realpathSync(candidate);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Owns background services for every project observed by the per-user daemon.
|
|
40
|
+
* Request-time handlers already carry cwd in their payloads; this registry
|
|
41
|
+
* gives the periodic services the same project isolation.
|
|
42
|
+
*/
|
|
43
|
+
export class ProjectServiceRegistry {
|
|
44
|
+
private readonly projects = new Map<
|
|
45
|
+
string,
|
|
46
|
+
ReadonlyArray<ProjectServiceHandle>
|
|
47
|
+
>();
|
|
48
|
+
private hostCredentialId: string | null = null;
|
|
49
|
+
private hostHandles: ReadonlyArray<ProjectServiceHandle> = [];
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
private readonly factory: ProjectServiceFactory,
|
|
53
|
+
private readonly options: ProjectServiceRegistryOptions = {},
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
register(cwd: string): boolean {
|
|
57
|
+
const normalized = normalizeProjectCwd(cwd);
|
|
58
|
+
if (!normalized || this.projects.has(normalized)) return false;
|
|
59
|
+
const credentialId =
|
|
60
|
+
this.options.credentialIdForCwd?.(normalized) ?? "project-local";
|
|
61
|
+
if (!credentialId) return false;
|
|
62
|
+
if (
|
|
63
|
+
this.hostCredentialId &&
|
|
64
|
+
this.hostCredentialId !== credentialId
|
|
65
|
+
) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
if (!this.hostCredentialId) {
|
|
69
|
+
this.hostCredentialId = credentialId;
|
|
70
|
+
this.hostHandles =
|
|
71
|
+
this.options.hostFactory?.(normalized, credentialId) ?? [];
|
|
72
|
+
}
|
|
73
|
+
const handles = this.factory(normalized);
|
|
74
|
+
this.projects.set(normalized, handles);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
has(cwd: string): boolean {
|
|
79
|
+
const normalized = normalizeProjectCwd(cwd);
|
|
80
|
+
return normalized ? this.projects.has(normalized) : false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
projectCwds(): string[] {
|
|
84
|
+
return Array.from(this.projects.keys());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async stopAll(): Promise<void> {
|
|
88
|
+
const handles = [
|
|
89
|
+
...Array.from(this.projects.values()).flat(),
|
|
90
|
+
...this.hostHandles,
|
|
91
|
+
];
|
|
92
|
+
this.projects.clear();
|
|
93
|
+
this.hostHandles = [];
|
|
94
|
+
this.hostCredentialId = null;
|
|
95
|
+
await Promise.allSettled(
|
|
96
|
+
handles.map((handle) => Promise.resolve(handle.stop())),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
size(): number {
|
|
101
|
+
return this.projects.size;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
activeCredentialId(): string | null {
|
|
105
|
+
return this.hostCredentialId;
|
|
106
|
+
}
|
|
107
|
+
}
|