@adrouter/agent 0.1.0-beta.3 → 0.1.0-beta.5

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 CHANGED
@@ -1,9 +1,8 @@
1
1
  # `@adrouter/agent`
2
2
 
3
- This dependency-free package installs and launches the credential-free,
4
- ad-hoc-signed universal macOS application from the matching
5
- [AdRouter Agent GitHub release](https://github.com/adrouter/adrouterAgent/releases).
6
- The desktop application is not duplicated in the npm tarball.
3
+ This dependency-free package installs and launches the matching AdRouter Agent
4
+ portable application from the canonical GitHub release. The desktop binaries
5
+ are not duplicated in the npm tarball.
7
6
 
8
7
  ```bash
9
8
  npm install --global @adrouter/agent@beta
@@ -11,25 +10,25 @@ adrouter-agent doctor --json
11
10
  adrouter-agent
12
11
  ```
13
12
 
14
- The launcher supports macOS 12 or newer, Apple Silicon and Intel, and Node.js
15
- 22.19.0 or newer. It accepts no alternate download URL or checksum. Downloads
16
- are bounded, checked against the release manifest embedded in this package,
17
- and installed as the real application at `~/Applications/AdRouter Agent.app`
18
- after archive, bundle identity, universal architecture, and ad-hoc `codesign`
19
- integrity checks.
13
+ Supported targets are macOS 12+ arm64/x64, Ubuntu Desktop 24.04 LTS x64, and
14
+ Windows 11 x64 with Node.js 22.19 or newer. The launcher accepts no alternate
15
+ download URL or checksum. It validates the embedded schema-3 manifest, bounded
16
+ download, SHA-256 digest, archive paths, executable, managed receipt, and
17
+ staged-update rollback before activation.
20
18
 
21
- This beta is not Developer ID signed or notarized. If macOS blocks the first
22
- launch, open **System Settings Privacy & Security** and choose **Open Anyway**.
23
- The launcher never removes quarantine metadata or changes Gatekeeper settings.
19
+ macOS is ad-hoc signed but not notarized. Linux and Windows portable beta
20
+ artifacts are unsigned. Run `adrouter-agent doctor --json` for the platform
21
+ install path, artifact key, verification result, sandbox readiness, and static
22
+ setup guidance. The launcher never disables host security or auto-elevates.
24
23
 
25
24
  Commands:
26
25
 
27
26
  - `adrouter-agent` or `adrouter-agent launch`: install if needed, then launch.
28
27
  - `adrouter-agent install`: install and verify without launching.
29
- - `adrouter-agent doctor --json`: report platform, installation receipt,
30
- bundle integrity, ad-hoc signature, and diagnostic Gatekeeper status.
28
+ - `adrouter-agent doctor --json`: report installation, integrity, and sandbox
29
+ status without credentials.
31
30
  - `adrouter-agent --version`: print the package/release version without a
32
31
  network request.
33
32
 
34
- See the [repository security policy](https://github.com/adrouter/adrouterAgent/blob/main/SECURITY.md)
35
- before reporting a vulnerability.
33
+ See the repository's platform-setup guide for Ubuntu dependencies, Windows
34
+ one-time sandbox provisioning, and staging bearer-token onboarding.
package/lib/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { inspectInstallation, install, launch } from './installer.mjs';
2
- import { readManifest } from './manifest.mjs';
2
+ import { readManifest, selectArtifact } from './manifest.mjs';
3
3
 
4
4
  const USAGE = `Usage: adrouter-agent [launch|install|doctor [--json]|--version]
5
5
 
@@ -46,5 +46,5 @@ export async function runCli(args, io = process) {
46
46
  const appPath = await install(manifest);
47
47
  const report = await inspectInstallation(manifest);
48
48
  if (report.warning) io.stderr.write(`Warning: ${report.warning}\n`);
49
- await launch(appPath);
49
+ await launch(appPath, { artifact: selectArtifact(manifest) });
50
50
  }
package/lib/installer.mjs CHANGED
@@ -2,6 +2,7 @@ import { execFile, spawn } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
3
  import {
4
4
  access,
5
+ constants as fsConstants,
5
6
  lstat,
6
7
  mkdir,
7
8
  mkdtemp,
@@ -16,15 +17,17 @@ import {
16
17
  import { homedir } from 'node:os';
17
18
  import { basename, join, posix, relative, sep } from 'node:path';
18
19
  import { promisify } from 'node:util';
20
+ import { selectArtifact } from './manifest.mjs';
19
21
 
20
22
  const execFileAsync = promisify(execFile);
21
- const APP_NAME = 'AdRouter Agent.app';
22
- const EXECUTABLE_NAME = 'AdRouter Agent';
23
23
  const RECEIPT_OWNER = '@adrouter/agent';
24
24
  const MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024;
25
25
  const MAX_REDIRECTS = 5;
26
- const GATEKEEPER_WARNING =
26
+ const WINDOWS_SANDBOX_SETUP = 'npx @anthropic-ai/sandbox-runtime@0.0.65 windows-install';
27
+ const MAC_WARNING =
27
28
  'This credential-free beta is not Developer ID signed or notarized. If macOS blocks it, open System Settings > Privacy & Security and choose Open Anyway.';
29
+ const PORTABLE_WARNING =
30
+ 'This portable beta is unsigned. Install only from the canonical release and retain checksum verification.';
28
31
  const ALLOWED_HOSTS = new Set([
29
32
  'github.com',
30
33
  'objects.githubusercontent.com',
@@ -32,12 +35,13 @@ const ALLOWED_HOSTS = new Set([
32
35
  ]);
33
36
 
34
37
  export function assertSupportedPlatform(platform = process.platform, arch = process.arch) {
35
- if (platform !== 'darwin') {
36
- throw new Error(`Unsupported platform ${platform}; AdRouter Agent requires macOS 12 or newer.`);
37
- }
38
- if (arch !== 'arm64' && arch !== 'x64') {
39
- throw new Error(`Unsupported macOS architecture ${arch}; expected arm64 or x64.`);
40
- }
38
+ selectArtifact(
39
+ {
40
+ artifacts: [{ key: 'darwin-universal' }, { key: 'linux-x64' }, { key: 'win32-x64' }],
41
+ },
42
+ platform,
43
+ arch
44
+ );
41
45
  }
42
46
 
43
47
  export function assertSupportedMacOsVersion(version) {
@@ -49,7 +53,9 @@ export function assertSupportedMacOsVersion(version) {
49
53
 
50
54
  export function assertNonRoot(uid = process.getuid?.()) {
51
55
  if (uid === 0) {
52
- throw new Error('Do not run adrouter-agent with sudo; install it as the logged-in macOS user.');
56
+ throw new Error(
57
+ 'Do not run adrouter-agent with sudo or as root; install it as the logged-in user.'
58
+ );
53
59
  }
54
60
  }
55
61
 
@@ -64,7 +70,7 @@ export function assertAllowedDownloadUrl(input) {
64
70
  return url;
65
71
  }
66
72
 
67
- export function assertSafeArchiveEntries(entries) {
73
+ export function assertSafeArchiveEntries(entries, archiveRoot = 'AdRouter Agent.app') {
68
74
  if (!Array.isArray(entries) || entries.length === 0) {
69
75
  throw new Error('The release ZIP is empty.');
70
76
  }
@@ -78,14 +84,15 @@ export function assertSafeArchiveEntries(entries) {
78
84
  ) {
79
85
  throw new Error(`Unsafe ZIP entry: ${JSON.stringify(entry)}`);
80
86
  }
81
- if (entry !== APP_NAME && !entry.startsWith(`${APP_NAME}/`)) {
87
+ const normalized = entry.replaceAll('\\', '/').replace(/\/$/, '');
88
+ if (normalized !== archiveRoot && !normalized.startsWith(`${archiveRoot}/`)) {
82
89
  throw new Error(`Unexpected ZIP layout entry: ${entry}`);
83
90
  }
84
91
  }
85
92
  }
86
93
 
87
- export function assertSafeArchiveSymlink(entry, target) {
88
- assertSafeArchiveEntries([entry]);
94
+ export function assertSafeArchiveSymlink(entry, target, archiveRoot = 'AdRouter Agent.app') {
95
+ assertSafeArchiveEntries([entry], archiveRoot);
89
96
  if (
90
97
  typeof target !== 'string' ||
91
98
  target.length === 0 ||
@@ -97,24 +104,53 @@ export function assertSafeArchiveSymlink(entry, target) {
97
104
  throw new Error(`Unsafe ZIP symbolic link target: ${JSON.stringify(target)}`);
98
105
  }
99
106
  const resolved = posix.resolve('/', posix.dirname(entry), target);
100
- const appRoot = `/${APP_NAME}`;
101
- if (resolved !== appRoot && !resolved.startsWith(`${appRoot}/`)) {
102
- throw new Error(`ZIP symbolic link escapes ${APP_NAME}: ${entry} -> ${target}`);
107
+ const root = `/${archiveRoot}`;
108
+ if (resolved !== root && !resolved.startsWith(`${root}/`)) {
109
+ throw new Error(`ZIP symbolic link escapes ${archiveRoot}: ${entry} -> ${target}`);
103
110
  }
104
111
  }
105
112
 
106
- export function releasePaths(_manifest, homeDirectory = homedir()) {
107
- const applicationsDirectory = join(homeDirectory, 'Applications');
108
- const supportDirectory = join(
109
- homeDirectory,
110
- 'Library',
111
- 'Application Support',
112
- 'adrouter-agent-launcher'
113
- );
113
+ export function releasePaths(
114
+ _manifest,
115
+ homeDirectory = homedir(),
116
+ platform = process.platform,
117
+ options = {}
118
+ ) {
119
+ if (platform === 'darwin') {
120
+ const applicationsDirectory = join(homeDirectory, 'Applications');
121
+ const supportDirectory = join(
122
+ homeDirectory,
123
+ 'Library',
124
+ 'Application Support',
125
+ 'adrouter-agent-launcher'
126
+ );
127
+ return {
128
+ applicationsDirectory,
129
+ supportDirectory,
130
+ appPath: join(applicationsDirectory, 'AdRouter Agent.app'),
131
+ receiptPath: join(supportDirectory, 'receipt.json'),
132
+ };
133
+ }
134
+ if (platform === 'linux') {
135
+ const dataDirectory =
136
+ options.xdgDataHome ?? process.env.XDG_DATA_HOME ?? join(homeDirectory, '.local', 'share');
137
+ const applicationsDirectory = join(dataDirectory, 'adrouter-agent');
138
+ const supportDirectory = join(dataDirectory, 'adrouter-agent-launcher');
139
+ return {
140
+ applicationsDirectory,
141
+ supportDirectory,
142
+ appPath: join(applicationsDirectory, 'app'),
143
+ receiptPath: join(supportDirectory, 'receipt.json'),
144
+ };
145
+ }
146
+ const localAppData =
147
+ options.localAppData ?? process.env.LOCALAPPDATA ?? join(homeDirectory, 'AppData', 'Local');
148
+ const applicationsDirectory = join(localAppData, 'Programs');
149
+ const supportDirectory = join(localAppData, 'adrouter-agent-launcher');
114
150
  return {
115
151
  applicationsDirectory,
116
152
  supportDirectory,
117
- appPath: join(applicationsDirectory, APP_NAME),
153
+ appPath: join(applicationsDirectory, 'AdRouter Agent'),
118
154
  receiptPath: join(supportDirectory, 'receipt.json'),
119
155
  };
120
156
  }
@@ -142,7 +178,7 @@ async function plistValue(appPath, key, executeImpl) {
142
178
  return stdout.trim();
143
179
  }
144
180
 
145
- async function verifyApp(appPath, manifest, executeImpl = execute) {
181
+ async function verifyMacApp(appPath, manifest, executeImpl) {
146
182
  await executeImpl('/usr/bin/codesign', ['--verify', '--deep', '--strict', appPath]);
147
183
  const signatureResult = await executeImpl('/usr/bin/codesign', ['-dv', '--verbose=4', appPath]);
148
184
  const signatureDetails = `${signatureResult.stdout ?? ''}${signatureResult.stderr ?? ''}`;
@@ -152,27 +188,44 @@ async function verifyApp(appPath, manifest, executeImpl = execute) {
152
188
  ) {
153
189
  throw new Error('The application is not the expected credential-free ad-hoc build.');
154
190
  }
155
-
156
- const bundleIdentifier = await plistValue(appPath, 'CFBundleIdentifier', executeImpl);
157
- const shortVersion = await plistValue(appPath, 'CFBundleShortVersionString', executeImpl);
158
- const buildVersion = await plistValue(appPath, 'CFBundleVersion', executeImpl);
159
- if (bundleIdentifier !== manifest.bundleIdentifier) {
191
+ if (
192
+ (await plistValue(appPath, 'CFBundleIdentifier', executeImpl)) !== manifest.bundleIdentifier
193
+ ) {
160
194
  throw new Error('The installed app bundle identifier does not match the release manifest.');
161
195
  }
162
- if (shortVersion !== manifest.bundleShortVersion) {
196
+ if (
197
+ (await plistValue(appPath, 'CFBundleShortVersionString', executeImpl)) !==
198
+ manifest.bundleShortVersion
199
+ ) {
163
200
  throw new Error('The installed app short bundle version does not match the release manifest.');
164
201
  }
165
- if (buildVersion !== manifest.bundleVersion) {
202
+ if ((await plistValue(appPath, 'CFBundleVersion', executeImpl)) !== manifest.bundleVersion) {
166
203
  throw new Error('The installed app build version does not match the release manifest.');
167
204
  }
168
-
169
- const executable = join(appPath, 'Contents', 'MacOS', EXECUTABLE_NAME);
205
+ const executable = join(appPath, 'Contents', 'MacOS', 'AdRouter Agent');
170
206
  const { stdout: architectures } = await executeImpl('/usr/bin/lipo', ['-archs', executable]);
171
207
  if (!architectures.includes('arm64') || !architectures.includes('x86_64')) {
172
208
  throw new Error('The installed app is not a universal arm64+x86_64 build.');
173
209
  }
174
210
  }
175
211
 
212
+ async function verifyPortableApp(appPath, artifact) {
213
+ const executable = join(appPath, ...artifact.executablePath.split('/'));
214
+ const stat = await lstat(executable);
215
+ if (!stat.isFile() || stat.isSymbolicLink()) {
216
+ throw new Error('The portable application executable is missing or unsafe.');
217
+ }
218
+ if (artifact.platform === 'linux') await access(executable, fsConstants.X_OK);
219
+ }
220
+
221
+ async function verifyApp(appPath, manifest, artifact, executeImpl = execute) {
222
+ if (artifact.verificationMode === 'macos-adhoc') {
223
+ await verifyMacApp(appPath, manifest, executeImpl);
224
+ } else {
225
+ await verifyPortableApp(appPath, artifact);
226
+ }
227
+ }
228
+
176
229
  async function assessGatekeeper(appPath, executeImpl = execute) {
177
230
  try {
178
231
  await executeImpl('/usr/sbin/spctl', ['--assess', '--type', 'execute', appPath]);
@@ -183,13 +236,13 @@ async function assessGatekeeper(appPath, executeImpl = execute) {
183
236
  }
184
237
  }
185
238
 
186
- async function verifyExtractedTree(root, appPath) {
239
+ async function verifyExtractedTree(root, appPath, archiveRoot) {
187
240
  const canonicalRoot = `${await realpath(root)}${sep}`;
188
241
  async function visit(path) {
189
242
  const stat = await lstat(path);
190
243
  if (stat.isSymbolicLink()) {
191
244
  const entry = relative(root, path).split(sep).join('/');
192
- assertSafeArchiveSymlink(entry, await readlink(path));
245
+ assertSafeArchiveSymlink(entry, await readlink(path), archiveRoot);
193
246
  }
194
247
  const canonical = await realpath(path);
195
248
  if (canonical !== canonicalRoot.slice(0, -1) && !canonical.startsWith(canonicalRoot)) {
@@ -199,14 +252,14 @@ async function verifyExtractedTree(root, appPath) {
199
252
  for (const entry of await readdir(path)) await visit(join(path, entry));
200
253
  }
201
254
  const topLevel = await readdir(root);
202
- if (topLevel.length !== 1 || topLevel[0] !== APP_NAME) {
203
- throw new Error('Release archive must contain exactly AdRouter Agent.app.');
255
+ if (topLevel.length !== 1 || topLevel[0] !== archiveRoot) {
256
+ throw new Error(`Release archive must contain exactly ${archiveRoot}.`);
204
257
  }
205
258
  await visit(appPath);
206
259
  }
207
260
 
208
- async function download(manifest, destination, fetchImpl = fetch) {
209
- let current = assertAllowedDownloadUrl(manifest.assetUrl);
261
+ async function download(manifest, artifact, destination, fetchImpl = fetch) {
262
+ let current = assertAllowedDownloadUrl(artifact.assetUrl);
210
263
  for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect += 1) {
211
264
  const response = await fetchImpl(current, {
212
265
  redirect: 'manual',
@@ -253,50 +306,91 @@ async function download(manifest, destination, fetchImpl = fetch) {
253
306
  throw new Error('Release download ended before the declared content length.');
254
307
  }
255
308
  const actual = digest.digest('hex');
256
- if (actual !== manifest.sha256) throw new Error('Release ZIP checksum verification failed.');
309
+ if (actual !== artifact.sha256) throw new Error('Release ZIP checksum verification failed.');
257
310
  return { bytes: received, sha256: actual, finalUrl: current.href };
258
311
  }
259
312
  throw new Error('Release download exceeded the redirect limit.');
260
313
  }
261
314
 
262
- async function archiveEntries(zipPath, executeImpl = execute) {
315
+ async function archiveEntries(zipPath, artifact, executeImpl = execute) {
316
+ if (artifact.platform === 'win32') {
317
+ const script =
318
+ '& { param([string]$zipPath) ' +
319
+ 'Add-Type -AssemblyName System.IO.Compression.FileSystem; ' +
320
+ '$z=[IO.Compression.ZipFile]::OpenRead($zipPath); ' +
321
+ 'try {$z.Entries.FullName} finally {$z.Dispose()} }';
322
+ const { stdout } = await executeImpl('powershell.exe', [
323
+ '-NoProfile',
324
+ '-NonInteractive',
325
+ '-Command',
326
+ script,
327
+ zipPath,
328
+ ]);
329
+ const entries = stdout.split(/\r?\n/).filter(Boolean);
330
+ assertSafeArchiveEntries(entries, artifact.archiveRoot);
331
+ return entries;
332
+ }
263
333
  const { stdout } = await executeImpl('/usr/bin/unzip', ['-Z1', zipPath]);
264
334
  const entries = stdout.split('\n').filter(Boolean);
265
- assertSafeArchiveEntries(entries);
335
+ assertSafeArchiveEntries(entries, artifact.archiveRoot);
266
336
  const { stdout: listing } = await executeImpl('/usr/bin/zipinfo', ['-l', zipPath]);
267
337
  for (const line of listing.split('\n').filter((value) => /^l[rwx-]{9}\s/.test(value))) {
268
338
  const match = line.match(/^l[rwx-]{9}\s+\S+\s+\S+\s+\d+\s+\S+\s+\d+\s+\S+\s+\S+\s+\S+\s+(.+)$/);
269
339
  if (!match) throw new Error('Unable to validate a ZIP symbolic link entry.');
270
340
  const entry = match[1];
271
341
  const { stdout: target } = await executeImpl('/usr/bin/unzip', ['-p', zipPath, entry]);
272
- assertSafeArchiveSymlink(entry, target);
342
+ assertSafeArchiveSymlink(entry, target, artifact.archiveRoot);
273
343
  }
274
344
  return entries;
275
345
  }
276
346
 
277
- async function readReceipt(receiptPath, appPath) {
347
+ async function extractArchive(archive, extracted, artifact, executeImpl) {
348
+ if (artifact.platform === 'darwin') {
349
+ await executeImpl('/usr/bin/ditto', ['-x', '-k', archive, extracted]);
350
+ } else if (artifact.platform === 'linux') {
351
+ await executeImpl('/usr/bin/unzip', ['-q', archive, '-d', extracted]);
352
+ } else {
353
+ const script =
354
+ '& { param([string]$archive, [string]$destination) ' +
355
+ 'Expand-Archive -LiteralPath $archive -DestinationPath $destination -Force }';
356
+ await executeImpl('powershell.exe', [
357
+ '-NoProfile',
358
+ '-NonInteractive',
359
+ '-Command',
360
+ script,
361
+ archive,
362
+ extracted,
363
+ ]);
364
+ }
365
+ }
366
+
367
+ async function readReceipt(receiptPath, appPath, platform) {
278
368
  try {
279
369
  const receipt = JSON.parse(await readFile(receiptPath, 'utf8'));
370
+ const current = receipt.schema === 3;
371
+ const legacyMac = platform === 'darwin' && receipt.schema === 2;
280
372
  const owned =
281
- receipt.schema === 2 &&
373
+ (current || legacyMac) &&
282
374
  receipt.owner === RECEIPT_OWNER &&
283
375
  receipt.applicationPath === appPath;
284
- return { receipt, owned };
376
+ return { receipt, owned, legacyMac };
285
377
  } catch {
286
- return { receipt: null, owned: false };
378
+ return { receipt: null, owned: false, legacyMac: false };
287
379
  }
288
380
  }
289
381
 
290
- function receiptMatchesManifest(receipt, manifest) {
382
+ function receiptMatchesManifest(receipt, manifest, artifact) {
291
383
  return (
292
384
  receipt?.releaseVersion === manifest.releaseVersion &&
293
- receipt?.sha256 === manifest.sha256 &&
294
- receipt?.releaseTag === manifest.releaseTag
385
+ receipt?.sha256 === artifact.sha256 &&
386
+ receipt?.releaseTag === manifest.releaseTag &&
387
+ (receipt.schema === 2 || receipt.artifactKey === artifact.key)
295
388
  );
296
389
  }
297
390
 
298
- async function appIsRunning(appPath, executeImpl = execute) {
299
- const executable = join(appPath, 'Contents', 'MacOS', EXECUTABLE_NAME);
391
+ async function appIsRunning(appPath, artifact, executeImpl = execute) {
392
+ const executable = join(appPath, ...artifact.executablePath.split('/'));
393
+ if (artifact.platform === 'win32') return false;
300
394
  const { stdout } = await executeImpl('/bin/ps', ['-axo', 'command=']);
301
395
  return stdout
302
396
  .split('\n')
@@ -304,14 +398,42 @@ async function appIsRunning(appPath, executeImpl = execute) {
304
398
  .some((line) => line === executable || line.startsWith(`${executable} `));
305
399
  }
306
400
 
401
+ function sandboxReport(platform, override) {
402
+ if (override) return override;
403
+ if (platform === 'win32') {
404
+ return {
405
+ status: 'setup-required',
406
+ detail: 'Run the one-time elevated Windows sandbox setup before command tools are enabled.',
407
+ setupCommands: [WINDOWS_SANDBOX_SETUP],
408
+ };
409
+ }
410
+ if (platform === 'linux') {
411
+ return {
412
+ status: 'setup-required',
413
+ detail: 'Verify Bubblewrap, socat, ripgrep, and the Ubuntu AppArmor userns profile.',
414
+ setupCommands: ['sudo apt-get install bubblewrap socat ripgrep'],
415
+ };
416
+ }
417
+ return {
418
+ status: 'ready',
419
+ detail: 'Seatbelt command sandboxing is built into macOS.',
420
+ setupCommands: [],
421
+ };
422
+ }
423
+
307
424
  export async function inspectInstallation(manifest, options = {}) {
308
- const paths = releasePaths(manifest, options.homeDirectory);
425
+ const platform = options.platform ?? process.platform;
426
+ const architecture = options.arch ?? process.arch;
427
+ const artifact = selectArtifact(manifest, platform, architecture);
428
+ const paths = releasePaths(manifest, options.homeDirectory, platform, options);
309
429
  const report = {
310
- schema: 2,
430
+ schema: 3,
311
431
  distributionMode: manifest.distributionMode,
312
- platform: options.platform ?? process.platform,
313
- architecture: options.arch ?? process.arch,
314
- macOsVersion: null,
432
+ platform,
433
+ architecture,
434
+ artifactKey: artifact.key,
435
+ verificationMode: artifact.verificationMode,
436
+ operatingSystemVersion: null,
315
437
  supported: false,
316
438
  releaseVersion: manifest.releaseVersion,
317
439
  releaseTag: manifest.releaseTag,
@@ -320,89 +442,96 @@ export async function inspectInstallation(manifest, options = {}) {
320
442
  receiptMatches: false,
321
443
  bundleIntegrity: false,
322
444
  signatureType: 'missing',
323
- gatekeeperAssessment: 'unavailable',
324
- warning: GATEKEEPER_WARNING,
445
+ gatekeeperAssessment: platform === 'darwin' ? 'unavailable' : 'not-applicable',
446
+ sandbox: sandboxReport(platform, options.sandbox),
447
+ warning: platform === 'darwin' ? MAC_WARNING : PORTABLE_WARNING,
325
448
  };
326
449
  try {
327
- assertSupportedPlatform(report.platform, report.architecture);
328
- const versionResult =
329
- options.macOsVersion === undefined
330
- ? await (options.executeImpl ?? execute)('/usr/bin/sw_vers', ['-productVersion'])
331
- : { stdout: options.macOsVersion };
332
- report.macOsVersion = versionResult.stdout.trim();
333
- assertSupportedMacOsVersion(report.macOsVersion);
450
+ assertSupportedPlatform(platform, architecture);
451
+ if (platform === 'darwin') {
452
+ const versionResult =
453
+ options.macOsVersion === undefined
454
+ ? await (options.executeImpl ?? execute)('/usr/bin/sw_vers', ['-productVersion'])
455
+ : { stdout: options.macOsVersion };
456
+ report.operatingSystemVersion = versionResult.stdout.trim();
457
+ assertSupportedMacOsVersion(report.operatingSystemVersion);
458
+ }
334
459
  report.supported = true;
335
460
  } catch (error) {
336
461
  report.error = error instanceof Error ? error.message : String(error);
337
462
  return report;
338
463
  }
339
-
340
464
  report.installed = await exists(paths.appPath);
341
465
  if (!report.installed) return report;
342
- const receiptStatus = await readReceipt(paths.receiptPath, paths.appPath);
466
+ const receiptStatus = await readReceipt(paths.receiptPath, paths.appPath, platform);
343
467
  report.receiptMatches =
344
- receiptStatus.owned && receiptMatchesManifest(receiptStatus.receipt, manifest);
468
+ receiptStatus.owned && receiptMatchesManifest(receiptStatus.receipt, manifest, artifact);
345
469
  try {
346
- await verifyApp(paths.appPath, manifest, options.executeImpl);
470
+ await verifyApp(paths.appPath, manifest, artifact, options.executeImpl);
347
471
  report.bundleIntegrity = true;
348
- report.signatureType = 'adhoc';
472
+ report.signatureType = platform === 'darwin' ? 'adhoc' : 'unsigned-portable';
349
473
  } catch (error) {
350
474
  report.signatureType = 'invalid';
351
475
  report.error = error instanceof Error ? error.message : String(error);
352
476
  }
353
- report.gatekeeperAssessment = await assessGatekeeper(paths.appPath, options.executeImpl);
354
- if (report.gatekeeperAssessment === 'accepted') report.warning = null;
477
+ if (platform === 'darwin') {
478
+ report.gatekeeperAssessment = await assessGatekeeper(paths.appPath, options.executeImpl);
479
+ if (report.gatekeeperAssessment === 'accepted') report.warning = null;
480
+ }
355
481
  return report;
356
482
  }
357
483
 
358
484
  export async function install(manifest, options = {}) {
359
- assertSupportedPlatform(options.platform ?? process.platform, options.arch ?? process.arch);
485
+ const platform = options.platform ?? process.platform;
486
+ const architecture = options.arch ?? process.arch;
487
+ const artifact = selectArtifact(manifest, platform, architecture);
360
488
  assertNonRoot(options.uid ?? process.getuid?.());
361
- const paths = releasePaths(manifest, options.homeDirectory);
489
+ const paths = releasePaths(manifest, options.homeDirectory, platform, options);
362
490
  const executeImpl = options.executeImpl ?? execute;
363
491
  const existing = await inspectInstallation(manifest, options);
364
- if (!existing.supported) {
365
- throw new Error(existing.error ?? 'This Mac is not supported.');
366
- }
367
- if (
368
- existing.installed &&
369
- existing.receiptMatches &&
370
- existing.bundleIntegrity &&
371
- existing.signatureType === 'adhoc'
372
- ) {
492
+ if (!existing.supported)
493
+ throw new Error(existing.error ?? 'This operating system is not supported.');
494
+ const receiptStatus = await readReceipt(paths.receiptPath, paths.appPath, platform);
495
+ if (existing.installed && existing.receiptMatches && existing.bundleIntegrity) {
496
+ if (receiptStatus.legacyMac) {
497
+ await (options.writeReceiptImpl ?? writeReceipt)(paths.receiptPath, {
498
+ ...receiptStatus.receipt,
499
+ schema: 3,
500
+ distributionMode: manifest.distributionMode,
501
+ artifactKey: artifact.key,
502
+ migratedAt: new Date().toISOString(),
503
+ });
504
+ }
373
505
  return paths.appPath;
374
506
  }
375
-
376
- const receiptStatus = await readReceipt(paths.receiptPath, paths.appPath);
377
507
  if (existing.installed && !receiptStatus.owned) {
378
508
  throw new Error(
379
509
  `${paths.appPath} already exists and is not managed by @adrouter/agent; move or remove it before installing.`
380
510
  );
381
511
  }
382
- if (existing.installed && (await appIsRunning(paths.appPath, executeImpl))) {
512
+ if (existing.installed && (await appIsRunning(paths.appPath, artifact, executeImpl))) {
383
513
  throw new Error('Quit AdRouter Agent before installing or updating it.');
384
514
  }
385
515
 
386
516
  await mkdir(paths.applicationsDirectory, { recursive: true, mode: 0o755 });
387
517
  await mkdir(paths.supportDirectory, { recursive: true, mode: 0o700 });
388
518
  const staging = await mkdtemp(join(paths.applicationsDirectory, '.adrouter-agent-staging-'));
389
- const archive = join(staging, basename(manifest.assetName));
519
+ const archive = join(staging, basename(artifact.assetName));
390
520
  const extracted = join(staging, 'extracted');
391
- const stagedApp = join(extracted, APP_NAME);
521
+ const stagedApp = join(extracted, artifact.archiveRoot);
392
522
  const backupPath = join(
393
523
  paths.applicationsDirectory,
394
- `.AdRouter Agent.app.backup-${process.pid}-${Date.now()}`
524
+ `.adrouter-agent-backup-${process.pid}-${Date.now()}`
395
525
  );
396
526
  let backedUp = false;
397
527
  let activated = false;
398
528
  try {
399
- const receipt = await download(manifest, archive, options.fetchImpl);
400
- await archiveEntries(archive, executeImpl);
529
+ const receipt = await download(manifest, artifact, archive, options.fetchImpl);
530
+ await archiveEntries(archive, artifact, executeImpl);
401
531
  await mkdir(extracted, { mode: 0o700 });
402
- await executeImpl('/usr/bin/ditto', ['-x', '-k', archive, extracted]);
403
- await verifyExtractedTree(extracted, stagedApp);
404
- await verifyApp(stagedApp, manifest, executeImpl);
405
-
532
+ await extractArchive(archive, extracted, artifact, executeImpl);
533
+ await verifyExtractedTree(extracted, stagedApp, artifact.archiveRoot);
534
+ await verifyApp(stagedApp, manifest, artifact, executeImpl);
406
535
  if (existing.installed) {
407
536
  await rename(paths.appPath, backupPath);
408
537
  backedUp = true;
@@ -410,9 +539,10 @@ export async function install(manifest, options = {}) {
410
539
  await rename(stagedApp, paths.appPath);
411
540
  activated = true;
412
541
  await (options.writeReceiptImpl ?? writeReceipt)(paths.receiptPath, {
413
- schema: 2,
542
+ schema: 3,
414
543
  owner: RECEIPT_OWNER,
415
544
  distributionMode: manifest.distributionMode,
545
+ artifactKey: artifact.key,
416
546
  releaseVersion: manifest.releaseVersion,
417
547
  releaseTag: manifest.releaseTag,
418
548
  sha256: receipt.sha256,
@@ -448,17 +578,24 @@ async function writeReceipt(path, receipt) {
448
578
  }
449
579
  }
450
580
 
451
- export async function launch(appPath, spawnImpl = spawn) {
581
+ export async function launch(appPath, options = {}) {
582
+ const platform = options.platform ?? process.platform;
583
+ const artifact = options.artifact ?? {
584
+ executablePath:
585
+ platform === 'darwin'
586
+ ? 'Contents/MacOS/AdRouter Agent'
587
+ : platform === 'win32'
588
+ ? 'AdRouter Agent.exe'
589
+ : 'AdRouter Agent',
590
+ };
591
+ const spawnImpl = options.spawnImpl ?? spawn;
592
+ const executable =
593
+ platform === 'darwin' ? '/usr/bin/open' : join(appPath, ...artifact.executablePath.split('/'));
594
+ const args = platform === 'darwin' ? [appPath] : [];
452
595
  await new Promise((resolvePromise, reject) => {
453
- const child = spawnImpl('/usr/bin/open', [appPath], {
454
- detached: true,
455
- stdio: 'ignore',
456
- });
596
+ const child = spawnImpl(executable, args, { detached: true, stdio: 'ignore' });
457
597
  child.once('error', reject);
458
- child.once('exit', (code) => {
459
- if (code === 0) resolvePromise();
460
- else reject(new Error(`macOS open failed with exit code ${code}.`));
461
- });
598
+ child.once('spawn', resolvePromise);
462
599
  child.unref();
463
600
  });
464
601
  }
package/lib/manifest.mjs CHANGED
@@ -2,30 +2,68 @@ import { readFile } from 'node:fs/promises';
2
2
 
3
3
  const SHA256_PATTERN = /^[a-f0-9]{64}$/;
4
4
  const VERSION_PATTERN = /^\d+\.\d+\.\d+-beta\.\d+$/;
5
+ const ARTIFACTS = Object.freeze({
6
+ 'darwin-universal': {
7
+ platform: 'darwin',
8
+ architectures: ['arm64', 'x64'],
9
+ archiveRoot: 'AdRouter Agent.app',
10
+ executablePath: 'Contents/MacOS/AdRouter Agent',
11
+ verificationMode: 'macos-adhoc',
12
+ suffix: 'darwin-universal',
13
+ },
14
+ 'linux-x64': {
15
+ platform: 'linux',
16
+ architectures: ['x64'],
17
+ archiveRoot: 'AdRouter Agent-linux-x64',
18
+ executablePath: 'AdRouter Agent',
19
+ verificationMode: 'portable-checksum',
20
+ suffix: 'linux-x64',
21
+ },
22
+ 'win32-x64': {
23
+ platform: 'win32',
24
+ architectures: ['x64'],
25
+ archiveRoot: 'AdRouter Agent-win32-x64',
26
+ executablePath: 'AdRouter Agent.exe',
27
+ verificationMode: 'portable-checksum',
28
+ suffix: 'win32-x64',
29
+ },
30
+ });
31
+
32
+ export function artifactKey(platform = process.platform, arch = process.arch) {
33
+ if (platform === 'darwin' && (arch === 'arm64' || arch === 'x64')) {
34
+ return 'darwin-universal';
35
+ }
36
+ if (platform === 'linux' && arch === 'x64') return 'linux-x64';
37
+ if (platform === 'win32' && arch === 'x64') return 'win32-x64';
38
+ throw new Error(`Unsupported operating system/architecture combination: ${platform}/${arch}.`);
39
+ }
40
+
41
+ export function selectArtifact(manifest, platform = process.platform, arch = process.arch) {
42
+ const key = artifactKey(platform, arch);
43
+ const artifact = manifest.artifacts.find((candidate) => candidate.key === key);
44
+ if (!artifact) throw new Error(`The release manifest does not contain ${key}.`);
45
+ return artifact;
46
+ }
5
47
 
6
48
  export function validateManifest(value) {
7
49
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
8
50
  throw new Error('The embedded release manifest is not an object.');
9
51
  }
10
- const requiredStrings = [
52
+ for (const key of [
11
53
  'releaseVersion',
12
54
  'releaseTag',
13
- 'assetName',
14
- 'assetUrl',
15
- 'sha256',
16
55
  'repository',
17
56
  'distributionMode',
18
57
  'bundleIdentifier',
19
58
  'bundleShortVersion',
20
59
  'bundleVersion',
21
- ];
22
- for (const key of requiredStrings) {
60
+ ]) {
23
61
  if (typeof value[key] !== 'string' || value[key].length === 0) {
24
62
  throw new Error(`The embedded release manifest has an invalid ${key}.`);
25
63
  }
26
64
  }
27
- if (value.schema !== 2) throw new Error('Unsupported release manifest schema.');
28
- if (value.distributionMode !== 'credential-free-adhoc') {
65
+ if (value.schema !== 3) throw new Error('Unsupported release manifest schema.');
66
+ if (value.distributionMode !== 'credential-free-portable') {
29
67
  throw new Error('The embedded release distribution mode is not supported.');
30
68
  }
31
69
  if (!VERSION_PATTERN.test(value.releaseVersion)) {
@@ -40,25 +78,64 @@ export function validateManifest(value) {
40
78
  if (value.bundleIdentifier !== 'com.adrouter.agent') {
41
79
  throw new Error('The embedded bundle identifier is not canonical.');
42
80
  }
43
- if (value.assetName !== `AdRouter-Agent-${value.releaseVersion}-universal.zip`) {
44
- throw new Error('The embedded release asset name is not canonical.');
45
- }
46
- const expectedUrl =
47
- `https://github.com/${value.repository}/releases/download/` +
48
- `${value.releaseTag}/${value.assetName}`;
49
- if (value.assetUrl !== expectedUrl) {
50
- throw new Error('The embedded release asset URL is not canonical.');
51
- }
52
- if (!SHA256_PATTERN.test(value.sha256)) {
53
- throw new Error('The embedded release checksum is not a SHA-256 digest.');
54
- }
55
81
  if (!/^\d+\.\d+\.\d+$/.test(value.bundleShortVersion)) {
56
82
  throw new Error('The embedded short bundle version is invalid.');
57
83
  }
58
84
  if (!/^\d+(?:\.\d+){0,2}$/.test(value.bundleVersion)) {
59
85
  throw new Error('The embedded numeric bundle version is invalid.');
60
86
  }
61
- return Object.freeze({ ...value });
87
+ if (!Array.isArray(value.artifacts) || value.artifacts.length !== 3) {
88
+ throw new Error('The embedded release manifest must contain exactly three artifacts.');
89
+ }
90
+ const seen = new Set();
91
+ for (const artifact of value.artifacts) {
92
+ const expected = ARTIFACTS[artifact?.key];
93
+ if (!expected || seen.has(artifact.key)) {
94
+ throw new Error(
95
+ `The embedded release manifest has an invalid artifact key: ${artifact?.key}.`
96
+ );
97
+ }
98
+ seen.add(artifact.key);
99
+ for (const field of [
100
+ 'platform',
101
+ 'assetName',
102
+ 'assetUrl',
103
+ 'sha256',
104
+ 'archiveRoot',
105
+ 'executablePath',
106
+ 'verificationMode',
107
+ ]) {
108
+ if (typeof artifact[field] !== 'string' || artifact[field].length === 0) {
109
+ throw new Error(`Artifact ${artifact.key} has an invalid ${field}.`);
110
+ }
111
+ }
112
+ if (
113
+ artifact.platform !== expected.platform ||
114
+ JSON.stringify(artifact.architectures) !== JSON.stringify(expected.architectures) ||
115
+ artifact.archiveRoot !== expected.archiveRoot ||
116
+ artifact.executablePath !== expected.executablePath ||
117
+ artifact.verificationMode !== expected.verificationMode
118
+ ) {
119
+ throw new Error(`Artifact ${artifact.key} does not match its canonical target metadata.`);
120
+ }
121
+ const expectedName = `AdRouter-Agent-${value.releaseVersion}-${expected.suffix}.zip`;
122
+ if (artifact.assetName !== expectedName) {
123
+ throw new Error(`Artifact ${artifact.key} has a non-canonical asset name.`);
124
+ }
125
+ const expectedUrl =
126
+ `https://github.com/${value.repository}/releases/download/` +
127
+ `${value.releaseTag}/${expectedName}`;
128
+ if (artifact.assetUrl !== expectedUrl) {
129
+ throw new Error(`Artifact ${artifact.key} has a non-canonical asset URL.`);
130
+ }
131
+ if (!SHA256_PATTERN.test(artifact.sha256)) {
132
+ throw new Error(`Artifact ${artifact.key} checksum is not a SHA-256 digest.`);
133
+ }
134
+ }
135
+ return Object.freeze({
136
+ ...value,
137
+ artifacts: Object.freeze(value.artifacts.map((artifact) => Object.freeze({ ...artifact }))),
138
+ });
62
139
  }
63
140
 
64
141
  export async function readManifest(url = new URL('../release-manifest.json', import.meta.url)) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@adrouter/agent",
3
- "version": "0.1.0-beta.3",
4
- "description": "Verified installer and launcher for the AdRouter Agent macOS application.",
3
+ "version": "0.1.0-beta.5",
4
+ "description": "Verified installer and launcher for the AdRouter Agent desktop application.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "bin": {
@@ -18,7 +18,9 @@
18
18
  "node": ">=22.19.0"
19
19
  },
