@synopackageland/cli 0.19.1 → 0.20.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.0 (2026-10-21)
4
+
5
+ - Align CLI/templates and the supported SDK minor line at 0.20; retain published UI 0.12.5.
6
+ - Compare upgrade approval requirements with `build/inspect --baseline`.
7
+ - Package native Compose shared-folder mounts with `select: install` and pass host health readiness to the daemon.
8
+ - Pin the 0.20.0 executor/lifecycle digest inventory and refresh canonical approval fixtures. Rebuilt executor Apps require administrator reapproval after upgrade.
9
+
3
10
  ## 0.19.1 (2026-10-20)
4
11
 
5
12
  - Record SDK 0.19.1 executor and lifecycle digests for the executor fix that accepts `deadline_ms` on delegated `webapi.call`; SDK 0.19.0 digests remain. Executor Apps rebuilt with SDK 0.19.1 require one administrator reapproval after upgrading.
@@ -8,9 +8,9 @@ export type NativeInstallValuesResult = {
8
8
  message: string;
9
9
  };
10
10
  /**
11
- * Install wizard values for a native-go/binary deploy. Only an install-selected
12
- * delegate asks the wizard for anything; preinst refuses an empty selection on
13
- * the first install, so refuse it here before building or uploading.
11
+ * Install wizard values for native and Compose deployment. An install-selected
12
+ * delegate or install.sharedFolder asks the wizard for a selection. Reject
13
+ * missing first-install answers before building or uploading.
14
14
  */
