@synopackageland/cli 0.41.9 → 0.41.11

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.
@@ -0,0 +1,62 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync, rmSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ /** Diagnostic text is untrusted and can contain daemon URLs or echoed credentials. */
6
+ export function maskDiagnostic(text) {
7
+ return text.replace(/https?:\/\/[^\s"'<>]+/gi, value => {
8
+ try {
9
+ const url = new URL(value);
10
+ if (!url.username && !url.password && !url.search && !url.hash)
11
+ return value;
12
+ url.username = '';
13
+ url.password = '';
14
+ url.search = '';
15
+ url.hash = '';
16
+ return url.href;
17
+ }
18
+ catch {
19
+ return '<redacted URL>';
20
+ }
21
+ }).replace(/\b(password|passwd|token|synotoken|_sid|authorization|cookie|set-cookie)\s*[:=]\s*[^\r\n]+/gi, '$1=<redacted>');
22
+ }
23
+ function pathFor(origin, packageId) {
24
+ const key = createHash('sha256').update(new URL(origin).origin + '\0' + packageId).digest('hex');
25
+ return join(process.env.SYNOPKGLAND_DIAGNOSTICS_DIR ?? join(homedir(), '.cache/synopkgland/diagnostics'), key + '.json');
26
+ }
27
+ export function savePullFailure(origin, packageId, message) {
28
+ try {
29
+ const file = pathFor(origin, packageId);
30
+ if (!message) {
31
+ rmSync(file, { force: true });
32
+ return;
33
+ }
34
+ const dir = file.slice(0, file.lastIndexOf('/'));
35
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
36
+ const temporary = `${file}.${process.pid}.tmp`;
37
+ writeFileSync(temporary, JSON.stringify({ source: 'CLI NAS pre-pull', observedAt: new Date().toISOString(), message: maskDiagnostic(message) }), { mode: 0o600 });
38
+ renameSync(temporary, file);
39
+ }
40
+ catch { /* Optional diagnostic retention must not mask the install result. */ }
41
+ }
42
+ function readPullFailure(origin, packageId) {
43
+ try {
44
+ const value = JSON.parse(readFileSync(pathFor(origin, packageId), 'utf8'));
45
+ return { source: value.source, observedAt: value.observedAt, message: maskDiagnostic(String(value.message)) };
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ export function packageDiagnostics(packageId, state, origin) {
52
+ const clean = (value) => typeof value === 'string' ? maskDiagnostic(value) : Array.isArray(value) ? value.map(clean)
53
+ : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, v]) => [key, /password|passwd|token|sid|authorization|cookie/i.test(key) ? '<redacted>' : clean(v)])) : value;
54
+ const worker = clean(state?.error?.workerMsg ?? []);
55
+ const messages = (value) => typeof value === 'string' ? [value] : Array.isArray(value) ? value.flatMap(messages)
56
+ : value && typeof value === 'object' ? Object.values(value).flatMap(messages) : [];
57
+ const lastComposeError = messages(worker).filter(line => /compose|pull|docker|x509|tls|certificate|registry/i.test(line)).at(-1);
58
+ return { packageIdentity: packageId, readiness: state?.status, error: clean(state?.error), worker_msg: worker,
59
+ statusCode: state?.statusCode, statusDescription: clean(state?.statusDescription),
60
+ ...(lastComposeError ? { lastComposeError } : {}),
61
+ ...(state?.status === 'broken' || state?.error?.code ? { lastPullError: readPullFailure(origin, packageId) } : {}), redacted: true };
62
+ }
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { registryBaseUrl } from "../compose/registry-mirrors.js";
2
3
  import { readFileSync, writeFileSync } from "node:fs";
3
4
  import { isScalar, parseDocument } from "yaml";
