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

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,87 @@ 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
+ 'Add-Type -AssemblyName System.IO.Compression.FileSystem; ' +
319
+ '$z=[IO.Compression.ZipFile]::OpenRead($args[0]); try {$z.Entries.FullName} finally {$z.Dispose()}';
320
+ const { stdout } = await executeImpl('powershell.exe', [
321
+ '-NoProfile',
322
+ '-NonInteractive',
323
+ '-Command',
324
+ script,
325
+ zipPath,
326
+ ]);
327
+ const entries = stdout.split(/\r?\n/).filter(Boolean);
328
+ assertSafeArchiveEntries(entries, artifact.archiveRoot);
329
+ return entries;
330
+ }
263
331
  const { stdout } = await executeImpl('/usr/bin/unzip', ['-Z1', zipPath]);
264
332
  const entries = stdout.split('\n').filter(Boolean);
265
- assertSafeArchiveEntries(entries);
333
+ assertSafeArchiveEntries(entries, artifact.archiveRoot);
266
334
  const { stdout: listing } = await executeImpl('/usr/bin/zipinfo', ['-l', zipPath]);
267
335
  for (const line of listing.split('\n').filter((value) => /^l[rwx-]{9}\s/.test(value))) {
268
336
  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
337
  if (!match) throw new Error('Unable to validate a ZIP symbolic link entry.');
270
338
  const entry = match[1];
271
339
  const { stdout: target } = await executeImpl('/usr/bin/unzip', ['-p', zipPath, entry]);
272
- assertSafeArchiveSymlink(entry, target);
340
+ assertSafeArchiveSymlink(entry, target, artifact.archiveRoot);
273
341
  }
274
342
  return entries;
275
343
  }
276
344
 
