@karma42/agent 0.1.0-beta.11 → 0.1.0-beta.12
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 +15 -13
- package/package.json +1 -1
- package/runtime/control-v2.mjs +6 -6
- package/runtime/manifest.json +2 -3
- package/runtime/project-tools.json +1 -1
- package/skills/karma42/SKILL.md +22 -4
- package/src/cli.mjs +3 -15
- package/src/layout.mjs +20 -0
- package/src/operation-lock.mjs +46 -0
- package/src/owner-client.mjs +143 -19
- package/src/reusable-stdio.mjs +77 -105
- package/src/system.mjs +19 -19
- package/src/version.mjs +3 -1
- package/src/worker-runtime.mjs +4 -2
- package/src/browser-batch.mjs +0 -130
- package/src/browser-pairing.mjs +0 -140
package/src/owner-client.mjs
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
2
|
+
import { dirname, join, isAbsolute } from 'node:path';
|
|
3
|
+
import { mkdirSync, readdirSync, unlinkSync } from 'node:fs';
|
|
4
|
+
import { assertStateOutsideWorkspace, canonicalPath, connectionDir, ensureHome, privateDir, readPrivate, writePrivate, lstatExists, digest, save as saveConnection } from './state.mjs';
|
|
5
|
+
import { sessionHome, sessionId, validateSession } from './session.mjs';
|
|
6
|
+
import { projectIdentity } from './project-identity.mjs';
|
|
7
|
+
import { withOperationLock } from './operation-lock.mjs';
|
|
8
|
+
import { layout, projectFolder, workspaceHome } from './layout.mjs';
|
|
6
9
|
|
|
7
10
|
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
8
11
|
const TOKEN = /^[\w-]{43}$/;
|
|
@@ -14,7 +17,7 @@ const revision = { type: 'integer', minimum: 0 };
|
|
|
14
17
|
const requestId = { type: 'string', pattern: '^[\\w-]{1,64}$', description: 'Keep this request ID and the original input when retrying a lost response.' };
|
|
15
18
|
export const OWNER_TOOLS = [
|
|
16
19
|
{ 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
|
|
20
|
+
{ name: 'finish_owner_account_connection', description: 'Check this human account approval. Optional wait_seconds waits without a model for the browser approval, so an approval continues with nothing typed. Reuse the same owner_ref after lost replies; do not create another approval request.', inputSchema: object({ owner_ref: ownerRef, wait_seconds: { type: 'integer', minimum: 0, maximum: 60, default: 0 } }, ['owner_ref']) },
|
|
18
21
|
{ 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
22
|
{ 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
23
|
{ 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']) },
|
|
@@ -23,16 +26,25 @@ export const OWNER_TOOLS = [
|
|
|
23
26
|
{ 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
27
|
{ 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
28
|
{ name: 'list_my_project_agents', description: 'List only your own registered agent identities in a project you own. Returns authoritative IDs/names for selecting a team; not worker or online status.', annotations: { readOnlyHint: true }, inputSchema: object({ owner_ref: ownerRef, project_id: project }, ['owner_ref','project_id']) },
|
|
26
|
-
{ name: 'register_my_project_agents', description: 'After the human approves the exact team names, atomically register 1–10 identities under that human account in its owned project. Retry the same names and client_request_id after lost replies. Creates no connection, worker, model, folder or GitHub changes.
|
|
29
|
+
{ name: 'register_my_project_agents', description: 'After the human approves the exact team names, atomically register 1–10 identities under that human account in its owned project. Retry the same names and client_request_id after lost replies. Creates no connection, worker, model, folder or GitHub changes. Connect them afterwards with connect_project_agents and the returned IDs; there is no browser step.', inputSchema: object({ owner_ref: ownerRef, project_id: project, names: { type:'array', minItems:1, maxItems:10, uniqueItems:true, items:{type:'string',pattern:'^[\\w-]{1,64}$'} }, client_request_id:requestId, approved:{type:'boolean',const:true} }, ['owner_ref','project_id','names','client_request_id','approved']) },
|
|
30
|
+
{ name: 'set_agent_work_settings', description: 'Save the limits one of your human’s own agents starts under, after the human chose them: how involved they want to be and how long it may run. They become the defaults offered next time and are shown on My agents. Starts nothing.', inputSchema: object({ owner_ref: ownerRef, project_id: project, agent_id: { type: 'string', pattern: '^[\\w-]{1,200}$' }, involvement: { type: 'string', enum: ['with_me', 'on_own'], description: 'with_me: works in this conversation. on_own: works on its own as a supervised worker and reports at the end.' }, minutes: { type: 'integer', minimum: 15, maximum: 240, multipleOf: 15 } }, ['owner_ref', 'project_id', 'agent_id', 'involvement', 'minutes']) },
|
|
31
|
+
{ name: 'add_project_agent', description: 'Add and connect one agent of your human’s in a project they belong to, through this computer’s one-time approval; no browser step. Use when the human asks for an agent in a project. Creates the named agent when it does not exist. Returns a connection_ref, the agent’s own folder and the repository to clone there; verify the reference with get_project. Starts no model and clones nothing. An agent already connected from another computer keeps that connection: add one under a new name instead.', inputSchema: object({ owner_ref: ownerRef, project_name: { type: 'string', minLength: 1, maxLength: 500, description: 'The project as the human named it. It is matched against the projects they belong to; when none or several match, the reply lists them.' }, project_id: { ...project, description: 'The exact project ID, instead of project_name, when it is already known.' }, agent_name: { type: 'string', pattern: '^[\\w-]{1,64}$' }, folder: { type: 'string', description: 'Leave out: the agent gets its own folder in the Karma42 layout, ~/karma42/projects/<project>/<agent>-<id>, named in the reply. Set an absolute folder only when the human asks for a different place.' } }, ['owner_ref', 'agent_name']) },
|
|
27
32
|
{ 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']) },
|
|
28
33
|
];
|
|
34
|
+
// The one question asked before an agent starts, in the shape a client's question tool takes.
|
|
35
|
+
export function startQuestion(agentName, projectName, settings) {
|
|
36
|
+
const minutes = Number.isInteger(settings?.minutes) ? settings.minutes : 60, hours = minutes / 60;
|
|
37
|
+
const length = minutes < 60 ? `${minutes} minutes` : minutes % 60 ? `${Math.floor(hours)} h ${minutes % 60} min` : hours === 1 ? '1 hour' : `${hours} hours`;
|
|
38
|
+
const how = settings?.involvement === 'with_me' ? 'works with you in this conversation' : 'runs on its own';
|
|
39
|
+
return { question: `Start ${agentName} on ${projectName ?? 'this project'}? It ${how}, for up to ${length}.`, options: ['Yes, start', 'Change the limits', 'Not now'] };
|
|
40
|
+
}
|
|
29
41
|
export function ownerOrigin(site) {
|
|
30
42
|
const u = new URL(site ?? process.env.KARMA42_SITE_URL ?? 'https://karma42.ai');
|
|
31
43
|
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.');
|
|
32
44
|
return u.origin;
|
|
33
45
|
}
|
|
34
|
-
export function createOwnerClient({ home = sessionHome(), site, fetchImpl = fetch, now = Date.now } = {}) {
|
|
35
|
-
const origin = ownerOrigin(site); home = canonicalPath(home);
|
|
46
|
+
export function createOwnerClient({ home = sessionHome(), workspace = workspaceHome(), site, fetchImpl = fetch, now = Date.now } = {}) {
|
|
47
|
+
const origin = ownerOrigin(site); home = canonicalPath(home); workspace = canonicalPath(workspace);
|
|
36
48
|
function directory() { ensureHome(home); const root = join(home, 'owners'); privateDir(root); const dir = join(root, digest(origin)); privateDir(dir); return dir; }
|
|
37
49
|
function receipt() {
|
|
38
50
|
const p = join(directory(), 'owner.json'); if (!lstatExists(p)) return null;
|
|
@@ -41,6 +53,15 @@ export function createOwnerClient({ home = sessionHome(), site, fetchImpl = fetc
|
|
|
41
53
|
return r;
|
|
42
54
|
}
|
|
43
55
|
const save = r => writePrivate(join(directory(), 'owner.json'), r);
|
|
56
|
+
// The one check behind every action that needs this computer's approval. It answers with the saved
|
|
57
|
+
// approval, or stops with the same instruction whether the computer was never approved or its approval
|
|
58
|
+
// ran out or was ended on My agents.
|
|
59
|
+
const NOT_APPROVED = `This computer is not approved for Karma42, or its approval has ended. Call connect_owner_account: your human approves this computer once in the browser, then repeat this call. Setup instructions: ${new URL('/viewer/?view=agents', origin).href}`;
|
|
60
|
+
function approved() {
|
|
61
|
+
const r = receipt();
|
|
62
|
+
if (!r || r.status !== 'approved' || r.connectionExpiresAt <= now()) throw new Error(NOT_APPROVED);
|
|
63
|
+
return r;
|
|
64
|
+
}
|
|
44
65
|
async function request(path, { method = 'GET', body, token, poll } = {}) {
|
|
45
66
|
let response;
|
|
46
67
|
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) } : {}) }); }
|
|
@@ -51,27 +72,30 @@ export function createOwnerClient({ home = sessionHome(), site, fetchImpl = fetc
|
|
|
51
72
|
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Owner service returned an invalid response.');
|
|
52
73
|
if (!response.ok) {
|
|
53
74
|
const detail = response.status < 500 && typeof data.message === 'string' && data.message.length <= 500 && !/[\x00-\x1f\x7f-\x9f]/.test(data.message) ? data.message : null;
|
|
54
|
-
|
|
75
|
+
// The service no longer accepts this computer's approval: remember that, so the next call asks for a new one.
|
|
76
|
+
// Any other refusal, such as a project the person does not belong to, leaves the approval as it is.
|
|
77
|
+
if (response.status === 401 && token && /^This computer approval /.test(data.message ?? '')) { const r = receipt(); if (r?.token === token && r.status === 'approved') save({ ...r, status: 'revoked' }); throw new Error(NOT_APPROVED); }
|
|
78
|
+
throw new Error(detail ? `Owner operation refused: ${detail}` : 'Owner operation refused. Read current status and review the original request before retrying.');
|
|
55
79
|
}
|
|
56
80
|
return data;
|
|
57
81
|
}
|
|
58
82
|
function publicState(r) {
|
|
59
83
|
const url = new URL('/viewer/', origin); url.searchParams.set('view','owner-connect'); url.searchParams.set('request',r.requestId);
|
|
60
|
-
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
|
|
84
|
+
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 this computer on Karma42, once. Then finish this same connection.' } : {}), agent_work_started: false };
|
|
61
85
|
}
|
|
62
86
|
async function connect(clientName = 'Karma42 CLI') {
|
|
63
|
-
return
|
|
87
|
+
return withOperationLock(directory(), async () => {
|
|
64
88
|
let r = receipt();
|
|
65
89
|
if (r?.status === 'approved' && r.connectionExpiresAt > now()) return publicState(r);
|
|
66
|
-
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,
|
|
67
|
-
const response = await request('/connect', { method:'POST', body:{ requestId:r.requestId, pollingSecret:r.pollingSecret, candidateTokenHash:hash(r.token), clientName:r.clientName
|
|
90
|
+
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); }
|
|
91
|
+
const response = await request('/connect', { method:'POST', body:{ requestId:r.requestId, pollingSecret:r.pollingSecret, candidateTokenHash:hash(r.token), clientName:r.clientName } });
|
|
68
92
|
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.');
|
|
69
|
-
r = { ...r, status:response.status === 'approved' ? 'pending' : response.status, expiresAt:response.expiresAt, verificationCode:response.verificationCode
|
|
93
|
+
r = { ...r, status:response.status === 'approved' ? 'pending' : response.status, expiresAt:response.expiresAt, verificationCode:response.verificationCode }; save(r);
|
|
70
94
|
return publicState(r);
|
|
71
95
|
});
|
|
72
96
|
}
|
|
73
97
|
async function finish() {
|
|
74
|
-
return
|
|
98
|
+
return withOperationLock(directory(), async () => {
|
|
75
99
|
const r = receipt(); if (!r) throw new Error('Owner connection has not been requested. Connect your human account first.');
|
|
76
100
|
if (r.status === 'approved' && r.connectionExpiresAt > now()) return publicState(r);
|
|
77
101
|
const response = await request(`/connect/${r.requestId}`, { poll:r.pollingSecret });
|
|
@@ -80,8 +104,8 @@ export function createOwnerClient({ home = sessionHome(), site, fetchImpl = fetc
|
|
|
80
104
|
});
|
|
81
105
|
}
|
|
82
106
|
async function operation(name, args) {
|
|
83
|
-
const r =
|
|
84
|
-
const specs = { list_my_project_agents:[`/projects/${args.project_id}/agents`], register_my_project_agents:[`/projects/${args.project_id}/agents`,'POST'], 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'] };
|
|
107
|
+
const r = approved();
|
|
108
|
+
const specs = { set_agent_work_settings:[`/projects/${args.project_id}/agents/${args.agent_id}/settings`,'POST'], list_my_project_agents:[`/projects/${args.project_id}/agents`], register_my_project_agents:[`/projects/${args.project_id}/agents`,'POST'], 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'] };
|
|
85
109
|
if (!specs[name]) throw new Error('Owner operation is unknown.');
|
|
86
110
|
if ((name.includes('owned_project') || name === 'get_my_proposal' || name === 'list_my_project_agents' || name === 'register_my_project_agents') && !ID.test(args.project_id ?? '')) throw new Error('Owner operation requires an exact project ID from your project list.');
|
|
87
111
|
const schema = OWNER_TOOLS.find(t=>t.name===name).inputSchema;
|
|
@@ -94,9 +118,12 @@ export function createOwnerClient({ home = sessionHome(), site, fetchImpl = fetc
|
|
|
94
118
|
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; }
|
|
95
119
|
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 }); }
|
|
96
120
|
}
|
|
121
|
+
if (name === 'set_agent_work_settings') {
|
|
122
|
+
if (!ID.test(args.project_id ?? '') || !/^[\w-]{1,200}$/.test(args.agent_id ?? '') || !['with_me','on_own'].includes(args.involvement) || !Number.isInteger(args.minutes) || args.minutes < 15 || args.minutes > 240 || args.minutes % 15 !== 0) throw new Error('Owner work settings need the exact project and agent IDs, an involvement of with_me or on_own, and 15–240 minutes in 15-minute steps.');
|
|
123
|
+
body = { involvement:args.involvement, minutes:args.minutes };
|
|
124
|
+
}
|
|
97
125
|
if (name === 'register_my_project_agents') {
|
|
98
126
|
if (args.approved !== true || !/^[\w-]{1,64}$/.test(args.client_request_id ?? '') || !Array.isArray(args.names) || args.names.length < 1 || args.names.length > 10 || new Set(args.names).size !== args.names.length || args.names.some(n => typeof n !== 'string' || !/^[\w-]{1,64}$/.test(n))) throw new Error('Owner team registration requires approved distinct names and the original request ID.');
|
|
99
|
-
if (!r.scopes?.includes('register_own_agents')) throw new Error('Owner connection did not approve agent registration. Disconnect it and approve a new owner connection including that permission.');
|
|
100
127
|
body = { names:args.names,client_request_id:args.client_request_id,approved:true };
|
|
101
128
|
}
|
|
102
129
|
if (name === 'disconnect_owner_account') body = {};
|
|
@@ -105,5 +132,102 @@ export function createOwnerClient({ home = sessionHome(), site, fetchImpl = fetc
|
|
|
105
132
|
if (name === 'disconnect_owner_account') save({...r,status:'revoked'});
|
|
106
133
|
return response;
|
|
107
134
|
}
|
|
108
|
-
|
|
135
|
+
// The projects the human belongs to, by name.
|
|
136
|
+
async function memberProjects(token) {
|
|
137
|
+
const projects = [];
|
|
138
|
+
for (let cursor, pages = 0; pages < 10; pages++) {
|
|
139
|
+
const page = await request('/member-projects' + (cursor ? '?cursor=' + encodeURIComponent(cursor) : ''), { token });
|
|
140
|
+
if (!Array.isArray(page.projects)) throw new Error('Owner service returned an invalid project list.');
|
|
141
|
+
projects.push(...page.projects.filter(p => ID.test(p?.projectId ?? '') && typeof p.projectName === 'string'));
|
|
142
|
+
if (!(cursor = page.next)) break;
|
|
143
|
+
}
|
|
144
|
+
return projects;
|
|
145
|
+
}
|
|
146
|
+
// The project the human named. Punctuation and case are ignored, and a shorter name is accepted when it
|
|
147
|
+
// fits exactly one project.
|
|
148
|
+
async function namedProject(name, token) {
|
|
149
|
+
const plain = value => String(value).normalize('NFKD').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(), wanted = plain(name), projects = await memberProjects(token);
|
|
150
|
+
const exact = projects.filter(p => plain(p.projectName) === wanted), found = exact.length ? exact : wanted ? projects.filter(p => plain(p.projectName).includes(wanted)) : [];
|
|
151
|
+
if (found.length === 1) return found[0];
|
|
152
|
+
const names = (found.length ? found : projects).map(p => `“${p.projectName}”`).join(', ');
|
|
153
|
+
throw new Error(found.length ? `Owner projects matching that name: ${names}. Ask your human which one.` : `Owner account belongs to no project with that name. Their projects: ${names || 'none; they join one on Karma42 first'}.`);
|
|
154
|
+
}
|
|
155
|
+
// Every agent connection saved on this computer, read from its private records.
|
|
156
|
+
function savedAgents() {
|
|
157
|
+
const found = [];
|
|
158
|
+
if (!lstatExists(home)) return found;
|
|
159
|
+
for (const project of readdirSync(home)) {
|
|
160
|
+
if (!ID.test(project)) continue;
|
|
161
|
+
for (const agent of readdirSync(join(home, project))) {
|
|
162
|
+
const dir = join(home, project, agent);
|
|
163
|
+
if (!lstatExists(join(dir, 'connection.json')) || !lstatExists(join(dir, 'token'))) continue;
|
|
164
|
+
const record = readPrivate(join(dir, 'connection.json'), true), token = readPrivate(join(dir, 'token'));
|
|
165
|
+
if (record?.schema === 'mcp-connection-1' && typeof record.folder === 'string' && TOKEN.test(token)) found.push({ record, token });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return found;
|
|
169
|
+
}
|
|
170
|
+
// The project's folder in the fixed layout, named after the project. The agent's own folder inside it,
|
|
171
|
+
// <agent>-<id>, is known once the service has answered with the agent's ID.
|
|
172
|
+
async function placedProject(projectId, service, token, saved, projectName) {
|
|
173
|
+
const projects = layout(workspace).projects, inside = folder => dirname(dirname(folder)) === projects;
|
|
174
|
+
const placed = saved.filter(s => inside(s.record.folder)).map(s => ({ projectId: s.record.service === service ? projectId : s.record.service, folder: dirname(s.record.folder) }));
|
|
175
|
+
projectName ??= (await memberProjects(token)).find(p => p.projectId === projectId)?.projectName;
|
|
176
|
+
if (typeof projectName !== 'string') throw new Error('Owner account does not belong to this project. Nothing was saved.');
|
|
177
|
+
const folder = projectFolder(workspace, projectId, projectName, placed);
|
|
178
|
+
mkdirSync(folder, { recursive: true }); mkdirSync(layout(workspace).main, { recursive: true });
|
|
179
|
+
return folder;
|
|
180
|
+
}
|
|
181
|
+
// The credential is generated here and saved before the service hears of it, so a lost reply is
|
|
182
|
+
// retried with the same credential and the service answers it as the same connection.
|
|
183
|
+
// exact: the agent's ID, when the caller names an existing agent. own: the folder is the agent's own checkout.
|
|
184
|
+
async function addAgent(args, clientName = 'Karma42 CLI', { exact, own = true } = {}) {
|
|
185
|
+
const r = approved();
|
|
186
|
+
const named = args.project_id === undefined && typeof args.project_name === 'string' && args.project_name.trim() && args.project_name.length <= 500 ? await namedProject(args.project_name, r.token) : null;
|
|
187
|
+
if (named) args = { ...args, project_id: named.projectId };
|
|
188
|
+
if (Object.keys(args).some(k => !['project_id','project_name','agent_name','folder'].includes(k)) || !ID.test(args.project_id ?? '') || !/^[\w-]{1,64}$/.test(args.agent_name ?? '') || (args.folder !== undefined && (typeof args.folder !== 'string' || !isAbsolute(args.folder)))) throw new Error('Adding an agent needs the project’s name or exact ID and a short agent name; a folder, when given, must be absolute.');
|
|
189
|
+
const service = projectIdentity({ projectUrl: new URL(`/p/${args.project_id}`, origin).href, folder: workspace }).projectUrl;
|
|
190
|
+
const adds = join(directory(), 'adds'); privateDir(adds);
|
|
191
|
+
const path = join(adds, `${digest([args.project_id, args.agent_name])}.json`);
|
|
192
|
+
const once = async (known, saved) => {
|
|
193
|
+
let add = lstatExists(path) ? readPrivate(path, true) : null;
|
|
194
|
+
if (add && (add.projectId !== args.project_id || add.name !== args.agent_name || !TOKEN.test(add.candidateToken ?? ''))) throw new Error('Saved agent request is invalid. No credentials were changed.');
|
|
195
|
+
// An agent this computer already connected keeps its folder and its saved credential, so picking it
|
|
196
|
+
// up again in a new conversation changes nothing and the service answers it as the same connection.
|
|
197
|
+
// An unfinished request keeps what it chose, also when its record was saved just before it was interrupted.
|
|
198
|
+
const chosen = args.folder !== undefined ? canonicalPath(args.folder) : add ? add.folder ?? null : known?.record.folder ?? null;
|
|
199
|
+
const project = chosen ? null : add?.project ?? await placedProject(args.project_id, service, r.token, saved, named?.projectName);
|
|
200
|
+
assertStateOutsideWorkspace(home, chosen ?? project);
|
|
201
|
+
if (add && (add.folder ?? null) !== chosen) throw new Error('An unfinished request for this agent names another folder. Retry it with that folder, or choose another agent name.');
|
|
202
|
+
if (!add) { add = { projectId: args.project_id, name: args.agent_name, folder: chosen, project, candidateToken: (known && known.record.folder === chosen ? known.token : null) ?? randomBytes(32).toString('base64url') }; writePrivate(path, add); }
|
|
203
|
+
// A refusal is final, so its request is not kept: the next attempt starts from what is saved.
|
|
204
|
+
// For an agent placed in the layout the service is told the project's folder; the agent's is inside it.
|
|
205
|
+
const result = await request(`/projects/${args.project_id}/agents/connect`, { method: 'POST', token: r.token, body: { name: args.agent_name, candidateTokenHash: hash(add.candidateToken), clientName, folder: chosen ?? project, ...(exact ?? known?.record.agentId ? { agentId: exact ?? known.record.agentId } : {}) } })
|
|
206
|
+
.catch(error => { if (/^Owner operation refused: /.test(error.message)) unlinkSync(path); throw error; });
|
|
207
|
+
if (!/^[\w-]{1,200}$/.test(result.agentId ?? '') || result.agentName !== args.agent_name || result.projectId !== args.project_id || ((exact ?? known?.record.agentId) && result.agentId !== (exact ?? known.record.agentId))) throw new Error('Owner service returned a different agent than requested. Nothing was saved; review My agents.');
|
|
208
|
+
const folder = chosen ?? join(project, `${args.agent_name}-${result.agentId.slice(0, 6).toLowerCase()}`);
|
|
209
|
+
assertStateOutsideWorkspace(home, folder);
|
|
210
|
+
const id = sessionId(service, result.agentId), dir = connectionDir(home, id);
|
|
211
|
+
// An agent this conversation's own process keeps connected is already saved with this credential:
|
|
212
|
+
// nothing is written, and its lock is this process's own.
|
|
213
|
+
const lock = join(dir, 'operation.lock'), here = known?.record.id === id && known.record.folder === folder && known.token === add.candidateToken && lstatExists(lock) && readPrivate(lock).trim() === String(process.pid);
|
|
214
|
+
const record = here ? validateSession(known.record, id) : validateSession({ schema: 'mcp-connection-1', id, service, agentId: result.agentId, agentName: result.agentName, repo: projectIdentity({ projectUrl: service, repo: result.repoUrl, folder }).repo, folder, createdAt: now(), approvedAt: now(), ...(own ? { ownFolder: true } : {}) }, id);
|
|
215
|
+
if (!here) await withOperationLock(dir, async () => {
|
|
216
|
+
writePrivate(join(dir, 'token'), add.candidateToken); saveConnection(dir, record);
|
|
217
|
+
});
|
|
218
|
+
unlinkSync(path);
|
|
219
|
+
return { record, repo: record.repo, created: result.created === true, settings: result.settings ?? null, project_name: typeof result.projectName === 'string' ? result.projectName : null };
|
|
220
|
+
};
|
|
221
|
+
return withOperationLock(directory(), async () => {
|
|
222
|
+
const saved = savedAgents(), known = saved.find(s => s.record.service === service && s.record.agentName === args.agent_name) ?? null;
|
|
223
|
+
try { return await once(known, saved); }
|
|
224
|
+
catch (error) {
|
|
225
|
+
// The saved record belongs to an agent the service no longer has under this name, such as one revoked on
|
|
226
|
+
// My agents. It is left alone and the name is added as a new agent, with a new credential and folder.
|
|
227
|
+
if (!known || exact || !/^Owner operation refused: You have no agent with this ID and name/.test(error.message)) throw error;
|
|
228
|
+
return once(null, saved);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return { origin,connect,finish,operation,addAgent };
|
|
109
233
|
}
|