@kontextmind/kxm 0.7.165 → 0.7.166
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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +16 -0
- package/docs/concepts/trust-model.md +1 -1
- package/docs/glossary.md +2 -2
- package/docs/operations/troubleshooting.md +2 -2
- package/docs/operations.md +62 -8
- package/docs/reference/cli-reference.md +11 -11
- package/docs/reference/config-reference.md +36 -11
- package/docs/reference/configuration.md +2 -2
- package/docs/reference/tools.md +1 -1
- package/docs/start/quickstart-claude-code.md +2 -2
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/README.md +3 -3
- package/plugins/kxm/dist/claude-hook.js +417 -66
- package/plugins/kxm/dist/cli.js +1399 -900
- package/plugins/kxm/dist/extension.js +399 -64
- package/plugins/kxm/dist/mcp-server.js +622 -135
- package/plugins/kxm/dist/runtime-supervisor.js +668 -166
- package/plugins/kxm/dist/runtime.js +713 -211
- package/plugins/kxm/dist/server.js +466 -58
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +15 -7
- package/plugins/kxm/src/cli/hub.ts +80 -21
- package/plugins/kxm/src/cli/types.ts +8 -1
- package/plugins/kxm/src/cli.ts +41 -7
- package/plugins/kxm/src/config.ts +246 -3
- package/plugins/kxm/src/extension.ts +8 -7
- package/plugins/kxm/src/hub-autostart.ts +20 -0
- package/plugins/kxm/src/hub-env.ts +83 -31
- package/plugins/kxm/src/hub-identity.ts +337 -0
- package/plugins/kxm/src/mcp-server.ts +17 -15
- package/plugins/kxm/src/project-name.ts +6 -47
- package/plugins/kxm/src/server.ts +22 -4
- package/scripts/kxm-hub.mjs +5 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
import {
|
|
6
|
+
type HubEndpointConfig,
|
|
7
|
+
type HubKeyRef,
|
|
8
|
+
type KxmHubMode,
|
|
9
|
+
isHubOpReference,
|
|
10
|
+
loadHubSettings,
|
|
11
|
+
} from "./config.ts";
|
|
12
|
+
import {
|
|
13
|
+
CloudTokenError,
|
|
14
|
+
type HubBindingRecord,
|
|
15
|
+
type HubBindingScope,
|
|
16
|
+
HubBindingError,
|
|
17
|
+
hubBindingScope,
|
|
18
|
+
readHubBinding,
|
|
19
|
+
splitTokenCommand,
|
|
20
|
+
validateHubUrl,
|
|
21
|
+
} from "./hub-binding.ts";
|
|
22
|
+
|
|
23
|
+
/** Where the project id presented to the hub came from. */
|
|
24
|
+
export type ProjectIdentitySource = "flag" | "env" | "config" | "project.yaml" | "package.json" | "directory";
|
|
25
|
+
|
|
26
|
+
export interface ProjectIdentity {
|
|
27
|
+
project: string;
|
|
28
|
+
source: ProjectIdentitySource;
|
|
29
|
+
/** Safe to print. Names the file or variable, never a secret. */
|
|
30
|
+
sourceLabel: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type OpReader = (reference: string, env: NodeJS.ProcessEnv) => string;
|
|
34
|
+
|
|
35
|
+
export interface ResolvedKey {
|
|
36
|
+
value: string;
|
|
37
|
+
/** `env:<name>` or `op:<op://...>`. Never the token. */
|
|
38
|
+
source: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface HubConnection {
|
|
42
|
+
mode: KxmHubMode;
|
|
43
|
+
modeSource: "config" | "binding" | "default";
|
|
44
|
+
url: string;
|
|
45
|
+
urlSource: "env" | "config" | "binding" | "default";
|
|
46
|
+
scope: HubBindingScope;
|
|
47
|
+
endpoint?: HubEndpointConfig;
|
|
48
|
+
binding?: HubBindingRecord;
|
|
49
|
+
/** Source that would supply the key. Never the key. */
|
|
50
|
+
keySource: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const OP_TIMEOUT_MS = 15_000;
|
|
54
|
+
const OP_MAX_BUFFER = 4096;
|
|
55
|
+
|
|
56
|
+
function normalizeUrl(raw: string): string | undefined {
|
|
57
|
+
try {
|
|
58
|
+
return validateHubUrl(raw);
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function projectYamlId(start: string): string | undefined {
|
|
65
|
+
let dir = resolve(start);
|
|
66
|
+
for (;;) {
|
|
67
|
+
const file = join(dir, ".kxm", "project.yaml");
|
|
68
|
+
if (existsSync(file)) {
|
|
69
|
+
try {
|
|
70
|
+
const doc = parseYaml(readFileSync(file, "utf8")) as { id?: unknown } | null;
|
|
71
|
+
if (doc && typeof doc === "object" && !Array.isArray(doc) && typeof doc.id === "string" && doc.id.trim()) {
|
|
72
|
+
return doc.id.trim();
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Malformed YAML falls through to package.json of the original directory.
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const parent = dirname(dir);
|
|
80
|
+
if (parent === dir) return undefined;
|
|
81
|
+
dir = parent;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function packageName(cwd: string): string | undefined {
|
|
86
|
+
try {
|
|
87
|
+
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")) as { name?: unknown };
|
|
88
|
+
if (typeof pkg.name === "string" && pkg.name.trim().length > 0) return pkg.name.trim();
|
|
89
|
+
} catch {
|
|
90
|
+
// No readable package.json: fall through to the directory name.
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function activeBinding(env: NodeJS.ProcessEnv): HubBindingRecord | undefined {
|
|
96
|
+
try {
|
|
97
|
+
return readHubBinding(env);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error instanceof HubBindingError) return undefined;
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function endpointFor(mode: KxmHubMode, settings: ReturnType<typeof loadHubSettings>): HubEndpointConfig | undefined {
|
|
105
|
+
return mode === "cloud" ? settings.cloud : settings.local;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Project id presented to the hub.
|
|
110
|
+
*
|
|
111
|
+
* Order: flag, then `KXM_PROJECT`, then the active mode's `project` in
|
|
112
|
+
* `.kxm/config.yaml`, then the `id` in the nearest `.kxm/project.yaml`, then
|
|
113
|
+
* the workspace `package.json` name, then the directory name.
|
|
114
|
+
*/
|
|
115
|
+
export function resolveProjectIdentity(
|
|
116
|
+
cwd: string,
|
|
117
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
118
|
+
explicit?: string,
|
|
119
|
+
): ProjectIdentity {
|
|
120
|
+
const fromExplicit = explicit?.trim();
|
|
121
|
+
if (fromExplicit) return { project: fromExplicit, source: "flag", sourceLabel: "--project" };
|
|
122
|
+
const fromEnv = env.KXM_PROJECT?.trim();
|
|
123
|
+
if (fromEnv) return { project: fromEnv, source: "env", sourceLabel: "KXM_PROJECT" };
|
|
124
|
+
const settings = loadHubSettings(cwd, env);
|
|
125
|
+
const mode = settings.mode ?? (activeBinding(env)?.cloud ? "cloud" : "local");
|
|
126
|
+
const fromConfig = endpointFor(mode, settings)?.project?.trim();
|
|
127
|
+
if (fromConfig) {
|
|
128
|
+
return { project: fromConfig, source: "config", sourceLabel: `.kxm/config.yaml hub.${mode}.project` };
|
|
129
|
+
}
|
|
130
|
+
const fromProject = projectYamlId(cwd);
|
|
131
|
+
if (fromProject) return { project: fromProject, source: "project.yaml", sourceLabel: ".kxm/project.yaml id" };
|
|
132
|
+
const fromPackage = packageName(cwd);
|
|
133
|
+
if (fromPackage) return { project: fromPackage, source: "package.json", sourceLabel: "package.json name" };
|
|
134
|
+
return { project: basename(cwd), source: "directory", sourceLabel: "directory name" };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Same order as {@link resolveProjectIdentity}. One function, string form. */
|
|
138
|
+
export function defaultProjectName(cwd: string, env: NodeJS.ProcessEnv = process.env, explicit?: string): string {
|
|
139
|
+
return resolveProjectIdentity(cwd, env, explicit).project;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function opReferenceFromTokenCommand(command: string): string | undefined {
|
|
143
|
+
try {
|
|
144
|
+
const args = splitTokenCommand(command);
|
|
145
|
+
if (args[0] === "op" && args[1] === "read" && args[2] && isHubOpReference(args[2])) return args[2];
|
|
146
|
+
} catch {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function defaultOpRead(reference: string, env: NodeJS.ProcessEnv): string {
|
|
153
|
+
if (!isHubOpReference(reference)) {
|
|
154
|
+
throw new CloudTokenError("cloud_token_command_invalid", `refusing to resolve ${reference}; an op:// reference is required`);
|
|
155
|
+
}
|
|
156
|
+
const result = spawnSync("op", ["read", reference], {
|
|
157
|
+
encoding: "utf8",
|
|
158
|
+
shell: false,
|
|
159
|
+
timeout: OP_TIMEOUT_MS,
|
|
160
|
+
maxBuffer: OP_MAX_BUFFER,
|
|
161
|
+
env,
|
|
162
|
+
windowsHide: true,
|
|
163
|
+
});
|
|
164
|
+
if (result.error || result.status !== 0) {
|
|
165
|
+
throw new CloudTokenError(
|
|
166
|
+
"cloud_token_command_failed",
|
|
167
|
+
`op read failed for ${reference}; the token was not saved`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const line = (result.stdout ?? "").trim();
|
|
171
|
+
if (!line || line.split(/\r?\n/).length !== 1) {
|
|
172
|
+
throw new CloudTokenError(
|
|
173
|
+
"cloud_token_command_failed",
|
|
174
|
+
`op read did not print a single token for ${reference}; the token was not saved`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return line;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resolve a key reference. A non-empty env var wins; otherwise `op read` runs.
|
|
182
|
+
* The value is returned to the caller and never written.
|
|
183
|
+
*/
|
|
184
|
+
export function resolveKeyReference(
|
|
185
|
+
ref: HubKeyRef | undefined,
|
|
186
|
+
env: NodeJS.ProcessEnv,
|
|
187
|
+
opRead: OpReader = defaultOpRead,
|
|
188
|
+
): ResolvedKey | undefined {
|
|
189
|
+
if (!ref) return undefined;
|
|
190
|
+
if (ref.env) {
|
|
191
|
+
const value = env[ref.env]?.trim();
|
|
192
|
+
if (value) return { value, source: `env:${ref.env}` };
|
|
193
|
+
}
|
|
194
|
+
if (ref.op) {
|
|
195
|
+
const value = opRead(ref.op, env).trim();
|
|
196
|
+
if (!value || value.split(/\r?\n/).length !== 1) {
|
|
197
|
+
throw new CloudTokenError("cloud_token_command_failed", `op read did not print a single token for ${ref.op}`);
|
|
198
|
+
}
|
|
199
|
+
return { value, source: `op:${ref.op}` };
|
|
200
|
+
}
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function keySourceFor(input: {
|
|
205
|
+
mode: KxmHubMode;
|
|
206
|
+
env: NodeJS.ProcessEnv;
|
|
207
|
+
endpoint?: HubEndpointConfig;
|
|
208
|
+
binding?: HubBindingRecord;
|
|
209
|
+
project: string;
|
|
210
|
+
hasSavedProjectToken?: (project: string) => boolean;
|
|
211
|
+
}): string {
|
|
212
|
+
const ref = input.endpoint?.key;
|
|
213
|
+
if (input.mode === "local" && input.env.KXM_AUTH_TOKEN?.trim()) return "env:KXM_AUTH_TOKEN";
|
|
214
|
+
if (ref?.env && input.env[ref.env]?.trim()) return `env:${ref.env}`;
|
|
215
|
+
if (ref?.op) return `op:${ref.op}`;
|
|
216
|
+
if (ref?.env) return `env:${ref.env}`;
|
|
217
|
+
if (input.mode === "cloud") {
|
|
218
|
+
if (input.binding?.tokenEnv) return `env:${input.binding.tokenEnv}`;
|
|
219
|
+
if (input.binding?.tokenCommand) {
|
|
220
|
+
const op = opReferenceFromTokenCommand(input.binding.tokenCommand);
|
|
221
|
+
return op ? `op:${op}` : "command";
|
|
222
|
+
}
|
|
223
|
+
return "missing";
|
|
224
|
+
}
|
|
225
|
+
if (input.hasSavedProjectToken?.(input.project)) return "hub-env";
|
|
226
|
+
return "missing";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Mode, URL, and key source for this checkout. The key source names an env var
|
|
231
|
+
* or an `op://` reference. It is never the token.
|
|
232
|
+
*/
|
|
233
|
+
export function describeHubConnection(
|
|
234
|
+
cwd: string,
|
|
235
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
236
|
+
hooks: { hasSavedProjectToken?: (project: string) => boolean; explicitProject?: string } = {},
|
|
237
|
+
): HubConnection & ProjectIdentity {
|
|
238
|
+
const settings = loadHubSettings(cwd, env);
|
|
239
|
+
const binding = activeBinding(env);
|
|
240
|
+
const mode: KxmHubMode = settings.mode ?? (binding?.cloud ? "cloud" : "local");
|
|
241
|
+
const modeSource: HubConnection["modeSource"] = settings.mode ? "config" : binding?.cloud ? "binding" : "default";
|
|
242
|
+
const endpoint = endpointFor(mode, settings);
|
|
243
|
+
const fromEnv = env.KXM_SERVER_URL?.trim();
|
|
244
|
+
const fromConfig = endpoint?.url?.trim();
|
|
245
|
+
const fromBinding = binding?.url;
|
|
246
|
+
let url = "http://127.0.0.1:7331";
|
|
247
|
+
let urlSource: HubConnection["urlSource"] = "default";
|
|
248
|
+
if (fromEnv) {
|
|
249
|
+
url = fromEnv.replace(/\/$/, "");
|
|
250
|
+
urlSource = "env";
|
|
251
|
+
} else if (fromConfig) {
|
|
252
|
+
url = fromConfig.replace(/\/$/, "");
|
|
253
|
+
urlSource = "config";
|
|
254
|
+
} else if (fromBinding) {
|
|
255
|
+
url = fromBinding;
|
|
256
|
+
urlSource = "binding";
|
|
257
|
+
}
|
|
258
|
+
const identity = resolveProjectIdentity(cwd, env, hooks.explicitProject);
|
|
259
|
+
const configuredCloudUrl = settings.cloud?.url ?? (binding?.cloud ? binding.url : undefined);
|
|
260
|
+
let scope: HubBindingScope;
|
|
261
|
+
if (mode === "cloud") {
|
|
262
|
+
const override = fromEnv ? normalizeUrl(fromEnv) : undefined;
|
|
263
|
+
const named = configuredCloudUrl ? normalizeUrl(configuredCloudUrl) : undefined;
|
|
264
|
+
scope = override && named && override !== named ? hubBindingScope(url) : "remote";
|
|
265
|
+
} else {
|
|
266
|
+
scope = hubBindingScope(url);
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
...identity,
|
|
270
|
+
mode,
|
|
271
|
+
modeSource,
|
|
272
|
+
url,
|
|
273
|
+
urlSource,
|
|
274
|
+
scope,
|
|
275
|
+
...(endpoint ? { endpoint } : {}),
|
|
276
|
+
...(binding ? { binding } : {}),
|
|
277
|
+
keySource: keySourceFor({
|
|
278
|
+
mode,
|
|
279
|
+
env,
|
|
280
|
+
...(endpoint ? { endpoint } : {}),
|
|
281
|
+
...(binding ? { binding } : {}),
|
|
282
|
+
project: identity.project,
|
|
283
|
+
...(hooks.hasSavedProjectToken ? { hasSavedProjectToken: hooks.hasSavedProjectToken } : {}),
|
|
284
|
+
}),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function projectTokenMissingMessage(identity: ProjectIdentity): string {
|
|
289
|
+
return [
|
|
290
|
+
`kxm has no project token for project ${identity.project} (from ${identity.sourceLabel}).`,
|
|
291
|
+
`Ask the user to set this project's token. Set KXM_AUTH_TOKEN, or set hub.local.project or hub.cloud.project in .kxm/config.yaml to the project key the hub already knows, or add a key reference under hub.projects.${identity.project} (key.op is an op:// reference, key.env is an environment variable name, never the token) and restart the hub.`,
|
|
292
|
+
"KXM_PROJECT or --project overrides the id for one command.",
|
|
293
|
+
"An agent never uses the hub admin token.",
|
|
294
|
+
].join(" ");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export interface HubProjectTokenSelection {
|
|
298
|
+
tokens: Record<string, string> | undefined;
|
|
299
|
+
source: "env" | "config" | "file" | "none";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** `KXM_PROJECT_TOKENS` replaces config, which replaces the saved file map. */
|
|
303
|
+
export function selectHubProjectTokenMap(input: {
|
|
304
|
+
explicit: Record<string, string> | undefined;
|
|
305
|
+
configured: Record<string, string> | undefined;
|
|
306
|
+
file: Record<string, string> | undefined;
|
|
307
|
+
}): HubProjectTokenSelection {
|
|
308
|
+
if (input.explicit) return { tokens: input.explicit, source: "env" };
|
|
309
|
+
if (input.configured && Object.keys(input.configured).length > 0) return { tokens: input.configured, source: "config" };
|
|
310
|
+
if (input.file && Object.keys(input.file).length > 0) return { tokens: input.file, source: "file" };
|
|
311
|
+
return { tokens: undefined, source: "none" };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Resolve `hub.projects` key references. Returns undefined when the map is
|
|
316
|
+
* absent. Does not write the resolved tokens.
|
|
317
|
+
*/
|
|
318
|
+
export function resolveConfiguredHubProjectTokens(
|
|
319
|
+
cwd: string,
|
|
320
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
321
|
+
opRead: OpReader = defaultOpRead,
|
|
322
|
+
): Record<string, string> | undefined {
|
|
323
|
+
const projects = loadHubSettings(cwd, env).projects;
|
|
324
|
+
if (!projects || Object.keys(projects).length === 0) return undefined;
|
|
325
|
+
const tokens: Record<string, string> = {};
|
|
326
|
+
for (const [project, ref] of Object.entries(projects)) {
|
|
327
|
+
const resolved = resolveKeyReference(ref, env, opRead);
|
|
328
|
+
if (!resolved?.value) {
|
|
329
|
+
throw new CloudTokenError(
|
|
330
|
+
"cloud_token_missing",
|
|
331
|
+
`hub.projects.${project} did not resolve; set ${ref.env ?? "the named variable"} or fix the op:// reference. The token was not saved`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
tokens[project] = resolved.value;
|
|
335
|
+
}
|
|
336
|
+
return tokens;
|
|
337
|
+
}
|
|
@@ -4,15 +4,14 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
6
6
|
import { HubClient, HubHttpError } from "./client.ts";
|
|
7
|
-
import { resolveAgentHubAuthToken } from "./hub-env.ts";
|
|
8
|
-
import {
|
|
9
|
-
import { defaultProjectName } from "./project-name.ts";
|
|
7
|
+
import { AgentProjectTokenMissingError, resolveAgentHubAuthToken } from "./hub-env.ts";
|
|
8
|
+
import { describeHubConnection, resolveProjectIdentity } from "./hub-identity.ts";
|
|
10
9
|
import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } from "./commands.ts";
|
|
11
10
|
import { deliverInboxNotification } from "./inbox.ts";
|
|
12
11
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
13
12
|
import { sessionTokenFixHint } from "./session-token-hint.ts";
|
|
14
13
|
|
|
15
|
-
const VERSION = "0.7.
|
|
14
|
+
const VERSION = "0.7.166";
|
|
16
15
|
const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
|
|
17
16
|
const inbox = new Map<string, MessageRecord>();
|
|
18
17
|
const notifiedInbox = new Set<string>();
|
|
@@ -47,12 +46,15 @@ function asRecord(value: unknown): Record<string, unknown> {
|
|
|
47
46
|
}
|
|
48
47
|
|
|
49
48
|
/** Where this session works and which hub it talks to, read from the launch environment. */
|
|
50
|
-
function sessionIdentity(): { projectDir: string; project: string; serverUrl: string } {
|
|
49
|
+
function sessionIdentity(): { projectDir: string; project: string; projectSource: string; sourceLabel: string; serverUrl: string } {
|
|
51
50
|
const projectDir = process.env.KXM_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
51
|
+
const connection = describeHubConnection(projectDir, process.env);
|
|
52
52
|
return {
|
|
53
53
|
projectDir,
|
|
54
|
-
project:
|
|
55
|
-
|
|
54
|
+
project: connection.project,
|
|
55
|
+
projectSource: connection.source,
|
|
56
|
+
sourceLabel: connection.sourceLabel,
|
|
57
|
+
serverUrl: connection.url,
|
|
56
58
|
};
|
|
57
59
|
}
|
|
58
60
|
|
|
@@ -136,15 +138,13 @@ async function ensureClient(): Promise<HubClient> {
|
|
|
136
138
|
if (meshClient?.agent) return meshClient;
|
|
137
139
|
if (starting) return starting;
|
|
138
140
|
starting = (async () => {
|
|
139
|
-
const { project, serverUrl } = sessionIdentity();
|
|
141
|
+
const { project, projectDir, serverUrl } = sessionIdentity();
|
|
140
142
|
// An agent session registers with a project token only. The hub admits its admin token
|
|
141
143
|
// to any project missing from its token map, so no project token fails here, before the
|
|
142
144
|
// hub is contacted.
|
|
143
|
-
const authToken = resolveAgentHubAuthToken(process.env, project);
|
|
145
|
+
const authToken = resolveAgentHubAuthToken(process.env, project, projectDir);
|
|
144
146
|
if (!authToken) {
|
|
145
|
-
throw new
|
|
146
|
-
`KXM has no project token for project ${project} on this machine. Ask the user to set the kxm plugin auth_token (${CONFIGURE_PLUGIN}) or to add ${project} to the hub KXM_PROJECT_TOKENS, listing every existing project too because that variable replaces the saved map.`,
|
|
147
|
-
);
|
|
147
|
+
throw new AgentProjectTokenMissingError(resolveProjectIdentity(projectDir, process.env));
|
|
148
148
|
}
|
|
149
149
|
const name = process.env.KXM_AGENT_NAME?.trim() || `claude-${process.pid}`;
|
|
150
150
|
try {
|
|
@@ -192,8 +192,10 @@ async function connectedClient(): Promise<HubClient> {
|
|
|
192
192
|
|
|
193
193
|
/** Tool errors are read by the model, so each names the next step and who takes it. */
|
|
194
194
|
function toolErrorText(error: unknown): string {
|
|
195
|
-
if (error instanceof
|
|
196
|
-
|
|
195
|
+
if (error instanceof AgentProjectTokenMissingError) return error.message;
|
|
196
|
+
if (error instanceof HubHttpError && (error.code === "invalid_auth" || error.code === "project_token_missing")) {
|
|
197
|
+
const identity = sessionIdentity();
|
|
198
|
+
return `KXM hub rejected the project token for project ${identity.project} (from ${identity.sourceLabel}). Set hub.local.project or hub.cloud.project in .kxm/config.yaml to the key the hub already knows, and set hub.local.key or hub.cloud.key to an op:// reference or an environment variable name. Ask the user before changing the kxm plugin auth_token (${CONFIGURE_PLUGIN}). Never put the token in config.`;
|
|
197
199
|
}
|
|
198
200
|
return error instanceof Error ? error.message : String(error);
|
|
199
201
|
}
|
|
@@ -238,7 +240,7 @@ function registersAtStartup(): boolean {
|
|
|
238
240
|
const { projectDir, project } = sessionIdentity();
|
|
239
241
|
try {
|
|
240
242
|
if (!statSync(join(projectDir, ".kxm")).isDirectory()) return false;
|
|
241
|
-
if (!resolveAgentHubAuthToken(process.env, project)) return false;
|
|
243
|
+
if (!resolveAgentHubAuthToken(process.env, project, projectDir)) return false;
|
|
242
244
|
} catch {
|
|
243
245
|
return false;
|
|
244
246
|
}
|
|
@@ -1,47 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
* Precedence: an explicit flag, then `KXM_PROJECT`, then the `id` in the
|
|
8
|
-
* nearest `.kxm/project.yaml` walking upward from `cwd`, then the workspace
|
|
9
|
-
* `package.json` `name` (that read never walks upward), then the directory
|
|
10
|
-
* name. A missing, malformed, or id-less project file falls through. A missing,
|
|
11
|
-
* malformed, or name-less package.json falls through to the directory name. */
|
|
12
|
-
export function defaultProjectName(cwd: string, env: NodeJS.ProcessEnv = process.env, explicit?: string): string {
|
|
13
|
-
const fromExplicit = explicit?.trim();
|
|
14
|
-
if (fromExplicit) return fromExplicit;
|
|
15
|
-
const fromEnv = env.KXM_PROJECT?.trim();
|
|
16
|
-
if (fromEnv) return fromEnv;
|
|
17
|
-
const fromProject = readProjectYamlId(cwd);
|
|
18
|
-
if (fromProject) return fromProject;
|
|
19
|
-
try {
|
|
20
|
-
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")) as { name?: unknown };
|
|
21
|
-
if (typeof pkg.name === "string" && pkg.name.trim().length > 0) return pkg.name.trim();
|
|
22
|
-
} catch {
|
|
23
|
-
// No readable package.json: fall through to the directory name.
|
|
24
|
-
}
|
|
25
|
-
return basename(cwd);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function readProjectYamlId(start: string): string | undefined {
|
|
29
|
-
let dir = resolve(start);
|
|
30
|
-
for (;;) {
|
|
31
|
-
const file = join(dir, ".kxm", "project.yaml");
|
|
32
|
-
if (existsSync(file)) {
|
|
33
|
-
try {
|
|
34
|
-
const doc = parseYaml(readFileSync(file, "utf8")) as { id?: unknown } | null;
|
|
35
|
-
if (doc && typeof doc === "object" && !Array.isArray(doc) && typeof doc.id === "string" && doc.id.trim()) {
|
|
36
|
-
return doc.id.trim();
|
|
37
|
-
}
|
|
38
|
-
} catch {
|
|
39
|
-
// Malformed YAML falls through to package.json of the original directory.
|
|
40
|
-
}
|
|
41
|
-
return undefined;
|
|
42
|
-
}
|
|
43
|
-
const parent = dirname(dir);
|
|
44
|
-
if (parent === dir) return undefined;
|
|
45
|
-
dir = parent;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
1
|
+
export {
|
|
2
|
+
defaultProjectName,
|
|
3
|
+
resolveProjectIdentity,
|
|
4
|
+
type ProjectIdentity,
|
|
5
|
+
type ProjectIdentitySource,
|
|
6
|
+
} from "./hub-identity.ts";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createMeshHub } from "./hub.ts";
|
|
2
2
|
import { createWriteStream, mkdirSync, readFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { resolveConfiguredHubProjectTokens, selectHubProjectTokenMap } from "./hub-identity.ts";
|
|
4
5
|
import {
|
|
5
6
|
DEFAULT_MESSAGE_RETENTION_MS,
|
|
6
7
|
DEFAULT_MESSAGE_TTL_MS,
|
|
@@ -42,10 +43,10 @@ const structuredLog = createLogger({
|
|
|
42
43
|
path: logPath,
|
|
43
44
|
});
|
|
44
45
|
|
|
45
|
-
function
|
|
46
|
-
const
|
|
47
|
-
if (!
|
|
48
|
-
const value = JSON.parse(
|
|
46
|
+
function parseProjectTokenEnv(raw: string | undefined): Record<string, string> | undefined {
|
|
47
|
+
const trimmed = raw?.trim();
|
|
48
|
+
if (!trimmed) return undefined;
|
|
49
|
+
const value = JSON.parse(trimmed) as unknown;
|
|
49
50
|
if (!value || Array.isArray(value) || typeof value !== "object") {
|
|
50
51
|
throw new Error("KXM_PROJECT_TOKENS must be a JSON object of project names to tokens");
|
|
51
52
|
}
|
|
@@ -56,6 +57,23 @@ function projectTokens(): Record<string, string> | undefined {
|
|
|
56
57
|
return Object.fromEntries(entries) as Record<string, string>;
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
function projectTokens(): Record<string, string> | undefined {
|
|
61
|
+
const explicit = process.env.KXM_PROJECT_TOKENS_EXPLICIT === "1";
|
|
62
|
+
const fromEnv = parseProjectTokenEnv(process.env.KXM_PROJECT_TOKENS);
|
|
63
|
+
const workdir = resolve(process.env.KXM_WORKDIR?.trim() || process.cwd());
|
|
64
|
+
const configured = explicit ? undefined : resolveConfiguredHubProjectTokens(workdir, process.env);
|
|
65
|
+
const selected = selectHubProjectTokenMap({
|
|
66
|
+
explicit: explicit ? fromEnv : undefined,
|
|
67
|
+
configured,
|
|
68
|
+
file: explicit ? undefined : fromEnv,
|
|
69
|
+
});
|
|
70
|
+
if (selected.source === "config") {
|
|
71
|
+
const count = Object.keys(selected.tokens ?? {}).length;
|
|
72
|
+
process.stderr.write(`kxm hub: project tokens from .kxm/config.yaml (${count} projects, key references resolved, values not saved)\n`);
|
|
73
|
+
}
|
|
74
|
+
return selected.tokens;
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
60
78
|
throw new Error("KXM_PORT must be an integer between 0 and 65535");
|
|
61
79
|
}
|
package/scripts/kxm-hub.mjs
CHANGED
|
@@ -165,9 +165,14 @@ try {
|
|
|
165
165
|
process.stderr.write(`kxm hub: ${error.message}\n`);
|
|
166
166
|
process.exit(1);
|
|
167
167
|
}
|
|
168
|
+
const operatorSetProjectTokens = Boolean(process.env.KXM_PROJECT_TOKENS?.trim());
|
|
168
169
|
const launchEnv = { ...process.env };
|
|
169
170
|
if (credentials.authToken) launchEnv.KXM_AUTH_TOKEN = credentials.authToken;
|
|
170
171
|
if (credentials.projectTokens) launchEnv.KXM_PROJECT_TOKENS = JSON.stringify(credentials.projectTokens);
|
|
172
|
+
// The operator's KXM_PROJECT_TOKENS replaces config. A map copied from
|
|
173
|
+
// hub-env.json does not, so the server can prefer hub.projects references
|
|
174
|
+
// and keep the resolved tokens out of the credential file.
|
|
175
|
+
if (operatorSetProjectTokens) launchEnv.KXM_PROJECT_TOKENS_EXPLICIT = "1";
|
|
171
176
|
if (credentials.authTokenSource !== "env") {
|
|
172
177
|
process.stdout.write(`kxm hub: using ${credentials.authTokenSource === "generated" ? "newly generated" : "persisted"} KXM_AUTH_TOKEN from ${credentials.file}\n`);
|
|
173
178
|
}
|