@synopackageland/cli 0.40.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 CHANGED
@@ -1,5 +1,16 @@
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
+
3
14
  ## 0.40.0 (2026-10-29)
4
15
 
5
16
  - `daemon.backend: true` runs the App's own native backend
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.40.0 uses templates ~0.35.0, whose default SDK pin is 0.37.0. Compose import and SDK defaults
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,7 +93,7 @@ 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.40.0 depends on `~0.35.0`: npm installation or
96
+ `@synopackageland/templates`. CLI 0.41.0 depends on `~0.35.0`: npm installation or
93
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;
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));
@@ -66,6 +66,9 @@ export interface ApiPayload {
66
66
  };
67
67
  commands: Record<string, Operation>;
68
68
  bindings: Record<string, Binding>;
69
+ external?: Record<string, {
70
+ method: string;
71
+ }>;
69
72
  }
70
73
  export interface Descriptor {
71
74
  format: 'synopkgland.api';
@@ -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
+ }
@@ -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): Promise<DsmSession>;
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)) });
@@ -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
  }
@@ -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;
@@ -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.40.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": {
@@ -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
@@ -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.37.0 and CLI 0.40.0 for App entry enforcement. Declare every App method
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
@@ -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/`),