feat(desktop): MSIX registers HermesGateway — desktop6:Service manifest extension

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).
This commit is contained in:
ethernet
2026-09-03 21:31:01 -04:00
parent f236039c3f
commit 5069aedb75
3 changed files with 90 additions and 3 deletions

View File

@@ -102,12 +102,53 @@ function writeMsixExtensions() {
</uap3:Properties>
</uap3:AppExtension>
</uap3:Extension>
${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 `<desktop6:Extension
xmlns:desktop6="http://schemas.microsoft.com/appx/manifest/desktop/windows10/6"
Category="windows.service"
Executable="${executable}"
EntryPoint="Windows.FullTrustApplication">
<desktop6:Service
Name="${name}"
StartupType="demand" />
</desktop6:Extension>
`
}
/**
* One uap5:Extension block per payload CLI launcher, each naming its own
* Executable (the distlib-minted launcher exes under bin/) and the alias

View File

@@ -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(/<desktop6:Extension/g) || []).length,
1,
'the 0x80080204 playbook: ONE extension block'
)
assert.ok(
frag.includes('xmlns:desktop6="http://schemas.microsoft.com/appx/manifest/desktop/windows10/6"'),
'namespace rides the fragment root (the copilot-fragment precedent)'
)
})
test('custom name propagates (per-install namespacing)', () => {
const frag = serviceExtensions({ name: 'HermesGateway_Tag1' })
assert.ok(frag.includes('Name="HermesGateway_Tag1"'))
})

View File

@@ -105,8 +105,11 @@ const manifest = substituteManifestMacros(template, (m) => {
case "resourceLanguages": return resourceLanguageTag(options.languages)
case "capabilities": return `<Capabilities>\n${buildCapabilitiesXml(options.capabilities)}\n</Capabilities>`
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`)
}