Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`querya-ext-tester --strict`** — warnings fail the run (exit 1), for example a recommended method that errors or an OS sandbox that could not be applied; with `--sandbox` in CI a green run now means the plugin really ran isolated. The `test-extension` action has a matching `strict` input.

- **Extension registry** — `StaticRegistryRepository` reads a static `index.json` published by [QueryaHub/extensions](https://github.com/QueryaHub/extensions) (GitHub Pages): yanked entries, non-GitHub-release URLs and extensions the running version is too old for are hidden, the index is cached, installs keep the SHA-256 / archive / sandbox checks. The Extensions → Marketplace tab now shows this catalog instead of the built-in demo one (which listed fake extensions and wrote placeholder folders into the real extensions directory, #1568); while the registry is empty or unreachable the tab says so, with a *Retry*. Also new for extension authors: the `ghcr.io/queryahub/features/querya-ext-tester` Dev Container Feature, `querya-testdb-*` test database images, and the [extension template](https://github.com/QueryaHub/extension-template). See [docs/extension-registry.md](docs/extension-registry.md).
- **`manifestVersion` in `manifest.json`** — the version of the manifest format itself, apart from the extension's `version` and the host range `engines`. Absent means format 1, so nothing existing changes; `querya-ext-tester` rejects a format the emulated host does not read, and the app does not offer, install or load an extension in a format newer than it reads. See [docs/extension-registry.md](docs/extension-registry.md#versions-what-is-checked-against-what).
- **Release hooks** — after a Release the registry and the template are told about the new version (`repository_dispatch`); needs the `QUERYA_DISPATCH_TOKEN` secret.

### Fixed
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Index of Querya Desktop documentation, grouped by audience.
- [Security / local data](security.md) — where metadata and secrets are stored.
- [MCP server](mcp-server.md) — let Claude Desktop, Cursor, VS Code and other AI clients read shared connections (read-only).
- [Testing extensions in CI](extension-testing.md) — `querya-ext-tester`, release binaries and the GitHub Action.
- [Extension manifest](extension-manifest.md) — every field of `manifest.json`, the sandbox policy, what checks it, and how the manifest format is versioned (`manifestVersion`).
- [Extension registry](extension-registry.md) — from an author's machine to the Marketplace: the template, test databases, the registry's CI and index, and how it follows Querya releases.

## For contributors
Expand Down
130 changes: 130 additions & 0 deletions docs/extension-manifest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# The extension manifest (`manifest.json`)

Every extension is a folder or zip with a `manifest.json` at its root. This is
the reference for that file: what the fields mean, what the tester and the app
check, and how the format itself is versioned.

Start from a working one: the [extension template](https://github.com/QueryaHub/extension-template)
has a complete manifest of a database driver. How an extension gets from there
to the Marketplace is in [extension-registry.md](extension-registry.md); how to
test it is in [extension-testing.md](extension-testing.md).

## A database driver

```json
{
"manifestVersion": 1,
"id": "acme.redis-driver",
"name": "Redis driver",
"version": "1.0.0",
"publisher": "Acme",
"type": "database_driver",
"main": "bin/redis-driver",
"engines": { "querya_desktop": ">=0.4.19" },
"capabilities": { "databaseDriver": true },
"sandbox": {
"engine": "process",
"permissions": {
"network": { "mode": "connection_host_only", "allow_ssl": false },
"filesystem": { "scratch_mb": 10, "access": "scratch_only" },
"resources": { "memory_mb": 128, "max_open_files": 32, "timeout_seconds": 60 }
}
},
"contributions": {
"drivers": [
{
"driverId": "acme-redis",
"displayName": "Redis (Acme)",
"defaultPort": 6379,
"connectionFormSchema": "assets/connection_form.json"
}
]
}
}
```

## Fields

| Field | Required | Meaning |
|-------|----------|---------|
| `manifestVersion` | no | The format of this file: a whole number, **1 when absent**. See [Versioning](#versioning-of-the-manifest-format) |
| `id` | yes | `publisher.name` in lowercase, digits and dashes. The install folder, the registry record (`extensions/<id>.json`) and everything else key on it |
| `name` | yes | Shown in the Marketplace and the Extensions list |
| `version` | yes | The extension's own version, `x.y.z`. The registry requires it to equal the record's |
| `publisher` | no | Shown as the author (`author` is read when `publisher` is missing) |
| `type` | yes | `database_driver`, `theme` or `script`. Only `database_driver` can use the OS process sandbox, and only it is run by the tester |
| `main` | drivers | The executable, relative to the root. Must be a file in the archive |
| `engines.querya_desktop` | yes | The Querya Desktop versions it runs on: `>=x.y.z` or `^x.y.z`. The app hides the extension outside the range; the tester warns when the emulated host is outside it |
| `description`, `icon`, `homepage`, `license`, `tags`, `preview` | no | Marketplace listing. `icon` is a path inside the archive |
| `capabilities` | no | `databaseDriver`, `sduiForms` flags |
| `sandbox` | drivers | What the driver is allowed to do; see below |
| `contributions` | drivers | `drivers[]` (`driverId`, `displayName`, `defaultPort`, `connectionFormSchema`, `icon`) and `commands[]` (`id`, `title`, `category`, `aliases`; commands need Querya Desktop 0.4.16 or newer). `contributes` is accepted as an alias |

Fields the marketplace adds (`downloadUrl`, `sha256`) are not written by the
author: the registry builds them from the release asset.

## The sandbox block

A database driver runs as its own process inside an OS sandbox (`bubblewrap` on
Linux, `sandbox-exec` on macOS). It declares what it needs, and the app refuses
anything beyond the policy:

| Setting | Allowed | Notes |
|---------|---------|-------|
| `engine` | `process` | `wasm` and `quickjs` are reserved for embedded scripts |
| `permissions.network.mode` | `connection_host_only` or `none` | Sockets only to the host the user configured. `allow_ssl` says it uses TLS |
| `permissions.filesystem` | `access: "scratch_only"`, `scratch_mb` up to 100 | The only writable place is a scratch directory |
| `permissions.resources` | `memory_mb` up to 512, `max_open_files` up to 64 | `timeout_seconds` is optional |

A driver without a sandbox block, or with one the policy rejects, is shown in the
Marketplace as a **Preview** listing and cannot be installed.

## What checks the manifest

| Where | What it checks |
|-------|----------------|
| `querya-ext-tester` (the `manifest` step) | Required fields, `type`, `main`, `engines` against `--target-version`, `manifestVersion` against that host, `contributions.drivers[]`. Every problem comes with its JSON path |
| The registry's CI (`registry.py check`) | `manifest.json` at the archive root, `id` and `version` equal the record's, `engines.querya_desktop` present, a driver's `main` is in the archive, `manifestVersion` is a format Querya defines. All platform archives of a record agree on `id`, `version`, `name`, `type`, `engines` and `manifestVersion` |
| The app, when it lists, installs or loads | The sandbox policy, the preview rule, `manifestVersion` not newer than it reads, the driver package (`main` exists) |

## Versioning of the manifest format

Three different versions meet in the file:

| Field | Versions | Who raises it |
|-------|----------|---------------|
| `version` | the extension | the author, for each release |
| `engines.querya_desktop` | the hosts the author tested | the author |
| `manifestVersion` | **the format of `manifest.json`** | Querya, when the format changes |

`manifestVersion` is **1 when it is absent**, so every manifest written so far is
format 1 and nothing has to be changed. Writing `"manifestVersion": 1` is
optional but makes the format explicit (the template does).

An older Querya Desktop must not guess what a newer manifest means, so one in a
format it does not read is refused outright: the tester reports an error for the
host it emulates, the Marketplace does not list it, and the installer and the
local loader say to update Querya Desktop. A format that a host reads is read
completely.

### When the format changes

Keep a change **additive** when it can be: a new optional field that an older
host ignores safely needs no new `manifestVersion`, only a line in this document
and, if the tester should warn about it, a rule in the validator for the hosts
that know it. Raise the format only for a change an older host would misread: a
field that changes the meaning of an existing one, a removed or renamed field, a
stricter requirement.

To add format *N*:

1. `ExtensionManifest.currentManifestVersion` → *N* (`lib/core/extensions/models/extension_manifest.dart`).
2. `ExtensionProtocolProfile._manifestFormats`: add `(N, <first Querya Desktop release that reads it>)`.
3. Teach the reading code (`ExtensionManifest.fromJson` and what uses it) the new shape, keeping format 1 working.
4. Add the rule to `validateExtensionManifest`, gated on `profile.manifestVersion >= N` where it only applies to the new format.
5. `QueryaHub/extensions`: `MAX_MANIFEST_VERSION` in `scripts/registry.py` → *N*, with tests.
6. Update this document, the [extension template](https://github.com/QueryaHub/extension-template) if its manifest should use the new format, and the CHANGELOG.
7. Tests: the validator on a host before and after the release, the support check, the local registry skip, the registry reader.

Authors then opt in with `"manifestVersion": N` once the hosts they support
(`engines`) all read it.
35 changes: 32 additions & 3 deletions docs/extension-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,34 @@ The extension's binary runs in the registry's CI, inside the OS sandbox, in a jo
with no secrets and a read-only token. Only the Linux x86-64 build is exercised; the other platforms' archives are
checked (hash, archive, manifest) but not run.

## Versions: what is checked against what

The whole manifest is described in [extension-manifest.md](extension-manifest.md); this is the part about versions.

Three different versions meet in `manifest.json`:

| Field | What it versions | Checked |
|-------|------------------|---------|
| `version` | the extension itself | the registry record and the archive must agree |
| `engines.querya_desktop` | the Querya Desktop versions the author says it runs on | the app hides the extension outside the range (`>=x.y.z` or `^x.y.z`) |
| `manifestVersion` | the **format of `manifest.json`** (apart from the other two) | see below |

`manifestVersion` is a whole number, **1 when absent** (every manifest written so
far). It exists so the format can change later without guessing from the host
version: a release that starts reading a new format raises
`ExtensionManifest.currentManifestVersion` and lists the first host version that
reads it in `ExtensionProtocolProfile`. Until a format 2 exists there is nothing
to opt into; new manifests may set `"manifestVersion": 1` explicitly (the
template does).

- **The tester** checks `manifestVersion` against the host it emulates
(`--target-version`): a format newer than that host reads is an error, a value
that is not a whole number >= 1 is an error.
- **The app** does not offer, install or load an extension in a format newer than
it reads, and says to update Querya Desktop.
- **The registry** refuses a record whose archive uses a format Querya does not
define yet, and copies the format into the index entry.

## What the app trusts

`StaticRegistryRepository` reads `https://queryahub.github.io/extensions/index.json`:
Expand All @@ -64,11 +92,12 @@ checked (hash, archive, manifest) but not run.
`macos-x64`, `windows-x64`), each with its own `downloadUrl`, `sha256` and
`main`. The app resolves the map to the build for the platform it runs on and
does not offer the extension where there is none.
- An index with another `schema` is refused, not half read.
- An index with another `schema` is refused, not half read. (`schema` is the
index's own format; `manifestVersion` of an entry is its manifest's.)
- Hidden from the list: `yanked` entries, entries without a checksum, a
`downloadUrl` that is not `https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>`,
and extensions whose `engines.querya_desktop` range excludes the running
version. One malformed entry does not hide the others.
extensions whose `engines.querya_desktop` range excludes the running
version, and extensions whose `manifestVersion` is newer than the app reads. One malformed entry does not hide the others.
- The index is cached for ten minutes; if a refresh fails the last good copy is
served.
- Installing is the same pipeline as any marketplace install: the archive's
Expand Down
5 changes: 4 additions & 1 deletion docs/extension-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ server, Flutter or the Querya Desktop source tree.
## What it checks

1. **`manifest.json`** — `id`, `name`, `version`, `type: "database_driver"`,
`main`, `engines.querya_desktop` (against the emulated host version),
`main`, `engines.querya_desktop` and `manifestVersion` (both against the
emulated host version; see [extension-registry.md](extension-registry.md#versions-what-is-checked-against-what)),
`contributions.drivers[]` (`driverId`, `displayName`). Every problem is
reported with its JSON path.
2. **Launch** — the `main` entry point exists and starts.
Expand Down Expand Up @@ -178,6 +179,8 @@ the container with `--security-opt seccomp=unconfined` (and, on hosts that
restrict namespaces with AppArmor, `--security-opt apparmor=unconfined`), or
run the tester directly on the CI runner with the action below.

The fields of `manifest.json` are in [extension-manifest.md](extension-manifest.md).

Starting a new extension? The [extension template](https://github.com/QueryaHub/extension-template)
has all of this wired up, and [extension-registry.md](extension-registry.md)
shows how a tested extension gets into the Marketplace.
Expand Down
12 changes: 12 additions & 0 deletions lib/core/extensions/extension_support.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ class ExtensionSupport {
static const databaseDriverMissingEntryMessage =
'Driver package is missing its main entry file. Installation aborted.';

/// Why this build cannot read [manifest], or null when it can: the manifest
/// is in a newer format than [ExtensionManifest.currentManifestVersion].
static String? manifestFormatProblem(ExtensionManifest manifest) {
if (manifest.manifestVersion <= ExtensionManifest.currentManifestVersion) {
return null;
}
return 'Extension "${manifest.id}" uses manifest format '
'${manifest.manifestVersion}; this Querya Desktop reads format '
'${ExtensionManifest.currentManifestVersion} or older. '
'Update Querya Desktop to use it.';
}

/// Type-level preview heuristic (drivers default to preview).
/// Prefer [isPreviewOnlyManifest] when a full manifest is available.
static bool isPreviewOnly(ExtensionType type) =>
Expand Down
14 changes: 14 additions & 0 deletions lib/core/extensions/harness/extension_manifest_validator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ List<HarnessDiagnostic> validateExtensionManifest(
requireString('id');
requireString('name');

final format = manifest['manifestVersion'];
if (format != null) {
if (format is! int || format < 1) {
error('manifestVersion', 'must be a whole number, 1 or more, got "$format"');
} else if (format > profile.manifestVersion) {
error(
'manifestVersion',
'format $format is newer than Querya Desktop ${profile.targetVersion} '
'reads (formats up to ${profile.manifestVersion}); an older host '
'would not understand this manifest',
);
}
}

final version = manifest['version'];
if (version == null) {
warning('version', 'missing; the host falls back to 0.0.0');
Expand Down
32 changes: 27 additions & 5 deletions lib/core/extensions/harness/extension_protocol_profile.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ final class HarnessProtocolMethod {
/// `since` values come from the first release tag whose sources contain the
/// method. The protocol did not exist before 0.4.11.
final class ExtensionProtocolProfile {
ExtensionProtocolProfile._(this.targetVersion, this.methods);
ExtensionProtocolProfile._(
this.targetVersion,
this.methods,
this.manifestVersion,
);

/// Oldest host version with the extension RPC protocol.
static const minSupported = HostVersion(0, 4, 11);
Expand Down Expand Up @@ -98,9 +102,20 @@ final class ExtensionProtocolProfile {
'commands.execute', HostVersion(0, 4, 16), HarnessMethodLevel.recommended),
];

/// `manifest.json` formats and the first Querya Desktop release that reads
/// each. Format 1 is the original; a manifest without `manifestVersion` is
/// format 1. Add an entry here when a release starts reading a new format, and
/// raise `ExtensionManifest.currentManifestVersion` in the same change.
static const _manifestFormats = <(int, HostVersion)>[
(1, minSupported),
];

/// Host version this profile emulates.
final HostVersion targetVersion;

/// Newest `manifestVersion` that host reads.
final int manifestVersion;

/// Methods that host version may send.
final List<HarnessProtocolMethod> methods;

Expand All @@ -115,10 +130,17 @@ final class ExtensionProtocolProfile {
'Extensions are not supported before Querya Desktop $minSupported',
);
}
return ExtensionProtocolProfile._(version, [
for (final m in _all)
if (version >= m.since) m,
]);
return ExtensionProtocolProfile._(
version,
[
for (final m in _all)
if (version >= m.since) m,
],
[
for (final (format, since) in _manifestFormats)
if (version >= since) format,
].fold(1, (a, b) => b > a ? b : a),
);
}

bool supports(String method) => methods.any((m) => m.name == method);
Expand Down
3 changes: 3 additions & 0 deletions lib/core/extensions/local_extension_installer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ class LocalExtensionInstaller {
throw MarketplaceException(ExtensionSupport.databaseDriverPreviewNotice);
}

final formatProblem = ExtensionSupport.manifestFormatProblem(manifest);
if (formatProblem != null) throw MarketplaceException(formatProblem);

final sandboxViolations = SandboxPolicy.validate(manifest);
if (sandboxViolations.isNotEmpty) {
throw MarketplaceException(
Expand Down
7 changes: 7 additions & 0 deletions lib/core/extensions/local_extension_registry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:path/path.dart' as p;

import 'extension_command_sync.dart';
import 'extension_paths.dart';
import 'extension_support.dart';
import 'models/extension_manifest.dart';
import 'sandbox/sandbox_policy.dart';

Expand Down Expand Up @@ -59,6 +60,12 @@ class LocalExtensionRegistry {
json,
installPath: entity.path,
);
final formatProblem =
ExtensionSupport.manifestFormatProblem(manifest);
if (formatProblem != null) {
debugPrint('LocalExtensionRegistry: skipped — $formatProblem');
continue;
}
final violations = SandboxPolicy.validate(manifest);
if (violations.isNotEmpty) {
debugPrint(
Expand Down
Loading
Loading