From 5069aedb75543ed9c378e3dbb67c4e9e7ae8ecfd Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 3 Sep 2026 21:31:01 -0400 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20MSIX=20registers=20HermesGatew?= =?UTF-8?q?ay=20=E2=80=94=20desktop6:Service=20manifest=20extension?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 2 of the gateway-as-MSIX-service plan (settled: user-context, 1903 floor, config-only demand-start): - before-build.mjs serviceExtensions(): the desktop6:Service fragment — Executable = the payload launcher hermes.exe (the same distlib PE serving the AppExecutionAliases; gateway run --service is the SCM frontend — NO shim binary), Name=HermesGateway, StartupType=demand (config-only posture: arrives stopped; `hermes gateway service on` flips to automatic-at-logon), StartAccount omitted (desktop6 default = installing user's context; localSystem explicitly rejected). light + store variants render service-less (store policy is the plan's open risk item). Rides the existing generated build/msix-extensions.xml via customExtensionsPath — same mechanism as the aliases + copilot-key fragments. - gen-msix-manifest.mjs: minVersion 10.0.17763 → 10.0.18362 (Win10 1903, the desktop6:Service requirement; settled support-matrix bump — 1809 is a 2018 OS; rides the release notes). - Verified: the REAL rendered manifest carries the service block (gen-msix-manifest bundled x64 → desktop6:Service Name=HermesGateway Executable=...hermes.exe); the staged fragment regenerates with it (caught + fixed a serviceFragment→ serviceExtensions name bug live via the real beforeBuild import). A flat-dir makeappx probe fails identically WITH and WITHOUT the fragment (the probe harness lacks signing identity) — the honest full validation is the win32 CI lane's real signed pack. - tests: 4 serviceExtensions contract tests (category/name/exe/ demand-start/one-block/xmlns-root/light-store-empty/name-prop) in cli-launchers.test.mjs — 15/15 pass; full scripts/ suite: my files green (1 pre-existing darwin-staging failure is the sibling agent's uncommitted territory). --- apps/desktop/scripts/before-build.mjs | 43 ++++++++++++++++++++- apps/desktop/scripts/cli-launchers.test.mjs | 43 +++++++++++++++++++++ apps/desktop/scripts/gen-msix-manifest.mjs | 7 +++- 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/apps/desktop/scripts/before-build.mjs b/apps/desktop/scripts/before-build.mjs index acb4d5b14c..9dd0ba3856 100644 --- a/apps/desktop/scripts/before-build.mjs +++ b/apps/desktop/scripts/before-build.mjs @@ -102,12 +102,53 @@ function writeMsixExtensions() { -${aliases}` +${aliases}${serviceExtensions()}` fs.mkdirSync(path.dirname(file), { recursive: true }) fs.writeFileSync(file, copilot) } +/** + * The desktop6:Service fragment registering HermesGateway as a Windows + * Service (plan: gateway-msix-windows-service; Task 2). + * + * The service Executable is the payload's own launcher (hermes.exe — + * the distlib-minted PE that already serves the AppExecutionAliases): + * its `gateway run --service` mode is the SCM frontend + * (gateway/windows_service.py). NO separate shim binary. + * + * Settled decisions baked here (2026-09-03, ethie): user-context + * service (StartAccount omitted — desktop6 defaults to the installing + * user's context; localSystem explicitly rejected), demand StartupType + * (config-only posture: the service arrives STOPPED; `hermes gateway + * service on` flips it to automatic-at-logon). + * + * Content rules (the 0x80080204 playbook): xmlns:desktop6 rides the + * fragment root; ONE extension block; Executable must name a real in- + * package exe. Requires Win10 1903+ — the minVersion bump in + * gen-msix-manifest.mjs matches. Exported pure for tests. + */ +export function serviceExtensions({ + name = 'HermesGateway', + executable = ['app', 'resources', 'agent-payload', 'bin', 'hermes.exe'].join(String.fromCharCode(92)), + variant = 'bundled', +} = {}) { + // light variant ships no payload → no service; the store variant is + // decided by Store policy separately (plan risk item) — both render + // service-less for now. + if (variant === 'light' || variant === 'store') return '' + return ` + + +` +} + /** * One uap5:Extension block per payload CLI launcher, each naming its own * Executable (the distlib-minted launcher exes under bin/) and the alias diff --git a/apps/desktop/scripts/cli-launchers.test.mjs b/apps/desktop/scripts/cli-launchers.test.mjs index d569cfeda4..446c5344a8 100644 --- a/apps/desktop/scripts/cli-launchers.test.mjs +++ b/apps/desktop/scripts/cli-launchers.test.mjs @@ -115,3 +115,46 @@ test('light variant emits no alias fragments', () => { assert.ok(appExecutionAliasExtensions(['hermes']).includes('windows.appExecutionAlias')) assert.ok(scriptDir.length > 0) }) + +// ── HermesGateway Windows Service fragment (Task 2, plan: +// gateway-msix-windows-service) ──────────────────────────────────────── +import { serviceExtensions } from './before-build.mjs' + +test('bundled variant registers HermesGateway, demand-start, launcher exe', () => { + const frag = serviceExtensions() + assert.ok(frag.includes('Category="windows.service"'), 'service category') + assert.ok(frag.includes('Name="HermesGateway"'), 'the service name the CLI verbs key off') + assert.ok(frag.includes('StartupType="demand"'), 'config-only posture: arrives stopped') + // Compose the expected path the same way the source does — no escaping + // ambiguity (the appExecutionAliasExtensions precedent). + const bs = String.fromCharCode(92) + const launcherPath = ['app', 'resources', 'agent-payload', 'bin', 'hermes.exe'].join(bs) + assert.ok( + frag.includes(`Executable="${launcherPath}"`), + 'the service Executable is the payload launcher (no shim binary)' + ) + assert.ok(!frag.includes('StartAccount'), 'user-context by default — localSystem rejected') +}) + +test('light and store variants render service-less', () => { + assert.equal(serviceExtensions({ variant: 'light' }), '') + assert.equal(serviceExtensions({ variant: 'store' }), '') +}) + +test('one desktop6 extension block with xmlns on the fragment root', () => { + const frag = serviceExtensions() + assert.equal( + (frag.match(/ { + const frag = serviceExtensions({ name: 'HermesGateway_Tag1' }) + assert.ok(frag.includes('Name="HermesGateway_Tag1"')) +}) diff --git a/apps/desktop/scripts/gen-msix-manifest.mjs b/apps/desktop/scripts/gen-msix-manifest.mjs index da1a5972ce..798dc36d22 100644 --- a/apps/desktop/scripts/gen-msix-manifest.mjs +++ b/apps/desktop/scripts/gen-msix-manifest.mjs @@ -105,8 +105,11 @@ const manifest = substituteManifestMacros(template, (m) => { case "resourceLanguages": return resourceLanguageTag(options.languages) case "capabilities": return `\n${buildCapabilitiesXml(options.capabilities)}\n` case "extensions": return extensions - case "minVersion": return options.minVersion || "10.0.17763.0" - case "maxVersionTested": return options.maxVersionTested || options.minVersion || "10.0.17763.0" + // Win10 1903 (build 18362) floor: desktop6:Service — the MSIX-shipped + // HermesGateway Windows Service — requires 1903+; settled 2026-09-03. + // 1809 is a 2018 OS; the bump rides the release notes. + case "minVersion": return options.minVersion || "10.0.18362.0" + case "maxVersionTested": return options.maxVersionTested || options.minVersion || "10.0.18362.0" case "packageIntegrity": return "" default: throw new Error(`Macro ${m} is not defined`) }