@synopackageland/cli 0.19.0 → 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 +13 -0
- package/README.md +13 -0
- package/dist/cli.js +9 -3
- package/dist/deploy/native-install-values.d.ts +3 -3
- package/dist/deploy/native-install-values.js +21 -7
- package/dist/error-registry.js +1 -0
- package/dist/runtime/approval-diff.d.ts +37 -0
- package/dist/runtime/approval-diff.js +56 -0
- package/dist/runtime/executor-digests.json +1 -13
- package/dist/standalone/compose-errors.js +3 -3
- package/dist/standalone/compose.d.ts +6 -3
- package/dist/standalone/compose.js +6 -4
- package/dist/standalone/inspect-spk.js +12 -4
- package/dist/standalone/lifecycle.js +2 -1
- package/dist/standalone/manifest.js +4 -3
- package/dist/standalone/root-window.js +6 -1
- package/package.json +2 -2
- package/skills/SKILL.md +2 -0
- package/skills/reference/approval-upgrades.md +36 -0
- package/skills/reference/compose.md +56 -3
- package/skills/reference/deploy.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
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
|
+
|
|
10
|
+
## 0.19.1 (2026-10-20)
|
|
11
|
+
|
|
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.
|
|
13
|
+
- Compare upgrade approval requirements with a baseline SPK: `build --baseline` and `inspect --baseline` report root program digests, policy differences, task template changes and executor-only handoff eligibility.
|
|
14
|
+
- Retain templates 0.19.0 (SDK 0.19.0) and UI 0.12.5.
|
|
15
|
+
|
|
3
16
|
## 0.19.0 (2026-10-20)
|
|
4
17
|
|
|
5
18
|
- Align the CLI SDK minor line and templates at 0.19.0; retain UI 0.12.5.
|
package/README.md
CHANGED
|
@@ -119,6 +119,19 @@ window's approval waits for it, while a status read answers `executor_starting`
|
|
|
119
119
|
SDK's panels check again on their own. The program cannot declare `run` or `job`, and
|
|
120
120
|
declaring it changes the policy digest, so an administrator approves the App again.
|
|
121
121
|
|
|
122
|
+
Before releasing an executor App, compare it with the previous released SPK of
|
|
123
|
+
that package and architecture. Include the resulting summary in release notes:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
synopkgland build --arch x86_64 --baseline previous.spk --json
|
|
127
|
+
synopkgland inspect current.spk --baseline previous.spk
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The summary covers root program digests, permission/parameter differences, task
|
|
131
|
+
template changes and executor-only handover eligibility. Without a comparable
|
|
132
|
+
baseline, approval retention is unknown. Eligibility still requires live protected
|
|
133
|
+
runtime and exact DSM task readback. See [the approval guide](skills/reference/approval-upgrades.md).
|
|
134
|
+
|
|
122
135
|
## Commands
|
|
123
136
|
|
|
124
137
|
| Command | Purpose |
|
package/dist/cli.js
CHANGED
|
@@ -33,6 +33,7 @@ import { buildNativeLayout, isNativeLayout, loadNativeLayout, LAYOUT_ARCHES, } f
|
|
|
33
33
|
import { buildStandalone, runnerFor } from "./standalone/build.js";
|
|
34
34
|
import { checkJobs, commitOutputs, forEachArch, } from "./standalone/arch-jobs.js";
|
|
35
35
|
import { checkReproducible } from "./reproducible.js";
|
|
36
|
+
import { spkApprovalDiff, formatApprovalDiff } from "./runtime/approval-diff.js";
|
|
36
37
|
import { inspectAnySpk } from "./spk/inspect.js";
|
|
37
38
|
function failure(code, message, hint = "Fix the configuration and retry; use --help to see command usage.") {
|
|
38
39
|
return Object.assign(new Error(codedMessage(code, message)), { code, hint });
|
|
@@ -389,16 +390,18 @@ export async function runCli(argv) {
|
|
|
389
390
|
});
|
|
390
391
|
program
|
|
391
392
|
.command("inspect <spk>")
|
|
393
|
+
.option("--baseline <spk>", "Compare approval requirements with the previous SPK")
|
|
392
394
|
.description("Inspect any SPK against the DSM 7 package format without installing; for a synopkgland-built SPK also check the build contract and root privilege requirements")
|
|
393
395
|
.option("--json", "Print a single JSON object")
|
|
394
|
-
.action(async (input) => {
|
|
396
|
+
.action(async (input, options) => {
|
|
395
397
|
const spk = resolve(input);
|
|
396
398
|
if (existsSync(spk) && statSync(spk).isDirectory())
|
|
397
399
|
throw failure("SPK_INSPECTION_FAILED", `${spk} is a directory; inspect checks .spk files.`, "Use synopkgland check to inspect project settings.");
|
|
398
400
|
const report = await inspectAnySpk(spk);
|
|
399
401
|
if (!report.dsm.ok || report.findings.length)
|
|
400
402
|
throw Object.assign(failure("SPK_INSPECTION_FAILED", formatSpkChecks(report).join("\n"), "Fix the SPK by error code and repackage; see reference/errors for the codes; do not install an SPK that fails inspection."), { report: { spk, ...report } });
|
|
401
|
-
|
|
403
|
+
const approval = await spkApprovalDiff(spk, options.baseline && resolve(options.baseline), report);
|
|
404
|
+
emit("inspect", { spk, ...report, approval }, formatSpkReport(spk, report) + "\n" + formatApprovalDiff(approval));
|
|
402
405
|
});
|
|
403
406
|
const images = program.command("images").description("Container image tools");
|
|
404
407
|
images
|
|
@@ -624,6 +627,7 @@ export async function runCli(argv) {
|
|
|
624
627
|
skill.command("show <id>").action((id) => stdout(readSkill(id)));
|
|
625
628
|
program
|
|
626
629
|
.command("build")
|
|
630
|
+
.option("--baseline <spk>", "Compare approval requirements with the previous SPK (one matching architecture)")
|
|
627
631
|
.description("Build an SPK from synology-app.yaml or dsm-package.yaml")
|
|
628
632
|
.option("--json", "Print a single JSON object")
|
|
629
633
|
.option("-o, --output <path>", "Output SPK; use with a single --arch")
|
|
@@ -684,13 +688,15 @@ export async function runCli(argv) {
|
|
|
684
688
|
outDir: undefined,
|
|
685
689
|
}),
|
|
686
690
|
});
|
|
691
|
+
const approval = await Promise.all(outputs.map(async (output) => ({ output, ...await spkApprovalDiff(output, options.baseline && resolve(options.baseline)) })));
|
|
687
692
|
const lines = [
|
|
693
|
+
...approval.map(item => item.output + "\n" + formatApprovalDiff(item)),
|
|
688
694
|
...outputs.map((output) => `Built ${output}`),
|
|
689
695
|
...(reproducible ? reproducibleLines(reproducible) : []),
|
|
690
696
|
];
|
|
691
697
|
if (reproducible && !reproducible.identical)
|
|
692
698
|
throw Object.assign(failure("BUILD_NOT_REPRODUCIBLE", lines.join("\n"), "Find the payload files that differ between builds (timestamps, random paths, unpinned dependencies) and retry after fixing them."), { report: { outputs, reproducible } });
|
|
693
|
-
emit("build", { outputs, ...(reproducible ? { reproducible } : {}) }, lines.join("\n"));
|
|
699
|
+
emit("build", { outputs, approval, ...(reproducible ? { reproducible } : {}) }, lines.join("\n"));
|
|
694
700
|
});
|
|
695
701
|
if (json)
|
|
696
702
|
console.log = (...args) => console.error(...args);
|
|
@@ -8,9 +8,9 @@ export type NativeInstallValuesResult = {
|
|
|
8
8
|
message: string;
|
|
9
9
|
};
|
|
10
10
|
/**
|
|
11
|
-
* Install wizard values for
|
|
12
|
-
* delegate asks the wizard for
|
|
13
|
-
*
|
|
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
|
|
10
|
-
* delegate asks the wizard for
|
|
11
|
-
*
|
|
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
|
|
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
|
-
|
|
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))
|
package/dist/error-registry.js
CHANGED
|
@@ -11,6 +11,7 @@ export const ERROR_REGISTRY = {
|
|
|
11
11
|
CTL_DESCRIBE_FAILED: { "code": "CTL_DESCRIBE_FAILED", "description": "The backend registry export failed.", "suggestion": "Check the backend --describe output and build diagnostics." },
|
|
12
12
|
CTL_COMMAND_UNREGISTERED: { "code": "CTL_COMMAND_UNREGISTERED", "description": "A command references an unregistered backend method.", "suggestion": "Correct ctl.commands bindings or register the backend method." },
|
|
13
13
|
CTL_DESCRIPTOR_INVALID: { "code": "CTL_DESCRIPTOR_INVALID", "description": "The generated API description violates the public contract.", "suggestion": "Correct ctl.commands and rebuild the private synopkgland-api.json." },
|
|
14
|
+
APPROVAL_BASELINE_INVALID: { code: "APPROVAL_BASELINE_INVALID", description: "The --baseline SPK failed inspection, so approval retention cannot be compared.", suggestion: "Pass the previous released SPK of the same package and architecture, unmodified; inspect it to see each finding." },
|
|
14
15
|
CTL_DECLARATION_INVALID: { "code": "CTL_DECLARATION_INVALID", "description": "The ctl declaration is invalid.", "suggestion": "Check ctl.enabled, aliases and explicit commands in dsm-package.yaml." },
|
|
15
16
|
...{
|
|
16
17
|
LAUNCHER_PORTAL_MISMATCH: {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ExecutorPolicy } from './executor-policy.js';
|
|
2
|
+
import { type SpkReport } from '../spk/inspect.js';
|
|
3
|
+
export interface ApprovalInventory {
|
|
4
|
+
package: string;
|
|
5
|
+
arch: string;
|
|
6
|
+
executorDigest: string;
|
|
7
|
+
policyDigest: string;
|
|
8
|
+
anchored: boolean;
|
|
9
|
+
policy: ExecutorPolicy;
|
|
10
|
+
tasks: Array<{
|
|
11
|
+
kind: string;
|
|
12
|
+
name: string;
|
|
13
|
+
script: string;
|
|
14
|
+
}>;
|
|
15
|
+
}
|
|
16
|
+
export interface ApprovalDifference {
|
|
17
|
+
retention: 'unknown' | 'eligible' | 'required';
|
|
18
|
+
permissions: 'unknown' | 'unchanged' | 'expanded' | 'review-required';
|
|
19
|
+
executor_only: boolean;
|
|
20
|
+
task_template_changed: boolean | null;
|
|
21
|
+
old_policy_digest?: string;
|
|
22
|
+
new_policy_digest?: string;
|
|
23
|
+
root_programs: Array<{
|
|
24
|
+
name: string;
|
|
25
|
+
old: string | null;
|
|
26
|
+
new: string | null;
|
|
27
|
+
}>;
|
|
28
|
+
policy_changes: Array<{
|
|
29
|
+
path: string;
|
|
30
|
+
old: unknown;
|
|
31
|
+
new: unknown;
|
|
32
|
+
}>;
|
|
33
|
+
reason: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function approvalDiff(old: ApprovalInventory | undefined, next: ApprovalInventory | undefined): ApprovalDifference;
|
|
36
|
+
export declare function spkApprovalDiff(current: string, baseline?: string, report?: SpkReport): Promise<ApprovalDifference>;
|
|
37
|
+
export declare function formatApprovalDiff(d: ApprovalDifference): string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { inspectAnySpk } from '../spk/inspect.js';
|
|
2
|
+
function canonical(value) {
|
|
3
|
+
if (Array.isArray(value))
|
|
4
|
+
return '[' + value.map(canonical).join(',') + ']';
|
|
5
|
+
if (value && typeof value === 'object')
|
|
6
|
+
return '{' + Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => JSON.stringify(k) + ':' + canonical(v)).join(',') + '}';
|
|
7
|
+
return JSON.stringify(value) ?? 'null';
|
|
8
|
+
}
|
|
9
|
+
function authority(policy) {
|
|
10
|
+
const p = structuredClone(policy);
|
|
11
|
+
p.root = Object.fromEntries(Object.entries(policy.root).map(([name, entry]) => [name, typeof entry === 'string' ? {} : Object.fromEntries(Object.entries(entry).filter(([key]) => key !== 'digest'))]));
|
|
12
|
+
return p;
|
|
13
|
+
}
|
|
14
|
+
function changes(a, b, path = '', out = []) {
|
|
15
|
+
if (canonical(a) === canonical(b))
|
|
16
|
+
return out;
|
|
17
|
+
if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a) && !Array.isArray(b)) {
|
|
18
|
+
for (const key of [...new Set([...Object.keys(a), ...Object.keys(b)])].sort())
|
|
19
|
+
changes(a[key], b[key], path + '/' + key.replaceAll('~', '~0').replaceAll('/', '~1'), out);
|
|
20
|
+
}
|
|
21
|
+
else
|
|
22
|
+
out.push({ path, old: a ?? null, new: b ?? null });
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
const rootDigests = (p) => Object.fromEntries(Object.entries(p.root).map(([name, e]) => [name, typeof e === 'string' ? e : e.digest]));
|
|
26
|
+
export function approvalDiff(old, next) {
|
|
27
|
+
const unknown = { retention: 'unknown', permissions: 'unknown', executor_only: false, task_template_changed: null, root_programs: [], policy_changes: [], reason: 'Cannot determine whether approval can be retained: a valid, matching baseline SPK and executor inventory are required.' };
|
|
28
|
+
if (!old || !next || old.package !== next.package || old.arch !== next.arch)
|
|
29
|
+
return unknown;
|
|
30
|
+
const a = rootDigests(old.policy), b = rootDigests(next.policy);
|
|
31
|
+
const root_programs = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort().filter(name => a[name] !== b[name]).map(name => ({ name, old: a[name] ?? null, new: b[name] ?? null }));
|
|
32
|
+
const policy_changes = changes(authority(old.policy), authority(next.policy));
|
|
33
|
+
const expanded = next.policy.operations.some(op => !old.policy.operations.includes(op)) || Object.keys(next.policy.root).some(name => !(name in old.policy.root));
|
|
34
|
+
const templates = (i) => i.tasks.map(t => ({ kind: t.kind, name: t.name, script: t.script.replaceAll(i.policyDigest, '@POLICY_DIGEST@') })).sort((a, b) => a.kind < b.kind ? -1 : 1);
|
|
35
|
+
const task_template_changed = canonical(templates(old)) !== canonical(templates(next));
|
|
36
|
+
const eligible = old.policyDigest === next.policyDigest && !task_template_changed && (old.executorDigest === next.executorDigest || next.anchored);
|
|
37
|
+
return { retention: eligible ? 'eligible' : 'required', permissions: expanded ? 'expanded' : policy_changes.length ? 'review-required' : 'unchanged', executor_only: eligible && old.executorDigest !== next.executorDigest, task_template_changed, old_policy_digest: old.policyDigest, new_policy_digest: next.policyDigest, root_programs, policy_changes, reason: eligible ? 'Approval can be retained if the installed protected runtime and exact DSM task readback still match this baseline.' : 'Administrator reapproval is required for the changed payload, policy, executor anchor or task definitions.' };
|
|
38
|
+
}
|
|
39
|
+
function inventory(r) {
|
|
40
|
+
if (!r.dsm.ok || r.findings.length || !r.executor || !r.package || !r.arch)
|
|
41
|
+
return;
|
|
42
|
+
return { package: r.package, arch: r.arch, ...r.executor, anchored: true }; // inspect verifies the INFO anchor and every root payload digest.
|
|
43
|
+
}
|
|
44
|
+
export async function spkApprovalDiff(current, baseline, report) {
|
|
45
|
+
if (!baseline)
|
|
46
|
+
return approvalDiff(undefined, undefined);
|
|
47
|
+
const next = inventory(report ?? await inspectAnySpk(current));
|
|
48
|
+
const old = await inspectAnySpk(baseline);
|
|
49
|
+
if (!old.dsm.ok || old.findings.length)
|
|
50
|
+
throw new Error('[APPROVAL_BASELINE_INVALID] Baseline SPK failed inspection: ' + [...old.findings, ...old.dsm.errors.map(e => e.message)].join('; '));
|
|
51
|
+
return approvalDiff(inventory(old), next);
|
|
52
|
+
}
|
|
53
|
+
export function formatApprovalDiff(d) {
|
|
54
|
+
const short = (s) => s?.slice(0, 12) ?? '—';
|
|
55
|
+
return ['Approval: ' + d.reason, 'Permissions: ' + d.permissions, ...d.root_programs.map(p => `Root program ${p.name}: ${short(p.old)} → ${short(p.new)}`), ...d.policy_changes.map(c => `Policy ${c.path}: ${JSON.stringify(c.old)} → ${JSON.stringify(c.new)}`), `Task template: ${d.task_template_changed === null ? 'unknown' : d.task_template_changed ? 'changed' : 'unchanged'}`, `Executor-only handover: ${d.executor_only ? 'eligible under the INFO anchor' : 'not established'}`].join('\n');
|
|
56
|
+
}
|
|
@@ -1,13 +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
|
-
}
|
|
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: "
|
|
216
|
-
suggestion: "
|
|
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: "
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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:
|
|
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 &&
|
|
@@ -359,9 +359,14 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
359
359
|
// here, and that first status serves the health check. Approval and
|
|
360
360
|
// control read back on their own.
|
|
361
361
|
function checked(status) { return pinned(status) && !!status.tasks && status.tasks.state === "ready"; }
|
|
362
|
+
function approvalExplanation(verified, status) {
|
|
363
|
+
var diff = status && status.approval;
|
|
364
|
+
if (verified.state === "needs-admin-approval" && diff && options.pins && diff.package === options.pins.package && diff.new_policy_digest === options.pins.policyDigest && diff.new_executor_digest === options.pins.executorDigest && (diff.old_policy_digest !== diff.new_policy_digest || diff.task_template_changed)) verified.approval = diff;
|
|
365
|
+
return verified;
|
|
366
|
+
}
|
|
362
367
|
function adminStatus() {
|
|
363
368
|
var asked = options.call("executor.status", {});
|
|
364
|
-
function verify() { return read().then(function (verified) { return health(verified, 0, asked); }); }
|
|
369
|
+
function verify() { return read().then(function (verified) { return health(verified, 0, asked); }).then(function (verified) { return asked.then(function (status) { return approvalExplanation(verified, status); }, function () { return verified; }); }); }
|
|
365
370
|
return asked.then(function (status) {
|
|
366
371
|
return checked(status) ? {state: "ready", tasks: {boot: {state: "ready"}, control: {state: "ready"}}, executor: status} : verify();
|
|
367
372
|
}, verify);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synopackageland/cli",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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",
|
package/skills/SKILL.md
CHANGED
|
@@ -5,6 +5,8 @@ description: Initialize, extend, check, build, inspect, and deploy Synology appl
|
|
|
5
5
|
|
|
6
6
|
# Independent Synology SPKs
|
|
7
7
|
|
|
8
|
+
Before releasing an executor App, read [reference/approval-upgrades.md](reference/approval-upgrades.md). Run build/inspect with the previous released SPK as `--baseline` and include the approval difference in release notes. Without a comparable baseline, retention is unknown.
|
|
9
|
+
|
|
8
10
|
Use this guide to initialize, build and inspect own applications or upstream OSS as independent packages. 依已實作能力區分 DSM session 檔案、私有 jobs 與 App 登入;delegate 已實作並隨 CLI 提供固定操作的離線委託(預設 `files.moveFromStaging`);共用 delegate/rootOps executor 的完整裝置生命週期驗收仍待完成,不得宣稱已通過;支援邊界依 delegate guide/support matrix,禁止發明新的身分平台。
|
|
9
11
|
|
|
10
12
|
For an existing open-source App, first read [reference/upstream-fork.md](reference/upstream-fork.md): upstream fork/history + untouched upstream files + all packaging in `synology/`, official container images pinned by tag/index digest, native official release binaries pinned by SHA-256 or a source build hook, and a single tag-merge upgrade command. Use `init --upstream-fork`; never rewrite the App as a component. Integrated upstream Apps require direct DSM administrator entry without a separate App password or an OIDC/SSO server: supported trusted headers first, disabling native login second; the shipped OpenList pattern uses the existing SDK 0.17.3 token adapter (`mode: token`, `access: dsm-admins`, `entry: admin-only`) with server-side Authorization injection. All DSM admins share one upstream admin; token reset requires package restart; WebDAV retains its native credentials.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Approval changes before release
|
|
2
|
+
|
|
3
|
+
Run the current CLI against the previous released SPK for the same package and
|
|
4
|
+
architecture before releasing an executor App:
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
synopkgland build --arch x86_64 --baseline previous-x86_64.spk --json
|
|
8
|
+
synopkgland inspect current-x86_64.spk --baseline previous-x86_64.spk
|
|
9
|
+
synopkgland inspect current-x86_64.spk --baseline previous-x86_64.spk --json
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`inspect` returns `data.approval`; `build` returns one entry per output in
|
|
13
|
+
`data.approval`. Put the human summary in release notes. Optionally copy that
|
|
14
|
+
same summary into the manifest's `metadata.changelog`, which the existing build writes to INFO for Package Center.
|
|
15
|
+
Do not assume DSM automatically opens an App after upgrading it.
|
|
16
|
+
|
|
17
|
+
The summary names root programs and their old/new SHA-256 prefixes, policy
|
|
18
|
+
permission/parameter paths, and task template changes. Task template comparison
|
|
19
|
+
removes only the policy pin; a policy pin change still requires approval.
|
|
20
|
+
`unchanged` means the policy excluding root digests is identical. Added root
|
|
21
|
+
programs or operations are `expanded`; other permission/parameter changes are
|
|
22
|
+
`review-required`, because arbitrary schema and scope changes cannot generally
|
|
23
|
+
be proven to narrow permissions. Program bytes can change behavior even when
|
|
24
|
+
the declared policy is unchanged.
|
|
25
|
+
|
|
26
|
+
`eligible` is conditional: the live protected runtime must still match the
|
|
27
|
+
baseline and DSM must read back the exact approved tasks. An executor-only
|
|
28
|
+
handover additionally needs the new executor's valid INFO anchor. This offline
|
|
29
|
+
summary does not grant approval. With no baseline, an incompatible package or
|
|
30
|
+
architecture, or no executor inventory, retention is `unknown`; never promise
|
|
31
|
+
that approval will be retained. Invalid baselines fail inspection.
|
|
32
|
+
|
|
33
|
+
The SDK approval screen reads root-produced diagnostics through executor.status,
|
|
34
|
+
then uses the existing PasswordConfirm and exact task readback. Older protected
|
|
35
|
+
runtimes without diagnostic support show an explicit unknown explanation. No App
|
|
36
|
+
option supplies an approved baseline or permission claim.
|
|
@@ -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 |
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|