@synopackageland/cli 0.12.1 → 0.13.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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0 (2026-10-17)
4
+
5
+ - Add the identity gateway for daemon web applications and package-account compose upstreams: OIDC, header and token modes, validated native payload targets and bounded connection policies. UDP connections are handled separately from TCP.
6
+ - `account.on_delete` declares application account/session cleanup when a local DSM account disappears; domain accounts support explicit gateway purge. Bundle the `identity-gateway` skill for wrapping and verifying existing open-source applications.
7
+ - Generated Apps pin templates/SDK 0.13.0 and UI 0.10.5; record the 0.13.0 executor/lifecycle digests. Skill examples use the released SDK version. This CLI builds SDK 0.13.x; upgrade backend and frontend pins together.
8
+
9
+ 繁中摘要:daemon 與 package-account compose App 可宣告 identity gateway,以 DSM 身分透過 OIDC、header 或 token 登入,並限制原生 payload 目標與網路連線(區分 UDP/TCP)。`account.on_delete` 宣告本機 DSM 帳號刪除時的 App 帳號/session 清理;網域帳號支援明確 purge。新增 `identity-gateway` skill 說明既有 OSS App 的包裝及驗證。templates/SDK 同步 0.13.0、UI 0.10.5,更新 executor digest 與範例版本;此 CLI 建置 SDK 0.13.x,後端/前端 pin 應一併升級。
10
+
11
+ ## 0.12.2 (2026-10-16)
12
+
13
+ - Generated Apps use templates 0.12.2, SDK 0.12.2 and UI 0.10.3. SDK 0.12.2 types `ctx.launchParams` and `ctx.onLaunch` and adds `followLaunchRoute`, `launchRoute` and `dsmLaunchURL`.
14
+ - Pin the SDK 0.12.2 executor and lifecycle digests, identical to 0.12.1 for all three architectures.
15
+ - The DSM window forwards launch parameters to the App. `ctx.launchParams` holds the App's own string values from the launch that opened the window, from a desktop URL `?launchApp=SYNO.SDS.<package>.Instance&launchParam=<url-encoded key=value&...>` or `SYNO.SDS.AppLaunch(id, {key: value})`; `ctx.onLaunch(fn)` runs fn with those of each later launch of the open window. DSM's own keys, non-string values, keys outside `[A-Za-z][A-Za-z0-9_.-]{0,63}`, values over 2048 characters or with control characters, and keys past 32 are left out.
16
+ - `ctx.reopen` callbacks now run when DSM launches the already open window again (main menu, desktop URL or AppLaunch); DSM calls the window's onRequest then, never onOpen again.
17
+
18
+ 繁中摘要:新專案使用 templates 0.12.2、SDK 0.12.2 與 UI 0.10.3,並加入 SDK 0.12.2 的 executor digest(與 0.12.1 相同)。DSM 視窗把啟動參數轉給 App:`ctx.launchParams` 是開啟視窗那次的 App 字串參數(桌面 URL `launchParam` 或 `SYNO.SDS.AppLaunch`),`ctx.onLaunch(fn)` 在已開視窗再次被帶參數啟動時呼叫;DSM 自有欄位、非字串、不合格的 key、超過 2048 字元或含控制字元的值與第 32 個之後的 key 會被略過。已開視窗再次啟動時 `ctx.reopen` 現在會觸發(DSM 呼叫 onRequest,不會再呼叫 onOpen)。
19
+
3
20
  ## 0.12.1 (2026-10-16)
4
21
 
5
22
  - Preserve optional `raw_max_bytes` in WebAPI profiles and administrator policy digests. Reject invalid limits and require raw-capable SDK 0.12.1; zero/omitted profiles stay JSON-only. Generated approval details receive the byte limit.
@@ -33,6 +33,7 @@ type deadlines struct {
33
33
  Uninstall int `json:"uninstall"`
34
34
  }
35
35
  type config struct {
36
+ Network *networkPolicy `json:"network,omitempty"`
36
37
  Package string `json:"package"`
37
38
  Exec string `json:"exec"`
38
39
  Args []string `json:"args"`
@@ -464,6 +465,23 @@ func (c *controller) supervise() error {
464
465
  signals := make(chan os.Signal, 1)
465
466
  signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT)
466
467
  defer signal.Stop(signals)
468
+ if c.Network != nil {
469
+ deadline := time.Now().Add(time.Duration(c.Timeouts.Start-2) * time.Second)
470
+ for {
471
+ err := checkNetwork("/proc/net", os.Geteuid(), c.Network)
472
+ if err == nil {
473
+ break
474
+ }
475
+ if !time.Now().Before(deadline) {
476
+ return err
477
+ }
478
+ select {
479
+ case <-signals:
480
+ return errors.New("compose startup interrupted")
481
+ case <-time.After(100 * time.Millisecond):
482
+ }
483
+ }
484
+ }
467
485
  cmd := c.child(c.Args)
468
486
  cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
469
487
  if err := cmd.Start(); err != nil {
@@ -552,8 +570,15 @@ func (c *controller) supervise() error {
552
570
  }
553
571
  }
554
572
  }()
