What's Changed
Added
addSsl.upstreamCertValidationcontrols how the OS reverse proxy validates the container's TLS certificate when it rewraps SSL (addSslset AND the protocol'ssecure.ssl === true, e.g.https/wss— the OS terminates the client's TLS and opens a fresh TLS connection to the container). Omitted (the default) validates against the StartOS root CA, unchanged from before.'disable'skips validation entirely — for containers serving a self-signed cert on the trusted internal bridge.{ certificate: '<pem>' }validates against a supplied PEM certificate/chain instead of the root CA. Set it throughbindPort'saddSsl, e.g.multi.bindPort(443, { protocol: 'https', addSsl: { upstreamCertValidation: 'disable' } }). Exported asUpstreamCertValidation- Init progress reporting via
FullProgressTracker(auto-syncing). Service code never calls a progress effect — and usually never callssync()either. The init harness builds one rootFullProgressTrackerwith the effects context baked in and passes it to every init handler as a third argument:setupOnInit(async (effects, kind, progress) => …). Each handler adds its own phases (with its own names) to the shared tracker, unaware of the others. Add phases and update them (phase.setTotal/setDone/complete); every update auto-reports in the background to the "Installing" / "Updating" phase of the install. Auto-sync is coalesced — at most one report in flight and one queued, so a burst of updates collapses to the latest snapshot and promises never stack up.tracker.sync()is now an explicit flush (no args) that resolves once in-flight + queued reports have drained; the harness calls it before returning. Migrations receive the same tracker viamigrations.up/down/otheropts (async ({ effects, progress }) => …). A handler that didn't keep whataddPhase/addNestedPhasereturned can fetch it back by name withtracker.getPhase(name)(aPhaseHandleor, for a nested phase, aFullProgressTracker).FullProgressTrackeris exported fromutils(utils.FullProgressTracker). The underlyingeffects.setInitProgress/effects.setBackupProgressremain but are internal - Backup/restore progress mirrors the same structure.
Backups.createBackupandrestoreBackupbuild an auto-syncingFullProgressTrackerinstead of calling the effect directly. The pre/post backup and restore hooks (setPreBackup,setPostBackup,setPreRestore,setPostRestore) now receive aFullProgressTrackeras a second argument so custom work (DB dumps, etc.) can report sub-progress; restore progress flows through the init tracker since restore runs during init. Existing hooks that ignore the new argument keep working PgDumpConfig.readyTimeout/MysqlDumpConfig.readyTimeoutmake the post-start readiness wait inBackups.withPgDump/Backups.withMysqlDumpconfigurable (milliseconds, matching thegracePeriod/sigtermTimeoutconvention; defaults 60000 Postgres / 30000 MySQL/MariaDB — unchanged from before). Each dump helper boots a throwaway DB server against the volume and polls until it accepts connections; a large or crash-recovering data directory can exceed the old hard-coded ceiling and fail the backup with "failed to become ready within N seconds" even though the live daemon — which gets a far longergracePeriod— starts fine. Raise it to match, e.g.withMysqlDump({ …, readyTimeout: 180000 })userspaceFilesystemsandvirtualNetworkingmanifest flags split the formernestedRuntimeflag into its two independent device grants.userspaceFilesystemsmounts/dev/fusefor fuse-overlayfs storage (the rootless driver behind a nested OCI runtime).virtualNetworkingmounts/dev/net/tunso a service can bring up kernel tun interfaces for VPN / WireGuard / tun-class workloads. The service LXC already retainsCAP_NET_ADMINwithin its user namespace via the standarduserns.confinclude, so no extra capability machinery is required — only the device node was missingSharedOptions.idmapon volume / asset / dependency / backup mounts is now functional (the field was previously declared but inert). Each entry is{ fromId, toId, range? }— maprangeconsecutive ids (default1) from filesystem idfromId(u) to mountpoint idtoId(k) — so a mount can present files under the uid/gid the service expects regardless of how they're stored on the host volume. The container's own LXC id-mapping is applied automatically and must not be included here. End-to-end support requires the StartOS 0.4.0-beta.10 host (7.0.7-backports kernel): the inner bind now runs through a new in-LXCstart-container mount(open_tree(OPEN_TREE_CLONE)+mount_setattr(MOUNT_ATTR_IDMAP)+move_mount) rather thanmount --bind -oX-mount.idmap=…, andSubContainer.bind()uses the same path. The standaloneIdMapbinding is removed (folded intoMountTarget.idmap) and the host-sideIdMap::stackworkaround (which produced overlapping uid_map ranges on 6.x) is gone. The publiceffects.mount(...)signature is unchanged. Fixes #3248MultiHost.bindPortRange({ internalStartPort, externalStartPort, numberOfPorts })reserves a contiguous TCP+UDP port range in a single call, intended for real-time / WebRTC servers (coturn, RTP, SIP) that need a public range. The whole range is allocated atomically; any partial collision with already-bound external ports is a hard error (no shifted-range fallback). A range is 2–500 ports; for a single port usebindPort.externalStartPortmay differ frominternalStartPort: the forward maps the external range onto the internal range by offset (port-preserving when the two bases are equal). Returns aRangeOrigin, on which.export(sdk.createRangeInterface(effects, { id, name, description, scheme? }))registers the range's single, restrictedapiservice interface — no SSL and nomasked/username/path/query/schemeOverride. A range exposes exactly one interface (distinct endpoints are separatebindPortRangecalls); the optionalschemeis a transport prefix (e.g.tcpfor bitcoin ZMQ endpoints) that most ranges (coturn RTP, FTP data) omit. The OS persists the range as a singleRangeBindInforecord underHost.binding_ranges(not N entries inHost.bindings), carrying the exported interface and installing one nft rule per chain covering the whole range (PortForwardgains acountfield, defaulting to 1 for back-compat). Backed by the effectseffects.bindRange(...)andeffects.exportRangeServiceInterface(...)(requires StartOS with the matching backend handlers — landed in this PR). Fixes #3269sdk.Daemons.dynamic(fn)builds a reactivemainentrypoint whose daemon set is a function of on-disk state. The supplied builder returns a regularsdk.Daemons.of({ effects }).addDaemon(...)chain (now record-then-materialize — see below), and the SDK diffs its entries against the running set on everyeffects.constRetrytrigger (typically fired by aFileHelper.read().const(effects)watcher). Per id: absent → present start, present → absent stop, sameconfigHashleave alone, differentconfigHashrestart. Dependents of any restarted or stopped daemon are also restarted to keeprequireswiring consistent. Re-runs coalesce while one is in flight. Designed for multi-tenant packages like the registry-portal s9pk that add, rename, and delete sub-instance daemons without restarting the serviceconfigHashcovers the subcontainer descriptor (imageId,sharedRun,name, structuralmounts.build()), exec (command,env,cwd,user,runAsInit,sigtermTimeout),requires(sorted), and the structural parts ofready(display,gracePeriod). Closures (ready.fn,ready.trigger, function-formexec.fn) and pre-builtDaemoninstances are intentionally excluded — surface a value through one of the hashed fields if you want the reconciler to react to it changingsdk.SubContainer.eager(...)creates a SubContainer with its filesystem materialized immediately. Use when you needcreateFsfailures to surface at the construction site instead of at first method call, or when you need sync access torootfs/guid/subpath()before running any methodsSubContainerLazy.eager(): Promise<SubContainerEager<M>>forces materialization on a lazy handle and returns the underlying eager subcontainer for callers that need the narrowed sync interface- New compile-time validator
ValidateExVerRange<T>validates exver version-range string literals across the full range grammar (comparison / caret / tilde anchors over[#flavor:]upstream[:downstream]specs,&&/||, parentheses, negation,#flavoratoms, and*), catching a malformed version atom anywhere in the expression. It now guards the two places a range literal is written: a dependency'sversionRange(viasdk.setupDependencies) and the keys ofmigrations.other, so typos like'>=2.f'or'^28 || 30.x:0'fail attsctime instead of at runtime. Purely additive types — runtime parsing is unchanged, and structural mistakes (unbalanced parens, a dangling operator) are still left to runtime validation - Actions gain an optional
accessfield on the metadata passed toAction.withInput/Action.withoutInput—'public' | 'dependent' | 'user', defaulting to'user'when omitted. It controls who may invoke the action directly viaeffects.action.run({ packageId, actionId, input }):'public'— any installed package;'dependent'— only services that declare this package as a current dependency;'user'— only the user (other services musteffects.action.createTask(...)). Direct cross-package runs against an action whose access denies the caller are rejected. Direct runs still go through the owning service and honor the action'svisibility(disabled) andallowedStatuseschecks detach()onSubContainer,Daemon, andDaemons(plus the object returned byDaemons.dynamic(...).build()) severs the instance from the effects context it was created under, so that context leaving (onLeaveContext) no longer terminates the daemon(s) or destroys the subcontainer(s). This is the escape hatch for the now-deterministic context cleanup (see Fixed): a short-lived context — typically an action — can build aDaemonschain (or a loneDaemon/SubContainer),detach()it, and let it outlive the RPC that started it. ADaemontakes ownership of its subcontainer's teardown at construction (detaching it from the per-context cleanup net so only the daemon'sterm()— stop process, then destroy — can tear it down), so detaching the daemon/chain only governs its own self-term. After detaching, the caller owns the lifecycle and must callterm()(ordestroy()) explicitly. Idempotent- The published package now ships
s9pk.mkandtsconfig.base.jsonat its root, so a package can consume the canonical build plumbing by reference instead of vendoring copies: itsMakefiledoesinclude node_modules/@start9labs/start-sdk/s9pk.mkand itstsconfig.jsondoes"extends": "@start9labs/start-sdk/tsconfig.base.json". Bumping the SDK then delivers build-system fixes automatically, ending the per-package drift of these files. Thes9pk.mkinstall/publishtargets resolve the deploy host and registry throughstart-cli(the packaging-workspace.startos/config.yamlhost/registryprofiles, or-H/-r) instead of parsing~/.startos/config.yamldirectly, so the plumbing works with thes9pk init-workspacemodel out of the box
Changed
-
Breaking —
sdk.SubContainer.of(...)is now lazy by default. Returns aSubContainerLazy<M>synchronously (noPromise<>wrapper) whose filesystem is materialized on first method call.rootfs/guid/subpath()on the unifiedSubContainer<M>interface widen toT | Promise<T>; the concrete classes narrow (SubContainerEagertoT,SubContainerLazytoPromise<T>). Code holding the generic interface mustawaitthose accessors; code holdingSubContainerEagerkeeps sync access. Migration: most callers already use only async methods (.exec,.writeFile,.spawn,.launch) and need no change beyond dropping the redundantawaitonSubContainer.of(...); callers that read.rootfs/.guid/.subpath()add anawaitor switch tosdk.SubContainer.eager(...). The lazy default enablesDaemons.dynamic's "leave alone is load-bearing" guarantee: unchanged daemons across reconciles never materialize a fresh subcontainer -
Breaking —
SubContainerOwned/SubContainerRccollapsed intoSubContainerEager. The reference-counted handle (SubContainerRc,.rc(),.isOwned()) is replaced by an internal hold-count on the unifiedSubContainer. Multiple consumers each callsub.hold()(returns a release fn); the container'sdestroyFsfires whendestroy()has been called and the last hold is released, in either order.Daemon.start()takes its own hold for the daemon's lifetime and releases it onterm(). ThedestroySubcontainerflag onDaemon.term/HealthDaemon.termis gone —Daemons.term()callsdestroy()on each unique subcontainer in its entries, and the hold machinery handles sharing safely -
Breaking —
Daemon.sharesSubcontainerWithremoved. TwoDaemons share a subcontainer when constructed with the sameSubContainerinstance (comparedaemon.subcontainer.identity). Daemons' internal "should I destroy this subc?" branching is gone — it always callsdestroy(), and hold-count decides -
Breaking —
Daemonsis record-then-materialize..addDaemon()/.addOneshot()/.addHealthCheck()append a recorded entry without constructingHealthDaemonorDaemon.Daemons.build()walks the entries and constructs the chain in one pass. Functionally identical forsetupMaincallers — the original "construct on addDaemon, kick off on build" timing is invisible because nothing actually starts untilbuild()'supdateStatus()calls anyway. The change is load-bearing forDaemons.dynamic, which needs to diff entries without paying the cost of building them eagerly each reconcile -
Breaking —
SubContaineraddsidentity: symbol. Sync, stable handle preserved acrossSubContainerLazy.eager()materialization. Use for sharing checks that must work before materialization (replacesDaemon.sharesSubcontainerWith's previousguidcomparison, which couldn't fire pre-materialization) -
Container-runtime updated:
SubContainerOwned→SubContainer.eager;SubContainerRc<M>→SubContainer<M>;daemon.subcontainerRc()→daemon.subcontainer -
zod bumped
4.3.6→4.4.3(it was emergency-pinned to exactly4.3.6in 1.4.1). zod 4.4.0 tightened object parsing so a required key wrapped in.catch()threw"expected nonoptional, received undefined"on a missing key — which broke the SDK at import (actions/input/inputSpecConstants.tsparses its SMTP shapes at module load) and the.merge({})seed pattern that every FileHelper-based package relies on. zod 4.4.3 (#5939, #5941) restores the 4.3.6 behavior, so the pin is exactly4.4.3— not^4.4.0, which would admit the still-broken 4.4.0–4.4.2. ThezExportloose-object patch and FileHelper are unaffected (verified against the shipped build). Downstream: packages taking SDK 2.0 inherit zod 4.4.3 transitively and need no schema change for the catch-on-missing case; the other cumulative 4.4.0 fixes (stricterz.base64()/z.httpUrl(),.merge()throwing on schemas with refinements, materialized tuple defaults) may surface astscerrors only in packages that use those specific APIs -
TypeScript bumped
^5.9.3→^6.0.3, and the build migrated off the deprecatedmoduleResolution: "node"(node10). TS 6.0 deprecates node10 resolution (removed entirely in TS 7.0). The SDK's ownbase/packagetsconfigs now usemoduleResolution: "bundler"(keepingmodule: "commonjs"— emitted CJS is byte-identical and the published.d.tsAPI surface is unchanged) plus an explicitrootDir(TS 6.0 changed the default to the tsconfig directory). The shippedtsconfig.base.jsonthat packagesextendsnow usestarget: "ES2022",moduleResolution: "bundler"+module: "preserve",types: ["node"], androotDir: "${configDir}", so a package needs onlyextends+includeand nothing else: node globals are not auto-included under bundler resolution; the priorES2018target predatedObject.fromEntriesand other ES2019+ lib APIs packages rely on; and TS 6.0 requires an explicitrootDirfor thenccemit (the${configDir}template resolves to each extending package's own directory). Putting the fleet on modern resolution before TS 7, no SDK source changes were required -
Breaking —
input-not-matchestask input split intoaccept(a list) andset.TaskInputwas{ kind: 'partial', value }, where the singlevalueboth decided whether the task was satisfied (the current action input had to be a superset of it) and prefilled the action form when the user ran the task. It is now{ kind: 'partial', accept: DeepPartial<Input>[], set: DeepPartial<Input> }: the task is satisfied when the current input matches any entry inaccept, and when none match the task is shown and prefillsset. This lets a package accept several already-good configurations while still pushing a single recommended value when none of them hold — e.g. accept either of two valid network modes, but set the preferred one otherwise. The cross-package critical-conflict guard now activates only when the input conflicts with everyacceptentry. Migration: replacevalue: Xwithaccept: [X], set: Xfor identical behavior to 1.x. Already-published s9pks built against the pre-2.0 SDK keep working without a rebuild — the StartOS host still accepts the legacy{ kind: 'partial', value }payload over the effects socket and normalizes it toaccept: [value], set: value -
Breaking (internal) — zod loose-object handling unified onto a single
zexport; the globalrequire.cachepatch was removed. The SDK makesz.object()preserve unknown keys (soFileHelpermodels that declare only a subset of a config file round-trip the rest). This was previously done by two mechanisms: a shadowz(the one exported from@start9labs/start-sdk) plus a walk over Node'srequire.cachethat mutated the rawzodmodule so the SDK's own internals — whichimport { z } from 'zod'— also got loose objects. The cache walk was fragile (it duck-typed the module cache and silently no-op'd outside CommonJS). It is removed: the SDK's internal modules now importzfrom the shadow directly, andFileHelper's structured factories (json/yaml/toml/ini/env/xml) now deep-loosen their shape explicitly viaz.deepLoose(shape), making unknown-key preservation a property of the file-model boundary rather than a global default. Packaging code is unaffected:import { z } from '@start9labs/start-sdk'still yields loose-by-default objects and every package's file models keep preserving undeclared on-disk keys. The only behavioral change is for code that importedzdirectly fromzodand relied on the SDK to have patched it — it must importzfrom@start9labs/start-sdkinstead. No StartOS packaging code does this (only vendored upstream application sources importzoddirectly, and they use their own copy) -
Breaking — service interfaces moved onto their binding;
sdk.serviceInterface.*accessors replaced bysdk.host.*. A service interface is no longer a flat entry onPackageDataEntry.serviceInterfaces(that field is removed) — it now lives on the binding that exported it:Host.bindings[internalPort].interfaces(aServiceInterfaceId-keyed map; a port may back several) for single-portOrigin.export, andHost.bindingRanges[internalStartPort].interfacefor a range'sRangeOrigin.export. Correspondingly the SDK dropssdk.serviceInterface.{getOwn, get, getAllOwn, getAll}and addssdk.host.getOwn(effects, hostId)/sdk.host.get(effects, { hostId, packageId? }), which return the reactiveHost(sameconst/once/watch/onChange/waitForread strategies). Both accessors take an optionalmap(andeq, default deep-equal) selector — matching the oldsdk.serviceInterface.*— soconst()can re-run on a change to a single child attr of the host rather than wholesale on the entire host. Reach an interface by walking the host —Object.values(host.bindings).flatMap(b => Object.values(b.interfaces))(orhost.bindingRanges[start].interface). Each single-port interface'saddressInfocomes back already filled — carrying thefilter/format/nonLocal/public/bridge/toUrlhelpers directly (e.g.iface.addressInfo.format('url')), so no separateutils.filledAddress(host, …)call is needed. The win: a binding's host is now reachable even when it exports no interface.
Fixed
- Every materialized
SubContaineris now torn down when the effects context that created it leaves (onLeaveContext), instead of lingering until GC eventually runs itsDropfinalizer. This closes the gap where a subcontainer created inmain(or any context) but never attached to a daemon — e.g. an ad-hoc setup/bootstrap container — could outlive its context. One cleanup hook is armed per effects object and each subcontainer removes itself ondestroy(), so repeated short-lived containers (withTemp, per-poll health checks) don't accumulate registrations; teardown still routes through the hold-awaredestroy(), so a container held by a running daemon defers until the daemon's own shutdown releases it filledAddressnow excludes mDNS (.local) addresses whose gateways have no enabled LAN IP. An mDNS name resolves only via a LAN IP on a shared gateway, so when every such IP is disabled the.localaddress is unreachable — it was previously still reported as available, which let the StartOS UI offer (and launch) an unresolvable.localURL even though the address table showed it disabled. The rule is now exported asutils.mdnsResolvable(hostname, enabledHostnames), shared between the SDK's reachable-address filter and the UI's address table so the two stay consistent
Removed
- Breaking —
nestedRuntimemanifest flag removed, with no compatibility alias. It conflated two unrelated device grants (/dev/fusefor fuse-overlayfs storage and/dev/net/tunfor kernel tun interfaces). Replace withuserspaceFilesystems(nested OCI runtimes) and/orvirtualNetworking(kernel tun interfaces). Packages must republish: a host updating StartOS parses an oldnestedRuntimefield as absent, so both new flags default tofalse - Breaking — package
alertsmanifest field removed, with no compatibility alias. Packages can no longer defineinstall/update/uninstall/restore/start/stopalert messages (the confirmation prompts StartOS showed before those lifecycle actions). Drop thealertsblock fromsetupManifest; a host updating StartOS parses any leftoveralertsfield as absent and StartOS no longer surfaces these prompts. Built-in confirmations for destructive actions (uninstalling, stopping a service with active dependents) are unaffected SubContainerOwned,SubContainerRc,SubContainer.rc(),SubContainer.isOwned()— folded into the unifiedSubContainerEager/SubContainerLazywith hold/release lifecycleDaemon.subcontainerRc(),Daemon.markManaged(),Daemon.sharesSubcontainerWith()— superseded bydaemon.subcontainer(public readonly) and the hold-count modeldestroySubcontaineroption onDaemon.term/HealthDaemon.term—Daemonscallssubcontainer.destroy()for each unique subc on shutdown, and the hold-count decides actual timing- Breaking —
sdk.serviceInterface.{getOwn, get, getAllOwn, getAll}andPackageDataEntry.serviceInterfaces— interfaces moved onto their binding; usesdk.host.{getOwn, get}and walk the host's bindings (see Changed). The underlyingutils.getServiceInterface/utils.getServiceInterfaceshelpers and theGetServiceInterfacereader class are removed with them —sdk.host.get/getOwnreturns a host whose interfaceaddressInfos are already filled (filter/format/nonLocal/public/bridge/toUrl). Theget_service_interfaceRPC/effect is retained for backwards compatibility with packages built against older SDKs