@amalgm/shell 0.1.164-preview.34187698689 → 0.1.164-preview.34192807991
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/PURPOSE.md +32 -18
- package/examinations/network-view.mjs +76 -22
- package/examinations/network-view.native-test.mjs +159 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -4
package/PURPOSE.md
CHANGED
|
@@ -6,6 +6,9 @@ while its host can execute, recover without opening a UI, contain a failing
|
|
|
6
6
|
workload, and leave the machine's resources available for useful work. Shell
|
|
7
7
|
owns composition, process boot, and native host effects; Core and the product
|
|
8
8
|
SDKs own the decisions those effects execute.
|
|
9
|
+
At the completed brick/mortar boundary, compatible bricks advance through independently selected artifacts while Shell
|
|
10
|
+
remains the mortar; publishing a brick must not require publishing unchanged
|
|
11
|
+
Shell code.
|
|
9
12
|
|
|
10
13
|
## Operating axioms
|
|
11
14
|
|
|
@@ -15,6 +18,9 @@ defines the terms and examinations; the [September 2026 audit](docs/operating-au
|
|
|
15
18
|
records implementation gaps and historical evidence.
|
|
16
19
|
The [release environments plan](docs/release-environments-plan.md) records the
|
|
17
20
|
agreed CI/CD and update evolution; its target behavior is not yet delivered.
|
|
21
|
+
The owner permits temporary bundled Shell releases for brick changes during
|
|
22
|
+
boundary cleanup. Independent brick adoption remains the target, and Engine
|
|
23
|
+
migration is out of scope because no existing Engine users need it.
|
|
18
24
|
|
|
19
25
|
- **O1 — Lifetime follows installation intent.** One installation owner keeps
|
|
20
26
|
the enabled runtime available independently of its launching UI or terminal
|
|
@@ -66,12 +72,12 @@ native examinations before either becomes a managed public candidate. Public
|
|
|
66
72
|
metadata records the exact internal archive it was derived from; no wrapper,
|
|
67
73
|
copied Engine implementation or second dependency graph supplies runtime behavior.
|
|
68
74
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
At the target boundary, release assembly selects exact registry artifacts from the same managed branch
|
|
76
|
+
into a signed installation recipe. Compatible brick changes reuse the existing
|
|
77
|
+
Shell artifact without a Shell dependency-update PR or lockfile edit. A product
|
|
78
|
+
owns changes to libraries embedded in its own artifact. Reconciliation preserves
|
|
79
|
+
human edits, coalesces candidates, and never republishes unchanged components
|
|
80
|
+
because a notification or check was repeated.
|
|
75
81
|
|
|
76
82
|
1. **The shell implements host effects; SDKs own domain decisions.** Shell
|
|
77
83
|
assembles published capabilities, implements their native effect ports,
|
|
@@ -157,17 +163,25 @@ changes it. Examinations inject their credential store explicitly.
|
|
|
157
163
|
decisions receive effects explicitly and never discover ambient process
|
|
158
164
|
state or disk on their own.
|
|
159
165
|
|
|
160
|
-
5. **Bricks
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
166
|
+
5. **Bricks release independently; one installation owner adopts the graph.**
|
|
167
|
+
Shell carries allowed component identities and supported host contracts,
|
|
168
|
+
without bundling brick implementations or pinning their runtime versions.
|
|
169
|
+
A separately signed recipe selects exact compatible artifacts and dependency
|
|
170
|
+
graphs; compatible brick changes reuse unchanged Shell and UI artifacts.
|
|
171
|
+
Registry tags locate candidates; fresh TUF metadata authorizes adoption.
|
|
172
|
+
Bricks never update themselves, and one coherent tested graph governs each
|
|
173
|
+
activation and its recovery. Artifact identity is separate from process
|
|
174
|
+
lifetime: a changed in-process implementation requires draining its actual
|
|
175
|
+
process cohort even when Shell's bytes stay unchanged. Release proof checks
|
|
176
|
+
actual dependency resolutions and public contracts; it never forces all
|
|
177
|
+
consumers onto one untested Core version.
|
|
178
|
+
|
|
179
|
+
Accepted transition: current Shell packages still bundle a pinned SDK graph,
|
|
180
|
+
and the current updater stages one Shell archive. A brick change may therefore
|
|
181
|
+
temporarily require a checked Shell release while the boundary is completed;
|
|
182
|
+
this does not block initial delivery. The
|
|
183
|
+
[release contract](docs/release-environments-plan.md#current-release-contract--2026-09-08)
|
|
184
|
+
defines the required transition and distinguishes it from existing evidence.
|
|
171
185
|
|
|
172
186
|
The installed runtime follows the [update axioms](src/updates/README.md).
|
|
173
187
|
Each public managed package declares its controller, process and state-format
|
|
@@ -997,7 +1011,7 @@ Local profiles retain their selected sources and do not follow managed targets.
|
|
|
997
1011
|
|
|
998
1012
|
65. **A Shell release is tested as the artifact users install.** The release
|
|
999
1013
|
gate packs Shell, installs that tarball with the exact public Amalgm
|
|
1000
|
-
|
|
1014
|
+
artifacts selected by the installation recipe, and exercises authorization
|
|
1001
1015
|
through Chat using package exports only. Sibling repositories and source
|
|
1002
1016
|
paths are never an integration-test dependency.
|
|
1003
1017
|
|
|
@@ -18,6 +18,32 @@ const run = (file, args, options = {}) => execFileSync(file, args, { encoding: '
|
|
|
18
18
|
const sudo = (file, args) => run('sudo', ['-n', file, ...args]);
|
|
19
19
|
const powershell = (source, env = {}) => run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', source], { env: { ...process.env, ...env } });
|
|
20
20
|
|
|
21
|
+
async function waitForNativeResolution(hostsFile, expectedHosts) {
|
|
22
|
+
const deadline = Date.now() + 15_000;
|
|
23
|
+
let observed;
|
|
24
|
+
do {
|
|
25
|
+
try {
|
|
26
|
+
// A fresh, bounded process uses the OS resolver used by installed clients.
|
|
27
|
+
observed = JSON.parse(run(process.execPath, ['--input-type=module', '-e',
|
|
28
|
+
"import { lookup } from 'node:dns/promises'; process.stdout.write(JSON.stringify(await lookup(process.argv[1], { all: true })));",
|
|
29
|
+
canonicalHost], { timeout: Math.max(1, deadline - Date.now()) }));
|
|
30
|
+
if (observed.length > 0 && observed.every(value => value.address === '127.0.0.1')) return;
|
|
31
|
+
} catch (error) { observed = error.message; }
|
|
32
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
33
|
+
} while (Date.now() < deadline);
|
|
34
|
+
const bytes = await fs.readFile(hostsFile);
|
|
35
|
+
const stat = await fs.stat(hostsFile);
|
|
36
|
+
const hosts = { path: await fs.realpath(hostsFile), mode: (stat.mode & 0o777).toString(8),
|
|
37
|
+
uid: stat.uid, gid: stat.gid, exact: bytes.equals(expectedHosts),
|
|
38
|
+
entries: bytes.toString('utf8').split(/\r?\n/).filter(line => line.includes(canonicalHost)) };
|
|
39
|
+
let native;
|
|
40
|
+
if (process.platform === 'darwin') {
|
|
41
|
+
try { native = run('dscacheutil', ['-q', 'host', '-a', 'name', canonicalHost]); }
|
|
42
|
+
catch (error) { native = error.message; }
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`The native resolver did not adopt the examination host: ${JSON.stringify({ observed, hosts, native })}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
21
47
|
export async function startNetworkView({ temporary, immutable, previous = {}, candidate, artifacts = [], events, hint = {}, serviceEnvironment = false }) {
|
|
22
48
|
assert.equal(process.env.CI, 'true');
|
|
23
49
|
assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Never replace DNS or TLS trust on a developer account');
|
|
@@ -29,7 +55,8 @@ export async function startNetworkView({ temporary, immutable, previous = {}, ca
|
|
|
29
55
|
const ca = path.join(directory, 'ca.crt');
|
|
30
56
|
const key = path.join(directory, 'server.key');
|
|
31
57
|
const certificate = path.join(directory, 'server.crt');
|
|
32
|
-
const name = `Amalgm
|
|
58
|
+
const name = `Amalgm native examination ${crypto.randomUUID()}`;
|
|
59
|
+
assert.ok(Buffer.byteLength(name, 'utf8') <= 64, 'A disposable authority must fit the X.509 common-name limit');
|
|
33
60
|
const extension = path.join(directory, 'server.ext');
|
|
34
61
|
await fs.writeFile(extension, `subjectAltName=DNS:${canonicalHost}\nextendedKeyUsage=serverAuth\n`);
|
|
35
62
|
run('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-subj', `/CN=${name}`, '-keyout', path.join(directory, 'ca.key'), '-out', ca], { stdio: 'pipe' });
|
|
@@ -39,9 +66,7 @@ export async function startNetworkView({ temporary, immutable, previous = {}, ca
|
|
|
39
66
|
await fs.rm(path.join(directory, 'ca.key'));
|
|
40
67
|
const hostsFile = process.platform === 'win32' ? path.join(process.env.SystemRoot, 'System32/drivers/etc/hosts') : '/etc/hosts';
|
|
41
68
|
const originalHosts = await fs.readFile(hostsFile);
|
|
42
|
-
const
|
|
43
|
-
await fs.writeFile(hosts, Buffer.concat([originalHosts, Buffer.from(`\n127.0.0.1 ${canonicalHost}\n`)]));
|
|
44
|
-
const original = path.join(directory, 'hosts.original'); await fs.writeFile(original, originalHosts);
|
|
69
|
+
const examinationHosts = Buffer.concat([originalHosts, Buffer.from(`\n127.0.0.1 ${canonicalHost}\n`)]);
|
|
45
70
|
const linuxCa = `/usr/local/share/ca-certificates/amalgm-examination-${crypto.randomUUID()}.crt`;
|
|
46
71
|
const nss = path.join(process.env.HOME || process.env.USERPROFILE, '.pki/nssdb');
|
|
47
72
|
const fingerprint = run('openssl', ['x509', '-in', ca, '-noout', '-fingerprint', '-sha1']).split('=')[1].trim().replace(/:/g, '');
|
|
@@ -52,13 +77,27 @@ export async function startNetworkView({ temporary, immutable, previous = {}, ca
|
|
|
52
77
|
else if (process.platform === 'linux') run('systemctl', ['--user', value === null ? 'unset-environment' : 'set-environment', value === null ? 'NODE_EXTRA_CA_CERTS' : `NODE_EXTRA_CA_CERTS=${value}`]);
|
|
53
78
|
else powershell("[Environment]::SetEnvironmentVariable('NODE_EXTRA_CA_CERTS', $env:AMALGM_EXAMINATION_CA, 'User')", { AMALGM_EXAMINATION_CA: value ?? '' });
|
|
54
79
|
};
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
80
|
+
const writeHosts = bytes => {
|
|
81
|
+
// Preserve the existing readable inode and metadata for native observers.
|
|
82
|
+
// File-copy tools can temporarily make the destination root-only, causing
|
|
83
|
+
// the resolver to miss the data change while it cannot reopen the file.
|
|
84
|
+
run(process.platform === 'win32' ? process.execPath : 'sudo', [
|
|
85
|
+
...(process.platform === 'win32' ? [] : ['-n', process.execPath]),
|
|
86
|
+
'--input-type=module', '-e', `
|
|
87
|
+
import fs from 'node:fs';
|
|
88
|
+
const bytes = fs.readFileSync(0);
|
|
89
|
+
const fd = fs.openSync(process.argv[1], 'r+');
|
|
90
|
+
try { fs.writeFileSync(fd, bytes); fs.ftruncateSync(fd, bytes.length); fs.fsyncSync(fd); }
|
|
91
|
+
finally { fs.closeSync(fd); }
|
|
92
|
+
`, hostsFile,
|
|
93
|
+
], { input: bytes });
|
|
58
94
|
};
|
|
59
95
|
const close = async () => {
|
|
60
96
|
const errors = [];
|
|
61
|
-
|
|
97
|
+
const attempt = async effect => {
|
|
98
|
+
try { await effect(); } catch (error) { errors.push(error); }
|
|
99
|
+
};
|
|
100
|
+
await attempt(async () => {
|
|
62
101
|
if (child && child.exitCode === null) {
|
|
63
102
|
child.stdin.end();
|
|
64
103
|
await new Promise((resolve, reject) => {
|
|
@@ -66,19 +105,28 @@ export async function startNetworkView({ temporary, immutable, previous = {}, ca
|
|
|
66
105
|
child.once('exit', () => { clearTimeout(timeout); resolve(); });
|
|
67
106
|
});
|
|
68
107
|
}
|
|
69
|
-
}
|
|
70
|
-
if (changedHosts) await
|
|
71
|
-
if (changedServiceEnvironment) serviceCa(priorServiceCa);
|
|
108
|
+
});
|
|
109
|
+
if (changedHosts) await attempt(() => writeHosts(originalHosts));
|
|
110
|
+
if (changedServiceEnvironment) await attempt(() => serviceCa(priorServiceCa));
|
|
72
111
|
if (process.platform === 'darwin') {
|
|
73
|
-
if (trusted)
|
|
74
|
-
|
|
75
|
-
|
|
112
|
+
if (trusted) {
|
|
113
|
+
// Removing the last admin record can request GUI authorization on macOS.
|
|
114
|
+
// Explicitly revoke this CA, then delete its owned certificate; the
|
|
115
|
+
// negative trust record lasts only until this disposable CI VM ends.
|
|
116
|
+
await attempt(() => sudo('security', ['add-trusted-cert', '-d', '-r', 'deny', '-k', '/Library/Keychains/System.keychain', ca]));
|
|
117
|
+
await attempt(() => sudo('security', ['delete-certificate', '-Z', fingerprint, '/Library/Keychains/System.keychain']));
|
|
118
|
+
}
|
|
119
|
+
await attempt(() => sudo('dscacheutil', ['-flushcache']));
|
|
120
|
+
await attempt(() => sudo('killall', ['-HUP', 'mDNSResponder']));
|
|
76
121
|
} else if (process.platform === 'linux') {
|
|
77
|
-
if (nssTrusted) run('certutil', ['-D', '-d', `sql:${nss}`, '-n', name]);
|
|
78
|
-
if (trusted) {
|
|
122
|
+
if (nssTrusted) await attempt(() => run('certutil', ['-D', '-d', `sql:${nss}`, '-n', name]));
|
|
123
|
+
if (trusted) {
|
|
124
|
+
await attempt(() => sudo('rm', ['-f', linuxCa]));
|
|
125
|
+
await attempt(() => sudo('update-ca-certificates', []));
|
|
126
|
+
}
|
|
79
127
|
} else {
|
|
80
|
-
if (trusted) run('certutil.exe', ['-
|
|
81
|
-
run('ipconfig.exe', ['/flushdns']);
|
|
128
|
+
if (trusted) await attempt(() => run('certutil.exe', ['-delstore', 'Root', fingerprint]));
|
|
129
|
+
await attempt(() => run('ipconfig.exe', ['/flushdns']));
|
|
82
130
|
}
|
|
83
131
|
if (errors.length) throw new AggregateError(errors, 'Examination transport cleanup failed');
|
|
84
132
|
};
|
|
@@ -93,15 +141,16 @@ export async function startNetworkView({ temporary, immutable, previous = {}, ca
|
|
|
93
141
|
run('certutil', ['-A', '-d', `sql:${nss}`, '-n', name, '-t', 'C,,', '-i', ca]); nssTrusted = true;
|
|
94
142
|
} else {
|
|
95
143
|
powershell("if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Disposable Windows CI account must be an administrator' }");
|
|
96
|
-
run('certutil.exe', ['-
|
|
144
|
+
run('certutil.exe', ['-addstore', '-f', 'Root', ca]); trusted = true;
|
|
97
145
|
}
|
|
98
146
|
if (serviceEnvironment) {
|
|
99
147
|
if (process.platform === 'darwin') { try { priorServiceCa = run('launchctl', ['getenv', 'NODE_EXTRA_CA_CERTS']).trim() || null; } catch { /* unset */ } }
|
|
100
148
|
else if (process.platform === 'linux') priorServiceCa = run('systemctl', ['--user', 'show-environment']).split('\n').find(line => line.startsWith('NODE_EXTRA_CA_CERTS='))?.slice('NODE_EXTRA_CA_CERTS='.length) ?? null;
|
|
101
|
-
else priorServiceCa = JSON.parse(powershell("ConvertTo-Json -Compress -InputObject
|
|
149
|
+
else priorServiceCa = JSON.parse(powershell("ConvertTo-Json -Compress -InputObject @{ value = [Environment]::GetEnvironmentVariable('NODE_EXTRA_CA_CERTS', 'User') }")).value;
|
|
102
150
|
serviceCa(ca); changedServiceEnvironment = true;
|
|
103
151
|
}
|
|
104
|
-
|
|
152
|
+
changedHosts = true; writeHosts(examinationHosts);
|
|
153
|
+
assert.deepEqual(await fs.readFile(hostsFile), examinationHosts, 'The OS hosts file must contain the exact examination view');
|
|
105
154
|
if (process.platform === 'darwin') { sudo('dscacheutil', ['-flushcache']); sudo('killall', ['-HUP', 'mDNSResponder']); }
|
|
106
155
|
else if (process.platform === 'win32') run('ipconfig.exe', ['/flushdns']);
|
|
107
156
|
const config = path.join(directory, 'config.json');
|
|
@@ -128,8 +177,13 @@ export async function startNetworkView({ temporary, immutable, previous = {}, ca
|
|
|
128
177
|
});
|
|
129
178
|
};
|
|
130
179
|
await command({ operation: 'status' });
|
|
180
|
+
await waitForNativeResolution(hostsFile, examinationHosts);
|
|
131
181
|
return { ca, close, command };
|
|
132
|
-
} catch (error) {
|
|
182
|
+
} catch (error) {
|
|
183
|
+
try { await close(); }
|
|
184
|
+
catch (cleanup) { throw new AggregateError([error, cleanup], 'Examination transport initialization and cleanup failed'); }
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
133
187
|
}
|
|
134
188
|
|
|
135
189
|
async function serve(configFile) {
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import https from 'node:https';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import { canonicalHost, startNetworkView } from './network-view.mjs';
|
|
10
|
+
|
|
11
|
+
const execute = promisify(execFile);
|
|
12
|
+
const resource = value => ({ body: Buffer.from(value).toString('base64') });
|
|
13
|
+
const run = (file, args) => execFileSync(file, args, { encoding: 'utf8', timeout: 15_000 });
|
|
14
|
+
function serviceCa() {
|
|
15
|
+
if (process.platform === 'darwin') {
|
|
16
|
+
try { return run('launchctl', ['getenv', 'NODE_EXTRA_CA_CERTS']).trim() || null; }
|
|
17
|
+
catch { return null; }
|
|
18
|
+
}
|
|
19
|
+
if (process.platform === 'linux') return run('systemctl', ['--user', 'show-environment'])
|
|
20
|
+
.split('\n').find(line => line.startsWith('NODE_EXTRA_CA_CERTS='))?.slice('NODE_EXTRA_CA_CERTS='.length) ?? null;
|
|
21
|
+
return JSON.parse(run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
22
|
+
"ConvertTo-Json -Compress -InputObject @{ value = [Environment]::GetEnvironmentVariable('NODE_EXTRA_CA_CERTS', 'User') }"])).value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Each request discovers the OS trust store anew. No supplied CA, DNS lookup,
|
|
26
|
+
// disabled TLS validation or inherited Node options can substitute for it.
|
|
27
|
+
async function nativeRequest(options) {
|
|
28
|
+
const env = { ...process.env };
|
|
29
|
+
for (const name of Object.keys(env)) {
|
|
30
|
+
if (/^(NODE_EXTRA_CA_CERTS|NODE_OPTIONS|NODE_TLS_REJECT_UNAUTHORIZED|SSL_CERT_FILE|SSL_CERT_DIR)$/i.test(name)) delete env[name];
|
|
31
|
+
}
|
|
32
|
+
const { stdout } = await execute(process.execPath, ['--use-system-ca', '--input-type=module', '-e', `
|
|
33
|
+
import https from 'node:https';
|
|
34
|
+
const result = await new Promise(resolve => {
|
|
35
|
+
const request = https.get({ ...JSON.parse(process.argv[1]), agent: false, signal: AbortSignal.timeout(10_000) }, response => {
|
|
36
|
+
const peer = { authorized: response.socket.authorized, address: response.socket.remoteAddress };
|
|
37
|
+
let body = '';
|
|
38
|
+
response.setEncoding('utf8');
|
|
39
|
+
response.on('data', data => { body += data; });
|
|
40
|
+
response.on('end', () => resolve({ status: response.statusCode, body, ...peer }));
|
|
41
|
+
response.on('error', error => resolve({ error: error.code, message: error.message }));
|
|
42
|
+
});
|
|
43
|
+
request.on('error', error => resolve({ error: error.code, message: error.message }));
|
|
44
|
+
});
|
|
45
|
+
process.stdout.write(JSON.stringify(result));
|
|
46
|
+
`, JSON.stringify(options)], { env, timeout: 15_000, maxBuffer: 64 * 1024 });
|
|
47
|
+
return JSON.parse(stdout);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('disposable native transport owns DNS, TLS trust, selected views and cleanup', {
|
|
51
|
+
skip: process.env.AMALGM_TEST_NATIVE_NETWORK !== '1', timeout: 180_000,
|
|
52
|
+
}, async () => {
|
|
53
|
+
assert.equal(process.env.CI, 'true');
|
|
54
|
+
assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Native transport examination requires a disposable GitHub runner');
|
|
55
|
+
const hostsFile = process.platform === 'win32' ? path.join(process.env.SystemRoot, 'System32/drivers/etc/hosts') : '/etc/hosts';
|
|
56
|
+
const originalHosts = await fs.readFile(hostsFile);
|
|
57
|
+
const originalServiceCa = serviceCa();
|
|
58
|
+
const originalProcessCa = process.env.NODE_EXTRA_CA_CERTS;
|
|
59
|
+
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'amalgm-network-preflight-'));
|
|
60
|
+
const scope = '/trust/native-preflight';
|
|
61
|
+
const immutablePath = `${scope}/immutable`;
|
|
62
|
+
const selectedPath = `${scope}/selected`;
|
|
63
|
+
const events = `${scope}/events`;
|
|
64
|
+
let network; let leafServer; let streamRequest;
|
|
65
|
+
const failures = [];
|
|
66
|
+
const request = pathname => nativeRequest({ hostname: canonicalHost, path: pathname });
|
|
67
|
+
const expect = async (pathname, status, body) => {
|
|
68
|
+
assert.deepEqual(await request(pathname), { status, body, authorized: true, address: '127.0.0.1' });
|
|
69
|
+
};
|
|
70
|
+
try {
|
|
71
|
+
network = await startNetworkView({ temporary, events, serviceEnvironment: true,
|
|
72
|
+
immutable: { [immutablePath]: resource('immutable\n') },
|
|
73
|
+
previous: { [selectedPath]: resource('previous\n') },
|
|
74
|
+
candidate: { [selectedPath]: resource('candidate\n') },
|
|
75
|
+
});
|
|
76
|
+
assert.equal(serviceCa(), network.ca);
|
|
77
|
+
await expect(immutablePath, 200, 'immutable\n');
|
|
78
|
+
await expect(selectedPath, 200, 'previous\n');
|
|
79
|
+
await network.command({ operation: 'publish' });
|
|
80
|
+
await expect(selectedPath, 200, 'candidate\n');
|
|
81
|
+
await expect(immutablePath, 200, 'immutable\n');
|
|
82
|
+
await network.command({ operation: 'unavailable' });
|
|
83
|
+
await expect(selectedPath, 404, '');
|
|
84
|
+
await expect(events, 503, '');
|
|
85
|
+
await expect(immutablePath, 200, 'immutable\n');
|
|
86
|
+
await network.command({ operation: 'reset' });
|
|
87
|
+
await expect(selectedPath, 200, 'previous\n');
|
|
88
|
+
|
|
89
|
+
// Supply the original CA with the actual leaf so cleanup must revoke trust
|
|
90
|
+
// even when the peer supplies its full chain after OS certificate deletion.
|
|
91
|
+
const leaf = path.join(temporary, 'network/server.crt');
|
|
92
|
+
const caFile = network.ca;
|
|
93
|
+
const ca = await fs.readFile(caFile);
|
|
94
|
+
const nativeEvaluation = ['verify-cert', '-c', leaf, '-c', caFile, '-p', 'ssl', '-s', canonicalHost, '-L'];
|
|
95
|
+
if (process.platform === 'darwin') run('security', nativeEvaluation);
|
|
96
|
+
leafServer = https.createServer({
|
|
97
|
+
key: await fs.readFile(path.join(temporary, 'network/server.key')),
|
|
98
|
+
cert: Buffer.concat([await fs.readFile(leaf), ca]),
|
|
99
|
+
}, (_request, response) => response.end('same leaf\n'));
|
|
100
|
+
await new Promise((resolve, reject) => {
|
|
101
|
+
leafServer.once('error', reject); leafServer.listen(0, '127.0.0.1', resolve);
|
|
102
|
+
});
|
|
103
|
+
const leafAddress = { hostname: '127.0.0.1', port: leafServer.address().port, servername: canonicalHost, path: '/' };
|
|
104
|
+
assert.deepEqual(await nativeRequest(leafAddress), { status: 200, body: 'same leaf\n', authorized: true, address: '127.0.0.1' });
|
|
105
|
+
|
|
106
|
+
// Hold an event stream through cleanup; its lifetime belongs to the view.
|
|
107
|
+
let streamClosed;
|
|
108
|
+
await new Promise((resolve, reject) => {
|
|
109
|
+
streamRequest = https.get(`https://${canonicalHost}${events}`, { ca, agent: false, signal: AbortSignal.timeout(15_000) }, response => {
|
|
110
|
+
streamClosed = new Promise(done => response.once('close', done));
|
|
111
|
+
response.on('error', () => {}); // Shutdown may terminate the open stream.
|
|
112
|
+
if (response.statusCode !== 200) { response.resume(); reject(new Error(`Event stream returned ${response.statusCode}`)); return; }
|
|
113
|
+
let frame = ''; let ready = false;
|
|
114
|
+
response.setEncoding('utf8');
|
|
115
|
+
response.on('data', data => {
|
|
116
|
+
if (ready) return;
|
|
117
|
+
frame += data;
|
|
118
|
+
if (!frame.includes('\n\n')) return;
|
|
119
|
+
try { assert.match(frame, /^event: ready\n/); ready = true; resolve(); } catch (error) { reject(error); }
|
|
120
|
+
});
|
|
121
|
+
response.once('close', () => { if (!ready) reject(new Error('Event stream closed before readiness')); });
|
|
122
|
+
});
|
|
123
|
+
streamRequest.on('error', reject);
|
|
124
|
+
});
|
|
125
|
+
assert.equal((await network.command({ operation: 'status' })).subscribers, 1);
|
|
126
|
+
const closing = network; network = undefined;
|
|
127
|
+
await closing.close();
|
|
128
|
+
await streamClosed;
|
|
129
|
+
assert.deepEqual(await fs.readFile(hostsFile), originalHosts, 'DNS cleanup restores the exact prior hosts bytes');
|
|
130
|
+
assert.equal(serviceCa(), originalServiceCa, 'Native service-manager trust environment is restored');
|
|
131
|
+
assert.equal(process.env.NODE_EXTRA_CA_CERTS, originalProcessCa, 'Caller trust environment remains owned by the caller');
|
|
132
|
+
assert.equal((await nativeRequest({ hostname: '127.0.0.1', servername: canonicalHost, path: selectedPath })).error,
|
|
133
|
+
'ECONNREFUSED', 'The privileged HTTPS listener exits with its view');
|
|
134
|
+
const untrusted = await nativeRequest(leafAddress);
|
|
135
|
+
assert.match(untrusted.error ?? '', /^(UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT_LOCALLY|CERT_UNTRUSTED|CERT_REJECTED|SELF_SIGNED_CERT_IN_CHAIN|DEPTH_ZERO_SELF_SIGNED_CERT)$/,
|
|
136
|
+
`The retired disposable CA must no longer authorize its supplied chain: ${JSON.stringify(untrusted)}`);
|
|
137
|
+
if (process.platform === 'darwin') {
|
|
138
|
+
assert.throws(() => run('security', nativeEvaluation),
|
|
139
|
+
error => Number.isInteger(error.status) && error.status > 0 && error.signal === null,
|
|
140
|
+
'The same native SSL policy invocation must reject the chain it previously accepted');
|
|
141
|
+
}
|
|
142
|
+
} catch (error) { failures.push(error); }
|
|
143
|
+
finally {
|
|
144
|
+
// Cleanup has its own verdict; it must not erase the initiating failure or
|
|
145
|
+
// prevent the other owned resources from being released.
|
|
146
|
+
for (const close of [
|
|
147
|
+
() => streamRequest?.destroy(),
|
|
148
|
+
() => network?.close(),
|
|
149
|
+
async () => {
|
|
150
|
+
if (leafServer) { leafServer.closeAllConnections(); await new Promise(resolve => leafServer.close(resolve)); }
|
|
151
|
+
},
|
|
152
|
+
() => fs.rm(temporary, { recursive: true, force: true }),
|
|
153
|
+
]) {
|
|
154
|
+
try { await close(); } catch (error) { failures.push(error); }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (failures.length === 1) throw failures[0];
|
|
158
|
+
if (failures.length) throw new AggregateError(failures, 'Native transport examination and cleanup failed');
|
|
159
|
+
});
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amalgm/shell",
|
|
3
|
-
"version": "0.1.164-preview.
|
|
3
|
+
"version": "0.1.164-preview.34192807991",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@amalgm/shell",
|
|
9
|
-
"version": "0.1.164-preview.
|
|
9
|
+
"version": "0.1.164-preview.34192807991",
|
|
10
10
|
"license": "UNLICENSED",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@amalgm/agents": "0.2.3-preview.34176069706",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amalgm/shell",
|
|
3
|
-
"version": "0.1.164-preview.
|
|
3
|
+
"version": "0.1.164-preview.34192807991",
|
|
4
4
|
"description": "The Amalgm runtime host assembled from the published @amalgm SDK capabilities.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -151,11 +151,11 @@
|
|
|
151
151
|
}
|
|
152
152
|
]
|
|
153
153
|
},
|
|
154
|
-
"gitHead": "
|
|
154
|
+
"gitHead": "f61742639720d43804c6c19aa14c2bdda763a78d",
|
|
155
155
|
"amalgmSource": {
|
|
156
156
|
"repository": "amalgm-inc/amalgm-shell",
|
|
157
157
|
"branch": "preview",
|
|
158
|
-
"commit": "
|
|
159
|
-
"occurrence": "
|
|
158
|
+
"commit": "f61742639720d43804c6c19aa14c2bdda763a78d",
|
|
159
|
+
"occurrence": "34192807991"
|
|
160
160
|
}
|
|
161
161
|
}
|