4
5
  /**
@@ -73,18 +74,6 @@ export function parseImageReference(image) {
73
74
  }
74
75
  return { registry, repository, tag, digest, explicitTag };
75
76
  }
76
- function registryBaseUrl(registry) {
77
- if (registry === "docker.io" || registry === "index.docker.io") {
78
- return "https://registry-1.docker.io";
79
- }
80
- const host = registry.startsWith("[")
81
- ? registry.slice(0, registry.indexOf("]") + 1)
82
- : registry.split(":")[0];
83
- if (host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]") {
84
- return `http://${registry}`;
85
- }
86
- return `https://${registry}`;
87
- }
88
77
  function parseBearerChallenge(header) {
89
78
  if (!header) {
90
79
  return null;
@@ -152,12 +141,13 @@ export async function resolveImageDigest(image, deps = {}) {
152
141
  if (!response.ok) {
153
142
  throw new ImageDigestError(`registry answered HTTP ${response.status} for ${image}`);
154
143
  }
144
+ const body = Buffer.from(await response.arrayBuffer());
145
+ const digest = `sha256:${createHash("sha256").update(body).digest("hex")}`;
155
146
  const header = response.headers.get("docker-content-digest");
156
- if (header && DIGEST_VALUE.test(header)) {
157
- return header.toLowerCase();
147
+ if (header && (!DIGEST_VALUE.test(header) || header.toLowerCase() !== digest)) {
148
+ throw new ImageDigestError(`registry content digest mismatch for ${image}`);
158
149
  }
159
- const body = Buffer.from(await response.arrayBuffer());
160
- return `sha256:${createHash("sha256").update(body).digest("hex")}`;
150
+ return digest;
161
151
  }
162
152
  /** Keep the source's quote style so only the reference text changes. */
