@aglit-ai/cli 0.0.0-stage → 0.1.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 +68 -2
- package/bin/aglit.cjs +138 -0
- package/package.json +22 -4
- package/src/control.cjs +55 -0
- package/src/extract.cjs +52 -0
- package/src/installation.cjs +214 -0
package/README.md
CHANGED
|
@@ -1,3 +1,69 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Aglit Computer Use
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Computer Use makes a signed-in desktop remotely controllable from the user's Aglit
|
|
4
|
+
account. Aglit Mini Computer remains the separate VM host application.
|
|
5
|
+
|
|
6
|
+
After the CLI and a compatible signed desktop release have been published:
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
pnpm add -g @aglit-ai/cli
|
|
10
|
+
aglit install
|
|
11
|
+
aglit init
|
|
12
|
+
aglit status
|
|
13
|
+
aglit disable
|
|
14
|
+
aglit enable
|
|
15
|
+
aglit uninstall
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`init` opens Aglit's Google login in the browser, asks for a computer name,
|
|
19
|
+
detects existing Codex/Claude logins, and uses a configured authenticated provider
|
|
20
|
+
or the sole available provider. Multiple usable providers prompt for a choice.
|
|
21
|
+
Provider login detection is not an inference acceptance check. Original provider
|
|
22
|
+
accounts remain owned by the user; uninstall does not delete their original login.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
aglit init --name "Office Mac" --provider codex
|
|
26
|
+
aglit providers
|
|
27
|
+
aglit teams
|
|
28
|
+
aglit init --name "Office Mac" --provider auto --team TEAM_ID
|
|
29
|
+
aglit invite USER_EMAIL --team TEAM_ID
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
For unattended setup, provide `--name` and an explicit `--provider` when detection
|
|
33
|
+
cannot select a single provider. Initial account login and OS consent still need
|
|
34
|
+
the user. On macOS, Screen Recording and Accessibility must be granted before
|
|
35
|
+
`init` enables access. The native tray offers status, enable/disable, Console and
|
|
36
|
+
quit while the application shell stays hidden. Login-item registration starts
|
|
37
|
+
the same private profile on the next user login.
|
|
38
|
+
|
|
39
|
+
`--team` selects the existing backend team/invitation scope. It does not grant team
|
|
40
|
+
members access to a separately registered personal computer. The backend requires
|
|
41
|
+
current accepted membership/admin authority for its operations; a pending
|
|
42
|
+
invitation is not access. Shared computer enrollment needs an authoritative team
|
|
43
|
+
device binding, which this CLI does not create from a caller-supplied team ID.
|
|
44
|
+
|
|
45
|
+
`status` distinguishes the local enabled setting from a connected tunnel. It also
|
|
46
|
+
reports permissions and the selected provider. Neither status means video/input,
|
|
47
|
+
inference or end-to-end cryptographic acceptance has been verified.
|
|
48
|
+
|
|
49
|
+
`uninstall` purges by default. It disables this installation, revokes viewer
|
|
50
|
+
sessions, confirms deletion of its cloud registration, removes its login item,
|
|
51
|
+
signs out, waits for its process to exit and deletes its private runtime/profile.
|
|
52
|
+
This includes bundled plugins/helpers, copied provider state, keys, settings,
|
|
53
|
+
history and logs inside that installation. Mini Computer, VMs, shared browser
|
|
54
|
+
profiles, existing provider accounts and other applications are outside that
|
|
55
|
+
ownership. A failed cloud revocation preserves credentials for retry; local
|
|
56
|
+
access is disabled. Interrupted native-file cleanup can be resumed with the same
|
|
57
|
+
command. The package-manager-installed CLI wrapper itself can be removed with
|
|
58
|
+
`pnpm remove -g @aglit-ai/cli` after runtime uninstall.
|
|
59
|
+
|
|
60
|
+
The installer supports the existing Apple silicon macOS and Windows x64 release
|
|
61
|
+
formats. It verifies the platform pointer against the immutable version receipt,
|
|
62
|
+
asset size and SHA-256/SHA-512, contained archive paths/links, OS publisher admission,
|
|
63
|
+
the exact source commit, obfuscation marker and native CLI protocol version before
|
|
64
|
+
launch. Unsigned Windows artifacts and older desktop releases are rejected.
|
|
65
|
+
Computer Use starts with Anywhere Access disabled and a new per-install device
|
|
66
|
+
identity. It never adopts a managed guest's key or changes its registration.
|
|
67
|
+
|
|
68
|
+
See [architecture and acceptance](../../docs/computer-use.md). The source CLI can
|
|
69
|
+
be inspected without installation using `node packages/cli/bin/aglit.cjs --help`.
|
package/bin/aglit.cjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const { parseArgs } = require('node:util');
|
|
7
|
+
const { createInterface } = require('node:readline/promises');
|
|
8
|
+
const installation = require('../src/installation.cjs');
|
|
9
|
+
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
10
|
+
const help = `Aglit Computer Use
|
|
11
|
+
|
|
12
|
+
aglit install
|
|
13
|
+
aglit init [--name "Computer name"] [--provider codex|claude|auto] [--team TEAM_ID] [--invite EMAIL]
|
|
14
|
+
aglit status [--json]
|
|
15
|
+
aglit enable | disable
|
|
16
|
+
aglit teams
|
|
17
|
+
aglit providers
|
|
18
|
+
aglit invite EMAIL --team TEAM_ID
|
|
19
|
+
aglit uninstall
|
|
20
|
+
|
|
21
|
+
Uninstall revokes remote access and purges this installation, including login,
|
|
22
|
+
settings, owned bundled plugins/helpers and local data. Mini Computer and VMs
|
|
23
|
+
are separate. Team invitations require backend authorization; pending invites
|
|
24
|
+
grant no access. A team selector never grants access to a personal computer.
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
async function finishUninstall(receipt) {
|
|
28
|
+
const deadline = Date.now() + 30000;
|
|
29
|
+
while (Date.now() < deadline) {
|
|
30
|
+
await delay(500);
|
|
31
|
+
try { process.kill(receipt.pid, 0); } catch (error) {
|
|
32
|
+
if (error.code !== 'ESRCH') throw error;
|
|
33
|
+
installation.purge();
|
|
34
|
+
process.stdout.write(JSON.stringify({ uninstalled: true, purged: true }) + '\n'); return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
throw Error('Runtime has not exited; local files preserved. Retry aglit uninstall after it quits.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function main() {
|
|
41
|
+
const { values, positionals } = parseArgs({ options: { name: { type: 'string' }, provider: { type: 'string' }, team: { type: 'string' }, invite: { type: 'string' }, json: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } }, allowPositionals: true, strict: true });
|
|
42
|
+
if (values.help || !positionals.length) { process.stdout.write(help); return; }
|
|
43
|
+
const [command, email] = positionals;
|
|
44
|
+
const options = { install: [], init: ['name', 'provider', 'team', 'invite'], status: ['json'], enable: [], disable: [], teams: [], providers: [], invite: ['team'], uninstall: [] };
|
|
45
|
+
if (!Object.hasOwn(options, command) || Object.keys(values).some(key => !options[command].includes(key)) || positionals.length > (command === 'invite' ? 2 : 1)) throw Error('Invalid command or options. Run aglit --help.');
|
|
46
|
+
if (values.team && !/^[A-Za-z0-9._:-]{1,128}$/.test(values.team)) throw Error('Invalid team ID');
|
|
47
|
+
if (values.provider && !['auto', 'codex', 'claude'].includes(values.provider)) throw Error('Provider must be auto, codex or claude');
|
|
48
|
+
const recipient = command === 'invite' ? email : values.invite;
|
|
49
|
+
if (recipient && (recipient.length > 254 || !/^[^\s@*]+@[^\s@*]+\.[^\s@*]+$/.test(recipient))) throw Error('An individual invitation email address is required');
|
|
50
|
+
if ((command === 'invite' || values.invite) && (!recipient || !values.team)) throw Error('Invitations require an email address and --team TEAM_ID');
|
|
51
|
+
if (values.name !== undefined && (!values.name.trim() || values.name.length > 100 || /[\x00-\x1f\x7f]/.test(values.name))) throw Error('Computer name must contain 1–100 printable characters');
|
|
52
|
+
const print = value => process.stdout.write(JSON.stringify(value, null, 2) + '\n');
|
|
53
|
+
if (command === 'install') { print(await installation.install()); return; }
|
|
54
|
+
if (command === 'status' && !fs.existsSync(path.join(installation.root, 'installation.json'))) {
|
|
55
|
+
print({ product: 'Aglit Computer Use', installed: false, running: false, enabled: false }); return;
|
|
56
|
+
}
|
|
57
|
+
if (command === 'uninstall' && !fs.existsSync(path.join(installation.root, 'installation.json'))) {
|
|
58
|
+
if (fs.existsSync(installation.root)) installation.purgeIncomplete();
|
|
59
|
+
print({ uninstalled: true, purged: true }); return;
|
|
60
|
+
}
|
|
61
|
+
if (command === 'uninstall' || command === 'status') {
|
|
62
|
+
const pending = installation.readUninstall();
|
|
63
|
+
if (pending) {
|
|
64
|
+
if (command === 'uninstall') await finishUninstall(pending);
|
|
65
|
+
else print({ installed: true, uninstalling: true, enabled: false });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
installation.readInstallation();
|
|
70
|
+
const configPath = path.join(installation.profile, 'ipc/sdk-ipc.json');
|
|
71
|
+
const control = (action, fields = {}) => require('../src/control.cjs').control(configPath, action, fields);
|
|
72
|
+
let state;
|
|
73
|
+
try { state = await control('status'); } catch (error) {
|
|
74
|
+
if (command === 'status') { print({ installed: true, running: false, enabled: null, error: error.message }); process.exitCode = 1; return; }
|
|
75
|
+
installation.start();
|
|
76
|
+
const deadline = Date.now() + 60000;
|
|
77
|
+
while (Date.now() < deadline) {
|
|
78
|
+
await delay(1000);
|
|
79
|
+
try { state = await control('status'); break; } catch { /* Wait for this installed runtime's native socket. */ }
|
|
80
|
+
}
|
|
81
|
+
if (!state) throw Error('Computer Use did not become ready. See runtime.log in its installation directory.');
|
|
82
|
+
}
|
|
83
|
+
if (command === 'status') { print(state); return; }
|
|
84
|
+
if (command === 'init') {
|
|
85
|
+
if (!state.email) {
|
|
86
|
+
await control('login');
|
|
87
|
+
process.stderr.write('Complete sign-in in your browser.\n');
|
|
88
|
+
const deadline = Date.now() + 5 * 60000;
|
|
89
|
+
while (Date.now() < deadline) {
|
|
90
|
+
await delay(1500); state = await control('status'); if (state.email) break;
|
|
91
|
+
}
|
|
92
|
+
if (!state.email) throw Error('Sign-in timed out. Run aglit init to resume.');
|
|
93
|
+
}
|
|
94
|
+
let name = values.name || state.name;
|
|
95
|
+
if (!name && process.stdin.isTTY) {
|
|
96
|
+
const prompt = createInterface({ input: process.stdin, output: process.stderr });
|
|
97
|
+
try { name = (await prompt.question(`Computer name [${os.hostname()}]: `)).trim() || os.hostname(); } finally { prompt.close(); }
|
|
98
|
+
}
|
|
99
|
+
if (!name) throw Error('Provide --name for unattended setup');
|
|
100
|
+
const detected = await control('providers');
|
|
101
|
+
const available = detected.providers.filter(provider => provider.authenticated).map(provider => provider.provider);
|
|
102
|
+
let provider = values.provider && values.provider !== 'auto' ? values.provider :
|
|
103
|
+
available.includes(detected.preferred) ? detected.preferred : available.length === 1 ? available[0] : null;
|
|
104
|
+
if (!provider && available.length && process.stdin.isTTY) {
|
|
105
|
+
const prompt = createInterface({ input: process.stdin, output: process.stderr });
|
|
106
|
+
try { provider = (await prompt.question(`Provider (${available.join(', ')}): `)).trim(); } finally { prompt.close(); }
|
|
107
|
+
}
|
|
108
|
+
if (!provider || !available.includes(provider)) throw Error('No provider selected with a usable login. Sign in to Codex or Claude on this computer, then rerun aglit init --provider codex|claude.');
|
|
109
|
+
await control('configure', { name, provider, ...(values.team ? { teamId: values.team } : {}) });
|
|
110
|
+
const permissions = await control('permissions');
|
|
111
|
+
print(permissions);
|
|
112
|
+
if (permissions.permissions?.supported && (permissions.permissions.screenRecording !== 'granted' || permissions.permissions.accessibility !== 'granted'))
|
|
113
|
+
throw Error('Grant Screen Recording and Accessibility in System Settings, then rerun aglit init. Anywhere Access has not been enabled.');
|
|
114
|
+
// An enable receipt is configuration, not proof that capture/input work.
|
|
115
|
+
await control('enable');
|
|
116
|
+
const connectionDeadline = Date.now() + 30000;
|
|
117
|
+
do {
|
|
118
|
+
state = await control('status');
|
|
119
|
+
if (state.connected) break;
|
|
120
|
+
await delay(1000);
|
|
121
|
+
} while (Date.now() < connectionDeadline);
|
|
122
|
+
print(state);
|
|
123
|
+
if (!state.connected) throw Error('Anywhere Access is enabled but its connection is not ready. Check aglit status before retrying.');
|
|
124
|
+
if (values.invite) print(await control('invite', { teamId: values.team, email: values.invite }));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (command === 'invite') { print(await control('invite', { teamId: values.team, email })); return; }
|
|
128
|
+
if (command === 'uninstall') {
|
|
129
|
+
const result = await control('uninstall');
|
|
130
|
+
if (!result?.revoked || !result?.quitting || !Number.isSafeInteger(result.pid) || result.pid <= 0) throw Error('Revocation was not confirmed; installation preserved');
|
|
131
|
+
await finishUninstall(result); return;
|
|
132
|
+
}
|
|
133
|
+
print(await control(command));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
main().then(() => process.exit(process.exitCode || 0), error => {
|
|
137
|
+
process.stderr.write(`aglit: ${error.message}\n`); process.exit(1);
|
|
138
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglit-ai/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Install and manage Aglit Computer Use",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"aglit": "bin/aglit.cjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"yauzl": "3.4.0"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/control.cjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const net = require('node:net');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
|
|
6
|
+
// One authenticated request per connection, with no automatic mutation replay.
|
|
7
|
+
// This is the existing native SDK handshake, not a new localhost HTTP API.
|
|
8
|
+
function control(configPath, action, fields = {}) {
|
|
9
|
+
const stat = fs.lstatSync(configPath);
|
|
10
|
+
if (!stat.isFile() || stat.isSymbolicLink() || fs.realpathSync(configPath) !== configPath || stat.nlink !== 1 || stat.size > 65536 ||
|
|
11
|
+
process.platform !== 'win32' && (stat.uid !== process.getuid() || stat.mode & 0o077)) throw Error('Private native control configuration required');
|
|
12
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
13
|
+
if (config.version !== 1 || typeof config.endpoint !== 'string' || typeof config.token !== 'string' ||
|
|
14
|
+
!config.token || config.token.length > 4096) throw Error('Native control configuration is incomplete');
|
|
15
|
+
if (process.platform === 'win32') {
|
|
16
|
+
if (!config.endpoint.startsWith('\\\\.\\pipe\\aglit-ai-sdk-')) throw Error('Unexpected native pipe');
|
|
17
|
+
} else {
|
|
18
|
+
const socket = fs.lstatSync(config.endpoint);
|
|
19
|
+
if (!socket.isSocket() || socket.uid !== process.getuid()) throw Error('Native socket owner differs');
|
|
20
|
+
}
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const id = crypto.randomUUID();
|
|
23
|
+
const socket = net.createConnection(config.endpoint);
|
|
24
|
+
let buffer = '', authenticated = false, settled = false;
|
|
25
|
+
const timeout = setTimeout(() => finish(Error('Native command timed out; its outcome may be uncertain. Check aglit status before retrying.')), action === 'permissions' ? 5 * 60000 : 30000);
|
|
26
|
+
const finish = (error, result) => {
|
|
27
|
+
if (settled) return;
|
|
28
|
+
settled = true; clearTimeout(timeout); socket.destroy();
|
|
29
|
+
if (error) reject(error); else resolve(result);
|
|
30
|
+
};
|
|
31
|
+
socket.setEncoding('utf8');
|
|
32
|
+
socket.once('connect', () => socket.write(JSON.stringify({ type: 'handshake', token: config.token }) + '\n'));
|
|
33
|
+
socket.on('error', error => finish(error));
|
|
34
|
+
socket.once('close', () => { if (!settled) finish(Error('Native connection closed before command acknowledgement')); });
|
|
35
|
+
socket.on('data', chunk => {
|
|
36
|
+
buffer += chunk;
|
|
37
|
+
if (Buffer.byteLength(buffer) > 256 * 1024) return finish(Error('Native response too large'));
|
|
38
|
+
let newline;
|
|
39
|
+
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
40
|
+
let response;
|
|
41
|
+
try { response = JSON.parse(buffer.slice(0, newline)); } catch { return finish(Error('Invalid native response')); }
|
|
42
|
+
buffer = buffer.slice(newline + 1);
|
|
43
|
+
if (!authenticated) {
|
|
44
|
+
if (response.type !== 'handshake' || response.ok !== true || response.version !== 1) return finish(Error('Native authentication rejected'));
|
|
45
|
+
authenticated = true;
|
|
46
|
+
socket.write(JSON.stringify({ id, request: { method: 'POST', path: '/computer-use/control', body: { action, ...fields } } }) + '\n');
|
|
47
|
+
} else {
|
|
48
|
+
if (response.id !== id) return finish(Error('Native response identity differs'));
|
|
49
|
+
return response.ok === true ? finish(null, response.result) : finish(Error(response.error || 'Native command rejected'));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
module.exports = { control };
|
package/src/extract.cjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { pipeline } = require('node:stream/promises');
|
|
5
|
+
|
|
6
|
+
// Extract into a fresh private directory. Defer contained relative symlinks until
|
|
7
|
+
// all regular files have been written, so archive links cannot redirect writes.
|
|
8
|
+
async function extract(archive, root) {
|
|
9
|
+
const yauzl = require('yauzl');
|
|
10
|
+
const zip = await new Promise((resolve, reject) => yauzl.open(archive, { lazyEntries: true, strictFileNames: true }, (error, value) => error ? reject(error) : resolve(value)));
|
|
11
|
+
const links = [];
|
|
12
|
+
let entries = 0, bytes = 0;
|
|
13
|
+
try {
|
|
14
|
+
await new Promise((resolve, reject) => {
|
|
15
|
+
zip.on('error', reject);
|
|
16
|
+
zip.on('end', resolve);
|
|
17
|
+
zip.on('entry', entry => {
|
|
18
|
+
void (async () => {
|
|
19
|
+
const name = entry.fileName;
|
|
20
|
+
if (++entries > 200000 || name.length > 4096 || /[\x00-\x1f\\]/.test(name) || path.isAbsolute(name) || /^[A-Za-z]:/.test(name) || name.split('/').includes('..')) throw Error('Unsafe archive entry');
|
|
21
|
+
const destination = path.resolve(root, name);
|
|
22
|
+
if (!destination.startsWith(root + path.sep)) throw Error('Archive entry escapes installation');
|
|
23
|
+
bytes += entry.uncompressedSize;
|
|
24
|
+
if (!Number.isSafeInteger(bytes) || bytes > 16 * 1024 ** 3) throw Error('Expanded release exceeds installation limit');
|
|
25
|
+
const mode = entry.externalFileAttributes >>> 16;
|
|
26
|
+
if (name.endsWith('/')) { fs.mkdirSync(destination, { recursive: true, mode: 0o755 }); return; }
|
|
27
|
+
const stream = await new Promise((done, fail) => zip.openReadStream(entry, (error, value) => error ? fail(error) : done(value)));
|
|
28
|
+
if ((mode & 0o170000) === 0o120000) {
|
|
29
|
+
if (entry.uncompressedSize > 4096) throw Error('Archive link too large');
|
|
30
|
+
const chunks = [];
|
|
31
|
+
for await (const chunk of stream) chunks.push(chunk);
|
|
32
|
+
const target = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
|
|
33
|
+
const resolved = path.resolve(path.dirname(destination), target);
|
|
34
|
+
if (!target || /[\x00-\x1f\\]/.test(target) || path.isAbsolute(target) || /^[A-Za-z]:/.test(target) || !resolved.startsWith(root + path.sep)) throw Error('Archive link escapes installation');
|
|
35
|
+
links.push({ destination, target });
|
|
36
|
+
} else {
|
|
37
|
+
if ((mode & 0o170000) && (mode & 0o170000) !== 0o100000) throw Error('Unsupported archive entry');
|
|
38
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o755 });
|
|
39
|
+
await pipeline(stream, fs.createWriteStream(destination, { flags: 'wx', mode: mode & 0o777 || 0o644 }));
|
|
40
|
+
}
|
|
41
|
+
})().then(() => zip.readEntry(), reject);
|
|
42
|
+
});
|
|
43
|
+
zip.readEntry();
|
|
44
|
+
});
|
|
45
|
+
for (const link of links) fs.mkdirSync(path.dirname(link.destination), { recursive: true, mode: 0o755 });
|
|
46
|
+
for (const link of links) fs.symlinkSync(link.target, link.destination);
|
|
47
|
+
for (const link of links) {
|
|
48
|
+
if (!fs.realpathSync(link.destination).startsWith(root + path.sep)) throw Error('Resolved archive link escapes installation');
|
|
49
|
+
}
|
|
50
|
+
} finally { zip.close(); }
|
|
51
|
+
}
|
|
52
|
+
module.exports = { extract };
|
|
@@ -0,0 +1,214 @@
|
|
|
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 { spawn, execFileSync } = require('node:child_process');
|
|
7
|
+
const { pipeline } = require('node:stream/promises');
|
|
8
|
+
const { Readable, Transform } = require('node:stream');
|
|
9
|
+
|
|
10
|
+
const root = path.join(process.platform === 'darwin' ? path.join(os.homedir(), 'Library/Application Support') :
|
|
11
|
+
process.platform === 'win32' ? process.env.APPDATA || path.join(os.homedir(), 'AppData/Roaming') : path.join(os.homedir(), '.config'), 'Aglit Computer Use');
|
|
12
|
+
const profile = path.join(root, 'profile');
|
|
13
|
+
const receiptPath = path.join(root, 'installation.json');
|
|
14
|
+
const same = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
15
|
+
function canonical(file) {
|
|
16
|
+
if (!path.isAbsolute(file) || !same(fs.realpathSync(file), file) || fs.lstatSync(file).isSymbolicLink()) throw Error('Canonical installation path required');
|
|
17
|
+
}
|
|
18
|
+
function run(program, args, options = {}) {
|
|
19
|
+
return execFileSync(program, args, { windowsHide: true, timeout: 60000, maxBuffer: 1024 * 1024, encoding: 'utf8', ...options });
|
|
20
|
+
}
|
|
21
|
+
const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
|
|
22
|
+
function privateRoot(create = false) {
|
|
23
|
+
canonical(root);
|
|
24
|
+
if (process.platform !== 'win32') {
|
|
25
|
+
const stat = fs.statSync(root);
|
|
26
|
+
if (stat.uid !== process.getuid() || stat.mode & 0o077) throw Error('Private installation owner required');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const script = String.raw`
|
|
30
|
+
$ErrorActionPreference='Stop'
|
|
31
|
+
$target=$env:AGLIT_PRIVATE_ROOT;$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
32
|
+
$system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18')
|
|
33
|
+
if($env:AGLIT_CREATE_PRIVATE_ROOT -eq '1'){
|
|
34
|
+
$acl=[Security.AccessControl.DirectorySecurity]::new();$acl.SetOwner($sid);$acl.SetAccessRuleProtection($true,$false)
|
|
35
|
+
foreach($identity in @($sid,$system)){$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($identity,'FullControl','ContainerInherit,ObjectInherit','None','Allow'))}
|
|
36
|
+
Set-Acl -LiteralPath $target -AclObject $acl
|
|
37
|
+
}
|
|
38
|
+
$acl=Get-Acl -LiteralPath $target
|
|
39
|
+
if(!$acl.AreAccessRulesProtected -or $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $sid.Value){throw 'Private installation owner required'}
|
|
40
|
+
foreach($rule in $acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])){
|
|
41
|
+
if($rule.AccessControlType -ne 'Allow' -or $rule.IdentityReference.Value -notin @($sid.Value,$system.Value)){throw 'Private installation ACL required'}
|
|
42
|
+
}
|
|
43
|
+
`;
|
|
44
|
+
run(powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', script], {
|
|
45
|
+
env: { ...process.env, AGLIT_PRIVATE_ROOT: root, AGLIT_CREATE_PRIVATE_ROOT: create ? '1' : '0' },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function readInstallation(allowPartialCleanup = false) {
|
|
49
|
+
privateRoot();
|
|
50
|
+
canonical(receiptPath);
|
|
51
|
+
if (fs.statSync(receiptPath).size > 65536) throw Error('Installation receipt too large');
|
|
52
|
+
const value = JSON.parse(fs.readFileSync(receiptPath, 'utf8'));
|
|
53
|
+
if (value.kind !== 'aglit-computer-use-installation-v1' || value.root !== root ||
|
|
54
|
+
value.profile !== profile || typeof value.executable !== 'string' ||
|
|
55
|
+
!value.executable.startsWith(root + path.sep) || !/^[a-f0-9]{64}$/.test(value.sha256)) throw Error('Installation receipt differs');
|
|
56
|
+
if (!allowPartialCleanup) { canonical(value.executable); canonical(value.profile); }
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
async function download(url) {
|
|
60
|
+
for (let hop = 0; hop < 6; hop++) {
|
|
61
|
+
const target = new URL(url);
|
|
62
|
+
if (target.protocol !== 'https:' || !['github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com'].includes(target.hostname)) throw Error('Untrusted release URL');
|
|
63
|
+
const response = await fetch(target, { redirect: 'manual', signal: AbortSignal.timeout(30 * 60 * 1000) });
|
|
64
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
65
|
+
await response.body?.cancel();
|
|
66
|
+
url = new URL(response.headers.get('location'), target).href;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (!response.ok || !response.body) throw Error(`Release download failed (${response.status})`);
|
|
70
|
+
return response;
|
|
71
|
+
}
|
|
72
|
+
throw Error('Too many release redirects');
|
|
73
|
+
}
|
|
74
|
+
async function json(url) {
|
|
75
|
+
const response = await download(url);
|
|
76
|
+
let size = 0;
|
|
77
|
+
const chunks = [];
|
|
78
|
+
for await (const chunk of response.body) {
|
|
79
|
+
size += chunk.length;
|
|
80
|
+
if (size > 65536) throw Error('Release metadata too large');
|
|
81
|
+
chunks.push(chunk);
|
|
82
|
+
}
|
|
83
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
84
|
+
}
|
|
85
|
+
function parseRelease(value, platform) {
|
|
86
|
+
const fields = ['kind', 'package', 'platform', 'version', 'sourceCommit', 'assets', 'windowsSigningPolicy'];
|
|
87
|
+
if (!value || Object.keys(value).some(key => !fields.includes(key)) || value.kind !== 'aglit-github-release-v1' ||
|
|
88
|
+
value.package !== 'desktop-app' || value.platform !== platform || !/^\d+\.\d+\.\d+$/.test(value.version) ||
|
|
89
|
+
!/^[a-f0-9]{40}$/.test(value.sourceCommit) || !Array.isArray(value.assets) || value.assets.length !== 3 ||
|
|
90
|
+
value.windowsSigningPolicy !== undefined && (platform !== 'win' || value.windowsSigningPolicy !== 'unsigned-user-accepted')) throw Error('Invalid desktop release receipt');
|
|
91
|
+
const stem = `Aglit.AI-${value.version}-${platform === 'mac' ? 'mac-arm64' : 'win-x64'}`;
|
|
92
|
+
const names = platform === 'mac' ? [stem + '.dmg', stem + '.zip', stem + '.zip.blockmap'] : [stem + '.exe', stem + '.exe.blockmap', stem + '.zip'];
|
|
93
|
+
for (let index = 0; index < 3; index++) {
|
|
94
|
+
const asset = value.assets[index];
|
|
95
|
+
if (!asset || Object.keys(asset).sort().join(',') !== 'name,sha256,sha512,size' || asset.name !== names[index] ||
|
|
96
|
+
!Number.isSafeInteger(asset.size) || asset.size <= 0 || asset.size > 2 * 1024 ** 3 ||
|
|
97
|
+
!/^[a-f0-9]{64}$/.test(asset.sha256) || !/^[A-Za-z0-9+/]{86}==$/.test(asset.sha512)) throw Error('Invalid release asset');
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
function verifyPublisher(executable) {
|
|
102
|
+
if (process.platform === 'darwin') {
|
|
103
|
+
const bundle = path.resolve(executable, '../../..');
|
|
104
|
+
run('/usr/bin/codesign', ['--verify', '--deep', '--strict', '--test-requirement', '=anchor apple generic and identifier "ai.aglit.desktop" and certificate leaf[subject.OU] = "8DVJD3J343"', bundle]);
|
|
105
|
+
run('/usr/sbin/spctl', ['--assess', '--type', 'execute', bundle]);
|
|
106
|
+
} else {
|
|
107
|
+
const script = '$s=Get-AuthenticodeSignature -LiteralPath $env:AGLIT_VERIFY_EXECUTABLE; if($s.Status -ne "Valid" -or $s.SignerCertificate.Subject -notmatch "(?:^|,\\s*)CN=Aglit AI(?:,|$)"){throw "Aglit publisher signature required"}';
|
|
108
|
+
run(powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', script], { env: { ...process.env, AGLIT_VERIFY_EXECUTABLE: executable } });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function install() {
|
|
112
|
+
if (fs.existsSync(path.join(root, 'uninstalling.json'))) throw Error('Finish aglit uninstall before reinstalling');
|
|
113
|
+
if (!(process.platform === 'darwin' && process.arch === 'arm64') && !(process.platform === 'win32' && process.arch === 'x64')) throw Error('Published Computer Use releases currently support Apple silicon Macs and Windows x64');
|
|
114
|
+
if (fs.existsSync(receiptPath)) return { installed: true, ...readInstallation() };
|
|
115
|
+
if (fs.existsSync(root)) throw Error(`Existing unowned installation directory preserved: ${root}`);
|
|
116
|
+
const platform = process.platform === 'darwin' ? 'mac' : 'win';
|
|
117
|
+
const base = 'https://github.com/Aglit-AI/desktop-app-release/releases/download/';
|
|
118
|
+
const desired = parseRelease(await json(base + `updates-${platform}/github-release-${platform}.json`), platform);
|
|
119
|
+
const release = parseRelease(await json(base + `v${desired.version}/github-release-${platform}.json`), platform);
|
|
120
|
+
if (JSON.stringify(desired) !== JSON.stringify(release)) throw Error('Release pointer differs from immutable release');
|
|
121
|
+
// The product's unsigned Windows policy is not silently inherited by the CLI.
|
|
122
|
+
if (release.windowsSigningPolicy) throw Error('This Windows release is unsigned; Computer Use installation requires a signed release');
|
|
123
|
+
canonical(path.dirname(root));
|
|
124
|
+
fs.mkdirSync(root, { mode: 0o700 });
|
|
125
|
+
privateRoot(true);
|
|
126
|
+
fs.writeFileSync(path.join(root, 'installation-attempt.json'), JSON.stringify({ kind: 'aglit-computer-use-attempt-v1', root }) + '\n', { flag: 'wx', mode: 0o600 });
|
|
127
|
+
const asset = release.assets.find(item => item.name.endsWith('.zip'));
|
|
128
|
+
const archive = path.join(root, 'runtime.zip');
|
|
129
|
+
let received = 0;
|
|
130
|
+
const sha256 = crypto.createHash('sha256'), sha512 = crypto.createHash('sha512');
|
|
131
|
+
const meter = new Transform({ transform(chunk, _encoding, done) {
|
|
132
|
+
received += chunk.length;
|
|
133
|
+
if (received > asset.size) return done(Error('Release exceeds declared size'));
|
|
134
|
+
sha256.update(chunk); sha512.update(chunk); done(null, chunk);
|
|
135
|
+
} });
|
|
136
|
+
const response = await download(base + `v${release.version}/${asset.name}`);
|
|
137
|
+
await pipeline(Readable.fromWeb(response.body), meter, fs.createWriteStream(archive, { flags: 'wx', mode: 0o600 }));
|
|
138
|
+
if (received !== asset.size || sha256.digest('hex') !== asset.sha256 || sha512.digest('base64') !== asset.sha512) throw Error('Release checksum differs; archive was not executed');
|
|
139
|
+
const runtime = path.join(root, 'runtime');
|
|
140
|
+
fs.mkdirSync(runtime, { mode: 0o700 });
|
|
141
|
+
await require('./extract.cjs').extract(archive, runtime);
|
|
142
|
+
const executable = process.platform === 'darwin' ? path.join(runtime, 'Aglit AI.app/Contents/MacOS/Aglit AI') : path.join(runtime, 'Aglit AI.exe');
|
|
143
|
+
canonical(executable);
|
|
144
|
+
verifyPublisher(executable);
|
|
145
|
+
const resources = process.platform === 'darwin' ? path.join(runtime, 'Aglit AI.app/Contents/Resources') : path.join(runtime, 'resources');
|
|
146
|
+
const buildInfoPath = path.join(resources, 'app.asar.unpacked/dist-electron/desktop-app/build-info.json');
|
|
147
|
+
canonical(buildInfoPath);
|
|
148
|
+
if (fs.statSync(buildInfoPath).size > 16 * 1024 * 1024) throw Error('Build metadata too large');
|
|
149
|
+
const buildInfo = JSON.parse(fs.readFileSync(buildInfoPath, 'utf8'));
|
|
150
|
+
if (buildInfo.gitCommit !== release.sourceCommit || buildInfo.dirty !== false ||
|
|
151
|
+
buildInfo.runtimeMinified !== true || buildInfo.computerUseControlVersion !== 1)
|
|
152
|
+
throw Error('A published obfuscated desktop release with Computer Use CLI support is required; this archive was not launched');
|
|
153
|
+
fs.mkdirSync(profile, { mode: 0o700 });
|
|
154
|
+
fs.writeFileSync(path.join(profile, 'system-settings.json'), JSON.stringify({ settings: { enableAnywhereAccess: false, remoteAccessDeviceId: crypto.randomUUID() } }) + '\n', { flag: 'wx', mode: 0o600 });
|
|
155
|
+
const receipt = { kind: 'aglit-computer-use-installation-v1', root, profile, executable, version: release.version, sourceCommit: release.sourceCommit, sha256: asset.sha256 };
|
|
156
|
+
fs.writeFileSync(receiptPath, JSON.stringify(receipt) + '\n', { flag: 'wx', mode: 0o600 });
|
|
157
|
+
fs.unlinkSync(archive);
|
|
158
|
+
return { installed: true, ...receipt };
|
|
159
|
+
}
|
|
160
|
+
function start() {
|
|
161
|
+
if (fs.existsSync(path.join(root, 'uninstalling.json'))) throw Error('Computer Use is uninstalling. Run aglit uninstall to finish cleanup.');
|
|
162
|
+
if (process.platform === 'win32') run(powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command',
|
|
163
|
+
'$p=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()); if($p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw "Run aglit init from a non-elevated interactive user terminal"}']);
|
|
164
|
+
const installed = readInstallation();
|
|
165
|
+
verifyPublisher(installed.executable);
|
|
166
|
+
const log = fs.openSync(path.join(root, 'runtime.log'), 'a', 0o600);
|
|
167
|
+
const env = { ...process.env };
|
|
168
|
+
// Do not let an operator's guest/development environment adopt another identity.
|
|
169
|
+
for (const key of Object.keys(env)) if (key.startsWith('AGLIT_') || key.startsWith('VITE_') || ['NODE_ENV', 'ELECTRON_RUN_AS_NODE', 'SERVER_PORT', 'ELECTRON_BRIDGE_PORT'].includes(key)) delete env[key];
|
|
170
|
+
Object.assign(env, { AGLIT_USER_DATA_PATH: profile, AGLIT_ELECTRON_USER_DATA_PATH: profile });
|
|
171
|
+
const child = spawn(installed.executable, ['--computer-use'], { detached: true, windowsHide: true, stdio: ['ignore', log, log], env });
|
|
172
|
+
child.on('error', error => { process.stderr.write(`Computer Use failed to start: ${error.message}\n`); });
|
|
173
|
+
child.unref(); fs.closeSync(log);
|
|
174
|
+
}
|
|
175
|
+
function purge() {
|
|
176
|
+
readInstallation(true);
|
|
177
|
+
// Exact fixed per-user root only. Node removes contained symlinks themselves;
|
|
178
|
+
// it does not recursively follow them into Mini Computer or browser profiles.
|
|
179
|
+
if (path.resolve(root) !== root || path.basename(root) !== 'Aglit Computer Use') throw Error('Purge target differs');
|
|
180
|
+
canonical(root);
|
|
181
|
+
// Preserve recovery receipts until all potentially locked native payloads have
|
|
182
|
+
// been removed. A failed Windows deletion can then resume the same uninstall.
|
|
183
|
+
for (const name of fs.readdirSync(root)) {
|
|
184
|
+
if (name === 'installation.json' || name === 'uninstalling.json') continue;
|
|
185
|
+
const target = path.join(root, name);
|
|
186
|
+
if (path.dirname(target) !== root) throw Error('Cleanup target escapes installation');
|
|
187
|
+
fs.rmSync(target, { recursive: true, force: false, maxRetries: 3, retryDelay: 300 });
|
|
188
|
+
}
|
|
189
|
+
if (fs.existsSync(path.join(root, 'uninstalling.json'))) fs.unlinkSync(path.join(root, 'uninstalling.json'));
|
|
190
|
+
fs.unlinkSync(receiptPath);
|
|
191
|
+
fs.rmdirSync(root);
|
|
192
|
+
if (fs.existsSync(root)) throw Error('Computer Use cleanup is incomplete');
|
|
193
|
+
}
|
|
194
|
+
function purgeIncomplete() {
|
|
195
|
+
if (fs.existsSync(receiptPath)) throw Error('Installed Computer Use must revoke access before uninstall');
|
|
196
|
+
privateRoot();
|
|
197
|
+
const attempt = path.join(root, 'installation-attempt.json');
|
|
198
|
+
canonical(attempt);
|
|
199
|
+
if (fs.statSync(attempt).size > 4096) throw Error('Invalid installation attempt');
|
|
200
|
+
const receipt = JSON.parse(fs.readFileSync(attempt, 'utf8'));
|
|
201
|
+
if (receipt.kind !== 'aglit-computer-use-attempt-v1' || receipt.root !== root) throw Error('Unknown installation directory preserved');
|
|
202
|
+
fs.rmSync(root, { recursive: true, force: false });
|
|
203
|
+
}
|
|
204
|
+
function readUninstall() {
|
|
205
|
+
const file = path.join(root, 'uninstalling.json');
|
|
206
|
+
if (!fs.existsSync(file)) return null;
|
|
207
|
+
readInstallation(true); canonical(file);
|
|
208
|
+
if (fs.statSync(file).size > 4096) throw Error('Invalid uninstall receipt');
|
|
209
|
+
const receipt = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
210
|
+
if (receipt.kind !== 'aglit-computer-use-uninstall-v1' || receipt.root !== root || receipt.revoked !== true ||
|
|
211
|
+
!Number.isSafeInteger(receipt.pid) || receipt.pid <= 0) throw Error('Invalid uninstall receipt');
|
|
212
|
+
return receipt;
|
|
213
|
+
}
|
|
214
|
+
module.exports = { root, profile, readInstallation, install, start, purge, purgeIncomplete, readUninstall };
|