@karma42/agent 0.1.0-beta.3 → 0.1.0-beta.5
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 +17 -97
- package/package.json +4 -3
- package/runtime/manifest.json +1 -6
- package/runtime/project-tools.json +510 -0
- package/src/browser-pairing.mjs +34 -36
- package/src/cli.mjs +28 -78
- package/src/mcp-configuration.mjs +35 -0
- package/src/project-identity.mjs +15 -0
- package/src/reusable-stdio.mjs +163 -0
- package/src/session-stdio.mjs +7 -4
- package/src/session.mjs +8 -117
- package/src/state.mjs +11 -29
- package/src/system.mjs +4 -40
- package/src/version.mjs +1 -0
- package/runtime/AGENT_PROMPT_V2.md +0 -115
- package/runtime/claude-settings.json +0 -82
- package/runtime/launcher.mjs +0 -55
- package/runtime/providers.mjs +0 -284
- package/runtime/runner.mjs +0 -591
- package/runtime/runtime.mjs +0 -67
- package/src/connection.mjs +0 -214
- package/src/doctor.mjs +0 -28
- package/src/plan.mjs +0 -88
- package/src/supervisor.mjs +0 -17
- package/src/terminal.mjs +0 -33
package/src/cli.mjs
CHANGED
|
@@ -2,98 +2,48 @@
|
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
4
|
import { realpathSync } from 'node:fs';
|
|
5
|
-
import { VERSION
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { createSessionOperations } from './session.mjs';
|
|
9
|
-
import { createBrowserOperations } from './browser-pairing.mjs';
|
|
10
|
-
import { serveSession } from './session-stdio.mjs';
|
|
11
|
-
import { terminal } from './terminal.mjs';
|
|
5
|
+
import { VERSION } from './version.mjs';
|
|
6
|
+
import { serveReusable } from './reusable-stdio.mjs';
|
|
7
|
+
import { reusableConfiguration, renderReusableConfiguration } from './mcp-configuration.mjs';
|
|
12
8
|
|
|
13
|
-
const HELP = `Karma42
|
|
9
|
+
const HELP = `Karma42 MCP ${VERSION}
|
|
14
10
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
browser-status --connection ID (private local state only)
|
|
19
|
-
session-connect --project-url HTTPS_SERVICE/p/PROJECT_ID --provider claude|codex [--agent-id EXPECTED_AGENT_ID] [--folder ABSOLUTE_WORKSPACE]
|
|
20
|
-
session-prepare --connection ID (recover interrupted pairing)
|
|
21
|
-
session-config --connection ID
|
|
22
|
-
session-status
|
|
23
|
-
session-serve --connection ID (MCP clients only; protocol on stdout)
|
|
11
|
+
Connect your existing AI coding client to Karma42 projects.
|
|
12
|
+
The connector does not choose or run a model. Your MCP-capable client may use
|
|
13
|
+
Claude, Codex, DeepSeek or another model supported by that client.
|
|
24
14
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
permissions --connection ID --mode guarded|bypass [--provider claude|codex]
|
|
32
|
-
status [--connection ID] [--json]
|
|
33
|
-
doctor --provider claude|codex [--json]
|
|
34
|
-
preview --provider claude|codex --mode guarded|bypass
|
|
35
|
-
--project-url HTTPS_SERVICE/p/PROJECT_ID --repo GITHUB_URL --folder ABSOLUTE_PATH [--json]
|
|
15
|
+
Commands:
|
|
16
|
+
mcp-config --client claude|codex|other [--state-dir ABSOLUTE_PRIVATE_DIRECTORY]
|
|
17
|
+
Print first-time client registration instructions; makes no changes.
|
|
18
|
+
mcp-serve [--state-dir ABSOLUTE_PRIVATE_DIRECTORY]
|
|
19
|
+
MCP transport, started by your client. Each process starts unbound.
|
|
20
|
+
--help
|
|
36
21
|
--version
|
|
37
22
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
No automatic personal client configuration edits or background launch.`;
|
|
44
|
-
const selection = { connection: { type: 'string' }, 'state-dir': { type: 'string' } };
|
|
23
|
+
First register the Karma42 MCP once in your client. Then copy the project-agent
|
|
24
|
+
instructions from https://karma42.ai into a conversation with its tools loaded.
|
|
25
|
+
Project approval happens separately in your browser. Keep native trust and
|
|
26
|
+
permission controls. No automatic settings edits, model execution or background launch.
|
|
27
|
+
Codes and tokens are never accepted as command arguments.`;
|
|
45
28
|
const specs = {
|
|
46
|
-
'
|
|
47
|
-
'
|
|
48
|
-
'session-connect': { 'agent-id': { type: 'string' }, provider: { type: 'string' }, 'project-url': { type: 'string' }, folder: { type: 'string' }, 'state-dir': { type: 'string' } },
|
|
49
|
-
'session-prepare': selection, 'session-config': selection, 'session-serve': selection, 'session-status': { 'state-dir': { type: 'string' } },
|
|
50
|
-
connect: { provider: { type: 'string' }, mode: { type: 'string' }, 'project-url': { type: 'string' }, folder: { type: 'string' }, 'state-dir': { type: 'string' } },
|
|
51
|
-
prepare: selection, start: selection, reconnect: selection,
|
|
52
|
-
permissions: { ...selection, provider: { type: 'string' }, mode: { type: 'string' } },
|
|
53
|
-
status: { ...selection, json: { type: 'boolean' } },
|
|
54
|
-
preview: { provider: { type: 'string' }, mode: { type: 'string' }, 'project-url': { type: 'string' }, repo: { type: 'string' }, folder: { type: 'string' }, json: { type: 'boolean' } },
|
|
55
|
-
doctor: { provider: { type: 'string' }, json: { type: 'boolean' } },
|
|
29
|
+
'mcp-config': { client: { type: 'string' }, 'state-dir': { type: 'string' } },
|
|
30
|
+
'mcp-serve': { 'state-dir': { type: 'string' } },
|
|
56
31
|
};
|
|
57
|
-
export function main(args, out = console.log, err = console.error
|
|
32
|
+
export function main(args, out = console.log, err = console.error) {
|
|
58
33
|
if (!args.length || (args.length === 1 && ['--help', 'help', '-h'].includes(args[0]))) { out(HELP); return 0; }
|
|
59
34
|
if (args.length === 1 && args[0] === '--version') { out(VERSION); return 0; }
|
|
60
35
|
const [command, ...rest] = args;
|
|
61
36
|
if (!Object.hasOwn(specs, command)) { err('Unknown command. Run --help for details.'); return 2; }
|
|
62
37
|
try {
|
|
63
|
-
// Do not echo unknown options or their values: users may accidentally paste a secret.
|
|
64
38
|
let parsed;
|
|
65
39
|
try { parsed = parseArgs({ args: rest, options: specs[command], allowPositionals: false, tokens: true }); }
|
|
66
|
-
catch { throw new Error('Invalid arguments. Run --help.
|
|
67
|
-
const names = parsed.tokens.filter(
|
|
40
|
+
catch { throw new Error('Invalid arguments. Run --help. Codes and tokens are not accepted as arguments.'); }
|
|
41
|
+
const names = parsed.tokens.filter(token => token.kind === 'option').map(token => token.name);
|
|
68
42
|
if (new Set(names).size !== names.length) throw new Error('Supply each option once.');
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (!['preview', 'doctor'].includes(command)) {
|
|
75
|
-
if (v['state-dir'] && (!v['state-dir'].startsWith('/') || /[\x00-\x1f\x7f-\x9f]/.test(v['state-dir']))) throw new Error('Choose an absolute private state directory.');
|
|
76
|
-
if (command === 'session-serve') return serveSession({ connection: v.connection, home: v['state-dir'] }).then(() => 0).catch(() => { err('MCP session could not start. Review its private connection record.'); return 2; });
|
|
77
|
-
const io = terminal(); io.say = out;
|
|
78
|
-
if (command.startsWith('browser-')) {
|
|
79
|
-
const browser = createBrowserOperations({ home: v['state-dir'], io });
|
|
80
|
-
return Promise.resolve().then(() => browser[command.slice(8)](v)).then(() => 0).catch(error => { err(error.message); return 2; });
|
|
81
|
-
}
|
|
82
|
-
if (command.startsWith('session-')) {
|
|
83
|
-
const sessions = createSessionOperations({ home: v['state-dir'], io });
|
|
84
|
-
return Promise.resolve().then(() => sessions[command.slice(8)](v)).then(() => 0).catch(error => { err(error.message); return 2; });
|
|
85
|
-
}
|
|
86
|
-
const operations = createOperations({ home: v['state-dir'], io });
|
|
87
|
-
return Promise.resolve().then(() => operations[command](v)).then(result => command === 'start' ? result : 0).catch(error => { err(error.message); return 2; });
|
|
88
|
-
}
|
|
89
|
-
const report = runDoctor(providerName(v.provider));
|
|
90
|
-
out(v.json ? JSON.stringify(report, null, 2) : [
|
|
91
|
-
'Karma42 — local prerequisites',
|
|
92
|
-
...report.checks.map(c => `${c.ok ? 'Found' : 'Action required'}: ${c.tool} — ${c.version ?? 'not detected'}${c.minimum ? ` (requires ${c.minimum}+)` : ''}`),
|
|
93
|
-
'Sign-in and repository access: not checked. No connection or model work was started.',
|
|
94
|
-
'Passing these checks does not establish that an agent is ready to work.',
|
|
95
|
-
].join('\n'));
|
|
96
|
-
return report.readyForPairingChecks ? 0 : 1;
|
|
97
|
-
} catch (e) { err(e.message); return 2; }
|
|
43
|
+
const values = parsed.values;
|
|
44
|
+
if (values['state-dir'] && (!values['state-dir'].startsWith('/') || /[\x00-\x1f\x7f-\x9f]/.test(values['state-dir']))) throw new Error('Choose an absolute private state directory.');
|
|
45
|
+
if (command === 'mcp-config') { out(renderReusableConfiguration(reusableConfiguration({ client: values.client, home: values['state-dir'] }))); return 0; }
|
|
46
|
+
return serveReusable({ home: values['state-dir'] }).then(() => 0).catch(() => { err('Karma42 MCP could not start. Review its private state directory.'); return 2; });
|
|
47
|
+
} catch (error) { err(error.message); return 2; }
|
|
98
48
|
}
|
|
99
49
|
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exitCode = await main(process.argv.slice(2));
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path';
|
|
2
|
+
import { VERSION } from './version.mjs';
|
|
3
|
+
import { sessionHome } from './session.mjs';
|
|
4
|
+
import { canonicalPath } from './state.mjs';
|
|
5
|
+
|
|
6
|
+
const quote = value => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
7
|
+
// Client selection changes registration format only. The transport and project identity are model agnostic.
|
|
8
|
+
export function reusableConfiguration({ client, home = sessionHome() } = {}) {
|
|
9
|
+
if (!['claude', 'codex', 'other'].includes(client)) throw new Error('Choose --client claude, codex or other. This selects your MCP client, not your model.');
|
|
10
|
+
if (typeof home !== 'string' || !isAbsolute(home) || /[\x00-\x1f\x7f-\x9f]/.test(home)) throw new Error('Choose an absolute private state directory.');
|
|
11
|
+
home = canonicalPath(home);
|
|
12
|
+
const name = 'karma42';
|
|
13
|
+
const args = ['--yes', '--ignore-scripts', `@karma42/agent@${VERSION}`, 'mcp-serve', '--state-dir', home];
|
|
14
|
+
const prefix = client === 'claude' ? ['claude', 'mcp', 'add', '--scope', 'user', name, '--', 'npx'] : client === 'codex' ? ['codex', 'mcp', 'add', name, '--', 'npx'] : null;
|
|
15
|
+
const entry = { command: 'npx', args };
|
|
16
|
+
return { name, client, scope: client === 'other' ? 'client-settings' : 'user',
|
|
17
|
+
command: prefix ? [...prefix, ...args].map(quote).join(' ') : null,
|
|
18
|
+
entry, generic: { mcpServers: { [name]: entry } },
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function renderReusableConfiguration(config) {
|
|
22
|
+
return [
|
|
23
|
+
'Karma42 — first-time MCP setup',
|
|
24
|
+
`Karma42 agent ${VERSION} — beta release. Instructions only; no settings were changed.`,
|
|
25
|
+
'',
|
|
26
|
+
config.client === 'other' ? 'Add the karma42 stdio connection using your MCP client’s settings and configuration format. The JSON below describes the command and arguments; adapt its wrapper if your client uses a different schema.' : `Register karma42 once in ${config.client === 'claude' ? 'Claude Code’s user-level MCP settings' : 'Codex’s user-level ~/.codex/config.toml'}.`,
|
|
27
|
+
'Approve adding this entry before changing settings. If karma42 already exists, inspect it first. Preserve other entries and existing permissions.',
|
|
28
|
+
config.command ?? JSON.stringify(config.generic, null, 2),
|
|
29
|
+
'',
|
|
30
|
+
'Then start a project conversation with Karma42’s tools loaded and copy its project-agent instructions from the website. Approve native trust prompts yourself.',
|
|
31
|
+
'This connector chooses no model, launches no coding agent and grants no project access by itself. Your MCP client may use DeepSeek or another supported model.',
|
|
32
|
+
'Every transport process starts unbound. Project-agent onboarding and browser approval are separate; no global active identity is stored.',
|
|
33
|
+
'Printing configuration does not verify that your client loaded it. Private records use the selected state directory; secrets never belong in client configuration.',
|
|
34
|
+
].join('\n');
|
|
35
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path';
|
|
2
|
+
function clean(value, label) {
|
|
3
|
+
if (typeof value !== 'string' || !value || value.length > 2048 || /[\x00-\x1f\x7f-\x9f]/.test(value)) throw new Error(`Provide a valid ${label}.`);
|
|
4
|
+
return value;
|
|
5
|
+
}
|
|
6
|
+
export function projectIdentity({ projectUrl, repo = 'https://github.com/placeholder/project', folder }) {
|
|
7
|
+
let url;
|
|
8
|
+
try { url = new URL(clean(projectUrl, 'project URL')); } catch { throw new Error('Use a Karma42 project service URL.'); }
|
|
9
|
+
if (!['https:', 'http:'].includes(url.protocol) || (url.protocol === 'http:' && !['localhost', '127.0.0.1'].includes(url.hostname)) || url.username || url.password || url.search || url.hash || !/^\/p\/[a-f0-9]{64}\/?$/.test(url.pathname)) throw new Error('Use an HTTPS project service URL ending in /p/PROJECT_ID, without credentials or query parameters.');
|
|
10
|
+
clean(repo, 'GitHub repository URL');
|
|
11
|
+
if (!/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/?$/.test(repo) || /\/(?:\.|\.\.)(?:\/|$)/.test(repo)) throw new Error('Use a GitHub repository URL without credentials, query parameters or fragments.');
|
|
12
|
+
clean(folder, 'absolute project folder');
|
|
13
|
+
if (!isAbsolute(folder)) throw new Error('Choose an absolute project folder.');
|
|
14
|
+
return { projectUrl: url.href.replace(/\/$/, ''), repo: repo.replace(/\/$/, ''), folder };
|
|
15
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { PassThrough } from 'node:stream';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { join, isAbsolute } from 'node:path';
|
|
6
|
+
import { verifyBundle } from './system.mjs';
|
|
7
|
+
import { assertStateOutsideWorkspace, canonicalPath, connectionDir, digest, load, lstatExists, readPrivate } from './state.mjs';
|
|
8
|
+
import { sessionHome, sessionId, validateSession } from './session.mjs';
|
|
9
|
+
import { projectIdentity } from './project-identity.mjs';
|
|
10
|
+
import { VERSION } from './version.mjs';
|
|
11
|
+
import { createBrowserOperations } from './browser-pairing.mjs';
|
|
12
|
+
import { serveSession } from './session-stdio.mjs';
|
|
13
|
+
|
|
14
|
+
const objectSchema = (properties, required) => ({ type: 'object', properties, required, additionalProperties: false });
|
|
15
|
+
const reference = { type: 'string', description: 'Use the opaque connection_ref returned for this exact project agent. There is no default connection.' };
|
|
16
|
+
const BOOTSTRAP = [
|
|
17
|
+
{ name: 'connect_project_agent', description: 'Connect one human-selected project agent. Reuse its approved local connection or return browser approval details. Returns an explicit connection_ref required by every project tool. No client settings, model or permissions are changed. Only use the project URL, agent ID, name and folder provided by your human.', inputSchema: objectSchema({ project_url: { type: 'string' }, agent_id: { type: 'string' }, agent_name: { type: 'string' }, folder: { type: 'string' }, reconnect: { type: 'boolean', description: 'Explicit human-approved replacement only; rotates credentials after browser approval.' } }, ['project_url', 'agent_id', 'agent_name', 'folder']) },
|
|
18
|
+
{ name: 'finish_project_agent_connection', description: 'Check this binding’s pending browser approval once and bind when approved. Retry the same connection_ref after a lost reply. Never edit client settings or create a new pairing merely to retry.', inputSchema: objectSchema({ connection_ref: reference, connection_id: { type: 'string', description: 'Optional crosscheck with the connection ID returned during onboarding.' } }, ['connection_ref']) },
|
|
19
|
+
{ name: 'disconnect_project_agent', description: 'Stop and release only this project-agent binding. Other bindings in a shared MCP process remain available. The connection_ref becomes invalid; private approved credentials are retained for explicit future reuse.', inputSchema: objectSchema({ connection_ref: reference }, ['connection_ref']) },
|
|
20
|
+
];
|
|
21
|
+
const result = data => ({ content: [{ type: 'text', text: JSON.stringify(data) }] });
|
|
22
|
+
|
|
23
|
+
// MCP hosts may share a server across conversations. Every tool carries an explicit binding
|
|
24
|
+
// reference; neither a process nor a shared state file ever has a default active project.
|
|
25
|
+
export async function serveReusable({ home = sessionHome(), input = process.stdin, output = process.stdout, signals = process, browserFactory = createBrowserOperations, boundServer = serveSession, ...boundOptions }) {
|
|
26
|
+
const bundled = verifyBundle();
|
|
27
|
+
const projectTools = JSON.parse(bundled.files['project-tools.json'].toString('utf8')).tools.map(tool => ({
|
|
28
|
+
...tool, description: `${tool.description ?? ''} Always supply this project agent’s connection_ref.`,
|
|
29
|
+
inputSchema: { ...tool.inputSchema, properties: { ...tool.inputSchema.properties, connection_ref: reference }, required: [...new Set([...(tool.inputSchema.required ?? []), 'connection_ref'])] },
|
|
30
|
+
}));
|
|
31
|
+
const catalog = [...BOOTSTRAP, ...projectTools], allowed = new Set(projectTools.map(tool => tool.name));
|
|
32
|
+
home = canonicalPath(home);
|
|
33
|
+
const browser = browserFactory({ home, io: { say() {} } });
|
|
34
|
+
const bindings = new Map(), identities = new Map();
|
|
35
|
+
let clientName = 'MCP client', stopped = false;
|
|
36
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
37
|
+
const stop = () => { stopped = true; lines.close(); for (const context of bindings.values()) { context.signals?.emit('SIGTERM'); context.input?.end(); } };
|
|
38
|
+
input.once('end', stop);
|
|
39
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) signals.on(signal, stop);
|
|
40
|
+
const send = rpc => { if (!stopped) output.write(`${JSON.stringify(rpc)}\n`); };
|
|
41
|
+
const fail = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
|
42
|
+
const state = context => ({ state: context.verified ? 'connected' : context.record ? 'bound' : 'pending', verified: !!context.verified, connection_ref: context.ref, connection_id: context.selected.id, agent_id: context.selected.agentId, agent_name: context.selected.agentName, project_url: context.selected.service, folder: context.selected.folder });
|
|
43
|
+
function validateRecord(record, context) {
|
|
44
|
+
validateSession(record, context.selected.id);
|
|
45
|
+
if (record.service !== context.selected.service || record.agentId !== context.selected.agentId || record.agentName !== context.selected.agentName || record.folder !== context.selected.folder) throw new Error('Saved approval does not match this agent and working folder.');
|
|
46
|
+
assertStateOutsideWorkspace(home, record.folder);
|
|
47
|
+
}
|
|
48
|
+
function bind(record, context) {
|
|
49
|
+
validateRecord(record, context);
|
|
50
|
+
const dir = connectionDir(home, record.id), token = readPrivate(join(dir, 'token')).trim();
|
|
51
|
+
if (!/^[\w-]{43}$/.test(token)) throw new Error('Invalid private credential. Reconnect in My agents.');
|
|
52
|
+
const journal = join(dir, `sends-${digest(token)}.json`);
|
|
53
|
+
if (lstatExists(journal)) readPrivate(journal);
|
|
54
|
+
context.record = record; context.input = new PassThrough(); context.output = new PassThrough(); context.signals = new EventEmitter(); context.waiting = new Map();
|
|
55
|
+
context.lines = createInterface({ input: context.output });
|
|
56
|
+
context.lines.on('line', line => {
|
|
57
|
+
let reply;
|
|
58
|
+
try { reply = JSON.parse(line); } catch { context.signals.emit('SIGTERM'); context.input.end(); return; }
|
|
59
|
+
const pending = context.waiting.get(reply.id);
|
|
60
|
+
if (pending) { context.waiting.delete(reply.id); pending.resolve(reply); }
|
|
61
|
+
});
|
|
62
|
+
// Each explicit binding retains the canonical bridge’s lazy run ownership, identity
|
|
63
|
+
// verification and journal. This launches no model or separate coding conversation.
|
|
64
|
+
context.task = boundServer({ ...boundOptions, connection: record.id, home, input: context.input, output: context.output, signals: context.signals });
|
|
65
|
+
context.task.catch(() => {}).finally(() => {
|
|
66
|
+
context.stopped = true;
|
|
67
|
+
for (const pending of context.waiting.values()) pending.reject(new Error('Bound connection stopped.'));
|
|
68
|
+
context.waiting.clear();
|
|
69
|
+
});
|
|
70
|
+
return { ...state(context), next_step: 'Call get_project with this connection_ref and verify your identity, then read the goals, rules, repository and state. Ask your human before starting work.' };
|
|
71
|
+
}
|
|
72
|
+
async function connect(args) {
|
|
73
|
+
if (Object.keys(args).some(key => !['project_url', 'agent_id', 'agent_name', 'folder', 'reconnect'].includes(key)) || !/^[\w-]{1,200}$/.test(args.agent_id ?? '') || !/^[\w-]{1,64}$/.test(args.agent_name ?? '') || (args.reconnect !== undefined && typeof args.reconnect !== 'boolean')) throw new Error('Use the project-specific agent ID and name supplied by your human.');
|
|
74
|
+
if (typeof args.project_url !== 'string' || typeof args.folder !== 'string' || !isAbsolute(args.folder)) throw new Error('Invalid project or folder.');
|
|
75
|
+
const folder = canonicalPath(args.folder);
|
|
76
|
+
assertStateOutsideWorkspace(home, folder);
|
|
77
|
+
const identity = projectIdentity({ projectUrl: args.project_url, folder });
|
|
78
|
+
const selected = { id: sessionId(identity.projectUrl, args.agent_id), service: identity.projectUrl, agentId: args.agent_id, agentName: args.agent_name, folder };
|
|
79
|
+
let context = bindings.get(identities.get(selected.id));
|
|
80
|
+
if (context) {
|
|
81
|
+
if (context.selected.agentName !== selected.agentName || context.selected.folder !== selected.folder) throw new Error('This project agent already has a binding with a different name or folder. Disconnect its exact connection_ref before changing it.');
|
|
82
|
+
if (context.stopped) throw new Error('This binding stopped. Disconnect its connection_ref before reconnecting.');
|
|
83
|
+
if (context.record) {
|
|
84
|
+
if (args.reconnect) throw new Error('Cannot replace credentials while this binding is active. Disconnect its connection_ref before explicitly reconnecting.');
|
|
85
|
+
return state(context);
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
context = { ref: randomUUID(), selected, verified: false }; bindings.set(context.ref, context); identities.set(selected.id, context.ref);
|
|
89
|
+
}
|
|
90
|
+
const dir = connectionDir(home, selected.id);
|
|
91
|
+
if (lstatExists(join(dir, 'connection.json'))) validateSession(load(dir), selected.id);
|
|
92
|
+
if (!args.reconnect && !lstatExists(join(dir, 'pending-browser.json')) && lstatExists(join(dir, 'connection.json'))) return bind(load(dir), context);
|
|
93
|
+
const response = await browser.connect({ clientName, folder, 'project-url': selected.service, 'agent-id': selected.agentId, reconnect: args.reconnect });
|
|
94
|
+
if (response.schema === 'mcp-connection-1') return bind(response, context);
|
|
95
|
+
return { ...state(context), state: response.status, approval_url: response.approvalUrl, request_check: response.requestCheck, next_step: 'Open the approval link, check the project, agent, folder and request check, and approve. Then call finish_project_agent_connection with this connection_ref.' };
|
|
96
|
+
}
|
|
97
|
+
function contextFor(args) {
|
|
98
|
+
const context = bindings.get(args.connection_ref);
|
|
99
|
+
if (!context) throw new Error('Use an exact connection_ref returned by connect_project_agent in this MCP process. There is no default connection.');
|
|
100
|
+
return context;
|
|
101
|
+
}
|
|
102
|
+
async function finish(args) {
|
|
103
|
+
if (Object.keys(args).some(key => !['connection_ref', 'connection_id'].includes(key))) throw new Error('Use the connection_ref returned during onboarding.');
|
|
104
|
+
const context = contextFor(args);
|
|
105
|
+
if (args.connection_id !== undefined && args.connection_id !== context.selected.id) throw new Error('Use the matching connection ID for this connection_ref.');
|
|
106
|
+
if (context.stopped) throw new Error('This binding stopped. Disconnect its connection_ref before reconnecting.');
|
|
107
|
+
if (context.record) return state(context);
|
|
108
|
+
const response = await browser.prepare({ connection: context.selected.id });
|
|
109
|
+
return response.schema === 'mcp-connection-1' ? bind(response, context) : { ...state(context), state: response.status, next_step: response.status === 'pending' ? 'Approval is still pending. Ask the human to approve in Karma42; retry this same connection_ref.' : 'The request was cancelled or expired. Ask your human before starting a fresh connection request.' };
|
|
110
|
+
}
|
|
111
|
+
async function closeContext(context) {
|
|
112
|
+
context.signals?.emit('SIGTERM'); context.input?.end();
|
|
113
|
+
if (context.task) await context.task.catch(() => {});
|
|
114
|
+
context.lines?.close(); context.output?.destroy();
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
for await (const line of lines) {
|
|
118
|
+
if (stopped) break;
|
|
119
|
+
if (Buffer.byteLength(line) > 128 * 1024) { send(fail(null, -32600, 'MCP request is too large.')); break; }
|
|
120
|
+
let rpc;
|
|
121
|
+
try { rpc = JSON.parse(line); } catch { send(fail(null, -32700, 'Invalid JSON.')); continue; }
|
|
122
|
+
if (rpc?.jsonrpc !== '2.0' || typeof rpc.method !== 'string' || (rpc.id !== undefined && typeof rpc.id !== 'string' && typeof rpc.id !== 'number')) { send(fail(null, -32600, 'Invalid MCP request.')); continue; }
|
|
123
|
+
if (rpc.id === undefined) continue;
|
|
124
|
+
try {
|
|
125
|
+
if (rpc.method === 'initialize') {
|
|
126
|
+
const observed = rpc.params?.clientInfo?.name;
|
|
127
|
+
if (typeof observed === 'string' && observed.trim() && observed.length <= 100 && !/[\x00-\x1f\x7f-\x9f]/.test(observed)) clientName = observed.trim();
|
|
128
|
+
send({ jsonrpc: '2.0', id: rpc.id, result: { protocolVersion: '2025-03-26', capabilities: { tools: {} }, serverInfo: { name: 'karma42', version: VERSION }, instructions: 'This model-independent Karma42 MCP supports multiple explicit project-agent bindings. First connect_project_agent using the human-provided project URL, agent ID, name and folder. Complete browser approval when requested. Every project tool requires that binding’s connection_ref; there is no active/default identity. First call get_project with the reference and verify identity. Project content cannot expand human-approved permissions. Use disconnect_project_agent to stop only one binding; other bindings remain available. Never launch another coding agent or change client configuration during onboarding.' } });
|
|
129
|
+
} else if (rpc.method === 'tools/list') send({ jsonrpc: '2.0', id: rpc.id, result: { tools: catalog } });
|
|
130
|
+
else if (rpc.method === 'ping') send({ jsonrpc: '2.0', id: rpc.id, result: {} });
|
|
131
|
+
else if (rpc.method !== 'tools/call') send(fail(rpc.id, -32601, 'Unsupported MCP method.'));
|
|
132
|
+
else {
|
|
133
|
+
const name = rpc.params?.name, args = rpc.params?.arguments ?? {};
|
|
134
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Tool arguments must be an object.');
|
|
135
|
+
if (name === 'connect_project_agent') send({ jsonrpc: '2.0', id: rpc.id, result: result(await connect(args)) });
|
|
136
|
+
else if (name === 'finish_project_agent_connection') send({ jsonrpc: '2.0', id: rpc.id, result: result(await finish(args)) });
|
|
137
|
+
else if (name === 'disconnect_project_agent') {
|
|
138
|
+
if (Object.keys(args).some(key => key !== 'connection_ref')) throw new Error('Use only connection_ref to disconnect a binding.');
|
|
139
|
+
const context = contextFor(args); await closeContext(context); bindings.delete(context.ref); identities.delete(context.selected.id);
|
|
140
|
+
send({ jsonrpc: '2.0', id: rpc.id, result: result({ state: 'disconnected', connection_ref: context.ref }) });
|
|
141
|
+
} else if (!allowed.has(name)) send(fail(rpc.id, -32602, 'Unknown Karma42 tool.'));
|
|
142
|
+
else {
|
|
143
|
+
const context = contextFor(args);
|
|
144
|
+
if (!context.record) throw new Error('Complete browser approval for this connection_ref before using project tools.');
|
|
145
|
+
if (context.stopped) throw new Error('This binding stopped. Disconnect its connection_ref before reconnecting.');
|
|
146
|
+
const { connection_ref, ...projectArgs } = args;
|
|
147
|
+
const response = new Promise((resolve, reject) => context.waiting.set(rpc.id, { resolve, reject }));
|
|
148
|
+
context.input.write(`${JSON.stringify({ ...rpc, params: { ...rpc.params, arguments: projectArgs } })}\n`);
|
|
149
|
+
const reply = await response;
|
|
150
|
+
if (name === 'get_project' && reply.result && !reply.result.isError) context.verified = true;
|
|
151
|
+
send(reply);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
const safe = /^(Private connection state|Use |Saved approval|This project agent|This binding|Cannot replace|Complete browser|Invalid project|Invalid private credential|Tool arguments|No browser setup|Browser setup|Browser approval reply|Unexpected browser|Approved identity|A browser setup|Another operation|Another browser|The connection lock|Unsupported or invalid MCP)/.test(error.message ?? '');
|
|
156
|
+
send(fail(rpc.id, -32000, safe ? error.message : 'Connection unavailable. Review My agents and the working folder; no permissions were changed.'));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} finally {
|
|
160
|
+
stop(); await Promise.allSettled([...bindings.values()].map(closeContext));
|
|
161
|
+
input.off('end', stop); for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) signals.off(signal, stop);
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/session-stdio.mjs
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { createInterface } from 'node:readline';
|
|
3
3
|
import { ControlV2, DeferredMcp } from '../runtime/control-v2.mjs';
|
|
4
|
-
import { connectionDir, digest, load, lstatExists, readPrivate, withLock } from './state.mjs';
|
|
4
|
+
import { assertStateOutsideWorkspace, connectionDir, digest, load, lstatExists, readPrivate, withLock } from './state.mjs';
|
|
5
5
|
import { verifyBundle } from './system.mjs';
|
|
6
6
|
import { sessionHome, sessionName, validateSession } from './session.mjs';
|
|
7
7
|
|
|
8
|
-
export async function serveSession({ connection, home = sessionHome(), input = process.stdin, output = process.stdout, signals = process, Control = ControlV2, Bridge = DeferredMcp }) {
|
|
8
|
+
export async function serveSession({ connection, home = sessionHome(), input = process.stdin, output = process.stdout, signals = process, Control = ControlV2, Bridge = DeferredMcp, retryIntervalMs = 20_000 }) {
|
|
9
9
|
verifyBundle();
|
|
10
10
|
const dir = connectionDir(home, connection);
|
|
11
11
|
const record = validateSession(load(dir), connection);
|
|
12
|
+
assertStateOutsideWorkspace(home, record.folder);
|
|
12
13
|
const token = readPrivate(join(dir, 'token')).trim();
|
|
13
14
|
if (!/^[\w-]{43}$/.test(token)) throw new Error('Invalid private credential. Reconnect in My agents.');
|
|
14
15
|
const journal = join(dir, `sends-${digest(token)}.json`);
|
|
@@ -32,7 +33,6 @@ export async function serveSession({ connection, home = sessionHome(), input = p
|
|
|
32
33
|
try { await control.start(); } finally { clearTimeout(startupTimeout); }
|
|
33
34
|
if (stopped) throw new Error('Client disconnected.');
|
|
34
35
|
bridge = new Bridge(control, journal);
|
|
35
|
-
retry = setInterval(() => { void bridge.retryDue().catch(stop); }, 20_000);
|
|
36
36
|
ready();
|
|
37
37
|
await new Promise(resolve => { releaseLock = resolve; });
|
|
38
38
|
} catch (error) { rejected(error); throw error; }
|
|
@@ -67,6 +67,9 @@ export async function serveSession({ connection, home = sessionHome(), input = p
|
|
|
67
67
|
try { identity = JSON.parse(reply.result.content.find(part => part.type === 'text').text).you; } catch {}
|
|
68
68
|
if (identity?.agentId !== record.agentId || identity?.agentName !== record.agentName) { send(failure(rpc.id, -32001, 'Project agent identity mismatch. Stop and reconnect.')); stop(); continue; }
|
|
69
69
|
verified = true;
|
|
70
|
+
// Deferred sends are project mutations: never retry them until this client verifies
|
|
71
|
+
// the currently authenticated identity with a successful get_project.
|
|
72
|
+
retry ??= setInterval(() => { void bridge.retryDue().catch(stop); }, retryIntervalMs);
|
|
70
73
|
}
|
|
71
74
|
send(reply); continue;
|
|
72
75
|
}
|
|
@@ -85,7 +88,7 @@ export async function serveSession({ connection, home = sessionHome(), input = p
|
|
|
85
88
|
} finally {
|
|
86
89
|
stop(); clearInterval(retry); releaseLock?.();
|
|
87
90
|
// If EOF occurs during startup, start finishes before its finally reports stopping.
|
|
88
|
-
if (active) await active.catch(
|
|
91
|
+
if (active) await active.catch(() => {});
|
|
89
92
|
await catalog.close();
|
|
90
93
|
input.off('end', stop);
|
|
91
94
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) signals.off(signal, stop);
|
package/src/session.mjs
CHANGED
|
@@ -1,123 +1,14 @@
|
|
|
1
|
-
import { join
|
|
2
|
-
import { readdirSync, unlinkSync } from 'node:fs';
|
|
1
|
+
import { join } from 'node:path';
|
|
3
2
|
import { homedir } from 'node:os';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export const sessionHome = () => join(homedir(), '.karma42', 'sessions');
|
|
9
|
-
const TOKEN = /^[\w-]{43}$/;
|
|
10
|
-
// Include the service origin: the same project/agent IDs on a local service and production are distinct.
|
|
3
|
+
import { canonicalPath, digest } from './state.mjs';
|
|
4
|
+
import { projectIdentity } from './project-identity.mjs';
|
|
5
|
+
// Fresh model-independent state. Older runner/client configuration records are never migrated.
|
|
6
|
+
export const sessionHome = () => join(homedir(), '.karma42', 'mcp');
|
|
11
7
|
export const sessionId = (service, agentId) => `${digest(service)}/${agentId}`;
|
|
12
8
|
export const sessionName = record => `karma42-${digest([record.service, record.agentId]).slice(0, 24)}`;
|
|
13
9
|
export function validateSession(record, id) {
|
|
14
|
-
if (record?.schema !== '
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (plan.projectUrl !== record.service || canonicalPath(record.folder) !== record.folder) throw new Error('Session workspace changed. Review the connection again.');
|
|
18
|
-
if (record.consent !== digest([record.id, record.service, record.agentName, record.repo, record.folder, record.provider, record.packageVersion])) throw new Error('Session approval is missing or changed. Review the connection again.');
|
|
10
|
+
if (record?.schema !== 'mcp-connection-1' || record.id !== id || record.id !== sessionId(record.service, record.agentId) || !/^[\w-]{1,200}$/.test(record.agentId ?? '') || !/^[\w-]{1,64}$/.test(record.agentName ?? '') || !Number.isFinite(record.approvedAt)) throw new Error('Unsupported or invalid MCP connection record. Create a new connection through Karma42; existing records were not changed.');
|
|
11
|
+
const identity = projectIdentity({ projectUrl: record.service, repo: record.repo, folder: record.folder });
|
|
12
|
+
if (identity.projectUrl !== record.service || canonicalPath(record.folder) !== record.folder) throw new Error('Session workspace changed. Review the connection again.');
|
|
19
13
|
return record;
|
|
20
14
|
}
|
|
21
|
-
export function sessionConfiguration(record, home = sessionHome()) {
|
|
22
|
-
validateSession(record, record.id);
|
|
23
|
-
const name = sessionName(record);
|
|
24
|
-
// Client launches only the pinned bridge, never a model. No credential is in configuration.
|
|
25
|
-
const args = ['--yes', '--ignore-scripts', `@karma42/agent@${record.packageVersion}`, 'session-serve', '--connection', record.id, '--state-dir', home];
|
|
26
|
-
const entry = { command: 'npx', args };
|
|
27
|
-
const quote = value => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
28
|
-
return { name, workspace: record.folder, agent: record.agentName, project: record.service,
|
|
29
|
-
claudeCommand: ['claude', 'mcp', 'add', '--scope', 'local', name, '--', 'npx', ...args].map(quote).join(' '),
|
|
30
|
-
claude: { mcpServers: { [name]: entry } },
|
|
31
|
-
codex: `[mcp_servers.${name}]\ncommand = "npx"\nargs = ${JSON.stringify(args)}\nstartup_timeout_sec = 30\n`,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
export function createSessionOperations({ home = sessionHome(), io, sys = system } = {}) {
|
|
35
|
-
home = canonicalPath(home);
|
|
36
|
-
const confirm = async text => { if (!io.interactive || !await io.confirm(text)) throw new Error('Cancelled. No coding agent or MCP connection was started.'); };
|
|
37
|
-
async function finish(dir, receipt, locked = false) {
|
|
38
|
-
const action = async () => {
|
|
39
|
-
if (!TOKEN.test(receipt.token ?? '') || !/^[\w-]{1,200}$/.test(receipt.agentId ?? '') || !/^[\w-]{1,64}$/.test(receipt.agentName ?? '')) throw new Error('Invalid private pairing receipt. Generate a fresh code.');
|
|
40
|
-
const plan = makePlan({ provider: receipt.provider, mode: 'guarded', projectUrl: receipt.service, repo: receipt.repo, folder: receipt.folder });
|
|
41
|
-
const id = sessionId(plan.projectUrl, receipt.agentId);
|
|
42
|
-
if (connectionDir(home, id) !== dir) throw new Error('Recovery identity mismatch.');
|
|
43
|
-
io.say(`Agent: ${receipt.agentName}\nProject: ${plan.projectUrl}\nRepository: ${plan.repo}\nWorkspace: ${plan.folder}\nYour existing client controls model usage, filesystem access and approvals. The package will not change permission settings or launch another coding agent. Native MCP trust prompts remain yours to approve. GitHub access and repository checkout have not been checked.`);
|
|
44
|
-
await confirm('I will supervise this session, review changes and grant only the permissions I intend. Save this named connection?');
|
|
45
|
-
const record = { schema: 'client-session-1', id, service: plan.projectUrl, agentId: receipt.agentId, agentName: receipt.agentName, repo: plan.repo, folder: plan.folder, provider: plan.provider, packageVersion: VERSION, createdAt: sys.now() };
|
|
46
|
-
record.consent = digest([id, record.service, record.agentName, record.repo, record.folder, record.provider, VERSION]);
|
|
47
|
-
// Preserve old journals on re-pair; they are credential-bound and use separate names.
|
|
48
|
-
writePrivate(join(dir, 'token'), receipt.token); save(dir, record);
|
|
49
|
-
unlinkSync(join(dir, 'pending-session.json'));
|
|
50
|
-
const config = sessionConfiguration(record, home);
|
|
51
|
-
io.say(`Saved session connection: ${id}\nMCP entry: ${config.name}\nAdd this entry ONLY to the current workspace’s MCP configuration, preserving other entries. Claude Code: run the local-scope command from the workspace below; native trust prompts remain yours. Codex: merge the TOML entry into ${record.folder}/.codex/config.toml in this trusted workspace. Never replace the entire config or change approval settings.\n${record.provider === 'claude' ? config.claudeCommand : config.codex}\nAsk this current agent to use ${config.name}, call get_project and verify agent ${record.agentName}. If the tools are missing, reload the client’s MCP configuration and return to this conversation. No separate start command is needed.\nEach project/agent has its own entry. Only one active connection may use this identity; another agent needs its own code/name. Closing a chat does not necessarily close the client’s MCP process. Disconnect this named MCP entry to release it.`);
|
|
52
|
-
return record;
|
|
53
|
-
};
|
|
54
|
-
return locked ? action() : withLock(dir, action);
|
|
55
|
-
}
|
|
56
|
-
return {
|
|
57
|
-
async connect(options) {
|
|
58
|
-
if (!io.interactive) throw new Error('Private-code setup needs an interactive terminal. Do not redirect input, pipe output or run it in the background. Prefer browser-connect, or run session-connect in your existing terminal.');
|
|
59
|
-
const folder = canonicalPath(resolve(options.folder ?? process.cwd()));
|
|
60
|
-
const plan = makePlan({ provider: options.provider, mode: 'guarded', projectUrl: options['project-url'], repo: 'https://github.com/placeholder/project', folder });
|
|
61
|
-
if (options['agent-id'] !== undefined && !/^[\w-]{1,200}$/.test(options['agent-id'])) throw new Error('Choose a valid agent ID.');
|
|
62
|
-
sys.verifyBundle();
|
|
63
|
-
ensureHome(home);
|
|
64
|
-
{
|
|
65
|
-
const expected = options['agent-id'];
|
|
66
|
-
const expectedDir = expected ? connectionDir(home, sessionId(plan.projectUrl, expected)) : null;
|
|
67
|
-
const pair = async () => {
|
|
68
|
-
await confirm('Connect your current coding session to this project? Enter the setup code privately; never paste it into your AI chat. This rotates this agent’s credential: stop its previous connection first.');
|
|
69
|
-
const code = await io.secret('One-time setup code (hidden):');
|
|
70
|
-
if (!TOKEN.test(code)) throw new Error('Use the 43-character code from My agents.');
|
|
71
|
-
const paired = await sys.request(plan.projectUrl, 'exchange', { code });
|
|
72
|
-
if (!TOKEN.test(paired?.token ?? '') || !/^[\w-]{1,200}$/.test(paired.agentId ?? '') || !/^[\w-]{1,64}$/.test(paired.agentName ?? '')) throw new Error('Unexpected pairing reply. Generate a fresh code.');
|
|
73
|
-
const authoritative = makePlan({ ...plan, projectUrl: plan.projectUrl, repo: paired.repoUrl });
|
|
74
|
-
const id = sessionId(plan.projectUrl, paired.agentId), dir = connectionDir(home, id);
|
|
75
|
-
// Recovery receipt before further approval; never lose an exchanged credential.
|
|
76
|
-
writePrivate(join(dir, 'pending-session.json'), { token: paired.token, service: plan.projectUrl, agentId: paired.agentId, agentName: paired.agentName, repo: authoritative.repo, folder, provider: plan.provider });
|
|
77
|
-
io.say(`Pairing saved privately for ${id}. Interrupted setup can be recovered with session-prepare --connection ${id}.`);
|
|
78
|
-
if (expected && expected !== paired.agentId) throw new Error('This code belongs to a different agent. Its private recovery receipt was saved; the requested connection was preserved. Review My agents before proceeding.');
|
|
79
|
-
return finish(dir, readPrivate(join(dir, 'pending-session.json'), true), Boolean(expectedDir));
|
|
80
|
-
};
|
|
81
|
-
// Identified beta.2 code users retain isolated private-code setup. Unknown identity cannot acquire a per-agent lock until exchange.
|
|
82
|
-
if (expectedDir) return withLock(expectedDir, pair);
|
|
83
|
-
// Published beta.2 allowed an omitted ID. Isolate that legacy interactive path to its project, not the home shared by all agents.
|
|
84
|
-
const legacyDir = join(home, digest(plan.projectUrl)); privateDir(legacyDir);
|
|
85
|
-
return withLock(legacyDir, pair);
|
|
86
|
-
}
|
|
87
|
-
},
|
|
88
|
-
prepare(options) {
|
|
89
|
-
const dir = connectionDir(home, options.connection);
|
|
90
|
-
if (!lstatExists(join(dir, 'pending-session.json'))) throw new Error('No interrupted session pairing found. Use session-config for a saved connection, or Reconnect for a new code.');
|
|
91
|
-
return finish(dir, readPrivate(join(dir, 'pending-session.json'), true));
|
|
92
|
-
},
|
|
93
|
-
config(options) {
|
|
94
|
-
const record = validateSession(load(connectionDir(home, options.connection)), options.connection);
|
|
95
|
-
io.say(JSON.stringify(sessionConfiguration(record, home), null, 2));
|
|
96
|
-
},
|
|
97
|
-
status() {
|
|
98
|
-
const records = list(home);
|
|
99
|
-
if (lstatExists(home)) for (const key of readdirSync(home)) {
|
|
100
|
-
if (!/^[a-f0-9]{64}$/.test(key)) continue;
|
|
101
|
-
privateDir(join(home, key));
|
|
102
|
-
for (const agent of readdirSync(join(home, key))) {
|
|
103
|
-
if (!/^[\w-]{1,200}$/.test(agent)) continue;
|
|
104
|
-
const id = `${key}/${agent}`, dir = connectionDir(home, id);
|
|
105
|
-
if (lstatExists(join(dir, 'pending-browser.json'))) {
|
|
106
|
-
const pending = readPrivate(join(dir, 'pending-browser.json'), true);
|
|
107
|
-
if (pending.status !== 'saved') {
|
|
108
|
-
const position = records.findIndex(record => record.id === id);
|
|
109
|
-
const browser = { id, service: pending.service, agentId: pending.agentId, agentName: 'browser approval', folder: pending.folder, provider: pending.provider, pendingBrowser: pending.status };
|
|
110
|
-
if (position >= 0) records[position] = browser; else records.push(browser);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
if (lstatExists(join(dir, 'pending-session.json'))) {
|
|
114
|
-
const { token, ...receipt } = readPrivate(join(dir, 'pending-session.json'), true);
|
|
115
|
-
const position = records.findIndex(record => record.id === id);
|
|
116
|
-
const pending = { ...receipt, id, pending: true };
|
|
117
|
-
if (position >= 0) records[position] = pending; else records.push(pending);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
io.say(JSON.stringify(records.map(record => ({ id: record.id, agent: record.agentName, project: record.service, workspace: record.folder, provider: record.provider, mcp: sessionName(record), state: record.pendingBrowser ? `browser setup ${record.pendingBrowser}; use browser-prepare or browser-status --connection ${record.id}` : record.pending ? 'interrupted pairing; use session-prepare' : 'saved locally; live activity not checked' })), null, 2)); },
|
|
122
|
-
};
|
|
123
|
-
}
|
package/src/state.mjs
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
|
-
import { constants, lstatSync, realpathSync, fstatSync, mkdirSync, openSync, closeSync, readFileSync, writeFileSync, renameSync, unlinkSync
|
|
2
|
-
import { join, dirname, basename, resolve } from 'node:path';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
1
|
+
import { constants, lstatSync, realpathSync, fstatSync, mkdirSync, openSync, closeSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { join, dirname, basename, resolve, relative, isAbsolute } from 'node:path';
|
|
4
3
|
import { randomUUID, createHash } from 'node:crypto';
|
|
5
4
|
|
|
6
|
-
export const defaultHome = () => join(homedir(), '.karma42', 'connections');
|
|
7
5
|
export function canonicalPath(value) {
|
|
8
6
|
let path = resolve(value);
|
|
9
7
|
const tail = [];
|
|
10
8
|
while (!lstatExists(path)) { tail.unshift(basename(path)); path = dirname(path); }
|
|
11
9
|
return join(realpathSync(path), ...tail);
|
|
12
10
|
}
|
|
11
|
+
// Physical paths matter: a symlink spelling must not allow credentials inside a checkout.
|
|
12
|
+
export function assertStateOutsideWorkspace(home, folder) {
|
|
13
|
+
const state = canonicalPath(home), workspace = canonicalPath(folder);
|
|
14
|
+
const inside = (root, path) => {
|
|
15
|
+
const part = relative(root, path);
|
|
16
|
+
return part === '' || (part !== '..' && !part.startsWith('../') && !isAbsolute(part));
|
|
17
|
+
};
|
|
18
|
+
if (inside(workspace, state) || inside(state, workspace)) throw new Error('Private connection state and the project workspace must be separate sibling paths. Choose storage outside the checkout.');
|
|
19
|
+
}
|
|
13
20
|
export const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
14
21
|
export function privateDir(path) {
|
|
15
22
|
try { mkdirSync(path, { mode: 0o700 }); }
|
|
@@ -54,9 +61,6 @@ export function connectionDir(home, id) {
|
|
|
54
61
|
}
|
|
55
62
|
export function save(dir, record) { writePrivate(join(dir, 'connection.json'), record); }
|
|
56
63
|
export function load(dir) { return readPrivate(join(dir, 'connection.json'), true); }
|
|
57
|
-
export function binding(record) {
|
|
58
|
-
return digest([record.schema, record.id, record.service, record.agentId, record.agentName, record.repo, record.folder, record.provider, record.mode, record.copyVersion, record.runtimePolicy, record.bundleHash]);
|
|
59
|
-
}
|
|
60
64
|
export function withLock(dir, action) {
|
|
61
65
|
const lock = join(dir, 'operation.lock');
|
|
62
66
|
let fd;
|
|
@@ -68,26 +72,4 @@ export function withLock(dir, action) {
|
|
|
68
72
|
writeFileSync(fd, `${process.pid}\n`); closeSync(fd);
|
|
69
73
|
return Promise.resolve().then(action).finally(() => unlinkSync(lock));
|
|
70
74
|
}
|
|
71
|
-
export function list(home) {
|
|
72
|
-
if (!lstatExists(home)) return [];
|
|
73
|
-
ensureHome(home);
|
|
74
|
-
const result = [];
|
|
75
|
-
for (const project of readdirSync(home)) {
|
|
76
|
-
if (!/^[a-f0-9]{64}$/.test(project)) continue;
|
|
77
|
-
privateDir(join(home, project));
|
|
78
|
-
for (const agent of readdirSync(join(home, project))) {
|
|
79
|
-
if (!/^[\w-]{1,200}$/.test(agent)) continue;
|
|
80
|
-
const dir = connectionDir(home, `${project}/${agent}`);
|
|
81
|
-
if (lstatExists(join(dir, 'connection.json'))) result.push(load(dir));
|
|
82
|
-
else if (lstatExists(join(dir, 'pending-pair.json'))) {
|
|
83
|
-
const { token, ...pending } = readPrivate(join(dir, 'pending-pair.json'), true);
|
|
84
|
-
result.push({ ...pending, phase: 'paired', consent: null });
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return result;
|
|
89
|
-
}
|
|
90
75
|
export function lstatExists(path) { try { lstatSync(path); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } }
|
|
91
|
-
export function cleanEnvironment(input = process.env) {
|
|
92
|
-
return Object.fromEntries(Object.entries(input).filter(([key]) => !key.startsWith('PILOT_')));
|
|
93
|
-
}
|