163
153
  function quoteLike(node, value) {
package/dist/log.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { loadStandaloneManifest } from "./standalone/manifest.js";
2
2
  import { resolveDeployIdentity } from "./deploy/identity.js";
3
+ import { maskDiagnostic, packageDiagnostics } from './host/package-diagnostics.js';
3
4
  export async function followProjectLogs(options) {
4
5
  const identity = resolveDeployIdentity(options.project, { dev: options.dev });
5
6
  const installed = await options.host.isPackageInstalled(identity.packageId);
@@ -10,12 +11,8 @@ export async function followProjectLogs(options) {
10
11
  const info = await options.host.getInstalledPackageState?.(identity.packageId);
11
12
  return {
12
13
  ok: true,
13
- lines,
14
- diagnostics: {
15
- packageIdentity: identity.packageId,
16
- readiness: info?.status,
17
- redacted: true,
18
- },
14
+ lines: lines.map(maskDiagnostic),
15
+ diagnostics: packageDiagnostics(identity.packageId, info, options.host.dsmOrigin),
19
16
  };
20
17
  }
21
18
  export async function followStandaloneLogs(options) {
@@ -31,7 +28,7 @@ export async function followStandaloneLogs(options) {
31
28
  const info = await options.host.getInstalledPackageState?.(packageIdentity);
32
29
  return {
33
30
  ok: true,
34
- lines,
35
- diagnostics: { packageIdentity, readiness: info?.status, redacted: true },
31
+ lines: lines.map(maskDiagnostic),
32
+ diagnostics: packageDiagnostics(packageIdentity, info, options.host.dsmOrigin),
36
33
  };
37
34
  }
package/dist/nas/auth.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { CtlError } from '../ctl/errors.js';
2
2
  import { createRequest } from './transport.js';
3
3
  export async function loginDsm(origin, username, password, options) {
4
- const request = options.request ?? createRequest(origin, 30000, options.ca);
4
+ const request = options.request ?? createRequest(origin, undefined, options.ca);
5
5
  const form = new URLSearchParams({ api: 'SYNO.API.Auth', version: '7', method: 'login', account: username, passwd: password, session: 'DSM', format: 'cookie', enable_syno_token: 'yes', enable_device_token: options.remember === false ? 'no' : 'yes', device_name: 'synopkgland', device_id: options.deviceId ?? '' });
6
6
  let result = await request('/webapi/entry.cgi', form);
7
7
  if (result?.error?.code === 402) {
@@ -6,7 +6,7 @@ export async function connectNas(name) {
6
6
  const credentials = resolveHostCredentials(name);
7
7
  const session = await credentials.loadSession();
8
8
  registerDsmSession(session, credentials.hostName, credentials.ca, credentials.fingerprint, credentials.tls);
9
- const request = createRequest(session.origin, 62000, credentials.ca, credentials.tls);
9
+ const request = createRequest(session.origin, undefined, credentials.ca, credentials.tls);
10
10
  return {
11
11
  name: credentials.hostName,
12
12
  session,
@@ -2,4 +2,4 @@ import { type DsmSession } from './transport.js';
2
2
  import type { HostTls } from './tls.js';
3
3
  export declare function registerDsmSession(session: DsmSession, name: string, ca?: string, fingerprint?: string, policy?: HostTls): void;
4
4
  /** All DSM fetch callers, including session-only automation, use the NAS socket policy. */
5
- export declare function dsmFetch(url: string, init?: RequestInit): Promise<Response>;
5
+ export declare function dsmFetch(url: string, init?: RequestInit, timeoutMs?: number): Promise<Response>;
package/dist/nas/http.js CHANGED
@@ -9,12 +9,12 @@ export function registerDsmSession(session, name, ca, fingerprint, policy) {
9
9
  policies.set(session.origin, trust);
10
10
  }
11
11
  /** All DSM fetch callers, including session-only automation, use the NAS socket policy. */
12
- export async function dsmFetch(url, init = {}) {
12
+ export async function dsmFetch(url, init = {}, timeoutMs) {
13
13
  const headers = new Headers(init.headers), origin = new URL(url).origin;
14
14
  const cookie = headers.get('cookie') ?? '';
15
15
  // App cookies can accompany a DSM cookie; keep its token and TLS policy.
16
16
  const binding = [...bindings.values()].find(b => b.session.origin === origin && cookie.split(/;\s*/).includes(b.session.cookie));
17
- const response = await sessionFetch(url, { ...init, headers }, binding ? { ...binding.session, cookie } : undefined, undefined, binding?.trust ?? policies.get(origin));
17
+ const response = await sessionFetch(url, { ...init, headers }, binding ? { ...binding.session, cookie } : undefined, undefined, binding?.trust ?? policies.get(origin), timeoutMs);
18
18
  if (response.status >= 300 && response.status < 400)
19
19
  throw new CtlError('CTL_HTTP_ERROR', `HTTP ${response.status}; redirects are never followed`, 'Check the NAS URL', 6);
20
20
  if (binding && new URL(url).pathname === '/webapi/entry.cgi') {
package/dist/nas/store.js CHANGED
@@ -99,7 +99,7 @@ export class CtlStore {
99
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
- 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)) });
102
+ session = await loginDsm(host.origin, host.username, process.env.DSM_PASSWORD, { remember: false, otp: async () => process.env.DSM_OTP ?? '', request: createRequest(host.origin, undefined, undefined, this.trust(host)) });
103
103
  }
104
104
  else
105
105
  session = await this.credentials.get(this.key(host));
@@ -0,0 +1,8 @@
1
+ export declare const dsmRequestTimeoutMs: () => number;
2
+ export declare const dsmOperationTimeoutMs: () => number;
3
+ export declare const dsmUninstallTimeoutMs: () => number;
4
+ export declare const dsmInstallTimeoutMs: () => number;
5
+ export declare const dsmPollIntervalMs: () => number;
6
+ export declare const dsmPullStallTimeoutMs: () => number;
7
+ export declare function isHostTimeout(error: unknown): boolean;
8
+ export declare function dsmApiTimeoutMs(api: string | null, method: string | null): number;
@@ -0,0 +1,25 @@
1
+ import { CtlError } from '../ctl/errors.js';
2
+ /** Seconds in the existing DSM_* automation environment (also loaded from .env). */
3
+ function seconds(name, fallback) {
4
+ const value = process.env[name];
5
+ if (value === undefined)
6
+ return fallback * 1000;
7
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1 || Number(value) > 86400)
8
+ throw new CtlError('DSM_TIMEOUT_INVALID', `${name} must be a whole number of seconds from 1 to 86400`, 'Set a positive DSM timeout budget');
9
+ return Number(value) * 1000;
10
+ }
11
+ export const dsmRequestTimeoutMs = () => seconds('DSM_REQUEST_TIMEOUT_SECONDS', 180);
12
+ export const dsmOperationTimeoutMs = () => seconds('DSM_OPERATION_TIMEOUT_SECONDS', 600);
13
+ export const dsmUninstallTimeoutMs = () => seconds('DSM_UNINSTALL_TIMEOUT_SECONDS', 1800);
14
+ export const dsmInstallTimeoutMs = () => seconds('DSM_INSTALL_TIMEOUT_SECONDS', 1800);
15
+ export const dsmPollIntervalMs = () => seconds('DSM_POLL_INTERVAL_SECONDS', 5);
16
+ export const dsmPullStallTimeoutMs = () => seconds('DSM_IMAGE_PULL_STALL_TIMEOUT_SECONDS', 1200);
17
+ export function isHostTimeout(error) {
18
+ return error instanceof CtlError && error.code === 'HOST_TIMEOUT';
19
+ }
20
+ export function dsmApiTimeoutMs(api, method) {
21
+ const longOperation = api === 'SYNO.Entry.Request' || api === 'SYNO.Core.Package.Uninstallation' ||
22
+ (api === 'SYNO.Core.Package.Installation' && ['upload', 'install'].includes(method ?? '')) ||
23
+ (api === 'SYNO.Core.Package.Control' && ['start', 'stop'].includes(method ?? ''));
24
+ return longOperation ? dsmOperationTimeoutMs() : dsmRequestTimeoutMs();
25
+ }
package/dist/nas/tls.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { dsmRequestTimeoutMs } from './timeouts.js';
1
2
  import tls from "node:tls";
2
3
  import https from "node:https";
3
4
  import { isIP } from "node:net";
@@ -47,7 +48,7 @@ export function probeCertificate(origin, options = {}) {
47
48
  ...socketOptions(origin, options.ca),
48
49
  rejectUnauthorized: false,
49
50
  });
50
- const timer = setTimeout(() => socket.destroy(timeoutError()), options.timeoutMs ?? 30000);
51
+ const timer = setTimeout(() => socket.destroy(timeoutError()), options.timeoutMs ?? dsmRequestTimeoutMs());
51
52
  socket.once("error", (e) => reject(networkError(e)));
52
53
  socket.once("close", () => clearTimeout(timer));
53
54
  socket.once("secureConnect", () => {
@@ -11,7 +11,7 @@ export interface DsmSession {
11
11
  }
12
12
  export type JsonRequest = (path: string, body: URLSearchParams | Record<string, unknown>, session?: DsmSession) => Promise<any>;
13
13
  export declare function canonicalOrigin(input: string, tunnel?: boolean): string;
14
- export declare function createRequest(origin: string, timeoutMs?: number | (() => number), ca?: string, trust?: HostTls): JsonRequest;
15
- export declare function sessionFetch(url: string, init: RequestInit, session: DsmSession | undefined, ca?: string, trust?: HostTls): Promise<Response>;
14
+ export declare function createRequest(origin: string, timeoutMs?: number | (() => number) | undefined, ca?: string, trust?: HostTls): JsonRequest;
15
+ export declare function sessionFetch(url: string, init: RequestInit, session: DsmSession | undefined, ca?: string, trust?: HostTls, timeoutMs?: number): Promise<Response>;
16
16
  /** The only Node HTTP dispatch for NAS endpoints, including streaming transfers. */
17
17
  export declare function nasHttpRequest(url: URL, options: http.RequestOptions, timeoutMs: number, trust: HostTls | undefined, listener: (res: http.IncomingMessage) => void): http.ClientRequest;
@@ -6,6 +6,7 @@ import { assertSessionActive } from './session.js';
6
6
  import { nasOptions } from './options.js';
7
7
  import { readFileSync } from 'node:fs';
8
8
  import { CtlError } from '../ctl/errors.js';
9
+ import { dsmRequestTimeoutMs, dsmApiTimeoutMs } from './timeouts.js';
9
10
  export const CTL_TIMEOUT_MS = 62000;
10
11
  export const APP_COOKIE = Symbol("ctlAppCookie");
11
12
  export function canonicalOrigin(input, tunnel = false) {
@@ -20,11 +21,12 @@ export function canonicalOrigin(input, tunnel = false) {
20
21
  throw new CtlError('CTL_URL_INVALID', 'Use HTTPS, or explicit --ssh-tunnel for a loopback HTTP tunnel.', 'run: synopkgland host add <name> --url https://<nas>:5001');
21
22
  return url.origin;
22
23
  }
23
- export function createRequest(origin, timeoutMs = CTL_TIMEOUT_MS, ca, trust) {
24
+ export function createRequest(origin, timeoutMs = undefined, ca, trust) {
24
25
  return async (path, body, session) => {
25
26
  if (!path.startsWith('/') || path.startsWith('//') || path.includes('?') || path.includes('#') || path.includes('\\') || path.includes('%') || path.split('/').some(p => p === '.' || p === '..'))
26
27
  throw new CtlError('CTL_PATH_INVALID', 'Unsafe request path.', 'run: synopkgland ctl list --refresh');
27
- const remaining = typeof timeoutMs === 'function' ? timeoutMs() : timeoutMs;
28
+ const params = body instanceof URLSearchParams ? body : undefined;
29
+ const remaining = typeof timeoutMs === 'function' ? timeoutMs() : timeoutMs ?? (path === '/webapi/entry.cgi' ? dsmApiTimeoutMs(params?.get('api') ?? null, params?.get('method') ?? null) : CTL_TIMEOUT_MS);
28
30
  if (remaining <= 0)
29
31
  throw new CtlError('CTL_NETWORK', 'Request budget exhausted before dispatch.', 'Check App state before retrying writes.', 6);
30
32
  const url = new URL(path, origin);
@@ -91,7 +93,10 @@ export function createRequest(origin, timeoutMs = CTL_TIMEOUT_MS, ca, trust) {
91
93
  });
92
94
  };
93
95
  }
94
- export async function sessionFetch(url, init, session, ca, trust) {
96
+ export async function sessionFetch(url, init, session, ca, trust, timeoutMs) {
97
+ const targetUrl = new URL(url);
98
+ const params = init.body instanceof URLSearchParams ? init.body : targetUrl.searchParams;
99
+ timeoutMs ??= targetUrl.pathname === '/webapi/entry.cgi' ? dsmApiTimeoutMs(params.get('api'), params.get('method')) : dsmRequestTimeoutMs();
95
100
  if (session && new URL(url).origin !== session.origin)
96
101
  throw new CtlError('CTL_ORIGIN_MISMATCH', 'Credentials cannot cross NAS origins', 'Check the registered NAS origin', 4);
97
102
  const headers = new Headers(init.headers);
@@ -102,14 +107,14 @@ export async function sessionFetch(url, init, session, ca, trust) {
102
107
  const request = new Request(url, { ...init, headers }), bytes = init.body ? Buffer.from(await request.arrayBuffer()) : undefined;
103
108
  return new Promise((resolve, reject) => {
104
109
  const target = new URL(url);
105
- const req = nasHttpRequest(target, { method: request.method, headers: Object.fromEntries(request.headers) }, 62000, trustWithCa(trust, ca), res => {
110
+ const req = nasHttpRequest(target, { method: request.method, headers: Object.fromEntries(request.headers) }, timeoutMs, trustWithCa(trust, ca), res => {
106
111
  const chunks = [];
107
112
  let length = 0;
108
113
  res.on('data', c => { length += c.length; if (length > 16 * 1024 * 1024)
109
114
  res.destroy(new CtlError('CTL_RESPONSE_TOO_LARGE', 'DSM response exceeds 16 MiB', 'Use a narrower operation', 5));
110
115
  else
111
116
  chunks.push(c); });
112
- res.on('error', () => reject(new CtlError('DSM_NETWORK', 'DSM response interrupted', 'Check the NAS connection', 6)));
117
+ res.on('error', e => reject(e instanceof CtlError ? e : new CtlError('DSM_NETWORK', 'DSM response interrupted', 'Check the NAS connection', 6)));
113
118
  res.on('end', () => { const headers = new Headers(); for (const [k, v] of Object.entries(res.headers))
114
119
  for (const x of Array.isArray(v) ? v : [v])
115
120
  if (x !== undefined)
@@ -121,7 +126,7 @@ export async function sessionFetch(url, init, session, ca, trust) {
121
126
  else
122
127
  request.signal.addEventListener('abort', abort, { once: true });
123
128
  req.once('close', () => request.signal.removeEventListener('abort', abort));
124
- const timer = setTimeout(() => req.destroy(timeoutError()), 62000);
129
+ const timer = setTimeout(() => req.destroy(timeoutError()), timeoutMs);
125
130
  req.once('close', () => clearTimeout(timer));
126
131
  req.on('timeout', () => req.destroy(timeoutError()));
127
132
  req.on('error', e => reject(networkError(e)));
@@ -1,3 +1,4 @@
1
+ import { composeRegistryMirrors } from "../compose/registry-mirrors.js";
1
2
  import { portDefaults } from './install-ports.js';
2
3
  import { validComposeAppData } from "../runtime/compose-data.js";
3
4
  import { composeDiagnosisMessages, renderComposeDiagnosis } from "../build-spk/compose-diagnosis.js";
@@ -126,10 +127,7 @@ export function readComposeApp(value, root, arches, fail, context = {}) {
126
127
  install_settings: context.installSettings,
127
128
  install_ports: context.installPorts,
128
129
  resolve_images: context.resolveImages === true,
129
- registry_mirrors: context.resolveImages &&
130
- process.env.SYNOPKGLAND_COMPOSE_REGISTRY_MIRRORS
131
- ? JSON.parse(process.env.SYNOPKGLAND_COMPOSE_REGISTRY_MIRRORS)
132
- : undefined,
130
+ registry_mirrors: context.resolveImages ? composeRegistryMirrors() : undefined,
133
131
  architecture: context.architecture,
134
132
  image_proofs: context.imageProofs,
135
133
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.41.9",
3
+ "version": "0.41.11",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/skills/SKILL.md CHANGED
@@ -316,8 +316,12 @@ Use `appOpenSmoke` from `@synopackageland/cli/e2e`; keep one content-specific
316
316
  reuses the existing DSM credentials/profiles/tunnel, and registers administrator,
317
317
  normal-user and fresh anonymous tests. Configure `DSM_E2E_ACCOUNT_ADMIN` and
318
318
  `DSM_E2E_ACCOUNT_NORMAL` as private account files; a missing normal profile and
319
- credentials is a reported skip. For `entry: admin-only`, N must see the SDK
320
- gateway denial page with no App content or ready marker in the DOM; use
319
+ credentials is a reported skip. Smoke needs `e2e`, `@e2e-dev/web` and Chromium;
320
+ `ai` and `@ai-sdk/openai` are required only for `magpieAgent()`. Declare an optional
321
+ interstitial as `preReady: {clickIfVisible: {text: "Continue on this device"}}`.
322
+ For `entry: admin-only`, N must see the SDK gateway denial page or an explicit
323
+ DSM denial/launcher privilege signal without an App window. Timeout alone is not
324
+ denial. No App content or ready marker may be present in the DOM; use
321
325
  `content` for a separate App shell marker. Other entry kinds require N to
322
326
  reach `ready`; grant Application Privileges as needed. Preserve the manifest's
323
327
  entry policy when running the smoke.