@openhands/extensions 0.25.0 → 0.27.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.
Files changed (123) hide show
  1. package/.claude-plugin/marketplace.json +24 -0
  2. package/.codex-plugin/marketplace.json +24 -0
  3. package/.github/pull_request_template.md +17 -2
  4. package/.github/scripts/check_pr_description.py +64 -5
  5. package/.github/scripts/markdown_sections.py +95 -0
  6. package/.github/workflows/build-vscode-app.yml +30 -0
  7. package/.github/workflows/check-extensions.yml +3 -0
  8. package/.github/workflows/tests.yml +26 -0
  9. package/.plugin/marketplace.json +24 -0
  10. package/.release-please-manifest.json +1 -1
  11. package/AGENTS.md +3 -2
  12. package/README.md +9 -3
  13. package/apps/README.md +20 -0
  14. package/apps/catalog.json +10 -0
  15. package/apps/index.d.ts +11 -0
  16. package/apps/index.js +5 -0
  17. package/apps/vscode/README.md +13 -0
  18. package/apps/vscode/canvas-extension.json +19 -0
  19. package/apps/vscode/extension.js +114 -0
  20. package/apps/vscode/scripts/package.mjs +84 -0
  21. package/automations/README.md +7 -6
  22. package/automations/bundle-index.js +15 -15
  23. package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
  24. package/automations/catalog/github-pr-reviewer/manifest.json +5 -5
  25. package/automations/catalog/github-stale-ci-pr-closer/manifest.json +3 -3
  26. package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
  27. package/automations/catalog/news-digest/manifest.json +2 -2
  28. package/automations/index.d.ts +9 -1
  29. package/automations/interface.json +9 -0
  30. package/automations/interface.schema.json +37 -2
  31. package/index.d.ts +2 -0
  32. package/index.js +1 -0
  33. package/marketplaces/openhands-extensions.json +24 -0
  34. package/package.json +9 -1
  35. package/plugins/openhands/skills/openhands-api/README.md +1 -1
  36. package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
  37. package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
  38. package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
  39. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
  40. package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
  41. package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
  42. package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
  43. package/plugins/runtime-api-configs/README.md +59 -0
  44. package/plugins/runtime-api-configs/SKILL.md +240 -0
  45. package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
  46. package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
  47. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
  48. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +67 -15
  49. package/pyproject.toml +1 -1
  50. package/python/openhands_extensions/_version.py +1 -1
  51. package/scripts/check_plugin_resources.py +116 -0
  52. package/scripts/sync_extensions.py +32 -5
  53. package/skills/canvas-extension-api/SKILL.md +34 -3
  54. package/skills/canvas-extension-api/references/backend-safety.md +14 -0
  55. package/skills/canvas-extension-api/references/connections.md +24 -0
  56. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
  57. package/skills/canvas-extension-api/references/v1-contract.md +53 -3
  58. package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
  59. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
  60. package/skills/github/scripts/agent_conversation.py +223 -43
  61. package/skills/github/scripts/github_client.py +67 -15
  62. package/skills/github-agents-md-maintainer/README.md +7 -0
  63. package/skills/github-agents-md-maintainer/SKILL.md +13 -6
  64. package/skills/github-agents-md-maintainer/scripts/github_client.py +67 -15
  65. package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
  66. package/skills/github-delivery-watchdog/scripts/github_client.py +67 -15
  67. package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
  68. package/skills/github-issue-to-pr/scripts/github_client.py +67 -15
  69. package/skills/github-issue-to-pr/scripts/worker.py +10 -1
  70. package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
  71. package/skills/github-issue-triage/scripts/github_client.py +67 -15
  72. package/skills/github-pr-reviewer/README.md +5 -4
  73. package/skills/github-pr-reviewer/SKILL.md +8 -17
  74. package/skills/github-pr-reviewer/references/state-schema.md +0 -15
  75. package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
  76. package/skills/github-pr-reviewer/scripts/github_client.py +67 -15
  77. package/skills/github-pr-reviewer/scripts/worker.py +131 -108
  78. package/skills/github-repo-monitor/scripts/github_client.py +67 -15
  79. package/skills/github-stale-ci-pr-closer/scripts/worker.py +223 -50
  80. package/skills/github-stale-ci-pr-closer/tests/test_worker.py +155 -15
  81. package/skills/gitlab-issue-to-mr/README.md +12 -0
  82. package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
  83. package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
  84. package/skills/incident-retrospective/SKILL.md +5 -5
  85. package/skills/index.js +23 -14
  86. package/skills/jira-issue-to-pr/README.md +12 -4
  87. package/skills/jira-issue-to-pr/SKILL.md +129 -18
  88. package/skills/jira-issue-to-pr/references/setup.md +8 -0
  89. package/skills/jira-issue-to-pr/scripts/main.py +181 -54
  90. package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
  91. package/skills/linear-triage/SKILL.md +5 -5
  92. package/skills/news-digest/README.md +6 -0
  93. package/skills/news-digest/SKILL.md +19 -9
  94. package/skills/news-digest/commands/news-digest-setup.md +1 -1
  95. package/skills/news-digest/scripts/main.py +133 -15
  96. package/skills/openhands-api/README.md +1 -1
  97. package/skills/openhands-api/SKILL.md +3 -3
  98. package/skills/openhands-api/scripts/openhands_api.py +13 -0
  99. package/skills/openhands-automation/SKILL.md +66 -4
  100. package/skills/openhands-automation/references/custom-automation.md +21 -7
  101. package/skills/research-brief/SKILL.md +5 -5
  102. package/skills/slack-standup-digest/SKILL.md +5 -5
  103. package/skills/ticket-to-code-change/SKILL.md +6 -3
  104. package/skills/use-jev/.claude-plugin/plugin.json +8 -0
  105. package/skills/use-jev/.codex-plugin/plugin.json +8 -0
  106. package/skills/use-jev/.plugin/plugin.json +8 -0
  107. package/skills/use-jev/README.md +66 -0
  108. package/skills/use-jev/SKILL.md +66 -0
  109. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  110. package/tests/test_agent_conversation_dispatch.py +212 -0
  111. package/tests/test_apps_catalog.py +26 -0
  112. package/tests/test_automation_setup.py +44 -0
  113. package/tests/test_check_pr_description.py +89 -6
  114. package/tests/test_github_reviewer_delivery.py +254 -118
  115. package/tests/test_github_stale_ci_pr_closer.py +56 -0
  116. package/tests/test_interface_manifest.py +23 -1
  117. package/tests/test_jira_issue_to_pr.py +312 -0
  118. package/tests/test_jira_setup_webhook.py +309 -0
  119. package/tests/test_openhands_api_python.py +37 -0
  120. package/tests/test_skill_plugin_loading.py +25 -15
  121. package/tests/test_sync_extensions.py +102 -0
  122. package/tests/test_vscode_app.py +33 -0
  123. package/tests/vscode-app.test.mjs +43 -0
