@karma42/agent 0.0.0-stage → 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -2
- package/package.json +32 -4
- package/runtime/AGENT_PROMPT_V2.md +115 -0
- package/runtime/claude-settings.json +82 -0
- package/runtime/control-v2.mjs +357 -0
- package/runtime/launcher.mjs +55 -0
- package/runtime/manifest.json +12 -0
- package/runtime/providers.mjs +284 -0
- package/runtime/runner.mjs +591 -0
- package/runtime/runtime.mjs +67 -0
- package/src/cli.mjs +72 -0
- package/src/connection.mjs +214 -0
- package/src/doctor.mjs +28 -0
- package/src/plan.mjs +88 -0
- package/src/state.mjs +93 -0
- package/src/supervisor.mjs +17 -0
- package/src/system.mjs +75 -0
- package/src/terminal.mjs +33 -0
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { realpathSync } from 'node:fs';
|
|
5
|
+
import { VERSION, makePlan, providerName, renderPlan } from './plan.mjs';
|
|
6
|
+
import { doctor } from './doctor.mjs';
|
|
7
|
+
import { createOperations } from './connection.mjs';
|
|
8
|
+
import { terminal } from './terminal.mjs';
|
|
9
|
+
|
|
10
|
+
const HELP = `Karma42 agent ${VERSION} — beta release
|
|
11
|
+
|
|
12
|
+
Commands:
|
|
13
|
+
connect --project-url HTTPS_SERVICE/p/PROJECT_ID --provider claude|codex
|
|
14
|
+
--mode guarded|bypass [--folder ABSOLUTE_CHECKOUT_PATH]
|
|
15
|
+
prepare --connection ID
|
|
16
|
+
start --connection ID
|
|
17
|
+
reconnect --connection ID
|
|
18
|
+
permissions --connection ID --mode guarded|bypass [--provider claude|codex]
|
|
19
|
+
status [--connection ID] [--json]
|
|
20
|
+
doctor --provider claude|codex [--json]
|
|
21
|
+
preview --provider claude|codex --mode guarded|bypass
|
|
22
|
+
--project-url HTTPS_SERVICE/p/PROJECT_ID --repo GITHUB_URL --folder ABSOLUTE_PATH [--json]
|
|
23
|
+
--version
|
|
24
|
+
|
|
25
|
+
Connection commands accept --state-dir ABSOLUTE_PRIVATE_DIRECTORY (default ~/.karma42/connections).
|
|
26
|
+
Enter the one-time setup code privately when prompted. Never pass codes or tokens as arguments.
|
|
27
|
+
Connect prepares only. Start requires separate approval and runs in the foreground; Ctrl+C stops.
|
|
28
|
+
status reports local records, not online/working state. See My agents for observed service activity.
|
|
29
|
+
No automatic installation, personal client configuration edits, or background launch.`;
|
|
30
|
+
const selection = { connection: { type: 'string' }, 'state-dir': { type: 'string' } };
|
|
31
|
+
const specs = {
|
|
32
|
+
connect: { provider: { type: 'string' }, mode: { type: 'string' }, 'project-url': { type: 'string' }, folder: { type: 'string' }, 'state-dir': { type: 'string' } },
|
|
33
|
+
prepare: selection, start: selection, reconnect: selection,
|
|
34
|
+
permissions: { ...selection, provider: { type: 'string' }, mode: { type: 'string' } },
|
|
35
|
+
status: { ...selection, json: { type: 'boolean' } },
|
|
36
|
+
preview: { provider: { type: 'string' }, mode: { type: 'string' }, 'project-url': { type: 'string' }, repo: { type: 'string' }, folder: { type: 'string' }, json: { type: 'boolean' } },
|
|
37
|
+
doctor: { provider: { type: 'string' }, json: { type: 'boolean' } },
|
|
38
|
+
};
|
|
39
|
+
export function main(args, out = console.log, err = console.error, runDoctor = doctor) {
|
|
40
|
+
if (!args.length || (args.length === 1 && ['--help', 'help', '-h'].includes(args[0]))) { out(HELP); return 0; }
|
|
41
|
+
if (args.length === 1 && args[0] === '--version') { out(VERSION); return 0; }
|
|
42
|
+
const [command, ...rest] = args;
|
|
43
|
+
if (!Object.hasOwn(specs, command)) { err('Unknown command. Run --help for details.'); return 2; }
|
|
44
|
+
try {
|
|
45
|
+
// Do not echo unknown options or their values: users may accidentally paste a secret.
|
|
46
|
+
let parsed;
|
|
47
|
+
try { parsed = parseArgs({ args: rest, options: specs[command], allowPositionals: false, tokens: true }); }
|
|
48
|
+
catch { throw new Error('Invalid arguments. Run --help. Setup codes and tokens are not accepted as arguments.'); }
|
|
49
|
+
const names = parsed.tokens.filter(t => t.kind === 'option').map(t => t.name);
|
|
50
|
+
if (new Set(names).size !== names.length) throw new Error('Supply each option once.');
|
|
51
|
+
const v = parsed.values;
|
|
52
|
+
if (command === 'preview') {
|
|
53
|
+
const plan = makePlan({ provider: v.provider, mode: v.mode, projectUrl: v['project-url'], repo: v.repo, folder: v.folder });
|
|
54
|
+
out(v.json ? JSON.stringify(plan, null, 2) : renderPlan(plan)); return 0;
|
|
55
|
+
}
|
|
56
|
+
if (!['preview', 'doctor'].includes(command)) {
|
|
57
|
+
if (v['state-dir'] && (!v['state-dir'].startsWith('/') || /[\x00-\x1f\x7f-\x9f]/.test(v['state-dir']))) throw new Error('Choose an absolute private state directory.');
|
|
58
|
+
const io = terminal(); io.say = out;
|
|
59
|
+
const operations = createOperations({ home: v['state-dir'], io });
|
|
60
|
+
return Promise.resolve().then(() => operations[command](v)).then(result => command === 'start' ? result : 0).catch(error => { err(error.message); return 2; });
|
|
61
|
+
}
|
|
62
|
+
const report = runDoctor(providerName(v.provider));
|
|
63
|
+
out(v.json ? JSON.stringify(report, null, 2) : [
|
|
64
|
+
'Karma42 — local prerequisites',
|
|
65
|
+
...report.checks.map(c => `${c.ok ? 'Found' : 'Action required'}: ${c.tool} — ${c.version ?? 'not detected'}${c.minimum ? ` (requires ${c.minimum}+)` : ''}`),
|
|
66
|
+
'Sign-in and repository access: not checked. No connection or model work was started.',
|
|
67
|
+
'Passing these checks does not establish that an agent is ready to work.',
|
|
68
|
+
].join('\n'));
|
|
69
|
+
return report.readyForPairingChecks ? 0 : 1;
|
|
70
|
+
} catch (e) { err(e.message); return 2; }
|
|
71
|
+
}
|
|
72
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exitCode = await main(process.argv.slice(2));
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { join, dirname, relative, isAbsolute, resolve } from 'node:path';
|
|
2
|
+
import { lstatSync, realpathSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { makePlan, providerName, MODES, COPY_VERSION, VERSION } from './plan.mjs';
|
|
4
|
+
import { defaultHome, canonicalPath, ensureHome, connectionDir, privateDir, readPrivate, writePrivate, load, save, binding, withLock, list, lstatExists } from './state.mjs';
|
|
5
|
+
import { system } from './system.mjs';
|
|
6
|
+
|
|
7
|
+
export const RUNTIME_POLICY = 'existing-service-managed-v2';
|
|
8
|
+
const TOKEN = /^[\w-]{43}$/;
|
|
9
|
+
export const normalizeRepo = repo => repo.replace(/\.git\/?$/, '').replace(/\/$/, '');
|
|
10
|
+
function safeFolder(folder) {
|
|
11
|
+
if (typeof folder !== 'string' || !isAbsolute(folder) || folder.length > 2048 || /[\x00-\x1f\x7f-\x9f]/.test(folder)) throw new Error('Choose a valid absolute checkout folder.');
|
|
12
|
+
return resolve(folder);
|
|
13
|
+
}
|
|
14
|
+
function inside(root, path) {
|
|
15
|
+
const part = relative(root, path);
|
|
16
|
+
return part === '' || (!part.startsWith('../') && part !== '..' && !isAbsolute(part));
|
|
17
|
+
}
|
|
18
|
+
function checkoutPath(dir, value) {
|
|
19
|
+
const folder = safeFolder(value);
|
|
20
|
+
const state = join(dir, 'agent-state');
|
|
21
|
+
const actualFolder = canonicalPath(folder);
|
|
22
|
+
if (inside(actualFolder, dir) || inside(state, actualFolder) || inside(actualFolder, state)) throw new Error('Checkout and private connection state must be separate. Choose a sibling checkout folder.');
|
|
23
|
+
return folder;
|
|
24
|
+
}
|
|
25
|
+
function validateRecord(record, id) {
|
|
26
|
+
if (record?.schema !== 1 || record.id !== id || !/^[\w-]{1,64}$/.test(record.agentName ?? '') || !/^[\w-]{1,200}$/.test(record.agentId ?? '') || id.split('/')[1] !== record.agentId) throw new Error('Invalid connection record. Restore the private record or reconnect.');
|
|
27
|
+
makePlan({ provider: record.provider, mode: record.mode, projectUrl: record.service, repo: record.repo, folder: record.folder });
|
|
28
|
+
if (new URL(record.service).pathname.split('/')[2] !== id.split('/')[0]) throw new Error('Connection project identity mismatch.');
|
|
29
|
+
if (record.runtimePolicy !== RUNTIME_POLICY) throw new Error('Unsupported runtime policy. Prepare this connection using a supported package.');
|
|
30
|
+
return record;
|
|
31
|
+
}
|
|
32
|
+
export function createOperations({ home = defaultHome(), io, sys = system } = {}) {
|
|
33
|
+
if (lstatExists(home) && lstatSync(home).isSymbolicLink()) throw new Error('Use a real private state directory without a symbolic link.');
|
|
34
|
+
home = canonicalPath(home);
|
|
35
|
+
const say = text => io.say(text);
|
|
36
|
+
const stateOption = resolve(home) === resolve(defaultHome()) ? '' : ` --state-dir '${home.replaceAll("'", "'\\''")}'`;
|
|
37
|
+
const requireInteractive = () => { if (!io.interactive) throw new Error('Use an interactive terminal. Connection and start require your explicit approval; setup codes are never command arguments.'); };
|
|
38
|
+
const confirm = async text => {
|
|
39
|
+
if (!await io.confirm(text)) throw new Error('Cancelled. No agent was started.');
|
|
40
|
+
};
|
|
41
|
+
const read = id => validateRecord(load(connectionDir(home, id)), id);
|
|
42
|
+
const prerequisites = provider => {
|
|
43
|
+
const report = sys.doctor(provider);
|
|
44
|
+
if (!report.readyForPairingChecks) throw new Error(`Install or update the required local tools first: ${report.checks.filter(c => !c.ok).map(c => c.tool).join(', ')}. Run doctor for details.`);
|
|
45
|
+
sys.verifyBundle();
|
|
46
|
+
};
|
|
47
|
+
async function consent(record, access) {
|
|
48
|
+
const plan = makePlan({ provider: record.provider, mode: record.mode, projectUrl: record.service, repo: record.repo, folder: record.folder });
|
|
49
|
+
say(`\nReview this connection\nProject: ${record.service}\nAgent: ${record.agentName}\nRepository: ${record.repo}\nCheckout: ${record.folder}\nProvider: ${record.provider}\nPermissions: ${plan.permissions.title}\n${plan.permissions.detail}\n${plan.permissions.boundary}`);
|
|
50
|
+
say(`GitHub account: ${access.githubLogin ?? 'not signed in / not detected'}; repository write access: ${access.github ? 'checked' : 'not confirmed'}. Model sign-in: not checked.`);
|
|
51
|
+
say('The foreground runner uses service-selected runtime releases and instructions, including compatible updates between turns. Hashes detect corruption; they are not independent publisher signatures. Package updates are a separate manual installation. Existing client trust checks remain in place. No personal client configuration is edited.');
|
|
52
|
+
say('There is no default time, turn or spending cap. Readiness checks do not verify model sign-in; sign in with your chosen local client before starting.');
|
|
53
|
+
for (const line of plan.acknowledgements) await confirm(line);
|
|
54
|
+
await confirm('I understand and accept this service-managed runtime update policy.');
|
|
55
|
+
if (record.mode === 'bypass' && await io.ask('Type BYPASS to confirm this permission choice:') !== 'BYPASS') throw new Error('Bypass not confirmed. No agent was started.');
|
|
56
|
+
await confirm('Prepare the checkout and connection now? This may clone the repository; Git can invoke configured hooks and filters. No model will start.');
|
|
57
|
+
return { binding: binding(record), acknowledgedAt: sys.now(), packageVersion: VERSION, copyVersion: COPY_VERSION };
|
|
58
|
+
}
|
|
59
|
+
function checkFolder(record, clone = false) {
|
|
60
|
+
if (!lstatExists(record.folder)) {
|
|
61
|
+
if (!clone) throw new Error('Checkout is missing. Run prepare before starting.');
|
|
62
|
+
if (!lstatExists(dirname(record.folder)) || realpathSync(dirname(record.folder)) !== dirname(record.folder)) throw new Error('Choose a checkout under an existing real directory without symbolic links.');
|
|
63
|
+
if (sys.run('git', ['clone', '--', record.repo, record.folder]) === null) throw new Error('Could not clone the repository. Check GitHub sign-in and access, then retry prepare.');
|
|
64
|
+
}
|
|
65
|
+
if (!lstatSync(record.folder).isDirectory() || lstatSync(record.folder).isSymbolicLink() || realpathSync(record.folder) !== record.folder) throw new Error('Use the real checkout path without symbolic links.');
|
|
66
|
+
const top = sys.run('git', ['rev-parse', '--show-toplevel'], record.folder);
|
|
67
|
+
const origin = sys.run('git', ['remote', 'get-url', 'origin'], record.folder);
|
|
68
|
+
if (top !== record.folder || !origin || normalizeRepo(origin) !== normalizeRepo(record.repo)) throw new Error('Checkout must be the project repository root with the exact HTTPS GitHub origin. Existing files were preserved.');
|
|
69
|
+
}
|
|
70
|
+
function github(record) {
|
|
71
|
+
const raw = sys.run('gh', ['api', 'user']);
|
|
72
|
+
let login = null, permission = false;
|
|
73
|
+
try { const value = JSON.parse(raw); if (/^[A-Za-z0-9-]{1,39}$/.test(value.login)) login = value.login; } catch {}
|
|
74
|
+
const slug = new URL(normalizeRepo(record.repo)).pathname.slice(1);
|
|
75
|
+
if (login) {
|
|
76
|
+
try { permission = JSON.parse(sys.run('gh', ['api', `repos/${slug}`]))?.permissions?.push === true; } catch {}
|
|
77
|
+
}
|
|
78
|
+
return { github: permission, githubLogin: login };
|
|
79
|
+
}
|
|
80
|
+
async function finish(dir, record) {
|
|
81
|
+
prerequisites(record.provider);
|
|
82
|
+
const candidate = { ...record, folder: checkoutPath(dir, record.folder), copyVersion: COPY_VERSION, bundleHash: sys.verifyBundle().fingerprint, consent: null, phase: 'paired' };
|
|
83
|
+
const approved = await consent(candidate, github(candidate));
|
|
84
|
+
// Mark any older approval invalid before touching the selected checkout.
|
|
85
|
+
save(dir, candidate);
|
|
86
|
+
checkFolder(candidate, true);
|
|
87
|
+
privateDir(join(dir, 'agent-state'));
|
|
88
|
+
const state = join(dir, 'agent-state', candidate.agentName); privateDir(state);
|
|
89
|
+
const bundleHash = sys.installBundle(state);
|
|
90
|
+
const check = { runner: true, folder: true, ...github(candidate) };
|
|
91
|
+
const token = readPrivate(join(state, 'token')).trim();
|
|
92
|
+
if (!TOKEN.test(token)) throw new Error('Invalid stored credential. Reconnect in My agents.');
|
|
93
|
+
await sys.request(candidate.service, 'readiness', check, token);
|
|
94
|
+
const prepared = { ...candidate, phase: 'prepared', consent: approved, bundleHash, readiness: { ...check, checkedAt: sys.now() } };
|
|
95
|
+
save(dir, prepared);
|
|
96
|
+
say(`\nConnection prepared; runner not started.\nConnection ID: ${prepared.id}\nGitHub: ${check.github ? `write access checked for ${check.githubLogin}` : 'action required — sign in with gh and accept the owner’s repository invitation, then prepare again.'}\nStart separately: karma42-agent start --connection ${prepared.id}${stateOption}`);
|
|
97
|
+
return prepared;
|
|
98
|
+
}
|
|
99
|
+
async function pair(service, expected) {
|
|
100
|
+
await confirm('Exchange a one-time setup code from this project and save its private credential? This rotates the existing connection credential. Stop any previous runner first. No model will start.');
|
|
101
|
+
const code = await io.secret('Paste the setup code from My agents (hidden):');
|
|
102
|
+
if (!TOKEN.test(code)) throw new Error('Use the 43-character setup code from My agents.');
|
|
103
|
+
const response = await sys.request(service, 'exchange', { code });
|
|
104
|
+
if (!TOKEN.test(response?.token ?? '') || !/^[\w-]{1,200}$/.test(response.agentId ?? '') || !/^[\w-]{1,64}$/.test(response.agentName ?? '')) throw new Error('Unexpected pairing response. Generate a fresh code in My agents.');
|
|
105
|
+
// Validate authoritative repository before displaying it or using it in a subprocess.
|
|
106
|
+
const plan = makePlan({ provider: expected.provider, mode: expected.mode, projectUrl: service, repo: response.repoUrl, folder: expected.folder ?? '/placeholder' });
|
|
107
|
+
const id = `${new URL(service).pathname.split('/')[2]}/${response.agentId}`;
|
|
108
|
+
const dir = connectionDir(home, id);
|
|
109
|
+
// Persist a private recovery receipt before attempting preparation: never lose a valid token
|
|
110
|
+
// on a clone failure, cancelled consent, or a reconnect identity mismatch.
|
|
111
|
+
if (expected.folder) checkoutPath(dir, expected.folder);
|
|
112
|
+
const matches = !expected.id || (id === expected.id && response.agentName === expected.agentName && normalizeRepo(plan.repo) === normalizeRepo(expected.repo));
|
|
113
|
+
const receipt = { schema: 1, id, service, agentId: response.agentId, agentName: response.agentName, repo: plan.repo, folder: safeFolder(matches && expected.folder ? expected.folder : join(dir, 'workspace')), provider: expected.provider, mode: expected.mode, copyVersion: COPY_VERSION, runtimePolicy: RUNTIME_POLICY, token: response.token, pairedAt: sys.now() };
|
|
114
|
+
writePrivate(join(dir, 'pending-pair.json'), receipt);
|
|
115
|
+
if (!matches) throw new Error(`That code belongs to a different connection or repository. Existing checkout and saved credential were preserved. A private recovery receipt was saved for connection ${id}; run prepare for that connection ID to review and recover it without another exchange.`);
|
|
116
|
+
return { id, dir, receipt };
|
|
117
|
+
}
|
|
118
|
+
async function applyPair(pairing, options, locked = false) {
|
|
119
|
+
const { dir, id, receipt } = pairing;
|
|
120
|
+
const action = async () => {
|
|
121
|
+
validateRecord(receipt, id);
|
|
122
|
+
if (!TOKEN.test(receipt.token ?? '')) throw new Error('Invalid recovery receipt. Generate a new setup code.');
|
|
123
|
+
let previous = null;
|
|
124
|
+
if (lstatExists(join(dir, 'connection.json'))) previous = read(id);
|
|
125
|
+
if (previous && (previous.service !== receipt.service || previous.agentName !== receipt.agentName || normalizeRepo(previous.repo) !== normalizeRepo(receipt.repo))) throw new Error('This connection has a different saved identity. Private recovery receipt saved; existing state preserved.');
|
|
126
|
+
const folder = checkoutPath(dir, options.folder ?? previous?.folder ?? receipt.folder);
|
|
127
|
+
privateDir(join(dir, 'agent-state'));
|
|
128
|
+
const state = join(dir, 'agent-state', receipt.agentName); privateDir(state);
|
|
129
|
+
const { token, ...metadata } = receipt;
|
|
130
|
+
const record = { ...metadata, folder, provider: options.provider ?? receipt.provider, mode: options.mode ?? receipt.mode, phase: 'paired', consent: null };
|
|
131
|
+
// Readiness and approval are invalidated when the server credential rotates.
|
|
132
|
+
save(dir, record);
|
|
133
|
+
writePrivate(join(state, 'token'), `${token}\n`);
|
|
134
|
+
writePrivate(join(state, 'service.json'), { service: receipt.service });
|
|
135
|
+
unlinkSync(join(dir, 'pending-pair.json'));
|
|
136
|
+
return finish(dir, record);
|
|
137
|
+
};
|
|
138
|
+
return locked ? action() : withLock(dir, action);
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
async connect(options) {
|
|
142
|
+
requireInteractive(); ensureHome(home);
|
|
143
|
+
const provider = providerName(options.provider);
|
|
144
|
+
const mode = options.mode;
|
|
145
|
+
// Use shared URL/permission validators before the first service request.
|
|
146
|
+
const plan = makePlan({ provider, mode, projectUrl: options['project-url'], repo: 'https://github.com/placeholder/project', folder: options.folder ?? '/placeholder' });
|
|
147
|
+
if (options.folder && (inside(canonicalPath(safeFolder(options.folder)), home) || inside(home, canonicalPath(safeFolder(options.folder))))) throw new Error('Choose a custom checkout outside private connection storage; it must not contain that storage either.');
|
|
148
|
+
prerequisites(provider);
|
|
149
|
+
return withLock(home, async () => applyPair(await pair(plan.projectUrl, { provider, mode, folder: options.folder }), { provider, mode, folder: options.folder }));
|
|
150
|
+
},
|
|
151
|
+
async prepare(options) {
|
|
152
|
+
requireInteractive();
|
|
153
|
+
const dir = connectionDir(home, options.connection);
|
|
154
|
+
return withLock(dir, async () => {
|
|
155
|
+
if (lstatExists(join(dir, 'pending-pair.json'))) {
|
|
156
|
+
say('A previous pairing was interrupted. Reviewing its saved private credential; no new code will be consumed.');
|
|
157
|
+
return applyPair({ dir, id: options.connection, receipt: readPrivate(join(dir, 'pending-pair.json'), true) }, {}, true);
|
|
158
|
+
}
|
|
159
|
+
return finish(dir, read(options.connection));
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
async permissions(options) {
|
|
163
|
+
requireInteractive();
|
|
164
|
+
const dir = connectionDir(home, options.connection);
|
|
165
|
+
return withLock(dir, async () => {
|
|
166
|
+
const record = read(options.connection);
|
|
167
|
+
return finish(dir, { ...record, provider: options.provider ? providerName(options.provider) : record.provider, mode: options.mode });
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
async reconnect(options) {
|
|
171
|
+
requireInteractive();
|
|
172
|
+
const dir = connectionDir(home, options.connection);
|
|
173
|
+
return withLock(home, () => withLock(dir, async () => {
|
|
174
|
+
const record = read(options.connection);
|
|
175
|
+
prerequisites(record.provider);
|
|
176
|
+
return applyPair(await pair(record.service, record), record, true);
|
|
177
|
+
}));
|
|
178
|
+
},
|
|
179
|
+
async start(options) {
|
|
180
|
+
requireInteractive();
|
|
181
|
+
const dir = connectionDir(home, options.connection);
|
|
182
|
+
return withLock(dir, async () => {
|
|
183
|
+
const record = read(options.connection);
|
|
184
|
+
if (lstatExists(join(dir, 'pending-pair.json'))) throw new Error('A saved pairing needs recovery. Run prepare before starting.');
|
|
185
|
+
if (record.phase !== 'prepared' || record.copyVersion !== COPY_VERSION || record.consent?.binding !== binding(record)) throw new Error('This connection needs preparation or renewed permission approval. Run prepare first.');
|
|
186
|
+
prerequisites(record.provider);
|
|
187
|
+
if (sys.verifyInstalled(join(dir, 'agent-state', record.agentName)) !== record.bundleHash) throw new Error('The packaged runtime changed. Run prepare to renew approval.');
|
|
188
|
+
checkFolder(record);
|
|
189
|
+
const check = { runner: true, folder: true, ...github(record) };
|
|
190
|
+
const token = readPrivate(join(dir, 'agent-state', record.agentName, 'token')).trim();
|
|
191
|
+
if (!TOKEN.test(token)) throw new Error('Invalid stored credential. Reconnect in My agents.');
|
|
192
|
+
await sys.request(record.service, 'readiness', check, token);
|
|
193
|
+
say(`\nStart ${record.agentName}\nRepository: ${record.repo}\nCheckout: ${record.folder}\nProvider: ${record.provider}\nPermissions: ${MODES[record.provider][record.mode].title}\n${MODES[record.provider][record.mode].detail}`);
|
|
194
|
+
say(`GitHub account: ${check.githubLogin ?? 'not signed in / not detected'}; repository write access: ${check.github ? 'checked now' : 'not confirmed'}. Model sign-in is checked by the local client when work starts.`);
|
|
195
|
+
if (!check.github) await confirm('GitHub write access is not confirmed. Coding and publishing may fail. Start with this limitation?');
|
|
196
|
+
await confirm('Start this agent now? An active project can begin model work immediately and consume your usage. A preparing or paused project waits. Keep this terminal open; Ctrl+C stops the runner. Completed actions are not undone.');
|
|
197
|
+
const started = { ...record, readiness: { ...check, checkedAt: sys.now() }, lastLocalStartAt: sys.now(), lastLocalExit: null };
|
|
198
|
+
save(dir, started);
|
|
199
|
+
const code = await sys.launch(dir, started);
|
|
200
|
+
save(dir, { ...started, lastLocalExit: { at: sys.now(), code } });
|
|
201
|
+
say('Foreground runner exited. Check My agents for service-observed contact and activity.');
|
|
202
|
+
return code;
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
status(options = {}) {
|
|
206
|
+
const records = options.connection ? list(home).filter(r => r.id === options.connection).map(r => validateRecord(r, r.id)) : list(home).map(r => validateRecord(r, r.id));
|
|
207
|
+
if (options.connection && !records.length) throw new Error('No saved connection with this ID. Run status to list connections.');
|
|
208
|
+
const pending = record => lstatExists(join(connectionDir(home, record.id), 'pending-pair.json'));
|
|
209
|
+
const result = records.map(record => ({ id: record.id, agent: record.agentName, provider: record.provider, permissions: record.mode, service: record.service, repository: record.repo, folder: record.folder, preparation: record.phase, pairingPending: pending(record), approvalCurrent: !pending(record) && record.phase === 'prepared' && record.copyVersion === COPY_VERSION && record.consent?.binding === binding(record), github: record.readiness ?? null, localOperationLock: lstatExists(join(connectionDir(home, record.id), 'operation.lock')), lastLocalStartAt: record.lastLocalStartAt ?? null, lastLocalExit: record.lastLocalExit ?? null, serverContact: 'not checked', modelSignIn: 'not checked' }));
|
|
210
|
+
say(options.json ? JSON.stringify(result, null, 2) : result.length ? result.map(r => `${r.agent} (${r.provider}, ${r.permissions})\n${r.id}\nPreparation: ${r.pairingPending ? 'pairing recovery needed — run prepare' : r.preparation}; current approval: ${r.approvalCurrent ? 'yes' : 'no'}\nLocal operation lock: ${r.localOperationLock ? 'present — this does not prove a live process' : 'absent'}\nGitHub check: ${r.github?.github ? 'write access checked' : 'not confirmed'}${r.github ? ` at ${r.github.checkedAt}` : ''}\nServer contact and model sign-in: not checked. Open My agents for observed activity.`).join('\n\n') : 'No saved package connections. Legacy setup connections are preserved separately.');
|
|
211
|
+
return result;
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
package/src/doctor.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { providerName } from './plan.mjs';
|
|
3
|
+
|
|
4
|
+
function atLeast(actual, minimum) {
|
|
5
|
+
const a = actual.split('.').map(Number), b = minimum.split('.').map(Number);
|
|
6
|
+
for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) return a[i] > b[i]; }
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
export function probeVersion(command) {
|
|
10
|
+
const result = spawnSync(command, ['--version'], { encoding: 'utf8', timeout: 5000, maxBuffer: 64 * 1024, shell: false, windowsHide: true });
|
|
11
|
+
if (result.error || result.status !== 0) return null;
|
|
12
|
+
return result.stdout;
|
|
13
|
+
}
|
|
14
|
+
export function doctor(provider, probe = probeVersion, nodeVersion = process.versions.node) {
|
|
15
|
+
providerName(provider);
|
|
16
|
+
const spec = [
|
|
17
|
+
['git', /git version (\d+\.\d+\.\d+)/, null],
|
|
18
|
+
['gh', /gh version (\d+\.\d+\.\d+)/, null],
|
|
19
|
+
provider === 'claude' ? ['claude', /(?:^|\n)(\d+\.\d+\.\d+)/, '2.1.259'] : ['codex', /codex-cli (\d+\.\d+\.\d+)/, '0.160.0'],
|
|
20
|
+
];
|
|
21
|
+
const checks = [{ tool: 'node', version: nodeVersion, ok: /^\d+\.\d+\.\d+$/.test(nodeVersion) && atLeast(nodeVersion, '22.0.0'), minimum: '22.0.0' }];
|
|
22
|
+
for (const [tool, pattern, minimum] of spec) {
|
|
23
|
+
const output = probe(tool);
|
|
24
|
+
const version = typeof output === 'string' ? pattern.exec(output)?.[1] ?? null : null;
|
|
25
|
+
checks.push({ tool, version, minimum, ok: !!version && (!minimum || atLeast(version, minimum)) });
|
|
26
|
+
}
|
|
27
|
+
return { checks, readyForPairingChecks: checks.every(c => c.ok), authenticated: 'not checked', repositoryAccess: 'not checked', executionReady: false };
|
|
28
|
+
}
|
package/src/plan.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path';
|
|
2
|
+
|
|
3
|
+
export const VERSION = '0.1.0-beta.1';
|
|
4
|
+
export const COPY_VERSION = '2026-10-09.2';
|
|
5
|
+
// These descriptions reflect pilot/agent-kit/providers.mjs. They are a proposal
|
|
6
|
+
// for the onboarding vocabulary, not a change to the current adapter settings.
|
|
7
|
+
export const MODES = {
|
|
8
|
+
claude: {
|
|
9
|
+
guarded: {
|
|
10
|
+
title: 'Guarded automation',
|
|
11
|
+
detail: 'Claude can edit files and run the configured Git, GitHub and project commands without asking. Commands needing additional approval are refused by this foreground runner. It cannot pause to ask you for each action.',
|
|
12
|
+
boundary: 'The command rules are not a system sandbox. Repository scripts, hooks and tools can access files and credentials available to your operating-system account.',
|
|
13
|
+
},
|
|
14
|
+
bypass: {
|
|
15
|
+
title: 'Bypass permissions',
|
|
16
|
+
detail: 'Claude skips its normal permission checks and may read, edit or delete files and execute potentially destructive commands without asking.',
|
|
17
|
+
boundary: 'Use an isolated or disposable execution environment. A dedicated repository folder alone does not isolate your machine or its credentials.',
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
codex: {
|
|
21
|
+
guarded: {
|
|
22
|
+
title: 'Guarded automation',
|
|
23
|
+
detail: 'Codex uses a workspace-write sandbox with automatic approval review. Some escalated commands can be approved to run outside that sandbox. This runner does not ask you to approve every action.',
|
|
24
|
+
boundary: 'Automatic approval is not human review. You must supervise the run and review the resulting changes.',
|
|
25
|
+
},
|
|
26
|
+
bypass: {
|
|
27
|
+
title: 'Full access / bypass permissions',
|
|
28
|
+
detail: 'This adapter disables the Codex sandbox and approval review. Commands run with the access available to your operating-system account without per-action prompts.',
|
|
29
|
+
boundary: 'Use an isolated or disposable execution environment. A dedicated repository folder alone does not isolate your machine or its credentials.',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function clean(value, label) {
|
|
35
|
+
if (typeof value !== 'string' || !value || value.length > 2048 || /[\x00-\x1f\x7f-\x9f]/.test(value)) throw new Error(`Provide a valid ${label}.`);
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
export function providerName(value) {
|
|
39
|
+
if (!Object.hasOwn(MODES, value ?? '')) throw new Error('Choose --provider claude or codex.');
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
export function makePlan(input) {
|
|
43
|
+
const provider = providerName(input.provider);
|
|
44
|
+
if (!Object.hasOwn(MODES[provider], input.mode ?? '')) throw new Error('Choose --mode guarded or bypass explicitly.');
|
|
45
|
+
const raw = clean(input.projectUrl, 'project URL');
|
|
46
|
+
let url;
|
|
47
|
+
try { url = new URL(raw); } catch { throw new Error('Use a Karma42 project service URL.'); }
|
|
48
|
+
const local = ['localhost', '127.0.0.1'].includes(url.hostname);
|
|
49
|
+
if (!['https:', 'http:'].includes(url.protocol) || (url.protocol === 'http:' && !local)
|
|
50
|
+
|| url.username || url.password || url.search || url.hash || !/^\/p\/[a-f0-9]{64}\/?$/.test(url.pathname)) {
|
|
51
|
+
throw new Error('Use an HTTPS project service URL ending in /p/PROJECT_ID, without credentials or query parameters. HTTP is allowed only on loopback for local previews.');
|
|
52
|
+
}
|
|
53
|
+
const repo = clean(input.repo, 'GitHub repository URL');
|
|
54
|
+
if (!/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/?$/.test(repo)) throw new Error('Use a GitHub repository URL without credentials, query parameters or fragments.');
|
|
55
|
+
if (new URL(repo).pathname.split('/').filter(Boolean).length !== 2 || /\/(?:\.|\.\.)(?:\/|$)/.test(repo)) throw new Error('Use a valid owner and repository in the GitHub URL.');
|
|
56
|
+
const folder = clean(input.folder, 'absolute checkout folder');
|
|
57
|
+
if (!isAbsolute(folder)) throw new Error('Choose an absolute checkout folder.');
|
|
58
|
+
return {
|
|
59
|
+
draft: true, verified: false, version: VERSION, copyVersion: COPY_VERSION,
|
|
60
|
+
projectUrl: url.href.replace(/\/$/, ''), repo: repo.replace(/\/$/, ''), folder, provider, mode: input.mode,
|
|
61
|
+
permissions: MODES[provider][input.mode],
|
|
62
|
+
acknowledgements: [
|
|
63
|
+
'I understand that this agent can change files, run commands, use my granted GitHub access and consume my model usage.',
|
|
64
|
+
'I will monitor its activity, review changes and stop it if it acts unexpectedly.',
|
|
65
|
+
...(input.mode === 'bypass' ? ['I understand that bypass skips per-action permission checks. I have chosen an isolated or disposable environment appropriate for this risk.'] : []),
|
|
66
|
+
],
|
|
67
|
+
checks: { account: 'not checked', membership: 'not checked', repositoryAccess: 'not checked', folder: 'not checked', connection: 'not created', runner: 'not started' },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function renderPlan(plan) {
|
|
71
|
+
return [
|
|
72
|
+
'Karma42 — onboarding preview',
|
|
73
|
+
'DRAFT: this is a walkthrough. Nothing is connected and no consent is recorded.',
|
|
74
|
+
'', '1. Review the proposed connection',
|
|
75
|
+
`Project service: ${plan.projectUrl}`, `Repository (unverified): ${plan.repo}`, `Folder (unverified): ${plan.folder}`,
|
|
76
|
+
`Provider: ${plan.provider}`, `Permissions: ${plan.permissions.title}`,
|
|
77
|
+
'', '2. Understand the permission choice', plan.permissions.detail, plan.permissions.boundary,
|
|
78
|
+
'Karma42 membership and GitHub permissions still apply. This choice does not grant additional access.',
|
|
79
|
+
'', '3. Acknowledge before preparing the connection',
|
|
80
|
+
...plan.acknowledgements.map(line => `[ ] ${line}`),
|
|
81
|
+
'Proposed action: Prepare connection. Account, project and repository must first be checked against the service.',
|
|
82
|
+
'', '4. Start separately when ready',
|
|
83
|
+
'Proposed confirmation: Start this agent now? If the project is active, it can begin work immediately and consume model usage. If the project is preparing or paused, it waits.',
|
|
84
|
+
'Keep the foreground terminal open to monitor activity. Ctrl+C stops the runner. Previously completed actions are not undone.',
|
|
85
|
+
'The runner will report model usage when available; this is not a measured price or a spending cap.',
|
|
86
|
+
'', 'Preview result: not connected; not started. Connect separately in an interactive terminal.',
|
|
87
|
+
].join('\n');
|
|
88
|
+
}
|
package/src/state.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { constants, lstatSync, realpathSync, fstatSync, mkdirSync, openSync, closeSync, readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join, dirname, basename, resolve } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
export const defaultHome = () => join(homedir(), '.karma42', 'connections');
|
|
7
|
+
export function canonicalPath(value) {
|
|
8
|
+
let path = resolve(value);
|
|
9
|
+
const tail = [];
|
|
10
|
+
while (!lstatExists(path)) { tail.unshift(basename(path)); path = dirname(path); }
|
|
11
|
+
return join(realpathSync(path), ...tail);
|
|
12
|
+
}
|
|
13
|
+
export const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
14
|
+
export function privateDir(path) {
|
|
15
|
+
try { mkdirSync(path, { mode: 0o700 }); }
|
|
16
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
17
|
+
const stat = lstatSync(path);
|
|
18
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077)) throw new Error('Connection storage must be a private directory, without symbolic links.');
|
|
19
|
+
}
|
|
20
|
+
export function ensureHome(home) {
|
|
21
|
+
// Only the caller-selected root is created recursively. Existing root must be private.
|
|
22
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
23
|
+
privateDir(home);
|
|
24
|
+
}
|
|
25
|
+
export function readPrivate(path, json = false) {
|
|
26
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
27
|
+
try {
|
|
28
|
+
const stat = fstatSync(fd);
|
|
29
|
+
if (!stat.isFile() || (stat.mode & 0o077) || stat.nlink !== 1) throw new Error('Connection files must be private regular files.');
|
|
30
|
+
const value = readFileSync(fd, 'utf8');
|
|
31
|
+
if (!json) return value;
|
|
32
|
+
try { return JSON.parse(value); } catch { throw new Error('Invalid private connection record. Restore its backup or reconnect.'); }
|
|
33
|
+
} finally { closeSync(fd); }
|
|
34
|
+
}
|
|
35
|
+
export function writePrivate(path, value) {
|
|
36
|
+
try {
|
|
37
|
+
const stat = lstatSync(path);
|
|
38
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) || stat.nlink !== 1) throw new Error('Refusing to replace unsafe connection storage.');
|
|
39
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
40
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
41
|
+
const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
42
|
+
try { writeFileSync(fd, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`); }
|
|
43
|
+
finally { closeSync(fd); }
|
|
44
|
+
try { renameSync(temp, path); } finally { try { unlinkSync(temp); } catch {} }
|
|
45
|
+
}
|
|
46
|
+
export function connectionDir(home, id) {
|
|
47
|
+
if (!/^[a-f0-9]{64}\/[\w-]{1,200}$/.test(id ?? '')) throw new Error('Choose a connection ID from status.');
|
|
48
|
+
ensureHome(home);
|
|
49
|
+
const [project, agent] = id.split('/');
|
|
50
|
+
privateDir(join(home, project));
|
|
51
|
+
const dir = join(home, project, agent);
|
|
52
|
+
privateDir(dir);
|
|
53
|
+
return dir;
|
|
54
|
+
}
|
|
55
|
+
export function save(dir, record) { writePrivate(join(dir, 'connection.json'), record); }
|
|
56
|
+
export function load(dir) { return readPrivate(join(dir, 'connection.json'), true); }
|
|
57
|
+
export function binding(record) {
|
|
58
|
+
return digest([record.schema, record.id, record.service, record.agentId, record.agentName, record.repo, record.folder, record.provider, record.mode, record.copyVersion, record.runtimePolicy, record.bundleHash]);
|
|
59
|
+
}
|
|
60
|
+
export function withLock(dir, action) {
|
|
61
|
+
const lock = join(dir, 'operation.lock');
|
|
62
|
+
let fd;
|
|
63
|
+
try { fd = openSync(lock, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code === 'EEXIST') throw new Error('Another operation holds this connection. After a crash, check that its process has stopped before removing operation.lock.');
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
writeFileSync(fd, `${process.pid}\n`); closeSync(fd);
|
|
69
|
+
return Promise.resolve().then(action).finally(() => unlinkSync(lock));
|
|
70
|
+
}
|
|
71
|
+
export function list(home) {
|
|
72
|
+
if (!lstatExists(home)) return [];
|
|
73
|
+
ensureHome(home);
|
|
74
|
+
const result = [];
|
|
75
|
+
for (const project of readdirSync(home)) {
|
|
76
|
+
if (!/^[a-f0-9]{64}$/.test(project)) continue;
|
|
77
|
+
privateDir(join(home, project));
|
|
78
|
+
for (const agent of readdirSync(join(home, project))) {
|
|
79
|
+
if (!/^[\w-]{1,200}$/.test(agent)) continue;
|
|
80
|
+
const dir = connectionDir(home, `${project}/${agent}`);
|
|
81
|
+
if (lstatExists(join(dir, 'connection.json'))) result.push(load(dir));
|
|
82
|
+
else if (lstatExists(join(dir, 'pending-pair.json'))) {
|
|
83
|
+
const { token, ...pending } = readPrivate(join(dir, 'pending-pair.json'), true);
|
|
84
|
+
result.push({ ...pending, phase: 'paired', consent: null });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
export function lstatExists(path) { try { lstatSync(path); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } }
|
|
91
|
+
export function cleanEnvironment(input = process.env) {
|
|
92
|
+
return Object.fromEntries(Object.entries(input).filter(([key]) => !key.startsWith('PILOT_')));
|
|
93
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Separate process keeps ambient PILOT_* overrides out of the canonical supervisor.
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { load, binding } from './state.mjs';
|
|
4
|
+
import { COPY_VERSION } from './plan.mjs';
|
|
5
|
+
import { supervise } from '../runtime/launcher.mjs';
|
|
6
|
+
const dir = process.argv[2];
|
|
7
|
+
const record = load(dir);
|
|
8
|
+
if (record.phase !== 'prepared' || record.copyVersion !== COPY_VERSION || record.consent?.binding !== binding(record)) throw new Error('Prepare this connection and confirm its permissions before starting.');
|
|
9
|
+
process.exitCode = await supervise({
|
|
10
|
+
service: record.service, agentName: record.agentName, repoDir: record.folder, home: join(dir, 'agent-state'),
|
|
11
|
+
env: {
|
|
12
|
+
PILOT_PROVIDER: record.provider,
|
|
13
|
+
PILOT_PERMISSION_MODE: record.mode === 'bypass' ? 'bypassPermissions' : 'acceptEdits',
|
|
14
|
+
PILOT_MAX_TURNS_PER_HOUR: '0', PILOT_TURN_SECONDS: '0', PILOT_TURN_BUDGET_USD: '0',
|
|
15
|
+
PILOT_WORK_INTERVAL_MINUTES: '15',
|
|
16
|
+
},
|
|
17
|
+
});
|
package/src/system.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { spawnSync, spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { doctor } from './doctor.mjs';
|
|
7
|
+
import { cleanEnvironment, privateDir, readPrivate, writePrivate } from './state.mjs';
|
|
8
|
+
|
|
9
|
+
export function run(command, args, cwd) {
|
|
10
|
+
const result = spawnSync(command, args, { cwd, shell: false, encoding: 'utf8', timeout: 120_000, maxBuffer: 1024 * 1024, env: cleanEnvironment(), windowsHide: true });
|
|
11
|
+
// Do not print raw subprocess errors/output: they can contain credentials or terminal escapes.
|
|
12
|
+
if (result.error || result.status !== 0) return null;
|
|
13
|
+
return result.stdout.trim();
|
|
14
|
+
}
|
|
15
|
+
export async function request(service, route, body, token) {
|
|
16
|
+
let response;
|
|
17
|
+
try {
|
|
18
|
+
response = await fetch(`${service}/api/join/${route}`, {
|
|
19
|
+
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15_000),
|
|
20
|
+
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
21
|
+
body: JSON.stringify(body),
|
|
22
|
+
});
|
|
23
|
+
} catch { throw new Error(route === 'exchange' ? 'Pairing reply unavailable. The code may have been consumed; generate a fresh code in My agents.' : 'Could not update the readiness check. Retry prepare when the service is available.'); }
|
|
24
|
+
if (!response.ok) {
|
|
25
|
+
if (response.status === 401 || response.status === 403) throw new Error(route === 'exchange' ? 'The setup code is expired, invalid or no longer authorized. Generate a fresh code in My agents.' : 'This connection is no longer authorized. Check membership and reconnect in My agents.');
|
|
26
|
+
if (response.status === 429) throw new Error('Too many requests. Wait before retrying.');
|
|
27
|
+
throw new Error('The project service could not complete this request.');
|
|
28
|
+
}
|
|
29
|
+
const raw = await response.text();
|
|
30
|
+
if (raw.length > 16_384) throw new Error('Unexpected project service response.');
|
|
31
|
+
try { return JSON.parse(raw); } catch { throw new Error('Unexpected project service response.'); }
|
|
32
|
+
}
|
|
33
|
+
export function verifyBundle() {
|
|
34
|
+
const root = fileURLToPath(new URL('../runtime/', import.meta.url));
|
|
35
|
+
let manifest;
|
|
36
|
+
try { manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); }
|
|
37
|
+
catch { throw new Error('Packaged runtime missing. Build or reinstall this package before preparing an agent.'); }
|
|
38
|
+
const names = ['runner.mjs', 'control-v2.mjs', 'runtime.mjs', 'launcher.mjs', 'claude-settings.json', 'AGENT_PROMPT_V2.md', 'providers.mjs'];
|
|
39
|
+
if (Object.keys(manifest.files ?? {}).sort().join() !== [...names].sort().join()) throw new Error('Invalid packaged runtime manifest.');
|
|
40
|
+
const files = {};
|
|
41
|
+
for (const name of names) {
|
|
42
|
+
const content = readFileSync(join(root, name));
|
|
43
|
+
if (createHash('sha256').update(content).digest('hex') !== manifest.files[name]) throw new Error('Packaged runtime integrity check failed. Reinstall the package.');
|
|
44
|
+
files[name] = content;
|
|
45
|
+
}
|
|
46
|
+
return { files, fingerprint: createHash('sha256').update(JSON.stringify(manifest.files)).digest('hex') };
|
|
47
|
+
}
|
|
48
|
+
export function installBundle(state) {
|
|
49
|
+
const bundle = verifyBundle();
|
|
50
|
+
const kit = join(state, 'kit'); privateDir(kit);
|
|
51
|
+
for (const [name, content] of Object.entries(bundle.files)) writePrivate(join(kit, name), content.toString('utf8'));
|
|
52
|
+
return bundle.fingerprint;
|
|
53
|
+
}
|
|
54
|
+
export function verifyInstalled(state) {
|
|
55
|
+
const bundle = verifyBundle();
|
|
56
|
+
for (const [name, content] of Object.entries(bundle.files)) {
|
|
57
|
+
if (readPrivate(join(state, 'kit', name)) !== content.toString('utf8')) throw new Error('Prepared runtime files changed. Run prepare before starting.');
|
|
58
|
+
}
|
|
59
|
+
return bundle.fingerprint;
|
|
60
|
+
}
|
|
61
|
+
export function launch(dir, record) {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL('./supervisor.mjs', import.meta.url)), dir], {
|
|
64
|
+
shell: false, stdio: 'inherit', env: cleanEnvironment(),
|
|
65
|
+
});
|
|
66
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
67
|
+
let stopping = false;
|
|
68
|
+
const handlers = Object.fromEntries(signals.map(signal => [signal, () => { stopping = true; child.kill(signal); }]));
|
|
69
|
+
for (const [signal, handler] of Object.entries(handlers)) process.on(signal, handler);
|
|
70
|
+
function cleanup() { for (const [signal, handler] of Object.entries(handlers)) process.off(signal, handler); }
|
|
71
|
+
child.once('error', () => { cleanup(); reject(new Error('Could not launch the foreground supervisor.')); });
|
|
72
|
+
child.once('exit', (code, signal) => { cleanup(); resolve(stopping && signal ? 0 : code ?? 1); });
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export const system = { doctor, run, request, installBundle, verifyBundle, verifyInstalled, launch, now: () => new Date().toISOString() };
|
package/src/terminal.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
export function terminal(input = process.stdin, output = process.stdout) {
|
|
3
|
+
return {
|
|
4
|
+
interactive: !!input.isTTY && !!output.isTTY,
|
|
5
|
+
say: text => output.write(`${text}\n`),
|
|
6
|
+
async ask(text) {
|
|
7
|
+
const rl = createInterface({ input, output });
|
|
8
|
+
try { return (await rl.question(`${text} `)).trim(); } finally { rl.close(); }
|
|
9
|
+
},
|
|
10
|
+
async confirm(text) { return (await this.ask(`${text} [yes/no]`)).toLowerCase() === 'yes'; },
|
|
11
|
+
secret(text) {
|
|
12
|
+
if (!input.isTTY || !output.isTTY) throw new Error('Enter the setup code in an interactive terminal.');
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const wasRaw = input.isRaw; let value = '';
|
|
15
|
+
input.setRawMode(true); input.resume(); output.write(`${text} `);
|
|
16
|
+
const finish = (error) => {
|
|
17
|
+
input.off('data', onData); input.off('end', onEnd); input.setRawMode(wasRaw); input.pause(); output.write('\n');
|
|
18
|
+
if (error) reject(error); else resolve(value);
|
|
19
|
+
};
|
|
20
|
+
const onEnd = () => finish(new Error('Code entry cancelled.'));
|
|
21
|
+
const onData = chunk => {
|
|
22
|
+
for (const char of chunk.toString('utf8')) {
|
|
23
|
+
if (char === '\x03' || char === '\x04') { finish(new Error('Code entry cancelled.')); return; }
|
|
24
|
+
if (char === '\r' || char === '\n') { finish(); return; }
|
|
25
|
+
if (char === '\x7f' || char === '\b') value = value.slice(0, -1);
|
|
26
|
+
else if (/[\w-]/.test(char) && value.length < 100) value += char;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
input.on('data', onData); input.once('end', onEnd);
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|