@synopackageland/cli 0.39.0 → 0.41.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 +31 -0
- package/README.md +7 -3
- package/assets/go/route-audit/main.go +8 -0
- package/dist/cli.js +4 -0
- package/dist/ctl/cli.d.ts +14 -11
- package/dist/ctl/cli.js +4 -1
- package/dist/ctl/descriptor.d.ts +3 -0
- package/dist/ctl/descriptor.js +9 -0
- package/dist/ctl/external.d.ts +27 -0
- package/dist/ctl/external.js +195 -0
- package/dist/nas/store.d.ts +3 -1
- package/dist/nas/store.js +2 -2
- package/dist/skill.js +5 -0
- package/dist/standalone/api-description.js +12 -1
- package/dist/standalone/daemon.d.ts +10 -1
- package/dist/standalone/daemon.js +53 -17
- package/dist/standalone/inspect-spk.js +14 -3
- package/dist/standalone/manifest.d.ts +1 -0
- package/dist/standalone/manifest.js +12 -2
- package/docs/ctl-external.md +212 -0
- package/package.json +3 -2
- package/schema/synopkgland-api-v1.schema.json +23 -0
- package/skills/SKILL.md +4 -2
- package/skills/reference/ctl.md +21 -0
- package/skills/reference/daemon-mode.md +90 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.41.0 (2026-10-29)
|
|
4
|
+
|
|
5
|
+
- Add generic `ctl external --protocol <id> --host <profile> --input - --json`
|
|
6
|
+
for local MCP bridges. Apps opt in with `ctl.external` and a dedicated
|
|
7
|
+
administrator RPC entry. Each request checks the stored login, pinned host,
|
|
8
|
+
installed package and fresh description; calls also check the current catalog
|
|
9
|
+
revision and schema. The App verifies pins/revision at dispatch and creates
|
|
10
|
+
caller authority and MCP provenance. Ordinary ctl cannot invoke the external
|
|
11
|
+
entry. Lost mutation replies are never retried. See
|
|
12
|
+
[the external contract](docs/ctl-external.md) for App integration requirements.
|
|
13
|
+
|
|
14
|
+
## 0.40.0 (2026-10-29)
|
|
15
|
+
|
|
16
|
+
- `daemon.backend: true` runs the App's own native backend
|
|
17
|
+
(`ui/cgi/api.cgi`) as the package daemon with `args`, instead of a separate
|
|
18
|
+
`exec`. The binary keeps the route audit and build digests of the CGI
|
|
19
|
+
build. `targets` becomes optional; with `daemon.node` the CLI writes its
|
|
20
|
+
Node launcher and passes `SYNOPKGLAND_NODE` and
|
|
21
|
+
`SYNOPKGLAND_DAEMON_PAYLOAD` so the backend can start Node children itself.
|
|
22
|
+
It needs an SDK whose daemon controller supports backend daemons
|
|
23
|
+
(`STANDALONE_DAEMON_BUILD_FAILED` otherwise) and cannot be combined with
|
|
24
|
+
`identity_gateway`. `inspect` verifies the `backend` config and the
|
|
25
|
+
backend's mode.
|
|
26
|
+
- New skill reference `daemon-mode`: when a resident backend daemon pays off
|
|
27
|
+
and when to stay with the CGI, with a decision table, the factors to
|
|
28
|
+
measure and decision steps.
|
|
29
|
+
- The route audit accepts `api.ServeIngress` (SDK) like `cgi.Serve`: called
|
|
30
|
+
directly, and never with a private or raw HTTP handler.
|
|
31
|
+
- Pin templates 0.35.0 and its SDK/App 0.37.0 defaults. `daemon.backend` and
|
|
32
|
+
`api.ServeIngress` need SDK/App 0.37.0.
|
|
33
|
+
|
|
3
34
|
## 0.39.0 (2026-10-29)
|
|
4
35
|
|
|
5
36
|
- Add schema-2 `dependencies.packages` for DSM package dependencies independent
|
package/README.md
CHANGED
|
@@ -61,6 +61,10 @@ from `target/app` as the package account for each request (see `skills/reference
|
|
|
61
61
|
Application Privileges admission, explicit method policies, gateway conflicts and
|
|
62
62
|
the build-bound SDK checks.
|
|
63
63
|
|
|
64
|
+
[External callers](docs/ctl-external.md) describes the local MCP bridge command,
|
|
65
|
+
App-declared protocols and required trusted administrator entry. This opt-in
|
|
66
|
+
contract does not add an MCP server or automatically wire an App's tool Engine.
|
|
67
|
+
|
|
64
68
|
## SDK and templates
|
|
65
69
|
|
|
66
70
|
The CLI packages, deploys and checks. The app-side code is the SDK
|
|
@@ -78,7 +82,7 @@ alongside npm lockfiles. Do not disable checksum verification for public SDK
|
|
|
78
82
|
packages. Private mirrors can use explicitly configured Go authentication and
|
|
79
83
|
checksum settings; a failed fetch reports `SDK_DOWNLOAD_FAILED`.
|
|
80
84
|
|
|
81
|
-
CLI 0.
|
|
85
|
+
CLI 0.41.0 uses templates ~0.35.0, whose default SDK pin is 0.37.0. Compose import and SDK defaults
|
|
82
86
|
read the SDK pin from the installed templates; explicit App SDK declarations take precedence.
|
|
83
87
|
Every App method must be declared in the permissions table. Dynamic names and
|
|
84
88
|
custom HTTP dispatchers are rejected. With SDK 0.33.0, use fixed
|
|
@@ -89,8 +93,8 @@ unimported development commands are excluded. Use
|
|
|
89
93
|
authentication. See [App entry and registration](docs/app-entry.md).
|
|
90
94
|
|
|
91
95
|
`init` and `add` read their templates from the locally installed
|
|
92
|
-
`@synopackageland/templates`. CLI 0.
|
|
93
|
-
update can select the latest templates patch in the 0.
|
|
96
|
+
`@synopackageland/templates`. CLI 0.41.0 depends on `~0.35.0`: npm installation or
|
|
97
|
+
update can select the latest templates patch in the 0.35 minor line. Templates
|
|
94
98
|
patches need only a templates release; update the npm installation to receive them.
|
|
95
99
|
The CLI never fetches templates at runtime, so offline use works after installation;
|
|
96
100
|
commit a lockfile and use `npm ci` to reproduce a chosen dependency tree.
|
|
@@ -600,6 +600,10 @@ func main() {
|
|
|
600
600
|
if !direct[selector] {
|
|
601
601
|
problem(selector.Pos(), "route constructor aliases cannot be audited; register methods directly")
|
|
602
602
|
}
|
|
603
|
+
case "ServeIngress":
|
|
604
|
+
if !direct[selector] {
|
|
605
|
+
problem(selector.Pos(), "ingress constructor aliases cannot be audited")
|
|
606
|
+
}
|
|
603
607
|
}
|
|
604
608
|
}
|
|
605
609
|
if module == "net/http" {
|
|
@@ -659,6 +663,10 @@ func main() {
|
|
|
659
663
|
if module == "net/http/cgi" && selector.Sel.Name == "Serve" && len(call.Args) > 0 && reusesBoundary(call.Args[0], src, sources, safe, map[ast.Node]bool{}) {
|
|
660
664
|
problem(call.Pos(), "private or raw HTTP handler reused outside its SDK boundary")
|
|
661
665
|
}
|
|
666
|
+
// The daemon ingress serves the same browser requests as cgi.Serve.
|
|
667
|
+
if module == sdkModule+"/api" && selector.Sel.Name == "ServeIngress" && len(call.Args) > 1 && reusesBoundary(call.Args[1], src, sources, safe, map[ast.Node]bool{}) {
|
|
668
|
+
problem(call.Pos(), "private or raw HTTP handler reused outside its SDK boundary")
|
|
669
|
+
}
|
|
662
670
|
if module == "github.com/synopackageland/syno-pkg-land-sdk/dsm" && selector.Sel.Name == "NewEntryGate" {
|
|
663
671
|
out.EntryGate = true
|
|
664
672
|
}
|
package/dist/cli.js
CHANGED
|
@@ -297,6 +297,10 @@ async function containerBuild(project, arches, options) {
|
|
|
297
297
|
}
|
|
298
298
|
/** Resolves after dispatch; commands may set process.exitCode. Rejections are failures. */
|
|
299
299
|
export async function runCli(argv) {
|
|
300
|
+
if (argv[2] === 'ctl' && argv[3] === 'external') {
|
|
301
|
+
const { runCtl } = await import('./ctl/cli.js');
|
|
302
|
+
return runCtl(argv.slice(3));
|
|
303
|
+
}
|
|
300
304
|
const { args, options } = nasFlags(argv, argv[2] !== "ctl");
|
|
301
305
|
return withNasOptions(options, () => runCliWithin(args));
|
|
302
306
|
}
|
package/dist/ctl/cli.d.ts
CHANGED
|
@@ -10,17 +10,6 @@ export interface CtlIO {
|
|
|
10
10
|
requestFor?: (host: Host) => JsonRequest;
|
|
11
11
|
}
|
|
12
12
|
export declare function listView(host: string, apps: Descriptor[]): {
|
|
13
|
-
host: string;
|
|
14
|
-
package: {
|
|
15
|
-
id: string;
|
|
16
|
-
version: string;
|
|
17
|
-
displayName: string;
|
|
18
|
-
};
|
|
19
|
-
aliases: string[];
|
|
20
|
-
protocolVersion: 1;
|
|
21
|
-
developedBy: string;
|
|
22
|
-
provenance: string;
|
|
23
|
-
hash: string;
|
|
24
13
|
commands: {
|
|
25
14
|
parameters: string | Record<string, any>;
|
|
26
15
|
examples: string[];
|
|
@@ -33,5 +22,19 @@ export declare function listView(host: string, apps: Descriptor[]): {
|
|
|
33
22
|
flow: import("./descriptor.js").Flow;
|
|
34
23
|
id: string;
|
|
35
24
|
}[];
|
|
25
|
+
external?: Record<string, {
|
|
26
|
+
method: string;
|
|
27
|
+
}> | undefined;
|
|
28
|
+
host: string;
|
|
29
|
+
package: {
|
|
30
|
+
id: string;
|
|
31
|
+
version: string;
|
|
32
|
+
displayName: string;
|
|
33
|
+
};
|
|
34
|
+
aliases: string[];
|
|
35
|
+
protocolVersion: 1;
|
|
36
|
+
developedBy: string;
|
|
37
|
+
provenance: string;
|
|
38
|
+
hash: string;
|
|
36
39
|
}[];
|
|
37
40
|
export declare function runCtl(argv: string[], io?: CtlIO): Promise<void>;
|
package/dist/ctl/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import { selectHosts, selectApp, selectOperation, flagName, RESERVED_FLAGS } fro
|
|
|
10
10
|
import { canonicalOrigin, CTL_TIMEOUT_MS, createRequest } from './transport.js';
|
|
11
11
|
import { loginDsm, logoutDsm } from './dsm-auth.js';
|
|
12
12
|
import { publicOperation } from './redaction.js';
|
|
13
|
+
import { runExternal } from './external.js';
|
|
13
14
|
const USAGE = 'synopkgland host add|list|show|login|logout|remove|revoke|default|trust; ctl list [--host a,b|--all-hosts] [--app pkg] [--refresh]; ctl <alias|package> <command...> [typed flags|--input file|-] [--dry-run] [--yes]; NAS HTTPS accepts any certificate by default; --strict-tls or --ca <file> opts into verification';
|
|
14
15
|
async function prompt(message, secret = false) {
|
|
15
16
|
if (!process.stdin.isTTY)
|
|
@@ -59,8 +60,10 @@ function example(host, app, op) {
|
|
|
59
60
|
return `printf '%s' ${q(JSON.stringify(params))} | ${base} --input -` + (op.effect === 'read' ? '' : ' --yes');
|
|
60
61
|
});
|
|
61
62
|
}
|
|
62
|
-
export function listView(host, apps) { return apps.map(app => ({ host, package: app.payload.package, aliases: app.payload.aliases, protocolVersion: app.version, developedBy: app.payload.provenance.developedBy, provenance: 'self-declared', hash: app.sha256, commands: Object.entries(app.payload.commands).map(([id, original]) => { const op = publicOperation(original); return { id, ...op, parameters: op.paramsSchema ?? 'Undescribed parameters; use --input', examples: example(host, app, op) }; }) })); }
|
|
63
|
+
export function listView(host, apps) { return apps.map(app => ({ host, package: app.payload.package, aliases: app.payload.aliases, protocolVersion: app.version, developedBy: app.payload.provenance.developedBy, provenance: 'self-declared', hash: app.sha256, ...(app.payload.external ? { external: app.payload.external } : {}), commands: Object.entries(app.payload.commands).map(([id, original]) => { const op = publicOperation(original); return { id, ...op, parameters: op.paramsSchema ?? 'Undescribed parameters; use --input', examples: example(host, app, op) }; }) })); }
|
|
63
64
|
export async function runCtl(argv, io = {}) {
|
|
65
|
+
if (argv[0] === 'external')
|
|
66
|
+
return runExternal(argv.slice(1), io);
|
|
64
67
|
try {
|
|
65
68
|
const { args, options } = nasFlags(argv);
|
|
66
69
|
return await withNasOptions({ ...options, warn: io.err ?? console.error }, () => runCtlWithin(args, io));
|
package/dist/ctl/descriptor.d.ts
CHANGED
package/dist/ctl/descriptor.js
CHANGED
|
@@ -27,6 +27,13 @@ export function validateDescriptor(value, id, version) {
|
|
|
27
27
|
return fail('Invalid command allowlist.');
|
|
28
28
|
if (!p.bindings || typeof p.bindings !== 'object')
|
|
29
29
|
return fail('Missing internal method bindings.');
|
|
30
|
+
const externalMethods = new Set();
|
|
31
|
+
for (const entry of Object.values(p.external ?? {})) {
|
|
32
|
+
const binding = p.bindings[entry.method];
|
|
33
|
+
if (!binding || binding.permission !== 'administrators' || binding.effect !== 'destructive' || entry.method.startsWith('identity.') || entry.method === DESCRIPTION_METHOD)
|
|
34
|
+
return fail('External entries require dedicated administrator bindings.');
|
|
35
|
+
externalMethods.add(entry.method);
|
|
36
|
+
}
|
|
30
37
|
const paths = new Set();
|
|
31
38
|
for (const [method, raw] of Object.entries(p.commands)) {
|
|
32
39
|
const op = raw;
|
|
@@ -38,6 +45,8 @@ export function validateDescriptor(value, id, version) {
|
|
|
38
45
|
catch {
|
|
39
46
|
return fail('Invalid command flow or method bindings.');
|
|
40
47
|
}
|
|
48
|
+
if (externalMethods.has(method) || flowMethods(op.flow).some(name => externalMethods.has(name)))
|
|
49
|
+
return fail('External entries cannot be ordinary commands or flow helpers.');
|
|
41
50
|
const path = op.cli.join(' ');
|
|
42
51
|
if (paths.has(path))
|
|
43
52
|
return fail('Duplicate CLI command path.');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { CtlStore, type Host } from './store.js';
|
|
2
|
+
import { type JsonRequest } from './transport.js';
|
|
3
|
+
import type { CtlIO } from './cli.js';
|
|
4
|
+
type Describe = {
|
|
5
|
+
protocol: string;
|
|
6
|
+
action: 'describe';
|
|
7
|
+
};
|
|
8
|
+
type Call = {
|
|
9
|
+
protocol: string;
|
|
10
|
+
action: 'call';
|
|
11
|
+
revision: string;
|
|
12
|
+
method: string;
|
|
13
|
+
arguments: Record<string, unknown>;
|
|
14
|
+
};
|
|
15
|
+
export type ExternalRequest = Describe | Call;
|
|
16
|
+
export declare function parseExternalRequest(raw: string, protocol: string): ExternalRequest;
|
|
17
|
+
/** A separate entry, never an ordinary ctl flow with relabeled provenance. */
|
|
18
|
+
export declare class ExternalClient {
|
|
19
|
+
private readonly store;
|
|
20
|
+
private readonly host;
|
|
21
|
+
private readonly request;
|
|
22
|
+
private readonly deadline;
|
|
23
|
+
constructor(store: CtlStore, host: Host, request?: JsonRequest);
|
|
24
|
+
execute(protocol: string, input: ExternalRequest): Promise<unknown>;
|
|
25
|
+
}
|
|
26
|
+
export declare function runExternal(argv: string[], io?: CtlIO): Promise<void>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { CtlError, ctlError } from './errors.js';
|
|
2
|
+
import { readBounded, strictJson } from './json.js';
|
|
3
|
+
import { ajv, safeSchema, validateDescriptor } from './descriptor.js';
|
|
4
|
+
import { CtlStore } from './store.js';
|
|
5
|
+
import { CtlClient } from './service.js';
|
|
6
|
+
import { canonicalOrigin, createRequest } from './transport.js';
|
|
7
|
+
import { nasOptions } from '../nas/options.js';
|
|
8
|
+
import { SensitiveOutput } from './redaction.js';
|
|
9
|
+
const LIMIT = 1048576;
|
|
10
|
+
const PROTOCOL = /^[a-z][a-z0-9-]{0,63}$/;
|
|
11
|
+
const METHOD = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
|
|
12
|
+
const AUTHORITY_KEYS = ['actor', 'via', 'allowMutation', 'allow_mutation', 'host'];
|
|
13
|
+
const USAGE = 'synopkgland ctl external --protocol <declared-protocol> --host <profile> --input - --json';
|
|
14
|
+
const HINT = 'Check local login, administrator access, App approval and tool revision. Query original state before repeating a mutation.';
|
|
15
|
+
function fail(code, message, exit = 4) { throw new CtlError(code, message, HINT, exit); }
|
|
16
|
+
function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
17
|
+
function revision(value) { return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value); }
|
|
18
|
+
export function parseExternalRequest(raw, protocol) {
|
|
19
|
+
try {
|
|
20
|
+
if (!PROTOCOL.test(protocol) || Buffer.byteLength(raw) > LIMIT)
|
|
21
|
+
throw Error();
|
|
22
|
+
const value = strictJson(raw);
|
|
23
|
+
if (!object(value) || value.protocol !== protocol)
|
|
24
|
+
throw Error();
|
|
25
|
+
const keys = value.action === 'describe' ? ['protocol', 'action'] : ['protocol', 'action', 'revision', 'method', 'arguments'];
|
|
26
|
+
if (Object.keys(value).length !== keys.length || keys.some(key => !Object.hasOwn(value, key)))
|
|
27
|
+
throw Error();
|
|
28
|
+
if (value.action === 'describe')
|
|
29
|
+
return value;
|
|
30
|
+
if (value.action !== 'call' || !revision(value.revision) || typeof value.method !== 'string' || value.method.length > 128 || !METHOD.test(value.method) || !object(value.arguments) || AUTHORITY_KEYS.some(key => Object.hasOwn(value.arguments, key)))
|
|
31
|
+
throw Error();
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return fail('CTL_EXTERNAL_INPUT', 'Invalid external request.', 3);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function validateCatalog(value) {
|
|
39
|
+
if (!object(value) || !revision(value.revision) || !Array.isArray(value.tools) || value.tools.length > 1024)
|
|
40
|
+
return fail('CTL_EXTERNAL_CATALOG', 'Invalid external tool catalog.', 5);
|
|
41
|
+
const methods = new Set(), names = new Set();
|
|
42
|
+
for (const tool of value.tools) {
|
|
43
|
+
if (!object(tool) || typeof tool.name !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(tool.name) ||
|
|
44
|
+
!object(tool._meta) || typeof tool._meta.method !== 'string' || tool._meta.method.length > 128 || !METHOD.test(tool._meta.method) ||
|
|
45
|
+
methods.has(tool._meta.method) || names.has(tool.name) || !object(tool.inputSchema) || !object(tool.outputSchema))
|
|
46
|
+
return fail('CTL_EXTERNAL_CATALOG', 'Invalid external tool metadata.', 5);
|
|
47
|
+
methods.add(tool._meta.method);
|
|
48
|
+
names.add(tool.name);
|
|
49
|
+
try {
|
|
50
|
+
for (const schema of [tool.inputSchema, tool.outputSchema]) {
|
|
51
|
+
safeSchema(schema);
|
|
52
|
+
ajv.compile(schema);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return fail('CTL_EXTERNAL_CATALOG', 'Invalid external tool schema.', 5);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
/** A separate entry, never an ordinary ctl flow with relabeled provenance. */
|
|
62
|
+
export class ExternalClient {
|
|
63
|
+
store;
|
|
64
|
+
host;
|
|
65
|
+
request;
|
|
66
|
+
deadline = performance.now() + 30000;
|
|
67
|
+
constructor(store, host, request) {
|
|
68
|
+
this.store = store;
|
|
69
|
+
this.host = host;
|
|
70
|
+
this.request = request ?? createRequest(host.origin, () => Math.max(0, this.deadline - performance.now()), undefined, { ...store.trust(host), expectedFingerprint: host.fingerprint });
|
|
71
|
+
}
|
|
72
|
+
async execute(protocol, input) {
|
|
73
|
+
input = parseExternalRequest(JSON.stringify(input), protocol);
|
|
74
|
+
// Ambient credentials and URL overrides are for ordinary automation only.
|
|
75
|
+
if (this.host.ephemeral || canonicalOrigin(this.host.origin, !!this.host.tunnel) !== this.host.origin ||
|
|
76
|
+
this.host.origin.startsWith('https:') && !/^[a-f0-9]{64}$/.test(this.host.fingerprint ?? ''))
|
|
77
|
+
fail('CTL_EXTERNAL_HOST', 'External callers require a registered, pinned host.');
|
|
78
|
+
const snapshot = JSON.stringify(this.host);
|
|
79
|
+
const session = await this.store.session(this.host, { storedOnly: true });
|
|
80
|
+
const assertCurrent = async () => {
|
|
81
|
+
if (JSON.stringify(this.store.get(this.host.name)) !== snapshot)
|
|
82
|
+
fail('CTL_EXTERNAL_PINS_CHANGED', 'Host or App approval changed.');
|
|
83
|
+
const current = await this.store.session(this.host, { storedOnly: true });
|
|
84
|
+
if (current.cookie !== session.cookie || current.token !== session.token)
|
|
85
|
+
fail('CTL_NOT_AUTHENTICATED', 'Stored login changed.', 2);
|
|
86
|
+
};
|
|
87
|
+
await assertCurrent();
|
|
88
|
+
const packages = await new CtlClient(this.store, this.host, this.request).inventory(session);
|
|
89
|
+
// Cached descriptors select an already approved credential recipient only.
|
|
90
|
+
// Neither cached schemas nor a new package may establish external authority.
|
|
91
|
+
const candidates = packages.filter(pkg => {
|
|
92
|
+
const approval = this.host.approved[pkg.id], cached = this.store.cache(this.host, pkg.id, pkg.version);
|
|
93
|
+
return approval?.version === pkg.version && approval.hash && cached?.hash === approval.hash &&
|
|
94
|
+
Object.hasOwn(cached.description?.payload?.external ?? {}, protocol);
|
|
95
|
+
});
|
|
96
|
+
if (candidates.length !== 1)
|
|
97
|
+
fail('CTL_EXTERNAL_PROTOCOL', 'Protocol must match exactly one pinned App.');
|
|
98
|
+
const pkg = candidates[0], approval = this.host.approved[pkg.id];
|
|
99
|
+
await assertCurrent();
|
|
100
|
+
const path = `/webman/3rdparty/${pkg.id}/cgi/api.cgi`;
|
|
101
|
+
const described = await this.request(path, { method: 'synopkgland.describe', params: {} }, session);
|
|
102
|
+
if (described?.success !== true)
|
|
103
|
+
fail('CTL_EXTERNAL_DENIED', 'App description refused.');
|
|
104
|
+
const app = validateDescriptor(described.data, pkg.id, pkg.version);
|
|
105
|
+
if (app.sha256 !== approval.hash)
|
|
106
|
+
fail('CTL_DESCRIPTION_CHANGED', 'Pinned App description changed.');
|
|
107
|
+
const entry = app.payload.external?.[protocol];
|
|
108
|
+
if (!entry)
|
|
109
|
+
fail('CTL_EXTERNAL_PROTOCOL', 'App does not declare this protocol.');
|
|
110
|
+
if (app.payload.transport.auth !== 'dsm-session')
|
|
111
|
+
fail('CTL_EXTERNAL_TRANSPORT', 'External callers require the DSM session transport.');
|
|
112
|
+
const pins = { package: { id: pkg.id, version: pkg.version }, descriptionSha256: app.sha256 };
|
|
113
|
+
const output = new SensitiveOutput({ carriers: [session.token, session.cookie, ...session.cookie.split(';').map(part => part.slice(part.indexOf('=') + 1).trim())] }, { type: 'object', properties: { carriers: { writeOnly: true } } });
|
|
114
|
+
const invoke = async (request) => {
|
|
115
|
+
await assertCurrent();
|
|
116
|
+
// The App must verify these pins and revision in its dispatch boundary,
|
|
117
|
+
// freshly authenticate an administrator and derive authority server-side.
|
|
118
|
+
const reply = await this.request(path, { method: entry.method, params: { ...request, ...pins } }, session);
|
|
119
|
+
if (reply?.success !== true)
|
|
120
|
+
fail('CTL_EXTERNAL_DENIED', 'Trusted external entry refused.');
|
|
121
|
+
const proof = reply.data?.verified;
|
|
122
|
+
if (!object(proof) || proof.protocol !== protocol || proof.package?.id !== pkg.id || proof.package?.version !== pkg.version ||
|
|
123
|
+
proof.descriptionSha256 !== app.sha256 || proof.user !== session.username || proof.administrator !== true ||
|
|
124
|
+
!revision(proof.revision) || request.action === 'call' && proof.revision !== request.revision ||
|
|
125
|
+
request.action === 'describe' && proof.revision !== reply.data?.data?.revision)
|
|
126
|
+
fail('CTL_EXTERNAL_VERIFICATION', 'External verification did not match the pinned request.', 5);
|
|
127
|
+
return reply.data.data;
|
|
128
|
+
};
|
|
129
|
+
const catalog = validateCatalog(await invoke({ protocol, action: 'describe' }));
|
|
130
|
+
if (input.action === 'describe')
|
|
131
|
+
return output.clean(catalog);
|
|
132
|
+
if (catalog.revision !== input.revision)
|
|
133
|
+
fail('CTL_EXTERNAL_REVISION', 'External tool revision changed.');
|
|
134
|
+
const tool = catalog.tools.find(tool => tool._meta.method === input.method);
|
|
135
|
+
if (!tool)
|
|
136
|
+
fail('CTL_EXTERNAL_METHOD', 'Method is not an external tool.');
|
|
137
|
+
if (!ajv.validate(tool.inputSchema, input.arguments))
|
|
138
|
+
fail('CTL_EXTERNAL_INPUT', 'Arguments do not match the current tool schema.', 3);
|
|
139
|
+
const sensitive = new SensitiveOutput(input.arguments, tool.inputSchema);
|
|
140
|
+
// Exactly one call attempt. Errors, lost replies and invalid results never replay it.
|
|
141
|
+
const result = await invoke(input);
|
|
142
|
+
if (!ajv.validate(tool.outputSchema, result))
|
|
143
|
+
fail('CTL_EXTERNAL_RESULT', 'External result does not match its schema.', 5);
|
|
144
|
+
return output.clean(sensitive.clean(result, tool.outputSchema));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export async function runExternal(argv, io = {}) {
|
|
148
|
+
const out = io.out ?? console.log;
|
|
149
|
+
try {
|
|
150
|
+
if (argv.length === 1 && argv[0] === '--help') {
|
|
151
|
+
out(JSON.stringify({ ok: true, data: { usage: USAGE } }));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const values = new Map();
|
|
155
|
+
for (let i = 0; i < argv.length; i++) {
|
|
156
|
+
const flag = argv[i], key = flag.split('=')[0];
|
|
157
|
+
if (!['--protocol', '--host', '--input', '--json'].includes(key) || values.has(key))
|
|
158
|
+
fail('CTL_EXTERNAL_FLAGS', 'Invalid external options.', 3);
|
|
159
|
+
if (key === '--json') {
|
|
160
|
+
if (flag !== key)
|
|
161
|
+
fail('CTL_EXTERNAL_FLAGS', 'Invalid JSON option.', 3);
|
|
162
|
+
values.set(key, 'true');
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const value = flag.includes('=') ? flag.slice(key.length + 1) : argv[++i];
|
|
166
|
+
if (!value || value.startsWith('--'))
|
|
167
|
+
fail('CTL_EXTERNAL_FLAGS', 'Missing external option value.', 3);
|
|
168
|
+
values.set(key, value);
|
|
169
|
+
}
|
|
170
|
+
const protocol = values.get('--protocol'), hostName = values.get('--host');
|
|
171
|
+
if (values.size !== 4 || !protocol || !PROTOCOL.test(protocol) || !hostName || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(hostName) ||
|
|
172
|
+
values.get('--input') !== '-' || nasOptions().url || nasOptions().ca || nasOptions().strictTls)
|
|
173
|
+
fail('CTL_EXTERNAL_FLAGS', 'Use the fixed external command contract.', 3);
|
|
174
|
+
let raw;
|
|
175
|
+
try {
|
|
176
|
+
raw = readBounded(0);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return fail('CTL_EXTERNAL_INPUT', 'Cannot read bounded UTF-8 stdin.', 3);
|
|
180
|
+
}
|
|
181
|
+
const input = parseExternalRequest(raw, protocol);
|
|
182
|
+
const store = io.store ?? new CtlStore(), host = store.get(hostName);
|
|
183
|
+
const data = await new ExternalClient(store, host, io.requestFor?.(host)).execute(protocol, input);
|
|
184
|
+
const line = JSON.stringify({ ok: true, data });
|
|
185
|
+
if (Buffer.byteLength(line + '\n') > LIMIT)
|
|
186
|
+
fail('CTL_EXTERNAL_OUTPUT', 'External output exceeds 1 MiB.', 5);
|
|
187
|
+
out(line);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
const e = ctlError(error);
|
|
191
|
+
// Never echo upstream messages, input, session carriers or exception text.
|
|
192
|
+
out(JSON.stringify({ ok: false, error: { code: e.code, message: 'External request refused.', hint: HINT } }));
|
|
193
|
+
process.exitCode = e.exitCode;
|
|
194
|
+
}
|
|
195
|
+
}
|
package/dist/nas/store.d.ts
CHANGED
|
@@ -44,7 +44,9 @@ export declare class CtlStore {
|
|
|
44
44
|
save(host: Host): void;
|
|
45
45
|
add(host: Host, session: DsmSession): Promise<void>;
|
|
46
46
|
trust(host: Host): HostTls;
|
|
47
|
-
session(host: Host
|
|
47
|
+
session(host: Host, options?: {
|
|
48
|
+
storedOnly?: boolean;
|
|
49
|
+
}): Promise<DsmSession>;
|
|
48
50
|
cacheRoot(host: Host): string;
|
|
49
51
|
cacheFile(host: Host, pkg: string, version: string): string;
|
|
50
52
|
cache(host: Host, pkg: string, version: string): any | null;
|
package/dist/nas/store.js
CHANGED
|
@@ -94,9 +94,9 @@ export class CtlStore {
|
|
|
94
94
|
} },
|
|
95
95
|
record: (fingerprint) => { host.fingerprint = fingerprint; this.save(host); }, warn: override.warn };
|
|
96
96
|
}
|
|
97
|
-
async session(host) {
|
|
97
|
+
async session(host, options = {}) {
|
|
98
98
|
let session;
|
|
99
|
-
if (process.env.DSM_USERNAME && process.env.DSM_PASSWORD) {
|
|
99
|
+
if (!options.storedOnly && process.env.DSM_USERNAME && process.env.DSM_PASSWORD) {
|
|
100
100
|
if (process.env.DSM_USERNAME !== host.username)
|
|
101
101
|
throw new CtlError('CTL_CREDENTIAL_MISMATCH', 'Automation account differs from the registered host.', 'Use the registered account or a separately named host.', 2);
|
|
102
102
|
session = await loginDsm(host.origin, host.username, process.env.DSM_PASSWORD, { remember: false, otp: async () => process.env.DSM_OTP ?? '', request: createRequest(host.origin, 30000, undefined, this.trust(host)) });
|
package/dist/skill.js
CHANGED
|
@@ -57,6 +57,11 @@ const SKILL_CATALOG = [
|
|
|
57
57
|
file: "reference/backup.md",
|
|
58
58
|
when: "Change or opt out of Hyper Backup application backup",
|
|
59
59
|
},
|
|
60
|
+
{
|
|
61
|
+
id: "daemon-mode",
|
|
62
|
+
file: "reference/daemon-mode.md",
|
|
63
|
+
when: "Decide whether the backend should run as a resident daemon (daemon.backend)",
|
|
64
|
+
},
|
|
60
65
|
{ id: "deploy", file: "reference/deploy.md", when: "Deploy to a NAS" },
|
|
61
66
|
{
|
|
62
67
|
id: "diagnose",
|
|
@@ -34,6 +34,7 @@ export async function buildApiDescription(m, bridgeMethods, run) {
|
|
|
34
34
|
throw new StandaloneBuildError('CTL_DESCRIBE_FAILED', 'Invalid registry snapshot.');
|
|
35
35
|
const registry = new Map(methods.map(item => [item.method, item.permission]));
|
|
36
36
|
const rawHTTP = new Set(methods.filter(item => item.kind === 'http').map(item => item.method));
|
|
37
|
+
const nonRPC = new Set(methods.filter(item => item.kind && item.kind !== 'json').map(item => item.method));
|
|
37
38
|
// Identity transport routes are outside the business registry but have immutable SDK ownership.
|
|
38
39
|
if (m.capabilities.identity) {
|
|
39
40
|
registry.set('identity.session', 'public');
|
|
@@ -41,6 +42,16 @@ export async function buildApiDescription(m, bridgeMethods, run) {
|
|
|
41
42
|
}
|
|
42
43
|
const bindings = {};
|
|
43
44
|
const commands = {};
|
|
45
|
+
if (m.ctl.external) {
|
|
46
|
+
if (m.capabilities.identity)
|
|
47
|
+
throw new StandaloneBuildError('CTL_EXTERNAL_TRANSPORT', 'External entries require the DSM session transport.');
|
|
48
|
+
for (const entry of Object.values(m.ctl.external)) {
|
|
49
|
+
if (!entry || typeof entry.method !== 'string' || !registry.has(entry.method) || nonRPC.has(entry.method) || m.entry !== 'admin-only' && registry.get(entry.method) !== 'administrators')
|
|
50
|
+
throw new StandaloneBuildError('CTL_EXTERNAL_ENTRY', 'External entries require registered administrator RPC methods.');
|
|
51
|
+
registry.set(entry.method, 'administrators');
|
|
52
|
+
bindings[entry.method] = { permission: 'administrators', effect: 'destructive' };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
44
55
|
const weights = { read: 0, write: 1, destructive: 2 };
|
|
45
56
|
for (const [id, declared] of Object.entries(m.ctl.commands)) {
|
|
46
57
|
const command = structuredClone(declared);
|
|
@@ -66,7 +77,7 @@ export async function buildApiDescription(m, bridgeMethods, run) {
|
|
|
66
77
|
for (const method of ['identity.session', 'identity.logout'])
|
|
67
78
|
bindings[method] = { permission: 'public', effect: 'write' };
|
|
68
79
|
}
|
|
69
|
-
const description = makeDescriptor({ package: { id: m.package, version: m.spkVersion, displayName: m.displayname }, aliases: m.ctl.aliases ?? [m.package.toLowerCase()], provenance: { developedBy: m.ctl.developedBy ?? 'unknown', generator: 'synopkgland-cli' }, entry: m.entry ?? 'policy', transport: { path: `/webman/3rdparty/${m.package}/cgi/api.cgi`, auth: m.capabilities.identity ? 'dsm-app-session' : 'dsm-session' }, commands, bindings });
|
|
80
|
+
const description = makeDescriptor({ package: { id: m.package, version: m.spkVersion, displayName: m.displayname }, aliases: m.ctl.aliases ?? [m.package.toLowerCase()], provenance: { developedBy: m.ctl.developedBy ?? 'unknown', generator: 'synopkgland-cli' }, entry: m.entry ?? 'policy', transport: { path: `/webman/3rdparty/${m.package}/cgi/api.cgi`, auth: m.capabilities.identity ? 'dsm-app-session' : 'dsm-session' }, commands, bindings, ...(m.ctl.external ? { external: m.ctl.external } : {}) });
|
|
70
81
|
try {
|
|
71
82
|
return { description: validateDescriptor(description, m.package, m.spkVersion), registry: Object.fromEntries(registry) };
|
|
72
83
|
}
|
|
@@ -7,8 +7,13 @@ import type { StandaloneError, StandaloneManifest } from "./manifest.js";
|
|
|
7
7
|
export interface Daemon {
|
|
8
8
|
/** Optional bounded recovery; omitted means off. SDK 0.26.0 or later. */
|
|
9
9
|
restart?: DaemonRestart;
|
|
10
|
-
/** Fixed foreground executable relative to daemon/payload. */
|
|
10
|
+
/** Fixed foreground executable relative to daemon/payload; empty for a backend daemon. */
|
|
11
11
|
exec: string;
|
|
12
|
+
/**
|
|
13
|
+
* The App's own audited backend (ui/cgi/api.cgi) runs as the daemon with
|
|
14
|
+
* `args`; `node`, if set, is then only a runtime for the backend's children.
|
|
15
|
+
*/
|
|
16
|
+
backend?: true;
|
|
12
17
|
/** Architecture-specific project directories, copied without links. */
|
|
13
18
|
targets: Partial<Record<NativeArchFamily, string>>;
|
|
14
19
|
args: string[];
|
|
@@ -56,6 +61,10 @@ export declare function daemonConfig(manifest: StandaloneManifest): string;
|
|
|
56
61
|
* launcher with its entry first, and ingress names the socket to listen on.
|
|
57
62
|
*/
|
|
58
63
|
export declare function runnableDaemon(pkg: string, daemon: Daemon): Daemon;
|
|
64
|
+
/** What a backend daemon learns about the Node runtime it may start children with. */
|
|
65
|
+
export declare const backendNodeEnv: () => Record<string, string>;
|
|
66
|
+
/** daemonctl runs ui/cgi/api.cgi (`"backend": true`) from SDK/App 0.37.0. */
|
|
67
|
+
export declare function requireBackendDaemonSource(source: string, daemon: Daemon | undefined): void;
|
|
59
68
|
export declare function buildDaemonControl(manifest: StandaloneManifest, arch: NativeArchFamily, work: string, run: CommandRunner): Promise<string | undefined>;
|
|
60
69
|
export declare function copyDaemonPayload(manifest: StandaloneManifest, arch: NativeArchFamily, target: string, controller: string): void;
|
|
61
70
|
/** Copies a bounded, link-free package-account payload tree. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { operationBudgetSeconds, runtimeBudgetSdkSupported } from "../runtime/runtime-budgets.js";
|
|
2
2
|
import { goChildEnv } from "../runtime/go-env.js";
|
|
3
|
-
import { chmodSync, existsSync, copyFileSync, lstatSync, mkdirSync, readdirSync, realpathSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { chmodSync, existsSync, copyFileSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync, } from "node:fs";
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
5
|
import { PINNED_GO_HINT, pinnedGoBuildEnv, pinnedGoProblem, } from "../build-spk/go-toolchain.js";
|
|
6
6
|
import { sdkSource, appSdkVersion } from "../runtime/sdk-source.js";
|
|
@@ -35,6 +35,7 @@ exec "$node" "$@"
|
|
|
35
35
|
}
|
|
36
36
|
const fields = new Set([
|
|
37
37
|
"exec",
|
|
38
|
+
"backend",
|
|
38
39
|
"targets",
|
|
39
40
|
"args",
|
|
40
41
|
"env",
|
|
@@ -69,19 +70,25 @@ export function readDaemon(value, root, arches, fail) {
|
|
|
69
70
|
const invalid = () => {
|
|
70
71
|
fail({
|
|
71
72
|
code: "STANDALONE_DAEMON_INVALID",
|
|
72
|
-
message: "daemon accepts only fixed exec, targets, args, env and timeouts; paths must be inside the project, and the environment does not accept loader/shell/system variables.",
|
|
73
|
+
message: "daemon accepts only fixed exec (or backend: true), targets, args, env and timeouts; paths must be inside the project, and the environment does not accept loader/shell/system variables.",
|
|
73
74
|
});
|
|
74
75
|
return undefined;
|
|
75
76
|
};
|
|
76
77
|
if (!record(value) || Object.keys(value).some((k) => !fields.has(k)))
|
|
77
78
|
return invalid();
|
|
78
|
-
|
|
79
|
+
const backend = value.backend === true;
|
|
80
|
+
if (value.backend !== undefined && !backend)
|
|
79
81
|
return invalid();
|
|
80
|
-
if (!
|
|
81
|
-
|
|
82
|
+
if (backend ? value.exec !== undefined : !(value.node === undefined ? relativeExec(value.exec) : relativeEntry(value.exec)))
|
|
83
|
+
return invalid();
|
|
84
|
+
// A backend daemon needs a payload only for its children's files.
|
|
85
|
+
const payload = !backend || value.targets !== undefined;
|
|
86
|
+
if (payload &&
|
|
87
|
+
(!record(value.targets) ||
|
|
88
|
+
Object.keys(value.targets).length !== arches.length))
|
|
82
89
|
return invalid();
|
|
83
90
|
const targets = {};
|
|
84
|
-
for (const arch of arches) {
|
|
91
|
+
for (const arch of payload ? arches : []) {
|
|
85
92
|
const path = value.targets[arch];
|
|
86
93
|
if (typeof path !== "string" ||
|
|
87
94
|
!path.trim() ||
|
|
@@ -123,8 +130,11 @@ export function readDaemon(value, root, arches, fail) {
|
|
|
123
130
|
return undefined;
|
|
124
131
|
if (ingress && Object.keys(env).some((key) => key in ingressEnv("", ingress)))
|
|
125
132
|
return invalid();
|
|
133
|
+
if (backend && node && Object.keys(env).some((key) => key in backendNodeEnv()))
|
|
134
|
+
return invalid();
|
|
126
135
|
return {
|
|
127
|
-
exec: value.exec,
|
|
136
|
+
exec: backend ? "" : value.exec,
|
|
137
|
+
...(backend ? { backend: true } : {}),
|
|
128
138
|
targets,
|
|
129
139
|
args,
|
|
130
140
|
...(uninstall ? { uninstall_args: uninstall } : {}),
|
|
@@ -171,7 +181,7 @@ export function daemonConfig(manifest) {
|
|
|
171
181
|
: runnableDaemon(manifest.package, manifest.daemon);
|
|
172
182
|
return (JSON.stringify({
|
|
173
183
|
package: manifest.package,
|
|
174
|
-
exec,
|
|
184
|
+
...(!manifest.compose && manifest.daemon?.backend ? { backend: true } : { exec }),
|
|
175
185
|
args,
|
|
176
186
|
...(uninstall_args ? { uninstall_args } : {}),
|
|
177
187
|
...(restart ? { restart } : {}),
|
|
@@ -187,8 +197,12 @@ export function daemonConfig(manifest) {
|
|
|
187
197
|
* launcher with its entry first, and ingress names the socket to listen on.
|
|
188
198
|
*/
|
|
189
199
|
export function runnableDaemon(pkg, daemon) {
|
|
190
|
-
const entry = daemon.node && [`\${SYNOPKG_PKGDEST}/daemon/payload/${daemon.exec}`];
|
|
191
|
-
const env =
|
|
200
|
+
const entry = daemon.node && !daemon.backend && [`\${SYNOPKG_PKGDEST}/daemon/payload/${daemon.exec}`];
|
|
201
|
+
const env = {
|
|
202
|
+
...daemon.env,
|
|
203
|
+
...(daemon.ingress ? ingressEnv(pkg, daemon.ingress) : {}),
|
|
204
|
+
...(daemon.backend && daemon.node ? backendNodeEnv() : {}),
|
|
205
|
+
};
|
|
192
206
|
return {
|
|
193
207
|
...daemon,
|
|
194
208
|
...(entry ? { exec: NODE_LAUNCHER, args: [...entry, ...daemon.args] } : {}),
|
|
@@ -196,6 +210,19 @@ export function runnableDaemon(pkg, daemon) {
|
|
|
196
210
|
env: Object.fromEntries(Object.keys(env).sort().map((key) => [key, env[key]])),
|
|
197
211
|
};
|
|
198
212
|
}
|
|
213
|
+
/** What a backend daemon learns about the Node runtime it may start children with. */
|
|
214
|
+
export const backendNodeEnv = () => ({
|
|
215
|
+
SYNOPKGLAND_DAEMON_PAYLOAD: "${SYNOPKG_PKGDEST}/daemon/payload",
|
|
216
|
+
SYNOPKGLAND_NODE: `\${SYNOPKG_PKGDEST}/daemon/payload/${NODE_LAUNCHER}`,
|
|
217
|
+
});
|
|
218
|
+
/** daemonctl runs ui/cgi/api.cgi (`"backend": true`) from SDK/App 0.37.0. */
|
|
219
|
+
export function requireBackendDaemonSource(source, daemon) {
|
|
220
|
+
if (!daemon?.backend)
|
|
221
|
+
return;
|
|
222
|
+
const main = join(source, "daemonctl/main.go");
|
|
223
|
+
if (!existsSync(main) || !readFileSync(main, "utf8").includes("backendExecutable"))
|
|
224
|
+
throw new StandaloneBuildError("STANDALONE_DAEMON_BUILD_FAILED", "daemon.backend needs an SDK whose daemon controller can run the backend (SDK/App 0.37.0 or later); upgrade the App SDK.");
|
|
225
|
+
}
|
|
199
226
|
export async function buildDaemonControl(manifest, arch, work, run) {
|
|
200
227
|
if (!manifest.daemon && !(manifest.compose && manifest.identityGateway))
|
|
201
228
|
return undefined;
|
|
@@ -203,6 +230,7 @@ export async function buildDaemonControl(manifest, arch, work, run) {
|
|
|
203
230
|
const sdk = daemonSdkVersion(manifest);
|
|
204
231
|
const source = runtimeBudgetSource(sdkSource(sdk).goDir, manifest);
|
|
205
232
|
requireDaemonRestartSource(source, sdk, manifest.daemon?.restart);
|
|
233
|
+
requireBackendDaemonSource(source, manifest.daemon);
|
|
206
234
|
const result = await run("go", [
|
|
207
235
|
"build",
|
|
208
236
|
"-trimpath",
|
|
@@ -233,16 +261,16 @@ export function copyDaemonPayload(manifest, arch, target, controller) {
|
|
|
233
261
|
copyPayloadTree(manifest.root, source, join(target, "payload"));
|
|
234
262
|
else
|
|
235
263
|
mkdirSync(join(target, "payload"), { recursive: true });
|
|
236
|
-
if (!manifest.compose && manifest.daemon?.
|
|
264
|
+
if (!manifest.compose && manifest.daemon?.backend) {
|
|
265
|
+
// The CLI installs the backend itself at ui/cgi/api.cgi.
|
|
266
|
+
if (manifest.daemon.node)
|
|
267
|
+
writeNodeLauncher(source, target, manifest.daemon.node);
|
|
268
|
+
}
|
|
269
|
+
else if (!manifest.compose && manifest.daemon?.node) {
|
|
237
270
|
const exec = join(source, daemon.exec);
|
|
238
271
|
if (!existsSync(exec) || !lstatSync(exec).isFile())
|
|
239
272
|
throw new StandaloneBuildError("STANDALONE_DAEMON_PAYLOAD_INVALID", `daemon.exec must name the Node entry file inside the payload: ${exec}`);
|
|
240
|
-
|
|
241
|
-
throw new StandaloneBuildError("STANDALONE_DAEMON_PAYLOAD_INVALID", `The daemon payload must not contain ${NODE_LAUNCHER.split("/")[0]}/; the CLI writes its Node launcher there.`);
|
|
242
|
-
mkdirSync(join(target, "payload", dirname(NODE_LAUNCHER)), { recursive: true });
|
|
243
|
-
chmodSync(join(target, "payload", dirname(NODE_LAUNCHER)), 0o755);
|
|
244
|
-
writeFileSync(join(target, "payload", NODE_LAUNCHER), renderNodeLauncher(manifest.daemon.node));
|
|
245
|
-
chmodSync(join(target, "payload", NODE_LAUNCHER), 0o755);
|
|
273
|
+
writeNodeLauncher(source, target, manifest.daemon.node);
|
|
246
274
|
}
|
|
247
275
|
else if (!manifest.compose) {
|
|
248
276
|
const exec = join(source, daemon.exec);
|
|
@@ -257,6 +285,14 @@ export function copyDaemonPayload(manifest, arch, target, controller) {
|
|
|
257
285
|
mode: 0o644,
|
|
258
286
|
});
|
|
259
287
|
}
|
|
288
|
+
function writeNodeLauncher(source, target, node) {
|
|
289
|
+
if (source && existsSync(join(source, NODE_LAUNCHER.split("/")[0])))
|
|
290
|
+
throw new StandaloneBuildError("STANDALONE_DAEMON_PAYLOAD_INVALID", `The daemon payload must not contain ${NODE_LAUNCHER.split("/")[0]}/; the CLI writes its Node launcher there.`);
|
|
291
|
+
mkdirSync(join(target, "payload", dirname(NODE_LAUNCHER)), { recursive: true });
|
|
292
|
+
chmodSync(join(target, "payload", dirname(NODE_LAUNCHER)), 0o755);
|
|
293
|
+
writeFileSync(join(target, "payload", NODE_LAUNCHER), renderNodeLauncher(node));
|
|
294
|
+
chmodSync(join(target, "payload", NODE_LAUNCHER), 0o755);
|
|
295
|
+
}
|
|
260
296
|
/** Copies a bounded, link-free package-account payload tree. */
|
|
261
297
|
export function copyPayloadTree(root, source, target) {
|
|
262
298
|
const invalid = (message) => {
|
|
@@ -24,7 +24,7 @@ import { familyOfArchToken, NATIVE_ARCH_FAMILIES, } from "../build-spk/dsm-arch.
|
|
|
24
24
|
import { CONTAINER_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, PORTAL_MIN_DSM, PORTAL_MIN_DSM_REASON, } from "../build-spk/dsm-version.js";
|
|
25
25
|
import { ELF_MACHINE } from "./binary.js";
|
|
26
26
|
import { CGI_PAYLOAD_PATH, entryLauncherAccess } from "./dsm-window.js";
|
|
27
|
-
import { daemonConfig, readDaemon, readLifecycleTimeouts, renderNodeLauncher, nodeDependency, NODE_LAUNCHER } from "./daemon.js";
|
|
27
|
+
import { backendNodeEnv, daemonConfig, readDaemon, readLifecycleTimeouts, renderNodeLauncher, nodeDependency, NODE_LAUNCHER } from "./daemon.js";
|
|
28
28
|
import { DAEMON_INGRESS_CONFIG, daemonIngressMinDsmProblem, readDaemonIngress, renderDaemonIngressConfig } from "./daemon-ingress.js";
|
|
29
29
|
import { daemonRestartSdkProblem, readDaemonRestart } from "./daemon-restart.js";
|
|
30
30
|
import { ROOT_SCRIPT_HINTS } from "./root-ops.js";
|
|
@@ -970,6 +970,7 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
|
|
|
970
970
|
Object.keys(config).some((key) => ![
|
|
971
971
|
"package",
|
|
972
972
|
"exec",
|
|
973
|
+
"backend",
|
|
973
974
|
"args",
|
|
974
975
|
"env",
|
|
975
976
|
"timeouts",
|
|
@@ -981,8 +982,11 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
|
|
|
981
982
|
throw new Error("package/config mismatch");
|
|
982
983
|
if (!isDeepStrictEqual(config.restart, extras.restart))
|
|
983
984
|
throw new Error("daemon restart differs from its build declaration");
|
|
985
|
+
const backend = config.backend === true;
|
|
986
|
+
if (config.backend !== undefined && !backend)
|
|
987
|
+
throw new Error("invalid backend flag");
|
|
984
988
|
const daemon = readDaemon({
|
|
985
|
-
exec: config.exec,
|
|
989
|
+
...(backend ? { backend: true } : { exec: config.exec }),
|
|
986
990
|
args: config.args,
|
|
987
991
|
uninstall_args: config.uninstall_args,
|
|
988
992
|
env: config.env,
|
|
@@ -994,6 +998,12 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
|
|
|
994
998
|
throw new Error("invalid config");
|
|
995
999
|
// The packaged config already carries the launcher argv; only the launcher itself is checked here.
|
|
996
1000
|
if (extras.node &&
|
|
1001
|
+
backend &&
|
|
1002
|
+
(daemon.env.SYNOPKGLAND_NODE !== backendNodeEnv().SYNOPKGLAND_NODE ||
|
|
1003
|
+
readOptional(join(unpacked, "daemon/payload", NODE_LAUNCHER)) !== renderNodeLauncher(extras.node)))
|
|
1004
|
+
throw new Error("Node launcher differs from its build declaration");
|
|
1005
|
+
if (extras.node &&
|
|
1006
|
+
!backend &&
|
|
997
1007
|
(daemon.exec !== NODE_LAUNCHER ||
|
|
998
1008
|
!daemon.args[0]?.startsWith("${SYNOPKG_PKGDEST}/daemon/payload/") ||
|
|
999
1009
|
readOptional(join(unpacked, "daemon/payload", NODE_LAUNCHER)) !== renderNodeLauncher(extras.node)))
|
|
@@ -1007,7 +1017,8 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
|
|
|
1007
1017
|
JSON.stringify(daemon.args) !== '["run"]' ||
|
|
1008
1018
|
daemon.uninstall_args))
|
|
1009
1019
|
throw new Error("compose gateway must run without a child");
|
|
1010
|
-
|
|
1020
|
+
// A backend daemon runs the audited CGI binary.
|
|
1021
|
+
if (payload.find((p) => p.path === (backend ? CGI_PAYLOAD_PATH : `daemon/payload/${daemon.exec}`))?.mode !==
|
|
1011
1022
|
"0755")
|
|
1012
1023
|
throw new Error("missing executable");
|
|
1013
1024
|
const manifest = {
|
|
@@ -50,6 +50,7 @@ export interface StandaloneManifest {
|
|
|
50
50
|
aliases?: string[];
|
|
51
51
|
developedBy?: string;
|
|
52
52
|
commands: Record<string, import("../ctl/descriptor.js").Operation>;
|
|
53
|
+
external?: import("../ctl/descriptor.js").ApiPayload['external'];
|
|
53
54
|
};
|
|
54
55
|
metadata?: PackageMetadata;
|
|
55
56
|
dependencies?: PackageDependencies;
|
|
@@ -312,8 +312,8 @@ function readManifest(raw, c) {
|
|
|
312
312
|
if (problem)
|
|
313
313
|
c.fail("STANDALONE_DAEMON_RESTART_SDK_REQUIRED", problem);
|
|
314
314
|
}
|
|
315
|
-
if ((daemon?.ingress || daemon?.node) && raw.identity_gateway !== undefined)
|
|
316
|
-
c.fail("STANDALONE_DAEMON_INVALID", "daemon.node and daemon.ingress run the App's own daemon; identity_gateway runs its gateway as the daemon. Use one or the other.");
|
|
315
|
+
if ((daemon?.ingress || daemon?.node || daemon?.backend) && raw.identity_gateway !== undefined)
|
|
316
|
+
c.fail("STANDALONE_DAEMON_INVALID", "daemon.backend, daemon.node and daemon.ingress run the App's own daemon; identity_gateway runs its gateway as the daemon. Use one or the other.");
|
|
317
317
|
if (compose && identityGateway && compose.sdk !== identityGateway.sdk)
|
|
318
318
|
c.fail("STANDALONE_IDENTITY_GATEWAY_INVALID", "compose and identity_gateway must use the same SDK release.");
|
|
319
319
|
if (compose &&
|
|
@@ -495,6 +495,16 @@ function readManifest(raw, c) {
|
|
|
495
495
|
c.fail("CTL_DECLARATION_INVALID", "ctl requires enabled and explicit commands.");
|
|
496
496
|
return undefined;
|
|
497
497
|
}
|
|
498
|
+
if (isRecord(raw.ctl) && raw.ctl.external !== undefined) {
|
|
499
|
+
const external = raw.ctl.external;
|
|
500
|
+
if (!isRecord(external) || Object.keys(external).length < 1 || Object.keys(external).length > 16 ||
|
|
501
|
+
Object.entries(external).some(([protocol, entry]) => !/^[a-z][a-z0-9-]{0,63}$/.test(protocol) ||
|
|
502
|
+
!isRecord(entry) || Object.keys(entry).length !== 1 || typeof entry.method !== 'string' ||
|
|
503
|
+
entry.method.length > 128 || !/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(entry.method))) {
|
|
504
|
+
c.fail('CTL_EXTERNAL_DECLARATION_INVALID', 'ctl.external requires protocol names and fixed RPC methods.');
|
|
505
|
+
return undefined;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
498
508
|
return {
|
|
499
509
|
...(raw.ctl ? { ctl: raw.ctl } : {}),
|
|
500
510
|
root: c.root,
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Local external callers for MCP
|
|
2
|
+
|
|
3
|
+
The CLI supplies a generic local bridge transport:
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
synopkgland ctl external --protocol example-external-v1 --host controller --input - --json
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
It accepts one UTF-8 JSON object from stdin and emits exactly one compact
|
|
10
|
+
`{ok:true,data:...}` or `{ok:false,error:{code,message,hint}}` object on stdout.
|
|
11
|
+
Input and output, including the final newline, are bounded to 1 MiB. Requests
|
|
12
|
+
have a shared 30-second network budget. A bridge should also bound the child
|
|
13
|
+
process lifetime, stdin and stderr. Invalid/duplicate options and duplicate JSON
|
|
14
|
+
keys are refused. Business arguments never belong in argv.
|
|
15
|
+
|
|
16
|
+
This command is implemented in the CLI. Apps must implement the trusted entry
|
|
17
|
+
below; the CLI does not create an MCP server, HTTP listener, shared service or
|
|
18
|
+
tool Engine. Apps without that entry remain unsupported. No SDK change or
|
|
19
|
+
automatic App upgrade is included.
|
|
20
|
+
|
|
21
|
+
## App declaration and approval
|
|
22
|
+
|
|
23
|
+
An App may declare up to 16 lowercase protocol IDs (1–64 letters/digits/dashes,
|
|
24
|
+
starting with a letter). There is no App-specific protocol dispatch. For a native
|
|
25
|
+
App with explicit ordinary ctl commands, extend `dsm-package.yaml`:
|
|
26
|
+
|
|
27
|
+
```yaml
|
|
28
|
+
ctl:
|
|
29
|
+
enabled: true
|
|
30
|
+
# Keep the App's existing aliases, developedBy and commands here.
|
|
31
|
+
external:
|
|
32
|
+
example-external-v1:
|
|
33
|
+
method: bridge.external
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The existing route registry must export `bridge.external` as an administrator
|
|
37
|
+
RPC method (or have an administrator-only App entry). Build rejects missing,
|
|
38
|
+
nonadministrator and raw HTTP routes. The generated v1 API descriptor adds:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"external": {"example-external-v1": {"method": "bridge.external"}}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This is a field of `payload`, included in its existing canonical SHA-256.
|
|
47
|
+
`payload.bindings["bridge.external"]` is
|
|
48
|
+
`{"effect":"destructive","permission":"administrators"}` as a conservative
|
|
49
|
+
transport classification. The external method cannot appear as a command ID or
|
|
50
|
+
any ordinary command's invoke, job, pagination, streaming or upload helper.
|
|
51
|
+
`ctl list` displays declarations separately from ordinary commands.
|
|
52
|
+
App-owned runtime descriptors may produce this same extension. Keep their
|
|
53
|
+
packaged descriptor, permissions and registry in agreement with the live one.
|
|
54
|
+
|
|
55
|
+
The operator must first register/sign in locally, then explicitly approve the
|
|
56
|
+
specific App's fresh description with ordinary discovery, for example:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
synopkgland host login controller
|
|
60
|
+
synopkgland ctl list --host controller --app Example --refresh --approve-installed --json
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Approval authorizes sending the DSM session to that installed App's fixed CGI.
|
|
64
|
+
Obtain the user's authorization before approving an App. External calls cannot
|
|
65
|
+
approve Apps or update description pins. They match the protocol against cached
|
|
66
|
+
descriptors of approved installed package versions, then reread and verify the
|
|
67
|
+
selected live descriptor against its approved hash. Zero or multiple matching
|
|
68
|
+
Apps refuse; revoke the unwanted approval or use distinct protocol IDs. A cache
|
|
69
|
+
is only a recipient selector and never grants tool authority.
|
|
70
|
+
|
|
71
|
+
Only a named, registered host and its OS-stored session are used. Ambient
|
|
72
|
+
`DSM_*` credentials/URL cannot create or replace this login. HTTPS requires the
|
|
73
|
+
stored SHA-256 certificate pin and refuses rotation before sending HTTP headers,
|
|
74
|
+
including under ordinary ctl's permissive TLS default. Registered strict-TLS/CA
|
|
75
|
+
settings still apply. A registered explicit loopback SSH tunnel is also accepted.
|
|
76
|
+
No default-host fallback, multi-host mode, URL/TLS override, `--yes`, `--via` or
|
|
77
|
+
automatic login is available. Reapprove intentional changes outside the bridge.
|
|
78
|
+
|
|
79
|
+
## Local stdin/stdout contract
|
|
80
|
+
|
|
81
|
+
Describe input has exactly these fields:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{"protocol":"example-external-v1","action":"describe"}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Success data is the App's current Catalog, with a revision binding the entire
|
|
88
|
+
tool set:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{"ok":true,"data":{"revision":"r1","tools":[{"name":"notes__list","description":"notes.list","inputSchema":{"type":"object","additionalProperties":false},"outputSchema":{"type":"object"},"_meta":{"method":"notes.list"}}]}}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Catalog revisions contain 1–128 letters/digits/underscores/dashes. At most 1024
|
|
95
|
+
tools fit within the output budget. Each tool has a unique MCP-compatible `name`
|
|
96
|
+
(1–128 letters/digits/underscores/dashes), unique original dotted method in
|
|
97
|
+
`_meta.method`, and self-contained JSON Schema 2020-12 input/output objects.
|
|
98
|
+
Other App metadata is preserved. Schema references must be local. The App owns
|
|
99
|
+
tool eligibility, effect, schema hashes and revision construction; the bridge
|
|
100
|
+
may additionally compare these against its matching built-in OpSpecs.
|
|
101
|
+
|
|
102
|
+
Call input has exactly these fields:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{"protocol":"example-external-v1","action":"call","revision":"r1","method":"notes.list","arguments":{}}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The CLI fetches a fresh Catalog through the trusted entry, refuses stale
|
|
109
|
+
revisions/unknown methods, and validates arguments against that tool's current
|
|
110
|
+
input schema. Success data is the App's tool Result, validated against the
|
|
111
|
+
tool's output schema. The CLI never starts an ordinary ctl flow for this call.
|
|
112
|
+
`actor`, `via`, `allowMutation`, `allow_mutation` and `host` are forbidden as
|
|
113
|
+
request fields and root argument fields. They cannot establish authority through
|
|
114
|
+
nested business data either: App handlers must derive transport authority from
|
|
115
|
+
the verified server context. Model arguments are business data only.
|
|
116
|
+
|
|
117
|
+
## Trusted App RPC contract
|
|
118
|
+
|
|
119
|
+
Both actions enter only the declared method at the package's existing fixed
|
|
120
|
+
`/webman/3rdparty/<package>/cgi/api.cgi`, using the stored DSM session and
|
|
121
|
+
SynoToken. This initial implementation requires `dsm-session`; Apps using
|
|
122
|
+
`dsm-app-session` cannot declare external entries in generated descriptions.
|
|
123
|
+
|
|
124
|
+
The CLI adds installed-package and approved-description pins to RPC `params`:
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
{"method":"bridge.external","params":{"protocol":"example-external-v1","action":"call","revision":"r1","method":"notes.list","arguments":{},"package":{"id":"Example","version":"1"},"descriptionSha256":"<approved 64-hex hash>"}}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Describe has the same pins plus `protocol` and `action`, without call fields.
|
|
131
|
+
The App must strictly decode this envelope and, for **both** actions:
|
|
132
|
+
|
|
133
|
+
1. Authenticate the current DSM session and SynoToken, require a current
|
|
134
|
+
administrator, and establish the verified user from the SDK Caller.
|
|
135
|
+
2. Verify the protocol, installed package/version and current descriptor hash.
|
|
136
|
+
Do not echo unverified request values as evidence.
|
|
137
|
+
3. Derive MCP provenance from this dedicated entry. Never take actor, provenance,
|
|
138
|
+
scope authority or mutation intent from request JSON. Ordinary ctl must keep
|
|
139
|
+
its existing provenance and must not expose this entry as a normal tool.
|
|
140
|
+
4. Describe the eligible shared Engine tool set. For calls, atomically compare
|
|
141
|
+
revision and pins with the tool set being dispatched, before any effect.
|
|
142
|
+
CLI preflight cannot close a server-side revision race.
|
|
143
|
+
5. Resolve scope/targets, enforce explicit server-authorized mutation intent,
|
|
144
|
+
draft/selection gates and budgets, then execute the shared Engine. Human-only
|
|
145
|
+
methods must refuse guessed names; sensitive adapters return existing cards.
|
|
146
|
+
|
|
147
|
+
Use the ordinary SDK `{success,data|error}` RPC envelope. On success, `data` is:
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
{
|
|
151
|
+
"verified": {
|
|
152
|
+
"protocol": "example-external-v1",
|
|
153
|
+
"package": {"id": "Example", "version": "1"},
|
|
154
|
+
"descriptionSha256": "<verified 64-hex hash>",
|
|
155
|
+
"user": "<verified DSM username>",
|
|
156
|
+
"administrator": true,
|
|
157
|
+
"revision": "r1"
|
|
158
|
+
},
|
|
159
|
+
"data": {"revision":"r1","tools":[]}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
For calls the inner `data` is the tool Result and the verified revision must
|
|
164
|
+
equal the requested revision. The CLI verifies all evidence against its pins,
|
|
165
|
+
stored username and Catalog revision, then unwraps only the inner data for the
|
|
166
|
+
local bridge. Evidence is an assertion by the approved authenticated App, not
|
|
167
|
+
a signed delegation token. The server must perform these checks before dispatch;
|
|
168
|
+
returning evidence afterward cannot undo an unauthorized effect.
|
|
169
|
+
|
|
170
|
+
## Failure and verification boundary
|
|
171
|
+
|
|
172
|
+
The CLI rereads local session/host/approval state before each external RPC. It
|
|
173
|
+
does not refresh expired sessions. All errors return bounded generic text;
|
|
174
|
+
upstream messages, request arguments and exception text are never echoed.
|
|
175
|
+
Session carriers and schema-marked sensitive call values are redacted from
|
|
176
|
+
results. The App remains responsible for its own secret-free Catalog and Result.
|
|
177
|
+
Authentication failures exit 2, invalid input/options 3, trust/permission/revision
|
|
178
|
+
refusals 4, contract failures 5 and network failures 6.
|
|
179
|
+
|
|
180
|
+
There is exactly one mutation dispatch attempt. Timeout, interruption, invalid
|
|
181
|
+
evidence/schema or oversized output may follow a committed mutation; none
|
|
182
|
+
causes a retry. Query the original receipt/Operation or current state before any
|
|
183
|
+
further action. Killing the CLI stops observation and does not cancel an App
|
|
184
|
+
Operation.
|
|
185
|
+
|
|
186
|
+
Tests exercise compiled CLI subprocesses and a local TLS fixture, including
|
|
187
|
+
fresh administrator checks, revision races, lost replies and certificate
|
|
188
|
+
rotation. They do not establish any App's installed or deployed readiness.
|
|
189
|
+
|
|
190
|
+
## Syngle I-B / K9 integration checklist
|
|
191
|
+
|
|
192
|
+
- Keep the existing bridge argv and stdin/stdout shapes, with
|
|
193
|
+
`syngle-external-v1` declared under `ctl.external`; no protocol-ID rename is
|
|
194
|
+
needed. Select a fixed administrator RPC method such as `mcp.external`.
|
|
195
|
+
- Register that exact RPC in CGI and worker route composition and digest-bound
|
|
196
|
+
administrator permissions. Align generated/static and runtime descriptors,
|
|
197
|
+
their canonical hashes and package version. Keep it out of ordinary ctl flows.
|
|
198
|
+
- Strictly decode the trusted RPC params and implement the evidence envelope
|
|
199
|
+
above. Generate Describe data with the current exact `mcpbridge.NewCatalog`.
|
|
200
|
+
Compare revision/pins at the shared Engine dispatch boundary, not after effects.
|
|
201
|
+
- Construct `aitools.NewExternal(Config)` per verified Caller, preserving the
|
|
202
|
+
scope resolver, mutation-intent gate, selection/DraftGate and read-NAS budgets.
|
|
203
|
+
Keep `Authority.AllowMutation` server-authorized; do not enable it because the
|
|
204
|
+
request uses this transport. Return the original `aitools.Result` for Call.
|
|
205
|
+
- After the coordinator releases a compatible CLI, update the distribution pin
|
|
206
|
+
and setup guidance. Login alone is insufficient: include explicit App
|
|
207
|
+
discovery/approval. Preserve the bridge's exact OpSpec comparison, cancellation
|
|
208
|
+
semantics, output budget and refusal to retry lost mutations.
|
|
209
|
+
- Run composed App/bridge acceptance for expired/nonadmin sessions, forged
|
|
210
|
+
authority, stale/racing revisions, human-only guesses, ordinary `via=ctl`,
|
|
211
|
+
trusted `via=mcp`, AI disabled and lost mutation replies. Until then, report
|
|
212
|
+
Syngle end-to-end integration as pending.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synopackageland/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.0",
|
|
4
4
|
"description": "Syno Package Land Developer Kit CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"assets",
|
|
15
15
|
"CHANGELOG.md",
|
|
16
16
|
"schema",
|
|
17
|
+
"docs/ctl-external.md",
|
|
17
18
|
"skills"
|
|
18
19
|
],
|
|
19
20
|
"scripts": {
|
|
@@ -27,7 +28,7 @@
|
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
30
|
"@napi-rs/keyring": "^2.1.1",
|
|
30
|
-
"@synopackageland/templates": "~0.
|
|
31
|
+
"@synopackageland/templates": "~0.35.0",
|
|
31
32
|
"ajv": "^8.17.1",
|
|
32
33
|
"commander": "^13.1.0",
|
|
33
34
|
"json-canonicalize": "^3.0.1",
|
|
@@ -397,6 +397,29 @@
|
|
|
397
397
|
"flow"
|
|
398
398
|
]
|
|
399
399
|
}
|
|
400
|
+
},
|
|
401
|
+
"external": {
|
|
402
|
+
"type": "object",
|
|
403
|
+
"minProperties": 1,
|
|
404
|
+
"maxProperties": 16,
|
|
405
|
+
"propertyNames": {
|
|
406
|
+
"type": "string",
|
|
407
|
+
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
|
408
|
+
},
|
|
409
|
+
"additionalProperties": {
|
|
410
|
+
"type": "object",
|
|
411
|
+
"additionalProperties": false,
|
|
412
|
+
"properties": {
|
|
413
|
+
"method": {
|
|
414
|
+
"type": "string",
|
|
415
|
+
"maxLength": 128,
|
|
416
|
+
"pattern": "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$"
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
"required": [
|
|
420
|
+
"method"
|
|
421
|
+
]
|
|
422
|
+
}
|
|
400
423
|
}
|
|
401
424
|
},
|
|
402
425
|
"required": [
|
package/skills/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: Initialize, extend, check, build, inspect, and deploy Synology appl
|
|
|
5
5
|
|
|
6
6
|
# Independent Synology SPKs
|
|
7
7
|
|
|
8
|
-
The CLI installs templates through `~0.
|
|
8
|
+
The CLI installs templates through `~0.35.0`. A templates patch needs only a
|
|
9
9
|
templates release; npm install/update selects a patch in that minor line. The CLI
|
|
10
10
|
uses the locally installed templates and reads their SDK pin for defaults; it
|
|
11
11
|
never downloads templates at runtime. Preserve the lockfile and use `npm ci` for
|
|
@@ -24,7 +24,7 @@ Use this guide to initialize, build and inspect own applications or upstream OSS
|
|
|
24
24
|
|
|
25
25
|
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.
|
|
26
26
|
|
|
27
|
-
Use SDK/App 0.
|
|
27
|
+
Use SDK/App 0.37.0 and CLI 0.41.0 for App entry enforcement. Declare every App method
|
|
28
28
|
in the permissions table; dynamic names and unregistered HTTP dispatchers are rejected.
|
|
29
29
|
Use fixed SDK routes and `identity_gateway.upstream_auth_paths` for client API
|
|
30
30
|
paths that rely on upstream authentication. Read
|
|
@@ -118,6 +118,8 @@ entries are injected, and the declaration grants no private-data access.
|
|
|
118
118
|
|
|
119
119
|
Read reference/portal.md before declaring or changing a DSM Application Portal alias (`add portal`); DSM owns the saved setting; DSM 7.2-64570+ supports schema-2 Portal without an executor, while DSM 7.1 requires executor registration. Read reference/files.md and reference/identity.md for capability boundaries. RootOps and delegate share one-time protected executor boot/control setup with digest approval. Existing native products can explicitly use the separate native runtimes in @synopackageland/sdk/native; do not mix its tasks with generated executor capabilities. App sessions alone do not grant NAS file authority.
|
|
120
120
|
|
|
121
|
+
Before choosing `daemon.backend` (the backend as a resident daemon behind `daemon.ingress`) for speed, read [reference/daemon-mode.md](reference/daemon-mode.md): measure the CGI first; the daemon pays off only for repeated, cacheable per-request work on a NAS with memory to spare.
|
|
122
|
+
|
|
121
123
|
Read [reference/lifecycle.md](reference/lifecycle.md) before editing `dsm-scripts/` (preinst, postinst, preupgrade, postupgrade, preuninst, postuninst, start-stop-status): the SDK runs them inside its generated scripts as the package account; never add root work or repeat SDK steps there.
|
|
122
124
|
|
|
123
125
|
Native Apps appear in Hyper Backup as applications by default. Read [reference/backup.md](reference/backup.md) before changing `backup` in dsm-package.yaml (opt out with `hyper_backup: false`, online mode, data version, hook). Hyper Backup runs the generated `scripts/backup/` as root; it is a fixed template that only switches to the package account, so never add or edit files there.
|
package/skills/reference/ctl.md
CHANGED
|
@@ -16,6 +16,27 @@ Use `host show nas` to inspect approvals/pins, `host revoke nas PackageID` to re
|
|
|
16
16
|
|
|
17
17
|
Stdout defaults to JSON without a TTY. Success is {ok:true,data}; failure is {ok:false,error:{code,message,hint,allowed?}}. Multi-host output uses {ok,results:[{host,ok,data|error}]}. Follow the concrete hint and complete allowed list; do not guess another method or bypass ctl with raw HTTP. Exit codes: 2 authentication, 3 parameters/names, 4 permissions/trust, 5 App/contract, 6 network, 7 confirmation, 8 partial failure.
|
|
18
18
|
|
|
19
|
+
## Local MCP external callers
|
|
20
|
+
|
|
21
|
+
`synopkgland ctl external --protocol <declared-id> --host <profile> --input - --json`
|
|
22
|
+
accepts one strict JSON `describe` or revision-bound `call` on stdin and emits one
|
|
23
|
+
`{ok,data|error}` object. This requires an App-declared `ctl.external` protocol,
|
|
24
|
+
an approved package/version/description pin from ordinary discovery, and a stored
|
|
25
|
+
local login. The CLI rereads the installed inventory and descriptor and verifies
|
|
26
|
+
the App's fresh administrator evidence. HTTPS uses the registered certificate
|
|
27
|
+
pin even when ordinary NAS commands accept certificate changes. Environment
|
|
28
|
+
passwords never log in this caller; model input cannot choose the host, actor,
|
|
29
|
+
provenance or mutation authority. No `--yes`, multi-host selection, credential,
|
|
30
|
+
URL or TLS override is accepted. A lost mutation reply is never retried; query
|
|
31
|
+
the original operation/state first.
|
|
32
|
+
|
|
33
|
+
Read [the App contract](../../docs/ctl-external.md) before implementing an adapter.
|
|
34
|
+
The trusted App entry must atomically verify revision/pins, authenticate the
|
|
35
|
+
administrator and derive `via=mcp` and mutation authority server-side. Ordinary
|
|
36
|
+
ctl cannot invoke this entry through command flows. A protocol declaration alone
|
|
37
|
+
does not implement these checks or make an App MCP-ready. `syngle-external-v1`
|
|
38
|
+
works as a declared protocol; the CLI has no App-specific protocol branch.
|
|
39
|
+
|
|
19
40
|
## Plan command coverage
|
|
20
41
|
|
|
21
42
|
For packaging an upstream open-source App (including forks with `synology/`),
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# When to run the backend as a daemon
|
|
2
|
+
|
|
3
|
+
A native App's backend normally runs as a CGI: DSM starts `ui/cgi/api.cgi`
|
|
4
|
+
for every request, and the process exits when the request ends. With
|
|
5
|
+
`daemon.backend: true` and `daemon.ingress` ([docs/daemon.md](../../docs/daemon.md)),
|
|
6
|
+
the same binary runs once as the package daemon and serves the pages through
|
|
7
|
+
DSM's nginx. The CGI stays for integrations, transfers and fallback.
|
|
8
|
+
|
|
9
|
+
Daemon mode is not the default recommendation. It removes work that repeats
|
|
10
|
+
on every request. It does not make a single expensive request cheaper, and it
|
|
11
|
+
costs memory for as long as the package runs. Measure first, then decide.
|
|
12
|
+
|
|
13
|
+
## Decision table
|
|
14
|
+
|
|
15
|
+
| Factor | Daemon helps | Stay with CGI |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| Fixed cost per request | High: each request loads or compiles many definitions, opens several stores or runs sign-in checks that could be remembered | Low: the handler reads a row or two and returns |
|
|
18
|
+
| Request rate | Pages send many calls; many users at once | A few calls per page; few users |
|
|
19
|
+
| Repeated, cacheable work | Most of the time goes to work whose result does not change between requests (compiled definitions, permission answers, zone data) | Each request does new work (one-off reports, large exports) |
|
|
20
|
+
| Long connections | Streams, server-sent events, long polling | None |
|
|
21
|
+
| Background work | A tick, outbox sending or polling that should run within seconds | A scheduled task every few minutes is enough |
|
|
22
|
+
| App size | Many Apps, large definitions | One small App |
|
|
23
|
+
| NAS memory | Enough room for the base process plus a bounded cache | Small models (2–4 GB) that already swap; every resident MB competes with DSM and other packages |
|
|
24
|
+
| Idle cost | Acceptable: the process stays up when nobody uses the App | Not acceptable: the App is opened rarely |
|
|
25
|
+
|
|
26
|
+
The base process is usually small: an idle Go daemon is typically 10–30 MB
|
|
27
|
+
RSS. Measure your own binary. The real variable is the cache, so give every
|
|
28
|
+
cache an upper bound and measure it filled.
|
|
29
|
+
|
|
30
|
+
## Factors and example numbers
|
|
31
|
+
|
|
32
|
+
These numbers come from Studio Office. Use them as an example of what
|
|
33
|
+
to measure, not as a budget.
|
|
34
|
+
|
|
35
|
+
- **CGI cost per request.** On a DS SA3400 with 25 Apps, every Studio call
|
|
36
|
+
spent 100–140 ms before the handler did any work: process start, the DSM
|
|
37
|
+
sign-in check (`authenticate.cgi`), the App Privileges check and schema
|
|
38
|
+
checks. Recompiling App definitions on each request took about 70% of
|
|
39
|
+
`studio.apps.list`. Measure this on the NAS: time an empty call (for
|
|
40
|
+
example `app.status`) and the slowest common page calls.
|
|
41
|
+
- **Resident memory.** Measured on macOS arm64 in a Go test process, not
|
|
42
|
+
on the NAS: 37 MB RSS with the routes built and the store opened, and
|
|
43
|
+
81 MB after 23 compiled template Apps were kept (about 1.1 MB of
|
|
44
|
+
heap per App).
|
|
45
|
+
The Node AI child has not been measured. Measure on the target NAS with your
|
|
46
|
+
real data before you choose cache sizes.
|
|
47
|
+
- **Cache bounds.** Bound each cache by entry count (LRU) and derive the bound
|
|
48
|
+
from the measured size per entry. Studio keeps 32 compiled releases, about
|
|
49
|
+
35 MB at the measured size. Cache only results that are pure functions of
|
|
50
|
+
their key, such as compiled definitions keyed by a hash of the stored plan,
|
|
51
|
+
and cache only successes.
|
|
52
|
+
- **Sign-in cache.** `dsm.CacheSessions` remembers verified sessions for a
|
|
53
|
+
TTL you choose (Studio: 10 minutes). The cost is staleness: a sign-out, a
|
|
54
|
+
disabled account or a lost administrator role takes effect up to the TTL
|
|
55
|
+
later. App Privileges keep their own 30-second check.
|
|
56
|
+
- **Crash and restart.** The SDK daemon controller restarts the daemon
|
|
57
|
+
(`daemon.restart`). Children of the daemon, such as a Node service, are the
|
|
58
|
+
backend's own job: start them, restart them with backoff and stop them on
|
|
59
|
+
SIGTERM. Keep the CGI as fallback, so pages still work when the daemon is
|
|
60
|
+
down. Retry only reads automatically, plus calls the daemon cannot have seen.
|
|
61
|
+
- **Upgrade.** DSM stops the old daemon and starts the new one. In-memory
|
|
62
|
+
caches start empty and fill on first use. Migrations still run at start.
|
|
63
|
+
The CGI is the same binary, so both paths always run the same version.
|
|
64
|
+
- **Security boundary.** The daemon serves the same handler as the CGI. Origin,
|
|
65
|
+
App entry, DSM session and route permission are checked on every request.
|
|
66
|
+
The ingress socket is mode 0666 because nginx runs as another account, so
|
|
67
|
+
a local process can connect directly and choose `X-Real-IP`. A valid DSM
|
|
68
|
+
session is still required, but IP-based App Privileges rules can be
|
|
69
|
+
bypassed this way. The route audit treats `api.ServeIngress` like
|
|
70
|
+
`cgi.Serve`.
|
|
71
|
+
|
|
72
|
+
## Decision steps
|
|
73
|
+
|
|
74
|
+
1. Measure the CGI. Time an empty authenticated call and the 3–5 calls a
|
|
75
|
+
common page makes, on the target NAS, a few times each. Count how many
|
|
76
|
+
calls one page sends.
|
|
77
|
+
2. Find where the time goes. Split fixed cost (process start, sign-in, entry
|
|
78
|
+
checks) from handler work. Then split handler work into repeated,
|
|
79
|
+
cacheable work and work that changes per request.
|
|
80
|
+
3. Optimize inside the CGI first, if that is enough:
|
|
81
|
+
- Send fewer requests: batch reads, and return all items in one page instead
|
|
82
|
+
of serial paging.
|
|
83
|
+
- Use disk caches keyed by content hash (for example compiled definitions
|
|
84
|
+
next to the store).
|
|
85
|
+
- Avoid loading what the call does not use.
|
|
86
|
+
4. Choose the daemon only when, after step 3, most of the remaining time is
|
|
87
|
+
fixed cost or cacheable repeated work, and the NAS has memory for the
|
|
88
|
+
measured base plus the bounded cache.
|
|
89
|
+
5. After switching, measure idle RSS and RSS with a full cache on the NAS, and
|
|
90
|
+
the same page calls as in step 1. Write the numbers into the App's docs.
|