@@ -23,7 +23,7 @@ Inspect these files when targeting a newer OpenHands revision:
23
23
 
24
24
  Treat the active Agent Server as the owner of installed apps. Source, resolved revision, files, manifest, and enabled state live on that backend. Switching backend replaces the active app inventory.
25
25
 
26
- Treat enabled code as trusted same-realm JavaScript. No iframe, worker sandbox, granular permission boundary, or CSS security boundary exists in v1. Enabled code has the ambient browser authority available to Canvas and can use authenticated helpers for the current backend.
26
+ Treat enabled code as trusted same-realm JavaScript. No iframe, worker sandbox, granular permission boundary, or CSS security boundary exists in v1 for App-authored code or the App's own frame. Enabled code has the ambient browser authority available to Canvas and can use authenticated helpers for the current backend. The only frame in v1 is the host-owned managed-backend view, created by the `host.appBackendView` helper described below and never by the App; it isolates the backend's own content on a separate ingress origin and grants the App no authority it did not already have.
27
27
 
28
28
  Keep install and enable separate. Installation produces a disabled app. Enabling is the explicit execution consent point. Enablement and disablement are hot and require no restart.
29
29
 
@@ -42,6 +42,22 @@ interface CanvasExtensionManifest {
42
42
  contributes?: {
43
43
  pages?: CanvasExtensionPageContribution[] | null;
44
44
  } | null;
45
+ backend?: CanvasExtensionBackend | null;
46
+ }
47
+
48
+ interface CanvasExtensionBackend {
49
+ schema_version: 1;
50
+ artifacts: Partial<Record<"linux-amd64" | "linux-arm64", {
51
+ path: string;
52
+ sha256: string;
53
+ }>>;
54
+ argv: string[];
55
+ health?: {
56
+ path?: string;
57
+ timeout_seconds?: number;
58
+ interval_seconds?: number;
59
+ };
60
+ inherit_environment?: string[];
45
61
  }