573
+ tick := time.NewTicker(time.Second)
574
+ defer tick.Stop()
555
575
  for {
556
576
  select {
577
+ case <-tick.C:
578
+ if err := checkNetwork("/proc/net", os.Geteuid(), c.Network); err != nil {
579
+ clean = stop() == nil
580
+ return err
581
+ }
557
582
  case <-done:
558
583
  return nil
559
584
  case <-signals:
@@ -0,0 +1,107 @@
1
+ package main
2
+
3
+ import (
4
+ "bufio"
5
+ "errors"
6
+ "fmt"
7
+ "os"
8
+ "path/filepath"
9
+ "strconv"
10
+ "strings"
11
+ )
12
+
13
+ // Host-network containers use the package UID. The kernel tables, rather than
14
+ // an application's claimed environment setting, prove its listener bindings.
15
+ // This is a configuration guard, not a network namespace or a firewall.
16
+ type networkPolicy struct {
17
+ Ports []int `json:"ports"`
18
+ PublicPorts []int `json:"public_ports"`
19
+ }
20
+
21
+ func containsPort(ports []int, port int) bool {
22
+ for _, p := range ports {
23
+ if p == port {
24
+ return true
25
+ }
26
+ }
27
+ return false
28
+ }
29
+
30
+ func checkSocketTable(raw string, uid int, p *networkPolicy, udp bool) error {
31
+ _, err := socketPorts(raw, uid, p, udp)
32
+ return err
33
+ }
34
+
35
+ func socketPorts(raw string, uid int, p *networkPolicy, udp bool) (map[int]bool, error) {
36
+ found := map[int]bool{}
37
+ scan := bufio.NewScanner(strings.NewReader(raw))
38
+ if !scan.Scan() {
39
+ return nil, errors.New("missing socket table header")
40
+ }
41
+ for scan.Scan() {
42
+ f := strings.Fields(scan.Text())
43
+ if len(f) == 0 {
44
+ continue
45
+ }
46
+ if len(f) < 10 {
47
+ return nil, errors.New("invalid socket table")
48
+ }
49
+ owner, err := strconv.Atoi(f[7])
50
+ if err != nil {
51
+ return nil, err
52
+ }
53
+ if owner != uid {
54
+ continue
55
+ }
56
+ if (!udp && f[3] != "0A") || (udp && f[3] != "07") {
57
+ continue
58
+ }
59
+ endpoint := strings.Split(f[1], ":")
60
+ if len(endpoint) != 2 {
61
+ return nil, errors.New("invalid socket endpoint")
62
+ }
63
+ port64, err := strconv.ParseInt(endpoint[1], 16, 32)
64
+ if err != nil {
65
+ return nil, err
66
+ }
67
+ port := int(port64)
68
+ if !udp && containsPort(p.PublicPorts, port) {
69
+ continue
70
+ }
71
+ // /proc/net's IPv4 and IPv6 words are little-endian on supported DSM arches.
72
+ loopback := endpoint[0] == "0100007F" || endpoint[0] == "00000000000000000000000001000000"
73
+ if !loopback {
74
+ return nil, fmt.Errorf("compose package listener on port %d is not bound to loopback", port)
75
+ }
76
+ if !udp && containsPort(p.Ports, port) && endpoint[0] == "0100007F" {
77
+ found[port] = true
78
+ }
79
+ }
80
+ return found, scan.Err()
81
+ }
82
+
83
+ func checkNetwork(dir string, uid int, p *networkPolicy) error {
84
+ if p == nil {
85
+ return nil
86
+ }
87
+ found := map[int]bool{}
88
+ for _, name := range []string{"tcp", "tcp6", "udp", "udp6"} {
89
+ raw, err := os.ReadFile(filepath.Join(dir, name))
90
+ if err != nil {
91
+ return err
92
+ }
93
+ ports, err := socketPorts(string(raw), uid, p, strings.HasPrefix(name, "udp"))
94
+ if err != nil {
95
+ return err
96
+ }
97
+ for port := range ports {
98
+ found[port] = true
99
+ }
100
+ }
101
+ for _, port := range p.Ports {
102
+ if !found[port] {
103
+ return fmt.Errorf("compose upstream port %d is not ready as the package UID on 127.0.0.1", port)
104
+ }
105
+ }
106
+ return nil
107
+ }
@@ -0,0 +1,86 @@
1
+ package main
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "path/filepath"
7
+ "testing"
8
+ )
9
+
10
+ func TestComposeNetworkPolicy(t *testing.T) {
11
+ uid := os.Geteuid()
12
+ for _, tc := range []struct {
13
+ name, addr, state, port string
14
+ ok bool
15
+ }{
16
+ {"loopback", "0100007F", "0A", "6D64", true},
17
+ {"wildcard", "00000000", "0A", "6D64", false},
18
+ {"other loopback", "0200007F", "0A", "6D64", false},
19
+ {"public gateway", "00000000", "0A", "6D60", true},
20
+ {"unknown port", "00000000", "0A", "6D66", false},
21
+ {"connected", "00000000", "01", "6D66", true},
22
+ } {
23
+ t.Run(tc.name, func(t *testing.T) {
24
+ line := socketFixture(tc.addr, tc.port, tc.state, uid)
25
+ err := checkSocketTable(line, uid, &networkPolicy{Ports: []int{28004}, PublicPorts: []int{28000}}, false)
26
+ if (err == nil) != tc.ok {
27
+ t.Fatalf("got %v, expected ok %v", err, tc.ok)
28
+ }
29
+ })
30
+ }
31
+ }
32
+
33
+ func TestComposeNetworkRequiresOwnedUpstream(t *testing.T) {
34
+ dir := t.TempDir()
35
+ for _, name := range []string{"tcp", "tcp6", "udp", "udp6"} {
36
+ os.WriteFile(filepath.Join(dir, name), []byte("header\n"), 0600)
37
+ }
38
+ p := &networkPolicy{Ports: []int{28004}, PublicPorts: []int{28000}}
39
+ if err := checkNetwork(dir, os.Geteuid(), p); err == nil {
40
+ t.Fatal("missing upstream accepted")
41
+ }
42
+ os.WriteFile(filepath.Join(dir, "tcp"), []byte(socketFixture("0100007F", "6D64", "0A", os.Geteuid()+1)), 0600)
43
+ if err := checkNetwork(dir, os.Geteuid(), p); err == nil {
44
+ t.Fatal("foreign upstream accepted")
45
+ }
46
+ os.WriteFile(filepath.Join(dir, "tcp"), []byte(socketFixture("0100007F", "6D64", "0A", os.Geteuid())), 0600)
47
+ if err := checkNetwork(dir, os.Geteuid(), p); err != nil {
48
+ t.Fatal(err)
49
+ }
50
+ os.WriteFile(filepath.Join(dir, "udp"), []byte(socketFixture("00000000", "6D66", "07", os.Geteuid())), 0600)
51
+ if err := checkNetwork(dir, os.Geteuid(), p); err == nil {
52
+ t.Fatal("UDP wildcard accepted")
53
+ }
54
+ }
55
+
56
+ func socketFixture(addr, port, state string, uid int) string {
57
+ return fmt.Sprintf("header\n 0: %s:%s 00000000:0000 %s 0:0 0:0 0 %d 0 1\n", addr, port, state, uid)
58
+ }
59
+
60
+ func TestComposeUDPConnectedIsNotAListener(t *testing.T) {
61
+ raw := socketFixture("EA53110A", "BEEF", "01", os.Geteuid())
62
+ if err := checkSocketTable(raw, os.Geteuid(), &networkPolicy{Ports: []int{28004}}, true); err != nil {
63
+ t.Fatal(err)
64
+ }
65
+ }
66
+
67
+ func TestComposeIPv6AndUDPPublicPort(t *testing.T) {
68
+ p := &networkPolicy{Ports: []int{28004}, PublicPorts: []int{28000}}
69
+ for _, tc := range []struct {
70
+ addr, port string
71
+ udp, ok bool
72
+ }{
73
+ {"00000000000000000000000001000000", "6D64", false, true},
74
+ {"00000000000000000000000000000000", "6D64", false, false},
75
+ {"00000000", "6D60", true, false},
76
+ } {
77
+ state := "0A"
78
+ if tc.udp {
79
+ state = "07"
80
+ }
81
+ err := checkSocketTable(socketFixture(tc.addr, tc.port, state, os.Geteuid()), os.Geteuid(), p, tc.udp)
82
+ if (err == nil) != tc.ok {
83
+ t.Fatalf("%+v: %v", tc, err)
84
+ }
85
+ }
86
+ }
@@ -479,6 +479,21 @@ export const ERROR_REGISTRY = {
479
479
  "description": "The built supervisor is missing while assembling the daemon SPK.",
480
480
  "suggestion": "Build the complete artifacts with synopkgland build; do not call the assembler directly without the controller."
481
481
  },
482
+ "STANDALONE_IDENTITY_GATEWAY_INVALID": {
483
+ "code": "STANDALONE_IDENTITY_GATEWAY_INVALID",
484
+ "description": "The identity_gateway declaration does not match the gateway contract.",
485
+ "suggestion": "Declare a daemon; set sdk, mode (oidc, header or token), the App's loopback upstream port and distinct listen ports other than 5000/5001; keep passthrough and redirects local paths outside /_sso; oidc needs issuer_port and clients, header needs headers.user, token needs token.exec in the daemon payload."
486
+ },
487
+ "STANDALONE_IDENTITY_GATEWAY_BUILD_FAILED": {
488
+ "code": "STANDALONE_IDENTITY_GATEWAY_BUILD_FAILED",
489
+ "description": "The SDK identity gateway could not be built from identity_gateway.sdk.",
490
+ "suggestion": "Use an SDK release that contains idgateway/cmd/gateway, install Go (builds pin go1.26.9) and check the compiler stderr."
491
+ },
492
+ "STANDALONE_IDENTITY_GATEWAY_ARTIFACT_REQUIRED": {
493
+ "code": "STANDALONE_IDENTITY_GATEWAY_ARTIFACT_REQUIRED",
494
+ "description": "The built identity gateway is missing while assembling the SPK.",
495
+ "suggestion": "Build the complete artifacts with synopkgland build; do not call the assembler directly without the gateway."
496
+ },
482
497
  "STANDALONE_SHARED_FOLDER_INVALID": {
483
498
  "code": "STANDALONE_SHARED_FOLDER_INVALID",
484
499
  "description": "The install.sharedFolder declaration in dsm-package.yaml is invalid.",
@@ -1,16 +1,5 @@
1
1
  {
2
- "0.12.0": {
3
- "x86_64": "abe0a42699fed06aeeb7520e5a4f3393cae64f83a66773b70630f0db0c5ed107",
4
- "aarch64": "1fdf171a665279f16ca1c2605a54996b2359fa69531c6fe4e890f5bda61c1bef",
5
- "armv7": "946f73df833929283f94289f025fd73897416777be5e0867d1e73f0b6c3031d7",
6
- "lifecycle": {
7
- "x86_64": "d65500c4f04713a32b45d360059281391acfa5e00ee24895fdd2de7b82735372",
8
- "aarch64": "94f6cd119186ff482b8aea56541dd1f9d53c147566bc1efdca6f5a1c86198331",
9
- "armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
10
- },
11
- "goToolchain": "go1.26.9"
12
- },
13
- "0.12.1": {
2
+ "0.13.0": {
14
3
  "x86_64": "efe409287770f6b70e7f8465e8767eeb2413b9ab8775020cf4dffb1555d7bf1f",
15
4
  "aarch64": "d68c9889ed2391e918b9c278679ea0a01cd04425528c11090700a3b6341fc543",
16
5
  "armv7": "9149a05efb9f2d7d86a400707099b3d657382b1c0dbad6588be8c500e90445cc",
package/dist/skill.js CHANGED
@@ -5,6 +5,7 @@ const SKILL_CATALOG = [
5
5
  { id: "orchestrator", file: "SKILL.md", when: "Read first" },
6
6
  { id: "create", file: "reference/create.md", when: "Start from nothing or a one-liner" },
7
7
  { id: "wrap", file: "reference/wrap.md", when: "Existing Compose, or adding a contract" },
8
+ { id: "identity-gateway", file: "reference/identity-gateway.md", when: "Wrap OSS native or container apps with DSM account SSO" },
8
9
  { id: "identity", file: "reference/identity.md", when: "Needs a DSM sign-in" },
9
10
  { id: "files", file: "reference/files.md", when: "Needs NAS files" },
10
11
  { id: "portal", file: "reference/portal.md", when: "Needs a DSM Application Portal alias link" },
@@ -10,6 +10,7 @@ import { checkCgiBinary } from "./binary.js";
10
10
  import { loadStandaloneManifest } from "./manifest.js";
11
11
  import { assembleStandaloneSpk, StandaloneBuildError } from "./spk.js";
12
12
  import { buildDaemonControl } from "./daemon.js";
13
+ import { buildIdentityGateway } from "./identity-gateway.js";
13
14
  import { buildExecutor } from "../runtime/executor-build.js";
14
15
  import { BACKGROUND_WORKER_MAIN } from "./background.js";
15
16
  import { readApiPermissions } from "./api-permissions.js";
@@ -88,6 +89,8 @@ export async function buildStandalone(options) {
88
89
  if (manifest.portal && !isDsmAtLeast(manifest.os_min_ver, PORTAL_EXECUTOR_MIN_DSM)) {
89
90
  throw new StandaloneBuildError("STANDALONE_PORTAL_MIN_DSM", `[STANDALONE_PORTAL_MIN_DSM] ${portalMinDsmMessage(manifest.os_min_ver, "portal")}`);
90
91
  }
92
+ if (manifest.compose && !isDsmAtLeast(manifest.os_min_ver, "7.2.1-69057"))
93
+ throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_INVALID", "compose identity_gateway requires DSM >= 7.2.1.");
91
94
  const jobs = checkJobs(options.jobs);
92
95
  const arches = options.arches ?? manifest.arches;
93
96
  for (const arch of arches) {
@@ -111,9 +114,10 @@ export async function buildStandalone(options) {
111
114
  const { cgi: cgiPath, worker: backgroundWorker, app } = await backendFor(builtManifest, arch, archWork, run, bridge);
112
115
  const outputPath = join(stage, `${manifest.package}-${manifest.spkVersion}-${arch}.spk`);
113
116
  const daemonControl = await buildDaemonControl(manifest, arch, archWork, run);
117
+ const identityGateway = await buildIdentityGateway(manifest, arch, archWork, run);
114
118
  // A portal needs the executor, so the executor's SDK carries the panel text.
115
119
  const portalText = manifest.portal && executor ? portalCatalogs(sdkSource(executor.sdk).goDir) : undefined;
116
- await assembleStandaloneSpk({ manifest: builtManifest, arch, cgiPath, outputPath, daemonControl, backgroundWorker, executor, app, portalText });
120
+ await assembleStandaloneSpk({ manifest: builtManifest, arch, cgiPath, outputPath, daemonControl, identityGateway, backgroundWorker, executor, app, portalText });
117
121
  return outputPath;
118
122
  }));
119
123
  return { outputs, warnings: [...warnings] };
@@ -0,0 +1,22 @@
1
+ import type { NativeArchFamily } from '../build-spk/dsm-arch.js';
2
+ import { type Daemon } from './daemon.js';
3
+ import type { StandaloneError, StandaloneManifest } from './manifest.js';
4
+ export interface ComposeApp {
5
+ file: string;
6
+ targets: Daemon['targets'];
7
+ services: Record<string, {
8
+ address_env: string;
9
+ port: number;
10
+ }>;
11
+ document: Record<string, any>;
12
+ }
13
+ /** Explicit host-network policy; ordinary Compose keeps its existing rejection rules. */
14
+ export declare function readComposeApp(value: unknown, root: string, arches: NativeArchFamily[], fail: (e: StandaloneError) => void): ComposeApp | undefined;
15
+ export declare function composeDaemon(m: Pick<StandaloneManifest, 'compose'>): Daemon;
16
+ export declare function composeNetwork(m: Pick<StandaloneManifest, 'compose' | 'identityGateway'>): {
17
+ ports: number[];
18
+ public_ports: number[];
19
+ };
20
+ export declare function composeSource(c: ComposeApp): string;
21
+ /** preinst only materializes values in the incoming payload; DSM creates var before acquire. */
22
+ export declare function renderComposeInstall(pkg: string): string;
@@ -0,0 +1,90 @@
1
+ import { lstatSync, readFileSync } from 'node:fs';
2
+ import { isAbsolute, relative, resolve } from 'node:path';
3
+ import { parse, stringify } from 'yaml';
4
+ import { readDaemon } from './daemon.js';
5
+ const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
6
+ const only = (v, keys) => Object.keys(v).every(k => keys.includes(k));
7
+ const vars = new Set(['SYNOPKGLAND_UID', 'SYNOPKGLAND_GID', 'SYNOPKG_PKGVAR']);
8
+ /** Explicit host-network policy; ordinary Compose keeps its existing rejection rules. */
9
+ export function readComposeApp(value, root, arches, fail) {
10
+ if (value === undefined)
11
+ return;
12
+ const invalid = (why) => { fail({ code: 'STANDALONE_IDENTITY_GATEWAY_INVALID', message: `compose identity_gateway: ${why}` }); return undefined; };
13
+ if (arches.some(a => a !== 'x86_64' && a !== 'aarch64'))
14
+ return invalid('Container Manager supports x86_64 and aarch64, not armv7.');
15
+ if (!record(value) || !only(value, ['file', 'targets', 'services']) || typeof value.file !== 'string')
16
+ return invalid('accepts file, targets and services.');
17
+ const path = resolve(root, value.file), rel = relative(root, path);
18
+ if (!rel || isAbsolute(value.file) || rel.startsWith('..') || value.file.split(/[\\/]/).includes('..'))
19
+ return invalid('file must be inside the project.');
20
+ let doc;
21
+ try {
22
+ if (lstatSync(path).isSymbolicLink())
23
+ return invalid('file must not be a symlink.');
24
+ doc = parse(readFileSync(path, 'utf8'));
25
+ }
26
+ catch {
27
+ return invalid('cannot read compose file.');
28
+ }
29
+ const payload = readDaemon({ exec: '_sso/gateway', targets: value.targets }, root, arches, fail);
30
+ if (!payload)
31
+ return;
32
+ if (!record(doc) || !only(doc, ['services']) || !record(doc.services) || !Object.keys(doc.services).length || !record(value.services))
33
+ return invalid('only services are supported; every container needs a services binding policy.');
34
+ if (Object.keys(value.services).sort().join() !== Object.keys(doc.services).sort().join())
35
+ return invalid('declare a binding policy for every deployed container.');
36
+ const ports = new Set();
37
+ for (const [name, service] of Object.entries(doc.services)) {
38
+ const policy = value.services[name];
39
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name) || !record(policy) || !only(policy, ['address_env', 'port']) || !/^[A-Z][A-Z0-9_]*$/.test(policy.address_env) || !Number.isInteger(policy.port) || policy.port < 1024 || policy.port > 65535 || ports.has(policy.port))
40
+ return invalid(`services.${name} needs address_env and a unique unprivileged port.`);
41
+ ports.add(policy.port);
42
+ if (!record(service) || !only(service, ['image', 'network_mode', 'user', 'cap_drop', 'security_opt', 'environment', 'volumes', 'restart', 'command', 'entrypoint', 'working_dir']))
43
+ return invalid(`${name}: unsupported Compose fields (no build, ports, devices, capabilities, namespaces or Docker socket).`);
44
+ if (service.network_mode !== 'host')
45
+ return invalid(`${name}: identity_gateway requires network_mode: host with the app bound to 127.0.0.1; bridge cannot reach the loopback issuer.`);
46
+ if (typeof service.image !== 'string' || !service.image.trim() || /\s/.test(service.image))
47
+ return invalid(`${name}: use a prebuilt image.`);
48
+ if (service.user !== '${SYNOPKGLAND_UID}:${SYNOPKGLAND_GID}')
49
+ return invalid(`${name}: user must be \${SYNOPKGLAND_UID}:\${SYNOPKGLAND_GID}, materialized from the package account.`);
50
+ if (JSON.stringify(service.cap_drop) !== '["ALL"]' || JSON.stringify(service.security_opt) !== '["no-new-privileges:true"]')
51
+ return invalid(`${name}: require cap_drop: [ALL] and security_opt: [no-new-privileges:true].`);
52
+ if (!record(service.environment) || service.environment[policy.address_env] !== '127.0.0.1' || Object.values(service.environment).some(v => typeof v !== 'string'))
53
+ return invalid(`${name}: environment.${policy.address_env} must be 127.0.0.1; verify the upstream's actual listen setting.`);
54
+ if (service.restart !== undefined && service.restart !== 'no')
55
+ return invalid(`${name}: restart must be no; Container Manager owns the lifecycle.`);
56
+ if (service.volumes !== undefined && (!Array.isArray(service.volumes) || service.volumes.some((v) => typeof v !== 'string' || !/^\$\{SYNOPKG_PKGVAR\}:\/[A-Za-z0-9_./-]+(?::rw|:ro)?$/.test(v) || v.split(':')[1].split('/').some(p => p === '..' || p === '.') || /docker\.sock|container-manager\.sock/.test(v))))
57
+ return invalid(`${name}: mount only the DSM-created \${SYNOPKG_PKGVAR} root, with a fixed container path.`);
58
+ for (const key of ['command', 'entrypoint'])
59
+ if (service[key] !== undefined && (!Array.isArray(service[key]) || !service[key].every((v) => typeof v === 'string')))
60
+ return invalid(`${name}: ${key} must be argv.`);
61
+ }
62
+ for (const match of JSON.stringify(doc).matchAll(/\$(?:\{([^}]+)\}|([A-Za-z_][A-Za-z0-9_]*))/g))
63
+ if (!vars.has(match[1] ?? match[2]))
64
+ return invalid('only package UID, GID and var interpolation is supported.');
65
+ return { file: path, targets: payload.targets, services: value.services, document: doc };
66
+ }
67
+ export function composeDaemon(m) {
68
+ return { exec: '_sso/gateway', args: ['run'], targets: m.compose.targets, env: {}, timeouts: { start: 30, stop: 30, status: 5, uninstall: 60 } };
69
+ }
70
+ export function composeNetwork(m) {
71
+ return { ports: Object.values(m.compose.services).map(s => s.port).sort((a, b) => a - b), public_ports: Object.values(m.identityGateway.listen).sort((a, b) => a - b) };
72
+ }
73
+ export function composeSource(c) { return stringify(c.document); }
74
+ /** preinst only materializes values in the incoming payload; DSM creates var before acquire. */
75
+ export function renderComposeInstall(pkg) {
76
+ return `#!/bin/sh
77
+ set -eu
78
+ [ "$(id -u)" != 0 ] || exit 1
79
+ umask 077
80
+ project="\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose"
81
+ [ -d "$project" ] && [ ! -L "$project" ] || exit 1
82
+ [ ! -L "$project/.env" ] || exit 1
83
+ {
84
+ printf 'SYNOPKGLAND_UID=%s\\n' "$(id -u)"
85
+ printf 'SYNOPKGLAND_GID=%s\\n' "$(id -g)"
86
+ printf 'SYNOPKG_PKGVAR=%s\\n' '/var/packages/${pkg}/var'
87
+ } > "$project/.env"
88
+ chmod 600 "$project/.env"
89
+ `;
90
+ }
@@ -28,3 +28,5 @@ export declare function readDaemon(value: unknown, root: string, arches: NativeA
28
28
  export declare function daemonConfig(manifest: StandaloneManifest): string;
29
29
  export declare function buildDaemonControl(manifest: StandaloneManifest, arch: NativeArchFamily, work: string, run: CommandRunner): Promise<string | undefined>;
30
30
  export declare function copyDaemonPayload(manifest: StandaloneManifest, arch: NativeArchFamily, target: string, controller: string): void;
31
+ /** Copies a bounded, link-free package-account payload tree. */
32
+ export declare function copyPayloadTree(root: string, source: string, target: string): void;
@@ -4,6 +4,8 @@ import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { PINNED_GO_HINT, pinnedGoBuildEnv, pinnedGoProblem } from "../build-spk/go-toolchain.js";
5
5
  import { goAssetDir } from "../template-paths.js";
6
6
  import { checkCgiBinary } from "./binary.js";
7
+ import { composeDaemon, composeNetwork } from "./compose.js";
8
+ import { gatewayDaemon } from "./identity-gateway.js";
7
9
  import { StandaloneBuildError } from "./spk.js";
8
10
  const fields = new Set(["exec", "targets", "args", "env", "timeouts", "uninstall_args"]);
9
11
  const record = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
@@ -57,11 +59,11 @@ export function readDaemon(value, root, arches, fail) {
57
59
  return { exec: value.exec, targets, args, ...(uninstall ? { uninstall_args: uninstall } : {}), env: Object.fromEntries(Object.keys(env).sort().map(key => [key, env[key]])), timeouts: timeouts };
58
60
  }
59
61
  export function daemonConfig(manifest) {
60
- const { exec, args, uninstall_args, env, timeouts } = manifest.daemon;
61
- return JSON.stringify({ package: manifest.package, exec, args, ...(uninstall_args ? { uninstall_args } : {}), env, timeouts }) + "\n";
62
+ const { exec, args, uninstall_args, env, timeouts } = manifest.compose ? composeDaemon(manifest) : manifest.identityGateway ? gatewayDaemon(manifest.daemon) : manifest.daemon;
63
+ return JSON.stringify({ package: manifest.package, exec, args, ...(uninstall_args ? { uninstall_args } : {}), env, timeouts, ...(manifest.compose ? { network: composeNetwork(manifest) } : {}) }) + "\n";
62
64
  }
63
65
  export async function buildDaemonControl(manifest, arch, work, run) {
64
- if (!manifest.daemon)
66
+ if (!manifest.daemon && !manifest.compose)
65
67
  return undefined;
66
68
  const out = join(work, `daemon-control-${arch}`);
67
69
  const result = await run("go", ["build", "-trimpath", "-buildvcs=false", "-ldflags", "-s -w -buildid=", "-o", out, "."], {
@@ -78,13 +80,27 @@ export async function buildDaemonControl(manifest, arch, work, run) {
78
80
  return out;
79
81
  }
80
82
  export function copyDaemonPayload(manifest, arch, target, controller) {
81
- const daemon = manifest.daemon;
83
+ const daemon = manifest.daemon ?? composeDaemon(manifest);
82
84
  const source = daemon.targets[arch];
85
+ copyPayloadTree(manifest.root, source, join(target, "payload"));
86
+ if (!manifest.compose) {
87
+ const exec = join(source, daemon.exec);
88
+ const problem = checkCgiBinary(manifest.root, exec, arch);
89
+ if (problem)
90
+ throw new StandaloneBuildError(problem.code, problem.message);
91
+ chmodSync(join(target, "payload", daemon.exec), 0o755);
92
+ }
93
+ copyFileSync(controller, join(target, "control"));
94
+ chmodSync(join(target, "control"), 0o755);
95
+ writeFileSync(join(target, "config.json"), daemonConfig(manifest), { mode: 0o644 });
96
+ }
97
+ /** Copies a bounded, link-free package-account payload tree. */
98
+ export function copyPayloadTree(root, source, target) {
83
99
  const invalid = (message) => { throw new StandaloneBuildError("STANDALONE_DAEMON_PAYLOAD_INVALID", message); };
84
100
  let total = 0;
85
101
  const walk = (from, to) => {
86
102
  const stat = lstatSync(from);
87
- const rel = relative(realpathSync(manifest.root), realpathSync(from));
103
+ const rel = relative(realpathSync(root), realpathSync(from));
88
104
  if (rel.startsWith("..") || isAbsolute(rel) || stat.isSymbolicLink())
89
105
  invalid(`A daemon payload path must not contain a symlink or escape the project: ${from}`);
90
106
  if (stat.isDirectory()) {
@@ -106,19 +122,11 @@ export function copyDaemonPayload(manifest, arch, target, controller) {
106
122
  try {
107
123
  if (!lstatSync(source).isDirectory())
108
124
  invalid("daemon.targets must point at a directory.");
109
- walk(source, join(target, "payload"));
125
+ walk(source, target);
110
126
  }
111
127
  catch (error) {
112
128
  if (error instanceof StandaloneBuildError)
113
129
  throw error;
114
130
  invalid(`Cannot read the daemon payload: ${String(error)}`);
115
131
  }
116
- const exec = join(source, daemon.exec);
117
- const problem = checkCgiBinary(manifest.root, exec, arch);
118
- if (problem)
119
- throw new StandaloneBuildError(problem.code, problem.message);
120
- chmodSync(join(target, "payload", daemon.exec), 0o755);
121
- copyFileSync(controller, join(target, "control"));
122
- chmodSync(join(target, "control"), 0o755);
123
- writeFileSync(join(target, "config.json"), daemonConfig(manifest), { mode: 0o644 });
124
132
  }
@@ -42,5 +42,8 @@ export declare function renderUiConfig(manifest: StandaloneManifest, icons?: Pac
42
42
  * (and add a device-width viewport), and `ctx.appIcon` is the package icon a
43
43
  * full-page shell shows beside the name, where a DSM window has a title bar.
44
44
  * A portal App's `ctx.portal` shows `portalText`, the SDK's panel text.
45
+ * `ctx.launchParams` holds the App's own values from the launch that opened
46
+ * the window (a `launchParam` desktop URL or `SYNO.SDS.AppLaunch(id, param)`),
47
+ * and `ctx.onLaunch(fn)` hands fn those of each later launch with values.
45
48
  */
46
49
  export declare function renderWindowScript(manifest: StandaloneManifest, assetVersion: string, icons?: PackageIcons, portalText?: PortalCatalogs): string;
@@ -1,4 +1,5 @@
1
1
  import { REOPEN_EVENTS_RUNTIME } from "./reopen-events.js";
2
+ import { LAUNCH_PARAMS_RUNTIME } from "./launch-params.js";
2
3
  import { DSM_LANGS } from "./i18n.js";
3
4
  import { PASSWORD_CONFIRM, ROOT_PANEL, ROOT_RUNTIME, rootPanelCatalogs } from "./root-window.js";
4
5
  import { HOST_ADAPTER_RUNTIME } from "./host-adapter.js";
@@ -128,6 +129,9 @@ export function renderUiConfig(manifest, icons = packageIcons(manifest.icon), lo
128
129
  * (and add a device-width viewport), and `ctx.appIcon` is the package icon a
129
130
  * full-page shell shows beside the name, where a DSM window has a title bar.
130
131
  * A portal App's `ctx.portal` shows `portalText`, the SDK's panel text.
132
+ * `ctx.launchParams` holds the App's own values from the launch that opened
133
+ * the window (a `launchParam` desktop URL or `SYNO.SDS.AppLaunch(id, param)`),
134
+ * and `ctx.onLaunch(fn)` hands fn those of each later launch with values.
131
135
  */
132
136
  export function renderWindowScript(manifest, assetVersion, icons = packageIcons(manifest.icon), portalText) {
133
137
  const pkg = manifest.package;
@@ -270,6 +274,11 @@ ${REOPEN_EVENTS_RUNTIME}
270
274
  return createReopenEvents;
271
275
  })();
272
276
 
277
+ ${ns}.launchParams = (function () {
278
+ ${LAUNCH_PARAMS_RUNTIME}
279
+ return launchParams;
280
+ })();
281
+
273
282
  ${ns}.createHost = (function () {
274
283
  ${HOST_ADAPTER_RUNTIME}
275
284
  return createHostAdapter;
@@ -332,7 +341,7 @@ Ext.define(${JSON.stringify(`${ns}.MainWindow`)}, {
332
341
  getRestoreSizePos: function (config) {
333
342
  return ${ns}.fitDesktop(Ext.apply({ width: config.width, height: config.height }, this.callParent(arguments)));
334
343
  },
335
- onOpen: function () {
344
+ onOpen: function (param) {
336
345
  this.callParent(arguments);
337
346
  var me = this;
338
347
  if (me.mounted) {
@@ -341,9 +350,11 @@ Ext.define(${JSON.stringify(`${ns}.MainWindow`)}, {
341
350
  }
342
351
  if (me.mountPending) return;
343
352
  me.mountPending = true;
353
+ me.launchParams = ${ns}.launchParams(param);
344
354
  var mountToken = me.mountToken = {};
345
355
  me.reopenEvents = ${ns}.createReopenEvents();
346
356
  me.reopenHandlers = me.reopenEvents.handlers;
357
+ me.launchEvents = ${ns}.createReopenEvents();
347
358
  var lang = "enu";
348
359
  try { if (typeof _S === "function") lang = _S("lang"); } catch (error) {}
349
360
  var ctx = { requestTimeout: ${requestTimeout} };
@@ -362,6 +373,8 @@ ${root ? root.mount : ""} ${ns}.loadApp(function (app) {
362
373
  call: call,
363
374
  files: {endpoint: ${JSON.stringify(cgiUrl(pkg))}, token: function () { return SYNO.SDS.Session && SYNO.SDS.Session.SynoToken; }},
364
375
  reopen: me.reopenEvents.subscribe,
376
+ launchParams: me.launchParams,
377
+ onLaunch: me.launchEvents.subscribe,
365
378
  user: ${ns}.sessionUser(),${manifest.portal ? `\n portal: ${renderPortalAccess(ns, manifest)},` : ""}
366
379
  host: me.hostAccess,
367
380
  appWindow: me,
@@ -378,17 +391,33 @@ ${root ? root.mount : ""} ${ns}.loadApp(function (app) {
378
391
  me.reopenEvents.dispose();
379
392
  me.reopenEvents = null;
380
393
  me.reopenHandlers = [];
394
+ me.launchEvents.dispose();
395
+ me.launchEvents = null;
381
396
  throw error;
382
397
  }
383
398
  }
384
399
  });
385
400
  },
401
+ // DSM opens a running App's window again through onRequest, not onOpen,
402
+ // with that launch's param. Before the App mounts, it mounts with the latest.
403
+ onRequest: function (param) {
404
+ this.callParent(arguments);
405
+ var params = ${ns}.launchParams(param);
406
+ if (!this.mounted) {
407
+ if (this.mountPending && Object.keys(params).length) this.launchParams = params;
408
+ return;
409
+ }
410
+ this.reopenEvents.emit();
411
+ if (Object.keys(params).length) this.launchEvents.emit(params);
412
+ },
386
413
  onDestroy: function () {
387
414
  this.mountToken = null;
388
415
  this.mountPending = false;
389
416
  if (this.reopenEvents) this.reopenEvents.dispose();
390
417
  this.reopenEvents = null;
391
418
  this.reopenHandlers = [];
419
+ if (this.launchEvents) this.launchEvents.dispose();
420
+ this.launchEvents = null;
392
421
  if (this.hostAccess) this.hostAccess.dispose();
393
422
  this.hostAccess = null;
394
423
  var mounted = this.mounted;