15
15
  export declare function resolveNativeInstallValues(manifest: StandaloneManifest, provided: Record<string, string>, state: {
16
16
  installed: boolean;
@@ -6,20 +6,34 @@ function validShareName(name) {
6
6
  !/^homes?$/i.test(name) && name.trim() === name && name.length <= 256);
7
7
  }
8
8
  /**
9
- * Install wizard values for a native-go/binary deploy. Only an install-selected
10
- * delegate asks the wizard for anything; preinst refuses an empty selection on
11
- * the first install, so refuse it here before building or uploading.
9
+ * Install wizard values for native and Compose deployment. An install-selected
10
+ * delegate or install.sharedFolder asks the wizard for a selection. Reject
11
+ * missing first-install answers before building or uploading.
12
12
  */
13
13
  export function resolveNativeInstallValues(manifest, provided, state) {
14
14
  const fail = (code, message) => ({ ok: false, code, message });
15
15
  const selected = manifest.capabilities.delegate?.scope === "install-selected";
16
+ const folder = manifest.sharedFolder?.mode === "select" ? manifest.sharedFolder : undefined;
17
+ const key = folder?.key;
18
+ const changeKey = key ? `CHANGE_${key}` : undefined;
16
19
  const names = Object.keys(provided);
17
- if (!selected && names.length)
20
+ if (!selected && !folder && names.length)
18
21
  return fail("DEPLOY_PARAM_UNDECLARED", `${manifest.package} has no install wizard values; remove --param ${names[0]}.`);
19
- const unknown = names.find((name) => name !== "delegate_share" && name !== "delegate_home");
22
+ const unknown = names.find((name) => !(selected && (name === "delegate_share" || name === "delegate_home")) && name !== key && name !== changeKey);
20
23
  if (unknown)
21
- return fail("DEPLOY_PARAM_UNDECLARED", `--param ${unknown} is not an install wizard value. Use: ${USAGE}.`);
22
- const { delegate_share: share, delegate_home: home } = provided;
24
+ return fail("DEPLOY_PARAM_UNDECLARED", `--param ${unknown} is not an install wizard value. Use: ${folder ? `--param ${folder.key}=<shared folder>` : USAGE}.`);
25
+ if (key && changeKey) {
26
+ const name = provided[key], change = provided[changeKey];
27
+ if (change !== undefined && change !== "true" && change !== "false")
28
+ return fail("DEPLOY_PARAM_TYPE_INVALID", `--param ${changeKey} must be true or false.`);
29
+ if (name !== undefined && (name === "" || /[/\\"\x00-\x1f\x7f]/.test(name) || name.includes("..") || /^[.@]/.test(name)))
30
+ return fail("DEPLOY_PARAM_TYPE_INVALID", `--param ${key} must be one DSM Shared Folder name, never a path.`);
31
+ if ((!state.installed || change === "true") && name === undefined)
32
+ return fail("DEPLOY_PARAM_REQUIRED", `Select the native Shared Folder with --param ${key}=<shared folder>.`);
33
+ if (state.installed && name !== undefined && change !== "true")
34
+ return fail("DEPLOY_PARAM_REQUIRED", `Changing the selected folder requires --param ${changeKey}=true and --param ${key}=<shared folder>.`);
35
+ }
36
+ const share = selected ? provided.delegate_share : undefined, home = selected ? provided.delegate_home : undefined;
23
37
  if (home !== undefined && home !== "true" && home !== "false")
24
38
  return fail("DEPLOY_PARAM_TYPE_INVALID", "--param delegate_home must be true or false.");
25
39
  if (share !== undefined && !validShareName(share))
@@ -1,24 +1 @@
1
- {
2
- "0.19.0": {
3
- "x86_64": "3b23252a4d2ad188e8920aee3c022d054a85f31797dfb9f34a576449f8b08f41",
4
- "aarch64": "024a8a01977324645b475330371afb8b8c8387eac6a11ec3d9d30a4a0e445174",
5
- "armv7": "fae3266f65863815f1417332a3c73d12507941029077e0ddc118da8461e8cff3",
6
- "lifecycle": {
7
- "x86_64": "9bb53301670b43cdcd6e21cebac99ab7abd82962d874f32423a81e6cb49d4972",
8
- "aarch64": "38fe873c86eb102c850baa1f304114a83a31d1df72defd22792bc37610fec9bb",
9
- "armv7": "256fb348b275da7ac5728a3cb7cb7c65660de0d5c2c59430ab4f5ade1b0a3681"
10
- },
11
- "goToolchain": "go1.26.9"
12
- },
13
- "0.19.1": {
14
- "x86_64": "e27cfeb85fed221b06f9c58166f02c0f701a724308cd17c96a43a843a0b5c4aa",
15
- "aarch64": "908db00cbd37d072f6ffc339e7bca24da5efb8cddd4befcbad02c7398beb7191",
16
- "armv7": "a0f35d62ec80a1bf3fb49f5e2d1a5f7576eda569a6fd0695084cec6cb8e100bc",
17
- "lifecycle": {
18
- "x86_64": "9bb53301670b43cdcd6e21cebac99ab7abd82962d874f32423a81e6cb49d4972",
19
- "aarch64": "38fe873c86eb102c850baa1f304114a83a31d1df72defd22792bc37610fec9bb",
20
- "armv7": "256fb348b275da7ac5728a3cb7cb7c65660de0d5c2c59430ab4f5ade1b0a3681"
21
- },
22
- "goToolchain": "go1.26.9"
23
- }
24
- }
1
+ { "0.20.0": { "x86_64": "e27cfeb85fed221b06f9c58166f02c0f701a724308cd17c96a43a843a0b5c4aa", "aarch64": "908db00cbd37d072f6ffc339e7bca24da5efb8cddd4befcbad02c7398beb7191", "armv7": "a0f35d62ec80a1bf3fb49f5e2d1a5f7576eda569a6fd0695084cec6cb8e100bc", "lifecycle": { "x86_64": "9bb53301670b43cdcd6e21cebac99ab7abd82962d874f32423a81e6cb49d4972", "aarch64": "38fe873c86eb102c850baa1f304114a83a31d1df72defd22792bc37610fec9bb", "armv7": "256fb348b275da7ac5728a3cb7cb7c65660de0d5c2c59430ab4f5ade1b0a3681" }, "goToolchain": "go1.26.9" } }
@@ -212,13 +212,13 @@ export const COMPOSE_ERRORS = {
212
212
  },
213
213
  COMPOSE_SHARE_INVALID: {
214
214
  code: "COMPOSE_SHARE_INVALID",
215
- description: "Shares.the declared value needs admin selection, ro/rw and fixed mounts.",
216
- suggestion: "Keep the project disabled until an official protected mount/project adapter is verified; never use a destination grant or host root workaround.",
215
+ description: "Shared Folder selection, permission or fixed mount declaration is invalid.",
216
+ suggestion: "For select: install, match install.sharedFolder mode: select, key and permission; choose an existing unambiguous folder. Dynamic admin-after-install remains blocked. Never use a destination grant or host root workaround.",
217
217
  },
218
218
  COMPOSE_SHARE_NEEDS_APPROVAL: {
219
219
  code: "COMPOSE_SHARE_NEEDS_APPROVAL",
220
220
  description: "Declare capabilities.composeMounts: true; user destination grants cannot authorize package mounts.",
221
- suggestion: "Keep the project disabled until an official protected mount/project adapter is verified; never use a destination grant or host root workaround.",
221
+ suggestion: "Declare the package-wide composeMounts opt-in. Native install selection requires DSM package-user permission; dynamic admin-after-install remains blocked.",
222
222
  },
223
223
  COMPOSE_SOURCE_UNSAFE: {
224
224
  code: "COMPOSE_SOURCE_UNSAFE",
@@ -1,6 +1,7 @@
1
1
  import { type ComposeAppData } from "../runtime/compose-data.js";
2
2
  import type { NativeArchFamily } from "../build-spk/dsm-arch.js";
3
3
  import { type Daemon } from "./daemon.js";
4
+ import type { SharedFolder } from "./shared-folder.js";
4
5
  import type { StandaloneError, StandaloneManifest } from "./manifest.js";
5
6
  export interface ComposePlan {
6
7
  app_data?: ComposeAppData;
@@ -77,6 +78,7 @@ export declare function readComposeApp(value: unknown, root: string, arches: Nat
77
78
  sdk?: string;
78
79
  mode?: string;
79
80
  composeMounts?: boolean;
81
+ sharedFolder?: SharedFolder;
80
82
  resolveImages?: boolean;
81
83
  architecture?: NativeArchFamily;
82
84
  imageProofs?: unknown;
@@ -85,12 +87,12 @@ export declare function composeDaemon(m: Pick<StandaloneManifest, "compose" | "l
85
87
  export declare function composeNetwork(m: Pick<StandaloneManifest, "compose" | "identityGateway" | "spkVersion">): {
86
88
  public_ports: number[];
87
89
  readiness?: {
88
- version: string;
89
- plan_sha256: string;
90
90
  health: {
91
91
  path: string;
92
92
  status: number;
93
93
  } | undefined;
94
+ version?: string | undefined;
95
+ plan_sha256?: string | undefined;
94
96
  } | undefined;
95
97
  ingress?: {
96
98
  candidate: number;
@@ -101,7 +103,7 @@ export declare function composeNetwork(m: Pick<StandaloneManifest, "compose" | "
101
103
  export declare function composeSource(c: ComposeApp): string;
102
104
  /** Retain upstream bytes, the true override and a lock without secret values. */
103
105
  export declare function writeComposePayload(c: ComposeApp, root: string, target: string, gateway?: unknown): void;
104
- /** Only package identity values are materialized, in the incoming tree. */
106
+ /** Materialize fixed package identity and native wizard share names in the incoming tree. */
105
107
  export declare function renderComposeInstall(pkg: string): string;
106
108
  /** The same SDK entry point supplies the package-account install materializer. */
107
109
  export declare function buildComposeControl(m: StandaloneManifest, arch: NativeArchFamily, work: string, run: import("./build.js").CommandRunner): Promise<string | undefined>;
@@ -118,3 +120,4 @@ export declare function dsmComposeProblem(c: ComposeApp): {
118
120
  message: string;
119
121
  } | undefined;
120
122
  export declare const COMPOSE_READINESS_LIMITATION = "Availability only: App health + fixed endpoint TCP + package lifecycle acquire generation matching the installed version. This does not attest the actual Docker image, project or generation. Another process already executing locally on the NAS may impersonate the fixed endpoint. Accepted for this local-code-execution threat model; replace with full official readback when available.";
123
+ export declare const COMPOSE_HOST_READINESS_LIMITATION = "Host availability only: package UID and loopback listener bindings, plus declared App HTTP health when configured. This does not attest the actual Docker image, project or generation.";
@@ -93,6 +93,7 @@ export function readComposeApp(value, root, arches, fail, context = {}) {
93
93
  package: context.package ?? "ComposeApp",
94
94
  identity_mode: context.mode ?? "none",
95
95
  compose_mounts: context.composeMounts === true,
96
+ ...(context.sharedFolder?.mode === "select" ? { install_shared_folder: context.sharedFolder } : {}),
96
97
  resolve_images: context.resolveImages === true,
97
98
  registry_mirrors: context.resolveImages &&
98
99
  process.env.SYNOPKGLAND_COMPOSE_REGISTRY_MIRRORS
@@ -171,11 +172,10 @@ export function composeNetwork(m) {
171
172
  ...(c.plan.ingress
172
173
  ? { ingress: { candidate: c.plan.ingress.candidate } }
173
174
  : {}),
174
- ...(!host && c.web && !c.plan.mounts_blocked
175
+ ...(c.web && (!host || c.web.health) && !c.plan.mounts_blocked
175
176
  ? {
176
177
  readiness: {
177
- version: m.spkVersion,
178
- plan_sha256: c.plan.sha256,
178
+ ...(!host ? { version: m.spkVersion, plan_sha256: c.plan.sha256 } : {}),
179
179
  health: c.web.health,
180
180
  },
181
181
  }
@@ -207,7 +207,7 @@ export function writeComposePayload(c, root, target, gateway) {
207
207
  }))
208
208
  writeFileSync(join(target, name), content, { mode: 0o600 });
209
209
  }
210
- /** Only package identity values are materialized, in the incoming tree. */
210
+ /** Materialize fixed package identity and native wizard share names in the incoming tree. */
211
211
  export function renderComposeInstall(pkg) {
212
212
  return `#!/bin/sh
213
213
  set -eu
@@ -268,6 +268,7 @@ export function lockComposeImages(m, arch, resolveImages, imageProofs) {
268
268
  sdk: m.compose.sdk,
269
269
  mode: m.identityGateway?.mode ?? "none",
270
270
  composeMounts: m.capabilities.composeMounts,
271
+ sharedFolder: m.sharedFolder,
271
272
  resolveImages,
272
273
  architecture: arch,
273
274
  imageProofs,
@@ -346,3 +347,4 @@ export function dsmComposeProblem(c) {
346
347
  };
347
348
  }
348
349
  export const COMPOSE_READINESS_LIMITATION = "Availability only: App health + fixed endpoint TCP + package lifecycle acquire generation matching the installed version. This does not attest the actual Docker image, project or generation. Another process already executing locally on the NAS may impersonate the fixed endpoint. Accepted for this local-code-execution threat model; replace with full official readback when available.";
350
+ export const COMPOSE_HOST_READINESS_LIMITATION = "Host availability only: package UID and loopback listener bindings, plus declared App HTTP health when configured. This does not attest the actual Docker image, project or generation.";
@@ -6,7 +6,7 @@ import { inspectApiDescription } from "./api-description-inspect.js";
6
6
  import { dsmLauncherType } from "../contract/app-launch.js";
7
7
  import { isWebLaunchMode, resolvedWebLaunch, trustedAppEntryPath, usesAuthGateway, } from "../contract/web-launch.js";
8
8
  import { loadContract } from "../contract/load.js";
9
- import { readComposeApp, composeSource, dsmComposeProblem, COMPOSE_READINESS_LIMITATION, } from "./compose.js";
9
+ import { readComposeApp, composeSource, dsmComposeProblem, COMPOSE_READINESS_LIMITATION, COMPOSE_HOST_READINESS_LIMITATION, } from "./compose.js";
10
10
  import { createHash } from "node:crypto";
11
11
  import { inspectNativeLayout } from "./native-layout.js";
12
12
  import { checkBridgeApp, checkBridgeExecutor } from "./inspect-bridge.js";
@@ -575,6 +575,7 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
575
575
  requiresAdminApproval: true,
576
576
  })),
577
577
  };
578
+ const appScripts = unwrapAppScripts(join(dir, "scripts"), findings);
578
579
  const root = join(dir, "unified-compose-inspection");
579
580
  mkdirSync(root);
580
581
  await tar.x({ file: join(dir, "package.tgz"), cwd: root });
@@ -596,6 +597,7 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
596
597
  sdk: saved.sdk,
597
598
  mode: cfg?.mode ?? "none",
598
599
  composeMounts: metadata.capabilities?.includes("composeMounts"),
600
+ sharedFolder: recoverSharedFolder(readOptional(join(dir, "scripts/postinst"))),
599
601
  architecture: arch,
600
602
  imageProofs: JSON.parse(readFileSync(join(root, "compose/images.json"), "utf8")),
601
603
  });
@@ -641,6 +643,7 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
641
643
  : {}),
642
644
  },
643
645
  compose: c,
646
+ sharedFolder: recoverSharedFolder(readOptional(join(dir, "scripts/postinst"))),
644
647
  ...(g ? { identityGateway: g } : {}),
645
648
  ...(metadata.entry ? { entry: metadata.entry } : {}),
646
649
  };
@@ -650,6 +653,11 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
650
653
  throw new Error("gateway configuration differs from the declared package contract");
651
654
  if (readOptional(join(dir, "conf/privilege")) !== renderNativePrivilege())
652
655
  throw new Error("Compose hooks must run as the package account");
656
+ if (manifest.sharedFolder) {
657
+ for (const [name, text] of Object.entries(expectedWizardFiles(info.package, manifest.sharedFolder)))
658
+ if (readOptional(join(dir, "WIZARD_UIFILES", name)) !== text)
659
+ throw new Error(`unexpected WIZARD_UIFILES/${name}; native share selection must match the declaration`);
660
+ }
653
661
  const resource = renderStandaloneResource(manifest);
654
662
  if (readOptional(join(dir, "conf/resource")) !== resource)
655
663
  throw new Error("unexpected worker resource; unapproved Shares must never auto-acquire");
@@ -671,7 +679,6 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
671
679
  if (readOptional(join(dir, "WIZARD_UIFILES", name)) !== text)
672
680
  throw new Error(`unexpected WIZARD_UIFILES/${name}; retained-volume guidance must match the plan`);
673
681
  }
674
- const appScripts = unwrapAppScripts(join(dir, "scripts"), findings);
675
682
  manifest.appScripts = undefined;
676
683
  for (const hook of [
677
684
  "preinst",
@@ -737,9 +744,10 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
737
744
  ? "none"
738
745
  : c.plan.services.find((s) => s.service === c.web?.service)
739
746
  ?.network === "host"
740
- ? "host-uid-listener"
747
+ ? c.web.health ? "host-uid-listener-health" : "host-uid-listener"
741
748
  : "health-fixed-ip-lifecycle",
742
- limitation: COMPOSE_READINESS_LIMITATION,
749
+ limitation: c.plan.services.find((s) => s.service === c.web?.service)?.network === "host"
750
+ ? COMPOSE_HOST_READINESS_LIMITATION : COMPOSE_READINESS_LIMITATION,
743
751
  },
744
752
  };
745
753
  }
@@ -33,7 +33,8 @@ function renderStandaloneScriptInternal(name, manifest) {
33
33
  const control = `"/var/packages/${manifest.package}/target/daemon/control"`;
34
34
  const paths = `"\${SYNOPKG_PKGDEST:-/var/packages/${manifest.package}/target}" "\${SYNOPKG_PKGVAR:-/var/packages/${manifest.package}/var}"`;
35
35
  const preflight = name === "start-stop-status"
36
- ? `case "$1" in prestart|start|restart)\n "\${SYNOPKG_PKGDEST:-/var/packages/${manifest.package}/target}/compose/control" preflight || exit $? ;;\nesac\n`
36
+ ? `case "$1" in prestart|start|restart)\n "\${SYNOPKG_PKGDEST:-/var/packages/${manifest.package}/target}/compose/control" preflight || exit $? ;;\nesac\n` +
37
+ (!manifest.identityGateway ? `case "$1" in prestart|prestop) exit 0 ;; esac\n` : "")
37
38
  : "";
38
39
  const remember = name === "postinst" &&
39
40
  manifest.compose.plan.ingress &&
@@ -240,12 +240,16 @@ function readManifest(raw, c) {
240
240
  c.fail("CTL_UNSUPPORTED_BACKEND", "Compose does not support automatic ctl descriptions; disable ctl.");
241
241
  if (isRecord(raw.compose) && isRecord(raw.compose.web) && !isRecord(raw.compose.web.health))
242
242
  c.fail("COMPOSE_HEALTH_REQUIRED", "Select compose.web.health.path and expected 2xx status for the actual App. Readiness does not follow redirects; check is static and does not probe HTTP.");
243
+ const sharedFolder = raw.install === undefined
244
+ ? undefined
245
+ : readSharedFolder(raw.install, (e) => c.fail(e.code, e.message));
243
246
  const compose = arches &&
244
247
  readComposeApp(raw.compose, c.root, arches, (e) => c.fail(e.code, e.message), {
245
248
  package: packageId,
246
249
  sdk: typeof gatewayRaw.sdk === "string" ? gatewayRaw.sdk : undefined,
247
250
  mode: typeof gatewayRaw.mode === "string" ? gatewayRaw.mode : "none",
248
251
  composeMounts: capabilities.composeMounts,
252
+ sharedFolder,
249
253
  });
250
254
  if (raw.compose !== undefined &&
251
255
  ((raw.daemon !== undefined && !timeoutOnly) ||
@@ -306,9 +310,6 @@ function readManifest(raw, c) {
306
310
  if (permissions)
307
311
  checkBridgeMethods(backend, permissions, c);
308
312
  }
309
- const sharedFolder = raw.install === undefined
310
- ? undefined
311
- : readSharedFolder(raw.install, (e) => c.fail(e.code, e.message));
312
313
  const entry = raw.entry === undefined
313
314
  ? undefined
314
315
  : raw.compose !== undefined &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@napi-rs/keyring": "^2.1.1",
30
- "@synopackageland/templates": "0.19.0",
30
+ "@synopackageland/templates": "0.20.0",
31
31
  "ajv": "^8.17.1",
32
32
  "commander": "^13.1.0",
33
33
  "json-canonicalize": "^3.0.1",
@@ -98,7 +98,7 @@ a build operator trust decision. `check`/`inspect` remain offline.
98
98
  | Private bind data | DSM build rejects COMPOSE_PRIVATE_BIND_TIMING_UNSUPPORTED: clean-install preinst precedes pkgvar creation, while worker acquire precedes postinst; no root initialization or automatic volume conversion |
99
99
  | Image VOLUME | Build inventories verified image config and assigns a stable named volume |
100
100
  | Existing external volume | Rejected without trusted admin ownership/driver readback |
101
- | Existing Shared Folder | Explicit opt-in intent only; blocked settings page until official protected ACL/project control exists |
101
+ | Existing Shared Folder | Native install selection plus DSM package-user ACL; dynamic admin-after-install authority stays blocked |
102
102
  | Bridge | Default no-publish dedicated internal bridge with a package-derived fixed IP; availability requires App health, TCP and the current lifecycle acquire record; no Docker identity attestation |
103
103
  | Host | Explicit verified OIDC web consumer with address_env forced to 127.0.0.1; no container-root; internet access is visible |
104
104
  | Egress | isolated internal networks, or explicit internet outbound bridge; fine allowlist declarations fail |
@@ -122,7 +122,48 @@ modification and deletion. DSM identity/account bridge and file authority are
122
122
  separate features. Per-DSM-user filesystem access requires a File Station session
123
123
  or Broker Adapter; it is outside this change and cannot be obtained through a mount.
124
124
 
125
- Declare `capabilities: {composeMounts: true}` and, for example:
125
+ For one existing Shared Folder, reuse the native install/upgrade wizard:
126
+
127
+ ```yaml
128
+ capabilities: {composeMounts: true}
129
+ install:
130
+ sharedFolder: {mode: select, key: media, permission: ro}
131
+ compose:
132
+ shares:
133
+ media:
134
+ select: install
135
+ # Omitted permission defaults to ro. For rw, set rw in both declarations.
136
+ mounts: [{service: web, target: /media}]
137
+ ```
138
+
139
+ The share key and permission must match `install.sharedFolder` exactly. This
140
+ route supports `mode: select`; `create` resource ordering is not supported.
141
+ The package-account preinst resolves the chosen name on `/volumeN` and writes
142
+ only the fixed source placeholder into the worker's private `.env`. Missing,
143
+ ambiguous or symlinked sources fail installation. Host paths are never wizard
144
+ answers. Every mounted service uses the fixed package UID:GID, drops all Linux
145
+ capabilities and has no-new-privileges. Container-root cannot mount Shares.
146
+
147
+ **Selecting an existing share does not grant permission.** An administrator must
148
+ grant the package account access in **Control Panel > Shared Folder > Edit >
149
+ Permissions > System internal user**. Until then, container file operations fail
150
+ under the kernel's ACL checks. No root helper, ACL writer or Docker socket is
151
+ given to the App. Grant directly to the package account; supplementary DSM groups
152
+ are not added to the container. See the SDK Shared Folder permission guide.
153
+
154
+ The upgrade wizard keeps the saved name unless its change checkbox is selected.
155
+ CLI deployment uses `--param media=Media` on first install, and
156
+ `--param CHANGE_media=true --param media=OtherMedia` to change it on upgrade.
157
+ Stop/disable and uninstall use DSM's official project worker; source data and
158
+ administrator-managed ACL entries are retained. A moved share needs a new
159
+ upgrade to resolve its volume path; start does not rematerialize the bind.
160
+
161
+ ACL changes affect subsequent filesystem operations. Already-open file
162
+ descriptors and data already read are not recalled. To stop existing streams,
163
+ stop the package before removing its access. This native route does not promise
164
+ automatic container termination or recreation on ACL changes.
165
+
166
+ For dynamic selection after install, declare the separate, blocked authority:
126
167
 
127
168
  ```yaml
128
169
  compose:
@@ -140,13 +181,25 @@ upstream docker-project is acquired. The independent authority purpose is
140
181
  `compose-mount`; a user destination grant from Download Hub/delegate never
141
182
  permits a package-wide mount.
142
183
 
143
- The current implementation does **not** issue a mount grant, invoke ACL tools,
184
+ The dynamic implementation does **not** issue a mount grant, invoke ACL tools,
144
185
  install a protected executor for Compose, or activate/revoke an upstream project.
145
186
  Confirmation returns `COMPOSE_PROJECT_CONTROL_UNAVAILABLE`. It must remain
146
187
  unsupported until an official ACL plus fixed project-control adapter can use
147
188
  protected policy/digest/generation authority, stop/recreate/read back and revoke
148
189
  without reviving stale grants. No app runs as host root to bypass this boundary.
149
190
 
191
+ | Acceptance blocker | Native install selection |
192
+ | --- | --- |
193
+ | `DSM_DOCKER_PROJECT_MODEL` | Uses the existing official docker-project worker; device acceptance still applies. |
194
+ | `DSM_STORAGE_OWNERSHIP_TIMING` | Existing shares need no package-created directory or chown; private package binds remain unsupported. |
195
+ | `DSM_EGRESS_BOUNDARY` | Unchanged independent network acceptance condition. |
196
+ | `COMPOSE_PROJECT_CONTROL_UNAVAILABLE` | Bypassed through native install/upgrade/stop/uninstall, without a dynamic controller. |
197
+ | `DSM_MOUNT_ACL_REVOKE` | Native kernel ACL semantics above; immediate stream cancellation remains outside this route. |
198
+
199
+ The first three general device acceptance labels remain in the plan. Dynamic
200
+ `admin-after-install` and `install-draft` keep all five blockers and
201
+ `mounts_blocked: true`; mixing dynamic authority with native selection is refused.
202
+
150
203
  ## Gateway transport
151
204
 
152
205
  `none` keeps native app login; `header`, `token` and embedded `oidc` use supported
@@ -5,7 +5,7 @@
5
5
  3. Review `synopkgland deploy --dry-run --arch x86_64`, which does not connect to a NAS.
6
6
  4. Only when installation is authorized, run `synopkgland deploy --host <profile>`.
7
7
 
8
- Deployment uploads directly to DSM. It does not publish artifacts. Compose uses schema-2 differences and fixed package identity. Existing Shared Folder intent requires admin confirmation after install; official protected ACL/project control is currently blocked. No install answer or user destination grant activates a package mount. See compose.md and its device acceptance blockers.
8
+ Deployment uploads directly to DSM. It does not publish artifacts. Compose uses schema-2 differences and fixed package identity. For `compose.shares` with `select: install`, use matching `install.sharedFolder: {mode: select, key: media, permission: ro}` and pass `--param media=Media`. Upgrade keeps the selection unless `--param CHANGE_media=true --param media=OtherMedia` is provided. An administrator grants the fixed package account access through DSM; selection is not a grant. Dynamic admin-after-install protected ACL/project control remains blocked. A user destination grant never activates a package mount. See compose.md and its device acceptance blockers.
9
9
 
10
10
  For native-go root-ops, approve fixed operations in DSM after installation. Updates that change the task text require fresh approval. Uninstall removes the executor tasks, the App's Application Privileges rules and a stale portal alias itself. Never delete installed packages or data as part of a code cleanup.
11
11