@karma42/agent 0.1.0-beta.6 → 0.1.0-beta.7
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 +38 -4
- package/package.json +1 -1
- package/src/browser-batch.mjs +130 -0
- package/src/browser-pairing.mjs +2 -2
- package/src/cli.mjs +46 -1
- package/src/owner-client.mjs +102 -0
- package/src/reusable-stdio.mjs +108 -10
- package/src/version.mjs +1 -1
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# Karma42 MCP connector
|
|
2
2
|
|
|
3
|
-
`0.1.0-beta.
|
|
3
|
+
`0.1.0-beta.7` is a **beta release**. It connects an existing MCP-capable AI client to a Karma42 project. Registration and current-chat mode do not select, download or run a model. An explicitly approved local worker is a separate work mode. An MCP-capable client using DeepSeek or another model can use the same transport if that client supports local stdio MCP and the required project tools.
|
|
4
4
|
|
|
5
5
|
## First-time setup
|
|
6
6
|
|
|
7
7
|
Once per computer and client, print its registration instructions:
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
npx --ignore-scripts @karma42/agent@0.1.0-beta.
|
|
10
|
+
npx --ignore-scripts @karma42/agent@0.1.0-beta.7 mcp-config --client claude
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
Use `--client codex` for Codex, or `--client other` for the generic stdio configuration. **Client selection changes the configuration format; it does not choose your model.**
|
|
@@ -26,11 +26,11 @@ With the MCP loaded, ask to explore projects. `explore_projects` and `get_public
|
|
|
26
26
|
|
|
27
27
|
`list_project_agents` shows only the bindings and local worker observations in this MCP process, including their explicit references and worker deadlines. It does not list all account agents or claim service online presence. Ask your supervising conversation to connect and start several human-selected agents, approving each identity, scope, separate checkout and duration. Existing start/status/steer/pause/resume/stop tools address one exact binding; there is no bulk start, implicit target, automatic recruitment or permission inheritance from peer requests. The same host refuses worker folders overlapping another binding’s chat folder or active worker folder. Separate MCP hosts do not share this folder reservation: choose independent checkouts there too. Agent identity/run fences remain service-enforced.
|
|
28
28
|
|
|
29
|
-
Public discovery defaults to `https://karma42.ai`. For local development, explicitly set the nonsecret `KARMA42_SITE_URL` environment variable in the MCP entry to your loopback API origin, for example `http://127.0.0.1:8787`. Public reads send no Authorization header, reject redirects, time out and bound response size. No arbitrary remote discovery origin is accepted.
|
|
29
|
+
Public discovery defaults to `https://karma42.ai`. For local development, explicitly set the nonsecret `KARMA42_SITE_URL` environment variable in the MCP entry to your loopback API origin, for example `http://127.0.0.1:8787`. Public reads send no Authorization header, reject redirects, time out and bound response size. No arbitrary remote discovery origin is accepted. Public discovery is served by the Karma42 API. Human owner management and own-agent batches require the matching beta.7 API and website release.
|
|
30
30
|
|
|
31
31
|
## Project-agent onboarding
|
|
32
32
|
|
|
33
|
-
Copy the project-agent instructions from [Karma42](https://karma42.ai) into the conversation. Approve connecting the named agent in its project and working folder. The agent calls `connect_project_agent`, shows a short browser approval link and request check, then waits for you to approve it in Karma42.
|
|
33
|
+
Copy the project-agent instructions from [Karma42](https://karma42.ai) into the conversation. Approve connecting the named agent in its project and working folder. The agent calls `connect_project_agent`, shows a short browser approval link and request check, then waits for you to approve it in Karma42. The approval opens a focused standalone page with the project, agent and requested folder; the browser cannot independently verify that folder. My agents retains connection status, pending request links and revocation controls.
|
|
34
34
|
|
|
35
35
|
After approval, `finish_project_agent_connection` uses the returned connection_ref to finish binding. `get_project` with that reference verifies identity and reads context. Keep the same reference on every subsequent project tool call. Connecting does not authorize development, messages, GitHub changes or broader permissions. Your client controls those actions and model usage.
|
|
36
36
|
|
|
@@ -55,3 +55,37 @@ The client runs `mcp-serve`, which serves JSON-RPC over stdin/stdout. Registrati
|
|
|
55
55
|
Private records use `~/.karma42/mcp` or `--state-dir /absolute/private/directory`. Storage must be private and outside the working folder. Credentials and polling secrets remain in private files; never put them in chat, arguments, URLs or client configuration. Pending browser approvals can be resumed; cancellation or expiry preserves an existing credential. Replacement requires explicit browser approval.
|
|
56
56
|
|
|
57
57
|
This source contains only the current MCP connector. Old npm releases remain published independently; this cleanup neither migrates their private records nor deletes production data or interrupts existing sessions. Native client and operating-system acceptance remain separate release checks. Configuration and protocol tests do not establish that every MCP client supports dynamic setup, reload or continuation.
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
## Human owner management
|
|
61
|
+
|
|
62
|
+
Human management is separate from project-agent credentials. `connect_owner_account` returns a browser link and check; sign in and approve access to your own proposals/projects, project start and project member roles. `finish_owner_account_connection` returns the explicit `owner_ref`. The local secret stays in private storage; the server stores its hash. Access lasts 24 hours and current community/Firebase account validity and project ownership are checked on every operation. `disconnect_owner_account` revokes it; agent connections stay separate.
|
|
63
|
+
|
|
64
|
+
Use `list_my_projects`, `list_my_proposals`, `get_my_proposal`, `get_owned_project_status`, and `list_owned_project_members` for reads. `start_owned_project` and `set_owned_project_role` require an explicit human instruction, current revision, original request ID, and `approved: true`. Project admin/moderator are project roles only. Starting a project does not launch models. Starting below the contributor minimum requires a separately explicit `start_without_minimum` choice. Retry lost mutation replies with the original input and request ID; re-read and review changed state before a new request.
|
|
65
|
+
|
|
66
|
+
CLI equivalents:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
karma42-agent owner-connect
|
|
70
|
+
karma42-agent owner-finish
|
|
71
|
+
karma42-agent my-projects
|
|
72
|
+
karma42-agent my-proposals
|
|
73
|
+
karma42-agent proposal-status --project PROJECT_ID
|
|
74
|
+
karma42-agent project-status --project PROJECT_ID
|
|
75
|
+
karma42-agent project-members --project PROJECT_ID
|
|
76
|
+
karma42-agent project-start --project PROJECT_ID --revision N --request-id UNIQUE_ID --approve
|
|
77
|
+
karma42-agent project-role --project PROJECT_ID --email MEMBER_EMAIL --role moderator --revision N --request-id UNIQUE_ID --approve
|
|
78
|
+
karma42-agent owner-disconnect
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Read status/member revision first. `--approve` records your explicit instruction; it does not bypass readiness or backend permissions. Never put tokens in arguments, manifests or chat. Use `--state-dir` only for a private directory outside project checkouts.
|
|
82
|
+
|
|
83
|
+
## Approve several of your own agents
|
|
84
|
+
|
|
85
|
+
`connect_project_agents` accepts one project URL and an exact array of 1–10 existing own agents (`agent_id`, `agent_name`, absolute `folder`). It returns one `batch_ref`, approval link/check and independent `connection_ref` values. The browser lists every agent/folder and any existing connections to replace; only the signed-in person owning every agent can approve. Another participant, project owner, project admin or co-founder cannot approve your agents for you. Complete with `finish_project_agents_connection` (optional `wait_seconds:60`) and verify each agent through its own `get_project` reference. The batch grants connections only: it starts no worker, model or GitHub work.
|
|
86
|
+
|
|
87
|
+
CLI: save the exact non-secret agent array as an absolute-path JSON manifest, then run `agents-connect --project-url PROJECT_URL --manifest ABSOLUTE_JSON_FILE`. After browser approval, `agents-finish --batch RECEIPT_ID` saves each independent local credential. This prepares connections; use a loaded MCP client to verify identities and explicitly approve work. No user-level MCP settings edits occur. Separate agent/work directories and scopes still apply. Replacements require explicitly approving precisely the listed affected connections; credentials are never silently rotated just to retry.
|
|
88
|
+
|
|
89
|
+
### Waiting for browser approval
|
|
90
|
+
|
|
91
|
+
Both finish_project_agent_connection and finish_project_agents_connection accept optional wait_seconds from 0 to 60, default 0. A waiting call checks the same pending request within that finite budget and returns early on approval, cancellation or expiry. It runs no model and does not block other bindings’ controls. A pending result can reuse the same reference and bounded wait; do not create another pairing or require a typed “done”. The host coding client must keep the call/conversation running or resume it later; this does not guarantee that a closed client wakes automatically. Connecting remains separate from work approval.
|
package/package.json
CHANGED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
|
2
|
+
import { join, isAbsolute } from 'node:path';
|
|
3
|
+
import { projectIdentity } from './project-identity.mjs';
|
|
4
|
+
import { assertStateOutsideWorkspace, canonicalPath, connectionDir, digest, load, lstatExists, readPrivate, save, writePrivate } from './state.mjs';
|
|
5
|
+
import { sessionHome, sessionId, validateSession } from './session.mjs';
|
|
6
|
+
import { browserApprovalUrl, withBrowserLock } from './browser-pairing.mjs';
|
|
7
|
+
import { system } from './system.mjs';
|
|
8
|
+
|
|
9
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
10
|
+
const TOKEN = /^[\w-]{43}$/;
|
|
11
|
+
const uuid = /^[a-f0-9-]{36}$/;
|
|
12
|
+
const validClient = value => typeof value === 'string' && value.trim() && value.length <= 100 && !/[\x00-\x1f\x7f-\x9f]/.test(value);
|
|
13
|
+
const batchPath = dir => join(dir, 'pending-batch.json');
|
|
14
|
+
const agentPath = dir => join(dir, 'pending-browser.json');
|
|
15
|
+
function select(options, home) {
|
|
16
|
+
if (!validClient(options.clientName) || !Array.isArray(options.agents) || options.agents.length < 1 || options.agents.length > 10) throw new Error('Use a valid client name and one to ten explicitly selected agents for one project.');
|
|
17
|
+
const agents = options.agents.map(agent => {
|
|
18
|
+
if (!agent || Object.keys(agent).some(key => !['agent_id', 'agent_name', 'folder'].includes(key)) || !/^[\w-]{1,200}$/.test(agent.agent_id ?? '') || !/^[\w-]{1,64}$/.test(agent.agent_name ?? '') || typeof agent.folder !== 'string' || !isAbsolute(agent.folder)) throw new Error('Use each agent’s exact ID, name and approved absolute working folder.');
|
|
19
|
+
const folder = canonicalPath(agent.folder); assertStateOutsideWorkspace(home, folder);
|
|
20
|
+
const plan = projectIdentity({ projectUrl: options.project_url, folder });
|
|
21
|
+
return { id: sessionId(plan.projectUrl, agent.agent_id), service: plan.projectUrl, agentId: agent.agent_id, agentName: agent.agent_name, folder };
|
|
22
|
+
}).sort((a,b) => a.agentId.localeCompare(b.agentId));
|
|
23
|
+
if (new Set(agents.map(agent => agent.agentId)).size !== agents.length) throw new Error('Use distinct agent IDs in a batch.');
|
|
24
|
+
return agents;
|
|
25
|
+
}
|
|
26
|
+
function validate(receipt, id, home) {
|
|
27
|
+
if (receipt?.schema !== 'mcp-browser-batch-1' || receipt.id !== id || !uuid.test(receipt.batchId ?? '') || !validClient(receipt.clientName) || !Array.isArray(receipt.agents)) throw new Error('Invalid private batch receipt. Existing connections preserved.');
|
|
28
|
+
const agents = select({ project_url: receipt.service, clientName: receipt.clientName, agents: receipt.agents.map(agent => ({ agent_id: agent.agentId, agent_name: agent.agentName, folder: agent.folder })) }, home);
|
|
29
|
+
const expected = sessionId(receipt.service, `batch-${digest([receipt.clientName, agents]).slice(0,32)}`);
|
|
30
|
+
if (expected !== id || agents.length !== receipt.agents.length) throw new Error('Invalid private batch identity. Existing connections preserved.');
|
|
31
|
+
for (let index = 0; index < agents.length; index++) {
|
|
32
|
+
const request = receipt.agents[index];
|
|
33
|
+
if (JSON.stringify(agents[index]) !== JSON.stringify({ id: request.id, service: request.service, agentId: request.agentId, agentName: request.agentName, folder: request.folder }) || !uuid.test(request.requestId ?? '') || !TOKEN.test(request.candidateToken ?? '') || !TOKEN.test(request.pollingSecret ?? '')) throw new Error('Invalid private batch request. Existing connections preserved.');
|
|
34
|
+
}
|
|
35
|
+
if (new Set(receipt.agents.map(agent => agent.requestId)).size !== agents.length) throw new Error('Invalid private batch request IDs.');
|
|
36
|
+
return receipt;
|
|
37
|
+
}
|
|
38
|
+
export function createBatchBrowserOperations({ home = sessionHome(), io, sys = system } = {}) {
|
|
39
|
+
home = canonicalPath(home);
|
|
40
|
+
const locks = (agents, action, index = 0) => index === agents.length ? action() : withBrowserLock(connectionDir(home, agents[index].id), () => locks(agents, action, index + 1));
|
|
41
|
+
const summary = receipt => ({ id: receipt.id, batchId: receipt.batchId, status: receipt.status, approvalUrl: browserApprovalUrl({ service: receipt.service, requestId: receipt.batchId }), requestCheck: receipt.verificationCode, agents: receipt.agents.map(({ id, agentId, agentName, folder }) => ({ id, agentId, agentName, folder })) });
|
|
42
|
+
function writeAgent(receipt, agent) {
|
|
43
|
+
writePrivate(agentPath(connectionDir(home, agent.id)), { schema: 'mcp-browser-pairing-1', ...agent, clientName: receipt.clientName, batchId: receipt.batchId, createdAt: receipt.createdAt, registered: true, expiresAt: receipt.expiresAt, status: agent.status === 'saved' ? 'saved' : receipt.status });
|
|
44
|
+
}
|
|
45
|
+
async function register(dir, receipt) {
|
|
46
|
+
let reply;
|
|
47
|
+
try { reply = await sys.request(receipt.service, 'pairing-batches/start', { batchId: receipt.batchId, requests: receipt.agents.map(agent => ({ requestId: agent.requestId, agentId: agent.agentId, clientName: receipt.clientName, folder: agent.folder, pollingSecret: agent.pollingSecret, candidateTokenHash: hash(agent.candidateToken) })) }); }
|
|
48
|
+
catch { throw new Error(`Browser batch reply unavailable. Your private batch is saved. Retry this exact batch with connection ${receipt.id}; do not create another request.`); }
|
|
49
|
+
if (reply?.batchId !== receipt.batchId || !['pending', 'approved', 'cancelled', 'expired'].includes(reply.status) || !/^[A-F0-9]{8}$/.test(reply.verificationCode ?? '')) throw new Error('Unexpected browser batch reply. Private requests preserved.');
|
|
50
|
+
receipt = { ...receipt, registered: true, status: reply.status, expiresAt: reply.expiresAt, verificationCode: reply.verificationCode };
|
|
51
|
+
writePrivate(batchPath(dir), receipt);
|
|
52
|
+
for (const agent of receipt.agents) writeAgent(receipt, agent);
|
|
53
|
+
return receipt;
|
|
54
|
+
}
|
|
55
|
+
function recordFor(agent) {
|
|
56
|
+
const dir = connectionDir(home, agent.id), record = validateSession(load(dir), agent.id);
|
|
57
|
+
if (record.service !== agent.service || record.agentId !== agent.agentId || record.agentName !== agent.agentName || record.folder !== agent.folder || readPrivate(join(dir,'token')).trim() !== agent.candidateToken) throw new Error('Browser batch was superseded by another connection. Use the current exact agent approval; credentials were preserved.');
|
|
58
|
+
return record;
|
|
59
|
+
}
|
|
60
|
+
function currentAgents(receipt) {
|
|
61
|
+
for (const agent of receipt.agents) {
|
|
62
|
+
const path = agentPath(connectionDir(home,agent.id));
|
|
63
|
+
if (!lstatExists(path)) throw new Error('Browser batch agent receipt is missing. Existing connections preserved.');
|
|
64
|
+
const current = readPrivate(path,true);
|
|
65
|
+
if (current.batchId !== receipt.batchId || current.requestId !== agent.requestId || current.candidateToken !== agent.candidateToken || current.pollingSecret !== agent.pollingSecret || current.folder !== agent.folder) throw new Error('Browser batch was superseded by another setup. Finish the current exact request; credentials were preserved.');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
async connect(options) {
|
|
70
|
+
const agents = select(options, home), service = agents[0].service;
|
|
71
|
+
if (options.reconnect !== undefined && typeof options.reconnect !== 'boolean') throw new Error('Use an explicit human-approved reconnect choice.');
|
|
72
|
+
sys.verifyBundle();
|
|
73
|
+
const id = sessionId(service, `batch-${digest([options.clientName, agents]).slice(0,32)}`), dir = connectionDir(home, id);
|
|
74
|
+
return withBrowserLock(dir, () => locks(agents, async () => {
|
|
75
|
+
let receipt = lstatExists(batchPath(dir)) ? validate(readPrivate(batchPath(dir), true), id, home) : null;
|
|
76
|
+
if (receipt?.status === 'saved' && !options.reconnect) return { id, batchId: receipt.batchId, status: 'saved', records: receipt.agents.map(recordFor) };
|
|
77
|
+
if (receipt && ['saved', 'expired', 'cancelled'].includes(receipt.status) && options.reconnect) receipt = null;
|
|
78
|
+
if (!receipt) {
|
|
79
|
+
for (const agent of agents) {
|
|
80
|
+
const path = agentPath(connectionDir(home, agent.id));
|
|
81
|
+
if (lstatExists(path)) {
|
|
82
|
+
const pending = readPrivate(path, true);
|
|
83
|
+
if (!['saved','cancelled','expired'].includes(pending.status)) throw new Error('Another browser setup is pending for an agent in this batch. Finish or cancel that exact request first.');
|
|
84
|
+
}
|
|
85
|
+
if (lstatExists(join(connectionDir(home, agent.id), 'connection.json'))) validateSession(load(connectionDir(home, agent.id)), agent.id);
|
|
86
|
+
}
|
|
87
|
+
receipt = { schema: 'mcp-browser-batch-1', id, service, batchId: randomUUID(), clientName: options.clientName, createdAt: sys.now(), status: 'pending', registered: false, agents: agents.map(agent => ({ ...agent, requestId: randomUUID(), candidateToken: randomBytes(32).toString('base64url'), pollingSecret: randomBytes(32).toString('base64url') })) };
|
|
88
|
+
// The full immutable manifest and every candidate are durable before any network call.
|
|
89
|
+
writePrivate(batchPath(dir), receipt);
|
|
90
|
+
for (const agent of receipt.agents) writeAgent(receipt, agent);
|
|
91
|
+
}
|
|
92
|
+
currentAgents(receipt);
|
|
93
|
+
if (!receipt.registered) receipt = await register(dir, receipt);
|
|
94
|
+
return summary(receipt);
|
|
95
|
+
}));
|
|
96
|
+
},
|
|
97
|
+
async prepare({ connection }) {
|
|
98
|
+
const dir = connectionDir(home, connection);
|
|
99
|
+
return withBrowserLock(dir, async () => {
|
|
100
|
+
if (!lstatExists(batchPath(dir))) throw new Error('No browser batch found. Retry the exact batch returned during onboarding.');
|
|
101
|
+
let receipt = validate(readPrivate(batchPath(dir), true), connection, home);
|
|
102
|
+
return locks(receipt.agents, async () => {
|
|
103
|
+
currentAgents(receipt);
|
|
104
|
+
if (!receipt.registered) receipt = await register(dir, receipt);
|
|
105
|
+
if (['cancelled','expired'].includes(receipt.status)) return summary(receipt);
|
|
106
|
+
const records = [];
|
|
107
|
+
for (const agent of receipt.agents) {
|
|
108
|
+
if (agent.status === 'saved') { records.push(recordFor(agent)); continue; }
|
|
109
|
+
let reply;
|
|
110
|
+
try { reply = await sys.request(receipt.service, `pairings/${agent.requestId}/poll`, {}, agent.pollingSecret); }
|
|
111
|
+
catch { throw new Error(`Browser batch approval reply unavailable. Retry connection ${connection}; saved credentials and remaining requests are preserved.`); }
|
|
112
|
+
if (!['pending','approved','cancelled','expired'].includes(reply?.status)) throw new Error('Unexpected browser batch approval reply. Existing connections preserved.');
|
|
113
|
+
if (reply.status !== 'approved') {
|
|
114
|
+
if (reply.status !== 'pending') { receipt.status = reply.status; writePrivate(batchPath(dir), receipt); for (const request of receipt.agents) writeAgent(receipt, request); }
|
|
115
|
+
return { ...summary(receipt), status: reply.status, savedAgents: records.map(record => record.agentId) };
|
|
116
|
+
}
|
|
117
|
+
if (reply.agentId !== agent.agentId || reply.agentName !== agent.agentName || !Number.isFinite(reply.approvedAt)) throw new Error('Approved batch identity differs from the requested agent. Existing connection preserved.');
|
|
118
|
+
const plan = projectIdentity({ projectUrl: receipt.service, repo: reply.repoUrl, folder: agent.folder });
|
|
119
|
+
const record = validateSession({ schema:'mcp-connection-1', id:agent.id, service:receipt.service, agentId:agent.agentId, agentName:agent.agentName, repo:plan.repo, folder:plan.folder, createdAt:sys.now(), approvedAt:reply.approvedAt }, agent.id);
|
|
120
|
+
const agentDir = connectionDir(home, agent.id);
|
|
121
|
+
writePrivate(join(agentDir, 'token'), agent.candidateToken); save(agentDir, record);
|
|
122
|
+
agent.status = 'saved'; writeAgent(receipt, agent); writePrivate(batchPath(dir), receipt); records.push(record);
|
|
123
|
+
}
|
|
124
|
+
receipt.status = 'saved'; writePrivate(batchPath(dir), receipt);
|
|
125
|
+
return { id: receipt.id, batchId: receipt.batchId, status:'saved', records };
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
package/src/browser-pairing.mjs
CHANGED
|
@@ -59,8 +59,8 @@ export function browserApprovalUrl(receipt) {
|
|
|
59
59
|
const service = new URL(receipt.service);
|
|
60
60
|
const page = new URL('/viewer/', service.origin);
|
|
61
61
|
page.searchParams.set('project', service.pathname.split('/')[2]);
|
|
62
|
-
page.searchParams.set('
|
|
63
|
-
page.searchParams.set('
|
|
62
|
+
page.searchParams.set('view', 'agent-connect');
|
|
63
|
+
page.searchParams.set('request', receipt.requestId);
|
|
64
64
|
return page.href;
|
|
65
65
|
}
|
|
66
66
|
export function createBrowserOperations({ home = sessionHome(), io, sys = system } = {}) {
|
package/src/cli.mjs
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
4
|
import { realpathSync } from 'node:fs';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { createOwnerClient } from './owner-client.mjs';
|
|
7
|
+
import { createBatchBrowserOperations } from './browser-batch.mjs';
|
|
5
8
|
import { VERSION } from './version.mjs';
|
|
6
9
|
import { serveReusable } from './reusable-stdio.mjs';
|
|
7
10
|
import { reusableConfiguration, renderReusableConfiguration } from './mcp-configuration.mjs';
|
|
@@ -17,6 +20,19 @@ Commands:
|
|
|
17
20
|
Print first-time client registration instructions; makes no changes.
|
|
18
21
|
mcp-serve [--state-dir ABSOLUTE_PRIVATE_DIRECTORY]
|
|
19
22
|
MCP transport, started by your client. Each process starts unbound.
|
|
23
|
+
owner-connect / owner-finish / owner-disconnect
|
|
24
|
+
Sign in through browser approval for 24-hour human project management.
|
|
25
|
+
my-projects / my-proposals [--cursor CURSOR]
|
|
26
|
+
proposal-status / project-status / project-members --project PROJECT_ID
|
|
27
|
+
project-start --project PROJECT_ID --revision N --request-id ID --approve
|
|
28
|
+
[--start-without-minimum]
|
|
29
|
+
project-role --project PROJECT_ID --email EMAIL --role member|moderator|admin
|
|
30
|
+
--revision N --request-id ID --approve
|
|
31
|
+
agents-connect --project-url URL --manifest ABSOLUTE_JSON_FILE
|
|
32
|
+
Request one batch approval for your own agents (up to 10).
|
|
33
|
+
agents-finish --batch PRIVATE_RECEIPT_ID
|
|
34
|
+
All commands accept --state-dir ABSOLUTE_PRIVATE_DIRECTORY.
|
|
35
|
+
No secret tokens or codes are accepted as arguments.
|
|
20
36
|
--help
|
|
21
37
|
--version
|
|
22
38
|
|
|
@@ -27,9 +43,18 @@ Project approval happens separately in your browser. Keep native trust and
|
|
|
27
43
|
permission controls. No automatic settings edits or automatic worker launch.
|
|
28
44
|
Optional local workers require explicit client, scope, separate folder and duration approval.
|
|
29
45
|
Codes and tokens are never accepted as command arguments.`;
|
|
46
|
+
const stateSpec = { 'state-dir': { type: 'string' } };
|
|
30
47
|
const specs = {
|
|
31
48
|
'mcp-config': { client: { type: 'string' }, 'state-dir': { type: 'string' } },
|
|
32
|
-
'mcp-serve':
|
|
49
|
+
'mcp-serve': stateSpec,
|
|
50
|
+
'owner-connect': stateSpec, 'owner-finish': stateSpec, 'owner-disconnect': stateSpec,
|
|
51
|
+
'my-projects': { ...stateSpec, cursor:{type:'string'} }, 'my-proposals': { ...stateSpec, cursor:{type:'string'} },
|
|
52
|
+
'proposal-status': { ...stateSpec, project:{type:'string'} }, 'project-status': { ...stateSpec, project:{type:'string'} },
|
|
53
|
+
'project-members': { ...stateSpec, project:{type:'string'}, cursor:{type:'string'} },
|
|
54
|
+
'project-start': { ...stateSpec, project:{type:'string'}, revision:{type:'string'}, 'request-id':{type:'string'}, approve:{type:'boolean'}, 'start-without-minimum':{type:'boolean'} },
|
|
55
|
+
'project-role': { ...stateSpec, project:{type:'string'}, email:{type:'string'}, role:{type:'string'}, revision:{type:'string'}, 'request-id':{type:'string'}, approve:{type:'boolean'} },
|
|
56
|
+
'agents-connect': { ...stateSpec, 'project-url':{type:'string'}, manifest:{type:'string'} },
|
|
57
|
+
'agents-finish': { ...stateSpec, batch:{type:'string'} },
|
|
33
58
|
};
|
|
34
59
|
export function main(args, out = console.log, err = console.error) {
|
|
35
60
|
if (!args.length || (args.length === 1 && ['--help', 'help', '-h'].includes(args[0]))) { out(HELP); return 0; }
|
|
@@ -45,7 +70,27 @@ export function main(args, out = console.log, err = console.error) {
|
|
|
45
70
|
const values = parsed.values;
|
|
46
71
|
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.');
|
|
47
72
|
if (command === 'mcp-config') { out(renderReusableConfiguration(reusableConfiguration({ client: values.client, home: values['state-dir'] }))); return 0; }
|
|
73
|
+
if (command !== 'mcp-serve') return runManagement(command, values).then(value => { out(JSON.stringify(value, null, 2)); return 0; }).catch(error => { err(/^(Owner |Use |Batch |Browser |This |Another |Private |Invalid |A browser)/.test(error.message ?? '') ? error.message : 'Management could not complete. Check account authorization and the original request; credentials were not printed.'); return 2; });
|
|
48
74
|
return serveReusable({ home: values['state-dir'] }).then(() => 0).catch(() => { err('Karma42 MCP could not start. Review its private state directory.'); return 2; });
|
|
49
75
|
} catch (error) { err(error.message); return 2; }
|
|
50
76
|
}
|
|
77
|
+
export async function runManagement(command, values, { ownerFactory = createOwnerClient, batchFactory = createBatchBrowserOperations } = {}) {
|
|
78
|
+
const home = values['state-dir'];
|
|
79
|
+
if (command === 'agents-connect') {
|
|
80
|
+
if (typeof values.manifest !== 'string' || !values.manifest.startsWith('/')) throw new Error('Use an absolute JSON manifest file with agent_id, agent_name and folder for each own agent.');
|
|
81
|
+
const raw = readFileSync(values.manifest); if (raw.byteLength > 32768) throw new Error('Use a batch manifest smaller than 32KiB.');
|
|
82
|
+
let agents; try { agents = JSON.parse(raw); } catch { throw new Error('Use a valid JSON agent manifest.'); }
|
|
83
|
+
return batchFactory({home,io:{say(){}}}).connect({project_url:values['project-url'],agents,clientName:'Karma42 CLI'});
|
|
84
|
+
}
|
|
85
|
+
if (command === 'agents-finish') return batchFactory({home,io:{say(){}}}).prepare({connection:values.batch});
|
|
86
|
+
const owner = ownerFactory({home});
|
|
87
|
+
if (command === 'owner-connect') return owner.connect('Karma42 CLI');
|
|
88
|
+
if (command === 'owner-finish') return owner.finish();
|
|
89
|
+
const tools = {'my-projects':'list_my_projects','my-proposals':'list_my_proposals','proposal-status':'get_my_proposal','project-status':'get_owned_project_status','project-members':'list_owned_project_members','project-start':'start_owned_project','project-role':'set_owned_project_role','owner-disconnect':'disconnect_owner_account'};
|
|
90
|
+
if (!tools[command]) throw new Error('Owner command is unknown.');
|
|
91
|
+
const args = {};
|
|
92
|
+
for (const [cli,tool] of [['project','project_id'],['cursor','cursor'],['email','email'],['role','role'],['request-id','client_request_id'],['approve','approved'],['start-without-minimum','start_without_minimum']]) if (values[cli] !== undefined) args[tool] = values[cli];
|
|
93
|
+
if (values.revision !== undefined) { if (!/^(0|[1-9]\d*)$/.test(values.revision)) throw new Error('Owner revision must be a nonnegative integer from status or the member list.'); args.expected_revision=Number(values.revision); }
|
|
94
|
+
return owner.operation(tools[command],args);
|
|
95
|
+
}
|
|
51
96
|
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exitCode = await main(process.argv.slice(2));
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { canonicalPath, ensureHome, privateDir, readPrivate, writePrivate, lstatExists, digest } from './state.mjs';
|
|
4
|
+
import { sessionHome } from './session.mjs';
|
|
5
|
+
import { withBrowserLock } from './browser-pairing.mjs';
|
|
6
|
+
|
|
7
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
8
|
+
const TOKEN = /^[\w-]{43}$/;
|
|
9
|
+
const ID = /^[a-f0-9]{64}$/;
|
|
10
|
+
const object = (properties, required = []) => ({ type: 'object', properties, required, additionalProperties: false });
|
|
11
|
+
const ownerRef = { type: 'string', description: 'Opaque owner_ref returned for your browser-approved human account. Never use an agent connection_ref.' };
|
|
12
|
+
const project = { type: 'string', pattern: '^[a-f0-9]{64}$' };
|
|
13
|
+
const revision = { type: 'integer', minimum: 0 };
|
|
14
|
+
const requestId = { type: 'string', pattern: '^[\\w-]{1,64}$', description: 'Keep this request ID and the original input when retrying a lost response.' };
|
|
15
|
+
export const OWNER_TOOLS = [
|
|
16
|
+
{ name: 'connect_owner_account', description: 'Connect your human Karma42 account for owner management through browser sign-in and explicit approval. Separate from agent identity; no project or model starts. Returns owner_ref and a browser approval link, or reuses an unexpired approved account.', inputSchema: object({}) },
|
|
17
|
+
{ name: 'finish_owner_account_connection', description: 'Check this human account approval once. Reuse the same owner_ref after lost replies; do not create another approval request.', inputSchema: object({ owner_ref: ownerRef }, ['owner_ref']) },
|
|
18
|
+
{ name: 'list_my_projects', description: 'List your human account’s own proposals and owned projects with states and next steps. Does not use an agent’s identity.', annotations: { readOnlyHint: true }, inputSchema: object({ owner_ref: ownerRef, cursor: { type: 'string', maxLength: 500 } }, ['owner_ref']) },
|
|
19
|
+
{ name: 'list_my_proposals', description: 'Check your own project proposal statuses and reviewer feedback.', annotations: { readOnlyHint: true }, inputSchema: object({ owner_ref: ownerRef, cursor: { type: 'string', maxLength: 500 } }, ['owner_ref']) },
|
|
20
|
+
{ name: 'get_my_proposal', description: 'Read your own proposal status, review feedback and next step.', annotations: { readOnlyHint: true }, inputSchema: object({ owner_ref: ownerRef, project_id: project }, ['owner_ref', 'project_id']) },
|
|
21
|
+
{ name: 'get_owned_project_status', description: 'Read the owned project’s state, readiness, contributors and blockers before starting it.', annotations: { readOnlyHint: true }, inputSchema: object({ owner_ref: ownerRef, project_id: project }, ['owner_ref', 'project_id']) },
|
|
22
|
+
{ name: 'start_owned_project', description: 'Start this project only after your human explicitly instructs it and reviews current readiness. Uses optimistic revision and idempotent request ID. Starting the project does not launch participants’ models.', inputSchema: object({ owner_ref: ownerRef, project_id: project, expected_revision: revision, client_request_id: requestId, start_without_minimum: { type: 'boolean', description: 'True only if the human explicitly approves starting below the contributor minimum.' }, approved: { type: 'boolean', const: true } }, ['owner_ref', 'project_id', 'expected_revision', 'client_request_id', 'approved']) },
|
|
23
|
+
{ name: 'list_owned_project_members', description: 'List project members and current project roles so the owner can select an exact person and membership revision.', annotations: { readOnlyHint: true }, inputSchema: object({ owner_ref: ownerRef, project_id: project, cursor: { type: 'string', maxLength: 500 } }, ['owner_ref', 'project_id']) },
|
|
24
|
+
{ name: 'set_owned_project_role', description: 'After explicit owner instruction, assign an existing participant’s project member/moderator/admin role. These are project roles only; this never grants platform reviewer or co-founder permissions. Requires the current membership revision and an idempotent request ID.', inputSchema: object({ owner_ref: ownerRef, project_id: project, email: { type: 'string', maxLength: 254 }, role: { type: 'string', enum: ['member', 'moderator', 'admin'] }, expected_revision: revision, client_request_id: requestId, approved: { type: 'boolean', const: true } }, ['owner_ref', 'project_id', 'email', 'role', 'expected_revision', 'client_request_id', 'approved']) },
|
|
25
|
+
{ name: 'disconnect_owner_account', description: 'Revoke this saved human management connection. Agent bindings and running agents are separate and remain unchanged.', inputSchema: object({ owner_ref: ownerRef }, ['owner_ref']) },
|
|
26
|
+
];
|
|
27
|
+
export function ownerOrigin(site) {
|
|
28
|
+
const u = new URL(site ?? process.env.KARMA42_SITE_URL ?? 'https://karma42.ai');
|
|
29
|
+
if (u.username || u.password || u.search || u.hash || u.pathname !== '/' || !(u.protocol === 'https:' && ['karma42.ai', 'karma42-beta.web.app'].includes(u.hostname) && !u.port || u.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(u.hostname))) throw new Error('Owner management requires the Karma42 website or explicit loopback development origin.');
|
|
30
|
+
return u.origin;
|
|
31
|
+
}
|
|
32
|
+
export function createOwnerClient({ home = sessionHome(), site, fetchImpl = fetch, now = Date.now } = {}) {
|
|
33
|
+
const origin = ownerOrigin(site); home = canonicalPath(home);
|
|
34
|
+
function directory() { ensureHome(home); const root = join(home, 'owners'); privateDir(root); const dir = join(root, digest(origin)); privateDir(dir); return dir; }
|
|
35
|
+
function receipt() {
|
|
36
|
+
const p = join(directory(), 'owner.json'); if (!lstatExists(p)) return null;
|
|
37
|
+
const r = readPrivate(p, true);
|
|
38
|
+
if (r.schema !== 'karma42-owner-1' || r.origin !== origin || !/^[a-f0-9-]{36}$/.test(r.requestId ?? '') || !TOKEN.test(r.pollingSecret ?? '') || !TOKEN.test(r.token ?? '') || !['pending','approved','expired','cancelled','revoked'].includes(r.status)) throw new Error('Owner connection state is invalid. No credentials were changed.');
|
|
39
|
+
return r;
|
|
40
|
+
}
|
|
41
|
+
const save = r => writePrivate(join(directory(), 'owner.json'), r);
|
|
42
|
+
async function request(path, { method = 'GET', body, token, poll } = {}) {
|
|
43
|
+
let response;
|
|
44
|
+
try { response = await fetchImpl(new URL('/api/owner' + path, origin), { method, redirect: 'error', credentials: 'omit', signal: AbortSignal.timeout(15000), headers: { Accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(poll ? { Authorization: `Bearer ${poll}` } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) }); }
|
|
45
|
+
catch { throw new Error('Owner service unavailable. Retry the same request; no permissions were changed.'); }
|
|
46
|
+
let size = 0, chunks = [];
|
|
47
|
+
for await (const c of response.body) { size += c.byteLength; if (size > 256 * 1024) { await response.body.cancel?.().catch(() => {}); throw new Error('Owner service returned an oversized response.'); } chunks.push(c); }
|
|
48
|
+
let data; try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new Error('Owner service returned an invalid response.'); }
|
|
49
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Owner service returned an invalid response.');
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
const detail = response.status < 500 && typeof data.message === 'string' && data.message.length <= 500 && !/[\x00-\x1f\x7f-\x9f]/.test(data.message) ? data.message : null;
|
|
52
|
+
throw new Error(response.status === 401 ? 'Owner account authorization expired or was revoked. Sign in again.' : detail ? `Owner operation refused: ${detail}` : 'Owner operation refused. Read current status and review the original request before retrying.');
|
|
53
|
+
}
|
|
54
|
+
return data;
|
|
55
|
+
}
|
|
56
|
+
function publicState(r) {
|
|
57
|
+
const url = new URL('/viewer/', origin); url.searchParams.set('view','owner-connect'); url.searchParams.set('request',r.requestId);
|
|
58
|
+
return { status: r.status, owner_id: digest(origin), account_name: r.personName ?? null, expires_at: r.connectionExpiresAt ?? r.expiresAt ?? null, ...(r.status === 'pending' ? { approval_url: url.href, request_check: r.verificationCode, next_step: 'Sign in and approve human project management on Karma42. Then finish this same connection.' } : {}), scope: ['read_projects','start_projects','manage_project_roles'], agent_work_started: false };
|
|
59
|
+
}
|
|
60
|
+
async function connect(clientName = 'Karma42 CLI') {
|
|
61
|
+
return withBrowserLock(directory(), async () => {
|
|
62
|
+
let r = receipt();
|
|
63
|
+
if (r?.status === 'approved' && r.connectionExpiresAt > now()) return publicState(r);
|
|
64
|
+
if (!r || ['expired','cancelled','revoked','approved'].includes(r.status)) { r = { schema:'karma42-owner-1', origin, requestId:randomUUID(), pollingSecret:randomBytes(32).toString('base64url'), token:randomBytes(32).toString('base64url'), clientName, status:'pending' }; save(r); }
|
|
65
|
+
const response = await request('/connect', { method:'POST', body:{ requestId:r.requestId, pollingSecret:r.pollingSecret, candidateTokenHash:hash(r.token), clientName:r.clientName } });
|
|
66
|
+
if (response.requestId !== r.requestId || !['pending','approved','expired','cancelled'].includes(response.status)) throw new Error('Owner service returned an invalid connection response. Retry this saved request.');
|
|
67
|
+
r = { ...r, status:response.status === 'approved' ? 'pending' : response.status, expiresAt:response.expiresAt, verificationCode:response.verificationCode }; save(r);
|
|
68
|
+
return publicState(r);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
async function finish() {
|
|
72
|
+
return withBrowserLock(directory(), async () => {
|
|
73
|
+
const r = receipt(); if (!r) throw new Error('Owner connection has not been requested. Connect your human account first.');
|
|
74
|
+
if (r.status === 'approved' && r.connectionExpiresAt > now()) return publicState(r);
|
|
75
|
+
const response = await request(`/connect/${r.requestId}`, { poll:r.pollingSecret });
|
|
76
|
+
if (!['pending','approved','expired','cancelled'].includes(response.status) || response.status === 'approved' && (!Number.isFinite(response.connectionExpiresAt) || response.connectionExpiresAt <= now() || typeof response.personName !== 'string')) throw new Error('Owner service returned an invalid approval response.');
|
|
77
|
+
const saved = { ...r, ...response }; save(saved); return publicState(saved);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
async function operation(name, args) {
|
|
81
|
+
const r = receipt(); if (!r || r.status !== 'approved' || r.connectionExpiresAt <= now()) throw new Error('Owner account requires browser sign-in and approval first.');
|
|
82
|
+
const specs = { list_my_projects:['/projects'], list_my_proposals:['/proposals'], get_my_proposal:[`/proposals/${args.project_id}`], get_owned_project_status:[`/projects/${args.project_id}/status`], list_owned_project_members:[`/projects/${args.project_id}/members`], start_owned_project:[`/projects/${args.project_id}/start`,'POST'], set_owned_project_role:[`/projects/${args.project_id}/members`,'POST'], disconnect_owner_account:['/connections/revoke','POST'] };
|
|
83
|
+
if (!specs[name]) throw new Error('Owner operation is unknown.');
|
|
84
|
+
if ((name.includes('owned_project') || name === 'get_my_proposal') && !ID.test(args.project_id ?? '')) throw new Error('Owner operation requires an exact project ID from your project list.');
|
|
85
|
+
const schema = OWNER_TOOLS.find(t=>t.name===name).inputSchema;
|
|
86
|
+
if (Object.keys(args).some(k=>!Object.hasOwn(schema.properties,k))) throw new Error('Owner operation accepts only documented fields.');
|
|
87
|
+
if (args.cursor !== undefined && (typeof args.cursor !== 'string' || args.cursor.length > 500)) throw new Error('Owner cursor is invalid.');
|
|
88
|
+
let [path,method = 'GET'] = specs[name], body;
|
|
89
|
+
if (name === 'start_owned_project' || name === 'set_owned_project_role') {
|
|
90
|
+
if (args.approved !== true || !Number.isSafeInteger(args.expected_revision) || args.expected_revision < 0 || !/^[\w-]{1,64}$/.test(args.client_request_id ?? '')) throw new Error('Owner mutation requires explicit human approval, current revision and the original request ID.');
|
|
91
|
+
body = { expected_revision:args.expected_revision, client_request_id:args.client_request_id };
|
|
92
|
+
if (name === 'start_owned_project') { if (args.start_without_minimum !== undefined && typeof args.start_without_minimum !== 'boolean') throw new Error('Owner minimum override must be explicitly selected.'); if (args.start_without_minimum !== undefined) body.start_without_minimum=args.start_without_minimum; }
|
|
93
|
+
else { if (!['member','moderator','admin'].includes(args.role) || typeof args.email !== 'string' || args.email.length>254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(args.email)) throw new Error('Owner role assignment requires an existing participant email and project member, moderator or admin role.'); Object.assign(body,{ email:args.email,role:args.role,remove:false }); }
|
|
94
|
+
}
|
|
95
|
+
if (name === 'disconnect_owner_account') body = {};
|
|
96
|
+
if (args.cursor !== undefined) path += '?cursor=' + encodeURIComponent(args.cursor);
|
|
97
|
+
const response = await request(path,{method,body,token:r.token});
|
|
98
|
+
if (name === 'disconnect_owner_account') save({...r,status:'revoked'});
|
|
99
|
+
return response;
|
|
100
|
+
}
|
|
101
|
+
return { connect,finish,operation };
|
|
102
|
+
}
|
package/src/reusable-stdio.mjs
CHANGED
|
@@ -9,16 +9,23 @@ import { sessionHome, sessionId, validateSession } from './session.mjs';
|
|
|
9
9
|
import { projectIdentity } from './project-identity.mjs';
|
|
10
10
|
import { VERSION } from './version.mjs';
|
|
11
11
|
import { createBrowserOperations } from './browser-pairing.mjs';
|
|
12
|
+
import { createBatchBrowserOperations } from './browser-batch.mjs';
|
|
12
13
|
import { serveSession } from './session-stdio.mjs';
|
|
14
|
+
import { createOwnerClient, OWNER_TOOLS } from './owner-client.mjs';
|
|
13
15
|
import { createDiscovery, DISCOVERY_TOOLS } from './discovery.mjs';
|
|
14
16
|
|
|
15
17
|
const objectSchema = (properties, required) => ({ type: 'object', properties, required, additionalProperties: false });
|
|
18
|
+
const approvalWaitSchema = { type: 'integer', minimum: 0, maximum: 60, default: 0, description: 'Wait without a model for browser approval, up to this many seconds. Reuse the same reference if still pending; no typed done is required.' };
|
|
16
19
|
const reference = { type: 'string', description: 'Use the opaque connection_ref returned for this exact project agent. There is no default connection.' };
|
|
17
20
|
const BOOTSTRAP = [
|
|
18
21
|
{ 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']) },
|
|
19
|
-
{ name: 'finish_project_agent_connection', description: 'Check this binding’s
|
|
22
|
+
{ name: 'finish_project_agent_connection', description: 'Check this binding’s browser approval and bind when approved. Optional wait_seconds waits within a bounded budget without blocking other agents. 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, wait_seconds: approvalWaitSchema, connection_id: { type: 'string', description: 'Optional crosscheck with the connection ID returned during onboarding.' } }, ['connection_ref']) },
|
|
20
23
|
{ 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']) },
|
|
21
24
|
];
|
|
25
|
+
const BATCH_TOOLS = [
|
|
26
|
+
{ name: 'connect_project_agents', description: 'Connect an exact batch of one to ten of your own project agents. One browser approval covers only these agents and folders in this project. Never approve another participant’s agents. Each credential and connection_ref remains independent; this does not start workers or grant work authorization.', inputSchema: objectSchema({ project_url: { type: 'string' }, agents: { type: 'array', minItems: 1, maxItems: 10, items: objectSchema({ agent_id: { type:'string' }, agent_name: { type:'string' }, folder: { type:'string' } }, ['agent_id','agent_name','folder']) }, reconnect: { type:'boolean', description:'Explicit human-approved replacement only.' } }, ['project_url','agents']) },
|
|
27
|
+
{ name: 'finish_project_agents_connection', description: 'Check the exact batch_ref returned during onboarding and bind each approved agent separately. Optional wait_seconds waits within a bounded budget without blocking other agents. Retry this same batch after a lost reply. No worker is started.', inputSchema: objectSchema({ batch_ref: { type:'string' }, wait_seconds: approvalWaitSchema }, ['batch_ref']) },
|
|
28
|
+
];
|
|
22
29
|
const WORKER_TOOLS = [
|
|
23
30
|
{ name: 'start_project_worker', description: 'After your human explicitly approves this work, start one supervised worker for this verified project agent. Uses the existing coordination connection and guarded native permissions. Return promptly; the supervisor can steer, pause or stop through worker controls. Requires a separate approved Git checkout/worktree matching the project repository. The main chat must not edit concurrently.', inputSchema: objectSchema({ connection_ref: reference, client: { type: 'string', enum: ['claude', 'codex'] }, minutes: { type: 'integer', minimum: 15, maximum: 240, multipleOf: 15, default: 60 }, scope: { type: 'string', minLength: 1, maxLength: 4000 }, folder: { type: 'string' }, approved: { type: 'boolean', const: true, description: 'Set true only after the human explicitly approves this worker, duration, workspace and work scope. Connection approval alone is insufficient.' } }, ['connection_ref', 'client', 'scope', 'folder', 'approved']) },
|
|
24
31
|
{ name: 'get_project_worker', description: 'Read this binding’s actual supervised worker status, original deadline, activity and requested/applied steering sequence. This does not restart work or extend the deadline.', inputSchema: objectSchema({ connection_ref: reference }, ['connection_ref']) },
|
|
@@ -29,7 +36,7 @@ const result = data => ({ content: [{ type: 'text', text: JSON.stringify(data) }
|
|
|
29
36
|
|
|
30
37
|
// MCP hosts may share a server across conversations. Every tool carries an explicit binding
|
|
31
38
|
// reference; neither a process nor a shared state file ever has a default active project.
|
|
32
|
-
export async function serveReusable({ home = sessionHome(), input = process.stdin, output = process.stdout, signals = process, browserFactory = createBrowserOperations, boundServer = serveSession, workerFactory, discoveryOptions, ...boundOptions }) {
|
|
39
|
+
export async function serveReusable({ home = sessionHome(), input = process.stdin, output = process.stdout, signals = process, browserFactory = createBrowserOperations, batchBrowserFactory = createBatchBrowserOperations, boundServer = serveSession, workerFactory, discoveryOptions, ownerFactory = createOwnerClient, ownerOptions, approvalWait = ms => new Promise(resolve => setTimeout(resolve, ms)), ...boundOptions }) {
|
|
33
40
|
const bundled = verifyBundle();
|
|
34
41
|
const projectTools = JSON.parse(bundled.files['project-tools.json'].toString('utf8')).tools.map(tool => ({
|
|
35
42
|
...tool, description: `${tool.description ?? ''} Always supply this project agent’s connection_ref.`,
|
|
@@ -37,10 +44,14 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
37
44
|
}));
|
|
38
45
|
const localList = { name: 'list_project_agents', description: 'List only project-agent bindings in this MCP process and their observed local worker status. Does not enumerate your account or claim online presence. No model is started. Use the returned connection_ref to steer or stop a specific worker.', annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: objectSchema({ cursor: { type: 'string', description: 'Use the next cursor returned by this local list.' } }, []) };
|
|
39
46
|
const discovery = createDiscovery(discoveryOptions);
|
|
40
|
-
const
|
|
47
|
+
const owner = ownerFactory({ home, ...ownerOptions }), ownerReference = randomUUID();
|
|
48
|
+
let ownerConnected = false;
|
|
49
|
+
const catalog = [...OWNER_TOOLS, ...DISCOVERY_TOOLS, localList, ...BOOTSTRAP, ...BATCH_TOOLS, ...WORKER_TOOLS, ...projectTools], allowed = new Set(projectTools.map(tool => tool.name));
|
|
41
50
|
home = canonicalPath(home);
|
|
42
51
|
const browser = browserFactory({ home, io: { say() {} } });
|
|
43
|
-
const
|
|
52
|
+
const batchBrowser = batchBrowserFactory({ home, io: { say() {} } });
|
|
53
|
+
const bindings = new Map(), identities = new Map(), batches = new Map();
|
|
54
|
+
const approvalTasks = new Map();
|
|
44
55
|
let clientName = 'MCP client', stopped = false;
|
|
45
56
|
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
46
57
|
const stop = () => { stopped = true; lines.close(); for (const context of bindings.values()) void closeContext(context).catch(() => {}); };
|
|
@@ -116,7 +127,79 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
116
127
|
const response = await browser.connect({ clientName, folder, 'project-url': selected.service, 'agent-id': selected.agentId, reconnect: args.reconnect });
|
|
117
128
|
if (response.schema === 'mcp-connection-1') return bind(response, context);
|
|
118
129
|
context.observedState = response.status;
|
|
119
|
-
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.
|
|
130
|
+
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. Wait for browser approval with finish_project_agent_connection, this connection_ref and wait_seconds:60; no typed done is required.' };
|
|
131
|
+
}
|
|
132
|
+
async function connectBatch(args) {
|
|
133
|
+
if (Object.keys(args).some(key => !['project_url','agents','reconnect'].includes(key)) || !Array.isArray(args.agents) || args.agents.length < 1 || args.agents.length > 10 || (args.reconnect !== undefined && typeof args.reconnect !== 'boolean')) throw new Error('Use one to ten explicitly selected agents for one project.');
|
|
134
|
+
const selected = args.agents.map(agent => {
|
|
135
|
+
if (!agent || Object.keys(agent).some(key => !['agent_id','agent_name','folder'].includes(key)) || !/^[\w-]{1,200}$/.test(agent.agent_id ?? '') || !/^[\w-]{1,64}$/.test(agent.agent_name ?? '') || typeof agent.folder !== 'string' || !isAbsolute(agent.folder)) throw new Error('Use each agent’s exact ID, name and approved absolute working folder.');
|
|
136
|
+
const folder = canonicalPath(agent.folder); assertStateOutsideWorkspace(home, folder);
|
|
137
|
+
const identity = projectIdentity({ projectUrl: args.project_url, folder });
|
|
138
|
+
for (const other of bindings.values()) {
|
|
139
|
+
if (workerOwnsMutations(other) && other.workerFolder) {
|
|
140
|
+
try { assertStateOutsideWorkspace(other.workerFolder, folder); } catch { throw new Error('Use a separate chat folder: this folder overlaps an active worker in this MCP process.'); }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { id: sessionId(identity.projectUrl,agent.agent_id), service:identity.projectUrl, agentId:agent.agent_id, agentName:agent.agent_name, folder };
|
|
144
|
+
}).sort((a,b) => a.agentId.localeCompare(b.agentId));
|
|
145
|
+
if (new Set(selected.map(agent => agent.id)).size !== selected.length) throw new Error('Use distinct agent IDs in a batch.');
|
|
146
|
+
const contexts = selected.map(agent => bindings.get(identities.get(agent.id)));
|
|
147
|
+
for (let index=0; index<contexts.length; index++) {
|
|
148
|
+
const context = contexts[index]; if (!context) continue;
|
|
149
|
+
if (context.selected.agentName !== selected[index].agentName || context.selected.folder !== selected[index].folder || context.stopped) throw new Error('This project agent already has a different or stopped binding. Disconnect its exact connection_ref first.');
|
|
150
|
+
if (context.record && args.reconnect) throw new Error('Cannot replace credentials while a batch binding is active. Disconnect its exact connection_ref first.');
|
|
151
|
+
}
|
|
152
|
+
if (contexts.every(context => context?.record)) return { state:'bound', agents:contexts.map(state), next_step:'Verify each agent with get_project using its separate connection_ref. Work requires separate approval.' };
|
|
153
|
+
if (contexts.some(context => context?.record)) throw new Error('Cannot replace credentials for active agents in a batch. Select only unbound agents or disconnect their exact connection_ref first.');
|
|
154
|
+
const response = await batchBrowser.connect({ ...args, clientName });
|
|
155
|
+
const key = response.id;
|
|
156
|
+
let batch = [...batches.values()].find(candidate => candidate.id === key);
|
|
157
|
+
if (!batch) { batch = { ref:randomUUID(), id:key, contexts:selected.map((agent,index) => {
|
|
158
|
+
const context = contexts[index] ?? { ref:randomUUID(), selected:agent, verified:false };
|
|
159
|
+
bindings.set(context.ref,context); identities.set(agent.id,context.ref); return context;
|
|
160
|
+
}) }; batches.set(batch.ref,batch); }
|
|
161
|
+
return batchResponse(response,batch);
|
|
162
|
+
}
|
|
163
|
+
function batchResponse(response,batch) {
|
|
164
|
+
if (response.status === 'saved') {
|
|
165
|
+
// Validate every record before binding any, so a mismatch cannot select another agent.
|
|
166
|
+
if (response.records.length !== batch.contexts.length) throw new Error('Approved batch identity differs from the selected agents.');
|
|
167
|
+
for (const context of batch.contexts) validateRecord(response.records.find(record => record.id === context.selected.id),context);
|
|
168
|
+
for (const context of batch.contexts) if (!context.record) bind(response.records.find(record => record.id === context.selected.id),context);
|
|
169
|
+
return { state:'bound', batch_ref:batch.ref, agents:batch.contexts.map(state), next_step:'Verify each agent with get_project using its separate connection_ref. Work requires separate approval.' };
|
|
170
|
+
}
|
|
171
|
+
for (const context of batch.contexts) context.observedState = response.status;
|
|
172
|
+
return { state:response.status, batch_ref:batch.ref, approval_url:response.approvalUrl, request_check:response.requestCheck, agents:batch.contexts.map(state), next_step:'Approve this exact batch of your own agents in Karma42, wait for the result with finish_project_agents_connection, this batch_ref and wait_seconds:60; no typed done is required. Connection approval does not start work.' };
|
|
173
|
+
}
|
|
174
|
+
function queueApprovalWait(rpc, args, once) {
|
|
175
|
+
const key = `${once === finishBatch ? 'batch' : 'agent'}:${args.batch_ref ?? args.connection_ref}`;
|
|
176
|
+
if (approvalTasks.has(key)) throw new Error('Use the existing approval wait for this same reference; it is still running.');
|
|
177
|
+
if (approvalTasks.size >= 20) throw new Error('Approval wait limit reached. Finish or cancel a pending request first.');
|
|
178
|
+
// Waiting for a browser must not block steering or stopping another bound agent.
|
|
179
|
+
const task = waitForApproval(args, once).then(value => send({ jsonrpc:'2.0', id:rpc.id, result:result(value) }), error => {
|
|
180
|
+
const safe = /^(Use |Approval wait|This binding|This project agent|Browser setup|Another operation|Another browser)/.test(error.message ?? '');
|
|
181
|
+
send(fail(rpc.id, -32000, safe ? error.message : 'Connection unavailable. Review the original request; no permissions were changed.'));
|
|
182
|
+
}).finally(() => approvalTasks.delete(key));
|
|
183
|
+
approvalTasks.set(key, task);
|
|
184
|
+
}
|
|
185
|
+
async function waitForApproval(args, once) {
|
|
186
|
+
const { wait_seconds = 0, ...selection } = args;
|
|
187
|
+
if (!Number.isInteger(wait_seconds) || wait_seconds < 0 || wait_seconds > 60) throw new Error('Approval wait must be between 0 and 60 seconds.');
|
|
188
|
+
let remaining = wait_seconds * 1000;
|
|
189
|
+
while (true) {
|
|
190
|
+
const response = await once(selection);
|
|
191
|
+
if (response.state !== 'pending' || remaining <= 0 || stopped) return response;
|
|
192
|
+
const delay = Math.min(5000, remaining);
|
|
193
|
+
await approvalWait(delay); remaining -= delay;
|
|
194
|
+
if (stopped) return response;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async function finishBatch(args) {
|
|
198
|
+
if (Object.keys(args).some(key => key !== 'batch_ref') || !batches.has(args.batch_ref)) throw new Error('Use the exact batch_ref returned during onboarding in this MCP process.');
|
|
199
|
+
const batch = batches.get(args.batch_ref);
|
|
200
|
+
if (batch.contexts.some(context => context.stopped || bindings.get(context.ref) !== context)) throw new Error('This binding stopped or disconnected. Retry with explicitly selected unbound agents.');
|
|
201
|
+
if (batch.contexts.every(context => context.record)) return { state:'bound', batch_ref:batch.ref, agents:batch.contexts.map(state) };
|
|
202
|
+
return batchResponse(await batchBrowser.prepare({ connection:batch.id }),batch);
|
|
120
203
|
}
|
|
121
204
|
function contextFor(args) {
|
|
122
205
|
const context = bindings.get(args.connection_ref);
|
|
@@ -218,14 +301,27 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
218
301
|
if (rpc.method === 'initialize') {
|
|
219
302
|
const observed = rpc.params?.clientInfo?.name;
|
|
220
303
|
if (typeof observed === 'string' && observed.trim() && observed.length <= 100 && !/[\x00-\x1f\x7f-\x9f]/.test(observed)) clientName = observed.trim();
|
|
221
|
-
send({ jsonrpc: '2.0', id: rpc.id, result: { protocolVersion: '2025-03-26', capabilities: { tools: {} }, serverInfo: { name: 'karma42', version: VERSION }, instructions: 'Explore published projects first with explore_projects and get_public_project; no connection or sign-in is required for these reads. get_project_join_link sends your human to sign in, join and create/select an agent; never infer their agent ID. list_project_agents reports bindings in this MCP process only. Multiple workers require separate explicit human approval of each identity, scope, folder and duration; workers must use separate checkouts and cannot recruit or start other workers. 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. A supervised project worker can start only after separate human approval through start_project_worker. While it owns the work, this chat supervises and steers; do not edit the same repository or send project mutations concurrently.' } });
|
|
304
|
+
send({ jsonrpc: '2.0', id: rpc.id, result: { protocolVersion: '2025-03-26', capabilities: { tools: {} }, serverInfo: { name: 'karma42', version: VERSION }, instructions: 'Human owner management uses connect_owner_account and browser approval, separate from agent identities. Use its exact owner_ref for proposal status, project readiness/start and project roles. Only act on the human’s explicit instruction for start/role changes. Connection approval does not authorize a model or platform privileges. Explore published projects first with explore_projects and get_public_project; no connection or sign-in is required for these reads. get_project_join_link sends your human to sign in, join and create/select an agent; never infer their agent ID. list_project_agents reports bindings in this MCP process only. Multiple workers require separate explicit human approval of each identity, scope, folder and duration; workers must use separate checkouts and cannot recruit or start other workers. 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. A supervised project worker can start only after separate human approval through start_project_worker. While it owns the work, this chat supervises and steers; do not edit the same repository or send project mutations concurrently.' } });
|
|
222
305
|
} else if (rpc.method === 'tools/list') send({ jsonrpc: '2.0', id: rpc.id, result: { tools: catalog } });
|
|
223
306
|
else if (rpc.method === 'ping') send({ jsonrpc: '2.0', id: rpc.id, result: {} });
|
|
224
307
|
else if (rpc.method !== 'tools/call') send(fail(rpc.id, -32601, 'Unsupported MCP method.'));
|
|
225
308
|
else {
|
|
226
309
|
const name = rpc.params?.name, args = rpc.params?.arguments ?? {};
|
|
227
310
|
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Tool arguments must be an object.');
|
|
228
|
-
if (
|
|
311
|
+
if (OWNER_TOOLS.some(tool => tool.name === name)) {
|
|
312
|
+
if (name === 'connect_owner_account') {
|
|
313
|
+
if (Object.keys(args).length) throw new Error('Owner connection takes no credentials or agent reference.');
|
|
314
|
+
const account = await owner.connect(clientName); ownerConnected = true;
|
|
315
|
+
send({ jsonrpc: '2.0', id: rpc.id, result: result({ ...account, owner_ref: ownerReference }) });
|
|
316
|
+
} else {
|
|
317
|
+
if (!ownerConnected || args.owner_ref !== ownerReference) throw new Error('Owner operation requires the exact owner_ref from connect_owner_account in this MCP process.');
|
|
318
|
+
const { owner_ref, ...fields } = args;
|
|
319
|
+
const account = name === 'finish_owner_account_connection' ? (Object.keys(fields).length ? (() => { throw new Error('Owner finish takes only owner_ref.'); })() : await owner.finish()) : await owner.operation(name, fields);
|
|
320
|
+
if (name === 'disconnect_owner_account') ownerConnected = false;
|
|
321
|
+
send({ jsonrpc: '2.0', id: rpc.id, result: result({ ...account, owner_ref: ownerReference }) });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
else if (DISCOVERY_TOOLS.some(tool => tool.name === name)) send({ jsonrpc: '2.0', id: rpc.id, result: result(await discovery.call(name, args)) });
|
|
229
325
|
else if (name === 'list_project_agents') {
|
|
230
326
|
if (Object.keys(args).some(key => key !== 'cursor')) throw new Error('Use only the cursor returned by list_project_agents.');
|
|
231
327
|
const entries = [...bindings.values()];
|
|
@@ -234,8 +330,10 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
234
330
|
const page = entries.slice(offset, offset + 20);
|
|
235
331
|
send({ jsonrpc: '2.0', id: rpc.id, result: result({ scope: 'this_mcp_process', agents: page.map(context => ({ ...state(context), worker: context.worker?.status() ?? { state: 'not_started', active: false } })), next: entries.length > offset + 20 ? page.at(-1).ref : null, active_workers: entries.filter(workerOwnsMutations).length, presence: 'local observation; not service online state' }) });
|
|
236
332
|
}
|
|
333
|
+
else if (name === 'connect_project_agents') send({ jsonrpc:'2.0', id:rpc.id, result:result(await connectBatch(args)) });
|
|
334
|
+
else if (name === 'finish_project_agents_connection') queueApprovalWait(rpc, args, finishBatch);
|
|
237
335
|
else if (name === 'connect_project_agent') send({ jsonrpc: '2.0', id: rpc.id, result: result(await connect(args)) });
|
|
238
|
-
else if (name === 'finish_project_agent_connection')
|
|
336
|
+
else if (name === 'finish_project_agent_connection') queueApprovalWait(rpc, args, finish);
|
|
239
337
|
else if (name === 'start_project_worker') send({ jsonrpc: '2.0', id: rpc.id, result: result(await startWorker(args)) });
|
|
240
338
|
else if (name === 'get_project_worker') {
|
|
241
339
|
if (Object.keys(args).some(key => key !== 'connection_ref')) throw new Error('Use only connection_ref for worker status.');
|
|
@@ -263,12 +361,12 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
263
361
|
}
|
|
264
362
|
}
|
|
265
363
|
} catch (error) {
|
|
266
|
-
const safe = /^(Public discovery|This project is not published|Start the project|The MCP session closed|Only paused or blocked|There is no active work|Too many steering|Call get_project|Worker |Worker start|Choose |Provide a clear|Explicit human approval|Supervised workers|Saved worker model session|The worker folder|This work session|This worker|Sign in locally|Cannot run (claude|codex)|This adapter requires|Cannot inspect local MCP|Invalid local MCP|Unsupported local MCP|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 ?? '');
|
|
364
|
+
const safe = /^(Owner |Public discovery|This project is not published|Start the project|The MCP session closed|Only paused or blocked|There is no active work|Too many steering|Call get_project|Worker |Worker start|Choose |Provide a clear|Explicit human approval|Supervised workers|Saved worker model session|The worker folder|This work session|This worker|Sign in locally|Cannot run (claude|codex)|This adapter requires|Cannot inspect local MCP|Invalid local MCP|Unsupported local MCP|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 ?? '');
|
|
267
365
|
send(fail(rpc.id, -32000, safe ? error.message : 'Connection unavailable. Review My agents and the working folder; no permissions were changed.'));
|
|
268
366
|
}
|
|
269
367
|
}
|
|
270
368
|
} finally {
|
|
271
|
-
stop(); await Promise.allSettled([...bindings.values()].map(closeContext));
|
|
369
|
+
stop(); await Promise.allSettled([...approvalTasks.values(), ...[...bindings.values()].map(closeContext)]);
|
|
272
370
|
input.off('end', stop); for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) signals.off(signal, stop);
|
|
273
371
|
}
|
|
274
372
|
}
|
package/src/version.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.1.0-beta.
|
|
1
|
+
export const VERSION = '0.1.0-beta.7';
|