20
20
  "os": [
21
- "darwin"
21
+ "darwin",
22
+ "linux",
23
+ "win32"
22
24
  ],
23
25
  "cpu": [
24
26
  "arm64",
@@ -1,13 +1,52 @@
1
1
  {
2
- "schema": 2,
3
- "distributionMode": "credential-free-adhoc",
4
- "releaseVersion": "0.1.0-beta.3",
5
- "releaseTag": "v0.1.0-beta.3",
6
- "assetName": "AdRouter-Agent-0.1.0-beta.3-universal.zip",
7
- "assetUrl": "https://github.com/adrouter/adrouterAgent/releases/download/v0.1.0-beta.3/AdRouter-Agent-0.1.0-beta.3-universal.zip",
8
- "sha256": "269e60704fea20797edefc64d440afb54112b4a238d2fed9df119bb656b7d397",
2
+ "schema": 3,
3
+ "distributionMode": "credential-free-portable",
4
+ "releaseVersion": "0.1.0-beta.5",
5
+ "releaseTag": "v0.1.0-beta.5",
9
6
  "repository": "adrouter/adrouterAgent",
10
7
  "bundleIdentifier": "com.adrouter.agent",
11
8
  "bundleShortVersion": "0.1.0",
12
- "bundleVersion": "10003"
9
+ "bundleVersion": "10005",
10
+ "artifacts": [
11
+ {
12
+ "key": "darwin-universal",
13
+ "platform": "darwin",
14
+ "architectures": [
15
+ "arm64",
16
+ "x64"
17
+ ],
18
+ "archiveRoot": "AdRouter Agent.app",
19
+ "executablePath": "Contents/MacOS/AdRouter Agent",
20
+ "verificationMode": "macos-adhoc",
21
+ "assetName": "AdRouter-Agent-0.1.0-beta.5-darwin-universal.zip",
22
+ "assetUrl": "https://github.com/adrouter/adrouterAgent/releases/download/v0.1.0-beta.5/AdRouter-Agent-0.1.0-beta.5-darwin-universal.zip",
23
+ "sha256": "aeadd409a8173390ef8a02507c557dd979c76e3f11f6cd72a9463fcaf0bca9df"
24
+ },
25
+ {
26
+ "key": "linux-x64",
27
+ "platform": "linux",
28
+ "architectures": [
29
+ "x64"
30
+ ],
31
+ "archiveRoot": "AdRouter Agent-linux-x64",
32
+ "executablePath": "AdRouter Agent",
33
+ "verificationMode": "portable-checksum",
34
+ "assetName": "AdRouter-Agent-0.1.0-beta.5-linux-x64.zip",
35
+ "assetUrl": "https://github.com/adrouter/adrouterAgent/releases/download/v0.1.0-beta.5/AdRouter-Agent-0.1.0-beta.5-linux-x64.zip",
36
+ "sha256": "b9bac9988637fb14b005af2614b2c16ad3301d00b189d38261297d78259402a9"
37
+ },
38
+ {
39
+ "key": "win32-x64",
40
+ "platform": "win32",
41
+ "architectures": [
42
+ "x64"
43
+ ],
44
+ "archiveRoot": "AdRouter Agent-win32-x64",
45
+ "executablePath": "AdRouter Agent.exe",
46
+ "verificationMode": "portable-checksum",
47
+ "assetName": "AdRouter-Agent-0.1.0-beta.5-win32-x64.zip",
48
+ "assetUrl": "https://github.com/adrouter/adrouterAgent/releases/download/v0.1.0-beta.5/AdRouter-Agent-0.1.0-beta.5-win32-x64.zip",
49
+ "sha256": "e6eae7321991e8f661f937a057a64fe77bcd69ff9b263ef9a754f466fd36ff94"
50
+ }
51
+ ]
13
52
  }