46
62
 
47
63
  interface CanvasExtensionPageContribution {
@@ -53,7 +69,23 @@ interface CanvasExtensionPageContribution {
53
69
  }
54
70
  ```
55
71
 
56
- Required manifest fields are `schema_version`, `name`, `version`, and `entrypoint`. A useful page package also supplies `display_name`, `description`, and at least one `contributes.pages` entry.
72
+ Required manifest fields are `schema_version`, `name`, `version`, and `entrypoint`. A useful page package also supplies `display_name`, `description`, and at least one `contributes.pages` entry. `backend` is optional: omitting it preserves a browser-only App or a manual/deployment-owned Sidecar.
73
+
74
+ The managed-backend contract requires the stacked, unreleased Agent Server changes in [software-agent-sdk PR #5270](https://github.com/OpenHands/software-agent-sdk/pull/5270) at `ac7b322ddf7d2e0e90a3643c5e26d848236af9a3` and [PR #5272](https://github.com/OpenHands/software-agent-sdk/pull/5272) at `6d9c82973e765cce4843d6a2fb52f634efa64293`. Until a release containing both lands, those exact revisions are the minimum Agent Server implementation. Feature-detect `canvas_app_backend_bridge_v1` in `GET /server_info`; the same response must provide `app_backend_ingress_url`.
75
+
76
+ The minimum host is the Agent Canvas revision that adds the optional `host.appBackendView` helper specified under Host API 1 below — the frontend change in OpenHands/OpenHands#17641, which this document's PR lists as a merge prerequisite. No upstream release pins that helper yet, so there is no release number to name; until there is, the minimum host is exactly "the build whose `host.appBackendView` is present", and feature-detecting that helper is the only supported test. Absence is an actionable unsupported state, not a reason to guess an ingress port or read Canvas internals.
77
+
78
+ All references in this bundle to "the minimum host version documented in `v1-contract.md`" mean this paragraph.
79
+
80
+ For a managed backend:
81
+
82
+ - Key `artifacts` only by `linux-amd64` or `linux-arm64`; one matching entry is sufficient.
83
+ - Keep each artifact `path` package-relative, contained, and ending in `.tar.gz`.
84
+ - Set `sha256` to exactly 64 lowercase hexadecimal characters.
85
+ - Use a non-empty structured `argv`; `argv[0]` must resolve inside `{artifact_dir}`.
86
+ - Use only `{artifact_dir}`, `{data_dir}`, and `{port}` placeholders.
87
+ - Keep `health.path` root-relative. Defaults are `/health`, 30 seconds, and 0.1 seconds.
88
+ - Request only `LANG`, `LC_ALL`, `LC_CTYPE`, `PATH`, `TMPDIR`, or `TZ` in `inherit_environment`, without duplicates. Ambient Agent Server credentials are never inherited.
57
89
 
58
90
  Apply these rules:
59
91
 
@@ -95,6 +127,9 @@ interface CanvasExtensionHost {
95
127
  headers?: Record<string, string>;
96
128
  }): Promise<T>;
97
129
  };
130
+ readonly appBackendView?: {
131
+ mount(options: { container: HTMLElement }): () => void;
132
+ };
98
133
  }
99
134
 
100
135
  type CanvasExtensionPageMount = (context: {
@@ -114,6 +149,11 @@ export function activate(
114
149
 
115
150
  The loader fetches authenticated JavaScript text, creates a temporary Blob URL, imports it as ESM, verifies the `activate` export, and revokes the Blob URL. Import does not activate a disabled installation.
116
151
 
152
+ `appBackendView` is additive and optional. Feature-detect it before mounting. It is absent for host builds without the helper, no-backend and cloud selections, or selected Agent Servers without `canvas_app_backend_bridge_v1`, `app_backend_ingress_url`, and the compatible typed client. `mount({ container })` returns a disposer. The helper obtains an app-scoped session, validates the HTTP(S) ingress and sandbox, renders loading/error/retry and safe new-tab fallback, and revokes the session on disposal. It does not prepare, start, stop, or delete backend data.
153
+
154
+ Every App API call is scoped to the active backend. When the user changes backend, Canvas disposes the old activation, mounted backend views, and sessions before activating the App with a fresh host snapshot.
155
+
156
+
117
157
  ## Registration constraints
118
158
 
119
159
  Register each page during activation using exactly the page ID declared in the manifest. The runtime rejects undeclared IDs and duplicate registrations.
@@ -213,6 +253,16 @@ The current Canvas frontend service calls:
213
253
  | `PATCH` | `/api/canvas-extensions/installed/{name}` | Set enabled state |
214
254
  | `DELETE` | `/api/canvas-extensions/installed/{name}` | Uninstall |
215
255
  | `GET` | `/api/canvas-extensions/installed/{name}/bundle` | Fetch entrypoint JavaScript text |
256
+ | `GET` | `/api/canvas-extensions/installed/{name}/backend` | Read managed-backend status and revision |
257
+ | `POST` | `/api/canvas-extensions/installed/{name}/backend/prepare` | Verify and prepare the approved revision |
258
+ | `POST` | `/api/canvas-extensions/installed/{name}/backend/start` | Start the prepared approved revision |
259
+ | `POST` | `/api/canvas-extensions/installed/{name}/backend/stop` | Stop the owned process group |
260
+ | `GET` | `/api/canvas-extensions/installed/{name}/backend/logs` | Read bounded backend logs |
261
+ | `DELETE` | `/api/canvas-extensions/installed/{name}/backend/data` | Separately delete preserved mutable data |
262
+
263
+ Prepare and start bodies are `{"revision":"<exact installed revision>"}`. Status is exactly `missing`, `stopped`, `starting`, `ready`, `unhealthy`, or `unsupported`. Installation, enablement, activation, and page mounting never prepare or start a backend. Updating invalidates approval. Disable and uninstall stop owned processes and revoke bridge access but preserve backend data; data deletion is separate.
264
+
265
+ The bridge advertises `canvas_app_backend_bridge_v1` and `app_backend_ingress_url` through `GET /server_info`. Its control endpoints are `POST` and `DELETE` on `/app-backends/{name}/session` at the configured ingress, which must be a separate browser origin from Canvas. Session responses contain `ingress_url`, `expires_at`, and `iframe_sandbox`; the credential is an HttpOnly cookie and never appears in the response or URL. The v1 sandbox is `allow-forms allow-modals allow-popups allow-same-origin allow-scripts`. Preserve `allow-same-origin`: the separate ingress origin is the isolation boundary, while an opaque sandbox origin causes Chromium to send `Origin: null` for mutating fetches and WebSockets, omit the partitioned cookie, and reject Worker creation with `SecurityError`.
216
266
 
217
267
  Install request:
218
268
 
@@ -243,7 +293,7 @@ Do not promise or implement these features without confirming a newer contract:
243
293
  - visualizer augmentation or replacement;
244
294
  - marketplace signing or publisher verification;
245
295
  - enforceable fine-grained permissions;
246
- - iframe or worker isolation;
296
+ - iframe or worker isolation for App-authored frames: an App must not create its own frames or Workers to obtain an isolation boundary. (A host-owned managed-backend view is the one exception and is not App-authored: it is reachable only through the feature-detected `host.appBackendView` helper, which owns the ingress origin, sandbox attributes, and session lifecycle. The App never constructs that frame, picks its URL, or reads its credential.)
247
297
  - arbitrary install lifecycle scripts;
248
298
  - automatic agent-driven enablement.
249
299
 
@@ -48,6 +48,58 @@ for (const field of ["name", "version", "entrypoint"]) {
48
48
  if (typeof manifest.name === "string" && !name.test(manifest.name)) fail("Manifest name must use lowercase kebab-case.");
49
49
  if (typeof manifest.version === "string" && !semver.test(manifest.version)) fail("Manifest version must use semantic versioning.");
50
50
 
51
+ if (manifest.backend !== undefined && manifest.backend !== null) {
52
+ const backend = manifest.backend;
53
+ if (typeof backend !== "object" || Array.isArray(backend)) {
54
+ fail("Manifest backend must be an object.");
55
+ } else {
56
+ if (backend.schema_version !== 1) fail("Manifest backend.schema_version must equal 1.");
57
+ const artifacts = backend.artifacts;
58
+ if (!artifacts || typeof artifacts !== "object" || Array.isArray(artifacts) || Object.keys(artifacts).length === 0) {
59
+ fail("Manifest backend.artifacts must be a non-empty platform map.");
60
+ } else {
61
+ for (const [platform, artifact] of Object.entries(artifacts)) {
62
+ const label = `backend.artifacts[${platform}]`;
63
+ if (!new Set(["linux-amd64", "linux-arm64"]).has(platform)) fail(`${label} uses an unsupported platform.`);
64
+ if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) { fail(`${label} must be an object.`); continue; }
65
+ if (typeof artifact.path !== "string" || !artifact.path || artifact.path.startsWith("/") || artifact.path.split(/[\\/]/).includes("..") || !artifact.path.endsWith(".tar.gz")) fail(`${label}.path must be a contained relative .tar.gz path.`);
66
+ if (typeof artifact.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(artifact.sha256)) fail(`${label}.sha256 must be a lowercase SHA-256 checksum.`);
67
+ }
68
+ }
69
+ if (!Array.isArray(backend.argv) || backend.argv.length === 0 || backend.argv.some((argument) => typeof argument !== "string" || !argument || argument.includes("\0"))) {
70
+ fail("Manifest backend.argv must be a non-empty array of strings.");
71
+ } else {
72
+ if (!backend.argv[0].startsWith("{artifact_dir}/")) {
73
+ fail("Manifest backend.argv[0] must execute from {artifact_dir}.");
74
+ } else if (backend.argv[0].slice("{artifact_dir}/".length).split(/[\\/]/).includes("..")) {
75
+ fail("Manifest backend.argv[0] must resolve inside {artifact_dir}.");
76
+ }
77
+ const allowed = new Set(["{port}", "{data_dir}", "{artifact_dir}"]);
78
+ for (const argument of backend.argv) {
79
+ for (const placeholder of argument.match(/\{[^{}]+\}/g) ?? []) {
80
+ if (!allowed.has(placeholder)) fail(`Manifest backend.argv contains unsupported placeholder ${placeholder}.`);
81
+ }
82
+ }
83
+ }
84
+ if (backend.health !== undefined) {
85
+ const health = backend.health;
86
+ if (!health || typeof health !== "object" || Array.isArray(health)) {
87
+ fail("Manifest backend.health must be an object.");
88
+ } else {
89
+ if (health.path !== undefined && (typeof health.path !== "string" || !/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$/.test(health.path))) fail("Manifest backend.health.path must be a root-relative HTTP path.");
90
+ for (const [field, maximum] of [["timeout_seconds", 300], ["interval_seconds", 10]]) {
91
+ if (health[field] !== undefined && (typeof health[field] !== "number" || health[field] <= 0 || health[field] > maximum)) fail(`Manifest backend.health.${field} must be greater than 0 and at most ${maximum}.`);
92
+ }
93
+ }
94
+ }
95
+ if (backend.inherit_environment !== undefined) {
96
+ const environment = backend.inherit_environment;
97
+ const allowed = new Set(["LANG", "LC_ALL", "LC_CTYPE", "PATH", "TMPDIR", "TZ"]);
98
+ if (!Array.isArray(environment) || environment.some((variable) => typeof variable !== "string" || !allowed.has(variable)) || new Set(environment).size !== environment.length) fail("Manifest backend.inherit_environment must contain unique names from the supported non-credential allowlist.");
99
+ }
100
+ }
101
+ }
102
+
51
103
  const ids = new Set();
52
104
  const paths = new Set();
53
105
  const pages = manifest.contributes?.pages;
@@ -7,7 +7,7 @@ import test from "node:test";
7
7
 
8
8
  const validator = new URL("./validate-extension.mjs", import.meta.url);
9
9
 
10
- async function createApp(entrypoint) {
10
+ async function createApp(entrypoint, manifestOverrides = {}) {
11
11
  const root = await mkdtemp(path.join(os.tmpdir(), "canvas-extension-validator-"));
12
12
  await mkdir(path.join(root, "dist"), { recursive: true });
13
13
  await writeFile(
@@ -20,6 +20,7 @@ async function createApp(entrypoint) {
20
20
  contributes: {
21
21
  pages: [{ id: "home", title: "Home", path: "/home" }],
22
22
  },
23
+ ...manifestOverrides,
23
24
  }),
24
25
  );
25
26
  await writeFile(
@@ -29,6 +30,29 @@ async function createApp(entrypoint) {
29
30
  return root;
30
31
  }
31
32
 
33
+ function validate(root, ...options) {
34
+ return spawnSync(process.execPath, [validator.pathname, root, ...options], {
35
+ encoding: "utf8",
36
+ });
37
+ }
38
+
39
+ const backend = {
40
+ schema_version: 1,
41
+ artifacts: {
42
+ "linux-amd64": {
43
+ path: "backend/linux-amd64.tar.gz",
44
+ sha256: "a".repeat(64),
45
+ },
46
+ "linux-arm64": {
47
+ path: "backend/linux-arm64.tar.gz",
48
+ sha256: "b".repeat(64),
49
+ },
50
+ },
51
+ argv: ["{artifact_dir}/bin/service", "--port", "{port}", "--data", "{data_dir}"],
52
+ health: { path: "/health", timeout_seconds: 30, interval_seconds: 0.1 },
53
+ inherit_environment: ["LANG", "TZ"],
54
+ };
55
+
32
56
  test("--dist uses the manifest entrypoint name", async () => {
33
57
  const root = await createApp("bundle.mjs");
34
58
  try {
@@ -58,3 +82,170 @@ test("--dist rejects sibling output chunks", async () => {
58
82
  await rm(root, { recursive: true, force: true });
59
83
  }
60
84
  });
85
+
86
+ test("accepts a package-owned backend declaration", async () => {
87
+ const root = await createApp("extension.js", { backend });
88
+ try {
89
+ const result = validate(root, "--dist");
90
+ assert.equal(result.status, 0, result.stderr);
91
+ } finally {
92
+ await rm(root, { recursive: true, force: true });
93
+ }
94
+ });
95
+
96
+ test("rejects a backend artifact path outside the package", async () => {
97
+ const root = await createApp("extension.js", {
98
+ backend: {
99
+ ...backend,
100
+ artifacts: {
101
+ "linux-amd64": {
102
+ path: "../service.tar.gz",
103
+ sha256: "a".repeat(64),
104
+ },
105
+ },
106
+ },
107
+ });
108
+ try {
109
+ const result = validate(root, "--dist");
110
+ assert.notEqual(result.status, 0);
111
+ assert.match(
112
+ result.stderr,
113
+ /backend\.artifacts\[linux-amd64\]\.path must be a contained relative \.tar\.gz path/,
114
+ );
115
+ } finally {
116
+ await rm(root, { recursive: true, force: true });
117
+ }
118
+ });
119
+
120
+ test("rejects a backend argument vector that is not a structured list", async () => {
121
+ const root = await createApp("extension.js", {
122
+ backend: { ...backend, argv: "service --port {port}" },
123
+ });
124
+ try {
125
+ const result = validate(root, "--dist");
126
+ assert.notEqual(result.status, 0);
127
+ assert.match(
128
+ result.stderr,
129
+ /backend\.argv must be a non-empty array of strings/,
130
+ );
131
+ } finally {
132
+ await rm(root, { recursive: true, force: true });
133
+ }
134
+ });
135
+
136
+ test("rejects a backend executable outside the prepared artifact", async () => {
137
+ const root = await createApp("extension.js", {
138
+ backend: { ...backend, argv: ["bin/service", "--port", "{port}"] },
139
+ });
140
+ try {
141
+ const result = validate(root, "--dist");
142
+ assert.notEqual(result.status, 0);
143
+ assert.match(
144
+ result.stderr,
145
+ /backend\.argv\[0\] must execute from \{artifact_dir\}/,
146
+ );
147
+ } finally {
148
+ await rm(root, { recursive: true, force: true });
149
+ }
150
+ });
151
+
152
+ test("rejects a backend executable that traverses outside the artifact", async () => {
153
+ const root = await createApp("extension.js", {
154
+ backend: { ...backend, argv: ["{artifact_dir}/../outside"] },
155
+ });
156
+ try {
157
+ const result = validate(root, "--dist");
158
+ assert.notEqual(result.status, 0);
159
+ assert.match(
160
+ result.stderr,
161
+ /backend\.argv\[0\] must resolve inside \{artifact_dir\}/,
162
+ );
163
+ } finally {
164
+ await rm(root, { recursive: true, force: true });
165
+ }
166
+ });
167
+
168
+ test("rejects unsupported backend argument placeholders", async () => {
169
+ const root = await createApp("extension.js", {
170
+ backend: { ...backend, argv: ["{artifact_dir}/bin/service", "{secret}"] },
171
+ });
172
+ try {
173
+ const result = validate(root, "--dist");
174
+ assert.notEqual(result.status, 0);
175
+ assert.match(result.stderr, /backend\.argv contains unsupported placeholder \{secret\}/);
176
+ } finally {
177
+ await rm(root, { recursive: true, force: true });
178
+ }
179
+ });
180
+
181
+ test("rejects invalid backend health settings", async () => {
182
+ const root = await createApp("extension.js", {
183
+ backend: { ...backend, health: { path: "health", timeout_seconds: 0 } },
184
+ });
185
+ try {
186
+ const result = validate(root, "--dist");
187
+ assert.notEqual(result.status, 0);
188
+ assert.match(
189
+ result.stderr,
190
+ /backend\.health\.path must be a root-relative HTTP path/,
191
+ );
192
+ assert.match(
193
+ result.stderr,
194
+ /backend\.health\.timeout_seconds must be greater than 0 and at most 300/,
195
+ );
196
+ } finally {
197
+ await rm(root, { recursive: true, force: true });
198
+ }
199
+ });
200
+
201
+
202
+
203
+ test("accepts omitted managed backend health defaults", async () => {
204
+ const root = await createApp("extension.js", {
205
+ backend: { ...backend, health: {} },
206
+ });
207
+ try {
208
+ const result = validate(root, "--dist");
209
+ assert.equal(result.status, 0, result.stderr);
210
+ } finally {
211
+ await rm(root, { recursive: true, force: true });
212
+ }
213
+ });
214
+
215
+ test("rejects disallowed or duplicate inherited environment names", async () => {
216
+ const root = await createApp("extension.js", {
217
+ backend: { ...backend, inherit_environment: ["LANG", "SECRET", "LANG"] },
218
+ });
219
+ try {
220
+ const result = validate(root, "--dist");
221
+ assert.notEqual(result.status, 0);
222
+ assert.match(
223
+ result.stderr,
224
+ /backend\.inherit_environment must contain unique names from the supported non-credential allowlist/,
225
+ );
226
+ } finally {
227
+ await rm(root, { recursive: true, force: true });
228
+ }
229
+ });
230
+
231
+ test("preserves browser-only packages without a backend declaration", async () => {
232
+ const root = await createApp("extension.js");
233
+ try {
234
+ const result = validate(root, "--dist");
235
+ assert.equal(result.status, 0, result.stderr);
236
+ } finally {
237
+ await rm(root, { recursive: true, force: true });
238
+ }
239
+ });
240
+
241
+ test("preserves manual sidecars with no managed backend declaration", async () => {
242
+ const root = await createApp("extension.js", {
243
+ description: "Connects to a deployment-owned sidecar configured by an operator.",
244
+ });
245
+ try {
246
+ const result = validate(root, "--dist");
247
+ assert.equal(result.status, 0, result.stderr);
248
+ } finally {
249
+ await rm(root, { recursive: true, force: true });
250
+ }
251
+ });