@aglit-ai/cli 0.1.10 → 0.1.11
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/package.json +1 -1
- package/src/model-connections.cjs +109 -0
- package/src/runtime-download.cjs +77 -0
package/package.json
CHANGED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** UI-free selection policy lives in runtime-api; the CLI prompts only on its
|
|
3
|
+
* explicit choice-required result, then persists the choice through native control. */
|
|
4
|
+
async function listModelConnections(control) {
|
|
5
|
+
try {
|
|
6
|
+
const view = await control('models-list');
|
|
7
|
+
if (!view || !Array.isArray(view.connections) || !view.selection || typeof view.selection.status !== 'string') throw Error('Invalid discovery response');
|
|
8
|
+
return view;
|
|
9
|
+
} catch {
|
|
10
|
+
return { connections: [], selection: { status: 'discovery-unavailable', connection_id: null },
|
|
11
|
+
warnings: ['Model endpoint discovery is unavailable. Existing provider settings are unchanged; retry aglit models list later.'] };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
async function chooseModelConnection(control, { provider, question } = {}) {
|
|
15
|
+
let view = await listModelConnections(control);
|
|
16
|
+
if (view.selection.status === 'discovery-unavailable') return { view, connectionId: null, unavailable: true };
|
|
17
|
+
// Init/choose is an explicit connection action. Passive models-list never
|
|
18
|
+
// prompts; reconnect only the saved/chosen endpoint, never every known host.
|
|
19
|
+
if (['preferred-unavailable','setup-required'].includes(view.selection.status)) {
|
|
20
|
+
const remote = view.connections.filter(value => value.kind === 'remote');
|
|
21
|
+
let target = view.preferred_connection_id ? remote.find(value => value.id === view.preferred_connection_id) : remote.length === 1 ? remote[0] : undefined;
|
|
22
|
+
let chosen = false;
|
|
23
|
+
if (!target && !view.preferred_connection_id && remote.length > 1) {
|
|
24
|
+
if (!question) throw Error('More than one model connection is available. Run aglit models choose.');
|
|
25
|
+
const answer = (await question(remote.map((value,index)=>`${index+1}. ${value.name}`).join('\n')+'\nModel connection: ')).trim();
|
|
26
|
+
if (!/^[1-9]\d*$/.test(answer) || !remote[Number(answer)-1]) throw Error('Choose a listed model connection');
|
|
27
|
+
target = remote[Number(answer)-1]; chosen = true;
|
|
28
|
+
}
|
|
29
|
+
if (target) {
|
|
30
|
+
view = await modelCommand(control,'register',undefined,{device:target.device_id,fingerprint:target.identity_fingerprint,name:target.name},question);
|
|
31
|
+
if (chosen) view = await control('models-prefer',{connection_id:target.id});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
let selection = view.selection;
|
|
35
|
+
if (provider && provider !== 'auto' && provider !== 'aglit') {
|
|
36
|
+
const candidates = view.connections.filter(value => value.available && value.models.some(model => model.provider === provider));
|
|
37
|
+
if (candidates.length === 1) {
|
|
38
|
+
view = await control('models-prefer', { connection_id: candidates[0].id }); selection = view.selection;
|
|
39
|
+
} else if (!candidates.length) throw Error('No connected account supplies that provider');
|
|
40
|
+
else { view = { ...view, connections: candidates }; selection = { status: 'choice-required', connection_id: null }; }
|
|
41
|
+
}
|
|
42
|
+
if (selection.status === 'choice-required') {
|
|
43
|
+
if (!question) throw Error('More than one model connection is available. Run aglit models choose or aglit models prefer CONNECTION_ID.');
|
|
44
|
+
const choices = view.connections.filter(value => value.available && value.models.length);
|
|
45
|
+
const answer = await question(choices.map((value,index)=>`${index+1}. ${value.name}`).join('\n')+'\nModel connection: ');
|
|
46
|
+
if (!/^[1-9]\d*$/.test(answer.trim()) || !choices[Number(answer.trim())-1]) throw Error('Choose a listed model connection');
|
|
47
|
+
view = await control('models-prefer', { connection_id: choices[Number(answer.trim())-1].id }); selection = view.selection;
|
|
48
|
+
}
|
|
49
|
+
if (selection.status === 'preferred-unavailable') throw Error('Your saved model connection is unavailable. Reconnect it or explicitly choose another with aglit models prefer CONNECTION_ID.');
|
|
50
|
+
if (selection.status !== 'selected') throw Error('No model connection is ready. Connect a local provider or register an Aglit model endpoint.');
|
|
51
|
+
return { view, connectionId: selection.connection_id };
|
|
52
|
+
}
|
|
53
|
+
function validateModelCommand(command, id, values) {
|
|
54
|
+
const allowed = {list:['json'],test:['connection','model','harness'],choose:[],pool:['name','connections'],publish:['connection','model'],unpublish:['connection','model'],unserve:[],register:['device','fingerprint','name','challenge','code'],prefer:[],remove:[]};
|
|
55
|
+
if (!Object.hasOwn(allowed,command) || Object.keys(values).some(key=>!allowed[command].includes(key)) ||
|
|
56
|
+
(!['prefer','remove','pool'].includes(command) && id) || (['prefer','remove','pool'].includes(command) && !id)) throw Error('Invalid model command or options');
|
|
57
|
+
if (command === 'pool' && (!/^[A-Za-z0-9._-]{1,80}$/.test(id) || !values.name?.trim() || values.name.length > 100 ||
|
|
58
|
+
typeof values.connections !== 'string' || values.connections.length > 6400 || !values.connections.split(',').every(value => /^local:[A-Za-z0-9._:-]{1,180}$/.test(value) && !value.startsWith('local:pool:'))))
|
|
59
|
+
throw Error('Use aglit models pool POOL_ID --name NAME --connections local:ACCOUNT_A,local:ACCOUNT_B');
|
|
60
|
+
if (command === 'register' && (!/^[A-Za-z0-9._:-]{1,180}$/.test(values.device || '') || !/^[a-f0-9]{64}$/.test(values.fingerprint || '')))
|
|
61
|
+
throw Error('Use --device DEVICE_ID and --fingerprint with the endpoint SHA-256 fingerprint');
|
|
62
|
+
if (Boolean(values.challenge) !== Boolean(values.code) || values.code && !/^[0-9]{4,12}$/.test(values.code) ||
|
|
63
|
+
values.challenge && (!/^[A-Za-z0-9._:-]{1,256}$/.test(values.challenge))) throw Error('Supply both --challenge and a numeric --code');
|
|
64
|
+
if (id && !/^[A-Za-z0-9._:-]{1,200}$/.test(id)) throw Error('Invalid model connection ID');
|
|
65
|
+
if (['publish','unpublish'].includes(command) && (!/^local:[A-Za-z0-9._:-]{1,180}$/.test(values.connection||'') || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(values.model||''))) throw Error('Choose a local --connection and --model');
|
|
66
|
+
if (command === 'remove' && !id.startsWith('remote:')) throw Error('A registered remote connection ID is required');
|
|
67
|
+
}
|
|
68
|
+
async function modelCommand(control, command, id, values, question) {
|
|
69
|
+
validateModelCommand(command,id,values);
|
|
70
|
+
if (command === 'list') return listModelConnections(control);
|
|
71
|
+
if (command === 'test') {
|
|
72
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(values.model || '') || values.connection && !/^[A-Za-z0-9._:-]{1,200}$/.test(values.connection)) throw Error('Use aglit models test --model MODEL_ID [--connection CONNECTION_ID]');
|
|
73
|
+
return control('models-test', { model_id: values.model, ...(values.connection ? { connection_id: values.connection } : {}), ...(values.harness ? { harness: 'true' } : {}) });
|
|
74
|
+
}
|
|
75
|
+
if (command === 'pool') return control('models-pool-save', { pool_id: 'local:pool:' + id, name: values.name,
|
|
76
|
+
connection_ids: JSON.stringify(values.connections.split(',')) });
|
|
77
|
+
if (command === 'register') {
|
|
78
|
+
if (!values.device || !values.fingerprint || id) throw Error('Use aglit models register --device DEVICE_ID --fingerprint SHA256 [--name NAME]');
|
|
79
|
+
if (Boolean(values.challenge) !== Boolean(values.code) || values.code && !/^[0-9]{4,12}$/.test(values.code)) throw Error('Supply both --challenge and a numeric --code');
|
|
80
|
+
const fields = {device_id:values.device,identity_fingerprint:values.fingerprint,name:values.name||values.device, ...(values.challenge ? {challenge_id:values.challenge,code:values.code} : {})};
|
|
81
|
+
let result = await control('models-register',fields);
|
|
82
|
+
let consoleUrl;
|
|
83
|
+
if (typeof result.console_url === 'string') {
|
|
84
|
+
try { const url = new URL(result.console_url);
|
|
85
|
+
if ((url.protocol === 'https:' || process.env.NODE_ENV === 'development' && url.protocol === 'http:' && url.hostname === '127.0.0.1') &&
|
|
86
|
+
!url.username && !url.password && !url.search && !url.hash && url.pathname === '/employees/' + encodeURIComponent(values.device)) consoleUrl = url.href;
|
|
87
|
+
} catch { /* Never present an unvalidated authentication destination. */ }
|
|
88
|
+
}
|
|
89
|
+
const instructions = `A security-code dialog appears on ${fields.name}. Check that device.` +
|
|
90
|
+
(consoleUrl ? ` Open its console on that device if needed: ${consoleUrl}` : '');
|
|
91
|
+
if (result.authentication_required && result.challenge_id && question) {
|
|
92
|
+
const code = (await question(instructions + '\nRemote Access authentication code: ')).trim();
|
|
93
|
+
if (!/^[0-9]{4,12}$/.test(code)) throw Error('A numeric Remote Access code is required');
|
|
94
|
+
result = await control('models-register',{...fields,challenge_id:result.challenge_id,code});
|
|
95
|
+
}
|
|
96
|
+
if (result.authentication_required) throw Error(instructions + ' Complete registration with --challenge CHALLENGE_ID --code CODE' +
|
|
97
|
+
(result.challenge_id ? ' (challenge '+result.challenge_id+')' : ''));
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
if (command === 'prefer' || command === 'remove') {
|
|
101
|
+
if (!id || !/^[A-Za-z0-9._:-]{1,200}$/.test(id)) throw Error('A model connection ID is required');
|
|
102
|
+
return control(command==='prefer'?'models-prefer':'models-remove',{connection_id:command==='prefer'&&id==='auto'?'':id});
|
|
103
|
+
}
|
|
104
|
+
if (command === 'publish' || command === 'unpublish') return control('models-publish',{connection_id:values.connection,model_id:values.model,published:String(command==='publish')});
|
|
105
|
+
if (command === 'unserve') return control('models-unpublish-all');
|
|
106
|
+
if (command === 'choose') return (await chooseModelConnection(control,{question})).view;
|
|
107
|
+
throw Error('Use aglit models list|register|prefer|remove|publish|unpublish|unserve|choose');
|
|
108
|
+
}
|
|
109
|
+
module.exports={chooseModelConnection,listModelConnections,modelCommand,validateModelCommand};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const { execFile } = require('node:child_process');
|
|
7
|
+
const { promisify } = require('node:util');
|
|
8
|
+
const run = promisify(execFile);
|
|
9
|
+
const need = (value, message) => { if (!value) throw Error(message); };
|
|
10
|
+
|
|
11
|
+
// Read a single payload from a verified npm tarball. Native tar writes only to
|
|
12
|
+
// stdout: package paths, links and lifecycle scripts never write into the guest.
|
|
13
|
+
async function packageFile(manifest, file, maximum, signal = AbortSignal.timeout(120000)) {
|
|
14
|
+
const url = new URL(manifest.dist.tarball);
|
|
15
|
+
need(url.protocol === 'https:' && url.hostname === 'registry.npmjs.org' && !url.port && !url.username && !url.password &&
|
|
16
|
+
/^sha512-[A-Za-z0-9+/]{86}==$/.test(manifest.dist.integrity), 'Untrusted runtime npm package');
|
|
17
|
+
need(['runtime/desktop-runtime.json', 'runtime/part.bin'].includes(file), 'Unknown runtime payload');
|
|
18
|
+
const directory = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'aglit-runtime-'));
|
|
19
|
+
const archive = path.join(directory, 'package.tgz');
|
|
20
|
+
try {
|
|
21
|
+
const response = await fetch(url, {redirect: 'error', signal: AbortSignal.any([signal, AbortSignal.timeout(120000)])});
|
|
22
|
+
need(response.ok && response.body, 'Runtime npm download unavailable');
|
|
23
|
+
const fd = fs.openSync(archive, 'wx', 0o600), digest = crypto.createHash('sha512');
|
|
24
|
+
let size = 0;
|
|
25
|
+
try {
|
|
26
|
+
for await (const chunk of response.body) {
|
|
27
|
+
size += chunk.length; need(size <= maximum + 2 * 1024 ** 2, 'Runtime npm package exceeds limit');
|
|
28
|
+
digest.update(chunk);
|
|
29
|
+
let offset = 0;
|
|
30
|
+
while (offset < chunk.length) offset += fs.writeSync(fd, chunk, offset, chunk.length - offset);
|
|
31
|
+
}
|
|
32
|
+
need('sha512-' + digest.digest('base64') === manifest.dist.integrity, 'Runtime npm integrity differs');
|
|
33
|
+
} finally { fs.closeSync(fd); }
|
|
34
|
+
const tar = process.platform === 'win32' ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32/tar.exe') : '/usr/bin/tar';
|
|
35
|
+
const {stdout} = await run(tar, ['-xOf', archive, 'package/' + file], {encoding: 'buffer', maxBuffer: maximum + 1, timeout: 30000, windowsHide: true});
|
|
36
|
+
need(stdout.length > 0 && stdout.length <= maximum, 'Runtime payload exceeds limit');
|
|
37
|
+
return stdout;
|
|
38
|
+
} finally { fs.rmSync(directory, {recursive: true, force: true}); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The same verified part stream feeds normal downloads and native baseline
|
|
42
|
+
// admission, whose existing descriptor custody stays owned by its native worker.
|
|
43
|
+
async function* archiveParts({descriptor, parts, signal}) {
|
|
44
|
+
need(parts.length === descriptor.chunks.length && parts.length > 0 && parts.length <= 32, 'Runtime part graph differs');
|
|
45
|
+
const sha256 = crypto.createHash('sha256'), sha512 = crypto.createHash('sha512');
|
|
46
|
+
let received = 0;
|
|
47
|
+
for (const [index, part] of descriptor.chunks.entries()) {
|
|
48
|
+
need(parts[index].name === part.package && parts[index].version === part.version, 'Runtime part identity differs');
|
|
49
|
+
const bytes = await packageFile(parts[index], part.file, part.size, signal);
|
|
50
|
+
need(bytes.length === part.size && crypto.createHash('sha256').update(bytes).digest('hex') === part.sha256 &&
|
|
51
|
+
crypto.createHash('sha512').update(bytes).digest('base64') === part.sha512, 'Runtime part checksum differs');
|
|
52
|
+
received += bytes.length; sha256.update(bytes); sha512.update(bytes);
|
|
53
|
+
yield bytes;
|
|
54
|
+
}
|
|
55
|
+
need(received === descriptor.archive.size && sha256.digest('hex') === descriptor.archive.sha256 &&
|
|
56
|
+
sha512.digest('base64') === descriptor.archive.sha512, 'Runtime archive checksum differs');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function downloadArchive({descriptor, parts, destination, onProgress = () => {}}) {
|
|
60
|
+
const fd = fs.openSync(destination, 'wx', 0o600), owned = fs.fstatSync(fd);
|
|
61
|
+
let received = 0, verified = false;
|
|
62
|
+
try {
|
|
63
|
+
for await (const bytes of archiveParts({descriptor, parts})) {
|
|
64
|
+
let offset = 0;
|
|
65
|
+
while (offset < bytes.length) offset += fs.writeSync(fd, bytes, offset, bytes.length - offset);
|
|
66
|
+
received += bytes.length; onProgress(received);
|
|
67
|
+
}
|
|
68
|
+
fs.fsyncSync(fd); verified = true;
|
|
69
|
+
} finally {
|
|
70
|
+
fs.closeSync(fd);
|
|
71
|
+
if (!verified) {
|
|
72
|
+
const current = fs.lstatSync(destination);
|
|
73
|
+
if (current.dev === owned.dev && current.ino === owned.ino && current.isFile() && !current.isSymbolicLink()) fs.unlinkSync(destination);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
module.exports = {packageFile, archiveParts, downloadArchive};
|