277
- async function readReceipt(receiptPath, appPath) {
345
+ async function extractArchive(archive, extracted, artifact, executeImpl) {
346
+ if (artifact.platform === 'darwin') {
347
+ await executeImpl('/usr/bin/ditto', ['-x', '-k', archive, extracted]);
348
+ } else if (artifact.platform === 'linux') {
349
+ await executeImpl('/usr/bin/unzip', ['-q', archive, '-d', extracted]);
350
+ } else {
351
+ const script = 'Expand-Archive -LiteralPath $args[0] -DestinationPath $args[1] -Force';
352
+ await executeImpl('powershell.exe', [
353
+ '-NoProfile',
354
+ '-NonInteractive',
355
+ '-Command',
356
+ script,
357
+ archive,
358
+ extracted,
359
+ ]);
360
+ }
361
+ }
362
+
363
+ async function readReceipt(receiptPath, appPath, platform) {
278
364
  try {
279
365
  const receipt = JSON.parse(await readFile(receiptPath, 'utf8'));
366
+ const current = receipt.schema === 3;
367
+ const legacyMac = platform === 'darwin' && receipt.schema === 2;
280
368
  const owned =
281
- receipt.schema === 2 &&
369
+ (current || legacyMac) &&
282
370
  receipt.owner === RECEIPT_OWNER &&
283
371
  receipt.applicationPath === appPath;
284
- return { receipt, owned };
372
+ return { receipt, owned, legacyMac };
285
373
  } catch {
286
- return { receipt: null, owned: false };
374
+ return { receipt: null, owned: false, legacyMac: false };
287
375
  }
288
376
  }
289
377
 
290
- function receiptMatchesManifest(receipt, manifest) {
378
+ function receiptMatchesManifest(receipt, manifest, artifact) {
291
379
  return (
292
380
  receipt?.releaseVersion === manifest.releaseVersion &&
293
- receipt?.sha256 === manifest.sha256 &&
294
- receipt?.releaseTag === manifest.releaseTag
381
+ receipt?.sha256 === artifact.sha256 &&
382
+ receipt?.releaseTag === manifest.releaseTag &&
383
+ (receipt.schema === 2 || receipt.artifactKey === artifact.key)
295
384
  );
296
385
  }
297
386
 
298
- async function appIsRunning(appPath, executeImpl = execute) {
299
- const executable = join(appPath, 'Contents', 'MacOS', EXECUTABLE_NAME);
387
+ async function appIsRunning(appPath, artifact, executeImpl = execute) {
388
+ const executable = join(appPath, ...artifact.executablePath.split('/'));
389
+ if (artifact.platform === 'win32') return false;
300
390
  const { stdout } = await executeImpl('/bin/ps', ['-axo', 'command=']);
301
391
  return stdout
302
392
  .split('\n')
@@ -304,14 +394,42 @@ async function appIsRunning(appPath, executeImpl = execute) {
304
394
  .some((line) => line === executable || line.startsWith(`${executable} `));
305
395
  }
306
396
 
397
+ function sandboxReport(platform, override) {
398
+ if (override) return override;
399
+ if (platform === 'win32') {
400
+ return {
401
+ status: 'setup-required',
402
+ detail: 'Run the one-time elevated Windows sandbox setup before command tools are enabled.',
403
+ setupCommands: [WINDOWS_SANDBOX_SETUP],
404
+ };
405
+ }
406
+ if (platform === 'linux') {
407
+ return {
408
+ status: 'setup-required',
409
+ detail: 'Verify Bubblewrap, socat, ripgrep, and the Ubuntu AppArmor userns profile.',
410
+ setupCommands: ['sudo apt-get install bubblewrap socat ripgrep'],
411
+ };
412
+ }
413
+ return {
414
+ status: 'ready',
415
+ detail: 'Seatbelt command sandboxing is built into macOS.',
416
+ setupCommands: [],
417
+ };
418
+ }
419
+
307
420
  export async function inspectInstallation(manifest, options = {}) {
308
- const paths = releasePaths(manifest, options.homeDirectory);
421
+ const platform = options.platform ?? process.platform;
422
+ const architecture = options.arch ?? process.arch;
423
+ const artifact = selectArtifact(manifest, platform, architecture);
424
+ const paths = releasePaths(manifest, options.homeDirectory, platform, options);
309
425
  const report = {
310
- schema: 2,
426
+ schema: 3,
311
427
  distributionMode: manifest.distributionMode,
312
- platform: options.platform ?? process.platform,
313
- architecture: options.arch ?? process.arch,
314
- macOsVersion: null,
428
+ platform,
429
+ architecture,
430
+ artifactKey: artifact.key,
431
+ verificationMode: artifact.verificationMode,
432
+ operatingSystemVersion: null,
315
433
  supported: false,
316
434
  releaseVersion: manifest.releaseVersion,
317
435
  releaseTag: manifest.releaseTag,
@@ -320,89 +438,96 @@ export async function inspectInstallation(manifest, options = {}) {
320
438
  receiptMatches: false,
321
439
  bundleIntegrity: false,
322
440
  signatureType: 'missing',
323
- gatekeeperAssessment: 'unavailable',
324
- warning: GATEKEEPER_WARNING,
441
+ gatekeeperAssessment: platform === 'darwin' ? 'unavailable' : 'not-applicable',
442
+ sandbox: sandboxReport(platform, options.sandbox),
443
+ warning: platform === 'darwin' ? MAC_WARNING : PORTABLE_WARNING,
325
444
  };
326
445
  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);
446
+ assertSupportedPlatform(platform, architecture);
447
+ if (platform === 'darwin') {
448
+ const versionResult =
449
+ options.macOsVersion === undefined
450
+ ? await (options.executeImpl ?? execute)('/usr/bin/sw_vers', ['-productVersion'])
451
+ : { stdout: options.macOsVersion };
452
+ report.operatingSystemVersion = versionResult.stdout.trim();
453
+ assertSupportedMacOsVersion(report.operatingSystemVersion);
454
+ }
334
455
  report.supported = true;
335
456
  } catch (error) {
336
457
  report.error = error instanceof Error ? error.message : String(error);
337
458
  return report;
338
459
  }
339
-
340
460
  report.installed = await exists(paths.appPath);
341
461
  if (!report.installed) return report;
342
- const receiptStatus = await readReceipt(paths.receiptPath, paths.appPath);
462
+ const receiptStatus = await readReceipt(paths.receiptPath, paths.appPath, platform);
343
463
  report.receiptMatches =
344
- receiptStatus.owned && receiptMatchesManifest(receiptStatus.receipt, manifest);
464
+ receiptStatus.owned && receiptMatchesManifest(receiptStatus.receipt, manifest, artifact);
345
465
  try {
346
- await verifyApp(paths.appPath, manifest, options.executeImpl);
466
+ await verifyApp(paths.appPath, manifest, artifact, options.executeImpl);
347
467
  report.bundleIntegrity = true;
348
- report.signatureType = 'adhoc';
468
+ report.signatureType = platform === 'darwin' ? 'adhoc' : 'unsigned-portable';
349
469
  } catch (error) {
350
470
  report.signatureType = 'invalid';
351
471
  report.error = error instanceof Error ? error.message : String(error);
352
472
  }
353
- report.gatekeeperAssessment = await assessGatekeeper(paths.appPath, options.executeImpl);
354
- if (report.gatekeeperAssessment === 'accepted') report.warning = null;
473
+ if (platform === 'darwin') {
474
+ report.gatekeeperAssessment = await assessGatekeeper(paths.appPath, options.executeImpl);
475
+ if (report.gatekeeperAssessment === 'accepted') report.warning = null;
476
+ }
355
477
  return report;
356
478
  }
357
479
 
358
480
  export async function install(manifest, options = {}) {
359
- assertSupportedPlatform(options.platform ?? process.platform, options.arch ?? process.arch);
481
+ const platform = options.platform ?? process.platform;
482
+ const architecture = options.arch ?? process.arch;
483
+ const artifact = selectArtifact(manifest, platform, architecture);
360
484
  assertNonRoot(options.uid ?? process.getuid?.());
361
- const paths = releasePaths(manifest, options.homeDirectory);
485
+ const paths = releasePaths(manifest, options.homeDirectory, platform, options);
362
486
  const executeImpl = options.executeImpl ?? execute;
363
487
  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
- ) {
488
+ if (!existing.supported)
489
+ throw new Error(existing.error ?? 'This operating system is not supported.');
490
+ const receiptStatus = await readReceipt(paths.receiptPath, paths.appPath, platform);
491
+ if (existing.installed && existing.receiptMatches && existing.bundleIntegrity) {
492
+ if (receiptStatus.legacyMac) {
493
+ await (options.writeReceiptImpl ?? writeReceipt)(paths.receiptPath, {
494
+ ...receiptStatus.receipt,
495
+ schema: 3,
496
+ distributionMode: manifest.distributionMode,
497
+ artifactKey: artifact.key,
498
+ migratedAt: new Date().toISOString(),
499
+ });
500
+ }
373
501
  return paths.appPath;
374
502
  }
375
-
376
- const receiptStatus = await readReceipt(paths.receiptPath, paths.appPath);
377
503
  if (existing.installed && !receiptStatus.owned) {
378
504
  throw new Error(
379
505
  `${paths.appPath} already exists and is not managed by @adrouter/agent; move or remove it before installing.`
380
506
  );
381
507
  }
382
- if (existing.installed && (await appIsRunning(paths.appPath, executeImpl))) {
508
+ if (existing.installed && (await appIsRunning(paths.appPath, artifact, executeImpl))) {
383
509
  throw new Error('Quit AdRouter Agent before installing or updating it.');
384
510
  }
385
511
 
386
512
  await mkdir(paths.applicationsDirectory, { recursive: true, mode: 0o755 });
387
513
  await mkdir(paths.supportDirectory, { recursive: true, mode: 0o700 });
388
514
  const staging = await mkdtemp(join(paths.applicationsDirectory, '.adrouter-agent-staging-'));
389
- const archive = join(staging, basename(manifest.assetName));
515
+ const archive = join(staging, basename(artifact.assetName));
390
516
  const extracted = join(staging, 'extracted');
391
- const stagedApp = join(extracted, APP_NAME);
517
+ const stagedApp = join(extracted, artifact.archiveRoot);
392
518
  const backupPath = join(
393
519
  paths.applicationsDirectory,
394
- `.AdRouter Agent.app.backup-${process.pid}-${Date.now()}`
520
+ `.adrouter-agent-backup-${process.pid}-${Date.now()}`
395
521
  );
396
522
  let backedUp = false;
397
523
  let activated = false;
398
524
  try {
399
- const receipt = await download(manifest, archive, options.fetchImpl);
400
- await archiveEntries(archive, executeImpl);
525
+ const receipt = await download(manifest, artifact, archive, options.fetchImpl);
526
+ await archiveEntries(archive, artifact, executeImpl);
401
527
  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
-
528
+ await extractArchive(archive, extracted, artifact, executeImpl);
529
+ await verifyExtractedTree(extracted, stagedApp, artifact.archiveRoot);
530
+ await verifyApp(stagedApp, manifest, artifact, executeImpl);
406
531
  if (existing.installed) {
407
532
  await rename(paths.appPath, backupPath);
408
533
  backedUp = true;
@@ -410,9 +535,10 @@ export async function install(manifest, options = {}) {
410
535
  await rename(stagedApp, paths.appPath);
411
536
  activated = true;
412
537
  await (options.writeReceiptImpl ?? writeReceipt)(paths.receiptPath, {
413
- schema: 2,
538
+ schema: 3,
414
539
  owner: RECEIPT_OWNER,
415
540
  distributionMode: manifest.distributionMode,
541
+ artifactKey: artifact.key,
416
542
  releaseVersion: manifest.releaseVersion,
417
543
  releaseTag: manifest.releaseTag,
418
544
  sha256: receipt.sha256,
@@ -448,17 +574,24 @@ async function writeReceipt(path, receipt) {
448
574
  }
449
575
  }
450
576
 
451
- export async function launch(appPath, spawnImpl = spawn) {
577
+ export async function launch(appPath, options = {}) {
578
+ const platform = options.platform ?? process.platform;
579
+ const artifact = options.artifact ?? {
580
+ executablePath:
581
+ platform === 'darwin'
582
+ ? 'Contents/MacOS/AdRouter Agent'
583
+ : platform === 'win32'
584
+ ? 'AdRouter Agent.exe'
585
+ : 'AdRouter Agent',
586
+ };
587
+ const spawnImpl = options.spawnImpl ?? spawn;
588
+ const executable =
589
+ platform === 'darwin' ? '/usr/bin/open' : join(appPath, ...artifact.executablePath.split('/'));
590
+ const args = platform === 'darwin' ? [appPath] : [];
452
591
  await new Promise((resolvePromise, reject) => {
453
- const child = spawnImpl('/usr/bin/open', [appPath], {
454
- detached: true,
455
- stdio: 'ignore',
456
- });
592
+ const child = spawnImpl(executable, args, { detached: true, stdio: 'ignore' });
457
593
  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
- });
594
+ child.once('spawn', resolvePromise);
462
595
  child.unref();
463
596
  });
464
597
  }
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.4",
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.4",
5
+ "releaseTag": "v0.1.0-beta.4",
9
6
  "repository": "adrouter/adrouterAgent",
10
7
  "bundleIdentifier": "com.adrouter.agent",
11
8
  "bundleShortVersion": "0.1.0",
12
- "bundleVersion": "10003"
9
+ "bundleVersion": "10004",
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.4-darwin-universal.zip",
22
+ "assetUrl": "https://github.com/adrouter/adrouterAgent/releases/download/v0.1.0-beta.4/AdRouter-Agent-0.1.0-beta.4-darwin-universal.zip",
23
+ "sha256": "8ef05bc2098f4ac9c8ecff943ee64e0e76389c5e69e3a2fd165a42edad7df48d"
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.4-linux-x64.zip",
35
+ "assetUrl": "https://github.com/adrouter/adrouterAgent/releases/download/v0.1.0-beta.4/AdRouter-Agent-0.1.0-beta.4-linux-x64.zip",
36
+ "sha256": "85b81bab36458938f74afd2d0de30150cbd1bd7c5e92f20f6cc65cb1d36e2346"
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.4-win32-x64.zip",
48
+ "assetUrl": "https://github.com/adrouter/adrouterAgent/releases/download/v0.1.0-beta.4/AdRouter-Agent-0.1.0-beta.4-win32-x64.zip",
49
+ "sha256": "f5cefd74b561511642832aa9e17b00a2dc3034bbb00467b09aaeca0818e4c443"
50
+ }
51
+ ]
13
52
  }